diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 0ee0dfacd3..0000000000 --- a/.editorconfig +++ /dev/null @@ -1,13 +0,0 @@ -root = true - -[*] -end_of_line = lf -insert_final_newline = true - -[*.{js,jsx,ts,tsx,cjs,json,yml,yaml,css,less,scss}] -charset = utf-8 -indent_style = space -indent_size = 4 - -[CNAME] -insert_final_newline = false diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 94f480de94..0000000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* text=auto eol=lf \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index ac71ce785f..0000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: wavetermdev diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000000..3db62b1a99 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,31 @@ +--- +name: Bug Report +about: Create a bug report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. MacOS/Linux, x64 or arm64] + - Version [e.g. v0.5.0] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml deleted file mode 100644 index 336732048f..0000000000 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: 🐞 Bug Report -description: Create a bug report to help us improve. -title: "[Bug]: " -labels: ["bug", "triage"] -body: - - type: markdown - attributes: - value: | - ## Bug description - - type: textarea - attributes: - label: Current Behavior - description: A concise description of what you're experiencing. - validations: - required: true - - type: textarea - attributes: - label: Expected Behavior - description: A concise description of what you expected to happen. - validations: - required: true - - type: textarea - attributes: - label: Steps To Reproduce - description: Steps to reproduce the behavior. - placeholder: | - 1. In this environment... - 2. With this config... - 3. Run '...' - 4. See error... - validations: - required: true - - - type: markdown - attributes: - value: | - ## Environment details - - We require that you provide us the version of Wave you're running so we can track issues across versions. To find the Wave version, go to the app menu (this always visible on macOS, for Windows and Linux, click the `...` button) and navigate to `Wave -> About Wave Terminal`. This will bring up the About modal. Copy the client version and paste it below. - - type: input - attributes: - label: Wave Version - description: The version of Wave you are running - placeholder: v0.8.8 - validations: - required: true - - type: dropdown - attributes: - label: Platform - description: The OS platform of the computer where you are running Wave - options: - - macOS - - Linux - - Windows - validations: - required: true - - type: input - attributes: - label: OS Version/Distribution - description: The version of the operating system of the computer where you are running Wave - placeholder: Ubuntu 24.04 - validations: - required: false - - type: dropdown - attributes: - label: Architecture - description: The architecture of the computer where you are running Wave - options: - - arm64 - - x64 - validations: - required: true - - - type: markdown - attributes: - value: | - ## Extra details - - type: textarea - attributes: - label: Anything else? - description: | - Links? References? Anything that will give us more context about the issue you are encountering! - - Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. - validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index cd5fa92c29..0000000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,11 +0,0 @@ -blank_issues_enabled: true -contact_links: - - name: General Question - url: https://github.com/wavetermdev/waveterm/discussions - about: Have a question on something? Start a new discussion thread. - - name: Engage with us directly on Discord - url: https://discord.gg/XfvZ334gwU - about: Join our Discord server to get updates on new features, bug fixes, and more. - - name: Review open issues - url: https://github.com/wavetermdev/waveterm/issues - about: Please check if your issue isn't already there. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 0000000000..9eb042ae78 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,14 @@ +--- +name: Feature Request +about: Suggest a new idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml deleted file mode 100644 index e7a2e3f6ed..0000000000 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: 🚀 Feature Request / Idea -description: Suggest a new idea for this project. -title: "[Feature]: " -labels: ["enhancement", "triage"] -body: - - type: textarea - attributes: - label: Feature description - description: Describe the issue in detail and why we should add it. To help us out, please poke through our issue tracker and make sure it's not a duplicate issue. Ex. As a user, I can do [...] - validations: - required: true - - type: textarea - attributes: - label: Implementation Suggestion - description: If you have any suggestions on how to design this feature, list them here. - validations: - required: false - - type: textarea - attributes: - label: Anything else? - description: | - Links? References? Anything that will give us more context about how to deliver your feature! - - Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. - validations: - required: false diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 397ce1c97f..0000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,46 +0,0 @@ -# Wave Terminal — Copilot Instructions - -## Project Rules - -- See the overview of the project in `.kilocode/rules/overview.md` -- Read and follow all guidelines in `.kilocode/rules/rules.md` - ---- - -## Skill Guides - -This project uses a set of "skill" guides — focused how-to documents for common implementation tasks. When your task matches one of the descriptions below, **read the linked SKILL.md file before proceeding** and follow its instructions precisely. - -| Skill | File | Description | -| ------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| add-config | `.kilocode/skills/add-config/SKILL.md` | Guide for adding new configuration settings to Wave Terminal. Use when adding a new setting to the configuration system, implementing a new config key, or adding user-customizable settings. | -| add-rpc | `.kilocode/skills/add-rpc/SKILL.md` | Guide for adding new RPC calls to Wave Terminal. Use when implementing new RPC commands, adding server-client communication methods, or extending the RPC interface with new functionality. | -| add-wshcmd | `.kilocode/skills/add-wshcmd/SKILL.md` | Guide for adding new wsh commands to Wave Terminal. Use when implementing new CLI commands, adding command-line functionality, or extending the wsh command interface. | -| context-menu | `.kilocode/skills/context-menu/SKILL.md` | Guide for creating and displaying context menus in Wave Terminal. Use when implementing right-click menus, adding context menu items, creating submenus, or handling menu interactions with checkboxes and separators. | -| create-view | `.kilocode/skills/create-view/SKILL.md` | Guide for implementing a new view type in Wave Terminal. Use when creating a new view component, implementing the ViewModel interface, registering a new view type in BlockRegistry, or adding a new content type to display within blocks. | -| electron-api | `.kilocode/skills/electron-api/SKILL.md` | Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC. | -| waveenv | `.kilocode/skills/waveenv/SKILL.md` | Guide for creating WaveEnv narrowings in Wave Terminal. Use when writing a named subset type of WaveEnv for a component tree, documenting environmental dependencies, or enabling mock environments for preview/test server usage. | -| wps-events | `.kilocode/skills/wps-events/SKILL.md` | Guide for working with Wave Terminal's WPS (Wave PubSub) event system. Use when implementing new event types, publishing events, subscribing to events, or adding asynchronous communication between components. | - -> **How skills work:** Each skill is a self-contained guide covering the exact files to edit, patterns to follow, and steps to take for a specific type of task in this codebase. If your task matches a skill's description, open that SKILL.md and treat it as your primary reference for the implementation. - ---- - -## Preview Server - -To run the standalone component preview (no Electron, no backend required): - -``` -task preview -``` - -This runs `cd frontend/preview && npx vite` and serves at **http://localhost:7007** (port configured in `frontend/preview/vite.config.ts`). - -To build a static preview: `task build:preview` - -**Do NOT use any of the following to start the preview — they all launch the full Electron app or serve the wrong content:** - -- `npm run dev` — runs `electron-vite dev`, launches Electron -- `npm run start` — also launches Electron -- `npx vite` from the repo root — uses the Electron-Vite config, not the preview app -- Serving the `dist/` directory — the preview app is never built there; it has its own build output diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 85c6597557..0000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,159 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "weekly" - day: "friday" - time: "09:00" - timezone: "America/Los_Angeles" - groups: - dev-dependencies-patch: - dependency-type: "development" - exclude-patterns: - - "*storybook*" - - "*electron*" - - "jotai" - - "react" - - "@types/react" - - "*react-dom" - - "*docusaurus*" - update-types: - - "patch" - dev-dependencies-minor: - dependency-type: "development" - exclude-patterns: - - "*storybook*" - - "*electron*" - - "jotai" - - "react" - - "@types/react" - - "*react-dom" - - "*docusaurus*" - update-types: - - "minor" - - prod-dependencies-patch: - dependency-type: "production" - exclude-patterns: - - "*storybook*" - - "*electron*" - - "jotai" - - "react" - - "@types/react" - - "*react-dom" - - "*docusaurus*" - update-types: - - "patch" - prod-dependencies-minor: - dependency-type: "production" - exclude-patterns: - - "*storybook*" - - "*electron*" - - "jotai" - - "react" - - "@types/react" - - "*react-dom" - - "*docusaurus*" - update-types: - - "minor" - - storybook-patch: - patterns: - - "*storybook*" - update-types: - - "patch" - storybook-minor: - patterns: - - "*storybook*" - update-types: - - "minor" - storybook-major: - patterns: - - "*storybook*" - update-types: - - "major" - - electron-patch: - patterns: - - "*electron*" - update-types: - - "patch" - electron-minor: - patterns: - - "*electron*" - update-types: - - "minor" - electron-major: - patterns: - - "*electron*" - update-types: - - "major" - - docusaurus-patch: - patterns: - - "*docusaurus*" - update-types: - - "patch" - docusaurus-minor: - patterns: - - "*docusaurus*" - update-types: - - "minor" - docusaurus-major: - patterns: - - "*docusaurus*" - update-types: - - "major" - - react-patch: - patterns: - - "react" - - "@types/react" - - "*react-dom" - update-types: - - "patch" - react-minor: - patterns: - - "react" - - "@types/react" - - "*react-dom" - update-types: - - "minor" - react-major: - patterns: - - "react" - - "@types/react" - - "*react-dom" - update-types: - - "major" - - jotai-patch: - patterns: - - "jotai" - update-types: - - "patch" - jotai-minor: - patterns: - - "jotai" - update-types: - - "minor" - jotai-major: - patterns: - - "jotai" - update-types: - - "major" - - package-ecosystem: "gomod" - directory: "/" - schedule: - interval: "weekly" - day: "friday" - time: "09:00" - timezone: "America/Los_Angeles" - - package-ecosystem: "github-actions" - directory: "/.github/workflows" - schedule: - interval: "weekly" - day: "friday" - time: "09:00" - timezone: "America/Los_Angeles" diff --git a/.github/workflows/build-helper.yml b/.github/workflows/build-helper.yml index eadb18ce77..4d8518e58b 100644 --- a/.github/workflows/build-helper.yml +++ b/.github/workflows/build-helper.yml @@ -1,209 +1,78 @@ -# Build Helper workflow - Builds, signs, and packages binaries for each supported platform, then uploads to a staging bucket in S3 for wider distribution. -# For more information on the macOS signing and notarization, see https://www.electron.build/code-signing and https://www.electron.build/configuration/mac -# For more information on the Windows Code Signing, see https://docs.digicert.com/en/digicert-keylocker/ci-cd-integrations/plugins/github-custom-action-for-keypair-signing.html and https://docs.digicert.com/en/digicert-keylocker/signing-tools/sign-authenticode-with-electron-builder-using-ksp-integration.html - -name: Build Helper -run-name: Build ${{ github.ref_name }}${{ github.event_name == 'workflow_dispatch' && ' - Manual' || '' }} -on: - push: - tags: - - "v[0-9]+.[0-9]+.[0-9]+*" - workflow_dispatch: +name: "Build Helper" +on: workflow_dispatch env: - GO_VERSION: "1.25.6" - NODE_VERSION: 22 - NODE_OPTIONS: --max-old-space-size=4096 + GO_VERSION: "1.22.0" + NODE_VERSION: "21.5.0" jobs: - build-app: - outputs: - version: ${{ steps.set-version.outputs.WAVETERM_VERSION }} + runbuild: strategy: matrix: include: - platform: "darwin" - runner: "macos-latest" + arch: "universal" + runner: "macos-latest-xlarge" + scripthaus: "build-package" - platform: "linux" + arch: "amd64" runner: "ubuntu-latest" + scripthaus: "build-package-linux" - platform: "linux" - runner: ubuntu-24.04-arm - - platform: "windows" - runner: "windows-latest" - # - platform: "windows" - # runner: "windows-11-arm64-16core" + arch: "arm64" + runner: ubuntu-24.04-arm64-16core + scripthaus: "build-package-linux" runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: scripthaus-dev/scripthaus + path: scripthaus - name: Install Linux Build Dependencies (Linux only) if: matrix.platform == 'linux' run: | sudo apt-get update - sudo apt-get install --no-install-recommends -y libarchive-tools libopenjp2-tools rpm squashfs-tools - sudo snap install snapcraft --classic - sudo snap install lxd - sudo lxd init --auto - sudo snap refresh - - name: Install Zig (not Mac) - if: matrix.platform != 'darwin' - uses: mlugg/setup-zig@v2 - - # The pre-installed version of the AWS CLI has a segfault problem so we'll install it via Homebrew instead. - - name: Upgrade AWS CLI (Mac only) - if: matrix.platform == 'darwin' - run: brew install awscli - - # The version of FPM that comes bundled with electron-builder doesn't include a Linux ARM target. Installing Gems onto the runner is super quick so we'll just do this for all targets. - - name: Install FPM (not Windows) - if: matrix.platform != 'windows' + sudo apt-get install --no-install-recommends -y libarchive-tools libopenjp2-tools rpm + - name: Install FPM # The version of FPM that comes bundled with electron-builder doesn't include a Linux ARM target. Installing Gems onto the runner is super quick so we'll just do this for all targets. run: sudo gem install fpm - - name: Install FPM (Windows only) - if: matrix.platform == 'windows' - run: gem install fpm - - # General build dependencies - - uses: actions/setup-go@v6 + - uses: actions/setup-go@v5 with: go-version: ${{env.GO_VERSION}} cache-dependency-path: | - go.sum - - uses: actions/setup-node@v6 + wavesrv/go.sum + waveshell/go.sum + scripthaus/go.sum + - name: Install Scripthaus + run: | + go work use ./scripthaus; + cd scripthaus; + go get ./...; + CGO_ENABLED=1 go build -o scripthaus cmd/main.go + echo $PWD >> $GITHUB_PATH + - uses: actions/setup-node@v4 with: node-version: ${{env.NODE_VERSION}} - cache: npm - cache-dependency-path: package-lock.json - - name: Force git deps to HTTPS + - name: Install yarn run: | - git config --global url.https://github.com/.insteadof ssh://git@github.com/ - git config --global url.https://github.com/.insteadof git@github.com: - - uses: nick-fields/retry@v4 - name: npm ci - with: - command: npm ci --no-audit --no-fund - retry_on: error - max_attempts: 3 - timeout_minutes: 5 - env: - GIT_ASKPASS: "echo" - GIT_TERMINAL_PROMPT: "0" - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: "Set Version" + corepack enable + yarn install + - name: Set Version id: set-version - run: echo "WAVETERM_VERSION=$(task version)" >> "$GITHUB_OUTPUT" - shell: bash - - # Windows Code Signing Setup - - name: Set up certificate (Windows only) - if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch' run: | - echo "${{ secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12 - shell: bash - - name: Set signing variables (Windows only) - if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch' - id: variables - run: | - echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" - echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" - echo "SM_CODE_SIGNING_CERT_SHA1_HASH=${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}" >> "$GITHUB_ENV" - echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV" - echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_OUTPUT" - echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV" - echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH - echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH - echo "C:\Program Files\DigiCert\DigiCert Keylocker Tools" >> $GITHUB_PATH - shell: bash - - name: Setup Keylocker KSP (Windows only) - if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch' - run: | - curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o Keylockertools-windows-x64.msi - msiexec /i Keylockertools-windows-x64.msi /quiet /qn - C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user - smctl windows certsync - shell: cmd - - # Build and upload packages - - name: Build (Linux) - if: matrix.platform == 'linux' - run: task package - env: - USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. - SNAPCRAFT_BUILD_ENVIRONMENT: host - # Retry Darwin build in case of notarization failures - - uses: nick-fields/retry@v4 - name: Build (Darwin) - if: matrix.platform == 'darwin' - with: - command: task package - timeout_minutes: 120 - retry_on: error - max_attempts: 3 + VERSION=$(node -e 'console.log(require("./version.js"))') + echo "WAVETERM_VERSION=${VERSION}" >> "$GITHUB_OUTPUT" + - name: Build ${{ matrix.platform }}/${{ matrix.arch }} + run: scripthaus run ${{ matrix.scripthaus }} env: + GOARCH: ${{ matrix.arch }} USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. - CSC_LINK: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_2}} - CSC_KEY_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_PWD_2 }} - APPLE_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_APPLE_ID_2 }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_PWD_2 }} - APPLE_TEAM_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_TEAM_ID_2 }} - STATIC_DOCSITE_PATH: ${{env.STATIC_DOCSITE_PATH}} - - name: Build (Windows) - if: matrix.platform == 'windows' - run: task package - env: - USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. - CSC_LINK: ${{ steps.variables.outputs.SM_CLIENT_CERT_FILE }} - CSC_KEY_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} - STATIC_DOCSITE_PATH: ${{env.STATIC_DOCSITE_PATH}} - shell: powershell # electron-builder's Windows code signing package has some compatibility issues with pwsh, so we need to use Windows Powershell - - # Upload artifacts to the S3 staging and to the workflow output for the draft release job + CSC_LINK: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE}} + CSC_KEY_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_PWD }} + APPLE_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_PWD }} + APPLE_TEAM_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} - name: Upload to S3 staging - if: github.event_name != 'workflow_dispatch' - run: task artifacts:upload + run: aws s3 cp make/ s3://waveterm-github-artifacts/staging-legacy/${{ steps.set-version.outputs.WAVETERM_VERSION }}/ --recursive --exclude "*/*" --exclude "builder-*.yml" env: - AWS_ACCESS_KEY_ID: "${{ secrets.ARTIFACTS_KEY_ID }}" - AWS_SECRET_ACCESS_KEY: "${{ secrets.ARTIFACTS_KEY_SECRET }}" + AWS_ACCESS_KEY_ID: "${{ secrets.S3_USERID }}" + AWS_SECRET_ACCESS_KEY: "${{ secrets.S3_SECRETKEY }}" AWS_DEFAULT_REGION: us-west-2 - - name: Upload artifacts - uses: actions/upload-artifact@v5 - with: - name: ${{ matrix.runner }} - path: make - - name: Upload Snapcraft logs on failure - if: failure() - uses: actions/upload-artifact@v5 - with: - name: ${{ matrix.runner }}-log - path: /home/runner/.local/state/snapcraft/log - create-release: - runs-on: ubuntu-latest - needs: build-app - permissions: - contents: write - if: ${{ github.event_name != 'workflow_dispatch' }} - steps: - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - path: make - merge-multiple: true - - name: Create draft release - uses: softprops/action-gh-release@v2 - with: - prerelease: ${{ contains(github.ref_name, '-beta') }} - name: Wave Terminal ${{ github.ref_name }} Release - generate_release_notes: true - draft: true - files: | - make/*.zip - make/*.dmg - make/*.exe - make/*.msi - make/*.rpm - make/*.deb - make/*.pacman - make/*.snap - make/*.flatpak - make/*.AppImage diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml deleted file mode 100644 index fa7f31df6e..0000000000 --- a/.github/workflows/bump-version.yml +++ /dev/null @@ -1,89 +0,0 @@ -# Workflow to manage bumping the package version and pushing it to the target branch with a new tag. -# This workflow uses a GitHub App to bypass branch protection and uses the GitHub API directly to ensure commits and tags are signed. -# For more information, see this doc: https://github.com/Nautilus-Cyberneering/pygithub/blob/main/docs/how_to_sign_automatic_commits_in_github_actions.md - -name: Bump Version -run-name: "branch: ${{ github.ref_name }}; semver-bump: ${{ inputs.bump }}; prerelease: ${{ inputs.is-prerelease }}" -on: - workflow_dispatch: - inputs: - bump: - description: SemVer Bump - required: true - type: choice - default: none - options: - - none - - patch - - minor - - major - is-prerelease: - description: Is Prerelease - required: true - type: boolean - default: true -env: - NODE_VERSION: 22 -jobs: - bump-version: - runs-on: ubuntu-latest - steps: - - name: Get App Token - uses: actions/create-github-app-token@v3 - id: app-token - with: - app-id: ${{ vars.WAVE_BUILDER_APPID }} - private-key: ${{ secrets.WAVE_BUILDER_KEY }} - - uses: actions/checkout@v6 - with: - token: ${{ steps.app-token.outputs.token }} - - # General build dependencies - - uses: actions/setup-node@v6 - with: - node-version: ${{env.NODE_VERSION}} - cache: npm - cache-dependency-path: package-lock.json - - uses: nick-fields/retry@v4 - name: npm ci - with: - command: npm ci --no-audit --no-fund - retry_on: error - max_attempts: 3 - timeout_minutes: 5 - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: "Bump Version: ${{ inputs.bump }}" - id: bump-version - run: echo "WAVETERM_VERSION=$( task version -- ${{ inputs.bump }} ${{inputs.is-prerelease}} )" >> "$GITHUB_OUTPUT" - shell: bash - - - name: "Push version bump: ${{ steps.bump-version.outputs.WAVETERM_VERSION }}" - if: github.ref_protected - run: | - # Create a new commit for the package version bump in package.json - export VERSION=${{ steps.bump-version.outputs.WAVETERM_VERSION }} - export MESSAGE="chore: bump package version to $VERSION" - export FILE=package.json - export BRANCH=${{github.ref_name}} - export SHA=$( git rev-parse $BRANCH:$FILE ) - export CONTENT=$( base64 -i $FILE ) - gh api --method PUT /repos/:owner/:repo/contents/$FILE \ - --field branch="$BRANCH" \ - --field message="$MESSAGE" \ - --field content="$CONTENT" \ - --field sha="$SHA" - - # Fetch the new commit and create a tag referencing it - git fetch - export TAG_SHA=$( git rev-parse origin/$BRANCH ) - gh api --method POST /repos/:owner/:repo/git/refs \ - --field ref="refs/tags/v$VERSION" \ - --field sha="$TAG_SHA" - shell: bash - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 30a8979b9b..07ae2985b4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -14,28 +14,11 @@ name: "CodeQL" on: push: branches: ["main"] - paths: - - "**/*.go" - - "**/*.ts" - - "**/*.tsx" pull_request: branches: ["main"] - paths: - - "**/*.go" - - "**/*.ts" - - "**/*.tsx" - types: - - opened - - synchronize - - reopened - - ready_for_review schedule: - cron: "36 5 * * 5" -env: - NODE_VERSION: 22 - GO_VERSION: "1.25.6" - jobs: analyze: name: Analyze @@ -44,7 +27,6 @@ jobs: # - https://gh.io/supported-runners-and-hardware-resources # - https://gh.io/using-larger-runners # Consider using larger runners for possible analysis time improvements. - if: github.event.pull_request.draft == false runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} permissions: @@ -63,40 +45,37 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 - - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} + uses: actions/checkout@v4 - - uses: actions/setup-node@v6 - with: - node-version: ${{env.NODE_VERSION}} - cache: npm - cache-dependency-path: package-lock.json - - uses: nick-fields/retry@v4 - name: npm ci + - name: Checkout Scripthaus (Go only) + if: matrix.language == 'go' + uses: actions/checkout@v4 with: - command: npm ci --no-audit --no-fund - retry_on: error - max_attempts: 3 - timeout_minutes: 5 + repository: scripthaus-dev/scripthaus + path: scripthaus - - name: Setup Go - uses: actions/setup-go@v6 + - name: Setup Go (Go only) + uses: actions/setup-go@v5 + if: matrix.language == 'go' with: - go-version: ${{env.GO_VERSION}} + go-version: stable cache-dependency-path: | - go.sum - # We use Zig instead of glibc for cgo compilation as it is more-easily statically linked - - name: Setup Zig - run: sudo snap install zig --classic --beta + wavesrv/go.sum + waveshell/go.sum + scripthaus/go.sum + + - name: Install Scripthaus (Go only) + if: matrix.language == 'go' + run: | + go work use ./scripthaus; + cd scripthaus; + go get ./...; + CGO_ENABLED=1 go build -o scripthaus cmd/main.go + echo $PWD >> $GITHUB_PATH # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -106,20 +85,15 @@ jobs: # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality - - name: Generate bindings - run: task generate - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild (not Go) if: matrix.language != 'go' - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@v3 - name: Build (Go only) if: matrix.language == 'go' - run: | - task build:server - task build:wsh + run: scripthaus run build-backend # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -132,6 +106,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml deleted file mode 100644 index 20f05975b2..0000000000 --- a/.github/workflows/copilot-setup-steps.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Copilot Setup Steps - -on: - workflow_dispatch: - push: - paths: [.github/workflows/copilot-setup-steps.yml] - pull_request: - paths: [.github/workflows/copilot-setup-steps.yml] - -# Note: global env vars are NOT used here — they are not reliable in all -# GitHub Actions contexts (e.g. Copilot setup steps). Values are inlined -# directly into each step that needs them. - -jobs: - copilot-setup-steps: - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - uses: actions/checkout@v6 - - # Go + Node versions match your helper - - uses: actions/setup-go@v6 - with: - go-version: "1.25.6" - cache-dependency-path: go.sum - - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: npm - cache-dependency-path: package-lock.json - - # Zig is used by your Linux CGO builds (kept available, but we won't build here) - - uses: mlugg/setup-zig@v2 - - # Task CLI for your Taskfile - - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - # Git HTTPS so deps resolve non-interactively - - name: Force git deps to HTTPS - run: | - git config --global url.https://github.com/.insteadof ssh://git@github.com/ - git config --global url.https://github.com/.insteadof git@github.com: - - # Warm caches only (no builds) - - uses: nick-fields/retry@v4 - name: npm ci - with: - command: npm ci --no-audit --no-fund - retry_on: error - max_attempts: 3 - timeout_minutes: 5 - env: - GIT_ASKPASS: "echo" - GIT_TERMINAL_PROMPT: "0" - - - name: Pre-fetch Go modules - env: - GOTOOLCHAIN: auto - run: | - go version - go mod download diff --git a/.github/workflows/deploy-docsite.yml b/.github/workflows/deploy-docsite.yml deleted file mode 100644 index 092b024cb5..0000000000 --- a/.github/workflows/deploy-docsite.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Docsite CI/CD - -run-name: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'Build and Deploy' || 'Test Build' }} Docsite - -env: - NODE_VERSION: 22 - -on: - push: - branches: - - main - workflow_dispatch: - # Also run any time a PR is opened targeting the docs - pull_request: - branches: - - main - types: - - opened - - synchronize - - reopened - - ready_for_review - paths: - - "docs/**" - - ".github/workflows/deploy-docsite.yml" - - "Taskfile.yml" - -jobs: - build: - name: Build Docsite - runs-on: ubuntu-latest - if: github.event.pull_request.draft == false - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - uses: actions/setup-node@v6 - with: - node-version: ${{env.NODE_VERSION}} - cache: npm - cache-dependency-path: package-lock.json - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - uses: nick-fields/retry@v4 - name: npm ci - with: - command: npm ci --no-audit --no-fund - retry_on: error - max_attempts: 3 - timeout_minutes: 5 - - name: Build docsite - run: task docsite:build:public - - name: Upload Build Artifact - # Only upload the build artifact when pushed to the main branch - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v4 - with: - path: docs/build - deploy: - name: Deploy to GitHub Pages - # Only deploy when pushed to the main branch - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: build - # Grant GITHUB_TOKEN the permissions required to make a Pages deployment - permissions: - pages: write # to deploy to Pages - id-token: write # to verify the deployment originates from an appropriate source - - # Deploy to the github-pages environment - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - runs-on: ubuntu-latest - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5 diff --git a/.github/workflows/merge-gatekeeper.yml b/.github/workflows/merge-gatekeeper.yml deleted file mode 100644 index d3defadca8..0000000000 --- a/.github/workflows/merge-gatekeeper.yml +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: Merge Gatekeeper - -on: - pull_request_target: - branches: - - main - - master - types: - - opened - - synchronize - - reopened - - ready_for_review - -jobs: - merge-gatekeeper: - runs-on: ubuntu-latest - if: github.event.pull_request.draft == false - # Restrict permissions of the GITHUB_TOKEN. - # Docs: https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs - permissions: - checks: read - statuses: read - steps: - - name: Run Merge Gatekeeper - # NOTE: v1 is updated to reflect the latest v1.x.y. Please use any tag/branch that suits your needs: - # https://github.com/upsidr/merge-gatekeeper/tags - # https://github.com/upsidr/merge-gatekeeper/branches - uses: upsidr/merge-gatekeeper@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - ignored: Build for TestDriver.ai, TestDriver.ai Run, Analyze (go), Analyze (javascript-typescript), License Compliance, CodeRabbit diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml deleted file mode 100644 index 268e37724d..0000000000 --- a/.github/workflows/publish-release.yml +++ /dev/null @@ -1,96 +0,0 @@ -# Workflow to copy artifacts from the staging bucket to the release bucket when a new GitHub Release is published. - -name: Publish Release -run-name: Publish ${{ github.ref_name }} -on: - release: - types: [published] -jobs: - publish-s3: - name: Publish to Releases - if: ${{ startsWith(github.ref, 'refs/tags/') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Publish from staging - run: "task artifacts:publish:${{ github.ref_name }}" - env: - AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}" - AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}" - AWS_DEFAULT_REGION: us-west-2 - shell: bash - publish-snap-amd64: - name: Publish AMD64 Snap - if: ${{ startsWith(github.ref, 'refs/tags/') }} - needs: [publish-s3] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install Snapcraft - run: sudo snap install snapcraft --classic - shell: bash - - name: Download Snap from Release - uses: robinraju/release-downloader@v1 - with: - tag: ${{github.ref_name}} - fileName: "*amd64.snap" - - name: Publish to Snapcraft - run: "task artifacts:snap:publish:${{ github.ref_name }}" - env: - SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}" - shell: bash - publish-snap-arm64: - name: Publish ARM64 Snap - if: ${{ startsWith(github.ref, 'refs/tags/') }} - needs: [publish-s3] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install Snapcraft - run: sudo snap install snapcraft --classic - shell: bash - - name: Download Snap from Release - uses: robinraju/release-downloader@v1 - with: - tag: ${{github.ref_name}} - fileName: "*arm64.snap" - - name: Publish to Snapcraft - run: "task artifacts:snap:publish:${{ github.ref_name }}" - env: - SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}" - shell: bash - bump-winget: - name: Submit WinGet PR - if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, 'beta') }} - needs: [publish-s3] - runs-on: windows-latest - steps: - - uses: actions/checkout@v6 - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install wingetcreate - run: winget install -e --silent --accept-package-agreements --accept-source-agreements wingetcreate - shell: pwsh - - name: Submit WinGet version bump - run: "task artifacts:winget:publish:${{ github.ref_name }}" - env: - GITHUB_TOKEN: ${{ secrets.WINGET_BUMP_PAT }} - shell: pwsh diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml new file mode 100644 index 0000000000..9214b99d72 --- /dev/null +++ b/.github/workflows/regression.yml @@ -0,0 +1,51 @@ +name: TestDriver.ai Regression Testing - Waveterm +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: 0 21 * * * + workflow_dispatch: null + +permissions: + contents: read # To allow the action to read repository contents + pull-requests: write # To allow the action to create/update pull request comments + + +jobs: + test: + name: "TestDriver" + runs-on: ubuntu-latest + steps: + - uses: dashcamio/testdriver@main + id: testdriver + with: + version: v3.9.0 + key: ${{secrets.DASHCAM_API}} + os: mac + prerun: | + cd ~/actions-runner/_work/testdriver/testdriver/ + brew install go + brew tap scripthaus-dev/scripthaus + brew install corepack + brew install scripthaus + corepack enable + yarn install + scripthaus run build-backend + echo "Yarn" + yarn + echo "Rebuild" + scripthaus run electron-rebuild + echo "Webpack" + scripthaus run webpack-build + echo "Starting Electron" + scripthaus run electron 1>/dev/null 2>&1 & + echo "Electron Done" + cd /Users/ec2-user/Downloads/td/ + npm rebuild + exit + prompt: | + 1. /run /Users/ec2-user/actions-runner/_work/testdriver/testdriver/.testdriver/wave1.yml diff --git a/.github/workflows/testdriver-build.yml b/.github/workflows/testdriver-build.yml deleted file mode 100644 index da190073e6..0000000000 --- a/.github/workflows/testdriver-build.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: TestDriver.ai Build - -on: - push: - branches: - - main - tags: - - "v[0-9]+.[0-9]+.[0-9]+*" - pull_request: - # branches: - # - main - # paths-ignore: - # - "docs/**" - # - ".storybook/**" - # - ".vscode/**" - # - ".editorconfig" - # - ".gitignore" - # - ".prettierrc" - # - ".eslintrc.js" - # - "**/*.md" - types: - - opened - - synchronize - - reopened - - ready_for_review - schedule: - - cron: 0 21 * * * - workflow_dispatch: null - -env: - GO_VERSION: "1.25.6" - NODE_VERSION: 22 - -permissions: - contents: read # To allow the action to read repository contents - pull-requests: write # To allow the action to create/update pull request comments - -jobs: - build_and_upload: - name: Build for TestDriver.ai - runs-on: windows-latest - if: github.event.pull_request.draft == false - steps: - - uses: actions/checkout@v6 - - # General build dependencies - - uses: actions/setup-go@v6 - with: - go-version: ${{env.GO_VERSION}} - - uses: actions/setup-node@v6 - with: - node-version: ${{env.NODE_VERSION}} - cache: npm - cache-dependency-path: package-lock.json - - uses: nick-fields/retry@v4 - name: npm ci - with: - command: npm ci --no-audit --no-fund - retry_on: error - max_attempts: 3 - timeout_minutes: 5 - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install Zig - uses: mlugg/setup-zig@v2 - - - name: Build - run: task package - env: - USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. - CSC_IDENTITY_AUTO_DISCOVERY: false # disable codesign - shell: powershell # electron-builder's Windows code signing package has some compatibility issues with pwsh, so we need to use Windows Powershell - - # Upload .exe as an artifact - - name: Upload .exe artifact - id: upload - uses: actions/upload-artifact@v5 - with: - name: windows-exe - path: make/*.exe diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml deleted file mode 100644 index 9d51ec7659..0000000000 --- a/.github/workflows/testdriver.yml +++ /dev/null @@ -1,141 +0,0 @@ -name: TestDriver.ai Run - -on: - workflow_run: - workflows: ["TestDriver.ai Build"] - types: - - completed - -env: - GO_VERSION: "1.25.6" - NODE_VERSION: 22 - -permissions: - contents: read - statuses: write - -jobs: - context: - runs-on: ubuntu-22.04 - steps: - - name: Dump GitHub context - env: - GITHUB_CONTEXT: ${{ toJson(github) }} - run: echo "$GITHUB_CONTEXT" - - name: Dump job context - env: - JOB_CONTEXT: ${{ toJson(job) }} - run: echo "$JOB_CONTEXT" - - name: Dump steps context - env: - STEPS_CONTEXT: ${{ toJson(steps) }} - run: echo "$STEPS_CONTEXT" - - name: Dump runner context - env: - RUNNER_CONTEXT: ${{ toJson(runner) }} - run: echo "$RUNNER_CONTEXT" - - name: Dump strategy context - env: - STRATEGY_CONTEXT: ${{ toJson(strategy) }} - run: echo "$STRATEGY_CONTEXT" - - name: Dump matrix context - env: - MATRIX_CONTEXT: ${{ toJson(matrix) }} - run: echo "$MATRIX_CONTEXT" - run_testdriver: - name: Run TestDriver.ai - runs-on: windows-latest - if: github.event.workflow_run.conclusion == 'success' - steps: - - uses: testdriverai/action@main - id: testdriver - env: - FORCE_COLOR: "3" - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - key: ${{ secrets.DASHCAM_API }} - prerun: | - $headers = @{ - Authorization = "token ${{ secrets.GITHUB_TOKEN }}" - } - - $downloadFolder = "./download" - $artifactFileName = "waveterm.exe" - $artifactFilePath = "$downloadFolder/$artifactFileName" - - Write-Host "Starting the artifact download process..." - - # Create the download directory if it doesn't exist - if (-not (Test-Path -Path $downloadFolder)) { - Write-Host "Creating download folder..." - mkdir $downloadFolder - } else { - Write-Host "Download folder already exists." - } - - # Fetch the artifact upload URL - Write-Host "Fetching the artifact upload URL..." - $artifactUrl = (Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}/artifacts" -Headers $headers).artifacts[0].archive_download_url - - if ($artifactUrl) { - Write-Host "Artifact URL successfully fetched: $artifactUrl" - } else { - Write-Error "Failed to fetch the artifact URL." - exit 1 - } - - # Download the artifact (zipped file) - Write-Host "Starting artifact download..." - $artifactZipPath = "$env:TEMP\artifact.zip" - try { - Invoke-WebRequest -Uri $artifactUrl ` - -Headers $headers ` - -OutFile $artifactZipPath ` - -MaximumRedirection 5 - - Write-Host "Artifact downloaded successfully to $artifactZipPath" - } catch { - Write-Error "Error downloading artifact: $_" - exit 1 - } - - # Unzip the artifact - $artifactUnzipPath = "$env:TEMP\artifact" - Write-Host "Unzipping the artifact to $artifactUnzipPath..." - try { - Expand-Archive -Path $artifactZipPath -DestinationPath $artifactUnzipPath -Force - Write-Host "Artifact unzipped successfully to $artifactUnzipPath" - } catch { - Write-Error "Failed to unzip the artifact: $_" - exit 1 - } - - # Find the installer or app executable - $artifactInstallerPath = Get-ChildItem -Path $artifactUnzipPath -Filter *.exe -Recurse | Select-Object -First 1 - - if ($artifactInstallerPath) { - Write-Host "Executable file found: $($artifactInstallerPath.FullName)" - } else { - Write-Error "Executable file not found. Exiting." - exit 1 - } - - # Run the installer and log the result - Write-Host "Running the installer: $($artifactInstallerPath.FullName)..." - try { - Start-Process -FilePath $artifactInstallerPath.FullName -Wait - Write-Host "Installer ran successfully." - } catch { - Write-Error "Failed to run the installer: $_" - exit 1 - } - - # Optional: If the app executable is different from the installer, find and launch it - $wavePath = Join-Path $env:USERPROFILE "AppData\Local\Programs\waveterm\Wave.exe" - - Write-Host "Launching the application: $($wavePath)" - Start-Process -FilePath $wavePath - Write-Host "Application launched." - - prompt: | - 1. /run testdriver/onboarding.yml diff --git a/.gitignore b/.gitignore index 2111b1182d..6f4a3f63e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,27 @@ -.task -frontend/dist dist/ dist-dev/ -frontend/node_modules node_modules/ -frontend/bindings -bindings/ +*~ *.log -*.tsbuildinfo +*.out +out/ +.DS_Store bin/ +waveshell/bin/ +wavesrv/bin/ +dev-bin +local-server-bin +*.pw +build/ *.dmg -*.exe -.DS_Store -*~ -out/ +webshare/dist/ +webshare/dist-dev/ +temp.sql +.idea/ +test/ +.vscode/ make/ -artifacts/ -mikework/ -aiplans/ -manifests/ -.env -out +waveterm-builds.zip # Yarn Modern .pnp.* @@ -29,17 +30,4 @@ out !.yarn/plugins !.yarn/releases !.yarn/sdks -!.yarn/versions - - -*storybook.log -storybook-static/ - -test-results.xml - -docsite/ - -.kilo-format-temp-* -.superpowers -docs/superpowers -.claude +!.yarn/versions \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index e8bd47262b..0000000000 --- a/.golangci.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2 - -linters: - disable: - - unused - -issues: - exclude-rules: - - linters: - - unused - text: "unused parameter" diff --git a/.kilocode/rules/overview.md b/.kilocode/rules/overview.md deleted file mode 100644 index 944a4021dd..0000000000 --- a/.kilocode/rules/overview.md +++ /dev/null @@ -1,154 +0,0 @@ -# Wave Terminal - High Level Architecture Overview - -## Project Description - -Wave Terminal is an open-source AI-native terminal built for seamless workflows. It's an Electron application that serves as a command line terminal host (it hosts CLI applications rather than running inside a CLI). The application combines a React frontend with a Go backend server to provide a modern terminal experience with advanced features. - -## Top-Level Directory Structure - -``` -waveterm/ -├── emain/ # Electron main process code -├── frontend/ # React application (renderer process) -├── cmd/ # Go command-line applications -├── pkg/ # Go packages/modules -├── db/ # Database migrations -├── docs/ # Documentation (Docusaurus) -├── build/ # Build configuration and assets -├── assets/ # Application assets (icons, images) -├── public/ # Static public assets -├── tests/ # Test files -├── .github/ # GitHub workflows and configuration -└── Configuration files (package.json, tsconfig.json, etc.) -``` - -## Architecture Components - -### 1. Electron Main Process (`emain/`) - -The Electron main process handles the native desktop application layer: - -**Key Files:** - -- [`emain.ts`](emain/emain.ts) - Main entry point, application lifecycle management -- [`emain-window.ts`](emain/emain-window.ts) - Window management (`WaveBrowserWindow` class) -- [`emain-tabview.ts`](emain/emain-tabview.ts) - Tab view management (`WaveTabView` class) -- [`emain-wavesrv.ts`](emain/emain-wavesrv.ts) - Go backend server integration -- [`emain-wsh.ts`](emain/emain-wsh.ts) - WSH (Wave Shell) client integration -- [`emain-ipc.ts`](emain/emain-ipc.ts) - IPC handlers for frontend ↔ main process communication -- [`emain-menu.ts`](emain/emain-menu.ts) - Application menu system -- [`updater.ts`](emain/updater.ts) - Auto-update functionality -- [`preload.ts`](emain/preload.ts) - Preload script for renderer security -- [`preload-webview.ts`](emain/preload-webview.ts) - Webview preload script - -### 2. Frontend React Application (`frontend/`) - -The React application runs in the Electron renderer process: - -**Structure:** - -``` -frontend/ -├── app/ # Main application code -│ ├── app.tsx # Root App component -│ ├── aipanel/ # AI panel UI -│ ├── block/ # Block-based UI components -│ ├── element/ # Reusable UI elements -│ ├── hook/ # Custom React hooks -│ ├── modals/ # Modal components -│ ├── store/ # State management (Jotai) -│ ├── tab/ # Tab components -│ ├── view/ # Different view types -│ │ ├── codeeditor/ # Code editor (Monaco) -│ │ ├── preview/ # File preview -│ │ ├── sysinfo/ # System info view -│ │ ├── term/ # Terminal view -│ │ ├── tsunami/ # Tsunami builder view -│ │ ├── vdom/ # Virtual DOM view -│ │ ├── waveai/ # AI chat integration -│ │ ├── waveconfig/ # Config editor view -│ │ └── webview/ # Web view -│ └── workspace/ # Workspace management -├── builder/ # Builder app entry -├── layout/ # Layout system -├── preview/ # Standalone preview renderer -├── types/ # TypeScript type definitions -└── util/ # Utility functions -``` - -**Key Technologies:** - -- Electron (desktop application shell) -- React 19 with TypeScript -- Jotai for state management -- Monaco Editor for code editing -- XTerm.js for terminal emulation -- Tailwind CSS v4 for styling -- SCSS for additional styling (deprecated, new components should use Tailwind) -- Vite / electron-vite for bundling -- Task (Taskfile.yml) for build and code generation commands - -### 3. Go Backend Server (`cmd/server/`) - -The Go backend server handles all heavy lifting operations: - -**Entry Point:** [`main-server.go`](cmd/server/main-server.go) - -### 4. Go Packages (`pkg/`) - -The Go codebase is organized into modular packages: - -**Key Packages:** - -- `wstore/` - Database and storage layer -- `wconfig/` - Configuration management -- `wcore/` - Core business logic -- `wshrpc/` - RPC communication system -- `wshutil/` - WSH (Wave Shell) utilities -- `blockcontroller/` - Block execution management -- `remote/` - Remote connection handling -- `filestore/` - File storage system -- `web/` - Web server and WebSocket handling -- `telemetry/` - Usage analytics and telemetry -- `waveobj/` - Core data objects -- `service/` - Service layer -- `wps/` - Wave PubSub event system -- `waveai/` - AI functionality -- `shellexec/` - Shell execution -- `util/` - Common utilities - -### 5. Command Line Tools (`cmd/`) - -Key Go command-line utilities: - -- `wsh/` - Wave Shell command-line tool -- `server/` - Main backend server -- `generatego/` - Code generation -- `generateschema/` - Schema generation -- `generatets/` - TypeScript generation - -## Communication Architecture - -The core communication system is built around the **WSH RPC (Wave Shell RPC)** system, which provides a unified interface for all inter-process communication: frontend ↔ Go backend, Electron main process ↔ backend, and backend ↔ remote systems (SSH, WSL). - -### WSH RPC System (`pkg/wshrpc/`) - -The WSH RPC system is the backbone of Wave Terminal's communication architecture: - -**Key Components:** - -- [`wshrpctypes.go`](pkg/wshrpc/wshrpctypes.go) - Core RPC interface and type definitions (source of truth for all RPC commands) -- [`wshserver/`](pkg/wshrpc/wshserver/) - Server-side RPC implementation -- [`wshremote/`](pkg/wshrpc/wshremote/) - Remote connection handling -- [`wshclient.go`](pkg/wshrpc/wshclient.go) - Go client for making RPC calls -- [`frontend/app/store/wshclientapi.ts`](frontend/app/store/wshclientapi.ts) - Generated TypeScript RPC client - -**Routing:** Callers address RPC calls using _routes_ (e.g. a block ID, connection name, or `"waveapp"`) rather than caring about the underlying transport. The RPC layer resolves the route to the correct transport (WebSocket, Unix socket, SSH tunnel, stdio) automatically. This means the same RPC interface works whether the target is local or a remote SSH connection. - -## Development Notes - -- **Build commands** - Use `task` (Taskfile.yml) for all build, generate, and packaging commands -- **Code generation** - Run `task generate` after modifying Go types in `pkg/wshrpc/wshrpctypes.go`, `pkg/wconfig/settingsconfig.go`, or `pkg/waveobj/wtypemeta.go` -- **Testing** - Vitest for frontend unit tests; standard `go test` for Go packages -- **Database migrations** - SQL migration files in `db/migrations-wstore/` and `db/migrations-filestore/` -- **Documentation** - Docusaurus site in `docs/` diff --git a/.kilocode/rules/rules.md b/.kilocode/rules/rules.md deleted file mode 100644 index 904292ea97..0000000000 --- a/.kilocode/rules/rules.md +++ /dev/null @@ -1,204 +0,0 @@ -Wave Terminal is a modern terminal which provides graphical blocks, dynamic layout, workspaces, and SSH connection management. It is cross platform and built on electron. - -### Project Structure - -It has a TypeScript/React frontend and a Go backend. They talk together over `wshrpc` a custom RPC protocol that is implemented over websocket (and domain sockets). - -### Coding Guidelines - -- **Go Conventions**: - - Don't use custom enum types in Go. Instead, use string constants (e.g., `const StatusRunning = "running"` rather than creating a custom type like `type Status string`). - - Use string constants for status values, packet types, and other string-based enumerations. - - in Go code, prefer using Printf() vs Println() - - use "Make" as opposed to "New" for struct initialization func names - - in general const decls go at the top of the file (before types and functions) - - NEVER run `go build` (especially in weird sub-package directories). we can tell if everything compiles by seeing there are no problems/errors. -- **Synchronization**: - - Always prefer to use the `lock.Lock(); defer lock.Unlock()` pattern for synchronization if possible - - Avoid inline lock/unlock pairs - instead create helper functions that use the defer pattern - - When accessing shared data structures (maps, slices, etc.), ensure proper locking - - Example: Instead of `gc.lock.Lock(); gc.map[key]++; gc.lock.Unlock()`, create a helper function like `getNextValue(key string) int { gc.lock.Lock(); defer gc.lock.Unlock(); gc.map[key]++; return gc.map[key] }` -- **TypeScript Imports**: - - Use `@/...` for imports from different parts of the project (configured in `tsconfig.json` as `"@/*": ["frontend/*"]`). - - Prefer relative imports (`"./name"`) only within the same directory. - - Use named exports exclusively; avoid default exports. It's acceptable to export functions directly (e.g., React Components). - - Our indent is 4 spaces -- **JSON Field Naming**: All fields must be lowercase, without underscores. -- **TypeScript Conventions** - - **Type Handling**: - - In TypeScript we have strict null checks off, so no need to add "| null" to all the types. - - In TypeScript for Jotai atoms, if we want to write, we need to type the atom as a PrimitiveAtom - - Jotai has a bug with strict null checks off where if you create a null atom, e.g. atom(null) it does not "type" correctly. That's no issue, just cast it to the proper PrimitiveAtom type (no "| null") and it will work fine. - - Generally never use "=== undefined" or "!== undefined". This is bad style. Just use a "== null" or "!= null" unless it is a very specific case where we need to distinguish undefined from null. - - **Coding Style**: - - Use all lowercase filenames (except where case is actually important like Taskfile.yml) - - Import the "cn" function from "@/util/util" to do classname / clsx class merge (it uses twMerge underneath) - - Do NOT create private fields in classes (they are impossible to inspect) - - Use PascalCase for global consts at the top of files - - **Component Practices**: - - Make sure to add cursor-pointer to buttons/links and clickable items - - NEVER use cursor-help (it looks terrible) - - useAtom() and useAtomValue() are react HOOKS, so they must be called at the component level not inline in JSX - - If you use React.memo(), make sure to add a displayName for the component - - Other - - never use atob() or btoa() (not UTF-8 safe). use functions in frontend/util/util.ts for base64 decoding and encoding -- In general, when writing functions, we prefer _early returns_ rather than putting the majority of a function inside of an if block. - -### Styling - -- We use **Tailwind v4** to style. Custom stuff is defined in frontend/tailwindsetup.css -- _never_ use cursor-help, or cursor-not-allowed (it looks terrible) -- We have custom CSS setup as well, so it is a hybrid system. For new code we prefer tailwind, and are working to migrate code to all use tailwind. -- For accent buttons, use "bg-accent/80 text-primary rounded hover:bg-accent transition-colors cursor-pointer" (if you do "bg-accent hover:bg-accent/80" it looks weird as on hover the button gets darker instead of lighter) - -### RPC System - -To define a new RPC call, add the new definition to `pkg/wshrpc/wshrpctypes.go` including any input/output data that is required. After modifying wshrpctypes.go run `task generate` to generate the client APIs. - -For normal "server" RPCs (where a frontend client is calling the main server) you should implement the RPC call in `pkg/wshrpc/wshserver.go`. - -### Electron API - -From within the FE to get the electron API (e.g. the preload functions): - -```ts -import { getApi } from "@/store/global"; - -getApi().getIsDev(); -``` - -The full API is defined in custom.d.ts as type ElectronApi. - -### Code Generation - -- **TypeScript Types**: TypeScript types are automatically generated from Go types. After modifying Go types in `pkg/wshrpc/wshrpctypes.go`, run `task generate` to update the TypeScript type definitions in `frontend/types/gotypes.d.ts`. -- **Manual Edits**: Do not manually edit generated files like `frontend/types/gotypes.d.ts` or `frontend/app/store/wshclientapi.ts`. Instead, modify the source Go types and run `task generate`. - -### Frontend Architecture - -- The application uses Jotai for state management. -- When working with Jotai atoms that need to be updated, define them as `PrimitiveAtom` rather than just `atom`. - -### Notes - -- **CRITICAL: Completion format MUST be: "Done: [one-line description]"** -- **Keep your Task Completed summaries VERY short** -- **No lengthy pre-completion summaries** - Do not provide detailed explanations of implementation before using attempt_completion -- **No recaps of changes** - Skip explaining what was done before completion -- **Go directly to completion** - After making changes, proceed directly to attempt_completion without summarizing -- The project is currently an un-released POC / MVP. Do not worry about backward compatibility when making changes -- With React hooks, always complete all hook calls at the top level before any conditional returns (including jotai hook calls useAtom and useAtomValue); when a user explicitly tells you a function handles null inputs, trust them and stop trying to "protect" it with unnecessary checks or workarounds. -- **Match response length to question complexity** - For simple, direct questions in Ask mode (especially those that can be answered in 1-2 sentences), provide equally brief answers. Save detailed explanations for complex topics or when explicitly requested. -- **CRITICAL** - useAtomValue and useAtom are React HOOKS. They cannot be used inline in JSX code, they must appear at the top of a component in the hooks area of the react code. -- for simple functions, we prefer `if (!cond) { return }; functionality;` pattern over `if (cond) { functionality }` because it produces less indentation and is easier to follow. -- It is now 2026, so if you write new files, or update files use 2026 for the copyright year -- React.MutableRefObject is deprecated, just use React.RefObject now (in React 19 RefObject is always mutable) - -### Strict Comment Rules - -- **NEVER add comments that merely describe what code is doing**: - - ❌ `mutex.Lock() // Lock the mutex` - - ❌ `counter++ // Increment the counter` - - ❌ `buffer.Write(data) // Write data to buffer` - - ❌ `// Header component for app run list` (above AppRunListHeader) - - ❌ `// Updated function to include onClick parameter` - - ❌ `// Changed padding calculation` - - ❌ `// Removed unnecessary div` - - ❌ `// Using the model's width value here` -- **Only use comments for**: - - Explaining WHY a particular approach was chosen - - Documenting non-obvious edge cases or side effects - - Warning about potential pitfalls in usage - - Explaining complex algorithms that can't be simplified -- **When in doubt, leave it out**. No comment is better than a redundant comment. -- **Never add comments explaining code changes** - The code should speak for itself, and version control tracks changes. The one exception to this rule is if it is a very unobvious implementation. Something that someone would typically implement in a different (wrong) way. Then the comment helps us remember WHY we changed it to a less obvious implementation. -- **Never remove existing comments** unless specifically directed by the user. Comments that are already defined in existing code have been vetted by the user. - -### Jotai Model Pattern (our rules) - -- **Atoms live on the model.** -- **Simple atoms:** define as **field initializers**. -- **Atoms that depend on values/other atoms:** create in the **constructor**. -- Models **never use React hooks**; they use `globalStore.get/set`. -- It's fine to call model methods from **event handlers** or **`useEffect`**. -- Models use the **singleton pattern** with a `private static instance` field, a `private constructor`, and a `static getInstance()` method. -- The constructor is `private`; callers always use `getInstance()`. - -```ts -// model/MyModel.ts -import * as jotai from "jotai"; -import { globalStore } from "@/app/store/jotaiStore"; - -export class MyModel { - private static instance: MyModel | null = null; - - // simple atoms (field init) - statusAtom = jotai.atom<"idle" | "running" | "error">("idle"); - outputAtom = jotai.atom(""); - - // ctor-built atoms (need types) - lengthAtom!: jotai.Atom; - thresholdedAtom!: jotai.Atom; - - private constructor(initialThreshold = 20) { - this.lengthAtom = jotai.atom((get) => get(this.outputAtom).length); - this.thresholdedAtom = jotai.atom((get) => get(this.lengthAtom) > initialThreshold); - } - - static getInstance(): MyModel { - if (!MyModel.instance) { - MyModel.instance = new MyModel(); - } - return MyModel.instance; - } - - static resetInstance(): void { - MyModel.instance = null; - } - - async doWork() { - globalStore.set(this.statusAtom, "running"); - // ... do work ... - globalStore.set(this.statusAtom, "idle"); - } -} -``` - -```tsx -// component usage (events & effects OK) -import { useAtomValue } from "jotai"; - -function Panel() { - const model = MyModel.getInstance(); - const status = useAtomValue(model.statusAtom); - const isBig = useAtomValue(model.thresholdedAtom); - - const onClick = () => model.doWork(); - - return ( -
- {status} â€ĸ {String(isBig)} -
- ); -} -``` - -**Remember:** singleton pattern with `getInstance()`, `private constructor`, atoms on the model, simple-as-fields, ctor for dependent/derived, updates via `globalStore.set/get`. -**Note** Older models may not use the singleton pattern - -### Tool Use - -Do NOT use write_to_file unless it is a new file or very short. Always prefer to use replace_in_file. Often your diffs fail when a file may be out of date in your cache vs the actual on-disk format. You should RE-READ the file and try to create diffs again if your diffs fail rather than fall back to write_to_file. If you feel like your ONLY option is to use write_to_file please ask first. - -Also when adding content to the end of files prefer to use the new append_file tool rather than trying to create a diff (as your diffs are often not specific enough and end up inserting code in the middle of existing functions). - -### Directory Awareness - -- **ALWAYS verify the current working directory before executing commands** -- Either run "pwd" first to verify the directory, or do a "cd" to the correct absolute directory before running commands -- When running tests, do not "cd" to the pkg directory and then run the test. This screws up the cwd and you never recover. run the test from the project root instead. - -### Testing / Compiling Go Code - -No need to run a `go build` or a `go run` to just check if the Go code compiles. VSCode's errors/problems cover this well. -If there are no Go errors in VSCode you can assume the code compiles fine. diff --git a/.kilocode/skills/add-config/SKILL.md b/.kilocode/skills/add-config/SKILL.md deleted file mode 100644 index f961093bb8..0000000000 --- a/.kilocode/skills/add-config/SKILL.md +++ /dev/null @@ -1,471 +0,0 @@ ---- -name: add-config -description: Guide for adding new configuration settings to Wave Terminal. Use when adding a new setting to the configuration system, implementing a new config key, or adding user-customizable settings. ---- - -# Adding a New Configuration Setting to Wave Terminal - -This guide explains how to add a new configuration setting to Wave Terminal's hierarchical configuration system. - -## Configuration System Overview - -Wave Terminal uses a hierarchical configuration system with: - -1. **Go Struct Definitions** - Type-safe configuration structure in `pkg/wconfig/settingsconfig.go` -2. **JSON Schema** - Auto-generated validation schema in `schema/settings.json` -3. **Default Values** - Built-in defaults in `pkg/wconfig/defaultconfig/settings.json` -4. **User Configuration** - User overrides in `~/.config/waveterm/settings.json` -5. **Block Metadata** - Block-level overrides in `pkg/waveobj/wtypemeta.go` -6. **Documentation** - User-facing docs in `docs/docs/config.mdx` - -Settings cascade from defaults → user settings → connection config → block overrides. - -## Step-by-Step Guide - -### Step 1: Add to Go Struct Definition - -Edit `pkg/wconfig/settingsconfig.go` and add your new field to the `SettingsType` struct: - -```go -type SettingsType struct { - // ... existing fields ... - - // Add your new field with appropriate JSON tag - MyNewSetting string `json:"mynew:setting,omitempty"` - - // For different types: - MyBoolSetting bool `json:"mynew:boolsetting,omitempty"` - MyNumberSetting float64 `json:"mynew:numbersetting,omitempty"` - MyIntSetting *int64 `json:"mynew:intsetting,omitempty"` // Use pointer for optional ints - MyArraySetting []string `json:"mynew:arraysetting,omitempty"` -} -``` - -**Naming Conventions:** - -- Use namespace prefixes (e.g., `term:`, `window:`, `ai:`, `web:`, `app:`) -- Use lowercase with colons as separators -- Field names should be descriptive and follow Go naming conventions -- Use `omitempty` tag to exclude empty values from JSON - -**Type Guidelines:** - -- Use `*int64` and `*float64` for optional numeric values -- Use `*bool` for optional boolean values (or `bool` if default is false) -- Use `string` for text values -- Use `[]string` for arrays -- Use `float64` for numbers that can be decimals - -**Namespace Organization:** - -- `app:*` - Application-level settings -- `term:*` - Terminal-specific settings -- `window:*` - Window and UI settings -- `ai:*` - AI-related settings -- `web:*` - Web browser settings -- `editor:*` - Code editor settings -- `conn:*` - Connection settings - -### Step 1.5: Add to Block Metadata (Optional) - -If your setting should support block-level overrides, also add it to `pkg/waveobj/wtypemeta.go`: - -```go -type MetaTSType struct { - // ... existing fields ... - - // Add your new field with matching JSON tag and type - MyNewSetting *string `json:"mynew:setting,omitempty"` // Use pointer for optional values - - // For different types: - MyBoolSetting *bool `json:"mynew:boolsetting,omitempty"` - MyNumberSetting *float64 `json:"mynew:numbersetting,omitempty"` - MyIntSetting *int `json:"mynew:intsetting,omitempty"` - MyArraySetting []string `json:"mynew:arraysetting,omitempty"` -} -``` - -**Block Metadata Guidelines:** - -- Use pointer types (`*string`, `*bool`, `*int`, `*float64`) for optional overrides -- JSON tags should exactly match the corresponding settings field -- This enables the hierarchical config system: block metadata → connection config → global settings -- Only add settings here that make sense to override per-block or per-connection - -### Step 2: Set Default Value (Optional) - -If your setting should have a default value, add it to `pkg/wconfig/defaultconfig/settings.json`: - -```json -{ - "ai:preset": "ai@global", - "ai:model": "gpt-5-mini", - // ... existing defaults ... - - "mynew:setting": "default value", - "mynew:boolsetting": true, - "mynew:numbersetting": 42.5, - "mynew:intsetting": 100 -} -``` - -**Default Value Guidelines:** - -- Only add defaults for settings that should have non-zero/non-empty initial values -- Ensure defaults make sense for typical user experience -- Keep defaults conservative and safe -- Boolean settings often don't need defaults if `false` is the correct default - -### Step 3: Update Documentation - -Add your new setting to the configuration table in `docs/docs/config.mdx`: - -```markdown -| Key Name | Type | Function | -| ------------------- | -------- | ----------------------------------------- | -| mynew:setting | string | Description of what this setting controls | -| mynew:boolsetting | bool | Enable/disable some feature | -| mynew:numbersetting | float | Numeric setting for some parameter | -| mynew:intsetting | int | Integer setting for some configuration | -| mynew:arraysetting | string[] | Array of strings for multiple values | -``` - -**Documentation Guidelines:** - -- Provide clear, concise descriptions -- For new settings in upcoming releases, add `` -- Update the default configuration example if you added defaults -- Explain what values are valid and what they do - -### Step 4: Regenerate Schema and TypeScript Types - -Run the generate task to automatically regenerate the JSON schema and TypeScript types: - -```bash -task generate -``` - -**What this does:** - -- Runs `task build:schema` (automatically generates JSON schema from Go structs) -- Generates TypeScript type definitions in `frontend/types/gotypes.d.ts` -- Generates RPC client APIs -- Generates metadata constants - -**Important:** The JSON schema in `schema/settings.json` is **automatically generated** from the Go struct definitions - you don't need to edit it manually. - -### Step 5: Use in Frontend Code - -Access your new setting in React components: - -```typescript -import { getOverrideConfigAtom, getSettingsKeyAtom, useAtomValue } from "@/store/global"; - -// In a React component -const MyComponent = ({ blockId }: { blockId: string }) => { - // Use override config atom for hierarchical resolution - // This automatically checks: block metadata → connection config → global settings → default - const mySettingAtom = getOverrideConfigAtom(blockId, "mynew:setting"); - const mySetting = useAtomValue(mySettingAtom) ?? "fallback value"; - - // For global-only settings (no block overrides) - const globalOnlySetting = useAtomValue(getSettingsKeyAtom("mynew:globalsetting")) ?? "fallback"; - - return
Setting value: {mySetting}
; -}; -``` - -**Frontend Configuration Patterns:** - -```typescript -// 1. Settings with block-level overrides (recommended for most view/display settings) -const termFontSize = useAtomValue(getOverrideConfigAtom(blockId, "term:fontsize")) ?? 12; - -// 2. Global-only settings (app-wide settings that don't vary by block) -const appGlobalHotkey = useAtomValue(getSettingsKeyAtom("app:globalhotkey")) ?? ""; - -// 3. Connection-specific settings -const connStatus = useAtomValue(getConnStatusAtom(connectionName)); -``` - -**When to use each pattern:** - -- Use `getOverrideConfigAtom()` for settings that can vary by block or connection (most UI/display settings) -- Use `getSettingsKeyAtom()` for app-level settings that are always global -- Always provide a fallback value with `??` operator - -### Step 6: Use in Backend Code - -Access settings in Go code: - -```go -// Get the full config -fullConfig := wconfig.GetWatcher().GetFullConfig() - -// Access your setting -myValue := fullConfig.Settings.MyNewSetting - -// For optional values (pointers) -if fullConfig.Settings.MyIntSetting != nil { - intValue := *fullConfig.Settings.MyIntSetting - // Use intValue -} -``` - -## Complete Examples - -### Example 1: Simple Boolean Setting (No Block Override) - -**Use case:** Add a setting to hide the AI button globally - -#### 1. Go Struct (`pkg/wconfig/settingsconfig.go`) - -```go -type SettingsType struct { - // ... existing fields ... - AppHideAiButton bool `json:"app:hideaibutton,omitempty"` -} -``` - -#### 2. Default Value (`pkg/wconfig/defaultconfig/settings.json`) - -```json -{ - "app:hideaibutton": false -} -``` - -#### 3. Documentation (`docs/docs/config.mdx`) - -```markdown -| app:hideaibutton | bool | Hide the AI button in the tab bar (defaults to false) | -``` - -#### 4. Generate Types - -```bash -task generate -``` - -#### 5. Frontend Usage - -```typescript -import { getSettingsKeyAtom } from "@/store/global"; - -const TabBar = () => { - const hideAiButton = useAtomValue(getSettingsKeyAtom("app:hideaibutton")); - - if (hideAiButton) { - return null; // Don't render AI button - } - - return ; -}; -``` - -#### 6. Usage Examples - -```bash -# Set in settings file -wsh setconfig app:hideaibutton=true - -# Or edit ~/.config/waveterm/settings.json -{ - "app:hideaibutton": true -} -``` - -### Example 2: Terminal Setting with Block Override - -**Use case:** Add a terminal bell sound setting that can be overridden per block - -#### 1. Go Struct (`pkg/wconfig/settingsconfig.go`) - -```go -type SettingsType struct { - // ... existing fields ... - TermBellSound string `json:"term:bellsound,omitempty"` -} -``` - -#### 2. Block Metadata (`pkg/waveobj/wtypemeta.go`) - -```go -type MetaTSType struct { - // ... existing fields ... - TermBellSound *string `json:"term:bellsound,omitempty"` // Pointer for optional override -} -``` - -#### 3. Default Value (`pkg/wconfig/defaultconfig/settings.json`) - -```json -{ - "term:bellsound": "default" -} -``` - -#### 4. Documentation (`docs/docs/config.mdx`) - -```markdown -| term:bellsound | string | Sound to play for terminal bell ("default", "none", or custom sound file path) | -``` - -#### 5. Generate Types - -```bash -task generate -``` - -#### 6. Frontend Usage - -```typescript -import { getOverrideConfigAtom } from "@/store/global"; - -const TerminalView = ({ blockId }: { blockId: string }) => { - // Use override config for hierarchical resolution - const bellSoundAtom = getOverrideConfigAtom(blockId, "term:bellsound"); - const bellSound = useAtomValue(bellSoundAtom) ?? "default"; - - const playBellSound = () => { - if (bellSound === "none") return; - // Play the bell sound - }; - - return
Terminal with bell: {bellSound}
; -}; -``` - -#### 7. Usage Examples - -```bash -# Set globally in settings file -wsh setconfig term:bellsound="custom.wav" - -# Set for current block only -wsh setmeta term:bellsound="none" - -# Set for specific block -wsh setmeta --block BLOCK_ID term:bellsound="beep" - -# Or edit ~/.config/waveterm/settings.json -{ - "term:bellsound": "custom.wav" -} -``` - -## Configuration Patterns - -### Clear/Reset Pattern - -Each namespace can have a "clear" field for resetting all settings in that namespace: - -```go -AppClear bool `json:"app:*,omitempty"` -TermClear bool `json:"term:*,omitempty"` -``` - -### Optional vs Required Settings - -- Use pointer types (`*bool`, `*int64`, `*float64`) for truly optional settings -- Use regular types for settings that should always have a value -- Provide sensible defaults for important settings - -### Block-Level Overrides via RPC - -Settings can be overridden at the block level using metadata: - -```typescript -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { WOS } from "@/store/global"; - -// Set block-specific override -await RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", blockId), - meta: { "mynew:setting": "block-specific value" }, -}); -``` - -## Common Pitfalls - -### 1. Forgetting to Run `task generate` - -**Problem:** TypeScript types not updated, schema out of sync - -**Solution:** Always run `task generate` after modifying Go structs - -### 2. Type Mismatch Between Settings and Metadata - -**Problem:** Settings uses `string`, metadata uses `*int` - -**Solution:** Ensure types match (except metadata uses pointers for optionals) - -### 3. Not Providing Fallback Values - -**Problem:** Component breaks if setting is undefined - -**Solution:** Always use `??` operator with fallback: - -```typescript -const value = useAtomValue(getSettingsKeyAtom("key")) ?? "default"; -``` - -### 4. Using Wrong Config Atom - -**Problem:** Using `getSettingsKeyAtom()` for settings that need block overrides - -**Solution:** Use `getOverrideConfigAtom()` for any setting in `MetaTSType` - -## Best Practices - -### Naming - -- **Use descriptive names**: `term:fontsize` not `term:fs` -- **Follow namespace conventions**: Group related settings with common prefix -- **Use consistent casing**: Always lowercase with colons - -### Types - -- **Use `bool`** for simple on/off settings (no pointer if false is default) -- **Use `*bool`** only if you need to distinguish unset from false -- **Use `*int64`/`*float64`** for optional numeric values -- **Use `string`** for text, paths, or enum-like values -- **Use `[]string`** for lists - -### Defaults - -- **Provide sensible defaults** for settings users will commonly change -- **Omit defaults** for advanced/optional settings -- **Keep defaults safe** - don't enable experimental features by default -- **Document defaults** clearly in config.mdx - -### Block Overrides - -- **Enable for view/display settings**: Font sizes, colors, themes, etc. -- **Don't enable for app-wide settings**: Global hotkeys, window behavior, etc. -- **Consider the use case**: Would a user want different values per block or connection? - -### Documentation - -- **Be specific**: Explain what the setting does and what values are valid -- **Provide examples**: Show common use cases -- **Add version badges**: Mark new settings with `` -- **Keep it current**: Update docs when behavior changes - -## Quick Reference - -When adding a new configuration setting: - -- [ ] Add field to `SettingsType` in `pkg/wconfig/settingsconfig.go` -- [ ] Add field to `MetaTSType` in `pkg/waveobj/wtypemeta.go` (if block override needed) -- [ ] Add default to `pkg/wconfig/defaultconfig/settings.json` (if needed) -- [ ] Document in `docs/docs/config.mdx` -- [ ] Run `task generate` to update TypeScript types -- [ ] Use appropriate atom (`getOverrideConfigAtom` or `getSettingsKeyAtom`) in frontend - -## Related Documentation - -- **User Documentation**: `docs/docs/config.mdx` - User-facing configuration docs -- **Type Definitions**: `pkg/wconfig/settingsconfig.go` - Go struct definitions -- **Metadata Types**: `pkg/waveobj/wtypemeta.go` - Block metadata definitions diff --git a/.kilocode/skills/add-rpc/SKILL.md b/.kilocode/skills/add-rpc/SKILL.md deleted file mode 100644 index 0bf5117f9f..0000000000 --- a/.kilocode/skills/add-rpc/SKILL.md +++ /dev/null @@ -1,453 +0,0 @@ ---- -name: add-rpc -description: Guide for adding new RPC calls to Wave Terminal. Use when implementing new RPC commands, adding server-client communication methods, or extending the RPC interface with new functionality. ---- - -# Adding RPC Calls Guide - -## Overview - -Wave Terminal uses a WebSocket-based RPC (Remote Procedure Call) system for communication between different components. The RPC system allows the frontend, backend, electron main process, remote servers, and terminal blocks to communicate with each other through well-defined commands. - -This guide covers how to add a new RPC command to the system. - -## Key Files - -- `pkg/wshrpc/wshrpctypes.go` - RPC interface and type definitions -- `pkg/wshrpc/wshserver/wshserver.go` - Main server implementation (most common) -- `emain/emain-wsh.ts` - Electron main process implementation -- `frontend/app/store/tabrpcclient.ts` - Frontend tab implementation -- `pkg/wshrpc/wshremote/wshremote.go` - Remote server implementation -- `frontend/app/view/term/term-wsh.tsx` - Terminal block implementation - -## RPC Command Structure - -RPC commands in Wave Terminal follow these conventions: - -- **Method names** must end with `Command` -- **First parameter** must be `context.Context` -- **Remaining parameters** are a regular Go parameter list (zero or more typed args) -- **Return values** can be either just an error, or one return value plus an error -- **Streaming commands** return a channel instead of a direct value - -## Adding a New RPC Call - -### Step 1: Define the Command in the Interface - -Add your command to the `WshRpcInterface` in `pkg/wshrpc/wshrpctypes.go`: - -```go -type WshRpcInterface interface { - // ... existing commands ... - - // Add your new command - YourNewCommand(ctx context.Context, data CommandYourNewData) (*YourNewResponse, error) -} -``` - -**Method Signature Rules:** - -- Method name must end with `Command` -- First parameter must be `ctx context.Context` -- Remaining parameters are a regular Go parameter list (zero or more) -- Return either `error` or `(ReturnType, error)` -- For streaming, return `chan RespOrErrorUnion[T]` - -### Step 2: Define Request and Response Types - -If your command needs structured input or output, define types in the same file: - -```go -type CommandYourNewData struct { - FieldOne string `json:"fieldone"` - FieldTwo int `json:"fieldtwo"` - SomeId string `json:"someid"` -} - -type YourNewResponse struct { - ResultField string `json:"resultfield"` - Success bool `json:"success"` -} -``` - -**Type Naming Conventions:** - -- Request types: `Command[Name]Data` (e.g., `CommandGetMetaData`) -- Response types: `[Name]Response` or `Command[Name]RtnData` (e.g., `CommandResolveIdsRtnData`) -- Use `json` struct tags with lowercase field names -- Follow existing patterns in the file for consistency - -### Step 3: Generate Bindings - -After modifying `pkg/wshrpc/wshrpctypes.go`, run code generation to create TypeScript bindings and Go helper code: - -```bash -task generate -``` - -This command will: -- Generate TypeScript type definitions in `frontend/types/gotypes.d.ts` -- Create RPC client bindings -- Update routing code - -**Note:** If generation fails, check that your method signature follows all the rules above. - -### Step 4: Implement the Command - -Choose where to implement your command based on what it needs to do: - -#### A. Main Server Implementation (Most Common) - -Implement in `pkg/wshrpc/wshserver/wshserver.go`: - -```go -func (ws *WshServer) YourNewCommand(ctx context.Context, data wshrpc.CommandYourNewData) (*wshrpc.YourNewResponse, error) { - // Validate input - if data.SomeId == "" { - return nil, fmt.Errorf("someid is required") - } - - // Implement your logic - result := doSomething(data) - - // Return response - return &wshrpc.YourNewResponse{ - ResultField: result, - Success: true, - }, nil -} -``` - -**Use main server when:** -- Accessing the database -- Managing blocks, tabs, or workspaces -- Coordinating between components -- Handling file operations on the main filesystem - -#### B. Electron Implementation - -Implement in `emain/emain-wsh.ts`: - -```typescript -async handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise { - // Electron-specific logic - const result = await electronAPI.doSomething(data); - - return { - resultfield: result, - success: true, - }; -} -``` - -**Use Electron when:** -- Accessing native OS features -- Managing application windows -- Using Electron APIs (notifications, system tray, etc.) -- Handling encryption/decryption with safeStorage - -#### C. Frontend Tab Implementation - -Implement in `frontend/app/store/tabrpcclient.ts`: - -```typescript -async handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise { - // Access frontend state/models - const layoutModel = getLayoutModelForStaticTab(); - - // Implement tab-specific logic - const result = layoutModel.doSomething(data); - - return { - resultfield: result, - success: true, - }; -} -``` - -**Use tab client when:** -- Accessing React state or Jotai atoms -- Manipulating UI layout -- Capturing screenshots -- Reading frontend-only data - -#### D. Remote Server Implementation - -Implement in `pkg/wshrpc/wshremote/wshremote.go`: - -```go -func (impl *ServerImpl) RemoteYourNewCommand(ctx context.Context, data wshrpc.CommandRemoteYourNewData) (*wshrpc.YourNewResponse, error) { - // Remote filesystem or process operations - result, err := performRemoteOperation(data) - if err != nil { - return nil, fmt.Errorf("remote operation failed: %w", err) - } - - return &wshrpc.YourNewResponse{ - ResultField: result, - Success: true, - }, nil -} -``` - -**Use remote server when:** -- Operating on remote filesystems -- Executing commands on remote hosts -- Managing remote processes -- Convention: prefix command name with `Remote` (e.g., `RemoteGetInfoCommand`) - -#### E. Terminal Block Implementation - -Implement in `frontend/app/view/term/term-wsh.tsx`: - -```typescript -async handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise { - // Access terminal-specific data - const termWrap = this.model.termRef.current; - - // Implement terminal logic - const result = termWrap.doSomething(data); - - return { - resultfield: result, - success: true, - }; -} -``` - -**Use terminal client when:** -- Accessing terminal buffer/scrollback -- Managing VDOM contexts -- Reading terminal-specific state -- Interacting with xterm.js - -## Complete Example: Adding GetWaveInfo Command - -### 1. Define Interface - -In `pkg/wshrpc/wshrpctypes.go`: - -```go -type WshRpcInterface interface { - // ... other commands ... - WaveInfoCommand(ctx context.Context) (*WaveInfoData, error) -} - -type WaveInfoData struct { - Version string `json:"version"` - BuildTime string `json:"buildtime"` - ConfigPath string `json:"configpath"` - DataPath string `json:"datapath"` -} -``` - -### 2. Generate Bindings - -```bash -task generate -``` - -### 3. Implement in Main Server - -In `pkg/wshrpc/wshserver/wshserver.go`: - -```go -func (ws *WshServer) WaveInfoCommand(ctx context.Context) (*wshrpc.WaveInfoData, error) { - return &wshrpc.WaveInfoData{ - Version: wavebase.WaveVersion, - BuildTime: wavebase.BuildTime, - ConfigPath: wavebase.GetConfigDir(), - DataPath: wavebase.GetWaveDataDir(), - }, nil -} -``` - -### 4. Call from Frontend - -```typescript -import { RpcApi } from "@/app/store/wshclientapi"; - -// Call the RPC -const info = await RpcApi.WaveInfoCommand(TabRpcClient); -console.log("Wave Version:", info.version); -``` - -## Streaming Commands - -For commands that return data progressively, use channels: - -### Define Streaming Interface - -```go -type WshRpcInterface interface { - StreamYourDataCommand(ctx context.Context, request YourDataRequest) chan RespOrErrorUnion[YourDataType] -} -``` - -### Implement Streaming Command - -```go -func (ws *WshServer) StreamYourDataCommand(ctx context.Context, request wshrpc.YourDataRequest) chan wshrpc.RespOrErrorUnion[wshrpc.YourDataType] { - rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.YourDataType]) - - go func() { - defer close(rtn) - defer func() { - panichandler.PanicHandler("StreamYourDataCommand", recover()) - }() - - // Stream data - for i := 0; i < 10; i++ { - select { - case <-ctx.Done(): - return - default: - rtn <- wshrpc.RespOrErrorUnion[wshrpc.YourDataType]{ - Response: wshrpc.YourDataType{ - Value: i, - }, - } - time.Sleep(100 * time.Millisecond) - } - } - }() - - return rtn -} -``` - -## Best Practices - -1. **Validation First**: Always validate input parameters at the start of your implementation - -2. **Descriptive Names**: Use clear, action-oriented command names (e.g., `GetFullConfigCommand`, not `ConfigCommand`) - -3. **Error Handling**: Return descriptive errors with context: - ```go - return nil, fmt.Errorf("error creating block: %w", err) - ``` - -4. **Context Awareness**: Respect context cancellation for long-running operations: - ```go - select { - case <-ctx.Done(): - return ctx.Err() - default: - // continue - } - ``` - -5. **Consistent Types**: Follow existing naming patterns for request/response types - -6. **JSON Tags**: Always use lowercase JSON tags matching frontend conventions - -7. **Documentation**: Add comments explaining complex commands or special behaviors - -8. **Type Safety**: Leverage TypeScript generation - your types will be checked on both ends - -9. **Panic Recovery**: Use `panichandler.PanicHandler` in goroutines to prevent crashes - -10. **Route Awareness**: For multi-route scenarios, use `wshutil.GetRpcSourceFromContext(ctx)` to identify callers - -## Common Command Patterns - -### Simple Query - -```go -func (ws *WshServer) GetSomethingCommand(ctx context.Context, id string) (*Something, error) { - obj, err := wstore.DBGet[*Something](ctx, id) - if err != nil { - return nil, fmt.Errorf("error getting something: %w", err) - } - return obj, nil -} -``` - -### Mutation with Updates - -```go -func (ws *WshServer) UpdateSomethingCommand(ctx context.Context, data wshrpc.CommandUpdateData) error { - ctx = waveobj.ContextWithUpdates(ctx) - - // Make changes - err := wstore.UpdateObject(ctx, data.ORef, data.Updates) - if err != nil { - return fmt.Errorf("error updating: %w", err) - } - - // Broadcast updates - updates := waveobj.ContextGetUpdatesRtn(ctx) - wps.Broker.SendUpdateEvents(updates) - - return nil -} -``` - -### Command with Side Effects - -```go -func (ws *WshServer) DoActionCommand(ctx context.Context, data wshrpc.CommandActionData) error { - // Perform action - result, err := performAction(data) - if err != nil { - return err - } - - // Publish event about the action - go func() { - wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_ActionComplete, - Data: result, - }) - }() - - return nil -} -``` - -## Troubleshooting - -### Command Not Found - -- Ensure method name ends with `Command` -- Verify you ran `task generate` -- Check that the interface is in `WshRpcInterface` - -### Type Mismatch Errors - -- Run `task generate` after changing types -- Ensure JSON tags are lowercase -- Verify TypeScript code is using generated types - -### Command Times Out - -- Check for blocking operations -- Ensure context is passed through -- Consider using a streaming command for long operations - -### Routing Issues - -- For remote commands, ensure they're implemented in correct location -- Check route configuration in RpcContext -- Verify authentication for secured routes - -## Quick Reference - -When adding a new RPC command: - -- [ ] Add method to `WshRpcInterface` in `pkg/wshrpc/wshrpctypes.go` (must end with `Command`) -- [ ] Define request/response types with JSON tags (if needed) -- [ ] Run `task generate` to create bindings -- [ ] Implement in appropriate location: - - [ ] `wshserver.go` for main server (most common) - - [ ] `emain-wsh.ts` for Electron - - [ ] `tabrpcclient.ts` for frontend - - [ ] `wshremote.go` for remote (prefix with `Remote`) - - [ ] `term-wsh.tsx` for terminal -- [ ] Add input validation -- [ ] Handle errors with context -- [ ] Test the command end-to-end - -## Related Documentation - -- **WPS Events**: See the `wps-events` skill - Publishing events from RPC commands diff --git a/.kilocode/skills/add-wshcmd/SKILL.md b/.kilocode/skills/add-wshcmd/SKILL.md deleted file mode 100644 index 0cdae64702..0000000000 --- a/.kilocode/skills/add-wshcmd/SKILL.md +++ /dev/null @@ -1,921 +0,0 @@ ---- -name: add-wshcmd -description: Guide for adding new wsh commands to Wave Terminal. Use when implementing new CLI commands, adding command-line functionality, or extending the wsh command interface. ---- - -# Adding a New wsh Command to Wave Terminal - -This guide explains how to add a new command to the `wsh` CLI tool. - -## wsh Command System Overview - -Wave Terminal's `wsh` command provides CLI access to Wave Terminal features. The system uses: - -1. **Cobra Framework** - CLI command structure and parsing -2. **Command Files** - Individual command implementations in `cmd/wsh/cmd/wshcmd-*.go` -3. **RPC Client** - Communication with Wave Terminal backend via `RpcClient` -4. **Activity Tracking** - Telemetry for command usage analytics -5. **Documentation** - User-facing docs in `docs/docs/wsh-reference.mdx` - -Commands are registered in their `init()` functions and execute through the Cobra framework. - -## Step-by-Step Guide - -### Step 1: Create Command File - -Create a new file in `cmd/wsh/cmd/` named `wshcmd-[commandname].go`: - -```go -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var myCommandCmd = &cobra.Command{ - Use: "mycommand [args]", - Short: "Brief description of what this command does", - Long: `Detailed description of the command. -Can include multiple lines and examples of usage.`, - RunE: myCommandRun, - PreRunE: preRunSetupRpcClient, // Include if command needs RPC - DisableFlagsInUseLine: true, -} - -// Flag variables -var ( - myCommandFlagExample string - myCommandFlagVerbose bool -) - -func init() { - // Add command to root - rootCmd.AddCommand(myCommandCmd) - - // Define flags - myCommandCmd.Flags().StringVarP(&myCommandFlagExample, "example", "e", "", "example flag description") - myCommandCmd.Flags().BoolVarP(&myCommandFlagVerbose, "verbose", "v", false, "enable verbose output") -} - -func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { - // Always track activity for telemetry - defer func() { - sendActivity("mycommand", rtnErr == nil) - }() - - // Validate arguments - if len(args) == 0 { - OutputHelpMessage(cmd) - return fmt.Errorf("requires at least one argument") - } - - // Command implementation - fmt.Printf("Command executed successfully\n") - return nil -} -``` - -**File Naming Convention:** -- Use `wshcmd-[commandname].go` format -- Use lowercase, hyphenated names for multi-word commands -- Examples: `wshcmd-getvar.go`, `wshcmd-setmeta.go`, `wshcmd-ai.go` - -### Step 2: Command Structure - -#### Basic Command Structure - -```go -var myCommandCmd = &cobra.Command{ - Use: "mycommand [required] [optional...]", - Short: "One-line description (shown in help)", - Long: `Detailed multi-line description`, - - // Argument validation - Args: cobra.MinimumNArgs(1), // Or cobra.ExactArgs(1), cobra.NoArgs, etc. - - // Execution function - RunE: myCommandRun, - - // Pre-execution setup (if needed) - PreRunE: preRunSetupRpcClient, // Sets up RPC client for backend communication - - // Example usage (optional) - Example: " wsh mycommand foo\n wsh mycommand --flag bar", - - // Disable flag notation in usage line - DisableFlagsInUseLine: true, -} -``` - -**Key Fields:** -- `Use`: Command name and argument pattern -- `Short`: Brief description for command list -- `Long`: Detailed description shown in help -- `Args`: Argument validator (optional) -- `RunE`: Main execution function (returns error) -- `PreRunE`: Setup function that runs before `RunE` -- `Example`: Usage examples (optional) -- `DisableFlagsInUseLine`: Clean up help display - -#### When to Use PreRunE - -Include `PreRunE: preRunSetupRpcClient` if your command: -- Communicates with the Wave Terminal backend -- Needs access to `RpcClient` -- Requires JWT authentication (WAVETERM_JWT env var) -- Makes RPC calls via `wshclient.*Command()` functions - -**Don't include PreRunE** for commands that: -- Only manipulate local state -- Don't need backend communication -- Are purely informational/local operations - -### Step 3: Implement Command Logic - -#### Command Function Pattern - -```go -func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { - // Step 1: Always track activity (for telemetry) - defer func() { - sendActivity("mycommand", rtnErr == nil) - }() - - // Step 2: Validate arguments and flags - if len(args) != 1 { - OutputHelpMessage(cmd) - return fmt.Errorf("requires exactly one argument") - } - - // Step 3: Parse/prepare data - targetArg := args[0] - - // Step 4: Make RPC call if needed - result, err := wshclient.SomeCommand(RpcClient, wshrpc.CommandSomeData{ - Field: targetArg, - }, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("executing command: %w", err) - } - - // Step 5: Output results - fmt.Printf("Result: %s\n", result) - return nil -} -``` - -**Important Patterns:** - -1. **Activity Tracking**: Always include deferred `sendActivity()` call - ```go - defer func() { - sendActivity("commandname", rtnErr == nil) - }() - ``` - -2. **Error Handling**: Return errors, don't call `os.Exit()` - ```go - if err != nil { - return fmt.Errorf("context: %w", err) - } - ``` - -3. **Output**: Use standard `fmt` package for output - ```go - fmt.Printf("Success message\n") - fmt.Fprintf(os.Stderr, "Error message\n") - ``` - -4. **Help Messages**: Show help when arguments are invalid - ```go - if len(args) == 0 { - OutputHelpMessage(cmd) - return fmt.Errorf("requires arguments") - } - ``` - -5. **Exit Codes**: Set custom exit code via `WshExitCode` - ```go - if notFound { - WshExitCode = 1 - return nil // Don't return error, just set exit code - } - ``` - -### Step 4: Define Flags - -Add flags in the `init()` function: - -```go -var ( - // Declare flag variables at package level - myCommandFlagString string - myCommandFlagBool bool - myCommandFlagInt int -) - -func init() { - rootCmd.AddCommand(myCommandCmd) - - // String flag with short version - myCommandCmd.Flags().StringVarP(&myCommandFlagString, "name", "n", "default", "description") - - // Boolean flag - myCommandCmd.Flags().BoolVarP(&myCommandFlagBool, "verbose", "v", false, "enable verbose") - - // Integer flag - myCommandCmd.Flags().IntVar(&myCommandFlagInt, "count", 10, "set count") - - // Flag without short version - myCommandCmd.Flags().StringVar(&myCommandFlagString, "longname", "", "description") -} -``` - -**Flag Types:** -- `StringVar/StringVarP` - String values -- `BoolVar/BoolVarP` - Boolean flags -- `IntVar/IntVarP` - Integer values -- The `P` suffix versions include a short flag name - -**Flag Naming:** -- Use camelCase for variable names: `myCommandFlagName` -- Use kebab-case for flag names: `--flag-name` -- Prefix variable names with command name for clarity - -### Step 5: Working with Block Arguments - -Many commands operate on blocks. Use the standard block resolution pattern: - -```go -func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("mycommand", rtnErr == nil) - }() - - // Resolve block using the -b/--block flag - fullORef, err := resolveBlockArg() - if err != nil { - return err - } - - // Use the blockid in RPC call - err = wshclient.SomeCommand(RpcClient, wshrpc.CommandSomeData{ - BlockId: fullORef.OID, - }, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("command failed: %w", err) - } - - return nil -} -``` - -**Block Resolution:** -- The `-b/--block` flag is defined globally in `wshcmd-root.go` -- `resolveBlockArg()` resolves the block argument to a full ORef -- Supports: `this`, `tab`, full UUIDs, 8-char prefixes, block numbers -- Default is `"this"` (current block) - -**Alternative: Manual Block Resolution** - -```go -// Get tab ID from environment -tabId := os.Getenv("WAVETERM_TABID") -if tabId == "" { - return fmt.Errorf("WAVETERM_TABID not set") -} - -// Create route for tab-level operations -route := wshutil.MakeTabRouteId(tabId) - -// Use route in RPC call -err := wshclient.SomeCommand(RpcClient, commandData, &wshrpc.RpcOpts{ - Route: route, - Timeout: 2000, -}) -``` - -### Step 6: Making RPC Calls - -Use the `wshclient` package to make RPC calls: - -```go -import ( - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -// Simple RPC call -result, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ - ORef: *fullORef, -}, &wshrpc.RpcOpts{Timeout: 2000}) -if err != nil { - return fmt.Errorf("getting metadata: %w", err) -} - -// RPC call with routing -err := wshclient.SetMetaCommand(RpcClient, wshrpc.CommandSetMetaData{ - ORef: *fullORef, - Meta: metaMap, -}, &wshrpc.RpcOpts{ - Route: route, - Timeout: 5000, -}) -if err != nil { - return fmt.Errorf("setting metadata: %w", err) -} -``` - -**RPC Options:** -- `Timeout`: Request timeout in milliseconds (typically 2000-5000) -- `Route`: Route ID for targeting specific components -- Available routes: `wshutil.ControlRoute`, `wshutil.MakeTabRouteId(tabId)` - -### Step 7: Add Documentation - -Add your command to `docs/docs/wsh-reference.mdx`: - -````markdown -## mycommand - -Brief description of what the command does. - -```sh -wsh mycommand [args] [flags] -``` - -Detailed explanation of the command's purpose and behavior. - -Flags: -- `-n, --name ` - description of this flag -- `-v, --verbose` - enable verbose output -- `-b, --block ` - specify target block (default: current block) - -Examples: - -```sh -# Basic usage -wsh mycommand arg1 - -# With flags -wsh mycommand --name value arg1 - -# With block targeting -wsh mycommand -b 2 arg1 - -# Complex example -wsh mycommand -v --name "example" arg1 arg2 -``` - -Additional notes, tips, or warnings about the command. - ---- -```` - -**Documentation Guidelines:** -- Place in alphabetical order with other commands -- Include command signature with argument pattern -- List all flags with short and long versions -- Provide practical examples (at least 3-5) -- Explain common use cases and patterns -- Add tips or warnings if relevant -- Use `---` separator between commands - -### Step 8: Test Your Command - -Build and test the command: - -```bash -# Build wsh -task build:wsh - -# Or build everything -task build - -# Test the command -./bin/wsh/wsh mycommand --help -./bin/wsh/wsh mycommand arg1 arg2 -``` - -**Testing Checklist:** -- [ ] Help message displays correctly -- [ ] Required arguments validated -- [ ] Flags work as expected -- [ ] Error messages are clear -- [ ] Success cases work correctly -- [ ] RPC calls complete successfully -- [ ] Output is formatted correctly - -## Complete Examples - -### Example 1: Simple Command with No RPC - -**Use case:** A command that prints Wave Terminal version info - -#### Command File (`cmd/wsh/cmd/wshcmd-version.go`) - -```go -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wavebase" -) - -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print Wave Terminal version", - RunE: versionRun, -} - -func init() { - rootCmd.AddCommand(versionCmd) -} - -func versionRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("version", rtnErr == nil) - }() - - fmt.Printf("Wave Terminal %s\n", wavebase.WaveVersion) - return nil -} -``` - -#### Documentation - -````markdown -## version - -Print the current Wave Terminal version. - -```sh -wsh version -``` - -Examples: - -```sh -# Print version -wsh version -``` -```` - -### Example 2: Command with Flags and RPC - -**Use case:** A command to update block title - -#### Command File (`cmd/wsh/cmd/wshcmd-settitle.go`) - -```go -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var setTitleCmd = &cobra.Command{ - Use: "settitle [title]", - Short: "Set block title", - Long: `Set the title for the current or specified block.`, - Args: cobra.ExactArgs(1), - RunE: setTitleRun, - PreRunE: preRunSetupRpcClient, - DisableFlagsInUseLine: true, -} - -var setTitleIcon string - -func init() { - rootCmd.AddCommand(setTitleCmd) - setTitleCmd.Flags().StringVarP(&setTitleIcon, "icon", "i", "", "set block icon") -} - -func setTitleRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("settitle", rtnErr == nil) - }() - - title := args[0] - - // Resolve block - fullORef, err := resolveBlockArg() - if err != nil { - return err - } - - // Build metadata map - meta := make(map[string]interface{}) - meta["title"] = title - if setTitleIcon != "" { - meta["icon"] = setTitleIcon - } - - // Make RPC call - err = wshclient.SetMetaCommand(RpcClient, wshrpc.CommandSetMetaData{ - ORef: *fullORef, - Meta: meta, - }, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("setting title: %w", err) - } - - fmt.Printf("title updated\n") - return nil -} -``` - -#### Documentation - -````markdown -## settitle - -Set the title for a block. - -```sh -wsh settitle [title] -``` - -Update the display title for the current or specified block. Optionally set an icon as well. - -Flags: -- `-i, --icon ` - set block icon along with title -- `-b, --block ` - specify target block (default: current block) - -Examples: - -```sh -# Set title for current block -wsh settitle "My Terminal" - -# Set title and icon -wsh settitle --icon "terminal" "Development Shell" - -# Set title for specific block -wsh settitle -b 2 "Build Output" -``` -```` - -### Example 3: Subcommands - -**Use case:** Command with multiple subcommands (like `wsh conn`) - -#### Command File (`cmd/wsh/cmd/wshcmd-mygroup.go`) - -```go -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var myGroupCmd = &cobra.Command{ - Use: "mygroup", - Short: "Manage something", -} - -var myGroupListCmd = &cobra.Command{ - Use: "list", - Short: "List items", - RunE: myGroupListRun, - PreRunE: preRunSetupRpcClient, -} - -var myGroupAddCmd = &cobra.Command{ - Use: "add [name]", - Short: "Add an item", - Args: cobra.ExactArgs(1), - RunE: myGroupAddRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - // Add parent command - rootCmd.AddCommand(myGroupCmd) - - // Add subcommands - myGroupCmd.AddCommand(myGroupListCmd) - myGroupCmd.AddCommand(myGroupAddCmd) -} - -func myGroupListRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("mygroup:list", rtnErr == nil) - }() - - // Implementation - fmt.Printf("Listing items...\n") - return nil -} - -func myGroupAddRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("mygroup:add", rtnErr == nil) - }() - - name := args[0] - fmt.Printf("Adding item: %s\n", name) - return nil -} -``` - -#### Documentation - -````markdown -## mygroup - -Manage something with subcommands. - -### list - -List all items. - -```sh -wsh mygroup list -``` - -### add - -Add a new item. - -```sh -wsh mygroup add [name] -``` - -Examples: - -```sh -# List items -wsh mygroup list - -# Add an item -wsh mygroup add "new-item" -``` -```` - -## Common Patterns - -### Reading from Stdin - -```go -import "io" - -func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("mycommand", rtnErr == nil) - }() - - // Check if reading from stdin (using "-" convention) - var data []byte - var err error - - if len(args) > 0 && args[0] == "-" { - data, err = io.ReadAll(os.Stdin) - if err != nil { - return fmt.Errorf("reading stdin: %w", err) - } - } else { - // Read from file or other source - data, err = os.ReadFile(args[0]) - if err != nil { - return fmt.Errorf("reading file: %w", err) - } - } - - // Process data - fmt.Printf("Read %d bytes\n", len(data)) - return nil -} -``` - -### JSON File Input - -```go -import ( - "encoding/json" - "io" -) - -func loadJSONFile(filepath string) (map[string]interface{}, error) { - var data []byte - var err error - - if filepath == "-" { - data, err = io.ReadAll(os.Stdin) - if err != nil { - return nil, fmt.Errorf("reading stdin: %w", err) - } - } else { - data, err = os.ReadFile(filepath) - if err != nil { - return nil, fmt.Errorf("reading file: %w", err) - } - } - - var result map[string]interface{} - if err := json.Unmarshal(data, &result); err != nil { - return nil, fmt.Errorf("parsing JSON: %w", err) - } - - return result, nil -} -``` - -### Conditional Output (TTY Detection) - -```go -func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("mycommand", rtnErr == nil) - }() - - isTty := getIsTty() - - // Output value - fmt.Printf("%s", value) - - // Add newline only if TTY (for better piping experience) - if isTty { - fmt.Printf("\n") - } - - return nil -} -``` - -### Environment Variable Access - -```go -func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("mycommand", rtnErr == nil) - }() - - // Get block ID from environment - blockId := os.Getenv("WAVETERM_BLOCKID") - if blockId == "" { - return fmt.Errorf("WAVETERM_BLOCKID not set") - } - - // Get tab ID from environment - tabId := os.Getenv("WAVETERM_TABID") - if tabId == "" { - return fmt.Errorf("WAVETERM_TABID not set") - } - - fmt.Printf("Block: %s, Tab: %s\n", blockId, tabId) - return nil -} -``` - -## Best Practices - -### Command Design - -1. **Single Responsibility**: Each command should do one thing well -2. **Composable**: Design commands to work with pipes and other commands -3. **Consistent**: Follow existing wsh command patterns and conventions -4. **Documented**: Provide clear help text and examples - -### Error Handling - -1. **Context**: Wrap errors with context using `fmt.Errorf("context: %w", err)` -2. **User-Friendly**: Make error messages clear and actionable -3. **No Panics**: Return errors instead of calling `os.Exit()` or `panic()` -4. **Exit Codes**: Use `WshExitCode` for custom exit codes - -### Output - -1. **Structured**: Use consistent formatting for output -2. **Quiet by Default**: Only output what's necessary -3. **Verbose Flag**: Optionally provide `-v` for detailed output -4. **Stderr for Errors**: Use `fmt.Fprintf(os.Stderr, ...)` for error messages - -### Flags - -1. **Short Versions**: Provide `-x` short versions for common flags -2. **Sensible Defaults**: Choose defaults that work for most users -3. **Boolean Flags**: Use for on/off options -4. **String Flags**: Use for values that need user input - -### RPC Calls - -1. **Timeouts**: Always specify reasonable timeouts -2. **Error Context**: Wrap RPC errors with operation context -3. **Retries**: Don't retry automatically; let user retry command -4. **Routes**: Use appropriate routes for different operations - -## Common Pitfalls - -### 1. Forgetting Activity Tracking - -**Problem**: Command usage not tracked in telemetry - -**Solution**: Always include deferred `sendActivity()` call: -```go -defer func() { - sendActivity("commandname", rtnErr == nil) -}() -``` - -### 2. Using os.Exit() Instead of Returning Error - -**Problem**: Breaks defer statements and cleanup - -**Solution**: Return errors from RunE function: -```go -// Bad -if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) -} - -// Good -if err != nil { - return fmt.Errorf("operation failed: %w", err) -} -``` - -### 3. Not Validating Arguments - -**Problem**: Command crashes with nil pointer or index out of range - -**Solution**: Validate arguments early and show help: -```go -if len(args) == 0 { - OutputHelpMessage(cmd) - return fmt.Errorf("requires at least one argument") -} -``` - -### 4. Forgetting to Add to init() - -**Problem**: Command not available when running wsh - -**Solution**: Always add command in `init()` function: -```go -func init() { - rootCmd.AddCommand(myCommandCmd) -} -``` - -### 5. Inconsistent Output - -**Problem**: Inconsistent use of output methods - -**Solution**: Use standard `fmt` package functions: -```go -// For stdout -fmt.Printf("output\n") - -// For stderr -fmt.Fprintf(os.Stderr, "error message\n") -``` - -## Quick Reference Checklist - -When adding a new wsh command: - -- [ ] Create `cmd/wsh/cmd/wshcmd-[commandname].go` -- [ ] Define command struct with Use, Short, Long descriptions -- [ ] Add `PreRunE: preRunSetupRpcClient` if using RPC -- [ ] Implement command function with activity tracking -- [ ] Add command to `rootCmd` in `init()` function -- [ ] Define flags in `init()` function if needed -- [ ] Add documentation to `docs/docs/wsh-reference.mdx` -- [ ] Build and test: `task build:wsh` -- [ ] Test help: `wsh [commandname] --help` -- [ ] Test all flag combinations -- [ ] Test error cases - -## Related Files - -- **Root Command**: `cmd/wsh/cmd/wshcmd-root.go` - Main command setup and utilities -- **RPC Client**: `pkg/wshrpc/wshclient/` - Client functions for RPC calls -- **RPC Types**: `pkg/wshrpc/wshrpctypes.go` - RPC request/response data structures -- **Documentation**: `docs/docs/wsh-reference.mdx` - User-facing command reference -- **Examples**: `cmd/wsh/cmd/wshcmd-*.go` - Existing command implementations diff --git a/.kilocode/skills/context-menu/SKILL.md b/.kilocode/skills/context-menu/SKILL.md deleted file mode 100644 index dda3b7b985..0000000000 --- a/.kilocode/skills/context-menu/SKILL.md +++ /dev/null @@ -1,160 +0,0 @@ ---- -name: context-menu -description: Guide for creating and displaying context menus in Wave Terminal. Use when implementing right-click menus, adding context menu items, creating submenus, or handling menu interactions with checkboxes and separators. ---- - -# Context Menu Quick Reference - -This guide provides a quick overview of how to create and display a context menu using our system. - ---- - -## ContextMenuItem Type - -Define each menu item using the `ContextMenuItem` type: - -```ts -type ContextMenuItem = { - label?: string; - type?: "separator" | "normal" | "submenu" | "checkbox" | "radio"; - role?: string; // Electron role (optional) - click?: () => void; // Callback for item selection (not needed if role is set) - submenu?: ContextMenuItem[]; // For nested menus - checked?: boolean; // For checkbox or radio items - visible?: boolean; - enabled?: boolean; - sublabel?: string; -}; -``` - ---- - -## Import and Show the Menu - -Import the context menu module: - -```ts -import { ContextMenuModel } from "@/app/store/contextmenu"; -``` - -To display the context menu, call: - -```ts -ContextMenuModel.getInstance().showContextMenu(menu, event); -``` - -- **menu**: An array of `ContextMenuItem`. -- **event**: The mouse event that triggered the context menu (typically from an onContextMenu handler). - ---- - -## Basic Example - -A simple context menu with a separator: - -```ts -const menu: ContextMenuItem[] = [ - { - label: "New File", - click: () => { - /* create a new file */ - }, - }, - { - label: "New Folder", - click: () => { - /* create a new folder */ - }, - }, - { type: "separator" }, - { - label: "Rename", - click: () => { - /* rename item */ - }, - }, -]; - -ContextMenuModel.getInstance().showContextMenu(menu, e); -``` - ---- - -## Example with Submenu and Checkboxes - -Toggle settings using a submenu with checkbox items: - -```ts -const isClearOnStart = true; // Example setting - -const menu: ContextMenuItem[] = [ - { - label: "Clear Output On Restart", - submenu: [ - { - label: "On", - type: "checkbox", - checked: isClearOnStart, - click: () => { - // Set the config to enable clear on restart - }, - }, - { - label: "Off", - type: "checkbox", - checked: !isClearOnStart, - click: () => { - // Set the config to disable clear on restart - }, - }, - ], - }, -]; - -ContextMenuModel.getInstance().showContextMenu(menu, e); -``` - ---- - -## Editing a Config File Example - -Open a configuration file (e.g., `widgets.json`) in preview mode: - -```ts -{ - label: "Edit widgets.json", - click: () => { - fireAndForget(async () => { - const path = `${getApi().getConfigDir()}/widgets.json`; - const blockDef: BlockDef = { - meta: { view: "preview", file: path }, - }; - await createBlock(blockDef, false, true); - }); - }, -} -``` - ---- - -## Summary - -- **Menu Definition**: Use the `ContextMenuItem` type. -- **Actions**: Use `click` for actions; use `submenu` for nested options. -- **Separators**: Use `type: "separator"` to group items. -- **Toggles**: Use `type: "checkbox"` or `"radio"` with the `checked` property. -- **Displaying**: Use `ContextMenuModel.getInstance().showContextMenu(menu, event)` to render the menu. - -## Common Use Cases - -### File/Folder Operations -Context menus are commonly used for file operations like creating, renaming, and deleting files or folders. - -### Settings Toggles -Use checkbox menu items to toggle settings on and off, with the `checked` property reflecting the current state. - -### Nested Options -Use `submenu` to organize related options hierarchically, keeping the top-level menu clean and organized. - -### Conditional Items -Use the `visible` and `enabled` properties to dynamically show or disable menu items based on the current state. diff --git a/.kilocode/skills/create-view/SKILL.md b/.kilocode/skills/create-view/SKILL.md deleted file mode 100644 index 49049ca9e5..0000000000 --- a/.kilocode/skills/create-view/SKILL.md +++ /dev/null @@ -1,520 +0,0 @@ ---- -name: create-view -description: Guide for implementing a new view type in Wave Terminal. Use when creating a new view component, implementing the ViewModel interface, registering a new view type in BlockRegistry, or adding a new content type to display within blocks. ---- - -# Creating a New View in Wave Terminal - -This guide explains how to implement a new view type in Wave Terminal. Views are the core content components displayed within blocks in the terminal interface. - -## Architecture Overview - -Wave Terminal uses a **Model-View architecture** where: - -- **ViewModel** - Contains all state, logic, and UI configuration as Jotai atoms -- **ViewComponent** - Pure React component that renders the UI using the model -- **BlockFrame** - Wraps views with a header, connection management, and standard controls - -The separation between model and component ensures: - -- Models can update state without React hooks -- Components remain pure and testable -- State is centralized in Jotai atoms for easy access - -## ViewModel Interface - -Every view must implement the `ViewModel` interface defined in `frontend/types/custom.d.ts`: - -```typescript -interface ViewModel { - // Required: The type identifier for this view (e.g., "term", "web", "preview") - viewType: string; - - // Required: The React component that renders this view - viewComponent: ViewComponent; - - // Optional: Icon shown in block header (FontAwesome icon name or IconButtonDecl) - viewIcon?: jotai.Atom; - - // Optional: Display name shown in block header (e.g., "Terminal", "Web", "Preview") - viewName?: jotai.Atom; - - // Optional: Additional header elements (text, buttons, inputs) shown after the name - viewText?: jotai.Atom; - - // Optional: Icon button shown before the view name in header - preIconButton?: jotai.Atom; - - // Optional: Icon buttons shown at the end of the header (before settings/close) - endIconButtons?: jotai.Atom; - - // Optional: Custom background styling for the block - blockBg?: jotai.Atom; - - // Optional: If true, completely hides the block header - noHeader?: jotai.Atom; - - // Optional: If true, shows connection picker in header for remote connections - manageConnection?: jotai.Atom; - - // Optional: If true, filters out 'nowsh' connections from connection picker - filterOutNowsh?: jotai.Atom; - - // Optional: If true, removes default padding from content area - noPadding?: jotai.Atom; - - // Optional: Atoms for managing in-block search functionality - searchAtoms?: SearchAtoms; - - // Optional: Returns whether this is a basic terminal (for multi-input feature) - isBasicTerm?: (getFn: jotai.Getter) => boolean; - - // Optional: Returns context menu items for the settings dropdown - getSettingsMenuItems?: () => ContextMenuItem[]; - - // Optional: Focuses the view when called, returns true if successful - giveFocus?: () => boolean; - - // Optional: Handles keyboard events, returns true if handled - keyDownHandler?: (e: WaveKeyboardEvent) => boolean; - - // Optional: Cleanup when block is closed - dispose?: () => void; -} -``` - -### Key Concepts - -**Atoms**: All UI-related properties must be Jotai atoms. This enables: - -- Reactive updates when state changes -- Access from anywhere via `globalStore.get()`/`globalStore.set()` -- Derived atoms that compute values from other atoms - -**ViewComponent**: The React component receives these props: - -```typescript -type ViewComponentProps = { - blockId: string; // Unique ID for this block - blockRef: React.RefObject; // Ref to block container - contentRef: React.RefObject; // Ref to content area - model: T; // Your ViewModel instance -}; -``` - -## Step-by-Step Guide - -### 1. Create the View Model Class - -Create a new file for your view model (e.g., `frontend/app/view/myview/myview-model.ts`): - -```typescript -import { BlockNodeModel } from "@/app/block/blocktypes"; -import { globalStore } from "@/app/store/jotaiStore"; -import { WOS, useBlockAtom } from "@/store/global"; -import * as jotai from "jotai"; -import { MyView } from "./myview"; - -export class MyViewModel implements ViewModel { - viewType: string; - blockId: string; - nodeModel: BlockNodeModel; - blockAtom: jotai.Atom; - - // Define your atoms (simple field initializers) - viewIcon = jotai.atom("circle"); - viewName = jotai.atom("My View"); - noPadding = jotai.atom(true); - - // Derived atom (created in constructor) - viewText!: jotai.Atom; - - constructor(blockId: string, nodeModel: BlockNodeModel) { - this.viewType = "myview"; - this.blockId = blockId; - this.nodeModel = nodeModel; - this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); - - // Create derived atoms that depend on block data or other atoms - this.viewText = jotai.atom((get) => { - const blockData = get(this.blockAtom); - const rtn: HeaderElem[] = []; - - // Add header buttons/text based on state - rtn.push({ - elemtype: "iconbutton", - icon: "refresh", - title: "Refresh", - click: () => this.refresh(), - }); - - return rtn; - }); - } - - get viewComponent(): ViewComponent { - return MyView; - } - - refresh() { - // Update state using globalStore - // Never use React hooks in model methods - console.log("refreshing..."); - } - - giveFocus(): boolean { - // Focus your view component - return true; - } - - dispose() { - // Cleanup resources (unsubscribe from events, etc.) - } -} -``` - -### 2. Create the View Component - -Create your React component (e.g., `frontend/app/view/myview/myview.tsx`): - -```typescript -import { ViewComponentProps } from "@/app/block/blocktypes"; -import { MyViewModel } from "./myview-model"; -import { useAtomValue } from "jotai"; -import "./myview.scss"; - -export const MyView: React.FC> = ({ - blockId, - model, - contentRef -}) => { - // Use atoms from the model (these are React hooks - call at top level!) - const blockData = useAtomValue(model.blockAtom); - - return ( -
-
Block ID: {blockId}
-
View: {model.viewType}
- {/* Your view content here */} -
- ); -}; -``` - -### 3. Register the View - -Add your view to the `BlockRegistry` in `frontend/app/block/blockregistry.ts`: - -```typescript -import { MyViewModel } from "@/app/view/myview/myview-model"; - -const BlockRegistry: Map = new Map(); -BlockRegistry.set("term", TermViewModel); -BlockRegistry.set("preview", PreviewModel); -BlockRegistry.set("web", WebViewModel); -// ... existing registrations ... -BlockRegistry.set("myview", MyViewModel); // Add your view here -``` - -The registry key (e.g., `"myview"`) becomes the view type used in block metadata. - -### 4. Create Blocks with Your View - -Users can create blocks with your view type: - -- Via CLI: `wsh view myview` -- Via RPC: Use the block's `meta.view` field set to `"myview"` - -## Real-World Examples - -### Example 1: Terminal View (`term-model.ts`) - -The terminal view demonstrates: - -- **Connection management** via `manageConnection` atom -- **Dynamic header buttons** showing shell status (play/restart) -- **Mode switching** between terminal and vdom views -- **Custom keyboard handling** for terminal-specific shortcuts -- **Focus management** to focus the xterm.js instance -- **Shell integration status** showing AI capability indicators - -Key features: - -```typescript -this.manageConnection = jotai.atom((get) => { - const termMode = get(this.termMode); - if (termMode == "vdom") return false; - return true; // Show connection picker for regular terminal mode -}); - -this.endIconButtons = jotai.atom((get) => { - const shellProcStatus = get(this.shellProcStatus); - const buttons: IconButtonDecl[] = []; - - if (shellProcStatus == "running") { - buttons.push({ - elemtype: "iconbutton", - icon: "refresh", - title: "Restart Shell", - click: this.forceRestartController.bind(this), - }); - } - return buttons; -}); -``` - -### Example 2: Web View (`webview.tsx`) - -The web view shows: - -- **Complex header controls** (back/forward/home/URL input) -- **State management** for loading, URL, and navigation -- **Event handling** for webview navigation events -- **Custom styling** with `noPadding` for full-bleed content -- **Media controls** showing play/pause/mute when media is active - -Key features: - -```typescript -this.viewText = jotai.atom((get) => { - const url = get(this.url); - const rtn: HeaderElem[] = []; - - // Navigation buttons - rtn.push({ - elemtype: "iconbutton", - icon: "chevron-left", - click: this.handleBack.bind(this), - disabled: this.shouldDisableBackButton(), - }); - - // URL input with nested controls - rtn.push({ - elemtype: "div", - className: "block-frame-div-url", - children: [ - { - elemtype: "input", - value: url, - onChange: this.handleUrlChange.bind(this), - onKeyDown: this.handleKeyDown.bind(this), - }, - { - elemtype: "iconbutton", - icon: "rotate-right", - click: this.handleRefresh.bind(this), - }, - ], - }); - - return rtn; -}); -``` - -## Header Elements (`HeaderElem`) - -The `viewText` atom can return an array of these element types: - -```typescript -// Icon button -{ - elemtype: "iconbutton", - icon: "refresh", - title: "Tooltip text", - click: () => { /* handler */ }, - disabled?: boolean, - iconColor?: string, - iconSpin?: boolean, - noAction?: boolean, // Shows icon but no click action -} - -// Text element -{ - elemtype: "text", - text: "Display text", - className?: string, - noGrow?: boolean, - ref?: React.RefObject, - onClick?: (e: React.MouseEvent) => void, -} - -// Text button -{ - elemtype: "textbutton", - text: "Button text", - className?: string, - title: "Tooltip", - onClick: (e: React.MouseEvent) => void, -} - -// Input field -{ - elemtype: "input", - value: string, - className?: string, - onChange: (e: React.ChangeEvent) => void, - onKeyDown?: (e: React.KeyboardEvent) => void, - onFocus?: (e: React.FocusEvent) => void, - onBlur?: (e: React.FocusEvent) => void, - ref?: React.RefObject, -} - -// Container with children -{ - elemtype: "div", - className?: string, - children: HeaderElem[], - onMouseOver?: (e: React.MouseEvent) => void, - onMouseOut?: (e: React.MouseEvent) => void, -} - -// Menu button (dropdown) -{ - elemtype: "menubutton", - // ... MenuButtonProps ... -} -``` - -## Best Practices - -### Jotai Model Pattern - -Follow these rules for Jotai atoms in models: - -1. **Simple atoms as field initializers**: - - ```typescript - viewIcon = jotai.atom("circle"); - noPadding = jotai.atom(true); - ``` - -2. **Derived atoms in constructor** (need dependency on other atoms): - - ```typescript - constructor(blockId: string, nodeModel: BlockNodeModel) { - this.viewText = jotai.atom((get) => { - const blockData = get(this.blockAtom); - return [/* computed based on blockData */]; - }); - } - ``` - -3. **Models never use React hooks** - Use `globalStore.get()`/`set()`: - - ```typescript - refresh() { - const currentData = globalStore.get(this.blockAtom); - globalStore.set(this.dataAtom, newData); - } - ``` - -4. **Components use hooks for atoms**: - ```typescript - const data = useAtomValue(model.dataAtom); - const [value, setValue] = useAtom(model.valueAtom); - ``` - -### State Management - -- All view state should live in atoms on the model -- Use `useBlockAtom()` helper for block-scoped atoms that persist -- Use `globalStore` for imperative access outside React components -- Subscribe to Wave events using `waveEventSubscribe()` - -### Styling - -- Create a `.scss` file for your view styles -- Use Tailwind utilities where possible (v4) -- Add `noPadding: atom(true)` for full-bleed content -- Use `blockBg` atom to customize block background - -### Focus Management - -Implement `giveFocus()` to focus your view when: - -- Block gains focus via keyboard navigation -- User clicks the block -- Return `true` if successfully focused, `false` otherwise - -### Keyboard Handling - -Implement `keyDownHandler(e: WaveKeyboardEvent)` for: - -- View-specific keyboard shortcuts -- Return `true` if event was handled (prevents propagation) -- Use `keyutil.checkKeyPressed(waveEvent, "Cmd:K")` for shortcut checks - -### Cleanup - -Implement `dispose()` to: - -- Unsubscribe from Wave events -- Unregister routes/handlers -- Clear timers/intervals -- Release resources - -### Connection Management - -For views that need remote connections: - -```typescript -this.manageConnection = jotai.atom(true); // Show connection picker -this.filterOutNowsh = jotai.atom(true); // Hide nowsh connections -``` - -Access connection status: - -```typescript -const connStatus = jotai.atom((get) => { - const blockData = get(this.blockAtom); - const connName = blockData?.meta?.connection; - return get(getConnStatusAtom(connName)); -}); -``` - -## Common Patterns - -### Reading Block Metadata - -```typescript -import { getBlockMetaKeyAtom } from "@/store/global"; - -// In constructor: -this.someFlag = getBlockMetaKeyAtom(blockId, "myview:flag"); - -// In component: -const flag = useAtomValue(model.someFlag); -``` - -### Configuration Overrides - -Wave has a hierarchical config system (global → connection → block): - -```typescript -import { getOverrideConfigAtom } from "@/store/global"; - -this.settingAtom = jotai.atom((get) => { - // Checks block meta, then connection config, then global settings - return get(getOverrideConfigAtom(this.blockId, "myview:setting")) ?? defaultValue; -}); -``` - -### Updating Block Metadata - -```typescript -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { WOS } from "@/store/global"; - -await RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), - meta: { "myview:key": value }, -}); -``` - -## Additional Resources - -- `frontend/app/block/blockframe-header.tsx` - Block header rendering -- `frontend/app/view/term/term-model.ts` - Complex view example -- `frontend/app/view/webview/webview.tsx` - Navigation UI example -- `frontend/types/custom.d.ts` - Type definitions diff --git a/.kilocode/skills/electron-api/SKILL.md b/.kilocode/skills/electron-api/SKILL.md deleted file mode 100644 index 0014e82a50..0000000000 --- a/.kilocode/skills/electron-api/SKILL.md +++ /dev/null @@ -1,182 +0,0 @@ ---- -name: electron-api -description: Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC. ---- - -# Adding Electron APIs - -Electron APIs allow the frontend to call Electron main process functionality directly via IPC. - -## Four Files to Edit - -1. [`frontend/types/custom.d.ts`](frontend/types/custom.d.ts) - TypeScript [`ElectronApi`](frontend/types/custom.d.ts:82) type -2. [`emain/preload.ts`](emain/preload.ts) - Expose method via `contextBridge` -3. [`emain/emain-ipc.ts`](emain/emain-ipc.ts) - Implement IPC handler -4. [`frontend/preview/preview-electron-api.ts`](frontend/preview/preview-electron-api.ts) - Add a no-op stub to keep the `previewElectronApi` object in sync with the `ElectronApi` type - -## Three Communication Patterns - -1. **Sync** - `ipcRenderer.sendSync()` + `ipcMain.on()` + `event.returnValue = ...` -2. **Async** - `ipcRenderer.invoke()` + `ipcMain.handle()` -3. **Fire-and-forget** - `ipcRenderer.send()` + `ipcMain.on()` - -## Example: Async Method - -### 1. Define TypeScript Interface - -In [`frontend/types/custom.d.ts`](frontend/types/custom.d.ts): - -```typescript -type ElectronApi = { - captureScreenshot: (rect: Electron.Rectangle) => Promise; // capture-screenshot -}; -``` - -### 2. Expose in Preload - -In [`emain/preload.ts`](emain/preload.ts): - -```typescript -contextBridge.exposeInMainWorld("api", { - captureScreenshot: (rect: Rectangle) => ipcRenderer.invoke("capture-screenshot", rect), -}); -``` - -### 3. Implement Handler - -In [`emain/emain-ipc.ts`](emain/emain-ipc.ts): - -```typescript -electron.ipcMain.handle("capture-screenshot", async (event, rect) => { - const tabView = getWaveTabViewByWebContentsId(event.sender.id); - if (!tabView) throw new Error("No tab view found"); - const image = await tabView.webContents.capturePage(rect); - return `data:image/png;base64,${image.toPNG().toString("base64")}`; -}); -``` - -### 4. Add Preview Stub - -In [`frontend/preview/preview-electron-api.ts`](frontend/preview/preview-electron-api.ts): - -```typescript -captureScreenshot: (_rect: Electron.Rectangle) => Promise.resolve(""), -``` - -### 5. Call from Frontend - -```typescript -import { getApi } from "@/store/global"; - -const dataUrl = await getApi().captureScreenshot({ x: 0, y: 0, width: 800, height: 600 }); -``` - -## Example: Sync Method - -### 1. Define - -```typescript -type ElectronApi = { - getUserName: () => string; // get-user-name -}; -``` - -### 2. Preload - -```typescript -getUserName: () => ipcRenderer.sendSync("get-user-name"), -``` - -### 3. Handler (âš ī¸ MUST set event.returnValue or browser hangs) - -```typescript -electron.ipcMain.on("get-user-name", (event) => { - event.returnValue = process.env.USER || "unknown"; -}); -``` - -### 4. Call - -```typescript -import { getApi } from "@/store/global"; - -const userName = getApi().getUserName(); // blocks until returns -``` - -## Example: Fire-and-Forget - -### 1. Define - -```typescript -type ElectronApi = { - openExternal: (url: string) => void; // open-external -}; -``` - -### 2. Preload - -```typescript -openExternal: (url) => ipcRenderer.send("open-external", url), -``` - -### 3. Handler - -```typescript -electron.ipcMain.on("open-external", (event, url) => { - electron.shell.openExternal(url); -}); -``` - -## Example: Event Listener - -### 1. Define - -```typescript -type ElectronApi = { - onZoomFactorChange: (callback: (zoomFactor: number) => void) => void; // zoom-factor-change -}; -``` - -### 2. Preload - -```typescript -onZoomFactorChange: (callback) => - ipcRenderer.on("zoom-factor-change", (_event, zoomFactor) => callback(zoomFactor)), -``` - -### 3. Send from Main - -```typescript -webContents.send("zoom-factor-change", newZoomFactor); -``` - -## Quick Reference - -**Use Sync when:** -- Getting config/env vars -- Quick lookups, no I/O -- âš ī¸ **CRITICAL**: Always set `event.returnValue` or browser hangs - -**Use Async when:** -- File operations -- Network requests -- Can fail or take time - -**Use Fire-and-forget when:** -- No return value needed -- Triggering actions - -**Electron API vs RPC:** -- Electron API: Native OS features, window management, Electron APIs -- RPC: Database, backend logic, remote servers - -## Checklist - -- [ ] Add to [`ElectronApi`](frontend/types/custom.d.ts:82) in [`custom.d.ts`](frontend/types/custom.d.ts) -- [ ] Include IPC channel name in comment -- [ ] Expose in [`preload.ts`](emain/preload.ts) -- [ ] Implement in [`emain-ipc.ts`](emain/emain-ipc.ts) -- [ ] Add no-op stub to [`preview-electron-api.ts`](frontend/preview/preview-electron-api.ts) -- [ ] IPC channel names match exactly -- [ ] **For sync**: Set `event.returnValue` (or browser hangs!) -- [ ] Test end-to-end diff --git a/.kilocode/skills/waveenv/SKILL.md b/.kilocode/skills/waveenv/SKILL.md deleted file mode 100644 index c5d56af4f1..0000000000 --- a/.kilocode/skills/waveenv/SKILL.md +++ /dev/null @@ -1,133 +0,0 @@ ---- -name: waveenv -description: Guide for creating WaveEnv narrowings in Wave Terminal. Use when writing a named subset type of WaveEnv for a component tree, documenting environmental dependencies, or enabling mock environments for preview/test server usage. ---- - -# WaveEnv Narrowing Skill - -## Purpose - -A WaveEnv narrowing creates a _named subset type_ of `WaveEnv` that: - -1. Documents exactly which parts of the environment a component tree actually uses. -2. Forms a type contract so callers and tests know what to provide. -3. Enables mocking in the preview/test server — you only need to implement what's listed. - -## When To Create One - -Create a narrowing whenever you are writing a component (or group of components) that you want to test in the preview server, or when you want to make the environmental dependencies of a component tree explicit. - -## Core Principle: Only Include What You Use - -**Only list the fields, methods, atoms, and keys that the component tree actually accesses.** If you don't call `wos`, don't include `wos`. If you only call one RPC command, only list that one command. The narrowing is a precise dependency declaration — not a copy of `WaveEnv`. - -## File Location - -- **Separate file** (preferred for shared/complex envs): name it `env.ts` next to the component, e.g. `frontend/app/block/blockenv.ts`. -- **Inline** (acceptable for small, single-file components): export the type directly from the component file, e.g. `WidgetsEnv` in `frontend/app/workspace/widgets.tsx`. - -## Imports Required - -```ts -import { - MetaKeyAtomFnType, // only if you use getBlockMetaKeyAtom or getTabMetaKeyAtom - ConnConfigKeyAtomFnType, // only if you use getConnConfigKeyAtom - SettingsKeyAtomFnType, // only if you use getSettingsKeyAtom - WaveEnv, - WaveEnvSubset, -} from "@/app/waveenv/waveenv"; -``` - -## The Shape - -```ts -export type MyEnv = WaveEnvSubset<{ - // --- Simple WaveEnv properties --- - // Copy the type verbatim from WaveEnv with WaveEnv["key"] syntax. - isDev: WaveEnv["isDev"]; - createBlock: WaveEnv["createBlock"]; - showContextMenu: WaveEnv["showContextMenu"]; - platform: WaveEnv["platform"]; - - // --- electron: list only the methods you call --- - electron: { - openExternal: WaveEnv["electron"]["openExternal"]; - }; - - // --- rpc: list only the commands you call --- - rpc: { - ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; - ConnEnsureCommand: WaveEnv["rpc"]["ConnEnsureCommand"]; - }; - - // --- atoms: list only the atoms you read --- - atoms: { - modalOpen: WaveEnv["atoms"]["modalOpen"]; - fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; - }; - - // --- wos: always take the whole thing, no sub-typing needed --- - wos: WaveEnv["wos"]; - - // --- services: list only the services you call; no method-level narrowing --- - services: { - block: WaveEnv["services"]["block"]; - workspace: WaveEnv["services"]["workspace"]; - }; - - // --- key-parameterized atom factories: enumerate the keys you use --- - getSettingsKeyAtom: SettingsKeyAtomFnType<"app:focusfollowscursor" | "window:magnifiedblockopacity">; - getBlockMetaKeyAtom: MetaKeyAtomFnType<"view" | "frame:title" | "connection">; - getTabMetaKeyAtom: MetaKeyAtomFnType<"tabid" | "name">; - getConnConfigKeyAtom: ConnConfigKeyAtomFnType<"conn:wshenabled">; - - // --- other atom helpers: copy verbatim --- - getConnStatusAtom: WaveEnv["getConnStatusAtom"]; - getLocalHostDisplayNameAtom: WaveEnv["getLocalHostDisplayNameAtom"]; - getConfigBackgroundAtom: WaveEnv["getConfigBackgroundAtom"]; -}>; -``` - -### Automatically Included Fields - -Every `WaveEnvSubset` automatically includes the mock fields — you never need to declare them: - -- `isMock: boolean` -- `mockSetWaveObj: (oref: string, obj: T) => void` -- `mockModels?: Map` - -### Rules for Each Section - -| Section | Pattern | Notes | -| -------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | -| `electron` | `electron: { method: WaveEnv["electron"]["method"]; }` | List every method called; omit the rest. | -| `rpc` | `rpc: { Cmd: WaveEnv["rpc"]["Cmd"]; }` | List every RPC command called; omit the rest. | -| `atoms` | `atoms: { atom: WaveEnv["atoms"]["atom"]; }` | List every atom read; omit the rest. | -| `wos` | `wos: WaveEnv["wos"]` | Take the whole `wos` object (no sub-typing needed), but **only add it if `wos` is actually used**. | -| `services` | `services: { svc: WaveEnv["services"]["svc"]; }` | List each service used; take the whole service object (no method-level narrowing). | -| `getSettingsKeyAtom` | `SettingsKeyAtomFnType<"key1" \| "key2">` | Union all settings keys accessed. | -| `getBlockMetaKeyAtom` | `MetaKeyAtomFnType<"key1" \| "key2">` | Union all block meta keys accessed. | -| `getTabMetaKeyAtom` | `MetaKeyAtomFnType<"key1" \| "key2">` | Union all tab meta keys accessed. | -| `getConnConfigKeyAtom` | `ConnConfigKeyAtomFnType<"key1">` | Union all conn config keys accessed. | -| All other `WaveEnv` fields | `WaveEnv["fieldName"]` | Copy type verbatim. | - -## Using the Narrowed Type in Components - -```ts -import { useWaveEnv } from "@/app/waveenv/waveenv"; -import { MyEnv } from "./myenv"; - -const MyComponent = memo(() => { - const env = useWaveEnv(); - // TypeScript now enforces you only access what's in MyEnv. - const val = useAtomValue(env.getSettingsKeyAtom("app:focusfollowscursor")); - ... -}); -``` - -The generic parameter on `useWaveEnv()` casts the context to your narrowed type. The real production `WaveEnv` satisfies every narrowing; mock envs only need to implement the listed subset. - -## Real Examples - -- `BlockEnv` in `frontend/app/block/blockenv.ts` — complex narrowing with all section types, in a separate file. -- `WidgetsEnv` in `frontend/app/workspace/widgets.tsx` — smaller narrowing defined inline in the component file. diff --git a/.kilocode/skills/wps-events/SKILL.md b/.kilocode/skills/wps-events/SKILL.md deleted file mode 100644 index 4bc6be717a..0000000000 --- a/.kilocode/skills/wps-events/SKILL.md +++ /dev/null @@ -1,339 +0,0 @@ ---- -name: wps-events -description: Guide for working with Wave Terminal's WPS (Wave PubSub) event system. Use when implementing new event types, publishing events, subscribing to events, or adding asynchronous communication between components. ---- - -# WPS Events Guide - -## Overview - -WPS (Wave PubSub) is Wave Terminal's publish-subscribe event system that enables different parts of the application to communicate asynchronously. The system uses a broker pattern to route events from publishers to subscribers based on event types and scopes. - -## Key Files - -- `pkg/wps/wpstypes.go` - Event type constants and data structures -- `pkg/wps/wps.go` - Broker implementation and core logic -- `pkg/wcore/wcore.go` - Example usage patterns - -## Event Structure - -Events in WPS have the following structure: - -```go -type WaveEvent struct { - Event string `json:"event"` // Event type constant - Scopes []string `json:"scopes,omitempty"` // Optional scopes for targeted delivery - Sender string `json:"sender,omitempty"` // Optional sender identifier - Persist int `json:"persist,omitempty"` // Number of events to persist in history - Data any `json:"data,omitempty"` // Event payload -} -``` - -## Adding a New Event Type - -### Step 1: Define the Event Constant - -Add your event type constant to `pkg/wps/wpstypes.go`: - -```go -const ( - Event_BlockClose = "blockclose" - Event_ConnChange = "connchange" - // ... other events ... - Event_YourNewEvent = "your:newevent" // type: YourEventData (or "none" if no data) -) -``` - -**Naming Convention:** - -- Use descriptive PascalCase for the constant name with `Event_` prefix -- Use lowercase with colons for the string value (e.g., "namespace:eventname") -- Group related events with the same namespace prefix -- Always add a `// type: ` comment; use `// type: none` if no data is sent - -### Step 2: Add to AllEvents - -Add your new constant to the `AllEvents` slice in `pkg/wps/wpstypes.go`: - -```go -var AllEvents []string = []string{ - // ... existing events ... - Event_YourNewEvent, -} -``` - -### Step 3: Register in WaveEventDataTypes (REQUIRED) - -You **must** add an entry to `WaveEventDataTypes` in `pkg/tsgen/tsgenevent.go`. This drives TypeScript type generation for the event's `data` field: - -```go -var WaveEventDataTypes = map[string]reflect.Type{ - // ... existing entries ... - wps.Event_YourNewEvent: reflect.TypeOf(YourEventData{}), // value type - // wps.Event_YourNewEvent: reflect.TypeOf((*YourEventData)(nil)), // pointer type - // wps.Event_YourNewEvent: nil, // no data (type: none) -} -``` - -- Use `reflect.TypeOf(YourType{})` for value types -- Use `reflect.TypeOf((*YourType)(nil))` for pointer types -- Use `nil` if no data is sent for the event - -### Step 4: Define Event Data Structure (Optional) - -If your event carries structured data, define a type for it: - -```go -type YourEventData struct { - Field1 string `json:"field1"` - Field2 int `json:"field2"` -} -``` - -### Step 5: Expose Type to Frontend (If Needed) - -If your event data type isn't already exposed via an RPC call, you need to add it to `pkg/tsgen/tsgen.go` so TypeScript types are generated: - -```go -// add extra types to generate here -var ExtraTypes = []any{ - waveobj.ORef{}, - // ... other types ... - uctypes.RateLimitInfo{}, // Example: already added - YourEventData{}, // Add your new type here -} -``` - -Then run code generation: - -```bash -task generate -``` - -This will update `frontend/types/gotypes.d.ts` with TypeScript definitions for your type, ensuring type safety in the frontend when handling these events. - -## Publishing Events - -### Basic Publishing - -To publish an event, use the global broker: - -```go -import "github.com/wavetermdev/waveterm/pkg/wps" - -wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_YourNewEvent, - Data: yourData, -}) -``` - -### Publishing with Scopes - -Scopes allow targeted event delivery. Subscribers can filter events by scope: - -```go -wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_WaveObjUpdate, - Scopes: []string{oref.String()}, // Target specific object - Data: updateData, -}) -``` - -### Publishing in a Goroutine - -To avoid blocking the caller, publish events asynchronously: - -```go -go func() { - wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_YourNewEvent, - Data: data, - }) -}() -``` - -**When to use goroutines:** - -- When publishing from performance-critical code paths -- When the event is informational and doesn't need immediate delivery -- When publishing from code that holds locks (to prevent deadlocks) - -### Event Persistence - -Events can be persisted in memory for late subscribers: - -```go -wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_YourNewEvent, - Persist: 100, // Keep last 100 events - Data: data, -}) -``` - -## Complete Example: Rate Limit Updates - -This example shows how rate limit information is published when AI chat responses include rate limit headers. - -### 1. Define the Event Type - -In `pkg/wps/wpstypes.go`: - -```go -const ( - // ... other events ... - Event_WaveAIRateLimit = "waveai:ratelimit" -) -``` - -### 2. Publish the Event - -In `pkg/aiusechat/usechat.go`: - -```go -import "github.com/wavetermdev/waveterm/pkg/wps" - -func updateRateLimit(info *uctypes.RateLimitInfo) { - if info == nil { - return - } - rateLimitLock.Lock() - defer rateLimitLock.Unlock() - globalRateLimitInfo = info - - // Publish event in goroutine to avoid blocking - go func() { - wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_WaveAIRateLimit, - Data: info, // RateLimitInfo struct - }) - }() -} -``` - -### 3. Subscribe to the Event (Frontend) - -In the frontend, subscribe to events via WebSocket: - -```typescript -// Subscribe to rate limit updates -const subscription = { - event: "waveai:ratelimit", - allscopes: true, // Receive all rate limit events -}; -``` - -## Subscribing to Events - -### From Go Code - -```go -// Subscribe to all events of a type -wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ - Event: wps.Event_YourNewEvent, - AllScopes: true, -}) - -// Subscribe to specific scopes -wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ - Event: wps.Event_WaveObjUpdate, - Scopes: []string{"workspace:123"}, -}) - -// Unsubscribe -wps.Broker.Unsubscribe(routeId, wps.Event_YourNewEvent) -``` - -### Scope Matching - -Scopes support wildcard matching: - -- `*` matches a single scope segment -- `**` matches multiple scope segments - -```go -// Subscribe to all workspace events -wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ - Event: wps.Event_WaveObjUpdate, - Scopes: []string{"workspace:*"}, -}) -``` - -## Best Practices - -1. **Use Namespaces**: Prefix event names with a namespace (e.g., `waveai:`, `workspace:`, `block:`) - -2. **Don't Block**: Use goroutines when publishing from performance-critical code or while holding locks - -3. **Type-Safe Data**: Define struct types for event data rather than using maps - -4. **Scope Wisely**: Use scopes to limit event delivery and reduce unnecessary processing - -5. **Document Events**: Add comments explaining when events are fired and what data they carry - -6. **Consider Persistence**: Use `Persist` for events that late subscribers might need (like status updates). This is normally not used. We normally do a live RPC call to get the current value and then subscribe for updates. - -## Common Event Patterns - -### Status Updates - -```go -wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_ControllerStatus, - Scopes: []string{blockId}, - Persist: 1, // Keep only latest status - Data: statusData, -}) -``` - -### Object Updates - -```go -wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_WaveObjUpdate, - Scopes: []string{oref.String()}, - Data: waveobj.WaveObjUpdate{ - UpdateType: waveobj.UpdateType_Update, - OType: obj.GetOType(), - OID: waveobj.GetOID(obj), - Obj: obj, - }, -}) -``` - -### Batch Updates - -```go -// Helper function for multiple updates -func (b *BrokerType) SendUpdateEvents(updates waveobj.UpdatesRtnType) { - for _, update := range updates { - b.Publish(WaveEvent{ - Event: Event_WaveObjUpdate, - Scopes: []string{waveobj.MakeORef(update.OType, update.OID).String()}, - Data: update, - }) - } -} -``` - -## Debugging - -To debug event flow: - -1. Check broker subscription map: `wps.Broker.SubMap` -2. View persisted events: `wps.Broker.ReadEventHistory(eventType, scope, maxItems)` -3. Add logging in publish/subscribe methods -4. Monitor WebSocket traffic in browser dev tools - -## Quick Reference - -When adding a new event: - -- [ ] Add event constant to [`pkg/wps/wpstypes.go`](pkg/wps/wpstypes.go) with a `// type: ` comment (use `none` if no data) -- [ ] Add the constant to `AllEvents` in [`pkg/wps/wpstypes.go`](pkg/wps/wpstypes.go) -- [ ] **REQUIRED**: Add an entry to `WaveEventDataTypes` in [`pkg/tsgen/tsgenevent.go`](pkg/tsgen/tsgenevent.go) — use `nil` for events with no data -- [ ] Define event data structure (if needed) -- [ ] Add data type to `pkg/tsgen/tsgen.go` for frontend use (if not already exposed via RPC) -- [ ] Run `task generate` to update TypeScript types -- [ ] Publish events using `wps.Broker.Publish()` -- [ ] Use goroutines for non-blocking publish when appropriate -- [ ] Subscribe to events in relevant components diff --git a/.prettierignore b/.prettierignore index 885ce63370..2bdfa2f9e7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,8 +1,9 @@ +node_modules +dist +dist-dev +static build bin .git -frontend/dist -frontend/node_modules -*.min.* -frontend/app/store/services.ts -frontend/types/gotypes.d.ts +webshare +out diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000000..08d6d29d70 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "printWidth": 120 +} diff --git a/.roo/rules/overview.md b/.roo/rules/overview.md deleted file mode 100644 index 944a4021dd..0000000000 --- a/.roo/rules/overview.md +++ /dev/null @@ -1,154 +0,0 @@ -# Wave Terminal - High Level Architecture Overview - -## Project Description - -Wave Terminal is an open-source AI-native terminal built for seamless workflows. It's an Electron application that serves as a command line terminal host (it hosts CLI applications rather than running inside a CLI). The application combines a React frontend with a Go backend server to provide a modern terminal experience with advanced features. - -## Top-Level Directory Structure - -``` -waveterm/ -├── emain/ # Electron main process code -├── frontend/ # React application (renderer process) -├── cmd/ # Go command-line applications -├── pkg/ # Go packages/modules -├── db/ # Database migrations -├── docs/ # Documentation (Docusaurus) -├── build/ # Build configuration and assets -├── assets/ # Application assets (icons, images) -├── public/ # Static public assets -├── tests/ # Test files -├── .github/ # GitHub workflows and configuration -└── Configuration files (package.json, tsconfig.json, etc.) -``` - -## Architecture Components - -### 1. Electron Main Process (`emain/`) - -The Electron main process handles the native desktop application layer: - -**Key Files:** - -- [`emain.ts`](emain/emain.ts) - Main entry point, application lifecycle management -- [`emain-window.ts`](emain/emain-window.ts) - Window management (`WaveBrowserWindow` class) -- [`emain-tabview.ts`](emain/emain-tabview.ts) - Tab view management (`WaveTabView` class) -- [`emain-wavesrv.ts`](emain/emain-wavesrv.ts) - Go backend server integration -- [`emain-wsh.ts`](emain/emain-wsh.ts) - WSH (Wave Shell) client integration -- [`emain-ipc.ts`](emain/emain-ipc.ts) - IPC handlers for frontend ↔ main process communication -- [`emain-menu.ts`](emain/emain-menu.ts) - Application menu system -- [`updater.ts`](emain/updater.ts) - Auto-update functionality -- [`preload.ts`](emain/preload.ts) - Preload script for renderer security -- [`preload-webview.ts`](emain/preload-webview.ts) - Webview preload script - -### 2. Frontend React Application (`frontend/`) - -The React application runs in the Electron renderer process: - -**Structure:** - -``` -frontend/ -├── app/ # Main application code -│ ├── app.tsx # Root App component -│ ├── aipanel/ # AI panel UI -│ ├── block/ # Block-based UI components -│ ├── element/ # Reusable UI elements -│ ├── hook/ # Custom React hooks -│ ├── modals/ # Modal components -│ ├── store/ # State management (Jotai) -│ ├── tab/ # Tab components -│ ├── view/ # Different view types -│ │ ├── codeeditor/ # Code editor (Monaco) -│ │ ├── preview/ # File preview -│ │ ├── sysinfo/ # System info view -│ │ ├── term/ # Terminal view -│ │ ├── tsunami/ # Tsunami builder view -│ │ ├── vdom/ # Virtual DOM view -│ │ ├── waveai/ # AI chat integration -│ │ ├── waveconfig/ # Config editor view -│ │ └── webview/ # Web view -│ └── workspace/ # Workspace management -├── builder/ # Builder app entry -├── layout/ # Layout system -├── preview/ # Standalone preview renderer -├── types/ # TypeScript type definitions -└── util/ # Utility functions -``` - -**Key Technologies:** - -- Electron (desktop application shell) -- React 19 with TypeScript -- Jotai for state management -- Monaco Editor for code editing -- XTerm.js for terminal emulation -- Tailwind CSS v4 for styling -- SCSS for additional styling (deprecated, new components should use Tailwind) -- Vite / electron-vite for bundling -- Task (Taskfile.yml) for build and code generation commands - -### 3. Go Backend Server (`cmd/server/`) - -The Go backend server handles all heavy lifting operations: - -**Entry Point:** [`main-server.go`](cmd/server/main-server.go) - -### 4. Go Packages (`pkg/`) - -The Go codebase is organized into modular packages: - -**Key Packages:** - -- `wstore/` - Database and storage layer -- `wconfig/` - Configuration management -- `wcore/` - Core business logic -- `wshrpc/` - RPC communication system -- `wshutil/` - WSH (Wave Shell) utilities -- `blockcontroller/` - Block execution management -- `remote/` - Remote connection handling -- `filestore/` - File storage system -- `web/` - Web server and WebSocket handling -- `telemetry/` - Usage analytics and telemetry -- `waveobj/` - Core data objects -- `service/` - Service layer -- `wps/` - Wave PubSub event system -- `waveai/` - AI functionality -- `shellexec/` - Shell execution -- `util/` - Common utilities - -### 5. Command Line Tools (`cmd/`) - -Key Go command-line utilities: - -- `wsh/` - Wave Shell command-line tool -- `server/` - Main backend server -- `generatego/` - Code generation -- `generateschema/` - Schema generation -- `generatets/` - TypeScript generation - -## Communication Architecture - -The core communication system is built around the **WSH RPC (Wave Shell RPC)** system, which provides a unified interface for all inter-process communication: frontend ↔ Go backend, Electron main process ↔ backend, and backend ↔ remote systems (SSH, WSL). - -### WSH RPC System (`pkg/wshrpc/`) - -The WSH RPC system is the backbone of Wave Terminal's communication architecture: - -**Key Components:** - -- [`wshrpctypes.go`](pkg/wshrpc/wshrpctypes.go) - Core RPC interface and type definitions (source of truth for all RPC commands) -- [`wshserver/`](pkg/wshrpc/wshserver/) - Server-side RPC implementation -- [`wshremote/`](pkg/wshrpc/wshremote/) - Remote connection handling -- [`wshclient.go`](pkg/wshrpc/wshclient.go) - Go client for making RPC calls -- [`frontend/app/store/wshclientapi.ts`](frontend/app/store/wshclientapi.ts) - Generated TypeScript RPC client - -**Routing:** Callers address RPC calls using _routes_ (e.g. a block ID, connection name, or `"waveapp"`) rather than caring about the underlying transport. The RPC layer resolves the route to the correct transport (WebSocket, Unix socket, SSH tunnel, stdio) automatically. This means the same RPC interface works whether the target is local or a remote SSH connection. - -## Development Notes - -- **Build commands** - Use `task` (Taskfile.yml) for all build, generate, and packaging commands -- **Code generation** - Run `task generate` after modifying Go types in `pkg/wshrpc/wshrpctypes.go`, `pkg/wconfig/settingsconfig.go`, or `pkg/waveobj/wtypemeta.go` -- **Testing** - Vitest for frontend unit tests; standard `go test` for Go packages -- **Database migrations** - SQL migration files in `db/migrations-wstore/` and `db/migrations-filestore/` -- **Documentation** - Docusaurus site in `docs/` diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md deleted file mode 100644 index 99f3f08b70..0000000000 --- a/.roo/rules/rules.md +++ /dev/null @@ -1,203 +0,0 @@ -Wave Terminal is a modern terminal which provides graphical blocks, dynamic layout, workspaces, and SSH connection management. It is cross platform and built on electron. - -### Project Structure - -It has a TypeScript/React frontend and a Go backend. They talk together over `wshrpc` a custom RPC protocol that is implemented over websocket (and domain sockets). - -### Coding Guidelines - -- **Go Conventions**: - - Don't use custom enum types in Go. Instead, use string constants (e.g., `const StatusRunning = "running"` rather than creating a custom type like `type Status string`). - - Use string constants for status values, packet types, and other string-based enumerations. - - in Go code, prefer using Printf() vs Println() - - use "Make" as opposed to "New" for struct initialization func names - - in general const decls go at the top of the file (before types and functions) - - NEVER run `go build` (especially in weird sub-package directories). we can tell if everything compiles by seeing there are no problems/errors. -- **Synchronization**: - - Always prefer to use the `lock.Lock(); defer lock.Unlock()` pattern for synchronization if possible - - Avoid inline lock/unlock pairs - instead create helper functions that use the defer pattern - - When accessing shared data structures (maps, slices, etc.), ensure proper locking - - Example: Instead of `gc.lock.Lock(); gc.map[key]++; gc.lock.Unlock()`, create a helper function like `getNextValue(key string) int { gc.lock.Lock(); defer gc.lock.Unlock(); gc.map[key]++; return gc.map[key] }` -- **TypeScript Imports**: - - Use `@/...` for imports from different parts of the project (configured in `tsconfig.json` as `"@/*": ["frontend/*"]`). - - Prefer relative imports (`"./name"`) only within the same directory. - - Use named exports exclusively; avoid default exports. It's acceptable to export functions directly (e.g., React Components). - - Our indent is 4 spaces -- **JSON Field Naming**: All fields must be lowercase, without underscores. -- **TypeScript Conventions** - - **Type Handling**: - - In TypeScript we have strict null checks off, so no need to add "| null" to all the types. - - In TypeScript for Jotai atoms, if we want to write, we need to type the atom as a PrimitiveAtom - - Jotai has a bug with strict null checks off where if you create a null atom, e.g. atom(null) it does not "type" correctly. That's no issue, just cast it to the proper PrimitiveAtom type (no "| null") and it will work fine. - - Generally never use "=== undefined" or "!== undefined". This is bad style. Just use a "== null" or "!= null" unless it is a very specific case where we need to distinguish undefined from null. - - **Coding Style**: - - Use all lowercase filenames (except where case is actually important like Taskfile.yml) - - Import the "cn" function from "@/util/util" to do classname / clsx class merge (it uses twMerge underneath) - - Do NOT create private fields in classes (they are impossible to inspect) - - Use PascalCase for global consts at the top of files - - **Component Practices**: - - Make sure to add cursor-pointer to buttons/links and clickable items - - NEVER use cursor-help (it looks terrible) - - useAtom() and useAtomValue() are react HOOKS, so they must be called at the component level not inline in JSX - - If you use React.memo(), make sure to add a displayName for the component - - Other - - never use atob() or btoa() (not UTF-8 safe). use functions in frontend/util/util.ts for base64 decoding and encoding -- In general, when writing functions, we prefer _early returns_ rather than putting the majority of a function inside of an if block. - -### Styling - -- We use **Tailwind v4** to style. Custom stuff is defined in frontend/tailwindsetup.css -- _never_ use cursor-help, or cursor-not-allowed (it looks terrible) -- We have custom CSS setup as well, so it is a hybrid system. For new code we prefer tailwind, and are working to migrate code to all use tailwind. -- For accent buttons, use "bg-accent/80 text-primary rounded hover:bg-accent transition-colors cursor-pointer" (if you do "bg-accent hover:bg-accent/80" it looks weird as on hover the button gets darker instead of lighter) - -### RPC System - -To define a new RPC call, add the new definition to `pkg/wshrpc/wshrpctypes.go` including any input/output data that is required. After modifying wshrpctypes.go run `task generate` to generate the client APIs. - -For normal "server" RPCs (where a frontend client is calling the main server) you should implement the RPC call in `pkg/wshrpc/wshserver.go`. - -### Electron API - -From within the FE to get the electron API (e.g. the preload functions): - -```ts -import { getApi } from "@/store/global"; - -getApi().getIsDev(); -``` - -The full API is defined in custom.d.ts as type ElectronApi. - -### Code Generation - -- **TypeScript Types**: TypeScript types are automatically generated from Go types. After modifying Go types in `pkg/wshrpc/wshrpctypes.go`, run `task generate` to update the TypeScript type definitions in `frontend/types/gotypes.d.ts`. -- **Manual Edits**: Do not manually edit generated files like `frontend/types/gotypes.d.ts` or `frontend/app/store/wshclientapi.ts`. Instead, modify the source Go types and run `task generate`. - -### Frontend Architecture - -- The application uses Jotai for state management. -- When working with Jotai atoms that need to be updated, define them as `PrimitiveAtom` rather than just `atom`. - -### Notes - -- **CRITICAL: Completion format MUST be: "Done: [one-line description]"** -- **Keep your Task Completed summaries VERY short** -- **No double-summarization** - Put your summary ONLY inside attempt_completion. Do not write a summary in the message body AND then repeat it in attempt_completion. One summary, one place. -- **Go directly to completion** - After making changes, proceed directly to attempt_completion without summarizing -- The project is currently an un-released POC / MVP. Do not worry about backward compatibility when making changes -- With React hooks, always complete all hook calls at the top level before any conditional returns (including jotai hook calls useAtom and useAtomValue); when a user explicitly tells you a function handles null inputs, trust them and stop trying to "protect" it with unnecessary checks or workarounds. -- **Match response length to question complexity** - For simple, direct questions in Ask mode (especially those that can be answered in 1-2 sentences), provide equally brief answers. Save detailed explanations for complex topics or when explicitly requested. -- **CRITICAL** - useAtomValue and useAtom are React HOOKS. They cannot be used inline in JSX code, they must appear at the top of a component in the hooks area of the react code. -- for simple functions, we prefer `if (!cond) { return }; functionality;` pattern over `if (cond) { functionality }` because it produces less indentation and is easier to follow. -- It is now 2026, so if you write new files, or update files use 2026 for the copyright year -- React.MutableRefObject is deprecated, just use React.RefObject now (in React 19 RefObject is always mutable) - -### Strict Comment Rules - -- **NEVER add comments that merely describe what code is doing**: - - ❌ `mutex.Lock() // Lock the mutex` - - ❌ `counter++ // Increment the counter` - - ❌ `buffer.Write(data) // Write data to buffer` - - ❌ `// Header component for app run list` (above AppRunListHeader) - - ❌ `// Updated function to include onClick parameter` - - ❌ `// Changed padding calculation` - - ❌ `// Removed unnecessary div` - - ❌ `// Using the model's width value here` -- **Only use comments for**: - - Explaining WHY a particular approach was chosen - - Documenting non-obvious edge cases or side effects - - Warning about potential pitfalls in usage - - Explaining complex algorithms that can't be simplified -- **When in doubt, leave it out**. No comment is better than a redundant comment. -- **Never add comments explaining code changes** - The code should speak for itself, and version control tracks changes. The one exception to this rule is if it is a very unobvious implementation. Something that someone would typically implement in a different (wrong) way. Then the comment helps us remember WHY we changed it to a less obvious implementation. -- **Never remove existing comments** unless specifically directed by the user. Comments that are already defined in existing code have been vetted by the user. - -### Jotai Model Pattern (our rules) - -- **Atoms live on the model.** -- **Simple atoms:** define as **field initializers**. -- **Atoms that depend on values/other atoms:** create in the **constructor**. -- Models **never use React hooks**; they use `globalStore.get/set`. -- It's fine to call model methods from **event handlers** or **`useEffect`**. -- Models use the **singleton pattern** with a `private static instance` field, a `private constructor`, and a `static getInstance()` method. -- The constructor is `private`; callers always use `getInstance()`. - -```ts -// model/MyModel.ts -import * as jotai from "jotai"; -import { globalStore } from "@/app/store/jotaiStore"; - -export class MyModel { - private static instance: MyModel | null = null; - - // simple atoms (field init) - statusAtom = jotai.atom<"idle" | "running" | "error">("idle"); - outputAtom = jotai.atom(""); - - // ctor-built atoms (need types) - lengthAtom!: jotai.Atom; - thresholdedAtom!: jotai.Atom; - - private constructor(initialThreshold = 20) { - this.lengthAtom = jotai.atom((get) => get(this.outputAtom).length); - this.thresholdedAtom = jotai.atom((get) => get(this.lengthAtom) > initialThreshold); - } - - static getInstance(): MyModel { - if (!MyModel.instance) { - MyModel.instance = new MyModel(); - } - return MyModel.instance; - } - - static resetInstance(): void { - MyModel.instance = null; - } - - async doWork() { - globalStore.set(this.statusAtom, "running"); - // ... do work ... - globalStore.set(this.statusAtom, "idle"); - } -} -``` - -```tsx -// component usage (events & effects OK) -import { useAtomValue } from "jotai"; - -function Panel() { - const model = MyModel.getInstance(); - const status = useAtomValue(model.statusAtom); - const isBig = useAtomValue(model.thresholdedAtom); - - const onClick = () => model.doWork(); - - return ( -
- {status} â€ĸ {String(isBig)} -
- ); -} -``` - -**Remember:** singleton pattern with `getInstance()`, `private constructor`, atoms on the model, simple-as-fields, ctor for dependent/derived, updates via `globalStore.set/get`. -**Note** Older models may not use the singleton pattern - -### Tool Use - -Do NOT use write_to_file unless it is a new file or very short. Always prefer to use replace_in_file. Often your diffs fail when a file may be out of date in your cache vs the actual on-disk format. You should RE-READ the file and try to create diffs again if your diffs fail rather than fall back to write_to_file. If you feel like your ONLY option is to use write_to_file please ask first. - -Also when adding content to the end of files prefer to use the new append_file tool rather than trying to create a diff (as your diffs are often not specific enough and end up inserting code in the middle of existing functions). - -### Directory Awareness - -- **ALWAYS verify the current working directory before executing commands** -- Either run "pwd" first to verify the directory, or do a "cd" to the correct absolute directory before running commands -- When running tests, do not "cd" to the pkg directory and then run the test. This screws up the cwd and you never recover. run the test from the project root instead. - -### Testing / Compiling Go Code - -No need to run a `go build` or a `go run` to just check if the Go code compiles. VSCode's errors/problems cover this well. -If there are no Go errors in VSCode you can assume the code compiles fine. diff --git a/.testdriver/wave1.yml b/.testdriver/wave1.yml new file mode 100644 index 0000000000..501e8dc381 --- /dev/null +++ b/.testdriver/wave1.yml @@ -0,0 +1,37 @@ +version: 3.8.0 + steps: + - prompt: "Focus electron" + commands: + - command: focus-application + name: Electron + - command: hover-text + description: Get started CTA + text: Get Started + action: click + - command: hover-text + description: Settings button + text: Settings + action: click + - command: hover-text + description: font size 13 + text: 13px + action: click + - command: hover-text + description: font size 12 + text: 12px + action: click + - command: hover-text + description: theme selector + text: Dark + action: click + - command: hover-text + description: theme color white + text: Light + action: click + - command: hover-text + description: workspace + text: workspace-1 + action: click + - command: assert + expect: the terminal is white + diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 23561f94bf..c962bfb8d6 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,3 @@ { - "recommendations": [ - "esbenp.prettier-vscode", - "golang.go", - "dbaeumer.vscode-eslint", - "vitest.explorer", - "task.vscode-task" - ] + "recommendations": ["esbenp.prettier-vscode", "golang.go", "dbaeumer.vscode-eslint"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index e0209de61b..d52752a242 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,5 @@ { "editor.formatOnSave": true, - "editor.detectIndentation": false, - "editor.formatOnPaste": true, - "editor.tabSize": 4, - "editor.insertSpaces": false, - "prettier.useEditorConfig": true, - "diffEditor.renderSideBySide": false, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, @@ -21,48 +15,7 @@ "[less]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[scss]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[html]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[yaml]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.insertSpaces": true, - "editor.autoIndent": "keep" - }, - "[github-actions-workflow]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.insertSpaces": true, - "editor.autoIndent": "keep" - }, - "[go]": { - "editor.defaultFormatter": "golang.go" - }, - "[mdx]": { - "editor.wordWrap": "on" - }, - "[md]": { - "editor.wordWrap": "on" - }, - "files.associations": { - "*.css": "tailwindcss" - }, - "gopls": { - "analyses": { - "QF1003": false - }, - "directoryFilters": ["-tsunami/frontend/scaffold", "-dist", "-make"] - }, - "tailwindCSS.lint.suggestCanonicalClasses": "ignore", - "go.coverageDecorator": { - "type": "gutter" } } diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000000..3186f3f079 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/.zed/settings.json b/.zed/settings.json deleted file mode 100644 index c5332294f7..0000000000 --- a/.zed/settings.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "format_on_save": "on", - "languages": { - "JavaScript": { - "formatter": { - "external": { - "command": "./node_modules/.bin/prettier", - "arguments": ["--stdin-filepath", "{buffer_path}"] - } - } - }, - "JSON": { - "formatter": { - "external": { - "command": "./node_modules/.bin/prettier", - "arguments": ["--stdin-filepath", "{buffer_path}"] - } - } - }, - "TypeScript": { - "formatter": { - "external": { - "command": "./node_modules/.bin/prettier", - "arguments": ["--stdin-filepath", "{buffer_path}"] - } - } - }, - "CSS": { - "formatter": { - "external": { - "command": "./node_modules/.bin/prettier", - "arguments": ["--stdin-filepath", "{buffer_path}"] - } - } - }, - "SCSS": { - "formatter": { - "external": { - "command": "./node_modules/.bin/prettier", - "arguments": ["--stdin-filepath", "{buffer_path}"] - } - } - }, - "YAML": { - "formatter": { - "external": { - "command": "./node_modules/.bin/prettier", - "arguments": ["--stdin-filepath", "{buffer_path}"] - } - } - } - }, - "lsp": { - "eslint": { - "settings": { - "codeActionOnSave": { - "rules": ["import/order"] - }, - "nodePath": "./node_modules/.bin", - "language_ids": ["typescript", "javascript", "typescriptreact", "javascriptreact"] - } - } - } -} diff --git a/BUILD.md b/BUILD.md index 15229bb091..35c43b9b9c 100644 --- a/BUILD.md +++ b/BUILD.md @@ -1,138 +1,86 @@ -# Building Wave Terminal +# Build Instructions for Wave Terminal -These instructions are for setting up dependencies and building Wave Terminal from source on macOS, Linux, and Windows. +These instructions are for setting up the build on MacOS. +If you're developing on Linux please use the [Linux Build Instructions](./build-linux.md). -## Prerequisites +## Running the Development Version of Wave -### OS-specific dependencies +If you install the production version of Wave, you'll see a semi-transparent gray sidebar, and the data for Wave is stored in the directory ~/.waveterm. The development version has a blue sidebar and stores its data in ~/.waveterm-dev. This allows the production and development versions to be run simultaneously with no conflicts. If the dev database is corrupted by development bugs, or the schema changes in development it will not affect the production copy. -See [Minimum requirements](README.md#minimum-requirements) to learn whether your OS is supported. +## Prereqs and Tools -#### macOS - -macOS does not have any platform-specific dependencies. - -#### Linux - -You must have `zip` installed. We also require the [Zig](https://ziglang.org/) compiler for statically linking CGO. - -Debian/Ubuntu: - -```sh -sudo apt install zip snapd -sudo snap install zig --classic --beta -``` - -Fedora/RHEL: +Download and install Go (must be at least go 1.18): ```sh -sudo dnf install zip zig +brew install go ``` -Arch: +Download and install ScriptHaus (to run the build commands): ```sh -sudo pacman -S zip zig +brew tap scripthaus-dev/scripthaus +brew install scripthaus ``` -##### For packaging - -For packaging, the following additional packages are required: - -- `fpm` — If you're on x64 you can skip this. If you're on ARM64, install fpm via [Gem](https://rubygems.org/gems/fpm) -- `rpm` — If you're not on Fedora, install RPM via your package manager. -- `snapd` — If your distro doesn't already include it, [install `snapd`](https://snapcraft.io/docs/installing-snapd) -- `lxd` — [Installation instructions](https://canonical.com/lxd/install) -- `snapcraft` — Run `sudo snap install snapcraft --classic` -- `libarchive-tools` — Install via your package manager -- `binutils` — Install via your package manager -- `libopenjp2-tools` — Install via your package manager -- `squashfs-tools` — Install via your package manager - -#### Windows - -You will need the [Zig](https://ziglang.org/) compiler for statically linking CGO. - -You can find installation instructions for Zig on Windows [here](https://ziglang.org/learn/getting-started/#managers). - -### Task - -Download and install Task (to run the build commands): https://taskfile.dev/installation/ +You also need a relatively modern nodejs with npm and yarn installed. -Task is a modern equivalent to GNU Make. We use it to coordinate our build steps. You can find our full Task configuration in [Taskfile.yml](Taskfile.yml). +Node can be installed from [https://nodejs.org](https://nodejs.org). -### Go +We use Yarn Modern to manage our packages. The recommended way to install Yarn Modern is using Corepack, a new utility shipped by NodeJS that lets you manage your package manager versioning as you would any packages. -Download and install Go via your package manager or directly from the website: https://go.dev/doc/install +If you installed NodeJS from the official feed (via the website or using NVM), this should come preinstalled. If you use Homebrew or some other feed, you may need to manually install Corepack using `npm install -g corepack`. -### NodeJS +For more information on Corepack, check out [this link](https://yarnpkg.com/corepack). -Make sure you have a NodeJS 22 LTS installed. - -See NodeJS's website for platform-specific instructions: https://nodejs.org/en/download - -We now use `npm`, so you can just run an `npm install` to install node dependencies. - -## Clone the Repo +Once you've verified that you have Corepack installed, run the following script to set up Yarn for the repository: ```sh -git clone git@github.com:wavetermdev/waveterm.git +corepack enable +yarn install ``` -or +## Clone the Repo ```sh -git clone https://github.com/wavetermdev/waveterm.git +git clone git@github.com:wavetermdev/waveterm.git ``` -## Install code dependencies - -The first time you clone the repo, you'll need to run the following to load the dependencies. If you ever have issues building the app, try running this again: +## Building WaveShell / WaveSrv ```sh -task init +scripthaus run build-backend ``` -## Build and Run - -All the methods below will install Node and Go dependencies when they run the first time. All these should be run from within the Git repository. +This builds the Golang backends for Wave. The binaries will put in waveshell/bin and wavesrv/bin respectively. If you're working on a new plugin or other pure frontend changes to Wave, you won't need to rebuild these unless you pull new code from the Wave Repository. -### Development server +## One-Time Setup -Run the following command to build the app and run it via Vite's development server (this enables Hot Module Reloading): +Install modules (we use yarn): ```sh -task dev +yarn ``` -### Standalone +## Running WebPack -Run the following command to build the app and run it standalone, without the development server. This will not reload on change: +We use webpack to build both the React and Electron App Wrapper code. They are both run together using: ```sh -task start +scripthaus run webpack-watch ``` -### Packaged +## Running the WaveTerm Dev Client -Run the following command to generate a production build and package it. This lets you install the app locally. All artifacts will be placed in `make/`. +Now that webpack is running (and watching for file changes) we can finally run the WaveTerm Dev Client! To start the client run: ```sh -task package +scripthaus run electron ``` -If you're on Linux ARM64, run the following: - -```sh -USE_SYSTEM_FPM=1 task package -``` - -## Debugging - -### Frontend logs +To kill the client, either exit the Electron App normally or just Ctrl-C the `scripthaus run electron` command. -You can use the regular Chrome DevTools to debug the frontend application. You can open the DevTools using the keyboard shortcut `Cmd+Option+I` on macOS or `Ctrl+Option+I` on Linux and Windows. Logs will be sent to the Console tab in DevTools. +Because we're running webpack in watch mode, any changes you make to the typescript will be automatically picked up by the client after a refresh. Note that I've disabled hot-reloading in the webpack config, so to pick up new changes you'll have to manually refresh the WaveTerm Client window. To do that use "Option-R" (Command-R is used internally by WaveTerm and will not force a refresh). -### Backend logs +## Debugging the Dev Client -Backend logs for the development version of Wave can be found at `~/.waveterm-dev/waveapp.log`. Both the NodeJS backend from Electron and the main Go backend will log here. +You can use the regular Chrome DevTools to debug the frontend application. You can open the DevTools using the keyboard shortcut `Cmd-Option-I`. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ea0daa9425..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,18 +0,0 @@ -@.kilocode/rules/rules.md - ---- - -## Skill Guides - -This project uses a set of "skill" guides — focused how-to documents for common implementation tasks. When your task matches one of the descriptions below, **read the linked SKILL.md file before proceeding** and follow its instructions precisely. - -| Skill | File | Description | -| ------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| add-config | `.kilocode/skills/add-config/SKILL.md` | Guide for adding new configuration settings to Wave Terminal. Use when adding a new setting to the configuration system, implementing a new config key, or adding user-customizable settings. | -| add-rpc | `.kilocode/skills/add-rpc/SKILL.md` | Guide for adding new RPC calls to Wave Terminal. Use when implementing new RPC commands, adding server-client communication methods, or extending the RPC interface with new functionality. | -| add-wshcmd | `.kilocode/skills/add-wshcmd/SKILL.md` | Guide for adding new wsh commands to Wave Terminal. Use when implementing new CLI commands, adding command-line functionality, or extending the wsh command interface. | -| context-menu | `.kilocode/skills/context-menu/SKILL.md` | Guide for creating and displaying context menus in Wave Terminal. Use when implementing right-click menus, adding context menu items, creating submenus, or handling menu interactions with checkboxes and separators. | -| create-view | `.kilocode/skills/create-view/SKILL.md` | Guide for implementing a new view type in Wave Terminal. Use when creating a new view component, implementing the ViewModel interface, registering a new view type in BlockRegistry, or adding a new content type to display within blocks. | -| electron-api | `.kilocode/skills/electron-api/SKILL.md` | Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC. | -| waveenv | `.kilocode/skills/waveenv/SKILL.md` | Guide for creating WaveEnv narrowings in Wave Terminal. Use when writing a named subset type of WaveEnv for a component tree, documenting environmental dependencies, or enabling mock environments for preview/test server usage. | -| wps-events | `.kilocode/skills/wps-events/SKILL.md` | Guide for working with Wave Terminal's WPS (Wave PubSub) event system. Use when implementing new event types, publishing events, subscribing to events, or adding asynchronous communication between components. | diff --git a/CNAME b/CNAME deleted file mode 100644 index c6be95cde6..0000000000 --- a/CNAME +++ /dev/null @@ -1 +0,0 @@ -docs.waveterm.dev \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 409d27207d..55a1d00e76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,111 +1,47 @@ -# Contributing to Wave Terminal +# Contributing to Wave Terminal -Wave Terminal is an opinionated project with a single active maintainer. Contributions are welcome, but **alignment matters more than volume**. +We welcome and value contributions to Wave Terminal! Wave is an open source project, always open for contributors. There are several ways you can contribute: + * Submit issues related to bugs or new feature requests + * Fix outstanding [issues](https://github.com/wavetermdev/waveterm/issues) with the existing code + * Contribute to [documentation](https://github.com/wavetermdev/waveterm-docs) + * Spread the word on social media (tag us on [LinkedIn](https://www.linkedin.com/company/commandlinedev), [Twitter/X](https://twitter.com/commandlinedev)) + * Or simply â­ī¸ the repository to show your appreciation -This document helps you decide _whether_ and _how_ to contribute in a way that's likely to be accepted, saving both of us time. +However you choose to contribute, please be mindful and respect our [code of conduct](./CODE_OF_CONDUCT.md). -## High-level expectations +> All contributions are highly appreciated! đŸĨ° -- Wave has a strong product direction and centralized ownership. -- Review bandwidth is limited. -- Not all contributions can or will be accepted, even if they are technically correct. +## Before You Start +We accept patches in the form of github pull requests. If you are new to github, please review this [github pull request guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests). -This is normal for a solo-maintainer project. +### Contributor License Agreement +Contributions to this project must be accompanied by a Contributor License Agreement (CLA). You (or your employer) retain the copyright to your contribution, this simply gives us permission to use and redistribute your contributions as part of the project. -## What makes a great contribution +> On submission of your first pull request you will be prompted to sign the CLA confirming your original code contribution and that you own the intellectual property. -The following are most likely to be accepted: +### Style guide +The project uses American English. -- **Bug fixes** - especially with clear reproduction steps -- **Documentation improvements** - typos, clarifications, examples -- **Discussed features** - after alignment in Discord -- **Small, focused changes** - easy to review and low risk +Coding style and formatting is automated for each pull request. We use [Prettier](https://prettier.io/). -If your change is small and obvious (typo fix, narrowly-scoped bug fix, small docs improvement), you are welcome to open a pull request directly. +## How to contribute -## Keep changes focused + * For minor changes, you are welcome to [open a pull request](https://github.com/wavetermdev/waveterm/pulls). + * For major changes, please [create an issue](https://github.com/wavetermdev/waveterm/issues/new) first. + * If you are looking for a place to start take a look at [open issues](https://github.com/wavetermdev/waveterm/issues). + * Join the [Discord channel](https://discord.gg/XfvZ334gwU) to collaborate with the community on your contribution. -**Only change what is necessary to accomplish your stated goal.** -If you're fixing a bug in `file.ts`, do not: +### Development Environment -- Reformat other files -- Clean up unrelated code -- Fix style issues in files you didn't need to touch -- Combine multiple unrelated fixes in one PR +To build and run wave term locally see instructions below: + * [MacOS build instructions](./BUILD.md) + * [Linux build instructions](./build-linux.md) -Even if these changes are "improvements," they make review harder and require unnecessary back-and-forth. If you want to clean up code, discuss it first and submit it as a separate, focused PR. +### Create a Pull Request -**One PR = one logical change.** - -## Discuss first (required for larger changes) - -For anything beyond a small fix, **discussion is required before opening a pull request**. - -This includes: - -- New features -- UI/UX changes or changes to default behavior -- Refactors or "cleanup" work -- Performance rewrites -- Architectural changes -- Changes that touch many files or systems - -**Where to discuss:** Discord is the preferred place for these conversations -- https://discord.gg/XfvZ334gwU - -Pull requests that introduce larger changes without prior discussion will be closed without detailed review. - -This is not meant to discourage contribution — it is meant to ensure alignment before significant work is done. - -## What this project is not - -To set expectations clearly: - -- Wave is not designed as a "first open source contribution" project -- We do not currently curate beginner-friendly or mentorship issues -- Large, unsolicited changes are unlikely to be accepted -- Mechanical refactors, broad style changes, or drive-by rewrites are not helpful -- AI-assisted contributions are welcome, but PRs must reflect clear understanding of context, existing patterns, and project direction. Low-effort or poorly supervised changes will be closed. - -Being clear about this helps everyone spend their time effectively. - -## FAQ - -**Q: Should I ask before fixing a typo or obvious bug?** -A: No, just open a PR for small, obvious fixes. - -**Q: I have an idea for a new feature.** -A: Great! Come discuss it in Discord first. Do not open a PR without prior discussion. - -**Q: My PR was closed without detailed feedback.** -A: This usually means it didn't align with project direction or required more review bandwidth than available. This is normal for a solo-maintained project. - -**Q: Can I work on an open issue?** -A: Comment on the issue first to confirm it's still relevant and that nobody else is working on it. For anything non-trivial, discuss your approach before implementing. - -**Q: I noticed some code that could be cleaner while working on my fix.** -A: Focus on your stated goal. Submit cleanup as a separate PR after discussion, if desired. - -## Contributor License Agreement (CLA) - -Contributions to this project must be accompanied by a Contributor License Agreement (CLA). You (or your employer) retain the copyright to your contribution; the CLA simply gives us permission to use and redistribute your contributions as part of the project. - -On submission of your first pull request, you will be prompted to sign the CLA confirming that you own the intellectual property in your contribution. - -**A signed CLA is required before a pull request can be reviewed.** If the CLA is not completed within a reasonable timeframe, the pull request may be closed. - -## Style guide - -The project uses American English. Please follow existing formatting and style conventions. Use gofmt and prettier where applicable. - -## Development setup - -To build and run Wave locally, see instructions at [Building Wave Terminal](./BUILD.md). - -## Code of Conduct - -All contributors are expected to follow the project's [Code of Conduct](./CODE_OF_CONDUCT.md). - ---- - -Thank you for your interest in Wave Terminal. Clear expectations help keep the project moving quickly and sustainably. +Guidelines: + * Before writing any code, please look through existing PRs or issues to make sure nobody is already working on the same thing. + * Develop features on a branch - do not work on the main branch + * For anything but minor fixes, please submit tests and documentation + * Please reference the issue in the pull request diff --git a/LICENSE b/LICENSE index ded9695d5d..261eeb9e9f 100644 --- a/LICENSE +++ b/LICENSE @@ -2,180 +2,180 @@ Version 2.0, January 2004 http://www.apache.org/licenses/ -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" @@ -186,16 +186,16 @@ APPENDIX: How to apply the Apache License to your work. same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2025 Command Line Inc. + Copyright [yyyy] [name of copyright owner] -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE index b592fcd9b4..ceb4d0361b 100644 --- a/NOTICE +++ b/NOTICE @@ -1 +1 @@ -Copyright 2025, Command Line Inc. +Copyright 2023, Command Line Inc. diff --git a/README.ko.md b/README.ko.md deleted file mode 100644 index d18ccfaed9..0000000000 --- a/README.ko.md +++ /dev/null @@ -1,111 +0,0 @@ -

- - - - - Wave Terminal Logo - - -
-

- -# Wave Terminal - -
- -[English](README.md) | [한ęĩ­ė–´](README.ko.md) | [įšéĢ”ä¸­æ–‡](README.zh-TW.md) - -
- -[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_shield) - -> ė´ ëŦ¸ė„œëŠ” ėģ¤ëŽ¤ë‹ˆí‹° 한ęĩ­ė–´ ë˛ˆė—­ëŗ¸ėž…ë‹ˆë‹¤. ėĩœė‹  뛐ëŦ¸ė€ [README.md](README.md)ė—ė„œ í™•ė¸í•˜ė„¸ėš”. - -Wave는 macOS, Linux, Windowsė—ė„œ ë™ėž‘í•˜ëŠ” ė˜¤í”ˆė†ŒėŠ¤ AI í†ĩ합 í„°ë¯¸ë„ėž…ë‹ˆë‹¤. ė–´ë–¤ AI ëĒ¨ë¸ęŗŧ도 함ęģ˜ ė‚ŦėšŠí•  눘 ėžˆėŠĩ니다. OpenAI, Claude, Gemini는 API 키ëĨŧ 링렑 ė—°ę˛°í•´ ė‚ŦėšŠí•  눘 ėžˆęŗ , Ollama 및 LM StudioëĨŧ í†ĩ해 로ėģŦ ëĒ¨ë¸ë„ ė‹¤í–‰í•  눘 ėžˆėŠĩ니다. ęŗ„ė • ėƒė„ąė€ í•„ėš”í•˜ė§€ ė•ŠėŠĩ니다. - -또한 Wave는 ë„¤íŠ¸ė›ŒíŦ ė¤‘ë‹¨ė´ë‚˜ ėžŦė‹œėž‘ ė´í›„ė—ë„ ėœ ė§€ë˜ëŠ” 내ęĩŦė„ą ėžˆëŠ” SSH ė„¸ė…˜ė„ ė§€ė›í•˜ëŠ°, ėžë™ ėžŦ뗰枰 기ëŠĨė„ 렜ęŗĩ합니다. 내ėžĨ 그래í”Ŋ ė—ë””í„°ëĄœ ė›ę˛Š 파ėŧė„ íŽ¸ė§‘í•˜ęŗ , í„°ë¯¸ë„ė„ ë˛—ė–´ë‚˜ė§€ ė•Šęŗ ë„ 파ėŧė„ ė¸ëŧė¸ėœŧ로 미ëĻŦëŗŧ 눘 ėžˆėŠĩ니다. - -![WaveTerm Screenshot](./assets/wave-screenshot.webp) - -## ėŖŧėš” 기ëŠĨ - -- Wave AI - 터미널 ėļœë Ĩęŗŧ ėœ„ė ¯ė„ ė´í•´í•˜ęŗ  파ėŧ ėž‘ė—…ęšŒė§€ ėˆ˜í–‰í•  눘 ėžˆëŠ” ėģ¨í…ėŠ¤íŠ¸ ė¸ė§€í˜• 터미널 ė–´ė‹œėŠ¤í„´íŠ¸ -- 내ęĩŦė„ą ėžˆëŠ” SSH ė„¸ė…˜ - 뗰枰 끊김, ë„¤íŠ¸ė›ŒíŦ ëŗ€ę˛Ŋ, Wave ėžŦė‹œėž‘ ėƒí™Šė—ė„œë„ ėžë™ ėžŦė—°ę˛°ëĄœ ė„¸ė…˜ ėœ ė§€ -- 터미널 블록, ė—ë””í„°, ė›š 브ëŧėš°ė €, AI ė–´ė‹œėŠ¤í„´íŠ¸ëĨŧ ėœ ė—°í•˜ę˛Œ ë°°ėš˜í•  눘 ėžˆëŠ” 드래그 땤 드롭 ė¸í„°íŽ˜ė´ėŠ¤ -- ęĩŦëŦ¸ ę°•ėĄ°ė™€ ėĩœė‹  íŽ¸ė§‘ 기ëŠĨė„ 렜ęŗĩ하는 ė›ę˛Š 파ėŧ íŽ¸ė§‘ėšŠ 내ėžĨ ė—ë””í„° -- ė›ę˛Š 파ėŧėšŠ 풍ëļ€í•œ 미ëĻŦëŗ´ę¸° ė‹œėŠ¤í…œ (Markdown, ė´ë¯¸ė§€, ë™ė˜ėƒ, PDF, CSV, 디렉터ëĻŦ) -- 블록 ë‹¨ėœ„ ëš ëĨ¸ 렄랴 화면 토글 - 터미널/ė—ë””í„°/미ëĻŦëŗ´ę¸°ëĨŧ íŦ枌 ëŗ´ęŗ  ėĻ‰ė‹œ 멀티 블록 ëŗ´ę¸°ëĄœ ëŗĩ귀 -- ë‹¤ė¤‘ ëĒ¨ë¸ė„ ė§€ė›í•˜ëŠ” AI ėą„íŒ… ėœ„ė ¯ (OpenAI, Claude, Azure, Perplexity, Ollama) -- ę°œëŗ„ ëĒ…ë šė„ ëļ„ëĻŦí•˜ęŗ  ëĒ¨ë‹ˆí„°ë§í•  눘 ėžˆëŠ” Command Blocks -- 한 ë˛ˆė˜ 클ëĻ­ėœŧ로 ė›ę˛Š 뗰枰 및 렄랴 터미널/파ėŧ ė‹œėŠ¤í…œ ė ‘ęˇŧ -- ë„¤ė´í‹°ë¸Œ ė‹œėŠ¤í…œ ë°ąė—”ë“œëĨŧ ė‚ŦėšŠí•˜ëŠ” ė•ˆė „í•œ ė‹œíŦëĻŋ ė €ėžĨ - API í‚¤ė™€ ėžę˛Š ėĻëĒ…ė„ 로ėģŦ뗐 ė €ėžĨí•˜ęŗ  SSH ė„¸ė…˜ 간 ęŗĩ뜠 -- 탭 테마, 터미널 ėŠ¤íƒ€ėŧ, ë°°ę˛Ŋ ė´ë¯¸ė§€ 등 í­ë„“ė€ ėģ¤ėŠ¤í„°ë§ˆė´ė§• -- CLIė—ė„œ ė›ŒíŦėŠ¤íŽ˜ė´ėŠ¤ëĨŧ ė œė–´í•˜ęŗ  ė„¸ė…˜ 간 ë°ė´í„°ëĨŧ ęŗĩėœ í•˜ëŠ” 강ë Ĩ한 `wsh` ëĒ…ë š ė‹œėŠ¤í…œ -- `wsh file`ė„ í†ĩ한 ė—°ę˛°í˜• 파ėŧ 관ëĻŦ - 로ėģŦęŗŧ ė›ę˛Š SSH í˜¸ėŠ¤íŠ¸ 간 파ėŧ ëŗĩė‚Ŧ/동기화 - -## Wave AI - -Wave AI는 ė›ŒíŦėŠ¤íŽ˜ė´ėŠ¤ ë§ĨëŊė„ ė´í•´í•˜ëŠ” 터미널 ė–´ė‹œėŠ¤í„´íŠ¸ėž…ë‹ˆë‹¤. - -- **터미널 ėģ¨í…ėŠ¤íŠ¸**: 디버깅ęŗŧ ëļ„ė„ė„ ėœ„í•´ 터미널 ėļœë Ĩęŗŧ 늤íŦëĄ¤ë°ąė„ ėŊėŠĩ니다. -- **파ėŧ ėž‘ė—…**: ėžë™ ë°ąė—… 및 ė‚ŦėšŠėž ėŠšė¸ 기반ėœŧ로 파ėŧ ėŊ기/듰揰/íŽ¸ė§‘ė„ ėˆ˜í–‰í•Šë‹ˆë‹¤. -- **CLI í†ĩ합**: `wsh ai`로 ëĒ…ë šė¤„ė—ė„œ ėļœë Ĩ íŒŒė´í”„ 뗰枰 또는 파ėŧ 랍ëļ€ę°€ 가ëŠĨ합니다. -- **BYOK 맀뛐**: OpenAI, Claude, Gemini, Azure 등 ë‹¤ė–‘í•œ 렜ęŗĩėžė— API 키ëĨŧ 링렑 ė—°ę˛°í•  눘 ėžˆėŠĩ니다. -- **로ėģŦ ëĒ¨ë¸**: Ollama, LM Studio 및 기타 OpenAI 호환 렜ęŗĩėžëĨŧ í†ĩ해 로ėģŦ ëĒ¨ë¸ė„ ė‹¤í–‰í•  눘 ėžˆėŠĩ니다. -- **ëŦ´ëŖŒ 베타**: ę˛Ŋ험 ę°œė„  기간 ë™ė•ˆ AI íŦë ˆë”§ė´ 렜ęŗĩ됩니다. -- **ęŗ§ 렜ęŗĩ ė˜ˆė •**: ëĒ…ë š ė‹¤í–‰ 기ëŠĨ (ė‚ŦėšŠėž ėŠšė¸ 기반) - -ėžė„¸í•œ ë‚´ėšŠė€ [Wave AI ëŦ¸ė„œ](https://docs.waveterm.dev/waveai)뙀 [Wave AI Modes ëŦ¸ė„œ](https://docs.waveterm.dev/waveai-modes)ëĨŧ ė°¸ęŗ í•˜ė„¸ėš”. - -## ė„¤ėš˜ - -Wave Terminalė€ macOS, Linux, Windowsė—ė„œ ë™ėž‘í•Šë‹ˆë‹¤. - -플ëžĢíŧëŗ„ ė„¤ėš˜ ë°Šë˛•ė€ [ė—Ŧ기](https://docs.waveterm.dev/gettingstarted)ė—ė„œ í™•ė¸í•  눘 ėžˆėŠĩ니다. - -링렑 ë‹¤ėš´ëĄœë“œí•˜ė—Ŧ ė„¤ėš˜í•˜ë ¤ëŠ´ [www.waveterm.dev/download](https://www.waveterm.dev/download)ė„ ė´ėšŠí•˜ė„¸ėš”. - -### ėĩœė†Œ ėš”ęĩŦ ė‚Ŧ항 - -Wave Terminalė€ ë‹¤ėŒ 플ëžĢíŧė—ė„œ ė‹¤í–‰ëŠë‹ˆë‹¤. - -- macOS 11 ė´ėƒ (arm64, x64) -- Windows 10 1809 ė´ėƒ (x64) -- glibc-2.28 ė´ėƒ 기반 Linux (Debian 10, RHEL 8, Ubuntu 20.04 등) (arm64, x64) - -WSH í—Ŧíŧ는 ë‹¤ėŒ 플ëžĢíŧė—ė„œ ė‹¤í–‰ëŠë‹ˆë‹¤. - -- macOS 11 ė´ėƒ (arm64, x64) -- Windows 10 ė´ėƒ (x64) -- Linux Kernel 2.6.32 ė´ėƒ (x64), Linux Kernel 3.1 ė´ėƒ (arm64) - -## 로드ë§ĩ - -Wave는 ęŗ„ė† ë°œė „í•˜ęŗ  ėžˆėŠĩ니다. 로드ë§ĩė€ ëĻ´ëĻŦ늤 ëĒŠí‘œė— 맞ėļ° ė§€ė†ė ėœŧ로 ė—…ë°ė´íŠ¸ëŠë‹ˆë‹¤. [ė—Ŧ기](./ROADMAP.md)ė—ė„œ í™•ė¸í•˜ė„¸ėš”. - -í–Ĩ후 ëĻ´ëĻŦ늤 ë°Ší–Ĩ뗐 ė˜ę˛Ŧė„ ėŖŧęŗ  ė‹ļ다면 [Discord](https://discord.gg/XfvZ334gwU)뗐 ė°¸ė—Ŧ하거나 [Feature Request](https://github.com/wavetermdev/waveterm/issues/new/choose)ëĨŧ 등록해 ėŖŧė„¸ėš”. - -## 링íŦ - -- í™ˆíŽ˜ė´ė§€ — https://www.waveterm.dev -- ë‹¤ėš´ëĄœë“œ íŽ˜ė´ė§€ — https://www.waveterm.dev/download -- ëŦ¸ė„œ — https://docs.waveterm.dev -- X — https://x.com/wavetermdev -- Discord ėģ¤ëŽ¤ë‹ˆí‹° — https://discord.gg/XfvZ334gwU - -## ė†ŒėŠ¤ė—ė„œ 빌드 - -[Building Wave Terminal](BUILD.md)ė„ ė°¸ęŗ í•˜ė„¸ėš”. - -## 기ė—Ŧ하기 - -Wave는 GitHub IssuesëĨŧ ė´ėŠˆ ėļ”렁뗐 ė‚ŦėšŠí•Šë‹ˆë‹¤. - -[기ė—Ŧ ę°€ė´ë“œ](CONTRIBUTING.md)ė—ė„œ 더 ë§Žė€ ė •ëŗ´ëĨŧ í™•ė¸í•  눘 ėžˆėŠĩ니다. - -- [기ė—Ŧ 방법](CONTRIBUTING.md#contributing-to-wave-terminal) -- [기ė—Ŧ ę°€ė´ë“œëŧė¸](CONTRIBUTING.md#high-level-expectations) - -## ëŧė´ė„ ėŠ¤ - -Wave Terminalė€ Apache-2.0 ëŧė´ė„ ėŠ¤ëĨŧ 따ëĻ…ë‹ˆë‹¤. ė˜ėĄ´ė„ą ė •ëŗ´ëŠ” [ė—Ŧ기](./ACKNOWLEDGEMENTS.md)ė—ė„œ í™•ė¸í•  눘 ėžˆėŠĩ니다. diff --git a/README.md b/README.md index a9f406725c..02e1a90e12 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,68 @@

- - - - - Wave Terminal Logo - - + + + + Wave Terminal Logo +

-# Wave Terminal - -
+# Wave Legacy -[English](README.md) | [한ęĩ­ė–´](README.ko.md) | [įšéĢ”ä¸­æ–‡](README.zh-TW.md) +This branch is for the legacy v0.7.7 version of Wave. For the new Wave v8+ code, please use the main branch. -
+# Wave Terminal [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_shield) -Wave is an open-source, AI-integrated terminal for macOS, Linux, and Windows. It works with any AI model. Bring your own API keys for OpenAI, Claude, or Gemini, or run local models via Ollama and LM Studio. No accounts required. - -Wave also supports durable SSH sessions that survive network interruptions and restarts, with automatic reconnection. Edit remote files with a built-in graphical editor and preview files inline without leaving the terminal. - -![WaveTerm Screenshot](./assets/wave-screenshot.webp) +Wave is an open-source AI-native terminal built for seamless workflows. -## Key Features +Wave isn't just another terminal emulator; it's a rethink on how terminals are built. Wave combines command line with the power of the open web to help veteran CLI users and new developers alike. -- Wave AI - Context-aware terminal assistant that reads your terminal output, analyzes widgets, and performs file operations -- Durable SSH Sessions - Remote terminal sessions survive connection interruptions, network changes, and Wave restarts with automatic reconnection -- Flexible drag & drop interface to organize terminal blocks, editors, web browsers, and AI assistants -- Built-in editor for editing remote files with syntax highlighting and modern editor features -- Rich file preview system for remote files (markdown, images, video, PDFs, CSVs, directories) -- Quick full-screen toggle for any block - expand terminals, editors, and previews for better visibility, then instantly return to multi-block view -- AI chat widget with support for multiple models (OpenAI, Claude, Azure, Perplexity, Ollama) -- Command Blocks for isolating and monitoring individual commands -- One-click remote connections with full terminal and file system access -- Secure secret storage using native system backends - store API keys and credentials locally, access them across SSH sessions -- Rich customization including tab themes, terminal styles, and background images -- Powerful `wsh` command system for managing your workspace from the CLI and sharing data between terminal sessions -- Connected file management with `wsh file` - seamlessly copy and sync files between local and remote SSH hosts +- Inline renderers to cut down on context switching. Render code, images, markdown, and CSV files without ever leaving the terminal. +- Persistent sessions that can restore state across network disconnections and reboots +- Searchable contextual command history across all remote sessions (saved locally) +- Workspaces, tabs, and command blocks to keep you organized +- CodeEdit, to edit local and remote files with a VSCode-like inline editor +- AI Integration with ChatGPT (or ChatGPT compatible APIs) to help write commands and get answers inline -## Wave AI - -Wave AI is your context-aware terminal assistant with access to your workspace: - -- **Terminal Context**: Reads terminal output and scrollback for debugging and analysis -- **File Operations**: Read, write, and edit files with automatic backups and user approval -- **CLI Integration**: Use `wsh ai` to pipe output or attach files directly from the command line -- **BYOK Support**: Bring your own API keys for OpenAI, Claude, Gemini, Azure, and other providers -- **Local Models**: Run local models with Ollama, LM Studio, and other OpenAI-compatible providers -- **Free Beta**: Included AI credits while we refine the experience -- **Coming Soon**: Command execution (with approval) - -Learn more in our [Wave AI documentation](https://docs.waveterm.dev/waveai) and [Wave AI Modes documentation](https://docs.waveterm.dev/waveai-modes). +![WaveTerm Screenshot](./assets/wave-screenshot.png) ## Installation -Wave Terminal works on macOS, Linux, and Windows. - -Platform-specific installation instructions can be found [here](https://docs.waveterm.dev/gettingstarted). - -You can also install Wave Terminal directly from: [www.waveterm.dev/download](https://www.waveterm.dev/download). +Wave Terminal works with MacOS and Linux. -### Minimum requirements +Install Wave Terminal from: [www.waveterm.dev/download](https://www.waveterm.dev/download) -Wave Terminal runs on the following platforms: +Also available as a homebrew cask for MacOS: -- macOS 11 or later (arm64, x64) -- Windows 10 1809 or later (x64) -- Linux based on glibc-2.28 or later (Debian 10, RHEL 8, Ubuntu 20.04, etc.) (arm64, x64) - -The WSH helper runs on the following platforms: - -- macOS 11 or later (arm64, x64) -- Windows 10 or later (x64) -- Linux Kernel 2.6.32 or later (x64), Linux Kernel 3.1 or later (arm64) - -## Roadmap - -Wave is constantly improving! Our roadmap will be continuously updated with our goals for each release. You can find it [here](./ROADMAP.md). - -Want to provide input to our future releases? Connect with us on [Discord](https://discord.gg/XfvZ334gwU) or open a [Feature Request](https://github.com/wavetermdev/waveterm/issues/new/choose)! +``` +brew install --cask wave +``` ## Links -- Homepage — https://www.waveterm.dev -- Download Page — https://www.waveterm.dev/download -- Documentation — https://docs.waveterm.dev -- X — https://x.com/wavetermdev -- Discord Community — https://discord.gg/XfvZ334gwU +- Homepage — https://www.waveterm.dev +- Download Page — https://www.waveterm.dev/download +- Documentation — https://docs.waveterm.dev/ +- Blog — https://blog.waveterm.dev/ +- Quick Start Guide — https://docs.waveterm.dev/quickstart/ +- Discord Community — https://discord.gg/XfvZ334gwU ## Building from Source -See [Building Wave Terminal](BUILD.md). +- [MacOS Build Instructions](./BUILD.md) +- [Linux Build Instructions](./build-linux.md) ## Contributing -Wave uses GitHub Issues for issue tracking. +Wave uses Github Issues for issue tracking. Find more information in our [Contributions Guide](CONTRIBUTING.md), which includes: -- [Ways to contribute](CONTRIBUTING.md#contributing-to-wave-terminal) -- [Contribution guidelines](CONTRIBUTING.md#before-you-start) - -### Sponsoring Wave â¤ī¸ - -If Wave Terminal is useful to you or your company, consider sponsoring development. - -Sponsorship helps support the time spent building and maintaining the project. - -- https://github.com/sponsors/wavetermdev +- [Ways to contribute](CONTRIBUTING.md#contributing-to-wave-terminal) +- [Contribution guidelines](CONTRIBUTING.md#before-you-start) ## License -Wave Terminal is licensed under the Apache-2.0 License. For more information on our dependencies, see [here](./ACKNOWLEDGEMENTS.md). +Wave Terminal is licensed under the Apache-2.0 License. For more information on our dependencies, see [here](./acknowledgements/README.md). diff --git a/README.zh-TW.md b/README.zh-TW.md deleted file mode 100644 index c24dca360c..0000000000 --- a/README.zh-TW.md +++ /dev/null @@ -1,168 +0,0 @@ -

- - - - - Wave Terminal Logo - - -
-

- -# Wave Terminal - -
- -[English](README.md) | [한ęĩ­ė–´](README.ko.md) | [įšéĢ”ä¸­æ–‡](README.zh-TW.md) - -
- -[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_shield) - -> æœŦ文äģļį‚ēį¤žįž¤įšéĢ”ä¸­æ–‡įŋģč­¯į‰ˆæœŦ。最新原文čĢ‹åƒé–ą [README.md](README.md)。 - -Wave 是一æŦžé–‹æēã€æ•´åˆ AI įš„įĩ‚įĢ¯æŠŸæ‡‰į”¨į¨‹åŧīŧŒæ”¯æ´ macOS、Linux 與 Windows。厃可äģĨ搭配äģģäŊ• AI æ¨Ąåž‹äŊŋį”¨â€”â€”č‡ĒčĄŒæäž› OpenAI、Claude 或 Gemini įš„ API 金鑰īŧŒæˆ–透過 Ollama 與 LM Studio åŸˇčĄŒæœŦåœ°æ¨Ąåž‹īŧŒåŽŒå…¨ä¸éœ€čρč¨ģå†Šå¸ŗč™Ÿã€‚ - -Wave 同時支援**持䚅化 SSH é€Ŗįˇš**īŧŒåŗäŊŋįļ˛čˇ¯ä¸­æ–ˇæˆ–æ‡‰į”¨į¨‹åŧé‡æ–°å•Ÿå‹•īŧŒé€Ŗįˇšä🿜ƒč‡Ē動æĸ垊。äŊ å¯äģĨäŊŋᔍ內åģēįš„åœ–åŊĸåŒ–įˇ¨čŧ¯å™¨į›´æŽĨᎍčŧ¯é į̝æĒ”æĄˆīŧŒäšŸčƒŊ在不é›ĸ開įĩ‚įĢ¯æŠŸįš„æƒ…æŗä¸‹åŗæ™‚é čĻŊæĒ”æĄˆå…§åŽšã€‚ - -![WaveTerm Screenshot](./assets/wave-screenshot.webp) - -## ä¸ģčρ功čƒŊ - -### 🤖 Wave AI — 情åĸƒæ„ŸįŸĨįĩ‚įĢ¯æŠŸåŠŠæ‰‹ - -Wave AI 不åĒæ˜¯ä¸€å€‹čŠå¤ŠæŠŸå™¨äēē——厃čƒŊį›´æŽĨčŽ€å–äŊ įš„įĩ‚įĢ¯æŠŸčŧ¸å‡ēã€åˆ†æžį›Žå‰é–‹å•Ÿįš„å°åˇĨå…ˇīŧˆWidgetīŧ‰īŧŒé‚„čƒŊåŸˇčĄŒæĒ”æĄˆæ“äŊœã€‚į•ļäŊ åœ¨ Debug 時īŧŒAI čƒŊįœ‹åˆ°äŊ įš„錯čĒ¤č¨Šæ¯ä¸ĻįĩĻäēˆé‡å°æ€§įš„åģēč­°īŧŒč€Œä¸æ˜¯æŗ›æŗ›įš„å›žį­”ã€‚ - -- **įĩ‚įĢ¯æŠŸæƒ…åĸƒæ„ŸįŸĨ**īŧšč‡Ēå‹•čŽ€å–įĩ‚įĢ¯æŠŸčŧ¸å‡ēčˆ‡æ˛å‹•įˇŠčĄå€īŧˆScrollbackīŧ‰īŧŒį”¨æ–ŧé™¤éŒ¯čˆ‡åˆ†æž -- **æĒ”æĄˆæ“äŊœ**īŧšå¯čŽ€å–ã€å¯Ģå…Ĩã€įˇ¨čŧ¯æĒ”æĄˆīŧŒæ­é…č‡Ē動備äģŊ抟åˆļ與äŊŋį”¨č€…å¯Šæ ¸įĸēčĒ -- **CLI 整合**īŧšé€éŽ `wsh ai` å‘Ŋäģ¤īŧŒį›´æŽĨ在å‘Ŋäģ¤åˆ—中將čŧ¸å‡ē導å…Ĩ AI 或附加æĒ”æĄˆ -- **BYOKīŧˆč‡Ēå¸ļ金鑰īŧ‰**īŧšæ”¯æ´ OpenAI、Claude、Gemini、Azure į­‰å¤šåŽļäž›æ‡‰å•†įš„ API 金鑰 -- **æœŦåœ°æ¨Ąåž‹**īŧšé€éŽ Ollama、LM Studio 及å…ļäģ– OpenAI į›¸åŽšäž›æ‡‰å•†åŸˇčĄŒæœŦåœ°æ¨Ąåž‹īŧŒčŗ‡æ–™åŽŒå…¨ä¸é›ĸ開äŊ įš„é›ģč…Ļ -- **免č˛ģ Beta**īŧšéĢ”éŠ—å„Ē化期間提䞛免č˛ģ AI 額åēĻ -- **åŗå°‡æŽ¨å‡ē**īŧšå‘Ŋäģ¤åŸˇčĄŒåŠŸčƒŊīŧˆéœ€äŊŋį”¨č€…æ ¸å‡†īŧ‰ - -čŠŗį´°čĒĒæ˜ŽčĢ‹åƒé–ą [Wave AI 文äģļ](https://docs.waveterm.dev/waveai) 與 [Wave AI Modes 文äģļ](https://docs.waveterm.dev/waveai-modes)。 - -### 🔗 持䚅化 SSH é€Ŗįˇš - -傺įĩąįš„ SSH é€Ŗįˇšåœ¨įļ˛čˇ¯ä¸įŠŠæ™‚å°ąæœƒæ–ˇé–‹īŧŒäŊ åž—é‡æ–°é€Ŗįˇšã€é‡æ–°åˆ‡æ›į›ŽéŒ„ã€é‡æ–°å•Ÿå‹•į¨‹åŧã€‚Wave įš„æŒäš…åŒ– SSH é€Ŗįˇšåžšåē•č§Ŗæąēäē†é€™å€‹į—›éģžâ€”â€”é€Ŗįˇšä¸­æ–ˇåžŒæœƒč‡Ē動重新åģēįĢ‹īŧŒäŊ įš„åˇĨäŊœéšŽæŽĩīŧˆSessionīŧ‰åŽŒæ•´äŋį•™īŧŒå°ąåƒäģ€éēŧéƒŊæ˛’į™ŧį”ŸéŽä¸€æ¨Ŗã€‚ - -- é€Ŗįˇšä¸­æ–ˇã€įļ˛čˇ¯åˆ‡æ›ã€Wave 重啟垌č‡Ēå‹•é‡æ–°é€Ŗįˇš -- åˇĨäŊœéšŽæŽĩį‹€æ…‹åŽŒæ•´äŋį•™ -- 一éĩåŗå¯é€Ŗįˇšé į̝äŧ翜å™¨īŧŒåŽŒæ•´å­˜å–įĩ‚įĢ¯æŠŸčˆ‡æĒ”æĄˆįŗģįĩą - -### 🧩 åŊˆæ€§æ‹–攞äģ‹éĸ - -Wave įš„äģ‹éĸį”ąå¯č‡Ēį”ąæŽ’åˆ—įš„ã€Œå€åĄŠīŧˆBlockīŧ‰ã€įĩ„成。äŊ å¯äģĨ將įĩ‚įĢ¯æŠŸã€įˇ¨čŧ¯å™¨ã€įļ˛é į€čĻŊ器、AI 劊手像æ‹ŧåœ–ä¸€æ¨ŖæŽ’åˆ—åœ¨åŒä¸€å€‹į•Ģéĸ中īŧŒæ‰“造最遊合äŊ åˇĨäŊœæĩį¨‹įš„äŊˆåą€ã€‚æ¯å€‹å€åĄŠéƒŊčƒŊ一éĩ切換全čžĸåš•īŧŒæ”žå¤§æŸĨįœ‹åžŒįĢ‹åŗå›žåˆ°å¤šå€åĄŠčĻ–åœ–ã€‚ - -### âœī¸ 內åģēᎍčŧ¯å™¨ - -不需čĻéĄå¤–é–‹å•Ÿ VS Code 或 Vim——Wave 內åģēįš„åœ–åŊĸåŒ–įˇ¨čŧ¯å™¨æ”¯æ´čĒžæŗ•é̘äēŽčˆ‡įžäģŖįˇ¨čŧ¯åŠŸčƒŊīŧŒå¯äģĨį›´æŽĨᎍčŧ¯æœŦ地或遠į̝æĒ”æĄˆã€‚å°æ–ŧ需čρåŋĢ速äŋŽæ”šč¨­åޚæĒ”æˆ–į¨‹åŧįĸŧįš„å ´æ™¯į‰šåˆĨæ–šäžŋ。 - -### 📄 čąå¯Œįš„æĒ”æĄˆé čĻŊįŗģįĩą - -į›´æŽĨ在įĩ‚įĢ¯æŠŸå…§é čĻŊå„į¨Žæ ŧåŧįš„遠į̝æĒ”æĄˆīŧŒį„Ąéœ€ä¸‹čŧ‰īŧš - -- Markdown 文äģļīŧˆæ¸˛æŸ“åžŒå‘ˆįžīŧ‰ -- åœ–į‰‡ã€åŊąį‰‡ -- PDF 文äģļ -- CSV čŠĻįŽ—čĄ¨ -- į›ŽéŒ„įĩæ§‹ - -### đŸ’Ŧ AI čŠå¤Šå°åˇĨå…ˇ - -æ”¯æ´å¤šį¨Ž AI æ¨Ąåž‹įš„čŠå¤Šäģ‹éĸīŧŒå¯åŒæ™‚開啟多個 AI å°čŠąčĻ–įĒ—īŧš - -- OpenAIīŧˆGPT įŗģ列īŧ‰ -- Anthropic Claude -- Azure OpenAI -- Perplexity -- OllamaīŧˆæœŦåœ°æ¨Ąåž‹īŧ‰ - -### đŸ“Ļ Command Blocksīŧˆå‘Ŋäģ¤å€åĄŠīŧ‰ - -æ¯å€‹åŸˇčĄŒįš„å‘Ŋäģ¤éƒŊ會čĸĢį¨įĢ‹å°čŖåœ¨ä¸€å€‹å€åĄŠä¸­īŧŒäŊ å¯äģĨīŧš - -- 清æĨšåˆ†éš”不同å‘Ŋäģ¤įš„čŧ¸å‡ēįĩæžœ -- 個åˆĨį›ŖæŽ§é•ˇæ™‚é–“åŸˇčĄŒįš„å‘Ŋäģ¤ -- čŧ•éŦ†å›žéĄ§æ­ˇå˛å‘Ŋäģ¤įš„čŧ¸å‡ē - -### 🔐 åŽ‰å…¨įš„å¯†é‘°å„˛å­˜ - -äŊŋᔍäŊœæĨ­įŗģįĩąåŽŸį”Ÿįš„åŽ‰å…¨å„˛å­˜åžŒį̝īŧˆåĻ‚ macOS Keychain、Windows Credential Managerīŧ‰äž†äŋå­˜ API 金鑰和į™ģå…Ĩæ†‘č­‰ã€‚å¯†é‘°å„˛å­˜åœ¨æœŦ地īŧŒä¸Ļå¯åœ¨ä¸åŒįš„ SSH é€Ŗįˇšé–“å…ąäēĢäŊŋį”¨ã€‚ - -### 🎨 čąå¯Œįš„č‡Ē訂選項 - -- 分頁ä¸ģ題配色 -- įĩ‚įĢ¯æŠŸæ¨ŖåŧčĒŋ整 -- čƒŒæ™¯åœ–į‰‡č¨­åŽš -- 打造專åąŦæ–ŧäŊ įš„åˇĨäŊœį’°åĸƒ - -### đŸ› ī¸ `wsh` å‘Ŋäģ¤įŗģįĩą - -`wsh` 是 Wave æäž›įš„åŧˇå¤§ CLI åˇĨå…ˇīŧŒčŽ“äŊ åžžå‘Ŋäģ¤åˆ—įŽĄį†æ•´å€‹åˇĨäŊœįŠē間īŧš - -- 在不同įĩ‚įĢ¯æŠŸé€Ŗįˇšé–“å…ąäēĢčŗ‡æ–™ -- 透過 `wsh file` 在æœŦåœ°čˆ‡é į̝ SSH ä¸ģæŠŸäš‹é–“į„Ąį¸Ģ複čŖŊ和同æ­ĨæĒ”æĄˆ -- åžžå‘Ŋäģ¤åˆ—į›´æŽĨ控åˆļ Wave įš„äģ‹éĸäŊˆåą€ - -## åŽ‰čŖ - -Wave Terminal 支援 macOS、Linux 與 Windows。 - -å„åšŗå°įš„åŽ‰čŖčĒĒæ˜ŽčĢ‹åƒé–ą[æ­¤č™•](https://docs.waveterm.dev/gettingstarted)。 - -äŊ äšŸå¯äģĨį›´æŽĨ垞厘斚下čŧ‰é éĸåŽ‰čŖīŧš[www.waveterm.dev/download](https://www.waveterm.dev/download)。 - -### 最äŊŽįŗģįĩąéœ€æą‚ - -Wave Terminal 支援äģĨä¸‹åšŗå°īŧš - -- macOS 11 æˆ–æ›´æ–°į‰ˆæœŦīŧˆarm64、x64īŧ‰ -- Windows 10 1809 æˆ–æ›´æ–°į‰ˆæœŦīŧˆx64īŧ‰ -- åŸēæ–ŧ glibc-2.28 æˆ–æ›´æ–°į‰ˆæœŦįš„ LinuxīŧˆDebian 10、RHEL 8、Ubuntu 20.04 į­‰īŧ‰īŧˆarm64、x64īŧ‰ - -WSH čŧ”åŠŠį¨‹åŧæ”¯æ´äģĨä¸‹åšŗå°īŧš - -- macOS 11 æˆ–æ›´æ–°į‰ˆæœŦīŧˆarm64、x64īŧ‰ -- Windows 10 æˆ–æ›´æ–°į‰ˆæœŦīŧˆx64īŧ‰ -- Linux Kernel 2.6.32 æˆ–æ›´æ–°į‰ˆæœŦīŧˆx64īŧ‰ã€Linux Kernel 3.1 æˆ–æ›´æ–°į‰ˆæœŦīŧˆarm64īŧ‰ - -## į™ŧåą•č—åœ– - -Wave 持įēŒé€˛åŒ–中īŧį™ŧåą•č—åœ–æœƒéš¨æ¯æŦĄį™ŧčĄŒį‰ˆæœŦ持į猿›´æ–°īŧŒč̋臺[æ­¤č™•](./ROADMAP.md)æŸĨé–ąã€‚ - -æƒŗį‚ēæœĒäž†į‰ˆæœŦ提䞛åģēč­°īŧŸæ­ĄčŋŽåŠ å…Ĩ [Discord](https://discord.gg/XfvZ334gwU) į¤žįž¤īŧŒæˆ–提äē¤ [Feature Request](https://github.com/wavetermdev/waveterm/issues/new/choose)īŧ - -## 逪įĩ - -- 厘斚įļ˛įĢ™ — https://www.waveterm.dev -- 下čŧ‰é éĸ — https://www.waveterm.dev/download -- æŠ€čĄ“æ–‡äģļ — https://docs.waveterm.dev -- XīŧˆTwitterīŧ‰— https://x.com/wavetermdev -- Discord į¤žįž¤ — https://discord.gg/XfvZ334gwU - -## 垞原始įĸŧåģēįŊŽ - -čĢ‹åƒé–ą [Building Wave Terminal](BUILD.md)。 - -## č˛ĸįģ - -Wave äŊŋᔍ GitHub Issues é€˛čĄŒå•éĄŒčŋŊčš¤ã€‚ - -æ›´å¤ščŗ‡č¨ŠčĢ‹åƒé–ą[č˛ĸįģ指南](CONTRIBUTING.md)īŧŒå…ļ中包åĢīŧš - -- [č˛ĸįģæ–šåŧ](CONTRIBUTING.md#contributing-to-wave-terminal) -- [č˛ĸįģčĻį¯„](CONTRIBUTING.md#before-you-start) - -### č´ŠåŠŠ Wave â¤ī¸ - -åĻ‚æžœ Wave Terminal 對äŊ æˆ–äŊ įš„å…Ŧ司有åšĢ劊īŧŒæ­ĄčŋŽč´ŠåŠŠé–‹į™ŧåˇĨäŊœã€‚ - -č´ŠåŠŠæœ‰åŠŠæ–ŧæ”¯æŒå°ˆæĄˆįš„åģēįŊŽčˆ‡įļ­č­ˇæ‰€æŠ•å…Ĩįš„æ™‚é–“ã€‚ - -- https://github.com/sponsors/wavetermdev - -## 授æŦŠæĸæŦž - -Wave Terminal æŽĄį”¨ Apache-2.0 授æŦŠæĸæŦžã€‚į›¸äžæ€§čŗ‡č¨ŠčĢ‹åƒé–ą[æ­¤č™•](./ACKNOWLEDGEMENTS.md)。 diff --git a/RELEASES.md b/RELEASES.md deleted file mode 100644 index acde6eccf1..0000000000 --- a/RELEASES.md +++ /dev/null @@ -1,89 +0,0 @@ -# Building for release - -## Step-by-step guide - -1. Go to the [Actions tab](https://github.com/wavetermdev/waveterm/actions) and select "Bump Version" from the left sidebar. -2. Click on "Run workflow". - - You will see two options: - - "SemVer Bump": This defaults to `none`. Adjust this if you want to increment the version number according to semantic versioning rules (`patch`, `minor`, `major`). - - "Is Prerelease": This defaults to `true`. If set to `true`, a `-beta.X` version will be appended to the end of the version. If one is already present and the base SemVer is not being incremented, the `-beta` version will be incremented (i.e. `0.11.1-beta.0` to `0.11.1-beta.1`). If set to `false`, the `-beta.X` suffix will be removed from the version number. If one was not already present, it will remain absent. - - Some examples: - - If you are creating a new prerelease following an official release, you would set "SemVer Bump" to to the expected version bump (`patch`, `minor`, or `major`) and "Is Prerelease" to `true`. - - If you are bumping an existing prerelease to a new prerelease under the same version, you would set "SemVer Bump" to `none` and "Is Prerelease" to `true`. - - If you are promoting a prerelease version to an official release, you would set "SemVer Bump" to `none` and "Is Prerelease" to `false`. -3. After "Bump Version" a "Build Helper" run will kick off automatically for the new version. When this completes, it will generate a [draft GitHub Release](https://github.com/wavetermdev/waveterm/releases) with all the built artifacts. -4. Review the artifacts in the release and test them locally. -5. When you are confident that the build is good, edit the GitHub Release to add a changelog and release summary and publish the release. -6. The new version will be published to our release feed automatically when the GitHub Release is published. If the build is a prerelease, it will only release to users subscribed to the `beta` channel. If it is a general release, it will be released to all users. - -## Details - -### Bump Version workflow - -All releases start by first bumping the package version and creating a new Git tag. We have a workflow set up to automate this. - -To run it, trigger a new run of the [Bump Version workflow](https://github.com/wavetermdev/waveterm/actions/workflows/bump-version.yml). When triggering the run, you will be prompted to select a version bump type, either `none`, `patch`, `minor`, or `major`, and whether the version is prerelease or not. This determines how much the version number is incremented. - -See [`version.cjs`](./version.cjs) for more details on how this works. - -Once the tag has been created, a new [Build Helper](#build-helper-workflow) run will be automatically queued to generate the artifacts. - -### Build Helper workflow - -Our release builds are managed by the [Build Helper workflow](https://github.com/wavetermdev/waveterm/actions/workflows/build-helper.yml). - -Under the hood, this will call the `package` task in [`Taskfile.yml`](./Taskfile.yml), which will build the `wavesrv` and `wsh` binaries, then the frontend and Electron codebases using Vite, then it will call `electron-builder` to generate the distributable app packages. The configuration for `electron-builder` is defined in [`electron-builder.config.cjs`](./electron-builder.config.cjs). - -This will also sign and notarize the macOS app packages and sign the Windows packages. - -Once a build is complete, the artifacts will be placed in `s3://waveterm-github-artifacts/staging-w2/`. A new draft release will be created on GitHub and the artifacts will be uploaded there too. - -### Testing new releases - -The [Build Helper workflow](https://github.com/wavetermdev/waveterm/actions/workflows/build-helper.yml). creates a draft release on GitHub once it completes. You can find this on the [Releases page](https://github.com/wavetermdev/waveterm/releases) of the repo. You can use this to download the build artifacts for testing. - -You can also use the `artifacts:download` task in the [`Taskfile.yml`](./Taskfile.yml) to download all the artifacts for a build. You will need to configure an AWS CLI profile with write permissions for the S3 buckets in order for the script to work. You should invoke the tasks as follows: - -```bash -task artifacts:download: -- --profile -``` - -### Publishing a release - -Once you have validated that the new release is ready, navigate to the [Releases page](https://github.com/wavetermdev/waveterm/releases) and click on the draft release for the version that is ready. Click the pencil button in the top right corner to edit the draft. Use this opportunity to adjust the release notes as needed. When you are ready to publish, scroll all the way to the bottom of the release editor and click Publish. This will kick off the [Publish Release workflow](https://github.com/wavetermdev/waveterm/actions/workflows/publish-release.yml), at which point all further tasks are automated and hands-off. - -### Automatic updates - -Thanks to [`electron-updater`](https://www.electron.build/auto-update.html), we are able to provide automatic app updates for macOS, Linux, and Windows, as long as the app was distributed as a DMG, AppImage, RPM, or DEB file (all Windows targets support auto updates). - -With each release, YAML files will be produced that point to the newest release for the current channel. These also include file sizes and checksums to aid in validating the packages. The app will check these files in our S3 bucket every hour to see if a new version is available. - -#### Update channels - -We utilize update channels to roll out beta and stable releases. These are determined based on the package versioning [described above](#bump-version-workflow). Users can select their update channel using the `autoupdate:channel` setting in Wave. See [here](https://www.electron.build/tutorials/release-using-channels.html) for more information. - -### Package Managers - -We currently publish to Homebrew (macOS), WinGet (Windows), Chocolatey (Windows), and Snap (Linux or macOS). - -#### Homebrew - -Homebrew maintains an Autobump bot that regularly checks our release feed for new general releases and updates our Cask automatically. You can find the configuration for our cask [here](https://github.com/Homebrew/homebrew-cask/blob/master/Casks/w/wave.rb). We added ourselves to [this list](https://github.com/Homebrew/homebrew-cask/blob/master/.github/autobump.txt) to indicate that we want the bot to autobump us. - -#### WinGet - -WinGet uses PRs to manage version bumps for packages. They ship a tool called [`wingetcreate`](https://github.com/microsoft/winget-create) which automates most of this process. We run this tool in our [Publish Release workflow](https://github.com/wavetermdev/waveterm/actions/workflows/publish-release.yml) for all general releases. This publishes a PR to their repository using our [Wave Release Bot](https://github.com/wave-releaser) service account. They usually pick up these changes within a day. - -#### Chocolatey - -Chocolatey maintains a [PowerShell module](https://github.com/chocolatey-community/chocolatey-au) for publishing releases to their system. We have a separate repository which contains this script and the workflow to run it: [wavetermdev/chocolatey](https://github.com/wavetermdev/chocolatey). This workflow gets run once a day. It checks whether there are new changes, validates the SHA and that the package can install, and then pushes the new version to Chocolatey. It then commits the updated package spec back to our repository. They usually take up to two weeks to accept our updates. - -#### Snap - -Snap maintains [snapcraft](https://snapcraft.io/docs/snapcraft) to build and publish Snaps to the Snap Store. We run this tool in our [Publish Release workflow](https://github.com/wavetermdev/waveterm/actions/workflows/publish-release.yml) workflow for all beta and general releases. Beta releases publish only to the `beta` channel, while general releases publish to both `beta` and `stable`. These changes are picked up immediately. - -### `electron-build` configuration - -Most of our configuration is fairly standard. The main exception to this is that we exclude our Go binaries from the ASAR archive that Electron generates. ASAR files cannot be executed by NodeJS because they are not seen as files and therefore cannot be executed via a Shell command. More information can be found [here](https://www.electronjs.org/docs/latest/tutorial/asar-archives#executing-binaries-inside-asar-archive). - -We also exclude most of our `node_modules` from packaging, as Vite handles packaging of any dependencies for us. The one exception is `monaco-editor`. diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index c41bece9ae..0000000000 --- a/ROADMAP.md +++ /dev/null @@ -1,86 +0,0 @@ -# Wave Terminal Roadmap - -This roadmap outlines major upcoming features and improvements for Wave Terminal. As with any roadmap, priorities and timelines may shift as development progresses. - -Want input on the roadmap? Join the discussion on [Discord](https://discord.gg/XfvZ334gwU). - -Legend: ✅ Done | 🔧 In Progress | 🔷 Planned | 🤞 Stretch Goal - -## Current AI Capabilities - -Wave Terminal's AI assistant is already powerful and continues to evolve. Here's what works today: - -### AI Provider Support - -- ✅ OpenAI (including gpt-5 and gpt-5-mini models) -- ✅ Google Gemini (v0.13) -- ✅ OpenRouter and custom OpenAI-compatible endpoints (v0.13) -- ✅ Azure OpenAI (modern and legacy APIs) (v0.13) -- ✅ Local AI models via Ollama, LM Studio, vLLM, and other OpenAI-compatible servers (v0.13) - -### Context & Input - -- ✅ Widget context integration - AI sees your open terminals, web views, and other widgets -- ✅ Image and document upload - Attach images and files to conversations -- ✅ Local file reading - Read text files and directory listings on local machine -- ✅ Web search - Native web search capability for current information -- ✅ Shell integration awareness - AI understands terminal state (shell, version, OS, etc.) - -### Widget Interaction Tools - -- ✅ Widget screenshots - Capture visual state of any widget -- ✅ Terminal scrollback access - Read terminal history and output -- ✅ Web navigation - Control browser widgets - -## ROADMAP Enhanced AI Capabilities - -### AI Configuration & Flexibility - -- ✅ BYOK (Bring Your Own Key) - Use your own API keys for any supported provider (v0.13) -- ✅ Local AI agents - Run AI models locally on your machine (v0.13) -- 🔧 Enhanced provider configuration options -- 🔷 Context (add markdown files to give persistent system context) - -### Expanded Provider Support - -- 🔷 Anthropic Claude - Full integration with extended thinking and tool use - -### Advanced AI Tools - -#### File Operations - -- ✅ AI file writing with intelligent diff previews -- ✅ Rollback support for AI-made changes -- 🔷 Multi-file editing workflows -- 🔷 Safe file modification patterns - -#### Terminal Command Execution - -- 🔧 Execute commands directly from AI -- ✅ Intelligent terminal state detection -- 🔧 Command result capture and parsing - -### Remote & Advanced Capabilities - -- 🔷 Remote file operations - Read and write files on SSH connections -- 🔷 Custom AI-powered widgets (Tsunami framework) -- 🔷 AI Can spawn Wave Blocks -- 🔷 Drag&Drop from Preview Widgets to Wave AI - -### Wave AI Widget Builder - -- 🔷 Visual builder for creating custom AI-powered widgets -- 🔷 Template library for common AI workflows -- 🔷 Rapid prototyping and iteration tools - -## Other Platform & UX Improvements (Non AI) - -- 🔷 Import/Export tab layouts and widgets -- 🔧 Enhanced layout actions (splitting, replacing blocks) -- 🔷 Extended drag & drop for files/URLs -- 🔷 Tab templates for quick workspace setup -- 🔷 Advanced keybinding customization - - 🔷 Widget launch shortcuts - - 🔷 System keybinding reassignment -- 🔷 Command Palette -- 🔷 Monaco Editor theming diff --git a/SECURITY.md b/SECURITY.md index 966322e190..10f45830a6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,4 +2,4 @@ To report vulnerabilities or security concerns, please email us at: [security@commandline.dev](mailto:security@commandline.dev) -**Please do not report security vulnerabilities through public github issues.** \ No newline at end of file +** Please do not report security vulnerabilities through public github issues. ** \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml deleted file mode 100644 index bf37a83e45..0000000000 --- a/Taskfile.yml +++ /dev/null @@ -1,659 +0,0 @@ -# Copyright 2026, Command Line Inc. -# SPDX-License-Identifier: Apache-2.0 - -version: "3" - -vars: - APP_NAME: "Wave" - BIN_DIR: "bin" - VERSION: - sh: node version.cjs - RMRF: '{{if eq OS "windows"}}powershell -NoProfile -NonInteractive Remove-Item -Force -Recurse -ErrorAction SilentlyContinue{{else}}rm -rf{{end}}' - DATE: '{{if eq OS "windows"}}powershell -NoProfile -NonInteractive Get-Date -UFormat{{else}}date{{end}}' - ARTIFACTS_BUCKET: waveterm-github-artifacts/staging-w2 - RELEASES_BUCKET: dl.waveterm.dev/releases-w2 - WINGET_PACKAGE: CommandLine.Wave - -tasks: - electron:dev: - desc: Run the Electron application via the Vite dev server (enables hot reloading). - cmd: npm run dev - aliases: - - dev - deps: - - npm:install - - build:backend - - build:tsunamiscaffold - env: - WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" - WCLOUD_PING_ENDPOINT: "https://ping-dev.waveterm.dev/central" - WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central" - WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev" - WAVETERM_NOCONFIRMQUIT: "1" - - electron:start: - desc: Run the Electron application directly. - cmd: npm run start - aliases: - - start - deps: - - npm:install - - build:backend - env: - WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" - WCLOUD_PING_ENDPOINT: "https://ping-dev.waveterm.dev/central" - WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central" - WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev" - - electron:quickdev: - desc: Run the Electron application via the Vite dev server (quick dev - no docsite, arm64 only, no generate, no wsh). - cmd: npm run dev - deps: - - npm:install - - build:backend:quickdev - env: - WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" - WCLOUD_PING_ENDPOINT: "https://ping-dev.waveterm.dev/central" - WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central" - WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev/" - WAVETERM_NOCONFIRMQUIT: "1" - - preview: - desc: Run the standalone component preview server with HMR (no Electron, no backend). - dir: frontend/preview - cmd: npx vite - deps: - - npm:install - - build:preview: - desc: Build the component preview server for static deployment. - dir: frontend/preview - cmd: npx vite build - deps: - - npm:install - - electron:winquickdev: - desc: Run the Electron application via the Vite dev server (quick dev - Windows amd64 only, no generate, no wsh). - cmd: npm run dev - deps: - - npm:install - - build:backend:quickdev:windows - env: - WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" - WCLOUD_PING_ENDPOINT: "https://ping-dev.waveterm.dev/central" - WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central" - WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev/" - - docs:npm:install: - desc: Runs `npm install` in docs directory - internal: true - generates: - - docs/node_modules/**/* - - docs/package-lock.json - sources: - - docs/package-lock.json - - docs/package.json - cmd: npm install - dir: docs - - docsite:start: - desc: Start the docsite dev server. - cmd: npm run start - dir: docs - aliases: - - docsite - deps: - - docs:npm:install - - docsite:build:public: - desc: Build the full docsite. - cmds: - - cd docs && npm run build - env: - USE_SIMPLE_CSS_MINIFIER: "true" - sources: - - "docs/*" - - "docs/src/**/*" - - "docs/docs/**/*" - - "docs/static/**/*" - generates: - - "docs/build/**/*" - deps: - - docs:npm:install - - package: - desc: Package the application for the current platform. - cmds: - - npm run build:prod && npm exec electron-builder -- -c electron-builder.config.cjs -p never {{.CLI_ARGS}} - deps: - - clean - - npm:install - - build:backend - - build:tsunamiscaffold - - build:frontend:dev: - desc: Build the frontend in development mode. - cmd: npm run build:dev - deps: - - npm:install - - build:backend: - desc: Build the wavesrv and wsh components. - cmds: - - task: build:server - - task: build:wsh - - build:backend:quickdev: - desc: Build only the wavesrv component for quickdev (arm64 macOS only, no generate, no wsh). - cmds: - - task: build:server:quickdev - sources: - - go.mod - - go.sum - - pkg/**/*.go - - pkg/**/*.sh - - cmd/**/*.go - - tsunami/go.mod - - tsunami/go.sum - - tsunami/**/*.go - - package.json - - build:schema: - desc: Build the schema for configuration. - sources: - - "cmd/generateschema/*.go" - - "pkg/wconfig/*.go" - generates: - - "dist/schema/**/*" - cmds: - - go run cmd/generateschema/main-generateschema.go - - cmd: '{{.RMRF}} "dist/schema"' - ignore_error: true - - task: copyfiles:'schema':'dist/schema' - - build:server: - desc: Build the wavesrv component. - cmds: - - task: build:server:linux - - task: build:server:macos - - task: build:server:windows - deps: - - go:mod:tidy - - generate - sources: - - "cmd/server/*.go" - - "pkg/**/*.go" - - "pkg/**/*.json" - - "pkg/**/*.sh" - - tsunami/**/*.go - - package.json - generates: - - dist/bin/wavesrv.* - - build:server:macos: - desc: Build the wavesrv component for macOS (Darwin) platforms (generates artifacts for both arm64 and amd64). - platforms: [darwin] - cmds: - - cmd: rm -f dist/bin/wavesrv* - ignore_error: true - - task: build:server:internal - vars: - ARCHS: arm64,amd64 - - build:server:quickdev: - desc: Build the wavesrv component for quickdev (arm64 macOS only, no generate). - platforms: [darwin] - cmds: - - task: build:server:internal - vars: - ARCHS: arm64 - deps: - - go:mod:tidy - sources: - - "cmd/server/*.go" - - "pkg/**/*.go" - - "pkg/**/*.json" - - "pkg/**/*.sh" - - "tsunami/**/*.go" - generates: - - dist/bin/wavesrv.* - - build:backend:quickdev:windows: - desc: Build only the wavesrv component for quickdev (Windows amd64 only, no generate, no wsh). - platforms: [windows] - cmds: - - task: build:server:internal - vars: - ARCHS: amd64 - GO_ENV_VARS: CC="zig cc -target x86_64-windows-gnu" - deps: - - go:mod:tidy - sources: - - "cmd/server/*.go" - - "pkg/**/*.go" - - "pkg/**/*.json" - - "pkg/**/*.sh" - - "tsunami/**/*.go" - generates: - - dist/bin/wavesrv.x64.exe - - build:server:windows: - desc: Build the wavesrv component for Windows platforms (only generates artifacts for the current architecture). - platforms: [windows] - cmds: - - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wavesrv*" - ignore_error: true - - task: build:server:internal - vars: - ARCHS: - sh: echo {{if eq "arm" ARCH}}arm64{{else}}{{ARCH}}{{end}} - GO_ENV_VARS: - sh: echo "{{if eq "amd64" ARCH}}CC=\"zig cc -target x86_64-windows-gnu\"{{else}}CC=\"zig cc -target aarch64-windows-gnu\"{{end}}" - - build:server:linux: - desc: Build the wavesrv component for Linux platforms (only generates artifacts for the current architecture). - platforms: [linux] - cmds: - - cmd: rm -f dist/bin/wavesrv* - ignore_error: true - - task: build:server:internal - vars: - ARCHS: - sh: echo {{if eq "arm" ARCH}}arm64{{else}}{{ARCH}}{{end}} - GO_ENV_VARS: - sh: echo "{{if eq "amd64" ARCH}}CC=\"zig cc -target x86_64-linux-gnu.2.28\"{{else}}CC=\"zig cc -target aarch64-linux-gnu.2.28\"{{end}}" - - build:server:internal: - requires: - vars: - - ARCHS - cmd: - cmd: CGO_ENABLED=1 GOARCH={{.GOARCH}} {{.GO_ENV_VARS}} go build -tags "osusergo,sqlite_omit_load_extension" -ldflags "{{.GO_LDFLAGS}} -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wavesrv.{{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}{{exeExt}} cmd/server/main-server.go - for: - var: ARCHS - split: "," - as: GOARCH - internal: true - - build:wsh: - desc: Build the wsh component for all possible targets. - cmds: - - cmd: rm -f dist/bin/wsh* - platforms: [darwin, linux] - ignore_error: true - - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wsh*" - platforms: [windows] - ignore_error: true - - task: build:wsh:parallel - deps: - - go:mod:tidy - - generate - sources: - - "cmd/wsh/**/*.go" - - "pkg/**/*.go" - - package.json - generates: - - "dist/bin/wsh*" - - build:wsh:parallel: - deps: - - task: build:wsh:internal - vars: - GOOS: darwin - GOARCH: arm64 - - task: build:wsh:internal - vars: - GOOS: darwin - GOARCH: amd64 - - task: build:wsh:internal - vars: - GOOS: linux - GOARCH: arm64 - - task: build:wsh:internal - vars: - GOOS: linux - GOARCH: amd64 - - task: build:wsh:internal - vars: - GOOS: linux - GOARCH: mips - - task: build:wsh:internal - vars: - GOOS: linux - GOARCH: mips64 - - task: build:wsh:internal - vars: - GOOS: windows - GOARCH: amd64 - - task: build:wsh:internal - vars: - GOOS: windows - GOARCH: arm64 - internal: true - - build:wsh:internal: - vars: - EXT: - sh: echo {{if eq .GOOS "windows"}}.exe{{end}} - NORMALIZEDARCH: - sh: echo {{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}} - requires: - vars: - - GOOS - - GOARCH - - VERSION - cmd: (CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} go build -ldflags="-s -w -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wsh-{{.VERSION}}-{{.GOOS}}.{{.NORMALIZEDARCH}}{{.EXT}} cmd/wsh/main-wsh.go) - internal: true - - build:tsunamiscaffold: - desc: Build and copy tsunami scaffold to dist directory. - cmds: - - cmd: "{{.RMRF}} dist/tsunamiscaffold" - ignore_error: true - - task: copyfiles:'tsunami/frontend/scaffold':'dist/tsunamiscaffold' - - cmd: '{{if eq OS "windows"}}powershell -NoProfile -NonInteractive Copy-Item -Path tsunami/templates/empty-gomod.tmpl -Destination dist/tsunamiscaffold/go.mod{{else}}cp tsunami/templates/empty-gomod.tmpl dist/tsunamiscaffold/go.mod{{end}}' - deps: - - tsunami:scaffold - sources: - - "tsunami/frontend/dist/**/*" - - "tsunami/templates/**/*" - generates: - - "dist/tsunamiscaffold/**/*" - - generate: - desc: Generate Typescript bindings for the Go backend. - cmds: - - go run cmd/generatets/main-generatets.go - - go run cmd/generatego/main-generatego.go - deps: - - build:schema - sources: - - "cmd/generatego/*.go" - - "cmd/generatets/*.go" - - "pkg/**/*.go" - # don't add generates key (otherwise will always execute) - - outdated: - desc: Check for outdated packages using npm-check-updates. - cmd: npx npm-check-updates@latest - - version: - desc: Get the current package version, or bump version if args are present. To pass args to `version.cjs`, add them after `--`. See `version.cjs` for usage definitions for the arguments. - cmd: node version.cjs {{.CLI_ARGS}} - - artifacts:upload: - desc: Uploads build artifacts to the staging bucket in S3. To add additional AWS CLI arguments, add them after `--`. - vars: - ORIGIN: "make/" - DESTINATION: "{{.ARTIFACTS_BUCKET}}/{{.VERSION}}" - cmd: aws s3 cp {{.ORIGIN}}/ s3://{{.DESTINATION}}/ --recursive --exclude "*/*" --exclude "builder-*.yml" {{.CLI_ARGS}} - - artifacts:download:*: - desc: Downloads the specified artifacts version from the staging bucket. To add additional AWS CLI arguments, add them after `--`. - vars: - DL_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' - ORIGIN: "{{.ARTIFACTS_BUCKET}}/{{.DL_VERSION}}" - DESTINATION: "artifacts/{{.DL_VERSION}}" - cmds: - - '{{.RMRF}} "{{.DESTINATION}}"' - - aws s3 cp s3://{{.ORIGIN}}/ {{.DESTINATION}}/ --recursive {{.CLI_ARGS}} - - artifacts:publish:*: - desc: Publishes the specified artifacts version from the staging bucket to the releases bucket. To add additional AWS CLI arguments, add them after `--`. - vars: - UP_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' - ORIGIN: "{{.ARTIFACTS_BUCKET}}/{{.UP_VERSION}}" - DESTINATION: "{{.RELEASES_BUCKET}}" - cmd: | - OUTPUT=$(aws s3 cp s3://{{.ORIGIN}}/ s3://{{.DESTINATION}}/ --recursive {{.CLI_ARGS}}) - - for line in $OUTPUT; do - PREFIX=${line%%{{.DESTINATION}}*} - SUFFIX=${line:${#PREFIX}} - if [[ -n "$SUFFIX" ]]; then - echo "https://$SUFFIX" - fi - done - artifacts:snap:publish:*: - desc: Publishes the specified artifacts version to Snapcraft. - vars: - UP_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' - CHANNEL: '{{if contains "beta" .UP_VERSION}}beta{{else}}beta,stable{{end}}' - cmd: | - echo "Releasing to channels: [{{.CHANNEL}}]" - for file in waveterm_{{.UP_VERSION}}_*.snap; do - echo "Publishing $file" - snapcraft upload --release={{.CHANNEL}} $file - echo "Finished publishing $file" - done - - artifacts:winget:publish:*: - desc: Submits a version bump request to WinGet for the latest release. - status: - - exit {{if contains "beta" .UP_VERSION}}0{{else}}1{{end}} - vars: - UP_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' - cmd: | - wingetcreate update {{.WINGET_PACKAGE}} -s -v {{.UP_VERSION}} -u "https://{{.RELEASES_BUCKET}}/{{.APP_NAME}}-win32-x64-{{.UP_VERSION}}.msi" -t {{.GITHUB_TOKEN}} - - dev:installwsh: - desc: quick shortcut to rebuild wsh and install for macos arm64 - requires: - vars: - - VERSION - cmds: - - task: build:wsh:internal - vars: - GOOS: darwin - GOARCH: arm64 - - cp dist/bin/wsh-{{.VERSION}}-darwin.arm64 ~/Library/Application\ Support/waveterm-dev/bin/wsh - - dev:clearconfig: - desc: Clear the config directory for waveterm-dev - cmd: "{{.RMRF}} ~/.config/waveterm-dev" - - dev:cleardata: - desc: Clear the data directory for waveterm-dev - cmds: - - task: dev:cleardata:windows - - task: dev:cleardata:linux - - task: dev:cleardata:macos - - check:ts: - desc: Typecheck TypeScript code (frontend and electron). - cmd: npx tsc --noEmit - deps: - - npm:install - - init: - desc: Initialize the project for development. - cmds: - - npm install - - go mod tidy - - cd docs && npm install - - dev:cleardata:windows: - internal: true - platforms: [windows] - cmd: '{{.RMRF}} %LOCALAPPDATA%\waveterm-dev\Data' - - dev:cleardata:linux: - internal: true - platforms: [linux] - cmd: "rm -rf ~/.local/share/waveterm-dev" - - dev:cleardata:macos: - internal: true - platforms: [darwin] - cmd: 'rm -rf ~/Library/Application\ Support/waveterm-dev' - - npm:install: - desc: Runs `npm install` - internal: true - generates: - - node_modules/**/* - - package-lock.json - sources: - - package-lock.json - - package.json - cmd: npm install - - go:mod:tidy: - desc: Runs `go mod tidy` - internal: true - generates: - - go.sum - sources: - - go.mod - cmd: go mod tidy - - copyfiles:*:*: - desc: Recursively copy directory and its contents. - internal: true - cmd: '{{if eq OS "windows"}}powershell -NoProfile -NonInteractive Copy-Item -Recurse -Force -Path {{index .MATCH 0}} -Destination {{index .MATCH 1}}{{else}}mkdir -p "$(dirname {{index .MATCH 1}})" && cp -r {{index .MATCH 0}} {{index .MATCH 1}}{{end}}' - - clean: - desc: clean make/dist directories - cmds: - - cmd: '{{.RMRF}} "make"' - ignore_error: true - - cmd: '{{.RMRF}} "dist"' - ignore_error: true - - tsunami:demo:todo: - desc: Run the tsunami todo demo application - cmd: go run demo/todo/*.go - dir: tsunami - env: - TSUNAMI_LISTENADDR: "localhost:12026" - - tsunami:frontend:dev: - desc: Run the tsunami frontend vite dev server - cmd: npm run dev - dir: tsunami/frontend - - tsunami:frontend:build: - desc: Build the tsunami frontend - cmd: npm run build - dir: tsunami/frontend - - tsunami:frontend:devbuild: - desc: Build the tsunami frontend in development mode (with source maps and symbols) - cmd: npm run build:dev - dir: tsunami/frontend - - tsunami:scaffold: - desc: Build scaffold for tsunami frontend development - deps: - - tsunami:frontend:build - cmds: - - task: tsunami:scaffold:internal - - tsunami:devscaffold: - desc: Build scaffold for tsunami frontend development (with source maps and symbols) - deps: - - tsunami:frontend:devbuild - cmds: - - task: tsunami:scaffold:internal - - tsunami:scaffold:packagejson: - desc: Create package.json for tsunami scaffold using npm commands - dir: tsunami/frontend/scaffold - cmds: - - cmd: rm -f package.json - platforms: [darwin, linux] - ignore_error: true - - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path package.json" - platforms: [windows] - ignore_error: true - - npm --no-workspaces init -y --init-license Apache-2.0 - - npm pkg set name=tsunami-scaffold - - npm pkg delete author - - npm pkg set author.name="Command Line Inc" - - npm pkg set author.email="info@commandline.dev" - - npm --no-workspaces install tailwindcss@4.1.13 @tailwindcss/cli@4.1.13 - - tsunami:scaffold:internal: - desc: Internal task to create scaffold directory structure - internal: true - cmds: - - task: tsunami:scaffold:internal:unix - - task: tsunami:scaffold:internal:windows - - tsunami:scaffold:internal:unix: - desc: Internal task to create scaffold directory structure (Unix) - dir: tsunami/frontend - internal: true - platforms: [darwin, linux] - cmds: - - cmd: "{{.RMRF}} scaffold" - ignore_error: true - - mkdir -p scaffold - - cp ../templates/package.json.tmpl scaffold/package.json - - cd scaffold && npm install - - mv scaffold/node_modules scaffold/nm - - cp -r dist scaffold/ - - mkdir -p scaffold/dist/tw - - cp ../templates/*.go.tmpl scaffold/ - - cp ../templates/tailwind.css scaffold/ - - cp ../templates/gitignore.tmpl scaffold/.gitignore - - cp src/element/*.tsx scaffold/dist/tw/ - - cp ../ui/*.go scaffold/dist/tw/ - - cp ../engine/errcomponent.go scaffold/dist/tw/ - - tsunami:scaffold:internal:windows: - desc: Internal task to create scaffold directory structure (Windows) - dir: tsunami/frontend - internal: true - platforms: [windows] - cmds: - - cmd: "{{.RMRF}} scaffold" - ignore_error: true - - powershell -NoProfile -NonInteractive New-Item -ItemType Directory -Force -Path scaffold - - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/package.json.tmpl -Destination scaffold/package.json - - powershell -NoProfile -NonInteractive -Command "Set-Location scaffold; npm install" - - powershell -NoProfile -NonInteractive Move-Item -Path scaffold/node_modules -Destination scaffold/nm - - powershell -NoProfile -NonInteractive Copy-Item -Recurse -Force -Path dist -Destination scaffold/ - - powershell -NoProfile -NonInteractive New-Item -ItemType Directory -Force -Path scaffold/dist/tw - - powershell -NoProfile -NonInteractive Copy-Item -Path '../templates/*.go.tmpl' -Destination scaffold/ - - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/tailwind.css -Destination scaffold/ - - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/gitignore.tmpl -Destination scaffold/.gitignore - - powershell -NoProfile -NonInteractive Copy-Item -Path 'src/element/*.tsx' -Destination scaffold/dist/tw/ - - powershell -NoProfile -NonInteractive Copy-Item -Path '../ui/*.go' -Destination scaffold/dist/tw/ - - powershell -NoProfile -NonInteractive Copy-Item -Path ../engine/errcomponent.go -Destination scaffold/dist/tw/ - - tsunami:build: - desc: Build the tsunami binary. - cmds: - - cmd: rm -f bin/tsunami* - platforms: [darwin, linux] - ignore_error: true - - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path bin/tsunami*" - platforms: [windows] - ignore_error: true - - mkdir -p bin - - cd tsunami && go build -ldflags "-X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.TsunamiVersion={{.VERSION}}" -o ../bin/tsunami{{exeExt}} cmd/main-tsunami.go - sources: - - "tsunami/**/*.go" - - "tsunami/go.mod" - - "tsunami/go.sum" - generates: - - "bin/tsunami{{exeExt}}" - - tsunami:clean: - desc: Clean tsunami frontend build artifacts - dir: tsunami/frontend - cmds: - - cmd: "{{.RMRF}} dist" - ignore_error: true - - cmd: "{{.RMRF}} scaffold" - ignore_error: true - - godoc: - desc: Start the Go documentation server for the root module - cmd: $(go env GOPATH)/bin/pkgsite -http=:6060 - - tsunami:godoc: - desc: Start the Go documentation server for the tsunami module - cmd: $(go env GOPATH)/bin/pkgsite -http=:6060 - dir: tsunami diff --git a/ACKNOWLEDGEMENTS.md b/acknowledgements/README.md similarity index 100% rename from ACKNOWLEDGEMENTS.md rename to acknowledgements/README.md diff --git a/aiprompts/aimodesconfig.md b/aiprompts/aimodesconfig.md deleted file mode 100644 index 207b6fad88..0000000000 --- a/aiprompts/aimodesconfig.md +++ /dev/null @@ -1,709 +0,0 @@ -# Wave AI Modes Configuration - Visual Editor Architecture - -## Overview - -Wave Terminal's AI modes configuration system allows users to define custom AI assistants with different providers, models, and capabilities. The configuration is stored in `~/.waveterm/config/waveai.json` and provides a flexible way to configure multiple AI modes that appear in the Wave AI panel. - -**Key Design Decisions:** -- Visual editor works on **valid JSON only** - if JSON is invalid, fall back to JSON editor -- Default modes (`waveai@quick`, `waveai@balanced`, `waveai@deep`) are **read-only** in visual editor -- Edits modify the **in-memory JSON directly** - changes saved via existing save button -- Mode keys are **auto-generated** from provider + model or random ID (last 4-6 chars) -- Secrets use **fixed naming convention** per provider (e.g., `OPENAI_KEY`, `OPENROUTER_KEY`) -- Quick **inline secret editor** instead of complex secret management - -## Current System Architecture - -### Data Structure - -**Location:** `pkg/wconfig/settingsconfig.go:264-284` - -```go -type AIModeConfigType struct { - // Display Configuration - DisplayName string `json:"display:name"` // Required - DisplayOrder float64 `json:"display:order,omitempty"` - DisplayIcon string `json:"display:icon,omitempty"` - DisplayShortDesc string `json:"display:shortdesc,omitempty"` - DisplayDescription string `json:"display:description,omitempty"` - - // Provider & Model - Provider string `json:"ai:provider,omitempty"` // wave, google, openrouter, openai, azure, azure-legacy, custom - APIType string `json:"ai:apitype"` // Required: anthropic-messages, openai-responses, openai-chat - Model string `json:"ai:model"` // Required - - // AI Behavior - ThinkingLevel string `json:"ai:thinkinglevel,omitempty"` // low, medium, high - Capabilities []string `json:"ai:capabilities,omitempty"` // pdfs, images, tools - - // Connection Details - Endpoint string `json:"ai:endpoint,omitempty"` - APIVersion string `json:"ai:apiversion,omitempty"` - APIToken string `json:"ai:apitoken,omitempty"` - APITokenSecretName string `json:"ai:apitokensecretname,omitempty"` - - // Azure-Specific - AzureResourceName string `json:"ai:azureresourcename,omitempty"` - AzureDeployment string `json:"ai:azuredeployment,omitempty"` - - // Wave AI Specific - WaveAICloud bool `json:"waveai:cloud,omitempty"` - WaveAIPremium bool `json:"waveai:premium,omitempty"` -} -``` - -**Storage:** `FullConfigType.WaveAIModes` - `map[string]AIModeConfigType` - -Keys follow pattern: `provider@modename` (e.g., `waveai@quick`, `openai@gpt4`) - -### Provider Types & Defaults - -**Defined in:** `pkg/aiusechat/uctypes/uctypes.go:27-35` - -1. **wave** - Wave AI Cloud service - - Auto-sets: `waveai:cloud = true`, endpoint from env or default - - Default endpoint: `https://cfapi.waveterm.dev/api/waveai` - - Used for Wave's hosted AI modes - -2. **openai** - OpenAI API - - Auto-sets: endpoint `https://api.openai.com/v1` - - Auto-detects API type based on model: - - Legacy models (gpt-4o, gpt-3.5): `openai-chat` - - New models (gpt-5*, gpt-4.1*, o1*, o3*): `openai-responses` - -3. **openrouter** - OpenRouter service - - Auto-sets: endpoint `https://openrouter.ai/api/v1`, API type `openai-chat` - -4. **google** - Google AI (Gemini, etc.) - - No auto-defaults currently - -5. **azure** - Azure OpenAI (new unified API) - - Auto-sets: API version `v1`, endpoint from resource name - - Endpoint pattern: `https://{resource}.openai.azure.com/openai/v1/{responses|chat/completions}` - - Auto-detects API type based on model - -6. **azure-legacy** - Azure OpenAI (legacy chat completions) - - Auto-sets: API version `2025-04-01-preview`, API type `openai-chat` - - Endpoint pattern: `https://{resource}.openai.azure.com/openai/deployments/{deployment}/chat/completions?api-version={version}` - - Requires `AzureResourceName` and `AzureDeployment` - -7. **custom** - Custom provider - - No auto-defaults - - User must specify all fields manually - -### Default Configuration - -**Location:** `pkg/wconfig/defaultconfig/waveai.json` - -Ships with three Wave AI modes: -- `waveai@quick` - Fast responses (gpt-5-mini, low thinking) -- `waveai@balanced` - Balanced (gpt-5.1, low thinking) [premium] -- `waveai@deep` - Maximum capability (gpt-5.1, medium thinking) [premium] - -### Current UI State - -**Location:** `frontend/app/view/waveconfig/waveaivisual.tsx` - -Currently shows placeholder: "Visual editor coming soon..." - -The component receives: -- `model: WaveConfigViewModel` - Access to config file operations -- Existing patterns from `SecretsContent` for list/detail views - -## Visual Editor Design Plan - -### High-Level Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ Wave AI Modes Configuration │ -│ ┌───────────────┐ ┌──────────────────────────────┐ │ -│ │ │ │ │ │ -│ │ Mode List │ │ Mode Editor/Viewer │ │ -│ │ │ │ │ │ -│ │ [Quick] │ │ Provider: [wave â–ŧ] │ │ -│ │ [Balanced] │ │ │ │ -│ │ [Deep] │ │ Display Configuration │ │ -│ │ [Custom] │ │ ├─ Name: ... │ │ -│ │ │ │ ├─ Icon: ... │ │ -│ │ [+ Add New] │ │ └─ Description: ... │ │ -│ │ │ │ │ │ -│ │ │ │ Provider Configuration │ │ -│ │ │ │ (Provider-specific fields) │ │ -│ │ │ │ │ │ -│ │ │ │ [Save] [Delete] [Cancel] │ │ -│ └───────────────┘ └──────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -### Component Structure - -```typescript -WaveAIVisualContent -├─ ModeList (left panel) -│ ├─ Header with "Add New Mode" button -│ ├─ List of existing modes (sorted by display:order) -│ │ └─ ModeListItem (icon, name, short desc, provider badge) -│ └─ Empty state if no modes -│ -└─ ModeEditor (right panel) - ├─ Provider selector dropdown (when creating/editing) - ├─ Display section (common to all providers) - │ ├─ Name input (required) - │ ├─ Icon picker (optional) - │ ├─ Display order (optional, number) - │ ├─ Short description (optional) - │ └─ Description textarea (optional) - │ - ├─ Provider Configuration section (dynamic based on provider) - │ └─ [Provider-specific form fields] - │ - └─ Action buttons (Save, Delete, Cancel) -``` - -### Provider-Specific Form Fields - -#### 1. Wave Provider (`wave`) -**Read-only/Auto-managed:** -- Endpoint (shows default or env override) -- Cloud flag (always true) -- Secret: Not applicable (managed by Wave) - -**User-configurable:** -- Model (required, text input with suggestions: gpt-5-mini, gpt-5.1) -- API Type (required, dropdown: openai-responses, openai-chat) -- Thinking Level (optional, dropdown: low, medium, high) -- Capabilities (optional, checkboxes: tools, images, pdfs) -- Premium flag (checkbox) - -#### 2. OpenAI Provider (`openai`) -**Auto-managed:** -- Endpoint (shows: api.openai.com/v1) -- API Type (auto-detected from model, editable) -- Secret Name: Fixed as `OPENAI_KEY` - -**User-configurable:** -- Model (required, text input with suggestions: gpt-4o, gpt-5-mini, gpt-5.1, o1-preview) -- API Key (via secret modal - see Secret Management below) -- Thinking Level (optional) -- Capabilities (optional) - -#### 3. OpenRouter Provider (`openrouter`) -**Auto-managed:** -- Endpoint (shows: openrouter.ai/api/v1) -- API Type (always openai-chat) -- Secret Name: Fixed as `OPENROUTER_KEY` - -**User-configurable:** -- Model (required, text input - OpenRouter model format) -- API Key (via secret modal) -- Thinking Level (optional) -- Capabilities (optional) - -#### 4. Azure Provider (`azure`) -**Auto-managed:** -- API Version (always v1) -- Endpoint (computed from resource name) -- API Type (auto-detected from model) -- Secret Name: Fixed as `AZURE_KEY` - -**User-configurable:** -- Azure Resource Name (required, validated format) -- Model (required) -- API Key (via secret modal) -- Thinking Level (optional) -- Capabilities (optional) - -#### 5. Azure Legacy Provider (`azure-legacy`) -**Auto-managed:** -- API Version (default: 2025-04-01-preview, editable) -- API Type (always openai-chat) -- Endpoint (computed from resource + deployment + version) -- Secret Name: Fixed as `AZURE_KEY` - -**User-configurable:** -- Azure Resource Name (required, validated) -- Azure Deployment (required) -- Model (required) -- API Key (via secret modal) -- Thinking Level (optional) -- Capabilities (optional) - -#### 6. Google Provider (`google`) -**Auto-managed:** -- Secret Name: Fixed as `GOOGLE_KEY` - -**User-configurable:** -- Model (required) -- API Type (required dropdown) -- Endpoint (required) -- API Key (via secret modal) -- API Version (optional) -- Thinking Level (optional) -- Capabilities (optional) - -#### 7. Custom Provider (`custom`) -**User must specify everything:** -- Model (required) -- API Type (required dropdown) -- Endpoint (required) -- Secret Name (required text input - user defines their own secret name) -- API Key (via secret modal using custom secret name) -- API Version (optional) -- Thinking Level (optional) -- Capabilities (optional) -- Azure Resource Name (optional) -- Azure Deployment (optional) - -### Data Flow - -``` -Load JSON → Parse → Render Visual Editor - ↓ - User Edits Mode → Update fileContentAtom (JSON string) - ↓ - Click Save → Existing save logic validates & writes -``` - -**Simplified Operations:** -1. **Load:** Parse `fileContentAtom` JSON string into mode objects for display -2. **Edit Mode:** Update parsed object → stringify → set `fileContentAtom` → marks as edited -3. **Add Mode:** - - Generate unique key from provider/model or random ID - - Add new mode to parsed object → stringify → set `fileContentAtom` -4. **Delete Mode:** Remove key from parsed object → stringify → set `fileContentAtom` -5. **Save:** Existing `model.saveFile()` handles validation and write - -**Mode Key Generation:** -```typescript -function generateModeKey(provider: string, model: string): string { - // Try semantic key first: provider@model-sanitized - const sanitized = model.toLowerCase() - .replace(/[^a-z0-9]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - const semanticKey = `${provider}@${sanitized}`; - - // Check for collision, if exists append random suffix - if (existingModes[semanticKey]) { - const randomId = crypto.randomUUID().slice(-6); - return `${provider}@${sanitized}-${randomId}`; - } - return semanticKey; -} -// Examples: openai@gpt-4o, openrouter@claude-3-5-sonnet, azure@custom-fb4a2c -``` - -**Secret Naming Convention:** -```typescript -// Fixed secret names per provider (except custom) -const SECRET_NAMES = { - openai: "OPENAI_KEY", - openrouter: "OPENROUTER_KEY", - azure: "AZURE_KEY", - "azure-legacy": "AZURE_KEY", - google: "GOOGLE_KEY", - // custom provider: user specifies their own secret name -} as const; - -function getSecretName(provider: string, customSecretName?: string): string { - if (provider === "custom") { - return customSecretName || "CUSTOM_API_KEY"; - } - return SECRET_NAMES[provider]; -} -``` - -### Secret Management UI - -**Secret Status Indicator:** -Display next to API Key field for providers that need one: -- ✅ Green check icon: Secret exists and is set -- âš ī¸ Warning icon (yellow/orange): Secret not set or empty -- Click icon to open secret modal - -**Secret Modal:** -``` -┌─────────────────────────────────────┐ -│ Set API Key for OpenAI │ -│ │ -│ Secret Name: OPENAI_KEY │ -│ [read-only for non-custom] │ -│ │ -│ API Key: │ -│ [********************] [Show/Hide]│ -│ │ -│ [Cancel] [Save] │ -└─────────────────────────────────────┘ -``` - -**Modal Behavior:** -1. **Open Modal:** Click status icon or "Set API Key" button -2. **Show Secret Name:** - - Non-custom providers: Read-only, shows fixed name - - Custom provider: Editable text input (user specifies) -3. **API Key Input:** - - Masked password field - - Show/Hide toggle button - - Load existing value if secret already exists -4. **Save:** - - Validates not empty - - Calls RPC to set secret - - Updates status icon -5. **Cancel:** Close without changes - -**Integration with Mode Editor:** -- Check secret existence on mode load/select -- Update icon based on RPC `GetSecretsCommand` result -- "Save" button for mode only saves JSON config -- Secret is set immediately via modal (separate from JSON save) - -### Key Features - -#### 1. Mode List -- Display modes sorted by `display:order` (ascending) -- Show icon, name, short description -- Badge showing provider type -- Highlight Wave AI premium modes -- Click to edit - -#### 2. Add New Mode Flow -1. Click "Add New Mode" -2. Enter mode key (validated: alphanumeric, @, -, ., _) -3. Select provider from dropdown -4. Form dynamically updates to show provider-specific fields -5. Fill required fields (marked with *) -6. Save → validates → adds to config → refreshes list - -#### 3. Edit Mode Flow -1. Click mode from list -2. Load mode data into form -3. Provider is fixed (show read-only or with warning about changing) -4. Edit fields -5. Save → validates → updates config → refreshes list - -**Raw JSON Editor Option:** -- "Edit Raw JSON" button in mode editor (available for all modes) -- Opens modal with Monaco editor showing just this mode's JSON -- Validates JSON structure before allowing save -- Useful for: - - Modes without a provider field (edge cases) - - Advanced users who want precise control - - Copying/modifying complex configurations -- Validation checks: - - Valid JSON syntax - - Required fields present (`display:name`, `ai:apitype`, `ai:model`) - - Enum values valid - - Custom error messages for each validation failure - -#### 4. Delete Mode Flow -1. Click mode from list -2. Delete button in editor -3. Confirm dialog -4. Remove from config → save → refresh list - -#### 5. Secret Integration -- For API Token fields, provide two options: - - Direct input (text field, masked) - - Secret reference (dropdown of existing secrets + link to secrets page) -- When secret is selected, store name in `ai:apitokensecretname` -- When direct token, store in `ai:apitoken` - -#### 6. Validation -- **Mode Key:** Must match pattern `^[a-zA-Z0-9_@.-]+$` -- **Required Fields:** `display:name`, `ai:apitype`, `ai:model` -- **Azure Resource Name:** Must match `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` (1-63 chars) -- **Provider:** Must be one of the valid enum values -- **API Type:** Must be valid enum value -- **Thinking Level:** Must be low/medium/high if present -- **Capabilities:** Must be from valid enum (pdfs, images, tools) - -#### 7. Smart Defaults -When provider changes or model changes: -- Show info about what will be auto-configured -- Display computed endpoint (read-only with info icon) -- Display auto-detected API type (editable with warning) -- Pre-fill common values based on provider - -### UI Components Needed - -#### New Components -```typescript -// Main container -WaveAIVisualContent - -// Left panel -ModeList -├─ ModeListItem (icon, name, provider badge, premium badge, drag handle) -└─ AddModeButton - -// Right panel - viewer -ModeViewer -├─ ModeHeader (name, icon, actions) -├─ DisplaySection (read-only view of display fields) -├─ ProviderSection (read-only view of provider config) -└─ EditButton - -// Right panel - editor -ModeEditor -├─ ProviderSelector (dropdown, only for new modes) -├─ DisplayFieldsForm -├─ ProviderFieldsForm (dynamic based on provider) -│ ├─ WaveProviderForm -│ ├─ OpenAIProviderForm -│ ├─ OpenRouterProviderForm -│ ├─ AzureProviderForm -│ ├─ AzureLegacyProviderForm -│ ├─ GoogleProviderForm -│ └─ CustomProviderForm -└─ ActionButtons (Edit Raw JSON, Delete, Cancel) - -// Modals -RawJSONModal -├─ Title ("Edit Raw JSON: {mode name}") -├─ MonacoEditor (JSON, single mode object) -├─ ValidationErrors (inline display) -└─ Actions (Cancel, Save) - -// Shared components -SecretSelector (dropdown + link to secrets) -InfoTooltip (explains auto-configured fields) -ProviderBadge (visual indicator) -IconPicker (select from available icons) -DragHandle (for reordering modes in list) -``` - -**Drag & Drop for Reordering:** -```typescript -// Reordering updates display:order automatically -function handleModeReorder(draggedKey: string, targetKey: string) { - const modes = parseAIModes(fileContent); - const modesList = Object.entries(modes) - .sort((a, b) => (a[1]["display:order"] || 0) - (b[1]["display:order"] || 0)); - - // Find indices - const draggedIndex = modesList.findIndex(([k]) => k === draggedKey); - const targetIndex = modesList.findIndex(([k]) => k === targetKey); - - // Recalculate display:order for all modes - const newOrder = [...modesList]; - newOrder.splice(draggedIndex, 1); - newOrder.splice(targetIndex, 0, modesList[draggedIndex]); - - // Assign new order values (0, 10, 20, 30...) - newOrder.forEach(([key, mode], index) => { - modes[key] = { ...mode, "display:order": index * 10 }; - }); - - updateFileContent(JSON.stringify(modes, null, 2)); -} -``` - -### Model Extensions (Minimal) - -**No new atoms needed!** Visual editor uses existing `fileContentAtom`: - -```typescript -// Use existing atoms from WaveConfigViewModel: -// - fileContentAtom (contains JSON string) -// - hasEditedAtom (tracks if modified) -// - errorMessageAtom (for errors) - -// Visual editor parses fileContentAtom on render: -function parseAIModes(jsonString: string): Record | null { - try { - return JSON.parse(jsonString); - } catch { - return null; // Show "invalid JSON" error - } -} - -// Updates modify fileContentAtom: -function updateMode(key: string, mode: AIModeConfigType) { - const modes = parseAIModes(globalStore.get(model.fileContentAtom)); - if (!modes) return; - - modes[key] = mode; - const newJson = JSON.stringify(modes, null, 2); - globalStore.set(model.fileContentAtom, newJson); - globalStore.set(model.hasEditedAtom, true); -} - -// Secrets use existing model methods: -// - model.refreshSecrets() - already exists -// - RpcApi.GetSecretsCommand() - check if secret exists -// - RpcApi.SetSecretsCommand() - set secret value -``` - -**Component State (useState):** -```typescript -// In WaveAIVisualContent component: -const [selectedModeKey, setSelectedModeKey] = useState(null); -const [isAddingMode, setIsAddingMode] = useState(false); -const [showSecretModal, setShowSecretModal] = useState(false); -const [secretModalProvider, setSecretModalProvider] = useState(""); -``` - -### Implementation Phases - -#### Phase 1: Foundation & List View -- Parse `fileContentAtom` JSON into modes on render -- Display mode list (left panel, ~300px) - - Built-in modes with 🔒 icon at top - - Custom modes below - - Sort by `display:order` -- Select mode → show in right panel (empty state initially) -- Handle invalid JSON → show error, switch to JSON tab - -#### Phase 2: Built-in Mode Viewer -- Click built-in mode → show read-only details -- Display all fields (display, provider, config) -- "Built-in Mode" badge/banner -- No edit/delete buttons - -#### Phase 3: Custom Mode Editor (Basic) -- Click custom mode → load into editor form -- Display fields (name, icon, order, description) -- Provider field (read-only, badge) -- Model field (text input) -- Save → update `fileContentAtom` JSON -- Cancel → revert to previous selection - -#### Phase 4: Provider-Specific Fields -- Dynamic form based on provider type -- OpenAI: model, thinking level, capabilities -- Azure: resource name, model, thinking, capabilities -- Azure Legacy: resource name, deployment, model -- OpenRouter: model -- Google: model, API type, endpoint -- Custom: everything manual -- Info tooltips for auto-configured fields - -#### Phase 5: Secret Integration -- Check secret existence on mode select -- Display status icon (✅ / âš ī¸) -- Click icon → open secret modal -- Secret modal: fixed name (or custom input), password field -- Save secret → immediate RPC call -- Update status icon after save - -#### Phase 6: Add New Mode -- "Add New Mode" button -- Provider dropdown selector -- Auto-generate mode key from provider + model -- Form with provider-specific fields -- Add to modes → update JSON → mark edited -- Select newly created mode - -#### Phase 7: Delete Mode -- Delete button for custom modes only -- Simple confirmation dialog -- Remove from modes → update JSON → deselect - -#### Phase 8: Raw JSON Editor -- "Edit Raw JSON" button in mode editor (all modes) -- Modal with Monaco editor for single mode -- JSON validation before save: - - Syntax check with error highlighting - - Required fields check (`display:name`, `ai:apitype`, `ai:model`) - - Enum validation (provider, apitype, thinkinglevel, capabilities) - - Display specific error messages per validation failure -- Parse validated JSON and update mode in main JSON -- Useful for edge cases (modes without provider) and power users - -#### Phase 9: Drag & Drop Reordering -- Add drag handle icon to custom mode list items -- Implement drag & drop functionality: - - Visual feedback during drag (opacity, cursor) - - Drop target highlighting - - Smooth reordering animation -- On drop: - - Recalculate `display:order` for all affected modes - - Use spacing (0, 10, 20, 30...) for easy manual adjustment - - Update JSON with new order values - - Built-in modes always stay at top (negative order values) - -#### Phase 10: Polish & UX Refinements -- Field validation with inline error messages -- Empty state when no mode selected -- Icon picker dropdown (Font Awesome icons) -- Capabilities checkboxes with descriptions -- Thinking level dropdown with explanations -- Help tooltips throughout -- Keyboard shortcuts (e.g., Ctrl/Cmd+E for raw JSON) -- Loading states for secret checks -- Smooth transitions and animations - -#### Phase 8: Raw JSON Editor -- "Edit Raw JSON" button in mode editor -- Modal with Monaco editor for single mode -- JSON validation before save: - - Syntax check - - Required fields check - - Enum validation - - Display specific error messages -- Parse and update mode in main JSON - -#### Phase 9: Drag & Drop Reordering -- Make mode list items draggable (custom modes only) -- Visual feedback during drag (drag handle icon) -- Drop target highlighting -- On drop: - - Calculate new `display:order` values - - Maintain spacing between modes - - Update all affected modes in JSON - - Preserve built-in modes at top - -#### Phase 10: Polish & UX Refinements -- Field validation (required, format) -- Error messages inline -- Empty state when no mode selected -- Icon picker dropdown -- Capabilities checkboxes -- Thinking level dropdown -- Help tooltips throughout -- Keyboard shortcuts (e.g., Cmd+E for raw JSON) - -### Technical Considerations - -1. **JSON Sync:** Parse/stringify from `fileContentAtom` on every read/write -2. **Validation:** Validate on blur or before updating JSON -3. **Built-in Detection:** Check if key starts with `waveai@` → read-only -4. **Type Safety:** Use `AIModeConfigType` from gotypes.d.ts -5. **State Management:** - - Model atoms for shared state (`fileContentAtom`, `hasEditedAtom`) - - Component useState for UI state (selected mode, modals) -6. **Error Handling:** - - Invalid JSON → show message, disable visual editor - - Parse errors → gracefully handle, don't crash -7. **Performance:** - - Parse JSON on mount and when `fileContentAtom` changes externally - - Debounce frequent updates if needed -8. **Secret Checks:** - - Load secret existence on mode select - - Cache results to avoid repeated RPC calls - -### Testing Strategy - -1. **Unit Tests:** Validation functions, key generation -2. **Integration Tests:** Form submission, backend sync -3. **E2E Tests:** Full add/edit/delete flows -4. **Provider Tests:** Each provider form with various inputs -5. **Edge Cases:** Empty config, invalid JSON, malformed data - -### Documentation Needs - -1. **In-app help:** Tooltips and info bubbles explaining fields -2. **Provider guides:** What each provider needs, where to get API keys -3. **Examples:** Show example configurations for common setups -4. **Troubleshooting:** Common errors and solutions - -## Next Steps - -1. Create detailed mockups/wireframes -2. Implement Phase 1 (basic list view) -3. Add RPC methods if needed for secrets integration -4. Iterate on provider forms -5. Polish and ship - -This design provides a user-friendly way to configure AI modes without directly editing JSON, while still maintaining the power and flexibility of the underlying system. \ No newline at end of file diff --git a/aiprompts/aisdk-streaming.md b/aiprompts/aisdk-streaming.md deleted file mode 100644 index ad53103aab..0000000000 --- a/aiprompts/aisdk-streaming.md +++ /dev/null @@ -1,288 +0,0 @@ -## Data Stream Protocol - -A data stream follows a special protocol that the AI SDK provides to send information to the frontend. - -The data stream protocol uses Server-Sent Events (SSE) format for improved standardization, keep-alive through ping, reconnect capabilities, and better cache handling. - - - When you provide data streams from a custom backend, you need to set the - `x-vercel-ai-ui-message-stream` header to `v1`. - - -The following stream parts are currently supported: - -### Message Start Part - -Indicates the beginning of a new message with metadata. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"start","messageId":"..."} - -``` - -### Text Parts - -Text content is streamed using a start/delta/end pattern with unique IDs for each text block. - -#### Text Start Part - -Indicates the beginning of a text block. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"text-start","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d"} - -``` - -#### Text Delta Part - -Contains incremental text content for the text block. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"text-delta","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d","delta":"Hello"} - -``` - -#### Text End Part - -Indicates the completion of a text block. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"text-end","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d"} - -``` - -### Reasoning Parts - -Reasoning content is streamed using a start/delta/end pattern with unique IDs for each reasoning block. - -#### Reasoning Start Part - -Indicates the beginning of a reasoning block. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"reasoning-start","id":"reasoning_123"} - -``` - -#### Reasoning Delta Part - -Contains incremental reasoning content for the reasoning block. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"reasoning-delta","id":"reasoning_123","delta":"This is some reasoning"} - -``` - -#### Reasoning End Part - -Indicates the completion of a reasoning block. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"reasoning-end","id":"reasoning_123"} - -``` - -### Source Parts - -Source parts provide references to external content sources. - -#### Source URL Part - -References to external URLs. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"source-url","sourceId":"https://example.com","url":"https://example.com"} - -``` - -#### Source Document Part - -References to documents or files. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"source-document","sourceId":"https://example.com","mediaType":"file","title":"Title"} - -``` - -### File Part - -The file parts contain references to files with their media type. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"file","url":"https://example.com/file.png","mediaType":"image/png"} - -``` - -### Data Parts - -Custom data parts allow streaming of arbitrary structured data with type-specific handling. - -Format: Server-Sent Event with JSON object where the type includes a custom suffix - -Example: - -``` -data: {"type":"data-weather","data":{"location":"SF","temperature":100}} - -``` - -The `data-*` type pattern allows you to define custom data types that your frontend can handle specifically. - -### Error Part - -The error parts are appended to the message as they are received. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"error","errorText":"error message"} - -``` - -### Tool Input Start Part - -Indicates the beginning of tool input streaming. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"tool-input-start","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","toolName":"getWeatherInformation"} - -``` - -### Tool Input Delta Part - -Incremental chunks of tool input as it's being generated. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"tool-input-delta","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","inputTextDelta":"San Francisco"} - -``` - -### Tool Input Available Part - -Indicates that tool input is complete and ready for execution. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"tool-input-available","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","toolName":"getWeatherInformation","input":{"city":"San Francisco"}} - -``` - -### Tool Output Available Part - -Contains the result of tool execution. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"tool-output-available","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","output":{"city":"San Francisco","weather":"sunny"}} - -``` - -### Start Step Part - -A part indicating the start of a step. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"start-step"} - -``` - -### Finish Step Part - -A part indicating that a step (i.e., one LLM API call in the backend) has been completed. - -This part is necessary to correctly process multiple stitched assistant calls, e.g. when calling tools in the backend, and using steps in `useChat` at the same time. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"finish-step"} - -``` - -### Finish Message Part - -A part indicating the completion of a message. - -Format: Server-Sent Event with JSON object - -Example: - -``` -data: {"type":"finish"} - -``` - -### Stream Termination - -The stream ends with a special `[DONE]` marker. - -Format: Server-Sent Event with literal `[DONE]` - -Example: - -``` -data: [DONE] - -``` diff --git a/aiprompts/aisdk-uimessage-type.md b/aiprompts/aisdk-uimessage-type.md deleted file mode 100644 index 9cabf1e460..0000000000 --- a/aiprompts/aisdk-uimessage-type.md +++ /dev/null @@ -1,237 +0,0 @@ -# `UIMessage` - -`UIMessage` serves as the source of truth for your application's state, representing the complete message history including metadata, data parts, and all contextual information. In contrast to `ModelMessage`, which represents the state or context passed to the model, `UIMessage` contains the full application state needed for UI rendering and client-side functionality. - -## Type Safety - -`UIMessage` is designed to be type-safe and accepts three generic parameters to ensure proper typing throughout your application: - -1. **`METADATA`** - Custom metadata type for additional message information -2. **`DATA_PARTS`** - Custom data part types for structured data components -3. **`TOOLS`** - Tool definitions for type-safe tool interactions - -## Creating Your Own UIMessage Type - -Here's an example of how to create a custom typed UIMessage for your application: - -```typescript -import { InferUITools, ToolSet, UIMessage, tool } from "ai"; -import z from "zod"; - -const metadataSchema = z.object({ - someMetadata: z.string().datetime(), -}); - -type MyMetadata = z.infer; - -const dataPartSchema = z.object({ - someDataPart: z.object({}), - anotherDataPart: z.object({}), -}); - -type MyDataPart = z.infer; - -const tools = { - someTool: tool({}), -} satisfies ToolSet; - -type MyTools = InferUITools; - -export type MyUIMessage = UIMessage; -``` - -## `UIMessage` Interface - -```typescript -interface UIMessage { - /** - * A unique identifier for the message. - */ - id: string; - - /** - * The role of the message. - */ - role: "system" | "user" | "assistant"; - - /** - * The metadata of the message. - */ - metadata?: METADATA; - - /** - * The parts of the message. Use this for rendering the message in the UI. - */ - parts: Array>; -} -``` - -## `UIMessagePart` Types - -### `TextUIPart` - -A text part of a message. - -```typescript -type TextUIPart = { - type: "text"; - /** - * The text content. - */ - text: string; - /** - * The state of the text part. - */ - state?: "streaming" | "done"; -}; -``` - -### `ReasoningUIPart` - -A reasoning part of a message. - -```typescript -type ReasoningUIPart = { - type: "reasoning"; - /** - * The reasoning text. - */ - text: string; - /** - * The state of the reasoning part. - */ - state?: "streaming" | "done"; - /** - * The provider metadata. - */ - providerMetadata?: Record; -}; -``` - -### `ToolUIPart` - -A tool part of a message that represents tool invocations and their results. - - - The type is based on the name of the tool (e.g., `tool-someTool` for a tool - named `someTool`). - - -```typescript -type ToolUIPart = ValueOf<{ - [NAME in keyof TOOLS & string]: { - type: `tool-${NAME}`; - toolCallId: string; - } & ( - | { - state: "input-streaming"; - input: DeepPartial | undefined; - providerExecuted?: boolean; - output?: never; - errorText?: never; - } - | { - state: "input-available"; - input: TOOLS[NAME]["input"]; - providerExecuted?: boolean; - output?: never; - errorText?: never; - } - | { - state: "output-available"; - input: TOOLS[NAME]["input"]; - output: TOOLS[NAME]["output"]; - errorText?: never; - providerExecuted?: boolean; - } - | { - state: "output-error"; - input: TOOLS[NAME]["input"]; - output?: never; - errorText: string; - providerExecuted?: boolean; - } - ); -}>; -``` - -### `SourceUrlUIPart` - -A source URL part of a message. - -```typescript -type SourceUrlUIPart = { - type: "source-url"; - sourceId: string; - url: string; - title?: string; - providerMetadata?: Record; -}; -``` - -### `SourceDocumentUIPart` - -A document source part of a message. - -```typescript -type SourceDocumentUIPart = { - type: "source-document"; - sourceId: string; - mediaType: string; - title: string; - filename?: string; - providerMetadata?: Record; -}; -``` - -### `FileUIPart` - -A file part of a message. - -```typescript -type FileUIPart = { - type: "file"; - /** - * IANA media type of the file. - */ - mediaType: string; - /** - * Optional filename of the file. - */ - filename?: string; - /** - * The URL of the file. - * It can either be a URL to a hosted file or a Data URL. - */ - url: string; -}; -``` - -### `DataUIPart` - -A data part of a message for custom data types. - - - The type is based on the name of the data part (e.g., `data-someDataPart` for - a data part named `someDataPart`). - - -```typescript -type DataUIPart = ValueOf<{ - [NAME in keyof DATA_TYPES & string]: { - type: `data-${NAME}`; - id?: string; - data: DATA_TYPES[NAME]; - }; -}>; -``` - -### `StepStartUIPart` - -A step boundary part of a message. - -```typescript -type StepStartUIPart = { - type: "step-start"; -}; -``` diff --git a/aiprompts/anthropic-messages-api.md b/aiprompts/anthropic-messages-api.md deleted file mode 100644 index 3d487891b9..0000000000 --- a/aiprompts/anthropic-messages-api.md +++ /dev/null @@ -1,3746 +0,0 @@ -# Messages - -> Send a structured list of input messages with text and/or image content, and the model will generate the next message in the conversation. - -The Messages API can be used for either single queries or stateless multi-turn conversations. - -Learn more about the Messages API in our [user guide](/en/docs/initial-setup) - -## OpenAPI - -````yaml post /v1/messages -paths: - path: /v1/messages - method: post - servers: - - url: https://api.anthropic.com - request: - security: [] - parameters: - path: {} - query: {} - header: - anthropic-beta: - schema: - - type: array - items: - allOf: - - type: string - required: false - title: Anthropic-Beta - description: >- - Optional header to specify the beta version(s) you want to use. - - - To use multiple betas, use a comma separated list like - `beta1,beta2` or specify the header multiple times for each - beta. - anthropic-version: - schema: - - type: string - required: true - title: Anthropic-Version - description: >- - The version of the Anthropic API you want to use. - - - Read more about versioning and our version history - [here](https://docs.anthropic.com/en/api/versioning). - x-api-key: - schema: - - type: string - required: true - title: X-Api-Key - description: >- - Your unique API key for authentication. - - - This key is required in the header of all API requests, to - authenticate your account and access Anthropic's services. Get - your API key through the - [Console](https://console.anthropic.com/settings/keys). Each key - is scoped to a Workspace. - cookie: {} - body: - application/json: - schemaArray: - - type: object - properties: - model: - allOf: - - description: >- - The model that will complete your prompt. - - - See - [models](https://docs.anthropic.com/en/docs/models-overview) - for additional details and options. - examples: - - claude-sonnet-4-20250514 - maxLength: 256 - minLength: 1 - title: Model - type: string - messages: - allOf: - - description: >- - Input messages. - - - Our models are trained to operate on alternating `user` - and `assistant` conversational turns. When creating a new - `Message`, you specify the prior conversational turns with - the `messages` parameter, and the model then generates the - next `Message` in the conversation. Consecutive `user` or - `assistant` turns in your request will be combined into a - single turn. - - - Each input message must be an object with a `role` and - `content`. You can specify a single `user`-role message, - or you can include multiple `user` and `assistant` - messages. - - - If the final message uses the `assistant` role, the - response content will continue immediately from the - content in that message. This can be used to constrain - part of the model's response. - - - Example with a single `user` message: - - - ```json - - [{"role": "user", "content": "Hello, Claude"}] - - ``` - - - Example with multiple conversational turns: - - - ```json - - [ - {"role": "user", "content": "Hello there."}, - {"role": "assistant", "content": "Hi, I'm Claude. How can I help you?"}, - {"role": "user", "content": "Can you explain LLMs in plain English?"}, - ] - - ``` - - - Example with a partially-filled response from Claude: - - - ```json - - [ - {"role": "user", "content": "What's the Greek name for Sun? (A) Sol (B) Helios (C) Sun"}, - {"role": "assistant", "content": "The best answer is ("}, - ] - - ``` - - - Each input message `content` may be either a single - `string` or an array of content blocks, where each block - has a specific `type`. Using a `string` for `content` is - shorthand for an array of one content block of type - `"text"`. The following input messages are equivalent: - - - ```json - - {"role": "user", "content": "Hello, Claude"} - - ``` - - - ```json - - {"role": "user", "content": [{"type": "text", "text": - "Hello, Claude"}]} - - ``` - - - See - [examples](https://docs.anthropic.com/en/api/messages-examples) - for more input examples. - - - Note that if you want to include a [system - prompt](https://docs.anthropic.com/en/docs/system-prompts), - you can use the top-level `system` parameter — there is no - `"system"` role for input messages in the Messages API. - - - There is a limit of 100,000 messages in a single request. - items: - $ref: "#/components/schemas/InputMessage" - title: Messages - type: array - container: - allOf: - - anyOf: - - type: string - - type: "null" - description: Container identifier for reuse across requests. - title: Container - max_tokens: - allOf: - - description: >- - The maximum number of tokens to generate before stopping. - - - Note that our models may stop _before_ reaching this - maximum. This parameter only specifies the absolute - maximum number of tokens to generate. - - - Different models have different maximum values for this - parameter. See - [models](https://docs.anthropic.com/en/docs/models-overview) - for details. - examples: - - 1024 - minimum: 1 - title: Max Tokens - type: integer - mcp_servers: - allOf: - - description: MCP servers to be utilized in this request - items: - $ref: "#/components/schemas/RequestMCPServerURLDefinition" - maxItems: 20 - title: Mcp Servers - type: array - metadata: - allOf: - - $ref: "#/components/schemas/Metadata" - description: An object describing metadata about the request. - service_tier: - allOf: - - description: >- - Determines whether to use priority capacity (if available) - or standard capacity for this request. - - - Anthropic offers different levels of service for your API - requests. See - [service-tiers](https://docs.anthropic.com/en/api/service-tiers) - for details. - enum: - - auto - - standard_only - title: Service Tier - type: string - stop_sequences: - allOf: - - description: >- - Custom text sequences that will cause the model to stop - generating. - - - Our models will normally stop when they have naturally - completed their turn, which will result in a response - `stop_reason` of `"end_turn"`. - - - If you want the model to stop generating when it - encounters custom strings of text, you can use the - `stop_sequences` parameter. If the model encounters one of - the custom sequences, the response `stop_reason` value - will be `"stop_sequence"` and the response `stop_sequence` - value will contain the matched stop sequence. - items: - type: string - title: Stop Sequences - type: array - stream: - allOf: - - description: >- - Whether to incrementally stream the response using - server-sent events. - - - See - [streaming](https://docs.anthropic.com/en/api/messages-streaming) - for details. - title: Stream - type: boolean - system: - allOf: - - anyOf: - - type: string - - items: - $ref: "#/components/schemas/RequestTextBlock" - type: array - description: >- - System prompt. - - - A system prompt is a way of providing context and - instructions to Claude, such as specifying a particular - goal or role. See our [guide to system - prompts](https://docs.anthropic.com/en/docs/system-prompts). - examples: - - - text: Today's date is 2024-06-01. - type: text - - Today's date is 2023-01-01. - title: System - temperature: - allOf: - - description: >- - Amount of randomness injected into the response. - - - Defaults to `1.0`. Ranges from `0.0` to `1.0`. Use - `temperature` closer to `0.0` for analytical / multiple - choice, and closer to `1.0` for creative and generative - tasks. - - - Note that even with `temperature` of `0.0`, the results - will not be fully deterministic. - examples: - - 1 - maximum: 1 - minimum: 0 - title: Temperature - type: number - thinking: - allOf: - - description: >- - Configuration for enabling Claude's extended thinking. - - - When enabled, responses include `thinking` content blocks - showing Claude's thinking process before the final answer. - Requires a minimum budget of 1,024 tokens and counts - towards your `max_tokens` limit. - - - See [extended - thinking](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) - for details. - discriminator: - mapping: - disabled: "#/components/schemas/ThinkingConfigDisabled" - enabled: "#/components/schemas/ThinkingConfigEnabled" - propertyName: type - oneOf: - - $ref: "#/components/schemas/ThinkingConfigEnabled" - - $ref: "#/components/schemas/ThinkingConfigDisabled" - tool_choice: - allOf: - - description: >- - How the model should use the provided tools. The model can - use a specific tool, any available tool, decide by itself, - or not use tools at all. - discriminator: - mapping: - any: "#/components/schemas/ToolChoiceAny" - auto: "#/components/schemas/ToolChoiceAuto" - none: "#/components/schemas/ToolChoiceNone" - tool: "#/components/schemas/ToolChoiceTool" - propertyName: type - oneOf: - - $ref: "#/components/schemas/ToolChoiceAuto" - - $ref: "#/components/schemas/ToolChoiceAny" - - $ref: "#/components/schemas/ToolChoiceTool" - - $ref: "#/components/schemas/ToolChoiceNone" - tools: - allOf: - - description: >- - Definitions of tools that the model may use. - - - If you include `tools` in your API request, the model may - return `tool_use` content blocks that represent the - model's use of those tools. You can then run those tools - using the tool input generated by the model and then - optionally return results back to the model using - `tool_result` content blocks. - - - There are two types of tools: **client tools** and - **server tools**. The behavior described below applies to - client tools. For [server - tools](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview\#server-tools), - see their individual documentation as each has its own - behavior (e.g., the [web search - tool](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-search-tool)). - - - Each tool definition includes: - - - * `name`: Name of the tool. - - * `description`: Optional, but strongly-recommended - description of the tool. - - * `input_schema`: [JSON - schema](https://json-schema.org/draft/2020-12) for the - tool `input` shape that the model will produce in - `tool_use` output content blocks. - - - For example, if you defined `tools` as: - - - ```json - - [ - { - "name": "get_stock_price", - "description": "Get the current stock price for a given ticker symbol.", - "input_schema": { - "type": "object", - "properties": { - "ticker": { - "type": "string", - "description": "The stock ticker symbol, e.g. AAPL for Apple Inc." - } - }, - "required": ["ticker"] - } - } - ] - - ``` - - - And then asked the model "What's the S&P 500 at today?", - the model might produce `tool_use` content blocks in the - response like this: - - - ```json - - [ - { - "type": "tool_use", - "id": "toolu_01D7FLrfh4GYq7yT1ULFeyMV", - "name": "get_stock_price", - "input": { "ticker": "^GSPC" } - } - ] - - ``` - - - You might then run your `get_stock_price` tool with - `{"ticker": "^GSPC"}` as an input, and return the - following back to the model in a subsequent `user` - message: - - - ```json - - [ - { - "type": "tool_result", - "tool_use_id": "toolu_01D7FLrfh4GYq7yT1ULFeyMV", - "content": "259.75 USD" - } - ] - - ``` - - - Tools can be used for workflows that include running - client-side tools and functions, or more generally - whenever you want the model to produce a particular JSON - structure of output. - - - See our - [guide](https://docs.anthropic.com/en/docs/tool-use) for - more details. - examples: - - description: Get the current weather in a given location - input_schema: - properties: - location: - description: The city and state, e.g. San Francisco, CA - type: string - unit: - description: >- - Unit for the output - one of (celsius, - fahrenheit) - type: string - required: - - location - type: object - name: get_weather - items: - oneOf: - - $ref: "#/components/schemas/Tool" - - $ref: "#/components/schemas/BashTool_20241022" - - $ref: "#/components/schemas/BashTool_20250124" - - $ref: "#/components/schemas/CodeExecutionTool_20250522" - - $ref: "#/components/schemas/ComputerUseTool_20241022" - - $ref: "#/components/schemas/ComputerUseTool_20250124" - - $ref: "#/components/schemas/TextEditor_20241022" - - $ref: "#/components/schemas/TextEditor_20250124" - - $ref: "#/components/schemas/TextEditor_20250429" - - $ref: "#/components/schemas/TextEditor_20250728" - - $ref: "#/components/schemas/WebSearchTool_20250305" - title: Tools - type: array - top_k: - allOf: - - description: >- - Only sample from the top K options for each subsequent - token. - - - Used to remove "long tail" low probability responses. - [Learn more technical details - here](https://towardsdatascience.com/how-to-sample-from-language-models-682bceb97277). - - - Recommended for advanced use cases only. You usually only - need to use `temperature`. - examples: - - 5 - minimum: 0 - title: Top K - type: integer - top_p: - allOf: - - description: >- - Use nucleus sampling. - - - In nucleus sampling, we compute the cumulative - distribution over all the options for each subsequent - token in decreasing probability order and cut it off once - it reaches a particular probability specified by `top_p`. - You should either alter `temperature` or `top_p`, but not - both. - - - Recommended for advanced use cases only. You usually only - need to use `temperature`. - examples: - - 0.7 - maximum: 1 - minimum: 0 - title: Top P - type: number - required: true - title: CreateMessageParams - requiredProperties: - - model - - messages - - max_tokens - additionalProperties: false - example: - max_tokens: 1024 - messages: - - content: Hello, world - role: user - model: claude-sonnet-4-20250514 - examples: - example: - value: - max_tokens: 1024 - messages: - - content: Hello, world - role: user - model: claude-sonnet-4-20250514 - codeSamples: - - lang: bash - source: |- - curl https://api.anthropic.com/v1/messages \ - --header "x-api-key: $ANTHROPIC_API_KEY" \ - --header "anthropic-version: 2023-06-01" \ - --header "content-type: application/json" \ - --data \ - '{ - "model": "claude-sonnet-4-20250514", - "max_tokens": 1024, - "messages": [ - {"role": "user", "content": "Hello, world"} - ] - }' - - lang: python - source: |- - import anthropic - - anthropic.Anthropic().messages.create( - model="claude-sonnet-4-20250514", - max_tokens=1024, - messages=[ - {"role": "user", "content": "Hello, world"} - ] - ) - - lang: javascript - source: |- - import { Anthropic } from '@anthropic-ai/sdk'; - - const anthropic = new Anthropic(); - - await anthropic.messages.create({ - model: "claude-sonnet-4-20250514", - max_tokens: 1024, - messages: [ - {"role": "user", "content": "Hello, world"} - ] - }); - response: - "200": - application/json: - schemaArray: - - type: object - properties: - id: - allOf: - - description: |- - Unique object identifier. - - The format and length of IDs may change over time. - examples: - - msg_013Zva2CMHLNnXjNJJKqJ2EF - title: Id - type: string - type: - allOf: - - const: message - default: message - description: |- - Object type. - - For Messages, this is always `"message"`. - enum: - - message - title: Type - type: string - role: - allOf: - - const: assistant - default: assistant - description: |- - Conversational role of the generated message. - - This will always be `"assistant"`. - enum: - - assistant - title: Role - type: string - content: - allOf: - - description: >- - Content generated by the model. - - - This is an array of content blocks, each of which has a - `type` that determines its shape. - - - Example: - - - ```json - - [{"type": "text", "text": "Hi, I'm Claude."}] - - ``` - - - If the request input `messages` ended with an `assistant` - turn, then the response `content` will continue directly - from that last turn. You can use this to constrain the - model's output. - - - For example, if the input `messages` were: - - ```json - - [ - {"role": "user", "content": "What's the Greek name for Sun? (A) Sol (B) Helios (C) Sun"}, - {"role": "assistant", "content": "The best answer is ("} - ] - - ``` - - - Then the response `content` might be: - - - ```json - - [{"type": "text", "text": "B)"}] - - ``` - examples: - - - text: Hi! My name is Claude. - type: text - items: - discriminator: - mapping: - code_execution_tool_result: >- - #/components/schemas/ResponseCodeExecutionToolResultBlock - container_upload: "#/components/schemas/ResponseContainerUploadBlock" - mcp_tool_result: "#/components/schemas/ResponseMCPToolResultBlock" - mcp_tool_use: "#/components/schemas/ResponseMCPToolUseBlock" - redacted_thinking: "#/components/schemas/ResponseRedactedThinkingBlock" - server_tool_use: "#/components/schemas/ResponseServerToolUseBlock" - text: "#/components/schemas/ResponseTextBlock" - thinking: "#/components/schemas/ResponseThinkingBlock" - tool_use: "#/components/schemas/ResponseToolUseBlock" - web_search_tool_result: >- - #/components/schemas/ResponseWebSearchToolResultBlock - propertyName: type - oneOf: - - $ref: "#/components/schemas/ResponseTextBlock" - - $ref: "#/components/schemas/ResponseThinkingBlock" - - $ref: "#/components/schemas/ResponseRedactedThinkingBlock" - - $ref: "#/components/schemas/ResponseToolUseBlock" - - $ref: "#/components/schemas/ResponseServerToolUseBlock" - - $ref: >- - #/components/schemas/ResponseWebSearchToolResultBlock - - $ref: >- - #/components/schemas/ResponseCodeExecutionToolResultBlock - - $ref: "#/components/schemas/ResponseMCPToolUseBlock" - - $ref: "#/components/schemas/ResponseMCPToolResultBlock" - - $ref: "#/components/schemas/ResponseContainerUploadBlock" - title: Content - type: array - model: - allOf: - - description: The model that handled the request. - examples: - - claude-sonnet-4-20250514 - maxLength: 256 - minLength: 1 - title: Model - type: string - stop_reason: - allOf: - - anyOf: - - enum: - - end_turn - - max_tokens - - stop_sequence - - tool_use - - pause_turn - - refusal - type: string - - type: "null" - description: >- - The reason that we stopped. - - - This may be one the following values: - - * `"end_turn"`: the model reached a natural stopping point - - * `"max_tokens"`: we exceeded the requested `max_tokens` - or the model's maximum - - * `"stop_sequence"`: one of your provided custom - `stop_sequences` was generated - - * `"tool_use"`: the model invoked one or more tools - - * `"pause_turn"`: we paused a long-running turn. You may - provide the response back as-is in a subsequent request to - let the model continue. - - * `"refusal"`: when streaming classifiers intervene to - handle potential policy violations - - - In non-streaming mode this value is always non-null. In - streaming mode, it is null in the `message_start` event - and non-null otherwise. - title: Stop Reason - stop_sequence: - allOf: - - anyOf: - - type: string - - type: "null" - default: null - description: >- - Which custom stop sequence was generated, if any. - - - This value will be a non-null string if one of your custom - stop sequences was generated. - title: Stop Sequence - usage: - allOf: - - $ref: "#/components/schemas/Usage" - description: >- - Billing and rate-limit usage. - - - Anthropic's API bills and rate-limits by token counts, as - tokens represent the underlying cost to our systems. - - - Under the hood, the API transforms requests into a format - suitable for the model. The model's output then goes - through a parsing stage before becoming an API response. - As a result, the token counts in `usage` will not match - one-to-one with the exact visible content of an API - request or response. - - - For example, `output_tokens` will be non-zero, even for an - empty string response from Claude. - - - Total input tokens in a request is the summation of - `input_tokens`, `cache_creation_input_tokens`, and - `cache_read_input_tokens`. - examples: - - input_tokens: 2095 - output_tokens: 503 - container: - allOf: - - anyOf: - - $ref: "#/components/schemas/Container" - - type: "null" - default: null - description: >- - Information about the container used in this request. - - - This will be non-null if a container tool (e.g. code - execution) was used. - title: Message - examples: - - content: &ref_0 - - text: Hi! My name is Claude. - type: text - id: msg_013Zva2CMHLNnXjNJJKqJ2EF - model: claude-sonnet-4-20250514 - role: assistant - stop_reason: end_turn - stop_sequence: null - type: message - usage: &ref_1 - input_tokens: 2095 - output_tokens: 503 - requiredProperties: - - id - - type - - role - - content - - model - - stop_reason - - stop_sequence - - usage - - container - example: - content: *ref_0 - id: msg_013Zva2CMHLNnXjNJJKqJ2EF - model: claude-sonnet-4-20250514 - role: assistant - stop_reason: end_turn - stop_sequence: null - type: message - usage: *ref_1 - examples: - example: - value: - content: - - text: Hi! My name is Claude. - type: text - id: msg_013Zva2CMHLNnXjNJJKqJ2EF - model: claude-sonnet-4-20250514 - role: assistant - stop_reason: end_turn - stop_sequence: null - type: message - usage: - input_tokens: 2095 - output_tokens: 503 - description: Message object. - 4XX: - application/json: - schemaArray: - - type: object - properties: - error: - allOf: - - discriminator: - mapping: - api_error: "#/components/schemas/APIError" - authentication_error: "#/components/schemas/AuthenticationError" - billing_error: "#/components/schemas/BillingError" - invalid_request_error: "#/components/schemas/InvalidRequestError" - not_found_error: "#/components/schemas/NotFoundError" - overloaded_error: "#/components/schemas/OverloadedError" - permission_error: "#/components/schemas/PermissionError" - rate_limit_error: "#/components/schemas/RateLimitError" - timeout_error: "#/components/schemas/GatewayTimeoutError" - propertyName: type - oneOf: - - $ref: "#/components/schemas/InvalidRequestError" - - $ref: "#/components/schemas/AuthenticationError" - - $ref: "#/components/schemas/BillingError" - - $ref: "#/components/schemas/PermissionError" - - $ref: "#/components/schemas/NotFoundError" - - $ref: "#/components/schemas/RateLimitError" - - $ref: "#/components/schemas/GatewayTimeoutError" - - $ref: "#/components/schemas/APIError" - - $ref: "#/components/schemas/OverloadedError" - title: Error - type: - allOf: - - const: error - default: error - enum: - - error - title: Type - type: string - title: ErrorResponse - requiredProperties: - - error - - type - examples: - example: - value: - error: - message: Invalid request - type: invalid_request_error - type: error - description: >- - Error response. - - - See our [errors - documentation](https://docs.anthropic.com/en/api/errors) for more - details. - deprecated: false - type: path -components: - schemas: - APIError: - properties: - message: - default: Internal server error - title: Message - type: string - type: - const: api_error - default: api_error - enum: - - api_error - title: Type - type: string - required: - - message - - type - title: APIError - type: object - AuthenticationError: - properties: - message: - default: Authentication error - title: Message - type: string - type: - const: authentication_error - default: authentication_error - enum: - - authentication_error - title: Type - type: string - required: - - message - - type - title: AuthenticationError - type: object - Base64ImageSource: - additionalProperties: false - properties: - data: - format: byte - title: Data - type: string - media_type: - enum: - - image/jpeg - - image/png - - image/gif - - image/webp - title: Media Type - type: string - type: - const: base64 - enum: - - base64 - title: Type - type: string - required: - - data - - media_type - - type - title: Base64ImageSource - type: object - Base64PDFSource: - additionalProperties: false - properties: - data: - format: byte - title: Data - type: string - media_type: - const: application/pdf - enum: - - application/pdf - title: Media Type - type: string - type: - const: base64 - enum: - - base64 - title: Type - type: string - required: - - data - - media_type - - type - title: PDF (base64) - type: object - BashTool_20241022: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - name: - const: bash - description: >- - Name of the tool. - - - This is how the tool will be called by the model and in `tool_use` - blocks. - enum: - - bash - title: Name - type: string - type: - const: bash_20241022 - enum: - - bash_20241022 - title: Type - type: string - required: - - name - - type - title: Bash tool (2024-10-22) - type: object - BashTool_20250124: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - name: - const: bash - description: >- - Name of the tool. - - - This is how the tool will be called by the model and in `tool_use` - blocks. - enum: - - bash - title: Name - type: string - type: - const: bash_20250124 - enum: - - bash_20250124 - title: Type - type: string - required: - - name - - type - title: Bash tool (2025-01-24) - type: object - BillingError: - properties: - message: - default: Billing error - title: Message - type: string - type: - const: billing_error - default: billing_error - enum: - - billing_error - title: Type - type: string - required: - - message - - type - title: BillingError - type: object - CacheControlEphemeral: - additionalProperties: false - properties: - ttl: - description: |- - The time-to-live for the cache control breakpoint. - - This may be one the following values: - - `5m`: 5 minutes - - `1h`: 1 hour - - Defaults to `5m`. - enum: - - 5m - - 1h - title: Ttl - type: string - type: - const: ephemeral - enum: - - ephemeral - title: Type - type: string - required: - - type - title: CacheControlEphemeral - type: object - CacheCreation: - properties: - ephemeral_1h_input_tokens: - default: 0 - description: The number of input tokens used to create the 1 hour cache entry. - minimum: 0 - title: Ephemeral 1H Input Tokens - type: integer - ephemeral_5m_input_tokens: - default: 0 - description: The number of input tokens used to create the 5 minute cache entry. - minimum: 0 - title: Ephemeral 5M Input Tokens - type: integer - required: - - ephemeral_1h_input_tokens - - ephemeral_5m_input_tokens - title: CacheCreation - type: object - CodeExecutionToolResultErrorCode: - enum: - - invalid_tool_input - - unavailable - - too_many_requests - - execution_time_exceeded - title: CodeExecutionToolResultErrorCode - type: string - CodeExecutionTool_20250522: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - name: - const: code_execution - description: >- - Name of the tool. - - - This is how the tool will be called by the model and in `tool_use` - blocks. - enum: - - code_execution - title: Name - type: string - type: - const: code_execution_20250522 - enum: - - code_execution_20250522 - title: Type - type: string - required: - - name - - type - title: Code execution tool (2025-05-22) - type: object - ComputerUseTool_20241022: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - display_height_px: - description: The height of the display in pixels. - minimum: 1 - title: Display Height Px - type: integer - display_number: - anyOf: - - minimum: 0 - type: integer - - type: "null" - description: The X11 display number (e.g. 0, 1) for the display. - title: Display Number - display_width_px: - description: The width of the display in pixels. - minimum: 1 - title: Display Width Px - type: integer - name: - const: computer - description: >- - Name of the tool. - - - This is how the tool will be called by the model and in `tool_use` - blocks. - enum: - - computer - title: Name - type: string - type: - const: computer_20241022 - enum: - - computer_20241022 - title: Type - type: string - required: - - display_height_px - - display_width_px - - name - - type - title: Computer use tool (2024-01-22) - type: object - ComputerUseTool_20250124: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - display_height_px: - description: The height of the display in pixels. - minimum: 1 - title: Display Height Px - type: integer - display_number: - anyOf: - - minimum: 0 - type: integer - - type: "null" - description: The X11 display number (e.g. 0, 1) for the display. - title: Display Number - display_width_px: - description: The width of the display in pixels. - minimum: 1 - title: Display Width Px - type: integer - name: - const: computer - description: >- - Name of the tool. - - - This is how the tool will be called by the model and in `tool_use` - blocks. - enum: - - computer - title: Name - type: string - type: - const: computer_20250124 - enum: - - computer_20250124 - title: Type - type: string - required: - - display_height_px - - display_width_px - - name - - type - title: Computer use tool (2025-01-24) - type: object - Container: - description: >- - Information about the container used in the request (for the code - execution tool) - properties: - expires_at: - description: The time at which the container will expire. - format: date-time - title: Expires At - type: string - id: - description: Identifier for the container used in this request - title: Id - type: string - required: - - expires_at - - id - title: Container - type: object - ContentBlockSource: - additionalProperties: false - properties: - content: - anyOf: - - type: string - - items: - discriminator: - mapping: - image: "#/components/schemas/RequestImageBlock" - text: "#/components/schemas/RequestTextBlock" - propertyName: type - oneOf: - - $ref: "#/components/schemas/RequestTextBlock" - - $ref: "#/components/schemas/RequestImageBlock" - type: array - title: Content - type: - const: content - enum: - - content - title: Type - type: string - required: - - content - - type - title: Content block - type: object - FileDocumentSource: - additionalProperties: false - properties: - file_id: - title: File Id - type: string - type: - const: file - enum: - - file - title: Type - type: string - required: - - file_id - - type - title: File document - type: object - FileImageSource: - additionalProperties: false - properties: - file_id: - title: File Id - type: string - type: - const: file - enum: - - file - title: Type - type: string - required: - - file_id - - type - title: FileImageSource - type: object - GatewayTimeoutError: - properties: - message: - default: Request timeout - title: Message - type: string - type: - const: timeout_error - default: timeout_error - enum: - - timeout_error - title: Type - type: string - required: - - message - - type - title: GatewayTimeoutError - type: object - InputMessage: - additionalProperties: false - properties: - content: - anyOf: - - type: string - - items: - discriminator: - mapping: - code_execution_tool_result: "#/components/schemas/RequestCodeExecutionToolResultBlock" - container_upload: "#/components/schemas/RequestContainerUploadBlock" - document: "#/components/schemas/RequestDocumentBlock" - image: "#/components/schemas/RequestImageBlock" - mcp_tool_result: "#/components/schemas/RequestMCPToolResultBlock" - mcp_tool_use: "#/components/schemas/RequestMCPToolUseBlock" - redacted_thinking: "#/components/schemas/RequestRedactedThinkingBlock" - search_result: "#/components/schemas/RequestSearchResultBlock" - server_tool_use: "#/components/schemas/RequestServerToolUseBlock" - text: "#/components/schemas/RequestTextBlock" - thinking: "#/components/schemas/RequestThinkingBlock" - tool_result: "#/components/schemas/RequestToolResultBlock" - tool_use: "#/components/schemas/RequestToolUseBlock" - web_search_tool_result: "#/components/schemas/RequestWebSearchToolResultBlock" - propertyName: type - oneOf: - - $ref: "#/components/schemas/RequestTextBlock" - description: Regular text content. - - $ref: "#/components/schemas/RequestImageBlock" - description: >- - Image content specified directly as base64 data or as a - reference via a URL. - - $ref: "#/components/schemas/RequestDocumentBlock" - description: >- - Document content, either specified directly as base64 - data, as text, or as a reference via a URL. - - $ref: "#/components/schemas/RequestSearchResultBlock" - description: >- - A search result block containing source, title, and - content from search operations. - - $ref: "#/components/schemas/RequestThinkingBlock" - description: A block specifying internal thinking by the model. - - $ref: "#/components/schemas/RequestRedactedThinkingBlock" - description: >- - A block specifying internal, redacted thinking by the - model. - - $ref: "#/components/schemas/RequestToolUseBlock" - description: A block indicating a tool use by the model. - - $ref: "#/components/schemas/RequestToolResultBlock" - description: A block specifying the results of a tool use by the model. - - $ref: "#/components/schemas/RequestServerToolUseBlock" - - $ref: "#/components/schemas/RequestWebSearchToolResultBlock" - - $ref: "#/components/schemas/RequestCodeExecutionToolResultBlock" - - $ref: "#/components/schemas/RequestMCPToolUseBlock" - - $ref: "#/components/schemas/RequestMCPToolResultBlock" - - $ref: "#/components/schemas/RequestContainerUploadBlock" - type: array - title: Content - role: - enum: - - user - - assistant - title: Role - type: string - required: - - content - - role - title: InputMessage - type: object - InputSchema: - additionalProperties: true - properties: - properties: - anyOf: - - type: object - - type: "null" - title: Properties - required: - anyOf: - - items: - type: string - type: array - - type: "null" - title: Required - type: - const: object - enum: - - object - title: Type - type: string - required: - - type - title: InputSchema - type: object - InvalidRequestError: - properties: - message: - default: Invalid request - title: Message - type: string - type: - const: invalid_request_error - default: invalid_request_error - enum: - - invalid_request_error - title: Type - type: string - required: - - message - - type - title: InvalidRequestError - type: object - Metadata: - additionalProperties: false - properties: - user_id: - anyOf: - - maxLength: 256 - type: string - - type: "null" - description: >- - An external identifier for the user who is associated with the - request. - - - This should be a uuid, hash value, or other opaque identifier. - Anthropic may use this id to help detect abuse. Do not include any - identifying information such as name, email address, or phone - number. - examples: - - 13803d75-b4b5-4c3e-b2a2-6f21399b021b - title: User Id - title: Metadata - type: object - NotFoundError: - properties: - message: - default: Not found - title: Message - type: string - type: - const: not_found_error - default: not_found_error - enum: - - not_found_error - title: Type - type: string - required: - - message - - type - title: NotFoundError - type: object - OverloadedError: - properties: - message: - default: Overloaded - title: Message - type: string - type: - const: overloaded_error - default: overloaded_error - enum: - - overloaded_error - title: Type - type: string - required: - - message - - type - title: OverloadedError - type: object - PermissionError: - properties: - message: - default: Permission denied - title: Message - type: string - type: - const: permission_error - default: permission_error - enum: - - permission_error - title: Type - type: string - required: - - message - - type - title: PermissionError - type: object - PlainTextSource: - additionalProperties: false - properties: - data: - title: Data - type: string - media_type: - const: text/plain - enum: - - text/plain - title: Media Type - type: string - type: - const: text - enum: - - text - title: Type - type: string - required: - - data - - media_type - - type - title: Plain text - type: object - RateLimitError: - properties: - message: - default: Rate limited - title: Message - type: string - type: - const: rate_limit_error - default: rate_limit_error - enum: - - rate_limit_error - title: Type - type: string - required: - - message - - type - title: RateLimitError - type: object - RequestCharLocationCitation: - additionalProperties: false - properties: - cited_text: - title: Cited Text - type: string - document_index: - minimum: 0 - title: Document Index - type: integer - document_title: - anyOf: - - maxLength: 255 - minLength: 1 - type: string - - type: "null" - title: Document Title - end_char_index: - title: End Char Index - type: integer - start_char_index: - minimum: 0 - title: Start Char Index - type: integer - type: - const: char_location - enum: - - char_location - title: Type - type: string - required: - - cited_text - - document_index - - document_title - - end_char_index - - start_char_index - - type - title: Character location - type: object - RequestCitationsConfig: - additionalProperties: false - properties: - enabled: - title: Enabled - type: boolean - title: RequestCitationsConfig - type: object - RequestCodeExecutionOutputBlock: - additionalProperties: false - properties: - file_id: - title: File Id - type: string - type: - const: code_execution_output - enum: - - code_execution_output - title: Type - type: string - required: - - file_id - - type - title: RequestCodeExecutionOutputBlock - type: object - RequestCodeExecutionResultBlock: - additionalProperties: false - properties: - content: - items: - $ref: "#/components/schemas/RequestCodeExecutionOutputBlock" - title: Content - type: array - return_code: - title: Return Code - type: integer - stderr: - title: Stderr - type: string - stdout: - title: Stdout - type: string - type: - const: code_execution_result - enum: - - code_execution_result - title: Type - type: string - required: - - content - - return_code - - stderr - - stdout - - type - title: Code execution result - type: object - RequestCodeExecutionToolResultBlock: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - content: - anyOf: - - $ref: "#/components/schemas/RequestCodeExecutionToolResultError" - - $ref: "#/components/schemas/RequestCodeExecutionResultBlock" - title: Content - tool_use_id: - pattern: ^srvtoolu_[a-zA-Z0-9_]+$ - title: Tool Use Id - type: string - type: - const: code_execution_tool_result - enum: - - code_execution_tool_result - title: Type - type: string - required: - - content - - tool_use_id - - type - title: Code execution tool result - type: object - RequestCodeExecutionToolResultError: - additionalProperties: false - properties: - error_code: - $ref: "#/components/schemas/CodeExecutionToolResultErrorCode" - type: - const: code_execution_tool_result_error - enum: - - code_execution_tool_result_error - title: Type - type: string - required: - - error_code - - type - title: Code execution tool error - type: object - RequestContainerUploadBlock: - additionalProperties: false - description: >- - A content block that represents a file to be uploaded to the container - - Files uploaded via this block will be available in the container's input - directory. - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - file_id: - title: File Id - type: string - type: - const: container_upload - enum: - - container_upload - title: Type - type: string - required: - - file_id - - type - title: Container upload - type: object - RequestContentBlockLocationCitation: - additionalProperties: false - properties: - cited_text: - title: Cited Text - type: string - document_index: - minimum: 0 - title: Document Index - type: integer - document_title: - anyOf: - - maxLength: 255 - minLength: 1 - type: string - - type: "null" - title: Document Title - end_block_index: - title: End Block Index - type: integer - start_block_index: - minimum: 0 - title: Start Block Index - type: integer - type: - const: content_block_location - enum: - - content_block_location - title: Type - type: string - required: - - cited_text - - document_index - - document_title - - end_block_index - - start_block_index - - type - title: Content block location - type: object - RequestDocumentBlock: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - citations: - $ref: "#/components/schemas/RequestCitationsConfig" - context: - anyOf: - - minLength: 1 - type: string - - type: "null" - title: Context - source: - discriminator: - mapping: - base64: "#/components/schemas/Base64PDFSource" - content: "#/components/schemas/ContentBlockSource" - file: "#/components/schemas/FileDocumentSource" - text: "#/components/schemas/PlainTextSource" - url: "#/components/schemas/URLPDFSource" - propertyName: type - oneOf: - - $ref: "#/components/schemas/Base64PDFSource" - - $ref: "#/components/schemas/PlainTextSource" - - $ref: "#/components/schemas/ContentBlockSource" - - $ref: "#/components/schemas/URLPDFSource" - - $ref: "#/components/schemas/FileDocumentSource" - title: - anyOf: - - maxLength: 500 - minLength: 1 - type: string - - type: "null" - title: Title - type: - const: document - enum: - - document - title: Type - type: string - required: - - source - - type - title: Document - type: object - RequestImageBlock: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - source: - discriminator: - mapping: - base64: "#/components/schemas/Base64ImageSource" - file: "#/components/schemas/FileImageSource" - url: "#/components/schemas/URLImageSource" - propertyName: type - oneOf: - - $ref: "#/components/schemas/Base64ImageSource" - - $ref: "#/components/schemas/URLImageSource" - - $ref: "#/components/schemas/FileImageSource" - title: Source - type: - const: image - enum: - - image - title: Type - type: string - required: - - source - - type - title: Image - type: object - RequestMCPServerToolConfiguration: - additionalProperties: false - properties: - allowed_tools: - anyOf: - - items: - type: string - type: array - - type: "null" - title: Allowed Tools - enabled: - anyOf: - - type: boolean - - type: "null" - title: Enabled - title: RequestMCPServerToolConfiguration - type: object - RequestMCPServerURLDefinition: - additionalProperties: false - properties: - authorization_token: - anyOf: - - type: string - - type: "null" - title: Authorization Token - name: - title: Name - type: string - tool_configuration: - anyOf: - - $ref: "#/components/schemas/RequestMCPServerToolConfiguration" - - type: "null" - type: - const: url - enum: - - url - title: Type - type: string - url: - title: Url - type: string - required: - - name - - type - - url - title: RequestMCPServerURLDefinition - type: object - RequestMCPToolResultBlock: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - content: - anyOf: - - type: string - - items: - $ref: "#/components/schemas/RequestTextBlock" - type: array - title: Content - is_error: - title: Is Error - type: boolean - tool_use_id: - pattern: ^[a-zA-Z0-9_-]+$ - title: Tool Use Id - type: string - type: - const: mcp_tool_result - enum: - - mcp_tool_result - title: Type - type: string - required: - - tool_use_id - - type - title: MCP tool result - type: object - RequestMCPToolUseBlock: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - id: - pattern: ^[a-zA-Z0-9_-]+$ - title: Id - type: string - input: - title: Input - type: object - name: - title: Name - type: string - server_name: - description: The name of the MCP server - title: Server Name - type: string - type: - const: mcp_tool_use - enum: - - mcp_tool_use - title: Type - type: string - required: - - id - - input - - name - - server_name - - type - title: MCP tool use - type: object - RequestPageLocationCitation: - additionalProperties: false - properties: - cited_text: - title: Cited Text - type: string - document_index: - minimum: 0 - title: Document Index - type: integer - document_title: - anyOf: - - maxLength: 255 - minLength: 1 - type: string - - type: "null" - title: Document Title - end_page_number: - title: End Page Number - type: integer - start_page_number: - minimum: 1 - title: Start Page Number - type: integer - type: - const: page_location - enum: - - page_location - title: Type - type: string - required: - - cited_text - - document_index - - document_title - - end_page_number - - start_page_number - - type - title: Page location - type: object - RequestRedactedThinkingBlock: - additionalProperties: false - properties: - data: - title: Data - type: string - type: - const: redacted_thinking - enum: - - redacted_thinking - title: Type - type: string - required: - - data - - type - title: Redacted thinking - type: object - RequestSearchResultBlock: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - citations: - $ref: "#/components/schemas/RequestCitationsConfig" - content: - items: - $ref: "#/components/schemas/RequestTextBlock" - title: Content - type: array - source: - title: Source - type: string - title: - title: Title - type: string - type: - const: search_result - enum: - - search_result - title: Type - type: string - required: - - content - - source - - title - - type - title: Search result - type: object - RequestSearchResultLocationCitation: - additionalProperties: false - properties: - cited_text: - title: Cited Text - type: string - end_block_index: - title: End Block Index - type: integer - search_result_index: - minimum: 0 - title: Search Result Index - type: integer - source: - title: Source - type: string - start_block_index: - minimum: 0 - title: Start Block Index - type: integer - title: - anyOf: - - type: string - - type: "null" - title: Title - type: - const: search_result_location - enum: - - search_result_location - title: Type - type: string - required: - - cited_text - - end_block_index - - search_result_index - - source - - start_block_index - - title - - type - title: RequestSearchResultLocationCitation - type: object - RequestServerToolUseBlock: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - id: - pattern: ^srvtoolu_[a-zA-Z0-9_]+$ - title: Id - type: string - input: - title: Input - type: object - name: - enum: - - web_search - - code_execution - title: Name - type: string - type: - const: server_tool_use - enum: - - server_tool_use - title: Type - type: string - required: - - id - - input - - name - - type - title: Server tool use - type: object - RequestTextBlock: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - citations: - anyOf: - - items: - discriminator: - mapping: - char_location: "#/components/schemas/RequestCharLocationCitation" - content_block_location: "#/components/schemas/RequestContentBlockLocationCitation" - page_location: "#/components/schemas/RequestPageLocationCitation" - search_result_location: "#/components/schemas/RequestSearchResultLocationCitation" - web_search_result_location: >- - #/components/schemas/RequestWebSearchResultLocationCitation - propertyName: type - oneOf: - - $ref: "#/components/schemas/RequestCharLocationCitation" - - $ref: "#/components/schemas/RequestPageLocationCitation" - - $ref: "#/components/schemas/RequestContentBlockLocationCitation" - - $ref: >- - #/components/schemas/RequestWebSearchResultLocationCitation - - $ref: "#/components/schemas/RequestSearchResultLocationCitation" - type: array - - type: "null" - title: Citations - text: - minLength: 1 - title: Text - type: string - type: - const: text - enum: - - text - title: Type - type: string - required: - - text - - type - title: Text - type: object - RequestThinkingBlock: - additionalProperties: false - properties: - signature: - title: Signature - type: string - thinking: - title: Thinking - type: string - type: - const: thinking - enum: - - thinking - title: Type - type: string - required: - - signature - - thinking - - type - title: Thinking - type: object - RequestToolResultBlock: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - content: - anyOf: - - type: string - - items: - discriminator: - mapping: - image: "#/components/schemas/RequestImageBlock" - search_result: "#/components/schemas/RequestSearchResultBlock" - text: "#/components/schemas/RequestTextBlock" - propertyName: type - oneOf: - - $ref: "#/components/schemas/RequestTextBlock" - - $ref: "#/components/schemas/RequestImageBlock" - - $ref: "#/components/schemas/RequestSearchResultBlock" - type: array - title: Content - is_error: - title: Is Error - type: boolean - tool_use_id: - pattern: ^[a-zA-Z0-9_-]+$ - title: Tool Use Id - type: string - type: - const: tool_result - enum: - - tool_result - title: Type - type: string - required: - - tool_use_id - - type - title: Tool result - type: object - RequestToolUseBlock: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - id: - pattern: ^[a-zA-Z0-9_-]+$ - title: Id - type: string - input: - title: Input - type: object - name: - maxLength: 200 - minLength: 1 - title: Name - type: string - type: - const: tool_use - enum: - - tool_use - title: Type - type: string - required: - - id - - input - - name - - type - title: Tool use - type: object - RequestWebSearchResultBlock: - additionalProperties: false - properties: - encrypted_content: - title: Encrypted Content - type: string - page_age: - anyOf: - - type: string - - type: "null" - title: Page Age - title: - title: Title - type: string - type: - const: web_search_result - enum: - - web_search_result - title: Type - type: string - url: - title: Url - type: string - required: - - encrypted_content - - title - - type - - url - title: RequestWebSearchResultBlock - type: object - RequestWebSearchResultLocationCitation: - additionalProperties: false - properties: - cited_text: - title: Cited Text - type: string - encrypted_index: - title: Encrypted Index - type: string - title: - anyOf: - - maxLength: 512 - minLength: 1 - type: string - - type: "null" - title: Title - type: - const: web_search_result_location - enum: - - web_search_result_location - title: Type - type: string - url: - maxLength: 2048 - minLength: 1 - title: Url - type: string - required: - - cited_text - - encrypted_index - - title - - type - - url - title: RequestWebSearchResultLocationCitation - type: object - RequestWebSearchToolResultBlock: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - content: - anyOf: - - items: - $ref: "#/components/schemas/RequestWebSearchResultBlock" - type: array - - $ref: "#/components/schemas/RequestWebSearchToolResultError" - title: Content - tool_use_id: - pattern: ^srvtoolu_[a-zA-Z0-9_]+$ - title: Tool Use Id - type: string - type: - const: web_search_tool_result - enum: - - web_search_tool_result - title: Type - type: string - required: - - content - - tool_use_id - - type - title: Web search tool result - type: object - RequestWebSearchToolResultError: - additionalProperties: false - properties: - error_code: - $ref: "#/components/schemas/WebSearchToolResultErrorCode" - type: - const: web_search_tool_result_error - enum: - - web_search_tool_result_error - title: Type - type: string - required: - - error_code - - type - title: RequestWebSearchToolResultError - type: object - ResponseCharLocationCitation: - properties: - cited_text: - title: Cited Text - type: string - document_index: - minimum: 0 - title: Document Index - type: integer - document_title: - anyOf: - - type: string - - type: "null" - title: Document Title - end_char_index: - title: End Char Index - type: integer - file_id: - anyOf: - - type: string - - type: "null" - default: null - title: File Id - start_char_index: - minimum: 0 - title: Start Char Index - type: integer - type: - const: char_location - default: char_location - enum: - - char_location - title: Type - type: string - required: - - cited_text - - document_index - - document_title - - end_char_index - - file_id - - start_char_index - - type - title: Character location - type: object - ResponseCodeExecutionOutputBlock: - properties: - file_id: - title: File Id - type: string - type: - const: code_execution_output - default: code_execution_output - enum: - - code_execution_output - title: Type - type: string - required: - - file_id - - type - title: ResponseCodeExecutionOutputBlock - type: object - ResponseCodeExecutionResultBlock: - properties: - content: - items: - $ref: "#/components/schemas/ResponseCodeExecutionOutputBlock" - title: Content - type: array - return_code: - title: Return Code - type: integer - stderr: - title: Stderr - type: string - stdout: - title: Stdout - type: string - type: - const: code_execution_result - default: code_execution_result - enum: - - code_execution_result - title: Type - type: string - required: - - content - - return_code - - stderr - - stdout - - type - title: Code execution result - type: object - ResponseCodeExecutionToolResultBlock: - properties: - content: - anyOf: - - $ref: "#/components/schemas/ResponseCodeExecutionToolResultError" - - $ref: "#/components/schemas/ResponseCodeExecutionResultBlock" - title: Content - tool_use_id: - pattern: ^srvtoolu_[a-zA-Z0-9_]+$ - title: Tool Use Id - type: string - type: - const: code_execution_tool_result - default: code_execution_tool_result - enum: - - code_execution_tool_result - title: Type - type: string - required: - - content - - tool_use_id - - type - title: Code execution tool result - type: object - ResponseCodeExecutionToolResultError: - properties: - error_code: - $ref: "#/components/schemas/CodeExecutionToolResultErrorCode" - type: - const: code_execution_tool_result_error - default: code_execution_tool_result_error - enum: - - code_execution_tool_result_error - title: Type - type: string - required: - - error_code - - type - title: Code execution tool error - type: object - ResponseContainerUploadBlock: - description: Response model for a file uploaded to the container. - properties: - file_id: - title: File Id - type: string - type: - const: container_upload - default: container_upload - enum: - - container_upload - title: Type - type: string - required: - - file_id - - type - title: Container upload - type: object - ResponseContentBlockLocationCitation: - properties: - cited_text: - title: Cited Text - type: string - document_index: - minimum: 0 - title: Document Index - type: integer - document_title: - anyOf: - - type: string - - type: "null" - title: Document Title - end_block_index: - title: End Block Index - type: integer - file_id: - anyOf: - - type: string - - type: "null" - default: null - title: File Id - start_block_index: - minimum: 0 - title: Start Block Index - type: integer - type: - const: content_block_location - default: content_block_location - enum: - - content_block_location - title: Type - type: string - required: - - cited_text - - document_index - - document_title - - end_block_index - - file_id - - start_block_index - - type - title: Content block location - type: object - ResponseMCPToolResultBlock: - properties: - content: - anyOf: - - type: string - - items: - $ref: "#/components/schemas/ResponseTextBlock" - type: array - title: Content - is_error: - default: false - title: Is Error - type: boolean - tool_use_id: - pattern: ^[a-zA-Z0-9_-]+$ - title: Tool Use Id - type: string - type: - const: mcp_tool_result - default: mcp_tool_result - enum: - - mcp_tool_result - title: Type - type: string - required: - - content - - is_error - - tool_use_id - - type - title: MCP tool result - type: object - ResponseMCPToolUseBlock: - properties: - id: - pattern: ^[a-zA-Z0-9_-]+$ - title: Id - type: string - input: - title: Input - type: object - name: - description: The name of the MCP tool - title: Name - type: string - server_name: - description: The name of the MCP server - title: Server Name - type: string - type: - const: mcp_tool_use - default: mcp_tool_use - enum: - - mcp_tool_use - title: Type - type: string - required: - - id - - input - - name - - server_name - - type - title: MCP tool use - type: object - ResponsePageLocationCitation: - properties: - cited_text: - title: Cited Text - type: string - document_index: - minimum: 0 - title: Document Index - type: integer - document_title: - anyOf: - - type: string - - type: "null" - title: Document Title - end_page_number: - title: End Page Number - type: integer - file_id: - anyOf: - - type: string - - type: "null" - default: null - title: File Id - start_page_number: - minimum: 1 - title: Start Page Number - type: integer - type: - const: page_location - default: page_location - enum: - - page_location - title: Type - type: string - required: - - cited_text - - document_index - - document_title - - end_page_number - - file_id - - start_page_number - - type - title: Page location - type: object - ResponseRedactedThinkingBlock: - properties: - data: - title: Data - type: string - type: - const: redacted_thinking - default: redacted_thinking - enum: - - redacted_thinking - title: Type - type: string - required: - - data - - type - title: Redacted thinking - type: object - ResponseSearchResultLocationCitation: - properties: - cited_text: - title: Cited Text - type: string - end_block_index: - title: End Block Index - type: integer - search_result_index: - minimum: 0 - title: Search Result Index - type: integer - source: - title: Source - type: string - start_block_index: - minimum: 0 - title: Start Block Index - type: integer - title: - anyOf: - - type: string - - type: "null" - title: Title - type: - const: search_result_location - default: search_result_location - enum: - - search_result_location - title: Type - type: string - required: - - cited_text - - end_block_index - - search_result_index - - source - - start_block_index - - title - - type - title: ResponseSearchResultLocationCitation - type: object - ResponseServerToolUseBlock: - properties: - id: - pattern: ^srvtoolu_[a-zA-Z0-9_]+$ - title: Id - type: string - input: - title: Input - type: object - name: - enum: - - web_search - - code_execution - title: Name - type: string - type: - const: server_tool_use - default: server_tool_use - enum: - - server_tool_use - title: Type - type: string - required: - - id - - input - - name - - type - title: Server tool use - type: object - ResponseTextBlock: - properties: - citations: - anyOf: - - items: - discriminator: - mapping: - char_location: "#/components/schemas/ResponseCharLocationCitation" - content_block_location: "#/components/schemas/ResponseContentBlockLocationCitation" - page_location: "#/components/schemas/ResponsePageLocationCitation" - search_result_location: "#/components/schemas/ResponseSearchResultLocationCitation" - web_search_result_location: >- - #/components/schemas/ResponseWebSearchResultLocationCitation - propertyName: type - oneOf: - - $ref: "#/components/schemas/ResponseCharLocationCitation" - - $ref: "#/components/schemas/ResponsePageLocationCitation" - - $ref: "#/components/schemas/ResponseContentBlockLocationCitation" - - $ref: >- - #/components/schemas/ResponseWebSearchResultLocationCitation - - $ref: "#/components/schemas/ResponseSearchResultLocationCitation" - type: array - - type: "null" - default: null - description: >- - Citations supporting the text block. - - - The type of citation returned will depend on the type of document - being cited. Citing a PDF results in `page_location`, plain text - results in `char_location`, and content document results in - `content_block_location`. - title: Citations - text: - maxLength: 5000000 - minLength: 0 - title: Text - type: string - type: - const: text - default: text - enum: - - text - title: Type - type: string - required: - - citations - - text - - type - title: Text - type: object - ResponseThinkingBlock: - properties: - signature: - title: Signature - type: string - thinking: - title: Thinking - type: string - type: - const: thinking - default: thinking - enum: - - thinking - title: Type - type: string - required: - - signature - - thinking - - type - title: Thinking - type: object - ResponseToolUseBlock: - properties: - id: - pattern: ^[a-zA-Z0-9_-]+$ - title: Id - type: string - input: - title: Input - type: object - name: - minLength: 1 - title: Name - type: string - type: - const: tool_use - default: tool_use - enum: - - tool_use - title: Type - type: string - required: - - id - - input - - name - - type - title: Tool use - type: object - ResponseWebSearchResultBlock: - properties: - encrypted_content: - title: Encrypted Content - type: string - page_age: - anyOf: - - type: string - - type: "null" - default: null - title: Page Age - title: - title: Title - type: string - type: - const: web_search_result - default: web_search_result - enum: - - web_search_result - title: Type - type: string - url: - title: Url - type: string - required: - - encrypted_content - - page_age - - title - - type - - url - title: ResponseWebSearchResultBlock - type: object - ResponseWebSearchResultLocationCitation: - properties: - cited_text: - title: Cited Text - type: string - encrypted_index: - title: Encrypted Index - type: string - title: - anyOf: - - maxLength: 512 - type: string - - type: "null" - title: Title - type: - const: web_search_result_location - default: web_search_result_location - enum: - - web_search_result_location - title: Type - type: string - url: - title: Url - type: string - required: - - cited_text - - encrypted_index - - title - - type - - url - title: ResponseWebSearchResultLocationCitation - type: object - ResponseWebSearchToolResultBlock: - properties: - content: - anyOf: - - $ref: "#/components/schemas/ResponseWebSearchToolResultError" - - items: - $ref: "#/components/schemas/ResponseWebSearchResultBlock" - type: array - title: Content - tool_use_id: - pattern: ^srvtoolu_[a-zA-Z0-9_]+$ - title: Tool Use Id - type: string - type: - const: web_search_tool_result - default: web_search_tool_result - enum: - - web_search_tool_result - title: Type - type: string - required: - - content - - tool_use_id - - type - title: Web search tool result - type: object - ResponseWebSearchToolResultError: - properties: - error_code: - $ref: "#/components/schemas/WebSearchToolResultErrorCode" - type: - const: web_search_tool_result_error - default: web_search_tool_result_error - enum: - - web_search_tool_result_error - title: Type - type: string - required: - - error_code - - type - title: ResponseWebSearchToolResultError - type: object - ServerToolUsage: - properties: - web_search_requests: - default: 0 - description: The number of web search tool requests. - examples: - - 0 - minimum: 0 - title: Web Search Requests - type: integer - required: - - web_search_requests - title: ServerToolUsage - type: object - TextEditor_20241022: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - name: - const: str_replace_editor - description: >- - Name of the tool. - - - This is how the tool will be called by the model and in `tool_use` - blocks. - enum: - - str_replace_editor - title: Name - type: string - type: - const: text_editor_20241022 - enum: - - text_editor_20241022 - title: Type - type: string - required: - - name - - type - title: Text editor tool (2024-10-22) - type: object - TextEditor_20250124: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - name: - const: str_replace_editor - description: >- - Name of the tool. - - - This is how the tool will be called by the model and in `tool_use` - blocks. - enum: - - str_replace_editor - title: Name - type: string - type: - const: text_editor_20250124 - enum: - - text_editor_20250124 - title: Type - type: string - required: - - name - - type - title: Text editor tool (2025-01-24) - type: object - TextEditor_20250429: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - name: - const: str_replace_based_edit_tool - description: >- - Name of the tool. - - - This is how the tool will be called by the model and in `tool_use` - blocks. - enum: - - str_replace_based_edit_tool - title: Name - type: string - type: - const: text_editor_20250429 - enum: - - text_editor_20250429 - title: Type - type: string - required: - - name - - type - title: Text editor tool (2025-04-29) - type: object - TextEditor_20250728: - additionalProperties: false - properties: - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - max_characters: - anyOf: - - minimum: 1 - type: integer - - type: "null" - description: >- - Maximum number of characters to display when viewing a file. If not - specified, defaults to displaying the full file. - title: Max Characters - name: - const: str_replace_based_edit_tool - description: >- - Name of the tool. - - - This is how the tool will be called by the model and in `tool_use` - blocks. - enum: - - str_replace_based_edit_tool - title: Name - type: string - type: - const: text_editor_20250728 - enum: - - text_editor_20250728 - title: Type - type: string - required: - - name - - type - title: TextEditor_20250728 - type: object - ThinkingConfigDisabled: - additionalProperties: false - properties: - type: - const: disabled - enum: - - disabled - title: Type - type: string - required: - - type - title: Disabled - type: object - ThinkingConfigEnabled: - additionalProperties: false - properties: - budget_tokens: - description: >- - Determines how many tokens Claude can use for its internal reasoning - process. Larger budgets can enable more thorough analysis for - complex problems, improving response quality. - - - Must be â‰Ĩ1024 and less than `max_tokens`. - - - See [extended - thinking](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) - for details. - minimum: 1024 - title: Budget Tokens - type: integer - type: - const: enabled - enum: - - enabled - title: Type - type: string - required: - - budget_tokens - - type - title: Enabled - type: object - Tool: - additionalProperties: false - properties: - type: - anyOf: - - type: "null" - - const: custom - enum: - - custom - type: string - title: Type - description: - description: >- - Description of what this tool does. - - - Tool descriptions should be as detailed as possible. The more - information that the model has about what the tool is and how to use - it, the better it will perform. You can use natural language - descriptions to reinforce important aspects of the tool input JSON - schema. - examples: - - Get the current weather in a given location - title: Description - type: string - name: - description: >- - Name of the tool. - - - This is how the tool will be called by the model and in `tool_use` - blocks. - maxLength: 128 - minLength: 1 - pattern: ^[a-zA-Z0-9_-]{1,128}$ - title: Name - type: string - input_schema: - $ref: "#/components/schemas/InputSchema" - description: >- - [JSON schema](https://json-schema.org/draft/2020-12) for this tool's - input. - - - This defines the shape of the `input` that your tool accepts and - that the model will produce. - examples: - - properties: - location: - description: The city and state, e.g. San Francisco, CA - type: string - unit: - description: Unit for the output - one of (celsius, fahrenheit) - type: string - required: - - location - type: object - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - required: - - name - - input_schema - title: Custom tool - type: object - ToolChoiceAny: - additionalProperties: false - description: The model will use any available tools. - properties: - disable_parallel_tool_use: - description: >- - Whether to disable parallel tool use. - - - Defaults to `false`. If set to `true`, the model will output exactly - one tool use. - title: Disable Parallel Tool Use - type: boolean - type: - const: any - enum: - - any - title: Type - type: string - required: - - type - title: Any - type: object - ToolChoiceAuto: - additionalProperties: false - description: The model will automatically decide whether to use tools. - properties: - disable_parallel_tool_use: - description: >- - Whether to disable parallel tool use. - - - Defaults to `false`. If set to `true`, the model will output at most - one tool use. - title: Disable Parallel Tool Use - type: boolean - type: - const: auto - enum: - - auto - title: Type - type: string - required: - - type - title: Auto - type: object - ToolChoiceNone: - additionalProperties: false - description: The model will not be allowed to use tools. - properties: - type: - const: none - enum: - - none - title: Type - type: string - required: - - type - title: None - type: object - ToolChoiceTool: - additionalProperties: false - description: The model will use the specified tool with `tool_choice.name`. - properties: - disable_parallel_tool_use: - description: >- - Whether to disable parallel tool use. - - - Defaults to `false`. If set to `true`, the model will output exactly - one tool use. - title: Disable Parallel Tool Use - type: boolean - name: - description: The name of the tool to use. - title: Name - type: string - type: - const: tool - enum: - - tool - title: Type - type: string - required: - - name - - type - title: Tool - type: object - URLImageSource: - additionalProperties: false - properties: - type: - const: url - enum: - - url - title: Type - type: string - url: - title: Url - type: string - required: - - type - - url - title: URLImageSource - type: object - URLPDFSource: - additionalProperties: false - properties: - type: - const: url - enum: - - url - title: Type - type: string - url: - title: Url - type: string - required: - - type - - url - title: PDF (URL) - type: object - Usage: - properties: - cache_creation: - anyOf: - - $ref: "#/components/schemas/CacheCreation" - - type: "null" - default: null - description: Breakdown of cached tokens by TTL - cache_creation_input_tokens: - anyOf: - - minimum: 0 - type: integer - - type: "null" - default: null - description: The number of input tokens used to create the cache entry. - examples: - - 2051 - title: Cache Creation Input Tokens - cache_read_input_tokens: - anyOf: - - minimum: 0 - type: integer - - type: "null" - default: null - description: The number of input tokens read from the cache. - examples: - - 2051 - title: Cache Read Input Tokens - input_tokens: - description: The number of input tokens which were used. - examples: - - 2095 - minimum: 0 - title: Input Tokens - type: integer - output_tokens: - description: The number of output tokens which were used. - examples: - - 503 - minimum: 0 - title: Output Tokens - type: integer - server_tool_use: - anyOf: - - $ref: "#/components/schemas/ServerToolUsage" - - type: "null" - default: null - description: The number of server tool requests. - service_tier: - anyOf: - - enum: - - standard - - priority - - batch - type: string - - type: "null" - default: null - description: If the request used the priority, standard, or batch tier. - title: Service Tier - required: - - cache_creation - - cache_creation_input_tokens - - cache_read_input_tokens - - input_tokens - - output_tokens - - server_tool_use - - service_tier - title: Usage - type: object - UserLocation: - additionalProperties: false - properties: - city: - anyOf: - - maxLength: 255 - minLength: 1 - type: string - - type: "null" - description: The city of the user. - examples: - - New York - - Tokyo - - Los Angeles - title: City - country: - anyOf: - - maxLength: 2 - minLength: 2 - type: string - - type: "null" - description: >- - The two letter [ISO country - code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) of the user. - examples: - - US - - JP - - GB - title: Country - region: - anyOf: - - maxLength: 255 - minLength: 1 - type: string - - type: "null" - description: The region of the user. - examples: - - California - - Ontario - - Wales - title: Region - timezone: - anyOf: - - maxLength: 255 - minLength: 1 - type: string - - type: "null" - description: The [IANA timezone](https://nodatime.org/TimeZones) of the user. - examples: - - America/New_York - - Asia/Tokyo - - Europe/London - title: Timezone - type: - const: approximate - enum: - - approximate - title: Type - type: string - required: - - type - title: UserLocation - type: object - WebSearchToolResultErrorCode: - enum: - - invalid_tool_input - - unavailable - - max_uses_exceeded - - too_many_requests - - query_too_long - title: WebSearchToolResultErrorCode - type: string - WebSearchTool_20250305: - additionalProperties: false - properties: - allowed_domains: - anyOf: - - items: - type: string - type: array - - type: "null" - description: >- - If provided, only these domains will be included in results. Cannot - be used alongside `blocked_domains`. - title: Allowed Domains - blocked_domains: - anyOf: - - items: - type: string - type: array - - type: "null" - description: >- - If provided, these domains will never appear in results. Cannot be - used alongside `allowed_domains`. - title: Blocked Domains - cache_control: - anyOf: - - discriminator: - mapping: - ephemeral: "#/components/schemas/CacheControlEphemeral" - propertyName: type - oneOf: - - $ref: "#/components/schemas/CacheControlEphemeral" - - type: "null" - description: Create a cache control breakpoint at this content block. - title: Cache Control - max_uses: - anyOf: - - exclusiveMinimum: 0 - type: integer - - type: "null" - description: Maximum number of times the tool can be used in the API request. - title: Max Uses - name: - const: web_search - description: >- - Name of the tool. - - - This is how the tool will be called by the model and in `tool_use` - blocks. - enum: - - web_search - title: Name - type: string - type: - const: web_search_20250305 - enum: - - web_search_20250305 - title: Type - type: string - user_location: - anyOf: - - $ref: "#/components/schemas/UserLocation" - - type: "null" - description: >- - Parameters for the user's location. Used to provide more relevant - search results. - required: - - name - - type - title: Web search tool (2025-03-05) - type: object -```` diff --git a/aiprompts/anthropic-streaming.md b/aiprompts/anthropic-streaming.md deleted file mode 100644 index 8868e7d4d8..0000000000 --- a/aiprompts/anthropic-streaming.md +++ /dev/null @@ -1,631 +0,0 @@ -# Streaming Messages - -When creating a Message, you can set `"stream": true` to incrementally stream the response using [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent%5Fevents/Using%5Fserver-sent%5Fevents) (SSE). - -## Streaming with SDKs - -Our [Python](https://github.com/anthropics/anthropic-sdk-python) and [TypeScript](https://github.com/anthropics/anthropic-sdk-typescript) SDKs offer multiple ways of streaming. The Python SDK allows both sync and async streams. See the documentation in each SDK for details. - - - ```Python Python - import anthropic - -client = anthropic.Anthropic() - -with client.messages.stream( -max_tokens=1024, -messages=[{"role": "user", "content": "Hello"}], -model="claude-opus-4-1-20250805", -) as stream: -for text in stream.text_stream: -print(text, end="", flush=True) - -```` - -```TypeScript TypeScript -import Anthropic from '@anthropic-ai/sdk'; - -const client = new Anthropic(); - -await client.messages.stream({ - messages: [{role: 'user', content: "Hello"}], - model: 'claude-opus-4-1-20250805', - max_tokens: 1024, -}).on('text', (text) => { - console.log(text); -}); -```` - - - -## Event types - -Each server-sent event includes a named event type and associated JSON data. Each event will use an SSE event name (e.g. `event: message_stop`), and include the matching event `type` in its data. - -Each stream uses the following event flow: - -1. `message_start`: contains a `Message` object with empty `content`. -2. A series of content blocks, each of which have a `content_block_start`, one or more `content_block_delta` events, and a `content_block_stop` event. Each content block will have an `index` that corresponds to its index in the final Message `content` array. -3. One or more `message_delta` events, indicating top-level changes to the final `Message` object. -4. A final `message_stop` event. - - - The token counts shown in the `usage` field of the `message_delta` event are *cumulative*. - - -### Ping events - -Event streams may also include any number of `ping` events. - -### Error events - -We may occasionally send [errors](/en/api/errors) in the event stream. For example, during periods of high usage, you may receive an `overloaded_error`, which would normally correspond to an HTTP 529 in a non-streaming context: - -```json Example error -event: error -data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}} -``` - -### Other events - -In accordance with our [versioning policy](/en/api/versioning), we may add new event types, and your code should handle unknown event types gracefully. - -## Content block delta types - -Each `content_block_delta` event contains a `delta` of a type that updates the `content` block at a given `index`. - -### Text delta - -A `text` content block delta looks like: - -```JSON Text delta -event: content_block_delta -data: {"type": "content_block_delta","index": 0,"delta": {"type": "text_delta", "text": "ello frien"}} -``` - -### Input JSON delta - -The deltas for `tool_use` content blocks correspond to updates for the `input` field of the block. To support maximum granularity, the deltas are _partial JSON strings_, whereas the final `tool_use.input` is always an _object_. - -You can accumulate the string deltas and parse the JSON once you receive a `content_block_stop` event, by using a library like [Pydantic](https://docs.pydantic.dev/latest/concepts/json/#partial-json-parsing) to do partial JSON parsing, or by using our [SDKs](https://docs.anthropic.com/en/api/client-sdks), which provide helpers to access parsed incremental values. - -A `tool_use` content block delta looks like: - -```JSON Input JSON delta -event: content_block_delta -data: {"type": "content_block_delta","index": 1,"delta": {"type": "input_json_delta","partial_json": "{\"location\": \"San Fra"}}} -``` - -Note: Our current models only support emitting one complete key and value property from `input` at a time. As such, when using tools, there may be delays between streaming events while the model is working. Once an `input` key and value are accumulated, we emit them as multiple `content_block_delta` events with chunked partial json so that the format can automatically support finer granularity in future models. - -### Thinking delta - -When using [extended thinking](/en/docs/build-with-claude/extended-thinking#streaming-thinking) with streaming enabled, you'll receive thinking content via `thinking_delta` events. These deltas correspond to the `thinking` field of the `thinking` content blocks. - -For thinking content, a special `signature_delta` event is sent just before the `content_block_stop` event. This signature is used to verify the integrity of the thinking block. - -A typical thinking delta looks like: - -```JSON Thinking delta -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "Let me solve this step by step:\n\n1. First break down 27 * 453"}} -``` - -The signature delta looks like: - -```JSON Signature delta -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds..."}} -``` - -## Full HTTP Stream response - -We strongly recommend that you use our [client SDKs](/en/api/client-sdks) when using streaming mode. However, if you are building a direct API integration, you will need to handle these events yourself. - -A stream response is comprised of: - -1. A `message_start` event -2. Potentially multiple content blocks, each of which contains: - - A `content_block_start` event - - Potentially multiple `content_block_delta` events - - A `content_block_stop` event -3. A `message_delta` event -4. A `message_stop` event - -There may be `ping` events dispersed throughout the response as well. See [Event types](#event-types) for more details on the format. - -### Basic streaming request - - - ```bash Shell - curl https://api.anthropic.com/v1/messages \ - --header "anthropic-version: 2023-06-01" \ - --header "content-type: application/json" \ - --header "x-api-key: $ANTHROPIC_API_KEY" \ - --data \ - '{ - "model": "claude-opus-4-1-20250805", - "messages": [{"role": "user", "content": "Hello"}], - "max_tokens": 256, - "stream": true - }' - ``` - -```python Python -import anthropic - -client = anthropic.Anthropic() - -with client.messages.stream( - model="claude-opus-4-1-20250805", - messages=[{"role": "user", "content": "Hello"}], - max_tokens=256, -) as stream: - for text in stream.text_stream: - print(text, end="", flush=True) -``` - - - -```json Response -event: message_start -data: {"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-opus-4-1-20250805", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}} - -event: content_block_start -data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} - -event: ping -data: {"type": "ping"} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "!"}} - -event: content_block_stop -data: {"type": "content_block_stop", "index": 0} - -event: message_delta -data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence":null}, "usage": {"output_tokens": 15}} - -event: message_stop -data: {"type": "message_stop"} - -``` - -### Streaming request with tool use - - - Tool use now supports fine-grained streaming for parameter values as a beta feature. For more details, see [Fine-grained tool streaming](/en/docs/agents-and-tools/tool-use/fine-grained-tool-streaming). - - -In this request, we ask Claude to use a tool to tell us the weather. - - - ```bash Shell - curl https://api.anthropic.com/v1/messages \ - -H "content-type: application/json" \ - -H "x-api-key: $ANTHROPIC_API_KEY" \ - -H "anthropic-version: 2023-06-01" \ - -d '{ - "model": "claude-opus-4-1-20250805", - "max_tokens": 1024, - "tools": [ - { - "name": "get_weather", - "description": "Get the current weather in a given location", - "input_schema": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The city and state, e.g. San Francisco, CA" - } - }, - "required": ["location"] - } - } - ], - "tool_choice": {"type": "any"}, - "messages": [ - { - "role": "user", - "content": "What is the weather like in San Francisco?" - } - ], - "stream": true - }' - ``` - -```python Python -import anthropic - -client = anthropic.Anthropic() - -tools = [ - { - "name": "get_weather", - "description": "Get the current weather in a given location", - "input_schema": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The city and state, e.g. San Francisco, CA" - } - }, - "required": ["location"] - } - } -] - -with client.messages.stream( - model="claude-opus-4-1-20250805", - max_tokens=1024, - tools=tools, - tool_choice={"type": "any"}, - messages=[ - { - "role": "user", - "content": "What is the weather like in San Francisco?" - } - ], -) as stream: - for text in stream.text_stream: - print(text, end="", flush=True) -``` - - - -```json Response -event: message_start -data: {"type":"message_start","message":{"id":"msg_014p7gG3wDgGV9EUtLvnow3U","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","stop_sequence":null,"usage":{"input_tokens":472,"output_tokens":2},"content":[],"stop_reason":null}} - -event: content_block_start -data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - -event: ping -data: {"type": "ping"} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Okay"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":","}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" let"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"'s"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" check"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" the"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" weather"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" for"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" San"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Francisco"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":","}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" CA"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":":"}} - -event: content_block_stop -data: {"type":"content_block_stop","index":0} - -event: content_block_start -data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01T1x1fJ34qAmk2tNTrN7Up6","name":"get_weather","input":{}}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"location\":"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" \"San"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" Francisc"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"o,"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" CA\""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":", "}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"unit\": \"fah"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"renheit\"}"}} - -event: content_block_stop -data: {"type":"content_block_stop","index":1} - -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":89}} - -event: message_stop -data: {"type":"message_stop"} -``` - -### Streaming request with extended thinking - -In this request, we enable extended thinking with streaming to see Claude's step-by-step reasoning. - - - ```bash Shell - curl https://api.anthropic.com/v1/messages \ - --header "x-api-key: $ANTHROPIC_API_KEY" \ - --header "anthropic-version: 2023-06-01" \ - --header "content-type: application/json" \ - --data \ - '{ - "model": "claude-opus-4-1-20250805", - "max_tokens": 20000, - "stream": true, - "thinking": { - "type": "enabled", - "budget_tokens": 16000 - }, - "messages": [ - { - "role": "user", - "content": "What is 27 * 453?" - } - ] - }' - ``` - -```python Python -import anthropic - -client = anthropic.Anthropic() - -with client.messages.stream( - model="claude-opus-4-1-20250805", - max_tokens=20000, - thinking={ - "type": "enabled", - "budget_tokens": 16000 - }, - messages=[ - { - "role": "user", - "content": "What is 27 * 453?" - } - ], -) as stream: - for event in stream: - if event.type == "content_block_delta": - if event.delta.type == "thinking_delta": - print(event.delta.thinking, end="", flush=True) - elif event.delta.type == "text_delta": - print(event.delta.text, end="", flush=True) -``` - - - -```json Response -event: message_start -data: {"type": "message_start", "message": {"id": "msg_01...", "type": "message", "role": "assistant", "content": [], "model": "claude-opus-4-1-20250805", "stop_reason": null, "stop_sequence": null}} - -event: content_block_start -data: {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "Let me solve this step by step:\n\n1. First break down 27 * 453"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "\n2. 453 = 400 + 50 + 3"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "\n3. 27 * 400 = 10,800"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "\n4. 27 * 50 = 1,350"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "\n5. 27 * 3 = 81"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "\n6. 10,800 + 1,350 + 81 = 12,231"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds..."}} - -event: content_block_stop -data: {"type": "content_block_stop", "index": 0} - -event: content_block_start -data: {"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "27 * 453 = 12,231"}} - -event: content_block_stop -data: {"type": "content_block_stop", "index": 1} - -event: message_delta -data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null}} - -event: message_stop -data: {"type": "message_stop"} -``` - -### Streaming request with web search tool use - -In this request, we ask Claude to search the web for current weather information. - - - ```bash Shell - curl https://api.anthropic.com/v1/messages \ - --header "x-api-key: $ANTHROPIC_API_KEY" \ - --header "anthropic-version: 2023-06-01" \ - --header "content-type: application/json" \ - --data \ - '{ - "model": "claude-opus-4-1-20250805", - "max_tokens": 1024, - "stream": true, - "tools": [ - { - "type": "web_search_20250305", - "name": "web_search", - "max_uses": 5 - } - ], - "messages": [ - { - "role": "user", - "content": "What is the weather like in New York City today?" - } - ] - }' - ``` - -```python Python -import anthropic - -client = anthropic.Anthropic() - -with client.messages.stream( - model="claude-opus-4-1-20250805", - max_tokens=1024, - tools=[ - { - "type": "web_search_20250305", - "name": "web_search", - "max_uses": 5 - } - ], - messages=[ - { - "role": "user", - "content": "What is the weather like in New York City today?" - } - ], -) as stream: - for text in stream.text_stream: - print(text, end="", flush=True) -``` - - - -```json Response -event: message_start -data: {"type":"message_start","message":{"id":"msg_01G...","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":2679,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":3}}} - -event: content_block_start -data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I'll check"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" the current weather in New York City for you"}} - -event: ping -data: {"type": "ping"} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"."}} - -event: content_block_stop -data: {"type":"content_block_stop","index":0} - -event: content_block_start -data: {"type":"content_block_start","index":1,"content_block":{"type":"server_tool_use","id":"srvtoolu_014hJH82Qum7Td6UV8gDXThB","name":"web_search","input":{}}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"query"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\":"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" \"weather"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" NY"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"C to"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"day\"}"}} - -event: content_block_stop -data: {"type":"content_block_stop","index":1 } - -event: content_block_start -data: {"type":"content_block_start","index":2,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_014hJH82Qum7Td6UV8gDXThB","content":[{"type":"web_search_result","title":"Weather in New York City in May 2025 (New York) - detailed Weather Forecast for a month","url":"https://world-weather.info/forecast/usa/new_york/may-2025/","encrypted_content":"Ev0DCioIAxgCIiQ3NmU4ZmI4OC1k...","page_age":null},...]}} - -event: content_block_stop -data: {"type":"content_block_stop","index":2} - -event: content_block_start -data: {"type":"content_block_start","index":3,"content_block":{"type":"text","text":""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":"Here's the current weather information for New York"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":" City:\n\n# Weather"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":" in New York City"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":"\n\n"}} - -... - -event: content_block_stop -data: {"type":"content_block_stop","index":17} - -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10682,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":510,"server_tool_use":{"web_search_requests":1}}} - -event: message_stop -data: {"type":"message_stop"} -``` - -## Error recovery - -When a streaming request is interrupted due to network issues, timeouts, or other errors, you can recover by resuming from where the stream was interrupted. This approach saves you from re-processing the entire response. - -The basic recovery strategy involves: - -1. **Capture the partial response**: Save all content that was successfully received before the error occurred -2. **Construct a continuation request**: Create a new API request that includes the partial assistant response as the beginning of a new assistant message -3. **Resume streaming**: Continue receiving the rest of the response from where it was interrupted - -### Error recovery best practices - -1. **Use SDK features**: Leverage the SDK's built-in message accumulation and error handling capabilities -2. **Handle content types**: Be aware that messages can contain multiple content blocks (`text`, `tool_use`, `thinking`). Tool use and extended thinking blocks cannot be partially recovered. You can resume streaming from the most recent text block. diff --git a/aiprompts/blockcontroller-lifecycle.md b/aiprompts/blockcontroller-lifecycle.md deleted file mode 100644 index 4fa6e1b32f..0000000000 --- a/aiprompts/blockcontroller-lifecycle.md +++ /dev/null @@ -1,291 +0,0 @@ -# Block Controller Lifecycle - -## Overview - -Block controllers manage the execution lifecycle of terminal shells, commands, and other interactive processes. **The frontend drives the controller lifecycle** - the backend is reactive, creating and managing controllers in response to frontend requests. - -## Controller States - -Controllers have three primary states: -- **`init`** - Controller exists but process is not running -- **`running`** - Process is actively running -- **`done`** - Process has exited - -## Architecture Components - -### Backend: Controller Registry - -Location: [`pkg/blockcontroller/blockcontroller.go`](pkg/blockcontroller/blockcontroller.go) - -The backend maintains a **global controller registry** that maps blockIds to controller instances: - -```go -var ( - controllerRegistry = make(map[string]Controller) - registryLock sync.RWMutex -) -``` - -Controllers implement the [`Controller` interface](pkg/blockcontroller/blockcontroller.go:64): -- `Start(ctx, blockMeta, rtOpts, force)` - Start the controller process -- `Stop(graceful, newStatus)` - Stop the controller process -- `GetRuntimeStatus()` - Get current runtime status -- `SendInput(input)` - Send input (data, signals, terminal size) to the process - -### Frontend: View Model - -Location: [`frontend/app/view/term/term-model.ts`](frontend/app/view/term/term-model.ts) - -The [`TermViewModel`](frontend/app/view/term/term-model.ts:44) manages the frontend side of a terminal block: - -**Key Atoms:** -- `shellProcFullStatus` - Holds the current controller status from backend -- `shellProcStatus` - Derived atom for just the status string ("init", "running", "done") -- `isRestarting` - UI state for restart animation - -**Event Subscription:** -The constructor subscribes to controller status events (line 317-324): -```typescript -this.shellProcStatusUnsubFn = waveEventSubscribe({ - eventType: "controllerstatus", - scope: WOS.makeORef("block", blockId), - handler: (event) => { - let bcRTS: BlockControllerRuntimeStatus = event.data; - this.updateShellProcStatus(bcRTS); - }, -}); -``` - -This creates a **reactive data flow**: backend publishes status updates → frontend receives via WebSocket events → UI updates automatically via Jotai atoms. - -## Lifecycle Flow - -### 1. Frontend Triggers Controller Creation/Start - -**Entry Point:** [`ResyncController()`](pkg/blockcontroller/blockcontroller.go:120) RPC endpoint - -The frontend calls this via [`RpcApi.ControllerResyncCommand`](frontend/app/view/term/term-model.ts:661) when: - -1. **Manual Restart** - User clicks restart button or presses Enter when process is done - - Triggered by [`forceRestartController()`](frontend/app/view/term/term-model.ts:652) - - Passes `forcerestart: true` flag - - Includes current terminal size (`termsize: { rows, cols }`) - -2. **Connection Status Changes** - Connection becomes available/unavailable - - Monitored by [`TermResyncHandler`](frontend/app/view/term/term.tsx:34) component - - Watches `connStatus` atom for changes - - Calls `termRef.current?.resyncController("resync handler")` - -3. **Block Meta Changes** - Configuration like controller type or connection changes - - Happens when block metadata is updated - - Backend detects changes and triggers resync - -### 2. Backend Processes Resync Request - -The [`ResyncController()`](pkg/blockcontroller/blockcontroller.go:120) function: - -```go -func ResyncController(ctx context.Context, tabId, blockId string, - rtOpts *waveobj.RuntimeOpts, force bool) error -``` - -**Steps:** - -1. **Get Block Data** - Fetch block metadata from database -2. **Determine Controller Type** - Read `controller` meta key ("shell", "cmd", "tsunami") -3. **Check Existing Controller:** - - If controller type changed → stop old, create new - - If connection changed (for shell/cmd) → stop and restart - - If `force=true` → stop existing -4. **Register Controller** - Add to registry (replaces existing if present) -5. **Check if Start Needed** - If status is "init" or "done": - - For remote connections: verify connection status first - - Call `controller.Start(ctx, blockMeta, rtOpts, force)` -6. **Publish Status** - Controller publishes runtime status updates - -**Important:** Registering a new controller automatically stops any existing controller for that blockId (line 95-98): -```go -if existingController != nil { - existingController.Stop(false, Status_Done) - wstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, blockId)) -} -``` - -### 3. Backend Publishes Status Updates - -Controllers publish their status via the event system when: -- Process starts -- Process state changes -- Process exits - -The status includes: -- `shellprocstatus` - "init", "running", or "done" -- `shellprocconnname` - Connection name being used -- `shellprocexitcode` - Exit code when done -- `version` - Incrementing version number for ordering - -### 4. Frontend Receives and Processes Updates - -**Status Update Handler** (line 321-323): -```typescript -handler: (event) => { - let bcRTS: BlockControllerRuntimeStatus = event.data; - this.updateShellProcStatus(bcRTS); -} -``` - -**Status Update Logic** (line 430-438): -```typescript -updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) { - if (fullStatus == null) return; - const curStatus = globalStore.get(this.shellProcFullStatus); - // Only update if newer version - if (curStatus == null || curStatus.version < fullStatus.version) { - globalStore.set(this.shellProcFullStatus, fullStatus); - } -} -``` - -The version check ensures out-of-order events don't cause issues. - -### 5. UI Updates Reactively - -The UI reacts to status changes through Jotai atoms: - -**Header Buttons** (line 263-306): -- Show "Play" icon when status is "init" -- Show "Refresh" icon when status is "running" or "done" -- Display exit code/status icons for cmd controller - -**Restart Behavior** (line 631-635 in term.tsx via term-model.ts): -```typescript -const shellProcStatus = globalStore.get(this.shellProcStatus); -if ((shellProcStatus == "done" || shellProcStatus == "init") && - keyutil.checkKeyPressed(waveEvent, "Enter")) { - this.forceRestartController(); - return false; -} -``` - -Pressing Enter when the process is done/init triggers a restart. - -## Input Flow - -**Frontend → Backend:** - -When user types in terminal, data flows through [`sendDataToController()`](frontend/app/view/term/term-model.ts:408): -```typescript -sendDataToController(data: string) { - const b64data = stringToBase64(data); - RpcApi.ControllerInputCommand(TabRpcClient, { - blockid: this.blockId, - inputdata64: b64data - }); -} -``` - -This calls the backend [`SendInput()`](pkg/blockcontroller/blockcontroller.go:260) function which forwards to the controller's `SendInput()` method. - -The [`BlockInputUnion`](pkg/blockcontroller/blockcontroller.go:48) supports three types of input: -- `inputdata` - Raw terminal input bytes -- `signame` - Signal names (e.g., "SIGTERM", "SIGINT") -- `termsize` - Terminal size changes (rows/cols) - -## Key Design Principles - -### 1. Frontend-Driven Architecture - -The frontend has full control over controller lifecycle: -- **Creates** controllers by calling ResyncController -- **Restarts** controllers via forcerestart flag -- **Monitors** status via event subscriptions -- **Sends input** via ControllerInput RPC - -The backend is stateless and reactive - it doesn't make lifecycle decisions autonomously. - -### 2. Idempotent Resync - -`ResyncController()` is idempotent - calling it multiple times with the same state is safe: -- If controller exists and is running with correct type/connection → no-op -- If configuration changed → replaces controller -- If force flag set → always restarts - -This makes it safe to call on various triggers (connection change, focus, etc.). - -### 3. Versioned Status Updates - -Status includes a monotonically increasing version number: -- Frontend can process events out-of-order -- Only applies updates with newer versions -- Prevents race conditions from concurrent updates - -### 4. Automatic Cleanup - -When a controller is replaced: -- Old controller is automatically stopped -- Runtime info is cleaned up -- Registry entry is updated atomically - -The `registerController()` function handles this automatically (line 84-99). - -## Common Patterns - -### Restarting a Controller - -```typescript -// In term-model.ts -forceRestartController() { - this.triggerRestartAtom(); // UI feedback - const termsize = { - rows: this.termRef.current?.terminal?.rows, - cols: this.termRef.current?.terminal?.cols, - }; - RpcApi.ControllerResyncCommand(TabRpcClient, { - tabid: globalStore.get(atoms.staticTabId), - blockid: this.blockId, - forcerestart: true, - rtopts: { termsize: termsize }, - }); -} -``` - -### Handling Connection Changes - -```typescript -// In term.tsx - TermResyncHandler component -React.useEffect(() => { - const isConnected = connStatus?.status == "connected"; - const wasConnected = lastConnStatus?.status == "connected"; - if (isConnected == wasConnected && curConnName == lastConnName) { - return; // No change - } - model.termRef.current?.resyncController("resync handler"); - setLastConnStatus(connStatus); -}, [connStatus]); -``` - -### Monitoring Status - -```typescript -// Status is automatically available via atom -const shellProcStatus = jotai.useAtomValue(model.shellProcStatus); - -// Use in UI -if (shellProcStatus == "running") { - // Show running state -} else if (shellProcStatus == "done") { - // Show restart button -} -``` - -## Summary - -The block controller lifecycle is **frontend-driven and event-reactive**: - -1. **Frontend triggers** controller creation/restart via `ControllerResyncCommand` RPC -2. **Backend processes** the request in `ResyncController()`, creating/starting controllers as needed -3. **Backend publishes** status updates via WebSocket events -4. **Frontend receives** status updates and updates Jotai atoms -5. **UI reacts** automatically to atom changes via React components - -This architecture gives the frontend full control over when processes start/stop while keeping the backend focused on process management. The event-based status updates create a clean separation of concerns and enable real-time UI updates without polling. diff --git a/aiprompts/config-system.md b/aiprompts/config-system.md deleted file mode 100644 index 65fd996f5b..0000000000 --- a/aiprompts/config-system.md +++ /dev/null @@ -1,368 +0,0 @@ -# Wave Terminal Configuration System - -This document explains how Wave Terminal's configuration system works and provides step-by-step instructions for adding new configuration values. - -## Overview - -Wave Terminal uses a hierarchical configuration system with the following components: - -1. **Go Struct Definitions** - Type-safe configuration structure in Go -2. **JSON Schema** - Validation schema for configuration files -3. **Default Values** - Built-in default configuration -4. **User Configuration** - User-customizable settings in `~/.config/waveterm/settings.json` -5. **Documentation** - User-facing documentation - -## Configuration File Structure - -Wave Terminal's configuration system is organized into several key directories and files: - -``` -waveterm/ -├── pkg/wconfig/ # Go configuration package -│ ├── settingsconfig.go # Main settings struct definitions -│ ├── defaultconfig/ # Default configuration files -│ │ ├── settings.json # Default settings values -│ │ ├── termthemes.json # Default terminal themes -│ │ ├── presets.json # Default background presets -│ │ └── widgets.json # Default widget configurations -│ └── ... # Other config-related Go files -├── schema/ # JSON Schema definitions -│ ├── settings.json # Settings validation schema -│ └── ... # Other schema files -├── docs/docs/ # User documentation -│ └── config.mdx # Configuration documentation -└── ~/.config/waveterm/ # User config directory (runtime) - ├── settings.json # User settings overrides - ├── termthemes.json # User terminal themes - ├── presets.json # User background presets - ├── widgets.json # User widget configurations - ├── bookmarks.json # Web bookmarks - └── connections.json # SSH/remote connections -``` - -**Key Files:** - -- **[`pkg/wconfig/settingsconfig.go`](pkg/wconfig/settingsconfig.go)** - Defines the `SettingsType` struct with all configuration fields -- **[`schema/settings.json`](schema/settings.json)** - JSON Schema for validation and type checking -- **[`pkg/wconfig/defaultconfig/settings.json`](pkg/wconfig/defaultconfig/settings.json)** - Default values for all settings -- **[`docs/docs/config.mdx`](docs/docs/config.mdx)** - User-facing documentation with descriptions and examples - -## Configuration Architecture - -### Configuration Hierarchy - -1. **Built-in Defaults** (`pkg/wconfig/defaultconfig/settings.json`) -2. **User Settings** (`~/.config/waveterm/settings.json`) -3. **Block-level Overrides** (stored in block metadata) - -Settings cascade from defaults → user settings → block overrides. - -### Block-Level Metadata Override System - -Wave Terminal supports block-level configuration overrides through the metadata system. This allows settings to be applied globally, per-connection, or per-block: - -1. **Global Settings** (`~/.config/waveterm/settings.json`) - Apply to all blocks by default -2. **Connection Settings** (in connections config) - Apply to all blocks using a specific connection -3. **Block Metadata** - Override settings for individual blocks - -**Key Files for Block Overrides:** - -- **[`pkg/waveobj/wtypemeta.go`](pkg/waveobj/wtypemeta.go)** - Defines the `MetaTSType` struct for block-level metadata -- Block metadata fields should match the corresponding settings fields for consistency - -**Frontend Usage:** - -```typescript -// Use getOverrideConfigAtom for hierarchical config resolution -const settingValue = useAtomValue(getOverrideConfigAtom(blockId, "namespace:setting")); - -// This automatically resolves in order: block metadata → connection config → global settings → default -``` - -**Setting Block Metadata:** - -```bash -# Set for current block -wsh setmeta namespace:setting=value - -# Set for specific block -wsh setmeta --block BLOCK_ID namespace:setting=value -``` - -## How to Add a New Configuration Value - -Follow these steps to add a new configuration setting: - -### Step 1: Add to Go Struct Definition - -Edit [`pkg/wconfig/settingsconfig.go`](pkg/wconfig/settingsconfig.go) and add your new field to the `SettingsType` struct: - -```go -type SettingsType struct { - // ... existing fields ... - - // Add your new field with appropriate JSON tag - MyNewSetting string `json:"mynew:setting,omitempty"` - - // For different types: - MyBoolSetting bool `json:"mynew:boolsetting,omitempty"` - MyNumberSetting float64 `json:"mynew:numbersetting,omitempty"` - MyIntSetting *int64 `json:"mynew:intsetting,omitempty"` // Use pointer for optional ints - MyArraySetting []string `json:"mynew:arraysetting,omitempty"` -} -``` - -**Naming Conventions:** - -- Use namespace prefixes (e.g., `term:`, `window:`, `ai:`, `web:`) -- Use lowercase with colons as separators -- Field names should be descriptive and follow Go naming conventions -- Use `omitempty` tag to exclude empty values from JSON - -**Type Guidelines:** - -- Use `*int64` and `*float64` for optional numeric values -- Use `*bool` for optional boolean values -- Use `string` for text values -- Use `[]string` for arrays -- Use `float64` for numbers that can be decimals - -### Step 1.5: Add to Block Metadata (Optional) - -If your setting should support block-level overrides, also add it to [`pkg/waveobj/wtypemeta.go`](pkg/waveobj/wtypemeta.go): - -```go -type MetaTSType struct { - // ... existing fields ... - - // Add your new field with matching JSON tag and type - MyNewSetting *string `json:"mynew:setting,omitempty"` // Use pointer for optional values - - // For different types: - MyBoolSetting *bool `json:"mynew:boolsetting,omitempty"` - MyNumberSetting *float64 `json:"mynew:numbersetting,omitempty"` - MyIntSetting *int `json:"mynew:intsetting,omitempty"` - MyArraySetting []string `json:"mynew:arraysetting,omitempty"` -} -``` - -**Block Metadata Guidelines:** - -- Use pointer types (`*string`, `*bool`, `*int`, `*float64`) for optional overrides -- JSON tags should exactly match the corresponding settings field -- This enables the hierarchical config system: block metadata → connection config → global settings - -### Step 2: Set Default Value (Optional) - -If your setting should have a default value, add it to [`pkg/wconfig/defaultconfig/settings.json`](pkg/wconfig/defaultconfig/settings.json): - -```json -{ - "ai:preset": "ai@global", - "ai:model": "gpt-5-mini", - // ... existing defaults ... - - "mynew:setting": "default value", - "mynew:boolsetting": true, - "mynew:numbersetting": 42.5, - "mynew:intsetting": 100 -} -``` - -**Default Value Guidelines:** - -- Only add defaults for settings that should have non-zero/non-empty initial values -- Ensure defaults make sense for the typical user experience -- Keep defaults conservative and safe - -### Step 3: Update Documentation - -Add your new setting to the configuration table in [`docs/docs/config.mdx`](docs/docs/config.mdx): - -```markdown -| Key Name | Type | Function | -| ------------------- | -------- | ----------------------------------------- | -| mynew:setting | string | Description of what this setting controls | -| mynew:boolsetting | bool | Enable/disable some feature | -| mynew:numbersetting | float | Numeric setting for some parameter | -| mynew:intsetting | int | Integer setting for some configuration | -| mynew:arraysetting | string[] | Array of strings for multiple values | -``` - -Also update the default configuration example in the same file if you added defaults. - -### Step 4: Regenerate Schema and TypeScript Types - -Run the generate task to automatically regenerate the JSON schema and TypeScript types: - -```bash -task generate -``` - -**What this does:** -- Runs `task build:schema` (automatically generates JSON schema from Go structs) -- Generates TypeScript type definitions in [`frontend/types/gotypes.d.ts`](frontend/types/gotypes.d.ts) -- Generates RPC client APIs -- Generates metadata constants - -**Note:** The JSON schema in [`schema/settings.json`](schema/settings.json) is **automatically generated** from the Go struct definitions - you don't need to edit it manually. - -### Step 5: Use in Frontend Code - -Access your new setting in React components: - -```typescript -import { getOverrideConfigAtom, useAtomValue } from "@/store/global"; - -// In a React component -const MyComponent = ({ blockId }: { blockId: string }) => { - // Use override config atom for hierarchical resolution - // This automatically checks: block metadata → connection config → global settings → default - const mySettingAtom = getOverrideConfigAtom(blockId, "mynew:setting"); - const mySetting = useAtomValue(mySettingAtom) ?? "fallback value"; - - // For global-only settings (no block overrides) - const globalOnlySetting = useAtomValue(getSettingsKeyAtom("mynew:globalsetting")) ?? "fallback"; - - return
Setting value: {mySetting}
; -}; -``` - -**Frontend Configuration Patterns:** - -```typescript -// 1. Settings with block-level overrides (recommended) -const termFontSize = useAtomValue(getOverrideConfigAtom(blockId, "term:fontsize")) ?? 12; - -// 2. Global-only settings -const appGlobalHotkey = useAtomValue(getSettingsKeyAtom("app:globalhotkey")) ?? ""; - -// 3. Connection-specific settings -const connStatus = useAtomValue(getConnStatusAtom(connectionName)); -``` - -### Step 6: Use in Backend Code - -Access settings in Go code: - -```go -// Get the full config -fullConfig := wconfig.GetWatcher().GetFullConfig() - -// Access your setting -myValue := fullConfig.Settings.MyNewSetting -``` - -## Configuration Patterns - -### Namespace Organization - -Settings are organized by namespace using colon separators: - -- `app:*` - Application-level settings -- `term:*` - Terminal-specific settings -- `window:*` - Window and UI settings -- `ai:*` - AI-related settings -- `web:*` - Web browser settings -- `editor:*` - Code editor settings -- `conn:*` - Connection settings - -### Clear/Reset Pattern - -Each namespace can have a "clear" field for resetting all settings in that namespace: - -```go -AppClear bool `json:"app:*,omitempty"` -TermClear bool `json:"term:*,omitempty"` -``` - -### Optional vs Required Settings - -- Use pointer types (`*bool`, `*int64`, `*float64`) for truly optional settings -- Use regular types for settings that should always have a value -- Provide sensible defaults for important settings - -### Block-Level Overrides - -Settings can be overridden at the block level using metadata: - -```typescript -// Set block-specific override -await RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", blockId), - meta: { "mynew:setting": "block-specific value" }, -}); -``` - -## Example: Adding a New Terminal Setting - -Here's a complete example adding a new terminal setting `term:bellsound` with block-level override support: - -### 1. Go Struct (settingsconfig.go) - -```go -type SettingsType struct { - // ... existing fields ... - TermBellSound string `json:"term:bellsound,omitempty"` -} -``` - -### 2. Block Metadata (wtypemeta.go) - -```go -type MetaTSType struct { - // ... existing fields ... - TermBellSound *string `json:"term:bellsound,omitempty"` // Pointer for optional override -} -``` - -### 3. Default Value (defaultconfig/settings.json - optional) - -```json -{ - "term:bellsound": "default" -} -``` - -### 4. Documentation (docs/config.mdx) - -```markdown -| term:bellsound | string | Sound to play for terminal bell ("default", "none", or custom sound file path) | -``` - -### 5. Regenerate Types - -```bash -task generate -``` - -### 6. Frontend Usage - -```typescript -// Use override config for hierarchical resolution -const bellSoundAtom = getOverrideConfigAtom(blockId, "term:bellsound"); -const bellSound = useAtomValue(bellSoundAtom) ?? "default"; -``` - -### 7. Usage Examples - -```bash -# Set globally -wsh setconfig term:bellsound="custom.wav" - -# Set for current block only -wsh setmeta term:bellsound="none" - -# Set for specific block -wsh setmeta --block BLOCK_ID term:bellsound="beep" -``` - -## Testing Your Configuration - -1. **Build and run** Wave Terminal with your changes -2. **Test default behavior** - Ensure the default value works -3. **Test user override** - Add your setting to `~/.config/waveterm/settings.json` -4. **Test block override** - Set block-specific metadata -5. **Verify schema validation** - Ensure invalid values are rejected - -## Common Pitfalls diff --git a/aiprompts/conn-arch.md b/aiprompts/conn-arch.md deleted file mode 100644 index b03abbad2f..0000000000 --- a/aiprompts/conn-arch.md +++ /dev/null @@ -1,612 +0,0 @@ -# Wave Terminal Connection Architecture - -## Overview - -Wave Terminal's connection system is designed to provide a unified interface for running shell processes across local, SSH, and WSL environments. The architecture is built in layers, with clear separation of concerns between connection management, shell process execution, and block-level orchestration. - -## Architecture Layers - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Block Controllers │ -│ (blockcontroller/blockcontroller.go, shellcontroller.go) │ -│ - Block lifecycle management │ -│ - Controller registry and switching │ -│ - Connection status verification │ -└─────────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ Connection Controllers (ConnUnion) │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Local │ │ SSH │ │ WSL │ │ -│ │ │ │ (conncontrol │ │ (wslconn) │ │ -│ │ │ │ ler) │ │ │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -│ - Connection lifecycle (init → connecting → connected) │ -│ - WSH (Wave Shell Extensions) management │ -│ - Domain socket setup for RPC communication │ -└─────────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ Shell Process Execution │ -│ (shellexec/shellexec.go) │ -│ - ShellProc wrapper for running processes │ -│ - PTY management │ -│ - Process lifecycle (start, wait, kill) │ -└─────────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ Low-Level Connection Implementation │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ os/exec │ │golang.org/x/ │ │ pkg/wsl │ │ -│ │ │ │ crypto/ssh │ │ │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -│ - Local process spawning │ -│ - SSH protocol implementation │ -│ - WSL command execution │ -└─────────────────────────────────────────────────────────────────┘ -``` - -## Key Components - -### 1. Block Controllers (`pkg/blockcontroller/`) - -**Primary Files:** -- [`blockcontroller.go`](../pkg/blockcontroller/blockcontroller.go) - Controller registry and orchestration -- [`shellcontroller.go`](../pkg/blockcontroller/shellcontroller.go) - Shell/terminal controller implementation - -**Responsibilities:** -- **Controller Registry**: Maintains a global map of active block controllers (`controllerRegistry`) -- **Lifecycle Management**: Handles controller creation, starting, stopping, and switching -- **Connection Verification**: Checks connection status before starting shell processes ([`CheckConnStatus()`](../pkg/blockcontroller/blockcontroller.go:360)) -- **Controller Types**: Supports different controller types (shell, cmd, tsunami) - -**Key Functions:** -- [`ResyncController()`](../pkg/blockcontroller/blockcontroller.go:120) - Main entry point for synchronizing block state with desired controller -- [`registerController()`](../pkg/blockcontroller/blockcontroller.go:84) - Registers a new controller, stopping any existing one -- [`getController()`](../pkg/blockcontroller/blockcontroller.go:78) - Retrieves active controller for a block - -**ShellController Details:** -- Implements the `Controller` interface -- Manages shell processes via [`ShellProc`](../pkg/shellexec/shellexec.go:48) -- Handles three connection types via `ConnUnion`: - - **Local**: Direct process execution on local machine - - **SSH**: Remote execution via SSH connections - - **WSL**: Windows Subsystem for Linux execution -- Key methods: - - [`setupAndStartShellProcess()`](../pkg/blockcontroller/shellcontroller.go:364) - Sets up and starts shell process - - [`getConnUnion()`](../pkg/blockcontroller/shellcontroller.go:321) - Determines connection type and retrieves connection object - - [`manageRunningShellProcess()`](../pkg/blockcontroller/shellcontroller.go:500+) - Manages I/O for running process - -### 2. Connection Controllers - -#### SSH Connections (`pkg/remote/conncontroller/`) - -**Primary File:** [`conncontroller.go`](../pkg/remote/conncontroller/conncontroller.go) - -**Architecture:** -- **Global Registry**: `clientControllerMap` maintains all SSH connections -- **Connection Lifecycle**: - ``` - init → connecting → connected → (running) → disconnected/error - ``` -- **Thread Safety**: Each connection has its own lock (`SSHConn.Lock`) - -**SSHConn Structure:** -```go -type SSHConn struct { - Lock *sync.Mutex - Status string // Connection state - WshEnabled *atomic.Bool // WSH availability flag - Opts *remote.SSHOpts // Connection parameters - Client *ssh.Client // Underlying SSH client - DomainSockName string // Unix socket for RPC - DomainSockListener net.Listener // Socket listener - ConnController *ssh.Session // Runs "wsh connserver" - Error string // Connection error - WshError string // WSH-specific error - WshVersion string // Installed WSH version - // ... -} -``` - -**Key Responsibilities:** -1. **SSH Client Management**: - - Establishes SSH connections using [`golang.org/x/crypto/ssh`](https://pkg.go.dev/golang.org/x/crypto/ssh) - - Handles authentication (pubkey, password, keyboard-interactive) - - Supports ProxyJump for multi-hop connections - -2. **Domain Socket Setup** ([`OpenDomainSocketListener()`](../pkg/remote/conncontroller/conncontroller.go:201)): - - Creates Unix domain socket on remote host (`/tmp/waveterm-*.sock`) - - Enables bidirectional RPC communication - - Socket used by both connserver and shell processes - -3. **WSH (Wave Shell Extensions) Management**: - - **Version Check** ([`StartConnServer()`](../pkg/remote/conncontroller/conncontroller.go:277)): Runs `wsh version` to check installation - - **Installation** ([`InstallWsh()`](../pkg/remote/conncontroller/conncontroller.go:478)): Copies appropriate WSH binary to remote - - **Update** ([`UpdateWsh()`](../pkg/remote/conncontroller/conncontroller.go:417)): Updates existing WSH installation - - **User Prompts** ([`getPermissionToInstallWsh()`](../pkg/remote/conncontroller/conncontroller.go:434)): Asks user for install permission - -4. **Connection Server** (`wsh connserver`): - - Long-running process on remote host - - Provides RPC services for file operations, command execution, etc. - - Communicates via domain socket - - Template: [`ConnServerCmdTemplate`](../pkg/remote/conncontroller/conncontroller.go:74) - -**Connection Flow:** -``` -1. GetConn(opts) - Retrieve or create connection -2. Connect(ctx) - Initiate connection -3. CheckIfNeedsAuth() - Verify authentication needed -4. OpenDomainSocketListener() - Set up RPC channel -5. StartConnServer() - Launch wsh connserver -6. (Install/Update WSH if needed) -7. Status: Connected - Ready for shell processes -``` - -#### SSH Client (`pkg/remote/sshclient.go`) - -**Responsibilities:** -- **Authentication Methods**: - - Public key with optional passphrase ([`createPublicKeyCallback()`](../pkg/remote/sshclient.go:118)) - - Password authentication ([`createPasswordCallbackPrompt()`](../pkg/remote/sshclient.go:227)) - - Keyboard-interactive ([`createInteractiveKbdInteractiveChallenge()`](../pkg/remote/sshclient.go:264)) - - SSH agent support - -- **Known Hosts Verification** ([`createHostKeyCallback()`](../pkg/remote/sshclient.go:429)): - - Reads `~/.ssh/known_hosts` and global known_hosts - - Prompts user for unknown hosts - - Handles key changes/mismatches - -- **ProxyJump Support**: - - Recursive connection through jump hosts - - Max depth: `SshProxyJumpMaxDepth = 10` - -- **User Interaction**: - - Integrates with Wave's [`userinput`](../pkg/userinput/) system - - Non-blocking prompts for passwords, passphrases, host verification - -#### WSL Connections (`pkg/wslconn/`) - -**Primary File:** [`wslconn.go`](../pkg/wslconn/wslconn.go) - -**Architecture:** -- **Similar to SSH**: Parallel structure to `conncontroller` but for WSL -- **Global Registry**: `clientControllerMap` for WSL connections -- **Connection Naming**: `wsl://[distro-name]` (e.g., `wsl://Ubuntu`) - -**WslConn Structure:** -```go -type WslConn struct { - Lock *sync.Mutex - Status string - WshEnabled *atomic.Bool - Name wsl.WslName // Distro name - Client *wsl.Distro // WSL distro interface - DomainSockName string // Uses RemoteFullDomainSocketPath - ConnController *wsl.WslCmd // Runs "wsh connserver" - // ... similar to SSHConn -} -``` - -**Key Differences from SSH:** -- **No Network Socket**: WSL processes run locally, no SSH connection needed -- **Domain Socket Path**: Uses predetermined path ([`wavebase.RemoteFullDomainSocketPath`](../pkg/wavebase/)) -- **Command Execution**: Uses `wsl.exe` command-line tool -- **Simpler Authentication**: No auth needed, user already logged into Windows - -**Connection Flow:** -``` -1. GetWslConn(distroName) - Get/create WSL connection -2. Connect(ctx) - Start connection process -3. OpenDomainSocketListener() - Set domain socket path (no actual listener) -4. StartConnServer() - Launch wsh connserver in WSL -5. (Install/Update WSH if needed) -6. Status: Connected - Ready for shell processes -``` - -### 3. Shell Process Execution (`pkg/shellexec/`) - -**Primary File:** [`shellexec.go`](../pkg/shellexec/shellexec.go) - -**ShellProc Structure:** -```go -type ShellProc struct { - ConnName string // Connection identifier - Cmd ConnInterface // Actual process interface - CloseOnce *sync.Once // Ensures single close - DoneCh chan any // Signals process completion - WaitErr error // Process exit status -} -``` - -**ConnInterface Implementations:** -- **Local**: [`CombinedConnInterface`](../pkg/shellexec/) wraps `os/exec.Cmd` with PTY -- **SSH**: [`RemoteConnInterface`](../pkg/shellexec/) wraps SSH session -- **WSL**: [`WslConnInterface`](../pkg/shellexec/) wraps WSL command - -**Process Startup Functions:** -- [`StartLocalShellProc()`](../pkg/shellexec/) - Local shell processes -- [`StartRemoteShellProc()`](../pkg/shellexec/) - SSH remote shells (with WSH) -- [`StartRemoteShellProcNoWsh()`](../pkg/shellexec/) - SSH remote shells (no WSH) -- [`StartWslShellProc()`](../pkg/shellexec/) - WSL shells (with WSH) -- [`StartWslShellProcNoWsh()`](../pkg/shellexec/) - WSL shells (no WSH) - -**Key Features:** -- **PTY Management**: Pseudo-terminal for interactive shells -- **Graceful Shutdown**: Sends SIGTERM, waits briefly, then SIGKILL -- **Process Wrapping**: Abstracts differences between local/remote/WSL execution - -### 4. Generic Connection Interface (`pkg/genconn/`) - -**Purpose**: Provides abstraction layer for running commands across different connection types - -**Primary File:** [`ssh-impl.go`](../pkg/genconn/ssh-impl.go) - -**Interface Hierarchy:** -```go -ShellClient -> ShellProcessController -``` - -**SSHShellClient:** -- Wraps `*ssh.Client` -- Creates `SSHProcessController` for each command - -**SSHProcessController:** -- Wraps `*ssh.Session` -- Implements stdio piping (stdin, stdout, stderr) -- Handles command lifecycle (Start, Wait, Kill) -- Thread-safe with internal locking - -**Usage Pattern:** -```go -client := genconn.MakeSSHShellClient(sshClient) -proc, _ := client.MakeProcessController(cmdSpec) -stdout, _ := proc.StdoutPipe() -proc.Start() -// Read from stdout... -proc.Wait() -``` - -### 5. Shell Utilities (`pkg/util/shellutil/`) - -**Primary File:** [`shellutil.go`](../pkg/util/shellutil/shellutil.go) - -**Responsibilities:** - -1. **Shell Detection**: - - [`DetectLocalShellPath()`](../pkg/util/shellutil/shellutil.go:87) - Finds user's default shell - - [`GetShellTypeFromShellPath()`](../pkg/util/shellutil/shellutil.go:462) - Identifies shell type (bash, zsh, fish, pwsh) - - [`DetectShellTypeAndVersion()`](../pkg/util/shellutil/shellutil.go:486) - Gets shell version info - -2. **Shell Integration Files**: - - [`InitCustomShellStartupFiles()`](../pkg/util/shellutil/shellutil.go:270) - Creates Wave's shell integration - - Manages startup files for each shell type: - - Bash: `.bashrc` in `shell/bash/` - - Zsh: `.zshrc`, `.zprofile`, etc. in `shell/zsh/` - - Fish: `wave.fish` in `shell/fish/` - - PowerShell: `wavepwsh.ps1` in `shell/pwsh/` - -3. **Environment Management**: - - [`WaveshellLocalEnvVars()`](../pkg/util/shellutil/shellutil.go:218) - Wave-specific environment variables - - [`UpdateCmdEnv()`](../pkg/util/shellutil/shellutil.go:231) - Updates command environment - -4. **WSH Binary Management**: - - [`GetLocalWshBinaryPath()`](../pkg/util/shellutil/shellutil.go:334) - Locates platform-specific WSH binary - - Supports multiple OS/arch combinations - -5. **Git Bash Detection** (Windows): - - [`FindGitBash()`](../pkg/util/shellutil/shellutil.go:156) - Locates Git Bash installation - - Checks multiple common installation paths - -## Connection Types and Workflows - -### Local Connections - -**Connection Name**: `"local"`, `"local:"`, or `""` (empty) - -**Workflow:** -1. Block controller checks connection type via [`IsLocalConnName()`](../pkg/remote/conncontroller/conncontroller.go:80) -2. No connection setup needed -3. Shell process started directly via [`StartLocalShellProc()`](../pkg/shellexec/) -4. Uses `os/exec.Cmd` with PTY -5. WSH integration via environment variables - -**Special Case - Git Bash (Windows):** -- Variant: `"local:gitbash"` -- Requires special shell path detection -- Uses Git Bash binary instead of default shell - -### SSH Connections - -**Connection Name**: `"user@host:port"` (parsed by [`remote.ParseOpts()`](../pkg/remote/)) - -**Full Connection Workflow:** - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 1. Connection Request (from Block Controller) │ -└─────────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ 2. GetConn(opts) - Retrieve/Create SSHConn │ -│ - Check global registry (clientControllerMap) │ -│ - Create new SSHConn if needed │ -│ - Status: "init" │ -└─────────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ 3. conn.Connect(ctx) - Establish SSH Connection │ -│ - Status: "connecting" │ -│ - Read SSH config (~/.ssh/config) │ -│ - Resolve ProxyJump if configured │ -│ - Create SSH client auth methods: │ -│ â€ĸ Public key (with agent support) │ -│ â€ĸ Password │ -│ â€ĸ Keyboard-interactive │ -│ - Establish SSH connection │ -│ - Verify known_hosts │ -└─────────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ 4. OpenDomainSocketListener(ctx) - Set Up RPC Channel │ -│ - Create random socket path: /tmp/waveterm-[random].sock │ -│ - Use ssh.Client.ListenUnix() for remote forwarding │ -│ - Start RPC listener goroutine │ -│ - Socket available for all subsequent operations │ -└─────────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ 5. StartConnServer(ctx) - Launch Wave Shell Extensions │ -│ - Run: "wsh version" to check installation │ -│ - If not installed or outdated: │ -│ a. Detect remote platform (OS/arch) │ -│ b. Get user permission (if configured) │ -│ c. InstallWsh() - Copy binary to remote │ -│ d. Retry StartConnServer() │ -│ - Run: "wsh connserver" on remote │ -│ - Pass JWT token for authentication │ -│ - Monitor connserver output │ -│ - Wait for RPC route registration │ -│ - Status: "connected" │ -└─────────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ 6. Connection Ready - Can Start Shell Processes │ -│ - SSHConn available in registry │ -│ - Domain socket active for RPC │ -│ - WSH connserver running │ -└─────────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ 7. Start Shell Process (from ShellController) │ -│ - setupAndStartShellProcess() │ -│ - Create swap token (for shell integration) │ -│ - StartRemoteShellProc() or StartRemoteShellProcNoWsh() │ -│ - SSH session created for shell │ -│ - PTY allocated │ -│ - Shell starts with Wave integration │ -└─────────────────────────────────────────────────────────────────┘ -``` - -**WSH (Wave Shell Extensions) Details:** - -**What is WSH?** -- Binary program (`wsh`) that runs on remote hosts -- Provides RPC services for Wave Terminal -- Written in Go, cross-platform -- Versioned to match Wave Terminal version - -**WSH Components:** -1. **wsh version**: Reports installed version -2. **wsh connserver**: Long-running RPC server - - Handles file operations - - Executes commands - - Provides remote state information - - Communicates over domain socket - -**WSH Installation Process:** -1. Check if wsh is installed: Run `wsh version` -2. If not installed: Detect platform with `uname -sm` -3. Get appropriate binary from local cache -4. Copy to remote: `~/.waveterm/bin/wsh` -5. Set executable permissions -6. Restart connection process - -**With vs Without WSH:** -- **With WSH**: Full RPC support, better integration, file sync -- **Without WSH**: Basic shell only, limited features -- Fallback to no-WSH mode on installation failure - -### WSL Connections - -**Connection Name**: `"wsl://[distro]"` (e.g., `"wsl://Ubuntu"`) - -**Workflow:** -``` -1. GetWslConn(distroName) - Get/create WslConn -2. conn.Connect(ctx) - Start connection -3. OpenDomainSocketListener() - Set socket path (no actual listener) -4. StartConnServer() - Launch "wsh connserver" via wsl.exe -5. Install/update WSH if needed (similar to SSH) -6. Status: "connected" -7. StartWslShellProc() - Create shell process in WSL -``` - -**Key Differences from SSH:** -- Uses `wsl.exe` command-line tool -- No network connection overhead -- Predetermined domain socket path -- Simpler authentication (inherited from Windows) - -## Token Swap System - -**Purpose**: Pass connection-specific environment variables to shell processes - -**Implementation:** [`shellutil.TokenSwapEntry`](../pkg/util/shellutil/) - -**Flow:** -1. ShellController creates swap token before starting process -2. Token contains: - - Socket name for RPC - - JWT token for authentication - - RPC context (TabId, BlockId, Conn) - - Custom environment variables -3. Token stored in global swap map -4. Shell process receives token ID via environment -5. Shell integration scripts swap token for actual values -6. Token removed from map after use - -**Purpose:** -- Avoid exposing JWT tokens in process listings -- Enable shell integration without hardcoded values -- Support multiple shells on same connection - -## Error Handling and Recovery - -### Connection Failures - -**SSH Connection Errors:** -- Authentication failure → Prompt user (password, passphrase) -- Host key mismatch → Prompt for verification -- Network timeout → Status: "error", display error message -- ProxyJump failure → Error shows which jump host failed - -**Recovery Mechanisms:** -- [`conn.Reconnect(ctx)`](../pkg/remote/conncontroller/) - Close and re-establish connection -- [`conn.WaitForConnect(ctx)`](../pkg/remote/conncontroller/) - Block until connected -- Automatic fallback to no-WSH mode on installation failure - -### Process Failures - -**Shell Process Errors:** -- Process crash → WaitErr contains exit code -- PTY failure → Captured in error message -- I/O errors → Logged and surfaced to user - -**Cleanup:** -- [`ShellProc.Close()`](../pkg/shellexec/shellexec.go:56) - Graceful then forceful kill -- [`SSHConn.close_nolock()`](../pkg/remote/conncontroller/conncontroller.go:167) - Cleanup all resources -- [`deleteController()`](../pkg/blockcontroller/blockcontroller.go:101) - Remove from registry - -## Configuration Integration - -### Connection Configuration - -**Source:** [`pkg/wconfig/`](../pkg/wconfig/) - -**Per-Connection Settings:** -- `conn:wshenabled` - Enable/disable WSH -- `conn:wshpath` - Custom WSH binary path -- `conn:shellpath` - Custom shell path - -**Global Settings:** -- `conn:askbeforewshinstall` - Prompt before WSH installation -- Stored in `~/.waveterm/config/settings.json` -- Per-connection overrides in `~/.waveterm/config/connections.json` - -### SSH Configuration - -**Source:** `~/.ssh/config` - -**Supported Directives:** -- `Host` - Connection matching -- `HostName` - Target hostname -- `Port` - SSH port -- `User` - Username -- `IdentityFile` - Private key paths -- `ProxyJump` - Jump host specification -- `UserKnownHostsFile` - Known hosts file -- `GlobalKnownHostsFile` - System known hosts -- `AddKeysToAgent` - Add keys to SSH agent - -**Library:** [`github.com/kevinburke/ssh_config`](https://github.com/kevinburke/ssh_config) - -## Thread Safety - -### Synchronization Patterns - -**SSHConn/WslConn:** -```go -conn.Lock.Lock() -defer conn.Lock.Unlock() -// ... modify connection state -``` - -**Atomic Flags:** -```go -conn.WshEnabled.Load() // Read WSH enabled status -conn.WshEnabled.Store(v) // Update atomically -``` - -**Controller Registry:** -```go -registryLock.RLock() // Read lock for lookups -registryLock.Lock() // Write lock for modifications -``` - -**ShellProc Completion:** -```go -sp.CloseOnce.Do(func() { // Ensure single execution - sp.WaitErr = waitErr - close(sp.DoneCh) // Signal completion -}) -``` - -## Event System Integration - -### Connection Events - -**Published via:** [`pkg/wps/`](../pkg/wps/) (Wave Publish/Subscribe) - -**Event Types:** -- `Event_ConnChange` - Connection status changed -- `Event_ControllerStatus` - Block controller status update -- `Event_BlockFile` - Block file operation (terminal output) - -**Example:** -```go -wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_ConnChange, - Scopes: []string{fmt.Sprintf("connection:%s", connName)}, - Data: connStatus, -}) -``` - -**Frontend Integration:** -- Events received via WebSocket -- Connection status updates UI -- Real-time terminal output streaming - -## Summary of Responsibilities - -| Component | Responsibilities | -|-----------|-----------------| -| **blockcontroller/** | Block lifecycle, controller registry, connection coordination | -| **shellcontroller** | Shell process management, ConnUnion abstraction, I/O handling | -| **conncontroller/** | SSH connection lifecycle, WSH management, domain socket setup | -| **wslconn/** | WSL connection lifecycle, parallel to SSH but for WSL | -| **sshclient.go** | Low-level SSH: auth, known_hosts, ProxyJump | -| **shellexec/** | Process execution abstraction, PTY management | -| **genconn/** | Generic command execution interface | -| **shellutil/** | Shell detection, integration files, environment setup | - -## Key Design Principles - -1. **Layered Architecture**: Clear separation between block management, connection management, and process execution - -2. **Connection Abstraction**: ConnUnion pattern allows uniform handling of Local/SSH/WSL - -3. **WSH Optional**: System works with and without Wave Shell Extensions, degrading gracefully - -4. **Thread Safety**: Defensive locking, atomic flags, singleton patterns prevent race conditions - -5. **Error Recovery**: Multiple retry mechanisms, fallback modes, user prompts for resolution - -6. **Configuration Hierarchy**: Global → Connection-Specific → Runtime overrides - -7. **Event-Driven Updates**: Real-time status updates via pub/sub system - -8. **User Interaction**: Non-blocking prompts for passwords, confirmations, installations - -This architecture provides a robust foundation for Wave Terminal's multi-environment shell capabilities, with clear extension points for adding new connection types or capabilities. \ No newline at end of file diff --git a/aiprompts/contextmenu.md b/aiprompts/contextmenu.md deleted file mode 100644 index 7447c4f1df..0000000000 --- a/aiprompts/contextmenu.md +++ /dev/null @@ -1,141 +0,0 @@ -# Context Menu Quick Reference - -This guide provides a quick overview of how to create and display a context menu using our system. - ---- - -## ContextMenuItem Type - -Define each menu item using the `ContextMenuItem` type: - -```ts -type ContextMenuItem = { - label?: string; - type?: "separator" | "normal" | "submenu" | "checkbox" | "radio"; - role?: string; // Electron role (optional) - click?: () => void; // Callback for item selection (not needed if role is set) - submenu?: ContextMenuItem[]; // For nested menus - checked?: boolean; // For checkbox or radio items - visible?: boolean; - enabled?: boolean; - sublabel?: string; -}; -``` - ---- - -## Import and Show the Menu - -Import the context menu module: - -```ts -import { ContextMenuModel } from "@/app/store/contextmenu"; -``` - -To display the context menu, call: - -```ts -ContextMenuModel.showContextMenu(menu, event); -``` - -- **menu**: An array of `ContextMenuItem`. -- **event**: The mouse event that triggered the context menu (typically from an onContextMenu handler). - ---- - -## Basic Example - -A simple context menu with a separator: - -```ts -const menu: ContextMenuItem[] = [ - { - label: "New File", - click: () => { - /* create a new file */ - }, - }, - { - label: "New Folder", - click: () => { - /* create a new folder */ - }, - }, - { type: "separator" }, - { - label: "Rename", - click: () => { - /* rename item */ - }, - }, -]; - -ContextMenuModel.showContextMenu(menu, e); -``` - ---- - -## Example with Submenu and Checkboxes - -Toggle settings using a submenu with checkbox items: - -```ts -const isClearOnStart = true; // Example setting - -const menu: ContextMenuItem[] = [ - { - label: "Clear Output On Restart", - submenu: [ - { - label: "On", - type: "checkbox", - checked: isClearOnStart, - click: () => { - // Set the config to enable clear on restart - }, - }, - { - label: "Off", - type: "checkbox", - checked: !isClearOnStart, - click: () => { - // Set the config to disable clear on restart - }, - }, - ], - }, -]; - -ContextMenuModel.showContextMenu(menu, e); -``` - ---- - -## Editing a Config File Example - -Open a configuration file (e.g., `widgets.json`) in preview mode: - -```ts -{ - label: "Edit widgets.json", - click: () => { - fireAndForget(async () => { - const path = `${getApi().getConfigDir()}/widgets.json`; - const blockDef: BlockDef = { - meta: { view: "preview", file: path }, - }; - await createBlock(blockDef, false, true); - }); - }, -} -``` - ---- - -## Summary - -- **Menu Definition**: Use the `ContextMenuItem` type. -- **Actions**: Use `click` for actions; use `submenu` for nested options. -- **Separators**: Use `type: "separator"` to group items. -- **Toggles**: Use `type: "checkbox"` or `"radio"` with the `checked` property. -- **Displaying**: Use `ContextMenuModel.showContextMenu(menu, event)` to render the menu. diff --git a/aiprompts/fe-conn-arch.md b/aiprompts/fe-conn-arch.md deleted file mode 100644 index eafb46ceaf..0000000000 --- a/aiprompts/fe-conn-arch.md +++ /dev/null @@ -1,1007 +0,0 @@ -# Wave Terminal Frontend Connection Architecture - -## Overview - -The frontend connection architecture provides a reactive interface for managing and interacting with connections (local, SSH, WSL, S3). It follows a unidirectional data flow pattern where the backend manages connection state, the frontend observes this state through Jotai atoms, and user interactions trigger backend operations via RPC commands. - -## Architecture Pattern - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ User Interface │ -│ - ConnectionButton (displays status) │ -│ - ChangeConnectionBlockModal (connection picker) │ -│ - ConnStatusOverlay (error states) │ -└─────────────────────────────────────────────────────────────────┘ - ↕ -┌─────────────────────────────────────────────────────────────────┐ -│ Jotai Reactive State │ -│ - ConnStatusMapAtom (connection statuses) │ -│ - View Model Atoms (derived connection state) │ -│ - Block Metadata (connection selection) │ -└─────────────────────────────────────────────────────────────────┘ - ↕ -┌─────────────────────────────────────────────────────────────────┐ -│ RPC Commands │ -│ - ConnListCommand (list connections) │ -│ - ConnEnsureCommand (ensure connected) │ -│ - ConnConnectCommand/ConnDisconnectCommand │ -│ - SetMetaCommand (change block connection) │ -│ - ControllerInputCommand (send data to shell) │ -└─────────────────────────────────────────────────────────────────┘ - ↕ -┌─────────────────────────────────────────────────────────────────┐ -│ Backend (see conn-arch.md) │ -│ - Connection Controllers (SSHConn, WslConn) │ -│ - Block Controllers (ShellController) │ -│ - Shell Process Execution │ -└─────────────────────────────────────────────────────────────────┘ -``` - -## Key Components - -### 1. Connection State Management ([`frontend/app/store/global.ts`](../frontend/app/store/global.ts)) - -**ConnStatusMapAtom** -```typescript -const ConnStatusMapAtom = atom(new Map>()) -``` - -- Global registry of connection status atoms -- One atom per connection (keyed by connection name) -- Backend updates status via wave events -- Frontend components subscribe to individual connection atoms - -**getConnStatusAtom()** -```typescript -function getConnStatusAtom(connName: string): PrimitiveAtom -``` - -- Retrieves or creates status atom for a connection -- Returns cached atom if exists -- Creates new atom initialized to default if needed -- Used by view models to track their connection - -**ConnStatus Structure** -```typescript -interface ConnStatus { - status: "init" | "connecting" | "connected" | "disconnected" | "error" - connection: string // Connection name - connected: boolean // Is currently connected - activeconnnum: number // Color assignment number (1-8) - wshenabled: boolean // WSH available on this connection - error?: string // Error message if status is "error" - wsherror?: string // WSH-specific error -} -``` - -**allConnStatusAtom** -```typescript -const allConnStatusAtom = atom((get) => { - const connStatusMap = get(ConnStatusMapAtom) - const connStatuses = Array.from(connStatusMap.values()).map((atom) => get(atom)) - return connStatuses -}) -``` - -- Provides array of all connection statuses -- Used by connection modal to display all available connections -- Automatically updates when any connection status changes - -### 2. Connection Button UI ([`frontend/app/block/blockutil.tsx`](../frontend/app/block/blockutil.tsx)) - -**ConnectionButton Component** - -```typescript -export const ConnectionButton = React.memo( - React.forwardRef( - ({ connection, changeConnModalAtom }, ref) => { - const connStatusAtom = getConnStatusAtom(connection) - const connStatus = jotai.useAtomValue(connStatusAtom) - // ... renders connection status with colored icon - } - ) -) -``` - -**Responsibilities:** -- Displays connection name and status icon -- Color-codes connections (8 colors, cycling) -- Shows visual states: - - **Local**: Laptop icon (grey) - - **Connecting**: Animated dots (yellow/warning) - - **Connected**: Arrow icon (colored by activeconnnum) - - **Error**: Slashed arrow icon (red) - - **Disconnected**: Slashed arrow icon (grey) -- Opens connection modal on click - -**Color Assignment:** -```typescript -function computeConnColorNum(connStatus: ConnStatus): number { - const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors - return connColorNum == 0 ? NumActiveConnColors : connColorNum -} -``` - -- Backend assigns `activeconnnum` sequentially -- Frontend cycles through 8 CSS color variables -- `var(--conn-icon-color-1)` through `var(--conn-icon-color-8)` - -### 3. Connection Selection Modal ([`frontend/app/modals/conntypeahead.tsx`](../frontend/app/modals/conntypeahead.tsx)) - -**ChangeConnectionBlockModal Component** - -**Data Fetching:** -```typescript -useEffect(() => { - if (!changeConnModalOpen) return - - // Fetch available connections - RpcApi.ConnListCommand(TabRpcClient, { timeout: 2000 }) - .then(setConnList) - - RpcApi.WslListCommand(TabRpcClient, { timeout: 2000 }) - .then(setWslList) - - RpcApi.ConnListAWSCommand(TabRpcClient, { timeout: 2000 }) - .then(setS3List) -}, [changeConnModalOpen]) -``` - -**Connection Change Handler:** -```typescript -const changeConnection = async (connName: string) => { - // Update block metadata with new connection - await RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", blockId), - meta: { - connection: connName, - file: newFile, // Reset file path for new connection - "cmd:cwd": null // Clear working directory - } - }) - - // Ensure connection is established - await RpcApi.ConnEnsureCommand(TabRpcClient, { - connname: connName, - logblockid: blockId - }, { timeout: 60000 }) -} -``` - -**Suggestion Categories:** -1. **Local Connections** - - Local machine (`""` or `"local:"`) - - Git Bash (Windows only: `"local:gitbash"`) - - WSL distros (`"wsl://Ubuntu"`, etc.) - -2. **Remote Connections** (SSH) - - User-configured SSH connections - - Format: `"user@host"` or `"user@host:port"` - - Filtered by `display:hidden` config - -3. **S3 Connections** (optional) - - AWS S3 profiles - - Format: `"aws:profile-name"` - -4. **Actions** - - Reconnect (if disconnected/error) - - Disconnect (if connected) - - Edit Connections (opens config editor) - - New Connection (creates new SSH config) - -**Filtering Logic:** -```typescript -function filterConnections( - connList: Array, - connSelected: string, - fullConfig: FullConfigType, - filterOutNowsh: boolean -): Array { - const connectionsConfig = fullConfig.connections - return connList.filter((conn) => { - const hidden = connectionsConfig?.[conn]?.["display:hidden"] ?? false - const wshEnabled = connectionsConfig?.[conn]?.["conn:wshenabled"] ?? true - return conn.includes(connSelected) && - !hidden && - (wshEnabled || !filterOutNowsh) - }) -} -``` - -### 4. Connection Status Overlay ([`frontend/app/block/blockframe.tsx`](../frontend/app/block/blockframe.tsx)) - -**ConnStatusOverlay Component** - -Displays over block content when: -- Connection is disconnected or in error state -- WSH installation/update errors occur -- Not in layout mode (Ctrl+Shift held) -- Connection modal is not open - -**Features:** -- Shows connection status text -- Displays error messages (scrollable) -- Reconnect button (for disconnected/error) -- "Always disable wsh" button (for WSH errors) -- Adaptive layout based on width - -**Handlers:** -```typescript -// Reconnect to failed connection -const handleTryReconnect = () => { - RpcApi.ConnConnectCommand(TabRpcClient, { - host: connName, - logblockid: nodeModel.blockId - }, { timeout: 60000 }) -} - -// Disable WSH for this connection -const handleDisableWsh = async () => { - await RpcApi.SetConnectionsConfigCommand(TabRpcClient, { - host: connName, - metamaptype: { "conn:wshenabled": false } - }) -} -``` - -### 5. View Model Integration - -View models integrate connection state into their reactive data flow: - -#### Terminal View Model ([`frontend/app/view/term/term-model.ts`](../frontend/app/view/term/term-model.ts)) - -```typescript -class TermViewModel implements ViewModel { - // Connection management flag - manageConnection = atom((get) => { - const termMode = get(this.termMode) - if (termMode == "vdom") return false // VDOM mode doesn't show conn button - - const isCmd = get(this.isCmdController) - if (isCmd) return false // Cmd controller doesn't manage connections - - return true // Standard terminals show connection button - }) - - // Connection status for this block - connStatus = atom((get) => { - const blockData = get(this.blockAtom) - const connName = blockData?.meta?.connection - const connAtom = getConnStatusAtom(connName) - return get(connAtom) - }) - - // Filter connections without WSH - filterOutNowsh = atom(false) -} -``` - -**End Icon Button Logic:** -```typescript -endIconButtons = atom((get) => { - const connStatus = get(this.connStatus) - const shellProcStatus = get(this.shellProcStatus) - - // Only show restart button if connected - if (connStatus?.status != "connected") { - return [] - } - - // Show appropriate icon based on shell state - if (shellProcStatus == "init") { - return [{ icon: "play", title: "Click to Start Shell" }] - } else if (shellProcStatus == "running") { - return [{ icon: "refresh", title: "Shell Running. Click to Restart" }] - } else if (shellProcStatus == "done") { - return [{ icon: "refresh", title: "Shell Exited. Click to Restart" }] - } -}) -``` - -#### Preview View Model ([`frontend/app/view/preview/preview-model.tsx`](../frontend/app/view/preview/preview-model.tsx)) - -```typescript -class PreviewModel implements ViewModel { - // Always manages connection - manageConnection = atom(true) - - // Connection status - connStatus = atom((get) => { - const blockData = get(this.blockAtom) - const connName = blockData?.meta?.connection - const connAtom = getConnStatusAtom(connName) - return get(connAtom) - }) - - // Filter out connections without WSH (file ops require WSH) - filterOutNowsh = atom(true) - - // Ensure connection before operations - connection = atom>(async (get) => { - const connName = get(this.blockAtom)?.meta?.connection - try { - await RpcApi.ConnEnsureCommand(TabRpcClient, { - connname: connName - }, { timeout: 60000 }) - globalStore.set(this.connectionError, "") - } catch (e) { - globalStore.set(this.connectionError, e as string) - } - return connName - }) -} -``` - -**File Operations Over Connection:** -```typescript -// Reads file from remote/local connection -statFile = atom>(async (get) => { - const fileName = get(this.metaFilePath) - const path = await this.formatRemoteUri(fileName, get) - - return await RpcApi.FileInfoCommand(TabRpcClient, { - info: { path } - }) -}) - -fullFile = atom>(async (get) => { - const fileName = get(this.metaFilePath) - const path = await this.formatRemoteUri(fileName, get) - - return await RpcApi.FileReadCommand(TabRpcClient, { - info: { path } - }) -}) -``` - -### 6. Block Controller Integration - -**View models do NOT directly manage shell processes.** They interact with block controllers via RPC: - -**Starting a Shell:** -```typescript -// User clicks restart button in terminal -forceRestartController() { - // Backend handles connection verification and process startup - RpcApi.ControllerRestartCommand(TabRpcClient, { - blockid: this.blockId, - force: true - }) -} -``` - -**Sending Input to Shell:** -```typescript -sendDataToController(data: string) { - const b64data = stringToBase64(data) - RpcApi.ControllerInputCommand(TabRpcClient, { - blockid: this.blockId, - inputdata64: b64data - }) -} -``` - -**Backend Block Controller Flow:** -1. Frontend calls `ControllerRestartCommand` -2. Backend `ShellController.Run()` starts -3. `CheckConnStatus()` verifies connection is ready -4. If not connected, triggers connection attempt -5. Once connected, `setupAndStartShellProcess()` -6. `getConnUnion()` retrieves appropriate connection (Local/SSH/WSL) -7. `StartLocalShellProc()`, `StartRemoteShellProc()`, or `StartWslShellProc()` -8. Process I/O managed by `manageRunningShellProcess()` - -## Connection Configuration - -### Hierarchical Configuration System - -Wave uses a three-level config hierarchy for connections: - -1. **Global Settings** (`settings`) -2. **Connection-Level Config** (`connections[connName]`) -3. **Block-Level Overrides** (`block.meta`) - -**Override Resolution:** -```typescript -function getOverrideConfigAtom(blockId: string, key: T): Atom { - return atom((get) => { - // 1. Check block metadata - const metaKeyVal = get(getBlockMetaKeyAtom(blockId, key)) - if (metaKeyVal != null) return metaKeyVal - - // 2. Check connection config - const connName = get(getBlockMetaKeyAtom(blockId, "connection")) - const connConfigKeyVal = get(getConnConfigKeyAtom(connName, key)) - if (connConfigKeyVal != null) return connConfigKeyVal - - // 3. Fall back to global settings - const settingsVal = get(getSettingsKeyAtom(key)) - return settingsVal ?? null - }) -} -``` - -### Common Connection Settings - -**Connection Keywords** (apply to specific connections): -- `conn:wshenabled` - Enable/disable WSH for this connection -- `conn:wshpath` - Custom WSH binary path -- `display:hidden` - Hide connection from selector -- `display:order` - Sort order in connection list -- `term:fontsize` - Font size for terminals on this connection -- `term:theme` - Color theme for terminals on this connection - -**Example Usage in View Models:** -```typescript -// Font size with connection override -fontSizeAtom = atom((get) => { - const blockData = get(this.blockAtom) - const connName = blockData?.meta?.connection - const fullConfig = get(atoms.fullConfigAtom) - - // Check: block meta > connection config > global settings - const fontSize = blockData?.meta?.["term:fontsize"] ?? - fullConfig?.connections?.[connName]?.["term:fontsize"] ?? - get(getSettingsKeyAtom("term:fontsize")) ?? - 12 - - return boundNumber(fontSize, 4, 64) -}) -``` - -## RPC Interface - -### Connection Management Commands - -**ConnListCommand** -```typescript -ConnListCommand(client: RpcClient): Promise -``` -- Returns list of configured SSH connection names -- Used by connection modal to populate remote connections -- Filters by `display:hidden` config on frontend - -**WslListCommand** -```typescript -WslListCommand(client: RpcClient): Promise -``` -- Returns list of installed WSL distribution names -- Windows only (silently fails on other platforms) -- Connection names formatted as `wsl://[distro]` - -**ConnListAWSCommand** -```typescript -ConnListAWSCommand(client: RpcClient): Promise -``` -- Returns list of AWS profile names from config -- Used for S3 preview connections -- Connection names formatted as `aws:[profile]` - -**ConnEnsureCommand** -```typescript -ConnEnsureCommand( - client: RpcClient, - data: { connname: string, logblockid?: string } -): Promise -``` -- Ensures connection is in "connected" state -- Triggers connection if not already connected -- Waits for connection to complete or timeout -- Used before file operations and by view models - -**ConnConnectCommand** -```typescript -ConnConnectCommand( - client: RpcClient, - data: { host: string, logblockid?: string } -): Promise -``` -- Explicitly connects to specified connection -- Used by "Reconnect" action in overlay -- Returns when connection succeeds or fails - -**ConnDisconnectCommand** -```typescript -ConnDisconnectCommand( - client: RpcClient, - connName: string -): Promise -``` -- Disconnects active connection -- Used by "Disconnect" action in connection modal -- Closes all shells/processes on that connection - -**SetMetaCommand** -```typescript -SetMetaCommand( - client: RpcClient, - data: { - oref: string, // WaveObject reference - meta: MetaType // Metadata updates - } -): Promise -``` -- Updates block metadata (including connection) -- Used when changing block's connection -- Triggers backend to switch connection context - -**SetConnectionsConfigCommand** -```typescript -SetConnectionsConfigCommand( - client: RpcClient, - data: { - host: string, // Connection name - metamaptype: any // Config updates - } -): Promise -``` -- Updates connection-level configuration -- Used to disable WSH (`conn:wshenabled: false`) -- Persists to config file - -### File Operations (Connection-Aware) - -**FileInfoCommand** -```typescript -FileInfoCommand( - client: RpcClient, - data: { info: { path: string } } -): Promise -``` -- Gets file metadata (size, type, permissions, etc.) -- Path format: `[connName]:[filepath]` (e.g., `user@host:~/file.txt`) -- Uses connection's WSH for remote files - -**FileReadCommand** -```typescript -FileReadCommand( - client: RpcClient, - data: { info: { path: string } } -): Promise -``` -- Reads file content as base64 -- Supports streaming for large files -- Remote files read via connection's WSH - -### Controller Commands (Indirect Connection Usage) - -**ControllerInputCommand** -```typescript -ControllerInputCommand( - client: RpcClient, - data: { blockid: string, inputdata64: string } -): Promise -``` -- Sends input to block's controller (shell) -- Controller uses block's connection for execution -- Base64-encoded to handle binary data - -**ControllerRestartCommand** -```typescript -ControllerRestartCommand( - client: RpcClient, - data: { blockid: string, force?: boolean } -): Promise -``` -- Restarts block's controller -- Backend checks connection status before starting -- If not connected, triggers connection first - -## Event-Driven Updates - -### Wave Event Subscriptions - -**Connection Status Updates:** -```typescript -waveEventSubscribe({ - eventType: "connstatus", - handler: (event) => { - const status: ConnStatus = event.data - updateConnStatusAtom(status.connection, status) - } -}) -``` -- Backend emits connection status changes -- Frontend updates corresponding atom -- All subscribed components re-render automatically - -**Configuration Updates:** -```typescript -waveEventSubscribe({ - eventType: "config", - handler: (event) => { - const fullConfig = event.data.fullconfig - globalStore.set(atoms.fullConfigAtom, fullConfig) - } -}) -``` -- Backend watches config files for changes -- Pushes updates to all connected frontends -- Connection configuration changes take effect immediately - -## Data Flow Patterns - -### Pattern 1: Changing Block Connection - -``` -User Action: Click connection button → select new connection - ↓ - ChangeConnectionBlockModal.changeConnection() - ↓ - RpcApi.SetMetaCommand({ connection: newConn }) - ↓ - Backend updates block metadata → emits waveobj:update - ↓ - Frontend WOS updates blockAtom - ↓ - View model connStatus atom recomputes - ↓ - ConnectionButton re-renders with new connection - ↓ - RpcApi.ConnEnsureCommand() ensures connected - ↓ - Backend triggers connection if needed - ↓ - Backend emits connstatus events as connection progresses - ↓ - Frontend updates ConnStatus atom ("connecting" → "connected") - ↓ - ConnectionButton shows connecting animation → connected state -``` - -### Pattern 2: Shell Process Lifecycle - -``` -User Action: Press Enter in disconnected terminal - ↓ - View model detects shellProcStatus == "init" or "done" - ↓ - forceRestartController() called - ↓ - RpcApi.ControllerRestartCommand() - ↓ - Backend ShellController.Run() starts - ↓ - CheckConnStatus() verifies connection - ↓ - If not connected: trigger connection - ↓ - (Frontend shows ConnStatusOverlay with "connecting") - ↓ - Connection succeeds → WSH available - ↓ - setupAndStartShellProcess() - ↓ - StartRemoteShellProc() with connection's SSH client - ↓ - Backend emits controllerstatus event - ↓ - Frontend updates shellProcStatus atom - ↓ - View model endIconButtons recomputes (restart button) - ↓ - Terminal ready for input -``` - -### Pattern 3: File Preview Over Connection - -``` -User Action: Open preview block with file path - ↓ - PreviewModel initialized with file path - ↓ - connection atom ensures connection - ↓ - RpcApi.ConnEnsureCommand(connName) - ↓ - Backend establishes connection if needed - ↓ - (Frontend shows ConnStatusOverlay if connecting) - ↓ - Connection ready - ↓ - statFile atom triggers FileInfoCommand - ↓ - Backend routes to connection's WSH - ↓ - WSH executes stat on remote file - ↓ - FileInfo returned to frontend - ↓ - PreviewModel determines if text/binary/streaming - ↓ - fullFile atom triggers FileReadCommand - ↓ - Backend streams file via WSH - ↓ - File content displayed in preview -``` - -## Connection Types and Behaviors - -### Local Connection - -**Connection Names:** -- `""` (empty string) -- `"local"` -- `"local:"` -- `"local:gitbash"` (Windows only) - -**Frontend Behavior:** -- No connection modal interaction needed -- ConnectionButton shows laptop icon (grey) -- No ConnStatusOverlay shown (always "connected") -- File paths used directly without connection prefix -- Shell processes spawn locally via `os/exec` - -**View Model Configuration:** -```typescript -connName = "" // or "local" or "local:gitbash" -connStatus = { - status: "connected", - connection: "", - connected: true, - activeconnnum: 0, // No color assignment - wshenabled: true // Local WSH always available -} -``` - -### SSH Connection - -**Connection Names:** -- Format: `"user@host"`, `"user@host:port"`, or config name -- Examples: `"ubuntu@192.168.1.10"`, `"myserver"`, `"deploy@prod:2222"` - -**Frontend Behavior:** -- ConnectionButton shows arrow icon with color -- Color cycles through 8 colors based on `activeconnnum` -- ConnStatusOverlay shown during connecting/error states -- File paths prefixed with connection: `user@host:~/file.txt` -- Modal allows reconnect/disconnect actions - -**Connection States:** -```typescript -// Connecting -connStatus = { - status: "connecting", - connection: "user@host", - connected: false, - activeconnnum: 3, - wshenabled: false // Not yet determined -} - -// Connected with WSH -connStatus = { - status: "connected", - connection: "user@host", - connected: true, - activeconnnum: 3, - wshenabled: true -} - -// Connected without WSH -connStatus = { - status: "connected", - connection: "user@host", - connected: true, - activeconnnum: 3, - wshenabled: false, - wsherror: "wsh installation failed: permission denied" -} - -// Error -connStatus = { - status: "error", - connection: "user@host", - connected: false, - activeconnnum: 3, - wshenabled: false, - error: "ssh: connection refused" -} -``` - -**WSH Errors:** -- Shown in ConnStatusOverlay -- "always disable wsh" button sets `conn:wshenabled: false` -- Terminal still works without WSH (limited features) -- Preview requires WSH (shows error if unavailable) - -### WSL Connection - -**Connection Names:** -- Format: `"wsl://[distro]"` -- Examples: `"wsl://Ubuntu"`, `"wsl://Debian"`, `"wsl://Ubuntu-20.04"` - -**Frontend Behavior:** -- Similar to SSH (colored arrow icon) -- Listed under "Local" section in modal -- No authentication prompts -- File paths: `wsl://Ubuntu:~/file.txt` - -**Backend Differences:** -- Uses `wsl.exe` instead of SSH -- No network overhead -- Predetermined domain socket path -- Simpler error handling - -### S3 Connection (Preview Only) - -**Connection Names:** -- Format: `"aws:[profile]"` -- Examples: `"aws:default"`, `"aws:production"` - -**Frontend Behavior:** -- Database icon (accent color) -- Only available in Preview view -- No shell/terminal support -- File paths: `aws:profile:/bucket/key` - -**View Model Settings:** -```typescript -// Terminal: S3 not shown -showS3 = atom(false) - -// Preview: S3 shown -showS3 = atom(true) -``` - -## Error Handling - -### Connection Errors - -**Authentication Failures:** -- Backend prompts for credentials via `userinput` events -- Frontend shows UserInputModal -- User enters password/passphrase -- Connection retries automatically - -**Network Errors:** -- ConnStatus.status becomes "error" -- ConnStatus.error contains message -- ConnStatusOverlay displays error -- "Reconnect" button triggers `ConnConnectCommand` - -**WSH Installation Errors:** -- ConnStatus.wsherror contains message -- ConnStatusOverlay shows separate WSH error section -- Options: - - Dismiss error (temporary) - - "always disable wsh" (permanent config change) - -### View Model Error Handling - -**Terminal View:** -```typescript -// Shell won't start if connection failed -endIconButtons = atom((get) => { - const connStatus = get(this.connStatus) - if (connStatus?.status != "connected") { - return [] // Hide restart button - } - // ... show restart button -}) - -// ConnStatusOverlay blocks terminal interaction -``` - -**Preview View:** -```typescript -// File operations return errors -errorMsgAtom = atom(null) as PrimitiveAtom - -statFile = atom(async (get) => { - try { - const fileInfo = await RpcApi.FileInfoCommand(...) - return fileInfo - } catch (e) { - globalStore.set(this.errorMsgAtom, { - status: "File Read Failed", - text: `${e}` - }) - throw e - } -}) - -// Error displayed in preview content area -``` - -## Best Practices - -### For View Model Authors - -1. **Use Connection Atoms:** - ```typescript - connStatus = atom((get) => { - const blockData = get(this.blockAtom) - const connName = blockData?.meta?.connection - return get(getConnStatusAtom(connName)) - }) - ``` - -2. **Check Connection Before Operations:** - ```typescript - if (connStatus?.status != "connected") { - return // Don't attempt operation - } - ``` - -3. **Use ConnEnsureCommand for File Ops:** - ```typescript - await RpcApi.ConnEnsureCommand(TabRpcClient, { - connname: connName, - logblockid: blockId // For better logging - }, { timeout: 60000 }) - ``` - -4. **Set manageConnection Appropriately:** - ```typescript - // Show connection button for views that need connections - manageConnection = atom(true) - - // Hide for views that don't use connections - manageConnection = atom(false) - ``` - -5. **Use filterOutNowsh for WSH Requirements:** - ```typescript - // Filter connections without WSH (file ops, etc.) - filterOutNowsh = atom(true) - - // Allow all connections (basic shell) - filterOutNowsh = atom(false) - ``` - -### For RPC Command Usage - -1. **Always Handle Errors:** - ```typescript - try { - await RpcApi.ConnConnectCommand(...) - } catch (e) { - console.error("Connection failed:", e) - // Update UI to show error - } - ``` - -2. **Use Appropriate Timeouts:** - ```typescript - // Connection operations: longer timeout - { timeout: 60000 } // 60 seconds - - // List operations: shorter timeout - { timeout: 2000 } // 2 seconds - ``` - -3. **Batch Related Operations:** - ```typescript - // Good: Single SetMetaCommand with all changes - await RpcApi.SetMetaCommand(TabRpcClient, { - oref: blockRef, - meta: { - connection: newConn, - file: newPath, - "cmd:cwd": null - } - }) - - // Bad: Multiple SetMetaCommand calls - ``` - -## Summary - -The frontend connection architecture is **reactive and declarative**: - -1. **Backend owns connection state** - All connection management happens in Go -2. **Frontend observes state** - Jotai atoms mirror backend state -3. **User actions trigger backend** - RPC commands initiate backend operations -4. **Events flow back to frontend** - Backend pushes updates via wave events -5. **View models isolate concerns** - Each view manages its own connection needs -6. **Block controllers bridge the gap** - Backend controllers use connections for process execution - -This architecture ensures: -- **Consistency** - Single source of truth (backend) -- **Reactivity** - UI updates automatically with state changes -- **Separation** - Frontend doesn't manage connection lifecycle -- **Flexibility** - Views can easily add connection support -- **Robustness** - Errors handled at appropriate layers \ No newline at end of file diff --git a/aiprompts/focus-layout.md b/aiprompts/focus-layout.md deleted file mode 100644 index 7056b5ad3e..0000000000 --- a/aiprompts/focus-layout.md +++ /dev/null @@ -1,174 +0,0 @@ -# Wave Terminal Focus System - Layout State Flow - -This document explains how focus state changes in the layout system propagate through the application to update both the visual focus ring and physical DOM focus. - -## Overview - -When layout operations modify focus state, a straightforward chain of updates occurs: -1. **Visual feedback** - The focus ring updates immediately -2. **Physical DOM focus** - The terminal (or other view) receives actual browser focus - -The system uses local atoms as the source of truth with async persistence to the backend. - -## The Flow - -### 1. Setting Focus in Layout Operations - -Throughout [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts), operations directly mutate `layoutState.focusedNodeId`: - -```typescript -// Example from insertNode -if (action.magnified) { - layoutState.magnifiedNodeId = action.node.id; - layoutState.focusedNodeId = action.node.id; -} -if (action.focused) { - layoutState.focusedNodeId = action.node.id; -} -``` - -This happens in ~10 places: insertNode, insertNodeAtIndex, deleteNode, focusNode, magnifyNodeToggle, etc. - -### 2. Committing to Local Atom - -The [`LayoutModel.treeReducer()`](../frontend/layout/lib/layoutModel.ts:547) commits changes: - -```typescript -treeReducer(action: LayoutTreeAction, setState = true): boolean { - // Mutate tree state - focusNode(this.treeState, action); - - if (setState) { - this.updateTree(); // Compute leafOrder, etc. - this.setter(this.localTreeStateAtom, { ...this.treeState }); // Sync update - this.persistToBackend(); // Async persistence - } -} -``` - -The key is `{ ...this.treeState }` creates a new object reference, triggering Jotai reactivity. - -### 3. Derived Atoms Recalculate - -Each block's `NodeModel` has an `isFocused` atom: - -```typescript -isFocused: atom((get) => { - const treeState = get(this.localTreeStateAtom); - const isFocused = treeState.focusedNodeId === nodeid; - const waveAIFocused = get(atoms.waveAIFocusedAtom); - return isFocused && !waveAIFocused; -}) -``` - -When `localTreeStateAtom` updates, all `isFocused` atoms recalculate. Only the matching node returns `true`. - -### 4. React Components Re-render - -**Visual Focus Ring** - Components subscribe to `isFocused`: - -```typescript -const isFocused = useAtomValue(nodeModel.isFocused); -``` - -CSS classes update immediately, showing the focus ring. - -**Physical DOM Focus** - Two-step effect chain: - -```typescript -// Step 1: isFocused → blockClicked -useLayoutEffect(() => { - setBlockClicked(isFocused); -}, [isFocused]); - -// Step 2: blockClicked → physical focus -useLayoutEffect(() => { - if (!blockClicked) return; - setBlockClicked(false); - const focusWithin = focusedBlockId() == nodeModel.blockId; - if (!focusWithin) { - setFocusTarget(); // Calls viewModel.giveFocus() - } -}, [blockClicked, isFocused]); -``` - -The terminal's `giveFocus()` method grants actual browser focus: - -```typescript -giveFocus(): boolean { - if (termMode == "term" && this.termRef?.current?.terminal) { - this.termRef.current.terminal.focus(); - return true; - } - return false; -} -``` - -### 5. Background Persistence - -While the UI updates synchronously, persistence happens asynchronously: - -```typescript -private persistToBackend() { - // Debounced (100ms) to avoid excessive writes - setTimeout(() => { - waveObj.rootnode = this.treeState.rootNode; - waveObj.focusednodeid = this.treeState.focusedNodeId; - waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; - waveObj.leaforder = this.treeState.leafOrder; - this.setter(this.waveObjectAtom, waveObj); - }, 100); -} -``` - -The WaveObject is used purely for persistence (tab restore, uncaching). - -## The Complete Chain - -``` -User action - ↓ -layoutState.focusedNodeId = nodeId - ↓ -setter(localTreeStateAtom, { ...treeState }) - ↓ -isFocused atoms recalculate - ↓ -React re-renders - ↓ -┌────────────────────â”Ŧ────────────────────┐ -│ Visual Ring │ Physical Focus │ -│ (immediate CSS) │ (2-step effect) │ -└────────────────────┴────────────────────┘ - ↓ -persistToBackend() (async, debounced) -``` - -## Key Points - -1. **Local atoms** - `localTreeStateAtom` is the source of truth during runtime -2. **Synchronous updates** - UI changes happen immediately in one React tick -3. **Async persistence** - Backend writes are fire-and-forget with debouncing -4. **Two-step focus** - Separates visual (instant) from physical (coordinated) DOM focus -5. **View delegation** - Each view implements `giveFocus()` for custom focus behavior - -## User-Initiated Focus - -When a user clicks a block: - -1. **`onFocusCapture`** (mousedown) → calls `nodeModel.focusNode()` → visual focus ring appears -2. **`onClick`** → sets `blockClicked = true` → two-step effect chain → physical DOM focus - -This ensures visual feedback is instant while protecting selections. - -## Backend Actions - -On initialization or backend updates, queued actions are processed: - -```typescript -if (initialState.pendingBackendActions?.length) { - fireAndForget(() => this.processPendingBackendActions()); -} -``` - -Backend can queue layout operations (create blocks, etc.) via `PendingBackendActions`. \ No newline at end of file diff --git a/aiprompts/focus.md b/aiprompts/focus.md deleted file mode 100644 index e07f674da6..0000000000 --- a/aiprompts/focus.md +++ /dev/null @@ -1,236 +0,0 @@ -# Wave Terminal Focus System - -This document explains how the focus system works in Wave Terminal, particularly for terminal blocks. - -## Overview - -Wave Terminal uses a multi-layered focus system that coordinates between: -- **Layout Focus State**: Jotai atoms tracking which block is focused (`nodeModel.isFocused`) -- **Visual Focus Ring**: CSS styling showing the focused block -- **DOM Focus**: Actual browser focus on interactive elements -- **View-Specific Focus**: Custom focus handling by view models (e.g., XTerm terminal focus) - -## Focus Flow on Block Click - -When you click on a terminal block, this sequence occurs: - -### 1. Click Handler Setup -[`frontend/app/block/block.tsx:219-223`](frontend/app/block/block.tsx:219-223) - -```typescript -const blockModel: BlockComponentModel2 = { - onClick: setBlockClickedTrue, - onFocusCapture: handleChildFocus, - blockRef: blockRef, -}; -``` - -### 2. Click Triggers State Change -[`frontend/app/block/block.tsx:165-167`](frontend/app/block/block.tsx:165-167) - -When clicked, `setBlockClickedTrue` sets the `blockClicked` state to true. - -### 3. useLayoutEffect Responds -[`frontend/app/block/block.tsx:151-163`](frontend/app/block/block.tsx:151-163) - -```typescript -useLayoutEffect(() => { - if (!blockClicked) { - return; - } - setBlockClicked(false); - const focusWithin = focusedBlockId() == nodeModel.blockId; - if (!focusWithin) { - setFocusTarget(); - } - if (!isFocused) { - nodeModel.focusNode(); - } -}, [blockClicked, isFocused]); -``` - -### 4. Focus Target Decision -[`frontend/app/block/block.tsx:211-217`](frontend/app/block/block.tsx:211-217) - -```typescript -const setFocusTarget = useCallback(() => { - const ok = viewModel?.giveFocus?.(); - if (ok) { - return; - } - focusElemRef.current?.focus({ preventScroll: true }); -}, []); -``` - -The `setFocusTarget` function: -1. First attempts to call the view model's `giveFocus()` method -2. If that succeeds (returns true), we're done -3. Otherwise, falls back to focusing a dummy input element - -### 5. Terminal-Specific Focus -[`frontend/app/view/term/term.tsx:414-427`](frontend/app/view/term/term.tsx:414-427) - -```typescript -giveFocus(): boolean { - if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) { - return true; - } - let termMode = globalStore.get(this.termMode); - if (termMode == "term") { - if (this.termRef?.current?.terminal) { - this.termRef.current.terminal.focus(); - return true; - } - } - return false; -} -``` - -The terminal's `giveFocus()` calls XTerm's `terminal.focus()` to grant actual DOM focus. - -## Selection Protection - -A critical feature is that text selections are preserved when clicking within the same block. - -### The Protection Mechanism -[`frontend/app/block/block.tsx:156-158`](frontend/app/block/block.tsx:156-158) - -```typescript -const focusWithin = focusedBlockId() == nodeModel.blockId; -if (!focusWithin) { - setFocusTarget(); -} -``` - -The key is [`focusedBlockId()`](frontend/util/focusutil.ts:48-70) which checks: - -1. **Active Element**: Is there a focused DOM element within this block? -2. **Selection**: Is there a text selection within this block? - -```typescript -export function focusedBlockId(): string { - const focused = document.activeElement; - if (focused instanceof HTMLElement) { - const blockId = findBlockId(focused); - if (blockId) { - return blockId; - } - } - const sel = document.getSelection(); - if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) { - let anchor = sel.anchorNode; - if (anchor instanceof Text) { - anchor = anchor.parentElement; - } - if (anchor instanceof HTMLElement) { - const blockId = findBlockId(anchor); - if (blockId) { - return blockId; - } - } - } - return null; -} -``` - -**When making a text selection within a block:** -- `focusWithin` returns true (selection exists in the block) -- `setFocusTarget()` is **skipped** -- Selection is preserved -- Only `nodeModel.focusNode()` is called to update layout state - -## Visual Focus vs DOM Focus - -There's an important separation between visual focus (the focus ring) and actual DOM focus. - -### Visual Focus (Immediate) -[`frontend/app/block/block.tsx:200-209`](frontend/app/block/block.tsx:200-209) - -```typescript -const handleChildFocus = useCallback( - (event: React.FocusEvent) => { - if (!isFocused) { - nodeModel.focusNode(); // Updates layout state immediately - } - }, - [isFocused] -); -``` - -This `onFocusCapture` handler fires on **mousedown** (capture phase), immediately updating the visual focus ring. - -### DOM Focus (On Click Complete) - -The actual DOM focus via `giveFocus()` only happens after click completion, through the onClick → useLayoutEffect path. - -### Selection Example: Two Terminals - -When making a selection in terminal 2 while terminal 1 is focused: - -1. **Mousedown** → `onFocusCapture` fires → `nodeModel.focusNode()` updates focus ring - - Terminal 2 now shows the focus ring - - Layout state updated -2. **Drag** → Selection is made in terminal 2 -3. **Mouseup** → Selection completes -4. **Click handler** → `onClick` fires → `setBlockClickedTrue` → triggers useLayoutEffect -5. **useLayoutEffect** → Checks `focusWithin` (now true because selection exists) -6. **Protected** → Skips `setFocusTarget()`, preserving the selection - -**Result:** Focus ring updates immediately, but DOM focus is only granted after the selection is made, and is protected by the `focusWithin` check. - -## Terminal-Specific Focus Events - -The terminal view has three useEffects that call `giveFocus()`: - -### 1. Search Close -[`frontend/app/view/term/term.tsx:970-974`](frontend/app/view/term/term.tsx:970-974) - -When the search panel closes, focus returns to the terminal. - -### 2. Terminal Recreation -[`frontend/app/view/term/term.tsx:1035-1038`](frontend/app/view/term/term.tsx:1035-1038) - -When a terminal is recreated while focused (e.g., settings change), focus is restored. - -### 3. Mode Switch -[`frontend/app/view/term/term.tsx:1046-1052`](frontend/app/view/term/term.tsx:1046-1052) - -When switching from vdom mode back to term mode, the terminal receives focus. - -## Key Components - -### Block Component -[`frontend/app/block/block.tsx`](frontend/app/block/block.tsx) -- Manages the BlockFull component -- Handles click and focus capture events -- Coordinates between layout focus and DOM focus - -### BlockNodeModel -[`frontend/app/block/blocktypes.ts:7-12`](frontend/app/block/blocktypes.ts:7-12) -```typescript -export interface BlockNodeModel { - blockId: string; - isFocused: Atom; - onClose: () => void; - focusNode: () => void; -} -``` - -### ViewModel Interface -View models can implement `giveFocus(): boolean` to handle focus in a view-specific way. - -### Focus Utilities -[`frontend/util/focusutil.ts`](frontend/util/focusutil.ts) -- `focusedBlockId()`: Determines which block has focus or selection -- `hasSelection()`: Checks if there's an active text selection -- `findBlockId()`: Traverses DOM to find containing block - -## Summary - -The focus system elegantly separates concerns: -- **Visual feedback** updates immediately on mousedown -- **DOM focus** is deferred until after user interaction completes -- **Selections are protected** by checking focus state before granting focus -- **View-specific focus** is delegated to view models via `giveFocus()` - -This design allows for responsive UI (immediate focus ring updates) while preventing disruption of user interactions like text selection. \ No newline at end of file diff --git a/aiprompts/getsetconfigvar.md b/aiprompts/getsetconfigvar.md deleted file mode 100644 index c2ac244117..0000000000 --- a/aiprompts/getsetconfigvar.md +++ /dev/null @@ -1,50 +0,0 @@ -# Setting and Reading Config Variables - -This document provides a quick reference for updating and reading configuration values in our system. - ---- - -## Setting a Config Variable - -To update a configuration, use the `RpcApi.SetConfigCommand` function. The command takes an object with a key/value pair where the key is the config variable and the value is the new setting. - -**Example:** - -```ts -await RpcApi.SetConfigCommand(TabRpcClient, { "web:defaulturl": url }); -``` - -In this example, `"web:defaulturl"` is the key and `url` is the new value. Use this approach for any config key. - ---- - -## Reading a Config Value - -To read a configuration value, retrieve the corresponding atom using `getSettingsKeyAtom` and then use `globalStore.get` to access its current value. getSettingsKeyAtom returns a jotai Atom. - -**Example:** - -```ts -const configAtom = getSettingsKeyAtom("app:defaultnewblock"); -const configValue = globalStore.get(configAtom) ?? "default value"; -``` - -Here, `"app:defaultnewblock"` is the config key and `"default value"` serves as a fallback if the key isn't set. - -Inside of a react componet we should not use globalStore, instead we use useSettingsKeyAtom (this is just a jotai useAtomValue call wrapped around the getSettingsKeyAtom call) - -```tsx -const configValue = useSettingsKeyAtom("app:defaultnewblock") ?? "default value"; -``` - ---- - -## Relevant Imports - -```ts -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { getSettingsKeyAtom, useSettingsKeyAtom, globalStore } from "@/app/store/global"; -``` - -Keep this guide handy for a quick reference when working with configuration values. diff --git a/aiprompts/layout-simplification.md b/aiprompts/layout-simplification.md deleted file mode 100644 index 785bfb1cca..0000000000 --- a/aiprompts/layout-simplification.md +++ /dev/null @@ -1,857 +0,0 @@ -# Wave Terminal Layout System - Simplification via Write Cache Pattern - -## Executive Summary - -The current layout system uses a complex bidirectional atom architecture that forces every layout change to round-trip through the backend WaveObject, even though **the backend never reads this data** - it only queues actions via `PendingBackendActions`. By switching to a "write cache" pattern where local atoms are the source of truth and backend writes are fire-and-forget, we can eliminate ~70% of the complexity while maintaining full persistence. - -## Current Architecture Problems - -### The Unnecessary Round-Trip - -Every layout change (split, close, focus, magnify) currently follows this flow: - -``` -User action - ↓ -treeReducer() mutates layoutState - ↓ -layoutState.generation++ ← Only purpose: trigger the write - ↓ -Bidirectional atom setter (checks generation) - ↓ -Write to WaveObject {rootnode, focusednodeid, magnifiednodeid} - ↓ -WaveObject update notification - ↓ -Bidirectional atom getter runs - ↓ -ALL dependent atoms recalculate (every isFocused, etc.) - ↓ -React re-renders with updated state -``` - -**The critical insight**: The backend reads ONLY `leaforder` from the WaveObject (for block number resolution in commands like `wsh block:1`). The `rootnode`, `focusednodeid`, and `magnifiednodeid` fields exist **only for persistence** (tab restore, uncaching). - -### What the Backend Actually Does - -**Backend Reads** (from [`pkg/wshrpc/wshserver/resolvers.go`](../pkg/wshrpc/wshserver/resolvers.go:196-206)): -- **`LeafOrder`** - Used to resolve block numbers in commands (e.g., `wsh block:1` → blockId lookup) - -**Backend Writes** (from [`pkg/wcore/layout.go`](../pkg/wcore/layout.go)): -- **`PendingBackendActions`** - Queued layout actions via [`QueueLayoutAction()`](../pkg/wcore/layout.go:101-118) - -**Backend NEVER touches**: -- **`RootNode`** - Never read, only written by frontend for persistence -- **`FocusedNodeId`** - Never read, only written by frontend for persistence -- **`MagnifiedNodeId`** - Never read, only written by frontend for persistence - -**The key insight**: Only `LeafOrder` needs to be synced to backend (for command resolution). The tree structure fields (`rootnode`, `focusednodeid`, `magnifiednodeid`) are pure persistence! - -### Complexity Symptoms - -1. **Generation tracking**: [`layoutState.generation++`](../frontend/layout/lib/layoutTree.ts:294) appears in 10+ places, only to trigger atom writes -2. **Bidirectional atoms**: [`withLayoutTreeStateAtomFromTab()`](../frontend/layout/lib/layoutAtom.ts:18-60) has complex read/write logic -3. **Timing coordination**: The entire Section 8 of the WaveAI focus proposal exists only because of race conditions between focus updates and atom commits -4. **False reactivity**: Changes to `focusedNodeId` trigger full tree state propagation even though they're unrelated to tree structure - -## Proposed "Write Cache" Architecture - -### Core Concept - -``` -User action - ↓ -Update LOCAL atom (immediate, synchronous) - ↓ -React re-renders (single tick, all atoms see new state) - ↓ -[async, fire-and-forget] Persist to WaveObject -``` - -### Key Principles - -1. **Local atoms are source of truth** during runtime -2. **WaveObject is persistence layer** only (read on init, write async) -3. **Backend actions still work** via `PendingBackendActions` -4. **No generation tracking needed** (no need to trigger writes) - -## Implementation Design - -### 1. New LayoutModel Structure - -```typescript -// frontend/layout/lib/layoutModel.ts - -class LayoutModel { - // BEFORE: Bidirectional atom with generation tracking - // treeStateAtom: WritableLayoutTreeStateAtom - - // AFTER: Simple local atom (source of truth) - private localTreeStateAtom: PrimitiveAtom; - - // Keep reference to WaveObject atom for persistence - private waveObjectAtom: WritableWaveObjectAtom; - - constructor(tabAtom: Atom, ...) { - this.waveObjectAtom = getLayoutStateAtomFromTab(tabAtom); - - // Initialize local atom (starts empty) - this.localTreeStateAtom = atom({ - rootNode: undefined, - focusedNodeId: undefined, - magnifiedNodeId: undefined, - leafOrder: undefined, - pendingBackendActions: undefined, - generation: 0 // Can be removed entirely or kept for debugging - }); - - // Read from WaveObject ONCE during initialization - this.initializeFromWaveObject(); - } - - private async initializeFromWaveObject() { - const waveObjState = this.getter(this.waveObjectAtom); - - // Load persisted state into local atom - const initialState: LayoutTreeState = { - rootNode: waveObjState?.rootnode, - focusedNodeId: waveObjState?.focusednodeid, - magnifiedNodeId: waveObjState?.magnifiednodeid, - leafOrder: undefined, // Computed by updateTree() - pendingBackendActions: waveObjState?.pendingbackendactions, - generation: 0 - }; - - // Set local state - this.treeState = initialState; - this.setter(this.localTreeStateAtom, initialState); - - // Process any pending backend actions - if (initialState.pendingBackendActions?.length) { - await this.processPendingBackendActions(); - } - - // Initialize tree (compute leafOrder, etc.) - this.updateTree(); - } - - // Process backend-queued actions (startup only) - private async processPendingBackendActions() { - const actions = this.treeState.pendingBackendActions; - if (!actions?.length) return; - - this.treeState.pendingBackendActions = undefined; - - for (const action of actions) { - // Convert backend action to frontend action and run through treeReducer - // This code already exists in onTreeStateAtomUpdated() - switch (action.actiontype) { - case LayoutTreeActionType.InsertNode: - this.treeReducer({ - type: LayoutTreeActionType.InsertNode, - node: newLayoutNode(undefined, undefined, undefined, { - blockId: action.blockid - }), - magnified: action.magnified, - focused: action.focused - }, false); - break; - // ... other action types - } - } - } -} -``` - -### 2. Simplified treeReducer - -```typescript -class LayoutModel { - treeReducer(action: LayoutTreeAction, setState = true): boolean { - // Run the tree operation (mutates this.treeState) - switch (action.type) { - case LayoutTreeActionType.InsertNode: - insertNode(this.treeState, action); - break; - case LayoutTreeActionType.FocusNode: - focusNode(this.treeState, action); - break; - case LayoutTreeActionType.DeleteNode: - deleteNode(this.treeState, action); - break; - // ... all other cases unchanged - } - - if (setState) { - // Update tree (compute leafOrder, validate, etc.) - this.updateTree(); - - // Update local atom IMMEDIATELY (synchronous) - this.setter(this.localTreeStateAtom, { ...this.treeState }); - - // Persist to backend asynchronously (fire and forget) - this.persistToBackend(); - } - - return true; - } - - // Fire-and-forget persistence - private async persistToBackend() { - const waveObj = this.getter(this.waveObjectAtom); - if (!waveObj) return; - - // Update WaveObject fields - waveObj.rootnode = this.treeState.rootNode; // Persistence only - waveObj.focusednodeid = this.treeState.focusedNodeId; // Persistence only - waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; // Persistence only - waveObj.leaforder = this.treeState.leafOrder; // Backend reads this for command resolution! - - // Write to backend (don't await - fire and forget) - this.setter(this.waveObjectAtom, waveObj); - - // Optional: Debounce if rapid changes are a concern - } -} -``` - -### 3. Simplified NodeModel isFocused - -```typescript -class LayoutModel { - getNodeModel(node: LayoutNode): NodeModel { - return { - // BEFORE: Complex dependency on bidirectional treeStateAtom - // isFocused: atom((get) => { - // const treeState = get(this.treeStateAtom); // Triggers on any tree change - // ... - // }) - - // AFTER: Simple dependency on local atom - isFocused: atom((get) => { - const treeState = get(this.localTreeStateAtom); // Simple read - const focusType = get(focusManager.focusType); - return treeState.focusedNodeId === node.id && focusType === "node"; - }), - - // All other atoms similarly simplified... - isMagnified: atom((get) => { - const treeState = get(this.localTreeStateAtom); - return treeState.magnifiedNodeId === node.id; - }), - - // ... rest unchanged - }; - } -} -``` - -### 4. Remove Generation Tracking - -The `generation` field can be removed entirely from [`LayoutTreeState`](../frontend/layout/lib/types.ts): - -```typescript -// frontend/layout/lib/types.ts - -export interface LayoutTreeState { - rootNode?: LayoutNode; - focusedNodeId?: string; - magnifiedNodeId?: string; - leafOrder?: LayoutLeafEntry[]; - pendingBackendActions?: LayoutActionData[]; - // generation: number; ← DELETE THIS -} -``` - -And remove all `generation++` calls from [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts) (appears in 10+ places). - -### 5. Simplified layoutAtom.ts - -```typescript -// frontend/layout/lib/layoutAtom.ts - -// BEFORE: Complex bidirectional atom (60 lines) -// AFTER: Can be deleted entirely or simplified to just helper for WaveObject access - -export function getLayoutStateAtomFromTab( - tabAtom: Atom, - get: Getter -): WritableWaveObjectAtom { - const tabData = get(tabAtom); - if (!tabData) return; - const layoutStateOref = WOS.makeORef("layout", tabData.layoutstate); - return WOS.getWaveObjectAtom(layoutStateOref); -} - -// No more withLayoutTreeStateAtomFromTab() - not needed! -``` - -## Benefits - -### Immediate Benefits - -1. **10x simpler reactivity**: Local atoms update synchronously, React sees complete state in one tick -2. **No generation tracking**: Eliminate 10+ `generation++` calls and all related logic -3. **No timing issues**: Everything happens synchronously, no coordination needed -4. **Faster updates**: No round-trip through WaveObject for every change -5. **Easier debugging**: Clear separation between runtime state (local atoms) and persistence (WaveObject) - -### Impact on WaveAI Focus Proposal - -The entire Section 8 ("Layout Model Focus Integration - CRITICAL TIMING") **becomes unnecessary**: - -**BEFORE** (complex timing coordination): -```typescript -treeReducer(action: LayoutTreeAction) { - insertNode(this.treeState, action); // generation++ - - // CRITICAL: Must update focus manager BEFORE atom commits - if (action.focused) { - focusManager.requestNodeFocus(); // Synchronous! - } - - // Then atom commits - this.setter(this.treeStateAtom, ...); - // Now isFocused sees correct focusType -} -``` - -**AFTER** (trivial): -```typescript -treeReducer(action: LayoutTreeAction) { - insertNode(this.treeState, action); // Just mutates local state - - // Update local atom (synchronous) - this.setter(this.localTreeStateAtom, { ...this.treeState }); - - // Update focus manager (order doesn't matter - both updated synchronously) - if (action.focused) { - focusManager.setBlockFocus(); - } - - // Both updates happen in same tick, no race condition possible! -} -``` - -### Code Deletion - -**Can delete**: -- `generation` field and all `generation++` calls (~15 places) -- Complex bidirectional atom logic in [`layoutAtom.ts`](../frontend/layout/lib/layoutAtom.ts) (~40 lines) -- `lastTreeStateGeneration` tracking in [`LayoutModel`](../frontend/layout/lib/layoutModel.ts) -- All `generation > this.treeState.generation` checks - -**Total**: ~200-300 lines of complex coordination code deleted - -## Edge Cases & Considerations - -### 1. Rapid Changes - -**Concern**: Many layout changes in quick succession could cause many backend writes. - -**Solution**: Debounce the `persistToBackend()` call (e.g., 100ms). Users won't notice the delay in persistence. - -```typescript -private persistDebounceTimer: NodeJS.Timeout | null = null; - -private persistToBackend() { - if (this.persistDebounceTimer) { - clearTimeout(this.persistDebounceTimer); - } - - this.persistDebounceTimer = setTimeout(() => { - const waveObj = this.getter(this.waveObjectAtom); - if (!waveObj) return; - - waveObj.rootnode = this.treeState.rootNode; - waveObj.focusednodeid = this.treeState.focusedNodeId; - waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; - waveObj.leaforder = this.treeState.leafOrder; - - this.setter(this.waveObjectAtom, waveObj); - this.persistDebounceTimer = null; - }, 100); -} -``` - -### 2. Tab Switching - -**Current**: Each tab has its own `treeStateAtom` in a WeakMap. - -**After**: Each tab has its own `localTreeStateAtom` in the LayoutModel instance. No change needed - already isolated per tab. - -### 3. Tab Uncaching (Electron Limit) - -**Current**: Tab gets uncached, needs to reload layout from WaveObject. - -**After**: Same - `initializeFromWaveObject()` reads persisted state. No change in behavior. - -### 4. Backend Actions (New Blocks) -### 5. LeafOrder and CLI Commands - -**Concern**: The backend reads `LeafOrder` for CLI command resolution (e.g., `wsh block:1`). What if it's not synced yet? - -**Solution**: Fire-and-forget is perfectly fine! CLI commands aren't time-sensitive: -- Commands are typed/run by users (human speed, not machine speed) -- Even if `LeafOrder` is 100ms behind, no one will notice -- By the time a user types `wsh block:1`, the async write has long since completed -- Worst case: User types command during a split operation and gets previous block - extremely rare and not breaking - - -## Immutability and Jotai Atoms - -### Question: Do we need deep copies for Jotai to detect changes? - -**Answer: NO - shallow copy is sufficient!** ✓ - -### Current System (Already Uses Shallow Updates) - -Looking at the current code in [`layoutModel.ts:587`](../frontend/layout/lib/layoutModel.ts:587): - -```typescript -setTreeStateAtom(bumpGeneration = false) { - if (bumpGeneration) { - this.treeState.generation++; - } - this.lastTreeStateGeneration = this.treeState.generation; - this.setter(this.treeStateAtom, this.treeState); // ← Sets same object! -} -``` - -**The current system doesn't create new objects either!** It relies on `generation` changing to trigger the bidirectional atom's setter. - -### Why Shallow Copy Works with Jotai - -```typescript -// In treeReducer after mutations -this.setter(this.localTreeStateAtom, { ...this.treeState }); -``` - -**This works because**: -1. **Jotai checks reference equality** on the atom value itself (the `LayoutTreeState` object) -2. **`{ ...this.treeState }` creates a NEW object** with a different reference -3. **Nested structures don't matter** - Jotai doesn't do deep equality checks - -**Example**: -```typescript -const oldState = { rootNode: someTree, focusedNodeId: "node1" }; -const newState = { ...oldState }; - -oldState === newState // FALSE - different objects! -oldState.rootNode === newState.rootNode // TRUE - same tree reference - -// But Jotai only checks the first comparison, so it detects the change! -``` - -### Tree Mutations Don't Need Immutability - -All tree operations in [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts) **mutate in place**: -- `insertNode()` - Mutates `layoutState.rootNode` - -### Derived Atoms Will Update Correctly ✓ - -**Concern**: Will derived atoms like `isFocused` and `isMagnified` update when we change to local atoms? - -**Answer: YES - they will work perfectly!** ✓ - -### How Derived Atoms Work - -The NodeModel creates derived atoms that depend on `treeStateAtom`: - -```typescript -// From layoutModel.ts:936-946 -isFocused: atom((get) => { - const treeState = get(this.treeStateAtom); // Subscribe to treeStateAtom - const isFocused = treeState.focusedNodeId === nodeid; - const waveAIFocused = get(atoms.waveAIFocusedAtom); - return isFocused && !waveAIFocused; -}), - -isMagnified: atom((get) => { - const treeState = get(this.treeStateAtom); // Subscribe to treeStateAtom - return treeState.magnifiedNodeId === nodeid; -}), -``` - -### Why They'll Still Work with Local Atoms - -**After the change**: -```typescript -isFocused: atom((get) => { - const treeState = get(this.localTreeStateAtom); // Subscribe to localTreeStateAtom - const isFocused = treeState.focusedNodeId === nodeid; - const waveAIFocused = get(atoms.waveAIFocusedAtom); - return isFocused && !waveAIFocused; -}), -``` - -**The update flow**: -1. User clicks block → `focusNode()` called -2. `treeReducer()` runs → mutates `this.treeState.focusedNodeId = newId` -3. `this.setter(this.localTreeStateAtom, { ...this.treeState })` ← **New reference!** -4. Jotai detects reference change in `localTreeStateAtom` -5. All derived atoms that call `get(this.localTreeStateAtom)` are notified -6. They re-run their getter functions -7. They see the new `focusedNodeId` value -8. React components re-render with correct values ✓ - -### Key Insight - -**We're not mutating fields inside the atom** - we're replacing the entire state object: - -```typescript -// OLD way (current): -// 1. Mutate this.treeState.focusedNodeId = newId -// 2. Bump this.treeState.generation++ -// 3. Set bidirectional atom (checks generation, writes to WaveObject, reads back, updates) -// 4. Derived atoms see new state from the round-trip - -// NEW way (proposed): -// 1. Mutate this.treeState.focusedNodeId = newId (same!) -// 2. this.setter(localTreeStateAtom, { ...this.treeState }) (new object reference!) -// 3. Derived atoms immediately see new state (no round-trip!) -``` - -**Both approaches create a new state object that triggers Jotai's reactivity!** - -The new way is actually **MORE reliable** because: -- No round-trip delay -- No generation checking -- Direct, synchronous update -- Same Jotai reactivity mechanism - -### What About Nested Fields? - -**Question**: What if derived atoms access nested fields like `treeState.rootNode.children`? - -**Answer**: Still works! Example: - -```typescript -// Hypothetical derived atom -someAtom: atom((get) => { - const treeState = get(this.localTreeStateAtom); - return treeState.rootNode.children.length; // Nested access -}) -``` - -**This works because**: -1. We create new `LayoutTreeState` object: `{ ...this.treeState }` -2. Jotai sees new reference → notifies subscribers -3. Getter re-runs, calls `get(this.localTreeStateAtom)` -4. Gets the new state object -5. Accesses `newState.rootNode` (same reference as before, but that's OK!) -6. Returns correct value - -**The derived atom doesn't care that `rootNode` is the same object** - it just cares that the STATE object changed and it needs to re-evaluate. - -### Verification - -All derived atoms in NodeModel: -- ✅ `isFocused` - depends on `treeState.focusedNodeId` -- ✅ `isMagnified` - depends on `treeState.magnifiedNodeId` -- ✅ `blockNum` - depends on separate `this.leafOrder` atom (unaffected) -- ✅ `isEphemeral` - depends on separate `this.ephemeralNode` atom (unaffected) - -All will update correctly with the new local atom approach! - -- `deleteNode()` - Mutates parent's children array -- `focusNode()` - Mutates `layoutState.focusedNodeId` - -This is fine! We're not relying on immutability for change detection. We're relying on creating a new `LayoutTreeState` wrapper object via spread operator. - -### Backend Round-Trip - -When reading from WaveObject on initialization: -```typescript -const waveObjState = this.getter(this.waveObjectAtom); -const initialState: LayoutTreeState = { - rootNode: waveObjState?.rootnode, // New reference from backend - focusedNodeId: waveObjState?.focusednodeid, - // ... -}; -``` - -This creates a **completely new object** with new references, which is even more immutable than necessary. No issues here. - -### Summary - -✅ **We're covered** - Shallow copy via spread operator is sufficient - -✅ **Same as current system** - We're not making it worse, just simpler - -✅ **Jotai only checks reference equality** on the atom value, not deep equality - -✅ **Tree mutations are fine** - They've always worked this way - - -**Current**: Backend queues actions via [`QueueLayoutAction()`](../pkg/wcore/layout.go:101), frontend processes via `pendingBackendActions`. - -**After**: Same - `initializeFromWaveObject()` processes pending actions. No change needed. - -### 5. Write Failures - -**Concern**: What if the async write to WaveObject fails? - -**Solution**: -1. The app continues working (local state is fine) -2. On next persistence attempt, full state is written again -3. On tab reload, worst case is state from last successful write -4. Can add retry logic or error notification if needed - -## Migration Path - -### Phase 1: Preparation (No Breaking Changes) - -1. Add `localTreeStateAtom` alongside existing `treeStateAtom` -2. Keep both in sync -3. Update a few `isFocused` atoms to use local atom -4. Test thoroughly - -### Phase 2: Switch Over - -1. Update `treeReducer` to write to local atom + fire-and-forget persist -2. Update all `isFocused` and other computed atoms to use local atom -3. Remove generation checks and tracking -4. Test all layout operations - -### Phase 3: Cleanup - -1. Delete bidirectional atom logic from [`layoutAtom.ts`](../frontend/layout/lib/layoutAtom.ts) -2. Remove `generation` field from `LayoutTreeState` -3. Simplify `onTreeStateAtomUpdated()` (only needed for `pendingBackendActions`) -4. Update documentation - -### Testing Checklist - -- [ ] Split horizontal/vertical -- [ ] Close blocks (focused and unfocused) -- [ ] Focus changes via click, keyboard nav, tab switching -- [ ] Magnify/unmagnify -- [ ] Resize operations -- [ ] Drag & drop -- [ ] Tab switching (verify state persistence) -- [ ] App restart (verify state restore) -- [ ] Multiple windows -- [ ] Rapid operations (verify debouncing works) - -## Impact on Other Systems - -### Focus Manager - -**Before**: Must coordinate timing with atom commits. - -**After**: Can update `focusType` atom independently. Order doesn't matter since both updates happen synchronously. - -### Block Component - -**No change**: Blocks still subscribe to `nodeModel.isFocused`, which still reacts correctly (faster now). - -### Keyboard Navigation - -**No change**: Still calls `layoutModel.focusNode()`, which updates local state immediately. - -### Terminal/Views - -**No change**: Views don't interact with layout atoms directly. - -## Performance Implications - -### Improved - -1. **Faster reactivity**: No round-trip through WaveObject (save ~1-2ms per operation) -2. **Fewer atom updates**: Only local atom updates, not bidirectional propagation -3. **Batched writes**: Debouncing reduces backend write frequency - -### No Change - -1. **Tree operations**: Same complexity (balance, walk, compute, etc.) -2. **React rendering**: Same render triggers, just faster -3. **Memory usage**: Same (local atom vs bidirectional atom is similar size) - -## Conclusion - -The "write cache" pattern can simplify the layout system by ~70% while maintaining full functionality: - -- **Remove**: Generation tracking, bidirectional atoms, timing coordination -- **Keep**: All tree logic, backend integration, persistence -- **Gain**: Simpler code, faster updates, easier debugging - -This also makes the WaveAI focus integration trivial, eliminating the need for complex timing coordination. - -## Recommendation - -Implement this simplification **before** adding WaveAI focus features. The cleaner foundation will make the focus work much easier and the codebase more maintainable long-term. -# Wave Terminal Layout System - Simplification via Write Cache Pattern - -## Risk Assessment: LOW RISK, Well-Contained Change - -### Files to Modify: **4-5 files, all in `frontend/layout/`** - -1. **`frontend/layout/lib/layoutModel.ts`** (~150 lines changed) - - Add `localTreeStateAtom` field - - Modify `treeReducer()` to update local atom + persist async - - Add `initializeFromWaveObject()` method - - Add `persistToBackend()` method - - Update `getNodeModel()` atoms to use local atom - -2. **`frontend/layout/lib/layoutTree.ts`** (~15 line deletions) - - Remove all `layoutState.generation++` calls (appears 15 times) - - No other changes needed - -3. **`frontend/layout/lib/layoutAtom.ts`** (~40 lines deleted or simplified) - - Can delete most of the bidirectional atom logic - - Keep only `getLayoutStateAtomFromTab()` helper - -4. **`frontend/layout/lib/types.ts`** (~1 line deletion) - - Remove `generation: number` from `LayoutTreeState` - -5. **`frontend/layout/tests/model.ts`** (~1 line change) - - Remove generation from test fixtures - -**Total**: ~5 files, all within `frontend/layout/` directory. **No changes outside layout system!** - -### Why This is Low Risk - -#### 1. **Fail-Fast Behavior** ✓ -If we break something, it will be **immediately obvious**: -- Split horizontal/vertical won't work → visible immediately -- Block focus won't work → obvious when clicking -- Close block won't work → obvious -- Magnify won't work → obvious - -**No subtle corruption**: This change affects reactive state flow, not data persistence. If it breaks, the UI breaks obviously. We won't get "sometimes it works, sometimes it doesn't." - -#### 2. **Well-Contained Scope** ✓ -- **All changes in one directory**: `frontend/layout/` -- **No changes to**: - - Block components (unchanged) - - Terminal/views (unchanged) - - Keyboard navigation (unchanged) - - Focus manager (unchanged) - - Backend Go code (unchanged) - -The **interface** to the layout system stays the same: -- Blocks still call `nodeModel.focusNode()` -- Blocks still subscribe to `nodeModel.isFocused` -- Keyboard nav still calls `layoutModel.focusNode()` -- Nothing outside the layout system needs to know about the change - -#### 3. **No Data Corruption Risk** ✓ -This change affects **reactive state propagation**, not data storage: -- WaveObject still stores the same data -- Backend still queues actions the same way -- Blocks still have the same IDs -- Tab structure unchanged - -**Worst case**: Layout stops working, we revert the code. No data loss, no corruption. - -#### 4. **Incremental Implementation Possible** ✓ - -Can be done in safe phases: - -**Phase 1**: Add alongside existing (no breaking changes) -```typescript -class LayoutModel { - treeStateAtom: WritableLayoutTreeStateAtom; // Keep old - localTreeStateAtom: PrimitiveAtom; // Add new - - // Keep both in sync temporarily -} -``` - -**Phase 2**: Switch consumers one at a time -```typescript -// Change this gradually -isFocused: atom((get) => { - // const treeState = get(this.treeStateAtom); // Old - const treeState = get(this.localTreeStateAtom); // New - ... -}) -``` - -**Phase 3**: Remove old code once everything uses new atoms - -**Can test thoroughly at each phase before proceeding!** - -#### 5. **Easy to Test** ✓ - -Every layout operation is user-visible and testable: -- [ ] Split horizontal → obvious if broken -- [ ] Split vertical → obvious if broken -- [ ] Close block → obvious if broken -- [ ] Focus block → obvious if broken -- [ ] Magnify/unmagnify → obvious if broken -- [ ] Drag & drop → obvious if broken -- [ ] Tab switch → obvious if broken -- [ ] App restart → obvious if broken - -No subtle edge cases to hunt down. If it works in manual testing, it works. - -### Comparison to High-Risk Changes - -**This change is NOT**: -- ❌ Touching 20+ files across the codebase -- ❌ Changing subtle timing in async operations -- ❌ Modifying data storage formats -- ❌ Affecting backend/frontend protocol -- ❌ Requiring coordinated backend changes -- ❌ Creating subtle race conditions - -**This change IS**: -- ✅ Contained to 5 files in one directory -- ✅ Synchronous state updates (simpler than current!) -- ✅ Same data format, just different flow -- ✅ Frontend-only -- ✅ Backend unchanged -- ✅ Eliminating race conditions (not creating them) - -### What Could Go Wrong? (And How We'd Know) - -| Potential Issue | How We'd Detect | Recovery | -|-----------------|-----------------|----------| -| Local atom doesn't update | Layout frozen, nothing responds | Immediately obvious, revert | -| Persistence fails silently | State doesn't survive restart | Caught in testing, add logging | -| isFocused calculation wrong | Wrong focus ring | Immediately obvious, fix calculation | -| Missing generation++ somewhere | Old code path tries to use generation | Compile error or immediate runtime error | -| Tab switching breaks | Tabs don't load correctly | Immediately obvious | - -**All failure modes are immediate and obvious!** - -### Difficulty Assessment - -**Conceptual Difficulty**: LOW -- Replace bidirectional atom with simple atom -- Add async persist function -- Remove generation tracking -- Very straightforward refactor - -**Code Difficulty**: LOW-MEDIUM -- Changes are localized and mechanical -- Most changes are deletions (always good!) -- New code is simpler than old code -- No complex algorithms to implement - -**Testing Difficulty**: LOW -- All functionality is user-visible -- No need for complex test scenarios -- Manual testing catches everything -- Can test incrementally - -### Recommendation - -This is a **low-risk, high-reward change**: -- **Risk**: LOW (contained, fail-fast, no corruption) -- **Difficulty**: LOW-MEDIUM (straightforward refactor) -- **Reward**: HIGH (70% less complexity, easier future work) - -**Suggested approach**: -1. Implement in a feature branch -2. Add local atom alongside existing system -3. Test thoroughly with both systems running -4. Switch over gradually -5. Remove old code -6. Merge when confident - -Total implementation time: **1-2 days for experienced developer**, including thorough testing. - ---- diff --git a/aiprompts/layout.md b/aiprompts/layout.md deleted file mode 100644 index 0c1a8fe7e3..0000000000 --- a/aiprompts/layout.md +++ /dev/null @@ -1,413 +0,0 @@ -# Wave Terminal Layout System Architecture - -The Wave Terminal layout system is a sophisticated tile-based layout engine built with React, TypeScript, and Jotai state management. It provides a flexible, drag-and-drop interface for arranging terminal blocks and other content in complex layouts. - -## Overview - -The layout system manages a tree of `LayoutNode` objects that represent the hierarchical structure of content. Each node can either be: -- **Leaf node**: Contains actual content (block data) -- **Container node**: Contains child nodes with a specific flex direction - -The system uses CSS Flexbox for positioning but maintains its own tree structure for state management, drag-and-drop operations, and complex layout manipulations. - -## Core Architecture - -### File Structure - -``` -frontend/layout/lib/ -├── TileLayout.tsx # Main React component -├── layoutAtom.ts # Jotai state management -├── layoutModel.ts # Core model class -├── layoutModelHooks.ts # React hooks for integration -├── layoutNode.ts # Node manipulation functions -├── layoutTree.ts # Tree operation functions -├── nodeRefMap.ts # DOM reference tracking -├── types.ts # Type definitions -├── utils.ts # Utility functions -└── tilelayout.scss # Styling -``` - -## Key Data Structures - -### LayoutNode - -The fundamental building block of the layout system: - -```typescript -interface LayoutNode { - id: string; // Unique identifier - data?: TabLayoutData; // Content data (only for leaf nodes) - children?: LayoutNode[]; // Child nodes (only for containers) - flexDirection: FlexDirection; // "row" or "column" - size: number; // Flex size (0-100) -} -``` - -**Key Rules:** -- Either `data` OR `children` must be defined, never both -- Leaf nodes have `data`, container nodes have `children` -- All nodes have a `flexDirection` that determines layout axis -- `size` represents the relative flex size within the parent - -### LayoutTreeState - -The complete state of the layout: - -```typescript -interface LayoutTreeState { - rootNode: LayoutNode; // Root of the tree - focusedNodeId?: string; // Currently focused node - magnifiedNodeId?: string; // Currently magnified node - leafOrder?: LeafOrderEntry[]; // Computed leaf ordering - pendingBackendActions: LayoutActionData[]; // Actions from backend - generation: number; // State version number -} -``` - -**Generation System:** -- Incremented on every state change -- Used for optimistic updates and conflict resolution -- Prevents stale state overwrites - -### NodeModel - -Runtime model for individual nodes, providing React-friendly state: - -```typescript -interface NodeModel { - additionalProps: Atom; - innerRect: Atom; - blockNum: Atom; - nodeId: string; - blockId: string; - isFocused: Atom; - isMagnified: Atom; - isEphemeral: Atom; - toggleMagnify: () => void; - focusNode: () => void; - onClose: () => void; - dragHandleRef?: React.RefObject; - // ... additional state and methods -} -``` - -## Core Classes - -### LayoutModel - -The central orchestrator that manages the entire layout system: - -**Key Responsibilities:** -- Maintains tree state through Jotai atoms -- Processes layout actions (move, resize, insert, delete) -- Computes layout positions and transforms -- Manages drag-and-drop operations -- Handles resize operations -- Provides node models for React components - -**State Management:** -```typescript -class LayoutModel { - treeStateAtom: WritableLayoutTreeStateAtom; // Persistent state - leafs: PrimitiveAtom; // Computed leaf nodes - additionalProps: PrimitiveAtom>; - pendingTreeAction: AtomWithThrottle; - activeDrag: PrimitiveAtom; - // ... many more atoms for different aspects -} -``` - -**Action Processing:** -The model uses a reducer pattern to process actions: -```typescript -treeReducer(action: LayoutTreeAction) { - switch (action.type) { - case LayoutTreeActionType.Move: - moveNode(this.treeState, action); - break; - case LayoutTreeActionType.InsertNode: - insertNode(this.treeState, action); - break; - // ... handle all action types - } - this.updateTree(); // Recompute derived state -} -``` - -## Layout Actions - -The system uses a comprehensive action system for all modifications: - -### Action Types - -```typescript -enum LayoutTreeActionType { - ComputeMove = "computemove", // Preview move operation - Move = "move", // Execute move - Swap = "swap", // Swap two nodes - ResizeNode = "resize", // Resize node(s) - InsertNode = "insert", // Insert new node - InsertNodeAtIndex = "insertatindex", // Insert at specific index - DeleteNode = "delete", // Remove node - FocusNode = "focus", // Change focus - MagnifyNodeToggle = "magnify", // Toggle magnification - SplitHorizontal = "splithorizontal", // Split horizontally - SplitVertical = "splitvertical", // Split vertically - // ... more actions -} -``` - -### Action Flow - -1. **User Interaction** → Action triggered -2. **Action Validation** → Check if operation is valid -3. **Tree Modification** → Update `LayoutTreeState` -4. **State Propagation** → Update Jotai atoms -5. **Layout Computation** → Recalculate positions -6. **React Re-render** → Update UI - -### Example: Move Operation - -```typescript -// 1. Compute operation during drag -const computeAction: LayoutTreeComputeMoveNodeAction = { - type: LayoutTreeActionType.ComputeMove, - nodeId: targetNodeId, - nodeToMoveId: draggedNodeId, - direction: DropDirection.Right -}; - -// 2. Execute on drop -const moveAction: LayoutTreeMoveNodeAction = { - type: LayoutTreeActionType.Move, - parentId: newParentId, - index: insertIndex, - node: nodeToMove -}; -``` - -## Drag and Drop System - -The layout system implements a sophisticated drag-and-drop interface using `react-dnd`. - -### Drop Direction Logic - -When dragging over a node, the system determines drop direction based on cursor position: - -```typescript -enum DropDirection { - Top = 0, Right = 1, Bottom = 2, Left = 3, - OuterTop = 4, OuterRight = 5, OuterBottom = 6, OuterLeft = 7, - Center = 8 -} -``` - -**Drop Zones:** -- **Inner zones** (Top/Right/Bottom/Left): Insert within the target node -- **Outer zones**: Insert in the target's parent -- **Center**: Swap nodes - -### Drag Preview - -The system generates drag previews by: -1. Rendering content to an off-screen element -2. Converting to PNG using `html-to-image` -3. Using the image as the drag preview - -## Resize System - -### Resize Handles - -Resize handles are dynamically positioned between adjacent nodes: - -```typescript -interface ResizeHandleProps { - id: string; - parentNodeId: string; - parentIndex: number; - centerPx: number; // Handle position - transform: CSSProperties; // CSS positioning - flexDirection: FlexDirection; // Handle orientation -} -``` - -### Resize Operation - -1. **Handle Drag Start** → Store resize context -2. **Drag Move** → Compute new sizes based on cursor position -3. **Throttled Updates** → Update node sizes (10ms throttle) -4. **Drag End** → Commit final sizes - -## Layout Computation - -The system computes absolute positions from the tree structure: - -### Process - -1. **Tree Walk** → Traverse from root to leaves -2. **Flexbox Simulation** → Calculate container and child sizes -3. **Position Calculation** → Compute absolute positions -4. **Transform Generation** → Create CSS transforms -5. **Handle Positioning** → Place resize handles between nodes - -### Key Functions - -- [`updateTreeHelper()`](frontend/layout/lib/layoutModel.ts:638) - Main layout computation -- [`computeNodeFromProps()`](frontend/layout/lib/layoutModel.ts:718) - Individual node positioning -- [`setTransform()`](frontend/layout/lib/utils.ts:61) - CSS transform generation - -## Node Management - -### Node Operations - -The [`layoutNode.ts`](frontend/layout/lib/layoutNode.ts) file provides core node manipulation: - -```typescript -// Create new node -newLayoutNode(flexDirection?, size?, children?, data?) - -// Tree traversal -findNode(node, id) -findParent(node, id) -walkNodes(node, beforeCallback?, afterCallback?) - -// Modifications -addChildAt(node, index, ...children) -removeChild(parent, childToRemove) -balanceNode(node) // Optimize tree structure -``` - -### Tree Balancing - -The system automatically optimizes the tree structure: -- Removes unnecessary intermediate nodes -- Flattens single-child containers -- Ensures valid flex directions - -## State Synchronization - -### Frontend ↔ Backend Sync - -The layout state synchronizes with the backend through: - -1. **`layoutAtom.ts`** - Jotai atom that wraps backend state -2. **Generation tracking** - Prevents state conflicts -3. **Pending actions** - Backend-initiated changes -4. **Leaf order** - Frontend-computed ordering sent to backend - -### Atom Structure - -```typescript -const layoutTreeStateAtom = atom( - (get) => { - // Read from backend - const layoutState = get(backendLayoutStateAtom); - return transformToTreeState(layoutState); - }, - (get, set, treeState) => { - // Write to backend - if (generationNewer(treeState)) { - set(backendLayoutStateAtom, transformFromTreeState(treeState)); - } - } -); -``` - -## Special Features - -### Magnification - -Nodes can be magnified to take up the full layout space: -- Magnified nodes appear above others (higher z-index) -- Only one node can be magnified at a time -- Animation smoothly transitions between normal and magnified states - -### Ephemeral Nodes - -Temporary nodes that aren't part of the persistent tree: -- Used for preview/temporary content -- Automatically cleaned up -- Appear above the normal layout - -### Focus Management - -- One node can be focused at a time -- Focus affects keyboard navigation -- Integrates with the terminal's block focus system - -## Integration Points - -### React Integration - -**Hooks:** -- [`useTileLayout()`](frontend/layout/lib/layoutModelHooks.ts:51) - Main hook for layout setup -- [`useNodeModel()`](frontend/layout/lib/layoutModelHooks.ts:65) - Get node model for component -- [`useDebouncedNodeInnerRect()`](frontend/layout/lib/layoutModelHooks.ts:69) - Animated positioning - -### Content Rendering - -The layout system is content-agnostic through render callbacks: - -```typescript -interface TileLayoutContents { - renderContent: (nodeModel: NodeModel) => React.ReactNode; - renderPreview?: (nodeModel: NodeModel) => React.ReactElement; - onNodeDelete?: (data: TabLayoutData) => Promise; -} -``` - -### Performance Optimizations - -1. **Memoization** - Extensive use of `React.memo()` and `useMemo()` -2. **Throttling** - Resize and drag operations throttled to 10-50ms -3. **Transform-based positioning** - Uses CSS transforms for performance -4. **Split atoms** - Jotai `splitAtom()` for efficient array updates -5. **Selective re-rendering** - Only affected components re-render - -## Common Patterns - -### Adding New Actions - -1. Define action type in [`types.ts`](frontend/layout/lib/types.ts) -2. Implement handler in [`layoutTree.ts`](frontend/layout/lib/layoutTree.ts) -3. Add case to [`LayoutModel.treeReducer()`](frontend/layout/lib/layoutModel.ts:330) -4. Update generation and call `updateTree()` - -### Extending Node Properties - -1. Add to `LayoutNodeAdditionalProps` in [`types.ts`](frontend/layout/lib/types.ts) -2. Compute in [`updateTreeHelper()`](frontend/layout/lib/layoutModel.ts:638) -3. Access via `nodeModel.additionalProps` - -### Custom Layout Behaviors - -Override or extend layout computation by: -1. Modifying [`computeNodeFromProps()`](frontend/layout/lib/layoutModel.ts:718) -2. Adding custom CSS transforms -3. Implementing special handling in action reducers - -## Error Handling - -The system includes extensive validation: -- Node structure validation -- Action parameter checking -- Tree consistency checks -- Graceful degradation on errors - -## Testing - -The layout system includes comprehensive tests: -- [`layoutNode.test.ts`](frontend/layout/tests/layoutNode.test.ts) - Node operations -- [`layoutTree.test.ts`](frontend/layout/tests/layoutTree.test.ts) - Tree operations -- [`utils.test.ts`](frontend/layout/tests/utils.test.ts) - Utility functions - -## Debugging - -For debugging layout issues: -1. Check `treeState.generation` for state changes -2. Inspect `additionalProps` for computed layout data -3. Use browser dev tools to examine CSS transforms -4. Enable console logging in action reducers - -The layout system is complex but well-structured, providing a powerful foundation for Wave Terminal's dynamic layout capabilities. \ No newline at end of file diff --git a/aiprompts/monaco-v0.53.md b/aiprompts/monaco-v0.53.md deleted file mode 100644 index 2ec5c1dcff..0000000000 --- a/aiprompts/monaco-v0.53.md +++ /dev/null @@ -1,172 +0,0 @@ -# Monaco 0.52 → 0.53 ESM Migration Plan (Vite/Electron) - -**Status:** Deferred to next release. -**Current:** Pinned to `monaco-editor@0.52.x` (works with `@monaco-editor/loader`). -**Target:** Switch to `monaco-editor@â‰Ĩ0.53` ESM build and drop `@monaco-editor/loader` + AMD path copy. - ---- - -## Why this change - -- Monaco 0.53 deprecates the AMD build. The loader/AMD path mapping (`paths: { vs: "monaco" }`) becomes brittle. -- ESM build uses **module workers**, which require explicit worker wiring. -- Benefits: cleaner bundling with Vite, fewer legacy shims, better CSP/Electron compatibility. - ---- - -## High‑level plan - -1. **Remove AMD/loader**: uninstall `@monaco-editor/loader`; remove `viteStaticCopy` of `min/vs/*`; delete `loader.config/init` calls. -2. **Install Monaco â‰Ĩ0.53** and **wire ESM workers** via `MonacoEnvironment.getWorker`. -3. **Keep main bundle slim**: lazy‑load the Monaco setup; optionally force a separate `monaco` chunk. -4. **Electron / build**: ensure `base: './'` in Vite for packaged apps. - ---- - -## Step‑by‑step - -### 1) Dependencies - -```bash -# next cycle: -npm rm @monaco-editor/loader -npm i monaco-editor@^0.53 -``` - -### 2) Remove AMD-era build config - -- Delete `viteStaticCopy({ targets: [{ src: "node_modules/monaco-editor/min/vs/*", dest: "monaco" }] })`. -- Delete: - - ```ts - loader.config({ paths: { vs: "monaco" } }); - await loader.init(); - ``` - -### 3) Add ESM setup module - -Create `monaco-setup.ts`: - -```ts -// monaco-setup.ts -import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; -import "monaco-editor/esm/vs/editor/editor.all.css"; - -(self as any).MonacoEnvironment = { - getWorker(_moduleId: string, label: string) { - switch (label) { - case "json": - return new Worker(new URL("monaco-editor/esm/vs/language/json/json.worker.js", import.meta.url), { - type: "module", - }); - case "css": - return new Worker(new URL("monaco-editor/esm/vs/language/css/css.worker.js", import.meta.url), { - type: "module", - }); - case "html": - return new Worker(new URL("monaco-editor/esm/vs/language/html/html.worker.js", import.meta.url), { - type: "module", - }); - case "typescript": - case "javascript": - return new Worker(new URL("monaco-editor/esm/vs/language/typescript/ts.worker.js", import.meta.url), { - type: "module", - }); - default: - return new Worker(new URL("monaco-editor/esm/vs/editor/editor.worker.js", import.meta.url), { type: "module" }); - } - }, -}; - -export { monaco }; -``` - -### 4) Import lazily where used - -```ts -// where the editor UI mounts -const { monaco } = await import("./monaco-setup"); -const editor = monaco.editor.create(container, { language: "javascript", value: "" }); -``` - -### 5) Optional: isolate Monaco into its own chunk - -`vite.config.ts`: - -```ts -import { defineConfig } from "vite"; - -export default defineConfig({ - base: "./", // important for Electron packaged apps - build: { - rollupOptions: { - output: { - manualChunks(id) { - if (id.includes("node_modules/monaco-editor")) return "monaco"; - }, - }, - }, - }, -}); -``` - -> Note: Workers created via `new URL(..., import.meta.url)` are emitted as **separate chunks** automatically. - ---- - -## Bundle size controls (pick what you need) - -- Import `editor.api` instead of full `editor` (already done above). -- Only include workers you use (drop `json/css/html` blocks if not needed). -- Lazy‑load Monaco with `import()` behind the UI that needs it. -- Optionally dynamic‑import language contributions on demand: - - ```ts - if (lang === "json") { - await import("monaco-editor/esm/vs/language/json/monaco.contribution"); - } - ``` - ---- - -## Electron specifics - -- `base: './'` in `vite.config.ts` so worker URLs resolve under `file://` in packaged apps. -- `{ type: 'module' }` is required for Monaco’s ESM workers. -- This approach avoids blob URLs and works with stricter CSPs. - ---- - -## Test checklist - -- Dev: editor renders; no 404s for worker scripts; language services active (TS hover/diagnostics, JSON schema). -- Prod build: verify worker files emitted; open packaged Electron app and ensure workers load (no "Cannot use import statement outside a module"). -- Hot paths: open/close editor repeatedly; memory doesn’t grow unbounded. - ---- - -## Rollback plan - -If anything blocks the release, revert to: - -```bash -npm i monaco-editor@0.52.x -npm i -D @monaco-editor/loader -``` - -Restore the `viteStaticCopy` block and `loader.config/init` calls. - ---- - -## Open questions (optional) - -- Do we need JSON/CSS/HTML workers in the default bundle? (Decide before wiring.) -- Any extra CSP limitations for production? (If so, confirm worker script allowances.) - ---- - -## Snippet index (for quick copy) - -- `monaco-setup.ts` (ESM + workers): see above. -- `vite.config.ts` (`base: './'` + `manualChunks`): see above. -- Lazy import site: `const { monaco } = await import('./monaco-setup');` diff --git a/aiprompts/newview.md b/aiprompts/newview.md deleted file mode 100644 index ddb2da57fc..0000000000 --- a/aiprompts/newview.md +++ /dev/null @@ -1,526 +0,0 @@ -# Creating a New View in Wave Terminal - -This guide explains how to implement a new view type in Wave Terminal. Views are the core content components displayed within blocks in the terminal interface. - -## Architecture Overview - -Wave Terminal uses a **Model-View architecture** where: -- **ViewModel** - Contains all state, logic, and UI configuration as Jotai atoms -- **ViewComponent** - Pure React component that renders the UI using the model -- **BlockFrame** - Wraps views with a header, connection management, and standard controls - -The separation between model and component ensures: -- Models can update state without React hooks -- Components remain pure and testable -- State is centralized in Jotai atoms for easy access - -## ViewModel Interface - -Every view must implement the `ViewModel` interface defined in [`frontend/types/custom.d.ts`](../frontend/types/custom.d.ts:285-341): - -```typescript -interface ViewModel { - // Required: The type identifier for this view (e.g., "term", "web", "preview") - viewType: string; - - // Required: The React component that renders this view - viewComponent: ViewComponent; - - // Optional: Icon shown in block header (FontAwesome icon name or IconButtonDecl) - viewIcon?: jotai.Atom; - - // Optional: Display name shown in block header (e.g., "Terminal", "Web", "Preview") - viewName?: jotai.Atom; - - // Optional: Additional header elements (text, buttons, inputs) shown after the name - viewText?: jotai.Atom; - - // Optional: Icon button shown before the view name in header - preIconButton?: jotai.Atom; - - // Optional: Icon buttons shown at the end of the header (before settings/close) - endIconButtons?: jotai.Atom; - - // Optional: Custom background styling for the block - blockBg?: jotai.Atom; - - // Optional: If true, completely hides the block header - noHeader?: jotai.Atom; - - // Optional: If true, shows connection picker in header for remote connections - manageConnection?: jotai.Atom; - - // Optional: If true, filters out 'nowsh' connections from connection picker - filterOutNowsh?: jotai.Atom; - - // Optional: If true, shows S3 connections in connection picker - showS3?: jotai.Atom; - - // Optional: If true, removes default padding from content area - noPadding?: jotai.Atom; - - // Optional: Atoms for managing in-block search functionality - searchAtoms?: SearchAtoms; - - // Optional: Returns whether this is a basic terminal (for multi-input feature) - isBasicTerm?: (getFn: jotai.Getter) => boolean; - - // Optional: Returns context menu items for the settings dropdown - getSettingsMenuItems?: () => ContextMenuItem[]; - - // Optional: Focuses the view when called, returns true if successful - giveFocus?: () => boolean; - - // Optional: Handles keyboard events, returns true if handled - keyDownHandler?: (e: WaveKeyboardEvent) => boolean; - - // Optional: Cleanup when block is closed - dispose?: () => void; -} -``` - -### Key Concepts - -**Atoms**: All UI-related properties must be Jotai atoms. This enables: -- Reactive updates when state changes -- Access from anywhere via `globalStore.get()`/`globalStore.set()` -- Derived atoms that compute values from other atoms - -**ViewComponent**: The React component receives these props: -```typescript -type ViewComponentProps = { - blockId: string; // Unique ID for this block - blockRef: React.RefObject; // Ref to block container - contentRef: React.RefObject; // Ref to content area - model: T; // Your ViewModel instance -}; -``` - -## Step-by-Step Guide - -### 1. Create the View Model Class - -Create a new file for your view model (e.g., `frontend/app/view/myview/myview-model.ts`): - -```typescript -import { BlockNodeModel } from "@/app/block/blocktypes"; -import { globalStore } from "@/app/store/jotaiStore"; -import { WOS, useBlockAtom } from "@/store/global"; -import * as jotai from "jotai"; -import { MyView } from "./myview"; - -export class MyViewModel implements ViewModel { - viewType: string; - blockId: string; - nodeModel: BlockNodeModel; - blockAtom: jotai.Atom; - - // Define your atoms (simple field initializers) - viewIcon = jotai.atom("circle"); - viewName = jotai.atom("My View"); - noPadding = jotai.atom(true); - - // Derived atom (created in constructor) - viewText!: jotai.Atom; - - constructor(blockId: string, nodeModel: BlockNodeModel) { - this.viewType = "myview"; - this.blockId = blockId; - this.nodeModel = nodeModel; - this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); - - // Create derived atoms that depend on block data or other atoms - this.viewText = jotai.atom((get) => { - const blockData = get(this.blockAtom); - const rtn: HeaderElem[] = []; - - // Add header buttons/text based on state - rtn.push({ - elemtype: "iconbutton", - icon: "refresh", - title: "Refresh", - click: () => this.refresh(), - }); - - return rtn; - }); - } - - get viewComponent(): ViewComponent { - return MyView; - } - - refresh() { - // Update state using globalStore - // Never use React hooks in model methods - console.log("refreshing..."); - } - - giveFocus(): boolean { - // Focus your view component - return true; - } - - dispose() { - // Cleanup resources (unsubscribe from events, etc.) - } -} -``` - -### 2. Create the View Component - -Create your React component (e.g., `frontend/app/view/myview/myview.tsx`): - -```typescript -import { ViewComponentProps } from "@/app/block/blocktypes"; -import { MyViewModel } from "./myview-model"; -import { useAtomValue } from "jotai"; -import "./myview.scss"; - -export const MyView: React.FC> = ({ - blockId, - model, - contentRef -}) => { - // Use atoms from the model (these are React hooks - call at top level!) - const blockData = useAtomValue(model.blockAtom); - - return ( -
-
Block ID: {blockId}
-
View: {model.viewType}
- {/* Your view content here */} -
- ); -}; -``` - -### 3. Register the View - -Add your view to the `BlockRegistry` in [`frontend/app/block/block.tsx`](../frontend/app/block/block.tsx:42-55): - -```typescript -const BlockRegistry: Map = new Map(); -BlockRegistry.set("term", TermViewModel); -BlockRegistry.set("preview", PreviewModel); -BlockRegistry.set("web", WebViewModel); -// ... existing registrations ... -BlockRegistry.set("myview", MyViewModel); // Add your view here -``` - -The registry key (e.g., `"myview"`) becomes the view type used in block metadata. - -### 4. Create Blocks with Your View - -Users can create blocks with your view type: -- Via CLI: `wsh view myview` -- Via RPC: Use the block's `meta.view` field set to `"myview"` - -## Real-World Examples - -### Example 1: Terminal View ([`term-model.ts`](../frontend/app/view/term/term-model.ts)) - -The terminal view demonstrates: -- **Connection management** via `manageConnection` atom -- **Dynamic header buttons** showing shell status (play/restart) -- **Mode switching** between terminal and vdom views -- **Custom keyboard handling** for terminal-specific shortcuts -- **Focus management** to focus the xterm.js instance -- **Shell integration status** showing AI capability indicators - -Key features: -```typescript -this.manageConnection = jotai.atom((get) => { - const termMode = get(this.termMode); - if (termMode == "vdom") return false; - return true; // Show connection picker for regular terminal mode -}); - -this.endIconButtons = jotai.atom((get) => { - const shellProcStatus = get(this.shellProcStatus); - const buttons: IconButtonDecl[] = []; - - if (shellProcStatus == "running") { - buttons.push({ - elemtype: "iconbutton", - icon: "refresh", - title: "Restart Shell", - click: this.forceRestartController.bind(this), - }); - } - return buttons; -}); -``` - -### Example 2: Web View ([`webview.tsx`](../frontend/app/view/webview/webview.tsx)) - -The web view shows: -- **Complex header controls** (back/forward/home/URL input) -- **State management** for loading, URL, and navigation -- **Event handling** for webview navigation events -- **Custom styling** with `noPadding` for full-bleed content -- **Media controls** showing play/pause/mute when media is active - -Key features: -```typescript -this.viewText = jotai.atom((get) => { - const url = get(this.url); - const rtn: HeaderElem[] = []; - - // Navigation buttons - rtn.push({ - elemtype: "iconbutton", - icon: "chevron-left", - click: this.handleBack.bind(this), - disabled: this.shouldDisableBackButton(), - }); - - // URL input with nested controls - rtn.push({ - elemtype: "div", - className: "block-frame-div-url", - children: [ - { - elemtype: "input", - value: url, - onChange: this.handleUrlChange.bind(this), - onKeyDown: this.handleKeyDown.bind(this), - }, - { - elemtype: "iconbutton", - icon: "rotate-right", - click: this.handleRefresh.bind(this), - } - ], - }); - - return rtn; -}); -``` - -## Header Elements (`HeaderElem`) - -The `viewText` atom can return an array of these element types: - -```typescript -// Icon button -{ - elemtype: "iconbutton", - icon: "refresh", - title: "Tooltip text", - click: () => { /* handler */ }, - disabled?: boolean, - iconColor?: string, - iconSpin?: boolean, - noAction?: boolean, // Shows icon but no click action -} - -// Text element -{ - elemtype: "text", - text: "Display text", - className?: string, - noGrow?: boolean, - ref?: React.RefObject, - onClick?: (e: React.MouseEvent) => void, -} - -// Text button -{ - elemtype: "textbutton", - text: "Button text", - className?: string, - title: "Tooltip", - onClick: (e: React.MouseEvent) => void, -} - -// Input field -{ - elemtype: "input", - value: string, - className?: string, - onChange: (e: React.ChangeEvent) => void, - onKeyDown?: (e: React.KeyboardEvent) => void, - onFocus?: (e: React.FocusEvent) => void, - onBlur?: (e: React.FocusEvent) => void, - ref?: React.RefObject, -} - -// Container with children -{ - elemtype: "div", - className?: string, - children: HeaderElem[], - onMouseOver?: (e: React.MouseEvent) => void, - onMouseOut?: (e: React.MouseEvent) => void, -} - -// Menu button (dropdown) -{ - elemtype: "menubutton", - // ... MenuButtonProps ... -} -``` - -## Best Practices - -### Jotai Model Pattern - -Follow these rules for Jotai atoms in models: - -1. **Simple atoms as field initializers**: - ```typescript - viewIcon = jotai.atom("circle"); - noPadding = jotai.atom(true); - ``` - -2. **Derived atoms in constructor** (need dependency on other atoms): - ```typescript - constructor(blockId: string, nodeModel: BlockNodeModel) { - this.viewText = jotai.atom((get) => { - const blockData = get(this.blockAtom); - return [/* computed based on blockData */]; - }); - } - ``` - -3. **Models never use React hooks** - Use `globalStore.get()`/`set()`: - ```typescript - refresh() { - const currentData = globalStore.get(this.blockAtom); - globalStore.set(this.dataAtom, newData); - } - ``` - -4. **Components use hooks for atoms**: - ```typescript - const data = useAtomValue(model.dataAtom); - const [value, setValue] = useAtom(model.valueAtom); - ``` - -### State Management - -- All view state should live in atoms on the model -- Use `useBlockAtom()` helper for block-scoped atoms that persist -- Use `globalStore` for imperative access outside React components -- Subscribe to Wave events using `waveEventSubscribe()` - -### Styling - -- Create a `.scss` file for your view styles -- Use Tailwind utilities where possible (v4) -- Add `noPadding: atom(true)` for full-bleed content -- Use `blockBg` atom to customize block background - -### Focus Management - -Implement `giveFocus()` to focus your view when: -- Block gains focus via keyboard navigation -- User clicks the block -- Return `true` if successfully focused, `false` otherwise - -### Keyboard Handling - -Implement `keyDownHandler(e: WaveKeyboardEvent)` for: -- View-specific keyboard shortcuts -- Return `true` if event was handled (prevents propagation) -- Use `keyutil.checkKeyPressed(waveEvent, "Cmd:K")` for shortcut checks - -### Cleanup - -Implement `dispose()` to: -- Unsubscribe from Wave events -- Unregister routes/handlers -- Clear timers/intervals -- Release resources - -### Connection Management - -For views that need remote connections: -```typescript -this.manageConnection = jotai.atom(true); // Show connection picker -this.filterOutNowsh = jotai.atom(true); // Hide nowsh connections -this.showS3 = jotai.atom(true); // Show S3 connections -``` - -Access connection status: -```typescript -const connStatus = jotai.atom((get) => { - const blockData = get(this.blockAtom); - const connName = blockData?.meta?.connection; - return get(getConnStatusAtom(connName)); -}); -``` - -## Common Patterns - -### Reading Block Metadata - -```typescript -import { getBlockMetaKeyAtom } from "@/store/global"; - -// In constructor: -this.someFlag = getBlockMetaKeyAtom(blockId, "myview:flag"); - -// In component: -const flag = useAtomValue(model.someFlag); -``` - -### Configuration Overrides - -Wave has a hierarchical config system (global → connection → block): - -```typescript -import { getOverrideConfigAtom } from "@/store/global"; - -this.settingAtom = jotai.atom((get) => { - // Checks block meta, then connection config, then global settings - return get(getOverrideConfigAtom(this.blockId, "myview:setting")) ?? defaultValue; -}); -``` - -### Updating Block Metadata - -```typescript -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { WOS } from "@/store/global"; - -await RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), - meta: { "myview:key": value }, -}); -``` - -### Search Integration - -To add in-block search: - -```typescript -import { useSearch } from "@/app/element/search"; - -// In model: -this.searchAtoms = useSearch(); // Call in component, not model! - -// In component: -const searchAtoms = useSearch(); -// Pass to model or use directly -``` - -## Testing Your View - -1. Build the frontend: `task build:dev` or `task electron:dev` -2. Create a block with your view type -3. Test all interactive elements (buttons, inputs, etc.) -4. Test keyboard shortcuts -5. Test focus behavior -6. Test cleanup (close block and check console for errors) -7. Test with different block configurations via metadata - -## Additional Resources - -- [`frontend/app/block/blockframe.tsx`](../frontend/app/block/blockframe.tsx) - Block header rendering -- [`frontend/app/view/term/term-model.ts`](../frontend/app/view/term/term-model.ts) - Complex view example -- [`frontend/app/view/webview/webview.tsx`](../frontend/app/view/webview/webview.tsx) - Navigation UI example -- [`frontend/types/custom.d.ts`](../frontend/types/custom.d.ts) - Type definitions -- Project coding rules in [`.roo/rules/`](../.roo/rules/) \ No newline at end of file diff --git a/aiprompts/openai-request.md b/aiprompts/openai-request.md deleted file mode 100644 index f67ac0847a..0000000000 --- a/aiprompts/openai-request.md +++ /dev/null @@ -1,201 +0,0 @@ -# OpenAI Request Input Field Structure (On-the-Wire Format) - -This document describes the actual JSON structure sent to the OpenAI API in the `input` field of [`OpenAIRequest`](../pkg/aiusechat/openai/openai-convertmessage.go:111). - -## Overview - -The `input` field is a JSON array containing one of three object types: - -1. **Messages** (user/assistant) - `OpenAIMessage` objects -2. **Function Calls** (tool invocations) - `OpenAIFunctionCallInput` objects -3. **Function Call Results** (tool outputs) - `OpenAIFunctionCallOutputInput` objects - -These are converted from [`OpenAIChatMessage`](../pkg/aiusechat/openai/openai-backend.go:46-52) internal format and cleaned before transmission ([see lines 485-494](../pkg/aiusechat/openai/openai-backend.go:485-494)). - -## 1. Message Objects (User/Assistant) - -User and assistant messages sent as [`OpenAIMessage`](../pkg/aiusechat/openai/openai-backend.go:54-57): - -```json -{ - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello, analyze this image" - }, - { - "type": "input_image", - "image_url": "data:image/png;base64,iVBORw0KG..." - } - ] -} -``` - -**Key Points:** -- `role`: Always `"user"` or `"assistant"` -- `content`: **Always an array** of content blocks (never a plain string) - -### Content Block Types - -#### Text Block -```json -{ - "type": "input_text", - "text": "message content here" -} -``` - -#### Image Block -```json -{ - "type": "input_image", - "image_url": "data:image/png;base64,..." -} -``` -- Can be a data URL or https:// URL -- `filename` field is **removed** during cleaning - -#### PDF File Block -```json -{ - "type": "input_file", - "file_data": "JVBERi0xLjQKJeLjz9M...", - "filename": "document.pdf" -} -``` -- `file_data`: Base64-encoded PDF content - -#### Function Call Block (in assistant messages) -```json -{ - "type": "function_call", - "call_id": "call_abc123", - "name": "search_files", - "arguments": {"query": "test"} -} -``` - -## 2. Function Call Objects (Tool Invocations) - -Tool calls from the model sent as [`OpenAIFunctionCallInput`](../pkg/aiusechat/openai/openai-backend.go:59-67): - -```json -{ - "type": "function_call", - "call_id": "call_abc123", - "name": "search_files", - "arguments": "{\"query\":\"test\",\"path\":\"./src\"}" -} -``` - -**Key Points:** -- `type`: Always `"function_call"` -- `call_id`: Unique identifier generated by model -- `name`: Function name to execute -- `arguments`: JSON-encoded string of parameters -- `status`: Optional (`"in_progress"`, `"completed"`, `"incomplete"`) -- Internal `toolusedata` field is **removed** during cleaning - -## 3. Function Call Output Objects (Tool Results) - -Tool execution results sent as [`OpenAIFunctionCallOutputInput`](../pkg/aiusechat/openai/openai-backend.go:69-75): - -```json -{ - "type": "function_call_output", - "call_id": "call_abc123", - "output": "Found 3 files matching query" -} -``` - -**Key Points:** -- `type`: Always `"function_call_output"` -- `call_id`: Must match the original function call's `call_id` -- `output`: Can be text, image array, or error object - -### Output Value Types - -#### Text Output -```json -{ - "type": "function_call_output", - "call_id": "call_abc123", - "output": "Result text here" -} -``` - -#### Image Output -```json -{ - "type": "function_call_output", - "call_id": "call_abc123", - "output": [ - { - "type": "input_image", - "image_url": "data:image/png;base64,..." - } - ] -} -``` - -#### Error Output -```json -{ - "type": "function_call_output", - "call_id": "call_abc123", - "output": "{\"ok\":\"false\",\"error\":\"File not found\"}" -} -``` -- Error output is a JSON-encoded string containing `ok` and `error` fields - -## Complete Example - -```json -{ - "model": "gpt-4o", - "input": [ - { - "role": "user", - "content": [ - { - "type": "input_text", - "text": "What files are in src/?" - } - ] - }, - { - "type": "function_call", - "call_id": "call_xyz789", - "name": "list_files", - "arguments": "{\"path\":\"src/\"}" - }, - { - "type": "function_call_output", - "call_id": "call_xyz789", - "output": "main.go\nutil.go\nconfig.go" - }, - { - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "The src/ directory contains 3 files: main.go, util.go, and config.go" - } - ] - } - ], - "stream": true, - "max_output_tokens": 4096 -} -``` - -## Cleaning Process - -Before transmission, internal fields are removed ([cleanup code](../pkg/aiusechat/openai/openai-backend.go:485-494)): - -- **Messages**: `previewurl` field removed, `filename` removed from `input_image` blocks -- **Function Calls**: `toolusedata` field removed -- **Function Outputs**: Sent as-is (no cleaning needed) - -This ensures the API receives only the fields it expects. \ No newline at end of file diff --git a/aiprompts/openai-streaming-text.md b/aiprompts/openai-streaming-text.md deleted file mode 100644 index 7ca9214bf9..0000000000 --- a/aiprompts/openai-streaming-text.md +++ /dev/null @@ -1,74 +0,0 @@ -For **just text streaming**, you only need to handle these 3 core events: - -## Essential Events - -### 1. `response.created` - -```json -{ - "type": "response.created", - "response": { - "id": "resp_abc123", - "created_at": 1640995200, - "model": "gpt-5" - } -} -``` - -**Purpose**: Initialize response tracking (like Anthropic's `message_start`) - -### 2. `response.output_text.delta` - -```json -{ - "type": "response.output_text.delta", - "item_id": "msg_abc123", - "delta": "Hello, how can I" -} -``` - -**Purpose**: Stream text chunks (like Anthropic's `text_delta`) - -### 3. `response.completed` - -```json -{ - "type": "response.completed", - "response": { - "usage": { - "input_tokens": 100, - "output_tokens": 200 - } - } -} -``` - -**Purpose**: Finalize response (like Anthropic's `message_stop`) - -## Optional but Recommended - -### 4. `error` - -```json -{ - "type": "error", - "code": "rate_limit_exceeded", - "message": "Rate limit exceeded" -} -``` - -**Purpose**: Handle errors gracefully - ---- - -That's it for basic text streaming! You can ignore all the `response.output_item.added/done`, tool calling, reasoning, and annotation events if you just want simple text responses. - -Your Go implementation would be: - -1. Parse SSE stream -2. Switch on `event.type` -3. Handle these 4 event types -4. Accumulate text from `delta` fields -5. Emit to your existing SSE handler - -Much simpler than the full implementation. diff --git a/aiprompts/openai-streaming.md b/aiprompts/openai-streaming.md deleted file mode 100644 index fddff13086..0000000000 --- a/aiprompts/openai-streaming.md +++ /dev/null @@ -1,357 +0,0 @@ -# OpenAI Responses API SSE Events Documentation - -This document outlines the Server-Sent Events (SSE) format used by OpenAI's Responses API for streaming chat completions, based on the Vercel AI SDK implementation. - -## Core Event Types - -### Response Lifecycle Events - -#### `response.created` - -Emitted when a new response begins. - -```json -{ - "type": "response.created", - "response": { - "id": "resp_abc123", - "created_at": 1640995200, - "model": "gpt-5", - "service_tier": "default" - } -} -``` - -#### `response.completed` - -Emitted when the response completes successfully. - -```json -{ - "type": "response.completed", - "response": { - "incomplete_details": null, - "usage": { - "input_tokens": 100, - "input_tokens_details": { - "cached_tokens": 50 - }, - "output_tokens": 200, - "output_tokens_details": { - "reasoning_tokens": 150 - } - }, - "service_tier": "default" - } -} -``` - -#### `response.incomplete` - -Emitted when the response is incomplete (e.g., due to length limits). - -```json -{ - "type": "response.incomplete", - "response": { - "incomplete_details": { - "reason": "max_tokens" - }, - "usage": { - "input_tokens": 100, - "output_tokens": 4000 - } - } -} -``` - -### Content Block Events - -#### `response.output_item.added` - -Emitted when a new output item (content block) is added. - -```json -{ - "type": "response.output_item.added", - "output_index": 0, - "item": { - "type": "message", - "id": "msg_abc123" - } -} -``` - -Item types can be: - -- `message` - Text content -- `reasoning` - Reasoning/thinking content -- `function_call` - Tool call -- `web_search_call` - Web search tool call -- `computer_call` - Computer use tool call -- `file_search_call` - File search tool call -- `image_generation_call` - Image generation tool call -- `code_interpreter_call` - Code interpreter tool call - -#### `response.output_item.done` - -Emitted when an output item is completed. - -```json -{ - "type": "response.output_item.done", - "output_index": 0, - "item": { - "type": "message", - "id": "msg_abc123" - } -} -``` - -For function calls, includes the complete arguments: - -```json -{ - "type": "response.output_item.done", - "output_index": 1, - "item": { - "type": "function_call", - "id": "call_abc123", - "call_id": "call_abc123", - "name": "get_weather", - "arguments": "{\"location\": \"San Francisco\"}", - "status": "completed" - } -} -``` - -### Text Streaming Events - -#### `response.output_text.delta` - -Emitted for incremental text content. - -```json -{ - "type": "response.output_text.delta", - "item_id": "msg_abc123", - "delta": "Hello, how can I", - "logprobs": [ - { - "token": "Hello", - "logprob": -0.1, - "top_logprobs": [ - { - "token": "Hello", - "logprob": -0.1 - }, - { - "token": "Hi", - "logprob": -2.3 - } - ] - } - ] -} -``` - -### Tool Call Events - -#### `response.function_call_arguments.delta` - -Emitted for streaming function call arguments. - -```json -{ - "type": "response.function_call_arguments.delta", - "item_id": "call_abc123", - "output_index": 1, - "delta": "\"location\": \"San" -} -``` - -### Reasoning Events - -#### `response.reasoning_summary_part.added` - -Emitted when a new reasoning summary part is added. - -```json -{ - "type": "response.reasoning_summary_part.added", - "item_id": "reasoning_abc123", - "summary_index": 0 -} -``` - -#### `response.reasoning_summary_text.delta` - -Emitted for incremental reasoning text. - -```json -{ - "type": "response.reasoning_summary_text.delta", - "item_id": "reasoning_abc123", - "summary_index": 0, - "delta": "Let me think about this step by step..." -} -``` - -### Annotation Events - -#### `response.output_text.annotation.added` - -Emitted when citations or annotations are added to text. - -```json -{ - "type": "response.output_text.annotation.added", - "annotation": { - "type": "url_citation", - "url": "https://example.com/article", - "title": "Example Article" - } -} -``` - -Or for file citations: - -```json -{ - "type": "response.output_text.annotation.added", - "annotation": { - "type": "file_citation", - "file_id": "file_abc123", - "filename": "document.pdf", - "quote": "This is the relevant quote", - "start_index": 100, - "end_index": 150 - } -} -``` - -### Error Events - -#### `error` - -Emitted when an error occurs. - -```json -{ - "type": "error", - "code": "rate_limit_exceeded", - "message": "Rate limit exceeded. Please try again later.", - "param": null, - "sequence_number": 5 -} -``` - -## Built-in Tool Call Schemas - -### Web Search Call - -```json -{ - "type": "web_search_call", - "id": "search_abc123", - "status": "completed", - "action": { - "type": "search", - "query": "OpenAI API documentation" - } -} -``` - -### File Search Call - -```json -{ - "type": "file_search_call", - "id": "search_abc123", - "queries": ["OpenAI pricing", "API limits"], - "results": [ - { - "attributes": {}, - "file_id": "file_abc123", - "filename": "pricing.pdf", - "score": 0.85, - "text": "OpenAI API pricing starts at..." - } - ] -} -``` - -### Code Interpreter Call - -```json -{ - "type": "code_interpreter_call", - "id": "code_abc123", - "code": "print('Hello, world!')", - "container_id": "container_123", - "outputs": [ - { - "type": "logs", - "logs": "Hello, world!\n" - } - ] -} -``` - -### Image Generation Call - -```json -{ - "type": "image_generation_call", - "id": "img_abc123", - "result": "https://example.com/generated-image.png" -} -``` - -### Computer Use Call - -```json -{ - "type": "computer_call", - "id": "computer_abc123", - "status": "completed" -} -``` - -## Event Processing Flow - -1. **Response Start**: `response.created` → Initialize response tracking -2. **Content Blocks**: `response.output_item.added` → Start tracking content block -3. **Streaming Content**: - - `response.output_text.delta` → Accumulate text - - `response.function_call_arguments.delta` → Accumulate tool arguments - - `response.reasoning_summary_text.delta` → Accumulate reasoning -4. **Content Complete**: `response.output_item.done` → Finalize content block -5. **Response End**: `response.completed`/`response.incomplete` → Finalize response - -## Key Differences from Anthropic - -| Aspect | OpenAI Responses API | Anthropic Messages API | -| -------------- | ---------------------------------------- | ------------------------------------------------ | -| Text streaming | `response.output_text.delta` | `content_block_delta` (type: `text_delta`) | -| Tool arguments | `response.function_call_arguments.delta` | `content_block_delta` (type: `input_json_delta`) | -| Reasoning | `response.reasoning_summary_text.delta` | `content_block_delta` (type: `thinking_delta`) | -| Block tracking | `output_index` | `index` | -| Response start | `response.created` | `message_start` | -| Response end | `response.completed` | `message_stop` | - -## Error Handling - -- Parse each SSE event with proper JSON validation -- Handle unknown event types gracefully (forward as-is or ignore) -- Track `sequence_number` for error events to maintain order -- Use `output_index` to correlate events with specific content blocks -- Handle partial JSON in tool argument deltas (accumulate until complete) - -## Implementation Notes - -- Events may arrive out of order; use `output_index` and `item_id` for correlation -- Multiple reasoning summary parts can exist; track by `summary_index` -- Tool calls can be provider-executed (built-in tools) or require client execution -- Logprobs are optional and only included when requested -- Usage tokens are only available in completion events diff --git a/aiprompts/tailwind-container-queries.md b/aiprompts/tailwind-container-queries.md deleted file mode 100644 index 646bf970bb..0000000000 --- a/aiprompts/tailwind-container-queries.md +++ /dev/null @@ -1,70 +0,0 @@ -### Tailwind v4 Container Queries (Quick Overview) - -- **Viewport breakpoints**: `sm:`, `md:`, `lg:`, etc. → respond to **screen size**. -- **Container queries**: `@sm:`, `@md:`, etc. → respond to **parent element size**. - -#### Enable - -No plugin needed in **v4** (built-in). -In v3: install `@tailwindcss/container-queries`. - -#### Usage - -```html - -``` - -- `@container` marks the parent. -- `@sm:` / `@md:` refer to **container width**, not viewport. - -#### Max-Width Container Queries - -For max-width queries, use `@max-` prefix: - -```html -
- -
Only on containers < sm
- - -
- Fixed overlay on small, normal on large -
-
-``` - -- `@max-sm:` = max-width query (container **below** sm breakpoint) -- `@sm:` = min-width query (container **at or above** sm breakpoint) - -**IMPORTANT**: The syntax is `@max-w600:` NOT `max-@w600:` (prefix comes before the @) - -#### Notes - -- Based on native CSS container queries (well supported in modern browsers). -- Breakpoints for container queries reuse Tailwind’s `sm`, `md`, `lg`, etc. scales. -- Safe for modern webapps; no IE/legacy support. - -We have special breakpoints set up for panels: - - --container-w600: 600px; - --container-w450: 450px; - --container-xs: 300px; - --container-xxs: 200px; - --container-tiny: 120px; - -since often sm, md, and lg are too big for panels. - -Usage examples: - -```html - -
- - -
- - -
-``` diff --git a/aiprompts/tsunami-builder.md b/aiprompts/tsunami-builder.md deleted file mode 100644 index eb84289563..0000000000 --- a/aiprompts/tsunami-builder.md +++ /dev/null @@ -1,261 +0,0 @@ -# Tsunami AI Builder - V1 Architecture - -## Overview - -A split-screen builder for creating Tsunami applications: chat interface on left, tabbed preview/code/files on right. Users describe what they want, AI edits the code iteratively. - -## UI Layout - -### Left Panel - -- **đŸ’Ŧ Chat** - Conversation with AI - -### Right Panel - -**Top Section - Tabs:** -- **đŸ‘ī¸ Preview** (default) - Live preview of running Tsunami app, updates automatically after successful compilation -- **📝 Code** - Monaco editor for manual edits to app.go -- **📁 Files** - Static assets browser (images, etc) - -**Bottom Section - Build Panel (closable):** -- Shows compilation status and output (like VSCode's terminal panel) -- Displays success messages or errors with line numbers -- Auto-runs after AI edits -- For manual Code tab edits: auto-reruns or user clicks build button -- Can be manually closed/reopened by user - -### Top Bar - -- Current AppTitle (extracted from app.go) -- **Publish** button - Moves draft → published version -- **Revert** button - Copies published → draft (discards draft changes) - -## Version Management - -**Draft mode**: Auto-saved on every edit, persists when builder closes -**Published version**: What runs in main Wave Terminal, only updates on explicit "Publish" - -Flow: - -1. Edit in builder (always editing draft) -2. Click "Publish" when ready (copies draft → published) -3. Continue editing draft OR click "Revert" to abandon changes - -## Context Structure - -Every AI request includes: - -``` -[System Instructions] - - General system prompt - - Full system.md (Tsunami framework guide) - -[Conversation History] - - Recent messages (with prompt caching) - -[Current Context] (injected fresh each turn, removed from previous turns) - - Current app.go content - - Compilation results (success or errors with line numbers) - - Static files listing (e.g., "/static/logo.png") -``` - -**Context cleanup**: Old "current context" blocks are removed from previous messages and replaced with "[OLD CONTEXT REMOVED]" to save tokens. Only the latest app.go + compile results stay in context. - -## AI Tools - -### edit_appgo (str_replace) - -**Primary editing tool** - -- `old_str` - Unique string to find in app.go -- `new_str` - Replacement string -- `description` - What this change does - -**Backend behavior**: - -1. Apply string replacement to app.go -2. Immediately run `go build` -3. Return tool result: - - ✓ Success: "Edit applied, compilation successful" - - ✗ Failure: "Edit applied, compilation failed: [error details]" - -AI can make multiple edits in one response, getting compile feedback after each. - -### create_appgo - -**Bootstrap new apps** - -- `content` - Full app.go file content -- Only used for initial app creation or total rewrites - -Same compilation behavior as str_replace. - -### web_search - -**Look up APIs, docs, examples** - -- Implemented via provider backend (OpenAI/Anthropic) -- AI can research before making edits - -### read_file - -**Read user-provided documentation** - -- `path` - Path to file (e.g., "/docs/api-spec.md") -- User can upload docs/examples for AI to reference - -## User Actions (Not AI Tools) - -### Manage Static Assets - -- Upload via drag & drop into Files tab or file picker -- Delete files from Files tab -- Rename files from Files tab -- Appear in `/static/` directory -- Auto-injected into AI context as available files - -### Share Screenshot - -- User clicks "📷 Share preview with AI" button -- Captures current preview state -- Attaches to user's next message -- Useful for debugging layout/visual issues - -### Manual Code Editing - -- User can switch to Code tab -- Edit app.go directly in Monaco editor -- Changes auto-compile -- AI sees manual edits in next chat turn - -## Compilation Pipeline - -After every code change (AI or user): - -``` -1. Write app.go to disk -2. Run: go build app.go -3. Show build output in build panel -4. If success: - - Start/restart app process - - Update preview iframe - - Show success message in build panel -5. If failure: - - Parse error output (line numbers, messages) - - Show error in build panel (bottom of right side) - - Inject into AI context for next turn -``` - -**Auto-retry**: AI can fix its own compilation errors within the same response (up to 3 attempts). - -## Error Handling - -### Compilation Errors - -Shown in build panel at bottom of right side. - -Format for AI: - -``` -COMPILATION FAILED - -Error at line 45: - 43 | func(props TodoProps) any { - 44 | return vdom.H("div", nil -> 45 | vdom.H("span", nil, "test") - | ^ missing closing parenthesis - 46 | ) - -Message: expected ')', found 'vdom' -``` - -### Runtime Errors - -- Shown in preview tab (not errors panel) -- User can screenshot and report to AI -- Not auto-injected (v1 simplification) - -### Linting (Future) - -- Could add custom Tsunami-specific linting -- Would inject warnings alongside compile results -- Not required for v1 - -## Secrets/Configuration - -Apps can declare secrets using Tsunami's ConfigAtom: - -```go -var apiKeyAtom = app.ConfigAtom("api_key", "", &app.AtomMeta{ - Desc: "OpenAI API Key", - Secret: true, -}) -``` - -Builder detects these and shows input fields in UI for user to fill in. - -## Conversation Limits - -**V1 approach**: No summarization, no smart handling. - -When context limit hit: Show message "You've hit the conversation limit. Click 'Start Fresh' to continue editing this app in a new chat." - -Starting fresh uses current app.go as the beginning state. - -## Token Optimization - -- System.md + early messages benefit from prompt caching -- Only pay per-turn for: current app.go + new messages -- Old context blocks removed to prevent bloat -- Estimated: 10-20k tokens per turn (very manageable) - -## Example Flow - -``` -User: "Create a counter app" -AI: [calls create_appgo with full counter app] -Backend: ✓ Compiled successfully -Preview: Shows counter app - -User: "Add a reset button" -AI: [calls str_replace to add reset button] -Backend: ✓ Compiled successfully -Preview: Updates with reset button - -User: "Make buttons bigger" -AI: [calls str_replace to update button classes] -Backend: ✓ Compiled successfully -Preview: Updates with larger buttons - -User: [switches to Code tab, tweaks color manually] -Backend: ✓ Compiled successfully -Preview: Updates - -User: "Add a chart showing count over time" -AI: [calls web_search for "go charting library"] -AI: [calls str_replace to add chart] -Backend: ✗ Compilation failed - missing import -AI: [calls str_replace to add import] -Backend: ✓ Compiled successfully -Preview: Shows chart -``` - -## Out of Scope (V1) - -- Version history / snapshots -- Multiple files / project structure -- Collaboration / sharing -- Advanced linting -- Runtime error auto-injection -- Conversation summarization -- Component-specific editing tools - -These can be added in v2+ based on user feedback. - -## Success Criteria - -- User can create functional Tsunami app through chat in <5 minutes -- AI successfully fixes its own compilation errors 80%+ of the time -- Iteration cycle (message → edit → preview) takes <10 seconds -- Users can publish working apps to Wave Terminal -- Draft state persists across sessions diff --git a/aiprompts/usechat-backend-design.md b/aiprompts/usechat-backend-design.md deleted file mode 100644 index f5793718c1..0000000000 --- a/aiprompts/usechat-backend-design.md +++ /dev/null @@ -1,463 +0,0 @@ -# useChat Compatible Backend Design for Wave Terminal - -## Overview - -This document outlines how to create a `useChat()` compatible backend API using Go and Server-Sent Events (SSE) to replace the current complex RPC-based AI chat system. The goal is to leverage Vercel AI SDK's `useChat()` hook while maintaining all existing AI provider functionality. - -## Current vs Target Architecture - -### Current Architecture -``` -Frontend (React) → Custom RPC → Go Backend → AI Providers -- 10+ Jotai atoms for state management -- Custom WaveAIStreamRequest/WaveAIPacketType -- Complex configuration merging in frontend -- Custom streaming protocol over WebSocket -``` - -### Target Architecture -``` -Frontend (useChat) → HTTP/SSE → Go Backend → AI Providers -- Single useChat() hook manages all state -- Standard HTTP POST + SSE streaming -- Backend-driven configuration resolution -- Standard AI SDK streaming format -``` - -## API Design - -### 1. Endpoint Structure - -**Chat Streaming Endpoint:** -``` -POST /api/ai/chat/{blockId}?preset={presetKey} -``` - -**Conversation Persistence Endpoints:** -``` -POST /api/ai/conversations/{blockId} # Save conversation -GET /api/ai/conversations/{blockId} # Load conversation -``` - -**Why this approach:** -- `blockId`: Identifies the conversation context (existing Wave concept) -- `preset`: URL parameter for AI configuration preset -- **Separate persistence**: Clean separation of streaming vs storage -- **Fast localhost calls**: Frontend can call both endpoints quickly -- **Simple backend**: Each endpoint has single responsibility - -### 2. Request Format & Message Flow - -**Simplified Approach:** -- Frontend manages **entire conversation state** (like all modern chat apps) -- Frontend sends **complete message history** with each request -- Backend just processes the messages and streams response -- Frontend handles persistence via existing Wave file system - -**Standard useChat() Request:** -```json -{ - "messages": [ - { - "id": "msg-1", - "role": "user", - "content": "Hello world" - }, - { - "id": "msg-2", - "role": "assistant", - "content": "Hi there!" - }, - { - "id": "msg-3", - "role": "user", - "content": "How are you?" // <- NEW message user just typed - } - ] -} -``` - -**Backend Processing:** -1. **Receive complete conversation** from frontend -2. **Resolve AI configuration** (preset, model, etc.) -3. **Send messages directly** to AI provider -4. **Stream response** back to frontend -5. **Frontend calls separate persistence endpoint** when needed - -**Optional Extensions:** -```json -{ - "messages": [...], - "options": { - "temperature": 0.7, - "maxTokens": 1000, - "model": "gpt-4" // Override preset model - } -} -``` - -### 3. Configuration Resolution - -**Priority Order (backend resolves):** -1. **Request options** (highest priority) -2. **URL preset parameter** -3. **Block metadata** (`block.meta["ai:preset"]`) -4. **Global settings** (`settings["ai:preset"]`) -5. **Default preset** (lowest priority) - -**Backend Logic:** -```go -func resolveAIConfig(blockId, presetKey string, requestOptions map[string]any) (*WaveAIOptsType, error) { - // 1. Load block metadata - block := getBlock(blockId) - blockPreset := block.Meta["ai:preset"] - - // 2. Load global settings - settings := getGlobalSettings() - globalPreset := settings["ai:preset"] - - // 3. Resolve preset hierarchy - finalPreset := presetKey - if finalPreset == "" { - finalPreset = blockPreset - } - if finalPreset == "" { - finalPreset = globalPreset - } - if finalPreset == "" { - finalPreset = "default" - } - - // 4. Load and merge preset config - presetConfig := loadPreset(finalPreset) - - // 5. Apply request overrides - return mergeAIConfig(presetConfig, requestOptions), nil -} -``` - -### 4. Response Format (SSE) - -**Key Insight: Minimal Conversion** -Most AI providers (OpenAI, Anthropic) already return SSE streams. Instead of converting to our custom format and back, we can **proxy/transform** their streams directly to useChat format. - -**Headers:** -``` -Content-Type: text/event-stream -Cache-Control: no-cache -Connection: keep-alive -Access-Control-Allow-Origin: * -``` - -**useChat Expected Format:** -``` -data: {"type":"text","text":"Hello"} - -data: {"type":"text","text":" world"} - -data: {"type":"text","text":"!"} - -data: {"type":"finish","finish_reason":"stop","usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13}} - -data: [DONE] -``` - -**Provider Stream Transformation:** -- **OpenAI**: Already SSE → direct proxy (no conversion needed) -- **Anthropic**: Already SSE → direct proxy (minimal field mapping) -- **Google**: Already streaming → direct proxy -- **Perplexity**: OpenAI-compatible → direct proxy -- **Wave Cloud**: WebSocket → **requires conversion** (only one needing transformation) - -**Error Format:** -``` -data: {"type":"error","error":"API key invalid"} - -data: [DONE] -``` - -## Implementation Plan - -### Phase 1: HTTP Handler - -```go -// Simplified approach: Direct provider streaming with minimal transformation -func (s *WshServer) HandleAIChat(w http.ResponseWriter, r *http.Request) { - // 1. Parse URL parameters - blockId := mux.Vars(r)["blockId"] - presetKey := r.URL.Query().Get("preset") - - // 2. Parse request body - var req struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` - Options map[string]any `json:"options,omitempty"` - } - json.NewDecoder(r.Body).Decode(&req) - - // 3. Resolve configuration - aiOpts, err := resolveAIConfig(blockId, presetKey, req.Options) - if err != nil { - http.Error(w, err.Error(), 400) - return - } - - // 4. Set SSE headers - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - - // 5. Route to provider and stream directly - switch aiOpts.APIType { - case "openai", "perplexity": - // Direct proxy - these are already SSE compatible - streamDirectSSE(w, r.Context(), aiOpts, req.Messages) - case "anthropic": - // Direct proxy with minimal field mapping - streamAnthropicSSE(w, r.Context(), aiOpts, req.Messages) - case "google": - // Direct proxy - streamGoogleSSE(w, r.Context(), aiOpts, req.Messages) - default: - // Wave Cloud - only one requiring conversion (WebSocket → SSE) - if isCloudAIRequest(aiOpts) { - streamWaveCloudToUseChat(w, r.Context(), aiOpts, req.Messages) - } else { - http.Error(w, "Unsupported provider", 400) - } - } -} - -// Example: Direct OpenAI streaming (minimal conversion) -func streamOpenAIToUseChat(w http.ResponseWriter, ctx context.Context, opts *WaveAIOptsType, messages []Message) { - client := openai.NewClient(opts.APIToken) - - stream, err := client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ - Model: opts.Model, - Messages: convertToOpenAIMessages(messages), - Stream: true, - }) - if err != nil { - fmt.Fprintf(w, "data: {\"type\":\"error\",\"error\":%q}\n\n", err.Error()) - fmt.Fprintf(w, "data: [DONE]\n\n") - return - } - defer stream.Close() - - for { - response, err := stream.Recv() - if errors.Is(err, io.EOF) { - fmt.Fprintf(w, "data: [DONE]\n\n") - return - } - if err != nil { - fmt.Fprintf(w, "data: {\"type\":\"error\",\"error\":%q}\n\n", err.Error()) - fmt.Fprintf(w, "data: [DONE]\n\n") - return - } - - // Direct transformation: OpenAI format → useChat format - for _, choice := range response.Choices { - if choice.Delta.Content != "" { - fmt.Fprintf(w, "data: {\"type\":\"text\",\"text\":%q}\n\n", choice.Delta.Content) - } - if choice.FinishReason != "" { - fmt.Fprintf(w, "data: {\"type\":\"finish\",\"finish_reason\":%q}\n\n", choice.FinishReason) - } - } - - w.(http.Flusher).Flush() - } -} - -// Wave Cloud conversion (only provider needing transformation) -func streamWaveCloudToUseChat(w http.ResponseWriter, ctx context.Context, opts *WaveAIOptsType, messages []Message) { - // Use existing Wave Cloud WebSocket logic - waveReq := wshrpc.WaveAIStreamRequest{ - Opts: opts, - Prompt: convertMessagesToPrompt(messages), - } - - stream := waveai.RunAICommand(ctx, waveReq) // Returns WebSocket stream - - // Convert Wave Cloud packets to useChat SSE format - for packet := range stream { - if packet.Error != nil { - fmt.Fprintf(w, "data: {\"type\":\"error\",\"error\":%q}\n\n", packet.Error.Error()) - break - } - - resp := packet.Response - if resp.Text != "" { - fmt.Fprintf(w, "data: {\"type\":\"text\",\"text\":%q}\n\n", resp.Text) - } - if resp.FinishReason != "" { - usage := "" - if resp.Usage != nil { - usage = fmt.Sprintf(",\"usage\":{\"prompt_tokens\":%d,\"completion_tokens\":%d,\"total_tokens\":%d}", - resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens) - } - fmt.Fprintf(w, "data: {\"type\":\"finish\",\"finish_reason\":%q%s}\n\n", resp.FinishReason, usage) - } - - w.(http.Flusher).Flush() - } - - fmt.Fprintf(w, "data: [DONE]\n\n") -} -``` - -### Phase 2: Frontend Integration - -```typescript -import { useChat } from '@ai-sdk/react'; - -function WaveAI({ blockId }: { blockId: string }) { - // Get current preset from block metadata or settings - const preset = useAtomValue(currentPresetAtom); - - const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({ - api: `/api/ai/chat/${blockId}?preset=${preset}`, - initialMessages: [], // Load from existing aidata file - onFinish: (message) => { - // Save conversation to aidata file - saveConversation(blockId, messages); - } - }); - - return ( -
-
- {messages.map(message => ( -
- -
- ))} - {isLoading && } - {error &&
{error.message}
} -
- -
- -
-
- ); -} -``` - -### Phase 3: Advanced Features - -#### Multi-modal Support -```typescript -// useChat supports multi-modal out of the box -const { messages, append } = useChat({ - api: `/api/ai/chat/${blockId}`, -}); - -// Send image + text -await append({ - role: 'user', - content: [ - { type: 'text', text: 'What do you see in this image?' }, - { type: 'image', image: imageFile } - ] -}); -``` - -#### Thinking Models -```go -// Backend detects thinking models and formats appropriately -if isThinkingModel(aiOpts.Model) { - // Send thinking content separately - fmt.Fprintf(w, "data: {\"type\":\"thinking\",\"text\":%q}\n\n", thinkingText) - fmt.Fprintf(w, "data: {\"type\":\"text\",\"text\":%q}\n\n", responseText) -} -``` - -#### Context Injection -```typescript -// Add system messages or context via useChat options -const { messages, append } = useChat({ - api: `/api/ai/chat/${blockId}`, - initialMessages: [ - { - role: 'system', - content: 'You are a helpful terminal assistant...' - } - ] -}); -``` - -## Migration Strategy - -### 1. Parallel Implementation -- Keep existing RPC system running -- Add new HTTP/SSE endpoint alongside -- Feature flag to switch between systems - -### 2. Gradual Migration -- Start with new blocks using useChat -- Migrate existing conversations on first interaction -- Remove RPC system once stable - -### 3. Backward Compatibility -- Existing aidata files work unchanged -- Same provider backends (OpenAI, Anthropic, etc.) -- Same configuration system - -## Benefits - -### Complexity Reduction -- **Frontend**: ~900 lines → ~100 lines (90% reduction) -- **State Management**: 10+ atoms → 1 useChat hook -- **Configuration**: Frontend merging → Backend resolution -- **Streaming**: Custom protocol → Standard SSE - -### Modern Features -- **Multi-modal**: Images, files, audio support -- **Thinking Models**: Built-in reasoning trace support -- **Conversation Management**: Edit, retry, branch conversations -- **Error Handling**: Automatic retry and error boundaries -- **Performance**: Optimized streaming and batching - -### Developer Experience -- **Type Safety**: Full TypeScript support -- **Testing**: Standard HTTP endpoints easier to test -- **Debugging**: Standard browser dev tools work -- **Documentation**: Leverage AI SDK docs and community - -## Configuration Examples - -### URL-based Configuration -``` -POST /api/ai/chat/block-123?preset=claude-coding -POST /api/ai/chat/block-456?preset=gpt4-creative -``` - -### Header-based Overrides -``` -POST /api/ai/chat/block-123 -X-AI-Model: gpt-4-turbo -X-AI-Temperature: 0.8 -``` - -### Request Body Options -```json -{ - "messages": [...], - "options": { - "model": "claude-3-sonnet", - "temperature": 0.7, - "maxTokens": 2000 - } -} -``` - -This design maintains all existing functionality while dramatically simplifying the implementation and adding modern AI chat capabilities. \ No newline at end of file diff --git a/aiprompts/view-prompt.md b/aiprompts/view-prompt.md deleted file mode 100644 index b88ba17bff..0000000000 --- a/aiprompts/view-prompt.md +++ /dev/null @@ -1,233 +0,0 @@ -# Wave Terminal ViewModel Guide - -## Overview - -Wave Terminal uses a modular ViewModel system to define interactive blocks. Each block has a **ViewModel**, which manages its metadata, configuration, and state using **Jotai atoms**. The ViewModel also specifies a **React component (ViewComponent)** that renders the block. - -### Key Concepts - -1. **ViewModel Structure** - - Implements the `ViewModel` interface. - - Defines: - - `viewType`: Unique block type identifier. - - `viewIcon`, `viewName`, `viewText`: Atoms for UI metadata. - - `preIconButton`, `endIconButtons`: Atoms for action buttons. - - `blockBg`: Atom for background styling. - - `manageConnection`, `noPadding`, `searchAtoms`. - - `viewComponent`: React component rendering the block. - - Lifecycle methods like `dispose()`, `giveFocus()`, `keyDownHandler()`. - -2. **ViewComponent Structure** - - A **React function component** implementing `ViewComponentProps`. - - Uses `blockId`, `blockRef`, `contentRef`, and `model` as props. - - Retrieves ViewModel state using Jotai atoms. - - Returns JSX for rendering. - -3. **Header Elements (`HeaderElem[]`)** - - Can include: - - **Icons (`IconButtonDecl`)**: Clickable buttons. - - **Text (`HeaderText`)**: Metadata or status. - - **Inputs (`HeaderInput`)**: Editable fields. - - **Menu Buttons (`MenuButton`)**: Dropdowns. - -4. **Jotai Atoms for State Management** - - Use `atom`, `PrimitiveAtom`, `WritableAtom` for dynamic properties. - - `splitAtom` for managing lists of atoms. - - Read settings from `globalStore` and override with block metadata. - -5. **Metadata vs. Global Config** - - **Block Metadata (`SetMetaCommand`)**: Each block persists its **own configuration** in its metadata (`blockAtom.meta`). - - **Global Config (`SetConfigCommand`)**: Provides **default settings** for all blocks, stored in config files. - - **Cascading Behavior**: - - Blocks first check their **own metadata** for settings. - - If no override exists, they **fall back** to global config. - - Updating a block's setting is done via `SetMetaCommand` (persisted per block). - - Updating a global setting is done via `SetConfigCommand` (applies globally unless overridden). - -6. **Useful Helper Functions** - - To avoid repetitive boilerplate, use these global utilities from `global.ts`: - - `useBlockMetaKeyAtom(blockId, key)`: Retrieves and updates block-specific metadata. - - `useOverrideConfigAtom(blockId, key)`: Reads from global config but allows per-block overrides. - - `useSettingsKeyAtom(key)`: Accesses global settings efficiently. - -7. **Styling** - - Use TailWind CSS to style components - - Accent color is: text-accent, for a 50% transparent accent background use bg-accentbg - - Hover background is: bg-hoverbg - - Border color is "border", so use border-border - - Colors are also defined for error, warning, and success (text-error, text-warning, text-sucess) - -## Relevant TypeScript Types - -```typescript -type ViewComponentProps = { - blockId: string; - blockRef: React.RefObject; - contentRef: React.RefObject; - model: T; -}; - -type ViewComponent = React.FC>; - -interface ViewModel { - viewType: string; - viewIcon?: jotai.Atom; - viewName?: jotai.Atom; - viewText?: jotai.Atom; - preIconButton?: jotai.Atom; - endIconButtons?: jotai.Atom; - blockBg?: jotai.Atom; - manageConnection?: jotai.Atom; - noPadding?: jotai.Atom; - searchAtoms?: SearchAtoms; - viewComponent: ViewComponent; - dispose?: () => void; - giveFocus?: () => boolean; - keyDownHandler?: (e: WaveKeyboardEvent) => boolean; -} - -interface IconButtonDecl { - elemtype: "iconbutton"; - icon: string | React.ReactNode; - click?: (e: React.MouseEvent) => void; -} -type HeaderElem = - | IconButtonDecl - | ToggleIconButtonDecl - | HeaderText - | HeaderInput - | HeaderDiv - | HeaderTextButton - | ConnectionButton - | MenuButton; - -type IconButtonCommon = { - icon: string | React.ReactNode; - iconColor?: string; - iconSpin?: boolean; - className?: string; - title?: string; - disabled?: boolean; - noAction?: boolean; -}; - -type IconButtonDecl = IconButtonCommon & { - elemtype: "iconbutton"; - click?: (e: React.MouseEvent) => void; - longClick?: (e: React.MouseEvent) => void; -}; - -type ToggleIconButtonDecl = IconButtonCommon & { - elemtype: "toggleiconbutton"; - active: jotai.WritableAtom; -}; - -type HeaderTextButton = { - elemtype: "textbutton"; - text: string; - className?: string; - title?: string; - onClick?: (e: React.MouseEvent) => void; -}; - -type HeaderText = { - elemtype: "text"; - text: string; - ref?: React.RefObject; - className?: string; - noGrow?: boolean; - onClick?: (e: React.MouseEvent) => void; -}; - -type HeaderInput = { - elemtype: "input"; - value: string; - className?: string; - isDisabled?: boolean; - ref?: React.RefObject; - onChange?: (e: React.ChangeEvent) => void; - onKeyDown?: (e: React.KeyboardEvent) => void; - onFocus?: (e: React.FocusEvent) => void; - onBlur?: (e: React.FocusEvent) => void; -}; - -type HeaderDiv = { - elemtype: "div"; - className?: string; - children: HeaderElem[]; - onMouseOver?: (e: React.MouseEvent) => void; - onMouseOut?: (e: React.MouseEvent) => void; - onClick?: (e: React.MouseEvent) => void; -}; - -type ConnectionButton = { - elemtype: "connectionbutton"; - icon: string; - text: string; - iconColor: string; - onClick?: (e: React.MouseEvent) => void; - connected: boolean; -}; - -type MenuItem = { - label: string; - icon?: string | React.ReactNode; - subItems?: MenuItem[]; - onClick?: (e: React.MouseEvent) => void; -}; - -type MenuButtonProps = { - items: MenuItem[]; - className?: string; - text: string; - title?: string; - menuPlacement?: Placement; -}; - -type MenuButton = { - elemtype: "menubutton"; -} & MenuButtonProps; -``` - -## Minimal "Hello World" Example - -This example defines a simple ViewModel and ViewComponent for a block that displays "Hello, World!". - -```typescript -import * as jotai from "jotai"; -import React from "react"; - -class HelloWorldModel implements ViewModel { - viewType = "helloworld"; - viewIcon = jotai.atom("smile"); - viewName = jotai.atom("Hello World"); - viewText = jotai.atom("A simple greeting block"); - viewComponent = HelloWorldView; -} - -const HelloWorldView: ViewComponent = ({ model }) => { - return
Hello, World!
; -}; - -export { HelloWorldModel }; - -``` - -## Instructions to AI - -1. Generate a new **ViewModel** class for a block, following the structure above. -2. Generate a corresponding **ViewComponent**. -3. Use **Jotai atoms** to store all dynamic state. -4. Ensure the ViewModel defines **header elements** (`viewText`, `viewIcon`, `endIconButtons`). -5. Export the view model (to be registered in the BlockRegistry) -6. Use existing metadata patterns for config and settings. - -## Other Notes - -- The types you see above don't need to be imported, they are global types (custom.d.ts) - -**Output Format:** - -- TypeScript code defining the **ViewModel**. -- TypeScript code defining the **ViewComponent**. -- Ensure alignment with the patterns in `waveai.tsx`, `preview.tsx`, `sysinfo.tsx`, and `term.tsx`. diff --git a/aiprompts/wave-osc-16162.md b/aiprompts/wave-osc-16162.md deleted file mode 100644 index fe9c8c8352..0000000000 --- a/aiprompts/wave-osc-16162.md +++ /dev/null @@ -1,215 +0,0 @@ -# Wave Terminal OSC 16162 Escape Sequences - -Wave Terminal uses a custom OSC (Operating System Command) escape sequence numbered **16162** for shell integration. This allows the shell to communicate its state and events to the terminal. - -## Format - -All commands use this escape sequence format: - -``` -ESC ] 16162 ; command [;] BEL -``` - -Where: -- `ESC` = `\033` (escape character) -- `BEL` = `\007` (bell character) -- `command` = Single letter (A, C, M, D, I, or R) -- `` = Optional JSON payload (depends on command) - -## Commands - -### A - Prompt Start - -Marks the beginning of a new shell prompt. - -**Format:** `A` - -**When:** Sent in `precmd` hook (after previous command completes, before new prompt is displayed) - -**Purpose:** Signals to the terminal that a new prompt is being drawn. This helps Wave Terminal distinguish between prompt output and command output. - -**Example:** -```bash -printf '\033]16162;A\007' -``` - ---- - -### C - Command Execution - -Sent immediately before a command is executed, optionally including the command text. - -**Format:** `C[;]` - -**Data Type:** -```typescript -{ - cmd64?: string; // base64-encoded command text -} -``` - -**When:** Sent in `preexec` hook (after user presses Enter, before command runs) - -**Purpose:** Notifies the terminal that a command is about to execute. The command text is base64-encoded to handle special characters safely. - -**Example:** -```bash -cmd64=$(printf '%s' "ls -la" | base64) -printf '\033]16162;C;{"cmd64":"%s"}\007' "$cmd64" -``` - ---- - -### M - Metadata - -Sends shell metadata information (typically only once at shell initialization). - -**Format:** `M;` - -**Data Type:** -```typescript -{ - shell?: string; // Shell name (e.g., "zsh", "bash") - shellversion?: string; // Version string of the shell - uname?: string; // Output of "uname -smr" (e.g., "Darwin 23.0.0 arm64") - integration?: boolean; // Whether shell integration is active (true) or disabled (false) -} -``` - -**When:** Sent during first `precmd` hook (on shell startup) - -**Purpose:** Provides Wave Terminal with information about the shell environment and operating system. - -**Example:** -```bash -uname_info=$(uname -smr 2>/dev/null) -printf '\033]16162;M;{"shell":"zsh","shellversion":"5.9","uname":"%s"}\007' "$uname_info" -``` - ---- - -### D - Done (Exit Status) - -Reports the exit status of the previously executed command. - -**Format:** `D;` - -**Data Type:** -```typescript -{ - exitcode?: number; // Exit status code of the previous command -} -``` - -**When:** Sent in `precmd` hook (after command completes) - -**Purpose:** Communicates whether the previous command succeeded or failed, allowing Wave Terminal to display success/failure indicators. - -**Example:** -```bash -# After command exits with status 0 -printf '\033]16162;D;{"exitcode":0}\007' - -# After command exits with status 1 -printf '\033]16162;D;{"exitcode":1}\007' -``` - ---- - -### I - Input Status - -Reports the current state of the command line input buffer. - -**Format:** `I;` - -**Data Type:** -```typescript -{ - inputempty?: boolean; // Whether the command line buffer is empty -} -``` - -**When:** Sent during ZLE (Zsh Line Editor) hooks when buffer state changes -- `zle-line-init` - When line editor is initialized -- `zle-line-pre-redraw` - Before line is redrawn - -**Purpose:** Allows Wave Terminal to track the state of the command line input. Currently reports whether the buffer is empty, but may be extended to include additional input state information in the future. - -**Example:** -```bash -# When buffer is empty -I;{"inputempty":true} - -# When buffer has content -I;{"inputempty":false} -``` - -### R - Reset Alternate Buffer - -Resets the terminal if it's in alternate buffer mode. - -**Format:** `R` - -**When:** Can be sent at any time to ensure terminal is not stuck in alternate buffer mode - -**Purpose:** If the terminal is currently displaying the alternate screen buffer, this command switches back to the normal buffer. This is useful for recovering from programs that crash without properly restoring the screen. - -**Behavior:** -- Checks if terminal is in alternate buffer mode (`terminal.buffer.active.type === "alternate"`) -- If in alternate mode, sends `ESC [ ? 1049 l` to exit alternate buffer -- If not in alternate mode, does nothing - -**Example:** -```bash -R -``` - ---- - -## Typical Command Flow - -Here's the typical sequence during shell interaction: - -``` -1. Shell starts - → M; (metadata - shell info) - -2. First prompt appears - → A (prompt start) - -3. User types command and presses Enter - → I;{"inputempty":false} (input no longer empty - sent as user types) - → C;{"cmd64":"..."} (command about to execute) - -4. Command runs and completes - → D;{"exitcode":} (exit status) - → I;{"inputempty":true} (input empty again) - → A (next prompt start) - -5. Repeat from step 3... -``` - -## Implementation Notes - -- Shell integration is **disabled** when running inside tmux or screen (`TMUX`, `STY` environment variables, or `tmux*`/`screen*` TERM values) -- Commands are base64-encoded in the C sequence to safely handle special characters, newlines, and control characters -- The I (input empty) command is only sent when the state changes (not on every keystroke) -- The M (metadata) command is only sent once during the first precmd -- The D (exit status) command is skipped during the first precmd (no previous command to report) - -## Related Files - -- [`pkg/util/shellutil/shellintegration/zsh_zshrc.sh`](pkg/util/shellutil/shellintegration/zsh_zshrc.sh) - Zsh shell integration implementation -- Similar integrations exist for bash and other shells - -## Standard OSC 7 - -Wave Terminal also uses the standard **OSC 7** sequence for reporting the current working directory: - -**Format:** `7;file://` - -This is sent: -- During first precmd (after metadata) -- In the `chpwd` hook (whenever directory changes) - -The path is URL-encoded to safely handle special characters. \ No newline at end of file diff --git a/aiprompts/waveai-architecture.md b/aiprompts/waveai-architecture.md deleted file mode 100644 index 3e070fe750..0000000000 --- a/aiprompts/waveai-architecture.md +++ /dev/null @@ -1,366 +0,0 @@ -# Wave AI Architecture Documentation - -## Overview - -Wave AI is a chat-based AI assistant feature integrated into Wave Terminal. It provides a conversational interface for interacting with various AI providers (OpenAI, Anthropic, Perplexity, Google, and Wave's cloud proxy) through a unified streaming architecture. The feature is implemented as a block view within Wave Terminal's modular system. - -## Architecture Components - -### Frontend Architecture (`frontend/app/view/waveai/`) - -#### Core Components - -**1. WaveAiModel Class** -- **Purpose**: Main view model implementing the `ViewModel` interface -- **Responsibilities**: - - State management using Jotai atoms - - Configuration management (presets, AI options) - - Message handling and persistence - - RPC communication with backend - - UI state coordination - -**2. AiWshClient Class** -- **Purpose**: Specialized WSH RPC client for AI operations -- **Extends**: `WshClient` -- **Responsibilities**: - - Handle incoming `aisendmessage` RPC calls - - Route messages to the model's `sendMessage` method - -**3. React Components** -- **WaveAi**: Main container component -- **ChatWindow**: Scrollable message display with auto-scroll behavior -- **ChatItem**: Individual message renderer with role-based styling -- **ChatInput**: Auto-resizing textarea with keyboard navigation - -#### State Management (Jotai Atoms) - -**Message State**: -```typescript -messagesAtom: PrimitiveAtom> -messagesSplitAtom: SplitAtom> -latestMessageAtom: Atom -addMessageAtom: WritableAtom -updateLastMessageAtom: WritableAtom -removeLastMessageAtom: WritableAtom -``` - -**Configuration State**: -```typescript -presetKey: Atom // Current AI preset selection -presetMap: Atom<{[k: string]: MetaType}> // Available AI presets -mergedPresets: Atom // Merged configuration hierarchy -aiOpts: Atom // Final AI options for requests -``` - -**UI State**: -```typescript -locked: PrimitiveAtom // Prevents input during AI response -viewIcon: Atom // Header icon -viewName: Atom // Header title -viewText: Atom // Dynamic header elements -endIconButtons: Atom // Header action buttons -``` - -#### Configuration Hierarchy - -The AI configuration follows a three-tier hierarchy (lowest to highest priority): -1. **Global Settings**: `atoms.settingsAtom["ai:*"]` -2. **Preset Configuration**: `presets[presetKey]["ai:*"]` -3. **Block Metadata**: `block.meta["ai:*"]` - -Configuration is merged using `mergeMeta()` utility, allowing fine-grained overrides at each level. - -#### Data Flow - Frontend - -``` -User Input → sendMessage() → -├── Add user message to UI -├── Create WaveAIStreamRequest -├── Call RpcApi.StreamWaveAiCommand() -├── Add typing indicator -└── Stream response handling: - ├── Update message incrementally - ├── Handle errors - └── Save complete conversation -``` - -### Backend Architecture (`pkg/waveai/`) - -#### Core Interface - -**AIBackend Interface**: -```go -type AIBackend interface { - StreamCompletion( - ctx context.Context, - request wshrpc.WaveAIStreamRequest, - ) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] -} -``` - -#### Backend Implementations - -**1. OpenAIBackend** (`openaibackend.go`) -- **Providers**: OpenAI, Azure OpenAI, Cloudflare Azure -- **Features**: - - Reasoning model support (o1, o3, o4, gpt-5) - - Proxy support - - Multiple API types (OpenAI, Azure, AzureAD, CloudflareAzure) -- **Streaming**: Uses `go-openai` library for SSE streaming - -**2. AnthropicBackend** (`anthropicbackend.go`) -- **Provider**: Anthropic Claude -- **Features**: - - Custom SSE parser for Anthropic's event format - - System message handling - - Usage token tracking -- **Events**: `message_start`, `content_block_delta`, `message_stop`, etc. - -**3. WaveAICloudBackend** (`cloudbackend.go`) -- **Provider**: Wave's cloud proxy service -- **Transport**: WebSocket connection to Wave cloud -- **Features**: - - Fallback when no API token/baseURL provided - - Built-in rate limiting and abuse protection - -**4. PerplexityBackend** (`perplexitybackend.go`) -- **Provider**: Perplexity AI -- **Implementation**: Similar to OpenAI backend - -**5. GoogleBackend** (`googlebackend.go`) -- **Provider**: Google AI (Gemini) -- **Implementation**: Custom integration for Google's API - -#### Backend Routing Logic - -```go -func RunAICommand(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { - // Route based on request.Opts.APIType: - switch request.Opts.APIType { - case "anthropic": - backend = AnthropicBackend{} - case "perplexity": - backend = PerplexityBackend{} - case "google": - backend = GoogleBackend{} - default: - if IsCloudAIRequest(request.Opts) { - backend = WaveAICloudBackend{} - } else { - backend = OpenAIBackend{} - } - } - return backend.StreamCompletion(ctx, request) -} -``` - -### RPC Communication Layer - -#### WSH RPC Integration - -**Command**: `streamwaveai` -**Type**: Response Stream (one request, multiple responses) - -**Request Type** (`WaveAIStreamRequest`): -```go -type WaveAIStreamRequest struct { - ClientId string `json:"clientid,omitempty"` - Opts *WaveAIOptsType `json:"opts"` - Prompt []WaveAIPromptMessageType `json:"prompt"` -} -``` - -**Response Type** (`WaveAIPacketType`): -```go -type WaveAIPacketType struct { - Type string `json:"type"` - Model string `json:"model,omitempty"` - Created int64 `json:"created,omitempty"` - FinishReason string `json:"finish_reason,omitempty"` - Usage *WaveAIUsageType `json:"usage,omitempty"` - Index int `json:"index,omitempty"` - Text string `json:"text,omitempty"` - Error string `json:"error,omitempty"` -} -``` - -#### Configuration Types - -**AI Options** (`WaveAIOptsType`): -```go -type WaveAIOptsType struct { - Model string `json:"model"` - APIType string `json:"apitype,omitempty"` - APIToken string `json:"apitoken"` - OrgID string `json:"orgid,omitempty"` - APIVersion string `json:"apiversion,omitempty"` - BaseURL string `json:"baseurl,omitempty"` - ProxyURL string `json:"proxyurl,omitempty"` - MaxTokens int `json:"maxtokens,omitempty"` - MaxChoices int `json:"maxchoices,omitempty"` - TimeoutMs int `json:"timeoutms,omitempty"` -} -``` - -### Data Persistence - -#### Chat History Storage - -**Frontend**: -- **Method**: `fetchWaveFile(blockId, "aidata")` -- **Format**: JSON array of `WaveAIPromptMessageType` -- **Sliding Window**: Last 30 messages (`slidingWindowSize = 30`) - -**Backend**: -- **Service**: `BlockService.SaveWaveAiData(blockId, history)` -- **Storage**: Block-associated file storage -- **Persistence**: Automatic save after each complete exchange - -#### Message Format - -**UI Messages** (`ChatMessageType`): -```typescript -interface ChatMessageType { - id: string; - user: string; // "user" | "assistant" | "error" - text: string; - isUpdating?: boolean; -} -``` - -**Stored Messages** (`WaveAIPromptMessageType`): -```go -type WaveAIPromptMessageType struct { - Role string `json:"role"` // "user" | "assistant" | "system" | "error" - Content string `json:"content"` - Name string `json:"name,omitempty"` -} -``` - -### Error Handling - -#### Frontend Error Handling - -1. **Network Errors**: Caught in streaming loop, displayed as error messages -2. **Empty Responses**: Automatically remove typing indicator -3. **Cancellation**: User can cancel via stop button (`model.cancel = true`) -4. **Partial Responses**: Saved even if incomplete due to errors - -#### Backend Error Handling - -1. **Panic Recovery**: All backends use `panichandler.PanicHandler()` -2. **Context Cancellation**: Proper cleanup on request cancellation -3. **Provider Errors**: Wrapped and forwarded to frontend -4. **Connection Errors**: Detailed error messages for debugging - -### UI Features - -#### Message Rendering - -- **Markdown Support**: Full markdown rendering with syntax highlighting -- **Role-based Styling**: Different colors/layouts for user/assistant/error messages -- **Typing Indicator**: Animated dots during AI response -- **Font Configuration**: Configurable font sizes via presets - -#### Input Handling - -- **Auto-resize**: Textarea grows/shrinks with content (max 5 lines) -- **Keyboard Navigation**: - - Enter to send - - Cmd+L to clear history - - Arrow keys for code block selection -- **Code Block Selection**: Navigate through code blocks in responses - -#### Scroll Management - -- **Auto-scroll**: Automatically scrolls to new messages -- **User Scroll Detection**: Pauses auto-scroll when user manually scrolls -- **Smart Resume**: Resumes auto-scroll when near bottom - -### Configuration Management - -#### Preset System - -**Preset Structure**: -```json -{ - "ai@preset-name": { - "display:name": "Preset Display Name", - "display:order": 1, - "ai:model": "gpt-4", - "ai:apitype": "openai", - "ai:apitoken": "sk-...", - "ai:baseurl": "https://api.openai.com/v1", - "ai:maxtokens": 4000, - "ai:fontsize": "14px", - "ai:fixedfontsize": "12px" - } -} -``` - -**Configuration Keys**: -- `ai:model` - AI model name -- `ai:apitype` - Provider type (openai, anthropic, perplexity, google) -- `ai:apitoken` - API authentication token -- `ai:baseurl` - Custom API endpoint -- `ai:proxyurl` - HTTP proxy URL -- `ai:maxtokens` - Maximum response tokens -- `ai:timeoutms` - Request timeout -- `ai:fontsize` - UI font size -- `ai:fixedfontsize` - Code block font size - -#### Provider Detection - -The UI automatically detects and displays the active provider: - -- **Cloud**: Wave's proxy (no token/baseURL) -- **Local**: localhost/127.0.0.1 endpoints -- **Remote**: External API endpoints -- **Provider-specific**: Anthropic, Perplexity with custom icons - -### Performance Considerations - -#### Frontend Optimizations - -- **Jotai Atoms**: Granular reactivity, only re-render affected components -- **Memo Components**: `ChatWindow` and `ChatItem` are memoized -- **Throttled Scrolling**: Scroll events throttled to 100ms -- **Debounced Scroll Detection**: User scroll detection debounced to 300ms - -#### Backend Optimizations - -- **Streaming**: All responses are streamed for immediate feedback -- **Context Cancellation**: Proper cleanup prevents resource leaks -- **Connection Pooling**: HTTP clients reuse connections -- **Error Recovery**: Graceful degradation on provider failures - -### Security Considerations - -#### API Token Handling - -- **Storage**: Tokens stored in encrypted configuration -- **Transmission**: Tokens only sent to configured endpoints -- **Validation**: Backend validates token format and permissions - -#### Request Validation - -- **Input Sanitization**: User input validated before sending -- **Rate Limiting**: Cloud backend includes built-in rate limiting -- **Error Filtering**: Sensitive error details filtered from UI - -### Extension Points - -#### Adding New Providers - -1. **Implement AIBackend Interface**: Create new backend struct -2. **Add Provider Detection**: Update `RunAICommand()` routing logic -3. **Add Configuration**: Define provider-specific config keys -4. **Update UI**: Add provider detection in `viewText` atom - -#### Custom Message Types - -1. **Extend ChatMessageType**: Add new user types -2. **Update ChatItem Rendering**: Handle new message types -3. **Modify Storage**: Update persistence format if needed - -This architecture provides a flexible, extensible foundation for AI chat functionality while maintaining clean separation between UI, business logic, and provider integrations. \ No newline at end of file diff --git a/aiprompts/waveai-focus-updates.md b/aiprompts/waveai-focus-updates.md deleted file mode 100644 index b9550c73b0..0000000000 --- a/aiprompts/waveai-focus-updates.md +++ /dev/null @@ -1,742 +0,0 @@ -# Wave Terminal Focus System - Wave AI Integration - -## Problem - -Wave AI focus handling is fragile compared to blocks: - -1. Only watches textarea focus/blur, missing the multi-phase handling that blocks have -2. Selection handling breaks - selecting text causes blur → focus reverts to layout -3. Focus ring flashing - clicking Wave AI briefly shows focus ring on layout -4. Window blur sensitivity - `window.blur()` incorrectly assumes user wants to leave Wave AI -5. No capture phase - missing the immediate visual feedback that blocks get - -## Solution Overview - -Extend the block focus system pattern to Wave AI: - -- Multi-phase handling (capture + click) -- Selection protection -- Focus manager coordination -- View delegation - -## Architecture - -```mermaid -graph TB - User[User Interaction] - FM[Focus Manager] - Layout[Layout System] - WaveAI[Wave AI Panel] - - User -->|click/key| FM - FM -->|node focus| Layout - FM -->|waveai focus| WaveAI - Layout -->|request focus back| FM - WaveAI -->|request focus back| FM - - FM -->|focusType atom| State[Global State] - Layout -.->|checks| State - WaveAI -.->|checks| State -``` - -## Focus Manager Enhancements - -**File**: [`frontend/app/store/focusManager.ts`](frontend/app/store/focusManager.ts) - -Add selection-aware focus methods: - -```typescript -class FocusManager { - // Existing - focusType: PrimitiveAtom<"node" | "waveai">; // Single source of truth - blockFocusAtom: Atom; - - // NEW: Selection-aware focus checking - waveAIFocusWithin(): boolean; - nodeFocusWithin(): boolean; - - // NEW: Focus transitions (INTENTIONALLY not defensive) - requestNodeFocus(): void; // from Wave AI → node (BREAKS selections - that's the point!) - requestWaveAIFocus(): void; // from node → Wave AI - - // NEW: Get current focus type - getFocusType(): FocusStrType; - - // ENHANCED: Smart refocus based on focusType - refocusNode(): void; // already handles both types -} -``` - -**Critical Design Decision: `requestNodeFocus()` is NOT defensive** - -When `requestNodeFocus()` is called (e.g., Cmd+n, explicit focus change), it MUST take focus even if there's a selection in Wave AI. This is intentional - the user explicitly requested a focus change. Losing the selection is the correct behavior. - -**Focus Manager as Source of Truth** - -The `focusType` atom is the single source of truth. The old `waveAIFocusedAtom` will be kept in sync during migration but should eventually be removed. All components should read `focusManager.focusType` directly (via `useAtomValue`) to determine focus ring state - this ensures synchronized, reactive focus ring updates. - -## Wave AI Focus Utilities - -**New File**: [`frontend/app/aipanel/waveai-focus-utils.ts`](frontend/app/aipanel/waveai-focus-utils.ts) - -Similar to [`focusutil.ts`](frontend/util/focusutil.ts) but for Wave AI: - -```typescript -// Find if element is within Wave AI panel -export function findWaveAIPanel(element: HTMLElement): HTMLElement | null { - let current: HTMLElement = element; - while (current) { - if (current.hasAttribute("data-waveai-panel")) { - return current; - } - current = current.parentElement; - } - return null; -} - -// Check if Wave AI panel has focus or selection (like focusedBlockId()) -export function waveAIHasFocusWithin(): boolean { - // Check if activeElement is within Wave AI panel - const focused = document.activeElement; - if (focused instanceof HTMLElement) { - const waveAIPanel = findWaveAIPanel(focused); - if (waveAIPanel) return true; - } - - // Check if selection is within Wave AI panel - const sel = document.getSelection(); - if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) { - let anchor = sel.anchorNode; - if (anchor instanceof Text) { - anchor = anchor.parentElement; - } - if (anchor instanceof HTMLElement) { - const waveAIPanel = findWaveAIPanel(anchor); - if (waveAIPanel) return true; - } - } - - return false; -} - -// Check if there's an active selection in Wave AI -export function waveAIHasSelection(): boolean { - const sel = document.getSelection(); - if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { - return false; - } - - let anchor = sel.anchorNode; - if (anchor instanceof Text) { - anchor = anchor.parentElement; - } - if (anchor instanceof HTMLElement) { - return findWaveAIPanel(anchor) != null; - } - - return false; -} -``` - -## Wave AI Panel Integration - -**File**: [`frontend/app/aipanel/aipanel.tsx`](frontend/app/aipanel/aipanel.tsx) - -Add capture phase and selection protection: - -```typescript -// ADD: Capture phase handler (like blocks) -const handleFocusCapture = useCallback((event: React.FocusEvent) => { - console.log("Wave AI focus capture", getElemAsStr(event.target)); - focusManager.requestWaveAIFocus(); // Sets visual state immediately -}, []); - -// MODIFY: Click handler with selection protection -const handleClick = (e: React.MouseEvent) => { - const target = e.target as HTMLElement; - const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]'); - - if (isInteractive) { - return; - } - - // NEW: Check for selection protection - const hasSelection = waveAIHasSelection(); - if (hasSelection) { - // Just update visual focus, don't move DOM focus - focusManager.requestWaveAIFocus(); - return; - } - - // No selection, safe to move DOM focus - setTimeout(() => { - if (!waveAIHasSelection()) { // Double-check after timeout - model.focusInput(); - } - }, 0); -}; - -// Add data attribute and onFocusCapture to the div -
-``` - -## Wave AI Input Focus Handling - -**File**: [`frontend/app/aipanel/aipanelinput.tsx`](frontend/app/aipanel/aipanelinput.tsx) - -Smart blur handling: - -```typescript -// MODIFY: handleFocus - advisory only -const handleFocus = useCallback(() => { - focusManager.requestWaveAIFocus(); -}, []); - -// MODIFY: handleBlur - simplified with waveAIHasFocusWithin() -const handleBlur = useCallback((e: React.FocusEvent) => { - // Window blur - preserve state - if (e.relatedTarget === null) { - return; - } - - // Still within Wave AI (focus or selection) - don't revert - if (waveAIHasFocusWithin()) { - return; - } - - // Focus truly leaving Wave AI, revert to node focus - focusManager.requestNodeFocus(); -}, []); -``` - -**Note:** `waveAIHasFocusWithin()` checks both: - -1. If `relatedTarget` is within Wave AI panel (handles context menus, buttons) -2. If there's an active selection in Wave AI (handles text selection clicks) - -This combines both checks from the original implementation into a single utility call. - -## Block Focus Integration - -**File**: [`frontend/app/block/block.tsx`](frontend/app/block/block.tsx) - -**No changes needed in block.tsx** - the block code works perfectly as-is! - -**How it works:** - -When a block child gets focus (input field, terminal click, tab navigation): - -``` -1. handleChildFocus fires (capture phase) - ↓ -2. nodeModel.focusNode() - ↓ -3. layoutModel.focusNode(nodeId) - ↓ -4. treeReducer(FocusNodeAction) - ↓ -5. focusManager.requestNodeFocus() (see Layout Focus Coordination section) - ↓ -6. Updates localTreeStateAtom (synchronous) - ↓ -7. isFocused recalculates (sees focusType = "node") - ↓ -8. Two-step effect grants physical DOM focus -``` - -The focus manager update happens automatically in the treeReducer for all focus-claiming operations. - -## Layout Focus Integration - -**File**: [`frontend/layout/lib/layoutModel.ts`](frontend/layout/lib/layoutModel.ts) - -The `isFocused` atom already checks Wave AI state: - -```typescript -isFocused: atom((get) => { - const treeState = get(this.localTreeStateAtom); - const isFocused = treeState.focusedNodeId === nodeid; - const waveAIFocused = get(atoms.waveAIFocusedAtom); - return isFocused && !waveAIFocused; -}); -``` - -**Update to use focus manager:** - -```typescript -isFocused: atom((get) => { - const treeState = get(this.localTreeStateAtom); - const isFocused = treeState.focusedNodeId === nodeid; - const focusType = get(focusManager.focusType); - return isFocused && focusType === "node"; -}); -``` - -This single change coordinates the entire system: - -- Layout can set `focusedNodeId` freely -- The reactive chain runs normally -- But `isFocused` returns `false` if focus manager says "waveai" -- Block's two-step effect doesn't run -- Physical DOM focus stays with Wave AI - -## Layout Focus Coordination - -**File**: [`frontend/layout/lib/layoutModel.ts`](frontend/layout/lib/layoutModel.ts) - -**Critical Integration**: When layout operations claim focus, they must update the focus manager synchronously. - -```typescript -treeReducer(action: LayoutTreeAction, setState = true): boolean { - // Process the action (mutates this.treeState) - switch (action.type) { - case LayoutTreeActionType.InsertNode: - insertNode(this.treeState, action); - // If inserting with focus, claim focus from Wave AI - if ((action as LayoutTreeInsertNodeAction).focused) { - focusManager.requestNodeFocus(); - } - break; - - case LayoutTreeActionType.InsertNodeAtIndex: - insertNodeAtIndex(this.treeState, action); - if ((action as LayoutTreeInsertNodeAtIndexAction).focused) { - focusManager.requestNodeFocus(); - } - break; - - case LayoutTreeActionType.FocusNode: - focusNode(this.treeState, action); - // Explicit focus change always claims focus - focusManager.requestNodeFocus(); - break; - - case LayoutTreeActionType.MagnifyNodeToggle: - magnifyNodeToggle(this.treeState, action); - // Magnifying also focuses the node - focusManager.requestNodeFocus(); - break; - - // ... other cases don't affect focus - } - - if (setState) { - this.updateTree(); - this.setter(this.localTreeStateAtom, { ...this.treeState }); - this.persistToBackend(); - } - - return true; -} -``` - -**Why This Works:** - -1. `focusManager.requestNodeFocus()` updates `focusType` synchronously -2. Called BEFORE atoms commit (still in same function) -3. When `localTreeStateAtom` commits, `isFocused` sees the new `focusType` -4. Both updates happen in same tick → React sees consistent state -5. No race conditions, no flash - -**Order of Operations:** - -``` -Cmd+n pressed - ↓ -treeReducer() executes - ↓ -1. insertNode() mutates layoutState.focusedNodeId -2. focusManager.requestNodeFocus() updates focusType -3. setter(localTreeStateAtom) commits tree state - ↓ -[All synchronous - single call stack] - ↓ -React re-renders with both updates applied - ↓ -isFocused sees: focusedNodeId = newNode AND focusType = "node" - ↓ -Two-step effect grants physical focus -``` - -## Keyboard Navigation Integration - -**File**: [`frontend/app/store/keymodel.ts`](frontend/app/store/keymodel.ts) - -Use focus manager instead of direct atom checks: - -```typescript -function switchBlockInDirection(tabId: string, direction: NavigateDirection) { - const layoutModel = getLayoutModelForTabById(tabId); - const focusType = focusManager.getFocusType(); - - if (direction === NavigateDirection.Left) { - const numBlocks = globalStore.get(layoutModel.numLeafs); - if (focusType === "waveai") { - return; - } - if (numBlocks === 1) { - focusManager.requestWaveAIFocus(); - return; - } - } - - // For right navigation, switch from Wave AI to blocks - if (direction === NavigateDirection.Right && focusType === "waveai") { - focusManager.requestNodeFocus(); - return; - } - - // Rest of navigation logic... -} -``` - -## Focus Flow - -### Complete Flow (Single Tick, No Flash) - -``` -User presses Cmd+n - ↓ -treeReducer() called - ↓ -1. insertNode(focused: true) - SYNCHRONOUS - - layoutState.focusedNodeId = newNode - ↓ -2. setter(localTreeStateAtom, { ...treeState }) - SYNCHRONOUS - - Atom updated immediately - ↓ -3. persistToBackend() - ASYNC (fire-and-forget) - ↓ -[All in same tick - no intermediate renders] - ↓ -React re-renders (batched update) - ↓ -isFocused recalculates: - - get(localTreeStateAtom) → focusedNodeId = newNode ✓ - - get(focusType) → checks current focus type - - Returns TRUE if focusType === "node" - ↓ -useLayoutEffect #1: setBlockClicked(true) - ↓ -useLayoutEffect #2: setFocusTarget() - ↓ -Physical DOM focus granted ✓ -``` - -**Why there's no flash:** - -- Local atoms update synchronously -- React batches the updates -- Everything sees consistent state in one render - -## Edge Cases - -### 1. Window Blur (⌘+Tab to other app) - -- Textarea loses focus, triggers `handleBlur` -- `relatedTarget` is null → detected as window blur -- Focus state preserved - -### 2. Selection in Wave AI - -- User selects text -- Clicks elsewhere in Wave AI -- `waveAIHasSelection()` returns true -- Only visual focus updates, no DOM focus change -- Selection preserved - -### 3. Copy/Paste Context Menu - -- Right-click causes blur -- `relatedTarget` within Wave AI panel -- `handleBlur` detects this, doesn't revert focus - -### 4. Modal Dialogs - -- Modal opens, steals focus -- Modal closes → `globalRefocus()` -- Focus manager restores correct focus based on `focusType` - -## Implementation Steps - -### 1. Focus Manager Foundation - -- Implement enhanced `focusManager.ts` with new methods -- Create `waveai-focus-utils.ts` with selection utilities -- Add data attributes to Wave AI panel - -### 2. Wave AI Integration - -- Add `onFocusCapture` to Wave AI panel -- Update `handleBlur` with simplified `waveAIHasFocusWithin()` check -- Update `handleClick` with selection awareness -- Components read `focusManager.focusType` directly via `useAtomValue` for focus ring display - -### 3. Layout Integration - -- Update `isFocused` atom to check `focusManager.focusType` -- Add `focusManager.requestNodeFocus()` calls in `treeReducer` for focus-claiming operations -- Update keyboard navigation to use `focusManager.getFocusType()` - -### 4. Testing - -- Test all transitions and edge cases -- Verify selection protection works -- Confirm no focus ring flashing -- Verify focus rings are synchronized through focus manager - -## Files to Create/Modify - -### New Files - -- `frontend/app/aipanel/waveai-focus-utils.ts` - Focus utilities for Wave AI - -### Modified Files - -- [`frontend/app/store/focusManager.ts`](frontend/app/store/focusManager.ts) - Enhanced with new methods -- [`frontend/app/aipanel/aipanel.tsx`](frontend/app/aipanel/aipanel.tsx) - Add capture phase, improve click handler -- [`frontend/app/aipanel/aipanelinput.tsx`](frontend/app/aipanel/aipanelinput.tsx) - Smart blur handling -- [`frontend/layout/lib/layoutModel.ts`](frontend/layout/lib/layoutModel.ts) - Update isFocused atom AND add focus manager calls in treeReducer -- [`frontend/app/store/keymodel.ts`](frontend/app/store/keymodel.ts) - Use focus manager for navigation - -## Testing Checklist - -- [ ] Select text in Wave AI, click elsewhere in Wave AI → selection preserved -- [ ] Click Wave AI panel (not input) → focus moves to Wave AI -- [ ] Click block while in Wave AI (no selection) → focus moves to block -- [ ] Press Left arrow in single block → Wave AI focused -- [ ] Press Right arrow in Wave AI → block focused -- [ ] Window blur (⌘+Tab) → focus state preserved -- [ ] Open context menu in Wave AI → doesn't lose focus -- [ ] Modal opens/closes → focus restores correctly - -## Benefits - -1. **Selection protection** - Wave AI selections preserved like blocks -2. **No focus flash** - Capture phase provides immediate visual feedback -3. **Robust blur handling** - Smart detection of where focus is going -4. **Unified model** - Single source of truth simplifies reasoning -5. **Simple reactivity** - Everything updates synchronously in one tick -6. **No timing issues** - Local atoms eliminate race conditions - -## Phased Implementation Approach - -The changes can be broken into safe, independently testable phases. Each phase can be shipped and tested before proceeding to the next. - -### Phase 1: Foundation (Non-Breaking, Fully Testable) - -**Add focus manager methods WITHOUT changing existing code** - -```typescript -// In focusManager.ts - ADD these methods -class FocusManager { - // NEW methods that ALSO update the old waveAIFocusedAtom during migration - requestWaveAIFocus(): void { - globalStore.set(this.focusType, "waveai"); - globalStore.set(atoms.waveAIFocusedAtom, true); // ← Keep old atom in sync during migration! - } - - requestNodeFocus(): void { - // NO defensive checks - when called, we TAKE focus (selections may be lost) - globalStore.set(this.focusType, "node"); - globalStore.set(atoms.waveAIFocusedAtom, false); // ← Keep old atom in sync during migration! - } - - getFocusType(): FocusStrType { - return globalStore.get(this.focusType); - } - - waveAIFocusWithin(): boolean { - return waveAIHasFocusWithin(); - } - - nodeFocusWithin(): boolean { - return focusedBlockId() != null; - } -} -``` - -**Why this is safe:** - -- Doesn't change any existing code -- Focus manager updates BOTH new `focusType` AND old `waveAIFocusedAtom` during migration -- Everything keeps working exactly as before -- Can test focus manager methods in isolation -- Components can read `focusType` directly via `useAtomValue` for reactive updates -- No user-visible changes - -**Testing:** - -- Call the new methods manually in console -- Verify both atoms update correctly -- Verify existing focus behavior unchanged - ---- - -### Phase 2: Wave AI Improvements (Testable in Isolation) - -**Add utilities and improve Wave AI focus handling** - -1. Create `waveai-focus-utils.ts` with selection checking utilities -2. Update `aipanel.tsx`: - - Add `data-waveai-panel` attribute - - Add `onFocusCapture` handler - - Improve click handler with selection protection - - Call `focusManager.requestWaveAIFocus()` instead of setting atom directly -3. Update `aipanelinput.tsx`: - - Smart blur handling with selection checks - - Call `focusManager.requestNodeFocus()` instead of setting atom directly - -**Why this is safe:** - -- Wave AI now uses focus manager, but focus manager keeps old atom in sync -- Blocks still read `waveAIFocusedAtom` directly - still works! -- Can test Wave AI selection protection independently -- If there's a bug, only Wave AI is affected -- Blocks remain completely unchanged - -**Testing:** - -- Wave AI selection preservation when clicking within panel -- Wave AI blur handling (window blur, context menus, etc.) -- Verify blocks still work normally (unchanged) -- Test transitions between Wave AI and blocks - -**User-visible improvements:** - -- Wave AI text selections no longer lost when clicking in panel -- No focus ring flashing -- Better window blur handling - ---- - -### Phase 3: Layout isFocused Migration (Single Critical Change) - -**Update isFocused atom to use focus manager** - -```typescript -// In layoutModel.ts - CHANGE isFocused atom -isFocused: atom((get) => { - const treeState = get(this.localTreeStateAtom); - const isFocused = treeState.focusedNodeId === nodeid; - const focusType = get(focusManager.focusType); // ← Use focus manager - return isFocused && focusType === "node"; -}); -``` - -**Why this is safe:** - -- Focus manager already keeps `waveAIFocusedAtom` in sync (Phase 1) -- Wave AI already uses focus manager (Phase 2) -- Blocks read the new `focusType` but it's always consistent with old atom -- Should be completely transparent -- Single file change - easy to revert if issues - -**Testing:** - -- Focus transitions between blocks still work -- Wave AI → block transitions work -- Block → Wave AI transitions work -- Keyboard navigation still works -- All existing functionality preserved - -**No user-visible changes** - just internal refactoring - ---- - -### Phase 4: Layout Focus Coordination (Completes the System) - -**Add focus manager calls to treeReducer** - -```typescript -// In layoutModel.ts treeReducer - ADD focus manager calls -case LayoutTreeActionType.FocusNode: - focusNode(this.treeState, action); - focusManager.requestNodeFocus(); // ← NEW - break; - -case LayoutTreeActionType.InsertNode: - insertNode(this.treeState, action); - if ((action as LayoutTreeInsertNodeAction).focused) { - focusManager.requestNodeFocus(); // ← NEW - } - break; - -case LayoutTreeActionType.MagnifyNodeToggle: - magnifyNodeToggle(this.treeState, action); - focusManager.requestNodeFocus(); // ← NEW - break; -``` - -**Why this is safe:** - -- Just makes explicit what was already happening via Wave AI's blur handler -- Ensures focus manager is updated even when layout programmatically changes focus -- Makes the system more robust -- Small, focused changes in one file - -**Testing:** - -- Cmd+n creates new block with correct focus -- Magnify toggle works correctly -- Programmatic focus changes work -- Focus stays consistent during rapid operations - -**User-visible improvements:** - -- More robust focus handling during programmatic layout changes -- Edge cases with rapid focus changes handled better - ---- - -### Phase 5: Keyboard Nav & Cleanup (Optional Polish) - -**Use focus manager in keyboard navigation, remove old atom usage** - -1. Update `keymodel.ts` to use `focusManager.getFocusType()` -2. Remove direct `atoms.waveAIFocusedAtom` usage throughout codebase -3. (Optional) Stop syncing `waveAIFocusedAtom` in focus manager - can be deprecated - -**Why this is safe:** - -- Everything already using focus manager under the hood -- Just cleanup/optimization -- Can be done incrementally - -**Testing:** - -- Keyboard navigation between blocks -- Left/Right arrow to/from Wave AI -- All keyboard shortcuts still work - ---- - -## Key Insight: Dual Atom Sync - -**Phase 1 is the enabler**: By having the focus manager update BOTH the new `focusType` atom AND the old `waveAIFocusedAtom`, we create a safe transition period where: - -- New code can use focus manager -- Old code continues reading the old atom -- Everything stays consistent -- Each phase is independently testable -- Can ship and test after each phase - -This dual-sync approach eliminates the "all or nothing" problem. You can stop at any phase and have a working, tested system. - -## Testing Between Phases - -After each phase, you can ship and test: - -- **Phase 1** → No user-visible changes, foundation in place -- **Phase 2** → Wave AI improvements only, blocks unchanged -- **Phase 3** → Complete system working with new architecture -- **Phase 4** → More robust edge case handling -- **Phase 5** → Code cleanup and optimization - -Each phase builds on the previous one but can be independently verified. diff --git a/aiprompts/wps-events.md b/aiprompts/wps-events.md deleted file mode 100644 index 391a473e62..0000000000 --- a/aiprompts/wps-events.md +++ /dev/null @@ -1,296 +0,0 @@ -# WPS Events Guide - -## Overview - -WPS (Wave PubSub) is Wave Terminal's publish-subscribe event system that enables different parts of the application to communicate asynchronously. The system uses a broker pattern to route events from publishers to subscribers based on event types and scopes. - -## Key Files - -- [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go) - Event type constants and data structures -- [`pkg/wps/wps.go`](../pkg/wps/wps.go) - Broker implementation and core logic -- [`pkg/wcore/wcore.go`](../pkg/wcore/wcore.go) - Example usage patterns - -## Event Structure - -Events in WPS have the following structure: - -```go -type WaveEvent struct { - Event string `json:"event"` // Event type constant - Scopes []string `json:"scopes,omitempty"` // Optional scopes for targeted delivery - Sender string `json:"sender,omitempty"` // Optional sender identifier - Persist int `json:"persist,omitempty"` // Number of events to persist in history - Data any `json:"data,omitempty"` // Event payload -} -``` - -## Adding a New Event Type - -### Step 1: Define the Event Constant - -Add your event type constant to [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go:8-19): - -```go -const ( - Event_BlockClose = "blockclose" - Event_ConnChange = "connchange" - // ... other events ... - Event_YourNewEvent = "your:newevent" // Use colon notation for namespacing -) -``` - -**Naming Convention:** - -- Use descriptive PascalCase for the constant name with `Event_` prefix -- Use lowercase with colons for the string value (e.g., "namespace:eventname") -- Group related events with the same namespace prefix - -### Step 2: Define Event Data Structure (Optional) - -If your event carries structured data, define a type for it: - -```go -type YourEventData struct { - Field1 string `json:"field1"` - Field2 int `json:"field2"` -} -``` - -### Step 3: Expose Type to Frontend (If Needed) - -If your event data type isn't already exposed via an RPC call, you need to add it to [`pkg/tsgen/tsgen.go`](../pkg/tsgen/tsgen.go:29-56) so TypeScript types are generated: - -```go -// add extra types to generate here -var ExtraTypes = []any{ - waveobj.ORef{}, - // ... other types ... - uctypes.RateLimitInfo{}, // Example: already added - YourEventData{}, // Add your new type here -} -``` - -Then run code generation: - -```bash -task generate -``` - -This will update [`frontend/types/gotypes.d.ts`](../frontend/types/gotypes.d.ts) with TypeScript definitions for your type, ensuring type safety in the frontend when handling these events. - -## Publishing Events - -### Basic Publishing - -To publish an event, use the global broker: - -```go -import "github.com/wavetermdev/waveterm/pkg/wps" - -wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_YourNewEvent, - Data: yourData, -}) -``` - -### Publishing with Scopes - -Scopes allow targeted event delivery. Subscribers can filter events by scope: - -```go -wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_WaveObjUpdate, - Scopes: []string{oref.String()}, // Target specific object - Data: updateData, -}) -``` - -### Publishing in a Goroutine - -To avoid blocking the caller, publish events asynchronously: - -```go -go func() { - wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_YourNewEvent, - Data: data, - }) -}() -``` - -**When to use goroutines:** - -- When publishing from performance-critical code paths -- When the event is informational and doesn't need immediate delivery -- When publishing from code that holds locks (to prevent deadlocks) - -### Event Persistence - -Events can be persisted in memory for late subscribers: - -```go -wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_YourNewEvent, - Persist: 100, // Keep last 100 events - Data: data, -}) -``` - -## Complete Example: Rate Limit Updates - -This example shows how rate limit information is published when AI chat responses include rate limit headers. - -### 1. Define the Event Type - -In [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go:19): - -```go -const ( - // ... other events ... - Event_WaveAIRateLimit = "waveai:ratelimit" -) -``` - -### 2. Publish the Event - -In [`pkg/aiusechat/usechat.go`](../pkg/aiusechat/usechat.go:94-108): - -```go -import "github.com/wavetermdev/waveterm/pkg/wps" - -func updateRateLimit(info *uctypes.RateLimitInfo) { - if info == nil { - return - } - rateLimitLock.Lock() - defer rateLimitLock.Unlock() - globalRateLimitInfo = info - - // Publish event in goroutine to avoid blocking - go func() { - wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_WaveAIRateLimit, - Data: info, // RateLimitInfo struct - }) - }() -} -``` - -### 3. Subscribe to the Event (Frontend) - -In the frontend, subscribe to events via WebSocket: - -```typescript -// Subscribe to rate limit updates -const subscription = { - event: "waveai:ratelimit", - allscopes: true, // Receive all rate limit events -}; -``` - -## Subscribing to Events - -### From Go Code - -```go -// Subscribe to all events of a type -wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ - Event: wps.Event_YourNewEvent, - AllScopes: true, -}) - -// Subscribe to specific scopes -wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ - Event: wps.Event_WaveObjUpdate, - Scopes: []string{"workspace:123"}, -}) - -// Unsubscribe -wps.Broker.Unsubscribe(routeId, wps.Event_YourNewEvent) -``` - -### Scope Matching - -Scopes support wildcard matching: - -- `*` matches a single scope segment -- `**` matches multiple scope segments - -```go -// Subscribe to all workspace events -wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ - Event: wps.Event_WaveObjUpdate, - Scopes: []string{"workspace:*"}, -}) -``` - -## Best Practices - -1. **Use Namespaces**: Prefix event names with a namespace (e.g., `waveai:`, `workspace:`, `block:`) - -2. **Don't Block**: Use goroutines when publishing from performance-critical code or while holding locks - -3. **Type-Safe Data**: Define struct types for event data rather than using maps - -4. **Scope Wisely**: Use scopes to limit event delivery and reduce unnecessary processing - -5. **Document Events**: Add comments explaining when events are fired and what data they carry - -6. **Consider Persistence**: Use `Persist` for events that late subscribers might need (like status updates). This is normally not used. We normally do a live RPC call to get the current value and then subscribe for updates. - -## Common Event Patterns - -### Status Updates - -```go -wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_ControllerStatus, - Scopes: []string{blockId}, - Persist: 1, // Keep only latest status - Data: statusData, -}) -``` - -### Object Updates - -```go -wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_WaveObjUpdate, - Scopes: []string{oref.String()}, - Data: waveobj.WaveObjUpdate{ - UpdateType: waveobj.UpdateType_Update, - OType: obj.GetOType(), - OID: waveobj.GetOID(obj), - Obj: obj, - }, -}) -``` - -### Batch Updates - -```go -// Helper function for multiple updates -func (b *BrokerType) SendUpdateEvents(updates waveobj.UpdatesRtnType) { - for _, update := range updates { - b.Publish(WaveEvent{ - Event: Event_WaveObjUpdate, - Scopes: []string{waveobj.MakeORef(update.OType, update.OID).String()}, - Data: update, - }) - } -} -``` - -## Debugging - -To debug event flow: - -1. Check broker subscription map: `wps.Broker.SubMap` -2. View persisted events: `wps.Broker.ReadEventHistory(eventType, scope, maxItems)` -3. Add logging in publish/subscribe methods -4. Monitor WebSocket traffic in browser dev tools - -## Related Documentation - -- [Configuration System](config-system.md) - Uses WPS events for config updates -- [Wave AI Architecture](waveai-architecture.md) - AI-related events diff --git a/assets/appicon-windows.png b/assets/appicon-windows.png deleted file mode 100644 index 41ab8fed22..0000000000 Binary files a/assets/appicon-windows.png and /dev/null differ diff --git a/assets/appicon-windows.svg b/assets/appicon-windows.svg deleted file mode 100644 index b0876d1eb5..0000000000 --- a/assets/appicon-windows.svg +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/assets/default-keybindings.json b/assets/default-keybindings.json new file mode 100644 index 0000000000..aa44bea977 --- /dev/null +++ b/assets/default-keybindings.json @@ -0,0 +1,336 @@ +[ + { + "command": "system:toggleDeveloperTools", + "keys": ["Cmd:Option:i"], + "info": "Opens the chrome developer tool menu" + }, + { + "command": "system:hideWindow", + "keys": ["Cmd:m"] + }, + { + "command": "generic:cancel", + "keys": ["Escape"] + }, + { + "command": "generic:confirm", + "keys": ["Enter"] + }, + { + "command": "generic:expandTextInput", + "keys": ["Shift:Enter", "Ctrl:Enter"] + }, + { + "command": "generic:deleteItem", + "keys": ["Backspace", "Delete"] + }, + { + "command": "generic:space", + "keys": ["Space"] + }, + { + "command": "generic:tab", + "keys": ["Tab"] + }, + { + "command": "generic:numpad-0", + "keys": ["0"] + }, + { + "command": "generic:numpad-1", + "keys": ["1"] + }, + { + "command": "generic:numpad-2", + "keys": ["2"] + }, + { + "command": "generic:numpad-3", + "keys": ["3"] + }, + { + "command": "generic:numpad-4", + "keys": ["4"] + }, + { + "command": "generic:numpad-5", + "keys": ["5"] + }, + { + "command": "generic:numpad-6", + "keys": ["6"] + }, + { + "command": "generic:numpad-7", + "keys": ["7"] + }, + { + "command": "generic:numpad-8", + "keys": ["8"] + }, + { + "command": "generic:numpad-9", + "keys": ["9"] + }, + { + "command": "generic:selectAbove", + "keys": ["ArrowUp"] + }, + { + "command": "generic:selectBelow", + "keys": ["ArrowDown"] + }, + { + "command": "generic:selectLeft", + "keys": ["ArrowLeft"] + }, + { + "command": "generic:selectRight", + "keys": ["ArrowRight"] + }, + { + "command": "generic:selectPageAbove", + "keys": ["PageUp"] + }, + { + "command": "generic:selectPageBelow", + "keys": ["PageDown"] + }, + { + "command": "app:openHistoryView", + "keys": ["Cmd:h"] + }, + { + "command": "app:openTabSearchModal", + "keys": ["Cmd:p"] + }, + { + "command": "app:openConnectionsView", + "keys": [], + "commandStr": ["/mainview connections"] + }, + { + "command": "app:openSettingsView", + "keys": [], + "commandStr": ["/mainview clientsettings"] + }, + { + "command": "app:newTab", + "keys": ["Cmd:t"] + }, + { + "command": "app:focusCmdInput", + "keys": ["Cmd:i"] + }, + { + "command": "app:focusSelectedLine", + "keys": ["Cmd:l"] + }, + { + "command": "app:restartCommand", + "keys": ["Cmd:r"], + "info": "Restarts the command running in the current selected line" + }, + { + "command": "app:restartLastCommand", + "keys": ["Cmd:Shift:r"] + }, + { + "command": "app:closeCurrentTab", + "keys": ["Cmd:w"] + }, + { + "command": "app:selectLineAbove", + "keys": ["Cmd:ArrowUp", "Cmd:PageUp"] + }, + { + "command": "app:selectLineBelow", + "keys": ["Cmd:ArrowDown", "Cmd:PageDown"] + }, + { + "command": "app:selectTab-1", + "keys": ["Cmd:1"] + }, + { + "command": "app:selectTab-2", + "keys": ["Cmd:2"] + }, + { + "command": "app:selectTab-3", + "keys": ["Cmd:3"] + }, + { + "command": "app:selectTab-4", + "keys": ["Cmd:4"] + }, + { + "command": "app:selectTab-5", + "keys": ["Cmd:5"] + }, + { + "command": "app:selectTab-6", + "keys": ["Cmd:6"] + }, + { + "command": "app:selectTab-7", + "keys": ["Cmd:7"] + }, + { + "command": "app:selectTab-8", + "keys": ["Cmd:8"] + }, + { + "command": "app:selectTab-9", + "keys": ["Cmd:9"] + }, + { + "command": "app:selectTabLeft", + "keys": ["Cmd:["] + }, + { + "command": "app:selectTabRight", + "keys": ["Cmd:]"] + }, + { + "command": "app:selectWorkspace-1", + "keys": ["Cmd:Ctrl:1"] + }, + { + "command": "app:selectWorkspace-2", + "keys": ["Cmd:Ctrl:2"] + }, + { + "command": "app:selectWorkspace-3", + "keys": ["Cmd:Ctrl:3"] + }, + { + "command": "app:selectWorkspace-4", + "keys": ["Cmd:Ctrl:4"] + }, + { + "command": "app:selectWorkspace-5", + "keys": ["Cmd:Ctrl:5"] + }, + { + "command": "app:selectWorkspace-6", + "keys": ["Cmd:Ctrl:6"] + }, + { + "command": "app:selectWorkspace-7", + "keys": ["Cmd:Ctrl:7"] + }, + { + "command": "app:selectWorkspace-8", + "keys": ["Cmd:Ctrl:8"] + }, + { + "command": "app:selectWorkspace-9", + "keys": ["Cmd:Ctrl:9"] + }, + { + "command": "app:toggleSidebar", + "keys": ["Cmd:Ctrl:s"] + }, + { + "command": "app:deleteActiveLine", + "keys": ["Cmd:d"] + }, + { + "command": "app:openBookmarksView", + "keys": ["Cmd:b"], + "commandStr": ["/bookmarks:show"] + }, + { + "command": "bookmarks:edit", + "keys": ["e"] + }, + { + "command": "bookmarks:copy", + "keys": ["c"] + }, + { + "command": "cmdinput:autocomplete", + "keys": ["Tab"] + }, + { + "command": "cmdinput:expandInput", + "keys": ["Cmd:e"] + }, + { + "command": "cmdinput:clearInput", + "keys": ["Ctrl:c"] + }, + { + "command": "cmdinput:cutLineLeftOfCursor", + "keys": ["Ctrl:u"] + }, + { + "command": "cmdinput:previousHistoryItem", + "keys": ["Ctrl:p"] + }, + { + "command": "cmdinput:nextHistoryItem", + "keys": ["Ctrl:n"] + }, + { + "command": "cmdinput:cutWordLeftOfCursor", + "keys": ["Ctrl:w"] + }, + { + "command": "cmdinput:paste", + "keys": ["Ctrl:y"] + }, + { + "command": "cmdinput:openHistory", + "keys": ["Ctrl:r"], + "commandStr": ["/history"] + }, + { + "command": "history:closeHistory", + "keys": ["Ctrl:g", "Ctrl:c"] + }, + { + "command": "history:toggleShowRemotes", + "keys": ["Cmd:r", "Ctrl:r"] + }, + { + "command": "history:changeScope", + "keys": ["Ctrl:s", "Cmd:s"] + }, + { + "command": "history:selectNextItem", + "keys": ["Ctrl:n"] + }, + { + "command": "history:selectPreviousItem", + "keys": ["Ctrl:p"] + }, + { + "command": "aichat:clearHistory", + "keys": ["Ctrl:l"] + }, + { + "command": "terminal:copy", + "keys": ["Ctrl:Shift:c"] + }, + { + "command": "terminal:paste", + "keys": ["Ctrl:Shift:v"] + }, + { + "command": "codeedit:save", + "keys": ["Cmd:s"] + }, + { + "command": "codeedit:close", + "keys": ["Cmd:d"] + }, + { + "command": "codeedit:togglePreview", + "keys": ["Cmd:p"] + }, + { + "command": "rightsidebar:toggle", + "keys": ["Cmd:Shift:Space"] + } + ] \ No newline at end of file diff --git a/assets/wave-dark.png b/assets/wave-dark.png deleted file mode 100644 index e9cf7cb36c..0000000000 Binary files a/assets/wave-dark.png and /dev/null differ diff --git a/assets/wave-light.png b/assets/wave-light.png deleted file mode 100644 index ab3d58b887..0000000000 Binary files a/assets/wave-light.png and /dev/null differ diff --git a/docs/static/img/logo/wave-logo_horizontal-coloronblack.svg b/assets/wave-logo_horizontal-coloronblack.svg similarity index 100% rename from docs/static/img/logo/wave-logo_horizontal-coloronblack.svg rename to assets/wave-logo_horizontal-coloronblack.svg diff --git a/docs/static/img/logo/wave-logo_horizontal-coloronwhite.svg b/assets/wave-logo_horizontal-coloronwhite.svg similarity index 100% rename from docs/static/img/logo/wave-logo_horizontal-coloronwhite.svg rename to assets/wave-logo_horizontal-coloronwhite.svg diff --git a/assets/wave-logo_icon-outline-duotone.svg b/assets/wave-logo_icon-outline-duotone.svg deleted file mode 100644 index a255186a23..0000000000 --- a/assets/wave-logo_icon-outline-duotone.svg +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - diff --git a/assets/wave-logo_icon-outline.svg b/assets/wave-logo_icon-outline.svg deleted file mode 100644 index 8991caa9ed..0000000000 --- a/assets/wave-logo_icon-outline.svg +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - diff --git a/assets/wave-logo_icon-solid.svg b/assets/wave-logo_icon-solid.svg deleted file mode 100644 index 08715a7605..0000000000 --- a/assets/wave-logo_icon-solid.svg +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - diff --git a/assets/wave-screenshot.png b/assets/wave-screenshot.png new file mode 100644 index 0000000000..7d49419b4e Binary files /dev/null and b/assets/wave-screenshot.png differ diff --git a/assets/wave-screenshot.webp b/assets/wave-screenshot.webp deleted file mode 100644 index 372ff1700c..0000000000 Binary files a/assets/wave-screenshot.webp and /dev/null differ diff --git a/assets/waveterm-logo-with-bg.ico b/assets/waveterm-logo-with-bg.ico deleted file mode 100644 index 943e504bc0..0000000000 Binary files a/assets/waveterm-logo-with-bg.ico and /dev/null differ diff --git a/assets/waveterm-logo-with-bg.png b/assets/waveterm-logo-with-bg.png deleted file mode 100644 index acfc06869c..0000000000 Binary files a/assets/waveterm-logo-with-bg.png and /dev/null differ diff --git a/assets/waveterm-logo.png b/assets/waveterm-logo.png new file mode 100644 index 0000000000..daaf606e40 Binary files /dev/null and b/assets/waveterm-logo.png differ diff --git a/assets/waveterm-logo.svg b/assets/waveterm-logo.svg new file mode 100644 index 0000000000..630fdfa639 --- /dev/null +++ b/assets/waveterm-logo.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/build-linux.md b/build-linux.md new file mode 100644 index 0000000000..342e0bef29 --- /dev/null +++ b/build-linux.md @@ -0,0 +1,111 @@ +# Build Instructions for Wave Terminal on Linux + +These instructions are for setting up the build on Linux (Ubuntu). +If you're developing on MacOS please use the [MacOS Build Instructions](./BUILD.md). +If you are working on a different Linux distribution, you may need to adapt some of these instructions to fit your environment. + +## Running the Development Version of Wave + +If you install the production version of Wave, you'll see a semi-transparent gray sidebar, and the data for Wave is stored in the directory ~/.waveterm. The development version has a blue sidebar and stores its data in ~/.waveterm-dev. This allows the production and development versions to be run simultaneously with no conflicts. If the dev database is corrupted by development bugs, or the schema changes in development it will not affect the production copy. + +## Prereqs and Tools + +Download and install Go (must be at least go 1.18). We also need gcc installed to run a CGO build (for Golang). +zip is required to build linux deployment packages (not required for running and debugging dev builds). + +``` +sudo snap install go --classic +sudo apt-get update +sudo apt-get install gcc +sudo apt-get install zip +``` + +Download and install [ScriptHaus](https://github.com/scripthaus-dev/scripthaus) (to run the build commands): + +``` +git clone https://github.com/scripthaus-dev/scripthaus.git +cd scripthaus +CGO_ENABLED=1 go build -o scripthaus cmd/main.go +``` + +You'll now have to move the built `scripthaus` binary to a directory in your path (e.g. /usr/local/bin): + +``` +sudo cp scripthaus /usr/local/bin +``` + +## Install nodejs and yarn + +You also need a relatively modern nodejs with npm and yarn installed. + +Node can be installed from [https://nodejs.org](https://nodejs.org). + +We use Yarn Modern to manage our packages. The recommended way to install Yarn Modern is using Corepack, a new utility shipped by NodeJS that lets you manage your package manager versioning as you would any packages. + +If you installed NodeJS from the official feed (via the website or using NVM), this should come preinstalled. If you use Homebrew or some other feed, you may need to manually install Corepack using `npm install -g corepack`. + +For more information on Corepack, check out [this link](https://yarnpkg.com/corepack). + +Once you've verified that you have Corepack installed, run the following script to set up Yarn for the repository: + +```sh +corepack enable +yarn install +``` + +## Clone the Wave Repo + +Move out of the `scripthaus` directory if you're still in it. Clone the wave repository into the directory that you'd like to use for development. + +``` +git clone git@github.com:wavetermdev/waveterm.git +``` + +## One-Time Setup + +Install Wave modules (we use yarn): + +``` +yarn +``` + +Electron also requires specific builds of node_modules to work (because Electron embeds a specific node.js version that might not match your development node.js version). We use a special electron command to cross-compile those modules: + +``` +scripthaus run electron-rebuild +``` + +## Building WaveShell / WaveSrv + +cd into the waveterm directory (if you haven't already) and run the build-backend command using `scripthaus`. + +``` +cd waveterm +scripthaus run build-backend +``` + +This builds the Golang backends for Wave. The binaries will put in waveshell/bin and wavesrv/bin respectively. If you're working on a new plugin or other pure frontend changes to Wave, you won't need to rebuild these unless you pull new code from the Wave Repository. + +## Running WebPack + +We use webpack to build both the React and Electron App Wrapper code. They are both run together using: + +``` +scripthaus run webpack-watch +``` + +## Running the WaveTerm Dev Client + +Now that webpack is running (and watching for file changes) we can finally run the WaveTerm Dev Client! To start the client run: + +``` +scripthaus run electron +``` + +To kill the client, either exit the Electron App normally or just Ctrl-C the `scripthaus run electron` command. + +Because we're running webpack in watch mode, any changes you make to the typescript will be automatically picked up by the client after a refresh. Note that I've disabled hot-reloading in the webpack config, so to pick up new changes you'll have to manually refresh the WaveTerm Client window. To do that use "Command-Shift-R" (Command-R is used internally by Wave and will not force a refresh). + +## Debugging the Dev Client + +You can use the regular Chrome DevTools to debug the frontend application. You can open the DevTools using the keyboard shortcut `Cmd-Option-I`. diff --git a/build/deb-postinstall.tpl b/build/deb-postinstall.tpl deleted file mode 100644 index 71c7218609..0000000000 --- a/build/deb-postinstall.tpl +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -if type update-alternatives 2>/dev/null >&1; then - # Remove previous link if it doesn't use update-alternatives - if [ -L '/usr/bin/waveterm' -a -e '/usr/bin/waveterm' -a "`readlink '/usr/bin/waveterm'`" != '/etc/alternatives/waveterm' ]; then - rm -f '/usr/bin/waveterm' - fi - update-alternatives --install '/usr/bin/waveterm' 'waveterm' '/opt/Wave/waveterm' 100 || ln -sf '/opt/Wave/waveterm' '/usr/bin/waveterm' -else - ln -sf '/opt/Wave/waveterm' '/usr/bin/waveterm' -fi - -chmod 4755 '/opt/Wave/chrome-sandbox' || true - -if hash update-mime-database 2>/dev/null; then - update-mime-database /usr/share/mime || true -fi - -if hash update-desktop-database 2>/dev/null; then - update-desktop-database /usr/share/applications || true -fi diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist deleted file mode 100644 index c207a0b5c4..0000000000 --- a/build/entitlements.mac.plist +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - com.apple.security.cs.allow-jit - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.disable-library-validation - - - - com.apple.security.device.audio-input - - com.apple.security.device.camera - - com.apple.security.personal-information.addressbook - - com.apple.security.personal-information.calendars - - com.apple.security.personal-information.location - - com.apple.security.personal-information.photos-library - - - diff --git a/build/icon.icns b/build/icon.icns deleted file mode 100644 index 79cd56e676..0000000000 Binary files a/build/icon.icns and /dev/null differ diff --git a/build/icon.ico b/build/icon.ico deleted file mode 100644 index a6d2d8787e..0000000000 Binary files a/build/icon.ico and /dev/null differ diff --git a/build/icons/128x128.png b/build/icons/128x128.png deleted file mode 100644 index 577e0a80da..0000000000 Binary files a/build/icons/128x128.png and /dev/null differ diff --git a/build/icons/16x16.png b/build/icons/16x16.png deleted file mode 100644 index ff46e5b1a3..0000000000 Binary files a/build/icons/16x16.png and /dev/null differ diff --git a/build/icons/256x256.png b/build/icons/256x256.png deleted file mode 100644 index 8df719f7e5..0000000000 Binary files a/build/icons/256x256.png and /dev/null differ diff --git a/build/icons/32x32.png b/build/icons/32x32.png deleted file mode 100644 index 84b2a46670..0000000000 Binary files a/build/icons/32x32.png and /dev/null differ diff --git a/build/icons/48x48.png b/build/icons/48x48.png deleted file mode 100644 index e1250a8964..0000000000 Binary files a/build/icons/48x48.png and /dev/null differ diff --git a/build/icons/512x512.png b/build/icons/512x512.png deleted file mode 100644 index 942baa79ad..0000000000 Binary files a/build/icons/512x512.png and /dev/null differ diff --git a/build/icons/64x64.png b/build/icons/64x64.png deleted file mode 100644 index 0ecef4509e..0000000000 Binary files a/build/icons/64x64.png and /dev/null differ diff --git a/buildres/.gitignore b/buildres/.gitignore new file mode 100644 index 0000000000..a3e60d5ba5 --- /dev/null +++ b/buildres/.gitignore @@ -0,0 +1,4 @@ +*builds/ +*-staged/ +*.zip +*.dmg diff --git a/buildres/README.md b/buildres/README.md new file mode 100644 index 0000000000..58bf99cd48 --- /dev/null +++ b/buildres/README.md @@ -0,0 +1,71 @@ +# Building for release + +## Build Helper workflow + +Our release builds are managed by the "Build Helper" GitHub Action, which is defined +in [`build-helper.yml`](../.github/workflows/build-helper.yml). + +Under the hood, this will call the `build-package` and `build-package-linux` scripts in +[`scripthaus.md`](../scripthaus.md), which will build the Electron codebase using +WebPack and then the `wavesrv` and `mshell` binaries, then it will call `electron-builder` +to generate the distributable app packages. The configuration for `electron-builder` +is [`electron-builder.config.js`](../electron-builder.config.js). + +This will also sign and notarize the macOS app package. + +Once a build is complete, it will be placed in `s3://waveterm-github-artifacts/staging-legacy/`. +It can be downloaded for testing using the [`download-staged-artifact.sh`](./download-staged-artifact.sh) +script. When you are ready to publish the artifacts to the public release feed, use the +[`publish-from-staging.sh`](./publish-from-staging.sh) script to directly copy the artifacts from +the staging bucket to the releases bucket. + +## Automatic updates + +Thanks to `electron-updater`, we are able to provide automatic app updates for macOS and Linux, +as long as the app was distributed as a DMG, AppImage, RPM, or DEB file. + +With each release, `latest-mac.yml` and `latest-linux.yml` files will be produced that point to the +newest release. These also include file sizes and checksums to aid in validating the packages. The app +will check these files in our S3 bucket every hour to see if a new version is available. + +## Local signing and notarizing for macOS (Deprecated) + +The [`prepare-macos.sh`](./deprecated/prepare-macos.sh) script will download the latest build +artifacts from S3 and sign and notarize the macOS binaries within it. It will then +generate a DMG and a new ZIP archive with the new signed app. + +This will call a few different JS scripts to perform more complicated operations. +[`osx-sign.js`](./deprecated/osx-sign.js) and [`osx-notarize.js`](./deprecated/osx-notarize.js) call +underlying Electron APIs to sign and notarize the package. +[`update-latest-mac.js`](./deprecated/update-latest-mac.js) will then update the `latest-mac.yml` +file with the SHA512 checksum and file size of the new signed and notarized installer. This +is important for the `electron-updater` auto-update mechanism to then find and validate new releases. + +## Uploading release artifacts for distribution (Deprecated) + +### Upload script + +Once the build has been fully validated and is ready to be released, the +[`upload-release.sh`](./deprecated/upload-release.sh) script is then used to grab the completed +artifacts and upload them to the `dl.waveterm.dev` S3 bucket for distribution. + +### Homebrew + +Homebrew currently requires a manual bump of the version, but now that we have auto-updates, +we should add our cask to the list of apps that can be automatically bumped. + +### Linux + +We do not currently submit the Linux packages to any of the package repositories. We +are working on addressing this in the near future. + +## `electron-build` configuration + +Most of our configuration is fairly standard. The main exception to this is that we exclude +our Go binaries from the ASAR archive that Electron generates. ASAR files cannot be executed +by NodeJS because they are not seen as files and therefore cannot be executed via a Shell +command. More information can be found +[here](https://www.electronjs.org/docs/latest/tutorial/asar-archives#executing-binaries-inside-asar-archive). + +We also exclude most of our `node_modules` from packaging, as WebPack handles packaging +of any dependencies for us. The one exception is `monaco-editor`. diff --git a/buildres/deprecated/generate-hash.js b/buildres/deprecated/generate-hash.js new file mode 100644 index 0000000000..3c3b86a308 --- /dev/null +++ b/buildres/deprecated/generate-hash.js @@ -0,0 +1,46 @@ +// Usage: node generate-hash.js +// Example: node generate-hash.js ./make/Wave-0.0.1.dmg +// This script will generate a hash of the installer file, as defined by electron-builder. +// Courtesy of https://github.com/electron-userland/electron-builder/issues/3913#issuecomment-504698845 + +const path = require("path"); +const fs = require("fs"); +const crypto = require("crypto"); + +/** + * Generate a hash of a file, as defined by electron-builder + * @param {string} file - Path to the file + * @param {string} algorithm - Hash algorithm to use + * @param {string} encoding - Encoding to use + * @returns {Promise} - The hash of the file + */ +async function hashFile(file, algorithm = "sha512", encoding = "base64") { + return new Promise((resolve, reject) => { + const hash = crypto.createHash(algorithm); + hash.on("error", reject).setEncoding(encoding); + fs.createReadStream(file, { + highWaterMark: 1024 * 1024, + /* better to use more memory but hash faster */ + }) + .on("error", reject) + .on("end", () => { + hash.end(); + resolve(hash.read()); + }) + .pipe(hash, { + end: false, + }); + }); +} + +if (require.main === module) { + const installerPath = path.resolve(process.cwd(), process.argv[2]); + (async () => { + const hash = await hashFile(installerPath); + console.log(`hash of ${installerPath}: ${hash}`); + })(); +} + +module.exports = { + hashFile, +}; diff --git a/buildres/deprecated/osx-notarize.js b/buildres/deprecated/osx-notarize.js new file mode 100644 index 0000000000..c3852ca567 --- /dev/null +++ b/buildres/deprecated/osx-notarize.js @@ -0,0 +1,37 @@ +// Notarize the Wave.app for macOS + +const { notarize } = require("@electron/notarize"); +const path = require("path"); + +/** + * Notarize the Wave.app for macOS + * @param {string} waveAppPath - Path to the Wave.app + * @returns {Promise} + */ +async function notarizeApp(waveAppPath) { + return notarize({ + appPath: waveAppPath, + tool: "notarytool", + keychainProfile: "notarytool-creds", + }) + .then(() => { + console.log("notarize success"); + }) + .catch((e) => { + console.log("notarize error", e); + process.exit(1); + }); +} + +if (require.main === module) { + console.log("running osx-notarize"); + const waveAppPath = path.resolve(__dirname, "temp", "Wave.app"); + (async () => { + await notarizeApp(waveAppPath); + console.log("notarization complete"); + })(); +} + +module.exports = { + notarizeApp, +}; diff --git a/buildres/deprecated/osx-sign.js b/buildres/deprecated/osx-sign.js new file mode 100644 index 0000000000..e0a35c88a7 --- /dev/null +++ b/buildres/deprecated/osx-sign.js @@ -0,0 +1,45 @@ +// Sign the app and binaries for macOS + +const { signAsync } = require("@electron/osx-sign"); +const path = require("path"); +const fs = require("fs"); + +/** + * Sign the app and binaries for macOS + * @param {string} waveAppPath - Path to the Wave.app + * @returns {Promise} + */ +async function signApp(waveAppPath) { + const binDirPath = path.resolve(waveAppPath, "Contents", "Resources", "app.asar.unpacked", "bin"); + const binFilePaths = fs + .readdirSync(binDirPath, { recursive: true, withFileTypes: true }) + .filter((f) => f.isFile()) + .map((f) => path.resolve(binDirPath, f.path, f.name)); + console.log("waveAppPath", waveAppPath); + console.log("binDirPath", binDirPath); + console.log("binFilePaths", binFilePaths); + return signAsync({ + app: waveAppPath, + binaries: binFilePaths, + }) + .then(() => { + console.log("signing success"); + }) + .catch((e) => { + console.log("signing error", e); + process.exit(1); + }); +} + +if (require.main === module) { + console.log("running osx-sign"); + const waveAppPath = path.resolve(__dirname, "temp", "Wave.app"); + (async () => { + await signApp(waveAppPath); + console.log("signing complete"); + })(); +} + +module.exports = { + signApp, +}; diff --git a/buildres/deprecated/prepare-macos.sh b/buildres/deprecated/prepare-macos.sh new file mode 100644 index 0000000000..3cd28c5012 --- /dev/null +++ b/buildres/deprecated/prepare-macos.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# This script is used to sign and notarize the universal app for macOS + +# Gets the directory of the script +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +# Remove old files and dirs, create new ones +rm -f *.zip *.dmg +ZIP_DIR=$SCRIPT_DIR/zip +rm -rf $ZIP_DIR +mkdir $ZIP_DIR +TEMP_DIR=$SCRIPT_DIR/temp +rm -rf $TEMP_DIR +mkdir $TEMP_DIR +BUILDS_DIR=$SCRIPT_DIR/builds +rm -rf $BUILDS_DIR + +# Download the builds zip +aws s3 cp s3://waveterm-github-artifacts/waveterm-builds.zip . +BUILDS_ZIP=waveterm-builds.zip +if ! [ -f $BUILDS_ZIP ]; then + echo "no $BUILDS_ZIP found"; + exit 1; +fi +echo "unzipping $BUILDS_ZIP" +unzip -q $BUILDS_ZIP -d $BUILDS_DIR +rm $BUILDS_ZIP + +# Finds a file in a directory matching a filename pattern. Ensures there is exactly one match. +find_file() +{ + local FILE_DIR=$1 + local FILE_PATTERN=$2 + local FILE_PATH=$(find $FILE_DIR -type f -iname "$FILE_PATTERN") + local NUM_MATCHES=$(echo $FILE_PATH | wc -l) + if [ "0" -eq "$NUM_MATCHES" ]; then + echo "no $FILE_PATTERN found in $FILE_DIR" + exit 1 + elif [ "1" -lt "$NUM_MATCHES" ]; then + echo "multiple $FILE_PATTERN found in $FILE_DIR" + exit 1 + fi + echo $FILE_PATH +} + +# Unzip Mac build +MAC_ZIP=$(find_file $BUILDS_DIR "Wave-darwin-universal-*.zip") +unzip -q $MAC_ZIP -d $TEMP_DIR + +# Sign and notarize the app +node $SCRIPT_DIR/osx-sign.js +DEBUG=electron-notarize node $SCRIPT_DIR/osx-notarize.js + +# Zip and move +echo "creating universal zip" +ZIP_NAME=$(basename $MAC_ZIP) +TEMP_WAVE_DIR_UNIVERSAL=$TEMP_DIR/Wave.app +ditto $TEMP_WAVE_DIR_UNIVERSAL $ZIP_DIR/Wave.app +cd $ZIP_DIR +zip -9yqr $ZIP_NAME Wave.app +mv $ZIP_NAME $BUILDS_DIR/ +cd $SCRIPT_DIR + +# Create a dmg +# Expects create-dmg repo to be cloned in the same parent directory as the waveterm repo. +echo "creating universal dmg" +DMG_NAME=$(echo $ZIP_NAME | sed 's/\.zip/\.dmg/') +$SCRIPT_DIR/../../create-dmg/create-dmg \ + --volname "WaveTerm" \ + --window-pos 200 120 \ + --window-size 600 300 \ + --icon-size 100 \ + --icon "Wave.app" 200 130 \ + --hide-extension "Wave.app" \ + --app-drop-link 400 125 \ + $DMG_NAME \ + "$TEMP_WAVE_DIR_UNIVERSAL" +echo "success, created $DMG_NAME" +mv $DMG_NAME $BUILDS_DIR/ +spctl -a -vvv -t install $TEMP_WAVE_DIR_UNIVERSAL/ + +# Update latest-mac.yml +echo "updating latest-mac.yml" +LATEST_MAC_YML=$BUILDS_DIR/latest-mac.yml +node $SCRIPT_DIR/update-latest-mac.js $MAC_ZIP $LATEST_MAC_YML + +# Clean up +rm -rf $TEMP_DIR $ZIP_DIR diff --git a/buildres/deprecated/update-latest-mac.js b/buildres/deprecated/update-latest-mac.js new file mode 100644 index 0000000000..39d73baa5d --- /dev/null +++ b/buildres/deprecated/update-latest-mac.js @@ -0,0 +1,40 @@ +// Updates the latest-mac.yml file with the signed and notarized version of the latest installer +// Usage: node update-latest.js + +const path = require("path"); +const fs = require("fs"); +const { hashFile } = require("./generate-hash"); +const yaml = require("yaml"); + +/** + * Updates the latest-mac.yml file with the signed and notarized version of the latest installer + * @param {string} installerPath - Path to the installer + * @param {string} ymlPath - Path to the latest-mac.yml file + * @returns {Promise} + */ +async function updateLatestMac(installerPath, ymlPath) { + const hash = (await hashFile(installerPath)).trim(); + const size = fs.statSync(installerPath).size; + const yml = yaml.parse(fs.readFileSync(ymlPath, "utf8")); + for (const file of yml.files) { + if (file.url === path.basename(installerPath)) { + file.sha512 = hash; + file.size = size; + } + } + yml.sha512 = hash; + fs.writeFileSync(ymlPath, yaml.stringify(yml)); +} + +if (require.main === module) { + const installerPath = path.resolve(process.cwd(), process.argv[2]); + const ymlPath = path.resolve(process.cwd(), process.argv[3]); + (async () => { + await updateLatestMac(installerPath, ymlPath); + console.log("latest-mac.yml updated"); + })(); +} + +module.exports = { + updateLatestMac, +}; diff --git a/buildres/deprecated/upload-release.sh b/buildres/deprecated/upload-release.sh new file mode 100644 index 0000000000..1d93951f79 --- /dev/null +++ b/buildres/deprecated/upload-release.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# This script is used to upload signed and notarized releases to S3 and update the Electron auto-update release feeds. + +# Gets the directory of the script +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +BUILDS_DIR=$SCRIPT_DIR/builds +TEMP2_DIR=$SCRIPT_DIR/temp2 + +AUTOUPDATE_RELEASE_PATH="dl.waveterm.dev/releases-legacy" + +# Copy the builds to the temp2 directory +echo "Copying builds to temp2" +rm -rf $TEMP2_DIR +mkdir -p $TEMP2_DIR +cp -r $BUILDS_DIR/* $TEMP2_DIR + +UVERSION=$(cat $TEMP2_DIR/version.txt) + +if [ -z "$UVERSION" ]; then + echo "version.txt is empty" + exit 1 +fi + +# Remove files we don't want to upload +rm $TEMP2_DIR/version.txt +rm $TEMP2_DIR/builder-*.yml + +# Upload the artifacts +echo "Uploading build artifacts to $AUTOUPDATE_RELEASE_PATH" +aws s3 cp $TEMP2_DIR/ s3://$AUTOUPDATE_RELEASE_PATH/ --recursive + +# Clean up +echo "Cleaning up" +rm -rf $TEMP2_DIR diff --git a/buildres/download-staged-artifact.sh b/buildres/download-staged-artifact.sh new file mode 100644 index 0000000000..b970edba76 --- /dev/null +++ b/buildres/download-staged-artifact.sh @@ -0,0 +1,16 @@ +# Downloads the artifacts for the specified version from the staging bucket for local testing. +# Usage: download-staged-artifact.sh +# Example: download-staged-artifact.sh 0.1.0 + +# Retrieve version from the first argument +VERSION=$1 +if [ -z "$VERSION" ]; then + echo "Usage: $0 " + exit +fi + +# Download the artifacts for the specified version from the staging bucket +DOWNLOAD_DIR=$VERSION-staged +rm -rf $DOWNLOAD_DIR +mkdir -p $DOWNLOAD_DIR +aws s3 cp s3://waveterm-github-artifacts/staging-legacy/$VERSION/ $DOWNLOAD_DIR/ --recursive --profile $AWS_PROFILE diff --git a/buildres/publish-from-staging.sh b/buildres/publish-from-staging.sh new file mode 100644 index 0000000000..45c68d84db --- /dev/null +++ b/buildres/publish-from-staging.sh @@ -0,0 +1,21 @@ +# Takes a release from our staging bucket and publishes it to the public download bucket. +# Usage: publish-from-staging.sh +# Example: publish-from-staging.sh 0.1.0 + +# Takes the version as an argument +VERSION=$1 +if [ -z "$VERSION" ]; then + echo "Usage: $0 " + exit +fi + +ORIGIN="waveterm-github-artifacts/staging-legacy/$VERSION/" +DESTINATION="dl.waveterm.dev/releases-legacy/" + +OUTPUT=$(aws s3 cp s3://$ORIGIN s3://$DESTINATION --recursive --profile $AWS_PROFILE) + +for line in $OUTPUT; do + PREFIX=${line%%${DESTINATION}*} + SUFFIX=${line:${#PREFIX}} + echo "https://$SUFFIX" +done diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go deleted file mode 100644 index ab7e338439..0000000000 --- a/cmd/generatego/main-generatego.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "fmt" - "os" - "reflect" - "strings" - - "github.com/wavetermdev/waveterm/pkg/gogen" - "github.com/wavetermdev/waveterm/pkg/util/utilfn" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wconfig" - "github.com/wavetermdev/waveterm/pkg/wshrpc" -) - -const WshClientFileName = "pkg/wshrpc/wshclient/wshclient.go" -const WaveObjMetaConstsFileName = "pkg/waveobj/metaconsts.go" -const SettingsMetaConstsFileName = "pkg/wconfig/metaconsts.go" - -func GenerateWshClient() error { - fmt.Fprintf(os.Stderr, "generating wshclient file to %s\n", WshClientFileName) - var buf strings.Builder - gogen.GenerateBoilerplate(&buf, "wshclient", []string{ - "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes", - "github.com/wavetermdev/waveterm/pkg/baseds", - "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata", - "github.com/wavetermdev/waveterm/pkg/vdom", - "github.com/wavetermdev/waveterm/pkg/waveobj", - "github.com/wavetermdev/waveterm/pkg/wconfig", - "github.com/wavetermdev/waveterm/pkg/wps", - "github.com/wavetermdev/waveterm/pkg/wshrpc", - "github.com/wavetermdev/waveterm/pkg/wshutil", - }) - wshDeclMap := wshrpc.GenerateWshCommandDeclMap() - for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) { - methodDecl := wshDeclMap[key] - if methodDecl.CommandType == wshrpc.RpcType_ResponseStream { - gogen.GenMethod_ResponseStream(&buf, methodDecl) - } else if methodDecl.CommandType == wshrpc.RpcType_Call { - gogen.GenMethod_Call(&buf, methodDecl) - } else { - panic("unsupported command type " + methodDecl.CommandType) - } - } - buf.WriteString("\n") - written, err := utilfn.WriteFileIfDifferent(WshClientFileName, []byte(buf.String())) - if !written { - fmt.Fprintf(os.Stderr, "no changes to %s\n", WshClientFileName) - } - return err -} - -func GenerateWaveObjMetaConsts() error { - fmt.Fprintf(os.Stderr, "generating waveobj meta consts file to %s\n", WaveObjMetaConstsFileName) - var buf strings.Builder - gogen.GenerateBoilerplate(&buf, "waveobj", []string{}) - gogen.GenerateMetaMapConsts(&buf, "MetaKey_", reflect.TypeOf(waveobj.MetaTSType{}), false) - buf.WriteString("\n") - written, err := utilfn.WriteFileIfDifferent(WaveObjMetaConstsFileName, []byte(buf.String())) - if !written { - fmt.Fprintf(os.Stderr, "no changes to %s\n", WaveObjMetaConstsFileName) - } - return err -} - -func GenerateSettingsMetaConsts() error { - fmt.Fprintf(os.Stderr, "generating settings meta consts file to %s\n", SettingsMetaConstsFileName) - var buf strings.Builder - gogen.GenerateBoilerplate(&buf, "wconfig", []string{}) - gogen.GenerateMetaMapConsts(&buf, "ConfigKey_", reflect.TypeOf(wconfig.SettingsType{}), false) - buf.WriteString("\n") - written, err := utilfn.WriteFileIfDifferent(SettingsMetaConstsFileName, []byte(buf.String())) - if !written { - fmt.Fprintf(os.Stderr, "no changes to %s\n", SettingsMetaConstsFileName) - } - return err -} - -func main() { - err := GenerateWshClient() - if err != nil { - fmt.Fprintf(os.Stderr, "error generating wshclient: %v\n", err) - return - } - err = GenerateWaveObjMetaConsts() - if err != nil { - fmt.Fprintf(os.Stderr, "error generating waveobj meta consts: %v\n", err) - return - } - err = GenerateSettingsMetaConsts() - if err != nil { - fmt.Fprintf(os.Stderr, "error generating settings meta consts: %v\n", err) - return - } -} diff --git a/cmd/generateschema/main-generateschema.go b/cmd/generateschema/main-generateschema.go deleted file mode 100644 index dd24a4df0d..0000000000 --- a/cmd/generateschema/main-generateschema.go +++ /dev/null @@ -1,218 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "encoding/json" - "fmt" - "log" - "os" - "reflect" - - "github.com/invopop/jsonschema" - "github.com/wavetermdev/waveterm/pkg/util/utilfn" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wconfig" -) - -const WaveSchemaSettingsFileName = "schema/settings.json" -const WaveSchemaConnectionsFileName = "schema/connections.json" -const WaveSchemaAiPresetsFileName = "schema/aipresets.json" -const WaveSchemaWidgetsFileName = "schema/widgets.json" -const WaveSchemaBackgroundsFileName = "schema/backgrounds.json" -const WaveSchemaWaveAIFileName = "schema/waveai.json" - -// ViewNameType is a string type whose JSON Schema offers enum suggestions for the most -// common widget view names while still accepting any arbitrary string value. -type ViewNameType string - -func (ViewNameType) JSONSchema() *jsonschema.Schema { - return &jsonschema.Schema{ - AnyOf: []*jsonschema.Schema{ - { - Enum: []any{"term", "preview", "web", "sysinfo", "launcher"}, - }, - { - Type: "string", - }, - }, - } -} - -// ControllerNameType is a string type whose JSON Schema offers enum suggestions for the -// known block controller names while still accepting any arbitrary string value. -type ControllerNameType string - -func (ControllerNameType) JSONSchema() *jsonschema.Schema { - return &jsonschema.Schema{ - AnyOf: []*jsonschema.Schema{ - { - Enum: []any{"shell", "cmd"}, - }, - { - Type: "string", - }, - }, - } -} - -// WidgetsMetaSchemaHints provides schema hints for the blockdef.meta field in widget configs. -// It covers the most common keys used when defining widgets: view, file, url, controller, -// cmd and cmd:* options, and term:* options. -type WidgetsMetaSchemaHints struct { - View ViewNameType `json:"view,omitempty"` - File string `json:"file,omitempty"` - Url string `json:"url,omitempty"` - Controller ControllerNameType `json:"controller,omitempty"` - - Cmd string `json:"cmd,omitempty"` - CmdInteractive bool `json:"cmd:interactive,omitempty"` - CmdLogin bool `json:"cmd:login,omitempty"` - CmdPersistent bool `json:"cmd:persistent,omitempty"` - CmdRunOnStart bool `json:"cmd:runonstart,omitempty"` - CmdClearOnStart bool `json:"cmd:clearonstart,omitempty"` - CmdRunOnce bool `json:"cmd:runonce,omitempty"` - CmdCloseOnExit bool `json:"cmd:closeonexit,omitempty"` - CmdCloseOnExitForce bool `json:"cmd:closeonexitforce,omitempty"` - CmdCloseOnExitDelay float64 `json:"cmd:closeonexitdelay,omitempty"` - CmdNoWsh bool `json:"cmd:nowsh,omitempty"` - CmdArgs []string `json:"cmd:args,omitempty"` - CmdShell bool `json:"cmd:shell,omitempty"` - CmdAllowConnChange bool `json:"cmd:allowconnchange,omitempty"` - CmdEnv map[string]string `json:"cmd:env,omitempty"` - CmdCwd string `json:"cmd:cwd,omitempty"` - CmdInitScript string `json:"cmd:initscript,omitempty"` - CmdInitScriptSh string `json:"cmd:initscript.sh,omitempty"` - CmdInitScriptBash string `json:"cmd:initscript.bash,omitempty"` - CmdInitScriptZsh string `json:"cmd:initscript.zsh,omitempty"` - CmdInitScriptPwsh string `json:"cmd:initscript.pwsh,omitempty"` - CmdInitScriptFish string `json:"cmd:initscript.fish,omitempty"` - - TermFontSize int `json:"term:fontsize,omitempty"` - TermFontFamily string `json:"term:fontfamily,omitempty"` - TermMode string `json:"term:mode,omitempty"` - TermTheme string `json:"term:theme,omitempty"` - TermLocalShellPath string `json:"term:localshellpath,omitempty"` - TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` - TermScrollback *int `json:"term:scrollback,omitempty"` - TermTransparency *float64 `json:"term:transparency,omitempty"` - TermAllowBracketedPaste *bool `json:"term:allowbracketedpaste,omitempty"` - TermShiftEnterNewline *bool `json:"term:shiftenternewline,omitempty"` - TermMacOptionIsMeta *bool `json:"term:macoptionismeta,omitempty"` - TermBellSound *bool `json:"term:bellsound,omitempty"` - TermBellIndicator *bool `json:"term:bellindicator,omitempty"` - TermDurable *bool `json:"term:durable,omitempty"` -} - -// allowNullValues wraps the top-level additionalProperties of a map schema with -// anyOf: [originalSchema, {type: "null"}] so that setting a key to null is valid -// (e.g. "bg@foo": null to remove a default entry). -func allowNullValues(schema *jsonschema.Schema) { - if schema.AdditionalProperties != nil && schema.AdditionalProperties != jsonschema.TrueSchema && schema.AdditionalProperties != jsonschema.FalseSchema { - original := schema.AdditionalProperties - schema.AdditionalProperties = &jsonschema.Schema{ - AnyOf: []*jsonschema.Schema{ - original, - {Type: "null"}, - }, - } - } -} - -func generateSchema(template any, dir string, allowNull bool) error { - settingsSchema := jsonschema.Reflect(template) - if allowNull { - allowNullValues(settingsSchema) - } - - jsonSettingsSchema, err := json.MarshalIndent(settingsSchema, "", " ") - if err != nil { - return fmt.Errorf("failed to parse local schema: %w", err) - } - written, err := utilfn.WriteFileIfDifferent(dir, jsonSettingsSchema) - if !written { - fmt.Fprintf(os.Stderr, "no changes to %s\n", dir) - } - if err != nil { - return fmt.Errorf("failed to write local schema: %w", err) - } - return nil -} - -func generateWidgetsSchema(dir string) error { - metaT := reflect.TypeOf(waveobj.MetaMapType(nil)) - - // Build the hints schema once using an expanded reflector - hr := &jsonschema.Reflector{ - DoNotReference: true, - ExpandedStruct: true, - AllowAdditionalProperties: true, - } - hintSchema := hr.Reflect(&WidgetsMetaSchemaHints{}) - - r := &jsonschema.Reflector{} - r.Mapper = func(t reflect.Type) *jsonschema.Schema { - if t == metaT { - return &jsonschema.Schema{ - Type: "object", - Properties: hintSchema.Properties, - AdditionalProperties: jsonschema.TrueSchema, - } - } - return nil - } - - widgetsTemplate := make(map[string]wconfig.WidgetConfigType) - widgetsSchema := r.Reflect(&widgetsTemplate) - allowNullValues(widgetsSchema) - - jsonWidgetsSchema, err := json.MarshalIndent(widgetsSchema, "", " ") - if err != nil { - return fmt.Errorf("failed to parse widgets schema: %w", err) - } - written, err := utilfn.WriteFileIfDifferent(dir, jsonWidgetsSchema) - if !written { - fmt.Fprintf(os.Stderr, "no changes to %s\n", dir) - } - if err != nil { - return fmt.Errorf("failed to write widgets schema: %w", err) - } - return nil -} - -func main() { - err := generateSchema(&wconfig.SettingsType{}, WaveSchemaSettingsFileName, false) - if err != nil { - log.Fatalf("settings schema error: %v", err) - } - - connectionTemplate := make(map[string]wconfig.ConnKeywords) - err = generateSchema(&connectionTemplate, WaveSchemaConnectionsFileName, false) - if err != nil { - log.Fatalf("connections schema error: %v", err) - } - - aiPresetsTemplate := make(map[string]wconfig.AiSettingsType) - err = generateSchema(&aiPresetsTemplate, WaveSchemaAiPresetsFileName, false) - if err != nil { - log.Fatalf("ai presets schema error: %v", err) - } - - err = generateWidgetsSchema(WaveSchemaWidgetsFileName) - if err != nil { - log.Fatalf("widgets schema error: %v", err) - } - - backgroundsTemplate := make(map[string]wconfig.BackgroundConfigType) - err = generateSchema(&backgroundsTemplate, WaveSchemaBackgroundsFileName, true) - if err != nil { - log.Fatalf("backgrounds schema error: %v", err) - } - - waveAITemplate := make(map[string]wconfig.AIModeConfigType) - err = generateSchema(&waveAITemplate, WaveSchemaWaveAIFileName, false) - if err != nil { - log.Fatalf("waveai schema error: %v", err) - } -} diff --git a/cmd/generatets/main-generatets.go b/cmd/generatets/main-generatets.go deleted file mode 100644 index f282f9fa19..0000000000 --- a/cmd/generatets/main-generatets.go +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "bytes" - "fmt" - "os" - "reflect" - "sort" - "strings" - - "github.com/wavetermdev/waveterm/pkg/service" - "github.com/wavetermdev/waveterm/pkg/tsgen" - "github.com/wavetermdev/waveterm/pkg/util/utilfn" - "github.com/wavetermdev/waveterm/pkg/wshrpc" -) - -func generateTypesFile(tsTypesMap map[reflect.Type]string) error { - fileName := "frontend/types/gotypes.d.ts" - fmt.Fprintf(os.Stderr, "generating types file to %s\n", fileName) - tsgen.GenerateWaveObjTypes(tsTypesMap) - tsgen.GenerateWaveEventTypes(tsTypesMap) - err := tsgen.GenerateServiceTypes(tsTypesMap) - if err != nil { - fmt.Fprintf(os.Stderr, "Error generating service types: %v\n", err) - os.Exit(1) - } - err = tsgen.GenerateWshServerTypes(tsTypesMap) - if err != nil { - return fmt.Errorf("error generating wsh server types: %w", err) - } - var buf bytes.Buffer - fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") - fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") - fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") - fmt.Fprintf(&buf, "declare global {\n\n") - var keys []reflect.Type - for key := range tsTypesMap { - keys = append(keys, key) - } - sort.Slice(keys, func(i, j int) bool { - iname, _ := tsgen.TypeToTSType(keys[i], tsTypesMap) - jname, _ := tsgen.TypeToTSType(keys[j], tsTypesMap) - return iname < jname - }) - for _, key := range keys { - // don't output generic types - if strings.Contains(key.Name(), "[") { - continue - } - tsCode := tsTypesMap[key] - istr := utilfn.IndentString(" ", tsCode) - fmt.Fprint(&buf, istr) - } - fmt.Fprintf(&buf, "}\n\n") - fmt.Fprintf(&buf, "export {}\n") - written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) - if !written { - fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) - } - return err -} - -func generateWaveEventFile(tsTypesMap map[reflect.Type]string) error { - fileName := "frontend/types/waveevent.d.ts" - fmt.Fprintf(os.Stderr, "generating waveevent file to %s\n", fileName) - var buf bytes.Buffer - fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") - fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") - fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") - fmt.Fprintf(&buf, "declare global {\n\n") - fmt.Fprint(&buf, utilfn.IndentString(" ", tsgen.GenerateWaveEventTypes(tsTypesMap))) - fmt.Fprintf(&buf, "}\n\n") - fmt.Fprintf(&buf, "export {}\n") - written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) - if !written { - fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) - } - return err -} - -func generateServicesFile(tsTypesMap map[reflect.Type]string) error { - fileName := "frontend/app/store/services.ts" - var buf bytes.Buffer - fmt.Fprintf(os.Stderr, "generating services file to %s\n", fileName) - fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") - fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") - fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") - fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n") - fmt.Fprintf(&buf, "import type { WaveEnv } from \"@/app/waveenv/waveenv\";\n\n") - fmt.Fprintf(&buf, "function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise {\n") - fmt.Fprintf(&buf, " if (waveEnv != null) {\n") - fmt.Fprintf(&buf, " return waveEnv.callBackendService(service, method, args, noUIContext)\n") - fmt.Fprintf(&buf, " }\n") - fmt.Fprintf(&buf, " return WOS.callBackendService(service, method, args, noUIContext);\n") - fmt.Fprintf(&buf, "}\n\n") - orderedKeys := utilfn.GetOrderedMapKeys(service.ServiceMap) - for _, serviceName := range orderedKeys { - serviceObj := service.ServiceMap[serviceName] - svcStr := tsgen.GenerateServiceClass(serviceName, serviceObj, tsTypesMap) - fmt.Fprint(&buf, svcStr) - fmt.Fprint(&buf, "\n") - } - fmt.Fprintf(&buf, "export const AllServiceTypes = {\n") - for _, serviceName := range orderedKeys { - serviceObj := service.ServiceMap[serviceName] - serviceType := reflect.TypeOf(serviceObj) - tsServiceName := serviceType.Elem().Name() - fmt.Fprintf(&buf, " %q: %sType,\n", serviceName, tsServiceName) - } - fmt.Fprintf(&buf, "};\n\n") - fmt.Fprintf(&buf, "export const AllServiceImpls = {\n") - for _, serviceName := range orderedKeys { - serviceObj := service.ServiceMap[serviceName] - serviceType := reflect.TypeOf(serviceObj) - tsServiceName := serviceType.Elem().Name() - fmt.Fprintf(&buf, " %q: %s,\n", serviceName, tsServiceName) - } - fmt.Fprintf(&buf, "};\n") - written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) - if !written { - fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) - } - return err -} - -func generateWshClientApiFile(tsTypeMap map[reflect.Type]string) error { - fileName := "frontend/app/store/wshclientapi.ts" - var buf bytes.Buffer - declMap := wshrpc.GenerateWshCommandDeclMap() - fmt.Fprintf(os.Stderr, "generating wshclientapi file to %s\n", fileName) - fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") - fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") - fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") - fmt.Fprintf(&buf, "import { WshClient } from \"./wshclient\";\n\n") - fmt.Fprintf(&buf, "export interface MockRpcClient {\n") - fmt.Fprintf(&buf, " mockWshRpcCall(client: WshClient, command: string, data: any, opts?: RpcOpts): Promise;\n") - fmt.Fprintf(&buf, " mockWshRpcStream(client: WshClient, command: string, data: any, opts?: RpcOpts): AsyncGenerator;\n") - fmt.Fprintf(&buf, "}\n\n") - orderedKeys := utilfn.GetOrderedMapKeys(declMap) - fmt.Fprintf(&buf, "// WshServerCommandToDeclMap\n") - fmt.Fprintf(&buf, "export class RpcApiType {\n") - fmt.Fprintf(&buf, " mockClient: MockRpcClient = null;\n\n") - fmt.Fprintf(&buf, " setMockRpcClient(client: MockRpcClient): void {\n") - fmt.Fprintf(&buf, " this.mockClient = client;\n") - fmt.Fprintf(&buf, " }\n\n") - for _, methodDecl := range orderedKeys { - methodDecl := declMap[methodDecl] - methodStr := tsgen.GenerateWshClientApiMethod(methodDecl, tsTypeMap) - fmt.Fprint(&buf, methodStr) - fmt.Fprintf(&buf, "\n") - } - fmt.Fprintf(&buf, "}\n\n") - fmt.Fprintf(&buf, "export const RpcApi = new RpcApiType();\n") - written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) - if !written { - fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) - } - return err -} - -func main() { - err := service.ValidateServiceMap() - if err != nil { - fmt.Fprintf(os.Stderr, "Error validating service map: %v\n", err) - os.Exit(1) - } - tsTypesMap := make(map[reflect.Type]string) - err = generateTypesFile(tsTypesMap) - if err != nil { - fmt.Fprintf(os.Stderr, "Error generating types file: %v\n", err) - os.Exit(1) - } - err = generateServicesFile(tsTypesMap) - if err != nil { - fmt.Fprintf(os.Stderr, "Error generating services file: %v\n", err) - os.Exit(1) - } - err = generateWaveEventFile(tsTypesMap) - if err != nil { - fmt.Fprintf(os.Stderr, "Error generating wave event file: %v\n", err) - os.Exit(1) - } - err = generateWshClientApiFile(tsTypesMap) - if err != nil { - fmt.Fprintf(os.Stderr, "Error generating wshserver file: %v\n", err) - os.Exit(1) - } -} diff --git a/cmd/packfiles/main-packfiles.go b/cmd/packfiles/main-packfiles.go deleted file mode 100644 index ac0fd52acf..0000000000 --- a/cmd/packfiles/main-packfiles.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "bufio" - "fmt" - "io" - "os" - "path/filepath" -) - -func main() { - // Ensure at least one argument is provided - if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "Usage: go run main.go ...") - os.Exit(1) - } - - // Get the current working directory - cwd, err := os.Getwd() - if err != nil { - fmt.Fprintf(os.Stderr, "Error getting current working directory: %v\n", err) - os.Exit(1) - } - - for _, filePath := range os.Args[1:] { - if filePath == "" || filePath == "--" { - continue - } - // Convert file path to an absolute path - absPath, err := filepath.Abs(filePath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error resolving absolute path for %q: %v\n", filePath, err) - continue - } - - finfo, err := os.Stat(absPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error getting file info for %q: %v\n", absPath, err) - continue - } - if finfo.IsDir() { - fmt.Fprintf(os.Stderr, "%q is a directory, skipping\n", absPath) - continue - } - - // Get the path relative to the current working directory - relPath, err := filepath.Rel(cwd, absPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error resolving relative path for %q: %v\n", absPath, err) - continue - } - - // Open the file - file, err := os.Open(absPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error opening file %q: %v\n", absPath, err) - continue - } - defer file.Close() - - // Print start delimiter with quoted relative path - fmt.Printf("@@@start file %q\n", relPath) - - // Copy file contents to stdout - reader := bufio.NewReader(file) - for { - line, err := reader.ReadString('\n') - fmt.Print(line) // Print each line - if err == io.EOF { - break - } - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading file %q: %v\n", relPath, err) - break - } - } - - // Print end delimiter with quoted relative path - fmt.Printf("@@@end file %q\n", relPath) - } -} diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go deleted file mode 100644 index b204643ee8..0000000000 --- a/cmd/server/main-server.go +++ /dev/null @@ -1,616 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "context" - "fmt" - "log" - "os" - - "runtime" - "sync" - "time" - - "github.com/joho/godotenv" - "github.com/wavetermdev/waveterm/pkg/aiusechat" - "github.com/wavetermdev/waveterm/pkg/authkey" - "github.com/wavetermdev/waveterm/pkg/blockcontroller" - "github.com/wavetermdev/waveterm/pkg/blocklogger" - "github.com/wavetermdev/waveterm/pkg/filebackup" - "github.com/wavetermdev/waveterm/pkg/filestore" - "github.com/wavetermdev/waveterm/pkg/jobcontroller" - "github.com/wavetermdev/waveterm/pkg/panichandler" - "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" - "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" - "github.com/wavetermdev/waveterm/pkg/secretstore" - "github.com/wavetermdev/waveterm/pkg/service" - "github.com/wavetermdev/waveterm/pkg/telemetry" - "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" - "github.com/wavetermdev/waveterm/pkg/util/envutil" - "github.com/wavetermdev/waveterm/pkg/util/shellutil" - "github.com/wavetermdev/waveterm/pkg/util/sigutil" - "github.com/wavetermdev/waveterm/pkg/util/utilfn" - "github.com/wavetermdev/waveterm/pkg/wavebase" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wcloud" - "github.com/wavetermdev/waveterm/pkg/wconfig" - "github.com/wavetermdev/waveterm/pkg/wcore" - "github.com/wavetermdev/waveterm/pkg/web" - "github.com/wavetermdev/waveterm/pkg/wps" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver" - "github.com/wavetermdev/waveterm/pkg/wshutil" - "github.com/wavetermdev/waveterm/pkg/wslconn" - "github.com/wavetermdev/waveterm/pkg/wstore" - - "net/http" - _ "net/http/pprof" -) - -// these are set at build time -var WaveVersion = "0.0.0" -var BuildTime = "0" - -const InitialTelemetryWait = 10 * time.Second -const TelemetryTick = 2 * time.Minute -const TelemetryInterval = 4 * time.Hour -const TelemetryInitialCountsWait = 5 * time.Second -const TelemetryCountsInterval = 1 * time.Hour -const BackupCleanupTick = 2 * time.Minute -const BackupCleanupInterval = 4 * time.Hour -const InitialDiagnosticWait = 5 * time.Minute -const DiagnosticTick = 10 * time.Minute - -var shutdownOnce sync.Once - -func init() { - envFilePath := os.Getenv("WAVETERM_ENVFILE") - if envFilePath != "" { - log.Printf("applying env file: %s\n", envFilePath) - _ = godotenv.Load(envFilePath) - } -} - -func doShutdown(reason string) { - shutdownOnce.Do(func() { - log.Printf("shutting down: %s\n", reason) - ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelFn() - go blockcontroller.StopAllBlockControllersForShutdown() - shutdownActivityUpdate() - sendTelemetryWrapper() - // TODO deal with flush in progress - clearTempFiles() - filestore.WFS.FlushCache(ctx) - watcher := wconfig.GetWatcher() - if watcher != nil { - watcher.Close() - } - time.Sleep(500 * time.Millisecond) - log.Printf("shutdown complete\n") - os.Exit(0) - }) -} - -// watch stdin, kill server if stdin is closed -func stdinReadWatch() { - defer func() { - panichandler.PanicHandler("stdinReadWatch", recover()) - }() - buf := make([]byte, 1024) - for { - _, err := os.Stdin.Read(buf) - if err != nil { - doShutdown(fmt.Sprintf("stdin closed/error (%v)", err)) - break - } - } -} - -func startConfigWatcher() { - watcher := wconfig.GetWatcher() - if watcher != nil { - watcher.Start() - } -} - -func telemetryLoop() { - defer func() { - panichandler.PanicHandler("telemetryLoop", recover()) - }() - var nextSend int64 - time.Sleep(InitialTelemetryWait) - for { - if time.Now().Unix() > nextSend { - nextSend = time.Now().Add(TelemetryInterval).Unix() - sendTelemetryWrapper() - } - time.Sleep(TelemetryTick) - } -} - -func diagnosticLoop() { - defer func() { - panichandler.PanicHandler("diagnosticLoop", recover()) - }() - if os.Getenv("WAVETERM_NOPING") != "" { - log.Printf("WAVETERM_NOPING set, disabling diagnostic ping\n") - return - } - var lastSentDate string - time.Sleep(InitialDiagnosticWait) - for { - currentDate := time.Now().Format("2006-01-02") - if lastSentDate == "" || lastSentDate != currentDate { - if sendDiagnosticPing() { - lastSentDate = currentDate - } - } - time.Sleep(DiagnosticTick) - } -} - -func sendDiagnosticPing() bool { - ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelFn() - - rpcClient := wshclient.GetBareRpcClient() - isOnline, err := wshclient.NetworkOnlineCommand(rpcClient, &wshrpc.RpcOpts{Route: "electron", Timeout: 2000}) - if err != nil || !isOnline { - return false - } - clientId := wstore.GetClientId() - usageTelemetry := telemetry.IsTelemetryEnabled() - wcloud.SendDiagnosticPing(ctx, clientId, usageTelemetry) - return true -} - -func setupTelemetryConfigHandler() { - watcher := wconfig.GetWatcher() - if watcher == nil { - return - } - currentConfig := watcher.GetFullConfig() - currentTelemetryEnabled := currentConfig.Settings.TelemetryEnabled - - watcher.RegisterUpdateHandler(func(newConfig wconfig.FullConfigType) { - newTelemetryEnabled := newConfig.Settings.TelemetryEnabled - if newTelemetryEnabled != currentTelemetryEnabled { - currentTelemetryEnabled = newTelemetryEnabled - wcore.GoSendNoTelemetryUpdate(newTelemetryEnabled) - } - }) -} - -func backupCleanupLoop() { - defer func() { - panichandler.PanicHandler("backupCleanupLoop", recover()) - }() - var nextCleanup int64 - for { - if time.Now().Unix() > nextCleanup { - nextCleanup = time.Now().Add(BackupCleanupInterval).Unix() - err := filebackup.CleanupOldBackups() - if err != nil { - log.Printf("error cleaning up old backups: %v\n", err) - } - } - time.Sleep(BackupCleanupTick) - } -} - -func panicTelemetryHandler(panicName string) { - activity := wshrpc.ActivityUpdate{NumPanics: 1} - err := telemetry.UpdateActivity(context.Background(), activity) - if err != nil { - log.Printf("error updating activity (panicTelemetryHandler): %v\n", err) - } - telemetry.RecordTEvent(context.Background(), telemetrydata.MakeTEvent("debug:panic", telemetrydata.TEventProps{ - PanicType: panicName, - })) -} - -func sendTelemetryWrapper() { - defer func() { - panichandler.PanicHandler("sendTelemetryWrapper", recover()) - }() - ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second) - defer cancelFn() - beforeSendActivityUpdate(ctx) - clientId := wstore.GetClientId() - err := wcloud.SendAllTelemetry(clientId) - if err != nil { - log.Printf("[error] sending telemetry: %v\n", err) - } -} - -func updateTelemetryCounts(lastCounts telemetrydata.TEventProps) telemetrydata.TEventProps { - ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelFn() - var props telemetrydata.TEventProps - props.CountBlocks, _ = wstore.DBGetCount[*waveobj.Block](ctx) - props.CountTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx) - props.CountWindows, _ = wstore.DBGetCount[*waveobj.Window](ctx) - props.CountWorkspaces, _, _ = wstore.DBGetWSCounts(ctx) - props.CountSSHConn = conncontroller.GetNumSSHHasConnected() - props.CountWSLConn = wslconn.GetNumWSLHasConnected() - props.CountJobs = jobcontroller.GetNumJobsRunning() - props.CountJobsConnected = jobcontroller.GetNumJobsConnected() - props.CountViews, _ = wstore.DBGetBlockViewCounts(ctx) - - fullConfig := wconfig.GetWatcher().GetFullConfig() - customWidgets := fullConfig.CountCustomWidgets() - customAIPresets := fullConfig.CountCustomAIPresets() - customSettings := wconfig.CountCustomSettings() - customAIModes := fullConfig.CountCustomAIModes() - - props.UserSet = &telemetrydata.TEventUserProps{ - SettingsCustomWidgets: customWidgets, - SettingsCustomAIPresets: customAIPresets, - SettingsCustomSettings: customSettings, - SettingsCustomAIModes: customAIModes, - } - - secretsCount, err := secretstore.CountSecrets() - if err == nil { - props.UserSet.SettingsSecretsCount = secretsCount - } - - if utilfn.CompareAsMarshaledJson(props, lastCounts) { - return lastCounts - } - tevent := telemetrydata.MakeTEvent("app:counts", props) - err = telemetry.RecordTEvent(ctx, tevent) - if err != nil { - log.Printf("error recording counts tevent: %v\n", err) - } - return props -} - -func updateTelemetryCountsLoop() { - defer func() { - panichandler.PanicHandler("updateTelemetryCountsLoop", recover()) - }() - var nextSend int64 - var lastCounts telemetrydata.TEventProps - time.Sleep(TelemetryInitialCountsWait) - for { - if time.Now().Unix() > nextSend { - nextSend = time.Now().Add(TelemetryCountsInterval).Unix() - lastCounts = updateTelemetryCounts(lastCounts) - } - time.Sleep(TelemetryTick) - } -} - -func beforeSendActivityUpdate(ctx context.Context) { - activity := wshrpc.ActivityUpdate{} - activity.NumTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx) - activity.NumBlocks, _ = wstore.DBGetCount[*waveobj.Block](ctx) - activity.Blocks, _ = wstore.DBGetBlockViewCounts(ctx) - activity.NumWindows, _ = wstore.DBGetCount[*waveobj.Window](ctx) - activity.NumSSHConn = conncontroller.GetNumSSHHasConnected() - activity.NumWSLConn = wslconn.GetNumWSLHasConnected() - activity.NumWSNamed, activity.NumWS, _ = wstore.DBGetWSCounts(ctx) - err := telemetry.UpdateActivity(ctx, activity) - if err != nil { - log.Printf("error updating before activity: %v\n", err) - } -} - -func startupActivityUpdate(firstLaunch bool) { - defer func() { - panichandler.PanicHandler("startupActivityUpdate", recover()) - }() - ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelFn() - activity := wshrpc.ActivityUpdate{Startup: 1} - err := telemetry.UpdateActivity(ctx, activity) // set at least one record into activity (don't use go routine wrap here) - if err != nil { - log.Printf("error updating startup activity: %v\n", err) - } - autoUpdateChannel := telemetry.AutoUpdateChannel() - autoUpdateEnabled := telemetry.IsAutoUpdateEnabled() - shellType, shellVersion, shellErr := shellutil.DetectShellTypeAndVersion() - if shellErr != nil { - shellType = "error" - shellVersion = "" - } - userSetOnce := &telemetrydata.TEventUserProps{ - ClientInitialVersion: "v" + WaveVersion, - } - tosTs := telemetry.GetTosAgreedTs() - var cohortTime time.Time - if tosTs > 0 { - cohortTime = time.UnixMilli(tosTs) - } else { - cohortTime = time.Now() - } - cohortMonth := cohortTime.Format("2006-01") - year, week := cohortTime.ISOWeek() - cohortISOWeek := fmt.Sprintf("%04d-W%02d", year, week) - userSetOnce.CohortMonth = cohortMonth - userSetOnce.CohortISOWeek = cohortISOWeek - fullConfig := wconfig.GetWatcher().GetFullConfig() - props := telemetrydata.TEventProps{ - UserSet: &telemetrydata.TEventUserProps{ - ClientVersion: "v" + wavebase.WaveVersion, - ClientBuildTime: wavebase.BuildTime, - ClientArch: wavebase.ClientArch(), - ClientOSRelease: wavebase.UnameKernelRelease(), - ClientIsDev: wavebase.IsDevMode(), - ClientPackageType: wavebase.ClientPackageType(), - ClientMacOSVersion: wavebase.ClientMacOSVersion(), - AutoUpdateChannel: autoUpdateChannel, - AutoUpdateEnabled: autoUpdateEnabled, - LocalShellType: shellType, - LocalShellVersion: shellVersion, - SettingsTransparent: fullConfig.Settings.WindowTransparent, - }, - UserSetOnce: userSetOnce, - } - if firstLaunch { - props.AppFirstLaunch = true - } - tevent := telemetrydata.MakeTEvent("app:startup", props) - err = telemetry.RecordTEvent(ctx, tevent) - if err != nil { - log.Printf("error recording startup event: %v\n", err) - } -} - -func shutdownActivityUpdate() { - ctx, cancelFn := context.WithTimeout(context.Background(), 1*time.Second) - defer cancelFn() - activity := wshrpc.ActivityUpdate{Shutdown: 1} - err := telemetry.UpdateActivity(ctx, activity) // do NOT use the go routine wrap here (this needs to be synchronous) - if err != nil { - log.Printf("error updating shutdown activity: %v\n", err) - } - err = telemetry.TruncateActivityTEventForShutdown(ctx) - if err != nil { - log.Printf("error truncating activity t-event for shutdown: %v\n", err) - } - tevent := telemetrydata.MakeTEvent("app:shutdown", telemetrydata.TEventProps{}) - err = telemetry.RecordTEvent(ctx, tevent) - if err != nil { - log.Printf("error recording shutdown event: %v\n", err) - } -} - -func createMainWshClient() { - rpc := wshserver.GetMainRpcClient() - wshutil.DefaultRouter.RegisterTrustedLeaf(rpc, wshutil.DefaultRoute) - wps.Broker.SetClient(wshutil.DefaultRouter) - localInitialEnv := envutil.PruneInitialEnv(envutil.SliceToMap(os.Environ())) - sockName := wavebase.GetDomainSocketName() - remoteImpl := wshremote.MakeRemoteRpcServerImpl(nil, wshutil.DefaultRouter, wshclient.GetBareRpcClient(), true, localInitialEnv, sockName) - localConnWsh := wshutil.MakeWshRpc(wshrpc.RpcContext{Conn: wshrpc.LocalConnName}, remoteImpl, "conn:local") - go wshremote.RunSysInfoLoop(localConnWsh, wshrpc.LocalConnName) - wshutil.DefaultRouter.RegisterTrustedLeaf(localConnWsh, wshutil.MakeConnectionRouteId(wshrpc.LocalConnName)) - wshfs.RpcClient = localConnWsh - wshfs.RpcClientRouteId = wshutil.MakeConnectionRouteId(wshrpc.LocalConnName) -} - -func grabAndRemoveEnvVars() error { - err := authkey.SetAuthKeyFromEnv() - if err != nil { - return fmt.Errorf("setting auth key: %v", err) - } - err = wavebase.CacheAndRemoveEnvVars() - if err != nil { - return err - } - err = wcloud.CacheAndRemoveEnvVars() - if err != nil { - return err - } - - // Remove WAVETERM env vars that leak from prod => dev - os.Unsetenv("WAVETERM_CLIENTID") - os.Unsetenv("WAVETERM_WORKSPACEID") - os.Unsetenv("WAVETERM_TABID") - os.Unsetenv("WAVETERM_BLOCKID") - os.Unsetenv("WAVETERM_CONN") - os.Unsetenv("WAVETERM_JWT") - os.Unsetenv("WAVETERM_VERSION") - - return nil -} - -func clearTempFiles() error { - ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) - defer cancelFn() - client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) - if err != nil { - return fmt.Errorf("error getting client: %v", err) - } - filestore.WFS.DeleteZone(ctx, client.TempOID) - return nil -} - -func maybeStartPprofServer() { - settings := wconfig.GetWatcher().GetFullConfig().Settings - if settings.DebugPprofMemProfileRate != nil { - runtime.MemProfileRate = *settings.DebugPprofMemProfileRate - log.Printf("set runtime.MemProfileRate to %d\n", runtime.MemProfileRate) - } - if settings.DebugPprofPort == nil { - return - } - pprofPort := *settings.DebugPprofPort - if pprofPort < 1 || pprofPort > 65535 { - log.Printf("[error] debug:pprofport must be between 1 and 65535, got %d\n", pprofPort) - return - } - go func() { - addr := fmt.Sprintf("localhost:%d", pprofPort) - log.Printf("starting pprof server on %s\n", addr) - if err := http.ListenAndServe(addr, nil); err != nil { - log.Printf("[error] pprof server failed: %v\n", err) - } - }() -} - -func main() { - log.SetFlags(0) // disable timestamp since electron's winston logger already wraps with timestamp - log.SetPrefix("[wavesrv] ") - wavebase.WaveVersion = WaveVersion - wavebase.BuildTime = BuildTime - wshutil.DefaultRouter = wshutil.NewWshRouter() - wshutil.DefaultRouter.SetAsRootRouter() - - err := grabAndRemoveEnvVars() - if err != nil { - log.Printf("[error] %v\n", err) - return - } - err = service.ValidateServiceMap() - if err != nil { - log.Printf("error validating service map: %v\n", err) - return - } - err = wavebase.EnsureWaveDataDir() - if err != nil { - log.Printf("error ensuring wave home dir: %v\n", err) - return - } - err = wavebase.EnsureWaveDBDir() - if err != nil { - log.Printf("error ensuring wave db dir: %v\n", err) - return - } - err = wavebase.EnsureWaveConfigDir() - if err != nil { - log.Printf("error ensuring wave config dir: %v\n", err) - return - } - - // TODO: rather than ensure this dir exists, we should let the editor recursively create parent dirs on save - err = wavebase.EnsureWavePresetsDir() - if err != nil { - log.Printf("error ensuring wave presets dir: %v\n", err) - return - } - err = wavebase.EnsureWaveCachesDir() - if err != nil { - log.Printf("error ensuring wave caches dir: %v\n", err) - return - } - waveLock, err := wavebase.AcquireWaveLock() - if err != nil { - log.Printf("error acquiring wave lock (another instance of Wave is likely running): %v\n", err) - return - } - defer func() { - err = waveLock.Close() - if err != nil { - log.Printf("error releasing wave lock: %v\n", err) - } - }() - log.Printf("wave version: %s (%s)\n", WaveVersion, BuildTime) - log.Printf("wave data dir: %s\n", wavebase.GetWaveDataDir()) - log.Printf("wave config dir: %s\n", wavebase.GetWaveConfigDir()) - err = filestore.InitFilestore() - if err != nil { - log.Printf("error initializing filestore: %v\n", err) - return - } - err = wstore.InitWStore() - if err != nil { - log.Printf("error initializing wstore: %v\n", err) - return - } - panichandler.PanicTelemetryHandler = panicTelemetryHandler - go func() { - defer func() { - panichandler.PanicHandler("InitCustomShellStartupFiles", recover()) - }() - err := shellutil.InitCustomShellStartupFiles() - if err != nil { - log.Printf("error initializing wsh and shell-integration files: %v\n", err) - } - }() - firstLaunch, err := wcore.EnsureInitialData() - if err != nil { - log.Printf("error ensuring initial data: %v\n", err) - return - } - if firstLaunch { - log.Printf("first launch detected") - } - err = clearTempFiles() - if err != nil { - log.Printf("error clearing temp files: %v\n", err) - return - } - err = wcore.InitMainServer() - if err != nil { - log.Printf("error initializing mainserver: %v\n", err) - return - } - - err = shellutil.FixupWaveZshHistory() - if err != nil { - log.Printf("error fixing up wave zsh history: %v\n", err) - } - createMainWshClient() - sigutil.InstallShutdownSignalHandlers(doShutdown) - sigutil.InstallSIGUSR1Handler() - wconfig.MigratePresetsBackgrounds() - startConfigWatcher() - aiusechat.InitAIModeConfigWatcher() - maybeStartPprofServer() - go stdinReadWatch() - go telemetryLoop() - go diagnosticLoop() - setupTelemetryConfigHandler() - go updateTelemetryCountsLoop() - go backupCleanupLoop() - go startupActivityUpdate(firstLaunch) // must be after startConfigWatcher() - blocklogger.InitBlockLogger() - jobcontroller.InitJobController() - blockcontroller.InitBlockController() - err = wcore.InitBadgeStore() - if err != nil { - log.Printf("error initializing badge store: %v\n", err) - return - } - go func() { - defer func() { - panichandler.PanicHandler("GetSystemSummary", recover()) - }() - wavebase.GetSystemSummary() - }() - - webListener, err := web.MakeTCPListener("web") - if err != nil { - log.Printf("error creating web listener: %v\n", err) - return - } - wsListener, err := web.MakeTCPListener("websocket") - if err != nil { - log.Printf("error creating websocket listener: %v\n", err) - return - } - go web.RunWebSocketServer(wsListener) - unixListener, err := web.MakeUnixListener() - if err != nil { - log.Printf("error creating unix listener: %v\n", err) - return - } - go func() { - if BuildTime == "" { - BuildTime = "0" - } - // use fmt instead of log here to make sure it goes directly to stderr - fmt.Fprintf(os.Stderr, "WAVESRV-ESTART ws:%s web:%s version:%s buildtime:%s\n", wsListener.Addr(), webListener.Addr(), WaveVersion, BuildTime) - }() - go wshutil.RunWshRpcOverListener(unixListener, nil) - web.RunWebServer(webListener) // blocking - runtime.KeepAlive(waveLock) -} diff --git a/cmd/test-conn/cliprovider.go b/cmd/test-conn/cliprovider.go deleted file mode 100644 index 661c40544a..0000000000 --- a/cmd/test-conn/cliprovider.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "bufio" - "context" - "fmt" - "os" - "strings" - - "github.com/wavetermdev/waveterm/pkg/userinput" -) - -type CLIProvider struct { - AutoAccept bool -} - -func (p *CLIProvider) GetUserInput(ctx context.Context, request *userinput.UserInputRequest) (*userinput.UserInputResponse, error) { - response := &userinput.UserInputResponse{ - Type: request.ResponseType, - RequestId: request.RequestId, - } - - if request.Title != "" { - fmt.Printf("\n=== %s ===\n", request.Title) - } - fmt.Printf("%s\n", request.QueryText) - - if p.AutoAccept { - fmt.Printf("Auto-accepting (use -i for interactive mode)\n") - response.Confirm = true - response.Text = "yes" - return response, nil - } - - reader := bufio.NewReader(os.Stdin) - fmt.Printf("Accept? [y/n]: ") - text, err := reader.ReadString('\n') - if err != nil { - response.ErrorMsg = fmt.Sprintf("error reading input: %v", err) - return response, err - } - - text = strings.TrimSpace(strings.ToLower(text)) - if text == "y" || text == "yes" { - response.Confirm = true - response.Text = "yes" - } else { - response.Confirm = false - response.Text = "no" - } - - return response, nil -} diff --git a/cmd/test-conn/main-test-conn.go b/cmd/test-conn/main-test-conn.go deleted file mode 100644 index 7995832278..0000000000 --- a/cmd/test-conn/main-test-conn.go +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "flag" - "fmt" - "log" - "os" - "time" -) - -var ( - WaveVersion = "0.0.0" - BuildTime = "0" -) - -func usage() { - fmt.Fprintf(os.Stderr, `Test Harness for SSH Connection Flows - -Usage: - test-conn [flags] [args...] - -Commands: - connect - Test basic SSH connection with wsh - ssh - Test basic SSH connection - exec - Execute command and show output (no wsh) - wshexec - Execute command with wsh enabled - shell - Start interactive shell session - -Flags: - -t duration Connection timeout (default: 60s) - -i Interactive mode (prompt for user input instead of auto-accept) - -v Show version and exit - -Examples: - test-conn ssh user@example.com - test-conn exec user@example.com "ls -la" - test-conn wshexec user@example.com "wsh version" - test-conn -i connect user@example.com - test-conn shell user@example.com - -`) - os.Exit(1) -} - -func main() { - timeoutFlag := flag.Duration("t", 60*time.Second, "connection timeout") - interactiveFlag := flag.Bool("i", false, "interactive mode (prompt for user input)") - versionFlag := flag.Bool("v", false, "show version") - - flag.Usage = usage - flag.Parse() - - if *versionFlag { - fmt.Printf("test-conn version %s (built %s)\n", WaveVersion, BuildTime) - os.Exit(0) - } - - args := flag.Args() - if len(args) < 2 { - usage() - } - - command := args[0] - connName := args[1] - - autoAccept := !*interactiveFlag - - err := initTestHarness(autoAccept) - if err != nil { - log.Fatalf("Failed to initialize: %v", err) - } - - switch command { - case "ssh", "connect": - err = testBasicConnect(connName, *timeoutFlag) - - case "exec": - if len(args) < 3 { - log.Fatalf("exec command requires a command argument") - } - cmd := args[2] - err = testShellWithCommand(connName, cmd, *timeoutFlag) - - case "wshexec": - if len(args) < 3 { - log.Fatalf("wshexec command requires a command argument") - } - cmd := args[2] - err = testWshExec(connName, cmd, *timeoutFlag) - - case "shell": - err = testInteractiveShell(connName, *timeoutFlag) - - default: - log.Fatalf("Unknown command: %s", command) - } - - if err != nil { - log.Fatalf("Error: %v", err) - } -} diff --git a/cmd/test-conn/testutil.go b/cmd/test-conn/testutil.go deleted file mode 100644 index f82e7b7195..0000000000 --- a/cmd/test-conn/testutil.go +++ /dev/null @@ -1,326 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "context" - "fmt" - "log" - "os" - "path/filepath" - "runtime" - "time" - - "github.com/google/uuid" - "github.com/wavetermdev/waveterm/pkg/remote" - "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" - "github.com/wavetermdev/waveterm/pkg/shellexec" - "github.com/wavetermdev/waveterm/pkg/userinput" - "github.com/wavetermdev/waveterm/pkg/util/shellutil" - "github.com/wavetermdev/waveterm/pkg/wavebase" - "github.com/wavetermdev/waveterm/pkg/wavejwt" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wconfig" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver" - "github.com/wavetermdev/waveterm/pkg/wshutil" - "github.com/wavetermdev/waveterm/pkg/wstore" -) - -func setupWaveEnvVars() error { - homeDir, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("failed to get home directory: %w", err) - } - - isDev := os.Getenv("WAVETERM_DEV") != "" - devSuffix := "" - if isDev { - devSuffix = "-dev" - } - - configHome := os.Getenv("WAVETERM_CONFIG_HOME") - if configHome == "" { - configHome = filepath.Join(homeDir, ".config", "waveterm"+devSuffix) - os.Setenv("WAVETERM_CONFIG_HOME", configHome) - } - log.Printf("Using config directory: %s", configHome) - - dataHome := os.Getenv("WAVETERM_DATA_HOME") - if dataHome == "" { - if runtime.GOOS == "darwin" { - dataHome = filepath.Join(homeDir, "Library", "Application Support", "waveterm"+devSuffix) - os.Setenv("WAVETERM_DATA_HOME", dataHome) - } else { - return fmt.Errorf("WAVETERM_DATA_HOME must be set on non-macOS systems") - } - } - log.Printf("Using data directory: %s", dataHome) - - return nil -} - -func initTestHarness(autoAccept bool) error { - log.Printf("Initializing test harness...") - - err := setupWaveEnvVars() - if err != nil { - return fmt.Errorf("failed to setup wave env vars: %w", err) - } - - err = wavebase.CacheAndRemoveEnvVars() - if err != nil { - return fmt.Errorf("failed to cache env vars: %w", err) - } - - wshutil.DefaultRouter = wshutil.NewWshRouter() - wshutil.DefaultRouter.SetAsRootRouter() - - wstore.SetClientId("test-client-" + fmt.Sprintf("%d", time.Now().Unix())) - - userinput.SetUserInputProvider(&CLIProvider{AutoAccept: autoAccept}) - - keyPair, err := wavejwt.GenerateKeyPair() - if err != nil { - return fmt.Errorf("failed to generate JWT key pair: %w", err) - } - - err = wavejwt.SetPrivateKey(keyPair.PrivateKey) - if err != nil { - return fmt.Errorf("failed to set JWT private key: %w", err) - } - - err = wavejwt.SetPublicKey(keyPair.PublicKey) - if err != nil { - return fmt.Errorf("failed to set JWT public key: %w", err) - } - - rpc := wshserver.GetMainRpcClient() - wshutil.DefaultRouter.RegisterTrustedLeaf(rpc, wshutil.DefaultRoute) - - wconfig.GetWatcher().Start() - - log.Printf("Test harness initialized") - return nil -} - -func testBasicConnect(connName string, timeout time.Duration) error { - opts, err := remote.ParseOpts(connName) - if err != nil { - return fmt.Errorf("failed to parse connection string: %w", err) - } - - log.Printf("Connecting to %s...", opts.String()) - - conn := conncontroller.GetConn(opts) - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - err = conn.Connect(ctx, &wconfig.ConnKeywords{}) - if err != nil { - return fmt.Errorf("connection failed: %w", err) - } - - status := conn.DeriveConnStatus() - log.Printf("✓ Connected!") - log.Printf(" Status: %s", status.Status) - log.Printf(" WshEnabled: %v", status.WshEnabled) - log.Printf(" Connection: %s", status.Connection) - if status.WshVersion != "" { - log.Printf(" WshVersion: %s", status.WshVersion) - } - if status.WshError != "" { - log.Printf(" WshError: %s", status.WshError) - } - if status.NoWshReason != "" { - log.Printf(" NoWshReason: %s", status.NoWshReason) - } - - return nil -} - -func testShellWithCommand(connName string, cmd string, timeout time.Duration) error { - opts, err := remote.ParseOpts(connName) - if err != nil { - return fmt.Errorf("failed to parse connection string: %w", err) - } - - log.Printf("Connecting to %s...", opts.String()) - - conn := conncontroller.GetConn(opts) - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - err = conn.Connect(ctx, &wconfig.ConnKeywords{}) - if err != nil { - return fmt.Errorf("connection failed: %w", err) - } - - log.Printf("✓ Connected! Starting shell...") - - termSize := waveobj.TermSize{Rows: 24, Cols: 80} - shellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, "", shellexec.CommandOptsType{}, conn) - if err != nil { - return fmt.Errorf("failed to start shell: %w", err) - } - defer shellProc.Close() - - log.Printf("✓ Shell started! Executing: %s", cmd) - - _, err = shellProc.Cmd.Write([]byte(cmd + "\n")) - if err != nil { - return fmt.Errorf("failed to write command: %w", err) - } - - time.Sleep(500 * time.Millisecond) - - buf := make([]byte, 8192) - n, err := shellProc.Cmd.Read(buf) - if err != nil { - log.Printf("Warning: read error (may be expected): %v", err) - } - - if n > 0 { - log.Printf("\n--- Output ---\n%s\n--- End Output ---", string(buf[:n])) - } else { - log.Printf("No output received (timeout or no data)") - } - - return nil -} - -func testWshExec(connName string, cmd string, timeout time.Duration) error { - opts, err := remote.ParseOpts(connName) - if err != nil { - return fmt.Errorf("failed to parse connection string: %w", err) - } - - log.Printf("Connecting to %s with wsh enabled...", opts.String()) - - conn := conncontroller.GetConn(opts) - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - wshEnabled := true - err = conn.Connect(ctx, &wconfig.ConnKeywords{ - ConnWshEnabled: &wshEnabled, - }) - if err != nil { - return fmt.Errorf("connection failed: %w", err) - } - - status := conn.DeriveConnStatus() - log.Printf("✓ Connected! (wsh enabled: %v)", status.WshEnabled) - if status.WshVersion != "" { - log.Printf(" wsh version: %s", status.WshVersion) - } - if !status.WshEnabled { - log.Printf(" WARNING: wsh not enabled - reason: %s", status.NoWshReason) - } - - log.Printf("Starting wsh-enabled shell...") - - swapToken := &shellutil.TokenSwapEntry{ - Token: uuid.New().String(), - Env: make(map[string]string), - Exp: time.Now().Add(5 * time.Minute), - } - swapToken.Env["TERM_PROGRAM"] = "waveterm" - swapToken.Env["WAVETERM"] = "1" - swapToken.Env["WAVETERM_VERSION"] = wavebase.WaveVersion - swapToken.Env["WAVETERM_CONN"] = connName - - cmdOpts := shellexec.CommandOptsType{ - SwapToken: swapToken, - } - - termSize := waveobj.TermSize{Rows: 24, Cols: 80} - shellProc, err := shellexec.StartRemoteShellProc(ctx, ctx, termSize, "", cmdOpts, conn) - if err != nil { - return fmt.Errorf("failed to start shell: %w", err) - } - defer shellProc.Close() - - log.Printf("✓ Shell started! Executing: %s", cmd) - - _, err = shellProc.Cmd.Write([]byte(cmd + "\n")) - if err != nil { - return fmt.Errorf("failed to write command: %w", err) - } - - time.Sleep(500 * time.Millisecond) - - buf := make([]byte, 8192) - n, err := shellProc.Cmd.Read(buf) - if err != nil { - log.Printf("Warning: read error (may be expected): %v", err) - } - - if n > 0 { - log.Printf("\n--- Output ---\n%s\n--- End Output ---", string(buf[:n])) - } else { - log.Printf("No output received (timeout or no data)") - } - - return nil -} - -func testInteractiveShell(connName string, timeout time.Duration) error { - opts, err := remote.ParseOpts(connName) - if err != nil { - return fmt.Errorf("failed to parse connection string: %w", err) - } - - log.Printf("Connecting to %s...", opts.String()) - - conn := conncontroller.GetConn(opts) - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - err = conn.Connect(ctx, &wconfig.ConnKeywords{}) - if err != nil { - return fmt.Errorf("connection failed: %w", err) - } - - log.Printf("✓ Connected! Starting interactive shell...") - log.Printf("Note: This is a simple test - output may be mixed with prompts") - log.Printf("Type commands and press Enter. Type 'exit' to quit.\n") - - termSize := waveobj.TermSize{Rows: 24, Cols: 80} - shellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, "", shellexec.CommandOptsType{}, conn) - if err != nil { - return fmt.Errorf("failed to start shell: %w", err) - } - defer shellProc.Close() - - go func() { - buf := make([]byte, 8192) - for { - n, err := shellProc.Cmd.Read(buf) - if err != nil { - return - } - if n > 0 { - fmt.Print(string(buf[:n])) - } - } - }() - - go func() { - buf := make([]byte, 1) - for { - n, err := os.Stdin.Read(buf) - if err != nil { - return - } - if n > 0 { - shellProc.Cmd.Write(buf[:n]) - } - } - }() - - shellProc.Wait() - log.Printf("\nShell exited") - - return nil -} diff --git a/cmd/test-streammanager/bridge.go b/cmd/test-streammanager/bridge.go deleted file mode 100644 index 501adc3d32..0000000000 --- a/cmd/test-streammanager/bridge.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "fmt" - - "github.com/wavetermdev/waveterm/pkg/wshrpc" -) - -// WriterBridge - used by the writer broker -// Sends data to the pipe, receives acks from the pipe -type WriterBridge struct { - pipe *DeliveryPipe -} - -func (b *WriterBridge) StreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error { - b.pipe.EnqueueData(data) - return nil -} - -func (b *WriterBridge) StreamDataAckCommand(ack wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error { - return fmt.Errorf("writer bridge should not send acks") -} - -// ReaderBridge - used by the reader broker -// Sends acks to the pipe, receives data from the pipe -type ReaderBridge struct { - pipe *DeliveryPipe -} - -func (b *ReaderBridge) StreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error { - return fmt.Errorf("reader bridge should not send data") -} - -func (b *ReaderBridge) StreamDataAckCommand(ack wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error { - b.pipe.EnqueueAck(ack) - return nil -} diff --git a/cmd/test-streammanager/deliverypipe.go b/cmd/test-streammanager/deliverypipe.go deleted file mode 100644 index 8f8451f45a..0000000000 --- a/cmd/test-streammanager/deliverypipe.go +++ /dev/null @@ -1,249 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "encoding/base64" - "math/rand" - "sort" - "sync" - "time" - - "github.com/wavetermdev/waveterm/pkg/wshrpc" -) - -type DeliveryConfig struct { - Delay time.Duration - Skew time.Duration -} - -type taggedPacket struct { - seq uint64 - deliveryTime time.Time - isData bool - dataPk wshrpc.CommandStreamData - ackPk wshrpc.CommandStreamAckData - dataSize int -} - -type DeliveryPipe struct { - lock sync.Mutex - config DeliveryConfig - - // Sequence counters (separate for data and ack) - dataSeq uint64 - ackSeq uint64 - - // Pending packets sorted by (deliveryTime, seq) - dataPending []taggedPacket - ackPending []taggedPacket - - // Delivery targets - dataTarget func(wshrpc.CommandStreamData) - ackTarget func(wshrpc.CommandStreamAckData) - - // Control - closed bool - wg sync.WaitGroup - - // Metrics - metrics *Metrics - lastDataSeqNum int64 - lastAckSeqNum int64 - - // Byte tracking for high water mark - currentBytes int64 -} - -func NewDeliveryPipe(config DeliveryConfig, metrics *Metrics) *DeliveryPipe { - return &DeliveryPipe{ - config: config, - metrics: metrics, - lastDataSeqNum: -1, - lastAckSeqNum: -1, - } -} - -func (dp *DeliveryPipe) SetDataTarget(fn func(wshrpc.CommandStreamData)) { - dp.lock.Lock() - defer dp.lock.Unlock() - dp.dataTarget = fn -} - -func (dp *DeliveryPipe) SetAckTarget(fn func(wshrpc.CommandStreamAckData)) { - dp.lock.Lock() - defer dp.lock.Unlock() - dp.ackTarget = fn -} - -func (dp *DeliveryPipe) EnqueueData(pkt wshrpc.CommandStreamData) { - dp.lock.Lock() - defer dp.lock.Unlock() - - if dp.closed { - return - } - - dataSize := base64.StdEncoding.DecodedLen(len(pkt.Data64)) - dp.dataSeq++ - tagged := taggedPacket{ - seq: dp.dataSeq, - deliveryTime: dp.computeDeliveryTime(), - isData: true, - dataPk: pkt, - dataSize: dataSize, - } - - dp.dataPending = append(dp.dataPending, tagged) - dp.sortPending(&dp.dataPending) - - dp.currentBytes += int64(dataSize) - if dp.metrics != nil { - dp.metrics.AddDataPacket() - dp.metrics.UpdatePipeHighWaterMark(dp.currentBytes) - } -} - -func (dp *DeliveryPipe) EnqueueAck(pkt wshrpc.CommandStreamAckData) { - dp.lock.Lock() - defer dp.lock.Unlock() - - if dp.closed { - return - } - - dp.ackSeq++ - tagged := taggedPacket{ - seq: dp.ackSeq, - deliveryTime: dp.computeDeliveryTime(), - isData: false, - ackPk: pkt, - } - - dp.ackPending = append(dp.ackPending, tagged) - dp.sortPending(&dp.ackPending) - - if dp.metrics != nil { - dp.metrics.AddAckPacket() - } -} - -func (dp *DeliveryPipe) computeDeliveryTime() time.Time { - base := time.Now().Add(dp.config.Delay) - - if dp.config.Skew == 0 { - return base - } - - // Random skew: -skew to +skew - skewNs := dp.config.Skew.Nanoseconds() - randomSkew := time.Duration(rand.Int63n(2*skewNs+1) - skewNs) - return base.Add(randomSkew) -} - -func (dp *DeliveryPipe) sortPending(pending *[]taggedPacket) { - sort.Slice(*pending, func(i, j int) bool { - pi, pj := (*pending)[i], (*pending)[j] - if pi.deliveryTime.Equal(pj.deliveryTime) { - return pi.seq < pj.seq - } - return pi.deliveryTime.Before(pj.deliveryTime) - }) -} - -func (dp *DeliveryPipe) Start() { - dp.wg.Add(2) - go dp.dataDeliveryLoop() - go dp.ackDeliveryLoop() -} - -func (dp *DeliveryPipe) dataDeliveryLoop() { - defer dp.wg.Done() - dp.deliveryLoop( - func() *[]taggedPacket { return &dp.dataPending }, - func(pkt taggedPacket) { - if dp.dataTarget != nil { - // Track out-of-order packets - if dp.metrics != nil && dp.lastDataSeqNum != -1 { - if pkt.dataPk.Seq < dp.lastDataSeqNum { - dp.metrics.AddOOOPacket() - } - } - dp.lastDataSeqNum = pkt.dataPk.Seq - dp.dataTarget(pkt.dataPk) - - dp.lock.Lock() - dp.currentBytes -= int64(pkt.dataSize) - dp.lock.Unlock() - } - }, - ) -} - -func (dp *DeliveryPipe) ackDeliveryLoop() { - defer dp.wg.Done() - dp.deliveryLoop( - func() *[]taggedPacket { return &dp.ackPending }, - func(pkt taggedPacket) { - if dp.ackTarget != nil { - // Track out-of-order acks - if dp.metrics != nil && dp.lastAckSeqNum != -1 { - if pkt.ackPk.Seq < dp.lastAckSeqNum { - dp.metrics.AddOOOPacket() - } - } - dp.lastAckSeqNum = pkt.ackPk.Seq - dp.ackTarget(pkt.ackPk) - } - }, - ) -} - -func (dp *DeliveryPipe) deliveryLoop( - getPending func() *[]taggedPacket, - deliver func(taggedPacket), -) { - for { - dp.lock.Lock() - if dp.closed { - dp.lock.Unlock() - return - } - - pending := getPending() - now := time.Now() - - // Find all packets ready for delivery (deliveryTime <= now) - readyCount := 0 - for _, pkt := range *pending { - if pkt.deliveryTime.After(now) { - break - } - readyCount++ - } - - // Extract ready packets - ready := make([]taggedPacket, readyCount) - copy(ready, (*pending)[:readyCount]) - *pending = (*pending)[readyCount:] - - dp.lock.Unlock() - - // Deliver all ready packets (outside lock) - for _, pkt := range ready { - deliver(pkt) - } - - // Always sleep 1ms - simple busy loop - time.Sleep(1 * time.Millisecond) - } -} - -func (dp *DeliveryPipe) Close() { - dp.lock.Lock() - dp.closed = true - dp.lock.Unlock() - - dp.wg.Wait() -} diff --git a/cmd/test-streammanager/generator.go b/cmd/test-streammanager/generator.go deleted file mode 100644 index 5cfc92b4b3..0000000000 --- a/cmd/test-streammanager/generator.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "io" -) - -// Base64 charset: all printable, easy to inspect manually -const Base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" - -type TestDataGenerator struct { - totalBytes int64 - generated int64 -} - -func NewTestDataGenerator(totalBytes int64) *TestDataGenerator { - return &TestDataGenerator{totalBytes: totalBytes} -} - -func (g *TestDataGenerator) Read(p []byte) (n int, err error) { - if g.generated >= g.totalBytes { - return 0, io.EOF - } - - remaining := g.totalBytes - g.generated - toRead := int64(len(p)) - if toRead > remaining { - toRead = remaining - } - - // Sequential pattern using base64 chars (0-63 cycling) - for i := int64(0); i < toRead; i++ { - p[i] = Base64Chars[(g.generated+i)%64] - } - - g.generated += toRead - return int(toRead), nil -} diff --git a/cmd/test-streammanager/main-test-streammanager.go b/cmd/test-streammanager/main-test-streammanager.go deleted file mode 100644 index 4e6702e790..0000000000 --- a/cmd/test-streammanager/main-test-streammanager.go +++ /dev/null @@ -1,254 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "fmt" - "io" - "log" - "os" - "time" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/jobmanager" - "github.com/wavetermdev/waveterm/pkg/streamclient" - "github.com/wavetermdev/waveterm/pkg/wshrpc" -) - -type TestConfig struct { - Mode string - DataSize int64 - Delay time.Duration - Skew time.Duration - WindowSize int - SlowReader int - Verbose bool -} - -var config TestConfig - -var rootCmd = &cobra.Command{ - Use: "test-streammanager", - Short: "Integration test for StreamManager streaming system", - RunE: func(cmd *cobra.Command, args []string) error { - return runTest(config) - }, -} - -func init() { - rootCmd.Flags().StringVar(&config.Mode, "mode", "streammanager", "Writer mode: 'streammanager' or 'writer'") - rootCmd.Flags().Int64Var(&config.DataSize, "size", 10*1024*1024, "Total data to transfer (bytes)") - rootCmd.Flags().DurationVar(&config.Delay, "delay", 0, "Base delivery delay (e.g., 10ms)") - rootCmd.Flags().DurationVar(&config.Skew, "skew", 0, "Delivery skew +/- (e.g., 5ms)") - rootCmd.Flags().IntVar(&config.WindowSize, "windowsize", 64*1024, "Window size for both sender and receiver") - rootCmd.Flags().IntVar(&config.SlowReader, "slowreader", 0, "Slow reader mode: bytes per second (0=disabled, e.g., 1024)") - rootCmd.Flags().BoolVar(&config.Verbose, "verbose", false, "Enable verbose logging") -} - -func main() { - if err := rootCmd.Execute(); err != nil { - os.Exit(1) - } -} - -func runTest(config TestConfig) error { - if config.Mode != "streammanager" && config.Mode != "writer" { - return fmt.Errorf("invalid mode: %s (must be 'streammanager' or 'writer')", config.Mode) - } - - fmt.Printf("Starting Streaming Integration Test\n") - fmt.Printf(" Mode: %s\n", config.Mode) - fmt.Printf(" Data Size: %d bytes\n", config.DataSize) - fmt.Printf(" Delay: %v, Skew: %v\n", config.Delay, config.Skew) - fmt.Printf(" Window Size: %d\n", config.WindowSize) - if config.SlowReader > 0 { - fmt.Printf(" Slow Reader: %d bytes/sec\n", config.SlowReader) - } - - // 1. Create metrics - metrics := NewMetrics() - - // 2. Create the delivery pipe - pipe := NewDeliveryPipe(DeliveryConfig{ - Delay: config.Delay, - Skew: config.Skew, - }, metrics) - - // 3. Create brokers with bridges - writerBridge := &WriterBridge{pipe: pipe} - readerBridge := &ReaderBridge{pipe: pipe} - - writerBroker := streamclient.NewBroker(writerBridge) - readerBroker := streamclient.NewBroker(readerBridge) - - // 4. Wire up delivery targets - pipe.SetDataTarget(readerBroker.RecvData) - pipe.SetAckTarget(writerBroker.RecvAck) - - // 5. Start the delivery pipe - pipe.Start() - - // 6. Create the reader side - reader, streamMeta := readerBroker.CreateStreamReader("reader-route", "writer-route", int64(config.WindowSize)) - - // 7. Set up writer side based on mode - var writerDone chan error - if config.Mode == "streammanager" { - writerDone = runStreamManagerMode(config, writerBroker, streamMeta) - } else { - writerDone = runWriterMode(config, writerBroker, streamMeta) - } - - // 8. Create verifier - verifier := NewVerifier(config.DataSize) - - // 9. Create metrics writer wrapper - metricsWriter := &MetricsWriter{ - writer: verifier, - metrics: metrics, - } - - // 10. Wrap reader with slow reader if configured - var actualReader io.Reader = reader - if config.SlowReader > 0 { - actualReader = NewSlowReader(reader, config.SlowReader) - } - - // 11. Start reading from stream reader and writing to verifier - metrics.Start() - - readerDone := make(chan error) - go func() { - _, err := io.Copy(metricsWriter, actualReader) - readerDone <- err - }() - - // 12. Wait for completion - var writerErr, readerErr error - if writerDone != nil { - writerErr = <-writerDone - } - readerErr = <-readerDone - metrics.End() - - // 13. Cleanup - pipe.Close() - writerBroker.Close() - readerBroker.Close() - - // 14. Report results - fmt.Println(metrics.Report()) - fmt.Printf("Verification: received=%d, mismatches=%d\n", - verifier.TotalReceived(), verifier.Mismatches()) - - if writerErr != nil && writerErr != io.EOF { - return fmt.Errorf("writer error: %w", writerErr) - } - - if readerErr != nil && readerErr != io.EOF { - return fmt.Errorf("reader error: %w", readerErr) - } - - if verifier.Mismatches() > 0 { - return fmt.Errorf("data corruption: %d mismatches, first at byte %d", - verifier.Mismatches(), verifier.FirstMismatch()) - } - - fmt.Println("TEST PASSED") - return nil -} - -func runStreamManagerMode(config TestConfig, writerBroker *streamclient.Broker, streamMeta *wshrpc.StreamMeta) chan error { - streamManager := jobmanager.MakeStreamManagerWithSizes(config.WindowSize, 2*1024*1024) - writerBroker.AttachStreamWriter(streamMeta, streamManager) - - dataSender := &BrokerDataSender{broker: writerBroker} - startSeq, err := streamManager.ClientConnected(streamMeta.Id, dataSender, config.WindowSize, 0) - if err != nil { - fmt.Printf("failed to connect stream manager: %v\n", err) - return nil - } - fmt.Printf(" Stream connected, startSeq: %d\n", startSeq) - - generator := NewTestDataGenerator(config.DataSize) - if err := streamManager.AttachReader(generator); err != nil { - fmt.Printf("failed to attach reader: %v\n", err) - return nil - } - - return nil -} - -func runWriterMode(config TestConfig, writerBroker *streamclient.Broker, streamMeta *wshrpc.StreamMeta) chan error { - writer, err := writerBroker.CreateStreamWriter(streamMeta) - if err != nil { - fmt.Printf("failed to create stream writer: %v\n", err) - return nil - } - fmt.Printf(" Stream writer created\n") - - generator := NewTestDataGenerator(config.DataSize) - - done := make(chan error, 1) - go func() { - _, copyErr := io.Copy(writer, generator) - closeErr := writer.Close() - if copyErr != nil && copyErr != io.EOF { - done <- copyErr - } else { - done <- closeErr - } - }() - - return done -} - -// BrokerDataSender implements DataSender interface -type BrokerDataSender struct { - broker *streamclient.Broker -} - -func (s *BrokerDataSender) SendData(dataPk wshrpc.CommandStreamData) { - s.broker.SendData(dataPk) -} - -// MetricsWriter wraps an io.Writer and records bytes written to metrics -type MetricsWriter struct { - writer io.Writer - metrics *Metrics -} - -func (mw *MetricsWriter) Write(p []byte) (n int, err error) { - n, err = mw.writer.Write(p) - if n > 0 { - mw.metrics.AddBytes(int64(n)) - } - return n, err -} - -// SlowReader wraps an io.Reader and rate-limits reads to a specified bytes/sec -type SlowReader struct { - reader io.Reader - bytesPerSec int -} - -func NewSlowReader(reader io.Reader, bytesPerSec int) *SlowReader { - return &SlowReader{ - reader: reader, - bytesPerSec: bytesPerSec, - } -} - -func (sr *SlowReader) Read(p []byte) (n int, err error) { - time.Sleep(1 * time.Second) - - readSize := sr.bytesPerSec - if readSize > len(p) { - readSize = len(p) - } - - n, err = sr.reader.Read(p[:readSize]) - log.Printf("SlowReader: read %d bytes, err=%v", n, err) - return n, err -} diff --git a/cmd/test-streammanager/metrics.go b/cmd/test-streammanager/metrics.go deleted file mode 100644 index 94b4f4169b..0000000000 --- a/cmd/test-streammanager/metrics.go +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "fmt" - "sync" - "time" -) - -type Metrics struct { - lock sync.Mutex - - // Timing - startTime time.Time - endTime time.Time - - // Data transfer - totalBytes int64 - - // Packet counts - dataPackets int64 - ackPackets int64 - - // Out of order tracking - oooPackets int64 - - // High water mark for pipe bytes - pipeHighWaterMark int64 -} - -func NewMetrics() *Metrics { - return &Metrics{} -} - -func (m *Metrics) Start() { - m.lock.Lock() - defer m.lock.Unlock() - m.startTime = time.Now() -} - -func (m *Metrics) End() { - m.lock.Lock() - defer m.lock.Unlock() - m.endTime = time.Now() -} - -func (m *Metrics) AddDataPacket() { - m.lock.Lock() - defer m.lock.Unlock() - m.dataPackets++ -} - -func (m *Metrics) AddAckPacket() { - m.lock.Lock() - defer m.lock.Unlock() - m.ackPackets++ -} - -func (m *Metrics) AddOOOPacket() { - m.lock.Lock() - defer m.lock.Unlock() - m.oooPackets++ -} - -func (m *Metrics) AddBytes(n int64) { - m.lock.Lock() - defer m.lock.Unlock() - m.totalBytes += n -} - -func (m *Metrics) UpdatePipeHighWaterMark(currentBytes int64) { - m.lock.Lock() - defer m.lock.Unlock() - if currentBytes > m.pipeHighWaterMark { - m.pipeHighWaterMark = currentBytes - } -} - -func (m *Metrics) GetPipeHighWaterMark() int64 { - m.lock.Lock() - defer m.lock.Unlock() - return m.pipeHighWaterMark -} - -func (m *Metrics) Report() string { - m.lock.Lock() - defer m.lock.Unlock() - - duration := m.endTime.Sub(m.startTime) - durationSecs := duration.Seconds() - if durationSecs == 0 { - durationSecs = 1.0 - } - throughput := float64(m.totalBytes) / durationSecs / 1024 / 1024 - - return fmt.Sprintf(` -StreamManager Integration Test Results -====================================== -Duration: %v -Total Bytes: %d -Throughput: %.2f MB/s -Data Packets: %d -Ack Packets: %d -OOO Packets: %d -Pipe High Water: %d bytes (%.2f KB) -`, duration, m.totalBytes, throughput, m.dataPackets, m.ackPackets, m.oooPackets, - m.pipeHighWaterMark, float64(m.pipeHighWaterMark)/1024) -} diff --git a/cmd/test-streammanager/verifier.go b/cmd/test-streammanager/verifier.go deleted file mode 100644 index e6abe518a5..0000000000 --- a/cmd/test-streammanager/verifier.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "sync" -) - -type Verifier struct { - lock sync.Mutex - expectedGen *TestDataGenerator - totalReceived int64 - mismatches int - firstMismatch int64 -} - -func NewVerifier(totalBytes int64) *Verifier { - return &Verifier{ - expectedGen: NewTestDataGenerator(totalBytes), - firstMismatch: -1, - } -} - -func (v *Verifier) Write(p []byte) (n int, err error) { - v.lock.Lock() - defer v.lock.Unlock() - - expected := make([]byte, len(p)) - // expectedGen.Read() error ignored: TestDataGenerator is deterministic and won't fail, - // and any data length mismatch will be caught by byte comparison below - v.expectedGen.Read(expected) - - for i := 0; i < len(p); i++ { - if p[i] != expected[i] { - v.mismatches++ - if v.firstMismatch == -1 { - v.firstMismatch = v.totalReceived + int64(i) - } - } - } - - v.totalReceived += int64(len(p)) - return len(p), nil -} - -func (v *Verifier) TotalReceived() int64 { - v.lock.Lock() - defer v.lock.Unlock() - return v.totalReceived -} - -func (v *Verifier) Mismatches() int { - v.lock.Lock() - defer v.lock.Unlock() - return v.mismatches -} - -func (v *Verifier) FirstMismatch() int64 { - v.lock.Lock() - defer v.lock.Unlock() - return v.firstMismatch -} diff --git a/cmd/test/test-main.go b/cmd/test/test-main.go deleted file mode 100644 index bb16343da8..0000000000 --- a/cmd/test/test-main.go +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -func main() { - -} diff --git a/cmd/testai/main-testai.go b/cmd/testai/main-testai.go deleted file mode 100644 index 606e6ac6a1..0000000000 --- a/cmd/testai/main-testai.go +++ /dev/null @@ -1,548 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "context" - _ "embed" - "encoding/json" - "flag" - "fmt" - "log" - "net/http" - "os" - "time" - - "github.com/google/uuid" - "github.com/wavetermdev/waveterm/pkg/aiusechat" - "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" - "github.com/wavetermdev/waveterm/pkg/web/sse" -) - -//go:embed testschema.json -var testSchemaJSON string - -const ( - DefaultAnthropicModel = "claude-sonnet-4-5" - DefaultOpenAIModel = "gpt-5.1" - DefaultOpenRouterModel = "mistralai/mistral-small-3.2-24b-instruct" - DefaultNanoGPTModel = "zai-org/glm-4.7" - DefaultGeminiModel = "gemini-3-pro-preview" -) - -// TestResponseWriter implements http.ResponseWriter and additional interfaces for testing -type TestResponseWriter struct { - header http.Header -} - -func (w *TestResponseWriter) Header() http.Header { - if w.header == nil { - w.header = make(http.Header) - } - return w.header -} - -func (w *TestResponseWriter) Write(data []byte) (int, error) { - fmt.Printf("SSE: %s", string(data)) - return len(data), nil -} - -func (w *TestResponseWriter) WriteHeader(statusCode int) { - fmt.Printf("Status: %d\n", statusCode) -} - -// Implement http.Flusher interface -func (w *TestResponseWriter) Flush() { - // No-op for testing -} - -// Implement interfaces needed by http.ResponseController -func (w *TestResponseWriter) SetWriteDeadline(deadline time.Time) error { - // No-op for testing - return nil -} - -func (w *TestResponseWriter) SetReadDeadline(deadline time.Time) error { - // No-op for testing - return nil -} - -func getToolDefinitions() []uctypes.ToolDefinition { - var schemas map[string]any - if err := json.Unmarshal([]byte(testSchemaJSON), &schemas); err != nil { - log.Printf("Error parsing schema: %v\n", err) - return nil - } - - var configSchema map[string]any - if rawSchema, ok := schemas["config"]; ok && rawSchema != nil { - if schema, ok := rawSchema.(map[string]any); ok { - configSchema = schema - } - } - if configSchema == nil { - configSchema = map[string]any{"type": "object"} - } - - return []uctypes.ToolDefinition{ - { - Name: "get_config", - Description: "Get the current GitHub Actions Monitor configuration settings including repository, workflow, polling interval, and max workflow runs", - InputSchema: map[string]any{ - "type": "object", - }, - }, - { - Name: "update_config", - Description: "Update GitHub Actions Monitor configuration settings", - InputSchema: configSchema, - }, - { - Name: "get_data", - Description: "Get the current GitHub Actions workflow run data including workflow runs, loading state, and errors", - InputSchema: map[string]any{ - "type": "object", - }, - }, - } -} - -func testOpenAI(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { - apiKey := os.Getenv("OPENAI_APIKEY") - if apiKey == "" { - fmt.Println("Error: OPENAI_APIKEY environment variable not set") - os.Exit(1) - } - - opts := &uctypes.AIOptsType{ - APIType: uctypes.APIType_OpenAIResponses, - APIToken: apiKey, - Model: model, - MaxTokens: 4096, - ThinkingLevel: uctypes.ThinkingLevelMedium, - } - - // Generate a chat ID - chatID := uuid.New().String() - - // Convert to AIMessage format for WaveAIPostMessageWrap - aiMessage := &uctypes.AIMessage{ - MessageId: uuid.New().String(), - Parts: []uctypes.AIMessagePart{ - { - Type: uctypes.AIMessagePartTypeText, - Text: message, - }, - }, - } - - fmt.Printf("Testing OpenAI streaming with WaveAIPostMessageWrap, model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Printf("Chat ID: %s\n", chatID) - fmt.Println("---") - - testWriter := &TestResponseWriter{} - sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) - defer sseHandler.Close() - - chatOpts := uctypes.WaveChatOpts{ - ChatId: chatID, - ClientId: uuid.New().String(), - Config: *opts, - Tools: tools, - } - err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) - if err != nil { - fmt.Printf("OpenAI streaming error: %v\n", err) - } -} - -func testOpenAIComp(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { - apiKey := os.Getenv("OPENAI_APIKEY") - if apiKey == "" { - fmt.Println("Error: OPENAI_APIKEY environment variable not set") - os.Exit(1) - } - - opts := &uctypes.AIOptsType{ - APIType: uctypes.APIType_OpenAIChat, - APIToken: apiKey, - Endpoint: "https://api.openai.com/v1/chat/completions", - Model: model, - MaxTokens: 4096, - ThinkingLevel: uctypes.ThinkingLevelMedium, - } - - chatID := uuid.New().String() - - aiMessage := &uctypes.AIMessage{ - MessageId: uuid.New().String(), - Parts: []uctypes.AIMessagePart{ - { - Type: uctypes.AIMessagePartTypeText, - Text: message, - }, - }, - } - - fmt.Printf("Testing OpenAI Completions API with WaveAIPostMessageWrap, model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Printf("Chat ID: %s\n", chatID) - fmt.Println("---") - - testWriter := &TestResponseWriter{} - sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) - defer sseHandler.Close() - - chatOpts := uctypes.WaveChatOpts{ - ChatId: chatID, - ClientId: uuid.New().String(), - Config: *opts, - Tools: tools, - SystemPrompt: []string{"You are a helpful assistant. Be concise and clear in your responses."}, - } - err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) - if err != nil { - fmt.Printf("OpenAI Completions API streaming error: %v\n", err) - } -} - -func testOpenRouter(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { - apiKey := os.Getenv("OPENROUTER_APIKEY") - if apiKey == "" { - fmt.Println("Error: OPENROUTER_APIKEY environment variable not set") - os.Exit(1) - } - - opts := &uctypes.AIOptsType{ - APIType: uctypes.APIType_OpenAIChat, - APIToken: apiKey, - Endpoint: "https://openrouter.ai/api/v1/chat/completions", - Model: model, - MaxTokens: 4096, - ThinkingLevel: uctypes.ThinkingLevelMedium, - } - - chatID := uuid.New().String() - - aiMessage := &uctypes.AIMessage{ - MessageId: uuid.New().String(), - Parts: []uctypes.AIMessagePart{ - { - Type: uctypes.AIMessagePartTypeText, - Text: message, - }, - }, - } - - fmt.Printf("Testing OpenRouter with WaveAIPostMessageWrap, model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Printf("Chat ID: %s\n", chatID) - fmt.Println("---") - - testWriter := &TestResponseWriter{} - sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) - defer sseHandler.Close() - - chatOpts := uctypes.WaveChatOpts{ - ChatId: chatID, - ClientId: uuid.New().String(), - Config: *opts, - Tools: tools, - SystemPrompt: []string{"You are a helpful assistant. Be concise and clear in your responses."}, - } - err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) - if err != nil { - fmt.Printf("OpenRouter streaming error: %v\n", err) - } -} - -func testNanoGPT(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { - apiKey := os.Getenv("NANOGPT_KEY") - if apiKey == "" { - fmt.Println("Error: NANOGPT_KEY environment variable not set") - os.Exit(1) - } - - opts := &uctypes.AIOptsType{ - APIType: uctypes.APIType_OpenAIChat, - APIToken: apiKey, - Endpoint: "https://nano-gpt.com/api/v1/chat/completions", - Model: model, - MaxTokens: 4096, - } - - chatID := uuid.New().String() - - aiMessage := &uctypes.AIMessage{ - MessageId: uuid.New().String(), - Parts: []uctypes.AIMessagePart{ - { - Type: uctypes.AIMessagePartTypeText, - Text: message, - }, - }, - } - - fmt.Printf("Testing NanoGPT with WaveAIPostMessageWrap, model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Printf("Chat ID: %s\n", chatID) - fmt.Println("---") - - testWriter := &TestResponseWriter{} - sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) - defer sseHandler.Close() - - chatOpts := uctypes.WaveChatOpts{ - ChatId: chatID, - ClientId: uuid.New().String(), - Config: *opts, - Tools: tools, - SystemPrompt: []string{"You are a helpful assistant. Be concise and clear in your responses."}, - } - err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) - if err != nil { - fmt.Printf("NanoGPT streaming error: %v\n", err) - } -} - -func testAnthropic(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { - apiKey := os.Getenv("ANTHROPIC_APIKEY") - if apiKey == "" { - fmt.Println("Error: ANTHROPIC_APIKEY environment variable not set") - os.Exit(1) - } - - opts := &uctypes.AIOptsType{ - APIType: uctypes.APIType_AnthropicMessages, - APIToken: apiKey, - Model: model, - MaxTokens: 4096, - ThinkingLevel: uctypes.ThinkingLevelMedium, - } - - // Generate a chat ID - chatID := uuid.New().String() - - // Convert to AIMessage format for WaveAIPostMessageWrap - aiMessage := &uctypes.AIMessage{ - MessageId: uuid.New().String(), - Parts: []uctypes.AIMessagePart{ - { - Type: uctypes.AIMessagePartTypeText, - Text: message, - }, - }, - } - - fmt.Printf("Testing Anthropic streaming with WaveAIPostMessageWrap, model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Printf("Chat ID: %s\n", chatID) - fmt.Println("---") - - testWriter := &TestResponseWriter{} - sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) - defer sseHandler.Close() - - chatOpts := uctypes.WaveChatOpts{ - ChatId: chatID, - ClientId: uuid.New().String(), - Config: *opts, - Tools: tools, - } - err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) - if err != nil { - fmt.Printf("Anthropic streaming error: %v\n", err) - } -} - -func testGemini(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { - apiKey := os.Getenv("GOOGLE_APIKEY") - if apiKey == "" { - fmt.Println("Error: GOOGLE_APIKEY environment variable not set") - os.Exit(1) - } - - opts := &uctypes.AIOptsType{ - APIType: uctypes.APIType_GoogleGemini, - APIToken: apiKey, - Model: model, - MaxTokens: 8192, - Capabilities: []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs}, - } - - // Generate a chat ID - chatID := uuid.New().String() - - // Convert to AIMessage format for WaveAIPostMessageWrap - aiMessage := &uctypes.AIMessage{ - MessageId: uuid.New().String(), - Parts: []uctypes.AIMessagePart{ - { - Type: uctypes.AIMessagePartTypeText, - Text: message, - }, - }, - } - - fmt.Printf("Testing Google Gemini streaming with WaveAIPostMessageWrap, model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Printf("Chat ID: %s\n", chatID) - fmt.Println("---") - - testWriter := &TestResponseWriter{} - sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) - defer sseHandler.Close() - - chatOpts := uctypes.WaveChatOpts{ - ChatId: chatID, - ClientId: uuid.New().String(), - Config: *opts, - Tools: tools, - SystemPrompt: []string{"You are a helpful assistant. Be concise and clear in your responses."}, - } - err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) - if err != nil { - fmt.Printf("Google Gemini streaming error: %v\n", err) - } -} - -func testT1(ctx context.Context) { - tool := aiusechat.GetAdderToolDefinition() - tools := []uctypes.ToolDefinition{tool} - testAnthropic(ctx, DefaultAnthropicModel, "what is 2+2, use the provider adder tool", tools) -} - -func testT2(ctx context.Context) { - tool := aiusechat.GetAdderToolDefinition() - tools := []uctypes.ToolDefinition{tool} - testOpenAI(ctx, DefaultOpenAIModel, "what is 2+2+8, use the provider adder tool", tools) -} - -func testT3(ctx context.Context) { - testOpenAIComp(ctx, "gpt-4o", "what is 2+2? please be brief", nil) -} - -func testT4(ctx context.Context) { - tool := aiusechat.GetAdderToolDefinition() - tools := []uctypes.ToolDefinition{tool} - testGemini(ctx, DefaultGeminiModel, "what is 2+2+8, use the provider adder tool", tools) -} - -func printUsage() { - fmt.Println("Usage: go run main-testai.go [--anthropic|--openaicomp|--openrouter|--nanogpt|--gemini] [--tools] [--model ] [message]") - fmt.Println("Examples:") - fmt.Println(" go run main-testai.go 'What is 2+2?'") - fmt.Println(" go run main-testai.go --model o4-mini 'What is 2+2?'") - fmt.Println(" go run main-testai.go --anthropic 'What is 2+2?'") - fmt.Println(" go run main-testai.go --anthropic --model claude-3-5-sonnet-20241022 'What is 2+2?'") - fmt.Println(" go run main-testai.go --openaicomp --model gpt-4o 'What is 2+2?'") - fmt.Println(" go run main-testai.go --openrouter 'What is 2+2?'") - fmt.Println(" go run main-testai.go --openrouter --model anthropic/claude-3.5-sonnet 'What is 2+2?'") - fmt.Println(" go run main-testai.go --nanogpt 'What is 2+2?'") - fmt.Println(" go run main-testai.go --nanogpt --model gpt-4o 'What is 2+2?'") - fmt.Println(" go run main-testai.go --gemini 'What is 2+2?'") - fmt.Println(" go run main-testai.go --gemini --model gemini-1.5-pro 'What is 2+2?'") - fmt.Println(" go run main-testai.go --tools 'Help me configure GitHub Actions monitoring'") - fmt.Println("") - fmt.Println("Default models:") - fmt.Printf(" OpenAI: %s\n", DefaultOpenAIModel) - fmt.Printf(" Anthropic: %s\n", DefaultAnthropicModel) - fmt.Printf(" OpenAI Completions: gpt-4o\n") - fmt.Printf(" OpenRouter: %s\n", DefaultOpenRouterModel) - fmt.Printf(" NanoGPT: %s\n", DefaultNanoGPTModel) - fmt.Printf(" Google Gemini: %s\n", DefaultGeminiModel) - fmt.Println("") - fmt.Println("Environment variables:") - fmt.Println(" OPENAI_APIKEY (for OpenAI models)") - fmt.Println(" ANTHROPIC_APIKEY (for Anthropic models)") - fmt.Println(" OPENROUTER_APIKEY (for OpenRouter models)") - fmt.Println(" NANOGPT_KEY (for NanoGPT models)") - fmt.Println(" GOOGLE_APIKEY (for Google Gemini models)") -} - -func main() { - var anthropic, openaicomp, openrouter, nanogpt, gemini, tools, help, t1, t2, t3, t4 bool - var model string - flag.BoolVar(&anthropic, "anthropic", false, "Use Anthropic API instead of OpenAI") - flag.BoolVar(&openaicomp, "openaicomp", false, "Use OpenAI Completions API") - flag.BoolVar(&openrouter, "openrouter", false, "Use OpenRouter API") - flag.BoolVar(&nanogpt, "nanogpt", false, "Use NanoGPT API") - flag.BoolVar(&gemini, "gemini", false, "Use Google Gemini API") - flag.BoolVar(&tools, "tools", false, "Enable GitHub Actions Monitor tools for testing") - flag.StringVar(&model, "model", "", fmt.Sprintf("AI model to use (defaults: %s for OpenAI, %s for Anthropic, %s for OpenRouter, %s for NanoGPT, %s for Gemini)", DefaultOpenAIModel, DefaultAnthropicModel, DefaultOpenRouterModel, DefaultNanoGPTModel, DefaultGeminiModel)) - flag.BoolVar(&help, "help", false, "Show usage information") - flag.BoolVar(&t1, "t1", false, fmt.Sprintf("Run preset T1 test (%s with 'what is 2+2')", DefaultAnthropicModel)) - flag.BoolVar(&t2, "t2", false, fmt.Sprintf("Run preset T2 test (%s with 'what is 2+2')", DefaultOpenAIModel)) - flag.BoolVar(&t3, "t3", false, "Run preset T3 test (OpenAI Completions API with gpt-5.1)") - flag.BoolVar(&t4, "t4", false, "Run preset T4 test (OpenAI Completions API with gemini-3-pro-preview)") - flag.Parse() - - if help { - printUsage() - os.Exit(0) - } - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - if t1 { - testT1(ctx) - return - } - if t2 { - testT2(ctx) - return - } - if t3 { - testT3(ctx) - return - } - if t4 { - testT4(ctx) - return - } - - // Set default model based on API type if not provided - if model == "" { - if anthropic { - model = DefaultAnthropicModel - } else if openaicomp { - model = "gpt-4o" - } else if openrouter { - model = DefaultOpenRouterModel - } else if nanogpt { - model = DefaultNanoGPTModel - } else if gemini { - model = DefaultGeminiModel - } else { - model = DefaultOpenAIModel - } - } - - args := flag.Args() - message := "What is 2+2?" - if len(args) > 0 { - message = args[0] - } - - var toolDefs []uctypes.ToolDefinition - if tools { - toolDefs = getToolDefinitions() - } - - if anthropic { - testAnthropic(ctx, model, message, toolDefs) - } else if openaicomp { - testOpenAIComp(ctx, model, message, toolDefs) - } else if openrouter { - testOpenRouter(ctx, model, message, toolDefs) - } else if nanogpt { - testNanoGPT(ctx, model, message, toolDefs) - } else if gemini { - testGemini(ctx, model, message, toolDefs) - } else { - testOpenAI(ctx, model, message, toolDefs) - } -} diff --git a/cmd/testai/testschema.json b/cmd/testai/testschema.json deleted file mode 100644 index dc9de2b834..0000000000 --- a/cmd/testai/testschema.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "config": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Application configuration settings", - "properties": { - "maxWorkflowRuns": { - "description": "Maximum number of workflow runs to fetch", - "maximum": 100, - "minimum": 1, - "type": "integer" - }, - "pollInterval": { - "description": "Polling interval for GitHub API requests", - "maximum": 300, - "minimum": 1, - "type": "integer", - "units": "s" - }, - "repository": { - "description": "GitHub repository in owner/repo format", - "pattern": "^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$", - "type": "string" - }, - "workflow": { - "description": "GitHub Actions workflow file name", - "pattern": "^.+\\.(yml|yaml)$", - "type": "string" - } - }, - "title": "Application Configuration", - "type": "object" - }, - "data": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "definitions": { - "WorkflowRun": { - "properties": { - "conclusion": { - "type": "string" - }, - "created_at": { - "format": "date-time", - "type": "string" - }, - "html_url": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "run_number": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "updated_at": { - "format": "date-time", - "type": "string" - } - }, - "required": [ - "id", - "name", - "status", - "conclusion", - "created_at", - "updated_at", - "html_url", - "run_number" - ], - "type": "object" - } - }, - "description": "Application data schema", - "properties": { - "isLoading": { - "description": "Loading state for workflow data fetch", - "type": "boolean" - }, - "lastError": { - "description": "Last error message from GitHub API", - "type": "string" - }, - "lastRefreshTime": { - "description": "Timestamp of last successful data refresh", - "format": "date-time", - "type": "string" - }, - "workflowRuns": { - "description": "List of GitHub Actions workflow runs", - "items": { - "$ref": "#/definitions/WorkflowRun" - }, - "type": "array" - } - }, - "title": "Application Data", - "type": "object" - } -} diff --git a/cmd/testopenai/main-testopenai.go b/cmd/testopenai/main-testopenai.go deleted file mode 100644 index 7017407b47..0000000000 --- a/cmd/testopenai/main-testopenai.go +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "os" - "time" - - "github.com/wavetermdev/waveterm/pkg/aiusechat" - "github.com/wavetermdev/waveterm/pkg/aiusechat/openai" -) - -func makeOpenAIRequest(ctx context.Context, apiKey, model, message string, tools bool) error { - reqBody := openai.OpenAIRequest{ - Model: model, - Input: []any{ - openai.OpenAIMessage{ - Role: "user", - Content: []openai.OpenAIMessageContent{ - { - Type: "input_text", - Text: message, - }, - }, - }, - }, - Stream: true, - StreamOptions: &openai.StreamOptionsType{IncludeObfuscation: false}, - Reasoning: &openai.ReasoningType{Effort: "medium"}, - } - if tools { - reqBody.Tools = []openai.OpenAIRequestTool{ - openai.ConvertToolDefinitionToOpenAI(aiusechat.GetAdderToolDefinition()), - } - } - - jsonData, err := json.Marshal(reqBody) - if err != nil { - return fmt.Errorf("error marshaling request: %v", err) - } - - // Pretty print the request JSON for debugging - prettyJSON, err := json.MarshalIndent(reqBody, "", " ") - if err == nil { - fmt.Printf("Request JSON:\n%s\n", string(prettyJSON)) - } - - req, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/responses", bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("error creating request: %v", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+apiKey) - req.Header.Set("Accept", "text/event-stream") - - client := &http.Client{ - Timeout: 60 * time.Second, - } - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("error making request: %v", err) - } - defer resp.Body.Close() - - fmt.Printf("Response Status: %s\n", resp.Status) - fmt.Printf("Response Headers:\n") - for name, values := range resp.Header { - for _, value := range values { - fmt.Printf(" %s: %s\n", name, value) - } - } - fmt.Println("---") - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) - } - - return processSSEStream(resp.Body) -} - -func processSSEStream(reader io.Reader) error { - scanner := bufio.NewScanner(reader) - - fmt.Println("SSE Stream:") - fmt.Println("---") - - for scanner.Scan() { - line := scanner.Text() - fmt.Println(line) - } - - if err := scanner.Err(); err != nil { - return fmt.Errorf("error reading stream: %v", err) - } - - return nil -} - -func printUsage() { - fmt.Println("Usage: go run main-testopenai.go [--model ] [--tools] [message]") - fmt.Println("Examples:") - fmt.Println(" go run main-testopenai.go 'Stream me a limerick about gophers coding in Go.'") - fmt.Println(" go run main-testopenai.go --model gpt-4 'What is 2+2?'") - fmt.Println(" go run main-testopenai.go --tools 'What is 2+2? Use the adder tool.'") - fmt.Println("") - fmt.Println("Default model: gpt-5-mini") - fmt.Println("") - fmt.Println("Environment variables:") - fmt.Println(" OPENAI_APIKEY (required)") -} - -func main() { - var model string - var showHelp bool - var tools bool - - flag.StringVar(&model, "model", "gpt-5-mini", "OpenAI model to use") - flag.BoolVar(&showHelp, "help", false, "Show usage information") - flag.BoolVar(&tools, "tools", false, "Enable tools for testing") - flag.Parse() - - if showHelp { - printUsage() - os.Exit(0) - } - - apiKey := os.Getenv("OPENAI_APIKEY") - if apiKey == "" { - fmt.Println("Error: OPENAI_APIKEY environment variable not set") - printUsage() - os.Exit(1) - } - - args := flag.Args() - message := "Stream me a limerick about gophers coding in Go." - if len(args) > 0 { - message = args[0] - } - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - fmt.Printf("Testing OpenAI Responses API\n") - fmt.Printf("Model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Println("===") - - if err := makeOpenAIRequest(ctx, apiKey, model, message, tools); err != nil { - fmt.Printf("Error: %v\n", err) - os.Exit(1) - } -} diff --git a/cmd/testsummarize/main-testsummarize.go b/cmd/testsummarize/main-testsummarize.go deleted file mode 100644 index fc16e59e04..0000000000 --- a/cmd/testsummarize/main-testsummarize.go +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "context" - "flag" - "fmt" - "os" - "time" - - "github.com/wavetermdev/waveterm/pkg/aiusechat/google" -) - -func printUsage() { - fmt.Println("Usage: go run main-testsummarize.go [--help] [--mode MODE] ") - fmt.Println("Examples:") - fmt.Println(" go run main-testsummarize.go README.md") - fmt.Println(" go run main-testsummarize.go --mode useful /path/to/image.png") - fmt.Println(" go run main-testsummarize.go -m publiccode document.pdf") - fmt.Println("") - fmt.Println("Supported file types:") - fmt.Println(" - Text files (up to 200KB)") - fmt.Println(" - Images (up to 7MB)") - fmt.Println(" - PDFs (up to 5MB)") - fmt.Println("") - fmt.Println("Flags:") - fmt.Println(" --mode, -m Summarization mode (default: quick)") - fmt.Println(" Options: quick, useful, publiccode, htmlcontent, htmlfull") - fmt.Println("") - fmt.Println("Environment variables:") - fmt.Println(" GOOGLE_APIKEY (required)") -} - -func main() { - var showHelp bool - var mode string - flag.BoolVar(&showHelp, "help", false, "Show usage information") - flag.StringVar(&mode, "mode", "quick", "Summarization mode") - flag.StringVar(&mode, "m", "quick", "Summarization mode (shorthand)") - flag.Parse() - - if showHelp { - printUsage() - os.Exit(0) - } - - apiKey := os.Getenv("GOOGLE_APIKEY") - if apiKey == "" { - fmt.Println("Error: GOOGLE_APIKEY environment variable not set") - printUsage() - os.Exit(1) - } - - args := flag.Args() - if len(args) == 0 { - fmt.Println("Error: filename required") - printUsage() - os.Exit(1) - } - - filename := args[0] - - // Check if file exists - if _, err := os.Stat(filename); os.IsNotExist(err) { - fmt.Printf("Error: file '%s' does not exist\n", filename) - os.Exit(1) - } - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - fmt.Printf("Summarizing file: %s\n", filename) - fmt.Printf("Model: %s\n", google.SummarizeModel) - fmt.Printf("Mode: %s\n", mode) - - startTime := time.Now() - summary, usage, err := google.SummarizeFile(ctx, filename, google.SummarizeOpts{ - APIKey: apiKey, - Mode: mode, - }) - latency := time.Since(startTime) - - fmt.Printf("Latency: %d ms\n", latency.Milliseconds()) - fmt.Println("===") - if err != nil { - fmt.Printf("Error: %v\n", err) - os.Exit(1) - } - - fmt.Println("\nSummary:") - fmt.Println("---") - fmt.Println(summary) - fmt.Println("---") - - if usage != nil { - fmt.Println("\nUsage Statistics:") - fmt.Printf(" Prompt tokens: %d\n", usage.PromptTokenCount) - fmt.Printf(" Cached tokens: %d\n", usage.CachedContentTokenCount) - fmt.Printf(" Response tokens: %d\n", usage.CandidatesTokenCount) - fmt.Printf(" Total tokens: %d\n", usage.TotalTokenCount) - } -} \ No newline at end of file diff --git a/cmd/wsh/cmd/csscolormap.go b/cmd/wsh/cmd/csscolormap.go deleted file mode 100644 index 51addf5476..0000000000 --- a/cmd/wsh/cmd/csscolormap.go +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -var CssColorNames = map[string]bool{ - "aliceblue": true, - "antiquewhite": true, - "aqua": true, - "aquamarine": true, - "azure": true, - "beige": true, - "bisque": true, - "black": true, - "blanchedalmond": true, - "blue": true, - "blueviolet": true, - "brown": true, - "burlywood": true, - "cadetblue": true, - "chartreuse": true, - "chocolate": true, - "coral": true, - "cornflowerblue": true, - "cornsilk": true, - "crimson": true, - "cyan": true, - "darkblue": true, - "darkcyan": true, - "darkgoldenrod": true, - "darkgray": true, - "darkgreen": true, - "darkkhaki": true, - "darkmagenta": true, - "darkolivegreen": true, - "darkorange": true, - "darkorchid": true, - "darkred": true, - "darksalmon": true, - "darkseagreen": true, - "darkslateblue": true, - "darkslategray": true, - "darkturquoise": true, - "darkviolet": true, - "deeppink": true, - "deepskyblue": true, - "dimgray": true, - "dodgerblue": true, - "firebrick": true, - "floralwhite": true, - "forestgreen": true, - "fuchsia": true, - "gainsboro": true, - "ghostwhite": true, - "gold": true, - "goldenrod": true, - "gray": true, - "green": true, - "greenyellow": true, - "honeydew": true, - "hotpink": true, - "indianred": true, - "indigo": true, - "ivory": true, - "khaki": true, - "lavender": true, - "lavenderblush": true, - "lawngreen": true, - "lemonchiffon": true, - "lightblue": true, - "lightcoral": true, - "lightcyan": true, - "lightgoldenrodyellow": true, - "lightgray": true, - "lightgreen": true, - "lightpink": true, - "lightsalmon": true, - "lightseagreen": true, - "lightskyblue": true, - "lightslategray": true, - "lightsteelblue": true, - "lightyellow": true, - "lime": true, - "limegreen": true, - "linen": true, - "magenta": true, - "maroon": true, - "mediumaquamarine": true, - "mediumblue": true, - "mediumorchid": true, - "mediumpurple": true, - "mediumseagreen": true, - "mediumslateblue": true, - "mediumspringgreen": true, - "mediumturquoise": true, - "mediumvioletred": true, - "midnightblue": true, - "mintcream": true, - "mistyrose": true, - "moccasin": true, - "navajowhite": true, - "navy": true, - "oldlace": true, - "olive": true, - "olivedrab": true, - "orange": true, - "orangered": true, - "orchid": true, - "palegoldenrod": true, - "palegreen": true, - "paleturquoise": true, - "palevioletred": true, - "papayawhip": true, - "peachpuff": true, - "peru": true, - "pink": true, - "plum": true, - "powderblue": true, - "purple": true, - "red": true, - "rosybrown": true, - "royalblue": true, - "saddlebrown": true, - "salmon": true, - "sandybrown": true, - "seagreen": true, - "seashell": true, - "sienna": true, - "silver": true, - "skyblue": true, - "slateblue": true, - "slategray": true, - "snow": true, - "springgreen": true, - "steelblue": true, - "tan": true, - "teal": true, - "thistle": true, - "tomato": true, - "turquoise": true, - "violet": true, - "wheat": true, - "white": true, - "whitesmoke": true, - "yellow": true, - "yellowgreen": true, -} diff --git a/cmd/wsh/cmd/setmeta_test.go b/cmd/wsh/cmd/setmeta_test.go deleted file mode 100644 index 6f0e7be6b4..0000000000 --- a/cmd/wsh/cmd/setmeta_test.go +++ /dev/null @@ -1,170 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "reflect" - "testing" -) - -func TestParseMetaSets(t *testing.T) { - tests := []struct { - name string - input []string - want map[string]any - wantErr bool - }{ - { - name: "basic types", - input: []string{"str=hello", "num=42", "float=3.14", "bool=true", "null=null"}, - want: map[string]any{ - "str": "hello", - "num": int64(42), - "float": float64(3.14), - "bool": true, - "null": nil, - }, - }, - { - name: "json values", - input: []string{ - `arr=[1,2,3]`, - `obj={"foo":"bar"}`, - `str="quoted"`, - }, - want: map[string]any{ - "arr": []any{float64(1), float64(2), float64(3)}, - "obj": map[string]any{"foo": "bar"}, - "str": "quoted", - }, - }, - { - name: "nested paths", - input: []string{ - "a/b=55", - "a/c=2", - }, - want: map[string]any{ - "a": map[string]any{ - "b": int64(55), - "c": int64(2), - }, - }, - }, - { - name: "deep nesting", - input: []string{ - "a/b/c/d=hello", - }, - want: map[string]any{ - "a": map[string]any{ - "b": map[string]any{ - "c": map[string]any{ - "d": "hello", - }, - }, - }, - }, - }, - { - name: "override nested value", - input: []string{ - "a/b/c=1", - "a/b=2", - }, - want: map[string]any{ - "a": map[string]any{ - "b": int64(2), - }, - }, - }, - { - name: "override with null", - input: []string{ - "a/b=1", - "a/c=2", - "a=null", - }, - want: map[string]any{ - "a": nil, - }, - }, - { - name: "mixed types in path", - input: []string{ - "a/b=1", - "a/c=[1,2,3]", - "a/d/e=true", - }, - want: map[string]any{ - "a": map[string]any{ - "b": int64(1), - "c": []any{float64(1), float64(2), float64(3)}, - "d": map[string]any{ - "e": true, - }, - }, - }, - }, - { - name: "invalid format", - input: []string{"invalid"}, - wantErr: true, - }, - { - name: "invalid json", - input: []string{`a={"invalid`}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parseMetaSets(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("parseMetaSets() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { - t.Errorf("parseMetaSets() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestParseMetaValue(t *testing.T) { - tests := []struct { - name string - input string - want any - wantErr bool - }{ - {"empty string", "", nil, false}, - {"null", "null", nil, false}, - {"true", "true", true, false}, - {"false", "false", false, false}, - {"integer", "42", int64(42), false}, - {"negative integer", "-42", int64(-42), false}, - {"hex integer", "0xff", int64(255), false}, - {"float", "3.14", float64(3.14), false}, - {"string", "hello", "hello", false}, - {"json array", "[1,2,3]", []any{float64(1), float64(2), float64(3)}, false}, - {"json object", `{"foo":"bar"}`, map[string]any{"foo": "bar"}, false}, - {"quoted string", `"quoted"`, "quoted", false}, - {"invalid json", `{"invalid`, nil, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parseMetaValue(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("parseMetaValue() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { - t.Errorf("parseMetaValue() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/cmd/wsh/cmd/wshcmd-ai.go b/cmd/wsh/cmd/wshcmd-ai.go deleted file mode 100644 index 643c80ee7a..0000000000 --- a/cmd/wsh/cmd/wshcmd-ai.go +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/util/fileutil" - "github.com/wavetermdev/waveterm/pkg/util/utilfn" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -var aiCmd = &cobra.Command{ - Use: "ai [options] [files...]", - Short: "Append content to Wave AI sidebar prompt", - Long: `Append content to Wave AI sidebar prompt (does not auto-submit by default) - -Arguments: - files... Files to attach (use '-' for stdin) - -Examples: - git diff | wsh ai - # Pipe diff to AI, ask question in UI - wsh ai main.go # Attach file, ask question in UI - wsh ai *.go -m "find bugs" # Attach files with message - wsh ai -s - -m "review" < log.txt # Stdin + message, auto-submit - wsh ai -n config.json # New chat with file attached`, - RunE: aiRun, - PreRunE: preRunSetupRpcClient, - DisableFlagsInUseLine: true, -} - -var aiMessageFlag string -var aiSubmitFlag bool -var aiNewBlockFlag bool - -func init() { - rootCmd.AddCommand(aiCmd) - aiCmd.Flags().StringVarP(&aiMessageFlag, "message", "m", "", "optional message/question to append after files") - aiCmd.Flags().BoolVarP(&aiSubmitFlag, "submit", "s", false, "submit the prompt immediately after appending") - aiCmd.Flags().BoolVarP(&aiNewBlockFlag, "new", "n", false, "create a new AI chat instead of using existing") -} - -func detectMimeType(data []byte) string { - mimeType := http.DetectContentType(data) - return strings.Split(mimeType, ";")[0] -} - -func getMaxFileSize(mimeType string) (int, string) { - if mimeType == "application/pdf" { - return 5 * 1024 * 1024, "5MB" - } - if strings.HasPrefix(mimeType, "image/") { - return 7 * 1024 * 1024, "7MB" - } - return 200 * 1024, "200KB" -} - -func aiRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("ai", rtnErr == nil) - }() - - if len(args) == 0 && aiMessageFlag == "" { - OutputHelpMessage(cmd) - return fmt.Errorf("no files or message provided") - } - - const maxFileCount = 15 - const rpcTimeout = 30000 - - var allFiles []wshrpc.AIAttachedFile - var stdinUsed bool - - if len(args) > maxFileCount { - return fmt.Errorf("too many files (maximum %d files allowed)", maxFileCount) - } - - for _, filePath := range args { - var data []byte - var fileName string - var mimeType string - var err error - - if filePath == "-" { - if stdinUsed { - return fmt.Errorf("stdin (-) can only be used once") - } - stdinUsed = true - - data, err = io.ReadAll(os.Stdin) - if err != nil { - return fmt.Errorf("reading from stdin: %w", err) - } - fileName = "stdin" - mimeType = "text/plain" - } else { - fileInfo, err := os.Stat(filePath) - if err != nil { - return fmt.Errorf("accessing file %s: %w", filePath, err) - } - absPath, err := filepath.Abs(filePath) - if err != nil { - return fmt.Errorf("getting absolute path for %s: %w", filePath, err) - } - - if fileInfo.IsDir() { - result, err := fileutil.ReadDir(filePath, 500) - if err != nil { - return fmt.Errorf("reading directory %s: %w", filePath, err) - } - jsonData, err := json.Marshal(result) - if err != nil { - return fmt.Errorf("marshaling directory listing for %s: %w", filePath, err) - } - data = jsonData - fileName = absPath - mimeType = "directory" - } else { - data, err = os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("reading file %s: %w", filePath, err) - } - fileName = absPath - mimeType = detectMimeType(data) - } - } - - isPDF := mimeType == "application/pdf" - isImage := strings.HasPrefix(mimeType, "image/") - isDirectory := mimeType == "directory" - - if !isPDF && !isImage && !isDirectory { - mimeType = "text/plain" - if utilfn.ContainsBinaryData(data) { - return fmt.Errorf("file %s contains binary data and cannot be uploaded as text", fileName) - } - } - - maxSize, sizeStr := getMaxFileSize(mimeType) - if len(data) > maxSize { - return fmt.Errorf("file %s exceeds maximum size of %s for %s files", fileName, sizeStr, mimeType) - } - - allFiles = append(allFiles, wshrpc.AIAttachedFile{ - Name: fileName, - Type: mimeType, - Size: len(data), - Data64: base64.StdEncoding.EncodeToString(data), - }) - } - - tabId := os.Getenv("WAVETERM_TABID") - if tabId == "" { - return fmt.Errorf("WAVETERM_TABID environment variable not set") - } - - route := wshutil.MakeTabRouteId(tabId) - - if aiNewBlockFlag { - newChatData := wshrpc.CommandWaveAIAddContextData{ - NewChat: true, - } - err := wshclient.WaveAIAddContextCommand(RpcClient, newChatData, &wshrpc.RpcOpts{ - Route: route, - Timeout: rpcTimeout, - }) - if err != nil { - return fmt.Errorf("creating new chat: %w", err) - } - } - - for _, file := range allFiles { - contextData := wshrpc.CommandWaveAIAddContextData{ - Files: []wshrpc.AIAttachedFile{file}, - } - err := wshclient.WaveAIAddContextCommand(RpcClient, contextData, &wshrpc.RpcOpts{ - Route: route, - Timeout: rpcTimeout, - }) - if err != nil { - return fmt.Errorf("adding file %s: %w", file.Name, err) - } - } - - if aiMessageFlag != "" || aiSubmitFlag { - finalContextData := wshrpc.CommandWaveAIAddContextData{ - Text: aiMessageFlag, - Submit: aiSubmitFlag, - } - err := wshclient.WaveAIAddContextCommand(RpcClient, finalContextData, &wshrpc.RpcOpts{ - Route: route, - Timeout: rpcTimeout, - }) - if err != nil { - return fmt.Errorf("adding context: %w", err) - } - } - - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-badge.go b/cmd/wsh/cmd/wshcmd-badge.go deleted file mode 100644 index 590ed1e40b..0000000000 --- a/cmd/wsh/cmd/wshcmd-badge.go +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "runtime" - - "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/baseds" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wps" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -var badgeCmd = &cobra.Command{ - Use: "badge [icon]", - Short: "set or clear a block badge", - Args: cobra.MaximumNArgs(1), - RunE: badgeRun, - PreRunE: preRunSetupRpcClient, -} - -var ( - badgeColor string - badgePriority float64 - badgeClear bool - badgeBeep bool - badgePid int -) - -func init() { - rootCmd.AddCommand(badgeCmd) - badgeCmd.Flags().StringVar(&badgeColor, "color", "", "badge color") - badgeCmd.Flags().Float64Var(&badgePriority, "priority", 10, "badge priority") - badgeCmd.Flags().BoolVar(&badgeClear, "clear", false, "clear the badge") - badgeCmd.Flags().BoolVar(&badgeBeep, "beep", false, "play system bell sound") - badgeCmd.Flags().IntVar(&badgePid, "pid", 0, "watch a pid and automatically clear the badge when it exits (default priority 5)") -} - -func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("badge", rtnErr == nil) - }() - - if badgePid > 0 && runtime.GOOS == "windows" { - return fmt.Errorf("--pid flag is not supported on Windows") - } - if badgePid > 0 && !cmd.Flags().Changed("priority") { - badgePriority = 5 - } - - oref, err := resolveBlockArg() - if err != nil { - return fmt.Errorf("resolving block: %v", err) - } - if oref.OType != waveobj.OType_Block && oref.OType != waveobj.OType_Tab { - return fmt.Errorf("badge oref must be a block or tab (got %q)", oref.OType) - } - - var eventData baseds.BadgeEvent - eventData.ORef = oref.String() - - if badgeClear { - eventData.Clear = true - } else { - icon := "circle-small" - if len(args) > 0 { - icon = args[0] - } - badgeId, err := uuid.NewV7() - if err != nil { - return fmt.Errorf("generating badge id: %v", err) - } - eventData.Badge = &baseds.Badge{ - BadgeId: badgeId.String(), - Icon: icon, - Color: badgeColor, - Priority: badgePriority, - PidLinked: badgePid > 0, - } - } - - event := wps.WaveEvent{ - Event: wps.Event_Badge, - Scopes: []string{oref.String()}, - Data: eventData, - } - - err = wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true}) - if err != nil { - return fmt.Errorf("publishing badge event: %v", err) - } - - if badgeBeep { - err = wshclient.ElectronSystemBellCommand(RpcClient, &wshrpc.RpcOpts{Route: "electron"}) - if err != nil { - return fmt.Errorf("playing system bell: %v", err) - } - } - - if badgePid > 0 && eventData.Badge != nil { - conn := RpcContext.Conn - if conn == "" { - conn = wshrpc.LocalConnName - } - connRoute := wshutil.MakeConnectionRouteId(conn) - watchData := wshrpc.CommandBadgeWatchPidData{ - Pid: badgePid, - ORef: *oref, - BadgeId: eventData.Badge.BadgeId, - } - err = wshclient.BadgeWatchPidCommand(RpcClient, watchData, &wshrpc.RpcOpts{Route: connRoute}) - if err != nil { - return fmt.Errorf("watching pid: %v", err) - } - } - - if badgeClear { - fmt.Printf("badge cleared\n") - } else { - fmt.Printf("badge set\n") - } - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-blocks.go b/cmd/wsh/cmd/wshcmd-blocks.go deleted file mode 100644 index 7e4b935ee3..0000000000 --- a/cmd/wsh/cmd/wshcmd-blocks.go +++ /dev/null @@ -1,294 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "encoding/json" - "fmt" - "sort" - "strings" - "text/tabwriter" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -// Command-line flags for the blocks commands -var ( - blocksWindowId string // Window ID to filter blocks by - blocksWorkspaceId string // Workspace ID to filter blocks by - blocksTabId string // Tab ID to filter blocks by - blocksView string // View type to filter blocks by (term, web, etc.) - blocksJSON bool // Whether to output as JSON - blocksTimeout int // Timeout in milliseconds for RPC calls -) - -// BlockDetails represents the information about a block returned by the list command -type BlockDetails struct { - BlockId string `json:"blockid"` // Unique identifier for the block - WorkspaceId string `json:"workspaceid"` // ID of the workspace containing the block - TabId string `json:"tabid"` // ID of the tab containing the block - View string `json:"view"` // Canonical view type (term, web, preview, edit, sysinfo, waveai) - Meta waveobj.MetaMapType `json:"meta"` // Block metadata including view type -} - -// blocksListCmd represents the 'blocks list' command -var blocksListCmd = &cobra.Command{ - Use: "list", - Aliases: []string{"ls", "get"}, - Short: "List blocks in workspaces/windows", - Long: `List blocks with optional filtering by workspace, window, tab, or view type. - -Examples: - # List blocks from all workspaces - wsh blocks list - - # List only terminal blocks - wsh blocks list --view=term - - # Filter by window ID (get IDs from 'wsh workspace list') - wsh blocks list --window=dbca23b5-f89b-4780-a0fe-452f5bc7d900 - - # Filter by workspace ID - wsh blocks list --workspace=12d0c067-378e-454c-872e-77a314248114 - - # Filter by tab ID - wsh blocks list --tab=a0459921-cc1a-48cc-ae7b-5f4821e1c9e1 - - # Output as JSON for scripting - wsh blocks list --json - - # Set a different timeout (in milliseconds) - wsh blocks list --timeout=10000`, - RunE: blocksListRun, - PreRunE: preRunSetupRpcClient, - SilenceUsage: true, -} - -// init registers the blocks commands with the root command -// It configures all the flags and command options -func init() { - blocksListCmd.Flags().StringVar(&blocksWindowId, "window", "", "restrict to window id") - blocksListCmd.Flags().StringVar(&blocksWorkspaceId, "workspace", "", "restrict to workspace id") - blocksListCmd.Flags().StringVar(&blocksTabId, "tab", "", "restrict to specific tab id") - blocksListCmd.Flags().StringVar(&blocksView, "view", "", "restrict to view type (term/terminal, web/browser, preview/edit, sysinfo, waveai)") - blocksListCmd.Flags().BoolVar(&blocksJSON, "json", false, "output as JSON") - blocksListCmd.Flags().IntVar(&blocksTimeout, "timeout", 5000, "timeout in milliseconds for RPC calls (default: 5000)") - - for _, cmd := range rootCmd.Commands() { - if cmd.Use == "blocks" { - cmd.AddCommand(blocksListCmd) - return - } - } - - blocksCmd := &cobra.Command{ - Use: "blocks", - Short: "Manage blocks", - Long: "Commands for working with blocks", - } - - blocksCmd.AddCommand(blocksListCmd) - rootCmd.AddCommand(blocksCmd) -} - -// blocksListRun implements the 'blocks list' command -// It retrieves and displays blocks with optional filtering by workspace, window, tab, or view type -func blocksListRun(cmd *cobra.Command, args []string) error { - if v := strings.TrimSpace(blocksView); v != "" { - if !isKnownViewFilter(v) { - return fmt.Errorf("unknown --view %q; try one of: term, web, preview, edit, sysinfo, waveai", v) - } - } - - var allBlocks []BlockDetails - - workspaces, err := wshclient.WorkspaceListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)}) - if err != nil { - return fmt.Errorf("failed to list workspaces: %v", err) - } - - if len(workspaces) == 0 { - return fmt.Errorf("no workspaces found") - } - - var workspaceIdsToQuery []string - - // Determine which workspaces to query - if blocksWorkspaceId != "" && blocksWindowId != "" { - return fmt.Errorf("--workspace and --window are mutually exclusive; specify only one") - } - if blocksWorkspaceId != "" { - workspaceIdsToQuery = []string{blocksWorkspaceId} - } else if blocksWindowId != "" { - // Find workspace ID for this window - windowFound := false - for _, ws := range workspaces { - if ws.WindowId == blocksWindowId { - workspaceIdsToQuery = []string{ws.WorkspaceData.OID} - windowFound = true - break - } - } - if !windowFound { - return fmt.Errorf("window %s not found", blocksWindowId) - } - } else { - // Default to all workspaces - for _, ws := range workspaces { - workspaceIdsToQuery = append(workspaceIdsToQuery, ws.WorkspaceData.OID) - } - } - - // Query each selected workspace - hadSuccess := false - for _, wsId := range workspaceIdsToQuery { - req := wshrpc.BlocksListRequest{WorkspaceId: wsId} - if blocksWindowId != "" { - req.WindowId = blocksWindowId - } - - blocks, err := wshclient.BlocksListCommand(RpcClient, req, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)}) - if err != nil { - WriteStderr("Warning: couldn't list blocks for workspace %s: %v\n", wsId, err) - continue - } - hadSuccess = true - - // Apply filters - for _, b := range blocks { - if blocksTabId != "" && b.TabId != blocksTabId { - continue - } - - if blocksView != "" { - view := b.Meta.GetString(waveobj.MetaKey_View, "") - - // Support view type aliases - if !matchesViewType(view, blocksView) { - continue - } - } - - v := b.Meta.GetString(waveobj.MetaKey_View, "") - allBlocks = append(allBlocks, BlockDetails{ - BlockId: b.BlockId, - WorkspaceId: b.WorkspaceId, - TabId: b.TabId, - View: v, - Meta: b.Meta, - }) - } - } - - // No blocks found check - if len(allBlocks) == 0 { - if !hadSuccess { - return fmt.Errorf("failed to list blocks from all %d workspace(s)", len(workspaceIdsToQuery)) - } - WriteStdout("No blocks found\n") - return nil - } - - // Stable ordering for both JSON and table output - sort.SliceStable(allBlocks, func(i, j int) bool { - if allBlocks[i].WorkspaceId != allBlocks[j].WorkspaceId { - return allBlocks[i].WorkspaceId < allBlocks[j].WorkspaceId - } - if allBlocks[i].TabId != allBlocks[j].TabId { - return allBlocks[i].TabId < allBlocks[j].TabId - } - return allBlocks[i].BlockId < allBlocks[j].BlockId - }) - - // Output results - if blocksJSON { - bytes, err := json.MarshalIndent(allBlocks, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %v", err) - } - WriteStdout("%s\n", string(bytes)) - return nil - } - w := tabwriter.NewWriter(WrappedStdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintf(w, "BLOCK ID\tWORKSPACE\tTAB ID\tVIEW\tCONTENT\n") - - for _, b := range allBlocks { - blockID := b.BlockId - if len(blockID) > 36 { - blockID = blockID[:34] + ".." - } - view := strings.ToLower(b.View) - if view == "" { - view = "" - } - var content string - - switch view { - case "preview", "edit": - content = b.Meta.GetString(waveobj.MetaKey_File, "") - case "web": - content = b.Meta.GetString(waveobj.MetaKey_Url, "") - case "term": - content = b.Meta.GetString(waveobj.MetaKey_CmdCwd, "") - default: - content = "" - } - - wsID := b.WorkspaceId - if len(wsID) > 36 { - wsID = wsID[:34] + ".." - } - - tabID := b.TabId - if len(tabID) > 36 { - tabID = tabID[0:34] + ".." - } - - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", blockID, wsID, tabID, view, content) - } - - return nil -} - -// matchesViewType checks if a view type matches a filter, supporting aliases -func matchesViewType(actual, filter string) bool { - // Direct match (case insensitive) - if strings.EqualFold(actual, filter) { - return true - } - - // Handle aliases - switch strings.ToLower(filter) { - case "preview", "edit": - return strings.EqualFold(actual, "preview") || strings.EqualFold(actual, "edit") - case "terminal", "term", "shell", "console": - return strings.EqualFold(actual, "term") - case "web", "browser", "url": - return strings.EqualFold(actual, "web") - case "ai", "waveai", "assistant": - return strings.EqualFold(actual, "waveai") - case "sys", "sysinfo", "system": - return strings.EqualFold(actual, "sysinfo") - } - - return false -} - -// isKnownViewFilter checks if a filter value is recognized -func isKnownViewFilter(f string) bool { - switch strings.ToLower(strings.TrimSpace(f)) { - case "term", "terminal", "shell", "console", - "web", "browser", "url", - "preview", "edit", - "sysinfo", "sys", "system", - "waveai", "ai", "assistant": - return true - default: - return false - } -} diff --git a/cmd/wsh/cmd/wshcmd-conn.go b/cmd/wsh/cmd/wshcmd-conn.go deleted file mode 100644 index b0a6cd3522..0000000000 --- a/cmd/wsh/cmd/wshcmd-conn.go +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/remote" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var connCmd = &cobra.Command{ - Use: "conn", - Short: "manage Wave Terminal connections", - Long: "Commands to manage Wave Terminal SSH and WSL connections", -} - -var connStatusCmd = &cobra.Command{ - Use: "status", - Short: "show status of all connections", - Args: cobra.NoArgs, - RunE: connStatusRun, - PreRunE: preRunSetupRpcClient, -} - -var connReinstallCmd = &cobra.Command{ - Use: "reinstall CONNECTION", - Short: "reinstall wsh on a connection", - RunE: connReinstallRun, - PreRunE: preRunSetupRpcClient, -} - -var connDisconnectCmd = &cobra.Command{ - Use: "disconnect CONNECTION", - Short: "disconnect a connection", - Args: cobra.ExactArgs(1), - RunE: connDisconnectRun, - PreRunE: preRunSetupRpcClient, -} - -var connDisconnectAllCmd = &cobra.Command{ - Use: "disconnectall", - Short: "disconnect all connections", - Args: cobra.NoArgs, - RunE: connDisconnectAllRun, - PreRunE: preRunSetupRpcClient, -} - -var connConnectCmd = &cobra.Command{ - Use: "connect CONNECTION", - Short: "connect to a connection", - Args: cobra.ExactArgs(1), - RunE: connConnectRun, - PreRunE: preRunSetupRpcClient, -} - -var connEnsureCmd = &cobra.Command{ - Use: "ensure CONNECTION", - Short: "ensure wsh is installed on a connection", - Args: cobra.ExactArgs(1), - RunE: connEnsureRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - rootCmd.AddCommand(connCmd) - connCmd.AddCommand(connStatusCmd) - connCmd.AddCommand(connReinstallCmd) - connCmd.AddCommand(connDisconnectCmd) - connCmd.AddCommand(connDisconnectAllCmd) - connCmd.AddCommand(connConnectCmd) - connCmd.AddCommand(connEnsureCmd) -} - -func validateConnectionName(name string) error { - if !strings.HasPrefix(name, "wsl://") { - _, err := remote.ParseOpts(name) - if err != nil { - return fmt.Errorf("cannot parse connection name: %w", err) - } - } - return nil -} - -func getAllConnStatus() ([]wshrpc.ConnStatus, error) { - var allResp []wshrpc.ConnStatus - sshResp, err := wshclient.ConnStatusCommand(RpcClient, nil) - if err != nil { - return nil, fmt.Errorf("getting ssh connection status: %w", err) - } - allResp = append(allResp, sshResp...) - wslResp, err := wshclient.WslStatusCommand(RpcClient, nil) - if err != nil { - return nil, fmt.Errorf("getting wsl connection status: %w", err) - } - allResp = append(allResp, wslResp...) - return allResp, nil -} - -func connStatusRun(cmd *cobra.Command, args []string) error { - allResp, err := getAllConnStatus() - if err != nil { - return err - } - if len(allResp) == 0 { - WriteStdout("no connections\n") - return nil - } - WriteStdout("%-30s %-12s\n", "connection", "status") - WriteStdout("----------------------------------------------\n") - for _, conn := range allResp { - str := fmt.Sprintf("%-30s %-12s", conn.Connection, conn.Status) - if conn.Error != "" { - str += fmt.Sprintf(" (%s)", conn.Error) - } - WriteStdout("%s\n", str) - } - return nil -} - -func connReinstallRun(cmd *cobra.Command, args []string) error { - if len(args) != 1 { - if RpcContext.Conn == "" { - return fmt.Errorf("no connection specified") - } - args = []string{RpcContext.Conn} - } - connName := args[0] - if err := validateConnectionName(connName); err != nil { - return err - } - data := wshrpc.ConnExtData{ - ConnName: connName, - LogBlockId: RpcContext.BlockId, - } - err := wshclient.ConnReinstallWshCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 60000}) - if err != nil { - return fmt.Errorf("reinstalling connection: %w", err) - } - WriteStdout("wsh reinstalled on connection %q\n", connName) - return nil -} - -func connDisconnectRun(cmd *cobra.Command, args []string) error { - connName := args[0] - if err := validateConnectionName(connName); err != nil { - return err - } - err := wshclient.ConnDisconnectCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 10000}) - if err != nil { - return fmt.Errorf("disconnecting %q error: %w", connName, err) - } - WriteStdout("disconnected %q\n", connName) - return nil -} - -func connDisconnectAllRun(cmd *cobra.Command, args []string) error { - allConns, err := getAllConnStatus() - if err != nil { - return err - } - for _, conn := range allConns { - if conn.Status != "connected" { - continue - } - err := wshclient.ConnDisconnectCommand(RpcClient, conn.Connection, &wshrpc.RpcOpts{Timeout: 10000}) - if err != nil { - WriteStdout("error disconnecting %q: %v\n", conn.Connection, err) - } else { - WriteStdout("disconnected %q\n", conn.Connection) - } - } - return nil -} - -func connConnectRun(cmd *cobra.Command, args []string) error { - connName := args[0] - if err := validateConnectionName(connName); err != nil { - return err - } - data := wshrpc.ConnRequest{ - Host: connName, - LogBlockId: RpcContext.BlockId, - } - err := wshclient.ConnConnectCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 60000}) - if err != nil { - return fmt.Errorf("connecting connection: %w", err) - } - WriteStdout("connected connection %q\n", connName) - return nil -} - -func connEnsureRun(cmd *cobra.Command, args []string) error { - connName := args[0] - if err := validateConnectionName(connName); err != nil { - return err - } - data := wshrpc.ConnExtData{ - ConnName: connName, - LogBlockId: RpcContext.BlockId, - } - err := wshclient.ConnEnsureCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 60000}) - if err != nil { - return fmt.Errorf("ensuring connection: %w", err) - } - WriteStdout("wsh ensured on connection %q\n", connName) - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-connserver.go b/cmd/wsh/cmd/wshcmd-connserver.go deleted file mode 100644 index 1f892a24ce..0000000000 --- a/cmd/wsh/cmd/wshcmd-connserver.go +++ /dev/null @@ -1,506 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "encoding/base64" - "fmt" - "io" - "log" - "net" - "os" - "path/filepath" - "strings" - "sync/atomic" - "time" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/baseds" - "github.com/wavetermdev/waveterm/pkg/panichandler" - "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" - "github.com/wavetermdev/waveterm/pkg/util/envutil" - "github.com/wavetermdev/waveterm/pkg/util/packetparser" - "github.com/wavetermdev/waveterm/pkg/util/sigutil" - "github.com/wavetermdev/waveterm/pkg/wavebase" - "github.com/wavetermdev/waveterm/pkg/wavejwt" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -var serverCmd = &cobra.Command{ - Use: "connserver", - Hidden: true, - Short: "remote server to power wave blocks", - Args: cobra.NoArgs, - RunE: serverRun, -} - -const ( - JobLogRetentionTime = 48 * time.Hour - JobLogCleanupDelay = 10 * time.Second - JobLogCleanupInterval = 1 * time.Hour -) - -var connServerRouter bool -var connServerRouterDomainSocket bool -var connServerConnName string -var connServerDev bool -var ConnServerWshRouter *wshutil.WshRouter -var connServerInitialEnv map[string]string - -func init() { - serverCmd.Flags().BoolVar(&connServerRouter, "router", false, "run in local router mode (stdio upstream)") - serverCmd.Flags().BoolVar(&connServerRouterDomainSocket, "router-domainsocket", false, "run in local router mode (domain socket upstream)") - serverCmd.Flags().StringVar(&connServerConnName, "conn", "", "connection name") - serverCmd.Flags().BoolVar(&connServerDev, "dev", false, "enable dev mode with file logging and PID in logs") - rootCmd.AddCommand(serverCmd) -} - -func cleanupOldJobLogs() { - jobDir := wavebase.GetRemoteJobLogDir() - entries, err := os.ReadDir(jobDir) - if err != nil { - return - } - - cutoffTime := time.Now().Add(-JobLogRetentionTime) - - for _, entry := range entries { - if entry.IsDir() { - continue - } - - name := entry.Name() - if !strings.HasSuffix(name, ".log") { - continue - } - - info, err := entry.Info() - if err != nil { - continue - } - - if info.ModTime().Before(cutoffTime) { - filePath := filepath.Join(jobDir, name) - err := os.Remove(filePath) - if err != nil { - log.Printf("error removing old job log file %s: %v", filePath, err) - } else { - log.Printf("removed old job log file: %s", filePath) - } - } - } -} - -func startJobLogCleanup() { - go func() { - defer func() { - panichandler.PanicHandler("startJobLogCleanup", recover()) - }() - - time.Sleep(JobLogCleanupDelay) - - cleanupOldJobLogs() - - ticker := time.NewTicker(JobLogCleanupInterval) - defer ticker.Stop() - - for range ticker.C { - cleanupOldJobLogs() - } - }() -} - -func getRemoteDomainSocketName() string { - homeDir := wavebase.GetHomeDir() - return filepath.Join(homeDir, wavebase.RemoteWaveHomeDirName, wavebase.RemoteDomainSocketBaseName) -} - -func MakeRemoteUnixListener() (net.Listener, error) { - serverAddr := getRemoteDomainSocketName() - os.Remove(serverAddr) // ignore error - rtn, err := net.Listen("unix", serverAddr) - if err != nil { - return nil, fmt.Errorf("error creating listener at %v: %v", serverAddr, err) - } - os.Chmod(serverAddr, 0700) - log.Printf("Server [unix-domain] listening on %s\n", serverAddr) - return rtn, nil -} - -func handleNewListenerConn(conn net.Conn, router *wshutil.WshRouter) { - defer func() { - panichandler.PanicHandler("handleNewListenerConn", recover()) - }() - var linkIdContainer atomic.Int32 - proxy := wshutil.MakeRpcProxy(fmt.Sprintf("connserver:%s", conn.RemoteAddr().String())) - go func() { - defer func() { - panichandler.PanicHandler("handleNewListenerConn:AdaptOutputChToStream", recover()) - }() - writeErr := wshutil.AdaptOutputChToStream(proxy.ToRemoteCh, conn) - if writeErr != nil { - log.Printf("error writing to domain socket: %v\n", writeErr) - } - }() - go func() { - // when input is closed, close the connection - defer func() { - panichandler.PanicHandler("handleNewListenerConn:AdaptStreamToMsgCh", recover()) - }() - defer func() { - conn.Close() - linkId := linkIdContainer.Load() - if linkId != baseds.NoLinkId { - router.UnregisterLink(baseds.LinkId(linkId)) - } - }() - wshutil.AdaptStreamToMsgCh(conn, proxy.FromRemoteCh, nil) - }() - linkId := router.RegisterUntrustedLink(proxy) - linkIdContainer.Store(int32(linkId)) -} - -func runListener(listener net.Listener, router *wshutil.WshRouter) { - defer func() { - log.Printf("listener closed, exiting\n") - time.Sleep(500 * time.Millisecond) - wshutil.DoShutdown("", 1, true) - }() - for { - conn, err := listener.Accept() - if err == io.EOF { - break - } - if err != nil { - log.Printf("error accepting connection: %v\n", err) - continue - } - go handleNewListenerConn(conn, router) - } -} - -func setupConnServerRpcClientWithRouter(router *wshutil.WshRouter, sockName string) (*wshutil.WshRpc, string, error) { - routeId := wshutil.MakeConnectionRouteId(connServerConnName) - rpcCtx := wshrpc.RpcContext{ - RouteId: routeId, - Conn: connServerConnName, - } - - bareRouteId := wshutil.MakeRandomProcRouteId() - bareClient := wshutil.MakeWshRpc(wshrpc.RpcContext{}, &wshclient.WshServer{}, bareRouteId) - router.RegisterTrustedLeaf(bareClient, bareRouteId) - - connServerClient := wshutil.MakeWshRpc(rpcCtx, wshremote.MakeRemoteRpcServerImpl(os.Stdout, router, bareClient, false, connServerInitialEnv, sockName), routeId) - router.RegisterTrustedLeaf(connServerClient, routeId) - return connServerClient, routeId, nil -} - -func serverRunRouter() error { - log.Printf("starting connserver router") - router := wshutil.NewWshRouter() - ConnServerWshRouter = router - termProxy := wshutil.MakeRpcProxy("connserver-term") - rawCh := make(chan []byte, wshutil.DefaultOutputChSize) - go func() { - defer func() { - panichandler.PanicHandler("serverRunRouter:Parse", recover()) - }() - packetparser.Parse(os.Stdin, termProxy.FromRemoteCh, rawCh) - }() - go func() { - defer func() { - panichandler.PanicHandler("serverRunRouter:WritePackets", recover()) - }() - for msg := range termProxy.ToRemoteCh { - packetparser.WritePacket(os.Stdout, msg) - } - }() - go func() { - defer func() { - panichandler.PanicHandler("serverRunRouter:drainRawCh", recover()) - }() - defer func() { - log.Printf("stdin closed, shutting down") - wshutil.DoShutdown("", 0, true) - }() - for range rawCh { - // ignore - } - }() - router.RegisterUpstream(termProxy) - - sockName := getRemoteDomainSocketName() - - // setup the connserver rpc client first - client, bareRouteId, err := setupConnServerRpcClientWithRouter(router, sockName) - if err != nil { - return fmt.Errorf("error setting up connserver rpc client: %v", err) - } - wshfs.RpcClient = client - wshfs.RpcClientRouteId = bareRouteId - - log.Printf("trying to get JWT public key") - - // fetch and set JWT public key - jwtPublicKeyB64, err := wshclient.GetJwtPublicKeyCommand(client, nil) - if err != nil { - return fmt.Errorf("error getting jwt public key: %v", err) - } - jwtPublicKeyBytes, err := base64.StdEncoding.DecodeString(jwtPublicKeyB64) - if err != nil { - return fmt.Errorf("error decoding jwt public key: %v", err) - } - err = wavejwt.SetPublicKey(jwtPublicKeyBytes) - if err != nil { - return fmt.Errorf("error setting jwt public key: %v", err) - } - - log.Printf("got JWT public key") - - // now set up the domain socket - unixListener, err := MakeRemoteUnixListener() - if err != nil { - return fmt.Errorf("cannot create unix listener: %v", err) - } - log.Printf("unix listener started") - go func() { - defer func() { - panichandler.PanicHandler("serverRunRouter:runListener", recover()) - }() - runListener(unixListener, router) - }() - // run the sysinfo loop - go func() { - defer func() { - panichandler.PanicHandler("serverRunRouter:RunSysInfoLoop", recover()) - }() - wshremote.RunSysInfoLoop(client, connServerConnName) - }() - startJobLogCleanup() - log.Printf("running server, successfully started") - select {} -} - -func serverRunRouterDomainSocket(jwtToken string) error { - log.Printf("starting connserver router (domain socket upstream)") - - // extract socket name from JWT token (unverified - we're on the client side) - sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) - if err != nil { - return fmt.Errorf("error extracting socket name from JWT: %v", err) - } - - // connect to the forwarded domain socket - sockName = wavebase.ExpandHomeDirSafe(sockName) - conn, err := net.Dial("unix", sockName) - if err != nil { - return fmt.Errorf("error connecting to domain socket %s: %v", sockName, err) - } - - // create router - router := wshutil.NewWshRouter() - ConnServerWshRouter = router - - // create proxy for the domain socket connection - upstreamProxy := wshutil.MakeRpcProxy("connserver-upstream") - - // goroutine to write to the domain socket - go func() { - defer func() { - panichandler.PanicHandler("serverRunRouterDomainSocket:WriteLoop", recover()) - }() - writeErr := wshutil.AdaptOutputChToStream(upstreamProxy.ToRemoteCh, conn) - if writeErr != nil { - log.Printf("error writing to upstream domain socket: %v\n", writeErr) - } - }() - - // goroutine to read from the domain socket - go func() { - defer func() { - panichandler.PanicHandler("serverRunRouterDomainSocket:ReadLoop", recover()) - }() - defer func() { - log.Printf("upstream domain socket closed, shutting down") - wshutil.DoShutdown("", 0, true) - }() - wshutil.AdaptStreamToMsgCh(conn, upstreamProxy.FromRemoteCh, nil) - }() - - // register the domain socket connection as upstream - router.RegisterUpstream(upstreamProxy) - - // use the router's control RPC to authenticate with upstream - controlRpc := router.GetControlRpc() - - // authenticate with the upstream router using the JWT - _, err = wshclient.AuthenticateCommand(controlRpc, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRootRoute}) - if err != nil { - return fmt.Errorf("error authenticating with upstream: %v", err) - } - log.Printf("authenticated with upstream router") - - // fetch and set JWT public key - log.Printf("trying to get JWT public key") - jwtPublicKeyB64, err := wshclient.GetJwtPublicKeyCommand(controlRpc, nil) - if err != nil { - return fmt.Errorf("error getting jwt public key: %v", err) - } - jwtPublicKeyBytes, err := base64.StdEncoding.DecodeString(jwtPublicKeyB64) - if err != nil { - return fmt.Errorf("error decoding jwt public key: %v", err) - } - err = wavejwt.SetPublicKey(jwtPublicKeyBytes) - if err != nil { - return fmt.Errorf("error setting jwt public key: %v", err) - } - log.Printf("got JWT public key") - - // now setup the connserver rpc client - client, bareRouteId, err := setupConnServerRpcClientWithRouter(router, sockName) - if err != nil { - return fmt.Errorf("error setting up connserver rpc client: %v", err) - } - wshfs.RpcClient = client - wshfs.RpcClientRouteId = bareRouteId - - // set up the local domain socket listener for local wsh commands - unixListener, err := MakeRemoteUnixListener() - if err != nil { - return fmt.Errorf("cannot create unix listener: %v", err) - } - log.Printf("unix listener started") - go func() { - defer func() { - panichandler.PanicHandler("serverRunRouterDomainSocket:runListener", recover()) - }() - runListener(unixListener, router) - }() - - // run the sysinfo loop - go func() { - defer func() { - panichandler.PanicHandler("serverRunRouterDomainSocket:RunSysInfoLoop", recover()) - }() - wshremote.RunSysInfoLoop(client, connServerConnName) - }() - startJobLogCleanup() - - log.Printf("running server (router-domainsocket mode), successfully started") - select {} -} - -func serverRunNormal(jwtToken string) error { - sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) - if err != nil { - return fmt.Errorf("error extracting socket name from JWT: %v", err) - } - err = setupRpcClient(wshremote.MakeRemoteRpcServerImpl(os.Stdout, nil, nil, false, connServerInitialEnv, sockName), jwtToken) - if err != nil { - return err - } - wshfs.RpcClient = RpcClient - wshfs.RpcClientRouteId = RpcClientRouteId - WriteStdout("running wsh connserver (%s)\n", RpcContext.Conn) - go func() { - defer func() { - panichandler.PanicHandler("serverRunNormal:RunSysInfoLoop", recover()) - }() - wshremote.RunSysInfoLoop(RpcClient, RpcContext.Conn) - }() - startJobLogCleanup() - select {} // run forever -} - -func askForJwtToken() (string, error) { - // if it already exists in the environment, great, use it - jwtToken := os.Getenv(wavebase.WaveJwtTokenVarName) - if jwtToken != "" { - fmt.Printf("HAVE-JWT\n") - return jwtToken, nil - } - - // otherwise, ask for it - fmt.Printf("%s\n", wavebase.NeedJwtConst) - - // read a single line from stdin - var line string - _, err := fmt.Fscanln(os.Stdin, &line) - if err != nil { - return "", fmt.Errorf("failed to read JWT token from stdin: %w", err) - } - return strings.TrimSpace(line), nil -} - -func serverRun(cmd *cobra.Command, args []string) error { - connServerInitialEnv = envutil.PruneInitialEnv(envutil.SliceToMap(os.Environ())) - - var logFile *os.File - if connServerDev { - var err error - logFilePath := fmt.Sprintf("/tmp/waveterm-connserver-%d.log", os.Getuid()) - logFile, err = os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to open log file: %v\n", err) - log.SetFlags(log.LstdFlags | log.Lmicroseconds) - log.SetPrefix(fmt.Sprintf("[PID:%d] ", os.Getpid())) - } else { - defer logFile.Close() - logWriter := io.MultiWriter(os.Stderr, logFile) - log.SetOutput(logWriter) - log.SetFlags(log.LstdFlags | log.Lmicroseconds) - log.SetPrefix(fmt.Sprintf("[PID:%d] ", os.Getpid())) - } - } - if connServerConnName == "" { - if logFile != nil { - fmt.Fprintf(logFile, "--conn parameter is required\n") - } - return fmt.Errorf("--conn parameter is required") - } - installErr := wshutil.InstallRcFiles() - if installErr != nil { - if logFile != nil { - fmt.Fprintf(logFile, "error installing rc files: %v\n", installErr) - } - log.Printf("error installing rc files: %v", installErr) - } - sigutil.InstallSIGUSR1Handler() - if connServerRouter { - err := serverRunRouter() - if err != nil && logFile != nil { - fmt.Fprintf(logFile, "serverRunRouter error: %v\n", err) - } - return err - } - if connServerRouterDomainSocket { - jwtToken, err := askForJwtToken() - if err != nil { - if logFile != nil { - fmt.Fprintf(logFile, "askForJwtToken error: %v\n", err) - } - return err - } - err = serverRunRouterDomainSocket(jwtToken) - if err != nil && logFile != nil { - fmt.Fprintf(logFile, "serverRunRouterDomainSocket error: %v\n", err) - } - return err - } - jwtToken, err := askForJwtToken() - if err != nil { - if logFile != nil { - fmt.Fprintf(logFile, "askForJwtToken error: %v\n", err) - } - return err - } - err = serverRunNormal(jwtToken) - if err != nil && logFile != nil { - fmt.Fprintf(logFile, "serverRunNormal error: %v\n", err) - } - return err -} diff --git a/cmd/wsh/cmd/wshcmd-createblock.go b/cmd/wsh/cmd/wshcmd-createblock.go deleted file mode 100644 index aaf153e232..0000000000 --- a/cmd/wsh/cmd/wshcmd-createblock.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var createBlockMagnified bool - -var createBlockCmd = &cobra.Command{ - Use: "createblock viewname key=value ...", - Short: "create a new block", - Args: cobra.MinimumNArgs(1), - RunE: createBlockRun, - PreRunE: preRunSetupRpcClient, - Hidden: true, -} - -func init() { - createBlockCmd.Flags().BoolVarP(&createBlockMagnified, "magnified", "m", false, "create block in magnified mode") - rootCmd.AddCommand(createBlockCmd) -} - -func createBlockRun(cmd *cobra.Command, args []string) error { - viewName := args[0] - var metaSetStrs []string - if len(args) > 1 { - metaSetStrs = args[1:] - } - tabId := getTabIdFromEnv() - if tabId == "" { - return fmt.Errorf("no WAVETERM_TABID env var set") - } - meta, err := parseMetaSets(metaSetStrs) - if err != nil { - return err - } - meta["view"] = viewName - data := wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &waveobj.BlockDef{ - Meta: meta, - }, - Magnified: createBlockMagnified, - Focused: true, - } - oref, err := wshclient.CreateBlockCommand(RpcClient, data, nil) - if err != nil { - return fmt.Errorf("create block failed: %v", err) - } - fmt.Printf("created block %s\n", oref.OID) - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-debug.go b/cmd/wsh/cmd/wshcmd-debug.go deleted file mode 100644 index 9efac0ff87..0000000000 --- a/cmd/wsh/cmd/wshcmd-debug.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "encoding/json" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var debugCmd = &cobra.Command{ - Use: "debug", - Short: "debug commands", - PersistentPreRunE: preRunSetupRpcClient, - Hidden: true, -} - -var debugBlockIdsCmd = &cobra.Command{ - Use: "block", - Short: "list sub-blockids for block", - RunE: debugBlockIdsRun, - Hidden: true, -} - -var debugSendTelemetryCmd = &cobra.Command{ - Use: "send-telemetry", - Short: "send telemetry", - RunE: debugSendTelemetryRun, - Hidden: true, -} - -func init() { - debugCmd.AddCommand(debugBlockIdsCmd) - debugCmd.AddCommand(debugSendTelemetryCmd) - rootCmd.AddCommand(debugCmd) -} - -func debugSendTelemetryRun(cmd *cobra.Command, args []string) error { - err := wshclient.SendTelemetryCommand(RpcClient, nil) - return err -} - -func debugBlockIdsRun(cmd *cobra.Command, args []string) error { - oref, err := resolveBlockArg() - if err != nil { - return err - } - blockInfo, err := wshclient.BlockInfoCommand(RpcClient, oref.OID, nil) - if err != nil { - return err - } - barr, err := json.MarshalIndent(blockInfo, "", " ") - if err != nil { - return err - } - WriteStdout("%s\n", string(barr)) - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-debugterm.go b/cmd/wsh/cmd/wshcmd-debugterm.go deleted file mode 100644 index 66346c460a..0000000000 --- a/cmd/wsh/cmd/wshcmd-debugterm.go +++ /dev/null @@ -1,551 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "os" - "strconv" - "strings" - "unicode/utf8" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -const ( - DebugTermModeHex = "hex" - DebugTermModeDecode = "decode" -) - -var debugTermCmd = &cobra.Command{ - Use: "debugterm", - Short: "inspect recent terminal output bytes", - RunE: debugTermRun, - PreRunE: debugTermPreRun, - DisableFlagsInUseLine: true, - Hidden: true, -} - -var ( - debugTermSize int64 - debugTermMode string - debugTermStdin bool - debugTermInput string -) - -func init() { - rootCmd.AddCommand(debugTermCmd) - debugTermCmd.Flags().Int64Var(&debugTermSize, "size", 1000, "number of terminal bytes to read") - debugTermCmd.Flags().StringVar(&debugTermMode, "mode", DebugTermModeHex, "output mode: hex or decode") - debugTermCmd.Flags().BoolVar(&debugTermStdin, "stdin", false, "read input from stdin instead of rpc call") - debugTermCmd.Flags().StringVar(&debugTermInput, "input", "", "read input from file instead of rpc call") -} - -func debugTermRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("debugterm", rtnErr == nil) - }() - mode, err := getDebugTermMode() - if err != nil { - return err - } - if debugTermStdin { - stdinData, err := io.ReadAll(WrappedStdin) - if err != nil { - return fmt.Errorf("reading stdin: %w", err) - } - termData, err := parseDebugTermStdinData(stdinData) - if err != nil { - return err - } - if mode == DebugTermModeDecode { - WriteStdout("%s", formatDebugTermDecode(termData)) - } else { - WriteStdout("%s", formatDebugTermHex(termData)) - } - return nil - } - if debugTermInput != "" { - fileData, err := os.ReadFile(debugTermInput) - if err != nil { - return fmt.Errorf("reading input file: %w", err) - } - termData, err := parseDebugTermStdinData(fileData) - if err != nil { - return err - } - if mode == DebugTermModeDecode { - WriteStdout("%s", formatDebugTermDecode(termData)) - } else { - WriteStdout("%s", formatDebugTermHex(termData)) - } - return nil - } - if debugTermSize <= 0 { - return fmt.Errorf("size must be greater than 0") - } - fullORef, err := resolveBlockArg() - if err != nil { - return err - } - rtn, err := wshclient.DebugTermCommand(RpcClient, wshrpc.CommandDebugTermData{ - BlockId: fullORef.OID, - Size: debugTermSize, - }, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("reading terminal output: %w", err) - } - termData, err := base64.StdEncoding.DecodeString(rtn.Data64) - if err != nil { - return fmt.Errorf("decoding terminal output: %w", err) - } - var output string - if mode == DebugTermModeDecode { - output = formatDebugTermDecode(termData) - } else { - output = formatDebugTermHex(termData) - } - WriteStdout("%s", output) - return nil -} - -func debugTermPreRun(cmd *cobra.Command, args []string) error { - if debugTermStdin || debugTermInput != "" { - return nil - } - return preRunSetupRpcClient(cmd, args) -} - -func getDebugTermMode() (string, error) { - mode := strings.ToLower(debugTermMode) - if mode != DebugTermModeHex && mode != DebugTermModeDecode { - return "", fmt.Errorf("invalid mode %q (expected %q or %q)", debugTermMode, DebugTermModeHex, DebugTermModeDecode) - } - return mode, nil -} - -type debugTermStdinEntry struct { - Data string `json:"data"` -} - -func parseDebugTermStdinData(data []byte) ([]byte, error) { - trimmed := strings.TrimSpace(string(data)) - if len(trimmed) == 0 { - return data, nil - } - if trimmed[0] == '[' { - // try array of structs first - var structArr []debugTermStdinEntry - err := json.Unmarshal(data, &structArr) - if err == nil { - parts := make([]string, len(structArr)) - for i, entry := range structArr { - parts[i] = entry.Data - } - return []byte(strings.Join(parts, "")), nil - } - fmt.Fprintf(os.Stderr, "json read err %v\n", err) - // try array of strings - var strArr []string - err = json.Unmarshal(data, &strArr) - if err == nil { - return []byte(strings.Join(strArr, "")), nil - } - } - return data, nil -} - -func formatDebugTermHex(data []byte) string { - return hex.Dump(data) -} - -func parseCursorForwardN(seq []byte) (int, bool) { - if len(seq) < 3 || seq[len(seq)-1] != 'C' { - return 0, false - } - params := string(seq[2 : len(seq)-1]) - if params == "" { - return 1, true - } - n, err := strconv.Atoi(params) - if err != nil || n <= 0 { - return 0, false - } - return n, true -} - -// splitOnCRLFRuns splits s at the end of each run of \r and \n characters. -// Each segment includes its trailing CR/LF run. The last segment may have no such run. -func splitOnCRLFRuns(s string) []string { - var result []string - for len(s) > 0 { - // find start of next CR/LF run - i := 0 - for i < len(s) && s[i] != '\r' && s[i] != '\n' { - i++ - } - if i == len(s) { - break - } - // consume the CR/LF run - j := i - for j < len(s) && (s[j] == '\r' || s[j] == '\n') { - j++ - } - result = append(result, s[:j]) - s = s[j:] - } - if len(s) > 0 { - result = append(result, s) - } - return result -} - -func formatDebugTermDecode(data []byte) string { - if len(data) == 0 { - return "" - } - lines := make([]string, 0) - // textBuf accumulates text across CSI-C (cursor forward) sequences so consecutive - // "word CSI-C word" runs collapse into a single TXT line. The // NC annotation goes - // on the last segment only. - textBuf := "" - totalCSpaces := 0 - flushText := func() { - if textBuf == "" && totalCSpaces == 0 { - return - } - segs := splitOnCRLFRuns(textBuf) - if len(segs) == 0 { - segs = []string{textBuf} - } - for i, seg := range segs { - if i == len(segs)-1 && totalCSpaces > 0 { - lines = append(lines, fmt.Sprintf("TXT %s // %dC", strconv.Quote(seg), totalCSpaces)) - } else { - lines = append(lines, "TXT "+strconv.Quote(seg)) - } - } - textBuf = "" - totalCSpaces = 0 - } - for i := 0; i < len(data); { - b := data[i] - if b == 0x1b { - if i+1 >= len(data) { - flushText() - lines = append(lines, "ESC") - i++ - continue - } - next := data[i+1] - switch next { - case '[': - seq, end := consumeDebugTermCSI(data, i) - if n, ok := parseCursorForwardN(seq); ok { - textBuf += strings.Repeat(" ", n) - totalCSpaces += n - } else { - flushText() - lines = append(lines, formatDebugTermCSILine(seq)) - } - i = end - case ']': - flushText() - seq, end := consumeDebugTermOSC(data, i) - lines = append(lines, formatDebugTermOSCLine(seq)) - i = end - case 'P': - flushText() - seq, end := consumeDebugTermST(data, i) - lines = append(lines, "DCS "+strconv.QuoteToASCII(string(seq))) - i = end - case '^': - flushText() - seq, end := consumeDebugTermST(data, i) - lines = append(lines, "PM "+strconv.QuoteToASCII(string(seq))) - i = end - case '_': - flushText() - seq, end := consumeDebugTermST(data, i) - lines = append(lines, "APC "+strconv.QuoteToASCII(string(seq))) - i = end - default: - flushText() - seq := data[i : i+2] - lines = append(lines, "ESC "+strconv.QuoteToASCII(string(seq))) - i += 2 - } - continue - } - if b == 0x07 { - flushText() - lines = append(lines, "BEL") - i++ - continue - } - start, end := consumeDebugTermText(data, i) - if end > start { - textBuf += string(data[start:end]) - i = end - continue - } - flushText() - lines = append(lines, fmt.Sprintf("CTL 0x%02x", b)) - i++ - } - flushText() - return strings.Join(lines, "\n") + "\n" -} - -var csiCommandDescriptions = map[byte]string{ - '@': "insert character", - 'A': "cursor up", - 'B': "cursor down", - 'C': "cursor forward", - 'D': "cursor back", - 'E': "cursor next line", - 'F': "cursor prev line", - 'G': "cursor horizontal absolute", - 'H': "cursor position", - 'I': "cursor horizontal tab", - 'J': "erase display", - 'K': "erase line", - 'L': "insert line", - 'M': "delete line", - 'P': "delete character", - 'S': "scroll up", - 'T': "scroll down", - 'X': "erase character", - 'Z': "cursor backward tab", - 'a': "cursor horizontal relative", - 'b': "repeat character", - 'c': "device attributes", - 'd': "cursor vertical absolute", - 'e': "cursor vertical relative", - 'f': "horizontal vertical position", - 'g': "tab clear", - 'h': "set mode", - 'l': "reset mode", - 'm': "SGR", - 'n': "device status report", - 'r': "set scrolling region", - 's': "save cursor", - 'u': "restore cursor", -} - -var decModeDescriptions = map[string]string{ - "1": "application cursor keys", - "3": "132 column mode", - "6": "origin mode", - "7": "auto wrap", - "12": "blinking cursor", - "25": "show cursor", - "47": "alternate screen", - "1000": "mouse X10 tracking", - "1002": "mouse button events", - "1003": "mouse all events", - "1004": "focus events", - "1006": "SGR mouse mode", - "1049": "alt screen + save cursor", - "2004": "bracketed paste", - "2026": "synchronized output", -} - -var sgrSingleDescriptions = map[int]string{ - 0: "reset all", - 1: "bold", - 2: "dim", - 3: "italic", - 4: "underline", - 5: "blink", - 7: "reverse", - 8: "hidden", - 9: "strikethrough", - 21: "doubly underlined", - 22: "normal intensity", - 23: "not italic", - 24: "not underlined", - 25: "not blinking", - 27: "not reversed", - 28: "not hidden", - 29: "not strikethrough", - 39: "default fg", - 49: "default bg", -} - -func describeSGR(params string) string { - if params == "" { - return "reset all" - } - parts := strings.Split(params, ";") - if len(parts) >= 5 && parts[0] == "38" && parts[1] == "2" { - return fmt.Sprintf("fg rgb(%s,%s,%s)", parts[2], parts[3], parts[4]) - } - if len(parts) >= 5 && parts[0] == "48" && parts[1] == "2" { - return fmt.Sprintf("bg rgb(%s,%s,%s)", parts[2], parts[3], parts[4]) - } - if len(parts) == 3 && parts[0] == "38" && parts[1] == "5" { - return fmt.Sprintf("fg color256(%s)", parts[2]) - } - if len(parts) == 3 && parts[0] == "48" && parts[1] == "5" { - return fmt.Sprintf("bg color256(%s)", parts[2]) - } - if len(parts) != 1 { - return "" - } - n, err := strconv.Atoi(parts[0]) - if err != nil { - return "" - } - if desc, ok := sgrSingleDescriptions[n]; ok { - return desc - } - if n >= 30 && n <= 37 { - return fmt.Sprintf("fg ansi color %d", n-30) - } - if n >= 40 && n <= 47 { - return fmt.Sprintf("bg ansi color %d", n-40) - } - if n >= 90 && n <= 97 { - return fmt.Sprintf("fg bright color %d", n-90) - } - if n >= 100 && n <= 107 { - return fmt.Sprintf("bg bright color %d", n-100) - } - return "" -} - -func formatDebugTermCSILine(seq []byte) string { - // seq is the full sequence starting with ESC [ - if len(seq) < 3 { - return "CSI " + strconv.QuoteToASCII(string(seq)) - } - inner := seq[2:] - finalByte := inner[len(inner)-1] - params := string(inner[:len(inner)-1]) - - // DEC private mode: params starts with "?" and final byte is 'h' (set) or 'l' (reset) - if strings.HasPrefix(params, "?") && (finalByte == 'h' || finalByte == 'l') { - modeStr := params[1:] - var line string - if finalByte == 'h' { - line = "DEC SET " + modeStr - } else { - line = "DEC RST " + modeStr - } - if desc, ok := decModeDescriptions[modeStr]; ok { - line += " // " + desc - } - return line - } - - finalStr := string([]byte{finalByte}) - var line string - if params == "" { - line = "CSI " + finalStr - } else { - line = "CSI " + finalStr + " " + params - } - if finalByte == 'm' { - if desc := describeSGR(params); desc != "" { - line += " // " + desc - } - } else if desc, ok := csiCommandDescriptions[finalByte]; ok { - line += " // " + desc - } - return line -} - -func consumeDebugTermCSI(data []byte, start int) ([]byte, int) { - i := start + 2 - for i < len(data) { - if data[i] >= 0x40 && data[i] <= 0x7e { - return data[start : i+1], i + 1 - } - i++ - } - return data[start:], len(data) -} - -func formatDebugTermOSCLine(seq []byte) string { - // seq is the full sequence starting with ESC ] - if len(seq) < 3 { - return "OSC " + strconv.QuoteToASCII(string(seq)) - } - // strip ESC ] prefix - inner := string(seq[2:]) - // strip trailing BEL or ST (ESC \) - inner = strings.TrimSuffix(inner, "\x07") - inner = strings.TrimSuffix(inner, "\x1b\\") - // split code from data on first ; - if idx := strings.IndexByte(inner, ';'); idx >= 0 { - code := inner[:idx] - data := inner[idx+1:] - return "OSC " + code + " " + strconv.QuoteToASCII(data) - } - return "OSC " + strconv.QuoteToASCII(inner) -} - -func consumeDebugTermOSC(data []byte, start int) ([]byte, int) { - i := start + 2 - for i < len(data) { - if data[i] == 0x07 { - return data[start : i+1], i + 1 - } - if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { - return data[start : i+2], i + 2 - } - i++ - } - return data[start:], len(data) -} - -func consumeDebugTermST(data []byte, start int) ([]byte, int) { - i := start + 2 - for i < len(data) { - if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { - return data[start : i+2], i + 2 - } - i++ - } - return data[start:], len(data) -} - -func isDebugTermC0Control(b byte) bool { - return b < 0x20 || b == 0x7f -} - -func consumeDebugTermText(data []byte, i int) (start, end int) { - start = i - for i < len(data) { - b := data[i] - if b == 0x1b || b == 0x07 { - break - } - if b == '\n' || b == '\r' || b == '\t' { - i++ - continue - } - if isDebugTermC0Control(b) { - break - } - if b < 0x80 { - i++ - continue - } - _, sz := utf8.DecodeRune(data[i:]) - if sz == 1 { - break - } - i += sz - } - return start, i -} diff --git a/cmd/wsh/cmd/wshcmd-debugterm_test.go b/cmd/wsh/cmd/wshcmd-debugterm_test.go deleted file mode 100644 index eba2caeb7d..0000000000 --- a/cmd/wsh/cmd/wshcmd-debugterm_test.go +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "strings" - "testing" -) - -func TestFormatDebugTermHex(t *testing.T) { - output := formatDebugTermHex([]byte("abc")) - if !strings.Contains(output, "61 62 63") { - t.Fatalf("unexpected hex output: %q", output) - } -} - -func TestFormatDebugTermDecode(t *testing.T) { - data := []byte("abc\x1b[31mred\x1b[0m\x07\x1b]0;title\x07\x00") - output := formatDebugTermDecode(data) - expected := []string{ - `TXT "abc"`, - `CSI m 31`, - `TXT "red"`, - `CSI m 0`, - `BEL`, - `OSC 0 "title"`, - `CTL 0x00`, - } - for _, line := range expected { - if !strings.Contains(output, line) { - t.Fatalf("missing decode line %q in output %q", line, output) - } - } -} - -func TestParseDebugTermStdinData(t *testing.T) { - data, err := parseDebugTermStdinData([]byte(`["abc","\u001b[31mred","\u001b[0m"]`)) - if err != nil { - t.Fatalf("parseDebugTermStdinData() error: %v", err) - } - output := formatDebugTermDecode(data) - expected := []string{ - `TXT "abc"`, - `CSI m 31`, - `TXT "red"`, - `CSI m 0`, - } - for _, line := range expected { - if !strings.Contains(output, line) { - t.Fatalf("missing decode line %q in output %q", line, output) - } - } -} - -func TestParseDebugTermStdinDataStructs(t *testing.T) { - data, err := parseDebugTermStdinData([]byte(`[{"data":"abc"},{"data":"\u001b[31mred"},{"data":"\u001b[0m"}]`)) - if err != nil { - t.Fatalf("parseDebugTermStdinData() error: %v", err) - } - output := formatDebugTermDecode(data) - expected := []string{ - `TXT "abc"`, - `CSI m 31`, - `TXT "red"`, - `CSI m 0`, - } - for _, line := range expected { - if !strings.Contains(output, line) { - t.Fatalf("missing decode line %q in output %q", line, output) - } - } -} - -func TestFormatDebugTermDecodeCursorForward(t *testing.T) { - // CSI C sequences collapse into adjacent text; all consecutive text+CSI-C runs merge into one TXT line. - // The run is split into separate TXT lines at CR/LF run boundaries; // NC appears on the last line. - data := []byte("hi\x1b[1Cworld\x1b[3Cfoo\r\nbar") - output := formatDebugTermDecode(data) - expected := []string{ - `TXT "hi world foo\r\n"`, - `TXT "bar" // 4C`, - } - for _, line := range expected { - if !strings.Contains(output, line) { - t.Fatalf("missing decode line %q in output:\n%s", line, output) - } - } -} - -func TestParseDebugTermStdinDataRaw(t *testing.T) { - raw := []byte("hello\x1b[31mworld") - data, err := parseDebugTermStdinData(raw) - if err != nil { - t.Fatalf("parseDebugTermStdinData() error: %v", err) - } - if string(data) != string(raw) { - t.Fatalf("expected raw passthrough, got %q", data) - } -} diff --git a/cmd/wsh/cmd/wshcmd-deleteblock.go b/cmd/wsh/cmd/wshcmd-deleteblock.go deleted file mode 100644 index 76518e721c..0000000000 --- a/cmd/wsh/cmd/wshcmd-deleteblock.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var deleteBlockCmd = &cobra.Command{ - Use: "deleteblock", - Short: "delete a block", - RunE: deleteBlockRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - rootCmd.AddCommand(deleteBlockCmd) -} - -func deleteBlockRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("deleteblock", rtnErr == nil) - }() - fullORef, err := resolveBlockArg() - if err != nil { - return err - } - if fullORef.OType != "block" { - return fmt.Errorf("object reference is not a block") - } - deleteBlockData := &wshrpc.CommandDeleteBlockData{ - BlockId: fullORef.OID, - } - err = wshclient.DeleteBlockCommand(RpcClient, *deleteBlockData, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("delete block failed: %v", err) - } - WriteStdout("block deleted\n") - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-editconfig.go b/cmd/wsh/cmd/wshcmd-editconfig.go deleted file mode 100644 index cbd4015bae..0000000000 --- a/cmd/wsh/cmd/wshcmd-editconfig.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var editConfigMagnified bool - -var editConfigCmd = &cobra.Command{ - Use: "editconfig [configfile]", - Short: "edit Wave configuration files", - Long: "Edit Wave configuration files. Defaults to settings.json if no file specified. Common files: settings.json, presets.json, widgets.json", - Args: cobra.MaximumNArgs(1), - RunE: editConfigRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - editConfigCmd.Flags().BoolVarP(&editConfigMagnified, "magnified", "m", false, "open config in magnified mode") - rootCmd.AddCommand(editConfigCmd) -} - -func editConfigRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("editconfig", rtnErr == nil) - }() - - configFile := "settings.json" // default - if len(args) > 0 { - configFile = args[0] - } - - tabId := getTabIdFromEnv() - if tabId == "" { - return fmt.Errorf("no WAVETERM_TABID env var set") - } - - wshCmd := &wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &waveobj.BlockDef{ - Meta: map[string]interface{}{ - waveobj.MetaKey_View: "waveconfig", - waveobj.MetaKey_File: configFile, - }, - }, - Magnified: editConfigMagnified, - Focused: true, - } - - _, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("opening config file: %w", err) - } - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-editor.go b/cmd/wsh/cmd/wshcmd-editor.go deleted file mode 100644 index 4968b17509..0000000000 --- a/cmd/wsh/cmd/wshcmd-editor.go +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "io/fs" - "os" - "path/filepath" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wps" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var editMagnified bool - -var editorCmd = &cobra.Command{ - Use: "editor", - Short: "edit a file (blocks until editor is closed)", - RunE: editorRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - editorCmd.Flags().BoolVarP(&editMagnified, "magnified", "m", false, "open view in magnified mode") - rootCmd.AddCommand(editorCmd) -} - -func editorRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("editor", rtnErr == nil) - }() - if len(args) == 0 { - OutputHelpMessage(cmd) - return fmt.Errorf("no arguments. wsh editor requires a file or URL as an argument argument") - } - if len(args) > 1 { - OutputHelpMessage(cmd) - return fmt.Errorf("too many arguments. wsh editor requires exactly one argument") - } - fileArg := args[0] - absFile, err := filepath.Abs(fileArg) - if err != nil { - return fmt.Errorf("getting absolute path: %w", err) - } - _, err = os.Stat(absFile) - if err == fs.ErrNotExist { - return fmt.Errorf("file does not exist: %q", absFile) - } - if err != nil { - return fmt.Errorf("getting file info: %w", err) - } - - tabId := getTabIdFromEnv() - if tabId == "" { - return fmt.Errorf("no WAVETERM_TABID env var set") - } - - wshCmd := wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &waveobj.BlockDef{ - Meta: map[string]any{ - waveobj.MetaKey_View: "preview", - waveobj.MetaKey_File: absFile, - waveobj.MetaKey_Edit: true, - }, - }, - Magnified: editMagnified, - Focused: true, - } - if RpcContext.Conn != "" { - wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = RpcContext.Conn - } - blockRef, err := wshclient.CreateBlockCommand(RpcClient, wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("running view command: %w", err) - } - doneCh := make(chan bool) - RpcClient.EventListener.On(wps.Event_BlockClose, func(event *wps.WaveEvent) { - if event.HasScope(blockRef.String()) { - close(doneCh) - } - }) - wshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{blockRef.String()}}, nil) - <-doneCh - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-file-util.go b/cmd/wsh/cmd/wshcmd-file-util.go deleted file mode 100644 index 77934c524e..0000000000 --- a/cmd/wsh/cmd/wshcmd-file-util.go +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "context" - "encoding/base64" - "fmt" - "io" - "io/fs" - "strings" - - "github.com/wavetermdev/waveterm/pkg/remote/connparse" - "github.com/wavetermdev/waveterm/pkg/util/fileutil" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -func convertNotFoundErr(err error) error { - if err == nil { - return nil - } - if strings.HasPrefix(err.Error(), "NOTFOUND:") { - return fs.ErrNotExist - } - return err -} - -func ensureFile(fileData wshrpc.FileData) (*wshrpc.FileInfo, error) { - info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) - err = convertNotFoundErr(err) - if err == fs.ErrNotExist { - err = wshclient.FileCreateCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) - if err != nil { - return nil, fmt.Errorf("creating file: %w", err) - } - info, err = wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) - if err != nil { - return nil, fmt.Errorf("getting file info: %w", err) - } - return info, err - } - if err != nil { - return nil, fmt.Errorf("getting file info: %w", err) - } - return info, nil -} - -func streamWriteToFile(fileData wshrpc.FileData, reader io.Reader) error { - // First truncate the file with an empty write - emptyWrite := fileData - emptyWrite.Data64 = "" - err := wshclient.FileWriteCommand(RpcClient, emptyWrite, &wshrpc.RpcOpts{Timeout: fileTimeout}) - if err != nil { - return fmt.Errorf("initializing file with empty write: %w", err) - } - - const chunkSize = wshrpc.FileChunkSize // 32KB chunks - buf := make([]byte, chunkSize) - totalWritten := int64(0) - - for { - n, err := reader.Read(buf) - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("reading input: %w", err) - } - - // Check total size - totalWritten += int64(n) - if totalWritten > MaxFileSize { - return fmt.Errorf("input exceeds maximum file size of %d bytes", MaxFileSize) - } - - // Prepare and send chunk - chunk := buf[:n] - appendData := fileData - appendData.Data64 = base64.StdEncoding.EncodeToString(chunk) - - err = wshclient.FileAppendCommand(RpcClient, appendData, &wshrpc.RpcOpts{Timeout: int64(fileTimeout)}) - if err != nil { - return fmt.Errorf("appending chunk to file: %w", err) - } - } - - return nil -} - -func streamReadFromFile(ctx context.Context, fileData wshrpc.FileData, writer io.Writer) error { - broker := RpcClient.StreamBroker - if broker == nil { - return fmt.Errorf("stream broker not available") - } - if fileData.Info == nil { - return fmt.Errorf("file info is required") - } - readerRouteId := RpcClientRouteId - if readerRouteId == "" { - return fmt.Errorf("no route id available") - } - conn, err := connparse.ParseURI(fileData.Info.Path) - if err != nil { - return fmt.Errorf("parsing file path: %w", err) - } - writerRouteId := wshutil.MakeConnectionRouteId(conn.Host) - reader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, 256*1024) - defer reader.Close() - go func() { - <-ctx.Done() - reader.Close() - }() - data := wshrpc.CommandFileStreamData{ - Info: fileData.Info, - StreamMeta: *streamMeta, - } - _, err = wshclient.FileStreamCommand(RpcClient, data, nil) - if err != nil { - return fmt.Errorf("starting file stream: %w", err) - } - _, err = io.Copy(writer, reader) - return err -} - -func fixRelativePaths(path string) (string, error) { - conn, err := connparse.ParseURI(path) - if err != nil { - return "", err - } - if conn.Scheme == connparse.ConnectionTypeWsh { - if conn.Host == connparse.ConnHostCurrent { - conn.Host = RpcContext.Conn - fixedPath, err := fileutil.FixPath(conn.Path) - if err != nil { - return "", err - } - conn.Path = fixedPath - } - if conn.Host == "" { - conn.Host = wshrpc.LocalConnName - } - } - return conn.GetFullURI(), nil -} diff --git a/cmd/wsh/cmd/wshcmd-file.go b/cmd/wsh/cmd/wshcmd-file.go deleted file mode 100644 index e40eb324d2..0000000000 --- a/cmd/wsh/cmd/wshcmd-file.go +++ /dev/null @@ -1,535 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "bufio" - "bytes" - "encoding/base64" - "fmt" - "io" - "log" - "os" - "strings" - "text/tabwriter" - "time" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/util/utilfn" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "golang.org/x/term" -) - -const ( - MaxFileSize = 10 * 1024 * 1024 // 10MB - - TimeoutYear = int64(365) * 24 * 60 * 60 * 1000 - - UriHelpText = ` - -URI format: [profile]:[uri-scheme]://[connection]/[path] - -Supported URI schemes: - wsh: - Used to access files on remote hosts over SSH via the WSH helper. Allows - for file streaming to Wave and other remotes. - - Profiles are optional for WSH URIs, provided that you have configured the - remote host in your "connections.json" or "~/.ssh/config" file. - - If a profile is provided, it must be defined in "profiles.json" in the Wave - configuration directory. - - Format: wsh://[remote]/[path] - - Shorthands can be used for the current remote and your local computer: - [path] a relative or absolute path on the current remote - //[remote]/[path] a path on a remote - /~/[path] a path relative to the home directory on your local - computer` -) - -var fileCmd = &cobra.Command{ - Use: "file", - Short: "manage files across local and remote systems", - Long: `Manage files across local and remote systems. - -Wave Terminal is capable of managing files from remote SSH hosts and your local -computer. Files are addressed via URIs.` + UriHelpText} - -var fileTimeout int64 - -func init() { - rootCmd.AddCommand(fileCmd) - - fileCmd.PersistentFlags().Int64VarP(&fileTimeout, "timeout", "t", 15000, "timeout in milliseconds for long operations") - - fileListCmd.Flags().BoolP("long", "l", false, "use long listing format") - fileListCmd.Flags().BoolP("one", "1", false, "list one file per line") - fileListCmd.Flags().BoolP("files", "f", false, "list files only") - - fileCmd.AddCommand(fileListCmd) - fileCmd.AddCommand(fileCatCmd) - fileCmd.AddCommand(fileWriteCmd) - fileRmCmd.Flags().BoolP("recursive", "r", false, "remove directories recursively") - fileCmd.AddCommand(fileRmCmd) - fileCmd.AddCommand(fileInfoCmd) - fileCmd.AddCommand(fileAppendCmd) - fileCpCmd.Flags().BoolP("merge", "m", false, "merge directories") - fileCpCmd.Flags().BoolP("force", "f", false, "force overwrite of existing files") - fileCmd.AddCommand(fileCpCmd) - fileMvCmd.Flags().BoolP("force", "f", false, "force overwrite of existing files") - fileCmd.AddCommand(fileMvCmd) -} - -var fileListCmd = &cobra.Command{ - Use: "ls [uri]", - Aliases: []string{"list"}, - Short: "list files", - Long: "List files in a directory. By default, lists files in the current directory." + UriHelpText, - Example: " wsh file ls wsh://user@ec2/home/user/", - RunE: activityWrap("file", fileListRun), - PreRunE: preRunSetupRpcClient, -} - -var fileCatCmd = &cobra.Command{ - Use: "cat [uri]", - Short: "display contents of a file", - Long: "Display the contents of a file." + UriHelpText, - Example: " wsh file cat wsh://user@ec2/home/user/config.txt", - Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileCatRun), - PreRunE: preRunSetupRpcClient, -} - -var fileInfoCmd = &cobra.Command{ - Use: "info [uri]", - Short: "show wave file information", - Long: "Show information about a file." + UriHelpText, - Example: " wsh file info wsh://user@ec2/home/user/config.txt", - Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileInfoRun), - PreRunE: preRunSetupRpcClient, -} - -var fileRmCmd = &cobra.Command{ - Use: "rm [uri]", - Short: "remove a file", - Long: "Remove a file." + UriHelpText, - Example: " wsh file rm wsh://user@ec2/home/user/config.txt", - Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileRmRun), - PreRunE: preRunSetupRpcClient, -} - -var fileWriteCmd = &cobra.Command{ - Use: "write [uri]", - Short: "write stdin into a file (up to 10MB)", - Long: "Write stdin into a file, buffering input (10MB total file size limit)." + UriHelpText, - Example: " echo 'hello' | wsh file write ./greeting.txt", - Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileWriteRun), - PreRunE: preRunSetupRpcClient, -} - -var fileAppendCmd = &cobra.Command{ - Use: "append [uri]", - Short: "append stdin to a file", - Long: "Append stdin to a file, buffering input (10MB total file size limit)." + UriHelpText, - Example: " tail -f log.txt | wsh file append ./app.log", - Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileAppendRun), - PreRunE: preRunSetupRpcClient, -} - -var fileCpCmd = &cobra.Command{ - Use: "cp [source-uri] [destination-uri]" + UriHelpText, - Aliases: []string{"copy"}, - Short: "copy files between storage systems, recursively if needed", - Long: "Copy files between different storage systems." + UriHelpText, - Example: " wsh file cp wsh://user@ec2/home/user/config.txt ./local-config.txt\n wsh file cp ./local-config.txt wsh://user@ec2/home/user/config.txt", - Args: cobra.ExactArgs(2), - RunE: activityWrap("file", fileCpRun), - PreRunE: preRunSetupRpcClient, -} - -var fileMvCmd = &cobra.Command{ - Use: "mv [source-uri] [destination-uri]" + UriHelpText, - Aliases: []string{"move"}, - Short: "move files between storage systems", - Long: "Move files between different storage systems. The source file will be deleted once the operation completes successfully." + UriHelpText, - Example: " wsh file mv wsh://user@ec2/home/user/config.txt ./local-config.txt\n wsh file mv ./local-config.txt wsh://user@ec2/home/user/config.txt", - Args: cobra.ExactArgs(2), - RunE: activityWrap("file", fileMvRun), - PreRunE: preRunSetupRpcClient, -} - -func fileCatRun(cmd *cobra.Command, args []string) error { - path, err := fixRelativePaths(args[0]) - if err != nil { - return err - } - - fileData := wshrpc.FileData{ - Info: &wshrpc.FileInfo{ - Path: path}} - - err = streamReadFromFile(cmd.Context(), fileData, os.Stdout) - if err != nil { - return fmt.Errorf("reading file: %w", err) - } - - return nil -} - -func fileInfoRun(cmd *cobra.Command, args []string) error { - path, err := fixRelativePaths(args[0]) - if err != nil { - return err - } - fileData := wshrpc.FileData{ - Info: &wshrpc.FileInfo{ - Path: path}} - - info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) - err = convertNotFoundErr(err) - if err != nil { - return fmt.Errorf("getting file info: %w", err) - } - - if info.NotFound { - return fmt.Errorf("%s: no such file", path) - } - - WriteStdout("name:\t%s\n", info.Name) - if info.Mode != 0 { - WriteStdout("mode:\t%s\n", info.Mode.String()) - } - WriteStdout("mtime:\t%s\n", time.Unix(info.ModTime/1000, 0).Format(time.DateTime)) - if !info.IsDir { - WriteStdout("size:\t%d\n", info.Size) - } - if info.Meta != nil && len(*info.Meta) > 0 { - WriteStdout("metadata:\n") - for k, v := range *info.Meta { - WriteStdout("\t\t\t%s: %v\n", k, v) - } - } - return nil -} - -func fileRmRun(cmd *cobra.Command, args []string) error { - path, err := fixRelativePaths(args[0]) - if err != nil { - return err - } - recursive, err := cmd.Flags().GetBool("recursive") - if err != nil { - return err - } - - err = wshclient.FileDeleteCommand(RpcClient, wshrpc.CommandDeleteFileData{Path: path, Recursive: recursive}, &wshrpc.RpcOpts{Timeout: fileTimeout}) - if err != nil { - return fmt.Errorf("removing file: %w", err) - } - - return nil -} - -func fileWriteRun(cmd *cobra.Command, args []string) error { - path, err := fixRelativePaths(args[0]) - if err != nil { - return err - } - fileData := wshrpc.FileData{ - Info: &wshrpc.FileInfo{ - Path: path}} - - limitReader := io.LimitReader(WrappedStdin, MaxFileSize+1) - data, err := io.ReadAll(limitReader) - if err != nil { - return fmt.Errorf("reading input: %w", err) - } - if len(data) > MaxFileSize { - return fmt.Errorf("input exceeds maximum file size of %d bytes", MaxFileSize) - } - fileData.Data64 = base64.StdEncoding.EncodeToString(data) - err = wshclient.FileWriteCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) - if err != nil { - return fmt.Errorf("writing file: %w", err) - } - - return nil -} - -func fileAppendRun(cmd *cobra.Command, args []string) error { - path, err := fixRelativePaths(args[0]) - if err != nil { - return err - } - fileData := wshrpc.FileData{ - Info: &wshrpc.FileInfo{ - Path: path}} - - info, err := ensureFile(fileData) - if err != nil { - return err - } - if info.Size >= MaxFileSize { - return fmt.Errorf("file already at maximum size (%d bytes)", MaxFileSize) - } - - reader := bufio.NewReader(WrappedStdin) - var buf bytes.Buffer - remainingSpace := MaxFileSize - info.Size - for { - chunk := make([]byte, 8192) - n, err := reader.Read(chunk) - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("reading input: %w", err) - } - - if int64(buf.Len()+n) > remainingSpace { - return fmt.Errorf("append would exceed maximum file size of %d bytes", MaxFileSize) - } - - buf.Write(chunk[:n]) - - if buf.Len() >= 8192 { // 8KB batch size - fileData.Data64 = base64.StdEncoding.EncodeToString(buf.Bytes()) - err = wshclient.FileAppendCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) - if err != nil { - return fmt.Errorf("appending to file: %w", err) - } - remainingSpace -= int64(buf.Len()) - buf.Reset() - } - } - - if buf.Len() > 0 { - fileData.Data64 = base64.StdEncoding.EncodeToString(buf.Bytes()) - err = wshclient.FileAppendCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) - if err != nil { - return fmt.Errorf("appending to file: %w", err) - } - } - - return nil -} - -func checkFileSize(path string, maxSize int64) (*wshrpc.FileInfo, error) { - fileData := wshrpc.FileData{ - Info: &wshrpc.FileInfo{ - Path: path}} - - info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) - err = convertNotFoundErr(err) - if err != nil { - return nil, fmt.Errorf("getting file info: %w", err) - } - if info.NotFound { - return nil, fmt.Errorf("%s: no such file", path) - } - if info.IsDir { - return nil, fmt.Errorf("%s: is a directory", path) - } - if info.Size > maxSize { - return nil, fmt.Errorf("file size (%d bytes) exceeds maximum of %d bytes", info.Size, maxSize) - } - return info, nil -} - -func fileCpRun(cmd *cobra.Command, args []string) error { - src, dst := args[0], args[1] - merge, err := cmd.Flags().GetBool("merge") - if err != nil { - return err - } - force, err := cmd.Flags().GetBool("force") - if err != nil { - return err - } - - srcPath, err := fixRelativePaths(src) - if err != nil { - return fmt.Errorf("unable to parse src path: %w", err) - } - - _, err = checkFileSize(srcPath, MaxFileSize) - if err != nil { - return err - } - - destPath, err := fixRelativePaths(dst) - if err != nil { - return fmt.Errorf("unable to parse dest path: %w", err) - } - log.Printf("Copying %s to %s; merge: %v, force: %v", srcPath, destPath, merge, force) - rpcOpts := &wshrpc.RpcOpts{Timeout: TimeoutYear} - err = wshclient.FileCopyCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcPath, DestUri: destPath, Opts: &wshrpc.FileCopyOpts{Merge: merge, Overwrite: force, Timeout: TimeoutYear}}, rpcOpts) - if err != nil { - return fmt.Errorf("copying file: %w", err) - } - return nil -} - -func fileMvRun(cmd *cobra.Command, args []string) error { - src, dst := args[0], args[1] - force, err := cmd.Flags().GetBool("force") - if err != nil { - return err - } - - srcPath, err := fixRelativePaths(src) - if err != nil { - return fmt.Errorf("unable to parse src path: %w", err) - } - - _, err = checkFileSize(srcPath, MaxFileSize) - if err != nil { - return err - } - - destPath, err := fixRelativePaths(dst) - if err != nil { - return fmt.Errorf("unable to parse dest path: %w", err) - } - log.Printf("Moving %s to %s; force: %v", srcPath, destPath, force) - rpcOpts := &wshrpc.RpcOpts{Timeout: TimeoutYear} - err = wshclient.FileMoveCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcPath, DestUri: destPath, Opts: &wshrpc.FileCopyOpts{Overwrite: force, Timeout: TimeoutYear}}, rpcOpts) - if err != nil { - return fmt.Errorf("moving file: %w", err) - } - return nil -} - -func filePrintColumns(filesChan <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData]) error { - width := 80 - w, _, err := term.GetSize(int(os.Stdout.Fd())) - if err == nil { - width = w - } - - var allNames []string - maxLen := 0 - for respUnion := range filesChan { - if respUnion.Error != nil { - return respUnion.Error - } - for _, f := range respUnion.Response.FileInfo { - allNames = append(allNames, f.Name) - if len(f.Name) > maxLen { - maxLen = len(f.Name) - } - } - } - - colWidth := maxLen + 2 - numCols := width / colWidth - if numCols < 1 { - numCols = 1 - } - - col := 0 - for _, name := range allNames { - fmt.Fprintf(os.Stdout, "%-*s", colWidth, name) - col++ - if col >= numCols { - fmt.Fprintln(os.Stdout) - col = 0 - } - } - if col > 0 { - fmt.Fprintln(os.Stdout) - } - - return nil -} - -func filePrintLong(filesChan <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData]) error { - var allFiles []*wshrpc.FileInfo - - for respUnion := range filesChan { - if respUnion.Error != nil { - return respUnion.Error - } - resp := respUnion.Response - allFiles = append(allFiles, resp.FileInfo...) - } - - maxNameLen := 0 - for _, fi := range allFiles { - if len(fi.Name) > maxNameLen { - maxNameLen = len(fi.Name) - } - } - - nameWidth := maxNameLen + 2 - if nameWidth > 60 { - nameWidth = 60 - } - - writer := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) - - for _, f := range allFiles { - name := f.Name - t := time.Unix(f.ModTime/1000, 0) - timestamp := utilfn.FormatLsTime(t) - if f.Size == 0 && strings.HasSuffix(name, "/") { - fmt.Fprintf(writer, "%-*s\t%8s\t%s\n", nameWidth, name, "-", timestamp) - } else { - fmt.Fprintf(writer, "%-*s\t%8d\t%s\n", nameWidth, name, f.Size, timestamp) - } - } - writer.Flush() - - return nil -} - -func fileListRun(cmd *cobra.Command, args []string) error { - longForm, _ := cmd.Flags().GetBool("long") - onePerLine, _ := cmd.Flags().GetBool("one") - - // Check if we're in a pipe - stat, _ := os.Stdout.Stat() - isPipe := (stat.Mode() & os.ModeCharDevice) == 0 - if isPipe { - onePerLine = true - } - - if len(args) == 0 { - args = []string{"."} - } - - path, err := fixRelativePaths(args[0]) - if err != nil { - return err - } - - filesChan := wshclient.FileListStreamCommand(RpcClient, wshrpc.FileListData{Path: path, Opts: &wshrpc.FileListOpts{All: false}}, &wshrpc.RpcOpts{Timeout: 2000}) - // Drain the channel when done - defer utilfn.DrainChannelSafe(filesChan, "fileListRun") - if longForm { - return filePrintLong(filesChan) - } - - if onePerLine { - for respUnion := range filesChan { - if respUnion.Error != nil { - log.Printf("error: %v", respUnion.Error) - return respUnion.Error - } - for _, f := range respUnion.Response.FileInfo { - fmt.Fprintln(os.Stdout, f.Name) - } - } - return nil - } - - return filePrintColumns(filesChan) -} diff --git a/cmd/wsh/cmd/wshcmd-focusblock.go b/cmd/wsh/cmd/wshcmd-focusblock.go deleted file mode 100644 index 3f6603a3e2..0000000000 --- a/cmd/wsh/cmd/wshcmd-focusblock.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var focusBlockCmd = &cobra.Command{ - Use: "focusblock [-b {blockid|blocknum|this}]", - Short: "focus a block in the current tab", - Args: cobra.NoArgs, - RunE: focusBlockRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - rootCmd.AddCommand(focusBlockCmd) -} - -func focusBlockRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("focusblock", rtnErr == nil) - }() - - tabId := os.Getenv("WAVETERM_TABID") - if tabId == "" { - return fmt.Errorf("no tab id specified (set WAVETERM_TABID environment variable)") - } - - fullORef, err := resolveBlockArg() - if err != nil { - return err - } - - route := fmt.Sprintf("tab:%s", tabId) - err = wshclient.SetBlockFocusCommand(RpcClient, fullORef.OID, &wshrpc.RpcOpts{ - Route: route, - Timeout: 2000, - }) - if err != nil { - return fmt.Errorf("focusing block: %v", err) - } - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-getmeta.go b/cmd/wsh/cmd/wshcmd-getmeta.go deleted file mode 100644 index f5e1e40f67..0000000000 --- a/cmd/wsh/cmd/wshcmd-getmeta.go +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var getMetaCmd = &cobra.Command{ - Use: "getmeta [key...]", - Short: "get metadata for an entity", - Long: "Get metadata for an entity. Keys can be exact matches or patterns like 'name:*' to get all keys that start with 'name:'", - Args: cobra.ArbitraryArgs, - RunE: getMetaRun, - PreRunE: preRunSetupRpcClient, -} - -var getMetaRawOutput bool -var getMetaClearPrefix bool -var getMetaVerbose bool - -func init() { - rootCmd.AddCommand(getMetaCmd) - getMetaCmd.Flags().BoolVarP(&getMetaVerbose, "verbose", "v", false, "output full metadata") - getMetaCmd.Flags().BoolVar(&getMetaRawOutput, "raw", false, "output singleton string values without quotes") - getMetaCmd.Flags().BoolVar(&getMetaClearPrefix, "clear-prefix", false, "output the special clearing key for prefix queries") -} - -func filterMetaKeys(meta map[string]interface{}, keys []string) map[string]interface{} { - result := make(map[string]interface{}) - - // Process each requested key - for _, key := range keys { - if strings.HasSuffix(key, ":*") { - // Handle pattern matching - prefix := strings.TrimSuffix(key, "*") - baseKey := strings.TrimSuffix(prefix, ":") - - if getMetaClearPrefix { - result[key] = true - } - - // Include the base key without colon if it exists - if val, exists := meta[baseKey]; exists { - result[baseKey] = val - } - - // Include all keys with the prefix - for k, v := range meta { - if strings.HasPrefix(k, prefix) { - result[k] = v - } - } - } else { - // Handle exact key match - if val, exists := meta[key]; exists { - result[key] = val - } else { - result[key] = nil - } - } - } - - return result -} - -func getMetaRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("getmeta", rtnErr == nil) - }() - fullORef, err := resolveBlockArg() - if err != nil { - return err - } - if getMetaVerbose { - fmt.Fprintf(os.Stderr, "resolved-id: %s\n", fullORef.String()) - } - resp, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ORef: *fullORef}, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("getting metadata: %w", err) - } - - var output interface{} - if len(args) > 0 { - if len(args) == 1 && !strings.HasSuffix(args[0], ":*") { - // Single key case - output just the value - output = resp[args[0]] - } else { - // Multiple keys or pattern matching case - output object - output = filterMetaKeys(resp, args) - } - } else { - // No args case - output full metadata - output = resp - } - - // Handle raw string output - if getMetaRawOutput { - if str, ok := output.(string); ok { - WriteStdout("%s\n", str) - return - } - } - - outBArr, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("formatting metadata: %w", err) - } - outStr := string(outBArr) - WriteStdout("%s\n", outStr) - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-getvar.go b/cmd/wsh/cmd/wshcmd-getvar.go deleted file mode 100644 index 9391c4f5f2..0000000000 --- a/cmd/wsh/cmd/wshcmd-getvar.go +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var getVarCmd = &cobra.Command{ - Use: "getvar [flags] [key]", - Short: "get variable(s) from a block", - Long: `Get variable(s) from a block. Without --all, requires a key argument. -With --all, prints all variables. Use -0 for null-terminated output.`, - Example: " wsh getvar FOO\n wsh getvar --all\n wsh getvar --all -0", - RunE: getVarRun, - PreRunE: preRunSetupRpcClient, -} - -var ( - getVarFileName string - getVarAllVars bool - getVarNullTerminate bool - getVarLocal bool - getVarFlagNL bool - getVarFlagNoNL bool -) - -func init() { - rootCmd.AddCommand(getVarCmd) - getVarCmd.Flags().StringVar(&getVarFileName, "varfile", DefaultVarFileName, "var file name") - getVarCmd.Flags().BoolVar(&getVarAllVars, "all", false, "get all variables") - getVarCmd.Flags().BoolVarP(&getVarNullTerminate, "null", "0", false, "use null terminators in output") - getVarCmd.Flags().BoolVarP(&getVarLocal, "local", "l", false, "get variables local to block") - getVarCmd.Flags().BoolVarP(&getVarFlagNL, "newline", "n", false, "print newline after output") - getVarCmd.Flags().BoolVarP(&getVarFlagNoNL, "no-newline", "N", false, "do not print newline after output") -} - -func shouldPrintNewline() bool { - isTty := getIsTty() - if getVarFlagNL { - return true - } - if getVarFlagNoNL { - return false - } - return isTty -} - -func getVarRun(cmd *cobra.Command, args []string) error { - defer func() { - sendActivity("getvar", WshExitCode == 0) - }() - - // Resolve block to get zoneId - if blockArg == "" { - if getVarLocal { - blockArg = "this" - } else { - blockArg = "client" - } - } - fullORef, err := resolveBlockArg() - if err != nil { - return err - } - - if getVarAllVars { - if len(args) > 0 { - return fmt.Errorf("cannot specify key with --all") - } - return getAllVariables(fullORef.OID) - } - - // Single variable case - existing logic - if len(args) != 1 { - OutputHelpMessage(cmd) - return fmt.Errorf("requires a key argument") - } - - key := args[0] - commandData := wshrpc.CommandVarData{ - Key: key, - ZoneId: fullORef.OID, - FileName: getVarFileName, - } - - resp, err := wshclient.GetVarCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("getting variable: %w", err) - } - - if !resp.Exists { - WshExitCode = 1 - return nil - } - - WriteStdout("%s", resp.Val) - if shouldPrintNewline() { - WriteStdout("\n") - } - - return nil -} - -func getAllVariables(zoneId string) error { - commandData := wshrpc.CommandVarData{ - ZoneId: zoneId, - FileName: getVarFileName, - } - - vars, err := wshclient.GetAllVarsCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("getting variables: %w", err) - } - - terminator := "\n" - if getVarNullTerminate { - terminator = "\x00" - } - - for _, v := range vars { - WriteStdout("%s=%s%s", v.Key, v.Val, terminator) - } - - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-jobdebug.go b/cmd/wsh/cmd/wshcmd-jobdebug.go deleted file mode 100644 index 27a7b74772..0000000000 --- a/cmd/wsh/cmd/wshcmd-jobdebug.go +++ /dev/null @@ -1,448 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "encoding/json" - "fmt" - "io" - "os" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -var jobDebugCmd = &cobra.Command{ - Use: "jobdebug", - Short: "debugging commands for the job system", - Hidden: true, - PersistentPreRunE: preRunSetupRpcClient, -} - -var jobDebugListCmd = &cobra.Command{ - Use: "list", - Short: "list all jobs with debug information", - RunE: jobDebugListRun, -} - -var jobDebugDeleteCmd = &cobra.Command{ - Use: "delete", - Short: "delete a job entry by jobid", - RunE: jobDebugDeleteRun, -} - -var jobDebugDeleteAllCmd = &cobra.Command{ - Use: "deleteall", - Short: "delete all jobs", - RunE: jobDebugDeleteAllRun, -} - -var jobDebugPruneCmd = &cobra.Command{ - Use: "prune", - Short: "remove jobs where the job manager is no longer running", - RunE: jobDebugPruneRun, -} - -var jobDebugTerminateCmd = &cobra.Command{ - Use: "terminate", - Short: "terminate a job manager", - RunE: jobDebugTerminateRun, -} - -var jobDebugDisconnectCmd = &cobra.Command{ - Use: "disconnect", - Short: "disconnect from a job manager", - RunE: jobDebugDisconnectRun, -} - -var jobDebugReconnectCmd = &cobra.Command{ - Use: "reconnect", - Short: "reconnect to a job manager", - RunE: jobDebugReconnectRun, -} - -var jobDebugReconnectConnCmd = &cobra.Command{ - Use: "reconnectconn", - Short: "reconnect all jobs for a connection", - RunE: jobDebugReconnectConnRun, -} - -var jobDebugGetOutputCmd = &cobra.Command{ - Use: "getoutput", - Short: "get the terminal output for a job", - RunE: jobDebugGetOutputRun, -} - -var jobDebugStartCmd = &cobra.Command{ - Use: "start", - Short: "start a new job", - Args: cobra.MinimumNArgs(1), - RunE: jobDebugStartRun, -} - -var jobDebugAttachJobCmd = &cobra.Command{ - Use: "attachjob", - Short: "attach a job to a block", - RunE: jobDebugAttachJobRun, -} - -var jobDebugDetachJobCmd = &cobra.Command{ - Use: "detachjob", - Short: "detach a job from its block", - RunE: jobDebugDetachJobRun, -} - -var jobDebugBlockAttachmentCmd = &cobra.Command{ - Use: "blockattachment", - Short: "show the attached job for a block", - RunE: jobDebugBlockAttachmentRun, -} - -var jobIdFlag string -var jobDebugJsonFlag bool -var jobConnFlag string -var terminateJobIdFlag string -var disconnectJobIdFlag string -var reconnectJobIdFlag string -var reconnectConnNameFlag string -var attachJobIdFlag string -var attachBlockIdFlag string -var detachJobIdFlag string - -func init() { - rootCmd.AddCommand(jobDebugCmd) - jobDebugCmd.AddCommand(jobDebugListCmd) - jobDebugCmd.AddCommand(jobDebugDeleteCmd) - jobDebugCmd.AddCommand(jobDebugDeleteAllCmd) - jobDebugCmd.AddCommand(jobDebugPruneCmd) - jobDebugCmd.AddCommand(jobDebugTerminateCmd) - jobDebugCmd.AddCommand(jobDebugDisconnectCmd) - jobDebugCmd.AddCommand(jobDebugReconnectCmd) - jobDebugCmd.AddCommand(jobDebugReconnectConnCmd) - jobDebugCmd.AddCommand(jobDebugGetOutputCmd) - jobDebugCmd.AddCommand(jobDebugStartCmd) - jobDebugCmd.AddCommand(jobDebugAttachJobCmd) - jobDebugCmd.AddCommand(jobDebugDetachJobCmd) - jobDebugCmd.AddCommand(jobDebugBlockAttachmentCmd) - - jobDebugListCmd.Flags().BoolVar(&jobDebugJsonFlag, "json", false, "output as JSON") - - jobDebugDeleteCmd.Flags().StringVar(&jobIdFlag, "jobid", "", "job id to delete (required)") - jobDebugDeleteCmd.MarkFlagRequired("jobid") - - jobDebugTerminateCmd.Flags().StringVar(&terminateJobIdFlag, "jobid", "", "job id to terminate (required)") - jobDebugTerminateCmd.MarkFlagRequired("jobid") - - jobDebugDisconnectCmd.Flags().StringVar(&disconnectJobIdFlag, "jobid", "", "job id to disconnect (required)") - jobDebugDisconnectCmd.MarkFlagRequired("jobid") - - jobDebugReconnectCmd.Flags().StringVar(&reconnectJobIdFlag, "jobid", "", "job id to reconnect (required)") - jobDebugReconnectCmd.MarkFlagRequired("jobid") - - jobDebugReconnectConnCmd.Flags().StringVar(&reconnectConnNameFlag, "conn", "", "connection name (required)") - jobDebugReconnectConnCmd.MarkFlagRequired("conn") - - jobDebugGetOutputCmd.Flags().StringVar(&jobIdFlag, "jobid", "", "job id to get output for (required)") - jobDebugGetOutputCmd.MarkFlagRequired("jobid") - - jobDebugStartCmd.Flags().StringVar(&jobConnFlag, "conn", "", "connection name (required)") - jobDebugStartCmd.MarkFlagRequired("conn") - - jobDebugAttachJobCmd.Flags().StringVar(&attachJobIdFlag, "jobid", "", "job id to attach (required)") - jobDebugAttachJobCmd.MarkFlagRequired("jobid") - jobDebugAttachJobCmd.Flags().StringVar(&attachBlockIdFlag, "blockid", "", "block id to attach to (required)") - jobDebugAttachJobCmd.MarkFlagRequired("blockid") - - jobDebugDetachJobCmd.Flags().StringVar(&detachJobIdFlag, "jobid", "", "job id to detach (required)") - jobDebugDetachJobCmd.MarkFlagRequired("jobid") -} - -func jobDebugListRun(cmd *cobra.Command, args []string) error { - rtnData, err := wshclient.JobControllerListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000}) - if err != nil { - return fmt.Errorf("getting job debug list: %w", err) - } - - connectedJobIds, err := wshclient.JobControllerConnectedJobsCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000}) - if err != nil { - return fmt.Errorf("getting connected job ids: %w", err) - } - - connectedMap := make(map[string]bool) - for _, jobId := range connectedJobIds { - connectedMap[jobId] = true - } - - if jobDebugJsonFlag { - jsonData, err := json.MarshalIndent(rtnData, "", " ") - if err != nil { - return fmt.Errorf("marshaling json: %w", err) - } - fmt.Printf("%s\n", string(jsonData)) - return nil - } - - fmt.Printf("%-36s %-25s %-9s %-10s %-6s %-30s %-8s %-10s %-8s\n", "OID", "Connection", "Connected", "Manager", "Reason", "Cmd", "ExitCode", "Stream", "Attached") - for _, job := range rtnData { - connectedStatus := "no" - if connectedMap[job.OID] { - connectedStatus = "yes" - } - if job.TerminateOnReconnect { - connectedStatus += "*" - } - - streamStatus := "-" - if job.StreamDone { - if job.StreamError == "" { - streamStatus = "EOF" - } else { - streamStatus = fmt.Sprintf("%q", job.StreamError) - } - } - - exitCode := "-" - if job.CmdExitTs > 0 { - if job.CmdExitCode != nil { - exitCode = fmt.Sprintf("%d", *job.CmdExitCode) - } else if job.CmdExitSignal != "" { - exitCode = job.CmdExitSignal - } else { - exitCode = "?" - } - } - - doneReason := "-" - if job.JobManagerDoneReason == "startuperror" { - doneReason = "serr" - } else if job.JobManagerDoneReason == "gone" { - doneReason = "gone" - } else if job.JobManagerDoneReason == "terminated" { - doneReason = "term" - } - - attachedBlock := "-" - if job.AttachedBlockId != "" { - if len(job.AttachedBlockId) >= 8 { - attachedBlock = job.AttachedBlockId[:8] - } else { - attachedBlock = job.AttachedBlockId - } - } - - fmt.Printf("%-36s %-25s %-9s %-10s %-6s %-30s %-8s %-10s %-8s\n", - job.OID, job.Connection, connectedStatus, job.JobManagerStatus, doneReason, job.Cmd, exitCode, streamStatus, attachedBlock) - } - return nil -} - -func jobDebugDeleteRun(cmd *cobra.Command, args []string) error { - err := wshclient.JobControllerDeleteJobCommand(RpcClient, jobIdFlag, &wshrpc.RpcOpts{Timeout: 5000}) - if err != nil { - return fmt.Errorf("deleting job: %w", err) - } - - fmt.Printf("Job %s deleted successfully\n", jobIdFlag) - return nil -} - -func jobDebugDeleteAllRun(cmd *cobra.Command, args []string) error { - rtnData, err := wshclient.JobControllerListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000}) - if err != nil { - return fmt.Errorf("getting job debug list: %w", err) - } - - if len(rtnData) == 0 { - fmt.Printf("No jobs to delete\n") - return nil - } - - deletedCount := 0 - for _, job := range rtnData { - err := wshclient.JobControllerDeleteJobCommand(RpcClient, job.OID, &wshrpc.RpcOpts{Timeout: 5000}) - if err != nil { - fmt.Printf("Error deleting job %s: %v\n", job.OID, err) - } else { - deletedCount++ - } - } - - fmt.Printf("Deleted %d of %d job(s)\n", deletedCount, len(rtnData)) - return nil -} - -func jobDebugPruneRun(cmd *cobra.Command, args []string) error { - rtnData, err := wshclient.JobControllerListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000}) - if err != nil { - return fmt.Errorf("getting job debug list: %w", err) - } - - if len(rtnData) == 0 { - fmt.Printf("No jobs to prune\n") - return nil - } - - deletedCount := 0 - for _, job := range rtnData { - if job.JobManagerStatus != "running" { - err := wshclient.JobControllerDeleteJobCommand(RpcClient, job.OID, &wshrpc.RpcOpts{Timeout: 5000}) - if err != nil { - fmt.Printf("Error deleting job %s: %v\n", job.OID, err) - } else { - deletedCount++ - } - } - } - - if deletedCount == 0 { - fmt.Printf("No jobs with stopped job managers to prune\n") - } else { - fmt.Printf("Pruned %d job(s) with stopped job managers\n", deletedCount) - } - return nil -} - -func jobDebugTerminateRun(cmd *cobra.Command, args []string) error { - err := wshclient.JobControllerExitJobCommand(RpcClient, terminateJobIdFlag, nil) - if err != nil { - return fmt.Errorf("terminating job manager: %w", err) - } - - fmt.Printf("Job manager for %s terminated successfully\n", terminateJobIdFlag) - return nil -} - -func jobDebugDisconnectRun(cmd *cobra.Command, args []string) error { - err := wshclient.JobControllerDisconnectJobCommand(RpcClient, disconnectJobIdFlag, nil) - if err != nil { - return fmt.Errorf("disconnecting from job manager: %w", err) - } - - fmt.Printf("Disconnected from job manager for %s successfully\n", disconnectJobIdFlag) - return nil -} - -func jobDebugReconnectRun(cmd *cobra.Command, args []string) error { - err := wshclient.JobControllerReconnectJobCommand(RpcClient, reconnectJobIdFlag, nil) - if err != nil { - return fmt.Errorf("reconnecting to job manager: %w", err) - } - - fmt.Printf("Reconnected to job manager for %s successfully\n", reconnectJobIdFlag) - return nil -} - -func jobDebugReconnectConnRun(cmd *cobra.Command, args []string) error { - err := wshclient.JobControllerReconnectJobsForConnCommand(RpcClient, reconnectConnNameFlag, nil) - if err != nil { - return fmt.Errorf("reconnecting jobs for connection: %w", err) - } - - fmt.Printf("Reconnected all jobs for connection %s successfully\n", reconnectConnNameFlag) - return nil -} - -func jobDebugGetOutputRun(cmd *cobra.Command, args []string) error { - broker := RpcClient.StreamBroker - if broker == nil { - return fmt.Errorf("stream broker not available") - } - - readerRouteId, err := wshclient.ControlGetRouteIdCommand(RpcClient, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) - if err != nil { - return fmt.Errorf("getting route id: %w", err) - } - if readerRouteId == "" { - return fmt.Errorf("no route to receive data") - } - writerRouteId := "" // main server route - reader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, 64*1024) - defer reader.Close() - - data := wshrpc.CommandWaveFileReadStreamData{ - ZoneId: jobIdFlag, - Name: "term", - StreamMeta: *streamMeta, - } - - _, err = wshclient.WaveFileReadStreamCommand(RpcClient, data, nil) - if err != nil { - return fmt.Errorf("starting stream read: %w", err) - } - - _, err = io.Copy(os.Stdout, reader) - if err != nil { - return fmt.Errorf("reading stream: %w", err) - } - return nil -} - -func jobDebugStartRun(cmd *cobra.Command, args []string) error { - cmdToRun := args[0] - cmdArgs := args[1:] - - data := wshrpc.CommandJobControllerStartJobData{ - ConnName: jobConnFlag, - JobKind: "task", - Cmd: cmdToRun, - Args: cmdArgs, - Env: make(map[string]string), - TermSize: nil, - } - - jobId, err := wshclient.JobControllerStartJobCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 10000}) - if err != nil { - return fmt.Errorf("starting job: %w", err) - } - - fmt.Printf("Job started successfully with ID: %s\n", jobId) - return nil -} - -func jobDebugAttachJobRun(cmd *cobra.Command, args []string) error { - data := wshrpc.CommandJobControllerAttachJobData{ - JobId: attachJobIdFlag, - BlockId: attachBlockIdFlag, - } - - err := wshclient.JobControllerAttachJobCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 5000}) - if err != nil { - return fmt.Errorf("attaching job: %w", err) - } - - fmt.Printf("Job %s attached to block %s successfully\n", attachJobIdFlag, attachBlockIdFlag) - return nil -} - -func jobDebugDetachJobRun(cmd *cobra.Command, args []string) error { - err := wshclient.JobControllerDetachJobCommand(RpcClient, detachJobIdFlag, &wshrpc.RpcOpts{Timeout: 5000}) - if err != nil { - return fmt.Errorf("detaching job: %w", err) - } - - fmt.Printf("Job %s detached successfully\n", detachJobIdFlag) - return nil -} - -func jobDebugBlockAttachmentRun(cmd *cobra.Command, args []string) error { - blockORef, err := resolveBlockArg() - if err != nil { - return err - } - - blockId := blockORef.OID - jobStatus, err := wshclient.BlockJobStatusCommand(RpcClient, blockId, &wshrpc.RpcOpts{Timeout: 5000}) - if err != nil { - return fmt.Errorf("getting block job status: %w", err) - } - - if jobStatus.JobId == "" { - fmt.Printf("Block %s: no attached job\n", blockId) - } else { - fmt.Printf("Block %s: attached to job %s\n", blockId, jobStatus.JobId) - } - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-jobmanager.go b/cmd/wsh/cmd/wshcmd-jobmanager.go deleted file mode 100644 index bf5562c3a7..0000000000 --- a/cmd/wsh/cmd/wshcmd-jobmanager.go +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "bufio" - "context" - "encoding/base64" - "fmt" - "os" - "strings" - "time" - - "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/jobmanager" -) - -var jobManagerCmd = &cobra.Command{ - Use: "jobmanager", - Hidden: true, - Short: "job manager for wave terminal", - Args: cobra.NoArgs, - RunE: jobManagerRun, -} - -var jobManagerJobId string -var jobManagerClientId string - -func init() { - jobManagerCmd.Flags().StringVar(&jobManagerJobId, "jobid", "", "job ID (UUID, required)") - jobManagerCmd.Flags().StringVar(&jobManagerClientId, "clientid", "", "client ID (UUID, required)") - jobManagerCmd.MarkFlagRequired("jobid") - jobManagerCmd.MarkFlagRequired("clientid") - rootCmd.AddCommand(jobManagerCmd) -} - -func jobManagerRun(cmd *cobra.Command, args []string) error { - _, err := uuid.Parse(jobManagerJobId) - if err != nil { - return fmt.Errorf("invalid jobid: must be a valid UUID") - } - - _, err = uuid.Parse(jobManagerClientId) - if err != nil { - return fmt.Errorf("invalid clientid: must be a valid UUID") - } - - publicKeyB64 := os.Getenv("WAVETERM_PUBLICKEY") - if publicKeyB64 == "" { - return fmt.Errorf("WAVETERM_PUBLICKEY environment variable is not set") - } - - publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64) - if err != nil { - return fmt.Errorf("failed to decode WAVETERM_PUBLICKEY: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - jobAuthToken, err := readJobAuthToken(ctx) - if err != nil { - return fmt.Errorf("failed to read job auth token: %v", err) - } - - readyFile := os.NewFile(3, "ready-pipe") - _, err = readyFile.Stat() - if err != nil { - return fmt.Errorf("ready pipe (fd 3) not available: %v", err) - } - - err = jobmanager.SetupJobManager(jobManagerClientId, jobManagerJobId, publicKeyBytes, jobAuthToken, readyFile) - if err != nil { - return fmt.Errorf("error setting up job manager: %v", err) - } - - select {} -} - -func readJobAuthToken(ctx context.Context) (string, error) { - resultCh := make(chan string, 1) - errorCh := make(chan error, 1) - - go func() { - reader := bufio.NewReader(os.Stdin) - line, err := reader.ReadString('\n') - if err != nil { - errorCh <- fmt.Errorf("error reading from stdin: %v", err) - return - } - - line = strings.TrimSpace(line) - prefix := jobmanager.JobAccessTokenLabel + ":" - if !strings.HasPrefix(line, prefix) { - errorCh <- fmt.Errorf("invalid token format: expected '%s'", prefix) - return - } - - token := strings.TrimPrefix(line, prefix) - token = strings.TrimSpace(token) - if token == "" { - errorCh <- fmt.Errorf("empty job auth token") - return - } - - resultCh <- token - }() - - select { - case token := <-resultCh: - return token, nil - case err := <-errorCh: - return "", err - case <-ctx.Done(): - return "", ctx.Err() - } -} diff --git a/cmd/wsh/cmd/wshcmd-launch.go b/cmd/wsh/cmd/wshcmd-launch.go deleted file mode 100644 index 3ec582a6cd..0000000000 --- a/cmd/wsh/cmd/wshcmd-launch.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var magnifyBlock bool - -var launchCmd = &cobra.Command{ - Use: "launch", - Short: "launch a widget by its ID", - Args: cobra.ExactArgs(1), - RunE: launchRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - launchCmd.Flags().BoolVarP(&magnifyBlock, "magnify", "m", false, "start the widget in magnified mode") - rootCmd.AddCommand(launchCmd) -} - -func launchRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("launch", rtnErr == nil) - }() - - widgetId := args[0] - - // Get the full configuration - config, err := wshclient.GetFullConfigCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("getting configuration: %w", err) - } - - // Look for widget in both widgets and defaultwidgets - widget, ok := config.Widgets[widgetId] - if !ok { - widget, ok = config.DefaultWidgets[widgetId] - if !ok { - return fmt.Errorf("widget %q not found in configuration", widgetId) - } - } - - tabId := getTabIdFromEnv() - if tabId == "" { - return fmt.Errorf("no WAVETERM_TABID env var set") - } - - // Create block data from widget config - createBlockData := wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &widget.BlockDef, - Magnified: magnifyBlock || widget.Magnified, - Focused: true, - } - - // Create the block - oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) - if err != nil { - return fmt.Errorf("creating widget block: %w", err) - } - - WriteStdout("launched widget %q: %s\n", widgetId, oref) - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-notify.go b/cmd/wsh/cmd/wshcmd-notify.go deleted file mode 100644 index de2086e1f7..0000000000 --- a/cmd/wsh/cmd/wshcmd-notify.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -var notifyTitle string -var notifySilent bool - -var setNotifyCmd = &cobra.Command{ - Use: "notify [-t ] [-s]", - Short: "create a notification", - Args: cobra.ExactArgs(1), - RunE: notifyRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - setNotifyCmd.Flags().StringVarP(¬ifyTitle, "title", "t", "Wsh Notify", "the notification title") - setNotifyCmd.Flags().BoolVarP(¬ifySilent, "silent", "s", false, "whether or not the notification sound is silenced") - rootCmd.AddCommand(setNotifyCmd) -} - -func notifyRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("notify", rtnErr == nil) - }() - message := args[0] - notificationOptions := &wshrpc.WaveNotificationOptions{ - Title: notifyTitle, - Body: message, - Silent: notifySilent, - } - err := wshclient.NotifyCommand(RpcClient, *notificationOptions, &wshrpc.RpcOpts{Timeout: 2000, Route: wshutil.ElectronRoute}) - if err != nil { - return fmt.Errorf("sending notification: %w", err) - } - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-rcfiles.go b/cmd/wsh/cmd/wshcmd-rcfiles.go deleted file mode 100644 index 745d325682..0000000000 --- a/cmd/wsh/cmd/wshcmd-rcfiles.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -func init() { - rootCmd.AddCommand(rcfilesCmd) -} - -var rcfilesCmd = &cobra.Command{ - Use: "rcfiles", - Hidden: true, - Short: "Generate the rc files needed for various shells", - Run: func(cmd *cobra.Command, args []string) { - err := wshutil.InstallRcFiles() - if err != nil { - WriteStderr("%s\n", err.Error()) - return - } - }, -} diff --git a/cmd/wsh/cmd/wshcmd-readfile.go b/cmd/wsh/cmd/wshcmd-readfile.go deleted file mode 100644 index 09344967de..0000000000 --- a/cmd/wsh/cmd/wshcmd-readfile.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "io" - "os" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -var readFileCmd = &cobra.Command{ - Use: "readfile [filename]", - Short: "read a blockfile", - Args: cobra.ExactArgs(1), - Run: runReadFile, - PreRunE: preRunSetupRpcClient, - Hidden: true, -} - -func init() { - rootCmd.AddCommand(readFileCmd) -} - -func runReadFile(cmd *cobra.Command, args []string) { - fullORef, err := resolveBlockArg() - if err != nil { - WriteStderr("[error] %v\n", err) - return - } - - broker := RpcClient.StreamBroker - if broker == nil { - WriteStderr("[error] stream broker not available\n") - return - } - - readerRouteId, err := wshclient.ControlGetRouteIdCommand(RpcClient, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) - if err != nil { - WriteStderr("[error] getting route id: %v\n", err) - return - } - if readerRouteId == "" { - WriteStderr("[error] no route to receive data\n") - return - } - writerRouteId := "" - reader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, 64*1024) - defer reader.Close() - - data := wshrpc.CommandWaveFileReadStreamData{ - ZoneId: fullORef.OID, - Name: args[0], - StreamMeta: *streamMeta, - } - - _, err = wshclient.WaveFileReadStreamCommand(RpcClient, data, nil) - if err != nil { - WriteStderr("[error] starting stream read: %v\n", err) - return - } - - _, err = io.Copy(os.Stdout, reader) - if err != nil { - WriteStderr("[error] reading stream: %v\n", err) - return - } -} diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go deleted file mode 100644 index 9534d2e5f5..0000000000 --- a/cmd/wsh/cmd/wshcmd-root.go +++ /dev/null @@ -1,251 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "io" - "os" - "runtime/debug" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/util/shellutil" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -var ( - rootCmd = &cobra.Command{ - Use: "wsh", - Short: "CLI tool to control Wave Terminal", - Long: `wsh is a small utility that lets you do cool things with Wave Terminal, right from the command line`, - SilenceUsage: true, - } -) - -var WrappedStdin io.Reader = os.Stdin -var WrappedStdout io.Writer = &WrappedWriter{dest: os.Stdout} -var WrappedStderr io.Writer = &WrappedWriter{dest: os.Stderr} -var RpcClient *wshutil.WshRpc -var RpcContext wshrpc.RpcContext -var RpcClientRouteId string -var UsingTermWshMode bool -var blockArg string -var WshExitCode int - -type WrappedWriter struct { - dest io.Writer -} - -func (w *WrappedWriter) Write(p []byte) (n int, err error) { - if !UsingTermWshMode { - return w.dest.Write(p) - } - count := 0 - for _, b := range p { - if b == '\n' { - count++ - } - } - if count == 0 { - return w.dest.Write(p) - } - buf := make([]byte, len(p)+count) // Each '\n' adds one extra byte for '\r' - writeIdx := 0 - for _, b := range p { - if b == '\n' { - buf[writeIdx] = '\r' - buf[writeIdx+1] = '\n' - writeIdx += 2 - } else { - buf[writeIdx] = b - writeIdx++ - } - } - return w.dest.Write(buf) -} - -func WriteStderr(fmtStr string, args ...interface{}) { - WrappedStderr.Write([]byte(fmt.Sprintf(fmtStr, args...))) -} - -func WriteStdout(fmtStr string, args ...interface{}) { - WrappedStdout.Write([]byte(fmt.Sprintf(fmtStr, args...))) -} - -func OutputHelpMessage(cmd *cobra.Command) { - cmd.SetOutput(WrappedStderr) - cmd.Help() - WriteStderr("\n") -} - -func preRunSetupRpcClient(cmd *cobra.Command, args []string) error { - jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) - if jwtToken == "" { - return fmt.Errorf("wsh must be run inside a Wave-managed SSH session (WAVETERM_JWT not found)") - } - err := setupRpcClient(nil, jwtToken) - if err != nil { - return err - } - return nil -} - -func getIsTty() bool { - if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 { - return true - } - return false -} - -type RunEFnType = func(*cobra.Command, []string) error - -func activityWrap(activityStr string, origRunE RunEFnType) RunEFnType { - return func(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity(activityStr, rtnErr == nil) - }() - return origRunE(cmd, args) - } -} - -func resolveBlockArg() (*waveobj.ORef, error) { - oref := blockArg - if oref == "" { - oref = "this" - } - fullORef, err := resolveSimpleId(oref) - if err != nil { - return nil, fmt.Errorf("resolving blockid: %w", err) - } - return fullORef, nil -} - -func setupRpcClientWithToken(swapTokenStr string) (wshrpc.CommandAuthenticateRtnData, error) { - var rtn wshrpc.CommandAuthenticateRtnData - token, err := shellutil.UnpackSwapToken(swapTokenStr) - if err != nil { - return rtn, fmt.Errorf("error unpacking token: %w", err) - } - if token.RpcContext == nil { - return rtn, fmt.Errorf("no rpccontext in token") - } - if token.RpcContext.SockName == "" { - return rtn, fmt.Errorf("no sockname in token") - } - RpcContext = *token.RpcContext - RpcClient, err = wshutil.SetupDomainSocketRpcClient(token.RpcContext.SockName, nil, "wshcmd") - if err != nil { - return rtn, fmt.Errorf("error setting up domain socket rpc client: %w", err) - } - rtn, err = wshclient.AuthenticateTokenCommand(RpcClient, wshrpc.CommandAuthenticateTokenData{Token: token.Token}, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) - if err != nil { - return rtn, err - } - RpcClientRouteId = rtn.RouteId - return rtn, nil -} - -// returns the wrapped stdin and a new rpc client (that wraps the stdin input and stdout output) -func setupRpcClient(serverImpl wshutil.ServerImpl, jwtToken string) error { - rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken) - if err != nil { - return fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err) - } - RpcContext = *rpcCtx - sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) - if err != nil { - return fmt.Errorf("error extracting socket name from %s: %v", wshutil.WaveJwtTokenVarName, err) - } - RpcClient, err = wshutil.SetupDomainSocketRpcClient(sockName, serverImpl, "wshcmd") - if err != nil { - return fmt.Errorf("error setting up domain socket rpc client: %v", err) - } - authRtn, err := wshclient.AuthenticateCommand(RpcClient, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) - if err != nil { - return fmt.Errorf("error authenticating: %v", err) - } - RpcClientRouteId = authRtn.RouteId - blockId := os.Getenv("WAVETERM_BLOCKID") - if blockId != "" { - peerInfo := fmt.Sprintf("domain:block:%s", blockId) - wshclient.SetPeerInfoCommand(RpcClient, peerInfo, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) - } - // note we don't modify WrappedStdin here (just use os.Stdin) - return nil -} - -func isFullORef(orefStr string) bool { - _, err := waveobj.ParseORef(orefStr) - return err == nil -} - -func resolveSimpleId(id string) (*waveobj.ORef, error) { - if isFullORef(id) { - orefObj, err := waveobj.ParseORef(id) - if err != nil { - return nil, fmt.Errorf("error parsing full ORef: %v", err) - } - return &orefObj, nil - } - blockId := os.Getenv("WAVETERM_BLOCKID") - if blockId == "" { - return nil, fmt.Errorf("no WAVETERM_BLOCKID env var set") - } - rtnData, err := wshclient.ResolveIdsCommand(RpcClient, wshrpc.CommandResolveIdsData{ - BlockId: blockId, - Ids: []string{id}, - }, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return nil, fmt.Errorf("error resolving ids: %v", err) - } - oref, ok := rtnData.ResolvedIds[id] - if !ok { - return nil, fmt.Errorf("id not found: %q", id) - } - return &oref, nil -} - -func getTabIdFromEnv() string { - return os.Getenv("WAVETERM_TABID") -} - -// this will send wsh activity to the client running on *your* local machine (it does not contact any wave cloud infrastructure) -// if you've turned off telemetry in your local client, this data never gets sent to us -// no parameters or timestamps are sent, as you can see below, it just sends the name of the command (and if there was an error) -// (e.g. "wsh ai ..." would send "ai") -// this helps us understand which commands are actually being used so we know where to concentrate our effort -func sendActivity(wshCmdName string, success bool) { - if RpcClient == nil || wshCmdName == "" { - return - } - dataMap := make(map[string]int) - dataMap[wshCmdName] = 1 - if !success { - dataMap[wshCmdName+"#"+"error"] = 1 - } - wshclient.WshActivityCommand(RpcClient, dataMap, nil) -} - -// Execute executes the root command. -func Execute() { - defer func() { - r := recover() - if r != nil { - WriteStderr("[panic] %v\n", r) - debug.PrintStack() - wshutil.DoShutdown("", 1, true) - } else { - wshutil.DoShutdown("", WshExitCode, false) - } - }() - rootCmd.PersistentFlags().StringVarP(&blockArg, "block", "b", "", "for commands which require a block id") - err := rootCmd.Execute() - if err != nil { - wshutil.DoShutdown("", 1, true) - return - } -} diff --git a/cmd/wsh/cmd/wshcmd-run.go b/cmd/wsh/cmd/wshcmd-run.go deleted file mode 100644 index 6faf424c99..0000000000 --- a/cmd/wsh/cmd/wshcmd-run.go +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/util/envutil" - "github.com/wavetermdev/waveterm/pkg/wavebase" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var runCmd = &cobra.Command{ - Use: "run [flags] -- command [args...]", - Short: "run a command in a new block", - RunE: runRun, - PreRunE: preRunSetupRpcClient, - TraverseChildren: true, -} - -func init() { - flags := runCmd.Flags() - flags.BoolP("magnified", "m", false, "open view in magnified mode") - flags.StringP("command", "c", "", "run command string in shell") - flags.BoolP("exit", "x", false, "close block if command exits successfully (will stay open if there was an error)") - flags.BoolP("forceexit", "X", false, "close block when command exits, regardless of exit status") - flags.IntP("delay", "", 2000, "if -x, delay in milliseconds before closing block") - flags.BoolP("paused", "p", false, "create block in paused state") - flags.String("cwd", "", "set working directory for command") - flags.BoolP("append", "a", false, "append output on restart instead of clearing") - rootCmd.AddCommand(runCmd) -} - -func runRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("run", rtnErr == nil) - }() - - flags := cmd.Flags() - magnified, _ := flags.GetBool("magnified") - commandArg, _ := flags.GetString("command") - exit, _ := flags.GetBool("exit") - forceExit, _ := flags.GetBool("forceexit") - paused, _ := flags.GetBool("paused") - cwd, _ := flags.GetString("cwd") - delayMs, _ := flags.GetInt("delay") - appendOutput, _ := flags.GetBool("append") - var cmdArgs []string - var useShell bool - var shellCmd string - - for i, arg := range os.Args { - if arg == "--" { - if i+1 >= len(os.Args) { - OutputHelpMessage(cmd) - return fmt.Errorf("no command provided after --") - } - shellCmd = os.Args[i+1] - cmdArgs = os.Args[i+2:] - break - } - } - if shellCmd != "" && commandArg != "" { - OutputHelpMessage(cmd) - return fmt.Errorf("cannot specify both -c and command arguments") - } - if shellCmd == "" && commandArg == "" { - OutputHelpMessage(cmd) - return fmt.Errorf("command must be specified after -- or with -c") - } - if commandArg != "" { - shellCmd = commandArg - useShell = true - } - - // Get current working directory - if cwd == "" { - var err error - cwd, err = os.Getwd() - if err != nil { - return fmt.Errorf("getting current directory: %w", err) - } - } - cwd, err := filepath.Abs(cwd) - if err != nil { - return fmt.Errorf("getting absolute path: %w", err) - } - - // Get current environment and convert to map - envMap := make(map[string]string) - for _, envStr := range os.Environ() { - env := strings.SplitN(envStr, "=", 2) - if len(env) == 2 { - envMap[env[0]] = env[1] - } - } - - // Convert to null-terminated format - envContent := envutil.MapToEnv(envMap) - createMeta := map[string]any{ - waveobj.MetaKey_View: "term", - waveobj.MetaKey_CmdCwd: cwd, - waveobj.MetaKey_Controller: "cmd", - waveobj.MetaKey_CmdClearOnStart: true, - } - createMeta[waveobj.MetaKey_Cmd] = shellCmd - createMeta[waveobj.MetaKey_CmdArgs] = cmdArgs - createMeta[waveobj.MetaKey_CmdShell] = useShell - if paused { - createMeta[waveobj.MetaKey_CmdRunOnStart] = false - } else { - createMeta[waveobj.MetaKey_CmdRunOnce] = true - createMeta[waveobj.MetaKey_CmdRunOnStart] = true - } - if forceExit { - createMeta[waveobj.MetaKey_CmdCloseOnExitForce] = true - } else if exit { - createMeta[waveobj.MetaKey_CmdCloseOnExit] = true - } - createMeta[waveobj.MetaKey_CmdCloseOnExitDelay] = float64(delayMs) - if appendOutput { - createMeta[waveobj.MetaKey_CmdClearOnStart] = false - } - - if RpcContext.Conn != "" { - createMeta[waveobj.MetaKey_Connection] = RpcContext.Conn - } - - tabId := getTabIdFromEnv() - if tabId == "" { - return fmt.Errorf("no WAVETERM_TABID env var set") - } - - createBlockData := wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &waveobj.BlockDef{ - Meta: createMeta, - Files: map[string]*waveobj.FileDef{ - wavebase.BlockFile_Env: { - Content: envContent, - }, - }, - }, - Magnified: magnified, - Focused: true, - } - - oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) - if err != nil { - return fmt.Errorf("creating new run block: %w", err) - } - - WriteStdout("run block created: %s\n", oref) - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-secret.go b/cmd/wsh/cmd/wshcmd-secret.go deleted file mode 100644 index 916e3ae4a5..0000000000 --- a/cmd/wsh/cmd/wshcmd-secret.go +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "regexp" - "strings" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -// secretNameRegex must match the validation in pkg/wconfig/secretstore.go -var secretNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) - -var secretUiMagnified bool - -var secretCmd = &cobra.Command{ - Use: "secret", - Short: "manage secrets", - Long: "Manage secrets for Wave Terminal", -} - -var secretGetCmd = &cobra.Command{ - Use: "get [name]", - Short: "get a secret value", - Args: cobra.ExactArgs(1), - RunE: secretGetRun, - PreRunE: preRunSetupRpcClient, -} - -var secretSetCmd = &cobra.Command{ - Use: "set [name]=[value]", - Short: "set a secret value", - Args: cobra.ExactArgs(1), - RunE: secretSetRun, - PreRunE: preRunSetupRpcClient, -} - -var secretListCmd = &cobra.Command{ - Use: "list", - Short: "list all secret names", - Args: cobra.NoArgs, - RunE: secretListRun, - PreRunE: preRunSetupRpcClient, -} - -var secretDeleteCmd = &cobra.Command{ - Use: "delete [name]", - Short: "delete a secret", - Args: cobra.ExactArgs(1), - RunE: secretDeleteRun, - PreRunE: preRunSetupRpcClient, -} - -var secretUiCmd = &cobra.Command{ - Use: "ui", - Short: "open secrets UI", - Args: cobra.NoArgs, - RunE: secretUiRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - secretUiCmd.Flags().BoolVarP(&secretUiMagnified, "magnified", "m", false, "open secrets UI in magnified mode") - rootCmd.AddCommand(secretCmd) - secretCmd.AddCommand(secretGetCmd) - secretCmd.AddCommand(secretSetCmd) - secretCmd.AddCommand(secretListCmd) - secretCmd.AddCommand(secretDeleteCmd) - secretCmd.AddCommand(secretUiCmd) -} - -func secretGetRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("secret", rtnErr == nil) - }() - - name := args[0] - if !secretNameRegex.MatchString(name) { - return fmt.Errorf("invalid secret name: must start with a letter and contain only letters, numbers, and underscores") - } - - resp, err := wshclient.GetSecretsCommand(RpcClient, []string{name}, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("getting secret: %w", err) - } - - value, ok := resp[name] - if !ok { - return fmt.Errorf("secret not found: %s", name) - } - - WriteStdout("%s\n", value) - return nil -} - -func secretSetRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("secret", rtnErr == nil) - }() - - parts := strings.SplitN(args[0], "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid format: expected [name]=[value]") - } - - name := parts[0] - value := parts[1] - - if name == "" { - return fmt.Errorf("secret name cannot be empty") - } - - backend, err := wshclient.GetSecretsLinuxStorageBackendCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("checking secret storage backend: %w", err) - } - - if backend == "basic_text" || backend == "unknown" { - return fmt.Errorf("No appropriate secret manager found, cannot set secrets") - } - - secrets := map[string]*string{name: &value} - err = wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("setting secret: %w", err) - } - - WriteStdout("secret set: %s\n", name) - return nil -} - -func secretListRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("secret", rtnErr == nil) - }() - - names, err := wshclient.GetSecretsNamesCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("listing secrets: %w", err) - } - - for _, name := range names { - WriteStdout("%s\n", name) - } - return nil -} - -func secretDeleteRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("secret", rtnErr == nil) - }() - - name := args[0] - if !secretNameRegex.MatchString(name) { - return fmt.Errorf("invalid secret name: must start with a letter and contain only letters, numbers, and underscores") - } - - secrets := map[string]*string{name: nil} - err := wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("deleting secret: %w", err) - } - - WriteStdout("secret deleted: %s\n", name) - return nil -} - -func secretUiRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("secret", rtnErr == nil) - }() - - tabId := getTabIdFromEnv() - if tabId == "" { - return fmt.Errorf("no WAVETERM_TABID env var set") - } - - wshCmd := &wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &waveobj.BlockDef{ - Meta: map[string]interface{}{ - waveobj.MetaKey_View: "waveconfig", - waveobj.MetaKey_File: "secrets", - }, - }, - Magnified: secretUiMagnified, - Focused: true, - } - - _, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("opening secrets UI: %w", err) - } - return nil -} \ No newline at end of file diff --git a/cmd/wsh/cmd/wshcmd-setbg.go b/cmd/wsh/cmd/wshcmd-setbg.go deleted file mode 100644 index 4385409187..0000000000 --- a/cmd/wsh/cmd/wshcmd-setbg.go +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "encoding/hex" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/util/fileutil" - "github.com/wavetermdev/waveterm/pkg/wavebase" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var setBgCmd = &cobra.Command{ - Use: "setbg [--opacity value] [--tile|--center] [--scale value] [--border-color color] [--active-border-color color] (image-path|\"#color\"|color-name)", - Short: "set background image or color for a tab", - Long: `Set a background image or color for a tab. Colors can be specified as: - - A quoted hex value like "#ff0000" (quotes required to prevent # being interpreted as a shell comment) - - A CSS color name like "blue" or "forestgreen" -Or provide a path to a supported image file (jpg, png, gif, webp, or svg). - -You can also: - - Use --clear to remove the background - - Use --opacity without other arguments to change just the opacity - - Use --center for centered images without scaling (good for logos) - - Use --scale with --center to control image size - - Use --border-color to set the block frame border color - - Use --active-border-color to set the block frame focused border color - - Use --print to see the metadata without applying it`, - RunE: setBgRun, - PreRunE: preRunSetupRpcClient, -} - -var ( - setBgOpacity float64 - setBgTile bool - setBgCenter bool - setBgSize string - setBgClear bool - setBgPrint bool - setBgBorderColor string - setBgActiveBorderColor string -) - -func init() { - rootCmd.AddCommand(setBgCmd) - setBgCmd.Flags().Float64Var(&setBgOpacity, "opacity", 0.5, "background opacity (0.0-1.0)") - setBgCmd.Flags().BoolVar(&setBgTile, "tile", false, "tile the background image") - setBgCmd.Flags().BoolVar(&setBgCenter, "center", false, "center the image without scaling") - setBgCmd.Flags().StringVar(&setBgSize, "size", "auto", "size for centered images (px, %, or auto)") - setBgCmd.Flags().BoolVar(&setBgClear, "clear", false, "clear the background") - setBgCmd.Flags().BoolVar(&setBgPrint, "print", false, "print the metadata without applying it") - setBgCmd.Flags().StringVar(&setBgBorderColor, "border-color", "", "block frame border color (#RRGGBB, #RRGGBBAA, or CSS color name)") - setBgCmd.Flags().StringVar(&setBgActiveBorderColor, "active-border-color", "", "block frame focused border color (#RRGGBB, #RRGGBBAA, or CSS color name)") - - setBgCmd.MarkFlagsMutuallyExclusive("tile", "center") -} - -func validateHexColor(color string) error { - if !strings.HasPrefix(color, "#") { - return fmt.Errorf("color must start with #") - } - colorHex := color[1:] - if len(colorHex) != 6 && len(colorHex) != 8 { - return fmt.Errorf("color must be in #RRGGBB or #RRGGBBAA format") - } - _, err := hex.DecodeString(colorHex) - if err != nil { - return fmt.Errorf("invalid hex color: %v", err) - } - return nil -} - -func validateColor(color string) error { - if strings.HasPrefix(color, "#") { - return validateHexColor(color) - } - if !CssColorNames[strings.ToLower(color)] { - return fmt.Errorf("invalid color %q: must be a hex color (#RRGGBB or #RRGGBBAA) or a CSS color name", color) - } - return nil -} - -func setBgRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("setbg", rtnErr == nil) - }() - - borderColorChanged := cmd.Flags().Changed("border-color") - activeBorderColorChanged := cmd.Flags().Changed("active-border-color") - - if borderColorChanged { - if err := validateColor(setBgBorderColor); err != nil { - return fmt.Errorf("--border-color: %v", err) - } - } - if activeBorderColorChanged { - if err := validateColor(setBgActiveBorderColor); err != nil { - return fmt.Errorf("--active-border-color: %v", err) - } - } - - // Create base metadata - meta := map[string]interface{}{} - - // Handle opacity-only change or clear - if len(args) == 0 { - if !cmd.Flags().Changed("opacity") && !setBgClear && !borderColorChanged && !activeBorderColorChanged { - OutputHelpMessage(cmd) - return fmt.Errorf("setbg requires an image path or color value") - } - if setBgOpacity < 0 || setBgOpacity > 1 { - return fmt.Errorf("opacity must be between 0.0 and 1.0") - } - if setBgClear { - meta["bg:*"] = true - } else if cmd.Flags().Changed("opacity") { - meta["bg:opacity"] = setBgOpacity - } - } else if len(args) > 1 { - OutputHelpMessage(cmd) - return fmt.Errorf("too many arguments") - } else { - // Handle background setting - meta["bg:*"] = true - meta["tab:background"] = nil - if setBgOpacity < 0 || setBgOpacity > 1 { - return fmt.Errorf("opacity must be between 0.0 and 1.0") - } - meta["bg:opacity"] = setBgOpacity - - input := args[0] - var bgStyle string - - // Check for hex color - if strings.HasPrefix(input, "#") { - if err := validateHexColor(input); err != nil { - return err - } - bgStyle = input - } else if CssColorNames[strings.ToLower(input)] { - // Handle CSS color name - bgStyle = strings.ToLower(input) - } else { - // Handle image input - absPath, err := filepath.Abs(wavebase.ExpandHomeDirSafe(input)) - if err != nil { - return fmt.Errorf("resolving image path: %v", err) - } - - fileInfo, err := os.Stat(absPath) - if err != nil { - return fmt.Errorf("cannot access image file: %v", err) - } - if fileInfo.IsDir() { - return fmt.Errorf("path is a directory, not an image file") - } - - mimeType := fileutil.DetectMimeType(absPath, fileInfo, true) - switch mimeType { - case "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml": - // Valid image type - default: - return fmt.Errorf("file does not appear to be a valid image (detected type: %s)", mimeType) - } - - // Create URL-safe path - escapedPath := filepath.ToSlash(absPath) - escapedPath = strings.ReplaceAll(escapedPath, "'", "\\'") - bgStyle = fmt.Sprintf("url('%s')", escapedPath) - - switch { - case setBgTile: - bgStyle += " repeat" - case setBgCenter: - bgStyle += fmt.Sprintf(" no-repeat center/%s", setBgSize) - default: - bgStyle += " center/cover no-repeat" - } - } - - meta["bg"] = bgStyle - } - - if borderColorChanged { - meta["bg:bordercolor"] = setBgBorderColor - } - if activeBorderColorChanged { - meta["bg:activebordercolor"] = setBgActiveBorderColor - } - - if setBgPrint { - jsonBytes, err := json.MarshalIndent(meta, "", " ") - if err != nil { - return fmt.Errorf("error formatting metadata: %v", err) - } - WriteStdout("%s\n", string(jsonBytes)) - return nil - } - - // Resolve tab reference - id := blockArg - if id == "" { - id = "tab" - } - oRef, err := resolveSimpleId(id) - if err != nil { - return err - } - - // Send RPC request - setMetaWshCmd := wshrpc.CommandSetMetaData{ - ORef: *oRef, - Meta: meta, - } - err = wshclient.SetMetaCommand(RpcClient, setMetaWshCmd, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("setting background: %v", err) - } - - WriteStdout("background set\n") - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-setconfig.go b/cmd/wsh/cmd/wshcmd-setconfig.go deleted file mode 100644 index 3fcd1f94b2..0000000000 --- a/cmd/wsh/cmd/wshcmd-setconfig.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var setConfigCmd = &cobra.Command{ - Use: "setconfig", - Short: "set config", - Args: cobra.MinimumNArgs(1), - RunE: setConfigRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - rootCmd.AddCommand(setConfigCmd) -} - -func setConfigRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("setconfig", rtnErr == nil) - }() - - metaSetsStrs := args[:] - meta, err := parseMetaSets(metaSetsStrs) - if err != nil { - return err - } - commandData := wshrpc.MetaSettingsType{MetaMapType: meta} - err = wshclient.SetConfigCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("setting config: %w", err) - } - WriteStdout("config set\n") - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-setmeta.go b/cmd/wsh/cmd/wshcmd-setmeta.go deleted file mode 100644 index 79faa7e78c..0000000000 --- a/cmd/wsh/cmd/wshcmd-setmeta.go +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "encoding/json" - "fmt" - "io" - "os" - "strconv" - "strings" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var setMetaCmd = &cobra.Command{ - Use: "setmeta [-b {blockid|blocknum|this}] [--json file.json] key=value ...", - Short: "set metadata for an entity", - Args: cobra.MinimumNArgs(0), - RunE: setMetaRun, - PreRunE: preRunSetupRpcClient, -} - -var setMetaJsonFilePath string - -func init() { - rootCmd.AddCommand(setMetaCmd) - setMetaCmd.Flags().StringVar(&setMetaJsonFilePath, "json", "", "JSON file containing metadata to apply (use '-' for stdin)") -} - -func loadJSONFile(filepath string) (map[string]interface{}, error) { - var data []byte - var err error - - if filepath == "-" { - data, err = io.ReadAll(os.Stdin) - if err != nil { - return nil, fmt.Errorf("reading from stdin: %v", err) - } - } else { - data, err = os.ReadFile(filepath) - if err != nil { - return nil, fmt.Errorf("reading JSON file: %v", err) - } - } - - var result map[string]interface{} - if err := json.Unmarshal(data, &result); err != nil { - return nil, fmt.Errorf("parsing JSON file: %v", err) - } - - if result == nil { - return nil, fmt.Errorf("JSON file must contain an object, not null") - } - - return result, nil -} - -func parseMetaValue(setVal string) (any, error) { - if setVal == "" || setVal == "null" { - return nil, nil - } - if setVal == "true" { - return true, nil - } - if setVal == "false" { - return false, nil - } - if setVal[0] == '[' || setVal[0] == '{' || setVal[0] == '"' { - var val any - err := json.Unmarshal([]byte(setVal), &val) - if err != nil { - return nil, fmt.Errorf("invalid json value: %v", err) - } - return val, nil - } - - // Try parsing as integer - ival, err := strconv.ParseInt(setVal, 0, 64) - if err == nil { - return ival, nil - } - - // Try parsing as float - fval, err := strconv.ParseFloat(setVal, 64) - if err == nil { - return fval, nil - } - - // Fallback to string - return setVal, nil -} - -func setNestedValue(meta map[string]any, path []string, value any) { - // For single key, just set directly - if len(path) == 1 { - meta[path[0]] = value - return - } - - // For nested path, traverse or create maps as needed - current := meta - for i := 0; i < len(path)-1; i++ { - key := path[i] - // If next level doesn't exist or isn't a map, create new map - next, exists := current[key] - if !exists { - nextMap := make(map[string]any) - current[key] = nextMap - current = nextMap - } else if nextMap, ok := next.(map[string]any); ok { - current = nextMap - } else { - // If existing value isn't a map, replace with new map - nextMap = make(map[string]any) - current[key] = nextMap - current = nextMap - } - } - - // Set the final value - current[path[len(path)-1]] = value -} - -func parseMetaSets(metaSets []string) (map[string]any, error) { - meta := make(map[string]any) - for _, metaSet := range metaSets { - fields := strings.SplitN(metaSet, "=", 2) - if len(fields) != 2 { - return nil, fmt.Errorf("invalid meta set: %q", metaSet) - } - - val, err := parseMetaValue(fields[1]) - if err != nil { - return nil, err - } - - // Split the key path and set nested value - path := strings.Split(fields[0], "/") - setNestedValue(meta, path, val) - } - return meta, nil -} - -func simpleMergeMeta(meta map[string]interface{}, metaUpdate map[string]interface{}) map[string]interface{} { - for k, v := range metaUpdate { - if v == nil { - delete(meta, k) - } else { - meta[k] = v - } - } - return meta -} - -func setMetaRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("setmeta", rtnErr == nil) - }() - var jsonMeta map[string]interface{} - if setMetaJsonFilePath != "" { - var err error - jsonMeta, err = loadJSONFile(setMetaJsonFilePath) - if err != nil { - return err - } - } - - cmdMeta, err := parseMetaSets(args) - if err != nil { - return err - } - - // Merge JSON metadata with command-line metadata, with command-line taking precedence - var fullMeta map[string]any - if len(jsonMeta) > 0 { - fullMeta = simpleMergeMeta(jsonMeta, cmdMeta) - } else { - fullMeta = cmdMeta - } - if len(fullMeta) == 0 { - return fmt.Errorf("no metadata keys specified") - } - fullORef, err := resolveBlockArg() - if err != nil { - return err - } - - setMetaWshCmd := &wshrpc.CommandSetMetaData{ - ORef: *fullORef, - Meta: fullMeta, - } - err = wshclient.SetMetaCommand(RpcClient, *setMetaWshCmd, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("setting metadata: %v", err) - } - WriteStdout("metadata set\n") - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-setvar.go b/cmd/wsh/cmd/wshcmd-setvar.go deleted file mode 100644 index bbfb3e15a1..0000000000 --- a/cmd/wsh/cmd/wshcmd-setvar.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -const DefaultVarFileName = "var" - -var setVarCmd = &cobra.Command{ - Use: "setvar [flags] KEY=VALUE...", - Short: "set variable(s) for a block", - Long: `Set one or more variables for a block. -Use --remove/-r to remove variables instead of setting them. -When setting, each argument must be in KEY=VALUE format. -When removing, each argument is treated as a key to remove.`, - Example: " wsh setvar FOO=bar BAZ=123\n wsh setvar -r FOO BAZ", - Args: cobra.MinimumNArgs(1), - RunE: setVarRun, - PreRunE: preRunSetupRpcClient, -} - -var ( - setVarFileName string - setVarRemoveVar bool - setVarLocal bool -) - -func init() { - rootCmd.AddCommand(setVarCmd) - setVarCmd.Flags().StringVar(&setVarFileName, "varfile", DefaultVarFileName, "var file name") - setVarCmd.Flags().BoolVarP(&setVarLocal, "local", "l", false, "set variables local to block") - setVarCmd.Flags().BoolVarP(&setVarRemoveVar, "remove", "r", false, "remove the variable(s) instead of setting") -} - -func parseKeyValue(arg string) (key, value string, err error) { - if setVarRemoveVar { - return arg, "", nil - } - - parts := strings.SplitN(arg, "=", 2) - if len(parts) != 2 { - return "", "", fmt.Errorf("invalid KEY=VALUE format %q (= sign required)", arg) - } - key = parts[0] - if key == "" { - return "", "", fmt.Errorf("empty key not allowed") - } - return key, parts[1], nil -} - -func setVarRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("setvar", rtnErr == nil) - }() - - // Resolve block to get zoneId - if blockArg == "" { - if getVarLocal { - blockArg = "this" - } else { - blockArg = "client" - } - } - fullORef, err := resolveBlockArg() - if err != nil { - return err - } - - // Process all variables - for _, arg := range args { - key, value, err := parseKeyValue(arg) - if err != nil { - return err - } - - commandData := wshrpc.CommandVarData{ - Key: key, - ZoneId: fullORef.OID, - FileName: setVarFileName, - Remove: setVarRemoveVar, - } - - if !setVarRemoveVar { - commandData.Val = value - } - - err = wshclient.SetVarCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("setting variable %s: %w", key, err) - } - } - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-shell-unix.go b/cmd/wsh/cmd/wshcmd-shell-unix.go deleted file mode 100644 index f6dedb7bcc..0000000000 --- a/cmd/wsh/cmd/wshcmd-shell-unix.go +++ /dev/null @@ -1,40 +0,0 @@ -//go:build !windows - -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "os" - "runtime" - "strings" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/util/shellutil" -) - -func init() { - rootCmd.AddCommand(shellCmd) -} - -var shellCmd = &cobra.Command{ - Use: "shell", - Hidden: true, - Short: "Print the login shell of this user", - Run: func(cmd *cobra.Command, args []string) { - WriteStdout("%s", shellCmdInner()) - }, -} - -func shellCmdInner() string { - if runtime.GOOS == "darwin" { - return shellutil.GetMacUserShell() + "\n" - } - - shell := os.Getenv("SHELL") - if shell == "" { - return "/bin/bash\n" - } - return strings.TrimSpace(shell) + "\n" -} diff --git a/cmd/wsh/cmd/wshcmd-shell-win.go b/cmd/wsh/cmd/wshcmd-shell-win.go deleted file mode 100644 index a218eebb8f..0000000000 --- a/cmd/wsh/cmd/wshcmd-shell-win.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build windows - -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "github.com/spf13/cobra" -) - -func init() { - rootCmd.AddCommand(shellCmd) -} - -var shellCmd = &cobra.Command{ - Use: "shell", - Hidden: true, - Short: "Print the login shell of this user", - Run: func(cmd *cobra.Command, args []string) { - shellCmdInner() - }, -} - -func shellCmdInner() { - WriteStderr("not implemented/n") -} diff --git a/cmd/wsh/cmd/wshcmd-ssh.go b/cmd/wsh/cmd/wshcmd-ssh.go deleted file mode 100644 index 4eb1d42a4e..0000000000 --- a/cmd/wsh/cmd/wshcmd-ssh.go +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/remote" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wconfig" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var ( - identityFiles []string - sshLogin string - sshPort string - newBlock bool -) - -var sshCmd = &cobra.Command{ - Use: "ssh", - Short: "connect this terminal to a remote host", - Args: cobra.ExactArgs(1), - RunE: sshRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - sshCmd.Flags().StringArrayVarP(&identityFiles, "identityfile", "i", []string{}, "add an identity file for publickey authentication") - sshCmd.Flags().StringVarP(&sshLogin, "login", "l", "", "set the remote login name") - sshCmd.Flags().StringVarP(&sshPort, "port", "p", "", "set the remote port") - sshCmd.Flags().BoolVarP(&newBlock, "new", "n", false, "create a new terminal block with this connection") - rootCmd.AddCommand(sshCmd) -} - -func sshRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("ssh", rtnErr == nil) - }() - - sshArg := args[0] - var err error - sshArg, err = applySSHOverrides(sshArg, sshLogin, sshPort) - if err != nil { - return err - } - blockId := RpcContext.BlockId - if blockId == "" && !newBlock { - return fmt.Errorf("cannot determine blockid (not in JWT)") - } - - // Create connection request - connOpts := wshrpc.ConnRequest{ - Host: sshArg, - LogBlockId: blockId, - Keywords: wconfig.ConnKeywords{ - SshIdentityFile: identityFiles, - }, - } - wshclient.ConnConnectCommand(RpcClient, connOpts, &wshrpc.RpcOpts{Timeout: 60000}) - - if newBlock { - tabId := getTabIdFromEnv() - if tabId == "" { - return fmt.Errorf("no WAVETERM_TABID env var set") - } - - // Create a new block with the SSH connection - createMeta := map[string]any{ - waveobj.MetaKey_View: "term", - waveobj.MetaKey_Controller: "shell", - waveobj.MetaKey_Connection: sshArg, - } - if RpcContext.Conn != "" { - createMeta[waveobj.MetaKey_Connection] = RpcContext.Conn - } - createBlockData := wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &waveobj.BlockDef{ - Meta: createMeta, - }, - Focused: true, - } - oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) - if err != nil { - return fmt.Errorf("creating new terminal block: %w", err) - } - WriteStdout("new terminal block created with connection to %q: %s\n", sshArg, oref) - return nil - } - - // Update existing block with the new connection - data := wshrpc.CommandSetMetaData{ - ORef: waveobj.MakeORef(waveobj.OType_Block, blockId), - Meta: map[string]any{ - waveobj.MetaKey_Connection: sshArg, - waveobj.MetaKey_CmdCwd: nil, - }, - } - err = wshclient.SetMetaCommand(RpcClient, data, nil) - if err != nil { - return fmt.Errorf("setting connection in block: %w", err) - } - WriteStderr("switched connection to %q\n", sshArg) - return nil -} - -func applySSHOverrides(sshArg string, login string, port string) (string, error) { - if login == "" && port == "" { - return sshArg, nil - } - opts, err := remote.ParseOpts(sshArg) - if err != nil { - return "", err - } - if login != "" { - opts.SSHUser = login - } - if port != "" { - opts.SSHPort = port - } - return opts.String(), nil -} diff --git a/cmd/wsh/cmd/wshcmd-ssh_test.go b/cmd/wsh/cmd/wshcmd-ssh_test.go deleted file mode 100644 index 36da037464..0000000000 --- a/cmd/wsh/cmd/wshcmd-ssh_test.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import "testing" - -func TestApplySSHOverrides(t *testing.T) { - tests := []struct { - name string - sshArg string - login string - port string - want string - wantErr bool - }{ - { - name: "no overrides preserves target", - sshArg: "root@bar.com:2022", - want: "root@bar.com:2022", - }, - { - name: "login override replaces parsed user", - sshArg: "root@bar.com", - login: "foo", - want: "foo@bar.com", - }, - { - name: "port override replaces parsed port", - sshArg: "root@bar.com:2022", - port: "2222", - want: "root@bar.com:2222", - }, - { - name: "both overrides replace parsed user and port", - sshArg: "root@bar.com:2022", - login: "foo", - port: "2200", - want: "foo@bar.com:2200", - }, - { - name: "login override adds user to bare host", - sshArg: "bar.com", - login: "foo", - want: "foo@bar.com", - }, - { - name: "port override adds port to bare host", - sshArg: "bar.com", - port: "2200", - want: "bar.com:2200", - }, - { - name: "invalid target returns parse error when override requested", - sshArg: "bad host", - login: "foo", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := applySSHOverrides(tt.sshArg, tt.login, tt.port) - if (err != nil) != tt.wantErr { - t.Fatalf("applySSHOverrides() error = %v, wantErr %v", err, tt.wantErr) - } - if tt.wantErr { - return - } - if got != tt.want { - t.Fatalf("applySSHOverrides() = %q, want %q", got, tt.want) - } - }) - } -} diff --git a/cmd/wsh/cmd/wshcmd-tabindicator.go b/cmd/wsh/cmd/wshcmd-tabindicator.go deleted file mode 100644 index c3fa499cf9..0000000000 --- a/cmd/wsh/cmd/wshcmd-tabindicator.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "os" - - "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/baseds" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wps" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var tabIndicatorCmd = &cobra.Command{ - Use: "tabindicator [icon]", - Short: "set or clear a tab indicator (deprecated: use 'wsh badge')", - Args: cobra.MaximumNArgs(1), - RunE: tabIndicatorRun, - PreRunE: preRunSetupRpcClient, -} - -var ( - tabIndicatorTabId string - tabIndicatorColor string - tabIndicatorPriority float64 - tabIndicatorClear bool - tabIndicatorBeep bool -) - -func init() { - rootCmd.AddCommand(tabIndicatorCmd) - tabIndicatorCmd.Flags().StringVar(&tabIndicatorTabId, "tabid", "", "tab id (defaults to WAVETERM_TABID)") - tabIndicatorCmd.Flags().StringVar(&tabIndicatorColor, "color", "", "indicator color") - tabIndicatorCmd.Flags().Float64Var(&tabIndicatorPriority, "priority", 10, "indicator priority") - tabIndicatorCmd.Flags().BoolVar(&tabIndicatorClear, "clear", false, "clear the indicator") - tabIndicatorCmd.Flags().BoolVar(&tabIndicatorBeep, "beep", false, "play system bell sound") -} - -func tabIndicatorRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("tabindicator", rtnErr == nil) - }() - - fmt.Fprintf(os.Stderr, "tabindicator is deprecated, use 'wsh badge' instead\n") - - tabId := tabIndicatorTabId - if tabId == "" { - tabId = os.Getenv("WAVETERM_TABID") - } - if tabId == "" { - return fmt.Errorf("no tab id specified (use --tabid or set WAVETERM_TABID)") - } - - oref := waveobj.MakeORef(waveobj.OType_Tab, tabId) - - var eventData baseds.BadgeEvent - eventData.ORef = oref.String() - - if tabIndicatorClear { - eventData.Clear = true - } else { - icon := "bell" - if len(args) > 0 { - icon = args[0] - } - badgeId, err := uuid.NewV7() - if err != nil { - return fmt.Errorf("generating badge id: %v", err) - } - eventData.Badge = &baseds.Badge{ - BadgeId: badgeId.String(), - Icon: icon, - Color: tabIndicatorColor, - Priority: tabIndicatorPriority, - } - } - - event := wps.WaveEvent{ - Event: wps.Event_Badge, - Scopes: []string{oref.String()}, - Data: eventData, - } - - err := wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true}) - if err != nil { - return fmt.Errorf("publishing badge event: %v", err) - } - - if tabIndicatorBeep { - err = wshclient.ElectronSystemBellCommand(RpcClient, &wshrpc.RpcOpts{Route: "electron"}) - if err != nil { - return fmt.Errorf("playing system bell: %v", err) - } - } - - if tabIndicatorClear { - fmt.Printf("tab indicator cleared\n") - } else { - fmt.Printf("tab indicator set\n") - } - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-term.go b/cmd/wsh/cmd/wshcmd-term.go deleted file mode 100644 index f2119ad5b7..0000000000 --- a/cmd/wsh/cmd/wshcmd-term.go +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wavebase" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var termMagnified bool - -var termCmd = &cobra.Command{ - Use: "term", - Short: "open a terminal in directory", - Args: cobra.RangeArgs(0, 1), - RunE: termRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - termCmd.Flags().BoolVarP(&termMagnified, "magnified", "m", false, "open view in magnified mode") - rootCmd.AddCommand(termCmd) -} - -func termRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("term", rtnErr == nil) - }() - - var cwd string - if len(args) > 0 { - cwd = args[0] - cwdExpanded, err := wavebase.ExpandHomeDir(cwd) - if err != nil { - return err - } - cwd = cwdExpanded - } else { - var err error - cwd, err = os.Getwd() - if err != nil { - return fmt.Errorf("getting current directory: %w", err) - } - } - var err error - cwd, err = filepath.Abs(cwd) - if err != nil { - return fmt.Errorf("getting absolute path: %w", err) - } - - tabId := getTabIdFromEnv() - if tabId == "" { - return fmt.Errorf("no WAVETERM_TABID env var set") - } - - createMeta := map[string]any{ - waveobj.MetaKey_View: "term", - waveobj.MetaKey_CmdCwd: cwd, - waveobj.MetaKey_Controller: "shell", - } - if RpcContext.Conn != "" { - createMeta[waveobj.MetaKey_Connection] = RpcContext.Conn - } - createBlockData := wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &waveobj.BlockDef{ - Meta: createMeta, - }, - Magnified: termMagnified, - Focused: true, - } - oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) - if err != nil { - return fmt.Errorf("creating new terminal block: %w", err) - } - WriteStdout("terminal block created: %s\n", oref) - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-termscrollback.go b/cmd/wsh/cmd/wshcmd-termscrollback.go deleted file mode 100644 index 6368e1559d..0000000000 --- a/cmd/wsh/cmd/wshcmd-termscrollback.go +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "os" - "strings" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -var termScrollbackCmd = &cobra.Command{ - Use: "termscrollback", - Short: "Get terminal scrollback from a terminal block", - Long: `Get the terminal scrollback from a terminal block. - -By default, retrieves all lines. You can specify line ranges or get the -output of the last command using the --lastcommand flag.`, - RunE: termScrollbackRun, - PreRunE: preRunSetupRpcClient, - DisableFlagsInUseLine: true, -} - -var ( - termScrollbackLineStart int - termScrollbackLineEnd int - termScrollbackLastCmd bool - termScrollbackOutputFile string -) - -func init() { - rootCmd.AddCommand(termScrollbackCmd) - - termScrollbackCmd.Flags().IntVar(&termScrollbackLineStart, "start", 0, "starting line number (0 = beginning)") - termScrollbackCmd.Flags().IntVar(&termScrollbackLineEnd, "end", 0, "ending line number (0 = all lines)") - termScrollbackCmd.Flags().BoolVar(&termScrollbackLastCmd, "lastcommand", false, "get output of last command (requires shell integration)") - termScrollbackCmd.Flags().StringVarP(&termScrollbackOutputFile, "output", "o", "", "write output to file instead of stdout") -} - -func termScrollbackRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("termscrollback", rtnErr == nil) - }() - - // Resolve the block argument - fullORef, err := resolveBlockArg() - if err != nil { - return err - } - - // Get block metadata to verify it's a terminal block - metaData, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ - ORef: *fullORef, - }, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("error getting block metadata: %w", err) - } - - // Check if the block is a terminal block - viewType, ok := metaData[waveobj.MetaKey_View].(string) - if !ok || viewType != "term" { - return fmt.Errorf("block %s is not a terminal block (view type: %s)", fullORef.OID, viewType) - } - - // Make the RPC call to get scrollback - scrollbackData := wshrpc.CommandTermGetScrollbackLinesData{ - LineStart: termScrollbackLineStart, - LineEnd: termScrollbackLineEnd, - LastCommand: termScrollbackLastCmd, - } - - result, err := wshclient.TermGetScrollbackLinesCommand(RpcClient, scrollbackData, &wshrpc.RpcOpts{ - Route: wshutil.MakeFeBlockRouteId(fullORef.OID), - Timeout: 5000, - }) - if err != nil { - return fmt.Errorf("error getting terminal scrollback: %w", err) - } - - // Format the output - output := strings.Join(result.Lines, "\n") - if len(result.Lines) > 0 { - output += "\n" // Add final newline - } - - // Write to file or stdout - if termScrollbackOutputFile != "" { - err = os.WriteFile(termScrollbackOutputFile, []byte(output), 0644) - if err != nil { - return fmt.Errorf("error writing to file %s: %w", termScrollbackOutputFile, err) - } - fmt.Printf("terminal scrollback written to %s (%d lines)\n", termScrollbackOutputFile, len(result.Lines)) - } else { - fmt.Print(output) - } - - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-test.go b/cmd/wsh/cmd/wshcmd-test.go deleted file mode 100644 index 24706a1fe2..0000000000 --- a/cmd/wsh/cmd/wshcmd-test.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var testCmd = &cobra.Command{ - Use: "test", - Hidden: true, - Short: "test command", - PreRunE: preRunSetupRpcClient, - RunE: runTestCmd, -} - -func init() { - rootCmd.AddCommand(testCmd) -} - -func runTestCmd(cmd *cobra.Command, args []string) error { - rtn, err := wshclient.TestMultiArgCommand(RpcClient, "testarg", 42, true, nil) - if err != nil { - return err - } - WriteStdout("%s\n", rtn) - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-token.go b/cmd/wsh/cmd/wshcmd-token.go deleted file mode 100644 index 2660c12507..0000000000 --- a/cmd/wsh/cmd/wshcmd-token.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/util/shellutil" -) - -var tokenCmd = &cobra.Command{ - Use: "token [token] [shell-type]", - Short: "exchange token for shell initialization script", - RunE: tokenCmdRun, - Hidden: true, -} - -func init() { - rootCmd.AddCommand(tokenCmd) -} - -func tokenCmdRun(cmd *cobra.Command, args []string) (rtnErr error) { - if len(args) != 2 { - OutputHelpMessage(cmd) - return fmt.Errorf("wsh token requires exactly 2 arguments, got %d", len(args)) - } - tokenStr, shellType := args[0], args[1] - if tokenStr == "" || shellType == "" { - OutputHelpMessage(cmd) - return fmt.Errorf("wsh token requires non-empty arguments") - } - rtnData, err := setupRpcClientWithToken(tokenStr) - if err != nil { - return fmt.Errorf("error setting up rpc client: %w", err) - } - envScriptText, err := shellutil.EncodeEnvVarsForShell(shellType, rtnData.Env) - if err != nil { - return fmt.Errorf("error encoding env vars: %w", err) - } - WriteStdout("%s\n", envScriptText) - WriteStdout("%s\n", rtnData.InitScriptText) - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-version.go b/cmd/wsh/cmd/wshcmd-version.go deleted file mode 100644 index 80caab9f69..0000000000 --- a/cmd/wsh/cmd/wshcmd-version.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wavebase" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -var versionVerbose bool -var versionJSON bool - -// versionCmd represents the version command -var versionCmd = &cobra.Command{ - Use: "version [-v] [--json]", - Short: "Print the version number of wsh", - RunE: runVersionCmd, -} - -func init() { - versionCmd.Flags().BoolVarP(&versionVerbose, "verbose", "v", false, "Display full version information") - versionCmd.Flags().BoolVar(&versionJSON, "json", false, "Output version information in JSON format") - rootCmd.AddCommand(versionCmd) -} - -func runVersionCmd(cmd *cobra.Command, args []string) error { - if !versionVerbose && !versionJSON { - WriteStdout("wsh v%s\n", wavebase.WaveVersion) - return nil - } - - err := preRunSetupRpcClient(cmd, args) - if err != nil { - return err - } - - resp, err := wshclient.WaveInfoCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return err - } - - updateChannel, err := wshclient.GetUpdateChannelCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000, Route: wshutil.ElectronRoute}) - if err != nil { - return err - } - - if versionJSON { - info := map[string]interface{}{ - "version": resp.Version, - "clientid": resp.ClientId, - "buildtime": resp.BuildTime, - "configdir": resp.ConfigDir, - "datadir": resp.DataDir, - "updatechannel": updateChannel, - } - outBArr, err := json.MarshalIndent(info, "", " ") - if err != nil { - return fmt.Errorf("formatting version info: %v", err) - } - WriteStdout("%s\n", string(outBArr)) - return nil - } - - // Default verbose text output - fmt.Printf("v%s (%s)\n", resp.Version, resp.BuildTime) - fmt.Printf("clientid: %s\n", resp.ClientId) - fmt.Printf("configdir: %s\n", resp.ConfigDir) - fmt.Printf("datadir: %s\n", resp.DataDir) - fmt.Printf("update-channel: %s\n", updateChannel) - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-view.go b/cmd/wsh/cmd/wshcmd-view.go deleted file mode 100644 index 1ba84b516f..0000000000 --- a/cmd/wsh/cmd/wshcmd-view.go +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var viewMagnified bool - -var viewCmd = &cobra.Command{ - Use: "view {file|directory|URL}", - Aliases: []string{"preview", "open"}, - Short: "preview/edit a file or directory", - RunE: viewRun, - PreRunE: preRunSetupRpcClient, -} - -var editCmd = &cobra.Command{ - Use: "edit {file}", - Short: "edit a file", - RunE: viewRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - viewCmd.Flags().BoolVarP(&viewMagnified, "magnified", "m", false, "open view in magnified mode") - rootCmd.AddCommand(viewCmd) - editCmd.Flags().BoolVarP(&viewMagnified, "magnified", "m", false, "open view in magnified mode") - rootCmd.AddCommand(editCmd) -} - -func viewRun(cmd *cobra.Command, args []string) (rtnErr error) { - cmdName := cmd.Name() - defer func() { - sendActivity(cmdName, rtnErr == nil) - }() - if len(args) == 0 { - OutputHelpMessage(cmd) - return fmt.Errorf("no arguments. wsh %s requires a file or URL as an argument argument", cmdName) - } - if len(args) > 1 { - OutputHelpMessage(cmd) - return fmt.Errorf("too many arguments. wsh %s requires exactly one argument", cmdName) - } - tabId := getTabIdFromEnv() - if tabId == "" { - return fmt.Errorf("no WAVETERM_TABID env var set") - } - fileArg := args[0] - conn := RpcContext.Conn - var wshCmd *wshrpc.CommandCreateBlockData - if strings.HasPrefix(fileArg, "http://") || strings.HasPrefix(fileArg, "https://") { - wshCmd = &wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &waveobj.BlockDef{ - Meta: map[string]any{ - waveobj.MetaKey_View: "web", - waveobj.MetaKey_Url: fileArg, - }, - }, - Magnified: viewMagnified, - Focused: true, - } - } else { - absFile, err := filepath.Abs(fileArg) - if err != nil { - return fmt.Errorf("getting absolute path: %w", err) - } - absParent, err := filepath.Abs(filepath.Dir(fileArg)) - if err != nil { - return fmt.Errorf("getting absolute path of parent dir: %w", err) - } - _, err = os.Stat(absParent) - if err == fs.ErrNotExist { - return fmt.Errorf("parent directory does not exist: %q", absParent) - } - if err != nil { - return fmt.Errorf("getting file info: %w", err) - } - wshCmd = &wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &waveobj.BlockDef{ - Meta: map[string]interface{}{ - waveobj.MetaKey_View: "preview", - waveobj.MetaKey_File: absFile, - }, - }, - Magnified: viewMagnified, - Focused: true, - } - if cmdName == "edit" { - wshCmd.BlockDef.Meta[waveobj.MetaKey_Edit] = true - } - if conn != "" { - wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = conn - } - } - _, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("running view command: %w", err) - } - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-wavepath.go b/cmd/wsh/cmd/wshcmd-wavepath.go deleted file mode 100644 index 9a5ad6af39..0000000000 --- a/cmd/wsh/cmd/wshcmd-wavepath.go +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "bytes" - "fmt" - "io" - "os" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var wavepathCmd = &cobra.Command{ - Use: "wavepath {config|data|log}", - Short: "Get paths to various waveterm files and directories", - RunE: wavepathRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - wavepathCmd.Flags().BoolP("open", "o", false, "Open the path in a new block") - wavepathCmd.Flags().BoolP("open-external", "O", false, "Open the path in the default external application") - wavepathCmd.Flags().BoolP("tail", "t", false, "Tail the last 100 lines of the log") - rootCmd.AddCommand(wavepathCmd) -} - -func wavepathRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("wavepath", rtnErr == nil) - }() - - if len(args) == 0 { - OutputHelpMessage(cmd) - return fmt.Errorf("no arguments. wsh wavepath requires a type argument (config, data, or log)") - } - if len(args) > 1 { - OutputHelpMessage(cmd) - return fmt.Errorf("too many arguments. wsh wavepath requires exactly one argument") - } - - pathType := args[0] - if pathType != "config" && pathType != "data" && pathType != "log" { - OutputHelpMessage(cmd) - return fmt.Errorf("invalid path type %q. must be one of: config, data, log", pathType) - } - - tail, _ := cmd.Flags().GetBool("tail") - if tail && pathType != "log" { - return fmt.Errorf("--tail can only be used with the log path type") - } - - open, _ := cmd.Flags().GetBool("open") - openExternal, _ := cmd.Flags().GetBool("open-external") - - tabId := getTabIdFromEnv() - if tabId == "" { - return fmt.Errorf("no WAVETERM_TABID env var set") - } - - path, err := wshclient.PathCommand(RpcClient, wshrpc.PathCommandData{ - PathType: pathType, - Open: open, - OpenExternal: openExternal, - TabId: tabId, - }, nil) - if err != nil { - return fmt.Errorf("getting path: %w", err) - } - - if tail && pathType == "log" { - err = tailLogFile(path) - if err != nil { - return fmt.Errorf("tailing log file: %w", err) - } - return nil - } - - WriteStdout("%s\n", path) - return nil -} - -func tailLogFile(path string) error { - file, err := os.Open(path) - if err != nil { - return fmt.Errorf("opening log file: %w", err) - } - defer file.Close() - - // Get file size - stat, err := file.Stat() - if err != nil { - return fmt.Errorf("getting file stats: %w", err) - } - - // Read last 16KB or whole file if smaller - readSize := int64(16 * 1024) - var offset int64 - if stat.Size() > readSize { - offset = stat.Size() - readSize - } - - _, err = file.Seek(offset, 0) - if err != nil { - return fmt.Errorf("seeking file: %w", err) - } - - buf := make([]byte, readSize) - n, err := file.Read(buf) - if err != nil && err != io.EOF { - return fmt.Errorf("reading file: %w", err) - } - buf = buf[:n] - - // Skip partial line at start if we're not at beginning of file - if offset > 0 { - idx := bytes.IndexByte(buf, '\n') - if idx >= 0 { - buf = buf[idx+1:] - } - } - - // Split into lines - lines := bytes.Split(buf, []byte{'\n'}) - - // Take last 100 lines if we have more - startIdx := 0 - if len(lines) > 100 { - startIdx = len(lines) - 100 - } - - // Print lines - for _, line := range lines[startIdx:] { - WriteStdout("%s\n", string(line)) - } - - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-web.go b/cmd/wsh/cmd/wshcmd-web.go deleted file mode 100644 index bfda76b82c..0000000000 --- a/cmd/wsh/cmd/wshcmd-web.go +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -var webCmd = &cobra.Command{ - Use: "web [open|get|set]", - Short: "web commands", - PersistentPreRunE: preRunSetupRpcClient, -} - -var webOpenCmd = &cobra.Command{ - Use: "open url", - Short: "open a url a web widget", - Args: cobra.ExactArgs(1), - RunE: webOpenRun, -} - -var webGetCmd = &cobra.Command{ - Use: "get [--inner] [--all] [--json] css-selector", - Short: "get the html for a css selector", - Args: cobra.ExactArgs(1), - Hidden: true, - RunE: webGetRun, -} - -var webGetInner bool -var webGetAll bool -var webGetJson bool -var webOpenMagnified bool -var webOpenReplaceBlock string - -func init() { - webOpenCmd.Flags().BoolVarP(&webOpenMagnified, "magnified", "m", false, "open view in magnified mode") - webOpenCmd.Flags().StringVarP(&webOpenReplaceBlock, "replace", "r", "", "replace block") - webCmd.AddCommand(webOpenCmd) - webGetCmd.Flags().BoolVarP(&webGetInner, "inner", "", false, "get inner html (instead of outer)") - webGetCmd.Flags().BoolVarP(&webGetAll, "all", "", false, "get all matches (querySelectorAll)") - webGetCmd.Flags().BoolVarP(&webGetJson, "json", "", false, "output as json") - webCmd.AddCommand(webGetCmd) - rootCmd.AddCommand(webCmd) -} - -func webGetRun(cmd *cobra.Command, args []string) error { - fullORef, err := resolveBlockArg() - if err != nil { - return fmt.Errorf("resolving blockid: %w", err) - } - blockInfo, err := wshclient.BlockInfoCommand(RpcClient, fullORef.OID, nil) - if err != nil { - return fmt.Errorf("getting block info: %w", err) - } - if blockInfo.Block.Meta.GetString(waveobj.MetaKey_View, "") != "web" { - return fmt.Errorf("block %s is not a web block", fullORef.OID) - } - data := wshrpc.CommandWebSelectorData{ - WorkspaceId: blockInfo.WorkspaceId, - BlockId: fullORef.OID, - TabId: blockInfo.TabId, - Selector: args[0], - Opts: &wshrpc.WebSelectorOpts{ - Inner: webGetInner, - All: webGetAll, - }, - } - output, err := wshclient.WebSelectorCommand(RpcClient, data, &wshrpc.RpcOpts{ - Route: wshutil.ElectronRoute, - Timeout: 5000, - }) - if err != nil { - return err - } - if webGetJson { - barr, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("json encoding: %w", err) - } - WriteStdout("%s\n", string(barr)) - } else { - for _, item := range output { - WriteStdout("%s\n", item) - } - } - return nil -} - -func webOpenRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("web", rtnErr == nil) - }() - - var replaceBlockORef *waveobj.ORef - if webOpenReplaceBlock != "" { - var err error - replaceBlockORef, err = resolveSimpleId(webOpenReplaceBlock) - if err != nil { - return fmt.Errorf("resolving -r blockid: %w", err) - } - } - if replaceBlockORef != nil && webOpenMagnified { - return fmt.Errorf("cannot use --replace and --magnified together") - } - - tabId := getTabIdFromEnv() - if tabId == "" { - return fmt.Errorf("no WAVETERM_TABID env var set") - } - - wshCmd := wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &waveobj.BlockDef{ - Meta: map[string]any{ - waveobj.MetaKey_View: "web", - waveobj.MetaKey_Url: args[0], - }, - }, - Magnified: webOpenMagnified, - Focused: true, - } - if replaceBlockORef != nil { - wshCmd.TargetBlockId = replaceBlockORef.OID - wshCmd.TargetAction = wshrpc.CreateBlockAction_Replace - } - oref, err := wshclient.CreateBlockCommand(RpcClient, wshCmd, nil) - if err != nil { - return fmt.Errorf("creating block: %w", err) - } - WriteStdout("created block %s\n", oref) - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-workspace.go b/cmd/wsh/cmd/wshcmd-workspace.go deleted file mode 100644 index 6a793d68cf..0000000000 --- a/cmd/wsh/cmd/wshcmd-workspace.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var workspaceCommand = &cobra.Command{ - Use: "workspace", - Short: "Manage workspaces", - // Args: cobra.MinimumNArgs(1), -} - -func init() { - workspaceCommand.AddCommand(workspaceListCommand) - rootCmd.AddCommand(workspaceCommand) -} - -var workspaceListCommand = &cobra.Command{ - Use: "list", - Short: "List workspaces", - Run: workspaceListRun, - PreRunE: preRunSetupRpcClient, -} - -func workspaceListRun(cmd *cobra.Command, args []string) { - workspaces, err := wshclient.WorkspaceListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - WriteStderr("Unable to list workspaces: %v\n", err) - return - } - - WriteStdout("[\n") - for i, w := range workspaces { - WriteStdout(" {\n \"windowId\": \"%s\",\n", w.WindowId) - WriteStderr(" \"workspaceId\": \"%s\",\n", w.WorkspaceData.OID) - WriteStdout(" \"name\": \"%s\",\n", w.WorkspaceData.Name) - WriteStdout(" \"icon\": \"%s\",\n", w.WorkspaceData.Icon) - WriteStdout(" \"color\": \"%s\"\n", w.WorkspaceData.Color) - if i < len(workspaces)-1 { - WriteStdout(" },\n") - } else { - WriteStdout(" }\n") - } - } - WriteStdout("]\n") -} diff --git a/cmd/wsh/cmd/wshcmd-wsl.go b/cmd/wsh/cmd/wshcmd-wsl.go deleted file mode 100644 index cfe9cd47d8..0000000000 --- a/cmd/wsh/cmd/wshcmd-wsl.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" -) - -var distroName string - -var wslCmd = &cobra.Command{ - Use: "wsl [-d <distribution-name>]", - Short: "connect this terminal to a local wsl connection", - Args: cobra.NoArgs, - RunE: wslRun, - PreRunE: preRunSetupRpcClient, -} - -func init() { - wslCmd.Flags().StringVarP(&distroName, "distribution", "d", "", "Run the specified distribution") - rootCmd.AddCommand(wslCmd) -} - -func wslRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("wsl", rtnErr == nil) - }() - - var err error - if distroName == "" { - // get default distro from the host - distroName, err = wshclient.WslDefaultDistroCommand(RpcClient, nil) - if err != nil { - return err - } - } - if !strings.HasPrefix(distroName, "wsl://") { - distroName = "wsl://" + distroName - } - blockId := RpcContext.BlockId - if blockId == "" { - return fmt.Errorf("cannot determine blockid (not in JWT)") - } - data := wshrpc.CommandSetMetaData{ - ORef: waveobj.MakeORef(waveobj.OType_Block, blockId), - Meta: map[string]any{ - waveobj.MetaKey_Connection: distroName, - }, - } - err = wshclient.SetMetaCommand(RpcClient, data, nil) - if err != nil { - return fmt.Errorf("setting connection in block: %w", err) - } - WriteStderr("switched connection to %q\n", distroName) - return nil -} diff --git a/cmd/wsh/main-wsh.go b/cmd/wsh/main-wsh.go deleted file mode 100644 index 528fd17001..0000000000 --- a/cmd/wsh/main-wsh.go +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "github.com/wavetermdev/waveterm/cmd/wsh/cmd" - "github.com/wavetermdev/waveterm/pkg/wavebase" -) - -// set by main-server.go -var WaveVersion = "0.0.0" -var BuildTime = "0" - -func main() { - wavebase.WaveVersion = WaveVersion - wavebase.BuildTime = BuildTime - cmd.Execute() -} diff --git a/db/db.go b/db/db.go deleted file mode 100644 index 311a47c9e1..0000000000 --- a/db/db.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package db - -import "embed" - -//go:embed migrations-filestore/*.sql -var FilestoreMigrationFS embed.FS - -//go:embed migrations-wstore/*.sql -var WStoreMigrationFS embed.FS diff --git a/db/migrations-filestore/000001_init.down.sql b/db/migrations-filestore/000001_init.down.sql deleted file mode 100644 index 534c404e75..0000000000 --- a/db/migrations-filestore/000001_init.down.sql +++ /dev/null @@ -1,3 +0,0 @@ -DROP TABLE db_wave_file; - -DROP TABLE db_file_data; diff --git a/db/migrations-filestore/000001_init.up.sql b/db/migrations-filestore/000001_init.up.sql deleted file mode 100644 index af9fcf8c02..0000000000 --- a/db/migrations-filestore/000001_init.up.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE db_wave_file ( - zoneid varchar(36) NOT NULL, - name varchar(200) NOT NULL, - size bigint NOT NULL, - createdts bigint NOT NULL, - modts bigint NOT NULL, - opts json NOT NULL, - meta json NOT NULL, - PRIMARY KEY (zoneid, name) -); - -CREATE TABLE db_file_data ( - zoneid varchar(36) NOT NULL, - name varchar(200) NOT NULL, - partidx int NOT NULL, - data blob NOT NULL, - PRIMARY KEY(zoneid, name, partidx) -); - diff --git a/db/migrations-wstore/000001_init.down.sql b/db/migrations-wstore/000001_init.down.sql deleted file mode 100644 index 177ce08609..0000000000 --- a/db/migrations-wstore/000001_init.down.sql +++ /dev/null @@ -1,7 +0,0 @@ -DROP TABLE db_client; - -DROP TABLE db_workspace; - -DROP TABLE db_tab; - -DROP TABLE db_block; diff --git a/db/migrations-wstore/000001_init.up.sql b/db/migrations-wstore/000001_init.up.sql deleted file mode 100644 index 34c3b88327..0000000000 --- a/db/migrations-wstore/000001_init.up.sql +++ /dev/null @@ -1,30 +0,0 @@ -CREATE TABLE db_client ( - oid varchar(36) PRIMARY KEY, - version int NOT NULL, - data json NOT NULL -); - -CREATE TABLE db_window ( - oid varchar(36) PRIMARY KEY, - version int NOT NULL, - data json NOT NULL -); - -CREATE TABLE db_workspace ( - oid varchar(36) PRIMARY KEY, - version int NOT NULL, - data json NOT NULL -); - -CREATE TABLE db_tab ( - oid varchar(36) PRIMARY KEY, - version int NOT NULL, - data json NOT NULL -); - -CREATE TABLE db_block ( - oid varchar(36) PRIMARY KEY, - version int NOT NULL, - data json NOT NULL -); - diff --git a/db/migrations-wstore/000002_init.down.sql b/db/migrations-wstore/000002_init.down.sql deleted file mode 100644 index f7ef05cd1f..0000000000 --- a/db/migrations-wstore/000002_init.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE db_layout; diff --git a/db/migrations-wstore/000002_init.up.sql b/db/migrations-wstore/000002_init.up.sql deleted file mode 100644 index b0a05456c9..0000000000 --- a/db/migrations-wstore/000002_init.up.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE db_layout ( - oid varchar(36) PRIMARY KEY, - version int NOT NULL, - data json NOT NULL -); diff --git a/db/migrations-wstore/000003_activity.down.sql b/db/migrations-wstore/000003_activity.down.sql deleted file mode 100644 index f355a3156c..0000000000 --- a/db/migrations-wstore/000003_activity.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE db_activity; \ No newline at end of file diff --git a/db/migrations-wstore/000003_activity.up.sql b/db/migrations-wstore/000003_activity.up.sql deleted file mode 100644 index 142922bac7..0000000000 --- a/db/migrations-wstore/000003_activity.up.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE db_activity ( - day varchar(20) PRIMARY KEY, - uploaded boolean NOT NULL, - tdata json NOT NULL, - tzname varchar(50) NOT NULL, - tzoffset int NOT NULL, - clientversion varchar(20) NOT NULL, - clientarch varchar(20) NOT NULL, - buildtime varchar(20) NOT NULL DEFAULT '-', - osrelease varchar(20) NOT NULL DEFAULT '-' -); \ No newline at end of file diff --git a/db/migrations-wstore/000004_history.down.sql b/db/migrations-wstore/000004_history.down.sql deleted file mode 100644 index 556e9b40a8..0000000000 --- a/db/migrations-wstore/000004_history.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE history_migrated; \ No newline at end of file diff --git a/db/migrations-wstore/000004_history.up.sql b/db/migrations-wstore/000004_history.up.sql deleted file mode 100644 index 6b0f2a6849..0000000000 --- a/db/migrations-wstore/000004_history.up.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE history_migrated ( - historyid varchar(36) PRIMARY KEY, - ts bigint NOT NULL, - remotename varchar(200) NOT NULL, - haderror boolean NOT NULL, - cmdstr text NOT NULL, - exitcode int NULL DEFAULT NULL, - durationms int NULL DEFAULT NULL -); diff --git a/db/migrations-wstore/000005_blockparent.down.sql b/db/migrations-wstore/000005_blockparent.down.sql deleted file mode 100644 index 5aed013ca2..0000000000 --- a/db/migrations-wstore/000005_blockparent.down.sql +++ /dev/null @@ -1 +0,0 @@ --- we don't need to remove parentoref \ No newline at end of file diff --git a/db/migrations-wstore/000005_blockparent.up.sql b/db/migrations-wstore/000005_blockparent.up.sql deleted file mode 100644 index f81864ff85..0000000000 --- a/db/migrations-wstore/000005_blockparent.up.sql +++ /dev/null @@ -1,4 +0,0 @@ -UPDATE db_block -SET data = json_set(db_block.data, '$.parentoref', 'tab:' || db_tab.oid) -FROM db_tab -WHERE db_block.oid IN (SELECT value FROM json_each(db_tab.data, '$.blockids')); diff --git a/db/migrations-wstore/000006_workspace.down.sql b/db/migrations-wstore/000006_workspace.down.sql deleted file mode 100644 index 25991a9306..0000000000 --- a/db/migrations-wstore/000006_workspace.down.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Step 1: Restore the $.activetabid field to db_window.data -UPDATE db_window -SET data = json_set( - db_window.data, - '$.activetabid', - (SELECT json_extract(db_workspace.data, '$.activetabid') - FROM db_workspace - WHERE db_workspace.oid = json_extract(db_window.data, '$.workspaceid')) -) -WHERE json_extract(data, '$.workspaceid') IN ( - SELECT oid FROM db_workspace -); - --- Step 2: Remove the $.activetabid field from db_workspace.data -UPDATE db_workspace -SET data = json_remove(data, '$.activetabid') -WHERE oid IN ( - SELECT json_extract(db_window.data, '$.workspaceid') - FROM db_window -); diff --git a/db/migrations-wstore/000006_workspace.up.sql b/db/migrations-wstore/000006_workspace.up.sql deleted file mode 100644 index a8f5f3314f..0000000000 --- a/db/migrations-wstore/000006_workspace.up.sql +++ /dev/null @@ -1,18 +0,0 @@ --- Step 1: Update db_workspace.data to set the $.activetabid field -UPDATE db_workspace -SET data = json_set( - db_workspace.data, - '$.activetabid', - (SELECT json_extract(db_window.data, '$.activetabid')) -) -FROM db_window -WHERE db_workspace.oid IN ( - SELECT json_extract(db_window.data, '$.workspaceid') -); - --- Step 2: Remove the $.activetabid field from db_window.data -UPDATE db_window -SET data = json_remove(data, '$.activetabid') -WHERE json_extract(data, '$.workspaceid') IN ( - SELECT oid FROM db_workspace -); diff --git a/db/migrations-wstore/000007_events.down.sql b/db/migrations-wstore/000007_events.down.sql deleted file mode 100644 index 7acba0115a..0000000000 --- a/db/migrations-wstore/000007_events.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE db_tevent; diff --git a/db/migrations-wstore/000007_events.up.sql b/db/migrations-wstore/000007_events.up.sql deleted file mode 100644 index 3c6311960c..0000000000 --- a/db/migrations-wstore/000007_events.up.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE db_tevent ( - uuid varchar(36) PRIMARY KEY, - ts int NOT NULL, - tslocal varchar(100) NOT NULL, - event varchar(50) NOT NULL, - props json NOT NULL, - uploaded boolean NOT NULL DEFAULT 0 -); \ No newline at end of file diff --git a/db/migrations-wstore/000008_aimeta.down.sql b/db/migrations-wstore/000008_aimeta.down.sql deleted file mode 100644 index b654758c26..0000000000 --- a/db/migrations-wstore/000008_aimeta.down.sql +++ /dev/null @@ -1 +0,0 @@ --- presets exist in config files, and should automatically prepopulate the meta in the older code versions \ No newline at end of file diff --git a/db/migrations-wstore/000008_aimeta.up.sql b/db/migrations-wstore/000008_aimeta.up.sql deleted file mode 100644 index 203902ef99..0000000000 --- a/db/migrations-wstore/000008_aimeta.up.sql +++ /dev/null @@ -1,18 +0,0 @@ ---- removes all ai: keys except ai:preset -UPDATE db_block -SET data = json_remove( - db_block.data, - '$.meta.ai:*', - '$.meta.ai:apitype', - '$.meta.ai:baseurl', - '$.meta.ai:apitoken', - '$.meta.ai:name', - '$.meta.ai:model', - '$.meta.ai:orgid', - '$.meta.ai:apiversion', - '$.meta.ai:maxtokens', - '$.meta.ai:timeoutms', - '$.meta.ai:fontsize', - '$.meta.ai:fixedfontsize' -) -WHERE json_extract(data, '$.meta.view') = 'waveai'; \ No newline at end of file diff --git a/db/migrations-wstore/000009_mainserver.down.sql b/db/migrations-wstore/000009_mainserver.down.sql deleted file mode 100644 index 1b3a3329f0..0000000000 --- a/db/migrations-wstore/000009_mainserver.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS db_mainserver; diff --git a/db/migrations-wstore/000009_mainserver.up.sql b/db/migrations-wstore/000009_mainserver.up.sql deleted file mode 100644 index f025565364..0000000000 --- a/db/migrations-wstore/000009_mainserver.up.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE IF NOT EXISTS db_mainserver ( - oid varchar(36) PRIMARY KEY, - version int NOT NULL, - data json NOT NULL -); diff --git a/db/migrations-wstore/000010_merge_pinned_tabs.down.sql b/db/migrations-wstore/000010_merge_pinned_tabs.down.sql deleted file mode 100644 index 5b469ce8c7..0000000000 --- a/db/migrations-wstore/000010_merge_pinned_tabs.down.sql +++ /dev/null @@ -1,2 +0,0 @@ --- This migration cannot be reversed as pinned tab state is lost --- during the merge operation diff --git a/db/migrations-wstore/000010_merge_pinned_tabs.up.sql b/db/migrations-wstore/000010_merge_pinned_tabs.up.sql deleted file mode 100644 index 8091edc7cf..0000000000 --- a/db/migrations-wstore/000010_merge_pinned_tabs.up.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Merge PinnedTabIds into TabIds, preserving tab order -UPDATE db_workspace -SET data = json_set( - data, - '$.tabids', - ( - SELECT json_group_array(value) - FROM ( - SELECT value, 0 AS src, CAST(key AS INT) AS k - FROM json_each(data, '$.pinnedtabids') - UNION ALL - SELECT value, 1 AS src, CAST(key AS INT) AS k - FROM json_each(data, '$.tabids') - ORDER BY src, k - ) - ) -) -WHERE json_type(data, '$.pinnedtabids') = 'array' - AND json_array_length(data, '$.pinnedtabids') > 0; - -UPDATE db_workspace -SET data = json_remove(data, '$.pinnedtabids') -WHERE json_type(data, '$.pinnedtabids') IS NOT NULL; diff --git a/db/migrations-wstore/000011_job.down.sql b/db/migrations-wstore/000011_job.down.sql deleted file mode 100644 index 34620c17aa..0000000000 --- a/db/migrations-wstore/000011_job.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS db_job; diff --git a/db/migrations-wstore/000011_job.up.sql b/db/migrations-wstore/000011_job.up.sql deleted file mode 100644 index 3b032507bb..0000000000 --- a/db/migrations-wstore/000011_job.up.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE IF NOT EXISTS db_job ( - oid varchar(36) PRIMARY KEY, - version int NOT NULL, - data json NOT NULL -); diff --git a/docs/.editorconfig b/docs/.editorconfig deleted file mode 100644 index 39b95a2750..0000000000 --- a/docs/.editorconfig +++ /dev/null @@ -1,13 +0,0 @@ -root = true - -[*] -end_of_line = lf -insert_final_newline = true - -[*.{js,jsx,ts,tsx,cjs,json,yml,yaml,css,less}] -charset = utf-8 -indent_style = space -indent_size = 4 - -[CNAME] -insert_final_newline = false diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index 7b38a1bd29..0000000000 --- a/docs/.gitignore +++ /dev/null @@ -1,22 +0,0 @@ -# Dependencies -/node_modules -/.yarn - -# Production -/build -build.zip - -# Generated files -.docusaurus -.cache-loader - -# Misc -.DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local - -npm-debug.log* -yarn-debug.log* -yarn-error.log* diff --git a/docs/.prettierignore b/docs/.prettierignore deleted file mode 100644 index 8240e53e8a..0000000000 --- a/docs/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -build -.git -node_modules -*.min.* -*.mdx -CNAME diff --git a/docs/.remarkrc b/docs/.remarkrc deleted file mode 100644 index 000a34f8e3..0000000000 --- a/docs/.remarkrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "plugins": [ - "remark-preset-lint-consistent", - "remark-preset-lint-recommended", - "remark-mdx", - "remark-frontmatter" - ] -} diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index f118e41d1f..0000000000 --- a/docs/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Wave Terminal Documentation - -This is the home for Wave Terminal's documentation site. This README is specifically about _building_ and contributing to the docs site. If you are looking for the actual hosted docs, go here -- https://docs.waveterm.dev - -### Installation - -Our docs are built using [Docusaurus](https://docusaurus.io/), a modern static website generator. - -### Local Development - -```sh -task docsite -``` - -This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. - -### Build - -```sh -task docsite:build:public -``` - -This command generates static content into the `build` directory and can be served using any static contents hosting service. - -### Deployment - -Deployments are handled automatically by the [Docsite CI/CD workflow](../.github/workflows/deploy-docsite.yml) diff --git a/docs/babel.config.js b/docs/babel.config.js deleted file mode 100644 index dd249ac168..0000000000 --- a/docs/babel.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - presets: [require.resolve("@docusaurus/core/lib/babel/preset")], -}; diff --git a/docs/docs/ai-presets.mdx b/docs/docs/ai-presets.mdx deleted file mode 100644 index 6321dae3ad..0000000000 --- a/docs/docs/ai-presets.mdx +++ /dev/null @@ -1,253 +0,0 @@ ---- -sidebar_position: 3.6 -id: "ai-presets" -title: "AI Presets (Deprecated)" ---- -:::warning Deprecation Notice -The AI Widget and its presets are being replaced by [Wave AI](./waveai.mdx). Please refer to the Wave AI documentation for the latest AI features and configuration options. -::: - - -![AI Presets Menu](./img/ai-presets.png#right) - -Wave's AI widget can be configured to work with various AI providers and models through presets. Presets allow you to define multiple AI configurations and easily switch between them using the dropdown menu in the AI widget. - -## How AI Presets Work - -AI presets are defined in `~/.config/waveterm/presets/ai.json`. You can easily edit this file using: - -```bash -wsh editconfig presets/ai.json -``` - -Each preset defines a complete set of configuration values for the AI widget. When you select a preset from the dropdown menu, those configuration values are applied to the widget. If no preset is selected, the widget uses the default values from `settings.json`. - -Here's a basic example using Claude: - -```json -{ - "ai@claude-sonnet": { - "display:name": "Claude 3 Sonnet", - "display:order": 1, - "ai:*": true, - "ai:apitype": "anthropic", - "ai:model": "claude-3-5-sonnet-latest", - "ai:apitoken": "<your anthropic API key>" - } -} -``` - -To make a preset your default, add this single line to your `settings.json`: - -```json -{ - "ai:preset": "ai@claude-sonnet" -} -``` - -:::info -You can quickly set your default preset using the `setconfig` command: - -```bash -wsh setconfig ai:preset=ai@claude-sonnet -``` - -This is easier than editing settings.json directly! -::: - -## Provider-Specific Configurations - -### Anthropic (Claude) - -To use Claude models, create a preset like this: - -```json -{ - "ai@claude-sonnet": { - "display:name": "Claude 3 Sonnet", - "display:order": 1, - "ai:*": true, - "ai:apitype": "anthropic", - "ai:model": "claude-3-5-sonnet-latest", - "ai:apitoken": "<your anthropic API key>" - } -} -``` - -### OpenAI - -To use OpenAI's models: - -```json -{ - "ai@openai-gpt41": { - "display:name": "GPT-4.1", - "display:order": 2, - "ai:*": true, - "ai:model": "gpt-4.1", - "ai:apitoken": "<your OpenAI API key>" - } -} -``` - -### Local LLMs (Ollama) - -To connect to a local Ollama instance: - -```json -{ - "ai@ollama-llama": { - "display:name": "Ollama - Llama2", - "display:order": 3, - "ai:*": true, - "ai:baseurl": "http://localhost:11434/v1", - "ai:name": "llama2", - "ai:model": "llama2", - "ai:apitoken": "ollama" - } -} -``` - -Note: The `ai:apitoken` is required but can be any value as Ollama ignores it. See [Ollama OpenAI compatibility docs](https://github.com/ollama/ollama/blob/main/docs/openai.md) for more details. - -### Azure OpenAI - -To connect to Azure AI services: - -```json -{ - "ai@azure-gpt4": { - "display:name": "Azure GPT-4", - "display:order": 4, - "ai:*": true, - "ai:apitype": "azure", - "ai:baseurl": "<your Azure AI base URL>", - "ai:model": "<your model deployment name>", - "ai:apitoken": "<your Azure API key>" - } -} -``` - -Note: Do not include query parameters or `api-version` in the `ai:baseurl`. The `ai:model` should be your model deployment name in Azure. - -### Perplexity - -To use Perplexity's models: - -```json -{ - "ai@perplexity-sonar": { - "display:name": "Perplexity Sonar", - "display:order": 5, - "ai:*": true, - "ai:apitype": "perplexity", - "ai:model": "llama-3.1-sonar-small-128k-online", - "ai:apitoken": "<your perplexity API key>" - } -} -``` - -### Google (Gemini) - -To use Google's Gemini models from [Google AI Studio](https://aistudio.google.com): - -```json -{ - "ai@gemini-2.0": { - "display:name": "Gemini 2.0", - "display:order": 6, - "ai:*": true, - "ai:apitype": "google", - "ai:model": "gemini-2.0-flash-exp", - "ai:apitoken": "<your Google AI API key>" - } -} -``` - -### OpenRouter - -To use OpenRouter's models: - -```json -{ - "ai@openrouter": { - "display:name": "OpenRouter (Qwen)", - "display:order": 7, - "ai:*": true, - "ai:model": "qwen/qwen3-next-80b-a3b-thinking", - "ai:apitoken": "<openrouter-key>", - "ai:baseurl": "https://openrouter.ai/api/v1" - } -} -``` - -## Multiple Presets Example - -You can define multiple presets in your `ai.json` file: - -```json -{ - "ai@claude-sonnet": { - "display:name": "Claude 3 Sonnet", - "display:order": 1, - "ai:*": true, - "ai:apitype": "anthropic", - "ai:model": "claude-3-5-sonnet-latest", - "ai:apitoken": "<your anthropic API key>" - }, - "ai@openai-gpt41": { - "display:name": "GPT-4.1", - "display:order": 2, - "ai:*": true, - "ai:model": "gpt-4.1", - "ai:apitoken": "<your OpenAI API key>" - }, - "ai@ollama-llama": { - "display:name": "Ollama - Llama2", - "display:order": 3, - "ai:*": true, - "ai:baseurl": "http://localhost:11434/v1", - "ai:name": "llama2", - "ai:model": "llama2", - "ai:apitoken": "ollama" - }, - "ai@perplexity-sonar": { - "display:name": "Perplexity Sonar", - "display:order": 4, - "ai:*": true, - "ai:apitype": "perplexity", - "ai:model": "llama-3.1-sonar-small-128k-online", - "ai:apitoken": "<your perplexity API key>" - } -} -``` - -The `display:order` value determines the order in which presets appear in the dropdown menu. - -Remember to set your default preset in `settings.json`: - -```json -{ - "ai:preset": "ai@claude-sonnet" -} -``` - -## Using a Proxy - -If you need to route AI requests through an HTTP proxy, you can add the `ai:proxyurl` setting to any preset: - -```json -{ - "ai@claude-with-proxy": { - "display:name": "Claude 3 Sonnet (via Proxy)", - "display:order": 1, - "ai:*": true, - "ai:apitype": "anthropic", - "ai:model": "claude-3-5-sonnet-latest", - "ai:apitoken": "<your anthropic API key>", - "ai:proxyurl": "http://proxy.example.com:8080" - } -} -``` - -The proxy URL should be in the format `http://host:port` or `https://host:port`. This setting works with all AI providers except Wave Cloud AI (the default). diff --git a/docs/docs/claude-code.mdx b/docs/docs/claude-code.mdx deleted file mode 100644 index d16b0f0b0b..0000000000 --- a/docs/docs/claude-code.mdx +++ /dev/null @@ -1,131 +0,0 @@ ---- -sidebar_position: 1.9 -id: "claude-code" -title: "Claude Code Integration" ---- - -import { VersionBadge } from "@site/src/components/versionbadge"; - -# Claude Code Tab Badges <VersionBadge version="v0.14.2" /> - -When you run multiple Claude Code sessions in parallel — one per feature, one per repo, a few long-running tasks — it gets hard to know which tabs need your attention without clicking through each one. Wave's badge system solves this: hooks in Claude Code write a small visual indicator to the tab header whenever something important happens, so you can see at a glance which sessions are waiting, done, or in trouble. - -:::info -tl;dr You can copy and paste this page directly into Claude Code and it will help you set everything up! -::: - -## How it works - -Claude Code supports [lifecycle hooks](https://code.claude.com/docs/en/hooks) — shell commands that run automatically at specific points in a session. Wave's `wsh badge` command sets or clears a visual indicator on the current block or tab. By wiring these together, you get ambient awareness across all your sessions without watching any of them. - -Badges auto-clear when you focus the block, so they're purely a "hey, look over here" signal. Once you click in and read what's happening, the badge disappears on its own. - -Wave already shows a bell icon when a terminal outputs a BEL character. These hooks complement that with semantic badges — *permission needed*, *done* — that survive across tab switches and work across splits. - -### Badge rollup - -If a tab has multiple terminals (block), Wave shows the highest-priority badge on the tab header. Ties at the same priority go to the earliest badge set, so the most urgent signal from any pane in the tab floats to the top. - -## Setup - -These hooks go in your global Claude Code settings so they apply to every session on your machine, not just one project. - -Add the following to `~/.claude/settings.json`. If you already have a `hooks` key, merge the entries in: - -```json -{ - "hooks": { - "Notification": [ - { - "matcher": "permission_prompt", - "hooks": [ - { - "type": "command", - "command": "wsh badge bell-exclamation --color '#e0b956' --priority 20 --beep" - } - ] - }, - { - "matcher": "elicitation_dialog", - "hooks": [ - { - "type": "command", - "command": "wsh badge message-question --color '#e0b956' --priority 20 --beep" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "wsh badge check --color '#58c142' --priority 10" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "AskUserQuestion", - "hooks": [ - { - "type": "command", - "command": "wsh badge message-question --color '#e0b956' --priority 20 --beep" - } - ] - } - ] - } -} -``` - -That's it. Restart any running Claude Code sessions for the hooks to take effect. - -:::warning Known Issue -There is a known issue in Claude Code where `Notification` hooks may be delayed by several seconds before firing. This delay is unrelated to Wave — it occurs in Claude Code itself. See [#5186](https://github.com/anthropics/claude-code/issues/5186) and [#19627](https://github.com/anthropics/claude-code/issues/19627) for details. -::: - -## What each hook does - -### Permission prompt — `bell-exclamation` gold, priority 20 - -Claude Code occasionally needs your approval before it can continue — to run a command, write a file outside the project, or use a tool that requires explicit permission. When it hits one of these, it stops and waits. Without a signal, you might not notice for minutes. - -This hook fires on the `permission_prompt` notification type and sets a high-priority gold badge with an audible beep. Priority 20 means it beats any other badge on that tab, so a waiting session always surfaces above a finished one. - -When you click into the tab and approve or deny the request, the badge clears automatically. - -### Session complete — `check` green, priority 10 - -When Claude Code finishes responding, this hook sets a green check badge. It's a low-key signal: glance at the tab bar, see which sessions are done, review their output in whatever order you like. - -### AskUserQuestion — `message-question` gold, priority 20 - -When Claude Code uses the `AskUserQuestion` tool, it's paused and waiting for you to respond before it can proceed. This `PreToolUse` hook fires just before that tool call and sets the same high-priority gold badge as the permission prompt. - -`PreToolUse` hooks can match any tool by name, so you can add badges for other tools as well — for example, to get a signal whenever Claude runs a shell command (`Bash`) or edits a file (`Edit`). Any tool name Claude Code supports can be used as a matcher. - -## Choosing your own icons and colors - -Icon names are [Font Awesome](https://fontawesome.com/icons) icon names without the `fa-` prefix. Colors are any valid CSS color — hex values, named colors, or anything else CSS accepts. - -Some icon and color ideas: - -| Situation | Icon | Color | -|-----------|------|-------| -| Custom high-priority alert | `triangle-exclamation` | `#FF453A` | -| Blocked / waiting on input | `hourglass-half` | `#FF9500` | -| Neutral / informational | `circle-info` | `#429DFF` | -| Background task running | `spinner` | `#00FFDB` | - -See the [`wsh badge` reference](/wsh-reference#badge) for all available flags. - -## Adjusting priorities - -Priority controls which badge wins when multiple blocks in a tab each have one. Higher numbers take precedence. The defaults above use: - -- **20** for permission prompts — always surfaces above everything else -- **10** for session complete — visible when nothing more urgent is active - -If you add more hooks, keep permission-blocking signals at the high end (15–25) and informational signals at the low end (5–10). \ No newline at end of file diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx deleted file mode 100644 index e3b58325ae..0000000000 --- a/docs/docs/config.mdx +++ /dev/null @@ -1,385 +0,0 @@ ---- -sidebar_position: 3.45 -id: "config" -title: "Configuration" ---- - -import { Kbd } from "@site/src/components/kbd"; -import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; -import { VersionBadge, DeprecatedBadge } from "@site/src/components/versionbadge"; - -<PlatformProvider> - -<PlatformSelectorButton /> -<div style={{ marginBottom: 20 }}></div> - -Wave's configuration files are located at `~/.config/waveterm/`. - -The main configuration file is `settings.json` (`~/.config/waveterm/settings.json`). - -The file is structured as a mostly flat JSON file. Instead of using sub-objects we prefer to -use ":" as level separators. - -:::info - -The easiest way to edit your config files is to use the wsh editconfig command which will open your Wave config file in our built-in preview editor. - -``` -wsh editconfig -``` - -::: - -## Configuration Keys - -| Key Name | Type | Function | -| ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| app:globalhotkey | string | A systemwide keybinding to open your most recent wave window. This is a set of key names separated by `:`. For more info, see [Customizable Systemwide Global Hotkey](#customizable-systemwide-global-hotkey) | -| app:dismissarchitecturewarning | bool | Disable warnings on app start when you are using a non-native architecture for Wave. For more info, see [Why does Wave warn me about ARM64 translation when it launches?](./faq#why-does-wave-warn-me-about-arm64-translation-when-it-launches). | -| app:defaultnewblock | string | Sets the default new block (Cmd:n, Cmd:d). "term" for terminal block, "launcher" for launcher block (default = "term") | -| app:showoverlayblocknums | bool | Set to false to disable the Ctrl+Shift block number overlay that appears when holding Ctrl+Shift (defaults to true) | -| app:ctrlvpaste | bool | On Windows/Linux, when null (default) uses Control+V on Windows only. Set to true to force Control+V on all non-macOS platforms, false to disable the accelerator. macOS always uses Command+V regardless of this setting | -| app:confirmquit <VersionBadge version="v0.14" /> | bool | Set to false to disable the quit confirmation dialog when closing Wave Terminal (defaults to true, requires app restart) | -| app:hideaibutton <VersionBadge version="v0.14" /> | bool | Set to true to hide the AI button in the tab bar (defaults to false) | -| app:disablectrlshiftarrows <VersionBadge version="v0.14" /> | bool | Set to true to disable Ctrl+Shift block-navigation keybindings (`Arrow` and `h/j/k/l`) (defaults to false) | -| app:disablectrlshiftdisplay <VersionBadge version="v0.14" /> | bool | Set to true to disable the Ctrl+Shift visual indicator display (defaults to false) | -| app:focusfollowscursor <VersionBadge version="v0.14" /> | string | Controls whether block focus follows cursor movement: `"off"` (default), `"on"` (all blocks), or `"term"` (terminal blocks only) | -| app:tabbar <VersionBadge version="v0.14.4" /> | string | Controls the position of the tab bar: `"top"` (default) for a horizontal tab bar at the top of the window, or `"left"` for a vertical tab bar on the left side of the window | -| ai:preset | string | the default AI preset to use | -| ai:baseurl | string | Set the AI Base Url (must be OpenAI compatible) | -| ai:apitoken | string | your AI api token | -| ai:apitype | string | defaults to "open_ai", but can also set to "azure" (forspecial Azure AI handling), "anthropic", or "perplexity" | -| ai:name | string | string to display in the Wave AI block header | -| ai:model | string | model name to pass to API | -| ai:apiversion | string | for Azure AI only (when apitype is "azure", this will default to "2023-05-15") | -| ai:orgid | string | | -| ai:maxtokens | int | max tokens to pass to API | -| ai:timeoutms | int | timeout (in milliseconds) for AI calls | -| ai:proxyurl | string | HTTP proxy URL for AI API requests (does not apply to Wave Cloud AI) | -| conn:askbeforewshinstall | bool | set to false to disable popup asking if you want to install wsh extensions on new machines | -| conn:localhostdisplayname <VersionBadge version="v0.14" /> | string | override the display name for localhost in the UI (e.g., set to "My Laptop" or "Local", or set to empty string to hide the name) | -| term:fontsize | float | the fontsize for the terminal block | -| term:fontfamily | string | font family to use for terminal block | -| term:disablewebgl | bool | set to false to disable WebGL acceleration in terminal | -| term:localshellpath | string | set to override the default shell path for local terminals | -| term:localshellopts | string[] | set to pass additional parameters to the term:localshellpath (example: `["-NoLogo"]` for PowerShell will remove the copyright notice) | -| term:copyonselect | bool | set to false to disable terminal copy-on-select | -| term:scrollback | int | size of terminal scrollback buffer, max is 10000 | -| term:theme | string | preset name of terminal theme to apply by default (default is "default-dark") | -| term:transparency | float64 | set the background transparency of terminal theme (default 0.5, 0 = not transparent, 1.0 = fully transparent) | -| term:allowbracketedpaste | bool | allow bracketed paste mode in terminal (default false) | -| term:shiftenternewline | bool | when enabled, Shift+Enter sends escape sequence + newline (\u001b\n) instead of carriage return, useful for claude code and similar AI coding tools (default false) | -| term:macoptionismeta | bool | on macOS, treat the Option key as Meta key for terminal keybindings (default false) | -| term:cursor <VersionBadge version="v0.14" /> | string | terminal cursor style. valid values are `block` (default), `underline`, and `bar` | -| term:cursorblink <VersionBadge version="v0.14" /> | bool | when enabled, terminal cursor blinks (default false) | -| term:bellsound <VersionBadge version="v0.14" /> | bool | when enabled, plays the system beep sound when the terminal bell (BEL character) is received (default false) | -| term:bellindicator <VersionBadge version="v0.14" /> | bool | when enabled, shows a visual indicator in the tab when the terminal bell is received (default false) | -| term:osc52 <VersionBadge version="v0.14" /> | string | controls OSC 52 clipboard behavior: `always` (default, allows OSC 52 at any time) or `focus` (requires focused window and focused block) | -| term:durable <VersionBadge version="v0.14" /> | bool | makes remote terminal sessions durable across network disconnects (defaults to false) | -| term:showsplitbuttons <VersionBadge version="v0.15" /> | bool | when enabled, shows split horizontal and vertical buttons in the terminal block header (defaults to false) | -| editor:minimapenabled | bool | set to false to disable editor minimap | -| editor:stickyscrollenabled | bool | enables monaco editor's stickyScroll feature (pinning headers of current context, e.g. class names, method names, etc.), defaults to false | -| editor:wordwrap | bool | set to true to enable word wrapping in the editor (defaults to false) | -| editor:fontsize | float64 | set the font size for the editor (defaults to 12px) | -| editor:inlinediff | bool | set to true to show diffs inline instead of side-by-side, false for side-by-side (defaults to undefined which uses Monaco's responsive behavior) | -| preview:showhiddenfiles | bool | set to false to disable showing hidden files in the directory preview (defaults to true) | -| preview:defaultsort <VersionBadge version="v0.14.2" /> | string | sets the default sort column for directory preview. `"name"` (default) sorts alphabetically by name ascending; `"modtime"` sorts by last modified time descending (newest first) | -| markdown:fontsize | float64 | font size for the normal text when rendering markdown in preview. headers are scaled up from this size, (default 14px) | -| markdown:fixedfontsize | float64 | font size for the code blocks when rendering markdown in preview (default is 12px) | -| web:openlinksinternally | bool | set to false to open web links in external browser | -| web:defaulturl | string | default web page to open in the web widget when no url is provided (homepage) | -| web:defaultsearch | string | search template for web searches. e.g. `https://www.google.com/search?q={query}`. "\{query}" gets replaced by search term | -| autoupdate:enabled | bool | enable/disable checking for updates (requires app restart) | -| autoupdate:intervalms | float64 | time in milliseconds to wait between update checks (requires app restart) | -| autoupdate:installonquit | bool | whether to automatically install updates on quit (requires app restart) | -| autoupdate:channel | string | the auto update channel "latest" (stable builds), or "beta" (updated more frequently) (requires app restart) | -| tab:preset <DeprecatedBadge /> | string | a "bg@" preset to automatically apply to new tabs. e.g. `bg@green`. should match the preset key. deprecated in favor of `tab:background` | -| tab:background <VersionBadge version="v0.14.4" /> | string | a "bg@" preset to automatically apply to new tabs. e.g. `bg@green`. should match the preset key | -| tab:confirmclose | bool | if set to true, a confirmation dialog will be shown before closing a tab (defaults to false) | -| widget:showhelp | bool | whether to show help/tips widgets in right sidebar | -| window:transparent | bool | set to true to enable window transparency (cannot be combined with `window:blur`) (macOS and Windows only, requires app restart, see [note on Windows compatibility](https://www.electronjs.org/docs/latest/tutorial/custom-window-styles#limitations)) | -| window:blur | bool | set to enable window background blurring (cannot be combined with `window:transparent`) (macOS and Windows only, requires app restart, see [note on Windows compatibility](https://www.electronjs.org/docs/latest/tutorial/custom-window-styles#limitations)) | -| window:opacity | float64 | 0-1, window opacity when `window:transparent` or `window:blur` are set | -| window:bgcolor | string | set the window background color (should be hex: #xxxxxx) | -| window:reducedmotion | bool | set to true to disable most animations | -| window:tilegapsize | int | set to change override default gap size (in CSS pixels) between blocks | -| window:magnifiedblockopacity | float64 | change the opacity of a magnified block (must be between 0 and 1, defaults to 0.6) | -| window:magnifiedblocksize | float64 | change the size of a magnified block as a percentage of the dimensions of its parent layout (must be between 0 and 1, defaults to 0.9) | -| window:magnifiedblockblurprimarypx | int | change the blur in CSS pixels that is applied directly behind a magnified block (see [backdrop-filter](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter) for more info on how this gets applied) | -| window:magnifiedblockblursecondarypx | int | change the blur in CSS pixels that is applied to the visible portions of non-magnified blocks when a block is magnified (see [backdrop-filter](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter) for more info on how this gets applied) | -| window:maxtabcachesize | int | number of tabs to cache. when tabs are cached, switching between them is very fast. (defaults to 10) | -| window:showmenubar | bool | set to use the OS-native menu bar (Windows and Linux only, requires app restart) | -| window:nativetitlebar | bool | set to use the OS-native title bar, rather than the overlay (Windows and Linux only, requires app restart) | -| window:disablehardwareacceleration | bool | set to disable Chromium hardware acceleration to resolve graphical bugs (requires app restart) | -| window:fullscreenonlaunch | bool | set to true to launch the foreground window in fullscreen mode (defaults to false) | -| window:savelastwindow | bool | when `true`, the last window that is closed is preserved and is reopened the next time the app is launched (defaults to `true`) | -| window:confirmonclose | bool | when `true`, a prompt will ask a user to confirm that they want to close a window if it has an unsaved workspace with more than one tab (defaults to `true`) | -| window:dimensions | string | set the default dimensions for new windows using the format "WIDTHxHEIGHT" (e.g. "1920x1080"). when a new window is created, these dimensions will be automatically applied. The width and height values should be specified in pixels. | -| telemetry:enabled | bool | set to enable/disable telemetry | - -For reference, this is the current default configuration (v0.14.0): - -```json -{ - "ai:preset": "ai@global", - "ai:model": "gpt-5-mini", - "ai:maxtokens": 4000, - "ai:timeoutms": 60000, - "app:defaultnewblock": "term", - "app:confirmquit": true, - "app:hideaibutton": false, - "app:disablectrlshiftarrows": false, - "app:disablectrlshiftdisplay": false, - "app:focusfollowscursor": "off", - "autoupdate:enabled": true, - "autoupdate:installonquit": true, - "autoupdate:intervalms": 3600000, - "conn:askbeforewshinstall": true, - "conn:wshenabled": true, - "editor:minimapenabled": true, - "web:defaulturl": "https://github.com/wavetermdev/waveterm", - "web:defaultsearch": "https://www.google.com/search?q={query}", - "window:tilegapsize": 3, - "window:maxtabcachesize": 10, - "window:nativetitlebar": true, - "window:magnifiedblockopacity": 0.6, - "window:magnifiedblocksize": 0.9, - "window:magnifiedblockblurprimarypx": 10, - "window:fullscreenonlaunch": false, - "window:magnifiedblockblursecondarypx": 2, - "window:confirmclose": true, - "window:savelastwindow": true, - "telemetry:enabled": true, - "term:bellsound": false, - "term:bellindicator": false, - "term:osc52": "always", - "term:cursor": "block", - "term:cursorblink": false, - "term:copyonselect": true, - "term:durable": false, - "waveai:showcloudmodes": true, - "waveai:defaultmode": "waveai@balanced", - "preview:defaultsort": "name" -} -``` - -:::warning - -If you installed Wave pre-v0.9.0 your configuration file will be located at -`~/.waveterm/config/settings.json`. This includes all of the other configuration -files as well: `termthemes.json`, `presets.json`, and `widgets.json`. - -::: - -## Environment Variable Resolution - -To avoid putting secrets directly in config files, Wave supports environment variable resolution using `$ENV:VARIABLE_NAME` or `$ENV:VARIABLE_NAME:fallback` syntax. This works for any string value in any config file (settings.json, presets.json, ai.json, etc.). - -```json -{ - "ai:apitoken": "$ENV:OPENAI_APIKEY", - "ai:baseurl": "$ENV:AI_BASEURL:https://api.openai.com/v1" -} -``` - -## WebBookmarks Configuration - -WebBookmarks allows you to store and manage web links with customizable display preferences. The bookmarks are stored in a JSON file (`bookmarks.json`) as a key-value map where the key (`id`) is an arbitrary identifier for the bookmark. By convention, you should start your ids with "bookmark@". In the web widget, you can pull up your bookmarks using <Kbd k="Cmd:o"/> - -### Bookmark Structure - -Each bookmark follows this structure (only `url` is required): - -```json -{ - "url": "https://example.com", - "title": "Example Site", - "iconurl": "https://example.com/custom-icon.png", - "display:order": 1 -} -``` - -### Fields - -| Field | Type | Description | -| ------------- | ------- | ----------------------------------------------------------------------------------------------------------------- | -| url | string | **Required.** The URL of the bookmark. | -| title | string | **Optional.** A display title for the bookmark. | -| icon | string | **Optional, rarely used.** Overrides the default favicon with an icon name. | -| iconcolor | string | **Optional, rarely used.** Sets a custom color for the specified icon. | -| iconurl | string | **Optional.** Provides a custom icon URL, useful if the favicon is incorrect (e.g., for dark mode compatibility). | -| display:order | float64 | **Optional.** Defines the order in which bookmarks appear. | - -### Example `bookmarks.json` - -```json -{ - "bookmark@google": { - "url": "https://www.google.com", - "title": "Google" - }, - "bookmark@claude": { - "url": "https://claude.ai", - "title": "Claude AI" - }, - "bookmark@wave": { - "url": "https://waveterm.dev", - "title": "Wave Terminal", - "display:order": -1 - }, - "bookmark@wave-github": { - "url": "https://github.com/wavetermdev/waveterm", - "title": "Wave Github", - "iconurl": "https://github.githubassets.com/favicons/favicon-dark.png" - }, - "bookmark@chatgpt": { - "url": "https://chatgpt.com", - "iconurl": "https://cdn.oaistatic.com/assets/favicon-miwirzcw.ico" - }, - "bookmark@wave-pulls": { - "url": "https://github.com/wavetermdev/waveterm/pulls", - "title": "Wave Pull Requests", - "iconurl": "https://github.githubassets.com/favicons/favicon-dark.png" - } -} -``` - -### Behavior - -- If `iconurl` is set, it fetches the icon from the specified URL instead of the site's default favicon. -- Bookmarks are sorted based on `display:order` (if provided), otherwise by id. -- `icon` and `iconcolor` are rarely needed since the default behavior fetches the site's favicon. -- favicons are refreshed every 24-hours - -## Terminal Theming - -User-defined terminal themes are located in `~/.config/waveterm/termthemes.json`. - -This JSON file is structured as an object, with each sub-key defining a theme. -Themes are applied by right-clicking on the terminal's header bar and selecting an entry from the "Themes" sub-menu. Alternatively they can be applied to -the block's metadata key `term:theme`. This uses the JSON key value as the identifier. Note, for best consistency all colors should be of the format "#rrggbb" or "#rrggbbaa" (aa = alpha channel for transparency). - -``` -wsh setmeta this term:theme="default-dark" -``` - -Here is an example of defining a full terminal theme. All of the built-in themes are defined here: https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/termthemes.json (if you'd like to add a popular terminal theme, please submit a PR!) - -```json -{ - "default-dark": { - "display:name": "Default Dark", - "display:order": 1, - "black": "#757575", - "red": "#cc685c", - "green": "#76c266", - "yellow": "#cbca9b", - "blue": "#85aacb", - "magenta": "#cc72ca", - "cyan": "#74a7cb", - "white": "#c1c1c1", - "brightBlack": "#727272", - "brightRed": "#cc9d97", - "brightGreen": "#a3dd97", - "brightYellow": "#cbcaaa", - "brightBlue": "#9ab6cb", - "brightMagenta": "#cc8ecb", - "brightCyan": "#b7b8cb", - "brightWhite": "#f0f0f0", - "gray": "#8b918a", - "cmdtext": "#f0f0f0", - "foreground": "#c1c1c1", - "selectionBackground": "", - "background": "#00000077", - "cursorAccent": "" - } -} -``` - -:::info - -You can easily open the termthemes.json config file by running: - -``` -wsh editconfig termthemes.json -``` - -::: - -| Key Name | Type | ANSI FG# | ANSI BG# | Function | -| ------------------- | --------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| display:name | string | | | the name as it will appear in the UI context menu | -| display:order | float | | | entries in the context menu are sorted by display:order | -| black | CSS color | 30 | 40 | color for black | -| red | CSS color | 31 | 41 | color for red | -| green | CSS color | 32 | 42 | color for green | -| yellow | CSS color | 33 | 43 | color for yellow | -| blue | CSS color | 34 | 44 | color for blue | -| magenta | CSS color | 35 | 45 | color for magenta | -| cyan | CSS color | 36 | 46 | color for cyan | -| white | CSS color | 37 | 47 | color for white | -| brightBlack | CSS color | 90 | 100 | color for bright black | -| brightRed | CSS color | 91 | 101 | color for bright red | -| brightGreen | CSS color | 92 | 102 | color for bright green | -| brightYellow | CSS color | 93 | 103 | color for bright yellow | -| brightBlue | CSS color | 94 | 104 | color for bright blue | -| brightMagenta | CSS color | 95 | 105 | color for bright magenta | -| brightCyan | CSS color | 96 | 106 | color for bright cyan | -| brightWhite | CSS color | 97 | 107 | color for bright white | -| gray | CSS color | | | currently unused | -| cmdtext | CSS color | | | currently unused | -| foreground | CSS color | | | foreground color (default when no color code is applied) | -| background | CSS color | | | background color (default when no color code is applied), must have alpha channel (#rrggbbaa) if you want the terminal to be transparent | -| cursorAccent | CSS color | | | color for cursor | -| selectionBackground | CSS color | | | background color for selected text | - -## Customizable Systemwide Global Hotkey - -Wave allows settings a custom global hotkey to open your most recent window from anywhere in your computer. This has the name `"app:globalhotkey"` in the `settings.json` file and takes the form of a series of key names separated by the `:` character. - -### Examples - -As a practical example, suppose you want a value of `F5` as your global hotkey. Then you can simply set the value of `"app:globalhotkey"` to `"F5"` and reboot Wave to make that your global hotkey. - -As a less practical example, suppose you use the combination of the keys `Ctrl`, `Option`, and `e`. Then the value for this keybinding would be `"Ctrl:Option:e"`. - -### Allowed Key Names - -We support the following key names: - -- `Ctrl` -- `Cmd` -- `Shift` -- `Alt` -- `Option` -- `Meta` -- `Super` -- Digits (non-numpad) represented by `c{Digit0}` through `c{Digit9}` -- Letters `a` though `z` -- F keys `F1` through `F20` -- Soft keys `Soft1` through `Soft4`. These are essentially the same as `F21` through `F24`. -- Space represented as either `Space` or a literal space  <code> </code> -- `Enter` (This is labeled as return on Mac) -- `Tab` -- `CapsLock` -- `NumLock` -- `Backspace` (This is labeled as delete on Mac) -- `Delete` -- `Insert` -- The arrow keys `ArrowUp`, `ArrowDown`, `ArrowLeft`, and `ArrowRight` -- `Home` -- `End` -- `PageUp` -- `PageDown` -- `Esc` -- Volume controls `AudioVolumeUp`, `AudioVolumeDown`, `AudioVolumeMute` -- Media controls `MediaTrackNext`, `MediaTrackPrevious`, `MediaPlayPause`, and `MediaStop` -- `PrintScreen` -- Numpad keys represented by `c{Numpad0}` through `c{Numpad9}` -- The numpad decimal represented by `Decimal` -- The numpad plus/add represented by `Add` -- The numpad minus/subtract represented by `Subtract` -- The numpad star/multiply represented by `Multiply` -- The numpad slash/divide represented by `Divide` - -</PlatformProvider> diff --git a/docs/docs/connections.mdx b/docs/docs/connections.mdx deleted file mode 100644 index b5b050da6a..0000000000 --- a/docs/docs/connections.mdx +++ /dev/null @@ -1,284 +0,0 @@ ---- -sidebar_position: 3.1 -id: "connections" -title: "Connections" ---- - -import { VersionBadge } from "@site/src/components/versionbadge"; - -# Connections - -Wave allows users to connect to various machines and unify them together in a way that preserves the unique behavior of each. At the moment, this extends to SSH remote connections and local WSL connections. - -## Access a Connection in a Block - -The easiest way to access connections is to click the <i className="fa-sharp fa-laptop"/> icon. From there, you can type one of the following to depending on the connection you want: - -For SSH Connections: - -- `[user]@[host]` -- `[host]` -- `[user]@[host]:[port]` - -For WSL Connections: - -- `wsl://<distribution name>` - -Alternatively, if the connection already exists in the dropdown list, you can either click it or navigate to it with arrow keys and press enter to connect. - -![a dropdown showing a list of connections that already exist](./img/connection-dropdown.png) - -## Different Types of Connections - -As there are several different types of connections, not all of the types have access to the same features. SSH and WSL connections can always work in terminal widgets, and if `wsh` shell extensions are installed, they can also work in preview widgets and the sysinfo widget. - -## What are wsh Shell Extensions? - -`wsh` is a small program that helps manage waveterm regardless of which machine you are currently connected to. It is always included on your host machine, but you also have the option to install it when connecting to SSH and WSL Connections. If it is installed on the connection, it is installed at `~/.waveterm/bin/wsh`. Then, when wave connects to your connection (and only when wave connects to your connection), the following happens: - -- `~/.waveterm/bin` is added to your `PATH` for that individual session. This allows the user to use the `wsh` command without providing the complete path. -- Several environment variables are injected into the session to make certain tasks with `wsh` easier. These are [listed below](#additional-environment-variables). -- The user-defined environment variables in the `cmd:env` entry of`connections.json` are injected into the session. -- The user-defined initialization scripts located in `connections.json` are run. For more information on these scripts, see the section below. - -If this fails for some reason, Wave will attempt to run without `wsh`. You will see this indicated by a small **<code><i className="fa-link-slash fa-solid fa-sharp"/></code>** icon in the block header. For more info on what `wsh` is capable of, see [wsh command](/wsh). And if you wish to view the source code of `wsh`, you can find it [here](https://github.com/wavetermdev/waveterm/tree/main/cmd/wsh). - -With `wsh` installed, you have the ability to view certain widgets from the remote machine as if it were your host, for instance the `files` and `sysinfo` widgets. In addition, `wsh` can be used to influence the widgets across various machines. As a simple example, you can close a widget on the host machine by using the `wsh` command in a terminal window on a remote machine. For more information on what you can accomplish with `wsh`, take a look [here](/wsh). - -### Additional Environment Variables - -As mentioned above, `wsh` injects a few environment variables in remote sessions for the user's convenience. These are listed below: - -| Variable Name | Description | -| -------------------- | ----------------------------------------------------------------------------- | -| TERM_PROGRAM | Set to `waveterm` in wave. | -| WAVETERM | This is set to 1 in wave. | -| WAVETERM_BLOCKID | The id of the block containing your current terminal widget. | -| WAVETERM_CLIENTID | The id of the RPC Client being used by your current terminal widget. | -| WAVETERM_CONN | The name of the remote connection being used by your current terminal widget. | -| WAVETERM_TABID | The id of the tab containing your current terminal widget. | -| WAVETERM_VERSION | The current semver version of wave. | -| WAVETERM_WORKSPACEID | The id of thw workspace containing your current terminal widget. | - -# Initialization Scripts - -Wave provides you with options for running initialization scripts on your remote machines when connecting to them. These are defined in `connections.json` and can take either the form of the path of a script or a short script written directly in the file. If multiple scripts are defined, the most specific one relevant to the current shell is applied. The keywords for the scripts are: - -| Script Keyword | Shells Where Applied | -| ------------------- | -------------------- | -| cmd:initscript | all shells | -| cmd:initscript.sh | bash and zsh | -| cmd:initscript.bash | bash | -| cmd:initscript.zsh | zsh | -| cmd:initscript.pwsh | pwsh | -| cmd:initscript.fish | fish | - -## Add a New Connection to the Dropdown - -The SSH values that are loaded into the dropdown by default are obtained by parsing the internal `config/connections.json` file in addition to your `~/.ssh/config` and `/etc/ssh/ssh_config` files. Adding a new connection can be added in a couple ways: - -- adding a new `Host` to one of your ssh config files, typically the `~/.ssh/config` file -- adding a new entry in the internal `config/connections.json` file -- manually typing your connection into the connection box (if this successfully connects, the connection will be added to the internal `config/connections.json` file) -- use `wsh ssh [user]@[host]` in your terminal (if this successfully connects, the connection will be added to the internal `config/connections.json` file) - -WSL connections are added by searching the installed WSL distributions as they appear in the Windows Registry. They also exist in the `config/connections.json` file similarly to SSH connections. - -## SSH Config Parsing - -At the moment, we are capable of parsing any SSH config file that does not contain the `Match` keyword. This keyword is incompatible with a library we are using, but we are hoping to fix that soon. While all other valid keywords are parsed, we only support the functionality of a small subset of them at the moment: -| Keyword | Description | -|---------|-------------| -| Host | The pattern to match when attempting to connect via `[user]@[host]`. We list hosts that do not contain any wildcards characters (`*`, `?`, or `!`). Even if a host pattern contains wildcards, it will still be parsed when determining the values associated with the keys as usual.| -| User | The user of the SSH remote connection. This will default to the current user on the local machine if not specified.| -|HostName| The real host name of the machine to log into. An IP address can be used if desired. This will default to the Host if not specified. -| Port | The port to connect to the remote on. `22` is the default if not specified.| -| IdentityFile | This can be specified more than once per host. It gives the path to a private identity file (id_rsa, id_ed25519, id_ecdsa, etc.) that is used to authenticate the connection. Each will be tried in order, and they can be encrypted with a passphrase if desired. If no value is set, the default is to try in order: ~/.ssh/id_rsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ecdsa_sk, ~/.ssh/id_ed25519_sk, ~/.ssh/id_dsa.| -|BatchMode| If set to true, user interaction via password, challenge/response, and publickey passphrase authentication will be disabled. It is set to false by default.| -|PubkeyAuthentication| (partial) This is used to specify if pubkey authentication should be attempted. It is partially implementented as the `unbound` and `host-bound` values simply work the same as the `yes` value. The default is `yes`.| -|PasswordAuthentication| This is used to specify if password authentication should be attempted. The default is `yes`.| -|KbdInteractiveAuthentication| This is used to specify if keyboard-interactive authentication should be attempted. The default is `yes`.| -|PreferredAuthentications| (partial) Specifies the order the client should attempt to authenticate in. It is partially implemented as it does not support `gssapi-with-mic` or `hostbased` authentication. The default is `publickey,keyboard-interactive,password`| -|AddKeysToAgent| (partial) This option will automatically add keys and their corresponding passphrase to your running ssh agent if it is enabled. It is partially supported as it can only accept `yes` and `no` as valid inputs. Other inputs such as `confirm` or a time interval will behave the same as `no`. The default value is `no`.| -|IdentityAgent| Specifies the Unix Domain Socket used to communicate with the SSH Agent. This is used to overwrite the SSH_AUTH_SOCK identity agent.| -|IdentitiesOnly| Specifies that only the specified authentication identity files should be used. This is either the default files or the ones specified with the IdentityFile keyword. It can accept `yes` or `no`. The default value is `no`.| -|ProxyJump| Specifies one or more jump proxies in a comma separated list. Each will be visited sequentially using TCP forwarding before connecting to the desired connection (also using TCP forwarding). It can be set to `none` to disable the feature.| -|UserKnownHostsFile| Provides the location of one or more user host key database files for recording trusted remote connections. The filenames are entered in the same string and separated by whitespace. The default value is `"~/.ssh/known_hosts ~/.ssh/known_hosts2"`.| -|GlobalKnownHostsFile| Provides the location of one or more global host key database files for recording trusted remote connections. The filenames are entered in the same string and separated by whitespace. The default value is `"/etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2"`.| - -### Example SSH Config Host - -For a quick example, a host in your config file may look like: - -``` -Host myhost - User username - HostName 203.0.113.254 - IdentityFile ~/.ssh/id_rsa - AddKeysToAgent yes -``` - -You would then be able to access this connection with `myhost` or `username@myhost`. And if you wanted to manually specify a port such as port 2222, you could do that by either adding `Port 2222` to the config file or connecting to `username@myhost:2222`. - -## Internal SSH Configuration - -In addition to the regular ssh config file, wave also has its own config file to manage separate variables. These include -| Keyword | Description | -|---------|-------------| -| conn:wshenabled | This boolean allows `wsh` to be used for your connection, if it is set to `false`, `wsh` will never be used for that connection. It defaults to `true`.| -| conn:askbeforewshinstall | This boolean is used to prompt the user before installing wsh. If it is set to false, `wsh` will automatically be installed instead without prompting. It defaults to `true`.| -| conn:wshpath | A string indicating the path to the `wsh` executable on the connection. It defaults to `"~/.waveterm/bin/wsh"`.| -| conn:shellpath | A string indicating the path to the shell executable on the connection. If not set, the output of `$SHELL` on the connection will be used.| -| conn:ignoresshconfig | This boolean allows wave to ignore the `~/.ssh/config` file for resolving keywords for this connection. The regular defaults will be used, but all changes to those must be specified in the `connections.json` file instead. This defaults to false.| -| display:hidden | This boolean hides the connection from the dropdown list. It defaults to `false` | -| display:order | This float determines the order of connections in the connection dropdown. It defaults to `0`.| -| term:fontsize | This int can be used to override the terminal font size for blocks using this connection. The block metadata takes priority over this setting. It defaults to null which means the global setting will be used instead. | -| term:fontfamily | This string can be used to specify a terminal font family for blocks using this connection. The block metadata takes priority over this setting. It defaults to null which means the global setting will be used instead. | -| term:theme | This string can be used to specify a terminal theme for blocks using this connection. The block metadata takes priority over this setting. It defaults to null which means the global setting will be used instead. | -| cmd:env | A json object with key value pairs of environment variables and the value they should be set to for this remote. This only works if `wsh` is enabled. -| cmd:initscript | A script or a path to a script that runs when initializing this connection with any shell. This only works if `wsh` is enabled. | -| cmd:initscript.sh | A script or a path to a script that runs when initializing this connection with POSIX shells like `bash` or `zsh`. This only works if `wsh` is enabled. -| cmd:initscript.bash | A script or a path to a script that runs when initializing this connection with the `bash` shell. This only works if `wsh` is enabled. | -| cmd:initscript.zsh | A script or a path to a script that runs when initializing this connection with the `zsh` shell. This only works if `wsh` is enabled. | -| cmd:initscript.pwsh | A script or a path to a script that runs when initializing this connection with the `pwsh` shell. This only works if `wsh` is enabled. | -| cmd:initscript.fish | A script or a path to a script that runs when initializing this connection with the `fish` shell. This only works if `wsh` is enabled. | -| ssh:user | A string that indicates the username of the connection. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| -| ssh:hostname | A string representing the internal hostname of the connection. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| -| ssh:port | A string to indicate the numerical port to connect on. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| -| ssh:identityfile | A list of strings containing the paths to identity files that will be used. If a `wsh ssh` command using the `-i` flag is successful, the identity file will automatically be added here. These are used before the `~/.ssh/config` values.| -| ssh:identitiesonly | A boolean indicating if only the specified identity files should be used. This means only the files set with the `ssh:identityfile` flag or the defaults. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| -| ssh:batchmode | A boolean indicating if password and passphrase prompts should be skipped. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| -| ssh:pubkeyauthentication | A boolean indicating if public key authentication is enabled. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| -| ssh:passwordauthentication | A boolean indicating if password authentication is enabled. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored. | -| ssh:passwordsecretname | A string specifying the name of a secret stored in the [secret store](/secrets) to use as the SSH password. When set, this password will be automatically used for password authentication instead of prompting the user. <VersionBadge version="v0.13" /> | -| ssh:kbdinteractiveauthentication | A boolean indicating if keyboard interactive authentication is enabled. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored. | -| ssh:preferredauthentications | A list of strings indicating an ordering of different types of authentications. Each authentication type will be tried in order. This supports `"publickey"`, `"keyboard-interactive"`, and `"password"` as valid types. Other types of authentication are not handled and will be skipped. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| -| ssh:addkeystoagent | A boolean indicating if the keys used for a connection should be added to the ssh agent. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| -| ssh:identityagent | A string giving the path to the unix domain socket of the identity agent. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| -| ssh:proxyjump | A list of strings specifying the names of hosts that must be successively visited with tcp forwarding to establish a connection. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| -| ssh:userknownhostsfile | A list containing the paths of any user host key database files used to keep track of authorized connections. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| -| ssh:globalknownhostsfile | A list containing the paths of any global host key database files used to keep track of authorized connections. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| - -### SSH Agent Detection - -Wave resolves the identity agent path in this order: - -- If `ssh:identityagent` (or `IdentityAgent` in SSH config) is set for the connection, that socket or pipe is used. -- If not set on Windows, Wave falls back to the built-in OpenSSH agent pipe `\\.\pipe\openssh-ssh-agent`. Ensure the **OpenSSH Authentication Agent** service is running. -- If not set on macOS/Linux, Wave queries your shell environment for `SSH_AUTH_SOCK` to detect the agent path automatically. - -### Example Internal Configurations - -Here are a couple examples of things you can do using the internal configuration file `connections.json`: - -#### Hiding a Connection - -Suppose you have a connection named `github.com` in your `~/.ssh/config` file that shows up as `git@github.com` in the connections dropdown. While it does belong in the config file for authentication reasons, it makes no sense to be in the dropdown since it doesn't involve connecting to a remote environment. In that case, you can hide it as in the example below: - -```json -{ - <... other connections go here ...>, - "git@github.com" : { - "display:hidden": true - }, - <... other connections go here ...> -} -``` - -#### Moving a Connection - -Suppose you have a connection named `rarelyused` that shows up as `myusername@rarelyused:9999` in the connections dropdown. Since it's so rarely used, you would prefer to move it later in the list. In that case, you can move it as in the example below: - -```json -{ - <... other connections go here ...>, - "myusername@rarelyused:9999" : { - "display:order": 100 - }, - <... other connections go here ...> -} -``` - -#### Theming a Connection - -Suppose you have a connection named `myhost` that shows up as `myusername@myhost` in the connections dropdown. You use this connection a lot, but you keep getting it mixed up with your local connections. In this case, you can use the internal configuration file to style it differently. For example: - -```json -{ - <... other connections go here ...>, - "myusername@myhost" : { - "term:theme": "warmyellow", - "term:fontsize": 16, - "term:fontfamily": "menlo" - }, - <... other connections go here ...> -} -``` - -This style, font size, and font family will then only apply to the widgets that are using this connection. - -### Entirely Defined Internally - -Suppose you want to set up a connection but have no desire to learn the syntax of `~/.ssh/config`. In this case, you can entirely define the connection in your `connections.json` file. For example: - -```json -{ - <... other connections go here ...>, - "myusername@myhost" : { - "ssh:hostname": "190.0.2.0", - "ssh:identityfile": ["~/.ssh/myidentityfile"], - "ssh:identitiesonly": true, - "ssh:addkeystoagent": true - }, - <... other connections go here ...> -} -``` - -This will create a connection without that connection needing to be in the `~/.ssh/config` file. A couple additional options are set as well as an example of how that can be done. - -### Disabling wsh for a Connection - -While Wave provides an option disable `wsh` when first connecting to a remote, there are cases where you may wish to disable it afterward. The easiest way to do this is by editing the `connections.json` file. Suppose the connection shows up in the dropdown as `root@wshless`. Then you can disable it manually with the following line: - -```json -{ - <... other connections go here ...>, - "root@wshless" : { - "conn:enablewsh": false, - }, - <... other connections go here ...> -} -``` - -Note that this same line gets added to your `connections.json` file automatically when you choose to disable `wsh` in gui when initially connecting. - -## Managing Connections with the CLI - -The `wsh` command gives some commands specifically for interacting with the connections. You can view these [here](/wsh-reference#conn). - -## Troubleshooting Connections - -### Log Files - -If there are issues with connections, the easiest first step is to enable debugging in a terminal widget that is trying to connect. To do this, click the **<code><i className="fa-gear fa-solid fa-sharp"/></code>** button and hover over the **`Debug Connection`** item. From there you can select two log levels, `Info` and `Verbose`. After this, debug info will print out to the terminal during the connection. - -If this is not sufficient, it is also possible to view the full log file. To do this, you can run the command `wsh wavepath log` to get the location of a log file. - -### Known Limitations - -In the case that there is an error setting up `wsh`, your connection will still launch without `wsh`. However, depending on the debug info, there are a few things that can cause this. - -#### Shell Type - -Wave is capable of injecting `wsh` in the following shells: - -- bash -- zsh -- pwsh (powershell) -- fish - -If the shell is different than those, it is possible the `wsh` command will not work by default. The easiest way to fix this at the moment is the switch the shell type. This can be done by setting the `conn:shellpath` value with a path to one of the above shells in the `connections.json` file for the connection you are trying to use. Alternatively, you can use the `chsh` command to change the shell in that connection, but this will also take effect outside of wave. Once this is done, restart wave for the changes to take effect. - -#### AllowTcpForwarding in sshd - -Some systems have sshd configured to disable TCP forwarding by default. This can be found on the connection in the `/etc/ssh/sshd_config` file. In that file, search for the line containing `AllowTcpForwarding`. If this is set to `no`, it is likely the reason `wsh` will not work on your connection. In order to get `wsh` working, set the value for `AllowTcpForwarding` to either `yes` or `local` (they both provide different levels of permission but both work in this case). Then, restart the `sshd` service with whichever method your remote machine provides. Once that is done, restart wave, so it can reconnect with this change. diff --git a/docs/docs/customization.mdx b/docs/docs/customization.mdx deleted file mode 100644 index e393c8fdb9..0000000000 --- a/docs/docs/customization.mdx +++ /dev/null @@ -1,84 +0,0 @@ ---- -sidebar_position: 3.2 -id: "customization" -title: "Customization" ---- - -## Tab Themes - -![Tab Context Menu](./img/tab-context-menu.png#right) - -Right click on any tab to bring up a menu which allows you to rename the tab and select different backgrounds. - -It is also possible to create your own background themes using custom colors, gradients, images and more by editing your backgrounds.json config file. To see how Wave's built-in tab backgrounds are defined, you can check out the [default backgrounds.json file](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/backgrounds.json). - -To apply a tab background to all new tabs by default, set the key `tab:background` in your [Wave Config File](/config) to one of the background preset keys (e.g. `"bg@ocean-depths"`). The available built-in background keys can be found in the [default backgrounds.json file](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/backgrounds.json). - -## Terminal Customization - -#### Terminal Theme - -![Terminal Context Menu](./img/terminal-context-menu.png#right) - -Right click in the header area of any terminal block to bring up a menu which allows you to set a terminal -theme for that terminal. - -You can set the default theme for all terminals (which haven't had their theme manually overridden) by editing your settings.json file and adding the key `term:theme` and setting it to the appropriate key. The keys can be found -in the [default termthemes.json file](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/termthemes.json). - -If you add your own termthemes.json file in the config directory, you can also add your own custom terminal themes (just follow the same format). - -#### Font Size - -From the same context menu you can also change the font-size of the terminal. To change the default font size across all of your (non-overridden) terminals, you can set the config key `term:fontsize` to the size you want. e.g. `{ "term:fontsize": 14}`. - -#### Font Family - -There is no UI to edit your default terminal font family. But, it _can_ be overridden. In your settings.json file you can add the key `term:fontfamily` and set it to a font that is _installed_ on your local system. If type a font that is not installed, or use a non-monospace font, your terminal will look terrible (don't do that 🙂), delete the key to return to using the default. - -## Widgets Sidebar - -![Terminal Context Menu](./img/custom-widgets.png#right) - -See [Custom Widgets](/customwidgets) for detailed documentation around changing what appears in your right widget sidebar. - -Using widgets.json, you'll be able to remove any default widgets and add widgets of your own. You can fully customize the icons, colors, text, and defaults (like directories, webpages, AI model, remote connection, commands, etc.) of your custom widgets. - -You can also suppress the help widgets in the bottom right by setting the config key `widget:showhelp` to `false`. - -## Tab Backgrounds - -Wave supports powerful custom backgrounds for your tabs using images, patterns, gradients, and colors. The quickest way to set an image background is using the `wsh setbg` command: - -```bash -# Set an image background with 50% opacity (default) -wsh setbg ~/pictures/background.jpg - -# Set a color background (use quotes to prevent # being interpreted as a shell comment) -wsh setbg "#ff0000" # hex color -wsh setbg forestgreen # CSS color name - -# Adjust opacity -wsh setbg --opacity 0.3 ~/pictures/light-pattern.png -wsh setbg --opacity 0.7 # change only opacity of current background - -# Image positioning options -wsh setbg --tile ~/pictures/texture.png # create tiled pattern -wsh setbg --center ~/pictures/logo.png # center without scaling -wsh setbg --center --size 200px ~/pictures/logo.png # center with specific size (px, %, auto) - -# Remove background -wsh setbg --clear -``` - -You can use any JPEG, PNG, GIF, WebP, or SVG image as your background. The `--center` option is particularly useful for logos or icons where you want to maintain the original size. - -To preview the metadata for any background without applying it, use the `--print` flag: - -```bash -wsh setbg --print "#ff0000" -``` - -For more advanced customization options including gradients, colors, and saving your own custom backgrounds, check out our [Tab Backgrounds](/tab-backgrounds) documentation. - - diff --git a/docs/docs/customwidgets.mdx b/docs/docs/customwidgets.mdx deleted file mode 100644 index d35d1d84c2..0000000000 --- a/docs/docs/customwidgets.mdx +++ /dev/null @@ -1,428 +0,0 @@ ---- -sidebar_position: 6 -id: "customwidgets" -title: "Custom Widgets" ---- - -# Custom Widgets - -Wave allows users to create their own widgets to uniquely customize their experience for what works for them. While we plan on greatly expanding on this in the future, it is already possible to make some widgets that you can access at the press of a button. All widgets can be created by modifying the `<WAVETERM_HOME>/config/widgets.json` file. By adding a widget to this file, it is possible to add widgets to the widget bar. By default, the widget bar looks like this: -![The default widget bar](./img/all-widgets-default.webp) - -By adding additional widgets, it is possible to get a widget bar that looks like this: - -![A widget bar with custom widgets added](./img/all-widgets-extra.webp) - -## The Structure of a Widget - -All widgets share a similar structure that roughly looks like the example below: - -```json -"<widget name>": { - "icon": "<font awesome icon name>", - "label": "<the text label of the widget>", - "color": "<the color of the label>", - "blockdef": { - "meta": { - "view": "term", - "controller": "cmd", - "cmd": "<the actual cli command>" - } - } -} -``` - -This consists of a couple different parts. First and foremost, each widget has a unique identifying name. The value associated with this name is the outer `WidgetConfigType`. It is outlined in red below: - -![An example of a widget with outer keys labeled as WidgetConfigType and inner keys labeled as MetaTSType. In the example, the outer keys are icon, label, color, and blockdef. The inner keys are view, controller, and cmd.](./img/widget-example.webp) - -This `WidgetConfigType` is shared between all types of widgets. That is to say, all widgets—regardless of type— will use the same keys for this. The accepted keys are: - -| Key | Description | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| "display:order" | (optional) Overrides the order of widgets with a number in case you want the widget to be different than the order provided in the `widgets.json` file. Defaults to 0. | -| "icon" | (optional) The name of a [font awesome icon](#font-awesome-icons). Defaults to `"browser"`. | -| "color" | (optional) A string representing a color as would be used in CSS. Hex codes and custom CSS properties are included. This defaults to `"var(--secondary-text-color)"` which is a color wave uses for text that should be differentiated from other text. Out of the box, it is `"#c3c8c2"`. | -| "label" | (optional) A string representing the label that appears underneath the widget. It will also act as a tooltip on hover if the `"description"` key isn't filled out. It is null by default. | -| "description" | (optional) A description of what the widget does. If it is specified, this serves as a tooltip on hover. It is null by default. | -| "magnified" | (optional) A boolean indicating whether or not the widget should launch magnfied. It is false by default. | -| "blockdef" | This is where the the non-visual portion of the widget is defined. Note that all further definition takes place inside a meta object inside this one. | - -<a name="font-awesome-icons" /> -:::info - -**Font Awesome Icons** - -[Font Awesome](https://fontawesome.com/search) provides a ton of useful icons that you can use as a widget icon in your app. At its simplest, you can just provide the icon name and it will be used. For example, the string `"house"`, will provide an icon containing a house. We also allow you to apply a few different styles to your icon by modifying the name as follows: - -| format | description | -| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| <icon name> | The plain icon with no additional styles applied. | -| solid@<icon name> | Adds the `fa-solid` class to the icon to fill in the content with a fill color rather than leaving it a background. | -| regular@<icon name> | Adds the `fa-regular` class to the icon to ensure the content will not have a fill color and will use a standard outline instead. | -| brands@<icon name> | This is required to add the required `fa-brands` class to an icon associated with a brand. Without this, brand icons will not render properly. This will not work with icons that aren't brand icons. | - -::: - -The other options are part of the inner `MetaTSType` (outlined in blue in the image). This contains all of the details about how the widget actually works. The valid keys vary with each type of widget. They will be individually explored in more detail below. - -## Terminal and CLI Widgets - -A terminal widget, or CLI widget, is a widget that simply opens a terminal and runs a CLI command. They tend to look something like the example below: - -```json -{ - <... other widgets go here ...>, - "<widget name>": { - "icon": "<font awesome icon name>", - "label": "<the text label of the widget>", - "color": "<the color of the label>", - "blockdef": { - "meta": { - "view": "term", - "controller": "cmd", - "cmd": "<the actual cli command>" - } - } - }, - <... other widgets go here ...> -} -``` - -The `WidgetConfigType` takes the usual options common to all widgets. The `MetaTSType` can include the keys listed below: - -| Key | Description | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| "view" | A string that specifies the general type of widget. In the case of custom terminal widgets, this must be set to `"term"`. | -| "controller" | A string that specifies the type of command being used. For more persistent shell sessions, set it to "shell". For one off commands, set it to `"cmd"`. When `"cmd"` is set, the widget has an additional refresh button in its header that allows the command to be re-run. | -| "cmd" | (optional) When the `"controller"` is set to `"cmd"`, this option provides the actual command to be run. Note that because it is run as a command, there is no shell session unless you are launching a command that contains a shell session itself. Defaults to an empty string. | -| "cmd:args" | (optional, array of strings) arguments to pass to the `cmd` | -| "cmd:shell" | (optional) if cmd:shell if false (default), then we use `cmd` + `cmd:args` (suitable to pass to `execve`). if cmd:shell is true, then we just use `cmd`, and cmd can include spaces, and shell syntax (like pipes or redirections, etc.) | -| "cmd:interactive" | (optional) When the `"controller"` is set to `"term", this boolean adds the interactive flag to the launched terminal. Defaults to false. | -| "cmd:login" | (optional) When the `"controller"` is set to `"term"`, this boolean adds the login flag to the term command. Defaults to false. | -| "cmd:runonstart" | (optional) The command will rerun when the block is created or the app is started. Without it, you must manually run the command. Defaults to true. | -| "cmd:runonce" | (optional) Runs on start, but then sets "cmd:runonce" and "cmd:runonstart" to false (so future runs require manual restarts) | -| "cmd:clearonstart" | (optional) When the cmd runs, the contents of the block are cleared out. Defaults to false. | -| "cmd:closeonexit" | (optional) Automatically closes the block if the command successfully exits (exit code = 0) | -| "cmd:closeonexitforce" | (optional) Automatically closes the block if when the command exits (success or failure) | -| "cmd:closeonexitdelay | (optional) Change the delay between when the command exits and when the block gets closed, in milliseconds, default 2000 | -| "cmd:env" | (optional) A key-value object represting environment variables to be run with the command. Defaults to an empty object. | -| "cmd:cwd" | (optional) A string representing the current working directory to be run with the command. Currently only works locally. Defaults to the home directory. | -| "cmd:nowsh" | (optional) A boolean that will turn off wsh integration for the command. Defaults to false. | -| "cmd:jwt" | (optional) A boolean that forces adding JWT token to the environment. Required for running waveapps as widgets (both local and remote). Defaults to false. | -| "term:localshellpath" | (optional) Sets the shell used for running your widget command. Only works locally. If left blank, wave will determine your system default instead. | -| "term:localshellopts" | (optional) Sets the shell options meant to be used with `"term:localshellpath"`. This is useful if you are using a nonstandard shell and need to provide a specific option that we do not cover. Only works locally. Defaults to an empty string. | -| "cmd:initscript" | (optional) for "shell" controller only. an init script to run before starting the shell (can be an inline script or an absolute local file path) | -| cmd:initscript.sh" | (optional) same as `cmd:initscript` but applies to bash/zsh shells only | -| cmd:initscript.bash" | (optional) same as `cmd:initscript` but applies to bash shells only | -| cmd:initscript.zsh" | (optional) same as `cmd:initscript` but applies to zsh shells only | -| cmd:initscript.pwsh" | (optional) same as `cmd:initscript` but applies to pwsh/powershell shells only | -| cmd:initscript.fish" | (optional) same as `cmd:initscript` but applies to fish shells only | - -### Example Local Shell Widgets - -If you have multiple shells installed on your machine, there may be times when you want to use a non-default shell. For cases like this, it is easy to create a widget for each. - -Suppose you want a widget to launch a `fish` shell. Once you have `fish` installed on your system, you can define a widget as - -```json -{ - <... other widgets go here ...>, - "fish" : { - "icon": "fish", - "color": "#4abc39", - "label": "fish", - "blockdef": { - "meta": { - "view": "term", - "controller": "shell", - "term:localshellpath": "/usr/local/bin/fish", - "term:localshellopts": "-i -l" - } - } - }, - <... other widgets go here ...> -} -``` - -This adds an icon to the widget bar that you can press to launch a terminal running the `fish` shell. -![The example fish widget](./img/widget-example-fish.webp) - -:::info -It is possible that `fish` is not in your path. If this is true, using `"fish"` as the value of `"term:localshellpath"` will not work. In these cases, you will need to provide a direct path to it. This is often somewhere like `"/usr/local/bin/fish"`, but it may be different on your system. -::: - -If you want to do the same for something like Powershell Core, or `pwsh`, you can define the widget as - -```json -{ - <... other widgets go here ...>, - "pwsh" : { - "icon": "rectangle-terminal", - "color": "#2671be", - "label": "pwsh", - "blockdef": { - "meta": { - "view": "term", - "controller": "shell", - "term:localshellpath": "pwsh" - } - } - }, - <... other widgets go here ...> -} -``` - -This adds an icon to the widget bar that you can press to launch a terminal running the `pwsh` shell. -![The example pwsh widget](./img/widget-example-pwsh.webp) - -:::info -It is possible that `pwsh` is not in your path. If this is true, using `"pwsh"` as the value of `"term:localshellpath"` will not work. In these cases, you will need to provide a direct path to it. This could be somewhere like `"/usr/local/bin/pwsh"` on a Unix system or <code>"C:\\Program Files\\PowerShell\\7\\pwsh.exe"</code> on -Windows. but it may be different on your system. Also note that both `pwsh.exe` and `pwsh` work on Windows, but only `pwsh` works on Unix systems. -::: - -### Example Remote Shell Widgets - -If you want to open a terminal widget for a particular connection (SSH or WSL), you can use the `connection` meta key. The connection key's value should match connections.json (or what's in your connections dropdown menu). Note that you should only use the canonical name (do not use any custom "display:name" that you've set). For WSL that might look like `wsl://Ubuntu`, and for SSH connections that might look like `user@remotehostname`. - -```json -{ - <... other widgets go here ...>, - "remote-term": { - "icon": "rectangle-terminal", - "label": "remote", - "blockdef": { - "meta": { - "view": "term", - "controller": "shell", - "connection": "<connection>" - } - } - }, - <... other widgets go here ...> -} -``` - -### Example Cmd Widgets - -Here are a few simple cmd widgets to serve as examples. - -Suppose I want a widget that will run speedtest-go when opened. Then, I can define a widget as - -```json -{ - <... other widgets go here ...>, - "speedtest" : { - "icon": "gauge-high", - "label": "speed", - "blockdef": { - "meta": { - "view": "term", - "controller": "cmd", - "cmd": "speedtest-go --unix", - "cmd:clearonstart": true - } - } - }, - <... other widgets go here ...> -} -``` - -This adds an icon to the widget bar that you can press to launch a terminal running the `speedtest-go --unix` command. -![The example speedtest widget](./img/widget-example-speed.webp) - -Using `"cmd"` for the `"controller"` is the simplest way to accomplish this. `"cmd:clearonstart"` isn't necessary, but it makes it so every time the command is run (which can be done by right clicking the header and selecting `Force Controller Restart`), the previous contents are cleared out. - -Now suppose I wanted to run a TUI app, for instance, `dua`. Well, it turns out that you can more or less do the same thing: - -```json -{ - <... other widgets go here ...>, - "dua" : { - "icon": "brands@linux", - "label": "dua", - "blockdef": { - "meta": { - "view": "term", - "controller": "cmd", - "cmd": "dua" - } - } - }, - <... other widgets go here ...> -} -``` - -This adds an icon to the widget bar that you can press to launch a terminal running the `dua` command. -![The example speedtest widget](./img/widget-example-dua.webp) - -Because this is a TUI app that does not return anything when closed, the `"cmd:clearonstart"` option doesn't change the behavior, so it has been excluded. - -## Web Widgets - -Sometimes, it is desireable to open a page directly to a website. That can easily be accomplished by creating a custom `"web"` widget. They have the following form in general: - -```json -{ - <... other widgets go here ...>, - "<widget name>": { - "icon": "<font awesome icon name>", - "label": "<the text label of the widget>", - "color": "<the color of the label>", - "blockdef": { - "meta": { - "view": "web", - "url": "<url of the first webpage>" - } - } - }, - <... other widgets go here ...> -} -``` - -The `WidgetConfigType` takes the usual options common to all widgets. The `MetaTSType` can include the keys listed below: -| Key | Description | -|-----|-------------| -| "view" | A string that specifies the general type of widget. In the case of custom web widgets, this must be set to `"web"`.| -| "url" | This string is the url of the current page. As part of a widget, it will serve as the page the widget starts at. If not specified, this will default to the globally configurable `"web:defaulturl"` which is [https://github.com/wavetermdev/waveterm](https://github.com/wavetermdev/waveterm) on a fresh install. | -| "pinnedurl" | (optional) This string is the url the homepage button will take you to. If not specified, this will default to the globally configurable `"web:defaulturl"` which is [https://github.com/wavetermdev/waveterm](https://github.com/wavetermdev/waveterm) on a fresh install. | - -### Example Web Widgets - -Say you want a widget that automatically starts at YouTube and will use YouTube as the home page. This can be done using: - -```json -{ - <... other widgets go here ...>, - "youtube" : { - "icon": "brands@youtube", - "label": "youtube", - "blockdef": { - "meta": { - "view": "web", - "url": "https://youtube.com", - "pinnedurl": "https://youtube.com" - } - } - }, - <... other widgets go here ...> -} -``` - -This adds an icon to the widget bar that you can press to launch a web widget on the youtube homepage. -![The example speedtest widget](./img/widget-example-youtube.webp) - -Alternatively, say you want a web widget that opens to github as if it were a bookmark, but will use google as its home page after that. This can easily be done with: - -```json -{ - <... other widgets go here ...>, - "github" : { - "icon": "brands@github", - "label": "github", - "blockdef": { - "meta": { - "view": "web", - "url": "https://github.com", - "pinnedurl": "https://google.com" - } - } - }, - <... other widgets go here ...> -} -``` - -This adds an icon to the widget bar that you can press to launch a web widget on the github homepage. -![The example speedtest widget](./img/widget-example-github.webp) - -## Sysinfo Widgets - -The Sysinfo Widget is intentionally kept to a very small subset of possible values that we will expand over time. But it is still possible to configure your own version of it—for instance, if you want to load a different plot by default. The general form of this widget is: - -```json -{ - <... other widgets go here ...>, - "<widget name>": { - "icon": "<font awesome icon name>", - "label": "<the text label of the widget>", - "color": "<the color of the label>", - "blockdef": { - "meta": { - "view": "sysinfo", - "graph:numpoints": <the max number of points in the graph>, - "sysinfo:type": <the name of the plot collection>, - } - } - }, - <... other widgets go here ...> -} -``` - -The `WidgetConfigType` takes the usual options common to all widgets. The `MetaTSType` can include the keys listed below: -| Key | Description | -|-----|-------------| -| "view" | A string that specifies the general type of widget. In the case of custom sysinfo widgets, this must be set to `"sysinfo"`.| -| "graph:numpoints" | The maximum amount of points that can be shown on the graph. Equivalently, the number of seconds the graph window covers. This defaults to 100.| -| "sysinfo:type" | A string representing the collection of types to show on the graph. Valid values for this are `"CPU"`, `"Mem"`, `"CPU + Mem"`, and `All CPU`. Note that these are case sensitive. If no value is provided, the plot will default to showing `"CPU"`.| - -### Example Sysinfo Widgets - -Suppose you have a build process that lasts 3 minutes and you'd like to be able to see the entire build on the sysinfo graph. Also, you would really like to view both the cpu and memory since both are impacted by this process. In that case, you can set up a widget as follows: - -```json -{ - <... other widgets go here ...>, - "3min-info" : { - "icon": "circle-3", - "label": "3mininfo", - "blockdef": { - "meta": { - "view": "sysinfo", - "graph:numpoints": 180, - "sysinfo:type": "CPU + Mem" - } - } - }, - <... other widgets go here ...> -} -``` - -This adds an icon to the widget bar that you can press to launch the CPU and Memory plots by default with 180 seconds of data. -![The example speedtest widget](./img/widget-example-3mininfo.webp) - -Now, suppose you are fine with the default 100 points (and 100 seconds) but would like to show all of the CPU data when launched. In that case, you can write: - -```json -{ - <... other widgets go here ...>, - "all-cpu" : { - "icon": "chart-scatter", - "label": "all-cpu", - "blockdef": { - "meta": { - "view": "sysinfo", - "sysinfo:type": "All CPU" - } - } - }, - <... other widgets go here ...> -} -``` - -This adds an icon to the widget bar that you can press to launch All CPU plots by default. - -![The example speedtest widget](./img/widget-example-all-cpu.webp) - -## Overriding Default Widgets - -Wave ships with 5 default widgets in the widgets bar (terminal, files, web, ai, and sysinfo). You can modify or remove these by overriding their config in widgets.json. The names of the 5 widgets, in order, are: - -- `defwidget@terminal` -- `defwidget@files` -- `defwidget@web` -- `defwidget@ai` -- `defwidget@sysinfo` - -To remove any of them, just set that key to `null` in your widgets.json file. - -To see their definitions, to copy/paste them, or to understand how they work, you can view all of their definitions on [GitHub - default widgets.json](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/widgets.json) diff --git a/docs/docs/durable-sessions.mdx b/docs/docs/durable-sessions.mdx deleted file mode 100644 index fa112ba07d..0000000000 --- a/docs/docs/durable-sessions.mdx +++ /dev/null @@ -1,216 +0,0 @@ ---- -sidebar_position: 3.5 -id: "durable-sessions" -title: "Durable Sessions" ---- - -import { VersionBadge } from "@site/src/components/versionbadge"; - -# Durable Sessions <VersionBadge version="v0.14" /> - -Keep your remote SSH shell sessions alive through network changes, computer sleep, and Wave restarts. - -## Overview - -Durable sessions protect your terminal state when working with remote SSH connections, similar to tmux or screen but built directly into Wave. Unlike standard SSH sessions that terminate when the connection drops, durable sessions maintain your: - -- **Shell state** - Current directory, environment variables, and shell history -- **Running programs** - Background jobs and long-running commands continue executing -- **Terminal history** - Full scrollback buffer preserved across reconnections - -Durable sessions automatically reconnect when your connection is restored, picking up right where you left off. - -:::info Remote Connections Only -Durable sessions are designed for **remote SSH connections only**. Local terminals and WSL connections use standard sessions, as they're not affected by network interruptions and remain active as long as Wave is running. -::: - -## How It Works - -When you start a durable session, Wave launches a lightweight job manager on the remote server. Similar to how tmux and screen work, this manager: - -1. Keeps your shell process running independently of the Wave connection -2. Buffers terminal output while disconnected -3. Enables Wave to seamlessly reattach when you reconnect -4. Survives Wave restarts and network interruptions - -The session continues running on the remote server even if you close Wave, put your computer to sleep, or switch networks. - -## Session Status Indicator - -The shield icon in your terminal header shows the current session status: - -| Icon | Status | Description | -|------|--------|-------------| -| <i className="fa-sharp fa-regular fa-shield" style={{color: 'rgb(140, 145, 140)'}}></i> | Standard Session | Connection drops will end the session | -| <i className="fa-sharp fa-solid fa-shield" style={{color: '#0ea5e9'}}></i> | Durable (Attached) | Session is protected and connected | -| <i className="fa-sharp fa-solid fa-shield" style={{color: '#7dd3fc'}}></i> | Durable (Detached) | Session running, currently disconnected | -| <i className="fa-sharp fa-solid fa-shield" style={{color: 'rgb(140, 145, 140)'}}></i> | Durable (Awaiting) | Configured but not yet started | - -Hover over the shield icon to see detailed status information and available actions. - -## Configuration - -Durable sessions can be configured at three levels, with more specific settings overriding general ones: - -### Global Settings (Lowest Priority) - -Set the default for all SSH connections in your `settings.json`: - -```json -{ - "term:durable": true -} -``` - -### Connection Settings (Medium Priority) - -Configure durability per connection in your `connections.json`: - -```json -{ - "connections": { - "user@host": { - "term:durable": true - } - } -} -``` - -### Block Settings (Highest Priority) - -Override for individual terminal blocks through: - -- **Context Menu**: Right-click terminal → Advanced → Session Durability -- **Flyover Actions**: Click shield icon → "Restart as Durable" or "Restart as Standard" -- **Command Line**: Use `wsh setmeta term:durable=true` or `wsh setmeta term:durable=false` - -Configuration hierarchy (highest to lowest priority): -1. Block-level setting -2. Connection-level setting -3. Global setting - -### Default Behavior - -- **SSH connections**: Durable sessions disabled by default (opt-in via configuration) -- **Local terminals**: Always use standard sessions (durability not applicable) -- **WSL connections**: Always use standard sessions (durability not applicable) - -## Switching Between Modes - -### Standard to Durable - -1. Hover over the regular shield icon -2. Click **"Restart as Durable"** in the flyover -3. Your session will restart with durability enabled - -Or use the context menu: -- Right-click terminal → Advanced → Session Durability → Restart Session in Durable Mode - -### Durable to Standard - -1. Access the terminal context menu (right-click) -2. Navigate to Advanced → Session Durability -3. Select **"Restart Session in Standard Mode"** - -:::warning Switching Modes Restarts the Session -Converting between standard and durable modes requires restarting the shell. Any running processes in the current session will be terminated. -::: - -## Session States - -### Attached -Your terminal is connected to the remote session. You can interact with the shell and see real-time output. - -### Detached -Connection lost, but the session continues running on the remote server. Wave will automatically reconnect when possible. Any commands you ran continue executing. - -### Awaiting Start -Session configured for durability but not yet started. Click "Start Session" or run a command to begin. - -### Starting -Job manager is initializing on the remote server. The session will become attached shortly. - -### Ended -Session has terminated. Common reasons: -- **Exited**: Shell was closed normally (e.g., typed `exit`) -- **Lost**: Session not found on server (may have been terminated or system rebooted) -- **Failed to Start**: Job manager encountered an error during initialization - -Click "Restart Session" to start a new durable session, or "Restart as Standard" to switch modes. - -## Use Cases - -### Long-Running Commands -Start a build, deployment, or data processing job and close your laptop. The command continues executing, and you can check on it later. - -```bash -# Start a long build -./build.sh - -# Close your laptop, get coffee -# Later: reconnect and see the completed output -``` - -### Unstable Networks -Work from a cafÊ, train, or cellular connection. Brief disconnections won't terminate your session or lose your work. - -### Multiple Locations -Start work on your desktop, continue on your laptop. Your session and its state are preserved on the remote server. - -### System Maintenance -Wave updates, restarts, or crashes won't interrupt your remote work. Reconnect and resume immediately. - -## Session Lifecycle - -Durable sessions are tied to the terminal block in Wave. The session will be terminated when you: - -- **Close the block**: Closes the terminal and terminates the remote session -- **Switch connections**: Changing the connection on a block terminates the old session -- **Delete the workspace/tab**: Removes the block and terminates associated sessions - -### Cleanup Behavior - -If you close a block while **disconnected**, the remote session continues running until the next reconnection. When Wave reconnects to that server, it will automatically clean up any orphaned sessions from closed blocks. - -This ensures that remote sessions don't accumulate on your servers when you close terminals while offline. - -## Limitations - -- **Local terminals**: Not applicable (already persistent with your local machine) -- **WSL connections**: Not applicable (WSL sessions managed by Windows) -- **Network latency**: Detached sessions buffer output; reconnecting may take a moment to sync -- **Server resources**: Each durable session maintains a lightweight Go process on the remote server for session management - -## Troubleshooting - -### Session Shows as "Lost" -The session was terminated on the remote server, possibly due to: -- Server reboot -- Manual termination of the job manager process -- Remote system running out of resources - -**Solution**: Click "Restart Session" to start a new durable session. - -### Session Won't Reconnect -Verify that: -- Your SSH connection to the server is working (check the connection status) -- The job manager process is still running on the remote server - -**Try**: Right-click terminal → Advanced → Force Restart Controller - -### "Failed to Start" Error -The job manager couldn't initialize on the remote server. Check the error message for specific details. - -**Try**: Restart the session. If the issue persists, file a bug report with the error details. - -:::info Technical Details -Durable sessions use Unix domain sockets on the remote server to maintain persistent connections between the shell and Wave's job manager. The job manager process runs independently and survives SSH disconnections. -::: - -## Privacy & Security - -- Durable sessions run entirely on your remote servers -- All data is transmitted over SSH between your local Wave instance and the remote machine -- No open ports on the remote machine - communication happens through your existing SSH connection -- When disconnected, output is buffered locally on the remote machine until you reconnect -- Sessions are isolated per user and use your remote user's permissions diff --git a/docs/docs/faq.mdx b/docs/docs/faq.mdx deleted file mode 100644 index 61dc80beb4..0000000000 --- a/docs/docs/faq.mdx +++ /dev/null @@ -1,70 +0,0 @@ ---- -sidebar_position: 101 -id: "faq" -title: "FAQ" ---- - -import { VersionBadge } from "@site/src/components/versionbadge"; - -# FAQ - -### How can I see the block numbers? - -The block numbers will appear when you hold down Ctrl-Shift (and disappear once you release the key combo). - -### How do I make a remote connection? - -There is a button in the header. Click the <i className="fa-sharp fa-laptop"/> or <i className="fa-sharp fa-arrow-right-arrow-left"/> -and type the `[user]@[host]` that you wish to connect to. - -### On Windows, how can I use Git Bash as my default shell? - -Wave automatically detects Git Bash installations and adds them to the connection dropdown. Simply click the <i className="fa-sharp fa-laptop"/> or <i className="fa-sharp fa-arrow-right-arrow-left"/> button in the block header and select "Git Bash" from the list. - -Alternatively, you can manually set Git Bash as your default shell by setting the configuration variable `term:localshellpath` to -the location of the Git Bash "bash.exe" binary. By default it is located at "C:\Program Files\Git\bin\bash.exe". -Just remember in JSON, backslashes need to be escaped. So add this to your [settings.json](./config) file: - -```json -"term:localshellpath": "C:\\Program Files\\Git\\bin\\bash.exe" -``` - -### Can I use WSH outside of Wave? - -`wsh` is an internal CLI for extending control over Wave to the command line, you can learn more about it [here](./wsh). To prevent misuse by other applications, `wsh` requires an access token provided by Wave to work and will not function outside of the app. - - -## Why does Wave warn me about ARM64 translation when it launches? - -macOS and Windows both have compatibility layers that allow x64 applications to run on ARM computers. This helps more apps run on these systems while developers work to add native ARM support to their applications. However, it comes with significant performance tradeoffs. - -To get the best experience using Wave, it is recommended that you uninstall Wave and reinstall the version that is natively compiled for your computer. You can find the right version by consulting our [Installation Instructions](./gettingstarted#installation). - -You can disable this warning by setting `app:dismissarchitecturewarning=true` in [your configurations](./config). - -## How do I join the beta builds of Wave? - -Wave publishes to two channels, `latest` and `beta`. If you've installed the app for macOS, Windows, or Linux via DEB or RPM, you can set the following configurations in your `settings.json` (see [Configuration](./config) for more info): - -```json -"autoupdate:enabled": true, -"autoupdate:channel": "beta" -``` - -If you've installed via Snap, you can use the following command: - -```sh -sudo snap install waveterm --classic --beta -``` - -## Can I use Wave AI without enabling telemetry? - -<VersionBadge version="v0.13.1" noLeftMargin={true}/> - -Yes! Wave AI is normally disabled when telemetry is not enabled. However, you can enable Wave AI features without telemetry by configuring your own custom AI model (either a local model or using your own API key). - -To enable Wave AI without telemetry: -1. Configure a custom AI mode (see [Wave AI documentation](./waveai-modes)) -2. Set `waveai:defaultmode` to your custom mode's key in your Wave settings - -Once you've completed both steps, Wave AI will be enabled and you can use it completely privately without telemetry. This allows you to use local models like Ollama or your own API keys with providers like OpenAI, OpenRouter, or others. diff --git a/docs/docs/gettingstarted.mdx b/docs/docs/gettingstarted.mdx deleted file mode 100644 index 7ff961a9a9..0000000000 --- a/docs/docs/gettingstarted.mdx +++ /dev/null @@ -1,164 +0,0 @@ ---- -sidebar_position: 1 -id: "gettingstarted" -title: "Getting Started" ---- - -import { PlatformProvider, PlatformSelectorButton, PlatformItem } from "@site/src/components/platformcontext"; -import { Kbd } from "@site/src/components/kbd"; - -Wave Terminal is a modern terminal that includes graphical capabilities like web browsing, file previews, and AI assistance alongside traditional terminal features. This guide will help you get started. - -## Installation - -<PlatformProvider> - <PlatformSelectorButton /> - -### Platform requirements - -<PlatformItem platforms={["mac"]}> - -- Supported architectures: Apple Silicon, x64 -- Supported OS version: macOS 11 Big Sur or later - -</PlatformItem> - -<PlatformItem platforms={["windows"]}> - -- Supported architectures: x64 -- Supported OS version: Windows 10 1809 or later, Windows 11 - -:::note - -ARM64 is planned, but is currently blocked by upstream dependencies (see [Windows ARM Support](https://github.com/wavetermdev/waveterm/issues/928)). - -::: - -</PlatformItem> - -<PlatformItem platforms={["linux"]}> - -- Supported architectures: x64, ARM64 -- Supported OS version: must have glibc-2.28 or later (Debian >=10, RHEL >=8, Ubuntu >=20.04, etc.) - -</PlatformItem> - -### Package managers - -<PlatformItem platforms={["mac"]}> - -#### Homebrew - -```bash -brew install --cask wave -``` - -</PlatformItem> - -<PlatformItem platforms={["windows"]}> - -#### Windows Package Manager - -```powershell -winget install CommandLine.Wave -``` - -#### Chocolatey - -```powershell -choco install wave -``` - -</PlatformItem> - -<PlatformItem platforms={["linux"]}> - -#### Snap - -```bash -sudo snap install --classic waveterm -``` - -Other options available: [AUR package](https://aur.archlinux.org/packages/waveterm) (community maintained), [Nix package](https://search.nixos.org/packages?channel=unstable&show=waveterm) (community maintained) - -</PlatformItem> - -You can also download installers directly from our [Downloads page](https://www.waveterm.dev/download). - -## Core Concepts - -### Tabs and Blocks - -- **Tabs**: Like browser tabs, these help organize your work. Create new tabs with <Kbd k="Cmd:t"/>. -- **Blocks**: The building blocks of Wave. Each block can be a terminal, web browser, file preview, or other widget. -- **Layout**: Blocks can be dragged, dropped, and resized to create your ideal layout. - -### Key Features - -1. **Terminal Features** - - - Works with common shells (bash, zsh, fish) - - Supports standard terminal features (readline, control sequences, etc) - - Includes the `wsh` command for interacting with Wave's GUI features - - GPU accelerated (on most platforms) - -2. **Graphical Widgets** - - - Preview files (images, video, markdown, code with syntax highlighting) - - Browse web pages - - Ask questions and get AI help directly from the terminal (set up multiple AI models) - - Basic system monitoring graphs - -3. **Remote Connections** - - Easy SSH connections with the connection button <i className="fa-sharp fa-laptop"/> - - WSL integration on Windows - - Consistent experience across local and remote sessions - -## Quick Start Guide - -1. **Open Your First New Tab** - - - New Wave tabs start with a single terminal block - - Use it just like your regular terminal - - Create additional terminal blocks with <Kbd k="Cmd:n"/> - -2. **Try Some Basic Commands** - - ```bash - # View a file or directory - wsh view ~/Documents - - # Open a webpage - wsh web open github.com - - # Get AI assistance - wsh ai -m "how do I find large files in my current directory?" -s - ``` - -3. **Customize Your Layout** - - - Drag block headers to rearrange them - - Hover between blocks to resize them - - Right-click tab headers for background options - - Right-click block headers for block-specific options - -4. **Connect to Remote Machines** - - Click the <i className="fa-sharp fa-laptop"/> button - - Enter `username@hostname` for SSH connections - - Or select a WSL distribution on Windows - -## Next Steps - -- Explore [Key Bindings](./keybindings) to work more efficiently -- Learn about [Tab Layouts](./layout) to organize your workspace -- Set up [Custom Widgets](./customwidgets) for quick access to your tools -- Configure [Wave AI](./waveai) to use your preferred AI models -- Check out [Configuration](./config) for detailed customization options - -## Getting Help - -- Join our [Discord community](https://discord.gg/XfvZ334gwU) for help and discussions -- Report issues on [GitHub](https://github.com/wavetermdev/waveterm/issues) -- Check our [FAQ](./faq) for common questions - -</PlatformProvider> diff --git a/docs/docs/img/ai-presets.png b/docs/docs/img/ai-presets.png deleted file mode 100644 index 822d70118e..0000000000 Binary files a/docs/docs/img/ai-presets.png and /dev/null differ diff --git a/docs/docs/img/all-widgets-default.png b/docs/docs/img/all-widgets-default.png deleted file mode 100644 index 3699256c07..0000000000 Binary files a/docs/docs/img/all-widgets-default.png and /dev/null differ diff --git a/docs/docs/img/all-widgets-default.webp b/docs/docs/img/all-widgets-default.webp deleted file mode 100644 index fb0bfa2870..0000000000 Binary files a/docs/docs/img/all-widgets-default.webp and /dev/null differ diff --git a/docs/docs/img/all-widgets-extra.png b/docs/docs/img/all-widgets-extra.png deleted file mode 100644 index ea028ba481..0000000000 Binary files a/docs/docs/img/all-widgets-extra.png and /dev/null differ diff --git a/docs/docs/img/all-widgets-extra.webp b/docs/docs/img/all-widgets-extra.webp deleted file mode 100644 index 612d4cdf1c..0000000000 Binary files a/docs/docs/img/all-widgets-extra.webp and /dev/null differ diff --git a/docs/docs/img/backgrounds-menu.png b/docs/docs/img/backgrounds-menu.png deleted file mode 100644 index 6b8f95e7e1..0000000000 Binary files a/docs/docs/img/backgrounds-menu.png and /dev/null differ diff --git a/docs/docs/img/block-drag-example.jpg b/docs/docs/img/block-drag-example.jpg deleted file mode 100644 index 7fa76e15f5..0000000000 Binary files a/docs/docs/img/block-drag-example.jpg and /dev/null differ diff --git a/docs/docs/img/connection-dropdown.png b/docs/docs/img/connection-dropdown.png deleted file mode 100644 index 688403fe3d..0000000000 Binary files a/docs/docs/img/connection-dropdown.png and /dev/null differ diff --git a/docs/docs/img/custom-widgets.png b/docs/docs/img/custom-widgets.png deleted file mode 100644 index ec7654511d..0000000000 Binary files a/docs/docs/img/custom-widgets.png and /dev/null differ diff --git a/docs/docs/img/drag-edge.png b/docs/docs/img/drag-edge.png deleted file mode 100644 index 3a6113580f..0000000000 Binary files a/docs/docs/img/drag-edge.png and /dev/null differ diff --git a/docs/docs/img/drag-swap.png b/docs/docs/img/drag-swap.png deleted file mode 100644 index 3980e75317..0000000000 Binary files a/docs/docs/img/drag-swap.png and /dev/null differ diff --git a/docs/docs/img/node-resize.png b/docs/docs/img/node-resize.png deleted file mode 100644 index f702b6f3da..0000000000 Binary files a/docs/docs/img/node-resize.png and /dev/null differ diff --git a/docs/docs/img/tab-context-menu.png b/docs/docs/img/tab-context-menu.png deleted file mode 100644 index d704e2b6a9..0000000000 Binary files a/docs/docs/img/tab-context-menu.png and /dev/null differ diff --git a/docs/docs/img/terminal-context-menu.png b/docs/docs/img/terminal-context-menu.png deleted file mode 100644 index 342159548c..0000000000 Binary files a/docs/docs/img/terminal-context-menu.png and /dev/null differ diff --git a/docs/docs/img/wave-screenshot.webp b/docs/docs/img/wave-screenshot.webp deleted file mode 100644 index 372ff1700c..0000000000 Binary files a/docs/docs/img/wave-screenshot.webp and /dev/null differ diff --git a/docs/docs/img/waveai-model-dropdown.png b/docs/docs/img/waveai-model-dropdown.png deleted file mode 100644 index 2d512d7cd4..0000000000 Binary files a/docs/docs/img/waveai-model-dropdown.png and /dev/null differ diff --git a/docs/docs/img/widget-example-3mininfo.webp b/docs/docs/img/widget-example-3mininfo.webp deleted file mode 100644 index 0582cccb65..0000000000 Binary files a/docs/docs/img/widget-example-3mininfo.webp and /dev/null differ diff --git a/docs/docs/img/widget-example-all-cpu.webp b/docs/docs/img/widget-example-all-cpu.webp deleted file mode 100644 index 499b856ec9..0000000000 Binary files a/docs/docs/img/widget-example-all-cpu.webp and /dev/null differ diff --git a/docs/docs/img/widget-example-dua.webp b/docs/docs/img/widget-example-dua.webp deleted file mode 100644 index 403c808f33..0000000000 Binary files a/docs/docs/img/widget-example-dua.webp and /dev/null differ diff --git a/docs/docs/img/widget-example-fish.webp b/docs/docs/img/widget-example-fish.webp deleted file mode 100644 index 0819ce981d..0000000000 Binary files a/docs/docs/img/widget-example-fish.webp and /dev/null differ diff --git a/docs/docs/img/widget-example-github.webp b/docs/docs/img/widget-example-github.webp deleted file mode 100644 index e80079cf83..0000000000 Binary files a/docs/docs/img/widget-example-github.webp and /dev/null differ diff --git a/docs/docs/img/widget-example-pwsh.webp b/docs/docs/img/widget-example-pwsh.webp deleted file mode 100644 index 3eb46f1e73..0000000000 Binary files a/docs/docs/img/widget-example-pwsh.webp and /dev/null differ diff --git a/docs/docs/img/widget-example-speed.webp b/docs/docs/img/widget-example-speed.webp deleted file mode 100644 index 1f53c469ec..0000000000 Binary files a/docs/docs/img/widget-example-speed.webp and /dev/null differ diff --git a/docs/docs/img/widget-example-youtube.webp b/docs/docs/img/widget-example-youtube.webp deleted file mode 100644 index 130f694fc5..0000000000 Binary files a/docs/docs/img/widget-example-youtube.webp and /dev/null differ diff --git a/docs/docs/img/widget-example.webp b/docs/docs/img/widget-example.webp deleted file mode 100644 index d972e96c7c..0000000000 Binary files a/docs/docs/img/widget-example.webp and /dev/null differ diff --git a/docs/docs/img/workspace-switcher.png b/docs/docs/img/workspace-switcher.png deleted file mode 100644 index 1f651e86c3..0000000000 Binary files a/docs/docs/img/workspace-switcher.png and /dev/null differ diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx deleted file mode 100644 index f1665faae8..0000000000 --- a/docs/docs/index.mdx +++ /dev/null @@ -1,100 +0,0 @@ ---- -sidebar_position: -1 -id: "index" -title: "Home" -hide_title: true -hide_table_of_contents: true ---- - -import { Card, CardGroup } from "@site/src/components/card"; - -# Welcome to Wave Terminal - -Wave is an [open-source](https://github.com/wavetermdev/waveterm) terminal that combines traditional terminal features with graphical capabilities like file previews, web browsing, and AI assistance. It runs on MacOS, Linux, and Windows. - -Modern development involves constantly switching between terminals and browsers - checking documentation, previewing files, monitoring systems, and using AI tools. Wave brings these graphical tools directly into the terminal, letting you control them from the command line. This means you can stay in your terminal workflow while still having access to the visual interfaces you need. - -Check out [Getting Started](./gettingstarted) for installation instructions. - -![Wave Screenshot](./img/wave-screenshot.webp) - -<CardGroup> - <Card - href="./waveai" - icon="fa-sparkles" - title="Wave AI" - description="Context-aware terminal assistant with access to terminal output, widgets, and filesystem." - /> - <Card - href="./customization" - icon="fa-paintbrush" - title="Customization" - description="Set up tabs and terminals to match your workflow needs." - /> - <Card - href="./keybindings" - icon="fa-keyboard" - title="Key Bindings" - description="Boost efficiency with keyboard shortcuts for faster navigation." - /> - <Card - href="./layout" - icon="fa-grid-2" - title="Layout" - description="Organize your workspace using our layout system." - /> - <Card - href="./connections" - icon="fa-network-wired" - title="Remote Connections" - description="Quickly SSH or connect to remote machines in one step." - /> - <Card - href="./widgets" - icon="fa-rocket" - title="Widgets" - description="Explore built-in tools to extend your terminal’s functionality." - /> - <Card - href="./wsh" - icon="fa-rectangle-terminal" - title="wsh Command" - description="Control Wave and launch widgets directly from the command line." - /> -</CardGroup> - -<div style={{ marginBottom: 30 }} /> - -:::info - -If you have a question, please feel free to ask us in [Discord](https://discord.gg/XfvZ334gwU). If you'd like to file a bug/enchancement, please use [Github Issues](https://github.com/wavetermdev/waveterm/issues). These docs are also open-source and we do accept PRs for docs [here](https://github.com/wavetermdev/waveterm/blob/main/docs). You can click the "Edit this page" link at the bottom of the page to get taken directly to the editor page for that document in GitHub. - -::: - -<div className="reference-links"> - -Other References: - -- [Configuration](./config) -- [Custom Widgets](./customwidgets) -- [Full wsh reference](./wsh-reference) -- [Telemetry](./telemetry) -- [FAQ](./faq) -- [Release Notes](./releasenotes) - -</div> - -## Roadmap - -Want to provide input to our future releases? Connect with us on [Discord](https://discord.gg/XfvZ334gwU) or open a [Feature Request](https://github.com/wavetermdev/waveterm/issues/new/choose)! - -## Links - -- **Homepage** https://waveterm.dev -- **Download** https://waveterm.dev/download -- **Discord** https://discord.gg/XfvZ334gwU -- **GitHub** https://github.com/wavetermdev/waveterm/ - -## Looking for WaveLegacy documentation? - -WaveLegacy docs can be found at [legacydocs.waveterm.dev](https://legacydocs.waveterm.dev). diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx deleted file mode 100644 index d5d88856e8..0000000000 --- a/docs/docs/keybindings.mdx +++ /dev/null @@ -1,122 +0,0 @@ ---- -sidebar_position: 2 -id: "keybindings" -title: "Key Bindings" ---- - -import { Kbd, KbdChord } from "@site/src/components/kbd"; -import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; -import { VersionBadge } from "@site/src/components/versionbadge"; - -<PlatformProvider> - -Here's the set of default keybindings available in Wave. It is split into sections. -Some keybindings are always active. Others are only active for certain types of blocks. - -Note that these are the MacOS keybindings (they use "Cmd"). For Windows and Linux, -replace "Cmd" with "Alt" (note that "Ctrl" is "Ctrl" on both Mac, Windows, and Linux). - -Chords are shown with a + between the keys. You have 2 seconds to hit the 2nd chord key after typing the first key. Hitting Escape after an initial chord key will always be a no-op. - -## Global Keybindings - -<PlatformSelectorButton /> -<div style={{ marginBottom: 20 }}></div> - -| Key | Function | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| <Kbd k="Cmd:t"/> | Open a new tab | -| <Kbd k="Cmd:n"/> | Open a new block (defaults to a terminal block with the same connection and working directory). Switch to launcher using `app:defaultnewblock` setting | -| <Kbd k="Cmd:Shift:a"/> | Toggle WaveAI panel visibility | -| <Kbd k="Cmd:d"/> | Split horizontally, open a new block to the right | -| <Kbd k="Cmd:Shift:d"/> | Split vertically, open a new block below | -| <KbdChord karr={["Ctrl:Shift:s", "ArrowUp"]}/> | Split vertically, open a new block above | -| <KbdChord karr={["Ctrl:Shift:s", "ArrowDown"]}/> | Split vertically, open a new block below | -| <KbdChord karr={["Ctrl:Shift:s", "ArrowLeft"]}/> | Split horizontally, open a new block to the left | -| <KbdChord karr={["Ctrl:Shift:s", "ArrowRight"]}/> | Split horizontally, open a new block to the right | -| <Kbd k="Cmd:Shift:n"/> | Open a new window | -| <Kbd k="Cmd:w"/> | Close the current block | -| <Kbd k="Cmd:Shift:w"/> | Close the current tab | -| <Kbd k="Cmd:m"/> | Magnify / Un-Magnify the current block | -| <Kbd k="Cmd:g"/> | Open the "connection" switcher | -| <Kbd k="Cmd:i"/> | Refocus the current block (useful if the block has lost input focus) | -| <Kbd k="Ctrl:Shift"/> | Show block numbers | -| <Kbd k="Ctrl:Shift:0"/> | Focus WaveAI input | -| <Kbd k="Ctrl:Shift:1-9"/> | Switch to block number | -| <Kbd k="Ctrl:Shift:Arrows"/> / <Kbd k="Ctrl:Shift:h/j/k/l"/> | Move left, right, up, down between blocks | -| <Kbd k="Ctrl:Shift:x"/> | Replace the current block with a launcher block | -| <Kbd k="F2"/> | Rename the current tab <VersionBadge version="v0.15" /> | -| <Kbd k="Cmd:1-9"/> | Switch to tab number | -| <Kbd k="Cmd:["/> / <Kbd k="Shift:Cmd:["/> | Switch tab left | -| <Kbd k="Cmd:]"/> / <Kbd k="Shift:Cmd:]"/> | Switch tab right | -| <Kbd k="Cmd:Ctrl:1-9"/> | Switch to workspace number | -| <Kbd k="Cmd:Shift:r"/> | Refresh the UI | -| <Kbd k="Ctrl:Shift:i"/> | Toggle terminal multi-input mode | - -## File Preview Keybindings - -| Key | Function | -| ----------------------------------------- | -------------------------------------------------------------------------------------------------- | -| <Kbd k="[text]"/> | Any regular character (e.g. "a", "b") will filter the file list | -| <Kbd k="Escape"/> | Clears the filter | -| <Kbd k="ArrowUp"/> / <Kbd k="ArrowDown"/> | Change file selection up/down | -| <Kbd k="Enter"/> | Open the currently selected file/directory | -| <Kbd k="Cmd:ArrowUp"/> | Move "up" a directory (parent directory) | -| <Kbd k="Cmd:ArrowLeft"/> | Back, move to the previously selected file/directory | -| <Kbd k="Cmd:ArrowRight"/> | Forward (opposite of back) | -| <Kbd k="Cmd:o"/> | Open a new file (accepts relative paths to the current directory) | -| <Kbd k="Cmd:s"/> | When file editor is open, save file | -| <Kbd k="Cmd:e"/> | For files that can be previewed or edited (markdown, CSVs), switches between preview and edit mode | -| <Kbd k="Cmd:r"/> | When file editor is open, revert changes | - -## Web Keybindings - -| Key | Function | -| ------------------------- | ------------------------------------------------------------- | -| <Kbd k="Cmd:l"/> | Focus the URL input bar | -| <Kbd k="Escape"/> | When the URL input bar is focused, will focus the web content | -| <Kbd k="Cmd:r"/> | Reload webpage | -| <Kbd k="Cmd:ArrowLeft"/> | Back | -| <Kbd k="Cmd:ArrowRight"/> | Forward | -| <Kbd k="Cmd:f"/> | Find in webpage | -| <Kbd k="Cmd:o"/> | Open a bookmark | - -## WaveAI Keybindings - -| Key | Function | -| ----------------------- | ----------------------- | -| <Kbd k="Cmd:Shift:a"/> | Toggle WaveAI panel | -| <Kbd k="Ctrl:Shift:0" windows="Alt:0"/> | Focus WaveAI input | -| <Kbd k="Cmd:k"/> | Clear AI Chat | - -## Terminal Keybindings - -| Key | Function | -| ----------------------- | ---------------------------- | -| <Kbd k="Ctrl:Shift:c"/> | Copy | -| <Kbd k="Ctrl:Shift:v"/> | Paste | -| <Kbd k="Ctrl:v" mac="N/A" linux="N/A"/> | Paste (Windows Only) | -| <Kbd k="Cmd:k"/> | Clear Terminal | -| <Kbd k="Cmd:f"/> | Find in Terminal | -| <Kbd k="Shift:Home"/> | Scroll to top | -| <Kbd k="Shift:End"/> | Scroll to bottom | -| <Kbd k="Cmd:Home" windows="N/A" linux="N/A"/> | Scroll to top (macOS only) | -| <Kbd k="Cmd:End" windows="N/A" linux="N/A"/> | Scroll to bottom (macOS only)| -| <Kbd k="Cmd:ArrowLeft" windows="N/A" linux="N/A"/> | Move to beginning of line (macOS only) | -| <Kbd k="Cmd:ArrowRight" windows="N/A" linux="N/A"/> | Move to end of line (macOS only) | -| <Kbd k="Shift:PageUp"/> | Scroll up one page | -| <Kbd k="Shift:PageDown"/>| Scroll down one page | - -## Process Viewer Keybindings - -| Key | Function | -| ----------------------- | ------------------------------------- | -| <Kbd k="Space"/> | Pause / resume live updates | -| <Kbd k="Cmd:f"/> | Open process filter / search | -| <Kbd k="Escape"/> | Close search bar | - -## Customizeable Systemwide Global Hotkey - -Wave allows setting a custom global hotkey to focus your most recent window from anywhere in your computer. For more information on this, see [the config docs](./config#customizable-systemwide-global-hotkey). - -</PlatformProvider> diff --git a/docs/docs/layout.mdx b/docs/docs/layout.mdx deleted file mode 100644 index dcad9e32ee..0000000000 --- a/docs/docs/layout.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -sidebar_class_name: hidden -id: "layout" ---- - -import { Redirect } from "@docusaurus/router"; - -<Redirect to="/tabs#tab-layout-system" /> - -<!-- The contents of this page has moved to the tabs.mdx file under the "Tab Layout System" section. --> diff --git a/docs/docs/releasenotes.mdx b/docs/docs/releasenotes.mdx deleted file mode 100644 index 987be81534..0000000000 --- a/docs/docs/releasenotes.mdx +++ /dev/null @@ -1,698 +0,0 @@ ---- -id: "releasenotes" -title: "Release Notes" -sidebar_position: 200 ---- - -# Release Notes - -### v0.14.5 — Apr 16, 2026 - -Wave v0.14.5 introduces a new Process Viewer widget, Quake Mode for global hotkey, and several quality-of-life improvements. - -- **Process Viewer** - New widget that displays running processes on local and remote machines, with CPU and memory usage, sortable columns, and the ability to send signals to processes -- **Quake Mode** - The global hotkey (`app:globalhotkey`) now toggles a Wave window visible and invisible -- **[bugfix] Settings Widget** - Fixed a bug where config files that didn't exist yet couldn't be created or edited from the Settings widget UI -- **Drag & Drop Files into Terminal** - Drag files from Finder (macOS) or your file manager into a terminal block to paste their quoted path ([#746](https://github.com/wavetermdev/waveterm/issues/746)) -- New opt-in `app:showsplitbuttons` setting adds horizontal/vertical split buttons to block headers -- Toggle the widgets sidebar from the View menu; visibility persists per workspace -- F2 to rename the active tab -- Mouse button 3/4 (back/forward) now navigate in web widgets -- Terminal sessions now set `COLORTERM=truecolor` for better color support in CLI tools -- [bugfix] Trim trailing whitespace from terminal clipboard copies -- Package updates and dependency upgrades - -### v0.14.4 — Mar 26, 2026 - -Wave v0.14.4 introduces vertical tabs, upgrades to xterm.js v6, and includes a collection of bug fixes and internal improvements. - -**Vertical Tab Bar:** - -- **New Vertical Tab Bar Option** - Tabs can now be displayed vertically along the side of the window, giving you more horizontal space and easier access to tabs when you have many open. Toggle between horizontal and vertical tab layouts in settings. - -**Terminal Improvements:** - -- **xterm.js v6.0.0 Upgrade** - Upgraded to the latest xterm.js v6, bringing improved terminal compatibility and rendering. This should resolve various terminal rendering quirks observed with tools like Claude Code. - -**Other Changes:** - -- **`backgrounds.json`** - Renamed `presets/bg.json` to `backgrounds.json` and moved background config to new `tab:background` key (auto-migrated on startup) -- **Config Errors Moved** - Config errors removed from the tab bar and moved to Settings / WaveConfig view for less clutter -- **Warn on Unsaved Changes** - WaveConfig view now warns before discarding unsaved changes -- **Stream Performance** - Migrated file streaming to new modern interface with flow control, fixing a large time-to-first-byte streaming bug -- **macOS First Click** - Improved first-click handling on macOS (cancel the click but properly set block/WaveAI focus) -- Deprecated legacy AI widget has been removed -- [bugfix] Fixed focus bug for newly created blocks -- [bugfix] Fixed an issue around starting a new durable session by splitting an old one -- Electron upgraded to v41 -- Package updates and dependency upgrades - -### v0.14.2 — Mar 12, 2026 - -Wave v0.14.2 adds block/tab badges, directory preview improvements, and assorted bug fixes. - -**Block/Tab Badges:** - -- **Block Level Badges, Rolled up to Tabs** - Blocks can now display icon badges (with color and priority) that roll up and are visible in the tab bar for at-a-glance status -- **Bell Indicator Enabled by Default** - Terminal bell badge is now on by default, lighting up the block and tab when your terminal rings the bell (controlled with `term:bellindicator`) -- **`wsh badge`** - New `wsh badge` command to set or clear badges on blocks from the command line. Supports icons, colors, priorities, beep, and PID-linked badges that auto-clear when a process exits. Great for use with Claude Code hooks to surface notifications in the tab bar ([docs](https://docs.waveterm.dev/wsh-reference#badge)) - -**Other Changes:** - -- **Directory Preview Improvements** - Improved mod time formatting, zebra-striped rows, better default sort, YAML file support, and context menu improvements -- **Search Bar** - Clipboard and focus improvements in the search bar -- [bugfix] Fixed "New Window" hanging/not working on GNOME desktops -- [bugfix] Fixed "Save Session As..." (focused window tracking bug) -- [bugfix] Zoom change notifications were not being properly sent to all tabs (layout inconsistencies) -- Added a Release Notes link in the settings menu -- Working on anthropic-messages Wave AI backend (for native Claude integration) -- Lots of internal work on testing/mock infrastructure to enable quicker async AI edits -- Documention updates -- Package updates and dependency upgrades - -### v0.14.1 — Mar 3, 2026 - -Wave v0.14.1 fixes several high-impact terminal bugs (Claude Code scrolling, IME input) and adds new config options: focus-follows-cursor, cursor style customization, workspace-scoped widgets, and vim-style block navigation. - -**Terminal Improvements:** - -- **Claude Code Scroll Fix** - Fixed a long-standing bug that caused terminal windows to jump to the top unexpectedly, affecting many Claude Code users -- **IME Fix** - Fixed Korean/CJK input where characters were lost or stuck in composition and only committed on Space -- **Scroll Position Preserved on Resize** - Terminal now stays scrolled to the bottom across resizes when it was already at the bottom -- **Better Link Handling** - Terminal URLs now have improved context menus and tooltips for easier navigation -- **Terminal Scrollback Save** - New context menu item and `wsh` command to save terminal scrollback to a file - -**New Features:** - -- **Focus Follows Cursor** - New `app:focusfollowscursor` setting (off/on/term) for hover-based block focus -- **Terminal Cursor Style & Blink** - New settings for cursor style (block/bar/underline) and blink, configurable per-block -- **Tab Close Confirmation** - New `tab:confirmclose` setting to prompt before closing a tab -- **Workspace-Scoped Widgets** - New optional `workspaces` field in `widgets.json` to show/hide widgets per-workspace -- **Vim-Style Block Navigation** - Added Ctrl+Shift+H/J/K/L to navigate between blocks -- **New AI Providers** - Added Groq and NanoGPT as built-in AI provider presets - -**Other Changes:** - -- Fixed intermittant bugs with connection switching in terminal blocks -- Widgets.json schema improvements for better editor validation -- Package updates and dependency upgrades -- Internal code cleanup and refactoring - -### v0.14.0 — Feb 10, 2026 - -**Durable SSH Sessions and Enhanced Connection Monitoring** - -Wave v0.14 introduces Durable Sessions for SSH connections, allowing your remote terminal sessions to survive connection interruptions, network changes, and Wave restarts. This release also includes major improvements to connection monitoring, RPC infrastructure with flow control, and expanded terminal capabilities. - -**Durable Sessions (Remote SSH Only):** -- **Survive Interruptions** - SSH terminal sessions persist through network changes, computer sleep, and Wave restarts, automatically reconnecting when the connection is restored -- **Session Protection** - Shell state, running programs, and terminal history are maintained even when Wave is closed or disconnected -- **Visual Status Indicators** - Shield icons in terminal headers show session status (Standard, Durable Attached, Durable Detached, Durable Awaiting) with detailed flyover information -- **Flexible Configuration** - Configure at global, per-connection, or per-block level with easy switching between standard and durable modes -- See the new [Durable Sessions documentation](https://docs.waveterm.dev/durable-sessions) for setup and usage - -**Enhanced Connection Monitoring:** -- **Connection Keepalives** - Active monitoring of SSH connections with automatic keepalive probes -- **Stalled Connection Detection** - New connection monitor detects and displays "stalled" connection states when network issues occur, providing clear visual feedback -- **Better Error Handling** - Improved connection status tracking and user-facing connection state indicators - -**Terminal Improvements:** -- **OSC 52 Clipboard Support** - Terminal applications can now copy directly to your system clipboard using OSC 52 escape sequences -- **Enhanced Context Menu** - Right-click terminals for quick access to splits, URL opening, themes, file browser, and more -- **Streamlined Header Layout** - Terminal headers now focus on connection info without redundant view type labels - -**Wave AI Updates:** -- **Image/Vision Support** - Added image support for OpenAI chat completions API, enabling vision capabilities with compatible models -- **Stop Generation** - New ability to stop AI responses mid-generation across OpenAI and Gemini backends -- **AI Panel Scroll Latch** - Improved auto-scrolling behavior in Wave AI panel -- **Configurable Verbosity** - Control verbosity levels for OpenAI Responses API -- Deprecated old AI-widget proxy endpoint - -**RPC and Performance:** -- **RPC Streaming with Flow Control** - New streaming primitives with built-in flow control for better performance and reliability -- **WSH Router Refactor** - Major routing architecture improvements to prevent hangs on connection interruptions -- **RPC Client/Server Cleanup** - Improved RPC implementation and error handling - -**Configuration Updates:** -- **Hide AI Button** - New `app:hideaibutton` setting to hide the AI button from the UI -- **Disable Ctrl+Shift Arrows** - New `app:disablectrlshiftarrows` setting for keyboard shortcut conflicts -- **Disable Ctrl+Shift Display** - New `app:disablectrlshiftdisplay` setting to disable overlay block numbers - -**Breaking Changes:** -- **Removed Pinned Tabs** - Pinned tabs feature has been removed from the UI -- **Removed S3 and WaveFile** - S3 filesystem and wavefile implementations removed to prevent large/recursive file transfer issues and simplify codebase - -**Other Changes:** -- **Confirm on Quit** - Added confirmation dialog when closing Wave with active sessions -- Monaco Editor upgrade removing `monaco-editor/loader` and `monaco-editor/react` dependencies for better performance and stability -- New Tab model with React provider for improved state management -- Removed OSC 23198 and OSC 9283 legacy handlers -- Updated contribution guidelines -- Upgraded Go toolchain to 1.25.6 -- Enhanced OpenAI-compatible API provider documentation -- [bugfix] Fixed empty data handling in sysinfo view -- [bugfix] Fixed `app:ctrlvpaste` setting on Windows (can now be disabled) -- [bugfix] Fixed duplicated Wave AI system prompt for some providers -- [bugfix] Fixed disconnect hanging issue - disconnects now happen immediately -- [bugfix] Fixed tool approval lifecycle to match SSE connection timing -- [bugfix] Increased WSL connection timeout to handle slow initial WSL startup -- [bugfix] Improved terminal shutdown with SIGHUP for graceful shell exit -- Package updates and dependency upgrades - -### v0.13.1 — Dec 16, 2025 - -**Windows Improvements and Wave AI Enhancements** - -This release focuses on significant Windows platform improvements, Wave AI visual updates, and better flexibility for local AI usage. - -**Windows Platform Enhancements:** -- **Integrated Window Layout** - Removed separate title bar and menu bar on Windows, integrating controls directly into the tab-bar header for a cleaner, more unified interface -- **Git Bash Auto-Detection** - Wave now automatically detects Git Bash installations and adds them to the connection dropdown for easy access -- **SSH Agent Fallback** - Improved SSH agent support with automatic fallback to `\\.\pipe\openssh-ssh-agent` on Windows -- **Updated Focus Keybinding** - Wave AI focus key changed to Alt:0 on Windows for better consistency -- **Config Schemas** - Improved configuration validation and schema support -- Ctrl-V now works as standard paste in terminal on Windows - -**Wave AI Updates:** -- **Refreshed Visual Design** - Complete UI refresh removing blue accents and adding transparency support for better integration with custom backgrounds -- **BYOK Without Telemetry** - Wave AI now works with bring-your-own-key and local models without requiring telemetry to be enabled -- [bugfix] Fixed tool type "function" compatibility with providers like Mistral - -**Terminal Improvements:** -- **New Scrolling Keybindings** - Added Shift+Home, Shift+End, Shift+PageUp, and Shift+PageDown for better terminal navigation - -**Other Changes:** -- Package updates and dependency upgrades - -### v0.13.0 — Dec 8, 2025 - -**Wave v0.13 Brings Local AI Support, BYOK, and Unified Configuration** - -Wave v0.13 is a major release that opens up Wave AI to local models, third-party providers, and bring-your-own-key (BYOK) configurations. This release also includes a completely redesigned configuration system and several terminal improvements. - -**Local AI & BYOK Support:** -- **OpenAI-Compatible API** - Wave now supports any provider or local server using the `/v1/chat/completions` endpoint, enabling use of Ollama, LM Studio, vLLM, OpenRouter, and countless other local and hosted models -- **Google Gemini Integration** - Native support for Google's Gemini models with a dedicated API adapter -- **Provider Presets** - Simplified configuration with built-in presets for OpenAI, OpenRouter, Google, Azure, and custom endpoints -- **Multiple AI Modes** - Easily switch between different models and providers with a unified interface -- See the new [Wave AI Modes documentation](https://docs.waveterm.dev/waveai-modes) for configuration examples and setup guides - -**Unified Configuration Widget:** -- **New Config Interface** - Replaced the basic JSON editor with a dedicated configuration widget accessible from the sidebar -- **Better Organization** - Browse and edit different configuration types (general settings, AI modes, secrets) with improved validation and error handling -- **Integrated Secrets Management** - Access Wave's secret store directly from the config widget for secure credential management - -**Terminal Improvements:** -- **Bracketed Paste Mode** - Now enabled by default to improve multi-line paste behavior and compatibility with tools like Claude Code -- **Windows Paste Fix** - Ctrl+V now works as a standard paste accelerator on Windows -- **SSH Password Management** - Store SSH connection passwords in Wave's secret store to avoid re-typing credentials - -**Other Changes:** -- Package updates and dependency upgrades -- Various bug fixes and stability improvements - -### v0.12.5 — Nov 24, 2025 - -Quick patch release to fix paste behavior on Linux (prevent raw HTML from getting pasted to the terminal). - -### v0.12.4 — Nov 21, 2025 - -Quick patch release with bug fixes and minor improvements. - -- New `term:macoptionismeta` setting for macOS to treat Option key as Meta key in terminal -- Fixed directory tracking for zsh shells -- Fixed editor copy operations -- Minor Wave AI improvements (image handling, scrolling, focus) -- Package updates and dependency upgrades -- WIP: WaveApps builder framework (not yet released) - -### v0.12.3 — Nov 17, 2025 - -Patch release with Wave AI model upgrade, new secret management features, and improved terminal input handling. - -**Wave AI Updates:** -- **GPT-5.1 Model** - Upgraded to use OpenAI's GPT-5.1 model for improved responses -- **Thinking Mode Toggle** - New dropdown to select between Quick, Balanced, and Deep thinking modes for optimal response quality vs speed -- [bugfix] Fixed path mismatch issue when restoring AI write file backups - -**New Features:** -- **Secret Store** - New secret management widget for storing and managing sensitive credentials. Access secrets via CLI with `wsh secret list/get/set` commands - -**Terminal Improvements:** -- **Enhanced Input Handling** - Better support for interactive CLI tools like Claude Code. Shift+Enter now inserts newlines by default for multi-line commands -- **Image Paste Support** - Paste images directly into terminal (saved to temp files with path inserted). Works great in Claude Code! -- **IME Fix** - Fixed duplicate text issue when switching input methods during Chinese/Japanese/Korean composition - -**Other Changes:** -- Improved backend panic tracking for better debugging -- Fixed memory leak around sysinfo events -- WIP: New WaveApps builder framework (not yet released) -- Package updates and dependency bumps - -### v0.12.2 — Nov 4, 2025 - -Wave v0.12.2 adds file editing ability to Wave AI. Before approving a file edit you can easily see a diff (rendered in the Monaco Editor diff viewer), and after approving an edit you can easily roll back the change using a "Revert File" button. - -**Wave AI Updates:** -- **File Write Tool** - Wave AI can now create and modify files with your approval -- **Visual Diff Preview** - See exactly what will change before approving edits, rendered in Monaco Editor -- **Easy Rollback** - Revert file changes with a simple "Revert File" button -- **Drag & Drop Files** - Drag files from the preview viewer directly to Wave AI -- **Directory Listings** - `wsh ai` can now attach directory listings to chats -- **Adjustable Settings** - Control thinking level and max output tokens per chat - -**Bug Fixes & Improvements:** -- Fixed a significant memory leak in the RPC system -- Schema validation working again for config files -- Improved tool descriptions and input validations (run before tool approvals) -- Fixed issue with premature tool timeouts -- Fixed regression with PowerShell 5.x -- Fixed prompt caching issue when attaching files - -### v0.12.1 — Oct 20, 2025 - -Patch release focused on shell integration improvements and Wave AI enhancements. This release fixes syntax highlighting in the code editor and adds significant shell context tracking capabilities. - -**Shell Integration & Context:** -- **OSC 7 Support** - Added OSC 7 (current working directory) support across bash, zsh, fish, and pwsh shells. Wave now automatically tracks and restores your current directory across restarts for both local and remote terminals. -- **Shell Context Tracking** - Implemented shell integration for bash, zsh, and fish shells. Wave now tracks when your shell is ready to receive commands, the last command executed, and exit codes. This enhanced context enables better terminal management and lays the groundwork for Wave AI to write and execute commands intelligently. - -**Wave AI Improvements:** -- Display reasoning summaries in the UI while waiting for AI responses -- Added enhanced terminal context - Wave AI now has access to shell state including current directory, command history, and exit codes -- Added feedback buttons (thumbs up/down) for AI responses to help improve the experience -- Added copy button to easily copy AI responses to clipboard - -**Other Changes:** -- Mobile user agent emulation support for web widgets [#2442](https://github.com/wavetermdev/waveterm/issues/2442) -- [bugfix] Fixed padding for header buttons in code editor (Tailwind regression) -- [bugfix] Restored syntax highlighting in code editor preview blocks [#2427](https://github.com/wavetermdev/waveterm/issues/2427) -- Package updates and dependency bumps - -### v0.12.0 — Oct 16, 2025 - -**Wave v0.12 Has Arrived with Wave AI (beta)!** - -Wave Terminal v0.12.0 introduces a completely redesigned AI experience powered by OpenAI GPT-5. This represents a major upgrade and modernization over Wave's previous AI integration, bringing multi-modal support, advanced tool integration, and an intuitive new interface. The main AI PR alone included 128 commits and added 13,000+ lines of code. - -**Wave AI Features:** -- **New Slide-Out Chat Panel** - Access Wave AI via hotkeys (Cmd-Shift-A or Ctrl-Shift-0) from the left side of your screen -- **Multi-Modal Input** - Support for images, PDFs, and text file attachments -- **Drag & Drop Files** - Simply drag files into the chat to attach them -- **Command-Line Integration** - Send files and command output directly to Wave AI using `wsh ai` -- **Smart Context Switching** - Enable Wave AI to see into your widgets and file system -- **Built-in Tools:** - - Web search capabilities - - Local file and directory operations - - Widget screenshots - - Terminal scrollback access - - Web navigation - -Wave AI is in active beta with included AI credits while we refine the experience. BYOK (Bring Your Own Key) will be available once we've stabilized core features and gathered feedback on what works best. Share your feedback in our [Discord](https://discord.gg/XfvZ334gwU). - -For more information and upcoming features, visit our [Wave AI documentation](https://docs.waveterm.dev/waveai). - -**Other Improvements:** -- New onboarding flow showcasing block magnification, Wave AI, and wsh view/edit capabilities -- New `wsh blocks list` command for listing and filtering blocks by workspace, tab, or view type -- Continued migration from SCSS to Tailwind v4 -- Package upgrades and dependency updates -- Internal code cleanup and refactoring - -### v0.11.6 — Sep 22, 2025 - -Patch release to address an editor bug when you open two files in separate edit widgets. Also adds Mermaid support to markdown blocks. - -* WIP: Big AI overhaul coming (multi-modal support, premium models, and tool support) -* WIP: Integrating new Tsunami widget framework to make writing and running Wave widgets easier -* Lots of package updates -* Much internal cleanup (preview widget) -* More migration to Tailwind v4 CSS -* Build updates, switched to npm from yarn - -### v0.11.5 — Aug 28, 2025 - -Another housekeeping release to modernize Wave and bring it more up to date. - -* Wave AI Cloud Proxy now uses gpt-5-mini (upgraded from gpt-4o-mini) -* Fixed JWT issue with running "Wave Apps" from widgets -* Added an "$ENV:envvar:fallback" syntax to the config files to allow Wave's config to pick up values from the environment (mostly to allow moving secrets out of the config files) -* New setting to disable showing overlay blocknums when holding Ctrl:Shift (`app:showoverlayblocknums`) -* New setting to allow Shift-Enter to work with tools like Claude Code (`term:shiftenternewline`) -* Upgraded frontend to React 19 -* Migrated more of the frontend to Tailwind v4 (work in progress) -* Removed Universal MacOS build. 90% of Mac users are now on Apple Silicon, so universal build is less important (has a larger file size, and complicates the build process). -* [bugfix] Removed build-ids in RPM build to try to fix conflicts with Slack -* Removed some Wave v7 aware upgrades and old code paths -* Internal cleanup, TypeScript errors, linting fixes, etc. -* Other assorted Go/npm package bumps - -### v0.11.4 — Aug 19, 2025 - -Quick patch release to update packages, fix some security issues (with dependent packages), and some small bug fixes. - -* Update AI Libraries, GPT-5 now supported in WaveAI -* Added `ai:proxyurl` setting to allow proxy access (e.g. SOCKS) for AI access - -### v0.11.3 — May 2, 2025 - -Quick patch release to update packages, fix some security issues (with dependent packages), and some small bug fixes. - -### v0.11.2 — March 8, 2025 - -Quick patch release to fix a backend panic, and revert a change that caused WSL connections to hang. - -### v0.11.1 — Feb 28, 2025 - -Wave Terminal v0.11.1 adds a lot of new functionality over v0.11.0 (it could have almost been a v0.12)! - -The headline feature is our files/preview widget now supports browsing S3 buckets. We read credential information directly from your ~/.aws/config, and you can now easily select any of your AWS profiles in our connections drop down to start viewing S3 files. We even support editing S3 text files using our built-in editor. - -Lots of other features and bug fixes as well: - -- **S3 Bucket** directory viewing and file previews -- **Drag and Drop Files and Directories** between Wave directory views. This works across machines and between remote machines and S3 conections. -- Added json-schema support for some of our config files. You'll now get auto-complete popups for fields in our settings.json, widgets.json, ai.json, and connections.json file. -- New block splitting support -- Use Cmd-D and Cmd-Shift-D to split horizontally and vertically. For more control you can use Ctrl-Shift-S and then Up/Down/Left/Right to split in the given direction. -- Delete block (without removing it from the layout). You can use Ctrl-Shift-D to remove a block, while keeping it in the layout. you can then launch a new widget in its place. -- `wsh file` now supports copying files between your local machine, remote machines, and to/from S3 -- New analytics framework (event based as opposed to counter based). See Telemetry Docs for more information. -- Web bookmarks! Edit in your bookmarks.json file, can open them in the web widget using Cmd+O -- Edits to your ai.json presets file will now take effect _immediately_ in AI widgets -- Much better error handling and messaging when errors occur in the preview or editor widget -- `wsh ssh --new` added to open the new ssh connection in a new widget -- new `wsh launch` command to open any custom widget defined in widget.json -- When using terminal multi-input (Ctrl-Shift-I), pasting text will now be sent to all terminals -- [bugfix] Fix some hanging goroutines when commands failed or timed out -- [bugfix] Fix some file extension mimetypes to enable the editor for more file types -- [bugfix] Hitting "tab" would sometimes scroll a widget off screen making it unusable -- [bugfix] XDG variables will no longer leak to terminal widgets -- Added tailwind CSS and shadcn support to help build new widgets faster -- Better internal widget abstractions - -### v0.11.0 — Jan 24, 2025 - -Wave Terminal v0.11.0 includes a major rewrite of our connections infrastructure, with changes to both our backend and remote file protocol systems, alongside numerous features, bug fixes, and stability improvements. - -A key addition in this release is the new shell initialization system, which enables customization of your shell environment across local and remote connections. You can now configure environment variables and shell-specific init scripts on both a per-block and per-connection basis. - -For day-to-day use, we've added search functionality across both terminal and web blocks, along with a terminal multi-input feature for simultaneous input to all terminals within a tab. We've also added support for Google Gemini to Wave AI, expanding our suite of AI integrations. - -Behind the scenes, we've redesigned our remote file protocol, laying the groundwork for upcoming S3 (and S3-compatible system) support in our preview widget. This architectural change sets the stage for adding more file backends in the future. - -- **Shell Environment Customization** -- Configure your shell environment using environment variables and init scripts, with support for both local and remote connections -- **Connection Backend Improvements** -- Major rewrite with improved shell detection, better error logging, and reduced 2FA prompts when using ForceCommand -- **Multi-Shell Support** -- Enhanced support for bash, zsh, pwsh, and fish shells, with shell-specific initialization capabilities -- **Terminal Search** -- use Cmd-F to search for text in terminal widgets -- **Web Search** -- use Cmd-F to search for text in web views -- **Terminal Multi-Input** -- Use Ctrl-Shift-I to allow multi-input to all terminals in the same tab -- **Wave AI now supports Google Gemini** -- Improved WSL support with wsh-free connection options -- Added inline connection debugging information -- Fixed file permission handling issues on Windows systems -- Connection related popups are now delivered only to the initiating window -- Improved timeout handling for SSH connections which require 2FA prompts -- Fixed escape key handling in global event handlers (closing modals) -- Directory preview now fills the entire block width -- Custom widgets can now be launched in magnified mode -- Various workspace UX improvements around closing/deleting -- file:/// urls now work in web widget -- Increased max size of files allowed in `wsh ai` to 50k -- Increased maximum allowed term:scrollback to 50k lines -- Allow connections to entirely be defined in connections.json without relying on ~/.ssh/config -- Added an option to reveal files in external file viewer for local connection -- Added a New Window option when right clicking the MacOS dock icon button -- [build] Switched to free Ubuntu ARM runners for better ARM64 build support -- [build] Windows builds now use zig, simplifying Windows dev setup -- [bugfix] Connections dropdown now populated even when ssh config is missing or invalid -- [bugfix] Disabled bracketed paste mode by default (configuration option to turn it back on) -- [bugfix] Timeout for `wsh ssh` increased to 60s -- [bugfix] Fix for sysinfo widget when displaying a huge number of CPU graphs -- [bugfix] Fixes XDG variables for Snap installs -- [bugfix] Honor SSH IdentitiesOnly flag (useful when many keys are loaded into ssh-agent) -- [bugfix] Better shell environment variable setup when running local shells -- [bugfix] Fix preview for large text files -- [bugfix] Fix URLs in terminal (now clickable again) -- [bugfix] Windows URLs now work properly for Wave background images -- [bugfix] Connections launch without wsh if the unix domain socket can't be opened -- [bugfix] Connection status list lights up correctly with currently connected connections -- [bugfix] Use en_US.UTF-8 if the requested LANG is not available in your terminal -- Other bug fixes, performance improvements, and dependency updates - -### v0.10.4 — Dec 20, 2024 - -Quick update with bug fixes and new configuration options - -- Added "window:confirmclose" and "window:savelastwindow" configuration options -- [bugfix] Fixed broken scroll bar in the AI widget -- [bugfix] Fixed default path for wsh shell detection (used in remote connections) -- Dependency updates - -### v0.10.3 — Dec 19, 2024 - -Quick update to v0.10 with new features and bug fixes. - -- Global hotkey support [docs](https://docs.waveterm.dev/config#customizable-systemwide-global-hotkey) -- Added configuration to override the font size for markdown, AI-chat, and preview editor [docs](https://docs.waveterm.dev/config) -- Added ability to set independent zoom level for the web view (right click block header) -- New `wsh wavepath` command to open the config directory, data directory, and log file -- [bugfix] Fixed crash when /etc/sshd_config contained an unsupported Match directive (most common on Fedora) -- [bugfix] Workspaces are now more consistent across windows, closes associated window when Workspaces are deleted -- [bugfix] Fixed zsh on WSL -- [bugfix] Fixed long-standing bug around control sequences sometimes showing up in terminal output when switching tabs -- Lots of new examples in the docs for shell overrides, presets, widgets, and connections -- Other bug fixes and UI updates - -(note, v0.10.2 and v0.10.3's release notes have been merged together) - -### v0.10.1 — Dec 12, 2024 - -Quick update to fix the workspace app menu actions. Also fixes workspace switching to always open a new window when invoked from a non-workspace window. This reduces the chance of losing a non-workspace window's tabs accidentally. - -### v0.10.0 — Dec 11, 2024 - -Wave Terminal v0.10.0 introduces workspaces, making it easier to manage multiple work environments. We've added powerful new command execution capabilities with `wsh run`, allowing you to launch and control commands in dedicated blocks. This release also brings significant improvements to SSH with a new connections configuration system for managing your remote environments. - -- **Workspaces**: Organize your work into separate environments, each with their own tabs, layouts, and settings -- **Command Blocks**: New `wsh run` command for launching terminal commands in dedicated blocks, with support for magnification, auto-closing, and execution control ([docs](https://docs.waveterm.dev/wsh-reference#run)) -- **Connections**: New configuration system for managing SSH connections, with support for wsh-free operation, per-connection themes, and more ([docs](https://docs.waveterm.dev/connections)) -- Improved tab management with better switching behavior and context menus (many bug fixes) -- New tab features including pinned tabs and drag-and-drop improvements -- Create, rename, and delete files/directories directly in directory preview -- Attempt wsh-free connection as a fallback if wsh installation or execution fails -- New `-i` flag to add identity files with the `wsh ssh` command -- Added Perplexity API integration ([docs](https://docs.waveterm.dev/faq#perplexity)) -- `wsh setbg` command for background handling ([docs](https://docs.waveterm.dev/wsh-reference#setbg)) -- Switched from Less to SCSS for styling -- [bugfix] Fixed tab flickering issues during tab switches -- [bugfix] Corrected WaveAI text area resize behavior -- [bugfix] Fixed concurrent block controller start issues -- [bugfix] Fixed Preview Blocks for uninitialized connections -- [bugfix] Fixed unresponsive context menus -- [bugfix] Fixed connection errors in Help block -- Upgraded Go toolchain to 1.23.4 -- Lots of new documentation, including new pages for [Getting Started](https://docs.waveterm.dev/gettingstarted), [AI Presets](https://docs.waveterm.dev/ai-presets), and [wsh overview](https://docs.waveterm.dev/wsh). -- Other bug fixes, performance improvements, and dependency updates - -### v0.9.3 — Nov 20, 2024 - -New minor release that introduces Wave's connected computing extensions. We've introduced new `wsh` commands that allow you to store variables and files, and access them across terminal sessions (on both local and remote machines). - -- `wsh setvar/getvar` to get and set variables -- [Docs](https://docs.waveterm.dev/wsh-reference#getvarsetvar) -- `wsh file` operations (cat, write, append, rm, info, cp, and ls) -- [Docs](https://docs.waveterm.dev/wsh-reference#file) -- Improved golang panic handling to prevent backend crashes -- Improved SSH config logging and fixes a reused connection bug -- Updated telemetry to track additional counters -- New configuration settings (under "window:magnifiedblock") to control magnified block margins and display -- New block/zone aliases (client, global, block, workspace, temp) -- `wsh ai` file attachments are now rendered with special handling in the AI block -- New ephemeral block type for creating modal widgets which will not disturb the underlying layout -- Editing the AI presets file from the Wave AI block now brings up an ephemeral editor -- Clicking outside of a magnified bglock will now un-magnify it -- New button to clear the AI chat (also bound to Cmd-L) -- New button to reset terminal commands in custom cmd widgets -- [bugfix] Presets directory was not loading correctly on Windows -- [bugfix] Magnified blocks were not showing correct on startup -- [bugfix] Window opacity and background color was not getting applied properly in all cases -- [bugfix] Fix terminal theming when applying global defaults [#1287](https://github.com/wavetermdev/waveterm/issues/1287) -- MacOS 10.15 (Catalina) is no longer supported -- Other bug fixes, docs improvements, and dependency bumps - -### v0.9.2 — Nov 11, 2024 - -New minor release with bug fixes and new features! Fixed the bug around making Wave fullscreen (also affecting certain window managers like Hyprland). We've also put a lot of work into the doc site (https://docs.waveterm.dev), including documenting how [Widgets](./widgets) and Presets work! - -- Updated documentation -- Wave AI now supports the Anthropic API! Checkout the [FAQ](./faq) for how to use the Claude models with Wave AI. -- Removed defaultwidgets.json and unified it to widgets.json. Makes it more straightforward to override the default widgets. -- New resolvers for `-b` param in `wsh`. "tab:N" for accessing the nth tab, "[view]" and "[view]:N" for accessing blocks of a particlar view. -- New `wsh ai` command to send AI chats (and files) directly to a new or existing AI block -- wsh setmeta/getmeta improvements. Allow setmeta to take a json file (and also read from stdin), also better output formats for getmeta (compatible with setmeta). -- [bugfix] Set max completion tokens in the OpenAI API so we can now work with o1 models (also fallback to non-streaming mode) -- [bugfix] Fixed content resizing when entering "full screen" mode. This bug also affected certain window managers (like Hyprland) -- Lots of other small bug fixes, docs updates, and dependency bumps - -### v0.9.1 — Nov 1, 2024 - -Minor bug fix release to follow-up on the v0.9.0 build. Lots of issues fixed (especially for Windows). - -- CLI applications that need microphone, camera, or location access will now work on MacOS. You'll see a security popup in Wave to allow/deny [#1086](https://github.com/wavetermdev/waveterm/issues/1086) -- Can now use `wsh version -v` to print out the new data/config directories -- Restores the old T1, T2, T3, ... tab naming logic -- Temporarily revert to using the "Title Bar" on windows to mitgate a bug where the window controls were overlaying on top of our tabs (working on a real fix for the next release) -- There is a new setting in the editor to enable/disable word wrapping [#1038](https://github.com/wavetermdev/waveterm/issues/1038) -- Ctrl-S will now save files in codeedit [#1081](https://github.com/wavetermdev/waveterm/issues/1081) -- [#1020](https://github.com/wavetermdev/waveterm/issues/1020) there is now a preset config option to change the active border color in tab themes -- [bugfix] Multiple fixes for [#1167](https://github.com/wavetermdev/waveterm/issues/1167) to try to address tab loss while updating -- [bugfix] Windows app crashed on opening View menu because of a bad accelerator key -- [bugfix] The auto-updater messages in the tab bar are now more consistent when switching tabs, and we don't show errors when the network is disconnected -- [bugfix] Full-screen mode now actually shows tabs in full screen -- [bugfix] [#1175](https://github.com/wavetermdev/waveterm/issues/1175) can now edit .awk files -- [bugfix] [#1066](https://github.com/wavetermdev/waveterm/issues/1066) applying a default theme now updates the background appropriately without a refresh - -### v0.9.0 — Oct 28, 2024 - -New major Wave Terminal release! Wave tabs are now cached. Tab switching performance is -now much faster and webview state, editor state, and scroll positions are now persisted -across tab changes. We also have native WSL2 support. You can create native Wave connections -to your Windows WSL2 distributions using the connection button. - -We've also laid the groundwork for some big features that will be released over the -next couple of weeks, including Workspaces, AI improvments, and custom widgets. - -Lots of other smaller changes and bug fixes. See full list of PRs at https://github.com/wavetermdev/waveterm/releases/tag/v0.9.0 - -### v0.8.13 — Oct 24, 2024 - -- Wave is now available as a Snap for Linux users! You can find it [in the Snap Store](https://snapcraft.io/waveterm). -- Wave is now available via the Windows Package Manager! You can install it via `winget install CommandLine.Wave` -- can now use "term:fontsize" to override an individual terminal block's font size (also in context menu) -- we now allow mixed case hostnames for connections to be compatible with ssh config -- The Linux app icon is now updated to match the Windows icon -- [bugfix] fixed a bug that sometimes caused escape sequences to be printed when switching between tabs -- [bugfix] fixed an issue where the preview block was not cleaning up temp files (Windows only) -- [bugfix] fixed chrome sandbox permissions errors in linux -- [bugfix] fixed shutdown logic on MacOS/Linux which sometimes allowed orphaned processes to survive - -### v0.8.12 — Oct 18, 2024 - -- Added support for multiple AI configurations! You can now run Open AI side-by-side with Ollama models. Can create AI presets in presets.json, and can easily switch between them using a new dropdown in the AI widget -- Fix WebSocket reconnection error. this sometimes caused the terminal to hang when waking up from sleep -- Added memory graphs, and per-CPU graphs to the sysinfo widget (and renamed it from cpuplot) -- Added a new huge red "Config Error" button when there are parse errors in the config JSON file -- Preview/CodeEdit widget now shows errors (squiggly lines) when JSON or YAML files fail to parse -- New app icon for Windows to better match Fluent UI standards -- Added copy-on-select to the terminal (on by default, can disable using "term:copyonselect") -- Added a button to mute audio in webviews -- Added a right-click "Open Clipboard URL" to easily open a webview from an URL stored in your system clipboard -- [bugfix] fixed blank "help" pages when waking from sleep or restarting the app - -### v0.8.11 — Oct 10, 2024 - -Hotfix release to address a couple of bugs introduced in v0.8.10 - -- Fixes a regression in v0.8.10 which caused new tabs to sometimes come up blank and broken -- Layout fixes to the AI widget spacing -- Terminal scrollbar is now semi-transparent and overlays last column -- Fixes initial window size (on first startup) for both smaller and larger screens -- Added a "Don't Ask Again" checkbox for installing `wsh` on remote machines (sets a new config flag) -- Prevent the app from downgrading when you install a beta build. Installing a beta-build will now switch you to the beta-update channel. - -### v0.8.10 — Oct 9, 2024 - -Minor big fix release (but there are some new features). - -- added support for Azure AI [See FAQ](https://docs.waveterm.dev/faq#how-can-i-connect-to-azure-ai) -- AI errors now appear in the chat -- on MacOS, hitting "Space" in directorypreview will open selected file in Quick Look -- [bugfix] fixed transparency settings -- [bugfix] fixed issue with non-standard port numbers in connection dropdown -- [bugfix] fixed issue with embedded docsite (returned 404 after refresh) - -### v0.8.9 — Oct 8, 2024 - -Lots of bug fixes and new features! - -- New "help" view -- uses an embedded version of our doc site -- https://docs.waveterm.dev -- [breaking] wsh getmeta, wsh setmeta, and wsh deleteblock now take a blockid using a `-b` parameter instead of as a positional parameter -- allow metadata to override the block icon, header, and text (frame:title, frame:icon, and frame:text) -- home button on web widget to return to the homepage, option to set a homepage default for the whole app or just for the given block -- checkpoint the terminal less often to reduce frequency of output bug (still working on a full fix) -- new terminal themes -- Warm Yellow, and One Dark Pro -- we now support github flavored markdown alerts -- `wsh notify` command to send a desktop notification -- `wsh createblock` to create any block via the CLI -- right click to "Save Image" in webview -- `wsh edit` will now allow you to open new files (as long as the parent directly exists) -- added 8 new fun tab background presets (right click on any tab and select "Backgrounds" to try them out) -- [config] new config key "term:scrollback" to set the number of lines of scrollback for terminals. Use "-1" to set 0, max is 10000. -- [config] new config key "term:theme" to set the default terminal theme for all new terminals -- [config] new config key "preview:showhiddenfiles" to set the default "show hidden files" setting for preview -- [bugfix] fixed an formatting issue with `wsh getmeta` -- [bugfix] fix for startup issue on Linux when home directory is an NFS mount -- [bugfix] fix cursor color in terminal themes to work -- [bugfix] fix some double scrollbars when showing markdown content -- [bugfix] improved shutdown sequence to better capture wavesrv logs -- [bugfix] fix Alt+G keyboard accelerator for Linux/Windows -- other assorted bug fixes, cleanups, and security fixes - -### v0.8.8 — Oct 1, 2024 - -Quick patch release to fix Windows/Linux "Alt" keybindings. Also brings a huge performance improvement to AI streaming speed. - -### v0.8.7 — Sep 30, 2024 - -Quick patch release to fix bugs: - -- Fixes windows SSH connections (invalid path while trying to install wsh tools) -- Fixes an issue resolving `~` in windows paths `~\` now works instead of just `~/` -- Tries to fix background color for webpages. Pulls meta tag for color-scheme, and sets a black background if dark detected (fixes issue rendering raw githubusercontent files) -- Fixed our useDimensions hook to fire correctly. Fixes some sizing issues including allowing error messages to show consistently when SSH connections fail. -- Allow "data:" urls in custom tab backgrounds -- All the alias "tab" for the current tab's UUID when using wsh -- [BUILD] conditional write generated files only if they are updated - -### v0.8.6 — Sep 26, 2024 - -Another quick hotfix update. Fixes an issue where, if you deleted all of the tabs in a window, the window would be restored on next startup as completely blank. - -Also, as a bonus, we added fish shell support! - -### v0.8.5 — Sep 25, 2024 - -Hot fix, dowgrade `jotai` library. Upgrading caused a major regression in codeedit which did not allow -users to edit files. - -### v0.8.4 — Sep 25, 2024 - -- Added a setting `window:disablehardwareacceleration` to disable native hardware acceleration -- New startup model for legacy users given them the option to download the WaveLegacy -- Use WAVETERM_HOME for the home directory consistently - -### v0.8.3 — Sep 25, 2024 - -More hotfixes for Linux users. We now link against an older version of glibc and use -the zig compiler on linux (the newer version caused us not to run on older distros). -Also fixes a permissions issue when installing via .deb. There is also a new config value -`window:nativetitlebar` which restores the native titlebar on windows/linux. - -### v0.8.2 — Sep 24, 2024 - -Hot fix, fixes a nasty crash on startup for Linux users (dynamic linking but with netcgo DNS library) - -### v0.8.1 — Sep 23, 2024 - -Minor cleanup release. - -- fix number parsing for certain config file values -- add link to docs site -- add new back button for directory view -- telemetry fixes - -### v0.8.0 — Sep 20, 2024 - -**Major New Release of Wave Terminal** - -The new build is a fresh start, and a clean break from the current version. As such, your history, settings, and configuration will not be carried over. If you'd like to continue to run the legacy version, you will need to download it separately. - -Release Artificats and source code diffs can be found on (Github)[https://github.com/wavetermdev/waveterm]. diff --git a/docs/docs/secrets.mdx b/docs/docs/secrets.mdx deleted file mode 100644 index e01612c5b8..0000000000 --- a/docs/docs/secrets.mdx +++ /dev/null @@ -1,147 +0,0 @@ ---- -sidebar_position: 3.2 -id: "secrets" -title: "Secrets" ---- - -import { VersionBadge } from "@site/src/components/versionbadge"; - -# Secrets - -<VersionBadge version="v0.13" noLeftMargin={true} /> - -Wave Terminal provides a secure way to store sensitive information like passwords, API keys, and tokens. Secrets are stored encrypted in your system's native keychain (macOS Keychain, Windows Credential Manager, or Linux Secret Service), ensuring your sensitive data remains protected. - -## Why Use Secrets? - -Secrets in Wave Terminal allow you to: - -- **Store SSH passwords** - Automatically authenticate to SSH connections without typing passwords -- **Manage API keys** - Keep API tokens, keys, and credentials secure -- **Share across sessions** - Access your secrets from any terminal block or remote connection -- **Avoid plaintext storage** - Never store sensitive data in configuration files or scripts - -## Opening the Secrets UI - -There are several ways to access the secrets management interface: - -1. **From the widgets bar** (recommended): - - Click the **<i className="fa-gear fa-solid fa-sharp"/>** settings icon on the widgets bar - - Select **Secrets** from the menu - -2. **From the command line:** - ```bash - wsh secret ui - ``` - - -The secrets UI provides a visual interface to view, add, edit, and delete secrets. - -## Managing Secrets via CLI - -Wave Terminal provides a complete CLI for managing secrets from any terminal block: - -```bash -# List all secret names (not values) -wsh secret list - -# Get a specific secret value -wsh secret get MY_SECRET_NAME - -# Set a secret (format: name=value, no spaces around =) -wsh secret set GITHUB_TOKEN=ghp_xxxxxxxxxx -wsh secret set DB_PASSWORD=super_secure_password - -# Delete a secret -wsh secret delete MY_SECRET_NAME -``` - -## Secret Naming Rules - -Secret names must match the pattern: `^[A-Za-z][A-Za-z0-9_]*$` - -This means: -- Must start with a letter (A-Z or a-z) -- Can only contain letters, numbers, and underscores -- Cannot contain spaces or special characters - -**Valid names:** `MY_SECRET`, `ApiKey`, `ssh_password_1` -**Invalid names:** `123_SECRET`, `my-secret`, `secret name` - -## Using Secrets with SSH Connections - -<VersionBadge version="v0.13" /> - -Secrets can be used to automatically provide passwords for SSH connections, eliminating the need to type passwords repeatedly. - -### Configure in connections.json - -Add the `ssh:passwordsecretname` field to your connection configuration: - -```json -{ - "myserver": { - "ssh:hostname": "example.com", - "ssh:user": "myuser", - "ssh:passwordsecretname": "SERVER_PASSWORD" - } -} -``` - -Then store your password as a secret: - -```bash -wsh secret set SERVER_PASSWORD=my_actual_password -``` - -Now when Wave connects to `myserver`, it will automatically use the password from your secret store instead of prompting you. - -### Benefits - -- **Security**: Password stored encrypted in your system keychain -- **Convenience**: No need to type passwords for each connection -- **Flexibility**: Update passwords by changing the secret, not the configuration - -## Security Considerations - -- **Encrypted Storage**: Secrets are stored encrypted in your Wave configuration directory. The encryption key itself is protected by your operating system's secure credential storage (macOS Keychain, Windows Credential Manager, or Linux Secret Service). - -- **No Plaintext**: Secrets are never stored unencrypted in logs or accessible files. - -- **Access Control**: Secrets are only accessible to Wave Terminal. - - -## Storage Backend - -Wave Terminal automatically detects and uses the appropriate secret storage backend for your operating system: - -- **macOS**: Uses the macOS Keychain -- **Windows**: Uses Windows Credential Manager -- **Linux**: Uses the Secret Service API (freedesktop.org specification) - -:::warning Linux Secret Storage -On Linux systems, Wave requires a compatible secret service backend (typically GNOME Keyring or KWallet). These are usually pre-installed with your desktop environment. If no compatible backend is detected, you won't be able to set secrets, and the UI will display a warning. -::: - -## Troubleshooting - -### "No appropriate secret manager found" - -This error occurs on Linux when no compatible secret service backend is available. Install GNOME Keyring or KWallet and ensure the secret service is running. - -### Secret not found - -Ensure the secret name is spelled correctly (names are case-sensitive) and that the secret exists: - -```bash -wsh secret list -``` - -### Permission denied on Linux - -The secret service may require you to unlock your keyring. This typically happens after login. Consult your desktop environment's documentation for keyring management. - -## Related Documentation - -- [Connections](/connections) - Learn about SSH connections and configuration -- [wsh Command Reference](/wsh-reference#secret) - Complete CLI command documentation for secrets \ No newline at end of file diff --git a/docs/docs/tab-backgrounds.mdx b/docs/docs/tab-backgrounds.mdx deleted file mode 100644 index 77c02a2bb4..0000000000 --- a/docs/docs/tab-backgrounds.mdx +++ /dev/null @@ -1,120 +0,0 @@ ---- -sidebar_position: 3.5 -id: "tab-backgrounds" -title: "Tab Backgrounds" ---- - -# Tab Backgrounds - -Wave's background system harnesses the full power of CSS backgrounds, letting you create rich visual effects through the "background" attribute. You can apply solid colors, gradients (both linear and radial), images, and even blend multiple elements together. - -## Managing Backgrounds - -Custom backgrounds are stored in `~/.config/waveterm/backgrounds.json`. - -**To edit using the UI:** -1. Click the settings (gear) icon in the widget bar -2. Select "Settings" from the menu -3. Choose "Tab Backgrounds" from the settings sidebar - -**Or launch from the command line:** -```bash -wsh editconfig backgrounds.json -``` - -## File Format - -Backgrounds follow this format: - -```json -{ - "bg@<key>": { - "display:name": "<Background name>", - "display:order": <number>, - "bg": "<CSS background value>", - "bg:opacity": <float> - } -} -``` - -To see how Wave's built-in backgrounds are defined, check out the [default backgrounds.json file](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/backgrounds.json). - -## Configuration Keys - -| Key Name | Type | Function | -| -------------------- | ------ | ------------------------------------------------------------------------------------------------------- | -| display:name | string | Name shown in the UI menu (required) | -| display:order | float | Controls the order in the menu (optional) | -| bg | string | CSS `background` attribute for the tab (supports colors, gradients, images, etc.) | -| bg:opacity | float | The opacity of the background (defaults to 0.5) | -| bg:blendmode | string | The [blend mode](https://developer.mozilla.org/en-US/docs/Web/CSS/blend-mode) of the background | -| bg:bordercolor | string | The color of the border when a block is not active (rarely used) | -| bg:activebordercolor | string | The color of the border when a block is active | - -## Examples - -#### Simple solid color: - -```json -{ - "bg@blue": { - "display:name": "Blue", - "bg": "blue", - "bg:opacity": 0.3, - "bg:activebordercolor": "rgba(0, 0, 255, 1.0)" - } -} -``` - -#### Complex gradient: - -```json -{ - "bg@duskhorizon": { - "display:name": "Dusk Horizon", - "bg": "linear-gradient(0deg, rgba(128,0,0,1) 0%, rgba(204,85,0,0.7) 20%, rgba(255,140,0,0.6) 45%, rgba(160,90,160,0.5) 65%, rgba(60,60,120,1) 100%), radial-gradient(circle at 30% 30%, rgba(255,255,255,0.1), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.05), transparent 70%)", - "bg:opacity": 0.9, - "bg:blendmode": "overlay" - } -} -``` - -#### Background image: - -```json -{ - "bg@ocean": { - "display:name": "Ocean Scene", - "bg": "url('/path/to/ocean.jpg') center/cover no-repeat", - "bg:opacity": 0.2 - } -} -``` - -:::info -Background images support both URLs and local file paths. For better reliability, we recommend using local files. Local paths must be absolute or start with `~` (e.g., `~/Downloads/background.png`). We support common web formats: PNG, JPEG/JPG, WebP, GIF, and SVG. -::: - -:::tip -The `setbg` command can help generate background JSON: - -```bash -# Preview a solid color background -wsh setbg --print "#ff0000" -{ - "bg:*": true, - "bg": "#ff0000", - "bg:opacity": 0.5 -} - -# Preview a centered image background -wsh setbg --print --center --opacity 0.3 ~/logo.png -{ - "bg:*": true, - "bg": "url('/absolute/path/to/logo.png') no-repeat center/auto", - "bg:opacity": 0.3 -} -``` - -Just add the required `display:name` field and a `bg@<key>` wrapper to complete your background entry! -::: diff --git a/docs/docs/tabs.mdx b/docs/docs/tabs.mdx deleted file mode 100644 index 354089be4c..0000000000 --- a/docs/docs/tabs.mdx +++ /dev/null @@ -1,134 +0,0 @@ ---- -sidebar_position: 3.25 -id: "tabs" -title: "Tabs" ---- - -import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; -import { Kbd } from "@site/src/components/kbd"; - -<PlatformProvider> - -Tabs are collections of [Widgets](./widgets) that can be arranged into tiled dashboards. You can create as many tabs as you want within a given workspace to help organize your workflows. - -## Tab Bar - -The tab bar is located at the top of the window and shows all tabs within a given workspace. You can click on a tab to switch to it. When switching tabs, any commands in the previous tab will continue running and any unsaved work will be persisted until you return to it. If you close the window or switch workspaces within the same window, that work will be lost. - -<PlatformSelectorButton /> - -### Creating a new tab - -You can create a new tab by clicking the <i className="fa-sharp fa-plus" title="plus"/> button to the right of the tabs in the tab bar, or by pressing <Kbd k="Cmd:t"/> on the keyboard. This will also focus you to the new tab. - -### Closing a tab - -You can close a tab by hovering over it and clicking the <i className="fa-sharp fa-xmark-large" title="x"/> button that appears, or by pressing <Kbd k="Cmd:Shift:w"/> on the keyboard. You can also close a tab by [closing all the blocks](#delete-a-block) within it. - -Closing a block is a destructive action that will stop any running processes and discard any unsaved work. This cannot be undone. - -### Rearranging tabs - -You can rearrange tabs by dragging them around within the tab bar. - -### Switching tabs - -You can switch to an existing tab by clicking on it in the tab bar. You can also use the following shortcuts: - -| Key | Function | -| ------------------ | -------------------- | -| <Kbd k="Cmd:1-9"/> | Switch to tab number | -| <Kbd k="Cmd:["/> | Switch tab left | -| <Kbd k="Cmd:]"/> | Switch tab right | - -### Pinning a tab - -Pinning a tab makes it harder to close accidentally. You can pin a tab by right-clicking on it and selecting "Pin Tab" from the context menu that appears. You can also pin a tab by dragging it to a lesser index than an existing pinned tab. When a tab is pinned, the <i className="fa-sharp fa-xmark-large" title="x"/> button for the tab will be replaced with a <i className="fa-solid fa-sharp fa-thumbtack" title="pin"/> button. Clicking this button will unpin the tab. You can also unpin a tab by dragging it to an index higher than an existing unpinned tab. - -## Tab Layout System - -The tabs are comprised of tiled blocks. The contents of each block is a single widget. You can move blocks around and arrange them into layouts that best-suit your workflow. You can also magnify blocks to focus on a specific widget. - -![screenshot showing a block being dragged over another block, with the placeholder depicting a out-of-line before outer drop](./img/drag-edge.png) - -### Layout system under the hood - -:::info - -**Definitions** - -- Layout tree: the in-memory representation of a tab layout, comprised of nodes -- Node: An entry in the layout tree, either a single block (a leaf) or an ordered list of nodes. Defines a tiling direction (row or column) and a unitless size -- Block: The contents of a leaf in the layout tree, defines what contents is displayed at the given layout location - -::: - -Our layout system emulates the [CSS Flexbox](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_flexible_box_layout/Basic_concepts_of_flexbox) system, comprising of a tree of columns and rows. Under the hood, the layout is represented as an n-tree, where each node in the tree is either a single block, or a list of nodes. Each level in the tree alternates the direction in which it tiles (level 1 tiles as a row, level 2 as a column, level 3 as a row, etc.). - -### Layout actions - -<PlatformSelectorButton /> - -#### Add a new block - -You can add new blocks by selecting a widget from the right sidebar. - -Starting at the topmost level of the tree, since the first level tiles as a row, new blocks will be added to the right side of existing blocks. Once there are 5 blocks across, new blocks will begin being added below existing blocks, starting from the right side and working to the left. As a new block gets added below an existing one, the node containing the existing block is converted from a single-block node to a list node and the existing block definition is moved one level deeper in the tree as the first element of the node list. New blocks will always be added to the last-available node in the deepest level, where available is defined as having less than five children. We don't set a limit on the number of blocks in a tab, but you may experience degraded performance past around 25 blocks. - -While we define a 5-child limit for each node in the tree when automatically placing new blocks, there is no actual limit to the number of children a node can hold. After the block is placed, you are free to move it wherever in the layout - -#### Delete a block - -You can delete blocks by clicking the <i className="fa-sharp fa-xmark-large" title="x"/> button in the top-right corner of the block, by right-clicking on the block header and selecting "Close Block" from the context menu, or by running the [`wsh deleteblock` command](./wsh-reference#deleteblock). Alternatively, the currently focused block/widget can be closed by pressing <Kbd k="Cmd:w"/> - -When you delete a block, the layout tree will be automatically adjusted to minimize the tree depth. - -#### Move a block - -You can move blocks by clicking on the block header and dragging the block around the tab. You will see placeholders appear to show where the block will land when you drop it. - -There are 7 different drop targets for any given block. A block is divided into quadrants along its diagonals. If the block is tiling as a row (left-to-right), dropping a block into the left or right quadrant will place the dropped block in the same level as the targeted block. This can be considered dropping the block inline. If you drop the block out of line (in quadrants corresponding to opposite tiling direction), the block will either be placed one level above or one level below the targeted block. Dropping the block towards the outside will place it in the same level as the target block's parent, while dropping it towards the center of the block will create a new level, where both the target block and the dropped block will be moved. The middle fifth of the block is reserved for the swap action. Dropping a block here will cause the target block and the dropped block to swap positions in the layout. - -<video width="100%" height="100%" playsinline autoplay muted controls> - <source src="./img/drag-move-24fps-crf43.mp4" type="video/mp4" /> -</video> - -##### Possible block movements - -:::note -All block movements except for Swap will cause the rest of the layout to shift to accommodate the block's new displacement. -::: - -![screenshot showing a block being dragged over another block, with the placeholder depicting a swap movement](./img/drag-swap.png) -![annotated example showing the drop targets within a block](./img/block-drag-example.jpg) - -1. Inline before: Drops the block under the same node as the target block, placing it before the target in the same tiling direction -2. Inline after: Drops the block under the same node as the target block, placing it after the target in the same tiling direction -3. Out-of-line before outer: Drops the block before the target block's parent node in the opposite tiling direction -4. Out-of-line before inner: Segments the target block, creating a new node in the tree. Places the dropped block before the target block in the opposite tiling direction. -5. Out-of-line after inner: Segments the target block, creating a new node in the tree. Places the dropped block after the target block in the opposite tiling direction. -6. Out-of-line after outer: Drops the block after the target block's parent node in the opposite tiling direction -7. Swap: Swaps the position of the dropped block and the targeted block in the layout, preserving the rest of the layout - -#### Resize a block - -<video width="100%" height="100%" playsinline autoplay muted controls> - <source src="./img/resize-24fps-crf43.mp4" type="video/mp4" /> -</video> - -![screenshot showing the line that appears when the cursor hovers over the margin of a block, indicating which blocks -will be resized by dragging the margin](./img/node-resize.png) - -You do not directly resize a block. Rather, you resize the nodes containing the blocks. If you hover your mouse over the margin of a block, you will see the cursor change to <i className="fa-sharp fa-arrows-left-right" title="left/right arrows"/> or <i className="fa-sharp fa-arrows-up-down" title="up/down arrows"/> to indicate the direction the node can be resized. You will also see a line appear after 500ms to show you how many blocks will be resized by moving that margin. Clicking and dragging on this margin will cause the block(s) to get resized. - -Node sizes are unitless values. The ratio of all node sizes at a given tree level determines the displacement of each node. If you move a block and its node is deleted, the other nodes at the given tree level will adjust their sizes to account for the new size ratio. - -### Magnify a block - -You can magnify a block by clicking the <i className="custom-icon-inline custom-icon-magnify-disabled" title="magnify"/> button or by pressing <Kbd k="Cmd:m"/> on the keyboard. You can then un-magnify a block by clicking the <i className="custom-icon-inline custom-icon-magnify-enabled" title="un-magnify"/> button or by pressing <Kbd k="Cmd:m"/> again. - -### Change the gap size between blocks - -The gap between blocks defaults to 3px, but this value can be changed by modifying the `window:tilegapsize` configuration value. See [Configuration](./config) for more information on how to change configuration values. - -</PlatformProvider> diff --git a/docs/docs/telemetry-old.mdx b/docs/docs/telemetry-old.mdx deleted file mode 100644 index dba263dacb..0000000000 --- a/docs/docs/telemetry-old.mdx +++ /dev/null @@ -1,130 +0,0 @@ ---- -id: "telemetry-old" -title: "Legacy Telemetry" -sidebar_class_name: hidden ---- - -Wave Terminal collects telemetry data to help us track feature use, direct future product efforts, and generate aggregate metrics on Wave's popularity and usage. We do not collect or store any PII (personal identifiable information) and all metric data is only associated with and aggregated using your randomly generated _ClientId_. You may opt out of collection at any time. - -If you would like to turn telemetry on or off, the first opportunity is a button on the initial welcome page. After this, it can be turned off by adding `"telemetry:enabled": false` to the `config/settings.json` file. It can alternatively be turned on by adding `"telemetry:enabled": true` to the `config/settings.json` file. - -:::info - -You can also change your telemetry setting by running the wsh command: - -``` -wsh setconfig telemetry:enabled=true -``` - -::: - ---- - -## Sending Telemetry - -Provided that telemetry is enabled, it is sent 10 seconds after Waveterm is first booted and then again every 4 hours thereafter. It can also be sent in response to a few special cases listed below. When telemetry is sent, it is grouped into individual days as determined by your time zone. Any data from a previous day is marked as `Uploaded` so it will not need to be sent again. - -### Sending Once Telemetry is Enabled - -As soon as telemetry is enabled, a telemetry update is sent regardless of how long it has been since the last send. This does not reset the usual timer for telemetry sends. - -### Notifying that Telemetry is Disabled - -As soon as telemetry is disabled, Waveterm sends a special update that notifies us of this change. See [When Telemetry is Turned Off](#when-telemetry-is-turned-off) for more info. The timer still runs in the background but no data is sent. - -### When Waveterm is Closed - -Provided that telemetry is enabled, it will be sent when Waveterm is closed. - ---- - -## Telemetry Data - -When telemetry is active, we collect the following data. It is stored in the `telemetry.TelemetryData` type in the source code. - -| Name | Description | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ActiveMinutes | The number of minutes that the user has actively used Waveterm on a given day. This requires the terminal window to be in focus while the user is actively interacting with it. | -| FgMinutes | The number of minutes that Waveterm has been in the foreground on a given day. This requires the terminal window to be in focus regardless of user interaction. | -| OpenMinutes | The number of minutes that Waveterm has been open on a given day. This only requires that the terminal is open, even if the window is out of focus. | -| NumBlocks | The number of existing blocks open on a given day | -| NumTabs | The number of existing tabs open on a given day. | -| NewTab | The number of new tabs created on a given day | -| NumWindows | The number of existing windows open on a given day. | -| NumWS | The number of existing workspaces on a given day. | -| NumWSNamed | The number of named workspaces on a give day. | -| NewTab | The number of new tabs opened on a given day. | -| NumStartup | The number of times waveterm has been started on a given day. | -| NumShutdown | The number of times waveterm has been shut down on a given day. | -| SetTabTheme | The number of times the tab theme is changed from the context menu | -| NumMagnify | The number of times any block is magnified | -| NumPanics | The number of backend (golang) panics caught in the current day | -| NumAIReqs | The number of AI requests made in the current day | -| NumSSHConn | The number of distinct SSH connections that have been made to distinct hosts | -| NumWSLConns | The number of distinct WSL connections that have been made to distinct distros | -| Renderers | The number of new block views of each type are open on a given day. | -| WshCmds | The number of wsh commands of each type run on a given day | -| Blocks | The number of blocks of different view types open on a given day | -| Conn | The number of successful remote connections made (and errors) on a given day | - -## Associated Data - -In addition to the telemetry data collected, the following is also reported. It is stored in the `telemetry.ActivityType` type in the source code. - -| Name | Description | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Day | The date the telemetry is associated with. It does not include the time. | -| Uploaded | A boolean that indicates if the telemetry for this day is finalized. It is false during the day the telemetry is associated with, but gets set true at the first telemetry upload after that. Once it is true, the data for that particular day will not be sent up with the telemetry any more. | -| TzName | The code for the timezone the user's OS is reporting (e.g. PST, GMT, JST) | -| TzOffset | The offset for the timezone the user's OS is reporting (e.g. -08:00, +00:00, +09:00) | -| ClientVersion | Which version of Waveterm is installed. | -| ClientArch | This includes the user's operating system (e.g. linux or darwin) and architecture (e.g. x86_64 or arm64). It does not include data for any Connections at this time. | -| BuildTime | This serves as a more accurate version number that keeps track of when we built the version. It has no bearing on when that version was installed by you. | -| OSRelease | This lists the version of the operating system the user has installed. | -| Displays | Display resolutions (added in v0.9.3 to help us understand what screen resolutions to optimize for) | - -## Telemetry Metadata - -Lastly, some data is sent along with the telemetry that describes how to classify it. It is stored in the `wcloud.TelemetryInputType` in the source code. - -| Name | Description | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------- | -| UserId | Currently Unused. This is an anonymous UUID intended for use in future features. | -| ClientId | This is an anonymous UUID created when Waveterm is first launched. It is used for telemetry and sending prompts to Open AI. | -| AppType | This is used to differentiate the current version of waveterm from the legacy app. | -| AutoUpdateEnabled | Whether or not auto update is turned on. | -| AutoUpdateChannel | The type of auto update in use. This specifically refers to whether a latest or beta channel is selected. | -| CurDay | The current day (in your time zone) when telemetry is sent. It does not include the time of day. | - -## Geo Data - -We do not store IP addresses in our telemetry table. However, CloudFlare passes us Geo-Location headers. We store these two header values: - -| Name | Description | -| ------------ | ----------------------------------------------------------------- | -| CFCountry | 2-letter country code (e.g. "US", "FR", or "JP") | -| CFRegionCode | region code (often a provence, region, or state within a country) | - ---- - -## When Telemetry is Turned Off - -When a user disables telemetry, Waveterm sends a notification that their anonymous _ClientId_ has had its telemetry disabled. This is done with the `wcloud.NoTelemetryInputType` type in the source code. Beyond that, no further information is sent unless telemetry is turned on again. If it is turned on again, the previous 30 days of telemetry will be sent. - ---- - -## A Note on IP Addresses - -Telemetry is uploaded via https, which means your IP address is known to the telemetry server. We **do not** store your IP address in our telemetry table and **do not** associate it with your _ClientId_. - ---- - -## Previously Collected Telemetry Data - -While we believe the data we collect with telemetry is fairly minimal, we cannot make that decision for every user. If you ever change your mind about what has been collected previously, you may request that your data be deleted by emailing us at [support@waveterm.dev](mailto:support@waveterm.dev). If you do, we will need your _ClientId_ to remove it. - ---- - -## Privacy Policy - -For a summary of the above, you can take a look at our [Privacy Policy](https://www.waveterm.dev/privacy). diff --git a/docs/docs/telemetry.mdx b/docs/docs/telemetry.mdx deleted file mode 100644 index 2f9132276d..0000000000 --- a/docs/docs/telemetry.mdx +++ /dev/null @@ -1,71 +0,0 @@ ---- -sidebar_position: 100 -title: Telemetry -id: "telemetry" ---- - -## tl;dr - -Wave Terminal collects telemetry data to help us track feature use, direct future product efforts, and generate aggregate metrics on Wave's popularity and usage. We do NOT collect personal information (PII), keystrokes, file contents, AI prompts, IP addresses, hostnames, or commands. We attach all information to an anonymous, randomly generated _ClientId_ (UUID). You may opt out of collection at any time. - -Here's a quick summary of what is collected: - -- Basic App/System Info - OS, architecture, app version, update settings -- Usage Metrics - App start/shutdown, active minutes, foreground time, tab/block counts/usage -- Feature Interactions - When you create tabs, run commands, change settings, etc. -- Display Info - Monitor resolution, number of displays -- Connection Events - SSH/WSL connection attempts (but NOT hostnames/IPs) -- Wave AI Usage - Model/provider selection, token counts, request metrics, latency (but NOT prompts or responses) -- Error Reports - Crash/panic events with minimal debugging info, but no stack traces or detailed errors - -Telemetry can be disabled at any time in settings. If not disabled it is sent on startup, on shutdown, and every 4-hours. - -## How to Disable Telemetry - -Telemetry can be enabled or disabled on the initial welcome screen when Wave first starts. After setup, telemetry can be disabled by setting the `telemetry:enabled` key to `false` in Wave’s general configuration file. It can also be disabled using the CLI command `wsh setconfig telemetry:enabled=false`. - -:::info - -This document outlines the current telemetry system as of v0.11.1. As of v0.12.5, Wave Terminal no longer sends legacy telemetry. The previous telemetry documentation can be found in our [Legacy Telemetry Documentation](./telemetry-old.mdx) for historical reference. - -::: - -## Diagnostics Ping - -Wave sends a small, anonymous diagnostics ping after the app has been running for a short time and at most once per day thereafter. This is used to estimate active installs and understand which versions are still in use, so we can make informed decisions about ongoing support and deprecations. - -The ping includes only: your Wave version, OS/CPU arch, local date (yyyy-mm-dd, no timezone or clock time), your randomly generated anonymous client ID, and whether usage telemetry is enabled or disabled. - -It does not include usage data, commands, files, or any telemetry events. - -This ping is intentionally separate from telemetry so Wave can count active installs. If you'd like to disable it, set the WAVETERM_NOPING environment variable. - -## Sending Telemetry - -Provided that telemetry is enabled, it is sent shortly after Wave is first launched and then again every 4 hours thereafter. It can also be sent in response to a few special cases listed below. When telemetry is sent, events are marked as sent to prevent duplicate transmissions. - -### Sending Once Telemetry is Enabled - -As soon as telemetry is enabled, a telemetry update is sent regardless of how long it has been since the last send. This does not reset the usual timer for telemetry sends. - -### When Wave is Closed - -Provided that telemetry is enabled, it will be sent when Waveterm is closed. - -## Event Types and Properties - -Wave collects the event types and properties described in the summary above. As we add features, new events and properties may be added to track their usage. - -For the complete, current list of all telemetry events and properties, see the source code: [telemetrydata.go](https://github.com/wavetermdev/waveterm/blob/main/pkg/telemetry/telemetrydata/telemetrydata.go) - -## GDPR Opt-Out Compliance - -When telemetry is disabled, Wave sends a single minimal opt-out record associated with the anonymous client ID, recording that telemetry was turned off and when it occurred. This record is retained for compliance purposes. After that, no telemetry or usage data is sent. - -## Deleting Your Data - -If you want your previously collected telemetry data deleted, email us at support (at) waveterm.dev with your _ClientId_ and we'll remove it. - -## Privacy Policy - -For a summary of the above, you can take a look at our [Privacy Policy](https://www.waveterm.dev/privacy). diff --git a/docs/docs/waveai-modes.mdx b/docs/docs/waveai-modes.mdx deleted file mode 100644 index 62045b86a9..0000000000 --- a/docs/docs/waveai-modes.mdx +++ /dev/null @@ -1,565 +0,0 @@ ---- -sidebar_position: 1.6 -id: "waveai-modes" -title: "Wave AI (Local Models + BYOK)" ---- - -import { VersionBadge } from "@site/src/components/versionbadge"; - -<VersionBadge version="v0.13" noLeftMargin={true}/> - -Wave AI supports custom AI modes that allow you to use local models, custom API endpoints, and alternative AI providers. This gives you complete control over which models and providers you use with Wave's AI features. - -## Configuration Overview - -AI modes are configured in `~/.config/waveterm/waveai.json`. - -**To edit using the UI:** -1. Click the settings (gear) icon in the widget bar -2. Select "Settings" from the menu -3. Choose "Wave AI Modes" from the settings sidebar - -**Or launch from the command line:** -```bash -wsh editconfig waveai.json -``` - -Each mode defines a complete AI configuration including the model, API endpoint, authentication, and display properties. - -## Provider-Based Configuration - -Wave AI now supports provider-based configuration which automatically applies sensible defaults for common providers. By specifying the `ai:provider` field, you can significantly simplify your configuration as the system will automatically set up endpoints, API types, and secret names. - -### Supported Providers - -- **`openai`** - OpenAI API (automatically configures endpoint and secret name) [[see example](#openai)] -- **`openrouter`** - OpenRouter API (automatically configures endpoint and secret name) [[see example](#openrouter)] -- **`nanogpt`** - NanoGPT API (automatically configures endpoint and secret name) [[see example](#nanogpt)] -- **`groq`** - Groq API (automatically configures endpoint and secret name) [[see example](#groq)] -- **`google`** - Google AI (Gemini) [[see example](#google-ai-gemini)] -- **`azure`** - Azure OpenAI Service (modern API) [[see example](#azure-openai-modern-api)] -- **`azure-legacy`** - Azure OpenAI Service (legacy deployment API) [[see example](#azure-openai-legacy-deployment-api)] -- **`custom`** - Custom API endpoint (fully manual configuration) [[see examples](#local-model-examples)] - -### Supported API Types - -Wave AI supports the following API types: - -- **`openai-chat`**: Uses the `/v1/chat/completions` endpoint (most common) -- **`openai-responses`**: Uses the `/v1/responses` endpoint (modern API for GPT-5+ models) -- **`google-gemini`**: Google's Gemini API format (automatically set when using `ai:provider: "google"`, not typically used directly) - -## Global Wave AI Settings - -You can configure global Wave AI behavior in your Wave Terminal settings (separate from the mode configurations in `waveai.json`). - -### Setting a Default AI Mode - -After configuring a local model or custom mode, you can make it the default by setting `waveai:defaultmode` in your Wave Terminal settings. - -:::important -Use the **mode key** (the key in your `waveai.json` configuration), not the display name. For example, use `"ollama-llama"` (the key), not `"Ollama - Llama 3.3"` (the display name). -::: - -**Using the settings command:** -```bash -wsh setconfig waveai:defaultmode="ollama-llama" -``` - -**Or edit settings.json directly:** -1. Click the settings (gear) icon in the widget bar -2. Select "Settings" from the menu -3. Add the `waveai:defaultmode` key to your settings.json: -```json - "waveai:defaultmode": "ollama-llama" -``` - -This will make the specified mode the default selection when opening Wave AI features. - -:::note -Wave AI normally requires telemetry to be enabled. However, if you configure your own custom model (local or BYOK) and set `waveai:defaultmode` to that custom mode's key, you will not receive telemetry requirement messages. This allows you to use Wave AI features completely privately with your own models. <VersionBadge version="v0.13.1"/> -::: - -### Hiding Wave Cloud Modes - -If you prefer to use only your local or custom models and want to hide Wave's cloud AI modes from the mode dropdown, set `waveai:showcloudmodes` to `false`: - -**Using the settings command:** -```bash -wsh setconfig waveai:showcloudmodes=false -``` - -**Or edit settings.json directly:** -1. Click the settings (gear) icon in the widget bar -2. Select "Settings" from the menu -3. Add the `waveai:showcloudmodes` key to your settings.json: -```json - "waveai:showcloudmodes": false -``` - -This will hide Wave's built-in cloud AI modes, showing only your custom configured modes. - -## Local Model Examples - -### Ollama - -[Ollama](https://ollama.ai) provides an OpenAI-compatible API for running models locally: - -```json -{ - "ollama-llama": { - "display:name": "Ollama - Llama 3.3", - "display:order": 1, - "display:icon": "microchip", - "display:description": "Local Llama 3.3 70B model via Ollama", - "ai:apitype": "openai-chat", - "ai:model": "llama3.3:70b", - "ai:thinkinglevel": "medium", - "ai:endpoint": "http://localhost:11434/v1/chat/completions", - "ai:apitoken": "ollama" - } -} -``` - -:::tip -The `ai:apitoken` field is required but Ollama ignores it - you can set it to any value like `"ollama"`. -::: - -### LM Studio - -[LM Studio](https://lmstudio.ai) provides a local server that can run various models: - -```json -{ - "lmstudio-qwen": { - "display:name": "LM Studio - Qwen", - "display:order": 2, - "display:icon": "server", - "display:description": "Local Qwen model via LM Studio", - "ai:apitype": "openai-chat", - "ai:model": "qwen/qwen-2.5-coder-32b-instruct", - "ai:thinkinglevel": "medium", - "ai:endpoint": "http://localhost:1234/v1/chat/completions", - "ai:apitoken": "not-needed" - } -} -``` - -### vLLM - -[vLLM](https://docs.vllm.ai) is a high-performance inference server with OpenAI API compatibility: - -```json -{ - "vllm-local": { - "display:name": "vLLM", - "display:order": 3, - "display:icon": "server", - "display:description": "Local model via vLLM", - "ai:apitype": "openai-chat", - "ai:model": "your-model-name", - "ai:thinkinglevel": "medium", - "ai:endpoint": "http://localhost:8000/v1/chat/completions", - "ai:apitoken": "not-needed" - } -} -``` - -## Cloud Provider Examples - -### OpenAI - -Using the `openai` provider automatically configures the endpoint and secret name: - -```json -{ - "openai-gpt4o": { - "display:name": "GPT-4o", - "ai:provider": "openai", - "ai:model": "gpt-4o" - } -} -``` - -The provider automatically sets: -- `ai:endpoint` to `https://api.openai.com/v1/chat/completions` -- `ai:apitype` to `openai-chat` (or `openai-responses` for GPT-5+ models) -- `ai:apitokensecretname` to `OPENAI_KEY` (store your OpenAI API key with this name) -- `ai:capabilities` to `["tools", "images", "pdfs"]` (automatically determined based on model) - -For newer models like GPT-4.1 or GPT-5, the API type is automatically determined: - -```json -{ - "openai-gpt41": { - "display:name": "GPT-4.1", - "ai:provider": "openai", - "ai:model": "gpt-4.1" - } -} -``` - -### OpenAI Compatible - -To use an OpenAI compatible API provider, you need to provide the ai:endpoint, ai:apitokensecretname, ai:model parameters, -and use "openai-chat" as the ai:apitype. - -:::note -The ai:endpoint is *NOT* a baseurl. The endpoint should contain the full endpoint, not just the baseurl. -For example: https://api.x.ai/v1/chat/completions - -If you provide only the baseurl, you are likely to get a 404 message. -::: - -```json -{ - "xai-grokfast": { - "display:name": "xAI Grok Fast", - "display:order": 2, - "display:icon": "server", - "ai:apitype": "openai-chat", - "ai:model": "grok-4-1-fast-reasoning", - "ai:endpoint": "https://api.x.ai/v1/chat/completions", - "ai:apitokensecretname": "XAI_KEY", - "ai:capabilities": ["tools", "images", "pdfs"] - } -} -``` - -The `ai:apitokensecretname` should be the name of an environment variable that contains your API key. Set this environment variable before running Wave Terminal. - - -### OpenRouter - -[OpenRouter](https://openrouter.ai) provides access to multiple AI models. Using the `openrouter` provider simplifies configuration: - -```json -{ - "openrouter-qwen": { - "display:name": "OpenRouter - Qwen", - "ai:provider": "openrouter", - "ai:model": "qwen/qwen-2.5-coder-32b-instruct" - } -} -``` - -The provider automatically sets: -- `ai:endpoint` to `https://openrouter.ai/api/v1/chat/completions` -- `ai:apitype` to `openai-chat` -- `ai:apitokensecretname` to `OPENROUTER_KEY` (store your OpenRouter API key with this name) - -:::note -For OpenRouter, you must manually specify `ai:capabilities` based on your model's features. Example: -```json -{ - "openrouter-qwen": { - "display:name": "OpenRouter - Qwen", - "ai:provider": "openrouter", - "ai:model": "qwen/qwen-2.5-coder-32b-instruct", - "ai:capabilities": ["tools"] - } -} -``` -::: - -### NanoGPT - -[NanoGPT](https://nano-gpt.com) provides access to multiple AI models at competitive prices. Using the `nanogpt` provider simplifies configuration: - -```json -{ - "nanogpt-glm47": { - "display:name": "NanoGPT - GLM 4.7", - "ai:provider": "nanogpt", - "ai:model": "zai-org/glm-4.7" - } -} -``` - -The provider automatically sets: -- `ai:endpoint` to `https://nano-gpt.com/api/v1/chat/completions` -- `ai:apitype` to `openai-chat` -- `ai:apitokensecretname` to `NANOGPT_KEY` (store your NanoGPT API key with this name) - -:::note -NanoGPT is a proxy service that provides access to multiple AI models. You must manually specify `ai:capabilities` based on the model's features. NanoGPT supports OpenAI-compatible tool calling for models that have that capability. Check the model's `capabilities.vision` field from the [NanoGPT models API](https://nano-gpt.com/api/v1/models?detailed=true) to determine image support. Example for a text-only model with tool support: -```json -{ - "nanogpt-glm47": { - "display:name": "NanoGPT - GLM 4.7", - "ai:provider": "nanogpt", - "ai:model": "zai-org/glm-4.7", - "ai:capabilities": ["tools"] - } -} -``` -For vision-capable models like `openai/gpt-5`, add `"images"` to capabilities. -::: - -### Groq - -[Groq](https://groq.com) provides fast inference for open models through an OpenAI-compatible API. Using the `groq` provider simplifies configuration: - -```json -{ - "groq-kimi-k2": { - "display:name": "Groq - Kimi K2", - "ai:provider": "groq", - "ai:model": "moonshotai/kimi-k2-instruct" - } -} -``` - -The provider automatically sets: -- `ai:endpoint` to `https://api.groq.com/openai/v1/chat/completions` -- `ai:apitype` to `openai-chat` -- `ai:apitokensecretname` to `GROQ_KEY` (store your Groq API key with this name) - -:::note -For Groq, you must manually specify `ai:capabilities` based on your model's features. -::: - -### Google AI (Gemini) - -[Google AI](https://ai.google.dev) provides the Gemini family of models. Using the `google` provider simplifies configuration: - -```json -{ - "google-gemini": { - "display:name": "Gemini 3 Pro", - "ai:provider": "google", - "ai:model": "gemini-3-pro-preview" - } -} -``` - -The provider automatically sets: -- `ai:endpoint` to `https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent` -- `ai:apitype` to `google-gemini` -- `ai:apitokensecretname` to `GOOGLE_AI_KEY` (store your Google AI API key with this name) -- `ai:capabilities` to `["tools", "images", "pdfs"]` (automatically configured) - -### Azure OpenAI (Modern API) - -For the modern Azure OpenAI API, use the `azure` provider: - -```json -{ - "azure-gpt4": { - "display:name": "Azure GPT-4", - "ai:provider": "azure", - "ai:model": "gpt-4", - "ai:azureresourcename": "your-resource-name" - } -} -``` - -The provider automatically sets: -- `ai:endpoint` to `https://your-resource-name.openai.azure.com/openai/v1/chat/completions` (or `/responses` for newer models) -- `ai:apitype` based on the model -- `ai:apitokensecretname` to `AZURE_OPENAI_KEY` (store your Azure OpenAI key with this name) - -:::note -For Azure providers, you must manually specify `ai:capabilities` based on your model's features. Example: -```json -{ - "azure-gpt4": { - "display:name": "Azure GPT-4", - "ai:provider": "azure", - "ai:model": "gpt-4", - "ai:azureresourcename": "your-resource-name", - "ai:capabilities": ["tools", "images"] - } -} -``` -::: - -### Azure OpenAI (Legacy Deployment API) - -For legacy Azure deployments, use the `azure-legacy` provider: - -```json -{ - "azure-legacy-gpt4": { - "display:name": "Azure GPT-4 (Legacy)", - "ai:provider": "azure-legacy", - "ai:azureresourcename": "your-resource-name", - "ai:azuredeployment": "your-deployment-name" - } -} -``` - -The provider automatically constructs the full endpoint URL and sets the API version (defaults to `2025-04-01-preview`). You can override the API version with `ai:azureapiversion` if needed. - -:::note -For Azure Legacy provider, you must manually specify `ai:capabilities` based on your model's features. -::: - -## Using Secrets for API Keys - -Instead of storing API keys directly in the configuration, you should use Wave's secret store to keep your credentials secure. Secrets are stored encrypted using your system's native keychain. - -### Storing an API Key - -**Using the Secrets UI (recommended):** -1. Click the settings (gear) icon in the widget bar -2. Select "Secrets" from the menu -3. Click "Add New Secret" -4. Enter the secret name (e.g., `OPENAI_API_KEY`) and your API key -5. Click "Save" - -**Or from the command line:** -```bash -wsh secret set OPENAI_KEY=sk-xxxxxxxxxxxxxxxx -wsh secret set OPENROUTER_KEY=sk-xxxxxxxxxxxxxxxx -``` - -### Referencing the Secret - -When using providers like `openai` or `openrouter`, the secret name is automatically set. Just ensure the secret exists with the correct name: - -```json -{ - "my-openai-mode": { - "display:name": "OpenAI GPT-4o", - "ai:provider": "openai", - "ai:model": "gpt-4o" - } -} -``` - -The `openai` provider automatically looks for the `OPENAI_KEY` secret. See the [Secrets documentation](./secrets.mdx) for more information on managing secrets securely in Wave. - -## Multiple Modes Example - -You can define multiple AI modes and switch between them easily: - -```json -{ - "ollama-llama": { - "display:name": "Ollama - Llama 3.3", - "display:order": 1, - "ai:model": "llama3.3:70b", - "ai:endpoint": "http://localhost:11434/v1/chat/completions", - "ai:apitoken": "ollama" - }, - "ollama-codellama": { - "display:name": "Ollama - CodeLlama", - "display:order": 2, - "ai:model": "codellama:34b", - "ai:endpoint": "http://localhost:11434/v1/chat/completions", - "ai:apitoken": "ollama" - }, - "openai-gpt4o": { - "display:name": "GPT-4o", - "display:order": 10, - "ai:provider": "openai", - "ai:model": "gpt-4o" - } -} -``` - -## Troubleshooting - -### Connection Issues - -If Wave can't connect to your model server: - -1. **For cloud providers with `ai:provider` set**: Ensure you have the correct secret stored (e.g., `OPENAI_KEY`, `OPENROUTER_KEY`) -2. **For local/custom endpoints**: Verify the server is running (`curl http://localhost:11434/v1/models` for Ollama) -3. Check the `ai:endpoint` is the complete endpoint URL including the path (e.g., `http://localhost:11434/v1/chat/completions`) -4. Verify the `ai:apitype` matches your server's API (defaults are usually correct when using providers) -5. Check firewall settings if using a non-localhost address - -### Model Not Found - -If you get "model not found" errors: - -1. Verify the model name matches exactly what your server expects -2. For Ollama, use `ollama list` to see available models -3. Some servers require prefixes or specific naming formats - -### API Type Selection - -- The API type defaults to `openai-chat` if not specified, which works for most providers -- Use `openai-chat` for Ollama, LM Studio, custom endpoints, and most cloud providers -- Use `openai-responses` for newer OpenAI models (GPT-5+) or when your provider specifically requires it -- Provider presets automatically set the correct API type when needed - -## Configuration Reference - -### Minimal Configuration (with Provider) - -```json -{ - "mode-key": { - "display:name": "Qwen (OpenRouter)", - "ai:provider": "openrouter", - "ai:model": "qwen/qwen-2.5-coder-32b-instruct" - } -} -``` - -### Full Configuration (all fields) - -```json -{ - "mode-key": { - "display:name": "Display Name", - "display:order": 1, - "display:icon": "icon-name", - "display:description": "Full description", - "ai:provider": "custom", - "ai:apitype": "openai-chat", - "ai:model": "model-name", - "ai:thinkinglevel": "medium", - "ai:endpoint": "http://localhost:11434/v1/chat/completions", - "ai:azureapiversion": "v1", - "ai:apitoken": "your-token", - "ai:apitokensecretname": "PROVIDER_KEY", - "ai:azureresourcename": "your-resource", - "ai:azuredeployment": "your-deployment", - "ai:capabilities": ["tools", "images", "pdfs"] - } -} -``` - -### Field Reference - -| Field | Required | Description | -|-------|----------|-------------| -| `display:name` | Yes | Name shown in the AI mode selector | -| `display:order` | No | Sort order in the selector (lower numbers first) | -| `display:icon` | No | Icon identifier for the mode (can use any [FontAwesome icon](https://fontawesome.com/search), use the name without the "fa-" prefix). Default is "sparkles" | -| `display:description` | No | Full description of the mode | -| `ai:provider` | No | Provider preset: `openai`, `openrouter`, `nanogpt`, `groq`, `google`, `azure`, `azure-legacy`, `custom` | -| `ai:apitype` | No | API type: `openai-chat`, `openai-responses`, or `google-gemini` (defaults to `openai-chat` if not specified) | -| `ai:model` | No | Model identifier (required for most providers) | -| `ai:thinkinglevel` | No | Thinking level: `low`, `medium`, or `high` | -| `ai:endpoint` | No | *Full* API endpoint URL (auto-set by provider when available) | -| `ai:azureapiversion` | No | Azure API version (for `azure-legacy` provider, defaults to `2025-04-01-preview`) | -| `ai:apitoken` | No | API key/token (not recommended - use secrets instead) | -| `ai:apitokensecretname` | No | Name of secret containing API token (auto-set by provider) | -| `ai:azureresourcename` | No | Azure resource name (for Azure providers) | -| `ai:azuredeployment` | No | Azure deployment name (for `azure-legacy` provider) | -| `ai:capabilities` | No | Array of supported capabilities: `"tools"`, `"images"`, `"pdfs"` | -| `waveai:cloud` | No | Internal - for Wave Cloud AI configuration only | -| `waveai:premium` | No | Internal - for Wave Cloud AI configuration only | - -### AI Capabilities - -The `ai:capabilities` field specifies what features the AI mode supports: - -- **`tools`** - Enables AI tool usage for file reading/writing, shell integration, and widget interaction -- **`images`** - Allows image attachments in chat (model can view uploaded images) -- **`pdfs`** - Allows PDF file attachments in chat (model can read PDF content) - -**Provider-specific behavior:** -- **OpenAI and Google providers**: Capabilities are automatically configured based on the model. You don't need to specify them. -- **OpenRouter, NanoGPT, Groq, Azure, Azure-Legacy, and Custom providers**: You must manually specify capabilities based on your model's features. - -:::warning -If you don't include `"tools"` in the `ai:capabilities` array, the AI model will not be able to interact with your Wave terminal widgets, read/write files, or execute commands. Most AI modes should include `"tools"` for the best Wave experience. -::: - -Most models support `tools` and can benefit from it. Vision-capable models should include `images`. Not all models support PDFs, so only include `pdfs` if your model can process them. diff --git a/docs/docs/waveai.mdx b/docs/docs/waveai.mdx deleted file mode 100644 index 5189bc6792..0000000000 --- a/docs/docs/waveai.mdx +++ /dev/null @@ -1,110 +0,0 @@ ---- -sidebar_position: 1.5 -id: "waveai" -title: "Wave AI" ---- - -import { Kbd } from "@site/src/components/kbd"; -import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; - -<PlatformProvider> - -<PlatformSelectorButton /> - -<br/><br/> -Context-aware terminal assistant with access to terminal output, widgets, and filesystem. - -## Keyboard Shortcuts - -| Shortcut | Action | -|----------|--------| -| <Kbd k="Cmd:Shift:a"/> | Toggle AI panel | -| <Kbd k="Ctrl:Shift:0" windows="Alt:0"/> | Focus AI input | -| <Kbd k="Cmd:k"/> | Clear chat / start new | -| <Kbd k="Enter"/> | Send message | -| <Kbd k="Shift:Enter"/> | New line | - -## Widget Context Toggle - -Controls AI's access to your workspace: - -**ON**: AI can read terminal output, capture widget screenshots, access files/directories (with approval), navigate web widgets, and use custom widget tools. Use for debugging, code analysis, and workspace tasks. - -**OFF**: AI only sees your messages and attached files. Standard chat mode for general questions. - -## File Attachments - -Drag files onto the AI panel to attach (not supported with all models): - -| Type | Formats | Size Limit | Notes | -|------|---------|------------|-------| -| Images | JPEG, PNG, GIF, WebP, SVG | 10 MB | Auto-resized to 4096px max, converted to WebP | -| PDFs | `.pdf` | 5 MB | Text extraction for analysis | -| Text/Code | `.js`, `.ts`, `.py`, `.go`, `.md`, `.json`, `.yaml`, etc. | 200 KB | All common languages and configs | - -## CLI Integration - -Use `wsh ai` to send files and prompts from the command line: - -```bash -git diff | wsh ai - # Pipe to AI -wsh ai main.go -m "find bugs" # Attach files with message -wsh ai $(tail -n 500 my.log) -m "review" -s # Auto-submit with output -``` - -Supports text files, images, PDFs, and directories. Use `-n` for new chat, `-s` to auto-submit. - -## AI Tools (Widget Context Enabled) - -### Terminal -- **Read Terminal Output**: Fetches scrollback from terminal widgets, supports line ranges - -### File System -- **Read Files**: Reads text files with line range support (requires approval) -- **List Directories**: Returns file info, sizes, permissions, timestamps (requires approval) -- **Write Text Files**: Create or modify files with diff preview and approval (requires approval) - -### Web -- **Navigate Web**: Changes URLs in web browser widgets - -### All Widgets -- **Capture Screenshots**: Takes screenshots of any widget for visual analysis (not supported on all models) - -:::warning Security -File system operations require explicit approval. You control all file access. -::: - -## Local Models & BYOK - -Wave AI supports using your own AI models and API keys: - -- **Local Models**: Run AI models locally with [Ollama](https://ollama.ai), [LM Studio](https://lmstudio.ai), [vLLM](https://docs.vllm.ai), and other OpenAI-compatible servers -- **BYOK (Bring Your Own Key)**: Use your own API keys with OpenAI, OpenRouter, Google AI (Gemini), Azure OpenAI, and other cloud providers -- **Multiple Modes**: Configure and switch between multiple AI providers and models -- **Privacy**: Keep your data local or use your preferred cloud provider - -See the [**Local Models & BYOK guide**](./waveai-modes.mdx) for complete configuration instructions, examples, and troubleshooting. - -## Privacy - -**Default Wave AI Service:** -- Messages are proxied through the Wave Cloud AI service (powered by OpenAI's APIs). Please refer to OpenAI's privacy policy for details on how they handle your data. -- Wave does not store your chats, attachments, or use them for training -- Usage counters included in anonymous telemetry -- File access requires explicit approval - -**Local Models & BYOK:** -- When using local models, your chat data never leaves your machine -- When using BYOK with cloud providers, requests are sent directly to your chosen provider -- Refer to your provider's privacy policy for details on how they handle your data - -:::info Under Active Development -Wave AI is in active beta with included AI credits while we refine the experience. Share feedback in our [Discord](https://discord.gg/XfvZ334gwU). - -**Coming Soon:** -- **Remote File Access**: Read files on SSH-connected systems -- **Command Execution**: Run terminal commands with approval -- **Web Content**: Extract text from web pages (currently screenshots only) -::: - -</PlatformProvider> \ No newline at end of file diff --git a/docs/docs/widgets.mdx b/docs/docs/widgets.mdx deleted file mode 100644 index 52257619d1..0000000000 --- a/docs/docs/widgets.mdx +++ /dev/null @@ -1,148 +0,0 @@ ---- -sidebar_position: 3.3 -id: "widgets" -title: "Widgets" ---- - -import { Kbd } from "@site/src/components/kbd"; -import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; -import { VersionBadge } from "@site/src/components/versionbadge"; - -<PlatformProvider> - -# Widgets - -Every individual Component is contained in its own widget. These can be added, removed, moved and resized. Each widget has its own header which can be right clicked to reveal more operations you can do with that widget. - -<PlatformSelectorButton /> - -### How to Add a Widget - -Adding a widget can be done using the widget bar on the right hand side of the window. This will add a widget of the selected type to the current tab. - -### How to Close a Widget - -Widgets can be closed by clicking the **<code><i className="fa-solid fa-sharp fa-xmark"/></code>** button on the right side of the header. Alternatively, the currently focused widget can be closed by pressing <Kbd k="Cmd:w"/> - -### How to Navigate Widgets - -At most, it is possible to have one widget be focused. Depending on the type of widget, this allows you to directly interact with the content in that widget. A focused widget is always outlined with a distinct border. A widget may be focused by clicking on it. Alternatively, you can change the focused widget by pressing <Kbd k="Ctrl:Shift:Arrows"/> (Ctrl + Shift + Arrow Keys) to navigate relative to the currently selected widget. - -### How to Magnify Widgets - -Magnifying a widget will pop the widget out in front of everything else. You can magnify using the header icon, or with <Kbd k="Cmd:m"/>. - -### How to Reorganize Widgets - -By dragging and dropping their headers, widgets can be moved to different locations in the layout. This effectively allows you to reorganize your screen however you see fit. When dragging, you will see a preview of the widget that is being dragged. When the widget is over a valid drop point, the area where it would be moved to will turn green. Releasing the click will place the widget there and reflow the other widgets around it. If you see a green box cover half of two different widgets, the drop will place the widget between the two. If you see the green box cover half of one widget at the edge of the screen, the widget will be placed between that widget and the edge of the screen. If you see the green box cover one widget entirely, the two widgets will swap locations. - -See [Tab Layout System](./layout#move-a-block) for more information. - -### How to Resize Widgets - -Hovering the mouse between two widgets changes your cursor to <i className="fa-sharp fa-arrows-left-right"/> or <i className="fa-sharp fa-arrows-up-down"/>; and reveals a green line dividing the widgets. By dragging and dropping this green line, you are able to resize the widgets adjacent to it. - -See [Tab Layout System](./layout#resize-a-block) for more information. - -## Types of Widgets - -### Term - -The usual terminal you know and love. We add a few plugins via the `wsh` command that you can read more about further below. - -### Preview - -Preview is the generic type of widget used for viewing files. This can take many different forms based on the type of file being viewed. -You can use \`wsh view [path]\` from any Wave terminal window to open a preview widget with the contents of the specified path (e.g. `wsh view .` or `wsh view ~/myimage.jpg`). - -#### Directory - -When looking at a directory, preview will show a file viewer much like MacOS' _Finder_ application or Windows' _File Explorer_ application. This variant is slightly more geared toward software development with the focus on seeing what is shown by the `ls -alh` command. - -##### View a New File - -The simplest way to view a new file is to double click its row in the file viewer. Alternatively, while the widget is focused, you can use the <Kbd k="ArrowUp" /> and <Kbd k="ArrowDown" /> arrow keys to select a row and press enter to preview the associated file. - -##### Copy a File - -If you have two directory widgets open, you can copy a file or a directory between them. To do this, simply drag the file or directory from one directory preview widget to another that is opened to where you would like it dropped. This even works for copying files and directories across connections. - -##### View the Parent Directory - -In the directory view, this is as simple as opening the `..` file as if it were a regular file. This can be done with the method above. You can also use the keyboard shortcut <Kbd k="Cmd:ArrowUp"/>. - -##### Navigate Back and Forward - -When looking at a file, you can navigate back by clicking the back button in the widget header or the keyboard shortcut <Kbd k="Cmd:ArrowLeft" />. You can always navigate back and forward using <Kbd k="Cmd:ArrowLeft" /> and <Kbd k="Cmd:ArrowRight" />. - -##### Filter the List of Files - -While the widget is focused, you can filter by filename by typing a substring of the filename you're looking for. To clear the filter, you can click the **<code><i className="fa-solid fa-sharp fa-xmark"/></code>** on the filter dropdown or press <Kbd k="Escape" />. - -##### Sort by a File Column - -To sort a file by a specific column, click on the header for that column. If you click the header again, it will reverse the sort order. - -##### Hide and Show Hidden Files - -At the right of the widget header, there is an **<code><i className="fa fa-sharp fa-solid fa-eye"/></code>** button. Clicking this button hides and shows hidden files. - -##### Refresh the Directory - -At the right of the widget header, there is a refresh button **<code><i className="fa fa-sharp fa-solid fa-arrows-rotate" /></code>**. Clicking this button refreshes the directory contents. - -##### Navigate to Common Directories - -At the left of the widget header, there is a file icon **<code><i className="fa fa-sharp fa-solid fa-folder-open"/></code>**. Clicking and holding on this icon opens a menu where you can select a common folder to navigate to. The available options are _Home_, _Desktop_, _Downloads_, and _Root_. - -##### Open a New Terminal in the Current Directory - -If you right click the header of the widget (alternatively, click the gear icon **<code><i className="fa fa-sharp fa-solid fa-cog"/></code>**), one of the menu items listed is **Open Terminal in New Widget**. This will create a new terminal widget at your current directory. - -##### Open a New Terminal in a Child Directory - -If you want to open a terminal for a child directory instead, you can right click on that file's row to get the **Open Terminal in New Widget** option. Clicking this will open a terminal at that directory. Note that this option is only available for children that are directories. - -##### Open a New Preview for a Child - -To open a new Preview Widget for a Child, you can right click on that file's row and select the **Open Preview in New Widget** option. - -##### Quick Look (MacOS only) - -On a MacOS host, it is possible to use the Quick Look feature from the directory preview. To do this, select the file you wish to view and press <Kbd k="Space" />. This will open a preview of your file in a separate window. This preview can then be closed by pressing <Kbd k="Space" /> again. This currently supports the filetypes that can be accessed by the `qlmanage` command. - -#### Markdown - -Opening a markdown file will bring up a view of the rendered markdown. These files cannot be edited in the preview at this time. - -#### Images/Media - -Opening a picture will bring up the image of that picture. Opening a video will bring up a player that lets you watch the video. - -### Codeedit - -Opening most text files will open Codeedit to either view or edit the file. It is technically part of the Preview widget, but it is important enough to be singled out. -After opening a Codeedit widget, it is often useful to magnify it (<Kbd k="Cmd:m" />) to get a larger view. You can then use the hotkeys below to switch to edit mode, make your edits, save, and then use <Kbd k="Cmd:w" /> to close the widget (all without using the mouse!). - -#### Switch to Edit Mode - -To switch to edit mode, click the edit button to the right of the header. This lets you edit the file contents with a regular Monaco editor. -You can also switch to edit mode by pressing <Kbd k="Cmd:e" />. - -#### Save an Edit - -Once an edit has been made in **edit mode**, click the save button to the right of the header to save the contents. -You can also save by pressing <Kbd k="Cmd:s" />. - -#### Exit Edit Mode Without Saving - -To exit **edit mode** without saving, click the cancel button to the right of the header. -You can also exit without saving by pressing <Kbd k="Cmd:r" />. - -### Process Viewer <VersionBadge version="v0.14.5" /> - -The Process Viewer shows a live list of running processes on any connected host. It is similar to `top` or `htop`, displaying PID, command, CPU%, and memory usage. On Linux it also shows process status and thread count. - -Columns are sortable by clicking their headers. Right-clicking a row lets you send Unix signals (SIGTERM, SIGKILL, etc.) or copy the PID. You can filter the list by pressing <Kbd k="Cmd:f"/> and typing a search term. Press <Kbd k="Space"/> to pause live updates (useful when inspecting a specific process); press it again to resume. - -</PlatformProvider> diff --git a/docs/docs/workspaces.mdx b/docs/docs/workspaces.mdx deleted file mode 100644 index 8a1cae8f42..0000000000 --- a/docs/docs/workspaces.mdx +++ /dev/null @@ -1,72 +0,0 @@ ---- -sidebar_position: 3 -id: "workspaces" -title: "Workspaces" ---- - -# Workspaces - -Workspaces are a powerful way to organize your workflows into separate environments, which you can tailor and optimize. - -## Workspace Switcher - -![Workspace switcher screenshot](./img/workspace-switcher.png#right) - -The primary mechanism to interact with workspaces is via the Workspace Switcher, located to the left of the tab bar. - -This is where you can create a new workspace, edit how a workspace entry appears, and delete a workspace. - -The Workspace Switcher button changes to display the icon and color of the active workspace. If the current workspace is not saved, it will display the <i className="custom-icon-inline custom-icon-workspace"/> icon. Clicking the button will open the Workspace Switcher. - -The Switcher contains a list of all saved workspaces for your installation, each with a customizable icon, icon color, and name. - -The active workspace for the current window will have a <i className="fa fa-sharp fa-check"/> next to it. Any workspace that is currently open in another window will have the <i className="fa fa-sharp fa-window"/> icon next to it. - -Hovering over a workspace in the switcher will display a <i className="fa fa-sharp fa-pencil"/> icon, which will open an editor pane when clicked, in which you can change the workspace name, icon, and icon color. You can also delete a workspace from this pane. - -## Creating a new workspace - -Every new window is initialized with a blank workspace containing a single tab with a single terminal block inside it. There are three ways to create a new workspace: - -1. Create a new window, either via `File` app menu or using the [keybinding](./keybindings.mdx#global-keybindings). This will create a new window and a new workspace within that. -2. Create a new workspace via the `Workspace` app menu. This will create a new workspace and switch the current window to that workspace. -3. If you are on a saved workspace, you can click the "Create new workspace" button at the bottom of the Workspace Switcher. This will create a new workspace and switch the current window to that workspace. - -## Saving a workspace - -:::info - -A new workspace is ephemeral. When a window closes, its workspace, along with all its tabs, is deleted unless the workspace is saved. - -The exception to this rule is the last window will be preserved when closed and will be reopened next time you open the app, regardless of whether the workspace is saved. - -::: - -To preserve a new workspace, you must save it. This can be acheived by clicking the "Save workspace" button at the bottom of the Workspace Switcher. - -If you instead see "Create new workspace" at the bottom of the Workspace Switcher, you are already in a saved workspace. You can also confirm this by checking the wording at the top of the Workspace Switcher. For an unsaved workspace, you will see "Open workspace"; for a saved workspace, you will see "Switch workspaces". You can also confirm this because the icon for the Workspace Switcher button will be <i className="custom-icon-inline custom-icon-workspace"/>. - -Once a workspace is saved, you will see a new entry in the Workspace Switcher list for your saved workspace. It will be named `New Workspace (<random string>)`. To make the most of your workspace, is recommended to change this name, and the icon and icon color, to something more memorable or meaningful. - -## Switching workspaces - -There are two ways to switch workspaces: - -1. From an open window, you can open the Workspace Switcher and click on a workspace from the list. -2. From the Workspace app menu, click on a workspace from the list. - -If the workspace is already open in another window (it has the <i className="fa fa-sharp fa-window"/> next to it if you are in the Workspace Switcher), that window will take focus. - -If the workspace is not open, your current window will switch to it. If your current workspace is unsaved, you will be prompted whether you want to open the new workspace in a new window or whether you want to open it in the current window. **If you choose the latter option, the current workspace and its contents will be deleted.** - -The Workspace Switcher button will update with the colored icon for your new active workspace. - -## Edit a workspace - -:::info - -The tabs, layouts, and terminal and AI histories of a [saved workspace](#saving-a-workspace) are persisted automatically, however if you have unsaved file changes in an editor or a webpage, your progress will be lost when you close the window. - -::: - -To update the name, icon, and icon color of a workspace, hover over the workspace in the Workspace Switcher and click the <i className="fa fa-sharp fa-pencil"/> button that appears. This will open an editor pane, where you can make your changes. They are persisted and updated automatically. diff --git a/docs/docs/wsh-reference.mdx b/docs/docs/wsh-reference.mdx deleted file mode 100644 index 6ed1bcaa3f..0000000000 --- a/docs/docs/wsh-reference.mdx +++ /dev/null @@ -1,1123 +0,0 @@ ---- -sidebar_position: 4.1 -id: "wsh-reference" -title: "wsh reference" ---- - -import { Kbd } from "@site/src/components/kbd"; -import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; -import { VersionBadge } from "@site/src/components/versionbadge"; - -<PlatformProvider> - -# wsh command - -The `wsh` command is always available from Wave blocks. It is a powerful tool for interacting with Wave blocks and can bridge data between your CLI and the widget GUIs. - -This is the detailed wsh reference documention. For an overview of `wsh` functionality, please see our [wsh command docs](/wsh). - ---- - -## view - -You can open a preview block with the contents of any file or directory by running: - -```sh -wsh view [path] -wsh view -m [path] # opens in magnified block -``` - -You can use this command to easily preview images, markdown files, and directories. For code/text files this will open -a codeedit block which you can use to quickly edit the file using Wave's embedded graphical editor. - ---- - -## edit - -```sh -wsh edit [path] -wsh edit -m [path] # opens in magnified block -``` - -This will open up a codeedit block for the specified file. This is useful for quickly editing files on a local or remote machine in Wave's graphical editor. This command returns immediately after opening the block. - -For `$EDITOR` integration (e.g. with `git commit`), see [`wsh editor`](#editor) which blocks until the editor is closed. - ---- - -## editor - -```sh -wsh editor [path] -wsh editor -m [path] # opens in magnified block -``` - -This opens a codeedit block for the specified file and **blocks until the editor is closed**. This is useful for setting your `$EDITOR` environment variable so that CLI tools (e.g. `git commit`, `crontab -e`) open files in Wave's graphical editor: - -```sh -export EDITOR="wsh editor" -``` - -The file must already exist. Use `-m` to open the editor in magnified mode. - ---- - -## getmeta - -You can view the metadata of any block or tab by running: - -```sh -# get the metadata for the current terminal block -wsh getmeta - -# get the metadata for block num 2 (see block numbers by holidng down Ctrl+Shift) -wsh getmeta -b 2 - -# get the metadata for a blockid (get block ids by right clicking any block header "Copy Block Id") -wsh getmeta -b [blockid] - -# get the metadata for a tab -wsh getmeta -b tab - -# dump a single metadata key -wsh getmeta [-b [blockid]] [key] - -# dump a set of keys with a certain prefix -wsh getmeta -b tab "bg:*" - -# dump a set of keys with prefix (and include the 'clear' key) -wsh getmeta -b tab --clear-prefix "bg:*" -``` - -This is especially useful for preview and web blocks as you can see the file or url that they are pointing to and use that in your CLI scripts. - -blockid format: - -- `this` -- the current block (this is also the default) -- `tab` -- the id of the current tab -- `d6ff4966-231a-4074-b78a-20acc7226b41` -- a full blockid is a UUID -- `a67f55a3` -- blockids may be truncated to the first 8 characters -- `5` -- if a number less than 100 is given, it is a block number. blocks are numbered sequentially in the current tab from the top-left to bottom-right. holding <Kbd k="Ctrl:Shift"/> will show a block number overlay. - ---- - -## setmeta - -You can update any metadata key value pair for blocks (and tabs) by using the setmeta command. The setmeta command takes the same `-b` arguments as getmeta. - -```sh -wsh setmeta -b [blockid] [key]=[value] -wsh setmeta -b [blockid] file=~/myfile.txt -wsh setmeta -b [blockid] url=https://waveterm.dev/ - -# set the metadata for the current tab using the given json file -wsh setmeta -b tab --json [jsonfile] - -# set the metadata for the current tab using a json file read from stdin -wsh setmeta -b tab --json -``` - -You can get block and tab ids by right clicking on the appropriate block and selecting "Copy BlockId" (or use the block number via Ctrl:Shift). When you -update the metadata for a preview or web block you'll see the changes reflected instantly in the block. - -Other useful metadata values to override block titles, icons, colors, themes, etc. - -Here's a complex command that will copy the background (bg:\* keys) from one tab to the current tab: - -```sh -wsh getmeta -b [other-tab-id] "bg:*" --clear-prefix | wsh setmeta -b tab --json - -``` - ---- - -## ai - -Append content to the Wave AI sidebar. Files are attached as proper file attachments (supporting images, PDFs, and text), not encoded as text. By default, content is added to the sidebar without auto-submitting, allowing you to review and add more context before sending to the AI. - -You can attach multiple files at once (up to 15 files). Use `-m` to add a message along with files, `-s` to auto-submit immediately, and `-n` to start a new chat conversation. Use "-" to read from stdin. - -```sh -# Pipe command output to AI (ask question in UI) -git diff | wsh ai - -docker logs mycontainer | wsh ai - - -# Attach files without auto-submit (review in UI first) -wsh ai main.go utils.go -wsh ai screenshot.png logs.txt - -# Attach files with message -wsh ai app.py -m "find potential bugs" -wsh ai *.log -m "analyze these error logs" - -# Auto-submit immediately -wsh ai config.json -s -m "explain this configuration" -tail -n 50 app.log | wsh ai -s - -m "what's causing these errors?" - -# Start new chat and attach files -wsh ai -n report.pdf data.csv -m "summarize these reports" - -# Attach different file types (images, PDFs, code) -wsh ai architecture.png api-spec.pdf server.go -m "review the system design" -``` - -**File Size Limits:** -- Text files: 200KB maximum -- PDF files: 5MB maximum -- Image files: 7MB maximum (accounts for base64 encoding overhead) -- Maximum 15 files per command - -**Flags:** -- `-m, --message <text>` - Add message text along with files -- `-s, --submit` - Auto-submit immediately (default waits for user) -- `-n, --new` - Clear current chat and start fresh conversation - ---- - -## editconfig - -You can easily open up any of Wave's config files using this command. - -```sh -wsh editconfig [config-file-name] - -# opens the default settings.json file -wsh editconfig - -# opens presets.json -wsh editconfig presets.json - -# opens widgets.json -wsh editconfig widgets.json - -# opens ai presets -wsh editconfig presets/ai.json -``` - ---- - -## setbg - -The `setbg` command allows you to set a background image or color for the current tab with various customization options. - -```sh -wsh setbg [--opacity value] [--tile|--center] [--size value] [--border-color color] [--active-border-color color] (image-path|"#color"|color-name) -``` - -You can set a background using: - -- An image file (displayed as cover, tiled, or centered) -- A hex color (must be quoted like "#ff0000") -- A CSS color name (like "blue" or "forestgreen") - -Flags: - -- `--opacity value` - set the background opacity (0.0-1.0, default 0.5) -- `--tile` - tile the background image instead of using cover mode -- `--center` - center the image without scaling (good for logos) -- `--size` - size for centered images (px, %, or auto) -- `--clear` - remove the background -- `--border-color color` - set the block frame border color (hex or CSS color name) -- `--active-border-color color` - set the block frame focused border color (hex or CSS color name) -- `--print` - show the metadata without applying it - -Supported image formats: JPEG, PNG, GIF, WebP, and SVG. - -Examples: - -```sh -# Set an image background with default settings -wsh setbg ~/pictures/background.jpg - -# Set a background with custom opacity -wsh setbg --opacity 0.3 ~/pictures/light-pattern.png - -# Set a tiled background -wsh setbg --tile --opacity 0.2 ~/pictures/texture.png - -# Center an image (good for logos) -wsh setbg --center ~/pictures/logo.png -wsh setbg --center --size 200px ~/pictures/logo.png - -# Set color backgrounds -wsh setbg "#ff0000" # hex color (requires quotes) -wsh setbg forestgreen # CSS color name - -# Change just the opacity of current background -wsh setbg --opacity 0.7 - -# Set border colors alongside a background -wsh setbg --border-color "#ff0000" --active-border-color "#00ff00" ~/pictures/background.jpg -wsh setbg --border-color steelblue forestgreen - -# Remove background -wsh setbg --clear - -# Preview the metadata -wsh setbg --print "#ff0000" -``` - -The command validates that: - -- Color values are valid hex codes or CSS color names -- Image paths point to accessible, supported image files -- The opacity value is between 0.0 and 1.0 -- The center and tile options are not used together - -:::tip -Use `--print` to preview the metadata for any background configuration without applying it. You can then copy this JSON representation to use as a [Background entry](/tab-backgrounds) -::: - ---- - -## badge - -<VersionBadge version="v0.14.2" /> - -The `badge` command sets or clears a visual badge indicator on a block or tab header. - -```sh -wsh badge [icon] -wsh badge --clear -``` - -Badges are used to draw attention to a block or tab, such as indicating a process has completed or needs attention. If no icon is provided, it defaults to `circle-small`. Icon names are [Font Awesome](https://fontawesome.com/icons) icon names (without the `fa-` prefix). - -Flags: - -- `--color string` - set the badge color (CSS color name or hex) -- `--priority float` - set the badge priority (default 10; higher priority badges take precedence) -- `--clear` - remove the badge from the block or tab -- `--beep` - play the system bell sound when setting the badge -- `--pid int` - watch a PID and automatically clear the badge when it exits (sets default priority to 5) -- `-b, --block` - target a specific block or tab (same format as `getmeta`) - -Examples: - -```sh -# Set a default badge on the current block -wsh badge - -# Set a badge with a custom icon and color -wsh badge circle-check --color green - -# Set a high-priority badge on a specific block -wsh badge triangle-exclamation --color red --priority 20 -b 2 - -# Set a badge that clears when a process exits -wsh badge --pid 12345 - -# Play the bell and set a badge when done -wsh badge circle-check --beep - -# Clear the badge on the current block -wsh badge --clear - -# Clear the badge on a specific tab -wsh badge --clear -b tab -``` - -:::note -The `--pid` flag is not supported on Windows. -::: - ---- - -## run - -The `run` command creates a new terminal command block and executes a specified command within it. The command can be provided either as arguments after `--` or using the `-c` flag. Unless the `-x` or `-X` flags are passed, commands can be re-executed by pressing `Enter` once the command has finished running. - -```sh -# Run a command specified after -- -wsh run -- ls -la - -# Run a command using -c flag -wsh run -c "ls -la" - -# Run with working directory specified -wsh run --cwd /path/to/dir -- ./script.sh - -# Run in magnified mode -wsh run -m -- make build - -# Run and auto-close on successful completion -wsh run -x -- npm test - -# Run and auto-close regardless of exit status -wsh run -X -- ./long-running-task.sh -``` - -The command inherits the current environment variables and working directory by default. - -Flags: - -- `-m, --magnified` - open the block in magnified mode -- `-c, --command string` - run a command string in _shell_ -- `-x, --exit` - close block if command exits successfully (stays open if there was an error) -- `-X, --forceexit` - close block when command exits, regardless of exit status -- `--delay int` - if using -x/-X, delay in milliseconds before closing block (default 2000) -- `-p, --paused` - create block in paused state -- `-a, --append` - append output on command restart instead of clearing -- `--cwd string` - set working directory for command - -Examples: - -```sh -# Run a build command in magnified mode -wsh run -m -- npm run build - -# Execute a script and auto-close after success -wsh run -x -- ./backup-script.sh - -# Run a command in a specific directory -wsh run --cwd ./project -- make test - -# Run a shell command and force close after completion -wsh run -X -c "find . -name '*.log' -delete" - -# Start a command in paused state -wsh run -p -- ./server --dev - -# Run with custom close delay -wsh run -x --delay 5000 -- ./deployment.sh -``` - -When using the `-x` or `-X` flags, the block will automatically close after the command completes. The `-x` flag only closes on successful completion (exit code 0), while `-X` closes regardless of exit status. The `--delay` flag controls how long to wait before closing (default 2000ms). - -The `-p` flag creates the block in a paused state, allowing you to review the command before execution. - -:::tip -You can use either `--` followed by your command and arguments, or the `-c` flag with a quoted command string. The `--` method is preferred when you want to preserve argument handling, while `-c` is useful for shell commands with pipes or redirections. -::: - ---- - -## deleteblock - -```sh -wsh deleteblock -b [blockid] -``` - -This will delete the block with the specified id. - ---- - -## ssh - -```sh -wsh ssh [user@host] -``` - -This will use Wave's internal ssh implementation to connect to the specified remote machine. The `-i` flag can be used to specify a path to an identity file. - ---- - -## wsl - -```sh -wsh wsl [-d <distribution-name>] -``` - -This will connect to a WSL distribution on the local machine. It will use the default if no distribution is provided. - ---- - -## web - -The `web` command opens URLs in a web block within Wave Terminal. - -```sh -wsh web open [url] [-m] [-r blockid] -``` - -You can open a specific URL or perform a search using the configured search engine. - -Flags: - -- `-m, --magnified` - open the web block in magnified mode -- `-r, --replace <blockid>` - replace an existing block instead of creating a new one - -Examples: - -```sh -# Open a URL -wsh web open https://waveterm.dev - -# Search with the configured search engine -wsh web open "wave terminal documentation" - -# Open in magnified mode -wsh web open -m https://github.com - -# Replace an existing block -wsh web open -r 2 https://example.com -``` - -The command will open a new web block with the desired page, or replace an existing block if the `-r` flag is used. Note that `--replace` and `--magnified` cannot be used together. - ---- - -## notify - -The `notify` command creates a desktop notification from Wave Terminal. - -```sh -wsh notify [message] [-t title] [-s] -``` - -This allows you to trigger desktop notifications from scripts or commands. The notification will appear using your system's native notification system. It works on remote machines as well as your local machine. - -Flags: - -- `-t, --title string` - set the notification title (default "Wsh Notify") -- `-s, --silent` - disable the notification sound - -Examples: - -```sh -# Basic notification -wsh notify "Build completed successfully" - -# Notification with custom title -wsh notify -t "Deployment Status" "Production deployment finished" - -# Silent notification -wsh notify -s "Background task completed" -``` - -This is particularly useful for long-running commands where you want to be notified of completion or status changes. - ---- - -## conn - -This has several subcommands which all perform various features related to connections. - -### status - -```sh -wsh conn status -``` - -This command gives the status of all connections made since waveterm started. - -### reinstall - -For ssh connections, - -```sh -wsh conn reinstall [user@host] -``` - -For wsl connections, - -```sh -wsh conn reinstall [wsl://<distribution-name>] -``` - -This command reinstalls the Wave Shell Extensions on the specified connection. - -### disconnect - -For ssh connections, - -```sh -wsh conn disconnect [user@host] -``` - -For wsl connections, - -```sh -wsh conn disconnect [wsl://<distribution name>] -``` - -This command completely disconnects the specified connection. This will apply to all blocks where the connection is being used - -### connect - -For ssh connections, - -```sh -wsh conn connect [user@host] -``` - -For wsl connections, - -```sh -wsh conn connect [wsl://<distribution-name>] -``` - -This command connects to the specified connection but does not create a block for it. - -### ensure - -For ssh connections, - -```sh -wsh conn ensure [user@host] -``` - -For wsl connections, - -```sh -wsh conn ensure [wsl://<distribution-name>] -``` - -This command connects to the specified connection if it isn't already connected. - ---- - -## setconfig - -```sh -wsh setconfig [<config-name>=<config-value>] -``` - -This allows setting various options in the `config/settings.json` file. It will check to be sure a valid config option was provided. - ---- - -## file - -The `file` command provides a set of subcommands for managing files across different storage systems, such as `wsh` remote servers. - -:::note - -Wave Terminal is capable of managing files from remote SSH hosts. Files are addressed via URIs, which -vary depending on the storage system. If no scheme is specified, the file will be treated as a local connection. - -URI format: `[profile]:[uri-scheme]://[connection]/[path]` - -Supported URI schemes: - -- `wsh` - Used to access files on remote hosts over SSH via the WSH helper. Allows for file streaming to Wave and other remotes. - - Profiles are optional for WSH URIs, provided that you have configured the remote host in your "connections.json" or "~/.ssh/config" file. - - If a profile is provided, it must be defined in "profiles.json" in the Wave configuration directory. - - Format: `wsh://[remote]/[path]` - - Shorthands can be used for the current remote and your local computer: - `[path]` a relative or absolute path on the current remote - `//[remote]/[path]` a path on a remote - `/~/[path]` a path relative to the home directory on your local computer - -::: - -### cat - -```sh -wsh file cat [file-uri] -``` - -Display the contents of a file (maximum file size 10MB). For example: - -```sh -wsh file cat wsh://user@ec2/home/user/config.txt -wsh file cat ./local-config.txt -``` - -### write - -```sh -wsh file write [file-uri] -``` - -Write data from stdin to a file. The maximum file size is 10MB. For example: - -```sh -echo "hello" | wsh file write ./greeting.txt -cat config.json | wsh file write //ec2-user@remote01/~/config.json -``` - -### append - -```sh -wsh file append [file-uri] -``` - -Append data from stdin to a file. Input is buffered locally (up to 10MB total file size limit) before being written. For example: - -```sh -cat additional-content.txt | wsh file append ./notes.txt -echo "new line" | wsh file append //user@remote/~/notes.txt -``` - -### rm - -```sh -wsh file rm [flag] [file-uri] -``` - -Remove a file. For example: - -```sh -wsh file rm wsh://user@ec2/home/user/config.txt -wsh file rm ./local-config.txt -``` - -Flags: - -- `-r, --recursive` - recursively deletes directory entries - -### info - -```sh -wsh file info [file-uri] -``` - -Display information about a file including size, creation time, modification time, and metadata. For example: - -```sh -wsh file info wsh://user@ec2/home/user/config.txt -wsh file info ./local-config.txt -``` - -### cp - -```sh -wsh file cp [flags] [source-uri] [destination-uri] -``` - -Copy files between different storage systems (maximum file size 10MB). For example: - -```sh -# Copy a remote file to your local filesystem -wsh file cp wsh://user@ec2/home/user/config.txt ./local-config.txt - -# Copy a local file to a remote system -wsh file cp ./local-config.txt wsh://user@ec2/home/user/config.txt - -# Copy between remote systems -wsh file cp wsh://user@ec2/home/user/config.txt wsh://user@server2/home/user/backup.txt -``` - -Flags: - -- `-f, --force` - overwrites any conflicts when copying -- `-m, --merge` - does not clear existing directory entries when copying a directory, instead merging its contents with the destination's - -### mv - -```sh -wsh file mv [flags] [source-uri] [destination-uri] -``` - -Move files between different storage systems (maximum file size 10MB). The source file will be deleted once the operation completes successfully. For example: - -```sh -# Move a remote file to your local filesystem -wsh file mv wsh://user@ec2/home/user/config.txt ./local-config.txt - -# Move a local file to a remote system -wsh file mv ./local-config.txt wsh://user@ec2/home/user/config.txt - -# Move between remote systems -wsh file mv wsh://user@ec2/home/user/config.txt wsh://user@server2/home/user/backup.txt -``` - -Flags: - -- `-f, --force` - overwrites any conflicts when moving - -### ls - -```sh -wsh file ls [flags] [file-uri] -``` - -List files in a directory. By default, lists files in the current directory for the current terminal session. - -Examples: - -```sh -wsh file ls wsh://user@ec2/home/user/ -wsh file ls ./local-dir/ -``` - -Flags: - -- `-l, --long` - use long listing format showing size, timestamps, and metadata -- `-1, --one` - list one file per line -- `-f, --files` - list only files (no directories) - -When output is piped to another command, automatically switches to one-file-per-line format: - -```sh -# Easy to process with grep, awk, etc. -wsh file ls ./ | grep ".json$" -``` - ---- - -## launch - -The `wsh launch` command allows you to open pre-configured widgets directly from your terminal. - -```sh -wsh launch [flags] widget-id -``` - -The command will search for the specified widget ID in both user-defined widgets and default widgets, then create a new block using the widget's configuration. - -Flags: - -- `-m, --magnify` - open the widget in magnified mode, overriding the widget's default magnification setting - -Examples: - -```sh -# Launch a widget with its default settings -wsh launch my-custom-widget - -# Launch a widget in magnified mode -wsh launch -m system-monitor -``` - -The widget's configuration determines the initial block settings, including the view type, metadata, and default magnification state. The `-m` flag can be used to override the widget's default magnification setting. - -:::tip -Widget configurations can be customized in your `widgets.json` configuration file, which you can edit using `wsh editconfig widgets.json` -::: - ---- - -## getvar/setvar - -Wave Terminal provides commands for managing persistent variables at different scopes (block, tab, workspace, or client-wide). - -### setvar - -```sh -wsh setvar [flags] KEY=VALUE... -``` - -Set one or more variables. By default, variables are set at the client (global) level. Use `-l` for block-local variables. - -Examples: - -```sh -# Set a single variable -wsh setvar API_KEY=abc123 - -# Set multiple variables at once -wsh setvar HOST=localhost PORT=8080 DEBUG=true - -# Set a block-local variable -wsh setvar -l BLOCK_SPECIFIC=value - -# Remove variables -wsh setvar -r API_KEY PORT -``` - -Flags: - -- `-l, --local` - set variables local to the current block -- `-r, --remove` - remove the specified variables instead of setting them -- `--varfile string` - use a different variable file (default "var") -- `-b [blockid]` - used to set a specific zone (block, tab, workspace, client, or UUID) - -### getvar - -```sh -wsh getvar [flags] [key] -``` - -Get the value of a variable. Returns exit code 0 if the variable exists, 1 if it doesn't. This allows for shell scripting like: - -```sh -# Check if a variable exists -if wsh getvar API_KEY >/dev/null; then - echo "API key is set" -fi - -# Use a variable in a command -curl -H "Authorization: $(wsh getvar API_KEY)" https://api.example.com - -# Get a block-local variable -wsh getvar -l BLOCK_SPECIFIC - -# List all variables -wsh getvar --all - -# List all variables with null terminators (for scripting) -wsh getvar --all -0 -``` - -Flags: - -- `-l, --local` - get variables local to the current block -- `--all` - list all variables -- `-0, --null` - use null terminators in output instead of newlines -- `--varfile string` - use a different variable file (default "var") - -Variables can be accessed at different scopes using the `-b` flag: - -```sh -# Get/set at block level -wsh getvar -b block MYVAR -wsh setvar -b block MYVAR=value - -# Get/set at tab level -wsh getvar -b tab MYVAR -wsh setvar -b tab MYVAR=value - -# Get/set at workspace level -wsh getvar -b workspace MYVAR -wsh setvar -b workspace MYVAR=value - -# Get/set at client (global) level -wsh getvar -b client MYVAR -wsh setvar -b client MYVAR=value -``` - -Variables set with these commands persist across sessions and can be used to store configuration values, secrets, or any other string data that needs to be accessible across blocks or tabs. - ---- - -## termscrollback - -Get the terminal scrollback from a terminal block. This is useful for capturing terminal output for processing or archiving. - -```sh -wsh termscrollback [-b blockid] [flags] -``` - -By default, retrieves all lines from the current terminal block. You can specify line ranges or get only the output of the last command. - -Flags: - -- `-b, --block <blockid>` - specify target terminal block (default: current block) -- `--start <line>` - starting line number (0 = beginning, default: 0) -- `--end <line>` - ending line number (0 = all lines, default: 0) -- `--lastcommand` - get output of last command (requires shell integration) -- `-o, --output <file>` - write output to file instead of stdout - -Examples: - -```sh -# Get all scrollback from current terminal -wsh termscrollback - -# Get scrollback from a specific terminal block -wsh termscrollback -b 2 - -# Get only the last command's output -wsh termscrollback --lastcommand - -# Get a specific line range (lines 100-200) -wsh termscrollback --start 100 --end 200 - -# Save scrollback to a file -wsh termscrollback -o terminal-log.txt - -# Save last command output to a file -wsh termscrollback --lastcommand -o last-output.txt - -# Process last command output with grep -wsh termscrollback --lastcommand | grep "ERROR" -``` - -:::note -The `--lastcommand` flag requires shell integration to be enabled. This feature allows you to capture just the output from the most recent command, which is particularly useful for scripting and automation. -::: - ---- - -## wavepath - -The `wavepath` command lets you get the paths to various Wave Terminal directories and files, including configuration, data storage, and logs. - -```sh -wsh wavepath {config|data|log} -``` - -This command returns the full path to the requested Wave Terminal system directory or file. It's useful for accessing Wave's configuration files, data storage, or checking logs. - -Flags: - -- `-o, --open` - open the path in a new block -- `-O, --open-external` - open the path in the default external application -- `-t, --tail` - show the last ~100 lines of the log file (only valid for log path) - -Examples: - -```sh -# Get path to config directory -wsh wavepath config - -# Get path to data directory -wsh wavepath data - -# Get path to log file -wsh wavepath log - -# Open log file in a new block -wsh wavepath -o log - -# Open config directory in system file explorer -wsh wavepath -O config - -# View recent log entries -wsh wavepath -t log -``` - -The command will show you the full path to: - -- `config` - Where Wave Terminal stores its configuration files -- `data` - Where Wave Terminal stores its persistent data -- `log` - The main Wave Terminal log file - -:::tip -Use the `-t` flag with the log path to quickly view recent log entries without having to open the full file. This is particularly useful for troubleshooting. -::: - ---- - -## blocks - -The `blocks` command provides operations for listing and querying blocks across workspaces, windows, and tabs. Primarily useful for debugging and scripting. - -### list - -```sh -wsh blocks list [flags] -``` - -List all blocks with optional filtering by workspace, window, tab, or view type. Output can be formatted as a table (default) or JSON for scripting. - -Flags: -- `--workspace <id>` - restrict to specific workspace id -- `--window <id>` - restrict to specific window id -- `--tab <id>` - restrict to specific tab id -- `--view <type>` - filter by view type (term, web, preview, edit, sysinfo, waveai) -- `--json` - output results as JSON -- `--timeout <ms>` - RPC timeout in milliseconds (default: 5000) - -Examples: - -```sh -# List all blocks -wsh blocks list - -# List only terminal blocks -wsh blocks list --view=term - -# Filter by workspace -wsh blocks list --workspace=12d0c067-378e-454c-872e-77a314248114 - -# Output as JSON for scripting -wsh blocks list --json -``` - - ---- - -## secret - -The `secret` command provides secure storage and management of sensitive information like API keys, passwords, and tokens. Secrets are stored using your system's native secure storage backend (Keychain on macOS, Secret Service on Linux, Credential Manager on Windows). - -Secret names must start with a letter and contain only letters, numbers, and underscores. - -### get - -```sh -wsh secret get [name] -``` - -Retrieve and display the value of a stored secret. - -Examples: - -```sh -# Get an API key -wsh secret get github_token - -# Use in scripts -export API_KEY=$(wsh secret get my_api_key) -``` - -### set - -```sh -wsh secret set [name]=[value] -``` - -Store a secret value securely. This command requires an appropriate system secret manager to be available and will fail if only basic text storage is available. - -Examples: - -```sh -# Set an API token -wsh secret set github_token=ghp_abc123xyz - -# Set a database password -wsh secret set db_password=mySecurePassword123 -``` - -:::warning -The `set` command requires a proper system secret manager (Keychain, Secret Service, or Credential Manager). It will not work with basic text storage for security reasons. -::: - -### list - -```sh -wsh secret list -``` - -Display all stored secret names (values are not shown). - -Example: - -```sh -# List all secrets -wsh secret list -``` - -### delete - -```sh -wsh secret delete [name] -``` - -Remove a secret from secure storage. - -Examples: - -```sh -# Delete an API key -wsh secret delete github_token - -# Delete multiple secrets -wsh secret delete old_api_key -wsh secret delete temp_token -``` - -### ui - -```sh -wsh secret ui [-m] -``` - -Open the secrets management interface in a new block. This provides a graphical interface for viewing and managing all your secrets. - -Flags: - -- `-m, --magnified` - open the secrets UI in magnified mode - -Examples: - -```sh -# Open the secrets UI -wsh secret ui - -# Open the secrets UI in magnified mode -wsh secret ui -m -``` - -The secrets UI provides a convenient visual way to browse, add, edit, and delete secrets without needing to use the command-line interface. - -:::tip -Use secrets in your scripts to avoid hardcoding sensitive values. Secrets work across remote machines - store an API key locally with `wsh secret set`, then access it from any SSH or WSL connection with `wsh secret get`. The secret is securely retrieved from your local machine without needing to duplicate it on remote systems. -::: -</PlatformProvider> diff --git a/docs/docs/wsh.mdx b/docs/docs/wsh.mdx deleted file mode 100644 index 4c837bcf09..0000000000 --- a/docs/docs/wsh.mdx +++ /dev/null @@ -1,210 +0,0 @@ ---- -sidebar_position: 4 -id: "wsh" -title: "wsh overview" ---- - -The `wsh` command provides Wave Terminal's core command line interface, allowing users to interact with both terminal and graphical elements from the command line. This guide covers the basics of using `wsh` and its key features. - -See the [wsh reference](/wsh-reference) for a list of all wsh commands and their arguments. - -## Overview - -At its core, `wsh` enables seamless interaction between your terminal commands and Wave's graphical blocks. It allows you to: - -- Control graphical widgets directly from the command line -- Share data between terminal sessions and GUI components -- Manage your workspace programmatically -- Connect remote and local environments -- Send CLI output and files directly to AI conversations -- Run terminal commands in separate, isolated blocks - -## Key Concepts - -### Interacting with Blocks - -`wsh` provides direct interaction with Wave's graphical blocks through the command line. For example: - -```bash -# Open a file in the editor -wsh edit config.json - -# Get the current file path from a preview block -wsh getmeta -b 2 file - -# Send output to an AI assistant (the "-" reads from stdin) -ls -la | wsh ai - "what are the largest files here?" -``` - -### Persistent State - -`wsh` can maintain state across terminal sessions through its variable system: - -```bash -# Store a variable that persists across sessions -wsh setvar API_KEY=abc123 - -# Store globally -wsh setvar DEPLOY_ENV=prod -# Or store in the current workspace -wsh setvar -b workspace DEPLOY_ENV=staging - -# Use stored variables in commands -curl -H "Authorization: $(wsh getvar API_KEY)" https://api.example.com -``` - -### Accessing Local Files from Remote - -When working on remote machines, you can access files on your local computer using the `wsh://local/~/` path prefix with `wsh file` commands. The shorthand `/~/` can also be used as an alias for `wsh://local/~/`: - -```bash -# Read a local file from a remote machine -wsh file cat wsh://local/~/config/app.json - -# Run a local script on the remote machine using shell process substitution -bash <(wsh file cat wsh://local/~/scripts/deploy.sh) -python <(wsh file cat wsh://local/~/scripts/deploy.py) - -# Append remote output to a local log file -echo "Remote machine log entry" | wsh file append wsh://local/~/app.log - -# Copy a local file to the remote machine -wsh file cp wsh://local/~/data.csv ./remote-data.csv - -# Copy remote file back to local machine -wsh file cp ./results.txt wsh://local/~/results.txt - -# You can also use the shorthand /~/ instead of wsh://local/~/ -wsh file cat /~/config/app.json -``` - -### Block Management - -Every visual element in Wave is a block, and `wsh` gives you complete control over them (hold Ctrl+Shift to see block numbers): - -```bash -# Create a new block showing a webpage -wsh web open github.com - -# Do a web search in a new block -wsh web open "wave terminal" - -# Run a command in a new block and auto-close when done -wsh run -x -- npm test - -# Get information about the current block -wsh getmeta -``` - -## Common Workflows - -Here are some common ways to use `wsh`: - -### Development Workflow - -```bash -# Open directory or markdown files -wsh view . -wsh view README.md - -# add a -m to open the block in "magnified" mode -wsh view -m README.md - -# Start development server in a new block (-m will magnify the block on startup) -wsh run -m -- npm run dev - -# Open documentation in a web block -wsh web open http://localhost:3000 -``` - -### Remote Development - -```bash -# Connect to remote server with optional key -wsh ssh -i ~/.ssh/mykey.pem dev@server - -# Edit remote files -wsh edit /etc/nginx/nginx.conf - -# Monitor remote logs -wsh run -- tail -f /var/log/app.log - -# Share variables between sessions -wsh setvar -b tab SHARED_ENV=staging -``` - -### AI-Assisted Development - -The `wsh ai` command appends content to the Wave AI sidebar. By default, files are attached without auto-submitting, allowing you to review and add more context before sending. - -```bash -# Pipe output to AI sidebar (ask question in UI) -git diff | wsh ai - - -# Attach files with a message -wsh ai main.go utils.go -m "find bugs in these files" - -# Auto-submit with message -wsh ai config.json -s -m "explain this config" - -# Start new chat with attached files -wsh ai -n *.log -m "analyze these logs" - -# Attach multiple file types (images, PDFs, code) -wsh ai screenshot.png report.pdf app.py -m "review these" - -# Debug with stdin and auto-submit -dmesg | wsh ai -s - -m "help me understand these errors" -``` - -**Flags:** -- `-` - Read from stdin instead of a file -- `-m, --message` - Add message text along with files -- `-s, --submit` - Auto-submit immediately (default is to wait for user) -- `-n, --new` - Clear chat and start fresh conversation - -**File Limits:** -- Text files: 200KB max -- PDFs: 5MB max -- Images: 7MB max -- Maximum 15 files per command - -## Tips & Features - -1. **Working with Blocks** - - - Use block numbers (1-9) to target specific blocks within a tab (hold Ctrl+Shift to see block numbers) - - Can get full block ids by right click a block's header and selecting "Copy Block Id" (useful for scripting) - - Use references like "this", "tab", "workspace", or "global" for different scopes - -2. **Data Storage** - - - Use `wsh setvar/getvar` for configuration and secrets - - Store file data using `wsh file`, which can be easily referenced in all terminals (local and remote) - - Use appropriate storage scopes (block, tab, workspace, global) - -3. **Command Execution** - - Use `wsh run` to execute commands in new blocks - - Send command output and files quickly to AI blocks with `wsh ai` - -## Scripting with wsh - -wsh commands can be combined in scripts to automate common tasks. Here's an example that sets up a development environment and uses `wsh notify` to monitor a long-running build: - -```bash -#!/bin/bash -# Setup development environment -wsh run -- docker-compose up -d -wsh web open localhost:8080 -wsh view ./src -wsh run -- npm run test:watch - -# Get notified when long-running tasks complete using wsh notify -npm run build && wsh notify "Build complete" || wsh notify "Build failed" -``` - -## Getting Help - -You can get help on available commands by running `wsh` with no arguments, or get detailed help for a specific command using `wsh [command] -h`. - -For a complete reference of all `wsh` functionality, see the [WSH Command Reference](./wsh-reference). diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts deleted file mode 100644 index a5c20d6190..0000000000 --- a/docs/docusaurus.config.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { Config } from "@docusaurus/types"; -import rehypeHighlight from "rehype-highlight"; -import { docOgRenderer } from "./src/renderer/image-renderers"; - -const baseUrl = process.env.EMBEDDED ? "/docsite/" : "/"; - -const config: Config = { - title: "Wave Terminal Documentation", - tagline: "Level Up Your Terminal With Graphical Widgets", - favicon: "img/logo/wave-logo_appicon.svg", - - // Set the production url of your site here - url: "https://docs.waveterm.dev/", - // Set the /<baseUrl>/ pathname under which your site is served - // For GitHub pages deployment, it is often '/<projectName>/' - baseUrl, - - // GitHub pages deployment config. - // If you aren't using GitHub pages, you don't need these. - organizationName: "wavetermdev", // Usually your GitHub org/user name. - projectName: "waveterm-docs", // Usually your repo name. - deploymentBranch: "main", - - onBrokenAnchors: "ignore", - onBrokenLinks: "throw", - onBrokenMarkdownLinks: "warn", - trailingSlash: false, - - // Even if you don't use internationalization, you can use this field to set - // useful metadata like html lang. For example, if your site is Chinese, you - // may want to replace "en" with "zh-Hans". - i18n: { - defaultLocale: "en", - locales: ["en"], - }, - plugins: [ - [ - "content-docs", - { - path: "docs", - routeBasePath: "/", - exclude: ["features/**"], - editUrl: !process.env.EMBEDDED ? "https://github.com/wavetermdev/waveterm/edit/main/docs/" : undefined, - rehypePlugins: [rehypeHighlight], - } as import("@docusaurus/plugin-content-docs").Options, - ], - "ideal-image", - [ - "@docusaurus/plugin-sitemap", - { - changefreq: "daily", - filename: "sitemap.xml", - }, - ], - !process.env.EMBEDDED && [ - "@waveterm/docusaurus-og", - { - path: "./preview-images", // relative to the build directory - imageRenderers: { - "docusaurus-plugin-content-docs": docOgRenderer, - }, - }, - ], - "docusaurus-plugin-sass", - "@docusaurus/plugin-svgr", - ].filter((v) => v), - themes: [ - ["classic", { customCss: "src/css/custom.scss" }], - !process.env.EMBEDDED && "@docusaurus/theme-search-algolia", - ].filter((v) => v), - themeConfig: { - docs: { - sidebar: { - hideable: false, - autoCollapseCategories: false, - }, - }, - colorMode: { - defaultMode: "light", - disableSwitch: false, - respectPrefersColorScheme: true, - }, - navbar: { - logo: { - src: "img/logo/wave-light.png", - srcDark: "img/logo/wave-dark.png", - href: "https://www.waveterm.dev/", - }, - hideOnScroll: true, - items: [ - { - type: "doc", - position: "left", - docId: "index", - label: "Docs", - }, - !process.env.EMBEDDED - ? [ - { - position: "left", - href: "https://docs.waveterm.dev/storybook", - label: "Storybook", - }, - { - href: "https://discord.gg/zUeP2aAjaP", - position: "right", - className: "header-link-custom custom-icon-discord", - "aria-label": "Discord invite", - }, - { - href: "https://github.com/wavetermdev/waveterm", - position: "right", - className: "header-link-custom custom-icon-github", - "aria-label": "GitHub repository", - }, - ] - : [], - ].flat(), - }, - metadata: [ - { - name: "keywords", - content: - "terminal, developer, development, command, line, wave, linux, macos, windows, connection, ssh, cli, waveterm, documentation, docs, ai, graphical, widgets, remote, open, source, open-source, go, golang, react, typescript, javascript", - }, - { - name: "og:type", - content: "website", - }, - { - name: "og:site_name", - content: "Wave Terminal Documentation", - }, - { - name: "application-name", - content: "Wave Terminal Documentation", - }, - { - name: "apple-mobile-web-app-title", - content: "Wave Terminal Documentation", - }, - ], - footer: { - copyright: `Copyright Š ${new Date().getFullYear()} Command Line Inc. Built with Docusaurus.`, - }, - algolia: { - appId: "B6A8512SN4", - apiKey: "e879cd8663f109b2822cd004d9cd468c", - indexName: "waveterm", - }, - }, - headTags: [ - { - tagName: "link", - attributes: { - rel: "preload", - as: "font", - type: "font/woff2", - "data-next-font": "size-adjust", - href: `${baseUrl}fontawesome/webfonts/fa-sharp-regular-400.woff2`, - }, - }, - { - tagName: "link", - attributes: { - rel: "preload", - as: "font", - type: "font/woff2", - "data-next-font": "size-adjust", - href: `${baseUrl}fontawesome/webfonts/fa-sharp-solid-900.woff2`, - }, - }, - { - tagName: "link", - attributes: { - rel: "sitemap", - type: "application/xml", - title: "Sitemap", - href: `${baseUrl}sitemap.xml`, - }, - }, - !process.env.EMBEDDED && { - tagName: "script", - attributes: { - defer: "true", - "data-domain": "docs.waveterm.dev", - src: "https://plausible.io/js/script.file-downloads.outbound-links.tagged-events.js", - }, - }, - ].filter((v) => v), - stylesheets: [ - `${baseUrl}fontawesome/css/fontawesome.min.css`, - `${baseUrl}fontawesome/css/sharp-regular.min.css`, - `${baseUrl}fontawesome/css/sharp-solid.min.css`, - ], - staticDirectories: ["static", "storybook"], -}; - -export default config; diff --git a/docs/eslint.config.js b/docs/eslint.config.js deleted file mode 100644 index 7c6bd408c2..0000000000 --- a/docs/eslint.config.js +++ /dev/null @@ -1,27 +0,0 @@ -// @ts-check - -import eslint from "@eslint/js"; -import eslintConfigPrettier from "eslint-config-prettier"; -import * as mdx from "eslint-plugin-mdx"; -import tseslint from "typescript-eslint"; - -const baseConfig = tseslint.config( - eslint.configs.recommended, - ...tseslint.configs.recommended, - mdx.flat, - mdx.flatCodeBlocks -); - -const customConfig = { - ...baseConfig, - overrides: [ - { - files: ["emain/emain.ts", "electron.vite.config.ts"], - env: { - node: true, - }, - }, - ], -}; - -export default [customConfig, eslintConfigPrettier]; diff --git a/docs/package.json b/docs/package.json deleted file mode 100644 index 7288853b43..0000000000 --- a/docs/package.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "name": "waveterm-docs", - "version": "0.0.0", - "scripts": { - "docusaurus": "docusaurus", - "start": "docusaurus start", - "build": "docusaurus build", - "swizzle": "docusaurus swizzle", - "deploy": "docusaurus deploy", - "clear": "docusaurus clear", - "serve": "docusaurus serve", - "write-translations": "docusaurus write-translations", - "write-heading-ids": "docusaurus write-heading-ids", - "typecheck": "tsc" - }, - "dependencies": { - "@docusaurus/core": "^3.9.2", - "@docusaurus/plugin-content-docs": "^3.9.2", - "@docusaurus/plugin-debug": "^3.9.2", - "@docusaurus/plugin-ideal-image": "^3.9.2", - "@docusaurus/plugin-sitemap": "^3.9.2", - "@docusaurus/plugin-svgr": "^3.9.2", - "@docusaurus/theme-classic": "^3.9.2", - "@docusaurus/theme-search-algolia": "^3.9.2", - "@mdx-js/react": "^3.0.0", - "@waveterm/docusaurus-og": "https://codeload.github.com/wavetermdev/docusaurus-og/tar.gz/2156619012b8970d922c1ef47789d2f14e47e283", - "clsx": "^2.1.1", - "docusaurus-plugin-sass": "^0.2.6", - "prism-react-renderer": "^2.4.1", - "react": "^18.0.0", - "react-dom": "^18.0.0", - "rehype-highlight": "^7.0.2", - "remark-gfm": "^4.0.1", - "remark-typescript-code-import": "^1.0.1", - "sass": "^1.93.2" - }, - "devDependencies": { - "@docusaurus/module-type-aliases": "3.9.2", - "@docusaurus/tsconfig": "3.9.2", - "@docusaurus/types": "3.9.2", - "@eslint/js": "^9.39", - "@mdx-js/typescript-plugin": "^0.1.3", - "@types/react": "^18.3.0", - "@types/react-dom": "^18.3.0", - "eslint": "^9.39", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-mdx": "^3.7.0", - "prettier": "^3.8.1", - "prettier-plugin-jsdoc": "^1.8.0", - "prettier-plugin-organize-imports": "^4.3.0", - "remark-cli": "^12.0.1", - "remark-frontmatter": "^5.0.0", - "remark-mdx": "^3.1.0", - "remark-preset-lint-consistent": "^6.0.1", - "remark-preset-lint-recommended": "^7.0.1", - "typescript": "^5.9.3", - "typescript-eslint": "^8.56" - }, - "resolutions": { - "path-to-regexp@npm:2.2.1": "^3", - "cookie@0.6.0": "^0.7.0" - }, - "browserslist": { - "production": [ - ">0.5%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 3 chrome version", - "last 3 firefox version", - "last 5 safari version" - ] - }, - "engines": { - "node": ">=18.0" - } -} diff --git a/docs/prettier.config.cjs b/docs/prettier.config.cjs deleted file mode 100644 index 5488294c5e..0000000000 --- a/docs/prettier.config.cjs +++ /dev/null @@ -1,12 +0,0 @@ -/** @type {import("prettier").Config} */ -module.exports = { - plugins: ["prettier-plugin-jsdoc", "prettier-plugin-organize-imports"], - printWidth: 120, - trailingComma: "es5", - useTabs: false, - singleQuote: false, - jsdocVerticalAlignment: true, - jsdocSeparateReturnsFromParam: true, - jsdocSeparateTagGroups: true, - jsdocPreferCodeFences: true, -}; diff --git a/docs/src/components/card.css b/docs/src/components/card.css deleted file mode 100644 index ce4e3414b1..0000000000 --- a/docs/src/components/card.css +++ /dev/null @@ -1,64 +0,0 @@ -.card-group { - display: grid; - grid-template-columns: repeat(3, 1fr); - grid-template-rows: auto; - gap: 1rem; -} - -@media (max-width: 450px) { - .card-group { - grid-template-columns: 1fr; - } -} - -@media (min-width: 451px) and (max-width: 995px) { - .card-group { - grid-template-columns: repeat(2, 1fr); - } -} - -@media (min-width: 996px) { - .card-group { - grid-template-columns: repeat(3, 1fr); - } -} - -.card { - display: grid; - grid-template-columns: 1.5rem 1rem 1fr; - grid-template-rows: subgrid; - grid-column: span 1; - grid-row: span 2; - padding: 1rem; - - .icon { - grid-column: 1; - grid-row: 1; - font-size: 1.5rem; - line-height: 1.5rem; - } - - .title { - grid-column: 3; - grid-row: 1; - font-weight: bold; - font-size: 1.2rem; - line-height: 1.5rem; - } - - .description { - color: var(--ifm-font-color-base); - grid-column: span 3; - grid-row: 2; - } - - border: 2px solid var(--ifm-color-primary-lightest); - transition: transform 0.1s ease; - transform-origin: 50% 50%; -} - -.card:hover { - text-decoration: none; - transform: translateZ(0) scale(1.024); - -webkit-transform: translateZ(0) scale(1.024); -} diff --git a/docs/src/components/card.tsx b/docs/src/components/card.tsx deleted file mode 100644 index 9d4f881090..0000000000 --- a/docs/src/components/card.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import clsx from "clsx"; -import "./card.css"; - -interface CardProps { - icon: string; - title: string; - description: string; - href: string; -} - -export function Card({ icon, title, description, href }: CardProps) { - return ( - <a className="card" href={href}> - <div className={clsx("icon", "fa-sharp fa-regular", icon)} /> - <div className="title">{title}</div> - <div className="description">{description}</div> - </a> - ); -} - -export function CardGroup({ children }) { - return <div className="card-group">{children}</div>; -} diff --git a/docs/src/components/kbd.css b/docs/src/components/kbd.css deleted file mode 100644 index c942df085b..0000000000 --- a/docs/src/components/kbd.css +++ /dev/null @@ -1,43 +0,0 @@ -@font-face { - font-family: "JetBrains Mono"; - src: url("/static/fonts/JetBrainsMono-Regular.woff2") format("woff2"); - font-weight: normal; - font-style: normal; -} - -@font-face { - font-family: "JetBrains Mono"; - src: url("/static/fonts/JetBrainsMono-Bold.woff2") format("woff2"); - font-weight: bold; - font-style: normal; -} - -.kbd-group { - display: inline-flex; - gap: 4px; - align-items: center; -} - -kbd { - background-color: var(--ifm-color-primary-contrast-background); - border-radius: 4px; - border: 1px solid var(--ifm-color-secondary-darker); - color: var(--ifm-color-primary-contrast-foreground); - padding: 2px 6px; - font-size: 0.8em; - font-family: "JetBrains Mono", monospace; - display: inline-flex; - justify-content: center; - align-items: center; - height: 24px; - line-height: 24px; - - .spaced { - letter-spacing: 0.2em; - } -} - -.kbd-group kbd.symbol { - font-size: 0.8em; - line-height: 24px; -} diff --git a/docs/src/components/kbd.tsx b/docs/src/components/kbd.tsx deleted file mode 100644 index c3e1b3c9c3..0000000000 --- a/docs/src/components/kbd.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import BrowserOnly from "@docusaurus/BrowserOnly"; -import { useContext } from "react"; -import "./kbd.css"; -import type { Platform } from "./platformcontext"; -import { PlatformContext } from "./platformcontext"; - -function convertKey(platform: Platform, key: string): [any, string, boolean] { - if (key == "Arrows") { - return [<span className="spaced">↑→↓←</span>, "Arrow Keys", true]; - } - if (key == "ArrowUp") { - return ["↑", "Arrow Up", true]; - } - if (key == "ArrowRight") { - return ["→", "Arrow Right", true]; - } - if (key == "ArrowDown") { - return ["↓", "Arrow Down", true]; - } - if (key == "ArrowLeft") { - return ["←", "Arrow Left", true]; - } - if (key == "Cmd") { - if (platform === "mac") { - return ["⌘", "Command", true]; - } else { - return ["Alt", "Alt", false]; - } - } - if (key == "Ctrl") { - if (platform === "mac") { - return ["⌃", "Control", true]; - } else { - return ["Ctrl", "Control", false]; - } - } - if (key == "Shift") { - return ["⇧", "Shift", true]; - } - if (key == "Escape") { - return ["Esc", "Escape", false]; - } - return [key.length > 1 ? key : key.toUpperCase(), key, false]; -} - -// Custom KBD component -const KbdInternal = ({ k, windows, mac, linux }: { k: string; windows?: string; mac?: string; linux?: string }) => { - const { platform } = useContext(PlatformContext); - - // Determine which key binding to use based on platform overrides - let keyBinding = k; - if (platform === "windows" && windows) { - keyBinding = windows; - } else if (platform === "mac" && mac) { - keyBinding = mac; - } else if (platform === "linux" && linux) { - keyBinding = linux; - } - - if (keyBinding == "N/A") { - return "N/A"; - } - - const keys = keyBinding.split(":"); - const keyElems = keys.map((key, i) => { - const [displayKey, title, symbol] = convertKey(platform, key); - return ( - <kbd key={i} title={title} aria-label={title} className={symbol ? "symbol" : null}> - {displayKey} - </kbd> - ); - }); - return <div className="kbd-group">{keyElems}</div>; -}; - -export const Kbd = ({ k, windows, mac, linux }: { k: string; windows?: string; mac?: string; linux?: string }) => { - return ( - <BrowserOnly fallback={<kbd>{k}</kbd>}> - {() => <KbdInternal k={k} windows={windows} mac={mac} linux={linux} />} - </BrowserOnly> - ); -}; - -export const KbdChord = ({ karr }: { karr: string[] }) => { - const elems: React.ReactNode[] = []; - for (let i = 0; i < karr.length; i++) { - if (i > 0) { - elems.push(<span style={{ padding: "0 2px" }}>+</span>); - } - elems.push(<Kbd key={i} k={karr[i]} />); - } - const fullElem = <span style={{ whiteSpace: "nowrap" }}>{elems}</span>; - return <BrowserOnly fallback={null}>{() => fullElem}</BrowserOnly>; -}; diff --git a/docs/src/components/platformcontext.css b/docs/src/components/platformcontext.css deleted file mode 100644 index b0ca2fd678..0000000000 --- a/docs/src/components/platformcontext.css +++ /dev/null @@ -1,35 +0,0 @@ -.pill-toggle { - display: inline-flex; - border: 1px solid var(--ifm-scrollbar-thumb-background-color); - border-radius: 20px; - overflow: hidden; - background-color: var(--ifm-scrollbar-track-background-color); -} - -.pill-option { - padding: 8px 16px; - font-size: 0.9em; - font-weight: 500; - color: var(--ifm-color-secondary-contrast-foreground); - background-color: transparent; - border: none; - cursor: pointer; - transition: - background-color 0.2s ease, - color 0.2s ease; - outline: none; - font-weight: bold; - - &:not(:first-of-type) { - border-left: 1px solid var(--ifm-scrollbar-thumb-background-color); - } -} - -.pill-option.active { - background-color: var(--ifm-color-primary); - color: var(--ifm-color-secondary-contrast-background); -} - -.pill-option:not(.active):hover { - background-color: var(--ifm-scrollbar-thumb-background-color); -} diff --git a/docs/src/components/platformcontext.tsx b/docs/src/components/platformcontext.tsx deleted file mode 100644 index f48f6fd6ee..0000000000 --- a/docs/src/components/platformcontext.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import BrowserOnly from "@docusaurus/BrowserOnly"; -import { createContext, ReactNode, useCallback, useContext, useState } from "react"; - -import clsx from "clsx"; -import "./platformcontext.css"; - -export type Platform = "mac" | "linux" | "windows"; - -interface PlatformContextProps { - platform: Platform; - setPlatform: (platform: Platform) => void; -} - -export const PlatformContext = createContext<PlatformContextProps | undefined>(undefined); - -function getOS(): Platform { - const platform = window.navigator.platform; - const macosPlatforms = ["Macintosh", "MacIntel", "MacPPC", "Mac68K"]; - const windowsPlatforms = ["Win32", "Win64", "Windows", "WinCE"]; - const iosPlatforms = ["iPhone", "iPad", "iPod"]; - - if (macosPlatforms.includes(platform) || iosPlatforms.includes(platform)) { - return "mac"; - } else if (windowsPlatforms.includes(platform)) { - return "windows"; - } else { - return "linux"; - } -} - -const PlatformProviderInternal = ({ children }: { children: ReactNode }) => { - const [platform, setPlatform] = useState<Platform>(getOS()); - - const setPlatformCallback = useCallback((newPlatform: Platform) => { - setPlatform(newPlatform); - localStorage.setItem("platform", newPlatform); // Store in localStorage - }, []); - - return ( - <PlatformContext.Provider value={{ platform, setPlatform: setPlatformCallback }}> - {children} - </PlatformContext.Provider> - ); -}; - -export function PlatformProvider({ children }: { children: ReactNode }) { - return ( - <BrowserOnly fallback={<div />}> - {() => <PlatformProviderInternal>{children}</PlatformProviderInternal>} - </BrowserOnly> - ); -} - -export const usePlatform = (): PlatformContextProps => { - const context = useContext(PlatformContext); - if (!context) { - throw new Error("usePlatform must be used within a PlatformProvider"); - } - return context; -}; - -function PlatformSelectorButtonInternal() { - const { platform, setPlatform } = usePlatform(); - - return ( - <div className="pill-toggle"> - <button className={clsx("pill-option", { active: platform === "mac" })} onClick={() => setPlatform("mac")}> - macOS - </button> - <button - className={clsx("pill-option", { active: platform === "linux" })} - onClick={() => setPlatform("linux")} - > - Linux - </button> - <button - className={clsx("pill-option", { active: platform === "windows" })} - onClick={() => setPlatform("windows")} - > - Windows - </button> - </div> - ); -} - -export function PlatformSelectorButton() { - return <BrowserOnly fallback={<div />}>{() => <PlatformSelectorButtonInternal />}</BrowserOnly>; -} - -interface PlatformItemProps { - children: ReactNode; - platforms: Platform[]; -} - -const PlatformItemInternal = ({ children, platforms }: PlatformItemProps) => { - const platform = usePlatform(); - - return platforms.includes(platform.platform) && children; -}; - -export const PlatformItem = (props: PlatformItemProps) => { - return <BrowserOnly fallback={<div />}>{() => <PlatformItemInternal {...props} />}</BrowserOnly>; -}; diff --git a/docs/src/components/versionbadge.css b/docs/src/components/versionbadge.css deleted file mode 100644 index ea09d08480..0000000000 --- a/docs/src/components/versionbadge.css +++ /dev/null @@ -1,41 +0,0 @@ -.version-badge { - display: inline-block; - padding: 0.125rem 0.5rem; - margin-left: 0.25rem; - font-size: 0.75rem; - font-weight: 600; - line-height: 1.5; - border-radius: 0.25rem; - background-color: var(--ifm-color-primary-lightest); - color: var(--ifm-background-color); - vertical-align: middle; - white-space: nowrap; -} - -.version-badge.no-left-margin { - margin-left: 0; -} - -[data-theme="dark"] .version-badge { - background-color: var(--ifm-color-primary-dark); - color: var(--ifm-background-color); -} - -.deprecated-badge { - display: inline-block; - padding: 0.125rem 0.5rem; - margin-left: 0.25rem; - font-size: 0.75rem; - font-weight: 600; - line-height: 1.5; - border-radius: 0.25rem; - background-color: #9e9e9e; - color: #fff; - vertical-align: middle; - white-space: nowrap; -} - -[data-theme="dark"] .deprecated-badge { - background-color: #616161; - color: #e0e0e0; -} diff --git a/docs/src/components/versionbadge.tsx b/docs/src/components/versionbadge.tsx deleted file mode 100644 index c4af6d479f..0000000000 --- a/docs/src/components/versionbadge.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import "./versionbadge.css"; - -interface VersionBadgeProps { - version: string; - noLeftMargin?: boolean; -} - -export function VersionBadge({ version, noLeftMargin }: VersionBadgeProps) { - return <span className={`version-badge${noLeftMargin ? " no-left-margin" : ""}`}>{version}</span>; -} - -export function DeprecatedBadge() { - return <span className="deprecated-badge">deprecated</span>; -} \ No newline at end of file diff --git a/docs/src/css/custom.scss b/docs/src/css/custom.scss deleted file mode 100644 index 75a5c03794..0000000000 --- a/docs/src/css/custom.scss +++ /dev/null @@ -1,117 +0,0 @@ -@import url("../../../node_modules/highlight.js/scss/github-dark-dimmed.scss"); - -:root { - --ifm-background-color: #ffffff; - --ifm-color-primary: #1a660b; - --ifm-color-primary-dark: #175c0a; - --ifm-color-primary-darker: #165709; - --ifm-color-primary-darkest: #124708; - --ifm-color-primary-light: #1d700c; - --ifm-color-primary-lighter: #1e750d; - --ifm-color-primary-lightest: #22850e; -} - -[data-theme="dark"] { - --ifm-background-color: #1b1b1d; - --ifm-color-primary: #58c142; - --ifm-color-primary-dark: #4eb03a; - --ifm-color-primary-darker: #4aa636; - --ifm-color-primary-darkest: #429431; - --ifm-color-primary-light: #69c756; - --ifm-color-primary-lighter: #72cb5f; - --ifm-color-primary-lightest: #8cd47d; -} - -.docs-doc-id-index article nav { - display: none; -} - -body .markdown h1:first-child { - --ifm-h1-font-size: 2rem; -} - -body .markdown h2 { - --ifm-h2-font-size: 1.75rem; -} - -@media (min-width: 996px) { - .reference-links { - display: none; - } -} - -/* Adds extra margin between last navbar item and the dark mode toggle. */ -.navbar__items--right .navbar__item:last-of-type { - margin-right: 4px; -} - -.header-link-custom:before { - display: block; - height: 24px; - width: 24px; - background-color: var(--ifm-navbar-link-color); - transition: background-color 0.15s linear; -} - -.header-link-custom:hover:before { - background-color: var(--ifm-navbar-link-hover-color); -} - -.custom-icon-inline:before { - display: inline-block; - height: 16px; - width: 16px; - background-color: var(--ifm-color-primary-contrast-foreground); - transition: background-color 0.15s linear; -} - -.custom-icon-github:before { - content: ""; - mask: url(/img/github.svg) no-repeat center / contain; - -webkit-mask: url(/img/github.svg) no-repeat center / contain; -} - -.custom-icon-discord:before { - content: ""; - mask: url(/img/discord.svg) no-repeat center / contain; - -webkit-mask: url(/img/discord.svg) no-repeat center / contain; -} - -.custom-icon-workspace:before { - content: ""; - mask: url(/img/workspace.svg) no-repeat center / contain; - -webkit-mask: url(/img/workspace.svg) no-repeat center / contain; -} - -.custom-icon-magnify-enabled:before { - content: ""; - mask: url(/img/magnify-enabled.svg) no-repeat center / contain; - -webkit-mask: url(/img/magnify-enabled.svg) no-repeat center / contain; - margin-bottom: -2px; -} - -.custom-icon-magnify-disabled:before { - content: ""; - mask: url(/img/magnify-disabled.svg) no-repeat center / contain; - -webkit-mask: url(/img/magnify-disabled.svg) no-repeat center / contain; - margin-bottom: -2px; -} - -img[src*="#left"] { - float: left; - margin: 0 10px 10px 0; - max-width: 300px; -} -img[src*="#right"] { - float: right; - margin: 0 0 10px 10px; - max-width: 300px; -} -img[src*="#center"] { - display: block; - margin: auto; -} - -.hidden { - display: none; -} diff --git a/docs/src/renderer/image-renderers.ts b/docs/src/renderer/image-renderers.ts deleted file mode 100644 index 63170b4341..0000000000 --- a/docs/src/renderer/image-renderers.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { DocsPageData, ImageGeneratorOptions, ImageRenderer } from "@waveterm/docusaurus-og"; -import { readFileSync } from "fs"; -import { join } from "path"; -import React, { ReactNode } from "react"; - -const waveLogo = join(__dirname, "../../static/img/logo/wave-dark.png"); -const waveLogoBase64 = `data:image/png;base64,${readFileSync(waveLogo).toString("base64")}`; - -const titleElement = ({ children }) => - React.createElement( - "label", - { - style: { - fontSize: 72, - fontWeight: 800, - letterSpacing: 1, - margin: "25px 225px 10px 0px", - color: "#e3e3e3", - wordBreak: "break-word", - }, - }, - children - ); - -const waveLogoElement = React.createElement("img", { - src: waveLogoBase64, - style: { - width: 300, - }, -}); - -const headerElement = (header: string, svg: ReactNode) => - React.createElement( - "div", - { - style: { - display: "flex", - alignItems: "center", - marginTop: "50px", - }, - }, - svg, - React.createElement( - "label", - { - style: { - fontSize: 30, - fontWeight: 600, - letterSpacing: 1, - color: "#58c142", - }, - }, - header - ) - ); - -const rootDivStyle: React.CSSProperties = { - display: "flex", - flexDirection: "column", - height: "100%", - width: "100%", - padding: "50px 50px", - justifyContent: "center", - fontFamily: "Roboto", - fontSize: 32, - fontWeight: 400, - backgroundColor: "#1b1b1d", - color: "#e3e3e3", - borderBottom: "2rem solid #58c142", - zIndex: "2 !important", -}; - -export const docOgRenderer: ImageRenderer<DocsPageData> = async (data, context) => { - const element = React.createElement( - "div", - { style: rootDivStyle }, - waveLogoElement, - headerElement("Documentation", null), - React.createElement(titleElement, null, data.metadata.title), - React.createElement("div", null, data.metadata.description.replace("—", "-")) - ); - - return [element, await imageGeneratorOptions()]; -}; - -const imageGeneratorOptions = async (): Promise<ImageGeneratorOptions> => { - return { - width: 1200, - height: 600, - fonts: [ - { - name: "Roboto", - data: await getTtfFont("Roboto", ["ital", "wght"], [0, 400]), - weight: 400, - style: "normal", - }, - ], - }; -}; - -function docSectionPath(slug: string, title: string) { - let section = slug.split("/")[1].toString(); - - // Override some sections by slug - switch (section) { - case "api": - section = "REST APIs"; - break; - } - - section = section.charAt(0).toUpperCase() + section.slice(1); - - return `${title} / ${section}`; -} - -async function getTtfFont(family: string, axes: string[], value: number[]): Promise<ArrayBuffer> { - const familyParam = axes.join(",") + "@" + value.join(","); - - // Get css style sheet with user agent Mozilla/5.0 Firefox/1.0 to ensure TTF is returned - const cssCall = await fetch(`https://fonts.googleapis.com/css2?family=${family}:${familyParam}&display=swap`, { - headers: { - "User-Agent": "Mozilla/5.0 Firefox/1.0", - }, - }); - - const css = await cssCall.text(); - const ttfUrl = css.match(/url\(([^)]+)\)/)?.[1]; - - return await fetch(ttfUrl).then((res) => res.arrayBuffer()); -} diff --git a/docs/src/theme/MDXComponents/Heading.tsx b/docs/src/theme/MDXComponents/Heading.tsx deleted file mode 100644 index 0b1cd9f662..0000000000 --- a/docs/src/theme/MDXComponents/Heading.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { WrapperProps } from "@docusaurus/types"; -import Heading from "@theme-original/MDXComponents/Heading"; -import type HeadingType from "@theme/MDXComponents/Heading"; - -type Props = WrapperProps<typeof HeadingType>; - -export default function HeadingWrapper(props: Props): JSX.Element { - return ( - <> - <div style={{ clear: "both" }} /> - <Heading {...props} /> - </> - ); -} diff --git a/docs/static/.nojekyll b/docs/static/.nojekyll deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docs/static/fontawesome/css/fontawesome.min.css b/docs/static/fontawesome/css/fontawesome.min.css deleted file mode 100644 index 35058ecdd1..0000000000 --- a/docs/static/fontawesome/css/fontawesome.min.css +++ /dev/null @@ -1 +0,0 @@ -@charset "utf-8";.fa{font-family:var(--fa-style-family,"Font Awesome 6 Pro");font-weight:var(--fa-style,900)}.fas,.fass,.far,.fasr,.fal,.fasl,.fat,.fast,.fad,.fadr,.fadl,.fadt,.fasds,.fasdr,.fasdl,.fasdt,.fab,.fa-solid,.fa-regular,.fa-light,.fa-thin,.fa-brands,.fa-classic,.fa-duotone,.fa-sharp,.fa-sharp-duotone,.fa{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:var(--fa-display,inline-block);font-variant:normal;text-rendering:auto;font-style:normal;line-height:1}.fas:before,.fass:before,.far:before,.fasr:before,.fal:before,.fasl:before,.fat:before,.fast:before,.fad:before,.fadr:before,.fadl:before,.fadt:before,.fasds:before,.fasdr:before,.fasdl:before,.fasdt:before,.fab:before,.fa-solid:before,.fa-regular:before,.fa-light:before,.fa-thin:before,.fa-brands:before,.fa-classic:before,.fa-duotone:before,.fa-sharp:before,.fa-sharp-duotone:before,.fa:before{content:var(--fa)}.fad:after,.fa-duotone.fa-solid:after,.fa-duotone:after,.fadr:after,.fa-duotone.fa-regular:after,.fadl:after,.fa-duotone.fa-light:after,.fadt:after,.fa-duotone.fa-thin:after,.fasds:after,.fa-sharp-duotone.fa-solid:after,.fa-sharp-duotone:after,.fasdr:after,.fa-sharp-duotone.fa-regular:after,.fasdl:after,.fa-sharp-duotone.fa-light:after,.fasdt:after,.fa-sharp-duotone.fa-thin:after{content:var(--fa--fa)}.fa-classic.fa-duotone{font-family:"Font Awesome 6 Duotone"}.fass,.fa-sharp,.fad,.fa-duotone,.fasds,.fa-sharp-duotone{font-weight:900}.fa-classic,.fas,.fa-solid,.far,.fa-regular,.fal,.fa-light,.fat,.fa-thin{font-family:"Font Awesome 6 Pro"}.fa-duotone,.fad,.fadr,.fadl,.fadt{font-family:"Font Awesome 6 Duotone"}.fa-brands,.fab{font-family:"Font Awesome 6 Brands"}.fa-sharp,.fass,.fasr,.fasl,.fast{font-family:"Font Awesome 6 Sharp"}.fa-sharp-duotone,.fasds,.fasdr,.fasdl,.fasdt{font-family:"Font Awesome 6 Sharp Duotone"}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{vertical-align:.225em;font-size:.625em;line-height:.1em}.fa-xs{vertical-align:.125em;font-size:.75em;line-height:.08333em}.fa-sm{vertical-align:.05357em;font-size:.875em;line-height:.07143em}.fa-lg{vertical-align:-.075em;font-size:1.25em;line-height:.05em}.fa-xl{vertical-align:-.125em;font-size:1.5em;line-height:.04167em}.fa-2xl{vertical-align:-.1875em;font-size:2em;line-height:.03125em}.fa-fw{text-align:center;width:1.25em}.fa-ul{margin-left:var(--fa-li-margin,2.5em);padding-left:0;list-style-type:none}.fa-ul>li{position:relative}.fa-li{left:calc(-1*var(--fa-li-width,2em));text-align:center;width:var(--fa-li-width,2em);line-height:inherit;position:absolute}.fa-border{border-color:var(--fa-border-color,#eee);border-radius:var(--fa-border-radius,.1em);border-style:var(--fa-border-style,solid);border-width:var(--fa-border-width,.08em);padding:var(--fa-border-padding,.2em .25em .15em)}.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.fa-beat{-webkit-animation-name:fa-beat;animation-name:fa-beat;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{-webkit-animation-name:fa-bounce;animation-name:fa-bounce;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{-webkit-animation-name:fa-fade;animation-name:fa-fade;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade{-webkit-animation-name:fa-beat-fade;animation-name:fa-beat-fade;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{-webkit-animation-name:fa-flip;animation-name:fa-flip;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{-webkit-animation-name:fa-shake;animation-name:fa-shake;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,2s);animation-duration:var(--fa-animation-duration,2s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,steps(8));animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-bounce,.fa-fade,.fa-beat-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{-webkit-transition-duration:0s;transition-duration:0s;-webkit-transition-delay:0s;transition-delay:0s;-webkit-animation-duration:1ms;animation-duration:1ms;-webkit-animation-iteration-count:1;animation-iteration-count:1;-webkit-animation-delay:-1ms;animation-delay:-1ms}}@-webkit-keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@-webkit-keyframes fa-bounce{0%{-webkit-transform:matrix(1,0,0,1,0,0);transform:matrix(1,0,0,1,0,0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9))translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9))translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1))translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1))translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95))translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95))translateY(0)}57%{-webkit-transform:scale(1,1)translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1,1)translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:matrix(1,0,0,1,0,0);transform:matrix(1,0,0,1,0,0)}to{-webkit-transform:matrix(1,0,0,1,0,0);transform:matrix(1,0,0,1,0,0)}}@keyframes fa-bounce{0%{-webkit-transform:matrix(1,0,0,1,0,0);transform:matrix(1,0,0,1,0,0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9))translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9))translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1))translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1))translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95))translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95))translateY(0)}57%{-webkit-transform:scale(1,1)translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1,1)translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:matrix(1,0,0,1,0,0);transform:matrix(1,0,0,1,0,0)}to{-webkit-transform:matrix(1,0,0,1,0,0);transform:matrix(1,0,0,1,0,0)}}@-webkit-keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@-webkit-keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@-webkit-keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@-webkit-keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0);transform:rotate(0)}}@keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0);transform:rotate(0)}}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}.fa-rotate-by{-webkit-transform:rotate(var(--fa-rotate-angle,0));transform:rotate(var(--fa-rotate-angle,0))}.fa-stack{vertical-align:middle;width:2.5em;height:2em;line-height:2em;display:inline-block;position:relative}.fa-stack-1x,.fa-stack-2x{text-align:center;z-index:var(--fa-stack-z-index,auto);width:100%;position:absolute;left:0}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)}.fa-0{--fa:"0";--fa--fa:"00"}.fa-00{--fa:"";--fa--fa:""}.fa-1{--fa:"1";--fa--fa:"11"}.fa-100{--fa:"";--fa--fa:""}.fa-2{--fa:"2";--fa--fa:"22"}.fa-3{--fa:"3";--fa--fa:"33"}.fa-360-degrees{--fa:"";--fa--fa:""}.fa-4{--fa:"4";--fa--fa:"44"}.fa-5{--fa:"5";--fa--fa:"55"}.fa-6{--fa:"6";--fa--fa:"66"}.fa-7{--fa:"7";--fa--fa:"77"}.fa-8{--fa:"8";--fa--fa:"88"}.fa-9{--fa:"9";--fa--fa:"99"}.fa-a{--fa:"A";--fa--fa:"AA"}.fa-abacus{--fa:"";--fa--fa:""}.fa-accent-grave{--fa:"`";--fa--fa:"``"}.fa-acorn{--fa:"īšŽ";--fa--fa:"īšŽīšŽ"}.fa-ad{--fa:"";--fa--fa:""}.fa-add{--fa:"+";--fa--fa:"++"}.fa-address-book{--fa:"īŠš";--fa--fa:"īŠšīŠš"}.fa-address-card{--fa:"īŠģ";--fa--fa:"īŠģīŠģ"}.fa-adjust{--fa:"";--fa--fa:""}.fa-air-conditioner{--fa:"īŖ´";--fa--fa:"īŖ´īŖ´"}.fa-air-freshener{--fa:"";--fa--fa:""}.fa-airplay{--fa:"";--fa--fa:""}.fa-alarm-clock{--fa:"īŽ";--fa--fa:"īŽīŽ"}.fa-alarm-exclamation{--fa:"īĄƒ";--fa--fa:"īĄƒīĄƒ"}.fa-alarm-plus{--fa:"īĄ„";--fa--fa:"īĄ„īĄ„"}.fa-alarm-snooze{--fa:"īĄ…";--fa--fa:"īĄ…īĄ…"}.fa-album{--fa:"īĸŸ";--fa--fa:"īĸŸīĸŸ"}.fa-album-circle-plus{--fa:"";--fa--fa:""}.fa-album-circle-user{--fa:"";--fa--fa:""}.fa-album-collection{--fa:"īĸ ";--fa--fa:"īĸ īĸ "}.fa-album-collection-circle-plus{--fa:"";--fa--fa:""}.fa-album-collection-circle-user{--fa:"";--fa--fa:""}.fa-alicorn{--fa:"";--fa--fa:""}.fa-alien{--fa:"īŖĩ";--fa--fa:"īŖĩīŖĩ"}.fa-alien-8bit,.fa-alien-monster{--fa:"īŖļ";--fa--fa:"īŖļīŖļ"}.fa-align-center{--fa:"";--fa--fa:""}.fa-align-justify{--fa:"ī€š";--fa--fa:"ī€šī€š"}.fa-align-left{--fa:"ī€ļ";--fa--fa:"ī€ļī€ļ"}.fa-align-right{--fa:"";--fa--fa:""}.fa-align-slash{--fa:"īĄ†";--fa--fa:"īĄ†īĄ†"}.fa-allergies{--fa:"ī‘Ą";--fa--fa:"ī‘Ąī‘Ą"}.fa-alt{--fa:"";--fa--fa:""}.fa-ambulance{--fa:"īƒš";--fa--fa:"īƒšīƒš"}.fa-american-sign-language-interpreting{--fa:"";--fa--fa:""}.fa-amp-guitar{--fa:"īĸĄ";--fa--fa:"īĸĄīĸĄ"}.fa-ampersand{--fa:"&";--fa--fa:"&&"}.fa-analytics{--fa:"ī™ƒ";--fa--fa:"ī™ƒī™ƒ"}.fa-anchor{--fa:"ī„Ŋ";--fa--fa:"ī„Ŋī„Ŋ"}.fa-anchor-circle-check{--fa:"î’Ē";--fa--fa:"î’Ēî’Ē"}.fa-anchor-circle-exclamation{--fa:"î’Ģ";--fa--fa:"î’Ģî’Ģ"}.fa-anchor-circle-xmark{--fa:"î’Ŧ";--fa--fa:"î’Ŧî’Ŧ"}.fa-anchor-lock{--fa:"";--fa--fa:""}.fa-angel{--fa:"īš";--fa--fa:"īšīš"}.fa-angle{--fa:"";--fa--fa:""}.fa-angle-90{--fa:"";--fa--fa:""}.fa-angle-double-down{--fa:"ī„ƒ";--fa--fa:"ī„ƒī„ƒ"}.fa-angle-double-left{--fa:"ī„€";--fa--fa:""}.fa-angle-double-right{--fa:"";--fa--fa:""}.fa-angle-double-up{--fa:"ī„‚";--fa--fa:"ī„‚ī„‚"}.fa-angle-down{--fa:"";--fa--fa:""}.fa-angle-left{--fa:"ī„„";--fa--fa:"ī„„ī„„"}.fa-angle-right{--fa:"ī„…";--fa--fa:"ī„…ī„…"}.fa-angle-up{--fa:"";--fa--fa:""}.fa-angles-down{--fa:"ī„ƒ";--fa--fa:"ī„ƒī„ƒ"}.fa-angles-left{--fa:"ī„€";--fa--fa:""}.fa-angles-right{--fa:"";--fa--fa:""}.fa-angles-up{--fa:"ī„‚";--fa--fa:"ī„‚ī„‚"}.fa-angles-up-down{--fa:"";--fa--fa:""}.fa-angry{--fa:"ī•–";--fa--fa:"ī•–ī•–"}.fa-ankh{--fa:"";--fa--fa:""}.fa-ant{--fa:"";--fa--fa:""}.fa-apartment{--fa:"";--fa--fa:""}.fa-aperture{--fa:"";--fa--fa:""}.fa-apostrophe{--fa:"'";--fa--fa:"''"}.fa-apple-alt{--fa:"ī—‘";--fa--fa:"ī—‘ī—‘"}.fa-apple-core{--fa:"";--fa--fa:""}.fa-apple-crate{--fa:"īšą";--fa--fa:"īšąīšą"}.fa-apple-whole{--fa:"ī—‘";--fa--fa:"ī—‘ī—‘"}.fa-archive{--fa:"";--fa--fa:""}.fa-archway{--fa:"ī•—";--fa--fa:"ī•—ī•—"}.fa-area-chart{--fa:"ī‡ž";--fa--fa:"ī‡žī‡ž"}.fa-arrow-alt-circle-down{--fa:"ī˜";--fa--fa:"ī˜ī˜"}.fa-arrow-alt-circle-left{--fa:"ī™";--fa--fa:"ī™ī™"}.fa-arrow-alt-circle-right{--fa:"īš";--fa--fa:"īšīš"}.fa-arrow-alt-circle-up{--fa:"ī›";--fa--fa:"ī›ī›"}.fa-arrow-alt-down{--fa:"ī”";--fa--fa:"ī”ī”"}.fa-arrow-alt-from-bottom{--fa:"ī†";--fa--fa:"ī†ī†"}.fa-arrow-alt-from-left{--fa:"ī‡";--fa--fa:"ī‡ī‡"}.fa-arrow-alt-from-right{--fa:"īˆ";--fa--fa:"īˆīˆ"}.fa-arrow-alt-from-top{--fa:"ī‰";--fa--fa:"ī‰ī‰"}.fa-arrow-alt-left{--fa:"ī•";--fa--fa:"ī•ī•"}.fa-arrow-alt-right{--fa:"ī–";--fa--fa:"ī–ī–"}.fa-arrow-alt-square-down{--fa:"ī";--fa--fa:"īī"}.fa-arrow-alt-square-left{--fa:"ī‘";--fa--fa:"ī‘ī‘"}.fa-arrow-alt-square-right{--fa:"ī’";--fa--fa:"ī’ī’"}.fa-arrow-alt-square-up{--fa:"ī“";--fa--fa:"ī“ī“"}.fa-arrow-alt-to-bottom{--fa:"īŠ";--fa--fa:"īŠīŠ"}.fa-arrow-alt-to-left{--fa:"ī‹";--fa--fa:"ī‹ī‹"}.fa-arrow-alt-to-right{--fa:"īŒ";--fa--fa:"īŒīŒ"}.fa-arrow-alt-to-top{--fa:"ī";--fa--fa:"īī"}.fa-arrow-alt-up{--fa:"ī—";--fa--fa:"ī—ī—"}.fa-arrow-circle-down{--fa:"ī‚Ģ";--fa--fa:"ī‚Ģī‚Ģ"}.fa-arrow-circle-left{--fa:"";--fa--fa:""}.fa-arrow-circle-right{--fa:"ī‚Š";--fa--fa:"ī‚Šī‚Š"}.fa-arrow-circle-up{--fa:"ī‚Ē";--fa--fa:"ī‚Ēī‚Ē"}.fa-arrow-down{--fa:"";--fa--fa:""}.fa-arrow-down-1-9{--fa:"ī…ĸ";--fa--fa:"ī…ĸī…ĸ"}.fa-arrow-down-9-1{--fa:"īĸ†";--fa--fa:"īĸ†īĸ†"}.fa-arrow-down-a-z{--fa:"ī…";--fa--fa:"ī…ī…"}.fa-arrow-down-arrow-up{--fa:"īĸƒ";--fa--fa:"īĸƒīĸƒ"}.fa-arrow-down-big-small{--fa:"īĸŒ";--fa--fa:"īĸŒīĸŒ"}.fa-arrow-down-from-arc{--fa:"";--fa--fa:""}.fa-arrow-down-from-bracket{--fa:"";--fa--fa:""}.fa-arrow-down-from-dotted-line{--fa:"";--fa--fa:""}.fa-arrow-down-from-line{--fa:"ī…";--fa--fa:"ī…ī…"}.fa-arrow-down-left{--fa:"";--fa--fa:""}.fa-arrow-down-left-and-arrow-up-right-to-center{--fa:"";--fa--fa:""}.fa-arrow-down-long{--fa:"ī…ĩ";--fa--fa:"ī…ĩī…ĩ"}.fa-arrow-down-right{--fa:"";--fa--fa:""}.fa-arrow-down-short-wide{--fa:"īĸ„";--fa--fa:"īĸ„īĸ„"}.fa-arrow-down-small-big{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-arrow-down-square-triangle{--fa:"īĸ‰";--fa--fa:"īĸ‰īĸ‰"}.fa-arrow-down-to-arc{--fa:"";--fa--fa:""}.fa-arrow-down-to-bracket{--fa:"";--fa--fa:""}.fa-arrow-down-to-dotted-line{--fa:"";--fa--fa:""}.fa-arrow-down-to-line{--fa:"īŒŊ";--fa--fa:"īŒŊīŒŊ"}.fa-arrow-down-to-square{--fa:"";--fa--fa:""}.fa-arrow-down-triangle-square{--fa:"īĸˆ";--fa--fa:"īĸˆīĸˆ"}.fa-arrow-down-up-across-line{--fa:"";--fa--fa:""}.fa-arrow-down-up-lock{--fa:"";--fa--fa:""}.fa-arrow-down-wide-short{--fa:"ī… ";--fa--fa:"ī… ī… "}.fa-arrow-down-z-a{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-arrow-from-bottom{--fa:"ī‚";--fa--fa:"ī‚ī‚"}.fa-arrow-from-left{--fa:"īƒ";--fa--fa:"īƒīƒ"}.fa-arrow-from-right{--fa:"ī„";--fa--fa:"ī„ī„"}.fa-arrow-from-top{--fa:"ī…";--fa--fa:"ī…ī…"}.fa-arrow-left{--fa:"";--fa--fa:""}.fa-arrow-left-from-arc{--fa:"";--fa--fa:""}.fa-arrow-left-from-bracket{--fa:"";--fa--fa:""}.fa-arrow-left-from-line{--fa:"ī„";--fa--fa:"ī„ī„"}.fa-arrow-left-long{--fa:"ī…ˇ";--fa--fa:""}.fa-arrow-left-long-to-line{--fa:"";--fa--fa:""}.fa-arrow-left-rotate{--fa:"īƒĸ";--fa--fa:"īƒĸīƒĸ"}.fa-arrow-left-to-arc{--fa:"";--fa--fa:""}.fa-arrow-left-to-bracket{--fa:"";--fa--fa:""}.fa-arrow-left-to-line{--fa:"īŒž";--fa--fa:"īŒžīŒž"}.fa-arrow-pointer{--fa:"";--fa--fa:""}.fa-arrow-progress{--fa:"";--fa--fa:""}.fa-arrow-right{--fa:"īĄ";--fa--fa:"īĄīĄ"}.fa-arrow-right-arrow-left{--fa:"īƒŦ";--fa--fa:"īƒŦīƒŦ"}.fa-arrow-right-from-arc{--fa:"";--fa--fa:""}.fa-arrow-right-from-bracket{--fa:"ī‚‹";--fa--fa:"ī‚‹ī‚‹"}.fa-arrow-right-from-file{--fa:"ī•Ž";--fa--fa:"ī•Žī•Ž"}.fa-arrow-right-from-line{--fa:"īƒ";--fa--fa:"īƒīƒ"}.fa-arrow-right-long{--fa:"ī…¸";--fa--fa:""}.fa-arrow-right-long-to-line{--fa:"";--fa--fa:""}.fa-arrow-right-rotate{--fa:"ī€ž";--fa--fa:"ī€žī€ž"}.fa-arrow-right-to-arc{--fa:"";--fa--fa:""}.fa-arrow-right-to-bracket{--fa:"";--fa--fa:""}.fa-arrow-right-to-city{--fa:"î’ŗ";--fa--fa:"î’ŗî’ŗ"}.fa-arrow-right-to-file{--fa:"";--fa--fa:""}.fa-arrow-right-to-line{--fa:"ī€";--fa--fa:"ī€ī€"}.fa-arrow-rotate-back,.fa-arrow-rotate-backward{--fa:"īƒĸ";--fa--fa:"īƒĸīƒĸ"}.fa-arrow-rotate-forward{--fa:"ī€ž";--fa--fa:"ī€žī€ž"}.fa-arrow-rotate-left{--fa:"īƒĸ";--fa--fa:"īƒĸīƒĸ"}.fa-arrow-rotate-right{--fa:"ī€ž";--fa--fa:"ī€žī€ž"}.fa-arrow-square-down{--fa:"īŒš";--fa--fa:"īŒšīŒš"}.fa-arrow-square-left{--fa:"īŒē";--fa--fa:"īŒēīŒē"}.fa-arrow-square-right{--fa:"īŒģ";--fa--fa:"īŒģīŒģ"}.fa-arrow-square-up{--fa:"īŒŧ";--fa--fa:"īŒŧīŒŧ"}.fa-arrow-to-bottom{--fa:"īŒŊ";--fa--fa:"īŒŊīŒŊ"}.fa-arrow-to-left{--fa:"īŒž";--fa--fa:"īŒžīŒž"}.fa-arrow-to-right{--fa:"ī€";--fa--fa:"ī€ī€"}.fa-arrow-to-top{--fa:"ī";--fa--fa:"īī"}.fa-arrow-trend-down{--fa:"";--fa--fa:""}.fa-arrow-trend-up{--fa:"";--fa--fa:""}.fa-arrow-turn-down{--fa:"ī…‰";--fa--fa:""}.fa-arrow-turn-down-left{--fa:"";--fa--fa:""}.fa-arrow-turn-down-right{--fa:"";--fa--fa:""}.fa-arrow-turn-left{--fa:"";--fa--fa:""}.fa-arrow-turn-left-down{--fa:"î˜ŗ";--fa--fa:"î˜ŗî˜ŗ"}.fa-arrow-turn-left-up{--fa:"";--fa--fa:""}.fa-arrow-turn-right{--fa:"î˜ĩ";--fa--fa:"î˜ĩî˜ĩ"}.fa-arrow-turn-up{--fa:"ī…ˆ";--fa--fa:"ī…ˆī…ˆ"}.fa-arrow-up{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-arrow-up-1-9{--fa:"ī…Ŗ";--fa--fa:"ī…Ŗī…Ŗ"}.fa-arrow-up-9-1{--fa:"īĸ‡";--fa--fa:"īĸ‡īĸ‡"}.fa-arrow-up-a-z{--fa:"ī…ž";--fa--fa:"ī…žī…ž"}.fa-arrow-up-arrow-down{--fa:"";--fa--fa:""}.fa-arrow-up-big-small{--fa:"īĸŽ";--fa--fa:"īĸŽīĸŽ"}.fa-arrow-up-from-arc{--fa:"";--fa--fa:""}.fa-arrow-up-from-bracket{--fa:"";--fa--fa:""}.fa-arrow-up-from-dotted-line{--fa:"";--fa--fa:""}.fa-arrow-up-from-ground-water{--fa:"î’ĩ";--fa--fa:"î’ĩî’ĩ"}.fa-arrow-up-from-line{--fa:"ī‚";--fa--fa:"ī‚ī‚"}.fa-arrow-up-from-square{--fa:"";--fa--fa:""}.fa-arrow-up-from-water-pump{--fa:"î’ļ";--fa--fa:"î’ļî’ļ"}.fa-arrow-up-left{--fa:"";--fa--fa:""}.fa-arrow-up-left-from-circle{--fa:"";--fa--fa:""}.fa-arrow-up-long{--fa:"ī…ļ";--fa--fa:"ī…ļī…ļ"}.fa-arrow-up-right{--fa:"";--fa--fa:""}.fa-arrow-up-right-and-arrow-down-left-from-center{--fa:"";--fa--fa:""}.fa-arrow-up-right-dots{--fa:"";--fa--fa:""}.fa-arrow-up-right-from-square{--fa:"ī‚Ž";--fa--fa:"ī‚Žī‚Ž"}.fa-arrow-up-short-wide{--fa:"īĸ…";--fa--fa:"īĸ…īĸ…"}.fa-arrow-up-small-big{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-arrow-up-square-triangle{--fa:"īĸ‹";--fa--fa:"īĸ‹īĸ‹"}.fa-arrow-up-to-arc{--fa:"";--fa--fa:""}.fa-arrow-up-to-bracket{--fa:"î™Ē";--fa--fa:"î™Ēî™Ē"}.fa-arrow-up-to-dotted-line{--fa:"";--fa--fa:""}.fa-arrow-up-to-line{--fa:"ī";--fa--fa:"īī"}.fa-arrow-up-triangle-square{--fa:"īĸŠ";--fa--fa:"īĸŠīĸŠ"}.fa-arrow-up-wide-short{--fa:"ī…Ą";--fa--fa:"ī…Ąī…Ą"}.fa-arrow-up-z-a{--fa:"īĸ‚";--fa--fa:"īĸ‚īĸ‚"}.fa-arrows{--fa:"";--fa--fa:""}.fa-arrows-alt{--fa:"";--fa--fa:""}.fa-arrows-alt-h{--fa:"";--fa--fa:""}.fa-arrows-alt-v{--fa:"";--fa--fa:""}.fa-arrows-cross{--fa:"î‚ĸ";--fa--fa:"î‚ĸî‚ĸ"}.fa-arrows-down-to-line{--fa:"";--fa--fa:""}.fa-arrows-down-to-people{--fa:"";--fa--fa:""}.fa-arrows-from-dotted-line{--fa:"î‚Ŗ";--fa--fa:"î‚Ŗî‚Ŗ"}.fa-arrows-from-line{--fa:"";--fa--fa:""}.fa-arrows-h,.fa-arrows-left-right{--fa:"īž";--fa--fa:"īžīž"}.fa-arrows-left-right-to-line{--fa:"î’ē";--fa--fa:"î’ēî’ē"}.fa-arrows-maximize{--fa:"īŒ";--fa--fa:"īŒīŒ"}.fa-arrows-minimize{--fa:"î‚Ĩ";--fa--fa:"î‚Ĩî‚Ĩ"}.fa-arrows-repeat{--fa:"ī¤";--fa--fa:"ī¤ī¤"}.fa-arrows-repeat-1{--fa:"īĻ";--fa--fa:"īĻīĻ"}.fa-arrows-retweet{--fa:"īĄ";--fa--fa:"īĄīĄ"}.fa-arrows-rotate{--fa:"ī€Ą";--fa--fa:"ī€Ąī€Ą"}.fa-arrows-rotate-reverse{--fa:"";--fa--fa:""}.fa-arrows-spin{--fa:"î’ģ";--fa--fa:"î’ģî’ģ"}.fa-arrows-split-up-and-left{--fa:"î’ŧ";--fa--fa:"î’ŧî’ŧ"}.fa-arrows-to-circle{--fa:"î’Ŋ";--fa--fa:"î’Ŋî’Ŋ"}.fa-arrows-to-dot{--fa:"";--fa--fa:""}.fa-arrows-to-dotted-line{--fa:"î‚Ļ";--fa--fa:"î‚Ļî‚Ļ"}.fa-arrows-to-eye{--fa:"î’ŋ";--fa--fa:"î’ŋî’ŋ"}.fa-arrows-to-line{--fa:"";--fa--fa:""}.fa-arrows-turn-right{--fa:"";--fa--fa:""}.fa-arrows-turn-to-dots{--fa:"";--fa--fa:""}.fa-arrows-up-down{--fa:"īŊ";--fa--fa:"īŊīŊ"}.fa-arrows-up-down-left-right{--fa:"";--fa--fa:""}.fa-arrows-up-to-line{--fa:"";--fa--fa:""}.fa-arrows-v{--fa:"īŊ";--fa--fa:"īŊīŊ"}.fa-asl-interpreting{--fa:"";--fa--fa:""}.fa-assistive-listening-systems{--fa:"īŠĸ";--fa--fa:"īŠĸīŠĸ"}.fa-asterisk{--fa:"*";--fa--fa:"**"}.fa-at{--fa:"@";--fa--fa:"@@"}.fa-atlas{--fa:"ī•˜";--fa--fa:"ī•˜ī•˜"}.fa-atom{--fa:"ī—’";--fa--fa:"ī—’ī—’"}.fa-atom-alt,.fa-atom-simple{--fa:"ī—“";--fa--fa:"ī—“ī—“"}.fa-audio-description{--fa:"īŠž";--fa--fa:"īŠžīŠž"}.fa-audio-description-slash{--fa:"";--fa--fa:""}.fa-austral-sign{--fa:"";--fa--fa:""}.fa-automobile{--fa:"ī†š";--fa--fa:"ī†šī†š"}.fa-avocado{--fa:"î‚Ē";--fa--fa:"î‚Ēî‚Ē"}.fa-award{--fa:"ī•™";--fa--fa:""}.fa-award-simple{--fa:"î‚Ģ";--fa--fa:"î‚Ģî‚Ģ"}.fa-axe{--fa:"";--fa--fa:""}.fa-axe-battle{--fa:"";--fa--fa:""}.fa-b{--fa:"B";--fa--fa:"BB"}.fa-baby{--fa:"īŧ";--fa--fa:"īŧīŧ"}.fa-baby-carriage{--fa:"īŊ";--fa--fa:"īŊīŊ"}.fa-backpack{--fa:"ī—”";--fa--fa:""}.fa-backspace{--fa:"ī•š";--fa--fa:"ī•šī•š"}.fa-backward{--fa:"";--fa--fa:""}.fa-backward-fast{--fa:"";--fa--fa:""}.fa-backward-step{--fa:"";--fa--fa:""}.fa-bacon{--fa:"īŸĨ";--fa--fa:"īŸĨīŸĨ"}.fa-bacteria{--fa:"";--fa--fa:""}.fa-bacterium{--fa:"";--fa--fa:""}.fa-badge{--fa:"īŒĩ";--fa--fa:"īŒĩīŒĩ"}.fa-badge-check{--fa:"īŒļ";--fa--fa:"īŒļīŒļ"}.fa-badge-dollar{--fa:"ī™…";--fa--fa:"ī™…ī™…"}.fa-badge-percent{--fa:"";--fa--fa:""}.fa-badge-sheriff{--fa:"īĸĸ";--fa--fa:"īĸĸīĸĸ"}.fa-badger-honey{--fa:"";--fa--fa:""}.fa-badminton{--fa:"îŒē";--fa--fa:"îŒēîŒē"}.fa-bag-seedling{--fa:"";--fa--fa:""}.fa-bag-shopping{--fa:"";--fa--fa:""}.fa-bag-shopping-minus{--fa:"";--fa--fa:""}.fa-bag-shopping-plus{--fa:"";--fa--fa:""}.fa-bagel{--fa:"";--fa--fa:""}.fa-bags-shopping{--fa:"īĄ‡";--fa--fa:"īĄ‡īĄ‡"}.fa-baguette{--fa:"";--fa--fa:""}.fa-bahai{--fa:"ī™Ļ";--fa--fa:"ī™Ļī™Ļ"}.fa-baht-sign{--fa:"î‚Ŧ";--fa--fa:"î‚Ŧî‚Ŧ"}.fa-balance-scale{--fa:"ī‰Ž";--fa--fa:"ī‰Žī‰Ž"}.fa-balance-scale-left{--fa:"";--fa--fa:""}.fa-balance-scale-right{--fa:"ī”–";--fa--fa:"ī”–ī”–"}.fa-ball-pile{--fa:"īž";--fa--fa:"īžīž"}.fa-balloon{--fa:"î‹Ŗ";--fa--fa:"î‹Ŗî‹Ŗ"}.fa-balloons{--fa:"";--fa--fa:""}.fa-ballot{--fa:"";--fa--fa:""}.fa-ballot-check{--fa:"";--fa--fa:""}.fa-ban{--fa:"īž";--fa--fa:"īžīž"}.fa-ban-bug{--fa:"īŸš";--fa--fa:"īŸšīŸš"}.fa-ban-parking{--fa:"ī˜–";--fa--fa:"ī˜–ī˜–"}.fa-ban-smoking{--fa:"ī•";--fa--fa:"ī•ī•"}.fa-banana{--fa:"î‹Ĩ";--fa--fa:"î‹Ĩî‹Ĩ"}.fa-band-aid,.fa-bandage{--fa:"ī‘ĸ";--fa--fa:"ī‘ĸī‘ĸ"}.fa-bangladeshi-taka-sign{--fa:"î‹Ļ";--fa--fa:"î‹Ļî‹Ļ"}.fa-banjo{--fa:"īĸŖ";--fa--fa:"īĸŖīĸŖ"}.fa-bank{--fa:"ī†œ";--fa--fa:"ī†œī†œ"}.fa-bar-chart{--fa:"ī‚€";--fa--fa:""}.fa-barcode{--fa:"ī€Ē";--fa--fa:"ī€Ēī€Ē"}.fa-barcode-alt{--fa:"";--fa--fa:""}.fa-barcode-read{--fa:"";--fa--fa:""}.fa-barcode-scan{--fa:"ī‘Ĩ";--fa--fa:"ī‘Ĩī‘Ĩ"}.fa-barn-silo{--fa:"īĄ¤";--fa--fa:"īĄ¤īĄ¤"}.fa-bars{--fa:"īƒ‰";--fa--fa:"īƒ‰īƒ‰"}.fa-bars-filter{--fa:"";--fa--fa:""}.fa-bars-progress{--fa:"ī ¨";--fa--fa:""}.fa-bars-sort{--fa:"";--fa--fa:""}.fa-bars-staggered{--fa:"";--fa--fa:""}.fa-baseball,.fa-baseball-ball{--fa:"";--fa--fa:""}.fa-baseball-bat-ball{--fa:"";--fa--fa:""}.fa-basket-shopping{--fa:"īŠ‘";--fa--fa:"īŠ‘īŠ‘"}.fa-basket-shopping-minus{--fa:"";--fa--fa:""}.fa-basket-shopping-plus{--fa:"";--fa--fa:""}.fa-basket-shopping-simple{--fa:"";--fa--fa:""}.fa-basketball,.fa-basketball-ball{--fa:"";--fa--fa:""}.fa-basketball-hoop{--fa:"īĩ";--fa--fa:"īĩīĩ"}.fa-bat{--fa:"īšĩ";--fa--fa:"īšĩīšĩ"}.fa-bath,.fa-bathtub{--fa:"ī‹";--fa--fa:"ī‹ī‹"}.fa-battery{--fa:"";--fa--fa:""}.fa-battery-0{--fa:"";--fa--fa:""}.fa-battery-1{--fa:"";--fa--fa:""}.fa-battery-2{--fa:"ī‰ƒ";--fa--fa:"ī‰ƒī‰ƒ"}.fa-battery-3{--fa:"";--fa--fa:""}.fa-battery-4{--fa:"";--fa--fa:""}.fa-battery-5{--fa:"";--fa--fa:""}.fa-battery-bolt{--fa:"īļ";--fa--fa:"īļīļ"}.fa-battery-car{--fa:"ī—Ÿ";--fa--fa:"ī—Ÿī—Ÿ"}.fa-battery-empty{--fa:"";--fa--fa:""}.fa-battery-exclamation{--fa:"";--fa--fa:""}.fa-battery-full{--fa:"";--fa--fa:""}.fa-battery-half{--fa:"";--fa--fa:""}.fa-battery-low{--fa:"";--fa--fa:""}.fa-battery-quarter{--fa:"ī‰ƒ";--fa--fa:"ī‰ƒī‰ƒ"}.fa-battery-slash{--fa:"īˇ";--fa--fa:"īˇīˇ"}.fa-battery-three-quarters{--fa:"";--fa--fa:""}.fa-bed{--fa:"īˆļ";--fa--fa:"īˆļīˆļ"}.fa-bed-alt{--fa:"īŖˇ";--fa--fa:""}.fa-bed-bunk{--fa:"īŖ¸";--fa--fa:""}.fa-bed-empty{--fa:"īŖš";--fa--fa:"īŖšīŖš"}.fa-bed-front{--fa:"īŖˇ";--fa--fa:""}.fa-bed-pulse{--fa:"ī’‡";--fa--fa:""}.fa-bee{--fa:"";--fa--fa:""}.fa-beer{--fa:"īƒŧ";--fa--fa:"īƒŧīƒŧ"}.fa-beer-foam,.fa-beer-mug{--fa:"î‚ŗ";--fa--fa:"î‚ŗî‚ŗ"}.fa-beer-mug-empty{--fa:"īƒŧ";--fa--fa:"īƒŧīƒŧ"}.fa-bell{--fa:"";--fa--fa:""}.fa-bell-concierge{--fa:"ī•ĸ";--fa--fa:"ī•ĸī•ĸ"}.fa-bell-exclamation{--fa:"īĄˆ";--fa--fa:"īĄˆīĄˆ"}.fa-bell-on{--fa:"īŖē";--fa--fa:"īŖēīŖē"}.fa-bell-plus{--fa:"īĄ‰";--fa--fa:"īĄ‰īĄ‰"}.fa-bell-ring{--fa:"î˜Ŧ";--fa--fa:"î˜Ŧî˜Ŧ"}.fa-bell-school{--fa:"ī—•";--fa--fa:"ī—•ī—•"}.fa-bell-school-slash{--fa:"ī—–";--fa--fa:"ī—–ī—–"}.fa-bell-slash{--fa:"ī‡ļ";--fa--fa:"ī‡ļī‡ļ"}.fa-bells{--fa:"īŋ";--fa--fa:"īŋīŋ"}.fa-bench-tree{--fa:"";--fa--fa:""}.fa-betamax{--fa:"īĸ¤";--fa--fa:"īĸ¤īĸ¤"}.fa-bezier-curve{--fa:"ī•›";--fa--fa:""}.fa-bible{--fa:"";--fa--fa:""}.fa-bicycle{--fa:"īˆ†";--fa--fa:"īˆ†īˆ†"}.fa-biking{--fa:"īĄŠ";--fa--fa:"īĄŠīĄŠ"}.fa-biking-mountain{--fa:"īĄ‹";--fa--fa:"īĄ‹īĄ‹"}.fa-billboard{--fa:"";--fa--fa:""}.fa-bin-bottles{--fa:"î—ĩ";--fa--fa:"î—ĩî—ĩ"}.fa-bin-bottles-recycle{--fa:"î—ļ";--fa--fa:"î—ļî—ļ"}.fa-bin-recycle{--fa:"";--fa--fa:""}.fa-binary{--fa:"îŒģ";--fa--fa:"îŒģîŒģ"}.fa-binary-circle-check{--fa:"îŒŧ";--fa--fa:"îŒŧîŒŧ"}.fa-binary-lock{--fa:"îŒŊ";--fa--fa:"îŒŊîŒŊ"}.fa-binary-slash{--fa:"";--fa--fa:""}.fa-binoculars{--fa:"ī‡Ĩ";--fa--fa:"ī‡Ĩī‡Ĩ"}.fa-biohazard{--fa:"īž€";--fa--fa:"īž€īž€"}.fa-bird{--fa:"";--fa--fa:""}.fa-birthday-cake{--fa:"ī‡Ŋ";--fa--fa:"ī‡Ŋī‡Ŋ"}.fa-bitcoin-sign{--fa:"";--fa--fa:""}.fa-blackboard{--fa:"ī”›";--fa--fa:""}.fa-blanket{--fa:"ī’˜";--fa--fa:"ī’˜ī’˜"}.fa-blanket-fire{--fa:"";--fa--fa:""}.fa-blender{--fa:"ī”—";--fa--fa:"ī”—ī”—"}.fa-blender-phone{--fa:"īšļ";--fa--fa:"īšļīšļ"}.fa-blind{--fa:"īŠ";--fa--fa:"īŠīŠ"}.fa-blinds{--fa:"īŖģ";--fa--fa:"īŖģīŖģ"}.fa-blinds-open{--fa:"īŖŧ";--fa--fa:"īŖŧīŖŧ"}.fa-blinds-raised{--fa:"īŖŊ";--fa--fa:"īŖŊīŖŊ"}.fa-block{--fa:"î‘Ē";--fa--fa:"î‘Ēî‘Ē"}.fa-block-brick{--fa:"";--fa--fa:""}.fa-block-brick-fire{--fa:"";--fa--fa:""}.fa-block-question{--fa:"";--fa--fa:""}.fa-block-quote{--fa:"î‚ĩ";--fa--fa:"î‚ĩî‚ĩ"}.fa-blog{--fa:"īž";--fa--fa:"īžīž"}.fa-blueberries{--fa:"";--fa--fa:""}.fa-bluetooth{--fa:"īŠ“";--fa--fa:"īŠ“īŠ“"}.fa-bold{--fa:"";--fa--fa:""}.fa-bolt{--fa:"";--fa--fa:""}.fa-bolt-auto{--fa:"î‚ļ";--fa--fa:"î‚ļî‚ļ"}.fa-bolt-lightning{--fa:"";--fa--fa:""}.fa-bolt-slash{--fa:"";--fa--fa:""}.fa-bomb{--fa:"ī‡ĸ";--fa--fa:"ī‡ĸī‡ĸ"}.fa-bone{--fa:"ī——";--fa--fa:"ī——ī——"}.fa-bone-break{--fa:"ī—˜";--fa--fa:"ī—˜ī—˜"}.fa-bong{--fa:"ī•œ";--fa--fa:"ī•œī•œ"}.fa-book{--fa:"";--fa--fa:""}.fa-book-alt{--fa:"ī—™";--fa--fa:""}.fa-book-arrow-right{--fa:"";--fa--fa:""}.fa-book-arrow-up{--fa:"î‚ē";--fa--fa:"î‚ēî‚ē"}.fa-book-atlas{--fa:"ī•˜";--fa--fa:"ī•˜ī•˜"}.fa-book-bible{--fa:"";--fa--fa:""}.fa-book-blank{--fa:"ī—™";--fa--fa:""}.fa-book-bookmark{--fa:"î‚ģ";--fa--fa:"î‚ģî‚ģ"}.fa-book-circle{--fa:"îƒŋ";--fa--fa:"îƒŋîƒŋ"}.fa-book-circle-arrow-right{--fa:"î‚ŧ";--fa--fa:"î‚ŧî‚ŧ"}.fa-book-circle-arrow-up{--fa:"î‚Ŋ";--fa--fa:"î‚Ŋî‚Ŋ"}.fa-book-copy{--fa:"";--fa--fa:""}.fa-book-dead{--fa:"";--fa--fa:""}.fa-book-font{--fa:"î‚ŋ";--fa--fa:"î‚ŋî‚ŋ"}.fa-book-heart{--fa:"ī’™";--fa--fa:""}.fa-book-journal-whills{--fa:"ī™Ē";--fa--fa:"ī™Ēī™Ē"}.fa-book-law{--fa:"";--fa--fa:""}.fa-book-medical{--fa:"īŸĻ";--fa--fa:"īŸĻīŸĻ"}.fa-book-open{--fa:"ī”˜";--fa--fa:"ī”˜ī”˜"}.fa-book-open-alt,.fa-book-open-cover{--fa:"";--fa--fa:""}.fa-book-open-reader{--fa:"ī—š";--fa--fa:"ī—šī—š"}.fa-book-quran{--fa:"īš‡";--fa--fa:"īš‡īš‡"}.fa-book-reader{--fa:"ī—š";--fa--fa:"ī—šī—š"}.fa-book-section{--fa:"";--fa--fa:""}.fa-book-skull{--fa:"";--fa--fa:""}.fa-book-sparkles,.fa-book-spells{--fa:"";--fa--fa:""}.fa-book-tanakh{--fa:"ī §";--fa--fa:"ī §ī §"}.fa-book-user{--fa:"";--fa--fa:""}.fa-bookmark{--fa:"ī€Ž";--fa--fa:"ī€Žī€Ž"}.fa-bookmark-circle{--fa:"";--fa--fa:""}.fa-bookmark-slash{--fa:"";--fa--fa:""}.fa-books{--fa:"ī—›";--fa--fa:""}.fa-books-medical{--fa:"";--fa--fa:""}.fa-boombox{--fa:"īĸĨ";--fa--fa:"īĸĨīĸĨ"}.fa-boot{--fa:"īž‚";--fa--fa:"īž‚īž‚"}.fa-boot-heeled{--fa:"îŒŋ";--fa--fa:"îŒŋîŒŋ"}.fa-booth-curtain{--fa:"";--fa--fa:""}.fa-border-all{--fa:"īĄŒ";--fa--fa:"īĄŒīĄŒ"}.fa-border-bottom{--fa:"īĄ";--fa--fa:"īĄīĄ"}.fa-border-bottom-right{--fa:"īĄ”";--fa--fa:"īĄ”īĄ”"}.fa-border-center-h{--fa:"īĸœ";--fa--fa:"īĸœīĸœ"}.fa-border-center-v{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-border-inner{--fa:"īĄŽ";--fa--fa:"īĄŽīĄŽ"}.fa-border-left{--fa:"īĄ";--fa--fa:"īĄīĄ"}.fa-border-none{--fa:"īĄ";--fa--fa:"īĄīĄ"}.fa-border-outer{--fa:"īĄ‘";--fa--fa:"īĄ‘īĄ‘"}.fa-border-right{--fa:"īĄ’";--fa--fa:"īĄ’īĄ’"}.fa-border-style{--fa:"īĄ“";--fa--fa:"īĄ“īĄ“"}.fa-border-style-alt{--fa:"īĄ”";--fa--fa:"īĄ”īĄ”"}.fa-border-top{--fa:"īĄ•";--fa--fa:"īĄ•īĄ•"}.fa-border-top-left{--fa:"īĄ“";--fa--fa:"īĄ“īĄ“"}.fa-bore-hole{--fa:"";--fa--fa:""}.fa-bottle-baby{--fa:"î™ŗ";--fa--fa:"î™ŗî™ŗ"}.fa-bottle-droplet{--fa:"";--fa--fa:""}.fa-bottle-water{--fa:"";--fa--fa:""}.fa-bow-arrow{--fa:"īšš";--fa--fa:"īššīšš"}.fa-bowl-chopsticks{--fa:"";--fa--fa:""}.fa-bowl-chopsticks-noodles{--fa:"î‹Ē";--fa--fa:"î‹Ēî‹Ē"}.fa-bowl-food{--fa:"";--fa--fa:""}.fa-bowl-hot{--fa:"ī Ŗ";--fa--fa:"ī Ŗī Ŗ"}.fa-bowl-rice{--fa:"î‹Ģ";--fa--fa:"î‹Ģî‹Ģ"}.fa-bowl-salad{--fa:"ī ž";--fa--fa:"ī žī ž"}.fa-bowl-scoop{--fa:"";--fa--fa:""}.fa-bowl-scoops{--fa:"";--fa--fa:""}.fa-bowl-shaved-ice{--fa:"";--fa--fa:""}.fa-bowl-soft-serve{--fa:"î‘Ģ";--fa--fa:"î‘Ģî‘Ģ"}.fa-bowl-spoon{--fa:"";--fa--fa:""}.fa-bowling-ball{--fa:"īļ";--fa--fa:"īļīļ"}.fa-bowling-ball-pin{--fa:"";--fa--fa:""}.fa-bowling-pins{--fa:"";--fa--fa:""}.fa-box{--fa:"ī‘Ļ";--fa--fa:"ī‘Ļī‘Ļ"}.fa-box-alt{--fa:"ī’š";--fa--fa:"ī’šī’š"}.fa-box-archive{--fa:"";--fa--fa:""}.fa-box-ballot{--fa:"īœĩ";--fa--fa:"īœĩīœĩ"}.fa-box-check{--fa:"ī‘§";--fa--fa:"ī‘§ī‘§"}.fa-box-circle-check{--fa:"";--fa--fa:""}.fa-box-dollar{--fa:"ī’ ";--fa--fa:"ī’ ī’ "}.fa-box-fragile{--fa:"ī’›";--fa--fa:""}.fa-box-full{--fa:"ī’œ";--fa--fa:"ī’œī’œ"}.fa-box-heart{--fa:"ī’";--fa--fa:"ī’ī’"}.fa-box-open{--fa:"ī’ž";--fa--fa:"ī’žī’ž"}.fa-box-open-full{--fa:"ī’œ";--fa--fa:"ī’œī’œ"}.fa-box-taped{--fa:"ī’š";--fa--fa:"ī’šī’š"}.fa-box-tissue{--fa:"";--fa--fa:""}.fa-box-up{--fa:"ī’Ÿ";--fa--fa:"ī’Ÿī’Ÿ"}.fa-box-usd{--fa:"ī’ ";--fa--fa:"ī’ ī’ "}.fa-boxes,.fa-boxes-alt{--fa:"";--fa--fa:""}.fa-boxes-packing{--fa:"";--fa--fa:""}.fa-boxes-stacked{--fa:"";--fa--fa:""}.fa-boxing-glove{--fa:"";--fa--fa:""}.fa-bracket{--fa:"[";--fa--fa:"[["}.fa-bracket-curly,.fa-bracket-curly-left{--fa:"{";--fa--fa:"{{"}.fa-bracket-curly-right{--fa:"}";--fa--fa:"}}"}.fa-bracket-left{--fa:"[";--fa--fa:"[["}.fa-bracket-round{--fa:"(";--fa--fa:"(("}.fa-bracket-round-right{--fa:")";--fa--fa:"))"}.fa-bracket-square{--fa:"[";--fa--fa:"[["}.fa-bracket-square-right{--fa:"]";--fa--fa:"]]"}.fa-brackets{--fa:"īŸŠ";--fa--fa:"īŸŠīŸŠ"}.fa-brackets-curly{--fa:"īŸĒ";--fa--fa:"īŸĒīŸĒ"}.fa-brackets-round{--fa:"";--fa--fa:""}.fa-brackets-square{--fa:"īŸŠ";--fa--fa:"īŸŠīŸŠ"}.fa-braille{--fa:"īŠĄ";--fa--fa:"īŠĄīŠĄ"}.fa-brain{--fa:"ī—œ";--fa--fa:"ī—œī—œ"}.fa-brain-arrow-curved-right{--fa:"";--fa--fa:""}.fa-brain-circuit{--fa:"";--fa--fa:""}.fa-brake-warning{--fa:"";--fa--fa:""}.fa-brazilian-real-sign{--fa:"î‘Ŧ";--fa--fa:"î‘Ŧî‘Ŧ"}.fa-bread-loaf{--fa:"īŸĢ";--fa--fa:"īŸĢīŸĢ"}.fa-bread-slice{--fa:"īŸŦ";--fa--fa:"īŸŦīŸŦ"}.fa-bread-slice-butter{--fa:"";--fa--fa:""}.fa-bridge{--fa:"";--fa--fa:""}.fa-bridge-circle-check{--fa:"";--fa--fa:""}.fa-bridge-circle-exclamation{--fa:"";--fa--fa:""}.fa-bridge-circle-xmark{--fa:"";--fa--fa:""}.fa-bridge-lock{--fa:"";--fa--fa:""}.fa-bridge-suspension{--fa:"";--fa--fa:""}.fa-bridge-water{--fa:"";--fa--fa:""}.fa-briefcase{--fa:"ī‚ą";--fa--fa:"ī‚ąī‚ą"}.fa-briefcase-arrow-right{--fa:"";--fa--fa:""}.fa-briefcase-blank{--fa:"";--fa--fa:""}.fa-briefcase-clock{--fa:"ī™Š";--fa--fa:"ī™Šī™Š"}.fa-briefcase-medical{--fa:"ī‘Š";--fa--fa:"ī‘Šī‘Š"}.fa-brightness{--fa:"";--fa--fa:""}.fa-brightness-low{--fa:"";--fa--fa:""}.fa-bring-forward{--fa:"īĄ–";--fa--fa:"īĄ–īĄ–"}.fa-bring-front{--fa:"īĄ—";--fa--fa:"īĄ—īĄ—"}.fa-broadcast-tower{--fa:"ī”™";--fa--fa:""}.fa-broccoli{--fa:"îĸ";--fa--fa:"îĸîĸ"}.fa-broom{--fa:"ī”š";--fa--fa:"ī”šī”š"}.fa-broom-ball{--fa:"ī‘˜";--fa--fa:"ī‘˜ī‘˜"}.fa-broom-wide{--fa:"";--fa--fa:""}.fa-browser{--fa:"īž";--fa--fa:"īžīž"}.fa-browsers{--fa:"";--fa--fa:""}.fa-brush{--fa:"ī•";--fa--fa:"ī•ī•"}.fa-bucket{--fa:"";--fa--fa:""}.fa-bug{--fa:"ī†ˆ";--fa--fa:"ī†ˆī†ˆ"}.fa-bug-slash{--fa:"";--fa--fa:""}.fa-bugs{--fa:"";--fa--fa:""}.fa-building{--fa:"";--fa--fa:""}.fa-building-circle-arrow-right{--fa:"";--fa--fa:""}.fa-building-circle-check{--fa:"";--fa--fa:""}.fa-building-circle-exclamation{--fa:"";--fa--fa:""}.fa-building-circle-xmark{--fa:"";--fa--fa:""}.fa-building-columns{--fa:"ī†œ";--fa--fa:"ī†œī†œ"}.fa-building-flag{--fa:"";--fa--fa:""}.fa-building-lock{--fa:"";--fa--fa:""}.fa-building-magnifying-glass{--fa:"";--fa--fa:""}.fa-building-memo{--fa:"";--fa--fa:""}.fa-building-ngo{--fa:"";--fa--fa:""}.fa-building-shield{--fa:"";--fa--fa:""}.fa-building-un{--fa:"";--fa--fa:""}.fa-building-user{--fa:"";--fa--fa:""}.fa-building-wheat{--fa:"";--fa--fa:""}.fa-buildings{--fa:"";--fa--fa:""}.fa-bulldozer{--fa:"";--fa--fa:""}.fa-bullhorn{--fa:"ī‚Ą";--fa--fa:"ī‚Ąī‚Ą"}.fa-bullseye{--fa:"ī…€";--fa--fa:""}.fa-bullseye-arrow{--fa:"ī™ˆ";--fa--fa:"ī™ˆī™ˆ"}.fa-bullseye-pointer{--fa:"";--fa--fa:""}.fa-buoy{--fa:"î–ĩ";--fa--fa:"î–ĩî–ĩ"}.fa-buoy-mooring{--fa:"î–ļ";--fa--fa:"î–ļî–ļ"}.fa-burger{--fa:"ī …";--fa--fa:"ī …ī …"}.fa-burger-cheese{--fa:"īŸą";--fa--fa:"īŸąīŸą"}.fa-burger-fries{--fa:"";--fa--fa:""}.fa-burger-glass{--fa:"";--fa--fa:""}.fa-burger-lettuce{--fa:"îŖ";--fa--fa:"îŖîŖ"}.fa-burger-soda{--fa:"īĄ˜";--fa--fa:"īĄ˜īĄ˜"}.fa-burn{--fa:"ī‘Ē";--fa--fa:"ī‘Ēī‘Ē"}.fa-burrito{--fa:"";--fa--fa:""}.fa-burst{--fa:"";--fa--fa:""}.fa-bus{--fa:"īˆ‡";--fa--fa:"īˆ‡īˆ‡"}.fa-bus-alt{--fa:"ī•ž";--fa--fa:"ī•žī•ž"}.fa-bus-school{--fa:"ī—";--fa--fa:"ī—ī—"}.fa-bus-simple{--fa:"ī•ž";--fa--fa:"ī•žī•ž"}.fa-business-front{--fa:"";--fa--fa:""}.fa-business-time{--fa:"ī™Š";--fa--fa:"ī™Šī™Š"}.fa-butter{--fa:"";--fa--fa:""}.fa-c{--fa:"C";--fa--fa:"CC"}.fa-cab{--fa:"ī†ē";--fa--fa:"ī†ēī†ē"}.fa-cabin{--fa:"";--fa--fa:""}.fa-cabinet-filing{--fa:"";--fa--fa:""}.fa-cable-car{--fa:"";--fa--fa:""}.fa-cactus{--fa:"īĸ§";--fa--fa:"īĸ§īĸ§"}.fa-caduceus{--fa:"";--fa--fa:""}.fa-cake,.fa-cake-candles{--fa:"ī‡Ŋ";--fa--fa:"ī‡Ŋī‡Ŋ"}.fa-cake-slice{--fa:"îĨ";--fa--fa:"îĨîĨ"}.fa-calculator{--fa:"ī‡Ŧ";--fa--fa:"ī‡Ŧī‡Ŧ"}.fa-calculator-alt,.fa-calculator-simple{--fa:"ī™Œ";--fa--fa:"ī™Œī™Œ"}.fa-calendar{--fa:"";--fa--fa:""}.fa-calendar-alt{--fa:"";--fa--fa:""}.fa-calendar-arrow-down{--fa:"";--fa--fa:""}.fa-calendar-arrow-up{--fa:"";--fa--fa:""}.fa-calendar-check{--fa:"";--fa--fa:""}.fa-calendar-circle{--fa:"";--fa--fa:""}.fa-calendar-circle-exclamation{--fa:"";--fa--fa:""}.fa-calendar-circle-minus{--fa:"";--fa--fa:""}.fa-calendar-circle-plus{--fa:"";--fa--fa:""}.fa-calendar-circle-user{--fa:"";--fa--fa:""}.fa-calendar-clock{--fa:"";--fa--fa:""}.fa-calendar-day{--fa:"īžƒ";--fa--fa:"īžƒīžƒ"}.fa-calendar-days{--fa:"";--fa--fa:""}.fa-calendar-download{--fa:"";--fa--fa:""}.fa-calendar-edit{--fa:"";--fa--fa:""}.fa-calendar-exclamation{--fa:"";--fa--fa:""}.fa-calendar-heart{--fa:"";--fa--fa:""}.fa-calendar-image{--fa:"";--fa--fa:""}.fa-calendar-lines{--fa:"";--fa--fa:""}.fa-calendar-lines-pen{--fa:"";--fa--fa:""}.fa-calendar-minus{--fa:"";--fa--fa:""}.fa-calendar-note{--fa:"";--fa--fa:""}.fa-calendar-pen{--fa:"";--fa--fa:""}.fa-calendar-plus{--fa:"ī‰ą";--fa--fa:"ī‰ąī‰ą"}.fa-calendar-range{--fa:"";--fa--fa:""}.fa-calendar-star{--fa:"īœļ";--fa--fa:"īœļīœļ"}.fa-calendar-time{--fa:"";--fa--fa:""}.fa-calendar-times{--fa:"";--fa--fa:""}.fa-calendar-upload{--fa:"";--fa--fa:""}.fa-calendar-users{--fa:"î—ĸ";--fa--fa:"î—ĸî—ĸ"}.fa-calendar-week{--fa:"īž„";--fa--fa:"īž„īž„"}.fa-calendar-xmark{--fa:"";--fa--fa:""}.fa-calendars{--fa:"";--fa--fa:""}.fa-camcorder{--fa:"īĸ¨";--fa--fa:"īĸ¨īĸ¨"}.fa-camera,.fa-camera-alt{--fa:"";--fa--fa:""}.fa-camera-cctv{--fa:"īĸŦ";--fa--fa:"īĸŦīĸŦ"}.fa-camera-circle{--fa:"";--fa--fa:""}.fa-camera-home{--fa:"īŖž";--fa--fa:"īŖžīŖž"}.fa-camera-movie{--fa:"īĸŠ";--fa--fa:"īĸŠīĸŠ"}.fa-camera-polaroid{--fa:"īĸĒ";--fa--fa:"īĸĒīĸĒ"}.fa-camera-retro{--fa:"ī‚ƒ";--fa--fa:"ī‚ƒī‚ƒ"}.fa-camera-rotate{--fa:"";--fa--fa:""}.fa-camera-security{--fa:"īŖž";--fa--fa:"īŖžīŖž"}.fa-camera-slash{--fa:"";--fa--fa:""}.fa-camera-viewfinder{--fa:"";--fa--fa:""}.fa-camera-web{--fa:"ī ˛";--fa--fa:""}.fa-camera-web-slash{--fa:"ī ŗ";--fa--fa:"ī ŗī ŗ"}.fa-campfire{--fa:"īšē";--fa--fa:"īšēīšē"}.fa-campground{--fa:"īšģ";--fa--fa:"īšģīšģ"}.fa-can-food{--fa:"îĻ";--fa--fa:"îĻîĻ"}.fa-cancel{--fa:"īž";--fa--fa:"īžīž"}.fa-candle-holder{--fa:"īšŧ";--fa--fa:"īšŧīšŧ"}.fa-candy{--fa:"";--fa--fa:""}.fa-candy-bar{--fa:"";--fa--fa:""}.fa-candy-cane{--fa:"īž†";--fa--fa:"īž†īž†"}.fa-candy-corn{--fa:"īšŊ";--fa--fa:"īšŊīšŊ"}.fa-cannabis{--fa:"ī•Ÿ";--fa--fa:"ī•Ÿī•Ÿ"}.fa-cannon{--fa:"";--fa--fa:""}.fa-capsules{--fa:"ī‘Ģ";--fa--fa:"ī‘Ģī‘Ģ"}.fa-car{--fa:"ī†š";--fa--fa:"ī†šī†š"}.fa-car-alt{--fa:"ī—ž";--fa--fa:"ī—žī—ž"}.fa-car-battery{--fa:"ī—Ÿ";--fa--fa:"ī—Ÿī—Ÿ"}.fa-car-bolt{--fa:"";--fa--fa:""}.fa-car-building{--fa:"īĄ™";--fa--fa:"īĄ™īĄ™"}.fa-car-bump{--fa:"ī— ";--fa--fa:"ī— ī— "}.fa-car-burst{--fa:"ī—Ą";--fa--fa:"ī—Ąī—Ą"}.fa-car-bus{--fa:"īĄš";--fa--fa:"īĄšīĄš"}.fa-car-circle-bolt{--fa:"";--fa--fa:""}.fa-car-crash{--fa:"ī—Ą";--fa--fa:"ī—Ąī—Ą"}.fa-car-garage{--fa:"ī—ĸ";--fa--fa:"ī—ĸī—ĸ"}.fa-car-mechanic{--fa:"ī—Ŗ";--fa--fa:"ī—Ŗī—Ŗ"}.fa-car-mirrors{--fa:"";--fa--fa:""}.fa-car-on{--fa:"";--fa--fa:""}.fa-car-people{--fa:"";--fa--fa:""}.fa-car-rear{--fa:"ī—ž";--fa--fa:"ī—žī—ž"}.fa-car-side{--fa:"ī—¤";--fa--fa:""}.fa-car-side-bolt{--fa:"";--fa--fa:""}.fa-car-tilt{--fa:"ī—Ĩ";--fa--fa:"ī—Ĩī—Ĩ"}.fa-car-tunnel{--fa:"";--fa--fa:""}.fa-car-wash{--fa:"ī—Ļ";--fa--fa:"ī—Ļī—Ļ"}.fa-car-wrench{--fa:"ī—Ŗ";--fa--fa:"ī—Ŗī—Ŗ"}.fa-caravan{--fa:"īŖŋ";--fa--fa:"īŖŋīŖŋ"}.fa-caravan-alt,.fa-caravan-simple{--fa:"";--fa--fa:""}.fa-card-club{--fa:"";--fa--fa:""}.fa-card-diamond{--fa:"îĒ";--fa--fa:"îĒîĒ"}.fa-card-heart{--fa:"îĢ";--fa--fa:"îĢîĢ"}.fa-card-spade{--fa:"îŦ";--fa--fa:"îŦîŦ"}.fa-cards{--fa:"";--fa--fa:""}.fa-cards-blank{--fa:"";--fa--fa:""}.fa-caret-circle-down{--fa:"";--fa--fa:""}.fa-caret-circle-left{--fa:"īŒŽ";--fa--fa:"īŒŽīŒŽ"}.fa-caret-circle-right{--fa:"";--fa--fa:""}.fa-caret-circle-up{--fa:"īŒą";--fa--fa:"īŒąīŒą"}.fa-caret-down{--fa:"īƒ—";--fa--fa:"īƒ—īƒ—"}.fa-caret-left{--fa:"īƒ™";--fa--fa:"īƒ™īƒ™"}.fa-caret-right{--fa:"";--fa--fa:""}.fa-caret-square-down{--fa:"";--fa--fa:""}.fa-caret-square-left{--fa:"";--fa--fa:""}.fa-caret-square-right{--fa:"ī…’";--fa--fa:"ī…’ī…’"}.fa-caret-square-up{--fa:"ī…‘";--fa--fa:"ī…‘ī…‘"}.fa-caret-up{--fa:"";--fa--fa:""}.fa-carpool{--fa:"";--fa--fa:""}.fa-carriage-baby{--fa:"īŊ";--fa--fa:"īŊīŊ"}.fa-carrot{--fa:"īž‡";--fa--fa:"īž‡īž‡"}.fa-cars{--fa:"īĄ›";--fa--fa:"īĄ›īĄ›"}.fa-cart-arrow-down{--fa:"";--fa--fa:""}.fa-cart-arrow-up{--fa:"";--fa--fa:""}.fa-cart-circle-arrow-down{--fa:"";--fa--fa:""}.fa-cart-circle-arrow-up{--fa:"";--fa--fa:""}.fa-cart-circle-check{--fa:"";--fa--fa:""}.fa-cart-circle-exclamation{--fa:"";--fa--fa:""}.fa-cart-circle-plus{--fa:"îŗ";--fa--fa:"îŗîŗ"}.fa-cart-circle-xmark{--fa:"";--fa--fa:""}.fa-cart-flatbed{--fa:"ī‘´";--fa--fa:"ī‘´ī‘´"}.fa-cart-flatbed-boxes{--fa:"ī‘ĩ";--fa--fa:"ī‘ĩī‘ĩ"}.fa-cart-flatbed-empty{--fa:"ī‘ļ";--fa--fa:"ī‘ļī‘ļ"}.fa-cart-flatbed-suitcase{--fa:"ī–";--fa--fa:"ī–ī–"}.fa-cart-minus{--fa:"";--fa--fa:""}.fa-cart-plus{--fa:"īˆ—";--fa--fa:"īˆ—īˆ—"}.fa-cart-shopping{--fa:"īē";--fa--fa:"īēīē"}.fa-cart-shopping-fast{--fa:"";--fa--fa:""}.fa-cart-xmark{--fa:"";--fa--fa:""}.fa-cash-register{--fa:"īžˆ";--fa--fa:"īžˆīžˆ"}.fa-cassette-betamax{--fa:"īĸ¤";--fa--fa:"īĸ¤īĸ¤"}.fa-cassette-tape{--fa:"īĸĢ";--fa--fa:"īĸĢīĸĢ"}.fa-cassette-vhs{--fa:"īŖŦ";--fa--fa:"īŖŦīŖŦ"}.fa-castle{--fa:"";--fa--fa:""}.fa-cat{--fa:"īšž";--fa--fa:"īšžīšž"}.fa-cat-space{--fa:"";--fa--fa:""}.fa-cauldron{--fa:"īšŋ";--fa--fa:"īšŋīšŋ"}.fa-cctv{--fa:"īĸŦ";--fa--fa:"īĸŦīĸŦ"}.fa-cedi-sign{--fa:"";--fa--fa:""}.fa-cent-sign{--fa:"îĩ";--fa--fa:"îĩîĩ"}.fa-certificate{--fa:"";--fa--fa:""}.fa-chain{--fa:"";--fa--fa:""}.fa-chain-broken{--fa:"ī„§";--fa--fa:"ī„§ī„§"}.fa-chain-horizontal{--fa:"";--fa--fa:""}.fa-chain-horizontal-slash{--fa:"";--fa--fa:""}.fa-chain-slash{--fa:"ī„§";--fa--fa:"ī„§ī„§"}.fa-chair{--fa:"";--fa--fa:""}.fa-chair-office{--fa:"";--fa--fa:""}.fa-chalkboard{--fa:"ī”›";--fa--fa:""}.fa-chalkboard-teacher,.fa-chalkboard-user{--fa:"ī”œ";--fa--fa:"ī”œī”œ"}.fa-champagne-glass{--fa:"īžž";--fa--fa:"īžžīžž"}.fa-champagne-glasses{--fa:"īžŸ";--fa--fa:"īžŸīžŸ"}.fa-charging-station{--fa:"ī—§";--fa--fa:"ī—§ī—§"}.fa-chart-area{--fa:"ī‡ž";--fa--fa:"ī‡žī‡ž"}.fa-chart-bar{--fa:"ī‚€";--fa--fa:""}.fa-chart-bullet{--fa:"";--fa--fa:""}.fa-chart-candlestick{--fa:"îƒĸ";--fa--fa:"îƒĸîƒĸ"}.fa-chart-column{--fa:"îƒŖ";--fa--fa:"îƒŖîƒŖ"}.fa-chart-diagram{--fa:"";--fa--fa:""}.fa-chart-fft{--fa:"";--fa--fa:""}.fa-chart-gantt{--fa:"";--fa--fa:""}.fa-chart-kanban{--fa:"";--fa--fa:""}.fa-chart-line{--fa:"";--fa--fa:""}.fa-chart-line-down{--fa:"ī™";--fa--fa:"ī™ī™"}.fa-chart-line-up{--fa:"îƒĨ";--fa--fa:"îƒĨîƒĨ"}.fa-chart-line-up-down{--fa:"";--fa--fa:""}.fa-chart-mixed{--fa:"ī™ƒ";--fa--fa:"ī™ƒī™ƒ"}.fa-chart-mixed-up-circle-currency{--fa:"";--fa--fa:""}.fa-chart-mixed-up-circle-dollar{--fa:"";--fa--fa:""}.fa-chart-network{--fa:"īžŠ";--fa--fa:"īžŠīžŠ"}.fa-chart-pie{--fa:"īˆ€";--fa--fa:"īˆ€īˆ€"}.fa-chart-pie-alt,.fa-chart-pie-simple{--fa:"ī™Ž";--fa--fa:"ī™Žī™Ž"}.fa-chart-pie-simple-circle-currency{--fa:"";--fa--fa:""}.fa-chart-pie-simple-circle-dollar{--fa:"";--fa--fa:""}.fa-chart-pyramid{--fa:"îƒĻ";--fa--fa:"îƒĻîƒĻ"}.fa-chart-radar{--fa:"";--fa--fa:""}.fa-chart-scatter{--fa:"īŸŽ";--fa--fa:"īŸŽīŸŽ"}.fa-chart-scatter-3d{--fa:"";--fa--fa:""}.fa-chart-scatter-bubble{--fa:"";--fa--fa:""}.fa-chart-simple{--fa:"î‘ŗ";--fa--fa:"î‘ŗî‘ŗ"}.fa-chart-simple-horizontal{--fa:"";--fa--fa:""}.fa-chart-sine{--fa:"";--fa--fa:""}.fa-chart-tree-map{--fa:"îƒĒ";--fa--fa:"îƒĒîƒĒ"}.fa-chart-user{--fa:"";--fa--fa:""}.fa-chart-waterfall{--fa:"îƒĢ";--fa--fa:"îƒĢîƒĢ"}.fa-check{--fa:"ī€Œ";--fa--fa:"ī€Œī€Œ"}.fa-check-circle{--fa:"";--fa--fa:""}.fa-check-double{--fa:"ī• ";--fa--fa:"ī• ī• "}.fa-check-square{--fa:"ī…Š";--fa--fa:"ī…Šī…Š"}.fa-check-to-slot{--fa:"ī˛";--fa--fa:"ī˛ī˛"}.fa-cheese{--fa:"";--fa--fa:""}.fa-cheese-swiss{--fa:"";--fa--fa:""}.fa-cheeseburger{--fa:"īŸą";--fa--fa:"īŸąīŸą"}.fa-cherries{--fa:"îƒŦ";--fa--fa:"îƒŦîƒŦ"}.fa-chess{--fa:"īš";--fa--fa:"īšīš"}.fa-chess-bishop{--fa:"īē";--fa--fa:"īēīē"}.fa-chess-bishop-alt,.fa-chess-bishop-piece{--fa:"īģ";--fa--fa:"īģīģ"}.fa-chess-board{--fa:"īŧ";--fa--fa:"īŧīŧ"}.fa-chess-clock{--fa:"īŊ";--fa--fa:"īŊīŊ"}.fa-chess-clock-alt,.fa-chess-clock-flip{--fa:"īž";--fa--fa:"īžīž"}.fa-chess-king{--fa:"īŋ";--fa--fa:"īŋīŋ"}.fa-chess-king-alt,.fa-chess-king-piece{--fa:"ī‘€";--fa--fa:""}.fa-chess-knight{--fa:"";--fa--fa:""}.fa-chess-knight-alt,.fa-chess-knight-piece{--fa:"ī‘‚";--fa--fa:"ī‘‚ī‘‚"}.fa-chess-pawn{--fa:"ī‘ƒ";--fa--fa:"ī‘ƒī‘ƒ"}.fa-chess-pawn-alt,.fa-chess-pawn-piece{--fa:"ī‘„";--fa--fa:"ī‘„ī‘„"}.fa-chess-queen{--fa:"ī‘…";--fa--fa:"ī‘…ī‘…"}.fa-chess-queen-alt,.fa-chess-queen-piece{--fa:"";--fa--fa:""}.fa-chess-rook{--fa:"";--fa--fa:""}.fa-chess-rook-alt,.fa-chess-rook-piece{--fa:"ī‘ˆ";--fa--fa:"ī‘ˆī‘ˆ"}.fa-chestnut{--fa:"îļ";--fa--fa:"îļîļ"}.fa-chevron-circle-down{--fa:"ī„ē";--fa--fa:"ī„ēī„ē"}.fa-chevron-circle-left{--fa:"";--fa--fa:""}.fa-chevron-circle-right{--fa:"";--fa--fa:""}.fa-chevron-circle-up{--fa:"ī„š";--fa--fa:"ī„šī„š"}.fa-chevron-double-down{--fa:"īŒĸ";--fa--fa:"īŒĸīŒĸ"}.fa-chevron-double-left{--fa:"";--fa--fa:""}.fa-chevron-double-right{--fa:"";--fa--fa:""}.fa-chevron-double-up{--fa:"īŒĨ";--fa--fa:"īŒĨīŒĨ"}.fa-chevron-down{--fa:"";--fa--fa:""}.fa-chevron-left{--fa:"";--fa--fa:""}.fa-chevron-right{--fa:"";--fa--fa:""}.fa-chevron-square-down{--fa:"īŒŠ";--fa--fa:"īŒŠīŒŠ"}.fa-chevron-square-left{--fa:"īŒĒ";--fa--fa:"īŒĒīŒĒ"}.fa-chevron-square-right{--fa:"īŒĢ";--fa--fa:"īŒĢīŒĢ"}.fa-chevron-square-up{--fa:"īŒŦ";--fa--fa:"īŒŦīŒŦ"}.fa-chevron-up{--fa:"";--fa--fa:""}.fa-chevrons-down{--fa:"īŒĸ";--fa--fa:"īŒĸīŒĸ"}.fa-chevrons-left{--fa:"";--fa--fa:""}.fa-chevrons-right{--fa:"";--fa--fa:""}.fa-chevrons-up{--fa:"īŒĨ";--fa--fa:"īŒĨīŒĨ"}.fa-chf-sign{--fa:"";--fa--fa:""}.fa-child{--fa:"ī†Ž";--fa--fa:"ī†Žī†Ž"}.fa-child-combatant{--fa:"";--fa--fa:""}.fa-child-dress{--fa:"";--fa--fa:""}.fa-child-reaching{--fa:"";--fa--fa:""}.fa-child-rifle{--fa:"";--fa--fa:""}.fa-children{--fa:"";--fa--fa:""}.fa-chimney{--fa:"īž‹";--fa--fa:"īž‹īž‹"}.fa-chocolate-bar{--fa:"";--fa--fa:""}.fa-chopsticks{--fa:"";--fa--fa:""}.fa-church{--fa:"ī”";--fa--fa:"ī”ī”"}.fa-circle{--fa:"ī„‘";--fa--fa:"ī„‘ī„‘"}.fa-circle-0{--fa:"";--fa--fa:""}.fa-circle-1{--fa:"";--fa--fa:""}.fa-circle-2{--fa:"";--fa--fa:""}.fa-circle-3{--fa:"";--fa--fa:""}.fa-circle-4{--fa:"";--fa--fa:""}.fa-circle-5{--fa:"";--fa--fa:""}.fa-circle-6{--fa:"îƒŗ";--fa--fa:"îƒŗîƒŗ"}.fa-circle-7{--fa:"";--fa--fa:""}.fa-circle-8{--fa:"îƒĩ";--fa--fa:"îƒĩîƒĩ"}.fa-circle-9{--fa:"îƒļ";--fa--fa:"îƒļîƒļ"}.fa-circle-a{--fa:"";--fa--fa:""}.fa-circle-ampersand{--fa:"";--fa--fa:""}.fa-circle-arrow-down{--fa:"ī‚Ģ";--fa--fa:"ī‚Ģī‚Ģ"}.fa-circle-arrow-down-left{--fa:"";--fa--fa:""}.fa-circle-arrow-down-right{--fa:"îƒē";--fa--fa:"îƒēîƒē"}.fa-circle-arrow-left{--fa:"";--fa--fa:""}.fa-circle-arrow-right{--fa:"ī‚Š";--fa--fa:"ī‚Šī‚Š"}.fa-circle-arrow-up{--fa:"ī‚Ē";--fa--fa:"ī‚Ēī‚Ē"}.fa-circle-arrow-up-left{--fa:"îƒģ";--fa--fa:"îƒģîƒģ"}.fa-circle-arrow-up-right{--fa:"îƒŧ";--fa--fa:"îƒŧîƒŧ"}.fa-circle-b{--fa:"îƒŊ";--fa--fa:"îƒŊîƒŊ"}.fa-circle-bolt{--fa:"";--fa--fa:""}.fa-circle-book-open{--fa:"îƒŋ";--fa--fa:"îƒŋîƒŋ"}.fa-circle-bookmark{--fa:"";--fa--fa:""}.fa-circle-c{--fa:"";--fa--fa:""}.fa-circle-calendar{--fa:"";--fa--fa:""}.fa-circle-camera{--fa:"";--fa--fa:""}.fa-circle-caret-down{--fa:"";--fa--fa:""}.fa-circle-caret-left{--fa:"īŒŽ";--fa--fa:"īŒŽīŒŽ"}.fa-circle-caret-right{--fa:"";--fa--fa:""}.fa-circle-caret-up{--fa:"īŒą";--fa--fa:"īŒąīŒą"}.fa-circle-check{--fa:"";--fa--fa:""}.fa-circle-chevron-down{--fa:"ī„ē";--fa--fa:"ī„ēī„ē"}.fa-circle-chevron-left{--fa:"";--fa--fa:""}.fa-circle-chevron-right{--fa:"";--fa--fa:""}.fa-circle-chevron-up{--fa:"ī„š";--fa--fa:"ī„šī„š"}.fa-circle-d{--fa:"";--fa--fa:""}.fa-circle-dashed{--fa:"";--fa--fa:""}.fa-circle-divide{--fa:"";--fa--fa:""}.fa-circle-dollar{--fa:"";--fa--fa:""}.fa-circle-dollar-to-slot{--fa:"ī’š";--fa--fa:"ī’šī’š"}.fa-circle-dot{--fa:"";--fa--fa:""}.fa-circle-down{--fa:"ī˜";--fa--fa:"ī˜ī˜"}.fa-circle-down-left{--fa:"";--fa--fa:""}.fa-circle-down-right{--fa:"";--fa--fa:""}.fa-circle-e{--fa:"";--fa--fa:""}.fa-circle-ellipsis{--fa:"";--fa--fa:""}.fa-circle-ellipsis-vertical{--fa:"";--fa--fa:""}.fa-circle-envelope{--fa:"";--fa--fa:""}.fa-circle-euro{--fa:"";--fa--fa:""}.fa-circle-exclamation{--fa:"īĒ";--fa--fa:"īĒīĒ"}.fa-circle-exclamation-check{--fa:"";--fa--fa:""}.fa-circle-f{--fa:"";--fa--fa:""}.fa-circle-g{--fa:"";--fa--fa:""}.fa-circle-gf{--fa:"î™ŋ";--fa--fa:"î™ŋî™ŋ"}.fa-circle-h{--fa:"ī‘ž";--fa--fa:"ī‘žī‘ž"}.fa-circle-half{--fa:"";--fa--fa:""}.fa-circle-half-stroke{--fa:"";--fa--fa:""}.fa-circle-heart{--fa:"";--fa--fa:""}.fa-circle-i{--fa:"";--fa--fa:""}.fa-circle-info{--fa:"";--fa--fa:""}.fa-circle-j{--fa:"";--fa--fa:""}.fa-circle-k{--fa:"";--fa--fa:""}.fa-circle-l{--fa:"";--fa--fa:""}.fa-circle-left{--fa:"ī™";--fa--fa:"ī™ī™"}.fa-circle-location-arrow{--fa:"ī˜‚";--fa--fa:"ī˜‚ī˜‚"}.fa-circle-m{--fa:"";--fa--fa:""}.fa-circle-microphone{--fa:"";--fa--fa:""}.fa-circle-microphone-lines{--fa:"";--fa--fa:""}.fa-circle-minus{--fa:"";--fa--fa:""}.fa-circle-n{--fa:"";--fa--fa:""}.fa-circle-nodes{--fa:"î“ĸ";--fa--fa:"î“ĸî“ĸ"}.fa-circle-notch{--fa:"ī‡Ž";--fa--fa:"ī‡Žī‡Ž"}.fa-circle-o{--fa:"";--fa--fa:""}.fa-circle-p{--fa:"";--fa--fa:""}.fa-circle-parking{--fa:"ī˜•";--fa--fa:"ī˜•ī˜•"}.fa-circle-pause{--fa:"īŠ‹";--fa--fa:"īŠ‹īŠ‹"}.fa-circle-phone{--fa:"";--fa--fa:""}.fa-circle-phone-flip{--fa:"";--fa--fa:""}.fa-circle-phone-hangup{--fa:"";--fa--fa:""}.fa-circle-play{--fa:"ī…„";--fa--fa:"ī…„ī…„"}.fa-circle-plus{--fa:"";--fa--fa:""}.fa-circle-q{--fa:"";--fa--fa:""}.fa-circle-quarter{--fa:"";--fa--fa:""}.fa-circle-quarter-stroke{--fa:"";--fa--fa:""}.fa-circle-quarters{--fa:"";--fa--fa:""}.fa-circle-question{--fa:"";--fa--fa:""}.fa-circle-r{--fa:"";--fa--fa:""}.fa-circle-radiation{--fa:"īžē";--fa--fa:"īžēīžē"}.fa-circle-right{--fa:"īš";--fa--fa:"īšīš"}.fa-circle-s{--fa:"";--fa--fa:""}.fa-circle-small{--fa:"î„ĸ";--fa--fa:"î„ĸî„ĸ"}.fa-circle-sort{--fa:"";--fa--fa:""}.fa-circle-sort-down{--fa:"";--fa--fa:""}.fa-circle-sort-up{--fa:"";--fa--fa:""}.fa-circle-star{--fa:"î„Ŗ";--fa--fa:"î„Ŗî„Ŗ"}.fa-circle-sterling{--fa:"";--fa--fa:""}.fa-circle-stop{--fa:"īŠ";--fa--fa:"īŠīŠ"}.fa-circle-t{--fa:"";--fa--fa:""}.fa-circle-three-quarters{--fa:"î„Ĩ";--fa--fa:"î„Ĩî„Ĩ"}.fa-circle-three-quarters-stroke{--fa:"";--fa--fa:""}.fa-circle-trash{--fa:"î„Ļ";--fa--fa:"î„Ļî„Ļ"}.fa-circle-u{--fa:"";--fa--fa:""}.fa-circle-up{--fa:"ī›";--fa--fa:"ī›ī›"}.fa-circle-up-left{--fa:"";--fa--fa:""}.fa-circle-up-right{--fa:"";--fa--fa:""}.fa-circle-user{--fa:"īŠŊ";--fa--fa:"īŠŊīŠŊ"}.fa-circle-v{--fa:"î„Ē";--fa--fa:"î„Ēî„Ē"}.fa-circle-video{--fa:"î„Ģ";--fa--fa:"î„Ģî„Ģ"}.fa-circle-w{--fa:"î„Ŧ";--fa--fa:"î„Ŧî„Ŧ"}.fa-circle-waveform-lines{--fa:"";--fa--fa:""}.fa-circle-wifi{--fa:"î™Ŋ";--fa--fa:"î™Ŋî™Ŋ"}.fa-circle-wifi-circle-wifi,.fa-circle-wifi-group{--fa:"";--fa--fa:""}.fa-circle-x{--fa:"";--fa--fa:""}.fa-circle-xmark{--fa:"";--fa--fa:""}.fa-circle-y{--fa:"";--fa--fa:""}.fa-circle-yen{--fa:"";--fa--fa:""}.fa-circle-z{--fa:"";--fa--fa:""}.fa-circles-overlap{--fa:"";--fa--fa:""}.fa-circles-overlap-3{--fa:"";--fa--fa:""}.fa-citrus{--fa:"";--fa--fa:""}.fa-citrus-slice{--fa:"î‹ĩ";--fa--fa:"î‹ĩî‹ĩ"}.fa-city{--fa:"ī™";--fa--fa:"ī™ī™"}.fa-clapperboard{--fa:"";--fa--fa:""}.fa-clapperboard-play{--fa:"";--fa--fa:""}.fa-clarinet{--fa:"īĸ­";--fa--fa:"īĸ­īĸ­"}.fa-claw-marks{--fa:"";--fa--fa:""}.fa-clinic-medical{--fa:"";--fa--fa:""}.fa-clipboard{--fa:"";--fa--fa:""}.fa-clipboard-check{--fa:"ī‘Ŧ";--fa--fa:"ī‘Ŧī‘Ŧ"}.fa-clipboard-list{--fa:"ī‘­";--fa--fa:"ī‘­ī‘­"}.fa-clipboard-list-check{--fa:"";--fa--fa:""}.fa-clipboard-medical{--fa:"î„ŗ";--fa--fa:"î„ŗî„ŗ"}.fa-clipboard-prescription{--fa:"ī—¨";--fa--fa:""}.fa-clipboard-question{--fa:"î“Ŗ";--fa--fa:"î“Ŗî“Ŗ"}.fa-clipboard-user{--fa:"";--fa--fa:""}.fa-clock{--fa:"";--fa--fa:""}.fa-clock-desk{--fa:"";--fa--fa:""}.fa-clock-eight{--fa:"";--fa--fa:""}.fa-clock-eight-thirty{--fa:"";--fa--fa:""}.fa-clock-eleven{--fa:"";--fa--fa:""}.fa-clock-eleven-thirty{--fa:"";--fa--fa:""}.fa-clock-five{--fa:"";--fa--fa:""}.fa-clock-five-thirty{--fa:"";--fa--fa:""}.fa-clock-four{--fa:"";--fa--fa:""}.fa-clock-four-thirty{--fa:"";--fa--fa:""}.fa-clock-nine{--fa:"";--fa--fa:""}.fa-clock-nine-thirty{--fa:"";--fa--fa:""}.fa-clock-one{--fa:"";--fa--fa:""}.fa-clock-one-thirty{--fa:"";--fa--fa:""}.fa-clock-rotate-left{--fa:"ī‡š";--fa--fa:"ī‡šī‡š"}.fa-clock-seven{--fa:"";--fa--fa:""}.fa-clock-seven-thirty{--fa:"";--fa--fa:""}.fa-clock-six{--fa:"";--fa--fa:""}.fa-clock-six-thirty{--fa:"";--fa--fa:""}.fa-clock-ten{--fa:"";--fa--fa:""}.fa-clock-ten-thirty{--fa:"";--fa--fa:""}.fa-clock-three{--fa:"";--fa--fa:""}.fa-clock-three-thirty{--fa:"";--fa--fa:""}.fa-clock-twelve{--fa:"";--fa--fa:""}.fa-clock-twelve-thirty{--fa:"";--fa--fa:""}.fa-clock-two{--fa:"";--fa--fa:""}.fa-clock-two-thirty{--fa:"";--fa--fa:""}.fa-clone{--fa:"ī‰";--fa--fa:"ī‰ī‰"}.fa-close{--fa:"ī€";--fa--fa:"ī€ī€"}.fa-closed-captioning{--fa:"";--fa--fa:""}.fa-closed-captioning-slash{--fa:"î„ĩ";--fa--fa:"î„ĩî„ĩ"}.fa-clothes-hanger{--fa:"î„ļ";--fa--fa:"î„ļî„ļ"}.fa-cloud{--fa:"īƒ‚";--fa--fa:"īƒ‚īƒ‚"}.fa-cloud-arrow-down{--fa:"";--fa--fa:""}.fa-cloud-arrow-up{--fa:"īƒŽ";--fa--fa:"īƒŽīƒŽ"}.fa-cloud-binary{--fa:"";--fa--fa:""}.fa-cloud-bolt{--fa:"īŦ";--fa--fa:"īŦīŦ"}.fa-cloud-bolt-moon{--fa:"ī­";--fa--fa:"ī­ī­"}.fa-cloud-bolt-sun{--fa:"īŽ";--fa--fa:"īŽīŽ"}.fa-cloud-check{--fa:"";--fa--fa:""}.fa-cloud-download,.fa-cloud-download-alt{--fa:"";--fa--fa:""}.fa-cloud-drizzle{--fa:"";--fa--fa:""}.fa-cloud-exclamation{--fa:"";--fa--fa:""}.fa-cloud-fog{--fa:"īŽ";--fa--fa:"īŽīŽ"}.fa-cloud-hail{--fa:"īœš";--fa--fa:"īœšīœš"}.fa-cloud-hail-mixed{--fa:"īœē";--fa--fa:"īœēīœē"}.fa-cloud-meatball{--fa:"īœģ";--fa--fa:"īœģīœģ"}.fa-cloud-minus{--fa:"";--fa--fa:""}.fa-cloud-moon{--fa:"ī›ƒ";--fa--fa:"ī›ƒī›ƒ"}.fa-cloud-moon-rain{--fa:"īœŧ";--fa--fa:"īœŧīœŧ"}.fa-cloud-music{--fa:"īĸŽ";--fa--fa:"īĸŽīĸŽ"}.fa-cloud-plus{--fa:"";--fa--fa:""}.fa-cloud-question{--fa:"";--fa--fa:""}.fa-cloud-rain{--fa:"īœŊ";--fa--fa:"īœŊīœŊ"}.fa-cloud-rainbow{--fa:"īœž";--fa--fa:"īœžīœž"}.fa-cloud-showers{--fa:"īœŋ";--fa--fa:"īœŋīœŋ"}.fa-cloud-showers-heavy{--fa:"ī€";--fa--fa:"ī€ī€"}.fa-cloud-showers-water{--fa:"";--fa--fa:""}.fa-cloud-slash{--fa:"";--fa--fa:""}.fa-cloud-sleet{--fa:"ī";--fa--fa:"īī"}.fa-cloud-snow{--fa:"ī‚";--fa--fa:"ī‚ī‚"}.fa-cloud-sun{--fa:"";--fa--fa:""}.fa-cloud-sun-rain{--fa:"īƒ";--fa--fa:"īƒīƒ"}.fa-cloud-upload,.fa-cloud-upload-alt{--fa:"īƒŽ";--fa--fa:"īƒŽīƒŽ"}.fa-cloud-word{--fa:"";--fa--fa:""}.fa-cloud-xmark{--fa:"";--fa--fa:""}.fa-clouds{--fa:"ī„";--fa--fa:"ī„ī„"}.fa-clouds-moon{--fa:"ī…";--fa--fa:"ī…ī…"}.fa-clouds-sun{--fa:"ī†";--fa--fa:"ī†ī†"}.fa-clover{--fa:"";--fa--fa:""}.fa-club{--fa:"";--fa--fa:""}.fa-cny{--fa:"ī…—";--fa--fa:"ī…—ī…—"}.fa-cocktail{--fa:"ī•Ą";--fa--fa:"ī•Ąī•Ą"}.fa-coconut{--fa:"î‹ļ";--fa--fa:"î‹ļî‹ļ"}.fa-code{--fa:"ī„Ą";--fa--fa:"ī„Ąī„Ą"}.fa-code-branch{--fa:"ī„Ļ";--fa--fa:"ī„Ļī„Ļ"}.fa-code-commit{--fa:"īŽ†";--fa--fa:"īŽ†īŽ†"}.fa-code-compare{--fa:"î„ē";--fa--fa:"î„ēî„ē"}.fa-code-fork{--fa:"î„ģ";--fa--fa:"î„ģî„ģ"}.fa-code-merge{--fa:"īŽ‡";--fa--fa:"īŽ‡īŽ‡"}.fa-code-pull-request{--fa:"î„ŧ";--fa--fa:"î„ŧî„ŧ"}.fa-code-pull-request-closed{--fa:"";--fa--fa:""}.fa-code-pull-request-draft{--fa:"îē";--fa--fa:"îēîē"}.fa-code-simple{--fa:"î„Ŋ";--fa--fa:"î„Ŋî„Ŋ"}.fa-coffee{--fa:"";--fa--fa:""}.fa-coffee-bean{--fa:"";--fa--fa:""}.fa-coffee-beans{--fa:"î„ŋ";--fa--fa:"î„ŋî„ŋ"}.fa-coffee-pot{--fa:"";--fa--fa:""}.fa-coffee-togo{--fa:"ī›…";--fa--fa:"ī›…ī›…"}.fa-coffin{--fa:"";--fa--fa:""}.fa-coffin-cross{--fa:"";--fa--fa:""}.fa-cog{--fa:"";--fa--fa:""}.fa-cogs{--fa:"ī‚…";--fa--fa:"ī‚…ī‚…"}.fa-coin{--fa:"īĄœ";--fa--fa:"īĄœīĄœ"}.fa-coin-blank{--fa:"îģ";--fa--fa:"îģîģ"}.fa-coin-front{--fa:"îŧ";--fa--fa:"îŧîŧ"}.fa-coin-vertical{--fa:"îŊ";--fa--fa:"îŊîŊ"}.fa-coins{--fa:"ī”ž";--fa--fa:"ī”žī”ž"}.fa-colon{--fa:":";--fa--fa:"::"}.fa-colon-sign{--fa:"";--fa--fa:""}.fa-columns{--fa:"īƒ›";--fa--fa:"īƒ›īƒ›"}.fa-columns-3{--fa:"";--fa--fa:""}.fa-comet{--fa:"";--fa--fa:""}.fa-comma{--fa:",";--fa--fa:",,"}.fa-command{--fa:"";--fa--fa:""}.fa-comment{--fa:"īĩ";--fa--fa:"īĩīĩ"}.fa-comment-alt{--fa:"ī‰ē";--fa--fa:"ī‰ēī‰ē"}.fa-comment-alt-arrow-down{--fa:"";--fa--fa:""}.fa-comment-alt-arrow-up{--fa:"";--fa--fa:""}.fa-comment-alt-captions{--fa:"";--fa--fa:""}.fa-comment-alt-check{--fa:"ī’ĸ";--fa--fa:"ī’ĸī’ĸ"}.fa-comment-alt-dollar{--fa:"";--fa--fa:""}.fa-comment-alt-dots{--fa:"ī’Ŗ";--fa--fa:"ī’Ŗī’Ŗ"}.fa-comment-alt-edit{--fa:"ī’¤";--fa--fa:""}.fa-comment-alt-exclamation{--fa:"ī’Ĩ";--fa--fa:"ī’Ĩī’Ĩ"}.fa-comment-alt-image{--fa:"";--fa--fa:""}.fa-comment-alt-lines{--fa:"ī’Ļ";--fa--fa:"ī’Ļī’Ļ"}.fa-comment-alt-medical{--fa:"";--fa--fa:""}.fa-comment-alt-minus{--fa:"ī’§";--fa--fa:"ī’§ī’§"}.fa-comment-alt-music{--fa:"īĸ¯";--fa--fa:"īĸ¯īĸ¯"}.fa-comment-alt-plus{--fa:"ī’¨";--fa--fa:""}.fa-comment-alt-quote{--fa:"";--fa--fa:""}.fa-comment-alt-slash{--fa:"ī’Š";--fa--fa:"ī’Šī’Š"}.fa-comment-alt-smile{--fa:"ī’Ē";--fa--fa:"ī’Ēī’Ē"}.fa-comment-alt-text{--fa:"î‡Ļ";--fa--fa:"î‡Ļî‡Ļ"}.fa-comment-alt-times{--fa:"ī’Ģ";--fa--fa:"ī’Ģī’Ģ"}.fa-comment-arrow-down{--fa:"";--fa--fa:""}.fa-comment-arrow-up{--fa:"";--fa--fa:""}.fa-comment-arrow-up-right{--fa:"";--fa--fa:""}.fa-comment-captions{--fa:"";--fa--fa:""}.fa-comment-check{--fa:"ī’Ŧ";--fa--fa:"ī’Ŧī’Ŧ"}.fa-comment-code{--fa:"";--fa--fa:""}.fa-comment-dollar{--fa:"";--fa--fa:""}.fa-comment-dots{--fa:"ī’­";--fa--fa:"ī’­ī’­"}.fa-comment-edit{--fa:"ī’Ž";--fa--fa:"ī’Žī’Ž"}.fa-comment-exclamation{--fa:"ī’¯";--fa--fa:""}.fa-comment-heart{--fa:"";--fa--fa:""}.fa-comment-image{--fa:"";--fa--fa:""}.fa-comment-lines{--fa:"ī’°";--fa--fa:"ī’°ī’°"}.fa-comment-medical{--fa:"īŸĩ";--fa--fa:"īŸĩīŸĩ"}.fa-comment-middle{--fa:"";--fa--fa:""}.fa-comment-middle-alt{--fa:"";--fa--fa:""}.fa-comment-middle-top{--fa:"";--fa--fa:""}.fa-comment-middle-top-alt{--fa:"î‡ĸ";--fa--fa:"î‡ĸî‡ĸ"}.fa-comment-minus{--fa:"ī’ą";--fa--fa:"ī’ąī’ą"}.fa-comment-music{--fa:"īĸ°";--fa--fa:"īĸ°īĸ°"}.fa-comment-nodes{--fa:"";--fa--fa:""}.fa-comment-pen{--fa:"ī’Ž";--fa--fa:"ī’Žī’Ž"}.fa-comment-plus{--fa:"ī’˛";--fa--fa:""}.fa-comment-question{--fa:"";--fa--fa:""}.fa-comment-quote{--fa:"";--fa--fa:""}.fa-comment-slash{--fa:"ī’ŗ";--fa--fa:"ī’ŗī’ŗ"}.fa-comment-smile{--fa:"ī’´";--fa--fa:"ī’´ī’´"}.fa-comment-sms{--fa:"īŸ";--fa--fa:"īŸīŸ"}.fa-comment-text{--fa:"";--fa--fa:""}.fa-comment-times,.fa-comment-xmark{--fa:"ī’ĩ";--fa--fa:"ī’ĩī’ĩ"}.fa-commenting{--fa:"ī’­";--fa--fa:"ī’­ī’­"}.fa-comments{--fa:"";--fa--fa:""}.fa-comments-alt{--fa:"ī’ļ";--fa--fa:"ī’ļī’ļ"}.fa-comments-alt-dollar{--fa:"ī™’";--fa--fa:"ī™’ī™’"}.fa-comments-dollar{--fa:"";--fa--fa:""}.fa-comments-question{--fa:"";--fa--fa:""}.fa-comments-question-check{--fa:"";--fa--fa:""}.fa-compact-disc{--fa:"ī”Ÿ";--fa--fa:"ī”Ÿī”Ÿ"}.fa-compass{--fa:"ī…Ž";--fa--fa:"ī…Žī…Ž"}.fa-compass-drafting{--fa:"";--fa--fa:""}.fa-compass-slash{--fa:"ī—Š";--fa--fa:"ī—Šī—Š"}.fa-compress{--fa:"īĻ";--fa--fa:"īĻīĻ"}.fa-compress-alt{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-compress-arrows{--fa:"î‚Ĩ";--fa--fa:"î‚Ĩî‚Ĩ"}.fa-compress-arrows-alt{--fa:"īžŒ";--fa--fa:"īžŒīžŒ"}.fa-compress-wide{--fa:"īŒĻ";--fa--fa:"īŒĻīŒĻ"}.fa-computer{--fa:"î“Ĩ";--fa--fa:"î“Ĩî“Ĩ"}.fa-computer-classic{--fa:"īĸą";--fa--fa:"īĸąīĸą"}.fa-computer-mouse{--fa:"";--fa--fa:""}.fa-computer-mouse-scrollwheel{--fa:"īŖ";--fa--fa:"īŖīŖ"}.fa-computer-speaker{--fa:"īĸ˛";--fa--fa:"īĸ˛īĸ˛"}.fa-concierge-bell{--fa:"ī•ĸ";--fa--fa:"ī•ĸī•ĸ"}.fa-construction{--fa:"īĄ";--fa--fa:"īĄīĄ"}.fa-contact-book{--fa:"īŠš";--fa--fa:"īŠšīŠš"}.fa-contact-card{--fa:"īŠģ";--fa--fa:"īŠģīŠģ"}.fa-container-storage{--fa:"ī’ˇ";--fa--fa:""}.fa-conveyor-belt{--fa:"ī‘Ž";--fa--fa:"ī‘Žī‘Ž"}.fa-conveyor-belt-alt{--fa:"";--fa--fa:""}.fa-conveyor-belt-arm{--fa:"";--fa--fa:""}.fa-conveyor-belt-boxes{--fa:"";--fa--fa:""}.fa-conveyor-belt-empty{--fa:"";--fa--fa:""}.fa-cookie{--fa:"";--fa--fa:""}.fa-cookie-bite{--fa:"";--fa--fa:""}.fa-copy{--fa:"īƒ…";--fa--fa:"īƒ…īƒ…"}.fa-copyright{--fa:"ī‡š";--fa--fa:"ī‡šī‡š"}.fa-corn{--fa:"";--fa--fa:""}.fa-corner{--fa:"";--fa--fa:""}.fa-couch{--fa:"ī’¸";--fa--fa:""}.fa-couch-small{--fa:"ī“Œ";--fa--fa:"ī“Œī“Œ"}.fa-court-sport{--fa:"";--fa--fa:""}.fa-cow{--fa:"ī›ˆ";--fa--fa:"ī›ˆī›ˆ"}.fa-cowbell{--fa:"īĸŗ";--fa--fa:"īĸŗīĸŗ"}.fa-cowbell-circle-plus,.fa-cowbell-more{--fa:"īĸ´";--fa--fa:"īĸ´īĸ´"}.fa-crab{--fa:"îŋ";--fa--fa:"îŋîŋ"}.fa-crate-apple{--fa:"īšą";--fa--fa:"īšąīšą"}.fa-crate-empty{--fa:"";--fa--fa:""}.fa-credit-card,.fa-credit-card-alt{--fa:"ī‚";--fa--fa:"ī‚ī‚"}.fa-credit-card-blank{--fa:"īŽ‰";--fa--fa:"īŽ‰īŽ‰"}.fa-credit-card-front{--fa:"īŽŠ";--fa--fa:"īŽŠīŽŠ"}.fa-creemee{--fa:"";--fa--fa:""}.fa-cricket,.fa-cricket-bat-ball{--fa:"";--fa--fa:""}.fa-croissant{--fa:"īŸļ";--fa--fa:"īŸļīŸļ"}.fa-crop{--fa:"ī„Ĩ";--fa--fa:"ī„Ĩī„Ĩ"}.fa-crop-alt,.fa-crop-simple{--fa:"ī•Ĩ";--fa--fa:"ī•Ĩī•Ĩ"}.fa-cross{--fa:"ī™”";--fa--fa:""}.fa-crosshairs{--fa:"";--fa--fa:""}.fa-crosshairs-simple{--fa:"";--fa--fa:""}.fa-crow{--fa:"ī” ";--fa--fa:"ī” ī” "}.fa-crown{--fa:"ī”Ą";--fa--fa:"ī”Ąī”Ą"}.fa-crutch{--fa:"";--fa--fa:""}.fa-crutches{--fa:"";--fa--fa:""}.fa-cruzeiro-sign{--fa:"";--fa--fa:""}.fa-crystal-ball{--fa:"îĸ";--fa--fa:"îĸîĸ"}.fa-cube{--fa:"";--fa--fa:""}.fa-cubes{--fa:"";--fa--fa:""}.fa-cubes-stacked{--fa:"î“Ļ";--fa--fa:"î“Ļî“Ļ"}.fa-cucumber{--fa:"";--fa--fa:""}.fa-cup-straw{--fa:"îŖ";--fa--fa:"îŖîŖ"}.fa-cup-straw-swoosh{--fa:"";--fa--fa:""}.fa-cup-togo{--fa:"ī›…";--fa--fa:"ī›…ī›…"}.fa-cupcake{--fa:"";--fa--fa:""}.fa-curling,.fa-curling-stone{--fa:"ī‘Š";--fa--fa:"ī‘Šī‘Š"}.fa-custard{--fa:"";--fa--fa:""}.fa-cut{--fa:"īƒ„";--fa--fa:"īƒ„īƒ„"}.fa-cutlery{--fa:"ī‹§";--fa--fa:"ī‹§ī‹§"}.fa-d{--fa:"D";--fa--fa:"DD"}.fa-dagger{--fa:"";--fa--fa:""}.fa-dash{--fa:"";--fa--fa:""}.fa-dashboard{--fa:"";--fa--fa:""}.fa-database{--fa:"";--fa--fa:""}.fa-deaf,.fa-deafness{--fa:"";--fa--fa:""}.fa-debug{--fa:"īŸš";--fa--fa:"īŸšīŸš"}.fa-dedent{--fa:"ī€ģ";--fa--fa:"ī€ģī€ģ"}.fa-deer{--fa:"īžŽ";--fa--fa:"īžŽīžŽ"}.fa-deer-rudolph{--fa:"īž";--fa--fa:"īžīž"}.fa-delete-left{--fa:"ī•š";--fa--fa:"ī•šī•š"}.fa-delete-right{--fa:"";--fa--fa:""}.fa-democrat{--fa:"ī‡";--fa--fa:"ī‡ī‡"}.fa-desktop,.fa-desktop-alt{--fa:"īŽ";--fa--fa:"īŽīŽ"}.fa-desktop-arrow-down{--fa:"";--fa--fa:""}.fa-desktop-code{--fa:"î…Ĩ";--fa--fa:"î…Ĩî…Ĩ"}.fa-desktop-medical{--fa:"î…Ļ";--fa--fa:"î…Ļî…Ļ"}.fa-desktop-slash{--fa:"î‹ē";--fa--fa:"î‹ēî‹ē"}.fa-dewpoint{--fa:"īˆ";--fa--fa:"īˆīˆ"}.fa-dharmachakra{--fa:"";--fa--fa:""}.fa-diagnoses{--fa:"ī‘°";--fa--fa:"ī‘°ī‘°"}.fa-diagram-cells{--fa:"î‘ĩ";--fa--fa:"î‘ĩî‘ĩ"}.fa-diagram-lean-canvas{--fa:"";--fa--fa:""}.fa-diagram-nested{--fa:"";--fa--fa:""}.fa-diagram-next{--fa:"î‘ļ";--fa--fa:"î‘ļî‘ļ"}.fa-diagram-predecessor{--fa:"";--fa--fa:""}.fa-diagram-previous{--fa:"";--fa--fa:""}.fa-diagram-project{--fa:"ī•‚";--fa--fa:"ī•‚ī•‚"}.fa-diagram-sankey{--fa:"";--fa--fa:""}.fa-diagram-subtask{--fa:"";--fa--fa:""}.fa-diagram-successor{--fa:"î‘ē";--fa--fa:"î‘ēî‘ē"}.fa-diagram-venn{--fa:"";--fa--fa:""}.fa-dial{--fa:"";--fa--fa:""}.fa-dial-high{--fa:"";--fa--fa:""}.fa-dial-low{--fa:"";--fa--fa:""}.fa-dial-max{--fa:"";--fa--fa:""}.fa-dial-med{--fa:"";--fa--fa:""}.fa-dial-med-high{--fa:"";--fa--fa:""}.fa-dial-med-low{--fa:"";--fa--fa:""}.fa-dial-min{--fa:"";--fa--fa:""}.fa-dial-off{--fa:"î…ĸ";--fa--fa:"î…ĸî…ĸ"}.fa-diamond{--fa:"īˆ™";--fa--fa:"īˆ™īˆ™"}.fa-diamond-exclamation{--fa:"";--fa--fa:""}.fa-diamond-half{--fa:"";--fa--fa:""}.fa-diamond-half-stroke{--fa:"";--fa--fa:""}.fa-diamond-turn-right{--fa:"ī—Ģ";--fa--fa:"ī—Ģī—Ģ"}.fa-diamonds-4{--fa:"";--fa--fa:""}.fa-dice{--fa:"ī”ĸ";--fa--fa:"ī”ĸī”ĸ"}.fa-dice-d10{--fa:"ī›";--fa--fa:"ī›ī›"}.fa-dice-d12{--fa:"ī›Ž";--fa--fa:"ī›Žī›Ž"}.fa-dice-d20{--fa:"ī›";--fa--fa:"ī›ī›"}.fa-dice-d4{--fa:"";--fa--fa:""}.fa-dice-d6{--fa:"";--fa--fa:""}.fa-dice-d8{--fa:"ī›’";--fa--fa:"ī›’ī›’"}.fa-dice-five{--fa:"";--fa--fa:""}.fa-dice-four{--fa:"";--fa--fa:""}.fa-dice-one{--fa:"ī”Ĩ";--fa--fa:"ī”Ĩī”Ĩ"}.fa-dice-six{--fa:"ī”Ļ";--fa--fa:"ī”Ļī”Ļ"}.fa-dice-three{--fa:"ī”§";--fa--fa:"ī”§ī”§"}.fa-dice-two{--fa:"";--fa--fa:""}.fa-digging{--fa:"īĄž";--fa--fa:"īĄžīĄž"}.fa-digital-tachograph{--fa:"ī•Ļ";--fa--fa:"ī•Ļī•Ļ"}.fa-dinosaur{--fa:"";--fa--fa:""}.fa-diploma{--fa:"ī—Ē";--fa--fa:"ī—Ēī—Ē"}.fa-directions{--fa:"ī—Ģ";--fa--fa:"ī—Ģī—Ģ"}.fa-disc-drive{--fa:"īĸĩ";--fa--fa:"īĸĩīĸĩ"}.fa-disease{--fa:"īŸē";--fa--fa:"īŸēīŸē"}.fa-display{--fa:"î…Ŗ";--fa--fa:"î…Ŗî…Ŗ"}.fa-display-arrow-down{--fa:"";--fa--fa:""}.fa-display-chart-up{--fa:"î—Ŗ";--fa--fa:"î—Ŗî—Ŗ"}.fa-display-chart-up-circle-currency{--fa:"î—Ĩ";--fa--fa:"î—Ĩî—Ĩ"}.fa-display-chart-up-circle-dollar{--fa:"î—Ļ";--fa--fa:"î—Ļî—Ļ"}.fa-display-code{--fa:"î…Ĩ";--fa--fa:"î…Ĩî…Ĩ"}.fa-display-medical{--fa:"î…Ļ";--fa--fa:"î…Ļî…Ļ"}.fa-display-slash{--fa:"î‹ē";--fa--fa:"î‹ēî‹ē"}.fa-distribute-spacing-horizontal{--fa:"îĨ";--fa--fa:"îĨîĨ"}.fa-distribute-spacing-vertical{--fa:"îĻ";--fa--fa:"îĻîĻ"}.fa-ditto{--fa:"\"";--fa--fa:"\"\""}.fa-divide{--fa:"ī”Š";--fa--fa:"ī”Šī”Š"}.fa-dizzy{--fa:"ī•§";--fa--fa:"ī•§ī•§"}.fa-dna{--fa:"ī‘ą";--fa--fa:"ī‘ąī‘ą"}.fa-do-not-enter{--fa:"ī—Ŧ";--fa--fa:"ī—Ŧī—Ŧ"}.fa-dog{--fa:"";--fa--fa:""}.fa-dog-leashed{--fa:"ī›”";--fa--fa:""}.fa-dollar{--fa:"$";--fa--fa:"$$"}.fa-dollar-circle{--fa:"";--fa--fa:""}.fa-dollar-sign{--fa:"$";--fa--fa:"$$"}.fa-dollar-square{--fa:"ī‹Š";--fa--fa:"ī‹Šī‹Š"}.fa-dolly,.fa-dolly-box{--fa:"";--fa--fa:""}.fa-dolly-empty{--fa:"";--fa--fa:""}.fa-dolly-flatbed{--fa:"ī‘´";--fa--fa:"ī‘´ī‘´"}.fa-dolly-flatbed-alt{--fa:"ī‘ĩ";--fa--fa:"ī‘ĩī‘ĩ"}.fa-dolly-flatbed-empty{--fa:"ī‘ļ";--fa--fa:"ī‘ļī‘ļ"}.fa-dolphin{--fa:"";--fa--fa:""}.fa-donate{--fa:"ī’š";--fa--fa:"ī’šī’š"}.fa-dong-sign{--fa:"";--fa--fa:""}.fa-donut{--fa:"";--fa--fa:""}.fa-door-closed{--fa:"ī”Ē";--fa--fa:"ī”Ēī”Ē"}.fa-door-open{--fa:"ī”Ģ";--fa--fa:"ī”Ģī”Ģ"}.fa-dot-circle{--fa:"";--fa--fa:""}.fa-doughnut{--fa:"";--fa--fa:""}.fa-dove{--fa:"ī’ē";--fa--fa:"ī’ēī’ē"}.fa-down{--fa:"ī”";--fa--fa:"ī”ī”"}.fa-down-from-bracket{--fa:"î™Ģ";--fa--fa:"î™Ģî™Ģ"}.fa-down-from-dotted-line{--fa:"";--fa--fa:""}.fa-down-from-line{--fa:"ī‰";--fa--fa:"ī‰ī‰"}.fa-down-left{--fa:"î…Ē";--fa--fa:"î…Ēî…Ē"}.fa-down-left-and-up-right-to-center{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-down-long{--fa:"īŒ‰";--fa--fa:"īŒ‰īŒ‰"}.fa-down-right{--fa:"î…Ģ";--fa--fa:"î…Ģî…Ģ"}.fa-down-to-bracket{--fa:"";--fa--fa:""}.fa-down-to-dotted-line{--fa:"";--fa--fa:""}.fa-down-to-line{--fa:"īŠ";--fa--fa:"īŠīŠ"}.fa-download{--fa:"";--fa--fa:""}.fa-drafting-compass{--fa:"";--fa--fa:""}.fa-dragon{--fa:"";--fa--fa:""}.fa-draw-circle{--fa:"ī—­";--fa--fa:"ī—­ī—­"}.fa-draw-polygon{--fa:"ī—Ž";--fa--fa:"ī—Žī—Ž"}.fa-draw-square{--fa:"ī—¯";--fa--fa:""}.fa-dreidel{--fa:"īž’";--fa--fa:"īž’īž’"}.fa-drivers-license{--fa:"ī‹‚";--fa--fa:"ī‹‚ī‹‚"}.fa-drone{--fa:"īĄŸ";--fa--fa:"īĄŸīĄŸ"}.fa-drone-alt,.fa-drone-front{--fa:"īĄ ";--fa--fa:"īĄ īĄ "}.fa-droplet{--fa:"";--fa--fa:""}.fa-droplet-degree{--fa:"īˆ";--fa--fa:"īˆīˆ"}.fa-droplet-percent{--fa:"ī";--fa--fa:"īī"}.fa-droplet-slash{--fa:"ī—‡";--fa--fa:""}.fa-drum{--fa:"ī•Š";--fa--fa:"ī•Šī•Š"}.fa-drum-steelpan{--fa:"ī•Ē";--fa--fa:"ī•Ēī•Ē"}.fa-drumstick{--fa:"ī›–";--fa--fa:"ī›–ī›–"}.fa-drumstick-bite{--fa:"ī›—";--fa--fa:"ī›—ī›—"}.fa-dryer{--fa:"īĄĄ";--fa--fa:"īĄĄīĄĄ"}.fa-dryer-alt,.fa-dryer-heat{--fa:"īĄĸ";--fa--fa:"īĄĸīĄĸ"}.fa-duck{--fa:"ī›˜";--fa--fa:"ī›˜ī›˜"}.fa-dumbbell{--fa:"ī‘‹";--fa--fa:"ī‘‹ī‘‹"}.fa-dumpster{--fa:"īž“";--fa--fa:"īž“īž“"}.fa-dumpster-fire{--fa:"īž”";--fa--fa:"īž”īž”"}.fa-dungeon{--fa:"ī›™";--fa--fa:""}.fa-e{--fa:"E";--fa--fa:"EE"}.fa-ear{--fa:"ī—°";--fa--fa:"ī—°ī—°"}.fa-ear-deaf{--fa:"";--fa--fa:""}.fa-ear-listen{--fa:"īŠĸ";--fa--fa:"īŠĸīŠĸ"}.fa-ear-muffs{--fa:"īž•";--fa--fa:"īž•īž•"}.fa-earth{--fa:"ī•Ŋ";--fa--fa:"ī•Ŋī•Ŋ"}.fa-earth-africa{--fa:"ī•ŧ";--fa--fa:"ī•ŧī•ŧ"}.fa-earth-america,.fa-earth-americas{--fa:"ī•Ŋ";--fa--fa:"ī•Ŋī•Ŋ"}.fa-earth-asia{--fa:"ī•ž";--fa--fa:"ī•žī•ž"}.fa-earth-europe{--fa:"īžĸ";--fa--fa:"īžĸīžĸ"}.fa-earth-oceania{--fa:"î‘ģ";--fa--fa:"î‘ģî‘ģ"}.fa-eclipse{--fa:"ī‰";--fa--fa:"ī‰ī‰"}.fa-eclipse-alt{--fa:"īŠ";--fa--fa:"īŠīŠ"}.fa-edit{--fa:"";--fa--fa:""}.fa-egg{--fa:"īŸģ";--fa--fa:"īŸģīŸģ"}.fa-egg-fried{--fa:"īŸŧ";--fa--fa:"īŸŧīŸŧ"}.fa-eggplant{--fa:"î…Ŧ";--fa--fa:"î…Ŧî…Ŧ"}.fa-eject{--fa:"";--fa--fa:""}.fa-elephant{--fa:"ī›š";--fa--fa:"ī›šī›š"}.fa-elevator{--fa:"";--fa--fa:""}.fa-ellipsis,.fa-ellipsis-h{--fa:"";--fa--fa:""}.fa-ellipsis-h-alt,.fa-ellipsis-stroke{--fa:"īŽ›";--fa--fa:"īŽ›īŽ›"}.fa-ellipsis-stroke-vertical{--fa:"īŽœ";--fa--fa:"īŽœīŽœ"}.fa-ellipsis-v{--fa:"ī…‚";--fa--fa:"ī…‚ī…‚"}.fa-ellipsis-v-alt{--fa:"īŽœ";--fa--fa:"īŽœīŽœ"}.fa-ellipsis-vertical{--fa:"ī…‚";--fa--fa:"ī…‚ī…‚"}.fa-empty-set{--fa:"ī™–";--fa--fa:"ī™–ī™–"}.fa-engine{--fa:"";--fa--fa:""}.fa-engine-exclamation,.fa-engine-warning{--fa:"ī—˛";--fa--fa:""}.fa-envelope{--fa:"";--fa--fa:""}.fa-envelope-badge{--fa:"";--fa--fa:""}.fa-envelope-circle{--fa:"";--fa--fa:""}.fa-envelope-circle-check{--fa:"";--fa--fa:""}.fa-envelope-dot{--fa:"";--fa--fa:""}.fa-envelope-open{--fa:"īŠļ";--fa--fa:"īŠļīŠļ"}.fa-envelope-open-dollar{--fa:"ī™—";--fa--fa:"ī™—ī™—"}.fa-envelope-open-text{--fa:"ī™˜";--fa--fa:"ī™˜ī™˜"}.fa-envelope-square{--fa:"";--fa--fa:""}.fa-envelopes{--fa:"";--fa--fa:""}.fa-envelopes-bulk{--fa:"ī™´";--fa--fa:"ī™´ī™´"}.fa-equals{--fa:"=";--fa--fa:"=="}.fa-eraser{--fa:"ī„­";--fa--fa:"ī„­ī„­"}.fa-escalator{--fa:"";--fa--fa:""}.fa-ethernet{--fa:"īž–";--fa--fa:"īž–īž–"}.fa-eur,.fa-euro,.fa-euro-sign{--fa:"ī…“";--fa--fa:"ī…“ī…“"}.fa-excavator{--fa:"";--fa--fa:""}.fa-exchange{--fa:"īƒŦ";--fa--fa:"īƒŦīƒŦ"}.fa-exchange-alt{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-exclamation{--fa:"!";--fa--fa:"!!"}.fa-exclamation-circle{--fa:"īĒ";--fa--fa:"īĒīĒ"}.fa-exclamation-square{--fa:"īŒĄ";--fa--fa:"īŒĄīŒĄ"}.fa-exclamation-triangle{--fa:"īą";--fa--fa:"īąīą"}.fa-expand{--fa:"īĨ";--fa--fa:"īĨīĨ"}.fa-expand-alt{--fa:"";--fa--fa:""}.fa-expand-arrows{--fa:"īŒ";--fa--fa:"īŒīŒ"}.fa-expand-arrows-alt{--fa:"īŒž";--fa--fa:"īŒžīŒž"}.fa-expand-wide{--fa:"";--fa--fa:""}.fa-exploding-head{--fa:"";--fa--fa:""}.fa-explosion{--fa:"";--fa--fa:""}.fa-external-link{--fa:"ī‚Ž";--fa--fa:"ī‚Žī‚Ž"}.fa-external-link-alt{--fa:"ī";--fa--fa:"īī"}.fa-external-link-square{--fa:"ī…Œ";--fa--fa:"ī…Œī…Œ"}.fa-external-link-square-alt{--fa:"ī ";--fa--fa:"ī ī "}.fa-eye{--fa:"īŽ";--fa--fa:"īŽīŽ"}.fa-eye-dropper,.fa-eye-dropper-empty{--fa:"ī‡ģ";--fa--fa:"ī‡ģī‡ģ"}.fa-eye-dropper-full{--fa:"";--fa--fa:""}.fa-eye-dropper-half{--fa:"î…ŗ";--fa--fa:"î…ŗî…ŗ"}.fa-eye-evil{--fa:"ī››";--fa--fa:""}.fa-eye-low-vision{--fa:"";--fa--fa:""}.fa-eye-slash{--fa:"";--fa--fa:""}.fa-eyedropper{--fa:"ī‡ģ";--fa--fa:"ī‡ģī‡ģ"}.fa-eyes{--fa:"";--fa--fa:""}.fa-f{--fa:"F";--fa--fa:"FF"}.fa-face-angry{--fa:"ī•–";--fa--fa:"ī•–ī•–"}.fa-face-angry-horns{--fa:"";--fa--fa:""}.fa-face-anguished{--fa:"";--fa--fa:""}.fa-face-anxious-sweat{--fa:"îĒ";--fa--fa:"îĒîĒ"}.fa-face-astonished{--fa:"îĢ";--fa--fa:"îĢîĢ"}.fa-face-awesome{--fa:"";--fa--fa:""}.fa-face-beam-hand-over-mouth{--fa:"î‘ŧ";--fa--fa:"î‘ŧî‘ŧ"}.fa-face-clouds{--fa:"î‘Ŋ";--fa--fa:"î‘Ŋî‘Ŋ"}.fa-face-confounded{--fa:"îŦ";--fa--fa:"îŦîŦ"}.fa-face-confused{--fa:"";--fa--fa:""}.fa-face-cowboy-hat{--fa:"";--fa--fa:""}.fa-face-diagonal-mouth{--fa:"";--fa--fa:""}.fa-face-disappointed{--fa:"";--fa--fa:""}.fa-face-disguise{--fa:"";--fa--fa:""}.fa-face-dizzy{--fa:"ī•§";--fa--fa:"ī•§ī•§"}.fa-face-dotted{--fa:"î‘ŋ";--fa--fa:"î‘ŋî‘ŋ"}.fa-face-downcast-sweat{--fa:"";--fa--fa:""}.fa-face-drooling{--fa:"";--fa--fa:""}.fa-face-exhaling{--fa:"";--fa--fa:""}.fa-face-explode{--fa:"";--fa--fa:""}.fa-face-expressionless{--fa:"îŗ";--fa--fa:"îŗîŗ"}.fa-face-eyes-xmarks{--fa:"";--fa--fa:""}.fa-face-fearful{--fa:"îĩ";--fa--fa:"îĩîĩ"}.fa-face-flushed{--fa:"ī•š";--fa--fa:"ī•šī•š"}.fa-face-frown{--fa:"ī„™";--fa--fa:""}.fa-face-frown-open{--fa:"ī•ē";--fa--fa:"ī•ēī•ē"}.fa-face-frown-slight{--fa:"îļ";--fa--fa:"îļîļ"}.fa-face-glasses{--fa:"";--fa--fa:""}.fa-face-grimace{--fa:"ī•ŋ";--fa--fa:"ī•ŋī•ŋ"}.fa-face-grin{--fa:"ī–€";--fa--fa:""}.fa-face-grin-beam{--fa:"ī–‚";--fa--fa:"ī–‚ī–‚"}.fa-face-grin-beam-sweat{--fa:"ī–ƒ";--fa--fa:"ī–ƒī–ƒ"}.fa-face-grin-hearts{--fa:"ī–„";--fa--fa:"ī–„ī–„"}.fa-face-grin-squint{--fa:"ī–…";--fa--fa:"ī–…ī–…"}.fa-face-grin-squint-tears{--fa:"ī–†";--fa--fa:""}.fa-face-grin-stars{--fa:"ī–‡";--fa--fa:""}.fa-face-grin-tears{--fa:"ī–ˆ";--fa--fa:"ī–ˆī–ˆ"}.fa-face-grin-tongue{--fa:"ī–‰";--fa--fa:""}.fa-face-grin-tongue-squint{--fa:"ī–Š";--fa--fa:"ī–Šī–Š"}.fa-face-grin-tongue-wink{--fa:"ī–‹";--fa--fa:"ī–‹ī–‹"}.fa-face-grin-wide{--fa:"";--fa--fa:""}.fa-face-grin-wink{--fa:"ī–Œ";--fa--fa:"ī–Œī–Œ"}.fa-face-hand-over-mouth{--fa:"";--fa--fa:""}.fa-face-hand-peeking{--fa:"";--fa--fa:""}.fa-face-hand-yawn{--fa:"";--fa--fa:""}.fa-face-head-bandage{--fa:"îē";--fa--fa:"îēîē"}.fa-face-holding-back-tears{--fa:"";--fa--fa:""}.fa-face-hushed{--fa:"îģ";--fa--fa:"îģîģ"}.fa-face-icicles{--fa:"îŧ";--fa--fa:"îŧîŧ"}.fa-face-kiss{--fa:"ī––";--fa--fa:"ī––ī––"}.fa-face-kiss-beam{--fa:"ī–—";--fa--fa:"ī–—ī–—"}.fa-face-kiss-closed-eyes{--fa:"îŊ";--fa--fa:"îŊîŊ"}.fa-face-kiss-wink-heart{--fa:"ī–˜";--fa--fa:"ī–˜ī–˜"}.fa-face-laugh{--fa:"ī–™";--fa--fa:""}.fa-face-laugh-beam{--fa:"ī–š";--fa--fa:"ī–šī–š"}.fa-face-laugh-squint{--fa:"ī–›";--fa--fa:""}.fa-face-laugh-wink{--fa:"ī–œ";--fa--fa:"ī–œī–œ"}.fa-face-lying{--fa:"";--fa--fa:""}.fa-face-mask{--fa:"îŋ";--fa--fa:"îŋîŋ"}.fa-face-meh{--fa:"ī„š";--fa--fa:"ī„šī„š"}.fa-face-meh-blank{--fa:"ī–¤";--fa--fa:""}.fa-face-melting{--fa:"";--fa--fa:""}.fa-face-monocle{--fa:"";--fa--fa:""}.fa-face-nauseated{--fa:"";--fa--fa:""}.fa-face-nose-steam{--fa:"";--fa--fa:""}.fa-face-party{--fa:"";--fa--fa:""}.fa-face-pensive{--fa:"";--fa--fa:""}.fa-face-persevering{--fa:"";--fa--fa:""}.fa-face-pleading{--fa:"";--fa--fa:""}.fa-face-pouting{--fa:"";--fa--fa:""}.fa-face-raised-eyebrow{--fa:"";--fa--fa:""}.fa-face-relieved{--fa:"";--fa--fa:""}.fa-face-rolling-eyes{--fa:"ī–Ĩ";--fa--fa:"ī–Ĩī–Ĩ"}.fa-face-sad-cry{--fa:"ī–ŗ";--fa--fa:"ī–ŗī–ŗ"}.fa-face-sad-sweat{--fa:"";--fa--fa:""}.fa-face-sad-tear{--fa:"ī–´";--fa--fa:"ī–´ī–´"}.fa-face-saluting{--fa:"";--fa--fa:""}.fa-face-scream{--fa:"";--fa--fa:""}.fa-face-shush{--fa:"";--fa--fa:""}.fa-face-sleeping{--fa:"";--fa--fa:""}.fa-face-sleepy{--fa:"";--fa--fa:""}.fa-face-smile{--fa:"ī„˜";--fa--fa:"ī„˜ī„˜"}.fa-face-smile-beam{--fa:"ī–¸";--fa--fa:""}.fa-face-smile-halo{--fa:"";--fa--fa:""}.fa-face-smile-hearts{--fa:"";--fa--fa:""}.fa-face-smile-horns{--fa:"";--fa--fa:""}.fa-face-smile-plus{--fa:"ī–š";--fa--fa:"ī–šī–š"}.fa-face-smile-relaxed{--fa:"";--fa--fa:""}.fa-face-smile-tear{--fa:"";--fa--fa:""}.fa-face-smile-tongue{--fa:"";--fa--fa:""}.fa-face-smile-upside-down{--fa:"";--fa--fa:""}.fa-face-smile-wink{--fa:"ī“š";--fa--fa:"ī“šī“š"}.fa-face-smiling-hands{--fa:"";--fa--fa:""}.fa-face-smirking{--fa:"";--fa--fa:""}.fa-face-spiral-eyes{--fa:"";--fa--fa:""}.fa-face-sunglasses{--fa:"";--fa--fa:""}.fa-face-surprise{--fa:"ī—‚";--fa--fa:"ī—‚ī—‚"}.fa-face-swear{--fa:"";--fa--fa:""}.fa-face-thermometer{--fa:"";--fa--fa:""}.fa-face-thinking{--fa:"";--fa--fa:""}.fa-face-tired{--fa:"ī—ˆ";--fa--fa:"ī—ˆī—ˆ"}.fa-face-tissue{--fa:"";--fa--fa:""}.fa-face-tongue-money{--fa:"";--fa--fa:""}.fa-face-tongue-sweat{--fa:"";--fa--fa:""}.fa-face-unamused{--fa:"";--fa--fa:""}.fa-face-viewfinder{--fa:"î‹ŋ";--fa--fa:"î‹ŋî‹ŋ"}.fa-face-vomit{--fa:"";--fa--fa:""}.fa-face-weary{--fa:"";--fa--fa:""}.fa-face-woozy{--fa:"îŽĸ";--fa--fa:"îŽĸîŽĸ"}.fa-face-worried{--fa:"îŽŖ";--fa--fa:"îŽŖîŽŖ"}.fa-face-zany{--fa:"";--fa--fa:""}.fa-face-zipper{--fa:"îŽĨ";--fa--fa:"îŽĨîŽĨ"}.fa-falafel{--fa:"";--fa--fa:""}.fa-family{--fa:"";--fa--fa:""}.fa-family-dress{--fa:"";--fa--fa:""}.fa-family-pants{--fa:"";--fa--fa:""}.fa-fan{--fa:"īĄŖ";--fa--fa:"īĄŖīĄŖ"}.fa-fan-table{--fa:"";--fa--fa:""}.fa-farm{--fa:"īĄ¤";--fa--fa:"īĄ¤īĄ¤"}.fa-fast-backward{--fa:"";--fa--fa:""}.fa-fast-forward{--fa:"";--fa--fa:""}.fa-faucet{--fa:"";--fa--fa:""}.fa-faucet-drip{--fa:"";--fa--fa:""}.fa-fax{--fa:"ī†Ŧ";--fa--fa:"ī†Ŧī†Ŧ"}.fa-feather{--fa:"ī”­";--fa--fa:"ī”­ī”­"}.fa-feather-alt,.fa-feather-pointed{--fa:"ī•Ģ";--fa--fa:"ī•Ģī•Ģ"}.fa-feed{--fa:"ī‚ž";--fa--fa:"ī‚žī‚ž"}.fa-female{--fa:"";--fa--fa:""}.fa-fence{--fa:"";--fa--fa:""}.fa-ferris-wheel{--fa:"";--fa--fa:""}.fa-ferry{--fa:"î“Ē";--fa--fa:"î“Ēî“Ē"}.fa-field-hockey,.fa-field-hockey-stick-ball{--fa:"ī‘Œ";--fa--fa:"ī‘Œī‘Œ"}.fa-fighter-jet{--fa:"īƒģ";--fa--fa:"īƒģīƒģ"}.fa-file{--fa:"ī…›";--fa--fa:""}.fa-file-alt{--fa:"ī…œ";--fa--fa:"ī…œī…œ"}.fa-file-archive{--fa:"";--fa--fa:""}.fa-file-arrow-down{--fa:"ī•­";--fa--fa:"ī•­ī•­"}.fa-file-arrow-up{--fa:"ī•´";--fa--fa:"ī•´ī•´"}.fa-file-audio{--fa:"";--fa--fa:""}.fa-file-award{--fa:"ī—ŗ";--fa--fa:"ī—ŗī—ŗ"}.fa-file-binary{--fa:"î…ĩ";--fa--fa:"î…ĩî…ĩ"}.fa-file-cad{--fa:"";--fa--fa:""}.fa-file-caret-down{--fa:"";--fa--fa:""}.fa-file-caret-up{--fa:"îĒ";--fa--fa:"îĒîĒ"}.fa-file-certificate{--fa:"ī—ŗ";--fa--fa:"ī—ŗī—ŗ"}.fa-file-chart-column,.fa-file-chart-line{--fa:"ī™™";--fa--fa:""}.fa-file-chart-pie{--fa:"ī™š";--fa--fa:"ī™šī™š"}.fa-file-check{--fa:"īŒ–";--fa--fa:"īŒ–īŒ–"}.fa-file-circle-check{--fa:"";--fa--fa:""}.fa-file-circle-exclamation{--fa:"î“Ģ";--fa--fa:"î“Ģî“Ģ"}.fa-file-circle-info{--fa:"";--fa--fa:""}.fa-file-circle-minus{--fa:"";--fa--fa:""}.fa-file-circle-plus{--fa:"";--fa--fa:""}.fa-file-circle-question{--fa:"";--fa--fa:""}.fa-file-circle-xmark{--fa:"";--fa--fa:""}.fa-file-clipboard{--fa:"īƒĒ";--fa--fa:"īƒĒīƒĒ"}.fa-file-code{--fa:"";--fa--fa:""}.fa-file-contract{--fa:"ī•Ŧ";--fa--fa:"ī•Ŧī•Ŧ"}.fa-file-csv{--fa:"ī›";--fa--fa:"ī›ī›"}.fa-file-dashed-line{--fa:"īĄˇ";--fa--fa:"īĄˇīĄˇ"}.fa-file-doc{--fa:"";--fa--fa:""}.fa-file-download{--fa:"ī•­";--fa--fa:"ī•­ī•­"}.fa-file-edit{--fa:"";--fa--fa:""}.fa-file-eps{--fa:"";--fa--fa:""}.fa-file-excel{--fa:"ī‡ƒ";--fa--fa:"ī‡ƒī‡ƒ"}.fa-file-exclamation{--fa:"";--fa--fa:""}.fa-file-export{--fa:"ī•Ž";--fa--fa:"ī•Žī•Ž"}.fa-file-fragment{--fa:"";--fa--fa:""}.fa-file-gif{--fa:"";--fa--fa:""}.fa-file-half-dashed{--fa:"";--fa--fa:""}.fa-file-heart{--fa:"î…ļ";--fa--fa:"î…ļî…ļ"}.fa-file-image{--fa:"";--fa--fa:""}.fa-file-import{--fa:"";--fa--fa:""}.fa-file-invoice{--fa:"ī•°";--fa--fa:"ī•°ī•°"}.fa-file-invoice-dollar{--fa:"ī•ą";--fa--fa:"ī•ąī•ą"}.fa-file-jpg{--fa:"";--fa--fa:""}.fa-file-lines{--fa:"ī…œ";--fa--fa:"ī…œī…œ"}.fa-file-lock{--fa:"îŽĻ";--fa--fa:"îŽĻîŽĻ"}.fa-file-magnifying-glass{--fa:"īĄĨ";--fa--fa:"īĄĨīĄĨ"}.fa-file-medical{--fa:"";--fa--fa:""}.fa-file-medical-alt{--fa:"";--fa--fa:""}.fa-file-minus{--fa:"";--fa--fa:""}.fa-file-mov{--fa:"";--fa--fa:""}.fa-file-mp3{--fa:"";--fa--fa:""}.fa-file-mp4{--fa:"";--fa--fa:""}.fa-file-music{--fa:"īĸļ";--fa--fa:"īĸļīĸļ"}.fa-file-pdf{--fa:"";--fa--fa:""}.fa-file-pen{--fa:"";--fa--fa:""}.fa-file-plus{--fa:"īŒ™";--fa--fa:"īŒ™īŒ™"}.fa-file-plus-minus{--fa:"";--fa--fa:""}.fa-file-png{--fa:"î™Ļ";--fa--fa:"î™Ļî™Ļ"}.fa-file-powerpoint{--fa:"";--fa--fa:""}.fa-file-ppt{--fa:"";--fa--fa:""}.fa-file-prescription{--fa:"";--fa--fa:""}.fa-file-search{--fa:"īĄĨ";--fa--fa:"īĄĨīĄĨ"}.fa-file-shield{--fa:"";--fa--fa:""}.fa-file-signature{--fa:"";--fa--fa:""}.fa-file-slash{--fa:"";--fa--fa:""}.fa-file-spreadsheet{--fa:"ī™›";--fa--fa:""}.fa-file-svg{--fa:"";--fa--fa:""}.fa-file-text{--fa:"ī…œ";--fa--fa:"ī…œī…œ"}.fa-file-times{--fa:"īŒ—";--fa--fa:"īŒ—īŒ—"}.fa-file-upload{--fa:"ī•´";--fa--fa:"ī•´ī•´"}.fa-file-user{--fa:"ī™œ";--fa--fa:"ī™œī™œ"}.fa-file-vector{--fa:"";--fa--fa:""}.fa-file-video{--fa:"ī‡ˆ";--fa--fa:"ī‡ˆī‡ˆ"}.fa-file-waveform{--fa:"";--fa--fa:""}.fa-file-word{--fa:"";--fa--fa:""}.fa-file-xls{--fa:"";--fa--fa:""}.fa-file-xmark{--fa:"īŒ—";--fa--fa:"īŒ—īŒ—"}.fa-file-xml{--fa:"";--fa--fa:""}.fa-file-zip{--fa:"";--fa--fa:""}.fa-file-zipper{--fa:"";--fa--fa:""}.fa-files{--fa:"";--fa--fa:""}.fa-files-medical{--fa:"īŸŊ";--fa--fa:"īŸŊīŸŊ"}.fa-fill{--fa:"ī•ĩ";--fa--fa:"ī•ĩī•ĩ"}.fa-fill-drip{--fa:"ī•ļ";--fa--fa:"ī•ļī•ļ"}.fa-film{--fa:"ī€ˆ";--fa--fa:"ī€ˆī€ˆ"}.fa-film-alt{--fa:"īŽ ";--fa--fa:"īŽ īŽ "}.fa-film-canister,.fa-film-cannister{--fa:"īĸˇ";--fa--fa:"īĸˇīĸˇ"}.fa-film-simple{--fa:"īŽ ";--fa--fa:"īŽ īŽ "}.fa-film-slash{--fa:"";--fa--fa:""}.fa-films{--fa:"î…ē";--fa--fa:"î…ēî…ē"}.fa-filter{--fa:"ī‚°";--fa--fa:"ī‚°ī‚°"}.fa-filter-circle-dollar{--fa:"ī™ĸ";--fa--fa:"ī™ĸī™ĸ"}.fa-filter-circle-xmark{--fa:"î…ģ";--fa--fa:"î…ģî…ģ"}.fa-filter-list{--fa:"î…ŧ";--fa--fa:"î…ŧî…ŧ"}.fa-filter-slash{--fa:"î…Ŋ";--fa--fa:"î…Ŋî…Ŋ"}.fa-filters{--fa:"";--fa--fa:""}.fa-fingerprint{--fa:"";--fa--fa:""}.fa-fire{--fa:"";--fa--fa:""}.fa-fire-alt{--fa:"";--fa--fa:""}.fa-fire-burner{--fa:"";--fa--fa:""}.fa-fire-extinguisher{--fa:"ī„´";--fa--fa:"ī„´ī„´"}.fa-fire-flame{--fa:"ī›Ÿ";--fa--fa:"ī›Ÿī›Ÿ"}.fa-fire-flame-curved{--fa:"";--fa--fa:""}.fa-fire-flame-simple{--fa:"ī‘Ē";--fa--fa:"ī‘Ēī‘Ē"}.fa-fire-hydrant{--fa:"î…ŋ";--fa--fa:"î…ŋî…ŋ"}.fa-fire-smoke{--fa:"ī‹";--fa--fa:"ī‹ī‹"}.fa-fireplace{--fa:"īžš";--fa--fa:"īžšīžš"}.fa-firewall{--fa:"";--fa--fa:""}.fa-first-aid{--fa:"ī‘š";--fa--fa:"ī‘šī‘š"}.fa-fish{--fa:"";--fa--fa:""}.fa-fish-bones{--fa:"";--fa--fa:""}.fa-fish-cooked{--fa:"īŸž";--fa--fa:"īŸžīŸž"}.fa-fish-fins{--fa:"";--fa--fa:""}.fa-fishing-rod{--fa:"";--fa--fa:""}.fa-fist-raised{--fa:"ī›ž";--fa--fa:"ī›žī›ž"}.fa-flag{--fa:"";--fa--fa:""}.fa-flag-alt{--fa:"īŒ";--fa--fa:"īŒīŒ"}.fa-flag-checkered{--fa:"ī„ž";--fa--fa:"ī„žī„ž"}.fa-flag-pennant{--fa:"ī‘–";--fa--fa:"ī‘–ī‘–"}.fa-flag-swallowtail{--fa:"īŒ";--fa--fa:"īŒīŒ"}.fa-flag-usa{--fa:"ī";--fa--fa:"īī"}.fa-flame{--fa:"ī›Ÿ";--fa--fa:"ī›Ÿī›Ÿ"}.fa-flashlight{--fa:"īĸ¸";--fa--fa:"īĸ¸īĸ¸"}.fa-flask{--fa:"";--fa--fa:""}.fa-flask-gear{--fa:"";--fa--fa:""}.fa-flask-poison{--fa:"ī› ";--fa--fa:"ī› ī› "}.fa-flask-potion{--fa:"ī›Ą";--fa--fa:"ī›Ąī›Ą"}.fa-flask-round-poison{--fa:"ī› ";--fa--fa:"ī› ī› "}.fa-flask-round-potion{--fa:"ī›Ą";--fa--fa:"ī›Ąī›Ą"}.fa-flask-vial{--fa:"î“ŗ";--fa--fa:"î“ŗî“ŗ"}.fa-flatbread{--fa:"";--fa--fa:""}.fa-flatbread-stuffed{--fa:"";--fa--fa:""}.fa-floppy-disk{--fa:"īƒ‡";--fa--fa:"īƒ‡īƒ‡"}.fa-floppy-disk-circle-arrow-right{--fa:"";--fa--fa:""}.fa-floppy-disk-circle-xmark{--fa:"";--fa--fa:""}.fa-floppy-disk-pen{--fa:"";--fa--fa:""}.fa-floppy-disk-times{--fa:"";--fa--fa:""}.fa-floppy-disks{--fa:"";--fa--fa:""}.fa-florin-sign{--fa:"";--fa--fa:""}.fa-flower{--fa:"īŸŋ";--fa--fa:"īŸŋīŸŋ"}.fa-flower-daffodil{--fa:"ī €";--fa--fa:""}.fa-flower-tulip{--fa:"";--fa--fa:""}.fa-flushed{--fa:"ī•š";--fa--fa:"ī•šī•š"}.fa-flute{--fa:"īĸš";--fa--fa:"īĸšīĸš"}.fa-flux-capacitor{--fa:"īĸē";--fa--fa:"īĸēīĸē"}.fa-flying-disc{--fa:"";--fa--fa:""}.fa-fog{--fa:"īŽ";--fa--fa:"īŽīŽ"}.fa-folder{--fa:"īģ";--fa--fa:"īģīģ"}.fa-folder-arrow-down{--fa:"";--fa--fa:""}.fa-folder-arrow-up{--fa:"";--fa--fa:""}.fa-folder-blank{--fa:"īģ";--fa--fa:"īģīģ"}.fa-folder-bookmark{--fa:"";--fa--fa:""}.fa-folder-check{--fa:"";--fa--fa:""}.fa-folder-closed{--fa:"";--fa--fa:""}.fa-folder-cog{--fa:"";--fa--fa:""}.fa-folder-download{--fa:"";--fa--fa:""}.fa-folder-gear{--fa:"";--fa--fa:""}.fa-folder-grid{--fa:"";--fa--fa:""}.fa-folder-heart{--fa:"";--fa--fa:""}.fa-folder-image{--fa:"";--fa--fa:""}.fa-folder-magnifying-glass{--fa:"";--fa--fa:""}.fa-folder-medical{--fa:"";--fa--fa:""}.fa-folder-minus{--fa:"ī™";--fa--fa:"ī™ī™"}.fa-folder-music{--fa:"";--fa--fa:""}.fa-folder-open{--fa:"īŧ";--fa--fa:"īŧīŧ"}.fa-folder-plus{--fa:"ī™ž";--fa--fa:"ī™žī™ž"}.fa-folder-search{--fa:"";--fa--fa:""}.fa-folder-times{--fa:"ī™Ÿ";--fa--fa:"ī™Ÿī™Ÿ"}.fa-folder-tree{--fa:"ī ‚";--fa--fa:"ī ‚ī ‚"}.fa-folder-upload{--fa:"";--fa--fa:""}.fa-folder-user{--fa:"";--fa--fa:""}.fa-folder-xmark{--fa:"ī™Ÿ";--fa--fa:"ī™Ÿī™Ÿ"}.fa-folders{--fa:"ī™ ";--fa--fa:"ī™ ī™ "}.fa-fondue-pot{--fa:"";--fa--fa:""}.fa-font{--fa:"ī€ą";--fa--fa:"ī€ąī€ą"}.fa-font-awesome,.fa-font-awesome-flag,.fa-font-awesome-logo-full{--fa:"";--fa--fa:""}.fa-font-case{--fa:"īĄĻ";--fa--fa:"īĄĻīĄĻ"}.fa-football,.fa-football-ball{--fa:"ī‘Ž";--fa--fa:"ī‘Žī‘Ž"}.fa-football-helmet{--fa:"ī‘";--fa--fa:"ī‘ī‘"}.fa-fork{--fa:"";--fa--fa:""}.fa-fork-knife{--fa:"ī‹Ļ";--fa--fa:"ī‹Ļī‹Ļ"}.fa-forklift{--fa:"ī‘ē";--fa--fa:"ī‘ēī‘ē"}.fa-fort{--fa:"";--fa--fa:""}.fa-forward{--fa:"īŽ";--fa--fa:"īŽīŽ"}.fa-forward-fast{--fa:"";--fa--fa:""}.fa-forward-step{--fa:"";--fa--fa:""}.fa-fragile{--fa:"ī’ģ";--fa--fa:"ī’ģī’ģ"}.fa-frame{--fa:"";--fa--fa:""}.fa-franc-sign{--fa:"";--fa--fa:""}.fa-french-fries{--fa:"";--fa--fa:""}.fa-frog{--fa:"ī”Ž";--fa--fa:"ī”Žī”Ž"}.fa-frosty-head{--fa:"īž›";--fa--fa:"īž›īž›"}.fa-frown{--fa:"ī„™";--fa--fa:""}.fa-frown-open{--fa:"ī•ē";--fa--fa:"ī•ēī•ē"}.fa-function{--fa:"ī™Ą";--fa--fa:"ī™Ąī™Ą"}.fa-funnel-dollar{--fa:"ī™ĸ";--fa--fa:"ī™ĸī™ĸ"}.fa-futbol,.fa-futbol-ball{--fa:"";--fa--fa:""}.fa-g{--fa:"G";--fa--fa:"GG"}.fa-galaxy{--fa:"";--fa--fa:""}.fa-gallery-thumbnails{--fa:"îŽĒ";--fa--fa:"îŽĒîŽĒ"}.fa-game-board{--fa:"īĄ§";--fa--fa:"īĄ§īĄ§"}.fa-game-board-alt,.fa-game-board-simple{--fa:"īĄ¨";--fa--fa:"īĄ¨īĄ¨"}.fa-game-console-handheld{--fa:"īĸģ";--fa--fa:"īĸģīĸģ"}.fa-game-console-handheld-crank{--fa:"";--fa--fa:""}.fa-gamepad{--fa:"ī„›";--fa--fa:""}.fa-gamepad-alt,.fa-gamepad-modern{--fa:"î–ĸ";--fa--fa:"î–ĸî–ĸ"}.fa-garage{--fa:"";--fa--fa:""}.fa-garage-car{--fa:"";--fa--fa:""}.fa-garage-open{--fa:"";--fa--fa:""}.fa-garlic{--fa:"";--fa--fa:""}.fa-gas-pump{--fa:"";--fa--fa:""}.fa-gas-pump-slash{--fa:"ī—´";--fa--fa:"ī—´ī—´"}.fa-gauge{--fa:"";--fa--fa:""}.fa-gauge-circle-bolt{--fa:"";--fa--fa:""}.fa-gauge-circle-minus{--fa:"";--fa--fa:""}.fa-gauge-circle-plus{--fa:"";--fa--fa:""}.fa-gauge-high{--fa:"ī˜Ĩ";--fa--fa:"ī˜Ĩī˜Ĩ"}.fa-gauge-low{--fa:"";--fa--fa:""}.fa-gauge-max{--fa:"ī˜Ļ";--fa--fa:"ī˜Ļī˜Ļ"}.fa-gauge-med{--fa:"";--fa--fa:""}.fa-gauge-min{--fa:"";--fa--fa:""}.fa-gauge-simple{--fa:"ī˜Š";--fa--fa:"ī˜Šī˜Š"}.fa-gauge-simple-high{--fa:"ī˜Ē";--fa--fa:"ī˜Ēī˜Ē"}.fa-gauge-simple-low{--fa:"ī˜Ŧ";--fa--fa:"ī˜Ŧī˜Ŧ"}.fa-gauge-simple-max{--fa:"ī˜Ģ";--fa--fa:"ī˜Ģī˜Ģ"}.fa-gauge-simple-med{--fa:"ī˜Š";--fa--fa:"ī˜Šī˜Š"}.fa-gauge-simple-min{--fa:"";--fa--fa:""}.fa-gave-dandy{--fa:"";--fa--fa:""}.fa-gavel{--fa:"";--fa--fa:""}.fa-gbp{--fa:"ī…”";--fa--fa:""}.fa-gear{--fa:"";--fa--fa:""}.fa-gear-code{--fa:"";--fa--fa:""}.fa-gear-complex{--fa:"";--fa--fa:""}.fa-gear-complex-code{--fa:"î—Ģ";--fa--fa:"î—Ģî—Ģ"}.fa-gears{--fa:"ī‚…";--fa--fa:"ī‚…ī‚…"}.fa-gem{--fa:"īŽĨ";--fa--fa:"īŽĨīŽĨ"}.fa-genderless{--fa:"";--fa--fa:""}.fa-ghost{--fa:"ī›ĸ";--fa--fa:"ī›ĸī›ĸ"}.fa-gif{--fa:"";--fa--fa:""}.fa-gift{--fa:"īĢ";--fa--fa:"īĢīĢ"}.fa-gift-card{--fa:"";--fa--fa:""}.fa-gifts{--fa:"īžœ";--fa--fa:"īžœīžœ"}.fa-gingerbread-man{--fa:"īž";--fa--fa:"īžīž"}.fa-glass{--fa:"ī „";--fa--fa:"ī „ī „"}.fa-glass-champagne{--fa:"īžž";--fa--fa:"īžžīžž"}.fa-glass-cheers{--fa:"īžŸ";--fa--fa:"īžŸīžŸ"}.fa-glass-citrus{--fa:"īĄŠ";--fa--fa:"īĄŠīĄŠ"}.fa-glass-empty{--fa:"";--fa--fa:""}.fa-glass-half,.fa-glass-half-empty,.fa-glass-half-full{--fa:"";--fa--fa:""}.fa-glass-martini{--fa:"";--fa--fa:""}.fa-glass-martini-alt{--fa:"ī•ģ";--fa--fa:"ī•ģī•ģ"}.fa-glass-water{--fa:"";--fa--fa:""}.fa-glass-water-droplet{--fa:"î“ĩ";--fa--fa:"î“ĩî“ĩ"}.fa-glass-whiskey{--fa:"īž ";--fa--fa:"īž īž "}.fa-glass-whiskey-rocks{--fa:"īžĄ";--fa--fa:"īžĄīžĄ"}.fa-glasses{--fa:"ī”°";--fa--fa:"ī”°ī”°"}.fa-glasses-alt,.fa-glasses-round{--fa:"ī—ĩ";--fa--fa:"ī—ĩī—ĩ"}.fa-globe{--fa:"ī‚Ŧ";--fa--fa:"ī‚Ŧī‚Ŧ"}.fa-globe-africa{--fa:"ī•ŧ";--fa--fa:"ī•ŧī•ŧ"}.fa-globe-americas{--fa:"ī•Ŋ";--fa--fa:"ī•Ŋī•Ŋ"}.fa-globe-asia{--fa:"ī•ž";--fa--fa:"ī•žī•ž"}.fa-globe-europe{--fa:"īžĸ";--fa--fa:"īžĸīžĸ"}.fa-globe-oceania{--fa:"î‘ģ";--fa--fa:"î‘ģî‘ģ"}.fa-globe-pointer{--fa:"";--fa--fa:""}.fa-globe-snow{--fa:"īžŖ";--fa--fa:"īžŖīžŖ"}.fa-globe-stand{--fa:"ī—ļ";--fa--fa:"ī—ļī—ļ"}.fa-globe-wifi{--fa:"";--fa--fa:""}.fa-glove-boxing{--fa:"";--fa--fa:""}.fa-goal-net{--fa:"îŽĢ";--fa--fa:"îŽĢîŽĢ"}.fa-golf-ball,.fa-golf-ball-tee{--fa:"";--fa--fa:""}.fa-golf-club{--fa:"ī‘‘";--fa--fa:"ī‘‘ī‘‘"}.fa-golf-flag-hole{--fa:"îŽŦ";--fa--fa:"îŽŦîŽŦ"}.fa-gopuram{--fa:"";--fa--fa:""}.fa-graduation-cap{--fa:"ī†";--fa--fa:"ī†ī†"}.fa-gramophone{--fa:"īĸŊ";--fa--fa:"īĸŊīĸŊ"}.fa-grapes{--fa:"";--fa--fa:""}.fa-grate{--fa:"";--fa--fa:""}.fa-grate-droplet{--fa:"";--fa--fa:""}.fa-greater-than{--fa:">";--fa--fa:">>"}.fa-greater-than-equal{--fa:"";--fa--fa:""}.fa-grid{--fa:"";--fa--fa:""}.fa-grid-2{--fa:"";--fa--fa:""}.fa-grid-2-plus{--fa:"";--fa--fa:""}.fa-grid-3{--fa:"";--fa--fa:""}.fa-grid-4{--fa:"";--fa--fa:""}.fa-grid-5{--fa:"";--fa--fa:""}.fa-grid-dividers{--fa:"";--fa--fa:""}.fa-grid-horizontal{--fa:"";--fa--fa:""}.fa-grid-round{--fa:"";--fa--fa:""}.fa-grid-round-2{--fa:"";--fa--fa:""}.fa-grid-round-2-plus{--fa:"";--fa--fa:""}.fa-grid-round-4{--fa:"";--fa--fa:""}.fa-grid-round-5{--fa:"";--fa--fa:""}.fa-grill{--fa:"î–Ŗ";--fa--fa:"î–Ŗî–Ŗ"}.fa-grill-fire{--fa:"";--fa--fa:""}.fa-grill-hot{--fa:"î–Ĩ";--fa--fa:"î–Ĩî–Ĩ"}.fa-grimace{--fa:"ī•ŋ";--fa--fa:"ī•ŋī•ŋ"}.fa-grin{--fa:"ī–€";--fa--fa:""}.fa-grin-alt{--fa:"";--fa--fa:""}.fa-grin-beam{--fa:"ī–‚";--fa--fa:"ī–‚ī–‚"}.fa-grin-beam-sweat{--fa:"ī–ƒ";--fa--fa:"ī–ƒī–ƒ"}.fa-grin-hearts{--fa:"ī–„";--fa--fa:"ī–„ī–„"}.fa-grin-squint{--fa:"ī–…";--fa--fa:"ī–…ī–…"}.fa-grin-squint-tears{--fa:"ī–†";--fa--fa:""}.fa-grin-stars{--fa:"ī–‡";--fa--fa:""}.fa-grin-tears{--fa:"ī–ˆ";--fa--fa:"ī–ˆī–ˆ"}.fa-grin-tongue{--fa:"ī–‰";--fa--fa:""}.fa-grin-tongue-squint{--fa:"ī–Š";--fa--fa:"ī–Šī–Š"}.fa-grin-tongue-wink{--fa:"ī–‹";--fa--fa:"ī–‹ī–‹"}.fa-grin-wink{--fa:"ī–Œ";--fa--fa:"ī–Œī–Œ"}.fa-grip{--fa:"ī–";--fa--fa:"ī–ī–"}.fa-grip-dots{--fa:"";--fa--fa:""}.fa-grip-dots-vertical{--fa:"";--fa--fa:""}.fa-grip-horizontal{--fa:"ī–";--fa--fa:"ī–ī–"}.fa-grip-lines{--fa:"īž¤";--fa--fa:"īž¤īž¤"}.fa-grip-lines-vertical{--fa:"īžĨ";--fa--fa:"īžĨīžĨ"}.fa-grip-vertical{--fa:"ī–Ž";--fa--fa:"ī–Žī–Ž"}.fa-group-arrows-rotate{--fa:"î“ļ";--fa--fa:"î“ļî“ļ"}.fa-guarani-sign{--fa:"";--fa--fa:""}.fa-guitar{--fa:"īžĻ";--fa--fa:"īžĻīžĻ"}.fa-guitar-electric{--fa:"īĸž";--fa--fa:"īĸžīĸž"}.fa-guitars{--fa:"īĸŋ";--fa--fa:"īĸŋīĸŋ"}.fa-gun{--fa:"";--fa--fa:""}.fa-gun-slash{--fa:"";--fa--fa:""}.fa-gun-squirt{--fa:"";--fa--fa:""}.fa-h{--fa:"H";--fa--fa:"HH"}.fa-h-square{--fa:"īƒŊ";--fa--fa:"īƒŊīƒŊ"}.fa-h1{--fa:"īŒ“";--fa--fa:"īŒ“īŒ“"}.fa-h2{--fa:"īŒ”";--fa--fa:"īŒ”īŒ”"}.fa-h3{--fa:"īŒ•";--fa--fa:"īŒ•īŒ•"}.fa-h4{--fa:"īĄĒ";--fa--fa:"īĄĒīĄĒ"}.fa-h5{--fa:"";--fa--fa:""}.fa-h6{--fa:"";--fa--fa:""}.fa-hamburger{--fa:"ī …";--fa--fa:"ī …ī …"}.fa-hammer{--fa:"";--fa--fa:""}.fa-hammer-brush{--fa:"";--fa--fa:""}.fa-hammer-crash{--fa:"";--fa--fa:""}.fa-hammer-war{--fa:"";--fa--fa:""}.fa-hamsa{--fa:"ī™Ĩ";--fa--fa:"ī™Ĩī™Ĩ"}.fa-hand{--fa:"";--fa--fa:""}.fa-hand-back-fist{--fa:"";--fa--fa:""}.fa-hand-back-point-down{--fa:"";--fa--fa:""}.fa-hand-back-point-left{--fa:"";--fa--fa:""}.fa-hand-back-point-ribbon{--fa:"";--fa--fa:""}.fa-hand-back-point-right{--fa:"";--fa--fa:""}.fa-hand-back-point-up{--fa:"î†ĸ";--fa--fa:"î†ĸî†ĸ"}.fa-hand-dots{--fa:"ī‘Ą";--fa--fa:"ī‘Ąī‘Ą"}.fa-hand-fingers-crossed{--fa:"î†Ŗ";--fa--fa:"î†Ŗî†Ŗ"}.fa-hand-fist{--fa:"ī›ž";--fa--fa:"ī›žī›ž"}.fa-hand-heart{--fa:"ī’ŧ";--fa--fa:"ī’ŧī’ŧ"}.fa-hand-holding{--fa:"ī’Ŋ";--fa--fa:"ī’Ŋī’Ŋ"}.fa-hand-holding-box{--fa:"ī‘ģ";--fa--fa:"ī‘ģī‘ģ"}.fa-hand-holding-circle-dollar{--fa:"";--fa--fa:""}.fa-hand-holding-dollar{--fa:"ī“€";--fa--fa:""}.fa-hand-holding-droplet{--fa:"";--fa--fa:""}.fa-hand-holding-hand{--fa:"";--fa--fa:""}.fa-hand-holding-heart{--fa:"ī’ž";--fa--fa:"ī’žī’ž"}.fa-hand-holding-magic{--fa:"ī›Ĩ";--fa--fa:"ī›Ĩī›Ĩ"}.fa-hand-holding-medical{--fa:"";--fa--fa:""}.fa-hand-holding-seedling{--fa:"ī’ŋ";--fa--fa:"ī’ŋī’ŋ"}.fa-hand-holding-skull{--fa:"";--fa--fa:""}.fa-hand-holding-usd{--fa:"ī“€";--fa--fa:""}.fa-hand-holding-water{--fa:"";--fa--fa:""}.fa-hand-horns{--fa:"";--fa--fa:""}.fa-hand-lizard{--fa:"ī‰˜";--fa--fa:"ī‰˜ī‰˜"}.fa-hand-love{--fa:"î†Ĩ";--fa--fa:"î†Ĩî†Ĩ"}.fa-hand-middle-finger{--fa:"ī †";--fa--fa:""}.fa-hand-paper{--fa:"";--fa--fa:""}.fa-hand-peace{--fa:"";--fa--fa:""}.fa-hand-point-down{--fa:"ī‚§";--fa--fa:"ī‚§ī‚§"}.fa-hand-point-left{--fa:"ī‚Ĩ";--fa--fa:"ī‚Ĩī‚Ĩ"}.fa-hand-point-ribbon{--fa:"î†Ļ";--fa--fa:"î†Ļî†Ļ"}.fa-hand-point-right{--fa:"";--fa--fa:""}.fa-hand-point-up{--fa:"ī‚Ļ";--fa--fa:"ī‚Ļī‚Ļ"}.fa-hand-pointer{--fa:"ī‰š";--fa--fa:"ī‰šī‰š"}.fa-hand-receiving{--fa:"ī‘ŧ";--fa--fa:"ī‘ŧī‘ŧ"}.fa-hand-rock{--fa:"";--fa--fa:""}.fa-hand-scissors{--fa:"";--fa--fa:""}.fa-hand-sparkles{--fa:"";--fa--fa:""}.fa-hand-spock{--fa:"";--fa--fa:""}.fa-hand-wave{--fa:"";--fa--fa:""}.fa-handcuffs{--fa:"";--fa--fa:""}.fa-hands{--fa:"";--fa--fa:""}.fa-hands-american-sign-language-interpreting,.fa-hands-asl-interpreting{--fa:"";--fa--fa:""}.fa-hands-bound{--fa:"";--fa--fa:""}.fa-hands-bubbles{--fa:"";--fa--fa:""}.fa-hands-clapping{--fa:"";--fa--fa:""}.fa-hands-heart{--fa:"ī“ƒ";--fa--fa:"ī“ƒī“ƒ"}.fa-hands-helping{--fa:"ī“„";--fa--fa:"ī“„ī“„"}.fa-hands-holding{--fa:"ī“‚";--fa--fa:"ī“‚ī“‚"}.fa-hands-holding-child{--fa:"î“ē";--fa--fa:"î“ēî“ē"}.fa-hands-holding-circle{--fa:"î“ģ";--fa--fa:"î“ģî“ģ"}.fa-hands-holding-diamond{--fa:"ī‘ŧ";--fa--fa:"ī‘ŧī‘ŧ"}.fa-hands-holding-dollar{--fa:"ī“…";--fa--fa:"ī“…ī“…"}.fa-hands-holding-heart{--fa:"ī“ƒ";--fa--fa:"ī“ƒī“ƒ"}.fa-hands-praying{--fa:"īš„";--fa--fa:"īš„īš„"}.fa-hands-usd{--fa:"ī“…";--fa--fa:"ī“…ī“…"}.fa-hands-wash{--fa:"";--fa--fa:""}.fa-handshake{--fa:"īŠĩ";--fa--fa:"īŠĩīŠĩ"}.fa-handshake-alt{--fa:"";--fa--fa:""}.fa-handshake-alt-slash{--fa:"";--fa--fa:""}.fa-handshake-angle{--fa:"ī“„";--fa--fa:"ī“„ī“„"}.fa-handshake-simple{--fa:"";--fa--fa:""}.fa-handshake-simple-slash{--fa:"";--fa--fa:""}.fa-handshake-slash{--fa:"";--fa--fa:""}.fa-hanukiah{--fa:"ī›Ļ";--fa--fa:"ī›Ļī›Ļ"}.fa-hard-drive{--fa:"ī‚ ";--fa--fa:"ī‚ ī‚ "}.fa-hard-hat{--fa:"ī ‡";--fa--fa:""}.fa-hard-of-hearing{--fa:"";--fa--fa:""}.fa-hashtag{--fa:"#";--fa--fa:"##"}.fa-hashtag-lock{--fa:"";--fa--fa:""}.fa-hat-beach{--fa:"";--fa--fa:""}.fa-hat-chef{--fa:"īĄĢ";--fa--fa:"īĄĢīĄĢ"}.fa-hat-cowboy{--fa:"īŖ€";--fa--fa:""}.fa-hat-cowboy-side{--fa:"";--fa--fa:""}.fa-hat-hard{--fa:"ī ‡";--fa--fa:""}.fa-hat-santa{--fa:"īž§";--fa--fa:"īž§īž§"}.fa-hat-winter{--fa:"īž¨";--fa--fa:"īž¨īž¨"}.fa-hat-witch{--fa:"ī›§";--fa--fa:"ī›§ī›§"}.fa-hat-wizard{--fa:"";--fa--fa:""}.fa-haykal{--fa:"ī™Ļ";--fa--fa:"ī™Ļī™Ļ"}.fa-hdd{--fa:"ī‚ ";--fa--fa:"ī‚ ī‚ "}.fa-head-side{--fa:"ī›Š";--fa--fa:"ī›Šī›Š"}.fa-head-side-brain{--fa:"";--fa--fa:""}.fa-head-side-cough{--fa:"";--fa--fa:""}.fa-head-side-cough-slash{--fa:"îĸ";--fa--fa:"îĸîĸ"}.fa-head-side-gear{--fa:"";--fa--fa:""}.fa-head-side-goggles{--fa:"ī›Ē";--fa--fa:"ī›Ēī›Ē"}.fa-head-side-headphones{--fa:"īŖ‚";--fa--fa:"īŖ‚īŖ‚"}.fa-head-side-heart{--fa:"î†Ē";--fa--fa:"î†Ēî†Ē"}.fa-head-side-mask{--fa:"îŖ";--fa--fa:"îŖîŖ"}.fa-head-side-medical{--fa:"ī ‰";--fa--fa:""}.fa-head-side-virus{--fa:"";--fa--fa:""}.fa-head-vr{--fa:"ī›Ē";--fa--fa:"ī›Ēī›Ē"}.fa-header,.fa-heading{--fa:"ī‡œ";--fa--fa:"ī‡œī‡œ"}.fa-headphones{--fa:"ī€Ĩ";--fa--fa:"ī€Ĩī€Ĩ"}.fa-headphones-alt,.fa-headphones-simple{--fa:"ī–";--fa--fa:"ī–ī–"}.fa-headset{--fa:"";--fa--fa:""}.fa-heart{--fa:"";--fa--fa:""}.fa-heart-broken{--fa:"īžŠ";--fa--fa:"īžŠīžŠ"}.fa-heart-circle{--fa:"";--fa--fa:""}.fa-heart-circle-bolt{--fa:"î“ŧ";--fa--fa:"î“ŧî“ŧ"}.fa-heart-circle-check{--fa:"î“Ŋ";--fa--fa:"î“Ŋî“Ŋ"}.fa-heart-circle-exclamation{--fa:"";--fa--fa:""}.fa-heart-circle-minus{--fa:"î“ŋ";--fa--fa:"î“ŋî“ŋ"}.fa-heart-circle-plus{--fa:"";--fa--fa:""}.fa-heart-circle-xmark{--fa:"";--fa--fa:""}.fa-heart-crack{--fa:"īžŠ";--fa--fa:"īžŠīžŠ"}.fa-heart-half{--fa:"î†Ģ";--fa--fa:"î†Ģî†Ģ"}.fa-heart-half-alt,.fa-heart-half-stroke{--fa:"î†Ŧ";--fa--fa:"î†Ŧî†Ŧ"}.fa-heart-music-camera-bolt{--fa:"īĄ­";--fa--fa:"īĄ­īĄ­"}.fa-heart-pulse{--fa:"īˆž";--fa--fa:"īˆžīˆž"}.fa-heart-rate{--fa:"ī—¸";--fa--fa:""}.fa-heart-square{--fa:"ī“ˆ";--fa--fa:"ī“ˆī“ˆ"}.fa-heartbeat{--fa:"īˆž";--fa--fa:"īˆžīˆž"}.fa-heat{--fa:"";--fa--fa:""}.fa-helicopter{--fa:"";--fa--fa:""}.fa-helicopter-symbol{--fa:"";--fa--fa:""}.fa-helmet-battle{--fa:"ī›Ģ";--fa--fa:"ī›Ģī›Ģ"}.fa-helmet-safety{--fa:"ī ‡";--fa--fa:""}.fa-helmet-un{--fa:"";--fa--fa:""}.fa-hexagon{--fa:"īŒ’";--fa--fa:"īŒ’īŒ’"}.fa-hexagon-check{--fa:"";--fa--fa:""}.fa-hexagon-divide{--fa:"";--fa--fa:""}.fa-hexagon-exclamation{--fa:"";--fa--fa:""}.fa-hexagon-image{--fa:"";--fa--fa:""}.fa-hexagon-minus{--fa:"īŒ‡";--fa--fa:"īŒ‡īŒ‡"}.fa-hexagon-nodes{--fa:"";--fa--fa:""}.fa-hexagon-nodes-bolt{--fa:"";--fa--fa:""}.fa-hexagon-plus{--fa:"īŒ€";--fa--fa:"īŒ€īŒ€"}.fa-hexagon-vertical-nft{--fa:"";--fa--fa:""}.fa-hexagon-vertical-nft-slanted{--fa:"";--fa--fa:""}.fa-hexagon-xmark{--fa:"ī‹Ž";--fa--fa:"ī‹Žī‹Ž"}.fa-high-definition{--fa:"";--fa--fa:""}.fa-highlighter{--fa:"ī–‘";--fa--fa:"ī–‘ī–‘"}.fa-highlighter-line{--fa:"";--fa--fa:""}.fa-hiking{--fa:"ī›Ŧ";--fa--fa:"ī›Ŧī›Ŧ"}.fa-hill-avalanche{--fa:"";--fa--fa:""}.fa-hill-rockslide{--fa:"";--fa--fa:""}.fa-hippo{--fa:"ī›­";--fa--fa:"ī›­ī›­"}.fa-history{--fa:"ī‡š";--fa--fa:"ī‡šī‡š"}.fa-hockey-mask{--fa:"ī›Ž";--fa--fa:"ī›Žī›Ž"}.fa-hockey-puck{--fa:"ī‘“";--fa--fa:"ī‘“ī‘“"}.fa-hockey-stick-puck{--fa:"";--fa--fa:""}.fa-hockey-sticks{--fa:"ī‘”";--fa--fa:""}.fa-holly-berry{--fa:"īžĒ";--fa--fa:"īžĒīžĒ"}.fa-home,.fa-home-alt{--fa:"";--fa--fa:""}.fa-home-blank{--fa:"";--fa--fa:""}.fa-home-heart{--fa:"";--fa--fa:""}.fa-home-lg{--fa:"";--fa--fa:""}.fa-home-lg-alt{--fa:"";--fa--fa:""}.fa-home-user{--fa:"";--fa--fa:""}.fa-honey-pot{--fa:"";--fa--fa:""}.fa-hood-cloak{--fa:"";--fa--fa:""}.fa-horizontal-rule{--fa:"īĄŦ";--fa--fa:"īĄŦīĄŦ"}.fa-horse{--fa:"ī›°";--fa--fa:"ī›°ī›°"}.fa-horse-head{--fa:"īžĢ";--fa--fa:"īžĢīžĢ"}.fa-horse-saddle{--fa:"";--fa--fa:""}.fa-hose{--fa:"";--fa--fa:""}.fa-hose-reel{--fa:"";--fa--fa:""}.fa-hospital,.fa-hospital-alt{--fa:"";--fa--fa:""}.fa-hospital-symbol{--fa:"ī‘ž";--fa--fa:"ī‘žī‘ž"}.fa-hospital-user{--fa:"ī ";--fa--fa:"ī ī "}.fa-hospital-wide{--fa:"";--fa--fa:""}.fa-hospitals{--fa:"ī Ž";--fa--fa:"ī Žī Ž"}.fa-hot-tub,.fa-hot-tub-person{--fa:"ī–“";--fa--fa:"ī–“ī–“"}.fa-hotdog{--fa:"ī ";--fa--fa:"ī ī "}.fa-hotel{--fa:"ī–”";--fa--fa:""}.fa-hourglass{--fa:"";--fa--fa:""}.fa-hourglass-1{--fa:"";--fa--fa:""}.fa-hourglass-2{--fa:"";--fa--fa:""}.fa-hourglass-3{--fa:"";--fa--fa:""}.fa-hourglass-clock{--fa:"";--fa--fa:""}.fa-hourglass-empty{--fa:"";--fa--fa:""}.fa-hourglass-end{--fa:"";--fa--fa:""}.fa-hourglass-half{--fa:"";--fa--fa:""}.fa-hourglass-start{--fa:"";--fa--fa:""}.fa-house{--fa:"";--fa--fa:""}.fa-house-blank{--fa:"";--fa--fa:""}.fa-house-building{--fa:"";--fa--fa:""}.fa-house-chimney{--fa:"";--fa--fa:""}.fa-house-chimney-blank{--fa:"";--fa--fa:""}.fa-house-chimney-crack{--fa:"ī›ą";--fa--fa:"ī›ąī›ą"}.fa-house-chimney-heart{--fa:"";--fa--fa:""}.fa-house-chimney-medical{--fa:"";--fa--fa:""}.fa-house-chimney-user{--fa:"îĨ";--fa--fa:"îĨîĨ"}.fa-house-chimney-window{--fa:"";--fa--fa:""}.fa-house-circle-check{--fa:"";--fa--fa:""}.fa-house-circle-exclamation{--fa:"";--fa--fa:""}.fa-house-circle-xmark{--fa:"";--fa--fa:""}.fa-house-crack{--fa:"";--fa--fa:""}.fa-house-damage{--fa:"ī›ą";--fa--fa:"ī›ąī›ą"}.fa-house-day{--fa:"";--fa--fa:""}.fa-house-fire{--fa:"";--fa--fa:""}.fa-house-flag{--fa:"";--fa--fa:""}.fa-house-flood{--fa:"ī";--fa--fa:"īī"}.fa-house-flood-water{--fa:"";--fa--fa:""}.fa-house-flood-water-circle-arrow-right{--fa:"";--fa--fa:""}.fa-house-heart{--fa:"";--fa--fa:""}.fa-house-laptop{--fa:"îĻ";--fa--fa:"îĻîĻ"}.fa-house-leave{--fa:"";--fa--fa:""}.fa-house-lock{--fa:"";--fa--fa:""}.fa-house-medical{--fa:"";--fa--fa:""}.fa-house-medical-circle-check{--fa:"";--fa--fa:""}.fa-house-medical-circle-exclamation{--fa:"";--fa--fa:""}.fa-house-medical-circle-xmark{--fa:"";--fa--fa:""}.fa-house-medical-flag{--fa:"";--fa--fa:""}.fa-house-night{--fa:"";--fa--fa:""}.fa-house-person-arrive{--fa:"";--fa--fa:""}.fa-house-person-depart,.fa-house-person-leave{--fa:"";--fa--fa:""}.fa-house-person-return,.fa-house-return{--fa:"";--fa--fa:""}.fa-house-signal{--fa:"";--fa--fa:""}.fa-house-tree{--fa:"î†ŗ";--fa--fa:"î†ŗî†ŗ"}.fa-house-tsunami{--fa:"";--fa--fa:""}.fa-house-turret{--fa:"";--fa--fa:""}.fa-house-user{--fa:"";--fa--fa:""}.fa-house-water{--fa:"ī";--fa--fa:"īī"}.fa-house-window{--fa:"îŽŗ";--fa--fa:"îŽŗîŽŗ"}.fa-hryvnia,.fa-hryvnia-sign{--fa:"";--fa--fa:""}.fa-humidity{--fa:"ī";--fa--fa:"īī"}.fa-hundred-points{--fa:"";--fa--fa:""}.fa-hurricane{--fa:"ī‘";--fa--fa:"ī‘ī‘"}.fa-hydra{--fa:"";--fa--fa:""}.fa-hyphen{--fa:"-";--fa--fa:"--"}.fa-i{--fa:"I";--fa--fa:"II"}.fa-i-cursor{--fa:"";--fa--fa:""}.fa-ice-cream{--fa:"";--fa--fa:""}.fa-ice-skate{--fa:"īžŦ";--fa--fa:"īžŦīžŦ"}.fa-icicles{--fa:"īž­";--fa--fa:"īž­īž­"}.fa-icons{--fa:"īĄ­";--fa--fa:"īĄ­īĄ­"}.fa-icons-alt{--fa:"īĄŽ";--fa--fa:"īĄŽīĄŽ"}.fa-id-badge{--fa:"";--fa--fa:""}.fa-id-card{--fa:"ī‹‚";--fa--fa:"ī‹‚ī‹‚"}.fa-id-card-alt,.fa-id-card-clip{--fa:"ī‘ŋ";--fa--fa:"ī‘ŋī‘ŋ"}.fa-igloo{--fa:"īžŽ";--fa--fa:"īžŽīžŽ"}.fa-ils{--fa:"īˆ‹";--fa--fa:"īˆ‹īˆ‹"}.fa-image{--fa:"ī€ž";--fa--fa:"ī€žī€ž"}.fa-image-landscape{--fa:"î†ĩ";--fa--fa:"î†ĩî†ĩ"}.fa-image-polaroid{--fa:"īŖ„";--fa--fa:"īŖ„īŖ„"}.fa-image-polaroid-user{--fa:"î†ļ";--fa--fa:"î†ļî†ļ"}.fa-image-portrait{--fa:"ī ";--fa--fa:"ī ī "}.fa-image-slash{--fa:"";--fa--fa:""}.fa-image-user{--fa:"";--fa--fa:""}.fa-images{--fa:"īŒ‚";--fa--fa:"īŒ‚īŒ‚"}.fa-images-user{--fa:"";--fa--fa:""}.fa-inbox{--fa:"ī€œ";--fa--fa:"ī€œī€œ"}.fa-inbox-arrow-down{--fa:"";--fa--fa:""}.fa-inbox-arrow-up{--fa:"īŒ‘";--fa--fa:"īŒ‘īŒ‘"}.fa-inbox-full{--fa:"î†ē";--fa--fa:"î†ēî†ē"}.fa-inbox-in{--fa:"";--fa--fa:""}.fa-inbox-out{--fa:"īŒ‘";--fa--fa:"īŒ‘īŒ‘"}.fa-inboxes{--fa:"î†ģ";--fa--fa:"î†ģî†ģ"}.fa-indent{--fa:"ī€ŧ";--fa--fa:"ī€ŧī€ŧ"}.fa-indian-rupee,.fa-indian-rupee-sign{--fa:"î†ŧ";--fa--fa:"î†ŧî†ŧ"}.fa-industry{--fa:"ī‰ĩ";--fa--fa:"ī‰ĩī‰ĩ"}.fa-industry-alt,.fa-industry-windows{--fa:"īŽŗ";--fa--fa:"īŽŗīŽŗ"}.fa-infinity{--fa:"ī”´";--fa--fa:"ī”´ī”´"}.fa-info{--fa:"ī„Š";--fa--fa:"ī„Šī„Š"}.fa-info-circle{--fa:"";--fa--fa:""}.fa-info-square{--fa:"īŒ";--fa--fa:"īŒīŒ"}.fa-inhaler{--fa:"ī—š";--fa--fa:"ī—šī—š"}.fa-input-numeric{--fa:"î†Ŋ";--fa--fa:"î†Ŋî†Ŋ"}.fa-input-pipe{--fa:"";--fa--fa:""}.fa-input-text{--fa:"î†ŋ";--fa--fa:"î†ŋî†ŋ"}.fa-inr{--fa:"î†ŧ";--fa--fa:"î†ŧî†ŧ"}.fa-institution{--fa:"ī†œ";--fa--fa:"ī†œī†œ"}.fa-integral{--fa:"ī™§";--fa--fa:"ī™§ī™§"}.fa-interrobang{--fa:"î–ē";--fa--fa:"î–ēî–ē"}.fa-intersection{--fa:"";--fa--fa:""}.fa-inventory{--fa:"ī’€";--fa--fa:""}.fa-island-tree-palm,.fa-island-tropical{--fa:"ī ‘";--fa--fa:"ī ‘ī ‘"}.fa-italic{--fa:"";--fa--fa:""}.fa-j{--fa:"J";--fa--fa:"JJ"}.fa-jack-o-lantern{--fa:"īŒŽ";--fa--fa:"īŒŽīŒŽ"}.fa-jar{--fa:"";--fa--fa:""}.fa-jar-wheat{--fa:"";--fa--fa:""}.fa-jedi{--fa:"ī™Š";--fa--fa:"ī™Šī™Š"}.fa-jet-fighter{--fa:"īƒģ";--fa--fa:"īƒģīƒģ"}.fa-jet-fighter-up{--fa:"";--fa--fa:""}.fa-joint{--fa:"ī–•";--fa--fa:"ī–•ī–•"}.fa-journal-whills{--fa:"ī™Ē";--fa--fa:"ī™Ēī™Ē"}.fa-joystick{--fa:"īŖ…";--fa--fa:"īŖ…īŖ…"}.fa-jpy{--fa:"ī…—";--fa--fa:"ī…—ī…—"}.fa-jug{--fa:"īŖ†";--fa--fa:""}.fa-jug-bottle{--fa:"î—ģ";--fa--fa:"î—ģî—ģ"}.fa-jug-detergent{--fa:"";--fa--fa:""}.fa-k{--fa:"K";--fa--fa:"KK"}.fa-kaaba{--fa:"ī™Ģ";--fa--fa:"ī™Ģī™Ģ"}.fa-kazoo{--fa:"īŖ‡";--fa--fa:""}.fa-kerning{--fa:"īĄ¯";--fa--fa:"īĄ¯īĄ¯"}.fa-key{--fa:"ī‚„";--fa--fa:"ī‚„ī‚„"}.fa-key-skeleton{--fa:"";--fa--fa:""}.fa-key-skeleton-left-right{--fa:"";--fa--fa:""}.fa-keyboard{--fa:"ī„œ";--fa--fa:"ī„œī„œ"}.fa-keyboard-brightness{--fa:"";--fa--fa:""}.fa-keyboard-brightness-low{--fa:"";--fa--fa:""}.fa-keyboard-down{--fa:"";--fa--fa:""}.fa-keyboard-left{--fa:"";--fa--fa:""}.fa-keynote{--fa:"ī™Ŧ";--fa--fa:"ī™Ŧī™Ŧ"}.fa-khanda{--fa:"ī™­";--fa--fa:"ī™­ī™­"}.fa-kidneys{--fa:"ī—ģ";--fa--fa:"ī—ģī—ģ"}.fa-kip-sign{--fa:"";--fa--fa:""}.fa-kiss{--fa:"ī––";--fa--fa:"ī––ī––"}.fa-kiss-beam{--fa:"ī–—";--fa--fa:"ī–—ī–—"}.fa-kiss-wink-heart{--fa:"ī–˜";--fa--fa:"ī–˜ī–˜"}.fa-kit-medical{--fa:"ī‘š";--fa--fa:"ī‘šī‘š"}.fa-kitchen-set{--fa:"";--fa--fa:""}.fa-kite{--fa:"ī›´";--fa--fa:"ī›´ī›´"}.fa-kiwi-bird{--fa:"ī”ĩ";--fa--fa:"ī”ĩī”ĩ"}.fa-kiwi-fruit{--fa:"";--fa--fa:""}.fa-knife{--fa:"";--fa--fa:""}.fa-knife-kitchen{--fa:"ī›ĩ";--fa--fa:"ī›ĩī›ĩ"}.fa-krw{--fa:"ī…™";--fa--fa:""}.fa-l{--fa:"L";--fa--fa:"LL"}.fa-lacrosse-stick{--fa:"îŽĩ";--fa--fa:"îŽĩîŽĩ"}.fa-lacrosse-stick-ball{--fa:"îŽļ";--fa--fa:"îŽļîŽļ"}.fa-ladder-water{--fa:"ī—…";--fa--fa:"ī—…ī—…"}.fa-lambda{--fa:"ī™Ž";--fa--fa:"ī™Žī™Ž"}.fa-lamp{--fa:"ī“Š";--fa--fa:"ī“Šī“Š"}.fa-lamp-desk{--fa:"";--fa--fa:""}.fa-lamp-floor{--fa:"";--fa--fa:""}.fa-lamp-street{--fa:"";--fa--fa:""}.fa-land-mine-on{--fa:"";--fa--fa:""}.fa-landmark{--fa:"";--fa--fa:""}.fa-landmark-alt,.fa-landmark-dome{--fa:"ī’";--fa--fa:"ī’ī’"}.fa-landmark-flag{--fa:"";--fa--fa:""}.fa-landmark-magnifying-glass{--fa:"î˜ĸ";--fa--fa:"î˜ĸî˜ĸ"}.fa-landscape{--fa:"î†ĩ";--fa--fa:"î†ĩî†ĩ"}.fa-language{--fa:"ī†Ģ";--fa--fa:"ī†Ģī†Ģ"}.fa-laptop{--fa:"";--fa--fa:""}.fa-laptop-arrow-down{--fa:"";--fa--fa:""}.fa-laptop-binary{--fa:"";--fa--fa:""}.fa-laptop-code{--fa:"ī—ŧ";--fa--fa:"ī—ŧī—ŧ"}.fa-laptop-file{--fa:"";--fa--fa:""}.fa-laptop-house{--fa:"îĻ";--fa--fa:"îĻîĻ"}.fa-laptop-medical{--fa:"ī ’";--fa--fa:"ī ’ī ’"}.fa-laptop-mobile{--fa:"īĄē";--fa--fa:"īĄēīĄē"}.fa-laptop-slash{--fa:"";--fa--fa:""}.fa-lari-sign{--fa:"";--fa--fa:""}.fa-lasso{--fa:"";--fa--fa:""}.fa-lasso-sparkles{--fa:"";--fa--fa:""}.fa-laugh{--fa:"ī–™";--fa--fa:""}.fa-laugh-beam{--fa:"ī–š";--fa--fa:"ī–šī–š"}.fa-laugh-squint{--fa:"ī–›";--fa--fa:""}.fa-laugh-wink{--fa:"ī–œ";--fa--fa:"ī–œī–œ"}.fa-layer-group{--fa:"ī—Ŋ";--fa--fa:"ī—Ŋī—Ŋ"}.fa-layer-group-minus{--fa:"ī—ž";--fa--fa:"ī—žī—ž"}.fa-layer-group-plus{--fa:"ī—ŋ";--fa--fa:"ī—ŋī—ŋ"}.fa-layer-minus{--fa:"ī—ž";--fa--fa:"ī—žī—ž"}.fa-layer-plus{--fa:"ī—ŋ";--fa--fa:"ī—ŋī—ŋ"}.fa-leaf{--fa:"īŦ";--fa--fa:"īŦīŦ"}.fa-leaf-heart{--fa:"ī“‹";--fa--fa:"ī“‹ī“‹"}.fa-leaf-maple{--fa:"ī›ļ";--fa--fa:"ī›ļī›ļ"}.fa-leaf-oak{--fa:"";--fa--fa:""}.fa-leafy-green{--fa:"";--fa--fa:""}.fa-left{--fa:"ī•";--fa--fa:"ī•ī•"}.fa-left-from-bracket{--fa:"î™Ŧ";--fa--fa:"î™Ŧî™Ŧ"}.fa-left-from-line{--fa:"īˆ";--fa--fa:"īˆīˆ"}.fa-left-long{--fa:"";--fa--fa:""}.fa-left-long-to-line{--fa:"";--fa--fa:""}.fa-left-right{--fa:"";--fa--fa:""}.fa-left-to-bracket{--fa:"";--fa--fa:""}.fa-left-to-line{--fa:"ī‹";--fa--fa:"ī‹ī‹"}.fa-legal{--fa:"";--fa--fa:""}.fa-lemon{--fa:"ī‚”";--fa--fa:""}.fa-less-than{--fa:"<";--fa--fa:"<<"}.fa-less-than-equal{--fa:"";--fa--fa:""}.fa-level-down{--fa:"ī…‰";--fa--fa:""}.fa-level-down-alt{--fa:"īŽž";--fa--fa:"īŽžīŽž"}.fa-level-up{--fa:"ī…ˆ";--fa--fa:"ī…ˆī…ˆ"}.fa-level-up-alt{--fa:"īŽŋ";--fa--fa:"īŽŋīŽŋ"}.fa-life-ring{--fa:"ī‡";--fa--fa:"ī‡ī‡"}.fa-light-ceiling{--fa:"";--fa--fa:""}.fa-light-emergency{--fa:"";--fa--fa:""}.fa-light-emergency-on{--fa:"";--fa--fa:""}.fa-light-switch{--fa:"";--fa--fa:""}.fa-light-switch-off{--fa:"";--fa--fa:""}.fa-light-switch-on{--fa:"";--fa--fa:""}.fa-lightbulb{--fa:"īƒĢ";--fa--fa:"īƒĢīƒĢ"}.fa-lightbulb-cfl{--fa:"î–Ļ";--fa--fa:"î–Ļî–Ļ"}.fa-lightbulb-cfl-on{--fa:"";--fa--fa:""}.fa-lightbulb-dollar{--fa:"ī™°";--fa--fa:"ī™°ī™°"}.fa-lightbulb-exclamation{--fa:"ī™ą";--fa--fa:"ī™ąī™ą"}.fa-lightbulb-exclamation-on{--fa:"";--fa--fa:""}.fa-lightbulb-gear{--fa:"î—Ŋ";--fa--fa:"î—Ŋî—Ŋ"}.fa-lightbulb-message{--fa:"";--fa--fa:""}.fa-lightbulb-on{--fa:"";--fa--fa:""}.fa-lightbulb-slash{--fa:"";--fa--fa:""}.fa-lighthouse{--fa:"";--fa--fa:""}.fa-lights-holiday{--fa:"īž˛";--fa--fa:"īž˛īž˛"}.fa-line-chart{--fa:"";--fa--fa:""}.fa-line-columns{--fa:"īĄ°";--fa--fa:"īĄ°īĄ°"}.fa-line-height{--fa:"īĄą";--fa--fa:"īĄąīĄą"}.fa-lines-leaning{--fa:"";--fa--fa:""}.fa-link{--fa:"";--fa--fa:""}.fa-link-horizontal{--fa:"";--fa--fa:""}.fa-link-horizontal-slash{--fa:"";--fa--fa:""}.fa-link-simple{--fa:"";--fa--fa:""}.fa-link-simple-slash{--fa:"";--fa--fa:""}.fa-link-slash{--fa:"ī„§";--fa--fa:"ī„§ī„§"}.fa-lips{--fa:"ī˜€";--fa--fa:"ī˜€ī˜€"}.fa-lira-sign{--fa:"";--fa--fa:""}.fa-list{--fa:"ī€ē";--fa--fa:"ī€ēī€ē"}.fa-list-1-2{--fa:"īƒ‹";--fa--fa:"īƒ‹īƒ‹"}.fa-list-alt{--fa:"ī€ĸ";--fa--fa:"ī€ĸī€ĸ"}.fa-list-check{--fa:"ī‚Ž";--fa--fa:"ī‚Žī‚Ž"}.fa-list-dots{--fa:"";--fa--fa:""}.fa-list-dropdown{--fa:"";--fa--fa:""}.fa-list-music{--fa:"īŖ‰";--fa--fa:""}.fa-list-numeric,.fa-list-ol{--fa:"īƒ‹";--fa--fa:"īƒ‹īƒ‹"}.fa-list-radio{--fa:"";--fa--fa:""}.fa-list-squares{--fa:"ī€ē";--fa--fa:"ī€ēī€ē"}.fa-list-timeline{--fa:"";--fa--fa:""}.fa-list-tree{--fa:"";--fa--fa:""}.fa-list-ul{--fa:"";--fa--fa:""}.fa-litecoin-sign{--fa:"";--fa--fa:""}.fa-loader{--fa:"";--fa--fa:""}.fa-lobster{--fa:"";--fa--fa:""}.fa-location{--fa:"";--fa--fa:""}.fa-location-arrow{--fa:"";--fa--fa:""}.fa-location-arrow-up{--fa:"î˜ē";--fa--fa:"î˜ēî˜ē"}.fa-location-check{--fa:"ī˜†";--fa--fa:"ī˜†ī˜†"}.fa-location-circle{--fa:"ī˜‚";--fa--fa:"ī˜‚ī˜‚"}.fa-location-crosshairs{--fa:"";--fa--fa:""}.fa-location-crosshairs-slash{--fa:"";--fa--fa:""}.fa-location-dot{--fa:"ī…";--fa--fa:"ī…ī…"}.fa-location-dot-slash{--fa:"ī˜…";--fa--fa:"ī˜…ī˜…"}.fa-location-exclamation{--fa:"";--fa--fa:""}.fa-location-minus{--fa:"ī˜‰";--fa--fa:"ī˜‰ī˜‰"}.fa-location-pen{--fa:"ī˜‡";--fa--fa:"ī˜‡ī˜‡"}.fa-location-pin{--fa:"";--fa--fa:""}.fa-location-pin-lock{--fa:"";--fa--fa:""}.fa-location-pin-slash{--fa:"";--fa--fa:""}.fa-location-plus{--fa:"";--fa--fa:""}.fa-location-question{--fa:"ī˜‹";--fa--fa:"ī˜‹ī˜‹"}.fa-location-slash{--fa:"";--fa--fa:""}.fa-location-smile{--fa:"ī˜";--fa--fa:"ī˜ī˜"}.fa-location-xmark{--fa:"ī˜Ž";--fa--fa:"ī˜Žī˜Ž"}.fa-lock{--fa:"";--fa--fa:""}.fa-lock-a{--fa:"îĸ";--fa--fa:"îĸîĸ"}.fa-lock-alt{--fa:"īŒ";--fa--fa:"īŒīŒ"}.fa-lock-hashtag{--fa:"îŖ";--fa--fa:"îŖîŖ"}.fa-lock-keyhole{--fa:"īŒ";--fa--fa:"īŒīŒ"}.fa-lock-keyhole-open{--fa:"ī‚";--fa--fa:"ī‚ī‚"}.fa-lock-open{--fa:"ī";--fa--fa:"īī"}.fa-lock-open-alt{--fa:"ī‚";--fa--fa:"ī‚ī‚"}.fa-locust{--fa:"";--fa--fa:""}.fa-lollipop,.fa-lollypop{--fa:"";--fa--fa:""}.fa-long-arrow-alt-down{--fa:"īŒ‰";--fa--fa:"īŒ‰īŒ‰"}.fa-long-arrow-alt-left{--fa:"";--fa--fa:""}.fa-long-arrow-alt-right{--fa:"īŒ‹";--fa--fa:"īŒ‹īŒ‹"}.fa-long-arrow-alt-up{--fa:"";--fa--fa:""}.fa-long-arrow-down{--fa:"ī…ĩ";--fa--fa:"ī…ĩī…ĩ"}.fa-long-arrow-left{--fa:"ī…ˇ";--fa--fa:""}.fa-long-arrow-right{--fa:"ī…¸";--fa--fa:""}.fa-long-arrow-up{--fa:"ī…ļ";--fa--fa:"ī…ļī…ļ"}.fa-loveseat{--fa:"ī“Œ";--fa--fa:"ī“Œī“Œ"}.fa-low-vision{--fa:"";--fa--fa:""}.fa-luchador,.fa-luchador-mask{--fa:"ī‘•";--fa--fa:"ī‘•ī‘•"}.fa-luggage-cart{--fa:"ī–";--fa--fa:"ī–ī–"}.fa-lungs{--fa:"ī˜„";--fa--fa:"ī˜„ī˜„"}.fa-lungs-virus{--fa:"";--fa--fa:""}.fa-m{--fa:"M";--fa--fa:"MM"}.fa-mace{--fa:"";--fa--fa:""}.fa-magic{--fa:"";--fa--fa:""}.fa-magic-wand-sparkles{--fa:"";--fa--fa:""}.fa-magnet{--fa:"īļ";--fa--fa:"īļīļ"}.fa-magnifying-glass{--fa:"";--fa--fa:""}.fa-magnifying-glass-arrow-right{--fa:"";--fa--fa:""}.fa-magnifying-glass-arrows-rotate{--fa:"";--fa--fa:""}.fa-magnifying-glass-chart{--fa:"î”ĸ";--fa--fa:"î”ĸî”ĸ"}.fa-magnifying-glass-dollar{--fa:"";--fa--fa:""}.fa-magnifying-glass-location{--fa:"īš‰";--fa--fa:"īš‰īš‰"}.fa-magnifying-glass-minus{--fa:"";--fa--fa:""}.fa-magnifying-glass-music{--fa:"";--fa--fa:""}.fa-magnifying-glass-play{--fa:"";--fa--fa:""}.fa-magnifying-glass-plus{--fa:"ī€Ž";--fa--fa:"ī€Žī€Ž"}.fa-magnifying-glass-waveform{--fa:"";--fa--fa:""}.fa-mail-bulk{--fa:"ī™´";--fa--fa:"ī™´ī™´"}.fa-mail-forward{--fa:"";--fa--fa:""}.fa-mail-reply{--fa:"īĨ";--fa--fa:"īĨīĨ"}.fa-mail-reply-all{--fa:"ī„ĸ";--fa--fa:"ī„ĸī„ĸ"}.fa-mailbox{--fa:"ī “";--fa--fa:"ī “ī “"}.fa-mailbox-flag-up{--fa:"î–ģ";--fa--fa:"î–ģî–ģ"}.fa-maki-roll,.fa-makizushi{--fa:"";--fa--fa:""}.fa-male{--fa:"ī†ƒ";--fa--fa:"ī†ƒī†ƒ"}.fa-manat-sign{--fa:"";--fa--fa:""}.fa-mandolin{--fa:"ī›š";--fa--fa:"ī›šī›š"}.fa-mango{--fa:"";--fa--fa:""}.fa-manhole{--fa:"";--fa--fa:""}.fa-map{--fa:"ī‰š";--fa--fa:"ī‰šī‰š"}.fa-map-location{--fa:"ī–Ÿ";--fa--fa:"ī–Ÿī–Ÿ"}.fa-map-location-dot{--fa:"ī– ";--fa--fa:"ī– ī– "}.fa-map-marked{--fa:"ī–Ÿ";--fa--fa:"ī–Ÿī–Ÿ"}.fa-map-marked-alt{--fa:"ī– ";--fa--fa:"ī– ī– "}.fa-map-marker{--fa:"";--fa--fa:""}.fa-map-marker-alt{--fa:"ī…";--fa--fa:"ī…ī…"}.fa-map-marker-alt-slash{--fa:"ī˜…";--fa--fa:"ī˜…ī˜…"}.fa-map-marker-check{--fa:"ī˜†";--fa--fa:"ī˜†ī˜†"}.fa-map-marker-edit{--fa:"ī˜‡";--fa--fa:"ī˜‡ī˜‡"}.fa-map-marker-exclamation{--fa:"";--fa--fa:""}.fa-map-marker-minus{--fa:"ī˜‰";--fa--fa:"ī˜‰ī˜‰"}.fa-map-marker-plus{--fa:"";--fa--fa:""}.fa-map-marker-question{--fa:"ī˜‹";--fa--fa:"ī˜‹ī˜‹"}.fa-map-marker-slash{--fa:"";--fa--fa:""}.fa-map-marker-smile{--fa:"ī˜";--fa--fa:"ī˜ī˜"}.fa-map-marker-times,.fa-map-marker-xmark{--fa:"ī˜Ž";--fa--fa:"ī˜Žī˜Ž"}.fa-map-pin{--fa:"ī‰ļ";--fa--fa:"ī‰ļī‰ļ"}.fa-map-signs{--fa:"";--fa--fa:""}.fa-marker{--fa:"ī–Ą";--fa--fa:"ī–Ąī–Ą"}.fa-mars{--fa:"īˆĸ";--fa--fa:"īˆĸīˆĸ"}.fa-mars-and-venus{--fa:"";--fa--fa:""}.fa-mars-and-venus-burst{--fa:"î”Ŗ";--fa--fa:"î”Ŗî”Ŗ"}.fa-mars-double{--fa:"";--fa--fa:""}.fa-mars-stroke{--fa:"īˆŠ";--fa--fa:"īˆŠīˆŠ"}.fa-mars-stroke-h,.fa-mars-stroke-right{--fa:"īˆĢ";--fa--fa:"īˆĢīˆĢ"}.fa-mars-stroke-up,.fa-mars-stroke-v{--fa:"īˆĒ";--fa--fa:"īˆĒīˆĒ"}.fa-martini-glass{--fa:"ī•ģ";--fa--fa:"ī•ģī•ģ"}.fa-martini-glass-citrus{--fa:"ī•Ą";--fa--fa:"ī•Ąī•Ą"}.fa-martini-glass-empty{--fa:"";--fa--fa:""}.fa-mask{--fa:"ī›ē";--fa--fa:"ī›ēī›ē"}.fa-mask-face{--fa:"";--fa--fa:""}.fa-mask-luchador{--fa:"ī‘•";--fa--fa:"ī‘•ī‘•"}.fa-mask-snorkel{--fa:"";--fa--fa:""}.fa-mask-ventilator{--fa:"";--fa--fa:""}.fa-masks-theater{--fa:"";--fa--fa:""}.fa-mattress-pillow{--fa:"î”Ĩ";--fa--fa:"î”Ĩî”Ĩ"}.fa-maximize{--fa:"īŒž";--fa--fa:"īŒžīŒž"}.fa-meat{--fa:"ī ”";--fa--fa:""}.fa-medal{--fa:"ī–ĸ";--fa--fa:"ī–ĸī–ĸ"}.fa-medkit{--fa:"īƒē";--fa--fa:"īƒēīƒē"}.fa-megaphone{--fa:"ī™ĩ";--fa--fa:"ī™ĩī™ĩ"}.fa-meh{--fa:"ī„š";--fa--fa:"ī„šī„š"}.fa-meh-blank{--fa:"ī–¤";--fa--fa:""}.fa-meh-rolling-eyes{--fa:"ī–Ĩ";--fa--fa:"ī–Ĩī–Ĩ"}.fa-melon{--fa:"";--fa--fa:""}.fa-melon-slice{--fa:"";--fa--fa:""}.fa-memo{--fa:"";--fa--fa:""}.fa-memo-circle-check{--fa:"";--fa--fa:""}.fa-memo-circle-info{--fa:"";--fa--fa:""}.fa-memo-pad{--fa:"";--fa--fa:""}.fa-memory{--fa:"";--fa--fa:""}.fa-menorah{--fa:"ī™ļ";--fa--fa:"ī™ļī™ļ"}.fa-mercury{--fa:"";--fa--fa:""}.fa-merge{--fa:"î”Ļ";--fa--fa:"î”Ļî”Ļ"}.fa-message{--fa:"ī‰ē";--fa--fa:"ī‰ēī‰ē"}.fa-message-arrow-down{--fa:"";--fa--fa:""}.fa-message-arrow-up{--fa:"";--fa--fa:""}.fa-message-arrow-up-right{--fa:"";--fa--fa:""}.fa-message-bot{--fa:"";--fa--fa:""}.fa-message-captions{--fa:"";--fa--fa:""}.fa-message-check{--fa:"ī’ĸ";--fa--fa:"ī’ĸī’ĸ"}.fa-message-code{--fa:"";--fa--fa:""}.fa-message-dollar{--fa:"";--fa--fa:""}.fa-message-dots{--fa:"ī’Ŗ";--fa--fa:"ī’Ŗī’Ŗ"}.fa-message-edit{--fa:"ī’¤";--fa--fa:""}.fa-message-exclamation{--fa:"ī’Ĩ";--fa--fa:"ī’Ĩī’Ĩ"}.fa-message-heart{--fa:"";--fa--fa:""}.fa-message-image{--fa:"";--fa--fa:""}.fa-message-lines{--fa:"ī’Ļ";--fa--fa:"ī’Ļī’Ļ"}.fa-message-medical{--fa:"";--fa--fa:""}.fa-message-middle{--fa:"";--fa--fa:""}.fa-message-middle-top{--fa:"î‡ĸ";--fa--fa:"î‡ĸî‡ĸ"}.fa-message-minus{--fa:"ī’§";--fa--fa:"ī’§ī’§"}.fa-message-music{--fa:"īĸ¯";--fa--fa:"īĸ¯īĸ¯"}.fa-message-pen{--fa:"ī’¤";--fa--fa:""}.fa-message-plus{--fa:"ī’¨";--fa--fa:""}.fa-message-question{--fa:"î‡Ŗ";--fa--fa:"î‡Ŗî‡Ŗ"}.fa-message-quote{--fa:"";--fa--fa:""}.fa-message-slash{--fa:"ī’Š";--fa--fa:"ī’Šī’Š"}.fa-message-smile{--fa:"ī’Ē";--fa--fa:"ī’Ēī’Ē"}.fa-message-sms{--fa:"î‡Ĩ";--fa--fa:"î‡Ĩî‡Ĩ"}.fa-message-text{--fa:"î‡Ļ";--fa--fa:"î‡Ļî‡Ļ"}.fa-message-times,.fa-message-xmark{--fa:"ī’Ģ";--fa--fa:"ī’Ģī’Ģ"}.fa-messages{--fa:"ī’ļ";--fa--fa:"ī’ļī’ļ"}.fa-messages-dollar{--fa:"ī™’";--fa--fa:"ī™’ī™’"}.fa-messages-question{--fa:"";--fa--fa:""}.fa-messaging{--fa:"ī’Ŗ";--fa--fa:"ī’Ŗī’Ŗ"}.fa-meteor{--fa:"ī“";--fa--fa:"ī“ī“"}.fa-meter{--fa:"";--fa--fa:""}.fa-meter-bolt{--fa:"";--fa--fa:""}.fa-meter-droplet{--fa:"î‡Ē";--fa--fa:"î‡Ēî‡Ē"}.fa-meter-fire{--fa:"î‡Ģ";--fa--fa:"î‡Ģî‡Ģ"}.fa-microchip{--fa:"ī‹›";--fa--fa:""}.fa-microchip-ai{--fa:"î‡Ŧ";--fa--fa:"î‡Ŧî‡Ŧ"}.fa-microphone{--fa:"ī„°";--fa--fa:"ī„°ī„°"}.fa-microphone-alt{--fa:"ī‰";--fa--fa:"ī‰ī‰"}.fa-microphone-alt-slash{--fa:"ī”š";--fa--fa:"ī”šī”š"}.fa-microphone-circle{--fa:"";--fa--fa:""}.fa-microphone-circle-alt{--fa:"";--fa--fa:""}.fa-microphone-lines{--fa:"ī‰";--fa--fa:"ī‰ī‰"}.fa-microphone-lines-slash{--fa:"ī”š";--fa--fa:"ī”šī”š"}.fa-microphone-slash{--fa:"ī„ą";--fa--fa:"ī„ąī„ą"}.fa-microphone-stand{--fa:"īŖ‹";--fa--fa:"īŖ‹īŖ‹"}.fa-microscope{--fa:"";--fa--fa:""}.fa-microwave{--fa:"";--fa--fa:""}.fa-mill-sign{--fa:"";--fa--fa:""}.fa-mind-share{--fa:"";--fa--fa:""}.fa-minimize{--fa:"īžŒ";--fa--fa:"īžŒīžŒ"}.fa-minus{--fa:"";--fa--fa:""}.fa-minus-circle{--fa:"";--fa--fa:""}.fa-minus-hexagon{--fa:"īŒ‡";--fa--fa:"īŒ‡īŒ‡"}.fa-minus-large{--fa:"";--fa--fa:""}.fa-minus-octagon{--fa:"";--fa--fa:""}.fa-minus-square{--fa:"ī…†";--fa--fa:""}.fa-mistletoe{--fa:"īž´";--fa--fa:"īž´īž´"}.fa-mitten{--fa:"īžĩ";--fa--fa:"īžĩīžĩ"}.fa-mobile{--fa:"īŽ";--fa--fa:"īŽīŽ"}.fa-mobile-alt{--fa:"ī";--fa--fa:"īī"}.fa-mobile-android{--fa:"īŽ";--fa--fa:"īŽīŽ"}.fa-mobile-android-alt{--fa:"ī";--fa--fa:"īī"}.fa-mobile-button{--fa:"ī„‹";--fa--fa:"ī„‹ī„‹"}.fa-mobile-iphone,.fa-mobile-notch{--fa:"";--fa--fa:""}.fa-mobile-phone{--fa:"īŽ";--fa--fa:"īŽīŽ"}.fa-mobile-retro{--fa:"";--fa--fa:""}.fa-mobile-screen{--fa:"ī";--fa--fa:"īī"}.fa-mobile-screen-button{--fa:"ī";--fa--fa:"īī"}.fa-mobile-signal{--fa:"";--fa--fa:""}.fa-mobile-signal-out{--fa:"";--fa--fa:""}.fa-money-bill{--fa:"īƒ–";--fa--fa:"īƒ–īƒ–"}.fa-money-bill-1{--fa:"ī‘";--fa--fa:"ī‘ī‘"}.fa-money-bill-1-wave{--fa:"ī”ģ";--fa--fa:"ī”ģī”ģ"}.fa-money-bill-alt{--fa:"ī‘";--fa--fa:"ī‘ī‘"}.fa-money-bill-simple{--fa:"";--fa--fa:""}.fa-money-bill-simple-wave{--fa:"";--fa--fa:""}.fa-money-bill-transfer{--fa:"";--fa--fa:""}.fa-money-bill-trend-up{--fa:"";--fa--fa:""}.fa-money-bill-wave{--fa:"ī”ē";--fa--fa:"ī”ēī”ē"}.fa-money-bill-wave-alt{--fa:"ī”ģ";--fa--fa:"ī”ģī”ģ"}.fa-money-bill-wheat{--fa:"î”Ē";--fa--fa:"î”Ēî”Ē"}.fa-money-bills{--fa:"î‡ŗ";--fa--fa:"î‡ŗî‡ŗ"}.fa-money-bills-alt,.fa-money-bills-simple{--fa:"";--fa--fa:""}.fa-money-check{--fa:"ī”ŧ";--fa--fa:"ī”ŧī”ŧ"}.fa-money-check-alt,.fa-money-check-dollar{--fa:"ī”Ŋ";--fa--fa:"ī”Ŋī”Ŋ"}.fa-money-check-dollar-pen{--fa:"īĄŗ";--fa--fa:"īĄŗīĄŗ"}.fa-money-check-edit{--fa:"īĄ˛";--fa--fa:"īĄ˛īĄ˛"}.fa-money-check-edit-alt{--fa:"īĄŗ";--fa--fa:"īĄŗīĄŗ"}.fa-money-check-pen{--fa:"īĄ˛";--fa--fa:"īĄ˛īĄ˛"}.fa-money-from-bracket{--fa:"";--fa--fa:""}.fa-money-simple-from-bracket{--fa:"";--fa--fa:""}.fa-monitor-heart-rate,.fa-monitor-waveform{--fa:"ī˜‘";--fa--fa:"ī˜‘ī˜‘"}.fa-monkey{--fa:"ī›ģ";--fa--fa:"ī›ģī›ģ"}.fa-monument{--fa:"ī–Ļ";--fa--fa:"ī–Ļī–Ļ"}.fa-moon{--fa:"";--fa--fa:""}.fa-moon-cloud{--fa:"ī”";--fa--fa:"ī”ī”"}.fa-moon-over-sun{--fa:"īŠ";--fa--fa:"īŠīŠ"}.fa-moon-stars{--fa:"ī•";--fa--fa:"ī•ī•"}.fa-moped{--fa:"";--fa--fa:""}.fa-mortar-board{--fa:"ī†";--fa--fa:"ī†ī†"}.fa-mortar-pestle{--fa:"ī–§";--fa--fa:"ī–§ī–§"}.fa-mosque{--fa:"";--fa--fa:""}.fa-mosquito{--fa:"î”Ģ";--fa--fa:"î”Ģî”Ģ"}.fa-mosquito-net{--fa:"î”Ŧ";--fa--fa:"î”Ŧî”Ŧ"}.fa-motorcycle{--fa:"";--fa--fa:""}.fa-mound{--fa:"";--fa--fa:""}.fa-mountain{--fa:"ī›ŧ";--fa--fa:"ī›ŧī›ŧ"}.fa-mountain-city{--fa:"";--fa--fa:""}.fa-mountain-sun{--fa:"";--fa--fa:""}.fa-mountains{--fa:"ī›Ŋ";--fa--fa:"ī›Ŋī›Ŋ"}.fa-mouse{--fa:"";--fa--fa:""}.fa-mouse-alt{--fa:"īŖ";--fa--fa:"īŖīŖ"}.fa-mouse-field{--fa:"";--fa--fa:""}.fa-mouse-pointer{--fa:"";--fa--fa:""}.fa-mp3-player{--fa:"īŖŽ";--fa--fa:"īŖŽīŖŽ"}.fa-mug{--fa:"īĄ´";--fa--fa:"īĄ´īĄ´"}.fa-mug-hot{--fa:"īžļ";--fa--fa:"īžļīžļ"}.fa-mug-marshmallows{--fa:"īžˇ";--fa--fa:"īžˇīžˇ"}.fa-mug-saucer{--fa:"";--fa--fa:""}.fa-mug-tea{--fa:"īĄĩ";--fa--fa:"īĄĩīĄĩ"}.fa-mug-tea-saucer{--fa:"î‡ĩ";--fa--fa:"î‡ĩî‡ĩ"}.fa-multiply{--fa:"ī€";--fa--fa:"ī€ī€"}.fa-museum{--fa:"ī†œ";--fa--fa:"ī†œī†œ"}.fa-mushroom{--fa:"îĨ";--fa--fa:"îĨîĨ"}.fa-music{--fa:"";--fa--fa:""}.fa-music-alt{--fa:"īŖ";--fa--fa:"īŖīŖ"}.fa-music-alt-slash{--fa:"";--fa--fa:""}.fa-music-magnifying-glass{--fa:"î™ĸ";--fa--fa:"î™ĸî™ĸ"}.fa-music-note{--fa:"īŖ";--fa--fa:"īŖīŖ"}.fa-music-note-slash{--fa:"";--fa--fa:""}.fa-music-slash{--fa:"īŖ‘";--fa--fa:"īŖ‘īŖ‘"}.fa-mustache{--fa:"î–ŧ";--fa--fa:"î–ŧî–ŧ"}.fa-n{--fa:"N";--fa--fa:"NN"}.fa-naira-sign{--fa:"î‡ļ";--fa--fa:"î‡ļî‡ļ"}.fa-narwhal{--fa:"ī›ž";--fa--fa:"ī›žī›ž"}.fa-navicon{--fa:"īƒ‰";--fa--fa:"īƒ‰īƒ‰"}.fa-nesting-dolls{--fa:"îŽē";--fa--fa:"îŽēîŽē"}.fa-network-wired{--fa:"ī›ŋ";--fa--fa:"ī›ŋī›ŋ"}.fa-neuter{--fa:"īˆŦ";--fa--fa:"īˆŦīˆŦ"}.fa-newspaper{--fa:"ī‡Ē";--fa--fa:"ī‡Ēī‡Ē"}.fa-nfc{--fa:"";--fa--fa:""}.fa-nfc-lock{--fa:"";--fa--fa:""}.fa-nfc-magnifying-glass{--fa:"";--fa--fa:""}.fa-nfc-pen{--fa:"î‡ē";--fa--fa:"î‡ēî‡ē"}.fa-nfc-signal{--fa:"î‡ģ";--fa--fa:"î‡ģî‡ģ"}.fa-nfc-slash{--fa:"î‡ŧ";--fa--fa:"î‡ŧî‡ŧ"}.fa-nfc-symbol{--fa:"";--fa--fa:""}.fa-nfc-trash{--fa:"î‡Ŋ";--fa--fa:"î‡Ŋî‡Ŋ"}.fa-nigiri{--fa:"";--fa--fa:""}.fa-nose{--fa:"î–Ŋ";--fa--fa:"î–Ŋî–Ŋ"}.fa-not-equal{--fa:"ī”ž";--fa--fa:"ī”žī”ž"}.fa-notdef{--fa:"";--fa--fa:""}.fa-note{--fa:"î‡ŋ";--fa--fa:"î‡ŋî‡ŋ"}.fa-note-medical{--fa:"";--fa--fa:""}.fa-note-sticky{--fa:"";--fa--fa:""}.fa-notebook{--fa:"";--fa--fa:""}.fa-notes{--fa:"";--fa--fa:""}.fa-notes-medical{--fa:"";--fa--fa:""}.fa-o{--fa:"O";--fa--fa:"OO"}.fa-object-exclude{--fa:"";--fa--fa:""}.fa-object-group{--fa:"";--fa--fa:""}.fa-object-intersect{--fa:"";--fa--fa:""}.fa-object-subtract{--fa:"";--fa--fa:""}.fa-object-ungroup{--fa:"ī‰ˆ";--fa--fa:"ī‰ˆī‰ˆ"}.fa-object-union{--fa:"";--fa--fa:""}.fa-objects-align-bottom{--fa:"îŽģ";--fa--fa:"îŽģîŽģ"}.fa-objects-align-center-horizontal{--fa:"îŽŧ";--fa--fa:"îŽŧîŽŧ"}.fa-objects-align-center-vertical{--fa:"îŽŊ";--fa--fa:"îŽŊîŽŊ"}.fa-objects-align-left{--fa:"";--fa--fa:""}.fa-objects-align-right{--fa:"îŽŋ";--fa--fa:"îŽŋîŽŋ"}.fa-objects-align-top{--fa:"";--fa--fa:""}.fa-objects-column{--fa:"";--fa--fa:""}.fa-octagon{--fa:"īŒ†";--fa--fa:"īŒ†īŒ†"}.fa-octagon-check{--fa:"îĻ";--fa--fa:"îĻîĻ"}.fa-octagon-divide{--fa:"";--fa--fa:""}.fa-octagon-exclamation{--fa:"";--fa--fa:""}.fa-octagon-minus{--fa:"";--fa--fa:""}.fa-octagon-plus{--fa:"";--fa--fa:""}.fa-octagon-xmark{--fa:"ī‹°";--fa--fa:"ī‹°ī‹°"}.fa-octopus{--fa:"";--fa--fa:""}.fa-oil-can{--fa:"ī˜“";--fa--fa:"ī˜“ī˜“"}.fa-oil-can-drip{--fa:"";--fa--fa:""}.fa-oil-temp,.fa-oil-temperature{--fa:"ī˜”";--fa--fa:"ī˜”ī˜”"}.fa-oil-well{--fa:"";--fa--fa:""}.fa-olive{--fa:"";--fa--fa:""}.fa-olive-branch{--fa:"";--fa--fa:""}.fa-om{--fa:"ī™š";--fa--fa:"ī™šī™š"}.fa-omega{--fa:"ī™ē";--fa--fa:"ī™ēī™ē"}.fa-onion{--fa:"";--fa--fa:""}.fa-option{--fa:"";--fa--fa:""}.fa-ornament{--fa:"īž¸";--fa--fa:"īž¸īž¸"}.fa-otter{--fa:"īœ€";--fa--fa:"īœ€īœ€"}.fa-outdent{--fa:"ī€ģ";--fa--fa:"ī€ģī€ģ"}.fa-outlet{--fa:"";--fa--fa:""}.fa-oven{--fa:"";--fa--fa:""}.fa-overline{--fa:"īĄļ";--fa--fa:"īĄļīĄļ"}.fa-p{--fa:"P";--fa--fa:"PP"}.fa-page{--fa:"";--fa--fa:""}.fa-page-break{--fa:"īĄˇ";--fa--fa:"īĄˇīĄˇ"}.fa-page-caret-down{--fa:"";--fa--fa:""}.fa-page-caret-up{--fa:"îĒ";--fa--fa:"îĒîĒ"}.fa-pager{--fa:"ī •";--fa--fa:"ī •ī •"}.fa-paint-brush{--fa:"ī‡ŧ";--fa--fa:"ī‡ŧī‡ŧ"}.fa-paint-brush-alt,.fa-paint-brush-fine{--fa:"ī–Š";--fa--fa:"ī–Šī–Š"}.fa-paint-roller{--fa:"ī–Ē";--fa--fa:"ī–Ēī–Ē"}.fa-paintbrush{--fa:"ī‡ŧ";--fa--fa:"ī‡ŧī‡ŧ"}.fa-paintbrush-alt,.fa-paintbrush-fine{--fa:"ī–Š";--fa--fa:"ī–Šī–Š"}.fa-paintbrush-pencil{--fa:"";--fa--fa:""}.fa-palette{--fa:"ī”ŋ";--fa--fa:"ī”ŋī”ŋ"}.fa-palette-boxes{--fa:"ī’ƒ";--fa--fa:"ī’ƒī’ƒ"}.fa-pallet{--fa:"ī’‚";--fa--fa:"ī’‚ī’‚"}.fa-pallet-alt{--fa:"ī’ƒ";--fa--fa:"ī’ƒī’ƒ"}.fa-pallet-box{--fa:"";--fa--fa:""}.fa-pallet-boxes{--fa:"ī’ƒ";--fa--fa:"ī’ƒī’ƒ"}.fa-pan-food{--fa:"îĢ";--fa--fa:"îĢîĢ"}.fa-pan-frying{--fa:"îŦ";--fa--fa:"îŦîŦ"}.fa-pancakes{--fa:"";--fa--fa:""}.fa-panel-ews{--fa:"";--fa--fa:""}.fa-panel-fire{--fa:"";--fa--fa:""}.fa-panorama{--fa:"";--fa--fa:""}.fa-paper-plane{--fa:"ī‡˜";--fa--fa:"ī‡˜ī‡˜"}.fa-paper-plane-alt,.fa-paper-plane-top{--fa:"";--fa--fa:""}.fa-paperclip{--fa:"īƒ†";--fa--fa:"īƒ†īƒ†"}.fa-paperclip-vertical{--fa:"";--fa--fa:""}.fa-parachute-box{--fa:"ī“";--fa--fa:"ī“ī“"}.fa-paragraph{--fa:"ī‡";--fa--fa:"ī‡ī‡"}.fa-paragraph-left,.fa-paragraph-rtl{--fa:"īĄ¸";--fa--fa:"īĄ¸īĄ¸"}.fa-parentheses{--fa:"";--fa--fa:""}.fa-parenthesis{--fa:"(";--fa--fa:"(("}.fa-parking{--fa:"ī•€";--fa--fa:""}.fa-parking-circle{--fa:"ī˜•";--fa--fa:"ī˜•ī˜•"}.fa-parking-circle-slash{--fa:"ī˜–";--fa--fa:"ī˜–ī˜–"}.fa-parking-slash{--fa:"ī˜—";--fa--fa:"ī˜—ī˜—"}.fa-party-back{--fa:"";--fa--fa:""}.fa-party-bell{--fa:"";--fa--fa:""}.fa-party-horn{--fa:"";--fa--fa:""}.fa-passport{--fa:"ī–Ģ";--fa--fa:"ī–Ģī–Ģ"}.fa-pastafarianism{--fa:"ī™ģ";--fa--fa:"ī™ģī™ģ"}.fa-paste{--fa:"īƒĒ";--fa--fa:"īƒĒīƒĒ"}.fa-pause{--fa:"";--fa--fa:""}.fa-pause-circle{--fa:"īŠ‹";--fa--fa:"īŠ‹īŠ‹"}.fa-paw{--fa:"";--fa--fa:""}.fa-paw-alt{--fa:"";--fa--fa:""}.fa-paw-claws{--fa:"īœ‚";--fa--fa:"īœ‚īœ‚"}.fa-paw-simple{--fa:"";--fa--fa:""}.fa-peace{--fa:"ī™ŧ";--fa--fa:"ī™ŧī™ŧ"}.fa-peach{--fa:"";--fa--fa:""}.fa-peanut{--fa:"";--fa--fa:""}.fa-peanuts{--fa:"";--fa--fa:""}.fa-peapod{--fa:"";--fa--fa:""}.fa-pear{--fa:"";--fa--fa:""}.fa-pedestal{--fa:"";--fa--fa:""}.fa-pegasus{--fa:"";--fa--fa:""}.fa-pen{--fa:"īŒ„";--fa--fa:"īŒ„īŒ„"}.fa-pen-alt{--fa:"īŒ…";--fa--fa:"īŒ…īŒ…"}.fa-pen-alt-slash{--fa:"";--fa--fa:""}.fa-pen-circle{--fa:"";--fa--fa:""}.fa-pen-clip{--fa:"īŒ…";--fa--fa:"īŒ…īŒ…"}.fa-pen-clip-slash{--fa:"";--fa--fa:""}.fa-pen-fancy{--fa:"ī–Ŧ";--fa--fa:"ī–Ŧī–Ŧ"}.fa-pen-fancy-slash{--fa:"";--fa--fa:""}.fa-pen-field{--fa:"";--fa--fa:""}.fa-pen-line{--fa:"";--fa--fa:""}.fa-pen-nib{--fa:"ī–­";--fa--fa:"ī–­ī–­"}.fa-pen-nib-slash{--fa:"";--fa--fa:""}.fa-pen-paintbrush{--fa:"";--fa--fa:""}.fa-pen-ruler{--fa:"ī–Ž";--fa--fa:"ī–Žī–Ž"}.fa-pen-slash{--fa:"";--fa--fa:""}.fa-pen-square{--fa:"ī…‹";--fa--fa:"ī…‹ī…‹"}.fa-pen-swirl{--fa:"";--fa--fa:""}.fa-pen-to-square{--fa:"";--fa--fa:""}.fa-pencil,.fa-pencil-alt{--fa:"";--fa--fa:""}.fa-pencil-mechanical{--fa:"";--fa--fa:""}.fa-pencil-paintbrush{--fa:"";--fa--fa:""}.fa-pencil-ruler{--fa:"ī–Ž";--fa--fa:"ī–Žī–Ž"}.fa-pencil-slash{--fa:"";--fa--fa:""}.fa-pencil-square{--fa:"ī…‹";--fa--fa:"ī…‹ī…‹"}.fa-pennant{--fa:"ī‘–";--fa--fa:"ī‘–ī‘–"}.fa-people{--fa:"";--fa--fa:""}.fa-people-arrows,.fa-people-arrows-left-right{--fa:"";--fa--fa:""}.fa-people-carry,.fa-people-carry-box{--fa:"ī“Ž";--fa--fa:"ī“Žī“Ž"}.fa-people-dress{--fa:"";--fa--fa:""}.fa-people-dress-simple{--fa:"";--fa--fa:""}.fa-people-group{--fa:"î”ŗ";--fa--fa:"î”ŗî”ŗ"}.fa-people-line{--fa:"";--fa--fa:""}.fa-people-pants{--fa:"";--fa--fa:""}.fa-people-pants-simple{--fa:"";--fa--fa:""}.fa-people-pulling{--fa:"î”ĩ";--fa--fa:"î”ĩî”ĩ"}.fa-people-robbery{--fa:"î”ļ";--fa--fa:"î”ļî”ļ"}.fa-people-roof{--fa:"";--fa--fa:""}.fa-people-simple{--fa:"";--fa--fa:""}.fa-pepper{--fa:"";--fa--fa:""}.fa-pepper-hot{--fa:"ī –";--fa--fa:"ī –ī –"}.fa-percent,.fa-percentage{--fa:"%";--fa--fa:"%%"}.fa-period{--fa:".";--fa--fa:".."}.fa-person{--fa:"ī†ƒ";--fa--fa:"ī†ƒī†ƒ"}.fa-person-arrow-down-to-line{--fa:"";--fa--fa:""}.fa-person-arrow-up-from-line{--fa:"";--fa--fa:""}.fa-person-biking{--fa:"īĄŠ";--fa--fa:"īĄŠīĄŠ"}.fa-person-biking-mountain{--fa:"īĄ‹";--fa--fa:"īĄ‹īĄ‹"}.fa-person-booth{--fa:"ī–";--fa--fa:"ī–ī–"}.fa-person-breastfeeding{--fa:"î”ē";--fa--fa:"î”ēî”ē"}.fa-person-burst{--fa:"î”ģ";--fa--fa:"î”ģî”ģ"}.fa-person-cane{--fa:"î”ŧ";--fa--fa:"î”ŧî”ŧ"}.fa-person-carry,.fa-person-carry-box{--fa:"ī“";--fa--fa:"ī“ī“"}.fa-person-chalkboard{--fa:"î”Ŋ";--fa--fa:"î”Ŋî”Ŋ"}.fa-person-circle-check{--fa:"";--fa--fa:""}.fa-person-circle-exclamation{--fa:"î”ŋ";--fa--fa:"î”ŋî”ŋ"}.fa-person-circle-minus{--fa:"";--fa--fa:""}.fa-person-circle-plus{--fa:"";--fa--fa:""}.fa-person-circle-question{--fa:"";--fa--fa:""}.fa-person-circle-xmark{--fa:"";--fa--fa:""}.fa-person-digging{--fa:"īĄž";--fa--fa:"īĄžīĄž"}.fa-person-dolly{--fa:"";--fa--fa:""}.fa-person-dolly-empty{--fa:"ī“‘";--fa--fa:"ī“‘ī“‘"}.fa-person-dots-from-line{--fa:"ī‘°";--fa--fa:"ī‘°ī‘°"}.fa-person-dress{--fa:"";--fa--fa:""}.fa-person-dress-burst{--fa:"";--fa--fa:""}.fa-person-dress-fairy{--fa:"";--fa--fa:""}.fa-person-dress-simple{--fa:"";--fa--fa:""}.fa-person-drowning{--fa:"";--fa--fa:""}.fa-person-fairy{--fa:"";--fa--fa:""}.fa-person-falling{--fa:"";--fa--fa:""}.fa-person-falling-burst{--fa:"";--fa--fa:""}.fa-person-from-portal{--fa:"î€Ŗ";--fa--fa:"î€Ŗî€Ŗ"}.fa-person-half-dress{--fa:"";--fa--fa:""}.fa-person-harassing{--fa:"";--fa--fa:""}.fa-person-hiking{--fa:"ī›Ŧ";--fa--fa:"ī›Ŧī›Ŧ"}.fa-person-military-pointing{--fa:"";--fa--fa:""}.fa-person-military-rifle{--fa:"";--fa--fa:""}.fa-person-military-to-person{--fa:"";--fa--fa:""}.fa-person-pinball{--fa:"";--fa--fa:""}.fa-person-praying{--fa:"";--fa--fa:""}.fa-person-pregnant{--fa:"";--fa--fa:""}.fa-person-rays{--fa:"";--fa--fa:""}.fa-person-rifle{--fa:"";--fa--fa:""}.fa-person-running{--fa:"";--fa--fa:""}.fa-person-running-fast{--fa:"î—ŋ";--fa--fa:"î—ŋî—ŋ"}.fa-person-seat{--fa:"";--fa--fa:""}.fa-person-seat-reclined{--fa:"";--fa--fa:""}.fa-person-shelter{--fa:"";--fa--fa:""}.fa-person-sign{--fa:"ī—";--fa--fa:"ī—ī—"}.fa-person-simple{--fa:"";--fa--fa:""}.fa-person-skating{--fa:"īŸ…";--fa--fa:"īŸ…īŸ…"}.fa-person-ski-jumping{--fa:"īŸ‡";--fa--fa:"īŸ‡īŸ‡"}.fa-person-ski-lift{--fa:"";--fa--fa:""}.fa-person-skiing{--fa:"īŸ‰";--fa--fa:"īŸ‰īŸ‰"}.fa-person-skiing-nordic{--fa:"";--fa--fa:""}.fa-person-sledding{--fa:"īŸ‹";--fa--fa:"īŸ‹īŸ‹"}.fa-person-snowboarding{--fa:"īŸŽ";--fa--fa:"īŸŽīŸŽ"}.fa-person-snowmobiling{--fa:"īŸ‘";--fa--fa:"īŸ‘īŸ‘"}.fa-person-swimming{--fa:"ī—„";--fa--fa:"ī—„ī—„"}.fa-person-through-window{--fa:"";--fa--fa:""}.fa-person-to-door{--fa:"îŗ";--fa--fa:"îŗîŗ"}.fa-person-to-portal{--fa:"î€ĸ";--fa--fa:"î€ĸî€ĸ"}.fa-person-walking{--fa:"ī•”";--fa--fa:""}.fa-person-walking-arrow-loop-left{--fa:"";--fa--fa:""}.fa-person-walking-arrow-right{--fa:"";--fa--fa:""}.fa-person-walking-dashed-line-arrow-right{--fa:"";--fa--fa:""}.fa-person-walking-luggage{--fa:"";--fa--fa:""}.fa-person-walking-with-cane{--fa:"īŠ";--fa--fa:"īŠīŠ"}.fa-peseta-sign{--fa:"";--fa--fa:""}.fa-peso-sign{--fa:"îˆĸ";--fa--fa:"îˆĸîˆĸ"}.fa-phone{--fa:"ī‚•";--fa--fa:"ī‚•ī‚•"}.fa-phone-alt{--fa:"īĄš";--fa--fa:"īĄšīĄš"}.fa-phone-arrow-down,.fa-phone-arrow-down-left{--fa:"îˆŖ";--fa--fa:"îˆŖîˆŖ"}.fa-phone-arrow-right{--fa:"";--fa--fa:""}.fa-phone-arrow-up,.fa-phone-arrow-up-right{--fa:"";--fa--fa:""}.fa-phone-circle{--fa:"";--fa--fa:""}.fa-phone-circle-alt{--fa:"";--fa--fa:""}.fa-phone-circle-down{--fa:"";--fa--fa:""}.fa-phone-flip{--fa:"īĄš";--fa--fa:"īĄšīĄš"}.fa-phone-hangup{--fa:"îˆĨ";--fa--fa:"îˆĨîˆĨ"}.fa-phone-incoming{--fa:"îˆŖ";--fa--fa:"îˆŖîˆŖ"}.fa-phone-intercom{--fa:"";--fa--fa:""}.fa-phone-laptop{--fa:"īĄē";--fa--fa:"īĄēīĄē"}.fa-phone-missed{--fa:"îˆĻ";--fa--fa:"îˆĻîˆĻ"}.fa-phone-office{--fa:"ī™Ŋ";--fa--fa:"ī™Ŋī™Ŋ"}.fa-phone-outgoing{--fa:"";--fa--fa:""}.fa-phone-plus{--fa:"ī“’";--fa--fa:"ī“’ī“’"}.fa-phone-rotary{--fa:"īŖ“";--fa--fa:"īŖ“īŖ“"}.fa-phone-slash{--fa:"ī";--fa--fa:"īī"}.fa-phone-square{--fa:"ī‚˜";--fa--fa:"ī‚˜ī‚˜"}.fa-phone-square-alt{--fa:"īĄģ";--fa--fa:"īĄģīĄģ"}.fa-phone-square-down{--fa:"î‰ē";--fa--fa:"î‰ēî‰ē"}.fa-phone-volume{--fa:"";--fa--fa:""}.fa-phone-xmark{--fa:"";--fa--fa:""}.fa-photo-film{--fa:"īĄŧ";--fa--fa:"īĄŧīĄŧ"}.fa-photo-film-music{--fa:"";--fa--fa:""}.fa-photo-video{--fa:"īĄŧ";--fa--fa:"īĄŧīĄŧ"}.fa-pi{--fa:"ī™ž";--fa--fa:"ī™žī™ž"}.fa-piano{--fa:"īŖ”";--fa--fa:""}.fa-piano-keyboard{--fa:"īŖ•";--fa--fa:"īŖ•īŖ•"}.fa-pickaxe{--fa:"î–ŋ";--fa--fa:"î–ŋî–ŋ"}.fa-pickleball{--fa:"îĩ";--fa--fa:"îĩîĩ"}.fa-pie{--fa:"īœ…";--fa--fa:"īœ…īœ…"}.fa-pie-chart{--fa:"īˆ€";--fa--fa:"īˆ€īˆ€"}.fa-pig{--fa:"īœ†";--fa--fa:"īœ†īœ†"}.fa-piggy-bank{--fa:"ī““";--fa--fa:"ī““ī““"}.fa-pills{--fa:"ī’„";--fa--fa:"ī’„ī’„"}.fa-pinata{--fa:"";--fa--fa:""}.fa-pinball{--fa:"";--fa--fa:""}.fa-pineapple{--fa:"";--fa--fa:""}.fa-ping-pong-paddle-ball{--fa:"ī‘";--fa--fa:"ī‘ī‘"}.fa-pipe{--fa:"|";--fa--fa:"||"}.fa-pipe-circle-check{--fa:"îļ";--fa--fa:"îļîļ"}.fa-pipe-collar{--fa:"";--fa--fa:""}.fa-pipe-section{--fa:"";--fa--fa:""}.fa-pipe-smoking{--fa:"";--fa--fa:""}.fa-pipe-valve{--fa:"";--fa--fa:""}.fa-pizza{--fa:"ī —";--fa--fa:"ī —ī —"}.fa-pizza-slice{--fa:"";--fa--fa:""}.fa-place-of-worship{--fa:"ī™ŋ";--fa--fa:"ī™ŋī™ŋ"}.fa-plane{--fa:"";--fa--fa:""}.fa-plane-alt{--fa:"īž";--fa--fa:"īžīž"}.fa-plane-arrival{--fa:"ī–¯";--fa--fa:""}.fa-plane-circle-check{--fa:"";--fa--fa:""}.fa-plane-circle-exclamation{--fa:"";--fa--fa:""}.fa-plane-circle-xmark{--fa:"";--fa--fa:""}.fa-plane-departure{--fa:"ī–°";--fa--fa:"ī–°ī–°"}.fa-plane-engines{--fa:"īž";--fa--fa:"īžīž"}.fa-plane-lock{--fa:"";--fa--fa:""}.fa-plane-prop{--fa:"îˆĢ";--fa--fa:"îˆĢîˆĢ"}.fa-plane-slash{--fa:"";--fa--fa:""}.fa-plane-tail{--fa:"îˆŦ";--fa--fa:"îˆŦîˆŦ"}.fa-plane-up{--fa:"";--fa--fa:""}.fa-plane-up-slash{--fa:"";--fa--fa:""}.fa-planet-moon{--fa:"";--fa--fa:""}.fa-planet-ringed{--fa:"";--fa--fa:""}.fa-plant-wilt{--fa:"î–Ē";--fa--fa:"î–Ēî–Ē"}.fa-plate-utensils{--fa:"îģ";--fa--fa:"îģîģ"}.fa-plate-wheat{--fa:"";--fa--fa:""}.fa-play{--fa:"";--fa--fa:""}.fa-play-circle{--fa:"ī…„";--fa--fa:"ī…„ī…„"}.fa-play-pause{--fa:"";--fa--fa:""}.fa-plug{--fa:"ī‡Ļ";--fa--fa:"ī‡Ļī‡Ļ"}.fa-plug-circle-bolt{--fa:"";--fa--fa:""}.fa-plug-circle-check{--fa:"";--fa--fa:""}.fa-plug-circle-exclamation{--fa:"";--fa--fa:""}.fa-plug-circle-minus{--fa:"";--fa--fa:""}.fa-plug-circle-plus{--fa:"";--fa--fa:""}.fa-plug-circle-xmark{--fa:"";--fa--fa:""}.fa-plus{--fa:"+";--fa--fa:"++"}.fa-plus-circle{--fa:"";--fa--fa:""}.fa-plus-hexagon{--fa:"īŒ€";--fa--fa:"īŒ€īŒ€"}.fa-plus-large{--fa:"";--fa--fa:""}.fa-plus-minus{--fa:"îŧ";--fa--fa:"îŧîŧ"}.fa-plus-octagon{--fa:"";--fa--fa:""}.fa-plus-square{--fa:"īƒž";--fa--fa:"īƒžīƒž"}.fa-podcast{--fa:"ī‹Ž";--fa--fa:"ī‹Žī‹Ž"}.fa-podium{--fa:"īš€";--fa--fa:"īš€īš€"}.fa-podium-star{--fa:"ī˜";--fa--fa:"ī˜ī˜"}.fa-police-box{--fa:"";--fa--fa:""}.fa-poll{--fa:"";--fa--fa:""}.fa-poll-h{--fa:"īš‚";--fa--fa:"īš‚īš‚"}.fa-poll-people{--fa:"ī™";--fa--fa:"ī™ī™"}.fa-pompebled{--fa:"îŊ";--fa--fa:"îŊîŊ"}.fa-poo{--fa:"ī‹ž";--fa--fa:"ī‹žī‹ž"}.fa-poo-bolt,.fa-poo-storm{--fa:"īš";--fa--fa:"īšīš"}.fa-pool-8-ball{--fa:"";--fa--fa:""}.fa-poop{--fa:"ī˜™";--fa--fa:"ī˜™ī˜™"}.fa-popcorn{--fa:"ī ™";--fa--fa:""}.fa-popsicle{--fa:"";--fa--fa:""}.fa-portal-enter{--fa:"î€ĸ";--fa--fa:"î€ĸî€ĸ"}.fa-portal-exit{--fa:"î€Ŗ";--fa--fa:"î€Ŗî€Ŗ"}.fa-portrait{--fa:"ī ";--fa--fa:"ī ī "}.fa-pot-food{--fa:"îŋ";--fa--fa:"îŋîŋ"}.fa-potato{--fa:"";--fa--fa:""}.fa-pound-sign{--fa:"ī…”";--fa--fa:""}.fa-power-off{--fa:"";--fa--fa:""}.fa-pray{--fa:"";--fa--fa:""}.fa-praying-hands{--fa:"īš„";--fa--fa:"īš„īš„"}.fa-prescription{--fa:"ī–ą";--fa--fa:"ī–ąī–ą"}.fa-prescription-bottle{--fa:"ī’…";--fa--fa:"ī’…ī’…"}.fa-prescription-bottle-alt,.fa-prescription-bottle-medical{--fa:"ī’†";--fa--fa:""}.fa-prescription-bottle-pill{--fa:"";--fa--fa:""}.fa-presentation,.fa-presentation-screen{--fa:"īš…";--fa--fa:"īš…īš…"}.fa-pretzel{--fa:"";--fa--fa:""}.fa-print{--fa:"";--fa--fa:""}.fa-print-magnifying-glass,.fa-print-search{--fa:"";--fa--fa:""}.fa-print-slash{--fa:"īš†";--fa--fa:"īš†īš†"}.fa-pro{--fa:"îˆĩ";--fa--fa:"îˆĩîˆĩ"}.fa-procedures{--fa:"ī’‡";--fa--fa:""}.fa-project-diagram{--fa:"ī•‚";--fa--fa:"ī•‚ī•‚"}.fa-projector{--fa:"īŖ–";--fa--fa:"īŖ–īŖ–"}.fa-pronoun{--fa:"";--fa--fa:""}.fa-pump{--fa:"";--fa--fa:""}.fa-pump-medical{--fa:"îĒ";--fa--fa:"îĒîĒ"}.fa-pump-soap{--fa:"îĢ";--fa--fa:"îĢîĢ"}.fa-pumpkin{--fa:"īœ‡";--fa--fa:"īœ‡īœ‡"}.fa-puzzle{--fa:"";--fa--fa:""}.fa-puzzle-piece{--fa:"ī„Ž";--fa--fa:"ī„Žī„Ž"}.fa-puzzle-piece-alt,.fa-puzzle-piece-simple{--fa:"";--fa--fa:""}.fa-q{--fa:"Q";--fa--fa:"QQ"}.fa-qrcode{--fa:"ī€Š";--fa--fa:"ī€Šī€Š"}.fa-question{--fa:"?";--fa--fa:"??"}.fa-question-circle{--fa:"";--fa--fa:""}.fa-question-square{--fa:"ī‹Ŋ";--fa--fa:"ī‹Ŋī‹Ŋ"}.fa-quidditch,.fa-quidditch-broom-ball{--fa:"ī‘˜";--fa--fa:"ī‘˜ī‘˜"}.fa-quote-left,.fa-quote-left-alt{--fa:"ī„";--fa--fa:"ī„ī„"}.fa-quote-right,.fa-quote-right-alt{--fa:"ī„Ž";--fa--fa:"ī„Žī„Ž"}.fa-quotes{--fa:"";--fa--fa:""}.fa-quran{--fa:"īš‡";--fa--fa:"īš‡īš‡"}.fa-r{--fa:"R";--fa--fa:"RR"}.fa-rabbit{--fa:"";--fa--fa:""}.fa-rabbit-fast,.fa-rabbit-running{--fa:"īœ‰";--fa--fa:"īœ‰īœ‰"}.fa-raccoon{--fa:"";--fa--fa:""}.fa-racquet{--fa:"ī‘š";--fa--fa:"ī‘šī‘š"}.fa-radar{--fa:"";--fa--fa:""}.fa-radiation{--fa:"īžš";--fa--fa:"īžšīžš"}.fa-radiation-alt{--fa:"īžē";--fa--fa:"īžēīžē"}.fa-radio{--fa:"īŖ—";--fa--fa:"īŖ—īŖ—"}.fa-radio-alt,.fa-radio-tuner{--fa:"";--fa--fa:""}.fa-rainbow{--fa:"ī›";--fa--fa:"ī›ī›"}.fa-raindrops{--fa:"īœ";--fa--fa:"īœīœ"}.fa-ram{--fa:"";--fa--fa:""}.fa-ramp-loading{--fa:"ī“”";--fa--fa:""}.fa-random{--fa:"";--fa--fa:""}.fa-ranking-star{--fa:"";--fa--fa:""}.fa-raygun{--fa:"î€Ĩ";--fa--fa:"î€Ĩî€Ĩ"}.fa-receipt{--fa:"ī•ƒ";--fa--fa:"ī•ƒī•ƒ"}.fa-record-vinyl{--fa:"īŖ™";--fa--fa:""}.fa-rectangle{--fa:"ī‹ē";--fa--fa:"ī‹ēī‹ē"}.fa-rectangle-ad{--fa:"";--fa--fa:""}.fa-rectangle-barcode{--fa:"";--fa--fa:""}.fa-rectangle-code{--fa:"îŒĸ";--fa--fa:"îŒĸîŒĸ"}.fa-rectangle-hd{--fa:"";--fa--fa:""}.fa-rectangle-history{--fa:"î’ĸ";--fa--fa:"î’ĸî’ĸ"}.fa-rectangle-history-circle-plus{--fa:"î’Ŗ";--fa--fa:"î’Ŗî’Ŗ"}.fa-rectangle-history-circle-user{--fa:"";--fa--fa:""}.fa-rectangle-landscape{--fa:"ī‹ē";--fa--fa:"ī‹ēī‹ē"}.fa-rectangle-list{--fa:"ī€ĸ";--fa--fa:"ī€ĸī€ĸ"}.fa-rectangle-portrait{--fa:"ī‹ģ";--fa--fa:"ī‹ģī‹ģ"}.fa-rectangle-pro{--fa:"îˆĩ";--fa--fa:"îˆĩîˆĩ"}.fa-rectangle-sd{--fa:"";--fa--fa:""}.fa-rectangle-terminal{--fa:"îˆļ";--fa--fa:"îˆļîˆļ"}.fa-rectangle-times{--fa:"";--fa--fa:""}.fa-rectangle-vertical{--fa:"ī‹ģ";--fa--fa:"ī‹ģī‹ģ"}.fa-rectangle-vertical-history{--fa:"";--fa--fa:""}.fa-rectangle-wide{--fa:"ī‹ŧ";--fa--fa:"ī‹ŧī‹ŧ"}.fa-rectangle-xmark{--fa:"";--fa--fa:""}.fa-rectangles-mixed{--fa:"îŒŖ";--fa--fa:"îŒŖîŒŖ"}.fa-recycle{--fa:"";--fa--fa:""}.fa-redo{--fa:"ī€ž";--fa--fa:"ī€žī€ž"}.fa-redo-alt{--fa:"ī‹š";--fa--fa:"ī‹šī‹š"}.fa-reel{--fa:"";--fa--fa:""}.fa-reflect-both{--fa:"";--fa--fa:""}.fa-reflect-horizontal{--fa:"";--fa--fa:""}.fa-reflect-vertical{--fa:"î™Ĩ";--fa--fa:"î™Ĩî™Ĩ"}.fa-refresh{--fa:"ī€Ą";--fa--fa:"ī€Ąī€Ą"}.fa-refrigerator{--fa:"î€Ļ";--fa--fa:"î€Ļî€Ļ"}.fa-registered{--fa:"ī‰";--fa--fa:"ī‰ī‰"}.fa-remove{--fa:"ī€";--fa--fa:"ī€ī€"}.fa-remove-format{--fa:"īĄŊ";--fa--fa:"īĄŊīĄŊ"}.fa-reorder{--fa:"";--fa--fa:""}.fa-repeat{--fa:"īŖ";--fa--fa:"īŖīŖ"}.fa-repeat-1{--fa:"īĨ";--fa--fa:"īĨīĨ"}.fa-repeat-1-alt{--fa:"īĻ";--fa--fa:"īĻīĻ"}.fa-repeat-alt{--fa:"ī¤";--fa--fa:"ī¤ī¤"}.fa-reply{--fa:"īĨ";--fa--fa:"īĨīĨ"}.fa-reply-all{--fa:"ī„ĸ";--fa--fa:"ī„ĸī„ĸ"}.fa-reply-clock,.fa-reply-time{--fa:"";--fa--fa:""}.fa-republican{--fa:"īž";--fa--fa:"īžīž"}.fa-restroom{--fa:"īžŊ";--fa--fa:"īžŊīžŊ"}.fa-restroom-simple{--fa:"îˆē";--fa--fa:"îˆēîˆē"}.fa-retweet{--fa:"īš";--fa--fa:"īšīš"}.fa-retweet-alt{--fa:"īĄ";--fa--fa:"īĄīĄ"}.fa-rhombus{--fa:"îˆģ";--fa--fa:"îˆģîˆģ"}.fa-ribbon{--fa:"ī“–";--fa--fa:"ī“–ī“–"}.fa-right{--fa:"ī–";--fa--fa:"ī–ī–"}.fa-right-from-bracket{--fa:"ī‹ĩ";--fa--fa:"ī‹ĩī‹ĩ"}.fa-right-from-line{--fa:"ī‡";--fa--fa:"ī‡ī‡"}.fa-right-left{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-right-left-large{--fa:"";--fa--fa:""}.fa-right-long{--fa:"īŒ‹";--fa--fa:"īŒ‹īŒ‹"}.fa-right-long-to-line{--fa:"";--fa--fa:""}.fa-right-to-bracket{--fa:"ī‹ļ";--fa--fa:"ī‹ļī‹ļ"}.fa-right-to-line{--fa:"īŒ";--fa--fa:"īŒīŒ"}.fa-ring{--fa:"īœ‹";--fa--fa:"īœ‹īœ‹"}.fa-ring-diamond{--fa:"î–Ģ";--fa--fa:"î–Ģî–Ģ"}.fa-rings-wedding{--fa:"ī ›";--fa--fa:""}.fa-rmb{--fa:"ī…—";--fa--fa:"ī…—ī…—"}.fa-road{--fa:"ī€˜";--fa--fa:"ī€˜ī€˜"}.fa-road-barrier{--fa:"î•ĸ";--fa--fa:"î•ĸî•ĸ"}.fa-road-bridge{--fa:"î•Ŗ";--fa--fa:"î•Ŗî•Ŗ"}.fa-road-circle-check{--fa:"";--fa--fa:""}.fa-road-circle-exclamation{--fa:"î•Ĩ";--fa--fa:"î•Ĩî•Ĩ"}.fa-road-circle-xmark{--fa:"î•Ļ";--fa--fa:"î•Ļî•Ļ"}.fa-road-lock{--fa:"";--fa--fa:""}.fa-road-spikes{--fa:"";--fa--fa:""}.fa-robot{--fa:"ī•„";--fa--fa:"ī•„ī•„"}.fa-robot-astromech{--fa:"";--fa--fa:""}.fa-rocket{--fa:"ī„ĩ";--fa--fa:"ī„ĩī„ĩ"}.fa-rocket-launch{--fa:"";--fa--fa:""}.fa-rod-asclepius,.fa-rod-snake{--fa:"";--fa--fa:""}.fa-roller-coaster{--fa:"";--fa--fa:""}.fa-rotate{--fa:"ī‹ą";--fa--fa:"ī‹ąī‹ą"}.fa-rotate-back,.fa-rotate-backward{--fa:"ī‹Ē";--fa--fa:"ī‹Ēī‹Ē"}.fa-rotate-exclamation{--fa:"îˆŧ";--fa--fa:"îˆŧîˆŧ"}.fa-rotate-forward{--fa:"ī‹š";--fa--fa:"ī‹šī‹š"}.fa-rotate-left{--fa:"ī‹Ē";--fa--fa:"ī‹Ēī‹Ē"}.fa-rotate-reverse{--fa:"";--fa--fa:""}.fa-rotate-right{--fa:"ī‹š";--fa--fa:"ī‹šī‹š"}.fa-rouble{--fa:"ī…˜";--fa--fa:"ī…˜ī…˜"}.fa-route{--fa:"ī“—";--fa--fa:"ī“—ī“—"}.fa-route-highway{--fa:"";--fa--fa:""}.fa-route-interstate{--fa:"ī˜›";--fa--fa:"ī˜›ī˜›"}.fa-router{--fa:"";--fa--fa:""}.fa-rows{--fa:"";--fa--fa:""}.fa-rss{--fa:"ī‚ž";--fa--fa:"ī‚žī‚ž"}.fa-rss-square{--fa:"ī…ƒ";--fa--fa:"ī…ƒī…ƒ"}.fa-rub,.fa-ruble,.fa-ruble-sign{--fa:"ī…˜";--fa--fa:"ī…˜ī…˜"}.fa-rug{--fa:"";--fa--fa:""}.fa-rugby-ball{--fa:"";--fa--fa:""}.fa-ruler{--fa:"ī•…";--fa--fa:"ī•…ī•…"}.fa-ruler-combined{--fa:"";--fa--fa:""}.fa-ruler-horizontal{--fa:"";--fa--fa:""}.fa-ruler-triangle{--fa:"";--fa--fa:""}.fa-ruler-vertical{--fa:"ī•ˆ";--fa--fa:"ī•ˆī•ˆ"}.fa-running{--fa:"";--fa--fa:""}.fa-rupee,.fa-rupee-sign{--fa:"ī…–";--fa--fa:"ī…–ī…–"}.fa-rupiah-sign{--fa:"îˆŊ";--fa--fa:"îˆŊîˆŊ"}.fa-rv{--fa:"īžž";--fa--fa:"īžžīžž"}.fa-s{--fa:"S";--fa--fa:"SS"}.fa-sack{--fa:"";--fa--fa:""}.fa-sack-dollar{--fa:"ī ";--fa--fa:"ī ī "}.fa-sack-xmark{--fa:"î•Ē";--fa--fa:"î•Ēî•Ē"}.fa-sad-cry{--fa:"ī–ŗ";--fa--fa:"ī–ŗī–ŗ"}.fa-sad-tear{--fa:"ī–´";--fa--fa:"ī–´ī–´"}.fa-sailboat{--fa:"";--fa--fa:""}.fa-salad{--fa:"ī ž";--fa--fa:"ī žī ž"}.fa-salt-shaker{--fa:"";--fa--fa:""}.fa-sandwich{--fa:"";--fa--fa:""}.fa-satellite{--fa:"īžŋ";--fa--fa:"īžŋīžŋ"}.fa-satellite-dish{--fa:"īŸ€";--fa--fa:"īŸ€īŸ€"}.fa-sausage{--fa:"ī  ";--fa--fa:"ī  ī  "}.fa-save{--fa:"īƒ‡";--fa--fa:"īƒ‡īƒ‡"}.fa-save-circle-arrow-right{--fa:"";--fa--fa:""}.fa-save-circle-xmark,.fa-save-times{--fa:"";--fa--fa:""}.fa-sax-hot{--fa:"īŖ›";--fa--fa:""}.fa-saxophone{--fa:"";--fa--fa:""}.fa-saxophone-fire{--fa:"īŖ›";--fa--fa:""}.fa-scale-balanced{--fa:"ī‰Ž";--fa--fa:"ī‰Žī‰Ž"}.fa-scale-unbalanced{--fa:"";--fa--fa:""}.fa-scale-unbalanced-flip{--fa:"ī”–";--fa--fa:"ī”–ī”–"}.fa-scalpel{--fa:"ī˜";--fa--fa:"ī˜ī˜"}.fa-scalpel-line-dashed,.fa-scalpel-path{--fa:"ī˜ž";--fa--fa:"ī˜žī˜ž"}.fa-scanner,.fa-scanner-gun{--fa:"ī’ˆ";--fa--fa:"ī’ˆī’ˆ"}.fa-scanner-image{--fa:"īŖŗ";--fa--fa:"īŖŗīŖŗ"}.fa-scanner-keyboard{--fa:"ī’‰";--fa--fa:""}.fa-scanner-touchscreen{--fa:"ī’Š";--fa--fa:"ī’Šī’Š"}.fa-scarecrow{--fa:"īœ";--fa--fa:"īœīœ"}.fa-scarf{--fa:"";--fa--fa:""}.fa-school{--fa:"";--fa--fa:""}.fa-school-circle-check{--fa:"î•Ģ";--fa--fa:"î•Ģî•Ģ"}.fa-school-circle-exclamation{--fa:"î•Ŧ";--fa--fa:"î•Ŧî•Ŧ"}.fa-school-circle-xmark{--fa:"";--fa--fa:""}.fa-school-flag{--fa:"";--fa--fa:""}.fa-school-lock{--fa:"";--fa--fa:""}.fa-scissors{--fa:"īƒ„";--fa--fa:"īƒ„īƒ„"}.fa-screen-users{--fa:"ī˜Ŋ";--fa--fa:"ī˜Ŋī˜Ŋ"}.fa-screencast{--fa:"";--fa--fa:""}.fa-screenshot{--fa:"";--fa--fa:""}.fa-screwdriver{--fa:"ī•Š";--fa--fa:"ī•Šī•Š"}.fa-screwdriver-wrench{--fa:"īŸ™";--fa--fa:"īŸ™īŸ™"}.fa-scribble{--fa:"îˆŋ";--fa--fa:"îˆŋîˆŋ"}.fa-scroll{--fa:"īœŽ";--fa--fa:"īœŽīœŽ"}.fa-scroll-old{--fa:"īœ";--fa--fa:"īœīœ"}.fa-scroll-ribbon{--fa:"ī—Ē";--fa--fa:"ī—Ēī—Ē"}.fa-scroll-torah{--fa:"";--fa--fa:""}.fa-scrubber{--fa:"";--fa--fa:""}.fa-scythe{--fa:"";--fa--fa:""}.fa-sd-card{--fa:"īŸ‚";--fa--fa:"īŸ‚īŸ‚"}.fa-sd-cards{--fa:"";--fa--fa:""}.fa-seal{--fa:"";--fa--fa:""}.fa-seal-exclamation{--fa:"";--fa--fa:""}.fa-seal-question{--fa:"";--fa--fa:""}.fa-search{--fa:"";--fa--fa:""}.fa-search-dollar{--fa:"";--fa--fa:""}.fa-search-location{--fa:"īš‰";--fa--fa:"īš‰īš‰"}.fa-search-minus{--fa:"";--fa--fa:""}.fa-search-plus{--fa:"ī€Ž";--fa--fa:"ī€Žī€Ž"}.fa-seat-airline{--fa:"";--fa--fa:""}.fa-section{--fa:"";--fa--fa:""}.fa-seedling{--fa:"ī“˜";--fa--fa:"ī“˜ī“˜"}.fa-semicolon{--fa:";";--fa--fa:";;"}.fa-send{--fa:"";--fa--fa:""}.fa-send-back{--fa:"īĄž";--fa--fa:"īĄžīĄž"}.fa-send-backward{--fa:"īĄŋ";--fa--fa:"īĄŋīĄŋ"}.fa-sensor{--fa:"";--fa--fa:""}.fa-sensor-alert{--fa:"";--fa--fa:""}.fa-sensor-cloud{--fa:"î€Ŧ";--fa--fa:"î€Ŧî€Ŧ"}.fa-sensor-fire{--fa:"î€Ē";--fa--fa:"î€Ēî€Ē"}.fa-sensor-on{--fa:"î€Ģ";--fa--fa:"î€Ģî€Ģ"}.fa-sensor-smoke{--fa:"î€Ŧ";--fa--fa:"î€Ŧî€Ŧ"}.fa-sensor-triangle-exclamation{--fa:"";--fa--fa:""}.fa-server{--fa:"";--fa--fa:""}.fa-shapes{--fa:"";--fa--fa:""}.fa-share{--fa:"";--fa--fa:""}.fa-share-all{--fa:"ī§";--fa--fa:"ī§ī§"}.fa-share-alt{--fa:"";--fa--fa:""}.fa-share-alt-square{--fa:"ī‡Ą";--fa--fa:"ī‡Ąī‡Ą"}.fa-share-from-square{--fa:"ī…";--fa--fa:"ī…ī…"}.fa-share-nodes{--fa:"";--fa--fa:""}.fa-share-square{--fa:"ī…";--fa--fa:"ī…ī…"}.fa-sheep{--fa:"īœ‘";--fa--fa:"īœ‘īœ‘"}.fa-sheet-plastic{--fa:"";--fa--fa:""}.fa-shekel,.fa-shekel-sign{--fa:"īˆ‹";--fa--fa:"īˆ‹īˆ‹"}.fa-shelves{--fa:"ī’€";--fa--fa:""}.fa-shelves-empty{--fa:"";--fa--fa:""}.fa-sheqel,.fa-sheqel-sign{--fa:"īˆ‹";--fa--fa:"īˆ‹īˆ‹"}.fa-shield{--fa:"";--fa--fa:""}.fa-shield-alt{--fa:"ī­";--fa--fa:"ī­ī­"}.fa-shield-blank{--fa:"";--fa--fa:""}.fa-shield-cat{--fa:"";--fa--fa:""}.fa-shield-check{--fa:"";--fa--fa:""}.fa-shield-cross{--fa:"īœ’";--fa--fa:"īœ’īœ’"}.fa-shield-dog{--fa:"î•ŗ";--fa--fa:"î•ŗî•ŗ"}.fa-shield-exclamation{--fa:"";--fa--fa:""}.fa-shield-halved{--fa:"ī­";--fa--fa:"ī­ī­"}.fa-shield-heart{--fa:"";--fa--fa:""}.fa-shield-keyhole{--fa:"";--fa--fa:""}.fa-shield-minus{--fa:"";--fa--fa:""}.fa-shield-plus{--fa:"";--fa--fa:""}.fa-shield-quartered{--fa:"î•ĩ";--fa--fa:"î•ĩî•ĩ"}.fa-shield-slash{--fa:"";--fa--fa:""}.fa-shield-times{--fa:"";--fa--fa:""}.fa-shield-virus{--fa:"îŦ";--fa--fa:"îŦîŦ"}.fa-shield-xmark{--fa:"";--fa--fa:""}.fa-ship{--fa:"";--fa--fa:""}.fa-shipping-fast{--fa:"ī’‹";--fa--fa:"ī’‹ī’‹"}.fa-shipping-timed{--fa:"ī’Œ";--fa--fa:"ī’Œī’Œ"}.fa-shirt{--fa:"ī•“";--fa--fa:"ī•“ī•“"}.fa-shirt-long-sleeve{--fa:"";--fa--fa:""}.fa-shirt-running{--fa:"";--fa--fa:""}.fa-shirt-tank-top{--fa:"";--fa--fa:""}.fa-shish-kebab{--fa:"ī Ą";--fa--fa:"ī Ąī Ą"}.fa-shoe-prints{--fa:"ī•‹";--fa--fa:"ī•‹ī•‹"}.fa-shop{--fa:"ī•";--fa--fa:"ī•ī•"}.fa-shop-lock{--fa:"î’Ĩ";--fa--fa:"î’Ĩî’Ĩ"}.fa-shop-slash{--fa:"";--fa--fa:""}.fa-shopping-bag{--fa:"";--fa--fa:""}.fa-shopping-basket{--fa:"īŠ‘";--fa--fa:"īŠ‘īŠ‘"}.fa-shopping-basket-alt{--fa:"";--fa--fa:""}.fa-shopping-cart{--fa:"īē";--fa--fa:"īēīē"}.fa-shortcake{--fa:"îĨ";--fa--fa:"îĨîĨ"}.fa-shovel{--fa:"īœ“";--fa--fa:"īœ“īœ“"}.fa-shovel-snow{--fa:"";--fa--fa:""}.fa-shower{--fa:"ī‹Œ";--fa--fa:"ī‹Œī‹Œ"}.fa-shower-alt,.fa-shower-down{--fa:"";--fa--fa:""}.fa-shredder{--fa:"";--fa--fa:""}.fa-shrimp{--fa:"";--fa--fa:""}.fa-shuffle{--fa:"";--fa--fa:""}.fa-shutters{--fa:"";--fa--fa:""}.fa-shuttle-space{--fa:"";--fa--fa:""}.fa-shuttle-van{--fa:"ī–ļ";--fa--fa:"ī–ļī–ļ"}.fa-shuttlecock{--fa:"ī‘›";--fa--fa:""}.fa-sickle{--fa:"ī ĸ";--fa--fa:"ī ĸī ĸ"}.fa-sidebar{--fa:"";--fa--fa:""}.fa-sidebar-flip{--fa:"";--fa--fa:""}.fa-sigma{--fa:"īš‹";--fa--fa:"īš‹īš‹"}.fa-sign,.fa-sign-hanging{--fa:"ī“™";--fa--fa:""}.fa-sign-in{--fa:"";--fa--fa:""}.fa-sign-in-alt{--fa:"ī‹ļ";--fa--fa:"ī‹ļī‹ļ"}.fa-sign-language{--fa:"";--fa--fa:""}.fa-sign-out{--fa:"ī‚‹";--fa--fa:"ī‚‹ī‚‹"}.fa-sign-out-alt{--fa:"ī‹ĩ";--fa--fa:"ī‹ĩī‹ĩ"}.fa-sign-post{--fa:"";--fa--fa:""}.fa-sign-posts{--fa:"î˜Ĩ";--fa--fa:"î˜Ĩî˜Ĩ"}.fa-sign-posts-wrench{--fa:"î˜Ļ";--fa--fa:"î˜Ļî˜Ļ"}.fa-signal{--fa:"";--fa--fa:""}.fa-signal-1{--fa:"";--fa--fa:""}.fa-signal-2{--fa:"īš";--fa--fa:"īšīš"}.fa-signal-3{--fa:"īšŽ";--fa--fa:"īšŽīšŽ"}.fa-signal-4{--fa:"īš";--fa--fa:"īšīš"}.fa-signal-5{--fa:"";--fa--fa:""}.fa-signal-alt{--fa:"";--fa--fa:""}.fa-signal-alt-1{--fa:"īš‘";--fa--fa:"īš‘īš‘"}.fa-signal-alt-2{--fa:"īš’";--fa--fa:"īš’īš’"}.fa-signal-alt-3{--fa:"īš“";--fa--fa:"īš“īš“"}.fa-signal-alt-4{--fa:"";--fa--fa:""}.fa-signal-alt-slash{--fa:"īš”";--fa--fa:"īš”īš”"}.fa-signal-bars{--fa:"";--fa--fa:""}.fa-signal-bars-fair{--fa:"īš’";--fa--fa:"īš’īš’"}.fa-signal-bars-good{--fa:"īš“";--fa--fa:"īš“īš“"}.fa-signal-bars-slash{--fa:"īš”";--fa--fa:"īš”īš”"}.fa-signal-bars-strong{--fa:"";--fa--fa:""}.fa-signal-bars-weak{--fa:"īš‘";--fa--fa:"īš‘īš‘"}.fa-signal-fair{--fa:"īš";--fa--fa:"īšīš"}.fa-signal-good{--fa:"īšŽ";--fa--fa:"īšŽīšŽ"}.fa-signal-perfect{--fa:"";--fa--fa:""}.fa-signal-slash{--fa:"īš•";--fa--fa:"īš•īš•"}.fa-signal-stream{--fa:"īŖ";--fa--fa:"īŖīŖ"}.fa-signal-stream-slash{--fa:"";--fa--fa:""}.fa-signal-strong{--fa:"īš";--fa--fa:"īšīš"}.fa-signal-weak{--fa:"";--fa--fa:""}.fa-signature{--fa:"ī–ˇ";--fa--fa:""}.fa-signature-lock{--fa:"";--fa--fa:""}.fa-signature-slash{--fa:"";--fa--fa:""}.fa-signing{--fa:"";--fa--fa:""}.fa-signs-post{--fa:"";--fa--fa:""}.fa-sim-card{--fa:"īŸ„";--fa--fa:"īŸ„īŸ„"}.fa-sim-cards{--fa:"";--fa--fa:""}.fa-sink{--fa:"";--fa--fa:""}.fa-siren{--fa:"";--fa--fa:""}.fa-siren-on{--fa:"";--fa--fa:""}.fa-sitemap{--fa:"";--fa--fa:""}.fa-skating{--fa:"īŸ…";--fa--fa:"īŸ…īŸ…"}.fa-skeleton{--fa:"";--fa--fa:""}.fa-skeleton-ribs{--fa:"";--fa--fa:""}.fa-ski-boot{--fa:"";--fa--fa:""}.fa-ski-boot-ski{--fa:"";--fa--fa:""}.fa-ski-jump{--fa:"īŸ‡";--fa--fa:"īŸ‡īŸ‡"}.fa-ski-lift{--fa:"";--fa--fa:""}.fa-skiing{--fa:"īŸ‰";--fa--fa:"īŸ‰īŸ‰"}.fa-skiing-nordic{--fa:"";--fa--fa:""}.fa-skull{--fa:"ī•Œ";--fa--fa:"ī•Œī•Œ"}.fa-skull-cow{--fa:"īŖž";--fa--fa:"īŖžīŖž"}.fa-skull-crossbones{--fa:"īœ”";--fa--fa:"īœ”īœ”"}.fa-slash{--fa:"īœ•";--fa--fa:"īœ•īœ•"}.fa-slash-back{--fa:"\\";--fa--fa:"\\\\"}.fa-slash-forward{--fa:"/";--fa--fa:"//"}.fa-sledding{--fa:"īŸ‹";--fa--fa:"īŸ‹īŸ‹"}.fa-sleigh{--fa:"";--fa--fa:""}.fa-slider{--fa:"";--fa--fa:""}.fa-sliders,.fa-sliders-h{--fa:"ī‡ž";--fa--fa:"ī‡žī‡ž"}.fa-sliders-h-square{--fa:"ī°";--fa--fa:"ī°ī°"}.fa-sliders-simple{--fa:"";--fa--fa:""}.fa-sliders-up,.fa-sliders-v{--fa:"īą";--fa--fa:"īąīą"}.fa-sliders-v-square{--fa:"ī˛";--fa--fa:"ī˛ī˛"}.fa-slot-machine{--fa:"";--fa--fa:""}.fa-smile{--fa:"ī„˜";--fa--fa:"ī„˜ī„˜"}.fa-smile-beam{--fa:"ī–¸";--fa--fa:""}.fa-smile-plus{--fa:"ī–š";--fa--fa:"ī–šī–š"}.fa-smile-wink{--fa:"ī“š";--fa--fa:"ī“šī“š"}.fa-smog{--fa:"īŸ";--fa--fa:"īŸīŸ"}.fa-smoke{--fa:"ī ";--fa--fa:"ī ī "}.fa-smoking{--fa:"ī’";--fa--fa:"ī’ī’"}.fa-smoking-ban{--fa:"ī•";--fa--fa:"ī•ī•"}.fa-sms{--fa:"īŸ";--fa--fa:"īŸīŸ"}.fa-snake{--fa:"īœ–";--fa--fa:"īœ–īœ–"}.fa-snooze{--fa:"īĸ€";--fa--fa:"īĸ€īĸ€"}.fa-snow-blowing{--fa:"īĄ";--fa--fa:"īĄīĄ"}.fa-snowboarding{--fa:"īŸŽ";--fa--fa:"īŸŽīŸŽ"}.fa-snowflake{--fa:"ī‹œ";--fa--fa:"ī‹œī‹œ"}.fa-snowflake-droplets{--fa:"";--fa--fa:""}.fa-snowflakes{--fa:"īŸ";--fa--fa:"īŸīŸ"}.fa-snowman{--fa:"";--fa--fa:""}.fa-snowman-head{--fa:"īž›";--fa--fa:"īž›īž›"}.fa-snowmobile{--fa:"īŸ‘";--fa--fa:"īŸ‘īŸ‘"}.fa-snowplow{--fa:"īŸ’";--fa--fa:"īŸ’īŸ’"}.fa-soap{--fa:"";--fa--fa:""}.fa-soccer-ball{--fa:"";--fa--fa:""}.fa-socks{--fa:"īš–";--fa--fa:"īš–īš–"}.fa-soft-serve{--fa:"";--fa--fa:""}.fa-solar-panel{--fa:"ī–ē";--fa--fa:"ī–ēī–ē"}.fa-solar-system{--fa:"";--fa--fa:""}.fa-sort{--fa:"";--fa--fa:""}.fa-sort-alpha-asc{--fa:"ī…";--fa--fa:"ī…ī…"}.fa-sort-alpha-desc{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-sort-alpha-down{--fa:"ī…";--fa--fa:"ī…ī…"}.fa-sort-alpha-down-alt{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-sort-alpha-up{--fa:"ī…ž";--fa--fa:"ī…žī…ž"}.fa-sort-alpha-up-alt{--fa:"īĸ‚";--fa--fa:"īĸ‚īĸ‚"}.fa-sort-alt{--fa:"īĸƒ";--fa--fa:"īĸƒīĸƒ"}.fa-sort-amount-asc{--fa:"ī… ";--fa--fa:"ī… ī… "}.fa-sort-amount-desc{--fa:"īĸ„";--fa--fa:"īĸ„īĸ„"}.fa-sort-amount-down{--fa:"ī… ";--fa--fa:"ī… ī… "}.fa-sort-amount-down-alt{--fa:"īĸ„";--fa--fa:"īĸ„īĸ„"}.fa-sort-amount-up{--fa:"ī…Ą";--fa--fa:"ī…Ąī…Ą"}.fa-sort-amount-up-alt{--fa:"īĸ…";--fa--fa:"īĸ…īĸ…"}.fa-sort-asc{--fa:"īƒž";--fa--fa:"īƒžīƒž"}.fa-sort-circle{--fa:"";--fa--fa:""}.fa-sort-circle-down{--fa:"";--fa--fa:""}.fa-sort-circle-up{--fa:"";--fa--fa:""}.fa-sort-desc,.fa-sort-down{--fa:"īƒ";--fa--fa:"īƒīƒ"}.fa-sort-numeric-asc{--fa:"ī…ĸ";--fa--fa:"ī…ĸī…ĸ"}.fa-sort-numeric-desc{--fa:"īĸ†";--fa--fa:"īĸ†īĸ†"}.fa-sort-numeric-down{--fa:"ī…ĸ";--fa--fa:"ī…ĸī…ĸ"}.fa-sort-numeric-down-alt{--fa:"īĸ†";--fa--fa:"īĸ†īĸ†"}.fa-sort-numeric-up{--fa:"ī…Ŗ";--fa--fa:"ī…Ŗī…Ŗ"}.fa-sort-numeric-up-alt{--fa:"īĸ‡";--fa--fa:"īĸ‡īĸ‡"}.fa-sort-shapes-down{--fa:"īĸˆ";--fa--fa:"īĸˆīĸˆ"}.fa-sort-shapes-down-alt{--fa:"īĸ‰";--fa--fa:"īĸ‰īĸ‰"}.fa-sort-shapes-up{--fa:"īĸŠ";--fa--fa:"īĸŠīĸŠ"}.fa-sort-shapes-up-alt{--fa:"īĸ‹";--fa--fa:"īĸ‹īĸ‹"}.fa-sort-size-down{--fa:"īĸŒ";--fa--fa:"īĸŒīĸŒ"}.fa-sort-size-down-alt{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-sort-size-up{--fa:"īĸŽ";--fa--fa:"īĸŽīĸŽ"}.fa-sort-size-up-alt{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-sort-up{--fa:"īƒž";--fa--fa:"īƒžīƒž"}.fa-sort-up-down{--fa:"";--fa--fa:""}.fa-soup{--fa:"ī Ŗ";--fa--fa:"ī Ŗī Ŗ"}.fa-spa{--fa:"ī–ģ";--fa--fa:"ī–ģī–ģ"}.fa-space-shuttle{--fa:"";--fa--fa:""}.fa-space-station-moon{--fa:"î€ŗ";--fa--fa:"î€ŗî€ŗ"}.fa-space-station-moon-alt,.fa-space-station-moon-construction{--fa:"";--fa--fa:""}.fa-spade{--fa:"ī‹´";--fa--fa:"ī‹´ī‹´"}.fa-spaghetti-monster-flying{--fa:"ī™ģ";--fa--fa:"ī™ģī™ģ"}.fa-sparkle{--fa:"";--fa--fa:""}.fa-sparkles{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-speaker{--fa:"";--fa--fa:""}.fa-speakers{--fa:"īŖ ";--fa--fa:"īŖ īŖ "}.fa-spell-check{--fa:"īĸ‘";--fa--fa:"īĸ‘īĸ‘"}.fa-spider{--fa:"īœ—";--fa--fa:"īœ—īœ—"}.fa-spider-black-widow{--fa:"";--fa--fa:""}.fa-spider-web{--fa:"īœ™";--fa--fa:"īœ™īœ™"}.fa-spinner{--fa:"";--fa--fa:""}.fa-spinner-scale{--fa:"î˜Ē";--fa--fa:"î˜Ēî˜Ē"}.fa-spinner-third{--fa:"ī´";--fa--fa:"ī´ī´"}.fa-split{--fa:"";--fa--fa:""}.fa-splotch{--fa:"ī–ŧ";--fa--fa:"ī–ŧī–ŧ"}.fa-spoon{--fa:"ī‹Ĩ";--fa--fa:"ī‹Ĩī‹Ĩ"}.fa-sportsball{--fa:"";--fa--fa:""}.fa-spray-can{--fa:"ī–Ŋ";--fa--fa:"ī–Ŋī–Ŋ"}.fa-spray-can-sparkles{--fa:"";--fa--fa:""}.fa-sprinkler{--fa:"î€ĩ";--fa--fa:"î€ĩî€ĩ"}.fa-sprinkler-ceiling{--fa:"";--fa--fa:""}.fa-sprout{--fa:"ī“˜";--fa--fa:"ī“˜ī“˜"}.fa-square{--fa:"";--fa--fa:""}.fa-square-0{--fa:"";--fa--fa:""}.fa-square-1{--fa:"";--fa--fa:""}.fa-square-2{--fa:"";--fa--fa:""}.fa-square-3{--fa:"";--fa--fa:""}.fa-square-4{--fa:"";--fa--fa:""}.fa-square-5{--fa:"";--fa--fa:""}.fa-square-6{--fa:"";--fa--fa:""}.fa-square-7{--fa:"";--fa--fa:""}.fa-square-8{--fa:"";--fa--fa:""}.fa-square-9{--fa:"";--fa--fa:""}.fa-square-a{--fa:"";--fa--fa:""}.fa-square-a-lock{--fa:"";--fa--fa:""}.fa-square-ampersand{--fa:"";--fa--fa:""}.fa-square-arrow-down{--fa:"īŒš";--fa--fa:"īŒšīŒš"}.fa-square-arrow-down-left{--fa:"";--fa--fa:""}.fa-square-arrow-down-right{--fa:"î‰ĸ";--fa--fa:"î‰ĸî‰ĸ"}.fa-square-arrow-left{--fa:"īŒē";--fa--fa:"īŒēīŒē"}.fa-square-arrow-right{--fa:"īŒģ";--fa--fa:"īŒģīŒģ"}.fa-square-arrow-up{--fa:"īŒŧ";--fa--fa:"īŒŧīŒŧ"}.fa-square-arrow-up-left{--fa:"î‰Ŗ";--fa--fa:"î‰Ŗî‰Ŗ"}.fa-square-arrow-up-right{--fa:"ī…Œ";--fa--fa:"ī…Œī…Œ"}.fa-square-b{--fa:"";--fa--fa:""}.fa-square-binary{--fa:"";--fa--fa:""}.fa-square-bolt{--fa:"î‰Ĩ";--fa--fa:"î‰Ĩî‰Ĩ"}.fa-square-c{--fa:"î‰Ļ";--fa--fa:"î‰Ļî‰Ļ"}.fa-square-caret-down{--fa:"";--fa--fa:""}.fa-square-caret-left{--fa:"";--fa--fa:""}.fa-square-caret-right{--fa:"ī…’";--fa--fa:"ī…’ī…’"}.fa-square-caret-up{--fa:"ī…‘";--fa--fa:"ī…‘ī…‘"}.fa-square-check{--fa:"ī…Š";--fa--fa:"ī…Šī…Š"}.fa-square-chevron-down{--fa:"īŒŠ";--fa--fa:"īŒŠīŒŠ"}.fa-square-chevron-left{--fa:"īŒĒ";--fa--fa:"īŒĒīŒĒ"}.fa-square-chevron-right{--fa:"īŒĢ";--fa--fa:"īŒĢīŒĢ"}.fa-square-chevron-up{--fa:"īŒŦ";--fa--fa:"īŒŦīŒŦ"}.fa-square-code{--fa:"";--fa--fa:""}.fa-square-d{--fa:"";--fa--fa:""}.fa-square-dashed{--fa:"";--fa--fa:""}.fa-square-dashed-circle-plus{--fa:"";--fa--fa:""}.fa-square-divide{--fa:"î‰Ē";--fa--fa:"î‰Ēî‰Ē"}.fa-square-dollar{--fa:"ī‹Š";--fa--fa:"ī‹Šī‹Š"}.fa-square-down{--fa:"ī";--fa--fa:"īī"}.fa-square-down-left{--fa:"î‰Ģ";--fa--fa:"î‰Ģî‰Ģ"}.fa-square-down-right{--fa:"î‰Ŧ";--fa--fa:"î‰Ŧî‰Ŧ"}.fa-square-e{--fa:"";--fa--fa:""}.fa-square-ellipsis{--fa:"";--fa--fa:""}.fa-square-ellipsis-vertical{--fa:"";--fa--fa:""}.fa-square-envelope{--fa:"";--fa--fa:""}.fa-square-exclamation{--fa:"īŒĄ";--fa--fa:"īŒĄīŒĄ"}.fa-square-f{--fa:"";--fa--fa:""}.fa-square-fragile{--fa:"ī’›";--fa--fa:""}.fa-square-full{--fa:"ī‘œ";--fa--fa:"ī‘œī‘œ"}.fa-square-g{--fa:"";--fa--fa:""}.fa-square-h{--fa:"īƒŊ";--fa--fa:"īƒŊīƒŊ"}.fa-square-heart{--fa:"ī“ˆ";--fa--fa:"ī“ˆī“ˆ"}.fa-square-i{--fa:"";--fa--fa:""}.fa-square-info{--fa:"īŒ";--fa--fa:"īŒīŒ"}.fa-square-j{--fa:"î‰ŗ";--fa--fa:"î‰ŗî‰ŗ"}.fa-square-k{--fa:"";--fa--fa:""}.fa-square-kanban{--fa:"";--fa--fa:""}.fa-square-l{--fa:"î‰ĩ";--fa--fa:"î‰ĩî‰ĩ"}.fa-square-left{--fa:"ī‘";--fa--fa:"ī‘ī‘"}.fa-square-list{--fa:"";--fa--fa:""}.fa-square-m{--fa:"î‰ļ";--fa--fa:"î‰ļî‰ļ"}.fa-square-minus{--fa:"ī…†";--fa--fa:""}.fa-square-n{--fa:"";--fa--fa:""}.fa-square-nfi{--fa:"î•ļ";--fa--fa:"î•ļî•ļ"}.fa-square-o{--fa:"";--fa--fa:""}.fa-square-p{--fa:"";--fa--fa:""}.fa-square-parking{--fa:"ī•€";--fa--fa:""}.fa-square-parking-slash{--fa:"ī˜—";--fa--fa:"ī˜—ī˜—"}.fa-square-pen{--fa:"ī…‹";--fa--fa:"ī…‹ī…‹"}.fa-square-person-confined{--fa:"";--fa--fa:""}.fa-square-phone{--fa:"ī‚˜";--fa--fa:"ī‚˜ī‚˜"}.fa-square-phone-flip{--fa:"īĄģ";--fa--fa:"īĄģīĄģ"}.fa-square-phone-hangup{--fa:"î‰ē";--fa--fa:"î‰ēî‰ē"}.fa-square-plus{--fa:"īƒž";--fa--fa:"īƒžīƒž"}.fa-square-poll-horizontal{--fa:"īš‚";--fa--fa:"īš‚īš‚"}.fa-square-poll-vertical{--fa:"";--fa--fa:""}.fa-square-q{--fa:"î‰ģ";--fa--fa:"î‰ģî‰ģ"}.fa-square-quarters{--fa:"";--fa--fa:""}.fa-square-question{--fa:"ī‹Ŋ";--fa--fa:"ī‹Ŋī‹Ŋ"}.fa-square-quote{--fa:"";--fa--fa:""}.fa-square-r{--fa:"î‰ŧ";--fa--fa:"î‰ŧî‰ŧ"}.fa-square-right{--fa:"ī’";--fa--fa:"ī’ī’"}.fa-square-ring{--fa:"";--fa--fa:""}.fa-square-root{--fa:"īš—";--fa--fa:"īš—īš—"}.fa-square-root-alt,.fa-square-root-variable{--fa:"";--fa--fa:""}.fa-square-rss{--fa:"ī…ƒ";--fa--fa:"ī…ƒī…ƒ"}.fa-square-s{--fa:"î‰Ŋ";--fa--fa:"î‰Ŋî‰Ŋ"}.fa-square-share-nodes{--fa:"ī‡Ą";--fa--fa:"ī‡Ąī‡Ą"}.fa-square-sliders{--fa:"ī°";--fa--fa:"ī°ī°"}.fa-square-sliders-vertical{--fa:"ī˛";--fa--fa:"ī˛ī˛"}.fa-square-small{--fa:"";--fa--fa:""}.fa-square-star{--fa:"î‰ŋ";--fa--fa:"î‰ŋî‰ŋ"}.fa-square-t{--fa:"";--fa--fa:""}.fa-square-terminal{--fa:"îŒĒ";--fa--fa:"îŒĒîŒĒ"}.fa-square-this-way-up{--fa:"ī’Ÿ";--fa--fa:"ī’Ÿī’Ÿ"}.fa-square-u{--fa:"";--fa--fa:""}.fa-square-up{--fa:"ī“";--fa--fa:"ī“ī“"}.fa-square-up-left{--fa:"";--fa--fa:""}.fa-square-up-right{--fa:"ī ";--fa--fa:"ī ī "}.fa-square-user{--fa:"";--fa--fa:""}.fa-square-v{--fa:"";--fa--fa:""}.fa-square-virus{--fa:"";--fa--fa:""}.fa-square-w{--fa:"";--fa--fa:""}.fa-square-wine-glass-crack{--fa:"ī’›";--fa--fa:""}.fa-square-x{--fa:"";--fa--fa:""}.fa-square-xmark{--fa:"ī‹“";--fa--fa:"ī‹“ī‹“"}.fa-square-y{--fa:"";--fa--fa:""}.fa-square-z{--fa:"";--fa--fa:""}.fa-squid{--fa:"";--fa--fa:""}.fa-squirrel{--fa:"";--fa--fa:""}.fa-staff{--fa:"īœ›";--fa--fa:"īœ›īœ›"}.fa-staff-aesculapius,.fa-staff-snake{--fa:"";--fa--fa:""}.fa-stairs{--fa:"";--fa--fa:""}.fa-stamp{--fa:"ī–ŋ";--fa--fa:"ī–ŋī–ŋ"}.fa-standard-definition{--fa:"";--fa--fa:""}.fa-stapler{--fa:"";--fa--fa:""}.fa-star{--fa:"";--fa--fa:""}.fa-star-and-crescent{--fa:"īš™";--fa--fa:"īš™īš™"}.fa-star-christmas{--fa:"īŸ”";--fa--fa:"īŸ”īŸ”"}.fa-star-circle{--fa:"î„Ŗ";--fa--fa:"î„Ŗî„Ŗ"}.fa-star-exclamation{--fa:"";--fa--fa:""}.fa-star-half{--fa:"";--fa--fa:""}.fa-star-half-alt,.fa-star-half-stroke{--fa:"ī—€";--fa--fa:""}.fa-star-of-david{--fa:"";--fa--fa:""}.fa-star-of-life{--fa:"ī˜Ą";--fa--fa:"ī˜Ąī˜Ą"}.fa-star-sharp{--fa:"";--fa--fa:""}.fa-star-sharp-half{--fa:"";--fa--fa:""}.fa-star-sharp-half-alt,.fa-star-sharp-half-stroke{--fa:"";--fa--fa:""}.fa-star-shooting{--fa:"î€ļ";--fa--fa:"î€ļî€ļ"}.fa-starfighter{--fa:"";--fa--fa:""}.fa-starfighter-alt{--fa:"";--fa--fa:""}.fa-starfighter-alt-advanced{--fa:"";--fa--fa:""}.fa-starfighter-twin-ion-engine{--fa:"";--fa--fa:""}.fa-starfighter-twin-ion-engine-advanced{--fa:"";--fa--fa:""}.fa-stars{--fa:"īĸ";--fa--fa:"īĸīĸ"}.fa-starship{--fa:"";--fa--fa:""}.fa-starship-freighter{--fa:"î€ē";--fa--fa:"î€ēî€ē"}.fa-steak{--fa:"ī ¤";--fa--fa:""}.fa-steering-wheel{--fa:"ī˜ĸ";--fa--fa:"ī˜ĸī˜ĸ"}.fa-step-backward{--fa:"";--fa--fa:""}.fa-step-forward{--fa:"";--fa--fa:""}.fa-sterling-sign{--fa:"ī…”";--fa--fa:""}.fa-stethoscope{--fa:"īƒą";--fa--fa:"īƒąīƒą"}.fa-sticky-note{--fa:"";--fa--fa:""}.fa-stocking{--fa:"īŸ•";--fa--fa:"īŸ•īŸ•"}.fa-stomach{--fa:"";--fa--fa:""}.fa-stop{--fa:"ī";--fa--fa:"īī"}.fa-stop-circle{--fa:"īŠ";--fa--fa:"īŠīŠ"}.fa-stopwatch{--fa:"";--fa--fa:""}.fa-stopwatch-20{--fa:"";--fa--fa:""}.fa-store{--fa:"ī•Ž";--fa--fa:"ī•Žī•Ž"}.fa-store-alt{--fa:"ī•";--fa--fa:"ī•ī•"}.fa-store-alt-slash{--fa:"";--fa--fa:""}.fa-store-lock{--fa:"î’Ļ";--fa--fa:"î’Ļî’Ļ"}.fa-store-slash{--fa:"";--fa--fa:""}.fa-strawberry{--fa:"îŒĢ";--fa--fa:"îŒĢîŒĢ"}.fa-stream{--fa:"";--fa--fa:""}.fa-street-view{--fa:"īˆ";--fa--fa:"īˆīˆ"}.fa-stretcher{--fa:"ī Ĩ";--fa--fa:"ī Ĩī Ĩ"}.fa-strikethrough{--fa:"";--fa--fa:""}.fa-stroopwafel{--fa:"ī•‘";--fa--fa:"ī•‘ī•‘"}.fa-subscript{--fa:"ī„Ŧ";--fa--fa:"ī„Ŧī„Ŧ"}.fa-subtitles{--fa:"";--fa--fa:""}.fa-subtitles-slash{--fa:"";--fa--fa:""}.fa-subtract{--fa:"";--fa--fa:""}.fa-subway{--fa:"īˆš";--fa--fa:"īˆšīˆš"}.fa-subway-tunnel{--fa:"îŠŖ";--fa--fa:"îŠŖîŠŖ"}.fa-suitcase{--fa:"";--fa--fa:""}.fa-suitcase-medical{--fa:"īƒē";--fa--fa:"īƒēīƒē"}.fa-suitcase-rolling{--fa:"";--fa--fa:""}.fa-sun{--fa:"";--fa--fa:""}.fa-sun-alt,.fa-sun-bright{--fa:"";--fa--fa:""}.fa-sun-cloud{--fa:"īŖ";--fa--fa:"īŖīŖ"}.fa-sun-dust{--fa:"ī¤";--fa--fa:"ī¤ī¤"}.fa-sun-haze{--fa:"īĨ";--fa--fa:"īĨīĨ"}.fa-sun-plant-wilt{--fa:"î•ē";--fa--fa:"î•ēî•ē"}.fa-sunglasses{--fa:"īĸ’";--fa--fa:"īĸ’īĸ’"}.fa-sunrise{--fa:"īĻ";--fa--fa:"īĻīĻ"}.fa-sunset{--fa:"ī§";--fa--fa:"ī§ī§"}.fa-superscript{--fa:"ī„Ģ";--fa--fa:"ī„Ģī„Ģ"}.fa-surprise{--fa:"ī—‚";--fa--fa:"ī—‚ī—‚"}.fa-sushi{--fa:"";--fa--fa:""}.fa-sushi-roll{--fa:"";--fa--fa:""}.fa-swap{--fa:"";--fa--fa:""}.fa-swap-arrows{--fa:"";--fa--fa:""}.fa-swatchbook{--fa:"ī—ƒ";--fa--fa:"ī—ƒī—ƒ"}.fa-swimmer{--fa:"ī—„";--fa--fa:"ī—„ī—„"}.fa-swimming-pool{--fa:"ī—…";--fa--fa:"ī—…ī—…"}.fa-sword{--fa:"";--fa--fa:""}.fa-sword-laser{--fa:"î€ģ";--fa--fa:"î€ģî€ģ"}.fa-sword-laser-alt{--fa:"î€ŧ";--fa--fa:"î€ŧî€ŧ"}.fa-swords{--fa:"īœ";--fa--fa:"īœīœ"}.fa-swords-laser{--fa:"î€Ŋ";--fa--fa:"î€Ŋî€Ŋ"}.fa-symbols{--fa:"īĄŽ";--fa--fa:"īĄŽīĄŽ"}.fa-synagogue{--fa:"īš›";--fa--fa:"īš›īš›"}.fa-sync{--fa:"ī€Ą";--fa--fa:"ī€Ąī€Ą"}.fa-sync-alt{--fa:"ī‹ą";--fa--fa:"ī‹ąī‹ą"}.fa-syringe{--fa:"ī’Ž";--fa--fa:"ī’Žī’Ž"}.fa-t{--fa:"T";--fa--fa:"TT"}.fa-t-rex{--fa:"";--fa--fa:""}.fa-t-shirt{--fa:"ī•“";--fa--fa:"ī•“ī•“"}.fa-table{--fa:"īƒŽ";--fa--fa:"īƒŽīƒŽ"}.fa-table-cells{--fa:"ī€Š";--fa--fa:"ī€Šī€Š"}.fa-table-cells-column-lock{--fa:"";--fa--fa:""}.fa-table-cells-column-unlock{--fa:"";--fa--fa:""}.fa-table-cells-large{--fa:"";--fa--fa:""}.fa-table-cells-lock{--fa:"";--fa--fa:""}.fa-table-cells-row-lock{--fa:"î™ē";--fa--fa:"î™ēî™ē"}.fa-table-cells-row-unlock{--fa:"";--fa--fa:""}.fa-table-cells-unlock{--fa:"";--fa--fa:""}.fa-table-columns{--fa:"īƒ›";--fa--fa:"īƒ›īƒ›"}.fa-table-layout{--fa:"";--fa--fa:""}.fa-table-list{--fa:"";--fa--fa:""}.fa-table-picnic{--fa:"";--fa--fa:""}.fa-table-pivot{--fa:"";--fa--fa:""}.fa-table-rows{--fa:"";--fa--fa:""}.fa-table-tennis,.fa-table-tennis-paddle-ball{--fa:"ī‘";--fa--fa:"ī‘ī‘"}.fa-table-tree{--fa:"";--fa--fa:""}.fa-tablet{--fa:"īģ";--fa--fa:"īģīģ"}.fa-tablet-alt{--fa:"īē";--fa--fa:"īēīē"}.fa-tablet-android{--fa:"īģ";--fa--fa:"īģīģ"}.fa-tablet-android-alt{--fa:"īŧ";--fa--fa:"īŧīŧ"}.fa-tablet-button{--fa:"ī„Š";--fa--fa:"ī„Šī„Š"}.fa-tablet-rugged{--fa:"ī’";--fa--fa:"ī’ī’"}.fa-tablet-screen{--fa:"īŧ";--fa--fa:"īŧīŧ"}.fa-tablet-screen-button{--fa:"īē";--fa--fa:"īēīē"}.fa-tablets{--fa:"";--fa--fa:""}.fa-tachograph-digital{--fa:"ī•Ļ";--fa--fa:"ī•Ļī•Ļ"}.fa-tachometer{--fa:"ī˜Ē";--fa--fa:"ī˜Ēī˜Ē"}.fa-tachometer-alt{--fa:"ī˜Ĩ";--fa--fa:"ī˜Ĩī˜Ĩ"}.fa-tachometer-alt-average{--fa:"";--fa--fa:""}.fa-tachometer-alt-fast{--fa:"ī˜Ĩ";--fa--fa:"ī˜Ĩī˜Ĩ"}.fa-tachometer-alt-fastest{--fa:"ī˜Ļ";--fa--fa:"ī˜Ļī˜Ļ"}.fa-tachometer-alt-slow{--fa:"";--fa--fa:""}.fa-tachometer-alt-slowest{--fa:"";--fa--fa:""}.fa-tachometer-average{--fa:"ī˜Š";--fa--fa:"ī˜Šī˜Š"}.fa-tachometer-fast{--fa:"ī˜Ē";--fa--fa:"ī˜Ēī˜Ē"}.fa-tachometer-fastest{--fa:"ī˜Ģ";--fa--fa:"ī˜Ģī˜Ģ"}.fa-tachometer-slow{--fa:"ī˜Ŧ";--fa--fa:"ī˜Ŧī˜Ŧ"}.fa-tachometer-slowest{--fa:"";--fa--fa:""}.fa-taco{--fa:"ī Ļ";--fa--fa:"ī Ļī Ļ"}.fa-tag{--fa:"ī€Ģ";--fa--fa:"ī€Ģī€Ģ"}.fa-tags{--fa:"ī€Ŧ";--fa--fa:"ī€Ŧī€Ŧ"}.fa-tally{--fa:"";--fa--fa:""}.fa-tally-1{--fa:"";--fa--fa:""}.fa-tally-2{--fa:"";--fa--fa:""}.fa-tally-3{--fa:"";--fa--fa:""}.fa-tally-4{--fa:"";--fa--fa:""}.fa-tally-5{--fa:"";--fa--fa:""}.fa-tamale{--fa:"";--fa--fa:""}.fa-tanakh{--fa:"ī §";--fa--fa:"ī §ī §"}.fa-tank-water{--fa:"";--fa--fa:""}.fa-tape{--fa:"ī“›";--fa--fa:""}.fa-tarp{--fa:"î•ģ";--fa--fa:"î•ģî•ģ"}.fa-tarp-droplet{--fa:"î•ŧ";--fa--fa:"î•ŧî•ŧ"}.fa-tasks{--fa:"ī‚Ž";--fa--fa:"ī‚Žī‚Ž"}.fa-tasks-alt{--fa:"ī ¨";--fa--fa:""}.fa-taxi{--fa:"ī†ē";--fa--fa:"ī†ēī†ē"}.fa-taxi-bus{--fa:"";--fa--fa:""}.fa-teddy-bear{--fa:"";--fa--fa:""}.fa-teeth{--fa:"ī˜Ž";--fa--fa:"ī˜Žī˜Ž"}.fa-teeth-open{--fa:"";--fa--fa:""}.fa-telescope{--fa:"";--fa--fa:""}.fa-teletype{--fa:"";--fa--fa:""}.fa-teletype-answer{--fa:"";--fa--fa:""}.fa-television{--fa:"ī‰Ŧ";--fa--fa:"ī‰Ŧī‰Ŧ"}.fa-temperature-0{--fa:"ī‹‹";--fa--fa:"ī‹‹ī‹‹"}.fa-temperature-1{--fa:"ī‹Š";--fa--fa:"ī‹Šī‹Š"}.fa-temperature-2{--fa:"";--fa--fa:""}.fa-temperature-3{--fa:"ī‹ˆ";--fa--fa:"ī‹ˆī‹ˆ"}.fa-temperature-4{--fa:"";--fa--fa:""}.fa-temperature-arrow-down{--fa:"î€ŋ";--fa--fa:"î€ŋî€ŋ"}.fa-temperature-arrow-up{--fa:"";--fa--fa:""}.fa-temperature-down{--fa:"î€ŋ";--fa--fa:"î€ŋî€ŋ"}.fa-temperature-empty{--fa:"ī‹‹";--fa--fa:"ī‹‹ī‹‹"}.fa-temperature-frigid{--fa:"ī¨";--fa--fa:"ī¨ī¨"}.fa-temperature-full{--fa:"";--fa--fa:""}.fa-temperature-half{--fa:"";--fa--fa:""}.fa-temperature-high{--fa:"īŠ";--fa--fa:"īŠīŠ"}.fa-temperature-hot{--fa:"īĒ";--fa--fa:"īĒīĒ"}.fa-temperature-list{--fa:"";--fa--fa:""}.fa-temperature-low{--fa:"īĢ";--fa--fa:"īĢīĢ"}.fa-temperature-quarter{--fa:"ī‹Š";--fa--fa:"ī‹Šī‹Š"}.fa-temperature-snow{--fa:"ī¨";--fa--fa:"ī¨ī¨"}.fa-temperature-sun{--fa:"īĒ";--fa--fa:"īĒīĒ"}.fa-temperature-three-quarters{--fa:"ī‹ˆ";--fa--fa:"ī‹ˆī‹ˆ"}.fa-temperature-up{--fa:"";--fa--fa:""}.fa-tenge,.fa-tenge-sign{--fa:"īŸ—";--fa--fa:"īŸ—īŸ—"}.fa-tennis-ball{--fa:"ī‘ž";--fa--fa:"ī‘žī‘ž"}.fa-tent{--fa:"î•Ŋ";--fa--fa:"î•Ŋî•Ŋ"}.fa-tent-arrow-down-to-line{--fa:"";--fa--fa:""}.fa-tent-arrow-left-right{--fa:"î•ŋ";--fa--fa:"î•ŋî•ŋ"}.fa-tent-arrow-turn-left{--fa:"";--fa--fa:""}.fa-tent-arrows-down{--fa:"";--fa--fa:""}.fa-tent-double-peak{--fa:"";--fa--fa:""}.fa-tents{--fa:"";--fa--fa:""}.fa-terminal{--fa:"ī„ ";--fa--fa:"ī„ ī„ "}.fa-text{--fa:"īĸ“";--fa--fa:"īĸ“īĸ“"}.fa-text-height{--fa:"";--fa--fa:""}.fa-text-size{--fa:"īĸ”";--fa--fa:"īĸ”īĸ”"}.fa-text-slash{--fa:"īĄŊ";--fa--fa:"īĄŊīĄŊ"}.fa-text-width{--fa:"ī€ĩ";--fa--fa:"ī€ĩī€ĩ"}.fa-th{--fa:"ī€Š";--fa--fa:"ī€Šī€Š"}.fa-th-large{--fa:"";--fa--fa:""}.fa-th-list{--fa:"";--fa--fa:""}.fa-theater-masks{--fa:"";--fa--fa:""}.fa-thermometer{--fa:"ī’‘";--fa--fa:"ī’‘ī’‘"}.fa-thermometer-0{--fa:"ī‹‹";--fa--fa:"ī‹‹ī‹‹"}.fa-thermometer-1{--fa:"ī‹Š";--fa--fa:"ī‹Šī‹Š"}.fa-thermometer-2{--fa:"";--fa--fa:""}.fa-thermometer-3{--fa:"ī‹ˆ";--fa--fa:"ī‹ˆī‹ˆ"}.fa-thermometer-4{--fa:"";--fa--fa:""}.fa-thermometer-empty{--fa:"ī‹‹";--fa--fa:"ī‹‹ī‹‹"}.fa-thermometer-full{--fa:"";--fa--fa:""}.fa-thermometer-half{--fa:"";--fa--fa:""}.fa-thermometer-quarter{--fa:"ī‹Š";--fa--fa:"ī‹Šī‹Š"}.fa-thermometer-three-quarters{--fa:"ī‹ˆ";--fa--fa:"ī‹ˆī‹ˆ"}.fa-theta{--fa:"īšž";--fa--fa:"īšžīšž"}.fa-thought-bubble{--fa:"";--fa--fa:""}.fa-thumb-tack{--fa:"ī‚";--fa--fa:"ī‚ī‚"}.fa-thumb-tack-slash{--fa:"";--fa--fa:""}.fa-thumbs-down{--fa:"ī…Ĩ";--fa--fa:"ī…Ĩī…Ĩ"}.fa-thumbs-up{--fa:"ī…¤";--fa--fa:""}.fa-thumbtack{--fa:"ī‚";--fa--fa:"ī‚ī‚"}.fa-thumbtack-slash{--fa:"";--fa--fa:""}.fa-thunderstorm{--fa:"īŦ";--fa--fa:"īŦīŦ"}.fa-thunderstorm-moon{--fa:"ī­";--fa--fa:"ī­ī­"}.fa-thunderstorm-sun{--fa:"īŽ";--fa--fa:"īŽīŽ"}.fa-tick{--fa:"";--fa--fa:""}.fa-ticket{--fa:"ī……";--fa--fa:"ī……ī……"}.fa-ticket-airline{--fa:"";--fa--fa:""}.fa-ticket-alt{--fa:"īŋ";--fa--fa:"īŋīŋ"}.fa-ticket-perforated{--fa:"";--fa--fa:""}.fa-ticket-perforated-plane,.fa-ticket-plane{--fa:"";--fa--fa:""}.fa-ticket-simple{--fa:"īŋ";--fa--fa:"īŋīŋ"}.fa-tickets{--fa:"";--fa--fa:""}.fa-tickets-airline{--fa:"";--fa--fa:""}.fa-tickets-perforated{--fa:"î˜ŋ";--fa--fa:"î˜ŋî˜ŋ"}.fa-tickets-perforated-plane,.fa-tickets-plane{--fa:"";--fa--fa:""}.fa-tickets-simple{--fa:"";--fa--fa:""}.fa-tilde{--fa:"~";--fa--fa:"~~"}.fa-timeline{--fa:"";--fa--fa:""}.fa-timeline-arrow{--fa:"";--fa--fa:""}.fa-timer{--fa:"";--fa--fa:""}.fa-times{--fa:"ī€";--fa--fa:"ī€ī€"}.fa-times-circle{--fa:"";--fa--fa:""}.fa-times-hexagon{--fa:"ī‹Ž";--fa--fa:"ī‹Žī‹Ž"}.fa-times-octagon{--fa:"ī‹°";--fa--fa:"ī‹°ī‹°"}.fa-times-rectangle{--fa:"";--fa--fa:""}.fa-times-square{--fa:"ī‹“";--fa--fa:"ī‹“ī‹“"}.fa-times-to-slot{--fa:"īą";--fa--fa:"īąīą"}.fa-tint{--fa:"";--fa--fa:""}.fa-tint-slash{--fa:"ī—‡";--fa--fa:""}.fa-tire{--fa:"ī˜ą";--fa--fa:"ī˜ąī˜ą"}.fa-tire-flat{--fa:"";--fa--fa:""}.fa-tire-pressure-warning{--fa:"";--fa--fa:""}.fa-tire-rugged{--fa:"";--fa--fa:""}.fa-tired{--fa:"ī—ˆ";--fa--fa:"ī—ˆī—ˆ"}.fa-toggle-large-off{--fa:"";--fa--fa:""}.fa-toggle-large-on{--fa:"";--fa--fa:""}.fa-toggle-off{--fa:"īˆ„";--fa--fa:"īˆ„īˆ„"}.fa-toggle-on{--fa:"īˆ…";--fa--fa:"īˆ…īˆ…"}.fa-toilet{--fa:"";--fa--fa:""}.fa-toilet-paper{--fa:"īœž";--fa--fa:"īœžīœž"}.fa-toilet-paper-alt,.fa-toilet-paper-blank{--fa:"";--fa--fa:""}.fa-toilet-paper-blank-under{--fa:"";--fa--fa:""}.fa-toilet-paper-check{--fa:"";--fa--fa:""}.fa-toilet-paper-reverse{--fa:"";--fa--fa:""}.fa-toilet-paper-reverse-alt{--fa:"";--fa--fa:""}.fa-toilet-paper-reverse-slash{--fa:"";--fa--fa:""}.fa-toilet-paper-slash{--fa:"";--fa--fa:""}.fa-toilet-paper-under{--fa:"";--fa--fa:""}.fa-toilet-paper-under-slash{--fa:"";--fa--fa:""}.fa-toilet-paper-xmark{--fa:"î–ŗ";--fa--fa:"î–ŗî–ŗ"}.fa-toilet-portable{--fa:"";--fa--fa:""}.fa-toilets-portable{--fa:"";--fa--fa:""}.fa-tomato{--fa:"";--fa--fa:""}.fa-tombstone{--fa:"";--fa--fa:""}.fa-tombstone-alt,.fa-tombstone-blank{--fa:"īœĄ";--fa--fa:"īœĄīœĄ"}.fa-toolbox{--fa:"ī•’";--fa--fa:"ī•’ī•’"}.fa-tools{--fa:"īŸ™";--fa--fa:"īŸ™īŸ™"}.fa-tooth{--fa:"ī—‰";--fa--fa:""}.fa-toothbrush{--fa:"ī˜ĩ";--fa--fa:"ī˜ĩī˜ĩ"}.fa-torah{--fa:"";--fa--fa:""}.fa-torii-gate{--fa:"īšĄ";--fa--fa:"īšĄīšĄ"}.fa-tornado{--fa:"ī¯";--fa--fa:"ī¯ī¯"}.fa-tower-broadcast{--fa:"ī”™";--fa--fa:""}.fa-tower-cell{--fa:"";--fa--fa:""}.fa-tower-control{--fa:"îŠĸ";--fa--fa:"îŠĸîŠĸ"}.fa-tower-observation{--fa:"";--fa--fa:""}.fa-tractor{--fa:"īœĸ";--fa--fa:"īœĸīœĸ"}.fa-trademark{--fa:"ī‰œ";--fa--fa:"ī‰œī‰œ"}.fa-traffic-cone{--fa:"ī˜ļ";--fa--fa:"ī˜ļī˜ļ"}.fa-traffic-light{--fa:"";--fa--fa:""}.fa-traffic-light-go{--fa:"";--fa--fa:""}.fa-traffic-light-slow{--fa:"ī˜š";--fa--fa:"ī˜šī˜š"}.fa-traffic-light-stop{--fa:"ī˜ē";--fa--fa:"ī˜ēī˜ē"}.fa-trailer{--fa:"";--fa--fa:""}.fa-train{--fa:"";--fa--fa:""}.fa-train-subway{--fa:"īˆš";--fa--fa:"īˆšīˆš"}.fa-train-subway-tunnel{--fa:"îŠŖ";--fa--fa:"îŠŖîŠŖ"}.fa-train-track{--fa:"";--fa--fa:""}.fa-train-tram{--fa:"";--fa--fa:""}.fa-train-tunnel{--fa:"";--fa--fa:""}.fa-tram{--fa:"";--fa--fa:""}.fa-transformer-bolt{--fa:"";--fa--fa:""}.fa-transgender,.fa-transgender-alt{--fa:"īˆĨ";--fa--fa:"īˆĨīˆĨ"}.fa-transporter{--fa:"";--fa--fa:""}.fa-transporter-1{--fa:"";--fa--fa:""}.fa-transporter-2{--fa:"";--fa--fa:""}.fa-transporter-3{--fa:"";--fa--fa:""}.fa-transporter-4{--fa:"îŠĨ";--fa--fa:"îŠĨîŠĨ"}.fa-transporter-5{--fa:"îŠĻ";--fa--fa:"îŠĻîŠĻ"}.fa-transporter-6{--fa:"";--fa--fa:""}.fa-transporter-7{--fa:"";--fa--fa:""}.fa-transporter-empty{--fa:"";--fa--fa:""}.fa-trash{--fa:"";--fa--fa:""}.fa-trash-alt{--fa:"ī‹­";--fa--fa:"ī‹­ī‹­"}.fa-trash-alt-slash{--fa:"";--fa--fa:""}.fa-trash-arrow-turn-left{--fa:"īĸ•";--fa--fa:"īĸ•īĸ•"}.fa-trash-arrow-up{--fa:"ī Š";--fa--fa:"ī Šī Š"}.fa-trash-can{--fa:"ī‹­";--fa--fa:"ī‹­ī‹­"}.fa-trash-can-arrow-turn-left{--fa:"īĸ–";--fa--fa:"īĸ–īĸ–"}.fa-trash-can-arrow-up{--fa:"ī Ē";--fa--fa:"ī Ēī Ē"}.fa-trash-can-check{--fa:"";--fa--fa:""}.fa-trash-can-clock{--fa:"îŠĒ";--fa--fa:"îŠĒîŠĒ"}.fa-trash-can-list{--fa:"îŠĢ";--fa--fa:"îŠĢîŠĢ"}.fa-trash-can-plus{--fa:"îŠŦ";--fa--fa:"îŠŦîŠŦ"}.fa-trash-can-slash{--fa:"";--fa--fa:""}.fa-trash-can-undo{--fa:"īĸ–";--fa--fa:"īĸ–īĸ–"}.fa-trash-can-xmark{--fa:"";--fa--fa:""}.fa-trash-check{--fa:"";--fa--fa:""}.fa-trash-circle{--fa:"î„Ļ";--fa--fa:"î„Ļî„Ļ"}.fa-trash-clock{--fa:"";--fa--fa:""}.fa-trash-list{--fa:"";--fa--fa:""}.fa-trash-plus{--fa:"";--fa--fa:""}.fa-trash-restore{--fa:"ī Š";--fa--fa:"ī Šī Š"}.fa-trash-restore-alt{--fa:"ī Ē";--fa--fa:"ī Ēī Ē"}.fa-trash-slash{--fa:"îŠŗ";--fa--fa:"îŠŗîŠŗ"}.fa-trash-undo{--fa:"īĸ•";--fa--fa:"īĸ•īĸ•"}.fa-trash-undo-alt{--fa:"īĸ–";--fa--fa:"īĸ–īĸ–"}.fa-trash-xmark{--fa:"";--fa--fa:""}.fa-treasure-chest{--fa:"";--fa--fa:""}.fa-tree{--fa:"ī†ģ";--fa--fa:"ī†ģī†ģ"}.fa-tree-alt{--fa:"";--fa--fa:""}.fa-tree-christmas{--fa:"īŸ›";--fa--fa:"īŸ›īŸ›"}.fa-tree-city{--fa:"";--fa--fa:""}.fa-tree-deciduous{--fa:"";--fa--fa:""}.fa-tree-decorated{--fa:"";--fa--fa:""}.fa-tree-large{--fa:"īŸ";--fa--fa:"īŸīŸ"}.fa-tree-palm{--fa:"ī Ģ";--fa--fa:"ī Ģī Ģ"}.fa-trees{--fa:"";--fa--fa:""}.fa-trian-balbot{--fa:"";--fa--fa:""}.fa-triangle{--fa:"ī‹Ŧ";--fa--fa:"ī‹Ŧī‹Ŧ"}.fa-triangle-circle-square{--fa:"";--fa--fa:""}.fa-triangle-exclamation{--fa:"īą";--fa--fa:"īąīą"}.fa-triangle-instrument,.fa-triangle-music{--fa:"īŖĸ";--fa--fa:"īŖĸīŖĸ"}.fa-triangle-person-digging{--fa:"īĄ";--fa--fa:"īĄīĄ"}.fa-tricycle{--fa:"";--fa--fa:""}.fa-tricycle-adult{--fa:"";--fa--fa:""}.fa-trillium{--fa:"";--fa--fa:""}.fa-trophy{--fa:"ī‚‘";--fa--fa:"ī‚‘ī‚‘"}.fa-trophy-alt,.fa-trophy-star{--fa:"ī‹Ģ";--fa--fa:"ī‹Ģī‹Ģ"}.fa-trowel{--fa:"";--fa--fa:""}.fa-trowel-bricks{--fa:"";--fa--fa:""}.fa-truck{--fa:"īƒ‘";--fa--fa:"īƒ‘īƒ‘"}.fa-truck-arrow-right{--fa:"";--fa--fa:""}.fa-truck-bolt{--fa:"";--fa--fa:""}.fa-truck-clock{--fa:"ī’Œ";--fa--fa:"ī’Œī’Œ"}.fa-truck-container{--fa:"ī“œ";--fa--fa:"ī“œī“œ"}.fa-truck-container-empty{--fa:"îŠĩ";--fa--fa:"îŠĩîŠĩ"}.fa-truck-couch{--fa:"ī“";--fa--fa:"ī“ī“"}.fa-truck-droplet{--fa:"";--fa--fa:""}.fa-truck-fast{--fa:"ī’‹";--fa--fa:"ī’‹ī’‹"}.fa-truck-field{--fa:"";--fa--fa:""}.fa-truck-field-un{--fa:"";--fa--fa:""}.fa-truck-fire{--fa:"";--fa--fa:""}.fa-truck-flatbed{--fa:"îŠļ";--fa--fa:"îŠļîŠļ"}.fa-truck-front{--fa:"";--fa--fa:""}.fa-truck-ladder{--fa:"";--fa--fa:""}.fa-truck-loading{--fa:"ī“ž";--fa--fa:"ī“žī“ž"}.fa-truck-medical{--fa:"īƒš";--fa--fa:"īƒšīƒš"}.fa-truck-monster{--fa:"ī˜ģ";--fa--fa:"ī˜ģī˜ģ"}.fa-truck-moving{--fa:"ī“Ÿ";--fa--fa:"ī“Ÿī“Ÿ"}.fa-truck-pickup{--fa:"ī˜ŧ";--fa--fa:"ī˜ŧī˜ŧ"}.fa-truck-plane{--fa:"";--fa--fa:""}.fa-truck-plow{--fa:"īŸž";--fa--fa:"īŸžīŸž"}.fa-truck-ramp{--fa:"ī“ ";--fa--fa:"ī“ ī“ "}.fa-truck-ramp-box{--fa:"ī“ž";--fa--fa:"ī“žī“ž"}.fa-truck-ramp-couch{--fa:"ī“";--fa--fa:"ī“ī“"}.fa-truck-tow{--fa:"";--fa--fa:""}.fa-truck-utensils{--fa:"";--fa--fa:""}.fa-trumpet{--fa:"īŖŖ";--fa--fa:"īŖŖīŖŖ"}.fa-try{--fa:"îŠģ";--fa--fa:"îŠģîŠģ"}.fa-tshirt{--fa:"ī•“";--fa--fa:"ī•“ī•“"}.fa-tty{--fa:"";--fa--fa:""}.fa-tty-answer{--fa:"";--fa--fa:""}.fa-tugrik-sign{--fa:"îŠē";--fa--fa:"îŠēîŠē"}.fa-turkey{--fa:"īœĨ";--fa--fa:"īœĨīœĨ"}.fa-turkish-lira,.fa-turkish-lira-sign{--fa:"îŠģ";--fa--fa:"îŠģîŠģ"}.fa-turn-down{--fa:"īŽž";--fa--fa:"īŽžīŽž"}.fa-turn-down-left{--fa:"";--fa--fa:""}.fa-turn-down-right{--fa:"";--fa--fa:""}.fa-turn-left{--fa:"î˜ļ";--fa--fa:"î˜ļî˜ļ"}.fa-turn-left-down{--fa:"";--fa--fa:""}.fa-turn-left-up{--fa:"";--fa--fa:""}.fa-turn-right{--fa:"";--fa--fa:""}.fa-turn-up{--fa:"īŽŋ";--fa--fa:"īŽŋīŽŋ"}.fa-turntable{--fa:"īŖ¤";--fa--fa:""}.fa-turtle{--fa:"īœĻ";--fa--fa:"īœĻīœĻ"}.fa-tv,.fa-tv-alt{--fa:"ī‰Ŧ";--fa--fa:"ī‰Ŧī‰Ŧ"}.fa-tv-music{--fa:"īŖĻ";--fa--fa:"īŖĻīŖĻ"}.fa-tv-retro{--fa:"";--fa--fa:""}.fa-typewriter{--fa:"īŖ§";--fa--fa:"īŖ§īŖ§"}.fa-u{--fa:"U";--fa--fa:"UU"}.fa-ufo{--fa:"";--fa--fa:""}.fa-ufo-beam{--fa:"";--fa--fa:""}.fa-umbrella{--fa:"īƒŠ";--fa--fa:"īƒŠīƒŠ"}.fa-umbrella-alt{--fa:"îŠŧ";--fa--fa:"îŠŧîŠŧ"}.fa-umbrella-beach{--fa:"ī—Š";--fa--fa:"ī—Šī—Š"}.fa-umbrella-simple{--fa:"îŠŧ";--fa--fa:"îŠŧîŠŧ"}.fa-underline{--fa:"īƒ";--fa--fa:"īƒīƒ"}.fa-undo{--fa:"īƒĸ";--fa--fa:"īƒĸīƒĸ"}.fa-undo-alt{--fa:"ī‹Ē";--fa--fa:"ī‹Ēī‹Ē"}.fa-unicorn{--fa:"";--fa--fa:""}.fa-uniform-martial-arts{--fa:"";--fa--fa:""}.fa-union{--fa:"īšĸ";--fa--fa:"īšĸīšĸ"}.fa-universal-access{--fa:"";--fa--fa:""}.fa-university{--fa:"ī†œ";--fa--fa:"ī†œī†œ"}.fa-unlink{--fa:"ī„§";--fa--fa:"ī„§ī„§"}.fa-unlock{--fa:"ī‚œ";--fa--fa:"ī‚œī‚œ"}.fa-unlock-alt,.fa-unlock-keyhole{--fa:"ī„ž";--fa--fa:"ī„žī„ž"}.fa-unsorted{--fa:"";--fa--fa:""}.fa-up{--fa:"ī—";--fa--fa:"ī—ī—"}.fa-up-down{--fa:"";--fa--fa:""}.fa-up-down-left-right{--fa:"";--fa--fa:""}.fa-up-from-bracket{--fa:"";--fa--fa:""}.fa-up-from-dotted-line{--fa:"";--fa--fa:""}.fa-up-from-line{--fa:"ī†";--fa--fa:"ī†ī†"}.fa-up-left{--fa:"îŠŊ";--fa--fa:"îŠŊîŠŊ"}.fa-up-long{--fa:"";--fa--fa:""}.fa-up-right{--fa:"";--fa--fa:""}.fa-up-right-and-down-left-from-center{--fa:"";--fa--fa:""}.fa-up-right-from-square{--fa:"ī";--fa--fa:"īī"}.fa-up-to-bracket{--fa:"";--fa--fa:""}.fa-up-to-dotted-line{--fa:"";--fa--fa:""}.fa-up-to-line{--fa:"ī";--fa--fa:"īī"}.fa-upload{--fa:"ī‚“";--fa--fa:"ī‚“ī‚“"}.fa-usb-drive{--fa:"īŖŠ";--fa--fa:"īŖŠīŖŠ"}.fa-usd{--fa:"$";--fa--fa:"$$"}.fa-usd-circle{--fa:"";--fa--fa:""}.fa-usd-square{--fa:"ī‹Š";--fa--fa:"ī‹Šī‹Š"}.fa-user{--fa:"";--fa--fa:""}.fa-user-alien{--fa:"";--fa--fa:""}.fa-user-alt{--fa:"";--fa--fa:""}.fa-user-alt-slash{--fa:"ī“ē";--fa--fa:"ī“ēī“ē"}.fa-user-astronaut{--fa:"ī“ģ";--fa--fa:"ī“ģī“ģ"}.fa-user-beard-bolt{--fa:"";--fa--fa:""}.fa-user-bounty-hunter{--fa:"îŠŋ";--fa--fa:"îŠŋîŠŋ"}.fa-user-chart{--fa:"";--fa--fa:""}.fa-user-check{--fa:"ī“ŧ";--fa--fa:"ī“ŧī“ŧ"}.fa-user-chef{--fa:"";--fa--fa:""}.fa-user-circle{--fa:"īŠŊ";--fa--fa:"īŠŊīŠŊ"}.fa-user-clock{--fa:"ī“Ŋ";--fa--fa:"ī“Ŋī“Ŋ"}.fa-user-cog{--fa:"ī“ž";--fa--fa:"ī“žī“ž"}.fa-user-construction{--fa:"ī Ŧ";--fa--fa:"ī Ŧī Ŧ"}.fa-user-cowboy{--fa:"īŖĒ";--fa--fa:"īŖĒīŖĒ"}.fa-user-crown{--fa:"";--fa--fa:""}.fa-user-doctor{--fa:"";--fa--fa:""}.fa-user-doctor-hair{--fa:"";--fa--fa:""}.fa-user-doctor-hair-long{--fa:"";--fa--fa:""}.fa-user-doctor-message{--fa:"ī Ž";--fa--fa:"ī Žī Ž"}.fa-user-edit{--fa:"ī“ŋ";--fa--fa:"ī“ŋī“ŋ"}.fa-user-friends{--fa:"";--fa--fa:""}.fa-user-gear{--fa:"ī“ž";--fa--fa:"ī“žī“ž"}.fa-user-graduate{--fa:"";--fa--fa:""}.fa-user-group{--fa:"";--fa--fa:""}.fa-user-group-crown{--fa:"īšĨ";--fa--fa:"īšĨīšĨ"}.fa-user-group-simple{--fa:"";--fa--fa:""}.fa-user-hair{--fa:"";--fa--fa:""}.fa-user-hair-buns{--fa:"";--fa--fa:""}.fa-user-hair-long{--fa:"";--fa--fa:""}.fa-user-hair-mullet{--fa:"";--fa--fa:""}.fa-user-hard-hat{--fa:"ī Ŧ";--fa--fa:"ī Ŧī Ŧ"}.fa-user-headset{--fa:"ī ­";--fa--fa:"ī ­ī ­"}.fa-user-helmet-safety{--fa:"ī Ŧ";--fa--fa:"ī Ŧī Ŧ"}.fa-user-hoodie{--fa:"";--fa--fa:""}.fa-user-injured{--fa:"";--fa--fa:""}.fa-user-large{--fa:"";--fa--fa:""}.fa-user-large-slash{--fa:"ī“ē";--fa--fa:"ī“ēī“ē"}.fa-user-lock{--fa:"";--fa--fa:""}.fa-user-magnifying-glass{--fa:"";--fa--fa:""}.fa-user-md{--fa:"";--fa--fa:""}.fa-user-md-chat{--fa:"ī Ž";--fa--fa:"ī Žī Ž"}.fa-user-minus{--fa:"ī”ƒ";--fa--fa:"ī”ƒī”ƒ"}.fa-user-music{--fa:"īŖĢ";--fa--fa:"īŖĢīŖĢ"}.fa-user-ninja{--fa:"";--fa--fa:""}.fa-user-nurse{--fa:"ī ¯";--fa--fa:""}.fa-user-nurse-hair{--fa:"";--fa--fa:""}.fa-user-nurse-hair-long{--fa:"";--fa--fa:""}.fa-user-pen{--fa:"ī“ŋ";--fa--fa:"ī“ŋī“ŋ"}.fa-user-pilot{--fa:"";--fa--fa:""}.fa-user-pilot-tie{--fa:"";--fa--fa:""}.fa-user-plus{--fa:"";--fa--fa:""}.fa-user-police{--fa:"îŒŗ";--fa--fa:"îŒŗîŒŗ"}.fa-user-police-tie{--fa:"";--fa--fa:""}.fa-user-robot{--fa:"";--fa--fa:""}.fa-user-robot-xmarks{--fa:"";--fa--fa:""}.fa-user-secret{--fa:"īˆ›";--fa--fa:"īˆ›īˆ›"}.fa-user-shakespeare{--fa:"";--fa--fa:""}.fa-user-shield{--fa:"ī”…";--fa--fa:"ī”…ī”…"}.fa-user-slash{--fa:"";--fa--fa:""}.fa-user-tag{--fa:"";--fa--fa:""}.fa-user-tie{--fa:"ī”ˆ";--fa--fa:"ī”ˆī”ˆ"}.fa-user-tie-hair{--fa:"";--fa--fa:""}.fa-user-tie-hair-long{--fa:"";--fa--fa:""}.fa-user-times{--fa:"īˆĩ";--fa--fa:"īˆĩīˆĩ"}.fa-user-unlock{--fa:"";--fa--fa:""}.fa-user-visor{--fa:"";--fa--fa:""}.fa-user-vneck{--fa:"";--fa--fa:""}.fa-user-vneck-hair{--fa:"î‘ĸ";--fa--fa:"î‘ĸî‘ĸ"}.fa-user-vneck-hair-long{--fa:"î‘Ŗ";--fa--fa:"î‘Ŗî‘Ŗ"}.fa-user-xmark{--fa:"īˆĩ";--fa--fa:"īˆĩīˆĩ"}.fa-users{--fa:"īƒ€";--fa--fa:"īƒ€īƒ€"}.fa-users-between-lines{--fa:"";--fa--fa:""}.fa-users-class{--fa:"ī˜Ŋ";--fa--fa:"ī˜Ŋī˜Ŋ"}.fa-users-cog{--fa:"";--fa--fa:""}.fa-users-crown{--fa:"īšĨ";--fa--fa:"īšĨīšĨ"}.fa-users-gear{--fa:"";--fa--fa:""}.fa-users-line{--fa:"";--fa--fa:""}.fa-users-medical{--fa:"ī °";--fa--fa:"ī °ī °"}.fa-users-rays{--fa:"";--fa--fa:""}.fa-users-rectangle{--fa:"";--fa--fa:""}.fa-users-slash{--fa:"îŗ";--fa--fa:"îŗîŗ"}.fa-users-viewfinder{--fa:"";--fa--fa:""}.fa-utensil-fork{--fa:"";--fa--fa:""}.fa-utensil-knife{--fa:"";--fa--fa:""}.fa-utensil-spoon{--fa:"ī‹Ĩ";--fa--fa:"ī‹Ĩī‹Ĩ"}.fa-utensils{--fa:"ī‹§";--fa--fa:"ī‹§ī‹§"}.fa-utensils-alt{--fa:"ī‹Ļ";--fa--fa:"ī‹Ļī‹Ļ"}.fa-utensils-slash{--fa:"";--fa--fa:""}.fa-utility-pole{--fa:"";--fa--fa:""}.fa-utility-pole-double{--fa:"";--fa--fa:""}.fa-v{--fa:"V";--fa--fa:"VV"}.fa-vacuum{--fa:"";--fa--fa:""}.fa-vacuum-robot{--fa:"";--fa--fa:""}.fa-value-absolute{--fa:"īšĻ";--fa--fa:"īšĻīšĻ"}.fa-van-shuttle{--fa:"ī–ļ";--fa--fa:"ī–ļī–ļ"}.fa-vault{--fa:"";--fa--fa:""}.fa-vcard{--fa:"īŠģ";--fa--fa:"īŠģīŠģ"}.fa-vector-circle{--fa:"";--fa--fa:""}.fa-vector-polygon{--fa:"";--fa--fa:""}.fa-vector-square{--fa:"ī—‹";--fa--fa:"ī—‹ī—‹"}.fa-vent-damper{--fa:"î‘Ĩ";--fa--fa:"î‘Ĩî‘Ĩ"}.fa-venus{--fa:"īˆĄ";--fa--fa:"īˆĄīˆĄ"}.fa-venus-double{--fa:"īˆĻ";--fa--fa:"īˆĻīˆĻ"}.fa-venus-mars{--fa:"";--fa--fa:""}.fa-vest{--fa:"";--fa--fa:""}.fa-vest-patches{--fa:"";--fa--fa:""}.fa-vhs{--fa:"īŖŦ";--fa--fa:"īŖŦīŖŦ"}.fa-vial{--fa:"ī’’";--fa--fa:"ī’’ī’’"}.fa-vial-circle-check{--fa:"";--fa--fa:""}.fa-vial-virus{--fa:"";--fa--fa:""}.fa-vials{--fa:"ī’“";--fa--fa:"ī’“ī’“"}.fa-video{--fa:"ī€Ŋ";--fa--fa:"ī€Ŋī€Ŋ"}.fa-video-arrow-down-left{--fa:"";--fa--fa:""}.fa-video-arrow-up-right{--fa:"";--fa--fa:""}.fa-video-camera{--fa:"ī€Ŋ";--fa--fa:"ī€Ŋī€Ŋ"}.fa-video-circle{--fa:"î„Ģ";--fa--fa:"î„Ģî„Ģ"}.fa-video-handheld{--fa:"īĸ¨";--fa--fa:"īĸ¨īĸ¨"}.fa-video-plus{--fa:"ī“Ą";--fa--fa:"ī“Ąī“Ą"}.fa-video-slash{--fa:"ī“ĸ";--fa--fa:"ī“ĸī“ĸ"}.fa-vihara{--fa:"";--fa--fa:""}.fa-violin{--fa:"īŖ­";--fa--fa:"īŖ­īŖ­"}.fa-virus{--fa:"";--fa--fa:""}.fa-virus-covid{--fa:"";--fa--fa:""}.fa-virus-covid-slash{--fa:"";--fa--fa:""}.fa-virus-slash{--fa:"îĩ";--fa--fa:"îĩîĩ"}.fa-viruses{--fa:"îļ";--fa--fa:"îļîļ"}.fa-voicemail{--fa:"īĸ—";--fa--fa:"īĸ—īĸ—"}.fa-volcano{--fa:"ī°";--fa--fa:"ī°ī°"}.fa-volleyball,.fa-volleyball-ball{--fa:"ī‘Ÿ";--fa--fa:"ī‘Ÿī‘Ÿ"}.fa-volume{--fa:"";--fa--fa:""}.fa-volume-control-phone{--fa:"";--fa--fa:""}.fa-volume-down{--fa:"";--fa--fa:""}.fa-volume-high{--fa:"";--fa--fa:""}.fa-volume-low{--fa:"";--fa--fa:""}.fa-volume-medium{--fa:"";--fa--fa:""}.fa-volume-mute{--fa:"īšŠ";--fa--fa:"īšŠīšŠ"}.fa-volume-off{--fa:"ī€Ļ";--fa--fa:"ī€Ļī€Ļ"}.fa-volume-slash{--fa:"ī‹ĸ";--fa--fa:"ī‹ĸī‹ĸ"}.fa-volume-times{--fa:"īšŠ";--fa--fa:"īšŠīšŠ"}.fa-volume-up{--fa:"";--fa--fa:""}.fa-volume-xmark{--fa:"īšŠ";--fa--fa:"īšŠīšŠ"}.fa-vote-nay{--fa:"īą";--fa--fa:"īąīą"}.fa-vote-yea{--fa:"ī˛";--fa--fa:"ī˛ī˛"}.fa-vr-cardboard{--fa:"īœŠ";--fa--fa:"īœŠīœŠ"}.fa-w{--fa:"W";--fa--fa:"WW"}.fa-waffle{--fa:"î‘Ļ";--fa--fa:"î‘Ļî‘Ļ"}.fa-wagon-covered{--fa:"īŖŽ";--fa--fa:"īŖŽīŖŽ"}.fa-walker{--fa:"ī ą";--fa--fa:"ī ąī ą"}.fa-walkie-talkie{--fa:"īŖ¯";--fa--fa:""}.fa-walking{--fa:"ī•”";--fa--fa:""}.fa-wall-brick{--fa:"";--fa--fa:""}.fa-wallet{--fa:"ī••";--fa--fa:"ī••ī••"}.fa-wand{--fa:"īœĒ";--fa--fa:"īœĒīœĒ"}.fa-wand-magic{--fa:"";--fa--fa:""}.fa-wand-magic-sparkles{--fa:"";--fa--fa:""}.fa-wand-sparkles{--fa:"īœĢ";--fa--fa:"īœĢīœĢ"}.fa-warehouse{--fa:"ī’”";--fa--fa:""}.fa-warehouse-alt,.fa-warehouse-full{--fa:"ī’•";--fa--fa:"ī’•ī’•"}.fa-warning{--fa:"īą";--fa--fa:"īąīą"}.fa-washer,.fa-washing-machine{--fa:"īĸ˜";--fa--fa:"īĸ˜īĸ˜"}.fa-watch{--fa:"ī‹Ą";--fa--fa:"ī‹Ąī‹Ą"}.fa-watch-apple{--fa:"";--fa--fa:""}.fa-watch-calculator{--fa:"īŖ°";--fa--fa:"īŖ°īŖ°"}.fa-watch-fitness{--fa:"ī˜ž";--fa--fa:"ī˜žī˜ž"}.fa-watch-smart{--fa:"";--fa--fa:""}.fa-water{--fa:"īŗ";--fa--fa:"īŗīŗ"}.fa-water-arrow-down{--fa:"ī´";--fa--fa:"ī´ī´"}.fa-water-arrow-up{--fa:"īĩ";--fa--fa:"īĩīĩ"}.fa-water-ladder{--fa:"ī—…";--fa--fa:"ī—…ī—…"}.fa-water-lower{--fa:"ī´";--fa--fa:"ī´ī´"}.fa-water-rise{--fa:"īĩ";--fa--fa:"īĩīĩ"}.fa-watermelon-slice{--fa:"";--fa--fa:""}.fa-wave{--fa:"";--fa--fa:""}.fa-wave-pulse{--fa:"ī—¸";--fa--fa:""}.fa-wave-sine{--fa:"īĸ™";--fa--fa:"īĸ™īĸ™"}.fa-wave-square{--fa:"ī ž";--fa--fa:"ī žī ž"}.fa-wave-triangle{--fa:"īĸš";--fa--fa:"īĸšīĸš"}.fa-waveform{--fa:"īŖą";--fa--fa:"īŖąīŖą"}.fa-waveform-circle{--fa:"";--fa--fa:""}.fa-waveform-lines,.fa-waveform-path{--fa:"īŖ˛";--fa--fa:""}.fa-waves-sine{--fa:"";--fa--fa:""}.fa-web-awesome{--fa:"";--fa--fa:""}.fa-webcam{--fa:"ī ˛";--fa--fa:""}.fa-webcam-slash{--fa:"ī ŗ";--fa--fa:"ī ŗī ŗ"}.fa-webhook{--fa:"";--fa--fa:""}.fa-weight{--fa:"ī’–";--fa--fa:"ī’–ī’–"}.fa-weight-hanging{--fa:"ī—";--fa--fa:"ī—ī—"}.fa-weight-scale{--fa:"ī’–";--fa--fa:"ī’–ī’–"}.fa-whale{--fa:"īœŦ";--fa--fa:"īœŦīœŦ"}.fa-wheat{--fa:"";--fa--fa:""}.fa-wheat-alt,.fa-wheat-awn{--fa:"";--fa--fa:""}.fa-wheat-awn-circle-exclamation{--fa:"";--fa--fa:""}.fa-wheat-awn-slash{--fa:"";--fa--fa:""}.fa-wheat-slash{--fa:"";--fa--fa:""}.fa-wheelchair{--fa:"";--fa--fa:""}.fa-wheelchair-alt,.fa-wheelchair-move{--fa:"";--fa--fa:""}.fa-whiskey-glass{--fa:"īž ";--fa--fa:"īž īž "}.fa-whiskey-glass-ice{--fa:"īžĄ";--fa--fa:"īžĄīžĄ"}.fa-whistle{--fa:"ī‘ ";--fa--fa:"ī‘ ī‘ "}.fa-wifi{--fa:"ī‡Ģ";--fa--fa:"ī‡Ģī‡Ģ"}.fa-wifi-1{--fa:"īšĒ";--fa--fa:"īšĒīšĒ"}.fa-wifi-2{--fa:"īšĢ";--fa--fa:"īšĢīšĢ"}.fa-wifi-3{--fa:"ī‡Ģ";--fa--fa:"ī‡Ģī‡Ģ"}.fa-wifi-exclamation{--fa:"";--fa--fa:""}.fa-wifi-fair{--fa:"īšĢ";--fa--fa:"īšĢīšĢ"}.fa-wifi-slash{--fa:"īšŦ";--fa--fa:"īšŦīšŦ"}.fa-wifi-strong{--fa:"ī‡Ģ";--fa--fa:"ī‡Ģī‡Ģ"}.fa-wifi-weak{--fa:"īšĒ";--fa--fa:"īšĒīšĒ"}.fa-wind{--fa:"īœŽ";--fa--fa:"īœŽīœŽ"}.fa-wind-circle-exclamation{--fa:"īļ";--fa--fa:"īļīļ"}.fa-wind-turbine{--fa:"īĸ›";--fa--fa:"īĸ›īĸ›"}.fa-wind-warning{--fa:"īļ";--fa--fa:"īļīļ"}.fa-window{--fa:"īŽ";--fa--fa:"īŽīŽ"}.fa-window-alt{--fa:"ī";--fa--fa:"īī"}.fa-window-close{--fa:"";--fa--fa:""}.fa-window-flip{--fa:"ī";--fa--fa:"īī"}.fa-window-frame{--fa:"";--fa--fa:""}.fa-window-frame-open{--fa:"";--fa--fa:""}.fa-window-maximize{--fa:"";--fa--fa:""}.fa-window-minimize{--fa:"ī‹‘";--fa--fa:"ī‹‘ī‹‘"}.fa-window-restore{--fa:"ī‹’";--fa--fa:"ī‹’ī‹’"}.fa-windsock{--fa:"īˇ";--fa--fa:"īˇīˇ"}.fa-wine-bottle{--fa:"";--fa--fa:""}.fa-wine-glass{--fa:"";--fa--fa:""}.fa-wine-glass-alt{--fa:"ī—Ž";--fa--fa:"ī—Žī—Ž"}.fa-wine-glass-crack{--fa:"ī’ģ";--fa--fa:"ī’ģī’ģ"}.fa-wine-glass-empty{--fa:"ī—Ž";--fa--fa:"ī—Žī—Ž"}.fa-won,.fa-won-sign{--fa:"ī…™";--fa--fa:""}.fa-worm{--fa:"";--fa--fa:""}.fa-wreath{--fa:"īŸĸ";--fa--fa:"īŸĸīŸĸ"}.fa-wreath-laurel{--fa:"";--fa--fa:""}.fa-wrench{--fa:"ī‚­";--fa--fa:"ī‚­ī‚­"}.fa-wrench-simple{--fa:"";--fa--fa:""}.fa-x{--fa:"X";--fa--fa:"XX"}.fa-x-ray{--fa:"ī’—";--fa--fa:"ī’—ī’—"}.fa-xmark{--fa:"ī€";--fa--fa:"ī€ī€"}.fa-xmark-circle{--fa:"";--fa--fa:""}.fa-xmark-hexagon{--fa:"ī‹Ž";--fa--fa:"ī‹Žī‹Ž"}.fa-xmark-large{--fa:"";--fa--fa:""}.fa-xmark-octagon{--fa:"ī‹°";--fa--fa:"ī‹°ī‹°"}.fa-xmark-square{--fa:"ī‹“";--fa--fa:"ī‹“ī‹“"}.fa-xmark-to-slot{--fa:"īą";--fa--fa:"īąīą"}.fa-xmarks-lines{--fa:"";--fa--fa:""}.fa-y{--fa:"Y";--fa--fa:"YY"}.fa-yen,.fa-yen-sign{--fa:"ī…—";--fa--fa:"ī…—ī…—"}.fa-yin-yang{--fa:"";--fa--fa:""}.fa-z{--fa:"Z";--fa--fa:"ZZ"}.fa-zap{--fa:"";--fa--fa:""}.fa-zzz{--fa:"īĸ€";--fa--fa:"īĸ€īĸ€"}.sr-only,.fa-sr-only,.sr-only-focusable:not(:focus),.fa-sr-only-focusable:not(:focus){clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden} \ No newline at end of file diff --git a/docs/static/fontawesome/css/sharp-regular.min.css b/docs/static/fontawesome/css/sharp-regular.min.css deleted file mode 100644 index d1601da397..0000000000 --- a/docs/static/fontawesome/css/sharp-regular.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2024 Fonticons, Inc. - */ -:host,:root{--fa-style-family-sharp:"Font Awesome 6 Sharp";--fa-font-sharp-regular:normal 400 1em/1 "Font Awesome 6 Sharp"}@font-face{font-family:"Font Awesome 6 Sharp";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-sharp-regular-400.woff2) format("woff2"),url(../webfonts/fa-sharp-regular-400.ttf) format("truetype")}.fa-regular,.fasr{font-weight:400} \ No newline at end of file diff --git a/docs/static/fontawesome/css/sharp-solid.min.css b/docs/static/fontawesome/css/sharp-solid.min.css deleted file mode 100644 index fb4ab77689..0000000000 --- a/docs/static/fontawesome/css/sharp-solid.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 6.7.2 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2024 Fonticons, Inc. - */ -:host,:root{--fa-style-family-sharp:"Font Awesome 6 Sharp";--fa-font-sharp-solid:normal 900 1em/1 "Font Awesome 6 Sharp"}@font-face{font-family:"Font Awesome 6 Sharp";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-sharp-solid-900.woff2) format("woff2"),url(../webfonts/fa-sharp-solid-900.ttf) format("truetype")}.fa-solid,.fass{font-weight:900} \ No newline at end of file diff --git a/docs/static/fontawesome/webfonts/fa-sharp-regular-400.woff2 b/docs/static/fontawesome/webfonts/fa-sharp-regular-400.woff2 deleted file mode 100644 index 74fbd564f5..0000000000 Binary files a/docs/static/fontawesome/webfonts/fa-sharp-regular-400.woff2 and /dev/null differ diff --git a/docs/static/fontawesome/webfonts/fa-sharp-solid-900.woff2 b/docs/static/fontawesome/webfonts/fa-sharp-solid-900.woff2 deleted file mode 100644 index 85f66cb2b0..0000000000 Binary files a/docs/static/fontawesome/webfonts/fa-sharp-solid-900.woff2 and /dev/null differ diff --git a/docs/static/fonts/JetBrainsMono-Bold.woff2 b/docs/static/fonts/JetBrainsMono-Bold.woff2 deleted file mode 100644 index 4917f43410..0000000000 Binary files a/docs/static/fonts/JetBrainsMono-Bold.woff2 and /dev/null differ diff --git a/docs/static/fonts/JetBrainsMono-Regular.woff2 b/docs/static/fonts/JetBrainsMono-Regular.woff2 deleted file mode 100644 index 40da427651..0000000000 Binary files a/docs/static/fonts/JetBrainsMono-Regular.woff2 and /dev/null differ diff --git a/docs/static/img/discord.svg b/docs/static/img/discord.svg deleted file mode 100644 index 80a8f66e78..0000000000 --- a/docs/static/img/discord.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M524.5 69.8a1.5 1.5 0 0 0 -.8-.7A485.1 485.1 0 0 0 404.1 32a1.8 1.8 0 0 0 -1.9 .9 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.1-30.6 1.9 1.9 0 0 0 -1.9-.9A483.7 483.7 0 0 0 116.1 69.1a1.7 1.7 0 0 0 -.8 .7C39.1 183.7 18.2 294.7 28.4 404.4a2 2 0 0 0 .8 1.4A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.1-.7A348.2 348.2 0 0 0 208.1 430.4a1.9 1.9 0 0 0 -1-2.6 321.2 321.2 0 0 1 -45.9-21.9 1.9 1.9 0 0 1 -.2-3.1c3.1-2.3 6.2-4.7 9.1-7.1a1.8 1.8 0 0 1 1.9-.3c96.2 43.9 200.4 43.9 295.5 0a1.8 1.8 0 0 1 1.9 .2c2.9 2.4 6 4.9 9.1 7.2a1.9 1.9 0 0 1 -.2 3.1 301.4 301.4 0 0 1 -45.9 21.8 1.9 1.9 0 0 0 -1 2.6 391.1 391.1 0 0 0 30 48.8 1.9 1.9 0 0 0 2.1 .7A486 486 0 0 0 610.7 405.7a1.9 1.9 0 0 0 .8-1.4C623.7 277.6 590.9 167.5 524.5 69.8zM222.5 337.6c-29 0-52.8-26.6-52.8-59.2S193.1 219.1 222.5 219.1c29.7 0 53.3 26.8 52.8 59.2C275.3 311 251.9 337.6 222.5 337.6zm195.4 0c-29 0-52.8-26.6-52.8-59.2S388.4 219.1 417.9 219.1c29.7 0 53.3 26.8 52.8 59.2C470.7 311 447.5 337.6 417.9 337.6z"/></svg> \ No newline at end of file diff --git a/docs/static/img/drag-move-24fps-crf43.mp4 b/docs/static/img/drag-move-24fps-crf43.mp4 deleted file mode 100644 index c89f319808..0000000000 Binary files a/docs/static/img/drag-move-24fps-crf43.mp4 and /dev/null differ diff --git a/docs/static/img/github.svg b/docs/static/img/github.svg deleted file mode 100644 index cf245cc676..0000000000 --- a/docs/static/img/github.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg> \ No newline at end of file diff --git a/docs/static/img/logo/wave-dark.png b/docs/static/img/logo/wave-dark.png deleted file mode 100644 index e9cf7cb36c..0000000000 Binary files a/docs/static/img/logo/wave-dark.png and /dev/null differ diff --git a/docs/static/img/logo/wave-light.png b/docs/static/img/logo/wave-light.png deleted file mode 100644 index ab3d58b887..0000000000 Binary files a/docs/static/img/logo/wave-light.png and /dev/null differ diff --git a/docs/static/img/logo/wave-logo_appicon.svg b/docs/static/img/logo/wave-logo_appicon.svg deleted file mode 100644 index e71dcca077..0000000000 --- a/docs/static/img/logo/wave-logo_appicon.svg +++ /dev/null @@ -1,43 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Generator: Adobe Illustrator 27.8.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> -<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" - viewBox="0 0 1024 1024" style="enable-background:new 0 0 1024 1024;" xml:space="preserve"> -<style type="text/css"> - .st0{fill:url(#SVGID_1_);} - .st1{fill:url(#SVGID_00000075150658188964823570000013118982920252363443_);} - .st2{fill:#FFFFFF;} - .st3{fill:#58C142;} - .st4{fill:url(#SVGID_00000112594339711612990680000002624432530892021946_);} - .st5{fill:url(#SVGID_00000140719531123372030610000008195484546976963487_);} - .st6{fill:url(#SVGID_00000178921119776480960350000009431495353979092624_);} - .st7{fill:url(#SVGID_00000016768039583747816600000018263155026811122865_);} - .st8{fill:url(#SVGID_00000044885518158126509950000016130982060394487440_);} - .st9{fill:url(#SVGID_00000106129665382546024650000004877767554412198583_);} - .st10{fill:url(#SVGID_00000083802268480699847100000012000850824661000362_);} - .st11{fill:url(#SVGID_00000008837751933091038900000017899611971679674516_);} - .st12{fill:url(#SVGID_00000108294032626366517620000010242680764503622051_);} - .st13{fill:url(#SVGID_00000141443312969477751860000005551731547830776740_);} -</style> -<rect x="-11.5" y="-11.5" width="1047.1" height="1047.1"/> -<g> - <g> - <linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="143.9875" y1="428.1396" x2="833.9507" y2="428.1396"> - <stop offset="0.1418" style="stop-color:#1F4D22"/> - <stop offset="0.8656" style="stop-color:#418D31"/> - </linearGradient> - <path class="st0" d="M374,492.4c-36.2,0-56.1,23.5-67,76.1l-163-23.6c19.9-172,86.9-255.3,219.1-255.3 - c97.8,0,193.8,74.3,240.9,74.3c36.2,0,56.1-25.4,67-76.1l163,23.5c-18.1,172-86.9,255.4-219.1,255.4 - C515.2,566.7,422.9,492.4,374,492.4z"/> - </g> - <g> - - <linearGradient id="SVGID_00000066484680017610105720000010460662955817103787_" gradientUnits="userSpaceOnUse" x1="190.0493" y1="595.8604" x2="880.0125" y2="595.8604"> - <stop offset="0.2223" style="stop-color:#418D31"/> - <stop offset="0.7733" style="stop-color:#58C142"/> - </linearGradient> - <path style="fill:url(#SVGID_00000066484680017610105720000010460662955817103787_);" d="M420,660.1c-36.2,0-56.1,23.5-67,76.1 - l-163-23.6c19.9-172,86.9-255.3,219.1-255.3c97.8,0,193.8,74.3,240.9,74.3c36.2,0,56.1-25.4,67-76.1L880,479 - c-18.1,172-86.9,255.4-219.1,255.4C561.3,734.4,468.9,660.1,420,660.1z"/> - </g> -</g> -</svg> diff --git a/docs/static/img/magnify-disabled.svg b/docs/static/img/magnify-disabled.svg deleted file mode 100644 index 1dbe4231ae..0000000000 --- a/docs/static/img/magnify-disabled.svg +++ /dev/null @@ -1,24 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg - id="svg2" - version="1.1" - fill="none" - viewBox="0 0 16 16" - xmlns="http://www.w3.org/2000/svg" - xmlns:svg="http://www.w3.org/2000/svg"> - <defs - id="defs1" /> - <g - id="g2" - transform="matrix(1 0 0 1 -4 -4)" - fill="#000"> - <path - id="arrow1" - class="arrow" - d="m 6.65255,18.09625 c -0.4262,0 -0.7723,-0.34592 -0.7723,-0.77195 v -5.4527 c 0,-0.42604 0.3461,-0.77195 0.7723,-0.77195 0.42619,0 0.77227,0.34592 0.77227,0.77195 v 4.6825 l 4.6832,0.0017 c 0.4262,0 0.77228,0.34592 0.77228,0.77185 0,0.42604 -0.34608,0.77195 -0.77228,0.77195 h -5.4554 v -0.0034 z" /> - <path - id="arrow2" - class="arrow" - d="m 17.32733,5.90413 c 0.42624,0 0.77233,0.34581 0.77233,0.77184 v 5.4527 c 0,0.426 -0.34609,0.77191 -0.77233,0.77191 -0.42614,0 -0.77223,-0.34591 -0.77223,-0.77191 v -4.6826 l -4.6832,-0.0017 c -0.42625,0 -0.77223,-0.34591 -0.77223,-0.77187 0,-0.42603 0.34599,-0.77195 0.77223,-0.77195 h 5.4554 v 0.0035 z" /> - </g> -</svg> diff --git a/docs/static/img/magnify-enabled.svg b/docs/static/img/magnify-enabled.svg deleted file mode 100644 index 09a4919ca4..0000000000 --- a/docs/static/img/magnify-enabled.svg +++ /dev/null @@ -1,9 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<svg id="svg2" version="1.1" fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"> - <g id="g2" transform="matrix(1 0 0 1 -4 -4)" fill="#000"> - <path id="arrow1" class="arrow" - d="m10.208 13.003c0.4262 0 0.7723 0.34592 0.7723 0.77195v5.4527c0 0.42604-0.3461 0.77195-0.7723 0.77195-0.42619 0-0.77227-0.34592-0.77227-0.77195v-4.6825l-4.6832-0.0017c-0.4262 0-0.77228-0.34592-0.77228-0.77185 0-0.42604 0.34608-0.77195 0.77228-0.77195h5.4554v0.0034z" /> - <path id="arrow2" class="arrow" - d="m13.772 10.997c-0.42624 0-0.77233-0.34581-0.77233-0.77184v-5.4527c0-0.426 0.34609-0.77191 0.77233-0.77191 0.42614 0 0.77223 0.34591 0.77223 0.77191v4.6826l4.6832 0.0017c0.42625 0 0.77223 0.34591 0.77223 0.77187 0 0.42603-0.34599 0.77195-0.77223 0.77195h-5.4554v-0.0035z" /> - </g> -</svg> diff --git a/docs/static/img/resize-24fps-crf43.mp4 b/docs/static/img/resize-24fps-crf43.mp4 deleted file mode 100644 index db0a24eb13..0000000000 Binary files a/docs/static/img/resize-24fps-crf43.mp4 and /dev/null differ diff --git a/docs/static/img/workspace.svg b/docs/static/img/workspace.svg deleted file mode 100644 index 220153c894..0000000000 --- a/docs/static/img/workspace.svg +++ /dev/null @@ -1,8 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> - <g opacity="0.8"> - <path d="M12.0832 6.84115C13.694 6.84115 14.9998 5.53531 14.9998 3.92448C14.9998 2.31365 13.694 1.00781 12.0832 1.00781C10.4723 1.00781 9.1665 2.31365 9.1665 3.92448C9.1665 5.53531 10.4723 6.84115 12.0832 6.84115Z" fill="white"/> - <path d="M3.91667 6.84115C5.5275 6.84115 6.83333 5.53531 6.83333 3.92448C6.83333 2.31365 5.5275 1.00781 3.91667 1.00781C2.30584 1.00781 1 2.31365 1 3.92448C1 5.53531 2.30584 6.84115 3.91667 6.84115Z" fill="white"/> - <path d="M12.0832 15.0052C13.694 15.0052 14.9998 13.6994 14.9998 12.0885C14.9998 10.4777 13.694 9.17188 12.0832 9.17188C10.4723 9.17188 9.1665 10.4777 9.1665 12.0885C9.1665 13.6994 10.4723 15.0052 12.0832 15.0052Z" fill="white"/> - <path d="M3.91667 15.0052C5.5275 15.0052 6.83333 13.6994 6.83333 12.0885C6.83333 10.4777 5.5275 9.17188 3.91667 9.17188C2.30584 9.17188 1 10.4777 1 12.0885C1 13.6994 2.30584 15.0052 3.91667 15.0052Z" fill="white"/> - </g> -</svg> \ No newline at end of file diff --git a/docs/tsconfig.json b/docs/tsconfig.json deleted file mode 100644 index aa545fcd13..0000000000 --- a/docs/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - // This file is not used in compilation. It is here just for a nice editor experience. - "extends": "@docusaurus/tsconfig", - "compilerOptions": { - "baseUrl": ".", - "plugins": [ - { - "name": "@mdx-js/typescript-plugin" - } - ] - }, - "mdx": { - // Enable strict type checking in MDX files. - "checkMdx": true - } -} diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs deleted file mode 100644 index d49f2da616..0000000000 --- a/electron-builder.config.cjs +++ /dev/null @@ -1,143 +0,0 @@ -const { Arch } = require("electron-builder"); -const pkg = require("./package.json"); -const fs = require("fs"); -const path = require("path"); - -const windowsShouldSign = !!process.env.SM_CODE_SIGNING_CERT_SHA1_HASH; - -/** - * @type {import('electron-builder').Configuration} - * @see https://www.electron.build/configuration/configuration - */ -const config = { - appId: pkg.build.appId, - productName: pkg.productName, - executableName: pkg.productName, - artifactName: "${productName}-${platform}-${arch}-${version}.${ext}", - generateUpdatesFilesForAllChannels: true, - npmRebuild: false, - nodeGypRebuild: false, - electronCompile: false, - files: [ - { - from: "./dist", - to: "./dist", - filter: ["**/*", "!bin/*", "bin/wavesrv.${arch}*", "bin/wsh*", "!tsunamiscaffold/**/*"], - }, - { - from: ".", - to: ".", - filter: ["package.json"], - }, - "!node_modules", // We don't need electron-builder to package in Node modules as Vite has already bundled any code that our program is using. - ], - extraResources: [ - { - from: "dist/tsunamiscaffold", - to: "tsunamiscaffold", - }, - ], - directories: { - output: "make", - }, - asarUnpack: [ - "dist/bin/**/*", // wavesrv and wsh binaries - "dist/schema/**/*", // schema files for Monaco editor - ], - mac: { - target: [ - { - target: "zip", - arch: ["arm64", "x64"], - }, - { - target: "dmg", - arch: ["arm64", "x64"], - }, - ], - category: "public.app-category.developer-tools", - minimumSystemVersion: "10.15.0", - mergeASARs: true, - singleArchFiles: "**/dist/bin/wavesrv.*", - entitlements: "build/entitlements.mac.plist", - entitlementsInherit: "build/entitlements.mac.plist", - extendInfo: { - NSContactsUsageDescription: "A CLI application running in Wave wants to use your contacts.", - NSRemindersUsageDescription: "A CLI application running in Wave wants to use your reminders.", - NSLocationWhenInUseUsageDescription: - "A CLI application running in Wave wants to use your location information while active.", - NSLocationAlwaysUsageDescription: - "A CLI application running in Wave wants to use your location information, even in the background.", - NSCameraUsageDescription: "A CLI application running in Wave wants to use the camera.", - NSMicrophoneUsageDescription: "A CLI application running in Wave wants to use your microphone.", - NSCalendarsUsageDescription: "A CLI application running in Wave wants to use Calendar data.", - NSLocationUsageDescription: "A CLI application running in Wave wants to use your location information.", - NSAppleEventsUsageDescription: "A CLI application running in Wave wants to use AppleScript.", - }, - }, - linux: { - artifactName: "${name}-${platform}-${arch}-${version}.${ext}", - category: "TerminalEmulator", - executableName: pkg.name, - target: ["zip", "deb", "rpm", "snap", "AppImage", "pacman"], - synopsis: pkg.description, - description: null, - desktop: { - entry: { - Name: pkg.productName, - Comment: pkg.description, - Keywords: "developer;terminal;emulator;", - Categories: "Development;Utility;", - }, - }, - executableArgs: ["--enable-features", "UseOzonePlatform", "--ozone-platform-hint", "auto"], // Hint Electron to use Ozone abstraction layer for native Wayland support - }, - deb: { - afterInstall: "build/deb-postinstall.tpl", - }, - win: { - target: ["nsis", "msi", "zip"], - signtoolOptions: windowsShouldSign && { - signingHashAlgorithms: ["sha256"], - publisherName: "Command Line Inc", - certificateSubjectName: "Command Line Inc", - certificateSha1: process.env.SM_CODE_SIGNING_CERT_SHA1_HASH, - }, - }, - appImage: { - license: "LICENSE", - }, - snap: { - base: "core22", - confinement: "classic", - allowNativeWayland: true, - artifactName: "${name}_${version}_${arch}.${ext}", - }, - rpm: { - // this should remove /usr/lib/.build-id/ links which can conflict with other electron apps like slack - fpm: ["--rpm-rpmbuild-define", "_build_id_links none"], - }, - publish: { - provider: "generic", - url: "https://dl.waveterm.dev/releases-w2", - }, - afterPack: (context) => { - // This is a workaround to restore file permissions to the wavesrv binaries on macOS after packaging the universal binary. - if (context.electronPlatformName === "darwin" && context.arch === Arch.universal) { - const packageBinDir = path.resolve( - context.appOutDir, - `${pkg.productName}.app/Contents/Resources/app.asar.unpacked/dist/bin` - ); - - // Reapply file permissions to the wavesrv binaries in the final app package - fs.readdirSync(packageBinDir, { - recursive: true, - withFileTypes: true, - }) - .filter((f) => f.isFile() && f.name.startsWith("wavesrv")) - .forEach((f) => fs.chmodSync(path.resolve(f.parentPath ?? f.path, f.name), 0o755)); // 0o755 corresponds to -rwxr-xr-x - } - }, -}; - -module.exports = config; diff --git a/electron-builder.config.js b/electron-builder.config.js new file mode 100644 index 0000000000..a6b6179f69 --- /dev/null +++ b/electron-builder.config.js @@ -0,0 +1,95 @@ +const pkg = require("./package.json"); +const fs = require("fs"); +const path = require("path"); + +/** + * @type {import('electron-builder').Configuration} + * @see https://www.electron.build/configuration/configuration + */ +const config = { + appId: pkg.build.appId, + productName: pkg.productName, + artifactName: "${productName}-${platform}-${arch}-${version}.${ext}", + npmRebuild: false, + nodeGypRebuild: false, + electronCompile: false, + files: [ + { + from: "./dist", + to: "./dist", + filter: ["**/*"], + }, + { + from: "./public", + to: "./public", + filter: ["**/*"], + }, + { + from: "./bin", + to: "./bin", + filter: ["**/*"], + }, + { + from: ".", + to: ".", + filter: ["package.json"], + }, + "!**/node_modules/**${/*}", // Ignore node_modules by default + { + from: "./node_modules", + to: "./node_modules", + filter: ["monaco-editor/min/**/*"], // This is the only module we want to include + }, + ], + directories: { + output: "make", + }, + asarUnpack: ["bin/**/*"], + mac: { + target: [ + { + target: "zip", + arch: "universal", + }, + { + target: "dmg", + arch: "universal", + }, + ], + icon: "public/waveterm.icns", + category: "public.app-category.developer-tools", + minimumSystemVersion: "10.15.0", + notarize: process.env.APPLE_TEAM_ID + ? { + teamId: process.env.APPLE_TEAM_ID, + } + : false, + binaries: fs + .readdirSync("bin", { recursive: true, withFileTypes: true }) + .filter((f) => f.isFile()) + .map((f) => path.resolve(f.path, f.name)), + }, + linux: { + executableName: pkg.productName, + category: "TerminalEmulator", + icon: "public/waveterm.icns", + target: ["zip", "deb", "rpm", "AppImage", "pacman"], + synopsis: pkg.description, + description: null, + desktop: { + Name: pkg.productName, + Comment: pkg.description, + Keywords: "developer;terminal;emulator;", + category: "Development;Utility;", + }, + }, + appImage: { + license: "LICENSE", + }, + publish: { + provider: "generic", + url: "https://dl.waveterm.dev/releases-legacy", + }, +}; + +module.exports = config; diff --git a/electron.vite.config.ts b/electron.vite.config.ts deleted file mode 100644 index d94a166659..0000000000 --- a/electron.vite.config.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import tailwindcss from "@tailwindcss/vite"; -import react from "@vitejs/plugin-react-swc"; -import { defineConfig } from "electron-vite"; -import { ViteImageOptimizer } from "vite-plugin-image-optimizer"; -import svgr from "vite-plugin-svgr"; -import tsconfigPaths from "vite-tsconfig-paths"; - -// from our electron build -const CHROME = "chrome140"; -const NODE = "node22"; - -// for debugging -// target is like -- path.resolve(__dirname, "frontend/app/workspace/workspace-layout-model.ts"); -function whoImportsTarget(target: string) { - return { - name: "who-imports-target", - buildEnd() { - // Build reverse graph: child -> [importers...] - const parents = new Map<string, string[]>(); - for (const id of (this as any).getModuleIds()) { - const info = (this as any).getModuleInfo(id); - if (!info) continue; - for (const child of [...info.importedIds, ...info.dynamicallyImportedIds]) { - const arr = parents.get(child) ?? []; - arr.push(id); - parents.set(child, arr); - } - } - - // Walk upward from TARGET and print paths to entries - const entries = [...parents.keys()].filter((id) => { - const m = (this as any).getModuleInfo(id); - return m?.isEntry; - }); - - const seen = new Set<string>(); - const stack: string[] = []; - const dfs = (node: string) => { - if (seen.has(node)) return; - seen.add(node); - stack.push(node); - const ps = parents.get(node) || []; - if (ps.length === 0) { - // hit a root (likely main entry or plugin virtual) - console.log("\nImporter chain:"); - stack - .slice() - .reverse() - .forEach((s) => console.log(" â†ŗ", s)); - } else { - for (const p of ps) dfs(p); - } - stack.pop(); - }; - - if (!parents.has(target)) { - console.log(`[who-imports] TARGET not in MAIN graph: ${target}`); - } else { - dfs(target); - } - }, - async resolveId(id: any, importer: any) { - const r = await (this as any).resolve(id, importer, { skipSelf: true }); - if (r?.id === target) { - console.log(`[resolve] ${importer} -> ${id} -> ${r.id}`); - } - return null; - }, - }; -} - -export default defineConfig({ - main: { - root: ".", - build: { - target: NODE, - rollupOptions: { - input: { - index: "emain/emain.ts", - }, - }, - outDir: "dist/main", - externalizeDeps: false, - }, - plugins: [tsconfigPaths()], - resolve: { - alias: { - "@": "frontend", - }, - }, - server: { - open: false, - }, - define: { - "process.env.WS_NO_BUFFER_UTIL": "true", - "process.env.WS_NO_UTF_8_VALIDATE": "true", - }, - }, - preload: { - root: ".", - build: { - target: NODE, - sourcemap: true, - rollupOptions: { - input: { - index: "emain/preload.ts", - "preload-webview": "emain/preload-webview.ts", - }, - output: { - format: "cjs", - }, - }, - outDir: "dist/preload", - externalizeDeps: false, - }, - server: { - open: false, - }, - plugins: [tsconfigPaths()], - }, - renderer: { - root: ".", - build: { - target: CHROME, - sourcemap: true, - outDir: "dist/frontend", - rollupOptions: { - input: { - index: "index.html", - }, - output: { - manualChunks(id) { - const p = id.replace(/\\/g, "/"); - if (p.includes("node_modules/monaco") || p.includes("node_modules/@monaco")) return "monaco"; - if (p.includes("node_modules/mermaid") || p.includes("node_modules/@mermaid")) return "mermaid"; - if (p.includes("node_modules/katex") || p.includes("node_modules/@katex")) return "katex"; - if (p.includes("node_modules/shiki") || p.includes("node_modules/@shiki")) { - return "shiki"; - } - if (p.includes("node_modules/cytoscape") || p.includes("node_modules/@cytoscape")) - return "cytoscape"; - return undefined; - }, - }, - }, - }, - optimizeDeps: { - include: ["monaco-yaml/yaml.worker.js"], - }, - server: { - open: false, - watch: { - ignored: [ - "dist/**", - "**/*.go", - "**/go.mod", - "**/go.sum", - "**/*.md", - "**/*.mdx", - "**/*.json", - "**/emain/**", - "**/*.txt", - "**/*.log", - ], - }, - }, - css: { - preprocessorOptions: { - scss: { - silenceDeprecations: ["mixed-decls"], - }, - }, - }, - plugins: [ - tsconfigPaths(), - { ...ViteImageOptimizer(), apply: "build" }, - svgr({ - svgrOptions: { exportType: "default", ref: true, svgo: false, titleProp: true }, - include: "**/*.svg", - }), - react({}), - tailwindcss(), - ], - }, -}); diff --git a/emain/authkey.ts b/emain/authkey.ts deleted file mode 100644 index e481c7ca5f..0000000000 --- a/emain/authkey.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { ipcMain } from "electron"; -import { getWebServerEndpoint, getWSServerEndpoint } from "../frontend/util/endpoints"; - -const AuthKeyHeader = "X-AuthKey"; -export const WaveAuthKeyEnv = "WAVETERM_AUTH_KEY"; -export const AuthKey = crypto.randomUUID(); - -ipcMain.on("get-auth-key", (event) => { - event.returnValue = AuthKey; -}); - -export function configureAuthKeyRequestInjection(session: Electron.Session) { - const filter: Electron.WebRequestFilter = { - urls: [`${getWebServerEndpoint()}/*`, `${getWSServerEndpoint()}/*`], - }; - session.webRequest.onBeforeSendHeaders(filter, (details, callback) => { - details.requestHeaders[AuthKeyHeader] = AuthKey; - callback({ requestHeaders: details.requestHeaders }); - }); -} diff --git a/emain/emain-activity.ts b/emain/emain-activity.ts deleted file mode 100644 index 17dde466ae..0000000000 --- a/emain/emain-activity.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -// for activity updates -let wasActive = true; -let wasInFg = true; -let globalIsQuitting = false; -let globalIsStarting = true; -let globalIsRelaunching = false; -let forceQuit = false; -let userConfirmedQuit = false; -let termCommandsRun = 0; -let termCommandsRemote = 0; -let termCommandsWsl = 0; -let termCommandsDurable = 0; - -export function setWasActive(val: boolean) { - wasActive = val; -} - -export function setWasInFg(val: boolean) { - wasInFg = val; -} - -export function getActivityState(): { wasActive: boolean; wasInFg: boolean } { - return { wasActive, wasInFg }; -} - -export function setGlobalIsQuitting(val: boolean) { - globalIsQuitting = val; -} - -export function getGlobalIsQuitting(): boolean { - return globalIsQuitting; -} - -export function setGlobalIsStarting(val: boolean) { - globalIsStarting = val; -} - -export function getGlobalIsStarting(): boolean { - return globalIsStarting; -} - -export function setGlobalIsRelaunching(val: boolean) { - globalIsRelaunching = val; -} - -export function getGlobalIsRelaunching(): boolean { - return globalIsRelaunching; -} - -export function setForceQuit(val: boolean) { - forceQuit = val; -} - -export function getForceQuit(): boolean { - return forceQuit; -} - -export function setUserConfirmedQuit(val: boolean) { - userConfirmedQuit = val; -} - -export function getUserConfirmedQuit(): boolean { - return userConfirmedQuit; -} - -export function incrementTermCommandsRun() { - termCommandsRun++; -} - -export function getAndClearTermCommandsRun(): number { - const count = termCommandsRun; - termCommandsRun = 0; - return count; -} - -export function incrementTermCommandsRemote() { - termCommandsRemote++; -} - -export function getAndClearTermCommandsRemote(): number { - const count = termCommandsRemote; - termCommandsRemote = 0; - return count; -} - -export function incrementTermCommandsWsl() { - termCommandsWsl++; -} - -export function getAndClearTermCommandsWsl(): number { - const count = termCommandsWsl; - termCommandsWsl = 0; - return count; -} - -export function incrementTermCommandsDurable() { - termCommandsDurable++; -} - -export function getAndClearTermCommandsDurable(): number { - const count = termCommandsDurable; - termCommandsDurable = 0; - return count; -} diff --git a/emain/emain-builder.ts b/emain/emain-builder.ts deleted file mode 100644 index 8b223c0f9c..0000000000 --- a/emain/emain-builder.ts +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { ClientService } from "@/app/store/services"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { randomUUID } from "crypto"; -import { BrowserWindow, webContents } from "electron"; -import { globalEvents } from "emain/emain-events"; -import path from "path"; -import { getElectronAppBasePath, isDevVite, unamePlatform } from "./emain-platform"; -import { calculateWindowBounds, MinWindowHeight, MinWindowWidth } from "./emain-window"; -import { ElectronWshClient } from "./emain-wsh"; - -export type BuilderWindowType = BrowserWindow & { - builderId: string; - builderAppId?: string; - savedInitOpts: BuilderInitOpts; -}; - -const builderWindows: BuilderWindowType[] = []; -export let focusedBuilderWindow: BuilderWindowType = null; - -export function getBuilderWindowById(builderId: string): BuilderWindowType { - return builderWindows.find((win) => win.builderId === builderId); -} - -export function getBuilderWindowByWebContentsId(webContentsId: number): BuilderWindowType { - return builderWindows.find((win) => win.webContents.id === webContentsId); -} - -export function getAllBuilderWindows(): BuilderWindowType[] { - return builderWindows; -} - -export async function createBuilderWindow(appId: string): Promise<BuilderWindowType> { - const builderId = randomUUID(); - - const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); - const clientData = await ClientService.GetClientData(); - const clientId = clientData?.oid; - const windowId = randomUUID(); - - if (appId) { - const oref = `builder:${builderId}`; - await RpcApi.SetRTInfoCommand(ElectronWshClient, { - oref, - data: { "builder:appid": appId }, - }); - } - - const winBounds = calculateWindowBounds(undefined, undefined, fullConfig.settings); - - const builderWindow = new BrowserWindow({ - x: winBounds.x, - y: winBounds.y, - width: winBounds.width, - height: winBounds.height, - minWidth: MinWindowWidth, - minHeight: MinWindowHeight, - titleBarStyle: unamePlatform === "darwin" ? "hiddenInset" : "default", - icon: - unamePlatform === "linux" - ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") - : undefined, - show: false, - backgroundColor: "#222222", - webPreferences: { - preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), - webviewTag: true, - }, - }); - - if (isDevVite) { - await builderWindow.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html`); - } else { - await builderWindow.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); - } - - const initOpts: BuilderInitOpts = { - builderId, - clientId, - windowId, - }; - - const typedBuilderWindow = builderWindow as BuilderWindowType; - typedBuilderWindow.builderId = builderId; - typedBuilderWindow.builderAppId = appId; - typedBuilderWindow.savedInitOpts = initOpts; - - typedBuilderWindow.on("close", () => { - const wc = typedBuilderWindow.webContents; - if (wc.isDevToolsOpened()) { - wc.closeDevTools(); - } - for (const guest of webContents.getAllWebContents()) { - if (guest.getType() === "webview" && guest.hostWebContents?.id === wc.id) { - if (guest.isDevToolsOpened()) { - guest.closeDevTools(); - } - } - } - }); - - typedBuilderWindow.on("focus", () => { - focusedBuilderWindow = typedBuilderWindow; - console.log("builder window focused", builderId); - setTimeout(() => globalEvents.emit("windows-updated"), 50); - }); - - typedBuilderWindow.on("blur", () => { - if (focusedBuilderWindow === typedBuilderWindow) { - focusedBuilderWindow = null; - } - setTimeout(() => globalEvents.emit("windows-updated"), 50); - }); - - typedBuilderWindow.on("closed", () => { - console.log("builder window closed", builderId); - const index = builderWindows.indexOf(typedBuilderWindow); - if (index !== -1) { - builderWindows.splice(index, 1); - } - if (focusedBuilderWindow === typedBuilderWindow) { - focusedBuilderWindow = null; - } - RpcApi.DeleteBuilderCommand(ElectronWshClient, builderId, { noresponse: true }); - setTimeout(() => globalEvents.emit("windows-updated"), 50); - }); - - builderWindows.push(typedBuilderWindow); - typedBuilderWindow.show(); - - console.log("created builder window", builderId, appId); - return typedBuilderWindow; -} diff --git a/emain/emain-events.ts b/emain/emain-events.ts deleted file mode 100644 index 08d13cd4f4..0000000000 --- a/emain/emain-events.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { EventEmitter } from "events"; - -interface GlobalEvents { - "windows-updated": () => void; // emitted whenever a window is opened/closed -} - -class GlobalEventEmitter extends EventEmitter { - emit<K extends keyof GlobalEvents>(event: K, ...args: Parameters<GlobalEvents[K]>): boolean { - return super.emit(event, ...args); - } - - on<K extends keyof GlobalEvents>(event: K, listener: GlobalEvents[K]): this { - return super.on(event, listener); - } - - once<K extends keyof GlobalEvents>(event: K, listener: GlobalEvents[K]): this { - return super.once(event, listener); - } - - off<K extends keyof GlobalEvents>(event: K, listener: GlobalEvents[K]): this { - return super.off(event, listener); - } -} - -const globalEvents = new GlobalEventEmitter(); - -export { globalEvents }; diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts deleted file mode 100644 index 5e5f15b302..0000000000 --- a/emain/emain-ipc.ts +++ /dev/null @@ -1,533 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import * as electron from "electron"; -import { FastAverageColor } from "fast-average-color"; -import fs from "fs"; -import * as child_process from "node:child_process"; -import * as path from "path"; -import { PNG } from "pngjs"; -import { Readable } from "stream"; -import { RpcApi } from "../frontend/app/store/wshclientapi"; -import { getWebServerEndpoint } from "../frontend/util/endpoints"; -import * as keyutil from "../frontend/util/keyutil"; -import { fireAndForget, parseDataUrl } from "../frontend/util/util"; -import { - incrementTermCommandsDurable, - incrementTermCommandsRemote, - incrementTermCommandsRun, - incrementTermCommandsWsl, - setWasActive, -} from "./emain-activity"; -import { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from "./emain-builder"; -import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform"; -import { getWaveTabViewByWebContentsId } from "./emain-tabview"; -import { handleCtrlShiftState } from "./emain-util"; -import { getWaveVersion } from "./emain-wavesrv"; -import { createNewWaveWindow, getWaveWindowByWebContentsId } from "./emain-window"; -import { ElectronWshClient } from "./emain-wsh"; - -const electronApp = electron.app; - -let webviewFocusId: number = null; -let webviewKeys: string[] = []; - -export function openBuilderWindow(appId?: string) { - const normalizedAppId = appId || ""; - const existingBuilderWindows = getAllBuilderWindows(); - const existingWindow = existingBuilderWindows.find((win) => win.builderAppId === normalizedAppId); - if (existingWindow) { - existingWindow.focus(); - return; - } - fireAndForget(() => createBuilderWindow(normalizedAppId)); -} - -type UrlInSessionResult = { - stream: Readable; - mimeType: string; - fileName: string; -}; - -function getSingleHeaderVal(headers: Record<string, string | string[]>, key: string): string { - const val = headers[key]; - if (val == null) { - return null; - } - if (Array.isArray(val)) { - return val[0]; - } - return val; -} - -function cleanMimeType(mimeType: string): string { - if (mimeType == null) { - return null; - } - const parts = mimeType.split(";"); - return parts[0].trim(); -} - -function getFileNameFromUrl(url: string): string { - try { - const pathname = new URL(url).pathname; - const filename = pathname.substring(pathname.lastIndexOf("/") + 1); - return filename; - } catch (e) { - return null; - } -} - -function getUrlInSession(session: Electron.Session, url: string): Promise<UrlInSessionResult> { - return new Promise((resolve, reject) => { - if (url.startsWith("data:")) { - try { - const parsed = parseDataUrl(url); - const buffer = Buffer.from(parsed.buffer); - const readable = Readable.from(buffer); - resolve({ stream: readable, mimeType: parsed.mimeType, fileName: "image" }); - } catch (err) { - return reject(err); - } - return; - } - const request = electron.net.request({ - url, - method: "GET", - session, - }); - const readable = new Readable({ - read() {}, - }); - request.on("response", (response) => { - const statusCode = response.statusCode; - if (statusCode < 200 || statusCode >= 300) { - readable.destroy(); - request.abort(); - reject(new Error(`HTTP request failed with status ${statusCode}: ${response.statusMessage || ""}`)); - return; - } - - const mimeType = cleanMimeType(getSingleHeaderVal(response.headers, "content-type")); - const fileName = getFileNameFromUrl(url) || "image"; - response.on("data", (chunk) => { - readable.push(chunk); - }); - response.on("end", () => { - readable.push(null); - resolve({ stream: readable, mimeType, fileName }); - }); - response.on("error", (err) => { - readable.destroy(err); - reject(err); - }); - }); - request.on("error", (err) => { - readable.destroy(err); - reject(err); - }); - request.end(); - }); -} - -function saveImageFileWithNativeDialog( - sender: electron.WebContents, - defaultFileName: string, - mimeType: string, - readStream: Readable -) { - if (defaultFileName == null || defaultFileName == "") { - defaultFileName = "image"; - } - const ww = electron.BrowserWindow.fromWebContents(sender); - if (ww == null) { - readStream.destroy(); - return; - } - const mimeToExtension: { [key: string]: string } = { - "image/png": "png", - "image/jpeg": "jpg", - "image/gif": "gif", - "image/webp": "webp", - "image/bmp": "bmp", - "image/tiff": "tiff", - "image/heic": "heic", - "image/svg+xml": "svg", - }; - function addExtensionIfNeeded(fileName: string, mimeType: string): string { - const extension = mimeToExtension[mimeType]; - if (!path.extname(fileName) && extension) { - return `${fileName}.${extension}`; - } - return fileName; - } - defaultFileName = addExtensionIfNeeded(defaultFileName, mimeType); - electron.dialog - .showSaveDialog(ww, { - title: "Save Image", - defaultPath: defaultFileName, - filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "heic"] }], - }) - .then((file) => { - if (file.canceled) { - readStream.destroy(); - return; - } - const writeStream = fs.createWriteStream(file.filePath); - readStream.pipe(writeStream); - writeStream.on("finish", () => { - console.log("saved file", file.filePath); - }); - writeStream.on("error", (err) => { - console.log("error saving file (writeStream)", err); - readStream.destroy(); - }); - readStream.on("error", (err) => { - console.error("error saving file (readStream)", err); - writeStream.destroy(); - }); - }) - .catch((err) => { - console.log("error trying to save file", err); - }); -} - -export function initIpcHandlers() { - electron.ipcMain.on("open-external", (event, url) => { - if (url && typeof url === "string") { - fireAndForget(() => - callWithOriginalXdgCurrentDesktopAsync(() => - electron.shell.openExternal(url).catch((err) => { - console.error(`Failed to open URL ${url}:`, err); - }) - ) - ); - } else { - console.error("Invalid URL received in open-external event:", url); - } - }); - - electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => { - const menu = new electron.Menu(); - const win = getWaveWindowByWebContentsId(event.sender.hostWebContents?.id); - if (win == null) { - return; - } - menu.append( - new electron.MenuItem({ - label: "Save Image", - click: () => { - const resultP = getUrlInSession(event.sender.session, payload.src); - resultP - .then((result) => { - saveImageFileWithNativeDialog( - event.sender.hostWebContents, - result.fileName, - result.mimeType, - result.stream - ); - }) - .catch((e) => { - console.log("error getting image", e); - }); - }, - }) - ); - menu.popup(); - }); - - electron.ipcMain.on("webview-mouse-navigate", (event: electron.IpcMainEvent, direction: string) => { - if (direction === "back") { - event.sender.navigationHistory.goBack(); - } else if (direction === "forward") { - event.sender.navigationHistory.goForward(); - } - }); - - electron.ipcMain.on("download", (event, payload) => { - const baseName = encodeURIComponent(path.basename(payload.filePath)); - const streamingUrl = - getWebServerEndpoint() + "/wave/stream-file/" + baseName + "?path=" + encodeURIComponent(payload.filePath); - event.sender.downloadURL(streamingUrl); - }); - - electron.ipcMain.on("get-cursor-point", (event) => { - const tabView = getWaveTabViewByWebContentsId(event.sender.id); - if (tabView == null) { - event.returnValue = null; - return; - } - const screenPoint = electron.screen.getCursorScreenPoint(); - const windowRect = tabView.getBounds(); - const retVal: Electron.Point = { - x: screenPoint.x - windowRect.x, - y: screenPoint.y - windowRect.y, - }; - event.returnValue = retVal; - }); - - electron.ipcMain.handle("capture-screenshot", async (event, rect) => { - const tabView = getWaveTabViewByWebContentsId(event.sender.id); - if (!tabView) { - throw new Error("No tab view found for the given webContents id"); - } - const image = await tabView.webContents.capturePage(rect); - const base64String = image.toPNG().toString("base64"); - return `data:image/png;base64,${base64String}`; - }); - - electron.ipcMain.on("get-env", (event, varName) => { - event.returnValue = process.env[varName] ?? null; - }); - - electron.ipcMain.on("get-about-modal-details", (event) => { - event.returnValue = getWaveVersion() as AboutModalDetails; - }); - - electron.ipcMain.on("get-zoom-factor", (event) => { - event.returnValue = event.sender.getZoomFactor(); - }); - - const hasBeforeInputRegisteredMap = new Map<number, boolean>(); - - electron.ipcMain.on("webview-focus", (event: Electron.IpcMainEvent, focusedId: number) => { - webviewFocusId = focusedId; - console.log("webview-focus", focusedId); - if (focusedId == null) { - return; - } - const parentWc = event.sender; - const webviewWc = electron.webContents.fromId(focusedId); - if (webviewWc == null) { - webviewFocusId = null; - return; - } - if (!hasBeforeInputRegisteredMap.get(focusedId)) { - hasBeforeInputRegisteredMap.set(focusedId, true); - webviewWc.on("before-input-event", (e, input) => { - let waveEvent = keyutil.adaptFromElectronKeyEvent(input); - handleCtrlShiftState(parentWc, waveEvent); - if (webviewFocusId != focusedId) { - return; - } - if (input.type != "keyDown") { - return; - } - for (let keyDesc of webviewKeys) { - if (keyutil.checkKeyPressed(waveEvent, keyDesc)) { - e.preventDefault(); - parentWc.send("reinject-key", waveEvent); - console.log("webview reinject-key", keyDesc); - return; - } - } - }); - webviewWc.on("destroyed", () => { - hasBeforeInputRegisteredMap.delete(focusedId); - }); - } - }); - - electron.ipcMain.on("register-global-webview-keys", (event, keys: string[]) => { - webviewKeys = keys ?? []; - }); - - electron.ipcMain.on("set-keyboard-chord-mode", (event) => { - event.returnValue = null; - const tabView = getWaveTabViewByWebContentsId(event.sender.id); - tabView?.setKeyboardChordMode(true); - }); - - electron.ipcMain.handle("set-is-active", () => { - setWasActive(true); - }); - - const fac = new FastAverageColor(); - electron.ipcMain.on("update-window-controls-overlay", async (event, rect: Dimensions) => { - if (unamePlatform === "darwin") return; - try { - const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); - if (fullConfig?.settings?.["window:nativetitlebar"] && unamePlatform !== "win32") return; - - const zoomFactor = event.sender.getZoomFactor(); - const electronRect: Electron.Rectangle = { - x: rect.left * zoomFactor, - y: rect.top * zoomFactor, - height: rect.height * zoomFactor, - width: rect.width * zoomFactor, - }; - const overlay = await event.sender.capturePage(electronRect); - const overlayBuffer = overlay.toPNG(); - const png = PNG.sync.read(overlayBuffer); - const color = fac.prepareResult(fac.getColorFromArray4(png.data)); - const ww = getWaveWindowByWebContentsId(event.sender.id); - if (ww == null) return; - ww.setTitleBarOverlay({ - color: unamePlatform === "linux" ? color.rgba : "#00000000", - symbolColor: color.isDark ? "white" : "black", - }); - } catch (e) { - console.error("Error updating window controls overlay:", e); - } - }); - - electron.ipcMain.on("quicklook", (event, filePath: string) => { - if (unamePlatform !== "darwin") return; - child_process.execFile("/usr/bin/qlmanage", ["-p", filePath], (error, stdout, stderr) => { - if (error) { - console.error(`Error opening Quick Look: ${error}`); - } - }); - }); - - electron.ipcMain.handle("clear-webview-storage", async (event, webContentsId: number) => { - try { - const wc = electron.webContents.fromId(webContentsId); - if (wc && wc.session) { - await wc.session.clearStorageData(); - console.log("Cleared cookies and storage for webContentsId:", webContentsId); - } - } catch (e) { - console.error("Failed to clear cookies and storage:", e); - throw e; - } - }); - - electron.ipcMain.on("open-native-path", (event, filePath: string) => { - console.log("open-native-path", filePath); - filePath = filePath.replace("~", electronApp.getPath("home")); - fireAndForget(() => - callWithOriginalXdgCurrentDesktopAsync(() => - electron.shell.openPath(filePath).then((excuse) => { - if (excuse) console.error(`Failed to open ${filePath} in native application: ${excuse}`); - }) - ) - ); - }); - - electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-ready") => { - const tabView = getWaveTabViewByWebContentsId(event.sender.id); - if (tabView != null && tabView.initResolve != null) { - if (status === "ready") { - tabView.initResolve(); - if (tabView.savedInitOpts) { - console.log("savedInitOpts calling wave-init", tabView.waveTabId); - tabView.webContents.send("wave-init", tabView.savedInitOpts); - } - } else if (status === "wave-ready") { - tabView.waveReadyResolve(); - } - return; - } - - const builderWindow = getBuilderWindowByWebContentsId(event.sender.id); - if (builderWindow != null) { - if (status === "ready") { - if (builderWindow.savedInitOpts) { - console.log("savedInitOpts calling builder-init", builderWindow.savedInitOpts.builderId); - builderWindow.webContents.send("builder-init", builderWindow.savedInitOpts); - } - } - return; - } - - console.log("set-window-init-status: no window found for webContentsId", event.sender.id); - }); - - electron.ipcMain.on("fe-log", (event, logStr: string) => { - console.log("fe-log", logStr); - }); - - electron.ipcMain.on( - "increment-term-commands", - (event, opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => { - incrementTermCommandsRun(); - if (opts?.isRemote) { - incrementTermCommandsRemote(); - } - if (opts?.isWsl) { - incrementTermCommandsWsl(); - } - if (opts?.isDurable) { - incrementTermCommandsDurable(); - } - } - ); - - electron.ipcMain.on("native-paste", (event) => { - event.sender.paste(); - }); - - electron.ipcMain.on("open-builder", (event, appId?: string) => { - openBuilderWindow(appId); - }); - - electron.ipcMain.on("set-builder-window-appid", (event, appId: string) => { - const bw = getBuilderWindowByWebContentsId(event.sender.id); - if (bw == null) { - return; - } - bw.builderAppId = appId; - console.log("set-builder-window-appid", bw.builderId, appId); - }); - - electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); - - electron.ipcMain.on("close-builder-window", async (event) => { - const bw = getBuilderWindowByWebContentsId(event.sender.id); - if (bw == null) { - return; - } - const builderId = bw.builderId; - if (builderId) { - try { - await RpcApi.SetRTInfoCommand(ElectronWshClient, { - oref: `builder:${builderId}`, - data: {} as ObjRTInfo, - delete: true, - }); - } catch (e) { - console.error("Error deleting builder rtinfo:", e); - } - } - const wc = bw.webContents; - if (wc.isDevToolsOpened()) { - wc.closeDevTools(); - } - for (const guest of electron.webContents.getAllWebContents()) { - if (guest.getType() === "webview" && guest.hostWebContents?.id === wc.id) { - if (guest.isDevToolsOpened()) { - guest.closeDevTools(); - } - } - } - bw.destroy(); - }); - - electron.ipcMain.on("do-refresh", (event) => { - event.sender.reloadIgnoringCache(); - }); - - electron.ipcMain.handle("save-text-file", async (event, fileName: string, content: string) => { - const ww = electron.BrowserWindow.fromWebContents(event.sender); - if (ww == null) { - return false; - } - const result = await electron.dialog.showSaveDialog(ww, { - title: "Save Scrollback", - defaultPath: fileName || "session.log", - filters: [{ name: "Text Files", extensions: ["txt", "log"] }], - }); - if (result.canceled || !result.filePath) { - return false; - } - try { - await fs.promises.writeFile(result.filePath, content, "utf-8"); - console.log("saved scrollback to", result.filePath); - return true; - } catch (err) { - console.error("error saving scrollback file", err); - return false; - } - }); -} diff --git a/emain/emain-log.ts b/emain/emain-log.ts deleted file mode 100644 index 91241b522a..0000000000 --- a/emain/emain-log.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "fs"; -import path from "path"; -import { format } from "util"; -import winston from "winston"; -import { getWaveDataDir, isDev } from "./emain-platform"; - -const oldConsoleLog = console.log; - -function findHighestLogNumber(logsDir: string): number { - if (!fs.existsSync(logsDir)) { - return 0; - } - const files = fs.readdirSync(logsDir); - let maxNum = 0; - for (const file of files) { - const match = file.match(/^waveapp\.(\d+)\.log$/); - if (match) { - const num = parseInt(match[1], 10); - if (num > maxNum) { - maxNum = num; - } - } - } - return maxNum; -} - -function pruneOldLogs(logsDir: string): { pruned: string[]; error: any } { - if (!fs.existsSync(logsDir)) { - return { pruned: [], error: null }; - } - - const files = fs.readdirSync(logsDir); - const logFiles: { name: string; num: number }[] = []; - - for (const file of files) { - const match = file.match(/^waveapp\.(\d+)\.log$/); - if (match) { - logFiles.push({ name: file, num: parseInt(match[1], 10) }); - } - } - - if (logFiles.length <= 5) { - return { pruned: [], error: null }; - } - - logFiles.sort((a, b) => b.num - a.num); - const toDelete = logFiles.slice(5); - const pruned: string[] = []; - let firstError: any = null; - - for (const logFile of toDelete) { - try { - fs.unlinkSync(path.join(logsDir, logFile.name)); - pruned.push(logFile.name); - } catch (e) { - if (firstError == null) { - firstError = e; - } - } - } - - return { pruned, error: firstError }; -} - -function rotateLogIfNeeded(): string | null { - const waveDataDir = getWaveDataDir(); - const logFile = path.join(waveDataDir, "waveapp.log"); - const logsDir = path.join(waveDataDir, "logs"); - - if (!fs.existsSync(logsDir)) { - fs.mkdirSync(logsDir, { recursive: true }); - } - - if (!fs.existsSync(logFile)) { - return null; - } - - const stats = fs.statSync(logFile); - if (stats.size > 10 * 1024 * 1024) { - const nextNum = findHighestLogNumber(logsDir) + 1; - const rotatedPath = path.join(logsDir, `waveapp.${nextNum}.log`); - fs.renameSync(logFile, rotatedPath); - return rotatedPath; - } - return null; -} - -let logRotateError: any = null; -let rotatedPath: string | null = null; -let prunedFiles: string[] = []; -let pruneError: any = null; -try { - rotatedPath = rotateLogIfNeeded(); - const logsDir = path.join(getWaveDataDir(), "logs"); - const pruneResult = pruneOldLogs(logsDir); - prunedFiles = pruneResult.pruned; - pruneError = pruneResult.error; -} catch (e) { - logRotateError = e; -} - -const loggerTransports: winston.transport[] = [ - new winston.transports.File({ filename: path.join(getWaveDataDir(), "waveapp.log"), level: "info" }), -]; -if (isDev) { - loggerTransports.push(new winston.transports.Console()); -} -const loggerConfig = { - level: "info", - format: winston.format.combine( - winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }), - winston.format.printf((info) => `${info.timestamp} ${info.message}`) - ), - transports: loggerTransports, -}; -const logger = winston.createLogger(loggerConfig); - -function log(...msg: any[]) { - try { - logger.info(format(...msg)); - } catch (e) { - oldConsoleLog(...msg); - } -} - -if (logRotateError != null) { - log("error rotating/pruning logs (non-fatal):", logRotateError); -} -if (rotatedPath != null) { - log("rotated old log file to:", rotatedPath); -} -if (prunedFiles.length > 0) { - log("pruned old log files:", prunedFiles.join(", ")); -} -if (pruneError != null) { - log("error pruning some log files (non-fatal):", pruneError); -} - -export { log }; diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts deleted file mode 100644 index 1bdf6a7139..0000000000 --- a/emain/emain-menu.ts +++ /dev/null @@ -1,519 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { waveEventSubscribeSingle } from "@/app/store/wps"; -import { RpcApi } from "@/app/store/wshclientapi"; -import * as electron from "electron"; -import { fireAndForget } from "../frontend/util/util"; -import { focusedBuilderWindow, getBuilderWindowById } from "./emain-builder"; -import { openBuilderWindow } from "./emain-ipc"; -import { isDev, unamePlatform } from "./emain-platform"; -import { clearTabCache } from "./emain-tabview"; -import { decreaseZoomLevel, increaseZoomLevel, resetZoomLevel } from "./emain-util"; -import { - createNewWaveWindow, - createWorkspace, - focusedWaveWindow, - getAllWaveWindows, - getWaveWindowByWorkspaceId, - relaunchBrowserWindows, - WaveBrowserWindow, -} from "./emain-window"; -import { ElectronWshClient } from "./emain-wsh"; -import { updater } from "./updater"; - -type AppMenuCallbacks = { - createNewWaveWindow: () => Promise<void>; - relaunchBrowserWindows: () => Promise<void>; -}; - -function getWindowWebContents(window: electron.BaseWindow): electron.WebContents { - if (window == null) { - return null; - } - // Check BrowserWindow first (for Tsunami Builder windows) - if (window instanceof electron.BrowserWindow) { - return window.webContents; - } - // Check WaveBrowserWindow (for main Wave windows with tab views) - if (window instanceof WaveBrowserWindow) { - if (window.activeTabView) { - return window.activeTabView.webContents; - } - return null; - } - return null; -} - -async function getWorkspaceMenu(ww?: WaveBrowserWindow): Promise<Electron.MenuItemConstructorOptions[]> { - const workspaceList = await RpcApi.WorkspaceListCommand(ElectronWshClient); - const workspaceMenu: Electron.MenuItemConstructorOptions[] = [ - { - label: "Create Workspace", - click: (_, window) => fireAndForget(() => createWorkspace((window as WaveBrowserWindow) ?? ww)), - }, - ]; - function getWorkspaceSwitchAccelerator(i: number): string { - if (i < 9) { - return unamePlatform == "darwin" ? `Command+Control+${i + 1}` : `Alt+Control+${i + 1}`; - } - } - if (workspaceList?.length) { - workspaceMenu.push( - { type: "separator" }, - ...workspaceList.map<Electron.MenuItemConstructorOptions>((workspace, i) => { - return { - label: `${workspace.workspacedata.name}`, - click: (_, window) => { - ((window as WaveBrowserWindow) ?? ww)?.switchWorkspace(workspace.workspacedata.oid); - }, - accelerator: getWorkspaceSwitchAccelerator(i), - }; - }) - ); - } - return workspaceMenu; -} - -function makeEditMenu(fullConfig?: FullConfigType): Electron.MenuItemConstructorOptions[] { - let pasteAccelerator: string; - if (unamePlatform === "darwin") { - pasteAccelerator = "Command+V"; - } else { - const ctrlVPaste = fullConfig?.settings?.["app:ctrlvpaste"]; - if (ctrlVPaste == null) { - pasteAccelerator = unamePlatform === "win32" ? "Control+V" : ""; - } else if (ctrlVPaste) { - pasteAccelerator = "Control+V"; - } else { - pasteAccelerator = ""; - } - } - return [ - { - role: "undo", - accelerator: unamePlatform === "darwin" ? "Command+Z" : "", - }, - { - role: "redo", - accelerator: unamePlatform === "darwin" ? "Command+Shift+Z" : "", - }, - { type: "separator" }, - { - role: "cut", - accelerator: unamePlatform === "darwin" ? "Command+X" : "", - }, - { - role: "copy", - accelerator: unamePlatform === "darwin" ? "Command+C" : "", - }, - { - role: "paste", - accelerator: pasteAccelerator, - }, - { - role: "pasteAndMatchStyle", - accelerator: unamePlatform === "darwin" ? "Command+Shift+V" : "", - }, - { - role: "delete", - }, - { - role: "selectAll", - accelerator: unamePlatform === "darwin" ? "Command+A" : "", - }, - ]; -} - -function makeFileMenu( - numWaveWindows: number, - callbacks: AppMenuCallbacks, - fullConfig: FullConfigType -): Electron.MenuItemConstructorOptions[] { - const fileMenu: Electron.MenuItemConstructorOptions[] = [ - { - label: "New Window", - accelerator: "CommandOrControl+Shift+N", - click: () => fireAndForget(callbacks.createNewWaveWindow), - }, - { - role: "close", - accelerator: "", - click: () => { - focusedWaveWindow?.close(); - }, - }, - ]; - const featureWaveAppBuilder = fullConfig?.settings?.["feature:waveappbuilder"]; - if (isDev || featureWaveAppBuilder) { - fileMenu.splice(1, 0, { - label: "New WaveApp Builder Window", - accelerator: unamePlatform === "darwin" ? "Command+Shift+B" : "Alt+Shift+B", - click: () => openBuilderWindow(""), - }); - } - if (numWaveWindows == 0) { - fileMenu.push({ - label: "New Window (hidden-1)", - accelerator: unamePlatform === "darwin" ? "Command+N" : "Alt+N", - acceleratorWorksWhenHidden: true, - visible: false, - click: () => fireAndForget(callbacks.createNewWaveWindow), - }); - fileMenu.push({ - label: "New Window (hidden-2)", - accelerator: unamePlatform === "darwin" ? "Command+T" : "Alt+T", - acceleratorWorksWhenHidden: true, - visible: false, - click: () => fireAndForget(callbacks.createNewWaveWindow), - }); - } - return fileMenu; -} - -function makeAppMenuItems(webContents: electron.WebContents): Electron.MenuItemConstructorOptions[] { - const appMenuItems: Electron.MenuItemConstructorOptions[] = [ - { - label: "About Wave Terminal", - click: (_, window) => { - (getWindowWebContents(window) ?? webContents)?.send("menu-item-about"); - }, - }, - { - label: "Check for Updates", - click: () => { - fireAndForget(() => updater?.checkForUpdates(true)); - }, - }, - { type: "separator" }, - ]; - if (unamePlatform === "darwin") { - appMenuItems.push( - { role: "services" }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { type: "separator" } - ); - } - appMenuItems.push({ role: "quit" }); - return appMenuItems; -} - -function makeViewMenu( - webContents: electron.WebContents, - callbacks: AppMenuCallbacks, - isBuilderWindowFocused: boolean, - fullscreenOnLaunch: boolean -): Electron.MenuItemConstructorOptions[] { - const devToolsAccel = unamePlatform === "darwin" ? "Option+Command+I" : "Alt+Shift+I"; - return [ - { - label: isBuilderWindowFocused ? "Reload Window" : "Reload Tab", - accelerator: "Shift+CommandOrControl+R", - click: (_, window) => { - (getWindowWebContents(window) ?? webContents)?.reloadIgnoringCache(); - }, - }, - { - label: "Relaunch All Windows", - click: () => callbacks.relaunchBrowserWindows(), - }, - { - label: "Clear Tab Cache", - click: () => clearTabCache(), - }, - { - label: "Toggle DevTools", - accelerator: devToolsAccel, - click: (_, window) => { - const wc = getWindowWebContents(window) ?? webContents; - wc?.toggleDevTools(); - }, - }, - { type: "separator" }, - { - label: "Reset Zoom", - accelerator: "CommandOrControl+0", - click: (_, window) => { - const wc = getWindowWebContents(window) ?? webContents; - if (wc) { - resetZoomLevel(wc); - } - }, - }, - { - label: "Zoom In", - accelerator: "CommandOrControl+=", - click: (_, window) => { - const wc = getWindowWebContents(window) ?? webContents; - if (wc) { - increaseZoomLevel(wc); - } - }, - }, - { - label: "Zoom In (hidden)", - accelerator: "CommandOrControl+Shift+=", - click: (_, window) => { - const wc = getWindowWebContents(window) ?? webContents; - if (wc) { - increaseZoomLevel(wc); - } - }, - visible: false, - acceleratorWorksWhenHidden: true, - }, - { - label: "Zoom Out", - accelerator: "CommandOrControl+-", - click: (_, window) => { - const wc = getWindowWebContents(window) ?? webContents; - if (wc) { - decreaseZoomLevel(wc); - } - }, - }, - { - label: "Zoom Out (hidden)", - accelerator: "CommandOrControl+Shift+-", - click: (_, window) => { - const wc = getWindowWebContents(window) ?? webContents; - if (wc) { - decreaseZoomLevel(wc); - } - }, - visible: false, - acceleratorWorksWhenHidden: true, - }, - { - label: "Launch On Full Screen", - submenu: [ - { - label: "On", - type: "radio", - checked: fullscreenOnLaunch, - click: () => { - RpcApi.SetConfigCommand(ElectronWshClient, { "window:fullscreenonlaunch": true }); - }, - }, - { - label: "Off", - type: "radio", - checked: !fullscreenOnLaunch, - click: () => { - RpcApi.SetConfigCommand(ElectronWshClient, { "window:fullscreenonlaunch": false }); - }, - }, - ], - }, - { type: "separator" }, - { - role: "togglefullscreen", - }, - { type: "separator" }, - { - label: "Toggle Widgets Bar", - click: () => { - fireAndForget(async () => { - const workspaceId = focusedWaveWindow?.workspaceId; - if (!workspaceId) return; - const oref = `workspace:${workspaceId}`; - const meta = await RpcApi.GetMetaCommand(ElectronWshClient, { oref }); - const current = meta?.["layout:widgetsvisible"] ?? true; - await RpcApi.SetMetaCommand(ElectronWshClient, { oref, meta: { "layout:widgetsvisible": !current } }); - }); - }, - }, - ]; -} - -async function makeFullAppMenu(callbacks: AppMenuCallbacks, workspaceOrBuilderId?: string): Promise<Electron.Menu> { - const numWaveWindows = getAllWaveWindows().length; - const webContents = workspaceOrBuilderId && getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId); - const appMenuItems = makeAppMenuItems(webContents); - - const isBuilderWindowFocused = focusedBuilderWindow != null; - let fullscreenOnLaunch = false; - let fullConfig: FullConfigType = null; - try { - fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); - fullscreenOnLaunch = fullConfig?.settings["window:fullscreenonlaunch"]; - } catch (e) { - console.error("Error fetching config:", e); - } - const editMenu = makeEditMenu(fullConfig); - const fileMenu = makeFileMenu(numWaveWindows, callbacks, fullConfig); - const viewMenu = makeViewMenu(webContents, callbacks, isBuilderWindowFocused, fullscreenOnLaunch); - let workspaceMenu: Electron.MenuItemConstructorOptions[] = null; - try { - workspaceMenu = await getWorkspaceMenu(); - } catch (e) { - console.error("getWorkspaceMenu error:", e); - } - const windowMenu: Electron.MenuItemConstructorOptions[] = [ - { role: "minimize", accelerator: "" }, - { role: "zoom" }, - { type: "separator" }, - { role: "front" }, - ]; - const menuTemplate: Electron.MenuItemConstructorOptions[] = [ - { role: "appMenu", submenu: appMenuItems }, - { role: "fileMenu", submenu: fileMenu }, - { role: "editMenu", submenu: editMenu }, - { role: "viewMenu", submenu: viewMenu }, - ]; - if (workspaceMenu != null && !isBuilderWindowFocused) { - menuTemplate.push({ - label: "Workspace", - id: "workspace-menu", - submenu: workspaceMenu, - }); - } - menuTemplate.push({ - role: "windowMenu", - submenu: windowMenu, - }); - return electron.Menu.buildFromTemplate(menuTemplate); -} - -export function instantiateAppMenu(workspaceOrBuilderId?: string): Promise<electron.Menu> { - return makeFullAppMenu( - { - createNewWaveWindow, - relaunchBrowserWindows, - }, - workspaceOrBuilderId - ); -} - -// does not a set a menu on windows -export function makeAndSetAppMenu() { - if (unamePlatform === "win32") { - return; - } - fireAndForget(async () => { - const menu = await instantiateAppMenu(); - electron.Menu.setApplicationMenu(menu); - }); -} - -function initMenuEventSubscriptions() { - waveEventSubscribeSingle({ - eventType: "workspace:update", - handler: makeAndSetAppMenu, - }); -} - -function getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId: string): electron.WebContents { - const ww = getWaveWindowByWorkspaceId(workspaceOrBuilderId); - if (ww) { - return ww.activeTabView?.webContents; - } - - const bw = getBuilderWindowById(workspaceOrBuilderId); - if (bw) { - return bw.webContents; - } - - return null; -} - -function convertMenuDefArrToMenu( - webContents: electron.WebContents, - menuDefArr: ElectronContextMenuItem[], - menuState: { hasClick: boolean } -): electron.Menu { - const menuItems: electron.MenuItem[] = []; - for (const menuDef of menuDefArr) { - const menuItemTemplate: electron.MenuItemConstructorOptions = { - role: menuDef.role as any, - label: menuDef.label, - type: menuDef.type, - click: () => { - menuState.hasClick = true; - webContents.send("contextmenu-click", menuDef.id); - }, - checked: menuDef.checked, - enabled: menuDef.enabled, - }; - if (menuDef.submenu != null) { - menuItemTemplate.submenu = convertMenuDefArrToMenu(webContents, menuDef.submenu, menuState); - } - const menuItem = new electron.MenuItem(menuItemTemplate); - menuItems.push(menuItem); - } - return electron.Menu.buildFromTemplate(menuItems); -} - -electron.ipcMain.on( - "contextmenu-show", - (event, workspaceOrBuilderId: string, menuDefArr: ElectronContextMenuItem[]) => { - const webContents = getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId); - if (!webContents) { - console.error("invalid window for context menu:", workspaceOrBuilderId); - event.returnValue = true; - return; - } - if (menuDefArr.length === 0) { - webContents.send("contextmenu-click", null); - event.returnValue = true; - return; - } - fireAndForget(async () => { - const menuState = { hasClick: false }; - const menu = convertMenuDefArrToMenu(webContents, menuDefArr, menuState); - menu.popup({ - callback: () => { - if (!menuState.hasClick) { - webContents.send("contextmenu-click", null); - } - }, - }); - }); - event.returnValue = true; - } -); - -electron.ipcMain.on("workspace-appmenu-show", (event, workspaceId: string) => { - fireAndForget(async () => { - const webContents = getWebContentsByWorkspaceOrBuilderId(workspaceId); - if (!webContents) { - console.error("invalid window for workspace app menu:", workspaceId); - return; - } - const menu = await instantiateAppMenu(workspaceId); - menu.popup(); - }); - event.returnValue = true; -}); - -electron.ipcMain.on("builder-appmenu-show", (event, builderId: string) => { - fireAndForget(async () => { - const webContents = getWebContentsByWorkspaceOrBuilderId(builderId); - if (!webContents) { - console.error("invalid window for builder app menu:", builderId); - return; - } - const menu = await instantiateAppMenu(builderId); - menu.popup(); - }); - event.returnValue = true; -}); - -const dockMenu = electron.Menu.buildFromTemplate([ - { - label: "New Window", - click() { - fireAndForget(createNewWaveWindow); - }, - }, -]); - -function makeDockTaskbar() { - if (unamePlatform == "darwin") { - electron.app.dock.setMenu(dockMenu); - } -} - -export { initMenuEventSubscriptions, makeDockTaskbar }; diff --git a/emain/emain-platform.ts b/emain/emain-platform.ts deleted file mode 100644 index 32320e4eb4..0000000000 --- a/emain/emain-platform.ts +++ /dev/null @@ -1,286 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { fireAndForget } from "@/util/util"; -import { app, dialog, ipcMain, shell } from "electron"; -import envPaths from "env-paths"; -import { existsSync, mkdirSync } from "fs"; -import os from "os"; -import path from "path"; -import { WaveDevVarName, WaveDevViteVarName } from "../frontend/util/isdev"; -import * as keyutil from "../frontend/util/keyutil"; - -// This is a little trick to ensure that Electron puts all its runtime data into a subdirectory to avoid conflicts with our own data. -// On macOS, it will store to ~/Library/Application \Support/waveterm/electron -// On Linux, it will store to ~/.config/waveterm/electron -// On Windows, it will store to %LOCALAPPDATA%/waveterm/electron -app.setName("waveterm/electron"); - -const isDev = !app.isPackaged; -const isDevVite = isDev && process.env.ELECTRON_RENDERER_URL; -console.log(`Running in ${isDev ? "development" : "production"} mode`); -if (isDev) { - process.env[WaveDevVarName] = "1"; -} -if (isDevVite) { - process.env[WaveDevViteVarName] = "1"; -} - -const waveDirNamePrefix = "waveterm"; -const waveDirNameSuffix = isDev ? "dev" : ""; -const waveDirName = `${waveDirNamePrefix}${waveDirNameSuffix ? `-${waveDirNameSuffix}` : ""}`; - -const paths = envPaths("waveterm", { suffix: waveDirNameSuffix }); - -app.setName(isDev ? "Wave (Dev)" : "Wave"); -const unamePlatform = process.platform; -const unameArch: string = process.arch; -keyutil.setKeyUtilPlatform(unamePlatform); - -const WaveConfigHomeVarName = "WAVETERM_CONFIG_HOME"; -const WaveDataHomeVarName = "WAVETERM_DATA_HOME"; -const WaveHomeVarName = "WAVETERM_HOME"; - -export function checkIfRunningUnderARM64Translation(fullConfig: FullConfigType) { - if (!fullConfig.settings["app:dismissarchitecturewarning"] && app.runningUnderARM64Translation) { - console.log("Running under ARM64 translation, alerting user"); - const dialogOpts: Electron.MessageBoxOptions = { - type: "warning", - buttons: ["Dismiss", "Learn More"], - title: "Wave has detected a performance issue", - message: `Wave is running in ARM64 translation mode which may impact performance.\n\nRecommendation: Download the native ARM64 version from our website for optimal performance.`, - }; - - const choice = dialog.showMessageBoxSync(null, dialogOpts); - if (choice === 1) { - // Open the documentation URL - console.log("User chose to learn more"); - fireAndForget(() => - shell.openExternal( - "https://docs.waveterm.dev/faq#why-does-wave-warn-me-about-arm64-translation-when-it-launches" - ) - ); - throw new Error("User redirected to docsite to learn more about ARM64 translation, exiting"); - } else { - console.log("User dismissed the dialog"); - } - } -} - -/** - * Gets the path to the old Wave home directory (defaults to `~/.waveterm`). - * @returns The path to the directory if it exists and contains valid data for the current app, otherwise null. - */ -function getWaveHomeDir(): string { - let home = process.env[WaveHomeVarName]; - if (!home) { - const homeDir = app.getPath("home"); - if (homeDir) { - home = path.join(homeDir, `.${waveDirName}`); - } - } - // If home exists and it has `wave.lock` in it, we know it has valid data from Wave >=v0.8. Otherwise, it could be for WaveLegacy (<v0.8) - if (home && existsSync(home) && existsSync(path.join(home, "wave.lock"))) { - return home; - } - return null; -} - -/** - * Ensure the given path exists, creating it recursively if it doesn't. - * @param path The path to ensure. - * @returns The same path, for chaining. - */ -function ensurePathExists(path: string): string { - if (!existsSync(path)) { - mkdirSync(path, { recursive: true }); - } - return path; -} - -/** - * Gets the path to the directory where Wave configurations are stored. Creates the directory if it does not exist. - * Handles backwards compatibility with the old Wave Home directory model, where configurations and data were stored together. - * @returns The path where configurations should be stored. - */ -function getWaveConfigDir(): string { - // If wave home dir exists, use it for backwards compatibility - const waveHomeDir = getWaveHomeDir(); - if (waveHomeDir) { - return path.join(waveHomeDir, "config"); - } - - const override = process.env[WaveConfigHomeVarName]; - const xdgConfigHome = process.env.XDG_CONFIG_HOME; - let retVal: string; - if (override) { - retVal = override; - } else if (xdgConfigHome) { - retVal = path.join(xdgConfigHome, waveDirName); - } else { - retVal = path.join(app.getPath("home"), ".config", waveDirName); - } - return ensurePathExists(retVal); -} - -/** - * Gets the path to the directory where Wave data is stored. Creates the directory if it does not exist. - * Handles backwards compatibility with the old Wave Home directory model, where configurations and data were stored together. - * @returns The path where data should be stored. - */ -function getWaveDataDir(): string { - // If wave home dir exists, use it for backwards compatibility - const waveHomeDir = getWaveHomeDir(); - if (waveHomeDir) { - return waveHomeDir; - } - - const override = process.env[WaveDataHomeVarName]; - const xdgDataHome = process.env.XDG_DATA_HOME; - let retVal: string; - if (override) { - retVal = override; - } else if (xdgDataHome) { - retVal = path.join(xdgDataHome, waveDirName); - } else { - retVal = paths.data; - } - return ensurePathExists(retVal); -} - -function getElectronAppBasePath(): string { - // import.meta.dirname in dev points to waveterm/dist/main - return path.dirname(import.meta.dirname); -} - -function getElectronAppUnpackedBasePath(): string { - return getElectronAppBasePath().replace("app.asar", "app.asar.unpacked"); -} - -function getElectronAppResourcesPath(): string { - if (isDev) { - // import.meta.dirname in dev points to waveterm/dist/main - return path.dirname(import.meta.dirname); - } - return process.resourcesPath; -} - -const wavesrvBinName = `wavesrv.${unameArch}`; - -function getWaveSrvPath(): string { - if (process.platform === "win32") { - const winBinName = `${wavesrvBinName}.exe`; - const appPath = path.join(getElectronAppUnpackedBasePath(), "bin", winBinName); - return `${appPath}`; - } - return path.join(getElectronAppUnpackedBasePath(), "bin", wavesrvBinName); -} - -function getWaveSrvCwd(): string { - return getWaveDataDir(); -} - -ipcMain.on("get-is-dev", (event) => { - event.returnValue = isDev; -}); -ipcMain.on("get-platform", (event, url) => { - event.returnValue = unamePlatform; -}); -ipcMain.on("get-user-name", (event) => { - const userInfo = os.userInfo(); - event.returnValue = userInfo.username; -}); -ipcMain.on("get-host-name", (event) => { - event.returnValue = os.hostname(); -}); -ipcMain.on("get-webview-preload", (event) => { - event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs"); -}); -ipcMain.on("get-data-dir", (event) => { - event.returnValue = getWaveDataDir(); -}); -ipcMain.on("get-config-dir", (event) => { - event.returnValue = getWaveConfigDir(); -}); -ipcMain.on("get-home-dir", (event) => { - event.returnValue = app.getPath("home"); -}); - -/** - * Gets the value of the XDG_CURRENT_DESKTOP environment variable. If ORIGINAL_XDG_CURRENT_DESKTOP is set, it will be returned instead. - * This corrects for a strange behavior in Electron, where it sets its own value for XDG_CURRENT_DESKTOP to improve Chromium compatibility. - * @see https://www.electronjs.org/docs/latest/api/environment-variables#original_xdg_current_desktop - * @returns The value of the XDG_CURRENT_DESKTOP environment variable, or ORIGINAL_XDG_CURRENT_DESKTOP if set, or undefined if neither are set. - */ -function getXdgCurrentDesktop(): string { - if (process.env.ORIGINAL_XDG_CURRENT_DESKTOP) { - return process.env.ORIGINAL_XDG_CURRENT_DESKTOP; - } else if (process.env.XDG_CURRENT_DESKTOP) { - return process.env.XDG_CURRENT_DESKTOP; - } else { - return undefined; - } -} - -/** - * Calls the given callback with the value of the XDG_CURRENT_DESKTOP environment variable set to ORIGINAL_XDG_CURRENT_DESKTOP if it is set. - * @see https://www.electronjs.org/docs/latest/api/environment-variables#original_xdg_current_desktop - * @param callback The callback to call. - */ -function callWithOriginalXdgCurrentDesktop(callback: () => void) { - const currXdgCurrentDesktopDefined = "XDG_CURRENT_DESKTOP" in process.env; - const currXdgCurrentDesktop = process.env.XDG_CURRENT_DESKTOP; - const originalXdgCurrentDesktop = getXdgCurrentDesktop(); - if (originalXdgCurrentDesktop) { - process.env.XDG_CURRENT_DESKTOP = originalXdgCurrentDesktop; - } - callback(); - if (originalXdgCurrentDesktop) { - if (currXdgCurrentDesktopDefined) { - process.env.XDG_CURRENT_DESKTOP = currXdgCurrentDesktop; - } else { - delete process.env.XDG_CURRENT_DESKTOP; - } - } -} - -/** - * Calls the given async callback with the value of the XDG_CURRENT_DESKTOP environment variable set to ORIGINAL_XDG_CURRENT_DESKTOP if it is set. - * @see https://www.electronjs.org/docs/latest/api/environment-variables#original_xdg_current_desktop - * @param callback The async callback to call. - */ -async function callWithOriginalXdgCurrentDesktopAsync(callback: () => Promise<void>) { - const currXdgCurrentDesktopDefined = "XDG_CURRENT_DESKTOP" in process.env; - const currXdgCurrentDesktop = process.env.XDG_CURRENT_DESKTOP; - const originalXdgCurrentDesktop = getXdgCurrentDesktop(); - if (originalXdgCurrentDesktop) { - process.env.XDG_CURRENT_DESKTOP = originalXdgCurrentDesktop; - } - await callback(); - if (originalXdgCurrentDesktop) { - if (currXdgCurrentDesktopDefined) { - process.env.XDG_CURRENT_DESKTOP = currXdgCurrentDesktop; - } else { - delete process.env.XDG_CURRENT_DESKTOP; - } - } -} - -export { - callWithOriginalXdgCurrentDesktop, - callWithOriginalXdgCurrentDesktopAsync, - getElectronAppBasePath, - getElectronAppResourcesPath, - getElectronAppUnpackedBasePath, - getWaveConfigDir, - getWaveDataDir, - getWaveSrvCwd, - getWaveSrvPath, - getXdgCurrentDesktop, - isDev, - isDevVite, - unameArch, - unamePlatform, - WaveConfigHomeVarName, - WaveDataHomeVarName, -}; diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts deleted file mode 100644 index 753a53adec..0000000000 --- a/emain/emain-tabview.ts +++ /dev/null @@ -1,395 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { RpcApi } from "@/app/store/wshclientapi"; -import { adaptFromElectronKeyEvent, checkKeyPressed } from "@/util/keyutil"; -import { CHORD_TIMEOUT } from "@/util/sharedconst"; -import { Rectangle, shell, WebContentsView } from "electron"; -import { createNewWaveWindow, getWaveWindowById } from "emain/emain-window"; -import path from "path"; -import { configureAuthKeyRequestInjection } from "./authkey"; -import { setWasActive } from "./emain-activity"; -import { getElectronAppBasePath, isDevVite, unamePlatform } from "./emain-platform"; -import { - decreaseZoomLevel, - handleCtrlShiftFocus, - handleCtrlShiftState, - increaseZoomLevel, - resetZoomLevel, - shFrameNavHandler, - shNavHandler, -} from "./emain-util"; -import { ElectronWshClient } from "./emain-wsh"; - -function handleWindowsMenuAccelerators( - waveEvent: WaveKeyboardEvent, - tabView: WaveTabView, - fullConfig: FullConfigType -): boolean { - const waveWindow = getWaveWindowById(tabView.waveWindowId); - - if (checkKeyPressed(waveEvent, "Ctrl:Shift:n")) { - createNewWaveWindow(); - return true; - } - - if (checkKeyPressed(waveEvent, "Ctrl:Shift:r")) { - tabView.webContents.reloadIgnoringCache(); - return true; - } - - if (checkKeyPressed(waveEvent, "Ctrl:v")) { - const ctrlVPaste = fullConfig?.settings?.["app:ctrlvpaste"]; - const shouldPaste = ctrlVPaste ?? true; - if (!shouldPaste) { - return false; - } - tabView.webContents.paste(); - return true; - } - - if (checkKeyPressed(waveEvent, "Ctrl:0")) { - resetZoomLevel(tabView.webContents); - return true; - } - - if (checkKeyPressed(waveEvent, "Ctrl:=") || checkKeyPressed(waveEvent, "Ctrl:Shift:=")) { - increaseZoomLevel(tabView.webContents); - return true; - } - - if (checkKeyPressed(waveEvent, "Ctrl:-") || checkKeyPressed(waveEvent, "Ctrl:Shift:-")) { - decreaseZoomLevel(tabView.webContents); - return true; - } - - if (checkKeyPressed(waveEvent, "F11")) { - if (waveWindow) { - waveWindow.setFullScreen(!waveWindow.isFullScreen()); - } - return true; - } - - for (let i = 1; i <= 9; i++) { - if (checkKeyPressed(waveEvent, `Alt:Ctrl:${i}`)) { - const workspaceNum = i - 1; - RpcApi.WorkspaceListCommand(ElectronWshClient).then((workspaceList) => { - if (workspaceList && workspaceNum < workspaceList.length) { - const workspace = workspaceList[workspaceNum]; - if (waveWindow) { - waveWindow.switchWorkspace(workspace.workspacedata.oid); - } - } - }); - return true; - } - } - - if (checkKeyPressed(waveEvent, "Alt:Shift:i")) { - tabView.webContents.toggleDevTools(); - return true; - } - - return false; -} - -function computeBgColor(fullConfig: FullConfigType): string { - const settings = fullConfig?.settings; - const isTransparent = settings?.["window:transparent"] ?? false; - const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); - if (isTransparent) { - return "#00000000"; - } else if (isBlur) { - return "#00000000"; - } else { - return "#222222"; - } -} - -const wcIdToWaveTabMap = new Map<number, WaveTabView>(); - -export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabView { - if (webContentsId == null) { - return null; - } - return wcIdToWaveTabMap.get(webContentsId); -} - -export class WaveTabView extends WebContentsView { - waveWindowId: string; // this will be set for any tabviews that are initialized. (unset for the hot spare) - isActiveTab: boolean; - isWaveAIOpen: boolean; - private _waveTabId: string; // always set, WaveTabViews are unique per tab - lastUsedTs: number; // ts milliseconds - createdTs: number; // ts milliseconds - initPromise: Promise<void>; - initResolve: () => void; - savedInitOpts: WaveInitOpts; - waveReadyPromise: Promise<void>; - waveReadyResolve: () => void; - isInitialized: boolean = false; - isWaveReady: boolean = false; - isDestroyed: boolean = false; - keyboardChordMode: boolean = false; - resetChordModeTimeout: NodeJS.Timeout = null; - - constructor(fullConfig: FullConfigType) { - console.log("createBareTabView"); - super({ - webPreferences: { - preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), - webviewTag: true, - }, - }); - this.createdTs = Date.now(); - this.isWaveAIOpen = false; - this.savedInitOpts = null; - this.initPromise = new Promise((resolve, _) => { - this.initResolve = resolve; - }); - this.initPromise.then(() => { - this.isInitialized = true; - console.log("tabview init", Date.now() - this.createdTs + "ms"); - }); - this.waveReadyPromise = new Promise((resolve, _) => { - this.waveReadyResolve = resolve; - }); - this.waveReadyPromise.then(() => { - this.isWaveReady = true; - }); - const wcId = this.webContents.id; - wcIdToWaveTabMap.set(wcId, this); - if (isDevVite) { - this.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html`); - } else { - this.webContents.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); - } - this.webContents.on("destroyed", () => { - wcIdToWaveTabMap.delete(wcId); - removeWaveTabView(this.waveTabId); - this.isDestroyed = true; - }); - this.setBackgroundColor(computeBgColor(fullConfig)); - } - - get waveTabId(): string { - return this._waveTabId; - } - - set waveTabId(waveTabId: string) { - this._waveTabId = waveTabId; - } - - setKeyboardChordMode(mode: boolean) { - this.keyboardChordMode = mode; - if (mode) { - if (this.resetChordModeTimeout) { - clearTimeout(this.resetChordModeTimeout); - } - this.resetChordModeTimeout = setTimeout(() => { - this.keyboardChordMode = false; - }, CHORD_TIMEOUT); - } else { - if (this.resetChordModeTimeout) { - clearTimeout(this.resetChordModeTimeout); - this.resetChordModeTimeout = null; - } - } - } - - positionTabOnScreen(winBounds: Rectangle) { - const curBounds = this.getBounds(); - if ( - curBounds.width == winBounds.width && - curBounds.height == winBounds.height && - curBounds.x == 0 && - curBounds.y == 0 - ) { - return; - } - this.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height }); - } - - positionTabOffScreen(winBounds: Rectangle) { - this.setBounds({ - x: -15000, - y: -15000, - width: winBounds.width, - height: winBounds.height, - }); - } - - isOnScreen() { - const bounds = this.getBounds(); - return bounds.x == 0 && bounds.y == 0; - } - - destroy() { - console.log("destroy tab", this.waveTabId); - removeWaveTabView(this.waveTabId); - if (!this.isDestroyed) { - this.webContents?.close(); - } - this.isDestroyed = true; - } -} - -let MaxCacheSize = 10; -const wcvCache = new Map<string, WaveTabView>(); - -export function setMaxTabCacheSize(size: number) { - console.log("setMaxTabCacheSize", size); - MaxCacheSize = size; -} - -export function getWaveTabView(waveTabId: string): WaveTabView | undefined { - const rtn = wcvCache.get(waveTabId); - if (rtn) { - rtn.lastUsedTs = Date.now(); - } - return rtn; -} - -function tryEvictEntry(waveTabId: string): boolean { - const tabView = wcvCache.get(waveTabId); - if (!tabView) { - return false; - } - if (tabView.isActiveTab) { - return false; - } - const lastUsedDiff = Date.now() - tabView.lastUsedTs; - if (lastUsedDiff < 1000) { - return false; - } - const ww = getWaveWindowById(tabView.waveWindowId); - if (!ww) { - // this shouldn't happen, but if it does, just destroy the tabview - console.log("[error] WaveWindow not found for WaveTabView", tabView.waveTabId); - tabView.destroy(); - return true; - } else { - // will trigger a destroy on the tabview - ww.removeTabView(tabView.waveTabId, false); - return true; - } -} - -function checkAndEvictCache(): void { - if (wcvCache.size <= MaxCacheSize) { - return; - } - const sorted = Array.from(wcvCache.values()).sort((a, b) => { - // Prioritize entries which are active - if (a.isActiveTab && !b.isActiveTab) { - return -1; - } - // Otherwise, sort by lastUsedTs - return a.lastUsedTs - b.lastUsedTs; - }); - for (let i = 0; i < sorted.length - MaxCacheSize; i++) { - tryEvictEntry(sorted[i].waveTabId); - } -} - -export function clearTabCache() { - const wcVals = Array.from(wcvCache.values()); - for (let i = 0; i < wcVals.length; i++) { - const tabView = wcVals[i]; - tryEvictEntry(tabView.waveTabId); - } -} - -// returns [tabview, initialized] -export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: string): Promise<[WaveTabView, boolean]> { - let tabView = getWaveTabView(tabId); - if (tabView) { - return [tabView, true]; - } - const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); - tabView = getSpareTab(fullConfig); - tabView.waveWindowId = waveWindowId; - tabView.lastUsedTs = Date.now(); - setWaveTabView(tabId, tabView); - tabView.waveTabId = tabId; - tabView.webContents.on("will-navigate", shNavHandler); - tabView.webContents.on("will-frame-navigate", shFrameNavHandler); - tabView.webContents.on("did-attach-webview", (event, wc) => { - wc.setWindowOpenHandler((details) => { - if (wc == null || wc.isDestroyed() || tabView.webContents == null || tabView.webContents.isDestroyed()) { - return { action: "deny" }; - } - tabView.webContents.send("webview-new-window", wc.id, details); - return { action: "deny" }; - }); - }); - tabView.webContents.on("before-input-event", (e, input) => { - const waveEvent = adaptFromElectronKeyEvent(input); - // console.log("WIN bie", tabView.waveTabId.substring(0, 8), waveEvent.type, waveEvent.code); - handleCtrlShiftState(tabView.webContents, waveEvent); - setWasActive(true); - if (input.type == "keyDown" && tabView.keyboardChordMode) { - e.preventDefault(); - tabView.setKeyboardChordMode(false); - tabView.webContents.send("reinject-key", waveEvent); - return; - } - - if (unamePlatform === "win32" && input.type == "keyDown") { - if (handleWindowsMenuAccelerators(waveEvent, tabView, fullConfig)) { - e.preventDefault(); - return; - } - } - }); - tabView.webContents.setWindowOpenHandler(({ url, frameName }) => { - if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { - console.log("openExternal fallback", url); - shell.openExternal(url); - } - console.log("window-open denied", url); - return { action: "deny" }; - }); - tabView.webContents.on("blur", () => { - handleCtrlShiftFocus(tabView.webContents, false); - }); - configureAuthKeyRequestInjection(tabView.webContents.session); - return [tabView, false]; -} - -export function setWaveTabView(waveTabId: string, wcv: WaveTabView): void { - if (waveTabId == null) { - return; - } - wcvCache.set(waveTabId, wcv); - checkAndEvictCache(); -} - -function removeWaveTabView(waveTabId: string): void { - if (waveTabId == null) { - return; - } - wcvCache.delete(waveTabId); -} - -let HotSpareTab: WaveTabView = null; - -export function ensureHotSpareTab(fullConfig: FullConfigType) { - console.log("ensureHotSpareTab"); - if (HotSpareTab == null) { - HotSpareTab = new WaveTabView(fullConfig); - } -} - -export function getSpareTab(fullConfig: FullConfigType): WaveTabView { - setTimeout(() => ensureHotSpareTab(fullConfig), 500); - if (HotSpareTab != null) { - const rtn = HotSpareTab; - HotSpareTab = null; - console.log("getSpareTab: returning hotspare"); - return rtn; - } else { - console.log("getSpareTab: creating new tab"); - return new WaveTabView(fullConfig); - } -} diff --git a/emain/emain-util.ts b/emain/emain-util.ts deleted file mode 100644 index 88933ca8f2..0000000000 --- a/emain/emain-util.ts +++ /dev/null @@ -1,352 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import * as electron from "electron"; -import { getWebServerEndpoint } from "../frontend/util/endpoints"; - -export const WaveAppPathVarName = "WAVETERM_APP_PATH"; -export const WaveAppResourcesPathVarName = "WAVETERM_RESOURCES_PATH"; -export const WaveAppElectronExecPath = "WAVETERM_ELECTRONEXECPATH"; - -const MinZoomLevel = 0.4; -const MaxZoomLevel = 2.6; -const ZoomDelta = 0.2; - -// Note: Chromium automatically syncs zoom factor across all WebContents -// sharing the same origin/session, so we only need to notify renderers -// to update their CSS/state — not call setZoomFactor on each one. -// We broadcast to all WebContents (including devtools, webviews, etc.) but -// that is safe because "zoom-factor-change" is a custom app-defined event -// that only our renderers listen to; unrecognized IPC messages are ignored. -function broadcastZoomFactorChanged(newZoomFactor: number): void { - for (const wc of electron.webContents.getAllWebContents()) { - if (wc.isDestroyed()) { - continue; - } - wc.send("zoom-factor-change", newZoomFactor); - } -} - -export function increaseZoomLevel(webContents: electron.WebContents): void { - const newZoom = Math.min(MaxZoomLevel, webContents.getZoomFactor() + ZoomDelta); - webContents.setZoomFactor(newZoom); - broadcastZoomFactorChanged(newZoom); -} - -export function decreaseZoomLevel(webContents: electron.WebContents): void { - const newZoom = Math.max(MinZoomLevel, webContents.getZoomFactor() - ZoomDelta); - webContents.setZoomFactor(newZoom); - broadcastZoomFactorChanged(newZoom); -} - -export function resetZoomLevel(webContents: electron.WebContents): void { - webContents.setZoomFactor(1); - broadcastZoomFactorChanged(1); -} - -export function getElectronExecPath(): string { - return process.execPath; -} - -// not necessarily exact, but we use this to help get us unstuck in certain cases -let lastCtrlShiftSate: boolean = false; - -export function delay(ms): Promise<void> { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function setCtrlShift(wc: Electron.WebContents, state: boolean) { - lastCtrlShiftSate = state; - wc.send("control-shift-state-update", state); -} - -export function handleCtrlShiftFocus(sender: Electron.WebContents, focused: boolean) { - if (!focused) { - setCtrlShift(sender, false); - } -} - -export function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: WaveKeyboardEvent) { - if (waveEvent.type == "keyup") { - if (waveEvent.key === "Control" || waveEvent.key === "Shift") { - setCtrlShift(sender, false); - } - if (waveEvent.key == "Meta") { - if (waveEvent.control && waveEvent.shift) { - setCtrlShift(sender, true); - } - } - if (lastCtrlShiftSate) { - if (!waveEvent.control || !waveEvent.shift) { - setCtrlShift(sender, false); - } - } - return; - } - if (waveEvent.type == "keydown") { - if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") { - if (waveEvent.control && waveEvent.shift && !waveEvent.meta) { - // Set the control and shift without the Meta key - setCtrlShift(sender, true); - } else { - // Unset if Meta is pressed - setCtrlShift(sender, false); - } - } - return; - } -} - -export function shNavHandler(event: Electron.Event<Electron.WebContentsWillNavigateEventParams>, url: string) { - const isDev = !electron.app.isPackaged; - if ( - isDev && - (url.startsWith("http://127.0.0.1:5173/index.html") || - url.startsWith("http://localhost:5173/index.html") || - url.startsWith("http://127.0.0.1:5174/index.html") || - url.startsWith("http://localhost:5174/index.html")) - ) { - // this is a dev-mode hot-reload, ignore it - console.log("allowing hot-reload of index.html"); - return; - } - event.preventDefault(); - if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) { - console.log("open external, shNav", url); - electron.shell.openExternal(url); - } else { - console.log("navigation canceled", url); - } -} - -function frameOrAncestorHasName(frame: Electron.WebFrameMain, name: string): boolean { - let cur: Electron.WebFrameMain = frame; - while (cur != null) { - if (cur.name === name) { - return true; - } - cur = cur.parent; - } - return false; -} - -export function shFrameNavHandler(event: Electron.Event<Electron.WebContentsWillFrameNavigateEventParams>) { - if (!event.frame?.parent) { - // only use this handler to process iframe events (non-iframe events go to shNavHandler) - return; - } - const url = event.url; - console.log(`frame-navigation url=${url} frame=${event.frame.name}`); - if (event.frame.name == "webview") { - // "webview" links always open in new window - // this will *not* effect the initial load because srcdoc does not count as an electron navigation - console.log("open external, frameNav", url); - event.preventDefault(); - electron.shell.openExternal(url); - return; - } - if ( - frameOrAncestorHasName(event.frame, "pdfview") && - (url.startsWith("blob:file:///") || - url.startsWith("chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/") || - url.startsWith(getWebServerEndpoint() + "/wave/stream-file?") || - url.startsWith(getWebServerEndpoint() + "/wave/stream-file/") || - url.startsWith(getWebServerEndpoint() + "/wave/stream-local-file?")) - ) { - // allowed - return; - } - if (event.frame.name != null && event.frame.name.startsWith("tsunami:")) { - // Parse port from frame name: tsunami:[port]:[blockid] - const nameParts = event.frame.name.split(":"); - const expectedPort = nameParts.length >= 2 ? nameParts[1] : null; - - try { - const tsunamiUrl = new URL(url); - if ( - tsunamiUrl.protocol === "http:" && - tsunamiUrl.hostname === "localhost" && - expectedPort && - tsunamiUrl.port === expectedPort - ) { - // allowed - return; - } - // If navigation is not to expected port, open externally - event.preventDefault(); - electron.shell.openExternal(url); - return; - } catch (e) { - // Invalid URL, fall through to prevent navigation - } - } - event.preventDefault(); - console.log("frame navigation canceled", event.frame.name, url); -} - -function isWindowFullyVisible(bounds: electron.Rectangle): boolean { - const displays = electron.screen.getAllDisplays(); - - // Helper function to check if a point is inside any display - function isPointInDisplay(x: number, y: number) { - for (const display of displays) { - const { x: dx, y: dy, width, height } = display.bounds; - if (x >= dx && x < dx + width && y >= dy && y < dy + height) { - return true; - } - } - return false; - } - - // Check all corners of the window - const topLeft = isPointInDisplay(bounds.x, bounds.y); - const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y); - const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height); - const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height); - - return topLeft && topRight && bottomLeft && bottomRight; -} - -function findDisplayWithMostArea(bounds: electron.Rectangle): electron.Display { - const displays = electron.screen.getAllDisplays(); - let maxArea = 0; - let bestDisplay = null; - - for (let display of displays) { - const { x, y, width, height } = display.bounds; - const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x)); - const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y)); - const overlapArea = overlapX * overlapY; - - if (overlapArea > maxArea) { - maxArea = overlapArea; - bestDisplay = display; - } - } - - return bestDisplay; -} - -function adjustBoundsToFitDisplay(bounds: electron.Rectangle, display: electron.Display): electron.Rectangle { - const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea; - let { x, y, width, height } = bounds; - - // Adjust width and height to fit within the display's work area - width = Math.min(width, dWidth); - height = Math.min(height, dHeight); - - // Adjust x to ensure the window fits within the display - if (x < dx) { - x = dx; - } else if (x + width > dx + dWidth) { - x = dx + dWidth - width; - } - - // Adjust y to ensure the window fits within the display - if (y < dy) { - y = dy; - } else if (y + height > dy + dHeight) { - y = dy + dHeight - height; - } - return { x, y, width, height }; -} - -export function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle { - if (!isWindowFullyVisible(bounds)) { - let targetDisplay = findDisplayWithMostArea(bounds); - - if (!targetDisplay) { - targetDisplay = electron.screen.getPrimaryDisplay(); - } - - return adjustBoundsToFitDisplay(bounds, targetDisplay); - } - return bounds; -} - -export function waveKeyToElectronKey(waveKey: string): string { - const waveParts = waveKey.split(":"); - const electronParts: Array<string> = waveParts.map((part: string) => { - const digitRegexpMatch = new RegExp("^c{Digit([0-9])}$").exec(part); - const numpadRegexpMatch = new RegExp("^c{Numpad([0-9])}$").exec(part); - const lowercaseCharMatch = new RegExp("^([a-z])$").exec(part); - if (part == "ArrowUp") { - return "Up"; - } - if (part == "ArrowDown") { - return "Down"; - } - if (part == "ArrowLeft") { - return "Left"; - } - if (part == "ArrowRight") { - return "Right"; - } - if (part == "Soft1") { - return "F21"; - } - if (part == "Soft2") { - return "F22"; - } - if (part == "Soft3") { - return "F23"; - } - if (part == "Soft4") { - return "F24"; - } - if (part == " ") { - return "Space"; - } - if (part == "CapsLock") { - return "Capslock"; - } - if (part == "NumLock") { - return "Numlock"; - } - if (part == "ScrollLock") { - return "Scrolllock"; - } - if (part == "AudioVolumeUp") { - return "VolumeUp"; - } - if (part == "AudioVolumeDown") { - return "VolumeDown"; - } - if (part == "AudioVolumeMute") { - return "VolumeMute"; - } - if (part == "MediaTrackNext") { - return "MediaNextTrack"; - } - if (part == "MediaTrackPrevious") { - return "MediaPreviousTrack"; - } - if (part == "Decimal") { - return "numdec"; - } - if (part == "Add") { - return "numadd"; - } - if (part == "Subtract") { - return "numsub"; - } - if (part == "Multiply") { - return "nummult"; - } - if (part == "Divide") { - return "numdiv"; - } - if (digitRegexpMatch && digitRegexpMatch.length > 1) { - return digitRegexpMatch[1]; - } - if (numpadRegexpMatch && numpadRegexpMatch.length > 1) { - return `num${numpadRegexpMatch[1]}`; - } - if (lowercaseCharMatch && lowercaseCharMatch.length > 1) { - return lowercaseCharMatch[1].toUpperCase(); - } - - return part; - }); - return electronParts.join("+"); -} diff --git a/emain/emain-wavesrv.ts b/emain/emain-wavesrv.ts deleted file mode 100644 index f58d214a7e..0000000000 --- a/emain/emain-wavesrv.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import * as electron from "electron"; -import * as child_process from "node:child_process"; -import * as readline from "readline"; -import { WebServerEndpointVarName, WSServerEndpointVarName } from "../frontend/util/endpoints"; -import { AuthKey, WaveAuthKeyEnv } from "./authkey"; -import { setForceQuit, setUserConfirmedQuit } from "./emain-activity"; -import { - getElectronAppResourcesPath, - getElectronAppUnpackedBasePath, - getWaveConfigDir, - getWaveDataDir, - getWaveSrvCwd, - getWaveSrvPath, - getXdgCurrentDesktop, - WaveConfigHomeVarName, - WaveDataHomeVarName, -} from "./emain-platform"; -import { - getElectronExecPath, - WaveAppElectronExecPath, - WaveAppPathVarName, - WaveAppResourcesPathVarName, -} from "./emain-util"; -import { updater } from "./updater"; - -let isWaveSrvDead = false; -let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null; -let WaveVersion = "unknown"; // set by WAVESRV-ESTART -let WaveBuildTime = 0; // set by WAVESRV-ESTART - -export function getWaveVersion(): { version: string; buildTime: number } { - return { version: WaveVersion, buildTime: WaveBuildTime }; -} - -let waveSrvReadyResolve = (value: boolean) => {}; -const waveSrvReady: Promise<boolean> = new Promise((resolve, _) => { - waveSrvReadyResolve = resolve; -}); - -export function getWaveSrvReady(): Promise<boolean> { - return waveSrvReady; -} - -export function getWaveSrvProc(): child_process.ChildProcessWithoutNullStreams | null { - return waveSrvProc; -} - -export function getIsWaveSrvDead(): boolean { - return isWaveSrvDead; -} - -export function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promise<boolean> { - let pResolve: (value: boolean) => void; - let pReject: (reason?: any) => void; - const rtnPromise = new Promise<boolean>((argResolve, argReject) => { - pResolve = argResolve; - pReject = argReject; - }); - const envCopy = { ...process.env }; - const xdgCurrentDesktop = getXdgCurrentDesktop(); - if (xdgCurrentDesktop != null) { - envCopy["XDG_CURRENT_DESKTOP"] = xdgCurrentDesktop; - } - envCopy[WaveAppPathVarName] = getElectronAppUnpackedBasePath(); - envCopy[WaveAppResourcesPathVarName] = getElectronAppResourcesPath(); - envCopy[WaveAppElectronExecPath] = getElectronExecPath(); - envCopy[WaveAuthKeyEnv] = AuthKey; - envCopy[WaveDataHomeVarName] = getWaveDataDir(); - envCopy[WaveConfigHomeVarName] = getWaveConfigDir(); - const waveSrvCmd = getWaveSrvPath(); - console.log("trying to run local server", waveSrvCmd); - const proc = child_process.spawn(getWaveSrvPath(), { - cwd: getWaveSrvCwd(), - env: envCopy, - }); - proc.on("exit", (e) => { - if (updater?.status == "installing") { - return; - } - console.log("wavesrv exited, shutting down"); - setForceQuit(true); - isWaveSrvDead = true; - electron.app.quit(); - }); - proc.on("spawn", (e) => { - console.log("spawned wavesrv"); - waveSrvProc = proc; - pResolve(true); - }); - proc.on("error", (e) => { - console.log("error running wavesrv", e); - pReject(e); - }); - const rlStdout = readline.createInterface({ - input: proc.stdout, - terminal: false, - }); - rlStdout.on("line", (line) => { - console.log(line); - }); - const rlStderr = readline.createInterface({ - input: proc.stderr, - terminal: false, - }); - rlStderr.on("line", (line) => { - if (line.includes("WAVESRV-ESTART")) { - const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.-]+) buildtime:(\d+)/gm.exec( - line - ); - if (startParams == null) { - console.log("error parsing WAVESRV-ESTART line", line); - setUserConfirmedQuit(true); - electron.app.quit(); - return; - } - process.env[WSServerEndpointVarName] = startParams[1]; - process.env[WebServerEndpointVarName] = startParams[2]; - WaveVersion = startParams[3]; - WaveBuildTime = parseInt(startParams[4]); - waveSrvReadyResolve(true); - return; - } - if (line.startsWith("WAVESRV-EVENT:")) { - const evtJson = line.slice("WAVESRV-EVENT:".length); - try { - const evtMsg: WSEventType = JSON.parse(evtJson); - handleWSEvent(evtMsg); - } catch (e) { - console.log("error handling WAVESRV-EVENT", e); - } - return; - } - console.log(line); - }); - return rtnPromise; -} diff --git a/emain/emain-web.ts b/emain/emain-web.ts deleted file mode 100644 index fa0f419cb1..0000000000 --- a/emain/emain-web.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { ipcMain, webContents, WebContents } from "electron"; -import { WaveBrowserWindow } from "./emain-window"; - -export function getWebContentsByBlockId(ww: WaveBrowserWindow, tabId: string, blockId: string): Promise<WebContents> { - const prtn = new Promise<WebContents>((resolve, reject) => { - const randId = Math.floor(Math.random() * 1000000000).toString(); - const respCh = `getWebContentsByBlockId-${randId}`; - ww?.activeTabView?.webContents.send("webcontentsid-from-blockid", blockId, respCh); - ipcMain.once(respCh, (event, webContentsId) => { - if (webContentsId == null) { - resolve(null); - return; - } - const wc = webContents.fromId(parseInt(webContentsId)); - resolve(wc); - }); - setTimeout(() => { - reject(new Error("timeout waiting for response")); - }, 2000); - }); - return prtn; -} - -function escapeSelector(selector: string): string { - return selector - .replace(/\\/g, "\\\\") - .replace(/"/g, '\\"') - .replace(/'/g, "\\'") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/\t/g, "\\t"); -} - -export type WebGetOpts = { - all?: boolean; - inner?: boolean; -}; - -export async function webGetSelector(wc: WebContents, selector: string, opts?: WebGetOpts): Promise<string[]> { - if (!wc || !selector) { - return null; - } - const escapedSelector = escapeSelector(selector); - const queryMethod = opts?.all ? "querySelectorAll" : "querySelector"; - const prop = opts?.inner ? "innerHTML" : "outerHTML"; - const execExpr = ` - (() => { - const toArr = x => (x instanceof NodeList) ? Array.from(x) : (x ? [x] : []); - try { - const result = document.${queryMethod}("${escapedSelector}"); - const value = toArr(result).map(el => el.${prop}); - return { value }; - } catch (error) { - return { error: error.message }; - } - })()`; - const results = await wc.executeJavaScript(execExpr); - if (results.error) { - throw new Error(results.error); - } - return results.value; -} diff --git a/emain/emain-window.ts b/emain/emain-window.ts deleted file mode 100644 index e3bfa87751..0000000000 --- a/emain/emain-window.ts +++ /dev/null @@ -1,1119 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { ClientService, ObjectService, WindowService, WorkspaceService } from "@/app/store/services"; -import { waveEventSubscribeSingle } from "@/app/store/wps"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { fireAndForget } from "@/util/util"; -import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen, webContents } from "electron"; -import { globalEvents } from "emain/emain-events"; -import path from "path"; -import { debounce } from "throttle-debounce"; -import { - getGlobalIsQuitting, - getGlobalIsRelaunching, - setGlobalIsRelaunching, - setWasActive, - setWasInFg, -} from "./emain-activity"; -import { log } from "./emain-log"; -import { getElectronAppBasePath, isDev, unamePlatform } from "./emain-platform"; -import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview"; -import { delay, ensureBoundsAreVisible, waveKeyToElectronKey } from "./emain-util"; -import { ElectronWshClient } from "./emain-wsh"; -import { updater } from "./updater"; - -const DevInitTimeoutMs = 5000; - -export type WindowOpts = { - unamePlatform: NodeJS.Platform; - isPrimaryStartupWindow?: boolean; - foregroundWindow?: boolean; -}; - -export const MinWindowWidth = 800; -export const MinWindowHeight = 500; - -export function calculateWindowBounds( - winSize?: { width?: number; height?: number }, - pos?: { x?: number; y?: number }, - settings?: any -): { x: number; y: number; width: number; height: number } { - let winWidth = winSize?.width; - let winHeight = winSize?.height; - const winPosX = pos?.x ?? 100; - const winPosY = pos?.y ?? 100; - - if ( - (winWidth == null || winWidth === 0 || winHeight == null || winHeight === 0) && - settings?.["window:dimensions"] - ) { - const dimensions = settings["window:dimensions"]; - const match = dimensions.match(/^(\d+)[xX](\d+)$/); - - if (match) { - const [, dimensionWidth, dimensionHeight] = match; - const parsedWidth = parseInt(dimensionWidth, 10); - const parsedHeight = parseInt(dimensionHeight, 10); - - if ((!winWidth || winWidth === 0) && Number.isFinite(parsedWidth) && parsedWidth > 0) { - winWidth = parsedWidth; - } - if ((!winHeight || winHeight === 0) && Number.isFinite(parsedHeight) && parsedHeight > 0) { - winHeight = parsedHeight; - } - } else { - console.warn('Invalid window:dimensions format. Expected "widthxheight".'); - } - } - - if (winWidth == null || winWidth == 0) { - const primaryDisplay = screen.getPrimaryDisplay(); - const { width } = primaryDisplay.workAreaSize; - winWidth = width - winPosX - 100; - if (winWidth > 2000) { - winWidth = 2000; - } - } - if (winHeight == null || winHeight == 0) { - const primaryDisplay = screen.getPrimaryDisplay(); - const { height } = primaryDisplay.workAreaSize; - winHeight = height - winPosY - 100; - if (winHeight > 1200) { - winHeight = 1200; - } - } - - winWidth = Math.max(winWidth, MinWindowWidth); - winHeight = Math.max(winHeight, MinWindowHeight); - - const winBounds = { - x: winPosX, - y: winPosY, - width: winWidth, - height: winHeight, - }; - return ensureBoundsAreVisible(winBounds); -} - -export const waveWindowMap = new Map<string, WaveBrowserWindow>(); // waveWindowId -> WaveBrowserWindow - -// on blur we do not set this to null (but on destroy we do), so this tracks the *last* focused window -// e.g. it persists when the app itself is not focused -export let focusedWaveWindow: WaveBrowserWindow = null; - -// quake window for toggle hotkey (show/hide behavior) -let quakeWindow: WaveBrowserWindow | null = null; - -export function getQuakeWindow(): WaveBrowserWindow | null { - return quakeWindow; -} - -let cachedClientId: string = null; -let hasCompletedFirstRelaunch = false; - -async function getClientId() { - if (cachedClientId != null) { - return cachedClientId; - } - const clientData = await ClientService.GetClientData(); - cachedClientId = clientData?.oid; - return cachedClientId; -} - -type WindowActionQueueEntry = - | { - op: "switchtab"; - tabId: string; - setInBackend: boolean; - primaryStartupTab?: boolean; - } - | { - op: "createtab"; - } - | { - op: "closetab"; - tabId: string; - } - | { - op: "switchworkspace"; - workspaceId: string; - }; - -function isNonEmptyUnsavedWorkspace(workspace: Workspace): boolean { - return !workspace.name && !workspace.icon && workspace.tabids?.length > 1; -} - -export class WaveBrowserWindow extends BaseWindow { - waveWindowId: string; - workspaceId: string; - allLoadedTabViews: Map<string, WaveTabView>; - activeTabView: WaveTabView; - private canClose: boolean; - private deleteAllowed: boolean; - private actionQueue: WindowActionQueueEntry[]; - - constructor(waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts) { - const settings = fullConfig?.settings; - - console.log("create win", waveWindow.oid); - const winBounds = calculateWindowBounds(waveWindow.winsize, waveWindow.pos, settings); - const winOpts: BaseWindowConstructorOptions = { - x: winBounds.x, - y: winBounds.y, - width: winBounds.width, - height: winBounds.height, - minWidth: MinWindowWidth, - minHeight: MinWindowHeight, - show: false, - }; - - const isTransparent = settings?.["window:transparent"] ?? false; - const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); - - if (opts.unamePlatform === "darwin") { - winOpts.titleBarStyle = "hiddenInset"; - winOpts.titleBarOverlay = false; - winOpts.autoHideMenuBar = !settings?.["window:showmenubar"]; - winOpts.acceptFirstMouse = true; - if (isTransparent) { - winOpts.transparent = true; - } else if (isBlur) { - winOpts.vibrancy = "fullscreen-ui"; - } else { - winOpts.backgroundColor = "#222222"; - } - } else if (opts.unamePlatform === "linux") { - winOpts.titleBarStyle = settings["window:nativetitlebar"] ? "default" : "hidden"; - winOpts.titleBarOverlay = { - symbolColor: "white", - color: "#00000000", - }; - winOpts.icon = path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png"); - winOpts.autoHideMenuBar = !settings?.["window:showmenubar"]; - if (isTransparent) { - winOpts.transparent = true; - } else { - winOpts.backgroundColor = "#222222"; - } - } else if (opts.unamePlatform === "win32") { - winOpts.titleBarStyle = "hidden"; - winOpts.titleBarOverlay = { - color: "#222222", - symbolColor: "#c3c8c2", - height: 32, - }; - if (isTransparent) { - winOpts.transparent = true; - } else if (isBlur) { - winOpts.backgroundMaterial = "acrylic"; - } else { - winOpts.backgroundColor = "#222222"; - } - } - - super(winOpts); - - if (opts.unamePlatform === "win32") { - this.setMenu(null); - } - - const fullscreenOnLaunch = fullConfig?.settings["window:fullscreenonlaunch"]; - if (fullscreenOnLaunch && opts.foregroundWindow) { - this.once("show", () => { - this.setFullScreen(true); - }); - } - this.actionQueue = []; - this.waveWindowId = waveWindow.oid; - this.workspaceId = waveWindow.workspaceid; - this.allLoadedTabViews = new Map<string, WaveTabView>(); - const winBoundsPoller = setInterval(() => { - if (this.isDestroyed()) { - clearInterval(winBoundsPoller); - return; - } - if (this.actionQueue.length > 0) { - return; - } - this.finalizePositioning(); - }, 1000); - this.on( - // @ts-expect-error -- "resize" event with debounce handler not in Electron type definitions - "resize", - debounce(400, (e) => this.mainResizeHandler(e)) - ); - this.on("resize", () => { - if (this.isDestroyed()) { - return; - } - this.activeTabView?.positionTabOnScreen(this.getContentBounds()); - }); - this.on( - // @ts-expect-error -- "move" event with debounce handler not in Electron type definitions - "move", - debounce(400, (e) => this.mainResizeHandler(e)) - ); - this.on("enter-full-screen", async () => { - if (this.isDestroyed()) { - return; - } - console.log("enter-full-screen event", this.getContentBounds()); - const tabView = this.activeTabView; - if (tabView) { - tabView.webContents.send("fullscreen-change", true); - } - this.activeTabView?.positionTabOnScreen(this.getContentBounds()); - }); - this.on("leave-full-screen", async () => { - if (this.isDestroyed()) { - return; - } - const tabView = this.activeTabView; - if (tabView) { - tabView.webContents.send("fullscreen-change", false); - } - this.activeTabView?.positionTabOnScreen(this.getContentBounds()); - }); - this.on("focus", () => { - if (this.isDestroyed()) { - return; - } - if (getGlobalIsRelaunching()) { - return; - } - focusedWaveWindow = this; // eslint-disable-line @typescript-eslint/no-this-alias - console.log("focus win", this.waveWindowId); - fireAndForget(() => ClientService.FocusWindow(this.waveWindowId)); - setWasInFg(true); - setWasActive(true); - setTimeout(() => globalEvents.emit("windows-updated"), 50); - }); - this.on("blur", () => { - setTimeout(() => globalEvents.emit("windows-updated"), 50); - }); - this.on("close", (e) => { - if (this.canClose) { - return; - } - if (this.isDestroyed()) { - return; - } - this.closeAllDevTools(); - console.log("win 'close' handler fired", this.waveWindowId); - if (getGlobalIsQuitting() || updater?.status == "installing" || getGlobalIsRelaunching()) { - return; - } - e.preventDefault(); - fireAndForget(async () => { - const numWindows = waveWindowMap.size; - const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); - if (numWindows > 1 || !fullConfig.settings["window:savelastwindow"]) { - if (fullConfig.settings["window:confirmclose"]) { - const workspace = await WorkspaceService.GetWorkspace(this.workspaceId); - if (isNonEmptyUnsavedWorkspace(workspace)) { - const choice = dialog.showMessageBoxSync(this, { - type: "question", - buttons: ["Cancel", "Close Window"], - title: "Confirm", - message: - "Window has unsaved tabs, closing window will delete existing tabs.\n\nContinue?", - }); - if (choice === 0) { - return; - } - } - } - this.deleteAllowed = true; - } - this.canClose = true; - this.close(); - }); - }); - this.on("closed", () => { - console.log("win 'closed' handler fired", this.waveWindowId); - if (getGlobalIsQuitting() || updater?.status == "installing") { - console.log("win quitting or updating", this.waveWindowId); - return; - } - setTimeout(() => globalEvents.emit("windows-updated"), 50); - waveWindowMap.delete(this.waveWindowId); - if (focusedWaveWindow == this) { - focusedWaveWindow = null; - } - if (quakeWindow == this) { - quakeWindow = null; - } - this.removeAllChildViews(); - if (getGlobalIsRelaunching()) { - console.log("win relaunching", this.waveWindowId); - this.destroy(); - return; - } - if (this.deleteAllowed) { - console.log("win removing window from backend DB", this.waveWindowId); - fireAndForget(() => WindowService.CloseWindow(this.waveWindowId, true)); - } - }); - waveWindowMap.set(waveWindow.oid, this); - setTimeout(() => globalEvents.emit("windows-updated"), 50); - } - - private closeAllDevTools() { - for (const tabView of this.allLoadedTabViews.values()) { - if (tabView.webContents?.isDevToolsOpened()) { - tabView.webContents.closeDevTools(); - } - } - const tabViewIds = new Set( - [...this.allLoadedTabViews.values()].map((tv) => tv.webContents?.id).filter((id) => id != null) - ); - for (const wc of webContents.getAllWebContents()) { - if (wc.getType() === "webview" && tabViewIds.has(wc.hostWebContents?.id)) { - if (wc.isDevToolsOpened()) { - wc.closeDevTools(); - } - } - } - } - - private removeAllChildViews() { - for (const tabView of this.allLoadedTabViews.values()) { - if (!this.isDestroyed()) { - this.contentView.removeChildView(tabView); - } - tabView?.destroy(); - } - } - - async switchWorkspace(workspaceId: string) { - console.log("switchWorkspace", workspaceId, this.waveWindowId); - if (workspaceId == this.workspaceId) { - console.log("switchWorkspace already on this workspace", this.waveWindowId); - return; - } - - // If the workspace is already owned by a window, then we can just call SwitchWorkspace without first prompting the user, since it'll just focus to the other window. - const workspaceList = await WorkspaceService.ListWorkspaces(); - if (!workspaceList?.find((wse) => wse.workspaceid === workspaceId)?.windowid) { - const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId); - - if (curWorkspace && isNonEmptyUnsavedWorkspace(curWorkspace)) { - console.log( - `existing unsaved workspace ${this.workspaceId} has content, opening workspace ${workspaceId} in new window` - ); - await createWindowForWorkspace(workspaceId); - return; - } - } - await this._queueActionInternal({ op: "switchworkspace", workspaceId }); - } - - async setActiveTab(tabId: string, setInBackend: boolean, primaryStartupTab = false) { - console.log( - "setActiveTab", - tabId, - this.waveWindowId, - this.workspaceId, - setInBackend, - primaryStartupTab ? "(primary startup)" : "" - ); - await this._queueActionInternal({ op: "switchtab", tabId, setInBackend, primaryStartupTab }); - } - - private async initializeTab(tabView: WaveTabView, primaryStartupTab: boolean) { - const clientId = await getClientId(); - await this.awaitWithDevTimeout(tabView.initPromise, "initPromise", tabView.waveTabId); - const winBounds = this.getContentBounds(); - tabView.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height }); - this.contentView.addChildView(tabView); - const initOpts: WaveInitOpts = { - tabId: tabView.waveTabId, - clientId: clientId, - windowId: this.waveWindowId, - activate: true, - }; - if (primaryStartupTab) { - initOpts.primaryTabStartup = true; - } - tabView.savedInitOpts = { ...initOpts }; - tabView.savedInitOpts.activate = false; - delete tabView.savedInitOpts.primaryTabStartup; - const startTime = Date.now(); - console.log( - "before wave ready, init tab, sending wave-init", - tabView.waveTabId, - primaryStartupTab ? "(primary startup)" : "" - ); - tabView.webContents.send("wave-init", initOpts); - await this.awaitWithDevTimeout(tabView.waveReadyPromise, "waveReadyPromise", tabView.waveTabId); - console.log("wave-ready init time", Date.now() - startTime + "ms"); - } - - private async awaitWithDevTimeout<T>(promise: Promise<T>, name: string, tabId: string): Promise<T> { - if (!isDev) { - return promise; - } - let timeoutHandle: ReturnType<typeof setTimeout> = null; - const timeoutPromise = new Promise<never>((_, reject) => { - timeoutHandle = setTimeout(() => { - console.log( - `[dev] ${name} timed out after ${DevInitTimeoutMs}ms for tab ${tabId}, showing window for devtools` - ); - if (!this.isDestroyed() && !this.isVisible()) { - this.show(); - } - if (this.activeTabView?.webContents && !this.activeTabView.webContents.isDevToolsOpened()) { - this.activeTabView.webContents.openDevTools(); - } - reject(new Error(`[dev] ${name} timed out after ${DevInitTimeoutMs}ms`)); - }, DevInitTimeoutMs); - }); - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - clearTimeout(timeoutHandle); - } - } - - private async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean, primaryStartupTab = false) { - if (this.activeTabView == tabView) { - return; - } - const oldActiveView = this.activeTabView; - tabView.isActiveTab = true; - if (oldActiveView != null) { - oldActiveView.isActiveTab = false; - } - this.activeTabView = tabView; - this.allLoadedTabViews.set(tabView.waveTabId, tabView); - if (!tabInitialized) { - console.log("initializing a new tab", primaryStartupTab ? "(primary startup)" : ""); - await this.initializeTab(tabView, primaryStartupTab); - this.finalizePositioning(); - } else { - console.log("reusing an existing tab, calling wave-init", tabView.waveTabId); - tabView.webContents.send("wave-init", tabView.savedInitOpts); // reinit - this.finalizePositioning(); - } - - // something is causing the new tab to lose focus so it requires manual refocusing - tabView.webContents.focus(); - setTimeout(() => { - if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) { - tabView.webContents.focus(); - } - }, 10); - setTimeout(() => { - if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) { - tabView.webContents.focus(); - } - }, 30); - } - - private finalizePositioning() { - if (this.isDestroyed()) { - return; - } - const curBounds = this.getContentBounds(); - this.activeTabView?.positionTabOnScreen(curBounds); - for (const tabView of this.allLoadedTabViews.values()) { - if (tabView == this.activeTabView) { - continue; - } - tabView?.positionTabOffScreen(curBounds); - } - } - - async queueCreateTab() { - await this._queueActionInternal({ op: "createtab" }); - } - - async queueCloseTab(tabId: string) { - await this._queueActionInternal({ op: "closetab", tabId }); - } - - private async _queueActionInternal(entry: WindowActionQueueEntry) { - if (this.actionQueue.length >= 2) { - this.actionQueue[1] = entry; - return; - } - const wasEmpty = this.actionQueue.length === 0; - this.actionQueue.push(entry); - if (wasEmpty) { - await this.processActionQueue(); - } - } - - private removeTabViewLater(tabId: string, delayMs: number) { - setTimeout(() => { - this.removeTabView(tabId, false); - }, delayMs); - } - - // the queue and this function are used to serialize operations that update the window contents view - // processActionQueue will replace [1] if it is already set - // we don't mess with [0] because it is "in process" - // we replace [1] because there is no point to run an action that is going to be overwritten - private async processActionQueue() { - while (this.actionQueue.length > 0) { - try { - if (this.isDestroyed()) { - break; - } - const entry = this.actionQueue[0]; - let tabId: string = null; - // have to use "===" here to get the typechecker to work :/ - switch (entry.op) { - case "createtab": - tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true); - break; - case "switchtab": - tabId = entry.tabId; - if (this.activeTabView?.waveTabId == tabId) { - continue; - } - if (entry.setInBackend) { - await WorkspaceService.SetActiveTab(this.workspaceId, tabId); - } - break; - case "closetab": { - tabId = entry.tabId; - const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true); - if (rtn == null) { - console.log( - "[error] closeTab: no return value", - tabId, - this.workspaceId, - this.waveWindowId - ); - return; - } - this.removeTabViewLater(tabId, 1000); - if (rtn.closewindow) { - this.close(); - return; - } - if (!rtn.newactivetabid) { - return; - } - tabId = rtn.newactivetabid; - break; - } - case "switchworkspace": { - const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, entry.workspaceId); - if (!newWs) { - return; - } - console.log("processActionQueue switchworkspace newWs", newWs); - this.removeAllChildViews(); - console.log("destroyed all tabs", this.waveWindowId); - this.workspaceId = entry.workspaceId; - this.allLoadedTabViews = new Map(); - tabId = newWs.activetabid; - break; - } - } - if (tabId == null) { - return; - } - const [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId); - const primaryStartupTabFlag = entry.op === "switchtab" ? (entry.primaryStartupTab ?? false) : false; - await this.setTabViewIntoWindow(tabView, tabInitialized, primaryStartupTabFlag); - } catch (e) { - console.log("error caught in processActionQueue", e); - } finally { - this.actionQueue.shift(); - } - } - } - - private async mainResizeHandler(_: any) { - if (this == null || this.isDestroyed() || this.fullScreen) { - return; - } - const bounds = this.getBounds(); - try { - await WindowService.SetWindowPosAndSize( - this.waveWindowId, - { x: bounds.x, y: bounds.y }, - { width: bounds.width, height: bounds.height } - ); - } catch (e) { - console.log("error sending new window bounds to backend", e); - } - } - - removeTabView(tabId: string, force: boolean) { - if (!force && this.activeTabView?.waveTabId == tabId) { - console.log("cannot remove active tab", tabId, this.waveWindowId); - return; - } - const tabView = this.allLoadedTabViews.get(tabId); - if (tabView == null) { - console.log("removeTabView -- tabView not found", tabId, this.waveWindowId); - // the tab was never loaded, so just return - return; - } - this.contentView.removeChildView(tabView); - this.allLoadedTabViews.delete(tabId); - tabView.destroy(); - } - - destroy() { - console.log("destroy win", this.waveWindowId); - this.deleteAllowed = true; - super.destroy(); - } -} - -export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow { - for (const ww of waveWindowMap.values()) { - if (ww.allLoadedTabViews.has(tabId)) { - return ww; - } - } -} - -export function getWaveWindowByWebContentsId(webContentsId: number): WaveBrowserWindow { - if (webContentsId == null) { - return null; - } - const tabView = getWaveTabViewByWebContentsId(webContentsId); - if (tabView == null) { - return null; - } - return getWaveWindowByTabId(tabView.waveTabId); -} - -export function getWaveWindowById(windowId: string): WaveBrowserWindow { - return waveWindowMap.get(windowId); -} - -export function getWaveWindowByWorkspaceId(workspaceId: string): WaveBrowserWindow { - for (const waveWindow of waveWindowMap.values()) { - if (waveWindow.workspaceId === workspaceId) { - return waveWindow; - } - } -} - -export function getAllWaveWindows(): WaveBrowserWindow[] { - return Array.from(waveWindowMap.values()); -} - -export async function createWindowForWorkspace(workspaceId: string) { - const newWin = await WindowService.CreateWindow(null, workspaceId); - if (!newWin) { - console.log("error creating new window", this.waveWindowId); - } - const newBwin = await createBrowserWindow(newWin, await RpcApi.GetFullConfigCommand(ElectronWshClient), { - unamePlatform, - isPrimaryStartupWindow: false, - }); - newBwin.show(); -} - -// note, this does not *show* the window. -// to show, await win.readyPromise and then win.show() -export async function createBrowserWindow( - waveWindow: WaveWindow, - fullConfig: FullConfigType, - opts: WindowOpts -): Promise<WaveBrowserWindow> { - if (!waveWindow) { - console.log("createBrowserWindow: no waveWindow"); - waveWindow = await WindowService.CreateWindow(null, ""); - } - let workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid); - if (!workspace) { - console.log("createBrowserWindow: no workspace, creating new window"); - await WindowService.CloseWindow(waveWindow.oid, true); - waveWindow = await WindowService.CreateWindow(null, ""); - workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid); - } - console.log("createBrowserWindow", waveWindow.oid, workspace.oid, workspace); - const bwin = new WaveBrowserWindow(waveWindow, fullConfig, opts); - - if (workspace.activetabid) { - await bwin.setActiveTab(workspace.activetabid, false, opts.isPrimaryStartupWindow ?? false); - } - return bwin; -} - -ipcMain.on("set-active-tab", async (event, tabId) => { - const ww = getWaveWindowByWebContentsId(event.sender.id); - console.log("set-active-tab", tabId, ww?.waveWindowId); - await ww?.setActiveTab(tabId, true); -}); - -ipcMain.on("create-tab", async (event, _opts) => { - const senderWc = event.sender; - const ww = getWaveWindowByWebContentsId(senderWc.id); - if (ww != null) { - await ww.queueCreateTab(); - } - event.returnValue = true; - return null; -}); - -ipcMain.on("set-waveai-open", (event, isOpen: boolean) => { - const tabView = getWaveTabViewByWebContentsId(event.sender.id); - if (tabView) { - tabView.isWaveAIOpen = isOpen; - } -}); - -ipcMain.handle("close-tab", async (event, workspaceId: string, tabId: string, confirmClose: boolean) => { - const ww = getWaveWindowByWorkspaceId(workspaceId); - if (ww == null) { - console.log(`close-tab: no window found for workspace ws=${workspaceId} tab=${tabId}`); - return false; - } - if (confirmClose) { - const choice = dialog.showMessageBoxSync(ww, { - type: "question", - defaultId: 1, // Enter activates "Close Tab" - cancelId: 0, // Esc activates "Cancel" - buttons: ["Cancel", "Close Tab"], - title: "Confirm", - message: "Are you sure you want to close this tab?", - }); - if (choice === 0) { - return false; - } - } - await ww.queueCloseTab(tabId); - return true; -}); - -ipcMain.on("switch-workspace", (event, workspaceId) => { - fireAndForget(async () => { - const ww = getWaveWindowByWebContentsId(event.sender.id); - console.log("switch-workspace", workspaceId, ww?.waveWindowId); - await ww?.switchWorkspace(workspaceId); - }); -}); - -export async function createWorkspace(window: WaveBrowserWindow) { - const newWsId = await WorkspaceService.CreateWorkspace("", "", "", true); - if (newWsId) { - if (window) { - await window.switchWorkspace(newWsId); - } else { - await createWindowForWorkspace(newWsId); - } - } -} - -ipcMain.on("create-workspace", (event) => { - fireAndForget(async () => { - const ww = getWaveWindowByWebContentsId(event.sender.id); - console.log("create-workspace", ww?.waveWindowId); - await createWorkspace(ww); - }); -}); - -ipcMain.on("delete-workspace", (event, workspaceId) => { - fireAndForget(async () => { - const ww = getWaveWindowByWebContentsId(event.sender.id); - console.log("delete-workspace", workspaceId, ww?.waveWindowId); - - const workspaceList = await WorkspaceService.ListWorkspaces(); - - const _workspaceHasWindow = !!workspaceList.find((wse) => wse.workspaceid === workspaceId)?.windowid; - - const choice = dialog.showMessageBoxSync(this, { - type: "question", - buttons: ["Cancel", "Delete Workspace"], - title: "Confirm", - message: `Deleting workspace will also delete its contents.\n\nContinue?`, - }); - if (choice === 0) { - console.log("user cancelled workspace delete", workspaceId, ww?.waveWindowId); - return; - } - - const newWorkspaceId = await WorkspaceService.DeleteWorkspace(workspaceId); - console.log("delete-workspace done", workspaceId, ww?.waveWindowId); - if (ww?.workspaceId == workspaceId) { - if (newWorkspaceId) { - await ww.switchWorkspace(newWorkspaceId); - } else { - console.log("delete-workspace closing window", workspaceId, ww?.waveWindowId); - ww.destroy(); - } - } - }); -}); - -export async function createNewWaveWindow() { - log("createNewWaveWindow"); - const clientData = await ClientService.GetClientData(); - const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); - let recreatedWindow = false; - const allWindows = getAllWaveWindows(); - if (allWindows.length === 0 && clientData?.windowids?.length >= 1) { - console.log("no windows, but clientData has windowids, recreating first window"); - // reopen the first window - const existingWindowId = clientData.windowids[0]; - const existingWindowData = (await ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow; - if (existingWindowData != null) { - const win = await createBrowserWindow(existingWindowData, fullConfig, { - unamePlatform, - isPrimaryStartupWindow: false, - }); - if (quakeWindow == null) { - quakeWindow = win; - } - win.show(); - recreatedWindow = true; - } - } - if (recreatedWindow) { - console.log("recreated window, returning"); - return; - } - console.log("creating new window"); - const newBrowserWindow = await createBrowserWindow(null, fullConfig, { - unamePlatform, - isPrimaryStartupWindow: false, - }); - if (quakeWindow == null) { - quakeWindow = newBrowserWindow; - } - newBrowserWindow.show(); -} - -export async function relaunchBrowserWindows() { - console.log("relaunchBrowserWindows"); - setGlobalIsRelaunching(true); - const windows = getAllWaveWindows(); - if (windows.length > 0) { - for (const window of windows) { - console.log("relaunch -- closing window", window.waveWindowId); - window.close(); - } - await delay(1200); - } - setGlobalIsRelaunching(false); - - const clientData = await ClientService.GetClientData(); - const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); - const windowIds = clientData.windowids ?? []; - const wins: WaveBrowserWindow[] = []; - const isFirstRelaunch = !hasCompletedFirstRelaunch; - const primaryWindowId = windowIds.length > 0 ? windowIds[0] : null; - for (const windowId of windowIds.slice().reverse()) { - const windowData: WaveWindow = await WindowService.GetWindow(windowId); - if (windowData == null) { - console.log("relaunch -- window data not found, closing window", windowId); - await WindowService.CloseWindow(windowId, true); - continue; - } - const isPrimaryStartupWindow = isFirstRelaunch && windowId === primaryWindowId; - console.log( - "relaunch -- creating window", - windowId, - windowData, - isPrimaryStartupWindow ? "(primary startup)" : "" - ); - const win = await createBrowserWindow(windowData, fullConfig, { - unamePlatform, - isPrimaryStartupWindow, - foregroundWindow: windowId === primaryWindowId, - }); - wins.push(win); - if (windowId === primaryWindowId) { - quakeWindow = win; - console.log("designated quake window", win.waveWindowId); - } - } - hasCompletedFirstRelaunch = true; - for (const win of wins) { - console.log("show window", win.waveWindowId); - win.show(); - } -} - -function getDisplayForQuakeToggle() { - // We cannot reliably query the OS-wide active window in Electron. - // Cursor position is the best cross-platform proxy for the user's active display. - const cursorPoint = screen.getCursorScreenPoint(); - const displayAtCursor = screen - .getAllDisplays() - .find( - (display) => - cursorPoint.x >= display.bounds.x && - cursorPoint.x < display.bounds.x + display.bounds.width && - cursorPoint.y >= display.bounds.y && - cursorPoint.y < display.bounds.y + display.bounds.height - ); - return displayAtCursor ?? screen.getDisplayNearestPoint(cursorPoint); -} - -function moveWindowToDisplay(win: WaveBrowserWindow, targetDisplay: Electron.Display) { - if (!win || !targetDisplay || win.isDestroyed()) { - return; - } - const curBounds = win.getBounds(); - const sourceDisplay = screen.getDisplayMatching(curBounds); - if (sourceDisplay.id === targetDisplay.id) { - return; - } - - const sourceArea = sourceDisplay.workArea; - const targetArea = targetDisplay.workArea; - const nextHeight = Math.min(curBounds.height, targetArea.height); - const nextWidth = Math.min(curBounds.width, targetArea.width); - const maxXOffset = Math.max(0, targetArea.width - nextWidth); - const maxYOffset = Math.max(0, targetArea.height - nextHeight); - const sourceXOffset = curBounds.x - sourceArea.x; - const sourceYOffset = curBounds.y - sourceArea.y; - const nextX = targetArea.x + Math.min(Math.max(sourceXOffset, 0), maxXOffset); - const nextY = targetArea.y + Math.min(Math.max(sourceYOffset, 0), maxYOffset); - - win.setBounds({ ...curBounds, x: nextX, y: nextY, width: nextWidth, height: nextHeight }); -} - -const FullscreenTransitionTimeoutMs = 2000; - -// handles a theoretical race condition where the user spams the hotkey before the toggle finishes -let quakeToggleInProgress = false; -let quakeRestoreFullscreenOnShow = false; - -function waitForFullscreenLeave(window: WaveBrowserWindow): Promise<void> { - if (!window.isFullScreen()) { - return Promise.resolve(); - } - return new Promise((resolve, reject) => { - // eslint-disable-next-line prefer-const - let timeout: ReturnType<typeof setTimeout>; - const onLeave = () => { - clearTimeout(timeout); - resolve(); - }; - timeout = setTimeout(() => { - window.removeListener("leave-full-screen", onLeave); - reject(new Error("fullscreen transition timeout")); - }, FullscreenTransitionTimeoutMs); - window.once("leave-full-screen", onLeave); - }); -} - -function waitForFullscreenEnter(window: WaveBrowserWindow): Promise<void> { - if (window.isFullScreen()) { - return Promise.resolve(); - } - return new Promise((resolve, reject) => { - // eslint-disable-next-line prefer-const - let timeout: ReturnType<typeof setTimeout>; - const onEnter = () => { - clearTimeout(timeout); - resolve(); - }; - timeout = setTimeout(() => { - window.removeListener("enter-full-screen", onEnter); - reject(new Error("fullscreen transition timeout")); - }, FullscreenTransitionTimeoutMs); - window.once("enter-full-screen", onEnter); - }); -} - -async function quakeToggle() { - if (quakeToggleInProgress) { - return; - } - quakeToggleInProgress = true; - try { - let window = quakeWindow; - if (window?.isDestroyed()) { - quakeWindow = null; - window = null; - } - if (window == null) { - await createNewWaveWindow(); - return; - } - // Some environments don't hide or move the window if it's fullscreen (even when hidden), so leave fullscreen first - if (window.isFullScreen()) { - // macos has a really long fullscreen animation and can have issues restoring from fullscreen, so we skip on macos - quakeRestoreFullscreenOnShow = process.platform !== "darwin"; - const leavePromise = waitForFullscreenLeave(window); - window.setFullScreen(false); - try { - await leavePromise; - } catch { - // timeout — proceed anyway - } - if (window.isDestroyed()) { - return; - } - } - if (window.isVisible()) { - window.hide(); - } else { - const targetDisplay = getDisplayForQuakeToggle(); - moveWindowToDisplay(window, targetDisplay); - window.show(); - if (quakeRestoreFullscreenOnShow) { - const enterPromise = waitForFullscreenEnter(window); - window.setFullScreen(true); - try { - await enterPromise; - } catch { - // timeout — proceed anyway - } - } - quakeRestoreFullscreenOnShow = false; - window.focus(); - if (window.activeTabView?.webContents) { - window.activeTabView.webContents.focus(); - } - } - } finally { - quakeToggleInProgress = false; - } -} - -let currentRawGlobalHotKey: string = null; -let currentGlobalHotKey: string = null; - -export function registerGlobalHotkey(rawGlobalHotKey: string) { - if (rawGlobalHotKey === currentRawGlobalHotKey) { - return; - } - if (currentGlobalHotKey != null) { - globalShortcut.unregister(currentGlobalHotKey); - currentGlobalHotKey = null; - currentRawGlobalHotKey = null; - } - if (!rawGlobalHotKey) { - return; - } - try { - const electronHotKey = waveKeyToElectronKey(rawGlobalHotKey); - const ok = globalShortcut.register(electronHotKey, () => { - fireAndForget(quakeToggle); - }); - currentRawGlobalHotKey = rawGlobalHotKey; - currentGlobalHotKey = electronHotKey; - console.log("registered globalhotkey", rawGlobalHotKey, "=>", electronHotKey, "ok=", ok); - } catch (e) { - console.log("error registering global hotkey", rawGlobalHotKey, ":", e); - } -} - -export function initGlobalHotkeyEventSubscription() { - waveEventSubscribeSingle({ - eventType: "config", - handler: (event) => { - try { - const hotkey = event?.data?.fullconfig?.settings?.["app:globalhotkey"]; - registerGlobalHotkey(hotkey ?? null); - } catch (e) { - console.log("error handling config event for globalhotkey", e); - } - }, - }); -} diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts deleted file mode 100644 index d17dc2e106..0000000000 --- a/emain/emain-wsh.ts +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { WindowService } from "@/app/store/services"; -import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { Notification, net, safeStorage, shell } from "electron"; -import { getResolvedUpdateChannel } from "emain/updater"; -import { unamePlatform } from "./emain-platform"; -import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; -import { createBrowserWindow, getWaveWindowById, getWaveWindowByWorkspaceId } from "./emain-window"; - -export class ElectronWshClientType extends WshClient { - constructor() { - super("electron"); - } - - async handle_webselector(rh: RpcResponseHelper, data: CommandWebSelectorData): Promise<string[]> { - if (!data.tabid || !data.blockid || !data.workspaceid) { - throw new Error("tabid and blockid are required"); - } - const ww = getWaveWindowByWorkspaceId(data.workspaceid); - if (ww == null) { - throw new Error(`no window found with workspace ${data.workspaceid}`); - } - const wc = await getWebContentsByBlockId(ww, data.tabid, data.blockid); - if (wc == null) { - throw new Error(`no webcontents found with blockid ${data.blockid}`); - } - const rtn = await webGetSelector(wc, data.selector, data.opts); - return rtn; - } - - async handle_notify(rh: RpcResponseHelper, notificationOptions: WaveNotificationOptions) { - new Notification({ - title: notificationOptions.title, - body: notificationOptions.body, - silent: notificationOptions.silent, - }).show(); - } - - async handle_getupdatechannel(rh: RpcResponseHelper): Promise<string> { - return getResolvedUpdateChannel(); - } - - async handle_focuswindow(rh: RpcResponseHelper, windowId: string) { - console.log(`focuswindow ${windowId}`); - const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); - let ww = getWaveWindowById(windowId); - if (ww == null) { - const window = await WindowService.GetWindow(windowId); - if (window == null) { - throw new Error(`window ${windowId} not found`); - } - ww = await createBrowserWindow(window, fullConfig, { - unamePlatform, - isPrimaryStartupWindow: false, - }); - } - ww.focus(); - } - - async handle_electronencrypt( - rh: RpcResponseHelper, - data: CommandElectronEncryptData - ): Promise<CommandElectronEncryptRtnData> { - if (!safeStorage.isEncryptionAvailable()) { - throw new Error("encryption is not available"); - } - const encrypted = safeStorage.encryptString(data.plaintext); - const ciphertext = encrypted.toString("base64"); - - let storagebackend = ""; - if (process.platform === "linux") { - storagebackend = safeStorage.getSelectedStorageBackend(); - } - - return { - ciphertext, - storagebackend, - }; - } - - async handle_electrondecrypt( - rh: RpcResponseHelper, - data: CommandElectronDecryptData - ): Promise<CommandElectronDecryptRtnData> { - if (!safeStorage.isEncryptionAvailable()) { - throw new Error("encryption is not available"); - } - const encrypted = Buffer.from(data.ciphertext, "base64"); - const plaintext = safeStorage.decryptString(encrypted); - - let storagebackend = ""; - if (process.platform === "linux") { - storagebackend = safeStorage.getSelectedStorageBackend(); - } - - return { - plaintext, - storagebackend, - }; - } - - async handle_networkonline(rh: RpcResponseHelper): Promise<boolean> { - return net.isOnline(); - } - - async handle_electronsystembell(rh: RpcResponseHelper): Promise<void> { - shell.beep(); - } - - // async handle_workspaceupdate(rh: RpcResponseHelper) { - // console.log("workspaceupdate"); - // fireAndForget(async () => { - // console.log("workspace menu clicked"); - // const updatedWorkspaceMenu = await getWorkspaceMenu(); - // const workspaceMenu = Menu.getApplicationMenu().getMenuItemById("workspace-menu"); - // workspaceMenu.submenu = Menu.buildFromTemplate(updatedWorkspaceMenu); - // }); - // } -} - -export let ElectronWshClient: ElectronWshClientType; - -export function initElectronWshClient() { - ElectronWshClient = new ElectronWshClientType(); -} diff --git a/emain/emain.ts b/emain/emain.ts deleted file mode 100644 index 8b08178aec..0000000000 --- a/emain/emain.ts +++ /dev/null @@ -1,467 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { RpcApi } from "@/app/store/wshclientapi"; -import * as electron from "electron"; -import { focusedBuilderWindow, getAllBuilderWindows } from "emain/emain-builder"; -import { globalEvents } from "emain/emain-events"; -import { sprintf } from "sprintf-js"; -import * as services from "../frontend/app/store/services"; -import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil-base"; -import { fireAndForget, sleep } from "../frontend/util/util"; -import { AuthKey, configureAuthKeyRequestInjection } from "./authkey"; -import { - getActivityState, - getAndClearTermCommandsDurable, - getAndClearTermCommandsRemote, - getAndClearTermCommandsRun, - getAndClearTermCommandsWsl, - getForceQuit, - getGlobalIsRelaunching, - getUserConfirmedQuit, - setForceQuit, - setGlobalIsQuitting, - setGlobalIsStarting, - setUserConfirmedQuit, - setWasActive, - setWasInFg, -} from "./emain-activity"; -import { initIpcHandlers } from "./emain-ipc"; -import { log } from "./emain-log"; -import { initMenuEventSubscriptions, makeAndSetAppMenu, makeDockTaskbar } from "./emain-menu"; -import { - checkIfRunningUnderARM64Translation, - getElectronAppBasePath, - getElectronAppUnpackedBasePath, - getWaveConfigDir, - getWaveDataDir, - isDev, - unameArch, - unamePlatform, -} from "./emain-platform"; -import { ensureHotSpareTab, setMaxTabCacheSize } from "./emain-tabview"; -import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, runWaveSrv } from "./emain-wavesrv"; -import { - createBrowserWindow, - createNewWaveWindow, - focusedWaveWindow, - getAllWaveWindows, - getQuakeWindow, - getWaveWindowById, - getWaveWindowByWorkspaceId, - initGlobalHotkeyEventSubscription, - registerGlobalHotkey, - relaunchBrowserWindows, - WaveBrowserWindow, -} from "./emain-window"; -import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; -import { getLaunchSettings } from "./launchsettings"; -import { configureAutoUpdater, updater } from "./updater"; - -const electronApp = electron.app; - -let confirmQuit = true; - -const waveDataDir = getWaveDataDir(); -const waveConfigDir = getWaveConfigDir(); - -electron.nativeTheme.themeSource = "dark"; - -console.log = log; -console.log( - sprintf( - "waveterm-app starting, data_dir=%s, config_dir=%s electronpath=%s gopath=%s arch=%s/%s electron=%s", - waveDataDir, - waveConfigDir, - getElectronAppBasePath(), - getElectronAppUnpackedBasePath(), - unamePlatform, - unameArch, - process.versions.electron - ) -); -if (isDev) { - console.log("waveterm-app WAVETERM_DEV set"); -} - -function handleWSEvent(evtMsg: WSEventType) { - fireAndForget(async () => { - console.log("handleWSEvent", evtMsg?.eventtype); - if (evtMsg.eventtype == "electron:newwindow") { - console.log("electron:newwindow", evtMsg.data); - const windowId: string = evtMsg.data; - const windowData: WaveWindow = (await services.ObjectService.GetObject("window:" + windowId)) as WaveWindow; - if (windowData == null) { - return; - } - const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); - const newWin = await createBrowserWindow(windowData, fullConfig, { - unamePlatform, - isPrimaryStartupWindow: false, - }); - newWin.show(); - } else if (evtMsg.eventtype == "electron:closewindow") { - console.log("electron:closewindow", evtMsg.data); - if (evtMsg.data === undefined) return; - const ww = getWaveWindowById(evtMsg.data); - if (ww != null) { - ww.destroy(); // bypass the "are you sure?" dialog - } - } else if (evtMsg.eventtype == "electron:updateactivetab") { - const activeTabUpdate: { workspaceid: string; newactivetabid: string } = evtMsg.data; - console.log("electron:updateactivetab", activeTabUpdate); - const ww = getWaveWindowByWorkspaceId(activeTabUpdate.workspaceid); - if (ww == null) { - return; - } - await ww.setActiveTab(activeTabUpdate.newactivetabid, false); - } else { - console.log("unhandled electron ws eventtype", evtMsg.eventtype); - } - }); -} - -// we try to set the primary display as index [0] -function getActivityDisplays(): ActivityDisplayType[] { - const displays = electron.screen.getAllDisplays(); - const primaryDisplay = electron.screen.getPrimaryDisplay(); - const rtn: ActivityDisplayType[] = []; - for (const display of displays) { - const adt = { - width: display.size.width, - height: display.size.height, - dpr: display.scaleFactor, - internal: display.internal, - }; - if (display.id === primaryDisplay?.id) { - rtn.unshift(adt); - } else { - rtn.push(adt); - } - } - return rtn; -} - -async function sendDisplaysTDataEvent() { - const displays = getActivityDisplays(); - if (displays.length === 0) { - return; - } - const props: TEventProps = {}; - props["display:count"] = displays.length; - props["display:height"] = displays[0].height; - props["display:width"] = displays[0].width; - props["display:dpr"] = displays[0].dpr; - props["display:all"] = displays; - try { - await RpcApi.RecordTEventCommand( - ElectronWshClient, - { - event: "app:display", - props, - }, - { noresponse: true } - ); - } catch (e) { - console.log("error sending display tdata event", e); - } -} - -function logActiveState() { - fireAndForget(async () => { - const astate = getActivityState(); - const activity: ActivityUpdate = { openminutes: 1 }; - const ww = focusedWaveWindow; - const activeTabView = ww?.activeTabView; - const isWaveAIOpen = activeTabView?.isWaveAIOpen ?? false; - - if (astate.wasInFg) { - activity.fgminutes = 1; - } - if (astate.wasActive) { - activity.activeminutes = 1; - } - activity.displays = getActivityDisplays(); - - const termCmdCount = getAndClearTermCommandsRun(); - if (termCmdCount > 0) { - activity.termcommandsrun = termCmdCount; - } - const termCmdRemoteCount = getAndClearTermCommandsRemote(); - const termCmdWslCount = getAndClearTermCommandsWsl(); - const termCmdDurableCount = getAndClearTermCommandsDurable(); - - const props: TEventProps = { - "activity:activeminutes": activity.activeminutes, - "activity:fgminutes": activity.fgminutes, - "activity:openminutes": activity.openminutes, - }; - if (termCmdCount > 0) { - props["activity:termcommandsrun"] = termCmdCount; - } - if (termCmdRemoteCount > 0) { - props["activity:termcommands:remote"] = termCmdRemoteCount; - } - if (termCmdWslCount > 0) { - props["activity:termcommands:wsl"] = termCmdWslCount; - } - if (termCmdDurableCount > 0) { - props["activity:termcommands:durable"] = termCmdDurableCount; - } - if (astate.wasActive && isWaveAIOpen) { - props["activity:waveaiactiveminutes"] = 1; - } - if (astate.wasInFg && isWaveAIOpen) { - props["activity:waveaifgminutes"] = 1; - } - - try { - await RpcApi.ActivityCommand(ElectronWshClient, activity, { noresponse: true }); - await RpcApi.RecordTEventCommand( - ElectronWshClient, - { - event: "app:activity", - props, - }, - { noresponse: true } - ); - } catch (e) { - console.log("error logging active state", e); - } finally { - setWasInFg(ww?.isFocused() ?? false); - setWasActive(false); - } - }); -} - -// this isn't perfect, but gets the job done without being complicated -function runActiveTimer() { - logActiveState(); - setTimeout(runActiveTimer, 60000); -} - -function hideWindowWithCatch(window: WaveBrowserWindow) { - if (window == null) { - return; - } - try { - if (window.isDestroyed()) { - return; - } - window.hide(); - } catch (e) { - console.log("error hiding window", e); - } -} - -electronApp.on("window-all-closed", () => { - if (getGlobalIsRelaunching()) { - return; - } - if (unamePlatform !== "darwin") { - setUserConfirmedQuit(true); - electronApp.quit(); - } -}); -electronApp.on("before-quit", (e) => { - const allWindows = getAllWaveWindows(); - const allBuilders = getAllBuilderWindows(); - if ( - confirmQuit && - !getForceQuit() && - !getUserConfirmedQuit() && - (allWindows.length > 0 || allBuilders.length > 0) && - !getIsWaveSrvDead() && - !process.env.WAVETERM_NOCONFIRMQUIT - ) { - e.preventDefault(); - const choice = electron.dialog.showMessageBoxSync(null, { - type: "question", - buttons: ["Cancel", "Quit"], - title: "Confirm Quit", - message: "Are you sure you want to quit Wave Terminal?", - defaultId: 0, - cancelId: 0, - }); - if (choice === 0) { - return; - } - setUserConfirmedQuit(true); - electronApp.quit(); - return; - } - setGlobalIsQuitting(true); - updater?.stop(); - if (unamePlatform == "win32") { - // win32 doesn't have a SIGINT, so we just let electron die, which - // ends up killing wavesrv via closing it's stdin. - return; - } - getWaveSrvProc()?.kill("SIGINT"); - shutdownWshrpc(); - if (getForceQuit()) { - return; - } - e.preventDefault(); - for (const window of allWindows) { - hideWindowWithCatch(window); - } - for (const builder of allBuilders) { - builder.hide(); - } - if (getIsWaveSrvDead()) { - console.log("wavesrv is dead, quitting immediately"); - setForceQuit(true); - electronApp.quit(); - return; - } - setTimeout(() => { - console.log("waiting for wavesrv to exit..."); - setForceQuit(true); - electronApp.quit(); - }, 3000); -}); -process.on("SIGINT", () => { - console.log("Caught SIGINT, shutting down"); - setUserConfirmedQuit(true); - electronApp.quit(); -}); -process.on("SIGHUP", () => { - console.log("Caught SIGHUP, shutting down"); - setUserConfirmedQuit(true); - electronApp.quit(); -}); -process.on("SIGTERM", () => { - console.log("Caught SIGTERM, shutting down"); - setUserConfirmedQuit(true); - electronApp.quit(); -}); -let caughtException = false; -process.on("uncaughtException", (error) => { - if (caughtException) { - return; - } - - // Check if the error is related to QUIC protocol, if so, ignore (can happen with the updater) - if (error?.message?.includes("net::ERR_QUIC_PROTOCOL_ERROR")) { - console.log("Ignoring QUIC protocol error:", error.message); - console.log("Stack Trace:", error.stack); - return; - } - - caughtException = true; - console.log("Uncaught Exception, shutting down: ", error); - console.log("Stack Trace:", error.stack); - // Optionally, handle cleanup or exit the app - setUserConfirmedQuit(true); - electronApp.quit(); -}); - -let lastWaveWindowCount = 0; -let lastIsBuilderWindowActive = false; -globalEvents.on("windows-updated", () => { - const wwCount = getAllWaveWindows().length; - const isBuilderActive = focusedBuilderWindow != null; - if (wwCount == lastWaveWindowCount && isBuilderActive == lastIsBuilderWindowActive) { - return; - } - lastWaveWindowCount = wwCount; - lastIsBuilderWindowActive = isBuilderActive; - console.log("windows-updated", wwCount, "builder-active:", isBuilderActive); - makeAndSetAppMenu(); -}); - -async function appMain() { - // Set disableHardwareAcceleration as early as possible, if required. - const launchSettings = getLaunchSettings(); - if (launchSettings?.["window:disablehardwareacceleration"]) { - console.log("disabling hardware acceleration, per launch settings"); - electronApp.disableHardwareAcceleration(); - } - const startTs = Date.now(); - const instanceLock = electronApp.requestSingleInstanceLock(); - if (!instanceLock) { - console.log("waveterm-app could not get single-instance-lock, shutting down"); - setUserConfirmedQuit(true); - electronApp.quit(); - return; - } - electronApp.on("second-instance", (_event, argv, workingDirectory) => { - console.log("second-instance event, argv:", argv, "workingDirectory:", workingDirectory); - fireAndForget(createNewWaveWindow); - }); - try { - await runWaveSrv(handleWSEvent); - } catch (e) { - console.log(e.toString()); - } - const ready = await getWaveSrvReady(); - console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms"); - await electronApp.whenReady(); - configureAuthKeyRequestInjection(electron.session.defaultSession); - initIpcHandlers(); - - await sleep(10); // wait a bit for wavesrv to be ready - try { - initElectronWshClient(); - initElectronWshrpc(ElectronWshClient, { authKey: AuthKey }); - initMenuEventSubscriptions(); - } catch (e) { - console.log("error initializing wshrpc", e); - } - const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); - checkIfRunningUnderARM64Translation(fullConfig); - if (fullConfig?.settings?.["app:confirmquit"] != null) { - confirmQuit = fullConfig.settings["app:confirmquit"]; - } - ensureHotSpareTab(fullConfig); - await relaunchBrowserWindows(); - setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe - setTimeout(sendDisplaysTDataEvent, 5000); - - makeAndSetAppMenu(); - makeDockTaskbar(); - await configureAutoUpdater(); - setGlobalIsStarting(false); - if (fullConfig?.settings?.["window:maxtabcachesize"] != null) { - setMaxTabCacheSize(fullConfig.settings["window:maxtabcachesize"]); - } - - electronApp.on("activate", () => { - const allWindows = getAllWaveWindows(); - const anyVisible = allWindows.some((w) => !w.isDestroyed() && w.isVisible()); - if (anyVisible) { - return; - } - const qw = getQuakeWindow(); - if (qw != null && !qw.isDestroyed()) { - qw.show(); - qw.focus(); - return; - } - if (allWindows.length === 0) { - fireAndForget(createNewWaveWindow); - } - }); - electron.powerMonitor.on("resume", () => { - console.log("system resumed from sleep, notifying server"); - fireAndForget(async () => { - try { - await RpcApi.NotifySystemResumeCommand(ElectronWshClient, { noresponse: true }); - } catch (e) { - console.log("error calling NotifySystemResumeCommand", e); - } - }); - }); - const rawGlobalHotKey = launchSettings?.["app:globalhotkey"]; - if (rawGlobalHotKey) { - registerGlobalHotkey(rawGlobalHotKey); - } - initGlobalHotkeyEventSubscription(); -} - -appMain().catch((e) => { - console.log("appMain error", e); - setUserConfirmedQuit(true); - electronApp.quit(); -}); diff --git a/emain/launchsettings.ts b/emain/launchsettings.ts deleted file mode 100644 index 238c3a04ae..0000000000 --- a/emain/launchsettings.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "fs"; -import path from "path"; -import { getWaveConfigDir } from "./emain-platform"; - -/** - * Get settings directly from the Wave Home directory on launch. - * Only use this when the app is first starting up. Otherwise, prefer the settings.GetFullConfig function. - * @returns The initial launch settings for the application. - */ -export function getLaunchSettings(): SettingsType { - const settingsPath = path.join(getWaveConfigDir(), "settings.json"); - try { - const settingsContents = fs.readFileSync(settingsPath, "utf8"); - return JSON.parse(settingsContents); - } catch (_) { - // fail silently - } -} diff --git a/emain/preload-webview.ts b/emain/preload-webview.ts deleted file mode 100644 index e2a39a3b4e..0000000000 --- a/emain/preload-webview.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { ipcRenderer } from "electron"; - -document.addEventListener("contextmenu", (event) => { - console.log("contextmenu event", event); - if (event.target == null) { - return; - } - const targetElement = event.target as HTMLElement; - // Check if the right-click is on an image - if (targetElement.tagName === "IMG") { - setTimeout(() => { - if (event.defaultPrevented) { - return; - } - event.preventDefault(); - const imgElem = targetElement as HTMLImageElement; - const imageUrl = imgElem.src; - ipcRenderer.send("webview-image-contextmenu", { src: imageUrl }); - }, 50); - return; - } - // do nothing -}); - -document.addEventListener("mouseup", (event) => { - // Mouse button 3 = back, button 4 = forward - if (!event.isTrusted) { - return; - } - if (event.button === 3 || event.button === 4) { - event.preventDefault(); - ipcRenderer.send("webview-mouse-navigate", event.button === 3 ? "back" : "forward"); - } -}); - -console.log("loaded wave preload-webview.ts"); diff --git a/emain/preload.ts b/emain/preload.ts deleted file mode 100644 index 8d2b18a308..0000000000 --- a/emain/preload.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { contextBridge, ipcRenderer, Rectangle, webUtils, WebviewTag } from "electron"; - -// update type in custom.d.ts (ElectronApi type) -contextBridge.exposeInMainWorld("api", { - getAuthKey: () => ipcRenderer.sendSync("get-auth-key"), - getIsDev: () => ipcRenderer.sendSync("get-is-dev"), - getPlatform: () => ipcRenderer.sendSync("get-platform"), - getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"), - getUserName: () => ipcRenderer.sendSync("get-user-name"), - getHostName: () => ipcRenderer.sendSync("get-host-name"), - getDataDir: () => ipcRenderer.sendSync("get-data-dir"), - getConfigDir: () => ipcRenderer.sendSync("get-config-dir"), - getHomeDir: () => ipcRenderer.sendSync("get-home-dir"), - getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"), - getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"), - getZoomFactor: () => ipcRenderer.sendSync("get-zoom-factor"), - openNewWindow: () => ipcRenderer.send("open-new-window"), - showWorkspaceAppMenu: (workspaceId) => ipcRenderer.send("workspace-appmenu-show", workspaceId), - showBuilderAppMenu: (builderId) => ipcRenderer.send("builder-appmenu-show", builderId), - showContextMenu: (workspaceId, menu) => ipcRenderer.send("contextmenu-show", workspaceId, menu), - onContextMenuClick: (callback: (id: string | null) => void) => - ipcRenderer.on("contextmenu-click", (_event, id: string | null) => callback(id)), - downloadFile: (filePath) => ipcRenderer.send("download", { filePath }), - openExternal: (url) => { - if (url && typeof url === "string") { - ipcRenderer.send("open-external", url); - } else { - console.error("Invalid URL passed to openExternal:", url); - } - }, - getEnv: (varName) => ipcRenderer.sendSync("get-env", varName), - onFullScreenChange: (callback) => - ipcRenderer.on("fullscreen-change", (_event, isFullScreen) => callback(isFullScreen)), - onZoomFactorChange: (callback) => - ipcRenderer.on("zoom-factor-change", (_event, zoomFactor) => callback(zoomFactor)), - onUpdaterStatusChange: (callback) => ipcRenderer.on("app-update-status", (_event, status) => callback(status)), - getUpdaterStatus: () => ipcRenderer.sendSync("get-app-update-status"), - getUpdaterChannel: () => ipcRenderer.sendSync("get-updater-channel"), - installAppUpdate: () => ipcRenderer.send("install-app-update"), - onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback), - updateWindowControlsOverlay: (rect) => ipcRenderer.send("update-window-controls-overlay", rect), - onReinjectKey: (callback) => ipcRenderer.on("reinject-key", (_event, waveEvent) => callback(waveEvent)), - setWebviewFocus: (focused: number) => ipcRenderer.send("webview-focus", focused), - registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys), - onControlShiftStateUpdate: (callback) => - ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)), - createWorkspace: () => ipcRenderer.send("create-workspace"), - switchWorkspace: (workspaceId) => ipcRenderer.send("switch-workspace", workspaceId), - deleteWorkspace: (workspaceId) => ipcRenderer.send("delete-workspace", workspaceId), - setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId), - createTab: () => ipcRenderer.send("create-tab"), - closeTab: (workspaceId, tabId, confirmClose) => ipcRenderer.invoke("close-tab", workspaceId, tabId, confirmClose), - setWindowInitStatus: (status) => ipcRenderer.send("set-window-init-status", status), - onWaveInit: (callback) => ipcRenderer.on("wave-init", (_event, initOpts) => callback(initOpts)), - onBuilderInit: (callback) => ipcRenderer.on("builder-init", (_event, initOpts) => callback(initOpts)), - sendLog: (log) => ipcRenderer.send("fe-log", log), - onQuicklook: (filePath: string) => ipcRenderer.send("quicklook", filePath), - openNativePath: (filePath: string) => ipcRenderer.send("open-native-path", filePath), - captureScreenshot: (rect: Rectangle) => ipcRenderer.invoke("capture-screenshot", rect), - setKeyboardChordMode: () => ipcRenderer.send("set-keyboard-chord-mode"), - clearWebviewStorage: (webContentsId: number) => ipcRenderer.invoke("clear-webview-storage", webContentsId), - setWaveAIOpen: (isOpen: boolean) => ipcRenderer.send("set-waveai-open", isOpen), - closeBuilderWindow: () => ipcRenderer.send("close-builder-window"), - incrementTermCommands: (opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => - ipcRenderer.send("increment-term-commands", opts), - nativePaste: () => ipcRenderer.send("native-paste"), - openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), - setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), - doRefresh: () => ipcRenderer.send("do-refresh"), - getPathForFile: (file: File): string => webUtils.getPathForFile(file), - saveTextFile: (fileName: string, content: string) => ipcRenderer.invoke("save-text-file", fileName, content), - setIsActive: () => ipcRenderer.invoke("set-is-active"), -}); - -// Custom event for "new-window" -ipcRenderer.on("webview-new-window", (e, webContentsId, details) => { - const event = new CustomEvent("new-window", { detail: details }); - document.getElementById("webview").dispatchEvent(event); -}); - -ipcRenderer.on("webcontentsid-from-blockid", (e, blockId, responseCh) => { - const webviewElem: WebviewTag = document.querySelector("div[data-blockid='" + blockId + "'] webview"); - const wcId = webviewElem?.dataset?.webcontentsid; - ipcRenderer.send(responseCh, wcId); -}); diff --git a/emain/updater.ts b/emain/updater.ts deleted file mode 100644 index 8f06e6bec7..0000000000 --- a/emain/updater.ts +++ /dev/null @@ -1,253 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { dialog, ipcMain, Notification } from "electron"; -import { autoUpdater } from "electron-updater"; -import { readFileSync } from "fs"; -import path from "path"; -import YAML from "yaml"; -import { RpcApi } from "../frontend/app/store/wshclientapi"; -import { isDev } from "../frontend/util/isdev"; -import { fireAndForget } from "../frontend/util/util"; -import { setUserConfirmedQuit } from "./emain-activity"; -import { delay } from "./emain-util"; -import { focusedWaveWindow, getAllWaveWindows } from "./emain-window"; -import { ElectronWshClient } from "./emain-wsh"; - -export let updater: Updater; - -function getUpdateChannel(settings: SettingsType): string { - const updaterConfigPath = path.join(process.resourcesPath!, "app-update.yml"); - const updaterConfig = YAML.parse(readFileSync(updaterConfigPath, { encoding: "utf8" }).toString()); - console.log("Updater config from binary:", updaterConfig); - const updaterChannel: string = updaterConfig.channel ?? "latest"; - const settingsChannel = settings["autoupdate:channel"]; - let retVal = settingsChannel; - - // If the user setting doesn't exist yet, set it to the value of the updater config. - // If the user was previously on the `latest` channel and has downloaded a `beta` version, update their configured channel to `beta` to prevent downgrading. - if (!settingsChannel || (settingsChannel == "latest" && updaterChannel == "beta")) { - console.log("Update channel setting does not exist, setting to value from updater config."); - RpcApi.SetConfigCommand(ElectronWshClient, { "autoupdate:channel": updaterChannel }); - retVal = updaterChannel; - } - console.log("Update channel:", retVal); - return retVal; -} - -export class Updater { - autoCheckInterval: NodeJS.Timeout | null; - intervalms: number; - autoCheckEnabled: boolean; - availableUpdateReleaseName: string | null; - availableUpdateReleaseNotes: string | null; - private _status: UpdaterStatus; - lastUpdateCheck: Date; - - constructor(settings: SettingsType) { - this.intervalms = settings["autoupdate:intervalms"]; - console.log("Update check interval in milliseconds:", this.intervalms); - this.autoCheckEnabled = settings["autoupdate:enabled"]; - console.log("Update check enabled:", this.autoCheckEnabled); - - this._status = "up-to-date"; - this.lastUpdateCheck = new Date(0); - this.autoCheckInterval = null; - this.availableUpdateReleaseName = null; - - autoUpdater.autoInstallOnAppQuit = settings["autoupdate:installonquit"]; - console.log("Install update on quit:", settings["autoupdate:installonquit"]); - - // Only update the release channel if it's specified, otherwise use the one configured in the updater. - autoUpdater.channel = getUpdateChannel(settings); - autoUpdater.allowDowngrade = false; - - autoUpdater.removeAllListeners(); - - autoUpdater.on("error", (err) => { - console.log("updater error"); - console.log(err); - if (!err.toString()?.includes("net::ERR_INTERNET_DISCONNECTED")) this.status = "error"; - }); - - autoUpdater.on("checking-for-update", () => { - console.log("checking-for-update"); - this.status = "checking"; - }); - - autoUpdater.on("update-available", () => { - console.log("update-available; downloading..."); - this.status = "downloading"; - }); - - autoUpdater.on("update-not-available", () => { - console.log("update-not-available"); - this.status = "up-to-date"; - }); - - autoUpdater.on("update-downloaded", (event) => { - console.log("update-downloaded", [event]); - this.availableUpdateReleaseName = event.releaseName; - this.availableUpdateReleaseNotes = event.releaseNotes as string | null; - - // Display the update banner and create a system notification - this.status = "ready"; - const updateNotification = new Notification({ - title: "Wave Terminal", - body: "A new version of Wave Terminal is ready to install.", - }); - updateNotification.on("click", () => { - fireAndForget(this.promptToInstallUpdate.bind(this)); - }); - updateNotification.show(); - }); - } - - /** - * The status of the Updater. - */ - get status(): UpdaterStatus { - return this._status; - } - - private set status(value: UpdaterStatus) { - this._status = value; - getAllWaveWindows().forEach((window) => { - const allTabs = Array.from(window.allLoadedTabViews.values()); - allTabs.forEach((tab) => { - tab.webContents.send("app-update-status", value); - }); - }); - } - - /** - * Check for updates and start the background update check, if configured. - */ - async start() { - if (this.autoCheckEnabled) { - console.log("starting updater"); - this.autoCheckInterval = setInterval(() => { - fireAndForget(() => this.checkForUpdates(false)); - }, 600000); // intervals are unreliable when an app is suspended so we will check every 10 mins if the interval has passed. - await this.checkForUpdates(false); - } - } - - /** - * Stop the background update check, if configured. - */ - stop() { - console.log("stopping updater"); - if (this.autoCheckInterval) { - clearInterval(this.autoCheckInterval); - this.autoCheckInterval = null; - } - } - - /** - * Checks if the configured interval time has passed since the last update check, and if so, checks for updates using the `autoUpdater` object - * @param userInput Whether the user is requesting this. If so, an alert will report the result of the check. - */ - async checkForUpdates(userInput: boolean) { - const now = new Date(); - - // Run an update check always if the user requests it, otherwise only if there's an active update check interval and enough time has elapsed. - if ( - userInput || - (this.autoCheckInterval && - (!this.lastUpdateCheck || Math.abs(now.getTime() - this.lastUpdateCheck.getTime()) > this.intervalms)) - ) { - const result = await autoUpdater.checkForUpdates(); - - // If the user requested this check and we do not have an available update, let them know with a popup dialog. No need to tell them if there is an update, because we show a banner once the update is ready to install. - if (userInput && !result.downloadPromise) { - const dialogOpts: Electron.MessageBoxOptions = { - type: "info", - message: "There are currently no updates available.", - }; - if (focusedWaveWindow) { - dialog.showMessageBox(focusedWaveWindow, dialogOpts); - } - } - - // Only update the last check time if this is an automatic check. This ensures the interval remains consistent. - if (!userInput) this.lastUpdateCheck = now; - } - } - - /** - * Prompts the user to install the downloaded application update and restarts the application - */ - async promptToInstallUpdate() { - const dialogOpts: Electron.MessageBoxOptions = { - type: "info", - buttons: ["Restart", "Later"], - title: "Application Update", - message: process.platform === "win32" ? this.availableUpdateReleaseNotes : this.availableUpdateReleaseName, - detail: "A new version has been downloaded. Restart the application to apply the updates.", - }; - - const allWindows = getAllWaveWindows(); - if (allWindows.length > 0) { - await dialog.showMessageBox(focusedWaveWindow ?? allWindows[0], dialogOpts).then(({ response }) => { - if (response === 0) { - fireAndForget(this.installUpdate.bind(this)); - } - }); - } - } - - /** - * Restarts the app and installs an update if it is available. - */ - async installUpdate() { - if (this.status == "ready") { - this.status = "installing"; - await delay(1000); - setUserConfirmedQuit(true); - autoUpdater.quitAndInstall(); - } - } -} - -export function getResolvedUpdateChannel(): string { - return isDev() ? "dev" : (autoUpdater.channel ?? "latest"); -} - -ipcMain.on("install-app-update", () => fireAndForget(updater?.promptToInstallUpdate.bind(updater))); -ipcMain.on("get-app-update-status", (event) => { - event.returnValue = updater?.status; -}); -ipcMain.on("get-updater-channel", (event) => { - event.returnValue = getResolvedUpdateChannel(); -}); - -let autoUpdateLock = false; - -/** - * Configures the auto-updater based on the user's preference - */ -export async function configureAutoUpdater() { - if (isDev()) { - console.log("skipping auto-updater in dev mode"); - return; - } - - // simple lock to prevent multiple auto-update configuration attempts, this should be very rare - if (autoUpdateLock) { - console.log("auto-update configuration already in progress, skipping"); - return; - } - autoUpdateLock = true; - - try { - console.log("Configuring updater"); - const settings = (await RpcApi.GetFullConfigCommand(ElectronWshClient)).settings; - updater = new Updater(settings); - await updater.start(); - } catch (e) { - console.warn("error configuring updater", e.toString()); - } - - autoUpdateLock = false; -} diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 6e98b1d805..0000000000 --- a/eslint.config.js +++ /dev/null @@ -1,98 +0,0 @@ -// @ts-check - -import eslint from "@eslint/js"; -import eslintConfigPrettier from "eslint-config-prettier"; -import globals from "globals"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import tseslint from "typescript-eslint"; - -const tsconfigRootDir = path.dirname(fileURLToPath(new URL(import.meta.url))); - -export default [ - { - languageOptions: { - parserOptions: { - tsconfigRootDir, - }, - }, - }, - - { - ignores: [ - "**/node_modules/**", - "**/dist/**", - "**/build/**", - "**/make/**", - "tsunami/frontend/scaffold/**", - "docs/.docusaurus/**", - ], - }, - - { - files: ["frontend/**/*.{ts,tsx}", "emain/**/*.{ts,tsx}"], - languageOptions: { - parserOptions: { - tsconfigRootDir, - project: "./tsconfig.json", - }, - }, - }, - - { - files: ["docs/**/*.{ts,tsx}"], - languageOptions: { - parserOptions: { tsconfigRootDir, project: "./docs/tsconfig.json" }, - }, - }, - - eslint.configs.recommended, - ...tseslint.configs.recommended, - - { - rules: { - "@typescript-eslint/no-explicit-any": "off", - }, - }, - - { - files: ["emain/**/*.ts", "electron.vite.config.ts", "**/*.cjs", "eslint.config.js", "docs/babel.config.js"], - languageOptions: { - globals: { - ...globals.node, - }, - }, - }, - - { - files: ["**/*.js", "**/*.cjs"], - rules: { - "@typescript-eslint/no-require-imports": "off", - }, - }, - - { - rules: { - "@typescript-eslint/no-unused-vars": [ - "warn", - { - argsIgnorePattern: "^(_[a-zA-Z0-9_]*|e|get)$", - varsIgnorePattern: "^(_[a-zA-Z0-9_]*|dlog|e)$", - caughtErrorsIgnorePattern: "^(_[a-zA-Z0-9_]*|e)$", - }, - ], - "prefer-const": "warn", - "no-empty": "warn", - }, - }, - - { - files: ["frontend/app/store/services.ts"], - rules: { - "@typescript-eslint/no-unused-vars": "off", - "prefer-rest-params": "off", - }, - }, - - eslintConfigPrettier, -]; diff --git a/frontend/app/aipanel/ai-utils.ts b/frontend/app/aipanel/ai-utils.ts deleted file mode 100644 index 8bfd67bdc0..0000000000 --- a/frontend/app/aipanel/ai-utils.ts +++ /dev/null @@ -1,598 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { sortByDisplayOrder } from "@/util/util"; - -const TextFileLimit = 200 * 1024; // 200KB -const PdfLimit = 5 * 1024 * 1024; // 5MB -const ImageLimit = 10 * 1024 * 1024; // 10MB -const ImagePreviewSize = 128; -const ImagePreviewWebPQuality = 0.8; -const ImageMaxEdge = 4096; - -export const isAcceptableFile = (file: File): boolean => { - const acceptableTypes = [ - // Images - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", - "image/svg+xml", - // PDFs - "application/pdf", - // Text files - "text/plain", - "text/markdown", - "text/html", - "text/css", - "text/javascript", - "text/typescript", - // Application types for code files - "application/javascript", - "application/typescript", - "application/json", - "application/xml", - ]; - - if (acceptableTypes.includes(file.type)) { - return true; - } - - // Check file extensions for files without proper MIME types - const extension = file.name.split(".").pop()?.toLowerCase(); - const acceptableExtensions = [ - "txt", - "log", - "md", - "js", - "mjs", - "cjs", - "jsx", - "ts", - "mts", - "cts", - "tsx", - "go", - "py", - "java", - "c", - "cpp", - "h", - "hpp", - "html", - "htm", - "css", - "scss", - "sass", - "json", - "jsonc", - "json5", - "jsonl", - "ndjson", - "xml", - "yaml", - "yml", - "sh", - "bat", - "sql", - "php", - "rb", - "rs", - "swift", - "kt", - "cs", - "vb", - "r", - "scala", - "clj", - "ex", - "exs", - "ini", - "toml", - "conf", - "cfg", - "env", - "zsh", - "fish", - "ps1", - "psm1", - "bazel", - "bzl", - "csv", - "tsv", - "properties", - "ipynb", - "rmd", - "gradle", - "groovy", - "cmake", - ]; - - if (extension && acceptableExtensions.includes(extension)) { - return true; - } - - // Check for specific filenames (case-insensitive) - const fileName = file.name.toLowerCase(); - const acceptableFilenames = [ - "makefile", - "dockerfile", - "containerfile", - "go.mod", - "go.sum", - "go.work", - "go.work.sum", - "package.json", - "package-lock.json", - "yarn.lock", - "pnpm-lock.yaml", - "composer.json", - "composer.lock", - "gemfile", - "gemfile.lock", - "podfile", - "podfile.lock", - "cargo.toml", - "cargo.lock", - "pipfile", - "pipfile.lock", - "requirements.txt", - "setup.py", - "pyproject.toml", - "poetry.lock", - "build.gradle", - "settings.gradle", - "pom.xml", - "build.xml", - "readme", - "readme.md", - "license", - "license.md", - "changelog", - "changelog.md", - "contributing", - "contributing.md", - "authors", - "codeowners", - "procfile", - "jenkinsfile", - "vagrantfile", - "rakefile", - "gruntfile.js", - "gulpfile.js", - "webpack.config.js", - "rollup.config.js", - "vite.config.js", - "jest.config.js", - "vitest.config.js", - ".dockerignore", - ".gitignore", - ".gitattributes", - ".gitmodules", - ".editorconfig", - ".eslintrc", - ".prettierrc", - ".pylintrc", - ".bashrc", - ".bash_profile", - ".bash_login", - ".bash_logout", - ".profile", - ".zshrc", - ".zprofile", - ".zshenv", - ".zlogin", - ".zlogout", - ".kshrc", - ".cshrc", - ".tcshrc", - ".xonshrc", - ".shrc", - ".aliases", - ".functions", - ".exports", - ".direnvrc", - ".vimrc", - ".gvimrc", - ]; - - return acceptableFilenames.includes(fileName); -}; - -export const getFileIcon = (fileName: string, fileType: string): string => { - if (fileType === "directory") { - return "fa-folder"; - } - - if (fileType.startsWith("image/")) { - return "fa-image"; - } - - if (fileType === "application/pdf") { - return "fa-file-pdf"; - } - - // Check file extensions for code files - const ext = fileName.split(".").pop()?.toLowerCase(); - switch (ext) { - case "js": - case "jsx": - case "ts": - case "tsx": - return "fa-file-code"; - case "go": - return "fa-file-code"; - case "py": - return "fa-file-code"; - case "java": - case "c": - case "cpp": - case "h": - case "hpp": - return "fa-file-code"; - case "html": - case "css": - case "scss": - case "sass": - return "fa-file-code"; - case "json": - case "xml": - case "yaml": - case "yml": - return "fa-file-code"; - case "md": - case "txt": - return "fa-file-text"; - default: - return "fa-file"; - } -}; - -export const formatFileSize = (bytes: number): string => { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; -}; - -// Normalize MIME type for AI processing -export const normalizeMimeType = (file: File): string => { - const fileType = file.type; - - // Images keep their real mimetype - if (fileType.startsWith("image/")) { - return fileType; - } - - // PDFs keep their mimetype - if (fileType === "application/pdf") { - return fileType; - } - - // Everything else (code files, markdown, text, etc.) becomes text/plain - return "text/plain"; -}; - -// Helper function to read file as base64 for AIMessage -export const readFileAsBase64 = (file: File): Promise<string> => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result as string; - // Remove data URL prefix to get just base64 - const base64 = result.split(",")[1]; - resolve(base64); - }; - reader.onerror = reject; - reader.readAsDataURL(file); - }); -}; - -// Helper function to create data URL for UIMessage -export const createDataUrl = (file: File): Promise<string> => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(file); - }); -}; - -export interface FileSizeError { - fileName: string; - fileSize: number; - maxSize: number; - fileType: "text" | "pdf" | "image"; -} - -export const validateFileSize = (file: File): FileSizeError | null => { - if (file.type.startsWith("image/")) { - if (file.size > ImageLimit) { - return { - fileName: file.name, - fileSize: file.size, - maxSize: ImageLimit, - fileType: "image", - }; - } - } else if (file.type === "application/pdf") { - if (file.size > PdfLimit) { - return { - fileName: file.name, - fileSize: file.size, - maxSize: PdfLimit, - fileType: "pdf", - }; - } - } else { - if (file.size > TextFileLimit) { - return { - fileName: file.name, - fileSize: file.size, - maxSize: TextFileLimit, - fileType: "text", - }; - } - } - - return null; -}; - -export const validateFileSizeFromInfo = ( - fileName: string, - fileSize: number, - mimeType: string -): FileSizeError | null => { - let maxSize: number; - let fileType: "text" | "pdf" | "image"; - - if (mimeType.startsWith("image/")) { - maxSize = ImageLimit; - fileType = "image"; - } else if (mimeType === "application/pdf") { - maxSize = PdfLimit; - fileType = "pdf"; - } else { - maxSize = TextFileLimit; - fileType = "text"; - } - - if (fileSize > maxSize) { - return { - fileName, - fileSize, - maxSize, - fileType, - }; - } - - return null; -}; - -export const formatFileSizeError = (error: FileSizeError): string => { - const typeLabel = error.fileType === "image" ? "Image" : error.fileType === "pdf" ? "PDF" : "Text file"; - return `${typeLabel} "${error.fileName}" is too large (${formatFileSize(error.fileSize)}). Maximum size is ${formatFileSize(error.maxSize)}.`; -}; - -/** - * Resize an image to have a maximum edge of 4096px and convert to WebP format - * Returns the optimized image if it's smaller than the original, otherwise returns the original - */ -export const resizeImage = async (file: File): Promise<File> => { - // Only process actual image files (not SVG) - if (!file.type.startsWith("image/") || file.type === "image/svg+xml") { - return file; - } - - return new Promise((resolve) => { - const img = new Image(); - const url = URL.createObjectURL(file); - - img.onload = async () => { - URL.revokeObjectURL(url); - - let { width, height } = img; - - // Check if resizing is needed - if (width <= ImageMaxEdge && height <= ImageMaxEdge) { - // Image is already small enough, just try WebP conversion - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - ctx?.drawImage(img, 0, 0); - - canvas.toBlob( - (blob) => { - if (blob && blob.size < file.size) { - const webpFile = new File([blob], file.name.replace(/\.[^.]+$/, ".webp"), { - type: "image/webp", - }); - console.log( - `Image resized (no dimension change): ${file.name} - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}` - ); - resolve(webpFile); - } else { - console.log( - `Image kept original (WebP not smaller): ${file.name} - ${formatFileSize(file.size)}` - ); - resolve(file); - } - }, - "image/webp", - ImagePreviewWebPQuality - ); - return; - } - - // Calculate new dimensions while maintaining aspect ratio - if (width > height) { - height = Math.round((height * ImageMaxEdge) / width); - width = ImageMaxEdge; - } else { - width = Math.round((width * ImageMaxEdge) / height); - height = ImageMaxEdge; - } - - // Create canvas and resize - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - ctx?.drawImage(img, 0, 0, width, height); - - // Convert to WebP - canvas.toBlob( - (blob) => { - if (blob && blob.size < file.size) { - const webpFile = new File([blob], file.name.replace(/\.[^.]+$/, ".webp"), { - type: "image/webp", - }); - console.log( - `Image resized: ${file.name} (${img.width}x${img.height} → ${width}x${height}) - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}` - ); - resolve(webpFile); - } else { - console.log( - `Image kept original (WebP not smaller): ${file.name} (${img.width}x${img.height} → ${width}x${height}) - ${formatFileSize(file.size)}` - ); - resolve(file); - } - }, - "image/webp", - ImagePreviewWebPQuality - ); - }; - - img.onerror = () => { - URL.revokeObjectURL(url); - resolve(file); - }; - - img.src = url; - }); -}; - -/** - * Create a 128x128 preview data URL for an image file - */ -export const createImagePreview = async (file: File): Promise<string | null> => { - if (!file.type.startsWith("image/") || file.type === "image/svg+xml") { - return null; - } - - return new Promise((resolve) => { - const img = new Image(); - const url = URL.createObjectURL(file); - - img.onload = () => { - URL.revokeObjectURL(url); - - let { width, height } = img; - - if (width > height) { - height = Math.round((height * ImagePreviewSize) / width); - width = ImagePreviewSize; - } else { - width = Math.round((width * ImagePreviewSize) / height); - height = ImagePreviewSize; - } - - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - ctx?.drawImage(img, 0, 0, width, height); - - canvas.toBlob( - (blob) => { - if (blob) { - const reader = new FileReader(); - reader.onloadend = () => { - resolve(reader.result as string); - }; - reader.readAsDataURL(blob); - } else { - resolve(null); - } - }, - "image/webp", - ImagePreviewWebPQuality - ); - }; - - img.onerror = () => { - URL.revokeObjectURL(url); - resolve(null); - }; - - img.src = url; - }); -}; - - -/** - * Filter and organize AI mode configs into Wave and custom provider groups - * Returns organized configs that should be displayed based on settings and premium status - */ -export interface FilteredAIModeConfigs { - waveProviderConfigs: Array<{ mode: string } & AIModeConfigType>; - otherProviderConfigs: Array<{ mode: string } & AIModeConfigType>; - shouldShowCloudModes: boolean; -} - -export const getFilteredAIModeConfigs = ( - aiModeConfigs: Record<string, AIModeConfigType>, - showCloudModes: boolean, - inBuilder: boolean, - hasPremium: boolean, - currentMode?: string -): FilteredAIModeConfigs => { - const hideQuick = inBuilder && hasPremium; - - const allConfigs = Object.entries(aiModeConfigs) - .map(([mode, config]) => ({ mode, ...config })) - .filter((config) => !(hideQuick && config.mode === "waveai@quick")); - - const otherProviderConfigs = allConfigs - .filter((config) => config["ai:provider"] !== "wave") - .sort(sortByDisplayOrder); - - const hasCustomModels = otherProviderConfigs.length > 0; - const isCurrentModeCloud = currentMode?.startsWith("waveai@") ?? false; - const shouldShowCloudModes = showCloudModes || !hasCustomModels || isCurrentModeCloud; - - const waveProviderConfigs = shouldShowCloudModes - ? allConfigs.filter((config) => config["ai:provider"] === "wave").sort(sortByDisplayOrder) - : []; - - return { - waveProviderConfigs, - otherProviderConfigs, - shouldShowCloudModes, - }; -}; - -/** - * Get the display name for an AI mode configuration. - * If display:name is set, use that. Otherwise, construct from model/provider. - * For azure-legacy, show "azureresourcename (azure)". - * For other providers, show "model (provider)". - */ -export function getModeDisplayName(config: AIModeConfigType): string { - if (config["display:name"]) { - return config["display:name"]; - } - - const provider = config["ai:provider"]; - const model = config["ai:model"]; - const azureResourceName = config["ai:azureresourcename"]; - - if (provider === "azure-legacy") { - return `${azureResourceName || "unknown"} (azure)`; - } - - return `${model || "unknown"} (${provider || "custom"})`; -} diff --git a/frontend/app/aipanel/aidroppedfiles.tsx b/frontend/app/aipanel/aidroppedfiles.tsx deleted file mode 100644 index d7051c412f..0000000000 --- a/frontend/app/aipanel/aidroppedfiles.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { cn } from "@/util/util"; -import { useAtomValue } from "jotai"; -import { memo } from "react"; -import { formatFileSize, getFileIcon } from "./ai-utils"; -import type { WaveAIModel } from "./waveai-model"; - -interface AIDroppedFilesProps { - model: WaveAIModel; -} - -export const AIDroppedFiles = memo(({ model }: AIDroppedFilesProps) => { - const droppedFiles = useAtomValue(model.droppedFiles); - - if (droppedFiles.length === 0) { - return null; - } - - return ( - <div className="p-2 border-b border-gray-600"> - <div className="flex gap-2 overflow-x-auto pb-1"> - {droppedFiles.map((file) => ( - <div key={file.id} className="relative bg-zinc-700 rounded-lg p-2 min-w-20 flex-shrink-0 group"> - <button - onClick={() => model.removeFile(file.id)} - className="absolute top-1 right-1 w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer" - > - <i className="fa fa-times text-xs"></i> - </button> - - <div className="flex flex-col items-center text-center"> - {file.previewUrl ? ( - <div className="w-12 h-12 mb-1"> - <img - src={file.previewUrl} - alt={file.name} - className="w-full h-full object-cover rounded" - /> - </div> - ) : ( - <div className="w-12 h-12 mb-1 flex items-center justify-center bg-zinc-600 rounded"> - <i - className={cn("fa text-lg text-gray-300", getFileIcon(file.name, file.type))} - ></i> - </div> - )} - - <div className="text-[10px] text-gray-200 truncate w-full max-w-16" title={file.name}> - {file.name} - </div> - <div className="text-[9px] text-gray-400">{formatFileSize(file.size)}</div> - </div> - </div> - ))} - </div> - </div> - ); -}); - -AIDroppedFiles.displayName = "AIDroppedFiles"; diff --git a/frontend/app/aipanel/aifeedbackbuttons.tsx b/frontend/app/aipanel/aifeedbackbuttons.tsx deleted file mode 100644 index 30d9accc07..0000000000 --- a/frontend/app/aipanel/aifeedbackbuttons.tsx +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { cn, makeIconClass } from "@/util/util"; -import { memo, useState } from "react"; -import { WaveAIModel } from "./waveai-model"; - -interface AIFeedbackButtonsProps { - messageText: string; -} - -export const AIFeedbackButtons = memo(({ messageText }: AIFeedbackButtonsProps) => { - const [thumbsUpClicked, setThumbsUpClicked] = useState(false); - const [thumbsDownClicked, setThumbsDownClicked] = useState(false); - const [copied, setCopied] = useState(false); - - const handleThumbsUp = () => { - setThumbsUpClicked(!thumbsUpClicked); - if (thumbsDownClicked) { - setThumbsDownClicked(false); - } - if (!thumbsUpClicked) { - WaveAIModel.getInstance().handleAIFeedback("good"); - } - }; - - const handleThumbsDown = () => { - setThumbsDownClicked(!thumbsDownClicked); - if (thumbsUpClicked) { - setThumbsUpClicked(false); - } - if (!thumbsDownClicked) { - WaveAIModel.getInstance().handleAIFeedback("bad"); - } - }; - - const handleCopy = () => { - navigator.clipboard.writeText(messageText); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( - <div className="flex items-center gap-0.5 mt-2"> - <button - onClick={handleThumbsUp} - className={cn( - "p-1.5 rounded cursor-pointer transition-colors", - thumbsUpClicked - ? "text-accent" - : "text-secondary hover:bg-zinc-700 hover:text-primary" - )} - title="Good Response" - > - <i className={makeIconClass(thumbsUpClicked ? "solid@thumbs-up" : "regular@thumbs-up", false)} /> - </button> - <button - onClick={handleThumbsDown} - className={cn( - "p-1.5 rounded cursor-pointer transition-colors", - thumbsDownClicked - ? "text-accent" - : "text-secondary hover:bg-zinc-700 hover:text-primary" - )} - title="Bad Response" - > - <i className={makeIconClass(thumbsDownClicked ? "solid@thumbs-down" : "regular@thumbs-down", false)} /> - </button> - {messageText?.trim() && ( - <button - onClick={handleCopy} - className={cn( - "p-1.5 rounded cursor-pointer transition-colors", - copied - ? "text-success" - : "text-secondary hover:bg-zinc-700 hover:text-primary" - )} - title="Copy Message" - > - <i className={makeIconClass(copied ? "solid@check" : "regular@copy", false)} /> - </button> - )} - </div> - ); -}); - -AIFeedbackButtons.displayName = "AIFeedbackButtons"; \ No newline at end of file diff --git a/frontend/app/aipanel/aimessage.tsx b/frontend/app/aipanel/aimessage.tsx deleted file mode 100644 index 1bfadd121d..0000000000 --- a/frontend/app/aipanel/aimessage.tsx +++ /dev/null @@ -1,269 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { WaveStreamdown } from "@/app/element/streamdown"; -import { cn } from "@/util/util"; -import { memo, useEffect, useRef } from "react"; -import { getFileIcon } from "./ai-utils"; -import { AIFeedbackButtons } from "./aifeedbackbuttons"; -import { AIToolUseGroup } from "./aitooluse"; -import { WaveUIMessage, WaveUIMessagePart } from "./aitypes"; -import { WaveAIModel } from "./waveai-model"; - -const AIThinking = memo( - ({ - message = "AI is thinking...", - reasoningText, - isWaitingApproval = false, - }: { - message?: string; - reasoningText?: string; - isWaitingApproval?: boolean; - }) => { - const scrollRef = useRef<HTMLDivElement>(null); - - useEffect(() => { - if (scrollRef.current && reasoningText) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [reasoningText]); - - const displayText = reasoningText - ? (() => { - const lastDoubleNewline = reasoningText.lastIndexOf("\n\n"); - return lastDoubleNewline !== -1 ? reasoningText.substring(lastDoubleNewline + 2) : reasoningText; - })() - : ""; - - return ( - <div className="flex flex-col gap-1"> - <div className="flex items-center gap-2"> - {isWaitingApproval ? ( - <i className="fa fa-clock text-base text-yellow-500"></i> - ) : ( - <div className="animate-pulse flex items-center"> - <i className="fa fa-circle text-[10px]"></i> - <i className="fa fa-circle text-[10px] mx-1"></i> - <i className="fa fa-circle text-[10px]"></i> - </div> - )} - {message && <span className="text-sm text-gray-400">{message}</span>} - </div> - <div ref={scrollRef} className="text-sm text-gray-500 overflow-y-auto h-[3lh] max-w-[600px] pl-9"> - {displayText} - </div> - </div> - ); - } -); - -AIThinking.displayName = "AIThinking"; - -interface UserMessageFilesProps { - fileParts: Array<WaveUIMessagePart & { type: "data-userfile" }>; -} - -const UserMessageFiles = memo(({ fileParts }: UserMessageFilesProps) => { - if (fileParts.length === 0) return null; - - return ( - <div className="mt-2 pt-2 border-t border-gray-600"> - <div className="flex gap-2 overflow-x-auto pb-1"> - {fileParts.map((file, index) => ( - <div key={index} className="relative bg-zinc-700 rounded-lg p-2 min-w-20 flex-shrink-0"> - <div className="flex flex-col items-center text-center"> - <div className="w-12 h-12 mb-1 flex items-center justify-center bg-zinc-600 rounded overflow-hidden"> - {file.data?.previewurl ? ( - <img - src={file.data.previewurl} - alt={file.data?.filename || "File"} - className="w-full h-full object-cover" - /> - ) : ( - <i - className={cn( - "fa text-lg text-gray-300", - getFileIcon(file.data?.filename || "", file.data?.mimetype || "") - )} - ></i> - )} - </div> - <div - className="text-[10px] text-gray-200 truncate w-full max-w-16" - title={file.data?.filename || "File"} - > - {file.data?.filename || "File"} - </div> - </div> - </div> - ))} - </div> - </div> - ); -}); - -UserMessageFiles.displayName = "UserMessageFiles"; - -interface AIMessagePartProps { - part: WaveUIMessagePart; - role: string; - isStreaming: boolean; -} - -const AIMessagePart = memo(({ part, role, isStreaming }: AIMessagePartProps) => { - const model = WaveAIModel.getInstance(); - - if (part.type === "text") { - const content = part.text ?? ""; - - if (role === "user") { - return <div className="whitespace-pre-wrap break-words">{content}</div>; - } else { - return ( - <WaveStreamdown - text={content} - parseIncompleteMarkdown={isStreaming} - className="text-gray-100" - codeBlockMaxWidthAtom={model.codeBlockMaxWidth} - /> - ); - } - } - - return null; -}); - -AIMessagePart.displayName = "AIMessagePart"; - -interface AIMessageProps { - message: WaveUIMessage; - isStreaming: boolean; -} - -const isDisplayPart = (part: WaveUIMessagePart): boolean => { - return ( - part.type === "text" || - part.type === "data-tooluse" || - part.type === "data-toolprogress" || - (part.type.startsWith("tool-") && "state" in part && part.state === "input-available") - ); -}; - -type MessagePart = - | { type: "single"; part: WaveUIMessagePart } - | { type: "toolgroup"; parts: Array<WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }> }; - -const groupMessageParts = (parts: WaveUIMessagePart[]): MessagePart[] => { - const grouped: MessagePart[] = []; - let currentToolGroup: Array<WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }> = []; - - for (const part of parts) { - if (part.type === "data-tooluse" || part.type === "data-toolprogress") { - currentToolGroup.push(part as WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }); - } else { - if (currentToolGroup.length > 0) { - grouped.push({ type: "toolgroup", parts: currentToolGroup }); - currentToolGroup = []; - } - grouped.push({ type: "single", part }); - } - } - - if (currentToolGroup.length > 0) { - grouped.push({ type: "toolgroup", parts: currentToolGroup }); - } - - return grouped; -}; - -const getThinkingMessage = ( - parts: WaveUIMessagePart[], - isStreaming: boolean, - role: string -): { message: string; reasoningText?: string; isWaitingApproval?: boolean } | null => { - if (!isStreaming || role !== "assistant") { - return null; - } - - const hasPendingApprovals = parts.some( - (part) => part.type === "data-tooluse" && part.data?.approval === "needs-approval" - ); - - if (hasPendingApprovals) { - return { message: "Waiting for Tool Approvals...", isWaitingApproval: true }; - } - - const lastPart = parts[parts.length - 1]; - - if (lastPart?.type === "reasoning") { - const reasoningContent = lastPart.text || ""; - return { message: "AI is thinking...", reasoningText: reasoningContent }; - } - - if (lastPart?.type === "text" && lastPart.text) { - return null; - } - - return { message: "" }; -}; - -export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => { - const parts = message.parts || []; - const displayParts = parts.filter(isDisplayPart); - const fileParts = parts.filter( - (part): part is WaveUIMessagePart & { type: "data-userfile" } => part.type === "data-userfile" - ); - - const thinkingData = getThinkingMessage(parts, isStreaming, message.role); - const groupedParts = groupMessageParts(displayParts); - - return ( - <div className={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}> - <div - className={cn( - "px-2 rounded-lg [&>*:first-child]:!mt-0", - message.role === "user" - ? "py-2 bg-zinc-700/60 text-white max-w-[calc(100%-50px)]" - : "min-w-[min(100%,500px)]" - )} - > - {displayParts.length === 0 && !isStreaming && !thinkingData ? ( - <div className="whitespace-pre-wrap break-words">(no text content)</div> - ) : ( - <> - {groupedParts.map((group, index: number) => - group.type === "toolgroup" ? ( - <AIToolUseGroup key={index} parts={group.parts} isStreaming={isStreaming} /> - ) : ( - <div key={index} className="mt-2"> - <AIMessagePart part={group.part} role={message.role} isStreaming={isStreaming} /> - </div> - ) - )} - {thinkingData != null && ( - <div className="mt-2"> - <AIThinking - message={thinkingData.message} - reasoningText={thinkingData.reasoningText} - isWaitingApproval={thinkingData.isWaitingApproval} - /> - </div> - )} - </> - )} - - {message.role === "user" && <UserMessageFiles fileParts={fileParts} />} - {message.role === "assistant" && !isStreaming && displayParts.length > 0 && ( - <AIFeedbackButtons - messageText={parts - .filter((p) => p.type === "text") - .map((p) => p.text || "") - .join("\n\n")} - /> - )} - </div> - </div> - ); -}); - -AIMessage.displayName = "AIMessage"; diff --git a/frontend/app/aipanel/aimode.tsx b/frontend/app/aipanel/aimode.tsx deleted file mode 100644 index 3602cdd360..0000000000 --- a/frontend/app/aipanel/aimode.tsx +++ /dev/null @@ -1,329 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { Tooltip } from "@/app/element/tooltip"; -import { atoms, getSettingsKeyAtom } from "@/app/store/global"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { cn, fireAndForget, makeIconClass } from "@/util/util"; -import { useAtomValue } from "jotai"; -import { memo, useRef, useState } from "react"; -import { getFilteredAIModeConfigs, getModeDisplayName } from "./ai-utils"; -import { WaveAIModel } from "./waveai-model"; - -interface AIModeMenuItemProps { - config: AIModeConfigWithMode; - isSelected: boolean; - isDisabled: boolean; - isPremiumDisabled: boolean; - onClick: () => void; - isFirst?: boolean; - isLast?: boolean; -} - -const AIModeMenuItem = memo(({ config, isSelected, isDisabled, isPremiumDisabled, onClick, isFirst, isLast }: AIModeMenuItemProps) => { - return ( - <button - key={config.mode} - onClick={onClick} - disabled={isDisabled} - className={cn( - "w-full flex flex-col gap-0.5 px-3 transition-colors text-left", - isFirst ? "pt-1 pb-0.5" : isLast ? "pt-0.5 pb-1" : "pt-0.5 pb-0.5", - isDisabled ? "text-zinc-500" : "text-zinc-300 hover:bg-zinc-700 cursor-pointer" - )} - > - <div className="flex items-center gap-2 w-full"> - <i className={makeIconClass(config["display:icon"] || "sparkles", false)}></i> - <span className={cn("text-sm", isSelected && "font-bold")}> - {getModeDisplayName(config)} - {isPremiumDisabled && " (premium)"} - </span> - {isSelected && <i className="fa fa-check ml-auto"></i>} - </div> - {config["display:description"] && ( - <div - className={cn("text-xs pl-5", isDisabled ? "text-gray-500" : "text-muted")} - style={{ whiteSpace: "pre-line" }} - > - {config["display:description"]} - </div> - )} - </button> - ); -}); - -AIModeMenuItem.displayName = "AIModeMenuItem"; - -interface ConfigSection { - sectionName: string; - configs: AIModeConfigWithMode[]; - isIncompatible?: boolean; - noTelemetry?: boolean; -} - -function computeCompatibleSections( - currentMode: string, - aiModeConfigs: Record<string, AIModeConfigType>, - waveProviderConfigs: AIModeConfigWithMode[], - otherProviderConfigs: AIModeConfigWithMode[] -): ConfigSection[] { - const currentConfig = aiModeConfigs[currentMode]; - const allConfigs = [...waveProviderConfigs, ...otherProviderConfigs]; - - if (!currentConfig) { - return [{ sectionName: "Incompatible Modes", configs: allConfigs, isIncompatible: true }]; - } - - const currentSwitchCompat = currentConfig["ai:switchcompat"] || []; - const compatibleConfigs: AIModeConfigWithMode[] = [{ ...currentConfig, mode: currentMode }]; - const incompatibleConfigs: AIModeConfigWithMode[] = []; - - if (currentSwitchCompat.length === 0) { - allConfigs.forEach((config) => { - if (config.mode !== currentMode) { - incompatibleConfigs.push(config); - } - }); - } else { - allConfigs.forEach((config) => { - if (config.mode === currentMode) return; - - const configSwitchCompat = config["ai:switchcompat"] || []; - const hasMatch = currentSwitchCompat.some((currentTag: string) => configSwitchCompat.includes(currentTag)); - - if (hasMatch) { - compatibleConfigs.push(config); - } else { - incompatibleConfigs.push(config); - } - }); - } - - const sections: ConfigSection[] = []; - const compatibleSectionName = compatibleConfigs.length === 1 ? "Current" : "Compatible Modes"; - sections.push({ sectionName: compatibleSectionName, configs: compatibleConfigs }); - - if (incompatibleConfigs.length > 0) { - sections.push({ sectionName: "Incompatible Modes", configs: incompatibleConfigs, isIncompatible: true }); - } - - return sections; -} - -function computeWaveCloudSections( - waveProviderConfigs: AIModeConfigWithMode[], - otherProviderConfigs: AIModeConfigWithMode[], - telemetryEnabled: boolean -): ConfigSection[] { - const sections: ConfigSection[] = []; - - if (waveProviderConfigs.length > 0) { - sections.push({ - sectionName: "Wave AI Cloud", - configs: waveProviderConfigs, - noTelemetry: !telemetryEnabled, - }); - } - if (otherProviderConfigs.length > 0) { - sections.push({ sectionName: "Custom", configs: otherProviderConfigs }); - } - - return sections; -} - -interface AIModeDropdownProps { - compatibilityMode?: boolean; -} - -export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdownProps) => { - const model = WaveAIModel.getInstance(); - const currentMode = useAtomValue(model.currentAIMode); - const aiModeConfigs = useAtomValue(model.aiModeConfigs); - const waveaiModeConfigs = useAtomValue(atoms.waveaiModeConfigAtom); - const widgetContextEnabled = useAtomValue(model.widgetAccessAtom); - const hasPremium = useAtomValue(model.hasPremiumAtom); - const showCloudModes = useAtomValue(getSettingsKeyAtom("waveai:showcloudmodes")); - const telemetryEnabled = useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef<HTMLDivElement>(null); - - const { waveProviderConfigs, otherProviderConfigs } = getFilteredAIModeConfigs( - aiModeConfigs, - showCloudModes, - model.inBuilder, - hasPremium, - currentMode - ); - - const sections: ConfigSection[] = compatibilityMode - ? computeCompatibleSections(currentMode, aiModeConfigs, waveProviderConfigs, otherProviderConfigs) - : computeWaveCloudSections(waveProviderConfigs, otherProviderConfigs, telemetryEnabled); - - const showSectionHeaders = compatibilityMode || sections.length > 1; - - const handleSelect = (mode: string) => { - const config = aiModeConfigs[mode]; - if (!config) return; - if (!hasPremium && config["waveai:premium"]) { - return; - } - model.setAIMode(mode); - setIsOpen(false); - }; - - const displayConfig = aiModeConfigs[currentMode]; - const displayName = displayConfig ? getModeDisplayName(displayConfig) : `Invalid (${currentMode})`; - const displayIcon = displayConfig ? displayConfig["display:icon"] || "sparkles" : "question"; - const resolvedConfig = waveaiModeConfigs[currentMode]; - const hasToolsSupport = resolvedConfig && resolvedConfig["ai:capabilities"]?.includes("tools"); - const showNoToolsWarning = widgetContextEnabled && resolvedConfig && !hasToolsSupport; - - const handleNewChatClick = () => { - model.clearChat(); - setIsOpen(false); - }; - - const handleConfigureClick = () => { - fireAndForget(async () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "action:other", - props: { - "action:type": "waveai:configuremodes:contextmenu", - }, - }, - { noresponse: true } - ); - await model.openWaveAIConfig(); - setIsOpen(false); - }); - }; - - const handleEnableTelemetry = () => { - fireAndForget(async () => { - await RpcApi.WaveAIEnableTelemetryCommand(TabRpcClient); - setTimeout(() => { - model.focusInput(); - }, 100); - }); - }; - - return ( - <div className="relative" ref={dropdownRef}> - <button - onClick={() => setIsOpen(!isOpen)} - className={cn( - "group flex items-center gap-1.5 px-2 py-1 text-xs text-gray-300 hover:text-white rounded transition-colors cursor-pointer border border-gray-600/50", - isOpen ? "bg-zinc-700" : "bg-zinc-800/50 hover:bg-zinc-700" - )} - title={`AI Mode: ${displayName}`} - > - <i className={cn(makeIconClass(displayIcon, false), "text-[10px]")}></i> - <span className={`text-[11px]`}>{displayName}</span> - <i className="fa fa-chevron-down text-[8px]"></i> - </button> - - {showNoToolsWarning && ( - <Tooltip - content={ - <div className="max-w-xs"> - Warning: This custom mode was configured without the "tools" capability in the - "ai:capabilities" array. Without tool support, Wave AI will not be able to interact with - widgets or files. - </div> - } - placement="bottom" - > - <div className="flex items-center gap-1 text-[10px] text-yellow-600 mt-1 ml-1 cursor-default"> - <i className="fa fa-triangle-exclamation"></i> - <span>No Tools Support</span> - </div> - </Tooltip> - )} - - {isOpen && ( - <> - <div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} /> - <div className="absolute top-full left-0 mt-1 bg-zinc-800 border border-zinc-600 rounded shadow-lg z-50 min-w-[280px]"> - {sections.map((section, sectionIndex) => { - const isFirstSection = sectionIndex === 0; - const isLastSection = sectionIndex === sections.length - 1; - - return ( - <div key={section.sectionName}> - {!isFirstSection && <div className="border-t border-gray-600 my-2" />} - {showSectionHeaders && ( - <> - <div - className={cn( - "pb-1 text-center text-[10px] text-gray-400 uppercase tracking-wide", - isFirstSection ? "pt-2" : "pt-0" - )} - > - {section.sectionName} - </div> - {section.isIncompatible && ( - <div className="text-center text-[11px] text-red-300 pb-1"> - (Start a New Chat to Switch) - </div> - )} - {section.noTelemetry && ( - <button - onClick={handleEnableTelemetry} - className="text-center text-[11px] text-green-300 hover:text-green-200 pb-1 cursor-pointer transition-colors w-full" - > - (enable telemetry to unlock Wave AI Cloud) - </button> - )} - </> - )} - {section.configs.map((config, index) => { - const isFirst = index === 0 && isFirstSection && !showSectionHeaders; - const isLast = index === section.configs.length - 1 && isLastSection; - const isPremiumDisabled = !hasPremium && config["waveai:premium"]; - const isIncompatibleDisabled = section.isIncompatible || false; - const isTelemetryDisabled = section.noTelemetry || false; - const isDisabled = - isPremiumDisabled || isIncompatibleDisabled || isTelemetryDisabled; - const isSelected = currentMode === config.mode; - return ( - <AIModeMenuItem - key={config.mode} - config={config} - isSelected={isSelected} - isDisabled={isDisabled} - isPremiumDisabled={isPremiumDisabled} - onClick={() => handleSelect(config.mode)} - isFirst={isFirst} - isLast={isLast} - /> - ); - })} - </div> - ); - })} - <div className="border-t border-gray-600 my-1" /> - <button - onClick={handleNewChatClick} - className="w-full flex items-center gap-2 px-3 pt-1 pb-1 text-gray-300 hover:bg-zinc-700 cursor-pointer transition-colors text-left" - > - <i className={makeIconClass("plus", false)}></i> - <span className="text-sm">New Chat</span> - </button> - <button - onClick={handleConfigureClick} - className="w-full flex items-center gap-2 px-3 pt-1 pb-2 text-gray-300 hover:bg-zinc-700 cursor-pointer transition-colors text-left" - > - <i className={makeIconClass("gear", false)}></i> - <span className="text-sm">Configure Modes</span> - </button> - </div> - </> - )} - </div> - ); -}); - -AIModeDropdown.displayName = "AIModeDropdown"; diff --git a/frontend/app/aipanel/aipanel-contextmenu.ts b/frontend/app/aipanel/aipanel-contextmenu.ts deleted file mode 100644 index 4e78389198..0000000000 --- a/frontend/app/aipanel/aipanel-contextmenu.ts +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils"; -import { ContextMenuModel } from "@/app/store/contextmenu"; -import { isDev } from "@/app/store/global"; -import { globalStore } from "@/app/store/jotaiStore"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { WaveAIModel } from "./waveai-model"; - -export async function handleWaveAIContextMenu(e: React.MouseEvent, showCopy: boolean): Promise<void> { - e.preventDefault(); - e.stopPropagation(); - - const model = WaveAIModel.getInstance(); - const menu: ContextMenuItem[] = []; - - if (showCopy) { - const hasSelection = waveAIHasSelection(); - if (hasSelection) { - menu.push({ - role: "copy", - }); - menu.push({ type: "separator" }); - } - } - - menu.push({ - label: "New Chat", - click: () => { - model.clearChat(); - }, - }); - - menu.push({ type: "separator" }); - - const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - }); - - const defaultTokens = model.inBuilder ? 24576 : 4096; - const currentMaxTokens = rtInfo?.["waveai:maxoutputtokens"] ?? defaultTokens; - - const maxTokensSubmenu: ContextMenuItem[] = []; - - if (model.inBuilder) { - maxTokensSubmenu.push( - { - label: "24k", - type: "checkbox", - checked: currentMaxTokens === 24576, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 24576 }, - }); - }, - }, - { - label: "64k (Pro)", - type: "checkbox", - checked: currentMaxTokens === 65536, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 65536 }, - }); - }, - } - ); - } else { - if (isDev()) { - maxTokensSubmenu.push({ - label: "1k (Dev Testing)", - type: "checkbox", - checked: currentMaxTokens === 1024, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 1024 }, - }); - }, - }); - } - maxTokensSubmenu.push( - { - label: "4k", - type: "checkbox", - checked: currentMaxTokens === 4096, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 4096 }, - }); - }, - }, - { - label: "16k (Pro)", - type: "checkbox", - checked: currentMaxTokens === 16384, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 16384 }, - }); - }, - }, - { - label: "64k (Pro)", - type: "checkbox", - checked: currentMaxTokens === 65536, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 65536 }, - }); - }, - } - ); - } - - menu.push({ - label: "Max Output Tokens", - submenu: maxTokensSubmenu, - }); - - menu.push({ type: "separator" }); - - menu.push({ - label: "Configure Modes", - click: () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "action:other", - props: { - "action:type": "waveai:configuremodes:contextmenu", - }, - }, - { noresponse: true } - ); - model.openWaveAIConfig(); - }, - }); - - if (model.canCloseWaveAIPanel()) { - menu.push({ type: "separator" }); - - menu.push({ - label: "Hide Wave AI", - click: () => { - model.closeWaveAIPanel(); - }, - }); - } - - ContextMenuModel.getInstance().showContextMenu(menu, e); -} diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx deleted file mode 100644 index 32b8582141..0000000000 --- a/frontend/app/aipanel/aipanel.tsx +++ /dev/null @@ -1,636 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { handleWaveAIContextMenu } from "@/app/aipanel/aipanel-contextmenu"; -import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils"; -import { useTabBackground } from "@/app/block/blockutil"; -import { ErrorBoundary } from "@/app/element/errorboundary"; -import { atoms, getSettingsKeyAtom } from "@/app/store/global"; -import { globalStore } from "@/app/store/jotaiStore"; -import { useTabModelMaybe } from "@/app/store/tab-model"; -import { isBuilderWindow } from "@/app/store/windowtype"; -import { useWaveEnv } from "@/app/waveenv/waveenv"; -import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; -import { isMacOS, isWindows } from "@/util/platformutil"; -import { cn } from "@/util/util"; -import { useChat } from "@ai-sdk/react"; -import { DefaultChatTransport } from "ai"; -import * as jotai from "jotai"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; -import { useDrop } from "react-dnd"; -import { formatFileSizeError, isAcceptableFile, validateFileSize } from "./ai-utils"; -import { AIDroppedFiles } from "./aidroppedfiles"; -import { AIModeDropdown } from "./aimode"; -import { AIPanelHeader } from "./aipanelheader"; -import { AIPanelInput } from "./aipanelinput"; -import { AIPanelMessages } from "./aipanelmessages"; -import { AIRateLimitStrip } from "./airatelimitstrip"; -import { WaveUIMessage } from "./aitypes"; -import { BYOKAnnouncement } from "./byokannouncement"; -import { TelemetryRequiredMessage } from "./telemetryrequired"; -import { WaveAIModel } from "./waveai-model"; - -const AIBlockMask = memo(() => { - return ( - <div - key="block-mask" - className="absolute top-0 left-0 right-0 bottom-0 border-1 border-transparent pointer-events-auto select-none p-0.5" - style={{ - borderRadius: "var(--block-border-radius)", - zIndex: "var(--zindex-block-mask-inner)", - }} - > - <div - className="w-full mt-[44px] h-[calc(100%-44px)] flex items-center justify-center" - style={{ - backgroundColor: "rgb(from var(--block-bg-color) r g b / 50%)", - }} - > - <div className="font-bold opacity-70 mt-[-25%] text-[60px]">0</div> - </div> - </div> - ); -}); - -AIBlockMask.displayName = "AIBlockMask"; - -const AIDragOverlay = memo(() => { - return ( - <div - key="drag-overlay" - className="absolute inset-0 bg-accent/20 border-2 border-dashed border-accent rounded-lg flex items-center justify-center z-10 p-4" - > - <div className="text-accent text-center"> - <i className="fa fa-upload text-3xl mb-2"></i> - <div className="text-lg font-semibold">Drop files here</div> - <div className="text-sm">Images, PDFs, and text/code files supported</div> - </div> - </div> - ); -}); - -AIDragOverlay.displayName = "AIDragOverlay"; - -const KeyCap = memo(({ children, className }: { children: React.ReactNode; className?: string }) => { - return ( - <kbd - className={cn( - "px-1.5 py-0.5 text-xs bg-zinc-700 border border-zinc-600 rounded-sm shadow-sm font-mono", - className - )} - > - {children} - </kbd> - ); -}); - -KeyCap.displayName = "KeyCap"; - -const AIWelcomeMessage = memo(() => { - const modKey = isMacOS() ? "⌘" : "Alt"; - const aiModeConfigs = jotai.useAtomValue(atoms.waveaiModeConfigAtom); - const hasCustomModes = Object.keys(aiModeConfigs).some((key) => !key.startsWith("waveai@")); - return ( - <div className="text-secondary py-8"> - <div className="text-center"> - <i className="fa fa-sparkles text-4xl text-accent mb-2 block"></i> - <p className="text-lg font-bold text-primary">Welcome to Wave AI</p> - </div> - <div className="mt-4 text-left max-w-md mx-auto"> - <p className="text-sm mb-6"> - Wave AI is your terminal assistant with context. I can read your terminal output, analyze widgets, - access files, and help you solve problems faster. - </p> - <div className="bg-accent/10 border border-accent/30 rounded-lg p-4"> - <div className="text-sm font-semibold mb-3 text-accent">Getting Started:</div> - <div className="space-y-3 text-sm"> - <div className="flex items-start gap-3"> - <div className="w-4 text-center flex-shrink-0"> - <i className="fa-solid fa-plug text-accent"></i> - </div> - <div> - <span className="font-bold">Widget Context</span> - <div className="">When ON, I can read your terminal and analyze widgets.</div> - <div className="">When OFF, I'm sandboxed with no system access.</div> - </div> - </div> - <div className="flex items-start gap-3"> - <div className="w-4 text-center flex-shrink-0"> - <i className="fa-solid fa-file-import text-accent"></i> - </div> - <div>Drag & drop files or images for analysis</div> - </div> - <div className="flex items-start gap-3"> - <div className="w-4 text-center flex-shrink-0"> - <i className="fa-solid fa-keyboard text-accent"></i> - </div> - <div className="space-y-1"> - <div> - <KeyCap>{modKey}</KeyCap> - <KeyCap className="ml-1">K</KeyCap> - <span className="ml-1.5">to start a new chat</span> - </div> - <div> - <KeyCap>{modKey}</KeyCap> - <KeyCap className="ml-1">Shift</KeyCap> - <KeyCap className="ml-1">A</KeyCap> - <span className="ml-1.5">to toggle panel</span> - </div> - <div> - {isWindows() ? ( - <> - <KeyCap>Alt</KeyCap> - <KeyCap className="ml-1">0</KeyCap> - <span className="ml-1.5">to focus</span> - </> - ) : ( - <> - <KeyCap>Ctrl</KeyCap> - <KeyCap className="ml-1">Shift</KeyCap> - <KeyCap className="ml-1">0</KeyCap> - <span className="ml-1.5">to focus</span> - </> - )} - </div> - </div> - </div> - <div className="flex items-start gap-3"> - <div className="w-4 text-center flex-shrink-0"> - <i className="fa-brands fa-discord text-accent"></i> - </div> - <div> - Questions or feedback?{" "} - <a - target="_blank" - href="https://discord.gg/XfvZ334gwU" - rel="noopener" - className="text-accent hover:underline cursor-pointer" - > - Join our Discord - </a> - </div> - </div> - </div> - </div> - {!hasCustomModes && <BYOKAnnouncement />} - <div className="mt-4 text-center text-[12px] text-muted"> - BETA: Free to use. Daily limits keep our costs in check. - </div> - </div> - </div> - ); -}); - -AIWelcomeMessage.displayName = "AIWelcomeMessage"; - -const AIBuilderWelcomeMessage = memo(() => { - return ( - <div className="text-secondary py-8"> - <div className="text-center"> - <i className="fa fa-sparkles text-4xl text-accent mb-4 block"></i> - <p className="text-lg font-bold text-primary">WaveApp Builder</p> - </div> - <div className="mt-4 text-left max-w-md mx-auto"> - <p className="text-sm mb-6"> - The WaveApp builder helps create wave widgets that integrate seamlessly into Wave Terminal. - </p> - </div> - </div> - ); -}); - -AIBuilderWelcomeMessage.displayName = "AIBuilderWelcomeMessage"; - -const AIErrorMessage = memo(() => { - const model = WaveAIModel.getInstance(); - const errorMessage = jotai.useAtomValue(model.errorMessage); - - if (!errorMessage) { - return null; - } - - return ( - <div className="px-4 py-2 text-red-400 bg-red-900/20 border-l-4 border-red-500 mx-2 mb-2 relative"> - <button - onClick={() => model.clearError()} - className="absolute top-2 right-2 text-red-400 hover:text-red-300 cursor-pointer z-10" - aria-label="Close error" - > - <i className="fa fa-times text-sm"></i> - </button> - <div className="text-sm pr-6 max-h-[100px] overflow-y-auto"> - {errorMessage} - <button - onClick={() => model.clearChat()} - className="ml-2 text-xs text-red-300 hover:text-red-200 cursor-pointer underline" - > - New Chat - </button> - </div> - </div> - ); -}); - -AIErrorMessage.displayName = "AIErrorMessage"; - -const ConfigChangeModeFixer = memo(() => { - const model = WaveAIModel.getInstance(); - const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; - const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs); - - useEffect(() => { - model.fixModeAfterConfigChange(); - }, [telemetryEnabled, aiModeConfigs, model]); - - return null; -}); - -ConfigChangeModeFixer.displayName = "ConfigChangeModeFixer"; - -type AIPanelComponentInnerProps = { - roundTopLeft: boolean; -}; - -const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps) => { - const [isDragOver, setIsDragOver] = useState(false); - const [isReactDndDragOver, setIsReactDndDragOver] = useState(false); - const [initialLoadDone, setInitialLoadDone] = useState(false); - const model = WaveAIModel.getInstance(); - const containerRef = useRef<HTMLDivElement>(null); - const waveEnv = useWaveEnv(); - const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); - const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; - const isFocused = jotai.useAtomValue(model.isWaveAIFocusedAtom); - const focusFollowsCursorMode = jotai.useAtomValue(getSettingsKeyAtom("app:focusfollowscursor")) ?? "off"; - const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; - const isPanelVisible = jotai.useAtomValue(model.getPanelVisibleAtom()); - const tabModel = useTabModelMaybe(); - const [tabBorderColor, tabActiveBorderColor] = useTabBackground(waveEnv, tabModel?.tabId); - const defaultMode = jotai.useAtomValue(getSettingsKeyAtom("waveai:defaultmode")) ?? "waveai@balanced"; - const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs); - - const hasCustomModes = Object.keys(aiModeConfigs).some((key) => !key.startsWith("waveai@")); - const isUsingCustomMode = !defaultMode.startsWith("waveai@"); - const allowAccess = telemetryEnabled || (hasCustomModes && isUsingCustomMode); - - const { messages, sendMessage, status, setMessages, error, stop } = useChat<WaveUIMessage>({ - transport: new DefaultChatTransport({ - api: model.getUseChatEndpointUrl(), - prepareSendMessagesRequest: (_opts) => { - const msg = model.getAndClearMessage(); - const body: any = { - msg, - chatid: globalStore.get(model.chatId), - widgetaccess: globalStore.get(model.widgetAccessAtom), - aimode: globalStore.get(model.currentAIMode), - }; - if (isBuilderWindow()) { - body.builderid = globalStore.get(atoms.builderId); - body.builderappid = globalStore.get(atoms.builderAppId); - } else { - body.tabid = tabModel.tabId; - } - return { body }; - }, - }), - onError: (error) => { - console.error("AI Chat error:", error); - model.setError(error.message || "An error occurred"); - }, - }); - - model.registerUseChatData(sendMessage, setMessages, status, stop); - - // console.log("AICHAT messages", messages); - (window as any).aichatmessages = messages; - (window as any).aichatstatus = status; - - const handleKeyDown = (waveEvent: WaveKeyboardEvent): boolean => { - if (checkKeyPressed(waveEvent, "Cmd:k")) { - model.clearChat(); - return true; - } - return false; - }; - - useEffect(() => { - globalStore.set(model.isAIStreaming, status === "streaming" || status === "submitted"); - }, [status]); - - useEffect(() => { - const keyHandler = keydownWrapper(handleKeyDown); - document.addEventListener("keydown", keyHandler); - return () => { - document.removeEventListener("keydown", keyHandler); - }; - }, []); - - useEffect(() => { - const loadChat = async () => { - await model.uiLoadInitialChat(); - setInitialLoadDone(true); - }; - loadChat(); - }, [model]); - - useEffect(() => { - const updateWidth = () => { - if (containerRef.current) { - globalStore.set(model.containerWidth, containerRef.current.offsetWidth); - } - }; - - updateWidth(); - - const resizeObserver = new ResizeObserver(updateWidth); - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } - - return () => { - resizeObserver.disconnect(); - }; - }, [model]); - - useEffect(() => { - model.ensureRateLimitSet(); - }, [model]); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - await model.handleSubmit(); - setTimeout(() => { - model.focusInput(); - }, 100); - }; - - const hasFilesDragged = (dataTransfer: DataTransfer): boolean => { - // Check if the drag operation contains files by looking at the types - return dataTransfer.types.includes("Files"); - }; - - const handleDragOver = (e: React.DragEvent) => { - if (!allowAccess) { - return; - } - - const hasFiles = hasFilesDragged(e.dataTransfer); - - // Only handle native file drags here, let react-dnd handle FILE_ITEM drags - if (!hasFiles) { - return; - } - - e.preventDefault(); - e.stopPropagation(); - - if (!isDragOver) { - setIsDragOver(true); - } - }; - - const handleDragEnter = (e: React.DragEvent) => { - if (!allowAccess) { - return; - } - - const hasFiles = hasFilesDragged(e.dataTransfer); - - // Only handle native file drags here, let react-dnd handle FILE_ITEM drags - if (!hasFiles) { - return; - } - - e.preventDefault(); - e.stopPropagation(); - - setIsDragOver(true); - }; - - const handleDragLeave = (e: React.DragEvent) => { - if (!allowAccess) { - return; - } - - const hasFiles = hasFilesDragged(e.dataTransfer); - - // Only handle native file drags here, let react-dnd handle FILE_ITEM drags - if (!hasFiles) { - return; - } - - e.preventDefault(); - e.stopPropagation(); - - // Only set drag over to false if we're actually leaving the drop zone - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const x = e.clientX; - const y = e.clientY; - - if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { - setIsDragOver(false); - } - }; - - const handleDrop = async (e: React.DragEvent) => { - if (!allowAccess) { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - return; - } - - // Check if this is a FILE_ITEM drag from react-dnd - // If so, let react-dnd handle it instead - if (!e.dataTransfer.files.length) { - return; // Let react-dnd handle FILE_ITEM drags - } - - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - - const files = Array.from(e.dataTransfer.files); - const acceptableFiles = files.filter(isAcceptableFile); - - for (const file of acceptableFiles) { - const sizeError = validateFileSize(file); - if (sizeError) { - model.setError(formatFileSizeError(sizeError)); - return; - } - await model.addFile(file); - } - - if (acceptableFiles.length < files.length) { - const rejectedCount = files.length - acceptableFiles.length; - const rejectedFiles = files.filter((f) => !isAcceptableFile(f)); - const fileNames = rejectedFiles.map((f) => f.name).join(", "); - model.setError( - `${rejectedCount} file${rejectedCount > 1 ? "s" : ""} rejected (unsupported type): ${fileNames}. Supported: images, PDFs, and text/code files.` - ); - } - }; - - const handleFileItemDrop = useCallback( - (draggedFile: DraggedFile) => { - if (!allowAccess) { - return; - } - model.addFileFromRemoteUri(draggedFile); - }, - [model, allowAccess] - ); - - const [{ isOver, canDrop }, drop] = useDrop( - () => ({ - accept: "FILE_ITEM", - drop: handleFileItemDrop, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), - }), - }), - [handleFileItemDrop] - ); - - // Update drag over state for FILE_ITEM drags - useEffect(() => { - if (isOver && canDrop) { - setIsReactDndDragOver(true); - } else { - setIsReactDndDragOver(false); - } - }, [isOver, canDrop]); - - // Attach the drop ref to the container - useEffect(() => { - if (containerRef.current) { - drop(containerRef.current); - } - }, [drop]); - - const handleFocusCapture = useCallback( - (_event: React.FocusEvent) => { - // console.log("Wave AI focus capture", getElemAsStr(event.target)); - model.requestWaveAIFocus(); - }, - [model] - ); - - const handlePointerEnter = useCallback( - (event: React.PointerEvent<HTMLDivElement>) => { - if (focusFollowsCursorMode !== "on") return; - if (event.pointerType === "touch" || event.buttons > 0) return; - if (isFocused) return; - model.focusInput(); - }, - [focusFollowsCursorMode, isFocused, model] - ); - - const handleClick = (e: React.MouseEvent) => { - const target = e.target as HTMLElement; - const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]'); - - if (isInteractive) { - return; - } - - const hasSelection = waveAIHasSelection(); - if (hasSelection) { - model.requestWaveAIFocus(); - return; - } - - setTimeout(() => { - if (!waveAIHasSelection()) { - model.focusInput(); - } - }, 0); - }; - - const showBlockMask = isLayoutMode && showOverlayBlockNums; - const borderColor = isFocused ? (tabActiveBorderColor ?? null) : (tabBorderColor ?? null); - - return ( - <div - ref={containerRef} - data-waveai-panel="true" - className={cn( - "@container bg-zinc-900/70 flex flex-col relative", - model.inBuilder ? "mt-0 h-full" : "mt-1 h-[calc(100%-4px)]", - (isDragOver || isReactDndDragOver) && "bg-zinc-800 border-accent", - isFocused && !borderColor ? "border-2 border-accent" : "border-2 border-transparent" - )} - style={{ - borderTopLeftRadius: roundTopLeft ? 10 : 0, - borderTopRightRadius: model.inBuilder ? 0 : 10, - borderBottomRightRadius: model.inBuilder ? 0 : 10, - borderBottomLeftRadius: 10, - borderColor: borderColor ?? undefined, - }} - onFocusCapture={handleFocusCapture} - onPointerEnter={handlePointerEnter} - onDragOver={handleDragOver} - onDragEnter={handleDragEnter} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - onClick={handleClick} - inert={!isPanelVisible ? true : undefined} - data-aipanel="true" - > - <ConfigChangeModeFixer /> - {(isDragOver || isReactDndDragOver) && allowAccess && <AIDragOverlay />} - {showBlockMask && <AIBlockMask />} - <AIPanelHeader /> - <AIRateLimitStrip /> - - <div key="main-content" className="flex-1 flex flex-col min-h-0"> - {!allowAccess ? ( - <TelemetryRequiredMessage /> - ) : ( - <> - {messages.length === 0 && initialLoadDone ? ( - <div - className="flex-1 overflow-y-auto p-2 relative" - onContextMenu={(e) => handleWaveAIContextMenu(e, true)} - > - <div className="absolute top-2 left-2 z-10"> - <AIModeDropdown /> - </div> - {model.inBuilder ? <AIBuilderWelcomeMessage /> : <AIWelcomeMessage />} - </div> - ) : ( - <AIPanelMessages - messages={messages} - status={status} - onContextMenu={(e) => handleWaveAIContextMenu(e, true)} - /> - )} - <AIErrorMessage /> - <AIDroppedFiles model={model} /> - <AIPanelInput onSubmit={handleSubmit} status={status} model={model} /> - </> - )} - </div> - </div> - ); -}); - -AIPanelComponentInner.displayName = "AIPanelInner"; - -type AIPanelComponentProps = { - roundTopLeft: boolean; -}; - -const AIPanelComponent = ({ roundTopLeft }: AIPanelComponentProps) => { - return ( - <ErrorBoundary> - <AIPanelComponentInner roundTopLeft={roundTopLeft} /> - </ErrorBoundary> - ); -}; - -AIPanelComponent.displayName = "AIPanel"; - -export { AIPanelComponent as AIPanel }; diff --git a/frontend/app/aipanel/aipanelheader.tsx b/frontend/app/aipanel/aipanelheader.tsx deleted file mode 100644 index da54f6c9e9..0000000000 --- a/frontend/app/aipanel/aipanelheader.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { handleWaveAIContextMenu } from "@/app/aipanel/aipanel-contextmenu"; -import { useAtomValue } from "jotai"; -import { memo } from "react"; -import { WaveAIModel } from "./waveai-model"; - -export const AIPanelHeader = memo(() => { - const model = WaveAIModel.getInstance(); - const widgetAccess = useAtomValue(model.widgetAccessAtom); - const inBuilder = model.inBuilder; - - const handleKebabClick = (e: React.MouseEvent) => { - handleWaveAIContextMenu(e, false); - }; - - const handleContextMenu = (e: React.MouseEvent) => { - handleWaveAIContextMenu(e, false); - }; - - return ( - <div - className="py-2 pl-3 pr-1 @xs:p-2 @xs:pl-4 border-b border-gray-600 flex items-center justify-between min-w-0" - onContextMenu={handleContextMenu} - > - <h2 className="text-white text-sm @xs:text-lg font-semibold flex items-center gap-2 flex-shrink-0 whitespace-nowrap"> - <i className="fa fa-sparkles text-accent"></i> - Wave AI - </h2> - - <div className="flex items-center flex-shrink-0 whitespace-nowrap"> - {!inBuilder && ( - <div className="flex items-center text-sm whitespace-nowrap"> - <span className="text-gray-300 @xs:hidden mr-1 text-[12px]">Context</span> - <span className="text-gray-300 hidden @xs:inline mr-2 text-[12px]">Widget Context</span> - <button - onClick={() => { - model.setWidgetAccess(!widgetAccess); - setTimeout(() => { - model.focusInput(); - }, 0); - }} - className={`relative inline-flex h-6 w-14 items-center rounded-full transition-colors cursor-pointer ${ - widgetAccess ? "bg-accent-600" : "bg-zinc-600" - }`} - title={`Widget Access ${widgetAccess ? "ON" : "OFF"}`} - > - <span - className={`absolute inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${ - widgetAccess ? "translate-x-8" : "translate-x-1" - }`} - /> - <span - className={`relative z-10 text-xs text-white transition-all ${ - widgetAccess ? "ml-2.5 mr-6 text-left" : "ml-6 mr-1 text-right" - }`} - > - {widgetAccess ? "ON" : "OFF"} - </span> - </button> - </div> - )} - - <button - onClick={handleKebabClick} - className="text-gray-400 hover:text-white cursor-pointer transition-colors p-1 rounded flex-shrink-0 ml-2 focus:outline-none" - title="More options" - > - <i className="fa fa-ellipsis-vertical"></i> - </button> - </div> - </div> - ); -}); - -AIPanelHeader.displayName = "AIPanelHeader"; diff --git a/frontend/app/aipanel/aipanelinput.tsx b/frontend/app/aipanel/aipanelinput.tsx deleted file mode 100644 index ec52ca0d13..0000000000 --- a/frontend/app/aipanel/aipanelinput.tsx +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { formatFileSizeError, isAcceptableFile, validateFileSize } from "@/app/aipanel/ai-utils"; -import { waveAIHasFocusWithin } from "@/app/aipanel/waveai-focus-utils"; -import { type WaveAIModel } from "@/app/aipanel/waveai-model"; -import { Tooltip } from "@/element/tooltip"; -import { cn } from "@/util/util"; -import { useAtom, useAtomValue } from "jotai"; -import { memo, useCallback, useEffect, useRef } from "react"; - -interface AIPanelInputProps { - onSubmit: (e: React.FormEvent) => void; - status: string; - model: WaveAIModel; -} - -export interface AIPanelInputRef { - focus: () => void; - resize: () => void; - scrollToBottom: () => void; -} - -export const AIPanelInput = memo(({ onSubmit, status, model }: AIPanelInputProps) => { - const [input, setInput] = useAtom(model.inputAtom); - const isFocused = useAtomValue(model.isWaveAIFocusedAtom); - const isChatEmpty = useAtomValue(model.isChatEmptyAtom); - const textareaRef = useRef<HTMLTextAreaElement>(null); - const fileInputRef = useRef<HTMLInputElement>(null); - const isPanelOpen = useAtomValue(model.getPanelVisibleAtom()); - - let placeholder: string; - if (!isChatEmpty) { - placeholder = "Continue..."; - } else if (model.inBuilder) { - placeholder = "What would you like to build..."; - } else { - placeholder = "Ask Wave AI anything..."; - } - - const resizeTextarea = useCallback(() => { - const textarea = textareaRef.current; - if (!textarea) return; - - textarea.style.height = "auto"; - const scrollHeight = textarea.scrollHeight; - const maxHeight = 7 * 24; - textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; - }, []); - - useEffect(() => { - const inputRefObject: React.RefObject<AIPanelInputRef> = { - current: { - focus: () => { - textareaRef.current?.focus(); - }, - resize: resizeTextarea, - scrollToBottom: () => { - const textarea = textareaRef.current; - if (textarea) { - textarea.scrollTop = textarea.scrollHeight; - } - }, - }, - }; - model.registerInputRef(inputRefObject); - }, [model, resizeTextarea]); - - const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { - const isComposing = e.nativeEvent?.isComposing || e.keyCode == 229; - if (e.key === "Enter" && !e.shiftKey && !isComposing) { - e.preventDefault(); - onSubmit(e as any); - } - }; - - const handleFocus = useCallback(() => { - model.requestWaveAIFocus(); - }, [model]); - - const handleBlur = useCallback( - (e: React.FocusEvent) => { - if (e.relatedTarget === null) { - return; - } - - if (waveAIHasFocusWithin(e.relatedTarget)) { - return; - } - - model.requestNodeFocus(); - }, - [model] - ); - - useEffect(() => { - resizeTextarea(); - }, [input, resizeTextarea]); - - useEffect(() => { - if (isPanelOpen) { - resizeTextarea(); - } - }, [isPanelOpen, resizeTextarea]); - - const handleUploadClick = () => { - fileInputRef.current?.click(); - }; - - const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { - const files = Array.from(e.target.files || []); - const acceptableFiles = files.filter(isAcceptableFile); - - for (const file of acceptableFiles) { - const sizeError = validateFileSize(file); - if (sizeError) { - model.setError(formatFileSizeError(sizeError)); - if (e.target) { - e.target.value = ""; - } - return; - } - await model.addFile(file); - } - - if (acceptableFiles.length < files.length) { - console.warn(`${files.length - acceptableFiles.length} files were rejected due to unsupported file types`); - } - - if (e.target) { - e.target.value = ""; - } - }; - - return ( - <div className={cn("border-t", isFocused ? "border-accent/50" : "border-gray-600")}> - <input - ref={fileInputRef} - type="file" - multiple - accept="image/*,.pdf,.txt,.md,.js,.jsx,.ts,.tsx,.go,.py,.java,.c,.cpp,.h,.hpp,.html,.css,.scss,.sass,.json,.xml,.yaml,.yml,.sh,.bat,.sql" - onChange={handleFileChange} - className="hidden" - /> - <form onSubmit={onSubmit}> - <div className="relative"> - <textarea - ref={textareaRef} - value={input} - onChange={(e) => setInput(e.target.value)} - onKeyDown={handleKeyDown} - onFocus={handleFocus} - onBlur={handleBlur} - placeholder={placeholder} - className={cn( - "w-full text-white px-2 py-2 pr-5 focus:outline-none resize-none overflow-auto bg-zinc-800/50" - )} - style={{ fontSize: "13px" }} - rows={2} - /> - <Tooltip content="Attach files" placement="top" divClassName="absolute bottom-6.5 right-1"> - <button - type="button" - onClick={handleUploadClick} - className={cn( - "w-5 h-5 transition-colors flex items-center justify-center text-gray-400 hover:text-accent cursor-pointer" - )} - > - <i className="fa fa-paperclip text-sm"></i> - </button> - </Tooltip> - {status === "streaming" ? ( - <Tooltip content="Stop Response" placement="top" divClassName="absolute bottom-1.5 right-1"> - <button - type="button" - onClick={() => model.stopResponse()} - className={cn( - "w-5 h-5 transition-colors flex items-center justify-center", - "text-green-500 hover:text-green-400 cursor-pointer" - )} - > - <i className="fa fa-square text-sm"></i> - </button> - </Tooltip> - ) : ( - <Tooltip content="Send message (Enter)" placement="top" divClassName="absolute bottom-1.5 right-1"> - <button - type="submit" - disabled={status !== "ready" || !input.trim()} - className={cn( - "w-5 h-5 transition-colors flex items-center justify-center", - status !== "ready" || !input.trim() - ? "text-gray-400" - : "text-accent/80 hover:text-accent cursor-pointer" - )} - > - <i className="fa fa-paper-plane text-sm"></i> - </button> - </Tooltip> - )} - </div> - </form> - </div> - ); -}); - -AIPanelInput.displayName = "AIPanelInput"; diff --git a/frontend/app/aipanel/aipanelmessages.tsx b/frontend/app/aipanel/aipanelmessages.tsx deleted file mode 100644 index 478b20e658..0000000000 --- a/frontend/app/aipanel/aipanelmessages.tsx +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { useAtomValue } from "jotai"; -import { memo, useEffect, useRef, useState } from "react"; -import { AIMessage } from "./aimessage"; -import { AIModeDropdown } from "./aimode"; -import { type WaveUIMessage } from "./aitypes"; -import { WaveAIModel } from "./waveai-model"; - -interface AIPanelMessagesProps { - messages: WaveUIMessage[]; - status: string; - onContextMenu?: (e: React.MouseEvent) => void; -} - -export const AIPanelMessages = memo(({ messages, status, onContextMenu }: AIPanelMessagesProps) => { - const model = WaveAIModel.getInstance(); - const isPanelOpen = useAtomValue(model.getPanelVisibleAtom()); - const messagesEndRef = useRef<HTMLDivElement>(null); - const messagesContainerRef = useRef<HTMLDivElement>(null); - const prevStatusRef = useRef<string>(status); - const [shouldAutoScroll, setShouldAutoScroll] = useState(true); - - const checkIfAtBottom = () => { - const container = messagesContainerRef.current; - if (!container) return true; - - const threshold = 50; - const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - return scrollBottom <= threshold; - }; - - const handleScroll = () => { - const atBottom = checkIfAtBottom(); - setShouldAutoScroll(atBottom); - }; - - const scrollToBottom = () => { - const container = messagesContainerRef.current; - if (container) { - container.scrollTop = container.scrollHeight; - container.scrollLeft = 0; - setShouldAutoScroll(true); - } - }; - - useEffect(() => { - const container = messagesContainerRef.current; - if (!container) return; - - container.addEventListener("scroll", handleScroll); - return () => container.removeEventListener("scroll", handleScroll); - }, []); - - useEffect(() => { - model.registerScrollToBottom(scrollToBottom); - }, [model]); - - useEffect(() => { - if (shouldAutoScroll) { - scrollToBottom(); - } - }, [messages, shouldAutoScroll]); - - useEffect(() => { - if (isPanelOpen) { - scrollToBottom(); - } - }, [isPanelOpen]); - - useEffect(() => { - const wasStreaming = prevStatusRef.current === "streaming"; - const isNowNotStreaming = status !== "streaming"; - - if (wasStreaming && isNowNotStreaming) { - requestAnimationFrame(() => { - scrollToBottom(); - }); - } - - prevStatusRef.current = status; - }, [status]); - - return ( - <div ref={messagesContainerRef} className="flex-1 overflow-y-auto p-2 space-y-4" onContextMenu={onContextMenu}> - <div className="mb-2"> - <AIModeDropdown compatibilityMode={true} /> - </div> - {messages.map((message, index) => { - const isLastMessage = index === messages.length - 1; - const isStreaming = status === "streaming" && isLastMessage && message.role === "assistant"; - return <AIMessage key={message.id} message={message} isStreaming={isStreaming} />; - })} - - {status === "streaming" && - (messages.length === 0 || messages[messages.length - 1].role !== "assistant") && ( - <AIMessage - key="last-message" - message={{ role: "assistant", parts: [], id: "last-message" } as any} - isStreaming={true} - /> - )} - - <div ref={messagesEndRef} /> - </div> - ); -}); - -AIPanelMessages.displayName = "AIPanelMessages"; diff --git a/frontend/app/aipanel/airatelimitstrip.tsx b/frontend/app/aipanel/airatelimitstrip.tsx deleted file mode 100644 index 8b1fa134e2..0000000000 --- a/frontend/app/aipanel/airatelimitstrip.tsx +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { atoms } from "@/app/store/global"; -import * as jotai from "jotai"; -import { memo, useEffect, useState } from "react"; - -const GetMoreButton = memo(({ variant, showClose = true }: { variant: "yellow" | "red"; showClose?: boolean }) => { - const isYellow = variant === "yellow"; - const bgColor = isYellow ? "bg-yellow-900/30" : "bg-red-900/30"; - const hoverBg = isYellow ? "hover:bg-yellow-700/60" : "hover:bg-red-700/60"; - const borderColor = isYellow ? "border-yellow-700/50" : "border-red-700/50"; - const textColor = isYellow ? "text-yellow-200" : "text-red-200"; - const iconColor = isYellow ? "text-yellow-400" : "text-red-400"; - const iconHoverBg = - showClose && isYellow - ? "hover:has-[.close:hover]:bg-yellow-900/30" - : showClose - ? "hover:has-[.close:hover]:bg-red-900/30" - : ""; - - if (true as boolean) { - // disable now until we have modal - return null; - } - - return ( - <div className="pl-2 pb-1.5"> - <button - className={`flex items-center gap-1.5 ${showClose ? "pl-1" : "pl-2"} pr-2 py-1 ${bgColor} ${iconHoverBg} ${hoverBg} rounded-b border border-t-0 ${borderColor} text-[11px] ${textColor} cursor-pointer transition-colors`} - > - {showClose && ( - <i className={`close fa fa-xmark ${iconColor}/60 hover:${iconColor} transition-colors`}></i> - )} - <span>Get More</span> - <i className={`fa fa-arrow-right ${iconColor}`}></i> - </button> - </div> - ); -}); - -GetMoreButton.displayName = "GetMoreButton"; - -function formatTimeRemaining(expirationEpoch: number): string { - const now = Math.floor(Date.now() / 1000); - const secondsRemaining = expirationEpoch - now; - - if (secondsRemaining <= 0) { - return "soon"; - } - - const hours = Math.floor(secondsRemaining / 3600); - const minutes = Math.floor((secondsRemaining % 3600) / 60); - - if (hours > 0) { - return `${hours}h`; - } - return `${minutes}m`; -} - -const AIRateLimitStripComponent = memo(() => { - let rateLimitInfo = jotai.useAtomValue(atoms.waveAIRateLimitInfoAtom); - // rateLimitInfo = { req: 0, reqlimit: 200, preq: 0, preqlimit: 50, resetepoch: 1759374575 + 45 * 60 }; // testing - const [, forceUpdate] = useState({}); - - const shouldShow = rateLimitInfo && !rateLimitInfo.unknown && (rateLimitInfo.preq <= 5 || rateLimitInfo.req === 0); - - useEffect(() => { - if (!shouldShow) { - return; - } - - const interval = setInterval(() => { - forceUpdate({}); - }, 60000); - - return () => clearInterval(interval); - }, [shouldShow]); - - if (!rateLimitInfo || rateLimitInfo.unknown || !shouldShow) { - return null; - } - - const { req, reqlimit, preq, preqlimit, resetepoch } = rateLimitInfo; - const timeRemaining = formatTimeRemaining(resetepoch); - const totalLimit = preqlimit + reqlimit; - - if (preq > 0 && preq <= 5) { - return ( - <div> - <div className="bg-yellow-900/30 border-b border-yellow-700/50 px-2 py-1.5 flex items-center gap-1 text-[11px] text-yellow-200"> - <i className="fa fa-sparkles text-yellow-400"></i> - <span> - {preqlimit - preq}/{preqlimit} Premium Used - </span> - <div className="flex-1"></div> - <span className="text-yellow-300/80">Resets in {timeRemaining}</span> - </div> - <GetMoreButton variant="yellow" /> - </div> - ); - } - - if (preq === 0 && req > 0) { - return ( - <div> - <div className="bg-yellow-900/30 border-b border-yellow-700/50 px-2 pr-1 py-1.5 flex items-center gap-1 text-[11px] text-yellow-200"> - <i className="fa fa-check text-yellow-400"></i> - <span> - {preqlimit}/{preqlimit} Premium - </span> - <span className="text-yellow-400">â€ĸ</span> - <span className="font-medium">Now on Basic</span> - <div className="flex-1"></div> - <span className="text-yellow-300/80">Resets in {timeRemaining}</span> - </div> - <GetMoreButton variant="yellow" /> - </div> - ); - } - - if (req === 0 && preq === 0) { - return ( - <div> - <div className="bg-red-900/30 border-b border-red-700/50 px-2 py-1.5 flex items-center gap-2 text-[11px] text-red-200"> - <i className="fa fa-check text-red-400"></i> - <span> - {totalLimit}/{totalLimit} Reqs - </span> - <span className="text-red-400">â€ĸ</span> - <span className="font-medium">Limit Reached</span> - <div className="flex-1"></div> - <span className="text-red-300/80">Resets in {timeRemaining}</span> - </div> - <GetMoreButton variant="red" showClose={false} /> - </div> - ); - } - - return null; -}); - -AIRateLimitStripComponent.displayName = "AIRateLimitStrip"; - -export { AIRateLimitStripComponent as AIRateLimitStrip }; diff --git a/frontend/app/aipanel/aitooluse.tsx b/frontend/app/aipanel/aitooluse.tsx deleted file mode 100644 index 7868c188e9..0000000000 --- a/frontend/app/aipanel/aitooluse.tsx +++ /dev/null @@ -1,441 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { BlockModel } from "@/app/block/block-model"; -import { Modal } from "@/app/modals/modal"; -import { recordTEvent } from "@/app/store/global"; -import { cn, fireAndForget } from "@/util/util"; -import { useAtomValue } from "jotai"; -import { memo, useEffect, useRef, useState } from "react"; -import { WaveUIMessagePart } from "./aitypes"; -import { RestoreBackupModal } from "./restorebackupmodal"; -import { WaveAIModel } from "./waveai-model"; - -// matches pkg/filebackup/filebackup.go -const BackupRetentionDays = 5; - -interface ToolDescLineProps { - text: string; -} - -const ToolDescLine = memo(({ text }: ToolDescLineProps) => { - let displayText = text; - if (displayText.startsWith("* ")) { - displayText = "â€ĸ " + displayText.slice(2); - } - - const parts: React.ReactNode[] = []; - let lastIndex = 0; - const regex = /(?<!\w)([+-])(\d+)(?!\w)/g; - let match; - - while ((match = regex.exec(displayText)) !== null) { - if (match.index > lastIndex) { - parts.push(displayText.slice(lastIndex, match.index)); - } - - const sign = match[1]; - const number = match[2]; - const colorClass = sign === "+" ? "text-green-600" : "text-red-600"; - parts.push( - <span key={match.index} className={colorClass}> - {sign} - {number} - </span> - ); - - lastIndex = match.index + match[0].length; - } - - if (lastIndex < displayText.length) { - parts.push(displayText.slice(lastIndex)); - } - - return <div>{parts.length > 0 ? parts : displayText}</div>; -}); - -ToolDescLine.displayName = "ToolDescLine"; - -interface ToolDescProps { - text: string | string[]; - className?: string; -} - -const ToolDesc = memo(({ text, className }: ToolDescProps) => { - const lines = Array.isArray(text) ? text : text.split("\n"); - - if (lines.length === 0) return null; - - return ( - <div className={className}> - {lines.map((line, idx) => ( - <ToolDescLine key={idx} text={line} /> - ))} - </div> - ); -}); - -ToolDesc.displayName = "ToolDesc"; - -function getEffectiveApprovalStatus(baseApproval: string, isStreaming: boolean): string { - return !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval; -} - -interface AIToolApprovalButtonsProps { - count: number; - onApprove: () => void; - onDeny: () => void; -} - -const AIToolApprovalButtons = memo(({ count, onApprove, onDeny }: AIToolApprovalButtonsProps) => { - const approveText = count > 1 ? `Approve All (${count})` : "Approve"; - const denyText = count > 1 ? "Deny All" : "Deny"; - - return ( - <div className="mt-2 flex gap-2"> - <button - onClick={onApprove} - className="px-3 py-1 border border-gray-600 text-gray-300 hover:border-gray-500 hover:text-white text-sm rounded cursor-pointer transition-colors" - > - {approveText} - </button> - <button - onClick={onDeny} - className="px-3 py-1 border border-gray-600 text-gray-300 hover:border-gray-500 hover:text-white text-sm rounded cursor-pointer transition-colors" - > - {denyText} - </button> - </div> - ); -}); - -AIToolApprovalButtons.displayName = "AIToolApprovalButtons"; - -interface AIToolUseBatchItemProps { - part: WaveUIMessagePart & { type: "data-tooluse" }; - effectiveApproval: string; -} - -const AIToolUseBatchItem = memo(({ part, effectiveApproval }: AIToolUseBatchItemProps) => { - const statusIcon = part.data.status === "completed" ? "✓" : part.data.status === "error" ? "✗" : "â€ĸ"; - const statusColor = - part.data.status === "completed" - ? "text-success" - : part.data.status === "error" - ? "text-error" - : "text-gray-400"; - const effectiveErrorMessage = part.data.errormessage || (effectiveApproval === "timeout" ? "Not approved" : null); - - return ( - <div className="text-sm pl-2 flex items-start gap-1.5"> - <span className={cn("font-bold flex-shrink-0", statusColor)}>{statusIcon}</span> - <div className="flex-1"> - <span className="text-gray-400">{part.data.tooldesc}</span> - {effectiveErrorMessage && <div className="text-red-300 mt-0.5">{effectiveErrorMessage}</div>} - </div> - </div> - ); -}); - -AIToolUseBatchItem.displayName = "AIToolUseBatchItem"; - -interface AIToolUseBatchProps { - parts: Array<WaveUIMessagePart & { type: "data-tooluse" }>; - isStreaming: boolean; -} - -const AIToolUseBatch = memo(({ parts, isStreaming }: AIToolUseBatchProps) => { - const [userApprovalOverride, setUserApprovalOverride] = useState<string | null>(null); - - const firstTool = parts[0].data; - const baseApproval = userApprovalOverride || firstTool.approval; - const effectiveApproval = getEffectiveApprovalStatus(baseApproval, isStreaming); - - const handleApprove = () => { - setUserApprovalOverride("user-approved"); - parts.forEach((part) => { - WaveAIModel.getInstance().toolUseSendApproval(part.data.toolcallid, "user-approved"); - }); - }; - - const handleDeny = () => { - setUserApprovalOverride("user-denied"); - parts.forEach((part) => { - WaveAIModel.getInstance().toolUseSendApproval(part.data.toolcallid, "user-denied"); - }); - }; - - return ( - <div className="flex items-start gap-2 p-2 rounded bg-zinc-800/60 border border-zinc-700"> - <div className="flex-1"> - <div className="font-semibold">Reading Files</div> - <div className="mt-1 space-y-0.5"> - {parts.map((part, idx) => ( - <AIToolUseBatchItem key={idx} part={part} effectiveApproval={effectiveApproval} /> - ))} - </div> - {effectiveApproval === "needs-approval" && ( - <AIToolApprovalButtons count={parts.length} onApprove={handleApprove} onDeny={handleDeny} /> - )} - </div> - </div> - ); -}); - -AIToolUseBatch.displayName = "AIToolUseBatch"; - -interface AIToolUseProps { - part: WaveUIMessagePart & { type: "data-tooluse" }; - isStreaming: boolean; -} - -const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { - const toolData = part.data; - const [userApprovalOverride, setUserApprovalOverride] = useState<string | null>(null); - const model = WaveAIModel.getInstance(); - const restoreModalToolCallId = useAtomValue(model.restoreBackupModalToolCallId); - const showRestoreModal = restoreModalToolCallId === toolData.toolcallid; - const highlightTimeoutRef = useRef<NodeJS.Timeout | null>(null); - const highlightedBlockIdRef = useRef<string | null>(null); - - const statusIcon = toolData.status === "completed" ? "✓" : toolData.status === "error" ? "✗" : "â€ĸ"; - const statusColor = - toolData.status === "completed" ? "text-success" : toolData.status === "error" ? "text-error" : "text-gray-400"; - - const baseApproval = userApprovalOverride || toolData.approval; - const effectiveApproval = getEffectiveApprovalStatus(baseApproval, isStreaming); - - const isFileWriteTool = toolData.toolname === "write_text_file" || toolData.toolname === "edit_text_file"; - - useEffect(() => { - return () => { - if (highlightTimeoutRef.current) { - clearTimeout(highlightTimeoutRef.current); - } - }; - }, []); - - const handleApprove = () => { - setUserApprovalOverride("user-approved"); - WaveAIModel.getInstance().toolUseSendApproval(toolData.toolcallid, "user-approved"); - }; - - const handleDeny = () => { - setUserApprovalOverride("user-denied"); - WaveAIModel.getInstance().toolUseSendApproval(toolData.toolcallid, "user-denied"); - }; - - const handleMouseEnter = () => { - if (!toolData.blockid) return; - - if (highlightTimeoutRef.current) { - clearTimeout(highlightTimeoutRef.current); - } - - highlightedBlockIdRef.current = toolData.blockid; - BlockModel.getInstance().setBlockHighlight({ - blockId: toolData.blockid, - icon: "sparkles", - }); - - highlightTimeoutRef.current = setTimeout(() => { - if (highlightedBlockIdRef.current === toolData.blockid) { - BlockModel.getInstance().setBlockHighlight(null); - highlightedBlockIdRef.current = null; - } - }, 2000); - }; - - const handleMouseLeave = () => { - if (!toolData.blockid) return; - - if (highlightTimeoutRef.current) { - clearTimeout(highlightTimeoutRef.current); - highlightTimeoutRef.current = null; - } - - if (highlightedBlockIdRef.current === toolData.blockid) { - BlockModel.getInstance().setBlockHighlight(null); - highlightedBlockIdRef.current = null; - } - }; - - const handleOpenDiff = () => { - recordTEvent("waveai:showdiff"); - fireAndForget(() => WaveAIModel.getInstance().openDiff(toolData.inputfilename, toolData.toolcallid)); - }; - - return ( - <div - className={cn("flex flex-col gap-1 p-2 rounded bg-zinc-800/60 border border-zinc-700", statusColor)} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - > - <div className="flex items-center gap-2"> - <span className="font-bold">{statusIcon}</span> - <div className="font-semibold">{toolData.toolname}</div> - <div className="flex-1" /> - {isFileWriteTool && - toolData.inputfilename && - toolData.writebackupfilename && - toolData.runts && - Date.now() - toolData.runts < BackupRetentionDays * 24 * 60 * 60 * 1000 && ( - <button - onClick={() => { - recordTEvent("waveai:revertfile", { "waveai:action": "revertfile:open" }); - model.openRestoreBackupModal(toolData.toolcallid); - }} - className="flex-shrink-0 px-1.5 py-0.5 border border-zinc-600 hover:border-zinc-500 hover:bg-zinc-700 rounded cursor-pointer transition-colors flex items-center gap-1 text-zinc-400" - title="Restore backup file" - > - <span className="text-xs">Revert File</span> - <i className="fa fa-clock-rotate-left text-xs"></i> - </button> - )} - {isFileWriteTool && toolData.inputfilename && ( - <button - onClick={handleOpenDiff} - className="flex-shrink-0 px-1.5 py-0.5 border border-zinc-600 hover:border-zinc-500 hover:bg-zinc-700 rounded cursor-pointer transition-colors flex items-center gap-1 text-zinc-400" - title="Open in diff viewer" - > - <span className="text-xs">Show Diff</span> - <i className="fa fa-arrow-up-right-from-square text-xs"></i> - </button> - )} - </div> - {toolData.tooldesc && <ToolDesc text={toolData.tooldesc} className="text-sm text-gray-400 pl-6" />} - {(toolData.errormessage || effectiveApproval === "timeout") && ( - <div className="text-sm text-red-300 pl-6">{toolData.errormessage || "Not approved"}</div> - )} - {effectiveApproval === "needs-approval" && ( - <div className="pl-6"> - <AIToolApprovalButtons count={1} onApprove={handleApprove} onDeny={handleDeny} /> - </div> - )} - {showRestoreModal && <RestoreBackupModal part={part} />} - </div> - ); -}); - -AIToolUse.displayName = "AIToolUse"; - -interface AIToolProgressProps { - part: WaveUIMessagePart & { type: "data-toolprogress" }; -} - -const AIToolProgress = memo(({ part }: AIToolProgressProps) => { - const progressData = part.data; - - return ( - <div className="flex flex-col gap-1 p-2 rounded bg-zinc-800/60 border border-zinc-700"> - <div className="flex items-center gap-2"> - <i className="fa fa-spinner fa-spin text-gray-400"></i> - <div className="font-semibold">{progressData.toolname}</div> - </div> - {progressData.statuslines && progressData.statuslines.length > 0 && ( - <ToolDesc text={progressData.statuslines} className="text-sm text-gray-400 pl-6 space-y-0.5" /> - )} - </div> - ); -}); - -AIToolProgress.displayName = "AIToolProgress"; - -interface AIToolUseGroupProps { - parts: Array<WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }>; - isStreaming: boolean; -} - -type ToolGroupItem = - | { type: "batch"; parts: Array<WaveUIMessagePart & { type: "data-tooluse" }> } - | { type: "single"; part: WaveUIMessagePart & { type: "data-tooluse" } } - | { type: "progress"; part: WaveUIMessagePart & { type: "data-toolprogress" } }; - -export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) => { - const tooluseParts = parts.filter((p) => p.type === "data-tooluse") as Array< - WaveUIMessagePart & { type: "data-tooluse" } - >; - const toolprogressParts = parts.filter((p) => p.type === "data-toolprogress") as Array< - WaveUIMessagePart & { type: "data-toolprogress" } - >; - - const tooluseCallIds = new Set(tooluseParts.map((p) => p.data.toolcallid)); - const filteredProgressParts = toolprogressParts.filter((p) => !tooluseCallIds.has(p.data.toolcallid)); - - const isFileOp = (part: WaveUIMessagePart & { type: "data-tooluse" }) => { - const toolName = part.data?.toolname; - return toolName === "read_text_file" || toolName === "read_dir"; - }; - - const needsApproval = (part: WaveUIMessagePart & { type: "data-tooluse" }) => { - return getEffectiveApprovalStatus(part.data?.approval, isStreaming) === "needs-approval"; - }; - - const readFileNeedsApproval: Array<WaveUIMessagePart & { type: "data-tooluse" }> = []; - const readFileOther: Array<WaveUIMessagePart & { type: "data-tooluse" }> = []; - - for (const part of tooluseParts) { - if (isFileOp(part)) { - if (needsApproval(part)) { - readFileNeedsApproval.push(part); - } else { - readFileOther.push(part); - } - } - } - - const groupedItems: ToolGroupItem[] = []; - let addedApprovalBatch = false; - let addedOtherBatch = false; - - for (const part of tooluseParts) { - const isFileOpPart = isFileOp(part); - const partNeedsApproval = needsApproval(part); - - if (isFileOpPart && partNeedsApproval) { - if (!addedApprovalBatch) { - groupedItems.push({ type: "batch", parts: readFileNeedsApproval }); - addedApprovalBatch = true; - } - } else if (isFileOpPart && !partNeedsApproval) { - if (!addedOtherBatch) { - groupedItems.push({ type: "batch", parts: readFileOther }); - addedOtherBatch = true; - } - } else { - groupedItems.push({ type: "single", part }); - } - } - - filteredProgressParts.forEach((part) => { - groupedItems.push({ type: "progress", part }); - }); - - return ( - <> - {groupedItems.map((item, idx) => { - if (item.type === "batch") { - return ( - <div key={idx} className="mt-2"> - <AIToolUseBatch parts={item.parts} isStreaming={isStreaming} /> - </div> - ); - } else if (item.type === "progress") { - return ( - <div key={idx} className="mt-2"> - <AIToolProgress part={item.part} /> - </div> - ); - } else { - return ( - <div key={idx} className="mt-2"> - <AIToolUse part={item.part} isStreaming={isStreaming} /> - </div> - ); - } - })} - </> - ); -}); - -AIToolUseGroup.displayName = "AIToolUseGroup"; diff --git a/frontend/app/aipanel/aitypes.ts b/frontend/app/aipanel/aitypes.ts deleted file mode 100644 index fbce463a73..0000000000 --- a/frontend/app/aipanel/aitypes.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { ChatRequestOptions, FileUIPart, UIMessage, UIMessagePart } from "ai"; - -type WaveUIDataTypes = { - // pkg/aiusechat/uctypes/uctypes.go UIMessageDataUserFile - userfile: { - filename: string; - size: number; - mimetype: string; - previewurl?: string; - }; - // pkg/aiusechat/uctypes/uctypes.go UIMessageDataToolUse - tooluse: { - toolcallid: string; - toolname: string; - tooldesc: string; - status: "pending" | "error" | "completed"; - runts?: number; - errormessage?: string; - approval?: "needs-approval" | "user-approved" | "user-denied" | "auto-approved" | "timeout"; - blockid?: string; - writebackupfilename?: string; - inputfilename?: string; - }; - - toolprogress: { - toolcallid: string; - toolname: string; - statuslines: string[]; - }; -}; - -export type WaveUIMessage = UIMessage<unknown, WaveUIDataTypes, any>; -export type WaveUIMessagePart = UIMessagePart<WaveUIDataTypes, any>; - -export type UseChatSetMessagesType = ( - messages: WaveUIMessage[] | ((messages: WaveUIMessage[]) => WaveUIMessage[]) -) => void; - -export type UseChatSendMessageType = ( - message?: - | (Omit<WaveUIMessage, "id" | "role"> & { - id?: string; - role?: "system" | "user" | "assistant"; - } & { - text?: never; - files?: never; - messageId?: string; - }) - | { - text: string; - files?: FileList | FileUIPart[]; - metadata?: unknown; - parts?: never; - messageId?: string; - } - | { - files: FileList | FileUIPart[]; - metadata?: unknown; - parts?: never; - messageId?: string; - }, - options?: ChatRequestOptions -) => Promise<void>; diff --git a/frontend/app/aipanel/byokannouncement.tsx b/frontend/app/aipanel/byokannouncement.tsx deleted file mode 100644 index 935cc4a3b0..0000000000 --- a/frontend/app/aipanel/byokannouncement.tsx +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { WaveAIModel } from "./waveai-model"; - -const BYOKAnnouncement = () => { - const model = WaveAIModel.getInstance(); - - const handleOpenConfig = async () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "action:other", - props: { - "action:type": "waveai:configuremodes:panel", - }, - }, - { noresponse: true } - ); - await model.openWaveAIConfig(); - }; - - const handleViewDocs = () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "action:other", - props: { - "action:type": "waveai:viewdocs:panel", - }, - }, - { noresponse: true } - ); - }; - - return ( - <div className="bg-blue-900/20 border border-blue-800 rounded-lg p-4 mt-4"> - <div className="flex items-start gap-3"> - <i className="fa fa-key text-blue-400 text-lg mt-0.5"></i> - <div className="text-left flex-1"> - <div className="text-blue-400 font-medium mb-1">New: BYOK & Local AI Support</div> - <div className="text-secondary text-sm mb-3"> - Wave AI now supports bring-your-own-key (BYOK) with OpenAI, Google Gemini, Azure, and - OpenRouter, plus local models via Ollama, LM Studio, and other OpenAI-compatible providers. - </div> - <div className="flex items-center gap-3"> - <button - onClick={handleOpenConfig} - className="border border-blue-400 text-blue-400 hover:bg-blue-500/10 hover:text-blue-300 px-3 py-1.5 rounded-md text-sm font-medium cursor-pointer transition-colors" - > - Configure AI Modes - </button> - <a - href="https://docs.waveterm.dev/waveai-modes" - target="_blank" - rel="noopener noreferrer" - onClick={handleViewDocs} - className="text-blue-400! hover:text-blue-300! hover:underline text-sm cursor-pointer transition-colors flex items-center gap-1" - > - View Docs <i className="fa fa-external-link text-xs"></i> - </a> - </div> - </div> - </div> - </div> - ); -}; - -BYOKAnnouncement.displayName = "BYOKAnnouncement"; - -export { BYOKAnnouncement }; diff --git a/frontend/app/aipanel/restorebackupmodal.tsx b/frontend/app/aipanel/restorebackupmodal.tsx deleted file mode 100644 index 36b4be5b5d..0000000000 --- a/frontend/app/aipanel/restorebackupmodal.tsx +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { Modal } from "@/app/modals/modal"; -import { recordTEvent } from "@/app/store/global"; -import { useAtomValue } from "jotai"; -import { memo } from "react"; -import { WaveUIMessagePart } from "./aitypes"; -import { WaveAIModel } from "./waveai-model"; - -interface RestoreBackupModalProps { - part: WaveUIMessagePart & { type: "data-tooluse" }; -} - -export const RestoreBackupModal = memo(({ part }: RestoreBackupModalProps) => { - const model = WaveAIModel.getInstance(); - const toolData = part.data; - const status = useAtomValue(model.restoreBackupStatus); - const error = useAtomValue(model.restoreBackupError); - - const formatTimestamp = (ts: number) => { - if (!ts) return ""; - const date = new Date(ts); - return date.toLocaleString(); - }; - - const handleConfirm = () => { - recordTEvent("waveai:revertfile", { "waveai:action": "revertfile:confirm" }); - model.restoreBackup(toolData.toolcallid, toolData.writebackupfilename, toolData.inputfilename); - }; - - const handleCancel = () => { - recordTEvent("waveai:revertfile", { "waveai:action": "revertfile:cancel" }); - model.closeRestoreBackupModal(); - }; - - const handleClose = () => { - model.closeRestoreBackupModal(); - }; - - if (status === "success") { - return ( - <Modal className="restore-backup-modal pb-5 pr-5" onClose={handleClose} onOk={handleClose} okLabel="Close"> - <div className="flex flex-col gap-4 pt-4 pb-4 max-w-xl"> - <div className="font-semibold text-lg text-green-500">Backup Successfully Restored</div> - <div className="text-sm text-gray-300 leading-relaxed"> - The file <span className="font-mono text-white break-all">{toolData.inputfilename}</span> has - been restored to its previous state. - </div> - </div> - </Modal> - ); - } - - if (status === "error") { - return ( - <Modal className="restore-backup-modal pb-5 pr-5" onClose={handleClose} onOk={handleClose} okLabel="Close"> - <div className="flex flex-col gap-4 pt-4 pb-4 max-w-xl"> - <div className="font-semibold text-lg text-red-500">Failed to Restore Backup</div> - <div className="text-sm text-gray-300 leading-relaxed"> - An error occurred while restoring the backup: - </div> - <div className="text-sm text-red-400 font-mono bg-zinc-800 p-3 rounded break-all">{error}</div> - </div> - </Modal> - ); - } - - const isProcessing = status === "processing"; - - return ( - <Modal - className="restore-backup-modal pb-5 pr-5" - onClose={handleCancel} - onCancel={handleCancel} - onOk={handleConfirm} - okLabel={isProcessing ? "Restoring..." : "Confirm Restore"} - cancelLabel="Cancel" - okDisabled={isProcessing} - cancelDisabled={isProcessing} - > - <div className="flex flex-col gap-4 pt-4 pb-4 max-w-xl"> - <div className="font-semibold text-lg">Restore File Backup</div> - <div className="text-sm text-gray-300 leading-relaxed"> - This will restore <span className="font-mono text-white break-all">{toolData.inputfilename}</span>{" "} - to its state before this edit was made - {toolData.runts && <span> ({formatTimestamp(toolData.runts)})</span>}. - </div> - <div className="text-sm text-gray-300 leading-relaxed"> - Any changes made by this edit and subsequent edits will be lost. - </div> - </div> - </Modal> - ); -}); - -RestoreBackupModal.displayName = "RestoreBackupModal"; \ No newline at end of file diff --git a/frontend/app/aipanel/telemetryrequired.tsx b/frontend/app/aipanel/telemetryrequired.tsx deleted file mode 100644 index 692dec73d5..0000000000 --- a/frontend/app/aipanel/telemetryrequired.tsx +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { cn } from "@/util/util"; -import { useState } from "react"; -import { WaveAIModel } from "./waveai-model"; - -interface TelemetryRequiredMessageProps { - className?: string; -} - -const TelemetryRequiredMessage = ({ className }: TelemetryRequiredMessageProps) => { - const [isEnabling, setIsEnabling] = useState(false); - - const handleEnableTelemetry = async () => { - setIsEnabling(true); - try { - await RpcApi.WaveAIEnableTelemetryCommand(TabRpcClient); - setTimeout(() => { - WaveAIModel.getInstance().focusInput(); - }, 100); - } catch (error) { - console.error("Failed to enable telemetry:", error); - setIsEnabling(false); - } - }; - - return ( - <div className={cn("flex flex-col h-full", className)}> - <div className="flex-grow"></div> - <div className="flex items-center justify-center p-8 text-center"> - <div className="max-w-md space-y-6"> - <div className="space-y-4"> - <i className="fa fa-sparkles text-accent text-5xl"></i> - <h2 className="text-2xl font-semibold text-foreground">Wave AI</h2> - <p className="text-secondary leading-relaxed"> - Wave AI is free to use and provides integrated AI chat that can interact with your widgets, - help you with code, analyze files, and assist with your terminal workflows. - </p> - </div> - - <div className="bg-blue-900/20 border border-blue-500 rounded-lg p-4"> - <div className="flex items-start gap-3"> - <i className="fa fa-info-circle text-blue-400 text-lg mt-0.5"></i> - <div className="text-left"> - <div className="text-blue-400 font-medium mb-1">Telemetry keeps Wave AI free</div> - <div className="text-secondary text-sm mb-3"> - <p className="mb-2"> - To keep Wave AI free for everyone, we require a small amount of <i>anonymous</i>{" "} - usage data (app version, feature usage, system info). - </p> - <p className="mb-2"> - This helps us block abuse by automated systems and ensure it's used by real - people like you. - </p> - <p className="mb-2"> - We never collect your files, prompts, keystrokes, hostnames, or personally - identifying information. Wave AI is powered by OpenAI's APIs, please refer to - OpenAI's privacy policy for details on how they handle your data. - </p> - <p> - For information about BYOK and local model support, see{" "} - <a - href="https://docs.waveterm.dev/waveai-modes" - target="_blank" - rel="noopener noreferrer" - className="!text-secondary hover:!text-accent/80 cursor-pointer" - > - https://docs.waveterm.dev/waveai-modes - </a> - . - </p> - </div> - <button - onClick={handleEnableTelemetry} - disabled={isEnabling} - className="bg-accent/80 hover:bg-accent disabled:bg-accent/50 text-background px-4 py-2 rounded-lg font-medium cursor-pointer disabled:cursor-not-allowed" - > - {isEnabling ? "Enabling..." : "Enable Telemetry and Continue"} - </button> - </div> - </div> - </div> - - <div className="text-xs text-secondary"> - <a - href="https://waveterm.dev/privacy" - target="_blank" - rel="noopener noreferrer" - className="!text-secondary hover:!text-accent/80 cursor-pointer" - > - Privacy Policy - </a> - </div> - </div> - </div> - <div className="flex-grow-[2]"></div> - </div> - ); -}; - -TelemetryRequiredMessage.displayName = "TelemetryRequiredMessage"; - -export { TelemetryRequiredMessage }; diff --git a/frontend/app/aipanel/waveai-focus-utils.ts b/frontend/app/aipanel/waveai-focus-utils.ts deleted file mode 100644 index dba3daccb2..0000000000 --- a/frontend/app/aipanel/waveai-focus-utils.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -export function findWaveAIPanel(element: HTMLElement): HTMLElement | null { - let current: HTMLElement = element; - while (current) { - if (current.hasAttribute("data-waveai-panel")) { - return current; - } - current = current.parentElement; - } - return null; -} - -export function waveAIHasFocusWithin(focusTarget?: Element | null): boolean { - if (focusTarget !== undefined) { - if (focusTarget instanceof HTMLElement) { - return findWaveAIPanel(focusTarget) != null; - } - return false; - } - - const focused = document.activeElement; - if (focused instanceof HTMLElement) { - const waveAIPanel = findWaveAIPanel(focused); - if (waveAIPanel) return true; - } - - const sel = document.getSelection(); - if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) { - let anchor = sel.anchorNode; - if (anchor instanceof Text) { - anchor = anchor.parentElement; - } - if (anchor instanceof HTMLElement) { - const waveAIPanel = findWaveAIPanel(anchor); - if (waveAIPanel) return true; - } - } - - return false; -} - -export function waveAIHasSelection(): boolean { - const sel = document.getSelection(); - if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { - return false; - } - - let anchor = sel.anchorNode; - if (anchor instanceof Text) { - anchor = anchor.parentElement; - } - if (anchor instanceof HTMLElement) { - return findWaveAIPanel(anchor) != null; - } - - return false; -} \ No newline at end of file diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx deleted file mode 100644 index 194005adc6..0000000000 --- a/frontend/app/aipanel/waveai-model.tsx +++ /dev/null @@ -1,710 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { - UseChatSendMessageType, - UseChatSetMessagesType, - WaveUIMessage, - WaveUIMessagePart, -} from "@/app/aipanel/aitypes"; -import { FocusManager } from "@/app/store/focusManager"; -import { atoms, createBlock, getOrefMetaKeyAtom, getSettingsKeyAtom } from "@/app/store/global"; -import { globalStore } from "@/app/store/jotaiStore"; -import { isBuilderWindow } from "@/app/store/windowtype"; -import * as WOS from "@/app/store/wos"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; -import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; -import { getWebServerEndpoint } from "@/util/endpoints"; -import { base64ToArrayBuffer } from "@/util/util"; -import { ChatStatus } from "ai"; -import * as jotai from "jotai"; -import type React from "react"; -import { - createDataUrl, - createImagePreview, - formatFileSizeError, - isAcceptableFile, - normalizeMimeType, - resizeImage, - validateFileSizeFromInfo, -} from "./ai-utils"; -import type { AIPanelInputRef } from "./aipanelinput"; - -export interface DroppedFile { - id: string; - file: File; - name: string; - type: string; - size: number; - previewUrl?: string; -} - -const BuilderAIModeConfigs: Record<string, AIModeConfigType> = { - "waveaibuilder@default": { - "display:name": "Builder Default", - "display:order": -2, - "display:icon": "sparkles", - "display:description": "Good mix of speed and accuracy\n(gpt-5.4 with minimal thinking)", - "ai:provider": "wave", - "ai:switchcompat": ["wavecloud"], - "waveai:premium": true, - }, - "waveaibuilder@deep": { - "display:name": "Builder Deep", - "display:order": -1, - "display:icon": "lightbulb", - "display:description": "Slower but most capable\n(gpt-5.4 with full reasoning)", - "ai:provider": "wave", - "ai:switchcompat": ["wavecloud"], - "waveai:premium": true, - }, -}; - -export class WaveAIModel { - private static instance: WaveAIModel | null = null; - inputRef: React.RefObject<AIPanelInputRef> | null = null; - scrollToBottomCallback: (() => void) | null = null; - useChatSendMessage: UseChatSendMessageType | null = null; - useChatSetMessages: UseChatSetMessagesType | null = null; - useChatStatus: ChatStatus = "ready"; - useChatStop: (() => void) | null = null; - // Used for injecting Wave-specific message data into DefaultChatTransport's prepareSendMessagesRequest - realMessage: AIMessage | null = null; - orefContext: ORef; - inBuilder: boolean = false; - isAIStreaming = jotai.atom(false); - - widgetAccessAtom!: jotai.Atom<boolean>; - droppedFiles: jotai.PrimitiveAtom<DroppedFile[]> = jotai.atom([]); - chatId!: jotai.PrimitiveAtom<string>; - currentAIMode!: jotai.PrimitiveAtom<string>; - aiModeConfigs!: jotai.Atom<Record<string, AIModeConfigType>>; - hasPremiumAtom!: jotai.Atom<boolean>; - defaultModeAtom!: jotai.Atom<string>; - errorMessage: jotai.PrimitiveAtom<string> = jotai.atom(null) as jotai.PrimitiveAtom<string>; - containerWidth: jotai.PrimitiveAtom<number> = jotai.atom(0); - codeBlockMaxWidth!: jotai.Atom<number>; - inputAtom: jotai.PrimitiveAtom<string> = jotai.atom(""); - isLoadingChatAtom: jotai.PrimitiveAtom<boolean> = jotai.atom(false); - isChatEmptyAtom: jotai.PrimitiveAtom<boolean> = jotai.atom(true); - isWaveAIFocusedAtom!: jotai.Atom<boolean>; - panelVisibleAtom!: jotai.Atom<boolean>; - restoreBackupModalToolCallId: jotai.PrimitiveAtom<string | null> = jotai.atom(null) as jotai.PrimitiveAtom< - string | null - >; - restoreBackupStatus: jotai.PrimitiveAtom<"idle" | "processing" | "success" | "error"> = jotai.atom("idle"); - restoreBackupError: jotai.PrimitiveAtom<string> = jotai.atom(null) as jotai.PrimitiveAtom<string>; - - private constructor(orefContext: ORef, inBuilder: boolean) { - this.orefContext = orefContext; - this.inBuilder = inBuilder; - this.chatId = jotai.atom(null) as jotai.PrimitiveAtom<string>; - if (inBuilder) { - this.aiModeConfigs = jotai.atom(BuilderAIModeConfigs) as jotai.Atom<Record<string, AIModeConfigType>>; - } else { - this.aiModeConfigs = atoms.waveaiModeConfigAtom; - } - - this.hasPremiumAtom = jotai.atom((get) => { - const rateLimitInfo = get(atoms.waveAIRateLimitInfoAtom); - return !rateLimitInfo || rateLimitInfo.unknown || rateLimitInfo.preq > 0; - }); - - this.widgetAccessAtom = jotai.atom((get) => { - if (this.inBuilder) { - return true; - } - const widgetAccessMetaAtom = getOrefMetaKeyAtom(this.orefContext, "waveai:widgetcontext"); - const value = get(widgetAccessMetaAtom); - return value ?? true; - }); - - this.codeBlockMaxWidth = jotai.atom((get) => { - const width = get(this.containerWidth); - return width > 0 ? width - 35 : 0; - }); - - this.isWaveAIFocusedAtom = jotai.atom((get) => { - if (this.inBuilder) { - return get(BuilderFocusManager.getInstance().focusType) === "waveai"; - } - return get(FocusManager.getInstance().focusType) === "waveai"; - }); - - this.panelVisibleAtom = jotai.atom((get) => { - if (this.inBuilder) { - return true; - } - return get(WorkspaceLayoutModel.getInstance().panelVisibleAtom); - }); - - this.defaultModeAtom = jotai.atom((get) => { - const telemetryEnabled = get(getSettingsKeyAtom("telemetry:enabled")) ?? false; - if (this.inBuilder) { - return telemetryEnabled ? "waveaibuilder@default" : "invalid"; - } - const aiModeConfigs = get(this.aiModeConfigs); - if (!telemetryEnabled) { - let mode = get(getSettingsKeyAtom("waveai:defaultmode")); - if (mode == null || mode.startsWith("waveai@")) { - return "unknown"; - } - return mode; - } - const hasPremium = get(this.hasPremiumAtom); - const waveFallback = hasPremium ? "waveai@balanced" : "waveai@quick"; - let mode = get(getSettingsKeyAtom("waveai:defaultmode")) ?? waveFallback; - if (!hasPremium && mode.startsWith("waveai@")) { - mode = "waveai@quick"; - } - const modeExists = aiModeConfigs != null && mode in aiModeConfigs; - if (!modeExists) { - mode = waveFallback; - } - return mode; - }); - - const defaultMode = globalStore.get(this.defaultModeAtom); - this.currentAIMode = jotai.atom(defaultMode); - } - - getPanelVisibleAtom(): jotai.Atom<boolean> { - return this.panelVisibleAtom; - } - - static getInstance(): WaveAIModel { - if (!WaveAIModel.instance) { - let orefContext: ORef; - if (isBuilderWindow()) { - const builderId = globalStore.get(atoms.builderId); - orefContext = WOS.makeORef("builder", builderId); - } else { - const tabId = globalStore.get(atoms.staticTabId); - orefContext = WOS.makeORef("tab", tabId); - } - WaveAIModel.instance = new WaveAIModel(orefContext, isBuilderWindow()); - (window as any).WaveAIModel = WaveAIModel.instance; - } - return WaveAIModel.instance; - } - - static resetInstance(): void { - WaveAIModel.instance = null; - } - - getUseChatEndpointUrl(): string { - return `${getWebServerEndpoint()}/api/post-chat-message`; - } - - async addFile(file: File): Promise<DroppedFile> { - // Resize images before storing - const processedFile = await resizeImage(file); - - const droppedFile: DroppedFile = { - id: crypto.randomUUID(), - file: processedFile, - name: processedFile.name, - type: processedFile.type, - size: processedFile.size, - }; - - // Create 128x128 preview data URL for images - if (processedFile.type.startsWith("image/")) { - const previewDataUrl = await createImagePreview(processedFile); - if (previewDataUrl) { - droppedFile.previewUrl = previewDataUrl; - } - } - - const currentFiles = globalStore.get(this.droppedFiles); - globalStore.set(this.droppedFiles, [...currentFiles, droppedFile]); - - return droppedFile; - } - - async addFileFromRemoteUri(draggedFile: DraggedFile): Promise<void> { - if (draggedFile.isDir) { - this.setError("Cannot add directories to Wave AI. Please select a file."); - return; - } - - try { - const fileInfo = await RpcApi.FileInfoCommand(TabRpcClient, { info: { path: draggedFile.uri } }, null); - if (fileInfo.notfound) { - this.setError(`File not found: ${draggedFile.relName}`); - return; - } - if (fileInfo.isdir) { - this.setError("Cannot add directories to Wave AI. Please select a file."); - return; - } - - const mimeType = fileInfo.mimetype || "application/octet-stream"; - const fileSize = fileInfo.size || 0; - const sizeError = validateFileSizeFromInfo(draggedFile.relName, fileSize, mimeType); - if (sizeError) { - this.setError(formatFileSizeError(sizeError)); - return; - } - - const fileData = await RpcApi.FileReadCommand(TabRpcClient, { info: { path: draggedFile.uri } }, null); - if (!fileData.data64) { - this.setError(`Failed to read file: ${draggedFile.relName}`); - return; - } - - const buffer = base64ToArrayBuffer(fileData.data64); - const file = new File([buffer], draggedFile.relName, { type: mimeType }); - if (!isAcceptableFile(file)) { - this.setError( - `File type not supported: ${draggedFile.relName}. Supported: images, PDFs, and text/code files.` - ); - return; - } - - await this.addFile(file); - } catch (error) { - console.error("Error handling FILE_ITEM drop:", error); - const errorMsg = error instanceof Error ? error.message : String(error); - this.setError(`Failed to add file: ${errorMsg}`); - } - } - - removeFile(fileId: string) { - const currentFiles = globalStore.get(this.droppedFiles); - const updatedFiles = currentFiles.filter((f) => f.id !== fileId); - globalStore.set(this.droppedFiles, updatedFiles); - } - - clearFiles() { - const currentFiles = globalStore.get(this.droppedFiles); - - // Cleanup all preview URLs - currentFiles.forEach((file) => { - if (file.previewUrl) { - URL.revokeObjectURL(file.previewUrl); - } - }); - - globalStore.set(this.droppedFiles, []); - } - - clearChat() { - this.useChatStop?.(); - this.clearFiles(); - this.clearError(); - globalStore.set(this.isChatEmptyAtom, true); - const newChatId = crypto.randomUUID(); - globalStore.set(this.chatId, newChatId); - - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: this.orefContext, - data: { "waveai:chatid": newChatId }, - }); - - this.useChatSetMessages?.([]); - } - - setError(message: string) { - globalStore.set(this.errorMessage, message); - } - - clearError() { - globalStore.set(this.errorMessage, null); - } - - registerInputRef(ref: React.RefObject<AIPanelInputRef>) { - this.inputRef = ref; - } - - registerScrollToBottom(callback: () => void) { - this.scrollToBottomCallback = callback; - } - - registerUseChatData( - sendMessage: UseChatSendMessageType, - setMessages: UseChatSetMessagesType, - status: ChatStatus, - stop: () => void - ) { - this.useChatSendMessage = sendMessage; - this.useChatSetMessages = setMessages; - this.useChatStatus = status; - this.useChatStop = stop; - } - - scrollToBottom() { - this.scrollToBottomCallback?.(); - } - - focusInput() { - if (!this.inBuilder && !WorkspaceLayoutModel.getInstance().getAIPanelVisible()) { - WorkspaceLayoutModel.getInstance().setAIPanelVisible(true); - } - if (this.inputRef?.current) { - this.inputRef.current.focus(); - } - } - - async reloadChatFromBackend(chatIdValue: string): Promise<WaveUIMessage[]> { - const chatData = await RpcApi.GetWaveAIChatCommand(TabRpcClient, { chatid: chatIdValue }); - const messages: UIMessage[] = chatData?.messages ?? []; - globalStore.set(this.isChatEmptyAtom, messages.length === 0); - return messages as WaveUIMessage[]; - } - - async stopResponse() { - this.useChatStop?.(); - await new Promise((resolve) => setTimeout(resolve, 500)); - - const chatIdValue = globalStore.get(this.chatId); - if (!chatIdValue) { - return; - } - try { - const messages = await this.reloadChatFromBackend(chatIdValue); - this.useChatSetMessages?.(messages); - } catch (error) { - console.error("Failed to reload chat after stop:", error); - } - } - - getAndClearMessage(): AIMessage | null { - const msg = this.realMessage; - this.realMessage = null; - return msg; - } - - hasNonEmptyInput(): boolean { - const input = globalStore.get(this.inputAtom); - return input != null && input.trim().length > 0; - } - - appendText(text: string, newLine?: boolean, opts?: { scrollToBottom?: boolean }) { - const currentInput = globalStore.get(this.inputAtom); - let newInput = currentInput; - - if (newInput.length > 0) { - if (newLine) { - if (!newInput.endsWith("\n")) { - newInput += "\n"; - } - } else if (!newInput.endsWith(" ") && !newInput.endsWith("\n")) { - newInput += " "; - } - } - - newInput += text; - globalStore.set(this.inputAtom, newInput); - - if (opts?.scrollToBottom && this.inputRef?.current) { - setTimeout(() => this.inputRef.current.scrollToBottom(), 10); - } - } - - setModel(model: string) { - RpcApi.SetMetaCommand(TabRpcClient, { - oref: this.orefContext, - meta: { "waveai:model": model }, - }); - } - - setWidgetAccess(enabled: boolean) { - RpcApi.SetMetaCommand(TabRpcClient, { - oref: this.orefContext, - meta: { "waveai:widgetcontext": enabled }, - }); - } - - isValidMode(mode: string): boolean { - const telemetryEnabled = globalStore.get(getSettingsKeyAtom("telemetry:enabled")) ?? false; - if (mode.startsWith("waveai@") && !telemetryEnabled) { - return false; - } - - const aiModeConfigs = globalStore.get(this.aiModeConfigs); - if (aiModeConfigs == null || !(mode in aiModeConfigs)) { - return false; - } - - return true; - } - - setAIMode(mode: string) { - if (!this.isValidMode(mode)) { - this.setAIModeToDefault(); - } else { - globalStore.set(this.currentAIMode, mode); - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: this.orefContext, - data: { "waveai:mode": mode }, - }); - } - } - - setAIModeToDefault() { - const defaultMode = globalStore.get(this.defaultModeAtom); - globalStore.set(this.currentAIMode, defaultMode); - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: this.orefContext, - data: { "waveai:mode": null }, - }); - } - - async fixModeAfterConfigChange(): Promise<void> { - const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { - oref: this.orefContext, - }); - const mode = rtInfo?.["waveai:mode"]; - if (mode == null || !this.isValidMode(mode)) { - this.setAIModeToDefault(); - } - } - - async getRTInfo(): Promise<Record<string, any>> { - const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { - oref: this.orefContext, - }); - return rtInfo ?? {}; - } - - async loadInitialChat(): Promise<WaveUIMessage[]> { - const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { - oref: this.orefContext, - }); - let chatIdValue = rtInfo?.["waveai:chatid"]; - if (chatIdValue == null) { - chatIdValue = crypto.randomUUID(); - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: this.orefContext, - data: { "waveai:chatid": chatIdValue }, - }); - } - globalStore.set(this.chatId, chatIdValue); - - const aiModeValue = rtInfo?.["waveai:mode"]; - if (aiModeValue == null) { - const defaultMode = globalStore.get(this.defaultModeAtom); - globalStore.set(this.currentAIMode, defaultMode); - } else if (this.isValidMode(aiModeValue)) { - globalStore.set(this.currentAIMode, aiModeValue); - } else { - this.setAIModeToDefault(); - } - - try { - return await this.reloadChatFromBackend(chatIdValue); - } catch (error) { - console.error("Failed to load chat:", error); - this.setError("Failed to load chat. Starting new chat..."); - - this.clearChat(); - return []; - } - } - - async handleSubmit() { - const input = globalStore.get(this.inputAtom); - const droppedFiles = globalStore.get(this.droppedFiles); - - if (input.trim() === "/clear" || input.trim() === "/new") { - this.clearChat(); - globalStore.set(this.inputAtom, ""); - return; - } - - if ( - (!input.trim() && droppedFiles.length === 0) || - (this.useChatStatus !== "ready" && this.useChatStatus !== "error") || - globalStore.get(this.isLoadingChatAtom) - ) { - return; - } - - this.clearError(); - - const aiMessageParts: AIMessagePart[] = []; - const uiMessageParts: WaveUIMessagePart[] = []; - - if (input.trim()) { - aiMessageParts.push({ type: "text", text: input.trim() }); - uiMessageParts.push({ type: "text", text: input.trim() }); - } - - for (const droppedFile of droppedFiles) { - const normalizedMimeType = normalizeMimeType(droppedFile.file); - const dataUrl = await createDataUrl(droppedFile.file); - - aiMessageParts.push({ - type: "file", - filename: droppedFile.name, - mimetype: normalizedMimeType, - url: dataUrl, - size: droppedFile.file.size, - previewurl: droppedFile.previewUrl, - }); - - uiMessageParts.push({ - type: "data-userfile", - data: { - filename: droppedFile.name, - mimetype: normalizedMimeType, - size: droppedFile.file.size, - previewurl: droppedFile.previewUrl, - }, - }); - } - - const realMessage: AIMessage = { - messageid: crypto.randomUUID(), - parts: aiMessageParts, - }; - this.realMessage = realMessage; - - // console.log("SUBMIT MESSAGE", realMessage); - - this.useChatSendMessage?.({ parts: uiMessageParts }); - - globalStore.set(this.isChatEmptyAtom, false); - globalStore.set(this.inputAtom, ""); - this.clearFiles(); - } - - async uiLoadInitialChat() { - globalStore.set(this.isLoadingChatAtom, true); - const messages = await this.loadInitialChat(); - this.useChatSetMessages?.(messages); - globalStore.set(this.isLoadingChatAtom, false); - setTimeout(() => { - this.scrollToBottom(); - }, 100); - } - - async ensureRateLimitSet() { - const currentInfo = globalStore.get(atoms.waveAIRateLimitInfoAtom); - if (currentInfo != null) { - return; - } - try { - const rateLimitInfo = await RpcApi.GetWaveAIRateLimitCommand(TabRpcClient); - if (rateLimitInfo != null) { - globalStore.set(atoms.waveAIRateLimitInfoAtom, rateLimitInfo); - } - } catch (error) { - console.error("Failed to fetch rate limit info:", error); - } - } - - handleAIFeedback(feedback: "good" | "bad") { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "waveai:feedback", - props: { - "waveai:feedback": feedback, - }, - }, - { noresponse: true } - ); - } - - requestWaveAIFocus() { - if (this.inBuilder) { - BuilderFocusManager.getInstance().setWaveAIFocused(); - } else { - FocusManager.getInstance().requestWaveAIFocus(); - } - } - - requestNodeFocus() { - if (this.inBuilder) { - BuilderFocusManager.getInstance().setAppFocused(); - } else { - FocusManager.getInstance().requestNodeFocus(); - } - } - - getChatId(): string { - return globalStore.get(this.chatId); - } - - toolUseSendApproval(toolcallid: string, approval: string) { - RpcApi.WaveAIToolApproveCommand(TabRpcClient, { - toolcallid: toolcallid, - approval: approval, - }); - } - - async openDiff(fileName: string, toolcallid: string) { - const chatId = this.getChatId(); - - if (!chatId || !fileName) { - console.error("Missing chatId or fileName for opening diff", chatId, fileName); - return; - } - - const blockDef: BlockDef = { - meta: { - view: "aifilediff", - file: fileName, - "aifilediff:chatid": chatId, - "aifilediff:toolcallid": toolcallid, - }, - }; - await createBlock(blockDef, false, true); - } - - async openWaveAIConfig() { - const blockDef: BlockDef = { - meta: { - view: "waveconfig", - file: "waveai.json", - }, - }; - await createBlock(blockDef, false, true); - } - - openRestoreBackupModal(toolcallid: string) { - globalStore.set(this.restoreBackupModalToolCallId, toolcallid); - } - - closeRestoreBackupModal() { - globalStore.set(this.restoreBackupModalToolCallId, null); - globalStore.set(this.restoreBackupStatus, "idle"); - globalStore.set(this.restoreBackupError, null); - } - - async restoreBackup(toolcallid: string, backupFilePath: string, restoreToFileName: string) { - globalStore.set(this.restoreBackupStatus, "processing"); - globalStore.set(this.restoreBackupError, null); - try { - await RpcApi.FileRestoreBackupCommand(TabRpcClient, { - backupfilepath: backupFilePath, - restoretofilename: restoreToFileName, - }); - console.log("Backup restored successfully:", { toolcallid, backupFilePath, restoreToFileName }); - globalStore.set(this.restoreBackupStatus, "success"); - } catch (error) { - console.error("Failed to restore backup:", error); - const errorMsg = error?.message || String(error); - globalStore.set(this.restoreBackupError, errorMsg); - globalStore.set(this.restoreBackupStatus, "error"); - } - } - - canCloseWaveAIPanel(): boolean { - if (this.inBuilder) { - return false; - } - return true; - } - - closeWaveAIPanel() { - if (this.inBuilder) { - return; - } - WorkspaceLayoutModel.getInstance().setAIPanelVisible(false); - } -} diff --git a/frontend/app/app-bg.tsx b/frontend/app/app-bg.tsx deleted file mode 100644 index 2956e36d58..0000000000 --- a/frontend/app/app-bg.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { MetaKeyAtomFnType, useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; -import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; -import { computeBgStyleFromMeta } from "@/util/waveutil"; -import useResizeObserver from "@react-hook/resize-observer"; -import { useAtomValue } from "jotai"; -import { CSSProperties, useCallback, useLayoutEffect, useRef } from "react"; -import { debounce } from "throttle-debounce"; -import { atoms, getApi, WOS } from "./store/global"; -import { useWaveObjectValue } from "./store/wos"; - -type AppBgEnv = WaveEnvSubset<{ - getTabMetaKeyAtom: MetaKeyAtomFnType<"tab:background">; - getConfigBackgroundAtom: WaveEnv["getConfigBackgroundAtom"]; -}>; - -export function AppBackground() { - const bgRef = useRef<HTMLDivElement>(null); - const tabId = useAtomValue(atoms.staticTabId); - const [tabData] = useWaveObjectValue<Tab>(WOS.makeORef("tab", tabId)); - const env = useWaveEnv<AppBgEnv>(); - const tabBg = useAtomValue(env.getTabMetaKeyAtom(tabId, "tab:background")); - const configBg = useAtomValue(env.getConfigBackgroundAtom(tabBg)); - const resolvedMeta: Omit<BackgroundConfigType, "display:name"> = tabBg && configBg ? configBg : tabData?.meta; - const style: CSSProperties = computeBgStyleFromMeta(resolvedMeta, 0.5) ?? {}; - const getAvgColor = useCallback( - debounce(30, () => { - if ( - bgRef.current && - PLATFORM !== PlatformMacOS && - bgRef.current && - "windowControlsOverlay" in window.navigator - ) { - const titlebarRect: Dimensions = (window.navigator.windowControlsOverlay as any).getTitlebarAreaRect(); - const bgRect = bgRef.current.getBoundingClientRect(); - if (titlebarRect && bgRect) { - const windowControlsLeft = titlebarRect.width - titlebarRect.height; - const windowControlsRect: Dimensions = { - top: titlebarRect.top, - left: windowControlsLeft, - height: titlebarRect.height, - width: bgRect.width - bgRect.left - windowControlsLeft, - }; - getApi().updateWindowControlsOverlay(windowControlsRect); - } - } - }), - [bgRef, style] - ); - useLayoutEffect(getAvgColor, [getAvgColor]); - useResizeObserver(bgRef, getAvgColor); - - return ( - <div - ref={bgRef} - className="pointer-events-none absolute top-0 left-0 w-full h-full z-[var(--zindex-app-background)]" - style={style} - /> - ); -} diff --git a/frontend/app/app.scss b/frontend/app/app.scss deleted file mode 100644 index b85a6da3b0..0000000000 --- a/frontend/app/app.scss +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -@use "reset.scss"; -@use "theme.scss"; - -html { - overflow: hidden; -} - -body { - display: flex; - flex-direction: row; - width: 100vw; - height: 100vh; - color: var(--main-text-color); - font: var(--base-font); - overflow: hidden; - background: rgb(from var(--main-bg-color) r g b / var(--window-opacity)); - -webkit-font-smoothing: auto; - backface-visibility: hidden; - transform: translateZ(0); -} - -.is-transparent { - background-color: transparent; -} - -*::-webkit-scrollbar { - width: 4px; - height: 4px; -} - -*::-webkit-scrollbar-track { - background-color: var(--scrollbar-background-color); -} - -*::-webkit-scrollbar-thumb { - background-color: var(--scrollbar-thumb-color); - border-radius: 4px; - margin: 0 1px 0 1px; -} - -*::-webkit-scrollbar-thumb:hover { - background-color: var(--scrollbar-thumb-hover-color); -} - -.flex-spacer { - flex-grow: 1; -} - -.text-fixed { - font: var(--fixed-font); -} - -.error-boundary { - white-space: pre-wrap; - color: var(--error-color); -} - -.error-color { - color: var(--error-color); -} - -/* OverlayScrollbars styling */ -.os-scrollbar { - --os-handle-bg: var(--scrollbar-thumb-color); - --os-handle-bg-hover: var(--scrollbar-thumb-hover-color); - --os-handle-bg-active: var(--scrollbar-thumb-active-color); -} - -.scrollbar-hide-until-hover { - *::-webkit-scrollbar-thumb, - *::-webkit-scrollbar-track { - display: none; - } - - *::-webkit-scrollbar-corner { - display: none; - } - - *:hover::-webkit-scrollbar-thumb { - display: block; - } -} - -a { - color: var(--accent-color); -} - -.prefers-reduced-motion { - * { - transition-duration: none !important; - transition-timing-function: none !important; - transition-property: none !important; - transition-delay: none !important; - } -} diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx deleted file mode 100644 index e9c70a35df..0000000000 --- a/frontend/app/app.tsx +++ /dev/null @@ -1,393 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { - clearBadgesForBlockOnFocus, - clearBadgesForTabOnFocus, - getBadgeAtom, - getBlockBadgeAtom, -} from "@/app/store/badge"; -import { ClientModel } from "@/app/store/client-model"; -import { FocusManager } from "@/app/store/focusManager"; -import { GlobalModel } from "@/app/store/global-model"; -import { globalStore } from "@/app/store/jotaiStore"; -import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; -import { WaveEnvContext } from "@/app/waveenv/waveenv"; -import { makeWaveEnvImpl } from "@/app/waveenv/waveenvimpl"; -import { Workspace } from "@/app/workspace/workspace"; -import { getLayoutModelForStaticTab } from "@/layout/index"; -import { ContextMenuModel } from "@/store/contextmenu"; -import { atoms, createBlock, getSettingsPrefixAtom, refocusNode } from "@/store/global"; -import { appHandleKeyDown, keyboardMouseDownHandler } from "@/store/keymodel"; -import { getElemAsStr } from "@/util/focusutil"; -import * as keyutil from "@/util/keyutil"; -import { PLATFORM } from "@/util/platformutil"; -import * as util from "@/util/util"; -import clsx from "clsx"; -import debug from "debug"; -import { Provider, useAtomValue } from "jotai"; -import "overlayscrollbars/overlayscrollbars.css"; -import { useEffect, useRef } from "react"; -import { DndProvider } from "react-dnd"; -import { HTML5Backend } from "react-dnd-html5-backend"; -import { AppBackground } from "./app-bg"; -import { CenteredDiv } from "./element/quickelems"; - -import "./app.scss"; - -// tailwindsetup.css should come *after* app.scss (don't remove the newline above otherwise prettier will reorder these imports) -import "../tailwindsetup.css"; - -const dlog = debug("wave:app"); -const focusLog = debug("wave:focus"); - -const App = ({ onFirstRender }: { onFirstRender: () => void }) => { - const tabId = useAtomValue(atoms.staticTabId); - const waveEnvRef = useRef(makeWaveEnvImpl()); - useEffect(() => { - onFirstRender(); - }, []); - return ( - <Provider store={globalStore}> - <WaveEnvContext.Provider value={waveEnvRef.current}> - <TabModelContext.Provider value={getTabModelByTabId(tabId)}> - <AppInner /> - </TabModelContext.Provider> - </WaveEnvContext.Provider> - </Provider> - ); -}; - -function isContentEditableBeingEdited(): boolean { - const activeElement = document.activeElement; - return ( - activeElement && - activeElement.getAttribute("contenteditable") !== null && - activeElement.getAttribute("contenteditable") !== "false" - ); -} - -function canEnablePaste(): boolean { - const activeElement = document.activeElement; - return activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || isContentEditableBeingEdited(); -} - -function canEnableCopy(): boolean { - const sel = window.getSelection(); - return !util.isBlank(sel?.toString()); -} - -function canEnableCut(): boolean { - const sel = window.getSelection(); - if (document.activeElement?.classList.contains("xterm-helper-textarea")) { - return false; - } - return !util.isBlank(sel?.toString()) && canEnablePaste(); -} - -async function getClipboardURL(): Promise<URL> { - try { - const clipboardText = await navigator.clipboard.readText(); - if (clipboardText == null) { - return null; - } - const url = new URL(clipboardText); - if (!url.protocol.startsWith("http")) { - return null; - } - return url; - } catch (e) { - return null; - } -} - -async function handleContextMenu(e: React.MouseEvent<HTMLDivElement>) { - e.preventDefault(); - const canPaste = canEnablePaste(); - const canCopy = canEnableCopy(); - const canCut = canEnableCut(); - const clipboardURL = await getClipboardURL(); - if (!canPaste && !canCopy && !canCut && !clipboardURL) { - return; - } - const menu: ContextMenuItem[] = []; - if (canCut) { - menu.push({ label: "Cut", role: "cut" }); - } - if (canCopy) { - menu.push({ label: "Copy", role: "copy" }); - } - if (canPaste) { - menu.push({ label: "Paste", role: "paste" }); - } - if (clipboardURL) { - menu.push({ type: "separator" }); - menu.push({ - label: "Open Clipboard URL (" + clipboardURL.hostname + ")", - click: () => { - createBlock({ - meta: { - view: "web", - url: clipboardURL.toString(), - }, - }); - }, - }); - } - ContextMenuModel.getInstance().showContextMenu(menu, e); -} - -function AppSettingsUpdater() { - const windowSettingsAtom = getSettingsPrefixAtom("window"); - const windowSettings = useAtomValue(windowSettingsAtom); - useEffect(() => { - const isTransparentOrBlur = - (windowSettings?.["window:transparent"] || windowSettings?.["window:blur"]) ?? false; - const opacity = util.boundNumber(windowSettings?.["window:opacity"] ?? 0.8, 0, 1); - const baseBgColor = windowSettings?.["window:bgcolor"]; - const mainDiv = document.getElementById("main"); - // console.log("window settings", windowSettings, isTransparentOrBlur, opacity, baseBgColor, mainDiv); - if (isTransparentOrBlur) { - mainDiv.classList.add("is-transparent"); - if (opacity != null) { - document.body.style.setProperty("--window-opacity", `${opacity}`); - } else { - document.body.style.removeProperty("--window-opacity"); - } - } else { - mainDiv.classList.remove("is-transparent"); - document.body.style.removeProperty("--window-opacity"); - } - if (baseBgColor != null) { - document.body.style.setProperty("--main-bg-color", baseBgColor); - } else { - document.body.style.removeProperty("--main-bg-color"); - } - }, [windowSettings]); - return null; -} - -function appFocusIn(e: FocusEvent) { - focusLog("focusin", getElemAsStr(e.target), "<=", getElemAsStr(e.relatedTarget)); -} - -function appFocusOut(e: FocusEvent) { - focusLog("focusout", getElemAsStr(e.target), "=>", getElemAsStr(e.relatedTarget)); -} - -function appSelectionChange(e: Event) { - const selection = document.getSelection(); - focusLog("selectionchange", getElemAsStr(selection.anchorNode)); -} - -function AppFocusHandler() { - return null; - - // for debugging - useEffect(() => { - document.addEventListener("focusin", appFocusIn); - document.addEventListener("focusout", appFocusOut); - document.addEventListener("selectionchange", appSelectionChange); - const ivId = setInterval(() => { - const activeElement = document.activeElement; - if (activeElement instanceof HTMLElement) { - focusLog("activeElement", getElemAsStr(activeElement)); - } - }, 2000); - return () => { - document.removeEventListener("focusin", appFocusIn); - document.removeEventListener("focusout", appFocusOut); - document.removeEventListener("selectionchange", appSelectionChange); - clearInterval(ivId); - }; - }); - return null; -} - -const MacOSFirstClickHandler = () => { - useEffect(() => { - if (PLATFORM !== "darwin") { - return; - } - let windowFocusTime: number = null; - let cancelNextClick = false; - const handleWindowFocus = (e: FocusEvent) => { - windowFocusTime = Date.now(); - }; - const getBlockIdFromTarget = (target: EventTarget): string => { - let elem = target as HTMLElement; - while (elem != null) { - const blockId = elem.dataset?.blockid; - if (blockId) { - return blockId; - } - elem = elem.parentElement; - } - return null; - }; - const isAIPanelTarget = (target: EventTarget): boolean => { - let elem = target as HTMLElement; - while (elem != null) { - if (elem.dataset?.aipanel) { - return true; - } - elem = elem.parentElement; - } - return false; - }; - const handleMouseDown = (e: MouseEvent) => { - const timeDiff = Date.now() - windowFocusTime; - if (windowFocusTime != null && timeDiff < 50) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - cancelNextClick = true; - const blockId = getBlockIdFromTarget(e.target); - if (blockId != null) { - setTimeout(() => { - console.log("macos first-click, focusing block", blockId); - refocusNode(blockId); - }, 10); - } else if (isAIPanelTarget(e.target)) { - setTimeout(() => { - console.log("macos first-click, focusing AI panel"); - FocusManager.getInstance().setWaveAIFocused(true); - }, 10); - } - console.log("macos first-click detected, canceled", timeDiff + "ms"); - return; - } - cancelNextClick = false; - }; - const handleClick = (e: MouseEvent) => { - if (!cancelNextClick) { - return; - } - cancelNextClick = false; - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - console.log("macos first-click (click event) canceled"); - }; - window.addEventListener("focus", handleWindowFocus); - window.addEventListener("mousedown", handleMouseDown, true); - window.addEventListener("click", handleClick, true); - return () => { - window.removeEventListener("focus", handleWindowFocus); - window.removeEventListener("mousedown", handleMouseDown, true); - window.removeEventListener("click", handleClick, true); - }; - }, []); - return null; -}; - -const AppKeyHandlers = () => { - useEffect(() => { - const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown); - const staticMouseDownHandler = (e: MouseEvent) => { - keyboardMouseDownHandler(e); - GlobalModel.getInstance().setIsActive(); - }; - document.addEventListener("keydown", staticKeyDownHandler); - document.addEventListener("mousedown", staticMouseDownHandler); - - return () => { - document.removeEventListener("keydown", staticKeyDownHandler); - document.removeEventListener("mousedown", staticMouseDownHandler); - }; - }, []); - return null; -}; - -const BadgeAutoClearing = () => { - const tabId = useAtomValue(atoms.staticTabId); - const documentHasFocus = useAtomValue(atoms.documentHasFocus); - const layoutModel = getLayoutModelForStaticTab(); - const focusedNode = useAtomValue(layoutModel.focusedNode); - const focusedBlockId = focusedNode?.data?.blockId; - const badge = useAtomValue(getBlockBadgeAtom(focusedBlockId)); - const tabTransientBadge = useAtomValue(getBadgeAtom(tabId != null ? `tab:${tabId}` : null)); - const prevFocusedBlockIdRef = useRef<string>(null); - const prevDocHasFocusRef = useRef<boolean>(false); - const prevTabDocHasFocusRef = useRef<boolean>(false); - - useEffect(() => { - if (!focusedBlockId || !badge || !documentHasFocus) { - prevFocusedBlockIdRef.current = focusedBlockId; - prevDocHasFocusRef.current = documentHasFocus; - return; - } - const focusSwitched = - prevFocusedBlockIdRef.current !== focusedBlockId || prevDocHasFocusRef.current !== documentHasFocus; - prevFocusedBlockIdRef.current = focusedBlockId; - prevDocHasFocusRef.current = documentHasFocus; - const delay = focusSwitched ? 500 : 3000; - const timeoutId = setTimeout(() => { - if (!document.hasFocus()) { - return; - } - const currentFocusedNode = globalStore.get(layoutModel.focusedNode); - if (currentFocusedNode?.data?.blockId === focusedBlockId) { - clearBadgesForBlockOnFocus(focusedBlockId); - } - }, delay); - return () => clearTimeout(timeoutId); - }, [focusedBlockId, badge, documentHasFocus]); - - useEffect(() => { - if (!tabId || !tabTransientBadge || !documentHasFocus) { - prevTabDocHasFocusRef.current = documentHasFocus; - return; - } - const focusSwitched = prevTabDocHasFocusRef.current !== documentHasFocus; - prevTabDocHasFocusRef.current = documentHasFocus; - const delay = focusSwitched ? 500 : 3000; - const timeoutId = setTimeout(() => { - if (!document.hasFocus()) { - return; - } - clearBadgesForTabOnFocus(tabId); - }, delay); - return () => clearTimeout(timeoutId); - }, [tabId, tabTransientBadge, documentHasFocus]); - - return null; -}; - -const AppInner = () => { - const prefersReducedMotion = useAtomValue(atoms.prefersReducedMotionAtom); - const client = useAtomValue(ClientModel.getInstance().clientAtom); - const windowData = useAtomValue(GlobalModel.getInstance().windowDataAtom); - const isFullScreen = useAtomValue(atoms.isFullScreen); - - if (client == null || windowData == null) { - return ( - <div className="flex flex-col w-full h-full"> - <AppBackground /> - <CenteredDiv>invalid configuration, client or window was not loaded</CenteredDiv> - </div> - ); - } - - return ( - <div - className={clsx("flex flex-col w-full h-full", PLATFORM, { - fullscreen: isFullScreen, - "prefers-reduced-motion": prefersReducedMotion, - })} - onContextMenu={handleContextMenu} - > - <AppBackground /> - <MacOSFirstClickHandler /> - <AppKeyHandlers /> - <AppFocusHandler /> - <AppSettingsUpdater /> - <BadgeAutoClearing /> - <DndProvider backend={HTML5Backend}> - <Workspace /> - </DndProvider> - </div> - ); -}; - -export { App }; diff --git a/frontend/app/asset/claude-color.svg b/frontend/app/asset/claude-color.svg deleted file mode 100644 index b70e167740..0000000000 --- a/frontend/app/asset/claude-color.svg +++ /dev/null @@ -1 +0,0 @@ -<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg> \ No newline at end of file diff --git a/frontend/app/asset/dots-anim-4.svg b/frontend/app/asset/dots-anim-4.svg deleted file mode 100644 index 028aaf349b..0000000000 --- a/frontend/app/asset/dots-anim-4.svg +++ /dev/null @@ -1,18 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" height="12" width="12" viewBox="0 0 16 16"> -<title>dots anim 4 - - - - - - - - - \ No newline at end of file diff --git a/frontend/app/asset/logo.svg b/frontend/app/asset/logo.svg deleted file mode 100644 index 51c1d820d6..0000000000 --- a/frontend/app/asset/logo.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/app/asset/magnify-disabled.svg b/frontend/app/asset/magnify-disabled.svg deleted file mode 100644 index 1dbe4231ae..0000000000 --- a/frontend/app/asset/magnify-disabled.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - diff --git a/frontend/app/asset/magnify.svg b/frontend/app/asset/magnify.svg deleted file mode 100644 index 09a4919ca4..0000000000 --- a/frontend/app/asset/magnify.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - diff --git a/frontend/app/asset/thunder.svg b/frontend/app/asset/thunder.svg deleted file mode 100644 index 67bce33f9b..0000000000 --- a/frontend/app/asset/thunder.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/app/asset/workspace.svg b/frontend/app/asset/workspace.svg deleted file mode 100644 index 220153c894..0000000000 --- a/frontend/app/asset/workspace.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/frontend/app/block/block-model.ts b/frontend/app/block/block-model.ts deleted file mode 100644 index e2ce23e374..0000000000 --- a/frontend/app/block/block-model.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { globalStore } from "@/app/store/jotaiStore"; -import * as jotai from "jotai"; - -export interface BlockHighlightType { - blockId: string; - icon: string; -} - -export class BlockModel { - private static instance: BlockModel | null = null; - private blockHighlightAtomCache = new Map>(); - - blockHighlightAtom: jotai.PrimitiveAtom = jotai.atom(null) as jotai.PrimitiveAtom; - - private constructor() { - // Empty for now - } - - getBlockHighlightAtom(blockId: string): jotai.Atom { - let atom = this.blockHighlightAtomCache.get(blockId); - if (!atom) { - atom = jotai.atom((get) => { - const highlight = get(this.blockHighlightAtom); - if (highlight?.blockId === blockId) { - return highlight; - } - return null; - }); - this.blockHighlightAtomCache.set(blockId, atom); - } - return atom; - } - - setBlockHighlight(highlight: BlockHighlightType | null) { - globalStore.set(this.blockHighlightAtom, highlight); - } - - static getInstance(): BlockModel { - if (!BlockModel.instance) { - BlockModel.instance = new BlockModel(); - } - return BlockModel.instance; - } - - static resetInstance(): void { - BlockModel.instance = null; - } -} \ No newline at end of file diff --git a/frontend/app/block/block.scss b/frontend/app/block/block.scss deleted file mode 100644 index 6b0fda769c..0000000000 --- a/frontend/app/block/block.scss +++ /dev/null @@ -1,448 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.block { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - overflow: hidden; - min-height: 0; - border-radius: var(--block-border-radius); - - .block-frame-icon { - margin-right: 0.5em; - } - - .block-content { - position: relative; - display: flex; - flex-grow: 1; - width: 100%; - overflow: hidden; - min-height: 0; - padding: 5px; - - &.block-no-padding { - padding: 0; - } - } - - .block-focuselem { - height: 0; - width: 0; - input { - width: 0; - height: 0; - opacity: 0; - pointer-events: none; - } - } - - .block-header-animation-wrap { - max-height: 0; - transition: - max-height 0.3s ease-out, - opacity 0.3s ease-out; - overflow: hidden; - position: absolute; - top: 0; - width: 100%; - height: 30px; - z-index: var(--zindex-header-hover); - - &.is-showing { - max-height: 30px; - } - } - - &.block-preview.block-frame-default .block-frame-default-inner .block-frame-default-header { - background-color: rgb(from var(--block-bg-color) r g b / 70%); - } - - &.block-frame-default { - position: relative; - padding: 1px; - - .block-frame-default-inner { - background-color: var(--block-bg-color); - width: 100%; - height: 100%; - border-radius: var(--block-border-radius); - display: flex; - flex-direction: column; - - .block-frame-default-header { - max-height: var(--header-height); - min-height: var(--header-height); - display: flex; - padding: 4px 5px 4px 7px; - align-items: center; - gap: 8px; - font: var(--header-font); - border-bottom: 1px solid var(--border-color); - border-radius: var(--block-border-radius) var(--block-border-radius) 0 0; - - .block-frame-default-header-iconview { - display: flex; - flex-shrink: 3; - min-width: 17px; - align-items: center; - gap: 8px; - overflow-x: hidden; - - .block-frame-view-icon { - font-size: var(--header-icon-size); - opacity: 0.5; - width: var(--header-icon-width); - i { - margin-right: 0; - } - } - - .block-frame-view-type { - overflow-x: hidden; - text-wrap: nowrap; - text-overflow: ellipsis; - flex-shrink: 1; - min-width: 0; - } - - .block-frame-blockid { - opacity: 0.5; - } - } - - .block-frame-text { - font: var(--fixed-font); - font-size: 11px; - opacity: 0.7; - flex-grow: 1; - - &.flex-nogrow { - flex-grow: 0; - } - - &.preview-filename { - direction: rtl; - text-align: left; - span { - cursor: pointer; - - &:hover { - background: var(--highlight-bg-color); - } - } - } - } - - .connection-button { - display: flex; - align-items: center; - flex-wrap: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - font-weight: 400; - color: var(--main-text-color); - border-radius: 2px; - padding: auto; - - &:hover { - background-color: var(--highlight-bg-color); - } - - .connection-icon-box { - flex: 1 1 auto; - overflow: hidden; - } - - .connection-name { - flex: 1 2 auto; - overflow: hidden; - padding-right: 4px; - } - - .connecting-svg { - position: relative; - top: 5px; - left: 9px; - svg { - fill: var(--warning-color); - } - } - } - - .block-frame-textelems-wrapper { - display: flex; - flex: 1 2 auto; - min-width: 0; - gap: 8px; - align-items: center; - - .block-frame-div { - display: flex; - width: 100%; - height: 100%; - justify-content: space-between; - align-items: center; - - .input-wrapper { - flex-grow: 1; - - input { - background-color: transparent; - outline: none; - border: none; - color: var(--main-text-color); - width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - box-sizing: border-box; - opacity: 0.7; - font-weight: 500; - } - } - - .wave-button { - margin-left: 3px; - } - - // webview specific. for refresh button - .wave-iconbutton { - height: 100%; - width: 27px; - display: flex; - align-items: center; - justify-content: center; - } - } - - .menubutton { - .wave-button { - font-size: 11px; - } - } - } - - .block-frame-end-icons { - display: flex; - flex-shrink: 0; - - .wave-iconbutton { - display: flex; - width: 24px; - padding: 4px 6px; - align-items: center; - } - - .block-frame-magnify { - justify-content: center; - align-items: center; - padding: 0; - - svg { - #arrow1, - #arrow2 { - fill: var(--main-text-color); - } - } - } - } - } - - .block-frame-preview { - background-color: rgb(from var(--block-bg-color) r g b / 70%); - width: 100%; - flex-grow: 1; - border-bottom-left-radius: var(--block-border-radius); - border-bottom-right-radius: var(--block-border-radius); - display: flex; - align-items: center; - justify-content: center; - - .wave-iconbutton { - opacity: 0.7; - font-size: 45px; - margin: -30px 0 0 0; - } - } - } - - --magnified-block-opacity: 0.6; - --magnified-block-blur: 10px; - - &.magnified, - &.ephemeral { - background-color: rgb(from var(--block-bg-color) r g b / var(--magnified-block-opacity)); - backdrop-filter: blur(var(--magnified-block-blur)); - } - - .connstatus-overlay { - position: absolute; - top: calc(var(--header-height) + 6px); - left: 8px; - right: 8px; - z-index: var(--zindex-block-mask-inner); - display: flex; - align-items: center; - justify-content: flex-start; - flex-direction: column; - overflow: hidden; - background: var(--conn-status-overlay-bg-color); - backdrop-filter: blur(50px); - border-radius: 6px; - box-shadow: 0px 13px 16px 0px rgb(from var(--block-bg-color) r g b / 40%); - opacity: 0.9; - - .connstatus-content { - display: flex; - flex-direction: row; - justify-content: space-between; - padding: 10px 8px 10px 12px; - width: 100%; - font: var(--base-font); - color: var(--secondary-text-color); - - .connstatus-status-icon-wrapper { - display: flex; - flex-direction: row; - align-items: center; - gap: 12px; - flex-grow: 1; - min-width: 0; - - &.has-error { - align-items: flex-start; - } - - > i { - color: #e6ba1e; - font-size: 16px; - } - - .connstatus-status { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 4px; - flex-grow: 1; - width: 100%; - - .connstatus-status-text { - max-width: 100%; - font-size: 11px; - font-style: normal; - font-weight: 600; - line-height: 16px; - letter-spacing: 0.11px; - color: white; - } - - .connstatus-error { - font-size: 11px; - font-style: normal; - font-weight: 400; - line-height: 15px; - letter-spacing: 0.11px; - text-wrap: wrap; - max-height: 80px; - border-radius: 8px; - padding: 5px; - padding-left: 0; - position: relative; - - .copy-button { - visibility: hidden; - display: flex; - position: sticky; - top: 0; - right: 4px; - float: right; - border-radius: 4px; - backdrop-filter: blur(8px); - padding: 0.286em; - align-items: center; - justify-content: flex-end; - gap: 0.286em; - } - - &:hover .copy-button { - visibility: visible; - } - } - } - } - - .connstatus-actions { - display: flex; - align-items: flex-start; - justify-content: center; - gap: 6px; - - button { - i { - font-size: 11px; - opacity: 0.7; - } - } - - .wave-button:last-child { - margin-top: 1.5px; - } - } - } - } - - .block-mask { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border: 2px solid transparent; - pointer-events: none; - padding: 2px; - border-radius: var(--block-border-radius); - z-index: var(--zindex-block-mask-inner); - - &.show-block-mask { - user-select: none; - pointer-events: auto; - } - - &.show-block-mask .block-mask-inner { - margin-top: var(--header-height); // TODO fix this magic - background-color: rgb(from var(--block-bg-color) r g b / 50%); - height: calc(100% - var(--header-height)); - width: 100%; - display: flex; - align-items: center; - justify-content: center; - - .bignum { - margin-top: -15%; - font-size: 60px; - font-weight: bold; - opacity: 0.7; - } - } - } - - &.block-focused { - .block-mask { - border: 2px solid var(--accent-color); - } - - &.block-no-highlight, - &.block-preview { - .block-mask { - border: 2px solid rgb(from var(--border-color) r g b / 10%) !important; - } - } - } - } -} diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx deleted file mode 100644 index f199dc5e9c..0000000000 --- a/frontend/app/block/block.tsx +++ /dev/null @@ -1,336 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { - BlockComponentModel2, - BlockProps, - FullBlockProps, - FullSubBlockProps, - SubBlockProps, -} from "@/app/block/blocktypes"; -import { useTabModel } from "@/app/store/tab-model"; -import { useWaveEnv } from "@/app/waveenv/waveenv"; -import { ErrorBoundary } from "@/element/errorboundary"; -import { CenteredDiv } from "@/element/quickelems"; -import { useDebouncedNodeInnerRect } from "@/layout/index"; -import { counterInc } from "@/store/counters"; -import { getBlockComponentModel, registerBlockComponentModel, unregisterBlockComponentModel } from "@/store/global"; -import { makeORef } from "@/store/wos"; -import { focusedBlockId, getElemAsStr } from "@/util/focusutil"; -import { isBlank, useAtomValueSafe } from "@/util/util"; -import clsx from "clsx"; -import { useAtomValue } from "jotai"; -import { memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import "./block.scss"; -import { BlockEnv } from "./blockenv"; -import { BlockFrame } from "./blockframe"; -import { makeViewModel } from "./blockregistry"; - -function getViewElem( - blockId: string, - blockRef: React.RefObject, - contentRef: React.RefObject, - blockView: string, - viewModel: ViewModel -): React.ReactElement { - if (isBlank(blockView)) { - return No View; - } - if (viewModel.viewComponent == null) { - return No View Component; - } - const VC = viewModel.viewComponent; - return ; -} - -const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => { - const waveEnv = useWaveEnv(); - const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", nodeModel.blockId))); - if (blockIsNull) { - return null; - } - return ( - - ); -}); - -const BlockSubBlock = memo(({ nodeModel, viewModel }: FullSubBlockProps) => { - const waveEnv = useWaveEnv(); - const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", nodeModel.blockId))); - const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")) ?? ""; - const blockRef = useRef(null); - const contentRef = useRef(null); - const viewElem = useMemo( - () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockView, viewModel), - [nodeModel.blockId, blockView, viewModel] - ); - const noPadding = useAtomValueSafe(viewModel.noPadding); - if (blockIsNull) { - return null; - } - return ( -
- - Loading...}>{viewElem} - -
- ); -}); - -const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { - counterInc("render-BlockFull"); - const waveEnv = useWaveEnv(); - const focusElemRef = useRef(null); - const blockRef = useRef(null); - const contentRef = useRef(null); - const pendingFocusRafRef = useRef(null); - const [blockClicked, setBlockClicked] = useState(false); - const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")) ?? ""; - const isFocused = useAtomValue(nodeModel.isFocused); - const disablePointerEvents = useAtomValue(nodeModel.disablePointerEvents); - const isResizing = useAtomValue(nodeModel.isResizing); - const isMagnified = useAtomValue(nodeModel.isMagnified); - const anyMagnified = useAtomValue(nodeModel.anyMagnified); - const modalOpen = useAtomValue(waveEnv.atoms.modalOpen); - const focusFollowsCursorMode = useAtomValue(waveEnv.getSettingsKeyAtom("app:focusfollowscursor")) ?? "off"; - const innerRect = useDebouncedNodeInnerRect(nodeModel); - const noPadding = useAtomValueSafe(viewModel.noPadding); - - useEffect(() => { - return () => { - if (pendingFocusRafRef.current != null) { - cancelAnimationFrame(pendingFocusRafRef.current); - } - }; - }, []); - - useLayoutEffect(() => { - setBlockClicked(isFocused); - }, [isFocused]); - - useLayoutEffect(() => { - if (!blockClicked) { - return; - } - setBlockClicked(false); - const focusWithin = focusedBlockId() == nodeModel.blockId; - if (!focusWithin) { - setFocusTarget(); - } - if (!isFocused) { - nodeModel.focusNode(); - } - }, [blockClicked, isFocused]); - - const setBlockClickedTrue = useCallback(() => { - setBlockClicked(true); - }, []); - - const [blockContentOffset, setBlockContentOffset] = useState(); - - useEffect(() => { - if (blockRef.current && contentRef.current) { - const blockRect = blockRef.current.getBoundingClientRect(); - const contentRect = contentRef.current.getBoundingClientRect(); - setBlockContentOffset({ - top: 0, - left: 0, - width: blockRect.width - contentRect.width, - height: blockRect.height - contentRect.height, - }); - } - }, [blockRef, contentRef]); - - const blockContentStyle = useMemo(() => { - const retVal: React.CSSProperties = { - pointerEvents: disablePointerEvents ? "none" : undefined, - }; - if (innerRect?.width && innerRect.height && blockContentOffset) { - retVal.width = `calc(${innerRect?.width} - ${blockContentOffset.width}px)`; - retVal.height = `calc(${innerRect?.height} - ${blockContentOffset.height}px)`; - } - return retVal; - }, [innerRect, disablePointerEvents, blockContentOffset]); - - const viewElem = useMemo( - () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockView, viewModel), - [nodeModel.blockId, blockView, viewModel] - ); - - const handleChildFocus = useCallback( - (event: React.FocusEvent) => { - console.log("setFocusedChild", nodeModel.blockId, getElemAsStr(event.target)); - if (!isFocused) { - console.log("focusedChild focus", nodeModel.blockId); - nodeModel.focusNode(); - } - }, - [isFocused] - ); - - const setFocusTarget = useCallback(() => { - if (pendingFocusRafRef.current != null) { - cancelAnimationFrame(pendingFocusRafRef.current); - pendingFocusRafRef.current = null; - } - const ok = viewModel?.giveFocus?.(); - if (ok) { - return; - } - focusElemRef.current?.focus({ preventScroll: true }); - pendingFocusRafRef.current = requestAnimationFrame(() => { - pendingFocusRafRef.current = null; - if (blockRef.current?.contains(document.activeElement)) { - viewModel?.giveFocus?.(); - } - }); - }, [viewModel]); - - const focusFromPointerEnter = useCallback( - (event: React.PointerEvent) => { - const focusFollowsCursorEnabled = - focusFollowsCursorMode === "on" || (focusFollowsCursorMode === "term" && blockView === "term"); - if (!focusFollowsCursorEnabled || event.pointerType === "touch" || event.buttons > 0) { - return; - } - if (modalOpen || disablePointerEvents || isResizing || (anyMagnified && !isMagnified)) { - return; - } - if (isFocused && focusedBlockId() === nodeModel.blockId) { - return; - } - setFocusTarget(); - if (!isFocused) { - nodeModel.focusNode(); - } - }, - [ - focusFollowsCursorMode, - blockView, - modalOpen, - disablePointerEvents, - isResizing, - isMagnified, - anyMagnified, - isFocused, - nodeModel, - setFocusTarget, - ] - ); - - const blockModel = useMemo( - () => ({ - onClick: setBlockClickedTrue, - onPointerEnter: focusFromPointerEnter, - onFocusCapture: handleChildFocus, - blockRef: blockRef, - }), - [setBlockClickedTrue, focusFromPointerEnter, handleChildFocus, blockRef] - ); - - return ( - -
- {}} - /> -
-
- - Loading...}>{viewElem} - -
-
- ); -}); - -const BlockInner = memo((props: BlockProps & { viewType: string }) => { - counterInc("render-Block"); - counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8)); - const tabModel = useTabModel(); - const waveEnv = useWaveEnv(); - const bcm = getBlockComponentModel(props.nodeModel.blockId); - let viewModel = bcm?.viewModel; - if (viewModel == null) { - // viewModel gets the full waveEnv - viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv); - registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); - } - useEffect(() => { - return () => { - unregisterBlockComponentModel(props.nodeModel.blockId); - viewModel?.dispose?.(); - }; - }, []); - if (props.preview) { - return ; - } - return ; -}); -BlockInner.displayName = "BlockInner"; - -const Block = memo((props: BlockProps) => { - const waveEnv = useWaveEnv(); - const isNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", props.nodeModel.blockId))); - const viewType = useAtomValue(waveEnv.getBlockMetaKeyAtom(props.nodeModel.blockId, "view")) ?? ""; - if (isNull || isBlank(props.nodeModel.blockId)) { - return null; - } - return ; -}); - -const SubBlockInner = memo((props: SubBlockProps & { viewType: string }) => { - counterInc("render-Block"); - counterInc("render-Block-" + props.nodeModel.blockId?.substring(0, 8)); - const tabModel = useTabModel(); - const waveEnv = useWaveEnv(); - const bcm = getBlockComponentModel(props.nodeModel.blockId); - let viewModel = bcm?.viewModel; - if (viewModel == null) { - // viewModel gets the full waveEnv - viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv); - registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); - } - useEffect(() => { - return () => { - unregisterBlockComponentModel(props.nodeModel.blockId); - viewModel?.dispose?.(); - }; - }, []); - return ; -}); -SubBlockInner.displayName = "SubBlockInner"; - -const SubBlock = memo((props: SubBlockProps) => { - const waveEnv = useWaveEnv(); - const isNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", props.nodeModel.blockId))); - const viewType = useAtomValue(waveEnv.getBlockMetaKeyAtom(props.nodeModel.blockId, "view")) ?? ""; - if (isNull || isBlank(props.nodeModel.blockId)) { - return null; - } - return ; -}); - -export { Block, SubBlock }; diff --git a/frontend/app/block/blockenv.ts b/frontend/app/block/blockenv.ts deleted file mode 100644 index 8a529be11b..0000000000 --- a/frontend/app/block/blockenv.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { - ConnConfigKeyAtomFnType, - MetaKeyAtomFnType, - SettingsKeyAtomFnType, - WaveEnv, - WaveEnvSubset, -} from "@/app/waveenv/waveenv"; - -export type BlockEnv = WaveEnvSubset<{ - getSettingsKeyAtom: SettingsKeyAtomFnType< - | "app:focusfollowscursor" - | "app:showoverlayblocknums" - | "term:showsplitbuttons" - | "window:magnifiedblockblurprimarypx" - | "window:magnifiedblockopacity" - >; - showContextMenu: WaveEnv["showContextMenu"]; - atoms: { - modalOpen: WaveEnv["atoms"]["modalOpen"]; - controlShiftDelayAtom: WaveEnv["atoms"]["controlShiftDelayAtom"]; - }; - electron: { - openExternal: WaveEnv["electron"]["openExternal"]; - }; - rpc: { - ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; - ConnEnsureCommand: WaveEnv["rpc"]["ConnEnsureCommand"]; - ConnDisconnectCommand: WaveEnv["rpc"]["ConnDisconnectCommand"]; - ConnConnectCommand: WaveEnv["rpc"]["ConnConnectCommand"]; - SetConnectionsConfigCommand: WaveEnv["rpc"]["SetConnectionsConfigCommand"]; - DismissWshFailCommand: WaveEnv["rpc"]["DismissWshFailCommand"]; - }; - wos: WaveEnv["wos"]; - getConnStatusAtom: WaveEnv["getConnStatusAtom"]; - getLocalHostDisplayNameAtom: WaveEnv["getLocalHostDisplayNameAtom"]; - getConnConfigKeyAtom: ConnConfigKeyAtomFnType<"conn:wshenabled">; - getBlockMetaKeyAtom: MetaKeyAtomFnType< - | "frame:text" - | "frame:activebordercolor" - | "frame:bordercolor" - | "view" - | "connection" - | "icon:color" - | "frame:title" - | "frame:icon" - >; - getTabMetaKeyAtom: MetaKeyAtomFnType<"bg:activebordercolor" | "bg:bordercolor" | "tab:background">; - getConfigBackgroundAtom: WaveEnv["getConfigBackgroundAtom"]; -}>; diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx deleted file mode 100644 index a70f323e71..0000000000 --- a/frontend/app/block/blockframe-header.tsx +++ /dev/null @@ -1,294 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { - blockViewToIcon, - blockViewToName, - getViewIconElem, - OptMagnifyButton, - renderHeaderElements, -} from "@/app/block/blockutil"; -import { ConnectionButton } from "@/app/block/connectionbutton"; -import { DurableSessionFlyover } from "@/app/block/durable-session-flyover"; -import { getBlockBadgeAtom } from "@/app/store/badge"; -import { - createBlockSplitHorizontally, - createBlockSplitVertically, - recordTEvent, - refocusNode, - WOS, -} from "@/app/store/global"; -import { globalStore } from "@/app/store/jotaiStore"; -import { uxCloseBlock } from "@/app/store/keymodel"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { useWaveEnv } from "@/app/waveenv/waveenv"; -import { IconButton } from "@/element/iconbutton"; -import { NodeModel } from "@/layout/index"; -import * as util from "@/util/util"; -import { cn, makeIconClass } from "@/util/util"; -import * as jotai from "jotai"; -import * as React from "react"; -import { BlockEnv } from "./blockenv"; -import { BlockFrameProps } from "./blocktypes"; - -function handleHeaderContextMenu( - e: React.MouseEvent, - blockId: string, - viewModel: ViewModel, - nodeModel: NodeModel, - blockEnv: BlockEnv -) { - e.preventDefault(); - e.stopPropagation(); - const magnified = globalStore.get(nodeModel.isMagnified); - const menu: ContextMenuItem[] = [ - { - label: magnified ? "Un-Magnify Block" : "Magnify Block", - click: () => { - nodeModel.toggleMagnify(); - }, - }, - { type: "separator" }, - { - label: "Copy BlockId", - click: () => { - navigator.clipboard.writeText(blockId); - }, - }, - ]; - const extraItems = viewModel?.getSettingsMenuItems?.(); - if (extraItems && extraItems.length > 0) menu.push({ type: "separator" }, ...extraItems); - menu.push( - { type: "separator" }, - { - label: "Close Block", - click: () => uxCloseBlock(blockId), - } - ); - blockEnv.showContextMenu(menu, e); -} - -type HeaderTextElemsProps = { - viewModel: ViewModel; - blockId: string; - preview: boolean; - error?: Error; -}; - -const HeaderTextElems = React.memo(({ viewModel, blockId, preview, error }: HeaderTextElemsProps) => { - const waveEnv = useWaveEnv(); - const frameTextAtom = waveEnv.getBlockMetaKeyAtom(blockId, "frame:text"); - const frameText = jotai.useAtomValue(frameTextAtom); - let headerTextUnion = util.useAtomValueSafe(viewModel?.viewText); - headerTextUnion = frameText ?? headerTextUnion; - - const headerTextElems: React.ReactElement[] = []; - if (typeof headerTextUnion === "string") { - if (!util.isBlank(headerTextUnion)) { - headerTextElems.push( -
- ‎{headerTextUnion} -
- ); - } - } else if (Array.isArray(headerTextUnion)) { - headerTextElems.push(...renderHeaderElements(headerTextUnion, preview)); - } - if (error != null) { - const copyHeaderErr = () => { - navigator.clipboard.writeText(error.message + "\n" + error.stack); - }; - headerTextElems.push( -
- -
- ); - } - - return
{headerTextElems}
; -}); -HeaderTextElems.displayName = "HeaderTextElems"; - -type HeaderEndIconsProps = { - viewModel: ViewModel; - nodeModel: NodeModel; - blockId: string; -}; - -const HeaderEndIcons = React.memo(({ viewModel, nodeModel, blockId }: HeaderEndIconsProps) => { - const blockEnv = useWaveEnv(); - const endIconButtons = util.useAtomValueSafe(viewModel?.endIconButtons); - const magnified = jotai.useAtomValue(nodeModel.isMagnified); - const ephemeral = jotai.useAtomValue(nodeModel.isEphemeral); - const numLeafs = jotai.useAtomValue(nodeModel.numLeafs); - const magnifyDisabled = numLeafs <= 1; - const showSplitButtons = jotai.useAtomValue(blockEnv.getSettingsKeyAtom("term:showsplitbuttons")); - - const endIconsElem: React.ReactElement[] = []; - - if (endIconButtons && endIconButtons.length > 0) { - endIconsElem.push(...endIconButtons.map((button, idx) => )); - } - if (showSplitButtons && viewModel?.viewType === "term") { - const splitHorizontalDecl: IconButtonDecl = { - elemtype: "iconbutton", - icon: "columns", - title: "Split Horizontally", - click: (e) => { - e.stopPropagation(); - const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); - const blockData = globalStore.get(blockAtom); - const blockDef: BlockDef = { - meta: blockData?.meta || { view: "term", controller: "shell" }, - }; - createBlockSplitHorizontally(blockDef, blockId, "after"); - }, - }; - const splitVerticalDecl: IconButtonDecl = { - elemtype: "iconbutton", - icon: "grip-lines", - title: "Split Vertically", - click: (e) => { - e.stopPropagation(); - const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); - const blockData = globalStore.get(blockAtom); - const blockDef: BlockDef = { - meta: blockData?.meta || { view: "term", controller: "shell" }, - }; - createBlockSplitVertically(blockDef, blockId, "after"); - }, - }; - endIconsElem.push(); - endIconsElem.push(); - } - const settingsDecl: IconButtonDecl = { - elemtype: "iconbutton", - icon: "cog", - title: "Settings", - click: (e) => handleHeaderContextMenu(e, blockId, viewModel, nodeModel, blockEnv), - }; - endIconsElem.push(); - if (ephemeral) { - const addToLayoutDecl: IconButtonDecl = { - elemtype: "iconbutton", - icon: "circle-plus", - title: "Add to Layout", - click: () => { - nodeModel.addEphemeralNodeToLayout(); - }, - }; - endIconsElem.push(); - } else { - endIconsElem.push( - { - nodeModel.toggleMagnify(); - setTimeout(() => refocusNode(blockId), 50); - }} - disabled={magnifyDisabled} - /> - ); - } - - const closeDecl: IconButtonDecl = { - elemtype: "iconbutton", - icon: "xmark-large", - title: "Close", - click: () => uxCloseBlock(nodeModel.blockId), - }; - endIconsElem.push(); - - return
{endIconsElem}
; -}); -HeaderEndIcons.displayName = "HeaderEndIcons"; - -const BlockFrame_Header = ({ - nodeModel, - viewModel, - preview, - connBtnRef, - changeConnModalAtom, - error, -}: BlockFrameProps & { changeConnModalAtom: jotai.PrimitiveAtom; error?: Error }) => { - const waveEnv = useWaveEnv(); - const metaView = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")); - const metaFrameTitle = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:title")); - const metaFrameIcon = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:icon")); - const metaConnection = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); - let viewName = util.useAtomValueSafe(viewModel?.viewName) ?? blockViewToName(metaView); - let viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(metaView); - const preIconButton = util.useAtomValueSafe(viewModel?.preIconButton); - const useTermHeader = util.useAtomValueSafe(viewModel?.useTermHeader); - const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable); - const hideViewName = util.useAtomValueSafe(viewModel?.hideViewName); - const badge = jotai.useAtomValue(getBlockBadgeAtom(useTermHeader ? nodeModel.blockId : null)); - const magnified = jotai.useAtomValue(nodeModel.isMagnified); - const prevMagifiedState = React.useRef(magnified); - const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); - const iconColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "icon:color")); - const dragHandleRef = preview ? null : nodeModel.dragHandleRef; - const isTerminalBlock = metaView === "term"; - viewName = metaFrameTitle ?? viewName; - viewIconUnion = metaFrameIcon ?? viewIconUnion; - - React.useEffect(() => { - if (magnified && !preview && !prevMagifiedState.current) { - waveEnv.rpc.ActivityCommand(TabRpcClient, { nummagnify: 1 }); - recordTEvent("action:magnify", { "block:view": viewName }); - } - prevMagifiedState.current = magnified; - }, [magnified]); - - const viewIconElem = getViewIconElem(viewIconUnion, iconColor); - - return ( -
handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel, waveEnv)} - > - {!useTermHeader && ( - <> - {preIconButton && } -
- {viewIconElem} - {viewName && !hideViewName &&
{viewName}
} -
- - )} - {manageConnection && ( - - )} - {useTermHeader && termConfigedDurable != null && ( - - )} - {useTermHeader && badge && ( -
- -
- )} - - -
- ); -}; - -export { BlockFrame_Header }; diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx deleted file mode 100644 index 8ff2e2d0a7..0000000000 --- a/frontend/app/block/blockframe.tsx +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { BlockModel } from "@/app/block/block-model"; -import { BlockFrame_Header } from "@/app/block/blockframe-header"; -import { blockViewToIcon, getViewIconElem, useTabBackground } from "@/app/block/blockutil"; -import { ConnStatusOverlay } from "@/app/block/connstatusoverlay"; -import { ChangeConnectionBlockModal } from "@/app/modals/conntypeahead"; -import { getBlockComponentModel, globalStore, useBlockAtom } from "@/app/store/global"; -import { useTabModel } from "@/app/store/tab-model"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { useWaveEnv } from "@/app/waveenv/waveenv"; -import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; -import { ErrorBoundary } from "@/element/errorboundary"; -import { NodeModel } from "@/layout/index"; -import { makeORef } from "@/store/wos"; -import * as util from "@/util/util"; -import { makeIconClass } from "@/util/util"; -import { computeBgStyleFromMeta } from "@/util/waveutil"; -import clsx from "clsx"; -import * as jotai from "jotai"; -import * as React from "react"; -import { BlockEnv } from "./blockenv"; -import { BlockFrameProps } from "./blocktypes"; - -const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { - const waveEnv = useWaveEnv(); - const tabModel = useTabModel(); - const isFocused = jotai.useAtomValue(nodeModel.isFocused); - const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral); - const blockNum = jotai.useAtomValue(nodeModel.blockNum); - const isLayoutMode = jotai.useAtomValue(waveEnv.atoms.controlShiftDelayAtom); - const showOverlayBlockNums = jotai.useAtomValue(waveEnv.getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; - const blockHighlight = jotai.useAtomValue(BlockModel.getInstance().getBlockHighlightAtom(nodeModel.blockId)); - const frameActiveBorderColor = jotai.useAtomValue( - waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:activebordercolor") - ); - const frameBorderColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:bordercolor")); - const [tabBorderColor, tabActiveBorderColor] = useTabBackground(waveEnv, tabModel.tabId); - const style: React.CSSProperties = {}; - let showBlockMask = false; - - if (isFocused) { - if (tabActiveBorderColor) { - style.borderColor = tabActiveBorderColor; - } - if (frameActiveBorderColor) { - style.borderColor = frameActiveBorderColor; - } - } else { - if (tabBorderColor) { - style.borderColor = tabBorderColor; - } - if (frameBorderColor) { - style.borderColor = frameBorderColor; - } - if (isEphemeral && !style.borderColor) { - style.borderColor = "rgba(255, 255, 255, 0.7)"; - } - } - - if (blockHighlight && !style.borderColor) { - style.borderColor = "rgb(59, 130, 246)"; - } - - let innerElem = null; - if (isLayoutMode && showOverlayBlockNums) { - showBlockMask = true; - innerElem = ( -
-
{blockNum}
-
- ); - } else if (blockHighlight) { - showBlockMask = true; - const iconClass = makeIconClass(blockHighlight.icon, false); - innerElem = ( -
- -
- ); - } - - return ( -
- {innerElem} -
- ); -}); - -const BlockFrame_Default_Component = (props: BlockFrameProps) => { - const waveEnv = useWaveEnv(); - const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props; - const isFocused = jotai.useAtomValue(nodeModel.isFocused); - const aiPanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); - const metaView = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")); - const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(metaView); - const customBg = util.useAtomValueSafe(viewModel?.blockBg); - const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); - const changeConnModalAtom = useBlockAtom(nodeModel.blockId, "changeConn", () => { - return jotai.atom(false); - }) as jotai.PrimitiveAtom; - const connModalOpen = jotai.useAtomValue(changeConnModalAtom); - const isMagnified = jotai.useAtomValue(nodeModel.isMagnified); - const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral); - const [magnifiedBlockBlurAtom] = React.useState(() => - waveEnv.getSettingsKeyAtom("window:magnifiedblockblurprimarypx") - ); - const magnifiedBlockBlur = jotai.useAtomValue(magnifiedBlockBlurAtom); - const [magnifiedBlockOpacityAtom] = React.useState(() => - waveEnv.getSettingsKeyAtom("window:magnifiedblockopacity") - ); - const magnifiedBlockOpacity = jotai.useAtomValue(magnifiedBlockOpacityAtom); - const connBtnRef = React.useRef(null); - const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); - const iconColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "icon:color")); - const noHeader = util.useAtomValueSafe(viewModel?.noHeader); - - React.useEffect(() => { - if (!manageConnection) { - return; - } - const bcm = getBlockComponentModel(nodeModel.blockId); - if (bcm != null) { - bcm.openSwitchConnection = () => { - globalStore.set(changeConnModalAtom, true); - }; - } - return () => { - const bcm = getBlockComponentModel(nodeModel.blockId); - if (bcm != null) { - bcm.openSwitchConnection = null; - } - }; - }, [manageConnection]); - React.useEffect(() => { - // on mount, if manageConnection, call ConnEnsure - if (!manageConnection || preview) { - return; - } - if (!util.isLocalConnName(connName)) { - console.log("ensure conn", nodeModel.blockId, connName); - waveEnv.rpc - .ConnEnsureCommand( - TabRpcClient, - { connname: connName, logblockid: nodeModel.blockId }, - { timeout: 60000 } - ) - .catch((e) => { - console.log("error ensuring connection", nodeModel.blockId, connName, e); - }); - } - }, [manageConnection, connName]); - - const viewIconElem = getViewIconElem(viewIconUnion, iconColor); - let innerStyle: React.CSSProperties = {}; - if (!preview) { - innerStyle = computeBgStyleFromMeta(customBg); - } - const previewElem =
{viewIconElem}
; - const headerElem = ( - - ); - const headerElemNoView = React.cloneElement(headerElem, { viewModel: null }); - return ( -
- - {preview || viewModel == null || !manageConnection ? null : ( - - )} -
- {noHeader || {headerElem}} - {preview ? previewElem : children} -
- {preview || viewModel == null || !connModalOpen ? null : ( - - )} -
- ); -}; - -const BlockFrame_Default = React.memo(BlockFrame_Default_Component) as typeof BlockFrame_Default_Component; - -const BlockFrame = React.memo((props: BlockFrameProps) => { - const waveEnv = useWaveEnv(); - const tabModel = useTabModel(); - const blockId = props.nodeModel.blockId; - const blockIsNull = jotai.useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", blockId))); - const numBlocks = jotai.useAtomValue(tabModel.tabNumBlocksAtom); - if (!blockId || blockIsNull) { - return null; - } - return ; -}); - -export { BlockFrame }; diff --git a/frontend/app/block/blockregistry.ts b/frontend/app/block/blockregistry.ts deleted file mode 100644 index 5de7e05bd3..0000000000 --- a/frontend/app/block/blockregistry.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { BlockNodeModel } from "@/app/block/blocktypes"; -import type { TabModel } from "@/app/store/tab-model"; -import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; -import { LauncherViewModel } from "@/app/view/launcher/launcher"; -import { PreviewModel } from "@/app/view/preview/preview-model"; -import { ProcessViewerViewModel } from "@/app/view/processviewer/processviewer"; -import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; -import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; -import { VDomModel } from "@/app/view/vdom/vdom-model"; -import { WaveEnv } from "@/app/waveenv/waveenv"; -import { atom } from "jotai"; -import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview"; -import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model"; -import { blockViewToIcon, blockViewToName } from "./blockutil"; -import { HelpViewModel } from "@/view/helpview/helpview"; -import { TermViewModel } from "@/view/term/term-model"; -import { WaveAiModel } from "@/view/waveai/waveai"; -import { WebViewModel } from "@/view/webview/webview"; - -const BlockRegistry: Map = new Map(); -BlockRegistry.set("term", TermViewModel); -BlockRegistry.set("preview", PreviewModel); -BlockRegistry.set("web", WebViewModel); -BlockRegistry.set("waveai", WaveAiModel); -BlockRegistry.set("cpuplot", SysinfoViewModel); -BlockRegistry.set("sysinfo", SysinfoViewModel); -BlockRegistry.set("vdom", VDomModel); -BlockRegistry.set("tips", QuickTipsViewModel); -BlockRegistry.set("help", HelpViewModel); -BlockRegistry.set("launcher", LauncherViewModel); -BlockRegistry.set("tsunami", TsunamiViewModel); -BlockRegistry.set("aifilediff", AiFileDiffViewModel); -BlockRegistry.set("waveconfig", WaveConfigViewModel); -BlockRegistry.set("processviewer", ProcessViewerViewModel); - -function makeDefaultViewModel(viewType: string): ViewModel { - const viewModel: ViewModel = { - viewType: viewType, - viewIcon: atom(blockViewToIcon(viewType)), - viewName: atom(blockViewToName(viewType)), - preIconButton: atom(null), - endIconButtons: atom(null), - viewComponent: null, - }; - return viewModel; -} - -function makeViewModel( - blockId: string, - blockView: string, - nodeModel: BlockNodeModel, - tabModel: TabModel, - waveEnv: WaveEnv -): ViewModel { - const ctor = BlockRegistry.get(blockView); - if (ctor != null) { - return new ctor({ blockId, nodeModel, tabModel, waveEnv }); - } - return makeDefaultViewModel(blockView); -} - -export { makeViewModel }; diff --git a/frontend/app/block/blocktypes.ts b/frontend/app/block/blocktypes.ts deleted file mode 100644 index 5ea5a3b578..0000000000 --- a/frontend/app/block/blocktypes.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { NodeModel } from "@/layout/index"; -import { Atom } from "jotai"; - -export interface BlockNodeModel { - blockId: string; - isFocused: Atom; - isMagnified: Atom; - onClose: () => void; - focusNode: () => void; - toggleMagnify: () => void; -} - -export type FullBlockProps = { - preview: boolean; - nodeModel: NodeModel; - viewModel: ViewModel; -}; - -export interface BlockProps { - preview: boolean; - nodeModel: NodeModel; -} - -export type FullSubBlockProps = { - nodeModel: BlockNodeModel; - viewModel: ViewModel; -}; - -export interface SubBlockProps { - nodeModel: BlockNodeModel; -} - -export interface BlockComponentModel2 { - onClick?: () => void; - onPointerEnter?: React.PointerEventHandler; - onFocusCapture?: React.FocusEventHandler; - blockRef?: React.RefObject; -} - -export interface BlockFrameProps { - blockModel?: BlockComponentModel2; - nodeModel?: NodeModel; - viewModel?: ViewModel; - preview: boolean; - numBlocksInTab?: number; - children?: React.ReactNode; - connBtnRef?: React.RefObject; -} diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx deleted file mode 100644 index 3ef4d39821..0000000000 --- a/frontend/app/block/blockutil.tsx +++ /dev/null @@ -1,283 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { Button } from "@/app/element/button"; -import { - MetaKeyAtomFnType, - WaveEnv, - WaveEnvSubset, -} from "@/app/waveenv/waveenv"; -import { IconButton, ToggleIconButton } from "@/element/iconbutton"; -import { MagnifyIcon } from "@/element/magnify"; -import { MenuButton } from "@/element/menubutton"; -import * as util from "@/util/util"; -import clsx from "clsx"; -import * as jotai from "jotai"; -import * as React from "react"; - -export type TabBackgroundEnv = WaveEnvSubset<{ - getTabMetaKeyAtom: MetaKeyAtomFnType<"bg:activebordercolor" | "bg:bordercolor" | "tab:background">; - getConfigBackgroundAtom: WaveEnv["getConfigBackgroundAtom"]; -}>; - -export const colorRegex = /^((#[0-9a-f]{6,8})|([a-z]+))$/; -export const NumActiveConnColors = 8; - -export function blockViewToIcon(view: string): string { - if (view == "term") { - return "terminal"; - } - if (view == "preview") { - return "file"; - } - if (view == "web") { - return "globe"; - } - if (view == "waveai") { - return "sparkles"; - } - if (view == "help") { - return "circle-question"; - } - if (view == "tips") { - return "lightbulb"; - } - if (view == "processviewer") { - return "microchip"; - } - return "square"; -} - -export function blockViewToName(view: string): string { - if (util.isBlank(view)) { - return "(No View)"; - } - if (view == "term") { - return "Terminal"; - } - if (view == "preview") { - return "Preview"; - } - if (view == "web") { - return "Web"; - } - if (view == "waveai") { - return "WaveAI"; - } - if (view == "help") { - return "Help"; - } - if (view == "tips") { - return "Tips"; - } - if (view == "processviewer") { - return "Processes"; - } - return view; -} - -export function processTitleString(titleString: string): React.ReactNode[] { - if (titleString == null) { - return null; - } - const tagRegex = /<(\/)?([a-z]+)(?::([#a-z0-9@-]+))?>/g; - let lastIdx = 0; - let match; - const partsStack = [[]]; - while ((match = tagRegex.exec(titleString)) != null) { - const lastPart = partsStack[partsStack.length - 1]; - const before = titleString.substring(lastIdx, match.index); - lastPart.push(before); - lastIdx = match.index + match[0].length; - const [_, isClosing, tagName, tagParam] = match; - if (tagName == "icon" && !isClosing) { - if (tagParam == null) { - continue; - } - const iconClass = util.makeIconClass(tagParam, false); - if (iconClass == null) { - continue; - } - lastPart.push(); - continue; - } - if (tagName == "c" || tagName == "color") { - if (isClosing) { - if (partsStack.length <= 1) { - continue; - } - partsStack.pop(); - continue; - } - if (tagParam == null) { - continue; - } - if (!tagParam.match(colorRegex)) { - continue; - } - const children = []; - const rtag = React.createElement("span", { key: match.index, style: { color: tagParam } }, children); - lastPart.push(rtag); - partsStack.push(children); - continue; - } - if (tagName == "i" || tagName == "b") { - if (isClosing) { - if (partsStack.length <= 1) { - continue; - } - partsStack.pop(); - continue; - } - const children = []; - const rtag = React.createElement(tagName, { key: match.index }, children); - lastPart.push(rtag); - partsStack.push(children); - continue; - } - } - partsStack[partsStack.length - 1].push(titleString.substring(lastIdx)); - return partsStack[0]; -} - -export function getBlockHeaderIcon(blockIcon: string, overrideIconColor?: string): React.ReactNode { - let blockIconElem: React.ReactNode = null; - if (util.isBlank(blockIcon)) { - blockIcon = "square"; - } - let iconColor = overrideIconColor; - if (iconColor && !iconColor.match(colorRegex)) { - iconColor = null; - } - let iconStyle = null; - if (!util.isBlank(iconColor)) { - iconStyle = { color: iconColor }; - } - const iconClass = util.makeIconClass(blockIcon, true); - if (iconClass != null) { - blockIconElem = ; - } - return blockIconElem; -} - -export function getViewIconElem( - viewIconUnion: string | IconButtonDecl, - overrideIconColor?: string -): React.ReactElement { - if (viewIconUnion == null || typeof viewIconUnion === "string") { - const viewIcon = viewIconUnion as string; - return
{getBlockHeaderIcon(viewIcon, overrideIconColor)}
; - } else { - return ; - } -} - -export function useTabBackground( - waveEnv: TabBackgroundEnv, - tabId: string | null -): [string, string, BackgroundConfigType] { - const tabActiveBorderColorDirect = jotai.useAtomValue(waveEnv.getTabMetaKeyAtom(tabId, "bg:activebordercolor")); - const tabBorderColorDirect = jotai.useAtomValue(waveEnv.getTabMetaKeyAtom(tabId, "bg:bordercolor")); - const tabBg = jotai.useAtomValue(waveEnv.getTabMetaKeyAtom(tabId, "tab:background")); - const configBg = jotai.useAtomValue(waveEnv.getConfigBackgroundAtom(tabBg)); - const tabActiveBorderColor = tabActiveBorderColorDirect ?? configBg?.["bg:activebordercolor"]; - const tabBorderColor = tabBorderColorDirect ?? configBg?.["bg:bordercolor"]; - return [tabBorderColor, tabActiveBorderColor, configBg]; -} - -export const Input = React.memo( - ({ decl, className, preview }: { decl: HeaderInput; className: string; preview: boolean }) => { - const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl; - return ( -
- onChange(e)} - onKeyDown={(e) => onKeyDown(e)} - onFocus={(e) => onFocus(e)} - onBlur={(e) => onBlur(e)} - onDragStart={(e) => e.preventDefault()} - /> -
- ); - } -); - -export const OptMagnifyButton = React.memo( - ({ magnified, toggleMagnify, disabled }: { magnified: boolean; toggleMagnify: () => void; disabled: boolean }) => { - const magnifyDecl: IconButtonDecl = { - elemtype: "iconbutton", - icon: , - title: magnified ? "Minimize" : "Magnify", - click: toggleMagnify, - disabled, - }; - return ; - } -); - -export const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; preview: boolean }) => { - if (elem.elemtype == "iconbutton") { - return ; - } else if (elem.elemtype == "toggleiconbutton") { - return ; - } else if (elem.elemtype == "input") { - return ; - } else if (elem.elemtype == "text") { - return ( -
- elem?.onClick(e)}> - ‎{elem.text} - -
- ); - } else if (elem.elemtype == "textbutton") { - return ( - - ); - } else if (elem.elemtype == "div") { - return ( -
- {elem.children.map((child, childIdx) => ( - - ))} -
- ); - } else if (elem.elemtype == "menubutton") { - return ; - } - return null; -}); - -export function renderHeaderElements(headerTextUnion: HeaderElem[], preview: boolean): React.ReactElement[] { - const headerTextElems: React.ReactElement[] = []; - for (let idx = 0; idx < headerTextUnion.length; idx++) { - const elem = headerTextUnion[idx]; - const renderedElement = ; - if (renderedElement) { - headerTextElems.push(renderedElement); - } - } - return headerTextElems; -} - -export function computeConnColorNum(connStatus: ConnStatus): number { - const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors; - if (connColorNum == 0) { - return NumActiveConnColors; - } - return connColorNum; -} diff --git a/frontend/app/block/connectionbutton.tsx b/frontend/app/block/connectionbutton.tsx deleted file mode 100644 index c5a9b635c3..0000000000 --- a/frontend/app/block/connectionbutton.tsx +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { computeConnColorNum } from "@/app/block/blockutil"; -import { recordTEvent } from "@/app/store/global"; -import { useWaveEnv } from "@/app/waveenv/waveenv"; -import { IconButton } from "@/element/iconbutton"; -import * as util from "@/util/util"; -import * as jotai from "jotai"; -import * as React from "react"; -import DotsSvg from "../asset/dots-anim-4.svg"; -import { BlockEnv } from "./blockenv"; - -interface ConnectionButtonProps { - connection: string; - changeConnModalAtom: jotai.PrimitiveAtom; - isTerminalBlock?: boolean; -} - -export const ConnectionButton = React.memo( - React.forwardRef( - ({ connection, changeConnModalAtom, isTerminalBlock }: ConnectionButtonProps, ref) => { - const waveEnv = useWaveEnv(); - const [_connModalOpen, setConnModalOpen] = jotai.useAtom(changeConnModalAtom); - const isLocal = util.isLocalConnName(connection); - const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connection)); - const localName = jotai.useAtomValue(waveEnv.getLocalHostDisplayNameAtom()); - let showDisconnectedSlash = false; - let connIconElem: React.ReactNode = null; - const connColorNum = computeConnColorNum(connStatus); - let color = `var(--conn-icon-color-${connColorNum})`; - const clickHandler = function () { - recordTEvent("action:other", { "action:type": "conndropdown", "action:initiator": "mouse" }); - setConnModalOpen(true); - }; - let titleText = null; - let shouldSpin = false; - let connDisplayName: string = null; - let extraDisplayNameClassName = ""; - if (isLocal) { - color = "var(--color-secondary)"; - if (connection === "local:gitbash") { - titleText = "Connected to Git Bash"; - connDisplayName = "Git Bash"; - } else { - titleText = "Connected to Local Machine"; - if (localName) { - titleText += ` (${localName})`; - } - if (isTerminalBlock) { - connDisplayName = localName; - extraDisplayNameClassName = "text-muted group-hover:text-secondary"; - } - } - connIconElem = ( - - ); - } else { - titleText = "Connected to " + connection; - let iconName = "arrow-right-arrow-left"; - let iconSvg = null; - if (connStatus?.status == "connecting") { - color = "var(--warning-color)"; - titleText = "Connecting to " + connection; - shouldSpin = false; - iconSvg = ( -
- -
- ); - } else if (connStatus?.status == "error") { - color = "var(--error-color)"; - titleText = "Error connecting to " + connection; - if (connStatus?.error != null) { - titleText += " (" + connStatus.error + ")"; - } - showDisconnectedSlash = true; - } else if (!connStatus?.connected) { - color = "var(--grey-text-color)"; - titleText = "Disconnected from " + connection; - showDisconnectedSlash = true; - } else if (connStatus?.connhealthstatus === "degraded" || connStatus?.connhealthstatus === "stalled") { - color = "var(--warning-color)"; - iconName = "signal-bars-slash"; - if (connStatus.connhealthstatus === "degraded") { - titleText = "Connection degraded: " + connection; - } else { - titleText = "Connection stalled: " + connection; - } - } - if (iconSvg != null) { - connIconElem = iconSvg; - } else { - connIconElem = ( - - ); - } - } - - const wshProblem = connection && !connStatus?.wshenabled && connStatus?.status == "connected"; - const showNoWshButton = wshProblem && !isLocal; - - return ( - <> -
- - {connIconElem} - - - {connDisplayName ? ( -
- {connDisplayName} -
- ) : isLocal ? null : ( -
{connection}
- )} -
- {showNoWshButton && ( - - )} - - ); - } - ) -); -ConnectionButton.displayName = "ConnectionButton"; diff --git a/frontend/app/block/connstatusoverlay.tsx b/frontend/app/block/connstatusoverlay.tsx deleted file mode 100644 index d4d6ad14b8..0000000000 --- a/frontend/app/block/connstatusoverlay.tsx +++ /dev/null @@ -1,271 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { Button } from "@/app/element/button"; -import { CopyButton } from "@/app/element/copybutton"; -import { useDimensionsWithCallbackRef } from "@/app/hook/useDimensions"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { useWaveEnv } from "@/app/waveenv/waveenv"; -import { NodeModel } from "@/layout/index"; -import * as util from "@/util/util"; -import clsx from "clsx"; -import * as jotai from "jotai"; -import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; -import * as React from "react"; -import { BlockEnv } from "./blockenv"; - -function formatElapsedTime(elapsedMs: number): string { - if (elapsedMs <= 0) { - return ""; - } - - const elapsedSeconds = Math.floor(elapsedMs / 1000); - - if (elapsedSeconds < 60) { - return `${elapsedSeconds}s`; - } - - const elapsedMinutes = Math.floor(elapsedSeconds / 60); - if (elapsedMinutes < 60) { - return `${elapsedMinutes}m`; - } - - const elapsedHours = Math.floor(elapsedMinutes / 60); - const remainingMinutes = elapsedMinutes % 60; - - if (elapsedHours < 24) { - if (remainingMinutes === 0) { - return `${elapsedHours}h`; - } - return `${elapsedHours}h${remainingMinutes}m`; - } - - return "more than a day"; -} - -const StalledOverlay = React.memo( - ({ - connName, - connStatus, - overlayRefCallback, - }: { - connName: string; - connStatus: ConnStatus; - overlayRefCallback: (el: HTMLDivElement | null) => void; - }) => { - const [elapsedTime, setElapsedTime] = React.useState(""); - - const waveEnv = useWaveEnv(); - const handleDisconnect = React.useCallback(() => { - const prtn = waveEnv.rpc.ConnDisconnectCommand(TabRpcClient, connName, { timeout: 5000 }); - prtn.catch((e) => console.log("error disconnecting", connName, e)); - }, [connName, waveEnv]); - - React.useEffect(() => { - if (!connStatus.lastactivitybeforestalledtime) { - return; - } - - const updateElapsed = () => { - const now = Date.now(); - const lastActivity = connStatus.lastactivitybeforestalledtime!; - const elapsed = now - lastActivity; - setElapsedTime(formatElapsedTime(elapsed)); - }; - - updateElapsed(); - const interval = setInterval(updateElapsed, 1000); - - return () => clearInterval(interval); - }, [connStatus.lastactivitybeforestalledtime]); - - return ( -
-
- -
- Connection to "{connName}" is stalled - {elapsedTime && ` (no activity for ${elapsedTime})`} -
-
- -
-
- ); - } -); -StalledOverlay.displayName = "StalledOverlay"; - -export const ConnStatusOverlay = React.memo( - ({ - nodeModel, - viewModel, - changeConnModalAtom, - }: { - nodeModel: NodeModel; - viewModel: ViewModel; - changeConnModalAtom: jotai.PrimitiveAtom; - }) => { - const waveEnv = useWaveEnv(); - const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); - const [connModalOpen] = jotai.useAtom(changeConnModalAtom); - const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connName)); - const isLayoutMode = jotai.useAtomValue(waveEnv.atoms.controlShiftDelayAtom); - const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30); - const width = domRect?.width; - const [showError, setShowError] = React.useState(false); - const wshConfigEnabled = - jotai.useAtomValue(waveEnv.getConnConfigKeyAtom(connName, "conn:wshenabled")) ?? true; - const [showWshError, setShowWshError] = React.useState(false); - - React.useEffect(() => { - if (width) { - const hasError = !util.isBlank(connStatus.error); - const showError = hasError && width >= 250 && connStatus.status == "error"; - setShowError(showError); - } - }, [width, connStatus, setShowError]); - - const handleTryReconnect = React.useCallback(() => { - const prtn = waveEnv.rpc.ConnConnectCommand( - TabRpcClient, - { host: connName, logblockid: nodeModel.blockId }, - { timeout: 60000 } - ); - prtn.catch((e) => console.log("error reconnecting", connName, e)); - }, [connName, nodeModel.blockId, waveEnv]); - - const handleDisableWsh = React.useCallback(async () => { - const metamaptype: unknown = { - "conn:wshenabled": false, - }; - const data: ConnConfigRequest = { - host: connName, - metamaptype: metamaptype, - }; - try { - await waveEnv.rpc.SetConnectionsConfigCommand(TabRpcClient, data); - } catch (e) { - console.log("problem setting connection config: ", e); - } - }, [connName, waveEnv]); - - const handleRemoveWshError = React.useCallback(async () => { - try { - await waveEnv.rpc.DismissWshFailCommand(TabRpcClient, connName); - } catch (e) { - console.log("unable to dismiss wsh error: ", e); - } - }, [connName, waveEnv]); - - let statusText = `Disconnected from "${connName}"`; - let showReconnect = true; - if (connStatus.status == "connecting") { - statusText = `Connecting to "${connName}"...`; - showReconnect = false; - } - if (connStatus.status == "connected") { - showReconnect = false; - } - let reconDisplay = null; - let reconClassName = "outlined grey"; - if (width && width < 350) { - reconDisplay = ; - reconClassName = clsx(reconClassName, "text-[12px] py-[5px] px-[6px]"); - } else { - reconDisplay = "Reconnect"; - reconClassName = clsx(reconClassName, "text-[11px] py-[3px] px-[7px]"); - } - const showIcon = connStatus.status != "connecting"; - - React.useEffect(() => { - const showWshErrorTemp = - connStatus.status == "connected" && - connStatus.wsherror && - connStatus.wsherror != "" && - wshConfigEnabled; - - setShowWshError(showWshErrorTemp); - }, [connStatus, wshConfigEnabled]); - - const handleCopy = React.useCallback( - async (e: React.MouseEvent) => { - const errTexts = []; - if (showError) { - errTexts.push(`error: ${connStatus.error}`); - } - if (showWshError) { - errTexts.push(`unable to use wsh: ${connStatus.wsherror}`); - } - const textToCopy = errTexts.join("\n"); - await navigator.clipboard.writeText(textToCopy); - }, - [showError, showWshError, connStatus.error, connStatus.wsherror] - ); - - const showStalled = connStatus.status == "connected" && connStatus.connhealthstatus == "stalled"; - if (!showWshError && !showStalled && (isLayoutMode || connStatus.status == "connected" || connModalOpen)) { - return null; - } - - if (showStalled && !showWshError) { - return ( - - ); - } - - return ( -
-
-
- {showIcon && } -
-
{statusText}
- {(showError || showWshError) && ( - - - {showError ?
error: {connStatus.error}
: null} - {showWshError ?
unable to use wsh: {connStatus.wsherror}
: null} -
- )} - {showWshError && ( - - )} -
-
- {showReconnect ? ( -
- -
- ) : null} - {showWshError ? ( -
-
- ) : null} -
-
- ); - } -); -ConnStatusOverlay.displayName = "ConnStatusOverlay"; diff --git a/frontend/app/block/durable-session-flyover.tsx b/frontend/app/block/durable-session-flyover.tsx deleted file mode 100644 index 7ab7fa0b10..0000000000 --- a/frontend/app/block/durable-session-flyover.tsx +++ /dev/null @@ -1,440 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { recordTEvent } from "@/app/store/global"; -import { TermViewModel } from "@/app/view/term/term-model"; -import { useWaveEnv } from "@/app/waveenv/waveenv"; -import * as util from "@/util/util"; -import { cn } from "@/util/util"; -import { - autoUpdate, - flip, - FloatingPortal, - offset, - safePolygon, - shift, - useFloating, - useHover, - useInteractions, -} from "@floating-ui/react"; -import * as jotai from "jotai"; -import { useEffect, useRef, useState } from "react"; -import { BlockEnv } from "./blockenv"; - -function isTermViewModel(viewModel: ViewModel): viewModel is TermViewModel { - return viewModel?.viewType === "term"; -} - -function LearnMoreButton() { - const waveEnv = useWaveEnv(); - return ( - - ); -} - -interface StandardSessionContentProps { - viewModel: TermViewModel; - onClose: () => void; -} - -function StandardSessionContent({ viewModel, onClose }: StandardSessionContentProps) { - const handleRestartAsDurable = () => { - recordTEvent("action:termdurable", { "action:type": "restartdurable" }); - onClose(); - util.fireAndForget(() => viewModel.restartSessionWithDurability(true)); - }; - - return ( -
-
- - Standard SSH Session -
-
- Standard SSH sessions end when the connection drops. Durable sessions keep your shell state, running - programs, and history alive through network changes, computer sleep, and Wave restarts. -
- - -
- ); -} - -interface DurableAttachedContentProps { - onClose: () => void; -} - -function DurableAttachedContent({ onClose }: DurableAttachedContentProps) { - return ( -
-
- - Durable Session (Attached) -
-
- Your shell state, running programs, and history are protected. This session will survive network - disconnects. -
- -
- ); -} - -interface DurableDetachedContentProps { - onClose: () => void; -} - -function DurableDetachedContent({ onClose }: DurableDetachedContentProps) { - return ( -
-
- - Durable Session (Detached) -
-
- Connection lost, but your session is still running on the remote server. Wave will automatically - reconnect when the connection is restored. -
- -
- ); -} - -interface DurableAwaitingStartProps { - connected: boolean; - viewModel: TermViewModel; - onClose: () => void; -} - -function DurableAwaitingStart({ connected, viewModel, onClose }: DurableAwaitingStartProps) { - const handleStartSession = () => { - onClose(); - util.fireAndForget(() => viewModel.forceRestartController()); - }; - - if (!connected) { - return ( -
-
- - Durable Session (Awaiting Connection) -
-
- Configured for a durable session. The session will start when the connection is established. -
- -
- ); - } - - return ( -
-
- - Durable Session (Awaiting Start) -
-
- Configured for a durable session, but session hasn't started yet. Click below to start it manually. -
- - -
- ); -} - -interface DurableStartingContentProps { - onClose: () => void; -} - -function DurableStartingContent({ onClose }: DurableStartingContentProps) { - return ( -
-
- - Durable Session (Starting) -
-
The durable session is starting.
- -
- ); -} - -interface DurableEndedContentProps { - doneReason: string; - startupError?: string; - viewModel: TermViewModel; - onClose: () => void; -} - -function DurableEndedContent({ doneReason, startupError, viewModel, onClose }: DurableEndedContentProps) { - const handleRestartSession = () => { - onClose(); - util.fireAndForget(() => viewModel.forceRestartController()); - }; - - const handleRestartAsStandard = () => { - onClose(); - util.fireAndForget(() => viewModel.restartSessionWithDurability(false)); - }; - - let titleText = "Durable Session (Ended)"; - let descriptionText = "The durable session has ended. This block is still configured for durable sessions."; - const showRestartButton = true; - - if (doneReason === "terminated") { - titleText = "Durable Session (Ended, Exited)"; - descriptionText = - "The shell was terminated and is no longer running. This block is still configured for durable sessions."; - } else if (doneReason === "gone") { - titleText = "Durable Session (Ended, Lost)"; - descriptionText = - "The session was lost or not found on the remote server. This may have occurred due to a system reboot or the session being manually terminated."; - } else if (doneReason === "startuperror") { - titleText = "Durable Session (Failed to Start)"; - descriptionText = "The durable session failed to start."; - return ( -
-
- - {titleText} -
-
{descriptionText}
- {startupError && ( -
- {startupError} -
- )} - - - -
- ); - } - - return ( -
-
- - {titleText} -
-
{descriptionText}
- {showRestartButton && ( - - )} - -
- ); -} - -function getContentToRender( - viewModel: TermViewModel, - onClose: () => void, - jobStatus: BlockJobStatusData, - connStatus: ConnStatus, - isConfigedDurable?: boolean | null -): string | React.ReactNode { - if (isConfigedDurable === false) { - return ; - } - - const status = jobStatus?.status; - if (status === "connected") { - return ; - } else if (status === "disconnected") { - return ; - } else if (status === "init") { - return ; - } else if (status === "done") { - const doneReason = jobStatus?.donereason; - const startupError = jobStatus?.startuperror; - return ( - - ); - } else if (status == null) { - return ; - } - console.log("DurableSessionFlyover: unexpected jobStatus", jobStatus); - return null; -} - -function getIconProps(jobStatus: BlockJobStatusData, connStatus: ConnStatus, isConfigedDurable?: boolean | null) { - let color = "text-muted"; - let iconType: "fa-solid" | "fa-regular" = "fa-solid"; - - if (isConfigedDurable === false) { - color = "text-muted"; - iconType = "fa-regular"; - return { color, iconType }; - } - - const status = jobStatus?.status; - if (status === "connected") { - color = "text-sky-500"; - } else if (status === "disconnected") { - color = "text-sky-300"; - } else if (status === "init") { - color = "text-sky-300"; - } else if (status === "done") { - color = "text-muted"; - } else if (status == null) { - color = "text-muted"; - } - return { color, iconType }; -} - -interface DurableSessionFlyoverProps { - blockId: string; - viewModel: ViewModel; - placement?: "top" | "bottom" | "left" | "right"; - divClassName?: string; -} - -export function DurableSessionFlyover({ - blockId, - viewModel, - placement = "bottom", - divClassName, -}: DurableSessionFlyoverProps) { - const waveEnv = useWaveEnv(); - const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(blockId, "connection")); - const termDurableStatus = util.useAtomValueSafe(viewModel?.termDurableStatus); - const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable); - const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connName)); - - const { color: durableIconColor, iconType: durableIconType } = getIconProps( - termDurableStatus, - connStatus, - termConfigedDurable - ); - - const [isOpen, setIsOpen] = useState(false); - const [isVisible, setIsVisible] = useState(false); - const timeoutRef = useRef(null); - - const handleClose = () => { - setIsVisible(false); - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - timeoutRef.current = window.setTimeout(() => { - setIsOpen(false); - }, 300); - }; - - const { refs, floatingStyles, context } = useFloating({ - open: isOpen, - onOpenChange: (open) => { - if (open) { - setIsOpen(true); - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - timeoutRef.current = window.setTimeout(() => { - setIsVisible(true); - }, 300); - } else { - setIsVisible(false); - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - timeoutRef.current = window.setTimeout(() => { - setIsOpen(false); - }, 300); - } - }, - placement, - middleware: [offset(10), flip(), shift({ padding: 12 })], - whileElementsMounted: autoUpdate, - }); - - useEffect(() => { - return () => { - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - }; - }, []); - - const hover = useHover(context, { - handleClose: safePolygon(), - }); - const { getReferenceProps, getFloatingProps } = useInteractions([hover]); - - if (!isTermViewModel(viewModel)) { - return null; - } - - const content = getContentToRender(viewModel, handleClose, termDurableStatus, connStatus, termConfigedDurable); - if (content == null) { - return null; - } - - return ( - <> -
- -
- {isOpen && ( - -
e.stopPropagation()} - onFocusCapture={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - > - {content} -
-
- )} - - ); -} diff --git a/frontend/app/element/ansiline.tsx b/frontend/app/element/ansiline.tsx deleted file mode 100644 index c9f17bcbbd..0000000000 --- a/frontend/app/element/ansiline.tsx +++ /dev/null @@ -1,155 +0,0 @@ -export const ANSI_TAILWIND_MAP = { - // Reset and modifiers - 0: "reset", // special: clear state - 1: "font-bold", - 2: "opacity-75", - 3: "italic", - 4: "underline", - 8: "invisible", - 9: "line-through", - - // Foreground standard colors - 30: "text-ansi-black", - 31: "text-ansi-red", - 32: "text-ansi-green", - 33: "text-ansi-yellow", - 34: "text-ansi-blue", - 35: "text-ansi-magenta", - 36: "text-ansi-cyan", - 37: "text-ansi-white", - - // Foreground bright colors - 90: "text-ansi-brightblack", - 91: "text-ansi-brightred", - 92: "text-ansi-brightgreen", - 93: "text-ansi-brightyellow", - 94: "text-ansi-brightblue", - 95: "text-ansi-brightmagenta", - 96: "text-ansi-brightcyan", - 97: "text-ansi-brightwhite", - - // Background standard colors - 40: "bg-ansi-black", - 41: "bg-ansi-red", - 42: "bg-ansi-green", - 43: "bg-ansi-yellow", - 44: "bg-ansi-blue", - 45: "bg-ansi-magenta", - 46: "bg-ansi-cyan", - 47: "bg-ansi-white", - - // Background bright colors - 100: "bg-ansi-brightblack", - 101: "bg-ansi-brightred", - 102: "bg-ansi-brightgreen", - 103: "bg-ansi-brightyellow", - 104: "bg-ansi-brightblue", - 105: "bg-ansi-brightmagenta", - 106: "bg-ansi-brightcyan", - 107: "bg-ansi-brightwhite", -}; - -type InternalStateType = { - modifiers: Set; - textColor: string | null; - bgColor: string | null; - reverse: boolean; -}; - -type SegmentType = { - text: string; - classes: string; -}; - -const makeInitialState: () => InternalStateType = () => ({ - modifiers: new Set(), - textColor: null, - bgColor: null, - reverse: false, -}); - -const updateStateWithCodes = (state, codes) => { - codes.forEach((code) => { - if (code === 0) { - // Reset state - state.modifiers.clear(); - state.textColor = null; - state.bgColor = null; - state.reverse = false; - return; - } - // Instead of swapping immediately, we set a flag - if (code === 7) { - state.reverse = true; - return; - } - const tailwindClass = ANSI_TAILWIND_MAP[code]; - if (tailwindClass && tailwindClass !== "reset") { - if (tailwindClass.startsWith("text-")) { - state.textColor = tailwindClass; - } else if (tailwindClass.startsWith("bg-")) { - state.bgColor = tailwindClass; - } else { - state.modifiers.add(tailwindClass); - } - } - }); - return state; -}; - -const stateToClasses = (state: InternalStateType) => { - const classes = []; - classes.push(...Array.from(state.modifiers)); - - // Apply reverse: swap text and background colors if flag is set. - let textColor = state.textColor; - let bgColor = state.bgColor; - if (state.reverse) { - [textColor, bgColor] = [bgColor, textColor]; - } - if (textColor) classes.push(textColor); - if (bgColor) classes.push(bgColor); - - return classes.join(" "); -}; - -// eslint-disable-next-line no-control-regex -const ansiRegex = /\x1b\[([0-9;]+)m/g; - -const AnsiLine = ({ line }) => { - const segments: SegmentType[] = []; - let lastIndex = 0; - let currentState = makeInitialState(); - - let match: RegExpExecArray; - while ((match = ansiRegex.exec(line)) !== null) { - if (match.index > lastIndex) { - segments.push({ - text: line.substring(lastIndex, match.index), - classes: stateToClasses(currentState), - }); - } - const codes = match[1].split(";").map(Number); - updateStateWithCodes(currentState, codes); - lastIndex = ansiRegex.lastIndex; - } - - if (lastIndex < line.length) { - segments.push({ - text: line.substring(lastIndex), - classes: stateToClasses(currentState), - }); - } - - return ( -
- {segments.map((seg, idx) => ( - - {seg.text} - - ))} -
- ); -}; - -export default AnsiLine; diff --git a/frontend/app/element/button.scss b/frontend/app/element/button.scss deleted file mode 100644 index 5b1d94c2d5..0000000000 --- a/frontend/app/element/button.scss +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.wave-button { - // override default button appearance - border: 1px solid transparent; - outline: 1px solid transparent; - border: 1px solid transparent; - - cursor: pointer; - display: flex; - padding-top: 8px; - padding-bottom: 8px; - padding-left: 20px; - padding-right: 20px; - align-items: center; - gap: 4px; - border-radius: 6px; - height: auto; - line-height: 16px; - white-space: nowrap; - user-select: none; - font-size: 14px; - font-weight: normal; - transition: all 0.3s ease; - - &.solid { - &.green { - color: var(--button-text-color); - background-color: var(--accent-color); - border: 1px solid var(--button-green-border-color); - &:hover { - color: var(--button-text-color); - background-color: var(--button-green-border-color); - } - } - - &.grey { - background-color: var(--button-grey-bg); - border: 1px solid var(--button-grey-bg); - color: var(--main-text-color); - &:hover { - color: var(--main-text-color); - background-color: var(--button-grey-hover-bg); - } - } - - &.red { - background-color: var(--button-red-bg); - border: 1px solid var(--button-red-border-color); - color: var(--main-text-color); - &:hover { - background-color: var(--button-red-hover-bg); - } - } - - &.yellow { - color: var(--button-text-color); - background-color: var(--button-yellow-bg); - border: 1px solid var(--button-yellow-hover-bg); - &:hover { - color: var(--button-text-color); - background-color: var(--button-yellow-hover-bg); - } - } - } - - &.outlined { - background-color: transparent; - &.green { - color: var(--accent-color); - border: 1px solid var(--accent-color); - &:hover { - color: var(--button-green-border-color); - border: 1px solid var(--button-green-border-color); - } - } - - &.grey { - border: 1px solid var(--button-grey-outlined-color); - color: var(--button-grey-outlined-color); - &:hover { - color: var(--main-text-color); - border: 1px solid var(--main-text-color); - } - } - - &.red { - border: 1px solid var(--button-red-bg); - color: var(--button-red-bg); - &:hover { - color: var(--button-red-outlined-color); - border: 1px solid var(--button-red-outlined-color); - } - } - - &.yellow { - color: var(--button-yellow-bg); - border: 1px solid var(--button-yellow-bg); - &:hover { - color: var(--button-yellow-hover-bg); - border: 1px solid var(--button-yellow-hover-bg); - } - } - } - - &.ghost { - background-color: transparent; - padding-top: 8px; - padding-bottom: 8px; - padding-left: 8px; - padding-right: 8px; - - &.green { - border: none; - color: var(--accent-color); - &:hover { - color: var(--button-green-border-color); - } - } - - &.grey { - border: none; - color: var(--button-grey-outlined-color); - &:hover { - color: var(--main-text-color); - } - } - - &.red { - border: none; - color: var(--button-red-bg); - &:hover { - color: var(--button-red-border-color); - } - } - - &.yellow { - border: none; - color: var(--button-yellow-bg); - &:hover { - color: var(--button-yellow-hover-bg); - } - } - } - - &.bold { - font-weight: bold; - } - - &:disabled { - cursor: default; - opacity: 0.5; - pointer-events: none; - } - - &:focus-visible { - outline: 1px solid var(--success-color); - outline-offset: 2px; - } -} diff --git a/frontend/app/element/button.tsx b/frontend/app/element/button.tsx deleted file mode 100644 index 55de0d963c..0000000000 --- a/frontend/app/element/button.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import clsx from "clsx"; -import { forwardRef, memo, ReactNode, useImperativeHandle, useRef } from "react"; - -import "./button.scss"; - -interface ButtonProps extends React.ButtonHTMLAttributes { - className?: string; - children?: ReactNode; - as?: keyof React.JSX.IntrinsicElements | React.ComponentType; -} - -const Button = memo( - forwardRef( - ({ children, disabled, className = "", as: Component = "button", ...props }: ButtonProps, ref) => { - const btnRef = useRef(null); - useImperativeHandle(ref, () => btnRef.current as HTMLButtonElement); - - // Check if the className contains any of the categories: solid, outlined, or ghost - const containsButtonCategory = /(solid|outline|ghost)/.test(className); - // If no category is present, default to 'solid' - const categoryClassName = containsButtonCategory ? className : `solid ${className}`; - - // Check if the className contains any of the color options: green, grey, red, or yellow - const containsColor = /(green|grey|red|yellow)/.test(categoryClassName); - // If no color is present, default to 'green' - const finalClassName = containsColor ? categoryClassName : `green ${categoryClassName}`; - - return ( - - {children} - - ); - } - ) -); - -Button.displayName = "Button"; - -export { Button }; diff --git a/frontend/app/element/copybutton.scss b/frontend/app/element/copybutton.scss deleted file mode 100644 index 1689a0f976..0000000000 --- a/frontend/app/element/copybutton.scss +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.copy-button { - &.copied { - opacity: 1; - i { - color: var(--success-color); - } - } -} diff --git a/frontend/app/element/copybutton.tsx b/frontend/app/element/copybutton.tsx deleted file mode 100644 index 027311a096..0000000000 --- a/frontend/app/element/copybutton.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import clsx from "clsx"; -import { useEffect, useRef, useState } from "react"; -import "./copybutton.scss"; -import { IconButton } from "./iconbutton"; - -type CopyButtonProps = { - title: string; - className?: string; - onClick: (e: React.MouseEvent) => void; -}; - -const CopyButton = ({ title, className, onClick }: CopyButtonProps) => { - const [isCopied, setIsCopied] = useState(false); - const timeoutRef = useRef(null); - - const handleOnClick = (e: React.MouseEvent) => { - if (isCopied) { - return; - } - setIsCopied(true); - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - timeoutRef.current = setTimeout(() => { - setIsCopied(false); - timeoutRef.current = null; - }, 2000); - - if (onClick) { - onClick(e); - } - }; - - useEffect(() => { - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - return ( - - ); -}; - -export { CopyButton }; diff --git a/frontend/app/element/emojibutton.tsx b/frontend/app/element/emojibutton.tsx deleted file mode 100644 index 069c82e8f5..0000000000 --- a/frontend/app/element/emojibutton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { cn, makeIconClass } from "@/util/util"; -import { useLayoutEffect, useRef, useState } from "react"; - -export const EmojiButton = ({ - emoji, - icon, - isClicked, - onClick, - className, - suppressFlyUp, -}: { - emoji?: string; - icon?: string; - isClicked: boolean; - onClick: () => void; - className?: string; - suppressFlyUp?: boolean; -}) => { - const [showFloating, setShowFloating] = useState(false); - const prevClickedRef = useRef(isClicked); - - useLayoutEffect(() => { - if (isClicked && !prevClickedRef.current && !suppressFlyUp) { - setShowFloating(true); - setTimeout(() => setShowFloating(false), 600); - } - prevClickedRef.current = isClicked; - }, [isClicked, suppressFlyUp]); - - const content = icon ? : emoji; - - return ( -
- - {showFloating && ( - - {content} - - )} -
- ); -}; diff --git a/frontend/app/element/emojipalette.scss b/frontend/app/element/emojipalette.scss deleted file mode 100644 index b45118589f..0000000000 --- a/frontend/app/element/emojipalette.scss +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.emoji-palette-content { - padding: 10px; - max-height: 350px; - width: 300px; - display: flex; - flex-direction: column; -} - -.emoji-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(35px, 1fr)); - gap: 10px; - padding: 10px 0; - width: 100%; - height: 300px; - overflow-y: auto; -} - -.emoji-button { - font-size: 24px; - padding: 5px; - cursor: pointer; - background: none; - border: none; - transition: background-color 0.3s ease; - - &:hover { - background-color: rgba(0, 0, 0, 0.1); - border-radius: 5px; - } -} - -.no-emojis { - font-size: 14px; - color: #888; - text-align: center; -} diff --git a/frontend/app/element/emojipalette.tsx b/frontend/app/element/emojipalette.tsx deleted file mode 100644 index db480951d6..0000000000 --- a/frontend/app/element/emojipalette.tsx +++ /dev/null @@ -1,268 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { type Placement } from "@floating-ui/react"; -import clsx from "clsx"; -import { memo, useState } from "react"; -import { Button } from "./button"; -import { Input, InputGroup, InputLeftElement } from "./input"; -import { Popover, PopoverButton, PopoverContent } from "./popover"; - -import "./emojipalette.scss"; - -type EmojiItem = { emoji: string; name: string }; - -const emojiList: EmojiItem[] = [ - // Smileys & Emotion - { emoji: "😀", name: "grinning face" }, - { emoji: "😁", name: "beaming face with smiling eyes" }, - { emoji: "😂", name: "face with tears of joy" }, - { emoji: "đŸ¤Ŗ", name: "rolling on the floor laughing" }, - { emoji: "😃", name: "grinning face with big eyes" }, - { emoji: "😄", name: "grinning face with smiling eyes" }, - { emoji: "😅", name: "grinning face with sweat" }, - { emoji: "😆", name: "grinning squinting face" }, - { emoji: "😉", name: "winking face" }, - { emoji: "😊", name: "smiling face with smiling eyes" }, - { emoji: "😋", name: "face savoring food" }, - { emoji: "😎", name: "smiling face with sunglasses" }, - { emoji: "😍", name: "smiling face with heart-eyes" }, - { emoji: "😘", name: "face blowing a kiss" }, - { emoji: "😗", name: "kissing face" }, - { emoji: "😙", name: "kissing face with smiling eyes" }, - { emoji: "😚", name: "kissing face with closed eyes" }, - { emoji: "🙂", name: "slightly smiling face" }, - { emoji: "🤗", name: "hugging face" }, - { emoji: "🤔", name: "thinking face" }, - { emoji: "😐", name: "neutral face" }, - { emoji: "😑", name: "expressionless face" }, - { emoji: "đŸ˜ļ", name: "face without mouth" }, - { emoji: "🙄", name: "face with rolling eyes" }, - { emoji: "😏", name: "smirking face" }, - { emoji: "đŸ˜Ŗ", name: "persevering face" }, - { emoji: "đŸ˜Ĩ", name: "sad but relieved face" }, - { emoji: "😮", name: "face with open mouth" }, - { emoji: "🤐", name: "zipper-mouth face" }, - { emoji: "đŸ˜¯", name: "hushed face" }, - { emoji: "đŸ˜Ē", name: "sleepy face" }, - { emoji: "đŸ˜Ģ", name: "tired face" }, - { emoji: "đŸĨą", name: "yawning face" }, - { emoji: "😴", name: "sleeping face" }, - { emoji: "😌", name: "relieved face" }, - { emoji: "😛", name: "face with tongue" }, - { emoji: "😜", name: "winking face with tongue" }, - { emoji: "😝", name: "squinting face with tongue" }, - { emoji: "🤤", name: "drooling face" }, - { emoji: "😒", name: "unamused face" }, - { emoji: "😓", name: "downcast face with sweat" }, - { emoji: "😔", name: "pensive face" }, - { emoji: "😕", name: "confused face" }, - { emoji: "🙃", name: "upside-down face" }, - { emoji: "đŸĢ ", name: "melting face" }, - { emoji: "😲", name: "astonished face" }, - { emoji: "â˜šī¸", name: "frowning face" }, - { emoji: "🙁", name: "slightly frowning face" }, - { emoji: "😖", name: "confounded face" }, - { emoji: "😞", name: "disappointed face" }, - { emoji: "😟", name: "worried face" }, - { emoji: "😤", name: "face with steam from nose" }, - { emoji: "đŸ˜ĸ", name: "crying face" }, - { emoji: "😭", name: "loudly crying face" }, - { emoji: "đŸ˜Ļ", name: "frowning face with open mouth" }, - { emoji: "😧", name: "anguished face" }, - { emoji: "😨", name: "fearful face" }, - { emoji: "😩", name: "weary face" }, - { emoji: "đŸ¤¯", name: "exploding head" }, - { emoji: "đŸ˜Ŧ", name: "grimacing face" }, - { emoji: "😰", name: "anxious face with sweat" }, - { emoji: "😱", name: "face screaming in fear" }, - { emoji: "đŸĨĩ", name: "hot face" }, - { emoji: "đŸĨļ", name: "cold face" }, - { emoji: "đŸ˜ŗ", name: "flushed face" }, - { emoji: "đŸ¤Ē", name: "zany face" }, - { emoji: "đŸ˜ĩ", name: "dizzy face" }, - { emoji: "đŸĨ´", name: "woozy face" }, - { emoji: "😠", name: "angry face" }, - { emoji: "😡", name: "pouting face" }, - { emoji: "đŸ¤Ŧ", name: "face with symbols on mouth" }, - { emoji: "🤮", name: "face vomiting" }, - { emoji: "đŸ¤ĸ", name: "nauseated face" }, - { emoji: "😷", name: "face with medical mask" }, - - // Gestures & Hand Signs - { emoji: "👋", name: "waving hand" }, - { emoji: "🤚", name: "raised back of hand" }, - { emoji: "đŸ–ī¸", name: "hand with fingers splayed" }, - { emoji: "✋", name: "raised hand" }, - { emoji: "👌", name: "OK hand" }, - { emoji: "âœŒī¸", name: "victory hand" }, - { emoji: "🤞", name: "crossed fingers" }, - { emoji: "🤟", name: "love-you gesture" }, - { emoji: "🤘", name: "sign of the horns" }, - { emoji: "🤙", name: "call me hand" }, - { emoji: "👈", name: "backhand index pointing left" }, - { emoji: "👉", name: "backhand index pointing right" }, - { emoji: "👆", name: "backhand index pointing up" }, - { emoji: "👇", name: "backhand index pointing down" }, - { emoji: "👍", name: "thumbs up" }, - { emoji: "👎", name: "thumbs down" }, - { emoji: "👏", name: "clapping hands" }, - { emoji: "🙌", name: "raising hands" }, - { emoji: "👐", name: "open hands" }, - { emoji: "🙏", name: "folded hands" }, - - // Animals & Nature - { emoji: "đŸļ", name: "dog face" }, - { emoji: "🐱", name: "cat face" }, - { emoji: "🐭", name: "mouse face" }, - { emoji: "🐹", name: "hamster face" }, - { emoji: "🐰", name: "rabbit face" }, - { emoji: "đŸĻŠ", name: "fox face" }, - { emoji: "đŸģ", name: "bear face" }, - { emoji: "đŸŧ", name: "panda face" }, - { emoji: "🐨", name: "koala" }, - { emoji: "đŸ¯", name: "tiger face" }, - { emoji: "đŸĻ", name: "lion" }, - { emoji: "🐮", name: "cow face" }, - { emoji: "🐷", name: "pig face" }, - { emoji: "🐸", name: "frog face" }, - { emoji: "đŸĩ", name: "monkey face" }, - { emoji: "đŸĻ„", name: "unicorn face" }, - { emoji: "đŸĸ", name: "turtle" }, - { emoji: "🐍", name: "snake" }, - { emoji: "đŸĻ‹", name: "butterfly" }, - { emoji: "🐝", name: "honeybee" }, - { emoji: "🐞", name: "lady beetle" }, - { emoji: "đŸĻ€", name: "crab" }, - { emoji: "🐠", name: "tropical fish" }, - { emoji: "🐟", name: "fish" }, - { emoji: "đŸŦ", name: "dolphin" }, - { emoji: "đŸŗ", name: "spouting whale" }, - { emoji: "🐋", name: "whale" }, - { emoji: "đŸĻˆ", name: "shark" }, - - // Food & Drink - { emoji: "🍏", name: "green apple" }, - { emoji: "🍎", name: "red apple" }, - { emoji: "🍐", name: "pear" }, - { emoji: "🍊", name: "tangerine" }, - { emoji: "🍋", name: "lemon" }, - { emoji: "🍌", name: "banana" }, - { emoji: "🍉", name: "watermelon" }, - { emoji: "🍇", name: "grapes" }, - { emoji: "🍓", name: "strawberry" }, - { emoji: "đŸĢ", name: "blueberries" }, - { emoji: "🍈", name: "melon" }, - { emoji: "🍒", name: "cherries" }, - { emoji: "🍑", name: "peach" }, - { emoji: "đŸĨ­", name: "mango" }, - { emoji: "🍍", name: "pineapple" }, - { emoji: "đŸĨĨ", name: "coconut" }, - { emoji: "đŸĨ‘", name: "avocado" }, - { emoji: "đŸĨĻ", name: "broccoli" }, - { emoji: "đŸĨ•", name: "carrot" }, - { emoji: "đŸŒŊ", name: "corn" }, - { emoji: "đŸŒļī¸", name: "hot pepper" }, - { emoji: "🍔", name: "hamburger" }, - { emoji: "🍟", name: "french fries" }, - { emoji: "🍕", name: "pizza" }, - { emoji: "🌭", name: "hot dog" }, - { emoji: "đŸĨĒ", name: "sandwich" }, - { emoji: "đŸŋ", name: "popcorn" }, - { emoji: "đŸĨ“", name: "bacon" }, - { emoji: "đŸĨš", name: "egg" }, - { emoji: "🍰", name: "cake" }, - { emoji: "🎂", name: "birthday cake" }, - { emoji: "đŸĻ", name: "ice cream" }, - { emoji: "🍩", name: "doughnut" }, - { emoji: "đŸĒ", name: "cookie" }, - { emoji: "đŸĢ", name: "chocolate bar" }, - { emoji: "đŸŦ", name: "candy" }, - { emoji: "🍭", name: "lollipop" }, - - // Activities - { emoji: "âšŊ", name: "soccer ball" }, - { emoji: "🏀", name: "basketball" }, - { emoji: "🏈", name: "american football" }, - { emoji: "⚾", name: "baseball" }, - { emoji: "đŸĨŽ", name: "softball" }, - { emoji: "🎾", name: "tennis" }, - { emoji: "🏐", name: "volleyball" }, - { emoji: "đŸŽŗ", name: "bowling" }, - { emoji: "â›ŗ", name: "flag in hole" }, - { emoji: "🚴", name: "person biking" }, - { emoji: "🎮", name: "video game" }, - { emoji: "🎲", name: "game die" }, - { emoji: "🎸", name: "guitar" }, - { emoji: "đŸŽē", name: "trumpet" }, - - // Miscellaneous - { emoji: "🚀", name: "rocket" }, - { emoji: "💖", name: "sparkling heart" }, - { emoji: "🎉", name: "party popper" }, - { emoji: "đŸ”Ĩ", name: "fire" }, - { emoji: "🎁", name: "gift" }, - { emoji: "â¤ī¸", name: "red heart" }, - { emoji: "🧡", name: "orange heart" }, - { emoji: "💛", name: "yellow heart" }, - { emoji: "💚", name: "green heart" }, - { emoji: "💙", name: "blue heart" }, - { emoji: "💜", name: "purple heart" }, - { emoji: "🤍", name: "white heart" }, - { emoji: "🤎", name: "brown heart" }, - { emoji: "💔", name: "broken heart" }, -]; - -interface EmojiPaletteProps { - className?: string; - placement?: Placement; - onSelect?: (_: EmojiItem) => void; -} - -const EmojiPalette = memo(({ className, placement, onSelect }: EmojiPaletteProps) => { - const [searchTerm, setSearchTerm] = useState(""); - - const handleSearchChange = (val: string) => { - setSearchTerm(val.toLowerCase()); - }; - - const handleSelect = (item: { name: string; emoji: string }) => { - onSelect?.(item); - }; - - const filteredEmojis = emojiList.filter((item) => item.name.includes(searchTerm)); - - return ( -
- - - - - - - - - - - -
- {filteredEmojis.length > 0 ? ( - filteredEmojis.map((item, index) => ( - - )) - ) : ( -
No emojis found
- )} -
-
-
-
- ); -}); - -EmojiPalette.displayName = "EmojiPalette"; - -export { EmojiPalette }; -export type { EmojiItem }; diff --git a/frontend/app/element/errorboundary.tsx b/frontend/app/element/errorboundary.tsx deleted file mode 100644 index cdf446bc55..0000000000 --- a/frontend/app/element/errorboundary.tsx +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import React, { ReactNode } from "react"; - -export class ErrorBoundary extends React.Component< - { children: ReactNode; fallback?: React.ReactElement & { error?: Error } }, - { error: Error } -> { - constructor(props) { - super(props); - this.state = { error: null }; - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - console.error("ErrorBoundary caught an error:", error, errorInfo); - this.setState({ error: error }); - } - - render() { - const { fallback } = this.props; - const { error } = this.state; - if (error) { - if (fallback != null) { - return React.cloneElement(fallback as any, { error }); - } - const errorMsg = `Error: ${error?.message}\n\n${error?.stack}`; - return
{errorMsg}
; - } else { - return <>{this.props.children}; - } - } -} - -export class NullErrorBoundary extends React.Component< - { children: React.ReactNode; debugName?: string }, - { hasError: boolean } -> { - constructor(props: { children: React.ReactNode; debugName?: string }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - componentDidCatch(error: Error, info: React.ErrorInfo) { - console.error(`${this.props.debugName ?? "NullErrorBoundary"} error boundary caught error`, error, info); - } - - render() { - if (this.state.hasError) { - return null; - } - return this.props.children; - } -} diff --git a/frontend/app/element/expandablemenu.scss b/frontend/app/element/expandablemenu.scss deleted file mode 100644 index e269e9759c..0000000000 --- a/frontend/app/element/expandablemenu.scss +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.expandable-menu { - display: flex; - flex-direction: column; - width: 100%; - overflow: visible; -} - -.expandable-menu-item, -.expandable-menu-item-group-title { - display: flex; - align-items: center; - padding: 8px 12px; /* Left and right padding, we'll adjust this for the right side */ - cursor: pointer; - box-sizing: border-box; - border-radius: 4px; - - .label { - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } -} - -.expandable-menu-item-group-title { - &:hover { - background-color: var(--button-grey-hover-bg); - } -} - -.expandable-menu-item { - &.with-hover-effect { - &:hover { - background-color: var(--button-grey-hover-bg); - } - } -} - -.expandable-menu-item-left, -.expandable-menu-item-right { - display: flex; - align-items: center; -} - -.expandable-menu-item-left { - margin-right: 8px; /* Space for the left element */ -} - -.expandable-menu-item-right { - margin-left: auto; /* This keeps the right element (if any) on the far right */ - white-space: nowrap; -} - -.expandable-menu-item-content { - flex-grow: 1; /* Ensures the content grows to fill available space between left and right elements */ -} - -.expandable-menu-item-group-content { - max-height: 0; - overflow: hidden; - margin-left: 16px; /* Retaining left indentation */ - margin-right: 0; /* Removing right padding */ - - &.open { - max-height: 1000px; /* Ensure large enough max-height for expansion */ - } -} - -.no-indent .expandable-menu-item-group-content { - margin-left: 0; // Remove left indentation when noIndent is true -} diff --git a/frontend/app/element/expandablemenu.tsx b/frontend/app/element/expandablemenu.tsx deleted file mode 100644 index 3a5b60210d..0000000000 --- a/frontend/app/element/expandablemenu.tsx +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright 2025, Command Line -// SPDX-License-Identifier: Apache-2.0 - -import clsx from "clsx"; -import { atom, useAtom } from "jotai"; -import { Children, ReactElement, ReactNode, cloneElement, isValidElement, useRef } from "react"; - -import "./expandablemenu.scss"; - -// Define the global atom for managing open groups -const openGroupsAtom = atom<{ [key: string]: boolean }>({}); - -type BaseExpandableMenuItem = { - type: "item" | "group"; - id?: string; -}; - -interface ExpandableMenuItemType extends BaseExpandableMenuItem { - type: "item"; - leftElement?: string | ReactNode; - rightElement?: string | ReactNode; - content?: React.ReactNode | ((props: any) => React.ReactNode); -} - -interface ExpandableMenuItemGroupTitleType { - leftElement?: string | ReactNode; - label: string; - rightElement?: string | ReactNode; -} - -interface ExpandableMenuItemGroupType extends BaseExpandableMenuItem { - type: "group"; - title: ExpandableMenuItemGroupTitleType; - isOpen?: boolean; - children?: ExpandableMenuItemData[]; -} - -type ExpandableMenuItemData = ExpandableMenuItemType | ExpandableMenuItemGroupType; - -type ExpandableMenuProps = { - children: React.ReactNode; - className?: string; - noIndent?: boolean; - singleOpen?: boolean; -}; - -const ExpandableMenu = ({ children, className, noIndent = false, singleOpen = false }: ExpandableMenuProps) => { - return ( -
- {Children.map(children, (child) => { - if (isValidElement(child) && child.type === ExpandableMenuItemGroup) { - return cloneElement(child as any, { singleOpen }); - } - return child; - })} -
- ); -}; - -type ExpandableMenuItemProps = { - children: ReactNode; - className?: string; - withHoverEffect?: boolean; - onClick?: () => void; -}; - -const ExpandableMenuItem = ({ children, className, withHoverEffect = true, onClick }: ExpandableMenuItemProps) => { - return ( -
- {children} -
- ); -}; - -type ExpandableMenuItemGroupTitleProps = { - children: ReactNode; - className?: string; - onClick?: () => void; -}; - -const ExpandableMenuItemGroupTitle = ({ children, className, onClick }: ExpandableMenuItemGroupTitleProps) => { - return ( -
- {children} -
- ); -}; - -type ExpandableMenuItemGroupProps = { - children: React.ReactNode; - className?: string; - isOpen?: boolean; - onToggle?: (isOpen: boolean) => void; - singleOpen?: boolean; -}; - -const ExpandableMenuItemGroup = ({ - children, - className, - isOpen, - onToggle, - singleOpen = false, -}: ExpandableMenuItemGroupProps) => { - const [openGroups, setOpenGroups] = useAtom(openGroupsAtom); - - // Generate a unique ID for this group using useRef - const idRef = useRef(null); - - if (!idRef.current) { - // Generate a unique ID when the component is first rendered - idRef.current = `group-${Math.random().toString(36).substr(2, 9)}`; - } - - const id = idRef.current; - - // Determine if the component is controlled or uncontrolled - const isControlled = isOpen !== undefined; - - // Get the open state from global atom in uncontrolled mode - const actualIsOpen = isControlled ? isOpen : (openGroups[id] ?? false); - - const toggleOpen = () => { - const newIsOpen = !actualIsOpen; - - if (isControlled) { - // If controlled, call the onToggle callback - onToggle?.(newIsOpen); - } else { - // If uncontrolled, update global atom - setOpenGroups((prevOpenGroups) => { - if (singleOpen) { - // Close all other groups and open this one - return { [id]: newIsOpen }; - } else { - // Toggle this group - return { ...prevOpenGroups, [id]: newIsOpen }; - } - }); - } - }; - - const renderChildren = Children.map(children, (child: ReactElement) => { - if (child && child.type === ExpandableMenuItemGroupTitle) { - const childProps = child.props as ExpandableMenuItemGroupTitleProps; - return cloneElement(child as ReactElement, { - ...childProps, - onClick: () => { - childProps.onClick?.(); - toggleOpen(); - }, - }); - } else { - return
{child}
; - } - }); - - return ( -
{renderChildren}
- ); -}; - -type ExpandableMenuItemLeftElementProps = { - children: ReactNode; - onClick?: () => void; -}; - -const ExpandableMenuItemLeftElement = ({ children, onClick }: ExpandableMenuItemLeftElementProps) => { - return ( -
- {children} -
- ); -}; - -type ExpandableMenuItemRightElementProps = { - children: ReactNode; - onClick?: () => void; -}; - -const ExpandableMenuItemRightElement = ({ children, onClick }: ExpandableMenuItemRightElementProps) => { - return ( -
- {children} -
- ); -}; - -export { - ExpandableMenu, - ExpandableMenuItem, - ExpandableMenuItemGroup, - ExpandableMenuItemGroupTitle, - ExpandableMenuItemLeftElement, - ExpandableMenuItemRightElement, -}; -export type { ExpandableMenuItemData, ExpandableMenuItemGroupTitleType }; diff --git a/frontend/app/element/flyoutmenu.scss b/frontend/app/element/flyoutmenu.scss deleted file mode 100644 index fc114b0ee1..0000000000 --- a/frontend/app/element/flyoutmenu.scss +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.menu { - position: absolute; - z-index: 1000; - display: flex; - max-width: 400px; - min-width: 125px; - padding: 2px; - flex-direction: column; - justify-content: flex-end; - align-items: flex-start; - gap: 1px; - border-radius: 4px; - border: 1px solid rgba(255, 255, 255, 0.15); - background: #212121; - box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3); -} - -.menu-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 4px 6px; - cursor: pointer; - color: var(--main-text-color); - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: normal; - letter-spacing: -0.12px; - width: 100%; - border-radius: 2px; - - .label { - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-decoration: none; - } -} - -.menu-item { - color: var(--main-text-color); - - &:hover { - background-color: var(--accent-color); - color: var(--button-text-color); - border-radius: 2px; - } -} diff --git a/frontend/app/element/flyoutmenu.tsx b/frontend/app/element/flyoutmenu.tsx deleted file mode 100644 index 7f327c6fb5..0000000000 --- a/frontend/app/element/flyoutmenu.tsx +++ /dev/null @@ -1,303 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { FloatingPortal, type Placement, useDismiss, useFloating, useInteractions } from "@floating-ui/react"; -import clsx from "clsx"; -import { createRef, Fragment, memo, ReactNode, useRef, useState } from "react"; -import ReactDOM from "react-dom"; - -import "./flyoutmenu.scss"; - -type MenuProps = { - items: MenuItem[]; - className?: string; - placement?: Placement; - onOpenChange?: (isOpen: boolean) => void; - children: ReactNode | ReactNode[]; - renderMenu?: (subMenu: React.ReactElement, props: any) => React.ReactElement; - renderMenuItem?: (item: MenuItem, props: any) => React.ReactElement; -}; - -const FlyoutMenuComponent = memo( - ({ items, children, className, placement, onOpenChange, renderMenu, renderMenuItem }: MenuProps) => { - const [visibleSubMenus, setVisibleSubMenus] = useState<{ [key: string]: any }>({}); - const [hoveredItems, setHoveredItems] = useState([]); - const [subMenuPosition, setSubMenuPosition] = useState<{ - [key: string]: { top: number; left: number; label: string }; - }>({}); - const subMenuRefs = useRef<{ [key: string]: React.RefObject }>({}); - - const [isOpen, setIsOpen] = useState(false); - const onOpenChangeMenu = (isOpen: boolean) => { - setIsOpen(isOpen); - onOpenChange?.(isOpen); - }; - const { refs, floatingStyles, context } = useFloating({ - placement: placement ?? "bottom-start", - open: isOpen, - onOpenChange: onOpenChangeMenu, - }); - const dismiss = useDismiss(context); - const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]); - - items.forEach((_, idx) => { - const key = `${idx}`; - if (!subMenuRefs.current[key]) { - subMenuRefs.current[key] = createRef(); - } - }); - - // Position submenus based on available space and scroll position - const handleSubMenuPosition = (key: string, itemRect: DOMRect, label: string) => { - setTimeout(() => { - const subMenuRef = subMenuRefs.current[key]?.current; - if (!subMenuRef) return; - - const scrollTop = window.scrollY || document.documentElement.scrollTop; - const scrollLeft = window.scrollX || document.documentElement.scrollLeft; - - const submenuWidth = subMenuRef.offsetWidth; - const submenuHeight = subMenuRef.offsetHeight; - - let left = itemRect.right + scrollLeft - 2; // Adjust for horizontal scroll - let top = itemRect.top - 2 + scrollTop; // Adjust for vertical scroll - - // Adjust to the left if overflowing the right boundary - if (left + submenuWidth > window.innerWidth + scrollLeft) { - left = itemRect.left + scrollLeft - submenuWidth; - } - - // Adjust if the submenu overflows the bottom boundary - if (top + submenuHeight > window.innerHeight + scrollTop) { - top = window.innerHeight + scrollTop - submenuHeight - 10; - } - - setSubMenuPosition((prev) => ({ - ...prev, - [key]: { top, left, label }, - })); - }, 0); - }; - - const handleMouseEnterItem = ( - event: React.MouseEvent, - parentKey: string | null, - index: number, - item: MenuItem - ) => { - event.stopPropagation(); - - const key = parentKey ? `${parentKey}-${index}` : `${index}`; - - setVisibleSubMenus((prev) => { - const updatedState = { ...prev }; - updatedState[key] = { visible: true, label: item.label }; - - const ancestors = key.split("-").reduce((acc, part, idx) => { - if (idx === 0) return [part]; - return [...acc, `${acc[idx - 1]}-${part}`]; - }, [] as string[]); - - ancestors.forEach((ancestorKey) => { - if (updatedState[ancestorKey]) { - updatedState[ancestorKey].visible = true; - } - }); - - for (const pkey in updatedState) { - if (!ancestors.includes(pkey) && pkey !== key) { - updatedState[pkey].visible = false; - } - } - - return updatedState; - }); - - const newHoveredItems = key.split("-").reduce((acc, part, idx) => { - if (idx === 0) return [part]; - return [...acc, `${acc[idx - 1]}-${part}`]; - }, [] as string[]); - - setHoveredItems(newHoveredItems); - - const itemRect = event.currentTarget.getBoundingClientRect(); - handleSubMenuPosition(key, itemRect, item.label); - }; - - const handleOnClick = (e: React.MouseEvent, item: MenuItem) => { - e.stopPropagation(); - onOpenChangeMenu(false); - item.onClick?.(e); - }; - - return ( - <> -
onOpenChangeMenu(!isOpen)} - > - {children} -
- {isOpen && ( - -
- {items.map((item, index) => { - const key = `${index}`; - const isActive = hoveredItems.includes(key); - - const menuItemProps = { - className: clsx("menu-item", { active: isActive }), - onMouseEnter: (event: React.MouseEvent) => - handleMouseEnterItem(event, null, index, item), - onClick: (e: React.MouseEvent) => handleOnClick(e, item), - }; - - const renderedItem = renderMenuItem ? ( - renderMenuItem(item, menuItemProps) - ) : ( -
- {item.label} - {item.subItems && } -
- ); - - return ( - - {renderedItem} - {visibleSubMenus[key]?.visible && item.subItems && ( - - )} - - ); - })} -
-
- )} - - ); - } -); - -const FlyoutMenu = memo(FlyoutMenuComponent) as typeof FlyoutMenuComponent; - -type SubMenuProps = { - subItems: MenuItem[]; - parentKey: string; - subMenuPosition: { - [key: string]: { top: number; left: number; label: string }; - }; - visibleSubMenus: { [key: string]: any }; - hoveredItems: string[]; - subMenuRefs: React.RefObject<{ [key: string]: React.RefObject }>; - handleMouseEnterItem: ( - event: React.MouseEvent, - parentKey: string | null, - index: number, - item: MenuItem - ) => void; - handleOnClick: (e: React.MouseEvent, item: MenuItem) => void; - renderMenu?: (subMenu: React.ReactElement, props: any) => React.ReactElement; - renderMenuItem?: (item: MenuItem, props: any) => React.ReactElement; -}; - -const SubMenu = memo( - ({ - subItems, - parentKey, - subMenuPosition, - visibleSubMenus, - hoveredItems, - subMenuRefs, - handleMouseEnterItem, - handleOnClick, - renderMenu, - renderMenuItem, - }: SubMenuProps) => { - subItems.forEach((_, idx) => { - const newKey = `${parentKey}-${idx}`; - if (!subMenuRefs.current[newKey]) { - subMenuRefs.current[newKey] = createRef(); - } - }); - - const position = subMenuPosition[parentKey]; - const isPositioned = position && position.top !== undefined && position.left !== undefined; - - const subMenu = ( -
- {subItems.map((item, idx) => { - const newKey = `${parentKey}-${idx}`; - const isActive = hoveredItems.includes(newKey); - - const menuItemProps = { - className: clsx("menu-item", { active: isActive }), - onMouseEnter: (event: React.MouseEvent) => - handleMouseEnterItem(event, parentKey, idx, item), - onClick: (e: React.MouseEvent) => handleOnClick(e, item), - }; - - const renderedItem = renderMenuItem ? ( - renderMenuItem(item, menuItemProps) // Remove portal here - ) : ( -
- {item.label} - {item.subItems && } -
- ); - - return ( - - {renderedItem} - {visibleSubMenus[newKey]?.visible && item.subItems && ( - - )} - - ); - })} -
- ); - - return ReactDOM.createPortal(renderMenu ? renderMenu(subMenu, { parentKey }) : subMenu, document.body); - } -); - -export { FlyoutMenu }; diff --git a/frontend/app/element/iconbutton.scss b/frontend/app/element/iconbutton.scss deleted file mode 100644 index f39892c72b..0000000000 --- a/frontend/app/element/iconbutton.scss +++ /dev/null @@ -1,48 +0,0 @@ -.wave-iconbutton { - display: flex; - cursor: pointer; - opacity: 0.7; - align-items: center; - background: none; - border: none; - padding: 0; - font: inherit; - outline: inherit; - - &.bulb { - color: var(--bulb-color); - opacity: 1; - - &:hover i::before { - content: "\f672"; - position: relative; - left: -1px; - } - } - - &:hover { - opacity: 1; - } - - &.no-action { - cursor: default; - } - - &.disabled { - cursor: default; - opacity: 0.45 !important; - } - - &.toggle { - border-radius: 3px; - padding: 1px; - &.active { - opacity: 1; - border: 1px solid var(--accent-color); - padding: 0; - } - &:hover { - background: var(--highlight-bg-color); - } - } -} diff --git a/frontend/app/element/iconbutton.tsx b/frontend/app/element/iconbutton.tsx deleted file mode 100644 index 318e2cbbee..0000000000 --- a/frontend/app/element/iconbutton.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2023, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { useLongClick } from "@/app/hook/useLongClick"; -import { makeIconClass } from "@/util/util"; -import clsx from "clsx"; -import { atom, useAtom } from "jotai"; -import { CSSProperties, forwardRef, memo, useMemo, useRef } from "react"; -import "./iconbutton.scss"; - -type IconButtonProps = { decl: IconButtonDecl; className?: string }; -export const IconButton = memo( - forwardRef(({ decl, className }, ref) => { - ref = ref ?? useRef(null); - const spin = decl.iconSpin ?? false; - useLongClick(ref, decl.click, decl.longClick, decl.disabled); - const disabled = decl.disabled ?? false; - const styleVal: CSSProperties = {}; - if (decl.iconColor) { - styleVal.color = decl.iconColor; - } - return ( - - ); - }) -); - -type ToggleIconButtonProps = { decl: ToggleIconButtonDecl; className?: string }; - -export const ToggleIconButton = memo( - forwardRef(({ decl, className }, ref) => { - const activeAtom = useMemo(() => decl.active ?? atom(false), [decl.active]); - const [active, setActive] = useAtom(activeAtom); - ref = ref ?? useRef(null); - const spin = decl.iconSpin ?? false; - const title = `${decl.title}${active ? " (Active)" : ""}`; - const disabled = decl.disabled ?? false; - return ( - - ); - }) -); diff --git a/frontend/app/element/input.scss b/frontend/app/element/input.scss deleted file mode 100644 index d51acdd386..0000000000 --- a/frontend/app/element/input.scss +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.input { - width: 100%; - border: none; - font-size: 12px; - outline: none; - background-color: transparent; - color: var(--form-element-text-color); - background: var(--form-element-bg-color); - border: 2px solid var(--form-element-border-color); - border-radius: 6px; - padding: 4px 7px; - - &:focus { - border-color: var(--form-element-primary-color); - } - - &.disabled { - opacity: 0.75; - } - - &.error { - border-color: var(--form-element-error-color); - } -} - -/* Styles when an InputGroup is present */ -.input-group { - display: flex; - align-items: center; - border-radius: 6px; - position: relative; - width: 100%; - border: 2px solid var(--form-element-border-color); - background: var(--form-element-bg-color); - - /* Focus style for InputGroup */ - &.focused { - border-color: var(--form-element-primary-color); - } - - /* Error state for InputGroup */ - &.error { - border-color: var(--form-element-error-color); - } - - /* Disabled state for InputGroup */ - &.disabled { - opacity: 0.75; - } - - &:hover { - cursor: text; - } - - .input-left-element, - .input-right-element { - padding: 0 5px; - display: flex; - align-items: center; - justify-content: center; - } - - .input { - border: none; - flex-grow: 1; - border-radius: none; - - &:focus { - border-color: transparent; - } - - &.error { - border-color: transparent; - } - } -} diff --git a/frontend/app/element/input.tsx b/frontend/app/element/input.tsx deleted file mode 100644 index 060f27877d..0000000000 --- a/frontend/app/element/input.tsx +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import clsx from "clsx"; -import React, { forwardRef, memo, useImperativeHandle, useRef, useState } from "react"; - -import "./input.scss"; - -interface InputGroupProps { - children: React.ReactNode; - className?: string; -} - -const InputGroup = memo( - forwardRef(({ children, className }: InputGroupProps, ref) => { - const [isFocused, setIsFocused] = useState(false); - - const manageFocus = (focused: boolean) => { - setIsFocused(focused); - }; - - return ( -
- {React.Children.map(children, (child) => { - if (React.isValidElement(child)) { - return React.cloneElement(child as any, { manageFocus }); - } - return child; - })} -
- ); - }) -); - -interface InputLeftElementProps { - children: React.ReactNode; - className?: string; -} - -const InputLeftElement = memo(({ children, className }: InputLeftElementProps) => { - return
{children}
; -}); - -interface InputRightElementProps { - children: React.ReactNode; - className?: string; -} - -const InputRightElement = memo(({ children, className }: InputRightElementProps) => { - return
{children}
; -}); - -interface InputProps { - value?: string; - className?: string; - onChange?: (value: string) => void; - onKeyDown?: (event: React.KeyboardEvent) => void; - onFocus?: () => void; - onBlur?: () => void; - placeholder?: string; - defaultValue?: string; - required?: boolean; - maxLength?: number; - autoFocus?: boolean; - autoSelect?: boolean; - disabled?: boolean; - isNumber?: boolean; - inputRef?: React.RefObject; - manageFocus?: (isFocused: boolean) => void; -} - -const Input = memo( - forwardRef( - ( - { - value, - className, - onChange, - onKeyDown, - onFocus, - onBlur, - placeholder, - defaultValue = "", - required, - maxLength, - autoFocus, - autoSelect, - disabled, - isNumber, - manageFocus, - }: InputProps, - ref - ) => { - const [internalValue, setInternalValue] = useState(defaultValue); - const inputRef = useRef(null); - - useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); - - const handleInputChange = (e: React.ChangeEvent) => { - const inputValue = e.target.value; - - if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) { - return; - } - - if (value === undefined) { - setInternalValue(inputValue); - } - - onChange?.(inputValue); - }; - - const handleFocus = () => { - if (autoSelect) { - inputRef.current?.select(); - } - manageFocus?.(true); - onFocus?.(); - }; - - const handleBlur = () => { - manageFocus?.(false); - onBlur?.(); - }; - - const inputValue = value ?? internalValue; - - return ( - - ); - } - ) -); - -export { Input, InputGroup, InputLeftElement, InputRightElement }; -export type { InputGroupProps, InputLeftElementProps, InputProps, InputRightElementProps }; diff --git a/frontend/app/element/linkbutton.scss b/frontend/app/element/linkbutton.scss deleted file mode 100644 index a50be7c6f6..0000000000 --- a/frontend/app/element/linkbutton.scss +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.link-button { - text-decoration: none; -} diff --git a/frontend/app/element/linkbutton.tsx b/frontend/app/element/linkbutton.tsx deleted file mode 100644 index 295aa34d06..0000000000 --- a/frontend/app/element/linkbutton.tsx +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import clsx from "clsx"; -import * as React from "react"; - -import "./linkbutton.scss"; - -interface LinkButtonProps { - href: string; - rel?: string; - target?: string; - children: React.ReactNode; - disabled?: boolean; - style?: React.CSSProperties; - autoFocus?: boolean; - className?: string; - termInline?: boolean; - title?: string; - onClick?: (e: React.MouseEvent) => void; -} - -const LinkButton = ({ children, className, ...rest }: LinkButtonProps) => { - return ( - - {children} - - ); -}; - -export { LinkButton }; diff --git a/frontend/app/element/magnify.scss b/frontend/app/element/magnify.scss deleted file mode 100644 index 111674fdd6..0000000000 --- a/frontend/app/element/magnify.scss +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.magnify-icon { - display: inline-block; - width: 15px; - height: 15px; - svg { - #arrow1 { - transform: rotate(180deg); - transform-origin: calc(29.167% + 4px) calc(70.833% + 4px); // account for path offset in the svg itself - } - #arrow2 { - transform: rotate(-180deg); - transform-origin: calc(70.833% + 4px) calc(29.167% + 4px); - } - #arrow1, - #arrow2 { - transition: transform 300ms ease-in; - transition-delay: 100ms; - } - } - &.enabled { - svg { - #arrow1, - #arrow2 { - transform: rotate(0deg); - } - } - } -} diff --git a/frontend/app/element/magnify.tsx b/frontend/app/element/magnify.tsx deleted file mode 100644 index 8076cc5970..0000000000 --- a/frontend/app/element/magnify.tsx +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import clsx from "clsx"; -import MagnifySVG from "../asset/magnify.svg"; -import "./magnify.scss"; - -interface MagnifyIconProps { - enabled: boolean; -} - -export function MagnifyIcon({ enabled }: MagnifyIconProps) { - return ( -
- -
- ); -} diff --git a/frontend/app/element/markdown-contentblock-plugin.ts b/frontend/app/element/markdown-contentblock-plugin.ts deleted file mode 100644 index a826862b08..0000000000 --- a/frontend/app/element/markdown-contentblock-plugin.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import type { Paragraph, Root, Text } from "mdast"; -import { visit } from "unist-util-visit"; -import { type MarkdownContentBlockType } from "./markdown-util"; - -interface ContentBlockPluginOptions { - blocks: Map; -} - -export function createContentBlockPlugin(opts: ContentBlockPluginOptions) { - const { blocks } = opts; - - return function transformer(tree: Root) { - visit(tree, "paragraph", (node: Paragraph) => { - if (!node.children?.length) return; - - const newChildren = []; - for (const child of node.children) { - if (child.type !== "text") { - newChildren.push(child); - continue; - } - - const text = (child as Text).value; - let lastIndex = 0; - const parts = []; - - // Find all inline blocks - const regex = /!!!(\w+\[.*?\])!!!/g; - let match; - - while ((match = regex.exec(text)) !== null) { - // Add text before the match - if (match.index > lastIndex) { - parts.push({ - type: "text", - value: text.slice(lastIndex, match.index), - }); - } - - const key = match[1]; - const block = blocks.get(key); - - if (block) { - parts.push({ - type: "waveblock", - data: { - hName: "waveblock", - hProperties: { - blockkey: key, - }, - }, - block: block, - }); - } else { - parts.push({ - type: "text", - value: match[0], - }); - } - - lastIndex = match.index + match[0].length; - } - - // Add remaining text - if (lastIndex < text.length) { - parts.push({ - type: "text", - value: text.slice(lastIndex), - }); - } - - newChildren.push(...parts); - } - - node.children = newChildren; - }); - }; -} diff --git a/frontend/app/element/markdown-util.ts b/frontend/app/element/markdown-util.ts deleted file mode 100644 index ae860648d1..0000000000 --- a/frontend/app/element/markdown-util.ts +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { getWebServerEndpoint } from "@/util/endpoints"; -import { formatRemoteUri } from "@/util/waveutil"; -import parseSrcSet from "parse-srcset"; - -export type MarkdownContentBlockType = { - type: string; - id: string; - content: string; - opts?: Record; -}; - -const idMatchRe = /^("(?:[^"\\]|\\.)*")/; - -function formatInlineContentBlock(block: MarkdownContentBlockType): string { - return `!!!${block.type}[${block.id}]!!!`; -} - -function parseOptions(str: string): Record { - const trimmed = str.trim(); - if (!trimmed) return null; - - try { - const parsed = JSON.parse(trimmed); - // Ensure it's an object (not array or primitive) - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - return parsed; - } catch { - return null; - } -} - -function makeMarkdownWaveBlockKey(block: MarkdownContentBlockType): string { - return `${block.type}[${block.id}]`; -} - -export function transformBlocks(content: string): { content: string; blocks: Map } { - const lines = content.split("\n"); - const blocks = new Map(); - let currentBlock = null; - let currentContent = []; - let processedLines = []; - - for (const line of lines) { - // Check for start marker - if (line.startsWith("@@@start ")) { - // Already in a block? Add as content - if (currentBlock) { - processedLines.push(line); - continue; - } - - // Parse the start line - const [, type, rest] = line.slice(9).match(/^(\w+)\s+(.*)/) || []; - if (!type || !rest) { - // Invalid format - treat as regular content - processedLines.push(line); - continue; - } - - // Get the ID (everything between first set of quotes) - const idMatch = rest.match(idMatchRe); - if (!idMatch) { - processedLines.push(line); - continue; - } - - // Parse options if any exist after the ID - const afterId = rest.slice(idMatch[0].length).trim(); - const opts = parseOptions(afterId); - - currentBlock = { - type, - id: idMatch[1], - opts, - }; - continue; - } - - // Check for end marker - if (line.startsWith("@@@end ")) { - // If we're not in a block, treat as content - if (!currentBlock) { - processedLines.push(line); - continue; - } - - // Parse the end line - const [, type, rest] = line.slice(7).match(/^(\w+)\s+(.*)/) || []; - if (!type || !rest) { - currentContent.push(line); - continue; - } - - // Get the ID - const idMatch = rest.match(idMatchRe); - if (!idMatch) { - currentContent.push(line); - continue; - } - - const endId = idMatch[1]; - - // If this doesn't match our current block, treat as content - if (type !== currentBlock.type || endId !== currentBlock.id) { - currentContent.push(line); - continue; - } - - // Found matching end - store block and add placeholder - const key = makeMarkdownWaveBlockKey(currentBlock); - blocks.set(key, { - type: currentBlock.type, - id: currentBlock.id, - opts: currentBlock.opts, - content: currentContent.join("\n"), - }); - - processedLines.push(formatInlineContentBlock(currentBlock)); - currentBlock = null; - currentContent = []; - continue; - } - - // Regular line - add to current block or processed lines - if (currentBlock) { - currentContent.push(line); - } else { - processedLines.push(line); - } - } - - // Handle unclosed block - add what we have so far - if (currentBlock) { - const key = makeMarkdownWaveBlockKey(currentBlock); - blocks.set(key, { - type: currentBlock.type, - id: currentBlock.id, - opts: currentBlock.opts, - content: currentContent.join("\n"), - }); - processedLines.push(formatInlineContentBlock(currentBlock)); - } - - return { - content: processedLines.join("\n"), - blocks: blocks, - }; -} - -export const resolveRemoteFile = async (filepath: string, resolveOpts: MarkdownResolveOpts): Promise => { - if (!filepath || filepath.startsWith("http://") || filepath.startsWith("https://")) { - return filepath; - } - try { - const baseDirUri = formatRemoteUri(resolveOpts.baseDir, resolveOpts.connName); - const fileInfo = await RpcApi.FileJoinCommand(TabRpcClient, [baseDirUri, filepath]); - const remoteUri = formatRemoteUri(fileInfo.path, resolveOpts.connName); - // console.log("markdown resolve", resolveOpts, filepath, "=>", baseDirUri, remoteUri); - const usp = new URLSearchParams(); - usp.set("path", remoteUri); - return getWebServerEndpoint() + "/wave/stream-file?" + usp.toString(); - } catch (err) { - console.warn("Failed to resolve remote file:", filepath, err); - return null; - } -}; - -export const resolveSrcSet = async (srcSet: string, resolveOpts: MarkdownResolveOpts): Promise => { - if (!srcSet) return null; - - // Parse the srcset - const candidates = parseSrcSet(srcSet); - - // Resolve each URL in the array of candidates - const resolvedCandidates = await Promise.all( - candidates.map(async (candidate) => { - const resolvedUrl = await resolveRemoteFile(candidate.url, resolveOpts); - return { - ...candidate, - url: resolvedUrl, - }; - }) - ); - - // Reconstruct the srcset string - return resolvedCandidates - .map((candidate) => { - let part = candidate.url; - if (candidate.w) part += ` ${candidate.w}w`; - if (candidate.h) part += ` ${candidate.h}h`; - if (candidate.d) part += ` ${candidate.d}x`; - return part; - }) - .join(", "); -}; diff --git a/frontend/app/element/markdown.scss b/frontend/app/element/markdown.scss deleted file mode 100644 index dee02633e8..0000000000 --- a/frontend/app/element/markdown.scss +++ /dev/null @@ -1,233 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -@import url("../../../node_modules/highlight.js/scss/github-dark-dimmed.scss"); - -.markdown { - display: flex; - flex-direction: row; - overflow: hidden; - height: 100%; - width: 100%; - - .content { - height: 100%; - width: 100%; - overflow: scroll; - line-height: 1.5; - color: var(--main-text-color); - font-family: var(--markdown-font-family); - font-size: var(--markdown-font-size); - overflow-wrap: break-word; - - &.non-scrollable { - overflow: hidden; - } - - *:last-child { - margin-bottom: 0 !important; - } - - .heading:not(.heading ~ .heading) { - margin-top: 0 !important; - } - - .heading { - color: var(--main-text-color); - margin-top: 1.143em; - margin-bottom: 0.571em; - font-weight: semibold; - padding-top: 0.429em; - - &.is-1 { - border-bottom: 1px solid var(--border-color); - padding-bottom: 0.429em; - font-size: 2em; - } - &.is-2 { - border-bottom: 1px solid var(--border-color); - padding-bottom: 0.429em; - font-size: 1.5em; - } - &.is-3 { - font-size: 1.25em; - } - &.is-4 { - font-size: 1em; - } - &.is-5 { - font-size: 0.875em; - } - &.is-6 { - font-size: 0.85em; - } - } - - .paragraph { - margin-top: 0; - margin-bottom: 10px; - } - - img { - border-style: none; - max-width: 100%; - box-sizing: content-box; - - &[align="right"] { - padding-left: 20px; - } - - &[align="left"] { - padding-right: 20px; - } - } - - strong { - color: var(--main-text-color); - } - - a { - color: #32afff; - } - - ul { - list-style-type: disc; - list-style-position: outside; - margin-left: 1em; - } - - ol { - list-style-position: outside; - margin-left: 1.2em; - } - - blockquote { - margin: 0.286em 0.714em; - border-radius: 4px; - background-color: var(--panel-bg-color); - padding: 0.143em 0.286em 0.143em 0.429em; - } - - pre.codeblock { - background-color: var(--panel-bg-color); - margin: 0.286em 0.714em; - padding: 0.4em 0.7em; - border-radius: 4px; - position: relative; - - code { - line-height: 1.5; - white-space: pre-wrap; - word-wrap: break-word; - overflow: auto; - overflow: hidden; - background-color: transparent; - } - - .codeblock-actions { - visibility: hidden; - display: flex; - position: absolute; - top: 0; - right: 0; - border-radius: 4px; - backdrop-filter: blur(8px); - margin: 0.143em; - padding: 0.286em; - align-items: center; - justify-content: flex-end; - gap: 0.286em; - } - - &:hover .codeblock-actions { - visibility: visible; - } - } - - code { - color: var(--main-text-color); - font: var(--fixed-font); - font-size: var(--markdown-fixed-font-size); - border-radius: 4px; - } - - pre.selected { - outline: 2px solid var(--accent-color); - } - - .waveblock { - margin: 1.143em 0; - - .wave-block-content { - display: flex; - align-items: center; - padding: 0.857em; - background-color: var(--highlight-bg-color); - border: 1px solid var(--border-color); - border-radius: 8px; - transition: background-color 0.2s ease; - } - - .wave-block-icon { - display: flex; - align-items: center; - justify-content: center; - width: 2.857em; - height: 2.857em; - background-color: black; - border-radius: 8px; - margin-right: 0.857em; - } - - .wave-block-icon i { - font-size: 1.125em; - color: var(--secondary-text-color); - } - - .wave-block-info { - display: flex; - flex-direction: column; - } - - .wave-block-filename { - font-size: 1em; - font-weight: 500; - color: var(--main-text-color); - } - - .wave-block-size { - font-size: 0.857em; - color: var(--secondary-text-color); - } - } - } - - .toc { - max-width: 40%; - height: 100%; - overflow: scroll; - border-left: 1px solid var(--border-color); - .toc-inner { - height: fit-content; - position: sticky; - top: 0; - display: flex; - flex-direction: column; - gap: 0.357em; - text-wrap: wrap; - - h4 { - padding-left: 0.357em; - } - - .toc-item { - cursor: pointer; - --indent-factor: 1; - // The offset in the padding will ensure that when the text in the item wraps, it indents slightly. - // The indent factor is set in the React code and denotes the depth of the item in the TOC tree. - padding-left: calc((var(--indent-factor) - 1) * 0.714em + 0.357em); - text-indent: -0.357em; - } - } - } -} diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx deleted file mode 100644 index 5ecc252876..0000000000 --- a/frontend/app/element/markdown.tsx +++ /dev/null @@ -1,510 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { CopyButton } from "@/app/element/copybutton"; -import { createContentBlockPlugin } from "@/app/element/markdown-contentblock-plugin"; -import { - MarkdownContentBlockType, - resolveRemoteFile, - resolveSrcSet, - transformBlocks, -} from "@/app/element/markdown-util"; -import remarkMermaidToTag from "@/app/element/remark-mermaid-to-tag"; -import { boundNumber, useAtomValueSafe, cn } from "@/util/util"; -import clsx from "clsx"; -import { Atom } from "jotai"; -import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import ReactMarkdown, { Components } from "react-markdown"; -import rehypeHighlight from "rehype-highlight"; -import rehypeRaw from "rehype-raw"; -import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; -import rehypeSlug from "rehype-slug"; -import RemarkFlexibleToc, { TocItem } from "remark-flexible-toc"; -import remarkGfm from "remark-gfm"; -import { openLink } from "../store/global"; -import { IconButton } from "./iconbutton"; -import "./markdown.scss"; - -let mermaidInitialized = false; -let mermaidInstance: any = null; - -const initializeMermaid = async () => { - if (!mermaidInitialized) { - const mermaid = await import("mermaid"); - mermaidInstance = mermaid.default; - mermaidInstance.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); - mermaidInitialized = true; - } -}; - -const Link = ({ - setFocusedHeading, - props, -}: { - props: React.AnchorHTMLAttributes; - setFocusedHeading: (href: string) => void; -}) => { - const onClick = (e: React.MouseEvent) => { - e.preventDefault(); - if (props.href.startsWith("#")) { - setFocusedHeading(props.href); - } else { - openLink(props.href); - } - }; - return ( - - {props.children} - - ); -}; - -const Heading = ({ props, hnum }: { props: React.HTMLAttributes; hnum: number }) => { - return ( -
- {props.children} -
- ); -}; - -const Mermaid = ({ chart }: { chart: string }) => { - const ref = useRef(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const renderMermaid = async () => { - try { - setIsLoading(true); - setError(null); - - await initializeMermaid(); - if (!ref.current || !mermaidInstance) { - return; - } - - // Normalize the chart text - let normalizedChart = chart - .replace(//gi, "\n") // Convert
and
to newlines - .replace(/\r\n?/g, "\n") // Normalize \r \r\n to \n - .replace(/\n+$/, ""); // Remove final newline - - ref.current.removeAttribute("data-processed"); - ref.current.textContent = normalizedChart; - // console.log("mermaid", normalizedChart); - await mermaidInstance.run({ nodes: [ref.current] }); - setIsLoading(false); - } catch (err) { - console.error("Error rendering mermaid diagram:", err); - setError(`Failed to render diagram: ${err.message || err}`); - setIsLoading(false); - } - }; - - renderMermaid(); - }, [chart]); - - useEffect(() => { - if (!ref.current) return; - - if (error) { - ref.current.textContent = `Error: ${error}`; - ref.current.className = "mermaid error"; - } else if (isLoading) { - ref.current.textContent = "Loading diagram..."; - ref.current.className = "mermaid"; - } else { - ref.current.className = "mermaid"; - } - }, [isLoading, error]); - - return
; -}; - -const Code = ({ className = "", children }: { className?: string; children: React.ReactNode }) => { - if (/\blanguage-mermaid\b/.test(className)) { - const text = Array.isArray(children) ? children.join("") : String(children ?? ""); - return ; - } - return {children}; -}; - -type CodeBlockProps = { - children: React.ReactNode; - onClickExecute?: (cmd: string) => void; -}; - -const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => { - const getTextContent = (children: any): string => { - if (typeof children === "string") { - return children; - } else if (Array.isArray(children)) { - return children.map(getTextContent).join(""); - } else if (children.props && children.props.children) { - return getTextContent(children.props.children); - } - return ""; - }; - - const handleCopy = async (e: React.MouseEvent) => { - let textToCopy = getTextContent(children); - textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline - await navigator.clipboard.writeText(textToCopy); - }; - - const handleExecute = (e: React.MouseEvent) => { - let textToCopy = getTextContent(children); - textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline - if (onClickExecute) { - onClickExecute(textToCopy); - return; - } - }; - - return ( -
-            {children}
-            
- - {onClickExecute && ( - - )} -
-
- ); -}; - -const MarkdownSource = ({ - props, - resolveOpts, -}: { - props: React.HTMLAttributes & { - srcSet?: string; - media?: string; - }; - resolveOpts: MarkdownResolveOpts; -}) => { - const [resolvedSrcSet, setResolvedSrcSet] = useState(props.srcSet); - const [resolving, setResolving] = useState(true); - - useEffect(() => { - const resolvePath = async () => { - const resolved = await resolveSrcSet(props.srcSet, resolveOpts); - setResolvedSrcSet(resolved); - setResolving(false); - }; - - resolvePath(); - }, [props.srcSet]); - - if (resolving) { - return null; - } - - return ; -}; - -interface WaveBlockProps { - blockkey: string; - blockmap: Map; -} - -function WaveBlock(props: WaveBlockProps) { - const { blockkey, blockmap } = props; - const block = blockmap.get(blockkey); - if (block == null) { - return null; - } - const sizeInKB = Math.round((block.content.length / 1024) * 10) / 10; - const displayName = block.id.replace(/^"|"$/g, ""); - return ( -
-
-
- -
-
- {displayName} - {sizeInKB} KB -
-
-
- ); -} - -const MarkdownImg = ({ - props, - resolveOpts, -}: { - props: React.ImgHTMLAttributes; - resolveOpts: MarkdownResolveOpts; -}) => { - const [resolvedSrc, setResolvedSrc] = useState(props.src); - const [resolvedSrcSet, setResolvedSrcSet] = useState(props.srcSet); - const [resolvedStr, setResolvedStr] = useState(null); - const [resolving, setResolving] = useState(true); - - useEffect(() => { - if (props.src.startsWith("data:image/")) { - setResolving(false); - setResolvedSrc(props.src); - setResolvedStr(null); - return; - } - if (resolveOpts == null) { - setResolving(false); - setResolvedSrc(null); - setResolvedStr(`[img:${props.src}]`); - return; - } - - const resolveFn = async () => { - const [resolvedSrc, resolvedSrcSet] = await Promise.all([ - resolveRemoteFile(props.src, resolveOpts), - resolveSrcSet(props.srcSet, resolveOpts), - ]); - - setResolvedSrc(resolvedSrc); - setResolvedSrcSet(resolvedSrcSet); - setResolvedStr(null); - setResolving(false); - }; - resolveFn(); - }, [props.src, props.srcSet]); - - if (resolving) { - return null; - } - if (resolvedStr != null) { - return {resolvedStr}; - } - if (resolvedSrc != null) { - return ; - } - return [img]; -}; - -type MarkdownProps = { - text?: string; - textAtom?: Atom | Atom>; - showTocAtom?: Atom; - style?: React.CSSProperties; - className?: string; - contentClassName?: string; - onClickExecute?: (cmd: string) => void; - resolveOpts?: MarkdownResolveOpts; - scrollable?: boolean; - rehype?: boolean; - fontSizeOverride?: number; - fixedFontSizeOverride?: number; -}; - -const Markdown = ({ - text, - textAtom, - showTocAtom, - style, - className, - contentClassName, - resolveOpts, - fontSizeOverride, - fixedFontSizeOverride, - scrollable = true, - rehype = true, - onClickExecute, -}: MarkdownProps) => { - const textAtomValue = useAtomValueSafe(textAtom); - const tocRef = useRef([]); - const showToc = useAtomValueSafe(showTocAtom) ?? false; - const contentsOsRef = useRef(null); - const [focusedHeading, setFocusedHeading] = useState(null); - - // Ensure uniqueness of ids between MD preview instances. - const [idPrefix] = useState(crypto.randomUUID()); - - text = textAtomValue ?? text ?? ""; - const transformedOutput = transformBlocks(text); - const transformedText = transformedOutput.content; - const contentBlocksMap = transformedOutput.blocks; - - useEffect(() => { - if (focusedHeading && contentsOsRef.current && contentsOsRef.current.osInstance()) { - const { viewport } = contentsOsRef.current.osInstance().elements(); - const heading = document.getElementById(idPrefix + focusedHeading.slice(1)); - if (heading) { - const headingBoundingRect = heading.getBoundingClientRect(); - const viewportBoundingRect = viewport.getBoundingClientRect(); - const headingTop = headingBoundingRect.top - viewportBoundingRect.top; - viewport.scrollBy({ top: headingTop }); - } - } - }, [focusedHeading]); - - const markdownComponents: Partial = { - a: (props: React.HTMLAttributes) => ( - - ), - p: (props: React.HTMLAttributes) =>
, - h1: (props: React.HTMLAttributes) => , - h2: (props: React.HTMLAttributes) => , - h3: (props: React.HTMLAttributes) => , - h4: (props: React.HTMLAttributes) => , - h5: (props: React.HTMLAttributes) => , - h6: (props: React.HTMLAttributes) => , - img: (props: React.HTMLAttributes) => , - source: (props: React.HTMLAttributes) => ( - - ), - code: Code, - pre: (props: React.HTMLAttributes) => ( - - ), - }; - markdownComponents["waveblock"] = (props: any) => ; - markdownComponents["mermaidblock"] = (props: any) => { - const getTextContent = (children: any): string => { - if (typeof children === "string") { - return children; - } else if (Array.isArray(children)) { - return children.map(getTextContent).join(""); - } else if (children && typeof children === "object" && children.props && children.props.children) { - return getTextContent(children.props.children); - } - return String(children || ""); - }; - - const chartText = getTextContent(props.children); - return ; - }; - - const toc = useMemo(() => { - if (showToc) { - if (tocRef.current.length > 0) { - return tocRef.current.map((item) => { - return ( - setFocusedHeading(item.href)} - > - {item.value} - - ); - }); - } else { - return ( -
- No sub-headings found -
- ); - } - } - }, [showToc, tocRef]); - - let rehypePlugins = null; - if (rehype) { - rehypePlugins = [ - rehypeRaw, - rehypeHighlight, - () => - rehypeSanitize({ - ...defaultSchema, - attributes: { - ...defaultSchema.attributes, - span: [ - ...(defaultSchema.attributes?.span || []), - // Allow all class names starting with `hljs-`. - ["className", /^hljs-./], - ["srcset"], - ["media"], - ["type"], - // Alternatively, to allow only certain class names: - // ['className', 'hljs-number', 'hljs-title', 'hljs-variable'] - ], - waveblock: [["blockkey"]], - }, - tagNames: [ - ...(defaultSchema.tagNames || []), - "span", - "waveblock", - "picture", - "source", - "mermaidblock", - ], - }), - () => rehypeSlug({ prefix: idPrefix }), - ]; - } - const remarkPlugins: any = [ - remarkMermaidToTag, - remarkGfm, - [RemarkFlexibleToc, { tocRef: tocRef.current }], - [createContentBlockPlugin, { blocks: contentBlocksMap }], - ]; - - const ScrollableMarkdown = () => { - return ( - - - {transformedText} - - - ); - }; - - const NonScrollableMarkdown = () => { - return ( -
- - {transformedText} - -
- ); - }; - - const mergedStyle = { ...style }; - if (fontSizeOverride != null) { - mergedStyle["--markdown-font-size"] = `${boundNumber(fontSizeOverride, 6, 64)}px`; - } - if (fixedFontSizeOverride != null) { - mergedStyle["--markdown-fixed-font-size"] = `${boundNumber(fixedFontSizeOverride, 6, 64)}px`; - } - return ( -
- {scrollable ? : } - {toc && ( - -
-

Table of Contents

- {toc} -
-
- )} -
- ); -}; - -export { Markdown }; diff --git a/frontend/app/element/menubutton.scss b/frontend/app/element/menubutton.scss deleted file mode 100644 index bb938d3bfb..0000000000 --- a/frontend/app/element/menubutton.scss +++ /dev/null @@ -1,15 +0,0 @@ -.menubutton { - overflow: hidden; - .menu-anchor { - width: 100%; - .wave-button { - width: 100%; - div { - max-width: 100%; - text-overflow: ellipsis; - overflow: hidden; - flex-shrink: 1; - } - } - } -} diff --git a/frontend/app/element/menubutton.tsx b/frontend/app/element/menubutton.tsx deleted file mode 100644 index 234d69cc98..0000000000 --- a/frontend/app/element/menubutton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import clsx from "clsx"; -import { memo, useState } from "react"; -import { Button } from "./button"; -import { FlyoutMenu } from "./flyoutmenu"; -import "./menubutton.scss"; - -const MenuButtonComponent = ({ items, className, text, title }: MenuButtonProps) => { - const [isOpen, setIsOpen] = useState(false); - return ( -
- - - -
- ); -}; - -export const MenuButton = memo(MenuButtonComponent) as typeof MenuButtonComponent; diff --git a/frontend/app/element/modal.scss b/frontend/app/element/modal.scss deleted file mode 100644 index 5339d03fb0..0000000000 --- a/frontend/app/element/modal.scss +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.modal-container { - position: absolute; - top: 0; - left: 0; - width: 100vw; - height: 100%; - z-index: var(--zindex-elem-modal); - background-color: rgba(21, 23, 21, 0.7); - - .modal { - display: flex; - flex-direction: column; - border-radius: 10px; - padding: 0; - width: 80%; - margin-top: 25vh; - margin-left: auto; - margin-right: auto; - background: var(--main-bg-color); - border: 1px solid var(--border-color); - - .modal-header { - display: flex; - flex-direction: column; - padding: 20px 20px 10px; - border-bottom: 1px solid var(--border-color); - - .modal-title { - margin: 0 0 5px; - color: var(--main-text-color); - font-size: var(--title-font-size); - } - - p { - margin: 0; - font-size: 0.8rem; - color: var(--secondary-text-color); - } - } - - .modal-content { - padding: 20px; - overflow: auto; - } - - .modal-footer { - display: flex; - flex-direction: row; - justify-content: flex-end; - padding: 15px 20px; - gap: 20px; - } - } -} diff --git a/frontend/app/element/modal.tsx b/frontend/app/element/modal.tsx deleted file mode 100644 index 4655dc2afc..0000000000 --- a/frontend/app/element/modal.tsx +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { Button } from "@/element/button"; -import React from "react"; - -import "./modal.scss"; - -interface ModalProps { - id?: string; - children: React.ReactNode; - onClickOut: () => void; -} - -function Modal({ children, onClickOut, id = "modal", ...otherProps }: ModalProps) { - const handleOutsideClick = (e: React.SyntheticEvent) => { - if (typeof onClickOut === "function" && (e.target as Element).className === "modal-container") { - onClickOut(); - } - }; - - return ( -
- - {children} - -
- ); -} - -interface ModalContentProps { - children: React.ReactNode; -} - -function ModalContent({ children }: ModalContentProps) { - return
{children}
; -} - -interface ModalHeaderProps { - title: React.ReactNode; - description?: string; -} - -function ModalHeader({ title, description }: ModalHeaderProps) { - return ( -
- {typeof title === "string" ?

{title}

: title} - {description &&

{description}

} -
- ); -} - -interface ModalFooterProps { - children: React.ReactNode; -} - -function ModalFooter({ children }: ModalFooterProps) { - return
{children}
; -} - -interface WaveModalProps { - title: string; - description?: string; - id?: string; - onSubmit: () => void; - onCancel: () => void; - buttonLabel?: string; - children: React.ReactNode; -} - -function WaveModal({ title, description, onSubmit, onCancel, buttonLabel = "Ok", children }: WaveModalProps) { - return ( - - - {children} - - - - - ); -} - -export { WaveModal }; diff --git a/frontend/app/element/multilineinput.scss b/frontend/app/element/multilineinput.scss deleted file mode 100644 index 0296eeddca..0000000000 --- a/frontend/app/element/multilineinput.scss +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.multiline-input { - flex-grow: 1; - box-shadow: none; - box-sizing: border-box; - background-color: transparent; - resize: none; - overflow-y: auto; - line-height: 1.5; - color: var(--form-element-text-color); - vertical-align: top; - height: auto; - padding: 0; - border: 1px solid var(--form-element-border-color); - padding: 5px; - border-radius: 4px; - min-height: 26px; - - &:focus-visible { - outline: none; - } -} diff --git a/frontend/app/element/multilineinput.tsx b/frontend/app/element/multilineinput.tsx deleted file mode 100644 index 3530bce1a0..0000000000 --- a/frontend/app/element/multilineinput.tsx +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import clsx from "clsx"; -import React, { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from "react"; - -import "./multilineinput.scss"; - -interface MultiLineInputProps { - value?: string; - className?: string; - onChange?: (e: React.ChangeEvent) => void; - onKeyDown?: (e: React.KeyboardEvent) => void; - onFocus?: () => void; - onBlur?: () => void; - placeholder?: string; - defaultValue?: string; - maxLength?: number; - autoFocus?: boolean; - disabled?: boolean; - rows?: number; - maxRows?: number; - manageFocus?: (isFocused: boolean) => void; -} - -const MultiLineInput = memo( - forwardRef( - ( - { - value, - className, - onChange, - onKeyDown, - onFocus, - onBlur, - placeholder, - defaultValue = "", - maxLength, - autoFocus, - disabled, - rows = 1, - maxRows = 5, - manageFocus, - }: MultiLineInputProps, - ref - ) => { - const textareaRef = useRef(null); - const [internalValue, setInternalValue] = useState(defaultValue); - const [lineHeight, setLineHeight] = useState(24); // Default line height fallback of 24px - const [paddingTop, setPaddingTop] = useState(0); - const [paddingBottom, setPaddingBottom] = useState(0); - - useImperativeHandle(ref, () => textareaRef.current as HTMLTextAreaElement); - - // Function to count the number of lines in the textarea value - const countLines = (text: string) => { - return text.split("\n").length; - }; - - const adjustTextareaHeight = () => { - if (textareaRef.current) { - textareaRef.current.style.height = "auto"; // Reset height to auto first - - const maxHeight = maxRows * lineHeight + paddingTop + paddingBottom; // Max height based on maxRows - const currentLines = countLines(textareaRef.current.value); // Count the number of lines - const newHeight = Math.min(textareaRef.current.scrollHeight, maxHeight); // Calculate new height - - // If the number of lines is less than or equal to maxRows, set height accordingly - const calculatedHeight = - currentLines <= maxRows - ? `${lineHeight * currentLines + paddingTop + paddingBottom}px` - : `${newHeight}px`; - - textareaRef.current.style.height = calculatedHeight; - } - }; - - const handleInputChange = (e: React.ChangeEvent) => { - setInternalValue(e.target.value); - onChange?.(e); - - // Adjust the height of the textarea after text change - adjustTextareaHeight(); - }; - - const handleFocus = () => { - manageFocus?.(true); - onFocus?.(); - }; - - const handleBlur = () => { - manageFocus?.(false); - onBlur?.(); - }; - - useEffect(() => { - if (textareaRef.current) { - const computedStyle = window.getComputedStyle(textareaRef.current); - const detectedLineHeight = parseFloat(computedStyle.lineHeight); - const detectedPaddingTop = parseFloat(computedStyle.paddingTop); - const detectedPaddingBottom = parseFloat(computedStyle.paddingBottom); - - setLineHeight(detectedLineHeight); - setPaddingTop(detectedPaddingTop); - setPaddingBottom(detectedPaddingBottom); - } - }, [textareaRef]); - - useEffect(() => { - adjustTextareaHeight(); - }, [value, maxRows, lineHeight, paddingTop, paddingBottom]); - - const inputValue = value ?? internalValue; - - return ( - +
+
+ ); + } +} + +export { ChatSidebar }; diff --git a/src/app/sidebar/main.less b/src/app/sidebar/main.less new file mode 100644 index 0000000000..8c39738f36 --- /dev/null +++ b/src/app/sidebar/main.less @@ -0,0 +1,175 @@ +@import "@/common/icons/icons.less"; + +.main-sidebar { + padding: 0; + display: flex; + flex-direction: column; + position: relative; + line-height: 20px; + backdrop-filter: blur(4px); + z-index: 20; + font-size: var(--sidebar-font-size); + font-family: var(--base-font-family); + font-weight: var(--sidebar-font-weight); + border-right: 1px solid var(--app-border-color); + + .title-bar-drag { + -webkit-app-region: drag; + height: calc(var(--screentabs-height) + 1px); + border-bottom: 1px solid var(--app-border-color); + position: relative; + display: flex; + align-items: center; + + .logo { + margin-left: auto; + margin-right: auto; + margin-top: 4px; + svg { + width: 88px; + } + } + + .close-button { + -webkit-app-region: no-drag; + position: absolute; + right: 0; + height: 100%; + padding: 10px; + display: flex; + align-items: center; + cursor: pointer; + } + } + + &.collapsed { + display: none; + } + + .contents { + margin-top: 16px; + } + + .separator { + height: 1px; + margin: 16px 0; + background-color: var(--app-border-color); + } + + .item.workspaces-item { + margin-bottom: -4px; + } + + .top { + padding-right: 6px; + } + + .middle { + padding: 4px 6px 8px 6px; + border-bottom: 1px solid var(--app-border-color); + overflow-y: auto; + .item { + .index { + font-size: 10px; + } + .hotkey { + float: left !important; + margin-right: 0 !important; + visibility: hidden; + letter-spacing: 0 !important; + } + } + } + + .bottom { + position: absolute; + bottom: 2rem; + left: 0; + width: 100%; + padding-top: 0.8rem; + padding-right: 6px; + } + + .item { + padding: 5px; + margin-left: 6px; + border-radius: 4px; + opacity: 1; + display: flex; + flex-direction: row; + align-items: center; + + .front-icon { + .positional-icon-visible; + font-size: 15px; + } + + .end-icons { + height: 20px; + line-height: normal; + } + + &.bold { + font-weight: var(--sidebar-highlight-font-weight); + } + &.highlight { + background-color: var(--sidebar-highlight-color); + } + + .item-contents { + flex-grow: 1; + } + .icon { + width: 16px; + height: 16px; + } + .hotkey { + float: right; + margin-right: 6px; + letter-spacing: 6px; + margin-left: auto; + } + &:hover { + :not(.disabled) .hotkey { + .positional-icon-visible; + } + .actions { + .positional-icon-visible; + } + .link-offsite { + .positional-icon-visible; + } + } + + &:not(:hover) .status-indicator { + .status-indicator-visible; + } + + &.workspaces { + cursor: default; + .add-workspace { + cursor: pointer; + .positional-icon-visible; + float: right; + padding: 2px; + margin-right: 6px; + .fa-plus { + font-size: 13px; + } + } + } + + .fa-discord { + font-size: 13px; + } + } + + .update-banner { + font-weight: bold; + + .icon { + font-weight: normal; + font-size: 16px; + } + } +} diff --git a/src/app/sidebar/main.tsx b/src/app/sidebar/main.tsx new file mode 100644 index 0000000000..b1fa2e49f6 --- /dev/null +++ b/src/app/sidebar/main.tsx @@ -0,0 +1,363 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { boundMethod } from "autobind-decorator"; +import { clsx } from "clsx"; +import dayjs from "dayjs"; +import { If } from "tsx-control-statements/components"; + +import { ReactComponent as AppsIcon } from "@/assets/icons/apps.svg"; +import { ReactComponent as WorkspacesIcon } from "@/assets/icons/workspaces.svg"; +import { ReactComponent as SettingsIcon } from "@/assets/icons/settings.svg"; +import { ReactComponent as WaveLogo } from "@/assets/waveterm-logo.svg"; + +import localizedFormat from "dayjs/plugin/localizedFormat"; +import { GlobalModel, GlobalCommandRunner, Session } from "@/models"; +import { isBlank, openLink } from "@/util/util"; +import { ResizableSidebar } from "@/common/elements"; +import * as appconst from "@/app/appconst"; + +import "./main.less"; +import { ActionsIcon, CenteredIcon, FrontIcon, StatusIndicator } from "@/common/icons/icons"; + +import "overlayscrollbars/overlayscrollbars.css"; +import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; + +dayjs.extend(localizedFormat); + +class SideBarItem extends React.Component<{ + frontIcon: React.ReactNode; + contents: React.ReactNode | string; + endIcons?: React.ReactNode[]; + className?: string; + onClick?: React.MouseEventHandler; +}> { + render() { + return ( +
+ {this.props.frontIcon} +
{this.props.contents}
+
{this.props.endIcons}
+
+ ); + } +} + +class HotKeyIcon extends React.Component<{ hotkey: string }> { + render() { + return ( + + ⌘{this.props.hotkey} + + ); + } +} + +interface MainSideBarProps { + parentRef: React.RefObject; +} + +@mobxReact.observer +class MainSideBar extends React.Component { + middleHeightSubtractor = mobx.observable.box(404); + + handleSessionClick(sessionId: string) { + GlobalCommandRunner.switchSession(sessionId); + } + + handleNewSession() { + GlobalCommandRunner.createNewSession(); + } + + handleNewSharedSession() { + GlobalCommandRunner.openSharedSession(); + } + + clickLinks() { + mobx.action(() => { + GlobalModel.showLinks.set(!GlobalModel.showLinks.get()); + })(); + } + + remoteDisplayName(remote: RemoteType): any { + if (!isBlank(remote.remotealias)) { + return ( + <> + {remote.remotealias} + {remote.remotecanonicalname} + + ); + } + return {remote.remotecanonicalname}; + } + + clickRemote(remote: RemoteType) { + GlobalCommandRunner.showRemote(remote.remoteid); + } + + @boundMethod + handlePluginsClick(): void { + if (GlobalModel.activeMainView.get() == "plugins") { + GlobalModel.showSessionView(); + return; + } + GlobalModel.pluginsModel.showPluginsView(); + } + + @boundMethod + handleHistoryClick(): void { + if (GlobalModel.activeMainView.get() == "history") { + GlobalModel.showSessionView(); + return; + } + GlobalModel.historyViewModel.reSearch(); + } + + @boundMethod + handlePlaybookClick(): void { + console.log("playbook click"); + } + + @boundMethod + handleBookmarksClick(): void { + if (GlobalModel.activeMainView.get() == "bookmarks") { + GlobalModel.showSessionView(); + return; + } + GlobalCommandRunner.bookmarksView(); + } + + @boundMethod + handleConnectionsClick(): void { + if (GlobalModel.activeMainView.get() == "connections") { + GlobalModel.showSessionView(); + return; + } + GlobalCommandRunner.connectionsView(); + } + + @boundMethod + handleSettingsClick(): void { + if (GlobalModel.activeMainView.get() == "clientsettings") { + GlobalModel.showSessionView(); + return; + } + GlobalCommandRunner.clientSettingsView(); + } + + @boundMethod + openSessionSettings(e: any, session: Session): void { + e.preventDefault(); + e.stopPropagation(); + mobx.action(() => { + GlobalModel.sessionSettingsModal.set(session.sessionId); + })(); + GlobalModel.modalsModel.pushModal(appconst.SESSION_SETTINGS); + } + + /** + * Get the update banner for the app, if we need to show it. + * @returns Either a banner to install the ready update, a link to the download page, or null if no update is available. + */ + @boundMethod + getUpdateAppBanner(): React.ReactNode { + const status = GlobalModel.appUpdateStatus.get(); + if (status == "ready") { + return ( + } + contents="Click to Install Update" + onClick={() => GlobalModel.installAppUpdate()} + /> + ); + } else { + return null; + } + } + + getSessions() { + if (!GlobalModel.sessionListLoaded.get()) return
loading ...
; + const sessionList: Session[] = []; + const activeSessionId = GlobalModel.activeSessionId.get(); + for (const session of GlobalModel.sessionList) { + if (!session.archived.get() || session.sessionId == activeSessionId) { + sessionList.push(session); + } + } + return sessionList.map((session, index) => { + const isActive = activeSessionId == session.sessionId; + const showHighlight = isActive && GlobalModel.activeMainView.get() == "session"; + const sessionScreens = GlobalModel.getSessionScreens(session.sessionId); + const sessionIndicator = Math.max(...sessionScreens.map((screen) => screen.statusIndicator.get())); + const sessionRunningCommands = sessionScreens.some((screen) => screen.numRunningCmds.get() > 0); + return ( + {index + 1}} + contents={session.name.get()} + endIcons={[ + , + this.openSessionSettings(e, session)} />, + ]} + onClick={() => this.handleSessionClick(session.sessionId)} + /> + ); + }); + } + + /** + * Calculate the subtractor portion for the middle div's height calculation, which should be `100vh - subtractor`. + */ + setMiddleHeightSubtractor() { + const windowHeight = window.innerHeight; + const bottomHeight = windowHeight - window.document.getElementById("sidebar-bottom")?.offsetTop; + const middleTop = document.getElementById("sidebar-middle")?.offsetTop; + const newMiddleHeightSubtractor = bottomHeight + middleTop; + if (!Number.isNaN(newMiddleHeightSubtractor)) { + mobx.action(() => { + this.middleHeightSubtractor.set(newMiddleHeightSubtractor); + })(); + } + } + + componentDidMount() { + this.setMiddleHeightSubtractor(); + } + + componentDidUpdate() { + this.setMiddleHeightSubtractor(); + } + + render() { + const mainView = GlobalModel.activeMainView.get(); + const historyActive = mainView == "history"; + const connectionsActive = mainView == "connections"; + const settingsActive = mainView == "clientsettings"; + return ( + + {(toggleCollapse) => ( + +
+
+ +
+
+ +
+
+
+
+ } + className={clsx({ highlight: historyActive })} + contents="History" + endIcons={[]} + onClick={this.handleHistoryClick} + /> + {/* } contents="Favorites" endIcon={⌘B} onClick={this.handleBookmarksClick}/> */} + } + className={clsx({ highlight: connectionsActive })} + contents="Connections" + onClick={this.handleConnectionsClick} + /> +
+
+ } + contents="Workspaces" + endIcons={[ + + + , + ]} + /> + + {this.getSessions()} + + + +
+ + )} + + ); + } +} + +export { MainSideBar }; diff --git a/src/app/sidebar/right.less b/src/app/sidebar/right.less new file mode 100644 index 0000000000..4666e572e8 --- /dev/null +++ b/src/app/sidebar/right.less @@ -0,0 +1,83 @@ +@import "@/common/icons/icons.less"; + +.right-sidebar { + padding: 0; + display: flex; + flex-direction: column; + position: relative; + line-height: 20px; + backdrop-filter: blur(4px); + z-index: 20; + font-size: var(--sidebar-font-size); + font-family: var(--base-font-family); + font-weight: var(--sidebar-font-weight); + border-left: 1px solid var(--app-border-color); + + .sidebar-content { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + + .header { + height: 39px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 11px; + border-bottom: 1px solid var(--app-border-color); + } + + .rsb-modes { + display: flex; + flex-direction: row; + gap: 5px; + + .icon-container { + padding: 4px; + cursor: pointer; + + span { + margin-left: 5px; + } + } + + .icon-container:hover { + background-color: var(--app-selected-mask-color); + } + } + + &.collapsed { + display: none; + } + + .close { + padding: 10px; + } + + .keybind-debug-pane { + padding: 10px; + overflow: hidden; + width: 100%; + + .keybind-pane-title { + font-size: 18px; + font-weight: bold; + padding-bottom: 5px; + } + + .keybind-level { + margin-top: 10px; + font-weight: bold; + font-size: 16px; + } + + .keybind-domain { + font-size: 14px; + margin-left: 20px; + white-space: nowrap; + overflow: hidden; + } + } + } +} diff --git a/src/app/sidebar/right.tsx b/src/app/sidebar/right.tsx new file mode 100644 index 0000000000..44ce665009 --- /dev/null +++ b/src/app/sidebar/right.tsx @@ -0,0 +1,179 @@ +// Copyright 2023-2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import dayjs from "dayjs"; +import { If, For } from "tsx-control-statements/components"; + +import localizedFormat from "dayjs/plugin/localizedFormat"; +import { GlobalModel } from "@/models"; +import { ResizableSidebar, Button } from "@/elements"; +import { WaveBookDisplay } from "./wavebook"; +import { ChatSidebar } from "./aichat"; +import { boundMethod } from "autobind-decorator"; + +import "./right.less"; + +dayjs.extend(localizedFormat); + +@mobxReact.observer +class KeybindDevPane extends React.Component<{}, {}> { + render() { + let curActiveKeybinds: Array<{ name: string; domains: Array }> = + GlobalModel.keybindManager.getActiveKeybindings(); + let keybindLevel: { name: string; domains: Array } = null; + let domain: string = null; + let curVersion = GlobalModel.keybindManager.getActiveKeybindsVersion().get(); + let levelIdx: number = 0; + let domainIdx: number = 0; + let lastKeyData = GlobalModel.keybindManager.getLastKeyData(); + return ( +
+
Keybind Manager
+ +
+ {keybindLevel.name} +
+ +
+ {domain} +
+
+
+
+
+
+

Last KeyPress Domain: {lastKeyData.domain}

+

Last KeyPress key: {lastKeyData.keyPress}

+
+
+ ); + } +} + +class SidebarKeyBindings extends React.Component<{ component: RightSideBar }, {}> { + componentDidMount(): void { + const { component } = this.props; + const keybindManager = GlobalModel.keybindManager; + keybindManager.registerKeybinding("pane", "rightsidebar", "rightsidebar:toggle", (waveEvent) => { + return component.toggleCollapse(); + }); + } + + componentDidUpdate(): void { + // remove for now (needs to take into account right sidebar focus so it doesn't conflict with other ESC keybindings) + } + + componentWillUnmount(): void { + GlobalModel.keybindManager.unregisterDomain("rightsidebar"); + } + + render() { + return null; + } +} + +@mobxReact.observer +class RightSideBar extends React.Component< + { + parentRef: React.RefObject; + }, + {} +> { + mode: OV = mobx.observable.box("aichat", { name: "RightSideBar-mode" }); + timeoutId: NodeJS.Timeout = null; + + constructor(props) { + super(props); + mobx.makeObservable(this); + } + + componentWillUnmount() { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + } + + @mobx.action.bound + setMode(mode: string) { + if (mode == this.mode.get()) { + return; + } + this.mode.set(mode); + } + + @mobx.action.bound + toggleCollapse() { + const isCollapsed = GlobalModel.rightSidebarModel.getCollapsed(); + GlobalModel.rightSidebarModel.setCollapsed(!isCollapsed); + if (this.mode.get() == "aichat") { + if (isCollapsed) { + this.timeoutId = setTimeout(() => { + GlobalModel.inputModel.setChatSidebarFocus(); + }, 100); + } else { + GlobalModel.inputModel.setChatSidebarFocus(false); + } + } + return true; + } + + render() { + const isCollapsed = GlobalModel.rightSidebarModel.getCollapsed(); + const mode = this.mode.get(); + return ( + + {() => ( + + +
+
+
this.setMode("aichat")} + > + + Wave AI +
+
+ +
this.setMode("keybind")} + > + +
+
+
+ +
+ + + + + + + + + + + )} + + ); + } +} + +export { RightSideBar }; diff --git a/src/app/sidebar/wavebook.tsx b/src/app/sidebar/wavebook.tsx new file mode 100644 index 0000000000..8b4d7ee440 --- /dev/null +++ b/src/app/sidebar/wavebook.tsx @@ -0,0 +1,53 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { If, For } from "tsx-control-statements/components"; +import { GlobalModel } from "@/models"; + +import * as lexical from "lexical"; +import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; +import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { $convertFromMarkdownString, $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown"; +import { CodeNode, CodeHighlightNode } from "@lexical/code"; +import { LinkNode } from "@lexical/link"; +import { ListNode, ListItemNode } from "@lexical/list"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; +import { KeybindManager } from "@/util/keyutil"; + +const theme = { + // Theme styling goes here +}; + +class WaveBookKeybindings extends React.Component<{}, {}> { + componentDidMount(): void { + const keybindManager = GlobalModel.keybindManager; + keybindManager.registerKeybinding("pane", "wavebook", "generic:confirm", (waveEvent) => { + return true; + }); + } + componentWillUnmount(): void { + const keybindManager = GlobalModel.keybindManager; + keybindManager.unregisterDomain("wavebook"); + } + render() { + return null; + } +} + +@mobxReact.observer +class WaveBookDisplay extends React.Component<{}, {}> { + render() { + return "playbooks"; + } +} + +export { WaveBookDisplay }; diff --git a/src/app/workspace/cmdinput/auxview.less b/src/app/workspace/cmdinput/auxview.less new file mode 100644 index 0000000000..b9ab62326c --- /dev/null +++ b/src/app/workspace/cmdinput/auxview.less @@ -0,0 +1,52 @@ +// For the additonal views, we want less padding on the top and bottom than we want for the base-cmdinput div +.auxview { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; + + --auxview-titlebar-height: 18px; + + .auxview-titlebar { + background-color: var(--app-panel-bg-color); + color: var(--term-blue); + padding: 6px 10px 6px 10px; + display: flex; + flex: 0 0 auto; + flex-direction: row; + width: 100%; + border-bottom: 1px solid var(--app-border-color); + font: var(--base-font); + user-select: none; + cursor: default; + line-height: var(--auxview-titlebar-height); + overflow: hidden; + + .title-string { + font-weight: bold; + } + + .close-button { + cursor: pointer; + i { + color: var(--app-icon-color); + + &:hover { + color: var(--app-icon-hover-color); + } + } + } + + div:not(.close-button, .flex-spacer) { + margin-right: 10px; + } + } + + .auxview-content { + display: flex; + flex-flow: column nowrap; + flex: 1 1 auto; + padding: var(--termpad) calc(var(--termpad) * 2) var(--termpad) calc(var(--termpad) * 3 - 1px); + overflow-y: auto; + } +} diff --git a/src/app/workspace/cmdinput/auxview.tsx b/src/app/workspace/cmdinput/auxview.tsx new file mode 100644 index 0000000000..eae3f2d92f --- /dev/null +++ b/src/app/workspace/cmdinput/auxview.tsx @@ -0,0 +1,67 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import { clsx } from "clsx"; +import { Choose, If, Otherwise, When } from "tsx-control-statements/components"; +import { observer } from "mobx-react"; + +import "./auxview.less"; +import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; + +interface AuxiliaryCmdViewProps { + title?: string; + className?: string; + iconClass?: string; + titleBarContents?: React.ReactElement[]; + children?: React.ReactNode; + onClose?: React.MouseEventHandler; + onScrollbarInitialized?: () => void; + scrollable?: boolean; +} + +export const AuxiliaryCmdView: React.FC = observer((props) => { + const { title, className, iconClass, titleBarContents, children, onClose, onScrollbarInitialized } = props; + + return ( +
+ +
+ +
+ +
+
+
{title}
+ + {titleBarContents} + +
+ + +
+ +
+
+
+
+ + + + + {children} + + + +
{children}
+
+
+
+
+ ); +}); diff --git a/src/app/workspace/cmdinput/cmdinput.less b/src/app/workspace/cmdinput/cmdinput.less new file mode 100644 index 0000000000..ad1cfd9e7e --- /dev/null +++ b/src/app/workspace/cmdinput/cmdinput.less @@ -0,0 +1,207 @@ +@import "@/common/icons/icons.less"; + +.cmd-input { + max-height: max(300px, 40%); + display: flex; + flex-direction: column; + width: 100%; + z-index: 100; + border-top: 2px solid var(--app-border-color); + background-color: var(--app-bg-color); + position: relative; + + // Apply a border between the base cmdinput and any views shown above it + // TODO: use a generic selector for this + // &.has-aichat, + &.has-aicmdinfo, + &.has-history, + &.has-info, + &.has-suggestions { + .base-cmdinput { + border-top: 1px solid var(--app-border-color); + } + } + + // &.has-aichat, + &.has-history { + height: max(300px, 70%); + } + + .remote-status-warning { + display: flex; + flex-direction: row; + color: var(--app-warning-color); + align-items: center; + padding: var(--termpad) calc(var(--termpad) * 2) 0 calc(var(--termpad) * 2); + margin-left: 2px; + + .wave-button, + .button { + margin-left: 10px; + padding: 4px 10px; + } + } + + .cmd-input-grow-spacer { + flex-grow: 1; + } + + .base-cmdinput { + position: relative; + // Rather than apply the padding to the whole container, we will apply it to the inner contents directly. + // This is more fragile, but allows us to capture a larger target area for the individual components. + --padding-top: var(--termpad); + --padding-sides: calc(var(--termpad) * 2); + + .cmd-input-context { + color: var(--term-bright-white); + white-space: nowrap; + display: flex; + justify-content: space-between; + align-items: center; + font-family: var(--termfontfamily); + font-size: var(--termfontsize); + line-height: var(--termlineheight); + + // We don't want to pad the bottom or it will push the input field down. + padding: var(--padding-top) var(--padding-sides) 0 var(--padding-sides); + margin-left: 2px; + } + + .cmd-input-field { + position: relative; + font-family: var(--termfontfamily); + font-weight: normal; + line-height: var(--termlineheight); + font-size: var(--termfontsize); + border: none; + cursor: text; + + // We don't want to pad the top or it will push the input field down. + padding: 0 var(--padding-sides) var(--padding-top) var(--padding-sides); + + .cmd-hints { + position: absolute; + bottom: -14px; + right: 0px; + } + .control { + padding: 1em 2px; + } + + .textareainput-div { + position: relative; + + &.control { + padding: var(--termpad) 0; + } + + .shelltag { + position: absolute; + // 13px = 10px height + 3px padding. subtract termpad to account for textareainput-div padding (2px not sure?) + bottom: calc(-13px + var(--termpad)); + right: 0; + font-size: 10px; + color: var(--app-text-secondary-color); + line-height: 1; + user-select: none; + } + } + + .textarea, + .textarea-ghost { + position: absolute; + top: 0; + left: 0; + padding: var(--termpad) 0; + resize: none; + overflow: auto; + overflow-wrap: anywhere; + font-family: var(--termfontfamily); + line-height: var(--termlineheight); + font-size: var(--termfontsize); + background-color: transparent; + border: none; + box-shadow: none; + } + + .textarea { + color: var(--app-text-primary-color); + z-index: 2; + } + + .textarea-ghost { + color: var(--cmdinput-ghost-text-color); + z-index: 1; + } + + input.history-input { + border: 0; + padding: 0; + height: 0; + } + + .cmd-quick-context .button { + background-color: var(--app-bg-color) !important; + color: var(--app-text-color); + } + + &.inputmode-global .cmd-quick-context .button { + color: var(--app-bg-color); + background-color: var(--cmdinput-button-bg-color) !important; + } + + &.inputmode-comment .cmd-quick-context .button { + color: var(--app-bg-color); + background-color: var(--cmdinput-comment-button-bg-color) !important; + } + } + + .cmdinput-actions { + position: absolute; + font-size: calc(var(--termfontsize) + 2px); + line-height: 1.2; + + // Align to the same bounds as the input field + top: var(--padding-top); + right: var(--padding-sides); + + display: flex; + flex-direction: row; + align-items: center; + + .cmdinput-icon { + display: inline-flex; + color: var(--app-icon-hover-color); + opacity: 0.5; + + .centered-icon { + .positional-icon-visible; + } + + &.running-cmds { + .rotate { + fill: var(--app-warning-color); + } + } + + &.active { + opacity: 1; + } + + &:hover { + opacity: 1; + } + + // This aligns the icons with the prompt field. + // We don't need right margin because the whole input field is already padded. + margin: 2px 0 0 12px; + cursor: pointer; + } + + .line-icon + .line-icon:not(.line-icon-shrink-left) { + margin-left: 3px; + } + } + } +} diff --git a/src/app/workspace/cmdinput/cmdinput.tsx b/src/app/workspace/cmdinput/cmdinput.tsx new file mode 100644 index 0000000000..34d0736db5 --- /dev/null +++ b/src/app/workspace/cmdinput/cmdinput.tsx @@ -0,0 +1,310 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { boundMethod } from "autobind-decorator"; +import { Choose, If, When } from "tsx-control-statements/components"; +import { clsx } from "clsx"; +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import { GlobalModel, GlobalCommandRunner, Screen } from "@/models"; +import { Button } from "@/elements"; +import { TextAreaInput } from "./textareainput"; +import { InfoMsg } from "./infomsg"; +import { HistoryInfo } from "./historyinfo"; +import { Prompt } from "@/common/prompt/prompt"; +import { CenteredIcon, RotateIcon } from "@/common/icons/icons"; +import * as util from "@/util/util"; +import * as appconst from "@/app/appconst"; +import { AutocompleteSuggestionView } from "./suggestionview"; + +import "./cmdinput.less"; + +dayjs.extend(localizedFormat); + +@mobxReact.observer +class CmdInput extends React.Component<{}, {}> { + cmdInputRef: React.RefObject = React.createRef(); + promptRef: React.RefObject = React.createRef(); + sbcTimeoutId: NodeJS.Timeout = null; + + constructor(props) { + super(props); + mobx.makeObservable(this); + } + + componentDidMount() { + this.updateCmdInputHeight(); + } + + updateCmdInputHeight() { + const elem = this.cmdInputRef.current; + if (elem == null) { + return; + } + const height = elem.offsetHeight; + if (height == GlobalModel.inputModel.cmdInputHeight) { + return; + } + mobx.action(() => { + GlobalModel.inputModel.cmdInputHeight.set(height); + })(); + } + + componentDidUpdate(): void { + this.updateCmdInputHeight(); + } + + componentWillUnmount() { + if (this.sbcTimeoutId) { + clearTimeout(this.sbcTimeoutId); + this.sbcTimeoutId = null; + } + } + + @boundMethod + handleInnerHeightUpdate(): void { + this.updateCmdInputHeight(); + } + + @mobx.action.bound + clickFocusInputHint(): void { + GlobalModel.inputModel.giveFocus(); + } + + @boundMethod + baseCmdInputClick(e: React.SyntheticEvent): void { + if (this.promptRef.current != null) { + if (this.promptRef.current.contains(e.target)) { + return; + } + } + if ((e.target as HTMLDivElement).classList.contains("cmd-input-context")) { + e.stopPropagation(); + return; + } + GlobalModel.inputModel.setAuxViewFocus(false); + } + + @mobx.action.bound + clickHistoryAction(e: any): void { + e.preventDefault(); + e.stopPropagation(); + + const inputModel = GlobalModel.inputModel; + if (inputModel.getActiveAuxView() === appconst.InputAuxView_History) { + inputModel.resetHistory(); + } else { + inputModel.openHistory(); + } + } + + @mobx.action.bound + clickAIChatAction(e: any): void { + const isCollapsed = GlobalModel.rightSidebarModel.getCollapsed(); + GlobalModel.rightSidebarModel.setCollapsed(!isCollapsed); + if (isCollapsed) { + this.sbcTimeoutId = setTimeout(() => { + GlobalModel.inputModel.setChatSidebarFocus(); + }, 100); + } else { + GlobalModel.inputModel.setChatSidebarFocus(false); + } + } + + @boundMethod + clickConnectRemote(remoteId: string): void { + GlobalCommandRunner.connectRemote(remoteId); + } + + @mobx.action.bound + toggleFilter(screen: Screen) { + screen.filterRunning.set(!screen.filterRunning.get()); + } + + @boundMethod + clickResetState(): void { + GlobalCommandRunner.resetShellState(); + } + + getRemoteDisplayName(rptr: RemotePtrType): string { + if (rptr == null) { + return "(unknown)"; + } + const remote = GlobalModel.getRemote(rptr.remoteid); + if (remote == null) { + return "(invalid)"; + } + let remoteNamePart = ""; + if (!util.isBlank(rptr.name)) { + remoteNamePart = "#" + rptr.name; + } + if (remote.remotealias) { + return remote.remotealias + remoteNamePart; + } + return remote.remotecanonicalname + remoteNamePart; + } + + render() { + const model = GlobalModel; + const inputModel = model.inputModel; + const screen = GlobalModel.getActiveScreen(); + let ri: RemoteInstanceType = null; + let rptr: RemotePtrType = null; + if (screen != null) { + ri = screen.getCurRemoteInstance(); + rptr = screen.curRemote.get(); + } + let remote: RemoteType = null; + let feState: Record = null; + if (ri != null) { + remote = GlobalModel.getRemote(ri.remoteid); + feState = ri.festate; + } + if (remote == null && rptr != null) { + remote = GlobalModel.getRemote(rptr.remoteid); + } + feState = feState || {}; + const focusVal = inputModel.physicalInputFocused.get(); + const inputMode: string = inputModel.inputMode.get(); + const textAreaInputKey = screen == null ? "null" : screen.screenId; + const win = GlobalModel.getScreenLinesById(screen.screenId); + const filterRunning = screen.filterRunning.get(); + let numRunningLines = 0; + if (win != null) { + numRunningLines = mobx.computed(() => win.getRunningCmdLines().length).get(); + } + let shellInitMsg: string = null; + let hidePrompt = false; + + const openView = inputModel.getActiveAuxView(); + const hasOpenView = openView ? `has-${openView}` : null; + if (ri == null) { + let shellStr = "shell"; + if (!util.isBlank(remote?.defaultshelltype)) { + shellStr = remote.defaultshelltype; + } + if (numRunningLines > 0) { + shellInitMsg = `initializing ${shellStr}...`; + } else { + hidePrompt = true; + } + } + + return ( +
+ + +
+ +
+ + + + + + +
+ +
+ WARNING:  + [{GlobalModel.resolveRemoteIdToFullRef(remote.remoteid)}] +  is {remote.status} + + + +
+
+ +
+ The shell state for this tab is invalid ( + + see FAQ + + ). Must reset to continue. + +
+
+ +
+ Shell is not initialized, must reset to continue. + +
+
+
+
+ 0}> +
this.toggleFilter(screen)} + > + {numRunningLines}{" "} + + + +
+
+
+ +
+
+ +
+
+ +
+
+ + + +
+
+
+
+ +
+
{inputMode}
+
+
+ +
+
+
+ ); + } +} + +export { CmdInput }; diff --git a/src/app/workspace/cmdinput/historyinfo.less b/src/app/workspace/cmdinput/historyinfo.less new file mode 100644 index 0000000000..d2c9c4b6a1 --- /dev/null +++ b/src/app/workspace/cmdinput/historyinfo.less @@ -0,0 +1,61 @@ +.cmd-history { + color: var(--app-text-color); + font-family: var(--termfontfamily); + font-size: var(--termfontsize); + + .auxview-titlebar { + .history-opt { + white-space: nowrap; + } + + .history-clickable-opt { + cursor: pointer; + white-space: nowrap; + + &:hover { + color: var(--app-text-primary-color); + } + } + } + + .history-items { + color: var(--app-text-color); + display: flex; + flex-direction: column-reverse; + min-height: 100%; + + .history-item { + cursor: pointer; + border-radius: 5px; + + .history-line { + white-space: pre; + + &:first-child { + margin-left: 0 !important; + } + } + + &:hover { + background-color: var(--table-tr-hover-bg-color); + } + + &.history-haderror { + color: var(--cmdinput-history-item-error-color); + } + + &.is-selected { + font-weight: bold; + color: var(--app-text-primary-color); + background-color: var(--table-tr-selected-bg-color); + &:hover { + background-color: var(--table-tr-selected-hover-bg-color); + } + } + + &.is-selected.history-haderror { + color: var(--cmdinput-history-item-selected-error-color); + } + } + } +} diff --git a/src/app/workspace/cmdinput/historyinfo.tsx b/src/app/workspace/cmdinput/historyinfo.tsx new file mode 100644 index 0000000000..a660b5c50f --- /dev/null +++ b/src/app/workspace/cmdinput/historyinfo.tsx @@ -0,0 +1,285 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { sprintf } from "sprintf-js"; +import { boundMethod } from "autobind-decorator"; +import { If, For } from "tsx-control-statements/components"; +import { clsx } from "clsx"; +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import { GlobalModel } from "@/models"; +import { isBlank } from "@/util/util"; + +import "./historyinfo.less"; +import { AuxiliaryCmdView } from "./auxview"; + +dayjs.extend(localizedFormat); + +const TDots = "⋮"; + +function truncateWithTDots(str: string, maxLen: number): string { + if (str == null) { + return null; + } + if (str.length <= maxLen) { + return str; + } + return str.slice(0, maxLen - 1) + TDots; +} + +@mobxReact.observer +class HItem extends React.Component< + { + hitem: HistoryItem; + isSelected: boolean; + opts: HistoryQueryOpts; + snames: Record; + scrNames: Record; + onClick: (hitem: HistoryItem) => void; + }, + {} +> { + constructor(props) { + super(props); + mobx.makeObservable(this); + } + + renderRemote(hitem: HistoryItem): any { + if (hitem.remote == null || isBlank(hitem.remote.remoteid)) { + return sprintf("%-15s ", ""); + } + const r = GlobalModel.getRemote(hitem.remote.remoteid); + if (r == null) { + return sprintf("%-15s ", "???"); + } + let rname = ""; + if (!isBlank(r.remotealias)) { + rname = r.remotealias; + } else { + rname = r.remotecanonicalname; + } + if (!isBlank(hitem.remote.name)) { + rname = rname + ":" + hitem.remote.name; + } + let rtn = sprintf("%-15s ", "[" + truncateWithTDots(rname, 13) + "]"); + return rtn; + } + + renderHInfoText( + hitem: HistoryItem, + opts: HistoryQueryOpts, + isSelected: boolean, + snames: Record, + scrNames: Record + ): string { + let remoteStr = ""; + if (!opts.limitRemote) { + remoteStr = this.renderRemote(hitem); + } + const selectedStr = isSelected ? "*" : " "; + const lineNumStr = hitem.linenum > 0 ? "(" + hitem.linenum + ")" : ""; + if (isBlank(opts.queryType) || opts.queryType == "screen") { + return selectedStr + sprintf("%7s", lineNumStr) + " " + remoteStr; + } + if (opts.queryType == "session") { + let screenStr = ""; + if (!isBlank(hitem.screenid)) { + const scrName = scrNames[hitem.screenid]; + if (scrName != null) { + screenStr = "[" + truncateWithTDots(scrName, 15) + "]"; + } + } + return selectedStr + sprintf("%17s", screenStr) + sprintf("%7s", lineNumStr) + " " + remoteStr; + } + if (opts.queryType == "global") { + let sessionStr = ""; + if (!isBlank(hitem.sessionid)) { + const sessionName = snames[hitem.sessionid]; + if (sessionName != null) { + sessionStr = "#" + truncateWithTDots(sessionName, 15); + } + } + let screenStr = ""; + if (!isBlank(hitem.screenid)) { + const scrName = scrNames[hitem.screenid]; + if (scrName != null) { + screenStr = "[" + truncateWithTDots(scrName, 13) + "]"; + } + } + return ( + selectedStr + + sprintf("%15s ", sessionStr) + + " " + + sprintf("%15s", screenStr) + + sprintf("%7s", lineNumStr) + + " " + + remoteStr + ); + } + return "-"; + } + + render() { + const { hitem, isSelected, opts, snames, scrNames } = this.props; + const lines = hitem.cmdstr.split("\n"); + let line: string = ""; + let idx = 0; + const infoText = this.renderHInfoText(hitem, opts, isSelected, snames, scrNames); + const infoTextSpacer = sprintf("%" + infoText.length + "s", ""); + return ( +
this.props.onClick(hitem)} + > +
+ {infoText} {lines[0]} +
+ +
+ {infoTextSpacer} {line} +
+
+
+ ); + } +} + +@mobxReact.observer +class HistoryInfo extends React.Component<{}, {}> { + lastClickHNum: string = null; + lastClickTs: number = 0; + containingText: mobx.IObservableValue = mobx.observable.box(""); + + /** + * Handles the OverlayScrollbars initialization event to set the scroll position without it being overridden. + */ + @boundMethod + handleScrollbarInitialized() { + const inputModel = GlobalModel.inputModel; + let hitem = inputModel.getHistorySelectedItem(); + if (hitem == null) { + hitem = inputModel.getFirstHistoryItem(); + } + if (hitem != null) { + inputModel.scrollHistoryItemIntoView(hitem.historynum); + } + } + + @mobx.action.bound + handleClose() { + GlobalModel.inputModel.closeAuxView(); + } + + @mobx.action.bound + handleItemClick(hitem: HistoryItem) { + const inputModel = GlobalModel.inputModel; + const selItem = inputModel.getHistorySelectedItem(); + inputModel.setAuxViewFocus(!inputModel.getAuxViewFocus()); + if (this.lastClickHNum == hitem.historynum && selItem != null && selItem.historynum == hitem.historynum) { + inputModel.grabSelectedHistoryItem(); + return; + } + inputModel.setHistorySelectionNum(hitem.historynum); + const now = Date.now(); + this.lastClickHNum = hitem.historynum; + this.lastClickTs = now; + setTimeout(() => { + if (this.lastClickTs == now) { + this.lastClickHNum = null; + this.lastClickTs = 0; + } + }, 3000); + } + + @mobx.action.bound + handleClickType() { + const inputModel = GlobalModel.inputModel; + inputModel.setAuxViewFocus(true); + inputModel.toggleHistoryType(); + } + + @mobx.action.bound + handleClickRemote() { + const inputModel = GlobalModel.inputModel; + inputModel.setAuxViewFocus(true); + inputModel.toggleRemoteType(); + } + + @boundMethod + getTitleBarContents(): React.ReactElement[] { + const opts = GlobalModel.inputModel.historyQueryOpts.get(); + + return [ +
+ [for {opts.queryType} ⌘S] +
, +
+ [containing '{opts.queryStr}'] +
, +
+ [{opts.limitRemote ? "this" : "any"} remote ⌘R] +
, + ]; + } + + render() { + const inputModel = GlobalModel.inputModel; + const selItem = inputModel.getHistorySelectedItem(); + const hitems = inputModel.filteredHistoryItems; + const opts = inputModel.historyQueryOpts.get(); + let hitem: HistoryItem = null; + let snames: Record = {}; + let scrNames: Record = {}; + if (opts.queryType == "global") { + scrNames = GlobalModel.getScreenNames(); + snames = GlobalModel.getSessionNames(); + } else if (opts.queryType == "session") { + scrNames = GlobalModel.getScreenNames(); + } + return ( + +
+ [no history] + 0}> + + + + +
+
+ ); + } +} + +export { HistoryInfo }; diff --git a/src/app/workspace/cmdinput/infomsg.less b/src/app/workspace/cmdinput/infomsg.less new file mode 100644 index 0000000000..4e08f5b9e2 --- /dev/null +++ b/src/app/workspace/cmdinput/infomsg.less @@ -0,0 +1,49 @@ +.cmd-input-info { + font-family: var(--termfontfamily); + font-size: var(--termfontsize); + line-height: var(--termlineheight); + + .info-msg { + color: var(--term-blue); + padding-bottom: 2px; + + a { + color: var(--term-blue); + } + } + + .info-lines { + color: var(--app-text-color); + white-space: pre; + padding-bottom: 6px; + } + + .info-comps { + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding-bottom: 5px; + font-weight: normal; + font-family: var(--termfontfamily); + font-size: var(--termfontsize); + + .info-comp { + min-width: 200px; + color: var(--term-foreground); + margin-right: 10px; + + &.has-space { + text-decoration: underline dotted #777; + } + } + + .metacmd-comp { + color: var(--term-bright-green); + } + } + + .info-error { + color: var(--cmdinput-text-error-color); + padding-bottom: 2px; + } +} diff --git a/src/app/workspace/cmdinput/infomsg.tsx b/src/app/workspace/cmdinput/infomsg.tsx new file mode 100644 index 0000000000..3cca1851aa --- /dev/null +++ b/src/app/workspace/cmdinput/infomsg.tsx @@ -0,0 +1,122 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { If, For } from "tsx-control-statements/components"; +import { clsx } from "clsx"; +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import { GlobalModel } from "@/models"; +import * as appconst from "@/app/appconst"; +import { AuxiliaryCmdView } from "./auxview"; + +import "./infomsg.less"; + +dayjs.extend(localizedFormat); + +@mobxReact.observer +class InfoMsg extends React.Component<{}, {}> { + constructor(props) { + super(props); + mobx.makeObservable(this); + } + + getAfterSlash(s: string): string { + if (s.startsWith("^/")) { + return s.substring(1); + } + if (s.startsWith("^")) { + return s.substring(1); + } + let slashIdx = s.lastIndexOf("/"); + if (slashIdx == s.length - 1) { + slashIdx = s.lastIndexOf("/", slashIdx - 1); + } + if (slashIdx == -1) { + return s; + } + return s.substring(slashIdx + 1); + } + + hasSpace(s: string): boolean { + return s.indexOf(" ") != -1; + } + + handleCompClick(s: string): void { + // TODO -> complete to this completion + } + + render() { + const inputModel = GlobalModel.inputModel; + const infoMsg: InfoType = inputModel.infoMsg.get(); + const infoShow = inputModel.getActiveAuxView() == appconst.InputAuxView_Info; + let line: string = null; + let istr: string = null; + let idx: number = 0; + let titleStr = null; + if (infoMsg != null) { + titleStr = infoMsg.infotitle; + } + if (!infoShow) { + return null; + } + + return ( + GlobalModel.inputModel.closeAuxView()} + > + +
+ + + + {infoMsg.infomsg} +
+
+ +
+ +
{line == "" ? " " : line}
+
+
+
+ 0}> +
+ +
this.handleCompClick(istr)} + key={idx} + className={clsx( + "info-comp", + { "has-space": this.hasSpace(istr) }, + { "metacmd-comp": istr.startsWith("^") } + )} + > + {this.getAfterSlash(istr)} +
+
+ +
+ ... +
+
+
+
+ +
+ [error] {infoMsg.infoerror} +
+ +
to reset, run: /reset:cwd
+
+
+
+ ); + } +} + +export { InfoMsg }; diff --git a/src/app/workspace/cmdinput/suggestionview.less b/src/app/workspace/cmdinput/suggestionview.less new file mode 100644 index 0000000000..422732ea88 --- /dev/null +++ b/src/app/workspace/cmdinput/suggestionview.less @@ -0,0 +1,25 @@ +.suggestions-view .auxview-content { + display: flex; + flex-direction: column; + min-height: 1em; + .suggestion-item { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + cursor: pointer; + border-radius: 5px; + + &:hover { + background-color: var(--table-tr-hover-bg-color); + } + + &.is-selected { + font-weight: bold; + color: var(--app-text-primary-color); + background-color: var(--table-tr-selected-bg-color); + &:hover { + background-color: var(--table-tr-selected-hover-bg-color); + } + } + } +} diff --git a/src/app/workspace/cmdinput/suggestionview.tsx b/src/app/workspace/cmdinput/suggestionview.tsx new file mode 100644 index 0000000000..3e8caccc07 --- /dev/null +++ b/src/app/workspace/cmdinput/suggestionview.tsx @@ -0,0 +1,87 @@ +import { getAll, getFirst } from "@/autocomplete/runtime/utils"; +import { AuxiliaryCmdView } from "./auxview"; +import { clsx } from "clsx"; +import { action } from "mobx"; +import { observer } from "mobx-react"; +import { GlobalModel } from "@/models"; +import React, { useEffect } from "react"; +import { If } from "tsx-control-statements/components"; + +import "./suggestionview.less"; + +export const AutocompleteSuggestionView: React.FC = observer(() => { + const inputModel = GlobalModel.inputModel; + const autocompleteModel = GlobalModel.autocompleteModel; + const selectedSuggestion = autocompleteModel.getPrimarySuggestionIndex(); + + const updateScroll = action((index: number) => { + autocompleteModel.setPrimarySuggestionIndex(index); + const element = document.getElementsByClassName("suggestion-item")[index] as HTMLElement; + if (element) { + element.scrollIntoView({ block: "nearest" }); + } + }); + + const closeView = action(() => { + inputModel.closeAuxView(); + }); + + const setSuggestion = action((idx: number) => { + autocompleteModel.applySuggestion(idx); + autocompleteModel.loadSuggestions(); + closeView(); + }); + + useEffect(() => { + const keybindManager = GlobalModel.keybindManager; + + keybindManager.registerKeybinding("pane", "autocomplete", "generic:confirm", (waveEvent) => { + setSuggestion(selectedSuggestion); + return true; + }); + keybindManager.registerKeybinding("pane", "autocomplete", "generic:cancel", (waveEvent) => { + closeView(); + return true; + }); + keybindManager.registerKeybinding("pane", "autocomplete", "generic:selectAbove", (waveEvent) => { + updateScroll(Math.max(0, selectedSuggestion - 1)); + return true; + }); + keybindManager.registerKeybinding("pane", "autocomplete", "generic:selectBelow", (waveEvent) => { + updateScroll(Math.min(suggestions?.length - 1, selectedSuggestion + 1)); + return true; + }); + keybindManager.registerKeybinding("pane", "autocomplete", "generic:tab", (waveEvent) => { + updateScroll(Math.min(suggestions?.length - 1, selectedSuggestion + 1)); + return true; + }); + + return () => { + GlobalModel.keybindManager.unregisterDomain("autocomplete"); + }; + }); + + const suggestions: Fig.Suggestion[] = autocompleteModel.getSuggestions(); + + return ( + + +
No suggestions
+
+ {suggestions?.map((suggestion, idx) => ( + + ))} +
+ ); +}); diff --git a/src/app/workspace/cmdinput/textareainput.tsx b/src/app/workspace/cmdinput/textareainput.tsx new file mode 100644 index 0000000000..b3b76be7f9 --- /dev/null +++ b/src/app/workspace/cmdinput/textareainput.tsx @@ -0,0 +1,717 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import * as util from "@/util/util"; +import { If } from "tsx-control-statements/components"; +import { boundMethod } from "autobind-decorator"; +import { clsx } from "clsx"; +import { GlobalModel, GlobalCommandRunner, Screen } from "@/models"; +import { getMonoFontSize } from "@/util/textmeasure"; +import * as appconst from "@/app/appconst"; + +type OV = mobx.IObservableValue; +const MaxInputLength = 10 * 1024; + +function pageSize(div: any): number { + if (div == null) { + return 300; + } + let size = div.clientHeight; + if (size > 500) { + size = size - 100; + } else if (size > 200) { + size = size - 30; + } + return size; +} + +function scrollDiv(div: any, amt: number) { + if (div == null) { + return; + } + let newScrollTop = div.scrollTop + amt; + if (newScrollTop < 0) { + newScrollTop = 0; + } + div.scrollTo({ top: newScrollTop, behavior: "smooth" }); +} + +class HistoryKeybindings extends React.Component<{}, {}> { + componentDidMount(): void { + if (GlobalModel.activeMainView != "session") { + return; + } + const inputModel = GlobalModel.inputModel; + const keybindManager = GlobalModel.keybindManager; + keybindManager.registerKeybinding("pane", "history", "generic:cancel", (waveEvent) => { + inputModel.resetHistory(); + return true; + }); + keybindManager.registerKeybinding("pane", "history", "generic:confirm", (waveEvent) => { + inputModel.grabSelectedHistoryItem(); + return true; + }); + keybindManager.registerKeybinding("pane", "history", "history:closeHistory", (waveEvent) => { + inputModel.resetInput(); + return true; + }); + keybindManager.registerKeybinding("pane", "history", "history:toggleShowRemotes", (waveEvent) => { + inputModel.toggleRemoteType(); + return true; + }); + keybindManager.registerKeybinding("pane", "history", "history:changeScope", (waveEvent) => { + inputModel.toggleHistoryType(); + return true; + }); + keybindManager.registerKeybinding("pane", "history", "generic:selectAbove", (waveEvent) => { + inputModel.moveHistorySelection(1); + return true; + }); + keybindManager.registerKeybinding("pane", "history", "generic:selectBelow", (waveEvent) => { + inputModel.moveHistorySelection(-1); + return true; + }); + keybindManager.registerKeybinding("pane", "history", "generic:selectPageAbove", (waveEvent) => { + inputModel.moveHistorySelection(10); + return true; + }); + keybindManager.registerKeybinding("pane", "history", "generic:selectPageBelow", (waveEvent) => { + inputModel.moveHistorySelection(-10); + return true; + }); + keybindManager.registerKeybinding("pane", "history", "history:selectPreviousItem", (waveEvent) => { + inputModel.moveHistorySelection(1); + return true; + }); + keybindManager.registerKeybinding("pane", "history", "history:selectNextItem", (waveEvent) => { + inputModel.moveHistorySelection(-1); + return true; + }); + } + + componentWillUnmount(): void { + GlobalModel.keybindManager.unregisterDomain("history"); + } + + render() { + return null; + } +} + +class CmdInputKeybindings extends React.Component<{ inputObject: TextAreaInput }, {}> { + curPress: string; + lastTab: boolean; + + componentDidMount() { + if (GlobalModel.activeMainView != "session") { + return; + } + const inputObject = this.props.inputObject; + const keybindManager = GlobalModel.keybindManager; + const inputModel = GlobalModel.inputModel; + keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:autocomplete", (waveEvent) => { + this.curPress = "tab"; + // For now, we want to preserve the old behavior if autocomplete is disabled + if (GlobalModel.autocompleteModel.isEnabled) { + if (this.lastTab) { + const curLine = inputModel.curLine; + if (curLine != "") { + inputModel.setActiveAuxView(appconst.InputAuxView_Suggestions); + } + } else { + this.lastTab = true; + } + } else { + const lastTab = this.lastTab; + this.lastTab = true; + this.curPress = "tab"; + const curLine = inputModel.curLine; + if (lastTab) { + GlobalModel.submitCommand( + "_compgen", + null, + [curLine], + { comppos: String(curLine.length), compshow: "1", nohist: "1" }, + true + ); + } else { + GlobalModel.submitCommand( + "_compgen", + null, + [curLine], + { comppos: String(curLine.length), nohist: "1" }, + true + ); + } + return true; + } + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "generic:confirm", (waveEvent) => { + GlobalModel.closeTabSettings(); + if (GlobalModel.inputModel.isEmpty()) { + const activeWindow = GlobalModel.getScreenLinesForActiveScreen(); + const activeScreen = GlobalModel.getActiveScreen(); + if (activeScreen != null && activeWindow != null && activeWindow.lines.length > 0) { + activeScreen.setSelectedLine(0); + GlobalCommandRunner.screenSelectLine("E"); + } + } else { + setTimeout(() => GlobalModel.inputModel.uiSubmitCommand(), 0); + } + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "generic:cancel", (waveEvent) => { + GlobalModel.closeTabSettings(); + inputModel.closeAuxView(); + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:expandInput", (waveEvent) => { + inputModel.toggleExpandInput(); + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:clearInput", (waveEvent) => { + inputModel.resetInput(); + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:cutLineLeftOfCursor", (waveEvent) => { + inputObject.controlU(); + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:cutWordLeftOfCursor", (waveEvent) => { + inputObject.controlW(); + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:paste", (waveEvent) => { + inputObject.controlY(); + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:openHistory", (waveEvent) => { + inputModel.openHistory(); + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:previousHistoryItem", (waveEvent) => { + this.curPress = "historyupdown"; + inputObject.controlP(); + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:nextHistoryItem", (waveEvent) => { + this.curPress = "historyupdown"; + inputObject.controlN(); + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:openAIChat", (waveEvent) => { + inputModel.openAIAssistantChat(); + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "generic:selectAbove", (waveEvent) => { + this.curPress = "historyupdown"; + const rtn = inputObject.arrowUpPressed(); + return rtn; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "generic:selectBelow", (waveEvent) => { + this.curPress = "historyupdown"; + const rtn = inputObject.arrowDownPressed(); + return rtn; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "generic:selectRight", (waveEvent) => { + return inputObject.arrowRightPressed(); + }); + keybindManager.registerKeybinding("pane", "cmdinput", "generic:selectPageAbove", (waveEvent) => { + this.curPress = "historyupdown"; + inputObject.scrollPage(true); + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "generic:selectPageBelow", (waveEvent) => { + this.curPress = "historyupdown"; + inputObject.scrollPage(false); + return true; + }); + keybindManager.registerKeybinding("pane", "cmdinput", "generic:expandTextInput", (waveEvent) => { + inputObject.modEnter(); + return true; + }); + keybindManager.registerDomainCallback("cmdinput", (waveEvent) => { + if (this.curPress != "tab") { + this.lastTab = false; + } + if (this.curPress != "historyupdown") { + inputObject.lastHistoryUpDown = false; + } + this.curPress = ""; + return false; + }); + } + + componentWillUnmount() { + GlobalModel.keybindManager.unregisterDomain("cmdinput"); + } + + render() { + return null; + } +} + +@mobxReact.observer +class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () => void }, {}> { + lastHistoryUpDown: boolean = false; + lastFocusType: string = null; + mainInputRef: React.RefObject = React.createRef(); + historyInputRef: React.RefObject = React.createRef(); + controlRef: React.RefObject = React.createRef(); + lastHeight: number = 0; + lastSP: StrWithPos = { str: "", pos: appconst.NoStrPos }; + version: OV = mobx.observable.box(0, { name: "textAreaInput-version" }); // forces render updates + + constructor(props) { + super(props); + mobx.makeObservable(this); + } + + @mobx.action.bound + incVersion(): void { + const v = this.version.get(); + this.version.set(v + 1); + } + + getCurSP(): StrWithPos { + const textarea = this.mainInputRef.current; + if (textarea == null) { + return this.lastSP; + } + const str = textarea.value; + const pos = textarea.selectionStart; + const endPos = textarea.selectionEnd; + if (pos != endPos) { + return { str, pos: appconst.NoStrPos }; + } + return { str, pos }; + } + + updateSP(): void { + const curSP = this.getCurSP(); + if (curSP.str == this.lastSP.str && curSP.pos == this.lastSP.pos) { + return; + } + this.lastSP = curSP; + GlobalModel.sendCmdInputText(this.props.screen.screenId, curSP); + } + + @mobx.action + setFocus(): void { + GlobalModel.inputModel.giveFocus(); + } + + getTextAreaMaxCols(): number { + const taElem = this.mainInputRef.current; + if (taElem == null) { + return 0; + } + const cs = window.getComputedStyle(taElem); + const padding = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight); + const borders = parseFloat(cs.borderLeft) + parseFloat(cs.borderRight); + const contentWidth = taElem.clientWidth - padding - borders; + const fontSize = getMonoFontSize(parseInt(cs.fontSize)); + const maxCols = Math.floor(contentWidth / Math.ceil(fontSize.width)); + return maxCols; + } + + checkHeight(shouldFire: boolean): void { + const elem = this.controlRef.current; + if (elem == null) { + return; + } + const curHeight = elem.offsetHeight; + if (this.lastHeight == curHeight) { + return; + } + this.lastHeight = curHeight; + if (shouldFire && this.props.onHeightChange != null) { + this.props.onHeightChange(); + } + } + + @mobx.action.bound + handleComponentDidMount() { + const activeScreen = GlobalModel.getActiveScreen(); + if (activeScreen != null) { + const focusType = activeScreen.focusType.get(); + if (focusType == "input") { + this.setFocus(); + } + this.lastFocusType = focusType; + } + this.checkHeight(false); + this.updateSP(); + } + + componentDidMount() { + this.handleComponentDidMount(); + this.updateCursorPosIfForced(); + } + + updateCursorPosIfForced() { + const inputModel = GlobalModel.inputModel; + const fcpos = inputModel.forceCursorPos.get(); + if (fcpos != null && fcpos != appconst.NoStrPos) { + if (this.mainInputRef.current != null) { + this.mainInputRef.current.selectionStart = fcpos; + this.mainInputRef.current.selectionEnd = fcpos; + } + inputModel.forceCursorPos.set(null); + } + } + + @mobx.action + componentDidUpdate() { + const activeScreen = GlobalModel.getActiveScreen(); + if (activeScreen != null) { + const focusType = activeScreen.focusType.get(); + if (this.lastFocusType != focusType && focusType == "input") { + this.setFocus(); + } + this.lastFocusType = focusType; + } + const inputModel = GlobalModel.inputModel; + this.updateCursorPosIfForced(); + if (inputModel.forceInputFocus) { + inputModel.forceInputFocus = false; + this.setFocus(); + } + this.checkHeight(true); + this.updateSP(); + } + + getLinePos(elem: any): { numLines: number; linePos: number } { + const numLines = elem.value.split("\n").length; + const linePos = elem.value.substr(0, elem.selectionStart).split("\n").length; + return { numLines, linePos }; + } + + arrowUpPressed(): boolean { + console.log("arrowUpPressed"); + const inputModel = GlobalModel.inputModel; + if (!inputModel.isHistoryLoaded()) { + this.lastHistoryUpDown = true; + inputModel.loadHistory(false, 1, "screen"); + return true; + } + const currentRef = this.mainInputRef.current; + if (currentRef == null) { + return true; + } + const linePos = this.getLinePos(currentRef); + const lastHist = this.lastHistoryUpDown; + if (!lastHist && linePos.linePos > 1) { + // regular arrow + return false; + } + inputModel.moveHistorySelection(1); + this.lastHistoryUpDown = true; + return true; + } + + arrowDownPressed(): boolean { + const inputModel = GlobalModel.inputModel; + if (!inputModel.isHistoryLoaded()) { + return true; + } + const currentRef = this.mainInputRef.current; + if (currentRef == null) { + return true; + } + const linePos = this.getLinePos(currentRef); + const lastHist = this.lastHistoryUpDown; + if (!lastHist && linePos.linePos < linePos.numLines) { + // regular arrow + return false; + } + inputModel.moveHistorySelection(-1); + this.lastHistoryUpDown = true; + return true; + } + + @boundMethod + arrowRightPressed(): boolean { + // If the cursor is at the end of the line, apply the primary suggestion + const curSP = this.getCurSP(); + if (curSP.pos < curSP.str.length) { + return false; + } + GlobalModel.autocompleteModel.applyPrimarySuggestion(); + return true; + } + + scrollPage(up: boolean) { + const inputModel = GlobalModel.inputModel; + const infoScroll = inputModel.hasScrollingInfoMsg(); + if (infoScroll) { + const div = document.querySelector(".cmd-input-info"); + const amt = pageSize(div); + scrollDiv(div, up ? -amt : amt); + } + } + + modEnter() { + const currentRef = this.mainInputRef.current; + if (currentRef == null) { + return; + } + currentRef.setRangeText("\n", currentRef.selectionStart, currentRef.selectionEnd, "end"); + GlobalModel.inputModel.curLine = currentRef.value; + } + + @boundMethod + onKeyDown(e: any) {} + + @mobx.action.bound + onChange(e: any) { + GlobalModel.inputModel.curLine = e.target.value; + } + + @boundMethod + onSelect(e: any) { + this.incVersion(); + } + + @boundMethod + onHistoryKeyDown(e: any) {} + + @boundMethod + controlU() { + if (this.mainInputRef.current == null) { + return; + } + const selStart = this.mainInputRef.current.selectionStart; + const value = this.mainInputRef.current.value; + if (selStart > value.length) { + return; + } + const cutValue = value.substring(0, selStart); + const restValue = value.substring(selStart); + const cmdLineUpdate = { str: restValue, pos: 0 }; + navigator.clipboard.writeText(cutValue); + GlobalModel.inputModel.updateCmdLine(cmdLineUpdate); + } + + @mobx.action.bound + controlP() { + const inputModel = GlobalModel.inputModel; + if (!inputModel.isHistoryLoaded()) { + this.lastHistoryUpDown = true; + inputModel.loadHistory(false, 1, "screen"); + return; + } + inputModel.moveHistorySelection(1); + this.lastHistoryUpDown = true; + } + + @mobx.action.bound + controlN() { + const inputModel = GlobalModel.inputModel; + inputModel.moveHistorySelection(-1); + this.lastHistoryUpDown = true; + } + + @boundMethod + controlW() { + if (this.mainInputRef.current == null) { + return; + } + const selStart = this.mainInputRef.current.selectionStart; + const value = this.mainInputRef.current.value; + if (selStart > value.length) { + return; + } + let cutSpot = selStart - 1; + let initial = true; + for (; cutSpot >= 0; cutSpot--) { + const ch = value[cutSpot]; + if (ch == " " && initial) { + continue; + } + initial = false; + if (ch == " ") { + cutSpot++; + break; + } + } + if (cutSpot == -1) { + cutSpot = 0; + } + const cutValue = value.slice(cutSpot, selStart); + const prevValue = value.slice(0, cutSpot); + const restValue = value.slice(selStart); + const cmdLineUpdate = { str: prevValue + restValue, pos: prevValue.length }; + navigator.clipboard.writeText(cutValue); + GlobalModel.inputModel.updateCmdLine(cmdLineUpdate); + } + + @boundMethod + controlY() { + if (this.mainInputRef.current == null) { + return; + } + const pastePromise = navigator.clipboard.readText(); + pastePromise.then((clipText) => { + clipText = clipText ?? ""; + const selStart = this.mainInputRef.current.selectionStart; + const selEnd = this.mainInputRef.current.selectionEnd; + const value = this.mainInputRef.current.value; + if (selStart > value.length || selEnd > value.length) { + return; + } + const newValue = value.substring(0, selStart) + clipText + value.substring(selEnd); + const cmdLineUpdate = { str: newValue, pos: selStart + clipText.length }; + GlobalModel.inputModel.updateCmdLine(cmdLineUpdate); + }); + } + + @mobx.action.bound + handleHistoryInput(e: any) { + const inputModel = GlobalModel.inputModel; + const opts = mobx.toJS(inputModel.historyQueryOpts.get()); + opts.queryStr = e.target.value; + inputModel.setHistoryQueryOpts(opts); + } + + @mobx.action.bound + handleFocus(e: any) { + e.preventDefault(); + GlobalModel.inputModel.giveFocus(); + } + + @boundMethod + handleMainBlur(e: any) { + if (document.activeElement == this.mainInputRef.current) { + return; + } + GlobalModel.inputModel.setPhysicalInputFocused(false); + } + + @boundMethod + handleHistoryBlur(e: any) { + if (document.activeElement == this.historyInputRef.current) { + return; + } + GlobalModel.inputModel.setPhysicalInputFocused(false); + } + + render() { + const model = GlobalModel; + const inputModel = model.inputModel; + const curLine = inputModel.curLine; + let displayLines = 1; + const numLines = curLine.split("\n").length; + const maxCols = this.getTextAreaMaxCols(); + let longLine = false; + if (maxCols != 0 && curLine.length >= maxCols - 4) { + longLine = true; + } + if (numLines > 1 || longLine || inputModel.inputExpanded.get()) { + displayLines = 5; + } + + const auxViewFocused = inputModel.getAuxViewFocus(); + if (auxViewFocused) { + displayLines = 1; + } + const activeScreen = GlobalModel.getActiveScreen(); + if (activeScreen != null) { + activeScreen.focusType.get(); // for reaction + } + const termFontSize = GlobalModel.getTermFontSize(); + const fontSize = getMonoFontSize(termFontSize); + const termPad = fontSize.pad; + const computedInnerHeight = displayLines * fontSize.height + 2 * termPad; + const computedOuterHeight = computedInnerHeight + 2 * termPad; + let shellType: string = ""; + const screen = GlobalModel.getActiveScreen(); + if (screen != null) { + const ri = screen.getCurRemoteInstance(); + if (ri?.shelltype != null) { + shellType = ri.shelltype; + } + if (shellType == "") { + const rptr = screen.curRemote.get(); + if (rptr != null) { + const remote = GlobalModel.getRemote(rptr.remoteid); + if (remote != null) { + shellType = remote.defaultshelltype; + } + } + } + } + const renderCmdInputKeybindings = + inputModel.shouldRenderAuxViewKeybindings(null) || + inputModel.shouldRenderAuxViewKeybindings(appconst.InputAuxView_Info); + const renderHistoryKeybindings = inputModel.shouldRenderAuxViewKeybindings(appconst.InputAuxView_History); + + // Will be null if the feature is disabled + const primaryAutocompleteSuggestion = GlobalModel.autocompleteModel.getPrimarySuggestionCompletion(); + + return ( +
+ + + + + + + + +
{shellType}
+
+ +
+ {`${"\xa0".repeat(curLine.length)}${primaryAutocompleteSuggestion}`} +
+
+ + +
+ ); + } +} + +export { TextAreaInput }; diff --git a/src/app/workspace/screen/newtabsettings.tsx b/src/app/workspace/screen/newtabsettings.tsx new file mode 100644 index 0000000000..4b8fb3dae3 --- /dev/null +++ b/src/app/workspace/screen/newtabsettings.tsx @@ -0,0 +1,195 @@ +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { boundMethod } from "autobind-decorator"; +import { If, For } from "tsx-control-statements/components"; +import { clsx } from "clsx"; +import { GlobalCommandRunner, GlobalModel, Screen } from "@/models"; +import { TextField, Dropdown } from "@/elements"; +import { getRemoteStrWithAlias } from "@/common/prompt/prompt"; +import * as util from "@/util/util"; +import { TabIcon } from "@/elements/tabicon"; +import { ReactComponent as GlobeIcon } from "@/assets/icons/globe.svg"; +import { ReactComponent as StatusCircleIcon } from "@/assets/icons/statuscircle.svg"; +import * as appconst from "@/app/appconst"; + +import "./screenview.less"; +import "./tabs.less"; + +@mobxReact.observer +class TabNameTextField extends React.Component<{ screen: Screen; errorMessage?: OV }, {}> { + @boundMethod + updateName(val: string): void { + let { screen } = this.props; + if (util.isStrEq(val, screen.name.get())) { + return; + } + let prtn = GlobalCommandRunner.screenSetSettings(screen.screenId, { name: val }, false); + util.commandRtnHandler(prtn, this.props.errorMessage); + } + + render() { + let { screen } = this.props; + return ( + + ); + } +} + +@mobxReact.observer +class TabColorSelector extends React.Component<{ screen: Screen; errorMessage?: OV }, {}> { + @boundMethod + selectTabColor(color: string): void { + let { screen } = this.props; + if (screen.getTabColor() == color) { + return; + } + let prtn = GlobalCommandRunner.screenSetSettings(screen.screenId, { tabcolor: color }, false); + util.commandRtnHandler(prtn, this.props.errorMessage); + } + + render() { + let { screen } = this.props; + let curColor = screen.getTabColor(); + if (util.isBlank(curColor) || curColor == "default") { + curColor = "green"; + } + let color: string | null = null; + return ( +
+
+ +
{screen.getTabColor()}
+
+
|
+ +
this.selectTabColor(color)}> + +
+
+
+ ); + } +} + +@mobxReact.observer +class TabIconSelector extends React.Component<{ screen: Screen; errorMessage?: OV }, {}> { + @boundMethod + selectTabIcon(icon: string): void { + let { screen } = this.props; + if (screen.getTabIcon() == icon) { + return; + } + let prtn = GlobalCommandRunner.screenSetSettings(screen.screenId, { tabicon: icon }, false); + util.commandRtnHandler(prtn, this.props.errorMessage); + } + + render() { + let { screen } = this.props; + let curIcon = screen.getTabIcon(); + if (util.isBlank(curIcon) || curIcon == "default") { + curIcon = "square"; + } + let icon: string | null = null; + let curColor = screen.getTabColor(); + return ( +
+
+ +
{screen.getTabIcon()}
+
+
|
+ +
this.selectTabIcon(icon)}> + +
+
+
+ ); + } +} + +@mobxReact.observer +class TabRemoteSelector extends React.Component<{ screen: Screen; errorMessage?: OV }, {}> { + selectedRemoteCN: OV = mobx.observable.box(null, { name: "TabRemoteSelector-selectedRemoteCN" }); + + @boundMethod + selectRemote(cname: string): void { + if (cname == null) { + GlobalModel.remotesModel.openAddModal({ remoteedit: true }); + return; + } + mobx.action(() => { + this.selectedRemoteCN.set(cname); + })(); + let prtn = GlobalCommandRunner.screenSetRemote(cname, true, true); + util.commandRtnHandler(prtn, this.props.errorMessage); + prtn.then((crtn) => { + GlobalModel.inputModel.giveFocus(); + }); + } + + @boundMethod + getOptions(): DropdownItem[] { + const remotes = GlobalModel.remotes; + const options = remotes + .filter((r) => !r.archived) + .map((remote) => ({ + ...remote, + label: getRemoteStrWithAlias(remote), + value: remote.remotecanonicalname, + })) + .sort((a, b) => { + let connValA = util.getRemoteConnVal(a); + let connValB = util.getRemoteConnVal(b); + if (connValA !== connValB) { + return connValA - connValB; + } + return a.remoteidx - b.remoteidx; + }); + + options.push({ + label: "New Connection", + value: null, + icon: , + noop: true, + }); + + return options; + } + + render() { + const { screen } = this.props; + let selectedRemote = this.selectedRemoteCN.get(); + if (selectedRemote == null) { + const curRI = screen.getCurRemoteInstance(); + if (curRI != null) { + const curRemote = GlobalModel.getRemote(curRI.remoteid); + selectedRemote = curRemote.remotecanonicalname; + } else { + const localRemote = GlobalModel.getLocalRemote(); + selectedRemote = localRemote.remotecanonicalname; + } + } + let curRemote = GlobalModel.getRemoteByName(selectedRemote); + return ( + + + +
+ ), + }} + /> + ); + } +} + +export { TabColorSelector, TabIconSelector, TabNameTextField, TabRemoteSelector }; diff --git a/src/app/workspace/screen/screenview.less b/src/app/workspace/screen/screenview.less new file mode 100644 index 0000000000..5fc4849f17 --- /dev/null +++ b/src/app/workspace/screen/screenview.less @@ -0,0 +1,161 @@ +.main-content { + .screen-view { + flex-grow: 1; + position: relative; + border-top: 1px solid var(--app-border-color); + } + + .screen-sidebar, + .window-view { + transition: width 0.5s ease-in-out; + } + + .screen-sidebar { + position: absolute; + top: 0; + right: 0; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + overflow-y: auto; + border-left: 1px solid var(--app-border-color); + + .sidebar-header { + /* sidebar-header height linked to MagicLayout.ScreenSidebarHeaderHeight */ + display: flex; + flex-direction: row; + padding: 3px 0; + margin: 0; + border-bottom: 1px solid var(--app-border-color); + font-size: var(--termfontsize); + font-family: var(--termfontfamily); + font-weight: normal; + line-height: var(--termlineheight); + color: var(--screen-view-text-caption-color); + + &:hover { + color: var(--app-icon-hover-color); + i { + color: var(--app-icon-hover-color); + } + } + + div.pane-name { + visibility: hidden; + margin-left: calc(var(--termpad) * 2); + } + + &:hover div.pane-name { + visibility: visible; + } + + i { + padding: 3px; + } + } + + .screen-sidebar-close { + margin-top: 10px; + } + + .close-button-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 20px; + margin-bottom: 10px; + } + + .screen-sidebar-section { + &:last-child { + padding-bottom: 0; + } + } + + .empty-sidebar { + align-self: center; + margin-top: 20%; + + .sidebar-help-text { + margin-top: 20px; + padding: 5px 10px; + background-color: #333; + border-radius: 5px; + font-family: var(--fixed-font); + } + } + } + + .window-view { + display: flex; + flex-direction: column; + position: absolute; + height: 100%; + overflow-x: hidden; + + .window-empty { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 10px; + height: 100%; + color: var(--app-text-color); + + code { + background-color: transparent; + color: var(--term-green); + } + + &.should-fade { + opacity: 1; + animation: fade-in 2.5s; + } + } + + .filter-running { + position: relative; + display: flex; + flex-direction: row; + width: 100%; + border-top: 1px solid var(--app-border-color); + padding: calc(var(--termpad) + 2px) var(--termpad) calc(var(--termpad) + 2px) var(--termpad); + align-items: center; + justify-content: center; + + .filter-content { + cursor: pointer; + padding: 2px; + color: var(--app-text-primary-color); + z-index: 2; + } + + .filter-mask { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--app-accent-bg-color); + z-index: 1; + pointer-events: none; + } + } + } + + .screen-settings-inline { + padding: 2em; + .settings-field { + display: block; + padding: 1.5em 1em; + margin-top: 0; + line-height: 2.5em; + border-top: 1px solid var(--app-border); + &:first-child { + border-top: none; + } + } + } +} diff --git a/src/app/workspace/screen/screenview.tsx b/src/app/workspace/screen/screenview.tsx new file mode 100644 index 0000000000..163cbb8d20 --- /dev/null +++ b/src/app/workspace/screen/screenview.tsx @@ -0,0 +1,594 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { sprintf } from "sprintf-js"; +import { boundMethod } from "autobind-decorator"; +import { If } from "tsx-control-statements/components"; +import { clsx } from "clsx"; +import { debounce } from "throttle-debounce"; +import dayjs from "dayjs"; +import { GlobalCommandRunner, ForwardLineContainer, GlobalModel, ScreenLines, Screen, Session } from "@/models"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import { Button } from "@/elements"; +import { Line } from "@/app/line/linecomps"; +import { LinesView } from "@/app/line/linesview"; +import * as util from "@/util/util"; +import * as appconst from "@/app/appconst"; +import * as textmeasure from "@/util/textmeasure"; + +import "./screenview.less"; +import "./tabs.less"; +import { MagicLayout } from "../../magiclayout"; + +dayjs.extend(localizedFormat); + +@mobxReact.observer +class ScreenView extends React.Component<{ session: Session; screen: Screen }, {}> { + rszObs: ResizeObserver; + screenViewRef: React.RefObject = React.createRef(); + width: OV = mobx.observable.box(null, { name: "screenview-width" }); + handleResize_debounced: () => void; + sidebarShowing: OV = mobx.observable.box(false, { name: "screenview-sidebarShowing" }); + sidebarShowingTimeoutId: any = null; + + constructor(props: { session: Session; screen: Screen }) { + super(props); + this.handleResize_debounced = debounce(100, this.handleResize.bind(this)); + const screen = this.props.screen; + let hasSidebar = false; + if (screen != null) { + const viewOpts = screen.viewOpts.get(); + hasSidebar = viewOpts?.sidebar?.open; + } + this.sidebarShowing = mobx.observable.box(hasSidebar, { name: "screenview-sidebarShowing" }); + } + + componentDidMount(): void { + const elem = this.screenViewRef.current; + if (elem != null) { + this.rszObs = new ResizeObserver(this.handleResize_debounced); + this.rszObs.observe(elem); + this.handleResize(); + } + } + + componentDidUpdate(): void { + const { screen } = this.props; + if (screen == null) { + return; + } + const viewOpts = screen.viewOpts.get(); + const hasSidebar = viewOpts?.sidebar?.open; + if (hasSidebar && !this.sidebarShowing.get()) { + this.sidebarShowingTimeoutId = setTimeout(() => { + mobx.action(() => { + this.sidebarShowingTimeoutId = null; + this.sidebarShowing.set(true); + })(); + }, 500); + } else if (!hasSidebar) { + if (this.sidebarShowingTimeoutId != null) { + clearTimeout(this.sidebarShowingTimeoutId); + this.sidebarShowingTimeoutId = null; + } + mobx.action(() => this.sidebarShowing.set(false))(); + } + } + + componentWillUnmount(): void { + if (this.rszObs != null) { + this.rszObs.disconnect(); + } + } + + handleResize() { + const elem = this.screenViewRef.current; + if (elem == null) { + return; + } + mobx.action(() => { + this.width.set(elem.offsetWidth); + })(); + } + + @boundMethod + createWorkspace() { + GlobalCommandRunner.createNewSession(); + } + + @boundMethod + createTab() { + GlobalCommandRunner.createNewScreen(); + } + + render() { + const { session, screen } = this.props; + const screenWidth = this.width.get(); + if (screenWidth == null) { + return
; + } + if (session == null) { + const sessionCount = GlobalModel.sessionList.length; + return ( +
+
+
+
+
+ [no workspace] + + + +
+
+
+
+ ); + } + if (screen == null) { + const screens = GlobalModel.getSessionScreens(session.sessionId); + return ( +
+
+
+
+
+ [no active tab] + + + +
+
+
+
+ ); + } + const fontSize = GlobalModel.getTermFontSize(); + const dprStr = sprintf("%0.3f", GlobalModel.devicePixelRatio.get()); + const viewOpts = screen.viewOpts.get(); + const hasSidebar = viewOpts?.sidebar?.open; + let winWidth = "100%"; + let sidebarWidth = "0px"; + if (hasSidebar) { + const targetWidth = viewOpts?.sidebar?.width; + let realWidth = 0; + if (util.isBlank(targetWidth) || screenWidth < MagicLayout.ScreenSidebarMinWidth * 2) { + realWidth = Math.floor(screenWidth / 2) - MagicLayout.ScreenSidebarWidthPadding; + } else if (targetWidth.indexOf("%") != -1) { + let targetPercent = parseInt(targetWidth); + if (targetPercent > 100) { + targetPercent = 100; + } + realWidth = Math.floor((screenWidth * targetPercent) / 100); + realWidth = util.boundInt( + realWidth, + MagicLayout.ScreenSidebarMinWidth, + screenWidth - MagicLayout.ScreenSidebarMinWidth + ); + } else { + // screen is at least 400px wide + const targetWidthNum = parseInt(targetWidth); + realWidth = util.boundInt( + targetWidthNum, + MagicLayout.ScreenSidebarMinWidth, + screenWidth - MagicLayout.ScreenSidebarMinWidth + ); + } + winWidth = screenWidth - realWidth + "px"; + sidebarWidth = realWidth - MagicLayout.ScreenSidebarWidthPadding + "px"; + } + const termRenderVersion = GlobalModel.termRenderVersion.get(); + + return ( +
+ + + + +
+ ); + } +} + +type SidebarLineContainerPropsType = { + screen: Screen; + winSize: WindowSize; + lineId: string; +}; + +// note a new SidebarLineContainer will be made for every lineId (so lineId prop should never change) +// implemented using a 'key' in parent +@mobxReact.observer +class SidebarLineContainer extends React.Component { + container: ForwardLineContainer; + overrideCollapsed: OV = mobx.observable.box(false, { name: "overrideCollapsed" }); + visible: OV = mobx.observable.box(true, { name: "visible" }); + ready: OV = mobx.observable.box(false, { name: "ready" }); + + componentDidMount(): void { + let { screen, winSize, lineId } = this.props; + // TODO this is a hack for now to make the timing work out. + setTimeout(() => { + mobx.action(() => { + this.container = new ForwardLineContainer(screen, winSize, appconst.LineContainer_Sidebar, lineId); + this.ready.set(true); + })(); + }, 100); + } + + @boundMethod + handleHeightChange() {} + + componentDidUpdate(prevProps: SidebarLineContainerPropsType): void { + let prevWinSize = prevProps.winSize; + let winSize = this.props.winSize; + if (prevWinSize.width != winSize.width || prevWinSize.height != winSize.height) { + if (this.container != null) { + this.container.screenSizeCallback(mobx.toJS(winSize)); + } + } + } + + render() { + if (!this.ready.get() || this.container == null) { + return null; + } + let { screen, winSize, lineId } = this.props; + let line = screen.getLineById(lineId); + if (line == null) { + return null; + } + return ( + + ); + } +} + +@mobxReact.observer +class ScreenSidebar extends React.Component<{ screen: Screen; width: string }, {}> { + rszObs: ResizeObserver; + sidebarSize: OV = mobx.observable.box({ height: 0, width: 0 }, { name: "sidebarSize" }); + sidebarRef: React.RefObject = React.createRef(); + handleResize_debounced: (entries: ResizeObserverEntry[]) => void; + + constructor(props: any) { + super(props); + this.handleResize_debounced = debounce(100, this.handleResize.bind(this)); + } + + componentDidMount(): void { + let { screen } = this.props; + let sidebarElem = this.sidebarRef.current; + if (sidebarElem != null) { + this.rszObs = new ResizeObserver(this.handleResize_debounced); + this.rszObs.observe(sidebarElem); + this.handleResize([]); + } + let size = this.sidebarSize.get(); + } + + componentWillUnmount(): void { + if (this.rszObs != null) { + this.rszObs.disconnect(); + } + } + + @boundMethod + handleResize(entries: ResizeObserverEntry[]): void { + // dont use entries (just use the ref) -- we call it with an empty array in componentDidMount to initialize it + let sidebarElem = this.sidebarRef.current; + if (sidebarElem == null) { + return; + } + let size = { + width: sidebarElem.offsetWidth, + height: + sidebarElem.offsetHeight - + textmeasure.calcMaxLineChromeHeight(GlobalModel.lineHeightEnv) - + MagicLayout.ScreenSidebarHeaderHeight, + }; + mobx.action(() => this.sidebarSize.set(size))(); + } + + @boundMethod + sidebarClose(): void { + GlobalCommandRunner.screenSidebarClose(); + } + + @boundMethod + sidebarOpenHalf(): void { + GlobalCommandRunner.screenSidebarOpen("50%"); + } + + @boundMethod + sidebarOpenPartial(): void { + GlobalCommandRunner.screenSidebarOpen("500px"); + } + + getSidebarConfig(): ScreenSidebarOptsType { + let { screen } = this.props; + let viewOpts = screen.viewOpts.get(); + return viewOpts?.sidebar; + } + + render() { + let { screen, width } = this.props; + let sidebarSize = this.sidebarSize.get(); + let sidebar = this.getSidebarConfig(); + let lineId = sidebar?.sidebarlineid; + let sidebarOk = sidebarSize != null && sidebarSize.width > 0 && !util.isBlank(sidebar?.sidebarlineid); + return ( +
+
+
sidebar
+
+
+ +
+
+ +
+
+ +
+
+ +
+
No Sidebar Line Selected
+
+ /sidebar:open [width=[50%|500px]] +
+ /sidebar:close +
+ /sidebar:add line=[linenum] +
+
+
+ +
+
+
+ + + +
+ ); + } +} + +interface ScreenWindowViewProps { + session: Session; + screen: Screen; + width: string; +} + +// screen is not null +@mobxReact.observer +class ScreenWindowView extends React.Component { + rszObs: ResizeObserver; + windowViewRef: React.RefObject; + + width: mobx.IObservableValue = mobx.observable.box(0, { name: "sw-view-width" }); + height: mobx.IObservableValue = mobx.observable.box(0, { name: "sw-view-height" }); + setSize_debounced: (width: number, height: number) => void; + + renderMode: OV = mobx.observable.box("normal", { name: "renderMode" }); + shareCopied: OV = mobx.observable.box(false, { name: "sw-shareCopied" }); + + constructor(props: any) { + super(props); + this.setSize_debounced = debounce(1000, this.setSize.bind(this)); + this.windowViewRef = React.createRef(); + } + + setSize(width: number, height: number): void { + const { screen } = this.props; + if (screen == null) { + return; + } + if (width == null || height == null || width == 0 || height == 0) { + return; + } + mobx.action(() => { + this.width.set(width); + this.height.set(height); + screen.screenSizeCallback({ height: height, width: width }); + })(); + } + + componentDidMount() { + const { screen } = this.props; + const wvElem = this.windowViewRef.current; + if (wvElem != null) { + const width = wvElem.offsetWidth; + const height = wvElem.offsetHeight; + this.setSize(width, height); + this.rszObs = new ResizeObserver(this.handleResize.bind(this)); + this.rszObs.observe(wvElem); + } + if (screen.isNew) { + screen.isNew = false; + mobx.action(() => { + GlobalModel.tabSettingsOpen.set(true); + })(); + } + } + + componentWillUnmount() { + if (this.rszObs) { + this.rszObs.disconnect(); + } + } + + handleResize(entries: any) { + if (entries.length == 0) { + return; + } + const entry = entries[0]; + const width = entry.target.offsetWidth; + const height = entry.target.offsetHeight; + mobx.action(() => { + this.setSize_debounced(width, height); + })(); + } + + getScreenLines(): ScreenLines { + const { screen } = this.props; + let win = GlobalModel.getScreenLinesById(screen.screenId); + if (win == null) { + win = GlobalModel.loadScreenLines(screen.screenId); + } + return win; + } + + @boundMethod + toggleRenderMode() { + const renderMode = this.renderMode.get(); + mobx.action(() => { + this.renderMode.set(renderMode == "normal" ? "collapsed" : "normal"); + })(); + } + + renderError(message: string, fade: boolean) { + const { screen, width } = this.props; + return ( +
+
+
+
{message}
+
+
+ ); + } + + @boundMethod + copyShareLink(): void { + const { screen } = this.props; + const shareLink = screen.getWebShareUrl(); + if (shareLink == null) { + return; + } + navigator.clipboard.writeText(shareLink); + mobx.action(() => { + this.shareCopied.set(true); + })(); + setTimeout(() => { + mobx.action(() => { + this.shareCopied.set(false); + })(); + }, 600); + } + + @boundMethod + openScreenSettings(): void { + const { screen } = this.props; + mobx.action(() => { + GlobalModel.screenSettingsModal.set({ sessionId: screen.sessionId, screenId: screen.screenId }); + })(); + } + + @boundMethod + buildLineComponent(lineProps: LineFactoryProps): React.JSX.Element { + const { screen } = this.props; + const { line, ...restProps } = lineProps; + const realLine: LineType = line as LineType; + return ; + } + + determineVisibleLines(win: ScreenLines): LineType[] { + const { screen } = this.props; + if (screen.filterRunning.get()) { + return win.getRunningCmdLines(); + } + return win.getNonArchivedLines(); + } + + @boundMethod + disableFilter() { + const { screen } = this.props; + mobx.action(() => { + screen.filterRunning.set(false); + })(); + } + + render() { + const { session, screen, width } = this.props; + const win = this.getScreenLines(); + if (!win.loaded.get()) { + return this.renderError("...", true); + } + if (win.loadError.get() != null) { + return this.renderError(sprintf("(%s)", win.loadError.get()), false); + } + if (this.width.get() == 0) { + return this.renderError("", false); + } + const cdata = GlobalModel.clientData.get(); + if (cdata == null) { + return this.renderError("loading client data", true); + } + const lines = this.determineVisibleLines(win); + const renderMode = this.renderMode.get(); + return ( +
+ +
+
+
+
+ + [workspace="{session.name.get()}" tab="{screen.name.get()}"] + +
+
+
+
+ 0}> + + + +
+
+
+ Showing Running Commands   + +
+
+ +
+ ); + } +} + +export { ScreenView }; diff --git a/src/app/workspace/screen/tab.tsx b/src/app/workspace/screen/tab.tsx new file mode 100644 index 0000000000..de62ef60c3 --- /dev/null +++ b/src/app/workspace/screen/tab.tsx @@ -0,0 +1,163 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { boundMethod } from "autobind-decorator"; +import { clsx } from "clsx"; +import { GlobalModel, GlobalCommandRunner, Screen } from "@/models"; +import { ActionsIcon, StatusIndicator, CenteredIcon } from "@/common/icons/icons"; +import * as constants from "@/app/appconst"; +import { Reorder } from "framer-motion"; +import { MagicLayout } from "@/app/magiclayout"; +import { TabIcon } from "@/elements/tabicon"; +import * as appconst from "@/app/appconst"; + +@mobxReact.observer +class ScreenTab extends React.Component< + { screen: Screen; activeScreenId: string; index: number; onSwitchScreen: (screenId: string) => void }, + {} +> { + tabRef = React.createRef(); + dragEndTimeout = null; + scrollIntoViewTimeout = null; + theme: string; + themeReactionDisposer: mobx.IReactionDisposer; + + componentWillUnmount() { + if (this.scrollIntoViewTimeout) { + clearTimeout(this.dragEndTimeout); + } + if (this.themeReactionDisposer) { + this.themeReactionDisposer(); + } + } + + @boundMethod + handleDragEnd() { + if (this.dragEndTimeout) { + clearTimeout(this.dragEndTimeout); + } + + // Wait for the animation to complete + this.dragEndTimeout = setTimeout(() => { + const tabElement = this.tabRef.current; + if (tabElement) { + const finalTabPosition = tabElement.offsetLeft; + + // Calculate the new index based on the final position + const newIndex = Math.floor(finalTabPosition / MagicLayout.TabWidth); + + GlobalCommandRunner.screenReorder(this.props.screen.screenId, `${newIndex + 1}`); + } + }, 100); + } + + @boundMethod + openScreenSettings(e: any, screen: Screen): void { + e.preventDefault(); + e.stopPropagation(); + mobx.action(() => { + GlobalModel.tabSettingsOpen.set(!GlobalModel.tabSettingsOpen.get()); + })(); + } + + @boundMethod + onContextMenu(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + let { screen, activeScreenId } = this.props; + if (activeScreenId != screen.screenId) { + // only show context menu for active tab + GlobalCommandRunner.switchScreen(screen.screenId); + return; + } + let colorSubMenu: ContextMenuItem[] = []; + for (let color of appconst.TabColors) { + colorSubMenu.push({ + label: color, + click: () => { + GlobalCommandRunner.screenSetSettings(screen.screenId, { tabcolor: color }, false); + }, + }); + } + let menu: ContextMenuItem[] = [ + { + label: "New Tab", + click: () => { + GlobalCommandRunner.createNewScreen(); + }, + }, + { + type: "separator", + }, + { + label: "Set Tab Color", + submenu: colorSubMenu, + }, + { + label: "All Tab Settings", + click: () => { + GlobalModel.tabSettingsOpen.set(true); + }, + }, + { + type: "separator", + }, + { + label: "Close Tab", + click: () => { + GlobalModel.onCloseCurrentTab(); + }, + }, + ]; + GlobalModel.contextMenuModel.showContextMenu(menu, { x: e.clientX, y: e.clientY }); + return; + } + + render() { + let { screen, activeScreenId, index, onSwitchScreen } = this.props; + let archived = screen.archived.get() ? ( + + ) : null; + + const statusIndicatorLevel = screen.statusIndicator.get(); + const runningCommands = screen.numRunningCmds.get() > 0; + + return ( + onSwitchScreen(screen.screenId)} + onContextMenu={this.onContextMenu} + onDragEnd={this.handleDragEnd} + > +
+
+ + + +
+ {archived} + {screen.name.get()} +
+
+ + this.openScreenSettings(e, screen)} /> +
+
+
+
+ ); + } +} + +export { ScreenTab }; diff --git a/src/app/workspace/screen/tabs.less b/src/app/workspace/screen/tabs.less new file mode 100644 index 0000000000..865141b2c9 --- /dev/null +++ b/src/app/workspace/screen/tabs.less @@ -0,0 +1,220 @@ +@import "@/common/icons/icons.less"; + +// Theming values +#main .screen-tabs .screen-tab { + font: var(--base-font); + font-size: var(--screentabs-font-size); + line-height: var(--screentabs-line-height); + + &.is-active { + .background { + border-top: 1px solid var(--tab-color); + background-color: var(--tab-color); + } + } + + svg.svg-icon-inner path { + fill: var(--tab-color); + } + + .tabicon i { + color: var(--tab-color); + } + + &.color-green, + &.color-default { + --tab-color: var(--tab-green); + } + + &.color-orange { + --tab-color: var(--tab-orange); + } + + &.color-red { + --tab-color: var(--tab-red); + } + + &.color-yellow { + --tab-color: var(--tab-yellow); + } + + &.color-blue { + --tab-color: var(--tab-blue); + } + + &.color-mint { + --tab-color: var(--tab-mint); + } + + &.color-cyan { + --tab-color: var(--tab-cyan); + } + + &.color-white { + --tab-color: var(--tab-white); + } + + &.color-violet { + --tab-color: var(--tab-violet); + } + + &.color-pink { + --tab-color: var(--tab-pink); + } +} + +// Layout values +#main .screen-tabs-container { + display: flex; + position: relative; + overflow: hidden; + height: var(--screentabs-height); + + &:hover { + z-index: 200; + } + + &:hover .cmd-hints { + display: flex; + } + + .cmd-hints { + position: absolute; + bottom: -18px; + left: 0px; + display: flex; + } + + .screen-tabs-container-inner { + overflow-x: scroll; + overflow-y: hidden; + } + + .screen-tabs { + display: flex; + flex-direction: row; + height: 100%; + .screen-tab { + display: flex; + flex-direction: row; + position: relative; + border-top: 2px solid transparent; + background: var(--app-bg-color); + + .background { + // This applies a transparency mask to the background color, as set above, so that it will blend with whatever the theme's background color is. + z-index: 1; + width: var(--screen-tab-width); + mask-image: linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0) 100%); + } + + &.is-active { + opacity: 1; + font-weight: var(--screentabs-selected-font-weight); + border-top: 2px solid var(--tab-color); + + .screen-tab-inner { + cursor: default; + } + } + + &:not(.is-active) .status-indicator { + .status-indicator-visible; + } + + &.is-active:not(:hover) .status-indicator { + .status-indicator-visible; + } + + &.is-active:hover .actions { + .positional-icon-visible; + + &:hover { + background-color: var(--app-selected-mask-color); + border-radius: 4px; + } + } + + &.is-archived { + .fa.fa-archive { + margin-right: 4px; + } + } + + .screen-tab-inner { + display: flex; + flex-direction: row; + position: absolute; + z-index: 2; + min-width: var(--screen-tab-width); + max-width: var(--screen-tab-width); + align-items: center; + cursor: pointer; + padding: 8px 8px 4px 8px; // extra 4px of tab padding to account for horizontal scrollbar (to make tab text look centered) + .front-icon { + .positional-icon-visible; + } + + .tab-name { + flex-grow: 1; + } + + // Only one of these will be visible at a time + .end-icons { + // This adjusts the position of the icon to account for the default 8px margin on the parent. We want the positional calculations for this icon to assume it is flush with the edge of the screen tab. + margin: 0 -5px 0 0; + line-height: normal; + .tab-index { + font-size: 12.5px; + } + } + } + + .vertical-line { + border-left: 1px solid var(--app-border-color); + margin: 10px 0 8px 0; + } + } + } + + .new-screen { + flex-shrink: 0; + cursor: pointer; + display: flex; + align-items: center; + height: 100%; + + .icon { + height: 2rem; + border-radius: 50%; + padding: 0.4em; + vertical-align: middle; + } + } + + .tabs-end-spacer { + flex-grow: 1; + min-width: 30px; + -webkit-app-region: drag; + height: 100%; + } +} + +// This ensures the tab bar does not collide with the floating logo. The floating logo sits above the sidebar when it is not collapsed, so no additional margin is needed in that case. +// More margin is given on macOS to account for the traffic light buttons +#main.platform-darwin.mainsidebar-collapsed .screen-tabs-container { + margin-left: var(--floating-logo-width-darwin); +} + +#main:not(.platform-darwin).mainsidebar-collapsed .screen-tabs-container { + margin-left: var(--floating-logo-width); +} + +// This ensures the tab bar does not collide with the right sidebar triggers. +#main.platform-darwin.rightsidebar-collapsed .screen-tabs-container { + margin-right: var(--floating-right-sidebar-triggers-width-darwin); +} + +#main:not(.platform-darwin).rightsidebar-collapsed .screen-tabs-container { + margin-left: var(--floating-right-sidebar-triggers-width); +} diff --git a/src/app/workspace/screen/tabs.tsx b/src/app/workspace/screen/tabs.tsx new file mode 100644 index 0000000000..5ebfda2613 --- /dev/null +++ b/src/app/workspace/screen/tabs.tsx @@ -0,0 +1,217 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { sprintf } from "sprintf-js"; +import { boundMethod } from "autobind-decorator"; +import { For, If } from "tsx-control-statements/components"; +import { clsx } from "clsx"; +import { GlobalModel, GlobalCommandRunner, Session, Screen } from "@/models"; +import { ReactComponent as AddIcon } from "@/assets/icons/add.svg"; +import { Reorder } from "framer-motion"; +import { ScreenTab } from "./tab"; + +import "./tabs.less"; + +@mobxReact.observer +class ScreenTabs extends React.Component< + { session: Session }, + { showingScreens: Screen[]; scrollIntoViewTimeout: number } +> { + tabsRef: React.RefObject = React.createRef(); + lastActiveScreenId: string = null; + dragEndTimeout = null; + scrollIntoViewTimeoutId = null; + deltaYHistory = []; + disposeScreensReaction = null; + + constructor(props: any) { + super(props); + this.state = { + showingScreens: [], + scrollIntoViewTimeout: 0, + }; + } + + componentDidMount(): void { + // handle initial scrollIntoView + this.componentDidUpdate(); + + // populate showingScreens state + this.setState({ showingScreens: this.getScreens() }); + // Update showingScreens state when the screens change + this.disposeScreensReaction = mobx.reaction( + () => this.getScreens(), + (screens) => { + // Different timeout for when screens are added vs removed + let timeout = 100; + if (screens.length < this.state.showingScreens.length) { + timeout = 400; + } + this.setState({ showingScreens: screens, scrollIntoViewTimeout: timeout }); + } + ); + + // Add the wheel event listener to the tabsRef + if (this.tabsRef.current) { + this.tabsRef.current.addEventListener("wheel", this.handleWheel, { passive: false }); + } + } + + componentWillUnmount() { + if (this.dragEndTimeout) { + clearTimeout(this.dragEndTimeout); + } + + if (this.disposeScreensReaction) { + this.disposeScreensReaction(); // Clean up the reaction + } + } + + componentDidUpdate(): void { + // Scroll the active screen into view + let activeScreenId = this.getActiveScreenId(); + if (activeScreenId !== this.lastActiveScreenId) { + if (this.scrollIntoViewTimeoutId) { + clearTimeout(this.scrollIntoViewTimeoutId); + } + this.lastActiveScreenId = activeScreenId; + this.scrollIntoViewTimeoutId = setTimeout(() => { + if (!this.tabsRef.current) { + return; + } + let tabElem = this.tabsRef.current.querySelector( + sprintf('.screen-tab[data-screenid="%s"]', activeScreenId) + ); + if (!tabElem) { + return; + } + tabElem.scrollIntoView(); + }, this.state.scrollIntoViewTimeout); + } + } + + @boundMethod + getActiveScreenId(): string { + let { session } = this.props; + if (session) { + return session.activeScreenId.get(); + } + return null; + } + + @mobx.computed + @boundMethod + getScreens(): Screen[] { + let activeScreenId = this.getActiveScreenId(); + if (!activeScreenId) { + return []; + } + + let screens = GlobalModel.getSessionScreens(this.props.session.sessionId); + let showingScreens = []; + + for (const screen of screens) { + if (!screen.archived.get() || activeScreenId === screen.screenId) { + showingScreens.push(screen); + } + } + + showingScreens.sort((a, b) => a.screenIdx.get() - b.screenIdx.get()); + + return showingScreens; + } + + @boundMethod + handleNewScreen() { + GlobalCommandRunner.createNewScreen(); + } + + @boundMethod + handleSwitchScreen(screenId: string) { + let { session } = this.props; + if (session == null) { + return; + } + if (session.activeScreenId.get() == screenId) { + return; + } + let screen = session.getScreenById(screenId); + if (screen == null) { + return; + } + GlobalCommandRunner.switchScreen(screenId); + } + + @boundMethod + handleWheel(event: WheelEvent) { + if (!this.tabsRef.current) return; + + // Add the current deltaY to the history + this.deltaYHistory.push(Math.abs(event.deltaY)); + if (this.deltaYHistory.length > 5) { + this.deltaYHistory.shift(); // Keep only the last 5 entries + } + + // Check if any of the last 5 deltaY values are greater than a threshold + let isMouseWheel = this.deltaYHistory.some((deltaY) => deltaY > 0); + + if (isMouseWheel) { + // It's likely a mouse wheel event, so handle it for horizontal scrolling + this.tabsRef.current.scrollLeft += event.deltaY; + + // Prevent default vertical scroll + event.preventDefault(); + } + // For touchpad events, do nothing and let the browser handle it + } + + render() { + let { showingScreens } = this.state; + let { session } = this.props; + if (session == null) { + return null; + } + let screen: Screen | null = null; + let index = 0; + let activeScreenId = this.getActiveScreenId(); + return ( +
+ {/* Inner container ensures that hovering over the scrollbar doesn't trigger the hover effect on the tabs. This prevents weird flickering of the icons when the mouse is moved over the scrollbar. */} +
+ { + this.setState({ showingScreens: tabs }); + }} + values={showingScreens} + > + + + + +
+
+ +
+
+
+ ); + } +} + +export { ScreenTabs }; diff --git a/src/app/workspace/workspace.less b/src/app/workspace/workspace.less new file mode 100644 index 0000000000..122f2cb7bf --- /dev/null +++ b/src/app/workspace/workspace.less @@ -0,0 +1,194 @@ +.session-view { + overflow: hidden; + position: relative; + + &:before { + content: " "; + display: block; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + opacity: var(--tab-bg-image-opacity, 0.5); + background-image: var(--tab-bg-image-url, none); + background-size: cover; + } + + .center-message { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + color: var(--app-text-secondary-color); + } + + .tab-settings-pulldown { + position: absolute; + top: var(--screentabs-height); + width: 100%; + transition: height 0.2s ease-in-out; + overflow: hidden; + z-index: 11; + border-bottom: 3px solid var(--app-border-color); + background-color: var(--app-panel-bg-color); + border-radius: 0 0 5px 5px; + + &.closed { + height: 0; + border-bottom: none; + } + + .close-button { + position: absolute; + right: 10px; + top: 10px; + cursor: pointer; + padding: 5px; + border-radius: 4px; + &:hover { + background-color: var(--app-selected-mask-color); + } + } + } +} + +.newtab-container { + margin: 8px 16px 0 16px; + + .newtab-section { + display: flex; + padding: 10px 16px; + flex-direction: column; + align-items: flex-start; + gap: 8px; + align-self: stretch; + + .truncate { + max-width: 100%; + } + + &.conn-section { + gap: 8px; + } + } + + .cr-help-text { + color: var(--screen-view-text-caption-color); + margin-left: 5px; + } + + .newtab-spacer { + height: 1px; + background: var(--app-border-color); + } + + .control-iconlist { + display: flex; + margin-left: -2px; + padding: 8px 0 8px 2px; + align-items: flex-start; + gap: 14px; + + &.tabicon-list { + gap: 12px; + } + + .icondiv { + width: 20px; + height: 20px; + cursor: pointer; + position: relative; + font-size: 14px; + + &.tabicon { + display: flex; + align-items: center; + width: 22px; + } + + .icon { + width: 20px; + height: 20px; + } + + i { + padding-left: 3px; + padding-right: 3px; + } + + .icon.square-icon { + position: relative; + top: 3px; + width: 16px; + height: 16px; + } + + .check-icon { + width: 12px; + height: 12px; + position: absolute; + top: 4px; + left: 4px; + + path { + fill: black; + } + } + + .status-div { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 3px; + + svg.status-icon { + width: 10px; + height: 10px; + } + } + + .add-div { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + + svg.add-icon { + width: 16px; + height: 16px; + + path { + fill: var(--app-text-primary-color); + } + } + } + + .text-standard { + color: var(--app-text-secondary-color); + } + + .ellipsis { + text-overflow: ellipsis; + } + + &:hover { + background-color: rgba(241, 246, 243, 0.08); + } + + .icon.color-white + .check-icon { + path { + fill: black; + } + } + } + } + + .terminal-theme-dropdown { + width: 412px; + } +} diff --git a/src/app/workspace/workspaceview.tsx b/src/app/workspace/workspaceview.tsx new file mode 100644 index 0000000000..5b1f58a209 --- /dev/null +++ b/src/app/workspace/workspaceview.tsx @@ -0,0 +1,270 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { clsx } from "clsx"; +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import { If } from "tsx-control-statements/components"; +import { GlobalModel, GlobalCommandRunner } from "@/models"; +import { CmdInput } from "./cmdinput/cmdinput"; +import { ScreenView } from "./screen/screenview"; +import { ScreenTabs } from "./screen/tabs"; +import { ErrorBoundary } from "@/common/error/errorboundary"; +import { boundMethod } from "autobind-decorator"; +import type { Screen } from "@/models"; +import { Button, Dropdown } from "@/elements"; +import { commandRtnHandler } from "@/util/util"; +import { getTermThemes } from "@/util/themeutil"; +import { getRemoteStrWithAlias } from "@/common/prompt/prompt"; +import { TabColorSelector, TabIconSelector, TabNameTextField, TabRemoteSelector } from "./screen/newtabsettings"; +import * as util from "@/util/util"; + +import "./workspace.less"; + +dayjs.extend(localizedFormat); + +const ScreenDeleteMessage = ` +Are you sure you want to delete this tab? +`.trim(); + +class SessionKeybindings extends React.Component<{}, {}> { + componentDidMount() { + const keybindManager = GlobalModel.keybindManager; + keybindManager.registerKeybinding("mainview", "session", "app:toggleSidebar", (waveEvent) => { + GlobalModel.handleToggleSidebar(); + return true; + }); + keybindManager.registerKeybinding("mainview", "session", "app:newTab", (waveEvent) => { + GlobalModel.onNewTab(); + return true; + }); + keybindManager.registerKeybinding("mainview", "session", "app:closeCurrentTab", (waveEvent) => { + GlobalModel.onCloseCurrentTab(); + return true; + }); + for (let index = 1; index <= 9; index++) { + keybindManager.registerKeybinding("mainview", "session", "app:selectTab-" + index, (waveEvent) => { + GlobalModel.onSwitchScreenCmd(index); + return true; + }); + } + keybindManager.registerKeybinding("mainview", "session", "app:selectTabLeft", (waveEvent) => { + GlobalModel.onBracketCmd(-1); + return true; + }); + keybindManager.registerKeybinding("mainview", "session", "app:selectTabRight", (waveEvent) => { + GlobalModel.onBracketCmd(1); + return true; + }); + keybindManager.registerKeybinding("pane", "screen", "app:selectLineAbove", (waveEvent) => { + GlobalModel.onMetaArrowUp(); + return true; + }); + keybindManager.registerKeybinding("pane", "screen", "app:selectLineBelow", (waveEvent) => { + GlobalModel.onMetaArrowDown(); + return true; + }); + keybindManager.registerKeybinding("pane", "screen", "app:restartCommand", (waveEvent) => { + GlobalModel.onRestartCommand(); + return true; + }); + keybindManager.registerKeybinding("pane", "screen", "app:restartLastCommand", (waveEvent) => { + GlobalModel.onRestartLastCommand(); + return true; + }); + keybindManager.registerKeybinding("pane", "screen", "app:focusSelectedLine", (waveEvent) => { + GlobalModel.onFocusSelectedLine(); + return true; + }); + keybindManager.registerKeybinding("pane", "screen", "app:deleteActiveLine", (waveEvent) => { + return GlobalModel.handleDeleteActiveLine(); + }); + } + + componentWillUnmount() { + GlobalModel.keybindManager.unregisterDomain("session"); + GlobalModel.keybindManager.unregisterDomain("screen"); + } + + render() { + return null; + } +} + +@mobxReact.observer +class TabSettingsPulldownKeybindings extends React.Component<{}, {}> { + componentDidMount() { + const keybindManager = GlobalModel.keybindManager; + keybindManager.registerKeybinding("pane", "tabsettings", "generic:cancel", (waveEvent) => { + GlobalModel.closeTabSettings(); + return true; + }); + } + + componentWillUnmount() { + GlobalModel.keybindManager.unregisterDomain("tabsettings"); + } + + render() { + return null; + } +} + +@mobxReact.observer +class TabSettings extends React.Component<{ screen: Screen }, {}> { + errorMessage: OV = mobx.observable.box(null, { name: "TabSettings-errorMessage" }); + + @boundMethod + handleDeleteScreen(): void { + const { screen } = this.props; + if (screen == null) { + return; + } + let numLines = screen.getScreenLines().lines.length; + if (numLines < 10) { + GlobalCommandRunner.screenDelete(screen.screenId, false); + GlobalModel.modalsModel.popModal(); + return; + } + const message = ScreenDeleteMessage; + const alertRtn = GlobalModel.showAlert({ message: message, confirm: true, markdown: true }); + alertRtn.then((result) => { + if (!result) { + return; + } + const prtn = GlobalCommandRunner.screenDelete(screen.screenId, false); + util.commandRtnHandler(prtn, this.errorMessage); + GlobalModel.modalsModel.popModal(); + }); + } + + @boundMethod + handleChangeTermTheme(theme: string): void { + const { screenId } = this.props.screen; + const currTheme = GlobalModel.getTermThemeSettings()[screenId]; + if (currTheme == theme) { + return; + } + const prtn = GlobalCommandRunner.setScreenTermTheme(screenId, theme, false); + commandRtnHandler(prtn, this.errorMessage); + } + + render() { + const { screen } = this.props; + const rptr = screen.curRemote.get(); + const termThemes = getTermThemes(GlobalModel.termThemes.get()); + const currTermTheme = GlobalModel.getTermThemeSettings()[screen.screenId] ?? termThemes[0].label; + return ( +
+
+ +
+
+
+
+ You're connected to "{getRemoteStrWithAlias(rptr)}". Do you want to change it? +
+
+ +
+
+ To change connection from the command line use `cr [alias|user@host]` +
+
+
+ 0}> +
+ +
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+ ); + } +} + +@mobxReact.observer +class WorkspaceView extends React.Component<{}, {}> { + sessionRef = React.createRef(); + + @boundMethod + toggleTabSettings() { + mobx.action(() => { + GlobalModel.tabSettingsOpen.set(!GlobalModel.tabSettingsOpen.get()); + })(); + } + + render() { + const session = GlobalModel.getActiveSession(); + let activeScreen: Screen = null; + let sessionId: string = "none"; + if (session != null) { + sessionId = session.sessionId; + activeScreen = session.getActiveScreen(); + } + const isHidden = GlobalModel.activeMainView.get() != "session"; + const mainSidebarModel = GlobalModel.mainSidebarModel; + const showTabSettings = GlobalModel.tabSettingsOpen.get(); + return ( +
+ + + + + +
+ + + + + +
+
+ + + + + + +
+ ); + } +} + +export { WorkspaceView }; diff --git a/src/autocomplete/README.md b/src/autocomplete/README.md new file mode 100644 index 0000000000..5768d6b2e3 --- /dev/null +++ b/src/autocomplete/README.md @@ -0,0 +1,39 @@ +# Newton autocomplete parser + +Newton is a Fig-compatible autocomplete parser. It builds on a lot of goodness from the [@microsoft/inshellisense project](https://github.com/microsoft/inshellisense), with heavy modifications to minimize recursion and allow for caching of intermediate states. All suggestions, as with inshellisense, come from the [@withfig/autocomplete project](https://github.com/withfig/autocomplete). + +Any exec commands that need to be run are proxied through the Wave backend to ensure no additional permissions are required. + +The following features from Fig's object definitions are not yet supported: + +- Specs + - Versioned specs, such as the `az` CLI + - Custom specs from your filesystem + - Wave's slash commands and bracket syntax + - Slash commands will be added in a future PR, we just need to generate the proper specs for them + - Bracket syntax should not break the parser right now, you just won't get any suggestions when filling out metacommands within brackets +- Suggestions + - Rich icons support and icons served from the filesystem + - `isDangerous` field + - `hidden` field + - `deprecated` field + - `replaceValue` field - this requires a bit more work to properly parse out the text that needs to be replaced. + - `previewComponent` field - this does not appear to be used by any specs right now +- Subcommands + - `cache` field - All script outputs are currently cached for 5 minutes +- Options + - `isPersistent` field - this requires a bit of work to make sure we pass forward the correct options to subcommands + - `isRequired` field - this should prioritize options that are required + - `isRepeatable` field - this should let a flag be repeated a specified number of times before being invalidated and no longer suggested + - `requiresEquals` field - this is deprecated, but some popular specs still use it +- Args + - `suggestCurrentToken` field + - `isDangerous` field + - `isScript` field + - `isModule` field - only Python uses this right now + - `debounce` field + - `default` field + - `parserDirectives.alias` field +- Generators + - `getQueryTerm` field + - `cache` field - All script outputs are currently cached for 5 minutes diff --git a/src/autocomplete/index.ts b/src/autocomplete/index.ts new file mode 100644 index 0000000000..eedf8429c8 --- /dev/null +++ b/src/autocomplete/index.ts @@ -0,0 +1,5 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +export * from "./runtime/runtime"; +export * from "./utils/shell"; diff --git a/src/autocomplete/runtime/generator.ts b/src/autocomplete/runtime/generator.ts new file mode 100644 index 0000000000..1743b70794 --- /dev/null +++ b/src/autocomplete/runtime/generator.ts @@ -0,0 +1,145 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Modified from https://github.com/microsoft/inshellisense/blob/main/src/runtime/generator.ts +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import log from "../utils/log"; +import { runTemplates } from "./template"; +import { buildExecuteShellCommand, getEnvironmentVariables } from "./utils"; + +async function getGeneratorContext(cwd: string, env?: Record): Promise { + return { + environmentVariables: env ?? (await getEnvironmentVariables(cwd)), + currentWorkingDirectory: cwd, + currentProcess: "", // TODO: define current process + sshPrefix: "", // deprecated, should be empt + isDangerous: false, + searchTerm: "", // TODO: define search term + }; +} + +let lastFirstToken = ""; +let lastFinalToken = ""; +let cachedSuggestions: Fig.Suggestion[] = []; + +// TODO: add support getQueryTerm +export async function runGenerator( + generator: Fig.Generator, + tokens: string[], + cwd: string, + env?: Record +): Promise { + const { script, postProcess, scriptTimeout, splitOn, custom, template, filterTemplateSuggestions, trigger } = + generator; + + const newToken = tokens.at(-1) ?? ""; + + if (lastFirstToken == tokens.at(0) && trigger && cachedSuggestions.length > 0) { + log.debug("trigger", trigger); + if (typeof trigger === "string") { + if (!newToken?.includes(trigger)) { + log.debug("trigger string", newToken, trigger); + return cachedSuggestions; + } + } else if (typeof trigger === "function") { + log.debug("trigger function", "newToken:", newToken, "lastToken: ", lastFinalToken); + if (!trigger(newToken, lastFinalToken ?? "")) { + log.debug("trigger function false"); + return cachedSuggestions; + } else { + log.debug("trigger function true"); + } + } else { + switch (trigger.on) { + case "change": { + log.debug("trigger change", newToken, lastFinalToken); + if (lastFinalToken && newToken && lastFinalToken === newToken) { + log.debug("trigger change false"); + return cachedSuggestions; + } else { + log.debug("trigger change true"); + } + break; + } + case "match": { + if (Array.isArray(trigger.string)) { + log.debug("trigger match array", newToken, trigger.string); + if (!trigger.string.some((t) => newToken === t)) { + log.debug("trigger match false"); + return cachedSuggestions; + } else { + log.debug("trigger match true"); + } + } else if (trigger.string !== newToken) { + log.debug("trigger match single true", newToken, trigger.string); + return cachedSuggestions; + } else { + log.debug("trigger match single false", newToken, trigger.string); + } + break; + } + case "threshold": { + log.debug("trigger threshold", newToken, lastFinalToken, trigger.length); + if (Math.abs(newToken.length - lastFinalToken.length) < trigger.length) { + log.debug("trigger threshold false"); + return cachedSuggestions; + } else { + log.debug("trigger threshold true"); + } + break; + } + } + } + } else if (lastFirstToken === tokens.at(0) && newToken && lastFinalToken === newToken) { + log.debug("lastToken === newToken", lastFinalToken, newToken); + return cachedSuggestions; + } + log.debug("lastToken !== newToken", lastFinalToken, newToken); + + const executeShellCommand = buildExecuteShellCommand(scriptTimeout ?? 5000); + const suggestions = []; + lastFinalToken = tokens[-1]; + lastFirstToken = tokens[0]; + try { + if (script) { + const shellInput = typeof script === "function" ? script(tokens) : script; + const scriptOutput = Array.isArray(shellInput) + ? await executeShellCommand({ command: shellInput.at(0) ?? "", args: shellInput.slice(1), cwd }) + : await executeShellCommand({ ...shellInput, cwd }); + + const scriptStdout = scriptOutput.stdout.trim(); + const scriptStderr = scriptOutput.stderr.trim(); + if (scriptStderr) { + log.debug("script error, skipping processing", scriptStderr); + } else if (postProcess) { + suggestions.push(...postProcess(scriptStdout, tokens)); + } else if (splitOn) { + suggestions.push(...scriptStdout.split(splitOn).map((s) => ({ name: s }))); + } + } + + if (custom) { + log.debug("custom", custom); + const customSuggestions = await custom(tokens, executeShellCommand, await getGeneratorContext(cwd, env)); + log.debug("customSuggestions", customSuggestions); + suggestions.push(...customSuggestions); + } + + if (template != null) { + const templateSuggestions = await runTemplates(template, cwd); + if (filterTemplateSuggestions) { + suggestions.push(...filterTemplateSuggestions(templateSuggestions)); + } else { + suggestions.push(...templateSuggestions); + } + } + cachedSuggestions = suggestions; + return suggestions; + } catch (e) { + const err = typeof e === "string" ? e : e instanceof Error ? e.message : e; + log.debug({ msg: "generator failed", err, script, splitOn, template }); + } + return suggestions; +} diff --git a/src/autocomplete/runtime/loadspec.ts b/src/autocomplete/runtime/loadspec.ts new file mode 100644 index 0000000000..cf4e422e2a --- /dev/null +++ b/src/autocomplete/runtime/loadspec.ts @@ -0,0 +1,114 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Modified from https://github.com/microsoft/inshellisense/blob/main/src/runtime/runtime.ts +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import speclist, { + diffVersionedCompletions as versionedSpeclist, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore +} from "@withfig/autocomplete/build/index"; +import log from "../utils/log"; +import { buildExecuteShellCommand, mergeSubcomands } from "./utils"; + +const specSet: Record = {}; + +(speclist as string[]).forEach((s) => { + const suffix = versionedSpeclist.includes(s) ? "/index.js" : `.js`; + specSet[s] = `${s}${suffix}`; +}); + +const loadedSpecs: Record = {}; + +/** + * Loads the spec for the current command. If the spec has been loaded already, it will be returned. + * If the command defines a `loadSpec` function, that function is run and the result is set as the new spec. + * Otherwise, the spec is set to the command itself. + * @param specName The name of the spec to load. + * @param entries The entries to pass to the spec's `generateSpec` function, if it exists. + * @returns The loaded spec, or undefined if the spec could not be loaded. + */ +export const loadSpec = async (specName: string, entries: string[]): Promise => { + if (!specName) { + log.debug("specName empty, returning undefined"); + return; + } + + try { + log.debug("loading spec: ", specName); + + let spec: any; + + if (loadedSpecs[specName]) { + log.debug("loaded spec found"); + return loadedSpecs[specName]; + } + if (specSet[specName]) { + log.debug("loading spec"); + spec = await import(`@withfig/autocomplete/build/${specSet[specName]}`); + } else { + log.debug("no spec found, returning undefined"); + return; + } + + if (Object.hasOwn(spec, "getVersionCommand") && typeof spec.getVersionCommand === "function") { + log.debug("has getVersionCommand fn"); + const commandVersion = await (spec.getVersionCommand as Fig.GetVersionCommand)( + buildExecuteShellCommand(5000) + ); + log.debug("commandVersion: " + commandVersion); + log.debug("returning as version is not supported"); + return; + } + if (typeof spec.default === "object") { + const command = spec.default as Fig.Subcommand; + log.debug("Spec is valid Subcommand", command); + if (command.generateSpec) { + log.debug("has generateSpec function"); + const generatedSpec = await command.generateSpec(entries, buildExecuteShellCommand(5000)); + log.debug("generatedSpec: ", generatedSpec); + spec = mergeSubcomands(command, generatedSpec); + } else { + log.debug("no generateSpec function"); + spec = command; + } + loadedSpecs[specName] = spec; + return spec; + } else { + log.debug("Spec is not valid Subcommand"); + return; + } + } catch (e) { + console.warn("import failed: ", e); + } +}; + +// this load spec function should only be used for `loadSpec` on the fly as it is cacheless +export const lazyLoadSpec = async (key: string): Promise => { + return (await import(`@withfig/autocomplete/build/${key}.js`)).default; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- will be implemented in below TODO +export const lazyLoadSpecLocation = async (location: Fig.SpecLocation): Promise => { + return; //TODO: implement spec location loading +}; + +/** + * Returns the subcommand from a spec if it exists. + * @param spec The spec to get the subcommand from. + * @returns The subcommand, or undefined if the spec does not contain a subcommand. + */ +export const getSubcommand = (spec?: Fig.Spec): Fig.Subcommand | undefined => { + // TODO: handle subcommands that are versioned + if (spec == null) return; + if (typeof spec === "function") { + const potentialSubcommand = spec(); + if (Object.hasOwn(potentialSubcommand, "name")) { + return potentialSubcommand as Fig.Subcommand; + } + return; + } + return spec; +}; diff --git a/src/autocomplete/runtime/model.ts b/src/autocomplete/runtime/model.ts new file mode 100644 index 0000000000..e716d8968a --- /dev/null +++ b/src/autocomplete/runtime/model.ts @@ -0,0 +1,21 @@ +export enum TokenType { + UNKNOWN, + PATH, + FLAG, + OPTION, + ARGUMENT, + WHITESPACE, +} + +export interface Token { + type: TokenType; + value: string | undefined; +} + +export interface PathToken extends Token { + type: TokenType.PATH; + value: string; + prefix?: string; +} + +export const whitespace: Token = { type: TokenType.WHITESPACE, value: undefined }; diff --git a/src/autocomplete/runtime/newton.ts b/src/autocomplete/runtime/newton.ts new file mode 100644 index 0000000000..1d4f6ec673 --- /dev/null +++ b/src/autocomplete/runtime/newton.ts @@ -0,0 +1,1271 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Some code in this file has been modified from various files in https://github.com/microsoft/inshellisense +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import log from "../utils/log"; +import { Shell } from "../utils/shell"; +import { runGenerator } from "./generator"; +import { FilterStrategy, getIcon } from "./suggestion"; +import { runTemplates } from "./template"; +import { + buildExecuteShellCommand, + equalsAny, + getAll, + getFirst, + getPathSep, + isFlag, + isOption, + matchAny, + modifyPosixFlags, + resolveCwdToken, + sortSuggestions, + startsWithAny, +} from "./utils"; +import { getSubcommand, lazyLoadSpecLocation, loadSpec } from "./loadspec"; +import { PathToken, Token, TokenType, whitespace } from "./model"; + +/** + * The parser state. This is used to determine what the parser is currently matching. + */ +enum ParserState { + /** + * The parser is currently matching subcommands. + */ + Subcommand, + + /** + * The parser is currently matching options or non-POSIX flags. + */ + Option, + + /** + * The parser is currently matching POSIX flags. + */ + PosixFlag, + + /** + * The parser is currently matching arguments for an option or flag. + */ + OptionArgument, + + /** + * The parser is currently matching subcommand arguments. + */ + SubcommandArgument, +} + +/** + * A parser for a Fig.Subcommand spec. This class is used to traverse a spec and return suggestions for a given set of args. + */ +export class Newton { + /** + * The error message, if any. + */ + private error: string | undefined; + + /** + * The split segments of the current command. + */ + public entries: Token[]; + + /** + * The current index within `this.entries` that the parser is processing. + */ + public entryIndex: number; + + /** + * The options for the current command, as defined in the spec. A shorthand for `this.spec.options`. + */ + private options: Fig.Option[]; + + /** + * The subcommands for the current command, as defined in the spec. A shorthand for `this.spec.subcommands`. + */ + private subcommands: Fig.Subcommand[]; + + /** + * The most-recently-matched option. This is used to determine the final set of suggestions. + */ + private currentOption: Fig.Option | undefined; + + /** + * The most-recently-matched argument. This is used to determine the final set of suggestions. + */ + private args: Fig.Arg[] | undefined; + + /** + * The current argument index when parsing subcommand arguments. This is used to determine which argument the parser is currently matching. + */ + private subcommandArgIndex: number = 0; + + /** + * The current argument index when parsing option arguments. This is used to determine which argument the parser is currently matching. + */ + private optionArgIndex: number = 0; + + /** + * The available options for the current command. This is a map of option names to options. This is used to keep track of which options have already been used. + */ + private availableOptions: Record = {}; + + /** + * A map of option names to their dependent options. This is defined in the spec as `dependsOn`. Any options present in this map will be suggested with the highest priority. + */ + private dependentOptions: Record = {}; + + /** + * A map of option names to their mutually exclusive options. This is defined in the spec as `exclusiveOn`. Any options present in this map will be removed from the available options set and flagged as invalid. + */ + private mutuallyExclusiveOptions: Record = {}; + + /** + * Determines whether the parser should treat flags as POSIX-compliant. This is defined in the spec as `parserDirectives.flagsArePosixNoncompliant`. + */ + private flagsArePosixNoncompliant: boolean; + + /** + * Determines whether options or flags can precede arguments. This is defined in the spec as `parserDirectives.optionsMustPrecedeArguments`. + */ + private optionsMustPrecedeArguments: boolean; + + /** + * The option argument separators to use for the current command. This is defined in the spec as `parserDirectives.optionArgSeparators`. + * Remark: This does not appear to be widely used and most specs define argument separators in the options themselves. As this is mostly redundant, we may remove this in the future. + */ + private optionArgSeparators: string[]; + + /** + * Determines whether the parser should stop interpreting options. This is used when the user enters a double dash `--` to disable options and flags. + */ + private stopInterpretingOptions: boolean = false; + + /** + * The spec for the current command. + */ + private _spec: Fig.Subcommand | undefined; + + /** + * The number of iterations the parser has run. This is used to prevent infinite loops. + */ + private _numIters: number = 0; + + /** + * The current state of the parser. + */ + private curState: ParserState = ParserState.Subcommand; + + /** + * The previous state of the parser. + */ + private prevState: ParserState | undefined; + + /** + * The suggestions that will be returned to the user. + */ + private suggestions: Map = new Map(); + + /** + * The current working directory for the parser. + */ + private cwd: string; + + /** + * The environment variables for the current command. + */ + private envVars: Record; + + /** + * The user's shell type. + */ + public shell: Shell; + + constructor( + spec: Fig.Subcommand | undefined, + entries: Token[], + cwd: string, + shell: Shell, + envVars?: Record, + entryIndex: number = 0, + flagsArePosixNoncompliant: boolean = spec?.parserDirectives?.flagsArePosixNoncompliant ?? false, + optionsMustPrecedeArguments: boolean = spec?.parserDirectives?.optionsMustPrecedeArguments ?? false, + optionArgSeparators: string[] = spec?.parserDirectives?.optionArgSeparators + ? getAll(spec.parserDirectives?.optionArgSeparators) + : ["="], + options: Fig.Option[] = spec?.options ?? [], + subcommands: Fig.Subcommand[] = spec?.subcommands ?? [] + ) { + this.spec = spec; + this.entries = entries; + + // Clean the CWD to ensure it ends with a path separator + const sep = getPathSep(shell); + this.cwd = cwd.endsWith(sep) ? cwd : cwd + sep; + this.envVars = envVars; + + this.shell = shell; + this.entryIndex = entryIndex; + this.flagsArePosixNoncompliant = flagsArePosixNoncompliant; + this.optionsMustPrecedeArguments = optionsMustPrecedeArguments; + this.optionArgSeparators = optionArgSeparators; + this.options = options; + this.subcommands = subcommands; + } + + /** + * Get the spec for the current command. + */ + private get spec(): Fig.Subcommand | undefined { + return this._spec; + } + + /** + * Set the spec for the current command. This also sets the available options set and other parser directives, as defined in the spec. + */ + private set spec(spec: Fig.Subcommand | undefined) { + this._spec = spec; + this.options = spec?.options ?? []; + this.currentOption = undefined; + this.args = getAll(spec?.args); + this.subcommandArgIndex = 0; + this.availableOptions = {}; + this.dependentOptions = {}; + this.suggestions = new Map(); + this.subcommands = spec?.subcommands ?? []; + this.mutuallyExclusiveOptions = {}; + this.optionsMustPrecedeArguments = + spec?.parserDirectives?.optionsMustPrecedeArguments ?? this.optionsMustPrecedeArguments; + this.flagsArePosixNoncompliant = + spec?.parserDirectives?.flagsArePosixNoncompliant ?? this.flagsArePosixNoncompliant; + this.optionArgSeparators = spec?.parserDirectives?.optionArgSeparators + ? getAll(spec.parserDirectives?.optionArgSeparators) + : ["="]; + this.stopInterpretingOptions = false; + for (const option of this.options) { + for (const name of getAll(option.name)) { + this.availableOptions[name] = option; + } + } + } + + /** + * Gets the current entry in the list of entries. + * @see entries + * @see entryIndex + */ + private get currentEntry(): Token | undefined { + log.debug("currentEntry", this.entryIndex, this.entries); + return this.entries.at(this.entryIndex); + } + + /** + * Gets the last entry in the list of entries. + * @see entries + */ + private get lastEntry(): Token { + return this.entries.at(-1); + } + + /** + * Sets the last entry in the list of entries. + * @param value The value to set the last entry to. + */ + private set lastEntry(value: Token) { + this.entries[this.entries.length - 1] = value; + } + + /** + * Checks if the parser is at the end of the entry array. + * @returns True if the parser is at the end of the entry array. + * @see entries + * @see entryIndex + */ + private get atEndOfEntries(): boolean { + return this.entryIndex >= this.entries.length || this.currentEntry.type == TokenType.WHITESPACE; + } + + /** + * Checks if the parser is at the last entry of the entry array. + * @returns True if the parser is at the last entry of the entry array. + * @see entries + * @see entryIndex + */ + private get atLastEntry(): boolean { + return this.entryIndex == this.entries.length - 1; + } + + /** + * Gets all options that are POSIX-compliant flags. + * @returns All options that are POSIX-compliant flags, empty array if parser directives specify that flags are POSIX-noncompliant + * @see flagsArePosixNoncompliant + * @see availableOptions + */ + private get availablePosixFlags(): Fig.Option[] { + log.debug("availablePosixFlags", this.flagsArePosixNoncompliant, this.availableOptions); + return this.flagsArePosixNoncompliant + ? [] + : Object.values(this.availableOptions).filter((value) => matchAny(value.name, isFlag)); + } + + /** + * Filters out flags from the available options if the flags are POSIX-compliant. + * @returns All options that are not POSIX-compliant flags + * @see flagsArePosixNoncompliant + * @see availableOptions + */ + private get availableNonPosixFlags(): Fig.Option[] { + log.debug("availableNonPosixFlags", this.flagsArePosixNoncompliant, this.availableOptions); + const retVal = this.flagsArePosixNoncompliant + ? Object.values(this.availableOptions) + : Object.values(this.availableOptions).filter((value) => matchAny(value.name, isOption)); + return retVal; + } + + /** + * Get an option by name from the available options set. + * @param name The name of the option to get. + * @returns The option, or undefined if the option is not available. + * @see availableOptions + */ + private getAvailableOption(name: Token | string): Fig.Option | undefined { + return this.availableOptions[typeof name == "string" ? name : name.value]; + } + + /** + * Record an option as having been used, removing it from the available options set. This should remove both the flag and the option version. + * @param option The option to record. + * @see availableOptions + */ + private recordOption(option: Fig.Option) { + for (const name of getAll(option.name)) { + delete this.availableOptions[name]; + } + } + + /** + * Identify dependent and mutually exclusive options and modify the available options set accordingly. Dependent options are given the highest priority, while exclusive options are removed from the available options set. + * @param option The option to check. + * @see getAvailableOption + * @see dependentOptions + * @see mutuallyExclusiveOptions + */ + private findDependentAndExclusiveOptions(option: Fig.Option) { + if (option.dependsOn) { + const dependentOptions: Fig.Option[] = []; + + for (const name of option.dependsOn) { + const dependentOption = this.getAvailableOption(name); + if (dependentOption) { + // Dependent options are given the highest priority + dependentOptions.push(dependentOption); + const modifiedPriority = { ...dependentOption }; + modifiedPriority.priority = 1000; + this.availableOptions[name] = modifiedPriority; + } + } + for (const name of getAll(option.name)) { + this.dependentOptions[name] = dependentOptions; + } + } + if (option.exclusiveOn) { + const mutuallyExclusiveOptions: Fig.Option[] = []; + for (const name of option.exclusiveOn) { + const exclusiveOption = this.getAvailableOption(name); + if (exclusiveOption) { + // If the exclusive option is available, remove it so it won't be suggested. + delete this.availableOptions[name]; + mutuallyExclusiveOptions.push(exclusiveOption); + } + } + for (const name of getAll(option.name)) { + this.mutuallyExclusiveOptions[name] = mutuallyExclusiveOptions; + } + } + } + + /** + * Check if the option has an argument separator. If so, break it up and add the arguments as separate entries. + */ + private breakOutOptionArgs() { + const entry = this.currentEntry; + if (this.optionArgSeparators.length > 0) { + log.debug("optionArgSeparators", this.optionArgSeparators); + for (const sep of this.optionArgSeparators) { + log.debug("optionArgSeparators", sep); + if (entry.value.includes(sep)) { + log.debug("optionArgSeparators", sep, entry); + const optionStr = entry.value.substring(0, entry.value.indexOf(sep)); + log.debug("optionStr", optionStr); + const option = this.getAvailableOption(optionStr); + log.debug("option", option); + if ( + option && + (option.requiresSeparator === sep || + (option.requiresSeparator === true && this.optionArgSeparators.length == 1)) + ) { + this.entries[this.entryIndex].value = optionStr; + const argToken = { + type: TokenType.ARGUMENT, + value: entry.value.substring(entry.value.indexOf(sep) + 1, entry.value.length), + }; + log.debug("argStr", argToken); + if (argToken.value.length > 0) { + this.entries.splice(this.entryIndex + 1, 0, argToken); + } else if (this.atLastEntry) { + this.entries.push(whitespace); + } else { + this.error = `The option ${optionStr} requires an argument with a separator "${sep}".`; + } + return; + } + } else if (!this.atLastEntry && this.getAvailableOption(entry.value)?.requiresSeparator) { + this.error = `The option ${entry} requires an argument with a separator.`; + return; + } + } + } + } + + /** + * Parses the flag in the current entry and modifies the available options set accordingly. + */ + private parseFlag(): ParserState { + this.breakOutOptionArgs(); + const entry = this.currentEntry; + if (!entry) { + return; + } + + log.debug("parseFlag", entry); + const existingFlags = entry?.value.slice(1); + const flagsArr = existingFlags?.split("") ?? []; + if (flagsArr.length == 0) { + return ParserState.Option; + } + for (const index of flagsArr.keys()) { + const flag = flagsArr[index]; + const option = this.getAvailableOption(`-${flag}`); + if (option) { + // If the option is available, record it and move on to the next flag + if (option.args) { + if (index < flagsArr.length - 1) { + const hasRequiredArgs = getAll(option.args).find((arg) => !arg.isOptional) != null; + if (hasRequiredArgs) { + // If the option has required args and is not the last flag, it is invalid + this.error = `The option ${option.name} is invalid as it has required arguments and is not the last flag.`; + break; + } + } + } + // Identify dependent and mutually exclusive options + this.findDependentAndExclusiveOptions(option); + this.recordOption(option); + this.currentOption = option; + } else { + // If the option is not available, it has already been used or is not a valid option + this.error = `The option ${entry} is not valid.`; + return ParserState.Subcommand; + } + } + if (!this.currentOption) { + log.debug("no current option"); + return ParserState.Option; + } else if (this.currentOption.args !== undefined) { + log.debug("current option has args"); + return ParserState.OptionArgument; + } else if (!this.atLastEntry) { + // We are not done parsing the user entries so we should return to the subcommand state and then decide the next state. + log.debug("current option does not have args, return to subcommand"); + return ParserState.Subcommand; + } else { + // We are at the last entry so we can assume the user is not done writing the flag + return ParserState.PosixFlag; + } + } + + /** + * Parses the option in the current entry and modifies the available options set accordingly. + */ + private parseOption(): ParserState { + // This means we cannot use POSIX-style flags, so we have to check each option individually + log.debug("parseOption"); + this.breakOutOptionArgs(); + const entry = this.currentEntry; + if (!entry) { + log.debug("no entry, returning and setting state to subcommand"); + return ParserState.Subcommand; + } + + // We need to handle cases where a double dash is used to disable options and flags, such as in git commands. + if (entry.value === "--") { + if (this.atLastEntry) { + log.debug( + "double dash at last entry, returning and setting state to option to process all available options" + ); + this.currentOption = undefined; + return ParserState.Option; + } else { + log.debug("double dash not at last entry, disabling options and flags for all subsequent entries"); + this.stopInterpretingOptions = true; + return ParserState.Subcommand; + } + } + + if (!this.atLastEntry) { + // If the arg is not the last entry, we can check if it is a valid option + const option = this.getAvailableOption(entry); + if (option) { + // If the option is available, record it and move on to the next arg + this.recordOption(option); + + // Identify dependent and mutually exclusive options, verify that they are valid. + this.findDependentAndExclusiveOptions(option); + + this.currentOption = option; + if (option.args !== undefined) { + log.debug("option has args", option.args); + return ParserState.OptionArgument; + } else { + return ParserState.Subcommand; + } + } else { + // If the option is not available, it has already been used or is not a valid option + this.error = `The option ${entry} is not valid.`; + } + } else { + // The entry is incomplete, but it's the last one so we can just suggest all that start with the entry. + this.currentOption = undefined; + } + return ParserState.Option; + } + + /** + * Parses the the current entry as an argument for either a subcommand or an option. This will determine the next state of the parser. + */ + private async parseArgument(): Promise { + const entry = this.currentEntry; + let args = this.args; + let argIndex = this.subcommandArgIndex; + if (this.curState == ParserState.OptionArgument) { + if (this.prevState == ParserState.Option) { + // This is a new set of option arguments, so we need to reset the argIndex + this.optionArgIndex = 0; + } + argIndex = this.optionArgIndex; + args = getAll(this.currentOption.args); + } + + const incrementArgIndex = () => { + argIndex++; + if (this.curState == ParserState.OptionArgument) { + this.optionArgIndex++; + } else { + this.subcommandArgIndex++; + } + }; + + if (!entry || args.length == 0) { + log.debug("returning early from parseArgument", this.prevState); + return this.prevState; + } + + const currentArg = args[argIndex]; + + if (currentArg) { + const curEntryType = entry.type; + if (currentArg.isCommand) { + // The next entry is a command, so we need to load the spec for that command and start from scratch + this.spec = undefined; + return ParserState.Subcommand; + } else if (curEntryType == TokenType.OPTION || curEntryType == TokenType.FLAG) { + // We found an option or a flag, we will need to determine if this is allowed before continuing + if (!currentArg.isOptional || !currentArg.isVariadic) { + this.error = `The argument ${currentArg.name} is required and cannot be a flag or option.`; + } else if (currentArg.isVariadic && currentArg.optionsCanBreakVariadicArg && !this.atLastEntry) { + // If options can break the variadic argument, we should try parsing the option and then return to parsing the arguments. + this.entryIndex++; + this.parseOption(); + this.currentOption = undefined; + return ParserState.SubcommandArgument; + } + return ParserState.Option; + } else if (currentArg.isOptional) { + // The argument is optional, we want to see if we have any matches before determining if we should move on. + const numAdded = await this.addSuggestionsForArg(currentArg, true); + if (!this.atLastEntry && numAdded > 0) { + // We found a match and we are not at the end of the entry list, so let's keep matching arguments for the next entry + log.debug("has suggestion match for optional arg"); + incrementArgIndex(); + return ParserState.SubcommandArgument; + } else { + // We did not find a match, we should return to the previous state. + log.debug("no suggestion found for optional arg"); + return this.prevState; + } + } else if (currentArg.isVariadic) { + // Assume that the next entry is going to be another argument of the same type + return this.curState; + } else { + // Will try to match the next entry to the next argument in the list. + incrementArgIndex(); + return this.curState; + } + } else { + // We did not identify an argument to parse, return to the previous state + return this.prevState; + } + } + + /** + * After a set of suggestions has been generated, this function will clean up the suggestions and remove unnecessary fields. + * @param suggestion The suggestion to clean up. + * @param defaultType The default type to use if the suggestion does not have a type. + * @param prefixStr The prefix to add to the suggestion name, if any. + * @returns The cleaned up suggestion. + */ + private prepareSuggestion( + suggestion: Fig.Suggestion, + defaultType: Fig.SuggestionType, + prefixStr?: string + ): Fig.Suggestion { + if (suggestion == undefined) { + return undefined; + } + let partialCmd = this.lastEntry; + const entryPathPrefix = (partialCmd as PathToken)?.prefix ?? ""; + const suggestionMin: Fig.Suggestion = { + name: suggestion.name, + displayName: suggestion.displayName, + description: suggestion.description, + icon: suggestion.icon, + type: suggestion.type, + insertValue: suggestion.insertValue, + priority: suggestion.priority, + }; + log.debug( + "prepareSuggestion", + "suggestion", + suggestion, + "suggestionMin", + suggestionMin, + "partialCmd", + partialCmd + ); + + if (suggestionMin.name && prefixStr) { + suggestionMin.name = getAll(suggestionMin.name).map((name) => prefixStr + name); + } + + if (!suggestionMin.type) { + suggestionMin.type = defaultType; + } + if (!suggestionMin.icon) { + suggestionMin.icon = getIcon(suggestionMin.icon, suggestionMin.type); + } else if (suggestionMin.icon.startsWith("fig://")) { + suggestionMin.icon = getIcon(suggestionMin.icon, "special"); + } + if (!suggestionMin.insertValue) { + log.debug("prepareSuggestion no insertValue", suggestionMin.name, partialCmd); + if (!partialCmd?.value) { + log.debug("prepareSuggestion no insertValue, no partialCmd", getFirst(suggestionMin.name)); + suggestionMin.insertValue = getFirst(suggestionMin.name); + } else { + for (const name of getAll(suggestionMin.name)) { + if (name.startsWith(partialCmd.value) && name.length > (suggestionMin.insertValue?.length ?? 0)) { + log.debug("prepareSuggestion insertValue found", name, partialCmd); + suggestionMin.insertValue = name; + } + } + + log.debug( + "prepareSuggestion substring length", + suggestionMin.insertValue?.length, + partialCmd.value?.length + ); + // suggestionMin.insertValue = suggestionMin.insertValue?.slice(partialCmd.length); + } + log.debug("prepareSuggestion insertValue final", suggestionMin.insertValue); + } else { + log.debug("prepareSuggestion insertValue exists", suggestionMin.insertValue, partialCmd); + // Handle situations where the maintainer of the spec includes the command in the insertValue, failing to account for the fact that the user may have already typed the command. + const startsWithPartialCmd = suggestionMin.insertValue.startsWith(partialCmd.value); + for (const name in getAll(suggestionMin.name)) { + if (suggestionMin.insertValue.startsWith(name) && !startsWithPartialCmd) { + suggestionMin.insertValue = suggestionMin.insertValue.slice(name.length); + break; + } + } + log.debug("prepareSuggestion insertValue final", suggestionMin.insertValue); + } + + if ((suggestionMin.insertValue && suggestionMin.type == "file") || suggestionMin.type == "folder") { + log.debug( + `prepareSuggestion add entryPathPrefix "${entryPathPrefix}" to insert value "${suggestionMin.insertValue}"` + ); + suggestionMin.insertValue = entryPathPrefix + suggestionMin.insertValue; + } + if (!suggestionMin.priority) { + switch (suggestionMin.type) { + case "option": + suggestionMin.priority = 50; + break; + case "subcommand": + suggestionMin.priority = 51; + break; + case "arg": + suggestionMin.priority = 52; + break; + case "file": + suggestionMin.priority = 49; + break; + default: + suggestionMin.priority = 50; + } + } + return suggestionMin; + } + + /** + * Filter the suggestions using the specified filtering strategy. + * @param suggestions The suggestions to filter + * @param filterStrategy The filtering strategy to use. Will default to "prefix". + * @param partialCmd The current entry to use when filtering out the suggestions. + * @param suggestionType The type of suggestion object to interpret (currently not used by Newton). + * @returns The filtered suggestions. + */ + private filterSuggestionsAndAddToMap( + suggestions: (Fig.Suggestion | string)[], + filterStrategy: FilterStrategy, + partialCmd: Token, + suggestionType: Fig.SuggestionType, + prefixStr?: string + ) { + log.debug( + "filter", + "suggestions", + suggestions, + "filterStrategy", + filterStrategy, + "partialCmd", + `"${partialCmd}"`, + "suggestionType", + suggestionType + ); + const suggestionsArr = suggestions.map((s) => (typeof s === "string" ? { name: s } : s)); + if (!partialCmd || partialCmd.type == TokenType.WHITESPACE) { + this.addSuggestionsToMap(suggestionsArr, suggestionType, prefixStr); + return; + } + + if (filterStrategy === "fuzzy") { + log.debug("fuzzy"); + suggestionsArr.forEach((s) => { + if (s.name == null) return; + if (s.name instanceof Array) { + const matchedName = s.name.find((n) => n.toLowerCase().includes(partialCmd.value.toLowerCase())); + if (matchedName) { + this.addSuggestionsToMap([{ name: matchedName }], suggestionType, prefixStr); + } + } else if (s.name.toLowerCase().includes(partialCmd.value.toLowerCase())) { + this.addSuggestionsToMap([s], suggestionType, prefixStr); + } + }); + } else { + log.debug("prefix"); + suggestionsArr.forEach((s) => { + log.debug("prefix", s.name, partialCmd); + if (s.name == null) return; + if (s.name instanceof Array) { + const matchedName = s.name.find((n) => n.toLowerCase().startsWith(partialCmd.value.toLowerCase())); + if (matchedName) { + this.addSuggestionsToMap([{ name: matchedName }], suggestionType, prefixStr); + } + } else if (s.name.toLowerCase().startsWith(partialCmd.value.toLowerCase())) { + this.addSuggestionsToMap([s], suggestionType, prefixStr); + } + }); + } + } + + /** + * Add suggestions to the suggestions map. + * @param suggestions The suggestions to add. + * @param suggestionType The default suggestion type to use if the suggestion does not have a type. + * @param prefixStr The prefix string to add to the suggestions, if any. + */ + private addSuggestionsToMap(suggestions: Fig.Suggestion[], suggestionType: Fig.SuggestionType, prefixStr?: string) { + suggestions?.forEach((suggestion) => { + this.suggestions.set( + getFirst(suggestion.name), + this.prepareSuggestion(suggestion, suggestionType, prefixStr) + ); + }); + } + + /** + * Add suggestions for the current argument. + * @param arg The argument to add suggestions for. + * @param dryRun Whether to actually add the suggestions or just count them. + * @param prefixStr The prefix string to add to the suggestions, if any. + * @returns The number of suggestions added. + */ + private async addSuggestionsForArg(arg: Fig.Arg, dryRun: boolean, prefixStr?: string): Promise { + let entry = this.lastEntry; + const suggestions: Fig.Suggestion[] = []; + + if (arg?.generators) { + const generators = getAll(arg.generators); + log.debug("arg generators", generators); + suggestions.push( + ...( + await Promise.all( + generators.map((gen) => + runGenerator( + gen, + this.entries.map((e) => e.value), + this.cwd, + this.envVars + ) + ) + ) + ).flat() + ); + } + + if (arg?.suggestions) { + log.debug("arg suggestions", arg.suggestions); + suggestions.push(...(arg.suggestions.map((s) => (typeof s === "string" ? { name: s } : s)) ?? [])); + } + + if (arg?.template) { + log.debug("arg template", arg.template, this.cwd); + suggestions.push(...(await runTemplates(arg.template ?? [], this.cwd))); + } + + if (!dryRun) { + this.filterSuggestionsAndAddToMap( + suggestions, + arg.filterStrategy ?? this.spec.filterStrategy, + entry, + "arg", + prefixStr + ); + } + return suggestions.length; + } + + /** + * Add the subcommands for the current spec as suggestions. + */ + private addSuggestionsForSubcommands() { + this.filterSuggestionsAndAddToMap(this.subcommands, this.spec?.filterStrategy, this.lastEntry, "subcommand"); + } + + /** + * Add all available options and flags as suggestions. + */ + private addSuggestionsForOptionsAndFlags() { + if (this.stopInterpretingOptions) { + log.debug("cannot add suggestions for options after --"); + return; + } + const entry = this.lastEntry; + const precedingFlagsMaybe = entry?.value?.slice(1) ?? ""; + const availableOptions = [ + ...this.availablePosixFlags.map((option) => modifyPosixFlags(option, precedingFlagsMaybe)), + ...this.availableNonPosixFlags, + ]; + log.debug("availableOptions:", availableOptions); + this.filterSuggestionsAndAddToMap(availableOptions, this.spec?.filterStrategy, entry, "option"); + } + + /** + * Runs the history template and adds the suggestions to the suggestions map. This skips the filtering as the history template already does this. + */ + private async addSuggestionsForHistory(cwd: string = this.cwd): Promise { + this.addSuggestionsToMap(await runTemplates("history", cwd), "special"); + } + + /** + * Runs the filepaths template and adds the suggestions to the suggestions map. + */ + private async addSuggestionsForFilepaths(cwd: string = this.cwd): Promise { + log.debug("addSuggestionsForFilepaths", cwd, this.lastEntry, this.spec?.filterStrategy); + this.filterSuggestionsAndAddToMap( + await runTemplates("filepaths", cwd), + this.spec?.filterStrategy, + this.lastEntry, + "file" + ); + } + + /** + * Loads the spec for the current command. If the command defines a `loadSpec` function, that function is run and the result is set as the new spec. Otherwise, the spec is set to the command itself. + * @returns The spec for the current command. + */ + private async loadSpec(specName: string): Promise { + return await loadSpec( + specName, + this.atLastEntry ? this.entries.slice(this.entryIndex + 1).map((e) => e.value) : [] + ); + } + + /** + * Find a subcommand that matches the current entry and traverse it. + * @returns True if a subcommand was found. + */ + private async findSubcommand(): Promise { + const curEntry = this.currentEntry; + if (!curEntry || this.atLastEntry) { + return false; + } + + log.debug("curEntry: ", curEntry, "curSpec", this.spec); + + if (this.spec) { + // No need to run this if the user is typing an option + // Determine if a subcommand matches the current entry, if so set it as our new spec + const subcommand = this.spec.subcommands?.find((subcommand) => equalsAny(subcommand.name, curEntry.value)); + if (subcommand) { + log.debug("subcommand exists", subcommand); + // Subcommand module found; traverse it. + switch (typeof subcommand.loadSpec) { + case "string": { + log.debug("loadSpec is string", subcommand.loadSpec); + // The subcommand defines a path to a new spec; load that spec and set it as our new spec + this.spec = await this.loadSpec(subcommand.loadSpec); + break; + } + case "object": + log.debug("loadSpec is object"); + // The subcommand defines a new spec inline; this is our new spec + this.spec = { + ...subcommand, + ...(subcommand.loadSpec ?? {}), + loadSpec: undefined, + }; + break; + case "function": { + log.debug("loadSpec is function"); + const partSpec = await subcommand.loadSpec(curEntry.value, buildExecuteShellCommand(5000)); + if (partSpec instanceof Array) { + const locationSpecs = ( + await Promise.all(partSpec.map((s) => lazyLoadSpecLocation(s))) + ).filter((s) => s != null); + const subcommands = locationSpecs.map((s) => getSubcommand(s)).filter((s) => s != null); + this.spec = { + ...subcommand, + ...(subcommands.find((s) => s?.name == curEntry.value) ?? []), + loadSpec: undefined, + }; + } else if (Object.hasOwn(partSpec, "type")) { + const locationSingleSpec = await lazyLoadSpecLocation(partSpec as Fig.SpecLocation); + this.spec = { + ...subcommand, + ...(getSubcommand(locationSingleSpec) ?? []), + loadSpec: undefined, + }; + } else { + this.spec = subcommand; + } + break; + } + default: + // The subcommand defines options; suggest those options + this.spec = subcommand; + break; + } + } else { + log.debug("subcommand not found"); + return false; + } + } else { + log.debug("no spec"); + this.spec = await this.loadSpec(curEntry.value); + } + return true; + } + + /** + * Iterate through the shell entries and generate suggestions based on the matched `Fig.Spec`. + * @returns The final list of suggestions + */ + public async generateSuggestions(): Promise { + while (!this.error && !this.atEndOfEntries) { + const newPrevState = this.curState; + switch (this.curState) { + case ParserState.Subcommand: { + log.debug("subcommand"); + if (!(await this.findSubcommand())) { + switch (this.currentEntry?.type) { + case TokenType.FLAG: + case TokenType.OPTION: + this.curState = ParserState.Option; + break; + default: + this.curState = ParserState.SubcommandArgument; + break; + } + break; + } + + this.entryIndex++; + break; + } + case ParserState.Option: { + log.debug("option", this.currentEntry); + const curEntry = this.currentEntry; + const isEntryOption = curEntry?.type == TokenType.OPTION; + const isEntryFlag = curEntry?.type == TokenType.FLAG; + if (this.stopInterpretingOptions) { + if (isEntryOption || isEntryFlag) { + this.error = "Options and flags are not allowed after --"; + break; + } else { + this.curState = ParserState.SubcommandArgument; + break; + } + } + if (isEntryOption || (isEntryFlag && this.flagsArePosixNoncompliant)) { + log.debug("entry is option or non-posix flag"); + this.curState = this.parseOption(); + this.entryIndex++; + } else if (isEntryFlag) { + log.debug("entry is flag"); + this.curState = ParserState.PosixFlag; + break; + } else { + log.debug("not option or flag"); + this.curState = ParserState.SubcommandArgument; + } + break; + } + case ParserState.PosixFlag: { + log.debug("posix flag"); + this.curState = this.parseFlag(); + this.entryIndex++; + break; + } + case ParserState.SubcommandArgument: + log.debug("subcommand argument"); + const curEntryType = this.currentEntry?.type; + if ( + (curEntryType == TokenType.OPTION || curEntryType == TokenType.FLAG) && + !this.optionsMustPrecedeArguments + ) { + log.debug("subcommand argument is flag or option"); + this.curState = ParserState.Option; + break; + } else { + log.debug("subcommand argument is argument"); + this.curState = await this.parseArgument(); + this.entryIndex++; + } + break; + case ParserState.OptionArgument: + log.debug("option argument"); + this.curState = await this.parseArgument(); + this.entryIndex++; + break; + } + + // Protect against infinite loops + if (this._numIters++ > this.entries.length * 2) { + this.error = "Too many iterations"; + } + + this.prevState = newPrevState; + + // Bail out on error + if (this.error) { + console.warn("Error: " + this.error); + break; + } + } + + log.debug( + "done with loop, error: ", + this.error, + "entryIndex: ", + this.entryIndex, + "entries: ", + this.entries, + "curState: ", + this.curState, + "curOption: ", + this.currentOption, + "currentArgs: ", + this.args, + "argIndex: ", + this.subcommandArgIndex + ); + + if (!this.error) { + // We parsed the entire entry array without error, so we can return suggestions + let lastEntry: Token = this.lastEntry ?? { type: TokenType.UNKNOWN, value: undefined }; + log.debug( + "allEntries: ", + this.entries, + "lastEntry: ", + `"${lastEntry}"`, + "curState: ", + this.curState, + "lastEntryEndsWithSpace: ", + lastEntry.type == TokenType.WHITESPACE + ); + + const originalCwd = this.cwd; + + // Determine the current working directory to use for file suggestions. If the last entry is a valid path, trim any directory prefixes off the entry and set the new working directory. + if (lastEntry.type == TokenType.PATH && lastEntry.value) { + const { cwd: resolvedCwd, pathy } = await resolveCwdToken(lastEntry, this.cwd, this.shell); + if (pathy) { + this.cwd = resolvedCwd; + const lastEntryValue = lastEntry.value; + const lastSepIndex = lastEntryValue.lastIndexOf(getPathSep(this.shell)); + if (lastSepIndex != -1) { + const lastEntryPathToken: PathToken = { + type: TokenType.PATH, + value: lastEntryValue.slice(lastSepIndex + 1), + prefix: lastEntryValue.slice(0, lastSepIndex + 1), + }; + lastEntry = lastEntryPathToken; + this.lastEntry = lastEntry; + } + + log.debug("cwd", this.cwd); + } else { + lastEntry.type = TokenType.ARGUMENT; + this.lastEntry = lastEntry; + } + log.debug("resolvedCwd", resolvedCwd); + } + + switch (this.curState) { + case ParserState.Subcommand: { + log.debug("subcommands: ", this.subcommands, this.options, lastEntry); + // The parser never got to matching options or arguments, so suggest all available for the current spec. + if (lastEntry.type == TokenType.WHITESPACE) { + log.debug("lastEntry is space"); + const arg = getFirst(this.spec?.args); + if (arg) { + await this.addSuggestionsForArg(arg, false); + } + if (this.spec?.additionalSuggestions) { + this.filterSuggestionsAndAddToMap( + this.spec?.additionalSuggestions.map((s) => (typeof s === "string" ? { name: s } : s)), + this.spec?.filterStrategy, + lastEntry, + "subcommand" + ); + } + this.addSuggestionsForOptionsAndFlags(); + } + this.addSuggestionsForSubcommands(); + break; + } + case ParserState.Option: + case ParserState.PosixFlag: { + log.debug("option or posix flag"); + const availableOptions = Object.values(this.availableOptions); + if (lastEntry.type == TokenType.WHITESPACE) { + // The parser is currently matching options or subcommand arguments, so suggest all available options. + // TODO: this feels messy, not sure if there's a better way to do this + if (this.curState == ParserState.Option || !this.optionsMustPrecedeArguments) { + this.addSuggestionsToMap(availableOptions, "option"); + } + } else { + switch (this.prevState) { + case ParserState.Option: { + // The parser is currently matching options, so suggest all available options. + const suggestionsToAdd: Fig.Suggestion[] = availableOptions.filter((option) => + startsWithAny(option.name, lastEntry.value ?? "") + ); + if (this.currentOption) { + suggestionsToAdd.push(this.currentOption); + } + this.addSuggestionsToMap(suggestionsToAdd, "option"); + break; + } + case ParserState.PosixFlag: { + if (this.currentOption) { + const existingFlags = lastEntry.value?.slice(1) ?? ""; + + const newOption = modifyPosixFlags(this.currentOption, existingFlags); + // Suggest the other available flags as additional suggestions + this.addSuggestionsForOptionsAndFlags(); + // Push the last flag as a suggestion, in case that is as far as the user wants to go + this.addSuggestionsToMap([newOption], "option"); + } else { + this.addSuggestionsToMap(availableOptions, "option"); + } + break; + } + default: + // This should never happen. + break; + } + } + break; + } + case ParserState.SubcommandArgument: + log.debug("SubCommandArgument", "currentArgs: ", this.args, "argIndex: ", this.subcommandArgIndex); + // The parser is currently matching option arguments, so suggest all available arguments for the current option. + if (this.args && this.subcommandArgIndex < this.args.length) { + const arg = this.args[this.subcommandArgIndex]; + if (arg) { + await this.addSuggestionsForArg(arg, false); + } + } + break; + case ParserState.OptionArgument: { + // The parser is currently matching option arguments, so suggest all available arguments for the current option. + const option = this.currentOption; + log.debug("OptionArgument", "currentArgs: ", option.args, "argIndex: ", this.subcommandArgIndex); + + const args = getAll(option.args); + const argIndex = this.optionArgIndex; + if (args && argIndex < args.length) { + const arg = args[argIndex]; + if (arg) { + if (option.requiresSeparator) { + log.debug( + "requiresSeparator", + option.requiresSeparator, + this.entries[this.entryIndex - 1] + ); + const prefixStr = + this.entries[this.entryIndex - 1] + + (option.requiresSeparator === true ? "=" : option.requiresSeparator); + log.debug("prefixStr", prefixStr); + await this.addSuggestionsForArg(arg, false, prefixStr); + } else { + await this.addSuggestionsForArg(arg, false); + } + } + } + break; + } + default: + // This should never happen. + break; + } + + if (this.suggestions.size == 0) { + await this.addSuggestionsForFilepaths(); + } + + // Add history but use the original cwd, not the overridden one + await this.addSuggestionsForHistory(originalCwd); + + const suggestionsArr = Array.from(this.suggestions.values()); + sortSuggestions(suggestionsArr); + log.debug("suggestionsArr", suggestionsArr); + return suggestionsArr; + } + + return []; + } +} diff --git a/src/autocomplete/runtime/runtime.ts b/src/autocomplete/runtime/runtime.ts new file mode 100644 index 0000000000..08067d81d3 --- /dev/null +++ b/src/autocomplete/runtime/runtime.ts @@ -0,0 +1,58 @@ +import { Shell } from "../utils/shell"; +import { Newton } from "./newton"; +import { MemCache } from "@/util/memcache"; +import log from "../utils/log"; +import { Token, whitespace } from "./model"; +import { determineTokenType } from "./utils"; + +const parserCache = new MemCache(1000 * 60 * 5); + +const controlOperators = new Set(["||", "&&", ";;", "|&", "<(", ">>", ">&", "&", ";", "(", ")", "|", "<", ">"]); + +/** + * Starting from the end of the entry array, find the last sequence of strings, stopping when a non-string (i.e. an operand) is found. + * @param entry The command line to search. + * @returns The last sequence of strings, i.e. the last statement. If no strings are found, returns an empty array. + */ +function findLastStmt(entry: string, shell: Shell): Token[] { + const entrySplit = entry.split(/\s+/g); + log.debug(`Entry split: ${entrySplit}`); + let entries: Token[] = []; + for (let i = entrySplit.length - 1; i >= 0; i--) { + let entryValue = entrySplit[i].valueOf(); + if (controlOperators.has(entryValue)) { + break; + } else if (entryValue) { + entries.unshift({ value: entryValue, type: determineTokenType(entryValue, shell) }); + } + } + return entries; +} + +export async function getSuggestions(curLine: string, cwd: string, shell: Shell): Promise { + if (!curLine) { + return []; + } + const lastStmt = findLastStmt(curLine, shell); + log.debug(`Last statement: ${lastStmt}`); + if (curLine.endsWith(" ")) { + // shell-quote doesn't include trailing space in parse. We need to know this to determine if we should suggest subcommands + lastStmt.push(whitespace); + } + const lastStmtStr = lastStmt.slice(0, lastStmt.length - 2).join(" "); + // let parser: Newton = parserCache.get(lastStmtStr); + // if (parser) { + // console.log("Using cached parser"); + // parser.cwd = cwd; + // parser.shell = shell; + // parser.entries = lastStmt; + // parser.entryIndex = parser.entryIndex - 1; + // } else { + // console.log("Creating new parser"); + // parser = new Newton(undefined, lastStmt, cwd, shell); + // } + const parser: Newton = new Newton(undefined, lastStmt, cwd, shell); + const retVal = await parser.generateSuggestions(); + parserCache.put(lastStmtStr, parser); + return retVal; +} diff --git a/src/autocomplete/runtime/suggestion.ts b/src/autocomplete/runtime/suggestion.ts new file mode 100644 index 0000000000..d26dc039e9 --- /dev/null +++ b/src/autocomplete/runtime/suggestion.ts @@ -0,0 +1,47 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Modified from https://github.com/microsoft/inshellisense/blob/main/src/runtime/suggestion.ts +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +enum SuggestionIcons { + File = "📄", + Folder = "📁", + Subcommand = "đŸ“Ļ", + Option = "🔗", + Argument = "💲", + Mixin = "đŸī¸", + Shortcut = "đŸ”Ĩ", + Special = "⭐", + Default = "📀", +} + +export const getIcon = (icon: string | undefined, suggestionType: Fig.SuggestionType | undefined): string => { + // TODO: enable fig icons once spacing is better + // if (icon && /[^\u0000-\u00ff]/.test(icon)) { + // return icon; + // } + switch (suggestionType) { + case "arg": + return SuggestionIcons.Argument; + case "file": + return SuggestionIcons.File; + case "folder": + return SuggestionIcons.Folder; + case "option": + return SuggestionIcons.Option; + case "subcommand": + return SuggestionIcons.Subcommand; + case "mixin": + return SuggestionIcons.Mixin; + case "shortcut": + return SuggestionIcons.Shortcut; + case "special": + return SuggestionIcons.Special; + default: + return SuggestionIcons.Default; + } +}; + +export type FilterStrategy = "fuzzy" | "prefix" | "default"; diff --git a/src/autocomplete/runtime/template.ts b/src/autocomplete/runtime/template.ts new file mode 100644 index 0000000000..f22b72b60a --- /dev/null +++ b/src/autocomplete/runtime/template.ts @@ -0,0 +1,110 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Modified from https://github.com/microsoft/inshellisense/blob/main/src/runtime/template.ts +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { GlobalModel } from "@/models"; +import log from "../utils/log"; + +/** + * Retrieves the contents of the specified directory on the active remote machine. + * @param cwd The directory whose contents should be returned. + * @param tempType The template to use when returning the contents. If "folders" is passed, only the directories within the specified directory will be returned. Otherwise, all the contents will be returned. + * @returns The contents of the directory formatted to the specified template. + */ +export const getFileCompletionSuggestions = async ( + cwd: string, + tempType: "filepaths" | "folders" +): Promise => { + const comptype = tempType === "filepaths" ? "file" : "directory"; + if (comptype == null) return []; + const crtn = await GlobalModel.submitCommand("_compfiledir", null, [], { comptype, cwd }, false, false); + if (Array.isArray(crtn?.update?.data)) { + if (crtn.update.data.length === 0) return []; + const firstData = crtn.update.data[0]; + if (firstData.info?.infocomps) { + if (firstData.info.infocomps.length === 0) return []; + if (firstData.info.infocomps[0] === "(no completions)") return []; + return firstData.info.infocomps.map((comp: string) => { + log.debug("getFileCompletionSuggestions", cwd, comp); + return { + name: comp, + displayName: comp, + priority: comp.startsWith(".") ? 1 : 55, + context: { templateType: tempType }, + type: comp.endsWith("/") ? "folder" : "file", + }; + }); + } else { + return []; + } + } +}; + +const historyTemplate = (): Fig.TemplateSuggestion[] => { + const inputModel = GlobalModel.inputModel; + const cmdLine = inputModel.curLine; + inputModel.loadHistory(false, 0, "session"); + const hitems = GlobalModel.inputModel.filteredHistoryItems; + if (hitems.length > 0) { + const hmap: Map = new Map(); + hitems.forEach((h) => { + const cmdstr = h.cmdstr.trim(); + if (cmdstr.startsWith(cmdLine)) { + if (hmap.has(cmdstr)) { + hmap.get(cmdstr).priority += 1; + } else { + hmap.set(cmdstr, { + name: cmdstr, + priority: 90, + context: { + templateType: "history", + }, + icon: "🕒", + type: "special", + }); + } + } + }); + const ret = Array.from(hmap.values()); + log.debug("historyTemplate ret", ret); + return ret; + } + return []; +}; + +// TODO: implement help template +const helpTemplate = (): Fig.TemplateSuggestion[] => { + return []; +}; + +export const runTemplates = async ( + template: Fig.TemplateStrings[] | Fig.Template, + cwd: string +): Promise => { + const templates = template instanceof Array ? template : [template]; + log.debug("runTemplates", templates, cwd); + return ( + await Promise.all( + templates.map(async (t) => { + try { + switch (t) { + case "filepaths": + return await getFileCompletionSuggestions(cwd, "filepaths"); + case "folders": + return await getFileCompletionSuggestions(cwd, "folders"); + case "history": + return historyTemplate(); + case "help": + return helpTemplate(); + } + } catch (e) { + log.debug({ msg: "template failed", e, template: t, cwd }); + return []; + } + }) + ) + ).flat(); +}; diff --git a/src/autocomplete/runtime/utils.ts b/src/autocomplete/runtime/utils.ts new file mode 100644 index 0000000000..4179af6fb1 --- /dev/null +++ b/src/autocomplete/runtime/utils.ts @@ -0,0 +1,352 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Modified from https://github.com/microsoft/inshellisense/blob/main/src/runtime/utils.ts +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Shell } from "../utils/shell"; +import { GlobalModel, getApi } from "@/models"; +import { MemCache } from "@/util/memcache"; +import log from "../utils/log"; +import { Token, TokenType } from "./model"; + +export type ExecuteShellCommandTTYResult = { + code: number | null; +}; + +const commandResultCache = new MemCache(1000 * 60 * 5); + +export const buildExecuteShellCommand = + (timeout: number): Fig.ExecuteCommandFunction => + async (input: Fig.ExecuteCommandInput): Promise => { + const cachedResult = commandResultCache.get(input); + log.debug("cachedResult", cachedResult); + if (cachedResult) { + log.debug("Using cached result for", input); + return cachedResult; + } + log.debug("Executing command", input); + const { command, args, cwd, env } = input; + const resp = await GlobalModel.submitEphemeralCommand( + "eval", + null, + [[command, ...args].join(" ")], + null, + false, + { + expectsresponse: true, + overridecwd: cwd, + env: env, + timeoutms: timeout, + } + ); + + const { stdout, stderr } = await GlobalModel.getEphemeralCommandOutput(resp); + const output: Fig.ExecuteCommandOutput = { stdout, stderr, status: stderr?.length > 1 ? 1 : 0 }; + if (output.status !== 0) { + log.debug("Command failed, skipping caching", output); + } else { + commandResultCache.put(input, output); + } + return output; + }; + +const pathSeps = new Map(); + +/** + * Get the path separator for the given shell. + * @param shell The shell to get the path separator for. + * @returns The path separator. + */ +export function getPathSep(shell: Shell): string { + if (!pathSeps.has(shell)) { + const pathSep = getApi().pathSep(); + pathSeps.set(shell, pathSep); + return pathSep; + } + return pathSeps.get(shell) as string; +} + +export async function getEnvironmentVariables(cwd?: string): Promise> { + const resp = await GlobalModel.submitEphemeralCommand("eval", null, ["env"], null, false, { + expectsresponse: true, + overridecwd: cwd, + env: null, + }); + const { stdout, stderr } = await GlobalModel.getEphemeralCommandOutput(resp); + if (stderr) { + log.debug({ msg: "failed to get environment variables", stderr }); + } + + const env = {}; + stdout + .split("\n") + .filter((s) => s.length > 0) + .forEach((line) => { + const [key, value] = line.split("="); + env[key] = value; + }); + return env; +} + +/** + * Determine if the current token is a path or not. If it is an incomplete path, return the base name of the path as the new cwd to be used in downstream parsing operations. Otherwise, return the current cwd. + * @param token The token to check. + * @param cwd The current working directory. + * @param shell The shell being used. + * @returns The new cwd, whether the token is a path, and whether the path is complete. + */ +export async function resolveCwdToken( + token: Token, + cwd: string, + shell: Shell +): Promise<{ cwd: string; pathy: boolean; complete: boolean }> { + log.debug("resolveCwdToken start", { token, cwd }); + if (!token?.value) return { cwd, pathy: false, complete: false }; + log.debug("resolveCwdToken token not null"); + if (token.type != TokenType.PATH) return { cwd, pathy: false, complete: false }; + const sep = getPathSep(shell); + const complete = token.value.endsWith(sep); + const dirname = getApi().pathDirName(token.value); + log.debug("resolveCwdToken dirname", dirname); + + // This accounts for cases where the somewhat dumb path.dirname function parses a path out of a token that is not a path, like "git commit -m 'foo/bar'" + if (dirname !== "." && !token.value.startsWith(dirname)) return { cwd, pathy: false, complete: false }; + + let respCwd = await resolvePathRemote(complete ? token.value : dirname); + const exists = respCwd !== undefined; + respCwd = respCwd ? (respCwd?.endsWith(sep) ? respCwd : respCwd + sep) : cwd; + log.debug("resolveCwdToken", { token, cwd, complete, dirname, respCwd, exists }); + return { cwd: respCwd, pathy: exists, complete: complete && exists }; +} + +/** + * Determine if the given path exists on the remote machine. + * @param path The path to check. + * @returns True if the path exists. + */ +export async function resolvePathRemote(path: string): Promise { + const resp = await GlobalModel.submitEphemeralCommand( + "eval", + null, + [`if [ -d "${path}" ]; then cd "${path}" || return 1; pwd; else return 1; fi`], + null, + false, + { + expectsresponse: true, + env: {}, + } + ); + const output = await GlobalModel.getEphemeralCommandOutput(resp); + log.debug("resolvePathRemote", path, output); + return output.stderr?.length > 0 ? undefined : output.stdout.trimEnd(); +} + +/** + * Runs the comparator function on each value and returns true if any of them match + * @param values The value(s) to check + * @param comparator The function to use to compare the values + * @returns True if any of the values match the comparator + */ +export function matchAny(values: Fig.SingleOrArray, comparator: (a: T) => boolean) { + if (Array.isArray(values)) { + for (const value of values) { + if (comparator(value)) { + return true; + } + } + return false; + } else { + return comparator(values); + } +} + +/** + * Checks if any of the values start with the input string + * @param values The value(s) to check + * @param input The input to check against + * @returns True if any of the values start with the input + */ +export function startsWithAny(values: Fig.SingleOrArray, input: string) { + return matchAny(values, (a) => a.startsWith(input)); +} + +/** + * Checks if any of the values are not equal to the input + * @param values The value(s) to check + * @param input The input to check against + * @returns True if any of the values are not equal to the input + */ +export function equalsAny(values: Fig.SingleOrArray, input: T) { + return matchAny(values, (a) => a == input); +} + +/** + * Checks if any of the values of the SingleOrArray are not in the array + * @param values The value(s) to check + * @param arr The array to check against + * @returns True if any of the values are not in the array + */ +export function notInAny(values: Fig.SingleOrArray, arr: T[]) { + return matchAny(values, (a) => !arr.includes(a)); +} + +/** + * Get the first element of a Fig.SingleOrArray. + * @param values Either a single value or an array of values of the specified type. + * @returns The first element of the array, or the value. + */ +export function getFirst(values: Fig.SingleOrArray): T { + if (Array.isArray(values)) { + return values[0]; + } + return values; +} + +/** + * Get all elements of a Fig.SingleOrArray + * @param values Either a single value or an array of values of the specified type. + * @returns The array of values, or an empty array + */ +export function getAll(values: Fig.SingleOrArray | undefined): T[] { + if (Array.isArray(values)) { + return values; + } + return [values as T]; +} + +/** + * Checks if a string is an option, i.e. starts with "--". + * @param value The string to check. + * @returns True if the string is an option. + */ +export function isOption(value: string): boolean { + return value.startsWith("--"); +} + +/** + * Checks if a string is a flag, i.e. starts with "-" but not "--". + * @param value The string to check. + * @returns True if the string is a flag. + */ +export function isFlag(value: string): boolean { + return value.startsWith("-") && !isOption(value); +} + +/** + * Checks if a string is either a flag or an option, i.e. starts with "-". + * @param value The string to check. + * @returns True if the string is a flag or an option. + */ +export function isFlagOrOption(value: string): boolean { + return value.startsWith("-"); +} + +export function isPath(value: string, shell: Shell): boolean { + return value.includes(getPathSep(shell)); +} + +/** + * Get the flag of a Fig.SingleOrArray. + * @param values Either a string or an array of strings. + * @returns The flag, or undefined if none is found. + */ +export function getFlag(values: Fig.SingleOrArray): string | undefined { + if (Array.isArray(values)) { + for (const value of values) { + if (isFlag(value)) { + return value; + } + } + } else if (isFlag(values)) { + return values; + } + return undefined; +} + +export function determineTokenType(value: string, shell: Shell): TokenType { + if (isOption(value)) { + return TokenType.OPTION; + } else if (isFlag(value)) { + return TokenType.FLAG; + } else if (isPath(value, shell)) { + return TokenType.PATH; + } else { + return TokenType.ARGUMENT; + } +} + +/** + * Checks if an option suggestion contains a flag. If so, modifies the name to include the preceding flags. + * @param option The option to modify. + * @param precedingFlags The preceding flags to prepend to the suggestion name. + * @returns The modified option suggestion. + */ +export function modifyPosixFlags(option: Fig.Option, precedingFlags: string): Fig.Option { + // We only want to modify the name if the option is a flag + if (option.name) { + // Get the name of the flag without the preceding "-" + const name = getFlag(option.name)?.slice(1); + + if (name) { + // Shallow copy the option so we can modify the name without modifying the original spec. + option = { ...option }; + + // We want to prepend the existing flags to the name, except for the suggestion of the last flag (i.e. the `c` of an input `-abc`), which we want to replace with the existing flags. + // The end result is that we will suggest -abc instead of -a -b -c. We do not want -abb. The case of -aba should already be covered by filterFlags. + if (name === precedingFlags?.at(-1)) { + option.name = "-" + precedingFlags; + } else { + option.name = "-" + precedingFlags + name; + } + } + } + return option; +} + +/** + * Sort suggestions in-place by priority, then by name. + * @param suggestions The suggestions to sort. + */ +export function sortSuggestions(suggestions: Fig.Suggestion[]) { + suggestions.sort((a, b) => { + if (a.priority == b.priority) { + if (a.name) { + if (b.name) { + return getFirst(a.name).trim().localeCompare(getFirst(b.name)); + } else { + return -1; + } + } else if (b.name) { + return 1; + } + } + return (b.priority ?? 0) - (a.priority ?? 0); + }); +} + +/** + * Merge two subcommand objects, with the second subcommand taking precedence in case of conflicts. + * @param subcommand1 The first subcommand. + * @param subcommand2 The second subcommand. + * @returns The merged subcommand. + */ +export function mergeSubcomands(subcommand1: Fig.Subcommand, subcommand2: Fig.Subcommand): Fig.Subcommand { + log.debug("merging two subcommands", subcommand1, subcommand2); + const newCommand: Fig.Subcommand = { ...subcommand1 }; + + // Merge the generated spec with the existing spec + for (const key in subcommand2) { + if (Array.isArray(subcommand2[key])) { + newCommand[key] = [...subcommand2[key], ...(newCommand[key] ?? [])]; + continue; + } else if (typeof subcommand2[key] === "object") { + newCommand[key] = { ...subcommand2[key], ...(newCommand[key] ?? {}) }; + } else { + newCommand[key] = subcommand2[key]; + } + } + log.debug("merged subcommand:", newCommand); + return newCommand; +} diff --git a/src/autocomplete/utils/log.ts b/src/autocomplete/utils/log.ts new file mode 100644 index 0000000000..21ad3e477a --- /dev/null +++ b/src/autocomplete/utils/log.ts @@ -0,0 +1,17 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Modified from https://github.com/microsoft/inshellisense/blob/main/src/utils/log.ts +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { GlobalModel } from "@/models"; + +export const debug = (...content) => { + if (!GlobalModel.autocompleteModel.loggingEnabled) { + return; + } + console.log("[autocomplete]", ...content); +}; + +export default { debug }; diff --git a/src/autocomplete/utils/shell.ts b/src/autocomplete/utils/shell.ts new file mode 100644 index 0000000000..e593316c42 --- /dev/null +++ b/src/autocomplete/utils/shell.ts @@ -0,0 +1,16 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Modified from https://github.com/microsoft/inshellisense/blob/main/src/utils/shell.ts +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export enum Shell { + Bash = "bash", + Powershell = "powershell", + Pwsh = "pwsh", + Zsh = "zsh", + Fish = "fish", + Cmd = "cmd", + Xonsh = "xonsh", +} diff --git a/src/electron/emain.ts b/src/electron/emain.ts new file mode 100644 index 0000000000..3a384ef66c --- /dev/null +++ b/src/electron/emain.ts @@ -0,0 +1,1010 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as electron from "electron"; +import { autoUpdater } from "electron-updater"; +import * as path from "path"; +import * as fs from "fs"; +import fetch from "node-fetch"; +import * as child_process from "node:child_process"; +import { debounce } from "throttle-debounce"; +import * as winston from "winston"; +import * as util from "util"; +import * as waveutil from "../util/util"; +import { sprintf } from "sprintf-js"; +import { handleJsonFetchResponse, fireAndForget } from "@/util/util"; +import { v4 as uuidv4 } from "uuid"; +import { adaptFromElectronKeyEvent, setKeyUtilPlatform } from "@/util/keyutil"; +import { platform } from "os"; + +const WaveAppPathVarName = "WAVETERM_APP_PATH"; +const WaveDevVarName = "WAVETERM_DEV"; +const AuthKeyFile = "waveterm.authkey"; +const DevServerEndpoint = "http://127.0.0.1:8090"; +const ProdServerEndpoint = "http://127.0.0.1:1619"; + +const isDev = process.env[WaveDevVarName] != null; +const waveHome = getWaveHomeDir(); +const DistDir = isDev ? "dist-dev" : "dist"; +const instanceId = uuidv4(); +const oldConsoleLog = console.log; + +let GlobalAuthKey = ""; +let wasActive = true; +let wasInFg = true; +let currentGlobalShortcut: string | null = null; +let initialClientData: ClientDataType = null; + +checkPromptMigrate(); +ensureDir(waveHome); + +// these are either "darwin/amd64" or "darwin/arm64" +// normalize darwin/x64 to darwin/amd64 for GOARCH compatibility +const unamePlatform = process.platform; +let unameArch: string = process.arch; +if (unameArch == "x64") { + unameArch = "amd64"; +} +const loggerTransports: winston.transport[] = [ + new winston.transports.File({ filename: path.join(waveHome, "waveterm-app.log"), level: "info" }), +]; +if (isDev) { + loggerTransports.push(new winston.transports.Console()); +} +const loggerConfig = { + level: "info", + format: winston.format.combine( + winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), + winston.format.printf((info) => `${info.timestamp} ${info.message}`) + ), + transports: loggerTransports, +}; +const logger = winston.createLogger(loggerConfig); +function log(...msg: any[]) { + try { + logger.info(util.format(...msg)); + } catch (e) { + oldConsoleLog(...msg); + } +} +console.log = log; +console.log( + sprintf( + "waveterm-app starting, WAVETERM_HOME=%s, electronpath=%s gopath=%s arch=%s/%s", + waveHome, + getElectronAppBasePath(), + getGoAppBasePath(), + unamePlatform, + unameArch + ) +); +if (isDev) { + console.log("waveterm-app WAVETERM_DEV set"); +} +const app = electron.app; +app.setName(isDev ? "Wave (Dev)" : "Wave"); +let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null; +let waveSrvShouldRestart = false; + +electron.dialog.showErrorBox = (title, content) => { + oldConsoleLog("ERROR", title, content); +}; + +// must match golang +function getWaveHomeDir() { + let waveHome = process.env.WAVETERM_HOME; + if (waveHome == null) { + let homeDir = process.env.HOME; + if (homeDir == null) { + homeDir = "/"; + } + waveHome = path.join(homeDir, isDev ? ".waveterm-dev" : ".waveterm"); + } + return waveHome; +} + +function checkPromptMigrate() { + const waveHome = getWaveHomeDir(); + if (isDev || fs.existsSync(waveHome)) { + // don't migrate if we're running dev version or if wave home directory already exists + return; + } + if (process.env.HOME == null) { + return; + } + const homeDir: string = process.env.HOME; + const promptHome: string = path.join(homeDir, "prompt"); + if (!fs.existsSync(promptHome) || !fs.existsSync(path.join(promptHome, "prompt.db"))) { + // make sure we have a valid prompt home directory (prompt.db must exist inside) + return; + } + // rename directory, and then rename db and authkey files + fs.renameSync(promptHome, waveHome); + fs.renameSync(path.join(waveHome, "prompt.db"), path.join(waveHome, "waveterm.db")); + if (fs.existsSync(path.join(waveHome, "prompt.db-wal"))) { + fs.renameSync(path.join(waveHome, "prompt.db-wal"), path.join(waveHome, "waveterm.db-wal")); + } + if (fs.existsSync(path.join(waveHome, "prompt.db-shm"))) { + fs.renameSync(path.join(waveHome, "prompt.db-shm"), path.join(waveHome, "waveterm.db-shm")); + } + if (fs.existsSync(path.join(waveHome, "prompt.authkey"))) { + fs.renameSync(path.join(waveHome, "prompt.authkey"), path.join(waveHome, "waveterm.authkey")); + } +} + +/** + * Gets the base path to the Electron app resources. For dev, this is the root of the project. For packaged apps, this is the app.asar archive. + * @returns The base path of the Electron application + */ +function getElectronAppBasePath(): string { + return path.dirname(__dirname); +} + +/** + * Gets the base path to the Go backend. If the app is packaged as an asar, the path will be in a separate unpacked directory. + * @returns The base path of the Go backend + */ +function getGoAppBasePath(): string { + const appDir = getElectronAppBasePath(); + if (appDir.endsWith(".asar")) { + return `${appDir}.unpacked`; + } else { + return appDir; + } +} + +function getBaseHostPort(): string { + if (isDev) { + return DevServerEndpoint; + } + return ProdServerEndpoint; +} + +function getWaveSrvPath(): string { + if (isDev) { + return path.join(getGoAppBasePath(), "bin", "wavesrv"); + } + return path.join(getGoAppBasePath(), "bin", `wavesrv.${unameArch}`); +} + +function getWaveSrvCmd(): string { + const waveSrvPath = getWaveSrvPath(); + const waveHome = getWaveHomeDir(); + const logFile = path.join(waveHome, "wavesrv.log"); + return `"${waveSrvPath}" >> "${logFile}" 2>&1`; +} + +function getWaveSrvCwd(): string { + return getWaveHomeDir(); +} + +function ensureDir(dir: fs.PathLike) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); +} + +function readAuthKey(): string { + const homeDir = getWaveHomeDir(); + const authKeyFileName = path.join(homeDir, AuthKeyFile); + if (!fs.existsSync(authKeyFileName)) { + const authKeyStr = String(uuidv4()); + fs.writeFileSync(authKeyFileName, authKeyStr, { mode: 0o600 }); + return authKeyStr; + } + const authKeyData = fs.readFileSync(authKeyFileName); + const authKeyStr = String(authKeyData); + if (authKeyStr == null || authKeyStr == "") { + throw new Error("cannot read authkey"); + } + return authKeyStr.trim(); +} +const reloadAcceleratorKey = unamePlatform == "darwin" ? "Option+R" : "Super+R"; +const cmdOrAlt = process.platform === "darwin" ? "Cmd" : "Alt"; + +let viewSubMenu: Electron.MenuItemConstructorOptions[] = []; +viewSubMenu.push({ role: "reload", accelerator: reloadAcceleratorKey }); +viewSubMenu.push({ role: "toggleDevTools" }); +if (isDev) { + viewSubMenu.push({ + label: "Toggle Dev UI", + click: (_, window) => { + window?.webContents.send("toggle-devui"); + }, + }); +} +viewSubMenu.push({ type: "separator" }); +viewSubMenu.push({ + label: "Actual Size", + accelerator: cmdOrAlt + "+0", + click: (_, window) => { + window?.webContents.setZoomFactor(1); + window?.webContents.send("zoom-changed"); + }, +}); +viewSubMenu.push({ + label: "Zoom In", + accelerator: cmdOrAlt + "+Plus", + click: (_, window) => { + if (window == null) { + return; + } + const zoomFactor = window.webContents.getZoomFactor(); + window.webContents.setZoomFactor(zoomFactor * 1.1); + window.webContents.send("zoom-changed"); + }, +}); +viewSubMenu.push({ + label: "Zoom Out", + accelerator: cmdOrAlt + "+-", + click: (_, window) => { + if (window == null) { + return; + } + const zoomFactor = window.webContents.getZoomFactor(); + window.webContents.setZoomFactor(zoomFactor / 1.1); + window.webContents.send("zoom-changed"); + }, +}); +viewSubMenu.push({ type: "separator" }); +viewSubMenu.push({ role: "togglefullscreen" }); +const menuTemplate: Electron.MenuItemConstructorOptions[] = [ + { + role: "appMenu", + submenu: [ + { + label: "About Wave Terminal", + click: (_, window) => { + window?.webContents.send("menu-item-about"); + }, + }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { + label: "Hide", + click: () => { + app.hide(); + }, + }, + { role: "hideOthers" }, + { type: "separator" }, + { role: "quit" }, + ], + }, + { + role: "editMenu", + }, + { + role: "viewMenu", + submenu: viewSubMenu, + }, + { + role: "windowMenu", + }, +]; + +const menu = electron.Menu.buildFromTemplate(menuTemplate); +electron.Menu.setApplicationMenu(menu); + +function getMods(input: any): object { + return { meta: input.meta, shift: input.shift, ctrl: input.control, alt: input.alt }; +} + +function shNavHandler(event: Electron.Event, url: string) { + event.preventDefault(); + if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) { + console.log("open external, shNav", url); + electron.shell.openExternal(url); + } else { + console.log("navigation canceled", url); + } +} + +function shFrameNavHandler(event: Electron.Event) { + if (!event.frame?.parent) { + // only use this handler to process iframe events (non-iframe events go to shNavHandler) + return; + } + const url = event.url; + console.log(`frame-navigation url=${url} frame=${event.frame.name}`); + if (event.frame.name == "webview") { + // "webview" links always open in new window + // this will *not* effect the initial load because srcdoc does not count as an electron navigation + console.log("open external, frameNav", url); + event.preventDefault(); + electron.shell.openExternal(url); + return; + } + if (event.frame.name == "pdfview" && url.startsWith("blob:file:///")) { + // allowed + return; + } + event.preventDefault(); + console.log("frame navigation canceled"); +} + +function createWindow(clientData: ClientDataType | null): Electron.BrowserWindow { + const bounds = calcBounds(clientData); + setKeyUtilPlatform(platform()); + const win = new electron.BrowserWindow({ + x: bounds.x, + y: bounds.y, + titleBarStyle: "hiddenInset", + width: bounds.width, + height: bounds.height, + minWidth: 800, + minHeight: 600, + icon: + unamePlatform == "linux" + ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") + : undefined, + webPreferences: { + preload: path.join(getElectronAppBasePath(), DistDir, "preload.js"), + }, + show: false, + autoHideMenuBar: true, + }); + win.once("ready-to-show", () => { + win.show(); + }); + const indexHtml = isDev ? "index-dev.html" : "index.html"; + win.loadFile(path.join(getElectronAppBasePath(), "public", indexHtml)); + win.webContents.on("before-input-event", (e, input) => { + const waveEvent = adaptFromElectronKeyEvent(input); + if (win.isFocused()) { + wasActive = true; + } + if (input.type != "keyDown") { + return; + } + }); + win.webContents.on("will-navigate", shNavHandler); + win.webContents.on("will-frame-navigate", shFrameNavHandler); + win.on( + "resize", + debounce(400, (e) => mainResizeHandler(e, win)) + ); + win.on( + "move", + debounce(400, (e) => mainResizeHandler(e, win)) + ); + win.on("focus", () => { + wasInFg = true; + wasActive = true; + }); + win.webContents.on("zoom-changed", (e) => { + win.webContents.send("zoom-changed"); + }); + win.webContents.setWindowOpenHandler(({ url, frameName }) => { + if (url.startsWith("https://docs.waveterm.dev/") || url.startsWith("https://legacydocs.waveterm.dev")) { + console.log("openExternal docs", url); + electron.shell.openExternal(url); + } else if (url.startsWith("https://discord.gg/")) { + console.log("openExternal discord", url); + electron.shell.openExternal(url); + } else if (url.startsWith("https://extern/?")) { + const qmark = url.indexOf("?"); + const param = url.substring(qmark + 1); + const newUrl = decodeURIComponent(param); + console.log("openExternal extern", newUrl); + electron.shell.openExternal(newUrl); + } else if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { + console.log("openExternal fallback", url); + electron.shell.openExternal(url); + } + console.log("window-open denied", url); + return { action: "deny" }; + }); + return win; +} + +function mainResizeHandler(_: any, win: Electron.BrowserWindow) { + if (win == null || win.isDestroyed() || win.fullScreen) { + return; + } + const bounds = win.getBounds(); + const winSize = { width: bounds.width, height: bounds.height, top: bounds.y, left: bounds.x }; + const url = new URL(getBaseHostPort() + "/api/set-winsize"); + const fetchHeaders = getFetchHeaders(); + fetch(url, { method: "post", body: JSON.stringify(winSize), headers: fetchHeaders }) + .then((resp) => handleJsonFetchResponse(url, resp)) + .catch((err) => { + console.log("error setting winsize", err); + }); +} + +function mainPowerHandler(status: string) { + const url = new URL(getBaseHostPort() + "/api/power-monitor"); + const fetchHeaders = getFetchHeaders(); + const body = { status: status }; + fetch(url, { method: "post", body: JSON.stringify(body), headers: fetchHeaders }) + .then((resp) => handleJsonFetchResponse(url, resp)) + .catch((err) => { + console.log("error setting power monitor state", err); + }); +} + +function calcBounds(clientData: ClientDataType): Electron.Rectangle { + const primaryDisplay = electron.screen.getPrimaryDisplay(); + const pdBounds = primaryDisplay.bounds; + const size = { x: 100, y: 100, width: pdBounds.width - 200, height: pdBounds.height - 200 }; + if (clientData?.winsize?.width > 0) { + const cwinSize = clientData.winsize; + if (cwinSize.width > 0) { + size.width = cwinSize.width; + } + if (cwinSize.height > 0) { + size.height = cwinSize.height; + } + if (cwinSize.top >= 0) { + size.y = cwinSize.top; + } + if (cwinSize.left >= 0) { + size.x = cwinSize.left; + } + } + if (size.width < 300) { + size.width = 300; + } + if (size.height < 300) { + size.height = 300; + } + if (pdBounds.width < size.width) { + size.width = pdBounds.width; + } + if (pdBounds.height < size.height) { + size.height = pdBounds.height; + } + if (pdBounds.width < size.x + size.width) { + size.x = pdBounds.width - size.width; + } + if (pdBounds.height < size.y + size.height) { + size.y = pdBounds.height - size.height; + } + return size; +} + +app.on("window-all-closed", () => { + if (unamePlatform !== "darwin") app.quit(); +}); + +electron.ipcMain.on("toggle-developer-tools", (event) => { + const window = getWindowForEvent(event); + if (window != null) { + window.webContents.toggleDevTools(); + } + event.returnValue = true; +}); + +function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electron.Menu { + const menuItems: electron.MenuItem[] = []; + for (const menuDef of menuDefArr) { + const menuItemTemplate: electron.MenuItemConstructorOptions = { + role: menuDef.role as any, + label: menuDef.label, + type: menuDef.type, + click: (_, window) => { + window?.webContents.send("contextmenu-click", menuDef.id); + }, + }; + if (menuDef.submenu != null) { + menuItemTemplate.submenu = convertMenuDefArrToMenu(menuDef.submenu); + } + const menuItem = new electron.MenuItem(menuItemTemplate); + menuItems.push(menuItem); + } + return electron.Menu.buildFromTemplate(menuItems); +} + +function getWindowForEvent(event: Electron.IpcMainEvent): Electron.BrowserWindow { + const windowId = event.sender.id; + return electron.BrowserWindow.fromId(windowId); +} + +electron.ipcMain.on("contextmenu-show", (event, menuDefArr: ElectronContextMenuItem[], { x, y }) => { + if (menuDefArr == null || menuDefArr.length == 0) { + return; + } + const menu = convertMenuDefArrToMenu(menuDefArr); + menu.popup({ x, y }); + event.returnValue = true; +}); + +electron.ipcMain.on("hide-window", (event) => { + const window = getWindowForEvent(event); + if (window) { + window.hide(); + } + event.returnValue = true; +}); + +electron.ipcMain.on("get-id", (event) => { + event.returnValue = instanceId + ":" + event.processId; +}); + +electron.ipcMain.on("get-platform", (event) => { + event.returnValue = unamePlatform; +}); + +electron.ipcMain.on("get-isdev", (event) => { + event.returnValue = isDev; +}); + +electron.ipcMain.on("get-authkey", (event) => { + event.returnValue = GlobalAuthKey; +}); + +electron.ipcMain.on("wavesrv-status", (event) => { + event.returnValue = waveSrvProc != null; +}); + +electron.ipcMain.on("get-initial-termfontfamily", (event) => { + event.returnValue = initialClientData?.feopts?.termfontfamily; +}); + +electron.ipcMain.on("restart-server", (event) => { + if (waveSrvProc != null) { + waveSrvProc.kill(); + waveSrvShouldRestart = true; + return; + } else { + runWaveSrv(); + } + event.returnValue = true; +}); + +electron.ipcMain.on("reload-window", (event) => { + const window = getWindowForEvent(event); + if (window) { + window.reload(); + } + event.returnValue = true; +}); + +electron.ipcMain.on("open-external-link", (_, url) => fireAndForget(() => electron.shell.openExternal(url))); + +electron.ipcMain.on("reregister-global-shortcut", (event, shortcut: string) => { + reregisterGlobalShortcut(shortcut); + event.returnValue = true; +}); + +electron.ipcMain.on("get-last-logs", (event, numberOfLines) => { + fireAndForget(async () => { + try { + const logPath = path.join(getWaveHomeDir(), "wavesrv.log"); + const lastLines = await readLastLinesOfFile(logPath, numberOfLines); + event.reply("last-logs", lastLines); + } catch (err) { + console.error("Error reading log file:", err); + event.reply("last-logs", "Error reading log file."); + } + }); +}); + +electron.ipcMain.on("get-shouldusedarkcolors", (event) => { + event.returnValue = electron.nativeTheme.shouldUseDarkColors; +}); + +electron.ipcMain.on("get-nativethemesource", (event) => { + event.returnValue = electron.nativeTheme.themeSource; +}); + +electron.ipcMain.on("set-nativethemesource", (event, themeSource: "system" | "light" | "dark") => { + electron.nativeTheme.themeSource = themeSource; + event.returnValue = true; +}); + +electron.nativeTheme.on("updated", () => { + electron.BrowserWindow.getAllWindows().forEach((win) => { + win.webContents.send("nativetheme-updated"); + }); +}); + +electron.ipcMain.on("path-basename", (event, p) => { + event.returnValue = path.basename(p); +}); + +electron.ipcMain.on("path-dirname", (event, p) => { + event.returnValue = path.dirname(p); +}); + +electron.ipcMain.on("path-sep", (event) => { + event.returnValue = path.sep; +}); + +function readLastLinesOfFile(filePath: string, lineCount: number) { + return new Promise((resolve, reject) => { + child_process.exec(`tail -n ${lineCount} "${filePath}"`, (err, stdout, stderr) => { + if (err) { + reject(err.message); + return; + } + if (stderr) { + reject(stderr); + return; + } + resolve(stdout); + }); + }); +} + +function getContextMenu(): electron.Menu { + const menu = new electron.Menu(); + const menuItem = new electron.MenuItem({ label: "Testing", click: () => console.log("click testing!") }); + menu.append(menuItem); + return menu; +} + +function getFetchHeaders() { + return { + "x-authkey": GlobalAuthKey, + }; +} + +async function getClientDataPoll(loopNum: number): Promise { + const lastTime = loopNum >= 30; + const cdata = await getClientData(!lastTime, loopNum); + if (lastTime || cdata != null) { + return cdata; + } + await sleep(200); + return getClientDataPoll(loopNum + 1); +} + +async function getClientData(willRetry: boolean, retryNum: number): Promise { + const url = new URL(getBaseHostPort() + "/api/get-client-data"); + const fetchHeaders = getFetchHeaders(); + return fetch(url, { headers: fetchHeaders }) + .then((resp) => handleJsonFetchResponse(url, resp)) + .then((data) => { + if (data == null) { + return null; + } + return data.data; + }) + .catch((err) => { + if (willRetry) { + console.log("error getting client-data from wavesrv, will retry", "(" + retryNum + ")"); + } else { + console.log("error getting client-data from wavesrv, failed: ", err); + } + return null; + }); +} + +function sendWSSC() { + electron.BrowserWindow.getAllWindows().forEach((win) => { + if (waveSrvProc == null) { + win.webContents.send("wavesrv-status-change", false); + } else { + win.webContents.send("wavesrv-status-change", true, waveSrvProc.pid); + } + }); +} + +function runWaveSrv() { + let pResolve: (value: unknown) => void; + let pReject: (reason?: any) => void; + const rtnPromise = new Promise((argResolve, argReject) => { + pResolve = argResolve; + pReject = argReject; + }); + const envCopy = { ...process.env }; + envCopy[WaveAppPathVarName] = getGoAppBasePath(); + if (isDev) { + envCopy[WaveDevVarName] = "1"; + } + const waveSrvCmd = getWaveSrvCmd(); + console.log("trying to run local server", waveSrvCmd); + const proc = child_process.execFile("bash", ["-c", waveSrvCmd], { + cwd: getWaveSrvCwd(), + env: envCopy, + }); + proc.on("exit", (e) => { + console.log("wavesrv exit", e); + waveSrvProc = null; + sendWSSC(); + pReject(new Error(sprintf("failed to start local server (%s)", waveSrvCmd))); + if (waveSrvShouldRestart) { + waveSrvShouldRestart = false; + this.runWaveSrv(); + } + }); + proc.on("spawn", (e) => { + console.log("spawnned wavesrv"); + waveSrvProc = proc; + pResolve(true); + setTimeout(() => { + sendWSSC(); + }, 100); + }); + proc.on("error", (e) => { + console.log("error running wavesrv", e); + }); + proc.stdout.on("data", (_) => { + return; + }); + proc.stderr.on("data", (_) => { + return; + }); + return rtnPromise; +} + +electron.ipcMain.on("context-editmenu", (_, { x, y }, opts) => { + if (opts == null) { + opts = {}; + } + console.log("context-editmenu"); + const menu = new electron.Menu(); + if (opts.showCut) { + const menuItem = new electron.MenuItem({ label: "Cut", role: "cut" }); + menu.append(menuItem); + } + let menuItem = new electron.MenuItem({ label: "Copy", role: "copy" }); + menu.append(menuItem); + menuItem = new electron.MenuItem({ label: "Paste", role: "paste" }); + menu.append(menuItem); + menu.popup({ x, y }); +}); + +async function createWindowWrap() { + let clientData: ClientDataType | null = null; + try { + clientData = await getClientDataPoll(1); + initialClientData = clientData; + } catch (e) { + console.log("error getting wavesrv clientdata", e.toString()); + } + const win = createWindow(clientData); + if (clientData?.winsize.fullscreen) { + win.setFullScreen(true); + } + configureAutoUpdaterStartup(clientData); +} + +async function sleep(ms: number) { + return new Promise((resolve, _) => setTimeout(resolve, ms)); +} + +function logActiveState() { + const activeState = { fg: wasInFg, active: wasActive, open: true }; + const url = new URL(getBaseHostPort() + "/api/log-active-state"); + const fetchHeaders = getFetchHeaders(); + fetch(url, { method: "post", body: JSON.stringify(activeState), headers: fetchHeaders }) + .then((resp) => handleJsonFetchResponse(url, resp)) + .catch((err) => { + console.log("error logging active state", err); + }); + // for next iteration + wasInFg = electron.BrowserWindow.getFocusedWindow()?.isFocused() ?? false; + wasActive = false; +} + +// this isn't perfect, but gets the job done without being complicated +function runActiveTimer() { + logActiveState(); + setTimeout(runActiveTimer, 60000); +} + +function reregisterGlobalShortcut(shortcut: string) { + if (shortcut == "") { + shortcut = null; + } + if (currentGlobalShortcut == shortcut) { + return; + } + if (!waveutil.isBlank(currentGlobalShortcut)) { + if (electron.globalShortcut.isRegistered(currentGlobalShortcut)) { + electron.globalShortcut.unregister(currentGlobalShortcut); + } + } + if (waveutil.isBlank(shortcut)) { + currentGlobalShortcut = null; + return; + } + const ok = electron.globalShortcut.register(shortcut, async () => { + console.log("global shortcut triggered, showing window"); + if (electron.BrowserWindow.getAllWindows().length == 0) { + await createWindowWrap(); + } + const winToShow = electron.BrowserWindow.getFocusedWindow() ?? electron.BrowserWindow.getAllWindows()[0]; + winToShow?.show(); + }); + console.log("registered global shortcut", shortcut, ok ? "ok" : "failed"); + if (!ok) { + currentGlobalShortcut = null; + console.log("failed to register global shortcut", shortcut); + } + currentGlobalShortcut = shortcut; +} + +// ====== AUTO-UPDATER ====== // +let autoUpdateLock = false; +let autoUpdateEnabled = false; +let autoUpdateInterval: NodeJS.Timeout | null = null; +let availableUpdateReleaseName: string | null = null; +let availableUpdateReleaseNotes: string | null = null; +let appUpdateStatus = "unavailable"; +let lastUpdateCheck: Date = null; + +/** + * Sets the app update status and sends it to the main window + * @param status The AppUpdateStatus to set, either "ready" or "unavailable" + */ +function setAppUpdateStatus(status: string) { + appUpdateStatus = status; + electron.BrowserWindow.getAllWindows().forEach((window) => { + window.webContents.send("app-update-status", appUpdateStatus); + }); +} + +/** + * Checks if an hour has passed since the last update check, and if so, checks for updates using the `autoUpdater` object + */ +function checkForUpdates() { + if (!autoUpdateEnabled) { + return; + } + const now = new Date(); + if (!lastUpdateCheck || Math.abs(now.getTime() - lastUpdateCheck.getTime()) > 3600000) { + fireAndForget(() => autoUpdater.checkForUpdates()); + lastUpdateCheck = now; + } +} + +/** + * Initializes the auto-updater and sets up event listeners + * @returns The interval at which the auto-updater checks for updates + */ +function initUpdater(): NodeJS.Timeout { + if (isDev) { + console.log("skipping auto-updater in dev mode"); + return null; + } + + setAppUpdateStatus("unavailable"); + + autoUpdater.removeAllListeners(); + + autoUpdater.on("error", (err) => { + console.log("updater error"); + console.log(err); + }); + + autoUpdater.on("checking-for-update", () => { + console.log("checking-for-update"); + }); + + autoUpdater.on("update-available", () => { + console.log("update-available; downloading..."); + }); + + autoUpdater.on("update-not-available", () => { + console.log("update-not-available"); + }); + + autoUpdater.on("update-downloaded", (event) => { + console.log("update-downloaded", [event]); + availableUpdateReleaseName = event.releaseName; + availableUpdateReleaseNotes = event.releaseNotes as string | null; + + // Display the update banner and create a system notification + setAppUpdateStatus("ready"); + const updateNotification = new electron.Notification({ + title: "Wave Terminal", + body: "A new version of Wave Terminal is ready to install.", + }); + updateNotification.on("click", () => { + fireAndForget(() => installAppUpdate()); + }); + updateNotification.show(); + }); + + // check for updates right away and keep checking later + checkForUpdates(); + return setInterval(() => { + checkForUpdates(); + }, 600000); // intervals are unreliable when an app is suspended so we will check every 10 mins if an hour has passed. +} + +/** + * Prompts the user to install the downloaded application update and restarts the application + */ +async function installAppUpdate() { + const dialogOpts: Electron.MessageBoxOptions = { + type: "info", + buttons: ["Restart", "Later"], + title: "Application Update", + message: process.platform === "win32" ? availableUpdateReleaseNotes : availableUpdateReleaseName, + detail: "A new version has been downloaded. Restart the application to apply the updates.", + }; + + const allWindows = electron.BrowserWindow.getAllWindows(); + if (allWindows.length > 0) { + await electron.dialog + .showMessageBox(electron.BrowserWindow.getFocusedWindow() ?? allWindows[0], dialogOpts) + .then(({ response }) => { + if (response === 0) autoUpdater.quitAndInstall(); + }); + } +} + +electron.ipcMain.on("install-app-update", () => fireAndForget(() => installAppUpdate())); +electron.ipcMain.on("get-app-update-status", (event) => { + event.returnValue = appUpdateStatus; +}); + +electron.ipcMain.on("change-auto-update", (_, enable: boolean) => { + configureAutoUpdater(enable); +}); + +/** + * Configures the auto-updater based on the client data + * @param clientData The client data to use to configure the auto-updater. If the clientData has noreleasecheck set to true, the auto-updater will be disabled. + */ +function configureAutoUpdaterStartup(clientData: ClientDataType) { + if (clientData == null) { + configureAutoUpdater(false); + return; + } + configureAutoUpdater(!clientData.clientopts.noreleasecheck); +} + +/** + * Configures the auto-updater based on the user's preference + * @param enabled Whether the auto-updater should be enabled + */ +function configureAutoUpdater(enabled: boolean) { + // simple lock to prevent multiple auto-update configuration attempts, this should be very rare + if (autoUpdateLock) { + console.log("auto-update configuration already in progress, skipping"); + return; + } + + autoUpdateEnabled = enabled; + autoUpdateLock = true; + + if (autoUpdateEnabled && autoUpdateInterval == null) { + lastUpdateCheck = null; + try { + console.log("configuring auto updater"); + autoUpdateInterval = initUpdater(); + } catch (e) { + console.log("error configuring auto updater", e.toString()); + } + } else if (!autoUpdateEnabled && autoUpdateInterval != null) { + console.log("disabling auto updater"); + clearInterval(autoUpdateInterval); + autoUpdateInterval = null; + } + autoUpdateLock = false; +} +// ====== AUTO-UPDATER ====== // + +// ====== MAIN ====== // + +(async () => { + const instanceLock = app.requestSingleInstanceLock(); + if (!instanceLock) { + console.log("waveterm-app could not get single-instance-lock, shutting down"); + app.quit(); + return; + } + GlobalAuthKey = readAuthKey(); + try { + await runWaveSrv(); + } catch (e) { + console.log(e.toString()); + } + setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe + await app.whenReady(); + await createWindowWrap(); + + app.on("activate", () => { + if (electron.BrowserWindow.getAllWindows().length === 0) { + createWindowWrap().then(); + } + checkForUpdates(); + }); +})(); + +electron.powerMonitor.on("suspend", () => mainPowerHandler("suspend")); diff --git a/src/electron/preload.js b/src/electron/preload.js new file mode 100644 index 0000000000..2e33c62bb1 --- /dev/null +++ b/src/electron/preload.js @@ -0,0 +1,38 @@ +let { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("api", { + hideWindow: () => ipc.Renderer.send("hide-window"), + toggleDeveloperTools: () => ipcRenderer.send("toggle-developer-tools"), + getId: () => ipcRenderer.sendSync("get-id"), + getPlatform: () => ipcRenderer.sendSync("get-platform"), + getIsDev: () => ipcRenderer.sendSync("get-isdev"), + getAuthKey: () => ipcRenderer.sendSync("get-authkey"), + getWaveSrvStatus: () => ipcRenderer.sendSync("wavesrv-status"), + getLastLogs: (numberOfLines, callback) => { + ipcRenderer.send("get-last-logs", numberOfLines); + ipcRenderer.once("last-logs", (event, data) => callback(data)); + }, + getInitialTermFontFamily: () => ipcRenderer.sendSync("get-initial-termfontfamily"), + getShouldUseDarkColors: () => ipcRenderer.sendSync("get-shouldusedarkcolors"), + getNativeThemeSource: () => ipcRenderer.sendSync("get-nativethemesource"), + setNativeThemeSource: (source) => ipcRenderer.send("set-nativethemesource", source), + onNativeThemeUpdated: (callback) => ipcRenderer.on("nativetheme-updated", callback), + restartWaveSrv: () => ipcRenderer.sendSync("restart-server"), + reloadWindow: () => ipcRenderer.sendSync("reload-window"), + reregisterGlobalShortcut: (shortcut) => ipcRenderer.sendSync("reregister-global-shortcut", shortcut), + openExternalLink: (url) => ipcRenderer.send("open-external-link", url), + changeAutoUpdate: (enabled) => ipcRenderer.send("change-auto-update", enabled), + installAppUpdate: () => ipcRenderer.send("install-app-update"), + getAppUpdateStatus: () => ipcRenderer.sendSync("get-app-update-status"), + onAppUpdateStatus: (callback) => ipcRenderer.on("app-update-status", (_, val) => callback(val)), + onZoomChanged: (callback) => ipcRenderer.on("zoom-changed", callback), + onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback), + contextEditMenu: (position, opts) => ipcRenderer.send("context-editmenu", position, opts), + onWaveSrvStatusChange: (callback) => ipcRenderer.on("wavesrv-status-change", callback), + onToggleDevUI: (callback) => ipcRenderer.on("toggle-devui", callback), + pathBaseName: (path) => ipcRenderer.sendSync("path-basename", path), + pathDirName: (path) => ipcRenderer.sendSync("path-dirname", path), + pathSep: () => ipcRenderer.sendSync("path-sep"), + showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position), + onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", callback), +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000000..8c99a11e5d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,35 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import * as React from "react"; +import { createRoot } from "react-dom/client"; +import { sprintf } from "sprintf-js"; +import { App } from "@/app/app"; +import * as DOMPurify from "dompurify"; +import { loadFonts } from "@/util/fontutil"; +import * as textmeasure from "@/util/textmeasure"; + +// @ts-ignore +let VERSION = __WAVETERM_VERSION__; +// @ts-ignore +let BUILD = __WAVETERM_BUILD__; + +loadFonts(); + +document.addEventListener("DOMContentLoaded", () => { + let reactElem = React.createElement(App, null, null); + let elem = document.getElementById("app"); + let root = createRoot(elem); + document.fonts.ready.then(() => { + root.render(reactElem); + }); +}); + +// put some items on the window for debugging +(window as any).mobx = mobx; +(window as any).sprintf = sprintf; +(window as any).DOMPurify = DOMPurify; +(window as any).textmeasure = textmeasure; + +console.log("WaveTerm", VERSION, BUILD); diff --git a/src/models/autocomplete.ts b/src/models/autocomplete.ts new file mode 100644 index 0000000000..c4d6f3e118 --- /dev/null +++ b/src/models/autocomplete.ts @@ -0,0 +1,232 @@ +import { Shell, getSuggestions } from "@/autocomplete"; +import log from "@/autocomplete/utils/log"; +import { Model } from "./model"; +import * as mobx from "mobx"; + +/** + * Gets the length of the token at the end of the line. + * @param line the line + * @returns the length of the token at the end of the line + */ +function getEndTokenLength(line: string): number { + if (!line) { + return 0; + } + const lastSpaceIndex = line.lastIndexOf(" "); + if (lastSpaceIndex < line.length) { + return line.length - line.lastIndexOf(" ") - 1; + } + return line.length; +} + +/** + * The autocomplete model. + */ +export class AutocompleteModel { + globalModel: Model; + @mobx.observable suggestions: Fig.Suggestion[] = null; + @mobx.observable primarySuggestionIndex: number = 0; + charsToDrop: number = 0; + @mobx.observable loggingEnabled: boolean; + + constructor(globalModel: Model) { + mobx.makeObservable(this); + this.globalModel = globalModel; + + this.loggingEnabled = globalModel.isDev; + + // This is a hack to get the suggestions to update after the history is loaded the first time + mobx.reaction( + () => this.globalModel.inputModel.historyItems.get() != null, + () => { + log.debug("history loaded, reloading suggestions"); + this.loadSuggestions(); + } + ); + } + + /** + * Returns whether the autocomplete feature is enabled. + * @returns whether the autocomplete feature is enabled + */ + @mobx.computed + get isEnabled(): boolean { + const clientData: ClientDataType = this.globalModel.clientData.get(); + return clientData?.clientopts.autocompleteenabled ?? false; + } + + /** + * Lazily loads suggestions for the current input line. + */ + loadSuggestions = mobx.flow(function* (this: AutocompleteModel) { + if (!this.isEnabled) { + this.suggestions = null; + return; + } + log.debug("get suggestions"); + try { + const festate = this.globalModel.getCurRemoteInstance().festate; + const suggestions: Fig.Suggestion[] = yield getSuggestions( + this.globalModel.inputModel.curLine, + festate.cwd, + festate.shell as Shell + ); + this.suggestions = suggestions; + } catch (error) { + console.error("error getting suggestions: ", error); + } + }); + + /** + * Returns the current suggestions. + * @returns the current suggestions + */ + getSuggestions(): Fig.Suggestion[] { + if (!this.isEnabled) { + return null; + } + + return this.suggestions; + } + + /** + * Clears the current suggestions. + */ + clearSuggestions(): void { + if (!this.isEnabled) { + return; + } + mobx.action(() => { + this.suggestions = null; + this.primarySuggestionIndex = 0; + })(); + } + + /** + * Returns the index of the primary suggestion. + * @returns the index of the primary suggestion + */ + getPrimarySuggestionIndex(): number { + return this.primarySuggestionIndex; + } + + /** + * Sets the index of the primary suggestion. + * @param index the index of the primary suggestion + */ + setPrimarySuggestionIndex(index: number): void { + if (!this.isEnabled) { + return; + } + mobx.action(() => { + this.primarySuggestionIndex = index; + })(); + } + + /** + * Returns the additional text required to add to the current input line in order to apply the suggestion at the given index. + * @param index the index of the suggestion to apply + * @returns the additional text required to add to the current input line in order to apply the suggestion at the given index + */ + getSuggestionCompletion(index: number): string { + log.debug("getSuggestionCompletion", index); + const autocompleteSuggestions: Fig.Suggestion[] = this.getSuggestions(); + + // Build the ghost prompt with the primary suggestion if available + let retVal = ""; + log.debug("autocompleteSuggestions", autocompleteSuggestions); + if (autocompleteSuggestions != null && autocompleteSuggestions.length > index) { + const suggestion = autocompleteSuggestions[index]; + log.debug("suggestion", suggestion); + + if (!suggestion) { + return null; + } + + if (suggestion.insertValue) { + retVal = suggestion.insertValue; + } else if (typeof suggestion.name === "string") { + retVal = suggestion.name; + } else if (suggestion.name.length > 0) { + retVal = suggestion.name[0]; + } + const curLine = this.globalModel.inputModel.curLine; + + if (retVal.startsWith(curLine.trim())) { + // This accounts for if the first suggestion is a history item, since this will be the full command string. + retVal = retVal.substring(curLine.length); + } else { + log.debug("retVal", retVal); + + // The following is a workaround for slow responses from underlying commands. It assumes that the primary suggestion will be a continuation of the current token. + // The runtime will provide a number of chars to drop, but it will return after the render has already completed, meaning we will end up with a flicker. This is a workaround to prevent the flicker. + // As we add more characters to the current token, we assume we need to drop the same number of characters from the primary suggestion, even if the runtime has not yet provided the updated characters to drop. + const curEndTokenLen = getEndTokenLength(curLine); + const lastEndTokenLen = getEndTokenLength(this.globalModel.inputModel.lastCurLine); + log.debug("curEndTokenLen", curEndTokenLen, "lastEndTokenLen", lastEndTokenLen); + if (curEndTokenLen > lastEndTokenLen) { + this.charsToDrop = Math.max(curEndTokenLen, this.charsToDrop ?? 0); + } else { + this.charsToDrop = Math.min(curEndTokenLen, this.charsToDrop ?? 0); + } + + if (this.charsToDrop > 0) { + retVal = retVal.substring(this.charsToDrop); + } + log.debug("charsToDrop", this.charsToDrop, "retVal", retVal); + } + log.debug("ghost prompt", curLine + retVal); + } + return retVal; + } + + /** + * Returns the additional text required to add to the current input line in order to apply the primary suggestion. + * @returns the additional text required to add to the current input line in order to apply the primary suggestion + * @see getSuggestionCompletion + * @see getPrimarySuggestionIndex + */ + getPrimarySuggestionCompletion(): string { + if (!this.isEnabled || !this.globalModel.inputModel.curLine) return null; + const suggestionIndex = this.getPrimarySuggestionIndex(); + const retVal = this.getSuggestionCompletion(suggestionIndex); + if (retVal) { + return retVal; + } else if (suggestionIndex > 0) { + this.setPrimarySuggestionIndex(0); + } + } + + /** + * Applies the suggestion at the given index to the current input line. + * @param index the index of the suggestion to apply + */ + applySuggestion(index: number): void { + if (!this.isEnabled) { + return; + } + let suggestionCompletion = this.getSuggestionCompletion(index); + log.debug("applying suggestion: ", suggestionCompletion); + if (suggestionCompletion) { + let pos: number; + const curLine = this.globalModel.inputModel.curLine; + if (suggestionCompletion.includes("{cursor}")) { + pos = curLine.length + suggestionCompletion.indexOf("{cursor}"); + suggestionCompletion = suggestionCompletion.replace("{cursor}", ""); + } + const newLine = curLine + suggestionCompletion; + pos = pos ?? newLine.length; + log.debug("new line", `"${newLine}"`, "pos", pos); + this.globalModel.inputModel.updateCmdLine({ str: newLine, pos: pos }); + } + } + + /** + * Applies the primary suggestion to the current input line. + * @see applySuggestion + * @see getPrimarySuggestionIndex + */ + applyPrimarySuggestion(): void { + this.applySuggestion(this.getPrimarySuggestionIndex()); + } +} diff --git a/src/models/bookmarks.ts b/src/models/bookmarks.ts new file mode 100644 index 0000000000..aebfe79cd9 --- /dev/null +++ b/src/models/bookmarks.ts @@ -0,0 +1,281 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { sprintf } from "sprintf-js"; +import { boundMethod } from "autobind-decorator"; +import { genMergeSimpleData } from "@/util/util"; +import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; +import { GlobalCommandRunner } from "./global"; +import { Model } from "./model"; + +class BookmarksModel { + globalModel: Model; + bookmarks: OArr = mobx.observable.array([], { + name: "Bookmarks", + }); + activeBookmark: OV = mobx.observable.box(null, { + name: "activeBookmark", + }); + editingBookmark: OV = mobx.observable.box(null, { + name: "editingBookmark", + }); + pendingDelete: OV = mobx.observable.box(null, { + name: "pendingDelete", + }); + copiedIndicator: OV = mobx.observable.box(null, { + name: "copiedIndicator", + }); + + tempDesc: OV = mobx.observable.box("", { + name: "bookmarkEdit-tempDesc", + }); + tempCmd: OV = mobx.observable.box("", { + name: "bookmarkEdit-tempCmd", + }); + + constructor(globalModel: Model) { + this.globalModel = globalModel; + } + + showBookmarksView(bmArr: BookmarkType[], selectedBookmarkId: string): void { + bmArr = bmArr ?? []; + mobx.action(() => { + this.reset(); + this.globalModel.activeMainView.set("bookmarks"); + this.bookmarks.replace(bmArr); + if (selectedBookmarkId != null) { + this.selectBookmark(selectedBookmarkId); + } + if (this.activeBookmark.get() == null && bmArr.length > 0) { + this.activeBookmark.set(bmArr[0].bookmarkid); + } + })(); + } + + reset(): void { + mobx.action(() => { + this.activeBookmark.set(null); + this.editingBookmark.set(null); + this.pendingDelete.set(null); + this.tempDesc.set(""); + this.tempCmd.set(""); + })(); + } + + closeView(): void { + this.globalModel.showSessionView(); + setTimeout(() => this.globalModel.inputModel.giveFocus(), 50); + } + + @boundMethod + clearPendingDelete(): void { + mobx.action(() => this.pendingDelete.set(null))(); + } + + useBookmark(bookmarkId: string): void { + let bm = this.getBookmark(bookmarkId); + if (bm == null) { + return; + } + mobx.action(() => { + this.reset(); + this.globalModel.showSessionView(); + this.globalModel.inputModel.curLine = bm.cmdstr; + setTimeout(() => this.globalModel.inputModel.giveFocus(), 50); + })(); + } + + selectBookmark(bookmarkId: string): void { + let bm = this.getBookmark(bookmarkId); + if (bm == null) { + return; + } + if (this.activeBookmark.get() == bookmarkId) { + return; + } + mobx.action(() => { + this.cancelEdit(); + this.activeBookmark.set(bookmarkId); + })(); + } + + cancelEdit(): void { + mobx.action(() => { + this.pendingDelete.set(null); + this.editingBookmark.set(null); + this.tempDesc.set(""); + this.tempCmd.set(""); + })(); + } + + confirmEdit(): void { + if (this.editingBookmark.get() == null) { + return; + } + let bm = this.getBookmark(this.editingBookmark.get()); + mobx.action(() => { + this.editingBookmark.set(null); + bm.description = this.tempDesc.get(); + bm.cmdstr = this.tempCmd.get(); + this.tempDesc.set(""); + this.tempCmd.set(""); + })(); + GlobalCommandRunner.editBookmark(bm.bookmarkid, bm.description, bm.cmdstr); + } + + handleDeleteBookmark(bookmarkId: string): void { + if (this.pendingDelete.get() == null || this.pendingDelete.get() != this.activeBookmark.get()) { + mobx.action(() => this.pendingDelete.set(this.activeBookmark.get()))(); + setTimeout(this.clearPendingDelete, 2000); + return; + } + GlobalCommandRunner.deleteBookmark(bookmarkId); + this.clearPendingDelete(); + } + + getBookmark(bookmarkId: string): BookmarkType { + if (bookmarkId == null) { + return null; + } + for (const bm of this.bookmarks) { + if (bm.bookmarkid == bookmarkId) { + return bm; + } + } + return null; + } + + getBookmarkPos(bookmarkId: string): number { + if (bookmarkId == null) { + return -1; + } + for (let i = 0; i < this.bookmarks.length; i++) { + let bm = this.bookmarks[i]; + if (bm.bookmarkid == bookmarkId) { + return i; + } + } + return -1; + } + + getActiveBookmark(): BookmarkType { + let activeBookmarkId = this.activeBookmark.get(); + return this.getBookmark(activeBookmarkId); + } + + handleEditBookmark(bookmarkId: string): void { + let bm = this.getBookmark(bookmarkId); + if (bm == null) { + return; + } + mobx.action(() => { + this.pendingDelete.set(null); + this.activeBookmark.set(bookmarkId); + this.editingBookmark.set(bookmarkId); + this.tempDesc.set(bm.description ?? ""); + this.tempCmd.set(bm.cmdstr ?? ""); + })(); + } + + handleCopyBookmark(bookmarkId: string): void { + let bm = this.getBookmark(bookmarkId); + if (bm == null) { + return; + } + navigator.clipboard.writeText(bm.cmdstr); + mobx.action(() => { + this.copiedIndicator.set(bm.bookmarkid); + })(); + setTimeout(() => { + mobx.action(() => { + this.copiedIndicator.set(null); + })(); + }, 600); + } + + mergeBookmarks(bmArr: BookmarkType[]): void { + mobx.action(() => { + genMergeSimpleData( + this.bookmarks, + bmArr, + (bm: BookmarkType) => bm.bookmarkid, + (bm: BookmarkType) => sprintf("%05d", bm.orderidx) + ); + })(); + } + + handleUserClose() { + if (this.editingBookmark.get() != null) { + this.cancelEdit(); + return; + } + this.closeView(); + } + + handleUserDelete() { + if (this.editingBookmark.get() != null) { + return; + } + if (this.activeBookmark.get() == null) { + return; + } + this.handleDeleteBookmark(this.activeBookmark.get()); + } + + handleUserNavigate(amt: number) { + if (this.editingBookmark.get() != null) { + return; + } + if (this.bookmarks.length == 0) { + return; + } + let newPos = 0; // if active is null, then newPos will be 0 (select the first) + if (this.activeBookmark.get() != null) { + let curIdx = this.getBookmarkPos(this.activeBookmark.get()); + newPos = curIdx + amt; + if (newPos < 0) { + newPos = 0; + } + if (newPos >= this.bookmarks.length) { + newPos = this.bookmarks.length - 1; + } + } + let bm = this.bookmarks[newPos]; + mobx.action(() => { + this.activeBookmark.set(bm.bookmarkid); + })(); + } + + handleUserConfirm() { + if (this.editingBookmark.get() != null) { + return; + } + if (this.activeBookmark.get() == null) { + return; + } + this.useBookmark(this.activeBookmark.get()); + } + + handleUserEdit() { + if (this.editingBookmark.get() != null) { + return; + } + if (this.activeBookmark.get() == null) { + return; + } + this.handleEditBookmark(this.activeBookmark.get()); + } + + handleUserCopy() { + if (this.editingBookmark.get() != null) { + return; + } + if (this.activeBookmark.get() == null) { + return; + } + this.handleCopyBookmark(this.activeBookmark.get()); + } +} + +export { BookmarksModel }; diff --git a/src/models/clientsettingsview.ts b/src/models/clientsettingsview.ts new file mode 100644 index 0000000000..ad177e7ab6 --- /dev/null +++ b/src/models/clientsettingsview.ts @@ -0,0 +1,26 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { Model } from "./model"; + +class ClientSettingsViewModel { + globalModel: Model; + + constructor(globalModel: Model) { + this.globalModel = globalModel; + } + + closeView(): void { + this.globalModel.showSessionView(); + setTimeout(() => this.globalModel.inputModel.giveFocus(), 50); + } + + showClientSettingsView(): void { + mobx.action(() => { + this.globalModel.activeMainView.set("clientsettings"); + })(); + } +} + +export { ClientSettingsViewModel }; diff --git a/src/models/cmd.ts b/src/models/cmd.ts new file mode 100644 index 0000000000..ea677c19d4 --- /dev/null +++ b/src/models/cmd.ts @@ -0,0 +1,144 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { stringToBase64 } from "@/util/util"; +import { TermWrap } from "@/plugins/terminal/term"; +import { cmdStatusIsRunning } from "@/app/line/lineutil"; +import { Model } from "./model"; + +const InputChunkSize = 500; +class Cmd { + model: Model; + screenId: string; + remote: RemotePtrType; + lineId: string; + data: OV; + + constructor(cmd: CmdDataType) { + this.model = Model.getInstance(); + this.screenId = cmd.screenid; + this.lineId = cmd.lineid; + this.remote = cmd.remote; + this.data = mobx.observable.box(cmd, { deep: false, name: "cmd-data" }); + } + + setCmd(cmd: CmdDataType) { + mobx.action(() => { + let origData = this.data.get(); + this.data.set(cmd); + if (origData != null && cmd != null && origData.status != cmd.status) { + this.model.cmdStatusUpdate(this.screenId, this.lineId, origData.status, cmd.status); + } + })(); + } + + getRestartTs(): number { + return this.data.get().restartts; + } + + getDurationMs(): number { + return this.data.get().durationms; + } + + getAsWebCmd(lineid: string): WebCmd { + let cmd = this.data.get(); + let remote = this.model.getRemote(this.remote.remoteid); + let webRemote: WebRemote = null; + if (remote != null) { + webRemote = { + remoteid: cmd.remote.remoteid, + alias: remote.remotealias, + canonicalname: remote.remotecanonicalname, + name: this.remote.name, + homedir: remote.remotevars["home"], + isroot: !!remote.remotevars["isroot"], + }; + } + let webCmd: WebCmd = { + screenid: cmd.screenid, + lineid: lineid, + remote: webRemote, + status: cmd.status, + cmdstr: cmd.cmdstr, + rawcmdstr: cmd.rawcmdstr, + festate: cmd.festate, + termopts: cmd.termopts, + cmdpid: cmd.cmdpid, + remotepid: cmd.remotepid, + donets: cmd.donets, + exitcode: cmd.exitcode, + durationms: cmd.durationms, + rtnstate: cmd.rtnstate, + vts: 0, + rtnstatestr: null, + }; + return webCmd; + } + + getExitCode(): number { + return this.data.get().exitcode; + } + + getRtnState(): boolean { + return this.data.get().rtnstate; + } + + getStatus(): string { + return this.data.get().status; + } + + getTermOpts(): TermOptsType { + return this.data.get().termopts; + } + + getTermMaxRows(): number { + let termOpts = this.getTermOpts(); + return termOpts?.rows; + } + + getCmdStr(): string { + return this.data.get().cmdstr; + } + + getRemoteFeState(): Record { + return this.data.get().festate; + } + + isRunning(): boolean { + let data = this.data.get(); + return cmdStatusIsRunning(data.status); + } + + handleData(data: string, termWrap: TermWrap): void { + if (!this.isRunning()) { + return; + } + for (let pos = 0; pos < data.length; pos += InputChunkSize) { + let dataChunk = data.slice(pos, pos + InputChunkSize); + this.handleInputChunk(dataChunk); + } + } + + handleDataFromRenderer(data: string, renderer: RendererModel): void { + if (!this.isRunning()) { + return; + } + for (let pos = 0; pos < data.length; pos += InputChunkSize) { + let dataChunk = data.slice(pos, pos + InputChunkSize); + this.handleInputChunk(dataChunk); + } + } + + handleInputChunk(data: string): void { + let inputPacket: FeInputPacketType = { + type: "feinput", + ck: this.screenId + "/" + this.lineId, + remote: this.remote, + inputdata64: stringToBase64(data), + }; + this.model.sendInputPacket(inputPacket); + } +} + +export { Cmd }; diff --git a/src/models/commandrunner.ts b/src/models/commandrunner.ts new file mode 100644 index 0000000000..a2435a06b2 --- /dev/null +++ b/src/models/commandrunner.ts @@ -0,0 +1,557 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { GlobalModel } from "./global"; + +class CommandRunner { + private constructor() {} + + static getInstance(): CommandRunner { + if (!(window as any).GlobalCommandRunner) { + (window as any).GlobalCommandRunner = new CommandRunner(); + } + return (window as any).GlobalCommandRunner; + } + + loadHistory(show: boolean, htype: string) { + let kwargs = { nohist: "1" }; + if (!show) { + kwargs["noshow"] = "1"; + } + if (htype != null && htype != "screen") { + kwargs["type"] = htype; + } + GlobalModel.submitCommand("history", null, null, kwargs, true); + } + + resetShellState() { + GlobalModel.submitCommand("reset", null, null, null, true); + } + + historyPurgeLines(lines: string[]): Promise { + let prtn = GlobalModel.submitCommand("history", "purge", lines, { nohist: "1" }, false); + return prtn; + } + + switchView(view: string) { + mobx.action(() => { + GlobalModel.activeMainView.set(view); + })(); + } + + switchSession(session: string) { + mobx.action(() => { + GlobalModel.activeMainView.set("session"); + })(); + GlobalModel.submitCommand("session", null, [session], { nohist: "1" }, false); + } + + switchScreen(screen: string, session?: string) { + mobx.action(() => { + GlobalModel.activeMainView.set("session"); + })(); + let kwargs = { nohist: "1" }; + if (session != null) { + kwargs["session"] = session; + } + GlobalModel.submitCommand("screen", null, [screen], kwargs, false); + } + + lineView(sessionId: string, screenId: string, lineNum?: number) { + let screen = GlobalModel.getScreenById(sessionId, screenId); + if (screen != null && lineNum != null) { + screen.setAnchorFields(lineNum, 0, "line:view"); + } + let lineNumStr = lineNum == null || lineNum == 0 ? "E" : String(lineNum); + GlobalModel.submitCommand("line", "view", [sessionId, screenId, lineNumStr], { nohist: "1" }, false); + } + + lineArchive(lineArg: string, archive: boolean): Promise { + let kwargs = { nohist: "1" }; + let archiveStr = archive ? "1" : "0"; + return GlobalModel.submitCommand("line", "archive", [lineArg, archiveStr], kwargs, false); + } + + lineDelete(lineArg: string, interactive: boolean): Promise { + return GlobalModel.submitCommand("line", "delete", [lineArg], { nohist: "1" }, interactive); + } + + lineMinimize(lineId: string, minimize: boolean, interactive: boolean): Promise { + let minimizeStr = minimize ? "1" : "0"; + return GlobalModel.submitCommand("line", "minimize", [lineId, minimizeStr], { nohist: "1" }, interactive); + } + + lineRestart(lineArg: string, interactive: boolean): Promise { + return GlobalModel.submitCommand("line", "restart", [lineArg], { nohist: "1" }, interactive); + } + + lineSignal(lineArg: string, signal: string, interactive: boolean): Promise { + return GlobalModel.submitCommand("signal", null, [lineArg, signal], { nohist: "1" }, interactive); + } + + lineSet(lineArg: string, opts: { renderer?: string }): Promise { + let kwargs = { nohist: "1" }; + if ("renderer" in opts) { + kwargs["renderer"] = opts.renderer ?? ""; + } + return GlobalModel.submitCommand("line", "set", [lineArg], kwargs, false); + } + + ensureWorkspace() { + GlobalModel.submitCommand("session", "ensureone", null, { nohist: "1" }, true); + } + + createNewSession() { + GlobalModel.submitCommand("session", "open", null, { nohist: "1" }, false); + } + + createNewScreen() { + GlobalModel.submitCommand("screen", "open", null, { nohist: "1" }, false); + } + + closeScreen(screen: string) { + GlobalModel.submitCommand("screen", "close", [screen], { nohist: "1" }, false); + } + + // include is lineIds to include, exclude is lineIds to exclude + // if include is given then it *only* does those ids. if exclude is given (or not), + // it does all running commands in the screen except for excluded. + resizeScreen(screenId: string, rows: number, cols: number, opts?: { include?: string[]; exclude?: string[] }) { + let kwargs: Record = { + nohist: "1", + screen: screenId, + cols: String(cols), + rows: String(rows), + }; + if (opts?.include != null && opts?.include.length > 0) { + kwargs.include = opts.include.join(","); + } + if (opts?.exclude != null && opts?.exclude.length > 0) { + kwargs.exclude = opts.exclude.join(","); + } + GlobalModel.submitCommand("screen", "resize", null, kwargs, false); + } + + screenArchive(screenId: string, shouldArchive: boolean): Promise { + return GlobalModel.submitCommand( + "screen", + "archive", + [screenId, shouldArchive ? "1" : "0"], + { nohist: "1" }, + false + ); + } + + screenDelete(screenId: string, interactive: boolean): Promise { + return GlobalModel.submitCommand("screen", "delete", [screenId], { nohist: "1" }, interactive); + } + + screenWebShare(screenId: string, shouldShare: boolean): Promise { + let kwargs: Record = { nohist: "1" }; + kwargs["screen"] = screenId; + return GlobalModel.submitCommand("screen", "webshare", [shouldShare ? "1" : "0"], kwargs, false); + } + + showRemote(remoteid: string) { + GlobalModel.submitCommand("remote", "show", null, { nohist: "1", remote: remoteid }, true); + } + + showAllRemotes() { + GlobalModel.submitCommand("remote", "showall", null, { nohist: "1" }, true); + } + + connectRemote(remoteid: string) { + GlobalModel.submitCommand("remote", "connect", null, { nohist: "1", remote: remoteid }, true); + } + + disconnectRemote(remoteid: string) { + GlobalModel.submitCommand("remote", "disconnect", null, { nohist: "1", remote: remoteid }, true); + } + + installRemote(remoteid: string) { + GlobalModel.submitCommand("remote", "install", null, { nohist: "1", remote: remoteid }, true); + } + + installCancelRemote(remoteid: string) { + GlobalModel.submitCommand("remote", "installcancel", null, { nohist: "1", remote: remoteid }, true); + } + + createRemote(cname: string, kwargsArg: Record, interactive: boolean): Promise { + let kwargs = Object.assign({}, kwargsArg); + kwargs["nohist"] = "1"; + return GlobalModel.submitCommand("remote", "new", [cname], kwargs, interactive); + } + + openCreateRemote(): void { + GlobalModel.submitCommand("remote", "new", null, { nohist: "1", visual: "1" }, true); + } + + screenSetRemote(remoteArg: string, nohist: boolean, interactive: boolean): Promise { + let kwargs = {}; + if (nohist) { + kwargs["nohist"] = "1"; + } + return GlobalModel.submitCommand("connect", null, [remoteArg], kwargs, interactive); + } + + editRemote(remoteid: string, kwargsArg: Record): void { + let kwargs = Object.assign({}, kwargsArg); + kwargs["nohist"] = "1"; + kwargs["remote"] = remoteid; + GlobalModel.submitCommand("remote", "set", null, kwargs, true); + } + + openEditRemote(remoteid: string): void { + GlobalModel.submitCommand("remote", "set", null, { remote: remoteid, nohist: "1", visual: "1" }, true); + } + + archiveRemote(remoteid: string) { + GlobalModel.submitCommand("remote", "archive", null, { remote: remoteid, nohist: "1" }, true); + } + + importSshConfig() { + GlobalModel.submitCommand("remote", "parse", null, { nohist: "1", visual: "1" }, true); + } + + screenSelectLine(lineArg: string, focusVal?: string) { + let kwargs: Record = { + nohist: "1", + line: lineArg, + }; + if (focusVal != null) { + kwargs["focus"] = focusVal; + } + GlobalModel.submitCommand("screen", "set", null, kwargs, false); + } + + screenReorder(screenId: string, index: string) { + let kwargs: Record = { + nohist: "1", + screenId: screenId, + index: index, + }; + GlobalModel.submitCommand("screen", "reorder", null, kwargs, false); + } + + setTermUsedRows(termContext: RendererContext, height: number) { + let kwargs: Record = {}; + kwargs["screen"] = termContext.screenId; + kwargs["hohist"] = "1"; + let posargs = [String(termContext.lineNum), String(height)]; + GlobalModel.submitCommand("line", "setheight", posargs, kwargs, false); + } + + screenSetAnchor(sessionId: string, screenId: string, anchorVal: string): void { + let kwargs = { + nohist: "1", + anchor: anchorVal, + session: sessionId, + screen: screenId, + }; + GlobalModel.submitCommand("screen", "set", null, kwargs, false); + } + + screenSetFocus(focusVal: string): void { + GlobalModel.submitCommand("screen", "set", null, { focus: focusVal, nohist: "1" }, false); + } + + screenSetSettings( + screenId: string, + settings: { tabcolor?: string; tabicon?: string; name?: string; sharename?: string }, + interactive: boolean + ): Promise { + let kwargs: { [key: string]: any } = Object.assign({}, settings); + kwargs["nohist"] = "1"; + kwargs["screen"] = screenId; + return GlobalModel.submitCommand("screen", "set", null, kwargs, interactive); + } + + sessionArchive(sessionId: string, shouldArchive: boolean): Promise { + return GlobalModel.submitCommand( + "session", + "archive", + [sessionId, shouldArchive ? "1" : "0"], + { nohist: "1" }, + false + ); + } + + sessionDelete(sessionId: string): Promise { + return GlobalModel.submitCommand("session", "delete", [sessionId], { nohist: "1" }, false); + } + + sessionSetSettings(sessionId: string, settings: { name?: string }, interactive: boolean): Promise { + let kwargs = Object.assign({}, settings); + kwargs["nohist"] = "1"; + kwargs["session"] = sessionId; + return GlobalModel.submitCommand("session", "set", null, kwargs, interactive); + } + + lineStar(lineId: string, starVal: number) { + GlobalModel.submitCommand("line", "star", [lineId, String(starVal)], { nohist: "1" }, true); + } + + lineBookmark(lineId: string) { + GlobalModel.submitCommand("line", "bookmark", [lineId], { nohist: "1" }, true); + } + + linePin(lineId: string, val: boolean) { + GlobalModel.submitCommand("line", "pin", [lineId, val ? "1" : "0"], { nohist: "1" }, true); + } + + bookmarksView() { + GlobalModel.submitCommand("bookmarks", "show", null, { nohist: "1" }, true); + } + + connectionsView() { + GlobalModel.connectionViewModel.showConnectionsView(); + } + + clientSettingsView() { + GlobalModel.clientSettingsViewModel.showClientSettingsView(); + } + + syncShellState() { + GlobalModel.submitCommand("sync", null, null, { nohist: "1" }, false); + } + + historyView(params: HistorySearchParams) { + let kwargs = { nohist: "1" }; + kwargs["offset"] = String(params.offset); + kwargs["rawoffset"] = String(params.rawOffset); + if (params.searchText != null) { + kwargs["text"] = params.searchText; + } + if (params.searchSessionId != null) { + kwargs["searchsession"] = params.searchSessionId; + } + if (params.searchRemoteId != null) { + kwargs["searchremote"] = params.searchRemoteId; + } + if (params.fromTs != null) { + kwargs["fromts"] = String(params.fromTs); + } + if (params.noMeta) { + kwargs["meta"] = "0"; + } + if (params.filterCmds) { + kwargs["filter"] = "1"; + } + GlobalModel.submitCommand("history", "viewall", null, kwargs, true); + } + + telemetryOff(interactive: boolean): Promise { + return GlobalModel.submitCommand("telemetry", "off", null, { nohist: "1" }, interactive); + } + + telemetryOn(interactive: boolean): Promise { + return GlobalModel.submitCommand("telemetry", "on", null, { nohist: "1" }, interactive); + } + + releaseCheckAutoOff(interactive: boolean): Promise { + return GlobalModel.submitCommand("releasecheck", "autooff", null, { nohist: "1" }, interactive); + } + + releaseCheckAutoOn(interactive: boolean): Promise { + return GlobalModel.submitCommand("releasecheck", "autoon", null, { nohist: "1" }, interactive); + } + + setTermFontSize(fsize: number, interactive: boolean): Promise { + let kwargs = { + nohist: "1", + termfontsize: String(fsize), + }; + return GlobalModel.submitCommand("client", "set", null, kwargs, interactive); + } + + setTermFontFamily(fontFamily: string, interactive: boolean): Promise { + let kwargs = { + nohist: "1", + termfontfamily: fontFamily, + }; + return GlobalModel.submitCommand("client", "set", null, kwargs, interactive); + } + + setTheme(theme: NativeThemeSource, interactive: boolean): Promise { + let kwargs = { + nohist: "1", + theme: theme, + }; + return GlobalModel.submitCommand("client", "set", null, kwargs, interactive); + } + + setRootTermTheme(theme: string, interactive: boolean): Promise { + let ftheme = theme; + if (ftheme == "inherit") { + ftheme = ""; + } + let kwargs = { + nohist: "1", + termtheme: ftheme, + }; + return GlobalModel.submitCommand("client", "set", null, kwargs, interactive); + } + + setSessionTermTheme(sessionId: string, name: string, interactive: boolean): Promise { + let fname = name; + if (name == "inherit") { + fname = ""; + } + let kwargs = { + nohist: "1", + id: sessionId, + name: fname, + }; + return GlobalModel.submitCommand("session", "termtheme", null, kwargs, interactive); + } + + setScreenTermTheme(screenId: string, name: string, interactive: boolean): Promise { + let fname = name; + if (name == "inherit") { + fname = ""; + } + let kwargs = { + nohist: "1", + id: screenId, + name: fname, + }; + return GlobalModel.submitCommand("screen", "termtheme", null, kwargs, interactive); + } + + setClientOpenAISettings(opts: { + model?: string; + apitoken?: string; + maxtokens?: string; + baseurl?: string; + timeout?: string; + }): Promise { + let kwargs = { + nohist: "1", + }; + if (opts.model != null) { + kwargs["openaimodel"] = opts.model; + } + if (opts.apitoken != null) { + kwargs["openaiapitoken"] = opts.apitoken; + } + if (opts.maxtokens != null) { + kwargs["openaimaxtokens"] = opts.maxtokens; + } + if (opts.baseurl != null) { + kwargs["openaibaseurl"] = opts.baseurl; + } + if (opts.timeout != null) { + kwargs["openaitimeout"] = opts.timeout; + } + return GlobalModel.submitCommand("client", "set", null, kwargs, false); + } + + clientAcceptTos(): void { + GlobalModel.submitCommand("client", "accepttos", null, { nohist: "1" }, true); + } + + clientSetConfirmFlag(flag: string, value: boolean): Promise { + let kwargs = { nohist: "1" }; + let valueStr = value ? "1" : "0"; + return GlobalModel.submitCommand("client", "setconfirmflag", [flag, valueStr], kwargs, false); + } + + clientSetMainSidebar(width: number, collapsed: boolean): Promise { + let kwargs = { nohist: "1", width: `${width}`, collapsed: collapsed ? "1" : "0" }; + return GlobalModel.submitCommand("client", "setmainsidebar", null, kwargs, false); + } + + clientSetRightSidebar(width: number, collapsed: boolean): Promise { + let kwargs = { nohist: "1", width: `${width}`, collapsed: collapsed ? "1" : "0" }; + return GlobalModel.submitCommand("client", "setrightsidebar", null, kwargs, false); + } + + setSudoPwStore(store: string): Promise { + let kwargs = { + nohist: "1", + sudopwstore: store, + }; + return GlobalModel.submitCommand("client", "set", null, kwargs, false); + } + + setSudoPwTimeout(timeout: string): Promise { + let kwargs = { + nohist: "1", + sudopwtimeout: timeout, + }; + return GlobalModel.submitCommand("client", "set", null, kwargs, false); + } + + setSudoPwClearOnSleep(clear: boolean): Promise { + let kwargs = { + nohist: "1", + sudopwclearonsleep: String(clear), + }; + console.log(kwargs); + return GlobalModel.submitCommand("client", "set", null, kwargs, false); + } + + editBookmark(bookmarkId: string, desc: string, cmdstr: string) { + let kwargs = { + nohist: "1", + desc: desc, + cmdstr: cmdstr, + }; + GlobalModel.submitCommand("bookmark", "set", [bookmarkId], kwargs, true); + } + + deleteBookmark(bookmarkId: string): void { + GlobalModel.submitCommand("bookmark", "delete", [bookmarkId], { nohist: "1" }, true); + } + + openSharedSession(): void { + GlobalModel.submitCommand("session", "openshared", null, { nohist: "1" }, true); + } + + setLineState( + screenId: string, + lineId: string, + state: LineStateType, + interactive: boolean + ): Promise { + let stateStr = JSON.stringify(state); + return GlobalModel.submitCommand( + "line", + "set", + [lineId], + { screen: screenId, nohist: "1", state: stateStr }, + interactive + ); + } + + screenSidebarAddLine(lineId: string) { + GlobalModel.submitCommand("sidebar", "add", null, { nohist: "1", line: lineId }, false); + } + + screenSidebarRemove() { + GlobalModel.submitCommand("sidebar", "remove", null, { nohist: "1" }, false); + } + + screenSidebarClose(): void { + GlobalModel.submitCommand("sidebar", "close", null, { nohist: "1" }, false); + } + + screenSidebarOpen(width?: string): void { + let kwargs: Record = { nohist: "1" }; + if (width != null) { + kwargs.width = width; + } + GlobalModel.submitCommand("sidebar", "open", null, kwargs, false); + } + + setGlobalShortcut(shortcut: string): Promise { + return GlobalModel.submitCommand("client", "setglobalshortcut", [shortcut], { nohist: "1" }, false); + } + + setAutocompleteEnabled(enabled: boolean): Promise { + return GlobalModel.submitCommand("autocomplete", enabled ? "on" : "off", null, { nohist: "1" }, false); + } +} + +export { CommandRunner }; diff --git a/src/models/connectionsview.ts b/src/models/connectionsview.ts new file mode 100644 index 0000000000..b5fef5637c --- /dev/null +++ b/src/models/connectionsview.ts @@ -0,0 +1,27 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { Model } from "./model"; +import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; + +class ConnectionsViewModel { + globalModel: Model; + + constructor(globalModel: Model) { + this.globalModel = globalModel; + } + + closeView(): void { + this.globalModel.showSessionView(); + setTimeout(() => this.globalModel.inputModel.giveFocus(), 50); + } + + showConnectionsView(): void { + mobx.action(() => { + this.globalModel.activeMainView.set("connections"); + })(); + } +} + +export { ConnectionsViewModel }; diff --git a/src/models/contextmenu.ts b/src/models/contextmenu.ts new file mode 100644 index 0000000000..87fab75322 --- /dev/null +++ b/src/models/contextmenu.ts @@ -0,0 +1,50 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { Model } from "./model"; +import { v4 as uuidv4 } from "uuid"; + +class ContextMenuModel { + globalModel: Model; + handlers: Map void> = new Map(); // id -> handler + + constructor(globalModel: Model) { + this.globalModel = globalModel; + this.globalModel.getElectronApi().onContextMenuClick(this.handleContextMenuClick.bind(this)); + } + + handleContextMenuClick(e: any, id: string): void { + let handler = this.handlers.get(id); + if (handler) { + handler(); + } + } + + _convertAndRegisterMenu(menu: ContextMenuItem[]): ElectronContextMenuItem[] { + let electronMenuItems: ElectronContextMenuItem[] = []; + for (let item of menu) { + let electronItem: ElectronContextMenuItem = { + role: item.role, + type: item.type, + label: item.label, + id: uuidv4(), + }; + if (item.click) { + this.handlers.set(electronItem.id, item.click); + } + if (item.submenu) { + electronItem.submenu = this._convertAndRegisterMenu(item.submenu); + } + electronMenuItems.push(electronItem); + } + return electronMenuItems; + } + + showContextMenu(menu: ContextMenuItem[], position: { x: number; y: number }): void { + this.handlers.clear(); + const electronMenuItems = this._convertAndRegisterMenu(menu); + this.globalModel.getElectronApi().showContextMenu(electronMenuItems, position); + } +} + +export { ContextMenuModel }; diff --git a/src/models/forwardlinecontainer.ts b/src/models/forwardlinecontainer.ts new file mode 100644 index 0000000000..948b6f50d2 --- /dev/null +++ b/src/models/forwardlinecontainer.ts @@ -0,0 +1,115 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { TermWrap } from "@/plugins/terminal/term"; +import { windowWidthToCols, windowHeightToRows } from "@/util/textmeasure"; +import { MagicLayout } from "@/app/magiclayout"; +import { Model } from "./model"; +import { GlobalCommandRunner } from "./global"; +import { Cmd } from "./cmd"; +import { Screen } from "./screen"; +import * as lineutil from "@/app/line/lineutil"; + +class ForwardLineContainer { + globalModel: Model; + winSize: WindowSize; + screen: Screen; + containerType: LineContainerStrs; + lineId: string; + + constructor(screen: Screen, winSize: WindowSize, containerType: LineContainerStrs, lineId: string) { + this.globalModel = Model.getInstance(); + this.screen = screen; + this.winSize = winSize; + this.containerType = containerType; + this.lineId = lineId; + } + + screenSizeCallback(winSize: WindowSize): void { + this.winSize = winSize; + let termWrap = this.getTermWrap(this.lineId); + if (termWrap != null) { + let fontSize = this.globalModel.getTermFontSize(); + let cols = windowWidthToCols(winSize.width, fontSize); + let rows = windowHeightToRows(Model.getInstance().lineHeightEnv, this.winSize.height); + termWrap.resizeCols(cols); + GlobalCommandRunner.resizeScreen(this.screen.screenId, rows, cols, { include: [this.lineId] }); + } + } + + getContainerType(): LineContainerStrs { + return this.containerType; + } + + getCmd(line: LineType): Cmd { + return this.screen.getCmd(line); + } + + isSidebarOpen(): boolean { + return false; + } + + isLineIdInSidebar(lineId: string): boolean { + return false; + } + + setLineFocus(lineNum: number, focus: boolean): void { + this.screen.setLineFocus(lineNum, focus); + } + + setContentHeight(context: RendererContext, height: number): void { + return; + } + + getMaxContentSize(): WindowSize { + let rtn = { width: this.winSize.width, height: this.winSize.height }; + rtn.width = rtn.width - MagicLayout.ScreenMaxContentWidthBuffer; + return rtn; + } + + getIdealContentSize(): WindowSize { + return this.winSize; + } + + loadTerminalRenderer(elem: Element, line: LineType, cmd: Cmd, width: number): void { + this.screen.loadTerminalRenderer(elem, line, cmd, width); + } + + registerRenderer(lineId: string, renderer: RendererModel): void { + this.screen.registerRenderer(lineId, renderer); + } + + unloadRenderer(lineId: string): void { + this.screen.unloadRenderer(lineId); + } + + getContentHeight(context: RendererContext): number { + return this.screen.getContentHeight(context); + } + + getUsedRows(context: RendererContext, line: LineType, cmd: Cmd, width: number): number { + return this.screen.getUsedRows(context, line, cmd, width); + } + + getIsFocused(lineNum: number): boolean { + return this.screen.getIsFocused(lineNum); + } + + getRenderer(lineId: string): RendererModel { + return this.screen.getRenderer(lineId); + } + + getTermWrap(lineId: string): TermWrap { + return this.screen.getTermWrap(lineId); + } + + getFocusType(): FocusTypeStrs { + return this.screen.getFocusType(); + } + + getSelectedLine(): number { + return this.screen.getSelectedLine(); + } +} + +export { ForwardLineContainer }; diff --git a/src/models/global.ts b/src/models/global.ts new file mode 100644 index 0000000000..1bde77153d --- /dev/null +++ b/src/models/global.ts @@ -0,0 +1,6 @@ +import { Model } from "./model"; +import { CommandRunner } from "./commandrunner"; + +const GlobalModel: Model = Model.getInstance(); +const GlobalCommandRunner: CommandRunner = CommandRunner.getInstance(); +export { GlobalModel, GlobalCommandRunner }; diff --git a/src/models/historyview.ts b/src/models/historyview.ts new file mode 100644 index 0000000000..192b97d096 --- /dev/null +++ b/src/models/historyview.ts @@ -0,0 +1,312 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { boundMethod } from "autobind-decorator"; +import { isBlank } from "@/util/util"; +import { termWidthFromCols, termHeightFromRows } from "@/util/textmeasure"; +import dayjs from "dayjs"; +import * as appconst from "@/app/appconst"; +import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; +import { GlobalCommandRunner } from "./global"; +import { Model } from "./model"; +import { Cmd } from "./cmd"; +import { SpecialLineContainer } from "./speciallinecontainer"; + +const HistoryPageSize = 50; + +class HistoryViewModel { + globalModel: Model; + items: OArr = mobx.observable.array([], { + name: "HistoryItems", + }); + hasMore: OV = mobx.observable.box(false, { + name: "historyview-hasmore", + }); + offset: OV = mobx.observable.box(0, { name: "historyview-offset" }); + searchText: OV = mobx.observable.box("", { + name: "historyview-searchtext", + }); + activeSearchText: string = null; + selectedItems: OMap = mobx.observable.map({}, { name: "historyview-selectedItems" }); + deleteActive: OV = mobx.observable.box(false, { + name: "historyview-deleteActive", + }); + activeItem: OV = mobx.observable.box(null, { + name: "historyview-activeItem", + }); + searchSessionId: OV = mobx.observable.box(null, { + name: "historyview-searchSessionId", + }); + searchRemoteId: OV = mobx.observable.box(null, { + name: "historyview-searchRemoteId", + }); + searchShowMeta: OV = mobx.observable.box(true, { + name: "historyview-searchShowMeta", + }); + searchFromDate: OV = mobx.observable.box(null, { + name: "historyview-searchfromts", + }); + searchFilterCmds: OV = mobx.observable.box(true, { + name: "historyview-filtercmds", + }); + nextRawOffset: number = 0; + curRawOffset: number = 0; + + historyItemLines: LineType[] = []; + historyItemCmds: CmdDataType[] = []; + + specialLineContainer: SpecialLineContainer; + + constructor(globalModel: Model) { + this.globalModel = globalModel; + } + + closeView(): void { + this.globalModel.showSessionView(); + setTimeout(() => this.globalModel.inputModel.giveFocus(), 50); + } + + getLineById(lineId: string): LineType { + if (isBlank(lineId)) { + return null; + } + for (const line of this.historyItemLines) { + if (line.lineid == lineId) { + return line; + } + } + return null; + } + + getCmdById(lineId: string): Cmd { + if (isBlank(lineId)) { + return null; + } + for (const cmd of this.historyItemCmds) { + if (cmd.lineid == lineId) { + return new Cmd(cmd); + } + } + return null; + } + + getHistoryItemById(historyId: string): HistoryItem { + if (isBlank(historyId)) { + return null; + } + for (const hitem of this.items) { + if (hitem.historyid == historyId) { + return hitem; + } + } + return null; + } + + setActiveItem(historyId: string) { + if (this.activeItem.get() == historyId) { + return; + } + let hitem = this.getHistoryItemById(historyId); + mobx.action(() => { + if (hitem == null) { + this.activeItem.set(null); + this.specialLineContainer = null; + } else { + this.activeItem.set(hitem.historyid); + let width = termWidthFromCols(80, this.globalModel.getTermFontSize()); + let height = termHeightFromRows(25, this.globalModel.getTermFontSize(), 25); + this.specialLineContainer = new SpecialLineContainer( + this, + { width, height }, + false, + appconst.LineContainer_History + ); + } + })(); + } + + doSelectedDelete(): void { + if (!this.deleteActive.get()) { + mobx.action(() => { + this.deleteActive.set(true); + })(); + setTimeout(this.clearActiveDelete, 2000); + return; + } + let prtn = this.globalModel.showAlert({ + message: "Deleting lines from history also deletes their content from your workspaces.", + confirm: true, + }); + prtn.then((result) => { + if (!result) { + return; + } + if (result) { + this._deleteSelected(); + } + }); + } + + _deleteSelected(): void { + let lineIds: string[] = Array.from(this.selectedItems.keys()); + let prtn = GlobalCommandRunner.historyPurgeLines(lineIds); + prtn.then((result: CommandRtnType) => { + if (!result.success) { + this.globalModel.showAlert({ message: "Error removing history lines." }); + } + }); + let params = this._getSearchParams(); + GlobalCommandRunner.historyView(params); + } + + @boundMethod + clearActiveDelete(): void { + mobx.action(() => { + this.deleteActive.set(false); + })(); + } + + _getSearchParams(newOffset?: number, newRawOffset?: number): HistorySearchParams { + let offset = newOffset ?? this.offset.get(); + let rawOffset = newRawOffset ?? this.curRawOffset; + let opts: HistorySearchParams = { + offset: offset, + rawOffset: rawOffset, + searchText: this.activeSearchText, + searchSessionId: this.searchSessionId.get(), + searchRemoteId: this.searchRemoteId.get(), + }; + if (!this.searchShowMeta.get()) { + opts.noMeta = true; + } + if (this.searchFromDate.get() != null) { + let fromDate = this.searchFromDate.get(); + let fromTs = dayjs(fromDate, "YYYY-MM-DD").valueOf(); + let d = new Date(fromTs); + d.setDate(d.getDate() + 1); + let ts = d.getTime() - 1; + opts.fromTs = ts; + } + if (this.searchFilterCmds.get()) { + opts.filterCmds = true; + } + return opts; + } + + reSearch(): void { + this.setActiveItem(null); + GlobalCommandRunner.historyView(this._getSearchParams()); + } + + resetAllFilters(): void { + mobx.action(() => { + this.activeSearchText = ""; + this.searchText.set(""); + this.searchSessionId.set(null); + this.searchRemoteId.set(null); + this.searchFromDate.set(null); + this.searchShowMeta.set(true); + this.searchFilterCmds.set(true); + })(); + GlobalCommandRunner.historyView(this._getSearchParams(0, 0)); + } + + setFromDate(fromDate: string): void { + if (this.searchFromDate.get() == fromDate) { + return; + } + mobx.action(() => { + this.searchFromDate.set(fromDate); + })(); + GlobalCommandRunner.historyView(this._getSearchParams(0, 0)); + } + + setSearchFilterCmds(filter: boolean): void { + if (this.searchFilterCmds.get() == filter) { + return; + } + mobx.action(() => { + this.searchFilterCmds.set(filter); + })(); + GlobalCommandRunner.historyView(this._getSearchParams(0, 0)); + } + + setSearchShowMeta(show: boolean): void { + if (this.searchShowMeta.get() == show) { + return; + } + mobx.action(() => { + this.searchShowMeta.set(show); + })(); + GlobalCommandRunner.historyView(this._getSearchParams(0, 0)); + } + + setSearchSessionId(sessionId: string): void { + if (this.searchSessionId.get() == sessionId) { + return; + } + mobx.action(() => { + this.searchSessionId.set(sessionId); + })(); + GlobalCommandRunner.historyView(this._getSearchParams(0, 0)); + } + + setSearchRemoteId(remoteId: string): void { + if (this.searchRemoteId.get() == remoteId) { + return; + } + mobx.action(() => { + this.searchRemoteId.set(remoteId); + })(); + GlobalCommandRunner.historyView(this._getSearchParams(0, 0)); + } + + goPrev(): void { + let offset = this.offset.get(); + offset = offset - HistoryPageSize; + if (offset < 0) { + offset = 0; + } + let params = this._getSearchParams(offset, 0); + GlobalCommandRunner.historyView(params); + } + + goNext(): void { + let offset = this.offset.get(); + offset += HistoryPageSize; + let params = this._getSearchParams(offset, this.nextRawOffset ?? 0); + GlobalCommandRunner.historyView(params); + } + + submitSearch(): void { + mobx.action(() => { + this.hasMore.set(false); + this.items.replace([]); + this.activeSearchText = this.searchText.get(); + this.historyItemLines = []; + this.historyItemCmds = []; + })(); + GlobalCommandRunner.historyView(this._getSearchParams(0, 0)); + } + + handleUserClose() { + this.closeView(); + } + + showHistoryView(data: HistoryViewDataType): void { + mobx.action(() => { + this.globalModel.activeMainView.set("history"); + this.hasMore.set(data.hasmore); + this.items.replace(data.items || []); + this.offset.set(data.offset); + this.nextRawOffset = data.nextrawoffset; + this.curRawOffset = data.rawoffset; + this.historyItemLines = data.lines ?? []; + this.historyItemCmds = data.cmds ?? []; + this.selectedItems.clear(); + })(); + } +} + +export { HistoryViewModel }; diff --git a/src/models/index.ts b/src/models/index.ts new file mode 100644 index 0000000000..87f4edf201 --- /dev/null +++ b/src/models/index.ts @@ -0,0 +1,19 @@ +export type { SidebarModel } from "./sidebar"; +export * from "./global"; +export * from "./model"; +export { BookmarksModel } from "./bookmarks"; +export { ClientSettingsViewModel } from "./clientsettingsview"; +export { Cmd } from "./cmd"; +export { ConnectionsViewModel } from "./connectionsview"; +export { InputModel } from "./input"; +export { SidebarChatModel } from "./sidebarchat"; +export { MainSidebarModel } from "./mainsidebar"; +export { RightSidebarModel } from "./rightsidebar"; +export { ModalsModel } from "./modals"; +export { PluginsModel } from "./plugins"; +export { RemotesModel } from "./remotes"; +export { Screen } from "./screen"; +export { ScreenLines } from "./screenlines"; +export { Session } from "./session"; +export { SpecialLineContainer } from "./speciallinecontainer"; +export { ForwardLineContainer } from "./forwardlinecontainer"; diff --git a/src/models/input.ts b/src/models/input.ts new file mode 100644 index 0000000000..e6d282da05 --- /dev/null +++ b/src/models/input.ts @@ -0,0 +1,839 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type React from "react"; +import * as mobx from "mobx"; +import { isBlank } from "@/util/util"; +import * as appconst from "@/app/appconst"; +import type { Model } from "./model"; +import { GlobalCommandRunner, GlobalModel } from "./global"; + +function getDefaultHistoryQueryOpts(): HistoryQueryOpts { + return { + queryType: "screen", + limitRemote: true, + limitRemoteInstance: true, + limitUser: true, + queryStr: "", + maxItems: 10000, + includeMeta: true, + fromTs: 0, + }; +} + +class InputModel { + globalModel: Model; + activeAuxView: OV = mobx.observable.box(null); + auxViewFocus: OV = mobx.observable.box(false); + cmdInputHeight: OV = mobx.observable.box(0); + aiChatTextAreaRef: React.RefObject; + aiChatWindowRef: React.RefObject; + codeSelectBlockRefArray: Array>; + codeSelectSelectedIndex: OV = mobx.observable.box(-1); + codeSelectUuid: string; + inputPopUpType: OV = mobx.observable.box("none"); + + AICmdInfoChatItems: mobx.IObservableArray = mobx.observable.array([], { + name: "aicmdinfo-chat", + }); + readonly codeSelectTop: number = -2; + readonly codeSelectBottom: number = -1; + + historyType: mobx.IObservableValue = mobx.observable.box("screen"); + historyLoading: mobx.IObservableValue = mobx.observable.box(false); + historyAfterLoadIndex: number = 0; + historyItems: mobx.IObservableValue = mobx.observable.box(null, { + name: "history-items", + deep: false, + }); // sorted in reverse (most recent is index 0) + historyIndex: mobx.IObservableValue = mobx.observable.box(0, { + name: "history-index", + }); // 1-indexed (because 0 is current) + modHistory: mobx.IObservableArray = mobx.observable.array([""], { + name: "mod-history", + }); + historyQueryOpts: OV = mobx.observable.box(getDefaultHistoryQueryOpts()); + + infoMsg: OV = mobx.observable.box(null); + infoTimeoutId: any = null; + inputMode: OV = mobx.observable.box(null); + inputExpanded: OV = mobx.observable.box(false, { + name: "inputExpanded", + }); + + // cursor + forceCursorPos: OV = mobx.observable.box(null); + + // focus + inputFocused: OV = mobx.observable.box(false); + lineFocused: OV = mobx.observable.box(false); + physicalInputFocused: OV = mobx.observable.box(false); + forceInputFocus: boolean = false; + + lastCurLine: string = ""; + + constructor(globalModel: Model) { + mobx.makeObservable(this); + this.globalModel = globalModel; + mobx.action(() => { + this.codeSelectSelectedIndex.set(-1); + this.codeSelectBlockRefArray = []; + })(); + this.codeSelectUuid = ""; + } + + @mobx.action + setInputMode(inputMode: null | "comment" | "global"): void { + this.inputMode.set(inputMode); + } + + @mobx.action + toggleHistoryType(): void { + const opts = mobx.toJS(this.historyQueryOpts.get()); + let htype = opts.queryType; + if (htype == "screen") { + htype = "session"; + } else if (htype == "session") { + htype = "global"; + } else { + htype = "screen"; + } + this.setHistoryType(htype); + } + + @mobx.action + toggleRemoteType(): void { + const opts = mobx.toJS(this.historyQueryOpts.get()); + if (opts.limitRemote) { + opts.limitRemote = false; + opts.limitRemoteInstance = false; + } else { + opts.limitRemote = true; + opts.limitRemoteInstance = true; + } + this.setHistoryQueryOpts(opts); + } + + @mobx.action + onInputFocus(isFocused: boolean): void { + if (isFocused) { + this.inputFocused.set(true); + this.lineFocused.set(false); + } else if (this.inputFocused.get()) { + this.inputFocused.set(false); + } + } + + @mobx.action + onLineFocus(isFocused: boolean): void { + if (isFocused) { + this.inputFocused.set(false); + this.lineFocused.set(true); + } else if (this.lineFocused.get()) { + this.lineFocused.set(false); + } + } + + @mobx.action + giveFocus(): void { + const activeAuxView = this.getAuxViewFocus() ? this.getActiveAuxView() : null; + switch (activeAuxView) { + case appconst.InputAuxView_History: + console.log("focus history"); + const elem: HTMLElement = document.querySelector(".cmd-input input.history-input"); + if (elem) { + elem.focus(); + } + break; + case appconst.InputAuxView_AIChat: + this.setAIChatFocus(); + break; + case null: + if (GlobalModel.sidebarchatModel.hasFocus()) { + this.auxViewFocus.set(false); + const elem: HTMLElement = document.querySelector(".sidebarchat-input"); + if (elem != null) { + elem.focus(); + } + } else { + const elem = document.getElementById("main-cmd-input"); + if (elem) { + elem.focus(); + } + this.setPhysicalInputFocused(true); + } + break; + default: { + console.log("focus auxview"); + const elem: HTMLElement = document.querySelector(".cmd-input .auxview"); + if (elem != null) { + elem.focus(); + } + break; + } + } + } + + @mobx.action + setPhysicalInputFocused(isFocused: boolean): void { + this.physicalInputFocused.set(isFocused); + if (isFocused) { + const screen = this.globalModel.getActiveScreen(); + if (screen != null) { + if (screen.focusType.get() != "input") { + GlobalCommandRunner.screenSetFocus("input"); + } + } + } + } + + hasFocus(): boolean { + const mainInputElem = document.getElementById("main-cmd-input"); + if (document.activeElement == mainInputElem) { + return true; + } + const historyInputElem = document.querySelector(".cmd-input input.history-input"); + if (document.activeElement == historyInputElem) { + return true; + } + let aiChatInputElem = document.querySelector(".cmd-input chat-cmd-input"); + if (document.activeElement == aiChatInputElem) { + return true; + } + return false; + } + + @mobx.action + setHistoryType(htype: HistoryTypeStrs): void { + if (this.historyQueryOpts.get().queryType == htype) { + return; + } + this.loadHistory(true, -1, htype); + } + + findBestNewIndex(oldItem: HistoryItem): number { + if (oldItem == null) { + return 0; + } + const newItems = this.filteredHistoryItems; + if (newItems.length == 0) { + return 0; + } + let bestIdx = 0; + for (const [i, item] of newItems.entries()) { + // still start at i=0 to catch the historynum equality case + if (item.historynum == oldItem.historynum) { + bestIdx = i; + break; + } + const bestTsDiff = Math.abs(item.ts - newItems[bestIdx].ts); + const curTsDiff = Math.abs(item.ts - oldItem.ts); + if (curTsDiff < bestTsDiff) { + bestIdx = i; + } + } + return bestIdx + 1; + } + + @mobx.action + setHistoryQueryOpts(opts: HistoryQueryOpts): void { + const oldItem = this.getHistorySelectedItem(); + this.historyQueryOpts.set(opts); + const bestIndex = this.findBestNewIndex(oldItem); + setTimeout(() => this.setHistoryIndex(bestIndex, true), 10); + } + + @mobx.action + setOpenAICmdInfoChat(chat: OpenAICmdInfoChatMessageType[]): void { + this.AICmdInfoChatItems.replace(chat); + this.codeSelectBlockRefArray = []; + } + + isHistoryLoaded(): boolean { + if (this.historyLoading.get()) { + return false; + } + const hitems = this.historyItems.get(); + return hitems != null; + } + + @mobx.action + loadHistory(show: boolean, afterLoadIndex: number, htype: HistoryTypeStrs) { + if (this.historyLoading.get()) { + return; + } + if (this.isHistoryLoaded()) { + if (this.historyQueryOpts.get().queryType == htype) { + return; + } + } + this.historyAfterLoadIndex = afterLoadIndex; + this.historyLoading.set(true); + GlobalCommandRunner.loadHistory(show, htype); + } + + @mobx.action + openHistory(): void { + if (this.historyLoading.get()) { + return; + } + if (!this.isHistoryLoaded()) { + this.loadHistory(true, 0, "screen"); + return; + } + if (this.getActiveAuxView() != appconst.InputAuxView_History) { + this.dropModHistory(true); + this.setActiveAuxView(appconst.InputAuxView_History); + this.globalModel.sendActivity("history-open"); + } + } + + @mobx.action + setChatSidebarFocus(focus = true): void { + GlobalModel.sidebarchatModel.setFocus(focus); + this.giveFocus(); + } + + @mobx.action + updateCmdLine(cmdLine: StrWithPos): void { + this.curLine = cmdLine.str; + if (cmdLine.pos != appconst.NoStrPos) { + this.forceCursorPos.set(cmdLine.pos); + } + } + + getHistorySelectedItem(): HistoryItem { + const hidx = this.historyIndex.get(); + if (hidx == 0) { + return null; + } + const hitems = this.filteredHistoryItems; + if (hidx > hitems.length) { + return null; + } + return hitems[hidx - 1]; + } + + getFirstHistoryItem(): HistoryItem { + const hitems = this.filteredHistoryItems; + if (hitems.length == 0) { + return null; + } + return hitems[0]; + } + + @mobx.action + setHistorySelectionNum(hnum: string): void { + const hitems = this.filteredHistoryItems; + for (const [i, hitem] of hitems.entries()) { + if (hitem.historynum == hnum) { + this.setHistoryIndex(i + 1); + return; + } + } + } + + @mobx.action + setHistoryInfo(hinfo: HistoryInfoType): void { + const oldItem = this.getHistorySelectedItem(); + const hitems: HistoryItem[] = hinfo.items ?? []; + this.historyItems.set(hitems); + this.historyLoading.set(false); + this.historyQueryOpts.get().queryType = hinfo.historytype; + if (hinfo.historytype == "session" || hinfo.historytype == "global") { + this.historyQueryOpts.get().limitRemote = false; + this.historyQueryOpts.get().limitRemoteInstance = false; + } + if (this.historyAfterLoadIndex == -1) { + const bestIndex = this.findBestNewIndex(oldItem); + setTimeout(() => this.setHistoryIndex(bestIndex, true), 100); + } else if (this.historyAfterLoadIndex) { + if (hitems.length >= this.historyAfterLoadIndex) { + this.setHistoryIndex(this.historyAfterLoadIndex); + } + } + this.historyAfterLoadIndex = 0; + if (hinfo.show) { + this.openHistory(); + } + } + + @mobx.computed + get filteredHistoryItems(): HistoryItem[] { + const hitems: HistoryItem[] = this.historyItems.get() ?? []; + const rtn: HistoryItem[] = []; + const opts: HistoryQueryOpts = mobx.toJS(this.historyQueryOpts.get()); + const ctx = this.globalModel.getUIContext(); + let curRemote: RemotePtrType = ctx.remote; + if (curRemote == null) { + curRemote = { ownerid: "", name: "", remoteid: "" }; + } + curRemote = mobx.toJS(curRemote); + for (const hitem of hitems) { + if (hitem.ismetacmd) { + if (!opts.includeMeta) { + continue; + } + } else if (opts.limitRemoteInstance) { + if (hitem.remote == null || isBlank(hitem.remote.remoteid)) { + continue; + } + if ( + (curRemote.ownerid ?? "") != (hitem.remote.ownerid ?? "") || + (curRemote.remoteid ?? "") != (hitem.remote.remoteid ?? "") || + (curRemote.name ?? "") != (hitem.remote.name ?? "") + ) { + continue; + } + } else if (opts.limitRemote) { + if (hitem.remote == null || isBlank(hitem.remote.remoteid)) { + continue; + } + if ( + (curRemote.ownerid ?? "") != (hitem.remote.ownerid ?? "") || + (curRemote.remoteid ?? "") != (hitem.remote.remoteid ?? "") + ) { + continue; + } + } + if (!isBlank(opts.queryStr)) { + if (isBlank(hitem.cmdstr)) { + continue; + } + const idx = hitem.cmdstr.indexOf(opts.queryStr); + if (idx == -1) { + continue; + } + } + + rtn.push(hitem); + } + return rtn; + } + + scrollHistoryItemIntoView(hnum: string): void { + const elem: HTMLElement = document.querySelector(".cmd-history .hnum-" + hnum); + if (elem == null) { + return; + } + elem.scrollIntoView({ block: "nearest" }); + } + + @mobx.action + grabSelectedHistoryItem(): void { + const hitem = this.getHistorySelectedItem(); + if (hitem == null) { + this.resetHistory(); + return; + } + this.resetInput(); + this.curLine = hitem.cmdstr; + } + + // Closes the auxiliary view if it is open, focuses the main input + closeAuxView(): void { + if (this.activeAuxView.get() == null) { + return; + } + this.setActiveAuxView(null); + } + + // Gets the active auxiliary view, or null if none + getActiveAuxView(): InputAuxViewType { + return this.activeAuxView.get(); + } + + // Sets the active auxiliary view + setActiveAuxView(view: InputAuxViewType): void { + if (view == this.activeAuxView.get()) { + return; + } + mobx.action(() => { + this.auxViewFocus.set(view != null); + this.activeAuxView.set(view); + this.giveFocus(); + })(); + } + + // Gets the focus state of the auxiliary view. If true, the view will get focus. Otherwise, the main input will get focus. + // If the auxiliary view is not open, this will return false. + getAuxViewFocus(): boolean { + if (this.getActiveAuxView() == null) { + return false; + } + return this.auxViewFocus.get(); + } + + // Sets the focus state of the auxiliary view. If true, the view will get focus. Otherwise, the main input will get focus. + @mobx.action + setAuxViewFocus(focus: boolean): void { + this.auxViewFocus.set(focus); + this.giveFocus(); + } + + shouldRenderAuxViewKeybindings(view: InputAuxViewType): boolean { + if (GlobalModel.activeMainView.get() != "session") { + return false; + } + if (GlobalModel.getActiveScreen()?.getFocusType() != "input") { + return false; + } + // (view == null) means standard cmdinput keybindings + if (view == null) { + return !this.getAuxViewFocus() && !GlobalModel.sidebarchatModel.hasFocus(); + } else { + return this.getAuxViewFocus() && view == this.getActiveAuxView(); + } + } + + @mobx.action + setHistoryIndex(hidx: number, force?: boolean): void { + if (hidx < 0) { + return; + } + if (!force && this.historyIndex.get() == hidx) { + return; + } + this.historyIndex.set(hidx); + if (this.getActiveAuxView() == appconst.InputAuxView_History) { + let hitem = this.getHistorySelectedItem(); + if (hitem == null) { + hitem = this.getFirstHistoryItem(); + } + if (hitem != null) { + this.scrollHistoryItemIntoView(hitem.historynum); + } + } + this.globalModel.autocompleteModel.clearSuggestions(); + } + + moveHistorySelection(amt: number): void { + if (amt == 0) { + return; + } + if (!this.isHistoryLoaded()) { + return; + } + const hitems = this.filteredHistoryItems; + let idx = this.historyIndex.get() + amt; + if (idx < 0) { + idx = 0; + } + if (idx > hitems.length) { + idx = hitems.length; + } + this.setHistoryIndex(idx); + } + + @mobx.action + flashInfoMsg(info: InfoType, timeoutMs: number): void { + this._clearInfoTimeout(); + this.infoMsg.set(info); + + if (info == null && this.getActiveAuxView() == appconst.InputAuxView_Info) { + this.setActiveAuxView(null); + } else { + this.setActiveAuxView(appconst.InputAuxView_Info); + } + + if (info != null && timeoutMs) { + this.infoTimeoutId = setTimeout(() => { + if (this.activeAuxView.get() != appconst.InputAuxView_Info) { + return; + } + this.clearInfoMsg(false); + }, timeoutMs); + } + } + + setCmdInfoChatRefs( + textAreaRef: React.RefObject, + chatWindowRef: React.RefObject + ) { + this.aiChatTextAreaRef = textAreaRef; + this.aiChatWindowRef = chatWindowRef; + } + + setAIChatFocus() { + if (this.aiChatTextAreaRef?.current != null) { + this.aiChatTextAreaRef.current.focus(); + } + } + + grabCodeSelectSelection() { + if ( + this.codeSelectSelectedIndex.get() >= 0 && + this.codeSelectSelectedIndex.get() < this.codeSelectBlockRefArray.length + ) { + const curBlockRef = this.codeSelectBlockRefArray[this.codeSelectSelectedIndex.get()]; + const codeText = curBlockRef.current.innerText.replace(/\n$/, ""); // remove trailing newline + this.curLine = codeText; + this.giveFocus(); + } + } + + addCodeBlockToCodeSelect(blockRef: React.RefObject, uuid: string): number { + let rtn = -1; + if (uuid != this.codeSelectUuid) { + this.codeSelectUuid = uuid; + this.codeSelectBlockRefArray = []; + } + rtn = this.codeSelectBlockRefArray.length; + this.codeSelectBlockRefArray.push(blockRef); + return rtn; + } + + @mobx.action + setCodeSelectSelectedCodeBlock(blockIndex: number) { + if (blockIndex >= 0 && blockIndex < this.codeSelectBlockRefArray.length) { + this.codeSelectSelectedIndex.set(blockIndex); + const currentRef = this.codeSelectBlockRefArray[blockIndex].current; + if (currentRef != null && this.aiChatWindowRef?.current != null) { + const chatWindowTop = this.aiChatWindowRef.current.scrollTop; + const chatWindowBottom = chatWindowTop + this.aiChatWindowRef.current.clientHeight - 100; + const elemTop = currentRef.offsetTop; + let elemBottom = elemTop - currentRef.offsetHeight; + const elementIsInView = elemBottom < chatWindowBottom && elemTop > chatWindowTop; + if (!elementIsInView) { + this.aiChatWindowRef.current.scrollTop = elemBottom - this.aiChatWindowRef.current.clientHeight / 3; + } + } + } + this.codeSelectBlockRefArray = []; + this.setActiveAuxView(appconst.InputAuxView_AIChat); + this.setAuxViewFocus(true); + } + + @mobx.action + codeSelectSelectNextNewestCodeBlock() { + // oldest code block = index 0 in array + // this decrements codeSelectSelected index + if (this.codeSelectSelectedIndex.get() == this.codeSelectTop) { + this.codeSelectSelectedIndex.set(this.codeSelectBottom); + } else if (this.codeSelectSelectedIndex.get() == this.codeSelectBottom) { + return; + } + const incBlockIndex = this.codeSelectSelectedIndex.get() + 1; + if (this.codeSelectSelectedIndex.get() == this.codeSelectBlockRefArray.length - 1) { + this.codeSelectDeselectAll(); + if (this.aiChatWindowRef?.current != null) { + this.aiChatWindowRef.current.scrollTop = this.aiChatWindowRef.current.scrollHeight; + } + } + if (incBlockIndex >= 0 && incBlockIndex < this.codeSelectBlockRefArray.length) { + this.setCodeSelectSelectedCodeBlock(incBlockIndex); + } + } + + @mobx.action + codeSelectSelectNextOldestCodeBlock() { + if (this.codeSelectSelectedIndex.get() == this.codeSelectBottom) { + if (this.codeSelectBlockRefArray.length > 0) { + this.codeSelectSelectedIndex.set(this.codeSelectBlockRefArray.length); + } else { + return; + } + } else if (this.codeSelectSelectedIndex.get() == this.codeSelectTop) { + return; + } + const decBlockIndex = this.codeSelectSelectedIndex.get() - 1; + if (decBlockIndex < 0) { + this.codeSelectDeselectAll(this.codeSelectTop); + if (this.aiChatWindowRef?.current != null) { + this.aiChatWindowRef.current.scrollTop = 0; + } + } + if (decBlockIndex >= 0 && decBlockIndex < this.codeSelectBlockRefArray.length) { + this.setCodeSelectSelectedCodeBlock(decBlockIndex); + } + } + + getCodeSelectSelectedIndex() { + return this.codeSelectSelectedIndex.get(); + } + + getCodeSelectRefArrayLength() { + return this.codeSelectBlockRefArray.length; + } + + codeBlockIsSelected(blockIndex: number): boolean { + return blockIndex == this.codeSelectSelectedIndex.get(); + } + + codeSelectDeselectAll(direction: number = this.codeSelectBottom) { + if (this.codeSelectSelectedIndex.get() == direction) { + return; + } + mobx.action(() => { + this.codeSelectSelectedIndex.set(direction); + this.codeSelectBlockRefArray = []; + })(); + } + + @mobx.action + openAIAssistantChat(): void { + this.setActiveAuxView(appconst.InputAuxView_AIChat); + this.setAuxViewFocus(true); + this.globalModel.sendActivity("aichat-open"); + } + + clearAIAssistantChat(): void { + const prtn = this.globalModel.submitChatInfoCommand("", "", true); + prtn.then((rtn) => { + if (!rtn.success) { + console.log("submit chat command error: " + rtn.error); + } + }).catch((error) => { + console.log("submit chat command error: ", error); + }); + } + + hasScrollingInfoMsg(): boolean { + if (this.activeAuxView.get() !== appconst.InputAuxView_Info) { + return false; + } + const info = this.infoMsg.get(); + if (info == null) { + return false; + } + const div = document.querySelector(".cmd-input-info"); + if (div == null) { + return false; + } + return div.scrollHeight > div.clientHeight; + } + + _clearInfoTimeout(): void { + if (this.infoTimeoutId != null) { + clearTimeout(this.infoTimeoutId); + this.infoTimeoutId = null; + } + } + + @mobx.action + clearInfoMsg(setNull: boolean): void { + this._clearInfoTimeout(); + + if (this.getActiveAuxView() == appconst.InputAuxView_Info) { + this.setActiveAuxView(null); + } + if (setNull) { + this.infoMsg.set(null); + } + } + + @mobx.action + toggleInfoMsg(): void { + this._clearInfoTimeout(); + if (this.activeAuxView.get() == appconst.InputAuxView_Info) { + this.setActiveAuxView(null); + } else if (this.infoMsg.get() != null) { + this.setActiveAuxView(appconst.InputAuxView_Info); + } + } + + uiSubmitCommand(): void { + const commandStr = this.curLine; + if (commandStr.trim() == "") { + return; + } + mobx.action(() => { + this.resetInput(); + })(); + this.globalModel.submitRawCommand(commandStr, true, true); + } + + isEmpty(): boolean { + return this.curLine.trim() == ""; + } + + @mobx.action + resetInputMode(): void { + this.setInputMode(null); + this.curLine = ""; + } + + @mobx.action + resetInput(): void { + this.setActiveAuxView(null); + this.inputMode.set(null); + this.resetHistory(); + this.dropModHistory(false); + this.infoMsg.set(null); + this.inputExpanded.set(false); + this._clearInfoTimeout(); + } + + @mobx.action + toggleExpandInput(): void { + this.inputExpanded.set(!this.inputExpanded.get()); + this.forceInputFocus = true; + } + + @mobx.computed + get curLine(): string { + const hidx = this.historyIndex.get(); + if (hidx < this.modHistory.length && this.modHistory[hidx] != null) { + return this.modHistory[hidx]; + } + const hitems = this.filteredHistoryItems; + if (hidx == 0 || hitems == null || hidx > hitems.length) { + return ""; + } + const hitem = hitems[hidx - 1]; + if (hitem == null) { + return ""; + } + return hitem.cmdstr; + } + + set curLine(val: string) { + this.lastCurLine = this.curLine; + const hidx = this.historyIndex.get(); + const runGetSuggestions = this.curLine != val; + mobx.action(() => { + if (this.modHistory.length <= hidx) { + this.modHistory.length = hidx + 1; + } + this.modHistory[hidx] = val; + + // Whenever curLine changes, we should fetch the suggestions + if (val.trim() == "") { + this.globalModel.autocompleteModel.clearSuggestions(); + return; + } + if (runGetSuggestions) { + this.globalModel.autocompleteModel.loadSuggestions(); + } + this.modHistory[hidx] = val; + })(); + } + + @mobx.action + dropModHistory(keepLine0: boolean): void { + if (keepLine0) { + if (this.modHistory.length > 1) { + this.modHistory.splice(1, this.modHistory.length - 1); + } + } else { + this.modHistory.replace([""]); + } + } + + @mobx.action + resetHistory(): void { + if (this.getActiveAuxView() == appconst.InputAuxView_History) { + this.setActiveAuxView(null); + } + this.historyLoading.set(false); + this.historyType.set("screen"); + this.historyItems.set(null); + this.historyIndex.set(0); + this.historyQueryOpts.set(getDefaultHistoryQueryOpts()); + this.historyAfterLoadIndex = 0; + this.dropModHistory(true); + this.globalModel.autocompleteModel.clearSuggestions(); + } +} + +export { InputModel }; diff --git a/src/models/mainsidebar.ts b/src/models/mainsidebar.ts new file mode 100644 index 0000000000..4ce53db978 --- /dev/null +++ b/src/models/mainsidebar.ts @@ -0,0 +1,99 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { MagicLayout } from "@/app/magiclayout"; +import { Model } from "./model"; +import { GlobalCommandRunner } from "@/models"; + +class MainSidebarModel { + globalModel: Model = null; + tempWidth: OV = mobx.observable.box(null, { + name: "MainSidebarModel-tempWidth", + }); + tempCollapsed: OV = mobx.observable.box(null, { + name: "MainSidebarModel-tempCollapsed", + }); + isDragging: OV = mobx.observable.box(false, { + name: "MainSidebarModel-isDragging", + }); + + constructor(globalModel: Model) { + this.globalModel = globalModel; + } + + setTempWidthAndTempCollapsed(newWidth: number, newCollapsed: boolean): void { + const width = Math.max(MagicLayout.MainSidebarMinWidth, Math.min(newWidth, MagicLayout.MainSidebarMaxWidth)); + + mobx.action(() => { + this.tempWidth.set(width); + this.tempCollapsed.set(newCollapsed); + })(); + } + + /** + * Gets the intended width for the sidebar. If the sidebar is being dragged, returns the tempWidth. If the sidebar is collapsed, returns the default width. + * @param ignoreCollapse If true, returns the persisted width even if the sidebar is collapsed. + * @returns The intended width for the sidebar or the default width if the sidebar is collapsed. Can be overridden using ignoreCollapse. + */ + getWidth(ignoreCollapse: boolean = false): number { + const clientData = this.globalModel.clientData.get(); + let width = clientData?.clientopts?.mainsidebar?.width ?? MagicLayout.MainSidebarDefaultWidth; + if (this.isDragging.get()) { + if (this.tempWidth.get() == null && width == null) { + return MagicLayout.MainSidebarDefaultWidth; + } + if (this.tempWidth.get() == null) { + return width; + } + return this.tempWidth.get(); + } + // Set by CLI and collapsed + if (this.getCollapsed()) { + if (ignoreCollapse) { + return width; + } else { + return MagicLayout.MainSidebarMinWidth; + } + } else { + if (width <= MagicLayout.MainSidebarMinWidth) { + width = MagicLayout.MainSidebarDefaultWidth; + } + const snapPoint = MagicLayout.MainSidebarMinWidth + MagicLayout.MainSidebarSnapThreshold; + if (width < snapPoint || width > MagicLayout.MainSidebarMaxWidth) { + width = MagicLayout.MainSidebarDefaultWidth; + } + } + return width; + } + + getCollapsed(): boolean { + const clientData = this.globalModel.clientData.get(); + const collapsed = clientData?.clientopts?.mainsidebar?.collapsed; + if (this.isDragging.get()) { + if (this.tempCollapsed.get() == null && collapsed == null) { + return false; + } + if (this.tempCollapsed.get() == null) { + return collapsed; + } + return this.tempCollapsed.get(); + } + return collapsed; + } + + setCollapsed(collapsed: boolean): void { + const width = this.getWidth(true); + this.saveState(width, collapsed); + } + + saveState(width: number, collapsed: boolean): void { + GlobalCommandRunner.clientSetMainSidebar(width, collapsed).finally(() => { + mobx.action(() => { + this.isDragging.set(false); + })(); + }); + } +} + +export { MainSidebarModel }; diff --git a/src/models/modals.ts b/src/models/modals.ts new file mode 100644 index 0000000000..cbd0554a4d --- /dev/null +++ b/src/models/modals.ts @@ -0,0 +1,33 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { v4 as uuidv4 } from "uuid"; +import { modalsRegistry } from "@/modals/registry"; + +class ModalsModel { + store: OArr = mobx.observable.array([], { name: "ModalsModel-store", deep: false }); + + pushModal(modalId: string, props?: any) { + const modalFactory = modalsRegistry[modalId]; + + if (modalFactory && !this.store.some((modal) => modal.id === modalId)) { + mobx.action(() => { + this.store.push({ id: modalId, component: modalFactory, uniqueKey: uuidv4(), props }); + })(); + } + } + + popModal(callback?: () => void) { + mobx.action(() => { + this.store.pop(); + })(); + callback && callback(); + } + + hasOpenModals(): boolean { + return this.store.length > 0; + } +} + +export { ModalsModel }; diff --git a/src/models/model.ts b/src/models/model.ts new file mode 100644 index 0000000000..34b4cdd60b --- /dev/null +++ b/src/models/model.ts @@ -0,0 +1,1827 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { sprintf } from "sprintf-js"; +import { + handleJsonFetchResponse, + base64ToString, + base64ToArray, + genMergeData, + genMergeDataMap, + genMergeSimpleData, + isModKeyPress, + isBlank, +} from "@/util/util"; +import { WSControl } from "./ws"; +import { cmdStatusIsRunning } from "@/app/line/lineutil"; +import * as appconst from "@/app/appconst"; +import { remotePtrToString, cmdPacketString } from "@/util/modelutil"; +import { KeybindManager, adaptFromReactOrNativeKeyEvent, setKeyUtilPlatform } from "@/util/keyutil"; +import { Session } from "./session"; +import { ScreenLines } from "./screenlines"; +import { InputModel } from "./input"; +import { SidebarChatModel } from "./sidebarchat"; +import { PluginsModel } from "./plugins"; +import { BookmarksModel } from "./bookmarks"; +import { HistoryViewModel } from "./historyview"; +import { ConnectionsViewModel } from "./connectionsview"; +import { ClientSettingsViewModel } from "./clientsettingsview"; +import { RemotesModel } from "./remotes"; +import { ModalsModel } from "./modals"; +import { MainSidebarModel } from "./mainsidebar"; +import { RightSidebarModel } from "./rightsidebar"; +import { Screen } from "./screen"; +import { Cmd } from "./cmd"; +import { ContextMenuModel } from "./contextmenu"; +import { GlobalCommandRunner } from "./global"; +import { clearMonoFontCache, getMonoFontSize } from "@/util/textmeasure"; +import type { TermWrap } from "@/plugins/terminal/term"; +import * as util from "@/util/util"; +import { AutocompleteModel } from "./autocomplete"; + +type SWLinePtr = { + line: LineType; + slines: ScreenLines; + screen: Screen; +}; + +function getApi(): ElectronApi { + return (window as any).api; +} + +class Model { + clientId: string; + activeSessionId: OV = mobx.observable.box(null, { + name: "activeSessionId", + }); + sessionListLoaded: OV = mobx.observable.box(false, { + name: "sessionListLoaded", + }); + sessionList: OArr = mobx.observable.array([], { + name: "SessionList", + deep: false, + }); + screenMap: OMap = mobx.observable.map({}, { name: "ScreenMap", deep: false }); + ws: WSControl; + remotes: OArr = mobx.observable.array([], { + name: "remotes", + deep: false, + }); + remotesLoaded: OV = mobx.observable.box(false, { + name: "remotesLoaded", + }); + screenLines: OMap = mobx.observable.map({}, { name: "screenLines", deep: false }); // key = "sessionid/screenid" (screenlines) + termUsedRowsCache: Record = {}; // key = "screenid/lineid" + debugCmds: number = 0; + debugScreen: OV = mobx.observable.box(false); + waveSrvRunning: OV; + authKey: string; + isDev: boolean; + platform: string; + activeMainView: OV< + "plugins" | "session" | "history" | "bookmarks" | "webshare" | "connections" | "clientsettings" + > = mobx.observable.box("session", { + name: "activeMainView", + }); + termFontSize: CV; + alertMessage: OV = mobx.observable.box(null, { + name: "alertMessage", + }); + alertPromiseResolver: (result: boolean) => void; + aboutModalOpen: OV = mobx.observable.box(false, { + name: "aboutModalOpen", + }); + screenSettingsModal: OV<{ sessionId: string; screenId: string }> = mobx.observable.box(null, { + name: "screenSettingsModal", + }); + sessionSettingsModal: OV = mobx.observable.box(null, { + name: "sessionSettingsModal", + }); + clientSettingsModal: OV = mobx.observable.box(false, { + name: "clientSettingsModal", + }); + lineSettingsModal: OV = mobx.observable.box(null, { + name: "lineSettingsModal", + }); + devicePixelRatio: OV = mobx.observable.box(window.devicePixelRatio, { + name: "devicePixelRatio", + }); + tabSettingsOpen: OV = mobx.observable.box(false, { + name: "tabSettingsOpen", + }); + remotesModel: RemotesModel; + lineHeightEnv: LineHeightEnv; + + keybindManager: KeybindManager; + inputModel: InputModel; + autocompleteModel: AutocompleteModel; + sidebarchatModel: SidebarChatModel; + pluginsModel: PluginsModel; + bookmarksModel: BookmarksModel; + historyViewModel: HistoryViewModel; + connectionViewModel: ConnectionsViewModel; + clientSettingsViewModel: ClientSettingsViewModel; + modalsModel: ModalsModel; + mainSidebarModel: MainSidebarModel; + rightSidebarModel: RightSidebarModel; + contextMenuModel: ContextMenuModel; + isDarkTheme: OV = mobx.observable.box(getApi().getShouldUseDarkColors(), { + name: "isDarkTheme", + }); + clientData: OV = mobx.observable.box(null, { + name: "clientData", + }); + showLinks: OV = mobx.observable.box(true, { + name: "model-showLinks", + }); + packetSeqNum: number = 0; + + renderVersion: OV = mobx.observable.box(0, { + name: "renderVersion", + }); + appUpdateStatus = mobx.observable.box(getApi().getAppUpdateStatus(), { + name: "appUpdateStatus", + }); + termThemes: OV = mobx.observable.box(null, { + name: "terminalThemes", + deep: false, + }); + termRenderVersion: OV = mobx.observable.box(0, { + name: "termRenderVersion", + }); + + private constructor() { + this.clientId = getApi().getId(); + this.isDev = getApi().getIsDev(); + this.authKey = getApi().getAuthKey(); + getApi().onToggleDevUI(this.toggleDevUI.bind(this)); + this.ws = new WSControl(this.getBaseWsHostPort(), this.clientId, this.authKey, (message: any) => { + const interactive = message?.interactive ?? false; + this.runUpdate(message, interactive); + }); + this.ws.reconnect(); + this.keybindManager = new KeybindManager(this); + this.readConfigKeybindings(); + this.initSystemKeybindings(); + this.initAppKeybindings(); + this.inputModel = new InputModel(this); + this.autocompleteModel = new AutocompleteModel(this); + this.sidebarchatModel = new SidebarChatModel(this); + this.pluginsModel = new PluginsModel(this); + this.bookmarksModel = new BookmarksModel(this); + this.historyViewModel = new HistoryViewModel(this); + this.connectionViewModel = new ConnectionsViewModel(this); + this.clientSettingsViewModel = new ClientSettingsViewModel(this); + this.remotesModel = new RemotesModel(this); + this.modalsModel = new ModalsModel(); + this.mainSidebarModel = new MainSidebarModel(this); + this.rightSidebarModel = new RightSidebarModel(this); + this.contextMenuModel = new ContextMenuModel(this); + const isWaveSrvRunning = getApi().getWaveSrvStatus(); + this.waveSrvRunning = mobx.observable.box(isWaveSrvRunning, { + name: "model-wavesrv-running", + }); + this.platform = this.getPlatform(); + this.termFontSize = mobx.computed(() => { + const cdata = this.clientData.get(); + if (cdata?.feopts?.termfontsize == null) { + return appconst.DefaultTermFontSize; + } + const fontSize = Math.ceil(cdata.feopts.termfontsize); + if (fontSize < appconst.MinFontSize) { + return appconst.MinFontSize; + } + if (fontSize > appconst.MaxFontSize) { + return appconst.MaxFontSize; + } + return fontSize; + }); + getApi().onZoomChanged(this.onZoomChanged.bind(this)); + getApi().onMenuItemAbout(this.onMenuItemAbout.bind(this)); + getApi().onWaveSrvStatusChange(this.onWaveSrvStatusChange.bind(this)); + getApi().onAppUpdateStatus(this.onAppUpdateStatus.bind(this)); + getApi().onNativeThemeUpdated(this.onNativeThemeUpdated.bind(this)); + document.addEventListener("keydown", this.docKeyDownHandler.bind(this)); + document.addEventListener("selectionchange", this.docSelectionChangeHandler.bind(this)); + window.addEventListener("focus", this.windowFocus.bind(this)); + setTimeout(() => this.getClientDataLoop(1), 10); + this.lineHeightEnv = { + // defaults + fontSize: 12, + fontSizeSm: 10, + lineHeight: 15, + lineHeightSm: 13, + pad: 7, + }; + } + + readConfigKeybindings() { + const url = new URL(this.getBaseHostPort() + "/config/keybindings.json"); + let prtn = fetch(url, { method: "get", body: null, headers: this.getFetchHeaders() }); + prtn.then((resp) => { + if (resp.status == 404) { + return []; + } else if (!resp.ok) { + util.handleNotOkResp(resp, url); + } + return resp.json(); + }).then((userKeybindings) => { + this.keybindManager.setUserKeybindings(userKeybindings); + }); + } + + windowFocus(): void { + if (this.activeMainView.get() == "session" && !this.modalsModel.hasOpenModals()) { + this.refocus(); + } + } + + bumpTermRenderVersion() { + mobx.action(() => { + this.termRenderVersion.set(this.termRenderVersion.get() + 1); + })(); + } + + initSystemKeybindings() { + this.keybindManager.registerKeybinding("system", "electron", "system:toggleDeveloperTools", (waveEvent) => { + getApi().toggleDeveloperTools(); + return true; + }); + this.keybindManager.registerKeybinding("system", "electron", "system:minimizeWindow", (waveEvent) => { + getApi().hideWindow(); + return true; + }); + } + + initAppKeybindings() { + for (let index = 1; index <= 9; index++) { + this.keybindManager.registerKeybinding("app", "model", "app:selectWorkspace-" + index, (waveEvent) => { + this.onSwitchSessionCmd(index); + return true; + }); + } + this.keybindManager.registerKeybinding("app", "model", "app:focusCmdInput", (waveEvent) => { + this.onFocusCmdInputPressed(); + return true; + }); + this.keybindManager.registerKeybinding("app", "model", "app:openBookmarksView", null); + this.keybindManager.registerKeybinding("app", "model", "app:openHistoryView", (waveEvent) => { + this.onOpenHistoryPressed(); + return true; + }); + this.keybindManager.registerKeybinding("app", "model", "app:openTabSearchModal", (waveEvent) => { + this.onOpenTabSearchModalPressed(); + return true; + }); + this.keybindManager.registerKeybinding("app", "model", "app:openConnectionsView", null); + this.keybindManager.registerKeybinding("app", "model", "app:openSettingsView", null); + } + + static getInstance(): Model { + if (!(window as any).GlobalModel) { + (window as any).GlobalModel = new Model(); + } + return (window as any).GlobalModel; + } + + closeTabSettings() { + if (this.tabSettingsOpen.get()) { + mobx.action(() => { + this.tabSettingsOpen.set(false); + })(); + } + } + + toggleDevUI(): void { + document.body.classList.toggle("is-dev"); + } + + bumpRenderVersion() { + mobx.action(() => { + this.renderVersion.set(this.renderVersion.get() + 1); + })(); + } + + getNextPacketSeqNum(): number { + this.packetSeqNum++; + return this.packetSeqNum; + } + + getPlatform(): string { + if (this.platform != null) { + return this.platform; + } + this.platform = getApi().getPlatform(); + setKeyUtilPlatform(this.platform); + return this.platform; + } + + testGlobalModel() { + return ""; + } + + needsTos(): boolean { + const cdata = this.clientData.get(); + if (cdata == null) { + return false; + } + return !cdata.clientopts?.acceptedtos; + } + + refreshClient(): void { + getApi().reloadWindow(); + } + + /** + * Opens a new default browser window to the given url + * @param {string} url The url to open + */ + openExternalLink(url: string): void { + console.log("opening external link: " + url); + getApi().openExternalLink(url); + console.log("finished opening external link"); + } + + refocus() { + // givefocus() give back focus to cmd or input + const activeScreen = this.getActiveScreen(); + if (activeScreen == null) { + return; + } + activeScreen.giveFocus(); + } + + getWebSharedScreens(): Screen[] { + const rtn: Screen[] = []; + for (const screen of this.screenMap.values()) { + if (screen.shareMode.get() == "web") { + rtn.push(screen); + } + } + return rtn; + } + + getHasClientStop(): boolean { + if (this.clientData.get() == null) { + return true; + } + const cdata = this.clientData.get(); + if (cdata.cmdstoretype == "session") { + return true; + } + return false; + } + + showAlert(alertMessage: AlertMessageType): Promise { + if (alertMessage.confirmflag != null) { + const cdata = this.clientData.get(); + const noConfirm = cdata.clientopts?.confirmflags?.[alertMessage.confirmflag]; + if (noConfirm) { + return Promise.resolve(true); + } + } + mobx.action(() => { + this.alertMessage.set(alertMessage); + this.modalsModel.pushModal(appconst.ALERT); + })(); + const prtn = new Promise((resolve, reject) => { + this.alertPromiseResolver = resolve; + }); + return prtn; + } + + cancelAlert(): void { + mobx.action(() => { + this.alertMessage.set(null); + })(); + if (this.alertPromiseResolver != null) { + this.alertPromiseResolver(false); + this.alertPromiseResolver = null; + } + } + + confirmAlert(): void { + mobx.action(() => { + this.alertMessage.set(null); + this.modalsModel.popModal(); + })(); + if (this.alertPromiseResolver != null) { + this.alertPromiseResolver(true); + this.alertPromiseResolver = null; + } + } + + showSessionView(): void { + mobx.action(() => { + this.activeMainView.set("session"); + })(); + } + + getBaseHostPort(): string { + if (this.isDev) { + return appconst.DevServerEndpoint; + } + return appconst.ProdServerEndpoint; + } + + getTermFontFamily(): string { + let cdata = this.clientData.get(); + let ff = cdata?.feopts?.termfontfamily; + if (ff == null) { + ff = appconst.DefaultTermFontFamily; + } + return ff; + } + + getThemeSource(): NativeThemeSource { + return getApi().getNativeThemeSource(); + } + + onNativeThemeUpdated(): void { + const isDark = getApi().getShouldUseDarkColors(); + if (isDark != this.isDarkTheme.get()) { + mobx.action(() => { + this.isDarkTheme.set(isDark); + this.bumpRenderVersion(); + })(); + } + } + + getTermThemeSettings(): TermThemeSettingsType { + let cdata = this.clientData.get(); + if (cdata?.feopts?.termthemesettings) { + return mobx.toJS(cdata.feopts.termthemesettings); + } + return {}; + } + + getTermFontSize(): number { + return this.termFontSize.get(); + } + + getSudoPwStore(): string { + let cdata = this.clientData.get(); + return cdata?.feopts?.sudopwstore ?? appconst.DefaultSudoPwStore; + } + + getSudoPwTimeout(): number { + let cdata = this.clientData.get(); + const sudoPwTimeoutMs = cdata?.feopts?.sudopwtimeoutms ?? appconst.DefaultSudoPwTimeoutMs; + return sudoPwTimeoutMs / 1000 / 60; + } + + getSudoPwClearOnSleep(): boolean { + let cdata = this.clientData.get(); + return !cdata?.feopts?.nosudopwclearonsleep; + } + + updateTermFontSizeVars() { + let lhe = this.recomputeLineHeightEnv(); + mobx.action(() => { + this.bumpRenderVersion(); + this.setStyleVar(document.documentElement, "--termfontsize", lhe.fontSize + "px"); + this.setStyleVar(document.documentElement, "--termlineheight", lhe.lineHeight + "px"); + this.setStyleVar(document.documentElement, "--termpad", lhe.pad + "px"); + this.setStyleVar(document.documentElement, "--termfontsize-sm", lhe.fontSizeSm + "px"); + this.setStyleVar(document.documentElement, "--termlineheight-sm", lhe.lineHeightSm + "px"); + })(); + } + + recomputeLineHeightEnv(): LineHeightEnv { + const fontSize = this.getTermFontSize(); + const fontSizeSm = fontSize - 2; + const monoFontSize = getMonoFontSize(fontSize); + const monoFontSizeSm = getMonoFontSize(fontSizeSm); + this.lineHeightEnv = { + fontSize: fontSize, + fontSizeSm: fontSizeSm, + lineHeight: monoFontSize.height, + lineHeightSm: monoFontSizeSm.height, + pad: monoFontSize.pad, + }; + return this.lineHeightEnv; + } + + setStyleVar(element: HTMLElement, name: string, value: string): void { + element.style.setProperty(name, value); + } + + resetStyleVar(element: HTMLElement, name: string): void { + element.style.removeProperty(name); + } + + getBaseWsHostPort(): string { + if (this.isDev) { + return appconst.DevServerWsEndpoint; + } + return appconst.ProdServerWsEndpoint; + } + + getFetchHeaders(): Record { + return { + "x-authkey": this.authKey, + }; + } + + docSelectionChangeHandler(e: any) { + // nothing for now + } + + handleToggleSidebar() { + const activeScreen = this.getActiveScreen(); + if (activeScreen != null) { + const isSidebarOpen = activeScreen.isSidebarOpen(); + if (isSidebarOpen) { + GlobalCommandRunner.screenSidebarClose(); + } else { + GlobalCommandRunner.screenSidebarOpen(); + } + } + } + + handleDeleteActiveLine(): boolean { + return this.deleteActiveLine(); + } + + docKeyDownHandler(e: KeyboardEvent) { + const waveEvent = adaptFromReactOrNativeKeyEvent(e); + if (isModKeyPress(e)) { + return; + } + if (this.keybindManager.processKeyEvent(e, waveEvent)) { + return; + } + } + + deleteActiveLine(): boolean { + const activeScreen = this.getActiveScreen(); + if (activeScreen == null || activeScreen.getFocusType() != "cmd") { + return false; + } + const selectedLine = activeScreen.selectedLine.get(); + if (selectedLine == null || selectedLine <= 0) { + return false; + } + const line = activeScreen.getLineByNum(selectedLine); + if (line == null) { + return false; + } + const cmd = activeScreen.getCmd(line); + if (cmd != null) { + if (cmd.isRunning()) { + const info: InfoType = { infomsg: "Cannot delete a running command" }; + this.inputModel.flashInfoMsg(info, 2000); + return false; + } + } + GlobalCommandRunner.lineDelete(String(selectedLine), true); + return true; + } + + onCloseCurrentTab() { + if (this.activeMainView.get() != "session") { + return; + } + const activeScreen = this.getActiveScreen(); + if (activeScreen == null) { + return; + } + let numLines = activeScreen.getScreenLines().lines.length; + if (numLines < 10) { + GlobalCommandRunner.screenDelete(activeScreen.screenId, false); + return; + } + const rtnp = this.showAlert({ + message: "Are you sure you want to delete this tab?", + confirm: true, + }); + rtnp.then((result) => { + if (!result) { + return; + } + GlobalCommandRunner.screenDelete(activeScreen.screenId, false); + }); + } + + onRestartLastCommand() { + if (this.activeMainView.get() != "session") { + return; + } + const activeScreen = this.getActiveScreen(); + if (activeScreen == null) { + return; + } + GlobalCommandRunner.lineRestart("E", true); + } + + onRestartCommand() { + if (this.activeMainView.get() != "session") { + return; + } + const activeScreen = this.getActiveScreen(); + if (activeScreen == null) { + return; + } + const selectedLine = activeScreen.selectedLine.get(); + if (selectedLine == null || selectedLine == 0) { + return; + } + GlobalCommandRunner.lineRestart(String(selectedLine), true); + } + + onZoomChanged(): void { + mobx.action(() => { + this.devicePixelRatio.set(window.devicePixelRatio); + clearMonoFontCache(); + })(); + } + + // for debuggin + getSelectedTermWrap(): TermWrap { + let screen = this.getActiveScreen(); + if (screen == null) { + return null; + } + let lineNum = screen.selectedLine.get(); + if (lineNum == null) { + return null; + } + let line = screen.getLineByNum(lineNum); + if (line == null) { + return null; + } + return screen.getTermWrap(line.lineid); + } + + restartWaveSrv(): void { + getApi().restartWaveSrv(); + } + + getLocalRemote(): RemoteType { + for (const remote of this.remotes) { + if (remote.local && !remote.issudo) { + return remote; + } + } + return null; + } + + getCurRemoteInstance(): RemoteInstanceType { + const screen = this.getActiveScreen(); + if (screen == null) { + return null; + } + return screen.getCurRemoteInstance(); + } + + onWaveSrvStatusChange(status: boolean): void { + mobx.action(() => { + this.waveSrvRunning.set(status); + })(); + } + + getLastLogs(numbOfLines: number, cb: (logs: any) => void): void { + getApi().getLastLogs(numbOfLines, cb); + } + + getContentHeight(context: RendererContext): number { + const key = context.screenId + "/" + context.lineId; + return this.termUsedRowsCache[key]; + } + + setContentHeight(context: RendererContext, height: number): void { + const key = context.screenId + "/" + context.lineId; + this.termUsedRowsCache[key] = height; + GlobalCommandRunner.setTermUsedRows(context, height); + } + + contextEditMenu(e: any, opts: ContextMenuOpts) { + getApi().contextEditMenu({ x: e.x, y: e.y }, opts); + } + + getUIContext(): UIContextType { + const rtn: UIContextType = { + sessionid: null, + screenid: null, + remote: null, + winsize: null, + linenum: null, + build: appconst.VERSION + " " + appconst.BUILD, + }; + const session = this.getActiveSession(); + if (session != null) { + rtn.sessionid = session.sessionId; + const screen = session.getActiveScreen(); + if (screen != null) { + rtn.screenid = screen.screenId; + rtn.remote = screen.curRemote.get(); + rtn.winsize = { rows: screen.lastRows, cols: screen.lastCols }; + rtn.linenum = screen.selectedLine.get(); + } + } + return rtn; + } + + onNewTab() { + GlobalCommandRunner.createNewScreen(); + } + + onBookmarkViewPressed() { + GlobalCommandRunner.bookmarksView(); + } + + onFocusCmdInputPressed() { + if (this.activeMainView.get() != "session") { + mobx.action(() => { + this.activeMainView.set("session"); + setTimeout(() => { + // allows for the session view to load + this.inputModel.setAuxViewFocus(false); + this.inputModel.setChatSidebarFocus(false); + }, 100); + })(); + } else { + this.inputModel.setAuxViewFocus(false); + this.inputModel.setChatSidebarFocus(false); + } + } + + onFocusSelectedLine() { + const screen = this.getActiveScreen(); + if (screen != null) { + GlobalCommandRunner.screenSetFocus("cmd"); + } + } + + onOpenHistoryPressed() { + this.historyViewModel.reSearch(); + } + + onOpenTabSearchModalPressed() { + this.modalsModel.pushModal(appconst.TAB_SWITCHER); + } + + onOpenConnectionsViewPressed() { + this.activeMainView.set("connections"); + } + + onOpenSettingsViewPressed() { + this.activeMainView.set("clientsettings"); + } + + getFocusedLine(): LineFocusType { + if (this.inputModel.hasFocus()) { + return { cmdInputFocus: true }; + } + const lineElem: any = document.activeElement.closest(".line[data-lineid]"); + if (lineElem == null) { + return { cmdInputFocus: false }; + } + const lineNum = parseInt(lineElem.dataset.linenum); + return { + cmdInputFocus: false, + lineid: lineElem.dataset.lineid, + linenum: isNaN(lineNum) ? null : lineNum, + screenid: lineElem.dataset.screenid, + }; + } + + cmdStatusUpdate(screenId: string, lineId: string, origStatus: string, newStatus: string) { + const wasRunning = cmdStatusIsRunning(origStatus); + const isRunning = cmdStatusIsRunning(newStatus); + if (wasRunning && !isRunning) { + const ptr = this.getActiveLine(screenId, lineId); + if (ptr != null) { + const screen = ptr.screen; + const renderer = screen.getRenderer(lineId); + if (renderer != null) { + renderer.setIsDone(); + } + const term = screen.getTermWrap(lineId); + if (term != null) { + term.cmdDone(); + } + } + } + } + + onMenuItemAbout(): void { + mobx.action(() => { + this.modalsModel.pushModal(appconst.ABOUT); + })(); + } + + onMetaArrowUp(): void { + GlobalCommandRunner.screenSelectLine("-1"); + } + + onMetaArrowDown(): void { + GlobalCommandRunner.screenSelectLine("+1"); + } + + onBracketCmd(relative: number) { + if (relative == 1) { + GlobalCommandRunner.switchScreen("+"); + } else if (relative == -1) { + GlobalCommandRunner.switchScreen("-"); + } + } + + onSwitchScreenCmd(digit: number) { + GlobalCommandRunner.switchScreen(String(digit)); + } + + onSwitchSessionCmd(digit: number) { + GlobalCommandRunner.switchSession(String(digit)); + } + + onDigitCmd(e: any, arg: { digit: number }, mods: KeyModsType) { + GlobalCommandRunner.switchScreen(String(arg.digit)); + } + + isConnected(): boolean { + return this.ws.open.get(); + } + + runUpdate(genUpdate: UpdatePacket, interactive: boolean) { + mobx.action(() => { + const oldContext = this.getUIContext(); + try { + this.runUpdate_internal(genUpdate, oldContext, interactive); + } catch (e) { + console.warn("error running update", e, genUpdate); + throw e; + } + const newContext = this.getUIContext(); + if (oldContext.sessionid != newContext.sessionid || oldContext.screenid != newContext.screenid) { + this.inputModel.resetInput(); + if (genUpdate.type == "model") { + const reversedGenUpdate = genUpdate.data.slice().reverse(); + const lastCmdLine = reversedGenUpdate.find((update) => "cmdline" in update); + if (lastCmdLine) { + // TODO a bit of a hack since this update gets applied in runUpdate_internal. + // we then undo that update with the resetInput, and then redo it with the line below + // not sure how else to handle this for now though + this.inputModel.updateCmdLine(lastCmdLine.cmdline); + } + } + } else if (remotePtrToString(oldContext.remote) != remotePtrToString(newContext.remote)) { + this.inputModel.resetHistory(); + } + })(); + } + + updateScreens(screens: ScreenDataType[]): void { + const mods = genMergeDataMap( + this.screenMap, + screens, + (s: Screen) => s.screenId, + (sdata: ScreenDataType) => sdata.screenid, + (sdata: ScreenDataType) => new Screen(sdata, this) + ); + for (const screenId of mods.removed) { + this.removeScreenLinesByScreenId(screenId); + } + } + + markScreensAsNotNew(): void { + for (const screen of this.screenMap.values()) { + screen.isNew = false; + } + } + + updateSessions(sessions: SessionDataType[]): void { + genMergeData( + this.sessionList, + sessions, + (s: Session) => s.sessionId, + (sdata: SessionDataType) => sdata.sessionid, + (sdata: SessionDataType) => new Session(sdata, this), + (s: Session) => s.sessionIdx.get() + ); + } + + updateActiveSession(sessionId: string): void { + if (sessionId != null) { + const newSessionId = sessionId; + if (this.activeSessionId.get() != newSessionId) { + this.activeSessionId.set(newSessionId); + } + } + } + + updateScreenNumRunningCommands(numRunningCommandUpdates: ScreenNumRunningCommandsUpdateType[]) { + for (const update of numRunningCommandUpdates) { + this.getScreenById_single(update.screenid)?.setNumRunningCmds(update.num); + } + } + + mergeTermThemes(termThemes: TermThemesType) { + mobx.action(() => { + if (this.termThemes.get() == null) { + this.termThemes.set(termThemes); + return; + } + for (const [themeName, theme] of Object.entries(termThemes)) { + if (theme == null) { + delete this.termThemes.get()[themeName]; + continue; + } + this.termThemes.get()[themeName] = theme; + } + })(); + this.bumpTermRenderVersion(); + } + + getTermThemes(): TermThemesType { + return this.termThemes.get(); + } + + updateScreenStatusIndicators(screenStatusIndicators: ScreenStatusIndicatorUpdateType[]) { + for (const update of screenStatusIndicators) { + this.getScreenById_single(update.screenid)?.setStatusIndicator(update.status); + } + } + + runUpdate_internal(genUpdate: UpdatePacket, uiContext: UIContextType, interactive: boolean) { + if (genUpdate.type == "pty") { + const ptyMsg = genUpdate.data; + if (isBlank(ptyMsg.remoteid)) { + // regular update + this.updatePtyData(ptyMsg); + } else { + // remote update + const ptyData = base64ToArray(ptyMsg.ptydata64); + this.remotesModel.receiveData(ptyMsg.remoteid, ptyMsg.ptypos, ptyData); + } + } else if (genUpdate.type == "model") { + const modelUpdateItems = genUpdate.data; + + let showedRemotesModal = false; + const [oldActiveSessionId, oldActiveScreenId] = this.getActiveIds(); + modelUpdateItems.forEach((update) => { + if (update.connect != null) { + if (update.connect.screens != null) { + this.screenMap.clear(); + this.updateScreens(update.connect.screens); + this.markScreensAsNotNew(); + } + if (update.connect.sessions != null) { + this.sessionList.clear(); + this.updateSessions(update.connect.sessions); + } + if (update.connect.remotes != null) { + this.remotes.clear(); + this.updateRemotes(update.connect.remotes); + } + if (update.connect.activesessionid != null) { + this.updateActiveSession(update.connect.activesessionid); + } + if (update.connect.screennumrunningcommands != null) { + this.updateScreenNumRunningCommands(update.connect.screennumrunningcommands); + } + if (update.connect.screenstatusindicators != null) { + this.updateScreenStatusIndicators(update.connect.screenstatusindicators); + } + this.mergeTermThemes(update.connect.termthemes ?? {}); + this.sessionListLoaded.set(true); + this.remotesLoaded.set(true); + } else if (update.screen != null) { + this.updateScreens([update.screen]); + } else if (update.session != null) { + this.updateSessions([update.session]); + } else if (update.activesessionid != null) { + this.updateActiveSession(update.activesessionid); + } else if (update.line != null) { + this.addLineCmd(update.line.line, update.line.cmd, interactive); + } else if (update.cmd != null) { + this.updateCmd(update.cmd); + } else if (update.screenlines != null) { + this.updateScreenLines(update.screenlines, false); + } else if (update.remote != null) { + this.updateRemotes([update.remote]); + // This code's purpose is to show view remote connection modal when a new connection is added + if (!showedRemotesModal && this.remotesModel.recentConnAddedState.get()) { + showedRemotesModal = true; + this.remotesModel.openReadModal(update.remote.remoteid); + } + } else if (update.mainview != null) { + switch (update.mainview.mainview) { + case "session": + this.activeMainView.set("session"); + break; + case "history": + if (update.mainview.historyview != null) { + this.historyViewModel.showHistoryView(update.mainview.historyview); + } else { + console.warn("invalid historyview in update:", update.mainview); + } + break; + case "bookmarks": + if (update.mainview.bookmarksview != null) { + this.bookmarksModel.showBookmarksView( + update.mainview.bookmarksview?.bookmarks ?? [], + update.mainview.bookmarksview?.selectedbookmark + ); + } else { + console.warn("invalid bookmarksview in update:", update.mainview); + } + break; + case "clientsettings": + this.activeMainView.set("clientsettings"); + break; + case "connections": + this.activeMainView.set("connections"); + break; + case "plugins": + this.pluginsModel.showPluginsView(); + break; + default: + console.warn("invalid mainview in update:", update.mainview); + } + } else if (update.bookmarks != null) { + if (update.bookmarks.bookmarks != null) { + this.bookmarksModel.mergeBookmarks(update.bookmarks.bookmarks); + } + } else if (update.clientdata != null) { + this.setClientData(update.clientdata); + } else if (update.cmdline != null) { + this.inputModel.updateCmdLine(update.cmdline); + } else if (update.openaicmdinfochat != null) { + this.inputModel.setOpenAICmdInfoChat(update.openaicmdinfochat); + } else if (update.screenstatusindicator != null) { + this.updateScreenStatusIndicators([update.screenstatusindicator]); + } else if (update.screennumrunningcommands != null) { + this.updateScreenNumRunningCommands([update.screennumrunningcommands]); + } else if (update.userinputrequest != null) { + const userInputRequest: UserInputRequest = update.userinputrequest; + this.modalsModel.pushModal(appconst.USER_INPUT, userInputRequest); + } else if (update.termthemes != null) { + this.mergeTermThemes(update.termthemes); + } else if (update.sessiontombstone != null || update.screentombstone != null) { + // nothing (ignore) + } else { + // interactive-only updates follow below + // we check interactive *inside* of the conditions because of isDev console.log message + if (update.info != null) { + const info: InfoType = update.info; + if (interactive) { + this.inputModel.flashInfoMsg(info, info.timeoutms); + } + } else if (update.remoteview != null) { + const rview: RemoteViewType = update.remoteview; + if (interactive && rview.remoteedit != null) { + this.remotesModel.openEditModal({ ...rview.remoteedit }); + } + } else if (update.alertmessage != null) { + const alertMessage: AlertMessageType = update.alertmessage; + if (interactive) { + this.showAlert(alertMessage); + } + } else if (update.history != null) { + if ( + interactive && + uiContext.sessionid == update.history.sessionid && + uiContext.screenid == update.history.screenid + ) { + this.inputModel.setHistoryInfo(update.history); + } + } else if (update.interactive) { + // nothing (ignore) + } else if (this.isDev) { + console.log("did not match update", update); + } + } + }); + + // Check if the active session or screen has changed, and if so, watch the new screen + const [newActiveSessionId, newActiveScreenId] = this.getActiveIds(); + if (oldActiveSessionId != newActiveSessionId || oldActiveScreenId != newActiveScreenId) { + this.activeMainView.set("session"); + this.deactivateScreenLines(); + this.ws.watchScreen(newActiveSessionId, newActiveScreenId); + this.closeTabSettings(); + const activeScreen = this.getActiveScreen(); + if (activeScreen?.getCurRemoteInstance() != null) { + setTimeout(() => { + GlobalCommandRunner.syncShellState(); + }, 100); + } + } + } else { + console.warn("unknown update", genUpdate); + } + } + + updateRemotes(remotes: RemoteType[]): void { + genMergeSimpleData(this.remotes, remotes, (r) => r.remoteid, null); + } + + getActiveSession(): Session { + return this.getSessionById(this.activeSessionId.get()); + } + + getSessionNames(): Record { + const rtn: Record = {}; + for (const session of this.sessionList) { + rtn[session.sessionId] = session.name.get(); + } + return rtn; + } + + getScreenNames(): Record { + const rtn: Record = {}; + for (const screen of this.screenMap.values()) { + rtn[screen.screenId] = screen.name.get(); + } + return rtn; + } + + getSessionById(sessionId: string): Session { + if (sessionId == null) { + return null; + } + for (const session of this.sessionList) { + if (session.sessionId == sessionId) { + return session; + } + } + return null; + } + + deactivateScreenLines() { + mobx.action(() => { + this.screenLines.clear(); + })(); + } + + getScreenLinesById(screenId: string): ScreenLines { + return this.screenLines.get(screenId); + } + + updateScreenLines(slines: ScreenLinesType, load: boolean) { + mobx.action(() => { + const existingWin = this.screenLines.get(slines.screenid); + if (existingWin == null) { + if (!load) { + console.log("cannot update screen-lines that does not exist", slines.screenid); + return; + } + const newWindow = new ScreenLines(slines.screenid); + this.screenLines.set(slines.screenid, newWindow); + newWindow.updateData(slines, load); + } else { + existingWin.updateData(slines, load); + existingWin.loaded.set(true); + } + })(); + } + + removeScreenLinesByScreenId(screenId: string) { + mobx.action(() => { + this.screenLines.delete(screenId); + })(); + } + + getScreenById(sessionId: string, screenId: string): Screen { + return this.screenMap.get(screenId); + } + + getScreenById_single(screenId: string): Screen { + return this.screenMap.get(screenId); + } + + getSessionScreens(sessionId: string): Screen[] { + const rtn: Screen[] = []; + for (const screen of this.screenMap.values()) { + if (screen.sessionId == sessionId) { + rtn.push(screen); + } + } + return rtn; + } + + getScreenLinesForActiveScreen(): ScreenLines { + const screen = this.getActiveScreen(); + if (screen == null) { + return null; + } + return this.screenLines.get(screen.screenId); + } + + getActiveScreen(): Screen { + const session = this.getActiveSession(); + if (session == null) { + return null; + } + return session.getActiveScreen(); + } + + handleCmdRestart(cmd: CmdDataType) { + if (cmd == null || !cmd.restarted) { + return; + } + const screen = this.screenMap.get(cmd.screenid); + if (screen == null) { + return; + } + const termWrap = screen.getTermWrap(cmd.lineid); + if (termWrap == null) { + return; + } + termWrap.reload(0); + } + + addLineCmd(line: LineType, cmd: CmdDataType, interactive: boolean) { + const slines = this.getScreenLinesById(line.screenid); + if (slines == null) { + return; + } + slines.addLineCmd(line, cmd, interactive); + this.handleCmdRestart(cmd); + } + + updateCmd(cmd: CmdDataType) { + const slines = this.screenLines.get(cmd.screenid); + if (slines != null) { + slines.updateCmd(cmd); + } + this.handleCmdRestart(cmd); + } + + isInfoUpdate(update: UpdatePacket): boolean { + if (update == null) { + return false; + } + if (update.type == "model") { + return update.data.some((u) => u.info != null || u.history != null); + } else { + return false; + } + } + + getClientDataLoop(loopNum: number): void { + this.getClientData(); + const clientStop = this.getHasClientStop(); + if (this.clientData.get() != null && !clientStop) { + return; + } + let timeoutMs = 1000; + if (!clientStop && loopNum > 5) { + timeoutMs = 3000; + } + if (!clientStop && loopNum > 10) { + timeoutMs = 10000; + } + if (!clientStop && loopNum > 15) { + timeoutMs = 30000; + } + setTimeout(() => this.getClientDataLoop(loopNum + 1), timeoutMs); + } + + getClientData(): void { + const url = new URL(this.getBaseHostPort() + "/api/get-client-data"); + const fetchHeaders = this.getFetchHeaders(); + fetch(url, { method: "post", body: null, headers: fetchHeaders }) + .then((resp) => handleJsonFetchResponse(url, resp)) + .then((data) => { + const clientData: ClientDataType = data.data; + this.setClientData(clientData); + }) + .catch((err) => { + this.errorHandler("calling get-client-data", err, true); + }); + } + + setClientData(clientData: ClientDataType) { + let curClientDataIsNull = this.clientData.get() == null; + let newFontFamily = clientData?.feopts?.termfontfamily; + if (newFontFamily == null) { + newFontFamily = appconst.DefaultTermFontFamily; + } + let newFontSize = clientData?.feopts?.termfontsize; + if (newFontSize == null) { + newFontSize = appconst.DefaultTermFontSize; + } + const ffUpdated = curClientDataIsNull || newFontFamily != this.getTermFontFamily(); + const fsUpdated = newFontSize != this.getTermFontSize(); + + let newTheme = clientData?.feopts?.theme; + if (newTheme == null) { + newTheme = appconst.DefaultTheme; + } + const themeUpdated = newTheme != this.getThemeSource(); + mobx.action(() => { + this.clientData.set(clientData); + })(); + let shortcut = null; + if (clientData?.clientopts?.globalshortcutenabled) { + shortcut = clientData?.clientopts?.globalshortcut; + } + getApi().reregisterGlobalShortcut(shortcut); + if (ffUpdated) { + document.documentElement.style.setProperty("--termfontfamily", '"' + newFontFamily + '"'); + clearMonoFontCache(); + this.updateTermFontSizeVars(); // forces an update of css vars + this.bumpRenderVersion(); + } else if (fsUpdated) { + this.updateTermFontSizeVars(); + } + if (themeUpdated) { + getApi().setNativeThemeSource(newTheme); + this.bumpRenderVersion(); + } + } + + /** + * Submits a command packet to the server and processes the response. + * @param cmdPk The command packet to submit. + * @param interactive Whether the command is interactive. + * @param runUpdate Whether to run the update after the command is submitted. If true, the update will be processed and the frontend will be updated. If false, the update will be returned in the promise. + * @returns A promise that resolves to a CommandRtnType. + * @throws An error if the command fails. + * @see CommandRtnType + * @see FeCmdPacketType + **/ + submitCommandPacket( + cmdPk: FeCmdPacketType, + interactive: boolean, + runUpdate: boolean = true + ): Promise { + if (this.debugCmds > 0) { + console.log("[cmd]", cmdPacketString(cmdPk)); + if (this.debugCmds > 1) { + console.trace(); + } + } + // adding cmdStr for debugging only (easily filter run-command calls in the network tab of debugger) + const cmdStr = cmdPk.metacmd + (cmdPk.metasubcmd ? ":" + cmdPk.metasubcmd : ""); + const url = new URL(this.getBaseHostPort() + "/api/run-command?cmd=" + cmdStr); + const fetchHeaders = this.getFetchHeaders(); + const prtn = fetch(url, { + method: "post", + body: JSON.stringify(cmdPk), + headers: fetchHeaders, + }) + .then((resp) => handleJsonFetchResponse(url, resp)) + .then((data) => { + return mobx.action(() => { + const update = data.data; + if (update != null) { + if (runUpdate) { + this.runUpdate(update, interactive); + } else { + return { success: true, update: update }; + } + } + if (interactive && !this.isInfoUpdate(update)) { + this.inputModel.clearInfoMsg(true); + } + return { success: true }; + })(); + }) + .catch((err) => { + this.errorHandler("calling run-command", err, interactive); + let errMessage = "error running command"; + if (err != null && !isBlank(err.message)) { + errMessage = err.message; + } + return { success: false, error: errMessage }; + }); + return prtn; + } + + /** + * Submits a command to the server and processes the response. + * @param metaCmd The meta command to run. + * @param metaSubCmd The meta subcommand to run. + * @param args The arguments to pass to the command. + * @param kwargs The keyword arguments to pass to the command. + * @param interactive Whether the command is interactive. + * @param runUpdate Whether to run the update after the command is submitted. If true, the update will be processed and the frontend will be updated. If false, the update will be returned in the promise. + * @returns A promise that resolves to a CommandRtnType. + */ + submitCommand( + metaCmd: string, + metaSubCmd: string, + args: string[], + kwargs: Record, + interactive: boolean, + runUpdate: boolean = true + ): Promise { + const pk: FeCmdPacketType = { + type: "fecmd", + metacmd: metaCmd, + metasubcmd: metaSubCmd, + args: args, + kwargs: { ...kwargs }, + uicontext: this.getUIContext(), + interactive: interactive, + ephemeralopts: null, + }; + /** + console.log( + "CMD" + pk.metacmd + (pk.metasubcmd != null ? ":" + pk.metasubcmd : ""), + pk.args, + pk.kwargs, + pk.interactive + ); + */ + return this.submitCommandPacket(pk, interactive, runUpdate); + } + + getSingleEphemeralCommandOutput(url: URL): Promise { + return fetch(url, { method: "get", headers: this.getFetchHeaders() }) + .then((resp) => resp.text()) + .catch((err) => { + this.errorHandler("getting ephemeral command output", err, true); + return ""; + }); + } + + async getEphemeralCommandOutput( + ephemeralCommandResponse: EphemeralCommandResponsePacketType + ): Promise { + let stdout = ""; + let stderr = ""; + if (ephemeralCommandResponse.stdouturl) { + const url = new URL(this.getBaseHostPort() + ephemeralCommandResponse.stdouturl); + stdout = await this.getSingleEphemeralCommandOutput(url); + } + if (ephemeralCommandResponse.stderrurl) { + const url = new URL(this.getBaseHostPort() + ephemeralCommandResponse.stderrurl); + stderr = await this.getSingleEphemeralCommandOutput(url); + } + return { stdout: stdout, stderr: stderr }; + } + + submitEphemeralCommandPacket( + cmdPk: FeCmdPacketType, + interactive: boolean + ): Promise { + if (this.debugCmds > 0) { + console.log("[cmd]", cmdPacketString(cmdPk)); + if (this.debugCmds > 1) { + console.trace(); + } + } + // adding cmdStr for debugging only (easily filter run-command calls in the network tab of debugger) + const cmdStr = cmdPk.metacmd + (cmdPk.metasubcmd ? ":" + cmdPk.metasubcmd : ""); + const url = new URL(this.getBaseHostPort() + "/api/run-ephemeral-command?cmd=" + cmdStr); + const fetchHeaders = this.getFetchHeaders(); + const prtn = fetch(url, { + method: "post", + body: JSON.stringify(cmdPk), + headers: fetchHeaders, + }) + .then(async (resp) => { + const data = await handleJsonFetchResponse(url, resp); + if (data.success) { + return data.data as EphemeralCommandResponsePacketType; + } else { + console.log("error running ephemeral command", data); + return {}; + } + }) + .catch((err) => { + this.errorHandler("calling run-ephemeral-command", err, interactive); + return {}; + }); + return prtn; + } + + submitEphemeralCommand( + metaCmd: string, + metaSubCmd: string, + args: string[], + kwargs: Record, + interactive: boolean, + ephemeralopts?: EphemeralCmdOptsType + ): Promise { + const pk: FeCmdPacketType = { + type: "fecmd", + metacmd: metaCmd, + metasubcmd: metaSubCmd, + args: args, + kwargs: { ...kwargs }, + uicontext: this.getUIContext(), + interactive: interactive, + ephemeralopts: ephemeralopts, + }; + // console.log( + // "CMD", + // pk.metacmd + (pk.metasubcmd != null ? ":" + pk.metasubcmd : ""), + // pk.args, + // pk.kwargs, + // pk.interactive, + // pk.ephemeralopts + // ); + return this.submitEphemeralCommandPacket(pk, interactive); + } + + submitChatInfoCommand(chatMsg: string, curLineStr: string, clear: boolean): Promise { + const commandStr = "/chat " + chatMsg; + const interactive = false; + const pk: FeCmdPacketType = { + type: "fecmd", + metacmd: "eval", + args: [commandStr], + kwargs: {}, + uicontext: this.getUIContext(), + interactive: interactive, + rawstr: chatMsg, + }; + pk.kwargs["nohist"] = "1"; + if (clear) { + pk.kwargs["cmdinfoclear"] = "1"; + } else { + pk.kwargs["cmdinfo"] = "1"; + } + pk.kwargs["curline"] = curLineStr; + return this.submitCommandPacket(pk, interactive); + } + + submitRawCommand(cmdStr: string, addToHistory: boolean, interactive: boolean): Promise { + const pk: FeCmdPacketType = { + type: "fecmd", + metacmd: "eval", + args: [cmdStr], + kwargs: {}, + uicontext: this.getUIContext(), + interactive: interactive, + rawstr: cmdStr, + }; + if (!addToHistory && pk.kwargs) { + pk.kwargs["nohist"] = "1"; + } + return this.submitCommandPacket(pk, interactive); + } + + // returns [sessionId, screenId] + getActiveIds(): [string, string] { + const activeSession = this.getActiveSession(); + const activeScreen = this.getActiveScreen(); + return [activeSession?.sessionId, activeScreen?.screenId]; + } + + _loadScreenLinesAsync(newWin: ScreenLines) { + this.screenLines.set(newWin.screenId, newWin); + const usp = new URLSearchParams({ screenid: newWin.screenId }); + const url = new URL(this.getBaseHostPort() + "/api/get-screen-lines?" + usp.toString()); + const fetchHeaders = this.getFetchHeaders(); + fetch(url, { headers: fetchHeaders }) + .then((resp) => handleJsonFetchResponse(url, resp)) + .then((data) => { + if (data.data == null) { + console.log("null screen-lines returned from get-screen-lines"); + return; + } + const slines: ScreenLinesType = data.data; + this.updateScreenLines(slines, true); + }) + .catch((err) => { + this.errorHandler(sprintf("getting screen-lines=%s", newWin.screenId), err, false); + }); + } + + loadScreenLines(screenId: string): ScreenLines { + const newWin = new ScreenLines(screenId); + setTimeout(() => this._loadScreenLinesAsync(newWin), 0); + return newWin; + } + + getRemote(remoteId: string): RemoteType { + if (remoteId == null) { + return null; + } + return this.remotes.find((remote) => remote.remoteid === remoteId); + } + + getRemoteNames(): Record { + const rtn: Record = {}; + for (const remote of this.remotes) { + if (!isBlank(remote.remotealias)) { + rtn[remote.remoteid] = remote.remotealias; + } else { + rtn[remote.remoteid] = remote.remotecanonicalname; + } + } + return rtn; + } + + getRemoteByName(name: string): RemoteType { + for (const remote of this.remotes) { + if (remote.remotecanonicalname == name || remote.remotealias == name) { + return remote; + } + } + return null; + } + + getCmd(line: LineType): Cmd { + return this.getCmdByScreenLine(line.screenid, line.lineid); + } + + getCmdByScreenLine(screenId: string, lineId: string): Cmd { + const slines = this.getScreenLinesById(screenId); + if (slines == null) { + return null; + } + return slines.getCmd(lineId); + } + + getActiveLine(screenId: string, lineid: string): SWLinePtr { + const slines = this.screenLines.get(screenId); + if (slines == null) { + return null; + } + if (!slines.loaded.get()) { + return null; + } + const cmd = slines.getCmd(lineid); + if (cmd == null) { + return null; + } + let line: LineType = null; + for (const element of slines.lines) { + if (element.lineid == lineid) { + line = element; + break; + } + } + if (line == null) { + return null; + } + const screen = this.getScreenById_single(slines.screenId); + return { line: line, slines: slines, screen: screen }; + } + + updatePtyData(ptyMsg: PtyDataUpdateType): void { + const linePtr = this.getActiveLine(ptyMsg.screenid, ptyMsg.lineid); + if (linePtr != null) { + linePtr.screen.updatePtyData(ptyMsg); + } + } + + errorHandler(str: string, err: any, interactive: boolean) { + console.log("[error]", str, err); + if (interactive) { + let errMsg = "error running command"; + if (err?.message) { + errMsg = err.message; + } + let info: InfoType = { infoerror: errMsg }; + if (err?.errorcode) { + info.infoerrorcode = err.errorcode; + } + this.inputModel.flashInfoMsg(info, null); + } + } + + sendUserInput(userInputResponsePacket: UserInputResponsePacket) { + this.ws.pushMessage(userInputResponsePacket); + } + + sendInputPacket(inputPacket: any) { + this.ws.pushMessage(inputPacket); + } + + sendCmdInputText(screenId: string, sp: StrWithPos) { + const pk: CmdInputTextPacketType = { + type: "cmdinputtext", + seqnum: this.getNextPacketSeqNum(), + screenid: screenId, + text: sp, + }; + this.ws.pushMessage(pk); + } + + resolveUserIdToName(userid: string): string { + return "@[unknown]"; + } + + resolveRemoteIdToRef(remoteId: string) { + const remote = this.getRemote(remoteId); + if (remote == null) { + return "[unknown]"; + } + if (!isBlank(remote.remotealias)) { + return remote.remotealias; + } + return remote.remotecanonicalname; + } + + resolveRemoteIdToFullRef(remoteId: string) { + const remote = this.getRemote(remoteId); + if (remote == null) { + return "[unknown]"; + } + if (!isBlank(remote.remotealias)) { + return remote.remotealias + " (" + remote.remotecanonicalname + ")"; + } + return remote.remotecanonicalname; + } + + readRemoteFile(screenId: string, lineId: string, path: string, mimetype?: string): Promise { + const urlParams: Record = { + screenid: screenId, + lineid: lineId, + path: path, + }; + if (mimetype != null) { + urlParams["mimetype"] = mimetype; + } + const usp = new URLSearchParams(urlParams); + const url = new URL(this.getBaseHostPort() + "/api/read-file?" + usp.toString()); + const fetchHeaders = this.getFetchHeaders(); + let fileInfo: FileInfoType = null; + let badResponseStr: string = null; + const prtn = fetch(url, { method: "get", headers: fetchHeaders }) + .then((resp) => { + if (!resp.ok) { + badResponseStr = sprintf( + "Bad fetch response for /apiread-file: %d %s", + resp.status, + resp.statusText + ); + return resp.text() as any; + } + fileInfo = JSON.parse(base64ToString(resp.headers.get("X-FileInfo"))); + return resp.blob(); + }) + .then((blobOrText: any) => { + if (blobOrText instanceof Blob) { + const blob: Blob = blobOrText; + const file = new File([blob], fileInfo.name, { type: blob.type, lastModified: fileInfo.modts }); + const isWriteable = (fileInfo.perm & 0o222) > 0; // checks for unix permission "w" bits + (file as any).readOnly = !isWriteable; + (file as any).notFound = !!fileInfo.notfound; + return file as ExtFile; + } else { + const textError: string = blobOrText; + if (textError == null || textError.length == 0) { + throw new Error(badResponseStr); + } + throw new Error(textError); + } + }); + return prtn; + } + + async writeRemoteFile( + screenId: string, + lineId: string, + path: string, + data: Uint8Array, + opts?: { useTemp?: boolean } + ): Promise { + opts = opts || {}; + const params = { + screenid: screenId, + lineid: lineId, + path: path, + usetemp: !!opts.useTemp, + }; + const formData = new FormData(); + formData.append("params", JSON.stringify(params)); + const blob = new Blob([data], { type: "application/octet-stream" }); + formData.append("data", blob); + const url = new URL(this.getBaseHostPort() + "/api/write-file"); + const fetchHeaders = this.getFetchHeaders(); + const prtn = fetch(url, { method: "post", headers: fetchHeaders, body: formData }); + const resp = await prtn; + const _ = await handleJsonFetchResponse(url, resp); + } + + /** + * Tell Electron to install the waiting app update. Will prompt for user input before restarting. + */ + installAppUpdate(): void { + if (this.appUpdateStatus.get() == "ready") { + getApi().installAppUpdate(); + } + } + + onAppUpdateStatus(status: AppUpdateStatusType) { + mobx.action(() => { + this.appUpdateStatus.set(status); + })(); + } + + getElectronApi(): ElectronApi { + return getApi(); + } + + sendActivity(atype: string) { + const pk: FeActivityPacketType = { + type: "feactivity", + activity: {}, + }; + pk.activity[atype] = 1; + this.ws.pushMessage(pk); + } +} + +export { Model, getApi }; diff --git a/src/models/plugins.ts b/src/models/plugins.ts new file mode 100644 index 0000000000..031b3a0213 --- /dev/null +++ b/src/models/plugins.ts @@ -0,0 +1,44 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { PluginModel } from "@/plugins/plugins"; +import { Model } from "./model"; + +class PluginsModel { + globalModel: Model = null; + selectedPlugin: OV = mobx.observable.box(null, { name: "selectedPlugin" }); + + constructor(globalModel: Model) { + this.globalModel = globalModel; + } + + showPluginsView(): void { + PluginModel.loadAllPluginResources(); + mobx.action(() => { + this.reset(); + this.globalModel.activeMainView.set("plugins"); + const allPlugins = PluginModel.allPlugins(); + this.selectedPlugin.set(allPlugins.length > 0 ? allPlugins[0] : null); + })(); + } + + setSelectedPlugin(plugin: RendererPluginType): void { + mobx.action(() => { + this.selectedPlugin.set(plugin); + })(); + } + + reset(): void { + mobx.action(() => { + this.selectedPlugin.set(null); + })(); + } + + closeView(): void { + this.globalModel.showSessionView(); + setTimeout(() => this.globalModel.inputModel.giveFocus(), 50); + } +} + +export { PluginsModel }; diff --git a/src/models/remotes.ts b/src/models/remotes.ts new file mode 100644 index 0000000000..b57cbd052a --- /dev/null +++ b/src/models/remotes.ts @@ -0,0 +1,202 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { boundMethod } from "autobind-decorator"; +import { stringToBase64 } from "@/util/util"; +import { TermWrap } from "@/plugins/terminal/term"; +import * as appconst from "@/app/appconst"; +import { GlobalCommandRunner } from "./global"; +import { Model } from "./model"; +import { getTermPtyData } from "@/util/modelutil"; + +class RemotesModel { + globalModel: Model; + selectedRemoteId: OV = mobx.observable.box(null, { + name: "RemotesModel-selectedRemoteId", + }); + remoteTermWrap: TermWrap = null; + remoteTermWrapFocus: OV = mobx.observable.box(false, { + name: "RemotesModel-remoteTermWrapFocus", + }); + showNoInputMsg: OV = mobx.observable.box(false, { + name: "RemotesModel-showNoInputMg", + }); + showNoInputTimeoutId: any = null; + remoteEdit: OV = mobx.observable.box(null, { + name: "RemotesModel-remoteEdit", + }); + recentConnAddedState: OV = mobx.observable.box(false, { + name: "RemotesModel-recentlyAdded", + }); + + constructor(globalModel: Model) { + this.globalModel = globalModel; + } + + get recentConnAdded(): boolean { + return this.recentConnAddedState.get(); + } + + @boundMethod + setRecentConnAdded(value: boolean) { + mobx.action(() => { + this.recentConnAddedState.set(value); + })(); + } + + deSelectRemote(): void { + mobx.action(() => { + this.selectedRemoteId.set(null); + this.remoteEdit.set(null); + })(); + } + + openReadModal(remoteId: string): void { + mobx.action(() => { + this.setRecentConnAdded(false); + this.selectedRemoteId.set(remoteId); + this.remoteEdit.set(null); + this.globalModel.modalsModel.pushModal(appconst.VIEW_REMOTE); + })(); + } + + openAddModal(redit: RemoteEditType): void { + mobx.action(() => { + this.remoteEdit.set(redit); + this.globalModel.modalsModel.pushModal(appconst.CREATE_REMOTE); + })(); + } + + openEditModal(redit?: RemoteEditType): void { + mobx.action(() => { + this.selectedRemoteId.set(redit?.remoteid); + this.remoteEdit.set(redit); + this.globalModel.modalsModel.pushModal(appconst.EDIT_REMOTE); + })(); + } + + selectRemote(remoteId: string): void { + if (this.selectedRemoteId.get() == remoteId) { + return; + } + mobx.action(() => { + this.selectedRemoteId.set(remoteId); + this.remoteEdit.set(null); + })(); + } + + @boundMethod + startEditAuth(): void { + let remoteId = this.selectedRemoteId.get(); + if (remoteId != null) { + GlobalCommandRunner.openEditRemote(remoteId); + } + } + + isAuthEditMode(): boolean { + return this.remoteEdit.get() != null; + } + + @boundMethod + closeModal(): void { + mobx.action(() => { + this.globalModel.modalsModel.popModal(); + })(); + setTimeout(() => this.globalModel.refocus(), 10); + } + + disposeTerm(): void { + if (this.remoteTermWrap == null) { + return; + } + this.remoteTermWrap.dispose(); + this.remoteTermWrap = null; + mobx.action(() => { + this.remoteTermWrapFocus.set(false); + })(); + } + + receiveData(remoteId: string, ptyPos: number, ptyData: Uint8Array, reason?: string) { + if (this.remoteTermWrap == null) { + return; + } + if (this.remoteTermWrap.getContextRemoteId() != remoteId) { + return; + } + this.remoteTermWrap.receiveData(ptyPos, ptyData); + } + + @boundMethod + setRemoteTermWrapFocus(focus: boolean): void { + mobx.action(() => { + this.remoteTermWrapFocus.set(focus); + })(); + } + + @boundMethod + setShowNoInputMsg(val: boolean) { + mobx.action(() => { + if (this.showNoInputTimeoutId != null) { + clearTimeout(this.showNoInputTimeoutId); + this.showNoInputTimeoutId = null; + } + if (val) { + this.showNoInputMsg.set(true); + this.showNoInputTimeoutId = setTimeout(() => this.setShowNoInputMsg(false), 2000); + } else { + this.showNoInputMsg.set(false); + } + })(); + } + + @boundMethod + termKeyHandler(remoteId: string, event: any, termWrap: TermWrap): void { + let remote = this.globalModel.getRemote(remoteId); + if (remote == null) { + return; + } + if (remote.status != "connecting" && remote.installstatus != "connecting") { + this.setShowNoInputMsg(true); + return; + } + let inputPacket: RemoteInputPacketType = { + type: "remoteinput", + remoteid: remoteId, + inputdata64: stringToBase64(event.key), + }; + this.globalModel.sendInputPacket(inputPacket); + } + + createTermWrap(elem: HTMLElement): void { + this.disposeTerm(); + let remoteId = this.selectedRemoteId.get(); + if (remoteId == null) { + return; + } + let termOpts = { + rows: appconst.RemotePtyRows, + cols: appconst.RemotePtyCols, + flexrows: false, + maxptysize: 64 * 1024, + }; + let termWrap = new TermWrap(elem, { + termContext: { remoteId: remoteId }, + usedRows: appconst.RemotePtyRows, + termOpts: termOpts, + winSize: null, + keyHandler: (e, termWrap) => { + this.termKeyHandler(remoteId, e, termWrap); + }, + focusHandler: this.setRemoteTermWrapFocus.bind(this), + isRunning: true, + fontSize: this.globalModel.getTermFontSize(), + fontFamily: this.globalModel.getTermFontFamily(), + ptyDataSource: getTermPtyData, + onUpdateContentHeight: null, + }); + this.remoteTermWrap = termWrap; + } +} + +export { RemotesModel }; diff --git a/src/models/rightsidebar.ts b/src/models/rightsidebar.ts new file mode 100644 index 0000000000..b4ccfba05b --- /dev/null +++ b/src/models/rightsidebar.ts @@ -0,0 +1,101 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { MagicLayout } from "@/app/magiclayout"; +import { Model } from "./model"; +import { GlobalCommandRunner } from "@/models"; + +interface SidebarModel {} + +class RightSidebarModel implements SidebarModel { + globalModel: Model = null; + tempWidth: OV = mobx.observable.box(null, { + name: "RightSidebarModel-tempWidth", + }); + tempCollapsed: OV = mobx.observable.box(null, { + name: "RightSidebarModel-tempCollapsed", + }); + isDragging: OV = mobx.observable.box(false, { + name: "RightSidebarModel-isDragging", + }); + + constructor(globalModel: Model) { + this.globalModel = globalModel; + } + + setTempWidthAndTempCollapsed(newWidth: number, newCollapsed: boolean): void { + const width = Math.max(MagicLayout.RightSidebarMinWidth, Math.min(newWidth, MagicLayout.RightSidebarMaxWidth)); + + mobx.action(() => { + this.tempWidth.set(width); + this.tempCollapsed.set(newCollapsed); + })(); + } + + /** + * Gets the intended width for the sidebar. If the sidebar is being dragged, returns the tempWidth. If the sidebar is collapsed, returns the default width. + * @param ignoreCollapse If true, returns the persisted width even if the sidebar is collapsed. + * @returns The intended width for the sidebar or the default width if the sidebar is collapsed. Can be overridden using ignoreCollapse. + */ + getWidth(ignoreCollapse: boolean = false): number { + const clientData = this.globalModel.clientData.get(); + let width = clientData?.clientopts?.rightsidebar?.width ?? MagicLayout.RightSidebarDefaultWidth; + if (this.isDragging.get()) { + if (this.tempWidth.get() == null && width == null) { + return MagicLayout.RightSidebarDefaultWidth; + } + if (this.tempWidth.get() == null) { + return width; + } + return this.tempWidth.get(); + } + // Set by CLI and collapsed + if (this.getCollapsed()) { + if (ignoreCollapse) { + return width; + } else { + return MagicLayout.RightSidebarMinWidth; + } + } else { + if (width <= MagicLayout.RightSidebarMinWidth) { + width = MagicLayout.RightSidebarDefaultWidth; + } + const snapPoint = MagicLayout.RightSidebarMinWidth + MagicLayout.RightSidebarSnapThreshold; + if (width < snapPoint || width > MagicLayout.RightSidebarMaxWidth) { + width = MagicLayout.RightSidebarDefaultWidth; + } + } + return width; + } + + getCollapsed(): boolean { + const clientData = this.globalModel.clientData.get(); + const collapsed = clientData?.clientopts?.rightsidebar?.collapsed; + if (this.isDragging.get()) { + if (this.tempCollapsed.get() == null && collapsed == null) { + return false; + } + if (this.tempCollapsed.get() == null) { + return collapsed; + } + return this.tempCollapsed.get(); + } + return collapsed; + } + + setCollapsed(collapsed: boolean): void { + const width = this.getWidth(true); + this.saveState(width, collapsed); + } + + saveState(width: number, collapsed: boolean): void { + GlobalCommandRunner.clientSetRightSidebar(width, collapsed).finally(() => { + mobx.action(() => { + this.isDragging.set(false); + })(); + }); + } +} + +export { RightSidebarModel }; diff --git a/src/models/screen.ts b/src/models/screen.ts new file mode 100644 index 0000000000..9a41ee66a8 --- /dev/null +++ b/src/models/screen.ts @@ -0,0 +1,635 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { sprintf } from "sprintf-js"; +import { debounce } from "throttle-debounce"; +import { base64ToArray, boundInt, isModKeyPress, isBlank } from "@/util/util"; +import { TermWrap } from "@/plugins/terminal/term"; +import { windowWidthToCols, windowHeightToRows, termWidthFromCols, termHeightFromRows } from "@/util/textmeasure"; +import { getRendererContext } from "@/app/line/lineutil"; +import { MagicLayout } from "@/app/magiclayout"; +import * as appconst from "@/app/appconst"; +import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; +import { Model } from "./model"; +import { GlobalCommandRunner, GlobalModel } from "./global"; +import { Cmd } from "./cmd"; +import { ScreenLines } from "./screenlines"; +import { getTermPtyData } from "@/util/modelutil"; +import * as textmeasure from "@/util/textmeasure"; + +class Screen { + globalModel: Model; + sessionId: string; + screenId: string; + screenIdx: OV; + opts: OV; + viewOpts: OV; + name: OV; + archived: OV; + curRemote: OV; + nextLineNum: OV; + lastScreenSize: WindowSize; + lastCols: number; + lastRows: number; + selectedLine: OV; + focusType: OV; + anchor: OV<{ anchorLine: number; anchorOffset: number }>; + termLineNumFocus: OV; + setAnchor_debounced: (anchorLine: number, anchorOffset: number) => void; + terminals: Record = {}; // lineid => TermWrap + renderers: Record = {}; // lineid => RendererModel + shareMode: OV; + webShareOpts: OV; + filterRunning: OV; + statusIndicator: OV; + numRunningCmds: OV; + isNew: boolean; // used for showing screen settings on initial screen creation + + constructor(sdata: ScreenDataType, globalModel: Model) { + this.globalModel = globalModel; + this.sessionId = sdata.sessionid; + this.screenId = sdata.screenid; + this.name = mobx.observable.box(sdata.name, { name: "screen-name" }); + this.nextLineNum = mobx.observable.box(sdata.nextlinenum, { name: "screen-nextlinenum" }); + this.screenIdx = mobx.observable.box(sdata.screenidx, { + name: "screen-screenidx", + }); + this.opts = mobx.observable.box(sdata.screenopts, { name: "screen-opts" }); + this.viewOpts = mobx.observable.box(sdata.screenviewopts, { name: "viewOpts" }); + this.archived = mobx.observable.box(!!sdata.archived, { + name: "screen-archived", + }); + this.focusType = mobx.observable.box(sdata.focustype, { + name: "focusType", + }); + this.selectedLine = mobx.observable.box(sdata.selectedline == 0 ? null : sdata.selectedline, { + name: "selectedLine", + }); + this.setAnchor_debounced = debounce(1000, this.setAnchor.bind(this)); + this.anchor = mobx.observable.box( + { anchorLine: sdata.selectedline, anchorOffset: 0 }, + { name: "screen-anchor" } + ); + this.termLineNumFocus = mobx.observable.box(0, { + name: "termLineNumFocus", + }); + this.curRemote = mobx.observable.box(sdata.curremote, { + name: "screen-curRemote", + }); + this.shareMode = mobx.observable.box(sdata.sharemode, { + name: "screen-shareMode", + }); + this.webShareOpts = mobx.observable.box(sdata.webshareopts, { + name: "screen-webShareOpts", + }); + this.filterRunning = mobx.observable.box(false, { + name: "screen-filter-running", + }); + this.statusIndicator = mobx.observable.box(appconst.StatusIndicatorLevel.None, { + name: "screen-status-indicator", + }); + this.numRunningCmds = mobx.observable.box(0, { + name: "screen-num-running-cmds", + }); + this.isNew = true; + } + + dispose() {} + + isWebShared(): boolean { + return this.shareMode.get() == "web" && this.webShareOpts.get() != null; + } + + isSidebarOpen(): boolean { + let viewOpts = this.viewOpts.get(); + if (viewOpts == null) { + return false; + } + return viewOpts.sidebar?.open; + } + + isLineIdInSidebar(lineId: string): boolean { + let viewOpts = this.viewOpts.get(); + if (viewOpts == null) { + return false; + } + if (!viewOpts.sidebar?.open) { + return false; + } + return viewOpts?.sidebar?.sidebarlineid == lineId; + } + + getContainerType(): LineContainerStrs { + return appconst.LineContainer_Main; + } + + getShareName(): string { + if (!this.isWebShared()) { + return null; + } + let opts = this.webShareOpts.get(); + if (opts == null) { + return null; + } + return opts.sharename; + } + + getWebShareUrl(): string { + let viewKey: string = null; + if (this.webShareOpts.get() != null) { + viewKey = this.webShareOpts.get().viewkey; + } + if (viewKey == null) { + return null; + } + if (this.globalModel.isDev) { + return sprintf( + "http://devtest.getprompt.com:9001/static/index-dev.html?screenid=%s&viewkey=%s", + this.screenId, + viewKey + ); + } + return sprintf("https://share.getprompt.dev/share/%s?viewkey=%s", this.screenId, viewKey); + } + + mergeData(data: ScreenDataType) { + if (data.sessionid != this.sessionId || data.screenid != this.screenId) { + throw new Error("invalid screen update, ids don't match"); + } + mobx.action(() => { + this.screenIdx.set(data.screenidx); + this.opts.set(data.screenopts); + this.viewOpts.set(data.screenviewopts); + this.name.set(data.name); + this.nextLineNum.set(data.nextlinenum); + this.archived.set(!!data.archived); + let oldSelectedLine = this.selectedLine.get(); + let oldFocusType = this.focusType.get(); + this.selectedLine.set(data.selectedline); + this.curRemote.set(data.curremote); + this.focusType.set(data.focustype); + this.refocusLine(data, oldFocusType, oldSelectedLine); + this.shareMode.set(data.sharemode); + this.webShareOpts.set(data.webshareopts); + // do not update anchorLine/anchorOffset (only stored) + })(); + } + + getContentHeight(context: RendererContext): number { + return this.globalModel.getContentHeight(context); + } + + setContentHeight(context: RendererContext, height: number): void { + this.globalModel.setContentHeight(context, height); + } + + getCmd(line: LineType): Cmd { + return this.globalModel.getCmd(line); + } + + getCmdById(lineId: string): Cmd { + return this.globalModel.getCmdByScreenLine(this.screenId, lineId); + } + + getAnchorStr(): string { + let anchor = this.anchor.get(); + if (anchor.anchorLine == null || anchor.anchorLine == 0) { + return "0"; + } + return sprintf("%d:%d", anchor.anchorLine, anchor.anchorOffset); + } + + getTabColor(): string { + let tabColor = "default"; + let screenOpts = this.opts.get(); + if (screenOpts != null && !isBlank(screenOpts.tabcolor)) { + tabColor = screenOpts.tabcolor; + } + return tabColor; + } + + getTabIcon(): string { + let tabIcon = "default"; + let screenOpts = this.opts.get(); + if (screenOpts != null && !isBlank(screenOpts.tabicon)) { + tabIcon = screenOpts.tabicon; + } + return tabIcon; + } + + getCurRemoteInstance(): RemoteInstanceType { + let session = this.globalModel.getSessionById(this.sessionId); + let rptr = this.curRemote.get(); + if (rptr == null) { + return null; + } + return session.getRemoteInstance(this.screenId, rptr); + } + + setAnchorFields(anchorLine: number, anchorOffset: number, reason: string): void { + mobx.action(() => { + this.anchor.set({ anchorLine: anchorLine, anchorOffset: anchorOffset }); + })(); + } + + refocusLine(sdata: ScreenDataType, oldFocusType: string, oldSelectedLine: number): void { + if (this.globalModel.activeMainView.get() != "session") { + return; + } + let isCmdFocus = sdata.focustype == "cmd"; + if (!isCmdFocus) { + return; + } + if (document.activeElement != null) { + if (document.activeElement.nodeName == "INPUT" || document.activeElement.nodeName == "TEXTAREA") { + return; + } + } + if (this.globalModel.modalsModel.hasOpenModals()) { + return; + } + let curLineFocus = this.globalModel.getFocusedLine(); + let sline: LineType = null; + if (sdata.selectedline != 0) { + sline = this.getLineByNum(sdata.selectedline); + } + if ( + curLineFocus.cmdInputFocus || + (curLineFocus.linenum != null && curLineFocus.linenum != sdata.selectedline) + ) { + (document.activeElement as HTMLElement).blur(); + } + if (sline != null) { + let renderer = this.getRenderer(sline.lineid); + if (renderer != null) { + renderer.giveFocus(); + } + let termWrap = this.getTermWrap(sline.lineid); + if (termWrap != null) { + termWrap.giveFocus(); + } + } + } + + setFocusType(ftype: FocusTypeStrs): void { + mobx.action(() => { + this.focusType.set(ftype); + })(); + } + + setAnchor(anchorLine: number, anchorOffset: number): void { + let setVal = anchorLine == null || anchorLine == 0 ? "0" : sprintf("%d:%d", anchorLine, anchorOffset); + GlobalCommandRunner.screenSetAnchor(this.sessionId, this.screenId, setVal); + } + + getAnchor(): { anchorLine: number; anchorOffset: number } { + let anchor = this.anchor.get(); + if (anchor.anchorLine == null || anchor.anchorLine == 0) { + return { anchorLine: this.selectedLine.get(), anchorOffset: 0 }; + } + return anchor; + } + + getMaxLineNum(): number { + let win = this.getScreenLines(); + if (win == null) { + return null; + } + let lines = win.lines; + if (lines == null || lines.length == 0) { + return null; + } + return lines[lines.length - 1].linenum; + } + + getLineByNum(lineNum: number): LineType { + if (lineNum == null) { + return null; + } + let win = this.getScreenLines(); + if (win == null) { + return null; + } + let lines = win.lines; + if (lines == null || lines.length == 0) { + return null; + } + for (const line of lines) { + if (line.linenum == lineNum) { + return line; + } + } + return null; + } + + getLineById(lineId: string): LineType { + if (lineId == null) { + return null; + } + let win = this.getScreenLines(); + if (win == null) { + return null; + } + let lines = win.lines; + if (lines == null || lines.length == 0) { + return null; + } + for (const line of lines) { + if (line.lineid == lineId) { + return line; + } + } + return null; + } + + getPresentLineNum(lineNum: number): number { + let win = this.getScreenLines(); + if (win == null || !win.loaded.get()) { + return lineNum; + } + let lines = win.lines; + if (lines == null || lines.length == 0) { + return null; + } + if (lineNum == 0) { + return null; + } + for (const line of lines) { + if (line.linenum == lineNum) { + return lineNum; + } + if (line.linenum > lineNum) { + return line.linenum; + } + } + return lines[lines.length - 1].linenum; + } + + setSelectedLine(lineNum: number): void { + mobx.action(() => { + let pln = this.getPresentLineNum(lineNum); + if (pln != this.selectedLine.get()) { + this.selectedLine.set(pln); + } + })(); + } + + checkSelectedLine(): void { + let pln = this.getPresentLineNum(this.selectedLine.get()); + if (pln != this.selectedLine.get()) { + this.setSelectedLine(pln); + } + } + + updatePtyData(ptyMsg: PtyDataUpdateType) { + let lineId = ptyMsg.lineid; + let renderer = this.renderers[lineId]; + if (renderer != null) { + let data = base64ToArray(ptyMsg.ptydata64); + renderer.receiveData(ptyMsg.ptypos, data, "from-sw"); + } + let term = this.terminals[lineId]; + if (term != null) { + let data = base64ToArray(ptyMsg.ptydata64); + term.receiveData(ptyMsg.ptypos, data, "from-sw"); + } + } + + isActive(): boolean { + let activeScreen = this.globalModel.getActiveScreen(); + if (activeScreen == null) { + return false; + } + return this.sessionId == activeScreen.sessionId && this.screenId == activeScreen.screenId; + } + + screenSizeCallback(winSize: WindowSize): void { + if (winSize.height == 0 || winSize.width == 0) { + return; + } + if ( + this.lastScreenSize != null && + this.lastScreenSize.height == winSize.height && + this.lastScreenSize.width == winSize.width + ) { + return; + } + this.lastScreenSize = winSize; + let useableHeight = winSize.height - textmeasure.calcMaxLineChromeHeight(this.globalModel.lineHeightEnv); + let cols = windowWidthToCols(winSize.width, this.globalModel.getTermFontSize()); + let rows = windowHeightToRows(this.globalModel.lineHeightEnv, winSize.height); + this._termSizeCallback(rows, cols); + } + + getMaxContentSize(): WindowSize { + if (this.lastScreenSize == null) { + let width = termWidthFromCols(80, this.globalModel.getTermFontSize()); + let height = termHeightFromRows(25, this.globalModel.getTermFontSize(), 25); + return { width, height }; + } + let winSize = this.lastScreenSize; + let minSize = MagicLayout.ScreenMinContentSize; + let maxSize = MagicLayout.ScreenMaxContentSize; + let width = boundInt(winSize.width - MagicLayout.ScreenMaxContentWidthBuffer, minSize, maxSize); + let maxLineBuffer = textmeasure.calcMaxLineChromeHeight(this.globalModel.lineHeightEnv); + let height = boundInt(winSize.height - maxLineBuffer, minSize, maxSize); + return { width, height }; + } + + getIdealContentSize(): WindowSize { + if (this.lastScreenSize == null) { + let width = termWidthFromCols(80, this.globalModel.getTermFontSize()); + let height = termHeightFromRows(25, this.globalModel.getTermFontSize(), 25); + return { width, height }; + } + let winSize = this.lastScreenSize; + let width = boundInt(Math.ceil((winSize.width - 50) * 0.7), 100, 5000); + let height = boundInt(Math.ceil((winSize.height - 100) * 0.5), 100, 5000); + return { width, height }; + } + + _termSizeCallback(rows: number, cols: number): void { + if (cols == 0 || rows == 0) { + return; + } + if (rows == this.lastRows && cols == this.lastCols) { + return; + } + this.lastRows = rows; + this.lastCols = cols; + let exclude = []; + for (let lineid in this.terminals) { + let inSidebar = this.isLineIdInSidebar(lineid); + if (!inSidebar) { + this.terminals[lineid].resizeCols(cols); + } else { + exclude.push(lineid); + } + } + GlobalCommandRunner.resizeScreen(this.screenId, rows, cols, { exclude }); + } + + getTermWrap(lineId: string): TermWrap { + return this.terminals[lineId]; + } + + getRenderer(lineId: string): RendererModel { + return this.renderers[lineId]; + } + + registerRenderer(lineId: string, renderer: RendererModel) { + this.renderers[lineId] = renderer; + } + + setLineFocus(lineNum: number, focus: boolean): void { + mobx.action(() => this.termLineNumFocus.set(focus ? lineNum : 0))(); + if (focus && this.selectedLine.get() != lineNum) { + GlobalCommandRunner.screenSelectLine(String(lineNum), "cmd"); + } else if (focus && this.focusType.get() == "input") { + GlobalCommandRunner.screenSetFocus("cmd"); + } + } + + /** + * Set the status indicator for the screen. + * @param indicator The value of the status indicator. One of "none", "error", "success", "output". + */ + setStatusIndicator(indicator: appconst.StatusIndicatorLevel): void { + mobx.action(() => { + this.statusIndicator.set(indicator); + })(); + } + + /** + * Set the number of running commands for the screen. + * @param numRunning The number of running commands. + */ + setNumRunningCmds(numRunning: number): void { + mobx.action(() => { + this.numRunningCmds.set(numRunning); + })(); + } + + termCustomKeyHandler(e: any, termWrap: TermWrap): boolean { + return true; + } + + loadTerminalRenderer(elem: Element, line: LineType, cmd: Cmd, width: number) { + let lineId = cmd.lineId; + let termWrap = this.getTermWrap(lineId); + if (termWrap != null) { + console.log("term-wrap already exists for", this.screenId, lineId); + return; + } + let usedRows = this.globalModel.getContentHeight(getRendererContext(line)); + if (line.contentheight != null && line.contentheight != -1) { + usedRows = line.contentheight; + } + let termContext = { + sessionId: this.sessionId, + screenId: this.screenId, + lineId: line.lineid, + lineNum: line.linenum, + }; + // console.log("globalmodel)))))))))))))", this.globalModel.termThemeSrcEl.get()); + termWrap = new TermWrap(elem, { + termContext: termContext, + usedRows: usedRows, + termOpts: cmd.getTermOpts(), + winSize: { height: 0, width: width }, + dataHandler: cmd.handleData.bind(cmd), + focusHandler: (focus: boolean) => this.setLineFocus(line.linenum, focus), + isRunning: cmd.isRunning(), + customKeyHandler: this.termCustomKeyHandler.bind(this), + fontSize: this.globalModel.getTermFontSize(), + fontFamily: this.globalModel.getTermFontFamily(), + ptyDataSource: getTermPtyData, + onUpdateContentHeight: (termContext: RendererContext, height: number) => { + this.globalModel.setContentHeight(termContext, height); + }, + }); + this.terminals[lineId] = termWrap; + if (this.focusType.get() == "cmd" && this.selectedLine.get() == line.linenum) { + termWrap.giveFocus(); + } + } + + unloadRenderer(lineId: string) { + let rmodel = this.renderers[lineId]; + if (rmodel != null) { + rmodel.dispose(); + delete this.renderers[lineId]; + } + let term = this.terminals[lineId]; + if (term != null) { + term.dispose(); + delete this.terminals[lineId]; + } + } + + getUsedRows(context: RendererContext, line: LineType, cmd: Cmd, width: number): number { + if (cmd == null) { + return 0; + } + let termOpts = cmd.getTermOpts(); + if (!termOpts.flexrows) { + return termOpts.rows; + } + let termWrap = this.getTermWrap(cmd.lineId); + if (termWrap == null) { + let usedRows = this.globalModel.getContentHeight(context); + if (usedRows != null) { + return usedRows; + } + if (line.contentheight != null && line.contentheight != -1) { + return line.contentheight; + } + return cmd.isRunning() ? 1 : 0; + } + return termWrap.getUsedRows(); + } + + getIsFocused(lineNum: number): boolean { + return this.termLineNumFocus.get() == lineNum; + } + + getSelectedLine(): number { + return this.selectedLine.get(); + } + + getScreenLines(): ScreenLines { + return this.globalModel.getScreenLinesById(this.screenId); + } + + getFocusType(): FocusTypeStrs { + return this.focusType.get(); + } + + giveFocus(): void { + if (!this.isActive()) { + return; + } + let ftype = this.focusType.get(); + if (ftype == "input") { + this.globalModel.inputModel.giveFocus(); + } else { + let sline: LineType = null; + if (this.selectedLine.get() != 0) { + sline = this.getLineByNum(this.selectedLine.get()); + } + if (sline != null) { + let renderer = this.getRenderer(sline.lineid); + if (renderer != null) { + renderer.giveFocus(); + } + let termWrap = this.getTermWrap(sline.lineid); + if (termWrap != null) { + termWrap.giveFocus(); + } + } + } + } +} + +export { Screen }; diff --git a/src/models/screenlines.ts b/src/models/screenlines.ts new file mode 100644 index 0000000000..f34b297515 --- /dev/null +++ b/src/models/screenlines.ts @@ -0,0 +1,161 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { sprintf } from "sprintf-js"; +import { genMergeSimpleData } from "@/util/util"; +import { cmdStatusIsRunning } from "@/app/line/lineutil"; +import { Cmd } from "./cmd"; + +class ScreenLines { + screenId: string; + loaded: OV = mobx.observable.box(false, { name: "slines-loaded" }); + loadError: OV = mobx.observable.box(null); + lines: OArr = mobx.observable.array([], { + name: "slines-lines", + deep: false, + }); + cmds: Record = {}; // lineid => Cmd + + constructor(screenId: string) { + this.screenId = screenId; + } + + getNonArchivedLines(): LineType[] { + let rtn: LineType[] = []; + for (const line of this.lines) { + if (line.archived) { + continue; + } + rtn.push(line); + } + return rtn; + } + + updateData(slines: ScreenLinesType, load: boolean) { + mobx.action(() => { + if (load) { + this.loaded.set(true); + } + genMergeSimpleData( + this.lines, + slines.lines, + (l: LineType) => String(l.lineid), + (l: LineType) => sprintf("%013d:%s", l.ts, l.lineid) + ); + let cmds = slines.cmds || []; + for (const cmd of cmds) { + this.cmds[cmd.lineid] = new Cmd(cmd); + } + })(); + } + + setLoadError(errStr: string) { + mobx.action(() => { + this.loaded.set(true); + this.loadError.set(errStr); + })(); + } + + dispose() {} + + getCmd(lineId: string): Cmd { + return this.cmds[lineId]; + } + + /** + * Get all running cmds in the screen. + * @param returnFirst If true, return the first running cmd found. + * @returns An array of running cmds, or the first running cmd if returnFirst is true. + */ + getRunningCmdLines(returnFirst?: boolean): LineType[] { + let rtn: LineType[] = []; + for (const line of this.lines) { + const cmd = this.getCmd(line.lineid); + if (cmd == null) { + continue; + } + const status = cmd.getStatus(); + if (cmdStatusIsRunning(status)) { + if (returnFirst) { + return [line]; + } + rtn.push(line); + } + } + return rtn; + } + + /** + * Check if there are any running cmds in the screen. + * @returns True if there are any running cmds. + */ + hasRunningCmdLines(): boolean { + return this.getRunningCmdLines(true).length > 0; + } + + updateCmd(cmd: CmdDataType): void { + if (cmd.remove) { + throw new Error("cannot remove cmd with updateCmd call [" + cmd.lineid + "]"); + } + let origCmd = this.cmds[cmd.lineid]; + if (origCmd != null) { + origCmd.setCmd(cmd); + } + } + + mergeCmd(cmd: CmdDataType): void { + if (cmd.remove) { + delete this.cmds[cmd.lineid]; + return; + } + let origCmd = this.cmds[cmd.lineid]; + if (origCmd == null) { + this.cmds[cmd.lineid] = new Cmd(cmd); + return; + } + origCmd.setCmd(cmd); + } + + addLineCmd(line: LineType, cmd: CmdDataType, interactive: boolean) { + if (!this.loaded.get()) { + return; + } + mobx.action(() => { + if (cmd != null) { + this.mergeCmd(cmd); + } + if (line != null) { + let lines = this.lines; + if (line.remove) { + for (let i = 0; i < lines.length; i++) { + if (lines[i].lineid == line.lineid) { + this.lines.splice(i, 1); + break; + } + } + return; + } + let lineIdx = 0; + for (lineIdx; lineIdx < lines.length; lineIdx++) { + let lineId = lines[lineIdx].lineid; + let curTs = lines[lineIdx].ts; + if (lineId == line.lineid) { + this.lines[lineIdx] = line; + return; + } + if (curTs > line.ts || (curTs == line.ts && lineId > line.lineid)) { + break; + } + } + if (lineIdx == lines.length) { + this.lines.push(line); + return; + } + this.lines.splice(lineIdx, 0, line); + } + })(); + } +} + +export { ScreenLines }; diff --git a/src/models/session.ts b/src/models/session.ts new file mode 100644 index 0000000000..3dca33106e --- /dev/null +++ b/src/models/session.ts @@ -0,0 +1,102 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { sprintf } from "sprintf-js"; +import { genMergeSimpleData, isBlank, ces } from "@/util/util"; +import { Model } from "./model"; +import { Screen } from "./screen"; + +class Session { + sessionId: string; + name: OV; + activeScreenId: OV; + sessionIdx: OV; + notifyNum: OV = mobx.observable.box(0); + remoteInstances: OArr; + archived: OV; + globalModel: Model; + + constructor(sdata: SessionDataType, globalModel: Model) { + this.globalModel = globalModel; + this.sessionId = sdata.sessionid; + this.name = mobx.observable.box(sdata.name); + this.sessionIdx = mobx.observable.box(sdata.sessionidx); + this.archived = mobx.observable.box(!!sdata.archived); + this.activeScreenId = mobx.observable.box(ces(sdata.activescreenid)); + let remotes = sdata.remotes || []; + this.remoteInstances = mobx.observable.array(remotes); + } + + dispose(): void {} + + // session updates only contain screens (no windows) + mergeData(sdata: SessionDataType) { + if (sdata.sessionid != this.sessionId) { + throw new Error( + sprintf( + "cannot merge session data, sessionids don't match sid=%s, data-sid=%s", + this.sessionId, + sdata.sessionid + ) + ); + } + mobx.action(() => { + if (!isBlank(sdata.name)) { + this.name.set(sdata.name); + } + if (sdata.sessionidx > 0) { + this.sessionIdx.set(sdata.sessionidx); + } + if (sdata.notifynum >= 0) { + this.notifyNum.set(sdata.notifynum); + } + this.archived.set(!!sdata.archived); + if (!isBlank(sdata.activescreenid)) { + let screen = this.getScreenById(sdata.activescreenid); + if (screen == null) { + console.log( + sprintf("got session update, activescreenid=%s, screen not found", sdata.activescreenid) + ); + } else { + this.activeScreenId.set(sdata.activescreenid); + } + } + genMergeSimpleData(this.remoteInstances, sdata.remotes, (r) => r.riid, null); + })(); + } + + getActiveScreen(): Screen { + return this.getScreenById(this.activeScreenId.get()); + } + + setActiveScreenId(screenId: string) { + this.activeScreenId.set(screenId); + } + + getScreenById(screenId: string): Screen { + if (screenId == null) { + return null; + } + return this.globalModel.getScreenById(this.sessionId, screenId); + } + + getRemoteInstance(screenId: string, rptr: RemotePtrType): RemoteInstanceType { + if (rptr.name.startsWith("*")) { + screenId = ""; + } + for (const rdata of this.remoteInstances) { + if ( + rdata.screenid == screenId && + rdata.remoteid == rptr.remoteid && + rdata.remoteownerid == rptr.ownerid && + rdata.name == rptr.name + ) { + return rdata; + } + } + return null; + } +} + +export { Session }; diff --git a/src/models/sidebar.ts b/src/models/sidebar.ts new file mode 100644 index 0000000000..b6b1792b59 --- /dev/null +++ b/src/models/sidebar.ts @@ -0,0 +1,13 @@ +import { Model } from "./model"; + +export interface SidebarModel { + readonly globalModel: Model; + readonly tempWidth: OV; + readonly tempCollapsed: OV; + readonly isDragging: OV; + + setTempWidthAndTempCollapsed(newWidth: number, newCollapsed: boolean): void; + getWidth(ignoreCollapse?: boolean): number; + getCollapsed(): boolean; + saveState(width: number, collapsed: boolean): void; +} diff --git a/src/models/sidebarchat.ts b/src/models/sidebarchat.ts new file mode 100644 index 0000000000..41cfaff05d --- /dev/null +++ b/src/models/sidebarchat.ts @@ -0,0 +1,92 @@ +import * as mobx from "mobx"; +import { Model } from "./model"; + +class SidebarChatModel { + globalModel: Model; + sidebarChatFocused: OV = mobx.observable.box(false, { name: "SidebarChatModel-sidebarChatFocused" }); + cmdAndOutput: OV<{ cmd: string; output: string; usedRows: number; isError: boolean }> = mobx.observable.box( + { cmd: "", output: "", usedRows: 0, isError: false }, + { name: "SidebarChatModel-cmdAndOutput" } + ); + cmdFromChat: OV = mobx.observable.box("", { name: "SidebarChatModel-cmdFromChat" }); + selectedCodeBlockIndex: OV = mobx.observable.box(null, { name: "SidebarChatModel-codeBlockIndex" }); + + constructor(globalModel: Model) { + this.globalModel = globalModel; + mobx.makeObservable(this); + } + + // block can be the chat-window in terms of focus + @mobx.action + setFocus(focus: boolean): void { + this.resetFocus(); + this.sidebarChatFocused.set(focus); + } + + hasFocus(): boolean { + return this.sidebarChatFocused.get(); + } + + @mobx.action + resetFocus(): void { + this.sidebarChatFocused.set(false); + } + + @mobx.action + setCmdAndOutput(cmd: string, output: string, usedRows: number, isError: boolean): void { + console.log("cmd", cmd); + this.cmdAndOutput.set({ + cmd: cmd, + output: output, + usedRows: usedRows, + isError: isError, + }); + } + + getCmdAndOutput(): { cmd: string; output: string; usedRows: number; isError: boolean } { + return this.cmdAndOutput.get(); + } + + @mobx.action + resetCmdAndOutput(): void { + this.cmdAndOutput.set({ + cmd: "", + output: "", + usedRows: 0, + isError: false, + }); + } + + hasCmdAndOutput(): boolean { + const { cmd, output } = this.cmdAndOutput.get(); + return cmd.length > 0 || output.length > 0; + } + + @mobx.action + setCmdToExec(cmd: string): void { + this.cmdFromChat.set(cmd); + } + + @mobx.action + resetCmdToExec(): void { + this.cmdFromChat.set(""); + } + + getCmdToExec(): string { + return this.cmdFromChat.get(); + } + + getSelectedCodeBlockIndex(): number { + return this.selectedCodeBlockIndex.get(); + } + + setSelectedCodeBlockIndex(index: number): void { + this.selectedCodeBlockIndex.set(index); + } + + resetSelectedCodeBlockIndex(): void { + this.selectedCodeBlockIndex.set(null); + } +} + +export { SidebarChatModel }; diff --git a/src/models/speciallinecontainer.ts b/src/models/speciallinecontainer.ts new file mode 100644 index 0000000000..0d445f41f8 --- /dev/null +++ b/src/models/speciallinecontainer.ts @@ -0,0 +1,164 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { TermWrap } from "@/plugins/terminal/term"; +import { windowWidthToCols } from "@/util/textmeasure"; +import { getRendererContext } from "@/app/line/lineutil"; +import { getTermPtyData } from "@/util/modelutil"; +import { Cmd } from "./cmd"; +import { Model } from "./model"; + +type CmdFinder = { + getCmdById(cmdId: string): Cmd; +}; + +class SpecialLineContainer { + globalModel: Model; + wsize: WindowSize; + allowInput: boolean; + terminal: TermWrap; + renderer: RendererModel; + cmd: Cmd; + cmdFinder: CmdFinder; + containerType: LineContainerStrs; + + constructor(cmdFinder: CmdFinder, wsize: WindowSize, allowInput: boolean, containerType: LineContainerStrs) { + this.globalModel = Model.getInstance(); + this.cmdFinder = cmdFinder; + this.wsize = wsize; + this.allowInput = allowInput; + } + + getCmd(line: LineType): Cmd { + if (this.cmd == null) { + this.cmd = this.cmdFinder.getCmdById(line.lineid); + } + return this.cmd; + } + + getContainerType(): LineContainerStrs { + return this.containerType; + } + + isSidebarOpen(): boolean { + return false; + } + + isLineIdInSidebar(lineId: string): boolean { + return false; + } + + setLineFocus(lineNum: number, focus: boolean): void { + return; + } + + setContentHeight(context: RendererContext, height: number): void { + return; + } + + getMaxContentSize(): WindowSize { + return this.wsize; + } + + getIdealContentSize(): WindowSize { + return this.wsize; + } + + loadTerminalRenderer(elem: Element, line: LineType, cmd: Cmd, width: number): void { + this.unloadRenderer(null); + let lineId = cmd.lineId; + let termWrap = this.getTermWrap(lineId); + if (termWrap != null) { + console.log("term-wrap already exists for", line.screenid, lineId); + return; + } + let usedRows = this.globalModel.getContentHeight(getRendererContext(line)); + if (line.contentheight != null && line.contentheight != -1) { + usedRows = line.contentheight; + } + let termContext = { + screenId: line.screenid, + lineId: line.lineid, + lineNum: line.linenum, + }; + termWrap = new TermWrap(elem, { + termContext: termContext, + usedRows: usedRows, + termOpts: cmd.getTermOpts(), + winSize: { height: 0, width: width }, + dataHandler: null, + focusHandler: null, + isRunning: cmd.isRunning(), + customKeyHandler: null, + fontSize: this.globalModel.getTermFontSize(), + fontFamily: this.globalModel.getTermFontFamily(), + ptyDataSource: getTermPtyData, + onUpdateContentHeight: null, + }); + this.terminal = termWrap; + } + + registerRenderer(lineId: string, renderer: RendererModel): void { + this.renderer = renderer; + } + + unloadRenderer(lineId: string): void { + if (this.renderer != null) { + this.renderer.dispose(); + this.renderer = null; + } + if (this.terminal != null) { + this.terminal.dispose(); + this.terminal = null; + } + } + + getContentHeight(context: RendererContext): number { + return this.globalModel.getContentHeight(context); + } + + getUsedRows(context: RendererContext, line: LineType, cmd: Cmd, width: number): number { + if (cmd == null) { + return 0; + } + let termOpts = cmd.getTermOpts(); + if (!termOpts.flexrows) { + return termOpts.rows; + } + let termWrap = this.getTermWrap(cmd.lineId); + if (termWrap == null) { + let cols = windowWidthToCols(width, this.globalModel.getTermFontSize()); + let usedRows = this.globalModel.getContentHeight(context); + if (usedRows != null) { + return usedRows; + } + if (line.contentheight != null && line.contentheight != -1) { + return line.contentheight; + } + return cmd.isRunning() ? 1 : 0; + } + return termWrap.getUsedRows(); + } + + getIsFocused(lineNum: number): boolean { + return false; + } + + getRenderer(lineId: string): RendererModel { + return this.renderer; + } + + getTermWrap(lineId: string): TermWrap { + return this.terminal; + } + + getFocusType(): FocusTypeStrs { + return "input"; + } + + getSelectedLine(): number { + return null; + } +} + +export { SpecialLineContainer }; diff --git a/src/models/ws.ts b/src/models/ws.ts new file mode 100644 index 0000000000..594b9f02bb --- /dev/null +++ b/src/models/ws.ts @@ -0,0 +1,217 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { sprintf } from "sprintf-js"; +import { boundMethod } from "autobind-decorator"; +import dayjs from "dayjs"; +import * as appconst from "@/app/appconst"; + +class WSControl { + wsConn: any; + open: mobx.IObservableValue; + opening: boolean = false; + reconnectTimes: number = 0; + msgQueue: any[] = []; + clientId: string; + messageCallback: (any) => void = null; + watchSessionId: string = null; + watchScreenId: string = null; + wsLog: mobx.IObservableArray = mobx.observable.array([], { name: "wsLog" }); + authKey: string; + baseHostPort: string; + lastReconnectTime: number = 0; + + constructor(baseHostPort: string, clientId: string, authKey: string, messageCallback: (any) => void) { + this.baseHostPort = baseHostPort; + this.messageCallback = messageCallback; + this.clientId = clientId; + this.authKey = authKey; + this.open = mobx.observable.box(false, { name: "WSOpen" }); + setInterval(this.sendPing, 5000); + } + + log(str: string) { + mobx.action(() => { + let ts = dayjs().format("YYYY-MM-DD HH:mm:ss"); + this.wsLog.push("[" + ts + "] " + str); + if (this.wsLog.length > 50) { + this.wsLog.splice(0, this.wsLog.length - 50); + } + })(); + } + + @mobx.action + setOpen(val: boolean) { + mobx.action(() => { + this.open.set(val); + })(); + } + + connectNow(desc: string) { + if (this.open.get()) { + return; + } + this.lastReconnectTime = Date.now(); + this.log(sprintf("try reconnect (%s)", desc)); + this.opening = true; + this.wsConn = new WebSocket(this.baseHostPort + "/ws?clientid=" + this.clientId); + this.wsConn.onopen = this.onopen; + this.wsConn.onmessage = this.onmessage; + this.wsConn.onclose = this.onclose; + // turns out onerror is not necessary (onclose always follows onerror) + // this.wsConn.onerror = this.onerror; + } + + reconnect(forceClose?: boolean) { + if (this.open.get()) { + if (forceClose) { + this.wsConn.close(); // this will force a reconnect + } + return; + } + this.reconnectTimes++; + if (this.reconnectTimes > 20) { + this.log("cannot connect, giving up"); + return; + } + let timeoutArr = [0, 0, 2, 5, 10, 10, 30, 60]; + let timeout = 60; + if (this.reconnectTimes < timeoutArr.length) { + timeout = timeoutArr[this.reconnectTimes]; + } + if (Date.now() - this.lastReconnectTime < 500) { + timeout = 1; + } + if (timeout > 0) { + this.log(sprintf("sleeping %ds", timeout)); + } + setTimeout(() => { + this.connectNow(String(this.reconnectTimes)); + }, timeout * 1000); + } + + @boundMethod + onclose(event: any) { + // console.log("close", event); + if (event.wasClean) { + this.log("connection closed"); + } else { + this.log("connection error/disconnected"); + } + if (this.open.get() || this.opening) { + this.setOpen(false); + this.opening = false; + this.reconnect(); + } + } + + @boundMethod + onopen() { + this.log("connection open"); + this.setOpen(true); + this.opening = false; + this.runMsgQueue(); + this.sendWatchScreenPacket(true); + // reconnectTimes is reset in onmessage:hello + } + + runMsgQueue() { + if (!this.open.get()) { + return; + } + if (this.msgQueue.length == 0) { + return; + } + let msg = this.msgQueue.shift(); + this.sendMessage(msg); + setTimeout(() => { + this.runMsgQueue(); + }, 100); + } + + @boundMethod + onmessage(event: any) { + let eventData = null; + if (event.data != null) { + eventData = JSON.parse(event.data); + } + if (eventData == null) { + return; + } + if (eventData.type == "ping") { + this.wsConn.send(JSON.stringify({ type: "pong", stime: Date.now() })); + return; + } + if (eventData.type == "pong") { + // nothing + return; + } + if (eventData.type == "hello") { + this.reconnectTimes = 0; + return; + } + if (this.messageCallback) { + try { + this.messageCallback(eventData); + } catch (e) { + console.log("[error] messageCallback", e); + } + } + } + + @boundMethod + sendPing() { + if (!this.open.get()) { + return; + } + this.wsConn.send(JSON.stringify({ type: "ping", stime: Date.now() })); + } + + sendMessage(data: any) { + if (!this.open.get()) { + return; + } + let msg = JSON.stringify(data); + const byteSize = new Blob([msg]).size; + if (byteSize > appconst.MaxWebSocketSendSize) { + console.log("ws message too large", byteSize, data.type, msg.substring(0, 100)); + return; + } + this.wsConn.send(msg); + } + + pushMessage(data: any) { + if (!this.open.get()) { + this.msgQueue.push(data); + return; + } + this.sendMessage(data); + } + + sendWatchScreenPacket(connect: boolean) { + let pk: WatchScreenPacketType = { + type: "watchscreen", + connect: connect, + sessionid: null, + screenid: null, + authkey: this.authKey, + }; + if (this.watchSessionId != null) { + pk.sessionid = this.watchSessionId; + } + if (this.watchScreenId != null) { + pk.screenid = this.watchScreenId; + } + this.pushMessage(pk); + } + + // these params can be null. (null, null) means stop watching + watchScreen(sessionId: string, screenId: string) { + this.watchSessionId = sessionId; + this.watchScreenId = screenId; + this.sendWatchScreenPacket(false); + } +} + +export { WSControl }; diff --git a/src/plugins/code/code.less b/src/plugins/code/code.less new file mode 100644 index 0000000000..9fd9a4ba5a --- /dev/null +++ b/src/plugins/code/code.less @@ -0,0 +1,139 @@ +.code-renderer { + .monaco-editor { + .monaco-editor-background, + .margin-view-overlays { + background-color: var(--app-bg-color) !important; + } + .scrollbar { + height: 4px !important; + width: 4px !important; + .slider { + background-color: var(--scrollbar-background-color) !important; + } + } + } + .buttonContainer { + opacity: 0; + position: absolute; + padding: 0.5rem; + right: 0; + top: 3em; + z-index: 11; + .dropdown, + .button { + position: relative; + font-size: 0.8rem; + min-height: 1.5rem; + max-height: 1.5rem; + margin-right: 0.5rem; + line-height: 1rem; + } + .dropdown { + max-width: 7rem; + } + } + .scroller { + padding-top: 10px; + padding-bottom: 15px; + overflow: hidden; + } + &:hover { + .buttonContainer { + opacity: 1; + } + .scroller { + overflow: auto; + } + } + + .code-message { + text-wrap: nowrap; + text-overflow: ellipsis; + overflow: hidden; + color: var(--term-bright-green); + &.error { + color: var(--term-bright-red); + } + } + + .readonly { + position: absolute; + top: 0.2em; + right: 12rem; + border-radius: 5px; + background-color: var(--form-element-secondary-color); + color: var(--app-text-disabled-color); + z-index: 1; + padding: 0 0.8em; + font-size: 0.8em; + } + + /** customising react-split-it **/ + .jsoneditor { + border: none !important; + } + + .split-horizontal { + display: flex; + width: 100%; + height: calc(100% - var(--termlineheight) - 11px); + border-bottom: 1px solid var(--app-border-color); + } + + .split-vertical { + display: flex; + flex-direction: column; + height: 100%; + } + + .code-statusbar { + display: flex; + gap: var(--termpad); + flex-direction: row; + align-items: center; + font-size: var(--termfontsize); + line-height: var(--termlineheight); + background-color: var(--app-panel-bg-color); + border-top: 1px solid var(--app-border-color); + padding: 3px var(--termpad) 3px var(--termpad); + height: calc(var(--termlineheight) + 11px); + + .wave-button { + line-height: var(--termlineheight) !important; + text-wrap: nowrap; + } + + select.dropdown { + max-width: 200px; + } + } + + .gutter { + flex-shrink: 0; + flex-grow: 0; + background: var(--code-gutter-bg-color); + max-width: 3px; + } + .gutter-horizontal { + cursor: col-resize; + } + .gutter-vertical { + cursor: row-resize; + } + .gutter:hover { + background: var(--app-accent-color); + } + .gutter-dragging:hover { + background: var(--app-accent-color); + } + + .pane { + flex-shrink: 1; + flex-grow: 1; + position: relative; + min-width: 20rem; + } + .pane-dragging { + overflow: hidden; + } +} diff --git a/src/plugins/code/code.tsx b/src/plugins/code/code.tsx new file mode 100644 index 0000000000..2f5fef458e --- /dev/null +++ b/src/plugins/code/code.tsx @@ -0,0 +1,594 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobx from "mobx"; +import { boundMethod } from "autobind-decorator"; +import Editor, { Monaco } from "@monaco-editor/react"; +import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; +import { clsx } from "clsx"; +import { If } from "tsx-control-statements/components"; +import { Markdown, Button } from "@/elements"; +import { GlobalModel, GlobalCommandRunner } from "@/models"; +import Split from "react-split-it"; +import loader from "@monaco-editor/loader"; +import { adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; + +import "./code.less"; + +// TODO: need to update these on theme change (pull from CSS vars) +document.addEventListener("DOMContentLoaded", () => { + loader.config({ paths: { vs: "./node_modules/monaco-editor/min/vs" } }); + loader.init().then(() => { + monaco.editor.defineTheme("wave-theme-dark", { + base: "hc-black", + inherit: true, + rules: [], + colors: { + "editor.background": "#000000", + }, + }); + + monaco.editor.defineTheme("wave-theme-light", { + base: "hc-light", + inherit: true, + rules: [], + colors: { + "editor.background": "#fefefe", + }, + }); + }); +}); + +function renderCmdText(text: string): any { + return ⌘{text}; +} + +// there is a global monaco variable (TODO get the correct TS type) +declare var monaco: any; + +class CodeKeybindings extends React.Component<{ codeObject: SourceCodeRenderer }, {}> { + componentDidMount(): void { + this.props.codeObject.registerKeybindings(); + } + componentWillUnmount(): void { + this.props.codeObject.unregisterKeybindings(); + } + render() { + return null; + } +} + +class SourceCodeRenderer extends React.Component< + { + data: ExtBlob; + cmdstr: string; + cwd: string; + readOnly: boolean; + notFound: boolean; + exitcode: number; + context: RendererContext; + opts: RendererOpts; + savedHeight: number; + scrollToBringIntoViewport: () => void; + lineState: LineStateType; + isSelected: boolean; + shouldFocus: boolean; + rendererApi: RendererModelContainerApi; + }, + { + code: string; + languages: string[]; + selectedLanguage: string; + isSave: boolean; + isClosed: boolean; + editorHeight: number; + message: { status: "success" | "error"; text: string }; + isPreviewerAvailable: boolean; + showPreview: boolean; + editorFraction: number; + showReadonly: boolean; + } +> { + /** + * codeCache is a Hashmap with key=screenId:lineId:filepath and value=code + * Editor should never read the code directly from the filesystem. it should read from the cache. + */ + static readonly codeCache = new Map(); + + // which languages have preview options + languagesWithPreviewer: string[] = ["markdown", "mdx"]; + filePath: string; + cacheKey: string; + originalCode: string; + monacoEditor: MonacoTypes.editor.IStandaloneCodeEditor; // reference to mounted monaco editor. TODO need the correct type + markdownRef: React.RefObject; + syncing: boolean; + + constructor(props) { + super(props); + this.monacoEditor = null; + const editorHeight = Math.max(props.savedHeight - this.getEditorHeightBuffer(), 0); // must subtract the padding/margin to get the real editorHeight + this.markdownRef = React.createRef(); + this.syncing = false; + const isClosed = props.lineState["prompt:closed"]; + this.state = { + code: null, + languages: [], + selectedLanguage: "", + isSave: false, + isClosed: isClosed, + editorHeight, + message: null, + isPreviewerAvailable: false, + showPreview: this.props.lineState["showPreview"], + editorFraction: this.props.lineState["editorFraction"] || 0.5, + showReadonly: false, + }; + } + + componentDidMount(): void { + this.filePath = this.props.lineState["prompt:file"]; + const { screenId, lineId } = this.props.context; + this.cacheKey = `${screenId}-${lineId}-${this.filePath}`; + const code = SourceCodeRenderer.codeCache.get(this.cacheKey); + if (code) { + this.setState({ code }); + } else { + this.props.data.text().then((code) => { + this.originalCode = code; + this.setState({ code }); + SourceCodeRenderer.codeCache.set(this.cacheKey, code); + }); + } + } + + componentWillUnmount() { + this.unregisterKeybindings(); + } + + componentDidUpdate(prevProps: any): void { + if (!prevProps.shouldFocus && this.props.shouldFocus) { + if (this.monacoEditor) { + this.monacoEditor.focus(); + } + } + } + + @boundMethod + saveLineState(kvp) { + const { screenId, lineId } = this.props.context; + GlobalCommandRunner.setLineState(screenId, lineId, { ...this.props.lineState, ...kvp }, false); + } + + @boundMethod + setInitialLanguage(editor) { + // set all languages + const languages = monaco.languages.getLanguages().map((lang) => lang.id); + this.setState({ languages }); + // detect the current language from previous settings + let detectedLanguage = this.props.lineState["lang"]; + // if not found, we try to grab the filename from with filePath (coming from lineState["prompt:file"]) or cmdstr + if (!detectedLanguage) { + const strForFilePath = this.filePath || this.props.cmdstr; + const extension = RegExp(/(?:[^\\/:*?"<>|\r\n]+\.)([a-zA-Z0-9]+)\b/).exec(strForFilePath)?.[1] || ""; + const detectedLanguageObj = monaco.languages + .getLanguages() + .find((lang) => lang.extensions?.includes("." + extension)); + if (detectedLanguageObj) { + detectedLanguage = detectedLanguageObj.id; + this.saveLineState({ lang: detectedLanguage }); + } + } + if (detectedLanguage) { + const model = editor.getModel(); + if (model) { + monaco.editor.setModelLanguage(model, detectedLanguage); + this.setState({ + selectedLanguage: detectedLanguage, + isPreviewerAvailable: this.languagesWithPreviewer.includes(detectedLanguage), + }); + } + } + } + + @boundMethod + registerKeybindings() { + const { lineId } = this.props.context; + const domain = "code-" + lineId; + const keybindManager = GlobalModel.keybindManager; + keybindManager.registerKeybinding("plugin", domain, "codeedit:save", (waveEvent) => { + this.doSave(); + return true; + }); + keybindManager.registerKeybinding("plugin", domain, "codeedit:close", (waveEvent) => { + this.doClose(); + return true; + }); + keybindManager.registerKeybinding("plugin", domain, "codeedit:togglePreview", (waveEvent) => { + this.togglePreview(); + return true; + }); + } + + @boundMethod + unregisterKeybindings() { + const { lineId } = this.props.context; + const domain = "code-" + lineId; + GlobalModel.keybindManager.unregisterDomain(domain); + } + + @boundMethod + handleEditorDidMount(editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) { + this.monacoEditor = editor; + this.setInitialLanguage(editor); + this.setEditorHeight(); + setTimeout(() => { + const opts = this.getEditorOptions(); + editor.updateOptions(opts); + }, 2000); + editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => { + const waveEvent = adaptFromReactOrNativeKeyEvent(e.browserEvent); + if ( + GlobalModel.keybindManager.checkKeysPressed(waveEvent, [ + "codeedit:save", + "codeedit:close", + "codeedit:togglePreview", + ]) + ) { + GlobalModel.keybindManager.processKeyEvent(e.browserEvent, waveEvent); + } + }); + editor.onDidScrollChange((e) => { + if (!this.syncing && e.scrollTopChanged) { + this.syncing = true; + this.handleEditorScrollChange(e); + this.syncing = false; + } + }); + if (this.props.shouldFocus) { + this.monacoEditor.focus(); + this.props.rendererApi.onFocusChanged(true); + } + if (this.monacoEditor.onDidFocusEditorWidget) { + this.monacoEditor.onDidFocusEditorWidget(() => { + this.props.rendererApi.onFocusChanged(true); + }); + this.monacoEditor.onDidBlurEditorWidget(() => { + this.props.rendererApi.onFocusChanged(false); + }); + } + if (!this.getAllowEditing()) this.setState({ showReadonly: true }); + } + + @boundMethod + handleEditorScrollChange(e) { + if (!this.state.showPreview) return; + const scrollableHeightEditor = this.monacoEditor.getScrollHeight() - this.monacoEditor.getLayoutInfo().height; + const verticalScrollPercentage = e.scrollTop / scrollableHeightEditor; + const markdownDiv = this.markdownRef.current; + if (markdownDiv) { + const scrollableHeightMarkdown = markdownDiv.scrollHeight - markdownDiv.clientHeight; + markdownDiv.scrollTop = verticalScrollPercentage * scrollableHeightMarkdown; + } + } + + @boundMethod + handleDivScroll() { + if (!this.syncing) { + this.syncing = true; + // Calculate the scroll percentage for the markdown div + const markdownDiv = this.markdownRef.current; + const scrollableHeightMarkdown = markdownDiv.scrollHeight - markdownDiv.clientHeight; + const verticalScrollPercentage = markdownDiv.scrollTop / scrollableHeightMarkdown; + + // Apply the same percentage to the editor + const scrollableHeightEditor = + this.monacoEditor.getScrollHeight() - this.monacoEditor.getLayoutInfo().height; + this.monacoEditor.setScrollTop(verticalScrollPercentage * scrollableHeightEditor); + + this.syncing = false; + } + } + + @boundMethod + handleLanguageChange(e: any) { + const selectedLanguage = e.target.value; + this.setState({ + selectedLanguage, + isPreviewerAvailable: this.languagesWithPreviewer.includes(selectedLanguage), + }); + if (this.monacoEditor) { + const model = this.monacoEditor.getModel(); + if (model) { + monaco.editor.setModelLanguage(model, selectedLanguage); + this.saveLineState({ lang: selectedLanguage }); + } + } + } + + @boundMethod + doSave(onSave = () => {}) { + if (!this.state.isSave) return; + const { screenId, lineId } = this.props.context; + const encodedCode = new TextEncoder().encode(this.state.code); + GlobalModel.writeRemoteFile(screenId, lineId, this.filePath, encodedCode, { useTemp: true }) + .then(() => { + this.originalCode = this.state.code; + this.setState( + { + isSave: false, + message: { status: "success", text: `Saved to ${this.props.cwd}/${this.filePath}` }, + }, + onSave + ); + setTimeout(() => this.setState({ message: null }), 3000); + }) + .catch((e) => { + this.setState({ message: { status: "error", text: e.message } }); + setTimeout(() => this.setState({ message: null }), 3000); + }); + } + + @boundMethod + doClose() { + // if there is unsaved data + if (this.state.isSave) + return GlobalModel.showAlert({ + message: "Do you want to Save your changes before closing?", + confirm: true, + }).then((result) => { + if (result) return this.doSave(this.doClose); + this.setState({ code: this.originalCode, isSave: false }, this.doClose); + }); + const { screenId, lineId } = this.props.context; + GlobalCommandRunner.setLineState(screenId, lineId, { ...this.props.lineState, "prompt:closed": true }, false) + .then(() => { + this.setState({ + isClosed: true, + message: { status: "success", text: `Closed. This editor is now read-only` }, + showReadonly: true, + }); + setTimeout(() => { + this.setEditorHeight(); + }, 100); + setTimeout(() => { + this.setState({ message: null }); + }, 3000); + }) + .catch((e) => { + this.setState({ message: { status: "error", text: e.message } }); + + setTimeout(() => { + this.setState({ message: null }); + }, 3000); + }); + if (this.props.shouldFocus) { + GlobalCommandRunner.screenSetFocus("input"); + } + } + + @boundMethod + handleEditorChange(code) { + SourceCodeRenderer.codeCache.set(this.cacheKey, code); + this.setState({ code }, () => { + this.setEditorHeight(); + this.setState({ isSave: code !== this.originalCode }); + }); + } + + @boundMethod + getEditorHeightBuffer(): number { + const heightBuffer = GlobalModel.lineHeightEnv.lineHeight + 11; + return heightBuffer; + } + + @boundMethod + setEditorHeight() { + const maxEditorHeight = this.props.opts.maxSize.height - this.getEditorHeightBuffer(); + let _editorHeight = maxEditorHeight; + const allowEditing = this.getAllowEditing(); + if (!allowEditing) { + const noOfLines = Math.max(this.state.code.split("\n").length, 5); + const lineHeight = Math.ceil(GlobalModel.lineHeightEnv.lineHeight); + _editorHeight = Math.min(noOfLines * lineHeight + 10, maxEditorHeight); + } + this.setState({ editorHeight: _editorHeight }, () => { + if (this.props.isSelected) { + this.props.scrollToBringIntoViewport(); + } + }); + } + + @boundMethod + getAllowEditing(): boolean { + const lineState = this.props.lineState; + const mode = lineState["mode"] || "view"; + if (mode == "view") { + return false; + } + return !(this.props.readOnly || this.state.isClosed); + } + + @boundMethod + updateEditorOpts(): void { + if (!this.monacoEditor) { + return; + } + const opts = this.getEditorOptions(); + this.monacoEditor.updateOptions(opts); + } + + @boundMethod + getEditorOptions(): MonacoTypes.editor.IEditorOptions { + const opts: MonacoTypes.editor.IEditorOptions = { + scrollBeyondLastLine: false, + fontSize: GlobalModel.getTermFontSize(), + fontFamily: GlobalModel.getTermFontFamily(), + readOnly: !this.getAllowEditing(), + }; + const lineState = this.props.lineState; + if (this.state.showPreview || ("minimap" in lineState && !lineState["minimap"])) { + opts.minimap = { enabled: false }; + } + return opts; + } + + @boundMethod + getCodeEditor(theme: string) { + return ( +
+ {this.state.showReadonly &&
{"read-only"}
} + +
+ ); + } + + @boundMethod + getPreviewer() { + return ( +
this.handleDivScroll()} + > + +
+ ); + } + + @boundMethod + togglePreview() { + this.setState((prevState) => { + const newPreviewState = { showPreview: !prevState.showPreview }; + this.saveLineState(newPreviewState); + return newPreviewState; + }); + setTimeout(() => this.updateEditorOpts(), 0); + } + + @boundMethod + getEditorControls() { + const { selectedLanguage, languages, isPreviewerAvailable, showPreview } = this.state; + const allowEditing = this.getAllowEditing(); + return ( + <> + + + + + + + + + + ); + } + + @boundMethod + getMessage() { + return ( +
+
+ {this.state.message.text} +
+
+ ); + } + + @boundMethod + setSizes(sizes: number[]) { + this.setState({ editorFraction: sizes[0] }); + this.saveLineState({ editorFraction: sizes[0] }); + } + + render() { + const { exitcode } = this.props; + const { code, message, isPreviewerAvailable, showPreview, editorFraction } = this.state; + if (this.state.isClosed) { + return
; + } + if (code == null) { + return
; + } + if (exitcode === 1) { + return ( +
+ {code} +
+ ); + } + const { lineNum } = this.props.context; + const screen = GlobalModel.getActiveScreen(); + const lineIsSelected = mobx.computed( + () => screen.getSelectedLine() == lineNum && screen.getFocusType() == "cmd", + { + name: "code-lineisselected", + } + ); + + const theme = `wave-theme-${GlobalModel.isDarkTheme.get() ? "dark" : "light"}`; + return ( +
+ + + + + {this.getCodeEditor(theme)} + {isPreviewerAvailable && showPreview && this.getPreviewer()} + +
+
+ +
+ {this.state.message.text} +
+
+
+ {this.getEditorControls()} +
+
+ ); + } +} + +export { SourceCodeRenderer }; diff --git a/src/plugins/code/icon.svg b/src/plugins/code/icon.svg new file mode 100644 index 0000000000..be5696e37c --- /dev/null +++ b/src/plugins/code/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/plugins/code/meta.json b/src/plugins/code/meta.json new file mode 100644 index 0000000000..baf229879b --- /dev/null +++ b/src/plugins/code/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Code Viewer", + "vendor": "Wave", + "summary": "View and Edit source code with syntax highlightng and code completion." +} diff --git a/src/plugins/code/readme.md b/src/plugins/code/readme.md new file mode 100644 index 0000000000..b427f2ff71 --- /dev/null +++ b/src/plugins/code/readme.md @@ -0,0 +1 @@ +# CodeEdit diff --git a/src/plugins/code/screenshots/1.png b/src/plugins/code/screenshots/1.png new file mode 100644 index 0000000000..9743341dde Binary files /dev/null and b/src/plugins/code/screenshots/1.png differ diff --git a/src/plugins/core/basicrenderer.tsx b/src/plugins/core/basicrenderer.tsx new file mode 100644 index 0000000000..2b84d6c4aa --- /dev/null +++ b/src/plugins/core/basicrenderer.tsx @@ -0,0 +1,285 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { debounce } from "throttle-debounce"; +import * as util from "@/util/util"; +import { GlobalModel } from "@/models"; +import { clsx } from "clsx"; + +class SimpleBlobRendererModel { + context: RendererContext; + opts: RendererOpts; + isDone: OV; + api: RendererModelContainerApi; + savedHeight: number; + loading: OV; + loadError: OV = mobx.observable.box(null, { + name: "renderer-loadError", + }); + lineState: LineStateType; + ptyData: PtyDataType; + ptyDataSource: (termContext: TermContextUnion) => Promise; + dataBlob: ExtBlob; + readOnly: boolean; + notFound: boolean; + isClosed: boolean; + + initialize(params: RendererModelInitializeParams): void { + this.isClosed = !!params.lineState["prompt:closed"]; + this.loading = mobx.observable.box(!this.isClosed, { name: "renderer-loading" }); + this.isDone = mobx.observable.box(params.isDone, { + name: "renderer-isDone", + }); + this.context = params.context; + this.opts = params.opts; + this.api = params.api; + this.lineState = params.lineState; + this.savedHeight = params.savedHeight; + this.ptyDataSource = params.ptyDataSource; + if (this.isClosed) { + this.dataBlob = new Blob() as ExtBlob; + this.dataBlob.notFound = false; // TODO + } else { + if (this.isDone.get()) { + setTimeout(() => this.reload(0), 10); + } + } + } + + dispose(): void { + return; + } + + giveFocus(): void { + return; + } + + updateOpts(update: RendererOptsUpdate): void { + Object.assign(this.opts, update); + } + + updateHeight(newHeight: number): void { + if (this.savedHeight != newHeight) { + this.savedHeight = newHeight; + this.api.saveHeight(newHeight); + } + } + + setIsDone(): void { + if (this.isDone.get()) { + return; + } + mobx.action(() => { + this.isDone.set(true); + })(); + this.reload(0); + } + + reload(delayMs: number): void { + mobx.action(() => { + this.loading.set(true); + })(); + if (delayMs == 0) { + this.reload_noDelay(); + } else { + setTimeout(() => { + this.reload_noDelay(); + }, delayMs); + } + } + + reload_noDelay(): void { + let source = this.lineState["prompt:source"] || "pty"; + if (source == "pty") { + this.reloadPtyData(); + } else if (source == "file") { + this.reloadFileData(); + } else { + mobx.action(() => { + this.loadError.set("error: invalid load source: " + source); + })(); + } + } + + reloadFileData(): void { + // todo add file methods to API, so we don't have a GlobalModel dependency here! + let path = this.lineState["prompt:file"]; + if (util.isBlank(path)) { + mobx.action(() => { + this.loadError.set("renderer has file source, but no prompt:file specified"); + })(); + return; + } + let rtnp = GlobalModel.readRemoteFile(this.context.screenId, this.context.lineId, path); + rtnp.then((file) => { + this.notFound = (file as any).notFound; + this.readOnly = (file as any).readOnly; + this.dataBlob = file; + mobx.action(() => { + this.loading.set(false); + this.loadError.set(null); + })(); + }).catch((e) => { + mobx.action(() => { + this.loadError.set("error loading file data: " + e); + })(); + }); + } + + reloadPtyData(): void { + this.readOnly = true; + let rtnp = this.ptyDataSource(this.context); + if (rtnp == null) { + console.log("no promise returned from ptyDataSource (simplerenderer)", this.context); + return; + } + rtnp.then((ptydata) => { + this.ptyData = ptydata; + let blob: ExtBlob = new Blob([this.ptyData.data]) as ExtBlob; + blob.notFound = false; + this.dataBlob = blob; + mobx.action(() => { + this.loading.set(false); + this.loadError.set(null); + })(); + }).catch((e) => { + mobx.action(() => { + this.loadError.set("error loading data: " + e); + })(); + }); + } + + receiveData(pos: number, data: Uint8Array, reason?: string): void { + // this.dataBuf.receiveData(pos, data, reason); + } +} + +@mobxReact.observer +class SimpleBlobRenderer extends React.Component< + { + rendererContainer: RendererContainerType; + lineId: string; + plugin: RendererPluginType; + onHeightChange: () => void; + initParams: RendererModelInitializeParams; + scrollToBringIntoViewport: () => void; + isSelected: boolean; + shouldFocus: boolean; + }, + {} +> { + model: SimpleBlobRendererModel; + wrapperDivRef: React.RefObject = React.createRef(); + rszObs: ResizeObserver; + updateHeight_debounced: (newHeight: number) => void; + + constructor(props: any) { + super(props); + let { rendererContainer, lineId, plugin, initParams } = this.props; + this.model = new SimpleBlobRendererModel(); + this.model.initialize(initParams); + rendererContainer.registerRenderer(lineId, this.model); + this.updateHeight_debounced = debounce(1000, this.updateHeight.bind(this)); + } + + updateHeight(newHeight: number): void { + this.model.updateHeight(newHeight); + } + + handleResize(entries: ResizeObserverEntry[]): void { + if (this.model.loading.get()) { + return; + } + if (this.props.onHeightChange) { + this.props.onHeightChange(); + } + if (!this.model.loading.get() && this.wrapperDivRef.current != null) { + let height = this.wrapperDivRef.current.offsetHeight; + this.updateHeight_debounced(height); + } + } + + checkRszObs() { + if (this.rszObs != null) { + return; + } + if (this.wrapperDivRef.current == null) { + return; + } + this.rszObs = new ResizeObserver(this.handleResize.bind(this)); + this.rszObs.observe(this.wrapperDivRef.current); + } + + componentDidMount() { + this.checkRszObs(); + } + + componentWillUnmount() { + let { rendererContainer, lineId } = this.props; + rendererContainer.unloadRenderer(lineId); + if (this.rszObs != null) { + this.rszObs.disconnect(); + this.rszObs = null; + } + } + + componentDidUpdate() { + this.checkRszObs(); + } + + render() { + let { plugin } = this.props; + let model = this.model; + if (model.loadError.get() != null) { + let errorText = model.loadError.get(); + let height = this.model.savedHeight; + return ( +
+
ERROR: {errorText}
+
+ ); + } + if (model.loading.get()) { + let height = this.model.savedHeight; + return ( +
+ loading content +
+ ); + } + let Comp = plugin.simpleComponent; + if (Comp == null) { +
(no component found in plugin)
; + } + let { festate, cmdstr, exitcode } = this.props.initParams.rawCmd; + return ( +
+ +
+ ); + } +} + +export { SimpleBlobRendererModel, SimpleBlobRenderer }; diff --git a/src/plugins/core/incrementalrenderer.tsx b/src/plugins/core/incrementalrenderer.tsx new file mode 100644 index 0000000000..0118ba6636 --- /dev/null +++ b/src/plugins/core/incrementalrenderer.tsx @@ -0,0 +1,90 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import { debounce } from "throttle-debounce"; + +@mobxReact.observer +class IncrementalRenderer extends React.Component< + { + rendererContainer: RendererContainerType; + lineId: string; + plugin: RendererPluginType; + onHeightChange: () => void; + initParams: RendererModelInitializeParams; + isSelected: boolean; + }, + {} +> { + model: RendererModel; + wrapperDivRef: React.RefObject = React.createRef(); + rszObs: ResizeObserver; + updateHeight_debounced: (newHeight: number) => void; + + constructor(props: any) { + super(props); + const { rendererContainer, lineId, plugin, initParams } = this.props; + this.model = plugin.modelCtor(); + this.model.initialize(initParams); + rendererContainer.registerRenderer(lineId, this.model); + this.updateHeight_debounced = debounce(1000, this.updateHeight.bind(this)); + } + + updateHeight(newHeight: number): void { + this.model.updateHeight(newHeight); + } + + handleResize(entries: ResizeObserverEntry[]): void { + if (this.props.onHeightChange) { + this.props.onHeightChange(); + } + if (this.wrapperDivRef.current != null) { + const height = this.wrapperDivRef.current.offsetHeight; + this.updateHeight_debounced(height); + } + } + + checkRszObs() { + if (this.rszObs != null) { + return; + } + if (this.wrapperDivRef.current == null) { + return; + } + this.rszObs = new ResizeObserver(this.handleResize.bind(this)); + this.rszObs.observe(this.wrapperDivRef.current); + } + + componentDidMount() { + this.checkRszObs(); + } + + componentWillUnmount() { + const { rendererContainer, lineId } = this.props; + rendererContainer.unloadRenderer(lineId); + if (this.rszObs != null) { + this.rszObs.disconnect(); + this.rszObs = null; + } + } + + componentDidUpdate() { + this.checkRszObs(); + } + + render() { + const { plugin } = this.props; + const Comp = plugin.fullComponent; + if (Comp == null) { +
(no component found in plugin)
; + } + return ( +
+ +
+ ); + } +} + +export { IncrementalRenderer }; diff --git a/src/plugins/core/ptydata.ts b/src/plugins/core/ptydata.ts new file mode 100644 index 0000000000..a5540e002d --- /dev/null +++ b/src/plugins/core/ptydata.ts @@ -0,0 +1,132 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as mobx from "mobx"; +import { incObs } from "@/util/util"; + +const InitialSize = 10 * 1024; +const IncreaseFactor = 1.5; + +class PtyDataBuffer { + ptyPos: number; + dataVersion: mobx.IObservableValue; + brokenData: boolean; + rawData: Uint8Array; + dataSize: number; + + constructor() { + this.ptyPos = 0; + this.dataVersion = mobx.observable.box(0, { name: "dataVersion" }); + this._resetData(); + } + + _resetData() { + this.dataSize = 0; + this.rawData = new Uint8Array(InitialSize); + this.brokenData = false; + } + + reset(): void { + this._resetData(); + } + + getData(): Uint8Array { + return this.rawData.slice(0, this.dataSize); + } + + _growArray(minSize: number): void { + let newSize = Math.round(this.rawData.length * IncreaseFactor); + if (newSize < minSize) { + newSize = minSize; + } + let newData = new Uint8Array(newSize); + newData.set(this.rawData); + this.rawData = newData; + } + + receiveData(pos: number, data: Uint8Array, reason?: string): void { + if (pos != this.dataSize) { + this.brokenData = true; + return; + } + if (this.dataSize + data.length > this.rawData.length) { + this._growArray(this.dataSize + data.length); + } + this.rawData.set(data, pos); + this.dataSize += data.length; + incObs(this.dataVersion); + } +} + +const NewLineCharCode = "\n".charCodeAt(0); + +class PacketDataBuffer extends PtyDataBuffer { + parsePos: number; + callback: (any) => void; + + constructor(callback: (any) => void) { + super(); + this.parsePos = 0; + this.callback = callback; + } + + reset(): void { + super.reset(); + this.parsePos = 0; + } + + processLine(line: string) { + if (line.length == 0) { + return; + } + if (!line.startsWith("##")) { + console.log("invalid line packet", line); + return; + } + let bracePos = line.indexOf("{"); + if (bracePos == -1) { + console.log("invalid line packet", line); + return; + } + let packetStr = line.substring(bracePos); + let sizeStr = line.substring(2, bracePos); + if (sizeStr != "N") { + let packetSize = parseInt(sizeStr); + if (isNaN(packetSize) || packetSize != packetStr.length) { + console.log("invalid line packet", line); + } + } + let packet: any = null; + try { + packet = JSON.parse(packetStr); + } catch (e) { + console.log("invalid line packet (bad json)", line, e); + return; + } + if (packet != null) { + this.callback(packet); + } + } + + parseData() { + for (let i = this.parsePos; i < this.dataSize; i++) { + let ch = this.rawData[i]; + if (ch == NewLineCharCode) { + // line does *not* include the newline + let line = new TextDecoder().decode( + new Uint8Array(this.rawData.buffer, this.parsePos, i - this.parsePos) + ); + this.parsePos = i + 1; + this.processLine(line); + } + } + return; + } + + receiveData(pos: number, data: Uint8Array, reason?: string): void { + super.receiveData(pos, data, reason); + this.parseData(); + } +} + +export { PtyDataBuffer, PacketDataBuffer }; diff --git a/src/plugins/csv/csv.less b/src/plugins/csv/csv.less new file mode 100644 index 0000000000..0e241cdeae --- /dev/null +++ b/src/plugins/csv/csv.less @@ -0,0 +1,97 @@ +.csv-renderer { + opacity: 0; /* Start with an opacity of 0, meaning it's invisible */ + + .ellipsis() { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + overflow-x: auto; + overflow-y: hidden; + + .cursor-pointer { + cursor: pointer; + } + + .select-none { + user-select: none; + } + + table.probe { + position: absolute; + visibility: hidden; + } + + table { + border-collapse: collapse; + overflow-x: auto; + border: 1px solid var(--scrollbar-thumb-hover-color); + + thead { + position: relative; + display: block; + width: 100%; + overflow-y: scroll; + + tr { + border-bottom: 1px solid var(--scrollbar-thumb-hover-color); + + th { + color: var(--app-text-color); + border-right: 1px solid var(--scrollbar-thumb-hover-color); + border-bottom: none; + padding: 2px 10px; + flex-basis: 100%; + flex-grow: 2; + display: block; + text-align: left; + position: relative; + + .inner { + text-align: left; + padding-right: 15px; + position: relative; + .ellipsis(); + + .sort-icon { + filter: invert(100%); + position: absolute; + right: 0px; + top: 2px; + width: 9px; + } + } + } + } + } + + tbody { + display: block; + position: relative; + overflow-y: scroll; + overscroll-behavior: contain; + } + + tr { + width: 100%; + display: flex; + + td { + border-right: 1px solid var(--scrollbar-thumb-hover-color); + border-left: 1px solid var(--scrollbar-thumb-hover-color); + padding: 3px 10px; + flex-basis: 100%; + flex-grow: 2; + display: block; + text-align: left; + .ellipsis(); + } + } + } +} + +.csv-renderer.show { + opacity: 1; /* When loaded class is added, set the opacity to 1, making it visible */ +} diff --git a/src/plugins/csv/csv.tsx b/src/plugins/csv/csv.tsx new file mode 100644 index 0000000000..8e86e7edc6 --- /dev/null +++ b/src/plugins/csv/csv.tsx @@ -0,0 +1,261 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import React, { FC, useEffect, useState, useRef, useMemo } from "react"; +import { GlobalModel } from "@/models"; +import Papa from "papaparse"; +import { + createColumnHelper, + flexRender, + useReactTable, + getCoreRowModel, + getSortedRowModel, + FilterFn, +} from "@tanstack/react-table"; +import { useTableNav } from "@table-nav/react"; +import SortUpIcon from "./img/sort-up-solid.svg"; +import SortDownIcon from "./img/sort-down-solid.svg"; +import { clsx } from "clsx"; + +import "./csv.less"; + +const MAX_DATA_SIZE = 10 * 1024 * 1024; // 10MB in bytes + +type CSVRow = { + [key: string]: string | number; +}; + +interface Props { + data: ExtBlob; + readOnly: boolean; + context: RendererContext; + opts: RendererOpts; + savedHeight: number; + lineState: LineStateType; + shouldFocus: boolean; + rendererApi: RendererModelContainerApi; + scrollToBringIntoViewport: () => void; +} + +interface State { + content: string | null; + showReadonly: boolean; + tbodyHeight: number; +} + +const columnHelper = createColumnHelper(); + +const CSVRenderer: FC = (props: Props) => { + const { data, opts, lineState, context, shouldFocus, rendererApi, savedHeight } = props; + const { height: maxHeight } = opts.maxSize; + + const csvCacheRef = useRef(new Map()); + const rowRef = useRef<(HTMLTableRowElement | null)[]>([]); + const headerRef = useRef(null); + const probeRef = useRef(null); + const tbodyRef = useRef(null); + const [state, setState] = useState({ + content: null, + showReadonly: true, + tbodyHeight: 0, + }); + const [isFileTooLarge, setIsFileTooLarge] = useState(false); + const [tableLoaded, setTableLoaded] = useState(false); + const { listeners } = useTableNav(); + + const filePath = lineState["prompt:file"]; + const { screenId, lineId } = context; + const cacheKey = `${screenId}-${lineId}-${filePath}`; + + // Parse the CSV data + const parsedData = useMemo(() => { + if (!state.content) return []; + + // Trim the content and then check for headers based on the first row's content. + const trimmedContent = state.content.trim(); + const firstRow = trimmedContent.split("\n")[0]; + + // This checks if the first row starts with a letter or a quote + const hasHeaders = !!firstRow.match(/^[a-zA-Z"]/); + + const results = Papa.parse(trimmedContent, { header: hasHeaders }); + + // Check for non-header CSVs + if (!hasHeaders && Array.isArray(results.data) && Array.isArray(results.data[0])) { + const dataArray = results.data as string[][]; // Asserting the type + const headers = Array.from({ length: dataArray[0].length }, (_, i) => `Column ${i + 1}`); + results.data = dataArray.map((row) => { + const newRow: CSVRow = {}; + row.forEach((value, index) => { + newRow[headers[index]] = value; + }); + return newRow; + }); + } + + return results.data.map((row) => { + return Object.fromEntries( + Object.entries(row as CSVRow).map(([key, value]) => { + if (typeof value === "string") { + const numberValue = parseFloat(value); + if (!isNaN(numberValue) && String(numberValue) === value) { + return [key, numberValue]; + } + } + return [key, value]; + }) + ) as CSVRow; + }); + }, [state.content]); + + // Column Definitions + const columns = useMemo(() => { + if (parsedData.length === 0) { + return []; + } + const headers = Object.keys(parsedData[0]); + return headers.map((header) => + columnHelper.accessor(header, { + header: () => header, + cell: (info) => info.renderValue(), + }) + ); + }, [parsedData]); + + useEffect(() => { + const content = csvCacheRef.current.get(cacheKey); + if (content) { + setState((prevState) => ({ ...prevState, content })); + } else { + // Check if the file size exceeds 10MB + if (data.size > MAX_DATA_SIZE) { + // 10MB in bytes + setIsFileTooLarge(true); + return; + } + + data.text().then((content: string) => { + setState((prevState) => ({ ...prevState, content })); + csvCacheRef.current.set(cacheKey, content); + }); + } + }, []); + + useEffect(() => { + if (probeRef.current && headerRef.current && parsedData.length) { + const rowHeight = probeRef.current.offsetHeight; + const fullTBodyHeight = rowHeight * parsedData.length; + const headerHeight = headerRef.current.offsetHeight; + const maxHeightLessHeader = maxHeight - headerHeight; + const tbodyHeight = Math.min(maxHeightLessHeader, fullTBodyHeight); + + setState((prevState) => ({ ...prevState, tbodyHeight })); + } + }, [probeRef, headerRef, maxHeight, parsedData]); + + // Makes sure rows are rendered before setting the renderer as loaded + useEffect(() => { + let timer: any; + + if (rowRef.current.length === parsedData.length) { + timer = setTimeout(() => { + setTableLoaded(true); + }, 50); // Delay a bit to make sure the rows are rendered + } + + return () => clearTimeout(timer); + }, [rowRef, parsedData]); + + useEffect(() => { + if (shouldFocus) { + rendererApi.onFocusChanged(true); + } + }, [shouldFocus]); + + const table = useReactTable({ + manualPagination: true, + data: parsedData, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + if (isFileTooLarge) { + return ( +
+
The file size exceeds 10MB and cannot be displayed.
+
+ ); + } + + return ( +
+ + + + + + +
dummy data
+ + + {table.getHeaderGroups().map((headerGroup, index) => ( + + {headerGroup.headers.map((header, index) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row, index) => ( + (rowRef.current[index] = el)} id={row.id} tabIndex={index}> + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : ( +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getIsSorted() === "asc" ? ( + Ascending + ) : header.column.getIsSorted() === "desc" ? ( + Descending + ) : null} +
+ )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ); +}; + +export { CSVRenderer }; diff --git a/src/plugins/csv/icon.svg b/src/plugins/csv/icon.svg new file mode 100644 index 0000000000..5377e8f8d8 --- /dev/null +++ b/src/plugins/csv/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/plugins/csv/img/sort-down-solid.svg b/src/plugins/csv/img/sort-down-solid.svg new file mode 100644 index 0000000000..6fffc06388 --- /dev/null +++ b/src/plugins/csv/img/sort-down-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/plugins/csv/img/sort-solid.svg b/src/plugins/csv/img/sort-solid.svg new file mode 100644 index 0000000000..b8e1b4aafe --- /dev/null +++ b/src/plugins/csv/img/sort-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/plugins/csv/img/sort-up-solid.svg b/src/plugins/csv/img/sort-up-solid.svg new file mode 100644 index 0000000000..af0a520e10 --- /dev/null +++ b/src/plugins/csv/img/sort-up-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/plugins/csv/meta.json b/src/plugins/csv/meta.json new file mode 100644 index 0000000000..63d878731f --- /dev/null +++ b/src/plugins/csv/meta.json @@ -0,0 +1,5 @@ +{ + "title": "CSV Viewer", + "vendor": "Wave", + "summary": "View CSV files inline from within the terminal. I am now trying an animated gif :)" +} diff --git a/src/plugins/csv/readme.md b/src/plugins/csv/readme.md new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/plugins/csv/readme.md @@ -0,0 +1 @@ + diff --git a/src/plugins/csv/screenshots/csvview.gif b/src/plugins/csv/screenshots/csvview.gif new file mode 100644 index 0000000000..98c6c8a352 Binary files /dev/null and b/src/plugins/csv/screenshots/csvview.gif differ diff --git a/src/plugins/image/icon.svg b/src/plugins/image/icon.svg new file mode 100644 index 0000000000..c1637e1579 --- /dev/null +++ b/src/plugins/image/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/plugins/image/image.less b/src/plugins/image/image.less new file mode 100644 index 0000000000..2408290844 --- /dev/null +++ b/src/plugins/image/image.less @@ -0,0 +1,11 @@ +.image-renderer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + padding-top: var(--termpad); + + img { + display: block; + } +} diff --git a/src/plugins/image/image.tsx b/src/plugins/image/image.tsx new file mode 100644 index 0000000000..c46bad019e --- /dev/null +++ b/src/plugins/image/image.tsx @@ -0,0 +1,79 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobx from "mobx"; +import * as mobxReact from "mobx-react"; + +import "./image.less"; + +@mobxReact.observer +class SimpleImageRenderer extends React.Component< + { data: ExtBlob; context: RendererContext; opts: RendererOpts; savedHeight: number }, + {} +> { + objUrl: string = null; + imageRef: React.RefObject = React.createRef(); + imageLoaded: OV = mobx.observable.box(false, { name: "imageLoaded" }); + + componentDidMount() { + let img = this.imageRef.current; + if (img == null) { + return; + } + if (img.complete) { + this.setImageLoaded(); + return; + } + img.onload = () => { + this.setImageLoaded(); + }; + } + + setImageLoaded() { + mobx.action(() => { + this.imageLoaded.set(true); + })(); + } + + componentWillUnmount() { + if (this.objUrl != null) { + URL.revokeObjectURL(this.objUrl); + } + } + + render() { + let dataBlob = this.props.data; + if (dataBlob == null || dataBlob.notFound) { + return ( +
+
+ ERROR: file {dataBlob?.name ? JSON.stringify(dataBlob.name) : ""} not found +
+
+ ); + } + if (this.objUrl == null) { + if (dataBlob.name?.endsWith(".svg")) { + dataBlob = new Blob([dataBlob], { type: "image/svg+xml" }) as ExtBlob; + } + this.objUrl = URL.createObjectURL(dataBlob); + } + let opts = this.props.opts; + let forceHeight: number = null; + if (!this.imageLoaded.get() && this.props.savedHeight >= 0) { + forceHeight = this.props.savedHeight; + } + return ( +
+ +
+ ); + } +} + +export { SimpleImageRenderer }; diff --git a/src/plugins/image/meta.json b/src/plugins/image/meta.json new file mode 100644 index 0000000000..7b54c93e89 --- /dev/null +++ b/src/plugins/image/meta.json @@ -0,0 +1,5 @@ +{ + "title": "ImageViewer", + "vendor": "Wave", + "summary": "View Images inline in the terminal." +} diff --git a/src/plugins/image/readme.md b/src/plugins/image/readme.md new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/plugins/image/readme.md @@ -0,0 +1 @@ + diff --git a/src/plugins/image/screenshots/imagviewlion.gif b/src/plugins/image/screenshots/imagviewlion.gif new file mode 100644 index 0000000000..786a6bc36c Binary files /dev/null and b/src/plugins/image/screenshots/imagviewlion.gif differ diff --git a/src/plugins/markdown/icon.svg b/src/plugins/markdown/icon.svg new file mode 100644 index 0000000000..2d4ebcd095 --- /dev/null +++ b/src/plugins/markdown/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/plugins/markdown/markdown.less b/src/plugins/markdown/markdown.less new file mode 100644 index 0000000000..70ea9f5cc5 --- /dev/null +++ b/src/plugins/markdown/markdown.less @@ -0,0 +1,11 @@ +.markdown-renderer { + color: var(--app-text-color); + + .scroller { + overflow-y: auto; + } +} + +.markdown > *:first-child { + margin-top: 0 !important; +} diff --git a/src/plugins/markdown/markdown.tsx b/src/plugins/markdown/markdown.tsx new file mode 100644 index 0000000000..9399492154 --- /dev/null +++ b/src/plugins/markdown/markdown.tsx @@ -0,0 +1,90 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobx from "mobx"; +import * as mobxReact from "mobx-react"; +import { sprintf } from "sprintf-js"; +import { Markdown } from "@/elements"; + +import "./markdown.less"; + +const MaxMarkdownSize = 200000; +const DefaultMaxMarkdownWidth = 1000; + +@mobxReact.observer +class SimpleMarkdownRenderer extends React.Component< + { + data: ExtBlob; + context: RendererContext; + opts: RendererOpts; + savedHeight: number; + lineState: LineStateType; + }, + {} +> { + markdownText: OV = mobx.observable.box(null, { name: "markdownText" }); + markdownError: OV = mobx.observable.box(null, { name: "markdownError" }); + + componentDidMount() { + let dataBlob = this.props.data; + if (dataBlob == null || dataBlob.notFound) { + return; + } + if (dataBlob.size > MaxMarkdownSize) { + this.markdownError.set(sprintf("error: markdown too large to render size=%d", dataBlob.size)); + return; + } + let prtn = dataBlob.text(); + prtn.then((text) => { + if (/[\x00-\x08]/.test(text)) { + this.markdownError.set(sprintf("error: not rendering markdown, binary characters detected")); + return; + } + mobx.action(() => { + this.markdownText.set(text); + })(); + }); + } + + render() { + let dataBlob = this.props.data; + if (dataBlob == null || dataBlob.notFound) { + return ( +
+
+ ERROR: file {dataBlob && dataBlob.name ? JSON.stringify(dataBlob.name) : ""} not found +
+
+ ); + } + if (this.markdownError.get() != null) { + return ( +
+
{this.markdownError.get()}
+
+ ); + } + if (this.markdownText.get() == null) { + return
; + } + let opts = this.props.opts; + return ( +
+
+ +
+
+ ); + } +} + +export { SimpleMarkdownRenderer }; diff --git a/src/plugins/markdown/meta.json b/src/plugins/markdown/meta.json new file mode 100644 index 0000000000..6442c41b44 --- /dev/null +++ b/src/plugins/markdown/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Markdown", + "vendor": "Wave", + "summary": "Markdown is a lightweight markup language for creating formatted text using a plain-text editor." +} diff --git a/src/plugins/markdown/readme.md b/src/plugins/markdown/readme.md new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/plugins/markdown/readme.md @@ -0,0 +1 @@ + diff --git a/src/plugins/markdown/screenshots/.gitignore b/src/plugins/markdown/screenshots/.gitignore new file mode 100644 index 0000000000..f4572339b9 --- /dev/null +++ b/src/plugins/markdown/screenshots/.gitignore @@ -0,0 +1 @@ +# placeholder \ No newline at end of file diff --git a/src/plugins/media/media.less b/src/plugins/media/media.less new file mode 100644 index 0000000000..a77d18b2bc --- /dev/null +++ b/src/plugins/media/media.less @@ -0,0 +1,14 @@ +.media-renderer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + padding-top: var(--termpad); + + video { + object-fit: contain; + object-position: center; + height: 100%; + width: auto; + } +} diff --git a/src/plugins/media/media.tsx b/src/plugins/media/media.tsx new file mode 100644 index 0000000000..aad7b98587 --- /dev/null +++ b/src/plugins/media/media.tsx @@ -0,0 +1,60 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobx from "mobx"; +import * as mobxReact from "mobx-react"; +import * as util from "@/util/util"; +import { GlobalModel } from "@/models"; + +import "./media.less"; + +@mobxReact.observer +class SimpleMediaRenderer extends React.Component< + { data: ExtBlob; context: RendererContext; opts: RendererOpts; savedHeight: number; lineState: LineStateType }, + {} +> { + objUrl: string = null; + + componentWillUnmount() { + if (this.objUrl != null) { + URL.revokeObjectURL(this.objUrl); + } + } + + render() { + let dataBlob = this.props.data; + if (dataBlob == null || dataBlob.notFound) { + return ( +
+
+ ERROR: file {dataBlob && dataBlob.name ? JSON.stringify(dataBlob.name) : ""} not found +
+
+ ); + } + let fileUrl = this.props.lineState["wave:fileurl"]; + if (util.isBlank(fileUrl)) { + return ( +
+
+ ERROR: no fileurl found (please use `mediaview` to view media files) +
+
+ ); + } + let fullVideoUrl = GlobalModel.getBaseHostPort() + fileUrl; + const opts = this.props.opts; + const height = opts.idealSize.height - 10; + const width = opts.maxSize.width - 10; + return ( +
+ +
+ ); + } +} + +export { SimpleMediaRenderer }; diff --git a/src/plugins/mustache/icon.svg b/src/plugins/mustache/icon.svg new file mode 100644 index 0000000000..4e11dd0bbd --- /dev/null +++ b/src/plugins/mustache/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/plugins/mustache/meta.json b/src/plugins/mustache/meta.json new file mode 100644 index 0000000000..dc4ac3a7f8 --- /dev/null +++ b/src/plugins/mustache/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Mustache", + "vendor": "Wave", + "summary": "Mustache templates help create html files." +} diff --git a/src/plugins/mustache/mustache.less b/src/plugins/mustache/mustache.less new file mode 100644 index 0000000000..4c180ee385 --- /dev/null +++ b/src/plugins/mustache/mustache.less @@ -0,0 +1,17 @@ +.mustache-renderer { + color: var(--app-text-color); + .cmd-hints { + display: inline-block !important; + position: relative; + margin-right: 26px; + } + .hint-item { + border-radius: 4px 4px 0 0; + padding: 3px 9px 2px 8px; + text-align: center; + } + .refresh-button { + color: rgb(52, 52, 52); + background-color: var(--app-text-color); + } +} diff --git a/src/plugins/mustache/mustache.tsx b/src/plugins/mustache/mustache.tsx new file mode 100644 index 0000000000..2c97279fd6 --- /dev/null +++ b/src/plugins/mustache/mustache.tsx @@ -0,0 +1,215 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobx from "mobx"; +import * as mobxReact from "mobx-react"; +import { boundMethod } from "autobind-decorator"; +import { isBlank } from "@/util/util"; +import mustache from "mustache"; +import * as DOMPurify from "dompurify"; +import { GlobalModel } from "@/models"; + +import "./mustache.less"; + +@mobxReact.observer +class SimpleMustacheRenderer extends React.Component< + { + data: ExtBlob; + context: RendererContext; + opts: RendererOpts; + savedHeight: number; + lineState: LineStateType; + }, + {} +> { + templateLoading: OV = mobx.observable.box(true, { name: "templateLoading" }); + templateLoadError: OV = mobx.observable.box(null, { name: "templateLoadError" }); + dataLoading: OV = mobx.observable.box(true, { name: "dataLoading" }); + dataLoadError: OV = mobx.observable.box(null, { name: "dataLoadError" }); + mustacheTemplateText: OV = mobx.observable.box(null, { name: "mustacheTemplateText" }); + parsedData: OV = mobx.observable.box(null, { name: "parsedData" }); + + componentDidMount() { + this.reloadTemplate(); + this.reloadData(); + } + + reloadTemplate() { + if (isBlank(this.props.lineState.template)) { + mobx.action(() => { + this.templateLoading.set(false); + this.templateLoadError.set(`no 'template' specified`); + })(); + return; + } + mobx.action(() => { + this.templateLoading.set(true); + this.templateLoadError.set(null); + })(); + let context = this.props.context; + let lineState = this.props.lineState; + let quotedTemplateName = JSON.stringify(lineState.template); + let rtnp = GlobalModel.readRemoteFile(context.screenId, context.lineId, lineState.template); + rtnp.then((file) => { + if (file.notFound) { + this.trySetTemplateLoadError(`mustache template ${quotedTemplateName} not found`); + return null; + } + return file.text(); + }) + .then((text) => { + if (isBlank(text)) { + this.trySetTemplateLoadError(`blank mustache template ${quotedTemplateName}`); + return; + } + mobx.action(() => { + this.mustacheTemplateText.set(text); + this.templateLoading.set(false); + })(); + return; + }) + .catch((e) => { + this.trySetTemplateLoadError(`loading mustache template ${quotedTemplateName}: ${e}`); + }); + } + + reloadData() { + // load json content + let dataBlob = this.props.data; + if (dataBlob == null || dataBlob.notFound) { + mobx.action(() => { + this.dataLoading.set(false); + this.dataLoadError.set( + `file {dataBlob && dataBlob.name ? JSON.stringify(dataBlob.name) : ""} not found` + ); + })(); + return; + } + mobx.action(() => { + this.dataLoading.set(true); + this.dataLoadError.set(null); + })(); + let rtnp = dataBlob.text(); + let quotedDataName = dataBlob.name || '"terminal output"'; + rtnp.then((text) => { + mobx.action(() => { + try { + this.parsedData.set(JSON.parse(text)); + this.dataLoading.set(false); + } catch (e) { + this.trySetDataLoadError(`parsing json data from ${quotedDataName}: ${e}`); + } + })(); + }).catch((e) => { + this.trySetDataLoadError(`loading json data ${quotedDataName}: ${e}`); + }); + } + + trySetTemplateLoadError(msg: string) { + if (this.templateLoadError.get() != null) { + return; + } + mobx.action(() => { + this.templateLoadError.set(msg); + })(); + } + + trySetDataLoadError(msg: string) { + if (this.dataLoadError.get() != null) { + return; + } + mobx.action(() => { + this.dataLoadError.set(msg); + })(); + } + + @boundMethod + doRefresh() { + this.reloadTemplate(); + } + + renderCmdHints() { + return ( +
+
+
+ refresh +
+
+
+ ); + } + + render() { + let errorMessage = this.dataLoadError.get() ?? this.templateLoadError.get(); + if (errorMessage != null) { + return ( +
+
ERROR: {errorMessage}
+ {this.renderCmdHints()} +
+ ); + } + if (this.templateLoading.get() || this.dataLoading.get()) { + return ( +
+
+ loading content +
+ {this.renderCmdHints()} +
+ ); + } + let opts = this.props.opts; + let maxWidth = opts.maxSize.width; + let minWidth = opts.maxSize.width; + if (minWidth > 1000) { + minWidth = 1000; + } + let templateText = this.mustacheTemplateText.get(); + let templateData = this.parsedData.get() || {}; + let renderedText = null; + try { + renderedText = mustache.render(templateText, templateData); + renderedText = DOMPurify.sanitize(renderedText); + } catch (e) { + return ( +
+
ERROR running template: {e.message}
+ {this.renderCmdHints()} +
+ ); + } + // TODO non-term content font-size (default to 16) + return ( +
+
+
+
+ {this.renderCmdHints()} +
+ ); + } +} + +export { SimpleMustacheRenderer }; diff --git a/src/plugins/mustache/readme.md b/src/plugins/mustache/readme.md new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/plugins/mustache/readme.md @@ -0,0 +1 @@ + diff --git a/src/plugins/mustache/screenshots/.gitignore b/src/plugins/mustache/screenshots/.gitignore new file mode 100644 index 0000000000..f4572339b9 --- /dev/null +++ b/src/plugins/mustache/screenshots/.gitignore @@ -0,0 +1 @@ +# placeholder \ No newline at end of file diff --git a/src/plugins/openai/icon.svg b/src/plugins/openai/icon.svg new file mode 100644 index 0000000000..be5696e37c --- /dev/null +++ b/src/plugins/openai/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/plugins/openai/meta.json b/src/plugins/openai/meta.json new file mode 100644 index 0000000000..eec8cb52c1 --- /dev/null +++ b/src/plugins/openai/meta.json @@ -0,0 +1,5 @@ +{ + "title": "OpenAI", + "vendor": "Wave", + "summary": "OpenAI plugin allows chatting with OpenAI APIs." +} diff --git a/src/plugins/openai/openai.less b/src/plugins/openai/openai.less new file mode 100644 index 0000000000..f717182b9c --- /dev/null +++ b/src/plugins/openai/openai.less @@ -0,0 +1,45 @@ +.openai-renderer { + font-family: var(--termfontfamily); + font-size: var(--termfontsize); + line-height: var(--termlineheight); + + .openai-message { + display: flex; + flex-direction: row; + justify-content: flex-start; + font-weight: normal; + + .openai-role { + color: var(--term-bright-green); + width: 100px; + flex-shrink: 0; + font: var(--base-font); + } + + .openai-role.openai-role-assistant { + color: var(--app-text-primary-color); + } + + .openai-content-user { + color: var(--app-text-color); + font-family: var(--markdown-font); + font-weight: normal; + font-size: var(--markdown-font-size); + } + + .openai-content-assistant { + font-family: var(--markdown-font); + color: var(--app-text-color); + } + + .openai-role-error { + color: var(--app-text-color); + } + + .openai-content-error { + color: var(--app-text-color); + } + } + + overflow-y: auto; +} diff --git a/src/plugins/openai/openai.tsx b/src/plugins/openai/openai.tsx new file mode 100644 index 0000000000..ecd43a5dc0 --- /dev/null +++ b/src/plugins/openai/openai.tsx @@ -0,0 +1,271 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobx from "mobx"; +import * as mobxReact from "mobx-react"; +import { debounce } from "throttle-debounce"; +import { boundMethod } from "autobind-decorator"; +import { PacketDataBuffer } from "../core/ptydata"; +import { Markdown } from "@/elements"; +import { GlobalModel } from "@/models/global"; + +import "./openai.less"; + +type OpenAIOutputType = { + model: string; + created: number; + finish_reason: string; + message: string; +}; + +class OpenAIRendererModel { + context: RendererContext; + opts: RendererOpts; + isDone: OV; + api: RendererModelContainerApi; + savedHeight: number; + loading: OV; + loadError: OV = mobx.observable.box(null, { name: "renderer-loadError" }); + chatError: OV = mobx.observable.box(null, { name: "renderer-chatError" }); + updateHeight_debounced: (newHeight: number) => void; + ptyDataSource: (termContext: TermContextUnion) => Promise; + packetData: PacketDataBuffer; + rawCmd: WebCmd; + output: OV; + version: OV; + + constructor() { + this.updateHeight_debounced = debounce(1000, this.updateHeight.bind(this)); + this.packetData = new PacketDataBuffer(this.packetCallback); + this.output = mobx.observable.box(null, { name: "openai-output" }); + this.version = mobx.observable.box(0); + } + + initialize(params: RendererModelInitializeParams): void { + this.loading = mobx.observable.box(true, { name: "renderer-loading" }); + this.isDone = mobx.observable.box(params.isDone, { name: "renderer-isDone" }); + this.context = params.context; + this.opts = params.opts; + this.api = params.api; + this.savedHeight = params.savedHeight; + this.ptyDataSource = params.ptyDataSource; + this.rawCmd = params.rawCmd; + setTimeout(() => this.reload(0), 10); + } + + @boundMethod + packetCallback(packetAny: any) { + let packet: OpenAIPacketType = packetAny; + if (packet == null) { + return; + } + // console.log("got packet", packet); + if (packet.error != null) { + mobx.action(() => { + this.chatError.set(packet.error); + this.version.set(this.version.get() + 1); + })(); + return; + } + if (packet.model != null && (packet.index ?? 0) == 0) { + let output = { + model: packet.model, + created: packet.created, + finish_reason: packet.finish_reason, + message: packet.text ?? "", + }; + mobx.action(() => { + this.output.set(output); + })(); + return; + } + if ((packet.index ?? 0) == 0) { + mobx.action(() => { + let output = this.output.get(); + if (output == null) { + return; + } + if (packet.finish_reason != null) { + this.output.get().finish_reason = packet.finish_reason; + } + if (packet.text != null) { + this.output.get().message += packet.text; + } + this.version.set(this.version.get() + 1); + })(); + } + } + + dispose(): void { + return; + } + + giveFocus(): void { + return; + } + + updateOpts(update: RendererOptsUpdate): void { + Object.assign(this.opts, update); + } + + updateHeight(newHeight: number): void { + if (this.savedHeight != newHeight) { + this.savedHeight = newHeight; + this.api.saveHeight(newHeight); + } + } + + setIsDone(): void { + if (this.isDone.get()) { + return; + } + mobx.action(() => { + this.isDone.set(true); + })(); + // this.reload(0); + } + + reload(delayMs: number): void { + mobx.action(() => { + this.loading.set(true); + this.loadError.set(null); + this.chatError.set(null); + })(); + let rtnp = this.ptyDataSource(this.context); + if (rtnp == null) { + console.log("no promise returned from ptyDataSource (openai renderer)", this.context); + return; + } + rtnp.then((ptydata) => { + setTimeout(() => { + this.packetData.reset(); + this.receiveData(ptydata.pos, ptydata.data, "reload"); + mobx.action(() => { + this.loading.set(false); + })(); + }, delayMs); + }).catch((e) => { + console.log("error loading data", e); + mobx.action(() => { + this.loadError.set("error loading data: " + e); + })(); + }); + } + + receiveData(pos: number, data: Uint8Array, reason?: string): void { + this.packetData.receiveData(pos, data, reason); + } +} + +@mobxReact.observer +class OpenAIRenderer extends React.Component<{ model: OpenAIRendererModel }> { + renderPrompt(cmd: WebCmd) { + let cmdStr = cmd.cmdstr.trim(); + if (cmdStr.startsWith("/openai")) { + let spaceIdx = cmdStr.indexOf(" "); + if (spaceIdx > 0) { + cmdStr = cmdStr.substr(spaceIdx + 1).trim(); + } + } + return ( +
+ [user] +
{cmdStr}
+
+ ); + } + + renderError() { + let model: OpenAIRendererModel = this.props.model; + return ( +
+ [error] +
{model.loadError.get()}
+
+ ); + } + + renderOutput() { + let model = this.props.model; + let output = model.output.get(); + if (output == null || output.message == null || output.message == "") { + return null; + } + let message = output.message; + let opts = model.opts; + let minWidth = opts.maxSize.width; + if (minWidth > 1000) { + minWidth = 1000; + } + return ( +
+
[assistant]
+
+
+ +
+
+
+ ); + } + + renderChatError() { + let model = this.props.model; + let chatError = model.chatError.get(); + if (chatError == null) { + return null; + } + return ( +
+
[error]
+
{chatError}
+
+ ); + } + + render() { + let model: OpenAIRendererModel = this.props.model; + let cmd = model.rawCmd; + let styleVal: Record = null; + if (model.loading.get() && model.savedHeight >= 0 && model.isDone) { + styleVal = { + height: model.savedHeight, + maxHeight: model.opts.maxSize.height, + }; + } else { + let maxWidth = model.opts.maxSize.width; + if (maxWidth > 1000) { + maxWidth = 1000; + } + styleVal = { + maxWidth: maxWidth, + maxHeight: model.opts.maxSize.height, + }; + } + let version = model.version.get(); + let loadError = model.loadError.get(); + if (loadError != null) { + return ( +
+ {this.renderPrompt(cmd)} + {this.renderError()} +
+ ); + } + return ( +
+ {this.renderPrompt(cmd)} + {this.renderOutput()} + {this.renderChatError()} +
+ ); + } +} + +export { OpenAIRenderer, OpenAIRendererModel }; diff --git a/src/plugins/openai/readme.md b/src/plugins/openai/readme.md new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/plugins/openai/readme.md @@ -0,0 +1 @@ + diff --git a/src/plugins/openai/screenshots/.gitignore b/src/plugins/openai/screenshots/.gitignore new file mode 100644 index 0000000000..f4572339b9 --- /dev/null +++ b/src/plugins/openai/screenshots/.gitignore @@ -0,0 +1 @@ +# placeholder \ No newline at end of file diff --git a/src/plugins/pdf/pdf.less b/src/plugins/pdf/pdf.less new file mode 100644 index 0000000000..4f58b90f7f --- /dev/null +++ b/src/plugins/pdf/pdf.less @@ -0,0 +1,7 @@ +.pdf-renderer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + padding-top: var(--termpad); +} diff --git a/src/plugins/pdf/pdf.tsx b/src/plugins/pdf/pdf.tsx new file mode 100644 index 0000000000..5e21d7152d --- /dev/null +++ b/src/plugins/pdf/pdf.tsx @@ -0,0 +1,49 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobx from "mobx"; +import * as mobxReact from "mobx-react"; + +import "./pdf.less"; + +@mobxReact.observer +class SimplePdfRenderer extends React.Component< + { data: ExtBlob; context: RendererContext; opts: RendererOpts; savedHeight: number }, + {} +> { + objUrl: string = null; + + componentWillUnmount() { + if (this.objUrl != null) { + URL.revokeObjectURL(this.objUrl); + } + } + + render() { + let dataBlob = this.props.data; + if (dataBlob == null || dataBlob.notFound) { + return ( +
+
+ ERROR: file {dataBlob && dataBlob.name ? JSON.stringify(dataBlob.name) : ""} not found +
+
+ ); + } + if (this.objUrl == null) { + const pdfBlob = new File([dataBlob], dataBlob.name ?? "file.pdf", { type: "application/pdf" }); + this.objUrl = URL.createObjectURL(pdfBlob); + } + const opts = this.props.opts; + const maxHeight = opts.maxSize.height - 10; + const maxWidth = opts.maxSize.width - 10; + return ( +
+