[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nend_of_line = lf\ninsert_final_newline = true\n\n[*.{js,jsx,ts,tsx,cjs,json,yml,yaml,css,less,scss}]\ncharset = utf-8\nindent_style = space\nindent_size = 4\n\n[CNAME]\ninsert_final_newline = false\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: wavetermdev\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.yml",
    "content": "name: 🐞 Bug Report\ndescription: Create a bug report to help us improve.\ntitle: \"[Bug]: \"\nlabels: [\"bug\", \"triage\"]\nbody:\n    - type: markdown\n      attributes:\n          value: |\n              ## Bug description\n    - type: textarea\n      attributes:\n          label: Current Behavior\n          description: A concise description of what you're experiencing.\n      validations:\n          required: true\n    - type: textarea\n      attributes:\n          label: Expected Behavior\n          description: A concise description of what you expected to happen.\n      validations:\n          required: true\n    - type: textarea\n      attributes:\n          label: Steps To Reproduce\n          description: Steps to reproduce the behavior.\n          placeholder: |\n              1. In this environment...\n              2. With this config...\n              3. Run '...'\n              4. See error...\n      validations:\n          required: true\n\n    - type: markdown\n      attributes:\n          value: |\n              ## Environment details\n\n              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.\n    - type: input\n      attributes:\n          label: Wave Version\n          description: The version of Wave you are running\n          placeholder: v0.8.8\n      validations:\n          required: true\n    - type: dropdown\n      attributes:\n          label: Platform\n          description: The OS platform of the computer where you are running Wave\n          options:\n              - macOS\n              - Linux\n              - Windows\n      validations:\n          required: true\n    - type: input\n      attributes:\n          label: OS Version/Distribution\n          description: The version of the operating system of the computer where you are running Wave\n          placeholder: Ubuntu 24.04\n      validations:\n          required: false\n    - type: dropdown\n      attributes:\n          label: Architecture\n          description: The architecture of the computer where you are running Wave\n          options:\n              - arm64\n              - x64\n      validations:\n          required: true\n\n    - type: markdown\n      attributes:\n          value: |\n              ## Extra details\n    - type: textarea\n      attributes:\n          label: Anything else?\n          description: |\n              Links? References? Anything that will give us more context about the issue you are encountering!\n\n              Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.\n      validations:\n          required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: true\ncontact_links:\n    - name: General Question\n      url: https://github.com/wavetermdev/waveterm/discussions\n      about: Have a question on something? Start a new discussion thread.\n    - name: Engage with us directly on Discord\n      url: https://discord.gg/XfvZ334gwU\n      about: Join our Discord server to get updates on new features, bug fixes, and more.\n    - name: Review open issues\n      url: https://github.com/wavetermdev/waveterm/issues\n      about: Please check if your issue isn't already there.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature-request.yml",
    "content": "name: 🚀 Feature Request / Idea\ndescription: Suggest a new idea for this project.\ntitle: \"[Feature]: \"\nlabels: [\"enhancement\", \"triage\"]\nbody:\n    - type: textarea\n      attributes:\n          label: Feature description\n          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 [...]\n      validations:\n          required: true\n    - type: textarea\n      attributes:\n          label: Implementation Suggestion\n          description: If you have any suggestions on how to design this feature, list them here.\n      validations:\n          required: false\n    - type: textarea\n      attributes:\n          label: Anything else?\n          description: |\n              Links? References? Anything that will give us more context about how to deliver your feature!\n\n              Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.\n      validations:\n          required: false\n"
  },
  {
    "path": ".github/copilot-instructions.md",
    "content": "# Wave Terminal — Copilot Instructions\n\n## Project Rules\n\n- See the overview of the project in `.kilocode/rules/overview.md`\n- Read and follow all guidelines in `.kilocode/rules/rules.md`\n\n---\n\n## Skill Guides\n\nThis 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.\n\n| Skill        | File                                     | Description                                                                                                                                                                                                                                 |\n| ------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| 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.                                               |\n| 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.                                                 |\n| 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.                                                                      |\n| 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.                      |\n| 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. |\n| 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.                                                                                                         |\n| 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.          |\n| 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.                            |\n\n> **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.\n\n---\n\n## Preview Server\n\nTo run the standalone component preview (no Electron, no backend required):\n\n```\ntask preview\n```\n\nThis runs `cd frontend/preview && npx vite` and serves at **http://localhost:7007** (port configured in `frontend/preview/vite.config.ts`).\n\nTo build a static preview: `task build:preview`\n\n**Do NOT use any of the following to start the preview — they all launch the full Electron app or serve the wrong content:**\n\n- `npm run dev` — runs `electron-vite dev`, launches Electron\n- `npm run start` — also launches Electron\n- `npx vite` from the repo root — uses the Electron-Vite config, not the preview app\n- Serving the `dist/` directory — the preview app is never built there; it has its own build output\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n    - package-ecosystem: \"npm\"\n      directory: \"/\"\n      schedule:\n          interval: \"weekly\"\n          day: \"friday\"\n          time: \"09:00\"\n          timezone: \"America/Los_Angeles\"\n      groups:\n          dev-dependencies-patch:\n              dependency-type: \"development\"\n              exclude-patterns:\n                  - \"*storybook*\"\n                  - \"*electron*\"\n                  - \"jotai\"\n                  - \"react\"\n                  - \"@types/react\"\n                  - \"*react-dom\"\n                  - \"*docusaurus*\"\n              update-types:\n                  - \"patch\"\n          dev-dependencies-minor:\n              dependency-type: \"development\"\n              exclude-patterns:\n                  - \"*storybook*\"\n                  - \"*electron*\"\n                  - \"jotai\"\n                  - \"react\"\n                  - \"@types/react\"\n                  - \"*react-dom\"\n                  - \"*docusaurus*\"\n              update-types:\n                  - \"minor\"\n\n          prod-dependencies-patch:\n              dependency-type: \"production\"\n              exclude-patterns:\n                  - \"*storybook*\"\n                  - \"*electron*\"\n                  - \"jotai\"\n                  - \"react\"\n                  - \"@types/react\"\n                  - \"*react-dom\"\n                  - \"*docusaurus*\"\n              update-types:\n                  - \"patch\"\n          prod-dependencies-minor:\n              dependency-type: \"production\"\n              exclude-patterns:\n                  - \"*storybook*\"\n                  - \"*electron*\"\n                  - \"jotai\"\n                  - \"react\"\n                  - \"@types/react\"\n                  - \"*react-dom\"\n                  - \"*docusaurus*\"\n              update-types:\n                  - \"minor\"\n\n          storybook-patch:\n              patterns:\n                  - \"*storybook*\"\n              update-types:\n                  - \"patch\"\n          storybook-minor:\n              patterns:\n                  - \"*storybook*\"\n              update-types:\n                  - \"minor\"\n          storybook-major:\n              patterns:\n                  - \"*storybook*\"\n              update-types:\n                  - \"major\"\n\n          electron-patch:\n              patterns:\n                  - \"*electron*\"\n              update-types:\n                  - \"patch\"\n          electron-minor:\n              patterns:\n                  - \"*electron*\"\n              update-types:\n                  - \"minor\"\n          electron-major:\n              patterns:\n                  - \"*electron*\"\n              update-types:\n                  - \"major\"\n\n          docusaurus-patch:\n              patterns:\n                  - \"*docusaurus*\"\n              update-types:\n                  - \"patch\"\n          docusaurus-minor:\n              patterns:\n                  - \"*docusaurus*\"\n              update-types:\n                  - \"minor\"\n          docusaurus-major:\n              patterns:\n                  - \"*docusaurus*\"\n              update-types:\n                  - \"major\"\n\n          react-patch:\n              patterns:\n                  - \"react\"\n                  - \"@types/react\"\n                  - \"*react-dom\"\n              update-types:\n                  - \"patch\"\n          react-minor:\n              patterns:\n                  - \"react\"\n                  - \"@types/react\"\n                  - \"*react-dom\"\n              update-types:\n                  - \"minor\"\n          react-major:\n              patterns:\n                  - \"react\"\n                  - \"@types/react\"\n                  - \"*react-dom\"\n              update-types:\n                  - \"major\"\n\n          jotai-patch:\n              patterns:\n                  - \"jotai\"\n              update-types:\n                  - \"patch\"\n          jotai-minor:\n              patterns:\n                  - \"jotai\"\n              update-types:\n                  - \"minor\"\n          jotai-major:\n              patterns:\n                  - \"jotai\"\n              update-types:\n                  - \"major\"\n    - package-ecosystem: \"gomod\"\n      directory: \"/\"\n      schedule:\n          interval: \"weekly\"\n          day: \"friday\"\n          time: \"09:00\"\n          timezone: \"America/Los_Angeles\"\n    - package-ecosystem: \"github-actions\"\n      directory: \"/.github/workflows\"\n      schedule:\n          interval: \"weekly\"\n          day: \"friday\"\n          time: \"09:00\"\n          timezone: \"America/Los_Angeles\"\n"
  },
  {
    "path": ".github/workflows/build-helper.yml",
    "content": "# Build Helper workflow - Builds, signs, and packages binaries for each supported platform, then uploads to a staging bucket in S3 for wider distribution.\n# For more information on the macOS signing and notarization, see https://www.electron.build/code-signing and https://www.electron.build/configuration/mac\n# 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\n\nname: Build Helper\nrun-name: Build ${{ github.ref_name }}${{ github.event_name == 'workflow_dispatch' && ' - Manual' || '' }}\non:\n    push:\n        tags:\n            - \"v[0-9]+.[0-9]+.[0-9]+*\"\n    workflow_dispatch:\nenv:\n    GO_VERSION: \"1.25.6\"\n    NODE_VERSION: 22\n    NODE_OPTIONS: --max-old-space-size=4096\njobs:\n    build-app:\n        outputs:\n            version: ${{ steps.set-version.outputs.WAVETERM_VERSION }}\n        strategy:\n            matrix:\n                include:\n                    - platform: \"darwin\"\n                      runner: \"macos-latest\"\n                    - platform: \"linux\"\n                      runner: \"ubuntu-latest\"\n                    - platform: \"linux\"\n                      runner: ubuntu-24.04-arm\n                    - platform: \"windows\"\n                      runner: \"windows-latest\"\n                    # - platform: \"windows\"\n                    #   runner: \"windows-11-arm64-16core\"\n        runs-on: ${{ matrix.runner }}\n        steps:\n            - uses: actions/checkout@v6\n            - name: Install Linux Build Dependencies (Linux only)\n              if: matrix.platform == 'linux'\n              run: |\n                  sudo apt-get update\n                  sudo apt-get install --no-install-recommends -y libarchive-tools libopenjp2-tools rpm squashfs-tools\n                  sudo snap install snapcraft --classic\n                  sudo snap install lxd\n                  sudo lxd init --auto\n                  sudo snap refresh\n            - name: Install Zig (not Mac)\n              if: matrix.platform != 'darwin'\n              uses: mlugg/setup-zig@v2\n\n            # The pre-installed version of the AWS CLI has a segfault problem so we'll install it via Homebrew instead.\n            - name: Upgrade AWS CLI (Mac only)\n              if: matrix.platform == 'darwin'\n              run: brew install awscli\n\n            # 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.\n            - name: Install FPM (not Windows)\n              if: matrix.platform != 'windows'\n              run: sudo gem install fpm\n            - name: Install FPM (Windows only)\n              if: matrix.platform == 'windows'\n              run: gem install fpm\n\n            # General build dependencies\n            - uses: actions/setup-go@v6\n              with:\n                  go-version: ${{env.GO_VERSION}}\n                  cache-dependency-path: |\n                      go.sum\n            - uses: actions/setup-node@v6\n              with:\n                  node-version: ${{env.NODE_VERSION}}\n                  cache: npm\n                  cache-dependency-path: package-lock.json\n            - name: Force git deps to HTTPS\n              run: |\n                  git config --global url.https://github.com/.insteadof ssh://git@github.com/\n                  git config --global url.https://github.com/.insteadof git@github.com:\n            - uses: nick-fields/retry@v3\n              name: npm ci\n              with:\n                  command: npm ci --no-audit --no-fund\n                  retry_on: error\n                  max_attempts: 3\n                  timeout_minutes: 5\n              env:\n                  GIT_ASKPASS: \"echo\"\n                  GIT_TERMINAL_PROMPT: \"0\"\n            - name: Install Task\n              uses: arduino/setup-task@v2\n              with:\n                  version: 3.x\n                  repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n            - name: \"Set Version\"\n              id: set-version\n              run: echo \"WAVETERM_VERSION=$(task version)\" >> \"$GITHUB_OUTPUT\"\n              shell: bash\n\n            # Windows Code Signing Setup\n            - name: Set up certificate (Windows only)\n              if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch'\n              run: |\n                  echo \"${{ secrets.SM_CLIENT_CERT_FILE_B64 }}\" | base64 --decode > /d/Certificate_pkcs12.p12\n              shell: bash\n            - name: Set signing variables (Windows only)\n              if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch'\n              id: variables\n              run: |\n                  echo \"SM_HOST=${{ secrets.SM_HOST }}\" >> \"$GITHUB_ENV\"\n                  echo \"SM_API_KEY=${{ secrets.SM_API_KEY }}\" >> \"$GITHUB_ENV\"\n                  echo \"SM_CODE_SIGNING_CERT_SHA1_HASH=${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}\" >> \"$GITHUB_ENV\"\n                  echo \"SM_CLIENT_CERT_FILE=D:\\\\Certificate_pkcs12.p12\" >> \"$GITHUB_ENV\"\n                  echo \"SM_CLIENT_CERT_FILE=D:\\\\Certificate_pkcs12.p12\" >> \"$GITHUB_OUTPUT\"\n                  echo \"SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}\" >> \"$GITHUB_ENV\"\n                  echo \"C:\\Program Files (x86)\\Windows Kits\\10\\App Certification Kit\" >> $GITHUB_PATH\n                  echo \"C:\\Program Files (x86)\\Microsoft SDKs\\Windows\\v10.0A\\bin\\NETFX 4.8 Tools\" >> $GITHUB_PATH\n                  echo \"C:\\Program Files\\DigiCert\\DigiCert Keylocker Tools\" >> $GITHUB_PATH\n              shell: bash\n            - name: Setup Keylocker KSP (Windows only)\n              if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch'\n              run: |\n                  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\n                  msiexec /i Keylockertools-windows-x64.msi /quiet /qn\n                  C:\\Windows\\System32\\certutil.exe -csp \"DigiCert Signing Manager KSP\" -key -user\n                  smctl windows certsync\n              shell: cmd\n\n            # Build and upload packages\n            - name: Build (Linux)\n              if: matrix.platform == 'linux'\n              run: task package\n              env:\n                  USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one.\n                  SNAPCRAFT_BUILD_ENVIRONMENT: host\n            # Retry Darwin build in case of notarization failures\n            - uses: nick-fields/retry@v3\n              name: Build (Darwin)\n              if: matrix.platform == 'darwin'\n              with:\n                  command: task package\n                  timeout_minutes: 120\n                  retry_on: error\n                  max_attempts: 3\n              env:\n                  USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one.\n                  CSC_LINK: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_2}}\n                  CSC_KEY_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_PWD_2 }}\n                  APPLE_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_APPLE_ID_2 }}\n                  APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_PWD_2 }}\n                  APPLE_TEAM_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_TEAM_ID_2 }}\n                  STATIC_DOCSITE_PATH: ${{env.STATIC_DOCSITE_PATH}}\n            - name: Build (Windows)\n              if: matrix.platform == 'windows'\n              run: task package\n              env:\n                  USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one.\n                  CSC_LINK: ${{ steps.variables.outputs.SM_CLIENT_CERT_FILE }}\n                  CSC_KEY_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}\n                  STATIC_DOCSITE_PATH: ${{env.STATIC_DOCSITE_PATH}}\n              shell: powershell # electron-builder's Windows code signing package has some compatibility issues with pwsh, so we need to use Windows Powershell\n\n            # Upload artifacts to the S3 staging and to the workflow output for the draft release job\n            - name: Upload to S3 staging\n              if: github.event_name != 'workflow_dispatch'\n              run: task artifacts:upload\n              env:\n                  AWS_ACCESS_KEY_ID: \"${{ secrets.ARTIFACTS_KEY_ID }}\"\n                  AWS_SECRET_ACCESS_KEY: \"${{ secrets.ARTIFACTS_KEY_SECRET }}\"\n                  AWS_DEFAULT_REGION: us-west-2\n            - name: Upload artifacts\n              uses: actions/upload-artifact@v5\n              with:\n                  name: ${{ matrix.runner }}\n                  path: make\n            - name: Upload Snapcraft logs on failure\n              if: failure()\n              uses: actions/upload-artifact@v5\n              with:\n                  name: ${{ matrix.runner }}-log\n                  path: /home/runner/.local/state/snapcraft/log\n    create-release:\n        runs-on: ubuntu-latest\n        needs: build-app\n        permissions:\n            contents: write\n        if: ${{ github.event_name != 'workflow_dispatch' }}\n        steps:\n            - name: Download artifacts\n              uses: actions/download-artifact@v4\n              with:\n                  path: make\n                  merge-multiple: true\n            - name: Create draft release\n              uses: softprops/action-gh-release@v2\n              with:\n                  prerelease: ${{ contains(github.ref_name, '-beta') }}\n                  name: Wave Terminal ${{ github.ref_name }} Release\n                  generate_release_notes: true\n                  draft: true\n                  files: |\n                      make/*.zip\n                      make/*.dmg\n                      make/*.exe\n                      make/*.msi\n                      make/*.rpm\n                      make/*.deb\n                      make/*.pacman\n                      make/*.snap\n                      make/*.flatpak\n                      make/*.AppImage\n"
  },
  {
    "path": ".github/workflows/bump-version.yml",
    "content": "# Workflow to manage bumping the package version and pushing it to the target branch with a new tag.\n# This workflow uses a GitHub App to bypass branch protection and uses the GitHub API directly to ensure commits and tags are signed.\n# For more information, see this doc: https://github.com/Nautilus-Cyberneering/pygithub/blob/main/docs/how_to_sign_automatic_commits_in_github_actions.md\n\nname: Bump Version\nrun-name: \"branch: ${{ github.ref_name }}; semver-bump: ${{ inputs.bump }}; prerelease: ${{ inputs.is-prerelease }}\"\non:\n    workflow_dispatch:\n        inputs:\n            bump:\n                description: SemVer Bump\n                required: true\n                type: choice\n                default: none\n                options:\n                    - none\n                    - patch\n                    - minor\n                    - major\n            is-prerelease:\n                description: Is Prerelease\n                required: true\n                type: boolean\n                default: true\nenv:\n    NODE_VERSION: 22\njobs:\n    bump-version:\n        runs-on: ubuntu-latest\n        steps:\n            - name: Get App Token\n              uses: actions/create-github-app-token@v2\n              id: app-token\n              with:\n                  app-id: ${{ vars.WAVE_BUILDER_APPID }}\n                  private-key: ${{ secrets.WAVE_BUILDER_KEY }}\n            - uses: actions/checkout@v6\n              with:\n                  token: ${{ steps.app-token.outputs.token }}\n\n            # General build dependencies\n            - uses: actions/setup-node@v6\n              with:\n                  node-version: ${{env.NODE_VERSION}}\n                  cache: npm\n                  cache-dependency-path: package-lock.json\n            - uses: nick-fields/retry@v3\n              name: npm ci\n              with:\n                  command: npm ci --no-audit --no-fund\n                  retry_on: error\n                  max_attempts: 3\n                  timeout_minutes: 5\n            - name: Install Task\n              uses: arduino/setup-task@v2\n              with:\n                  version: 3.x\n                  repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n            - name: \"Bump Version: ${{ inputs.bump }}\"\n              id: bump-version\n              run: echo \"WAVETERM_VERSION=$( task version -- ${{ inputs.bump }} ${{inputs.is-prerelease}} )\" >> \"$GITHUB_OUTPUT\"\n              shell: bash\n\n            - name: \"Push version bump: ${{ steps.bump-version.outputs.WAVETERM_VERSION }}\"\n              if: github.ref_protected\n              run: |\n                  # Create a new commit for the package version bump in package.json\n                  export VERSION=${{ steps.bump-version.outputs.WAVETERM_VERSION }}\n                  export MESSAGE=\"chore: bump package version to $VERSION\"\n                  export FILE=package.json\n                  export BRANCH=${{github.ref_name}}\n                  export SHA=$( git rev-parse $BRANCH:$FILE )\n                  export CONTENT=$( base64 -i $FILE )\n                  gh api --method PUT /repos/:owner/:repo/contents/$FILE \\\n                      --field branch=\"$BRANCH\" \\\n                      --field message=\"$MESSAGE\" \\\n                      --field content=\"$CONTENT\" \\\n                      --field sha=\"$SHA\"\n\n                  # Fetch the new commit and create a tag referencing it\n                  git fetch\n                  export TAG_SHA=$( git rev-parse origin/$BRANCH )\n                  gh api --method POST /repos/:owner/:repo/git/refs \\\n                      --field ref=\"refs/tags/v$VERSION\" \\\n                      --field sha=\"$TAG_SHA\"\n              shell: bash\n              env:\n                  GH_TOKEN: ${{ steps.app-token.outputs.token }}\n"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "content": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# You may wish to alter this file to override the set of languages analyzed,\n# or to provide custom queries or build logic.\n#\n# ******** NOTE ********\n# We have attempted to detect the languages in your repository. Please check\n# the `language` matrix defined below to confirm you have the correct set of\n# supported CodeQL languages.\n#\nname: \"CodeQL\"\n\non:\n    push:\n        branches: [\"main\"]\n        paths:\n            - \"**/*.go\"\n            - \"**/*.ts\"\n            - \"**/*.tsx\"\n    pull_request:\n        branches: [\"main\"]\n        paths:\n            - \"**/*.go\"\n            - \"**/*.ts\"\n            - \"**/*.tsx\"\n        types:\n            - opened\n            - synchronize\n            - reopened\n            - ready_for_review\n    schedule:\n        - cron: \"36 5 * * 5\"\n\nenv:\n    NODE_VERSION: 22\n    GO_VERSION: \"1.25.6\"\n\njobs:\n    analyze:\n        name: Analyze\n        # Runner size impacts CodeQL analysis time. To learn more, please see:\n        #   - https://gh.io/recommended-hardware-resources-for-running-codeql\n        #   - https://gh.io/supported-runners-and-hardware-resources\n        #   - https://gh.io/using-larger-runners\n        # Consider using larger runners for possible analysis time improvements.\n        if: github.event.pull_request.draft == false\n        runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}\n        timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}\n        permissions:\n            actions: read\n            contents: read\n            security-events: write\n\n        strategy:\n            fail-fast: false\n            matrix:\n                language: [\"go\", \"javascript-typescript\"]\n                # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ]\n                # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both\n                # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both\n                # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support\n\n        steps:\n            - name: Checkout repository\n              uses: actions/checkout@v6\n\n            - name: Install Task\n              uses: arduino/setup-task@v2\n              with:\n                  version: 3.x\n                  repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n            - uses: actions/setup-node@v6\n              with:\n                  node-version: ${{env.NODE_VERSION}}\n                  cache: npm\n                  cache-dependency-path: package-lock.json\n            - uses: nick-fields/retry@v3\n              name: npm ci\n              with:\n                  command: npm ci --no-audit --no-fund\n                  retry_on: error\n                  max_attempts: 3\n                  timeout_minutes: 5\n\n            - name: Setup Go\n              uses: actions/setup-go@v6\n              with:\n                  go-version: ${{env.GO_VERSION}}\n                  cache-dependency-path: |\n                      go.sum\n            # We use Zig instead of glibc for cgo compilation as it is more-easily statically linked\n            - name: Setup Zig\n              run: sudo snap install zig --classic --beta\n\n            # Initializes the CodeQL tools for scanning.\n            - name: Initialize CodeQL\n              uses: github/codeql-action/init@v4\n              with:\n                  languages: ${{ matrix.language }}\n                  # If you wish to specify custom queries, you can do so here or in a config file.\n                  # By default, queries listed here will override any specified in a config file.\n                  # Prefix the list here with \"+\" to use these queries and those in the config file.\n\n                  # 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\n                  # queries: security-extended,security-and-quality\n\n            - name: Generate bindings\n              run: task generate\n\n            # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).\n            # If this step fails, then you should remove it and run the build manually (see below)\n            - name: Autobuild (not Go)\n              if: matrix.language != 'go'\n              uses: github/codeql-action/autobuild@v4\n\n            - name: Build (Go only)\n              if: matrix.language == 'go'\n              run: |\n                  task build:server\n                  task build:wsh\n\n            # ℹ️ Command-line programs to run using the OS shell.\n            # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun\n\n            #   If the Autobuild fails above, remove it and uncomment the following three lines.\n            #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.\n\n            # - run: |\n            #     echo \"Run, Build Application using script\"\n            #     ./location_of_script_within_repo/buildscript.sh\n\n            - name: Perform CodeQL Analysis\n              uses: github/codeql-action/analyze@v4\n              with:\n                  category: \"/language:${{matrix.language}}\"\n"
  },
  {
    "path": ".github/workflows/copilot-setup-steps.yml",
    "content": "name: Copilot Setup Steps\n\non:\n    workflow_dispatch:\n    push:\n        paths: [.github/workflows/copilot-setup-steps.yml]\n    pull_request:\n        paths: [.github/workflows/copilot-setup-steps.yml]\n\n# Note: global env vars are NOT used here — they are not reliable in all\n# GitHub Actions contexts (e.g. Copilot setup steps). Values are inlined\n# directly into each step that needs them.\n\njobs:\n    copilot-setup-steps:\n        runs-on: ubuntu-latest\n        permissions:\n            contents: read\n\n        steps:\n            - uses: actions/checkout@v6\n\n            # Go + Node versions match your helper\n            - uses: actions/setup-go@v6\n              with:\n                  go-version: \"1.25.6\"\n                  cache-dependency-path: go.sum\n\n            - uses: actions/setup-node@v6\n              with:\n                  node-version: 22\n                  cache: npm\n                  cache-dependency-path: package-lock.json\n\n            # Zig is used by your Linux CGO builds (kept available, but we won't build here)\n            - uses: mlugg/setup-zig@v2\n\n            # Task CLI for your Taskfile\n            - uses: arduino/setup-task@v2\n              with:\n                  version: 3.x\n                  repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n            # Git HTTPS so deps resolve non-interactively\n            - name: Force git deps to HTTPS\n              run: |\n                  git config --global url.https://github.com/.insteadof ssh://git@github.com/\n                  git config --global url.https://github.com/.insteadof git@github.com:\n\n            # Warm caches only (no builds)\n            - uses: nick-fields/retry@v3\n              name: npm ci\n              with:\n                  command: npm ci --no-audit --no-fund\n                  retry_on: error\n                  max_attempts: 3\n                  timeout_minutes: 5\n              env:\n                  GIT_ASKPASS: \"echo\"\n                  GIT_TERMINAL_PROMPT: \"0\"\n\n            - name: Pre-fetch Go modules\n              env:\n                  GOTOOLCHAIN: auto\n              run: |\n                  go version\n                  go mod download\n"
  },
  {
    "path": ".github/workflows/deploy-docsite.yml",
    "content": "name: Docsite CI/CD\n\nrun-name: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'Build and Deploy' || 'Test Build' }} Docsite\n\nenv:\n    NODE_VERSION: 22\n\non:\n    push:\n        branches:\n            - main\n    workflow_dispatch:\n    # Also run any time a PR is opened targeting the docs\n    pull_request:\n        branches:\n            - main\n        types:\n            - opened\n            - synchronize\n            - reopened\n            - ready_for_review\n        paths:\n            - \"docs/**\"\n            - \".github/workflows/deploy-docsite.yml\"\n            - \"Taskfile.yml\"\n\njobs:\n    build:\n        name: Build Docsite\n        runs-on: ubuntu-latest\n        if: github.event.pull_request.draft == false\n        steps:\n            - uses: actions/checkout@v6\n              with:\n                  fetch-depth: 0\n            - uses: actions/setup-node@v6\n              with:\n                  node-version: ${{env.NODE_VERSION}}\n                  cache: npm\n                  cache-dependency-path: package-lock.json\n            - name: Install Task\n              uses: arduino/setup-task@v2\n              with:\n                  version: 3.x\n                  repo-token: ${{ secrets.GITHUB_TOKEN }}\n            - uses: nick-fields/retry@v3\n              name: npm ci\n              with:\n                  command: npm ci --no-audit --no-fund\n                  retry_on: error\n                  max_attempts: 3\n                  timeout_minutes: 5\n            - name: Build docsite\n              run: task docsite:build:public\n            - name: Upload Build Artifact\n              # Only upload the build artifact when pushed to the main branch\n              if: github.event_name == 'push' && github.ref == 'refs/heads/main'\n              uses: actions/upload-pages-artifact@v3\n              with:\n                  path: docs/build\n    deploy:\n        name: Deploy to GitHub Pages\n        # Only deploy when pushed to the main branch\n        if: github.event_name == 'push' && github.ref == 'refs/heads/main'\n        needs: build\n        # Grant GITHUB_TOKEN the permissions required to make a Pages deployment\n        permissions:\n            pages: write # to deploy to Pages\n            id-token: write # to verify the deployment originates from an appropriate source\n\n        # Deploy to the github-pages environment\n        environment:\n            name: github-pages\n            url: ${{ steps.deployment.outputs.page_url }}\n\n        runs-on: ubuntu-latest\n        steps:\n            - name: Deploy to GitHub Pages\n              id: deployment\n              uses: actions/deploy-pages@v4\n"
  },
  {
    "path": ".github/workflows/merge-gatekeeper.yml",
    "content": "---\nname: Merge Gatekeeper\n\non:\n    pull_request_target:\n        branches:\n            - main\n            - master\n        types:\n            - opened\n            - synchronize\n            - reopened\n            - ready_for_review\n\njobs:\n    merge-gatekeeper:\n        runs-on: ubuntu-latest\n        if: github.event.pull_request.draft == false\n        # Restrict permissions of the GITHUB_TOKEN.\n        # Docs: https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs\n        permissions:\n            checks: read\n            statuses: read\n        steps:\n            - name: Run Merge Gatekeeper\n              # NOTE: v1 is updated to reflect the latest v1.x.y. Please use any tag/branch that suits your needs:\n              #       https://github.com/upsidr/merge-gatekeeper/tags\n              #       https://github.com/upsidr/merge-gatekeeper/branches\n              uses: upsidr/merge-gatekeeper@v1\n              with:\n                  token: ${{ secrets.GITHUB_TOKEN }}\n                  ignored: Build for TestDriver.ai, TestDriver.ai Run, Analyze (go), Analyze (javascript-typescript), License Compliance, CodeRabbit\n"
  },
  {
    "path": ".github/workflows/publish-release.yml",
    "content": "# Workflow to copy artifacts from the staging bucket to the release bucket when a new GitHub Release is published.\n\nname: Publish Release\nrun-name: Publish ${{ github.ref_name }}\non:\n    release:\n        types: [published]\njobs:\n    publish-s3:\n        name: Publish to Releases\n        if: ${{ startsWith(github.ref, 'refs/tags/') }}\n        runs-on: ubuntu-latest\n        steps:\n            - uses: actions/checkout@v6\n            - name: Install Task\n              uses: arduino/setup-task@v2\n              with:\n                  version: 3.x\n                  repo-token: ${{ secrets.GITHUB_TOKEN }}\n            - name: Publish from staging\n              run: \"task artifacts:publish:${{ github.ref_name }}\"\n              env:\n                  AWS_ACCESS_KEY_ID: \"${{ secrets.PUBLISHER_KEY_ID }}\"\n                  AWS_SECRET_ACCESS_KEY: \"${{ secrets.PUBLISHER_KEY_SECRET }}\"\n                  AWS_DEFAULT_REGION: us-west-2\n              shell: bash\n    publish-snap-amd64:\n        name: Publish AMD64 Snap\n        if: ${{ startsWith(github.ref, 'refs/tags/') }}\n        needs: [publish-s3]\n        runs-on: ubuntu-latest\n        steps:\n            - uses: actions/checkout@v6\n            - name: Install Task\n              uses: arduino/setup-task@v2\n              with:\n                  version: 3.x\n                  repo-token: ${{ secrets.GITHUB_TOKEN }}\n            - name: Install Snapcraft\n              run: sudo snap install snapcraft --classic\n              shell: bash\n            - name: Download Snap from Release\n              uses: robinraju/release-downloader@v1\n              with:\n                  tag: ${{github.ref_name}}\n                  fileName: \"*amd64.snap\"\n            - name: Publish to Snapcraft\n              run: \"task artifacts:snap:publish:${{ github.ref_name }}\"\n              env:\n                  SNAPCRAFT_STORE_CREDENTIALS: \"${{secrets.SNAPCRAFT_LOGIN_CREDS}}\"\n              shell: bash\n    publish-snap-arm64:\n        name: Publish ARM64 Snap\n        if: ${{ startsWith(github.ref, 'refs/tags/') }}\n        needs: [publish-s3]\n        runs-on: ubuntu-latest\n        steps:\n            - uses: actions/checkout@v6\n            - name: Install Task\n              uses: arduino/setup-task@v2\n              with:\n                  version: 3.x\n                  repo-token: ${{ secrets.GITHUB_TOKEN }}\n            - name: Install Snapcraft\n              run: sudo snap install snapcraft --classic\n              shell: bash\n            - name: Download Snap from Release\n              uses: robinraju/release-downloader@v1\n              with:\n                  tag: ${{github.ref_name}}\n                  fileName: \"*arm64.snap\"\n            - name: Publish to Snapcraft\n              run: \"task artifacts:snap:publish:${{ github.ref_name }}\"\n              env:\n                  SNAPCRAFT_STORE_CREDENTIALS: \"${{secrets.SNAPCRAFT_LOGIN_CREDS}}\"\n              shell: bash\n    bump-winget:\n        name: Submit WinGet PR\n        if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, 'beta') }}\n        needs: [publish-s3]\n        runs-on: windows-latest\n        steps:\n            - uses: actions/checkout@v6\n            - name: Install Task\n              uses: arduino/setup-task@v2\n              with:\n                  version: 3.x\n                  repo-token: ${{ secrets.GITHUB_TOKEN }}\n            - name: Install wingetcreate\n              run: winget install -e --silent --accept-package-agreements --accept-source-agreements wingetcreate\n              shell: pwsh\n            - name: Submit WinGet version bump\n              run: \"task artifacts:winget:publish:${{ github.ref_name }}\"\n              env:\n                  GITHUB_TOKEN: ${{ secrets.WINGET_BUMP_PAT }}\n              shell: pwsh\n"
  },
  {
    "path": ".github/workflows/testdriver-build.yml",
    "content": "name: TestDriver.ai Build\n\non:\n    push:\n        branches:\n            - main\n        tags:\n            - \"v[0-9]+.[0-9]+.[0-9]+*\"\n    pull_request:\n        # branches:\n        #     - main\n        # paths-ignore:\n        #     - \"docs/**\"\n        #     - \".storybook/**\"\n        #     - \".vscode/**\"\n        #     - \".editorconfig\"\n        #     - \".gitignore\"\n        #     - \".prettierrc\"\n        #     - \".eslintrc.js\"\n        #     - \"**/*.md\"\n        types:\n            - opened\n            - synchronize\n            - reopened\n            - ready_for_review\n    schedule:\n        - cron: 0 21 * * *\n    workflow_dispatch: null\n\nenv:\n    GO_VERSION: \"1.25.6\"\n    NODE_VERSION: 22\n\npermissions:\n    contents: read # To allow the action to read repository contents\n    pull-requests: write # To allow the action to create/update pull request comments\n\njobs:\n    build_and_upload:\n        name: Build for TestDriver.ai\n        runs-on: windows-latest\n        if: github.event.pull_request.draft == false\n        steps:\n            - uses: actions/checkout@v6\n\n            # General build dependencies\n            - uses: actions/setup-go@v6\n              with:\n                  go-version: ${{env.GO_VERSION}}\n            - uses: actions/setup-node@v6\n              with:\n                  node-version: ${{env.NODE_VERSION}}\n                  cache: npm\n                  cache-dependency-path: package-lock.json\n            - uses: nick-fields/retry@v3\n              name: npm ci\n              with:\n                  command: npm ci --no-audit --no-fund\n                  retry_on: error\n                  max_attempts: 3\n                  timeout_minutes: 5\n            - name: Install Task\n              uses: arduino/setup-task@v2\n              with:\n                  version: 3.x\n                  repo-token: ${{ secrets.GITHUB_TOKEN }}\n            - name: Install Zig\n              uses: mlugg/setup-zig@v2\n\n            - name: Build\n              run: task package\n              env:\n                  USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one.\n                  CSC_IDENTITY_AUTO_DISCOVERY: false # disable codesign\n              shell: powershell # electron-builder's Windows code signing package has some compatibility issues with pwsh, so we need to use Windows Powershell\n\n            # Upload .exe as an artifact\n            - name: Upload .exe artifact\n              id: upload\n              uses: actions/upload-artifact@v5\n              with:\n                  name: windows-exe\n                  path: make/*.exe\n"
  },
  {
    "path": ".github/workflows/testdriver.yml",
    "content": "name: TestDriver.ai Run\n\non:\n    workflow_run:\n        workflows: [\"TestDriver.ai Build\"]\n        types:\n            - completed\n\nenv:\n    GO_VERSION: \"1.25.6\"\n    NODE_VERSION: 22\n\npermissions:\n    contents: read\n    statuses: write\n\njobs:\n    context:\n        runs-on: ubuntu-22.04\n        steps:\n            - name: Dump GitHub context\n              env:\n                  GITHUB_CONTEXT: ${{ toJson(github) }}\n              run: echo \"$GITHUB_CONTEXT\"\n            - name: Dump job context\n              env:\n                  JOB_CONTEXT: ${{ toJson(job) }}\n              run: echo \"$JOB_CONTEXT\"\n            - name: Dump steps context\n              env:\n                  STEPS_CONTEXT: ${{ toJson(steps) }}\n              run: echo \"$STEPS_CONTEXT\"\n            - name: Dump runner context\n              env:\n                  RUNNER_CONTEXT: ${{ toJson(runner) }}\n              run: echo \"$RUNNER_CONTEXT\"\n            - name: Dump strategy context\n              env:\n                  STRATEGY_CONTEXT: ${{ toJson(strategy) }}\n              run: echo \"$STRATEGY_CONTEXT\"\n            - name: Dump matrix context\n              env:\n                  MATRIX_CONTEXT: ${{ toJson(matrix) }}\n              run: echo \"$MATRIX_CONTEXT\"\n    run_testdriver:\n        name: Run TestDriver.ai\n        runs-on: windows-latest\n        if: github.event.workflow_run.conclusion == 'success'\n        steps:\n            - uses: testdriverai/action@main\n              id: testdriver\n              env:\n                  FORCE_COLOR: \"3\"\n                  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              with:\n                  key: ${{ secrets.DASHCAM_API }}\n                  prerun: |\n                      $headers = @{\n                          Authorization = \"token ${{ secrets.GITHUB_TOKEN }}\"\n                      }\n\n                      $downloadFolder = \"./download\"\n                      $artifactFileName = \"waveterm.exe\"\n                      $artifactFilePath = \"$downloadFolder/$artifactFileName\"\n\n                      Write-Host \"Starting the artifact download process...\"\n\n                      # Create the download directory if it doesn't exist\n                      if (-not (Test-Path -Path $downloadFolder)) {\n                          Write-Host \"Creating download folder...\"\n                          mkdir $downloadFolder\n                      } else {\n                          Write-Host \"Download folder already exists.\"\n                      }\n\n                      # Fetch the artifact upload URL\n                      Write-Host \"Fetching the artifact upload URL...\"\n                      $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\n\n                      if ($artifactUrl) {\n                          Write-Host \"Artifact URL successfully fetched: $artifactUrl\"\n                      } else {\n                          Write-Error \"Failed to fetch the artifact URL.\"\n                          exit 1\n                      }\n\n                      # Download the artifact (zipped file)\n                      Write-Host \"Starting artifact download...\"\n                      $artifactZipPath = \"$env:TEMP\\artifact.zip\"\n                      try {\n                          Invoke-WebRequest -Uri $artifactUrl `\n                              -Headers $headers `\n                              -OutFile $artifactZipPath `\n                              -MaximumRedirection 5\n\n                          Write-Host \"Artifact downloaded successfully to $artifactZipPath\"\n                      } catch {\n                          Write-Error \"Error downloading artifact: $_\"\n                          exit 1\n                      }\n\n                      # Unzip the artifact\n                      $artifactUnzipPath = \"$env:TEMP\\artifact\"\n                      Write-Host \"Unzipping the artifact to $artifactUnzipPath...\"\n                      try {\n                          Expand-Archive -Path $artifactZipPath -DestinationPath $artifactUnzipPath -Force\n                          Write-Host \"Artifact unzipped successfully to $artifactUnzipPath\"\n                      } catch {\n                          Write-Error \"Failed to unzip the artifact: $_\"\n                          exit 1\n                      }\n\n                      # Find the installer or app executable\n                      $artifactInstallerPath = Get-ChildItem -Path $artifactUnzipPath -Filter *.exe -Recurse | Select-Object -First 1\n\n                      if ($artifactInstallerPath) {\n                          Write-Host \"Executable file found: $($artifactInstallerPath.FullName)\"\n                      } else {\n                          Write-Error \"Executable file not found. Exiting.\"\n                          exit 1\n                      }\n\n                      # Run the installer and log the result\n                      Write-Host \"Running the installer: $($artifactInstallerPath.FullName)...\"\n                      try {\n                          Start-Process -FilePath $artifactInstallerPath.FullName -Wait\n                          Write-Host \"Installer ran successfully.\"\n                      } catch {\n                          Write-Error \"Failed to run the installer: $_\"\n                          exit 1\n                      }\n\n                      # Optional: If the app executable is different from the installer, find and launch it\n                      $wavePath = Join-Path $env:USERPROFILE \"AppData\\Local\\Programs\\waveterm\\Wave.exe\"\n\n                      Write-Host \"Launching the application: $($wavePath)\"\n                      Start-Process -FilePath $wavePath\n                      Write-Host \"Application launched.\"\n\n                  prompt: |\n                      1. /run testdriver/onboarding.yml\n"
  },
  {
    "path": ".gitignore",
    "content": ".task\nfrontend/dist\ndist/\ndist-dev/\nfrontend/node_modules\nnode_modules/\nfrontend/bindings\nbindings/\n*.log\nbin/\n*.dmg\n*.exe\n.DS_Store\n*~\nout/\nmake/\nartifacts/\nmikework/\naiplans/\nmanifests/\n.env\nout\n\n# Yarn Modern\n.pnp.*\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/sdks\n!.yarn/versions\n\n\n*storybook.log\nstorybook-static/\n\ntest-results.xml\n\ndocsite/\n\n.kilo-format-temp-*\n"
  },
  {
    "path": ".golangci.yml",
    "content": "version: 2\n\nlinters:\n    disable:\n        - unused\n\nissues:\n    exclude-rules:\n        - linters:\n              - unused\n          text: \"unused parameter\"\n"
  },
  {
    "path": ".kilocode/rules/overview.md",
    "content": "# Wave Terminal - High Level Architecture Overview\n\n## Project Description\n\nWave 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.\n\n## Top-Level Directory Structure\n\n```\nwaveterm/\n├── emain/              # Electron main process code\n├── frontend/           # React application (renderer process)\n├── cmd/                # Go command-line applications\n├── pkg/                # Go packages/modules\n├── db/                 # Database migrations\n├── docs/               # Documentation (Docusaurus)\n├── build/              # Build configuration and assets\n├── assets/             # Application assets (icons, images)\n├── public/             # Static public assets\n├── tests/              # Test files\n├── .github/            # GitHub workflows and configuration\n└── Configuration files (package.json, tsconfig.json, etc.)\n```\n\n## Architecture Components\n\n### 1. Electron Main Process (`emain/`)\n\nThe Electron main process handles the native desktop application layer:\n\n**Key Files:**\n\n- [`emain.ts`](emain/emain.ts) - Main entry point, application lifecycle management\n- [`emain-window.ts`](emain/emain-window.ts) - Window management (`WaveBrowserWindow` class)\n- [`emain-tabview.ts`](emain/emain-tabview.ts) - Tab view management (`WaveTabView` class)\n- [`emain-wavesrv.ts`](emain/emain-wavesrv.ts) - Go backend server integration\n- [`emain-wsh.ts`](emain/emain-wsh.ts) - WSH (Wave Shell) client integration\n- [`emain-ipc.ts`](emain/emain-ipc.ts) - IPC handlers for frontend ↔ main process communication\n- [`emain-menu.ts`](emain/emain-menu.ts) - Application menu system\n- [`updater.ts`](emain/updater.ts) - Auto-update functionality\n- [`preload.ts`](emain/preload.ts) - Preload script for renderer security\n- [`preload-webview.ts`](emain/preload-webview.ts) - Webview preload script\n\n### 2. Frontend React Application (`frontend/`)\n\nThe React application runs in the Electron renderer process:\n\n**Structure:**\n\n```\nfrontend/\n├── app/                # Main application code\n│   ├── app.tsx         # Root App component\n│   ├── aipanel/        # AI panel UI\n│   ├── block/          # Block-based UI components\n│   ├── element/        # Reusable UI elements\n│   ├── hook/           # Custom React hooks\n│   ├── modals/         # Modal components\n│   ├── store/          # State management (Jotai)\n│   ├── tab/            # Tab components\n│   ├── view/           # Different view types\n│   │   ├── codeeditor/ # Code editor (Monaco)\n│   │   ├── preview/    # File preview\n│   │   ├── sysinfo/    # System info view\n│   │   ├── term/       # Terminal view\n│   │   ├── tsunami/    # Tsunami builder view\n│   │   ├── vdom/       # Virtual DOM view\n│   │   ├── waveai/     # AI chat integration\n│   │   ├── waveconfig/ # Config editor view\n│   │   └── webview/    # Web view\n│   └── workspace/      # Workspace management\n├── builder/            # Builder app entry\n├── layout/             # Layout system\n├── preview/            # Standalone preview renderer\n├── types/              # TypeScript type definitions\n└── util/               # Utility functions\n```\n\n**Key Technologies:**\n\n- Electron (desktop application shell)\n- React 19 with TypeScript\n- Jotai for state management\n- Monaco Editor for code editing\n- XTerm.js for terminal emulation\n- Tailwind CSS v4 for styling\n- SCSS for additional styling (deprecated, new components should use Tailwind)\n- Vite / electron-vite for bundling\n- Task (Taskfile.yml) for build and code generation commands\n\n### 3. Go Backend Server (`cmd/server/`)\n\nThe Go backend server handles all heavy lifting operations:\n\n**Entry Point:** [`main-server.go`](cmd/server/main-server.go)\n\n### 4. Go Packages (`pkg/`)\n\nThe Go codebase is organized into modular packages:\n\n**Key Packages:**\n\n- `wstore/` - Database and storage layer\n- `wconfig/` - Configuration management\n- `wcore/` - Core business logic\n- `wshrpc/` - RPC communication system\n- `wshutil/` - WSH (Wave Shell) utilities\n- `blockcontroller/` - Block execution management\n- `remote/` - Remote connection handling\n- `filestore/` - File storage system\n- `web/` - Web server and WebSocket handling\n- `telemetry/` - Usage analytics and telemetry\n- `waveobj/` - Core data objects\n- `service/` - Service layer\n- `wps/` - Wave PubSub event system\n- `waveai/` - AI functionality\n- `shellexec/` - Shell execution\n- `util/` - Common utilities\n\n### 5. Command Line Tools (`cmd/`)\n\nKey Go command-line utilities:\n\n- `wsh/` - Wave Shell command-line tool\n- `server/` - Main backend server\n- `generatego/` - Code generation\n- `generateschema/` - Schema generation\n- `generatets/` - TypeScript generation\n\n## Communication Architecture\n\nThe 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).\n\n### WSH RPC System (`pkg/wshrpc/`)\n\nThe WSH RPC system is the backbone of Wave Terminal's communication architecture:\n\n**Key Components:**\n\n- [`wshrpctypes.go`](pkg/wshrpc/wshrpctypes.go) - Core RPC interface and type definitions (source of truth for all RPC commands)\n- [`wshserver/`](pkg/wshrpc/wshserver/) - Server-side RPC implementation\n- [`wshremote/`](pkg/wshrpc/wshremote/) - Remote connection handling\n- [`wshclient.go`](pkg/wshrpc/wshclient.go) - Go client for making RPC calls\n- [`frontend/app/store/wshclientapi.ts`](frontend/app/store/wshclientapi.ts) - Generated TypeScript RPC client\n\n**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.\n\n## Development Notes\n\n- **Build commands** - Use `task` (Taskfile.yml) for all build, generate, and packaging commands\n- **Code generation** - Run `task generate` after modifying Go types in `pkg/wshrpc/wshrpctypes.go`, `pkg/wconfig/settingsconfig.go`, or `pkg/waveobj/wtypemeta.go`\n- **Testing** - Vitest for frontend unit tests; standard `go test` for Go packages\n- **Database migrations** - SQL migration files in `db/migrations-wstore/` and `db/migrations-filestore/`\n- **Documentation** - Docusaurus site in `docs/`\n"
  },
  {
    "path": ".kilocode/rules/rules.md",
    "content": "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.\n\n### Project Structure\n\nIt 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).\n\n### Coding Guidelines\n\n- **Go Conventions**:\n  - 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`).\n  - Use string constants for status values, packet types, and other string-based enumerations.\n  - in Go code, prefer using Printf() vs Println()\n  - use \"Make\" as opposed to \"New\" for struct initialization func names\n  - in general const decls go at the top of the file (before types and functions)\n  - NEVER run `go build` (especially in weird sub-package directories). we can tell if everything compiles by seeing there are no problems/errors.\n- **Synchronization**:\n  - Always prefer to use the `lock.Lock(); defer lock.Unlock()` pattern for synchronization if possible\n  - Avoid inline lock/unlock pairs - instead create helper functions that use the defer pattern\n  - When accessing shared data structures (maps, slices, etc.), ensure proper locking\n  - 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] }`\n- **TypeScript Imports**:\n  - Use `@/...` for imports from different parts of the project (configured in `tsconfig.json` as `\"@/*\": [\"frontend/*\"]`).\n  - Prefer relative imports (`\"./name\"`) only within the same directory.\n  - Use named exports exclusively; avoid default exports. It's acceptable to export functions directly (e.g., React Components).\n  - Our indent is 4 spaces\n- **JSON Field Naming**: All fields must be lowercase, without underscores.\n- **TypeScript Conventions**\n  - **Type Handling**:\n    - In TypeScript we have strict null checks off, so no need to add \"| null\" to all the types.\n    - In TypeScript for Jotai atoms, if we want to write, we need to type the atom as a PrimitiveAtom<Type>\n    - 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.\n    - 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.\n  - **Coding Style**:\n    - Use all lowercase filenames (except where case is actually important like Taskfile.yml)\n    - Import the \"cn\" function from \"@/util/util\" to do classname / clsx class merge (it uses twMerge underneath)\n    - For element variants use class-variance-authority\n    - Do NOT create private fields in classes (they are impossible to inspect)\n    - Use PascalCase for global consts at the top of files\n  - **Component Practices**:\n    - Make sure to add cursor-pointer to buttons/links and clickable items\n    - NEVER use cursor-help (it looks terrible)\n    - useAtom() and useAtomValue() are react HOOKS, so they must be called at the component level not inline in JSX\n    - If you use React.memo(), make sure to add a displayName for the component\n  - Other\n    - never use atob() or btoa() (not UTF-8 safe). use functions in frontend/util/util.ts for base64 decoding and encoding\n- In general, when writing functions, we prefer _early returns_ rather than putting the majority of a function inside of an if block.\n\n### Styling\n\n- We use **Tailwind v4** to style. Custom stuff is defined in frontend/tailwindsetup.css\n- _never_ use cursor-help, or cursor-not-allowed (it looks terrible)\n- 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.\n- 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)\n\n### RPC System\n\nTo 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.\n\nFor normal \"server\" RPCs (where a frontend client is calling the main server) you should implement the RPC call in `pkg/wshrpc/wshserver.go`.\n\n### Electron API\n\nFrom within the FE to get the electron API (e.g. the preload functions):\n\n```ts\nimport { getApi } from \"@/store/global\";\n\ngetApi().getIsDev();\n```\n\nThe full API is defined in custom.d.ts as type ElectronApi.\n\n### Code Generation\n\n- **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`.\n- **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`.\n\n### Frontend Architecture\n\n- The application uses Jotai for state management.\n- When working with Jotai atoms that need to be updated, define them as `PrimitiveAtom<Type>` rather than just `atom<Type>`.\n\n### Notes\n\n- **CRITICAL: Completion format MUST be: \"Done: [one-line description]\"**\n- **Keep your Task Completed summaries VERY short**\n- **No lengthy pre-completion summaries** - Do not provide detailed explanations of implementation before using attempt_completion\n- **No recaps of changes** - Skip explaining what was done before completion\n- **Go directly to completion** - After making changes, proceed directly to attempt_completion without summarizing\n- The project is currently an un-released POC / MVP. Do not worry about backward compatibility when making changes\n- 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.\n- **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.\n- **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.\n- for simple functions, we prefer `if (!cond) { return }; functionality;` pattern over `if (cond) { functionality }` because it produces less indentation and is easier to follow.\n- It is now 2026, so if you write new files, or update files use 2026 for the copyright year\n- React.MutableRefObject is deprecated, just use React.RefObject now (in React 19 RefObject is always mutable)\n\n### Strict Comment Rules\n\n- **NEVER add comments that merely describe what code is doing**:\n  - ❌ `mutex.Lock() // Lock the mutex`\n  - ❌ `counter++ // Increment the counter`\n  - ❌ `buffer.Write(data) // Write data to buffer`\n  - ❌ `// Header component for app run list` (above AppRunListHeader)\n  - ❌ `// Updated function to include onClick parameter`\n  - ❌ `// Changed padding calculation`\n  - ❌ `// Removed unnecessary div`\n  - ❌ `// Using the model's width value here`\n- **Only use comments for**:\n  - Explaining WHY a particular approach was chosen\n  - Documenting non-obvious edge cases or side effects\n  - Warning about potential pitfalls in usage\n  - Explaining complex algorithms that can't be simplified\n- **When in doubt, leave it out**. No comment is better than a redundant comment.\n- **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.\n- **Never remove existing comments** unless specifically directed by the user. Comments that are already defined in existing code have been vetted by the user.\n\n### Jotai Model Pattern (our rules)\n\n- **Atoms live on the model.**\n- **Simple atoms:** define as **field initializers**.\n- **Atoms that depend on values/other atoms:** create in the **constructor**.\n- Models **never use React hooks**; they use `globalStore.get/set`.\n- It's fine to call model methods from **event handlers** or **`useEffect`**.\n- Models use the **singleton pattern** with a `private static instance` field, a `private constructor`, and a `static getInstance()` method.\n- The constructor is `private`; callers always use `getInstance()`.\n\n```ts\n// model/MyModel.ts\nimport * as jotai from \"jotai\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\n\nexport class MyModel {\n  private static instance: MyModel | null = null;\n\n  // simple atoms (field init)\n  statusAtom = jotai.atom<\"idle\" | \"running\" | \"error\">(\"idle\");\n  outputAtom = jotai.atom(\"\");\n\n  // ctor-built atoms (need types)\n  lengthAtom!: jotai.Atom<number>;\n  thresholdedAtom!: jotai.Atom<boolean>;\n\n  private constructor(initialThreshold = 20) {\n    this.lengthAtom = jotai.atom((get) => get(this.outputAtom).length);\n    this.thresholdedAtom = jotai.atom((get) => get(this.lengthAtom) > initialThreshold);\n  }\n\n  static getInstance(): MyModel {\n    if (!MyModel.instance) {\n      MyModel.instance = new MyModel();\n    }\n    return MyModel.instance;\n  }\n\n  static resetInstance(): void {\n    MyModel.instance = null;\n  }\n\n  async doWork() {\n    globalStore.set(this.statusAtom, \"running\");\n    // ... do work ...\n    globalStore.set(this.statusAtom, \"idle\");\n  }\n}\n```\n\n```tsx\n// component usage (events & effects OK)\nimport { useAtomValue } from \"jotai\";\n\nfunction Panel() {\n  const model = MyModel.getInstance();\n  const status = useAtomValue(model.statusAtom);\n  const isBig = useAtomValue(model.thresholdedAtom);\n\n  const onClick = () => model.doWork();\n\n  return (\n    <div>\n      {status} • {String(isBig)}\n    </div>\n  );\n}\n```\n\n**Remember:** singleton pattern with `getInstance()`, `private constructor`, atoms on the model, simple-as-fields, ctor for dependent/derived, updates via `globalStore.set/get`.\n**Note** Older models may not use the singleton pattern\n\n### Tool Use\n\nDo 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.\n\nAlso 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).\n\n### Directory Awareness\n\n- **ALWAYS verify the current working directory before executing commands**\n- Either run \"pwd\" first to verify the directory, or do a \"cd\" to the correct absolute directory before running commands\n- 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.\n\n### Testing / Compiling Go Code\n\nNo 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.\nIf there are no Go errors in VSCode you can assume the code compiles fine.\n"
  },
  {
    "path": ".kilocode/skills/add-config/SKILL.md",
    "content": "---\nname: add-config\ndescription: 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.\n---\n\n# Adding a New Configuration Setting to Wave Terminal\n\nThis guide explains how to add a new configuration setting to Wave Terminal's hierarchical configuration system.\n\n## Configuration System Overview\n\nWave Terminal uses a hierarchical configuration system with:\n\n1. **Go Struct Definitions** - Type-safe configuration structure in `pkg/wconfig/settingsconfig.go`\n2. **JSON Schema** - Auto-generated validation schema in `schema/settings.json`\n3. **Default Values** - Built-in defaults in `pkg/wconfig/defaultconfig/settings.json`\n4. **User Configuration** - User overrides in `~/.config/waveterm/settings.json`\n5. **Block Metadata** - Block-level overrides in `pkg/waveobj/wtypemeta.go`\n6. **Documentation** - User-facing docs in `docs/docs/config.mdx`\n\nSettings cascade from defaults → user settings → connection config → block overrides.\n\n## Step-by-Step Guide\n\n### Step 1: Add to Go Struct Definition\n\nEdit `pkg/wconfig/settingsconfig.go` and add your new field to the `SettingsType` struct:\n\n```go\ntype SettingsType struct {\n    // ... existing fields ...\n\n    // Add your new field with appropriate JSON tag\n    MyNewSetting string `json:\"mynew:setting,omitempty\"`\n\n    // For different types:\n    MyBoolSetting   bool    `json:\"mynew:boolsetting,omitempty\"`\n    MyNumberSetting float64 `json:\"mynew:numbersetting,omitempty\"`\n    MyIntSetting    *int64  `json:\"mynew:intsetting,omitempty\"`    // Use pointer for optional ints\n    MyArraySetting  []string `json:\"mynew:arraysetting,omitempty\"`\n}\n```\n\n**Naming Conventions:**\n\n- Use namespace prefixes (e.g., `term:`, `window:`, `ai:`, `web:`, `app:`)\n- Use lowercase with colons as separators\n- Field names should be descriptive and follow Go naming conventions\n- Use `omitempty` tag to exclude empty values from JSON\n\n**Type Guidelines:**\n\n- Use `*int64` and `*float64` for optional numeric values\n- Use `*bool` for optional boolean values (or `bool` if default is false)\n- Use `string` for text values\n- Use `[]string` for arrays\n- Use `float64` for numbers that can be decimals\n\n**Namespace Organization:**\n\n- `app:*` - Application-level settings\n- `term:*` - Terminal-specific settings\n- `window:*` - Window and UI settings\n- `ai:*` - AI-related settings\n- `web:*` - Web browser settings\n- `editor:*` - Code editor settings\n- `conn:*` - Connection settings\n\n### Step 1.5: Add to Block Metadata (Optional)\n\nIf your setting should support block-level overrides, also add it to `pkg/waveobj/wtypemeta.go`:\n\n```go\ntype MetaTSType struct {\n    // ... existing fields ...\n\n    // Add your new field with matching JSON tag and type\n    MyNewSetting *string `json:\"mynew:setting,omitempty\"`  // Use pointer for optional values\n\n    // For different types:\n    MyBoolSetting   *bool    `json:\"mynew:boolsetting,omitempty\"`\n    MyNumberSetting *float64 `json:\"mynew:numbersetting,omitempty\"`\n    MyIntSetting    *int     `json:\"mynew:intsetting,omitempty\"`\n    MyArraySetting  []string `json:\"mynew:arraysetting,omitempty\"`\n}\n```\n\n**Block Metadata Guidelines:**\n\n- Use pointer types (`*string`, `*bool`, `*int`, `*float64`) for optional overrides\n- JSON tags should exactly match the corresponding settings field\n- This enables the hierarchical config system: block metadata → connection config → global settings\n- Only add settings here that make sense to override per-block or per-connection\n\n### Step 2: Set Default Value (Optional)\n\nIf your setting should have a default value, add it to `pkg/wconfig/defaultconfig/settings.json`:\n\n```json\n{\n  \"ai:preset\": \"ai@global\",\n  \"ai:model\": \"gpt-5-mini\",\n  // ... existing defaults ...\n\n  \"mynew:setting\": \"default value\",\n  \"mynew:boolsetting\": true,\n  \"mynew:numbersetting\": 42.5,\n  \"mynew:intsetting\": 100\n}\n```\n\n**Default Value Guidelines:**\n\n- Only add defaults for settings that should have non-zero/non-empty initial values\n- Ensure defaults make sense for typical user experience\n- Keep defaults conservative and safe\n- Boolean settings often don't need defaults if `false` is the correct default\n\n### Step 3: Update Documentation\n\nAdd your new setting to the configuration table in `docs/docs/config.mdx`:\n\n```markdown\n| Key Name            | Type     | Function                                  |\n| ------------------- | -------- | ----------------------------------------- |\n| mynew:setting       | string   | Description of what this setting controls |\n| mynew:boolsetting   | bool     | Enable/disable some feature               |\n| mynew:numbersetting | float    | Numeric setting for some parameter        |\n| mynew:intsetting    | int      | Integer setting for some configuration    |\n| mynew:arraysetting  | string[] | Array of strings for multiple values      |\n```\n\n**Documentation Guidelines:**\n\n- Provide clear, concise descriptions\n- For new settings in upcoming releases, add `<VersionBadge version=\"v0.14\" />`\n- Update the default configuration example if you added defaults\n- Explain what values are valid and what they do\n\n### Step 4: Regenerate Schema and TypeScript Types\n\nRun the generate task to automatically regenerate the JSON schema and TypeScript types:\n\n```bash\ntask generate\n```\n\n**What this does:**\n\n- Runs `task build:schema` (automatically generates JSON schema from Go structs)\n- Generates TypeScript type definitions in `frontend/types/gotypes.d.ts`\n- Generates RPC client APIs\n- Generates metadata constants\n\n**Important:** The JSON schema in `schema/settings.json` is **automatically generated** from the Go struct definitions - you don't need to edit it manually.\n\n### Step 5: Use in Frontend Code\n\nAccess your new setting in React components:\n\n```typescript\nimport { getOverrideConfigAtom, getSettingsKeyAtom, useAtomValue } from \"@/store/global\";\n\n// In a React component\nconst MyComponent = ({ blockId }: { blockId: string }) => {\n    // Use override config atom for hierarchical resolution\n    // This automatically checks: block metadata → connection config → global settings → default\n    const mySettingAtom = getOverrideConfigAtom(blockId, \"mynew:setting\");\n    const mySetting = useAtomValue(mySettingAtom) ?? \"fallback value\";\n\n    // For global-only settings (no block overrides)\n    const globalOnlySetting = useAtomValue(getSettingsKeyAtom(\"mynew:globalsetting\")) ?? \"fallback\";\n\n    return <div>Setting value: {mySetting}</div>;\n};\n```\n\n**Frontend Configuration Patterns:**\n\n```typescript\n// 1. Settings with block-level overrides (recommended for most view/display settings)\nconst termFontSize = useAtomValue(getOverrideConfigAtom(blockId, \"term:fontsize\")) ?? 12;\n\n// 2. Global-only settings (app-wide settings that don't vary by block)\nconst appGlobalHotkey = useAtomValue(getSettingsKeyAtom(\"app:globalhotkey\")) ?? \"\";\n\n// 3. Connection-specific settings\nconst connStatus = useAtomValue(getConnStatusAtom(connectionName));\n```\n\n**When to use each pattern:**\n\n- Use `getOverrideConfigAtom()` for settings that can vary by block or connection (most UI/display settings)\n- Use `getSettingsKeyAtom()` for app-level settings that are always global\n- Always provide a fallback value with `??` operator\n\n### Step 6: Use in Backend Code\n\nAccess settings in Go code:\n\n```go\n// Get the full config\nfullConfig := wconfig.GetWatcher().GetFullConfig()\n\n// Access your setting\nmyValue := fullConfig.Settings.MyNewSetting\n\n// For optional values (pointers)\nif fullConfig.Settings.MyIntSetting != nil {\n    intValue := *fullConfig.Settings.MyIntSetting\n    // Use intValue\n}\n```\n\n## Complete Examples\n\n### Example 1: Simple Boolean Setting (No Block Override)\n\n**Use case:** Add a setting to hide the AI button globally\n\n#### 1. Go Struct (`pkg/wconfig/settingsconfig.go`)\n\n```go\ntype SettingsType struct {\n    // ... existing fields ...\n    AppHideAiButton bool `json:\"app:hideaibutton,omitempty\"`\n}\n```\n\n#### 2. Default Value (`pkg/wconfig/defaultconfig/settings.json`)\n\n```json\n{\n  \"app:hideaibutton\": false\n}\n```\n\n#### 3. Documentation (`docs/docs/config.mdx`)\n\n```markdown\n| app:hideaibutton <VersionBadge version=\"v0.14\" /> | bool | Hide the AI button in the tab bar (defaults to false) |\n```\n\n#### 4. Generate Types\n\n```bash\ntask generate\n```\n\n#### 5. Frontend Usage\n\n```typescript\nimport { getSettingsKeyAtom } from \"@/store/global\";\n\nconst TabBar = () => {\n    const hideAiButton = useAtomValue(getSettingsKeyAtom(\"app:hideaibutton\"));\n\n    if (hideAiButton) {\n        return null; // Don't render AI button\n    }\n\n    return <button>AI</button>;\n};\n```\n\n#### 6. Usage Examples\n\n```bash\n# Set in settings file\nwsh setconfig app:hideaibutton=true\n\n# Or edit ~/.config/waveterm/settings.json\n{\n  \"app:hideaibutton\": true\n}\n```\n\n### Example 2: Terminal Setting with Block Override\n\n**Use case:** Add a terminal bell sound setting that can be overridden per block\n\n#### 1. Go Struct (`pkg/wconfig/settingsconfig.go`)\n\n```go\ntype SettingsType struct {\n    // ... existing fields ...\n    TermBellSound string `json:\"term:bellsound,omitempty\"`\n}\n```\n\n#### 2. Block Metadata (`pkg/waveobj/wtypemeta.go`)\n\n```go\ntype MetaTSType struct {\n    // ... existing fields ...\n    TermBellSound *string `json:\"term:bellsound,omitempty\"`  // Pointer for optional override\n}\n```\n\n#### 3. Default Value (`pkg/wconfig/defaultconfig/settings.json`)\n\n```json\n{\n  \"term:bellsound\": \"default\"\n}\n```\n\n#### 4. Documentation (`docs/docs/config.mdx`)\n\n```markdown\n| term:bellsound <VersionBadge version=\"v0.14\" /> | string | Sound to play for terminal bell (\"default\", \"none\", or custom sound file path) |\n```\n\n#### 5. Generate Types\n\n```bash\ntask generate\n```\n\n#### 6. Frontend Usage\n\n```typescript\nimport { getOverrideConfigAtom } from \"@/store/global\";\n\nconst TerminalView = ({ blockId }: { blockId: string }) => {\n    // Use override config for hierarchical resolution\n    const bellSoundAtom = getOverrideConfigAtom(blockId, \"term:bellsound\");\n    const bellSound = useAtomValue(bellSoundAtom) ?? \"default\";\n\n    const playBellSound = () => {\n        if (bellSound === \"none\") return;\n        // Play the bell sound\n    };\n\n    return <div>Terminal with bell: {bellSound}</div>;\n};\n```\n\n#### 7. Usage Examples\n\n```bash\n# Set globally in settings file\nwsh setconfig term:bellsound=\"custom.wav\"\n\n# Set for current block only\nwsh setmeta term:bellsound=\"none\"\n\n# Set for specific block\nwsh setmeta --block BLOCK_ID term:bellsound=\"beep\"\n\n# Or edit ~/.config/waveterm/settings.json\n{\n  \"term:bellsound\": \"custom.wav\"\n}\n```\n\n## Configuration Patterns\n\n### Clear/Reset Pattern\n\nEach namespace can have a \"clear\" field for resetting all settings in that namespace:\n\n```go\nAppClear  bool `json:\"app:*,omitempty\"`\nTermClear bool `json:\"term:*,omitempty\"`\n```\n\n### Optional vs Required Settings\n\n- Use pointer types (`*bool`, `*int64`, `*float64`) for truly optional settings\n- Use regular types for settings that should always have a value\n- Provide sensible defaults for important settings\n\n### Block-Level Overrides via RPC\n\nSettings can be overridden at the block level using metadata:\n\n```typescript\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { WOS } from \"@/store/global\";\n\n// Set block-specific override\nawait RpcApi.SetMetaCommand(TabRpcClient, {\n  oref: WOS.makeORef(\"block\", blockId),\n  meta: { \"mynew:setting\": \"block-specific value\" },\n});\n```\n\n## Common Pitfalls\n\n### 1. Forgetting to Run `task generate`\n\n**Problem:** TypeScript types not updated, schema out of sync\n\n**Solution:** Always run `task generate` after modifying Go structs\n\n### 2. Type Mismatch Between Settings and Metadata\n\n**Problem:** Settings uses `string`, metadata uses `*int`\n\n**Solution:** Ensure types match (except metadata uses pointers for optionals)\n\n### 3. Not Providing Fallback Values\n\n**Problem:** Component breaks if setting is undefined\n\n**Solution:** Always use `??` operator with fallback:\n\n```typescript\nconst value = useAtomValue(getSettingsKeyAtom(\"key\")) ?? \"default\";\n```\n\n### 4. Using Wrong Config Atom\n\n**Problem:** Using `getSettingsKeyAtom()` for settings that need block overrides\n\n**Solution:** Use `getOverrideConfigAtom()` for any setting in `MetaTSType`\n\n## Best Practices\n\n### Naming\n\n- **Use descriptive names**: `term:fontsize` not `term:fs`\n- **Follow namespace conventions**: Group related settings with common prefix\n- **Use consistent casing**: Always lowercase with colons\n\n### Types\n\n- **Use `bool`** for simple on/off settings (no pointer if false is default)\n- **Use `*bool`** only if you need to distinguish unset from false\n- **Use `*int64`/`*float64`** for optional numeric values\n- **Use `string`** for text, paths, or enum-like values\n- **Use `[]string`** for lists\n\n### Defaults\n\n- **Provide sensible defaults** for settings users will commonly change\n- **Omit defaults** for advanced/optional settings\n- **Keep defaults safe** - don't enable experimental features by default\n- **Document defaults** clearly in config.mdx\n\n### Block Overrides\n\n- **Enable for view/display settings**: Font sizes, colors, themes, etc.\n- **Don't enable for app-wide settings**: Global hotkeys, window behavior, etc.\n- **Consider the use case**: Would a user want different values per block or connection?\n\n### Documentation\n\n- **Be specific**: Explain what the setting does and what values are valid\n- **Provide examples**: Show common use cases\n- **Add version badges**: Mark new settings with `<VersionBadge version=\"v0.x\" />`\n- **Keep it current**: Update docs when behavior changes\n\n## Quick Reference\n\nWhen adding a new configuration setting:\n\n- [ ] Add field to `SettingsType` in `pkg/wconfig/settingsconfig.go`\n- [ ] Add field to `MetaTSType` in `pkg/waveobj/wtypemeta.go` (if block override needed)\n- [ ] Add default to `pkg/wconfig/defaultconfig/settings.json` (if needed)\n- [ ] Document in `docs/docs/config.mdx`\n- [ ] Run `task generate` to update TypeScript types\n- [ ] Use appropriate atom (`getOverrideConfigAtom` or `getSettingsKeyAtom`) in frontend\n\n## Related Documentation\n\n- **User Documentation**: `docs/docs/config.mdx` - User-facing configuration docs\n- **Type Definitions**: `pkg/wconfig/settingsconfig.go` - Go struct definitions\n- **Metadata Types**: `pkg/waveobj/wtypemeta.go` - Block metadata definitions\n"
  },
  {
    "path": ".kilocode/skills/add-rpc/SKILL.md",
    "content": "---\nname: add-rpc\ndescription: 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.\n---\n\n# Adding RPC Calls Guide\n\n## Overview\n\nWave 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.\n\nThis guide covers how to add a new RPC command to the system.\n\n## Key Files\n\n- `pkg/wshrpc/wshrpctypes.go` - RPC interface and type definitions\n- `pkg/wshrpc/wshserver/wshserver.go` - Main server implementation (most common)\n- `emain/emain-wsh.ts` - Electron main process implementation\n- `frontend/app/store/tabrpcclient.ts` - Frontend tab implementation\n- `pkg/wshrpc/wshremote/wshremote.go` - Remote server implementation\n- `frontend/app/view/term/term-wsh.tsx` - Terminal block implementation\n\n## RPC Command Structure\n\nRPC commands in Wave Terminal follow these conventions:\n\n- **Method names** must end with `Command`\n- **First parameter** must be `context.Context`\n- **Remaining parameters** are a regular Go parameter list (zero or more typed args)\n- **Return values** can be either just an error, or one return value plus an error\n- **Streaming commands** return a channel instead of a direct value\n\n## Adding a New RPC Call\n\n### Step 1: Define the Command in the Interface\n\nAdd your command to the `WshRpcInterface` in `pkg/wshrpc/wshrpctypes.go`:\n\n```go\ntype WshRpcInterface interface {\n    // ... existing commands ...\n    \n    // Add your new command\n    YourNewCommand(ctx context.Context, data CommandYourNewData) (*YourNewResponse, error)\n}\n```\n\n**Method Signature Rules:**\n\n- Method name must end with `Command`\n- First parameter must be `ctx context.Context`\n- Remaining parameters are a regular Go parameter list (zero or more)\n- Return either `error` or `(ReturnType, error)`\n- For streaming, return `chan RespOrErrorUnion[T]`\n\n### Step 2: Define Request and Response Types\n\nIf your command needs structured input or output, define types in the same file:\n\n```go\ntype CommandYourNewData struct {\n    FieldOne   string `json:\"fieldone\"`\n    FieldTwo   int    `json:\"fieldtwo\"`\n    SomeId     string `json:\"someid\"`\n}\n\ntype YourNewResponse struct {\n    ResultField string `json:\"resultfield\"`\n    Success     bool   `json:\"success\"`\n}\n```\n\n**Type Naming Conventions:**\n\n- Request types: `Command[Name]Data` (e.g., `CommandGetMetaData`)\n- Response types: `[Name]Response` or `Command[Name]RtnData` (e.g., `CommandResolveIdsRtnData`)\n- Use `json` struct tags with lowercase field names\n- Follow existing patterns in the file for consistency\n\n### Step 3: Generate Bindings\n\nAfter modifying `pkg/wshrpc/wshrpctypes.go`, run code generation to create TypeScript bindings and Go helper code:\n\n```bash\ntask generate\n```\n\nThis command will:\n- Generate TypeScript type definitions in `frontend/types/gotypes.d.ts`\n- Create RPC client bindings\n- Update routing code\n\n**Note:** If generation fails, check that your method signature follows all the rules above.\n\n### Step 4: Implement the Command\n\nChoose where to implement your command based on what it needs to do:\n\n#### A. Main Server Implementation (Most Common)\n\nImplement in `pkg/wshrpc/wshserver/wshserver.go`:\n\n```go\nfunc (ws *WshServer) YourNewCommand(ctx context.Context, data wshrpc.CommandYourNewData) (*wshrpc.YourNewResponse, error) {\n    // Validate input\n    if data.SomeId == \"\" {\n        return nil, fmt.Errorf(\"someid is required\")\n    }\n    \n    // Implement your logic\n    result := doSomething(data)\n    \n    // Return response\n    return &wshrpc.YourNewResponse{\n        ResultField: result,\n        Success:     true,\n    }, nil\n}\n```\n\n**Use main server when:**\n- Accessing the database\n- Managing blocks, tabs, or workspaces\n- Coordinating between components\n- Handling file operations on the main filesystem\n\n#### B. Electron Implementation\n\nImplement in `emain/emain-wsh.ts`:\n\n```typescript\nasync handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise<YourNewResponse> {\n    // Electron-specific logic\n    const result = await electronAPI.doSomething(data);\n    \n    return {\n        resultfield: result,\n        success: true,\n    };\n}\n```\n\n**Use Electron when:**\n- Accessing native OS features\n- Managing application windows\n- Using Electron APIs (notifications, system tray, etc.)\n- Handling encryption/decryption with safeStorage\n\n#### C. Frontend Tab Implementation\n\nImplement in `frontend/app/store/tabrpcclient.ts`:\n\n```typescript\nasync handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise<YourNewResponse> {\n    // Access frontend state/models\n    const layoutModel = getLayoutModelForStaticTab();\n    \n    // Implement tab-specific logic\n    const result = layoutModel.doSomething(data);\n    \n    return {\n        resultfield: result,\n        success: true,\n    };\n}\n```\n\n**Use tab client when:**\n- Accessing React state or Jotai atoms\n- Manipulating UI layout\n- Capturing screenshots\n- Reading frontend-only data\n\n#### D. Remote Server Implementation\n\nImplement in `pkg/wshrpc/wshremote/wshremote.go`:\n\n```go\nfunc (impl *ServerImpl) RemoteYourNewCommand(ctx context.Context, data wshrpc.CommandRemoteYourNewData) (*wshrpc.YourNewResponse, error) {\n    // Remote filesystem or process operations\n    result, err := performRemoteOperation(data)\n    if err != nil {\n        return nil, fmt.Errorf(\"remote operation failed: %w\", err)\n    }\n    \n    return &wshrpc.YourNewResponse{\n        ResultField: result,\n        Success:     true,\n    }, nil\n}\n```\n\n**Use remote server when:**\n- Operating on remote filesystems\n- Executing commands on remote hosts\n- Managing remote processes\n- Convention: prefix command name with `Remote` (e.g., `RemoteGetInfoCommand`)\n\n#### E. Terminal Block Implementation\n\nImplement in `frontend/app/view/term/term-wsh.tsx`:\n\n```typescript\nasync handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise<YourNewResponse> {\n    // Access terminal-specific data\n    const termWrap = this.model.termRef.current;\n    \n    // Implement terminal logic\n    const result = termWrap.doSomething(data);\n    \n    return {\n        resultfield: result,\n        success: true,\n    };\n}\n```\n\n**Use terminal client when:**\n- Accessing terminal buffer/scrollback\n- Managing VDOM contexts\n- Reading terminal-specific state\n- Interacting with xterm.js\n\n## Complete Example: Adding GetWaveInfo Command\n\n### 1. Define Interface\n\nIn `pkg/wshrpc/wshrpctypes.go`:\n\n```go\ntype WshRpcInterface interface {\n    // ... other commands ...\n    WaveInfoCommand(ctx context.Context) (*WaveInfoData, error)\n}\n\ntype WaveInfoData struct {\n    Version      string            `json:\"version\"`\n    BuildTime    string            `json:\"buildtime\"`\n    ConfigPath   string            `json:\"configpath\"`\n    DataPath     string            `json:\"datapath\"`\n}\n```\n\n### 2. Generate Bindings\n\n```bash\ntask generate\n```\n\n### 3. Implement in Main Server\n\nIn `pkg/wshrpc/wshserver/wshserver.go`:\n\n```go\nfunc (ws *WshServer) WaveInfoCommand(ctx context.Context) (*wshrpc.WaveInfoData, error) {\n    return &wshrpc.WaveInfoData{\n        Version:    wavebase.WaveVersion,\n        BuildTime:  wavebase.BuildTime,\n        ConfigPath: wavebase.GetConfigDir(),\n        DataPath:   wavebase.GetWaveDataDir(),\n    }, nil\n}\n```\n\n### 4. Call from Frontend\n\n```typescript\nimport { RpcApi } from \"@/app/store/wshclientapi\";\n\n// Call the RPC\nconst info = await RpcApi.WaveInfoCommand(TabRpcClient);\nconsole.log(\"Wave Version:\", info.version);\n```\n\n## Streaming Commands\n\nFor commands that return data progressively, use channels:\n\n### Define Streaming Interface\n\n```go\ntype WshRpcInterface interface {\n    StreamYourDataCommand(ctx context.Context, request YourDataRequest) chan RespOrErrorUnion[YourDataType]\n}\n```\n\n### Implement Streaming Command\n\n```go\nfunc (ws *WshServer) StreamYourDataCommand(ctx context.Context, request wshrpc.YourDataRequest) chan wshrpc.RespOrErrorUnion[wshrpc.YourDataType] {\n    rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.YourDataType])\n    \n    go func() {\n        defer close(rtn)\n        defer func() {\n            panichandler.PanicHandler(\"StreamYourDataCommand\", recover())\n        }()\n        \n        // Stream data\n        for i := 0; i < 10; i++ {\n            select {\n            case <-ctx.Done():\n                return\n            default:\n                rtn <- wshrpc.RespOrErrorUnion[wshrpc.YourDataType]{\n                    Response: wshrpc.YourDataType{\n                        Value: i,\n                    },\n                }\n                time.Sleep(100 * time.Millisecond)\n            }\n        }\n    }()\n    \n    return rtn\n}\n```\n\n## Best Practices\n\n1. **Validation First**: Always validate input parameters at the start of your implementation\n\n2. **Descriptive Names**: Use clear, action-oriented command names (e.g., `GetFullConfigCommand`, not `ConfigCommand`)\n\n3. **Error Handling**: Return descriptive errors with context:\n   ```go\n   return nil, fmt.Errorf(\"error creating block: %w\", err)\n   ```\n\n4. **Context Awareness**: Respect context cancellation for long-running operations:\n   ```go\n   select {\n   case <-ctx.Done():\n       return ctx.Err()\n   default:\n       // continue\n   }\n   ```\n\n5. **Consistent Types**: Follow existing naming patterns for request/response types\n\n6. **JSON Tags**: Always use lowercase JSON tags matching frontend conventions\n\n7. **Documentation**: Add comments explaining complex commands or special behaviors\n\n8. **Type Safety**: Leverage TypeScript generation - your types will be checked on both ends\n\n9. **Panic Recovery**: Use `panichandler.PanicHandler` in goroutines to prevent crashes\n\n10. **Route Awareness**: For multi-route scenarios, use `wshutil.GetRpcSourceFromContext(ctx)` to identify callers\n\n## Common Command Patterns\n\n### Simple Query\n\n```go\nfunc (ws *WshServer) GetSomethingCommand(ctx context.Context, id string) (*Something, error) {\n    obj, err := wstore.DBGet[*Something](ctx, id)\n    if err != nil {\n        return nil, fmt.Errorf(\"error getting something: %w\", err)\n    }\n    return obj, nil\n}\n```\n\n### Mutation with Updates\n\n```go\nfunc (ws *WshServer) UpdateSomethingCommand(ctx context.Context, data wshrpc.CommandUpdateData) error {\n    ctx = waveobj.ContextWithUpdates(ctx)\n    \n    // Make changes\n    err := wstore.UpdateObject(ctx, data.ORef, data.Updates)\n    if err != nil {\n        return fmt.Errorf(\"error updating: %w\", err)\n    }\n    \n    // Broadcast updates\n    updates := waveobj.ContextGetUpdatesRtn(ctx)\n    wps.Broker.SendUpdateEvents(updates)\n    \n    return nil\n}\n```\n\n### Command with Side Effects\n\n```go\nfunc (ws *WshServer) DoActionCommand(ctx context.Context, data wshrpc.CommandActionData) error {\n    // Perform action\n    result, err := performAction(data)\n    if err != nil {\n        return err\n    }\n    \n    // Publish event about the action\n    go func() {\n        wps.Broker.Publish(wps.WaveEvent{\n            Event: wps.Event_ActionComplete,\n            Data:  result,\n        })\n    }()\n    \n    return nil\n}\n```\n\n## Troubleshooting\n\n### Command Not Found\n\n- Ensure method name ends with `Command`\n- Verify you ran `task generate`\n- Check that the interface is in `WshRpcInterface`\n\n### Type Mismatch Errors\n\n- Run `task generate` after changing types\n- Ensure JSON tags are lowercase\n- Verify TypeScript code is using generated types\n\n### Command Times Out\n\n- Check for blocking operations\n- Ensure context is passed through\n- Consider using a streaming command for long operations\n\n### Routing Issues\n\n- For remote commands, ensure they're implemented in correct location\n- Check route configuration in RpcContext\n- Verify authentication for secured routes\n\n## Quick Reference\n\nWhen adding a new RPC command:\n\n- [ ] Add method to `WshRpcInterface` in `pkg/wshrpc/wshrpctypes.go` (must end with `Command`)\n- [ ] Define request/response types with JSON tags (if needed)\n- [ ] Run `task generate` to create bindings\n- [ ] Implement in appropriate location:\n  - [ ] `wshserver.go` for main server (most common)\n  - [ ] `emain-wsh.ts` for Electron\n  - [ ] `tabrpcclient.ts` for frontend\n  - [ ] `wshremote.go` for remote (prefix with `Remote`)\n  - [ ] `term-wsh.tsx` for terminal\n- [ ] Add input validation\n- [ ] Handle errors with context\n- [ ] Test the command end-to-end\n\n## Related Documentation\n\n- **WPS Events**: See the `wps-events` skill - Publishing events from RPC commands\n"
  },
  {
    "path": ".kilocode/skills/add-wshcmd/SKILL.md",
    "content": "---\nname: add-wshcmd\ndescription: 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.\n---\n\n# Adding a New wsh Command to Wave Terminal\n\nThis guide explains how to add a new command to the `wsh` CLI tool.\n\n## wsh Command System Overview\n\nWave Terminal's `wsh` command provides CLI access to Wave Terminal features. The system uses:\n\n1. **Cobra Framework** - CLI command structure and parsing\n2. **Command Files** - Individual command implementations in `cmd/wsh/cmd/wshcmd-*.go`\n3. **RPC Client** - Communication with Wave Terminal backend via `RpcClient`\n4. **Activity Tracking** - Telemetry for command usage analytics\n5. **Documentation** - User-facing docs in `docs/docs/wsh-reference.mdx`\n\nCommands are registered in their `init()` functions and execute through the Cobra framework.\n\n## Step-by-Step Guide\n\n### Step 1: Create Command File\n\nCreate a new file in `cmd/wsh/cmd/` named `wshcmd-[commandname].go`:\n\n```go\n// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n    \"fmt\"\n\n    \"github.com/spf13/cobra\"\n    \"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n    \"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar myCommandCmd = &cobra.Command{\n    Use:   \"mycommand [args]\",\n    Short: \"Brief description of what this command does\",\n    Long: `Detailed description of the command.\nCan include multiple lines and examples of usage.`,\n    RunE:                  myCommandRun,\n    PreRunE:               preRunSetupRpcClient,  // Include if command needs RPC\n    DisableFlagsInUseLine: true,\n}\n\n// Flag variables\nvar (\n    myCommandFlagExample string\n    myCommandFlagVerbose bool\n)\n\nfunc init() {\n    // Add command to root\n    rootCmd.AddCommand(myCommandCmd)\n    \n    // Define flags\n    myCommandCmd.Flags().StringVarP(&myCommandFlagExample, \"example\", \"e\", \"\", \"example flag description\")\n    myCommandCmd.Flags().BoolVarP(&myCommandFlagVerbose, \"verbose\", \"v\", false, \"enable verbose output\")\n}\n\nfunc myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) {\n    // Always track activity for telemetry\n    defer func() {\n        sendActivity(\"mycommand\", rtnErr == nil)\n    }()\n    \n    // Validate arguments\n    if len(args) == 0 {\n        OutputHelpMessage(cmd)\n        return fmt.Errorf(\"requires at least one argument\")\n    }\n    \n    // Command implementation\n    fmt.Printf(\"Command executed successfully\\n\")\n    return nil\n}\n```\n\n**File Naming Convention:**\n- Use `wshcmd-[commandname].go` format\n- Use lowercase, hyphenated names for multi-word commands\n- Examples: `wshcmd-getvar.go`, `wshcmd-setmeta.go`, `wshcmd-ai.go`\n\n### Step 2: Command Structure\n\n#### Basic Command Structure\n\n```go\nvar myCommandCmd = &cobra.Command{\n    Use:   \"mycommand [required] [optional...]\",\n    Short: \"One-line description (shown in help)\",\n    Long:  `Detailed multi-line description`,\n    \n    // Argument validation\n    Args:    cobra.MinimumNArgs(1),  // Or cobra.ExactArgs(1), cobra.NoArgs, etc.\n    \n    // Execution function\n    RunE:    myCommandRun,\n    \n    // Pre-execution setup (if needed)\n    PreRunE: preRunSetupRpcClient,  // Sets up RPC client for backend communication\n    \n    // Example usage (optional)\n    Example: \"  wsh mycommand foo\\n  wsh mycommand --flag bar\",\n    \n    // Disable flag notation in usage line\n    DisableFlagsInUseLine: true,\n}\n```\n\n**Key Fields:**\n- `Use`: Command name and argument pattern\n- `Short`: Brief description for command list\n- `Long`: Detailed description shown in help\n- `Args`: Argument validator (optional)\n- `RunE`: Main execution function (returns error)\n- `PreRunE`: Setup function that runs before `RunE`\n- `Example`: Usage examples (optional)\n- `DisableFlagsInUseLine`: Clean up help display\n\n#### When to Use PreRunE\n\nInclude `PreRunE: preRunSetupRpcClient` if your command:\n- Communicates with the Wave Terminal backend\n- Needs access to `RpcClient` \n- Requires JWT authentication (WAVETERM_JWT env var)\n- Makes RPC calls via `wshclient.*Command()` functions\n\n**Don't include PreRunE** for commands that:\n- Only manipulate local state\n- Don't need backend communication\n- Are purely informational/local operations\n\n### Step 3: Implement Command Logic\n\n#### Command Function Pattern\n\n```go\nfunc myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) {\n    // Step 1: Always track activity (for telemetry)\n    defer func() {\n        sendActivity(\"mycommand\", rtnErr == nil)\n    }()\n    \n    // Step 2: Validate arguments and flags\n    if len(args) != 1 {\n        OutputHelpMessage(cmd)\n        return fmt.Errorf(\"requires exactly one argument\")\n    }\n    \n    // Step 3: Parse/prepare data\n    targetArg := args[0]\n    \n    // Step 4: Make RPC call if needed\n    result, err := wshclient.SomeCommand(RpcClient, wshrpc.CommandSomeData{\n        Field: targetArg,\n    }, &wshrpc.RpcOpts{Timeout: 2000})\n    if err != nil {\n        return fmt.Errorf(\"executing command: %w\", err)\n    }\n    \n    // Step 5: Output results\n    fmt.Printf(\"Result: %s\\n\", result)\n    return nil\n}\n```\n\n**Important Patterns:**\n\n1. **Activity Tracking**: Always include deferred `sendActivity()` call\n   ```go\n   defer func() {\n       sendActivity(\"commandname\", rtnErr == nil)\n   }()\n   ```\n\n2. **Error Handling**: Return errors, don't call `os.Exit()`\n   ```go\n   if err != nil {\n       return fmt.Errorf(\"context: %w\", err)\n   }\n   ```\n\n3. **Output**: Use standard `fmt` package for output\n   ```go\n   fmt.Printf(\"Success message\\n\")\n   fmt.Fprintf(os.Stderr, \"Error message\\n\")\n   ```\n\n4. **Help Messages**: Show help when arguments are invalid\n   ```go\n   if len(args) == 0 {\n       OutputHelpMessage(cmd)\n       return fmt.Errorf(\"requires arguments\")\n   }\n   ```\n\n5. **Exit Codes**: Set custom exit code via `WshExitCode`\n   ```go\n   if notFound {\n       WshExitCode = 1\n       return nil  // Don't return error, just set exit code\n   }\n   ```\n\n### Step 4: Define Flags\n\nAdd flags in the `init()` function:\n\n```go\nvar (\n    // Declare flag variables at package level\n    myCommandFlagString string\n    myCommandFlagBool   bool\n    myCommandFlagInt    int\n)\n\nfunc init() {\n    rootCmd.AddCommand(myCommandCmd)\n    \n    // String flag with short version\n    myCommandCmd.Flags().StringVarP(&myCommandFlagString, \"name\", \"n\", \"default\", \"description\")\n    \n    // Boolean flag\n    myCommandCmd.Flags().BoolVarP(&myCommandFlagBool, \"verbose\", \"v\", false, \"enable verbose\")\n    \n    // Integer flag\n    myCommandCmd.Flags().IntVar(&myCommandFlagInt, \"count\", 10, \"set count\")\n    \n    // Flag without short version\n    myCommandCmd.Flags().StringVar(&myCommandFlagString, \"longname\", \"\", \"description\")\n}\n```\n\n**Flag Types:**\n- `StringVar/StringVarP` - String values\n- `BoolVar/BoolVarP` - Boolean flags\n- `IntVar/IntVarP` - Integer values\n- The `P` suffix versions include a short flag name\n\n**Flag Naming:**\n- Use camelCase for variable names: `myCommandFlagName`\n- Use kebab-case for flag names: `--flag-name`\n- Prefix variable names with command name for clarity\n\n### Step 5: Working with Block Arguments\n\nMany commands operate on blocks. Use the standard block resolution pattern:\n\n```go\nfunc myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) {\n    defer func() {\n        sendActivity(\"mycommand\", rtnErr == nil)\n    }()\n    \n    // Resolve block using the -b/--block flag\n    fullORef, err := resolveBlockArg()\n    if err != nil {\n        return err\n    }\n    \n    // Use the blockid in RPC call\n    err = wshclient.SomeCommand(RpcClient, wshrpc.CommandSomeData{\n        BlockId: fullORef.OID,\n    }, &wshrpc.RpcOpts{Timeout: 2000})\n    if err != nil {\n        return fmt.Errorf(\"command failed: %w\", err)\n    }\n    \n    return nil\n}\n```\n\n**Block Resolution:**\n- The `-b/--block` flag is defined globally in `wshcmd-root.go`\n- `resolveBlockArg()` resolves the block argument to a full ORef\n- Supports: `this`, `tab`, full UUIDs, 8-char prefixes, block numbers\n- Default is `\"this\"` (current block)\n\n**Alternative: Manual Block Resolution**\n\n```go\n// Get tab ID from environment\ntabId := os.Getenv(\"WAVETERM_TABID\")\nif tabId == \"\" {\n    return fmt.Errorf(\"WAVETERM_TABID not set\")\n}\n\n// Create route for tab-level operations\nroute := wshutil.MakeTabRouteId(tabId)\n\n// Use route in RPC call\nerr := wshclient.SomeCommand(RpcClient, commandData, &wshrpc.RpcOpts{\n    Route:   route,\n    Timeout: 2000,\n})\n```\n\n### Step 6: Making RPC Calls\n\nUse the `wshclient` package to make RPC calls:\n\n```go\nimport (\n    \"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n    \"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\n// Simple RPC call\nresult, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{\n    ORef: *fullORef,\n}, &wshrpc.RpcOpts{Timeout: 2000})\nif err != nil {\n    return fmt.Errorf(\"getting metadata: %w\", err)\n}\n\n// RPC call with routing\nerr := wshclient.SetMetaCommand(RpcClient, wshrpc.CommandSetMetaData{\n    ORef: *fullORef,\n    Meta: metaMap,\n}, &wshrpc.RpcOpts{\n    Route:   route,\n    Timeout: 5000,\n})\nif err != nil {\n    return fmt.Errorf(\"setting metadata: %w\", err)\n}\n```\n\n**RPC Options:**\n- `Timeout`: Request timeout in milliseconds (typically 2000-5000)\n- `Route`: Route ID for targeting specific components\n- Available routes: `wshutil.ControlRoute`, `wshutil.MakeTabRouteId(tabId)`\n\n### Step 7: Add Documentation\n\nAdd your command to `docs/docs/wsh-reference.mdx`:\n\n````markdown\n## mycommand\n\nBrief description of what the command does.\n\n```sh\nwsh mycommand [args] [flags]\n```\n\nDetailed explanation of the command's purpose and behavior.\n\nFlags:\n- `-n, --name <value>` - description of this flag\n- `-v, --verbose` - enable verbose output\n- `-b, --block <blockid>` - specify target block (default: current block)\n\nExamples:\n\n```sh\n# Basic usage\nwsh mycommand arg1\n\n# With flags\nwsh mycommand --name value arg1\n\n# With block targeting\nwsh mycommand -b 2 arg1\n\n# Complex example\nwsh mycommand -v --name \"example\" arg1 arg2\n```\n\nAdditional notes, tips, or warnings about the command.\n\n---\n````\n\n**Documentation Guidelines:**\n- Place in alphabetical order with other commands\n- Include command signature with argument pattern\n- List all flags with short and long versions\n- Provide practical examples (at least 3-5)\n- Explain common use cases and patterns\n- Add tips or warnings if relevant\n- Use `---` separator between commands\n\n### Step 8: Test Your Command\n\nBuild and test the command:\n\n```bash\n# Build wsh\ntask build:wsh\n\n# Or build everything\ntask build\n\n# Test the command\n./bin/wsh/wsh mycommand --help\n./bin/wsh/wsh mycommand arg1 arg2\n```\n\n**Testing Checklist:**\n- [ ] Help message displays correctly\n- [ ] Required arguments validated\n- [ ] Flags work as expected\n- [ ] Error messages are clear\n- [ ] Success cases work correctly\n- [ ] RPC calls complete successfully\n- [ ] Output is formatted correctly\n\n## Complete Examples\n\n### Example 1: Simple Command with No RPC\n\n**Use case:** A command that prints Wave Terminal version info\n\n#### Command File (`cmd/wsh/cmd/wshcmd-version.go`)\n\n```go\n// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n    \"github.com/spf13/cobra\"\n    \"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\nvar versionCmd = &cobra.Command{\n    Use:   \"version\",\n    Short: \"Print Wave Terminal version\",\n    RunE:  versionRun,\n}\n\nfunc init() {\n    rootCmd.AddCommand(versionCmd)\n}\n\nfunc versionRun(cmd *cobra.Command, args []string) (rtnErr error) {\n    defer func() {\n        sendActivity(\"version\", rtnErr == nil)\n    }()\n    \n    fmt.Printf(\"Wave Terminal %s\\n\", wavebase.WaveVersion)\n    return nil\n}\n```\n\n#### Documentation\n\n````markdown\n## version\n\nPrint the current Wave Terminal version.\n\n```sh\nwsh version\n```\n\nExamples:\n\n```sh\n# Print version\nwsh version\n```\n````\n\n### Example 2: Command with Flags and RPC\n\n**Use case:** A command to update block title\n\n#### Command File (`cmd/wsh/cmd/wshcmd-settitle.go`)\n\n```go\n// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n    \"fmt\"\n\n    \"github.com/spf13/cobra\"\n    \"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n    \"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar setTitleCmd = &cobra.Command{\n    Use:   \"settitle [title]\",\n    Short: \"Set block title\",\n    Long:  `Set the title for the current or specified block.`,\n    Args:  cobra.ExactArgs(1),\n    RunE:  setTitleRun,\n    PreRunE: preRunSetupRpcClient,\n    DisableFlagsInUseLine: true,\n}\n\nvar setTitleIcon string\n\nfunc init() {\n    rootCmd.AddCommand(setTitleCmd)\n    setTitleCmd.Flags().StringVarP(&setTitleIcon, \"icon\", \"i\", \"\", \"set block icon\")\n}\n\nfunc setTitleRun(cmd *cobra.Command, args []string) (rtnErr error) {\n    defer func() {\n        sendActivity(\"settitle\", rtnErr == nil)\n    }()\n    \n    title := args[0]\n    \n    // Resolve block\n    fullORef, err := resolveBlockArg()\n    if err != nil {\n        return err\n    }\n    \n    // Build metadata map\n    meta := make(map[string]interface{})\n    meta[\"title\"] = title\n    if setTitleIcon != \"\" {\n        meta[\"icon\"] = setTitleIcon\n    }\n    \n    // Make RPC call\n    err = wshclient.SetMetaCommand(RpcClient, wshrpc.CommandSetMetaData{\n        ORef: *fullORef,\n        Meta: meta,\n    }, &wshrpc.RpcOpts{Timeout: 2000})\n    if err != nil {\n        return fmt.Errorf(\"setting title: %w\", err)\n    }\n    \n    fmt.Printf(\"title updated\\n\")\n    return nil\n}\n```\n\n#### Documentation\n\n````markdown\n## settitle\n\nSet the title for a block.\n\n```sh\nwsh settitle [title]\n```\n\nUpdate the display title for the current or specified block. Optionally set an icon as well.\n\nFlags:\n- `-i, --icon <icon>` - set block icon along with title\n- `-b, --block <blockid>` - specify target block (default: current block)\n\nExamples:\n\n```sh\n# Set title for current block\nwsh settitle \"My Terminal\"\n\n# Set title and icon\nwsh settitle --icon \"terminal\" \"Development Shell\"\n\n# Set title for specific block\nwsh settitle -b 2 \"Build Output\"\n```\n````\n\n### Example 3: Subcommands\n\n**Use case:** Command with multiple subcommands (like `wsh conn`)\n\n#### Command File (`cmd/wsh/cmd/wshcmd-mygroup.go`)\n\n```go\n// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n    \"fmt\"\n\n    \"github.com/spf13/cobra\"\n    \"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n    \"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar myGroupCmd = &cobra.Command{\n    Use:   \"mygroup\",\n    Short: \"Manage something\",\n}\n\nvar myGroupListCmd = &cobra.Command{\n    Use:   \"list\",\n    Short: \"List items\",\n    RunE:  myGroupListRun,\n    PreRunE: preRunSetupRpcClient,\n}\n\nvar myGroupAddCmd = &cobra.Command{\n    Use:   \"add [name]\",\n    Short: \"Add an item\",\n    Args:  cobra.ExactArgs(1),\n    RunE:  myGroupAddRun,\n    PreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n    // Add parent command\n    rootCmd.AddCommand(myGroupCmd)\n    \n    // Add subcommands\n    myGroupCmd.AddCommand(myGroupListCmd)\n    myGroupCmd.AddCommand(myGroupAddCmd)\n}\n\nfunc myGroupListRun(cmd *cobra.Command, args []string) (rtnErr error) {\n    defer func() {\n        sendActivity(\"mygroup:list\", rtnErr == nil)\n    }()\n    \n    // Implementation\n    fmt.Printf(\"Listing items...\\n\")\n    return nil\n}\n\nfunc myGroupAddRun(cmd *cobra.Command, args []string) (rtnErr error) {\n    defer func() {\n        sendActivity(\"mygroup:add\", rtnErr == nil)\n    }()\n    \n    name := args[0]\n    fmt.Printf(\"Adding item: %s\\n\", name)\n    return nil\n}\n```\n\n#### Documentation\n\n````markdown\n## mygroup\n\nManage something with subcommands.\n\n### list\n\nList all items.\n\n```sh\nwsh mygroup list\n```\n\n### add\n\nAdd a new item.\n\n```sh\nwsh mygroup add [name]\n```\n\nExamples:\n\n```sh\n# List items\nwsh mygroup list\n\n# Add an item\nwsh mygroup add \"new-item\"\n```\n````\n\n## Common Patterns\n\n### Reading from Stdin\n\n```go\nimport \"io\"\n\nfunc myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) {\n    defer func() {\n        sendActivity(\"mycommand\", rtnErr == nil)\n    }()\n    \n    // Check if reading from stdin (using \"-\" convention)\n    var data []byte\n    var err error\n    \n    if len(args) > 0 && args[0] == \"-\" {\n        data, err = io.ReadAll(os.Stdin)\n        if err != nil {\n            return fmt.Errorf(\"reading stdin: %w\", err)\n        }\n    } else {\n        // Read from file or other source\n        data, err = os.ReadFile(args[0])\n        if err != nil {\n            return fmt.Errorf(\"reading file: %w\", err)\n        }\n    }\n    \n    // Process data\n    fmt.Printf(\"Read %d bytes\\n\", len(data))\n    return nil\n}\n```\n\n### JSON File Input\n\n```go\nimport (\n    \"encoding/json\"\n    \"io\"\n)\n\nfunc loadJSONFile(filepath string) (map[string]interface{}, error) {\n    var data []byte\n    var err error\n    \n    if filepath == \"-\" {\n        data, err = io.ReadAll(os.Stdin)\n        if err != nil {\n            return nil, fmt.Errorf(\"reading stdin: %w\", err)\n        }\n    } else {\n        data, err = os.ReadFile(filepath)\n        if err != nil {\n            return nil, fmt.Errorf(\"reading file: %w\", err)\n        }\n    }\n    \n    var result map[string]interface{}\n    if err := json.Unmarshal(data, &result); err != nil {\n        return nil, fmt.Errorf(\"parsing JSON: %w\", err)\n    }\n    \n    return result, nil\n}\n```\n\n### Conditional Output (TTY Detection)\n\n```go\nfunc myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) {\n    defer func() {\n        sendActivity(\"mycommand\", rtnErr == nil)\n    }()\n    \n    isTty := getIsTty()\n    \n    // Output value\n    fmt.Printf(\"%s\", value)\n    \n    // Add newline only if TTY (for better piping experience)\n    if isTty {\n        fmt.Printf(\"\\n\")\n    }\n    \n    return nil\n}\n```\n\n### Environment Variable Access\n\n```go\nfunc myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) {\n    defer func() {\n        sendActivity(\"mycommand\", rtnErr == nil)\n    }()\n    \n    // Get block ID from environment\n    blockId := os.Getenv(\"WAVETERM_BLOCKID\")\n    if blockId == \"\" {\n        return fmt.Errorf(\"WAVETERM_BLOCKID not set\")\n    }\n    \n    // Get tab ID from environment\n    tabId := os.Getenv(\"WAVETERM_TABID\")\n    if tabId == \"\" {\n        return fmt.Errorf(\"WAVETERM_TABID not set\")\n    }\n    \n    fmt.Printf(\"Block: %s, Tab: %s\\n\", blockId, tabId)\n    return nil\n}\n```\n\n## Best Practices\n\n### Command Design\n\n1. **Single Responsibility**: Each command should do one thing well\n2. **Composable**: Design commands to work with pipes and other commands\n3. **Consistent**: Follow existing wsh command patterns and conventions\n4. **Documented**: Provide clear help text and examples\n\n### Error Handling\n\n1. **Context**: Wrap errors with context using `fmt.Errorf(\"context: %w\", err)`\n2. **User-Friendly**: Make error messages clear and actionable\n3. **No Panics**: Return errors instead of calling `os.Exit()` or `panic()`\n4. **Exit Codes**: Use `WshExitCode` for custom exit codes\n\n### Output\n\n1. **Structured**: Use consistent formatting for output\n2. **Quiet by Default**: Only output what's necessary\n3. **Verbose Flag**: Optionally provide `-v` for detailed output\n4. **Stderr for Errors**: Use `fmt.Fprintf(os.Stderr, ...)` for error messages\n\n### Flags\n\n1. **Short Versions**: Provide `-x` short versions for common flags\n2. **Sensible Defaults**: Choose defaults that work for most users\n3. **Boolean Flags**: Use for on/off options\n4. **String Flags**: Use for values that need user input\n\n### RPC Calls\n\n1. **Timeouts**: Always specify reasonable timeouts\n2. **Error Context**: Wrap RPC errors with operation context\n3. **Retries**: Don't retry automatically; let user retry command\n4. **Routes**: Use appropriate routes for different operations\n\n## Common Pitfalls\n\n### 1. Forgetting Activity Tracking\n\n**Problem**: Command usage not tracked in telemetry\n\n**Solution**: Always include deferred `sendActivity()` call:\n```go\ndefer func() {\n    sendActivity(\"commandname\", rtnErr == nil)\n}()\n```\n\n### 2. Using os.Exit() Instead of Returning Error\n\n**Problem**: Breaks defer statements and cleanup\n\n**Solution**: Return errors from RunE function:\n```go\n// Bad\nif err != nil {\n    fmt.Fprintf(os.Stderr, \"error: %v\\n\", err)\n    os.Exit(1)\n}\n\n// Good\nif err != nil {\n    return fmt.Errorf(\"operation failed: %w\", err)\n}\n```\n\n### 3. Not Validating Arguments\n\n**Problem**: Command crashes with nil pointer or index out of range\n\n**Solution**: Validate arguments early and show help:\n```go\nif len(args) == 0 {\n    OutputHelpMessage(cmd)\n    return fmt.Errorf(\"requires at least one argument\")\n}\n```\n\n### 4. Forgetting to Add to init()\n\n**Problem**: Command not available when running wsh\n\n**Solution**: Always add command in `init()` function:\n```go\nfunc init() {\n    rootCmd.AddCommand(myCommandCmd)\n}\n```\n\n### 5. Inconsistent Output\n\n**Problem**: Inconsistent use of output methods\n\n**Solution**: Use standard `fmt` package functions:\n```go\n// For stdout\nfmt.Printf(\"output\\n\")\n\n// For stderr\nfmt.Fprintf(os.Stderr, \"error message\\n\")\n```\n\n## Quick Reference Checklist\n\nWhen adding a new wsh command:\n\n- [ ] Create `cmd/wsh/cmd/wshcmd-[commandname].go`\n- [ ] Define command struct with Use, Short, Long descriptions\n- [ ] Add `PreRunE: preRunSetupRpcClient` if using RPC\n- [ ] Implement command function with activity tracking\n- [ ] Add command to `rootCmd` in `init()` function\n- [ ] Define flags in `init()` function if needed\n- [ ] Add documentation to `docs/docs/wsh-reference.mdx`\n- [ ] Build and test: `task build:wsh`\n- [ ] Test help: `wsh [commandname] --help`\n- [ ] Test all flag combinations\n- [ ] Test error cases\n\n## Related Files\n\n- **Root Command**: `cmd/wsh/cmd/wshcmd-root.go` - Main command setup and utilities\n- **RPC Client**: `pkg/wshrpc/wshclient/` - Client functions for RPC calls\n- **RPC Types**: `pkg/wshrpc/wshrpctypes.go` - RPC request/response data structures\n- **Documentation**: `docs/docs/wsh-reference.mdx` - User-facing command reference\n- **Examples**: `cmd/wsh/cmd/wshcmd-*.go` - Existing command implementations\n"
  },
  {
    "path": ".kilocode/skills/context-menu/SKILL.md",
    "content": "---\nname: context-menu\ndescription: 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.\n---\n\n# Context Menu Quick Reference\n\nThis guide provides a quick overview of how to create and display a context menu using our system.\n\n---\n\n## ContextMenuItem Type\n\nDefine each menu item using the `ContextMenuItem` type:\n\n```ts\ntype ContextMenuItem = {\n  label?: string;\n  type?: \"separator\" | \"normal\" | \"submenu\" | \"checkbox\" | \"radio\";\n  role?: string; // Electron role (optional)\n  click?: () => void; // Callback for item selection (not needed if role is set)\n  submenu?: ContextMenuItem[]; // For nested menus\n  checked?: boolean; // For checkbox or radio items\n  visible?: boolean;\n  enabled?: boolean;\n  sublabel?: string;\n};\n```\n\n---\n\n## Import and Show the Menu\n\nImport the context menu module:\n\n```ts\nimport { ContextMenuModel } from \"@/app/store/contextmenu\";\n```\n\nTo display the context menu, call:\n\n```ts\nContextMenuModel.getInstance().showContextMenu(menu, event);\n```\n\n- **menu**: An array of `ContextMenuItem`.\n- **event**: The mouse event that triggered the context menu (typically from an onContextMenu handler).\n\n---\n\n## Basic Example\n\nA simple context menu with a separator:\n\n```ts\nconst menu: ContextMenuItem[] = [\n  {\n    label: \"New File\",\n    click: () => {\n      /* create a new file */\n    },\n  },\n  {\n    label: \"New Folder\",\n    click: () => {\n      /* create a new folder */\n    },\n  },\n  { type: \"separator\" },\n  {\n    label: \"Rename\",\n    click: () => {\n      /* rename item */\n    },\n  },\n];\n\nContextMenuModel.getInstance().showContextMenu(menu, e);\n```\n\n---\n\n## Example with Submenu and Checkboxes\n\nToggle settings using a submenu with checkbox items:\n\n```ts\nconst isClearOnStart = true; // Example setting\n\nconst menu: ContextMenuItem[] = [\n  {\n    label: \"Clear Output On Restart\",\n    submenu: [\n      {\n        label: \"On\",\n        type: \"checkbox\",\n        checked: isClearOnStart,\n        click: () => {\n          // Set the config to enable clear on restart\n        },\n      },\n      {\n        label: \"Off\",\n        type: \"checkbox\",\n        checked: !isClearOnStart,\n        click: () => {\n          // Set the config to disable clear on restart\n        },\n      },\n    ],\n  },\n];\n\nContextMenuModel.getInstance().showContextMenu(menu, e);\n```\n\n---\n\n## Editing a Config File Example\n\nOpen a configuration file (e.g., `widgets.json`) in preview mode:\n\n```ts\n{\n    label: \"Edit widgets.json\",\n    click: () => {\n        fireAndForget(async () => {\n            const path = `${getApi().getConfigDir()}/widgets.json`;\n            const blockDef: BlockDef = {\n                meta: { view: \"preview\", file: path },\n            };\n            await createBlock(blockDef, false, true);\n        });\n    },\n}\n```\n\n---\n\n## Summary\n\n- **Menu Definition**: Use the `ContextMenuItem` type.\n- **Actions**: Use `click` for actions; use `submenu` for nested options.\n- **Separators**: Use `type: \"separator\"` to group items.\n- **Toggles**: Use `type: \"checkbox\"` or `\"radio\"` with the `checked` property.\n- **Displaying**: Use `ContextMenuModel.getInstance().showContextMenu(menu, event)` to render the menu.\n\n## Common Use Cases\n\n### File/Folder Operations\nContext menus are commonly used for file operations like creating, renaming, and deleting files or folders.\n\n### Settings Toggles\nUse checkbox menu items to toggle settings on and off, with the `checked` property reflecting the current state.\n\n### Nested Options\nUse `submenu` to organize related options hierarchically, keeping the top-level menu clean and organized.\n\n### Conditional Items\nUse the `visible` and `enabled` properties to dynamically show or disable menu items based on the current state.\n"
  },
  {
    "path": ".kilocode/skills/create-view/SKILL.md",
    "content": "---\nname: create-view\ndescription: 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.\n---\n\n# Creating a New View in Wave Terminal\n\nThis 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.\n\n## Architecture Overview\n\nWave Terminal uses a **Model-View architecture** where:\n\n- **ViewModel** - Contains all state, logic, and UI configuration as Jotai atoms\n- **ViewComponent** - Pure React component that renders the UI using the model\n- **BlockFrame** - Wraps views with a header, connection management, and standard controls\n\nThe separation between model and component ensures:\n\n- Models can update state without React hooks\n- Components remain pure and testable\n- State is centralized in Jotai atoms for easy access\n\n## ViewModel Interface\n\nEvery view must implement the `ViewModel` interface defined in `frontend/types/custom.d.ts`:\n\n```typescript\ninterface ViewModel {\n  // Required: The type identifier for this view (e.g., \"term\", \"web\", \"preview\")\n  viewType: string;\n\n  // Required: The React component that renders this view\n  viewComponent: ViewComponent<ViewModel>;\n\n  // Optional: Icon shown in block header (FontAwesome icon name or IconButtonDecl)\n  viewIcon?: jotai.Atom<string | IconButtonDecl>;\n\n  // Optional: Display name shown in block header (e.g., \"Terminal\", \"Web\", \"Preview\")\n  viewName?: jotai.Atom<string>;\n\n  // Optional: Additional header elements (text, buttons, inputs) shown after the name\n  viewText?: jotai.Atom<string | HeaderElem[]>;\n\n  // Optional: Icon button shown before the view name in header\n  preIconButton?: jotai.Atom<IconButtonDecl>;\n\n  // Optional: Icon buttons shown at the end of the header (before settings/close)\n  endIconButtons?: jotai.Atom<IconButtonDecl[]>;\n\n  // Optional: Custom background styling for the block\n  blockBg?: jotai.Atom<MetaType>;\n\n  // Optional: If true, completely hides the block header\n  noHeader?: jotai.Atom<boolean>;\n\n  // Optional: If true, shows connection picker in header for remote connections\n  manageConnection?: jotai.Atom<boolean>;\n\n  // Optional: If true, filters out 'nowsh' connections from connection picker\n  filterOutNowsh?: jotai.Atom<boolean>;\n\n  // Optional: If true, removes default padding from content area\n  noPadding?: jotai.Atom<boolean>;\n\n  // Optional: Atoms for managing in-block search functionality\n  searchAtoms?: SearchAtoms;\n\n  // Optional: Returns whether this is a basic terminal (for multi-input feature)\n  isBasicTerm?: (getFn: jotai.Getter) => boolean;\n\n  // Optional: Returns context menu items for the settings dropdown\n  getSettingsMenuItems?: () => ContextMenuItem[];\n\n  // Optional: Focuses the view when called, returns true if successful\n  giveFocus?: () => boolean;\n\n  // Optional: Handles keyboard events, returns true if handled\n  keyDownHandler?: (e: WaveKeyboardEvent) => boolean;\n\n  // Optional: Cleanup when block is closed\n  dispose?: () => void;\n}\n```\n\n### Key Concepts\n\n**Atoms**: All UI-related properties must be Jotai atoms. This enables:\n\n- Reactive updates when state changes\n- Access from anywhere via `globalStore.get()`/`globalStore.set()`\n- Derived atoms that compute values from other atoms\n\n**ViewComponent**: The React component receives these props:\n\n```typescript\ntype ViewComponentProps<T extends ViewModel> = {\n  blockId: string; // Unique ID for this block\n  blockRef: React.RefObject<HTMLDivElement>; // Ref to block container\n  contentRef: React.RefObject<HTMLDivElement>; // Ref to content area\n  model: T; // Your ViewModel instance\n};\n```\n\n## Step-by-Step Guide\n\n### 1. Create the View Model Class\n\nCreate a new file for your view model (e.g., `frontend/app/view/myview/myview-model.ts`):\n\n```typescript\nimport { BlockNodeModel } from \"@/app/block/blocktypes\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { WOS, useBlockAtom } from \"@/store/global\";\nimport * as jotai from \"jotai\";\nimport { MyView } from \"./myview\";\n\nexport class MyViewModel implements ViewModel {\n  viewType: string;\n  blockId: string;\n  nodeModel: BlockNodeModel;\n  blockAtom: jotai.Atom<Block>;\n\n  // Define your atoms (simple field initializers)\n  viewIcon = jotai.atom<string>(\"circle\");\n  viewName = jotai.atom<string>(\"My View\");\n  noPadding = jotai.atom<boolean>(true);\n\n  // Derived atom (created in constructor)\n  viewText!: jotai.Atom<HeaderElem[]>;\n\n  constructor(blockId: string, nodeModel: BlockNodeModel) {\n    this.viewType = \"myview\";\n    this.blockId = blockId;\n    this.nodeModel = nodeModel;\n    this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);\n\n    // Create derived atoms that depend on block data or other atoms\n    this.viewText = jotai.atom((get) => {\n      const blockData = get(this.blockAtom);\n      const rtn: HeaderElem[] = [];\n\n      // Add header buttons/text based on state\n      rtn.push({\n        elemtype: \"iconbutton\",\n        icon: \"refresh\",\n        title: \"Refresh\",\n        click: () => this.refresh(),\n      });\n\n      return rtn;\n    });\n  }\n\n  get viewComponent(): ViewComponent {\n    return MyView;\n  }\n\n  refresh() {\n    // Update state using globalStore\n    // Never use React hooks in model methods\n    console.log(\"refreshing...\");\n  }\n\n  giveFocus(): boolean {\n    // Focus your view component\n    return true;\n  }\n\n  dispose() {\n    // Cleanup resources (unsubscribe from events, etc.)\n  }\n}\n```\n\n### 2. Create the View Component\n\nCreate your React component (e.g., `frontend/app/view/myview/myview.tsx`):\n\n```typescript\nimport { ViewComponentProps } from \"@/app/block/blocktypes\";\nimport { MyViewModel } from \"./myview-model\";\nimport { useAtomValue } from \"jotai\";\nimport \"./myview.scss\";\n\nexport const MyView: React.FC<ViewComponentProps<MyViewModel>> = ({\n    blockId,\n    model,\n    contentRef\n}) => {\n    // Use atoms from the model (these are React hooks - call at top level!)\n    const blockData = useAtomValue(model.blockAtom);\n\n    return (\n        <div className=\"myview-container\" ref={contentRef}>\n            <div>Block ID: {blockId}</div>\n            <div>View: {model.viewType}</div>\n            {/* Your view content here */}\n        </div>\n    );\n};\n```\n\n### 3. Register the View\n\nAdd your view to the `BlockRegistry` in `frontend/app/block/block.tsx`:\n\n```typescript\nconst BlockRegistry: Map<string, ViewModelClass> = new Map();\nBlockRegistry.set(\"term\", TermViewModel);\nBlockRegistry.set(\"preview\", PreviewModel);\nBlockRegistry.set(\"web\", WebViewModel);\n// ... existing registrations ...\nBlockRegistry.set(\"myview\", MyViewModel); // Add your view here\n```\n\nThe registry key (e.g., `\"myview\"`) becomes the view type used in block metadata.\n\n### 4. Create Blocks with Your View\n\nUsers can create blocks with your view type:\n\n- Via CLI: `wsh view myview`\n- Via RPC: Use the block's `meta.view` field set to `\"myview\"`\n\n## Real-World Examples\n\n### Example 1: Terminal View (`term-model.ts`)\n\nThe terminal view demonstrates:\n\n- **Connection management** via `manageConnection` atom\n- **Dynamic header buttons** showing shell status (play/restart)\n- **Mode switching** between terminal and vdom views\n- **Custom keyboard handling** for terminal-specific shortcuts\n- **Focus management** to focus the xterm.js instance\n- **Shell integration status** showing AI capability indicators\n\nKey features:\n\n```typescript\nthis.manageConnection = jotai.atom((get) => {\n  const termMode = get(this.termMode);\n  if (termMode == \"vdom\") return false;\n  return true; // Show connection picker for regular terminal mode\n});\n\nthis.endIconButtons = jotai.atom((get) => {\n  const shellProcStatus = get(this.shellProcStatus);\n  const buttons: IconButtonDecl[] = [];\n\n  if (shellProcStatus == \"running\") {\n    buttons.push({\n      elemtype: \"iconbutton\",\n      icon: \"refresh\",\n      title: \"Restart Shell\",\n      click: this.forceRestartController.bind(this),\n    });\n  }\n  return buttons;\n});\n```\n\n### Example 2: Web View (`webview.tsx`)\n\nThe web view shows:\n\n- **Complex header controls** (back/forward/home/URL input)\n- **State management** for loading, URL, and navigation\n- **Event handling** for webview navigation events\n- **Custom styling** with `noPadding` for full-bleed content\n- **Media controls** showing play/pause/mute when media is active\n\nKey features:\n\n```typescript\nthis.viewText = jotai.atom((get) => {\n  const url = get(this.url);\n  const rtn: HeaderElem[] = [];\n\n  // Navigation buttons\n  rtn.push({\n    elemtype: \"iconbutton\",\n    icon: \"chevron-left\",\n    click: this.handleBack.bind(this),\n    disabled: this.shouldDisableBackButton(),\n  });\n\n  // URL input with nested controls\n  rtn.push({\n    elemtype: \"div\",\n    className: \"block-frame-div-url\",\n    children: [\n      {\n        elemtype: \"input\",\n        value: url,\n        onChange: this.handleUrlChange.bind(this),\n        onKeyDown: this.handleKeyDown.bind(this),\n      },\n      {\n        elemtype: \"iconbutton\",\n        icon: \"rotate-right\",\n        click: this.handleRefresh.bind(this),\n      },\n    ],\n  });\n\n  return rtn;\n});\n```\n\n## Header Elements (`HeaderElem`)\n\nThe `viewText` atom can return an array of these element types:\n\n```typescript\n// Icon button\n{\n    elemtype: \"iconbutton\",\n    icon: \"refresh\",\n    title: \"Tooltip text\",\n    click: () => { /* handler */ },\n    disabled?: boolean,\n    iconColor?: string,\n    iconSpin?: boolean,\n    noAction?: boolean,  // Shows icon but no click action\n}\n\n// Text element\n{\n    elemtype: \"text\",\n    text: \"Display text\",\n    className?: string,\n    noGrow?: boolean,\n    ref?: React.RefObject<HTMLElement>,\n    onClick?: (e: React.MouseEvent) => void,\n}\n\n// Text button\n{\n    elemtype: \"textbutton\",\n    text: \"Button text\",\n    className?: string,\n    title: \"Tooltip\",\n    onClick: (e: React.MouseEvent) => void,\n}\n\n// Input field\n{\n    elemtype: \"input\",\n    value: string,\n    className?: string,\n    onChange: (e: React.ChangeEvent<HTMLInputElement>) => void,\n    onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void,\n    onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void,\n    onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void,\n    ref?: React.RefObject<HTMLInputElement>,\n}\n\n// Container with children\n{\n    elemtype: \"div\",\n    className?: string,\n    children: HeaderElem[],\n    onMouseOver?: (e: React.MouseEvent) => void,\n    onMouseOut?: (e: React.MouseEvent) => void,\n}\n\n// Menu button (dropdown)\n{\n    elemtype: \"menubutton\",\n    // ... MenuButtonProps ...\n}\n```\n\n## Best Practices\n\n### Jotai Model Pattern\n\nFollow these rules for Jotai atoms in models:\n\n1. **Simple atoms as field initializers**:\n\n   ```typescript\n   viewIcon = jotai.atom<string>(\"circle\");\n   noPadding = jotai.atom<boolean>(true);\n   ```\n\n2. **Derived atoms in constructor** (need dependency on other atoms):\n\n   ```typescript\n   constructor(blockId: string, nodeModel: BlockNodeModel) {\n       this.viewText = jotai.atom((get) => {\n           const blockData = get(this.blockAtom);\n           return [/* computed based on blockData */];\n       });\n   }\n   ```\n\n3. **Models never use React hooks** - Use `globalStore.get()`/`set()`:\n\n   ```typescript\n   refresh() {\n       const currentData = globalStore.get(this.blockAtom);\n       globalStore.set(this.dataAtom, newData);\n   }\n   ```\n\n4. **Components use hooks for atoms**:\n   ```typescript\n   const data = useAtomValue(model.dataAtom);\n   const [value, setValue] = useAtom(model.valueAtom);\n   ```\n\n### State Management\n\n- All view state should live in atoms on the model\n- Use `useBlockAtom()` helper for block-scoped atoms that persist\n- Use `globalStore` for imperative access outside React components\n- Subscribe to Wave events using `waveEventSubscribe()`\n\n### Styling\n\n- Create a `.scss` file for your view styles\n- Use Tailwind utilities where possible (v4)\n- Add `noPadding: atom(true)` for full-bleed content\n- Use `blockBg` atom to customize block background\n\n### Focus Management\n\nImplement `giveFocus()` to focus your view when:\n\n- Block gains focus via keyboard navigation\n- User clicks the block\n- Return `true` if successfully focused, `false` otherwise\n\n### Keyboard Handling\n\nImplement `keyDownHandler(e: WaveKeyboardEvent)` for:\n\n- View-specific keyboard shortcuts\n- Return `true` if event was handled (prevents propagation)\n- Use `keyutil.checkKeyPressed(waveEvent, \"Cmd:K\")` for shortcut checks\n\n### Cleanup\n\nImplement `dispose()` to:\n\n- Unsubscribe from Wave events\n- Unregister routes/handlers\n- Clear timers/intervals\n- Release resources\n\n### Connection Management\n\nFor views that need remote connections:\n\n```typescript\nthis.manageConnection = jotai.atom(true); // Show connection picker\nthis.filterOutNowsh = jotai.atom(true); // Hide nowsh connections\n```\n\nAccess connection status:\n\n```typescript\nconst connStatus = jotai.atom((get) => {\n  const blockData = get(this.blockAtom);\n  const connName = blockData?.meta?.connection;\n  return get(getConnStatusAtom(connName));\n});\n```\n\n## Common Patterns\n\n### Reading Block Metadata\n\n```typescript\nimport { getBlockMetaKeyAtom } from \"@/store/global\";\n\n// In constructor:\nthis.someFlag = getBlockMetaKeyAtom(blockId, \"myview:flag\");\n\n// In component:\nconst flag = useAtomValue(model.someFlag);\n```\n\n### Configuration Overrides\n\nWave has a hierarchical config system (global → connection → block):\n\n```typescript\nimport { getOverrideConfigAtom } from \"@/store/global\";\n\nthis.settingAtom = jotai.atom((get) => {\n  // Checks block meta, then connection config, then global settings\n  return get(getOverrideConfigAtom(this.blockId, \"myview:setting\")) ?? defaultValue;\n});\n```\n\n### Updating Block Metadata\n\n```typescript\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { WOS } from \"@/store/global\";\n\nawait RpcApi.SetMetaCommand(TabRpcClient, {\n  oref: WOS.makeORef(\"block\", this.blockId),\n  meta: { \"myview:key\": value },\n});\n```\n\n## Additional Resources\n\n- `frontend/app/block/blockframe-header.tsx` - Block header rendering\n- `frontend/app/view/term/term-model.ts` - Complex view example\n- `frontend/app/view/webview/webview.tsx` - Navigation UI example\n- `frontend/types/custom.d.ts` - Type definitions\n"
  },
  {
    "path": ".kilocode/skills/electron-api/SKILL.md",
    "content": "---\nname: electron-api\ndescription: Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC.\n---\n\n# Adding Electron APIs\n\nElectron APIs allow the frontend to call Electron main process functionality directly via IPC.\n\n## Four Files to Edit\n\n1. [`frontend/types/custom.d.ts`](frontend/types/custom.d.ts) - TypeScript [`ElectronApi`](frontend/types/custom.d.ts:82) type\n2. [`emain/preload.ts`](emain/preload.ts) - Expose method via `contextBridge`\n3. [`emain/emain-ipc.ts`](emain/emain-ipc.ts) - Implement IPC handler\n4. [`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\n\n## Three Communication Patterns\n\n1. **Sync** - `ipcRenderer.sendSync()` + `ipcMain.on()` + `event.returnValue = ...`\n2. **Async** - `ipcRenderer.invoke()` + `ipcMain.handle()`\n3. **Fire-and-forget** - `ipcRenderer.send()` + `ipcMain.on()`\n\n## Example: Async Method\n\n### 1. Define TypeScript Interface\n\nIn [`frontend/types/custom.d.ts`](frontend/types/custom.d.ts):\n\n```typescript\ntype ElectronApi = {\n    captureScreenshot: (rect: Electron.Rectangle) => Promise<string>; // capture-screenshot\n};\n```\n\n### 2. Expose in Preload\n\nIn [`emain/preload.ts`](emain/preload.ts):\n\n```typescript\ncontextBridge.exposeInMainWorld(\"api\", {\n    captureScreenshot: (rect: Rectangle) => ipcRenderer.invoke(\"capture-screenshot\", rect),\n});\n```\n\n### 3. Implement Handler\n\nIn [`emain/emain-ipc.ts`](emain/emain-ipc.ts):\n\n```typescript\nelectron.ipcMain.handle(\"capture-screenshot\", async (event, rect) => {\n    const tabView = getWaveTabViewByWebContentsId(event.sender.id);\n    if (!tabView) throw new Error(\"No tab view found\");\n    const image = await tabView.webContents.capturePage(rect);\n    return `data:image/png;base64,${image.toPNG().toString(\"base64\")}`;\n});\n```\n\n### 4. Add Preview Stub\n\nIn [`frontend/preview/preview-electron-api.ts`](frontend/preview/preview-electron-api.ts):\n\n```typescript\ncaptureScreenshot: (_rect: Electron.Rectangle) => Promise.resolve(\"\"),\n```\n\n### 5. Call from Frontend\n\n```typescript\nimport { getApi } from \"@/store/global\";\n\nconst dataUrl = await getApi().captureScreenshot({ x: 0, y: 0, width: 800, height: 600 });\n```\n\n## Example: Sync Method\n\n### 1. Define\n\n```typescript\ntype ElectronApi = {\n    getUserName: () => string; // get-user-name\n};\n```\n\n### 2. Preload\n\n```typescript\ngetUserName: () => ipcRenderer.sendSync(\"get-user-name\"),\n```\n\n### 3. Handler (⚠️ MUST set event.returnValue or browser hangs)\n\n```typescript\nelectron.ipcMain.on(\"get-user-name\", (event) => {\n    event.returnValue = process.env.USER || \"unknown\";\n});\n```\n\n### 4. Call\n\n```typescript\nimport { getApi } from \"@/store/global\";\n\nconst userName = getApi().getUserName(); // blocks until returns\n```\n\n## Example: Fire-and-Forget\n\n### 1. Define\n\n```typescript\ntype ElectronApi = {\n    openExternal: (url: string) => void; // open-external\n};\n```\n\n### 2. Preload\n\n```typescript\nopenExternal: (url) => ipcRenderer.send(\"open-external\", url),\n```\n\n### 3. Handler\n\n```typescript\nelectron.ipcMain.on(\"open-external\", (event, url) => {\n    electron.shell.openExternal(url);\n});\n```\n\n## Example: Event Listener\n\n### 1. Define\n\n```typescript\ntype ElectronApi = {\n    onZoomFactorChange: (callback: (zoomFactor: number) => void) => void; // zoom-factor-change\n};\n```\n\n### 2. Preload\n\n```typescript\nonZoomFactorChange: (callback) => \n    ipcRenderer.on(\"zoom-factor-change\", (_event, zoomFactor) => callback(zoomFactor)),\n```\n\n### 3. Send from Main\n\n```typescript\nwebContents.send(\"zoom-factor-change\", newZoomFactor);\n```\n\n## Quick Reference\n\n**Use Sync when:**\n- Getting config/env vars\n- Quick lookups, no I/O\n- ⚠️ **CRITICAL**: Always set `event.returnValue` or browser hangs\n\n**Use Async when:**\n- File operations\n- Network requests\n- Can fail or take time\n\n**Use Fire-and-forget when:**\n- No return value needed\n- Triggering actions\n\n**Electron API vs RPC:**\n- Electron API: Native OS features, window management, Electron APIs\n- RPC: Database, backend logic, remote servers\n\n## Checklist\n\n- [ ] Add to [`ElectronApi`](frontend/types/custom.d.ts:82) in [`custom.d.ts`](frontend/types/custom.d.ts)\n- [ ] Include IPC channel name in comment\n- [ ] Expose in [`preload.ts`](emain/preload.ts)\n- [ ] Implement in [`emain-ipc.ts`](emain/emain-ipc.ts)\n- [ ] Add no-op stub to [`preview-electron-api.ts`](frontend/preview/preview-electron-api.ts)\n- [ ] IPC channel names match exactly\n- [ ] **For sync**: Set `event.returnValue` (or browser hangs!)\n- [ ] Test end-to-end\n"
  },
  {
    "path": ".kilocode/skills/waveenv/SKILL.md",
    "content": "---\nname: waveenv\ndescription: 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.\n---\n\n# WaveEnv Narrowing Skill\n\n## Purpose\n\nA WaveEnv narrowing creates a _named subset type_ of `WaveEnv` that:\n\n1. Documents exactly which parts of the environment a component tree actually uses.\n2. Forms a type contract so callers and tests know what to provide.\n3. Enables mocking in the preview/test server — you only need to implement what's listed.\n\n## When To Create One\n\nCreate 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.\n\n## Core Principle: Only Include What You Use\n\n**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`.\n\n## File Location\n\n- **Separate file** (preferred for shared/complex envs): name it `<feature>env.ts` next to the component, e.g. `frontend/app/block/blockenv.ts`.\n- **Inline** (acceptable for small, single-file components): export the type directly from the component file, e.g. `WidgetsEnv` in `frontend/app/workspace/widgets.tsx`.\n\n## Imports Required\n\n```ts\nimport {\n  BlockMetaKeyAtomFnType, // only if you use getBlockMetaKeyAtom\n  ConnConfigKeyAtomFnType, // only if you use getConnConfigKeyAtom\n  SettingsKeyAtomFnType, // only if you use getSettingsKeyAtom\n  WaveEnv,\n  WaveEnvSubset,\n} from \"@/app/waveenv/waveenv\";\n```\n\n## The Shape\n\n```ts\nexport type MyEnv = WaveEnvSubset<{\n  // --- Simple WaveEnv properties ---\n  // Copy the type verbatim from WaveEnv with WaveEnv[\"key\"] syntax.\n  isDev: WaveEnv[\"isDev\"];\n  createBlock: WaveEnv[\"createBlock\"];\n  showContextMenu: WaveEnv[\"showContextMenu\"];\n  platform: WaveEnv[\"platform\"];\n\n  // --- electron: list only the methods you call ---\n  electron: {\n    openExternal: WaveEnv[\"electron\"][\"openExternal\"];\n  };\n\n  // --- rpc: list only the commands you call ---\n  rpc: {\n    ActivityCommand: WaveEnv[\"rpc\"][\"ActivityCommand\"];\n    ConnEnsureCommand: WaveEnv[\"rpc\"][\"ConnEnsureCommand\"];\n  };\n\n  // --- atoms: list only the atoms you read ---\n  atoms: {\n    modalOpen: WaveEnv[\"atoms\"][\"modalOpen\"];\n    fullConfigAtom: WaveEnv[\"atoms\"][\"fullConfigAtom\"];\n  };\n\n  // --- wos: always take the whole thing, no sub-typing needed ---\n  wos: WaveEnv[\"wos\"];\n\n  // --- services: list only the services you call; no method-level narrowing ---\n  services: {\n    block: WaveEnv[\"services\"][\"block\"];\n    workspace: WaveEnv[\"services\"][\"workspace\"];\n  };\n\n  // --- key-parameterized atom factories: enumerate the keys you use ---\n  getSettingsKeyAtom: SettingsKeyAtomFnType<\"app:focusfollowscursor\" | \"window:magnifiedblockopacity\">;\n  getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<\"view\" | \"frame:title\" | \"connection\">;\n  getConnConfigKeyAtom: ConnConfigKeyAtomFnType<\"conn:wshenabled\">;\n\n  // --- other atom helpers: copy verbatim ---\n  getConnStatusAtom: WaveEnv[\"getConnStatusAtom\"];\n  getLocalHostDisplayNameAtom: WaveEnv[\"getLocalHostDisplayNameAtom\"];\n}>;\n```\n\n### Automatically Included Fields\n\nEvery `WaveEnvSubset<T>` automatically includes the mock fields — you never need to declare them:\n\n- `isMock: boolean`\n- `mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => void`\n- `mockModels?: Map<any, any>`\n\n### Rules for Each Section\n\n| Section                    | Pattern                                                | Notes                                                                                              |\n| -------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |\n| `electron`                 | `electron: { method: WaveEnv[\"electron\"][\"method\"]; }` | List every method called; omit the rest.                                                           |\n| `rpc`                      | `rpc: { Cmd: WaveEnv[\"rpc\"][\"Cmd\"]; }`                 | List every RPC command called; omit the rest.                                                      |\n| `atoms`                    | `atoms: { atom: WaveEnv[\"atoms\"][\"atom\"]; }`           | List every atom read; omit the rest.                                                               |\n| `wos`                      | `wos: WaveEnv[\"wos\"]`                                  | Take the whole `wos` object (no sub-typing needed), but **only add it if `wos` is actually used**. |\n| `services`                 | `services: { svc: WaveEnv[\"services\"][\"svc\"]; }`       | List each service used; take the whole service object (no method-level narrowing).                 |\n| `getSettingsKeyAtom`       | `SettingsKeyAtomFnType<\"key1\" \\| \"key2\">`              | Union all settings keys accessed.                                                                  |\n| `getBlockMetaKeyAtom`      | `BlockMetaKeyAtomFnType<\"key1\" \\| \"key2\">`             | Union all block meta keys accessed.                                                                |\n| `getConnConfigKeyAtom`     | `ConnConfigKeyAtomFnType<\"key1\">`                      | Union all conn config keys accessed.                                                               |\n| All other `WaveEnv` fields | `WaveEnv[\"fieldName\"]`                                 | Copy type verbatim.                                                                                |\n\n## Using the Narrowed Type in Components\n\n```ts\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport { MyEnv } from \"./myenv\";\n\nconst MyComponent = memo(() => {\n    const env = useWaveEnv<MyEnv>();\n    // TypeScript now enforces you only access what's in MyEnv.\n    const val = useAtomValue(env.getSettingsKeyAtom(\"app:focusfollowscursor\"));\n    ...\n});\n```\n\nThe generic parameter on `useWaveEnv<MyEnv>()` casts the context to your narrowed type. The real production `WaveEnv` satisfies every narrowing; mock envs only need to implement the listed subset.\n\n## Real Examples\n\n- `BlockEnv` in `frontend/app/block/blockenv.ts` — complex narrowing with all section types, in a separate file.\n- `WidgetsEnv` in `frontend/app/workspace/widgets.tsx` — smaller narrowing defined inline in the component file.\n"
  },
  {
    "path": ".kilocode/skills/wps-events/SKILL.md",
    "content": "---\nname: wps-events\ndescription: 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.\n---\n\n# WPS Events Guide\n\n## Overview\n\nWPS (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.\n\n## Key Files\n\n- `pkg/wps/wpstypes.go` - Event type constants and data structures\n- `pkg/wps/wps.go` - Broker implementation and core logic\n- `pkg/wcore/wcore.go` - Example usage patterns\n\n## Event Structure\n\nEvents in WPS have the following structure:\n\n```go\ntype WaveEvent struct {\n    Event   string   `json:\"event\"`      // Event type constant\n    Scopes  []string `json:\"scopes,omitempty\"` // Optional scopes for targeted delivery\n    Sender  string   `json:\"sender,omitempty\"` // Optional sender identifier\n    Persist int      `json:\"persist,omitempty\"` // Number of events to persist in history\n    Data    any      `json:\"data,omitempty\"`    // Event payload\n}\n```\n\n## Adding a New Event Type\n\n### Step 1: Define the Event Constant\n\nAdd your event type constant to `pkg/wps/wpstypes.go`:\n\n```go\nconst (\n    Event_BlockClose       = \"blockclose\"\n    Event_ConnChange       = \"connchange\"\n    // ... other events ...\n    Event_YourNewEvent     = \"your:newevent\"  // type: YourEventData (or \"none\" if no data)\n)\n```\n\n**Naming Convention:**\n\n- Use descriptive PascalCase for the constant name with `Event_` prefix\n- Use lowercase with colons for the string value (e.g., \"namespace:eventname\")\n- Group related events with the same namespace prefix\n- Always add a `// type: <TypeName>` comment; use `// type: none` if no data is sent\n\n### Step 2: Add to AllEvents\n\nAdd your new constant to the `AllEvents` slice in `pkg/wps/wpstypes.go`:\n\n```go\nvar AllEvents []string = []string{\n    // ... existing events ...\n    Event_YourNewEvent,\n}\n```\n\n### Step 3: Register in WaveEventDataTypes (REQUIRED)\n\nYou **must** add an entry to `WaveEventDataTypes` in `pkg/tsgen/tsgenevent.go`. This drives TypeScript type generation for the event's `data` field:\n\n```go\nvar WaveEventDataTypes = map[string]reflect.Type{\n    // ... existing entries ...\n    wps.Event_YourNewEvent: reflect.TypeOf(YourEventData{}),        // value type\n    // wps.Event_YourNewEvent: reflect.TypeOf((*YourEventData)(nil)), // pointer type\n    // wps.Event_YourNewEvent: nil,                                   // no data (type: none)\n}\n```\n\n- Use `reflect.TypeOf(YourType{})` for value types\n- Use `reflect.TypeOf((*YourType)(nil))` for pointer types\n- Use `nil` if no data is sent for the event\n\n### Step 4: Define Event Data Structure (Optional)\n\nIf your event carries structured data, define a type for it:\n\n```go\ntype YourEventData struct {\n    Field1 string `json:\"field1\"`\n    Field2 int    `json:\"field2\"`\n}\n```\n\n### Step 5: Expose Type to Frontend (If Needed)\n\nIf 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:\n\n```go\n// add extra types to generate here\nvar ExtraTypes = []any{\n    waveobj.ORef{},\n    // ... other types ...\n    uctypes.RateLimitInfo{},  // Example: already added\n    YourEventData{},          // Add your new type here\n}\n```\n\nThen run code generation:\n\n```bash\ntask generate\n```\n\nThis will update `frontend/types/gotypes.d.ts` with TypeScript definitions for your type, ensuring type safety in the frontend when handling these events.\n\n## Publishing Events\n\n### Basic Publishing\n\nTo publish an event, use the global broker:\n\n```go\nimport \"github.com/wavetermdev/waveterm/pkg/wps\"\n\nwps.Broker.Publish(wps.WaveEvent{\n    Event: wps.Event_YourNewEvent,\n    Data:  yourData,\n})\n```\n\n### Publishing with Scopes\n\nScopes allow targeted event delivery. Subscribers can filter events by scope:\n\n```go\nwps.Broker.Publish(wps.WaveEvent{\n    Event:  wps.Event_WaveObjUpdate,\n    Scopes: []string{oref.String()},  // Target specific object\n    Data:   updateData,\n})\n```\n\n### Publishing in a Goroutine\n\nTo avoid blocking the caller, publish events asynchronously:\n\n```go\ngo func() {\n    wps.Broker.Publish(wps.WaveEvent{\n        Event: wps.Event_YourNewEvent,\n        Data:  data,\n    })\n}()\n```\n\n**When to use goroutines:**\n\n- When publishing from performance-critical code paths\n- When the event is informational and doesn't need immediate delivery\n- When publishing from code that holds locks (to prevent deadlocks)\n\n### Event Persistence\n\nEvents can be persisted in memory for late subscribers:\n\n```go\nwps.Broker.Publish(wps.WaveEvent{\n    Event:   wps.Event_YourNewEvent,\n    Persist: 100,  // Keep last 100 events\n    Data:    data,\n})\n```\n\n## Complete Example: Rate Limit Updates\n\nThis example shows how rate limit information is published when AI chat responses include rate limit headers.\n\n### 1. Define the Event Type\n\nIn `pkg/wps/wpstypes.go`:\n\n```go\nconst (\n    // ... other events ...\n    Event_WaveAIRateLimit  = \"waveai:ratelimit\"\n)\n```\n\n### 2. Publish the Event\n\nIn `pkg/aiusechat/usechat.go`:\n\n```go\nimport \"github.com/wavetermdev/waveterm/pkg/wps\"\n\nfunc updateRateLimit(info *uctypes.RateLimitInfo) {\n    if info == nil {\n        return\n    }\n    rateLimitLock.Lock()\n    defer rateLimitLock.Unlock()\n    globalRateLimitInfo = info\n\n    // Publish event in goroutine to avoid blocking\n    go func() {\n        wps.Broker.Publish(wps.WaveEvent{\n            Event: wps.Event_WaveAIRateLimit,\n            Data:  info,  // RateLimitInfo struct\n        })\n    }()\n}\n```\n\n### 3. Subscribe to the Event (Frontend)\n\nIn the frontend, subscribe to events via WebSocket:\n\n```typescript\n// Subscribe to rate limit updates\nconst subscription = {\n  event: \"waveai:ratelimit\",\n  allscopes: true, // Receive all rate limit events\n};\n```\n\n## Subscribing to Events\n\n### From Go Code\n\n```go\n// Subscribe to all events of a type\nwps.Broker.Subscribe(routeId, wps.SubscriptionRequest{\n    Event:     wps.Event_YourNewEvent,\n    AllScopes: true,\n})\n\n// Subscribe to specific scopes\nwps.Broker.Subscribe(routeId, wps.SubscriptionRequest{\n    Event:  wps.Event_WaveObjUpdate,\n    Scopes: []string{\"workspace:123\"},\n})\n\n// Unsubscribe\nwps.Broker.Unsubscribe(routeId, wps.Event_YourNewEvent)\n```\n\n### Scope Matching\n\nScopes support wildcard matching:\n\n- `*` matches a single scope segment\n- `**` matches multiple scope segments\n\n```go\n// Subscribe to all workspace events\nwps.Broker.Subscribe(routeId, wps.SubscriptionRequest{\n    Event:  wps.Event_WaveObjUpdate,\n    Scopes: []string{\"workspace:*\"},\n})\n```\n\n## Best Practices\n\n1. **Use Namespaces**: Prefix event names with a namespace (e.g., `waveai:`, `workspace:`, `block:`)\n\n2. **Don't Block**: Use goroutines when publishing from performance-critical code or while holding locks\n\n3. **Type-Safe Data**: Define struct types for event data rather than using maps\n\n4. **Scope Wisely**: Use scopes to limit event delivery and reduce unnecessary processing\n\n5. **Document Events**: Add comments explaining when events are fired and what data they carry\n\n6. **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.\n\n## Common Event Patterns\n\n### Status Updates\n\n```go\nwps.Broker.Publish(wps.WaveEvent{\n    Event:   wps.Event_ControllerStatus,\n    Scopes:  []string{blockId},\n    Persist: 1,  // Keep only latest status\n    Data:    statusData,\n})\n```\n\n### Object Updates\n\n```go\nwps.Broker.Publish(wps.WaveEvent{\n    Event:  wps.Event_WaveObjUpdate,\n    Scopes: []string{oref.String()},\n    Data: waveobj.WaveObjUpdate{\n        UpdateType: waveobj.UpdateType_Update,\n        OType:      obj.GetOType(),\n        OID:        waveobj.GetOID(obj),\n        Obj:        obj,\n    },\n})\n```\n\n### Batch Updates\n\n```go\n// Helper function for multiple updates\nfunc (b *BrokerType) SendUpdateEvents(updates waveobj.UpdatesRtnType) {\n    for _, update := range updates {\n        b.Publish(WaveEvent{\n            Event:  Event_WaveObjUpdate,\n            Scopes: []string{waveobj.MakeORef(update.OType, update.OID).String()},\n            Data:   update,\n        })\n    }\n}\n```\n\n## Debugging\n\nTo debug event flow:\n\n1. Check broker subscription map: `wps.Broker.SubMap`\n2. View persisted events: `wps.Broker.ReadEventHistory(eventType, scope, maxItems)`\n3. Add logging in publish/subscribe methods\n4. Monitor WebSocket traffic in browser dev tools\n\n## Quick Reference\n\nWhen adding a new event:\n\n- [ ] Add event constant to [`pkg/wps/wpstypes.go`](pkg/wps/wpstypes.go) with a `// type: <TypeName>` comment (use `none` if no data)\n- [ ] Add the constant to `AllEvents` in [`pkg/wps/wpstypes.go`](pkg/wps/wpstypes.go)\n- [ ] **REQUIRED**: Add an entry to `WaveEventDataTypes` in [`pkg/tsgen/tsgenevent.go`](pkg/tsgen/tsgenevent.go) — use `nil` for events with no data\n- [ ] Define event data structure (if needed)\n- [ ] Add data type to `pkg/tsgen/tsgen.go` for frontend use (if not already exposed via RPC)\n- [ ] Run `task generate` to update TypeScript types\n- [ ] Publish events using `wps.Broker.Publish()`\n- [ ] Use goroutines for non-blocking publish when appropriate\n- [ ] Subscribe to events in relevant components\n"
  },
  {
    "path": ".prettierignore",
    "content": "build\nbin\n.git\nfrontend/dist\nfrontend/node_modules\n*.min.*\nfrontend/app/store/services.ts\nfrontend/types/gotypes.d.ts\n"
  },
  {
    "path": ".roo/rules/overview.md",
    "content": "# Wave Terminal - High Level Architecture Overview\n\n## Project Description\n\nWave 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.\n\n## Top-Level Directory Structure\n\n```\nwaveterm/\n├── emain/              # Electron main process code\n├── frontend/           # React application (renderer process)\n├── cmd/                # Go command-line applications\n├── pkg/                # Go packages/modules\n├── db/                 # Database migrations\n├── docs/               # Documentation (Docusaurus)\n├── build/              # Build configuration and assets\n├── assets/             # Application assets (icons, images)\n├── public/             # Static public assets\n├── tests/              # Test files\n├── .github/            # GitHub workflows and configuration\n└── Configuration files (package.json, tsconfig.json, etc.)\n```\n\n## Architecture Components\n\n### 1. Electron Main Process (`emain/`)\n\nThe Electron main process handles the native desktop application layer:\n\n**Key Files:**\n\n- [`emain.ts`](emain/emain.ts) - Main entry point, application lifecycle management\n- [`emain-window.ts`](emain/emain-window.ts) - Window management (`WaveBrowserWindow` class)\n- [`emain-tabview.ts`](emain/emain-tabview.ts) - Tab view management (`WaveTabView` class)\n- [`emain-wavesrv.ts`](emain/emain-wavesrv.ts) - Go backend server integration\n- [`emain-wsh.ts`](emain/emain-wsh.ts) - WSH (Wave Shell) client integration\n- [`emain-ipc.ts`](emain/emain-ipc.ts) - IPC handlers for frontend ↔ main process communication\n- [`emain-menu.ts`](emain/emain-menu.ts) - Application menu system\n- [`updater.ts`](emain/updater.ts) - Auto-update functionality\n- [`preload.ts`](emain/preload.ts) - Preload script for renderer security\n- [`preload-webview.ts`](emain/preload-webview.ts) - Webview preload script\n\n### 2. Frontend React Application (`frontend/`)\n\nThe React application runs in the Electron renderer process:\n\n**Structure:**\n\n```\nfrontend/\n├── app/                # Main application code\n│   ├── app.tsx         # Root App component\n│   ├── aipanel/        # AI panel UI\n│   ├── block/          # Block-based UI components\n│   ├── element/        # Reusable UI elements\n│   ├── hook/           # Custom React hooks\n│   ├── modals/         # Modal components\n│   ├── store/          # State management (Jotai)\n│   ├── tab/            # Tab components\n│   ├── view/           # Different view types\n│   │   ├── codeeditor/ # Code editor (Monaco)\n│   │   ├── preview/    # File preview\n│   │   ├── sysinfo/    # System info view\n│   │   ├── term/       # Terminal view\n│   │   ├── tsunami/    # Tsunami builder view\n│   │   ├── vdom/       # Virtual DOM view\n│   │   ├── waveai/     # AI chat integration\n│   │   ├── waveconfig/ # Config editor view\n│   │   └── webview/    # Web view\n│   └── workspace/      # Workspace management\n├── builder/            # Builder app entry\n├── layout/             # Layout system\n├── preview/            # Standalone preview renderer\n├── types/              # TypeScript type definitions\n└── util/               # Utility functions\n```\n\n**Key Technologies:**\n\n- Electron (desktop application shell)\n- React 19 with TypeScript\n- Jotai for state management\n- Monaco Editor for code editing\n- XTerm.js for terminal emulation\n- Tailwind CSS v4 for styling\n- SCSS for additional styling (deprecated, new components should use Tailwind)\n- Vite / electron-vite for bundling\n- Task (Taskfile.yml) for build and code generation commands\n\n### 3. Go Backend Server (`cmd/server/`)\n\nThe Go backend server handles all heavy lifting operations:\n\n**Entry Point:** [`main-server.go`](cmd/server/main-server.go)\n\n### 4. Go Packages (`pkg/`)\n\nThe Go codebase is organized into modular packages:\n\n**Key Packages:**\n\n- `wstore/` - Database and storage layer\n- `wconfig/` - Configuration management\n- `wcore/` - Core business logic\n- `wshrpc/` - RPC communication system\n- `wshutil/` - WSH (Wave Shell) utilities\n- `blockcontroller/` - Block execution management\n- `remote/` - Remote connection handling\n- `filestore/` - File storage system\n- `web/` - Web server and WebSocket handling\n- `telemetry/` - Usage analytics and telemetry\n- `waveobj/` - Core data objects\n- `service/` - Service layer\n- `wps/` - Wave PubSub event system\n- `waveai/` - AI functionality\n- `shellexec/` - Shell execution\n- `util/` - Common utilities\n\n### 5. Command Line Tools (`cmd/`)\n\nKey Go command-line utilities:\n\n- `wsh/` - Wave Shell command-line tool\n- `server/` - Main backend server\n- `generatego/` - Code generation\n- `generateschema/` - Schema generation\n- `generatets/` - TypeScript generation\n\n## Communication Architecture\n\nThe 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).\n\n### WSH RPC System (`pkg/wshrpc/`)\n\nThe WSH RPC system is the backbone of Wave Terminal's communication architecture:\n\n**Key Components:**\n\n- [`wshrpctypes.go`](pkg/wshrpc/wshrpctypes.go) - Core RPC interface and type definitions (source of truth for all RPC commands)\n- [`wshserver/`](pkg/wshrpc/wshserver/) - Server-side RPC implementation\n- [`wshremote/`](pkg/wshrpc/wshremote/) - Remote connection handling\n- [`wshclient.go`](pkg/wshrpc/wshclient.go) - Go client for making RPC calls\n- [`frontend/app/store/wshclientapi.ts`](frontend/app/store/wshclientapi.ts) - Generated TypeScript RPC client\n\n**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.\n\n## Development Notes\n\n- **Build commands** - Use `task` (Taskfile.yml) for all build, generate, and packaging commands\n- **Code generation** - Run `task generate` after modifying Go types in `pkg/wshrpc/wshrpctypes.go`, `pkg/wconfig/settingsconfig.go`, or `pkg/waveobj/wtypemeta.go`\n- **Testing** - Vitest for frontend unit tests; standard `go test` for Go packages\n- **Database migrations** - SQL migration files in `db/migrations-wstore/` and `db/migrations-filestore/`\n- **Documentation** - Docusaurus site in `docs/`\n"
  },
  {
    "path": ".roo/rules/rules.md",
    "content": "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.\n\n### Project Structure\n\nIt 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).\n\n### Coding Guidelines\n\n- **Go Conventions**:\n  - 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`).\n  - Use string constants for status values, packet types, and other string-based enumerations.\n  - in Go code, prefer using Printf() vs Println()\n  - use \"Make\" as opposed to \"New\" for struct initialization func names\n  - in general const decls go at the top of the file (before types and functions)\n  - NEVER run `go build` (especially in weird sub-package directories). we can tell if everything compiles by seeing there are no problems/errors.\n- **Synchronization**:\n  - Always prefer to use the `lock.Lock(); defer lock.Unlock()` pattern for synchronization if possible\n  - Avoid inline lock/unlock pairs - instead create helper functions that use the defer pattern\n  - When accessing shared data structures (maps, slices, etc.), ensure proper locking\n  - 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] }`\n- **TypeScript Imports**:\n  - Use `@/...` for imports from different parts of the project (configured in `tsconfig.json` as `\"@/*\": [\"frontend/*\"]`).\n  - Prefer relative imports (`\"./name\"`) only within the same directory.\n  - Use named exports exclusively; avoid default exports. It's acceptable to export functions directly (e.g., React Components).\n  - Our indent is 4 spaces\n- **JSON Field Naming**: All fields must be lowercase, without underscores.\n- **TypeScript Conventions**\n  - **Type Handling**:\n    - In TypeScript we have strict null checks off, so no need to add \"| null\" to all the types.\n    - In TypeScript for Jotai atoms, if we want to write, we need to type the atom as a PrimitiveAtom<Type>\n    - 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.\n    - 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.\n  - **Coding Style**:\n    - Use all lowercase filenames (except where case is actually important like Taskfile.yml)\n    - Import the \"cn\" function from \"@/util/util\" to do classname / clsx class merge (it uses twMerge underneath)\n    - For element variants use class-variance-authority\n    - Do NOT create private fields in classes (they are impossible to inspect)\n    - Use PascalCase for global consts at the top of files\n  - **Component Practices**:\n    - Make sure to add cursor-pointer to buttons/links and clickable items\n    - NEVER use cursor-help (it looks terrible)\n    - useAtom() and useAtomValue() are react HOOKS, so they must be called at the component level not inline in JSX\n    - If you use React.memo(), make sure to add a displayName for the component\n  - Other\n    - never use atob() or btoa() (not UTF-8 safe). use functions in frontend/util/util.ts for base64 decoding and encoding\n- In general, when writing functions, we prefer _early returns_ rather than putting the majority of a function inside of an if block.\n\n### Styling\n\n- We use **Tailwind v4** to style. Custom stuff is defined in frontend/tailwindsetup.css\n- _never_ use cursor-help, or cursor-not-allowed (it looks terrible)\n- 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.\n- 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)\n\n### RPC System\n\nTo 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.\n\nFor normal \"server\" RPCs (where a frontend client is calling the main server) you should implement the RPC call in `pkg/wshrpc/wshserver.go`.\n\n### Electron API\n\nFrom within the FE to get the electron API (e.g. the preload functions):\n\n```ts\nimport { getApi } from \"@/store/global\";\n\ngetApi().getIsDev();\n```\n\nThe full API is defined in custom.d.ts as type ElectronApi.\n\n### Code Generation\n\n- **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`.\n- **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`.\n\n### Frontend Architecture\n\n- The application uses Jotai for state management.\n- When working with Jotai atoms that need to be updated, define them as `PrimitiveAtom<Type>` rather than just `atom<Type>`.\n\n### Notes\n\n- **CRITICAL: Completion format MUST be: \"Done: [one-line description]\"**\n- **Keep your Task Completed summaries VERY short**\n- **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.\n- **Go directly to completion** - After making changes, proceed directly to attempt_completion without summarizing\n- The project is currently an un-released POC / MVP. Do not worry about backward compatibility when making changes\n- 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.\n- **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.\n- **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.\n- for simple functions, we prefer `if (!cond) { return }; functionality;` pattern over `if (cond) { functionality }` because it produces less indentation and is easier to follow.\n- It is now 2026, so if you write new files, or update files use 2026 for the copyright year\n- React.MutableRefObject is deprecated, just use React.RefObject now (in React 19 RefObject is always mutable)\n\n### Strict Comment Rules\n\n- **NEVER add comments that merely describe what code is doing**:\n  - ❌ `mutex.Lock() // Lock the mutex`\n  - ❌ `counter++ // Increment the counter`\n  - ❌ `buffer.Write(data) // Write data to buffer`\n  - ❌ `// Header component for app run list` (above AppRunListHeader)\n  - ❌ `// Updated function to include onClick parameter`\n  - ❌ `// Changed padding calculation`\n  - ❌ `// Removed unnecessary div`\n  - ❌ `// Using the model's width value here`\n- **Only use comments for**:\n  - Explaining WHY a particular approach was chosen\n  - Documenting non-obvious edge cases or side effects\n  - Warning about potential pitfalls in usage\n  - Explaining complex algorithms that can't be simplified\n- **When in doubt, leave it out**. No comment is better than a redundant comment.\n- **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.\n- **Never remove existing comments** unless specifically directed by the user. Comments that are already defined in existing code have been vetted by the user.\n\n### Jotai Model Pattern (our rules)\n\n- **Atoms live on the model.**\n- **Simple atoms:** define as **field initializers**.\n- **Atoms that depend on values/other atoms:** create in the **constructor**.\n- Models **never use React hooks**; they use `globalStore.get/set`.\n- It's fine to call model methods from **event handlers** or **`useEffect`**.\n- Models use the **singleton pattern** with a `private static instance` field, a `private constructor`, and a `static getInstance()` method.\n- The constructor is `private`; callers always use `getInstance()`.\n\n```ts\n// model/MyModel.ts\nimport * as jotai from \"jotai\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\n\nexport class MyModel {\n  private static instance: MyModel | null = null;\n\n  // simple atoms (field init)\n  statusAtom = jotai.atom<\"idle\" | \"running\" | \"error\">(\"idle\");\n  outputAtom = jotai.atom(\"\");\n\n  // ctor-built atoms (need types)\n  lengthAtom!: jotai.Atom<number>;\n  thresholdedAtom!: jotai.Atom<boolean>;\n\n  private constructor(initialThreshold = 20) {\n    this.lengthAtom = jotai.atom((get) => get(this.outputAtom).length);\n    this.thresholdedAtom = jotai.atom((get) => get(this.lengthAtom) > initialThreshold);\n  }\n\n  static getInstance(): MyModel {\n    if (!MyModel.instance) {\n      MyModel.instance = new MyModel();\n    }\n    return MyModel.instance;\n  }\n\n  static resetInstance(): void {\n    MyModel.instance = null;\n  }\n\n  async doWork() {\n    globalStore.set(this.statusAtom, \"running\");\n    // ... do work ...\n    globalStore.set(this.statusAtom, \"idle\");\n  }\n}\n```\n\n```tsx\n// component usage (events & effects OK)\nimport { useAtomValue } from \"jotai\";\n\nfunction Panel() {\n  const model = MyModel.getInstance();\n  const status = useAtomValue(model.statusAtom);\n  const isBig = useAtomValue(model.thresholdedAtom);\n\n  const onClick = () => model.doWork();\n\n  return (\n    <div>\n      {status} • {String(isBig)}\n    </div>\n  );\n}\n```\n\n**Remember:** singleton pattern with `getInstance()`, `private constructor`, atoms on the model, simple-as-fields, ctor for dependent/derived, updates via `globalStore.set/get`.\n**Note** Older models may not use the singleton pattern\n\n### Tool Use\n\nDo 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.\n\nAlso 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).\n\n### Directory Awareness\n\n- **ALWAYS verify the current working directory before executing commands**\n- Either run \"pwd\" first to verify the directory, or do a \"cd\" to the correct absolute directory before running commands\n- 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.\n\n### Testing / Compiling Go Code\n\nNo 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.\nIf there are no Go errors in VSCode you can assume the code compiles fine.\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n    \"recommendations\": [\n        \"esbenp.prettier-vscode\",\n        \"golang.go\",\n        \"dbaeumer.vscode-eslint\",\n        \"vitest.explorer\",\n        \"task.vscode-task\"\n    ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n    \"editor.formatOnSave\": true,\n    \"editor.detectIndentation\": false,\n    \"editor.formatOnPaste\": true,\n    \"editor.tabSize\": 4,\n    \"editor.insertSpaces\": false,\n    \"prettier.useEditorConfig\": true,\n    \"diffEditor.renderSideBySide\": false,\n    \"[javascript]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[javascriptreact]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[typescript]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[typescriptreact]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[less]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[scss]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[css]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[html]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[json]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[yaml]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n        \"editor.insertSpaces\": true,\n        \"editor.autoIndent\": \"keep\"\n    },\n    \"[github-actions-workflow]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n        \"editor.insertSpaces\": true,\n        \"editor.autoIndent\": \"keep\"\n    },\n    \"[go]\": {\n        \"editor.defaultFormatter\": \"golang.go\"\n    },\n    \"[mdx]\": {\n        \"editor.wordWrap\": \"on\"\n    },\n    \"[md]\": {\n        \"editor.wordWrap\": \"on\"\n    },\n    \"files.associations\": {\n        \"*.css\": \"tailwindcss\"\n    },\n    \"gopls\": {\n        \"analyses\": {\n            \"QF1003\": false\n        },\n        \"directoryFilters\": [\"-tsunami/frontend/scaffold\", \"-dist\", \"-make\"]\n    },\n    \"tailwindCSS.lint.suggestCanonicalClasses\": \"ignore\",\n    \"go.coverageDecorator\": {\n        \"type\": \"gutter\"\n    }\n}\n"
  },
  {
    "path": ".zed/settings.json",
    "content": "{\n    \"format_on_save\": \"on\",\n    \"languages\": {\n        \"JavaScript\": {\n            \"formatter\": {\n                \"external\": {\n                    \"command\": \"./node_modules/.bin/prettier\",\n                    \"arguments\": [\"--stdin-filepath\", \"{buffer_path}\"]\n                }\n            }\n        },\n        \"JSON\": {\n            \"formatter\": {\n                \"external\": {\n                    \"command\": \"./node_modules/.bin/prettier\",\n                    \"arguments\": [\"--stdin-filepath\", \"{buffer_path}\"]\n                }\n            }\n        },\n        \"TypeScript\": {\n            \"formatter\": {\n                \"external\": {\n                    \"command\": \"./node_modules/.bin/prettier\",\n                    \"arguments\": [\"--stdin-filepath\", \"{buffer_path}\"]\n                }\n            }\n        },\n        \"CSS\": {\n            \"formatter\": {\n                \"external\": {\n                    \"command\": \"./node_modules/.bin/prettier\",\n                    \"arguments\": [\"--stdin-filepath\", \"{buffer_path}\"]\n                }\n            }\n        },\n        \"SCSS\": {\n            \"formatter\": {\n                \"external\": {\n                    \"command\": \"./node_modules/.bin/prettier\",\n                    \"arguments\": [\"--stdin-filepath\", \"{buffer_path}\"]\n                }\n            }\n        },\n        \"YAML\": {\n            \"formatter\": {\n                \"external\": {\n                    \"command\": \"./node_modules/.bin/prettier\",\n                    \"arguments\": [\"--stdin-filepath\", \"{buffer_path}\"]\n                }\n            }\n        }\n    },\n    \"lsp\": {\n        \"eslint\": {\n            \"settings\": {\n                \"codeActionOnSave\": {\n                    \"rules\": [\"import/order\"]\n                },\n                \"nodePath\": \"./node_modules/.bin\",\n                \"language_ids\": [\"typescript\", \"javascript\", \"typescriptreact\", \"javascriptreact\"]\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ACKNOWLEDGEMENTS.md",
    "content": "# Open-Source Acknowledgements\n\nWe make use of many amazing open-source projects to build Wave Terminal. We automatically generate license reports via FOSSA to comply with the license distribution requirements of our dependencies. Below is a summary of the licenses used by our product. For a full report, see [here](https://app.fossa.com/reports/24d13570-624b-4450-8c22-756e513060c9?full=true) (the page may take 20-30s to load).\n\n[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_large)\n"
  },
  {
    "path": "BUILD.md",
    "content": "# Building Wave Terminal\n\nThese instructions are for setting up dependencies and building Wave Terminal from source on macOS, Linux, and Windows.\n\n## Prerequisites\n\n### OS-specific dependencies\n\nSee [Minimum requirements](README.md#minimum-requirements) to learn whether your OS is supported.\n\n#### macOS\n\nmacOS does not have any platform-specific dependencies.\n\n#### Linux\n\nYou must have `zip` installed. We also require the [Zig](https://ziglang.org/) compiler for statically linking CGO.\n\nDebian/Ubuntu:\n\n```sh\nsudo apt install zip snapd\nsudo snap install zig --classic --beta\n```\n\nFedora/RHEL:\n\n```sh\nsudo dnf install zip zig\n```\n\nArch:\n\n```sh\nsudo pacman -S zip zig\n```\n\n##### For packaging\n\nFor packaging, the following additional packages are required:\n\n- `fpm` &mdash; If you're on x64 you can skip this. If you're on ARM64, install fpm via [Gem](https://rubygems.org/gems/fpm)\n- `rpm` &mdash; If you're not on Fedora, install RPM via your package manager.\n- `snapd` &mdash; If your distro doesn't already include it, [install `snapd`](https://snapcraft.io/docs/installing-snapd)\n- `lxd` &mdash; [Installation instructions](https://canonical.com/lxd/install)\n- `snapcraft` &mdash; Run `sudo snap install snapcraft --classic`\n- `libarchive-tools` &mdash; Install via your package manager\n- `binutils` &mdash; Install via your package manager\n- `libopenjp2-tools` &mdash; Install via your package manager\n- `squashfs-tools` &mdash; Install via your package manager\n\n#### Windows\n\nYou will need the [Zig](https://ziglang.org/) compiler for statically linking CGO.\n\nYou can find installation instructions for Zig on Windows [here](https://ziglang.org/learn/getting-started/#managers).\n\n### Task\n\nDownload and install Task (to run the build commands): https://taskfile.dev/installation/\n\nTask 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).\n\n### Go\n\nDownload and install Go via your package manager or directly from the website: https://go.dev/doc/install\n\n### NodeJS\n\nMake sure you have a NodeJS 22 LTS installed.\n\nSee NodeJS's website for platform-specific instructions: https://nodejs.org/en/download\n\nWe now use `npm`, so you can just run an `npm install` to install node dependencies.\n\n## Clone the Repo\n\n```sh\ngit clone git@github.com:wavetermdev/waveterm.git\n```\n\nor\n\n```sh\ngit clone https://github.com/wavetermdev/waveterm.git\n```\n\n## Install code dependencies\n\nThe 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:\n\n```sh\ntask init\n```\n\n## Build and Run\n\nAll 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.\n\n### Development server\n\nRun the following command to build the app and run it via Vite's development server (this enables Hot Module Reloading):\n\n```sh\ntask dev\n```\n\n### Standalone\n\nRun the following command to build the app and run it standalone, without the development server. This will not reload on change:\n\n```sh\ntask start\n```\n\n### Packaged\n\nRun 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/`.\n\n```sh\ntask package\n```\n\nIf you're on Linux ARM64, run the following:\n\n```sh\nUSE_SYSTEM_FPM=1 task package\n```\n\n## Debugging\n\n### Frontend logs\n\nYou 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.\n\n### Backend logs\n\nBackend 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.\n"
  },
  {
    "path": "CNAME",
    "content": "docs.waveterm.dev"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n* Demonstrating empathy and kindness toward other people\n* Being respectful of differing opinions, viewpoints, and experiences\n* Giving and gracefully accepting constructive feedback\n* Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n* Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n* The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n* Trolling, insulting or derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\ncoc@commandline.dev.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior,  harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\nhttps://www.contributor-covenant.org/version/2/0/code_of_conduct.html.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see the FAQ at\nhttps://www.contributor-covenant.org/faq. Translations are available at\nhttps://www.contributor-covenant.org/translations."
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Wave Terminal\n\nWave Terminal is an opinionated project with a single active maintainer. Contributions are welcome, but **alignment matters more than volume**.\n\nThis document helps you decide _whether_ and _how_ to contribute in a way that's likely to be accepted, saving both of us time.\n\n## High-level expectations\n\n- Wave has a strong product direction and centralized ownership.\n- Review bandwidth is limited.\n- Not all contributions can or will be accepted, even if they are technically correct.\n\nThis is normal for a solo-maintainer project.\n\n## What makes a great contribution\n\nThe following are most likely to be accepted:\n\n- **Bug fixes** - especially with clear reproduction steps\n- **Documentation improvements** - typos, clarifications, examples\n- **Discussed features** - after alignment in Discord\n- **Small, focused changes** - easy to review and low risk\n\nIf your change is small and obvious (typo fix, narrowly-scoped bug fix, small docs improvement), you are welcome to open a pull request directly.\n\n## Keep changes focused\n\n**Only change what is necessary to accomplish your stated goal.**\n\nIf you're fixing a bug in `file.ts`, do not:\n\n- Reformat other files\n- Clean up unrelated code\n- Fix style issues in files you didn't need to touch\n- Combine multiple unrelated fixes in one PR\n\nEven 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.\n\n**One PR = one logical change.**\n\n## Discuss first (required for larger changes)\n\nFor anything beyond a small fix, **discussion is required before opening a pull request**.\n\nThis includes:\n\n- New features\n- UI/UX changes or changes to default behavior\n- Refactors or \"cleanup\" work\n- Performance rewrites\n- Architectural changes\n- Changes that touch many files or systems\n\n**Where to discuss:** Discord is the preferred place for these conversations -- https://discord.gg/XfvZ334gwU\n\nPull requests that introduce larger changes without prior discussion will be closed without detailed review.\n\nThis is not meant to discourage contribution — it is meant to ensure alignment before significant work is done.\n\n## What this project is not\n\nTo set expectations clearly:\n\n- Wave is not designed as a \"first open source contribution\" project\n- We do not currently curate beginner-friendly or mentorship issues\n- Large, unsolicited changes are unlikely to be accepted\n- Mechanical refactors, broad style changes, or drive-by rewrites are not helpful\n- 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.\n\nBeing clear about this helps everyone spend their time effectively.\n\n## FAQ\n\n**Q: Should I ask before fixing a typo or obvious bug?**  \nA: No, just open a PR for small, obvious fixes.\n\n**Q: I have an idea for a new feature.**  \nA: Great! Come discuss it in Discord first. Do not open a PR without prior discussion.\n\n**Q: My PR was closed without detailed feedback.**  \nA: 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.\n\n**Q: Can I work on an open issue?**  \nA: 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.\n\n**Q: I noticed some code that could be cleaner while working on my fix.**  \nA: Focus on your stated goal. Submit cleanup as a separate PR after discussion, if desired.\n\n## Contributor License Agreement (CLA)\n\nContributions 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.\n\nOn submission of your first pull request, you will be prompted to sign the CLA confirming that you own the intellectual property in your contribution.\n\n**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.\n\n## Style guide\n\nThe project uses American English. Please follow existing formatting and style conventions. Use gofmt and prettier where applicable.\n\n## Development setup\n\nTo build and run Wave locally, see instructions at [Building Wave Terminal](./BUILD.md).\n\n## Code of Conduct\n\nAll contributors are expected to follow the project's [Code of Conduct](./CODE_OF_CONDUCT.md).\n\n---\n\nThank you for your interest in Wave Terminal. Clear expectations help keep the project moving quickly and sustainably.\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1.  Definitions.\n\n    \"License\" shall mean the terms and conditions for use, reproduction,\n    and distribution as defined by Sections 1 through 9 of this document.\n\n    \"Licensor\" shall mean the copyright owner or entity authorized by\n    the copyright owner that is granting the License.\n\n    \"Legal Entity\" shall mean the union of the acting entity and all\n    other entities that control, are controlled by, or are under common\n    control with that entity. For the purposes of this definition,\n    \"control\" means (i) the power, direct or indirect, to cause the\n    direction or management of such entity, whether by contract or\n    otherwise, or (ii) ownership of fifty percent (50%) or more of the\n    outstanding shares, or (iii) beneficial ownership of such entity.\n\n    \"You\" (or \"Your\") shall mean an individual or Legal Entity\n    exercising permissions granted by this License.\n\n    \"Source\" form shall mean the preferred form for making modifications,\n    including but not limited to software source code, documentation\n    source, and configuration files.\n\n    \"Object\" form shall mean any form resulting from mechanical\n    transformation or translation of a Source form, including but\n    not limited to compiled object code, generated documentation,\n    and conversions to other media types.\n\n    \"Work\" shall mean the work of authorship, whether in Source or\n    Object form, made available under the License, as indicated by a\n    copyright notice that is included in or attached to the work\n    (an example is provided in the Appendix below).\n\n    \"Derivative Works\" shall mean any work, whether in Source or Object\n    form, that is based on (or derived from) the Work and for which the\n    editorial revisions, annotations, elaborations, or other modifications\n    represent, as a whole, an original work of authorship. For the purposes\n    of this License, Derivative Works shall not include works that remain\n    separable from, or merely link (or bind by name) to the interfaces of,\n    the Work and Derivative Works thereof.\n\n    \"Contribution\" shall mean any work of authorship, including\n    the original version of the Work and any modifications or additions\n    to that Work or Derivative Works thereof, that is intentionally\n    submitted to Licensor for inclusion in the Work by the copyright owner\n    or by an individual or Legal Entity authorized to submit on behalf of\n    the copyright owner. For the purposes of this definition, \"submitted\"\n    means any form of electronic, verbal, or written communication sent\n    to the Licensor or its representatives, including but not limited to\n    communication on electronic mailing lists, source code control systems,\n    and issue tracking systems that are managed by, or on behalf of, the\n    Licensor for the purpose of discussing and improving the Work, but\n    excluding communication that is conspicuously marked or otherwise\n    designated in writing by the copyright owner as \"Not a Contribution.\"\n\n    \"Contributor\" shall mean Licensor and any individual or Legal Entity\n    on behalf of whom a Contribution has been received by Licensor and\n    subsequently incorporated within the Work.\n\n2.  Grant of Copyright License. Subject to the terms and conditions of\n    this License, each Contributor hereby grants to You a perpetual,\n    worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n    copyright license to reproduce, prepare Derivative Works of,\n    publicly display, publicly perform, sublicense, and distribute the\n    Work and such Derivative Works in Source or Object form.\n\n3.  Grant of Patent License. Subject to the terms and conditions of\n    this License, each Contributor hereby grants to You a perpetual,\n    worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n    (except as stated in this section) patent license to make, have made,\n    use, offer to sell, sell, import, and otherwise transfer the Work,\n    where such license applies only to those patent claims licensable\n    by such Contributor that are necessarily infringed by their\n    Contribution(s) alone or by combination of their Contribution(s)\n    with the Work to which such Contribution(s) was submitted. If You\n    institute patent litigation against any entity (including a\n    cross-claim or counterclaim in a lawsuit) alleging that the Work\n    or a Contribution incorporated within the Work constitutes direct\n    or contributory patent infringement, then any patent licenses\n    granted to You under this License for that Work shall terminate\n    as of the date such litigation is filed.\n\n4.  Redistribution. You may reproduce and distribute copies of the\n    Work or Derivative Works thereof in any medium, with or without\n    modifications, and in Source or Object form, provided that You\n    meet the following conditions:\n\n    (a) You must give any other recipients of the Work or\n    Derivative Works a copy of this License; and\n\n    (b) You must cause any modified files to carry prominent notices\n    stating that You changed the files; and\n\n    (c) You must retain, in the Source form of any Derivative Works\n    that You distribute, all copyright, patent, trademark, and\n    attribution notices from the Source form of the Work,\n    excluding those notices that do not pertain to any part of\n    the Derivative Works; and\n\n    (d) If the Work includes a \"NOTICE\" text file as part of its\n    distribution, then any Derivative Works that You distribute must\n    include a readable copy of the attribution notices contained\n    within such NOTICE file, excluding those notices that do not\n    pertain to any part of the Derivative Works, in at least one\n    of the following places: within a NOTICE text file distributed\n    as part of the Derivative Works; within the Source form or\n    documentation, if provided along with the Derivative Works; or,\n    within a display generated by the Derivative Works, if and\n    wherever such third-party notices normally appear. The contents\n    of the NOTICE file are for informational purposes only and\n    do not modify the License. You may add Your own attribution\n    notices within Derivative Works that You distribute, alongside\n    or as an addendum to the NOTICE text from the Work, provided\n    that such additional attribution notices cannot be construed\n    as modifying the License.\n\n    You may add Your own copyright statement to Your modifications and\n    may provide additional or different license terms and conditions\n    for use, reproduction, or distribution of Your modifications, or\n    for any such Derivative Works as a whole, provided Your use,\n    reproduction, and distribution of the Work otherwise complies with\n    the conditions stated in this License.\n\n5.  Submission of Contributions. Unless You explicitly state otherwise,\n    any Contribution intentionally submitted for inclusion in the Work\n    by You to the Licensor shall be under the terms and conditions of\n    this License, without any additional terms or conditions.\n    Notwithstanding the above, nothing herein shall supersede or modify\n    the terms of any separate license agreement you may have executed\n    with Licensor regarding such Contributions.\n\n6.  Trademarks. This License does not grant permission to use the trade\n    names, trademarks, service marks, or product names of the Licensor,\n    except as required for reasonable and customary use in describing the\n    origin of the Work and reproducing the content of the NOTICE file.\n\n7.  Disclaimer of Warranty. Unless required by applicable law or\n    agreed to in writing, Licensor provides the Work (and each\n    Contributor provides its Contributions) on an \"AS IS\" BASIS,\n    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n    implied, including, without limitation, any warranties or conditions\n    of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n    PARTICULAR PURPOSE. You are solely responsible for determining the\n    appropriateness of using or redistributing the Work and assume any\n    risks associated with Your exercise of permissions under this License.\n\n8.  Limitation of Liability. In no event and under no legal theory,\n    whether in tort (including negligence), contract, or otherwise,\n    unless required by applicable law (such as deliberate and grossly\n    negligent acts) or agreed to in writing, shall any Contributor be\n    liable to You for damages, including any direct, indirect, special,\n    incidental, or consequential damages of any character arising as a\n    result of this License or out of the use or inability to use the\n    Work (including but not limited to damages for loss of goodwill,\n    work stoppage, computer failure or malfunction, or any and all\n    other commercial damages or losses), even if such Contributor\n    has been advised of the possibility of such damages.\n\n9.  Accepting Warranty or Additional Liability. While redistributing\n    the Work or Derivative Works thereof, You may choose to offer,\n    and charge a fee for, acceptance of support, warranty, indemnity,\n    or other liability obligations and/or rights consistent with this\n    License. However, in accepting such obligations, You may act only\n    on Your own behalf and on Your sole responsibility, not on behalf\n    of any other Contributor, and only if You agree to indemnify,\n    defend, and hold each Contributor harmless for any liability\n    incurred by, or claims asserted against, such Contributor by reason\n    of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\nCopyright 2025 Command Line Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n"
  },
  {
    "path": "NOTICE",
    "content": "Copyright 2025, Command Line Inc.\n"
  },
  {
    "path": "README.ko.md",
    "content": "<p align=\"center\">\n  <a href=\"https://www.waveterm.dev\">\n\t<picture>\n\t\t<source media=\"(prefers-color-scheme: dark)\" srcset=\"./assets/wave-dark.png\">\n\t\t<source media=\"(prefers-color-scheme: light)\" srcset=\"./assets/wave-light.png\">\n\t\t<img alt=\"Wave Terminal Logo\" src=\"./assets/wave-light.png\" width=\"240\">\n\t</picture>\n  </a>\n  <br/>\n</p>\n\n# Wave Terminal\n\n<div align=\"center\">\n\n[English](README.md) | [한국어](README.ko.md)\n\n</div>\n\n[![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)\n\n> 이 문서는 커뮤니티 한국어 번역본입니다. 최신 원문은 [README.md](README.md)에서 확인하세요.\n\nWave는 macOS, Linux, Windows에서 동작하는 오픈소스 AI 통합 터미널입니다. 어떤 AI 모델과도 함께 사용할 수 있습니다. OpenAI, Claude, Gemini는 API 키를 직접 연결해 사용할 수 있고, Ollama 및 LM Studio를 통해 로컬 모델도 실행할 수 있습니다. 계정 생성은 필요하지 않습니다.\n\n또한 Wave는 네트워크 중단이나 재시작 이후에도 유지되는 내구성 있는 SSH 세션을 지원하며, 자동 재연결 기능을 제공합니다. 내장 그래픽 에디터로 원격 파일을 편집하고, 터미널을 벗어나지 않고도 파일을 인라인으로 미리볼 수 있습니다.\n\n![WaveTerm Screenshot](./assets/wave-screenshot.webp)\n\n## 주요 기능\n\n- Wave AI - 터미널 출력과 위젯을 이해하고 파일 작업까지 수행할 수 있는 컨텍스트 인지형 터미널 어시스턴트\n- 내구성 있는 SSH 세션 - 연결 끊김, 네트워크 변경, Wave 재시작 상황에서도 자동 재연결로 세션 유지\n- 터미널 블록, 에디터, 웹 브라우저, AI 어시스턴트를 유연하게 배치할 수 있는 드래그 앤 드롭 인터페이스\n- 구문 강조와 최신 편집 기능을 제공하는 원격 파일 편집용 내장 에디터\n- 원격 파일용 풍부한 미리보기 시스템 (Markdown, 이미지, 동영상, PDF, CSV, 디렉터리)\n- 블록 단위 빠른 전체 화면 토글 - 터미널/에디터/미리보기를 크게 보고 즉시 멀티 블록 보기로 복귀\n- 다중 모델을 지원하는 AI 채팅 위젯 (OpenAI, Claude, Azure, Perplexity, Ollama)\n- 개별 명령을 분리하고 모니터링할 수 있는 Command Blocks\n- 한 번의 클릭으로 원격 연결 및 전체 터미널/파일 시스템 접근\n- 네이티브 시스템 백엔드를 사용하는 안전한 시크릿 저장 - API 키와 자격 증명을 로컬에 저장하고 SSH 세션 간 공유\n- 탭 테마, 터미널 스타일, 배경 이미지 등 폭넓은 커스터마이징\n- CLI에서 워크스페이스를 제어하고 세션 간 데이터를 공유하는 강력한 `wsh` 명령 시스템\n- `wsh file`을 통한 연결형 파일 관리 - 로컬과 원격 SSH 호스트 간 파일 복사/동기화\n\n## Wave AI\n\nWave AI는 워크스페이스 맥락을 이해하는 터미널 어시스턴트입니다.\n\n- **터미널 컨텍스트**: 디버깅과 분석을 위해 터미널 출력과 스크롤백을 읽습니다.\n- **파일 작업**: 자동 백업 및 사용자 승인 기반으로 파일 읽기/쓰기/편집을 수행합니다.\n- **CLI 통합**: `wsh ai`로 명령줄에서 출력 파이프 연결 또는 파일 첨부가 가능합니다.\n- **BYOK 지원**: OpenAI, Claude, Gemini, Azure 등 다양한 제공자에 API 키를 직접 연결할 수 있습니다.\n- **로컬 모델**: Ollama, LM Studio 및 기타 OpenAI 호환 제공자를 통해 로컬 모델을 실행할 수 있습니다.\n- **무료 베타**: 경험 개선 기간 동안 AI 크레딧이 제공됩니다.\n- **곧 제공 예정**: 명령 실행 기능 (사용자 승인 기반)\n\n자세한 내용은 [Wave AI 문서](https://docs.waveterm.dev/waveai)와 [Wave AI Modes 문서](https://docs.waveterm.dev/waveai-modes)를 참고하세요.\n\n## 설치\n\nWave Terminal은 macOS, Linux, Windows에서 동작합니다.\n\n플랫폼별 설치 방법은 [여기](https://docs.waveterm.dev/gettingstarted)에서 확인할 수 있습니다.\n\n직접 다운로드하여 설치하려면 [www.waveterm.dev/download](https://www.waveterm.dev/download)을 이용하세요.\n\n### 최소 요구 사항\n\nWave Terminal은 다음 플랫폼에서 실행됩니다.\n\n- macOS 11 이상 (arm64, x64)\n- Windows 10 1809 이상 (x64)\n- glibc-2.28 이상 기반 Linux (Debian 10, RHEL 8, Ubuntu 20.04 등) (arm64, x64)\n\nWSH 헬퍼는 다음 플랫폼에서 실행됩니다.\n\n- macOS 11 이상 (arm64, x64)\n- Windows 10 이상 (x64)\n- Linux Kernel 2.6.32 이상 (x64), Linux Kernel 3.1 이상 (arm64)\n\n## 로드맵\n\nWave는 계속 발전하고 있습니다. 로드맵은 릴리스 목표에 맞춰 지속적으로 업데이트됩니다. [여기](./ROADMAP.md)에서 확인하세요.\n\n향후 릴리스 방향에 의견을 주고 싶다면 [Discord](https://discord.gg/XfvZ334gwU)에 참여하거나 [Feature Request](https://github.com/wavetermdev/waveterm/issues/new/choose)를 등록해 주세요.\n\n## 링크\n\n- 홈페이지 &mdash; https://www.waveterm.dev\n- 다운로드 페이지 &mdash; https://www.waveterm.dev/download\n- 문서 &mdash; https://docs.waveterm.dev\n- X &mdash; https://x.com/wavetermdev\n- Discord 커뮤니티 &mdash; https://discord.gg/XfvZ334gwU\n\n## 소스에서 빌드\n\n[Building Wave Terminal](BUILD.md)을 참고하세요.\n\n## 기여하기\n\nWave는 GitHub Issues를 이슈 추적에 사용합니다.\n\n[기여 가이드](CONTRIBUTING.md)에서 더 많은 정보를 확인할 수 있습니다.\n\n- [기여 방법](CONTRIBUTING.md#contributing-to-wave-terminal)\n- [기여 가이드라인](CONTRIBUTING.md#high-level-expectations)\n\n## 라이선스\n\nWave Terminal은 Apache-2.0 라이선스를 따릅니다. 의존성 정보는 [여기](./ACKNOWLEDGEMENTS.md)에서 확인할 수 있습니다.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <a href=\"https://www.waveterm.dev\">\n\t<picture>\n\t\t<source media=\"(prefers-color-scheme: dark)\" srcset=\"./assets/wave-dark.png\">\n\t\t<source media=\"(prefers-color-scheme: light)\" srcset=\"./assets/wave-light.png\">\n\t\t<img alt=\"Wave Terminal Logo\" src=\"./assets/wave-light.png\" width=\"240\">\n\t</picture>\n  </a>\n  <br/>\n</p>\n\n# Wave Terminal\n\n<div align=\"center\">\n\n[English](README.md) | [한국어](README.ko.md)\n\n</div>\n\n[![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)\n\nWave 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.\n\nWave 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.\n\n![WaveTerm Screenshot](./assets/wave-screenshot.webp)\n\n## Key Features\n\n- Wave AI - Context-aware terminal assistant that reads your terminal output, analyzes widgets, and performs file operations\n- Durable SSH Sessions - Remote terminal sessions survive connection interruptions, network changes, and Wave restarts with automatic reconnection\n- Flexible drag & drop interface to organize terminal blocks, editors, web browsers, and AI assistants\n- Built-in editor for editing remote files with syntax highlighting and modern editor features\n- Rich file preview system for remote files (markdown, images, video, PDFs, CSVs, directories)\n- Quick full-screen toggle for any block - expand terminals, editors, and previews for better visibility, then instantly return to multi-block view\n- AI chat widget with support for multiple models (OpenAI, Claude, Azure, Perplexity, Ollama)\n- Command Blocks for isolating and monitoring individual commands\n- One-click remote connections with full terminal and file system access\n- Secure secret storage using native system backends - store API keys and credentials locally, access them across SSH sessions\n- Rich customization including tab themes, terminal styles, and background images\n- Powerful `wsh` command system for managing your workspace from the CLI and sharing data between terminal sessions\n- Connected file management with `wsh file` - seamlessly copy and sync files between local and remote SSH hosts\n\n## Wave AI\n\nWave AI is your context-aware terminal assistant with access to your workspace:\n\n- **Terminal Context**: Reads terminal output and scrollback for debugging and analysis\n- **File Operations**: Read, write, and edit files with automatic backups and user approval\n- **CLI Integration**: Use `wsh ai` to pipe output or attach files directly from the command line\n- **BYOK Support**: Bring your own API keys for OpenAI, Claude, Gemini, Azure, and other providers\n- **Local Models**: Run local models with Ollama, LM Studio, and other OpenAI-compatible providers\n- **Free Beta**: Included AI credits while we refine the experience\n- **Coming Soon**: Command execution (with approval)\n\nLearn more in our [Wave AI documentation](https://docs.waveterm.dev/waveai) and [Wave AI Modes documentation](https://docs.waveterm.dev/waveai-modes).\n\n## Installation\n\nWave Terminal works on macOS, Linux, and Windows.\n\nPlatform-specific installation instructions can be found [here](https://docs.waveterm.dev/gettingstarted).\n\nYou can also install Wave Terminal directly from: [www.waveterm.dev/download](https://www.waveterm.dev/download).\n\n### Minimum requirements\n\nWave Terminal runs on the following platforms:\n\n- macOS 11 or later (arm64, x64)\n- Windows 10 1809 or later (x64)\n- Linux based on glibc-2.28 or later (Debian 10, RHEL 8, Ubuntu 20.04, etc.) (arm64, x64)\n\nThe WSH helper runs on the following platforms:\n\n- macOS 11 or later (arm64, x64)\n- Windows 10 or later (x64)\n- Linux Kernel 2.6.32 or later (x64), Linux Kernel 3.1 or later (arm64)\n\n## Roadmap\n\nWave is constantly improving! Our roadmap will be continuously updated with our goals for each release. You can find it [here](./ROADMAP.md).\n\nWant 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)!\n\n## Links\n\n- Homepage &mdash; https://www.waveterm.dev\n- Download Page &mdash; https://www.waveterm.dev/download\n- Documentation &mdash; https://docs.waveterm.dev\n- X &mdash; https://x.com/wavetermdev\n- Discord Community &mdash; https://discord.gg/XfvZ334gwU\n\n## Building from Source\n\nSee [Building Wave Terminal](BUILD.md).\n\n## Contributing\n\nWave uses GitHub Issues for issue tracking.\n\nFind more information in our [Contributions Guide](CONTRIBUTING.md), which includes:\n\n- [Ways to contribute](CONTRIBUTING.md#contributing-to-wave-terminal)\n- [Contribution guidelines](CONTRIBUTING.md#before-you-start)\n\n### Sponsoring Wave ❤️\n\nIf Wave Terminal is useful to you or your company, consider sponsoring development.\n\nSponsorship helps support the time spent building and maintaining the project.\n\n- https://github.com/sponsors/wavetermdev\n\n## License\n\nWave Terminal is licensed under the Apache-2.0 License. For more information on our dependencies, see [here](./ACKNOWLEDGEMENTS.md).\n"
  },
  {
    "path": "RELEASES.md",
    "content": "# Building for release\n\n## Step-by-step guide\n\n1. Go to the [Actions tab](https://github.com/wavetermdev/waveterm/actions) and select \"Bump Version\" from the left sidebar.\n2. Click on \"Run workflow\".\n   - You will see two options:\n     - \"SemVer Bump\": This defaults to `none`. Adjust this if you want to increment the version number according to semantic versioning rules (`patch`, `minor`, `major`).\n     - \"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.\n   - Some examples:\n     - 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`.\n     - 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`.\n     - If you are promoting a prerelease version to an official release, you would set \"SemVer Bump\" to `none` and \"Is Prerelease\" to `false`.\n3. 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.\n4. Review the artifacts in the release and test them locally.\n5. When you are confident that the build is good, edit the GitHub Release to add a changelog and release summary and publish the release.\n6. 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.\n\n## Details\n\n### Bump Version workflow\n\nAll releases start by first bumping the package version and creating a new Git tag. We have a workflow set up to automate this.\n\nTo 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.\n\nSee [`version.cjs`](./version.cjs) for more details on how this works.\n\nOnce the tag has been created, a new [Build Helper](#build-helper-workflow) run will be automatically queued to generate the artifacts.\n\n### Build Helper workflow\n\nOur release builds are managed by the [Build Helper workflow](https://github.com/wavetermdev/waveterm/actions/workflows/build-helper.yml).\n\nUnder 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).\n\nThis will also sign and notarize the macOS app packages and sign the Windows packages.\n\nOnce a build is complete, the artifacts will be placed in `s3://waveterm-github-artifacts/staging-w2/<version>`. A new draft release will be created on GitHub and the artifacts will be uploaded there too.\n\n### Testing new releases\n\nThe [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.\n\nYou 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:\n\n```bash\ntask artifacts:download:<version> -- --profile <aws-profile>\n```\n\n### Publishing a release\n\nOnce 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.\n\n### Automatic updates\n\nThanks 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).\n\nWith 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.\n\n#### Update channels\n\nWe 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.\n\n### Package Managers\n\nWe currently publish to Homebrew (macOS), WinGet (Windows), Chocolatey (Windows), and Snap (Linux or macOS).\n\n#### Homebrew\n\nHomebrew 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.\n\n#### WinGet\n\nWinGet 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.\n\n#### Chocolatey\n\nChocolatey 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.\n\n#### Snap\n\nSnap 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.\n\n### `electron-build` configuration\n\nMost 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).\n\nWe also exclude most of our `node_modules` from packaging, as Vite handles packaging of any dependencies for us. The one exception is `monaco-editor`.\n"
  },
  {
    "path": "ROADMAP.md",
    "content": "# Wave Terminal Roadmap\n\nThis roadmap outlines major upcoming features and improvements for Wave Terminal. As with any roadmap, priorities and timelines may shift as development progresses.\n\nWant input on the roadmap? Join the discussion on [Discord](https://discord.gg/XfvZ334gwU).\n\nLegend: ✅ Done | 🔧 In Progress | 🔷 Planned | 🤞 Stretch Goal\n\n## Current AI Capabilities\n\nWave Terminal's AI assistant is already powerful and continues to evolve. Here's what works today:\n\n### AI Provider Support\n\n- ✅ OpenAI (including gpt-5 and gpt-5-mini models)\n- ✅ Google Gemini (v0.13)\n- ✅ OpenRouter and custom OpenAI-compatible endpoints (v0.13)\n- ✅ Azure OpenAI (modern and legacy APIs) (v0.13)\n- ✅ Local AI models via Ollama, LM Studio, vLLM, and other OpenAI-compatible servers (v0.13)\n\n### Context & Input\n\n- ✅ Widget context integration - AI sees your open terminals, web views, and other widgets\n- ✅ Image and document upload - Attach images and files to conversations\n- ✅ Local file reading - Read text files and directory listings on local machine\n- ✅ Web search - Native web search capability for current information\n- ✅ Shell integration awareness - AI understands terminal state (shell, version, OS, etc.)\n\n### Widget Interaction Tools\n\n- ✅ Widget screenshots - Capture visual state of any widget\n- ✅ Terminal scrollback access - Read terminal history and output\n- ✅ Web navigation - Control browser widgets\n\n## ROADMAP Enhanced AI Capabilities\n\n### AI Configuration & Flexibility\n\n- ✅ BYOK (Bring Your Own Key) - Use your own API keys for any supported provider (v0.13)\n- ✅ Local AI agents - Run AI models locally on your machine (v0.13)\n- 🔧 Enhanced provider configuration options\n- 🔷 Context (add markdown files to give persistent system context)\n\n### Expanded Provider Support\n\n- 🔷 Anthropic Claude - Full integration with extended thinking and tool use\n\n### Advanced AI Tools\n\n#### File Operations\n\n- ✅ AI file writing with intelligent diff previews\n- ✅ Rollback support for AI-made changes\n- 🔷 Multi-file editing workflows\n- 🔷 Safe file modification patterns\n\n#### Terminal Command Execution\n\n- 🔧 Execute commands directly from AI\n- ✅ Intelligent terminal state detection\n- 🔧 Command result capture and parsing\n\n### Remote & Advanced Capabilities\n\n- 🔷 Remote file operations - Read and write files on SSH connections\n- 🔷 Custom AI-powered widgets (Tsunami framework)\n- 🔷 AI Can spawn Wave Blocks\n- 🔷 Drag&Drop from Preview Widgets to Wave AI\n\n### Wave AI Widget Builder\n\n- 🔷 Visual builder for creating custom AI-powered widgets\n- 🔷 Template library for common AI workflows\n- 🔷 Rapid prototyping and iteration tools\n\n## Other Platform & UX Improvements (Non AI)\n\n- 🔷 Import/Export tab layouts and widgets\n- 🔧 Enhanced layout actions (splitting, replacing blocks)\n- 🔷 Extended drag & drop for files/URLs\n- 🔷 Tab templates for quick workspace setup\n- 🔷 Advanced keybinding customization\n  - 🔷 Widget launch shortcuts\n  - 🔷 System keybinding reassignment\n- 🔷 Command Palette\n- 🔷 Monaco Editor theming\n"
  },
  {
    "path": "SECURITY.md",
    "content": "## Reporting Security Issues\n\nTo report vulnerabilities or security concerns, please email us at: [security@commandline.dev](mailto:security@commandline.dev)\n\n**Please do not report security vulnerabilities through public github issues.**"
  },
  {
    "path": "Taskfile.yml",
    "content": "# Copyright 2026, Command Line Inc.\n# SPDX-License-Identifier: Apache-2.0\n\nversion: \"3\"\n\nvars:\n    APP_NAME: \"Wave\"\n    BIN_DIR: \"bin\"\n    VERSION:\n        sh: node version.cjs\n    RMRF: '{{if eq OS \"windows\"}}powershell -NoProfile -NonInteractive Remove-Item -Force -Recurse -ErrorAction SilentlyContinue{{else}}rm -rf{{end}}'\n    DATE: '{{if eq OS \"windows\"}}powershell -NoProfile -NonInteractive Get-Date -UFormat{{else}}date{{end}}'\n    ARTIFACTS_BUCKET: waveterm-github-artifacts/staging-w2\n    RELEASES_BUCKET: dl.waveterm.dev/releases-w2\n    WINGET_PACKAGE: CommandLine.Wave\n\ntasks:\n    electron:dev:\n        desc: Run the Electron application via the Vite dev server (enables hot reloading).\n        cmd: npm run dev\n        aliases:\n            - dev\n        deps:\n            - npm:install\n            - build:backend\n            - build:tsunamiscaffold\n        env:\n            WAVETERM_ENVFILE: \"{{.ROOT_DIR}}/.env\"\n            WCLOUD_PING_ENDPOINT: \"https://ping-dev.waveterm.dev/central\"\n            WCLOUD_ENDPOINT: \"https://api-dev.waveterm.dev/central\"\n            WCLOUD_WS_ENDPOINT: \"wss://wsapi-dev.waveterm.dev\"\n            WAVETERM_NOCONFIRMQUIT: \"1\"\n\n    electron:start:\n        desc: Run the Electron application directly.\n        cmd: npm run start\n        aliases:\n            - start\n        deps:\n            - npm:install\n            - build:backend\n        env:\n            WAVETERM_ENVFILE: \"{{.ROOT_DIR}}/.env\"\n            WCLOUD_PING_ENDPOINT: \"https://ping-dev.waveterm.dev/central\"\n            WCLOUD_ENDPOINT: \"https://api-dev.waveterm.dev/central\"\n            WCLOUD_WS_ENDPOINT: \"wss://wsapi-dev.waveterm.dev\"\n\n    electron:quickdev:\n        desc: Run the Electron application via the Vite dev server (quick dev - no docsite, arm64 only, no generate, no wsh).\n        cmd: npm run dev\n        deps:\n            - npm:install\n            - build:backend:quickdev\n        env:\n            WAVETERM_ENVFILE: \"{{.ROOT_DIR}}/.env\"\n            WCLOUD_PING_ENDPOINT: \"https://ping-dev.waveterm.dev/central\"\n            WCLOUD_ENDPOINT: \"https://api-dev.waveterm.dev/central\"\n            WCLOUD_WS_ENDPOINT: \"wss://wsapi-dev.waveterm.dev/\"\n            WAVETERM_NOCONFIRMQUIT: \"1\"\n\n    preview:\n        desc: Run the standalone component preview server with HMR (no Electron, no backend).\n        dir: frontend/preview\n        cmd: npx vite\n        deps:\n            - npm:install\n\n    build:preview:\n        desc: Build the component preview server for static deployment.\n        dir: frontend/preview\n        cmd: npx vite build\n        deps:\n            - npm:install\n\n    electron:winquickdev:\n        desc: Run the Electron application via the Vite dev server (quick dev - Windows amd64 only, no generate, no wsh).\n        cmd: npm run dev\n        deps:\n            - npm:install\n            - build:backend:quickdev:windows\n        env:\n            WAVETERM_ENVFILE: \"{{.ROOT_DIR}}/.env\"\n            WCLOUD_PING_ENDPOINT: \"https://ping-dev.waveterm.dev/central\"\n            WCLOUD_ENDPOINT: \"https://api-dev.waveterm.dev/central\"\n            WCLOUD_WS_ENDPOINT: \"wss://wsapi-dev.waveterm.dev/\"\n\n    docs:npm:install:\n        desc: Runs `npm install` in docs directory\n        internal: true\n        generates:\n            - docs/node_modules/**/*\n            - docs/package-lock.json\n        sources:\n            - docs/package-lock.json\n            - docs/package.json\n        cmd: npm install\n        dir: docs\n\n    docsite:start:\n        desc: Start the docsite dev server.\n        cmd: npm run start\n        dir: docs\n        aliases:\n            - docsite\n        deps:\n            - docs:npm:install\n\n    docsite:build:public:\n        desc: Build the full docsite.\n        cmds:\n            - cd docs && npm run build\n        env:\n            USE_SIMPLE_CSS_MINIFIER: \"true\"\n        sources:\n            - \"docs/*\"\n            - \"docs/src/**/*\"\n            - \"docs/docs/**/*\"\n            - \"docs/static/**/*\"\n        generates:\n            - \"docs/build/**/*\"\n        deps:\n            - docs:npm:install\n\n    package:\n        desc: Package the application for the current platform.\n        cmds:\n            - npm run build:prod && npm exec electron-builder -- -c electron-builder.config.cjs -p never {{.CLI_ARGS}}\n        deps:\n            - clean\n            - npm:install\n            - build:backend\n            - build:tsunamiscaffold\n\n    build:frontend:dev:\n        desc: Build the frontend in development mode.\n        cmd: npm run build:dev\n        deps:\n            - npm:install\n\n    build:backend:\n        desc: Build the wavesrv and wsh components.\n        cmds:\n            - task: build:server\n            - task: build:wsh\n\n    build:backend:quickdev:\n        desc: Build only the wavesrv component for quickdev (arm64 macOS only, no generate, no wsh).\n        cmds:\n            - task: build:server:quickdev\n        sources:\n            - go.mod\n            - go.sum\n            - pkg/**/*.go\n            - pkg/**/*.sh\n            - cmd/**/*.go\n            - tsunami/go.mod\n            - tsunami/go.sum\n            - tsunami/**/*.go\n\n    build:schema:\n        desc: Build the schema for configuration.\n        sources:\n            - \"cmd/generateschema/*.go\"\n            - \"pkg/wconfig/*.go\"\n        generates:\n            - \"dist/schema/**/*\"\n        cmds:\n            - go run cmd/generateschema/main-generateschema.go\n            - cmd: '{{.RMRF}} \"dist/schema\"'\n              ignore_error: true\n            - task: copyfiles:'schema':'dist/schema'\n\n    build:server:\n        desc: Build the wavesrv component.\n        cmds:\n            - task: build:server:linux\n            - task: build:server:macos\n            - task: build:server:windows\n        deps:\n            - go:mod:tidy\n            - generate\n        sources:\n            - \"cmd/server/*.go\"\n            - \"pkg/**/*.go\"\n            - \"pkg/**/*.json\"\n            - \"pkg/**/*.sh\"\n            - tsunami/**/*.go\n        generates:\n            - dist/bin/wavesrv.*\n\n    build:server:macos:\n        desc: Build the wavesrv component for macOS (Darwin) platforms (generates artifacts for both arm64 and amd64).\n        platforms: [darwin]\n        cmds:\n            - cmd: rm -f dist/bin/wavesrv*\n              ignore_error: true\n            - task: build:server:internal\n              vars:\n                  ARCHS: arm64,amd64\n\n    build:server:quickdev:\n        desc: Build the wavesrv component for quickdev (arm64 macOS only, no generate).\n        platforms: [darwin]\n        cmds:\n            - task: build:server:internal\n              vars:\n                  ARCHS: arm64\n        deps:\n            - go:mod:tidy\n        sources:\n            - \"cmd/server/*.go\"\n            - \"pkg/**/*.go\"\n            - \"pkg/**/*.json\"\n            - \"pkg/**/*.sh\"\n            - \"tsunami/**/*.go\"\n        generates:\n            - dist/bin/wavesrv.*\n\n    build:backend:quickdev:windows:\n        desc: Build only the wavesrv component for quickdev (Windows amd64 only, no generate, no wsh).\n        platforms: [windows]\n        cmds:\n            - task: build:server:internal\n              vars:\n                  ARCHS: amd64\n                  GO_ENV_VARS: CC=\"zig cc -target x86_64-windows-gnu\"\n        deps:\n            - go:mod:tidy\n        sources:\n            - \"cmd/server/*.go\"\n            - \"pkg/**/*.go\"\n            - \"pkg/**/*.json\"\n            - \"pkg/**/*.sh\"\n            - \"tsunami/**/*.go\"\n        generates:\n            - dist/bin/wavesrv.x64.exe\n\n    build:server:windows:\n        desc: Build the wavesrv component for Windows platforms (only generates artifacts for the current architecture).\n        platforms: [windows]\n        cmds:\n            - cmd: powershell -NoProfile -NonInteractive -Command \"Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wavesrv*\"\n              ignore_error: true\n            - task: build:server:internal\n              vars:\n                  ARCHS:\n                      sh: echo {{if eq \"arm\" ARCH}}arm64{{else}}{{ARCH}}{{end}}\n                  GO_ENV_VARS:\n                      sh: echo \"{{if eq \"amd64\" ARCH}}CC=\\\"zig cc -target x86_64-windows-gnu\\\"{{else}}CC=\\\"zig cc -target aarch64-windows-gnu\\\"{{end}}\"\n\n    build:server:linux:\n        desc: Build the wavesrv component for Linux platforms (only generates artifacts for the current architecture).\n        platforms: [linux]\n        cmds:\n            - cmd: rm -f dist/bin/wavesrv*\n              ignore_error: true\n            - task: build:server:internal\n              vars:\n                  ARCHS:\n                      sh: echo {{if eq \"arm\" ARCH}}arm64{{else}}{{ARCH}}{{end}}\n                  GO_ENV_VARS:\n                      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}}\"\n\n    build:server:internal:\n        requires:\n            vars:\n                - ARCHS\n        cmd:\n            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\n            for:\n                var: ARCHS\n                split: \",\"\n                as: GOARCH\n        internal: true\n\n    build:wsh:\n        desc: Build the wsh component for all possible targets.\n        cmds:\n            - cmd: rm -f dist/bin/wsh*\n              platforms: [darwin, linux]\n              ignore_error: true\n            - cmd: powershell -NoProfile -NonInteractive -Command \"Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wsh*\"\n              platforms: [windows]\n              ignore_error: true\n            - task: build:wsh:parallel\n        deps:\n            - go:mod:tidy\n            - generate\n        sources:\n            - \"cmd/wsh/**/*.go\"\n            - \"pkg/**/*.go\"\n        generates:\n            - \"dist/bin/wsh*\"\n\n    build:wsh:parallel:\n        deps:\n            - task: build:wsh:internal\n              vars:\n                  GOOS: darwin\n                  GOARCH: arm64\n            - task: build:wsh:internal\n              vars:\n                  GOOS: darwin\n                  GOARCH: amd64\n            - task: build:wsh:internal\n              vars:\n                  GOOS: linux\n                  GOARCH: arm64\n            - task: build:wsh:internal\n              vars:\n                  GOOS: linux\n                  GOARCH: amd64\n            - task: build:wsh:internal\n              vars:\n                  GOOS: linux\n                  GOARCH: mips\n            - task: build:wsh:internal\n              vars:\n                  GOOS: linux\n                  GOARCH: mips64\n            - task: build:wsh:internal\n              vars:\n                  GOOS: windows\n                  GOARCH: amd64\n            - task: build:wsh:internal\n              vars:\n                  GOOS: windows\n                  GOARCH: arm64\n        internal: true\n\n    build:wsh:internal:\n        vars:\n            EXT:\n                sh: echo {{if eq .GOOS \"windows\"}}.exe{{end}}\n            NORMALIZEDARCH:\n                sh: echo {{if eq .GOARCH \"amd64\"}}x64{{else}}{{.GOARCH}}{{end}}\n        requires:\n            vars:\n                - GOOS\n                - GOARCH\n                - VERSION\n        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)\n        internal: true\n\n    build:tsunamiscaffold:\n        desc: Build and copy tsunami scaffold to dist directory.\n        cmds:\n            - cmd: \"{{.RMRF}} dist/tsunamiscaffold\"\n              ignore_error: true\n            - task: copyfiles:'tsunami/frontend/scaffold':'dist/tsunamiscaffold'\n            - 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}}'\n        deps:\n            - tsunami:scaffold\n        sources:\n            - \"tsunami/frontend/dist/**/*\"\n            - \"tsunami/templates/**/*\"\n        generates:\n            - \"dist/tsunamiscaffold/**/*\"\n\n    generate:\n        desc: Generate Typescript bindings for the Go backend.\n        cmds:\n            - go run cmd/generatets/main-generatets.go\n            - go run cmd/generatego/main-generatego.go\n        deps:\n            - build:schema\n        sources:\n            - \"cmd/generatego/*.go\"\n            - \"cmd/generatets/*.go\"\n            - \"pkg/**/*.go\"\n        # don't add generates key (otherwise will always execute)\n\n    outdated:\n        desc: Check for outdated packages using npm-check-updates.\n        cmd: npx npm-check-updates@latest\n\n    version:\n        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.\n        cmd: node version.cjs {{.CLI_ARGS}}\n\n    artifacts:upload:\n        desc: Uploads build artifacts to the staging bucket in S3. To add additional AWS CLI arguments, add them after `--`.\n        vars:\n            ORIGIN: \"make/\"\n            DESTINATION: \"{{.ARTIFACTS_BUCKET}}/{{.VERSION}}\"\n        cmd: aws s3 cp {{.ORIGIN}}/ s3://{{.DESTINATION}}/ --recursive --exclude \"*/*\" --exclude \"builder-*.yml\" {{.CLI_ARGS}}\n\n    artifacts:download:*:\n        desc: Downloads the specified artifacts version from the staging bucket. To add additional AWS CLI arguments, add them after `--`.\n        vars:\n            DL_VERSION: '{{ replace \"v\" \"\" (index .MATCH 0)}}'\n            ORIGIN: \"{{.ARTIFACTS_BUCKET}}/{{.DL_VERSION}}\"\n            DESTINATION: \"artifacts/{{.DL_VERSION}}\"\n        cmds:\n            - '{{.RMRF}} \"{{.DESTINATION}}\"'\n            - aws s3 cp s3://{{.ORIGIN}}/ {{.DESTINATION}}/ --recursive {{.CLI_ARGS}}\n\n    artifacts:publish:*:\n        desc: Publishes the specified artifacts version from the staging bucket to the releases bucket. To add additional AWS CLI arguments, add them after `--`.\n        vars:\n            UP_VERSION: '{{ replace \"v\" \"\" (index .MATCH 0)}}'\n            ORIGIN: \"{{.ARTIFACTS_BUCKET}}/{{.UP_VERSION}}\"\n            DESTINATION: \"{{.RELEASES_BUCKET}}\"\n        cmd: |\n            OUTPUT=$(aws s3 cp s3://{{.ORIGIN}}/ s3://{{.DESTINATION}}/ --recursive {{.CLI_ARGS}})\n\n            for line in $OUTPUT; do\n                PREFIX=${line%%{{.DESTINATION}}*}\n                SUFFIX=${line:${#PREFIX}}\n                if [[ -n \"$SUFFIX\" ]]; then\n                    echo \"https://$SUFFIX\"\n                fi\n            done\n    artifacts:snap:publish:*:\n        desc: Publishes the specified artifacts version to Snapcraft.\n        vars:\n            UP_VERSION: '{{ replace \"v\" \"\" (index .MATCH 0)}}'\n            CHANNEL: '{{if contains \"beta\" .UP_VERSION}}beta{{else}}beta,stable{{end}}'\n        cmd: |\n            echo \"Releasing to channels: [{{.CHANNEL}}]\"\n            for file in waveterm_{{.UP_VERSION}}_*.snap; do\n                echo \"Publishing $file\"\n                snapcraft upload --release={{.CHANNEL}} $file\n                echo \"Finished publishing $file\"\n            done\n\n    artifacts:winget:publish:*:\n        desc: Submits a version bump request to WinGet for the latest release.\n        status:\n            - exit {{if contains \"beta\" .UP_VERSION}}0{{else}}1{{end}}\n        vars:\n            UP_VERSION: '{{ replace \"v\" \"\" (index .MATCH 0)}}'\n        cmd: |\n            wingetcreate update {{.WINGET_PACKAGE}} -s -v {{.UP_VERSION}} -u \"https://{{.RELEASES_BUCKET}}/{{.APP_NAME}}-win32-x64-{{.UP_VERSION}}.msi\" -t {{.GITHUB_TOKEN}}\n\n    dev:installwsh:\n        desc: quick shortcut to rebuild wsh and install for macos arm64\n        requires:\n            vars:\n                - VERSION\n        cmds:\n            - task: build:wsh:internal\n              vars:\n                  GOOS: darwin\n                  GOARCH: arm64\n            - cp dist/bin/wsh-{{.VERSION}}-darwin.arm64 ~/Library/Application\\ Support/waveterm-dev/bin/wsh\n\n    dev:clearconfig:\n        desc: Clear the config directory for waveterm-dev\n        cmd: \"{{.RMRF}} ~/.config/waveterm-dev\"\n\n    dev:cleardata:\n        desc: Clear the data directory for waveterm-dev\n        cmds:\n            - task: dev:cleardata:windows\n            - task: dev:cleardata:linux\n            - task: dev:cleardata:macos\n\n    check:ts:\n        desc: Typecheck TypeScript code (frontend and electron).\n        cmd: npx tsc --noEmit\n        deps:\n            - npm:install\n\n    init:\n        desc: Initialize the project for development.\n        cmds:\n            - npm install\n            - go mod tidy\n            - cd docs && npm install\n\n    dev:cleardata:windows:\n        internal: true\n        platforms: [windows]\n        cmd: '{{.RMRF}} %LOCALAPPDATA%\\waveterm-dev\\Data'\n\n    dev:cleardata:linux:\n        internal: true\n        platforms: [linux]\n        cmd: \"rm -rf ~/.local/share/waveterm-dev\"\n\n    dev:cleardata:macos:\n        internal: true\n        platforms: [darwin]\n        cmd: 'rm -rf ~/Library/Application\\ Support/waveterm-dev'\n\n    npm:install:\n        desc: Runs `npm install`\n        internal: true\n        generates:\n            - node_modules/**/*\n            - package-lock.json\n        sources:\n            - package-lock.json\n            - package.json\n        cmd: npm install\n\n    go:mod:tidy:\n        desc: Runs `go mod tidy`\n        internal: true\n        generates:\n            - go.sum\n        sources:\n            - go.mod\n        cmd: go mod tidy\n\n    copyfiles:*:*:\n        desc: Recursively copy directory and its contents.\n        internal: true\n        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}}'\n\n    clean:\n        desc: clean make/dist directories\n        cmds:\n            - cmd: '{{.RMRF}} \"make\"'\n              ignore_error: true\n            - cmd: '{{.RMRF}} \"dist\"'\n              ignore_error: true\n\n    tsunami:demo:todo:\n        desc: Run the tsunami todo demo application\n        cmd: go run demo/todo/*.go\n        dir: tsunami\n        env:\n            TSUNAMI_LISTENADDR: \"localhost:12026\"\n\n    tsunami:frontend:dev:\n        desc: Run the tsunami frontend vite dev server\n        cmd: npm run dev\n        dir: tsunami/frontend\n\n    tsunami:frontend:build:\n        desc: Build the tsunami frontend\n        cmd: npm run build\n        dir: tsunami/frontend\n\n    tsunami:frontend:devbuild:\n        desc: Build the tsunami frontend in development mode (with source maps and symbols)\n        cmd: npm run build:dev\n        dir: tsunami/frontend\n\n    tsunami:scaffold:\n        desc: Build scaffold for tsunami frontend development\n        deps:\n            - tsunami:frontend:build\n        cmds:\n            - task: tsunami:scaffold:internal\n\n    tsunami:devscaffold:\n        desc: Build scaffold for tsunami frontend development (with source maps and symbols)\n        deps:\n            - tsunami:frontend:devbuild\n        cmds:\n            - task: tsunami:scaffold:internal\n\n    tsunami:scaffold:packagejson:\n        desc: Create package.json for tsunami scaffold using npm commands\n        dir: tsunami/frontend/scaffold\n        cmds:\n            - cmd: rm -f package.json\n              platforms: [darwin, linux]\n              ignore_error: true\n            - cmd: powershell -NoProfile -NonInteractive -Command \"Remove-Item -Force -ErrorAction SilentlyContinue -Path package.json\"\n              platforms: [windows]\n              ignore_error: true\n            - npm --no-workspaces init -y --init-license Apache-2.0\n            - npm pkg set name=tsunami-scaffold\n            - npm pkg delete author\n            - npm pkg set author.name=\"Command Line Inc\"\n            - npm pkg set author.email=\"info@commandline.dev\"\n            - npm --no-workspaces install tailwindcss@4.1.13 @tailwindcss/cli@4.1.13\n\n    tsunami:scaffold:internal:\n        desc: Internal task to create scaffold directory structure\n        internal: true\n        cmds:\n            - task: tsunami:scaffold:internal:unix\n            - task: tsunami:scaffold:internal:windows\n\n    tsunami:scaffold:internal:unix:\n        desc: Internal task to create scaffold directory structure (Unix)\n        dir: tsunami/frontend\n        internal: true\n        platforms: [darwin, linux]\n        cmds:\n            - cmd: \"{{.RMRF}} scaffold\"\n              ignore_error: true\n            - mkdir -p scaffold\n            - cp ../templates/package.json.tmpl scaffold/package.json\n            - cd scaffold && npm install\n            - mv scaffold/node_modules scaffold/nm\n            - cp -r dist scaffold/\n            - mkdir -p scaffold/dist/tw\n            - cp ../templates/*.go.tmpl scaffold/\n            - cp ../templates/tailwind.css scaffold/\n            - cp ../templates/gitignore.tmpl scaffold/.gitignore\n            - cp src/element/*.tsx scaffold/dist/tw/\n            - cp ../ui/*.go scaffold/dist/tw/\n            - cp ../engine/errcomponent.go scaffold/dist/tw/\n\n    tsunami:scaffold:internal:windows:\n        desc: Internal task to create scaffold directory structure (Windows)\n        dir: tsunami/frontend\n        internal: true\n        platforms: [windows]\n        cmds:\n            - cmd: \"{{.RMRF}} scaffold\"\n              ignore_error: true\n            - powershell -NoProfile -NonInteractive New-Item -ItemType Directory -Force -Path scaffold\n            - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/package.json.tmpl -Destination scaffold/package.json\n            - powershell -NoProfile -NonInteractive -Command \"Set-Location scaffold; npm install\"\n            - powershell -NoProfile -NonInteractive Move-Item -Path scaffold/node_modules -Destination scaffold/nm\n            - powershell -NoProfile -NonInteractive Copy-Item -Recurse -Force -Path dist -Destination scaffold/\n            - powershell -NoProfile -NonInteractive New-Item -ItemType Directory -Force -Path scaffold/dist/tw\n            - powershell -NoProfile -NonInteractive Copy-Item -Path '../templates/*.go.tmpl' -Destination scaffold/\n            - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/tailwind.css -Destination scaffold/\n            - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/gitignore.tmpl -Destination scaffold/.gitignore\n            - powershell -NoProfile -NonInteractive Copy-Item -Path 'src/element/*.tsx' -Destination scaffold/dist/tw/\n            - powershell -NoProfile -NonInteractive Copy-Item -Path '../ui/*.go' -Destination scaffold/dist/tw/\n            - powershell -NoProfile -NonInteractive Copy-Item -Path ../engine/errcomponent.go -Destination scaffold/dist/tw/\n\n    tsunami:build:\n        desc: Build the tsunami binary.\n        cmds:\n            - cmd: rm -f bin/tsunami*\n              platforms: [darwin, linux]\n              ignore_error: true\n            - cmd: powershell -NoProfile -NonInteractive -Command \"Remove-Item -Force -ErrorAction SilentlyContinue -Path bin/tsunami*\"\n              platforms: [windows]\n              ignore_error: true\n            - mkdir -p bin\n            - 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\n        sources:\n            - \"tsunami/**/*.go\"\n            - \"tsunami/go.mod\"\n            - \"tsunami/go.sum\"\n        generates:\n            - \"bin/tsunami{{exeExt}}\"\n\n    tsunami:clean:\n        desc: Clean tsunami frontend build artifacts\n        dir: tsunami/frontend\n        cmds:\n            - cmd: \"{{.RMRF}} dist\"\n              ignore_error: true\n            - cmd: \"{{.RMRF}} scaffold\"\n              ignore_error: true\n\n    godoc:\n        desc: Start the Go documentation server for the root module\n        cmd: $(go env GOPATH)/bin/pkgsite -http=:6060\n\n    tsunami:godoc:\n        desc: Start the Go documentation server for the tsunami module\n        cmd: $(go env GOPATH)/bin/pkgsite -http=:6060\n        dir: tsunami\n"
  },
  {
    "path": "aiprompts/aimodesconfig.md",
    "content": "# Wave AI Modes Configuration - Visual Editor Architecture\n\n## Overview\n\nWave 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.\n\n**Key Design Decisions:**\n- Visual editor works on **valid JSON only** - if JSON is invalid, fall back to JSON editor\n- Default modes (`waveai@quick`, `waveai@balanced`, `waveai@deep`) are **read-only** in visual editor\n- Edits modify the **in-memory JSON directly** - changes saved via existing save button\n- Mode keys are **auto-generated** from provider + model or random ID (last 4-6 chars)\n- Secrets use **fixed naming convention** per provider (e.g., `OPENAI_KEY`, `OPENROUTER_KEY`)\n- Quick **inline secret editor** instead of complex secret management\n\n## Current System Architecture\n\n### Data Structure\n\n**Location:** `pkg/wconfig/settingsconfig.go:264-284`\n\n```go\ntype AIModeConfigType struct {\n    // Display Configuration\n    DisplayName        string   `json:\"display:name\"`         // Required\n    DisplayOrder       float64  `json:\"display:order,omitempty\"`\n    DisplayIcon        string   `json:\"display:icon,omitempty\"`\n    DisplayShortDesc   string   `json:\"display:shortdesc,omitempty\"`\n    DisplayDescription string   `json:\"display:description,omitempty\"`\n    \n    // Provider & Model\n    Provider           string   `json:\"ai:provider,omitempty\"`     // wave, google, openrouter, openai, azure, azure-legacy, custom\n    APIType            string   `json:\"ai:apitype\"`                // Required: anthropic-messages, openai-responses, openai-chat\n    Model              string   `json:\"ai:model\"`                  // Required\n    \n    // AI Behavior\n    ThinkingLevel      string   `json:\"ai:thinkinglevel,omitempty\"` // low, medium, high\n    Capabilities       []string `json:\"ai:capabilities,omitempty\"`  // pdfs, images, tools\n    \n    // Connection Details\n    Endpoint           string   `json:\"ai:endpoint,omitempty\"`\n    APIVersion         string   `json:\"ai:apiversion,omitempty\"`\n    APIToken           string   `json:\"ai:apitoken,omitempty\"`\n    APITokenSecretName string   `json:\"ai:apitokensecretname,omitempty\"`\n    \n    // Azure-Specific\n    AzureResourceName  string   `json:\"ai:azureresourcename,omitempty\"`\n    AzureDeployment    string   `json:\"ai:azuredeployment,omitempty\"`\n    \n    // Wave AI Specific\n    WaveAICloud        bool     `json:\"waveai:cloud,omitempty\"`\n    WaveAIPremium      bool     `json:\"waveai:premium,omitempty\"`\n}\n```\n\n**Storage:** `FullConfigType.WaveAIModes` - `map[string]AIModeConfigType`\n\nKeys follow pattern: `provider@modename` (e.g., `waveai@quick`, `openai@gpt4`)\n\n### Provider Types & Defaults\n\n**Defined in:** `pkg/aiusechat/uctypes/uctypes.go:27-35`\n\n1. **wave** - Wave AI Cloud service\n   - Auto-sets: `waveai:cloud = true`, endpoint from env or default\n   - Default endpoint: `https://cfapi.waveterm.dev/api/waveai`\n   - Used for Wave's hosted AI modes\n\n2. **openai** - OpenAI API\n   - Auto-sets: endpoint `https://api.openai.com/v1`\n   - Auto-detects API type based on model:\n     - Legacy models (gpt-4o, gpt-3.5): `openai-chat`\n     - New models (gpt-5*, gpt-4.1*, o1*, o3*): `openai-responses`\n\n3. **openrouter** - OpenRouter service\n   - Auto-sets: endpoint `https://openrouter.ai/api/v1`, API type `openai-chat`\n\n4. **google** - Google AI (Gemini, etc.)\n   - No auto-defaults currently\n\n5. **azure** - Azure OpenAI (new unified API)\n   - Auto-sets: API version `v1`, endpoint from resource name\n   - Endpoint pattern: `https://{resource}.openai.azure.com/openai/v1/{responses|chat/completions}`\n   - Auto-detects API type based on model\n\n6. **azure-legacy** - Azure OpenAI (legacy chat completions)\n   - Auto-sets: API version `2025-04-01-preview`, API type `openai-chat`\n   - Endpoint pattern: `https://{resource}.openai.azure.com/openai/deployments/{deployment}/chat/completions?api-version={version}`\n   - Requires `AzureResourceName` and `AzureDeployment`\n\n7. **custom** - Custom provider\n   - No auto-defaults\n   - User must specify all fields manually\n\n### Default Configuration\n\n**Location:** `pkg/wconfig/defaultconfig/waveai.json`\n\nShips with three Wave AI modes:\n- `waveai@quick` - Fast responses (gpt-5-mini, low thinking)\n- `waveai@balanced` - Balanced (gpt-5.1, low thinking) [premium]\n- `waveai@deep` - Maximum capability (gpt-5.1, medium thinking) [premium]\n\n### Current UI State\n\n**Location:** `frontend/app/view/waveconfig/waveaivisual.tsx`\n\nCurrently shows placeholder: \"Visual editor coming soon...\"\n\nThe component receives:\n- `model: WaveConfigViewModel` - Access to config file operations\n- Existing patterns from `SecretsContent` for list/detail views\n\n## Visual Editor Design Plan\n\n### High-Level Architecture\n\n```\n┌─────────────────────────────────────────────────────────┐\n│  Wave AI Modes Configuration                            │\n│  ┌───────────────┐  ┌──────────────────────────────┐   │\n│  │               │  │                              │   │\n│  │  Mode List    │  │    Mode Editor/Viewer        │   │\n│  │               │  │                              │   │\n│  │  [Quick]      │  │  Provider: [wave ▼]         │   │\n│  │  [Balanced]   │  │                              │   │\n│  │  [Deep]       │  │  Display Configuration       │   │\n│  │  [Custom]     │  │  ├─ Name: ...                │   │\n│  │               │  │  ├─ Icon: ...                │   │\n│  │  [+ Add New]  │  │  └─ Description: ...         │   │\n│  │               │  │                              │   │\n│  │               │  │  Provider Configuration      │   │\n│  │               │  │  (Provider-specific fields)  │   │\n│  │               │  │                              │   │\n│  │               │  │  [Save] [Delete] [Cancel]    │   │\n│  └───────────────┘  └──────────────────────────────┘   │\n└─────────────────────────────────────────────────────────┘\n```\n\n### Component Structure\n\n```typescript\nWaveAIVisualContent\n├─ ModeList (left panel)\n│  ├─ Header with \"Add New Mode\" button\n│  ├─ List of existing modes (sorted by display:order)\n│  │  └─ ModeListItem (icon, name, short desc, provider badge)\n│  └─ Empty state if no modes\n│\n└─ ModeEditor (right panel)\n   ├─ Provider selector dropdown (when creating/editing)\n   ├─ Display section (common to all providers)\n   │  ├─ Name input (required)\n   │  ├─ Icon picker (optional)\n   │  ├─ Display order (optional, number)\n   │  ├─ Short description (optional)\n   │  └─ Description textarea (optional)\n   │\n   ├─ Provider Configuration section (dynamic based on provider)\n   │  └─ [Provider-specific form fields]\n   │\n   └─ Action buttons (Save, Delete, Cancel)\n```\n\n### Provider-Specific Form Fields\n\n#### 1. Wave Provider (`wave`)\n**Read-only/Auto-managed:**\n- Endpoint (shows default or env override)\n- Cloud flag (always true)\n- Secret: Not applicable (managed by Wave)\n\n**User-configurable:**\n- Model (required, text input with suggestions: gpt-5-mini, gpt-5.1)\n- API Type (required, dropdown: openai-responses, openai-chat)\n- Thinking Level (optional, dropdown: low, medium, high)\n- Capabilities (optional, checkboxes: tools, images, pdfs)\n- Premium flag (checkbox)\n\n#### 2. OpenAI Provider (`openai`)\n**Auto-managed:**\n- Endpoint (shows: api.openai.com/v1)\n- API Type (auto-detected from model, editable)\n- Secret Name: Fixed as `OPENAI_KEY`\n\n**User-configurable:**\n- Model (required, text input with suggestions: gpt-4o, gpt-5-mini, gpt-5.1, o1-preview)\n- API Key (via secret modal - see Secret Management below)\n- Thinking Level (optional)\n- Capabilities (optional)\n\n#### 3. OpenRouter Provider (`openrouter`)\n**Auto-managed:**\n- Endpoint (shows: openrouter.ai/api/v1)\n- API Type (always openai-chat)\n- Secret Name: Fixed as `OPENROUTER_KEY`\n\n**User-configurable:**\n- Model (required, text input - OpenRouter model format)\n- API Key (via secret modal)\n- Thinking Level (optional)\n- Capabilities (optional)\n\n#### 4. Azure Provider (`azure`)\n**Auto-managed:**\n- API Version (always v1)\n- Endpoint (computed from resource name)\n- API Type (auto-detected from model)\n- Secret Name: Fixed as `AZURE_KEY`\n\n**User-configurable:**\n- Azure Resource Name (required, validated format)\n- Model (required)\n- API Key (via secret modal)\n- Thinking Level (optional)\n- Capabilities (optional)\n\n#### 5. Azure Legacy Provider (`azure-legacy`)\n**Auto-managed:**\n- API Version (default: 2025-04-01-preview, editable)\n- API Type (always openai-chat)\n- Endpoint (computed from resource + deployment + version)\n- Secret Name: Fixed as `AZURE_KEY`\n\n**User-configurable:**\n- Azure Resource Name (required, validated)\n- Azure Deployment (required)\n- Model (required)\n- API Key (via secret modal)\n- Thinking Level (optional)\n- Capabilities (optional)\n\n#### 6. Google Provider (`google`)\n**Auto-managed:**\n- Secret Name: Fixed as `GOOGLE_KEY`\n\n**User-configurable:**\n- Model (required)\n- API Type (required dropdown)\n- Endpoint (required)\n- API Key (via secret modal)\n- API Version (optional)\n- Thinking Level (optional)\n- Capabilities (optional)\n\n#### 7. Custom Provider (`custom`)\n**User must specify everything:**\n- Model (required)\n- API Type (required dropdown)\n- Endpoint (required)\n- Secret Name (required text input - user defines their own secret name)\n- API Key (via secret modal using custom secret name)\n- API Version (optional)\n- Thinking Level (optional)\n- Capabilities (optional)\n- Azure Resource Name (optional)\n- Azure Deployment (optional)\n\n### Data Flow\n\n```\nLoad JSON → Parse → Render Visual Editor\n              ↓\n      User Edits Mode → Update fileContentAtom (JSON string)\n              ↓\n      Click Save → Existing save logic validates & writes\n```\n\n**Simplified Operations:**\n1. **Load:** Parse `fileContentAtom` JSON string into mode objects for display\n2. **Edit Mode:** Update parsed object → stringify → set `fileContentAtom` → marks as edited\n3. **Add Mode:**\n   - Generate unique key from provider/model or random ID\n   - Add new mode to parsed object → stringify → set `fileContentAtom`\n4. **Delete Mode:** Remove key from parsed object → stringify → set `fileContentAtom`\n5. **Save:** Existing `model.saveFile()` handles validation and write\n\n**Mode Key Generation:**\n```typescript\nfunction generateModeKey(provider: string, model: string): string {\n    // Try semantic key first: provider@model-sanitized\n    const sanitized = model.toLowerCase()\n        .replace(/[^a-z0-9]/g, '-')\n        .replace(/-+/g, '-')\n        .replace(/^-|-$/g, '');\n    const semanticKey = `${provider}@${sanitized}`;\n    \n    // Check for collision, if exists append random suffix\n    if (existingModes[semanticKey]) {\n        const randomId = crypto.randomUUID().slice(-6);\n        return `${provider}@${sanitized}-${randomId}`;\n    }\n    return semanticKey;\n}\n// Examples: openai@gpt-4o, openrouter@claude-3-5-sonnet, azure@custom-fb4a2c\n```\n\n**Secret Naming Convention:**\n```typescript\n// Fixed secret names per provider (except custom)\nconst SECRET_NAMES = {\n    openai: \"OPENAI_KEY\",\n    openrouter: \"OPENROUTER_KEY\",\n    azure: \"AZURE_KEY\",\n    \"azure-legacy\": \"AZURE_KEY\",\n    google: \"GOOGLE_KEY\",\n    // custom provider: user specifies their own secret name\n} as const;\n\nfunction getSecretName(provider: string, customSecretName?: string): string {\n    if (provider === \"custom\") {\n        return customSecretName || \"CUSTOM_API_KEY\";\n    }\n    return SECRET_NAMES[provider];\n}\n```\n\n### Secret Management UI\n\n**Secret Status Indicator:**\nDisplay next to API Key field for providers that need one:\n- ✅ Green check icon: Secret exists and is set\n- ⚠️ Warning icon (yellow/orange): Secret not set or empty\n- Click icon to open secret modal\n\n**Secret Modal:**\n```\n┌─────────────────────────────────────┐\n│  Set API Key for OpenAI             │\n│                                     │\n│  Secret Name: OPENAI_KEY            │\n│  [read-only for non-custom]         │\n│                                     │\n│  API Key:                           │\n│  [********************]  [Show/Hide]│\n│                                     │\n│  [Cancel]              [Save]       │\n└─────────────────────────────────────┘\n```\n\n**Modal Behavior:**\n1. **Open Modal:** Click status icon or \"Set API Key\" button\n2. **Show Secret Name:**\n   - Non-custom providers: Read-only, shows fixed name\n   - Custom provider: Editable text input (user specifies)\n3. **API Key Input:**\n   - Masked password field\n   - Show/Hide toggle button\n   - Load existing value if secret already exists\n4. **Save:**\n   - Validates not empty\n   - Calls RPC to set secret\n   - Updates status icon\n5. **Cancel:** Close without changes\n\n**Integration with Mode Editor:**\n- Check secret existence on mode load/select\n- Update icon based on RPC `GetSecretsCommand` result\n- \"Save\" button for mode only saves JSON config\n- Secret is set immediately via modal (separate from JSON save)\n\n### Key Features\n\n#### 1. Mode List\n- Display modes sorted by `display:order` (ascending)\n- Show icon, name, short description\n- Badge showing provider type\n- Highlight Wave AI premium modes\n- Click to edit\n\n#### 2. Add New Mode Flow\n1. Click \"Add New Mode\"\n2. Enter mode key (validated: alphanumeric, @, -, ., _)\n3. Select provider from dropdown\n4. Form dynamically updates to show provider-specific fields\n5. Fill required fields (marked with *)\n6. Save → validates → adds to config → refreshes list\n\n#### 3. Edit Mode Flow\n1. Click mode from list\n2. Load mode data into form\n3. Provider is fixed (show read-only or with warning about changing)\n4. Edit fields\n5. Save → validates → updates config → refreshes list\n\n**Raw JSON Editor Option:**\n- \"Edit Raw JSON\" button in mode editor (available for all modes)\n- Opens modal with Monaco editor showing just this mode's JSON\n- Validates JSON structure before allowing save\n- Useful for:\n  - Modes without a provider field (edge cases)\n  - Advanced users who want precise control\n  - Copying/modifying complex configurations\n- Validation checks:\n  - Valid JSON syntax\n  - Required fields present (`display:name`, `ai:apitype`, `ai:model`)\n  - Enum values valid\n  - Custom error messages for each validation failure\n\n#### 4. Delete Mode Flow\n1. Click mode from list\n2. Delete button in editor\n3. Confirm dialog\n4. Remove from config → save → refresh list\n\n#### 5. Secret Integration\n- For API Token fields, provide two options:\n  - Direct input (text field, masked)\n  - Secret reference (dropdown of existing secrets + link to secrets page)\n- When secret is selected, store name in `ai:apitokensecretname`\n- When direct token, store in `ai:apitoken`\n\n#### 6. Validation\n- **Mode Key:** Must match pattern `^[a-zA-Z0-9_@.-]+$`\n- **Required Fields:** `display:name`, `ai:apitype`, `ai:model`\n- **Azure Resource Name:** Must match `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` (1-63 chars)\n- **Provider:** Must be one of the valid enum values\n- **API Type:** Must be valid enum value\n- **Thinking Level:** Must be low/medium/high if present\n- **Capabilities:** Must be from valid enum (pdfs, images, tools)\n\n#### 7. Smart Defaults\nWhen provider changes or model changes:\n- Show info about what will be auto-configured\n- Display computed endpoint (read-only with info icon)\n- Display auto-detected API type (editable with warning)\n- Pre-fill common values based on provider\n\n### UI Components Needed\n\n#### New Components\n```typescript\n// Main container\nWaveAIVisualContent\n\n// Left panel\nModeList\n├─ ModeListItem (icon, name, provider badge, premium badge, drag handle)\n└─ AddModeButton\n\n// Right panel - viewer\nModeViewer\n├─ ModeHeader (name, icon, actions)\n├─ DisplaySection (read-only view of display fields)\n├─ ProviderSection (read-only view of provider config)\n└─ EditButton\n\n// Right panel - editor\nModeEditor\n├─ ProviderSelector (dropdown, only for new modes)\n├─ DisplayFieldsForm\n├─ ProviderFieldsForm (dynamic based on provider)\n│   ├─ WaveProviderForm\n│   ├─ OpenAIProviderForm\n│   ├─ OpenRouterProviderForm\n│   ├─ AzureProviderForm\n│   ├─ AzureLegacyProviderForm\n│   ├─ GoogleProviderForm\n│   └─ CustomProviderForm\n└─ ActionButtons (Edit Raw JSON, Delete, Cancel)\n\n// Modals\nRawJSONModal\n├─ Title (\"Edit Raw JSON: {mode name}\")\n├─ MonacoEditor (JSON, single mode object)\n├─ ValidationErrors (inline display)\n└─ Actions (Cancel, Save)\n\n// Shared components\nSecretSelector (dropdown + link to secrets)\nInfoTooltip (explains auto-configured fields)\nProviderBadge (visual indicator)\nIconPicker (select from available icons)\nDragHandle (for reordering modes in list)\n```\n\n**Drag & Drop for Reordering:**\n```typescript\n// Reordering updates display:order automatically\nfunction handleModeReorder(draggedKey: string, targetKey: string) {\n    const modes = parseAIModes(fileContent);\n    const modesList = Object.entries(modes)\n        .sort((a, b) => (a[1][\"display:order\"] || 0) - (b[1][\"display:order\"] || 0));\n    \n    // Find indices\n    const draggedIndex = modesList.findIndex(([k]) => k === draggedKey);\n    const targetIndex = modesList.findIndex(([k]) => k === targetKey);\n    \n    // Recalculate display:order for all modes\n    const newOrder = [...modesList];\n    newOrder.splice(draggedIndex, 1);\n    newOrder.splice(targetIndex, 0, modesList[draggedIndex]);\n    \n    // Assign new order values (0, 10, 20, 30...)\n    newOrder.forEach(([key, mode], index) => {\n        modes[key] = { ...mode, \"display:order\": index * 10 };\n    });\n    \n    updateFileContent(JSON.stringify(modes, null, 2));\n}\n```\n\n### Model Extensions (Minimal)\n\n**No new atoms needed!** Visual editor uses existing `fileContentAtom`:\n\n```typescript\n// Use existing atoms from WaveConfigViewModel:\n// - fileContentAtom (contains JSON string)\n// - hasEditedAtom (tracks if modified)\n// - errorMessageAtom (for errors)\n\n// Visual editor parses fileContentAtom on render:\nfunction parseAIModes(jsonString: string): Record<string, AIModeConfigType> | null {\n    try {\n        return JSON.parse(jsonString);\n    } catch {\n        return null; // Show \"invalid JSON\" error\n    }\n}\n\n// Updates modify fileContentAtom:\nfunction updateMode(key: string, mode: AIModeConfigType) {\n    const modes = parseAIModes(globalStore.get(model.fileContentAtom));\n    if (!modes) return;\n    \n    modes[key] = mode;\n    const newJson = JSON.stringify(modes, null, 2);\n    globalStore.set(model.fileContentAtom, newJson);\n    globalStore.set(model.hasEditedAtom, true);\n}\n\n// Secrets use existing model methods:\n// - model.refreshSecrets() - already exists\n// - RpcApi.GetSecretsCommand() - check if secret exists\n// - RpcApi.SetSecretsCommand() - set secret value\n```\n\n**Component State (useState):**\n```typescript\n// In WaveAIVisualContent component:\nconst [selectedModeKey, setSelectedModeKey] = useState<string | null>(null);\nconst [isAddingMode, setIsAddingMode] = useState(false);\nconst [showSecretModal, setShowSecretModal] = useState(false);\nconst [secretModalProvider, setSecretModalProvider] = useState<string>(\"\");\n```\n\n### Implementation Phases\n\n#### Phase 1: Foundation & List View\n- Parse `fileContentAtom` JSON into modes on render\n- Display mode list (left panel, ~300px)\n  - Built-in modes with 🔒 icon at top\n  - Custom modes below\n  - Sort by `display:order`\n- Select mode → show in right panel (empty state initially)\n- Handle invalid JSON → show error, switch to JSON tab\n\n#### Phase 2: Built-in Mode Viewer\n- Click built-in mode → show read-only details\n- Display all fields (display, provider, config)\n- \"Built-in Mode\" badge/banner\n- No edit/delete buttons\n\n#### Phase 3: Custom Mode Editor (Basic)\n- Click custom mode → load into editor form\n- Display fields (name, icon, order, description)\n- Provider field (read-only, badge)\n- Model field (text input)\n- Save → update `fileContentAtom` JSON\n- Cancel → revert to previous selection\n\n#### Phase 4: Provider-Specific Fields\n- Dynamic form based on provider type\n- OpenAI: model, thinking level, capabilities\n- Azure: resource name, model, thinking, capabilities\n- Azure Legacy: resource name, deployment, model\n- OpenRouter: model\n- Google: model, API type, endpoint\n- Custom: everything manual\n- Info tooltips for auto-configured fields\n\n#### Phase 5: Secret Integration\n- Check secret existence on mode select\n- Display status icon (✅ / ⚠️)\n- Click icon → open secret modal\n- Secret modal: fixed name (or custom input), password field\n- Save secret → immediate RPC call\n- Update status icon after save\n\n#### Phase 6: Add New Mode\n- \"Add New Mode\" button\n- Provider dropdown selector\n- Auto-generate mode key from provider + model\n- Form with provider-specific fields\n- Add to modes → update JSON → mark edited\n- Select newly created mode\n\n#### Phase 7: Delete Mode\n- Delete button for custom modes only\n- Simple confirmation dialog\n- Remove from modes → update JSON → deselect\n\n#### Phase 8: Raw JSON Editor\n- \"Edit Raw JSON\" button in mode editor (all modes)\n- Modal with Monaco editor for single mode\n- JSON validation before save:\n  - Syntax check with error highlighting\n  - Required fields check (`display:name`, `ai:apitype`, `ai:model`)\n  - Enum validation (provider, apitype, thinkinglevel, capabilities)\n  - Display specific error messages per validation failure\n- Parse validated JSON and update mode in main JSON\n- Useful for edge cases (modes without provider) and power users\n\n#### Phase 9: Drag & Drop Reordering\n- Add drag handle icon to custom mode list items\n- Implement drag & drop functionality:\n  - Visual feedback during drag (opacity, cursor)\n  - Drop target highlighting\n  - Smooth reordering animation\n- On drop:\n  - Recalculate `display:order` for all affected modes\n  - Use spacing (0, 10, 20, 30...) for easy manual adjustment\n  - Update JSON with new order values\n  - Built-in modes always stay at top (negative order values)\n\n#### Phase 10: Polish & UX Refinements\n- Field validation with inline error messages\n- Empty state when no mode selected\n- Icon picker dropdown (Font Awesome icons)\n- Capabilities checkboxes with descriptions\n- Thinking level dropdown with explanations\n- Help tooltips throughout\n- Keyboard shortcuts (e.g., Ctrl/Cmd+E for raw JSON)\n- Loading states for secret checks\n- Smooth transitions and animations\n\n#### Phase 8: Raw JSON Editor\n- \"Edit Raw JSON\" button in mode editor\n- Modal with Monaco editor for single mode\n- JSON validation before save:\n  - Syntax check\n  - Required fields check\n  - Enum validation\n  - Display specific error messages\n- Parse and update mode in main JSON\n\n#### Phase 9: Drag & Drop Reordering\n- Make mode list items draggable (custom modes only)\n- Visual feedback during drag (drag handle icon)\n- Drop target highlighting\n- On drop:\n  - Calculate new `display:order` values\n  - Maintain spacing between modes\n  - Update all affected modes in JSON\n  - Preserve built-in modes at top\n\n#### Phase 10: Polish & UX Refinements\n- Field validation (required, format)\n- Error messages inline\n- Empty state when no mode selected\n- Icon picker dropdown\n- Capabilities checkboxes\n- Thinking level dropdown\n- Help tooltips throughout\n- Keyboard shortcuts (e.g., Cmd+E for raw JSON)\n\n### Technical Considerations\n\n1. **JSON Sync:** Parse/stringify from `fileContentAtom` on every read/write\n2. **Validation:** Validate on blur or before updating JSON\n3. **Built-in Detection:** Check if key starts with `waveai@` → read-only\n4. **Type Safety:** Use `AIModeConfigType` from gotypes.d.ts\n5. **State Management:**\n   - Model atoms for shared state (`fileContentAtom`, `hasEditedAtom`)\n   - Component useState for UI state (selected mode, modals)\n6. **Error Handling:**\n   - Invalid JSON → show message, disable visual editor\n   - Parse errors → gracefully handle, don't crash\n7. **Performance:**\n   - Parse JSON on mount and when `fileContentAtom` changes externally\n   - Debounce frequent updates if needed\n8. **Secret Checks:**\n   - Load secret existence on mode select\n   - Cache results to avoid repeated RPC calls\n\n### Testing Strategy\n\n1. **Unit Tests:** Validation functions, key generation\n2. **Integration Tests:** Form submission, backend sync\n3. **E2E Tests:** Full add/edit/delete flows\n4. **Provider Tests:** Each provider form with various inputs\n5. **Edge Cases:** Empty config, invalid JSON, malformed data\n\n### Documentation Needs\n\n1. **In-app help:** Tooltips and info bubbles explaining fields\n2. **Provider guides:** What each provider needs, where to get API keys\n3. **Examples:** Show example configurations for common setups\n4. **Troubleshooting:** Common errors and solutions\n\n## Next Steps\n\n1. Create detailed mockups/wireframes\n2. Implement Phase 1 (basic list view)\n3. Add RPC methods if needed for secrets integration\n4. Iterate on provider forms\n5. Polish and ship\n\nThis 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."
  },
  {
    "path": "aiprompts/aisdk-streaming.md",
    "content": "## Data Stream Protocol\n\nA data stream follows a special protocol that the AI SDK provides to send information to the frontend.\n\nThe data stream protocol uses Server-Sent Events (SSE) format for improved standardization, keep-alive through ping, reconnect capabilities, and better cache handling.\n\n<Note>\n  When you provide data streams from a custom backend, you need to set the\n  `x-vercel-ai-ui-message-stream` header to `v1`.\n</Note>\n\nThe following stream parts are currently supported:\n\n### Message Start Part\n\nIndicates the beginning of a new message with metadata.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"start\",\"messageId\":\"...\"}\n\n```\n\n### Text Parts\n\nText content is streamed using a start/delta/end pattern with unique IDs for each text block.\n\n#### Text Start Part\n\nIndicates the beginning of a text block.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"text-start\",\"id\":\"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d\"}\n\n```\n\n#### Text Delta Part\n\nContains incremental text content for the text block.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"text-delta\",\"id\":\"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d\",\"delta\":\"Hello\"}\n\n```\n\n#### Text End Part\n\nIndicates the completion of a text block.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"text-end\",\"id\":\"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d\"}\n\n```\n\n### Reasoning Parts\n\nReasoning content is streamed using a start/delta/end pattern with unique IDs for each reasoning block.\n\n#### Reasoning Start Part\n\nIndicates the beginning of a reasoning block.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"reasoning-start\",\"id\":\"reasoning_123\"}\n\n```\n\n#### Reasoning Delta Part\n\nContains incremental reasoning content for the reasoning block.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"reasoning-delta\",\"id\":\"reasoning_123\",\"delta\":\"This is some reasoning\"}\n\n```\n\n#### Reasoning End Part\n\nIndicates the completion of a reasoning block.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"reasoning-end\",\"id\":\"reasoning_123\"}\n\n```\n\n### Source Parts\n\nSource parts provide references to external content sources.\n\n#### Source URL Part\n\nReferences to external URLs.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"source-url\",\"sourceId\":\"https://example.com\",\"url\":\"https://example.com\"}\n\n```\n\n#### Source Document Part\n\nReferences to documents or files.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"source-document\",\"sourceId\":\"https://example.com\",\"mediaType\":\"file\",\"title\":\"Title\"}\n\n```\n\n### File Part\n\nThe file parts contain references to files with their media type.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"file\",\"url\":\"https://example.com/file.png\",\"mediaType\":\"image/png\"}\n\n```\n\n### Data Parts\n\nCustom data parts allow streaming of arbitrary structured data with type-specific handling.\n\nFormat: Server-Sent Event with JSON object where the type includes a custom suffix\n\nExample:\n\n```\ndata: {\"type\":\"data-weather\",\"data\":{\"location\":\"SF\",\"temperature\":100}}\n\n```\n\nThe `data-*` type pattern allows you to define custom data types that your frontend can handle specifically.\n\n### Error Part\n\nThe error parts are appended to the message as they are received.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"error\",\"errorText\":\"error message\"}\n\n```\n\n### Tool Input Start Part\n\nIndicates the beginning of tool input streaming.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"tool-input-start\",\"toolCallId\":\"call_fJdQDqnXeGxTmr4E3YPSR7Ar\",\"toolName\":\"getWeatherInformation\"}\n\n```\n\n### Tool Input Delta Part\n\nIncremental chunks of tool input as it's being generated.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"tool-input-delta\",\"toolCallId\":\"call_fJdQDqnXeGxTmr4E3YPSR7Ar\",\"inputTextDelta\":\"San Francisco\"}\n\n```\n\n### Tool Input Available Part\n\nIndicates that tool input is complete and ready for execution.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"tool-input-available\",\"toolCallId\":\"call_fJdQDqnXeGxTmr4E3YPSR7Ar\",\"toolName\":\"getWeatherInformation\",\"input\":{\"city\":\"San Francisco\"}}\n\n```\n\n### Tool Output Available Part\n\nContains the result of tool execution.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"tool-output-available\",\"toolCallId\":\"call_fJdQDqnXeGxTmr4E3YPSR7Ar\",\"output\":{\"city\":\"San Francisco\",\"weather\":\"sunny\"}}\n\n```\n\n### Start Step Part\n\nA part indicating the start of a step.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"start-step\"}\n\n```\n\n### Finish Step Part\n\nA part indicating that a step (i.e., one LLM API call in the backend) has been completed.\n\nThis 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.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"finish-step\"}\n\n```\n\n### Finish Message Part\n\nA part indicating the completion of a message.\n\nFormat: Server-Sent Event with JSON object\n\nExample:\n\n```\ndata: {\"type\":\"finish\"}\n\n```\n\n### Stream Termination\n\nThe stream ends with a special `[DONE]` marker.\n\nFormat: Server-Sent Event with literal `[DONE]`\n\nExample:\n\n```\ndata: [DONE]\n\n```\n"
  },
  {
    "path": "aiprompts/aisdk-uimessage-type.md",
    "content": "# `UIMessage`\n\n`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.\n\n## Type Safety\n\n`UIMessage` is designed to be type-safe and accepts three generic parameters to ensure proper typing throughout your application:\n\n1. **`METADATA`** - Custom metadata type for additional message information\n2. **`DATA_PARTS`** - Custom data part types for structured data components\n3. **`TOOLS`** - Tool definitions for type-safe tool interactions\n\n## Creating Your Own UIMessage Type\n\nHere's an example of how to create a custom typed UIMessage for your application:\n\n```typescript\nimport { InferUITools, ToolSet, UIMessage, tool } from \"ai\";\nimport z from \"zod\";\n\nconst metadataSchema = z.object({\n  someMetadata: z.string().datetime(),\n});\n\ntype MyMetadata = z.infer<typeof metadataSchema>;\n\nconst dataPartSchema = z.object({\n  someDataPart: z.object({}),\n  anotherDataPart: z.object({}),\n});\n\ntype MyDataPart = z.infer<typeof dataPartSchema>;\n\nconst tools = {\n  someTool: tool({}),\n} satisfies ToolSet;\n\ntype MyTools = InferUITools<typeof tools>;\n\nexport type MyUIMessage = UIMessage<MyMetadata, MyDataPart, MyTools>;\n```\n\n## `UIMessage` Interface\n\n```typescript\ninterface UIMessage<METADATA = unknown, DATA_PARTS extends UIDataTypes = UIDataTypes, TOOLS extends UITools = UITools> {\n  /**\n   * A unique identifier for the message.\n   */\n  id: string;\n\n  /**\n   * The role of the message.\n   */\n  role: \"system\" | \"user\" | \"assistant\";\n\n  /**\n   * The metadata of the message.\n   */\n  metadata?: METADATA;\n\n  /**\n   * The parts of the message. Use this for rendering the message in the UI.\n   */\n  parts: Array<UIMessagePart<DATA_PARTS, TOOLS>>;\n}\n```\n\n## `UIMessagePart` Types\n\n### `TextUIPart`\n\nA text part of a message.\n\n```typescript\ntype TextUIPart = {\n  type: \"text\";\n  /**\n   * The text content.\n   */\n  text: string;\n  /**\n   * The state of the text part.\n   */\n  state?: \"streaming\" | \"done\";\n};\n```\n\n### `ReasoningUIPart`\n\nA reasoning part of a message.\n\n```typescript\ntype ReasoningUIPart = {\n  type: \"reasoning\";\n  /**\n   * The reasoning text.\n   */\n  text: string;\n  /**\n   * The state of the reasoning part.\n   */\n  state?: \"streaming\" | \"done\";\n  /**\n   * The provider metadata.\n   */\n  providerMetadata?: Record<string, any>;\n};\n```\n\n### `ToolUIPart`\n\nA tool part of a message that represents tool invocations and their results.\n\n<Note>\n  The type is based on the name of the tool (e.g., `tool-someTool` for a tool\n  named `someTool`).\n</Note>\n\n```typescript\ntype ToolUIPart<TOOLS extends UITools = UITools> = ValueOf<{\n  [NAME in keyof TOOLS & string]: {\n    type: `tool-${NAME}`;\n    toolCallId: string;\n  } & (\n    | {\n        state: \"input-streaming\";\n        input: DeepPartial<TOOLS[NAME][\"input\"]> | undefined;\n        providerExecuted?: boolean;\n        output?: never;\n        errorText?: never;\n      }\n    | {\n        state: \"input-available\";\n        input: TOOLS[NAME][\"input\"];\n        providerExecuted?: boolean;\n        output?: never;\n        errorText?: never;\n      }\n    | {\n        state: \"output-available\";\n        input: TOOLS[NAME][\"input\"];\n        output: TOOLS[NAME][\"output\"];\n        errorText?: never;\n        providerExecuted?: boolean;\n      }\n    | {\n        state: \"output-error\";\n        input: TOOLS[NAME][\"input\"];\n        output?: never;\n        errorText: string;\n        providerExecuted?: boolean;\n      }\n  );\n}>;\n```\n\n### `SourceUrlUIPart`\n\nA source URL part of a message.\n\n```typescript\ntype SourceUrlUIPart = {\n  type: \"source-url\";\n  sourceId: string;\n  url: string;\n  title?: string;\n  providerMetadata?: Record<string, any>;\n};\n```\n\n### `SourceDocumentUIPart`\n\nA document source part of a message.\n\n```typescript\ntype SourceDocumentUIPart = {\n  type: \"source-document\";\n  sourceId: string;\n  mediaType: string;\n  title: string;\n  filename?: string;\n  providerMetadata?: Record<string, any>;\n};\n```\n\n### `FileUIPart`\n\nA file part of a message.\n\n```typescript\ntype FileUIPart = {\n  type: \"file\";\n  /**\n   * IANA media type of the file.\n   */\n  mediaType: string;\n  /**\n   * Optional filename of the file.\n   */\n  filename?: string;\n  /**\n   * The URL of the file.\n   * It can either be a URL to a hosted file or a Data URL.\n   */\n  url: string;\n};\n```\n\n### `DataUIPart`\n\nA data part of a message for custom data types.\n\n<Note>\n  The type is based on the name of the data part (e.g., `data-someDataPart` for\n  a data part named `someDataPart`).\n</Note>\n\n```typescript\ntype DataUIPart<DATA_TYPES extends UIDataTypes> = ValueOf<{\n  [NAME in keyof DATA_TYPES & string]: {\n    type: `data-${NAME}`;\n    id?: string;\n    data: DATA_TYPES[NAME];\n  };\n}>;\n```\n\n### `StepStartUIPart`\n\nA step boundary part of a message.\n\n```typescript\ntype StepStartUIPart = {\n  type: \"step-start\";\n};\n```\n"
  },
  {
    "path": "aiprompts/anthropic-messages-api.md",
    "content": "# Messages\n\n> Send a structured list of input messages with text and/or image content, and the model will generate the next message in the conversation.\n\nThe Messages API can be used for either single queries or stateless multi-turn conversations.\n\nLearn more about the Messages API in our [user guide](/en/docs/initial-setup)\n\n## OpenAPI\n\n````yaml post /v1/messages\npaths:\n  path: /v1/messages\n  method: post\n  servers:\n    - url: https://api.anthropic.com\n  request:\n    security: []\n    parameters:\n      path: {}\n      query: {}\n      header:\n        anthropic-beta:\n          schema:\n            - type: array\n              items:\n                allOf:\n                  - type: string\n              required: false\n              title: Anthropic-Beta\n              description: >-\n                Optional header to specify the beta version(s) you want to use.\n\n\n                To use multiple betas, use a comma separated list like\n                `beta1,beta2` or specify the header multiple times for each\n                beta.\n        anthropic-version:\n          schema:\n            - type: string\n              required: true\n              title: Anthropic-Version\n              description: >-\n                The version of the Anthropic API you want to use.\n\n\n                Read more about versioning and our version history\n                [here](https://docs.anthropic.com/en/api/versioning).\n        x-api-key:\n          schema:\n            - type: string\n              required: true\n              title: X-Api-Key\n              description: >-\n                Your unique API key for authentication.\n\n\n                This key is required in the header of all API requests, to\n                authenticate your account and access Anthropic's services. Get\n                your API key through the\n                [Console](https://console.anthropic.com/settings/keys). Each key\n                is scoped to a Workspace.\n      cookie: {}\n    body:\n      application/json:\n        schemaArray:\n          - type: object\n            properties:\n              model:\n                allOf:\n                  - description: >-\n                      The model that will complete your prompt.\n\n\n                      See\n                      [models](https://docs.anthropic.com/en/docs/models-overview)\n                      for additional details and options.\n                    examples:\n                      - claude-sonnet-4-20250514\n                    maxLength: 256\n                    minLength: 1\n                    title: Model\n                    type: string\n              messages:\n                allOf:\n                  - description: >-\n                      Input messages.\n\n\n                      Our models are trained to operate on alternating `user`\n                      and `assistant` conversational turns. When creating a new\n                      `Message`, you specify the prior conversational turns with\n                      the `messages` parameter, and the model then generates the\n                      next `Message` in the conversation. Consecutive `user` or\n                      `assistant` turns in your request will be combined into a\n                      single turn.\n\n\n                      Each input message must be an object with a `role` and\n                      `content`. You can specify a single `user`-role message,\n                      or you can include multiple `user` and `assistant`\n                      messages.\n\n\n                      If the final message uses the `assistant` role, the\n                      response content will continue immediately from the\n                      content in that message. This can be used to constrain\n                      part of the model's response.\n\n\n                      Example with a single `user` message:\n\n\n                      ```json\n\n                      [{\"role\": \"user\", \"content\": \"Hello, Claude\"}]\n\n                      ```\n\n\n                      Example with multiple conversational turns:\n\n\n                      ```json\n\n                      [\n                        {\"role\": \"user\", \"content\": \"Hello there.\"},\n                        {\"role\": \"assistant\", \"content\": \"Hi, I'm Claude. How can I help you?\"},\n                        {\"role\": \"user\", \"content\": \"Can you explain LLMs in plain English?\"},\n                      ]\n\n                      ```\n\n\n                      Example with a partially-filled response from Claude:\n\n\n                      ```json\n\n                      [\n                        {\"role\": \"user\", \"content\": \"What's the Greek name for Sun? (A) Sol (B) Helios (C) Sun\"},\n                        {\"role\": \"assistant\", \"content\": \"The best answer is (\"},\n                      ]\n\n                      ```\n\n\n                      Each input message `content` may be either a single\n                      `string` or an array of content blocks, where each block\n                      has a specific `type`. Using a `string` for `content` is\n                      shorthand for an array of one content block of type\n                      `\"text\"`. The following input messages are equivalent:\n\n\n                      ```json\n\n                      {\"role\": \"user\", \"content\": \"Hello, Claude\"}\n\n                      ```\n\n\n                      ```json\n\n                      {\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\":\n                      \"Hello, Claude\"}]}\n\n                      ```\n\n\n                      See\n                      [examples](https://docs.anthropic.com/en/api/messages-examples)\n                      for more input examples.\n\n\n                      Note that if you want to include a [system\n                      prompt](https://docs.anthropic.com/en/docs/system-prompts),\n                      you can use the top-level `system` parameter — there is no\n                      `\"system\"` role for input messages in the Messages API.\n\n\n                      There is a limit of 100,000 messages in a single request.\n                    items:\n                      $ref: \"#/components/schemas/InputMessage\"\n                    title: Messages\n                    type: array\n              container:\n                allOf:\n                  - anyOf:\n                      - type: string\n                      - type: \"null\"\n                    description: Container identifier for reuse across requests.\n                    title: Container\n              max_tokens:\n                allOf:\n                  - description: >-\n                      The maximum number of tokens to generate before stopping.\n\n\n                      Note that our models may stop _before_ reaching this\n                      maximum. This parameter only specifies the absolute\n                      maximum number of tokens to generate.\n\n\n                      Different models have different maximum values for this\n                      parameter.  See\n                      [models](https://docs.anthropic.com/en/docs/models-overview)\n                      for details.\n                    examples:\n                      - 1024\n                    minimum: 1\n                    title: Max Tokens\n                    type: integer\n              mcp_servers:\n                allOf:\n                  - description: MCP servers to be utilized in this request\n                    items:\n                      $ref: \"#/components/schemas/RequestMCPServerURLDefinition\"\n                    maxItems: 20\n                    title: Mcp Servers\n                    type: array\n              metadata:\n                allOf:\n                  - $ref: \"#/components/schemas/Metadata\"\n                    description: An object describing metadata about the request.\n              service_tier:\n                allOf:\n                  - description: >-\n                      Determines whether to use priority capacity (if available)\n                      or standard capacity for this request.\n\n\n                      Anthropic offers different levels of service for your API\n                      requests. See\n                      [service-tiers](https://docs.anthropic.com/en/api/service-tiers)\n                      for details.\n                    enum:\n                      - auto\n                      - standard_only\n                    title: Service Tier\n                    type: string\n              stop_sequences:\n                allOf:\n                  - description: >-\n                      Custom text sequences that will cause the model to stop\n                      generating.\n\n\n                      Our models will normally stop when they have naturally\n                      completed their turn, which will result in a response\n                      `stop_reason` of `\"end_turn\"`.\n\n\n                      If you want the model to stop generating when it\n                      encounters custom strings of text, you can use the\n                      `stop_sequences` parameter. If the model encounters one of\n                      the custom sequences, the response `stop_reason` value\n                      will be `\"stop_sequence\"` and the response `stop_sequence`\n                      value will contain the matched stop sequence.\n                    items:\n                      type: string\n                    title: Stop Sequences\n                    type: array\n              stream:\n                allOf:\n                  - description: >-\n                      Whether to incrementally stream the response using\n                      server-sent events.\n\n\n                      See\n                      [streaming](https://docs.anthropic.com/en/api/messages-streaming)\n                      for details.\n                    title: Stream\n                    type: boolean\n              system:\n                allOf:\n                  - anyOf:\n                      - type: string\n                      - items:\n                          $ref: \"#/components/schemas/RequestTextBlock\"\n                        type: array\n                    description: >-\n                      System prompt.\n\n\n                      A system prompt is a way of providing context and\n                      instructions to Claude, such as specifying a particular\n                      goal or role. See our [guide to system\n                      prompts](https://docs.anthropic.com/en/docs/system-prompts).\n                    examples:\n                      - - text: Today's date is 2024-06-01.\n                          type: text\n                      - Today's date is 2023-01-01.\n                    title: System\n              temperature:\n                allOf:\n                  - description: >-\n                      Amount of randomness injected into the response.\n\n\n                      Defaults to `1.0`. Ranges from `0.0` to `1.0`. Use\n                      `temperature` closer to `0.0` for analytical / multiple\n                      choice, and closer to `1.0` for creative and generative\n                      tasks.\n\n\n                      Note that even with `temperature` of `0.0`, the results\n                      will not be fully deterministic.\n                    examples:\n                      - 1\n                    maximum: 1\n                    minimum: 0\n                    title: Temperature\n                    type: number\n              thinking:\n                allOf:\n                  - description: >-\n                      Configuration for enabling Claude's extended thinking. \n\n\n                      When enabled, responses include `thinking` content blocks\n                      showing Claude's thinking process before the final answer.\n                      Requires a minimum budget of 1,024 tokens and counts\n                      towards your `max_tokens` limit.\n\n\n                      See [extended\n                      thinking](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking)\n                      for details.\n                    discriminator:\n                      mapping:\n                        disabled: \"#/components/schemas/ThinkingConfigDisabled\"\n                        enabled: \"#/components/schemas/ThinkingConfigEnabled\"\n                      propertyName: type\n                    oneOf:\n                      - $ref: \"#/components/schemas/ThinkingConfigEnabled\"\n                      - $ref: \"#/components/schemas/ThinkingConfigDisabled\"\n              tool_choice:\n                allOf:\n                  - description: >-\n                      How the model should use the provided tools. The model can\n                      use a specific tool, any available tool, decide by itself,\n                      or not use tools at all.\n                    discriminator:\n                      mapping:\n                        any: \"#/components/schemas/ToolChoiceAny\"\n                        auto: \"#/components/schemas/ToolChoiceAuto\"\n                        none: \"#/components/schemas/ToolChoiceNone\"\n                        tool: \"#/components/schemas/ToolChoiceTool\"\n                      propertyName: type\n                    oneOf:\n                      - $ref: \"#/components/schemas/ToolChoiceAuto\"\n                      - $ref: \"#/components/schemas/ToolChoiceAny\"\n                      - $ref: \"#/components/schemas/ToolChoiceTool\"\n                      - $ref: \"#/components/schemas/ToolChoiceNone\"\n              tools:\n                allOf:\n                  - description: >-\n                      Definitions of tools that the model may use.\n\n\n                      If you include `tools` in your API request, the model may\n                      return `tool_use` content blocks that represent the\n                      model's use of those tools. You can then run those tools\n                      using the tool input generated by the model and then\n                      optionally return results back to the model using\n                      `tool_result` content blocks.\n\n\n                      There are two types of tools: **client tools** and\n                      **server tools**. The behavior described below applies to\n                      client tools. For [server\n                      tools](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview\\#server-tools),\n                      see their individual documentation as each has its own\n                      behavior (e.g., the [web search\n                      tool](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-search-tool)).\n\n\n                      Each tool definition includes:\n\n\n                      * `name`: Name of the tool.\n\n                      * `description`: Optional, but strongly-recommended\n                      description of the tool.\n\n                      * `input_schema`: [JSON\n                      schema](https://json-schema.org/draft/2020-12) for the\n                      tool `input` shape that the model will produce in\n                      `tool_use` output content blocks.\n\n\n                      For example, if you defined `tools` as:\n\n\n                      ```json\n\n                      [\n                        {\n                          \"name\": \"get_stock_price\",\n                          \"description\": \"Get the current stock price for a given ticker symbol.\",\n                          \"input_schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"ticker\": {\n                                \"type\": \"string\",\n                                \"description\": \"The stock ticker symbol, e.g. AAPL for Apple Inc.\"\n                              }\n                            },\n                            \"required\": [\"ticker\"]\n                          }\n                        }\n                      ]\n\n                      ```\n\n\n                      And then asked the model \"What's the S&P 500 at today?\",\n                      the model might produce `tool_use` content blocks in the\n                      response like this:\n\n\n                      ```json\n\n                      [\n                        {\n                          \"type\": \"tool_use\",\n                          \"id\": \"toolu_01D7FLrfh4GYq7yT1ULFeyMV\",\n                          \"name\": \"get_stock_price\",\n                          \"input\": { \"ticker\": \"^GSPC\" }\n                        }\n                      ]\n\n                      ```\n\n\n                      You might then run your `get_stock_price` tool with\n                      `{\"ticker\": \"^GSPC\"}` as an input, and return the\n                      following back to the model in a subsequent `user`\n                      message:\n\n\n                      ```json\n\n                      [\n                        {\n                          \"type\": \"tool_result\",\n                          \"tool_use_id\": \"toolu_01D7FLrfh4GYq7yT1ULFeyMV\",\n                          \"content\": \"259.75 USD\"\n                        }\n                      ]\n\n                      ```\n\n\n                      Tools can be used for workflows that include running\n                      client-side tools and functions, or more generally\n                      whenever you want the model to produce a particular JSON\n                      structure of output.\n\n\n                      See our\n                      [guide](https://docs.anthropic.com/en/docs/tool-use) for\n                      more details.\n                    examples:\n                      - description: Get the current weather in a given location\n                        input_schema:\n                          properties:\n                            location:\n                              description: The city and state, e.g. San Francisco, CA\n                              type: string\n                            unit:\n                              description: >-\n                                Unit for the output - one of (celsius,\n                                fahrenheit)\n                              type: string\n                          required:\n                            - location\n                          type: object\n                        name: get_weather\n                    items:\n                      oneOf:\n                        - $ref: \"#/components/schemas/Tool\"\n                        - $ref: \"#/components/schemas/BashTool_20241022\"\n                        - $ref: \"#/components/schemas/BashTool_20250124\"\n                        - $ref: \"#/components/schemas/CodeExecutionTool_20250522\"\n                        - $ref: \"#/components/schemas/ComputerUseTool_20241022\"\n                        - $ref: \"#/components/schemas/ComputerUseTool_20250124\"\n                        - $ref: \"#/components/schemas/TextEditor_20241022\"\n                        - $ref: \"#/components/schemas/TextEditor_20250124\"\n                        - $ref: \"#/components/schemas/TextEditor_20250429\"\n                        - $ref: \"#/components/schemas/TextEditor_20250728\"\n                        - $ref: \"#/components/schemas/WebSearchTool_20250305\"\n                    title: Tools\n                    type: array\n              top_k:\n                allOf:\n                  - description: >-\n                      Only sample from the top K options for each subsequent\n                      token.\n\n\n                      Used to remove \"long tail\" low probability responses.\n                      [Learn more technical details\n                      here](https://towardsdatascience.com/how-to-sample-from-language-models-682bceb97277).\n\n\n                      Recommended for advanced use cases only. You usually only\n                      need to use `temperature`.\n                    examples:\n                      - 5\n                    minimum: 0\n                    title: Top K\n                    type: integer\n              top_p:\n                allOf:\n                  - description: >-\n                      Use nucleus sampling.\n\n\n                      In nucleus sampling, we compute the cumulative\n                      distribution over all the options for each subsequent\n                      token in decreasing probability order and cut it off once\n                      it reaches a particular probability specified by `top_p`.\n                      You should either alter `temperature` or `top_p`, but not\n                      both.\n\n\n                      Recommended for advanced use cases only. You usually only\n                      need to use `temperature`.\n                    examples:\n                      - 0.7\n                    maximum: 1\n                    minimum: 0\n                    title: Top P\n                    type: number\n            required: true\n            title: CreateMessageParams\n            requiredProperties:\n              - model\n              - messages\n              - max_tokens\n            additionalProperties: false\n            example:\n              max_tokens: 1024\n              messages:\n                - content: Hello, world\n                  role: user\n              model: claude-sonnet-4-20250514\n        examples:\n          example:\n            value:\n              max_tokens: 1024\n              messages:\n                - content: Hello, world\n                  role: user\n              model: claude-sonnet-4-20250514\n    codeSamples:\n      - lang: bash\n        source: |-\n          curl https://api.anthropic.com/v1/messages \\\n               --header \"x-api-key: $ANTHROPIC_API_KEY\" \\\n               --header \"anthropic-version: 2023-06-01\" \\\n               --header \"content-type: application/json\" \\\n               --data \\\n          '{\n              \"model\": \"claude-sonnet-4-20250514\",\n              \"max_tokens\": 1024,\n              \"messages\": [\n                  {\"role\": \"user\", \"content\": \"Hello, world\"}\n              ]\n          }'\n      - lang: python\n        source: |-\n          import anthropic\n\n          anthropic.Anthropic().messages.create(\n              model=\"claude-sonnet-4-20250514\",\n              max_tokens=1024,\n              messages=[\n                  {\"role\": \"user\", \"content\": \"Hello, world\"}\n              ]\n          )\n      - lang: javascript\n        source: |-\n          import { Anthropic } from '@anthropic-ai/sdk';\n\n          const anthropic = new Anthropic();\n\n          await anthropic.messages.create({\n            model: \"claude-sonnet-4-20250514\",\n            max_tokens: 1024,\n            messages: [\n              {\"role\": \"user\", \"content\": \"Hello, world\"}\n            ]\n          });\n  response:\n    \"200\":\n      application/json:\n        schemaArray:\n          - type: object\n            properties:\n              id:\n                allOf:\n                  - description: |-\n                      Unique object identifier.\n\n                      The format and length of IDs may change over time.\n                    examples:\n                      - msg_013Zva2CMHLNnXjNJJKqJ2EF\n                    title: Id\n                    type: string\n              type:\n                allOf:\n                  - const: message\n                    default: message\n                    description: |-\n                      Object type.\n\n                      For Messages, this is always `\"message\"`.\n                    enum:\n                      - message\n                    title: Type\n                    type: string\n              role:\n                allOf:\n                  - const: assistant\n                    default: assistant\n                    description: |-\n                      Conversational role of the generated message.\n\n                      This will always be `\"assistant\"`.\n                    enum:\n                      - assistant\n                    title: Role\n                    type: string\n              content:\n                allOf:\n                  - description: >-\n                      Content generated by the model.\n\n\n                      This is an array of content blocks, each of which has a\n                      `type` that determines its shape.\n\n\n                      Example:\n\n\n                      ```json\n\n                      [{\"type\": \"text\", \"text\": \"Hi, I'm Claude.\"}]\n\n                      ```\n\n\n                      If the request input `messages` ended with an `assistant`\n                      turn, then the response `content` will continue directly\n                      from that last turn. You can use this to constrain the\n                      model's output.\n\n\n                      For example, if the input `messages` were:\n\n                      ```json\n\n                      [\n                        {\"role\": \"user\", \"content\": \"What's the Greek name for Sun? (A) Sol (B) Helios (C) Sun\"},\n                        {\"role\": \"assistant\", \"content\": \"The best answer is (\"}\n                      ]\n\n                      ```\n\n\n                      Then the response `content` might be:\n\n\n                      ```json\n\n                      [{\"type\": \"text\", \"text\": \"B)\"}]\n\n                      ```\n                    examples:\n                      - - text: Hi! My name is Claude.\n                          type: text\n                    items:\n                      discriminator:\n                        mapping:\n                          code_execution_tool_result: >-\n                            #/components/schemas/ResponseCodeExecutionToolResultBlock\n                          container_upload: \"#/components/schemas/ResponseContainerUploadBlock\"\n                          mcp_tool_result: \"#/components/schemas/ResponseMCPToolResultBlock\"\n                          mcp_tool_use: \"#/components/schemas/ResponseMCPToolUseBlock\"\n                          redacted_thinking: \"#/components/schemas/ResponseRedactedThinkingBlock\"\n                          server_tool_use: \"#/components/schemas/ResponseServerToolUseBlock\"\n                          text: \"#/components/schemas/ResponseTextBlock\"\n                          thinking: \"#/components/schemas/ResponseThinkingBlock\"\n                          tool_use: \"#/components/schemas/ResponseToolUseBlock\"\n                          web_search_tool_result: >-\n                            #/components/schemas/ResponseWebSearchToolResultBlock\n                        propertyName: type\n                      oneOf:\n                        - $ref: \"#/components/schemas/ResponseTextBlock\"\n                        - $ref: \"#/components/schemas/ResponseThinkingBlock\"\n                        - $ref: \"#/components/schemas/ResponseRedactedThinkingBlock\"\n                        - $ref: \"#/components/schemas/ResponseToolUseBlock\"\n                        - $ref: \"#/components/schemas/ResponseServerToolUseBlock\"\n                        - $ref: >-\n                            #/components/schemas/ResponseWebSearchToolResultBlock\n                        - $ref: >-\n                            #/components/schemas/ResponseCodeExecutionToolResultBlock\n                        - $ref: \"#/components/schemas/ResponseMCPToolUseBlock\"\n                        - $ref: \"#/components/schemas/ResponseMCPToolResultBlock\"\n                        - $ref: \"#/components/schemas/ResponseContainerUploadBlock\"\n                    title: Content\n                    type: array\n              model:\n                allOf:\n                  - description: The model that handled the request.\n                    examples:\n                      - claude-sonnet-4-20250514\n                    maxLength: 256\n                    minLength: 1\n                    title: Model\n                    type: string\n              stop_reason:\n                allOf:\n                  - anyOf:\n                      - enum:\n                          - end_turn\n                          - max_tokens\n                          - stop_sequence\n                          - tool_use\n                          - pause_turn\n                          - refusal\n                        type: string\n                      - type: \"null\"\n                    description: >-\n                      The reason that we stopped.\n\n\n                      This may be one the following values:\n\n                      * `\"end_turn\"`: the model reached a natural stopping point\n\n                      * `\"max_tokens\"`: we exceeded the requested `max_tokens`\n                      or the model's maximum\n\n                      * `\"stop_sequence\"`: one of your provided custom\n                      `stop_sequences` was generated\n\n                      * `\"tool_use\"`: the model invoked one or more tools\n\n                      * `\"pause_turn\"`: we paused a long-running turn. You may\n                      provide the response back as-is in a subsequent request to\n                      let the model continue.\n\n                      * `\"refusal\"`: when streaming classifiers intervene to\n                      handle potential policy violations\n\n\n                      In non-streaming mode this value is always non-null. In\n                      streaming mode, it is null in the `message_start` event\n                      and non-null otherwise.\n                    title: Stop Reason\n              stop_sequence:\n                allOf:\n                  - anyOf:\n                      - type: string\n                      - type: \"null\"\n                    default: null\n                    description: >-\n                      Which custom stop sequence was generated, if any.\n\n\n                      This value will be a non-null string if one of your custom\n                      stop sequences was generated.\n                    title: Stop Sequence\n              usage:\n                allOf:\n                  - $ref: \"#/components/schemas/Usage\"\n                    description: >-\n                      Billing and rate-limit usage.\n\n\n                      Anthropic's API bills and rate-limits by token counts, as\n                      tokens represent the underlying cost to our systems.\n\n\n                      Under the hood, the API transforms requests into a format\n                      suitable for the model. The model's output then goes\n                      through a parsing stage before becoming an API response.\n                      As a result, the token counts in `usage` will not match\n                      one-to-one with the exact visible content of an API\n                      request or response.\n\n\n                      For example, `output_tokens` will be non-zero, even for an\n                      empty string response from Claude.\n\n\n                      Total input tokens in a request is the summation of\n                      `input_tokens`, `cache_creation_input_tokens`, and\n                      `cache_read_input_tokens`.\n                    examples:\n                      - input_tokens: 2095\n                        output_tokens: 503\n              container:\n                allOf:\n                  - anyOf:\n                      - $ref: \"#/components/schemas/Container\"\n                      - type: \"null\"\n                    default: null\n                    description: >-\n                      Information about the container used in this request.\n\n\n                      This will be non-null if a container tool (e.g. code\n                      execution) was used.\n            title: Message\n            examples:\n              - content: &ref_0\n                  - text: Hi! My name is Claude.\n                    type: text\n                id: msg_013Zva2CMHLNnXjNJJKqJ2EF\n                model: claude-sonnet-4-20250514\n                role: assistant\n                stop_reason: end_turn\n                stop_sequence: null\n                type: message\n                usage: &ref_1\n                  input_tokens: 2095\n                  output_tokens: 503\n            requiredProperties:\n              - id\n              - type\n              - role\n              - content\n              - model\n              - stop_reason\n              - stop_sequence\n              - usage\n              - container\n            example:\n              content: *ref_0\n              id: msg_013Zva2CMHLNnXjNJJKqJ2EF\n              model: claude-sonnet-4-20250514\n              role: assistant\n              stop_reason: end_turn\n              stop_sequence: null\n              type: message\n              usage: *ref_1\n        examples:\n          example:\n            value:\n              content:\n                - text: Hi! My name is Claude.\n                  type: text\n              id: msg_013Zva2CMHLNnXjNJJKqJ2EF\n              model: claude-sonnet-4-20250514\n              role: assistant\n              stop_reason: end_turn\n              stop_sequence: null\n              type: message\n              usage:\n                input_tokens: 2095\n                output_tokens: 503\n        description: Message object.\n    4XX:\n      application/json:\n        schemaArray:\n          - type: object\n            properties:\n              error:\n                allOf:\n                  - discriminator:\n                      mapping:\n                        api_error: \"#/components/schemas/APIError\"\n                        authentication_error: \"#/components/schemas/AuthenticationError\"\n                        billing_error: \"#/components/schemas/BillingError\"\n                        invalid_request_error: \"#/components/schemas/InvalidRequestError\"\n                        not_found_error: \"#/components/schemas/NotFoundError\"\n                        overloaded_error: \"#/components/schemas/OverloadedError\"\n                        permission_error: \"#/components/schemas/PermissionError\"\n                        rate_limit_error: \"#/components/schemas/RateLimitError\"\n                        timeout_error: \"#/components/schemas/GatewayTimeoutError\"\n                      propertyName: type\n                    oneOf:\n                      - $ref: \"#/components/schemas/InvalidRequestError\"\n                      - $ref: \"#/components/schemas/AuthenticationError\"\n                      - $ref: \"#/components/schemas/BillingError\"\n                      - $ref: \"#/components/schemas/PermissionError\"\n                      - $ref: \"#/components/schemas/NotFoundError\"\n                      - $ref: \"#/components/schemas/RateLimitError\"\n                      - $ref: \"#/components/schemas/GatewayTimeoutError\"\n                      - $ref: \"#/components/schemas/APIError\"\n                      - $ref: \"#/components/schemas/OverloadedError\"\n                    title: Error\n              type:\n                allOf:\n                  - const: error\n                    default: error\n                    enum:\n                      - error\n                    title: Type\n                    type: string\n            title: ErrorResponse\n            requiredProperties:\n              - error\n              - type\n        examples:\n          example:\n            value:\n              error:\n                message: Invalid request\n                type: invalid_request_error\n              type: error\n        description: >-\n          Error response.\n\n\n          See our [errors\n          documentation](https://docs.anthropic.com/en/api/errors) for more\n          details.\n  deprecated: false\n  type: path\ncomponents:\n  schemas:\n    APIError:\n      properties:\n        message:\n          default: Internal server error\n          title: Message\n          type: string\n        type:\n          const: api_error\n          default: api_error\n          enum:\n            - api_error\n          title: Type\n          type: string\n      required:\n        - message\n        - type\n      title: APIError\n      type: object\n    AuthenticationError:\n      properties:\n        message:\n          default: Authentication error\n          title: Message\n          type: string\n        type:\n          const: authentication_error\n          default: authentication_error\n          enum:\n            - authentication_error\n          title: Type\n          type: string\n      required:\n        - message\n        - type\n      title: AuthenticationError\n      type: object\n    Base64ImageSource:\n      additionalProperties: false\n      properties:\n        data:\n          format: byte\n          title: Data\n          type: string\n        media_type:\n          enum:\n            - image/jpeg\n            - image/png\n            - image/gif\n            - image/webp\n          title: Media Type\n          type: string\n        type:\n          const: base64\n          enum:\n            - base64\n          title: Type\n          type: string\n      required:\n        - data\n        - media_type\n        - type\n      title: Base64ImageSource\n      type: object\n    Base64PDFSource:\n      additionalProperties: false\n      properties:\n        data:\n          format: byte\n          title: Data\n          type: string\n        media_type:\n          const: application/pdf\n          enum:\n            - application/pdf\n          title: Media Type\n          type: string\n        type:\n          const: base64\n          enum:\n            - base64\n          title: Type\n          type: string\n      required:\n        - data\n        - media_type\n        - type\n      title: PDF (base64)\n      type: object\n    BashTool_20241022:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        name:\n          const: bash\n          description: >-\n            Name of the tool.\n\n\n            This is how the tool will be called by the model and in `tool_use`\n            blocks.\n          enum:\n            - bash\n          title: Name\n          type: string\n        type:\n          const: bash_20241022\n          enum:\n            - bash_20241022\n          title: Type\n          type: string\n      required:\n        - name\n        - type\n      title: Bash tool (2024-10-22)\n      type: object\n    BashTool_20250124:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        name:\n          const: bash\n          description: >-\n            Name of the tool.\n\n\n            This is how the tool will be called by the model and in `tool_use`\n            blocks.\n          enum:\n            - bash\n          title: Name\n          type: string\n        type:\n          const: bash_20250124\n          enum:\n            - bash_20250124\n          title: Type\n          type: string\n      required:\n        - name\n        - type\n      title: Bash tool (2025-01-24)\n      type: object\n    BillingError:\n      properties:\n        message:\n          default: Billing error\n          title: Message\n          type: string\n        type:\n          const: billing_error\n          default: billing_error\n          enum:\n            - billing_error\n          title: Type\n          type: string\n      required:\n        - message\n        - type\n      title: BillingError\n      type: object\n    CacheControlEphemeral:\n      additionalProperties: false\n      properties:\n        ttl:\n          description: |-\n            The time-to-live for the cache control breakpoint.\n\n            This may be one the following values:\n            - `5m`: 5 minutes\n            - `1h`: 1 hour\n\n            Defaults to `5m`.\n          enum:\n            - 5m\n            - 1h\n          title: Ttl\n          type: string\n        type:\n          const: ephemeral\n          enum:\n            - ephemeral\n          title: Type\n          type: string\n      required:\n        - type\n      title: CacheControlEphemeral\n      type: object\n    CacheCreation:\n      properties:\n        ephemeral_1h_input_tokens:\n          default: 0\n          description: The number of input tokens used to create the 1 hour cache entry.\n          minimum: 0\n          title: Ephemeral 1H Input Tokens\n          type: integer\n        ephemeral_5m_input_tokens:\n          default: 0\n          description: The number of input tokens used to create the 5 minute cache entry.\n          minimum: 0\n          title: Ephemeral 5M Input Tokens\n          type: integer\n      required:\n        - ephemeral_1h_input_tokens\n        - ephemeral_5m_input_tokens\n      title: CacheCreation\n      type: object\n    CodeExecutionToolResultErrorCode:\n      enum:\n        - invalid_tool_input\n        - unavailable\n        - too_many_requests\n        - execution_time_exceeded\n      title: CodeExecutionToolResultErrorCode\n      type: string\n    CodeExecutionTool_20250522:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        name:\n          const: code_execution\n          description: >-\n            Name of the tool.\n\n\n            This is how the tool will be called by the model and in `tool_use`\n            blocks.\n          enum:\n            - code_execution\n          title: Name\n          type: string\n        type:\n          const: code_execution_20250522\n          enum:\n            - code_execution_20250522\n          title: Type\n          type: string\n      required:\n        - name\n        - type\n      title: Code execution tool (2025-05-22)\n      type: object\n    ComputerUseTool_20241022:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        display_height_px:\n          description: The height of the display in pixels.\n          minimum: 1\n          title: Display Height Px\n          type: integer\n        display_number:\n          anyOf:\n            - minimum: 0\n              type: integer\n            - type: \"null\"\n          description: The X11 display number (e.g. 0, 1) for the display.\n          title: Display Number\n        display_width_px:\n          description: The width of the display in pixels.\n          minimum: 1\n          title: Display Width Px\n          type: integer\n        name:\n          const: computer\n          description: >-\n            Name of the tool.\n\n\n            This is how the tool will be called by the model and in `tool_use`\n            blocks.\n          enum:\n            - computer\n          title: Name\n          type: string\n        type:\n          const: computer_20241022\n          enum:\n            - computer_20241022\n          title: Type\n          type: string\n      required:\n        - display_height_px\n        - display_width_px\n        - name\n        - type\n      title: Computer use tool (2024-01-22)\n      type: object\n    ComputerUseTool_20250124:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        display_height_px:\n          description: The height of the display in pixels.\n          minimum: 1\n          title: Display Height Px\n          type: integer\n        display_number:\n          anyOf:\n            - minimum: 0\n              type: integer\n            - type: \"null\"\n          description: The X11 display number (e.g. 0, 1) for the display.\n          title: Display Number\n        display_width_px:\n          description: The width of the display in pixels.\n          minimum: 1\n          title: Display Width Px\n          type: integer\n        name:\n          const: computer\n          description: >-\n            Name of the tool.\n\n\n            This is how the tool will be called by the model and in `tool_use`\n            blocks.\n          enum:\n            - computer\n          title: Name\n          type: string\n        type:\n          const: computer_20250124\n          enum:\n            - computer_20250124\n          title: Type\n          type: string\n      required:\n        - display_height_px\n        - display_width_px\n        - name\n        - type\n      title: Computer use tool (2025-01-24)\n      type: object\n    Container:\n      description: >-\n        Information about the container used in the request (for the code\n        execution tool)\n      properties:\n        expires_at:\n          description: The time at which the container will expire.\n          format: date-time\n          title: Expires At\n          type: string\n        id:\n          description: Identifier for the container used in this request\n          title: Id\n          type: string\n      required:\n        - expires_at\n        - id\n      title: Container\n      type: object\n    ContentBlockSource:\n      additionalProperties: false\n      properties:\n        content:\n          anyOf:\n            - type: string\n            - items:\n                discriminator:\n                  mapping:\n                    image: \"#/components/schemas/RequestImageBlock\"\n                    text: \"#/components/schemas/RequestTextBlock\"\n                  propertyName: type\n                oneOf:\n                  - $ref: \"#/components/schemas/RequestTextBlock\"\n                  - $ref: \"#/components/schemas/RequestImageBlock\"\n              type: array\n          title: Content\n        type:\n          const: content\n          enum:\n            - content\n          title: Type\n          type: string\n      required:\n        - content\n        - type\n      title: Content block\n      type: object\n    FileDocumentSource:\n      additionalProperties: false\n      properties:\n        file_id:\n          title: File Id\n          type: string\n        type:\n          const: file\n          enum:\n            - file\n          title: Type\n          type: string\n      required:\n        - file_id\n        - type\n      title: File document\n      type: object\n    FileImageSource:\n      additionalProperties: false\n      properties:\n        file_id:\n          title: File Id\n          type: string\n        type:\n          const: file\n          enum:\n            - file\n          title: Type\n          type: string\n      required:\n        - file_id\n        - type\n      title: FileImageSource\n      type: object\n    GatewayTimeoutError:\n      properties:\n        message:\n          default: Request timeout\n          title: Message\n          type: string\n        type:\n          const: timeout_error\n          default: timeout_error\n          enum:\n            - timeout_error\n          title: Type\n          type: string\n      required:\n        - message\n        - type\n      title: GatewayTimeoutError\n      type: object\n    InputMessage:\n      additionalProperties: false\n      properties:\n        content:\n          anyOf:\n            - type: string\n            - items:\n                discriminator:\n                  mapping:\n                    code_execution_tool_result: \"#/components/schemas/RequestCodeExecutionToolResultBlock\"\n                    container_upload: \"#/components/schemas/RequestContainerUploadBlock\"\n                    document: \"#/components/schemas/RequestDocumentBlock\"\n                    image: \"#/components/schemas/RequestImageBlock\"\n                    mcp_tool_result: \"#/components/schemas/RequestMCPToolResultBlock\"\n                    mcp_tool_use: \"#/components/schemas/RequestMCPToolUseBlock\"\n                    redacted_thinking: \"#/components/schemas/RequestRedactedThinkingBlock\"\n                    search_result: \"#/components/schemas/RequestSearchResultBlock\"\n                    server_tool_use: \"#/components/schemas/RequestServerToolUseBlock\"\n                    text: \"#/components/schemas/RequestTextBlock\"\n                    thinking: \"#/components/schemas/RequestThinkingBlock\"\n                    tool_result: \"#/components/schemas/RequestToolResultBlock\"\n                    tool_use: \"#/components/schemas/RequestToolUseBlock\"\n                    web_search_tool_result: \"#/components/schemas/RequestWebSearchToolResultBlock\"\n                  propertyName: type\n                oneOf:\n                  - $ref: \"#/components/schemas/RequestTextBlock\"\n                    description: Regular text content.\n                  - $ref: \"#/components/schemas/RequestImageBlock\"\n                    description: >-\n                      Image content specified directly as base64 data or as a\n                      reference via a URL.\n                  - $ref: \"#/components/schemas/RequestDocumentBlock\"\n                    description: >-\n                      Document content, either specified directly as base64\n                      data, as text, or as a reference via a URL.\n                  - $ref: \"#/components/schemas/RequestSearchResultBlock\"\n                    description: >-\n                      A search result block containing source, title, and\n                      content from search operations.\n                  - $ref: \"#/components/schemas/RequestThinkingBlock\"\n                    description: A block specifying internal thinking by the model.\n                  - $ref: \"#/components/schemas/RequestRedactedThinkingBlock\"\n                    description: >-\n                      A block specifying internal, redacted thinking by the\n                      model.\n                  - $ref: \"#/components/schemas/RequestToolUseBlock\"\n                    description: A block indicating a tool use by the model.\n                  - $ref: \"#/components/schemas/RequestToolResultBlock\"\n                    description: A block specifying the results of a tool use by the model.\n                  - $ref: \"#/components/schemas/RequestServerToolUseBlock\"\n                  - $ref: \"#/components/schemas/RequestWebSearchToolResultBlock\"\n                  - $ref: \"#/components/schemas/RequestCodeExecutionToolResultBlock\"\n                  - $ref: \"#/components/schemas/RequestMCPToolUseBlock\"\n                  - $ref: \"#/components/schemas/RequestMCPToolResultBlock\"\n                  - $ref: \"#/components/schemas/RequestContainerUploadBlock\"\n              type: array\n          title: Content\n        role:\n          enum:\n            - user\n            - assistant\n          title: Role\n          type: string\n      required:\n        - content\n        - role\n      title: InputMessage\n      type: object\n    InputSchema:\n      additionalProperties: true\n      properties:\n        properties:\n          anyOf:\n            - type: object\n            - type: \"null\"\n          title: Properties\n        required:\n          anyOf:\n            - items:\n                type: string\n              type: array\n            - type: \"null\"\n          title: Required\n        type:\n          const: object\n          enum:\n            - object\n          title: Type\n          type: string\n      required:\n        - type\n      title: InputSchema\n      type: object\n    InvalidRequestError:\n      properties:\n        message:\n          default: Invalid request\n          title: Message\n          type: string\n        type:\n          const: invalid_request_error\n          default: invalid_request_error\n          enum:\n            - invalid_request_error\n          title: Type\n          type: string\n      required:\n        - message\n        - type\n      title: InvalidRequestError\n      type: object\n    Metadata:\n      additionalProperties: false\n      properties:\n        user_id:\n          anyOf:\n            - maxLength: 256\n              type: string\n            - type: \"null\"\n          description: >-\n            An external identifier for the user who is associated with the\n            request.\n\n\n            This should be a uuid, hash value, or other opaque identifier.\n            Anthropic may use this id to help detect abuse. Do not include any\n            identifying information such as name, email address, or phone\n            number.\n          examples:\n            - 13803d75-b4b5-4c3e-b2a2-6f21399b021b\n          title: User Id\n      title: Metadata\n      type: object\n    NotFoundError:\n      properties:\n        message:\n          default: Not found\n          title: Message\n          type: string\n        type:\n          const: not_found_error\n          default: not_found_error\n          enum:\n            - not_found_error\n          title: Type\n          type: string\n      required:\n        - message\n        - type\n      title: NotFoundError\n      type: object\n    OverloadedError:\n      properties:\n        message:\n          default: Overloaded\n          title: Message\n          type: string\n        type:\n          const: overloaded_error\n          default: overloaded_error\n          enum:\n            - overloaded_error\n          title: Type\n          type: string\n      required:\n        - message\n        - type\n      title: OverloadedError\n      type: object\n    PermissionError:\n      properties:\n        message:\n          default: Permission denied\n          title: Message\n          type: string\n        type:\n          const: permission_error\n          default: permission_error\n          enum:\n            - permission_error\n          title: Type\n          type: string\n      required:\n        - message\n        - type\n      title: PermissionError\n      type: object\n    PlainTextSource:\n      additionalProperties: false\n      properties:\n        data:\n          title: Data\n          type: string\n        media_type:\n          const: text/plain\n          enum:\n            - text/plain\n          title: Media Type\n          type: string\n        type:\n          const: text\n          enum:\n            - text\n          title: Type\n          type: string\n      required:\n        - data\n        - media_type\n        - type\n      title: Plain text\n      type: object\n    RateLimitError:\n      properties:\n        message:\n          default: Rate limited\n          title: Message\n          type: string\n        type:\n          const: rate_limit_error\n          default: rate_limit_error\n          enum:\n            - rate_limit_error\n          title: Type\n          type: string\n      required:\n        - message\n        - type\n      title: RateLimitError\n      type: object\n    RequestCharLocationCitation:\n      additionalProperties: false\n      properties:\n        cited_text:\n          title: Cited Text\n          type: string\n        document_index:\n          minimum: 0\n          title: Document Index\n          type: integer\n        document_title:\n          anyOf:\n            - maxLength: 255\n              minLength: 1\n              type: string\n            - type: \"null\"\n          title: Document Title\n        end_char_index:\n          title: End Char Index\n          type: integer\n        start_char_index:\n          minimum: 0\n          title: Start Char Index\n          type: integer\n        type:\n          const: char_location\n          enum:\n            - char_location\n          title: Type\n          type: string\n      required:\n        - cited_text\n        - document_index\n        - document_title\n        - end_char_index\n        - start_char_index\n        - type\n      title: Character location\n      type: object\n    RequestCitationsConfig:\n      additionalProperties: false\n      properties:\n        enabled:\n          title: Enabled\n          type: boolean\n      title: RequestCitationsConfig\n      type: object\n    RequestCodeExecutionOutputBlock:\n      additionalProperties: false\n      properties:\n        file_id:\n          title: File Id\n          type: string\n        type:\n          const: code_execution_output\n          enum:\n            - code_execution_output\n          title: Type\n          type: string\n      required:\n        - file_id\n        - type\n      title: RequestCodeExecutionOutputBlock\n      type: object\n    RequestCodeExecutionResultBlock:\n      additionalProperties: false\n      properties:\n        content:\n          items:\n            $ref: \"#/components/schemas/RequestCodeExecutionOutputBlock\"\n          title: Content\n          type: array\n        return_code:\n          title: Return Code\n          type: integer\n        stderr:\n          title: Stderr\n          type: string\n        stdout:\n          title: Stdout\n          type: string\n        type:\n          const: code_execution_result\n          enum:\n            - code_execution_result\n          title: Type\n          type: string\n      required:\n        - content\n        - return_code\n        - stderr\n        - stdout\n        - type\n      title: Code execution result\n      type: object\n    RequestCodeExecutionToolResultBlock:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        content:\n          anyOf:\n            - $ref: \"#/components/schemas/RequestCodeExecutionToolResultError\"\n            - $ref: \"#/components/schemas/RequestCodeExecutionResultBlock\"\n          title: Content\n        tool_use_id:\n          pattern: ^srvtoolu_[a-zA-Z0-9_]+$\n          title: Tool Use Id\n          type: string\n        type:\n          const: code_execution_tool_result\n          enum:\n            - code_execution_tool_result\n          title: Type\n          type: string\n      required:\n        - content\n        - tool_use_id\n        - type\n      title: Code execution tool result\n      type: object\n    RequestCodeExecutionToolResultError:\n      additionalProperties: false\n      properties:\n        error_code:\n          $ref: \"#/components/schemas/CodeExecutionToolResultErrorCode\"\n        type:\n          const: code_execution_tool_result_error\n          enum:\n            - code_execution_tool_result_error\n          title: Type\n          type: string\n      required:\n        - error_code\n        - type\n      title: Code execution tool error\n      type: object\n    RequestContainerUploadBlock:\n      additionalProperties: false\n      description: >-\n        A content block that represents a file to be uploaded to the container\n\n        Files uploaded via this block will be available in the container's input\n        directory.\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        file_id:\n          title: File Id\n          type: string\n        type:\n          const: container_upload\n          enum:\n            - container_upload\n          title: Type\n          type: string\n      required:\n        - file_id\n        - type\n      title: Container upload\n      type: object\n    RequestContentBlockLocationCitation:\n      additionalProperties: false\n      properties:\n        cited_text:\n          title: Cited Text\n          type: string\n        document_index:\n          minimum: 0\n          title: Document Index\n          type: integer\n        document_title:\n          anyOf:\n            - maxLength: 255\n              minLength: 1\n              type: string\n            - type: \"null\"\n          title: Document Title\n        end_block_index:\n          title: End Block Index\n          type: integer\n        start_block_index:\n          minimum: 0\n          title: Start Block Index\n          type: integer\n        type:\n          const: content_block_location\n          enum:\n            - content_block_location\n          title: Type\n          type: string\n      required:\n        - cited_text\n        - document_index\n        - document_title\n        - end_block_index\n        - start_block_index\n        - type\n      title: Content block location\n      type: object\n    RequestDocumentBlock:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        citations:\n          $ref: \"#/components/schemas/RequestCitationsConfig\"\n        context:\n          anyOf:\n            - minLength: 1\n              type: string\n            - type: \"null\"\n          title: Context\n        source:\n          discriminator:\n            mapping:\n              base64: \"#/components/schemas/Base64PDFSource\"\n              content: \"#/components/schemas/ContentBlockSource\"\n              file: \"#/components/schemas/FileDocumentSource\"\n              text: \"#/components/schemas/PlainTextSource\"\n              url: \"#/components/schemas/URLPDFSource\"\n            propertyName: type\n          oneOf:\n            - $ref: \"#/components/schemas/Base64PDFSource\"\n            - $ref: \"#/components/schemas/PlainTextSource\"\n            - $ref: \"#/components/schemas/ContentBlockSource\"\n            - $ref: \"#/components/schemas/URLPDFSource\"\n            - $ref: \"#/components/schemas/FileDocumentSource\"\n        title:\n          anyOf:\n            - maxLength: 500\n              minLength: 1\n              type: string\n            - type: \"null\"\n          title: Title\n        type:\n          const: document\n          enum:\n            - document\n          title: Type\n          type: string\n      required:\n        - source\n        - type\n      title: Document\n      type: object\n    RequestImageBlock:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        source:\n          discriminator:\n            mapping:\n              base64: \"#/components/schemas/Base64ImageSource\"\n              file: \"#/components/schemas/FileImageSource\"\n              url: \"#/components/schemas/URLImageSource\"\n            propertyName: type\n          oneOf:\n            - $ref: \"#/components/schemas/Base64ImageSource\"\n            - $ref: \"#/components/schemas/URLImageSource\"\n            - $ref: \"#/components/schemas/FileImageSource\"\n          title: Source\n        type:\n          const: image\n          enum:\n            - image\n          title: Type\n          type: string\n      required:\n        - source\n        - type\n      title: Image\n      type: object\n    RequestMCPServerToolConfiguration:\n      additionalProperties: false\n      properties:\n        allowed_tools:\n          anyOf:\n            - items:\n                type: string\n              type: array\n            - type: \"null\"\n          title: Allowed Tools\n        enabled:\n          anyOf:\n            - type: boolean\n            - type: \"null\"\n          title: Enabled\n      title: RequestMCPServerToolConfiguration\n      type: object\n    RequestMCPServerURLDefinition:\n      additionalProperties: false\n      properties:\n        authorization_token:\n          anyOf:\n            - type: string\n            - type: \"null\"\n          title: Authorization Token\n        name:\n          title: Name\n          type: string\n        tool_configuration:\n          anyOf:\n            - $ref: \"#/components/schemas/RequestMCPServerToolConfiguration\"\n            - type: \"null\"\n        type:\n          const: url\n          enum:\n            - url\n          title: Type\n          type: string\n        url:\n          title: Url\n          type: string\n      required:\n        - name\n        - type\n        - url\n      title: RequestMCPServerURLDefinition\n      type: object\n    RequestMCPToolResultBlock:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        content:\n          anyOf:\n            - type: string\n            - items:\n                $ref: \"#/components/schemas/RequestTextBlock\"\n              type: array\n          title: Content\n        is_error:\n          title: Is Error\n          type: boolean\n        tool_use_id:\n          pattern: ^[a-zA-Z0-9_-]+$\n          title: Tool Use Id\n          type: string\n        type:\n          const: mcp_tool_result\n          enum:\n            - mcp_tool_result\n          title: Type\n          type: string\n      required:\n        - tool_use_id\n        - type\n      title: MCP tool result\n      type: object\n    RequestMCPToolUseBlock:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        id:\n          pattern: ^[a-zA-Z0-9_-]+$\n          title: Id\n          type: string\n        input:\n          title: Input\n          type: object\n        name:\n          title: Name\n          type: string\n        server_name:\n          description: The name of the MCP server\n          title: Server Name\n          type: string\n        type:\n          const: mcp_tool_use\n          enum:\n            - mcp_tool_use\n          title: Type\n          type: string\n      required:\n        - id\n        - input\n        - name\n        - server_name\n        - type\n      title: MCP tool use\n      type: object\n    RequestPageLocationCitation:\n      additionalProperties: false\n      properties:\n        cited_text:\n          title: Cited Text\n          type: string\n        document_index:\n          minimum: 0\n          title: Document Index\n          type: integer\n        document_title:\n          anyOf:\n            - maxLength: 255\n              minLength: 1\n              type: string\n            - type: \"null\"\n          title: Document Title\n        end_page_number:\n          title: End Page Number\n          type: integer\n        start_page_number:\n          minimum: 1\n          title: Start Page Number\n          type: integer\n        type:\n          const: page_location\n          enum:\n            - page_location\n          title: Type\n          type: string\n      required:\n        - cited_text\n        - document_index\n        - document_title\n        - end_page_number\n        - start_page_number\n        - type\n      title: Page location\n      type: object\n    RequestRedactedThinkingBlock:\n      additionalProperties: false\n      properties:\n        data:\n          title: Data\n          type: string\n        type:\n          const: redacted_thinking\n          enum:\n            - redacted_thinking\n          title: Type\n          type: string\n      required:\n        - data\n        - type\n      title: Redacted thinking\n      type: object\n    RequestSearchResultBlock:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        citations:\n          $ref: \"#/components/schemas/RequestCitationsConfig\"\n        content:\n          items:\n            $ref: \"#/components/schemas/RequestTextBlock\"\n          title: Content\n          type: array\n        source:\n          title: Source\n          type: string\n        title:\n          title: Title\n          type: string\n        type:\n          const: search_result\n          enum:\n            - search_result\n          title: Type\n          type: string\n      required:\n        - content\n        - source\n        - title\n        - type\n      title: Search result\n      type: object\n    RequestSearchResultLocationCitation:\n      additionalProperties: false\n      properties:\n        cited_text:\n          title: Cited Text\n          type: string\n        end_block_index:\n          title: End Block Index\n          type: integer\n        search_result_index:\n          minimum: 0\n          title: Search Result Index\n          type: integer\n        source:\n          title: Source\n          type: string\n        start_block_index:\n          minimum: 0\n          title: Start Block Index\n          type: integer\n        title:\n          anyOf:\n            - type: string\n            - type: \"null\"\n          title: Title\n        type:\n          const: search_result_location\n          enum:\n            - search_result_location\n          title: Type\n          type: string\n      required:\n        - cited_text\n        - end_block_index\n        - search_result_index\n        - source\n        - start_block_index\n        - title\n        - type\n      title: RequestSearchResultLocationCitation\n      type: object\n    RequestServerToolUseBlock:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        id:\n          pattern: ^srvtoolu_[a-zA-Z0-9_]+$\n          title: Id\n          type: string\n        input:\n          title: Input\n          type: object\n        name:\n          enum:\n            - web_search\n            - code_execution\n          title: Name\n          type: string\n        type:\n          const: server_tool_use\n          enum:\n            - server_tool_use\n          title: Type\n          type: string\n      required:\n        - id\n        - input\n        - name\n        - type\n      title: Server tool use\n      type: object\n    RequestTextBlock:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        citations:\n          anyOf:\n            - items:\n                discriminator:\n                  mapping:\n                    char_location: \"#/components/schemas/RequestCharLocationCitation\"\n                    content_block_location: \"#/components/schemas/RequestContentBlockLocationCitation\"\n                    page_location: \"#/components/schemas/RequestPageLocationCitation\"\n                    search_result_location: \"#/components/schemas/RequestSearchResultLocationCitation\"\n                    web_search_result_location: >-\n                      #/components/schemas/RequestWebSearchResultLocationCitation\n                  propertyName: type\n                oneOf:\n                  - $ref: \"#/components/schemas/RequestCharLocationCitation\"\n                  - $ref: \"#/components/schemas/RequestPageLocationCitation\"\n                  - $ref: \"#/components/schemas/RequestContentBlockLocationCitation\"\n                  - $ref: >-\n                      #/components/schemas/RequestWebSearchResultLocationCitation\n                  - $ref: \"#/components/schemas/RequestSearchResultLocationCitation\"\n              type: array\n            - type: \"null\"\n          title: Citations\n        text:\n          minLength: 1\n          title: Text\n          type: string\n        type:\n          const: text\n          enum:\n            - text\n          title: Type\n          type: string\n      required:\n        - text\n        - type\n      title: Text\n      type: object\n    RequestThinkingBlock:\n      additionalProperties: false\n      properties:\n        signature:\n          title: Signature\n          type: string\n        thinking:\n          title: Thinking\n          type: string\n        type:\n          const: thinking\n          enum:\n            - thinking\n          title: Type\n          type: string\n      required:\n        - signature\n        - thinking\n        - type\n      title: Thinking\n      type: object\n    RequestToolResultBlock:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        content:\n          anyOf:\n            - type: string\n            - items:\n                discriminator:\n                  mapping:\n                    image: \"#/components/schemas/RequestImageBlock\"\n                    search_result: \"#/components/schemas/RequestSearchResultBlock\"\n                    text: \"#/components/schemas/RequestTextBlock\"\n                  propertyName: type\n                oneOf:\n                  - $ref: \"#/components/schemas/RequestTextBlock\"\n                  - $ref: \"#/components/schemas/RequestImageBlock\"\n                  - $ref: \"#/components/schemas/RequestSearchResultBlock\"\n              type: array\n          title: Content\n        is_error:\n          title: Is Error\n          type: boolean\n        tool_use_id:\n          pattern: ^[a-zA-Z0-9_-]+$\n          title: Tool Use Id\n          type: string\n        type:\n          const: tool_result\n          enum:\n            - tool_result\n          title: Type\n          type: string\n      required:\n        - tool_use_id\n        - type\n      title: Tool result\n      type: object\n    RequestToolUseBlock:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        id:\n          pattern: ^[a-zA-Z0-9_-]+$\n          title: Id\n          type: string\n        input:\n          title: Input\n          type: object\n        name:\n          maxLength: 200\n          minLength: 1\n          title: Name\n          type: string\n        type:\n          const: tool_use\n          enum:\n            - tool_use\n          title: Type\n          type: string\n      required:\n        - id\n        - input\n        - name\n        - type\n      title: Tool use\n      type: object\n    RequestWebSearchResultBlock:\n      additionalProperties: false\n      properties:\n        encrypted_content:\n          title: Encrypted Content\n          type: string\n        page_age:\n          anyOf:\n            - type: string\n            - type: \"null\"\n          title: Page Age\n        title:\n          title: Title\n          type: string\n        type:\n          const: web_search_result\n          enum:\n            - web_search_result\n          title: Type\n          type: string\n        url:\n          title: Url\n          type: string\n      required:\n        - encrypted_content\n        - title\n        - type\n        - url\n      title: RequestWebSearchResultBlock\n      type: object\n    RequestWebSearchResultLocationCitation:\n      additionalProperties: false\n      properties:\n        cited_text:\n          title: Cited Text\n          type: string\n        encrypted_index:\n          title: Encrypted Index\n          type: string\n        title:\n          anyOf:\n            - maxLength: 512\n              minLength: 1\n              type: string\n            - type: \"null\"\n          title: Title\n        type:\n          const: web_search_result_location\n          enum:\n            - web_search_result_location\n          title: Type\n          type: string\n        url:\n          maxLength: 2048\n          minLength: 1\n          title: Url\n          type: string\n      required:\n        - cited_text\n        - encrypted_index\n        - title\n        - type\n        - url\n      title: RequestWebSearchResultLocationCitation\n      type: object\n    RequestWebSearchToolResultBlock:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        content:\n          anyOf:\n            - items:\n                $ref: \"#/components/schemas/RequestWebSearchResultBlock\"\n              type: array\n            - $ref: \"#/components/schemas/RequestWebSearchToolResultError\"\n          title: Content\n        tool_use_id:\n          pattern: ^srvtoolu_[a-zA-Z0-9_]+$\n          title: Tool Use Id\n          type: string\n        type:\n          const: web_search_tool_result\n          enum:\n            - web_search_tool_result\n          title: Type\n          type: string\n      required:\n        - content\n        - tool_use_id\n        - type\n      title: Web search tool result\n      type: object\n    RequestWebSearchToolResultError:\n      additionalProperties: false\n      properties:\n        error_code:\n          $ref: \"#/components/schemas/WebSearchToolResultErrorCode\"\n        type:\n          const: web_search_tool_result_error\n          enum:\n            - web_search_tool_result_error\n          title: Type\n          type: string\n      required:\n        - error_code\n        - type\n      title: RequestWebSearchToolResultError\n      type: object\n    ResponseCharLocationCitation:\n      properties:\n        cited_text:\n          title: Cited Text\n          type: string\n        document_index:\n          minimum: 0\n          title: Document Index\n          type: integer\n        document_title:\n          anyOf:\n            - type: string\n            - type: \"null\"\n          title: Document Title\n        end_char_index:\n          title: End Char Index\n          type: integer\n        file_id:\n          anyOf:\n            - type: string\n            - type: \"null\"\n          default: null\n          title: File Id\n        start_char_index:\n          minimum: 0\n          title: Start Char Index\n          type: integer\n        type:\n          const: char_location\n          default: char_location\n          enum:\n            - char_location\n          title: Type\n          type: string\n      required:\n        - cited_text\n        - document_index\n        - document_title\n        - end_char_index\n        - file_id\n        - start_char_index\n        - type\n      title: Character location\n      type: object\n    ResponseCodeExecutionOutputBlock:\n      properties:\n        file_id:\n          title: File Id\n          type: string\n        type:\n          const: code_execution_output\n          default: code_execution_output\n          enum:\n            - code_execution_output\n          title: Type\n          type: string\n      required:\n        - file_id\n        - type\n      title: ResponseCodeExecutionOutputBlock\n      type: object\n    ResponseCodeExecutionResultBlock:\n      properties:\n        content:\n          items:\n            $ref: \"#/components/schemas/ResponseCodeExecutionOutputBlock\"\n          title: Content\n          type: array\n        return_code:\n          title: Return Code\n          type: integer\n        stderr:\n          title: Stderr\n          type: string\n        stdout:\n          title: Stdout\n          type: string\n        type:\n          const: code_execution_result\n          default: code_execution_result\n          enum:\n            - code_execution_result\n          title: Type\n          type: string\n      required:\n        - content\n        - return_code\n        - stderr\n        - stdout\n        - type\n      title: Code execution result\n      type: object\n    ResponseCodeExecutionToolResultBlock:\n      properties:\n        content:\n          anyOf:\n            - $ref: \"#/components/schemas/ResponseCodeExecutionToolResultError\"\n            - $ref: \"#/components/schemas/ResponseCodeExecutionResultBlock\"\n          title: Content\n        tool_use_id:\n          pattern: ^srvtoolu_[a-zA-Z0-9_]+$\n          title: Tool Use Id\n          type: string\n        type:\n          const: code_execution_tool_result\n          default: code_execution_tool_result\n          enum:\n            - code_execution_tool_result\n          title: Type\n          type: string\n      required:\n        - content\n        - tool_use_id\n        - type\n      title: Code execution tool result\n      type: object\n    ResponseCodeExecutionToolResultError:\n      properties:\n        error_code:\n          $ref: \"#/components/schemas/CodeExecutionToolResultErrorCode\"\n        type:\n          const: code_execution_tool_result_error\n          default: code_execution_tool_result_error\n          enum:\n            - code_execution_tool_result_error\n          title: Type\n          type: string\n      required:\n        - error_code\n        - type\n      title: Code execution tool error\n      type: object\n    ResponseContainerUploadBlock:\n      description: Response model for a file uploaded to the container.\n      properties:\n        file_id:\n          title: File Id\n          type: string\n        type:\n          const: container_upload\n          default: container_upload\n          enum:\n            - container_upload\n          title: Type\n          type: string\n      required:\n        - file_id\n        - type\n      title: Container upload\n      type: object\n    ResponseContentBlockLocationCitation:\n      properties:\n        cited_text:\n          title: Cited Text\n          type: string\n        document_index:\n          minimum: 0\n          title: Document Index\n          type: integer\n        document_title:\n          anyOf:\n            - type: string\n            - type: \"null\"\n          title: Document Title\n        end_block_index:\n          title: End Block Index\n          type: integer\n        file_id:\n          anyOf:\n            - type: string\n            - type: \"null\"\n          default: null\n          title: File Id\n        start_block_index:\n          minimum: 0\n          title: Start Block Index\n          type: integer\n        type:\n          const: content_block_location\n          default: content_block_location\n          enum:\n            - content_block_location\n          title: Type\n          type: string\n      required:\n        - cited_text\n        - document_index\n        - document_title\n        - end_block_index\n        - file_id\n        - start_block_index\n        - type\n      title: Content block location\n      type: object\n    ResponseMCPToolResultBlock:\n      properties:\n        content:\n          anyOf:\n            - type: string\n            - items:\n                $ref: \"#/components/schemas/ResponseTextBlock\"\n              type: array\n          title: Content\n        is_error:\n          default: false\n          title: Is Error\n          type: boolean\n        tool_use_id:\n          pattern: ^[a-zA-Z0-9_-]+$\n          title: Tool Use Id\n          type: string\n        type:\n          const: mcp_tool_result\n          default: mcp_tool_result\n          enum:\n            - mcp_tool_result\n          title: Type\n          type: string\n      required:\n        - content\n        - is_error\n        - tool_use_id\n        - type\n      title: MCP tool result\n      type: object\n    ResponseMCPToolUseBlock:\n      properties:\n        id:\n          pattern: ^[a-zA-Z0-9_-]+$\n          title: Id\n          type: string\n        input:\n          title: Input\n          type: object\n        name:\n          description: The name of the MCP tool\n          title: Name\n          type: string\n        server_name:\n          description: The name of the MCP server\n          title: Server Name\n          type: string\n        type:\n          const: mcp_tool_use\n          default: mcp_tool_use\n          enum:\n            - mcp_tool_use\n          title: Type\n          type: string\n      required:\n        - id\n        - input\n        - name\n        - server_name\n        - type\n      title: MCP tool use\n      type: object\n    ResponsePageLocationCitation:\n      properties:\n        cited_text:\n          title: Cited Text\n          type: string\n        document_index:\n          minimum: 0\n          title: Document Index\n          type: integer\n        document_title:\n          anyOf:\n            - type: string\n            - type: \"null\"\n          title: Document Title\n        end_page_number:\n          title: End Page Number\n          type: integer\n        file_id:\n          anyOf:\n            - type: string\n            - type: \"null\"\n          default: null\n          title: File Id\n        start_page_number:\n          minimum: 1\n          title: Start Page Number\n          type: integer\n        type:\n          const: page_location\n          default: page_location\n          enum:\n            - page_location\n          title: Type\n          type: string\n      required:\n        - cited_text\n        - document_index\n        - document_title\n        - end_page_number\n        - file_id\n        - start_page_number\n        - type\n      title: Page location\n      type: object\n    ResponseRedactedThinkingBlock:\n      properties:\n        data:\n          title: Data\n          type: string\n        type:\n          const: redacted_thinking\n          default: redacted_thinking\n          enum:\n            - redacted_thinking\n          title: Type\n          type: string\n      required:\n        - data\n        - type\n      title: Redacted thinking\n      type: object\n    ResponseSearchResultLocationCitation:\n      properties:\n        cited_text:\n          title: Cited Text\n          type: string\n        end_block_index:\n          title: End Block Index\n          type: integer\n        search_result_index:\n          minimum: 0\n          title: Search Result Index\n          type: integer\n        source:\n          title: Source\n          type: string\n        start_block_index:\n          minimum: 0\n          title: Start Block Index\n          type: integer\n        title:\n          anyOf:\n            - type: string\n            - type: \"null\"\n          title: Title\n        type:\n          const: search_result_location\n          default: search_result_location\n          enum:\n            - search_result_location\n          title: Type\n          type: string\n      required:\n        - cited_text\n        - end_block_index\n        - search_result_index\n        - source\n        - start_block_index\n        - title\n        - type\n      title: ResponseSearchResultLocationCitation\n      type: object\n    ResponseServerToolUseBlock:\n      properties:\n        id:\n          pattern: ^srvtoolu_[a-zA-Z0-9_]+$\n          title: Id\n          type: string\n        input:\n          title: Input\n          type: object\n        name:\n          enum:\n            - web_search\n            - code_execution\n          title: Name\n          type: string\n        type:\n          const: server_tool_use\n          default: server_tool_use\n          enum:\n            - server_tool_use\n          title: Type\n          type: string\n      required:\n        - id\n        - input\n        - name\n        - type\n      title: Server tool use\n      type: object\n    ResponseTextBlock:\n      properties:\n        citations:\n          anyOf:\n            - items:\n                discriminator:\n                  mapping:\n                    char_location: \"#/components/schemas/ResponseCharLocationCitation\"\n                    content_block_location: \"#/components/schemas/ResponseContentBlockLocationCitation\"\n                    page_location: \"#/components/schemas/ResponsePageLocationCitation\"\n                    search_result_location: \"#/components/schemas/ResponseSearchResultLocationCitation\"\n                    web_search_result_location: >-\n                      #/components/schemas/ResponseWebSearchResultLocationCitation\n                  propertyName: type\n                oneOf:\n                  - $ref: \"#/components/schemas/ResponseCharLocationCitation\"\n                  - $ref: \"#/components/schemas/ResponsePageLocationCitation\"\n                  - $ref: \"#/components/schemas/ResponseContentBlockLocationCitation\"\n                  - $ref: >-\n                      #/components/schemas/ResponseWebSearchResultLocationCitation\n                  - $ref: \"#/components/schemas/ResponseSearchResultLocationCitation\"\n              type: array\n            - type: \"null\"\n          default: null\n          description: >-\n            Citations supporting the text block.\n\n\n            The type of citation returned will depend on the type of document\n            being cited. Citing a PDF results in `page_location`, plain text\n            results in `char_location`, and content document results in\n            `content_block_location`.\n          title: Citations\n        text:\n          maxLength: 5000000\n          minLength: 0\n          title: Text\n          type: string\n        type:\n          const: text\n          default: text\n          enum:\n            - text\n          title: Type\n          type: string\n      required:\n        - citations\n        - text\n        - type\n      title: Text\n      type: object\n    ResponseThinkingBlock:\n      properties:\n        signature:\n          title: Signature\n          type: string\n        thinking:\n          title: Thinking\n          type: string\n        type:\n          const: thinking\n          default: thinking\n          enum:\n            - thinking\n          title: Type\n          type: string\n      required:\n        - signature\n        - thinking\n        - type\n      title: Thinking\n      type: object\n    ResponseToolUseBlock:\n      properties:\n        id:\n          pattern: ^[a-zA-Z0-9_-]+$\n          title: Id\n          type: string\n        input:\n          title: Input\n          type: object\n        name:\n          minLength: 1\n          title: Name\n          type: string\n        type:\n          const: tool_use\n          default: tool_use\n          enum:\n            - tool_use\n          title: Type\n          type: string\n      required:\n        - id\n        - input\n        - name\n        - type\n      title: Tool use\n      type: object\n    ResponseWebSearchResultBlock:\n      properties:\n        encrypted_content:\n          title: Encrypted Content\n          type: string\n        page_age:\n          anyOf:\n            - type: string\n            - type: \"null\"\n          default: null\n          title: Page Age\n        title:\n          title: Title\n          type: string\n        type:\n          const: web_search_result\n          default: web_search_result\n          enum:\n            - web_search_result\n          title: Type\n          type: string\n        url:\n          title: Url\n          type: string\n      required:\n        - encrypted_content\n        - page_age\n        - title\n        - type\n        - url\n      title: ResponseWebSearchResultBlock\n      type: object\n    ResponseWebSearchResultLocationCitation:\n      properties:\n        cited_text:\n          title: Cited Text\n          type: string\n        encrypted_index:\n          title: Encrypted Index\n          type: string\n        title:\n          anyOf:\n            - maxLength: 512\n              type: string\n            - type: \"null\"\n          title: Title\n        type:\n          const: web_search_result_location\n          default: web_search_result_location\n          enum:\n            - web_search_result_location\n          title: Type\n          type: string\n        url:\n          title: Url\n          type: string\n      required:\n        - cited_text\n        - encrypted_index\n        - title\n        - type\n        - url\n      title: ResponseWebSearchResultLocationCitation\n      type: object\n    ResponseWebSearchToolResultBlock:\n      properties:\n        content:\n          anyOf:\n            - $ref: \"#/components/schemas/ResponseWebSearchToolResultError\"\n            - items:\n                $ref: \"#/components/schemas/ResponseWebSearchResultBlock\"\n              type: array\n          title: Content\n        tool_use_id:\n          pattern: ^srvtoolu_[a-zA-Z0-9_]+$\n          title: Tool Use Id\n          type: string\n        type:\n          const: web_search_tool_result\n          default: web_search_tool_result\n          enum:\n            - web_search_tool_result\n          title: Type\n          type: string\n      required:\n        - content\n        - tool_use_id\n        - type\n      title: Web search tool result\n      type: object\n    ResponseWebSearchToolResultError:\n      properties:\n        error_code:\n          $ref: \"#/components/schemas/WebSearchToolResultErrorCode\"\n        type:\n          const: web_search_tool_result_error\n          default: web_search_tool_result_error\n          enum:\n            - web_search_tool_result_error\n          title: Type\n          type: string\n      required:\n        - error_code\n        - type\n      title: ResponseWebSearchToolResultError\n      type: object\n    ServerToolUsage:\n      properties:\n        web_search_requests:\n          default: 0\n          description: The number of web search tool requests.\n          examples:\n            - 0\n          minimum: 0\n          title: Web Search Requests\n          type: integer\n      required:\n        - web_search_requests\n      title: ServerToolUsage\n      type: object\n    TextEditor_20241022:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        name:\n          const: str_replace_editor\n          description: >-\n            Name of the tool.\n\n\n            This is how the tool will be called by the model and in `tool_use`\n            blocks.\n          enum:\n            - str_replace_editor\n          title: Name\n          type: string\n        type:\n          const: text_editor_20241022\n          enum:\n            - text_editor_20241022\n          title: Type\n          type: string\n      required:\n        - name\n        - type\n      title: Text editor tool (2024-10-22)\n      type: object\n    TextEditor_20250124:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        name:\n          const: str_replace_editor\n          description: >-\n            Name of the tool.\n\n\n            This is how the tool will be called by the model and in `tool_use`\n            blocks.\n          enum:\n            - str_replace_editor\n          title: Name\n          type: string\n        type:\n          const: text_editor_20250124\n          enum:\n            - text_editor_20250124\n          title: Type\n          type: string\n      required:\n        - name\n        - type\n      title: Text editor tool (2025-01-24)\n      type: object\n    TextEditor_20250429:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        name:\n          const: str_replace_based_edit_tool\n          description: >-\n            Name of the tool.\n\n\n            This is how the tool will be called by the model and in `tool_use`\n            blocks.\n          enum:\n            - str_replace_based_edit_tool\n          title: Name\n          type: string\n        type:\n          const: text_editor_20250429\n          enum:\n            - text_editor_20250429\n          title: Type\n          type: string\n      required:\n        - name\n        - type\n      title: Text editor tool (2025-04-29)\n      type: object\n    TextEditor_20250728:\n      additionalProperties: false\n      properties:\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        max_characters:\n          anyOf:\n            - minimum: 1\n              type: integer\n            - type: \"null\"\n          description: >-\n            Maximum number of characters to display when viewing a file. If not\n            specified, defaults to displaying the full file.\n          title: Max Characters\n        name:\n          const: str_replace_based_edit_tool\n          description: >-\n            Name of the tool.\n\n\n            This is how the tool will be called by the model and in `tool_use`\n            blocks.\n          enum:\n            - str_replace_based_edit_tool\n          title: Name\n          type: string\n        type:\n          const: text_editor_20250728\n          enum:\n            - text_editor_20250728\n          title: Type\n          type: string\n      required:\n        - name\n        - type\n      title: TextEditor_20250728\n      type: object\n    ThinkingConfigDisabled:\n      additionalProperties: false\n      properties:\n        type:\n          const: disabled\n          enum:\n            - disabled\n          title: Type\n          type: string\n      required:\n        - type\n      title: Disabled\n      type: object\n    ThinkingConfigEnabled:\n      additionalProperties: false\n      properties:\n        budget_tokens:\n          description: >-\n            Determines how many tokens Claude can use for its internal reasoning\n            process. Larger budgets can enable more thorough analysis for\n            complex problems, improving response quality. \n\n\n            Must be ≥1024 and less than `max_tokens`.\n\n\n            See [extended\n            thinking](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking)\n            for details.\n          minimum: 1024\n          title: Budget Tokens\n          type: integer\n        type:\n          const: enabled\n          enum:\n            - enabled\n          title: Type\n          type: string\n      required:\n        - budget_tokens\n        - type\n      title: Enabled\n      type: object\n    Tool:\n      additionalProperties: false\n      properties:\n        type:\n          anyOf:\n            - type: \"null\"\n            - const: custom\n              enum:\n                - custom\n              type: string\n          title: Type\n        description:\n          description: >-\n            Description of what this tool does.\n\n\n            Tool descriptions should be as detailed as possible. The more\n            information that the model has about what the tool is and how to use\n            it, the better it will perform. You can use natural language\n            descriptions to reinforce important aspects of the tool input JSON\n            schema.\n          examples:\n            - Get the current weather in a given location\n          title: Description\n          type: string\n        name:\n          description: >-\n            Name of the tool.\n\n\n            This is how the tool will be called by the model and in `tool_use`\n            blocks.\n          maxLength: 128\n          minLength: 1\n          pattern: ^[a-zA-Z0-9_-]{1,128}$\n          title: Name\n          type: string\n        input_schema:\n          $ref: \"#/components/schemas/InputSchema\"\n          description: >-\n            [JSON schema](https://json-schema.org/draft/2020-12) for this tool's\n            input.\n\n\n            This defines the shape of the `input` that your tool accepts and\n            that the model will produce.\n          examples:\n            - properties:\n                location:\n                  description: The city and state, e.g. San Francisco, CA\n                  type: string\n                unit:\n                  description: Unit for the output - one of (celsius, fahrenheit)\n                  type: string\n              required:\n                - location\n              type: object\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n      required:\n        - name\n        - input_schema\n      title: Custom tool\n      type: object\n    ToolChoiceAny:\n      additionalProperties: false\n      description: The model will use any available tools.\n      properties:\n        disable_parallel_tool_use:\n          description: >-\n            Whether to disable parallel tool use.\n\n\n            Defaults to `false`. If set to `true`, the model will output exactly\n            one tool use.\n          title: Disable Parallel Tool Use\n          type: boolean\n        type:\n          const: any\n          enum:\n            - any\n          title: Type\n          type: string\n      required:\n        - type\n      title: Any\n      type: object\n    ToolChoiceAuto:\n      additionalProperties: false\n      description: The model will automatically decide whether to use tools.\n      properties:\n        disable_parallel_tool_use:\n          description: >-\n            Whether to disable parallel tool use.\n\n\n            Defaults to `false`. If set to `true`, the model will output at most\n            one tool use.\n          title: Disable Parallel Tool Use\n          type: boolean\n        type:\n          const: auto\n          enum:\n            - auto\n          title: Type\n          type: string\n      required:\n        - type\n      title: Auto\n      type: object\n    ToolChoiceNone:\n      additionalProperties: false\n      description: The model will not be allowed to use tools.\n      properties:\n        type:\n          const: none\n          enum:\n            - none\n          title: Type\n          type: string\n      required:\n        - type\n      title: None\n      type: object\n    ToolChoiceTool:\n      additionalProperties: false\n      description: The model will use the specified tool with `tool_choice.name`.\n      properties:\n        disable_parallel_tool_use:\n          description: >-\n            Whether to disable parallel tool use.\n\n\n            Defaults to `false`. If set to `true`, the model will output exactly\n            one tool use.\n          title: Disable Parallel Tool Use\n          type: boolean\n        name:\n          description: The name of the tool to use.\n          title: Name\n          type: string\n        type:\n          const: tool\n          enum:\n            - tool\n          title: Type\n          type: string\n      required:\n        - name\n        - type\n      title: Tool\n      type: object\n    URLImageSource:\n      additionalProperties: false\n      properties:\n        type:\n          const: url\n          enum:\n            - url\n          title: Type\n          type: string\n        url:\n          title: Url\n          type: string\n      required:\n        - type\n        - url\n      title: URLImageSource\n      type: object\n    URLPDFSource:\n      additionalProperties: false\n      properties:\n        type:\n          const: url\n          enum:\n            - url\n          title: Type\n          type: string\n        url:\n          title: Url\n          type: string\n      required:\n        - type\n        - url\n      title: PDF (URL)\n      type: object\n    Usage:\n      properties:\n        cache_creation:\n          anyOf:\n            - $ref: \"#/components/schemas/CacheCreation\"\n            - type: \"null\"\n          default: null\n          description: Breakdown of cached tokens by TTL\n        cache_creation_input_tokens:\n          anyOf:\n            - minimum: 0\n              type: integer\n            - type: \"null\"\n          default: null\n          description: The number of input tokens used to create the cache entry.\n          examples:\n            - 2051\n          title: Cache Creation Input Tokens\n        cache_read_input_tokens:\n          anyOf:\n            - minimum: 0\n              type: integer\n            - type: \"null\"\n          default: null\n          description: The number of input tokens read from the cache.\n          examples:\n            - 2051\n          title: Cache Read Input Tokens\n        input_tokens:\n          description: The number of input tokens which were used.\n          examples:\n            - 2095\n          minimum: 0\n          title: Input Tokens\n          type: integer\n        output_tokens:\n          description: The number of output tokens which were used.\n          examples:\n            - 503\n          minimum: 0\n          title: Output Tokens\n          type: integer\n        server_tool_use:\n          anyOf:\n            - $ref: \"#/components/schemas/ServerToolUsage\"\n            - type: \"null\"\n          default: null\n          description: The number of server tool requests.\n        service_tier:\n          anyOf:\n            - enum:\n                - standard\n                - priority\n                - batch\n              type: string\n            - type: \"null\"\n          default: null\n          description: If the request used the priority, standard, or batch tier.\n          title: Service Tier\n      required:\n        - cache_creation\n        - cache_creation_input_tokens\n        - cache_read_input_tokens\n        - input_tokens\n        - output_tokens\n        - server_tool_use\n        - service_tier\n      title: Usage\n      type: object\n    UserLocation:\n      additionalProperties: false\n      properties:\n        city:\n          anyOf:\n            - maxLength: 255\n              minLength: 1\n              type: string\n            - type: \"null\"\n          description: The city of the user.\n          examples:\n            - New York\n            - Tokyo\n            - Los Angeles\n          title: City\n        country:\n          anyOf:\n            - maxLength: 2\n              minLength: 2\n              type: string\n            - type: \"null\"\n          description: >-\n            The two letter [ISO country\n            code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) of the user.\n          examples:\n            - US\n            - JP\n            - GB\n          title: Country\n        region:\n          anyOf:\n            - maxLength: 255\n              minLength: 1\n              type: string\n            - type: \"null\"\n          description: The region of the user.\n          examples:\n            - California\n            - Ontario\n            - Wales\n          title: Region\n        timezone:\n          anyOf:\n            - maxLength: 255\n              minLength: 1\n              type: string\n            - type: \"null\"\n          description: The [IANA timezone](https://nodatime.org/TimeZones) of the user.\n          examples:\n            - America/New_York\n            - Asia/Tokyo\n            - Europe/London\n          title: Timezone\n        type:\n          const: approximate\n          enum:\n            - approximate\n          title: Type\n          type: string\n      required:\n        - type\n      title: UserLocation\n      type: object\n    WebSearchToolResultErrorCode:\n      enum:\n        - invalid_tool_input\n        - unavailable\n        - max_uses_exceeded\n        - too_many_requests\n        - query_too_long\n      title: WebSearchToolResultErrorCode\n      type: string\n    WebSearchTool_20250305:\n      additionalProperties: false\n      properties:\n        allowed_domains:\n          anyOf:\n            - items:\n                type: string\n              type: array\n            - type: \"null\"\n          description: >-\n            If provided, only these domains will be included in results. Cannot\n            be used alongside `blocked_domains`.\n          title: Allowed Domains\n        blocked_domains:\n          anyOf:\n            - items:\n                type: string\n              type: array\n            - type: \"null\"\n          description: >-\n            If provided, these domains will never appear in results. Cannot be\n            used alongside `allowed_domains`.\n          title: Blocked Domains\n        cache_control:\n          anyOf:\n            - discriminator:\n                mapping:\n                  ephemeral: \"#/components/schemas/CacheControlEphemeral\"\n                propertyName: type\n              oneOf:\n                - $ref: \"#/components/schemas/CacheControlEphemeral\"\n            - type: \"null\"\n          description: Create a cache control breakpoint at this content block.\n          title: Cache Control\n        max_uses:\n          anyOf:\n            - exclusiveMinimum: 0\n              type: integer\n            - type: \"null\"\n          description: Maximum number of times the tool can be used in the API request.\n          title: Max Uses\n        name:\n          const: web_search\n          description: >-\n            Name of the tool.\n\n\n            This is how the tool will be called by the model and in `tool_use`\n            blocks.\n          enum:\n            - web_search\n          title: Name\n          type: string\n        type:\n          const: web_search_20250305\n          enum:\n            - web_search_20250305\n          title: Type\n          type: string\n        user_location:\n          anyOf:\n            - $ref: \"#/components/schemas/UserLocation\"\n            - type: \"null\"\n          description: >-\n            Parameters for the user's location. Used to provide more relevant\n            search results.\n      required:\n        - name\n        - type\n      title: Web search tool (2025-03-05)\n      type: object\n````\n"
  },
  {
    "path": "aiprompts/anthropic-streaming.md",
    "content": "# Streaming Messages\n\nWhen 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).\n\n## Streaming with SDKs\n\nOur [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.\n\n<CodeGroup>\n  ```Python Python\n  import anthropic\n\nclient = anthropic.Anthropic()\n\nwith client.messages.stream(\nmax_tokens=1024,\nmessages=[{\"role\": \"user\", \"content\": \"Hello\"}],\nmodel=\"claude-opus-4-1-20250805\",\n) as stream:\nfor text in stream.text_stream:\nprint(text, end=\"\", flush=True)\n\n````\n\n```TypeScript TypeScript\nimport Anthropic from '@anthropic-ai/sdk';\n\nconst client = new Anthropic();\n\nawait client.messages.stream({\n    messages: [{role: 'user', content: \"Hello\"}],\n    model: 'claude-opus-4-1-20250805',\n    max_tokens: 1024,\n}).on('text', (text) => {\n    console.log(text);\n});\n````\n\n</CodeGroup>\n\n## Event types\n\nEach 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.\n\nEach stream uses the following event flow:\n\n1. `message_start`: contains a `Message` object with empty `content`.\n2. 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.\n3. One or more `message_delta` events, indicating top-level changes to the final `Message` object.\n4. A final `message_stop` event.\n\n<Warning>\n  The token counts shown in the `usage` field of the `message_delta` event are *cumulative*.\n</Warning>\n\n### Ping events\n\nEvent streams may also include any number of `ping` events.\n\n### Error events\n\nWe 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:\n\n```json Example error\nevent: error\ndata: {\"type\": \"error\", \"error\": {\"type\": \"overloaded_error\", \"message\": \"Overloaded\"}}\n```\n\n### Other events\n\nIn accordance with our [versioning policy](/en/api/versioning), we may add new event types, and your code should handle unknown event types gracefully.\n\n## Content block delta types\n\nEach `content_block_delta` event contains a `delta` of a type that updates the `content` block at a given `index`.\n\n### Text delta\n\nA `text` content block delta looks like:\n\n```JSON Text delta\nevent: content_block_delta\ndata: {\"type\": \"content_block_delta\",\"index\": 0,\"delta\": {\"type\": \"text_delta\", \"text\": \"ello frien\"}}\n```\n\n### Input JSON delta\n\nThe 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_.\n\nYou 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.\n\nA `tool_use` content block delta looks like:\n\n```JSON Input JSON delta\nevent: content_block_delta\ndata: {\"type\": \"content_block_delta\",\"index\": 1,\"delta\": {\"type\": \"input_json_delta\",\"partial_json\": \"{\\\"location\\\": \\\"San Fra\"}}}\n```\n\nNote: 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.\n\n### Thinking delta\n\nWhen 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.\n\nFor 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.\n\nA typical thinking delta looks like:\n\n```JSON Thinking delta\nevent: content_block_delta\ndata: {\"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\"}}\n```\n\nThe signature delta looks like:\n\n```JSON Signature delta\nevent: content_block_delta\ndata: {\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"signature_delta\", \"signature\": \"EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds...\"}}\n```\n\n## Full HTTP Stream response\n\nWe 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.\n\nA stream response is comprised of:\n\n1. A `message_start` event\n2. Potentially multiple content blocks, each of which contains:\n   - A `content_block_start` event\n   - Potentially multiple `content_block_delta` events\n   - A `content_block_stop` event\n3. A `message_delta` event\n4. A `message_stop` event\n\nThere may be `ping` events dispersed throughout the response as well. See [Event types](#event-types) for more details on the format.\n\n### Basic streaming request\n\n<CodeGroup>\n  ```bash Shell\n  curl https://api.anthropic.com/v1/messages \\\n       --header \"anthropic-version: 2023-06-01\" \\\n       --header \"content-type: application/json\" \\\n       --header \"x-api-key: $ANTHROPIC_API_KEY\" \\\n       --data \\\n  '{\n    \"model\": \"claude-opus-4-1-20250805\",\n    \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}],\n    \"max_tokens\": 256,\n    \"stream\": true\n  }'\n  ```\n\n```python Python\nimport anthropic\n\nclient = anthropic.Anthropic()\n\nwith client.messages.stream(\n    model=\"claude-opus-4-1-20250805\",\n    messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n    max_tokens=256,\n) as stream:\n    for text in stream.text_stream:\n        print(text, end=\"\", flush=True)\n```\n\n</CodeGroup>\n\n```json Response\nevent: message_start\ndata: {\"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}}}\n\nevent: content_block_start\ndata: {\"type\": \"content_block_start\", \"index\": 0, \"content_block\": {\"type\": \"text\", \"text\": \"\"}}\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"text_delta\", \"text\": \"Hello\"}}\n\nevent: content_block_delta\ndata: {\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"text_delta\", \"text\": \"!\"}}\n\nevent: content_block_stop\ndata: {\"type\": \"content_block_stop\", \"index\": 0}\n\nevent: message_delta\ndata: {\"type\": \"message_delta\", \"delta\": {\"stop_reason\": \"end_turn\", \"stop_sequence\":null}, \"usage\": {\"output_tokens\": 15}}\n\nevent: message_stop\ndata: {\"type\": \"message_stop\"}\n\n```\n\n### Streaming request with tool use\n\n<Tip>\n  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).\n</Tip>\n\nIn this request, we ask Claude to use a tool to tell us the weather.\n\n<CodeGroup>\n  ```bash Shell\n    curl https://api.anthropic.com/v1/messages \\\n      -H \"content-type: application/json\" \\\n      -H \"x-api-key: $ANTHROPIC_API_KEY\" \\\n      -H \"anthropic-version: 2023-06-01\" \\\n      -d '{\n        \"model\": \"claude-opus-4-1-20250805\",\n        \"max_tokens\": 1024,\n        \"tools\": [\n          {\n            \"name\": \"get_weather\",\n            \"description\": \"Get the current weather in a given location\",\n            \"input_schema\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"location\": {\n                  \"type\": \"string\",\n                  \"description\": \"The city and state, e.g. San Francisco, CA\"\n                }\n              },\n              \"required\": [\"location\"]\n            }\n          }\n        ],\n        \"tool_choice\": {\"type\": \"any\"},\n        \"messages\": [\n          {\n            \"role\": \"user\",\n            \"content\": \"What is the weather like in San Francisco?\"\n          }\n        ],\n        \"stream\": true\n      }'\n  ```\n\n```python Python\nimport anthropic\n\nclient = anthropic.Anthropic()\n\ntools = [\n    {\n        \"name\": \"get_weather\",\n        \"description\": \"Get the current weather in a given location\",\n        \"input_schema\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"location\": {\n                    \"type\": \"string\",\n                    \"description\": \"The city and state, e.g. San Francisco, CA\"\n                }\n            },\n            \"required\": [\"location\"]\n        }\n    }\n]\n\nwith client.messages.stream(\n    model=\"claude-opus-4-1-20250805\",\n    max_tokens=1024,\n    tools=tools,\n    tool_choice={\"type\": \"any\"},\n    messages=[\n        {\n            \"role\": \"user\",\n            \"content\": \"What is the weather like in San Francisco?\"\n        }\n    ],\n) as stream:\n    for text in stream.text_stream:\n        print(text, end=\"\", flush=True)\n```\n\n</CodeGroup>\n\n```json Response\nevent: message_start\ndata: {\"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}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Okay\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\",\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" let\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"'s\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" check\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" the\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" weather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" for\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" San\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Francisco\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\",\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" CA\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\":\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01T1x1fJ34qAmk2tNTrN7Up6\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"location\\\":\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" \\\"San\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" Francisc\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"o,\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" CA\\\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\", \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"unit\\\": \\\"fah\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"renheit\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":89}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n```\n\n### Streaming request with extended thinking\n\nIn this request, we enable extended thinking with streaming to see Claude's step-by-step reasoning.\n\n<CodeGroup>\n  ```bash Shell\n  curl https://api.anthropic.com/v1/messages \\\n       --header \"x-api-key: $ANTHROPIC_API_KEY\" \\\n       --header \"anthropic-version: 2023-06-01\" \\\n       --header \"content-type: application/json\" \\\n       --data \\\n  '{\n      \"model\": \"claude-opus-4-1-20250805\",\n      \"max_tokens\": 20000,\n      \"stream\": true,\n      \"thinking\": {\n          \"type\": \"enabled\",\n          \"budget_tokens\": 16000\n      },\n      \"messages\": [\n          {\n              \"role\": \"user\",\n              \"content\": \"What is 27 * 453?\"\n          }\n      ]\n  }'\n  ```\n\n```python Python\nimport anthropic\n\nclient = anthropic.Anthropic()\n\nwith client.messages.stream(\n    model=\"claude-opus-4-1-20250805\",\n    max_tokens=20000,\n    thinking={\n        \"type\": \"enabled\",\n        \"budget_tokens\": 16000\n    },\n    messages=[\n        {\n            \"role\": \"user\",\n            \"content\": \"What is 27 * 453?\"\n        }\n    ],\n) as stream:\n    for event in stream:\n        if event.type == \"content_block_delta\":\n            if event.delta.type == \"thinking_delta\":\n                print(event.delta.thinking, end=\"\", flush=True)\n            elif event.delta.type == \"text_delta\":\n                print(event.delta.text, end=\"\", flush=True)\n```\n\n</CodeGroup>\n\n```json Response\nevent: message_start\ndata: {\"type\": \"message_start\", \"message\": {\"id\": \"msg_01...\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [], \"model\": \"claude-opus-4-1-20250805\", \"stop_reason\": null, \"stop_sequence\": null}}\n\nevent: content_block_start\ndata: {\"type\": \"content_block_start\", \"index\": 0, \"content_block\": {\"type\": \"thinking\", \"thinking\": \"\"}}\n\nevent: content_block_delta\ndata: {\"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\"}}\n\nevent: content_block_delta\ndata: {\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"thinking_delta\", \"thinking\": \"\\n2. 453 = 400 + 50 + 3\"}}\n\nevent: content_block_delta\ndata: {\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"thinking_delta\", \"thinking\": \"\\n3. 27 * 400 = 10,800\"}}\n\nevent: content_block_delta\ndata: {\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"thinking_delta\", \"thinking\": \"\\n4. 27 * 50 = 1,350\"}}\n\nevent: content_block_delta\ndata: {\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"thinking_delta\", \"thinking\": \"\\n5. 27 * 3 = 81\"}}\n\nevent: content_block_delta\ndata: {\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"thinking_delta\", \"thinking\": \"\\n6. 10,800 + 1,350 + 81 = 12,231\"}}\n\nevent: content_block_delta\ndata: {\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"signature_delta\", \"signature\": \"EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds...\"}}\n\nevent: content_block_stop\ndata: {\"type\": \"content_block_stop\", \"index\": 0}\n\nevent: content_block_start\ndata: {\"type\": \"content_block_start\", \"index\": 1, \"content_block\": {\"type\": \"text\", \"text\": \"\"}}\n\nevent: content_block_delta\ndata: {\"type\": \"content_block_delta\", \"index\": 1, \"delta\": {\"type\": \"text_delta\", \"text\": \"27 * 453 = 12,231\"}}\n\nevent: content_block_stop\ndata: {\"type\": \"content_block_stop\", \"index\": 1}\n\nevent: message_delta\ndata: {\"type\": \"message_delta\", \"delta\": {\"stop_reason\": \"end_turn\", \"stop_sequence\": null}}\n\nevent: message_stop\ndata: {\"type\": \"message_stop\"}\n```\n\n### Streaming request with web search tool use\n\nIn this request, we ask Claude to search the web for current weather information.\n\n<CodeGroup>\n  ```bash Shell\n  curl https://api.anthropic.com/v1/messages \\\n       --header \"x-api-key: $ANTHROPIC_API_KEY\" \\\n       --header \"anthropic-version: 2023-06-01\" \\\n       --header \"content-type: application/json\" \\\n       --data \\\n  '{\n      \"model\": \"claude-opus-4-1-20250805\",\n      \"max_tokens\": 1024,\n      \"stream\": true,\n      \"tools\": [\n          {\n              \"type\": \"web_search_20250305\",\n              \"name\": \"web_search\",\n              \"max_uses\": 5\n          }\n      ],\n      \"messages\": [\n          {\n              \"role\": \"user\",\n              \"content\": \"What is the weather like in New York City today?\"\n          }\n      ]\n  }'\n  ```\n\n```python Python\nimport anthropic\n\nclient = anthropic.Anthropic()\n\nwith client.messages.stream(\n    model=\"claude-opus-4-1-20250805\",\n    max_tokens=1024,\n    tools=[\n        {\n            \"type\": \"web_search_20250305\",\n            \"name\": \"web_search\",\n            \"max_uses\": 5\n        }\n    ],\n    messages=[\n        {\n            \"role\": \"user\",\n            \"content\": \"What is the weather like in New York City today?\"\n        }\n    ],\n) as stream:\n    for text in stream.text_stream:\n        print(text, end=\"\", flush=True)\n```\n\n</CodeGroup>\n\n```json Response\nevent: message_start\ndata: {\"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}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I'll check\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" the current weather in New York City for you\"}}\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\".\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"server_tool_use\",\"id\":\"srvtoolu_014hJH82Qum7Td6UV8gDXThB\",\"name\":\"web_search\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"query\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\":\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" \\\"weather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" NY\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"C to\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"day\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1 }\n\nevent: content_block_start\ndata: {\"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},...]}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":3,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":3,\"delta\":{\"type\":\"text_delta\",\"text\":\"Here's the current weather information for New York\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":3,\"delta\":{\"type\":\"text_delta\",\"text\":\" City:\\n\\n# Weather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":3,\"delta\":{\"type\":\"text_delta\",\"text\":\" in New York City\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":3,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\n\"}}\n\n...\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":17}\n\nevent: message_delta\ndata: {\"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}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n```\n\n## Error recovery\n\nWhen 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.\n\nThe basic recovery strategy involves:\n\n1. **Capture the partial response**: Save all content that was successfully received before the error occurred\n2. **Construct a continuation request**: Create a new API request that includes the partial assistant response as the beginning of a new assistant message\n3. **Resume streaming**: Continue receiving the rest of the response from where it was interrupted\n\n### Error recovery best practices\n\n1. **Use SDK features**: Leverage the SDK's built-in message accumulation and error handling capabilities\n2. **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.\n"
  },
  {
    "path": "aiprompts/blockcontroller-lifecycle.md",
    "content": "# Block Controller Lifecycle\n\n## Overview\n\nBlock 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.\n\n## Controller States\n\nControllers have three primary states:\n- **`init`** - Controller exists but process is not running\n- **`running`** - Process is actively running\n- **`done`** - Process has exited\n\n## Architecture Components\n\n### Backend: Controller Registry\n\nLocation: [`pkg/blockcontroller/blockcontroller.go`](pkg/blockcontroller/blockcontroller.go)\n\nThe backend maintains a **global controller registry** that maps blockIds to controller instances:\n\n```go\nvar (\n    controllerRegistry = make(map[string]Controller)\n    registryLock       sync.RWMutex\n)\n```\n\nControllers implement the [`Controller` interface](pkg/blockcontroller/blockcontroller.go:64):\n- `Start(ctx, blockMeta, rtOpts, force)` - Start the controller process\n- `Stop(graceful, newStatus)` - Stop the controller process\n- `GetRuntimeStatus()` - Get current runtime status\n- `SendInput(input)` - Send input (data, signals, terminal size) to the process\n\n### Frontend: View Model\n\nLocation: [`frontend/app/view/term/term-model.ts`](frontend/app/view/term/term-model.ts)\n\nThe [`TermViewModel`](frontend/app/view/term/term-model.ts:44) manages the frontend side of a terminal block:\n\n**Key Atoms:**\n- `shellProcFullStatus` - Holds the current controller status from backend\n- `shellProcStatus` - Derived atom for just the status string (\"init\", \"running\", \"done\")\n- `isRestarting` - UI state for restart animation\n\n**Event Subscription:**\nThe constructor subscribes to controller status events (line 317-324):\n```typescript\nthis.shellProcStatusUnsubFn = waveEventSubscribe({\n    eventType: \"controllerstatus\",\n    scope: WOS.makeORef(\"block\", blockId),\n    handler: (event) => {\n        let bcRTS: BlockControllerRuntimeStatus = event.data;\n        this.updateShellProcStatus(bcRTS);\n    },\n});\n```\n\nThis creates a **reactive data flow**: backend publishes status updates → frontend receives via WebSocket events → UI updates automatically via Jotai atoms.\n\n## Lifecycle Flow\n\n### 1. Frontend Triggers Controller Creation/Start\n\n**Entry Point:** [`ResyncController()`](pkg/blockcontroller/blockcontroller.go:120) RPC endpoint\n\nThe frontend calls this via [`RpcApi.ControllerResyncCommand`](frontend/app/view/term/term-model.ts:661) when:\n\n1. **Manual Restart** - User clicks restart button or presses Enter when process is done\n   - Triggered by [`forceRestartController()`](frontend/app/view/term/term-model.ts:652)\n   - Passes `forcerestart: true` flag\n   - Includes current terminal size (`termsize: { rows, cols }`)\n\n2. **Connection Status Changes** - Connection becomes available/unavailable\n   - Monitored by [`TermResyncHandler`](frontend/app/view/term/term.tsx:34) component\n   - Watches `connStatus` atom for changes\n   - Calls `termRef.current?.resyncController(\"resync handler\")`\n\n3. **Block Meta Changes** - Configuration like controller type or connection changes\n   - Happens when block metadata is updated\n   - Backend detects changes and triggers resync\n\n### 2. Backend Processes Resync Request\n\nThe [`ResyncController()`](pkg/blockcontroller/blockcontroller.go:120) function:\n\n```go\nfunc ResyncController(ctx context.Context, tabId, blockId string, \n                      rtOpts *waveobj.RuntimeOpts, force bool) error\n```\n\n**Steps:**\n\n1. **Get Block Data** - Fetch block metadata from database\n2. **Determine Controller Type** - Read `controller` meta key (\"shell\", \"cmd\", \"tsunami\")\n3. **Check Existing Controller:**\n   - If controller type changed → stop old, create new\n   - If connection changed (for shell/cmd) → stop and restart\n   - If `force=true` → stop existing\n4. **Register Controller** - Add to registry (replaces existing if present)\n5. **Check if Start Needed** - If status is \"init\" or \"done\":\n   - For remote connections: verify connection status first\n   - Call `controller.Start(ctx, blockMeta, rtOpts, force)`\n6. **Publish Status** - Controller publishes runtime status updates\n\n**Important:** Registering a new controller automatically stops any existing controller for that blockId (line 95-98):\n```go\nif existingController != nil {\n    existingController.Stop(false, Status_Done)\n    wstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, blockId))\n}\n```\n\n### 3. Backend Publishes Status Updates\n\nControllers publish their status via the event system when:\n- Process starts\n- Process state changes\n- Process exits\n\nThe status includes:\n- `shellprocstatus` - \"init\", \"running\", or \"done\"\n- `shellprocconnname` - Connection name being used\n- `shellprocexitcode` - Exit code when done\n- `version` - Incrementing version number for ordering\n\n### 4. Frontend Receives and Processes Updates\n\n**Status Update Handler** (line 321-323):\n```typescript\nhandler: (event) => {\n    let bcRTS: BlockControllerRuntimeStatus = event.data;\n    this.updateShellProcStatus(bcRTS);\n}\n```\n\n**Status Update Logic** (line 430-438):\n```typescript\nupdateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) {\n    if (fullStatus == null) return;\n    const curStatus = globalStore.get(this.shellProcFullStatus);\n    // Only update if newer version\n    if (curStatus == null || curStatus.version < fullStatus.version) {\n        globalStore.set(this.shellProcFullStatus, fullStatus);\n    }\n}\n```\n\nThe version check ensures out-of-order events don't cause issues.\n\n### 5. UI Updates Reactively\n\nThe UI reacts to status changes through Jotai atoms:\n\n**Header Buttons** (line 263-306):\n- Show \"Play\" icon when status is \"init\"\n- Show \"Refresh\" icon when status is \"running\" or \"done\"\n- Display exit code/status icons for cmd controller\n\n**Restart Behavior** (line 631-635 in term.tsx via term-model.ts):\n```typescript\nconst shellProcStatus = globalStore.get(this.shellProcStatus);\nif ((shellProcStatus == \"done\" || shellProcStatus == \"init\") && \n    keyutil.checkKeyPressed(waveEvent, \"Enter\")) {\n    this.forceRestartController();\n    return false;\n}\n```\n\nPressing Enter when the process is done/init triggers a restart.\n\n## Input Flow\n\n**Frontend → Backend:**\n\nWhen user types in terminal, data flows through [`sendDataToController()`](frontend/app/view/term/term-model.ts:408):\n```typescript\nsendDataToController(data: string) {\n    const b64data = stringToBase64(data);\n    RpcApi.ControllerInputCommand(TabRpcClient, { \n        blockid: this.blockId, \n        inputdata64: b64data \n    });\n}\n```\n\nThis calls the backend [`SendInput()`](pkg/blockcontroller/blockcontroller.go:260) function which forwards to the controller's `SendInput()` method.\n\nThe [`BlockInputUnion`](pkg/blockcontroller/blockcontroller.go:48) supports three types of input:\n- `inputdata` - Raw terminal input bytes\n- `signame` - Signal names (e.g., \"SIGTERM\", \"SIGINT\")\n- `termsize` - Terminal size changes (rows/cols)\n\n## Key Design Principles\n\n### 1. Frontend-Driven Architecture\n\nThe frontend has full control over controller lifecycle:\n- **Creates** controllers by calling ResyncController\n- **Restarts** controllers via forcerestart flag\n- **Monitors** status via event subscriptions\n- **Sends input** via ControllerInput RPC\n\nThe backend is stateless and reactive - it doesn't make lifecycle decisions autonomously.\n\n### 2. Idempotent Resync\n\n`ResyncController()` is idempotent - calling it multiple times with the same state is safe:\n- If controller exists and is running with correct type/connection → no-op\n- If configuration changed → replaces controller\n- If force flag set → always restarts\n\nThis makes it safe to call on various triggers (connection change, focus, etc.).\n\n### 3. Versioned Status Updates\n\nStatus includes a monotonically increasing version number:\n- Frontend can process events out-of-order\n- Only applies updates with newer versions\n- Prevents race conditions from concurrent updates\n\n### 4. Automatic Cleanup\n\nWhen a controller is replaced:\n- Old controller is automatically stopped\n- Runtime info is cleaned up\n- Registry entry is updated atomically\n\nThe `registerController()` function handles this automatically (line 84-99).\n\n## Common Patterns\n\n### Restarting a Controller\n\n```typescript\n// In term-model.ts\nforceRestartController() {\n    this.triggerRestartAtom();  // UI feedback\n    const termsize = {\n        rows: this.termRef.current?.terminal?.rows,\n        cols: this.termRef.current?.terminal?.cols,\n    };\n    RpcApi.ControllerResyncCommand(TabRpcClient, {\n        tabid: globalStore.get(atoms.staticTabId),\n        blockid: this.blockId,\n        forcerestart: true,\n        rtopts: { termsize: termsize },\n    });\n}\n```\n\n### Handling Connection Changes\n\n```typescript\n// In term.tsx - TermResyncHandler component\nReact.useEffect(() => {\n    const isConnected = connStatus?.status == \"connected\";\n    const wasConnected = lastConnStatus?.status == \"connected\";\n    if (isConnected == wasConnected && curConnName == lastConnName) {\n        return;  // No change\n    }\n    model.termRef.current?.resyncController(\"resync handler\");\n    setLastConnStatus(connStatus);\n}, [connStatus]);\n```\n\n### Monitoring Status\n\n```typescript\n// Status is automatically available via atom\nconst shellProcStatus = jotai.useAtomValue(model.shellProcStatus);\n\n// Use in UI\nif (shellProcStatus == \"running\") {\n    // Show running state\n} else if (shellProcStatus == \"done\") {\n    // Show restart button\n}\n```\n\n## Summary\n\nThe block controller lifecycle is **frontend-driven and event-reactive**:\n\n1. **Frontend triggers** controller creation/restart via `ControllerResyncCommand` RPC\n2. **Backend processes** the request in `ResyncController()`, creating/starting controllers as needed\n3. **Backend publishes** status updates via WebSocket events\n4. **Frontend receives** status updates and updates Jotai atoms\n5. **UI reacts** automatically to atom changes via React components\n\nThis 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.\n"
  },
  {
    "path": "aiprompts/config-system.md",
    "content": "# Wave Terminal Configuration System\n\nThis document explains how Wave Terminal's configuration system works and provides step-by-step instructions for adding new configuration values.\n\n## Overview\n\nWave Terminal uses a hierarchical configuration system with the following components:\n\n1. **Go Struct Definitions** - Type-safe configuration structure in Go\n2. **JSON Schema** - Validation schema for configuration files\n3. **Default Values** - Built-in default configuration\n4. **User Configuration** - User-customizable settings in `~/.config/waveterm/settings.json`\n5. **Documentation** - User-facing documentation\n\n## Configuration File Structure\n\nWave Terminal's configuration system is organized into several key directories and files:\n\n```\nwaveterm/\n├── pkg/wconfig/                          # Go configuration package\n│   ├── settingsconfig.go                 # Main settings struct definitions\n│   ├── defaultconfig/                    # Default configuration files\n│   │   ├── settings.json                 # Default settings values\n│   │   ├── termthemes.json              # Default terminal themes\n│   │   ├── presets.json                 # Default background presets\n│   │   └── widgets.json                 # Default widget configurations\n│   └── ...                              # Other config-related Go files\n├── schema/                               # JSON Schema definitions\n│   ├── settings.json                     # Settings validation schema\n│   └── ...                              # Other schema files\n├── docs/docs/                           # User documentation\n│   └── config.mdx                       # Configuration documentation\n└── ~/.config/waveterm/                  # User config directory (runtime)\n    ├── settings.json                    # User settings overrides\n    ├── termthemes.json                  # User terminal themes\n    ├── presets.json                     # User background presets\n    ├── widgets.json                     # User widget configurations\n    ├── bookmarks.json                   # Web bookmarks\n    └── connections.json                 # SSH/remote connections\n```\n\n**Key Files:**\n\n- **[`pkg/wconfig/settingsconfig.go`](pkg/wconfig/settingsconfig.go)** - Defines the `SettingsType` struct with all configuration fields\n- **[`schema/settings.json`](schema/settings.json)** - JSON Schema for validation and type checking\n- **[`pkg/wconfig/defaultconfig/settings.json`](pkg/wconfig/defaultconfig/settings.json)** - Default values for all settings\n- **[`docs/docs/config.mdx`](docs/docs/config.mdx)** - User-facing documentation with descriptions and examples\n\n## Configuration Architecture\n\n### Configuration Hierarchy\n\n1. **Built-in Defaults** (`pkg/wconfig/defaultconfig/settings.json`)\n2. **User Settings** (`~/.config/waveterm/settings.json`)\n3. **Block-level Overrides** (stored in block metadata)\n\nSettings cascade from defaults → user settings → block overrides.\n\n### Block-Level Metadata Override System\n\nWave Terminal supports block-level configuration overrides through the metadata system. This allows settings to be applied globally, per-connection, or per-block:\n\n1. **Global Settings** (`~/.config/waveterm/settings.json`) - Apply to all blocks by default\n2. **Connection Settings** (in connections config) - Apply to all blocks using a specific connection\n3. **Block Metadata** - Override settings for individual blocks\n\n**Key Files for Block Overrides:**\n\n- **[`pkg/waveobj/wtypemeta.go`](pkg/waveobj/wtypemeta.go)** - Defines the `MetaTSType` struct for block-level metadata\n- Block metadata fields should match the corresponding settings fields for consistency\n\n**Frontend Usage:**\n\n```typescript\n// Use getOverrideConfigAtom for hierarchical config resolution\nconst settingValue = useAtomValue(getOverrideConfigAtom(blockId, \"namespace:setting\"));\n\n// This automatically resolves in order: block metadata → connection config → global settings → default\n```\n\n**Setting Block Metadata:**\n\n```bash\n# Set for current block\nwsh setmeta namespace:setting=value\n\n# Set for specific block\nwsh setmeta --block BLOCK_ID namespace:setting=value\n```\n\n## How to Add a New Configuration Value\n\nFollow these steps to add a new configuration setting:\n\n### Step 1: Add to Go Struct Definition\n\nEdit [`pkg/wconfig/settingsconfig.go`](pkg/wconfig/settingsconfig.go) and add your new field to the `SettingsType` struct:\n\n```go\ntype SettingsType struct {\n    // ... existing fields ...\n\n    // Add your new field with appropriate JSON tag\n    MyNewSetting string `json:\"mynew:setting,omitempty\"`\n\n    // For different types:\n    MyBoolSetting   bool    `json:\"mynew:boolsetting,omitempty\"`\n    MyNumberSetting float64 `json:\"mynew:numbersetting,omitempty\"`\n    MyIntSetting    *int64  `json:\"mynew:intsetting,omitempty\"`    // Use pointer for optional ints\n    MyArraySetting  []string `json:\"mynew:arraysetting,omitempty\"`\n}\n```\n\n**Naming Conventions:**\n\n- Use namespace prefixes (e.g., `term:`, `window:`, `ai:`, `web:`)\n- Use lowercase with colons as separators\n- Field names should be descriptive and follow Go naming conventions\n- Use `omitempty` tag to exclude empty values from JSON\n\n**Type Guidelines:**\n\n- Use `*int64` and `*float64` for optional numeric values\n- Use `*bool` for optional boolean values\n- Use `string` for text values\n- Use `[]string` for arrays\n- Use `float64` for numbers that can be decimals\n\n### Step 1.5: Add to Block Metadata (Optional)\n\nIf your setting should support block-level overrides, also add it to [`pkg/waveobj/wtypemeta.go`](pkg/waveobj/wtypemeta.go):\n\n```go\ntype MetaTSType struct {\n    // ... existing fields ...\n\n    // Add your new field with matching JSON tag and type\n    MyNewSetting *string `json:\"mynew:setting,omitempty\"`  // Use pointer for optional values\n\n    // For different types:\n    MyBoolSetting   *bool    `json:\"mynew:boolsetting,omitempty\"`\n    MyNumberSetting *float64 `json:\"mynew:numbersetting,omitempty\"`\n    MyIntSetting    *int     `json:\"mynew:intsetting,omitempty\"`\n    MyArraySetting  []string `json:\"mynew:arraysetting,omitempty\"`\n}\n```\n\n**Block Metadata Guidelines:**\n\n- Use pointer types (`*string`, `*bool`, `*int`, `*float64`) for optional overrides\n- JSON tags should exactly match the corresponding settings field\n- This enables the hierarchical config system: block metadata → connection config → global settings\n\n### Step 2: Set Default Value (Optional)\n\nIf your setting should have a default value, add it to [`pkg/wconfig/defaultconfig/settings.json`](pkg/wconfig/defaultconfig/settings.json):\n\n```json\n{\n  \"ai:preset\": \"ai@global\",\n  \"ai:model\": \"gpt-5-mini\",\n  // ... existing defaults ...\n\n  \"mynew:setting\": \"default value\",\n  \"mynew:boolsetting\": true,\n  \"mynew:numbersetting\": 42.5,\n  \"mynew:intsetting\": 100\n}\n```\n\n**Default Value Guidelines:**\n\n- Only add defaults for settings that should have non-zero/non-empty initial values\n- Ensure defaults make sense for the typical user experience\n- Keep defaults conservative and safe\n\n### Step 3: Update Documentation\n\nAdd your new setting to the configuration table in [`docs/docs/config.mdx`](docs/docs/config.mdx):\n\n```markdown\n| Key Name            | Type     | Function                                  |\n| ------------------- | -------- | ----------------------------------------- |\n| mynew:setting       | string   | Description of what this setting controls |\n| mynew:boolsetting   | bool     | Enable/disable some feature               |\n| mynew:numbersetting | float    | Numeric setting for some parameter        |\n| mynew:intsetting    | int      | Integer setting for some configuration    |\n| mynew:arraysetting  | string[] | Array of strings for multiple values      |\n```\n\nAlso update the default configuration example in the same file if you added defaults.\n\n### Step 4: Regenerate Schema and TypeScript Types\n\nRun the generate task to automatically regenerate the JSON schema and TypeScript types:\n\n```bash\ntask generate\n```\n\n**What this does:**\n- Runs `task build:schema` (automatically generates JSON schema from Go structs)\n- Generates TypeScript type definitions in [`frontend/types/gotypes.d.ts`](frontend/types/gotypes.d.ts)\n- Generates RPC client APIs\n- Generates metadata constants\n\n**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.\n\n### Step 5: Use in Frontend Code\n\nAccess your new setting in React components:\n\n```typescript\nimport { getOverrideConfigAtom, useAtomValue } from \"@/store/global\";\n\n// In a React component\nconst MyComponent = ({ blockId }: { blockId: string }) => {\n    // Use override config atom for hierarchical resolution\n    // This automatically checks: block metadata → connection config → global settings → default\n    const mySettingAtom = getOverrideConfigAtom(blockId, \"mynew:setting\");\n    const mySetting = useAtomValue(mySettingAtom) ?? \"fallback value\";\n\n    // For global-only settings (no block overrides)\n    const globalOnlySetting = useAtomValue(getSettingsKeyAtom(\"mynew:globalsetting\")) ?? \"fallback\";\n\n    return <div>Setting value: {mySetting}</div>;\n};\n```\n\n**Frontend Configuration Patterns:**\n\n```typescript\n// 1. Settings with block-level overrides (recommended)\nconst termFontSize = useAtomValue(getOverrideConfigAtom(blockId, \"term:fontsize\")) ?? 12;\n\n// 2. Global-only settings\nconst appGlobalHotkey = useAtomValue(getSettingsKeyAtom(\"app:globalhotkey\")) ?? \"\";\n\n// 3. Connection-specific settings\nconst connStatus = useAtomValue(getConnStatusAtom(connectionName));\n```\n\n### Step 6: Use in Backend Code\n\nAccess settings in Go code:\n\n```go\n// Get the full config\nfullConfig := wconfig.GetWatcher().GetFullConfig()\n\n// Access your setting\nmyValue := fullConfig.Settings.MyNewSetting\n```\n\n## Configuration Patterns\n\n### Namespace Organization\n\nSettings are organized by namespace using colon separators:\n\n- `app:*` - Application-level settings\n- `term:*` - Terminal-specific settings\n- `window:*` - Window and UI settings\n- `ai:*` - AI-related settings\n- `web:*` - Web browser settings\n- `editor:*` - Code editor settings\n- `conn:*` - Connection settings\n\n### Clear/Reset Pattern\n\nEach namespace can have a \"clear\" field for resetting all settings in that namespace:\n\n```go\nAppClear  bool `json:\"app:*,omitempty\"`\nTermClear bool `json:\"term:*,omitempty\"`\n```\n\n### Optional vs Required Settings\n\n- Use pointer types (`*bool`, `*int64`, `*float64`) for truly optional settings\n- Use regular types for settings that should always have a value\n- Provide sensible defaults for important settings\n\n### Block-Level Overrides\n\nSettings can be overridden at the block level using metadata:\n\n```typescript\n// Set block-specific override\nawait RpcApi.SetMetaCommand(TabRpcClient, {\n  oref: WOS.makeORef(\"block\", blockId),\n  meta: { \"mynew:setting\": \"block-specific value\" },\n});\n```\n\n## Example: Adding a New Terminal Setting\n\nHere's a complete example adding a new terminal setting `term:bellsound` with block-level override support:\n\n### 1. Go Struct (settingsconfig.go)\n\n```go\ntype SettingsType struct {\n    // ... existing fields ...\n    TermBellSound string `json:\"term:bellsound,omitempty\"`\n}\n```\n\n### 2. Block Metadata (wtypemeta.go)\n\n```go\ntype MetaTSType struct {\n    // ... existing fields ...\n    TermBellSound *string `json:\"term:bellsound,omitempty\"`  // Pointer for optional override\n}\n```\n\n### 3. Default Value (defaultconfig/settings.json - optional)\n\n```json\n{\n  \"term:bellsound\": \"default\"\n}\n```\n\n### 4. Documentation (docs/config.mdx)\n\n```markdown\n| term:bellsound | string | Sound to play for terminal bell (\"default\", \"none\", or custom sound file path) |\n```\n\n### 5. Regenerate Types\n\n```bash\ntask generate\n```\n\n### 6. Frontend Usage\n\n```typescript\n// Use override config for hierarchical resolution\nconst bellSoundAtom = getOverrideConfigAtom(blockId, \"term:bellsound\");\nconst bellSound = useAtomValue(bellSoundAtom) ?? \"default\";\n```\n\n### 7. Usage Examples\n\n```bash\n# Set globally\nwsh setconfig term:bellsound=\"custom.wav\"\n\n# Set for current block only\nwsh setmeta term:bellsound=\"none\"\n\n# Set for specific block\nwsh setmeta --block BLOCK_ID term:bellsound=\"beep\"\n```\n\n## Testing Your Configuration\n\n1. **Build and run** Wave Terminal with your changes\n2. **Test default behavior** - Ensure the default value works\n3. **Test user override** - Add your setting to `~/.config/waveterm/settings.json`\n4. **Test block override** - Set block-specific metadata\n5. **Verify schema validation** - Ensure invalid values are rejected\n\n## Common Pitfalls\n"
  },
  {
    "path": "aiprompts/conn-arch.md",
    "content": "# Wave Terminal Connection Architecture\n\n## Overview\n\nWave 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.\n\n## Architecture Layers\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                    Block Controllers                             │\n│  (blockcontroller/blockcontroller.go, shellcontroller.go)      │\n│  - Block lifecycle management                                    │\n│  - Controller registry and switching                             │\n│  - Connection status verification                                │\n└─────────────────────────────────────────────────────────────────┘\n                              ↓\n┌─────────────────────────────────────────────────────────────────┐\n│              Connection Controllers (ConnUnion)                  │\n│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐         │\n│  │   Local      │  │     SSH      │  │     WSL      │         │\n│  │              │  │ (conncontrol │  │  (wslconn)   │         │\n│  │              │  │    ler)      │  │              │         │\n│  └──────────────┘  └──────────────┘  └──────────────┘         │\n│  - Connection lifecycle (init → connecting → connected)         │\n│  - WSH (Wave Shell Extensions) management                       │\n│  - Domain socket setup for RPC communication                    │\n└─────────────────────────────────────────────────────────────────┘\n                              ↓\n┌─────────────────────────────────────────────────────────────────┐\n│                  Shell Process Execution                         │\n│                   (shellexec/shellexec.go)                      │\n│  - ShellProc wrapper for running processes                       │\n│  - PTY management                                                │\n│  - Process lifecycle (start, wait, kill)                         │\n└─────────────────────────────────────────────────────────────────┘\n                              ↓\n┌─────────────────────────────────────────────────────────────────┐\n│              Low-Level Connection Implementation                 │\n│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐         │\n│  │   os/exec    │  │golang.org/x/ │  │  pkg/wsl     │         │\n│  │              │  │  crypto/ssh  │  │              │         │\n│  └──────────────┘  └──────────────┘  └──────────────┘         │\n│  - Local process spawning                                        │\n│  - SSH protocol implementation                                   │\n│  - WSL command execution                                         │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n## Key Components\n\n### 1. Block Controllers (`pkg/blockcontroller/`)\n\n**Primary Files:**\n- [`blockcontroller.go`](../pkg/blockcontroller/blockcontroller.go) - Controller registry and orchestration\n- [`shellcontroller.go`](../pkg/blockcontroller/shellcontroller.go) - Shell/terminal controller implementation\n\n**Responsibilities:**\n- **Controller Registry**: Maintains a global map of active block controllers (`controllerRegistry`)\n- **Lifecycle Management**: Handles controller creation, starting, stopping, and switching\n- **Connection Verification**: Checks connection status before starting shell processes ([`CheckConnStatus()`](../pkg/blockcontroller/blockcontroller.go:360))\n- **Controller Types**: Supports different controller types (shell, cmd, tsunami)\n\n**Key Functions:**\n- [`ResyncController()`](../pkg/blockcontroller/blockcontroller.go:120) - Main entry point for synchronizing block state with desired controller\n- [`registerController()`](../pkg/blockcontroller/blockcontroller.go:84) - Registers a new controller, stopping any existing one\n- [`getController()`](../pkg/blockcontroller/blockcontroller.go:78) - Retrieves active controller for a block\n\n**ShellController Details:**\n- Implements the `Controller` interface\n- Manages shell processes via [`ShellProc`](../pkg/shellexec/shellexec.go:48)\n- Handles three connection types via `ConnUnion`:\n  - **Local**: Direct process execution on local machine\n  - **SSH**: Remote execution via SSH connections\n  - **WSL**: Windows Subsystem for Linux execution\n- Key methods:\n  - [`setupAndStartShellProcess()`](../pkg/blockcontroller/shellcontroller.go:364) - Sets up and starts shell process\n  - [`getConnUnion()`](../pkg/blockcontroller/shellcontroller.go:321) - Determines connection type and retrieves connection object\n  - [`manageRunningShellProcess()`](../pkg/blockcontroller/shellcontroller.go:500+) - Manages I/O for running process\n\n### 2. Connection Controllers\n\n#### SSH Connections (`pkg/remote/conncontroller/`)\n\n**Primary File:** [`conncontroller.go`](../pkg/remote/conncontroller/conncontroller.go)\n\n**Architecture:**\n- **Global Registry**: `clientControllerMap` maintains all SSH connections\n- **Connection Lifecycle**: \n  ```\n  init → connecting → connected → (running) → disconnected/error\n  ```\n- **Thread Safety**: Each connection has its own lock (`SSHConn.Lock`)\n\n**SSHConn Structure:**\n```go\ntype SSHConn struct {\n    Lock               *sync.Mutex\n    Status             string           // Connection state\n    WshEnabled         *atomic.Bool     // WSH availability flag\n    Opts               *remote.SSHOpts  // Connection parameters\n    Client             *ssh.Client      // Underlying SSH client\n    DomainSockName     string          // Unix socket for RPC\n    DomainSockListener net.Listener    // Socket listener\n    ConnController     *ssh.Session    // Runs \"wsh connserver\"\n    Error              string          // Connection error\n    WshError           string          // WSH-specific error\n    WshVersion         string          // Installed WSH version\n    // ...\n}\n```\n\n**Key Responsibilities:**\n1. **SSH Client Management**: \n   - Establishes SSH connections using [`golang.org/x/crypto/ssh`](https://pkg.go.dev/golang.org/x/crypto/ssh)\n   - Handles authentication (pubkey, password, keyboard-interactive)\n   - Supports ProxyJump for multi-hop connections\n\n2. **Domain Socket Setup** ([`OpenDomainSocketListener()`](../pkg/remote/conncontroller/conncontroller.go:201)):\n   - Creates Unix domain socket on remote host (`/tmp/waveterm-*.sock`)\n   - Enables bidirectional RPC communication\n   - Socket used by both connserver and shell processes\n\n3. **WSH (Wave Shell Extensions) Management**:\n   - **Version Check** ([`StartConnServer()`](../pkg/remote/conncontroller/conncontroller.go:277)): Runs `wsh version` to check installation\n   - **Installation** ([`InstallWsh()`](../pkg/remote/conncontroller/conncontroller.go:478)): Copies appropriate WSH binary to remote\n   - **Update** ([`UpdateWsh()`](../pkg/remote/conncontroller/conncontroller.go:417)): Updates existing WSH installation\n   - **User Prompts** ([`getPermissionToInstallWsh()`](../pkg/remote/conncontroller/conncontroller.go:434)): Asks user for install permission\n\n4. **Connection Server** (`wsh connserver`):\n   - Long-running process on remote host\n   - Provides RPC services for file operations, command execution, etc.\n   - Communicates via domain socket\n   - Template: [`ConnServerCmdTemplate`](../pkg/remote/conncontroller/conncontroller.go:74)\n\n**Connection Flow:**\n```\n1. GetConn(opts) - Retrieve or create connection\n2. Connect(ctx) - Initiate connection\n3. CheckIfNeedsAuth() - Verify authentication needed\n4. OpenDomainSocketListener() - Set up RPC channel\n5. StartConnServer() - Launch wsh connserver\n6. (Install/Update WSH if needed)\n7. Status: Connected - Ready for shell processes\n```\n\n#### SSH Client (`pkg/remote/sshclient.go`)\n\n**Responsibilities:**\n- **Authentication Methods**:\n  - Public key with optional passphrase ([`createPublicKeyCallback()`](../pkg/remote/sshclient.go:118))\n  - Password authentication ([`createPasswordCallbackPrompt()`](../pkg/remote/sshclient.go:227))\n  - Keyboard-interactive ([`createInteractiveKbdInteractiveChallenge()`](../pkg/remote/sshclient.go:264))\n  - SSH agent support\n\n- **Known Hosts Verification** ([`createHostKeyCallback()`](../pkg/remote/sshclient.go:429)):\n  - Reads `~/.ssh/known_hosts` and global known_hosts\n  - Prompts user for unknown hosts\n  - Handles key changes/mismatches\n\n- **ProxyJump Support**:\n  - Recursive connection through jump hosts\n  - Max depth: `SshProxyJumpMaxDepth = 10`\n\n- **User Interaction**:\n  - Integrates with Wave's [`userinput`](../pkg/userinput/) system\n  - Non-blocking prompts for passwords, passphrases, host verification\n\n#### WSL Connections (`pkg/wslconn/`)\n\n**Primary File:** [`wslconn.go`](../pkg/wslconn/wslconn.go)\n\n**Architecture:**\n- **Similar to SSH**: Parallel structure to `conncontroller` but for WSL\n- **Global Registry**: `clientControllerMap` for WSL connections\n- **Connection Naming**: `wsl://[distro-name]` (e.g., `wsl://Ubuntu`)\n\n**WslConn Structure:**\n```go\ntype WslConn struct {\n    Lock               *sync.Mutex\n    Status             string\n    WshEnabled         *atomic.Bool\n    Name               wsl.WslName      // Distro name\n    Client             *wsl.Distro      // WSL distro interface\n    DomainSockName     string          // Uses RemoteFullDomainSocketPath\n    ConnController     *wsl.WslCmd     // Runs \"wsh connserver\"\n    // ... similar to SSHConn\n}\n```\n\n**Key Differences from SSH:**\n- **No Network Socket**: WSL processes run locally, no SSH connection needed\n- **Domain Socket Path**: Uses predetermined path ([`wavebase.RemoteFullDomainSocketPath`](../pkg/wavebase/))\n- **Command Execution**: Uses `wsl.exe` command-line tool\n- **Simpler Authentication**: No auth needed, user already logged into Windows\n\n**Connection Flow:**\n```\n1. GetWslConn(distroName) - Get/create WSL connection\n2. Connect(ctx) - Start connection process\n3. OpenDomainSocketListener() - Set domain socket path (no actual listener)\n4. StartConnServer() - Launch wsh connserver in WSL\n5. (Install/Update WSH if needed)\n6. Status: Connected - Ready for shell processes\n```\n\n### 3. Shell Process Execution (`pkg/shellexec/`)\n\n**Primary File:** [`shellexec.go`](../pkg/shellexec/shellexec.go)\n\n**ShellProc Structure:**\n```go\ntype ShellProc struct {\n    ConnName  string          // Connection identifier\n    Cmd       ConnInterface   // Actual process interface\n    CloseOnce *sync.Once      // Ensures single close\n    DoneCh    chan any        // Signals process completion\n    WaitErr   error           // Process exit status\n}\n```\n\n**ConnInterface Implementations:**\n- **Local**: [`CombinedConnInterface`](../pkg/shellexec/) wraps `os/exec.Cmd` with PTY\n- **SSH**: [`RemoteConnInterface`](../pkg/shellexec/) wraps SSH session\n- **WSL**: [`WslConnInterface`](../pkg/shellexec/) wraps WSL command\n\n**Process Startup Functions:**\n- [`StartLocalShellProc()`](../pkg/shellexec/) - Local shell processes\n- [`StartRemoteShellProc()`](../pkg/shellexec/) - SSH remote shells (with WSH)\n- [`StartRemoteShellProcNoWsh()`](../pkg/shellexec/) - SSH remote shells (no WSH)\n- [`StartWslShellProc()`](../pkg/shellexec/) - WSL shells (with WSH)\n- [`StartWslShellProcNoWsh()`](../pkg/shellexec/) - WSL shells (no WSH)\n\n**Key Features:**\n- **PTY Management**: Pseudo-terminal for interactive shells\n- **Graceful Shutdown**: Sends SIGTERM, waits briefly, then SIGKILL\n- **Process Wrapping**: Abstracts differences between local/remote/WSL execution\n\n### 4. Generic Connection Interface (`pkg/genconn/`)\n\n**Purpose**: Provides abstraction layer for running commands across different connection types\n\n**Primary File:** [`ssh-impl.go`](../pkg/genconn/ssh-impl.go)\n\n**Interface Hierarchy:**\n```go\nShellClient -> ShellProcessController\n```\n\n**SSHShellClient:**\n- Wraps `*ssh.Client`\n- Creates `SSHProcessController` for each command\n\n**SSHProcessController:**\n- Wraps `*ssh.Session`\n- Implements stdio piping (stdin, stdout, stderr)\n- Handles command lifecycle (Start, Wait, Kill)\n- Thread-safe with internal locking\n\n**Usage Pattern:**\n```go\nclient := genconn.MakeSSHShellClient(sshClient)\nproc, _ := client.MakeProcessController(cmdSpec)\nstdout, _ := proc.StdoutPipe()\nproc.Start()\n// Read from stdout...\nproc.Wait()\n```\n\n### 5. Shell Utilities (`pkg/util/shellutil/`)\n\n**Primary File:** [`shellutil.go`](../pkg/util/shellutil/shellutil.go)\n\n**Responsibilities:**\n\n1. **Shell Detection**:\n   - [`DetectLocalShellPath()`](../pkg/util/shellutil/shellutil.go:87) - Finds user's default shell\n   - [`GetShellTypeFromShellPath()`](../pkg/util/shellutil/shellutil.go:462) - Identifies shell type (bash, zsh, fish, pwsh)\n   - [`DetectShellTypeAndVersion()`](../pkg/util/shellutil/shellutil.go:486) - Gets shell version info\n\n2. **Shell Integration Files**:\n   - [`InitCustomShellStartupFiles()`](../pkg/util/shellutil/shellutil.go:270) - Creates Wave's shell integration\n   - Manages startup files for each shell type:\n     - Bash: `.bashrc` in `shell/bash/`\n     - Zsh: `.zshrc`, `.zprofile`, etc. in `shell/zsh/`\n     - Fish: `wave.fish` in `shell/fish/`\n     - PowerShell: `wavepwsh.ps1` in `shell/pwsh/`\n\n3. **Environment Management**:\n   - [`WaveshellLocalEnvVars()`](../pkg/util/shellutil/shellutil.go:218) - Wave-specific environment variables\n   - [`UpdateCmdEnv()`](../pkg/util/shellutil/shellutil.go:231) - Updates command environment\n\n4. **WSH Binary Management**:\n   - [`GetLocalWshBinaryPath()`](../pkg/util/shellutil/shellutil.go:334) - Locates platform-specific WSH binary\n   - Supports multiple OS/arch combinations\n\n5. **Git Bash Detection** (Windows):\n   - [`FindGitBash()`](../pkg/util/shellutil/shellutil.go:156) - Locates Git Bash installation\n   - Checks multiple common installation paths\n\n## Connection Types and Workflows\n\n### Local Connections\n\n**Connection Name**: `\"local\"`, `\"local:\"`, or `\"\"` (empty)\n\n**Workflow:**\n1. Block controller checks connection type via [`IsLocalConnName()`](../pkg/remote/conncontroller/conncontroller.go:80)\n2. No connection setup needed\n3. Shell process started directly via [`StartLocalShellProc()`](../pkg/shellexec/)\n4. Uses `os/exec.Cmd` with PTY\n5. WSH integration via environment variables\n\n**Special Case - Git Bash (Windows):**\n- Variant: `\"local:gitbash\"`\n- Requires special shell path detection\n- Uses Git Bash binary instead of default shell\n\n### SSH Connections\n\n**Connection Name**: `\"user@host:port\"` (parsed by [`remote.ParseOpts()`](../pkg/remote/))\n\n**Full Connection Workflow:**\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│ 1. Connection Request (from Block Controller)                   │\n└─────────────────────────────────────────────────────────────────┘\n                              ↓\n┌─────────────────────────────────────────────────────────────────┐\n│ 2. GetConn(opts) - Retrieve/Create SSHConn                      │\n│    - Check global registry (clientControllerMap)                │\n│    - Create new SSHConn if needed                               │\n│    - Status: \"init\"                                             │\n└─────────────────────────────────────────────────────────────────┘\n                              ↓\n┌─────────────────────────────────────────────────────────────────┐\n│ 3. conn.Connect(ctx) - Establish SSH Connection                 │\n│    - Status: \"connecting\"                                        │\n│    - Read SSH config (~/.ssh/config)                            │\n│    - Resolve ProxyJump if configured                            │\n│    - Create SSH client auth methods:                            │\n│      • Public key (with agent support)                          │\n│      • Password                                                 │\n│      • Keyboard-interactive                                     │\n│    - Establish SSH connection                                    │\n│    - Verify known_hosts                                         │\n└─────────────────────────────────────────────────────────────────┘\n                              ↓\n┌─────────────────────────────────────────────────────────────────┐\n│ 4. OpenDomainSocketListener(ctx) - Set Up RPC Channel          │\n│    - Create random socket path: /tmp/waveterm-[random].sock    │\n│    - Use ssh.Client.ListenUnix() for remote forwarding         │\n│    - Start RPC listener goroutine                               │\n│    - Socket available for all subsequent operations             │\n└─────────────────────────────────────────────────────────────────┘\n                              ↓\n┌─────────────────────────────────────────────────────────────────┐\n│ 5. StartConnServer(ctx) - Launch Wave Shell Extensions         │\n│    - Run: \"wsh version\" to check installation                   │\n│    - If not installed or outdated:                              │\n│      a. Detect remote platform (OS/arch)                        │\n│      b. Get user permission (if configured)                     │\n│      c. InstallWsh() - Copy binary to remote                    │\n│      d. Retry StartConnServer()                                 │\n│    - Run: \"wsh connserver\" on remote                            │\n│    - Pass JWT token for authentication                          │\n│    - Monitor connserver output                                   │\n│    - Wait for RPC route registration                            │\n│    - Status: \"connected\"                                         │\n└─────────────────────────────────────────────────────────────────┘\n                              ↓\n┌─────────────────────────────────────────────────────────────────┐\n│ 6. Connection Ready - Can Start Shell Processes                 │\n│    - SSHConn available in registry                              │\n│    - Domain socket active for RPC                               │\n│    - WSH connserver running                                     │\n└─────────────────────────────────────────────────────────────────┘\n                              ↓\n┌─────────────────────────────────────────────────────────────────┐\n│ 7. Start Shell Process (from ShellController)                   │\n│    - setupAndStartShellProcess()                                │\n│    - Create swap token (for shell integration)                  │\n│    - StartRemoteShellProc() or StartRemoteShellProcNoWsh()     │\n│    - SSH session created for shell                              │\n│    - PTY allocated                                              │\n│    - Shell starts with Wave integration                         │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n**WSH (Wave Shell Extensions) Details:**\n\n**What is WSH?**\n- Binary program (`wsh`) that runs on remote hosts\n- Provides RPC services for Wave Terminal\n- Written in Go, cross-platform\n- Versioned to match Wave Terminal version\n\n**WSH Components:**\n1. **wsh version**: Reports installed version\n2. **wsh connserver**: Long-running RPC server\n   - Handles file operations\n   - Executes commands\n   - Provides remote state information\n   - Communicates over domain socket\n\n**WSH Installation Process:**\n1. Check if wsh is installed: Run `wsh version`\n2. If not installed: Detect platform with `uname -sm`\n3. Get appropriate binary from local cache\n4. Copy to remote: `~/.waveterm/bin/wsh`\n5. Set executable permissions\n6. Restart connection process\n\n**With vs Without WSH:**\n- **With WSH**: Full RPC support, better integration, file sync\n- **Without WSH**: Basic shell only, limited features\n- Fallback to no-WSH mode on installation failure\n\n### WSL Connections\n\n**Connection Name**: `\"wsl://[distro]\"` (e.g., `\"wsl://Ubuntu\"`)\n\n**Workflow:**\n```\n1. GetWslConn(distroName) - Get/create WslConn\n2. conn.Connect(ctx) - Start connection\n3. OpenDomainSocketListener() - Set socket path (no actual listener)\n4. StartConnServer() - Launch \"wsh connserver\" via wsl.exe\n5. Install/update WSH if needed (similar to SSH)\n6. Status: \"connected\"\n7. StartWslShellProc() - Create shell process in WSL\n```\n\n**Key Differences from SSH:**\n- Uses `wsl.exe` command-line tool\n- No network connection overhead\n- Predetermined domain socket path\n- Simpler authentication (inherited from Windows)\n\n## Token Swap System\n\n**Purpose**: Pass connection-specific environment variables to shell processes\n\n**Implementation:** [`shellutil.TokenSwapEntry`](../pkg/util/shellutil/)\n\n**Flow:**\n1. ShellController creates swap token before starting process\n2. Token contains:\n   - Socket name for RPC\n   - JWT token for authentication\n   - RPC context (TabId, BlockId, Conn)\n   - Custom environment variables\n3. Token stored in global swap map\n4. Shell process receives token ID via environment\n5. Shell integration scripts swap token for actual values\n6. Token removed from map after use\n\n**Purpose:**\n- Avoid exposing JWT tokens in process listings\n- Enable shell integration without hardcoded values\n- Support multiple shells on same connection\n\n## Error Handling and Recovery\n\n### Connection Failures\n\n**SSH Connection Errors:**\n- Authentication failure → Prompt user (password, passphrase)\n- Host key mismatch → Prompt for verification\n- Network timeout → Status: \"error\", display error message\n- ProxyJump failure → Error shows which jump host failed\n\n**Recovery Mechanisms:**\n- [`conn.Reconnect(ctx)`](../pkg/remote/conncontroller/) - Close and re-establish connection\n- [`conn.WaitForConnect(ctx)`](../pkg/remote/conncontroller/) - Block until connected\n- Automatic fallback to no-WSH mode on installation failure\n\n### Process Failures\n\n**Shell Process Errors:**\n- Process crash → WaitErr contains exit code\n- PTY failure → Captured in error message\n- I/O errors → Logged and surfaced to user\n\n**Cleanup:**\n- [`ShellProc.Close()`](../pkg/shellexec/shellexec.go:56) - Graceful then forceful kill\n- [`SSHConn.close_nolock()`](../pkg/remote/conncontroller/conncontroller.go:167) - Cleanup all resources\n- [`deleteController()`](../pkg/blockcontroller/blockcontroller.go:101) - Remove from registry\n\n## Configuration Integration\n\n### Connection Configuration\n\n**Source:** [`pkg/wconfig/`](../pkg/wconfig/)\n\n**Per-Connection Settings:**\n- `conn:wshenabled` - Enable/disable WSH\n- `conn:wshpath` - Custom WSH binary path\n- `conn:shellpath` - Custom shell path\n\n**Global Settings:**\n- `conn:askbeforewshinstall` - Prompt before WSH installation\n- Stored in `~/.waveterm/config/settings.json`\n- Per-connection overrides in `~/.waveterm/config/connections.json`\n\n### SSH Configuration\n\n**Source:** `~/.ssh/config`\n\n**Supported Directives:**\n- `Host` - Connection matching\n- `HostName` - Target hostname\n- `Port` - SSH port\n- `User` - Username\n- `IdentityFile` - Private key paths\n- `ProxyJump` - Jump host specification\n- `UserKnownHostsFile` - Known hosts file\n- `GlobalKnownHostsFile` - System known hosts\n- `AddKeysToAgent` - Add keys to SSH agent\n\n**Library:** [`github.com/kevinburke/ssh_config`](https://github.com/kevinburke/ssh_config)\n\n## Thread Safety\n\n### Synchronization Patterns\n\n**SSHConn/WslConn:**\n```go\nconn.Lock.Lock()\ndefer conn.Lock.Unlock()\n// ... modify connection state\n```\n\n**Atomic Flags:**\n```go\nconn.WshEnabled.Load()    // Read WSH enabled status\nconn.WshEnabled.Store(v)  // Update atomically\n```\n\n**Controller Registry:**\n```go\nregistryLock.RLock()       // Read lock for lookups\nregistryLock.Lock()        // Write lock for modifications\n```\n\n**ShellProc Completion:**\n```go\nsp.CloseOnce.Do(func() {   // Ensure single execution\n    sp.WaitErr = waitErr\n    close(sp.DoneCh)       // Signal completion\n})\n```\n\n## Event System Integration\n\n### Connection Events\n\n**Published via:** [`pkg/wps/`](../pkg/wps/) (Wave Publish/Subscribe)\n\n**Event Types:**\n- `Event_ConnChange` - Connection status changed\n- `Event_ControllerStatus` - Block controller status update\n- `Event_BlockFile` - Block file operation (terminal output)\n\n**Example:**\n```go\nwps.Broker.Publish(wps.WaveEvent{\n    Event: wps.Event_ConnChange,\n    Scopes: []string{fmt.Sprintf(\"connection:%s\", connName)},\n    Data: connStatus,\n})\n```\n\n**Frontend Integration:**\n- Events received via WebSocket\n- Connection status updates UI\n- Real-time terminal output streaming\n\n## Summary of Responsibilities\n\n| Component | Responsibilities |\n|-----------|-----------------|\n| **blockcontroller/** | Block lifecycle, controller registry, connection coordination |\n| **shellcontroller** | Shell process management, ConnUnion abstraction, I/O handling |\n| **conncontroller/** | SSH connection lifecycle, WSH management, domain socket setup |\n| **wslconn/** | WSL connection lifecycle, parallel to SSH but for WSL |\n| **sshclient.go** | Low-level SSH: auth, known_hosts, ProxyJump |\n| **shellexec/** | Process execution abstraction, PTY management |\n| **genconn/** | Generic command execution interface |\n| **shellutil/** | Shell detection, integration files, environment setup |\n\n## Key Design Principles\n\n1. **Layered Architecture**: Clear separation between block management, connection management, and process execution\n\n2. **Connection Abstraction**: ConnUnion pattern allows uniform handling of Local/SSH/WSL\n\n3. **WSH Optional**: System works with and without Wave Shell Extensions, degrading gracefully\n\n4. **Thread Safety**: Defensive locking, atomic flags, singleton patterns prevent race conditions\n\n5. **Error Recovery**: Multiple retry mechanisms, fallback modes, user prompts for resolution\n\n6. **Configuration Hierarchy**: Global → Connection-Specific → Runtime overrides\n\n7. **Event-Driven Updates**: Real-time status updates via pub/sub system\n\n8. **User Interaction**: Non-blocking prompts for passwords, confirmations, installations\n\nThis architecture provides a robust foundation for Wave Terminal's multi-environment shell capabilities, with clear extension points for adding new connection types or capabilities."
  },
  {
    "path": "aiprompts/contextmenu.md",
    "content": "# Context Menu Quick Reference\n\nThis guide provides a quick overview of how to create and display a context menu using our system.\n\n---\n\n## ContextMenuItem Type\n\nDefine each menu item using the `ContextMenuItem` type:\n\n```ts\ntype ContextMenuItem = {\n  label?: string;\n  type?: \"separator\" | \"normal\" | \"submenu\" | \"checkbox\" | \"radio\";\n  role?: string; // Electron role (optional)\n  click?: () => void; // Callback for item selection (not needed if role is set)\n  submenu?: ContextMenuItem[]; // For nested menus\n  checked?: boolean; // For checkbox or radio items\n  visible?: boolean;\n  enabled?: boolean;\n  sublabel?: string;\n};\n```\n\n---\n\n## Import and Show the Menu\n\nImport the context menu module:\n\n```ts\nimport { ContextMenuModel } from \"@/app/store/contextmenu\";\n```\n\nTo display the context menu, call:\n\n```ts\nContextMenuModel.showContextMenu(menu, event);\n```\n\n- **menu**: An array of `ContextMenuItem`.\n- **event**: The mouse event that triggered the context menu (typically from an onContextMenu handler).\n\n---\n\n## Basic Example\n\nA simple context menu with a separator:\n\n```ts\nconst menu: ContextMenuItem[] = [\n  {\n    label: \"New File\",\n    click: () => {\n      /* create a new file */\n    },\n  },\n  {\n    label: \"New Folder\",\n    click: () => {\n      /* create a new folder */\n    },\n  },\n  { type: \"separator\" },\n  {\n    label: \"Rename\",\n    click: () => {\n      /* rename item */\n    },\n  },\n];\n\nContextMenuModel.showContextMenu(menu, e);\n```\n\n---\n\n## Example with Submenu and Checkboxes\n\nToggle settings using a submenu with checkbox items:\n\n```ts\nconst isClearOnStart = true; // Example setting\n\nconst menu: ContextMenuItem[] = [\n  {\n    label: \"Clear Output On Restart\",\n    submenu: [\n      {\n        label: \"On\",\n        type: \"checkbox\",\n        checked: isClearOnStart,\n        click: () => {\n          // Set the config to enable clear on restart\n        },\n      },\n      {\n        label: \"Off\",\n        type: \"checkbox\",\n        checked: !isClearOnStart,\n        click: () => {\n          // Set the config to disable clear on restart\n        },\n      },\n    ],\n  },\n];\n\nContextMenuModel.showContextMenu(menu, e);\n```\n\n---\n\n## Editing a Config File Example\n\nOpen a configuration file (e.g., `widgets.json`) in preview mode:\n\n```ts\n{\n    label: \"Edit widgets.json\",\n    click: () => {\n        fireAndForget(async () => {\n            const path = `${getApi().getConfigDir()}/widgets.json`;\n            const blockDef: BlockDef = {\n                meta: { view: \"preview\", file: path },\n            };\n            await createBlock(blockDef, false, true);\n        });\n    },\n}\n```\n\n---\n\n## Summary\n\n- **Menu Definition**: Use the `ContextMenuItem` type.\n- **Actions**: Use `click` for actions; use `submenu` for nested options.\n- **Separators**: Use `type: \"separator\"` to group items.\n- **Toggles**: Use `type: \"checkbox\"` or `\"radio\"` with the `checked` property.\n- **Displaying**: Use `ContextMenuModel.showContextMenu(menu, event)` to render the menu.\n"
  },
  {
    "path": "aiprompts/fe-conn-arch.md",
    "content": "# Wave Terminal Frontend Connection Architecture\n\n## Overview\n\nThe 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.\n\n## Architecture Pattern\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                        User Interface                            │\n│  - ConnectionButton (displays status)                           │\n│  - ChangeConnectionBlockModal (connection picker)               │\n│  - ConnStatusOverlay (error states)                             │\n└─────────────────────────────────────────────────────────────────┘\n                               ↕\n┌─────────────────────────────────────────────────────────────────┐\n│                      Jotai Reactive State                        │\n│  - ConnStatusMapAtom (connection statuses)                      │\n│  - View Model Atoms (derived connection state)                  │\n│  - Block Metadata (connection selection)                        │\n└─────────────────────────────────────────────────────────────────┘\n                               ↕\n┌─────────────────────────────────────────────────────────────────┐\n│                         RPC Commands                             │\n│  - ConnListCommand (list connections)                           │\n│  - ConnEnsureCommand (ensure connected)                         │\n│  - ConnConnectCommand/ConnDisconnectCommand                     │\n│  - SetMetaCommand (change block connection)                     │\n│  - ControllerInputCommand (send data to shell)                  │\n└─────────────────────────────────────────────────────────────────┘\n                               ↕\n┌─────────────────────────────────────────────────────────────────┐\n│                    Backend (see conn-arch.md)                    │\n│  - Connection Controllers (SSHConn, WslConn)                    │\n│  - Block Controllers (ShellController)                          │\n│  - Shell Process Execution                                       │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n## Key Components\n\n### 1. Connection State Management ([`frontend/app/store/global.ts`](../frontend/app/store/global.ts))\n\n**ConnStatusMapAtom**\n```typescript\nconst ConnStatusMapAtom = atom(new Map<string, PrimitiveAtom<ConnStatus>>())\n```\n\n- Global registry of connection status atoms\n- One atom per connection (keyed by connection name)\n- Backend updates status via wave events\n- Frontend components subscribe to individual connection atoms\n\n**getConnStatusAtom()**\n```typescript\nfunction getConnStatusAtom(connName: string): PrimitiveAtom<ConnStatus>\n```\n\n- Retrieves or creates status atom for a connection\n- Returns cached atom if exists\n- Creates new atom initialized to default if needed\n- Used by view models to track their connection\n\n**ConnStatus Structure**\n```typescript\ninterface ConnStatus {\n    status: \"init\" | \"connecting\" | \"connected\" | \"disconnected\" | \"error\"\n    connection: string           // Connection name\n    connected: boolean           // Is currently connected\n    activeconnnum: number        // Color assignment number (1-8)\n    wshenabled: boolean         // WSH available on this connection\n    error?: string              // Error message if status is \"error\"\n    wsherror?: string           // WSH-specific error\n}\n```\n\n**allConnStatusAtom**\n```typescript\nconst allConnStatusAtom = atom<ConnStatus[]>((get) => {\n    const connStatusMap = get(ConnStatusMapAtom)\n    const connStatuses = Array.from(connStatusMap.values()).map((atom) => get(atom))\n    return connStatuses\n})\n```\n\n- Provides array of all connection statuses\n- Used by connection modal to display all available connections\n- Automatically updates when any connection status changes\n\n### 2. Connection Button UI ([`frontend/app/block/blockutil.tsx`](../frontend/app/block/blockutil.tsx))\n\n**ConnectionButton Component**\n\n```typescript\nexport const ConnectionButton = React.memo(\n    React.forwardRef<HTMLDivElement, ConnectionButtonProps>(\n        ({ connection, changeConnModalAtom }, ref) => {\n            const connStatusAtom = getConnStatusAtom(connection)\n            const connStatus = jotai.useAtomValue(connStatusAtom)\n            // ... renders connection status with colored icon\n        }\n    )\n)\n```\n\n**Responsibilities:**\n- Displays connection name and status icon\n- Color-codes connections (8 colors, cycling)\n- Shows visual states:\n  - **Local**: Laptop icon (grey)\n  - **Connecting**: Animated dots (yellow/warning)\n  - **Connected**: Arrow icon (colored by activeconnnum)\n  - **Error**: Slashed arrow icon (red)\n  - **Disconnected**: Slashed arrow icon (grey)\n- Opens connection modal on click\n\n**Color Assignment:**\n```typescript\nfunction computeConnColorNum(connStatus: ConnStatus): number {\n    const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors\n    return connColorNum == 0 ? NumActiveConnColors : connColorNum\n}\n```\n\n- Backend assigns `activeconnnum` sequentially\n- Frontend cycles through 8 CSS color variables\n- `var(--conn-icon-color-1)` through `var(--conn-icon-color-8)`\n\n### 3. Connection Selection Modal ([`frontend/app/modals/conntypeahead.tsx`](../frontend/app/modals/conntypeahead.tsx))\n\n**ChangeConnectionBlockModal Component**\n\n**Data Fetching:**\n```typescript\nuseEffect(() => {\n    if (!changeConnModalOpen) return\n    \n    // Fetch available connections\n    RpcApi.ConnListCommand(TabRpcClient, { timeout: 2000 })\n        .then(setConnList)\n    \n    RpcApi.WslListCommand(TabRpcClient, { timeout: 2000 })\n        .then(setWslList)\n    \n    RpcApi.ConnListAWSCommand(TabRpcClient, { timeout: 2000 })\n        .then(setS3List)\n}, [changeConnModalOpen])\n```\n\n**Connection Change Handler:**\n```typescript\nconst changeConnection = async (connName: string) => {\n    // Update block metadata with new connection\n    await RpcApi.SetMetaCommand(TabRpcClient, {\n        oref: WOS.makeORef(\"block\", blockId),\n        meta: { \n            connection: connName,\n            file: newFile,        // Reset file path for new connection\n            \"cmd:cwd\": null      // Clear working directory\n        }\n    })\n    \n    // Ensure connection is established\n    await RpcApi.ConnEnsureCommand(TabRpcClient, {\n        connname: connName,\n        logblockid: blockId\n    }, { timeout: 60000 })\n}\n```\n\n**Suggestion Categories:**\n1. **Local Connections**\n   - Local machine (`\"\"` or `\"local:\"`)\n   - Git Bash (Windows only: `\"local:gitbash\"`)\n   - WSL distros (`\"wsl://Ubuntu\"`, etc.)\n\n2. **Remote Connections** (SSH)\n   - User-configured SSH connections\n   - Format: `\"user@host\"` or `\"user@host:port\"`\n   - Filtered by `display:hidden` config\n\n3. **S3 Connections** (optional)\n   - AWS S3 profiles\n   - Format: `\"aws:profile-name\"`\n\n4. **Actions**\n   - Reconnect (if disconnected/error)\n   - Disconnect (if connected)\n   - Edit Connections (opens config editor)\n   - New Connection (creates new SSH config)\n\n**Filtering Logic:**\n```typescript\nfunction filterConnections(\n    connList: Array<string>,\n    connSelected: string,\n    fullConfig: FullConfigType,\n    filterOutNowsh: boolean\n): Array<string> {\n    const connectionsConfig = fullConfig.connections\n    return connList.filter((conn) => {\n        const hidden = connectionsConfig?.[conn]?.[\"display:hidden\"] ?? false\n        const wshEnabled = connectionsConfig?.[conn]?.[\"conn:wshenabled\"] ?? true\n        return conn.includes(connSelected) && \n               !hidden && \n               (wshEnabled || !filterOutNowsh)\n    })\n}\n```\n\n### 4. Connection Status Overlay ([`frontend/app/block/blockframe.tsx`](../frontend/app/block/blockframe.tsx))\n\n**ConnStatusOverlay Component**\n\nDisplays over block content when:\n- Connection is disconnected or in error state\n- WSH installation/update errors occur\n- Not in layout mode (Ctrl+Shift held)\n- Connection modal is not open\n\n**Features:**\n- Shows connection status text\n- Displays error messages (scrollable)\n- Reconnect button (for disconnected/error)\n- \"Always disable wsh\" button (for WSH errors)\n- Adaptive layout based on width\n\n**Handlers:**\n```typescript\n// Reconnect to failed connection\nconst handleTryReconnect = () => {\n    RpcApi.ConnConnectCommand(TabRpcClient, {\n        host: connName,\n        logblockid: nodeModel.blockId\n    }, { timeout: 60000 })\n}\n\n// Disable WSH for this connection\nconst handleDisableWsh = async () => {\n    await RpcApi.SetConnectionsConfigCommand(TabRpcClient, {\n        host: connName,\n        metamaptype: { \"conn:wshenabled\": false }\n    })\n}\n```\n\n### 5. View Model Integration\n\nView models integrate connection state into their reactive data flow:\n\n#### Terminal View Model ([`frontend/app/view/term/term-model.ts`](../frontend/app/view/term/term-model.ts))\n\n```typescript\nclass TermViewModel implements ViewModel {\n    // Connection management flag\n    manageConnection = atom((get) => {\n        const termMode = get(this.termMode)\n        if (termMode == \"vdom\") return false  // VDOM mode doesn't show conn button\n        \n        const isCmd = get(this.isCmdController)\n        if (isCmd) return false  // Cmd controller doesn't manage connections\n        \n        return true  // Standard terminals show connection button\n    })\n    \n    // Connection status for this block\n    connStatus = atom((get) => {\n        const blockData = get(this.blockAtom)\n        const connName = blockData?.meta?.connection\n        const connAtom = getConnStatusAtom(connName)\n        return get(connAtom)\n    })\n    \n    // Filter connections without WSH\n    filterOutNowsh = atom(false)\n}\n```\n\n**End Icon Button Logic:**\n```typescript\nendIconButtons = atom((get) => {\n    const connStatus = get(this.connStatus)\n    const shellProcStatus = get(this.shellProcStatus)\n    \n    // Only show restart button if connected\n    if (connStatus?.status != \"connected\") {\n        return []\n    }\n    \n    // Show appropriate icon based on shell state\n    if (shellProcStatus == \"init\") {\n        return [{ icon: \"play\", title: \"Click to Start Shell\" }]\n    } else if (shellProcStatus == \"running\") {\n        return [{ icon: \"refresh\", title: \"Shell Running. Click to Restart\" }]\n    } else if (shellProcStatus == \"done\") {\n        return [{ icon: \"refresh\", title: \"Shell Exited. Click to Restart\" }]\n    }\n})\n```\n\n#### Preview View Model ([`frontend/app/view/preview/preview-model.tsx`](../frontend/app/view/preview/preview-model.tsx))\n\n```typescript\nclass PreviewModel implements ViewModel {\n    // Always manages connection\n    manageConnection = atom(true)\n    \n    // Connection status\n    connStatus = atom((get) => {\n        const blockData = get(this.blockAtom)\n        const connName = blockData?.meta?.connection\n        const connAtom = getConnStatusAtom(connName)\n        return get(connAtom)\n    })\n    \n    // Filter out connections without WSH (file ops require WSH)\n    filterOutNowsh = atom(true)\n    \n    // Ensure connection before operations\n    connection = atom<Promise<string>>(async (get) => {\n        const connName = get(this.blockAtom)?.meta?.connection\n        try {\n            await RpcApi.ConnEnsureCommand(TabRpcClient, {\n                connname: connName\n            }, { timeout: 60000 })\n            globalStore.set(this.connectionError, \"\")\n        } catch (e) {\n            globalStore.set(this.connectionError, e as string)\n        }\n        return connName\n    })\n}\n```\n\n**File Operations Over Connection:**\n```typescript\n// Reads file from remote/local connection\nstatFile = atom<Promise<FileInfo>>(async (get) => {\n    const fileName = get(this.metaFilePath)\n    const path = await this.formatRemoteUri(fileName, get)\n    \n    return await RpcApi.FileInfoCommand(TabRpcClient, {\n        info: { path }\n    })\n})\n\nfullFile = atom<Promise<FileData>>(async (get) => {\n    const fileName = get(this.metaFilePath)\n    const path = await this.formatRemoteUri(fileName, get)\n    \n    return await RpcApi.FileReadCommand(TabRpcClient, {\n        info: { path }\n    })\n})\n```\n\n### 6. Block Controller Integration\n\n**View models do NOT directly manage shell processes.** They interact with block controllers via RPC:\n\n**Starting a Shell:**\n```typescript\n// User clicks restart button in terminal\nforceRestartController() {\n    // Backend handles connection verification and process startup\n    RpcApi.ControllerRestartCommand(TabRpcClient, {\n        blockid: this.blockId,\n        force: true\n    })\n}\n```\n\n**Sending Input to Shell:**\n```typescript\nsendDataToController(data: string) {\n    const b64data = stringToBase64(data)\n    RpcApi.ControllerInputCommand(TabRpcClient, {\n        blockid: this.blockId,\n        inputdata64: b64data\n    })\n}\n```\n\n**Backend Block Controller Flow:**\n1. Frontend calls `ControllerRestartCommand`\n2. Backend `ShellController.Run()` starts\n3. `CheckConnStatus()` verifies connection is ready\n4. If not connected, triggers connection attempt\n5. Once connected, `setupAndStartShellProcess()`\n6. `getConnUnion()` retrieves appropriate connection (Local/SSH/WSL)\n7. `StartLocalShellProc()`, `StartRemoteShellProc()`, or `StartWslShellProc()`\n8. Process I/O managed by `manageRunningShellProcess()`\n\n## Connection Configuration\n\n### Hierarchical Configuration System\n\nWave uses a three-level config hierarchy for connections:\n\n1. **Global Settings** (`settings`)\n2. **Connection-Level Config** (`connections[connName]`)\n3. **Block-Level Overrides** (`block.meta`)\n\n**Override Resolution:**\n```typescript\nfunction getOverrideConfigAtom<T>(blockId: string, key: T): Atom<T> {\n    return atom((get) => {\n        // 1. Check block metadata\n        const metaKeyVal = get(getBlockMetaKeyAtom(blockId, key))\n        if (metaKeyVal != null) return metaKeyVal\n        \n        // 2. Check connection config\n        const connName = get(getBlockMetaKeyAtom(blockId, \"connection\"))\n        const connConfigKeyVal = get(getConnConfigKeyAtom(connName, key))\n        if (connConfigKeyVal != null) return connConfigKeyVal\n        \n        // 3. Fall back to global settings\n        const settingsVal = get(getSettingsKeyAtom(key))\n        return settingsVal ?? null\n    })\n}\n```\n\n### Common Connection Settings\n\n**Connection Keywords** (apply to specific connections):\n- `conn:wshenabled` - Enable/disable WSH for this connection\n- `conn:wshpath` - Custom WSH binary path\n- `display:hidden` - Hide connection from selector\n- `display:order` - Sort order in connection list\n- `term:fontsize` - Font size for terminals on this connection\n- `term:theme` - Color theme for terminals on this connection\n\n**Example Usage in View Models:**\n```typescript\n// Font size with connection override\nfontSizeAtom = atom((get) => {\n    const blockData = get(this.blockAtom)\n    const connName = blockData?.meta?.connection\n    const fullConfig = get(atoms.fullConfigAtom)\n    \n    // Check: block meta > connection config > global settings\n    const fontSize = blockData?.meta?.[\"term:fontsize\"] ??\n                     fullConfig?.connections?.[connName]?.[\"term:fontsize\"] ??\n                     get(getSettingsKeyAtom(\"term:fontsize\")) ??\n                     12\n    \n    return boundNumber(fontSize, 4, 64)\n})\n```\n\n## RPC Interface\n\n### Connection Management Commands\n\n**ConnListCommand**\n```typescript\nConnListCommand(client: RpcClient): Promise<string[]>\n```\n- Returns list of configured SSH connection names\n- Used by connection modal to populate remote connections\n- Filters by `display:hidden` config on frontend\n\n**WslListCommand**\n```typescript\nWslListCommand(client: RpcClient): Promise<string[]>\n```\n- Returns list of installed WSL distribution names\n- Windows only (silently fails on other platforms)\n- Connection names formatted as `wsl://[distro]`\n\n**ConnListAWSCommand**\n```typescript\nConnListAWSCommand(client: RpcClient): Promise<string[]>\n```\n- Returns list of AWS profile names from config\n- Used for S3 preview connections\n- Connection names formatted as `aws:[profile]`\n\n**ConnEnsureCommand**\n```typescript\nConnEnsureCommand(\n    client: RpcClient,\n    data: { connname: string, logblockid?: string }\n): Promise<void>\n```\n- Ensures connection is in \"connected\" state\n- Triggers connection if not already connected\n- Waits for connection to complete or timeout\n- Used before file operations and by view models\n\n**ConnConnectCommand**\n```typescript\nConnConnectCommand(\n    client: RpcClient,\n    data: { host: string, logblockid?: string }\n): Promise<void>\n```\n- Explicitly connects to specified connection\n- Used by \"Reconnect\" action in overlay\n- Returns when connection succeeds or fails\n\n**ConnDisconnectCommand**\n```typescript\nConnDisconnectCommand(\n    client: RpcClient,\n    connName: string\n): Promise<void>\n```\n- Disconnects active connection\n- Used by \"Disconnect\" action in connection modal\n- Closes all shells/processes on that connection\n\n**SetMetaCommand**\n```typescript\nSetMetaCommand(\n    client: RpcClient,\n    data: {\n        oref: string,           // WaveObject reference\n        meta: MetaType          // Metadata updates\n    }\n): Promise<void>\n```\n- Updates block metadata (including connection)\n- Used when changing block's connection\n- Triggers backend to switch connection context\n\n**SetConnectionsConfigCommand**\n```typescript\nSetConnectionsConfigCommand(\n    client: RpcClient,\n    data: {\n        host: string,           // Connection name\n        metamaptype: any        // Config updates\n    }\n): Promise<void>\n```\n- Updates connection-level configuration\n- Used to disable WSH (`conn:wshenabled: false`)\n- Persists to config file\n\n### File Operations (Connection-Aware)\n\n**FileInfoCommand**\n```typescript\nFileInfoCommand(\n    client: RpcClient,\n    data: { info: { path: string } }\n): Promise<FileInfo>\n```\n- Gets file metadata (size, type, permissions, etc.)\n- Path format: `[connName]:[filepath]` (e.g., `user@host:~/file.txt`)\n- Uses connection's WSH for remote files\n\n**FileReadCommand**\n```typescript\nFileReadCommand(\n    client: RpcClient,\n    data: { info: { path: string } }\n): Promise<FileData>\n```\n- Reads file content as base64\n- Supports streaming for large files\n- Remote files read via connection's WSH\n\n### Controller Commands (Indirect Connection Usage)\n\n**ControllerInputCommand**\n```typescript\nControllerInputCommand(\n    client: RpcClient,\n    data: { blockid: string, inputdata64: string }\n): Promise<void>\n```\n- Sends input to block's controller (shell)\n- Controller uses block's connection for execution\n- Base64-encoded to handle binary data\n\n**ControllerRestartCommand**\n```typescript\nControllerRestartCommand(\n    client: RpcClient,\n    data: { blockid: string, force?: boolean }\n): Promise<void>\n```\n- Restarts block's controller\n- Backend checks connection status before starting\n- If not connected, triggers connection first\n\n## Event-Driven Updates\n\n### Wave Event Subscriptions\n\n**Connection Status Updates:**\n```typescript\nwaveEventSubscribe({\n    eventType: \"connstatus\",\n    handler: (event) => {\n        const status: ConnStatus = event.data\n        updateConnStatusAtom(status.connection, status)\n    }\n})\n```\n- Backend emits connection status changes\n- Frontend updates corresponding atom\n- All subscribed components re-render automatically\n\n**Configuration Updates:**\n```typescript\nwaveEventSubscribe({\n    eventType: \"config\",\n    handler: (event) => {\n        const fullConfig = event.data.fullconfig\n        globalStore.set(atoms.fullConfigAtom, fullConfig)\n    }\n})\n```\n- Backend watches config files for changes\n- Pushes updates to all connected frontends\n- Connection configuration changes take effect immediately\n\n## Data Flow Patterns\n\n### Pattern 1: Changing Block Connection\n\n```\nUser Action: Click connection button → select new connection\n                        ↓\n          ChangeConnectionBlockModal.changeConnection()\n                        ↓\n              RpcApi.SetMetaCommand({ connection: newConn })\n                        ↓\n         Backend updates block metadata → emits waveobj:update\n                        ↓\n              Frontend WOS updates blockAtom\n                        ↓\n          View model connStatus atom recomputes\n                        ↓\n           ConnectionButton re-renders with new connection\n                        ↓\n         RpcApi.ConnEnsureCommand() ensures connected\n                        ↓\n        Backend triggers connection if needed\n                        ↓\n      Backend emits connstatus events as connection progresses\n                        ↓\n    Frontend updates ConnStatus atom (\"connecting\" → \"connected\")\n                        ↓\n         ConnectionButton shows connecting animation → connected state\n```\n\n### Pattern 2: Shell Process Lifecycle\n\n```\nUser Action: Press Enter in disconnected terminal\n                        ↓\n    View model detects shellProcStatus == \"init\" or \"done\"\n                        ↓\n          forceRestartController() called\n                        ↓\n        RpcApi.ControllerRestartCommand()\n                        ↓\n    Backend ShellController.Run() starts\n                        ↓\n         CheckConnStatus() verifies connection\n                        ↓\n        If not connected: trigger connection\n                        ↓\n   (Frontend shows ConnStatusOverlay with \"connecting\")\n                        ↓\n         Connection succeeds → WSH available\n                        ↓\n       setupAndStartShellProcess()\n                        ↓\n  StartRemoteShellProc() with connection's SSH client\n                        ↓\n   Backend emits controllerstatus event\n                        ↓\n      Frontend updates shellProcStatus atom\n                        ↓\n  View model endIconButtons recomputes (restart button)\n                        ↓\n       Terminal ready for input\n```\n\n### Pattern 3: File Preview Over Connection\n\n```\nUser Action: Open preview block with file path\n                        ↓\n     PreviewModel initialized with file path\n                        ↓\n         connection atom ensures connection\n                        ↓\n     RpcApi.ConnEnsureCommand(connName)\n                        ↓\n  Backend establishes connection if needed\n                        ↓\n  (Frontend shows ConnStatusOverlay if connecting)\n                        ↓\n         Connection ready\n                        ↓\n     statFile atom triggers FileInfoCommand\n                        ↓\n      Backend routes to connection's WSH\n                        ↓\n     WSH executes stat on remote file\n                        ↓\n        FileInfo returned to frontend\n                        ↓\n   PreviewModel determines if text/binary/streaming\n                        ↓\n    fullFile atom triggers FileReadCommand\n                        ↓\n      Backend streams file via WSH\n                        ↓\n     File content displayed in preview\n```\n\n## Connection Types and Behaviors\n\n### Local Connection\n\n**Connection Names:**\n- `\"\"` (empty string)\n- `\"local\"`\n- `\"local:\"`\n- `\"local:gitbash\"` (Windows only)\n\n**Frontend Behavior:**\n- No connection modal interaction needed\n- ConnectionButton shows laptop icon (grey)\n- No ConnStatusOverlay shown (always \"connected\")\n- File paths used directly without connection prefix\n- Shell processes spawn locally via `os/exec`\n\n**View Model Configuration:**\n```typescript\nconnName = \"\" // or \"local\" or \"local:gitbash\"\nconnStatus = {\n    status: \"connected\",\n    connection: \"\",\n    connected: true,\n    activeconnnum: 0,  // No color assignment\n    wshenabled: true   // Local WSH always available\n}\n```\n\n### SSH Connection\n\n**Connection Names:**\n- Format: `\"user@host\"`, `\"user@host:port\"`, or config name\n- Examples: `\"ubuntu@192.168.1.10\"`, `\"myserver\"`, `\"deploy@prod:2222\"`\n\n**Frontend Behavior:**\n- ConnectionButton shows arrow icon with color\n- Color cycles through 8 colors based on `activeconnnum`\n- ConnStatusOverlay shown during connecting/error states\n- File paths prefixed with connection: `user@host:~/file.txt`\n- Modal allows reconnect/disconnect actions\n\n**Connection States:**\n```typescript\n// Connecting\nconnStatus = {\n    status: \"connecting\",\n    connection: \"user@host\",\n    connected: false,\n    activeconnnum: 3,\n    wshenabled: false  // Not yet determined\n}\n\n// Connected with WSH\nconnStatus = {\n    status: \"connected\", \n    connection: \"user@host\",\n    connected: true,\n    activeconnnum: 3,\n    wshenabled: true\n}\n\n// Connected without WSH\nconnStatus = {\n    status: \"connected\",\n    connection: \"user@host\",\n    connected: true,\n    activeconnnum: 3,\n    wshenabled: false,\n    wsherror: \"wsh installation failed: permission denied\"\n}\n\n// Error\nconnStatus = {\n    status: \"error\",\n    connection: \"user@host\",\n    connected: false,\n    activeconnnum: 3,\n    wshenabled: false,\n    error: \"ssh: connection refused\"\n}\n```\n\n**WSH Errors:**\n- Shown in ConnStatusOverlay\n- \"always disable wsh\" button sets `conn:wshenabled: false`\n- Terminal still works without WSH (limited features)\n- Preview requires WSH (shows error if unavailable)\n\n### WSL Connection\n\n**Connection Names:**\n- Format: `\"wsl://[distro]\"`\n- Examples: `\"wsl://Ubuntu\"`, `\"wsl://Debian\"`, `\"wsl://Ubuntu-20.04\"`\n\n**Frontend Behavior:**\n- Similar to SSH (colored arrow icon)\n- Listed under \"Local\" section in modal\n- No authentication prompts\n- File paths: `wsl://Ubuntu:~/file.txt`\n\n**Backend Differences:**\n- Uses `wsl.exe` instead of SSH\n- No network overhead\n- Predetermined domain socket path\n- Simpler error handling\n\n### S3 Connection (Preview Only)\n\n**Connection Names:**\n- Format: `\"aws:[profile]\"`\n- Examples: `\"aws:default\"`, `\"aws:production\"`\n\n**Frontend Behavior:**\n- Database icon (accent color)\n- Only available in Preview view\n- No shell/terminal support\n- File paths: `aws:profile:/bucket/key`\n\n**View Model Settings:**\n```typescript\n// Terminal: S3 not shown\nshowS3 = atom(false)\n\n// Preview: S3 shown\nshowS3 = atom(true)\n```\n\n## Error Handling\n\n### Connection Errors\n\n**Authentication Failures:**\n- Backend prompts for credentials via `userinput` events\n- Frontend shows UserInputModal\n- User enters password/passphrase\n- Connection retries automatically\n\n**Network Errors:**\n- ConnStatus.status becomes \"error\"\n- ConnStatus.error contains message\n- ConnStatusOverlay displays error\n- \"Reconnect\" button triggers `ConnConnectCommand`\n\n**WSH Installation Errors:**\n- ConnStatus.wsherror contains message\n- ConnStatusOverlay shows separate WSH error section\n- Options:\n  - Dismiss error (temporary)\n  - \"always disable wsh\" (permanent config change)\n\n### View Model Error Handling\n\n**Terminal View:**\n```typescript\n// Shell won't start if connection failed\nendIconButtons = atom((get) => {\n    const connStatus = get(this.connStatus)\n    if (connStatus?.status != \"connected\") {\n        return []  // Hide restart button\n    }\n    // ... show restart button\n})\n\n// ConnStatusOverlay blocks terminal interaction\n```\n\n**Preview View:**\n```typescript\n// File operations return errors\nerrorMsgAtom = atom(null) as PrimitiveAtom<ErrorMsg>\n\nstatFile = atom(async (get) => {\n    try {\n        const fileInfo = await RpcApi.FileInfoCommand(...)\n        return fileInfo\n    } catch (e) {\n        globalStore.set(this.errorMsgAtom, {\n            status: \"File Read Failed\",\n            text: `${e}`\n        })\n        throw e\n    }\n})\n\n// Error displayed in preview content area\n```\n\n## Best Practices\n\n### For View Model Authors\n\n1. **Use Connection Atoms:**\n   ```typescript\n   connStatus = atom((get) => {\n       const blockData = get(this.blockAtom)\n       const connName = blockData?.meta?.connection\n       return get(getConnStatusAtom(connName))\n   })\n   ```\n\n2. **Check Connection Before Operations:**\n   ```typescript\n   if (connStatus?.status != \"connected\") {\n       return // Don't attempt operation\n   }\n   ```\n\n3. **Use ConnEnsureCommand for File Ops:**\n   ```typescript\n   await RpcApi.ConnEnsureCommand(TabRpcClient, {\n       connname: connName,\n       logblockid: blockId  // For better logging\n   }, { timeout: 60000 })\n   ```\n\n4. **Set manageConnection Appropriately:**\n   ```typescript\n   // Show connection button for views that need connections\n   manageConnection = atom(true)\n   \n   // Hide for views that don't use connections\n   manageConnection = atom(false)\n   ```\n\n5. **Use filterOutNowsh for WSH Requirements:**\n   ```typescript\n   // Filter connections without WSH (file ops, etc.)\n   filterOutNowsh = atom(true)\n   \n   // Allow all connections (basic shell)\n   filterOutNowsh = atom(false)\n   ```\n\n### For RPC Command Usage\n\n1. **Always Handle Errors:**\n   ```typescript\n   try {\n       await RpcApi.ConnConnectCommand(...)\n   } catch (e) {\n       console.error(\"Connection failed:\", e)\n       // Update UI to show error\n   }\n   ```\n\n2. **Use Appropriate Timeouts:**\n   ```typescript\n   // Connection operations: longer timeout\n   { timeout: 60000 }  // 60 seconds\n   \n   // List operations: shorter timeout\n   { timeout: 2000 }   // 2 seconds\n   ```\n\n3. **Batch Related Operations:**\n   ```typescript\n   // Good: Single SetMetaCommand with all changes\n   await RpcApi.SetMetaCommand(TabRpcClient, {\n       oref: blockRef,\n       meta: {\n           connection: newConn,\n           file: newPath,\n           \"cmd:cwd\": null\n       }\n   })\n   \n   // Bad: Multiple SetMetaCommand calls\n   ```\n\n## Summary\n\nThe frontend connection architecture is **reactive and declarative**:\n\n1. **Backend owns connection state** - All connection management happens in Go\n2. **Frontend observes state** - Jotai atoms mirror backend state\n3. **User actions trigger backend** - RPC commands initiate backend operations\n4. **Events flow back to frontend** - Backend pushes updates via wave events\n5. **View models isolate concerns** - Each view manages its own connection needs\n6. **Block controllers bridge the gap** - Backend controllers use connections for process execution\n\nThis architecture ensures:\n- **Consistency** - Single source of truth (backend)\n- **Reactivity** - UI updates automatically with state changes\n- **Separation** - Frontend doesn't manage connection lifecycle\n- **Flexibility** - Views can easily add connection support\n- **Robustness** - Errors handled at appropriate layers"
  },
  {
    "path": "aiprompts/focus-layout.md",
    "content": "# Wave Terminal Focus System - Layout State Flow\n\nThis 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.\n\n## Overview\n\nWhen layout operations modify focus state, a straightforward chain of updates occurs:\n1. **Visual feedback** - The focus ring updates immediately\n2. **Physical DOM focus** - The terminal (or other view) receives actual browser focus\n\nThe system uses local atoms as the source of truth with async persistence to the backend.\n\n## The Flow\n\n### 1. Setting Focus in Layout Operations\n\nThroughout [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts), operations directly mutate `layoutState.focusedNodeId`:\n\n```typescript\n// Example from insertNode\nif (action.magnified) {\n    layoutState.magnifiedNodeId = action.node.id;\n    layoutState.focusedNodeId = action.node.id;\n}\nif (action.focused) {\n    layoutState.focusedNodeId = action.node.id;\n}\n```\n\nThis happens in ~10 places: insertNode, insertNodeAtIndex, deleteNode, focusNode, magnifyNodeToggle, etc.\n\n### 2. Committing to Local Atom\n\nThe [`LayoutModel.treeReducer()`](../frontend/layout/lib/layoutModel.ts:547) commits changes:\n\n```typescript\ntreeReducer(action: LayoutTreeAction, setState = true): boolean {\n    // Mutate tree state\n    focusNode(this.treeState, action);\n    \n    if (setState) {\n        this.updateTree();  // Compute leafOrder, etc.\n        this.setter(this.localTreeStateAtom, { ...this.treeState });  // Sync update\n        this.persistToBackend();  // Async persistence\n    }\n}\n```\n\nThe key is `{ ...this.treeState }` creates a new object reference, triggering Jotai reactivity.\n\n### 3. Derived Atoms Recalculate\n\nEach block's `NodeModel` has an `isFocused` atom:\n\n```typescript\nisFocused: atom((get) => {\n    const treeState = get(this.localTreeStateAtom);\n    const isFocused = treeState.focusedNodeId === nodeid;\n    const waveAIFocused = get(atoms.waveAIFocusedAtom);\n    return isFocused && !waveAIFocused;\n})\n```\n\nWhen `localTreeStateAtom` updates, all `isFocused` atoms recalculate. Only the matching node returns `true`.\n\n### 4. React Components Re-render\n\n**Visual Focus Ring** - Components subscribe to `isFocused`:\n\n```typescript\nconst isFocused = useAtomValue(nodeModel.isFocused);\n```\n\nCSS classes update immediately, showing the focus ring.\n\n**Physical DOM Focus** - Two-step effect chain:\n\n```typescript\n// Step 1: isFocused → blockClicked\nuseLayoutEffect(() => {\n    setBlockClicked(isFocused);\n}, [isFocused]);\n\n// Step 2: blockClicked → physical focus\nuseLayoutEffect(() => {\n    if (!blockClicked) return;\n    setBlockClicked(false);\n    const focusWithin = focusedBlockId() == nodeModel.blockId;\n    if (!focusWithin) {\n        setFocusTarget();  // Calls viewModel.giveFocus()\n    }\n}, [blockClicked, isFocused]);\n```\n\nThe terminal's `giveFocus()` method grants actual browser focus:\n\n```typescript\ngiveFocus(): boolean {\n    if (termMode == \"term\" && this.termRef?.current?.terminal) {\n        this.termRef.current.terminal.focus();\n        return true;\n    }\n    return false;\n}\n```\n\n### 5. Background Persistence\n\nWhile the UI updates synchronously, persistence happens asynchronously:\n\n```typescript\nprivate persistToBackend() {\n    // Debounced (100ms) to avoid excessive writes\n    setTimeout(() => {\n        waveObj.rootnode = this.treeState.rootNode;\n        waveObj.focusednodeid = this.treeState.focusedNodeId;\n        waveObj.magnifiednodeid = this.treeState.magnifiedNodeId;\n        waveObj.leaforder = this.treeState.leafOrder;\n        this.setter(this.waveObjectAtom, waveObj);\n    }, 100);\n}\n```\n\nThe WaveObject is used purely for persistence (tab restore, uncaching).\n\n## The Complete Chain\n\n```\nUser action\n    ↓\nlayoutState.focusedNodeId = nodeId\n    ↓\nsetter(localTreeStateAtom, { ...treeState })\n    ↓\nisFocused atoms recalculate\n    ↓\nReact re-renders\n    ↓\n┌────────────────────┬────────────────────┐\n│ Visual Ring        │ Physical Focus     │\n│ (immediate CSS)    │ (2-step effect)    │\n└────────────────────┴────────────────────┘\n    ↓\npersistToBackend() (async, debounced)\n```\n\n## Key Points\n\n1. **Local atoms** - `localTreeStateAtom` is the source of truth during runtime\n2. **Synchronous updates** - UI changes happen immediately in one React tick\n3. **Async persistence** - Backend writes are fire-and-forget with debouncing\n4. **Two-step focus** - Separates visual (instant) from physical (coordinated) DOM focus\n5. **View delegation** - Each view implements `giveFocus()` for custom focus behavior\n\n## User-Initiated Focus\n\nWhen a user clicks a block:\n\n1. **`onFocusCapture`** (mousedown) → calls `nodeModel.focusNode()` → visual focus ring appears\n2. **`onClick`** → sets `blockClicked = true` → two-step effect chain → physical DOM focus\n\nThis ensures visual feedback is instant while protecting selections.\n\n## Backend Actions\n\nOn initialization or backend updates, queued actions are processed:\n\n```typescript\nif (initialState.pendingBackendActions?.length) {\n    fireAndForget(() => this.processPendingBackendActions());\n}\n```\n\nBackend can queue layout operations (create blocks, etc.) via `PendingBackendActions`."
  },
  {
    "path": "aiprompts/focus.md",
    "content": "# Wave Terminal Focus System\n\nThis document explains how the focus system works in Wave Terminal, particularly for terminal blocks.\n\n## Overview\n\nWave Terminal uses a multi-layered focus system that coordinates between:\n- **Layout Focus State**: Jotai atoms tracking which block is focused (`nodeModel.isFocused`)\n- **Visual Focus Ring**: CSS styling showing the focused block\n- **DOM Focus**: Actual browser focus on interactive elements\n- **View-Specific Focus**: Custom focus handling by view models (e.g., XTerm terminal focus)\n\n## Focus Flow on Block Click\n\nWhen you click on a terminal block, this sequence occurs:\n\n### 1. Click Handler Setup\n[`frontend/app/block/block.tsx:219-223`](frontend/app/block/block.tsx:219-223)\n\n```typescript\nconst blockModel: BlockComponentModel2 = {\n    onClick: setBlockClickedTrue,\n    onFocusCapture: handleChildFocus,\n    blockRef: blockRef,\n};\n```\n\n### 2. Click Triggers State Change\n[`frontend/app/block/block.tsx:165-167`](frontend/app/block/block.tsx:165-167)\n\nWhen clicked, `setBlockClickedTrue` sets the `blockClicked` state to true.\n\n### 3. useLayoutEffect Responds\n[`frontend/app/block/block.tsx:151-163`](frontend/app/block/block.tsx:151-163)\n\n```typescript\nuseLayoutEffect(() => {\n    if (!blockClicked) {\n        return;\n    }\n    setBlockClicked(false);\n    const focusWithin = focusedBlockId() == nodeModel.blockId;\n    if (!focusWithin) {\n        setFocusTarget();\n    }\n    if (!isFocused) {\n        nodeModel.focusNode();\n    }\n}, [blockClicked, isFocused]);\n```\n\n### 4. Focus Target Decision\n[`frontend/app/block/block.tsx:211-217`](frontend/app/block/block.tsx:211-217)\n\n```typescript\nconst setFocusTarget = useCallback(() => {\n    const ok = viewModel?.giveFocus?.();\n    if (ok) {\n        return;\n    }\n    focusElemRef.current?.focus({ preventScroll: true });\n}, []);\n```\n\nThe `setFocusTarget` function:\n1. First attempts to call the view model's `giveFocus()` method\n2. If that succeeds (returns true), we're done\n3. Otherwise, falls back to focusing a dummy input element\n\n### 5. Terminal-Specific Focus\n[`frontend/app/view/term/term.tsx:414-427`](frontend/app/view/term/term.tsx:414-427)\n\n```typescript\ngiveFocus(): boolean {\n    if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) {\n        return true;\n    }\n    let termMode = globalStore.get(this.termMode);\n    if (termMode == \"term\") {\n        if (this.termRef?.current?.terminal) {\n            this.termRef.current.terminal.focus();\n            return true;\n        }\n    }\n    return false;\n}\n```\n\nThe terminal's `giveFocus()` calls XTerm's `terminal.focus()` to grant actual DOM focus.\n\n## Selection Protection\n\nA critical feature is that text selections are preserved when clicking within the same block.\n\n### The Protection Mechanism\n[`frontend/app/block/block.tsx:156-158`](frontend/app/block/block.tsx:156-158)\n\n```typescript\nconst focusWithin = focusedBlockId() == nodeModel.blockId;\nif (!focusWithin) {\n    setFocusTarget();\n}\n```\n\nThe key is [`focusedBlockId()`](frontend/util/focusutil.ts:48-70) which checks:\n\n1. **Active Element**: Is there a focused DOM element within this block?\n2. **Selection**: Is there a text selection within this block?\n\n```typescript\nexport function focusedBlockId(): string {\n    const focused = document.activeElement;\n    if (focused instanceof HTMLElement) {\n        const blockId = findBlockId(focused);\n        if (blockId) {\n            return blockId;\n        }\n    }\n    const sel = document.getSelection();\n    if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) {\n        let anchor = sel.anchorNode;\n        if (anchor instanceof Text) {\n            anchor = anchor.parentElement;\n        }\n        if (anchor instanceof HTMLElement) {\n            const blockId = findBlockId(anchor);\n            if (blockId) {\n                return blockId;\n            }\n        }\n    }\n    return null;\n}\n```\n\n**When making a text selection within a block:**\n- `focusWithin` returns true (selection exists in the block)\n- `setFocusTarget()` is **skipped**\n- Selection is preserved\n- Only `nodeModel.focusNode()` is called to update layout state\n\n## Visual Focus vs DOM Focus\n\nThere's an important separation between visual focus (the focus ring) and actual DOM focus.\n\n### Visual Focus (Immediate)\n[`frontend/app/block/block.tsx:200-209`](frontend/app/block/block.tsx:200-209)\n\n```typescript\nconst handleChildFocus = useCallback(\n    (event: React.FocusEvent<HTMLDivElement, Element>) => {\n        if (!isFocused) {\n            nodeModel.focusNode();  // Updates layout state immediately\n        }\n    },\n    [isFocused]\n);\n```\n\nThis `onFocusCapture` handler fires on **mousedown** (capture phase), immediately updating the visual focus ring.\n\n### DOM Focus (On Click Complete)\n\nThe actual DOM focus via `giveFocus()` only happens after click completion, through the onClick → useLayoutEffect path.\n\n### Selection Example: Two Terminals\n\nWhen making a selection in terminal 2 while terminal 1 is focused:\n\n1. **Mousedown** → `onFocusCapture` fires → `nodeModel.focusNode()` updates focus ring\n   - Terminal 2 now shows the focus ring\n   - Layout state updated\n2. **Drag** → Selection is made in terminal 2\n3. **Mouseup** → Selection completes\n4. **Click handler** → `onClick` fires → `setBlockClickedTrue` → triggers useLayoutEffect\n5. **useLayoutEffect** → Checks `focusWithin` (now true because selection exists)\n6. **Protected** → Skips `setFocusTarget()`, preserving the selection\n\n**Result:** Focus ring updates immediately, but DOM focus is only granted after the selection is made, and is protected by the `focusWithin` check.\n\n## Terminal-Specific Focus Events\n\nThe terminal view has three useEffects that call `giveFocus()`:\n\n### 1. Search Close\n[`frontend/app/view/term/term.tsx:970-974`](frontend/app/view/term/term.tsx:970-974)\n\nWhen the search panel closes, focus returns to the terminal.\n\n### 2. Terminal Recreation\n[`frontend/app/view/term/term.tsx:1035-1038`](frontend/app/view/term/term.tsx:1035-1038)\n\nWhen a terminal is recreated while focused (e.g., settings change), focus is restored.\n\n### 3. Mode Switch\n[`frontend/app/view/term/term.tsx:1046-1052`](frontend/app/view/term/term.tsx:1046-1052)\n\nWhen switching from vdom mode back to term mode, the terminal receives focus.\n\n## Key Components\n\n### Block Component\n[`frontend/app/block/block.tsx`](frontend/app/block/block.tsx)\n- Manages the BlockFull component\n- Handles click and focus capture events\n- Coordinates between layout focus and DOM focus\n\n### BlockNodeModel\n[`frontend/app/block/blocktypes.ts:7-12`](frontend/app/block/blocktypes.ts:7-12)\n```typescript\nexport interface BlockNodeModel {\n    blockId: string;\n    isFocused: Atom<boolean>;\n    onClose: () => void;\n    focusNode: () => void;\n}\n```\n\n### ViewModel Interface\nView models can implement `giveFocus(): boolean` to handle focus in a view-specific way.\n\n### Focus Utilities\n[`frontend/util/focusutil.ts`](frontend/util/focusutil.ts)\n- `focusedBlockId()`: Determines which block has focus or selection\n- `hasSelection()`: Checks if there's an active text selection\n- `findBlockId()`: Traverses DOM to find containing block\n\n## Summary\n\nThe focus system elegantly separates concerns:\n- **Visual feedback** updates immediately on mousedown\n- **DOM focus** is deferred until after user interaction completes\n- **Selections are protected** by checking focus state before granting focus\n- **View-specific focus** is delegated to view models via `giveFocus()`\n\nThis design allows for responsive UI (immediate focus ring updates) while preventing disruption of user interactions like text selection."
  },
  {
    "path": "aiprompts/getsetconfigvar.md",
    "content": "# Setting and Reading Config Variables\n\nThis document provides a quick reference for updating and reading configuration values in our system.\n\n---\n\n## Setting a Config Variable\n\nTo 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.\n\n**Example:**\n\n```ts\nawait RpcApi.SetConfigCommand(TabRpcClient, { \"web:defaulturl\": url });\n```\n\nIn this example, `\"web:defaulturl\"` is the key and `url` is the new value. Use this approach for any config key.\n\n---\n\n## Reading a Config Value\n\nTo 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.\n\n**Example:**\n\n```ts\nconst configAtom = getSettingsKeyAtom(\"app:defaultnewblock\");\nconst configValue = globalStore.get(configAtom) ?? \"default value\";\n```\n\nHere, `\"app:defaultnewblock\"` is the config key and `\"default value\"` serves as a fallback if the key isn't set.\n\nInside 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)\n\n```tsx\nconst configValue = useSettingsKeyAtom(\"app:defaultnewblock\") ?? \"default value\";\n```\n\n---\n\n## Relevant Imports\n\n```ts\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { getSettingsKeyAtom, useSettingsKeyAtom, globalStore } from \"@/app/store/global\";\n```\n\nKeep this guide handy for a quick reference when working with configuration values.\n"
  },
  {
    "path": "aiprompts/layout-simplification.md",
    "content": "# Wave Terminal Layout System - Simplification via Write Cache Pattern\n\n## Executive Summary\n\nThe 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.\n\n## Current Architecture Problems\n\n### The Unnecessary Round-Trip\n\nEvery layout change (split, close, focus, magnify) currently follows this flow:\n\n```\nUser action\n  ↓\ntreeReducer() mutates layoutState\n  ↓\nlayoutState.generation++  ← Only purpose: trigger the write\n  ↓\nBidirectional atom setter (checks generation)\n  ↓\nWrite to WaveObject {rootnode, focusednodeid, magnifiednodeid}\n  ↓\nWaveObject update notification\n  ↓\nBidirectional atom getter runs\n  ↓\nALL dependent atoms recalculate (every isFocused, etc.)\n  ↓\nReact re-renders with updated state\n```\n\n**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).\n\n### What the Backend Actually Does\n\n**Backend Reads** (from [`pkg/wshrpc/wshserver/resolvers.go`](../pkg/wshrpc/wshserver/resolvers.go:196-206)):\n- **`LeafOrder`** - Used to resolve block numbers in commands (e.g., `wsh block:1` → blockId lookup)\n\n**Backend Writes** (from [`pkg/wcore/layout.go`](../pkg/wcore/layout.go)):\n- **`PendingBackendActions`** - Queued layout actions via [`QueueLayoutAction()`](../pkg/wcore/layout.go:101-118)\n\n**Backend NEVER touches**:\n- **`RootNode`** - Never read, only written by frontend for persistence\n- **`FocusedNodeId`** - Never read, only written by frontend for persistence\n- **`MagnifiedNodeId`** - Never read, only written by frontend for persistence\n\n**The key insight**: Only `LeafOrder` needs to be synced to backend (for command resolution). The tree structure fields (`rootnode`, `focusednodeid`, `magnifiednodeid`) are pure persistence!\n\n### Complexity Symptoms\n\n1. **Generation tracking**: [`layoutState.generation++`](../frontend/layout/lib/layoutTree.ts:294) appears in 10+ places, only to trigger atom writes\n2. **Bidirectional atoms**: [`withLayoutTreeStateAtomFromTab()`](../frontend/layout/lib/layoutAtom.ts:18-60) has complex read/write logic\n3. **Timing coordination**: The entire Section 8 of the WaveAI focus proposal exists only because of race conditions between focus updates and atom commits\n4. **False reactivity**: Changes to `focusedNodeId` trigger full tree state propagation even though they're unrelated to tree structure\n\n## Proposed \"Write Cache\" Architecture\n\n### Core Concept\n\n```\nUser action\n  ↓\nUpdate LOCAL atom (immediate, synchronous)\n  ↓\nReact re-renders (single tick, all atoms see new state)\n  ↓\n[async, fire-and-forget] Persist to WaveObject\n```\n\n### Key Principles\n\n1. **Local atoms are source of truth** during runtime\n2. **WaveObject is persistence layer** only (read on init, write async)\n3. **Backend actions still work** via `PendingBackendActions`\n4. **No generation tracking needed** (no need to trigger writes)\n\n## Implementation Design\n\n### 1. New LayoutModel Structure\n\n```typescript\n// frontend/layout/lib/layoutModel.ts\n\nclass LayoutModel {\n  // BEFORE: Bidirectional atom with generation tracking\n  // treeStateAtom: WritableLayoutTreeStateAtom\n  \n  // AFTER: Simple local atom (source of truth)\n  private localTreeStateAtom: PrimitiveAtom<LayoutTreeState>;\n  \n  // Keep reference to WaveObject atom for persistence\n  private waveObjectAtom: WritableWaveObjectAtom<LayoutState>;\n  \n  constructor(tabAtom: Atom<Tab>, ...) {\n    this.waveObjectAtom = getLayoutStateAtomFromTab(tabAtom);\n    \n    // Initialize local atom (starts empty)\n    this.localTreeStateAtom = atom<LayoutTreeState>({\n      rootNode: undefined,\n      focusedNodeId: undefined,\n      magnifiedNodeId: undefined,\n      leafOrder: undefined,\n      pendingBackendActions: undefined,\n      generation: 0  // Can be removed entirely or kept for debugging\n    });\n    \n    // Read from WaveObject ONCE during initialization\n    this.initializeFromWaveObject();\n  }\n  \n  private async initializeFromWaveObject() {\n    const waveObjState = this.getter(this.waveObjectAtom);\n    \n    // Load persisted state into local atom\n    const initialState: LayoutTreeState = {\n      rootNode: waveObjState?.rootnode,\n      focusedNodeId: waveObjState?.focusednodeid,\n      magnifiedNodeId: waveObjState?.magnifiednodeid,\n      leafOrder: undefined,  // Computed by updateTree()\n      pendingBackendActions: waveObjState?.pendingbackendactions,\n      generation: 0\n    };\n    \n    // Set local state\n    this.treeState = initialState;\n    this.setter(this.localTreeStateAtom, initialState);\n    \n    // Process any pending backend actions\n    if (initialState.pendingBackendActions?.length) {\n      await this.processPendingBackendActions();\n    }\n    \n    // Initialize tree (compute leafOrder, etc.)\n    this.updateTree();\n  }\n  \n  // Process backend-queued actions (startup only)\n  private async processPendingBackendActions() {\n    const actions = this.treeState.pendingBackendActions;\n    if (!actions?.length) return;\n    \n    this.treeState.pendingBackendActions = undefined;\n    \n    for (const action of actions) {\n      // Convert backend action to frontend action and run through treeReducer\n      // This code already exists in onTreeStateAtomUpdated()\n      switch (action.actiontype) {\n        case LayoutTreeActionType.InsertNode:\n          this.treeReducer({\n            type: LayoutTreeActionType.InsertNode,\n            node: newLayoutNode(undefined, undefined, undefined, {\n              blockId: action.blockid\n            }),\n            magnified: action.magnified,\n            focused: action.focused\n          }, false);\n          break;\n        // ... other action types\n      }\n    }\n  }\n}\n```\n\n### 2. Simplified treeReducer\n\n```typescript\nclass LayoutModel {\n  treeReducer(action: LayoutTreeAction, setState = true): boolean {\n    // Run the tree operation (mutates this.treeState)\n    switch (action.type) {\n      case LayoutTreeActionType.InsertNode:\n        insertNode(this.treeState, action);\n        break;\n      case LayoutTreeActionType.FocusNode:\n        focusNode(this.treeState, action);\n        break;\n      case LayoutTreeActionType.DeleteNode:\n        deleteNode(this.treeState, action);\n        break;\n      // ... all other cases unchanged\n    }\n    \n    if (setState) {\n      // Update tree (compute leafOrder, validate, etc.)\n      this.updateTree();\n      \n      // Update local atom IMMEDIATELY (synchronous)\n      this.setter(this.localTreeStateAtom, { ...this.treeState });\n      \n      // Persist to backend asynchronously (fire and forget)\n      this.persistToBackend();\n    }\n    \n    return true;\n  }\n  \n  // Fire-and-forget persistence\n  private async persistToBackend() {\n    const waveObj = this.getter(this.waveObjectAtom);\n    if (!waveObj) return;\n    \n    // Update WaveObject fields\n    waveObj.rootnode = this.treeState.rootNode;           // Persistence only\n    waveObj.focusednodeid = this.treeState.focusedNodeId; // Persistence only\n    waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; // Persistence only\n    waveObj.leaforder = this.treeState.leafOrder;         // Backend reads this for command resolution!\n    \n    // Write to backend (don't await - fire and forget)\n    this.setter(this.waveObjectAtom, waveObj);\n    \n    // Optional: Debounce if rapid changes are a concern\n  }\n}\n```\n\n### 3. Simplified NodeModel isFocused\n\n```typescript\nclass LayoutModel {\n  getNodeModel(node: LayoutNode): NodeModel {\n    return {\n      // BEFORE: Complex dependency on bidirectional treeStateAtom\n      // isFocused: atom((get) => {\n      //   const treeState = get(this.treeStateAtom);  // Triggers on any tree change\n      //   ...\n      // })\n      \n      // AFTER: Simple dependency on local atom\n      isFocused: atom((get) => {\n        const treeState = get(this.localTreeStateAtom);  // Simple read\n        const focusType = get(focusManager.focusType);\n        return treeState.focusedNodeId === node.id && focusType === \"node\";\n      }),\n      \n      // All other atoms similarly simplified...\n      isMagnified: atom((get) => {\n        const treeState = get(this.localTreeStateAtom);\n        return treeState.magnifiedNodeId === node.id;\n      }),\n      \n      // ... rest unchanged\n    };\n  }\n}\n```\n\n### 4. Remove Generation Tracking\n\nThe `generation` field can be removed entirely from [`LayoutTreeState`](../frontend/layout/lib/types.ts):\n\n```typescript\n// frontend/layout/lib/types.ts\n\nexport interface LayoutTreeState {\n  rootNode?: LayoutNode;\n  focusedNodeId?: string;\n  magnifiedNodeId?: string;\n  leafOrder?: LayoutLeafEntry[];\n  pendingBackendActions?: LayoutActionData[];\n  // generation: number;  ← DELETE THIS\n}\n```\n\nAnd remove all `generation++` calls from [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts) (appears in 10+ places).\n\n### 5. Simplified layoutAtom.ts\n\n```typescript\n// frontend/layout/lib/layoutAtom.ts\n\n// BEFORE: Complex bidirectional atom (60 lines)\n// AFTER: Can be deleted entirely or simplified to just helper for WaveObject access\n\nexport function getLayoutStateAtomFromTab(\n  tabAtom: Atom<Tab>,\n  get: Getter\n): WritableWaveObjectAtom<LayoutState> {\n  const tabData = get(tabAtom);\n  if (!tabData) return;\n  const layoutStateOref = WOS.makeORef(\"layout\", tabData.layoutstate);\n  return WOS.getWaveObjectAtom<LayoutState>(layoutStateOref);\n}\n\n// No more withLayoutTreeStateAtomFromTab() - not needed!\n```\n\n## Benefits\n\n### Immediate Benefits\n\n1. **10x simpler reactivity**: Local atoms update synchronously, React sees complete state in one tick\n2. **No generation tracking**: Eliminate 10+ `generation++` calls and all related logic\n3. **No timing issues**: Everything happens synchronously, no coordination needed\n4. **Faster updates**: No round-trip through WaveObject for every change\n5. **Easier debugging**: Clear separation between runtime state (local atoms) and persistence (WaveObject)\n\n### Impact on WaveAI Focus Proposal\n\nThe entire Section 8 (\"Layout Model Focus Integration - CRITICAL TIMING\") **becomes unnecessary**:\n\n**BEFORE** (complex timing coordination):\n```typescript\ntreeReducer(action: LayoutTreeAction) {\n  insertNode(this.treeState, action);  // generation++\n  \n  // CRITICAL: Must update focus manager BEFORE atom commits\n  if (action.focused) {\n    focusManager.requestNodeFocus();  // Synchronous!\n  }\n  \n  // Then atom commits\n  this.setter(this.treeStateAtom, ...);\n  // Now isFocused sees correct focusType\n}\n```\n\n**AFTER** (trivial):\n```typescript\ntreeReducer(action: LayoutTreeAction) {\n  insertNode(this.treeState, action);  // Just mutates local state\n  \n  // Update local atom (synchronous)\n  this.setter(this.localTreeStateAtom, { ...this.treeState });\n  \n  // Update focus manager (order doesn't matter - both updated synchronously)\n  if (action.focused) {\n    focusManager.setBlockFocus();\n  }\n  \n  // Both updates happen in same tick, no race condition possible!\n}\n```\n\n### Code Deletion\n\n**Can delete**:\n- `generation` field and all `generation++` calls (~15 places)\n- Complex bidirectional atom logic in [`layoutAtom.ts`](../frontend/layout/lib/layoutAtom.ts) (~40 lines)\n- `lastTreeStateGeneration` tracking in [`LayoutModel`](../frontend/layout/lib/layoutModel.ts)\n- All `generation > this.treeState.generation` checks\n\n**Total**: ~200-300 lines of complex coordination code deleted\n\n## Edge Cases & Considerations\n\n### 1. Rapid Changes\n\n**Concern**: Many layout changes in quick succession could cause many backend writes.\n\n**Solution**: Debounce the `persistToBackend()` call (e.g., 100ms). Users won't notice the delay in persistence.\n\n```typescript\nprivate persistDebounceTimer: NodeJS.Timeout | null = null;\n\nprivate persistToBackend() {\n  if (this.persistDebounceTimer) {\n    clearTimeout(this.persistDebounceTimer);\n  }\n  \n  this.persistDebounceTimer = setTimeout(() => {\n    const waveObj = this.getter(this.waveObjectAtom);\n    if (!waveObj) return;\n    \n    waveObj.rootnode = this.treeState.rootNode;\n    waveObj.focusednodeid = this.treeState.focusedNodeId;\n    waveObj.magnifiednodeid = this.treeState.magnifiedNodeId;\n    waveObj.leaforder = this.treeState.leafOrder;\n    \n    this.setter(this.waveObjectAtom, waveObj);\n    this.persistDebounceTimer = null;\n  }, 100);\n}\n```\n\n### 2. Tab Switching\n\n**Current**: Each tab has its own `treeStateAtom` in a WeakMap.\n\n**After**: Each tab has its own `localTreeStateAtom` in the LayoutModel instance. No change needed - already isolated per tab.\n\n### 3. Tab Uncaching (Electron Limit)\n\n**Current**: Tab gets uncached, needs to reload layout from WaveObject.\n\n**After**: Same - `initializeFromWaveObject()` reads persisted state. No change in behavior.\n\n### 4. Backend Actions (New Blocks)\n### 5. LeafOrder and CLI Commands\n\n**Concern**: The backend reads `LeafOrder` for CLI command resolution (e.g., `wsh block:1`). What if it's not synced yet?\n\n**Solution**: Fire-and-forget is perfectly fine! CLI commands aren't time-sensitive:\n- Commands are typed/run by users (human speed, not machine speed)\n- Even if `LeafOrder` is 100ms behind, no one will notice\n- By the time a user types `wsh block:1`, the async write has long since completed\n- Worst case: User types command during a split operation and gets previous block - extremely rare and not breaking\n\n\n## Immutability and Jotai Atoms\n\n### Question: Do we need deep copies for Jotai to detect changes?\n\n**Answer: NO - shallow copy is sufficient!** ✓\n\n### Current System (Already Uses Shallow Updates)\n\nLooking at the current code in [`layoutModel.ts:587`](../frontend/layout/lib/layoutModel.ts:587):\n\n```typescript\nsetTreeStateAtom(bumpGeneration = false) {\n    if (bumpGeneration) {\n        this.treeState.generation++;\n    }\n    this.lastTreeStateGeneration = this.treeState.generation;\n    this.setter(this.treeStateAtom, this.treeState);  // ← Sets same object!\n}\n```\n\n**The current system doesn't create new objects either!** It relies on `generation` changing to trigger the bidirectional atom's setter.\n\n### Why Shallow Copy Works with Jotai\n\n```typescript\n// In treeReducer after mutations\nthis.setter(this.localTreeStateAtom, { ...this.treeState });\n```\n\n**This works because**:\n1. **Jotai checks reference equality** on the atom value itself (the `LayoutTreeState` object)\n2. **`{ ...this.treeState }` creates a NEW object** with a different reference\n3. **Nested structures don't matter** - Jotai doesn't do deep equality checks\n\n**Example**:\n```typescript\nconst oldState = { rootNode: someTree, focusedNodeId: \"node1\" };\nconst newState = { ...oldState };\n\noldState === newState        // FALSE - different objects!\noldState.rootNode === newState.rootNode  // TRUE - same tree reference\n\n// But Jotai only checks the first comparison, so it detects the change!\n```\n\n### Tree Mutations Don't Need Immutability\n\nAll tree operations in [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts) **mutate in place**:\n- `insertNode()` - Mutates `layoutState.rootNode`\n\n### Derived Atoms Will Update Correctly ✓\n\n**Concern**: Will derived atoms like `isFocused` and `isMagnified` update when we change to local atoms?\n\n**Answer: YES - they will work perfectly!** ✓\n\n### How Derived Atoms Work\n\nThe NodeModel creates derived atoms that depend on `treeStateAtom`:\n\n```typescript\n// From layoutModel.ts:936-946\nisFocused: atom((get) => {\n    const treeState = get(this.treeStateAtom);  // Subscribe to treeStateAtom\n    const isFocused = treeState.focusedNodeId === nodeid;\n    const waveAIFocused = get(atoms.waveAIFocusedAtom);\n    return isFocused && !waveAIFocused;\n}),\n\nisMagnified: atom((get) => {\n    const treeState = get(this.treeStateAtom);  // Subscribe to treeStateAtom\n    return treeState.magnifiedNodeId === nodeid;\n}),\n```\n\n### Why They'll Still Work with Local Atoms\n\n**After the change**:\n```typescript\nisFocused: atom((get) => {\n    const treeState = get(this.localTreeStateAtom);  // Subscribe to localTreeStateAtom\n    const isFocused = treeState.focusedNodeId === nodeid;\n    const waveAIFocused = get(atoms.waveAIFocusedAtom);\n    return isFocused && !waveAIFocused;\n}),\n```\n\n**The update flow**:\n1. User clicks block → `focusNode()` called\n2. `treeReducer()` runs → mutates `this.treeState.focusedNodeId = newId`\n3. `this.setter(this.localTreeStateAtom, { ...this.treeState })` ← **New reference!**\n4. Jotai detects reference change in `localTreeStateAtom`\n5. All derived atoms that call `get(this.localTreeStateAtom)` are notified\n6. They re-run their getter functions\n7. They see the new `focusedNodeId` value\n8. React components re-render with correct values ✓\n\n### Key Insight\n\n**We're not mutating fields inside the atom** - we're replacing the entire state object:\n\n```typescript\n// OLD way (current): \n// 1. Mutate this.treeState.focusedNodeId = newId\n// 2. Bump this.treeState.generation++\n// 3. Set bidirectional atom (checks generation, writes to WaveObject, reads back, updates)\n// 4. Derived atoms see new state from the round-trip\n\n// NEW way (proposed):\n// 1. Mutate this.treeState.focusedNodeId = newId  (same!)\n// 2. this.setter(localTreeStateAtom, { ...this.treeState })  (new object reference!)\n// 3. Derived atoms immediately see new state (no round-trip!)\n```\n\n**Both approaches create a new state object that triggers Jotai's reactivity!**\n\nThe new way is actually **MORE reliable** because:\n- No round-trip delay\n- No generation checking\n- Direct, synchronous update\n- Same Jotai reactivity mechanism\n\n### What About Nested Fields?\n\n**Question**: What if derived atoms access nested fields like `treeState.rootNode.children`?\n\n**Answer**: Still works! Example:\n\n```typescript\n// Hypothetical derived atom\nsomeAtom: atom((get) => {\n    const treeState = get(this.localTreeStateAtom);\n    return treeState.rootNode.children.length;  // Nested access\n})\n```\n\n**This works because**:\n1. We create new `LayoutTreeState` object: `{ ...this.treeState }`\n2. Jotai sees new reference → notifies subscribers\n3. Getter re-runs, calls `get(this.localTreeStateAtom)`\n4. Gets the new state object\n5. Accesses `newState.rootNode` (same reference as before, but that's OK!)\n6. Returns correct value\n\n**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.\n\n### Verification\n\nAll derived atoms in NodeModel:\n- ✅ `isFocused` - depends on `treeState.focusedNodeId` \n- ✅ `isMagnified` - depends on `treeState.magnifiedNodeId`\n- ✅ `blockNum` - depends on separate `this.leafOrder` atom (unaffected)\n- ✅ `isEphemeral` - depends on separate `this.ephemeralNode` atom (unaffected)\n\nAll will update correctly with the new local atom approach!\n\n- `deleteNode()` - Mutates parent's children array\n- `focusNode()` - Mutates `layoutState.focusedNodeId`\n\nThis is fine! We're not relying on immutability for change detection. We're relying on creating a new `LayoutTreeState` wrapper object via spread operator.\n\n### Backend Round-Trip\n\nWhen reading from WaveObject on initialization:\n```typescript\nconst waveObjState = this.getter(this.waveObjectAtom);\nconst initialState: LayoutTreeState = {\n  rootNode: waveObjState?.rootnode,  // New reference from backend\n  focusedNodeId: waveObjState?.focusednodeid,\n  // ...\n};\n```\n\nThis creates a **completely new object** with new references, which is even more immutable than necessary. No issues here.\n\n### Summary\n\n✅ **We're covered** - Shallow copy via spread operator is sufficient\n\n✅ **Same as current system** - We're not making it worse, just simpler\n\n✅ **Jotai only checks reference equality** on the atom value, not deep equality\n\n✅ **Tree mutations are fine** - They've always worked this way\n\n\n**Current**: Backend queues actions via [`QueueLayoutAction()`](../pkg/wcore/layout.go:101), frontend processes via `pendingBackendActions`.\n\n**After**: Same - `initializeFromWaveObject()` processes pending actions. No change needed.\n\n### 5. Write Failures\n\n**Concern**: What if the async write to WaveObject fails?\n\n**Solution**: \n1. The app continues working (local state is fine)\n2. On next persistence attempt, full state is written again\n3. On tab reload, worst case is state from last successful write\n4. Can add retry logic or error notification if needed\n\n## Migration Path\n\n### Phase 1: Preparation (No Breaking Changes)\n\n1. Add `localTreeStateAtom` alongside existing `treeStateAtom`\n2. Keep both in sync\n3. Update a few `isFocused` atoms to use local atom\n4. Test thoroughly\n\n### Phase 2: Switch Over\n\n1. Update `treeReducer` to write to local atom + fire-and-forget persist\n2. Update all `isFocused` and other computed atoms to use local atom\n3. Remove generation checks and tracking\n4. Test all layout operations\n\n### Phase 3: Cleanup\n\n1. Delete bidirectional atom logic from [`layoutAtom.ts`](../frontend/layout/lib/layoutAtom.ts)\n2. Remove `generation` field from `LayoutTreeState`\n3. Simplify `onTreeStateAtomUpdated()` (only needed for `pendingBackendActions`)\n4. Update documentation\n\n### Testing Checklist\n\n- [ ] Split horizontal/vertical\n- [ ] Close blocks (focused and unfocused)\n- [ ] Focus changes via click, keyboard nav, tab switching\n- [ ] Magnify/unmagnify\n- [ ] Resize operations\n- [ ] Drag & drop\n- [ ] Tab switching (verify state persistence)\n- [ ] App restart (verify state restore)\n- [ ] Multiple windows\n- [ ] Rapid operations (verify debouncing works)\n\n## Impact on Other Systems\n\n### Focus Manager\n\n**Before**: Must coordinate timing with atom commits.\n\n**After**: Can update `focusType` atom independently. Order doesn't matter since both updates happen synchronously.\n\n### Block Component\n\n**No change**: Blocks still subscribe to `nodeModel.isFocused`, which still reacts correctly (faster now).\n\n### Keyboard Navigation\n\n**No change**: Still calls `layoutModel.focusNode()`, which updates local state immediately.\n\n### Terminal/Views\n\n**No change**: Views don't interact with layout atoms directly.\n\n## Performance Implications\n\n### Improved\n\n1. **Faster reactivity**: No round-trip through WaveObject (save ~1-2ms per operation)\n2. **Fewer atom updates**: Only local atom updates, not bidirectional propagation\n3. **Batched writes**: Debouncing reduces backend write frequency\n\n### No Change\n\n1. **Tree operations**: Same complexity (balance, walk, compute, etc.)\n2. **React rendering**: Same render triggers, just faster\n3. **Memory usage**: Same (local atom vs bidirectional atom is similar size)\n\n## Conclusion\n\nThe \"write cache\" pattern can simplify the layout system by ~70% while maintaining full functionality:\n\n- **Remove**: Generation tracking, bidirectional atoms, timing coordination\n- **Keep**: All tree logic, backend integration, persistence\n- **Gain**: Simpler code, faster updates, easier debugging\n\nThis also makes the WaveAI focus integration trivial, eliminating the need for complex timing coordination.\n\n## Recommendation\n\nImplement this simplification **before** adding WaveAI focus features. The cleaner foundation will make the focus work much easier and the codebase more maintainable long-term.\n# Wave Terminal Layout System - Simplification via Write Cache Pattern\n\n## Risk Assessment: LOW RISK, Well-Contained Change\n\n### Files to Modify: **4-5 files, all in `frontend/layout/`**\n\n1. **`frontend/layout/lib/layoutModel.ts`** (~150 lines changed)\n   - Add `localTreeStateAtom` field\n   - Modify `treeReducer()` to update local atom + persist async\n   - Add `initializeFromWaveObject()` method\n   - Add `persistToBackend()` method\n   - Update `getNodeModel()` atoms to use local atom\n\n2. **`frontend/layout/lib/layoutTree.ts`** (~15 line deletions)\n   - Remove all `layoutState.generation++` calls (appears 15 times)\n   - No other changes needed\n\n3. **`frontend/layout/lib/layoutAtom.ts`** (~40 lines deleted or simplified)\n   - Can delete most of the bidirectional atom logic\n   - Keep only `getLayoutStateAtomFromTab()` helper\n\n4. **`frontend/layout/lib/types.ts`** (~1 line deletion)\n   - Remove `generation: number` from `LayoutTreeState`\n\n5. **`frontend/layout/tests/model.ts`** (~1 line change)\n   - Remove generation from test fixtures\n\n**Total**: ~5 files, all within `frontend/layout/` directory. **No changes outside layout system!**\n\n### Why This is Low Risk\n\n#### 1. **Fail-Fast Behavior** ✓\nIf we break something, it will be **immediately obvious**:\n- Split horizontal/vertical won't work → visible immediately\n- Block focus won't work → obvious when clicking\n- Close block won't work → obvious\n- Magnify won't work → obvious\n\n**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.\"\n\n#### 2. **Well-Contained Scope** ✓\n- **All changes in one directory**: `frontend/layout/`\n- **No changes to**:\n  - Block components (unchanged)\n  - Terminal/views (unchanged)\n  - Keyboard navigation (unchanged)\n  - Focus manager (unchanged)\n  - Backend Go code (unchanged)\n\nThe **interface** to the layout system stays the same:\n- Blocks still call `nodeModel.focusNode()`\n- Blocks still subscribe to `nodeModel.isFocused`\n- Keyboard nav still calls `layoutModel.focusNode()`\n- Nothing outside the layout system needs to know about the change\n\n#### 3. **No Data Corruption Risk** ✓\nThis change affects **reactive state propagation**, not data storage:\n- WaveObject still stores the same data\n- Backend still queues actions the same way\n- Blocks still have the same IDs\n- Tab structure unchanged\n\n**Worst case**: Layout stops working, we revert the code. No data loss, no corruption.\n\n#### 4. **Incremental Implementation Possible** ✓\n\nCan be done in safe phases:\n\n**Phase 1**: Add alongside existing (no breaking changes)\n```typescript\nclass LayoutModel {\n  treeStateAtom: WritableLayoutTreeStateAtom;  // Keep old\n  localTreeStateAtom: PrimitiveAtom<LayoutTreeState>;  // Add new\n  \n  // Keep both in sync temporarily\n}\n```\n\n**Phase 2**: Switch consumers one at a time\n```typescript\n// Change this gradually\nisFocused: atom((get) => {\n  // const treeState = get(this.treeStateAtom);  // Old\n  const treeState = get(this.localTreeStateAtom);  // New\n  ...\n})\n```\n\n**Phase 3**: Remove old code once everything uses new atoms\n\n**Can test thoroughly at each phase before proceeding!**\n\n#### 5. **Easy to Test** ✓\n\nEvery layout operation is user-visible and testable:\n- [ ] Split horizontal → obvious if broken\n- [ ] Split vertical → obvious if broken\n- [ ] Close block → obvious if broken\n- [ ] Focus block → obvious if broken\n- [ ] Magnify/unmagnify → obvious if broken\n- [ ] Drag & drop → obvious if broken\n- [ ] Tab switch → obvious if broken\n- [ ] App restart → obvious if broken\n\nNo subtle edge cases to hunt down. If it works in manual testing, it works.\n\n### Comparison to High-Risk Changes\n\n**This change is NOT**:\n- ❌ Touching 20+ files across the codebase\n- ❌ Changing subtle timing in async operations\n- ❌ Modifying data storage formats\n- ❌ Affecting backend/frontend protocol\n- ❌ Requiring coordinated backend changes\n- ❌ Creating subtle race conditions\n\n**This change IS**:\n- ✅ Contained to 5 files in one directory\n- ✅ Synchronous state updates (simpler than current!)\n- ✅ Same data format, just different flow\n- ✅ Frontend-only\n- ✅ Backend unchanged\n- ✅ Eliminating race conditions (not creating them)\n\n### What Could Go Wrong? (And How We'd Know)\n\n| Potential Issue | How We'd Detect | Recovery |\n|-----------------|-----------------|----------|\n| Local atom doesn't update | Layout frozen, nothing responds | Immediately obvious, revert |\n| Persistence fails silently | State doesn't survive restart | Caught in testing, add logging |\n| isFocused calculation wrong | Wrong focus ring | Immediately obvious, fix calculation |\n| Missing generation++ somewhere | Old code path tries to use generation | Compile error or immediate runtime error |\n| Tab switching breaks | Tabs don't load correctly | Immediately obvious |\n\n**All failure modes are immediate and obvious!**\n\n### Difficulty Assessment\n\n**Conceptual Difficulty**: LOW\n- Replace bidirectional atom with simple atom\n- Add async persist function\n- Remove generation tracking\n- Very straightforward refactor\n\n**Code Difficulty**: LOW-MEDIUM\n- Changes are localized and mechanical\n- Most changes are deletions (always good!)\n- New code is simpler than old code\n- No complex algorithms to implement\n\n**Testing Difficulty**: LOW\n- All functionality is user-visible\n- No need for complex test scenarios\n- Manual testing catches everything\n- Can test incrementally\n\n### Recommendation\n\nThis is a **low-risk, high-reward change**:\n- **Risk**: LOW (contained, fail-fast, no corruption)\n- **Difficulty**: LOW-MEDIUM (straightforward refactor)\n- **Reward**: HIGH (70% less complexity, easier future work)\n\n**Suggested approach**:\n1. Implement in a feature branch\n2. Add local atom alongside existing system\n3. Test thoroughly with both systems running\n4. Switch over gradually\n5. Remove old code\n6. Merge when confident\n\nTotal implementation time: **1-2 days for experienced developer**, including thorough testing.\n\n---\n"
  },
  {
    "path": "aiprompts/layout.md",
    "content": "# Wave Terminal Layout System Architecture\n\nThe 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.\n\n## Overview\n\nThe layout system manages a tree of `LayoutNode` objects that represent the hierarchical structure of content. Each node can either be:\n- **Leaf node**: Contains actual content (block data)  \n- **Container node**: Contains child nodes with a specific flex direction\n\nThe system uses CSS Flexbox for positioning but maintains its own tree structure for state management, drag-and-drop operations, and complex layout manipulations.\n\n## Core Architecture\n\n### File Structure\n\n```\nfrontend/layout/lib/\n├── TileLayout.tsx          # Main React component\n├── layoutAtom.ts           # Jotai state management  \n├── layoutModel.ts          # Core model class\n├── layoutModelHooks.ts     # React hooks for integration\n├── layoutNode.ts           # Node manipulation functions\n├── layoutTree.ts           # Tree operation functions\n├── nodeRefMap.ts           # DOM reference tracking\n├── types.ts                # Type definitions\n├── utils.ts                # Utility functions\n└── tilelayout.scss         # Styling\n```\n\n## Key Data Structures\n\n### LayoutNode\n\nThe fundamental building block of the layout system:\n\n```typescript\ninterface LayoutNode {\n    id: string;                    // Unique identifier\n    data?: TabLayoutData;          // Content data (only for leaf nodes)\n    children?: LayoutNode[];       // Child nodes (only for containers)\n    flexDirection: FlexDirection;  // \"row\" or \"column\"\n    size: number;                  // Flex size (0-100)\n}\n```\n\n**Key Rules:**\n- Either `data` OR `children` must be defined, never both\n- Leaf nodes have `data`, container nodes have `children`\n- All nodes have a `flexDirection` that determines layout axis\n- `size` represents the relative flex size within the parent\n\n### LayoutTreeState\n\nThe complete state of the layout:\n\n```typescript\ninterface LayoutTreeState {\n    rootNode: LayoutNode;                    // Root of the tree\n    focusedNodeId?: string;                  // Currently focused node\n    magnifiedNodeId?: string;                // Currently magnified node\n    leafOrder?: LeafOrderEntry[];            // Computed leaf ordering\n    pendingBackendActions: LayoutActionData[]; // Actions from backend\n    generation: number;                      // State version number\n}\n```\n\n**Generation System:**\n- Incremented on every state change\n- Used for optimistic updates and conflict resolution\n- Prevents stale state overwrites\n\n### NodeModel\n\nRuntime model for individual nodes, providing React-friendly state:\n\n```typescript\ninterface NodeModel {\n    additionalProps: Atom<LayoutNodeAdditionalProps>;\n    innerRect: Atom<CSSProperties>;\n    blockNum: Atom<number>;\n    nodeId: string;\n    blockId: string;\n    isFocused: Atom<boolean>;\n    isMagnified: Atom<boolean>;\n    isEphemeral: Atom<boolean>;\n    toggleMagnify: () => void;\n    focusNode: () => void;\n    onClose: () => void;\n    dragHandleRef?: React.RefObject<HTMLDivElement>;\n    // ... additional state and methods\n}\n```\n\n## Core Classes\n\n### LayoutModel\n\nThe central orchestrator that manages the entire layout system:\n\n**Key Responsibilities:**\n- Maintains tree state through Jotai atoms\n- Processes layout actions (move, resize, insert, delete)\n- Computes layout positions and transforms\n- Manages drag-and-drop operations\n- Handles resize operations\n- Provides node models for React components\n\n**State Management:**\n```typescript\nclass LayoutModel {\n    treeStateAtom: WritableLayoutTreeStateAtom;  // Persistent state\n    leafs: PrimitiveAtom<LayoutNode[]>;          // Computed leaf nodes\n    additionalProps: PrimitiveAtom<Record<string, LayoutNodeAdditionalProps>>;\n    pendingTreeAction: AtomWithThrottle<LayoutTreeAction>;\n    activeDrag: PrimitiveAtom<boolean>;\n    // ... many more atoms for different aspects\n}\n```\n\n**Action Processing:**\nThe model uses a reducer pattern to process actions:\n```typescript\ntreeReducer(action: LayoutTreeAction) {\n    switch (action.type) {\n        case LayoutTreeActionType.Move:\n            moveNode(this.treeState, action);\n            break;\n        case LayoutTreeActionType.InsertNode:\n            insertNode(this.treeState, action);\n            break;\n        // ... handle all action types\n    }\n    this.updateTree(); // Recompute derived state\n}\n```\n\n## Layout Actions\n\nThe system uses a comprehensive action system for all modifications:\n\n### Action Types\n\n```typescript\nenum LayoutTreeActionType {\n    ComputeMove = \"computemove\",      // Preview move operation\n    Move = \"move\",                    // Execute move\n    Swap = \"swap\",                    // Swap two nodes\n    ResizeNode = \"resize\",            // Resize node(s)\n    InsertNode = \"insert\",            // Insert new node\n    InsertNodeAtIndex = \"insertatindex\", // Insert at specific index\n    DeleteNode = \"delete\",            // Remove node\n    FocusNode = \"focus\",              // Change focus\n    MagnifyNodeToggle = \"magnify\",    // Toggle magnification\n    SplitHorizontal = \"splithorizontal\", // Split horizontally\n    SplitVertical = \"splitvertical\",  // Split vertically\n    // ... more actions\n}\n```\n\n### Action Flow\n\n1. **User Interaction** → Action triggered\n2. **Action Validation** → Check if operation is valid\n3. **Tree Modification** → Update `LayoutTreeState`\n4. **State Propagation** → Update Jotai atoms\n5. **Layout Computation** → Recalculate positions\n6. **React Re-render** → Update UI\n\n### Example: Move Operation\n\n```typescript\n// 1. Compute operation during drag\nconst computeAction: LayoutTreeComputeMoveNodeAction = {\n    type: LayoutTreeActionType.ComputeMove,\n    nodeId: targetNodeId,\n    nodeToMoveId: draggedNodeId,\n    direction: DropDirection.Right\n};\n\n// 2. Execute on drop\nconst moveAction: LayoutTreeMoveNodeAction = {\n    type: LayoutTreeActionType.Move,\n    parentId: newParentId,\n    index: insertIndex,\n    node: nodeToMove\n};\n```\n\n## Drag and Drop System\n\nThe layout system implements a sophisticated drag-and-drop interface using `react-dnd`.\n\n### Drop Direction Logic\n\nWhen dragging over a node, the system determines drop direction based on cursor position:\n\n```typescript\nenum DropDirection {\n    Top = 0, Right = 1, Bottom = 2, Left = 3,\n    OuterTop = 4, OuterRight = 5, OuterBottom = 6, OuterLeft = 7,\n    Center = 8\n}\n```\n\n**Drop Zones:**\n- **Inner zones** (Top/Right/Bottom/Left): Insert within the target node\n- **Outer zones**: Insert in the target's parent\n- **Center**: Swap nodes\n\n### Drag Preview\n\nThe system generates drag previews by:\n1. Rendering content to an off-screen element\n2. Converting to PNG using `html-to-image`\n3. Using the image as the drag preview\n\n## Resize System\n\n### Resize Handles\n\nResize handles are dynamically positioned between adjacent nodes:\n\n```typescript\ninterface ResizeHandleProps {\n    id: string;\n    parentNodeId: string;\n    parentIndex: number;\n    centerPx: number;              // Handle position\n    transform: CSSProperties;      // CSS positioning\n    flexDirection: FlexDirection;  // Handle orientation\n}\n```\n\n### Resize Operation\n\n1. **Handle Drag Start** → Store resize context\n2. **Drag Move** → Compute new sizes based on cursor position\n3. **Throttled Updates** → Update node sizes (10ms throttle)\n4. **Drag End** → Commit final sizes\n\n## Layout Computation\n\nThe system computes absolute positions from the tree structure:\n\n### Process\n\n1. **Tree Walk** → Traverse from root to leaves\n2. **Flexbox Simulation** → Calculate container and child sizes\n3. **Position Calculation** → Compute absolute positions\n4. **Transform Generation** → Create CSS transforms\n5. **Handle Positioning** → Place resize handles between nodes\n\n### Key Functions\n\n- [`updateTreeHelper()`](frontend/layout/lib/layoutModel.ts:638) - Main layout computation\n- [`computeNodeFromProps()`](frontend/layout/lib/layoutModel.ts:718) - Individual node positioning\n- [`setTransform()`](frontend/layout/lib/utils.ts:61) - CSS transform generation\n\n## Node Management\n\n### Node Operations\n\nThe [`layoutNode.ts`](frontend/layout/lib/layoutNode.ts) file provides core node manipulation:\n\n```typescript\n// Create new node\nnewLayoutNode(flexDirection?, size?, children?, data?)\n\n// Tree traversal\nfindNode(node, id)\nfindParent(node, id)\nwalkNodes(node, beforeCallback?, afterCallback?)\n\n// Modifications\naddChildAt(node, index, ...children)\nremoveChild(parent, childToRemove)\nbalanceNode(node) // Optimize tree structure\n```\n\n### Tree Balancing\n\nThe system automatically optimizes the tree structure:\n- Removes unnecessary intermediate nodes\n- Flattens single-child containers\n- Ensures valid flex directions\n\n## State Synchronization\n\n### Frontend ↔ Backend Sync\n\nThe layout state synchronizes with the backend through:\n\n1. **`layoutAtom.ts`** - Jotai atom that wraps backend state\n2. **Generation tracking** - Prevents state conflicts\n3. **Pending actions** - Backend-initiated changes\n4. **Leaf order** - Frontend-computed ordering sent to backend\n\n### Atom Structure\n\n```typescript\nconst layoutTreeStateAtom = atom(\n    (get) => {\n        // Read from backend\n        const layoutState = get(backendLayoutStateAtom);\n        return transformToTreeState(layoutState);\n    },\n    (get, set, treeState) => {\n        // Write to backend\n        if (generationNewer(treeState)) {\n            set(backendLayoutStateAtom, transformFromTreeState(treeState));\n        }\n    }\n);\n```\n\n## Special Features\n\n### Magnification\n\nNodes can be magnified to take up the full layout space:\n- Magnified nodes appear above others (higher z-index)\n- Only one node can be magnified at a time\n- Animation smoothly transitions between normal and magnified states\n\n### Ephemeral Nodes\n\nTemporary nodes that aren't part of the persistent tree:\n- Used for preview/temporary content\n- Automatically cleaned up\n- Appear above the normal layout\n\n### Focus Management\n\n- One node can be focused at a time\n- Focus affects keyboard navigation\n- Integrates with the terminal's block focus system\n\n## Integration Points\n\n### React Integration\n\n**Hooks:**\n- [`useTileLayout()`](frontend/layout/lib/layoutModelHooks.ts:51) - Main hook for layout setup\n- [`useNodeModel()`](frontend/layout/lib/layoutModelHooks.ts:65) - Get node model for component\n- [`useDebouncedNodeInnerRect()`](frontend/layout/lib/layoutModelHooks.ts:69) - Animated positioning\n\n### Content Rendering\n\nThe layout system is content-agnostic through render callbacks:\n\n```typescript\ninterface TileLayoutContents {\n    renderContent: (nodeModel: NodeModel) => React.ReactNode;\n    renderPreview?: (nodeModel: NodeModel) => React.ReactElement;\n    onNodeDelete?: (data: TabLayoutData) => Promise<void>;\n}\n```\n\n### Performance Optimizations\n\n1. **Memoization** - Extensive use of `React.memo()` and `useMemo()`\n2. **Throttling** - Resize and drag operations throttled to 10-50ms\n3. **Transform-based positioning** - Uses CSS transforms for performance\n4. **Split atoms** - Jotai `splitAtom()` for efficient array updates\n5. **Selective re-rendering** - Only affected components re-render\n\n## Common Patterns\n\n### Adding New Actions\n\n1. Define action type in [`types.ts`](frontend/layout/lib/types.ts)\n2. Implement handler in [`layoutTree.ts`](frontend/layout/lib/layoutTree.ts)\n3. Add case to [`LayoutModel.treeReducer()`](frontend/layout/lib/layoutModel.ts:330)\n4. Update generation and call `updateTree()`\n\n### Extending Node Properties\n\n1. Add to `LayoutNodeAdditionalProps` in [`types.ts`](frontend/layout/lib/types.ts)\n2. Compute in [`updateTreeHelper()`](frontend/layout/lib/layoutModel.ts:638)\n3. Access via `nodeModel.additionalProps`\n\n### Custom Layout Behaviors\n\nOverride or extend layout computation by:\n1. Modifying [`computeNodeFromProps()`](frontend/layout/lib/layoutModel.ts:718)\n2. Adding custom CSS transforms\n3. Implementing special handling in action reducers\n\n## Error Handling\n\nThe system includes extensive validation:\n- Node structure validation\n- Action parameter checking\n- Tree consistency checks\n- Graceful degradation on errors\n\n## Testing\n\nThe layout system includes comprehensive tests:\n- [`layoutNode.test.ts`](frontend/layout/tests/layoutNode.test.ts) - Node operations\n- [`layoutTree.test.ts`](frontend/layout/tests/layoutTree.test.ts) - Tree operations  \n- [`utils.test.ts`](frontend/layout/tests/utils.test.ts) - Utility functions\n\n## Debugging\n\nFor debugging layout issues:\n1. Check `treeState.generation` for state changes\n2. Inspect `additionalProps` for computed layout data\n3. Use browser dev tools to examine CSS transforms\n4. Enable console logging in action reducers\n\nThe layout system is complex but well-structured, providing a powerful foundation for Wave Terminal's dynamic layout capabilities."
  },
  {
    "path": "aiprompts/monaco-v0.53.md",
    "content": "# Monaco 0.52 → 0.53 ESM Migration Plan (Vite/Electron)\n\n**Status:** Deferred to next release.\n**Current:** Pinned to `monaco-editor@0.52.x` (works with `@monaco-editor/loader`).\n**Target:** Switch to `monaco-editor@≥0.53` ESM build and drop `@monaco-editor/loader` + AMD path copy.\n\n---\n\n## Why this change\n\n- Monaco 0.53 deprecates the AMD build. The loader/AMD path mapping (`paths: { vs: \"monaco\" }`) becomes brittle.\n- ESM build uses **module workers**, which require explicit worker wiring.\n- Benefits: cleaner bundling with Vite, fewer legacy shims, better CSP/Electron compatibility.\n\n---\n\n## High‑level plan\n\n1. **Remove AMD/loader**: uninstall `@monaco-editor/loader`; remove `viteStaticCopy` of `min/vs/*`; delete `loader.config/init` calls.\n2. **Install Monaco ≥0.53** and **wire ESM workers** via `MonacoEnvironment.getWorker`.\n3. **Keep main bundle slim**: lazy‑load the Monaco setup; optionally force a separate `monaco` chunk.\n4. **Electron / build**: ensure `base: './'` in Vite for packaged apps.\n\n---\n\n## Step‑by‑step\n\n### 1) Dependencies\n\n```bash\n# next cycle:\nnpm rm @monaco-editor/loader\nnpm i monaco-editor@^0.53\n```\n\n### 2) Remove AMD-era build config\n\n- Delete `viteStaticCopy({ targets: [{ src: \"node_modules/monaco-editor/min/vs/*\", dest: \"monaco\" }] })`.\n- Delete:\n\n  ```ts\n  loader.config({ paths: { vs: \"monaco\" } });\n  await loader.init();\n  ```\n\n### 3) Add ESM setup module\n\nCreate `monaco-setup.ts`:\n\n```ts\n// monaco-setup.ts\nimport * as monaco from \"monaco-editor/esm/vs/editor/editor.api\";\nimport \"monaco-editor/esm/vs/editor/editor.all.css\";\n\n(self as any).MonacoEnvironment = {\n  getWorker(_moduleId: string, label: string) {\n    switch (label) {\n      case \"json\":\n        return new Worker(new URL(\"monaco-editor/esm/vs/language/json/json.worker.js\", import.meta.url), {\n          type: \"module\",\n        });\n      case \"css\":\n        return new Worker(new URL(\"monaco-editor/esm/vs/language/css/css.worker.js\", import.meta.url), {\n          type: \"module\",\n        });\n      case \"html\":\n        return new Worker(new URL(\"monaco-editor/esm/vs/language/html/html.worker.js\", import.meta.url), {\n          type: \"module\",\n        });\n      case \"typescript\":\n      case \"javascript\":\n        return new Worker(new URL(\"monaco-editor/esm/vs/language/typescript/ts.worker.js\", import.meta.url), {\n          type: \"module\",\n        });\n      default:\n        return new Worker(new URL(\"monaco-editor/esm/vs/editor/editor.worker.js\", import.meta.url), { type: \"module\" });\n    }\n  },\n};\n\nexport { monaco };\n```\n\n### 4) Import lazily where used\n\n```ts\n// where the editor UI mounts\nconst { monaco } = await import(\"./monaco-setup\");\nconst editor = monaco.editor.create(container, { language: \"javascript\", value: \"\" });\n```\n\n### 5) Optional: isolate Monaco into its own chunk\n\n`vite.config.ts`:\n\n```ts\nimport { defineConfig } from \"vite\";\n\nexport default defineConfig({\n  base: \"./\", // important for Electron packaged apps\n  build: {\n    rollupOptions: {\n      output: {\n        manualChunks(id) {\n          if (id.includes(\"node_modules/monaco-editor\")) return \"monaco\";\n        },\n      },\n    },\n  },\n});\n```\n\n> Note: Workers created via `new URL(..., import.meta.url)` are emitted as **separate chunks** automatically.\n\n---\n\n## Bundle size controls (pick what you need)\n\n- Import `editor.api` instead of full `editor` (already done above).\n- Only include workers you use (drop `json/css/html` blocks if not needed).\n- Lazy‑load Monaco with `import()` behind the UI that needs it.\n- Optionally dynamic‑import language contributions on demand:\n\n  ```ts\n  if (lang === \"json\") {\n    await import(\"monaco-editor/esm/vs/language/json/monaco.contribution\");\n  }\n  ```\n\n---\n\n## Electron specifics\n\n- `base: './'` in `vite.config.ts` so worker URLs resolve under `file://` in packaged apps.\n- `{ type: 'module' }` is required for Monaco’s ESM workers.\n- This approach avoids blob URLs and works with stricter CSPs.\n\n---\n\n## Test checklist\n\n- Dev: editor renders; no 404s for worker scripts; language services active (TS hover/diagnostics, JSON schema).\n- Prod build: verify worker files emitted; open packaged Electron app and ensure workers load (no \"Cannot use import statement outside a module\").\n- Hot paths: open/close editor repeatedly; memory doesn’t grow unbounded.\n\n---\n\n## Rollback plan\n\nIf anything blocks the release, revert to:\n\n```bash\nnpm i monaco-editor@0.52.x\nnpm i -D @monaco-editor/loader\n```\n\nRestore the `viteStaticCopy` block and `loader.config/init` calls.\n\n---\n\n## Open questions (optional)\n\n- Do we need JSON/CSS/HTML workers in the default bundle? (Decide before wiring.)\n- Any extra CSP limitations for production? (If so, confirm worker script allowances.)\n\n---\n\n## Snippet index (for quick copy)\n\n- `monaco-setup.ts` (ESM + workers): see above.\n- `vite.config.ts` (`base: './'` + `manualChunks`): see above.\n- Lazy import site: `const { monaco } = await import('./monaco-setup');`\n"
  },
  {
    "path": "aiprompts/newview.md",
    "content": "# Creating a New View in Wave Terminal\n\nThis 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.\n\n## Architecture Overview\n\nWave Terminal uses a **Model-View architecture** where:\n- **ViewModel** - Contains all state, logic, and UI configuration as Jotai atoms\n- **ViewComponent** - Pure React component that renders the UI using the model\n- **BlockFrame** - Wraps views with a header, connection management, and standard controls\n\nThe separation between model and component ensures:\n- Models can update state without React hooks\n- Components remain pure and testable\n- State is centralized in Jotai atoms for easy access\n\n## ViewModel Interface\n\nEvery view must implement the `ViewModel` interface defined in [`frontend/types/custom.d.ts`](../frontend/types/custom.d.ts:285-341):\n\n```typescript\ninterface ViewModel {\n    // Required: The type identifier for this view (e.g., \"term\", \"web\", \"preview\")\n    viewType: string;\n\n    // Required: The React component that renders this view\n    viewComponent: ViewComponent<ViewModel>;\n\n    // Optional: Icon shown in block header (FontAwesome icon name or IconButtonDecl)\n    viewIcon?: jotai.Atom<string | IconButtonDecl>;\n\n    // Optional: Display name shown in block header (e.g., \"Terminal\", \"Web\", \"Preview\")\n    viewName?: jotai.Atom<string>;\n\n    // Optional: Additional header elements (text, buttons, inputs) shown after the name\n    viewText?: jotai.Atom<string | HeaderElem[]>;\n\n    // Optional: Icon button shown before the view name in header\n    preIconButton?: jotai.Atom<IconButtonDecl>;\n\n    // Optional: Icon buttons shown at the end of the header (before settings/close)\n    endIconButtons?: jotai.Atom<IconButtonDecl[]>;\n\n    // Optional: Custom background styling for the block\n    blockBg?: jotai.Atom<MetaType>;\n\n    // Optional: If true, completely hides the block header\n    noHeader?: jotai.Atom<boolean>;\n\n    // Optional: If true, shows connection picker in header for remote connections\n    manageConnection?: jotai.Atom<boolean>;\n\n    // Optional: If true, filters out 'nowsh' connections from connection picker\n    filterOutNowsh?: jotai.Atom<boolean>;\n\n    // Optional: If true, shows S3 connections in connection picker\n    showS3?: jotai.Atom<boolean>;\n\n    // Optional: If true, removes default padding from content area\n    noPadding?: jotai.Atom<boolean>;\n\n    // Optional: Atoms for managing in-block search functionality\n    searchAtoms?: SearchAtoms;\n\n    // Optional: Returns whether this is a basic terminal (for multi-input feature)\n    isBasicTerm?: (getFn: jotai.Getter) => boolean;\n\n    // Optional: Returns context menu items for the settings dropdown\n    getSettingsMenuItems?: () => ContextMenuItem[];\n\n    // Optional: Focuses the view when called, returns true if successful\n    giveFocus?: () => boolean;\n\n    // Optional: Handles keyboard events, returns true if handled\n    keyDownHandler?: (e: WaveKeyboardEvent) => boolean;\n\n    // Optional: Cleanup when block is closed\n    dispose?: () => void;\n}\n```\n\n### Key Concepts\n\n**Atoms**: All UI-related properties must be Jotai atoms. This enables:\n- Reactive updates when state changes\n- Access from anywhere via `globalStore.get()`/`globalStore.set()`\n- Derived atoms that compute values from other atoms\n\n**ViewComponent**: The React component receives these props:\n```typescript\ntype ViewComponentProps<T extends ViewModel> = {\n    blockId: string;                              // Unique ID for this block\n    blockRef: React.RefObject<HTMLDivElement>;    // Ref to block container\n    contentRef: React.RefObject<HTMLDivElement>;  // Ref to content area\n    model: T;                                      // Your ViewModel instance\n};\n```\n\n## Step-by-Step Guide\n\n### 1. Create the View Model Class\n\nCreate a new file for your view model (e.g., `frontend/app/view/myview/myview-model.ts`):\n\n```typescript\nimport { BlockNodeModel } from \"@/app/block/blocktypes\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { WOS, useBlockAtom } from \"@/store/global\";\nimport * as jotai from \"jotai\";\nimport { MyView } from \"./myview\";\n\nexport class MyViewModel implements ViewModel {\n    viewType: string;\n    blockId: string;\n    nodeModel: BlockNodeModel;\n    blockAtom: jotai.Atom<Block>;\n    \n    // Define your atoms (simple field initializers)\n    viewIcon = jotai.atom<string>(\"circle\");\n    viewName = jotai.atom<string>(\"My View\");\n    noPadding = jotai.atom<boolean>(true);\n    \n    // Derived atom (created in constructor)\n    viewText!: jotai.Atom<HeaderElem[]>;\n\n    constructor(blockId: string, nodeModel: BlockNodeModel) {\n        this.viewType = \"myview\";\n        this.blockId = blockId;\n        this.nodeModel = nodeModel;\n        this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);\n        \n        // Create derived atoms that depend on block data or other atoms\n        this.viewText = jotai.atom((get) => {\n            const blockData = get(this.blockAtom);\n            const rtn: HeaderElem[] = [];\n            \n            // Add header buttons/text based on state\n            rtn.push({\n                elemtype: \"iconbutton\",\n                icon: \"refresh\",\n                title: \"Refresh\",\n                click: () => this.refresh(),\n            });\n            \n            return rtn;\n        });\n    }\n\n    get viewComponent(): ViewComponent {\n        return MyView;\n    }\n\n    refresh() {\n        // Update state using globalStore\n        // Never use React hooks in model methods\n        console.log(\"refreshing...\");\n    }\n\n    giveFocus(): boolean {\n        // Focus your view component\n        return true;\n    }\n\n    dispose() {\n        // Cleanup resources (unsubscribe from events, etc.)\n    }\n}\n```\n\n### 2. Create the View Component\n\nCreate your React component (e.g., `frontend/app/view/myview/myview.tsx`):\n\n```typescript\nimport { ViewComponentProps } from \"@/app/block/blocktypes\";\nimport { MyViewModel } from \"./myview-model\";\nimport { useAtomValue } from \"jotai\";\nimport \"./myview.scss\";\n\nexport const MyView: React.FC<ViewComponentProps<MyViewModel>> = ({ \n    blockId, \n    model, \n    contentRef \n}) => {\n    // Use atoms from the model (these are React hooks - call at top level!)\n    const blockData = useAtomValue(model.blockAtom);\n    \n    return (\n        <div className=\"myview-container\" ref={contentRef}>\n            <div>Block ID: {blockId}</div>\n            <div>View: {model.viewType}</div>\n            {/* Your view content here */}\n        </div>\n    );\n};\n```\n\n### 3. Register the View\n\nAdd your view to the `BlockRegistry` in [`frontend/app/block/block.tsx`](../frontend/app/block/block.tsx:42-55):\n\n```typescript\nconst BlockRegistry: Map<string, ViewModelClass> = new Map();\nBlockRegistry.set(\"term\", TermViewModel);\nBlockRegistry.set(\"preview\", PreviewModel);\nBlockRegistry.set(\"web\", WebViewModel);\n// ... existing registrations ...\nBlockRegistry.set(\"myview\", MyViewModel);  // Add your view here\n```\n\nThe registry key (e.g., `\"myview\"`) becomes the view type used in block metadata.\n\n### 4. Create Blocks with Your View\n\nUsers can create blocks with your view type:\n- Via CLI: `wsh view myview`\n- Via RPC: Use the block's `meta.view` field set to `\"myview\"`\n\n## Real-World Examples\n\n### Example 1: Terminal View ([`term-model.ts`](../frontend/app/view/term/term-model.ts))\n\nThe terminal view demonstrates:\n- **Connection management** via `manageConnection` atom\n- **Dynamic header buttons** showing shell status (play/restart)\n- **Mode switching** between terminal and vdom views\n- **Custom keyboard handling** for terminal-specific shortcuts\n- **Focus management** to focus the xterm.js instance\n- **Shell integration status** showing AI capability indicators\n\nKey features:\n```typescript\nthis.manageConnection = jotai.atom((get) => {\n    const termMode = get(this.termMode);\n    if (termMode == \"vdom\") return false;\n    return true;  // Show connection picker for regular terminal mode\n});\n\nthis.endIconButtons = jotai.atom((get) => {\n    const shellProcStatus = get(this.shellProcStatus);\n    const buttons: IconButtonDecl[] = [];\n    \n    if (shellProcStatus == \"running\") {\n        buttons.push({\n            elemtype: \"iconbutton\",\n            icon: \"refresh\",\n            title: \"Restart Shell\",\n            click: this.forceRestartController.bind(this),\n        });\n    }\n    return buttons;\n});\n```\n\n### Example 2: Web View ([`webview.tsx`](../frontend/app/view/webview/webview.tsx))\n\nThe web view shows:\n- **Complex header controls** (back/forward/home/URL input)\n- **State management** for loading, URL, and navigation\n- **Event handling** for webview navigation events\n- **Custom styling** with `noPadding` for full-bleed content\n- **Media controls** showing play/pause/mute when media is active\n\nKey features:\n```typescript\nthis.viewText = jotai.atom((get) => {\n    const url = get(this.url);\n    const rtn: HeaderElem[] = [];\n    \n    // Navigation buttons\n    rtn.push({\n        elemtype: \"iconbutton\",\n        icon: \"chevron-left\",\n        click: this.handleBack.bind(this),\n        disabled: this.shouldDisableBackButton(),\n    });\n    \n    // URL input with nested controls\n    rtn.push({\n        elemtype: \"div\",\n        className: \"block-frame-div-url\",\n        children: [\n            {\n                elemtype: \"input\",\n                value: url,\n                onChange: this.handleUrlChange.bind(this),\n                onKeyDown: this.handleKeyDown.bind(this),\n            },\n            {\n                elemtype: \"iconbutton\",\n                icon: \"rotate-right\",\n                click: this.handleRefresh.bind(this),\n            }\n        ],\n    });\n    \n    return rtn;\n});\n```\n\n## Header Elements (`HeaderElem`)\n\nThe `viewText` atom can return an array of these element types:\n\n```typescript\n// Icon button\n{\n    elemtype: \"iconbutton\",\n    icon: \"refresh\",\n    title: \"Tooltip text\",\n    click: () => { /* handler */ },\n    disabled?: boolean,\n    iconColor?: string,\n    iconSpin?: boolean,\n    noAction?: boolean,  // Shows icon but no click action\n}\n\n// Text element\n{\n    elemtype: \"text\",\n    text: \"Display text\",\n    className?: string,\n    noGrow?: boolean,\n    ref?: React.RefObject<HTMLElement>,\n    onClick?: (e: React.MouseEvent) => void,\n}\n\n// Text button\n{\n    elemtype: \"textbutton\",\n    text: \"Button text\",\n    className?: string,\n    title: \"Tooltip\",\n    onClick: (e: React.MouseEvent) => void,\n}\n\n// Input field\n{\n    elemtype: \"input\",\n    value: string,\n    className?: string,\n    onChange: (e: React.ChangeEvent<HTMLInputElement>) => void,\n    onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void,\n    onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void,\n    onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void,\n    ref?: React.RefObject<HTMLInputElement>,\n}\n\n// Container with children\n{\n    elemtype: \"div\",\n    className?: string,\n    children: HeaderElem[],\n    onMouseOver?: (e: React.MouseEvent) => void,\n    onMouseOut?: (e: React.MouseEvent) => void,\n}\n\n// Menu button (dropdown)\n{\n    elemtype: \"menubutton\",\n    // ... MenuButtonProps ...\n}\n```\n\n## Best Practices\n\n### Jotai Model Pattern\n\nFollow these rules for Jotai atoms in models:\n\n1. **Simple atoms as field initializers**:\n   ```typescript\n   viewIcon = jotai.atom<string>(\"circle\");\n   noPadding = jotai.atom<boolean>(true);\n   ```\n\n2. **Derived atoms in constructor** (need dependency on other atoms):\n   ```typescript\n   constructor(blockId: string, nodeModel: BlockNodeModel) {\n       this.viewText = jotai.atom((get) => {\n           const blockData = get(this.blockAtom);\n           return [/* computed based on blockData */];\n       });\n   }\n   ```\n\n3. **Models never use React hooks** - Use `globalStore.get()`/`set()`:\n   ```typescript\n   refresh() {\n       const currentData = globalStore.get(this.blockAtom);\n       globalStore.set(this.dataAtom, newData);\n   }\n   ```\n\n4. **Components use hooks for atoms**:\n   ```typescript\n   const data = useAtomValue(model.dataAtom);\n   const [value, setValue] = useAtom(model.valueAtom);\n   ```\n\n### State Management\n\n- All view state should live in atoms on the model\n- Use `useBlockAtom()` helper for block-scoped atoms that persist\n- Use `globalStore` for imperative access outside React components\n- Subscribe to Wave events using `waveEventSubscribe()`\n\n### Styling\n\n- Create a `.scss` file for your view styles\n- Use Tailwind utilities where possible (v4)\n- Add `noPadding: atom(true)` for full-bleed content\n- Use `blockBg` atom to customize block background\n\n### Focus Management\n\nImplement `giveFocus()` to focus your view when:\n- Block gains focus via keyboard navigation\n- User clicks the block\n- Return `true` if successfully focused, `false` otherwise\n\n### Keyboard Handling\n\nImplement `keyDownHandler(e: WaveKeyboardEvent)` for:\n- View-specific keyboard shortcuts\n- Return `true` if event was handled (prevents propagation)\n- Use `keyutil.checkKeyPressed(waveEvent, \"Cmd:K\")` for shortcut checks\n\n### Cleanup\n\nImplement `dispose()` to:\n- Unsubscribe from Wave events\n- Unregister routes/handlers\n- Clear timers/intervals\n- Release resources\n\n### Connection Management\n\nFor views that need remote connections:\n```typescript\nthis.manageConnection = jotai.atom(true);  // Show connection picker\nthis.filterOutNowsh = jotai.atom(true);    // Hide nowsh connections\nthis.showS3 = jotai.atom(true);            // Show S3 connections\n```\n\nAccess connection status:\n```typescript\nconst connStatus = jotai.atom((get) => {\n    const blockData = get(this.blockAtom);\n    const connName = blockData?.meta?.connection;\n    return get(getConnStatusAtom(connName));\n});\n```\n\n## Common Patterns\n\n### Reading Block Metadata\n\n```typescript\nimport { getBlockMetaKeyAtom } from \"@/store/global\";\n\n// In constructor:\nthis.someFlag = getBlockMetaKeyAtom(blockId, \"myview:flag\");\n\n// In component:\nconst flag = useAtomValue(model.someFlag);\n```\n\n### Configuration Overrides\n\nWave has a hierarchical config system (global → connection → block):\n\n```typescript\nimport { getOverrideConfigAtom } from \"@/store/global\";\n\nthis.settingAtom = jotai.atom((get) => {\n    // Checks block meta, then connection config, then global settings\n    return get(getOverrideConfigAtom(this.blockId, \"myview:setting\")) ?? defaultValue;\n});\n```\n\n### Updating Block Metadata\n\n```typescript\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { WOS } from \"@/store/global\";\n\nawait RpcApi.SetMetaCommand(TabRpcClient, {\n    oref: WOS.makeORef(\"block\", this.blockId),\n    meta: { \"myview:key\": value },\n});\n```\n\n### Search Integration\n\nTo add in-block search:\n\n```typescript\nimport { useSearch } from \"@/app/element/search\";\n\n// In model:\nthis.searchAtoms = useSearch();  // Call in component, not model!\n\n// In component:\nconst searchAtoms = useSearch();\n// Pass to model or use directly\n```\n\n## Testing Your View\n\n1. Build the frontend: `task build:dev` or `task electron:dev`\n2. Create a block with your view type\n3. Test all interactive elements (buttons, inputs, etc.)\n4. Test keyboard shortcuts\n5. Test focus behavior\n6. Test cleanup (close block and check console for errors)\n7. Test with different block configurations via metadata\n\n## Additional Resources\n\n- [`frontend/app/block/blockframe.tsx`](../frontend/app/block/blockframe.tsx) - Block header rendering\n- [`frontend/app/view/term/term-model.ts`](../frontend/app/view/term/term-model.ts) - Complex view example\n- [`frontend/app/view/webview/webview.tsx`](../frontend/app/view/webview/webview.tsx) - Navigation UI example\n- [`frontend/types/custom.d.ts`](../frontend/types/custom.d.ts) - Type definitions\n- Project coding rules in [`.roo/rules/`](../.roo/rules/)"
  },
  {
    "path": "aiprompts/openai-request.md",
    "content": "# OpenAI Request Input Field Structure (On-the-Wire Format)\n\nThis document describes the actual JSON structure sent to the OpenAI API in the `input` field of [`OpenAIRequest`](../pkg/aiusechat/openai/openai-convertmessage.go:111).\n\n## Overview\n\nThe `input` field is a JSON array containing one of three object types:\n\n1. **Messages** (user/assistant) - `OpenAIMessage` objects\n2. **Function Calls** (tool invocations) - `OpenAIFunctionCallInput` objects\n3. **Function Call Results** (tool outputs) - `OpenAIFunctionCallOutputInput` objects\n\nThese 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)).\n\n## 1. Message Objects (User/Assistant)\n\nUser and assistant messages sent as [`OpenAIMessage`](../pkg/aiusechat/openai/openai-backend.go:54-57):\n\n```json\n{\n  \"role\": \"user\",\n  \"content\": [\n    {\n      \"type\": \"input_text\",\n      \"text\": \"Hello, analyze this image\"\n    },\n    {\n      \"type\": \"input_image\",\n      \"image_url\": \"data:image/png;base64,iVBORw0KG...\"\n    }\n  ]\n}\n```\n\n**Key Points:**\n- `role`: Always `\"user\"` or `\"assistant\"`\n- `content`: **Always an array** of content blocks (never a plain string)\n\n### Content Block Types\n\n#### Text Block\n```json\n{\n  \"type\": \"input_text\",\n  \"text\": \"message content here\"\n}\n```\n\n#### Image Block\n```json\n{\n  \"type\": \"input_image\",\n  \"image_url\": \"data:image/png;base64,...\"\n}\n```\n- Can be a data URL or https:// URL\n- `filename` field is **removed** during cleaning\n\n#### PDF File Block\n```json\n{\n  \"type\": \"input_file\",\n  \"file_data\": \"JVBERi0xLjQKJeLjz9M...\",\n  \"filename\": \"document.pdf\"\n}\n```\n- `file_data`: Base64-encoded PDF content\n\n#### Function Call Block (in assistant messages)\n```json\n{\n  \"type\": \"function_call\",\n  \"call_id\": \"call_abc123\",\n  \"name\": \"search_files\",\n  \"arguments\": {\"query\": \"test\"}\n}\n```\n\n## 2. Function Call Objects (Tool Invocations)\n\nTool calls from the model sent as [`OpenAIFunctionCallInput`](../pkg/aiusechat/openai/openai-backend.go:59-67):\n\n```json\n{\n  \"type\": \"function_call\",\n  \"call_id\": \"call_abc123\",\n  \"name\": \"search_files\",\n  \"arguments\": \"{\\\"query\\\":\\\"test\\\",\\\"path\\\":\\\"./src\\\"}\"\n}\n```\n\n**Key Points:**\n- `type`: Always `\"function_call\"`\n- `call_id`: Unique identifier generated by model\n- `name`: Function name to execute\n- `arguments`: JSON-encoded string of parameters\n- `status`: Optional (`\"in_progress\"`, `\"completed\"`, `\"incomplete\"`)\n- Internal `toolusedata` field is **removed** during cleaning\n\n## 3. Function Call Output Objects (Tool Results)\n\nTool execution results sent as [`OpenAIFunctionCallOutputInput`](../pkg/aiusechat/openai/openai-backend.go:69-75):\n\n```json\n{\n  \"type\": \"function_call_output\",\n  \"call_id\": \"call_abc123\",\n  \"output\": \"Found 3 files matching query\"\n}\n```\n\n**Key Points:**\n- `type`: Always `\"function_call_output\"`\n- `call_id`: Must match the original function call's `call_id`\n- `output`: Can be text, image array, or error object\n\n### Output Value Types\n\n#### Text Output\n```json\n{\n  \"type\": \"function_call_output\",\n  \"call_id\": \"call_abc123\",\n  \"output\": \"Result text here\"\n}\n```\n\n#### Image Output\n```json\n{\n  \"type\": \"function_call_output\",\n  \"call_id\": \"call_abc123\",\n  \"output\": [\n    {\n      \"type\": \"input_image\",\n      \"image_url\": \"data:image/png;base64,...\"\n    }\n  ]\n}\n```\n\n#### Error Output\n```json\n{\n  \"type\": \"function_call_output\",\n  \"call_id\": \"call_abc123\",\n  \"output\": \"{\\\"ok\\\":\\\"false\\\",\\\"error\\\":\\\"File not found\\\"}\"\n}\n```\n- Error output is a JSON-encoded string containing `ok` and `error` fields\n\n## Complete Example\n\n```json\n{\n  \"model\": \"gpt-4o\",\n  \"input\": [\n    {\n      \"role\": \"user\",\n      \"content\": [\n        {\n          \"type\": \"input_text\",\n          \"text\": \"What files are in src/?\"\n        }\n      ]\n    },\n    {\n      \"type\": \"function_call\",\n      \"call_id\": \"call_xyz789\",\n      \"name\": \"list_files\",\n      \"arguments\": \"{\\\"path\\\":\\\"src/\\\"}\"\n    },\n    {\n      \"type\": \"function_call_output\",\n      \"call_id\": \"call_xyz789\",\n      \"output\": \"main.go\\nutil.go\\nconfig.go\"\n    },\n    {\n      \"role\": \"assistant\",\n      \"content\": [\n        {\n          \"type\": \"output_text\",\n          \"text\": \"The src/ directory contains 3 files: main.go, util.go, and config.go\"\n        }\n      ]\n    }\n  ],\n  \"stream\": true,\n  \"max_output_tokens\": 4096\n}\n```\n\n## Cleaning Process\n\nBefore transmission, internal fields are removed ([cleanup code](../pkg/aiusechat/openai/openai-backend.go:485-494)):\n\n- **Messages**: `previewurl` field removed, `filename` removed from `input_image` blocks\n- **Function Calls**: `toolusedata` field removed\n- **Function Outputs**: Sent as-is (no cleaning needed)\n\nThis ensures the API receives only the fields it expects."
  },
  {
    "path": "aiprompts/openai-streaming-text.md",
    "content": "For **just text streaming**, you only need to handle these 3 core events:\n\n## Essential Events\n\n### 1. `response.created`\n\n```json\n{\n  \"type\": \"response.created\",\n  \"response\": {\n    \"id\": \"resp_abc123\",\n    \"created_at\": 1640995200,\n    \"model\": \"gpt-5\"\n  }\n}\n```\n\n**Purpose**: Initialize response tracking (like Anthropic's `message_start`)\n\n### 2. `response.output_text.delta`\n\n```json\n{\n  \"type\": \"response.output_text.delta\",\n  \"item_id\": \"msg_abc123\",\n  \"delta\": \"Hello, how can I\"\n}\n```\n\n**Purpose**: Stream text chunks (like Anthropic's `text_delta`)\n\n### 3. `response.completed`\n\n```json\n{\n  \"type\": \"response.completed\",\n  \"response\": {\n    \"usage\": {\n      \"input_tokens\": 100,\n      \"output_tokens\": 200\n    }\n  }\n}\n```\n\n**Purpose**: Finalize response (like Anthropic's `message_stop`)\n\n## Optional but Recommended\n\n### 4. `error`\n\n```json\n{\n  \"type\": \"error\",\n  \"code\": \"rate_limit_exceeded\",\n  \"message\": \"Rate limit exceeded\"\n}\n```\n\n**Purpose**: Handle errors gracefully\n\n---\n\nThat'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.\n\nYour Go implementation would be:\n\n1. Parse SSE stream\n2. Switch on `event.type`\n3. Handle these 4 event types\n4. Accumulate text from `delta` fields\n5. Emit to your existing SSE handler\n\nMuch simpler than the full implementation.\n"
  },
  {
    "path": "aiprompts/openai-streaming.md",
    "content": "# OpenAI Responses API SSE Events Documentation\n\nThis 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.\n\n## Core Event Types\n\n### Response Lifecycle Events\n\n#### `response.created`\n\nEmitted when a new response begins.\n\n```json\n{\n  \"type\": \"response.created\",\n  \"response\": {\n    \"id\": \"resp_abc123\",\n    \"created_at\": 1640995200,\n    \"model\": \"gpt-5\",\n    \"service_tier\": \"default\"\n  }\n}\n```\n\n#### `response.completed`\n\nEmitted when the response completes successfully.\n\n```json\n{\n  \"type\": \"response.completed\",\n  \"response\": {\n    \"incomplete_details\": null,\n    \"usage\": {\n      \"input_tokens\": 100,\n      \"input_tokens_details\": {\n        \"cached_tokens\": 50\n      },\n      \"output_tokens\": 200,\n      \"output_tokens_details\": {\n        \"reasoning_tokens\": 150\n      }\n    },\n    \"service_tier\": \"default\"\n  }\n}\n```\n\n#### `response.incomplete`\n\nEmitted when the response is incomplete (e.g., due to length limits).\n\n```json\n{\n  \"type\": \"response.incomplete\",\n  \"response\": {\n    \"incomplete_details\": {\n      \"reason\": \"max_tokens\"\n    },\n    \"usage\": {\n      \"input_tokens\": 100,\n      \"output_tokens\": 4000\n    }\n  }\n}\n```\n\n### Content Block Events\n\n#### `response.output_item.added`\n\nEmitted when a new output item (content block) is added.\n\n```json\n{\n  \"type\": \"response.output_item.added\",\n  \"output_index\": 0,\n  \"item\": {\n    \"type\": \"message\",\n    \"id\": \"msg_abc123\"\n  }\n}\n```\n\nItem types can be:\n\n- `message` - Text content\n- `reasoning` - Reasoning/thinking content\n- `function_call` - Tool call\n- `web_search_call` - Web search tool call\n- `computer_call` - Computer use tool call\n- `file_search_call` - File search tool call\n- `image_generation_call` - Image generation tool call\n- `code_interpreter_call` - Code interpreter tool call\n\n#### `response.output_item.done`\n\nEmitted when an output item is completed.\n\n```json\n{\n  \"type\": \"response.output_item.done\",\n  \"output_index\": 0,\n  \"item\": {\n    \"type\": \"message\",\n    \"id\": \"msg_abc123\"\n  }\n}\n```\n\nFor function calls, includes the complete arguments:\n\n```json\n{\n  \"type\": \"response.output_item.done\",\n  \"output_index\": 1,\n  \"item\": {\n    \"type\": \"function_call\",\n    \"id\": \"call_abc123\",\n    \"call_id\": \"call_abc123\",\n    \"name\": \"get_weather\",\n    \"arguments\": \"{\\\"location\\\": \\\"San Francisco\\\"}\",\n    \"status\": \"completed\"\n  }\n}\n```\n\n### Text Streaming Events\n\n#### `response.output_text.delta`\n\nEmitted for incremental text content.\n\n```json\n{\n  \"type\": \"response.output_text.delta\",\n  \"item_id\": \"msg_abc123\",\n  \"delta\": \"Hello, how can I\",\n  \"logprobs\": [\n    {\n      \"token\": \"Hello\",\n      \"logprob\": -0.1,\n      \"top_logprobs\": [\n        {\n          \"token\": \"Hello\",\n          \"logprob\": -0.1\n        },\n        {\n          \"token\": \"Hi\",\n          \"logprob\": -2.3\n        }\n      ]\n    }\n  ]\n}\n```\n\n### Tool Call Events\n\n#### `response.function_call_arguments.delta`\n\nEmitted for streaming function call arguments.\n\n```json\n{\n  \"type\": \"response.function_call_arguments.delta\",\n  \"item_id\": \"call_abc123\",\n  \"output_index\": 1,\n  \"delta\": \"\\\"location\\\": \\\"San\"\n}\n```\n\n### Reasoning Events\n\n#### `response.reasoning_summary_part.added`\n\nEmitted when a new reasoning summary part is added.\n\n```json\n{\n  \"type\": \"response.reasoning_summary_part.added\",\n  \"item_id\": \"reasoning_abc123\",\n  \"summary_index\": 0\n}\n```\n\n#### `response.reasoning_summary_text.delta`\n\nEmitted for incremental reasoning text.\n\n```json\n{\n  \"type\": \"response.reasoning_summary_text.delta\",\n  \"item_id\": \"reasoning_abc123\",\n  \"summary_index\": 0,\n  \"delta\": \"Let me think about this step by step...\"\n}\n```\n\n### Annotation Events\n\n#### `response.output_text.annotation.added`\n\nEmitted when citations or annotations are added to text.\n\n```json\n{\n  \"type\": \"response.output_text.annotation.added\",\n  \"annotation\": {\n    \"type\": \"url_citation\",\n    \"url\": \"https://example.com/article\",\n    \"title\": \"Example Article\"\n  }\n}\n```\n\nOr for file citations:\n\n```json\n{\n  \"type\": \"response.output_text.annotation.added\",\n  \"annotation\": {\n    \"type\": \"file_citation\",\n    \"file_id\": \"file_abc123\",\n    \"filename\": \"document.pdf\",\n    \"quote\": \"This is the relevant quote\",\n    \"start_index\": 100,\n    \"end_index\": 150\n  }\n}\n```\n\n### Error Events\n\n#### `error`\n\nEmitted when an error occurs.\n\n```json\n{\n  \"type\": \"error\",\n  \"code\": \"rate_limit_exceeded\",\n  \"message\": \"Rate limit exceeded. Please try again later.\",\n  \"param\": null,\n  \"sequence_number\": 5\n}\n```\n\n## Built-in Tool Call Schemas\n\n### Web Search Call\n\n```json\n{\n  \"type\": \"web_search_call\",\n  \"id\": \"search_abc123\",\n  \"status\": \"completed\",\n  \"action\": {\n    \"type\": \"search\",\n    \"query\": \"OpenAI API documentation\"\n  }\n}\n```\n\n### File Search Call\n\n```json\n{\n  \"type\": \"file_search_call\",\n  \"id\": \"search_abc123\",\n  \"queries\": [\"OpenAI pricing\", \"API limits\"],\n  \"results\": [\n    {\n      \"attributes\": {},\n      \"file_id\": \"file_abc123\",\n      \"filename\": \"pricing.pdf\",\n      \"score\": 0.85,\n      \"text\": \"OpenAI API pricing starts at...\"\n    }\n  ]\n}\n```\n\n### Code Interpreter Call\n\n```json\n{\n  \"type\": \"code_interpreter_call\",\n  \"id\": \"code_abc123\",\n  \"code\": \"print('Hello, world!')\",\n  \"container_id\": \"container_123\",\n  \"outputs\": [\n    {\n      \"type\": \"logs\",\n      \"logs\": \"Hello, world!\\n\"\n    }\n  ]\n}\n```\n\n### Image Generation Call\n\n```json\n{\n  \"type\": \"image_generation_call\",\n  \"id\": \"img_abc123\",\n  \"result\": \"https://example.com/generated-image.png\"\n}\n```\n\n### Computer Use Call\n\n```json\n{\n  \"type\": \"computer_call\",\n  \"id\": \"computer_abc123\",\n  \"status\": \"completed\"\n}\n```\n\n## Event Processing Flow\n\n1. **Response Start**: `response.created` → Initialize response tracking\n2. **Content Blocks**: `response.output_item.added` → Start tracking content block\n3. **Streaming Content**:\n   - `response.output_text.delta` → Accumulate text\n   - `response.function_call_arguments.delta` → Accumulate tool arguments\n   - `response.reasoning_summary_text.delta` → Accumulate reasoning\n4. **Content Complete**: `response.output_item.done` → Finalize content block\n5. **Response End**: `response.completed`/`response.incomplete` → Finalize response\n\n## Key Differences from Anthropic\n\n| Aspect         | OpenAI Responses API                     | Anthropic Messages API                           |\n| -------------- | ---------------------------------------- | ------------------------------------------------ |\n| Text streaming | `response.output_text.delta`             | `content_block_delta` (type: `text_delta`)       |\n| Tool arguments | `response.function_call_arguments.delta` | `content_block_delta` (type: `input_json_delta`) |\n| Reasoning      | `response.reasoning_summary_text.delta`  | `content_block_delta` (type: `thinking_delta`)   |\n| Block tracking | `output_index`                           | `index`                                          |\n| Response start | `response.created`                       | `message_start`                                  |\n| Response end   | `response.completed`                     | `message_stop`                                   |\n\n## Error Handling\n\n- Parse each SSE event with proper JSON validation\n- Handle unknown event types gracefully (forward as-is or ignore)\n- Track `sequence_number` for error events to maintain order\n- Use `output_index` to correlate events with specific content blocks\n- Handle partial JSON in tool argument deltas (accumulate until complete)\n\n## Implementation Notes\n\n- Events may arrive out of order; use `output_index` and `item_id` for correlation\n- Multiple reasoning summary parts can exist; track by `summary_index`\n- Tool calls can be provider-executed (built-in tools) or require client execution\n- Logprobs are optional and only included when requested\n- Usage tokens are only available in completion events\n"
  },
  {
    "path": "aiprompts/tailwind-container-queries.md",
    "content": "### Tailwind v4 Container Queries (Quick Overview)\n\n- **Viewport breakpoints**: `sm:`, `md:`, `lg:`, etc. → respond to **screen size**.\n- **Container queries**: `@sm:`, `@md:`, etc. → respond to **parent element size**.\n\n#### Enable\n\nNo plugin needed in **v4** (built-in).\nIn v3: install `@tailwindcss/container-queries`.\n\n#### Usage\n\n```html\n<aside class=\"@container w-64 bg-gray-100\">\n  <div class=\"w-32 @sm:w-48 @md:w-64 bg-blue-500\">Content</div>\n</aside>\n```\n\n- `@container` marks the parent.\n- `@sm:` / `@md:` refer to **container width**, not viewport.\n\n#### Max-Width Container Queries\n\nFor max-width queries, use `@max-` prefix:\n\n```html\n<div class=\"@container\">\n  <!-- Shows on small containers, hides on large -->\n  <div class=\"block @max-sm:hidden\">Only on containers < sm</div>\n  \n  <!-- Custom breakpoint -->\n  <div class=\"@max-w600:fixed @max-w600:bg-background\">\n    Fixed overlay on small, normal on large\n  </div>\n</div>\n```\n\n- `@max-sm:` = max-width query (container **below** sm breakpoint)\n- `@sm:` = min-width query (container **at or above** sm breakpoint)\n\n**IMPORTANT**: The syntax is `@max-w600:` NOT `max-@w600:` (prefix comes before the @)\n\n#### Notes\n\n- Based on native CSS container queries (well supported in modern browsers).\n- Breakpoints for container queries reuse Tailwind’s `sm`, `md`, `lg`, etc. scales.\n- Safe for modern webapps; no IE/legacy support.\n\nWe have special breakpoints set up for panels:\n\n    --container-w600: 600px;\n    --container-w450: 450px;\n    --container-xs: 300px;\n    --container-xxs: 200px;\n    --container-tiny: 120px;\n\nsince often sm, md, and lg are too big for panels.\n\nUsage examples:\n\n```html\n<!-- Min-width (container >= 600px) -->\n<div class=\"@w600:block @w600:h-full\">\n\n<!-- Max-width (container < 600px) -->\n<div class=\"@max-w600:hidden @max-w600:fixed\">\n\n<!-- Smaller breakpoints -->\n<div class=\"@xs:ml-4 @max-xxs:p-2\">\n```\n"
  },
  {
    "path": "aiprompts/tsunami-builder.md",
    "content": "# Tsunami AI Builder - V1 Architecture\n\n## Overview\n\nA 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.\n\n## UI Layout\n\n### Left Panel\n\n- **💬 Chat** - Conversation with AI\n\n### Right Panel\n\n**Top Section - Tabs:**\n- **👁️ Preview** (default) - Live preview of running Tsunami app, updates automatically after successful compilation\n- **📝 Code** - Monaco editor for manual edits to app.go\n- **📁 Files** - Static assets browser (images, etc)\n\n**Bottom Section - Build Panel (closable):**\n- Shows compilation status and output (like VSCode's terminal panel)\n- Displays success messages or errors with line numbers\n- Auto-runs after AI edits\n- For manual Code tab edits: auto-reruns or user clicks build button\n- Can be manually closed/reopened by user\n\n### Top Bar\n\n- Current AppTitle (extracted from app.go)\n- **Publish** button - Moves draft → published version\n- **Revert** button - Copies published → draft (discards draft changes)\n\n## Version Management\n\n**Draft mode**: Auto-saved on every edit, persists when builder closes\n**Published version**: What runs in main Wave Terminal, only updates on explicit \"Publish\"\n\nFlow:\n\n1. Edit in builder (always editing draft)\n2. Click \"Publish\" when ready (copies draft → published)\n3. Continue editing draft OR click \"Revert\" to abandon changes\n\n## Context Structure\n\nEvery AI request includes:\n\n```\n[System Instructions]\n  - General system prompt\n  - Full system.md (Tsunami framework guide)\n\n[Conversation History]\n  - Recent messages (with prompt caching)\n\n[Current Context] (injected fresh each turn, removed from previous turns)\n  - Current app.go content\n  - Compilation results (success or errors with line numbers)\n  - Static files listing (e.g., \"/static/logo.png\")\n```\n\n**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.\n\n## AI Tools\n\n### edit_appgo (str_replace)\n\n**Primary editing tool**\n\n- `old_str` - Unique string to find in app.go\n- `new_str` - Replacement string\n- `description` - What this change does\n\n**Backend behavior**:\n\n1. Apply string replacement to app.go\n2. Immediately run `go build`\n3. Return tool result:\n   - ✓ Success: \"Edit applied, compilation successful\"\n   - ✗ Failure: \"Edit applied, compilation failed: [error details]\"\n\nAI can make multiple edits in one response, getting compile feedback after each.\n\n### create_appgo\n\n**Bootstrap new apps**\n\n- `content` - Full app.go file content\n- Only used for initial app creation or total rewrites\n\nSame compilation behavior as str_replace.\n\n### web_search\n\n**Look up APIs, docs, examples**\n\n- Implemented via provider backend (OpenAI/Anthropic)\n- AI can research before making edits\n\n### read_file\n\n**Read user-provided documentation**\n\n- `path` - Path to file (e.g., \"/docs/api-spec.md\")\n- User can upload docs/examples for AI to reference\n\n## User Actions (Not AI Tools)\n\n### Manage Static Assets\n\n- Upload via drag & drop into Files tab or file picker\n- Delete files from Files tab\n- Rename files from Files tab\n- Appear in `/static/` directory\n- Auto-injected into AI context as available files\n\n### Share Screenshot\n\n- User clicks \"📷 Share preview with AI\" button\n- Captures current preview state\n- Attaches to user's next message\n- Useful for debugging layout/visual issues\n\n### Manual Code Editing\n\n- User can switch to Code tab\n- Edit app.go directly in Monaco editor\n- Changes auto-compile\n- AI sees manual edits in next chat turn\n\n## Compilation Pipeline\n\nAfter every code change (AI or user):\n\n```\n1. Write app.go to disk\n2. Run: go build app.go\n3. Show build output in build panel\n4. If success:\n   - Start/restart app process\n   - Update preview iframe\n   - Show success message in build panel\n5. If failure:\n   - Parse error output (line numbers, messages)\n   - Show error in build panel (bottom of right side)\n   - Inject into AI context for next turn\n```\n\n**Auto-retry**: AI can fix its own compilation errors within the same response (up to 3 attempts).\n\n## Error Handling\n\n### Compilation Errors\n\nShown in build panel at bottom of right side.\n\nFormat for AI:\n\n```\nCOMPILATION FAILED\n\nError at line 45:\n  43 | func(props TodoProps) any {\n  44 |     return vdom.H(\"div\", nil\n> 45 |         vdom.H(\"span\", nil, \"test\")\n     |         ^ missing closing parenthesis\n  46 |     )\n\nMessage: expected ')', found 'vdom'\n```\n\n### Runtime Errors\n\n- Shown in preview tab (not errors panel)\n- User can screenshot and report to AI\n- Not auto-injected (v1 simplification)\n\n### Linting (Future)\n\n- Could add custom Tsunami-specific linting\n- Would inject warnings alongside compile results\n- Not required for v1\n\n## Secrets/Configuration\n\nApps can declare secrets using Tsunami's ConfigAtom:\n\n```go\nvar apiKeyAtom = app.ConfigAtom(\"api_key\", \"\", &app.AtomMeta{\n    Desc: \"OpenAI API Key\",\n    Secret: true,\n})\n```\n\nBuilder detects these and shows input fields in UI for user to fill in.\n\n## Conversation Limits\n\n**V1 approach**: No summarization, no smart handling.\n\nWhen context limit hit: Show message \"You've hit the conversation limit. Click 'Start Fresh' to continue editing this app in a new chat.\"\n\nStarting fresh uses current app.go as the beginning state.\n\n## Token Optimization\n\n- System.md + early messages benefit from prompt caching\n- Only pay per-turn for: current app.go + new messages\n- Old context blocks removed to prevent bloat\n- Estimated: 10-20k tokens per turn (very manageable)\n\n## Example Flow\n\n```\nUser: \"Create a counter app\"\nAI: [calls create_appgo with full counter app]\nBackend: ✓ Compiled successfully\nPreview: Shows counter app\n\nUser: \"Add a reset button\"\nAI: [calls str_replace to add reset button]\nBackend: ✓ Compiled successfully\nPreview: Updates with reset button\n\nUser: \"Make buttons bigger\"\nAI: [calls str_replace to update button classes]\nBackend: ✓ Compiled successfully\nPreview: Updates with larger buttons\n\nUser: [switches to Code tab, tweaks color manually]\nBackend: ✓ Compiled successfully\nPreview: Updates\n\nUser: \"Add a chart showing count over time\"\nAI: [calls web_search for \"go charting library\"]\nAI: [calls str_replace to add chart]\nBackend: ✗ Compilation failed - missing import\nAI: [calls str_replace to add import]\nBackend: ✓ Compiled successfully\nPreview: Shows chart\n```\n\n## Out of Scope (V1)\n\n- Version history / snapshots\n- Multiple files / project structure\n- Collaboration / sharing\n- Advanced linting\n- Runtime error auto-injection\n- Conversation summarization\n- Component-specific editing tools\n\nThese can be added in v2+ based on user feedback.\n\n## Success Criteria\n\n- User can create functional Tsunami app through chat in <5 minutes\n- AI successfully fixes its own compilation errors 80%+ of the time\n- Iteration cycle (message → edit → preview) takes <10 seconds\n- Users can publish working apps to Wave Terminal\n- Draft state persists across sessions\n"
  },
  {
    "path": "aiprompts/usechat-backend-design.md",
    "content": "# useChat Compatible Backend Design for Wave Terminal\n\n## Overview\n\nThis 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.\n\n## Current vs Target Architecture\n\n### Current Architecture\n```\nFrontend (React) → Custom RPC → Go Backend → AI Providers\n- 10+ Jotai atoms for state management\n- Custom WaveAIStreamRequest/WaveAIPacketType\n- Complex configuration merging in frontend\n- Custom streaming protocol over WebSocket\n```\n\n### Target Architecture\n```\nFrontend (useChat) → HTTP/SSE → Go Backend → AI Providers\n- Single useChat() hook manages all state\n- Standard HTTP POST + SSE streaming\n- Backend-driven configuration resolution\n- Standard AI SDK streaming format\n```\n\n## API Design\n\n### 1. Endpoint Structure\n\n**Chat Streaming Endpoint:**\n```\nPOST /api/ai/chat/{blockId}?preset={presetKey}\n```\n\n**Conversation Persistence Endpoints:**\n```\nPOST /api/ai/conversations/{blockId}     # Save conversation\nGET  /api/ai/conversations/{blockId}     # Load conversation\n```\n\n**Why this approach:**\n- `blockId`: Identifies the conversation context (existing Wave concept)\n- `preset`: URL parameter for AI configuration preset\n- **Separate persistence**: Clean separation of streaming vs storage\n- **Fast localhost calls**: Frontend can call both endpoints quickly\n- **Simple backend**: Each endpoint has single responsibility\n\n### 2. Request Format & Message Flow\n\n**Simplified Approach:**\n- Frontend manages **entire conversation state** (like all modern chat apps)\n- Frontend sends **complete message history** with each request\n- Backend just processes the messages and streams response\n- Frontend handles persistence via existing Wave file system\n\n**Standard useChat() Request:**\n```json\n{\n  \"messages\": [\n    {\n      \"id\": \"msg-1\",\n      \"role\": \"user\",\n      \"content\": \"Hello world\"\n    },\n    {\n      \"id\": \"msg-2\",\n      \"role\": \"assistant\",\n      \"content\": \"Hi there!\"\n    },\n    {\n      \"id\": \"msg-3\",\n      \"role\": \"user\",\n      \"content\": \"How are you?\"  // <- NEW message user just typed\n    }\n  ]\n}\n```\n\n**Backend Processing:**\n1. **Receive complete conversation** from frontend\n2. **Resolve AI configuration** (preset, model, etc.)\n3. **Send messages directly** to AI provider\n4. **Stream response** back to frontend\n5. **Frontend calls separate persistence endpoint** when needed\n\n**Optional Extensions:**\n```json\n{\n  \"messages\": [...],\n  \"options\": {\n    \"temperature\": 0.7,\n    \"maxTokens\": 1000,\n    \"model\": \"gpt-4\"  // Override preset model\n  }\n}\n```\n\n### 3. Configuration Resolution\n\n**Priority Order (backend resolves):**\n1. **Request options** (highest priority)\n2. **URL preset parameter** \n3. **Block metadata** (`block.meta[\"ai:preset\"]`)\n4. **Global settings** (`settings[\"ai:preset\"]`)\n5. **Default preset** (lowest priority)\n\n**Backend Logic:**\n```go\nfunc resolveAIConfig(blockId, presetKey string, requestOptions map[string]any) (*WaveAIOptsType, error) {\n    // 1. Load block metadata\n    block := getBlock(blockId)\n    blockPreset := block.Meta[\"ai:preset\"]\n    \n    // 2. Load global settings\n    settings := getGlobalSettings()\n    globalPreset := settings[\"ai:preset\"]\n    \n    // 3. Resolve preset hierarchy\n    finalPreset := presetKey\n    if finalPreset == \"\" {\n        finalPreset = blockPreset\n    }\n    if finalPreset == \"\" {\n        finalPreset = globalPreset\n    }\n    if finalPreset == \"\" {\n        finalPreset = \"default\"\n    }\n    \n    // 4. Load and merge preset config\n    presetConfig := loadPreset(finalPreset)\n    \n    // 5. Apply request overrides\n    return mergeAIConfig(presetConfig, requestOptions), nil\n}\n```\n\n### 4. Response Format (SSE)\n\n**Key Insight: Minimal Conversion**\nMost 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.\n\n**Headers:**\n```\nContent-Type: text/event-stream\nCache-Control: no-cache\nConnection: keep-alive\nAccess-Control-Allow-Origin: *\n```\n\n**useChat Expected Format:**\n```\ndata: {\"type\":\"text\",\"text\":\"Hello\"}\n\ndata: {\"type\":\"text\",\"text\":\" world\"}\n\ndata: {\"type\":\"text\",\"text\":\"!\"}\n\ndata: {\"type\":\"finish\",\"finish_reason\":\"stop\",\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"total_tokens\":13}}\n\ndata: [DONE]\n```\n\n**Provider Stream Transformation:**\n- **OpenAI**: Already SSE → direct proxy (no conversion needed)\n- **Anthropic**: Already SSE → direct proxy (minimal field mapping)\n- **Google**: Already streaming → direct proxy\n- **Perplexity**: OpenAI-compatible → direct proxy\n- **Wave Cloud**: WebSocket → **requires conversion** (only one needing transformation)\n\n**Error Format:**\n```\ndata: {\"type\":\"error\",\"error\":\"API key invalid\"}\n\ndata: [DONE]\n```\n\n## Implementation Plan\n\n### Phase 1: HTTP Handler\n\n```go\n// Simplified approach: Direct provider streaming with minimal transformation\nfunc (s *WshServer) HandleAIChat(w http.ResponseWriter, r *http.Request) {\n    // 1. Parse URL parameters\n    blockId := mux.Vars(r)[\"blockId\"]\n    presetKey := r.URL.Query().Get(\"preset\")\n    \n    // 2. Parse request body\n    var req struct {\n        Messages []struct {\n            Role    string `json:\"role\"`\n            Content string `json:\"content\"`\n        } `json:\"messages\"`\n        Options map[string]any `json:\"options,omitempty\"`\n    }\n    json.NewDecoder(r.Body).Decode(&req)\n    \n    // 3. Resolve configuration\n    aiOpts, err := resolveAIConfig(blockId, presetKey, req.Options)\n    if err != nil {\n        http.Error(w, err.Error(), 400)\n        return\n    }\n    \n    // 4. Set SSE headers\n    w.Header().Set(\"Content-Type\", \"text/event-stream\")\n    w.Header().Set(\"Cache-Control\", \"no-cache\")\n    w.Header().Set(\"Connection\", \"keep-alive\")\n    \n    // 5. Route to provider and stream directly\n    switch aiOpts.APIType {\n    case \"openai\", \"perplexity\":\n        // Direct proxy - these are already SSE compatible\n        streamDirectSSE(w, r.Context(), aiOpts, req.Messages)\n    case \"anthropic\":\n        // Direct proxy with minimal field mapping\n        streamAnthropicSSE(w, r.Context(), aiOpts, req.Messages)\n    case \"google\":\n        // Direct proxy\n        streamGoogleSSE(w, r.Context(), aiOpts, req.Messages)\n    default:\n        // Wave Cloud - only one requiring conversion (WebSocket → SSE)\n        if isCloudAIRequest(aiOpts) {\n            streamWaveCloudToUseChat(w, r.Context(), aiOpts, req.Messages)\n        } else {\n            http.Error(w, \"Unsupported provider\", 400)\n        }\n    }\n}\n\n// Example: Direct OpenAI streaming (minimal conversion)\nfunc streamOpenAIToUseChat(w http.ResponseWriter, ctx context.Context, opts *WaveAIOptsType, messages []Message) {\n    client := openai.NewClient(opts.APIToken)\n    \n    stream, err := client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{\n        Model:    opts.Model,\n        Messages: convertToOpenAIMessages(messages),\n        Stream:   true,\n    })\n    if err != nil {\n        fmt.Fprintf(w, \"data: {\\\"type\\\":\\\"error\\\",\\\"error\\\":%q}\\n\\n\", err.Error())\n        fmt.Fprintf(w, \"data: [DONE]\\n\\n\")\n        return\n    }\n    defer stream.Close()\n    \n    for {\n        response, err := stream.Recv()\n        if errors.Is(err, io.EOF) {\n            fmt.Fprintf(w, \"data: [DONE]\\n\\n\")\n            return\n        }\n        if err != nil {\n            fmt.Fprintf(w, \"data: {\\\"type\\\":\\\"error\\\",\\\"error\\\":%q}\\n\\n\", err.Error())\n            fmt.Fprintf(w, \"data: [DONE]\\n\\n\")\n            return\n        }\n        \n        // Direct transformation: OpenAI format → useChat format\n        for _, choice := range response.Choices {\n            if choice.Delta.Content != \"\" {\n                fmt.Fprintf(w, \"data: {\\\"type\\\":\\\"text\\\",\\\"text\\\":%q}\\n\\n\", choice.Delta.Content)\n            }\n            if choice.FinishReason != \"\" {\n                fmt.Fprintf(w, \"data: {\\\"type\\\":\\\"finish\\\",\\\"finish_reason\\\":%q}\\n\\n\", choice.FinishReason)\n            }\n        }\n        \n        w.(http.Flusher).Flush()\n    }\n}\n\n// Wave Cloud conversion (only provider needing transformation)\nfunc streamWaveCloudToUseChat(w http.ResponseWriter, ctx context.Context, opts *WaveAIOptsType, messages []Message) {\n    // Use existing Wave Cloud WebSocket logic\n    waveReq := wshrpc.WaveAIStreamRequest{\n        Opts:   opts,\n        Prompt: convertMessagesToPrompt(messages),\n    }\n    \n    stream := waveai.RunAICommand(ctx, waveReq) // Returns WebSocket stream\n    \n    // Convert Wave Cloud packets to useChat SSE format\n    for packet := range stream {\n        if packet.Error != nil {\n            fmt.Fprintf(w, \"data: {\\\"type\\\":\\\"error\\\",\\\"error\\\":%q}\\n\\n\", packet.Error.Error())\n            break\n        }\n        \n        resp := packet.Response\n        if resp.Text != \"\" {\n            fmt.Fprintf(w, \"data: {\\\"type\\\":\\\"text\\\",\\\"text\\\":%q}\\n\\n\", resp.Text)\n        }\n        if resp.FinishReason != \"\" {\n            usage := \"\"\n            if resp.Usage != nil {\n                usage = fmt.Sprintf(\",\\\"usage\\\":{\\\"prompt_tokens\\\":%d,\\\"completion_tokens\\\":%d,\\\"total_tokens\\\":%d}\",\n                    resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens)\n            }\n            fmt.Fprintf(w, \"data: {\\\"type\\\":\\\"finish\\\",\\\"finish_reason\\\":%q%s}\\n\\n\", resp.FinishReason, usage)\n        }\n        \n        w.(http.Flusher).Flush()\n    }\n    \n    fmt.Fprintf(w, \"data: [DONE]\\n\\n\")\n}\n```\n\n### Phase 2: Frontend Integration\n\n```typescript\nimport { useChat } from '@ai-sdk/react';\n\nfunction WaveAI({ blockId }: { blockId: string }) {\n    // Get current preset from block metadata or settings\n    const preset = useAtomValue(currentPresetAtom);\n    \n    const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({\n        api: `/api/ai/chat/${blockId}?preset=${preset}`,\n        initialMessages: [], // Load from existing aidata file\n        onFinish: (message) => {\n            // Save conversation to aidata file\n            saveConversation(blockId, messages);\n        }\n    });\n    \n    return (\n        <div className=\"flex flex-col h-full\">\n            <div className=\"flex-1 overflow-y-auto\">\n                {messages.map(message => (\n                    <div key={message.id} className={`message ${message.role}`}>\n                        <Markdown text={message.content} />\n                    </div>\n                ))}\n                {isLoading && <TypingIndicator />}\n                {error && <div className=\"error\">{error.message}</div>}\n            </div>\n            \n            <form onSubmit={handleSubmit} className=\"border-t p-4\">\n                <input\n                    value={input}\n                    onChange={handleInputChange}\n                    placeholder=\"Type a message...\"\n                    className=\"w-full p-2 border rounded\"\n                />\n            </form>\n        </div>\n    );\n}\n```\n\n### Phase 3: Advanced Features\n\n#### Multi-modal Support\n```typescript\n// useChat supports multi-modal out of the box\nconst { messages, append } = useChat({\n    api: `/api/ai/chat/${blockId}`,\n});\n\n// Send image + text\nawait append({\n    role: 'user',\n    content: [\n        { type: 'text', text: 'What do you see in this image?' },\n        { type: 'image', image: imageFile }\n    ]\n});\n```\n\n#### Thinking Models\n```go\n// Backend detects thinking models and formats appropriately\nif isThinkingModel(aiOpts.Model) {\n    // Send thinking content separately\n    fmt.Fprintf(w, \"data: {\\\"type\\\":\\\"thinking\\\",\\\"text\\\":%q}\\n\\n\", thinkingText)\n    fmt.Fprintf(w, \"data: {\\\"type\\\":\\\"text\\\",\\\"text\\\":%q}\\n\\n\", responseText)\n}\n```\n\n#### Context Injection\n```typescript\n// Add system messages or context via useChat options\nconst { messages, append } = useChat({\n    api: `/api/ai/chat/${blockId}`,\n    initialMessages: [\n        {\n            role: 'system',\n            content: 'You are a helpful terminal assistant...'\n        }\n    ]\n});\n```\n\n## Migration Strategy\n\n### 1. Parallel Implementation\n- Keep existing RPC system running\n- Add new HTTP/SSE endpoint alongside\n- Feature flag to switch between systems\n\n### 2. Gradual Migration\n- Start with new blocks using useChat\n- Migrate existing conversations on first interaction\n- Remove RPC system once stable\n\n### 3. Backward Compatibility\n- Existing aidata files work unchanged\n- Same provider backends (OpenAI, Anthropic, etc.)\n- Same configuration system\n\n## Benefits\n\n### Complexity Reduction\n- **Frontend**: ~900 lines → ~100 lines (90% reduction)\n- **State Management**: 10+ atoms → 1 useChat hook\n- **Configuration**: Frontend merging → Backend resolution\n- **Streaming**: Custom protocol → Standard SSE\n\n### Modern Features\n- **Multi-modal**: Images, files, audio support\n- **Thinking Models**: Built-in reasoning trace support\n- **Conversation Management**: Edit, retry, branch conversations\n- **Error Handling**: Automatic retry and error boundaries\n- **Performance**: Optimized streaming and batching\n\n### Developer Experience\n- **Type Safety**: Full TypeScript support\n- **Testing**: Standard HTTP endpoints easier to test\n- **Debugging**: Standard browser dev tools work\n- **Documentation**: Leverage AI SDK docs and community\n\n## Configuration Examples\n\n### URL-based Configuration\n```\nPOST /api/ai/chat/block-123?preset=claude-coding\nPOST /api/ai/chat/block-456?preset=gpt4-creative\n```\n\n### Header-based Overrides\n```\nPOST /api/ai/chat/block-123\nX-AI-Model: gpt-4-turbo\nX-AI-Temperature: 0.8\n```\n\n### Request Body Options\n```json\n{\n  \"messages\": [...],\n  \"options\": {\n    \"model\": \"claude-3-sonnet\",\n    \"temperature\": 0.7,\n    \"maxTokens\": 2000\n  }\n}\n```\n\nThis design maintains all existing functionality while dramatically simplifying the implementation and adding modern AI chat capabilities."
  },
  {
    "path": "aiprompts/view-prompt.md",
    "content": "# Wave Terminal ViewModel Guide\n\n## Overview\n\nWave 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.\n\n### Key Concepts\n\n1. **ViewModel Structure**\n   - Implements the `ViewModel` interface.\n   - Defines:\n     - `viewType`: Unique block type identifier.\n     - `viewIcon`, `viewName`, `viewText`: Atoms for UI metadata.\n     - `preIconButton`, `endIconButtons`: Atoms for action buttons.\n     - `blockBg`: Atom for background styling.\n     - `manageConnection`, `noPadding`, `searchAtoms`.\n     - `viewComponent`: React component rendering the block.\n     - Lifecycle methods like `dispose()`, `giveFocus()`, `keyDownHandler()`.\n\n2. **ViewComponent Structure**\n   - A **React function component** implementing `ViewComponentProps<T extends ViewModel>`.\n   - Uses `blockId`, `blockRef`, `contentRef`, and `model` as props.\n   - Retrieves ViewModel state using Jotai atoms.\n   - Returns JSX for rendering.\n\n3. **Header Elements (`HeaderElem[]`)**\n   - Can include:\n     - **Icons (`IconButtonDecl`)**: Clickable buttons.\n     - **Text (`HeaderText`)**: Metadata or status.\n     - **Inputs (`HeaderInput`)**: Editable fields.\n     - **Menu Buttons (`MenuButton`)**: Dropdowns.\n\n4. **Jotai Atoms for State Management**\n   - Use `atom<T>`, `PrimitiveAtom<T>`, `WritableAtom<T>` for dynamic properties.\n   - `splitAtom` for managing lists of atoms.\n   - Read settings from `globalStore` and override with block metadata.\n\n5. **Metadata vs. Global Config**\n   - **Block Metadata (`SetMetaCommand`)**: Each block persists its **own configuration** in its metadata (`blockAtom.meta`).\n   - **Global Config (`SetConfigCommand`)**: Provides **default settings** for all blocks, stored in config files.\n   - **Cascading Behavior**:\n     - Blocks first check their **own metadata** for settings.\n     - If no override exists, they **fall back** to global config.\n     - Updating a block's setting is done via `SetMetaCommand` (persisted per block).\n     - Updating a global setting is done via `SetConfigCommand` (applies globally unless overridden).\n\n6. **Useful Helper Functions**\n   - To avoid repetitive boilerplate, use these global utilities from `global.ts`:\n     - `useBlockMetaKeyAtom(blockId, key)`: Retrieves and updates block-specific metadata.\n     - `useOverrideConfigAtom(blockId, key)`: Reads from global config but allows per-block overrides.\n     - `useSettingsKeyAtom(key)`: Accesses global settings efficiently.\n\n7. **Styling**\n   - Use TailWind CSS to style components\n   - Accent color is: text-accent, for a 50% transparent accent background use bg-accentbg\n   - Hover background is: bg-hoverbg\n   - Border color is \"border\", so use border-border\n   - Colors are also defined for error, warning, and success (text-error, text-warning, text-sucess)\n\n## Relevant TypeScript Types\n\n```typescript\ntype ViewComponentProps<T extends ViewModel> = {\n  blockId: string;\n  blockRef: React.RefObject<HTMLDivElement>;\n  contentRef: React.RefObject<HTMLDivElement>;\n  model: T;\n};\n\ntype ViewComponent = React.FC<ViewComponentProps<any>>;\n\ninterface ViewModel {\n  viewType: string;\n  viewIcon?: jotai.Atom<string | IconButtonDecl>;\n  viewName?: jotai.Atom<string>;\n  viewText?: jotai.Atom<string | HeaderElem[]>;\n  preIconButton?: jotai.Atom<IconButtonDecl>;\n  endIconButtons?: jotai.Atom<IconButtonDecl[]>;\n  blockBg?: jotai.Atom<MetaType>;\n  manageConnection?: jotai.Atom<boolean>;\n  noPadding?: jotai.Atom<boolean>;\n  searchAtoms?: SearchAtoms;\n  viewComponent: ViewComponent;\n  dispose?: () => void;\n  giveFocus?: () => boolean;\n  keyDownHandler?: (e: WaveKeyboardEvent) => boolean;\n}\n\ninterface IconButtonDecl {\n  elemtype: \"iconbutton\";\n  icon: string | React.ReactNode;\n  click?: (e: React.MouseEvent<any>) => void;\n}\ntype HeaderElem =\n  | IconButtonDecl\n  | ToggleIconButtonDecl\n  | HeaderText\n  | HeaderInput\n  | HeaderDiv\n  | HeaderTextButton\n  | ConnectionButton\n  | MenuButton;\n\ntype IconButtonCommon = {\n  icon: string | React.ReactNode;\n  iconColor?: string;\n  iconSpin?: boolean;\n  className?: string;\n  title?: string;\n  disabled?: boolean;\n  noAction?: boolean;\n};\n\ntype IconButtonDecl = IconButtonCommon & {\n  elemtype: \"iconbutton\";\n  click?: (e: React.MouseEvent<any>) => void;\n  longClick?: (e: React.MouseEvent<any>) => void;\n};\n\ntype ToggleIconButtonDecl = IconButtonCommon & {\n  elemtype: \"toggleiconbutton\";\n  active: jotai.WritableAtom<boolean, [boolean], void>;\n};\n\ntype HeaderTextButton = {\n  elemtype: \"textbutton\";\n  text: string;\n  className?: string;\n  title?: string;\n  onClick?: (e: React.MouseEvent<any>) => void;\n};\n\ntype HeaderText = {\n  elemtype: \"text\";\n  text: string;\n  ref?: React.RefObject<HTMLDivElement>;\n  className?: string;\n  noGrow?: boolean;\n  onClick?: (e: React.MouseEvent<any>) => void;\n};\n\ntype HeaderInput = {\n  elemtype: \"input\";\n  value: string;\n  className?: string;\n  isDisabled?: boolean;\n  ref?: React.RefObject<HTMLInputElement>;\n  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;\n  onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;\n  onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;\n  onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;\n};\n\ntype HeaderDiv = {\n  elemtype: \"div\";\n  className?: string;\n  children: HeaderElem[];\n  onMouseOver?: (e: React.MouseEvent<any>) => void;\n  onMouseOut?: (e: React.MouseEvent<any>) => void;\n  onClick?: (e: React.MouseEvent<any>) => void;\n};\n\ntype ConnectionButton = {\n  elemtype: \"connectionbutton\";\n  icon: string;\n  text: string;\n  iconColor: string;\n  onClick?: (e: React.MouseEvent<any>) => void;\n  connected: boolean;\n};\n\ntype MenuItem = {\n  label: string;\n  icon?: string | React.ReactNode;\n  subItems?: MenuItem[];\n  onClick?: (e: React.MouseEvent<any>) => void;\n};\n\ntype MenuButtonProps = {\n  items: MenuItem[];\n  className?: string;\n  text: string;\n  title?: string;\n  menuPlacement?: Placement;\n};\n\ntype MenuButton = {\n  elemtype: \"menubutton\";\n} & MenuButtonProps;\n```\n\n## Minimal \"Hello World\" Example\n\nThis example defines a simple ViewModel and ViewComponent for a block that displays \"Hello, World!\".\n\n```typescript\nimport * as jotai from \"jotai\";\nimport React from \"react\";\n\nclass HelloWorldModel implements ViewModel {\n    viewType = \"helloworld\";\n    viewIcon = jotai.atom(\"smile\");\n    viewName = jotai.atom(\"Hello World\");\n    viewText = jotai.atom(\"A simple greeting block\");\n    viewComponent = HelloWorldView;\n}\n\nconst HelloWorldView: ViewComponent<HelloWorldModel> = ({ model }) => {\n    return <div style={{ padding: \"10px\" }}>Hello, World!</div>;\n};\n\nexport { HelloWorldModel };\n\n```\n\n## Instructions to AI\n\n1. Generate a new **ViewModel** class for a block, following the structure above.\n2. Generate a corresponding **ViewComponent**.\n3. Use **Jotai atoms** to store all dynamic state.\n4. Ensure the ViewModel defines **header elements** (`viewText`, `viewIcon`, `endIconButtons`).\n5. Export the view model (to be registered in the BlockRegistry)\n6. Use existing metadata patterns for config and settings.\n\n## Other Notes\n\n- The types you see above don't need to be imported, they are global types (custom.d.ts)\n\n**Output Format:**\n\n- TypeScript code defining the **ViewModel**.\n- TypeScript code defining the **ViewComponent**.\n- Ensure alignment with the patterns in `waveai.tsx`, `preview.tsx`, `sysinfo.tsx`, and `term.tsx`.\n"
  },
  {
    "path": "aiprompts/wave-osc-16162.md",
    "content": "# Wave Terminal OSC 16162 Escape Sequences\n\nWave 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.\n\n## Format\n\nAll commands use this escape sequence format:\n\n```\nESC ] 16162 ; command [;<json-data>] BEL\n```\n\nWhere:\n- `ESC` = `\\033` (escape character)\n- `BEL` = `\\007` (bell character)\n- `command` = Single letter (A, C, M, D, I, or R)\n- `<json-data>` = Optional JSON payload (depends on command)\n\n## Commands\n\n### A - Prompt Start\n\nMarks the beginning of a new shell prompt.\n\n**Format:** `A`\n\n**When:** Sent in `precmd` hook (after previous command completes, before new prompt is displayed)\n\n**Purpose:** Signals to the terminal that a new prompt is being drawn. This helps Wave Terminal distinguish between prompt output and command output.\n\n**Example:**\n```bash\nprintf '\\033]16162;A\\007'\n```\n\n---\n\n### C - Command Execution\n\nSent immediately before a command is executed, optionally including the command text.\n\n**Format:** `C[;<json-data>]`\n\n**Data Type:**\n```typescript\n{\n  cmd64?: string;  // base64-encoded command text\n}\n```\n\n**When:** Sent in `preexec` hook (after user presses Enter, before command runs)\n\n**Purpose:** Notifies the terminal that a command is about to execute. The command text is base64-encoded to handle special characters safely.\n\n**Example:**\n```bash\ncmd64=$(printf '%s' \"ls -la\" | base64)\nprintf '\\033]16162;C;{\"cmd64\":\"%s\"}\\007' \"$cmd64\"\n```\n\n---\n\n### M - Metadata\n\nSends shell metadata information (typically only once at shell initialization).\n\n**Format:** `M;<json-data>`\n\n**Data Type:**\n```typescript\n{\n  shell?: string;        // Shell name (e.g., \"zsh\", \"bash\")\n  shellversion?: string; // Version string of the shell\n  uname?: string;        // Output of \"uname -smr\" (e.g., \"Darwin 23.0.0 arm64\")\n  integration?: boolean; // Whether shell integration is active (true) or disabled (false)\n}\n```\n\n**When:** Sent during first `precmd` hook (on shell startup)\n\n**Purpose:** Provides Wave Terminal with information about the shell environment and operating system.\n\n**Example:**\n```bash\nuname_info=$(uname -smr 2>/dev/null)\nprintf '\\033]16162;M;{\"shell\":\"zsh\",\"shellversion\":\"5.9\",\"uname\":\"%s\"}\\007' \"$uname_info\"\n```\n\n---\n\n### D - Done (Exit Status)\n\nReports the exit status of the previously executed command.\n\n**Format:** `D;<json-data>`\n\n**Data Type:**\n```typescript\n{\n  exitcode?: number;  // Exit status code of the previous command\n}\n```\n\n**When:** Sent in `precmd` hook (after command completes)\n\n**Purpose:** Communicates whether the previous command succeeded or failed, allowing Wave Terminal to display success/failure indicators.\n\n**Example:**\n```bash\n# After command exits with status 0\nprintf '\\033]16162;D;{\"exitcode\":0}\\007'\n\n# After command exits with status 1\nprintf '\\033]16162;D;{\"exitcode\":1}\\007'\n```\n\n---\n\n### I - Input Status\n\nReports the current state of the command line input buffer.\n\n**Format:** `I;<json-data>`\n\n**Data Type:**\n```typescript\n{\n  inputempty?: boolean;  // Whether the command line buffer is empty\n}\n```\n\n**When:** Sent during ZLE (Zsh Line Editor) hooks when buffer state changes\n- `zle-line-init` - When line editor is initialized\n- `zle-line-pre-redraw` - Before line is redrawn\n\n**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.\n\n**Example:**\n```bash\n# When buffer is empty\nI;{\"inputempty\":true}\n\n# When buffer has content\nI;{\"inputempty\":false}\n```\n\n### R - Reset Alternate Buffer\n\nResets the terminal if it's in alternate buffer mode.\n\n**Format:** `R`\n\n**When:** Can be sent at any time to ensure terminal is not stuck in alternate buffer mode\n\n**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.\n\n**Behavior:**\n- Checks if terminal is in alternate buffer mode (`terminal.buffer.active.type === \"alternate\"`)\n- If in alternate mode, sends `ESC [ ? 1049 l` to exit alternate buffer\n- If not in alternate mode, does nothing\n\n**Example:**\n```bash\nR\n```\n\n---\n\n## Typical Command Flow\n\nHere's the typical sequence during shell interaction:\n\n```\n1. Shell starts\n   → M;<json> (metadata - shell info)\n   \n2. First prompt appears\n   → A (prompt start)\n   \n3. User types command and presses Enter\n   → I;{\"inputempty\":false} (input no longer empty - sent as user types)\n   → C;{\"cmd64\":\"...\"} (command about to execute)\n   \n4. Command runs and completes\n   → D;{\"exitcode\":<status>} (exit status)\n   → I;{\"inputempty\":true} (input empty again)\n   → A (next prompt start)\n   \n5. Repeat from step 3...\n```\n\n## Implementation Notes\n\n- Shell integration is **disabled** when running inside tmux or screen (`TMUX`, `STY` environment variables, or `tmux*`/`screen*` TERM values)\n- Commands are base64-encoded in the C sequence to safely handle special characters, newlines, and control characters\n- The I (input empty) command is only sent when the state changes (not on every keystroke)\n- The M (metadata) command is only sent once during the first precmd\n- The D (exit status) command is skipped during the first precmd (no previous command to report)\n\n## Related Files\n\n- [`pkg/util/shellutil/shellintegration/zsh_zshrc.sh`](pkg/util/shellutil/shellintegration/zsh_zshrc.sh) - Zsh shell integration implementation\n- Similar integrations exist for bash and other shells\n\n## Standard OSC 7\n\nWave Terminal also uses the standard **OSC 7** sequence for reporting the current working directory:\n\n**Format:** `7;file://<hostname><encoded_path>`\n\nThis is sent:\n- During first precmd (after metadata)\n- In the `chpwd` hook (whenever directory changes)\n\nThe path is URL-encoded to safely handle special characters."
  },
  {
    "path": "aiprompts/waveai-architecture.md",
    "content": "# Wave AI Architecture Documentation\n\n## Overview\n\nWave 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.\n\n## Architecture Components\n\n### Frontend Architecture (`frontend/app/view/waveai/`)\n\n#### Core Components\n\n**1. WaveAiModel Class**\n- **Purpose**: Main view model implementing the `ViewModel` interface\n- **Responsibilities**:\n  - State management using Jotai atoms\n  - Configuration management (presets, AI options)\n  - Message handling and persistence\n  - RPC communication with backend\n  - UI state coordination\n\n**2. AiWshClient Class**\n- **Purpose**: Specialized WSH RPC client for AI operations\n- **Extends**: `WshClient`\n- **Responsibilities**:\n  - Handle incoming `aisendmessage` RPC calls\n  - Route messages to the model's `sendMessage` method\n\n**3. React Components**\n- **WaveAi**: Main container component\n- **ChatWindow**: Scrollable message display with auto-scroll behavior\n- **ChatItem**: Individual message renderer with role-based styling\n- **ChatInput**: Auto-resizing textarea with keyboard navigation\n\n#### State Management (Jotai Atoms)\n\n**Message State**:\n```typescript\nmessagesAtom: PrimitiveAtom<Array<ChatMessageType>>\nmessagesSplitAtom: SplitAtom<Array<ChatMessageType>>\nlatestMessageAtom: Atom<ChatMessageType>\naddMessageAtom: WritableAtom<unknown, [message: ChatMessageType], void>\nupdateLastMessageAtom: WritableAtom<unknown, [text: string, isUpdating: boolean], void>\nremoveLastMessageAtom: WritableAtom<unknown, [], void>\n```\n\n**Configuration State**:\n```typescript\npresetKey: Atom<string>           // Current AI preset selection\npresetMap: Atom<{[k: string]: MetaType}>  // Available AI presets\nmergedPresets: Atom<MetaType>     // Merged configuration hierarchy\naiOpts: Atom<WaveAIOptsType>      // Final AI options for requests\n```\n\n**UI State**:\n```typescript\nlocked: PrimitiveAtom<boolean>    // Prevents input during AI response\nviewIcon: Atom<string>            // Header icon\nviewName: Atom<string>            // Header title\nviewText: Atom<HeaderElem[]>      // Dynamic header elements\nendIconButtons: Atom<IconButtonDecl[]>  // Header action buttons\n```\n\n#### Configuration Hierarchy\n\nThe AI configuration follows a three-tier hierarchy (lowest to highest priority):\n1. **Global Settings**: `atoms.settingsAtom[\"ai:*\"]`\n2. **Preset Configuration**: `presets[presetKey][\"ai:*\"]`\n3. **Block Metadata**: `block.meta[\"ai:*\"]`\n\nConfiguration is merged using `mergeMeta()` utility, allowing fine-grained overrides at each level.\n\n#### Data Flow - Frontend\n\n```\nUser Input → sendMessage() → \n├── Add user message to UI\n├── Create WaveAIStreamRequest\n├── Call RpcApi.StreamWaveAiCommand()\n├── Add typing indicator\n└── Stream response handling:\n    ├── Update message incrementally\n    ├── Handle errors\n    └── Save complete conversation\n```\n\n### Backend Architecture (`pkg/waveai/`)\n\n#### Core Interface\n\n**AIBackend Interface**:\n```go\ntype AIBackend interface {\n    StreamCompletion(\n        ctx context.Context,\n        request wshrpc.WaveAIStreamRequest,\n    ) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]\n}\n```\n\n#### Backend Implementations\n\n**1. OpenAIBackend** (`openaibackend.go`)\n- **Providers**: OpenAI, Azure OpenAI, Cloudflare Azure\n- **Features**: \n  - Reasoning model support (o1, o3, o4, gpt-5)\n  - Proxy support\n  - Multiple API types (OpenAI, Azure, AzureAD, CloudflareAzure)\n- **Streaming**: Uses `go-openai` library for SSE streaming\n\n**2. AnthropicBackend** (`anthropicbackend.go`)\n- **Provider**: Anthropic Claude\n- **Features**:\n  - Custom SSE parser for Anthropic's event format\n  - System message handling\n  - Usage token tracking\n- **Events**: `message_start`, `content_block_delta`, `message_stop`, etc.\n\n**3. WaveAICloudBackend** (`cloudbackend.go`)\n- **Provider**: Wave's cloud proxy service\n- **Transport**: WebSocket connection to Wave cloud\n- **Features**: \n  - Fallback when no API token/baseURL provided\n  - Built-in rate limiting and abuse protection\n\n**4. PerplexityBackend** (`perplexitybackend.go`)\n- **Provider**: Perplexity AI\n- **Implementation**: Similar to OpenAI backend\n\n**5. GoogleBackend** (`googlebackend.go`)\n- **Provider**: Google AI (Gemini)\n- **Implementation**: Custom integration for Google's API\n\n#### Backend Routing Logic\n\n```go\nfunc RunAICommand(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] {\n    // Route based on request.Opts.APIType:\n    switch request.Opts.APIType {\n    case \"anthropic\":\n        backend = AnthropicBackend{}\n    case \"perplexity\":\n        backend = PerplexityBackend{}\n    case \"google\":\n        backend = GoogleBackend{}\n    default:\n        if IsCloudAIRequest(request.Opts) {\n            backend = WaveAICloudBackend{}\n        } else {\n            backend = OpenAIBackend{}\n        }\n    }\n    return backend.StreamCompletion(ctx, request)\n}\n```\n\n### RPC Communication Layer\n\n#### WSH RPC Integration\n\n**Command**: `streamwaveai`\n**Type**: Response Stream (one request, multiple responses)\n\n**Request Type** (`WaveAIStreamRequest`):\n```go\ntype WaveAIStreamRequest struct {\n    ClientId string                    `json:\"clientid,omitempty\"`\n    Opts     *WaveAIOptsType           `json:\"opts\"`\n    Prompt   []WaveAIPromptMessageType `json:\"prompt\"`\n}\n```\n\n**Response Type** (`WaveAIPacketType`):\n```go\ntype WaveAIPacketType struct {\n    Type         string           `json:\"type\"`\n    Model        string           `json:\"model,omitempty\"`\n    Created      int64            `json:\"created,omitempty\"`\n    FinishReason string           `json:\"finish_reason,omitempty\"`\n    Usage        *WaveAIUsageType `json:\"usage,omitempty\"`\n    Index        int              `json:\"index,omitempty\"`\n    Text         string           `json:\"text,omitempty\"`\n    Error        string           `json:\"error,omitempty\"`\n}\n```\n\n#### Configuration Types\n\n**AI Options** (`WaveAIOptsType`):\n```go\ntype WaveAIOptsType struct {\n    Model      string `json:\"model\"`\n    APIType    string `json:\"apitype,omitempty\"`\n    APIToken   string `json:\"apitoken\"`\n    OrgID      string `json:\"orgid,omitempty\"`\n    APIVersion string `json:\"apiversion,omitempty\"`\n    BaseURL    string `json:\"baseurl,omitempty\"`\n    ProxyURL   string `json:\"proxyurl,omitempty\"`\n    MaxTokens  int    `json:\"maxtokens,omitempty\"`\n    MaxChoices int    `json:\"maxchoices,omitempty\"`\n    TimeoutMs  int    `json:\"timeoutms,omitempty\"`\n}\n```\n\n### Data Persistence\n\n#### Chat History Storage\n\n**Frontend**:\n- **Method**: `fetchWaveFile(blockId, \"aidata\")`\n- **Format**: JSON array of `WaveAIPromptMessageType`\n- **Sliding Window**: Last 30 messages (`slidingWindowSize = 30`)\n\n**Backend**:\n- **Service**: `BlockService.SaveWaveAiData(blockId, history)`\n- **Storage**: Block-associated file storage\n- **Persistence**: Automatic save after each complete exchange\n\n#### Message Format\n\n**UI Messages** (`ChatMessageType`):\n```typescript\ninterface ChatMessageType {\n    id: string;\n    user: string;        // \"user\" | \"assistant\" | \"error\"\n    text: string;\n    isUpdating?: boolean;\n}\n```\n\n**Stored Messages** (`WaveAIPromptMessageType`):\n```go\ntype WaveAIPromptMessageType struct {\n    Role    string `json:\"role\"`     // \"user\" | \"assistant\" | \"system\" | \"error\"\n    Content string `json:\"content\"`\n    Name    string `json:\"name,omitempty\"`\n}\n```\n\n### Error Handling\n\n#### Frontend Error Handling\n\n1. **Network Errors**: Caught in streaming loop, displayed as error messages\n2. **Empty Responses**: Automatically remove typing indicator\n3. **Cancellation**: User can cancel via stop button (`model.cancel = true`)\n4. **Partial Responses**: Saved even if incomplete due to errors\n\n#### Backend Error Handling\n\n1. **Panic Recovery**: All backends use `panichandler.PanicHandler()`\n2. **Context Cancellation**: Proper cleanup on request cancellation\n3. **Provider Errors**: Wrapped and forwarded to frontend\n4. **Connection Errors**: Detailed error messages for debugging\n\n### UI Features\n\n#### Message Rendering\n\n- **Markdown Support**: Full markdown rendering with syntax highlighting\n- **Role-based Styling**: Different colors/layouts for user/assistant/error messages\n- **Typing Indicator**: Animated dots during AI response\n- **Font Configuration**: Configurable font sizes via presets\n\n#### Input Handling\n\n- **Auto-resize**: Textarea grows/shrinks with content (max 5 lines)\n- **Keyboard Navigation**: \n  - Enter to send\n  - Cmd+L to clear history\n  - Arrow keys for code block selection\n- **Code Block Selection**: Navigate through code blocks in responses\n\n#### Scroll Management\n\n- **Auto-scroll**: Automatically scrolls to new messages\n- **User Scroll Detection**: Pauses auto-scroll when user manually scrolls\n- **Smart Resume**: Resumes auto-scroll when near bottom\n\n### Configuration Management\n\n#### Preset System\n\n**Preset Structure**:\n```json\n{\n  \"ai@preset-name\": {\n    \"display:name\": \"Preset Display Name\",\n    \"display:order\": 1,\n    \"ai:model\": \"gpt-4\",\n    \"ai:apitype\": \"openai\",\n    \"ai:apitoken\": \"sk-...\",\n    \"ai:baseurl\": \"https://api.openai.com/v1\",\n    \"ai:maxtokens\": 4000,\n    \"ai:fontsize\": \"14px\",\n    \"ai:fixedfontsize\": \"12px\"\n  }\n}\n```\n\n**Configuration Keys**:\n- `ai:model` - AI model name\n- `ai:apitype` - Provider type (openai, anthropic, perplexity, google)\n- `ai:apitoken` - API authentication token\n- `ai:baseurl` - Custom API endpoint\n- `ai:proxyurl` - HTTP proxy URL\n- `ai:maxtokens` - Maximum response tokens\n- `ai:timeoutms` - Request timeout\n- `ai:fontsize` - UI font size\n- `ai:fixedfontsize` - Code block font size\n\n#### Provider Detection\n\nThe UI automatically detects and displays the active provider:\n\n- **Cloud**: Wave's proxy (no token/baseURL)\n- **Local**: localhost/127.0.0.1 endpoints\n- **Remote**: External API endpoints\n- **Provider-specific**: Anthropic, Perplexity with custom icons\n\n### Performance Considerations\n\n#### Frontend Optimizations\n\n- **Jotai Atoms**: Granular reactivity, only re-render affected components\n- **Memo Components**: `ChatWindow` and `ChatItem` are memoized\n- **Throttled Scrolling**: Scroll events throttled to 100ms\n- **Debounced Scroll Detection**: User scroll detection debounced to 300ms\n\n#### Backend Optimizations\n\n- **Streaming**: All responses are streamed for immediate feedback\n- **Context Cancellation**: Proper cleanup prevents resource leaks\n- **Connection Pooling**: HTTP clients reuse connections\n- **Error Recovery**: Graceful degradation on provider failures\n\n### Security Considerations\n\n#### API Token Handling\n\n- **Storage**: Tokens stored in encrypted configuration\n- **Transmission**: Tokens only sent to configured endpoints\n- **Validation**: Backend validates token format and permissions\n\n#### Request Validation\n\n- **Input Sanitization**: User input validated before sending\n- **Rate Limiting**: Cloud backend includes built-in rate limiting\n- **Error Filtering**: Sensitive error details filtered from UI\n\n### Extension Points\n\n#### Adding New Providers\n\n1. **Implement AIBackend Interface**: Create new backend struct\n2. **Add Provider Detection**: Update `RunAICommand()` routing logic\n3. **Add Configuration**: Define provider-specific config keys\n4. **Update UI**: Add provider detection in `viewText` atom\n\n#### Custom Message Types\n\n1. **Extend ChatMessageType**: Add new user types\n2. **Update ChatItem Rendering**: Handle new message types\n3. **Modify Storage**: Update persistence format if needed\n\nThis architecture provides a flexible, extensible foundation for AI chat functionality while maintaining clean separation between UI, business logic, and provider integrations."
  },
  {
    "path": "aiprompts/waveai-focus-updates.md",
    "content": "# Wave Terminal Focus System - Wave AI Integration\n\n## Problem\n\nWave AI focus handling is fragile compared to blocks:\n\n1. Only watches textarea focus/blur, missing the multi-phase handling that blocks have\n2. Selection handling breaks - selecting text causes blur → focus reverts to layout\n3. Focus ring flashing - clicking Wave AI briefly shows focus ring on layout\n4. Window blur sensitivity - `window.blur()` incorrectly assumes user wants to leave Wave AI\n5. No capture phase - missing the immediate visual feedback that blocks get\n\n## Solution Overview\n\nExtend the block focus system pattern to Wave AI:\n\n- Multi-phase handling (capture + click)\n- Selection protection\n- Focus manager coordination\n- View delegation\n\n## Architecture\n\n```mermaid\ngraph TB\n    User[User Interaction]\n    FM[Focus Manager]\n    Layout[Layout System]\n    WaveAI[Wave AI Panel]\n\n    User -->|click/key| FM\n    FM -->|node focus| Layout\n    FM -->|waveai focus| WaveAI\n    Layout -->|request focus back| FM\n    WaveAI -->|request focus back| FM\n\n    FM -->|focusType atom| State[Global State]\n    Layout -.->|checks| State\n    WaveAI -.->|checks| State\n```\n\n## Focus Manager Enhancements\n\n**File**: [`frontend/app/store/focusManager.ts`](frontend/app/store/focusManager.ts)\n\nAdd selection-aware focus methods:\n\n```typescript\nclass FocusManager {\n  // Existing\n  focusType: PrimitiveAtom<\"node\" | \"waveai\">;  // Single source of truth\n  blockFocusAtom: Atom<string | null>;\n\n  // NEW: Selection-aware focus checking\n  waveAIFocusWithin(): boolean;\n  nodeFocusWithin(): boolean;\n\n  // NEW: Focus transitions (INTENTIONALLY not defensive)\n  requestNodeFocus(): void; // from Wave AI → node (BREAKS selections - that's the point!)\n  requestWaveAIFocus(): void; // from node → Wave AI\n\n  // NEW: Get current focus type\n  getFocusType(): FocusStrType;\n\n  // ENHANCED: Smart refocus based on focusType\n  refocusNode(): void; // already handles both types\n}\n```\n\n**Critical Design Decision: `requestNodeFocus()` is NOT defensive**\n\nWhen `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.\n\n**Focus Manager as Source of Truth**\n\nThe `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.\n\n## Wave AI Focus Utilities\n\n**New File**: [`frontend/app/aipanel/waveai-focus-utils.ts`](frontend/app/aipanel/waveai-focus-utils.ts)\n\nSimilar to [`focusutil.ts`](frontend/util/focusutil.ts) but for Wave AI:\n\n```typescript\n// Find if element is within Wave AI panel\nexport function findWaveAIPanel(element: HTMLElement): HTMLElement | null {\n  let current: HTMLElement = element;\n  while (current) {\n    if (current.hasAttribute(\"data-waveai-panel\")) {\n      return current;\n    }\n    current = current.parentElement;\n  }\n  return null;\n}\n\n// Check if Wave AI panel has focus or selection (like focusedBlockId())\nexport function waveAIHasFocusWithin(): boolean {\n  // Check if activeElement is within Wave AI panel\n  const focused = document.activeElement;\n  if (focused instanceof HTMLElement) {\n    const waveAIPanel = findWaveAIPanel(focused);\n    if (waveAIPanel) return true;\n  }\n\n  // Check if selection is within Wave AI panel\n  const sel = document.getSelection();\n  if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) {\n    let anchor = sel.anchorNode;\n    if (anchor instanceof Text) {\n      anchor = anchor.parentElement;\n    }\n    if (anchor instanceof HTMLElement) {\n      const waveAIPanel = findWaveAIPanel(anchor);\n      if (waveAIPanel) return true;\n    }\n  }\n\n  return false;\n}\n\n// Check if there's an active selection in Wave AI\nexport function waveAIHasSelection(): boolean {\n  const sel = document.getSelection();\n  if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {\n    return false;\n  }\n\n  let anchor = sel.anchorNode;\n  if (anchor instanceof Text) {\n    anchor = anchor.parentElement;\n  }\n  if (anchor instanceof HTMLElement) {\n    return findWaveAIPanel(anchor) != null;\n  }\n\n  return false;\n}\n```\n\n## Wave AI Panel Integration\n\n**File**: [`frontend/app/aipanel/aipanel.tsx`](frontend/app/aipanel/aipanel.tsx)\n\nAdd capture phase and selection protection:\n\n```typescript\n// ADD: Capture phase handler (like blocks)\nconst handleFocusCapture = useCallback((event: React.FocusEvent) => {\n    console.log(\"Wave AI focus capture\", getElemAsStr(event.target));\n    focusManager.requestWaveAIFocus();  // Sets visual state immediately\n}, []);\n\n// MODIFY: Click handler with selection protection\nconst handleClick = (e: React.MouseEvent) => {\n    const target = e.target as HTMLElement;\n    const isInteractive = target.closest('button, a, input, textarea, select, [role=\"button\"], [tabindex]');\n\n    if (isInteractive) {\n        return;\n    }\n\n    // NEW: Check for selection protection\n    const hasSelection = waveAIHasSelection();\n    if (hasSelection) {\n        // Just update visual focus, don't move DOM focus\n        focusManager.requestWaveAIFocus();\n        return;\n    }\n\n    // No selection, safe to move DOM focus\n    setTimeout(() => {\n        if (!waveAIHasSelection()) {  // Double-check after timeout\n            model.focusInput();\n        }\n    }, 0);\n};\n\n// Add data attribute and onFocusCapture to the div\n<div\n    data-waveai-panel=\"true\"\n    className={...}\n    onFocusCapture={handleFocusCapture}\n    onClick={handleClick}\n    // ... rest\n>\n```\n\n## Wave AI Input Focus Handling\n\n**File**: [`frontend/app/aipanel/aipanelinput.tsx`](frontend/app/aipanel/aipanelinput.tsx)\n\nSmart blur handling:\n\n```typescript\n// MODIFY: handleFocus - advisory only\nconst handleFocus = useCallback(() => {\n  focusManager.requestWaveAIFocus();\n}, []);\n\n// MODIFY: handleBlur - simplified with waveAIHasFocusWithin()\nconst handleBlur = useCallback((e: React.FocusEvent) => {\n  // Window blur - preserve state\n  if (e.relatedTarget === null) {\n    return;\n  }\n\n  // Still within Wave AI (focus or selection) - don't revert\n  if (waveAIHasFocusWithin()) {\n    return;\n  }\n\n  // Focus truly leaving Wave AI, revert to node focus\n  focusManager.requestNodeFocus();\n}, []);\n```\n\n**Note:** `waveAIHasFocusWithin()` checks both:\n\n1. If `relatedTarget` is within Wave AI panel (handles context menus, buttons)\n2. If there's an active selection in Wave AI (handles text selection clicks)\n\nThis combines both checks from the original implementation into a single utility call.\n\n## Block Focus Integration\n\n**File**: [`frontend/app/block/block.tsx`](frontend/app/block/block.tsx)\n\n**No changes needed in block.tsx** - the block code works perfectly as-is!\n\n**How it works:**\n\nWhen a block child gets focus (input field, terminal click, tab navigation):\n\n```\n1. handleChildFocus fires (capture phase)\n     ↓\n2. nodeModel.focusNode()\n     ↓\n3. layoutModel.focusNode(nodeId)\n     ↓\n4. treeReducer(FocusNodeAction)\n     ↓\n5. focusManager.requestNodeFocus() (see Layout Focus Coordination section)\n     ↓\n6. Updates localTreeStateAtom (synchronous)\n     ↓\n7. isFocused recalculates (sees focusType = \"node\")\n     ↓\n8. Two-step effect grants physical DOM focus\n```\n\nThe focus manager update happens automatically in the treeReducer for all focus-claiming operations.\n\n## Layout Focus Integration\n\n**File**: [`frontend/layout/lib/layoutModel.ts`](frontend/layout/lib/layoutModel.ts)\n\nThe `isFocused` atom already checks Wave AI state:\n\n```typescript\nisFocused: atom((get) => {\n  const treeState = get(this.localTreeStateAtom);\n  const isFocused = treeState.focusedNodeId === nodeid;\n  const waveAIFocused = get(atoms.waveAIFocusedAtom);\n  return isFocused && !waveAIFocused;\n});\n```\n\n**Update to use focus manager:**\n\n```typescript\nisFocused: atom((get) => {\n  const treeState = get(this.localTreeStateAtom);\n  const isFocused = treeState.focusedNodeId === nodeid;\n  const focusType = get(focusManager.focusType);\n  return isFocused && focusType === \"node\";\n});\n```\n\nThis single change coordinates the entire system:\n\n- Layout can set `focusedNodeId` freely\n- The reactive chain runs normally\n- But `isFocused` returns `false` if focus manager says \"waveai\"\n- Block's two-step effect doesn't run\n- Physical DOM focus stays with Wave AI\n\n## Layout Focus Coordination\n\n**File**: [`frontend/layout/lib/layoutModel.ts`](frontend/layout/lib/layoutModel.ts)\n\n**Critical Integration**: When layout operations claim focus, they must update the focus manager synchronously.\n\n```typescript\ntreeReducer(action: LayoutTreeAction, setState = true): boolean {\n  // Process the action (mutates this.treeState)\n  switch (action.type) {\n    case LayoutTreeActionType.InsertNode:\n      insertNode(this.treeState, action);\n      // If inserting with focus, claim focus from Wave AI\n      if ((action as LayoutTreeInsertNodeAction).focused) {\n        focusManager.requestNodeFocus();\n      }\n      break;\n\n    case LayoutTreeActionType.InsertNodeAtIndex:\n      insertNodeAtIndex(this.treeState, action);\n      if ((action as LayoutTreeInsertNodeAtIndexAction).focused) {\n        focusManager.requestNodeFocus();\n      }\n      break;\n\n    case LayoutTreeActionType.FocusNode:\n      focusNode(this.treeState, action);\n      // Explicit focus change always claims focus\n      focusManager.requestNodeFocus();\n      break;\n\n    case LayoutTreeActionType.MagnifyNodeToggle:\n      magnifyNodeToggle(this.treeState, action);\n      // Magnifying also focuses the node\n      focusManager.requestNodeFocus();\n      break;\n\n    // ... other cases don't affect focus\n  }\n\n  if (setState) {\n    this.updateTree();\n    this.setter(this.localTreeStateAtom, { ...this.treeState });\n    this.persistToBackend();\n  }\n\n  return true;\n}\n```\n\n**Why This Works:**\n\n1. `focusManager.requestNodeFocus()` updates `focusType` synchronously\n2. Called BEFORE atoms commit (still in same function)\n3. When `localTreeStateAtom` commits, `isFocused` sees the new `focusType`\n4. Both updates happen in same tick → React sees consistent state\n5. No race conditions, no flash\n\n**Order of Operations:**\n\n```\nCmd+n pressed\n  ↓\ntreeReducer() executes\n  ↓\n1. insertNode() mutates layoutState.focusedNodeId\n2. focusManager.requestNodeFocus() updates focusType\n3. setter(localTreeStateAtom) commits tree state\n  ↓\n[All synchronous - single call stack]\n  ↓\nReact re-renders with both updates applied\n  ↓\nisFocused sees: focusedNodeId = newNode AND focusType = \"node\"\n  ↓\nTwo-step effect grants physical focus\n```\n\n## Keyboard Navigation Integration\n\n**File**: [`frontend/app/store/keymodel.ts`](frontend/app/store/keymodel.ts)\n\nUse focus manager instead of direct atom checks:\n\n```typescript\nfunction switchBlockInDirection(tabId: string, direction: NavigateDirection) {\n  const layoutModel = getLayoutModelForTabById(tabId);\n  const focusType = focusManager.getFocusType();\n\n  if (direction === NavigateDirection.Left) {\n    const numBlocks = globalStore.get(layoutModel.numLeafs);\n    if (focusType === \"waveai\") {\n      return;\n    }\n    if (numBlocks === 1) {\n      focusManager.requestWaveAIFocus();\n      return;\n    }\n  }\n\n  // For right navigation, switch from Wave AI to blocks\n  if (direction === NavigateDirection.Right && focusType === \"waveai\") {\n    focusManager.requestNodeFocus();\n    return;\n  }\n\n  // Rest of navigation logic...\n}\n```\n\n## Focus Flow\n\n### Complete Flow (Single Tick, No Flash)\n\n```\nUser presses Cmd+n\n  ↓\ntreeReducer() called\n  ↓\n1. insertNode(focused: true) - SYNCHRONOUS\n   - layoutState.focusedNodeId = newNode\n  ↓\n2. setter(localTreeStateAtom, { ...treeState }) - SYNCHRONOUS\n   - Atom updated immediately\n  ↓\n3. persistToBackend() - ASYNC (fire-and-forget)\n  ↓\n[All in same tick - no intermediate renders]\n  ↓\nReact re-renders (batched update)\n  ↓\nisFocused recalculates:\n  - get(localTreeStateAtom) → focusedNodeId = newNode ✓\n  - get(focusType) → checks current focus type\n  - Returns TRUE if focusType === \"node\"\n  ↓\nuseLayoutEffect #1: setBlockClicked(true)\n  ↓\nuseLayoutEffect #2: setFocusTarget()\n  ↓\nPhysical DOM focus granted ✓\n```\n\n**Why there's no flash:**\n\n- Local atoms update synchronously\n- React batches the updates\n- Everything sees consistent state in one render\n\n## Edge Cases\n\n### 1. Window Blur (⌘+Tab to other app)\n\n- Textarea loses focus, triggers `handleBlur`\n- `relatedTarget` is null → detected as window blur\n- Focus state preserved\n\n### 2. Selection in Wave AI\n\n- User selects text\n- Clicks elsewhere in Wave AI\n- `waveAIHasSelection()` returns true\n- Only visual focus updates, no DOM focus change\n- Selection preserved\n\n### 3. Copy/Paste Context Menu\n\n- Right-click causes blur\n- `relatedTarget` within Wave AI panel\n- `handleBlur` detects this, doesn't revert focus\n\n### 4. Modal Dialogs\n\n- Modal opens, steals focus\n- Modal closes → `globalRefocus()`\n- Focus manager restores correct focus based on `focusType`\n\n## Implementation Steps\n\n### 1. Focus Manager Foundation\n\n- Implement enhanced `focusManager.ts` with new methods\n- Create `waveai-focus-utils.ts` with selection utilities\n- Add data attributes to Wave AI panel\n\n### 2. Wave AI Integration\n\n- Add `onFocusCapture` to Wave AI panel\n- Update `handleBlur` with simplified `waveAIHasFocusWithin()` check\n- Update `handleClick` with selection awareness\n- Components read `focusManager.focusType` directly via `useAtomValue` for focus ring display\n\n### 3. Layout Integration\n\n- Update `isFocused` atom to check `focusManager.focusType`\n- Add `focusManager.requestNodeFocus()` calls in `treeReducer` for focus-claiming operations\n- Update keyboard navigation to use `focusManager.getFocusType()`\n\n### 4. Testing\n\n- Test all transitions and edge cases\n- Verify selection protection works\n- Confirm no focus ring flashing\n- Verify focus rings are synchronized through focus manager\n\n## Files to Create/Modify\n\n### New Files\n\n- `frontend/app/aipanel/waveai-focus-utils.ts` - Focus utilities for Wave AI\n\n### Modified Files\n\n- [`frontend/app/store/focusManager.ts`](frontend/app/store/focusManager.ts) - Enhanced with new methods\n- [`frontend/app/aipanel/aipanel.tsx`](frontend/app/aipanel/aipanel.tsx) - Add capture phase, improve click handler\n- [`frontend/app/aipanel/aipanelinput.tsx`](frontend/app/aipanel/aipanelinput.tsx) - Smart blur handling\n- [`frontend/layout/lib/layoutModel.ts`](frontend/layout/lib/layoutModel.ts) - Update isFocused atom AND add focus manager calls in treeReducer\n- [`frontend/app/store/keymodel.ts`](frontend/app/store/keymodel.ts) - Use focus manager for navigation\n\n## Testing Checklist\n\n- [ ] Select text in Wave AI, click elsewhere in Wave AI → selection preserved\n- [ ] Click Wave AI panel (not input) → focus moves to Wave AI\n- [ ] Click block while in Wave AI (no selection) → focus moves to block\n- [ ] Press Left arrow in single block → Wave AI focused\n- [ ] Press Right arrow in Wave AI → block focused\n- [ ] Window blur (⌘+Tab) → focus state preserved\n- [ ] Open context menu in Wave AI → doesn't lose focus\n- [ ] Modal opens/closes → focus restores correctly\n\n## Benefits\n\n1. **Selection protection** - Wave AI selections preserved like blocks\n2. **No focus flash** - Capture phase provides immediate visual feedback\n3. **Robust blur handling** - Smart detection of where focus is going\n4. **Unified model** - Single source of truth simplifies reasoning\n5. **Simple reactivity** - Everything updates synchronously in one tick\n6. **No timing issues** - Local atoms eliminate race conditions\n\n## Phased Implementation Approach\n\nThe changes can be broken into safe, independently testable phases. Each phase can be shipped and tested before proceeding to the next.\n\n### Phase 1: Foundation (Non-Breaking, Fully Testable)\n\n**Add focus manager methods WITHOUT changing existing code**\n\n```typescript\n// In focusManager.ts - ADD these methods\nclass FocusManager {\n  // NEW methods that ALSO update the old waveAIFocusedAtom during migration\n  requestWaveAIFocus(): void {\n    globalStore.set(this.focusType, \"waveai\");\n    globalStore.set(atoms.waveAIFocusedAtom, true); // ← Keep old atom in sync during migration!\n  }\n\n  requestNodeFocus(): void {\n    // NO defensive checks - when called, we TAKE focus (selections may be lost)\n    globalStore.set(this.focusType, \"node\");\n    globalStore.set(atoms.waveAIFocusedAtom, false); // ← Keep old atom in sync during migration!\n  }\n\n  getFocusType(): FocusStrType {\n    return globalStore.get(this.focusType);\n  }\n\n  waveAIFocusWithin(): boolean {\n    return waveAIHasFocusWithin();\n  }\n\n  nodeFocusWithin(): boolean {\n    return focusedBlockId() != null;\n  }\n}\n```\n\n**Why this is safe:**\n\n- Doesn't change any existing code\n- Focus manager updates BOTH new `focusType` AND old `waveAIFocusedAtom` during migration\n- Everything keeps working exactly as before\n- Can test focus manager methods in isolation\n- Components can read `focusType` directly via `useAtomValue` for reactive updates\n- No user-visible changes\n\n**Testing:**\n\n- Call the new methods manually in console\n- Verify both atoms update correctly\n- Verify existing focus behavior unchanged\n\n---\n\n### Phase 2: Wave AI Improvements (Testable in Isolation)\n\n**Add utilities and improve Wave AI focus handling**\n\n1. Create `waveai-focus-utils.ts` with selection checking utilities\n2. Update `aipanel.tsx`:\n   - Add `data-waveai-panel` attribute\n   - Add `onFocusCapture` handler\n   - Improve click handler with selection protection\n   - Call `focusManager.requestWaveAIFocus()` instead of setting atom directly\n3. Update `aipanelinput.tsx`:\n   - Smart blur handling with selection checks\n   - Call `focusManager.requestNodeFocus()` instead of setting atom directly\n\n**Why this is safe:**\n\n- Wave AI now uses focus manager, but focus manager keeps old atom in sync\n- Blocks still read `waveAIFocusedAtom` directly - still works!\n- Can test Wave AI selection protection independently\n- If there's a bug, only Wave AI is affected\n- Blocks remain completely unchanged\n\n**Testing:**\n\n- Wave AI selection preservation when clicking within panel\n- Wave AI blur handling (window blur, context menus, etc.)\n- Verify blocks still work normally (unchanged)\n- Test transitions between Wave AI and blocks\n\n**User-visible improvements:**\n\n- Wave AI text selections no longer lost when clicking in panel\n- No focus ring flashing\n- Better window blur handling\n\n---\n\n### Phase 3: Layout isFocused Migration (Single Critical Change)\n\n**Update isFocused atom to use focus manager**\n\n```typescript\n// In layoutModel.ts - CHANGE isFocused atom\nisFocused: atom((get) => {\n  const treeState = get(this.localTreeStateAtom);\n  const isFocused = treeState.focusedNodeId === nodeid;\n  const focusType = get(focusManager.focusType); // ← Use focus manager\n  return isFocused && focusType === \"node\";\n});\n```\n\n**Why this is safe:**\n\n- Focus manager already keeps `waveAIFocusedAtom` in sync (Phase 1)\n- Wave AI already uses focus manager (Phase 2)\n- Blocks read the new `focusType` but it's always consistent with old atom\n- Should be completely transparent\n- Single file change - easy to revert if issues\n\n**Testing:**\n\n- Focus transitions between blocks still work\n- Wave AI → block transitions work\n- Block → Wave AI transitions work\n- Keyboard navigation still works\n- All existing functionality preserved\n\n**No user-visible changes** - just internal refactoring\n\n---\n\n### Phase 4: Layout Focus Coordination (Completes the System)\n\n**Add focus manager calls to treeReducer**\n\n```typescript\n// In layoutModel.ts treeReducer - ADD focus manager calls\ncase LayoutTreeActionType.FocusNode:\n  focusNode(this.treeState, action);\n  focusManager.requestNodeFocus();  // ← NEW\n  break;\n\ncase LayoutTreeActionType.InsertNode:\n  insertNode(this.treeState, action);\n  if ((action as LayoutTreeInsertNodeAction).focused) {\n    focusManager.requestNodeFocus();  // ← NEW\n  }\n  break;\n\ncase LayoutTreeActionType.MagnifyNodeToggle:\n  magnifyNodeToggle(this.treeState, action);\n  focusManager.requestNodeFocus();  // ← NEW\n  break;\n```\n\n**Why this is safe:**\n\n- Just makes explicit what was already happening via Wave AI's blur handler\n- Ensures focus manager is updated even when layout programmatically changes focus\n- Makes the system more robust\n- Small, focused changes in one file\n\n**Testing:**\n\n- Cmd+n creates new block with correct focus\n- Magnify toggle works correctly\n- Programmatic focus changes work\n- Focus stays consistent during rapid operations\n\n**User-visible improvements:**\n\n- More robust focus handling during programmatic layout changes\n- Edge cases with rapid focus changes handled better\n\n---\n\n### Phase 5: Keyboard Nav & Cleanup (Optional Polish)\n\n**Use focus manager in keyboard navigation, remove old atom usage**\n\n1. Update `keymodel.ts` to use `focusManager.getFocusType()`\n2. Remove direct `atoms.waveAIFocusedAtom` usage throughout codebase\n3. (Optional) Stop syncing `waveAIFocusedAtom` in focus manager - can be deprecated\n\n**Why this is safe:**\n\n- Everything already using focus manager under the hood\n- Just cleanup/optimization\n- Can be done incrementally\n\n**Testing:**\n\n- Keyboard navigation between blocks\n- Left/Right arrow to/from Wave AI\n- All keyboard shortcuts still work\n\n---\n\n## Key Insight: Dual Atom Sync\n\n**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:\n\n- New code can use focus manager\n- Old code continues reading the old atom\n- Everything stays consistent\n- Each phase is independently testable\n- Can ship and test after each phase\n\nThis dual-sync approach eliminates the \"all or nothing\" problem. You can stop at any phase and have a working, tested system.\n\n## Testing Between Phases\n\nAfter each phase, you can ship and test:\n\n- **Phase 1** → No user-visible changes, foundation in place\n- **Phase 2** → Wave AI improvements only, blocks unchanged\n- **Phase 3** → Complete system working with new architecture\n- **Phase 4** → More robust edge case handling\n- **Phase 5** → Code cleanup and optimization\n\nEach phase builds on the previous one but can be independently verified.\n"
  },
  {
    "path": "aiprompts/wps-events.md",
    "content": "# WPS Events Guide\n\n## Overview\n\nWPS (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.\n\n## Key Files\n\n- [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go) - Event type constants and data structures\n- [`pkg/wps/wps.go`](../pkg/wps/wps.go) - Broker implementation and core logic\n- [`pkg/wcore/wcore.go`](../pkg/wcore/wcore.go) - Example usage patterns\n\n## Event Structure\n\nEvents in WPS have the following structure:\n\n```go\ntype WaveEvent struct {\n    Event   string   `json:\"event\"`      // Event type constant\n    Scopes  []string `json:\"scopes,omitempty\"` // Optional scopes for targeted delivery\n    Sender  string   `json:\"sender,omitempty\"` // Optional sender identifier\n    Persist int      `json:\"persist,omitempty\"` // Number of events to persist in history\n    Data    any      `json:\"data,omitempty\"`    // Event payload\n}\n```\n\n## Adding a New Event Type\n\n### Step 1: Define the Event Constant\n\nAdd your event type constant to [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go:8-19):\n\n```go\nconst (\n    Event_BlockClose       = \"blockclose\"\n    Event_ConnChange       = \"connchange\"\n    // ... other events ...\n    Event_YourNewEvent     = \"your:newevent\"  // Use colon notation for namespacing\n)\n```\n\n**Naming Convention:**\n\n- Use descriptive PascalCase for the constant name with `Event_` prefix\n- Use lowercase with colons for the string value (e.g., \"namespace:eventname\")\n- Group related events with the same namespace prefix\n\n### Step 2: Define Event Data Structure (Optional)\n\nIf your event carries structured data, define a type for it:\n\n```go\ntype YourEventData struct {\n    Field1 string `json:\"field1\"`\n    Field2 int    `json:\"field2\"`\n}\n```\n\n### Step 3: Expose Type to Frontend (If Needed)\n\nIf 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:\n\n```go\n// add extra types to generate here\nvar ExtraTypes = []any{\n    waveobj.ORef{},\n    // ... other types ...\n    uctypes.RateLimitInfo{},  // Example: already added\n    YourEventData{},          // Add your new type here\n}\n```\n\nThen run code generation:\n\n```bash\ntask generate\n```\n\nThis 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.\n\n## Publishing Events\n\n### Basic Publishing\n\nTo publish an event, use the global broker:\n\n```go\nimport \"github.com/wavetermdev/waveterm/pkg/wps\"\n\nwps.Broker.Publish(wps.WaveEvent{\n    Event: wps.Event_YourNewEvent,\n    Data:  yourData,\n})\n```\n\n### Publishing with Scopes\n\nScopes allow targeted event delivery. Subscribers can filter events by scope:\n\n```go\nwps.Broker.Publish(wps.WaveEvent{\n    Event:  wps.Event_WaveObjUpdate,\n    Scopes: []string{oref.String()},  // Target specific object\n    Data:   updateData,\n})\n```\n\n### Publishing in a Goroutine\n\nTo avoid blocking the caller, publish events asynchronously:\n\n```go\ngo func() {\n    wps.Broker.Publish(wps.WaveEvent{\n        Event: wps.Event_YourNewEvent,\n        Data:  data,\n    })\n}()\n```\n\n**When to use goroutines:**\n\n- When publishing from performance-critical code paths\n- When the event is informational and doesn't need immediate delivery\n- When publishing from code that holds locks (to prevent deadlocks)\n\n### Event Persistence\n\nEvents can be persisted in memory for late subscribers:\n\n```go\nwps.Broker.Publish(wps.WaveEvent{\n    Event:   wps.Event_YourNewEvent,\n    Persist: 100,  // Keep last 100 events\n    Data:    data,\n})\n```\n\n## Complete Example: Rate Limit Updates\n\nThis example shows how rate limit information is published when AI chat responses include rate limit headers.\n\n### 1. Define the Event Type\n\nIn [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go:19):\n\n```go\nconst (\n    // ... other events ...\n    Event_WaveAIRateLimit  = \"waveai:ratelimit\"\n)\n```\n\n### 2. Publish the Event\n\nIn [`pkg/aiusechat/usechat.go`](../pkg/aiusechat/usechat.go:94-108):\n\n```go\nimport \"github.com/wavetermdev/waveterm/pkg/wps\"\n\nfunc updateRateLimit(info *uctypes.RateLimitInfo) {\n    if info == nil {\n        return\n    }\n    rateLimitLock.Lock()\n    defer rateLimitLock.Unlock()\n    globalRateLimitInfo = info\n\n    // Publish event in goroutine to avoid blocking\n    go func() {\n        wps.Broker.Publish(wps.WaveEvent{\n            Event: wps.Event_WaveAIRateLimit,\n            Data:  info,  // RateLimitInfo struct\n        })\n    }()\n}\n```\n\n### 3. Subscribe to the Event (Frontend)\n\nIn the frontend, subscribe to events via WebSocket:\n\n```typescript\n// Subscribe to rate limit updates\nconst subscription = {\n  event: \"waveai:ratelimit\",\n  allscopes: true, // Receive all rate limit events\n};\n```\n\n## Subscribing to Events\n\n### From Go Code\n\n```go\n// Subscribe to all events of a type\nwps.Broker.Subscribe(routeId, wps.SubscriptionRequest{\n    Event:     wps.Event_YourNewEvent,\n    AllScopes: true,\n})\n\n// Subscribe to specific scopes\nwps.Broker.Subscribe(routeId, wps.SubscriptionRequest{\n    Event:  wps.Event_WaveObjUpdate,\n    Scopes: []string{\"workspace:123\"},\n})\n\n// Unsubscribe\nwps.Broker.Unsubscribe(routeId, wps.Event_YourNewEvent)\n```\n\n### Scope Matching\n\nScopes support wildcard matching:\n\n- `*` matches a single scope segment\n- `**` matches multiple scope segments\n\n```go\n// Subscribe to all workspace events\nwps.Broker.Subscribe(routeId, wps.SubscriptionRequest{\n    Event:  wps.Event_WaveObjUpdate,\n    Scopes: []string{\"workspace:*\"},\n})\n```\n\n## Best Practices\n\n1. **Use Namespaces**: Prefix event names with a namespace (e.g., `waveai:`, `workspace:`, `block:`)\n\n2. **Don't Block**: Use goroutines when publishing from performance-critical code or while holding locks\n\n3. **Type-Safe Data**: Define struct types for event data rather than using maps\n\n4. **Scope Wisely**: Use scopes to limit event delivery and reduce unnecessary processing\n\n5. **Document Events**: Add comments explaining when events are fired and what data they carry\n\n6. **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.\n\n## Common Event Patterns\n\n### Status Updates\n\n```go\nwps.Broker.Publish(wps.WaveEvent{\n    Event:   wps.Event_ControllerStatus,\n    Scopes:  []string{blockId},\n    Persist: 1,  // Keep only latest status\n    Data:    statusData,\n})\n```\n\n### Object Updates\n\n```go\nwps.Broker.Publish(wps.WaveEvent{\n    Event:  wps.Event_WaveObjUpdate,\n    Scopes: []string{oref.String()},\n    Data: waveobj.WaveObjUpdate{\n        UpdateType: waveobj.UpdateType_Update,\n        OType:      obj.GetOType(),\n        OID:        waveobj.GetOID(obj),\n        Obj:        obj,\n    },\n})\n```\n\n### Batch Updates\n\n```go\n// Helper function for multiple updates\nfunc (b *BrokerType) SendUpdateEvents(updates waveobj.UpdatesRtnType) {\n    for _, update := range updates {\n        b.Publish(WaveEvent{\n            Event:  Event_WaveObjUpdate,\n            Scopes: []string{waveobj.MakeORef(update.OType, update.OID).String()},\n            Data:   update,\n        })\n    }\n}\n```\n\n## Debugging\n\nTo debug event flow:\n\n1. Check broker subscription map: `wps.Broker.SubMap`\n2. View persisted events: `wps.Broker.ReadEventHistory(eventType, scope, maxItems)`\n3. Add logging in publish/subscribe methods\n4. Monitor WebSocket traffic in browser dev tools\n\n## Related Documentation\n\n- [Configuration System](config-system.md) - Uses WPS events for config updates\n- [Wave AI Architecture](waveai-architecture.md) - AI-related events\n"
  },
  {
    "path": "build/deb-postinstall.tpl",
    "content": "#!/bin/bash\n\nif type update-alternatives 2>/dev/null >&1; then\n    # Remove previous link if it doesn't use update-alternatives\n    if [ -L '/usr/bin/waveterm' -a -e '/usr/bin/waveterm' -a \"`readlink '/usr/bin/waveterm'`\" != '/etc/alternatives/waveterm' ]; then\n        rm -f '/usr/bin/waveterm'\n    fi\n    update-alternatives --install '/usr/bin/waveterm' 'waveterm' '/opt/Wave/waveterm' 100 || ln -sf '/opt/Wave/waveterm' '/usr/bin/waveterm'\nelse\n    ln -sf '/opt/Wave/waveterm' '/usr/bin/waveterm'\nfi\n\nchmod 4755 '/opt/Wave/chrome-sandbox' || true\n\nif hash update-mime-database 2>/dev/null; then\n    update-mime-database /usr/share/mime || true\nfi\n\nif hash update-desktop-database 2>/dev/null; then\n    update-desktop-database /usr/share/applications || true\nfi\n"
  },
  {
    "path": "build/entitlements.mac.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <!-- required for electron -->\n    <key>com.apple.security.cs.allow-jit</key>\n    <true/>\n    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n    <true/>\n    <key>com.apple.security.cs.disable-library-validation</key>\n    <true/>\n\n    <!-- we add the following entitlements so that *CLI* applications can request/use these features.  this matches iTerm's permission set -->\n    <key>com.apple.security.device.audio-input</key>\n    <true/>\n    <key>com.apple.security.device.camera</key>\n    <true/>\n    <key>com.apple.security.personal-information.addressbook</key>\n    <true/>\n    <key>com.apple.security.personal-information.calendars</key>\n    <true/>\n    <key>com.apple.security.personal-information.location</key>\n    <true/>\n    <key>com.apple.security.personal-information.photos-library</key>\n    <true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "cmd/generatego/main-generatego.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/gogen\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nconst WshClientFileName = \"pkg/wshrpc/wshclient/wshclient.go\"\nconst WaveObjMetaConstsFileName = \"pkg/waveobj/metaconsts.go\"\nconst SettingsMetaConstsFileName = \"pkg/wconfig/metaconsts.go\"\n\nfunc GenerateWshClient() error {\n\tfmt.Fprintf(os.Stderr, \"generating wshclient file to %s\\n\", WshClientFileName)\n\tvar buf strings.Builder\n\tgogen.GenerateBoilerplate(&buf, \"wshclient\", []string{\n\t\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\",\n\t\t\"github.com/wavetermdev/waveterm/pkg/baseds\",\n\t\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\",\n\t\t\"github.com/wavetermdev/waveterm/pkg/vdom\",\n\t\t\"github.com/wavetermdev/waveterm/pkg/waveobj\",\n\t\t\"github.com/wavetermdev/waveterm/pkg/wconfig\",\n\t\t\"github.com/wavetermdev/waveterm/pkg/wps\",\n\t\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\",\n\t\t\"github.com/wavetermdev/waveterm/pkg/wshutil\",\n\t})\n\twshDeclMap := wshrpc.GenerateWshCommandDeclMap()\n\tfor _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) {\n\t\tmethodDecl := wshDeclMap[key]\n\t\tif methodDecl.CommandType == wshrpc.RpcType_ResponseStream {\n\t\t\tgogen.GenMethod_ResponseStream(&buf, methodDecl)\n\t\t} else if methodDecl.CommandType == wshrpc.RpcType_Call {\n\t\t\tgogen.GenMethod_Call(&buf, methodDecl)\n\t\t} else {\n\t\t\tpanic(\"unsupported command type \" + methodDecl.CommandType)\n\t\t}\n\t}\n\tbuf.WriteString(\"\\n\")\n\twritten, err := utilfn.WriteFileIfDifferent(WshClientFileName, []byte(buf.String()))\n\tif !written {\n\t\tfmt.Fprintf(os.Stderr, \"no changes to %s\\n\", WshClientFileName)\n\t}\n\treturn err\n}\n\nfunc GenerateWaveObjMetaConsts() error {\n\tfmt.Fprintf(os.Stderr, \"generating waveobj meta consts file to %s\\n\", WaveObjMetaConstsFileName)\n\tvar buf strings.Builder\n\tgogen.GenerateBoilerplate(&buf, \"waveobj\", []string{})\n\tgogen.GenerateMetaMapConsts(&buf, \"MetaKey_\", reflect.TypeOf(waveobj.MetaTSType{}), false)\n\tbuf.WriteString(\"\\n\")\n\twritten, err := utilfn.WriteFileIfDifferent(WaveObjMetaConstsFileName, []byte(buf.String()))\n\tif !written {\n\t\tfmt.Fprintf(os.Stderr, \"no changes to %s\\n\", WaveObjMetaConstsFileName)\n\t}\n\treturn err\n}\n\nfunc GenerateSettingsMetaConsts() error {\n\tfmt.Fprintf(os.Stderr, \"generating settings meta consts file to %s\\n\", SettingsMetaConstsFileName)\n\tvar buf strings.Builder\n\tgogen.GenerateBoilerplate(&buf, \"wconfig\", []string{})\n\tgogen.GenerateMetaMapConsts(&buf, \"ConfigKey_\", reflect.TypeOf(wconfig.SettingsType{}), false)\n\tbuf.WriteString(\"\\n\")\n\twritten, err := utilfn.WriteFileIfDifferent(SettingsMetaConstsFileName, []byte(buf.String()))\n\tif !written {\n\t\tfmt.Fprintf(os.Stderr, \"no changes to %s\\n\", SettingsMetaConstsFileName)\n\t}\n\treturn err\n}\n\nfunc main() {\n\terr := GenerateWshClient()\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"error generating wshclient: %v\\n\", err)\n\t\treturn\n\t}\n\terr = GenerateWaveObjMetaConsts()\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"error generating waveobj meta consts: %v\\n\", err)\n\t\treturn\n\t}\n\terr = GenerateSettingsMetaConsts()\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"error generating settings meta consts: %v\\n\", err)\n\t\treturn\n\t}\n}\n"
  },
  {
    "path": "cmd/generateschema/main-generateschema.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"reflect\"\n\n\t\"github.com/invopop/jsonschema\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n)\n\nconst WaveSchemaSettingsFileName = \"schema/settings.json\"\nconst WaveSchemaConnectionsFileName = \"schema/connections.json\"\nconst WaveSchemaAiPresetsFileName = \"schema/aipresets.json\"\nconst WaveSchemaWidgetsFileName = \"schema/widgets.json\"\nconst WaveSchemaBgPresetsFileName = \"schema/bgpresets.json\"\nconst WaveSchemaWaveAIFileName = \"schema/waveai.json\"\n\n// ViewNameType is a string type whose JSON Schema offers enum suggestions for the most\n// common widget view names while still accepting any arbitrary string value.\ntype ViewNameType string\n\nfunc (ViewNameType) JSONSchema() *jsonschema.Schema {\n\treturn &jsonschema.Schema{\n\t\tAnyOf: []*jsonschema.Schema{\n\t\t\t{\n\t\t\t\tEnum: []any{\"term\", \"preview\", \"web\", \"sysinfo\", \"launcher\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tType: \"string\",\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ControllerNameType is a string type whose JSON Schema offers enum suggestions for the\n// known block controller names while still accepting any arbitrary string value.\ntype ControllerNameType string\n\nfunc (ControllerNameType) JSONSchema() *jsonschema.Schema {\n\treturn &jsonschema.Schema{\n\t\tAnyOf: []*jsonschema.Schema{\n\t\t\t{\n\t\t\t\tEnum: []any{\"shell\", \"cmd\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tType: \"string\",\n\t\t\t},\n\t\t},\n\t}\n}\n\n// WidgetsMetaSchemaHints provides schema hints for the blockdef.meta field in widget configs.\n// It covers the most common keys used when defining widgets: view, file, url, controller,\n// cmd and cmd:* options, and term:* options.\ntype WidgetsMetaSchemaHints struct {\n\tView       ViewNameType       `json:\"view,omitempty\"`\n\tFile       string             `json:\"file,omitempty\"`\n\tUrl        string             `json:\"url,omitempty\"`\n\tController ControllerNameType `json:\"controller,omitempty\"`\n\n\tCmd                 string            `json:\"cmd,omitempty\"`\n\tCmdInteractive      bool              `json:\"cmd:interactive,omitempty\"`\n\tCmdLogin            bool              `json:\"cmd:login,omitempty\"`\n\tCmdPersistent       bool              `json:\"cmd:persistent,omitempty\"`\n\tCmdRunOnStart       bool              `json:\"cmd:runonstart,omitempty\"`\n\tCmdClearOnStart     bool              `json:\"cmd:clearonstart,omitempty\"`\n\tCmdRunOnce          bool              `json:\"cmd:runonce,omitempty\"`\n\tCmdCloseOnExit      bool              `json:\"cmd:closeonexit,omitempty\"`\n\tCmdCloseOnExitForce bool              `json:\"cmd:closeonexitforce,omitempty\"`\n\tCmdCloseOnExitDelay float64           `json:\"cmd:closeonexitdelay,omitempty\"`\n\tCmdNoWsh            bool              `json:\"cmd:nowsh,omitempty\"`\n\tCmdArgs             []string          `json:\"cmd:args,omitempty\"`\n\tCmdShell            bool              `json:\"cmd:shell,omitempty\"`\n\tCmdAllowConnChange  bool              `json:\"cmd:allowconnchange,omitempty\"`\n\tCmdEnv              map[string]string `json:\"cmd:env,omitempty\"`\n\tCmdCwd              string            `json:\"cmd:cwd,omitempty\"`\n\tCmdInitScript       string            `json:\"cmd:initscript,omitempty\"`\n\tCmdInitScriptSh     string            `json:\"cmd:initscript.sh,omitempty\"`\n\tCmdInitScriptBash   string            `json:\"cmd:initscript.bash,omitempty\"`\n\tCmdInitScriptZsh    string            `json:\"cmd:initscript.zsh,omitempty\"`\n\tCmdInitScriptPwsh   string            `json:\"cmd:initscript.pwsh,omitempty\"`\n\tCmdInitScriptFish   string            `json:\"cmd:initscript.fish,omitempty\"`\n\n\tTermFontSize            int      `json:\"term:fontsize,omitempty\"`\n\tTermFontFamily          string   `json:\"term:fontfamily,omitempty\"`\n\tTermMode                string   `json:\"term:mode,omitempty\"`\n\tTermTheme               string   `json:\"term:theme,omitempty\"`\n\tTermLocalShellPath      string   `json:\"term:localshellpath,omitempty\"`\n\tTermLocalShellOpts      []string `json:\"term:localshellopts,omitempty\"`\n\tTermScrollback          *int     `json:\"term:scrollback,omitempty\"`\n\tTermTransparency        *float64 `json:\"term:transparency,omitempty\"`\n\tTermAllowBracketedPaste *bool    `json:\"term:allowbracketedpaste,omitempty\"`\n\tTermShiftEnterNewline   *bool    `json:\"term:shiftenternewline,omitempty\"`\n\tTermMacOptionIsMeta     *bool    `json:\"term:macoptionismeta,omitempty\"`\n\tTermBellSound           *bool    `json:\"term:bellsound,omitempty\"`\n\tTermBellIndicator       *bool    `json:\"term:bellindicator,omitempty\"`\n\tTermDurable             *bool    `json:\"term:durable,omitempty\"`\n}\n\nfunc generateSchema(template any, dir string) error {\n\tsettingsSchema := jsonschema.Reflect(template)\n\n\tjsonSettingsSchema, err := json.MarshalIndent(settingsSchema, \"\", \"  \")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse local schema: %w\", err)\n\t}\n\twritten, err := utilfn.WriteFileIfDifferent(dir, jsonSettingsSchema)\n\tif !written {\n\t\tfmt.Fprintf(os.Stderr, \"no changes to %s\\n\", dir)\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to write local schema: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc generateWidgetsSchema(dir string) error {\n\tmetaT := reflect.TypeOf(waveobj.MetaMapType(nil))\n\n\t// Build the hints schema once using an expanded reflector\n\thr := &jsonschema.Reflector{\n\t\tDoNotReference:            true,\n\t\tExpandedStruct:            true,\n\t\tAllowAdditionalProperties: true,\n\t}\n\thintSchema := hr.Reflect(&WidgetsMetaSchemaHints{})\n\n\tr := &jsonschema.Reflector{}\n\tr.Mapper = func(t reflect.Type) *jsonschema.Schema {\n\t\tif t == metaT {\n\t\t\treturn &jsonschema.Schema{\n\t\t\t\tType:                 \"object\",\n\t\t\t\tProperties:           hintSchema.Properties,\n\t\t\t\tAdditionalProperties: jsonschema.TrueSchema,\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\twidgetsTemplate := make(map[string]wconfig.WidgetConfigType)\n\twidgetsSchema := r.Reflect(&widgetsTemplate)\n\n\tjsonWidgetsSchema, err := json.MarshalIndent(widgetsSchema, \"\", \"  \")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse widgets schema: %w\", err)\n\t}\n\twritten, err := utilfn.WriteFileIfDifferent(dir, jsonWidgetsSchema)\n\tif !written {\n\t\tfmt.Fprintf(os.Stderr, \"no changes to %s\\n\", dir)\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to write widgets schema: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc main() {\n\terr := generateSchema(&wconfig.SettingsType{}, WaveSchemaSettingsFileName)\n\tif err != nil {\n\t\tlog.Fatalf(\"settings schema error: %v\", err)\n\t}\n\n\tconnectionTemplate := make(map[string]wconfig.ConnKeywords)\n\terr = generateSchema(&connectionTemplate, WaveSchemaConnectionsFileName)\n\tif err != nil {\n\t\tlog.Fatalf(\"connections schema error: %v\", err)\n\t}\n\n\taiPresetsTemplate := make(map[string]wconfig.AiSettingsType)\n\terr = generateSchema(&aiPresetsTemplate, WaveSchemaAiPresetsFileName)\n\tif err != nil {\n\t\tlog.Fatalf(\"ai presets schema error: %v\", err)\n\t}\n\n\terr = generateWidgetsSchema(WaveSchemaWidgetsFileName)\n\tif err != nil {\n\t\tlog.Fatalf(\"widgets schema error: %v\", err)\n\t}\n\n\tbgPresetsTemplate := make(map[string]wconfig.BgPresetsType)\n\terr = generateSchema(&bgPresetsTemplate, WaveSchemaBgPresetsFileName)\n\tif err != nil {\n\t\tlog.Fatalf(\"bg presets schema error: %v\", err)\n\t}\n\n\twaveAITemplate := make(map[string]wconfig.AIModeConfigType)\n\terr = generateSchema(&waveAITemplate, WaveSchemaWaveAIFileName)\n\tif err != nil {\n\t\tlog.Fatalf(\"waveai schema error: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "cmd/generatets/main-generatets.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"os\"\n\t\"reflect\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/service\"\n\t\"github.com/wavetermdev/waveterm/pkg/tsgen\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nfunc generateTypesFile(tsTypesMap map[reflect.Type]string) error {\n\tfileName := \"frontend/types/gotypes.d.ts\"\n\tfmt.Fprintf(os.Stderr, \"generating types file to %s\\n\", fileName)\n\ttsgen.GenerateWaveObjTypes(tsTypesMap)\n\ttsgen.GenerateWaveEventTypes(tsTypesMap)\n\terr := tsgen.GenerateServiceTypes(tsTypesMap)\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"Error generating service types: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\terr = tsgen.GenerateWshServerTypes(tsTypesMap)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error generating wsh server types: %w\", err)\n\t}\n\tvar buf bytes.Buffer\n\tfmt.Fprintf(&buf, \"// Copyright 2026, Command Line Inc.\\n\")\n\tfmt.Fprintf(&buf, \"// SPDX-License-Identifier: Apache-2.0\\n\\n\")\n\tfmt.Fprintf(&buf, \"// generated by cmd/generate/main-generatets.go\\n\\n\")\n\tfmt.Fprintf(&buf, \"declare global {\\n\\n\")\n\tvar keys []reflect.Type\n\tfor key := range tsTypesMap {\n\t\tkeys = append(keys, key)\n\t}\n\tsort.Slice(keys, func(i, j int) bool {\n\t\tiname, _ := tsgen.TypeToTSType(keys[i], tsTypesMap)\n\t\tjname, _ := tsgen.TypeToTSType(keys[j], tsTypesMap)\n\t\treturn iname < jname\n\t})\n\tfor _, key := range keys {\n\t\t// don't output generic types\n\t\tif strings.Contains(key.Name(), \"[\") {\n\t\t\tcontinue\n\t\t}\n\t\ttsCode := tsTypesMap[key]\n\t\tistr := utilfn.IndentString(\"    \", tsCode)\n\t\tfmt.Fprint(&buf, istr)\n\t}\n\tfmt.Fprintf(&buf, \"}\\n\\n\")\n\tfmt.Fprintf(&buf, \"export {}\\n\")\n\twritten, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes())\n\tif !written {\n\t\tfmt.Fprintf(os.Stderr, \"no changes to %s\\n\", fileName)\n\t}\n\treturn err\n}\n\nfunc generateWaveEventFile(tsTypesMap map[reflect.Type]string) error {\n\tfileName := \"frontend/types/waveevent.d.ts\"\n\tfmt.Fprintf(os.Stderr, \"generating waveevent file to %s\\n\", fileName)\n\tvar buf bytes.Buffer\n\tfmt.Fprintf(&buf, \"// Copyright 2026, Command Line Inc.\\n\")\n\tfmt.Fprintf(&buf, \"// SPDX-License-Identifier: Apache-2.0\\n\\n\")\n\tfmt.Fprintf(&buf, \"// generated by cmd/generate/main-generatets.go\\n\\n\")\n\tfmt.Fprintf(&buf, \"declare global {\\n\\n\")\n\tfmt.Fprint(&buf, utilfn.IndentString(\"    \", tsgen.GenerateWaveEventTypes(tsTypesMap)))\n\tfmt.Fprintf(&buf, \"}\\n\\n\")\n\tfmt.Fprintf(&buf, \"export {}\\n\")\n\twritten, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes())\n\tif !written {\n\t\tfmt.Fprintf(os.Stderr, \"no changes to %s\\n\", fileName)\n\t}\n\treturn err\n}\n\nfunc generateServicesFile(tsTypesMap map[reflect.Type]string) error {\n\tfileName := \"frontend/app/store/services.ts\"\n\tvar buf bytes.Buffer\n\tfmt.Fprintf(os.Stderr, \"generating services file to %s\\n\", fileName)\n\tfmt.Fprintf(&buf, \"// Copyright 2026, Command Line Inc.\\n\")\n\tfmt.Fprintf(&buf, \"// SPDX-License-Identifier: Apache-2.0\\n\\n\")\n\tfmt.Fprintf(&buf, \"// generated by cmd/generate/main-generatets.go\\n\\n\")\n\tfmt.Fprintf(&buf, \"import * as WOS from \\\"./wos\\\";\\n\")\n\tfmt.Fprintf(&buf, \"import type { WaveEnv } from \\\"@/app/waveenv/waveenv\\\";\\n\\n\")\n\tfmt.Fprintf(&buf, \"function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise<any> {\\n\")\n\tfmt.Fprintf(&buf, \"    if (waveEnv != null) {\\n\")\n\tfmt.Fprintf(&buf, \"        return waveEnv.callBackendService(service, method, args, noUIContext)\\n\")\n\tfmt.Fprintf(&buf, \"    }\\n\")\n\tfmt.Fprintf(&buf, \"    return WOS.callBackendService(service, method, args, noUIContext);\\n\")\n\tfmt.Fprintf(&buf, \"}\\n\\n\")\n\torderedKeys := utilfn.GetOrderedMapKeys(service.ServiceMap)\n\tfor _, serviceName := range orderedKeys {\n\t\tserviceObj := service.ServiceMap[serviceName]\n\t\tsvcStr := tsgen.GenerateServiceClass(serviceName, serviceObj, tsTypesMap)\n\t\tfmt.Fprint(&buf, svcStr)\n\t\tfmt.Fprint(&buf, \"\\n\")\n\t}\n\tfmt.Fprintf(&buf, \"export const AllServiceTypes = {\\n\")\n\tfor _, serviceName := range orderedKeys {\n\t\tserviceObj := service.ServiceMap[serviceName]\n\t\tserviceType := reflect.TypeOf(serviceObj)\n\t\ttsServiceName := serviceType.Elem().Name()\n\t\tfmt.Fprintf(&buf, \"    %q: %sType,\\n\", serviceName, tsServiceName)\n\t}\n\tfmt.Fprintf(&buf, \"};\\n\\n\")\n\tfmt.Fprintf(&buf, \"export const AllServiceImpls = {\\n\")\n\tfor _, serviceName := range orderedKeys {\n\t\tserviceObj := service.ServiceMap[serviceName]\n\t\tserviceType := reflect.TypeOf(serviceObj)\n\t\ttsServiceName := serviceType.Elem().Name()\n\t\tfmt.Fprintf(&buf, \"    %q: %s,\\n\", serviceName, tsServiceName)\n\t}\n\tfmt.Fprintf(&buf, \"};\\n\")\n\twritten, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes())\n\tif !written {\n\t\tfmt.Fprintf(os.Stderr, \"no changes to %s\\n\", fileName)\n\t}\n\treturn err\n}\n\nfunc generateWshClientApiFile(tsTypeMap map[reflect.Type]string) error {\n\tfileName := \"frontend/app/store/wshclientapi.ts\"\n\tvar buf bytes.Buffer\n\tdeclMap := wshrpc.GenerateWshCommandDeclMap()\n\tfmt.Fprintf(os.Stderr, \"generating wshclientapi file to %s\\n\", fileName)\n\tfmt.Fprintf(&buf, \"// Copyright 2026, Command Line Inc.\\n\")\n\tfmt.Fprintf(&buf, \"// SPDX-License-Identifier: Apache-2.0\\n\\n\")\n\tfmt.Fprintf(&buf, \"// generated by cmd/generate/main-generatets.go\\n\\n\")\n\tfmt.Fprintf(&buf, \"import { WshClient } from \\\"./wshclient\\\";\\n\\n\")\n\tfmt.Fprintf(&buf, \"export interface MockRpcClient {\\n\")\n\tfmt.Fprintf(&buf, \"    mockWshRpcCall(client: WshClient, command: string, data: any, opts?: RpcOpts): Promise<any>;\\n\")\n\tfmt.Fprintf(&buf, \"    mockWshRpcStream(client: WshClient, command: string, data: any, opts?: RpcOpts): AsyncGenerator<any, void, boolean>;\\n\")\n\tfmt.Fprintf(&buf, \"}\\n\\n\")\n\torderedKeys := utilfn.GetOrderedMapKeys(declMap)\n\tfmt.Fprintf(&buf, \"// WshServerCommandToDeclMap\\n\")\n\tfmt.Fprintf(&buf, \"export class RpcApiType {\\n\")\n\tfmt.Fprintf(&buf, \"    mockClient: MockRpcClient = null;\\n\\n\")\n\tfmt.Fprintf(&buf, \"    setMockRpcClient(client: MockRpcClient): void {\\n\")\n\tfmt.Fprintf(&buf, \"        this.mockClient = client;\\n\")\n\tfmt.Fprintf(&buf, \"    }\\n\\n\")\n\tfor _, methodDecl := range orderedKeys {\n\t\tmethodDecl := declMap[methodDecl]\n\t\tmethodStr := tsgen.GenerateWshClientApiMethod(methodDecl, tsTypeMap)\n\t\tfmt.Fprint(&buf, methodStr)\n\t\tfmt.Fprintf(&buf, \"\\n\")\n\t}\n\tfmt.Fprintf(&buf, \"}\\n\\n\")\n\tfmt.Fprintf(&buf, \"export const RpcApi = new RpcApiType();\\n\")\n\twritten, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes())\n\tif !written {\n\t\tfmt.Fprintf(os.Stderr, \"no changes to %s\\n\", fileName)\n\t}\n\treturn err\n}\n\nfunc main() {\n\terr := service.ValidateServiceMap()\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"Error validating service map: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\ttsTypesMap := make(map[reflect.Type]string)\n\terr = generateTypesFile(tsTypesMap)\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"Error generating types file: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\terr = generateServicesFile(tsTypesMap)\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"Error generating services file: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\terr = generateWaveEventFile(tsTypesMap)\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"Error generating wave event file: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\terr = generateWshClientApiFile(tsTypesMap)\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"Error generating wshserver file: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "cmd/packfiles/main-packfiles.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\t// Ensure at least one argument is provided\n\tif len(os.Args) < 2 {\n\t\tfmt.Fprintln(os.Stderr, \"Usage: go run main.go <file1> <file2> ...\")\n\t\tos.Exit(1)\n\t}\n\n\t// Get the current working directory\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"Error getting current working directory: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tfor _, filePath := range os.Args[1:] {\n\t\tif filePath == \"\" || filePath == \"--\" {\n\t\t\tcontinue\n\t\t}\n\t\t// Convert file path to an absolute path\n\t\tabsPath, err := filepath.Abs(filePath)\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Error resolving absolute path for %q: %v\\n\", filePath, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tfinfo, err := os.Stat(absPath)\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Error getting file info for %q: %v\\n\", absPath, err)\n\t\t\tcontinue\n\t\t}\n\t\tif finfo.IsDir() {\n\t\t\tfmt.Fprintf(os.Stderr, \"%q is a directory, skipping\\n\", absPath)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Get the path relative to the current working directory\n\t\trelPath, err := filepath.Rel(cwd, absPath)\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Error resolving relative path for %q: %v\\n\", absPath, err)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Open the file\n\t\tfile, err := os.Open(absPath)\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Error opening file %q: %v\\n\", absPath, err)\n\t\t\tcontinue\n\t\t}\n\t\tdefer file.Close()\n\n\t\t// Print start delimiter with quoted relative path\n\t\tfmt.Printf(\"@@@start file %q\\n\", relPath)\n\n\t\t// Copy file contents to stdout\n\t\treader := bufio.NewReader(file)\n\t\tfor {\n\t\t\tline, err := reader.ReadString('\\n')\n\t\t\tfmt.Print(line) // Print each line\n\t\t\tif err == io.EOF {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"Error reading file %q: %v\\n\", relPath, err)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// Print end delimiter with quoted relative path\n\t\tfmt.Printf(\"@@@end file %q\\n\", relPath)\n\t}\n}\n"
  },
  {
    "path": "cmd/server/main-server.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\n\t\"runtime\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/joho/godotenv\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat\"\n\t\"github.com/wavetermdev/waveterm/pkg/authkey\"\n\t\"github.com/wavetermdev/waveterm/pkg/blockcontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/blocklogger\"\n\t\"github.com/wavetermdev/waveterm/pkg/filebackup\"\n\t\"github.com/wavetermdev/waveterm/pkg/filestore\"\n\t\"github.com/wavetermdev/waveterm/pkg/jobcontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/conncontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs\"\n\t\"github.com/wavetermdev/waveterm/pkg/secretstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/service\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/envutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/sigutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcloud\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcore\"\n\t\"github.com/wavetermdev/waveterm/pkg/web\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wslconn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n\n\t\"net/http\"\n\t_ \"net/http/pprof\"\n)\n\n// these are set at build time\nvar WaveVersion = \"0.0.0\"\nvar BuildTime = \"0\"\n\nconst InitialTelemetryWait = 10 * time.Second\nconst TelemetryTick = 2 * time.Minute\nconst TelemetryInterval = 4 * time.Hour\nconst TelemetryInitialCountsWait = 5 * time.Second\nconst TelemetryCountsInterval = 1 * time.Hour\nconst BackupCleanupTick = 2 * time.Minute\nconst BackupCleanupInterval = 4 * time.Hour\nconst InitialDiagnosticWait = 5 * time.Minute\nconst DiagnosticTick = 10 * time.Minute\n\nvar shutdownOnce sync.Once\n\nfunc init() {\n\tenvFilePath := os.Getenv(\"WAVETERM_ENVFILE\")\n\tif envFilePath != \"\" {\n\t\tlog.Printf(\"applying env file: %s\\n\", envFilePath)\n\t\t_ = godotenv.Load(envFilePath)\n\t}\n}\n\nfunc doShutdown(reason string) {\n\tshutdownOnce.Do(func() {\n\t\tlog.Printf(\"shutting down: %s\\n\", reason)\n\t\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer cancelFn()\n\t\tgo blockcontroller.StopAllBlockControllersForShutdown()\n\t\tshutdownActivityUpdate()\n\t\tsendTelemetryWrapper()\n\t\t// TODO deal with flush in progress\n\t\tclearTempFiles()\n\t\tfilestore.WFS.FlushCache(ctx)\n\t\twatcher := wconfig.GetWatcher()\n\t\tif watcher != nil {\n\t\t\twatcher.Close()\n\t\t}\n\t\ttime.Sleep(500 * time.Millisecond)\n\t\tlog.Printf(\"shutdown complete\\n\")\n\t\tos.Exit(0)\n\t})\n}\n\n// watch stdin, kill server if stdin is closed\nfunc stdinReadWatch() {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"stdinReadWatch\", recover())\n\t}()\n\tbuf := make([]byte, 1024)\n\tfor {\n\t\t_, err := os.Stdin.Read(buf)\n\t\tif err != nil {\n\t\t\tdoShutdown(fmt.Sprintf(\"stdin closed/error (%v)\", err))\n\t\t\tbreak\n\t\t}\n\t}\n}\n\nfunc startConfigWatcher() {\n\twatcher := wconfig.GetWatcher()\n\tif watcher != nil {\n\t\twatcher.Start()\n\t}\n}\n\nfunc telemetryLoop() {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"telemetryLoop\", recover())\n\t}()\n\tvar nextSend int64\n\ttime.Sleep(InitialTelemetryWait)\n\tfor {\n\t\tif time.Now().Unix() > nextSend {\n\t\t\tnextSend = time.Now().Add(TelemetryInterval).Unix()\n\t\t\tsendTelemetryWrapper()\n\t\t}\n\t\ttime.Sleep(TelemetryTick)\n\t}\n}\n\nfunc diagnosticLoop() {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"diagnosticLoop\", recover())\n\t}()\n\tif os.Getenv(\"WAVETERM_NOPING\") != \"\" {\n\t\tlog.Printf(\"WAVETERM_NOPING set, disabling diagnostic ping\\n\")\n\t\treturn\n\t}\n\tvar lastSentDate string\n\ttime.Sleep(InitialDiagnosticWait)\n\tfor {\n\t\tcurrentDate := time.Now().Format(\"2006-01-02\")\n\t\tif lastSentDate == \"\" || lastSentDate != currentDate {\n\t\t\tif sendDiagnosticPing() {\n\t\t\t\tlastSentDate = currentDate\n\t\t\t}\n\t\t}\n\t\ttime.Sleep(DiagnosticTick)\n\t}\n}\n\nfunc sendDiagnosticPing() bool {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\n\trpcClient := wshclient.GetBareRpcClient()\n\tisOnline, err := wshclient.NetworkOnlineCommand(rpcClient, &wshrpc.RpcOpts{Route: \"electron\", Timeout: 2000})\n\tif err != nil || !isOnline {\n\t\treturn false\n\t}\n\tclientId := wstore.GetClientId()\n\tusageTelemetry := telemetry.IsTelemetryEnabled()\n\twcloud.SendDiagnosticPing(ctx, clientId, usageTelemetry)\n\treturn true\n}\n\nfunc setupTelemetryConfigHandler() {\n\twatcher := wconfig.GetWatcher()\n\tif watcher == nil {\n\t\treturn\n\t}\n\tcurrentConfig := watcher.GetFullConfig()\n\tcurrentTelemetryEnabled := currentConfig.Settings.TelemetryEnabled\n\n\twatcher.RegisterUpdateHandler(func(newConfig wconfig.FullConfigType) {\n\t\tnewTelemetryEnabled := newConfig.Settings.TelemetryEnabled\n\t\tif newTelemetryEnabled != currentTelemetryEnabled {\n\t\t\tcurrentTelemetryEnabled = newTelemetryEnabled\n\t\t\twcore.GoSendNoTelemetryUpdate(newTelemetryEnabled)\n\t\t}\n\t})\n}\n\nfunc backupCleanupLoop() {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"backupCleanupLoop\", recover())\n\t}()\n\tvar nextCleanup int64\n\tfor {\n\t\tif time.Now().Unix() > nextCleanup {\n\t\t\tnextCleanup = time.Now().Add(BackupCleanupInterval).Unix()\n\t\t\terr := filebackup.CleanupOldBackups()\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"error cleaning up old backups: %v\\n\", err)\n\t\t\t}\n\t\t}\n\t\ttime.Sleep(BackupCleanupTick)\n\t}\n}\n\nfunc panicTelemetryHandler(panicName string) {\n\tactivity := wshrpc.ActivityUpdate{NumPanics: 1}\n\terr := telemetry.UpdateActivity(context.Background(), activity)\n\tif err != nil {\n\t\tlog.Printf(\"error updating activity (panicTelemetryHandler): %v\\n\", err)\n\t}\n\ttelemetry.RecordTEvent(context.Background(), telemetrydata.MakeTEvent(\"debug:panic\", telemetrydata.TEventProps{\n\t\tPanicType: panicName,\n\t}))\n}\n\nfunc sendTelemetryWrapper() {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"sendTelemetryWrapper\", recover())\n\t}()\n\tctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancelFn()\n\tbeforeSendActivityUpdate(ctx)\n\tclientId := wstore.GetClientId()\n\terr := wcloud.SendAllTelemetry(clientId)\n\tif err != nil {\n\t\tlog.Printf(\"[error] sending telemetry: %v\\n\", err)\n\t}\n}\n\nfunc updateTelemetryCounts(lastCounts telemetrydata.TEventProps) telemetrydata.TEventProps {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tvar props telemetrydata.TEventProps\n\tprops.CountBlocks, _ = wstore.DBGetCount[*waveobj.Block](ctx)\n\tprops.CountTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx)\n\tprops.CountWindows, _ = wstore.DBGetCount[*waveobj.Window](ctx)\n\tprops.CountWorkspaces, _, _ = wstore.DBGetWSCounts(ctx)\n\tprops.CountSSHConn = conncontroller.GetNumSSHHasConnected()\n\tprops.CountWSLConn = wslconn.GetNumWSLHasConnected()\n\tprops.CountJobs = jobcontroller.GetNumJobsRunning()\n\tprops.CountJobsConnected = jobcontroller.GetNumJobsConnected()\n\tprops.CountViews, _ = wstore.DBGetBlockViewCounts(ctx)\n\n\tfullConfig := wconfig.GetWatcher().GetFullConfig()\n\tcustomWidgets := fullConfig.CountCustomWidgets()\n\tcustomAIPresets := fullConfig.CountCustomAIPresets()\n\tcustomSettings := wconfig.CountCustomSettings()\n\tcustomAIModes := fullConfig.CountCustomAIModes()\n\n\tprops.UserSet = &telemetrydata.TEventUserProps{\n\t\tSettingsCustomWidgets:   customWidgets,\n\t\tSettingsCustomAIPresets: customAIPresets,\n\t\tSettingsCustomSettings:  customSettings,\n\t\tSettingsCustomAIModes:   customAIModes,\n\t}\n\n\tsecretsCount, err := secretstore.CountSecrets()\n\tif err == nil {\n\t\tprops.UserSet.SettingsSecretsCount = secretsCount\n\t}\n\n\tif utilfn.CompareAsMarshaledJson(props, lastCounts) {\n\t\treturn lastCounts\n\t}\n\ttevent := telemetrydata.MakeTEvent(\"app:counts\", props)\n\terr = telemetry.RecordTEvent(ctx, tevent)\n\tif err != nil {\n\t\tlog.Printf(\"error recording counts tevent: %v\\n\", err)\n\t}\n\treturn props\n}\n\nfunc updateTelemetryCountsLoop() {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"updateTelemetryCountsLoop\", recover())\n\t}()\n\tvar nextSend int64\n\tvar lastCounts telemetrydata.TEventProps\n\ttime.Sleep(TelemetryInitialCountsWait)\n\tfor {\n\t\tif time.Now().Unix() > nextSend {\n\t\t\tnextSend = time.Now().Add(TelemetryCountsInterval).Unix()\n\t\t\tlastCounts = updateTelemetryCounts(lastCounts)\n\t\t}\n\t\ttime.Sleep(TelemetryTick)\n\t}\n}\n\nfunc beforeSendActivityUpdate(ctx context.Context) {\n\tactivity := wshrpc.ActivityUpdate{}\n\tactivity.NumTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx)\n\tactivity.NumBlocks, _ = wstore.DBGetCount[*waveobj.Block](ctx)\n\tactivity.Blocks, _ = wstore.DBGetBlockViewCounts(ctx)\n\tactivity.NumWindows, _ = wstore.DBGetCount[*waveobj.Window](ctx)\n\tactivity.NumSSHConn = conncontroller.GetNumSSHHasConnected()\n\tactivity.NumWSLConn = wslconn.GetNumWSLHasConnected()\n\tactivity.NumWSNamed, activity.NumWS, _ = wstore.DBGetWSCounts(ctx)\n\terr := telemetry.UpdateActivity(ctx, activity)\n\tif err != nil {\n\t\tlog.Printf(\"error updating before activity: %v\\n\", err)\n\t}\n}\n\nfunc startupActivityUpdate(firstLaunch bool) {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"startupActivityUpdate\", recover())\n\t}()\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tactivity := wshrpc.ActivityUpdate{Startup: 1}\n\terr := telemetry.UpdateActivity(ctx, activity) // set at least one record into activity (don't use go routine wrap here)\n\tif err != nil {\n\t\tlog.Printf(\"error updating startup activity: %v\\n\", err)\n\t}\n\tautoUpdateChannel := telemetry.AutoUpdateChannel()\n\tautoUpdateEnabled := telemetry.IsAutoUpdateEnabled()\n\tshellType, shellVersion, shellErr := shellutil.DetectShellTypeAndVersion()\n\tif shellErr != nil {\n\t\tshellType = \"error\"\n\t\tshellVersion = \"\"\n\t}\n\tuserSetOnce := &telemetrydata.TEventUserProps{\n\t\tClientInitialVersion: \"v\" + WaveVersion,\n\t}\n\ttosTs := telemetry.GetTosAgreedTs()\n\tvar cohortTime time.Time\n\tif tosTs > 0 {\n\t\tcohortTime = time.UnixMilli(tosTs)\n\t} else {\n\t\tcohortTime = time.Now()\n\t}\n\tcohortMonth := cohortTime.Format(\"2006-01\")\n\tyear, week := cohortTime.ISOWeek()\n\tcohortISOWeek := fmt.Sprintf(\"%04d-W%02d\", year, week)\n\tuserSetOnce.CohortMonth = cohortMonth\n\tuserSetOnce.CohortISOWeek = cohortISOWeek\n\tfullConfig := wconfig.GetWatcher().GetFullConfig()\n\tprops := telemetrydata.TEventProps{\n\t\tUserSet: &telemetrydata.TEventUserProps{\n\t\t\tClientVersion:       \"v\" + wavebase.WaveVersion,\n\t\t\tClientBuildTime:     wavebase.BuildTime,\n\t\t\tClientArch:          wavebase.ClientArch(),\n\t\t\tClientOSRelease:     wavebase.UnameKernelRelease(),\n\t\t\tClientIsDev:         wavebase.IsDevMode(),\n\t\t\tClientPackageType:   wavebase.ClientPackageType(),\n\t\t\tClientMacOSVersion:  wavebase.ClientMacOSVersion(),\n\t\t\tAutoUpdateChannel:   autoUpdateChannel,\n\t\t\tAutoUpdateEnabled:   autoUpdateEnabled,\n\t\t\tLocalShellType:      shellType,\n\t\t\tLocalShellVersion:   shellVersion,\n\t\t\tSettingsTransparent: fullConfig.Settings.WindowTransparent,\n\t\t},\n\t\tUserSetOnce: userSetOnce,\n\t}\n\tif firstLaunch {\n\t\tprops.AppFirstLaunch = true\n\t}\n\ttevent := telemetrydata.MakeTEvent(\"app:startup\", props)\n\terr = telemetry.RecordTEvent(ctx, tevent)\n\tif err != nil {\n\t\tlog.Printf(\"error recording startup event: %v\\n\", err)\n\t}\n}\n\nfunc shutdownActivityUpdate() {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 1*time.Second)\n\tdefer cancelFn()\n\tactivity := wshrpc.ActivityUpdate{Shutdown: 1}\n\terr := telemetry.UpdateActivity(ctx, activity) // do NOT use the go routine wrap here (this needs to be synchronous)\n\tif err != nil {\n\t\tlog.Printf(\"error updating shutdown activity: %v\\n\", err)\n\t}\n\terr = telemetry.TruncateActivityTEventForShutdown(ctx)\n\tif err != nil {\n\t\tlog.Printf(\"error truncating activity t-event for shutdown: %v\\n\", err)\n\t}\n\ttevent := telemetrydata.MakeTEvent(\"app:shutdown\", telemetrydata.TEventProps{})\n\terr = telemetry.RecordTEvent(ctx, tevent)\n\tif err != nil {\n\t\tlog.Printf(\"error recording shutdown event: %v\\n\", err)\n\t}\n}\n\nfunc createMainWshClient() {\n\trpc := wshserver.GetMainRpcClient()\n\twshfs.RpcClient = rpc\n\twshutil.DefaultRouter.RegisterTrustedLeaf(rpc, wshutil.DefaultRoute)\n\twps.Broker.SetClient(wshutil.DefaultRouter)\n\tlocalInitialEnv := envutil.PruneInitialEnv(envutil.SliceToMap(os.Environ()))\n\tsockName := wavebase.GetDomainSocketName()\n\tremoteImpl := wshremote.MakeRemoteRpcServerImpl(nil, wshutil.DefaultRouter, wshclient.GetBareRpcClient(), true, localInitialEnv, sockName)\n\tlocalConnWsh := wshutil.MakeWshRpc(wshrpc.RpcContext{Conn: wshrpc.LocalConnName}, remoteImpl, \"conn:local\")\n\tgo wshremote.RunSysInfoLoop(localConnWsh, wshrpc.LocalConnName)\n\twshutil.DefaultRouter.RegisterTrustedLeaf(localConnWsh, wshutil.MakeConnectionRouteId(wshrpc.LocalConnName))\n}\n\nfunc grabAndRemoveEnvVars() error {\n\terr := authkey.SetAuthKeyFromEnv()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"setting auth key: %v\", err)\n\t}\n\terr = wavebase.CacheAndRemoveEnvVars()\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = wcloud.CacheAndRemoveEnvVars()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Remove WAVETERM env vars that leak from prod => dev\n\tos.Unsetenv(\"WAVETERM_CLIENTID\")\n\tos.Unsetenv(\"WAVETERM_WORKSPACEID\")\n\tos.Unsetenv(\"WAVETERM_TABID\")\n\tos.Unsetenv(\"WAVETERM_BLOCKID\")\n\tos.Unsetenv(\"WAVETERM_CONN\")\n\tos.Unsetenv(\"WAVETERM_JWT\")\n\tos.Unsetenv(\"WAVETERM_VERSION\")\n\n\treturn nil\n}\n\nfunc clearTempFiles() error {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\tclient, err := wstore.DBGetSingleton[*waveobj.Client](ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting client: %v\", err)\n\t}\n\tfilestore.WFS.DeleteZone(ctx, client.TempOID)\n\treturn nil\n}\n\nfunc maybeStartPprofServer() {\n\tsettings := wconfig.GetWatcher().GetFullConfig().Settings\n\tif settings.DebugPprofMemProfileRate != nil {\n\t\truntime.MemProfileRate = *settings.DebugPprofMemProfileRate\n\t\tlog.Printf(\"set runtime.MemProfileRate to %d\\n\", runtime.MemProfileRate)\n\t}\n\tif settings.DebugPprofPort == nil {\n\t\treturn\n\t}\n\tpprofPort := *settings.DebugPprofPort\n\tif pprofPort < 1 || pprofPort > 65535 {\n\t\tlog.Printf(\"[error] debug:pprofport must be between 1 and 65535, got %d\\n\", pprofPort)\n\t\treturn\n\t}\n\tgo func() {\n\t\taddr := fmt.Sprintf(\"localhost:%d\", pprofPort)\n\t\tlog.Printf(\"starting pprof server on %s\\n\", addr)\n\t\tif err := http.ListenAndServe(addr, nil); err != nil {\n\t\t\tlog.Printf(\"[error] pprof server failed: %v\\n\", err)\n\t\t}\n\t}()\n}\n\nfunc main() {\n\tlog.SetFlags(0) // disable timestamp since electron's winston logger already wraps with timestamp\n\tlog.SetPrefix(\"[wavesrv] \")\n\twavebase.WaveVersion = WaveVersion\n\twavebase.BuildTime = BuildTime\n\twshutil.DefaultRouter = wshutil.NewWshRouter()\n\twshutil.DefaultRouter.SetAsRootRouter()\n\n\terr := grabAndRemoveEnvVars()\n\tif err != nil {\n\t\tlog.Printf(\"[error] %v\\n\", err)\n\t\treturn\n\t}\n\terr = service.ValidateServiceMap()\n\tif err != nil {\n\t\tlog.Printf(\"error validating service map: %v\\n\", err)\n\t\treturn\n\t}\n\terr = wavebase.EnsureWaveDataDir()\n\tif err != nil {\n\t\tlog.Printf(\"error ensuring wave home dir: %v\\n\", err)\n\t\treturn\n\t}\n\terr = wavebase.EnsureWaveDBDir()\n\tif err != nil {\n\t\tlog.Printf(\"error ensuring wave db dir: %v\\n\", err)\n\t\treturn\n\t}\n\terr = wavebase.EnsureWaveConfigDir()\n\tif err != nil {\n\t\tlog.Printf(\"error ensuring wave config dir: %v\\n\", err)\n\t\treturn\n\t}\n\n\t// TODO: rather than ensure this dir exists, we should let the editor recursively create parent dirs on save\n\terr = wavebase.EnsureWavePresetsDir()\n\tif err != nil {\n\t\tlog.Printf(\"error ensuring wave presets dir: %v\\n\", err)\n\t\treturn\n\t}\n\terr = wavebase.EnsureWaveCachesDir()\n\tif err != nil {\n\t\tlog.Printf(\"error ensuring wave caches dir: %v\\n\", err)\n\t\treturn\n\t}\n\twaveLock, err := wavebase.AcquireWaveLock()\n\tif err != nil {\n\t\tlog.Printf(\"error acquiring wave lock (another instance of Wave is likely running): %v\\n\", err)\n\t\treturn\n\t}\n\tdefer func() {\n\t\terr = waveLock.Close()\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error releasing wave lock: %v\\n\", err)\n\t\t}\n\t}()\n\tlog.Printf(\"wave version: %s (%s)\\n\", WaveVersion, BuildTime)\n\tlog.Printf(\"wave data dir: %s\\n\", wavebase.GetWaveDataDir())\n\tlog.Printf(\"wave config dir: %s\\n\", wavebase.GetWaveConfigDir())\n\terr = filestore.InitFilestore()\n\tif err != nil {\n\t\tlog.Printf(\"error initializing filestore: %v\\n\", err)\n\t\treturn\n\t}\n\terr = wstore.InitWStore()\n\tif err != nil {\n\t\tlog.Printf(\"error initializing wstore: %v\\n\", err)\n\t\treturn\n\t}\n\tpanichandler.PanicTelemetryHandler = panicTelemetryHandler\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"InitCustomShellStartupFiles\", recover())\n\t\t}()\n\t\terr := shellutil.InitCustomShellStartupFiles()\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error initializing wsh and shell-integration files: %v\\n\", err)\n\t\t}\n\t}()\n\tfirstLaunch, err := wcore.EnsureInitialData()\n\tif err != nil {\n\t\tlog.Printf(\"error ensuring initial data: %v\\n\", err)\n\t\treturn\n\t}\n\tif firstLaunch {\n\t\tlog.Printf(\"first launch detected\")\n\t}\n\terr = clearTempFiles()\n\tif err != nil {\n\t\tlog.Printf(\"error clearing temp files: %v\\n\", err)\n\t\treturn\n\t}\n\terr = wcore.InitMainServer()\n\tif err != nil {\n\t\tlog.Printf(\"error initializing mainserver: %v\\n\", err)\n\t\treturn\n\t}\n\n\terr = shellutil.FixupWaveZshHistory()\n\tif err != nil {\n\t\tlog.Printf(\"error fixing up wave zsh history: %v\\n\", err)\n\t}\n\tcreateMainWshClient()\n\tsigutil.InstallShutdownSignalHandlers(doShutdown)\n\tsigutil.InstallSIGUSR1Handler()\n\tstartConfigWatcher()\n\taiusechat.InitAIModeConfigWatcher()\n\tmaybeStartPprofServer()\n\tgo stdinReadWatch()\n\tgo telemetryLoop()\n\tgo diagnosticLoop()\n\tsetupTelemetryConfigHandler()\n\tgo updateTelemetryCountsLoop()\n\tgo backupCleanupLoop()\n\tgo startupActivityUpdate(firstLaunch) // must be after startConfigWatcher()\n\tblocklogger.InitBlockLogger()\n\tjobcontroller.InitJobController()\n\tblockcontroller.InitBlockController()\n\terr = wcore.InitBadgeStore()\n\tif err != nil {\n\t\tlog.Printf(\"error initializing badge store: %v\\n\", err)\n\t\treturn\n\t}\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"GetSystemSummary\", recover())\n\t\t}()\n\t\twavebase.GetSystemSummary()\n\t}()\n\n\twebListener, err := web.MakeTCPListener(\"web\")\n\tif err != nil {\n\t\tlog.Printf(\"error creating web listener: %v\\n\", err)\n\t\treturn\n\t}\n\twsListener, err := web.MakeTCPListener(\"websocket\")\n\tif err != nil {\n\t\tlog.Printf(\"error creating websocket listener: %v\\n\", err)\n\t\treturn\n\t}\n\tgo web.RunWebSocketServer(wsListener)\n\tunixListener, err := web.MakeUnixListener()\n\tif err != nil {\n\t\tlog.Printf(\"error creating unix listener: %v\\n\", err)\n\t\treturn\n\t}\n\tgo func() {\n\t\tif BuildTime == \"\" {\n\t\t\tBuildTime = \"0\"\n\t\t}\n\t\t// use fmt instead of log here to make sure it goes directly to stderr\n\t\tfmt.Fprintf(os.Stderr, \"WAVESRV-ESTART ws:%s web:%s version:%s buildtime:%s\\n\", wsListener.Addr(), webListener.Addr(), WaveVersion, BuildTime)\n\t}()\n\tgo wshutil.RunWshRpcOverListener(unixListener, nil)\n\tweb.RunWebServer(webListener) // blocking\n\truntime.KeepAlive(waveLock)\n}\n"
  },
  {
    "path": "cmd/test/test-main.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nfunc main() {\n\n}\n"
  },
  {
    "path": "cmd/test-conn/cliprovider.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/userinput\"\n)\n\ntype CLIProvider struct {\n\tAutoAccept bool\n}\n\nfunc (p *CLIProvider) GetUserInput(ctx context.Context, request *userinput.UserInputRequest) (*userinput.UserInputResponse, error) {\n\tresponse := &userinput.UserInputResponse{\n\t\tType:      request.ResponseType,\n\t\tRequestId: request.RequestId,\n\t}\n\n\tif request.Title != \"\" {\n\t\tfmt.Printf(\"\\n=== %s ===\\n\", request.Title)\n\t}\n\tfmt.Printf(\"%s\\n\", request.QueryText)\n\n\tif p.AutoAccept {\n\t\tfmt.Printf(\"Auto-accepting (use -i for interactive mode)\\n\")\n\t\tresponse.Confirm = true\n\t\tresponse.Text = \"yes\"\n\t\treturn response, nil\n\t}\n\n\treader := bufio.NewReader(os.Stdin)\n\tfmt.Printf(\"Accept? [y/n]: \")\n\ttext, err := reader.ReadString('\\n')\n\tif err != nil {\n\t\tresponse.ErrorMsg = fmt.Sprintf(\"error reading input: %v\", err)\n\t\treturn response, err\n\t}\n\n\ttext = strings.TrimSpace(strings.ToLower(text))\n\tif text == \"y\" || text == \"yes\" {\n\t\tresponse.Confirm = true\n\t\tresponse.Text = \"yes\"\n\t} else {\n\t\tresponse.Confirm = false\n\t\tresponse.Text = \"no\"\n\t}\n\n\treturn response, nil\n}\n"
  },
  {
    "path": "cmd/test-conn/main-test-conn.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"time\"\n)\n\nvar (\n\tWaveVersion = \"0.0.0\"\n\tBuildTime   = \"0\"\n)\n\nfunc usage() {\n\tfmt.Fprintf(os.Stderr, `Test Harness for SSH Connection Flows\n\nUsage:\n  test-conn [flags] <command> <user@host> [args...]\n\nCommands:\n  connect <user@host>            - Test basic SSH connection with wsh\n  ssh <user@host>                - Test basic SSH connection\n  exec <user@host> <command>     - Execute command and show output (no wsh)\n  wshexec <user@host> <command>  - Execute command with wsh enabled\n  shell <user@host>              - Start interactive shell session\n\nFlags:\n  -t duration  Connection timeout (default: 60s)\n  -i           Interactive mode (prompt for user input instead of auto-accept)\n  -v           Show version and exit\n\nExamples:\n  test-conn ssh user@example.com\n  test-conn exec user@example.com \"ls -la\"\n  test-conn wshexec user@example.com \"wsh version\"\n  test-conn -i connect user@example.com\n  test-conn shell user@example.com\n\n`)\n\tos.Exit(1)\n}\n\nfunc main() {\n\ttimeoutFlag := flag.Duration(\"t\", 60*time.Second, \"connection timeout\")\n\tinteractiveFlag := flag.Bool(\"i\", false, \"interactive mode (prompt for user input)\")\n\tversionFlag := flag.Bool(\"v\", false, \"show version\")\n\n\tflag.Usage = usage\n\tflag.Parse()\n\n\tif *versionFlag {\n\t\tfmt.Printf(\"test-conn version %s (built %s)\\n\", WaveVersion, BuildTime)\n\t\tos.Exit(0)\n\t}\n\n\targs := flag.Args()\n\tif len(args) < 2 {\n\t\tusage()\n\t}\n\n\tcommand := args[0]\n\tconnName := args[1]\n\n\tautoAccept := !*interactiveFlag\n\n\terr := initTestHarness(autoAccept)\n\tif err != nil {\n\t\tlog.Fatalf(\"Failed to initialize: %v\", err)\n\t}\n\n\tswitch command {\n\tcase \"ssh\", \"connect\":\n\t\terr = testBasicConnect(connName, *timeoutFlag)\n\n\tcase \"exec\":\n\t\tif len(args) < 3 {\n\t\t\tlog.Fatalf(\"exec command requires a command argument\")\n\t\t}\n\t\tcmd := args[2]\n\t\terr = testShellWithCommand(connName, cmd, *timeoutFlag)\n\n\tcase \"wshexec\":\n\t\tif len(args) < 3 {\n\t\t\tlog.Fatalf(\"wshexec command requires a command argument\")\n\t\t}\n\t\tcmd := args[2]\n\t\terr = testWshExec(connName, cmd, *timeoutFlag)\n\n\tcase \"shell\":\n\t\terr = testInteractiveShell(connName, *timeoutFlag)\n\n\tdefault:\n\t\tlog.Fatalf(\"Unknown command: %s\", command)\n\t}\n\n\tif err != nil {\n\t\tlog.Fatalf(\"Error: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "cmd/test-conn/testutil.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/conncontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/shellexec\"\n\t\"github.com/wavetermdev/waveterm/pkg/userinput\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavejwt\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nfunc setupWaveEnvVars() error {\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get home directory: %w\", err)\n\t}\n\n\tisDev := os.Getenv(\"WAVETERM_DEV\") != \"\"\n\tdevSuffix := \"\"\n\tif isDev {\n\t\tdevSuffix = \"-dev\"\n\t}\n\n\tconfigHome := os.Getenv(\"WAVETERM_CONFIG_HOME\")\n\tif configHome == \"\" {\n\t\tconfigHome = filepath.Join(homeDir, \".config\", \"waveterm\"+devSuffix)\n\t\tos.Setenv(\"WAVETERM_CONFIG_HOME\", configHome)\n\t}\n\tlog.Printf(\"Using config directory: %s\", configHome)\n\n\tdataHome := os.Getenv(\"WAVETERM_DATA_HOME\")\n\tif dataHome == \"\" {\n\t\tif runtime.GOOS == \"darwin\" {\n\t\t\tdataHome = filepath.Join(homeDir, \"Library\", \"Application Support\", \"waveterm\"+devSuffix)\n\t\t\tos.Setenv(\"WAVETERM_DATA_HOME\", dataHome)\n\t\t} else {\n\t\t\treturn fmt.Errorf(\"WAVETERM_DATA_HOME must be set on non-macOS systems\")\n\t\t}\n\t}\n\tlog.Printf(\"Using data directory: %s\", dataHome)\n\n\treturn nil\n}\n\nfunc initTestHarness(autoAccept bool) error {\n\tlog.Printf(\"Initializing test harness...\")\n\n\terr := setupWaveEnvVars()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to setup wave env vars: %w\", err)\n\t}\n\n\terr = wavebase.CacheAndRemoveEnvVars()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to cache env vars: %w\", err)\n\t}\n\n\twshutil.DefaultRouter = wshutil.NewWshRouter()\n\twshutil.DefaultRouter.SetAsRootRouter()\n\n\twstore.SetClientId(\"test-client-\" + fmt.Sprintf(\"%d\", time.Now().Unix()))\n\n\tuserinput.SetUserInputProvider(&CLIProvider{AutoAccept: autoAccept})\n\n\tkeyPair, err := wavejwt.GenerateKeyPair()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to generate JWT key pair: %w\", err)\n\t}\n\n\terr = wavejwt.SetPrivateKey(keyPair.PrivateKey)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to set JWT private key: %w\", err)\n\t}\n\n\terr = wavejwt.SetPublicKey(keyPair.PublicKey)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to set JWT public key: %w\", err)\n\t}\n\n\trpc := wshserver.GetMainRpcClient()\n\twshutil.DefaultRouter.RegisterTrustedLeaf(rpc, wshutil.DefaultRoute)\n\n\twconfig.GetWatcher().Start()\n\n\tlog.Printf(\"Test harness initialized\")\n\treturn nil\n}\n\nfunc testBasicConnect(connName string, timeout time.Duration) error {\n\topts, err := remote.ParseOpts(connName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse connection string: %w\", err)\n\t}\n\n\tlog.Printf(\"Connecting to %s...\", opts.String())\n\n\tconn := conncontroller.GetConn(opts)\n\tctx, cancel := context.WithTimeout(context.Background(), timeout)\n\tdefer cancel()\n\n\terr = conn.Connect(ctx, &wconfig.ConnKeywords{})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"connection failed: %w\", err)\n\t}\n\n\tstatus := conn.DeriveConnStatus()\n\tlog.Printf(\"✓ Connected!\")\n\tlog.Printf(\"  Status: %s\", status.Status)\n\tlog.Printf(\"  WshEnabled: %v\", status.WshEnabled)\n\tlog.Printf(\"  Connection: %s\", status.Connection)\n\tif status.WshVersion != \"\" {\n\t\tlog.Printf(\"  WshVersion: %s\", status.WshVersion)\n\t}\n\tif status.WshError != \"\" {\n\t\tlog.Printf(\"  WshError: %s\", status.WshError)\n\t}\n\tif status.NoWshReason != \"\" {\n\t\tlog.Printf(\"  NoWshReason: %s\", status.NoWshReason)\n\t}\n\n\treturn nil\n}\n\nfunc testShellWithCommand(connName string, cmd string, timeout time.Duration) error {\n\topts, err := remote.ParseOpts(connName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse connection string: %w\", err)\n\t}\n\n\tlog.Printf(\"Connecting to %s...\", opts.String())\n\n\tconn := conncontroller.GetConn(opts)\n\tctx, cancel := context.WithTimeout(context.Background(), timeout)\n\tdefer cancel()\n\n\terr = conn.Connect(ctx, &wconfig.ConnKeywords{})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"connection failed: %w\", err)\n\t}\n\n\tlog.Printf(\"✓ Connected! Starting shell...\")\n\n\ttermSize := waveobj.TermSize{Rows: 24, Cols: 80}\n\tshellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, \"\", shellexec.CommandOptsType{}, conn)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to start shell: %w\", err)\n\t}\n\tdefer shellProc.Close()\n\n\tlog.Printf(\"✓ Shell started! Executing: %s\", cmd)\n\n\t_, err = shellProc.Cmd.Write([]byte(cmd + \"\\n\"))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to write command: %w\", err)\n\t}\n\n\ttime.Sleep(500 * time.Millisecond)\n\n\tbuf := make([]byte, 8192)\n\tn, err := shellProc.Cmd.Read(buf)\n\tif err != nil {\n\t\tlog.Printf(\"Warning: read error (may be expected): %v\", err)\n\t}\n\n\tif n > 0 {\n\t\tlog.Printf(\"\\n--- Output ---\\n%s\\n--- End Output ---\", string(buf[:n]))\n\t} else {\n\t\tlog.Printf(\"No output received (timeout or no data)\")\n\t}\n\n\treturn nil\n}\n\nfunc testWshExec(connName string, cmd string, timeout time.Duration) error {\n\topts, err := remote.ParseOpts(connName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse connection string: %w\", err)\n\t}\n\n\tlog.Printf(\"Connecting to %s with wsh enabled...\", opts.String())\n\n\tconn := conncontroller.GetConn(opts)\n\tctx, cancel := context.WithTimeout(context.Background(), timeout)\n\tdefer cancel()\n\n\twshEnabled := true\n\terr = conn.Connect(ctx, &wconfig.ConnKeywords{\n\t\tConnWshEnabled: &wshEnabled,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"connection failed: %w\", err)\n\t}\n\n\tstatus := conn.DeriveConnStatus()\n\tlog.Printf(\"✓ Connected! (wsh enabled: %v)\", status.WshEnabled)\n\tif status.WshVersion != \"\" {\n\t\tlog.Printf(\"  wsh version: %s\", status.WshVersion)\n\t}\n\tif !status.WshEnabled {\n\t\tlog.Printf(\"  WARNING: wsh not enabled - reason: %s\", status.NoWshReason)\n\t}\n\n\tlog.Printf(\"Starting wsh-enabled shell...\")\n\n\tswapToken := &shellutil.TokenSwapEntry{\n\t\tToken: uuid.New().String(),\n\t\tEnv:   make(map[string]string),\n\t\tExp:   time.Now().Add(5 * time.Minute),\n\t}\n\tswapToken.Env[\"TERM_PROGRAM\"] = \"waveterm\"\n\tswapToken.Env[\"WAVETERM\"] = \"1\"\n\tswapToken.Env[\"WAVETERM_VERSION\"] = wavebase.WaveVersion\n\tswapToken.Env[\"WAVETERM_CONN\"] = connName\n\n\tcmdOpts := shellexec.CommandOptsType{\n\t\tSwapToken: swapToken,\n\t}\n\n\ttermSize := waveobj.TermSize{Rows: 24, Cols: 80}\n\tshellProc, err := shellexec.StartRemoteShellProc(ctx, ctx, termSize, \"\", cmdOpts, conn)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to start shell: %w\", err)\n\t}\n\tdefer shellProc.Close()\n\n\tlog.Printf(\"✓ Shell started! Executing: %s\", cmd)\n\n\t_, err = shellProc.Cmd.Write([]byte(cmd + \"\\n\"))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to write command: %w\", err)\n\t}\n\n\ttime.Sleep(500 * time.Millisecond)\n\n\tbuf := make([]byte, 8192)\n\tn, err := shellProc.Cmd.Read(buf)\n\tif err != nil {\n\t\tlog.Printf(\"Warning: read error (may be expected): %v\", err)\n\t}\n\n\tif n > 0 {\n\t\tlog.Printf(\"\\n--- Output ---\\n%s\\n--- End Output ---\", string(buf[:n]))\n\t} else {\n\t\tlog.Printf(\"No output received (timeout or no data)\")\n\t}\n\n\treturn nil\n}\n\nfunc testInteractiveShell(connName string, timeout time.Duration) error {\n\topts, err := remote.ParseOpts(connName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse connection string: %w\", err)\n\t}\n\n\tlog.Printf(\"Connecting to %s...\", opts.String())\n\n\tconn := conncontroller.GetConn(opts)\n\tctx, cancel := context.WithTimeout(context.Background(), timeout)\n\tdefer cancel()\n\n\terr = conn.Connect(ctx, &wconfig.ConnKeywords{})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"connection failed: %w\", err)\n\t}\n\n\tlog.Printf(\"✓ Connected! Starting interactive shell...\")\n\tlog.Printf(\"Note: This is a simple test - output may be mixed with prompts\")\n\tlog.Printf(\"Type commands and press Enter. Type 'exit' to quit.\\n\")\n\n\ttermSize := waveobj.TermSize{Rows: 24, Cols: 80}\n\tshellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, \"\", shellexec.CommandOptsType{}, conn)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to start shell: %w\", err)\n\t}\n\tdefer shellProc.Close()\n\n\tgo func() {\n\t\tbuf := make([]byte, 8192)\n\t\tfor {\n\t\t\tn, err := shellProc.Cmd.Read(buf)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif n > 0 {\n\t\t\t\tfmt.Print(string(buf[:n]))\n\t\t\t}\n\t\t}\n\t}()\n\n\tgo func() {\n\t\tbuf := make([]byte, 1)\n\t\tfor {\n\t\t\tn, err := os.Stdin.Read(buf)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif n > 0 {\n\t\t\t\tshellProc.Cmd.Write(buf[:n])\n\t\t\t}\n\t\t}\n\t}()\n\n\tshellProc.Wait()\n\tlog.Printf(\"\\nShell exited\")\n\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/test-streammanager/bridge.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\n// WriterBridge - used by the writer broker\n// Sends data to the pipe, receives acks from the pipe\ntype WriterBridge struct {\n\tpipe *DeliveryPipe\n}\n\nfunc (b *WriterBridge) StreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error {\n\tb.pipe.EnqueueData(data)\n\treturn nil\n}\n\nfunc (b *WriterBridge) StreamDataAckCommand(ack wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error {\n\treturn fmt.Errorf(\"writer bridge should not send acks\")\n}\n\n// ReaderBridge - used by the reader broker\n// Sends acks to the pipe, receives data from the pipe\ntype ReaderBridge struct {\n\tpipe *DeliveryPipe\n}\n\nfunc (b *ReaderBridge) StreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error {\n\treturn fmt.Errorf(\"reader bridge should not send data\")\n}\n\nfunc (b *ReaderBridge) StreamDataAckCommand(ack wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error {\n\tb.pipe.EnqueueAck(ack)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/test-streammanager/deliverypipe.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"encoding/base64\"\n\t\"math/rand\"\n\t\"sort\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype DeliveryConfig struct {\n\tDelay time.Duration\n\tSkew  time.Duration\n}\n\ntype taggedPacket struct {\n\tseq          uint64\n\tdeliveryTime time.Time\n\tisData       bool\n\tdataPk       wshrpc.CommandStreamData\n\tackPk        wshrpc.CommandStreamAckData\n\tdataSize     int\n}\n\ntype DeliveryPipe struct {\n\tlock   sync.Mutex\n\tconfig DeliveryConfig\n\n\t// Sequence counters (separate for data and ack)\n\tdataSeq uint64\n\tackSeq  uint64\n\n\t// Pending packets sorted by (deliveryTime, seq)\n\tdataPending []taggedPacket\n\tackPending  []taggedPacket\n\n\t// Delivery targets\n\tdataTarget func(wshrpc.CommandStreamData)\n\tackTarget  func(wshrpc.CommandStreamAckData)\n\n\t// Control\n\tclosed bool\n\twg     sync.WaitGroup\n\n\t// Metrics\n\tmetrics        *Metrics\n\tlastDataSeqNum int64\n\tlastAckSeqNum  int64\n\n\t// Byte tracking for high water mark\n\tcurrentBytes int64\n}\n\nfunc NewDeliveryPipe(config DeliveryConfig, metrics *Metrics) *DeliveryPipe {\n\treturn &DeliveryPipe{\n\t\tconfig:         config,\n\t\tmetrics:        metrics,\n\t\tlastDataSeqNum: -1,\n\t\tlastAckSeqNum:  -1,\n\t}\n}\n\nfunc (dp *DeliveryPipe) SetDataTarget(fn func(wshrpc.CommandStreamData)) {\n\tdp.lock.Lock()\n\tdefer dp.lock.Unlock()\n\tdp.dataTarget = fn\n}\n\nfunc (dp *DeliveryPipe) SetAckTarget(fn func(wshrpc.CommandStreamAckData)) {\n\tdp.lock.Lock()\n\tdefer dp.lock.Unlock()\n\tdp.ackTarget = fn\n}\n\nfunc (dp *DeliveryPipe) EnqueueData(pkt wshrpc.CommandStreamData) {\n\tdp.lock.Lock()\n\tdefer dp.lock.Unlock()\n\n\tif dp.closed {\n\t\treturn\n\t}\n\n\tdataSize := base64.StdEncoding.DecodedLen(len(pkt.Data64))\n\tdp.dataSeq++\n\ttagged := taggedPacket{\n\t\tseq:          dp.dataSeq,\n\t\tdeliveryTime: dp.computeDeliveryTime(),\n\t\tisData:       true,\n\t\tdataPk:       pkt,\n\t\tdataSize:     dataSize,\n\t}\n\n\tdp.dataPending = append(dp.dataPending, tagged)\n\tdp.sortPending(&dp.dataPending)\n\n\tdp.currentBytes += int64(dataSize)\n\tif dp.metrics != nil {\n\t\tdp.metrics.AddDataPacket()\n\t\tdp.metrics.UpdatePipeHighWaterMark(dp.currentBytes)\n\t}\n}\n\nfunc (dp *DeliveryPipe) EnqueueAck(pkt wshrpc.CommandStreamAckData) {\n\tdp.lock.Lock()\n\tdefer dp.lock.Unlock()\n\n\tif dp.closed {\n\t\treturn\n\t}\n\n\tdp.ackSeq++\n\ttagged := taggedPacket{\n\t\tseq:          dp.ackSeq,\n\t\tdeliveryTime: dp.computeDeliveryTime(),\n\t\tisData:       false,\n\t\tackPk:        pkt,\n\t}\n\n\tdp.ackPending = append(dp.ackPending, tagged)\n\tdp.sortPending(&dp.ackPending)\n\n\tif dp.metrics != nil {\n\t\tdp.metrics.AddAckPacket()\n\t}\n}\n\nfunc (dp *DeliveryPipe) computeDeliveryTime() time.Time {\n\tbase := time.Now().Add(dp.config.Delay)\n\n\tif dp.config.Skew == 0 {\n\t\treturn base\n\t}\n\n\t// Random skew: -skew to +skew\n\tskewNs := dp.config.Skew.Nanoseconds()\n\trandomSkew := time.Duration(rand.Int63n(2*skewNs+1) - skewNs)\n\treturn base.Add(randomSkew)\n}\n\nfunc (dp *DeliveryPipe) sortPending(pending *[]taggedPacket) {\n\tsort.Slice(*pending, func(i, j int) bool {\n\t\tpi, pj := (*pending)[i], (*pending)[j]\n\t\tif pi.deliveryTime.Equal(pj.deliveryTime) {\n\t\t\treturn pi.seq < pj.seq\n\t\t}\n\t\treturn pi.deliveryTime.Before(pj.deliveryTime)\n\t})\n}\n\nfunc (dp *DeliveryPipe) Start() {\n\tdp.wg.Add(2)\n\tgo dp.dataDeliveryLoop()\n\tgo dp.ackDeliveryLoop()\n}\n\nfunc (dp *DeliveryPipe) dataDeliveryLoop() {\n\tdefer dp.wg.Done()\n\tdp.deliveryLoop(\n\t\tfunc() *[]taggedPacket { return &dp.dataPending },\n\t\tfunc(pkt taggedPacket) {\n\t\t\tif dp.dataTarget != nil {\n\t\t\t\t// Track out-of-order packets\n\t\t\t\tif dp.metrics != nil && dp.lastDataSeqNum != -1 {\n\t\t\t\t\tif pkt.dataPk.Seq < dp.lastDataSeqNum {\n\t\t\t\t\t\tdp.metrics.AddOOOPacket()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tdp.lastDataSeqNum = pkt.dataPk.Seq\n\t\t\t\tdp.dataTarget(pkt.dataPk)\n\n\t\t\t\tdp.lock.Lock()\n\t\t\t\tdp.currentBytes -= int64(pkt.dataSize)\n\t\t\t\tdp.lock.Unlock()\n\t\t\t}\n\t\t},\n\t)\n}\n\nfunc (dp *DeliveryPipe) ackDeliveryLoop() {\n\tdefer dp.wg.Done()\n\tdp.deliveryLoop(\n\t\tfunc() *[]taggedPacket { return &dp.ackPending },\n\t\tfunc(pkt taggedPacket) {\n\t\t\tif dp.ackTarget != nil {\n\t\t\t\t// Track out-of-order acks\n\t\t\t\tif dp.metrics != nil && dp.lastAckSeqNum != -1 {\n\t\t\t\t\tif pkt.ackPk.Seq < dp.lastAckSeqNum {\n\t\t\t\t\t\tdp.metrics.AddOOOPacket()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tdp.lastAckSeqNum = pkt.ackPk.Seq\n\t\t\t\tdp.ackTarget(pkt.ackPk)\n\t\t\t}\n\t\t},\n\t)\n}\n\nfunc (dp *DeliveryPipe) deliveryLoop(\n\tgetPending func() *[]taggedPacket,\n\tdeliver func(taggedPacket),\n) {\n\tfor {\n\t\tdp.lock.Lock()\n\t\tif dp.closed {\n\t\t\tdp.lock.Unlock()\n\t\t\treturn\n\t\t}\n\n\t\tpending := getPending()\n\t\tnow := time.Now()\n\n\t\t// Find all packets ready for delivery (deliveryTime <= now)\n\t\treadyCount := 0\n\t\tfor _, pkt := range *pending {\n\t\t\tif pkt.deliveryTime.After(now) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treadyCount++\n\t\t}\n\n\t\t// Extract ready packets\n\t\tready := make([]taggedPacket, readyCount)\n\t\tcopy(ready, (*pending)[:readyCount])\n\t\t*pending = (*pending)[readyCount:]\n\n\t\tdp.lock.Unlock()\n\n\t\t// Deliver all ready packets (outside lock)\n\t\tfor _, pkt := range ready {\n\t\t\tdeliver(pkt)\n\t\t}\n\n\t\t// Always sleep 1ms - simple busy loop\n\t\ttime.Sleep(1 * time.Millisecond)\n\t}\n}\n\nfunc (dp *DeliveryPipe) Close() {\n\tdp.lock.Lock()\n\tdp.closed = true\n\tdp.lock.Unlock()\n\n\tdp.wg.Wait()\n}\n"
  },
  {
    "path": "cmd/test-streammanager/generator.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"io\"\n)\n\n// Base64 charset: all printable, easy to inspect manually\nconst Base64Chars = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\"\n\ntype TestDataGenerator struct {\n\ttotalBytes int64\n\tgenerated  int64\n}\n\nfunc NewTestDataGenerator(totalBytes int64) *TestDataGenerator {\n\treturn &TestDataGenerator{totalBytes: totalBytes}\n}\n\nfunc (g *TestDataGenerator) Read(p []byte) (n int, err error) {\n\tif g.generated >= g.totalBytes {\n\t\treturn 0, io.EOF\n\t}\n\n\tremaining := g.totalBytes - g.generated\n\ttoRead := int64(len(p))\n\tif toRead > remaining {\n\t\ttoRead = remaining\n\t}\n\n\t// Sequential pattern using base64 chars (0-63 cycling)\n\tfor i := int64(0); i < toRead; i++ {\n\t\tp[i] = Base64Chars[(g.generated+i)%64]\n\t}\n\n\tg.generated += toRead\n\treturn int(toRead), nil\n}\n"
  },
  {
    "path": "cmd/test-streammanager/main-test-streammanager.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/jobmanager\"\n\t\"github.com/wavetermdev/waveterm/pkg/streamclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype TestConfig struct {\n\tMode       string\n\tDataSize   int64\n\tDelay      time.Duration\n\tSkew       time.Duration\n\tWindowSize int\n\tSlowReader int\n\tVerbose    bool\n}\n\nvar config TestConfig\n\nvar rootCmd = &cobra.Command{\n\tUse:   \"test-streammanager\",\n\tShort: \"Integration test for StreamManager streaming system\",\n\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\treturn runTest(config)\n\t},\n}\n\nfunc init() {\n\trootCmd.Flags().StringVar(&config.Mode, \"mode\", \"streammanager\", \"Writer mode: 'streammanager' or 'writer'\")\n\trootCmd.Flags().Int64Var(&config.DataSize, \"size\", 10*1024*1024, \"Total data to transfer (bytes)\")\n\trootCmd.Flags().DurationVar(&config.Delay, \"delay\", 0, \"Base delivery delay (e.g., 10ms)\")\n\trootCmd.Flags().DurationVar(&config.Skew, \"skew\", 0, \"Delivery skew +/- (e.g., 5ms)\")\n\trootCmd.Flags().IntVar(&config.WindowSize, \"windowsize\", 64*1024, \"Window size for both sender and receiver\")\n\trootCmd.Flags().IntVar(&config.SlowReader, \"slowreader\", 0, \"Slow reader mode: bytes per second (0=disabled, e.g., 1024)\")\n\trootCmd.Flags().BoolVar(&config.Verbose, \"verbose\", false, \"Enable verbose logging\")\n}\n\nfunc main() {\n\tif err := rootCmd.Execute(); err != nil {\n\t\tos.Exit(1)\n\t}\n}\n\nfunc runTest(config TestConfig) error {\n\tif config.Mode != \"streammanager\" && config.Mode != \"writer\" {\n\t\treturn fmt.Errorf(\"invalid mode: %s (must be 'streammanager' or 'writer')\", config.Mode)\n\t}\n\n\tfmt.Printf(\"Starting Streaming Integration Test\\n\")\n\tfmt.Printf(\"  Mode: %s\\n\", config.Mode)\n\tfmt.Printf(\"  Data Size: %d bytes\\n\", config.DataSize)\n\tfmt.Printf(\"  Delay: %v, Skew: %v\\n\", config.Delay, config.Skew)\n\tfmt.Printf(\"  Window Size: %d\\n\", config.WindowSize)\n\tif config.SlowReader > 0 {\n\t\tfmt.Printf(\"  Slow Reader: %d bytes/sec\\n\", config.SlowReader)\n\t}\n\n\t// 1. Create metrics\n\tmetrics := NewMetrics()\n\n\t// 2. Create the delivery pipe\n\tpipe := NewDeliveryPipe(DeliveryConfig{\n\t\tDelay: config.Delay,\n\t\tSkew:  config.Skew,\n\t}, metrics)\n\n\t// 3. Create brokers with bridges\n\twriterBridge := &WriterBridge{pipe: pipe}\n\treaderBridge := &ReaderBridge{pipe: pipe}\n\n\twriterBroker := streamclient.NewBroker(writerBridge)\n\treaderBroker := streamclient.NewBroker(readerBridge)\n\n\t// 4. Wire up delivery targets\n\tpipe.SetDataTarget(readerBroker.RecvData)\n\tpipe.SetAckTarget(writerBroker.RecvAck)\n\n\t// 5. Start the delivery pipe\n\tpipe.Start()\n\n\t// 6. Create the reader side\n\treader, streamMeta := readerBroker.CreateStreamReader(\"reader-route\", \"writer-route\", int64(config.WindowSize))\n\n\t// 7. Set up writer side based on mode\n\tvar writerDone chan error\n\tif config.Mode == \"streammanager\" {\n\t\twriterDone = runStreamManagerMode(config, writerBroker, streamMeta)\n\t} else {\n\t\twriterDone = runWriterMode(config, writerBroker, streamMeta)\n\t}\n\n\t// 8. Create verifier\n\tverifier := NewVerifier(config.DataSize)\n\n\t// 9. Create metrics writer wrapper\n\tmetricsWriter := &MetricsWriter{\n\t\twriter:  verifier,\n\t\tmetrics: metrics,\n\t}\n\n\t// 10. Wrap reader with slow reader if configured\n\tvar actualReader io.Reader = reader\n\tif config.SlowReader > 0 {\n\t\tactualReader = NewSlowReader(reader, config.SlowReader)\n\t}\n\n\t// 11. Start reading from stream reader and writing to verifier\n\tmetrics.Start()\n\n\treaderDone := make(chan error)\n\tgo func() {\n\t\t_, err := io.Copy(metricsWriter, actualReader)\n\t\treaderDone <- err\n\t}()\n\n\t// 12. Wait for completion\n\tvar writerErr, readerErr error\n\tif writerDone != nil {\n\t\twriterErr = <-writerDone\n\t}\n\treaderErr = <-readerDone\n\tmetrics.End()\n\n\t// 13. Cleanup\n\tpipe.Close()\n\twriterBroker.Close()\n\treaderBroker.Close()\n\n\t// 14. Report results\n\tfmt.Println(metrics.Report())\n\tfmt.Printf(\"Verification: received=%d, mismatches=%d\\n\",\n\t\tverifier.TotalReceived(), verifier.Mismatches())\n\n\tif writerErr != nil && writerErr != io.EOF {\n\t\treturn fmt.Errorf(\"writer error: %w\", writerErr)\n\t}\n\n\tif readerErr != nil && readerErr != io.EOF {\n\t\treturn fmt.Errorf(\"reader error: %w\", readerErr)\n\t}\n\n\tif verifier.Mismatches() > 0 {\n\t\treturn fmt.Errorf(\"data corruption: %d mismatches, first at byte %d\",\n\t\t\tverifier.Mismatches(), verifier.FirstMismatch())\n\t}\n\n\tfmt.Println(\"TEST PASSED\")\n\treturn nil\n}\n\nfunc runStreamManagerMode(config TestConfig, writerBroker *streamclient.Broker, streamMeta *wshrpc.StreamMeta) chan error {\n\tstreamManager := jobmanager.MakeStreamManagerWithSizes(config.WindowSize, 2*1024*1024)\n\twriterBroker.AttachStreamWriter(streamMeta, streamManager)\n\n\tdataSender := &BrokerDataSender{broker: writerBroker}\n\tstartSeq, err := streamManager.ClientConnected(streamMeta.Id, dataSender, config.WindowSize, 0)\n\tif err != nil {\n\t\tfmt.Printf(\"failed to connect stream manager: %v\\n\", err)\n\t\treturn nil\n\t}\n\tfmt.Printf(\"  Stream connected, startSeq: %d\\n\", startSeq)\n\n\tgenerator := NewTestDataGenerator(config.DataSize)\n\tif err := streamManager.AttachReader(generator); err != nil {\n\t\tfmt.Printf(\"failed to attach reader: %v\\n\", err)\n\t\treturn nil\n\t}\n\n\treturn nil\n}\n\nfunc runWriterMode(config TestConfig, writerBroker *streamclient.Broker, streamMeta *wshrpc.StreamMeta) chan error {\n\twriter, err := writerBroker.CreateStreamWriter(streamMeta)\n\tif err != nil {\n\t\tfmt.Printf(\"failed to create stream writer: %v\\n\", err)\n\t\treturn nil\n\t}\n\tfmt.Printf(\"  Stream writer created\\n\")\n\n\tgenerator := NewTestDataGenerator(config.DataSize)\n\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\t_, copyErr := io.Copy(writer, generator)\n\t\tcloseErr := writer.Close()\n\t\tif copyErr != nil && copyErr != io.EOF {\n\t\t\tdone <- copyErr\n\t\t} else {\n\t\t\tdone <- closeErr\n\t\t}\n\t}()\n\n\treturn done\n}\n\n// BrokerDataSender implements DataSender interface\ntype BrokerDataSender struct {\n\tbroker *streamclient.Broker\n}\n\nfunc (s *BrokerDataSender) SendData(dataPk wshrpc.CommandStreamData) {\n\ts.broker.SendData(dataPk)\n}\n\n// MetricsWriter wraps an io.Writer and records bytes written to metrics\ntype MetricsWriter struct {\n\twriter  io.Writer\n\tmetrics *Metrics\n}\n\nfunc (mw *MetricsWriter) Write(p []byte) (n int, err error) {\n\tn, err = mw.writer.Write(p)\n\tif n > 0 {\n\t\tmw.metrics.AddBytes(int64(n))\n\t}\n\treturn n, err\n}\n\n// SlowReader wraps an io.Reader and rate-limits reads to a specified bytes/sec\ntype SlowReader struct {\n\treader      io.Reader\n\tbytesPerSec int\n}\n\nfunc NewSlowReader(reader io.Reader, bytesPerSec int) *SlowReader {\n\treturn &SlowReader{\n\t\treader:      reader,\n\t\tbytesPerSec: bytesPerSec,\n\t}\n}\n\nfunc (sr *SlowReader) Read(p []byte) (n int, err error) {\n\ttime.Sleep(1 * time.Second)\n\n\treadSize := sr.bytesPerSec\n\tif readSize > len(p) {\n\t\treadSize = len(p)\n\t}\n\n\tn, err = sr.reader.Read(p[:readSize])\n\tlog.Printf(\"SlowReader: read %d bytes, err=%v\", n, err)\n\treturn n, err\n}\n"
  },
  {
    "path": "cmd/test-streammanager/metrics.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n)\n\ntype Metrics struct {\n\tlock sync.Mutex\n\n\t// Timing\n\tstartTime time.Time\n\tendTime   time.Time\n\n\t// Data transfer\n\ttotalBytes int64\n\n\t// Packet counts\n\tdataPackets int64\n\tackPackets  int64\n\n\t// Out of order tracking\n\toooPackets int64\n\n\t// High water mark for pipe bytes\n\tpipeHighWaterMark int64\n}\n\nfunc NewMetrics() *Metrics {\n\treturn &Metrics{}\n}\n\nfunc (m *Metrics) Start() {\n\tm.lock.Lock()\n\tdefer m.lock.Unlock()\n\tm.startTime = time.Now()\n}\n\nfunc (m *Metrics) End() {\n\tm.lock.Lock()\n\tdefer m.lock.Unlock()\n\tm.endTime = time.Now()\n}\n\nfunc (m *Metrics) AddDataPacket() {\n\tm.lock.Lock()\n\tdefer m.lock.Unlock()\n\tm.dataPackets++\n}\n\nfunc (m *Metrics) AddAckPacket() {\n\tm.lock.Lock()\n\tdefer m.lock.Unlock()\n\tm.ackPackets++\n}\n\nfunc (m *Metrics) AddOOOPacket() {\n\tm.lock.Lock()\n\tdefer m.lock.Unlock()\n\tm.oooPackets++\n}\n\nfunc (m *Metrics) AddBytes(n int64) {\n\tm.lock.Lock()\n\tdefer m.lock.Unlock()\n\tm.totalBytes += n\n}\n\nfunc (m *Metrics) UpdatePipeHighWaterMark(currentBytes int64) {\n\tm.lock.Lock()\n\tdefer m.lock.Unlock()\n\tif currentBytes > m.pipeHighWaterMark {\n\t\tm.pipeHighWaterMark = currentBytes\n\t}\n}\n\nfunc (m *Metrics) GetPipeHighWaterMark() int64 {\n\tm.lock.Lock()\n\tdefer m.lock.Unlock()\n\treturn m.pipeHighWaterMark\n}\n\nfunc (m *Metrics) Report() string {\n\tm.lock.Lock()\n\tdefer m.lock.Unlock()\n\n\tduration := m.endTime.Sub(m.startTime)\n\tdurationSecs := duration.Seconds()\n\tif durationSecs == 0 {\n\t\tdurationSecs = 1.0\n\t}\n\tthroughput := float64(m.totalBytes) / durationSecs / 1024 / 1024\n\n\treturn fmt.Sprintf(`\nStreamManager Integration Test Results\n======================================\nDuration:        %v\nTotal Bytes:     %d\nThroughput:      %.2f MB/s\nData Packets:    %d\nAck Packets:     %d\nOOO Packets:     %d\nPipe High Water: %d bytes (%.2f KB)\n`, duration, m.totalBytes, throughput, m.dataPackets, m.ackPackets, m.oooPackets,\n\t\tm.pipeHighWaterMark, float64(m.pipeHighWaterMark)/1024)\n}\n"
  },
  {
    "path": "cmd/test-streammanager/verifier.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"sync\"\n)\n\ntype Verifier struct {\n\tlock          sync.Mutex\n\texpectedGen   *TestDataGenerator\n\ttotalReceived int64\n\tmismatches    int\n\tfirstMismatch int64\n}\n\nfunc NewVerifier(totalBytes int64) *Verifier {\n\treturn &Verifier{\n\t\texpectedGen:   NewTestDataGenerator(totalBytes),\n\t\tfirstMismatch: -1,\n\t}\n}\n\nfunc (v *Verifier) Write(p []byte) (n int, err error) {\n\tv.lock.Lock()\n\tdefer v.lock.Unlock()\n\n\texpected := make([]byte, len(p))\n\t// expectedGen.Read() error ignored: TestDataGenerator is deterministic and won't fail,\n\t// and any data length mismatch will be caught by byte comparison below\n\tv.expectedGen.Read(expected)\n\n\tfor i := 0; i < len(p); i++ {\n\t\tif p[i] != expected[i] {\n\t\t\tv.mismatches++\n\t\t\tif v.firstMismatch == -1 {\n\t\t\t\tv.firstMismatch = v.totalReceived + int64(i)\n\t\t\t}\n\t\t}\n\t}\n\n\tv.totalReceived += int64(len(p))\n\treturn len(p), nil\n}\n\nfunc (v *Verifier) TotalReceived() int64 {\n\tv.lock.Lock()\n\tdefer v.lock.Unlock()\n\treturn v.totalReceived\n}\n\nfunc (v *Verifier) Mismatches() int {\n\tv.lock.Lock()\n\tdefer v.lock.Unlock()\n\treturn v.mismatches\n}\n\nfunc (v *Verifier) FirstMismatch() int64 {\n\tv.lock.Lock()\n\tdefer v.lock.Unlock()\n\treturn v.firstMismatch\n}\n"
  },
  {
    "path": "cmd/testai/main-testai.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"context\"\n\t_ \"embed\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/web/sse\"\n)\n\n//go:embed testschema.json\nvar testSchemaJSON string\n\nconst (\n\tDefaultAnthropicModel  = \"claude-sonnet-4-5\"\n\tDefaultOpenAIModel     = \"gpt-5.1\"\n\tDefaultOpenRouterModel = \"mistralai/mistral-small-3.2-24b-instruct\"\n\tDefaultNanoGPTModel    = \"zai-org/glm-4.7\"\n\tDefaultGeminiModel     = \"gemini-3-pro-preview\"\n)\n\n// TestResponseWriter implements http.ResponseWriter and additional interfaces for testing\ntype TestResponseWriter struct {\n\theader http.Header\n}\n\nfunc (w *TestResponseWriter) Header() http.Header {\n\tif w.header == nil {\n\t\tw.header = make(http.Header)\n\t}\n\treturn w.header\n}\n\nfunc (w *TestResponseWriter) Write(data []byte) (int, error) {\n\tfmt.Printf(\"SSE: %s\", string(data))\n\treturn len(data), nil\n}\n\nfunc (w *TestResponseWriter) WriteHeader(statusCode int) {\n\tfmt.Printf(\"Status: %d\\n\", statusCode)\n}\n\n// Implement http.Flusher interface\nfunc (w *TestResponseWriter) Flush() {\n\t// No-op for testing\n}\n\n// Implement interfaces needed by http.ResponseController\nfunc (w *TestResponseWriter) SetWriteDeadline(deadline time.Time) error {\n\t// No-op for testing\n\treturn nil\n}\n\nfunc (w *TestResponseWriter) SetReadDeadline(deadline time.Time) error {\n\t// No-op for testing\n\treturn nil\n}\n\nfunc getToolDefinitions() []uctypes.ToolDefinition {\n\tvar schemas map[string]any\n\tif err := json.Unmarshal([]byte(testSchemaJSON), &schemas); err != nil {\n\t\tlog.Printf(\"Error parsing schema: %v\\n\", err)\n\t\treturn nil\n\t}\n\n\tvar configSchema map[string]any\n\tif rawSchema, ok := schemas[\"config\"]; ok && rawSchema != nil {\n\t\tif schema, ok := rawSchema.(map[string]any); ok {\n\t\t\tconfigSchema = schema\n\t\t}\n\t}\n\tif configSchema == nil {\n\t\tconfigSchema = map[string]any{\"type\": \"object\"}\n\t}\n\n\treturn []uctypes.ToolDefinition{\n\t\t{\n\t\t\tName:        \"get_config\",\n\t\t\tDescription: \"Get the current GitHub Actions Monitor configuration settings including repository, workflow, polling interval, and max workflow runs\",\n\t\t\tInputSchema: map[string]any{\n\t\t\t\t\"type\": \"object\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:        \"update_config\",\n\t\t\tDescription: \"Update GitHub Actions Monitor configuration settings\",\n\t\t\tInputSchema: configSchema,\n\t\t},\n\t\t{\n\t\t\tName:        \"get_data\",\n\t\t\tDescription: \"Get the current GitHub Actions workflow run data including workflow runs, loading state, and errors\",\n\t\t\tInputSchema: map[string]any{\n\t\t\t\t\"type\": \"object\",\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc testOpenAI(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) {\n\tapiKey := os.Getenv(\"OPENAI_APIKEY\")\n\tif apiKey == \"\" {\n\t\tfmt.Println(\"Error: OPENAI_APIKEY environment variable not set\")\n\t\tos.Exit(1)\n\t}\n\n\topts := &uctypes.AIOptsType{\n\t\tAPIType:       uctypes.APIType_OpenAIResponses,\n\t\tAPIToken:      apiKey,\n\t\tModel:         model,\n\t\tMaxTokens:     4096,\n\t\tThinkingLevel: uctypes.ThinkingLevelMedium,\n\t}\n\n\t// Generate a chat ID\n\tchatID := uuid.New().String()\n\n\t// Convert to AIMessage format for WaveAIPostMessageWrap\n\taiMessage := &uctypes.AIMessage{\n\t\tMessageId: uuid.New().String(),\n\t\tParts: []uctypes.AIMessagePart{\n\t\t\t{\n\t\t\t\tType: uctypes.AIMessagePartTypeText,\n\t\t\t\tText: message,\n\t\t\t},\n\t\t},\n\t}\n\n\tfmt.Printf(\"Testing OpenAI streaming with WaveAIPostMessageWrap, model: %s\\n\", model)\n\tfmt.Printf(\"Message: %s\\n\", message)\n\tfmt.Printf(\"Chat ID: %s\\n\", chatID)\n\tfmt.Println(\"---\")\n\n\ttestWriter := &TestResponseWriter{}\n\tsseHandler := sse.MakeSSEHandlerCh(testWriter, ctx)\n\tdefer sseHandler.Close()\n\n\tchatOpts := uctypes.WaveChatOpts{\n\t\tChatId:   chatID,\n\t\tClientId: uuid.New().String(),\n\t\tConfig:   *opts,\n\t\tTools:    tools,\n\t}\n\terr := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts)\n\tif err != nil {\n\t\tfmt.Printf(\"OpenAI streaming error: %v\\n\", err)\n\t}\n}\n\nfunc testOpenAIComp(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) {\n\tapiKey := os.Getenv(\"OPENAI_APIKEY\")\n\tif apiKey == \"\" {\n\t\tfmt.Println(\"Error: OPENAI_APIKEY environment variable not set\")\n\t\tos.Exit(1)\n\t}\n\n\topts := &uctypes.AIOptsType{\n\t\tAPIType:       uctypes.APIType_OpenAIChat,\n\t\tAPIToken:      apiKey,\n\t\tEndpoint:      \"https://api.openai.com/v1/chat/completions\",\n\t\tModel:         model,\n\t\tMaxTokens:     4096,\n\t\tThinkingLevel: uctypes.ThinkingLevelMedium,\n\t}\n\n\tchatID := uuid.New().String()\n\n\taiMessage := &uctypes.AIMessage{\n\t\tMessageId: uuid.New().String(),\n\t\tParts: []uctypes.AIMessagePart{\n\t\t\t{\n\t\t\t\tType: uctypes.AIMessagePartTypeText,\n\t\t\t\tText: message,\n\t\t\t},\n\t\t},\n\t}\n\n\tfmt.Printf(\"Testing OpenAI Completions API with WaveAIPostMessageWrap, model: %s\\n\", model)\n\tfmt.Printf(\"Message: %s\\n\", message)\n\tfmt.Printf(\"Chat ID: %s\\n\", chatID)\n\tfmt.Println(\"---\")\n\n\ttestWriter := &TestResponseWriter{}\n\tsseHandler := sse.MakeSSEHandlerCh(testWriter, ctx)\n\tdefer sseHandler.Close()\n\n\tchatOpts := uctypes.WaveChatOpts{\n\t\tChatId:       chatID,\n\t\tClientId:     uuid.New().String(),\n\t\tConfig:       *opts,\n\t\tTools:        tools,\n\t\tSystemPrompt: []string{\"You are a helpful assistant. Be concise and clear in your responses.\"},\n\t}\n\terr := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts)\n\tif err != nil {\n\t\tfmt.Printf(\"OpenAI Completions API streaming error: %v\\n\", err)\n\t}\n}\n\nfunc testOpenRouter(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) {\n\tapiKey := os.Getenv(\"OPENROUTER_APIKEY\")\n\tif apiKey == \"\" {\n\t\tfmt.Println(\"Error: OPENROUTER_APIKEY environment variable not set\")\n\t\tos.Exit(1)\n\t}\n\n\topts := &uctypes.AIOptsType{\n\t\tAPIType:       uctypes.APIType_OpenAIChat,\n\t\tAPIToken:      apiKey,\n\t\tEndpoint:      \"https://openrouter.ai/api/v1/chat/completions\",\n\t\tModel:         model,\n\t\tMaxTokens:     4096,\n\t\tThinkingLevel: uctypes.ThinkingLevelMedium,\n\t}\n\n\tchatID := uuid.New().String()\n\n\taiMessage := &uctypes.AIMessage{\n\t\tMessageId: uuid.New().String(),\n\t\tParts: []uctypes.AIMessagePart{\n\t\t\t{\n\t\t\t\tType: uctypes.AIMessagePartTypeText,\n\t\t\t\tText: message,\n\t\t\t},\n\t\t},\n\t}\n\n\tfmt.Printf(\"Testing OpenRouter with WaveAIPostMessageWrap, model: %s\\n\", model)\n\tfmt.Printf(\"Message: %s\\n\", message)\n\tfmt.Printf(\"Chat ID: %s\\n\", chatID)\n\tfmt.Println(\"---\")\n\n\ttestWriter := &TestResponseWriter{}\n\tsseHandler := sse.MakeSSEHandlerCh(testWriter, ctx)\n\tdefer sseHandler.Close()\n\n\tchatOpts := uctypes.WaveChatOpts{\n\t\tChatId:       chatID,\n\t\tClientId:     uuid.New().String(),\n\t\tConfig:       *opts,\n\t\tTools:        tools,\n\t\tSystemPrompt: []string{\"You are a helpful assistant. Be concise and clear in your responses.\"},\n\t}\n\terr := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts)\n\tif err != nil {\n\t\tfmt.Printf(\"OpenRouter streaming error: %v\\n\", err)\n\t}\n}\n\nfunc testNanoGPT(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) {\n\tapiKey := os.Getenv(\"NANOGPT_KEY\")\n\tif apiKey == \"\" {\n\t\tfmt.Println(\"Error: NANOGPT_KEY environment variable not set\")\n\t\tos.Exit(1)\n\t}\n\n\topts := &uctypes.AIOptsType{\n\t\tAPIType:   uctypes.APIType_OpenAIChat,\n\t\tAPIToken:  apiKey,\n\t\tEndpoint:  \"https://nano-gpt.com/api/v1/chat/completions\",\n\t\tModel:     model,\n\t\tMaxTokens: 4096,\n\t}\n\n\tchatID := uuid.New().String()\n\n\taiMessage := &uctypes.AIMessage{\n\t\tMessageId: uuid.New().String(),\n\t\tParts: []uctypes.AIMessagePart{\n\t\t\t{\n\t\t\t\tType: uctypes.AIMessagePartTypeText,\n\t\t\t\tText: message,\n\t\t\t},\n\t\t},\n\t}\n\n\tfmt.Printf(\"Testing NanoGPT with WaveAIPostMessageWrap, model: %s\\n\", model)\n\tfmt.Printf(\"Message: %s\\n\", message)\n\tfmt.Printf(\"Chat ID: %s\\n\", chatID)\n\tfmt.Println(\"---\")\n\n\ttestWriter := &TestResponseWriter{}\n\tsseHandler := sse.MakeSSEHandlerCh(testWriter, ctx)\n\tdefer sseHandler.Close()\n\n\tchatOpts := uctypes.WaveChatOpts{\n\t\tChatId:       chatID,\n\t\tClientId:     uuid.New().String(),\n\t\tConfig:       *opts,\n\t\tTools:        tools,\n\t\tSystemPrompt: []string{\"You are a helpful assistant. Be concise and clear in your responses.\"},\n\t}\n\terr := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts)\n\tif err != nil {\n\t\tfmt.Printf(\"NanoGPT streaming error: %v\\n\", err)\n\t}\n}\n\nfunc testAnthropic(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) {\n\tapiKey := os.Getenv(\"ANTHROPIC_APIKEY\")\n\tif apiKey == \"\" {\n\t\tfmt.Println(\"Error: ANTHROPIC_APIKEY environment variable not set\")\n\t\tos.Exit(1)\n\t}\n\n\topts := &uctypes.AIOptsType{\n\t\tAPIType:       uctypes.APIType_AnthropicMessages,\n\t\tAPIToken:      apiKey,\n\t\tModel:         model,\n\t\tMaxTokens:     4096,\n\t\tThinkingLevel: uctypes.ThinkingLevelMedium,\n\t}\n\n\t// Generate a chat ID\n\tchatID := uuid.New().String()\n\n\t// Convert to AIMessage format for WaveAIPostMessageWrap\n\taiMessage := &uctypes.AIMessage{\n\t\tMessageId: uuid.New().String(),\n\t\tParts: []uctypes.AIMessagePart{\n\t\t\t{\n\t\t\t\tType: uctypes.AIMessagePartTypeText,\n\t\t\t\tText: message,\n\t\t\t},\n\t\t},\n\t}\n\n\tfmt.Printf(\"Testing Anthropic streaming with WaveAIPostMessageWrap, model: %s\\n\", model)\n\tfmt.Printf(\"Message: %s\\n\", message)\n\tfmt.Printf(\"Chat ID: %s\\n\", chatID)\n\tfmt.Println(\"---\")\n\n\ttestWriter := &TestResponseWriter{}\n\tsseHandler := sse.MakeSSEHandlerCh(testWriter, ctx)\n\tdefer sseHandler.Close()\n\n\tchatOpts := uctypes.WaveChatOpts{\n\t\tChatId:   chatID,\n\t\tClientId: uuid.New().String(),\n\t\tConfig:   *opts,\n\t\tTools:    tools,\n\t}\n\terr := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts)\n\tif err != nil {\n\t\tfmt.Printf(\"Anthropic streaming error: %v\\n\", err)\n\t}\n}\n\nfunc testGemini(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) {\n\tapiKey := os.Getenv(\"GOOGLE_APIKEY\")\n\tif apiKey == \"\" {\n\t\tfmt.Println(\"Error: GOOGLE_APIKEY environment variable not set\")\n\t\tos.Exit(1)\n\t}\n\n\topts := &uctypes.AIOptsType{\n\t\tAPIType:      uctypes.APIType_GoogleGemini,\n\t\tAPIToken:     apiKey,\n\t\tModel:        model,\n\t\tMaxTokens:    8192,\n\t\tCapabilities: []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs},\n\t}\n\n\t// Generate a chat ID\n\tchatID := uuid.New().String()\n\n\t// Convert to AIMessage format for WaveAIPostMessageWrap\n\taiMessage := &uctypes.AIMessage{\n\t\tMessageId: uuid.New().String(),\n\t\tParts: []uctypes.AIMessagePart{\n\t\t\t{\n\t\t\t\tType: uctypes.AIMessagePartTypeText,\n\t\t\t\tText: message,\n\t\t\t},\n\t\t},\n\t}\n\n\tfmt.Printf(\"Testing Google Gemini streaming with WaveAIPostMessageWrap, model: %s\\n\", model)\n\tfmt.Printf(\"Message: %s\\n\", message)\n\tfmt.Printf(\"Chat ID: %s\\n\", chatID)\n\tfmt.Println(\"---\")\n\n\ttestWriter := &TestResponseWriter{}\n\tsseHandler := sse.MakeSSEHandlerCh(testWriter, ctx)\n\tdefer sseHandler.Close()\n\n\tchatOpts := uctypes.WaveChatOpts{\n\t\tChatId:       chatID,\n\t\tClientId:     uuid.New().String(),\n\t\tConfig:       *opts,\n\t\tTools:        tools,\n\t\tSystemPrompt: []string{\"You are a helpful assistant. Be concise and clear in your responses.\"},\n\t}\n\terr := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts)\n\tif err != nil {\n\t\tfmt.Printf(\"Google Gemini streaming error: %v\\n\", err)\n\t}\n}\n\nfunc testT1(ctx context.Context) {\n\ttool := aiusechat.GetAdderToolDefinition()\n\ttools := []uctypes.ToolDefinition{tool}\n\ttestAnthropic(ctx, DefaultAnthropicModel, \"what is 2+2, use the provider adder tool\", tools)\n}\n\nfunc testT2(ctx context.Context) {\n\ttool := aiusechat.GetAdderToolDefinition()\n\ttools := []uctypes.ToolDefinition{tool}\n\ttestOpenAI(ctx, DefaultOpenAIModel, \"what is 2+2+8, use the provider adder tool\", tools)\n}\n\nfunc testT3(ctx context.Context) {\n\ttestOpenAIComp(ctx, \"gpt-4o\", \"what is 2+2? please be brief\", nil)\n}\n\nfunc testT4(ctx context.Context) {\n\ttool := aiusechat.GetAdderToolDefinition()\n\ttools := []uctypes.ToolDefinition{tool}\n\ttestGemini(ctx, DefaultGeminiModel, \"what is 2+2+8, use the provider adder tool\", tools)\n}\n\nfunc printUsage() {\n\tfmt.Println(\"Usage: go run main-testai.go [--anthropic|--openaicomp|--openrouter|--nanogpt|--gemini] [--tools] [--model <model>] [message]\")\n\tfmt.Println(\"Examples:\")\n\tfmt.Println(\"  go run main-testai.go 'What is 2+2?'\")\n\tfmt.Println(\"  go run main-testai.go --model o4-mini 'What is 2+2?'\")\n\tfmt.Println(\"  go run main-testai.go --anthropic 'What is 2+2?'\")\n\tfmt.Println(\"  go run main-testai.go --anthropic --model claude-3-5-sonnet-20241022 'What is 2+2?'\")\n\tfmt.Println(\"  go run main-testai.go --openaicomp --model gpt-4o 'What is 2+2?'\")\n\tfmt.Println(\"  go run main-testai.go --openrouter 'What is 2+2?'\")\n\tfmt.Println(\"  go run main-testai.go --openrouter --model anthropic/claude-3.5-sonnet 'What is 2+2?'\")\n\tfmt.Println(\"  go run main-testai.go --nanogpt 'What is 2+2?'\")\n\tfmt.Println(\"  go run main-testai.go --nanogpt --model gpt-4o 'What is 2+2?'\")\n\tfmt.Println(\"  go run main-testai.go --gemini 'What is 2+2?'\")\n\tfmt.Println(\"  go run main-testai.go --gemini --model gemini-1.5-pro 'What is 2+2?'\")\n\tfmt.Println(\"  go run main-testai.go --tools 'Help me configure GitHub Actions monitoring'\")\n\tfmt.Println(\"\")\n\tfmt.Println(\"Default models:\")\n\tfmt.Printf(\"  OpenAI: %s\\n\", DefaultOpenAIModel)\n\tfmt.Printf(\"  Anthropic: %s\\n\", DefaultAnthropicModel)\n\tfmt.Printf(\"  OpenAI Completions: gpt-4o\\n\")\n\tfmt.Printf(\"  OpenRouter: %s\\n\", DefaultOpenRouterModel)\n\tfmt.Printf(\"  NanoGPT: %s\\n\", DefaultNanoGPTModel)\n\tfmt.Printf(\"  Google Gemini: %s\\n\", DefaultGeminiModel)\n\tfmt.Println(\"\")\n\tfmt.Println(\"Environment variables:\")\n\tfmt.Println(\"  OPENAI_APIKEY (for OpenAI models)\")\n\tfmt.Println(\"  ANTHROPIC_APIKEY (for Anthropic models)\")\n\tfmt.Println(\"  OPENROUTER_APIKEY (for OpenRouter models)\")\n\tfmt.Println(\"  NANOGPT_KEY (for NanoGPT models)\")\n\tfmt.Println(\"  GOOGLE_APIKEY (for Google Gemini models)\")\n}\n\nfunc main() {\n\tvar anthropic, openaicomp, openrouter, nanogpt, gemini, tools, help, t1, t2, t3, t4 bool\n\tvar model string\n\tflag.BoolVar(&anthropic, \"anthropic\", false, \"Use Anthropic API instead of OpenAI\")\n\tflag.BoolVar(&openaicomp, \"openaicomp\", false, \"Use OpenAI Completions API\")\n\tflag.BoolVar(&openrouter, \"openrouter\", false, \"Use OpenRouter API\")\n\tflag.BoolVar(&nanogpt, \"nanogpt\", false, \"Use NanoGPT API\")\n\tflag.BoolVar(&gemini, \"gemini\", false, \"Use Google Gemini API\")\n\tflag.BoolVar(&tools, \"tools\", false, \"Enable GitHub Actions Monitor tools for testing\")\n\tflag.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))\n\tflag.BoolVar(&help, \"help\", false, \"Show usage information\")\n\tflag.BoolVar(&t1, \"t1\", false, fmt.Sprintf(\"Run preset T1 test (%s with 'what is 2+2')\", DefaultAnthropicModel))\n\tflag.BoolVar(&t2, \"t2\", false, fmt.Sprintf(\"Run preset T2 test (%s with 'what is 2+2')\", DefaultOpenAIModel))\n\tflag.BoolVar(&t3, \"t3\", false, \"Run preset T3 test (OpenAI Completions API with gpt-5.1)\")\n\tflag.BoolVar(&t4, \"t4\", false, \"Run preset T4 test (OpenAI Completions API with gemini-3-pro-preview)\")\n\tflag.Parse()\n\n\tif help {\n\t\tprintUsage()\n\t\tos.Exit(0)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\n\tdefer cancel()\n\n\tif t1 {\n\t\ttestT1(ctx)\n\t\treturn\n\t}\n\tif t2 {\n\t\ttestT2(ctx)\n\t\treturn\n\t}\n\tif t3 {\n\t\ttestT3(ctx)\n\t\treturn\n\t}\n\tif t4 {\n\t\ttestT4(ctx)\n\t\treturn\n\t}\n\n\t// Set default model based on API type if not provided\n\tif model == \"\" {\n\t\tif anthropic {\n\t\t\tmodel = DefaultAnthropicModel\n\t\t} else if openaicomp {\n\t\t\tmodel = \"gpt-4o\"\n\t\t} else if openrouter {\n\t\t\tmodel = DefaultOpenRouterModel\n\t\t} else if nanogpt {\n\t\t\tmodel = DefaultNanoGPTModel\n\t\t} else if gemini {\n\t\t\tmodel = DefaultGeminiModel\n\t\t} else {\n\t\t\tmodel = DefaultOpenAIModel\n\t\t}\n\t}\n\n\targs := flag.Args()\n\tmessage := \"What is 2+2?\"\n\tif len(args) > 0 {\n\t\tmessage = args[0]\n\t}\n\n\tvar toolDefs []uctypes.ToolDefinition\n\tif tools {\n\t\ttoolDefs = getToolDefinitions()\n\t}\n\n\tif anthropic {\n\t\ttestAnthropic(ctx, model, message, toolDefs)\n\t} else if openaicomp {\n\t\ttestOpenAIComp(ctx, model, message, toolDefs)\n\t} else if openrouter {\n\t\ttestOpenRouter(ctx, model, message, toolDefs)\n\t} else if nanogpt {\n\t\ttestNanoGPT(ctx, model, message, toolDefs)\n\t} else if gemini {\n\t\ttestGemini(ctx, model, message, toolDefs)\n\t} else {\n\t\ttestOpenAI(ctx, model, message, toolDefs)\n\t}\n}\n"
  },
  {
    "path": "cmd/testai/testschema.json",
    "content": "{\n    \"config\": {\n        \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n        \"description\": \"Application configuration settings\",\n        \"properties\": {\n            \"maxWorkflowRuns\": {\n                \"description\": \"Maximum number of workflow runs to fetch\",\n                \"maximum\": 100,\n                \"minimum\": 1,\n                \"type\": \"integer\"\n            },\n            \"pollInterval\": {\n                \"description\": \"Polling interval for GitHub API requests\",\n                \"maximum\": 300,\n                \"minimum\": 1,\n                \"type\": \"integer\",\n                \"units\": \"s\"\n            },\n            \"repository\": {\n                \"description\": \"GitHub repository in owner/repo format\",\n                \"pattern\": \"^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$\",\n                \"type\": \"string\"\n            },\n            \"workflow\": {\n                \"description\": \"GitHub Actions workflow file name\",\n                \"pattern\": \"^.+\\\\.(yml|yaml)$\",\n                \"type\": \"string\"\n            }\n        },\n        \"title\": \"Application Configuration\",\n        \"type\": \"object\"\n    },\n    \"data\": {\n        \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n        \"definitions\": {\n            \"WorkflowRun\": {\n                \"properties\": {\n                    \"conclusion\": {\n                        \"type\": \"string\"\n                    },\n                    \"created_at\": {\n                        \"format\": \"date-time\",\n                        \"type\": \"string\"\n                    },\n                    \"html_url\": {\n                        \"type\": \"string\"\n                    },\n                    \"id\": {\n                        \"type\": \"integer\"\n                    },\n                    \"name\": {\n                        \"type\": \"string\"\n                    },\n                    \"run_number\": {\n                        \"type\": \"integer\"\n                    },\n                    \"status\": {\n                        \"type\": \"string\"\n                    },\n                    \"updated_at\": {\n                        \"format\": \"date-time\",\n                        \"type\": \"string\"\n                    }\n                },\n                \"required\": [\n                    \"id\",\n                    \"name\",\n                    \"status\",\n                    \"conclusion\",\n                    \"created_at\",\n                    \"updated_at\",\n                    \"html_url\",\n                    \"run_number\"\n                ],\n                \"type\": \"object\"\n            }\n        },\n        \"description\": \"Application data schema\",\n        \"properties\": {\n            \"isLoading\": {\n                \"description\": \"Loading state for workflow data fetch\",\n                \"type\": \"boolean\"\n            },\n            \"lastError\": {\n                \"description\": \"Last error message from GitHub API\",\n                \"type\": \"string\"\n            },\n            \"lastRefreshTime\": {\n                \"description\": \"Timestamp of last successful data refresh\",\n                \"format\": \"date-time\",\n                \"type\": \"string\"\n            },\n            \"workflowRuns\": {\n                \"description\": \"List of GitHub Actions workflow runs\",\n                \"items\": {\n                    \"$ref\": \"#/definitions/WorkflowRun\"\n                },\n                \"type\": \"array\"\n            }\n        },\n        \"title\": \"Application Data\",\n        \"type\": \"object\"\n    }\n}\n"
  },
  {
    "path": "cmd/testopenai/main-testopenai.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/openai\"\n)\n\nfunc makeOpenAIRequest(ctx context.Context, apiKey, model, message string, tools bool) error {\n\treqBody := openai.OpenAIRequest{\n\t\tModel: model,\n\t\tInput: []any{\n\t\t\topenai.OpenAIMessage{\n\t\t\t\tRole: \"user\",\n\t\t\t\tContent: []openai.OpenAIMessageContent{\n\t\t\t\t\t{\n\t\t\t\t\t\tType: \"input_text\",\n\t\t\t\t\t\tText: message,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tStream:        true,\n\t\tStreamOptions: &openai.StreamOptionsType{IncludeObfuscation: false},\n\t\tReasoning:     &openai.ReasoningType{Effort: \"medium\"},\n\t}\n\tif tools {\n\t\treqBody.Tools = []openai.OpenAIRequestTool{\n\t\t\topenai.ConvertToolDefinitionToOpenAI(aiusechat.GetAdderToolDefinition()),\n\t\t}\n\t}\n\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error marshaling request: %v\", err)\n\t}\n\n\t// Pretty print the request JSON for debugging\n\tprettyJSON, err := json.MarshalIndent(reqBody, \"\", \"  \")\n\tif err == nil {\n\t\tfmt.Printf(\"Request JSON:\\n%s\\n\", string(prettyJSON))\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", \"https://api.openai.com/v1/responses\", bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error creating request: %v\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\treq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\tclient := &http.Client{\n\t\tTimeout: 60 * time.Second,\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error making request: %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tfmt.Printf(\"Response Status: %s\\n\", resp.Status)\n\tfmt.Printf(\"Response Headers:\\n\")\n\tfor name, values := range resp.Header {\n\t\tfor _, value := range values {\n\t\t\tfmt.Printf(\"  %s: %s\\n\", name, value)\n\t\t}\n\t}\n\tfmt.Println(\"---\")\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn fmt.Errorf(\"API error (%d): %s\", resp.StatusCode, string(body))\n\t}\n\n\treturn processSSEStream(resp.Body)\n}\n\nfunc processSSEStream(reader io.Reader) error {\n\tscanner := bufio.NewScanner(reader)\n\n\tfmt.Println(\"SSE Stream:\")\n\tfmt.Println(\"---\")\n\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tfmt.Println(line)\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\treturn fmt.Errorf(\"error reading stream: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc printUsage() {\n\tfmt.Println(\"Usage: go run main-testopenai.go [--model <model>] [--tools] [message]\")\n\tfmt.Println(\"Examples:\")\n\tfmt.Println(\"  go run main-testopenai.go 'Stream me a limerick about gophers coding in Go.'\")\n\tfmt.Println(\"  go run main-testopenai.go --model gpt-4 'What is 2+2?'\")\n\tfmt.Println(\"  go run main-testopenai.go --tools 'What is 2+2? Use the adder tool.'\")\n\tfmt.Println(\"\")\n\tfmt.Println(\"Default model: gpt-5-mini\")\n\tfmt.Println(\"\")\n\tfmt.Println(\"Environment variables:\")\n\tfmt.Println(\"  OPENAI_APIKEY (required)\")\n}\n\nfunc main() {\n\tvar model string\n\tvar showHelp bool\n\tvar tools bool\n\n\tflag.StringVar(&model, \"model\", \"gpt-5-mini\", \"OpenAI model to use\")\n\tflag.BoolVar(&showHelp, \"help\", false, \"Show usage information\")\n\tflag.BoolVar(&tools, \"tools\", false, \"Enable tools for testing\")\n\tflag.Parse()\n\n\tif showHelp {\n\t\tprintUsage()\n\t\tos.Exit(0)\n\t}\n\n\tapiKey := os.Getenv(\"OPENAI_APIKEY\")\n\tif apiKey == \"\" {\n\t\tfmt.Println(\"Error: OPENAI_APIKEY environment variable not set\")\n\t\tprintUsage()\n\t\tos.Exit(1)\n\t}\n\n\targs := flag.Args()\n\tmessage := \"Stream me a limerick about gophers coding in Go.\"\n\tif len(args) > 0 {\n\t\tmessage = args[0]\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\n\tdefer cancel()\n\n\tfmt.Printf(\"Testing OpenAI Responses API\\n\")\n\tfmt.Printf(\"Model: %s\\n\", model)\n\tfmt.Printf(\"Message: %s\\n\", message)\n\tfmt.Println(\"===\")\n\n\tif err := makeOpenAIRequest(ctx, apiKey, model, message, tools); err != nil {\n\t\tfmt.Printf(\"Error: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "cmd/testsummarize/main-testsummarize.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"context\"\n\t\"flag\"\n\t\"fmt\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/google\"\n)\n\nfunc printUsage() {\n\tfmt.Println(\"Usage: go run main-testsummarize.go [--help] [--mode MODE] <filename>\")\n\tfmt.Println(\"Examples:\")\n\tfmt.Println(\"  go run main-testsummarize.go README.md\")\n\tfmt.Println(\"  go run main-testsummarize.go --mode useful /path/to/image.png\")\n\tfmt.Println(\"  go run main-testsummarize.go -m publiccode document.pdf\")\n\tfmt.Println(\"\")\n\tfmt.Println(\"Supported file types:\")\n\tfmt.Println(\"  - Text files (up to 200KB)\")\n\tfmt.Println(\"  - Images (up to 7MB)\")\n\tfmt.Println(\"  - PDFs (up to 5MB)\")\n\tfmt.Println(\"\")\n\tfmt.Println(\"Flags:\")\n\tfmt.Println(\"  --mode, -m  Summarization mode (default: quick)\")\n\tfmt.Println(\"              Options: quick, useful, publiccode, htmlcontent, htmlfull\")\n\tfmt.Println(\"\")\n\tfmt.Println(\"Environment variables:\")\n\tfmt.Println(\"  GOOGLE_APIKEY (required)\")\n}\n\nfunc main() {\n\tvar showHelp bool\n\tvar mode string\n\tflag.BoolVar(&showHelp, \"help\", false, \"Show usage information\")\n\tflag.StringVar(&mode, \"mode\", \"quick\", \"Summarization mode\")\n\tflag.StringVar(&mode, \"m\", \"quick\", \"Summarization mode (shorthand)\")\n\tflag.Parse()\n\n\tif showHelp {\n\t\tprintUsage()\n\t\tos.Exit(0)\n\t}\n\n\tapiKey := os.Getenv(\"GOOGLE_APIKEY\")\n\tif apiKey == \"\" {\n\t\tfmt.Println(\"Error: GOOGLE_APIKEY environment variable not set\")\n\t\tprintUsage()\n\t\tos.Exit(1)\n\t}\n\n\targs := flag.Args()\n\tif len(args) == 0 {\n\t\tfmt.Println(\"Error: filename required\")\n\t\tprintUsage()\n\t\tos.Exit(1)\n\t}\n\n\tfilename := args[0]\n\n\t// Check if file exists\n\tif _, err := os.Stat(filename); os.IsNotExist(err) {\n\t\tfmt.Printf(\"Error: file '%s' does not exist\\n\", filename)\n\t\tos.Exit(1)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\n\tdefer cancel()\n\n\tfmt.Printf(\"Summarizing file: %s\\n\", filename)\n\tfmt.Printf(\"Model: %s\\n\", google.SummarizeModel)\n\tfmt.Printf(\"Mode: %s\\n\", mode)\n\n\tstartTime := time.Now()\n\tsummary, usage, err := google.SummarizeFile(ctx, filename, google.SummarizeOpts{\n\t\tAPIKey: apiKey,\n\t\tMode:   mode,\n\t})\n\tlatency := time.Since(startTime)\n\n\tfmt.Printf(\"Latency: %d ms\\n\", latency.Milliseconds())\n\tfmt.Println(\"===\")\n\tif err != nil {\n\t\tfmt.Printf(\"Error: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tfmt.Println(\"\\nSummary:\")\n\tfmt.Println(\"---\")\n\tfmt.Println(summary)\n\tfmt.Println(\"---\")\n\n\tif usage != nil {\n\t\tfmt.Println(\"\\nUsage Statistics:\")\n\t\tfmt.Printf(\"  Prompt tokens: %d\\n\", usage.PromptTokenCount)\n\t\tfmt.Printf(\"  Cached tokens: %d\\n\", usage.CachedContentTokenCount)\n\t\tfmt.Printf(\"  Response tokens: %d\\n\", usage.CandidatesTokenCount)\n\t\tfmt.Printf(\"  Total tokens: %d\\n\", usage.TotalTokenCount)\n\t}\n}"
  },
  {
    "path": "cmd/wsh/cmd/csscolormap.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nvar CssColorNames = map[string]bool{\n\t\"aliceblue\":            true,\n\t\"antiquewhite\":         true,\n\t\"aqua\":                 true,\n\t\"aquamarine\":           true,\n\t\"azure\":                true,\n\t\"beige\":                true,\n\t\"bisque\":               true,\n\t\"black\":                true,\n\t\"blanchedalmond\":       true,\n\t\"blue\":                 true,\n\t\"blueviolet\":           true,\n\t\"brown\":                true,\n\t\"burlywood\":            true,\n\t\"cadetblue\":            true,\n\t\"chartreuse\":           true,\n\t\"chocolate\":            true,\n\t\"coral\":                true,\n\t\"cornflowerblue\":       true,\n\t\"cornsilk\":             true,\n\t\"crimson\":              true,\n\t\"cyan\":                 true,\n\t\"darkblue\":             true,\n\t\"darkcyan\":             true,\n\t\"darkgoldenrod\":        true,\n\t\"darkgray\":             true,\n\t\"darkgreen\":            true,\n\t\"darkkhaki\":            true,\n\t\"darkmagenta\":          true,\n\t\"darkolivegreen\":       true,\n\t\"darkorange\":           true,\n\t\"darkorchid\":           true,\n\t\"darkred\":              true,\n\t\"darksalmon\":           true,\n\t\"darkseagreen\":         true,\n\t\"darkslateblue\":        true,\n\t\"darkslategray\":        true,\n\t\"darkturquoise\":        true,\n\t\"darkviolet\":           true,\n\t\"deeppink\":             true,\n\t\"deepskyblue\":          true,\n\t\"dimgray\":              true,\n\t\"dodgerblue\":           true,\n\t\"firebrick\":            true,\n\t\"floralwhite\":          true,\n\t\"forestgreen\":          true,\n\t\"fuchsia\":              true,\n\t\"gainsboro\":            true,\n\t\"ghostwhite\":           true,\n\t\"gold\":                 true,\n\t\"goldenrod\":            true,\n\t\"gray\":                 true,\n\t\"green\":                true,\n\t\"greenyellow\":          true,\n\t\"honeydew\":             true,\n\t\"hotpink\":              true,\n\t\"indianred\":            true,\n\t\"indigo\":               true,\n\t\"ivory\":                true,\n\t\"khaki\":                true,\n\t\"lavender\":             true,\n\t\"lavenderblush\":        true,\n\t\"lawngreen\":            true,\n\t\"lemonchiffon\":         true,\n\t\"lightblue\":            true,\n\t\"lightcoral\":           true,\n\t\"lightcyan\":            true,\n\t\"lightgoldenrodyellow\": true,\n\t\"lightgray\":            true,\n\t\"lightgreen\":           true,\n\t\"lightpink\":            true,\n\t\"lightsalmon\":          true,\n\t\"lightseagreen\":        true,\n\t\"lightskyblue\":         true,\n\t\"lightslategray\":       true,\n\t\"lightsteelblue\":       true,\n\t\"lightyellow\":          true,\n\t\"lime\":                 true,\n\t\"limegreen\":            true,\n\t\"linen\":                true,\n\t\"magenta\":              true,\n\t\"maroon\":               true,\n\t\"mediumaquamarine\":     true,\n\t\"mediumblue\":           true,\n\t\"mediumorchid\":         true,\n\t\"mediumpurple\":         true,\n\t\"mediumseagreen\":       true,\n\t\"mediumslateblue\":      true,\n\t\"mediumspringgreen\":    true,\n\t\"mediumturquoise\":      true,\n\t\"mediumvioletred\":      true,\n\t\"midnightblue\":         true,\n\t\"mintcream\":            true,\n\t\"mistyrose\":            true,\n\t\"moccasin\":             true,\n\t\"navajowhite\":          true,\n\t\"navy\":                 true,\n\t\"oldlace\":              true,\n\t\"olive\":                true,\n\t\"olivedrab\":            true,\n\t\"orange\":               true,\n\t\"orangered\":            true,\n\t\"orchid\":               true,\n\t\"palegoldenrod\":        true,\n\t\"palegreen\":            true,\n\t\"paleturquoise\":        true,\n\t\"palevioletred\":        true,\n\t\"papayawhip\":           true,\n\t\"peachpuff\":            true,\n\t\"peru\":                 true,\n\t\"pink\":                 true,\n\t\"plum\":                 true,\n\t\"powderblue\":           true,\n\t\"purple\":               true,\n\t\"red\":                  true,\n\t\"rosybrown\":            true,\n\t\"royalblue\":            true,\n\t\"saddlebrown\":          true,\n\t\"salmon\":               true,\n\t\"sandybrown\":           true,\n\t\"seagreen\":             true,\n\t\"seashell\":             true,\n\t\"sienna\":               true,\n\t\"silver\":               true,\n\t\"skyblue\":              true,\n\t\"slateblue\":            true,\n\t\"slategray\":            true,\n\t\"snow\":                 true,\n\t\"springgreen\":          true,\n\t\"steelblue\":            true,\n\t\"tan\":                  true,\n\t\"teal\":                 true,\n\t\"thistle\":              true,\n\t\"tomato\":               true,\n\t\"turquoise\":            true,\n\t\"violet\":               true,\n\t\"wheat\":                true,\n\t\"white\":                true,\n\t\"whitesmoke\":           true,\n\t\"yellow\":               true,\n\t\"yellowgreen\":          true,\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/setmeta_test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestParseMetaSets(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tinput   []string\n\t\twant    map[string]any\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname:  \"basic types\",\n\t\t\tinput: []string{\"str=hello\", \"num=42\", \"float=3.14\", \"bool=true\", \"null=null\"},\n\t\t\twant: map[string]any{\n\t\t\t\t\"str\":   \"hello\",\n\t\t\t\t\"num\":   int64(42),\n\t\t\t\t\"float\": float64(3.14),\n\t\t\t\t\"bool\":  true,\n\t\t\t\t\"null\":  nil,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"json values\",\n\t\t\tinput: []string{\n\t\t\t\t`arr=[1,2,3]`,\n\t\t\t\t`obj={\"foo\":\"bar\"}`,\n\t\t\t\t`str=\"quoted\"`,\n\t\t\t},\n\t\t\twant: map[string]any{\n\t\t\t\t\"arr\": []any{float64(1), float64(2), float64(3)},\n\t\t\t\t\"obj\": map[string]any{\"foo\": \"bar\"},\n\t\t\t\t\"str\": \"quoted\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"nested paths\",\n\t\t\tinput: []string{\n\t\t\t\t\"a/b=55\",\n\t\t\t\t\"a/c=2\",\n\t\t\t},\n\t\t\twant: map[string]any{\n\t\t\t\t\"a\": map[string]any{\n\t\t\t\t\t\"b\": int64(55),\n\t\t\t\t\t\"c\": int64(2),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"deep nesting\",\n\t\t\tinput: []string{\n\t\t\t\t\"a/b/c/d=hello\",\n\t\t\t},\n\t\t\twant: map[string]any{\n\t\t\t\t\"a\": map[string]any{\n\t\t\t\t\t\"b\": map[string]any{\n\t\t\t\t\t\t\"c\": map[string]any{\n\t\t\t\t\t\t\t\"d\": \"hello\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"override nested value\",\n\t\t\tinput: []string{\n\t\t\t\t\"a/b/c=1\",\n\t\t\t\t\"a/b=2\",\n\t\t\t},\n\t\t\twant: map[string]any{\n\t\t\t\t\"a\": map[string]any{\n\t\t\t\t\t\"b\": int64(2),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"override with null\",\n\t\t\tinput: []string{\n\t\t\t\t\"a/b=1\",\n\t\t\t\t\"a/c=2\",\n\t\t\t\t\"a=null\",\n\t\t\t},\n\t\t\twant: map[string]any{\n\t\t\t\t\"a\": nil,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed types in path\",\n\t\t\tinput: []string{\n\t\t\t\t\"a/b=1\",\n\t\t\t\t\"a/c=[1,2,3]\",\n\t\t\t\t\"a/d/e=true\",\n\t\t\t},\n\t\t\twant: map[string]any{\n\t\t\t\t\"a\": map[string]any{\n\t\t\t\t\t\"b\": int64(1),\n\t\t\t\t\t\"c\": []any{float64(1), float64(2), float64(3)},\n\t\t\t\t\t\"d\": map[string]any{\n\t\t\t\t\t\t\"e\": true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid format\",\n\t\t\tinput:   []string{\"invalid\"},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid json\",\n\t\t\tinput:   []string{`a={\"invalid`},\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := parseMetaSets(tt.input)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"parseMetaSets() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !tt.wantErr && !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"parseMetaSets() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestParseMetaValue(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tinput   string\n\t\twant    any\n\t\twantErr bool\n\t}{\n\t\t{\"empty string\", \"\", nil, false},\n\t\t{\"null\", \"null\", nil, false},\n\t\t{\"true\", \"true\", true, false},\n\t\t{\"false\", \"false\", false, false},\n\t\t{\"integer\", \"42\", int64(42), false},\n\t\t{\"negative integer\", \"-42\", int64(-42), false},\n\t\t{\"hex integer\", \"0xff\", int64(255), false},\n\t\t{\"float\", \"3.14\", float64(3.14), false},\n\t\t{\"string\", \"hello\", \"hello\", false},\n\t\t{\"json array\", \"[1,2,3]\", []any{float64(1), float64(2), float64(3)}, false},\n\t\t{\"json object\", `{\"foo\":\"bar\"}`, map[string]any{\"foo\": \"bar\"}, false},\n\t\t{\"quoted string\", `\"quoted\"`, \"quoted\", false},\n\t\t{\"invalid json\", `{\"invalid`, nil, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := parseMetaValue(tt.input)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"parseMetaValue() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !tt.wantErr && !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"parseMetaValue() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-ai.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/fileutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nvar aiCmd = &cobra.Command{\n\tUse:   \"ai [options] [files...]\",\n\tShort: \"Append content to Wave AI sidebar prompt\",\n\tLong: `Append content to Wave AI sidebar prompt (does not auto-submit by default)\n\nArguments:\n  files...               Files to attach (use '-' for stdin)\n\nExamples:\n  git diff | wsh ai -                    # Pipe diff to AI, ask question in UI\n  wsh ai main.go                         # Attach file, ask question in UI\n  wsh ai *.go -m \"find bugs\"             # Attach files with message\n  wsh ai -s - -m \"review\" < log.txt      # Stdin + message, auto-submit\n  wsh ai -n config.json                  # New chat with file attached`,\n\tRunE:                  aiRun,\n\tPreRunE:               preRunSetupRpcClient,\n\tDisableFlagsInUseLine: true,\n}\n\nvar aiMessageFlag string\nvar aiSubmitFlag bool\nvar aiNewBlockFlag bool\n\nfunc init() {\n\trootCmd.AddCommand(aiCmd)\n\taiCmd.Flags().StringVarP(&aiMessageFlag, \"message\", \"m\", \"\", \"optional message/question to append after files\")\n\taiCmd.Flags().BoolVarP(&aiSubmitFlag, \"submit\", \"s\", false, \"submit the prompt immediately after appending\")\n\taiCmd.Flags().BoolVarP(&aiNewBlockFlag, \"new\", \"n\", false, \"create a new AI chat instead of using existing\")\n}\n\nfunc detectMimeType(data []byte) string {\n\tmimeType := http.DetectContentType(data)\n\treturn strings.Split(mimeType, \";\")[0]\n}\n\nfunc getMaxFileSize(mimeType string) (int, string) {\n\tif mimeType == \"application/pdf\" {\n\t\treturn 5 * 1024 * 1024, \"5MB\"\n\t}\n\tif strings.HasPrefix(mimeType, \"image/\") {\n\t\treturn 7 * 1024 * 1024, \"7MB\"\n\t}\n\treturn 200 * 1024, \"200KB\"\n}\n\nfunc aiRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"ai\", rtnErr == nil)\n\t}()\n\n\tif len(args) == 0 && aiMessageFlag == \"\" {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"no files or message provided\")\n\t}\n\n\tconst maxFileCount = 15\n\tconst rpcTimeout = 30000\n\n\tvar allFiles []wshrpc.AIAttachedFile\n\tvar stdinUsed bool\n\n\tif len(args) > maxFileCount {\n\t\treturn fmt.Errorf(\"too many files (maximum %d files allowed)\", maxFileCount)\n\t}\n\n\tfor _, filePath := range args {\n\t\tvar data []byte\n\t\tvar fileName string\n\t\tvar mimeType string\n\t\tvar err error\n\n\t\tif filePath == \"-\" {\n\t\t\tif stdinUsed {\n\t\t\t\treturn fmt.Errorf(\"stdin (-) can only be used once\")\n\t\t\t}\n\t\t\tstdinUsed = true\n\n\t\t\tdata, err = io.ReadAll(os.Stdin)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"reading from stdin: %w\", err)\n\t\t\t}\n\t\t\tfileName = \"stdin\"\n\t\t\tmimeType = \"text/plain\"\n\t\t} else {\n\t\t\tfileInfo, err := os.Stat(filePath)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"accessing file %s: %w\", filePath, err)\n\t\t\t}\n\t\t\tabsPath, err := filepath.Abs(filePath)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"getting absolute path for %s: %w\", filePath, err)\n\t\t\t}\n\n\t\t\tif fileInfo.IsDir() {\n\t\t\t\tresult, err := fileutil.ReadDir(filePath, 500)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"reading directory %s: %w\", filePath, err)\n\t\t\t\t}\n\t\t\t\tjsonData, err := json.Marshal(result)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"marshaling directory listing for %s: %w\", filePath, err)\n\t\t\t\t}\n\t\t\t\tdata = jsonData\n\t\t\t\tfileName = absPath\n\t\t\t\tmimeType = \"directory\"\n\t\t\t} else {\n\t\t\t\tdata, err = os.ReadFile(filePath)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"reading file %s: %w\", filePath, err)\n\t\t\t\t}\n\t\t\t\tfileName = absPath\n\t\t\t\tmimeType = detectMimeType(data)\n\t\t\t}\n\t\t}\n\n\t\tisPDF := mimeType == \"application/pdf\"\n\t\tisImage := strings.HasPrefix(mimeType, \"image/\")\n\t\tisDirectory := mimeType == \"directory\"\n\n\t\tif !isPDF && !isImage && !isDirectory {\n\t\t\tmimeType = \"text/plain\"\n\t\t\tif utilfn.ContainsBinaryData(data) {\n\t\t\t\treturn fmt.Errorf(\"file %s contains binary data and cannot be uploaded as text\", fileName)\n\t\t\t}\n\t\t}\n\n\t\tmaxSize, sizeStr := getMaxFileSize(mimeType)\n\t\tif len(data) > maxSize {\n\t\t\treturn fmt.Errorf(\"file %s exceeds maximum size of %s for %s files\", fileName, sizeStr, mimeType)\n\t\t}\n\n\t\tallFiles = append(allFiles, wshrpc.AIAttachedFile{\n\t\t\tName:   fileName,\n\t\t\tType:   mimeType,\n\t\t\tSize:   len(data),\n\t\t\tData64: base64.StdEncoding.EncodeToString(data),\n\t\t})\n\t}\n\n\ttabId := os.Getenv(\"WAVETERM_TABID\")\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"WAVETERM_TABID environment variable not set\")\n\t}\n\n\troute := wshutil.MakeTabRouteId(tabId)\n\n\tif aiNewBlockFlag {\n\t\tnewChatData := wshrpc.CommandWaveAIAddContextData{\n\t\t\tNewChat: true,\n\t\t}\n\t\terr := wshclient.WaveAIAddContextCommand(RpcClient, newChatData, &wshrpc.RpcOpts{\n\t\t\tRoute:   route,\n\t\t\tTimeout: rpcTimeout,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"creating new chat: %w\", err)\n\t\t}\n\t}\n\n\tfor _, file := range allFiles {\n\t\tcontextData := wshrpc.CommandWaveAIAddContextData{\n\t\t\tFiles: []wshrpc.AIAttachedFile{file},\n\t\t}\n\t\terr := wshclient.WaveAIAddContextCommand(RpcClient, contextData, &wshrpc.RpcOpts{\n\t\t\tRoute:   route,\n\t\t\tTimeout: rpcTimeout,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"adding file %s: %w\", file.Name, err)\n\t\t}\n\t}\n\n\tif aiMessageFlag != \"\" || aiSubmitFlag {\n\t\tfinalContextData := wshrpc.CommandWaveAIAddContextData{\n\t\t\tText:   aiMessageFlag,\n\t\t\tSubmit: aiSubmitFlag,\n\t\t}\n\t\terr := wshclient.WaveAIAddContextCommand(RpcClient, finalContextData, &wshrpc.RpcOpts{\n\t\t\tRoute:   route,\n\t\t\tTimeout: rpcTimeout,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"adding context: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-badge.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"runtime\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nvar badgeCmd = &cobra.Command{\n\tUse:     \"badge [icon]\",\n\tShort:   \"set or clear a block badge\",\n\tArgs:    cobra.MaximumNArgs(1),\n\tRunE:    badgeRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar (\n\tbadgeColor    string\n\tbadgePriority float64\n\tbadgeClear    bool\n\tbadgeBeep     bool\n\tbadgePid      int\n)\n\nfunc init() {\n\trootCmd.AddCommand(badgeCmd)\n\tbadgeCmd.Flags().StringVar(&badgeColor, \"color\", \"\", \"badge color\")\n\tbadgeCmd.Flags().Float64Var(&badgePriority, \"priority\", 10, \"badge priority\")\n\tbadgeCmd.Flags().BoolVar(&badgeClear, \"clear\", false, \"clear the badge\")\n\tbadgeCmd.Flags().BoolVar(&badgeBeep, \"beep\", false, \"play system bell sound\")\n\tbadgeCmd.Flags().IntVar(&badgePid, \"pid\", 0, \"watch a pid and automatically clear the badge when it exits (default priority 5)\")\n}\n\nfunc badgeRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"badge\", rtnErr == nil)\n\t}()\n\n\tif badgePid > 0 && runtime.GOOS == \"windows\" {\n\t\treturn fmt.Errorf(\"--pid flag is not supported on Windows\")\n\t}\n\tif badgePid > 0 && !cmd.Flags().Changed(\"priority\") {\n\t\tbadgePriority = 5\n\t}\n\n\toref, err := resolveBlockArg()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"resolving block: %v\", err)\n\t}\n\tif oref.OType != waveobj.OType_Block && oref.OType != waveobj.OType_Tab {\n\t\treturn fmt.Errorf(\"badge oref must be a block or tab (got %q)\", oref.OType)\n\t}\n\n\tvar eventData baseds.BadgeEvent\n\teventData.ORef = oref.String()\n\n\tif badgeClear {\n\t\teventData.Clear = true\n\t} else {\n\t\ticon := \"circle-small\"\n\t\tif len(args) > 0 {\n\t\t\ticon = args[0]\n\t\t}\n\t\tbadgeId, err := uuid.NewV7()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"generating badge id: %v\", err)\n\t\t}\n\t\teventData.Badge = &baseds.Badge{\n\t\t\tBadgeId:   badgeId.String(),\n\t\t\tIcon:      icon,\n\t\t\tColor:     badgeColor,\n\t\t\tPriority:  badgePriority,\n\t\t\tPidLinked: badgePid > 0,\n\t\t}\n\t}\n\n\tevent := wps.WaveEvent{\n\t\tEvent:  wps.Event_Badge,\n\t\tScopes: []string{oref.String()},\n\t\tData:   eventData,\n\t}\n\n\terr = wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"publishing badge event: %v\", err)\n\t}\n\n\tif badgeBeep {\n\t\terr = wshclient.ElectronSystemBellCommand(RpcClient, &wshrpc.RpcOpts{Route: \"electron\"})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"playing system bell: %v\", err)\n\t\t}\n\t}\n\n\tif badgePid > 0 && eventData.Badge != nil {\n\t\tconn := RpcContext.Conn\n\t\tif conn == \"\" {\n\t\t\tconn = wshrpc.LocalConnName\n\t\t}\n\t\tconnRoute := wshutil.MakeConnectionRouteId(conn)\n\t\twatchData := wshrpc.CommandBadgeWatchPidData{\n\t\t\tPid:     badgePid,\n\t\t\tORef:    *oref,\n\t\t\tBadgeId: eventData.Badge.BadgeId,\n\t\t}\n\t\terr = wshclient.BadgeWatchPidCommand(RpcClient, watchData, &wshrpc.RpcOpts{Route: connRoute})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"watching pid: %v\", err)\n\t\t}\n\t}\n\n\tif badgeClear {\n\t\tfmt.Printf(\"badge cleared\\n\")\n\t} else {\n\t\tfmt.Printf(\"badge set\\n\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-blocks.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\t\"text/tabwriter\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\n// Command-line flags for the blocks commands\nvar (\n\tblocksWindowId    string // Window ID to filter blocks by\n\tblocksWorkspaceId string // Workspace ID to filter blocks by\n\tblocksTabId       string // Tab ID to filter blocks by\n\tblocksView        string // View type to filter blocks by (term, web, etc.)\n\tblocksJSON        bool   // Whether to output as JSON\n\tblocksTimeout     int    // Timeout in milliseconds for RPC calls\n)\n\n// BlockDetails represents the information about a block returned by the list command\ntype BlockDetails struct {\n\tBlockId     string              `json:\"blockid\"`     // Unique identifier for the block\n\tWorkspaceId string              `json:\"workspaceid\"` // ID of the workspace containing the block\n\tTabId       string              `json:\"tabid\"`       // ID of the tab containing the block\n\tView        string              `json:\"view\"`        // Canonical view type (term, web, preview, edit, sysinfo, waveai)\n\tMeta        waveobj.MetaMapType `json:\"meta\"`        // Block metadata including view type\n}\n\n// blocksListCmd represents the 'blocks list' command\nvar blocksListCmd = &cobra.Command{\n\tUse:     \"list\",\n\tAliases: []string{\"ls\", \"get\"},\n\tShort:   \"List blocks in workspaces/windows\",\n\tLong:    `List blocks with optional filtering by workspace, window, tab, or view type.\n\nExamples:\n  # List blocks from all workspaces\n  wsh blocks list\n\n  # List only terminal blocks\n  wsh blocks list --view=term\n\n  # Filter by window ID (get IDs from 'wsh workspace list')\n  wsh blocks list --window=dbca23b5-f89b-4780-a0fe-452f5bc7d900\n\n  # Filter by workspace ID\n  wsh blocks list --workspace=12d0c067-378e-454c-872e-77a314248114\n\n  # Filter by tab ID\n  wsh blocks list --tab=a0459921-cc1a-48cc-ae7b-5f4821e1c9e1\n\n  # Output as JSON for scripting\n  wsh blocks list --json\n\n  # Set a different timeout (in milliseconds)\n  wsh blocks list --timeout=10000`,\n\tRunE:    blocksListRun,\n\tPreRunE: preRunSetupRpcClient,\n\tSilenceUsage: true,\n}\n\n// init registers the blocks commands with the root command\n// It configures all the flags and command options\nfunc init() {\n\tblocksListCmd.Flags().StringVar(&blocksWindowId, \"window\", \"\", \"restrict to window id\")\n\tblocksListCmd.Flags().StringVar(&blocksWorkspaceId, \"workspace\", \"\", \"restrict to workspace id\")\n\tblocksListCmd.Flags().StringVar(&blocksTabId, \"tab\", \"\", \"restrict to specific tab id\")\n\tblocksListCmd.Flags().StringVar(&blocksView, \"view\", \"\", \"restrict to view type (term/terminal, web/browser, preview/edit, sysinfo, waveai)\")\n\tblocksListCmd.Flags().BoolVar(&blocksJSON, \"json\", false, \"output as JSON\")\n\tblocksListCmd.Flags().IntVar(&blocksTimeout, \"timeout\", 5000, \"timeout in milliseconds for RPC calls (default: 5000)\")\n\n\tfor _, cmd := range rootCmd.Commands() {\n\t\tif cmd.Use == \"blocks\" {\n\t\t\tcmd.AddCommand(blocksListCmd)\n\t\t\treturn\n\t\t}\n\t}\n\n\tblocksCmd := &cobra.Command{\n\t\tUse:     \"blocks\",\n\t\tShort:   \"Manage blocks\",\n\t\tLong:    \"Commands for working with blocks\",\n\t}\n\n\tblocksCmd.AddCommand(blocksListCmd)\n\trootCmd.AddCommand(blocksCmd)\n}\n\n// blocksListRun implements the 'blocks list' command\n// It retrieves and displays blocks with optional filtering by workspace, window, tab, or view type\nfunc blocksListRun(cmd *cobra.Command, args []string) error {\n\tif v := strings.TrimSpace(blocksView); v != \"\" {\n\t\tif !isKnownViewFilter(v) {\n\t\t\treturn fmt.Errorf(\"unknown --view %q; try one of: term, web, preview, edit, sysinfo, waveai\", v)\n\t\t}\n\t}\n\n\tvar allBlocks []BlockDetails\n\n\tworkspaces, err := wshclient.WorkspaceListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to list workspaces: %v\", err)\n\t}\n\n\tif len(workspaces) == 0 {\n\t\treturn fmt.Errorf(\"no workspaces found\")\n\t}\n\n\tvar workspaceIdsToQuery []string\n\n\t// Determine which workspaces to query\n\tif blocksWorkspaceId != \"\" && blocksWindowId != \"\" {\n\t\treturn fmt.Errorf(\"--workspace and --window are mutually exclusive; specify only one\")\n\t}\n\tif blocksWorkspaceId != \"\" {\n\t\tworkspaceIdsToQuery = []string{blocksWorkspaceId}\n\t} else if blocksWindowId != \"\" {\n\t\t// Find workspace ID for this window\n\t\twindowFound := false\n\t\tfor _, ws := range workspaces {\n\t\t\tif ws.WindowId == blocksWindowId {\n\t\t\t\tworkspaceIdsToQuery = []string{ws.WorkspaceData.OID}\n\t\t\t\twindowFound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !windowFound {\n\t\t\treturn fmt.Errorf(\"window %s not found\", blocksWindowId)\n\t\t}\n\t} else {\n\t\t// Default to all workspaces\n\t\tfor _, ws := range workspaces {\n\t\t\tworkspaceIdsToQuery = append(workspaceIdsToQuery, ws.WorkspaceData.OID)\n\t\t}\n\t}\n\n\t// Query each selected workspace\n\thadSuccess := false\n\tfor _, wsId := range workspaceIdsToQuery {\n\t\treq := wshrpc.BlocksListRequest{WorkspaceId: wsId}\n\t\tif blocksWindowId != \"\" {\n\t\t\treq.WindowId = blocksWindowId\n\t\t}\n\n\t\tblocks, err := wshclient.BlocksListCommand(RpcClient, req, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)})\n\t\tif err != nil {\n\t\t\tWriteStderr(\"Warning: couldn't list blocks for workspace %s: %v\\n\", wsId, err)\n\t\t\tcontinue\n\t\t}\n\t\thadSuccess = true\n\n\t\t// Apply filters\n\t\tfor _, b := range blocks {\n\t\t\tif blocksTabId != \"\" && b.TabId != blocksTabId {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif blocksView != \"\" {\n\t\t\t\tview := b.Meta.GetString(waveobj.MetaKey_View, \"\")\n\n\t\t\t\t// Support view type aliases\n\t\t\t\tif !matchesViewType(view, blocksView) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tv := b.Meta.GetString(waveobj.MetaKey_View, \"\")\n\t\t\tallBlocks = append(allBlocks, BlockDetails{\n\t\t\t\tBlockId:     b.BlockId,\n\t\t\t\tWorkspaceId: b.WorkspaceId,\n\t\t\t\tTabId:       b.TabId,\n\t\t\t\tView:        v,\n\t\t\t\tMeta:        b.Meta,\n\t\t\t})\n\t\t}\n\t}\n\n\t// No blocks found check\n\tif len(allBlocks) == 0 {\n\t\tif !hadSuccess {\n\t\t\treturn fmt.Errorf(\"failed to list blocks from all %d workspace(s)\", len(workspaceIdsToQuery))\n\t\t}\n\t\tWriteStdout(\"No blocks found\\n\")\n\t\treturn nil\n\t}\n\n\t// Stable ordering for both JSON and table output\n\tsort.SliceStable(allBlocks, func(i, j int) bool {\n\t\tif allBlocks[i].WorkspaceId != allBlocks[j].WorkspaceId {\n\t\t\treturn allBlocks[i].WorkspaceId < allBlocks[j].WorkspaceId\n\t\t}\n\t\tif allBlocks[i].TabId != allBlocks[j].TabId {\n\t\t\treturn allBlocks[i].TabId < allBlocks[j].TabId\n\t\t}\n\t\treturn allBlocks[i].BlockId < allBlocks[j].BlockId\n\t})\n\n\t// Output results\n\tif blocksJSON {\n\t\tbytes, err := json.MarshalIndent(allBlocks, \"\", \"  \")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to marshal JSON: %v\", err)\n\t\t}\n\t\tWriteStdout(\"%s\\n\", string(bytes))\n\t\treturn nil\n\t}\n\tw := tabwriter.NewWriter(WrappedStdout, 0, 0, 2, ' ', 0)\n\tdefer w.Flush()\n\tfmt.Fprintf(w, \"BLOCK ID\\tWORKSPACE\\tTAB ID\\tVIEW\\tCONTENT\\n\")\n\n\tfor _, b := range allBlocks {\n\t\tblockID := b.BlockId\n\t\tif len(blockID) > 36 {\n\t\t\tblockID = blockID[:34] + \"..\"\n\t\t}\n\t\tview := strings.ToLower(b.View)\n\t\tif view == \"\" {\n\t\t\tview = \"<unknown>\"\n\t\t}\n\t\tvar content string\n\n\t\tswitch view {\n\t\tcase \"preview\", \"edit\":\n\t\t\tcontent = b.Meta.GetString(waveobj.MetaKey_File, \"<no file>\")\n\t\tcase \"web\":\n\t\t\tcontent = b.Meta.GetString(waveobj.MetaKey_Url, \"<no url>\")\n\t\tcase \"term\":\n\t\t\tcontent = b.Meta.GetString(waveobj.MetaKey_CmdCwd, \"<no cwd>\")\n\t\tdefault:\n\t\t\tcontent = \"\"\n\t\t}\n\n\t\twsID := b.WorkspaceId\n\t\tif len(wsID) > 36 {\n\t\t\twsID = wsID[:34] + \"..\"\n\t\t}\n\n\t\ttabID := b.TabId\n\t\tif len(tabID) > 36 {\n\t\t\ttabID = tabID[0:34] + \"..\"\n\t\t}\n\n\t\tfmt.Fprintf(w, \"%s\\t%s\\t%s\\t%s\\t%s\\n\", blockID, wsID, tabID, view, content)\n\t}\n\n\treturn nil\n}\n\n// matchesViewType checks if a view type matches a filter, supporting aliases\nfunc matchesViewType(actual, filter string) bool {\n\t// Direct match (case insensitive)\n\tif strings.EqualFold(actual, filter) {\n\t\treturn true\n\t}\n\n\t// Handle aliases\n\tswitch strings.ToLower(filter) {\n\tcase \"preview\", \"edit\":\n\t\treturn strings.EqualFold(actual, \"preview\") || strings.EqualFold(actual, \"edit\")\n\tcase \"terminal\", \"term\", \"shell\", \"console\":\n\t\treturn strings.EqualFold(actual, \"term\")\n\tcase \"web\", \"browser\", \"url\":\n\t\treturn strings.EqualFold(actual, \"web\")\n\tcase \"ai\", \"waveai\", \"assistant\":\n\t\treturn strings.EqualFold(actual, \"waveai\")\n\tcase \"sys\", \"sysinfo\", \"system\":\n\t\treturn strings.EqualFold(actual, \"sysinfo\")\n\t}\n\n\treturn false\n}\n\n// isKnownViewFilter checks if a filter value is recognized\nfunc isKnownViewFilter(f string) bool {\n\tswitch strings.ToLower(strings.TrimSpace(f)) {\n\tcase \"term\", \"terminal\", \"shell\", \"console\",\n\t\t\"web\", \"browser\", \"url\",\n\t\t\"preview\", \"edit\",\n\t\t\"sysinfo\", \"sys\", \"system\",\n\t\t\"waveai\", \"ai\", \"assistant\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-conn.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar connCmd = &cobra.Command{\n\tUse:   \"conn\",\n\tShort: \"manage Wave Terminal connections\",\n\tLong:  \"Commands to manage Wave Terminal SSH and WSL connections\",\n}\n\nvar connStatusCmd = &cobra.Command{\n\tUse:     \"status\",\n\tShort:   \"show status of all connections\",\n\tArgs:    cobra.NoArgs,\n\tRunE:    connStatusRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar connReinstallCmd = &cobra.Command{\n\tUse:     \"reinstall CONNECTION\",\n\tShort:   \"reinstall wsh on a connection\",\n\tRunE:    connReinstallRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar connDisconnectCmd = &cobra.Command{\n\tUse:     \"disconnect CONNECTION\",\n\tShort:   \"disconnect a connection\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    connDisconnectRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar connDisconnectAllCmd = &cobra.Command{\n\tUse:     \"disconnectall\",\n\tShort:   \"disconnect all connections\",\n\tArgs:    cobra.NoArgs,\n\tRunE:    connDisconnectAllRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar connConnectCmd = &cobra.Command{\n\tUse:     \"connect CONNECTION\",\n\tShort:   \"connect to a connection\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    connConnectRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar connEnsureCmd = &cobra.Command{\n\tUse:     \"ensure CONNECTION\",\n\tShort:   \"ensure wsh is installed on a connection\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    connEnsureRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\trootCmd.AddCommand(connCmd)\n\tconnCmd.AddCommand(connStatusCmd)\n\tconnCmd.AddCommand(connReinstallCmd)\n\tconnCmd.AddCommand(connDisconnectCmd)\n\tconnCmd.AddCommand(connDisconnectAllCmd)\n\tconnCmd.AddCommand(connConnectCmd)\n\tconnCmd.AddCommand(connEnsureCmd)\n}\n\nfunc validateConnectionName(name string) error {\n\tif !strings.HasPrefix(name, \"wsl://\") {\n\t\t_, err := remote.ParseOpts(name)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"cannot parse connection name: %w\", err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc getAllConnStatus() ([]wshrpc.ConnStatus, error) {\n\tvar allResp []wshrpc.ConnStatus\n\tsshResp, err := wshclient.ConnStatusCommand(RpcClient, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting ssh connection status: %w\", err)\n\t}\n\tallResp = append(allResp, sshResp...)\n\twslResp, err := wshclient.WslStatusCommand(RpcClient, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting wsl connection status: %w\", err)\n\t}\n\tallResp = append(allResp, wslResp...)\n\treturn allResp, nil\n}\n\nfunc connStatusRun(cmd *cobra.Command, args []string) error {\n\tallResp, err := getAllConnStatus()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(allResp) == 0 {\n\t\tWriteStdout(\"no connections\\n\")\n\t\treturn nil\n\t}\n\tWriteStdout(\"%-30s %-12s\\n\", \"connection\", \"status\")\n\tWriteStdout(\"----------------------------------------------\\n\")\n\tfor _, conn := range allResp {\n\t\tstr := fmt.Sprintf(\"%-30s %-12s\", conn.Connection, conn.Status)\n\t\tif conn.Error != \"\" {\n\t\t\tstr += fmt.Sprintf(\" (%s)\", conn.Error)\n\t\t}\n\t\tWriteStdout(\"%s\\n\", str)\n\t}\n\treturn nil\n}\n\nfunc connReinstallRun(cmd *cobra.Command, args []string) error {\n\tif len(args) != 1 {\n\t\tif RpcContext.Conn == \"\" {\n\t\t\treturn fmt.Errorf(\"no connection specified\")\n\t\t}\n\t\targs = []string{RpcContext.Conn}\n\t}\n\tconnName := args[0]\n\tif err := validateConnectionName(connName); err != nil {\n\t\treturn err\n\t}\n\tdata := wshrpc.ConnExtData{\n\t\tConnName:   connName,\n\t\tLogBlockId: RpcContext.BlockId,\n\t}\n\terr := wshclient.ConnReinstallWshCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 60000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reinstalling connection: %w\", err)\n\t}\n\tWriteStdout(\"wsh reinstalled on connection %q\\n\", connName)\n\treturn nil\n}\n\nfunc connDisconnectRun(cmd *cobra.Command, args []string) error {\n\tconnName := args[0]\n\tif err := validateConnectionName(connName); err != nil {\n\t\treturn err\n\t}\n\terr := wshclient.ConnDisconnectCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 10000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"disconnecting %q error: %w\", connName, err)\n\t}\n\tWriteStdout(\"disconnected %q\\n\", connName)\n\treturn nil\n}\n\nfunc connDisconnectAllRun(cmd *cobra.Command, args []string) error {\n\tallConns, err := getAllConnStatus()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, conn := range allConns {\n\t\tif conn.Status != \"connected\" {\n\t\t\tcontinue\n\t\t}\n\t\terr := wshclient.ConnDisconnectCommand(RpcClient, conn.Connection, &wshrpc.RpcOpts{Timeout: 10000})\n\t\tif err != nil {\n\t\t\tWriteStdout(\"error disconnecting %q: %v\\n\", conn.Connection, err)\n\t\t} else {\n\t\t\tWriteStdout(\"disconnected %q\\n\", conn.Connection)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc connConnectRun(cmd *cobra.Command, args []string) error {\n\tconnName := args[0]\n\tif err := validateConnectionName(connName); err != nil {\n\t\treturn err\n\t}\n\tdata := wshrpc.ConnRequest{\n\t\tHost:       connName,\n\t\tLogBlockId: RpcContext.BlockId,\n\t}\n\terr := wshclient.ConnConnectCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 60000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"connecting connection: %w\", err)\n\t}\n\tWriteStdout(\"connected connection %q\\n\", connName)\n\treturn nil\n}\n\nfunc connEnsureRun(cmd *cobra.Command, args []string) error {\n\tconnName := args[0]\n\tif err := validateConnectionName(connName); err != nil {\n\t\treturn err\n\t}\n\tdata := wshrpc.ConnExtData{\n\t\tConnName:   connName,\n\t\tLogBlockId: RpcContext.BlockId,\n\t}\n\terr := wshclient.ConnEnsureCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 60000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"ensuring connection: %w\", err)\n\t}\n\tWriteStdout(\"wsh ensured on connection %q\\n\", connName)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-connserver.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/envutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/packetparser\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/sigutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavejwt\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nvar serverCmd = &cobra.Command{\n\tUse:    \"connserver\",\n\tHidden: true,\n\tShort:  \"remote server to power wave blocks\",\n\tArgs:   cobra.NoArgs,\n\tRunE:   serverRun,\n}\n\nconst (\n\tJobLogRetentionTime   = 48 * time.Hour\n\tJobLogCleanupDelay    = 10 * time.Second\n\tJobLogCleanupInterval = 1 * time.Hour\n)\n\nvar connServerRouter bool\nvar connServerRouterDomainSocket bool\nvar connServerConnName string\nvar connServerDev bool\nvar ConnServerWshRouter *wshutil.WshRouter\nvar connServerInitialEnv map[string]string\n\nfunc init() {\n\tserverCmd.Flags().BoolVar(&connServerRouter, \"router\", false, \"run in local router mode (stdio upstream)\")\n\tserverCmd.Flags().BoolVar(&connServerRouterDomainSocket, \"router-domainsocket\", false, \"run in local router mode (domain socket upstream)\")\n\tserverCmd.Flags().StringVar(&connServerConnName, \"conn\", \"\", \"connection name\")\n\tserverCmd.Flags().BoolVar(&connServerDev, \"dev\", false, \"enable dev mode with file logging and PID in logs\")\n\trootCmd.AddCommand(serverCmd)\n}\n\nfunc cleanupOldJobLogs() {\n\tjobDir := wavebase.GetRemoteJobLogDir()\n\tentries, err := os.ReadDir(jobDir)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tcutoffTime := time.Now().Add(-JobLogRetentionTime)\n\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\tname := entry.Name()\n\t\tif !strings.HasSuffix(name, \".log\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tinfo, err := entry.Info()\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif info.ModTime().Before(cutoffTime) {\n\t\t\tfilePath := filepath.Join(jobDir, name)\n\t\t\terr := os.Remove(filePath)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"error removing old job log file %s: %v\", filePath, err)\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"removed old job log file: %s\", filePath)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc startJobLogCleanup() {\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"startJobLogCleanup\", recover())\n\t\t}()\n\n\t\ttime.Sleep(JobLogCleanupDelay)\n\n\t\tcleanupOldJobLogs()\n\n\t\tticker := time.NewTicker(JobLogCleanupInterval)\n\t\tdefer ticker.Stop()\n\n\t\tfor range ticker.C {\n\t\t\tcleanupOldJobLogs()\n\t\t}\n\t}()\n}\n\nfunc getRemoteDomainSocketName() string {\n\thomeDir := wavebase.GetHomeDir()\n\treturn filepath.Join(homeDir, wavebase.RemoteWaveHomeDirName, wavebase.RemoteDomainSocketBaseName)\n}\n\nfunc MakeRemoteUnixListener() (net.Listener, error) {\n\tserverAddr := getRemoteDomainSocketName()\n\tos.Remove(serverAddr) // ignore error\n\trtn, err := net.Listen(\"unix\", serverAddr)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating listener at %v: %v\", serverAddr, err)\n\t}\n\tos.Chmod(serverAddr, 0700)\n\tlog.Printf(\"Server [unix-domain] listening on %s\\n\", serverAddr)\n\treturn rtn, nil\n}\n\nfunc handleNewListenerConn(conn net.Conn, router *wshutil.WshRouter) {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"handleNewListenerConn\", recover())\n\t}()\n\tvar linkIdContainer atomic.Int32\n\tproxy := wshutil.MakeRpcProxy(fmt.Sprintf(\"connserver:%s\", conn.RemoteAddr().String()))\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"handleNewListenerConn:AdaptOutputChToStream\", recover())\n\t\t}()\n\t\twriteErr := wshutil.AdaptOutputChToStream(proxy.ToRemoteCh, conn)\n\t\tif writeErr != nil {\n\t\t\tlog.Printf(\"error writing to domain socket: %v\\n\", writeErr)\n\t\t}\n\t}()\n\tgo func() {\n\t\t// when input is closed, close the connection\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"handleNewListenerConn:AdaptStreamToMsgCh\", recover())\n\t\t}()\n\t\tdefer func() {\n\t\t\tconn.Close()\n\t\t\tlinkId := linkIdContainer.Load()\n\t\t\tif linkId != baseds.NoLinkId {\n\t\t\t\trouter.UnregisterLink(baseds.LinkId(linkId))\n\t\t\t}\n\t\t}()\n\t\twshutil.AdaptStreamToMsgCh(conn, proxy.FromRemoteCh, nil)\n\t}()\n\tlinkId := router.RegisterUntrustedLink(proxy)\n\tlinkIdContainer.Store(int32(linkId))\n}\n\nfunc runListener(listener net.Listener, router *wshutil.WshRouter) {\n\tdefer func() {\n\t\tlog.Printf(\"listener closed, exiting\\n\")\n\t\ttime.Sleep(500 * time.Millisecond)\n\t\twshutil.DoShutdown(\"\", 1, true)\n\t}()\n\tfor {\n\t\tconn, err := listener.Accept()\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error accepting connection: %v\\n\", err)\n\t\t\tcontinue\n\t\t}\n\t\tgo handleNewListenerConn(conn, router)\n\t}\n}\n\nfunc setupConnServerRpcClientWithRouter(router *wshutil.WshRouter, sockName string) (*wshutil.WshRpc, error) {\n\trouteId := wshutil.MakeConnectionRouteId(connServerConnName)\n\trpcCtx := wshrpc.RpcContext{\n\t\tRouteId: routeId,\n\t\tConn:    connServerConnName,\n\t}\n\n\tbareRouteId := wshutil.MakeRandomProcRouteId()\n\tbareClient := wshutil.MakeWshRpc(wshrpc.RpcContext{}, &wshclient.WshServer{}, bareRouteId)\n\trouter.RegisterTrustedLeaf(bareClient, bareRouteId)\n\n\tconnServerClient := wshutil.MakeWshRpc(rpcCtx, wshremote.MakeRemoteRpcServerImpl(os.Stdout, router, bareClient, false, connServerInitialEnv, sockName), routeId)\n\trouter.RegisterTrustedLeaf(connServerClient, routeId)\n\treturn connServerClient, nil\n}\n\nfunc serverRunRouter() error {\n\tlog.Printf(\"starting connserver router\")\n\trouter := wshutil.NewWshRouter()\n\tConnServerWshRouter = router\n\ttermProxy := wshutil.MakeRpcProxy(\"connserver-term\")\n\trawCh := make(chan []byte, wshutil.DefaultOutputChSize)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"serverRunRouter:Parse\", recover())\n\t\t}()\n\t\tpacketparser.Parse(os.Stdin, termProxy.FromRemoteCh, rawCh)\n\t}()\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"serverRunRouter:WritePackets\", recover())\n\t\t}()\n\t\tfor msg := range termProxy.ToRemoteCh {\n\t\t\tpacketparser.WritePacket(os.Stdout, msg)\n\t\t}\n\t}()\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"serverRunRouter:drainRawCh\", recover())\n\t\t}()\n\t\tdefer func() {\n\t\t\tlog.Printf(\"stdin closed, shutting down\")\n\t\t\twshutil.DoShutdown(\"\", 0, true)\n\t\t}()\n\t\tfor range rawCh {\n\t\t\t// ignore\n\t\t}\n\t}()\n\trouter.RegisterUpstream(termProxy)\n\n\tsockName := getRemoteDomainSocketName()\n\n\t// setup the connserver rpc client first\n\tclient, err := setupConnServerRpcClientWithRouter(router, sockName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting up connserver rpc client: %v\", err)\n\t}\n\twshfs.RpcClient = client\n\n\tlog.Printf(\"trying to get JWT public key\")\n\n\t// fetch and set JWT public key\n\tjwtPublicKeyB64, err := wshclient.GetJwtPublicKeyCommand(client, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting jwt public key: %v\", err)\n\t}\n\tjwtPublicKeyBytes, err := base64.StdEncoding.DecodeString(jwtPublicKeyB64)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error decoding jwt public key: %v\", err)\n\t}\n\terr = wavejwt.SetPublicKey(jwtPublicKeyBytes)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting jwt public key: %v\", err)\n\t}\n\n\tlog.Printf(\"got JWT public key\")\n\n\t// now set up the domain socket\n\tunixListener, err := MakeRemoteUnixListener()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot create unix listener: %v\", err)\n\t}\n\tlog.Printf(\"unix listener started\")\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"serverRunRouter:runListener\", recover())\n\t\t}()\n\t\trunListener(unixListener, router)\n\t}()\n\t// run the sysinfo loop\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"serverRunRouter:RunSysInfoLoop\", recover())\n\t\t}()\n\t\twshremote.RunSysInfoLoop(client, connServerConnName)\n\t}()\n\tstartJobLogCleanup()\n\tlog.Printf(\"running server, successfully started\")\n\tselect {}\n}\n\nfunc serverRunRouterDomainSocket(jwtToken string) error {\n\tlog.Printf(\"starting connserver router (domain socket upstream)\")\n\n\t// extract socket name from JWT token (unverified - we're on the client side)\n\tsockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error extracting socket name from JWT: %v\", err)\n\t}\n\n\t// connect to the forwarded domain socket\n\tsockName = wavebase.ExpandHomeDirSafe(sockName)\n\tconn, err := net.Dial(\"unix\", sockName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error connecting to domain socket %s: %v\", sockName, err)\n\t}\n\n\t// create router\n\trouter := wshutil.NewWshRouter()\n\tConnServerWshRouter = router\n\n\t// create proxy for the domain socket connection\n\tupstreamProxy := wshutil.MakeRpcProxy(\"connserver-upstream\")\n\n\t// goroutine to write to the domain socket\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"serverRunRouterDomainSocket:WriteLoop\", recover())\n\t\t}()\n\t\twriteErr := wshutil.AdaptOutputChToStream(upstreamProxy.ToRemoteCh, conn)\n\t\tif writeErr != nil {\n\t\t\tlog.Printf(\"error writing to upstream domain socket: %v\\n\", writeErr)\n\t\t}\n\t}()\n\n\t// goroutine to read from the domain socket\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"serverRunRouterDomainSocket:ReadLoop\", recover())\n\t\t}()\n\t\tdefer func() {\n\t\t\tlog.Printf(\"upstream domain socket closed, shutting down\")\n\t\t\twshutil.DoShutdown(\"\", 0, true)\n\t\t}()\n\t\twshutil.AdaptStreamToMsgCh(conn, upstreamProxy.FromRemoteCh, nil)\n\t}()\n\n\t// register the domain socket connection as upstream\n\trouter.RegisterUpstream(upstreamProxy)\n\n\t// use the router's control RPC to authenticate with upstream\n\tcontrolRpc := router.GetControlRpc()\n\n\t// authenticate with the upstream router using the JWT\n\t_, err = wshclient.AuthenticateCommand(controlRpc, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRootRoute})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error authenticating with upstream: %v\", err)\n\t}\n\tlog.Printf(\"authenticated with upstream router\")\n\n\t// fetch and set JWT public key\n\tlog.Printf(\"trying to get JWT public key\")\n\tjwtPublicKeyB64, err := wshclient.GetJwtPublicKeyCommand(controlRpc, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting jwt public key: %v\", err)\n\t}\n\tjwtPublicKeyBytes, err := base64.StdEncoding.DecodeString(jwtPublicKeyB64)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error decoding jwt public key: %v\", err)\n\t}\n\terr = wavejwt.SetPublicKey(jwtPublicKeyBytes)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting jwt public key: %v\", err)\n\t}\n\tlog.Printf(\"got JWT public key\")\n\n\t// now setup the connserver rpc client\n\tclient, err := setupConnServerRpcClientWithRouter(router, sockName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting up connserver rpc client: %v\", err)\n\t}\n\twshfs.RpcClient = client\n\n\t// set up the local domain socket listener for local wsh commands\n\tunixListener, err := MakeRemoteUnixListener()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot create unix listener: %v\", err)\n\t}\n\tlog.Printf(\"unix listener started\")\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"serverRunRouterDomainSocket:runListener\", recover())\n\t\t}()\n\t\trunListener(unixListener, router)\n\t}()\n\n\t// run the sysinfo loop\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"serverRunRouterDomainSocket:RunSysInfoLoop\", recover())\n\t\t}()\n\t\twshremote.RunSysInfoLoop(client, connServerConnName)\n\t}()\n\tstartJobLogCleanup()\n\n\tlog.Printf(\"running server (router-domainsocket mode), successfully started\")\n\tselect {}\n}\n\nfunc serverRunNormal(jwtToken string) error {\n\tsockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error extracting socket name from JWT: %v\", err)\n\t}\n\terr = setupRpcClient(wshremote.MakeRemoteRpcServerImpl(os.Stdout, nil, nil, false, connServerInitialEnv, sockName), jwtToken)\n\tif err != nil {\n\t\treturn err\n\t}\n\twshfs.RpcClient = RpcClient\n\tWriteStdout(\"running wsh connserver (%s)\\n\", RpcContext.Conn)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"serverRunNormal:RunSysInfoLoop\", recover())\n\t\t}()\n\t\twshremote.RunSysInfoLoop(RpcClient, RpcContext.Conn)\n\t}()\n\tstartJobLogCleanup()\n\tselect {} // run forever\n}\n\nfunc askForJwtToken() (string, error) {\n\t// if it already exists in the environment, great, use it\n\tjwtToken := os.Getenv(wavebase.WaveJwtTokenVarName)\n\tif jwtToken != \"\" {\n\t\tfmt.Printf(\"HAVE-JWT\\n\")\n\t\treturn jwtToken, nil\n\t}\n\n\t// otherwise, ask for it\n\tfmt.Printf(\"%s\\n\", wavebase.NeedJwtConst)\n\n\t// read a single line from stdin\n\tvar line string\n\t_, err := fmt.Fscanln(os.Stdin, &line)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to read JWT token from stdin: %w\", err)\n\t}\n\treturn strings.TrimSpace(line), nil\n}\n\nfunc serverRun(cmd *cobra.Command, args []string) error {\n\tconnServerInitialEnv = envutil.PruneInitialEnv(envutil.SliceToMap(os.Environ()))\n\n\tvar logFile *os.File\n\tif connServerDev {\n\t\tvar err error\n\t\tlogFilePath := fmt.Sprintf(\"/tmp/waveterm-connserver-%d.log\", os.Getuid())\n\t\tlogFile, err = os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"failed to open log file: %v\\n\", err)\n\t\t\tlog.SetFlags(log.LstdFlags | log.Lmicroseconds)\n\t\t\tlog.SetPrefix(fmt.Sprintf(\"[PID:%d] \", os.Getpid()))\n\t\t} else {\n\t\t\tdefer logFile.Close()\n\t\t\tlogWriter := io.MultiWriter(os.Stderr, logFile)\n\t\t\tlog.SetOutput(logWriter)\n\t\t\tlog.SetFlags(log.LstdFlags | log.Lmicroseconds)\n\t\t\tlog.SetPrefix(fmt.Sprintf(\"[PID:%d] \", os.Getpid()))\n\t\t}\n\t}\n\tif connServerConnName == \"\" {\n\t\tif logFile != nil {\n\t\t\tfmt.Fprintf(logFile, \"--conn parameter is required\\n\")\n\t\t}\n\t\treturn fmt.Errorf(\"--conn parameter is required\")\n\t}\n\tinstallErr := wshutil.InstallRcFiles()\n\tif installErr != nil {\n\t\tif logFile != nil {\n\t\t\tfmt.Fprintf(logFile, \"error installing rc files: %v\\n\", installErr)\n\t\t}\n\t\tlog.Printf(\"error installing rc files: %v\", installErr)\n\t}\n\tsigutil.InstallSIGUSR1Handler()\n\tif connServerRouter {\n\t\terr := serverRunRouter()\n\t\tif err != nil && logFile != nil {\n\t\t\tfmt.Fprintf(logFile, \"serverRunRouter error: %v\\n\", err)\n\t\t}\n\t\treturn err\n\t}\n\tif connServerRouterDomainSocket {\n\t\tjwtToken, err := askForJwtToken()\n\t\tif err != nil {\n\t\t\tif logFile != nil {\n\t\t\t\tfmt.Fprintf(logFile, \"askForJwtToken error: %v\\n\", err)\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t\terr = serverRunRouterDomainSocket(jwtToken)\n\t\tif err != nil && logFile != nil {\n\t\t\tfmt.Fprintf(logFile, \"serverRunRouterDomainSocket error: %v\\n\", err)\n\t\t}\n\t\treturn err\n\t}\n\tjwtToken, err := askForJwtToken()\n\tif err != nil {\n\t\tif logFile != nil {\n\t\t\tfmt.Fprintf(logFile, \"askForJwtToken error: %v\\n\", err)\n\t\t}\n\t\treturn err\n\t}\n\terr = serverRunNormal(jwtToken)\n\tif err != nil && logFile != nil {\n\t\tfmt.Fprintf(logFile, \"serverRunNormal error: %v\\n\", err)\n\t}\n\treturn err\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-createblock.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar createBlockMagnified bool\n\nvar createBlockCmd = &cobra.Command{\n\tUse:     \"createblock viewname key=value ...\",\n\tShort:   \"create a new block\",\n\tArgs:    cobra.MinimumNArgs(1),\n\tRunE:    createBlockRun,\n\tPreRunE: preRunSetupRpcClient,\n\tHidden:  true,\n}\n\nfunc init() {\n\tcreateBlockCmd.Flags().BoolVarP(&createBlockMagnified, \"magnified\", \"m\", false, \"create block in magnified mode\")\n\trootCmd.AddCommand(createBlockCmd)\n}\n\nfunc createBlockRun(cmd *cobra.Command, args []string) error {\n\tviewName := args[0]\n\tvar metaSetStrs []string\n\tif len(args) > 1 {\n\t\tmetaSetStrs = args[1:]\n\t}\n\ttabId := getTabIdFromEnv()\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"no WAVETERM_TABID env var set\")\n\t}\n\tmeta, err := parseMetaSets(metaSetStrs)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmeta[\"view\"] = viewName\n\tdata := wshrpc.CommandCreateBlockData{\n\t\tTabId: tabId,\n\t\tBlockDef: &waveobj.BlockDef{\n\t\t\tMeta: meta,\n\t\t},\n\t\tMagnified: createBlockMagnified,\n\t\tFocused:   true,\n\t}\n\toref, err := wshclient.CreateBlockCommand(RpcClient, data, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"create block failed: %v\", err)\n\t}\n\tfmt.Printf(\"created block %s\\n\", oref.OID)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-debug.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"encoding/json\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar debugCmd = &cobra.Command{\n\tUse:               \"debug\",\n\tShort:             \"debug commands\",\n\tPersistentPreRunE: preRunSetupRpcClient,\n\tHidden:            true,\n}\n\nvar debugBlockIdsCmd = &cobra.Command{\n\tUse:    \"block\",\n\tShort:  \"list sub-blockids for block\",\n\tRunE:   debugBlockIdsRun,\n\tHidden: true,\n}\n\nvar debugSendTelemetryCmd = &cobra.Command{\n\tUse:    \"send-telemetry\",\n\tShort:  \"send telemetry\",\n\tRunE:   debugSendTelemetryRun,\n\tHidden: true,\n}\n\nfunc init() {\n\tdebugCmd.AddCommand(debugBlockIdsCmd)\n\tdebugCmd.AddCommand(debugSendTelemetryCmd)\n\trootCmd.AddCommand(debugCmd)\n}\n\nfunc debugSendTelemetryRun(cmd *cobra.Command, args []string) error {\n\terr := wshclient.SendTelemetryCommand(RpcClient, nil)\n\treturn err\n}\n\nfunc debugBlockIdsRun(cmd *cobra.Command, args []string) error {\n\toref, err := resolveBlockArg()\n\tif err != nil {\n\t\treturn err\n\t}\n\tblockInfo, err := wshclient.BlockInfoCommand(RpcClient, oref.OID, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbarr, err := json.MarshalIndent(blockInfo, \"\", \"  \")\n\tif err != nil {\n\t\treturn err\n\t}\n\tWriteStdout(\"%s\\n\", string(barr))\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-debugterm.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"unicode/utf8\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nconst (\n\tDebugTermModeHex    = \"hex\"\n\tDebugTermModeDecode = \"decode\"\n)\n\nvar debugTermCmd = &cobra.Command{\n\tUse:                   \"debugterm\",\n\tShort:                 \"inspect recent terminal output bytes\",\n\tRunE:                  debugTermRun,\n\tPreRunE:               debugTermPreRun,\n\tDisableFlagsInUseLine: true,\n\tHidden:                true,\n}\n\nvar (\n\tdebugTermSize  int64\n\tdebugTermMode  string\n\tdebugTermStdin bool\n\tdebugTermInput string\n)\n\nfunc init() {\n\trootCmd.AddCommand(debugTermCmd)\n\tdebugTermCmd.Flags().Int64Var(&debugTermSize, \"size\", 1000, \"number of terminal bytes to read\")\n\tdebugTermCmd.Flags().StringVar(&debugTermMode, \"mode\", DebugTermModeHex, \"output mode: hex or decode\")\n\tdebugTermCmd.Flags().BoolVar(&debugTermStdin, \"stdin\", false, \"read input from stdin instead of rpc call\")\n\tdebugTermCmd.Flags().StringVar(&debugTermInput, \"input\", \"\", \"read input from file instead of rpc call\")\n}\n\nfunc debugTermRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"debugterm\", rtnErr == nil)\n\t}()\n\tmode, err := getDebugTermMode()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif debugTermStdin {\n\t\tstdinData, err := io.ReadAll(WrappedStdin)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"reading stdin: %w\", err)\n\t\t}\n\t\ttermData, err := parseDebugTermStdinData(stdinData)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif mode == DebugTermModeDecode {\n\t\t\tWriteStdout(\"%s\", formatDebugTermDecode(termData))\n\t\t} else {\n\t\t\tWriteStdout(\"%s\", formatDebugTermHex(termData))\n\t\t}\n\t\treturn nil\n\t}\n\tif debugTermInput != \"\" {\n\t\tfileData, err := os.ReadFile(debugTermInput)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"reading input file: %w\", err)\n\t\t}\n\t\ttermData, err := parseDebugTermStdinData(fileData)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif mode == DebugTermModeDecode {\n\t\t\tWriteStdout(\"%s\", formatDebugTermDecode(termData))\n\t\t} else {\n\t\t\tWriteStdout(\"%s\", formatDebugTermHex(termData))\n\t\t}\n\t\treturn nil\n\t}\n\tif debugTermSize <= 0 {\n\t\treturn fmt.Errorf(\"size must be greater than 0\")\n\t}\n\tfullORef, err := resolveBlockArg()\n\tif err != nil {\n\t\treturn err\n\t}\n\trtn, err := wshclient.DebugTermCommand(RpcClient, wshrpc.CommandDebugTermData{\n\t\tBlockId: fullORef.OID,\n\t\tSize:    debugTermSize,\n\t}, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reading terminal output: %w\", err)\n\t}\n\ttermData, err := base64.StdEncoding.DecodeString(rtn.Data64)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"decoding terminal output: %w\", err)\n\t}\n\tvar output string\n\tif mode == DebugTermModeDecode {\n\t\toutput = formatDebugTermDecode(termData)\n\t} else {\n\t\toutput = formatDebugTermHex(termData)\n\t}\n\tWriteStdout(\"%s\", output)\n\treturn nil\n}\n\nfunc debugTermPreRun(cmd *cobra.Command, args []string) error {\n\tif debugTermStdin || debugTermInput != \"\" {\n\t\treturn nil\n\t}\n\treturn preRunSetupRpcClient(cmd, args)\n}\n\nfunc getDebugTermMode() (string, error) {\n\tmode := strings.ToLower(debugTermMode)\n\tif mode != DebugTermModeHex && mode != DebugTermModeDecode {\n\t\treturn \"\", fmt.Errorf(\"invalid mode %q (expected %q or %q)\", debugTermMode, DebugTermModeHex, DebugTermModeDecode)\n\t}\n\treturn mode, nil\n}\n\ntype debugTermStdinEntry struct {\n\tData string `json:\"data\"`\n}\n\nfunc parseDebugTermStdinData(data []byte) ([]byte, error) {\n\ttrimmed := strings.TrimSpace(string(data))\n\tif len(trimmed) == 0 {\n\t\treturn data, nil\n\t}\n\tif trimmed[0] == '[' {\n\t\t// try array of structs first\n\t\tvar structArr []debugTermStdinEntry\n\t\terr := json.Unmarshal(data, &structArr)\n\t\tif err == nil {\n\t\t\tparts := make([]string, len(structArr))\n\t\t\tfor i, entry := range structArr {\n\t\t\t\tparts[i] = entry.Data\n\t\t\t}\n\t\t\treturn []byte(strings.Join(parts, \"\")), nil\n\t\t}\n\t\tfmt.Fprintf(os.Stderr, \"json read err %v\\n\", err)\n\t\t// try array of strings\n\t\tvar strArr []string\n\t\terr = json.Unmarshal(data, &strArr)\n\t\tif err == nil {\n\t\t\treturn []byte(strings.Join(strArr, \"\")), nil\n\t\t}\n\t}\n\treturn data, nil\n}\n\nfunc formatDebugTermHex(data []byte) string {\n\treturn hex.Dump(data)\n}\n\nfunc parseCursorForwardN(seq []byte) (int, bool) {\n\tif len(seq) < 3 || seq[len(seq)-1] != 'C' {\n\t\treturn 0, false\n\t}\n\tparams := string(seq[2 : len(seq)-1])\n\tif params == \"\" {\n\t\treturn 1, true\n\t}\n\tn, err := strconv.Atoi(params)\n\tif err != nil || n <= 0 {\n\t\treturn 0, false\n\t}\n\treturn n, true\n}\n\n// splitOnCRLFRuns splits s at the end of each run of \\r and \\n characters.\n// Each segment includes its trailing CR/LF run. The last segment may have no such run.\nfunc splitOnCRLFRuns(s string) []string {\n\tvar result []string\n\tfor len(s) > 0 {\n\t\t// find start of next CR/LF run\n\t\ti := 0\n\t\tfor i < len(s) && s[i] != '\\r' && s[i] != '\\n' {\n\t\t\ti++\n\t\t}\n\t\tif i == len(s) {\n\t\t\tbreak\n\t\t}\n\t\t// consume the CR/LF run\n\t\tj := i\n\t\tfor j < len(s) && (s[j] == '\\r' || s[j] == '\\n') {\n\t\t\tj++\n\t\t}\n\t\tresult = append(result, s[:j])\n\t\ts = s[j:]\n\t}\n\tif len(s) > 0 {\n\t\tresult = append(result, s)\n\t}\n\treturn result\n}\n\nfunc formatDebugTermDecode(data []byte) string {\n\tif len(data) == 0 {\n\t\treturn \"\"\n\t}\n\tlines := make([]string, 0)\n\t// textBuf accumulates text across CSI-C (cursor forward) sequences so consecutive\n\t// \"word CSI-C word\" runs collapse into a single TXT line. The // NC annotation goes\n\t// on the last segment only.\n\ttextBuf := \"\"\n\ttotalCSpaces := 0\n\tflushText := func() {\n\t\tif textBuf == \"\" && totalCSpaces == 0 {\n\t\t\treturn\n\t\t}\n\t\tsegs := splitOnCRLFRuns(textBuf)\n\t\tif len(segs) == 0 {\n\t\t\tsegs = []string{textBuf}\n\t\t}\n\t\tfor i, seg := range segs {\n\t\t\tif i == len(segs)-1 && totalCSpaces > 0 {\n\t\t\t\tlines = append(lines, fmt.Sprintf(\"TXT %s // %dC\", strconv.Quote(seg), totalCSpaces))\n\t\t\t} else {\n\t\t\t\tlines = append(lines, \"TXT \"+strconv.Quote(seg))\n\t\t\t}\n\t\t}\n\t\ttextBuf = \"\"\n\t\ttotalCSpaces = 0\n\t}\n\tfor i := 0; i < len(data); {\n\t\tb := data[i]\n\t\tif b == 0x1b {\n\t\t\tif i+1 >= len(data) {\n\t\t\t\tflushText()\n\t\t\t\tlines = append(lines, \"ESC\")\n\t\t\t\ti++\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tnext := data[i+1]\n\t\t\tswitch next {\n\t\t\tcase '[':\n\t\t\t\tseq, end := consumeDebugTermCSI(data, i)\n\t\t\t\tif n, ok := parseCursorForwardN(seq); ok {\n\t\t\t\t\ttextBuf += strings.Repeat(\" \", n)\n\t\t\t\t\ttotalCSpaces += n\n\t\t\t\t} else {\n\t\t\t\t\tflushText()\n\t\t\t\t\tlines = append(lines, formatDebugTermCSILine(seq))\n\t\t\t\t}\n\t\t\t\ti = end\n\t\t\tcase ']':\n\t\t\t\tflushText()\n\t\t\t\tseq, end := consumeDebugTermOSC(data, i)\n\t\t\t\tlines = append(lines, formatDebugTermOSCLine(seq))\n\t\t\t\ti = end\n\t\t\tcase 'P':\n\t\t\t\tflushText()\n\t\t\t\tseq, end := consumeDebugTermST(data, i)\n\t\t\t\tlines = append(lines, \"DCS \"+strconv.QuoteToASCII(string(seq)))\n\t\t\t\ti = end\n\t\t\tcase '^':\n\t\t\t\tflushText()\n\t\t\t\tseq, end := consumeDebugTermST(data, i)\n\t\t\t\tlines = append(lines, \"PM \"+strconv.QuoteToASCII(string(seq)))\n\t\t\t\ti = end\n\t\t\tcase '_':\n\t\t\t\tflushText()\n\t\t\t\tseq, end := consumeDebugTermST(data, i)\n\t\t\t\tlines = append(lines, \"APC \"+strconv.QuoteToASCII(string(seq)))\n\t\t\t\ti = end\n\t\t\tdefault:\n\t\t\t\tflushText()\n\t\t\t\tseq := data[i : i+2]\n\t\t\t\tlines = append(lines, \"ESC \"+strconv.QuoteToASCII(string(seq)))\n\t\t\t\ti += 2\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif b == 0x07 {\n\t\t\tflushText()\n\t\t\tlines = append(lines, \"BEL\")\n\t\t\ti++\n\t\t\tcontinue\n\t\t}\n\t\tstart, end := consumeDebugTermText(data, i)\n\t\tif end > start {\n\t\t\ttextBuf += string(data[start:end])\n\t\t\ti = end\n\t\t\tcontinue\n\t\t}\n\t\tflushText()\n\t\tlines = append(lines, fmt.Sprintf(\"CTL 0x%02x\", b))\n\t\ti++\n\t}\n\tflushText()\n\treturn strings.Join(lines, \"\\n\") + \"\\n\"\n}\n\nvar csiCommandDescriptions = map[byte]string{\n\t'@': \"insert character\",\n\t'A': \"cursor up\",\n\t'B': \"cursor down\",\n\t'C': \"cursor forward\",\n\t'D': \"cursor back\",\n\t'E': \"cursor next line\",\n\t'F': \"cursor prev line\",\n\t'G': \"cursor horizontal absolute\",\n\t'H': \"cursor position\",\n\t'I': \"cursor horizontal tab\",\n\t'J': \"erase display\",\n\t'K': \"erase line\",\n\t'L': \"insert line\",\n\t'M': \"delete line\",\n\t'P': \"delete character\",\n\t'S': \"scroll up\",\n\t'T': \"scroll down\",\n\t'X': \"erase character\",\n\t'Z': \"cursor backward tab\",\n\t'a': \"cursor horizontal relative\",\n\t'b': \"repeat character\",\n\t'c': \"device attributes\",\n\t'd': \"cursor vertical absolute\",\n\t'e': \"cursor vertical relative\",\n\t'f': \"horizontal vertical position\",\n\t'g': \"tab clear\",\n\t'h': \"set mode\",\n\t'l': \"reset mode\",\n\t'm': \"SGR\",\n\t'n': \"device status report\",\n\t'r': \"set scrolling region\",\n\t's': \"save cursor\",\n\t'u': \"restore cursor\",\n}\n\nvar decModeDescriptions = map[string]string{\n\t\"1\":    \"application cursor keys\",\n\t\"3\":    \"132 column mode\",\n\t\"6\":    \"origin mode\",\n\t\"7\":    \"auto wrap\",\n\t\"12\":   \"blinking cursor\",\n\t\"25\":   \"show cursor\",\n\t\"47\":   \"alternate screen\",\n\t\"1000\": \"mouse X10 tracking\",\n\t\"1002\": \"mouse button events\",\n\t\"1003\": \"mouse all events\",\n\t\"1004\": \"focus events\",\n\t\"1006\": \"SGR mouse mode\",\n\t\"1049\": \"alt screen + save cursor\",\n\t\"2004\": \"bracketed paste\",\n\t\"2026\": \"synchronized output\",\n}\n\nvar sgrSingleDescriptions = map[int]string{\n\t0:  \"reset all\",\n\t1:  \"bold\",\n\t2:  \"dim\",\n\t3:  \"italic\",\n\t4:  \"underline\",\n\t5:  \"blink\",\n\t7:  \"reverse\",\n\t8:  \"hidden\",\n\t9:  \"strikethrough\",\n\t21: \"doubly underlined\",\n\t22: \"normal intensity\",\n\t23: \"not italic\",\n\t24: \"not underlined\",\n\t25: \"not blinking\",\n\t27: \"not reversed\",\n\t28: \"not hidden\",\n\t29: \"not strikethrough\",\n\t39: \"default fg\",\n\t49: \"default bg\",\n}\n\nfunc describeSGR(params string) string {\n\tif params == \"\" {\n\t\treturn \"reset all\"\n\t}\n\tparts := strings.Split(params, \";\")\n\tif len(parts) >= 5 && parts[0] == \"38\" && parts[1] == \"2\" {\n\t\treturn fmt.Sprintf(\"fg rgb(%s,%s,%s)\", parts[2], parts[3], parts[4])\n\t}\n\tif len(parts) >= 5 && parts[0] == \"48\" && parts[1] == \"2\" {\n\t\treturn fmt.Sprintf(\"bg rgb(%s,%s,%s)\", parts[2], parts[3], parts[4])\n\t}\n\tif len(parts) == 3 && parts[0] == \"38\" && parts[1] == \"5\" {\n\t\treturn fmt.Sprintf(\"fg color256(%s)\", parts[2])\n\t}\n\tif len(parts) == 3 && parts[0] == \"48\" && parts[1] == \"5\" {\n\t\treturn fmt.Sprintf(\"bg color256(%s)\", parts[2])\n\t}\n\tif len(parts) != 1 {\n\t\treturn \"\"\n\t}\n\tn, err := strconv.Atoi(parts[0])\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif desc, ok := sgrSingleDescriptions[n]; ok {\n\t\treturn desc\n\t}\n\tif n >= 30 && n <= 37 {\n\t\treturn fmt.Sprintf(\"fg ansi color %d\", n-30)\n\t}\n\tif n >= 40 && n <= 47 {\n\t\treturn fmt.Sprintf(\"bg ansi color %d\", n-40)\n\t}\n\tif n >= 90 && n <= 97 {\n\t\treturn fmt.Sprintf(\"fg bright color %d\", n-90)\n\t}\n\tif n >= 100 && n <= 107 {\n\t\treturn fmt.Sprintf(\"bg bright color %d\", n-100)\n\t}\n\treturn \"\"\n}\n\nfunc formatDebugTermCSILine(seq []byte) string {\n\t// seq is the full sequence starting with ESC [\n\tif len(seq) < 3 {\n\t\treturn \"CSI \" + strconv.QuoteToASCII(string(seq))\n\t}\n\tinner := seq[2:]\n\tfinalByte := inner[len(inner)-1]\n\tparams := string(inner[:len(inner)-1])\n\n\t// DEC private mode: params starts with \"?\" and final byte is 'h' (set) or 'l' (reset)\n\tif strings.HasPrefix(params, \"?\") && (finalByte == 'h' || finalByte == 'l') {\n\t\tmodeStr := params[1:]\n\t\tvar line string\n\t\tif finalByte == 'h' {\n\t\t\tline = \"DEC SET \" + modeStr\n\t\t} else {\n\t\t\tline = \"DEC RST \" + modeStr\n\t\t}\n\t\tif desc, ok := decModeDescriptions[modeStr]; ok {\n\t\t\tline += \" // \" + desc\n\t\t}\n\t\treturn line\n\t}\n\n\tfinalStr := string([]byte{finalByte})\n\tvar line string\n\tif params == \"\" {\n\t\tline = \"CSI \" + finalStr\n\t} else {\n\t\tline = \"CSI \" + finalStr + \" \" + params\n\t}\n\tif finalByte == 'm' {\n\t\tif desc := describeSGR(params); desc != \"\" {\n\t\t\tline += \" // \" + desc\n\t\t}\n\t} else if desc, ok := csiCommandDescriptions[finalByte]; ok {\n\t\tline += \" // \" + desc\n\t}\n\treturn line\n}\n\nfunc consumeDebugTermCSI(data []byte, start int) ([]byte, int) {\n\ti := start + 2\n\tfor i < len(data) {\n\t\tif data[i] >= 0x40 && data[i] <= 0x7e {\n\t\t\treturn data[start : i+1], i + 1\n\t\t}\n\t\ti++\n\t}\n\treturn data[start:], len(data)\n}\n\nfunc formatDebugTermOSCLine(seq []byte) string {\n\t// seq is the full sequence starting with ESC ]\n\tif len(seq) < 3 {\n\t\treturn \"OSC \" + strconv.QuoteToASCII(string(seq))\n\t}\n\t// strip ESC ] prefix\n\tinner := string(seq[2:])\n\t// strip trailing BEL or ST (ESC \\)\n\tinner = strings.TrimSuffix(inner, \"\\x07\")\n\tinner = strings.TrimSuffix(inner, \"\\x1b\\\\\")\n\t// split code from data on first ;\n\tif idx := strings.IndexByte(inner, ';'); idx >= 0 {\n\t\tcode := inner[:idx]\n\t\tdata := inner[idx+1:]\n\t\treturn \"OSC \" + code + \" \" + strconv.QuoteToASCII(data)\n\t}\n\treturn \"OSC \" + strconv.QuoteToASCII(inner)\n}\n\nfunc consumeDebugTermOSC(data []byte, start int) ([]byte, int) {\n\ti := start + 2\n\tfor i < len(data) {\n\t\tif data[i] == 0x07 {\n\t\t\treturn data[start : i+1], i + 1\n\t\t}\n\t\tif data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\\\' {\n\t\t\treturn data[start : i+2], i + 2\n\t\t}\n\t\ti++\n\t}\n\treturn data[start:], len(data)\n}\n\nfunc consumeDebugTermST(data []byte, start int) ([]byte, int) {\n\ti := start + 2\n\tfor i < len(data) {\n\t\tif data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\\\' {\n\t\t\treturn data[start : i+2], i + 2\n\t\t}\n\t\ti++\n\t}\n\treturn data[start:], len(data)\n}\n\nfunc isDebugTermC0Control(b byte) bool {\n\treturn b < 0x20 || b == 0x7f\n}\n\nfunc consumeDebugTermText(data []byte, i int) (start, end int) {\n\tstart = i\n\tfor i < len(data) {\n\t\tb := data[i]\n\t\tif b == 0x1b || b == 0x07 {\n\t\t\tbreak\n\t\t}\n\t\tif b == '\\n' || b == '\\r' || b == '\\t' {\n\t\t\ti++\n\t\t\tcontinue\n\t\t}\n\t\tif isDebugTermC0Control(b) {\n\t\t\tbreak\n\t\t}\n\t\tif b < 0x80 {\n\t\t\ti++\n\t\t\tcontinue\n\t\t}\n\t\t_, sz := utf8.DecodeRune(data[i:])\n\t\tif sz == 1 {\n\t\t\tbreak\n\t\t}\n\t\ti += sz\n\t}\n\treturn start, i\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-debugterm_test.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestFormatDebugTermHex(t *testing.T) {\n\toutput := formatDebugTermHex([]byte(\"abc\"))\n\tif !strings.Contains(output, \"61 62 63\") {\n\t\tt.Fatalf(\"unexpected hex output: %q\", output)\n\t}\n}\n\nfunc TestFormatDebugTermDecode(t *testing.T) {\n\tdata := []byte(\"abc\\x1b[31mred\\x1b[0m\\x07\\x1b]0;title\\x07\\x00\")\n\toutput := formatDebugTermDecode(data)\n\texpected := []string{\n\t\t`TXT \"abc\"`,\n\t\t`CSI m 31`,\n\t\t`TXT \"red\"`,\n\t\t`CSI m 0`,\n\t\t`BEL`,\n\t\t`OSC 0 \"title\"`,\n\t\t`CTL 0x00`,\n\t}\n\tfor _, line := range expected {\n\t\tif !strings.Contains(output, line) {\n\t\t\tt.Fatalf(\"missing decode line %q in output %q\", line, output)\n\t\t}\n\t}\n}\n\nfunc TestParseDebugTermStdinData(t *testing.T) {\n\tdata, err := parseDebugTermStdinData([]byte(`[\"abc\",\"\\u001b[31mred\",\"\\u001b[0m\"]`))\n\tif err != nil {\n\t\tt.Fatalf(\"parseDebugTermStdinData() error: %v\", err)\n\t}\n\toutput := formatDebugTermDecode(data)\n\texpected := []string{\n\t\t`TXT \"abc\"`,\n\t\t`CSI m 31`,\n\t\t`TXT \"red\"`,\n\t\t`CSI m 0`,\n\t}\n\tfor _, line := range expected {\n\t\tif !strings.Contains(output, line) {\n\t\t\tt.Fatalf(\"missing decode line %q in output %q\", line, output)\n\t\t}\n\t}\n}\n\nfunc TestParseDebugTermStdinDataStructs(t *testing.T) {\n\tdata, err := parseDebugTermStdinData([]byte(`[{\"data\":\"abc\"},{\"data\":\"\\u001b[31mred\"},{\"data\":\"\\u001b[0m\"}]`))\n\tif err != nil {\n\t\tt.Fatalf(\"parseDebugTermStdinData() error: %v\", err)\n\t}\n\toutput := formatDebugTermDecode(data)\n\texpected := []string{\n\t\t`TXT \"abc\"`,\n\t\t`CSI m 31`,\n\t\t`TXT \"red\"`,\n\t\t`CSI m 0`,\n\t}\n\tfor _, line := range expected {\n\t\tif !strings.Contains(output, line) {\n\t\t\tt.Fatalf(\"missing decode line %q in output %q\", line, output)\n\t\t}\n\t}\n}\n\nfunc TestFormatDebugTermDecodeCursorForward(t *testing.T) {\n\t// CSI C sequences collapse into adjacent text; all consecutive text+CSI-C runs merge into one TXT line.\n\t// The run is split into separate TXT lines at CR/LF run boundaries; // NC appears on the last line.\n\tdata := []byte(\"hi\\x1b[1Cworld\\x1b[3Cfoo\\r\\nbar\")\n\toutput := formatDebugTermDecode(data)\n\texpected := []string{\n\t\t`TXT \"hi world   foo\\r\\n\"`,\n\t\t`TXT \"bar\" // 4C`,\n\t}\n\tfor _, line := range expected {\n\t\tif !strings.Contains(output, line) {\n\t\t\tt.Fatalf(\"missing decode line %q in output:\\n%s\", line, output)\n\t\t}\n\t}\n}\n\nfunc TestParseDebugTermStdinDataRaw(t *testing.T) {\n\traw := []byte(\"hello\\x1b[31mworld\")\n\tdata, err := parseDebugTermStdinData(raw)\n\tif err != nil {\n\t\tt.Fatalf(\"parseDebugTermStdinData() error: %v\", err)\n\t}\n\tif string(data) != string(raw) {\n\t\tt.Fatalf(\"expected raw passthrough, got %q\", data)\n\t}\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-deleteblock.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar deleteBlockCmd = &cobra.Command{\n\tUse:     \"deleteblock\",\n\tShort:   \"delete a block\",\n\tRunE:    deleteBlockRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\trootCmd.AddCommand(deleteBlockCmd)\n}\n\nfunc deleteBlockRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"deleteblock\", rtnErr == nil)\n\t}()\n\tfullORef, err := resolveBlockArg()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fullORef.OType != \"block\" {\n\t\treturn fmt.Errorf(\"object reference is not a block\")\n\t}\n\tdeleteBlockData := &wshrpc.CommandDeleteBlockData{\n\t\tBlockId: fullORef.OID,\n\t}\n\terr = wshclient.DeleteBlockCommand(RpcClient, *deleteBlockData, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"delete block failed: %v\", err)\n\t}\n\tWriteStdout(\"block deleted\\n\")\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-editconfig.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar editConfigMagnified bool\n\nvar editConfigCmd = &cobra.Command{\n\tUse:     \"editconfig [configfile]\",\n\tShort:   \"edit Wave configuration files\",\n\tLong:    \"Edit Wave configuration files. Defaults to settings.json if no file specified. Common files: settings.json, presets.json, widgets.json\",\n\tArgs:    cobra.MaximumNArgs(1),\n\tRunE:    editConfigRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\teditConfigCmd.Flags().BoolVarP(&editConfigMagnified, \"magnified\", \"m\", false, \"open config in magnified mode\")\n\trootCmd.AddCommand(editConfigCmd)\n}\n\nfunc editConfigRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"editconfig\", rtnErr == nil)\n\t}()\n\n\tconfigFile := \"settings.json\" // default\n\tif len(args) > 0 {\n\t\tconfigFile = args[0]\n\t}\n\n\ttabId := getTabIdFromEnv()\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"no WAVETERM_TABID env var set\")\n\t}\n\n\twshCmd := &wshrpc.CommandCreateBlockData{\n\t\tTabId: tabId,\n\t\tBlockDef: &waveobj.BlockDef{\n\t\t\tMeta: map[string]interface{}{\n\t\t\t\twaveobj.MetaKey_View: \"waveconfig\",\n\t\t\t\twaveobj.MetaKey_File: configFile,\n\t\t\t},\n\t\t},\n\t\tMagnified: editConfigMagnified,\n\t\tFocused:   true,\n\t}\n\n\t_, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"opening config file: %w\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-editor.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar editMagnified bool\n\nvar editorCmd = &cobra.Command{\n\tUse:     \"editor\",\n\tShort:   \"edit a file (blocks until editor is closed)\",\n\tRunE:    editorRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\teditorCmd.Flags().BoolVarP(&editMagnified, \"magnified\", \"m\", false, \"open view in magnified mode\")\n\trootCmd.AddCommand(editorCmd)\n}\n\nfunc editorRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"editor\", rtnErr == nil)\n\t}()\n\tif len(args) == 0 {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"no arguments.  wsh editor requires a file or URL as an argument argument\")\n\t}\n\tif len(args) > 1 {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"too many arguments.  wsh editor requires exactly one argument\")\n\t}\n\tfileArg := args[0]\n\tabsFile, err := filepath.Abs(fileArg)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting absolute path: %w\", err)\n\t}\n\t_, err = os.Stat(absFile)\n\tif err == fs.ErrNotExist {\n\t\treturn fmt.Errorf(\"file does not exist: %q\", absFile)\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting file info: %w\", err)\n\t}\n\n\ttabId := getTabIdFromEnv()\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"no WAVETERM_TABID env var set\")\n\t}\n\n\twshCmd := wshrpc.CommandCreateBlockData{\n\t\tTabId: tabId,\n\t\tBlockDef: &waveobj.BlockDef{\n\t\t\tMeta: map[string]any{\n\t\t\t\twaveobj.MetaKey_View: \"preview\",\n\t\t\t\twaveobj.MetaKey_File: absFile,\n\t\t\t\twaveobj.MetaKey_Edit: true,\n\t\t\t},\n\t\t},\n\t\tMagnified: editMagnified,\n\t\tFocused:   true,\n\t}\n\tif RpcContext.Conn != \"\" {\n\t\twshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = RpcContext.Conn\n\t}\n\tblockRef, err := wshclient.CreateBlockCommand(RpcClient, wshCmd, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"running view command: %w\", err)\n\t}\n\tdoneCh := make(chan bool)\n\tRpcClient.EventListener.On(wps.Event_BlockClose, func(event *wps.WaveEvent) {\n\t\tif event.HasScope(blockRef.String()) {\n\t\t\tclose(doneCh)\n\t\t}\n\t})\n\twshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{blockRef.String()}}, nil)\n\t<-doneCh\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-file-util.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/remote/connparse\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/fileshare/fsutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/fileutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nfunc convertNotFoundErr(err error) error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\tif strings.HasPrefix(err.Error(), \"NOTFOUND:\") {\n\t\treturn fs.ErrNotExist\n\t}\n\treturn err\n}\n\nfunc ensureFile(fileData wshrpc.FileData) (*wshrpc.FileInfo, error) {\n\tinfo, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout})\n\terr = convertNotFoundErr(err)\n\tif err == fs.ErrNotExist {\n\t\terr = wshclient.FileCreateCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"creating file: %w\", err)\n\t\t}\n\t\tinfo, err = wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"getting file info: %w\", err)\n\t\t}\n\t\treturn info, err\n\t}\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting file info: %w\", err)\n\t}\n\treturn info, nil\n}\n\nfunc streamWriteToFile(fileData wshrpc.FileData, reader io.Reader) error {\n\t// First truncate the file with an empty write\n\temptyWrite := fileData\n\temptyWrite.Data64 = \"\"\n\terr := wshclient.FileWriteCommand(RpcClient, emptyWrite, &wshrpc.RpcOpts{Timeout: fileTimeout})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"initializing file with empty write: %w\", err)\n\t}\n\n\tconst chunkSize = wshrpc.FileChunkSize // 32KB chunks\n\tbuf := make([]byte, chunkSize)\n\ttotalWritten := int64(0)\n\n\tfor {\n\t\tn, err := reader.Read(buf)\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"reading input: %w\", err)\n\t\t}\n\n\t\t// Check total size\n\t\ttotalWritten += int64(n)\n\t\tif totalWritten > MaxFileSize {\n\t\t\treturn fmt.Errorf(\"input exceeds maximum file size of %d bytes\", MaxFileSize)\n\t\t}\n\n\t\t// Prepare and send chunk\n\t\tchunk := buf[:n]\n\t\tappendData := fileData\n\t\tappendData.Data64 = base64.StdEncoding.EncodeToString(chunk)\n\n\t\terr = wshclient.FileAppendCommand(RpcClient, appendData, &wshrpc.RpcOpts{Timeout: int64(fileTimeout)})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"appending chunk to file: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc streamReadFromFile(ctx context.Context, fileData wshrpc.FileData, writer io.Writer) error {\n\tch := wshclient.FileReadStreamCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout})\n\treturn fsutil.ReadFileStreamToWriter(ctx, ch, writer)\n}\n\nfunc fixRelativePaths(path string) (string, error) {\n\tconn, err := connparse.ParseURI(path)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif conn.Scheme == connparse.ConnectionTypeWsh {\n\t\tif conn.Host == connparse.ConnHostCurrent {\n\t\t\tconn.Host = RpcContext.Conn\n\t\t\tfixedPath, err := fileutil.FixPath(conn.Path)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", err\n\t\t\t}\n\t\t\tconn.Path = fixedPath\n\t\t}\n\t\tif conn.Host == \"\" {\n\t\t\tconn.Host = wshrpc.LocalConnName\n\t\t}\n\t}\n\treturn conn.GetFullURI(), nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-file.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"strings\"\n\t\"text/tabwriter\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"golang.org/x/term\"\n)\n\nconst (\n\tMaxFileSize = 10 * 1024 * 1024 // 10MB\n\n\tTimeoutYear = int64(365) * 24 * 60 * 60 * 1000\n\n\tUriHelpText = `\n\nURI format: [profile]:[uri-scheme]://[connection]/[path]\n\nSupported URI schemes:\n  wsh:\n    Used to access files on remote hosts over SSH via the WSH helper. Allows\n    for file streaming to Wave and other remotes.\n\n    Profiles are optional for WSH URIs, provided that you have configured the\n    remote host in your \"connections.json\" or \"~/.ssh/config\" file.\n\n    If a profile is provided, it must be defined in \"profiles.json\" in the Wave\n    configuration directory.\n\n    Format: wsh://[remote]/[path]\n\n    Shorthands can be used for the current remote and your local computer:\n      [path]              a relative or absolute path on the current remote\n      //[remote]/[path]   a path on a remote\n      /~/[path]           a path relative to the home directory on your local\n                          computer`\n)\n\nvar fileCmd = &cobra.Command{\n\tUse:   \"file\",\n\tShort: \"manage files across local and remote systems\",\n\tLong: `Manage files across local and remote systems.\n    \nWave Terminal is capable of managing files from remote SSH hosts and your local\ncomputer. Files are addressed via URIs.` + UriHelpText}\n\nvar fileTimeout int64\n\nfunc init() {\n\trootCmd.AddCommand(fileCmd)\n\n\tfileCmd.PersistentFlags().Int64VarP(&fileTimeout, \"timeout\", \"t\", 15000, \"timeout in milliseconds for long operations\")\n\n\tfileListCmd.Flags().BoolP(\"long\", \"l\", false, \"use long listing format\")\n\tfileListCmd.Flags().BoolP(\"one\", \"1\", false, \"list one file per line\")\n\tfileListCmd.Flags().BoolP(\"files\", \"f\", false, \"list files only\")\n\n\tfileCmd.AddCommand(fileListCmd)\n\tfileCmd.AddCommand(fileCatCmd)\n\tfileCmd.AddCommand(fileWriteCmd)\n\tfileRmCmd.Flags().BoolP(\"recursive\", \"r\", false, \"remove directories recursively\")\n\tfileCmd.AddCommand(fileRmCmd)\n\tfileCmd.AddCommand(fileInfoCmd)\n\tfileCmd.AddCommand(fileAppendCmd)\n\tfileCpCmd.Flags().BoolP(\"merge\", \"m\", false, \"merge directories\")\n\tfileCpCmd.Flags().BoolP(\"force\", \"f\", false, \"force overwrite of existing files\")\n\tfileCmd.AddCommand(fileCpCmd)\n\tfileMvCmd.Flags().BoolP(\"force\", \"f\", false, \"force overwrite of existing files\")\n\tfileCmd.AddCommand(fileMvCmd)\n}\n\nvar fileListCmd = &cobra.Command{\n\tUse:     \"ls [uri]\",\n\tAliases: []string{\"list\"},\n\tShort:   \"list files\",\n\tLong:    \"List files in a directory. By default, lists files in the current directory.\" + UriHelpText,\n\tExample: \"  wsh file ls wsh://user@ec2/home/user/\",\n\tRunE:    activityWrap(\"file\", fileListRun),\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar fileCatCmd = &cobra.Command{\n\tUse:     \"cat [uri]\",\n\tShort:   \"display contents of a file\",\n\tLong:    \"Display the contents of a file.\" + UriHelpText,\n\tExample: \"  wsh file cat wsh://user@ec2/home/user/config.txt\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    activityWrap(\"file\", fileCatRun),\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar fileInfoCmd = &cobra.Command{\n\tUse:     \"info [uri]\",\n\tShort:   \"show wave file information\",\n\tLong:    \"Show information about a file.\" + UriHelpText,\n\tExample: \"  wsh file info wsh://user@ec2/home/user/config.txt\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    activityWrap(\"file\", fileInfoRun),\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar fileRmCmd = &cobra.Command{\n\tUse:     \"rm [uri]\",\n\tShort:   \"remove a file\",\n\tLong:    \"Remove a file.\" + UriHelpText,\n\tExample: \"  wsh file rm wsh://user@ec2/home/user/config.txt\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    activityWrap(\"file\", fileRmRun),\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar fileWriteCmd = &cobra.Command{\n\tUse:     \"write [uri]\",\n\tShort:   \"write stdin into a file (up to 10MB)\",\n\tLong:    \"Write stdin into a file, buffering input (10MB total file size limit).\" + UriHelpText,\n\tExample: \"  echo 'hello' | wsh file write ./greeting.txt\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    activityWrap(\"file\", fileWriteRun),\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar fileAppendCmd = &cobra.Command{\n\tUse:     \"append [uri]\",\n\tShort:   \"append stdin to a file\",\n\tLong:    \"Append stdin to a file, buffering input (10MB total file size limit).\" + UriHelpText,\n\tExample: \"  tail -f log.txt | wsh file append ./app.log\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    activityWrap(\"file\", fileAppendRun),\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar fileCpCmd = &cobra.Command{\n\tUse:     \"cp [source-uri] [destination-uri]\" + UriHelpText,\n\tAliases: []string{\"copy\"},\n\tShort:   \"copy files between storage systems, recursively if needed\",\n\tLong:    \"Copy files between different storage systems.\" + UriHelpText,\n\tExample: \"  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\",\n\tArgs:    cobra.ExactArgs(2),\n\tRunE:    activityWrap(\"file\", fileCpRun),\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar fileMvCmd = &cobra.Command{\n\tUse:     \"mv [source-uri] [destination-uri]\" + UriHelpText,\n\tAliases: []string{\"move\"},\n\tShort:   \"move files between storage systems\",\n\tLong:    \"Move files between different storage systems. The source file will be deleted once the operation completes successfully.\" + UriHelpText,\n\tExample: \"  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\",\n\tArgs:    cobra.ExactArgs(2),\n\tRunE:    activityWrap(\"file\", fileMvRun),\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc fileCatRun(cmd *cobra.Command, args []string) error {\n\tpath, err := fixRelativePaths(args[0])\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = checkFileSize(path, MaxFileSize)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfileData := wshrpc.FileData{\n\t\tInfo: &wshrpc.FileInfo{\n\t\t\tPath: path}}\n\n\terr = streamReadFromFile(cmd.Context(), fileData, os.Stdout)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reading file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc fileInfoRun(cmd *cobra.Command, args []string) error {\n\tpath, err := fixRelativePaths(args[0])\n\tif err != nil {\n\t\treturn err\n\t}\n\tfileData := wshrpc.FileData{\n\t\tInfo: &wshrpc.FileInfo{\n\t\t\tPath: path}}\n\n\tinfo, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout})\n\terr = convertNotFoundErr(err)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting file info: %w\", err)\n\t}\n\n\tif info.NotFound {\n\t\treturn fmt.Errorf(\"%s: no such file\", path)\n\t}\n\n\tWriteStdout(\"name:\\t%s\\n\", info.Name)\n\tif info.Mode != 0 {\n\t\tWriteStdout(\"mode:\\t%s\\n\", info.Mode.String())\n\t}\n\tWriteStdout(\"mtime:\\t%s\\n\", time.Unix(info.ModTime/1000, 0).Format(time.DateTime))\n\tif !info.IsDir {\n\t\tWriteStdout(\"size:\\t%d\\n\", info.Size)\n\t}\n\tif info.Meta != nil && len(*info.Meta) > 0 {\n\t\tWriteStdout(\"metadata:\\n\")\n\t\tfor k, v := range *info.Meta {\n\t\t\tWriteStdout(\"\\t\\t\\t%s: %v\\n\", k, v)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc fileRmRun(cmd *cobra.Command, args []string) error {\n\tpath, err := fixRelativePaths(args[0])\n\tif err != nil {\n\t\treturn err\n\t}\n\trecursive, err := cmd.Flags().GetBool(\"recursive\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = wshclient.FileDeleteCommand(RpcClient, wshrpc.CommandDeleteFileData{Path: path, Recursive: recursive}, &wshrpc.RpcOpts{Timeout: fileTimeout})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"removing file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc fileWriteRun(cmd *cobra.Command, args []string) error {\n\tpath, err := fixRelativePaths(args[0])\n\tif err != nil {\n\t\treturn err\n\t}\n\tfileData := wshrpc.FileData{\n\t\tInfo: &wshrpc.FileInfo{\n\t\t\tPath: path}}\n\n\tlimitReader := io.LimitReader(WrappedStdin, MaxFileSize+1)\n\tdata, err := io.ReadAll(limitReader)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reading input: %w\", err)\n\t}\n\tif len(data) > MaxFileSize {\n\t\treturn fmt.Errorf(\"input exceeds maximum file size of %d bytes\", MaxFileSize)\n\t}\n\tfileData.Data64 = base64.StdEncoding.EncodeToString(data)\n\terr = wshclient.FileWriteCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"writing file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc fileAppendRun(cmd *cobra.Command, args []string) error {\n\tpath, err := fixRelativePaths(args[0])\n\tif err != nil {\n\t\treturn err\n\t}\n\tfileData := wshrpc.FileData{\n\t\tInfo: &wshrpc.FileInfo{\n\t\t\tPath: path}}\n\n\tinfo, err := ensureFile(fileData)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif info.Size >= MaxFileSize {\n\t\treturn fmt.Errorf(\"file already at maximum size (%d bytes)\", MaxFileSize)\n\t}\n\n\treader := bufio.NewReader(WrappedStdin)\n\tvar buf bytes.Buffer\n\tremainingSpace := MaxFileSize - info.Size\n\tfor {\n\t\tchunk := make([]byte, 8192)\n\t\tn, err := reader.Read(chunk)\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"reading input: %w\", err)\n\t\t}\n\n\t\tif int64(buf.Len()+n) > remainingSpace {\n\t\t\treturn fmt.Errorf(\"append would exceed maximum file size of %d bytes\", MaxFileSize)\n\t\t}\n\n\t\tbuf.Write(chunk[:n])\n\n\t\tif buf.Len() >= 8192 { // 8KB batch size\n\t\t\tfileData.Data64 = base64.StdEncoding.EncodeToString(buf.Bytes())\n\t\t\terr = wshclient.FileAppendCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout})\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"appending to file: %w\", err)\n\t\t\t}\n\t\t\tremainingSpace -= int64(buf.Len())\n\t\t\tbuf.Reset()\n\t\t}\n\t}\n\n\tif buf.Len() > 0 {\n\t\tfileData.Data64 = base64.StdEncoding.EncodeToString(buf.Bytes())\n\t\terr = wshclient.FileAppendCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"appending to file: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc checkFileSize(path string, maxSize int64) (*wshrpc.FileInfo, error) {\n\tfileData := wshrpc.FileData{\n\t\tInfo: &wshrpc.FileInfo{\n\t\t\tPath: path}}\n\n\tinfo, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout})\n\terr = convertNotFoundErr(err)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting file info: %w\", err)\n\t}\n\tif info.NotFound {\n\t\treturn nil, fmt.Errorf(\"%s: no such file\", path)\n\t}\n\tif info.IsDir {\n\t\treturn nil, fmt.Errorf(\"%s: is a directory\", path)\n\t}\n\tif info.Size > maxSize {\n\t\treturn nil, fmt.Errorf(\"file size (%d bytes) exceeds maximum of %d bytes\", info.Size, maxSize)\n\t}\n\treturn info, nil\n}\n\nfunc fileCpRun(cmd *cobra.Command, args []string) error {\n\tsrc, dst := args[0], args[1]\n\tmerge, err := cmd.Flags().GetBool(\"merge\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tforce, err := cmd.Flags().GetBool(\"force\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsrcPath, err := fixRelativePaths(src)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to parse src path: %w\", err)\n\t}\n\n\t_, err = checkFileSize(srcPath, MaxFileSize)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdestPath, err := fixRelativePaths(dst)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to parse dest path: %w\", err)\n\t}\n\tlog.Printf(\"Copying %s to %s; merge: %v, force: %v\", srcPath, destPath, merge, force)\n\trpcOpts := &wshrpc.RpcOpts{Timeout: TimeoutYear}\n\terr = wshclient.FileCopyCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcPath, DestUri: destPath, Opts: &wshrpc.FileCopyOpts{Merge: merge, Overwrite: force, Timeout: TimeoutYear}}, rpcOpts)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"copying file: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc fileMvRun(cmd *cobra.Command, args []string) error {\n\tsrc, dst := args[0], args[1]\n\tforce, err := cmd.Flags().GetBool(\"force\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsrcPath, err := fixRelativePaths(src)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to parse src path: %w\", err)\n\t}\n\n\t_, err = checkFileSize(srcPath, MaxFileSize)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdestPath, err := fixRelativePaths(dst)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to parse dest path: %w\", err)\n\t}\n\tlog.Printf(\"Moving %s to %s; force: %v\", srcPath, destPath, force)\n\trpcOpts := &wshrpc.RpcOpts{Timeout: TimeoutYear}\n\terr = wshclient.FileMoveCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcPath, DestUri: destPath, Opts: &wshrpc.FileCopyOpts{Overwrite: force, Timeout: TimeoutYear}}, rpcOpts)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"moving file: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc filePrintColumns(filesChan <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData]) error {\n\twidth := 80\n\tw, _, err := term.GetSize(int(os.Stdout.Fd()))\n\tif err == nil {\n\t\twidth = w\n\t}\n\n\tvar allNames []string\n\tmaxLen := 0\n\tfor respUnion := range filesChan {\n\t\tif respUnion.Error != nil {\n\t\t\treturn respUnion.Error\n\t\t}\n\t\tfor _, f := range respUnion.Response.FileInfo {\n\t\t\tallNames = append(allNames, f.Name)\n\t\t\tif len(f.Name) > maxLen {\n\t\t\t\tmaxLen = len(f.Name)\n\t\t\t}\n\t\t}\n\t}\n\n\tcolWidth := maxLen + 2\n\tnumCols := width / colWidth\n\tif numCols < 1 {\n\t\tnumCols = 1\n\t}\n\n\tcol := 0\n\tfor _, name := range allNames {\n\t\tfmt.Fprintf(os.Stdout, \"%-*s\", colWidth, name)\n\t\tcol++\n\t\tif col >= numCols {\n\t\t\tfmt.Fprintln(os.Stdout)\n\t\t\tcol = 0\n\t\t}\n\t}\n\tif col > 0 {\n\t\tfmt.Fprintln(os.Stdout)\n\t}\n\n\treturn nil\n}\n\nfunc filePrintLong(filesChan <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData]) error {\n\tvar allFiles []*wshrpc.FileInfo\n\n\tfor respUnion := range filesChan {\n\t\tif respUnion.Error != nil {\n\t\t\treturn respUnion.Error\n\t\t}\n\t\tresp := respUnion.Response\n\t\tallFiles = append(allFiles, resp.FileInfo...)\n\t}\n\n\tmaxNameLen := 0\n\tfor _, fi := range allFiles {\n\t\tif len(fi.Name) > maxNameLen {\n\t\t\tmaxNameLen = len(fi.Name)\n\t\t}\n\t}\n\n\tnameWidth := maxNameLen + 2\n\tif nameWidth > 60 {\n\t\tnameWidth = 60\n\t}\n\n\twriter := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\\t', 0)\n\n\tfor _, f := range allFiles {\n\t\tname := f.Name\n\t\tt := time.Unix(f.ModTime/1000, 0)\n\t\ttimestamp := utilfn.FormatLsTime(t)\n\t\tif f.Size == 0 && strings.HasSuffix(name, \"/\") {\n\t\t\tfmt.Fprintf(writer, \"%-*s\\t%8s\\t%s\\n\", nameWidth, name, \"-\", timestamp)\n\t\t} else {\n\t\t\tfmt.Fprintf(writer, \"%-*s\\t%8d\\t%s\\n\", nameWidth, name, f.Size, timestamp)\n\t\t}\n\t}\n\twriter.Flush()\n\n\treturn nil\n}\n\nfunc fileListRun(cmd *cobra.Command, args []string) error {\n\tlongForm, _ := cmd.Flags().GetBool(\"long\")\n\tonePerLine, _ := cmd.Flags().GetBool(\"one\")\n\n\t// Check if we're in a pipe\n\tstat, _ := os.Stdout.Stat()\n\tisPipe := (stat.Mode() & os.ModeCharDevice) == 0\n\tif isPipe {\n\t\tonePerLine = true\n\t}\n\n\tif len(args) == 0 {\n\t\targs = []string{\".\"}\n\t}\n\n\tpath, err := fixRelativePaths(args[0])\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfilesChan := wshclient.FileListStreamCommand(RpcClient, wshrpc.FileListData{Path: path, Opts: &wshrpc.FileListOpts{All: false}}, &wshrpc.RpcOpts{Timeout: 2000})\n\t// Drain the channel when done\n\tdefer utilfn.DrainChannelSafe(filesChan, \"fileListRun\")\n\tif longForm {\n\t\treturn filePrintLong(filesChan)\n\t}\n\n\tif onePerLine {\n\t\tfor respUnion := range filesChan {\n\t\t\tif respUnion.Error != nil {\n\t\t\t\tlog.Printf(\"error: %v\", respUnion.Error)\n\t\t\t\treturn respUnion.Error\n\t\t\t}\n\t\t\tfor _, f := range respUnion.Response.FileInfo {\n\t\t\t\tfmt.Fprintln(os.Stdout, f.Name)\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\treturn filePrintColumns(filesChan)\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-focusblock.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar focusBlockCmd = &cobra.Command{\n\tUse:     \"focusblock [-b {blockid|blocknum|this}]\",\n\tShort:   \"focus a block in the current tab\",\n\tArgs:    cobra.NoArgs,\n\tRunE:    focusBlockRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\trootCmd.AddCommand(focusBlockCmd)\n}\n\nfunc focusBlockRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"focusblock\", rtnErr == nil)\n\t}()\n\n\ttabId := os.Getenv(\"WAVETERM_TABID\")\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"no tab id specified (set WAVETERM_TABID environment variable)\")\n\t}\n\n\tfullORef, err := resolveBlockArg()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\troute := fmt.Sprintf(\"tab:%s\", tabId)\n\terr = wshclient.SetBlockFocusCommand(RpcClient, fullORef.OID, &wshrpc.RpcOpts{\n\t\tRoute:   route,\n\t\tTimeout: 2000,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"focusing block: %v\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-getmeta.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar getMetaCmd = &cobra.Command{\n\tUse:     \"getmeta [key...]\",\n\tShort:   \"get metadata for an entity\",\n\tLong:    \"Get metadata for an entity. Keys can be exact matches or patterns like 'name:*' to get all keys that start with 'name:'\",\n\tArgs:    cobra.ArbitraryArgs,\n\tRunE:    getMetaRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar getMetaRawOutput bool\nvar getMetaClearPrefix bool\nvar getMetaVerbose bool\n\nfunc init() {\n\trootCmd.AddCommand(getMetaCmd)\n\tgetMetaCmd.Flags().BoolVarP(&getMetaVerbose, \"verbose\", \"v\", false, \"output full metadata\")\n\tgetMetaCmd.Flags().BoolVar(&getMetaRawOutput, \"raw\", false, \"output singleton string values without quotes\")\n\tgetMetaCmd.Flags().BoolVar(&getMetaClearPrefix, \"clear-prefix\", false, \"output the special clearing key for prefix queries\")\n}\n\nfunc filterMetaKeys(meta map[string]interface{}, keys []string) map[string]interface{} {\n\tresult := make(map[string]interface{})\n\n\t// Process each requested key\n\tfor _, key := range keys {\n\t\tif strings.HasSuffix(key, \":*\") {\n\t\t\t// Handle pattern matching\n\t\t\tprefix := strings.TrimSuffix(key, \"*\")\n\t\t\tbaseKey := strings.TrimSuffix(prefix, \":\")\n\n\t\t\tif getMetaClearPrefix {\n\t\t\t\tresult[key] = true\n\t\t\t}\n\n\t\t\t// Include the base key without colon if it exists\n\t\t\tif val, exists := meta[baseKey]; exists {\n\t\t\t\tresult[baseKey] = val\n\t\t\t}\n\n\t\t\t// Include all keys with the prefix\n\t\t\tfor k, v := range meta {\n\t\t\t\tif strings.HasPrefix(k, prefix) {\n\t\t\t\t\tresult[k] = v\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// Handle exact key match\n\t\t\tif val, exists := meta[key]; exists {\n\t\t\t\tresult[key] = val\n\t\t\t} else {\n\t\t\t\tresult[key] = nil\n\t\t\t}\n\t\t}\n\t}\n\n\treturn result\n}\n\nfunc getMetaRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"getmeta\", rtnErr == nil)\n\t}()\n\tfullORef, err := resolveBlockArg()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif getMetaVerbose {\n\t\tfmt.Fprintf(os.Stderr, \"resolved-id: %s\\n\", fullORef.String())\n\t}\n\tresp, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ORef: *fullORef}, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting metadata: %w\", err)\n\t}\n\n\tvar output interface{}\n\tif len(args) > 0 {\n\t\tif len(args) == 1 && !strings.HasSuffix(args[0], \":*\") {\n\t\t\t// Single key case - output just the value\n\t\t\toutput = resp[args[0]]\n\t\t} else {\n\t\t\t// Multiple keys or pattern matching case - output object\n\t\t\toutput = filterMetaKeys(resp, args)\n\t\t}\n\t} else {\n\t\t// No args case - output full metadata\n\t\toutput = resp\n\t}\n\n\t// Handle raw string output\n\tif getMetaRawOutput {\n\t\tif str, ok := output.(string); ok {\n\t\t\tWriteStdout(\"%s\\n\", str)\n\t\t\treturn\n\t\t}\n\t}\n\n\toutBArr, err := json.MarshalIndent(output, \"\", \"  \")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"formatting metadata: %w\", err)\n\t}\n\toutStr := string(outBArr)\n\tWriteStdout(\"%s\\n\", outStr)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-getvar.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar getVarCmd = &cobra.Command{\n\tUse:   \"getvar [flags] [key]\",\n\tShort: \"get variable(s) from a block\",\n\tLong: `Get variable(s) from a block. Without --all, requires a key argument.\nWith --all, prints all variables. Use -0 for null-terminated output.`,\n\tExample: \"  wsh getvar FOO\\n  wsh getvar --all\\n  wsh getvar --all -0\",\n\tRunE:    getVarRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar (\n\tgetVarFileName      string\n\tgetVarAllVars       bool\n\tgetVarNullTerminate bool\n\tgetVarLocal         bool\n\tgetVarFlagNL        bool\n\tgetVarFlagNoNL      bool\n)\n\nfunc init() {\n\trootCmd.AddCommand(getVarCmd)\n\tgetVarCmd.Flags().StringVar(&getVarFileName, \"varfile\", DefaultVarFileName, \"var file name\")\n\tgetVarCmd.Flags().BoolVar(&getVarAllVars, \"all\", false, \"get all variables\")\n\tgetVarCmd.Flags().BoolVarP(&getVarNullTerminate, \"null\", \"0\", false, \"use null terminators in output\")\n\tgetVarCmd.Flags().BoolVarP(&getVarLocal, \"local\", \"l\", false, \"get variables local to block\")\n\tgetVarCmd.Flags().BoolVarP(&getVarFlagNL, \"newline\", \"n\", false, \"print newline after output\")\n\tgetVarCmd.Flags().BoolVarP(&getVarFlagNoNL, \"no-newline\", \"N\", false, \"do not print newline after output\")\n}\n\nfunc shouldPrintNewline() bool {\n\tisTty := getIsTty()\n\tif getVarFlagNL {\n\t\treturn true\n\t}\n\tif getVarFlagNoNL {\n\t\treturn false\n\t}\n\treturn isTty\n}\n\nfunc getVarRun(cmd *cobra.Command, args []string) error {\n\tdefer func() {\n\t\tsendActivity(\"getvar\", WshExitCode == 0)\n\t}()\n\n\t// Resolve block to get zoneId\n\tif blockArg == \"\" {\n\t\tif getVarLocal {\n\t\t\tblockArg = \"this\"\n\t\t} else {\n\t\t\tblockArg = \"client\"\n\t\t}\n\t}\n\tfullORef, err := resolveBlockArg()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif getVarAllVars {\n\t\tif len(args) > 0 {\n\t\t\treturn fmt.Errorf(\"cannot specify key with --all\")\n\t\t}\n\t\treturn getAllVariables(fullORef.OID)\n\t}\n\n\t// Single variable case - existing logic\n\tif len(args) != 1 {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"requires a key argument\")\n\t}\n\n\tkey := args[0]\n\tcommandData := wshrpc.CommandVarData{\n\t\tKey:      key,\n\t\tZoneId:   fullORef.OID,\n\t\tFileName: getVarFileName,\n\t}\n\n\tresp, err := wshclient.GetVarCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting variable: %w\", err)\n\t}\n\n\tif !resp.Exists {\n\t\tWshExitCode = 1\n\t\treturn nil\n\t}\n\n\tWriteStdout(\"%s\", resp.Val)\n\tif shouldPrintNewline() {\n\t\tWriteStdout(\"\\n\")\n\t}\n\n\treturn nil\n}\n\nfunc getAllVariables(zoneId string) error {\n\tcommandData := wshrpc.CommandVarData{\n\t\tZoneId:   zoneId,\n\t\tFileName: getVarFileName,\n\t}\n\n\tvars, err := wshclient.GetAllVarsCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting variables: %w\", err)\n\t}\n\n\tterminator := \"\\n\"\n\tif getVarNullTerminate {\n\t\tterminator = \"\\x00\"\n\t}\n\n\tfor _, v := range vars {\n\t\tWriteStdout(\"%s=%s%s\", v.Key, v.Val, terminator)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-jobdebug.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nvar jobDebugCmd = &cobra.Command{\n\tUse:               \"jobdebug\",\n\tShort:             \"debugging commands for the job system\",\n\tHidden:            true,\n\tPersistentPreRunE: preRunSetupRpcClient,\n}\n\nvar jobDebugListCmd = &cobra.Command{\n\tUse:   \"list\",\n\tShort: \"list all jobs with debug information\",\n\tRunE:  jobDebugListRun,\n}\n\nvar jobDebugDeleteCmd = &cobra.Command{\n\tUse:   \"delete\",\n\tShort: \"delete a job entry by jobid\",\n\tRunE:  jobDebugDeleteRun,\n}\n\nvar jobDebugDeleteAllCmd = &cobra.Command{\n\tUse:   \"deleteall\",\n\tShort: \"delete all jobs\",\n\tRunE:  jobDebugDeleteAllRun,\n}\n\nvar jobDebugPruneCmd = &cobra.Command{\n\tUse:   \"prune\",\n\tShort: \"remove jobs where the job manager is no longer running\",\n\tRunE:  jobDebugPruneRun,\n}\n\nvar jobDebugTerminateCmd = &cobra.Command{\n\tUse:   \"terminate\",\n\tShort: \"terminate a job manager\",\n\tRunE:  jobDebugTerminateRun,\n}\n\nvar jobDebugDisconnectCmd = &cobra.Command{\n\tUse:   \"disconnect\",\n\tShort: \"disconnect from a job manager\",\n\tRunE:  jobDebugDisconnectRun,\n}\n\nvar jobDebugReconnectCmd = &cobra.Command{\n\tUse:   \"reconnect\",\n\tShort: \"reconnect to a job manager\",\n\tRunE:  jobDebugReconnectRun,\n}\n\nvar jobDebugReconnectConnCmd = &cobra.Command{\n\tUse:   \"reconnectconn\",\n\tShort: \"reconnect all jobs for a connection\",\n\tRunE:  jobDebugReconnectConnRun,\n}\n\nvar jobDebugGetOutputCmd = &cobra.Command{\n\tUse:   \"getoutput\",\n\tShort: \"get the terminal output for a job\",\n\tRunE:  jobDebugGetOutputRun,\n}\n\nvar jobDebugStartCmd = &cobra.Command{\n\tUse:   \"start\",\n\tShort: \"start a new job\",\n\tArgs:  cobra.MinimumNArgs(1),\n\tRunE:  jobDebugStartRun,\n}\n\nvar jobDebugAttachJobCmd = &cobra.Command{\n\tUse:   \"attachjob\",\n\tShort: \"attach a job to a block\",\n\tRunE:  jobDebugAttachJobRun,\n}\n\nvar jobDebugDetachJobCmd = &cobra.Command{\n\tUse:   \"detachjob\",\n\tShort: \"detach a job from its block\",\n\tRunE:  jobDebugDetachJobRun,\n}\n\nvar jobDebugBlockAttachmentCmd = &cobra.Command{\n\tUse:   \"blockattachment\",\n\tShort: \"show the attached job for a block\",\n\tRunE:  jobDebugBlockAttachmentRun,\n}\n\nvar jobIdFlag string\nvar jobDebugJsonFlag bool\nvar jobConnFlag string\nvar terminateJobIdFlag string\nvar disconnectJobIdFlag string\nvar reconnectJobIdFlag string\nvar reconnectConnNameFlag string\nvar attachJobIdFlag string\nvar attachBlockIdFlag string\nvar detachJobIdFlag string\n\nfunc init() {\n\trootCmd.AddCommand(jobDebugCmd)\n\tjobDebugCmd.AddCommand(jobDebugListCmd)\n\tjobDebugCmd.AddCommand(jobDebugDeleteCmd)\n\tjobDebugCmd.AddCommand(jobDebugDeleteAllCmd)\n\tjobDebugCmd.AddCommand(jobDebugPruneCmd)\n\tjobDebugCmd.AddCommand(jobDebugTerminateCmd)\n\tjobDebugCmd.AddCommand(jobDebugDisconnectCmd)\n\tjobDebugCmd.AddCommand(jobDebugReconnectCmd)\n\tjobDebugCmd.AddCommand(jobDebugReconnectConnCmd)\n\tjobDebugCmd.AddCommand(jobDebugGetOutputCmd)\n\tjobDebugCmd.AddCommand(jobDebugStartCmd)\n\tjobDebugCmd.AddCommand(jobDebugAttachJobCmd)\n\tjobDebugCmd.AddCommand(jobDebugDetachJobCmd)\n\tjobDebugCmd.AddCommand(jobDebugBlockAttachmentCmd)\n\n\tjobDebugListCmd.Flags().BoolVar(&jobDebugJsonFlag, \"json\", false, \"output as JSON\")\n\n\tjobDebugDeleteCmd.Flags().StringVar(&jobIdFlag, \"jobid\", \"\", \"job id to delete (required)\")\n\tjobDebugDeleteCmd.MarkFlagRequired(\"jobid\")\n\n\tjobDebugTerminateCmd.Flags().StringVar(&terminateJobIdFlag, \"jobid\", \"\", \"job id to terminate (required)\")\n\tjobDebugTerminateCmd.MarkFlagRequired(\"jobid\")\n\n\tjobDebugDisconnectCmd.Flags().StringVar(&disconnectJobIdFlag, \"jobid\", \"\", \"job id to disconnect (required)\")\n\tjobDebugDisconnectCmd.MarkFlagRequired(\"jobid\")\n\n\tjobDebugReconnectCmd.Flags().StringVar(&reconnectJobIdFlag, \"jobid\", \"\", \"job id to reconnect (required)\")\n\tjobDebugReconnectCmd.MarkFlagRequired(\"jobid\")\n\n\tjobDebugReconnectConnCmd.Flags().StringVar(&reconnectConnNameFlag, \"conn\", \"\", \"connection name (required)\")\n\tjobDebugReconnectConnCmd.MarkFlagRequired(\"conn\")\n\n\tjobDebugGetOutputCmd.Flags().StringVar(&jobIdFlag, \"jobid\", \"\", \"job id to get output for (required)\")\n\tjobDebugGetOutputCmd.MarkFlagRequired(\"jobid\")\n\n\tjobDebugStartCmd.Flags().StringVar(&jobConnFlag, \"conn\", \"\", \"connection name (required)\")\n\tjobDebugStartCmd.MarkFlagRequired(\"conn\")\n\n\tjobDebugAttachJobCmd.Flags().StringVar(&attachJobIdFlag, \"jobid\", \"\", \"job id to attach (required)\")\n\tjobDebugAttachJobCmd.MarkFlagRequired(\"jobid\")\n\tjobDebugAttachJobCmd.Flags().StringVar(&attachBlockIdFlag, \"blockid\", \"\", \"block id to attach to (required)\")\n\tjobDebugAttachJobCmd.MarkFlagRequired(\"blockid\")\n\n\tjobDebugDetachJobCmd.Flags().StringVar(&detachJobIdFlag, \"jobid\", \"\", \"job id to detach (required)\")\n\tjobDebugDetachJobCmd.MarkFlagRequired(\"jobid\")\n}\n\nfunc jobDebugListRun(cmd *cobra.Command, args []string) error {\n\trtnData, err := wshclient.JobControllerListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting job debug list: %w\", err)\n\t}\n\n\tconnectedJobIds, err := wshclient.JobControllerConnectedJobsCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting connected job ids: %w\", err)\n\t}\n\n\tconnectedMap := make(map[string]bool)\n\tfor _, jobId := range connectedJobIds {\n\t\tconnectedMap[jobId] = true\n\t}\n\n\tif jobDebugJsonFlag {\n\t\tjsonData, err := json.MarshalIndent(rtnData, \"\", \"  \")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"marshaling json: %w\", err)\n\t\t}\n\t\tfmt.Printf(\"%s\\n\", string(jsonData))\n\t\treturn nil\n\t}\n\n\tfmt.Printf(\"%-36s %-25s %-9s %-10s %-6s %-30s %-8s %-10s %-8s\\n\", \"OID\", \"Connection\", \"Connected\", \"Manager\", \"Reason\", \"Cmd\", \"ExitCode\", \"Stream\", \"Attached\")\n\tfor _, job := range rtnData {\n\t\tconnectedStatus := \"no\"\n\t\tif connectedMap[job.OID] {\n\t\t\tconnectedStatus = \"yes\"\n\t\t}\n\t\tif job.TerminateOnReconnect {\n\t\t\tconnectedStatus += \"*\"\n\t\t}\n\n\t\tstreamStatus := \"-\"\n\t\tif job.StreamDone {\n\t\t\tif job.StreamError == \"\" {\n\t\t\t\tstreamStatus = \"EOF\"\n\t\t\t} else {\n\t\t\t\tstreamStatus = fmt.Sprintf(\"%q\", job.StreamError)\n\t\t\t}\n\t\t}\n\n\t\texitCode := \"-\"\n\t\tif job.CmdExitTs > 0 {\n\t\t\tif job.CmdExitCode != nil {\n\t\t\t\texitCode = fmt.Sprintf(\"%d\", *job.CmdExitCode)\n\t\t\t} else if job.CmdExitSignal != \"\" {\n\t\t\t\texitCode = job.CmdExitSignal\n\t\t\t} else {\n\t\t\t\texitCode = \"?\"\n\t\t\t}\n\t\t}\n\n\t\tdoneReason := \"-\"\n\t\tif job.JobManagerDoneReason == \"startuperror\" {\n\t\t\tdoneReason = \"serr\"\n\t\t} else if job.JobManagerDoneReason == \"gone\" {\n\t\t\tdoneReason = \"gone\"\n\t\t} else if job.JobManagerDoneReason == \"terminated\" {\n\t\t\tdoneReason = \"term\"\n\t\t}\n\n\t\tattachedBlock := \"-\"\n\t\tif job.AttachedBlockId != \"\" {\n\t\t\tif len(job.AttachedBlockId) >= 8 {\n\t\t\t\tattachedBlock = job.AttachedBlockId[:8]\n\t\t\t} else {\n\t\t\t\tattachedBlock = job.AttachedBlockId\n\t\t\t}\n\t\t}\n\n\t\tfmt.Printf(\"%-36s %-25s %-9s %-10s %-6s %-30s %-8s %-10s %-8s\\n\",\n\t\t\tjob.OID, job.Connection, connectedStatus, job.JobManagerStatus, doneReason, job.Cmd, exitCode, streamStatus, attachedBlock)\n\t}\n\treturn nil\n}\n\nfunc jobDebugDeleteRun(cmd *cobra.Command, args []string) error {\n\terr := wshclient.JobControllerDeleteJobCommand(RpcClient, jobIdFlag, &wshrpc.RpcOpts{Timeout: 5000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"deleting job: %w\", err)\n\t}\n\n\tfmt.Printf(\"Job %s deleted successfully\\n\", jobIdFlag)\n\treturn nil\n}\n\nfunc jobDebugDeleteAllRun(cmd *cobra.Command, args []string) error {\n\trtnData, err := wshclient.JobControllerListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting job debug list: %w\", err)\n\t}\n\n\tif len(rtnData) == 0 {\n\t\tfmt.Printf(\"No jobs to delete\\n\")\n\t\treturn nil\n\t}\n\n\tdeletedCount := 0\n\tfor _, job := range rtnData {\n\t\terr := wshclient.JobControllerDeleteJobCommand(RpcClient, job.OID, &wshrpc.RpcOpts{Timeout: 5000})\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Error deleting job %s: %v\\n\", job.OID, err)\n\t\t} else {\n\t\t\tdeletedCount++\n\t\t}\n\t}\n\n\tfmt.Printf(\"Deleted %d of %d job(s)\\n\", deletedCount, len(rtnData))\n\treturn nil\n}\n\nfunc jobDebugPruneRun(cmd *cobra.Command, args []string) error {\n\trtnData, err := wshclient.JobControllerListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting job debug list: %w\", err)\n\t}\n\n\tif len(rtnData) == 0 {\n\t\tfmt.Printf(\"No jobs to prune\\n\")\n\t\treturn nil\n\t}\n\n\tdeletedCount := 0\n\tfor _, job := range rtnData {\n\t\tif job.JobManagerStatus != \"running\" {\n\t\t\terr := wshclient.JobControllerDeleteJobCommand(RpcClient, job.OID, &wshrpc.RpcOpts{Timeout: 5000})\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"Error deleting job %s: %v\\n\", job.OID, err)\n\t\t\t} else {\n\t\t\t\tdeletedCount++\n\t\t\t}\n\t\t}\n\t}\n\n\tif deletedCount == 0 {\n\t\tfmt.Printf(\"No jobs with stopped job managers to prune\\n\")\n\t} else {\n\t\tfmt.Printf(\"Pruned %d job(s) with stopped job managers\\n\", deletedCount)\n\t}\n\treturn nil\n}\n\nfunc jobDebugTerminateRun(cmd *cobra.Command, args []string) error {\n\terr := wshclient.JobControllerExitJobCommand(RpcClient, terminateJobIdFlag, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"terminating job manager: %w\", err)\n\t}\n\n\tfmt.Printf(\"Job manager for %s terminated successfully\\n\", terminateJobIdFlag)\n\treturn nil\n}\n\nfunc jobDebugDisconnectRun(cmd *cobra.Command, args []string) error {\n\terr := wshclient.JobControllerDisconnectJobCommand(RpcClient, disconnectJobIdFlag, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"disconnecting from job manager: %w\", err)\n\t}\n\n\tfmt.Printf(\"Disconnected from job manager for %s successfully\\n\", disconnectJobIdFlag)\n\treturn nil\n}\n\nfunc jobDebugReconnectRun(cmd *cobra.Command, args []string) error {\n\terr := wshclient.JobControllerReconnectJobCommand(RpcClient, reconnectJobIdFlag, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reconnecting to job manager: %w\", err)\n\t}\n\n\tfmt.Printf(\"Reconnected to job manager for %s successfully\\n\", reconnectJobIdFlag)\n\treturn nil\n}\n\nfunc jobDebugReconnectConnRun(cmd *cobra.Command, args []string) error {\n\terr := wshclient.JobControllerReconnectJobsForConnCommand(RpcClient, reconnectConnNameFlag, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reconnecting jobs for connection: %w\", err)\n\t}\n\n\tfmt.Printf(\"Reconnected all jobs for connection %s successfully\\n\", reconnectConnNameFlag)\n\treturn nil\n}\n\nfunc jobDebugGetOutputRun(cmd *cobra.Command, args []string) error {\n\tbroker := RpcClient.StreamBroker\n\tif broker == nil {\n\t\treturn fmt.Errorf(\"stream broker not available\")\n\t}\n\n\treaderRouteId, err := wshclient.ControlGetRouteIdCommand(RpcClient, &wshrpc.RpcOpts{Route: wshutil.ControlRoute})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting route id: %w\", err)\n\t}\n\tif readerRouteId == \"\" {\n\t\treturn fmt.Errorf(\"no route to receive data\")\n\t}\n\twriterRouteId := \"\" // main server route\n\treader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, 64*1024)\n\tdefer reader.Close()\n\n\tdata := wshrpc.CommandWaveFileReadStreamData{\n\t\tZoneId:     jobIdFlag,\n\t\tName:       \"term\",\n\t\tStreamMeta: *streamMeta,\n\t}\n\n\t_, err = wshclient.WaveFileReadStreamCommand(RpcClient, data, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"starting stream read: %w\", err)\n\t}\n\n\t_, err = io.Copy(os.Stdout, reader)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reading stream: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc jobDebugStartRun(cmd *cobra.Command, args []string) error {\n\tcmdToRun := args[0]\n\tcmdArgs := args[1:]\n\n\tdata := wshrpc.CommandJobControllerStartJobData{\n\t\tConnName: jobConnFlag,\n\t\tJobKind:  \"task\",\n\t\tCmd:      cmdToRun,\n\t\tArgs:     cmdArgs,\n\t\tEnv:      make(map[string]string),\n\t\tTermSize: nil,\n\t}\n\n\tjobId, err := wshclient.JobControllerStartJobCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 10000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"starting job: %w\", err)\n\t}\n\n\tfmt.Printf(\"Job started successfully with ID: %s\\n\", jobId)\n\treturn nil\n}\n\nfunc jobDebugAttachJobRun(cmd *cobra.Command, args []string) error {\n\tdata := wshrpc.CommandJobControllerAttachJobData{\n\t\tJobId:   attachJobIdFlag,\n\t\tBlockId: attachBlockIdFlag,\n\t}\n\n\terr := wshclient.JobControllerAttachJobCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 5000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"attaching job: %w\", err)\n\t}\n\n\tfmt.Printf(\"Job %s attached to block %s successfully\\n\", attachJobIdFlag, attachBlockIdFlag)\n\treturn nil\n}\n\nfunc jobDebugDetachJobRun(cmd *cobra.Command, args []string) error {\n\terr := wshclient.JobControllerDetachJobCommand(RpcClient, detachJobIdFlag, &wshrpc.RpcOpts{Timeout: 5000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"detaching job: %w\", err)\n\t}\n\n\tfmt.Printf(\"Job %s detached successfully\\n\", detachJobIdFlag)\n\treturn nil\n}\n\nfunc jobDebugBlockAttachmentRun(cmd *cobra.Command, args []string) error {\n\tblockORef, err := resolveBlockArg()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tblockId := blockORef.OID\n\tjobStatus, err := wshclient.BlockJobStatusCommand(RpcClient, blockId, &wshrpc.RpcOpts{Timeout: 5000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting block job status: %w\", err)\n\t}\n\n\tif jobStatus.JobId == \"\" {\n\t\tfmt.Printf(\"Block %s: no attached job\\n\", blockId)\n\t} else {\n\t\tfmt.Printf(\"Block %s: attached to job %s\\n\", blockId, jobStatus.JobId)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-jobmanager.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/jobmanager\"\n)\n\nvar jobManagerCmd = &cobra.Command{\n\tUse:    \"jobmanager\",\n\tHidden: true,\n\tShort:  \"job manager for wave terminal\",\n\tArgs:   cobra.NoArgs,\n\tRunE:   jobManagerRun,\n}\n\nvar jobManagerJobId string\nvar jobManagerClientId string\n\nfunc init() {\n\tjobManagerCmd.Flags().StringVar(&jobManagerJobId, \"jobid\", \"\", \"job ID (UUID, required)\")\n\tjobManagerCmd.Flags().StringVar(&jobManagerClientId, \"clientid\", \"\", \"client ID (UUID, required)\")\n\tjobManagerCmd.MarkFlagRequired(\"jobid\")\n\tjobManagerCmd.MarkFlagRequired(\"clientid\")\n\trootCmd.AddCommand(jobManagerCmd)\n}\n\nfunc jobManagerRun(cmd *cobra.Command, args []string) error {\n\t_, err := uuid.Parse(jobManagerJobId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid jobid: must be a valid UUID\")\n\t}\n\n\t_, err = uuid.Parse(jobManagerClientId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid clientid: must be a valid UUID\")\n\t}\n\n\tpublicKeyB64 := os.Getenv(\"WAVETERM_PUBLICKEY\")\n\tif publicKeyB64 == \"\" {\n\t\treturn fmt.Errorf(\"WAVETERM_PUBLICKEY environment variable is not set\")\n\t}\n\n\tpublicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to decode WAVETERM_PUBLICKEY: %v\", err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tjobAuthToken, err := readJobAuthToken(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read job auth token: %v\", err)\n\t}\n\n\treadyFile := os.NewFile(3, \"ready-pipe\")\n\t_, err = readyFile.Stat()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"ready pipe (fd 3) not available: %v\", err)\n\t}\n\n\terr = jobmanager.SetupJobManager(jobManagerClientId, jobManagerJobId, publicKeyBytes, jobAuthToken, readyFile)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting up job manager: %v\", err)\n\t}\n\n\tselect {}\n}\n\nfunc readJobAuthToken(ctx context.Context) (string, error) {\n\tresultCh := make(chan string, 1)\n\terrorCh := make(chan error, 1)\n\n\tgo func() {\n\t\treader := bufio.NewReader(os.Stdin)\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\terrorCh <- fmt.Errorf(\"error reading from stdin: %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tline = strings.TrimSpace(line)\n\t\tprefix := jobmanager.JobAccessTokenLabel + \":\"\n\t\tif !strings.HasPrefix(line, prefix) {\n\t\t\terrorCh <- fmt.Errorf(\"invalid token format: expected '%s'\", prefix)\n\t\t\treturn\n\t\t}\n\n\t\ttoken := strings.TrimPrefix(line, prefix)\n\t\ttoken = strings.TrimSpace(token)\n\t\tif token == \"\" {\n\t\t\terrorCh <- fmt.Errorf(\"empty job auth token\")\n\t\t\treturn\n\t\t}\n\n\t\tresultCh <- token\n\t}()\n\n\tselect {\n\tcase token := <-resultCh:\n\t\treturn token, nil\n\tcase err := <-errorCh:\n\t\treturn \"\", err\n\tcase <-ctx.Done():\n\t\treturn \"\", ctx.Err()\n\t}\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-launch.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar magnifyBlock bool\n\nvar launchCmd = &cobra.Command{\n\tUse:     \"launch\",\n\tShort:   \"launch a widget by its ID\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    launchRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\tlaunchCmd.Flags().BoolVarP(&magnifyBlock, \"magnify\", \"m\", false, \"start the widget in magnified mode\")\n\trootCmd.AddCommand(launchCmd)\n}\n\nfunc launchRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"launch\", rtnErr == nil)\n\t}()\n\n\twidgetId := args[0]\n\n\t// Get the full configuration\n\tconfig, err := wshclient.GetFullConfigCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting configuration: %w\", err)\n\t}\n\n\t// Look for widget in both widgets and defaultwidgets\n\twidget, ok := config.Widgets[widgetId]\n\tif !ok {\n\t\twidget, ok = config.DefaultWidgets[widgetId]\n\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"widget %q not found in configuration\", widgetId)\n\t\t}\n\t}\n\n\ttabId := getTabIdFromEnv()\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"no WAVETERM_TABID env var set\")\n\t}\n\n\t// Create block data from widget config\n\tcreateBlockData := wshrpc.CommandCreateBlockData{\n\t\tTabId:     tabId,\n\t\tBlockDef:  &widget.BlockDef,\n\t\tMagnified: magnifyBlock || widget.Magnified,\n\t\tFocused:   true,\n\t}\n\n\t// Create the block\n\toref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating widget block: %w\", err)\n\t}\n\n\tWriteStdout(\"launched widget %q: %s\\n\", widgetId, oref)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-notify.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nvar notifyTitle string\nvar notifySilent bool\n\nvar setNotifyCmd = &cobra.Command{\n\tUse:     \"notify <message> [-t <title>] [-s]\",\n\tShort:   \"create a notification\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    notifyRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\tsetNotifyCmd.Flags().StringVarP(&notifyTitle, \"title\", \"t\", \"Wsh Notify\", \"the notification title\")\n\tsetNotifyCmd.Flags().BoolVarP(&notifySilent, \"silent\", \"s\", false, \"whether or not the notification sound is silenced\")\n\trootCmd.AddCommand(setNotifyCmd)\n}\n\nfunc notifyRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"notify\", rtnErr == nil)\n\t}()\n\tmessage := args[0]\n\tnotificationOptions := &wshrpc.WaveNotificationOptions{\n\t\tTitle:  notifyTitle,\n\t\tBody:   message,\n\t\tSilent: notifySilent,\n\t}\n\terr := wshclient.NotifyCommand(RpcClient, *notificationOptions, &wshrpc.RpcOpts{Timeout: 2000, Route: wshutil.ElectronRoute})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"sending notification: %w\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-rcfiles.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nfunc init() {\n\trootCmd.AddCommand(rcfilesCmd)\n}\n\nvar rcfilesCmd = &cobra.Command{\n\tUse:    \"rcfiles\",\n\tHidden: true,\n\tShort:  \"Generate the rc files needed for various shells\",\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\terr := wshutil.InstallRcFiles()\n\t\tif err != nil {\n\t\t\tWriteStderr(\"%s\\n\", err.Error())\n\t\t\treturn\n\t\t}\n\t},\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-readfile.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"io\"\n\t\"os\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nvar readFileCmd = &cobra.Command{\n\tUse:     \"readfile [filename]\",\n\tShort:   \"read a blockfile\",\n\tArgs:    cobra.ExactArgs(1),\n\tRun:     runReadFile,\n\tPreRunE: preRunSetupRpcClient,\n\tHidden:  true,\n}\n\nfunc init() {\n\trootCmd.AddCommand(readFileCmd)\n}\n\nfunc runReadFile(cmd *cobra.Command, args []string) {\n\tfullORef, err := resolveBlockArg()\n\tif err != nil {\n\t\tWriteStderr(\"[error] %v\\n\", err)\n\t\treturn\n\t}\n\n\tbroker := RpcClient.StreamBroker\n\tif broker == nil {\n\t\tWriteStderr(\"[error] stream broker not available\\n\")\n\t\treturn\n\t}\n\n\treaderRouteId, err := wshclient.ControlGetRouteIdCommand(RpcClient, &wshrpc.RpcOpts{Route: wshutil.ControlRoute})\n\tif err != nil {\n\t\tWriteStderr(\"[error] getting route id: %v\\n\", err)\n\t\treturn\n\t}\n\tif readerRouteId == \"\" {\n\t\tWriteStderr(\"[error] no route to receive data\\n\")\n\t\treturn\n\t}\n\twriterRouteId := \"\"\n\treader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, 64*1024)\n\tdefer reader.Close()\n\n\tdata := wshrpc.CommandWaveFileReadStreamData{\n\t\tZoneId:     fullORef.OID,\n\t\tName:       args[0],\n\t\tStreamMeta: *streamMeta,\n\t}\n\n\t_, err = wshclient.WaveFileReadStreamCommand(RpcClient, data, nil)\n\tif err != nil {\n\t\tWriteStderr(\"[error] starting stream read: %v\\n\", err)\n\t\treturn\n\t}\n\n\t_, err = io.Copy(os.Stdout, reader)\n\tif err != nil {\n\t\tWriteStderr(\"[error] reading stream: %v\\n\", err)\n\t\treturn\n\t}\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-root.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"runtime/debug\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nvar (\n\trootCmd = &cobra.Command{\n\t\tUse:          \"wsh\",\n\t\tShort:        \"CLI tool to control Wave Terminal\",\n\t\tLong:         `wsh is a small utility that lets you do cool things with Wave Terminal, right from the command line`,\n\t\tSilenceUsage: true,\n\t}\n)\n\nvar WrappedStdin io.Reader = os.Stdin\nvar WrappedStdout io.Writer = &WrappedWriter{dest: os.Stdout}\nvar WrappedStderr io.Writer = &WrappedWriter{dest: os.Stderr}\nvar RpcClient *wshutil.WshRpc\nvar RpcContext wshrpc.RpcContext\nvar UsingTermWshMode bool\nvar blockArg string\nvar WshExitCode int\n\ntype WrappedWriter struct {\n\tdest io.Writer\n}\n\nfunc (w *WrappedWriter) Write(p []byte) (n int, err error) {\n\tif !UsingTermWshMode {\n\t\treturn w.dest.Write(p)\n\t}\n\tcount := 0\n\tfor _, b := range p {\n\t\tif b == '\\n' {\n\t\t\tcount++\n\t\t}\n\t}\n\tif count == 0 {\n\t\treturn w.dest.Write(p)\n\t}\n\tbuf := make([]byte, len(p)+count) // Each '\\n' adds one extra byte for '\\r'\n\twriteIdx := 0\n\tfor _, b := range p {\n\t\tif b == '\\n' {\n\t\t\tbuf[writeIdx] = '\\r'\n\t\t\tbuf[writeIdx+1] = '\\n'\n\t\t\twriteIdx += 2\n\t\t} else {\n\t\t\tbuf[writeIdx] = b\n\t\t\twriteIdx++\n\t\t}\n\t}\n\treturn w.dest.Write(buf)\n}\n\nfunc WriteStderr(fmtStr string, args ...interface{}) {\n\tWrappedStderr.Write([]byte(fmt.Sprintf(fmtStr, args...)))\n}\n\nfunc WriteStdout(fmtStr string, args ...interface{}) {\n\tWrappedStdout.Write([]byte(fmt.Sprintf(fmtStr, args...)))\n}\n\nfunc OutputHelpMessage(cmd *cobra.Command) {\n\tcmd.SetOutput(WrappedStderr)\n\tcmd.Help()\n\tWriteStderr(\"\\n\")\n}\n\nfunc preRunSetupRpcClient(cmd *cobra.Command, args []string) error {\n\tjwtToken := os.Getenv(wshutil.WaveJwtTokenVarName)\n\tif jwtToken == \"\" {\n\t\treturn fmt.Errorf(\"wsh must be run inside a Wave-managed SSH session (WAVETERM_JWT not found)\")\n\t}\n\terr := setupRpcClient(nil, jwtToken)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc getIsTty() bool {\n\tif fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {\n\t\treturn true\n\t}\n\treturn false\n}\n\ntype RunEFnType = func(*cobra.Command, []string) error\n\nfunc activityWrap(activityStr string, origRunE RunEFnType) RunEFnType {\n\treturn func(cmd *cobra.Command, args []string) (rtnErr error) {\n\t\tdefer func() {\n\t\t\tsendActivity(activityStr, rtnErr == nil)\n\t\t}()\n\t\treturn origRunE(cmd, args)\n\t}\n}\n\nfunc resolveBlockArg() (*waveobj.ORef, error) {\n\toref := blockArg\n\tif oref == \"\" {\n\t\toref = \"this\"\n\t}\n\tfullORef, err := resolveSimpleId(oref)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"resolving blockid: %w\", err)\n\t}\n\treturn fullORef, nil\n}\n\nfunc setupRpcClientWithToken(swapTokenStr string) (wshrpc.CommandAuthenticateRtnData, error) {\n\tvar rtn wshrpc.CommandAuthenticateRtnData\n\ttoken, err := shellutil.UnpackSwapToken(swapTokenStr)\n\tif err != nil {\n\t\treturn rtn, fmt.Errorf(\"error unpacking token: %w\", err)\n\t}\n\tif token.RpcContext == nil {\n\t\treturn rtn, fmt.Errorf(\"no rpccontext in token\")\n\t}\n\tif token.RpcContext.SockName == \"\" {\n\t\treturn rtn, fmt.Errorf(\"no sockname in token\")\n\t}\n\tRpcContext = *token.RpcContext\n\tRpcClient, err = wshutil.SetupDomainSocketRpcClient(token.RpcContext.SockName, nil, \"wshcmd\")\n\tif err != nil {\n\t\treturn rtn, fmt.Errorf(\"error setting up domain socket rpc client: %w\", err)\n\t}\n\treturn wshclient.AuthenticateTokenCommand(RpcClient, wshrpc.CommandAuthenticateTokenData{Token: token.Token}, &wshrpc.RpcOpts{Route: wshutil.ControlRoute})\n}\n\n// returns the wrapped stdin and a new rpc client (that wraps the stdin input and stdout output)\nfunc setupRpcClient(serverImpl wshutil.ServerImpl, jwtToken string) error {\n\trpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error extracting rpc context from %s: %v\", wshutil.WaveJwtTokenVarName, err)\n\t}\n\tRpcContext = *rpcCtx\n\tsockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error extracting socket name from %s: %v\", wshutil.WaveJwtTokenVarName, err)\n\t}\n\tRpcClient, err = wshutil.SetupDomainSocketRpcClient(sockName, serverImpl, \"wshcmd\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting up domain socket rpc client: %v\", err)\n\t}\n\t_, err = wshclient.AuthenticateCommand(RpcClient, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRoute})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error authenticating: %v\", err)\n\t}\n\tblockId := os.Getenv(\"WAVETERM_BLOCKID\")\n\tif blockId != \"\" {\n\t\tpeerInfo := fmt.Sprintf(\"domain:block:%s\", blockId)\n\t\twshclient.SetPeerInfoCommand(RpcClient, peerInfo, &wshrpc.RpcOpts{Route: wshutil.ControlRoute})\n\t}\n\t// note we don't modify WrappedStdin here (just use os.Stdin)\n\treturn nil\n}\n\nfunc isFullORef(orefStr string) bool {\n\t_, err := waveobj.ParseORef(orefStr)\n\treturn err == nil\n}\n\nfunc resolveSimpleId(id string) (*waveobj.ORef, error) {\n\tif isFullORef(id) {\n\t\torefObj, err := waveobj.ParseORef(id)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error parsing full ORef: %v\", err)\n\t\t}\n\t\treturn &orefObj, nil\n\t}\n\tblockId := os.Getenv(\"WAVETERM_BLOCKID\")\n\tif blockId == \"\" {\n\t\treturn nil, fmt.Errorf(\"no WAVETERM_BLOCKID env var set\")\n\t}\n\trtnData, err := wshclient.ResolveIdsCommand(RpcClient, wshrpc.CommandResolveIdsData{\n\t\tBlockId: blockId,\n\t\tIds:     []string{id},\n\t}, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error resolving ids: %v\", err)\n\t}\n\toref, ok := rtnData.ResolvedIds[id]\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"id not found: %q\", id)\n\t}\n\treturn &oref, nil\n}\n\nfunc getTabIdFromEnv() string {\n\treturn os.Getenv(\"WAVETERM_TABID\")\n}\n\n// this will send wsh activity to the client running on *your* local machine (it does not contact any wave cloud infrastructure)\n// if you've turned off telemetry in your local client, this data never gets sent to us\n// 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)\n// (e.g. \"wsh ai ...\" would send \"ai\")\n// this helps us understand which commands are actually being used so we know where to concentrate our effort\nfunc sendActivity(wshCmdName string, success bool) {\n\tif RpcClient == nil || wshCmdName == \"\" {\n\t\treturn\n\t}\n\tdataMap := make(map[string]int)\n\tdataMap[wshCmdName] = 1\n\tif !success {\n\t\tdataMap[wshCmdName+\"#\"+\"error\"] = 1\n\t}\n\twshclient.WshActivityCommand(RpcClient, dataMap, nil)\n}\n\n// Execute executes the root command.\nfunc Execute() {\n\tdefer func() {\n\t\tr := recover()\n\t\tif r != nil {\n\t\t\tWriteStderr(\"[panic] %v\\n\", r)\n\t\t\tdebug.PrintStack()\n\t\t\twshutil.DoShutdown(\"\", 1, true)\n\t\t} else {\n\t\t\twshutil.DoShutdown(\"\", WshExitCode, false)\n\t\t}\n\t}()\n\trootCmd.PersistentFlags().StringVarP(&blockArg, \"block\", \"b\", \"\", \"for commands which require a block id\")\n\terr := rootCmd.Execute()\n\tif err != nil {\n\t\twshutil.DoShutdown(\"\", 1, true)\n\t\treturn\n\t}\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-run.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/envutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar runCmd = &cobra.Command{\n\tUse:              \"run [flags] -- command [args...]\",\n\tShort:            \"run a command in a new block\",\n\tRunE:             runRun,\n\tPreRunE:          preRunSetupRpcClient,\n\tTraverseChildren: true,\n}\n\nfunc init() {\n\tflags := runCmd.Flags()\n\tflags.BoolP(\"magnified\", \"m\", false, \"open view in magnified mode\")\n\tflags.StringP(\"command\", \"c\", \"\", \"run command string in shell\")\n\tflags.BoolP(\"exit\", \"x\", false, \"close block if command exits successfully (will stay open if there was an error)\")\n\tflags.BoolP(\"forceexit\", \"X\", false, \"close block when command exits, regardless of exit status\")\n\tflags.IntP(\"delay\", \"\", 2000, \"if -x, delay in milliseconds before closing block\")\n\tflags.BoolP(\"paused\", \"p\", false, \"create block in paused state\")\n\tflags.String(\"cwd\", \"\", \"set working directory for command\")\n\tflags.BoolP(\"append\", \"a\", false, \"append output on restart instead of clearing\")\n\trootCmd.AddCommand(runCmd)\n}\n\nfunc runRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"run\", rtnErr == nil)\n\t}()\n\n\tflags := cmd.Flags()\n\tmagnified, _ := flags.GetBool(\"magnified\")\n\tcommandArg, _ := flags.GetString(\"command\")\n\texit, _ := flags.GetBool(\"exit\")\n\tforceExit, _ := flags.GetBool(\"forceexit\")\n\tpaused, _ := flags.GetBool(\"paused\")\n\tcwd, _ := flags.GetString(\"cwd\")\n\tdelayMs, _ := flags.GetInt(\"delay\")\n\tappendOutput, _ := flags.GetBool(\"append\")\n\tvar cmdArgs []string\n\tvar useShell bool\n\tvar shellCmd string\n\n\tfor i, arg := range os.Args {\n\t\tif arg == \"--\" {\n\t\t\tif i+1 >= len(os.Args) {\n\t\t\t\tOutputHelpMessage(cmd)\n\t\t\t\treturn fmt.Errorf(\"no command provided after --\")\n\t\t\t}\n\t\t\tshellCmd = os.Args[i+1]\n\t\t\tcmdArgs = os.Args[i+2:]\n\t\t\tbreak\n\t\t}\n\t}\n\tif shellCmd != \"\" && commandArg != \"\" {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"cannot specify both -c and command arguments\")\n\t}\n\tif shellCmd == \"\" && commandArg == \"\" {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"command must be specified after -- or with -c\")\n\t}\n\tif commandArg != \"\" {\n\t\tshellCmd = commandArg\n\t\tuseShell = true\n\t}\n\n\t// Get current working directory\n\tif cwd == \"\" {\n\t\tvar err error\n\t\tcwd, err = os.Getwd()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"getting current directory: %w\", err)\n\t\t}\n\t}\n\tcwd, err := filepath.Abs(cwd)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting absolute path: %w\", err)\n\t}\n\n\t// Get current environment and convert to map\n\tenvMap := make(map[string]string)\n\tfor _, envStr := range os.Environ() {\n\t\tenv := strings.SplitN(envStr, \"=\", 2)\n\t\tif len(env) == 2 {\n\t\t\tenvMap[env[0]] = env[1]\n\t\t}\n\t}\n\n\t// Convert to null-terminated format\n\tenvContent := envutil.MapToEnv(envMap)\n\tcreateMeta := map[string]any{\n\t\twaveobj.MetaKey_View:            \"term\",\n\t\twaveobj.MetaKey_CmdCwd:          cwd,\n\t\twaveobj.MetaKey_Controller:      \"cmd\",\n\t\twaveobj.MetaKey_CmdClearOnStart: true,\n\t}\n\tcreateMeta[waveobj.MetaKey_Cmd] = shellCmd\n\tcreateMeta[waveobj.MetaKey_CmdArgs] = cmdArgs\n\tcreateMeta[waveobj.MetaKey_CmdShell] = useShell\n\tif paused {\n\t\tcreateMeta[waveobj.MetaKey_CmdRunOnStart] = false\n\t} else {\n\t\tcreateMeta[waveobj.MetaKey_CmdRunOnce] = true\n\t\tcreateMeta[waveobj.MetaKey_CmdRunOnStart] = true\n\t}\n\tif forceExit {\n\t\tcreateMeta[waveobj.MetaKey_CmdCloseOnExitForce] = true\n\t} else if exit {\n\t\tcreateMeta[waveobj.MetaKey_CmdCloseOnExit] = true\n\t}\n\tcreateMeta[waveobj.MetaKey_CmdCloseOnExitDelay] = float64(delayMs)\n\tif appendOutput {\n\t\tcreateMeta[waveobj.MetaKey_CmdClearOnStart] = false\n\t}\n\n\tif RpcContext.Conn != \"\" {\n\t\tcreateMeta[waveobj.MetaKey_Connection] = RpcContext.Conn\n\t}\n\n\ttabId := getTabIdFromEnv()\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"no WAVETERM_TABID env var set\")\n\t}\n\n\tcreateBlockData := wshrpc.CommandCreateBlockData{\n\t\tTabId: tabId,\n\t\tBlockDef: &waveobj.BlockDef{\n\t\t\tMeta: createMeta,\n\t\t\tFiles: map[string]*waveobj.FileDef{\n\t\t\t\twavebase.BlockFile_Env: {\n\t\t\t\t\tContent: envContent,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tMagnified: magnified,\n\t\tFocused:   true,\n\t}\n\n\toref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating new run block: %w\", err)\n\t}\n\n\tWriteStdout(\"run block created: %s\\n\", oref)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-secret.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\n// secretNameRegex must match the validation in pkg/wconfig/secretstore.go\nvar secretNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)\n\nvar secretUiMagnified bool\n\nvar secretCmd = &cobra.Command{\n\tUse:   \"secret\",\n\tShort: \"manage secrets\",\n\tLong:  \"Manage secrets for Wave Terminal\",\n}\n\nvar secretGetCmd = &cobra.Command{\n\tUse:     \"get [name]\",\n\tShort:   \"get a secret value\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    secretGetRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar secretSetCmd = &cobra.Command{\n\tUse:     \"set [name]=[value]\",\n\tShort:   \"set a secret value\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    secretSetRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar secretListCmd = &cobra.Command{\n\tUse:     \"list\",\n\tShort:   \"list all secret names\",\n\tArgs:    cobra.NoArgs,\n\tRunE:    secretListRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar secretDeleteCmd = &cobra.Command{\n\tUse:     \"delete [name]\",\n\tShort:   \"delete a secret\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    secretDeleteRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar secretUiCmd = &cobra.Command{\n\tUse:     \"ui\",\n\tShort:   \"open secrets UI\",\n\tArgs:    cobra.NoArgs,\n\tRunE:    secretUiRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\tsecretUiCmd.Flags().BoolVarP(&secretUiMagnified, \"magnified\", \"m\", false, \"open secrets UI in magnified mode\")\n\trootCmd.AddCommand(secretCmd)\n\tsecretCmd.AddCommand(secretGetCmd)\n\tsecretCmd.AddCommand(secretSetCmd)\n\tsecretCmd.AddCommand(secretListCmd)\n\tsecretCmd.AddCommand(secretDeleteCmd)\n\tsecretCmd.AddCommand(secretUiCmd)\n}\n\nfunc secretGetRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"secret\", rtnErr == nil)\n\t}()\n\n\tname := args[0]\n\tif !secretNameRegex.MatchString(name) {\n\t\treturn fmt.Errorf(\"invalid secret name: must start with a letter and contain only letters, numbers, and underscores\")\n\t}\n\n\tresp, err := wshclient.GetSecretsCommand(RpcClient, []string{name}, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting secret: %w\", err)\n\t}\n\n\tvalue, ok := resp[name]\n\tif !ok {\n\t\treturn fmt.Errorf(\"secret not found: %s\", name)\n\t}\n\n\tWriteStdout(\"%s\\n\", value)\n\treturn nil\n}\n\nfunc secretSetRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"secret\", rtnErr == nil)\n\t}()\n\n\tparts := strings.SplitN(args[0], \"=\", 2)\n\tif len(parts) != 2 {\n\t\treturn fmt.Errorf(\"invalid format: expected [name]=[value]\")\n\t}\n\n\tname := parts[0]\n\tvalue := parts[1]\n\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"secret name cannot be empty\")\n\t}\n\n\tbackend, err := wshclient.GetSecretsLinuxStorageBackendCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"checking secret storage backend: %w\", err)\n\t}\n\n\tif backend == \"basic_text\" || backend == \"unknown\" {\n\t\treturn fmt.Errorf(\"No appropriate secret manager found, cannot set secrets\")\n\t}\n\n\tsecrets := map[string]*string{name: &value}\n\terr = wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"setting secret: %w\", err)\n\t}\n\n\tWriteStdout(\"secret set: %s\\n\", name)\n\treturn nil\n}\n\nfunc secretListRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"secret\", rtnErr == nil)\n\t}()\n\n\tnames, err := wshclient.GetSecretsNamesCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"listing secrets: %w\", err)\n\t}\n\n\tfor _, name := range names {\n\t\tWriteStdout(\"%s\\n\", name)\n\t}\n\treturn nil\n}\n\nfunc secretDeleteRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"secret\", rtnErr == nil)\n\t}()\n\n\tname := args[0]\n\tif !secretNameRegex.MatchString(name) {\n\t\treturn fmt.Errorf(\"invalid secret name: must start with a letter and contain only letters, numbers, and underscores\")\n\t}\n\n\tsecrets := map[string]*string{name: nil}\n\terr := wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"deleting secret: %w\", err)\n\t}\n\n\tWriteStdout(\"secret deleted: %s\\n\", name)\n\treturn nil\n}\n\nfunc secretUiRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"secret\", rtnErr == nil)\n\t}()\n\n\ttabId := getTabIdFromEnv()\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"no WAVETERM_TABID env var set\")\n\t}\n\n\twshCmd := &wshrpc.CommandCreateBlockData{\n\t\tTabId: tabId,\n\t\tBlockDef: &waveobj.BlockDef{\n\t\t\tMeta: map[string]interface{}{\n\t\t\t\twaveobj.MetaKey_View: \"waveconfig\",\n\t\t\t\twaveobj.MetaKey_File: \"secrets\",\n\t\t\t},\n\t\t},\n\t\tMagnified: secretUiMagnified,\n\t\tFocused:   true,\n\t}\n\n\t_, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"opening secrets UI: %w\", err)\n\t}\n\treturn nil\n}"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-setbg.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/fileutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar setBgCmd = &cobra.Command{\n\tUse:   \"setbg [--opacity value] [--tile|--center] [--scale value] (image-path|\\\"#color\\\"|color-name)\",\n\tShort: \"set background image or color for a tab\",\n\tLong: `Set a background image or color for a tab. Colors can be specified as:\n  - A quoted hex value like \"#ff0000\" (quotes required to prevent # being interpreted as a shell comment)\n  - A CSS color name like \"blue\" or \"forestgreen\"\nOr provide a path to a supported image file (jpg, png, gif, webp, or svg).\n\nYou can also:\n  - Use --clear to remove the background\n  - Use --opacity without other arguments to change just the opacity\n  - Use --center for centered images without scaling (good for logos)\n  - Use --scale with --center to control image size\n  - Use --print to see the metadata without applying it`,\n\tRunE:    setBgRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar (\n\tsetBgOpacity float64\n\tsetBgTile    bool\n\tsetBgCenter  bool\n\tsetBgSize    string\n\tsetBgClear   bool\n\tsetBgPrint   bool\n)\n\nfunc init() {\n\trootCmd.AddCommand(setBgCmd)\n\tsetBgCmd.Flags().Float64Var(&setBgOpacity, \"opacity\", 0.5, \"background opacity (0.0-1.0)\")\n\tsetBgCmd.Flags().BoolVar(&setBgTile, \"tile\", false, \"tile the background image\")\n\tsetBgCmd.Flags().BoolVar(&setBgCenter, \"center\", false, \"center the image without scaling\")\n\tsetBgCmd.Flags().StringVar(&setBgSize, \"size\", \"auto\", \"size for centered images (px, %, or auto)\")\n\tsetBgCmd.Flags().BoolVar(&setBgClear, \"clear\", false, \"clear the background\")\n\tsetBgCmd.Flags().BoolVar(&setBgPrint, \"print\", false, \"print the metadata without applying it\")\n\n\t// Make tile and center mutually exclusive\n\tsetBgCmd.MarkFlagsMutuallyExclusive(\"tile\", \"center\")\n}\n\nfunc validateHexColor(color string) error {\n\tif !strings.HasPrefix(color, \"#\") {\n\t\treturn fmt.Errorf(\"color must start with #\")\n\t}\n\tcolorHex := color[1:]\n\tif len(colorHex) != 6 && len(colorHex) != 8 {\n\t\treturn fmt.Errorf(\"color must be in #RRGGBB or #RRGGBBAA format\")\n\t}\n\t_, err := hex.DecodeString(colorHex)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid hex color: %v\", err)\n\t}\n\treturn nil\n}\n\nfunc setBgRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"setbg\", rtnErr == nil)\n\t}()\n\n\t// Create base metadata\n\tmeta := map[string]interface{}{}\n\n\t// Handle opacity-only change or clear\n\tif len(args) == 0 {\n\t\tif !cmd.Flags().Changed(\"opacity\") && !setBgClear {\n\t\t\tOutputHelpMessage(cmd)\n\t\t\treturn fmt.Errorf(\"setbg requires an image path or color value\")\n\t\t}\n\t\tif setBgOpacity < 0 || setBgOpacity > 1 {\n\t\t\treturn fmt.Errorf(\"opacity must be between 0.0 and 1.0\")\n\t\t}\n\t\tif setBgClear {\n\t\t\tmeta[\"bg:*\"] = true\n\t\t} else {\n\t\t\tmeta[\"bg:opacity\"] = setBgOpacity\n\t\t}\n\t} else if len(args) > 1 {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"too many arguments\")\n\t} else {\n\t\t// Handle background setting\n\t\tmeta[\"bg:*\"] = true\n\t\tif setBgOpacity < 0 || setBgOpacity > 1 {\n\t\t\treturn fmt.Errorf(\"opacity must be between 0.0 and 1.0\")\n\t\t}\n\t\tmeta[\"bg:opacity\"] = setBgOpacity\n\n\t\tinput := args[0]\n\t\tvar bgStyle string\n\n\t\t// Check for hex color\n\t\tif strings.HasPrefix(input, \"#\") {\n\t\t\tif err := validateHexColor(input); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tbgStyle = input\n\t\t} else if CssColorNames[strings.ToLower(input)] {\n\t\t\t// Handle CSS color name\n\t\t\tbgStyle = strings.ToLower(input)\n\t\t} else {\n\t\t\t// Handle image input\n\t\t\tabsPath, err := filepath.Abs(wavebase.ExpandHomeDirSafe(input))\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"resolving image path: %v\", err)\n\t\t\t}\n\n\t\t\tfileInfo, err := os.Stat(absPath)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"cannot access image file: %v\", err)\n\t\t\t}\n\t\t\tif fileInfo.IsDir() {\n\t\t\t\treturn fmt.Errorf(\"path is a directory, not an image file\")\n\t\t\t}\n\n\t\t\tmimeType := fileutil.DetectMimeType(absPath, fileInfo, true)\n\t\t\tswitch mimeType {\n\t\t\tcase \"image/jpeg\", \"image/png\", \"image/gif\", \"image/webp\", \"image/svg+xml\":\n\t\t\t\t// Valid image type\n\t\t\tdefault:\n\t\t\t\treturn fmt.Errorf(\"file does not appear to be a valid image (detected type: %s)\", mimeType)\n\t\t\t}\n\n\t\t\t// Create URL-safe path\n\t\t\tescapedPath := filepath.ToSlash(absPath)\n\t\t\tescapedPath = strings.ReplaceAll(escapedPath, \"'\", \"\\\\'\")\n\t\t\tbgStyle = fmt.Sprintf(\"url('%s')\", escapedPath)\n\n\t\t\tswitch {\n\t\t\tcase setBgTile:\n\t\t\t\tbgStyle += \" repeat\"\n\t\t\tcase setBgCenter:\n\t\t\t\tbgStyle += fmt.Sprintf(\" no-repeat center/%s\", setBgSize)\n\t\t\tdefault:\n\t\t\t\tbgStyle += \" center/cover no-repeat\"\n\t\t\t}\n\t\t}\n\n\t\tmeta[\"bg\"] = bgStyle\n\t}\n\n\tif setBgPrint {\n\t\tjsonBytes, err := json.MarshalIndent(meta, \"\", \"  \")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error formatting metadata: %v\", err)\n\t\t}\n\t\tWriteStdout(\"%s\\n\", string(jsonBytes))\n\t\treturn nil\n\t}\n\n\t// Resolve tab reference\n\tid := blockArg\n\tif id == \"\" {\n\t\tid = \"tab\"\n\t}\n\toRef, err := resolveSimpleId(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Send RPC request\n\tsetMetaWshCmd := wshrpc.CommandSetMetaData{\n\t\tORef: *oRef,\n\t\tMeta: meta,\n\t}\n\terr = wshclient.SetMetaCommand(RpcClient, setMetaWshCmd, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"setting background: %v\", err)\n\t}\n\n\tWriteStdout(\"background set\\n\")\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-setconfig.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar setConfigCmd = &cobra.Command{\n\tUse:     \"setconfig\",\n\tShort:   \"set config\",\n\tArgs:    cobra.MinimumNArgs(1),\n\tRunE:    setConfigRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\trootCmd.AddCommand(setConfigCmd)\n}\n\nfunc setConfigRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"setconfig\", rtnErr == nil)\n\t}()\n\n\tmetaSetsStrs := args[:]\n\tmeta, err := parseMetaSets(metaSetsStrs)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcommandData := wshrpc.MetaSettingsType{MetaMapType: meta}\n\terr = wshclient.SetConfigCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"setting config: %w\", err)\n\t}\n\tWriteStdout(\"config set\\n\")\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-setmeta.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar setMetaCmd = &cobra.Command{\n\tUse:     \"setmeta [-b {blockid|blocknum|this}] [--json file.json] key=value ...\",\n\tShort:   \"set metadata for an entity\",\n\tArgs:    cobra.MinimumNArgs(0),\n\tRunE:    setMetaRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar setMetaJsonFilePath string\n\nfunc init() {\n\trootCmd.AddCommand(setMetaCmd)\n\tsetMetaCmd.Flags().StringVar(&setMetaJsonFilePath, \"json\", \"\", \"JSON file containing metadata to apply (use '-' for stdin)\")\n}\n\nfunc loadJSONFile(filepath string) (map[string]interface{}, error) {\n\tvar data []byte\n\tvar err error\n\n\tif filepath == \"-\" {\n\t\tdata, err = io.ReadAll(os.Stdin)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"reading from stdin: %v\", err)\n\t\t}\n\t} else {\n\t\tdata, err = os.ReadFile(filepath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"reading JSON file: %v\", err)\n\t\t}\n\t}\n\n\tvar result map[string]interface{}\n\tif err := json.Unmarshal(data, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing JSON file: %v\", err)\n\t}\n\n\tif result == nil {\n\t\treturn nil, fmt.Errorf(\"JSON file must contain an object, not null\")\n\t}\n\n\treturn result, nil\n}\n\nfunc parseMetaValue(setVal string) (any, error) {\n\tif setVal == \"\" || setVal == \"null\" {\n\t\treturn nil, nil\n\t}\n\tif setVal == \"true\" {\n\t\treturn true, nil\n\t}\n\tif setVal == \"false\" {\n\t\treturn false, nil\n\t}\n\tif setVal[0] == '[' || setVal[0] == '{' || setVal[0] == '\"' {\n\t\tvar val any\n\t\terr := json.Unmarshal([]byte(setVal), &val)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid json value: %v\", err)\n\t\t}\n\t\treturn val, nil\n\t}\n\n\t// Try parsing as integer\n\tival, err := strconv.ParseInt(setVal, 0, 64)\n\tif err == nil {\n\t\treturn ival, nil\n\t}\n\n\t// Try parsing as float\n\tfval, err := strconv.ParseFloat(setVal, 64)\n\tif err == nil {\n\t\treturn fval, nil\n\t}\n\n\t// Fallback to string\n\treturn setVal, nil\n}\n\nfunc setNestedValue(meta map[string]any, path []string, value any) {\n\t// For single key, just set directly\n\tif len(path) == 1 {\n\t\tmeta[path[0]] = value\n\t\treturn\n\t}\n\n\t// For nested path, traverse or create maps as needed\n\tcurrent := meta\n\tfor i := 0; i < len(path)-1; i++ {\n\t\tkey := path[i]\n\t\t// If next level doesn't exist or isn't a map, create new map\n\t\tnext, exists := current[key]\n\t\tif !exists {\n\t\t\tnextMap := make(map[string]any)\n\t\t\tcurrent[key] = nextMap\n\t\t\tcurrent = nextMap\n\t\t} else if nextMap, ok := next.(map[string]any); ok {\n\t\t\tcurrent = nextMap\n\t\t} else {\n\t\t\t// If existing value isn't a map, replace with new map\n\t\t\tnextMap = make(map[string]any)\n\t\t\tcurrent[key] = nextMap\n\t\t\tcurrent = nextMap\n\t\t}\n\t}\n\n\t// Set the final value\n\tcurrent[path[len(path)-1]] = value\n}\n\nfunc parseMetaSets(metaSets []string) (map[string]any, error) {\n\tmeta := make(map[string]any)\n\tfor _, metaSet := range metaSets {\n\t\tfields := strings.SplitN(metaSet, \"=\", 2)\n\t\tif len(fields) != 2 {\n\t\t\treturn nil, fmt.Errorf(\"invalid meta set: %q\", metaSet)\n\t\t}\n\n\t\tval, err := parseMetaValue(fields[1])\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// Split the key path and set nested value\n\t\tpath := strings.Split(fields[0], \"/\")\n\t\tsetNestedValue(meta, path, val)\n\t}\n\treturn meta, nil\n}\n\nfunc simpleMergeMeta(meta map[string]interface{}, metaUpdate map[string]interface{}) map[string]interface{} {\n\tfor k, v := range metaUpdate {\n\t\tif v == nil {\n\t\t\tdelete(meta, k)\n\t\t} else {\n\t\t\tmeta[k] = v\n\t\t}\n\t}\n\treturn meta\n}\n\nfunc setMetaRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"setmeta\", rtnErr == nil)\n\t}()\n\tvar jsonMeta map[string]interface{}\n\tif setMetaJsonFilePath != \"\" {\n\t\tvar err error\n\t\tjsonMeta, err = loadJSONFile(setMetaJsonFilePath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tcmdMeta, err := parseMetaSets(args)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Merge JSON metadata with command-line metadata, with command-line taking precedence\n\tvar fullMeta map[string]any\n\tif len(jsonMeta) > 0 {\n\t\tfullMeta = simpleMergeMeta(jsonMeta, cmdMeta)\n\t} else {\n\t\tfullMeta = cmdMeta\n\t}\n\tif len(fullMeta) == 0 {\n\t\treturn fmt.Errorf(\"no metadata keys specified\")\n\t}\n\tfullORef, err := resolveBlockArg()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsetMetaWshCmd := &wshrpc.CommandSetMetaData{\n\t\tORef: *fullORef,\n\t\tMeta: fullMeta,\n\t}\n\terr = wshclient.SetMetaCommand(RpcClient, *setMetaWshCmd, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"setting metadata: %v\", err)\n\t}\n\tWriteStdout(\"metadata set\\n\")\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-setvar.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nconst DefaultVarFileName = \"var\"\n\nvar setVarCmd = &cobra.Command{\n\tUse:   \"setvar [flags] KEY=VALUE...\",\n\tShort: \"set variable(s) for a block\",\n\tLong: `Set one or more variables for a block. \nUse --remove/-r to remove variables instead of setting them.\nWhen setting, each argument must be in KEY=VALUE format.\nWhen removing, each argument is treated as a key to remove.`,\n\tExample: \"  wsh setvar FOO=bar BAZ=123\\n  wsh setvar -r FOO BAZ\",\n\tArgs:    cobra.MinimumNArgs(1),\n\tRunE:    setVarRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar (\n\tsetVarFileName  string\n\tsetVarRemoveVar bool\n\tsetVarLocal     bool\n)\n\nfunc init() {\n\trootCmd.AddCommand(setVarCmd)\n\tsetVarCmd.Flags().StringVar(&setVarFileName, \"varfile\", DefaultVarFileName, \"var file name\")\n\tsetVarCmd.Flags().BoolVarP(&setVarLocal, \"local\", \"l\", false, \"set variables local to block\")\n\tsetVarCmd.Flags().BoolVarP(&setVarRemoveVar, \"remove\", \"r\", false, \"remove the variable(s) instead of setting\")\n}\n\nfunc parseKeyValue(arg string) (key, value string, err error) {\n\tif setVarRemoveVar {\n\t\treturn arg, \"\", nil\n\t}\n\n\tparts := strings.SplitN(arg, \"=\", 2)\n\tif len(parts) != 2 {\n\t\treturn \"\", \"\", fmt.Errorf(\"invalid KEY=VALUE format %q (= sign required)\", arg)\n\t}\n\tkey = parts[0]\n\tif key == \"\" {\n\t\treturn \"\", \"\", fmt.Errorf(\"empty key not allowed\")\n\t}\n\treturn key, parts[1], nil\n}\n\nfunc setVarRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"setvar\", rtnErr == nil)\n\t}()\n\n\t// Resolve block to get zoneId\n\tif blockArg == \"\" {\n\t\tif getVarLocal {\n\t\t\tblockArg = \"this\"\n\t\t} else {\n\t\t\tblockArg = \"client\"\n\t\t}\n\t}\n\tfullORef, err := resolveBlockArg()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Process all variables\n\tfor _, arg := range args {\n\t\tkey, value, err := parseKeyValue(arg)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tcommandData := wshrpc.CommandVarData{\n\t\t\tKey:      key,\n\t\t\tZoneId:   fullORef.OID,\n\t\t\tFileName: setVarFileName,\n\t\t\tRemove:   setVarRemoveVar,\n\t\t}\n\n\t\tif !setVarRemoveVar {\n\t\t\tcommandData.Val = value\n\t\t}\n\n\t\terr = wshclient.SetVarCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"setting variable %s: %w\", key, err)\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-shell-unix.go",
    "content": "//go:build !windows\n\n// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"os\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n)\n\nfunc init() {\n\trootCmd.AddCommand(shellCmd)\n}\n\nvar shellCmd = &cobra.Command{\n\tUse:    \"shell\",\n\tHidden: true,\n\tShort:  \"Print the login shell of this user\",\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tWriteStdout(\"%s\", shellCmdInner())\n\t},\n}\n\nfunc shellCmdInner() string {\n\tif runtime.GOOS == \"darwin\" {\n\t\treturn shellutil.GetMacUserShell() + \"\\n\"\n\t}\n\n\tshell := os.Getenv(\"SHELL\")\n\tif shell == \"\" {\n\t\treturn \"/bin/bash\\n\"\n\t}\n\treturn strings.TrimSpace(shell) + \"\\n\"\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-shell-win.go",
    "content": "//go:build windows\n\n// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"github.com/spf13/cobra\"\n)\n\nfunc init() {\n\trootCmd.AddCommand(shellCmd)\n}\n\nvar shellCmd = &cobra.Command{\n\tUse:    \"shell\",\n\tHidden: true,\n\tShort:  \"Print the login shell of this user\",\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tshellCmdInner()\n\t},\n}\n\nfunc shellCmdInner() {\n\tWriteStderr(\"not implemented/n\")\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-ssh.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar (\n\tidentityFiles []string\n\tsshLogin      string\n\tsshPort       string\n\tnewBlock      bool\n)\n\nvar sshCmd = &cobra.Command{\n\tUse:     \"ssh\",\n\tShort:   \"connect this terminal to a remote host\",\n\tArgs:    cobra.ExactArgs(1),\n\tRunE:    sshRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\tsshCmd.Flags().StringArrayVarP(&identityFiles, \"identityfile\", \"i\", []string{}, \"add an identity file for publickey authentication\")\n\tsshCmd.Flags().StringVarP(&sshLogin, \"login\", \"l\", \"\", \"set the remote login name\")\n\tsshCmd.Flags().StringVarP(&sshPort, \"port\", \"p\", \"\", \"set the remote port\")\n\tsshCmd.Flags().BoolVarP(&newBlock, \"new\", \"n\", false, \"create a new terminal block with this connection\")\n\trootCmd.AddCommand(sshCmd)\n}\n\nfunc sshRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"ssh\", rtnErr == nil)\n\t}()\n\n\tsshArg := args[0]\n\tvar err error\n\tsshArg, err = applySSHOverrides(sshArg, sshLogin, sshPort)\n\tif err != nil {\n\t\treturn err\n\t}\n\tblockId := RpcContext.BlockId\n\tif blockId == \"\" && !newBlock {\n\t\treturn fmt.Errorf(\"cannot determine blockid (not in JWT)\")\n\t}\n\n\t// Create connection request\n\tconnOpts := wshrpc.ConnRequest{\n\t\tHost:       sshArg,\n\t\tLogBlockId: blockId,\n\t\tKeywords: wconfig.ConnKeywords{\n\t\t\tSshIdentityFile: identityFiles,\n\t\t},\n\t}\n\twshclient.ConnConnectCommand(RpcClient, connOpts, &wshrpc.RpcOpts{Timeout: 60000})\n\n\tif newBlock {\n\t\ttabId := getTabIdFromEnv()\n\t\tif tabId == \"\" {\n\t\t\treturn fmt.Errorf(\"no WAVETERM_TABID env var set\")\n\t\t}\n\n\t\t// Create a new block with the SSH connection\n\t\tcreateMeta := map[string]any{\n\t\t\twaveobj.MetaKey_View:       \"term\",\n\t\t\twaveobj.MetaKey_Controller: \"shell\",\n\t\t\twaveobj.MetaKey_Connection: sshArg,\n\t\t}\n\t\tif RpcContext.Conn != \"\" {\n\t\t\tcreateMeta[waveobj.MetaKey_Connection] = RpcContext.Conn\n\t\t}\n\t\tcreateBlockData := wshrpc.CommandCreateBlockData{\n\t\t\tTabId: tabId,\n\t\t\tBlockDef: &waveobj.BlockDef{\n\t\t\t\tMeta: createMeta,\n\t\t\t},\n\t\t\tFocused: true,\n\t\t}\n\t\toref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"creating new terminal block: %w\", err)\n\t\t}\n\t\tWriteStdout(\"new terminal block created with connection to %q: %s\\n\", sshArg, oref)\n\t\treturn nil\n\t}\n\n\t// Update existing block with the new connection\n\tdata := wshrpc.CommandSetMetaData{\n\t\tORef: waveobj.MakeORef(waveobj.OType_Block, blockId),\n\t\tMeta: map[string]any{\n\t\t\twaveobj.MetaKey_Connection: sshArg,\n\t\t\twaveobj.MetaKey_CmdCwd:     nil,\n\t\t},\n\t}\n\terr = wshclient.SetMetaCommand(RpcClient, data, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"setting connection in block: %w\", err)\n\t}\n\tWriteStderr(\"switched connection to %q\\n\", sshArg)\n\treturn nil\n}\n\nfunc applySSHOverrides(sshArg string, login string, port string) (string, error) {\n\tif login == \"\" && port == \"\" {\n\t\treturn sshArg, nil\n\t}\n\topts, err := remote.ParseOpts(sshArg)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif login != \"\" {\n\t\topts.SSHUser = login\n\t}\n\tif port != \"\" {\n\t\topts.SSHPort = port\n\t}\n\treturn opts.String(), nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-ssh_test.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport \"testing\"\n\nfunc TestApplySSHOverrides(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tsshArg  string\n\t\tlogin   string\n\t\tport    string\n\t\twant    string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname:   \"no overrides preserves target\",\n\t\t\tsshArg: \"root@bar.com:2022\",\n\t\t\twant:   \"root@bar.com:2022\",\n\t\t},\n\t\t{\n\t\t\tname:   \"login override replaces parsed user\",\n\t\t\tsshArg: \"root@bar.com\",\n\t\t\tlogin:  \"foo\",\n\t\t\twant:   \"foo@bar.com\",\n\t\t},\n\t\t{\n\t\t\tname:   \"port override replaces parsed port\",\n\t\t\tsshArg: \"root@bar.com:2022\",\n\t\t\tport:   \"2222\",\n\t\t\twant:   \"root@bar.com:2222\",\n\t\t},\n\t\t{\n\t\t\tname:   \"both overrides replace parsed user and port\",\n\t\t\tsshArg: \"root@bar.com:2022\",\n\t\t\tlogin:  \"foo\",\n\t\t\tport:   \"2200\",\n\t\t\twant:   \"foo@bar.com:2200\",\n\t\t},\n\t\t{\n\t\t\tname:   \"login override adds user to bare host\",\n\t\t\tsshArg: \"bar.com\",\n\t\t\tlogin:  \"foo\",\n\t\t\twant:   \"foo@bar.com\",\n\t\t},\n\t\t{\n\t\t\tname:   \"port override adds port to bare host\",\n\t\t\tsshArg: \"bar.com\",\n\t\t\tport:   \"2200\",\n\t\t\twant:   \"bar.com:2200\",\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid target returns parse error when override requested\",\n\t\t\tsshArg:  \"bad host\",\n\t\t\tlogin:   \"foo\",\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := applySSHOverrides(tt.sshArg, tt.login, tt.port)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Fatalf(\"applySSHOverrides() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Fatalf(\"applySSHOverrides() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-tabindicator.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar tabIndicatorCmd = &cobra.Command{\n\tUse:     \"tabindicator [icon]\",\n\tShort:   \"set or clear a tab indicator (deprecated: use 'wsh badge')\",\n\tArgs:    cobra.MaximumNArgs(1),\n\tRunE:    tabIndicatorRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar (\n\ttabIndicatorTabId    string\n\ttabIndicatorColor    string\n\ttabIndicatorPriority float64\n\ttabIndicatorClear    bool\n\ttabIndicatorBeep     bool\n)\n\nfunc init() {\n\trootCmd.AddCommand(tabIndicatorCmd)\n\ttabIndicatorCmd.Flags().StringVar(&tabIndicatorTabId, \"tabid\", \"\", \"tab id (defaults to WAVETERM_TABID)\")\n\ttabIndicatorCmd.Flags().StringVar(&tabIndicatorColor, \"color\", \"\", \"indicator color\")\n\ttabIndicatorCmd.Flags().Float64Var(&tabIndicatorPriority, \"priority\", 10, \"indicator priority\")\n\ttabIndicatorCmd.Flags().BoolVar(&tabIndicatorClear, \"clear\", false, \"clear the indicator\")\n\ttabIndicatorCmd.Flags().BoolVar(&tabIndicatorBeep, \"beep\", false, \"play system bell sound\")\n}\n\nfunc tabIndicatorRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"tabindicator\", rtnErr == nil)\n\t}()\n\n\tfmt.Fprintf(os.Stderr, \"tabindicator is deprecated, use 'wsh badge' instead\\n\")\n\n\ttabId := tabIndicatorTabId\n\tif tabId == \"\" {\n\t\ttabId = os.Getenv(\"WAVETERM_TABID\")\n\t}\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"no tab id specified (use --tabid or set WAVETERM_TABID)\")\n\t}\n\n\toref := waveobj.MakeORef(waveobj.OType_Tab, tabId)\n\n\tvar eventData baseds.BadgeEvent\n\teventData.ORef = oref.String()\n\n\tif tabIndicatorClear {\n\t\teventData.Clear = true\n\t} else {\n\t\ticon := \"bell\"\n\t\tif len(args) > 0 {\n\t\t\ticon = args[0]\n\t\t}\n\t\tbadgeId, err := uuid.NewV7()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"generating badge id: %v\", err)\n\t\t}\n\t\teventData.Badge = &baseds.Badge{\n\t\t\tBadgeId:  badgeId.String(),\n\t\t\tIcon:     icon,\n\t\t\tColor:    tabIndicatorColor,\n\t\t\tPriority: tabIndicatorPriority,\n\t\t}\n\t}\n\n\tevent := wps.WaveEvent{\n\t\tEvent:  wps.Event_Badge,\n\t\tScopes: []string{oref.String()},\n\t\tData:   eventData,\n\t}\n\n\terr := wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"publishing badge event: %v\", err)\n\t}\n\n\tif tabIndicatorBeep {\n\t\terr = wshclient.ElectronSystemBellCommand(RpcClient, &wshrpc.RpcOpts{Route: \"electron\"})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"playing system bell: %v\", err)\n\t\t}\n\t}\n\n\tif tabIndicatorClear {\n\t\tfmt.Printf(\"tab indicator cleared\\n\")\n\t} else {\n\t\tfmt.Printf(\"tab indicator set\\n\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-term.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar termMagnified bool\n\nvar termCmd = &cobra.Command{\n\tUse:     \"term\",\n\tShort:   \"open a terminal in directory\",\n\tArgs:    cobra.RangeArgs(0, 1),\n\tRunE:    termRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\ttermCmd.Flags().BoolVarP(&termMagnified, \"magnified\", \"m\", false, \"open view in magnified mode\")\n\trootCmd.AddCommand(termCmd)\n}\n\nfunc termRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"term\", rtnErr == nil)\n\t}()\n\n\tvar cwd string\n\tif len(args) > 0 {\n\t\tcwd = args[0]\n\t\tcwdExpanded, err := wavebase.ExpandHomeDir(cwd)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tcwd = cwdExpanded\n\t} else {\n\t\tvar err error\n\t\tcwd, err = os.Getwd()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"getting current directory: %w\", err)\n\t\t}\n\t}\n\tvar err error\n\tcwd, err = filepath.Abs(cwd)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting absolute path: %w\", err)\n\t}\n\n\ttabId := getTabIdFromEnv()\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"no WAVETERM_TABID env var set\")\n\t}\n\n\tcreateMeta := map[string]any{\n\t\twaveobj.MetaKey_View:       \"term\",\n\t\twaveobj.MetaKey_CmdCwd:     cwd,\n\t\twaveobj.MetaKey_Controller: \"shell\",\n\t}\n\tif RpcContext.Conn != \"\" {\n\t\tcreateMeta[waveobj.MetaKey_Connection] = RpcContext.Conn\n\t}\n\tcreateBlockData := wshrpc.CommandCreateBlockData{\n\t\tTabId: tabId,\n\t\tBlockDef: &waveobj.BlockDef{\n\t\t\tMeta: createMeta,\n\t\t},\n\t\tMagnified: termMagnified,\n\t\tFocused:   true,\n\t}\n\toref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating new terminal block: %w\", err)\n\t}\n\tWriteStdout(\"terminal block created: %s\\n\", oref)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-termscrollback.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nvar termScrollbackCmd = &cobra.Command{\n\tUse:   \"termscrollback\",\n\tShort: \"Get terminal scrollback from a terminal block\",\n\tLong: `Get the terminal scrollback from a terminal block.\n\nBy default, retrieves all lines. You can specify line ranges or get the \noutput of the last command using the --lastcommand flag.`,\n\tRunE:                  termScrollbackRun,\n\tPreRunE:               preRunSetupRpcClient,\n\tDisableFlagsInUseLine: true,\n}\n\nvar (\n\ttermScrollbackLineStart  int\n\ttermScrollbackLineEnd    int\n\ttermScrollbackLastCmd    bool\n\ttermScrollbackOutputFile string\n)\n\nfunc init() {\n\trootCmd.AddCommand(termScrollbackCmd)\n\n\ttermScrollbackCmd.Flags().IntVar(&termScrollbackLineStart, \"start\", 0, \"starting line number (0 = beginning)\")\n\ttermScrollbackCmd.Flags().IntVar(&termScrollbackLineEnd, \"end\", 0, \"ending line number (0 = all lines)\")\n\ttermScrollbackCmd.Flags().BoolVar(&termScrollbackLastCmd, \"lastcommand\", false, \"get output of last command (requires shell integration)\")\n\ttermScrollbackCmd.Flags().StringVarP(&termScrollbackOutputFile, \"output\", \"o\", \"\", \"write output to file instead of stdout\")\n}\n\nfunc termScrollbackRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"termscrollback\", rtnErr == nil)\n\t}()\n\n\t// Resolve the block argument\n\tfullORef, err := resolveBlockArg()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Get block metadata to verify it's a terminal block\n\tmetaData, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{\n\t\tORef: *fullORef,\n\t}, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting block metadata: %w\", err)\n\t}\n\n\t// Check if the block is a terminal block\n\tviewType, ok := metaData[waveobj.MetaKey_View].(string)\n\tif !ok || viewType != \"term\" {\n\t\treturn fmt.Errorf(\"block %s is not a terminal block (view type: %s)\", fullORef.OID, viewType)\n\t}\n\n\t// Make the RPC call to get scrollback\n\tscrollbackData := wshrpc.CommandTermGetScrollbackLinesData{\n\t\tLineStart:   termScrollbackLineStart,\n\t\tLineEnd:     termScrollbackLineEnd,\n\t\tLastCommand: termScrollbackLastCmd,\n\t}\n\n\tresult, err := wshclient.TermGetScrollbackLinesCommand(RpcClient, scrollbackData, &wshrpc.RpcOpts{\n\t\tRoute:   wshutil.MakeFeBlockRouteId(fullORef.OID),\n\t\tTimeout: 5000,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting terminal scrollback: %w\", err)\n\t}\n\n\t// Format the output\n\toutput := strings.Join(result.Lines, \"\\n\")\n\tif len(result.Lines) > 0 {\n\t\toutput += \"\\n\" // Add final newline\n\t}\n\n\t// Write to file or stdout\n\tif termScrollbackOutputFile != \"\" {\n\t\terr = os.WriteFile(termScrollbackOutputFile, []byte(output), 0644)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error writing to file %s: %w\", termScrollbackOutputFile, err)\n\t\t}\n\t\tfmt.Printf(\"terminal scrollback written to %s (%d lines)\\n\", termScrollbackOutputFile, len(result.Lines))\n\t} else {\n\t\tfmt.Print(output)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar testCmd = &cobra.Command{\n\tUse:     \"test\",\n\tHidden:  true,\n\tShort:   \"test command\",\n\tPreRunE: preRunSetupRpcClient,\n\tRunE:    runTestCmd,\n}\n\nfunc init() {\n\trootCmd.AddCommand(testCmd)\n}\n\nfunc runTestCmd(cmd *cobra.Command, args []string) error {\n\trtn, err := wshclient.TestMultiArgCommand(RpcClient, \"testarg\", 42, true, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tWriteStdout(\"%s\\n\", rtn)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-token.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n)\n\nvar tokenCmd = &cobra.Command{\n\tUse:    \"token [token] [shell-type]\",\n\tShort:  \"exchange token for shell initialization script\",\n\tRunE:   tokenCmdRun,\n\tHidden: true,\n}\n\nfunc init() {\n\trootCmd.AddCommand(tokenCmd)\n}\n\nfunc tokenCmdRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tif len(args) != 2 {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"wsh token requires exactly 2 arguments, got %d\", len(args))\n\t}\n\ttokenStr, shellType := args[0], args[1]\n\tif tokenStr == \"\" || shellType == \"\" {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"wsh token requires non-empty arguments\")\n\t}\n\trtnData, err := setupRpcClientWithToken(tokenStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting up rpc client: %w\", err)\n\t}\n\tenvScriptText, err := shellutil.EncodeEnvVarsForShell(shellType, rtnData.Env)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error encoding env vars: %w\", err)\n\t}\n\tWriteStdout(\"%s\\n\", envScriptText)\n\tWriteStdout(\"%s\\n\", rtnData.InitScriptText)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-version.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nvar versionVerbose bool\nvar versionJSON bool\n\n// versionCmd represents the version command\nvar versionCmd = &cobra.Command{\n\tUse:   \"version [-v] [--json]\",\n\tShort: \"Print the version number of wsh\",\n\tRunE:  runVersionCmd,\n}\n\nfunc init() {\n\tversionCmd.Flags().BoolVarP(&versionVerbose, \"verbose\", \"v\", false, \"Display full version information\")\n\tversionCmd.Flags().BoolVar(&versionJSON, \"json\", false, \"Output version information in JSON format\")\n\trootCmd.AddCommand(versionCmd)\n}\n\nfunc runVersionCmd(cmd *cobra.Command, args []string) error {\n\tif !versionVerbose && !versionJSON {\n\t\tWriteStdout(\"wsh v%s\\n\", wavebase.WaveVersion)\n\t\treturn nil\n\t}\n\n\terr := preRunSetupRpcClient(cmd, args)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tresp, err := wshclient.WaveInfoCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tupdateChannel, err := wshclient.GetUpdateChannelCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000, Route: wshutil.ElectronRoute})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif versionJSON {\n\t\tinfo := map[string]interface{}{\n\t\t\t\"version\":       resp.Version,\n\t\t\t\"clientid\":      resp.ClientId,\n\t\t\t\"buildtime\":     resp.BuildTime,\n\t\t\t\"configdir\":     resp.ConfigDir,\n\t\t\t\"datadir\":       resp.DataDir,\n\t\t\t\"updatechannel\": updateChannel,\n\t\t}\n\t\toutBArr, err := json.MarshalIndent(info, \"\", \"  \")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"formatting version info: %v\", err)\n\t\t}\n\t\tWriteStdout(\"%s\\n\", string(outBArr))\n\t\treturn nil\n\t}\n\n\t// Default verbose text output\n\tfmt.Printf(\"v%s (%s)\\n\", resp.Version, resp.BuildTime)\n\tfmt.Printf(\"clientid:  %s\\n\", resp.ClientId)\n\tfmt.Printf(\"configdir: %s\\n\", resp.ConfigDir)\n\tfmt.Printf(\"datadir:   %s\\n\", resp.DataDir)\n\tfmt.Printf(\"update-channel: %s\\n\", updateChannel)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-view.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar viewMagnified bool\n\nvar viewCmd = &cobra.Command{\n\tUse:     \"view {file|directory|URL}\",\n\tAliases: []string{\"preview\", \"open\"},\n\tShort:   \"preview/edit a file or directory\",\n\tRunE:    viewRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nvar editCmd = &cobra.Command{\n\tUse:     \"edit {file}\",\n\tShort:   \"edit a file\",\n\tRunE:    viewRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\tviewCmd.Flags().BoolVarP(&viewMagnified, \"magnified\", \"m\", false, \"open view in magnified mode\")\n\trootCmd.AddCommand(viewCmd)\n\teditCmd.Flags().BoolVarP(&viewMagnified, \"magnified\", \"m\", false, \"open view in magnified mode\")\n\trootCmd.AddCommand(editCmd)\n}\n\nfunc viewRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tcmdName := cmd.Name()\n\tdefer func() {\n\t\tsendActivity(cmdName, rtnErr == nil)\n\t}()\n\tif len(args) == 0 {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"no arguments.  wsh %s requires a file or URL as an argument argument\", cmdName)\n\t}\n\tif len(args) > 1 {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"too many arguments.  wsh %s requires exactly one argument\", cmdName)\n\t}\n\ttabId := getTabIdFromEnv()\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"no WAVETERM_TABID env var set\")\n\t}\n\tfileArg := args[0]\n\tconn := RpcContext.Conn\n\tvar wshCmd *wshrpc.CommandCreateBlockData\n\tif strings.HasPrefix(fileArg, \"http://\") || strings.HasPrefix(fileArg, \"https://\") {\n\t\twshCmd = &wshrpc.CommandCreateBlockData{\n\t\t\tTabId: tabId,\n\t\t\tBlockDef: &waveobj.BlockDef{\n\t\t\t\tMeta: map[string]any{\n\t\t\t\t\twaveobj.MetaKey_View: \"web\",\n\t\t\t\t\twaveobj.MetaKey_Url:  fileArg,\n\t\t\t\t},\n\t\t\t},\n\t\t\tMagnified: viewMagnified,\n\t\t\tFocused:   true,\n\t\t}\n\t} else {\n\t\tabsFile, err := filepath.Abs(fileArg)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"getting absolute path: %w\", err)\n\t\t}\n\t\tabsParent, err := filepath.Abs(filepath.Dir(fileArg))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"getting absolute path of parent dir: %w\", err)\n\t\t}\n\t\t_, err = os.Stat(absParent)\n\t\tif err == fs.ErrNotExist {\n\t\t\treturn fmt.Errorf(\"parent directory does not exist: %q\", absParent)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"getting file info: %w\", err)\n\t\t}\n\t\twshCmd = &wshrpc.CommandCreateBlockData{\n\t\t\tTabId: tabId,\n\t\t\tBlockDef: &waveobj.BlockDef{\n\t\t\t\tMeta: map[string]interface{}{\n\t\t\t\t\twaveobj.MetaKey_View: \"preview\",\n\t\t\t\t\twaveobj.MetaKey_File: absFile,\n\t\t\t\t},\n\t\t\t},\n\t\t\tMagnified: viewMagnified,\n\t\t\tFocused:   true,\n\t\t}\n\t\tif cmdName == \"edit\" {\n\t\t\twshCmd.BlockDef.Meta[waveobj.MetaKey_Edit] = true\n\t\t}\n\t\tif conn != \"\" {\n\t\t\twshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = conn\n\t\t}\n\t}\n\t_, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"running view command: %w\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-wavepath.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar wavepathCmd = &cobra.Command{\n\tUse:     \"wavepath {config|data|log}\",\n\tShort:   \"Get paths to various waveterm files and directories\",\n\tRunE:    wavepathRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\twavepathCmd.Flags().BoolP(\"open\", \"o\", false, \"Open the path in a new block\")\n\twavepathCmd.Flags().BoolP(\"open-external\", \"O\", false, \"Open the path in the default external application\")\n\twavepathCmd.Flags().BoolP(\"tail\", \"t\", false, \"Tail the last 100 lines of the log\")\n\trootCmd.AddCommand(wavepathCmd)\n}\n\nfunc wavepathRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"wavepath\", rtnErr == nil)\n\t}()\n\n\tif len(args) == 0 {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"no arguments. wsh wavepath requires a type argument (config, data, or log)\")\n\t}\n\tif len(args) > 1 {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"too many arguments. wsh wavepath requires exactly one argument\")\n\t}\n\n\tpathType := args[0]\n\tif pathType != \"config\" && pathType != \"data\" && pathType != \"log\" {\n\t\tOutputHelpMessage(cmd)\n\t\treturn fmt.Errorf(\"invalid path type %q. must be one of: config, data, log\", pathType)\n\t}\n\n\ttail, _ := cmd.Flags().GetBool(\"tail\")\n\tif tail && pathType != \"log\" {\n\t\treturn fmt.Errorf(\"--tail can only be used with the log path type\")\n\t}\n\n\topen, _ := cmd.Flags().GetBool(\"open\")\n\topenExternal, _ := cmd.Flags().GetBool(\"open-external\")\n\n\ttabId := getTabIdFromEnv()\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"no WAVETERM_TABID env var set\")\n\t}\n\n\tpath, err := wshclient.PathCommand(RpcClient, wshrpc.PathCommandData{\n\t\tPathType:     pathType,\n\t\tOpen:         open,\n\t\tOpenExternal: openExternal,\n\t\tTabId:        tabId,\n\t}, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting path: %w\", err)\n\t}\n\n\tif tail && pathType == \"log\" {\n\t\terr = tailLogFile(path)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"tailing log file: %w\", err)\n\t\t}\n\t\treturn nil\n\t}\n\n\tWriteStdout(\"%s\\n\", path)\n\treturn nil\n}\n\nfunc tailLogFile(path string) error {\n\tfile, err := os.Open(path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"opening log file: %w\", err)\n\t}\n\tdefer file.Close()\n\n\t// Get file size\n\tstat, err := file.Stat()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting file stats: %w\", err)\n\t}\n\n\t// Read last 16KB or whole file if smaller\n\treadSize := int64(16 * 1024)\n\tvar offset int64\n\tif stat.Size() > readSize {\n\t\toffset = stat.Size() - readSize\n\t}\n\n\t_, err = file.Seek(offset, 0)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"seeking file: %w\", err)\n\t}\n\n\tbuf := make([]byte, readSize)\n\tn, err := file.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\treturn fmt.Errorf(\"reading file: %w\", err)\n\t}\n\tbuf = buf[:n]\n\n\t// Skip partial line at start if we're not at beginning of file\n\tif offset > 0 {\n\t\tidx := bytes.IndexByte(buf, '\\n')\n\t\tif idx >= 0 {\n\t\t\tbuf = buf[idx+1:]\n\t\t}\n\t}\n\n\t// Split into lines\n\tlines := bytes.Split(buf, []byte{'\\n'})\n\n\t// Take last 100 lines if we have more\n\tstartIdx := 0\n\tif len(lines) > 100 {\n\t\tstartIdx = len(lines) - 100\n\t}\n\n\t// Print lines\n\tfor _, line := range lines[startIdx:] {\n\t\tWriteStdout(\"%s\\n\", string(line))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-web.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nvar webCmd = &cobra.Command{\n\tUse:               \"web [open|get|set]\",\n\tShort:             \"web commands\",\n\tPersistentPreRunE: preRunSetupRpcClient,\n}\n\nvar webOpenCmd = &cobra.Command{\n\tUse:   \"open url\",\n\tShort: \"open a url a web widget\",\n\tArgs:  cobra.ExactArgs(1),\n\tRunE:  webOpenRun,\n}\n\nvar webGetCmd = &cobra.Command{\n\tUse:    \"get [--inner] [--all] [--json] css-selector\",\n\tShort:  \"get the html for a css selector\",\n\tArgs:   cobra.ExactArgs(1),\n\tHidden: true,\n\tRunE:   webGetRun,\n}\n\nvar webGetInner bool\nvar webGetAll bool\nvar webGetJson bool\nvar webOpenMagnified bool\nvar webOpenReplaceBlock string\n\nfunc init() {\n\twebOpenCmd.Flags().BoolVarP(&webOpenMagnified, \"magnified\", \"m\", false, \"open view in magnified mode\")\n\twebOpenCmd.Flags().StringVarP(&webOpenReplaceBlock, \"replace\", \"r\", \"\", \"replace block\")\n\twebCmd.AddCommand(webOpenCmd)\n\twebGetCmd.Flags().BoolVarP(&webGetInner, \"inner\", \"\", false, \"get inner html (instead of outer)\")\n\twebGetCmd.Flags().BoolVarP(&webGetAll, \"all\", \"\", false, \"get all matches (querySelectorAll)\")\n\twebGetCmd.Flags().BoolVarP(&webGetJson, \"json\", \"\", false, \"output as json\")\n\twebCmd.AddCommand(webGetCmd)\n\trootCmd.AddCommand(webCmd)\n}\n\nfunc webGetRun(cmd *cobra.Command, args []string) error {\n\tfullORef, err := resolveBlockArg()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"resolving blockid: %w\", err)\n\t}\n\tblockInfo, err := wshclient.BlockInfoCommand(RpcClient, fullORef.OID, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting block info: %w\", err)\n\t}\n\tif blockInfo.Block.Meta.GetString(waveobj.MetaKey_View, \"\") != \"web\" {\n\t\treturn fmt.Errorf(\"block %s is not a web block\", fullORef.OID)\n\t}\n\tdata := wshrpc.CommandWebSelectorData{\n\t\tWorkspaceId: blockInfo.WorkspaceId,\n\t\tBlockId:     fullORef.OID,\n\t\tTabId:       blockInfo.TabId,\n\t\tSelector:    args[0],\n\t\tOpts: &wshrpc.WebSelectorOpts{\n\t\t\tInner: webGetInner,\n\t\t\tAll:   webGetAll,\n\t\t},\n\t}\n\toutput, err := wshclient.WebSelectorCommand(RpcClient, data, &wshrpc.RpcOpts{\n\t\tRoute:   wshutil.ElectronRoute,\n\t\tTimeout: 5000,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\tif webGetJson {\n\t\tbarr, err := json.MarshalIndent(output, \"\", \"  \")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"json encoding: %w\", err)\n\t\t}\n\t\tWriteStdout(\"%s\\n\", string(barr))\n\t} else {\n\t\tfor _, item := range output {\n\t\t\tWriteStdout(\"%s\\n\", item)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc webOpenRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"web\", rtnErr == nil)\n\t}()\n\n\tvar replaceBlockORef *waveobj.ORef\n\tif webOpenReplaceBlock != \"\" {\n\t\tvar err error\n\t\treplaceBlockORef, err = resolveSimpleId(webOpenReplaceBlock)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"resolving -r blockid: %w\", err)\n\t\t}\n\t}\n\tif replaceBlockORef != nil && webOpenMagnified {\n\t\treturn fmt.Errorf(\"cannot use --replace and --magnified together\")\n\t}\n\n\ttabId := getTabIdFromEnv()\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"no WAVETERM_TABID env var set\")\n\t}\n\n\twshCmd := wshrpc.CommandCreateBlockData{\n\t\tTabId: tabId,\n\t\tBlockDef: &waveobj.BlockDef{\n\t\t\tMeta: map[string]any{\n\t\t\t\twaveobj.MetaKey_View: \"web\",\n\t\t\t\twaveobj.MetaKey_Url:  args[0],\n\t\t\t},\n\t\t},\n\t\tMagnified: webOpenMagnified,\n\t\tFocused:   true,\n\t}\n\tif replaceBlockORef != nil {\n\t\twshCmd.TargetBlockId = replaceBlockORef.OID\n\t\twshCmd.TargetAction = wshrpc.CreateBlockAction_Replace\n\t}\n\toref, err := wshclient.CreateBlockCommand(RpcClient, wshCmd, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating block: %w\", err)\n\t}\n\tWriteStdout(\"created block %s\\n\", oref)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-workspace.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar workspaceCommand = &cobra.Command{\n\tUse:   \"workspace\",\n\tShort: \"Manage workspaces\",\n\t// Args:    cobra.MinimumNArgs(1),\n}\n\nfunc init() {\n\tworkspaceCommand.AddCommand(workspaceListCommand)\n\trootCmd.AddCommand(workspaceCommand)\n}\n\nvar workspaceListCommand = &cobra.Command{\n\tUse:     \"list\",\n\tShort:   \"List workspaces\",\n\tRun:     workspaceListRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc workspaceListRun(cmd *cobra.Command, args []string) {\n\tworkspaces, err := wshclient.WorkspaceListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000})\n\tif err != nil {\n\t\tWriteStderr(\"Unable to list workspaces: %v\\n\", err)\n\t\treturn\n\t}\n\n\tWriteStdout(\"[\\n\")\n\tfor i, w := range workspaces {\n\t\tWriteStdout(\"  {\\n    \\\"windowId\\\": \\\"%s\\\",\\n\", w.WindowId)\n\t\tWriteStderr(\"    \\\"workspaceId\\\": \\\"%s\\\",\\n\", w.WorkspaceData.OID)\n\t\tWriteStdout(\"    \\\"name\\\": \\\"%s\\\",\\n\", w.WorkspaceData.Name)\n\t\tWriteStdout(\"    \\\"icon\\\": \\\"%s\\\",\\n\", w.WorkspaceData.Icon)\n\t\tWriteStdout(\"    \\\"color\\\": \\\"%s\\\"\\n\", w.WorkspaceData.Color)\n\t\tif i < len(workspaces)-1 {\n\t\t\tWriteStdout(\"  },\\n\")\n\t\t} else {\n\t\t\tWriteStdout(\"  }\\n\")\n\t\t}\n\t}\n\tWriteStdout(\"]\\n\")\n}\n"
  },
  {
    "path": "cmd/wsh/cmd/wshcmd-wsl.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\nvar distroName string\n\nvar wslCmd = &cobra.Command{\n\tUse:     \"wsl [-d <distribution-name>]\",\n\tShort:   \"connect this terminal to a local wsl connection\",\n\tArgs:    cobra.NoArgs,\n\tRunE:    wslRun,\n\tPreRunE: preRunSetupRpcClient,\n}\n\nfunc init() {\n\twslCmd.Flags().StringVarP(&distroName, \"distribution\", \"d\", \"\", \"Run the specified distribution\")\n\trootCmd.AddCommand(wslCmd)\n}\n\nfunc wslRun(cmd *cobra.Command, args []string) (rtnErr error) {\n\tdefer func() {\n\t\tsendActivity(\"wsl\", rtnErr == nil)\n\t}()\n\n\tvar err error\n\tif distroName == \"\" {\n\t\t// get default distro from the host\n\t\tdistroName, err = wshclient.WslDefaultDistroCommand(RpcClient, nil)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif !strings.HasPrefix(distroName, \"wsl://\") {\n\t\tdistroName = \"wsl://\" + distroName\n\t}\n\tblockId := RpcContext.BlockId\n\tif blockId == \"\" {\n\t\treturn fmt.Errorf(\"cannot determine blockid (not in JWT)\")\n\t}\n\tdata := wshrpc.CommandSetMetaData{\n\t\tORef: waveobj.MakeORef(waveobj.OType_Block, blockId),\n\t\tMeta: map[string]any{\n\t\t\twaveobj.MetaKey_Connection: distroName,\n\t\t},\n\t}\n\terr = wshclient.SetMetaCommand(RpcClient, data, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"setting connection in block: %w\", err)\n\t}\n\tWriteStderr(\"switched connection to %q\\n\", distroName)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/wsh/main-wsh.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main\n\nimport (\n\t\"github.com/wavetermdev/waveterm/cmd/wsh/cmd\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\n// set by main-server.go\nvar WaveVersion = \"0.0.0\"\nvar BuildTime = \"0\"\n\nfunc main() {\n\twavebase.WaveVersion = WaveVersion\n\twavebase.BuildTime = BuildTime\n\tcmd.Execute()\n}\n"
  },
  {
    "path": "db/db.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage db\n\nimport \"embed\"\n\n//go:embed migrations-filestore/*.sql\nvar FilestoreMigrationFS embed.FS\n\n//go:embed migrations-wstore/*.sql\nvar WStoreMigrationFS embed.FS\n"
  },
  {
    "path": "db/migrations-filestore/000001_init.down.sql",
    "content": "DROP TABLE db_wave_file;\n\nDROP TABLE db_file_data;\n"
  },
  {
    "path": "db/migrations-filestore/000001_init.up.sql",
    "content": "CREATE TABLE db_wave_file (\n    zoneid varchar(36) NOT NULL,\n    name varchar(200) NOT NULL,\n    size bigint NOT NULL,\n    createdts bigint NOT NULL,\n    modts bigint NOT NULL,\n    opts json NOT NULL,\n    meta json NOT NULL,\n    PRIMARY KEY (zoneid, name)\n);\n\nCREATE TABLE db_file_data (\n    zoneid varchar(36) NOT NULL,\n    name varchar(200) NOT NULL,\n    partidx int NOT NULL,\n    data blob NOT NULL,\n    PRIMARY KEY(zoneid, name, partidx)\n);\n\n"
  },
  {
    "path": "db/migrations-wstore/000001_init.down.sql",
    "content": "DROP TABLE db_client;\n\nDROP TABLE db_workspace;\n\nDROP TABLE db_tab;\n\nDROP TABLE db_block;\n"
  },
  {
    "path": "db/migrations-wstore/000001_init.up.sql",
    "content": "CREATE TABLE db_client (\n    oid varchar(36) PRIMARY KEY,\n    version int NOT NULL,\n    data json NOT NULL\n);\n\nCREATE TABLE db_window (\n    oid varchar(36) PRIMARY KEY,\n    version int NOT NULL,\n    data json NOT NULL\n);\n\nCREATE TABLE db_workspace (\n    oid varchar(36) PRIMARY KEY,\n    version int NOT NULL,\n    data json NOT NULL\n);\n\nCREATE TABLE db_tab (\n    oid varchar(36) PRIMARY KEY,\n    version int NOT NULL,\n    data json NOT NULL\n);\n\nCREATE TABLE db_block (\n    oid varchar(36) PRIMARY KEY,\n    version int NOT NULL,\n    data json NOT NULL\n);\n\n"
  },
  {
    "path": "db/migrations-wstore/000002_init.down.sql",
    "content": "DROP TABLE db_layout;\n"
  },
  {
    "path": "db/migrations-wstore/000002_init.up.sql",
    "content": "CREATE TABLE db_layout (\n    oid varchar(36) PRIMARY KEY,\n    version int NOT NULL,\n    data json NOT NULL\n);\n"
  },
  {
    "path": "db/migrations-wstore/000003_activity.down.sql",
    "content": "DROP TABLE db_activity;"
  },
  {
    "path": "db/migrations-wstore/000003_activity.up.sql",
    "content": "CREATE TABLE db_activity (\n    day varchar(20) PRIMARY KEY,\n    uploaded boolean NOT NULL,\n    tdata json NOT NULL,\n    tzname varchar(50) NOT NULL,\n    tzoffset int NOT NULL,\n    clientversion varchar(20) NOT NULL,\n    clientarch varchar(20) NOT NULL,\n    buildtime varchar(20) NOT NULL DEFAULT '-',\n    osrelease varchar(20) NOT NULL DEFAULT '-'\n);"
  },
  {
    "path": "db/migrations-wstore/000004_history.down.sql",
    "content": "DROP TABLE history_migrated;"
  },
  {
    "path": "db/migrations-wstore/000004_history.up.sql",
    "content": "CREATE TABLE history_migrated (\n\thistoryid varchar(36) PRIMARY KEY,\n    ts bigint NOT NULL,\n\tremotename varchar(200) NOT NULL,\n\thaderror boolean NOT NULL,\n    cmdstr text NOT NULL,\n\texitcode int NULL DEFAULT NULL, \n\tdurationms int NULL DEFAULT NULL\n);\n"
  },
  {
    "path": "db/migrations-wstore/000005_blockparent.down.sql",
    "content": "-- we don't need to remove parentoref"
  },
  {
    "path": "db/migrations-wstore/000005_blockparent.up.sql",
    "content": "UPDATE db_block\nSET data = json_set(db_block.data, '$.parentoref', 'tab:' || db_tab.oid)\nFROM db_tab\nWHERE db_block.oid IN (SELECT value FROM json_each(db_tab.data, '$.blockids'));\n"
  },
  {
    "path": "db/migrations-wstore/000006_workspace.down.sql",
    "content": "-- Step 1: Restore the $.activetabid field to db_window.data\nUPDATE db_window\nSET data = json_set(\n    db_window.data,\n    '$.activetabid',\n    (SELECT json_extract(db_workspace.data, '$.activetabid')\n     FROM db_workspace\n     WHERE db_workspace.oid = json_extract(db_window.data, '$.workspaceid'))\n)\nWHERE json_extract(data, '$.workspaceid') IN (\n    SELECT oid FROM db_workspace\n);\n\n-- Step 2: Remove the $.activetabid field from db_workspace.data\nUPDATE db_workspace\nSET data = json_remove(data, '$.activetabid')\nWHERE oid IN (\n    SELECT json_extract(db_window.data, '$.workspaceid')\n    FROM db_window\n);\n"
  },
  {
    "path": "db/migrations-wstore/000006_workspace.up.sql",
    "content": "-- Step 1: Update db_workspace.data to set the $.activetabid field\nUPDATE db_workspace\nSET data = json_set(\n    db_workspace.data,\n    '$.activetabid',\n    (SELECT json_extract(db_window.data, '$.activetabid'))\n)\nFROM db_window\nWHERE db_workspace.oid IN (\n    SELECT json_extract(db_window.data, '$.workspaceid')\n);\n\n-- Step 2: Remove the $.activetabid field from db_window.data\nUPDATE db_window\nSET data = json_remove(data, '$.activetabid')\nWHERE json_extract(data, '$.workspaceid') IN (\n    SELECT oid FROM db_workspace\n);\n"
  },
  {
    "path": "db/migrations-wstore/000007_events.down.sql",
    "content": "DROP TABLE db_tevent;\n"
  },
  {
    "path": "db/migrations-wstore/000007_events.up.sql",
    "content": "CREATE TABLE db_tevent (\n   uuid varchar(36) PRIMARY KEY,\n   ts int NOT NULL,\n   tslocal varchar(100) NOT NULL,\n   event varchar(50) NOT NULL,\n   props json NOT NULL,\n   uploaded boolean NOT NULL DEFAULT 0\n);"
  },
  {
    "path": "db/migrations-wstore/000008_aimeta.down.sql",
    "content": "-- presets exist in config files, and should automatically prepopulate the meta in the older code versions"
  },
  {
    "path": "db/migrations-wstore/000008_aimeta.up.sql",
    "content": "--- removes all ai: keys except ai:preset\nUPDATE db_block\nSET data = json_remove(\n    db_block.data,\n    '$.meta.ai:*',\n    '$.meta.ai:apitype',\n    '$.meta.ai:baseurl',\n    '$.meta.ai:apitoken',\n    '$.meta.ai:name',\n    '$.meta.ai:model',\n    '$.meta.ai:orgid',\n    '$.meta.ai:apiversion',\n    '$.meta.ai:maxtokens',\n    '$.meta.ai:timeoutms',\n    '$.meta.ai:fontsize',\n    '$.meta.ai:fixedfontsize'\n)\nWHERE json_extract(data, '$.meta.view') = 'waveai';"
  },
  {
    "path": "db/migrations-wstore/000009_mainserver.down.sql",
    "content": "DROP TABLE IF EXISTS db_mainserver;\n"
  },
  {
    "path": "db/migrations-wstore/000009_mainserver.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS db_mainserver (\n    oid varchar(36) PRIMARY KEY,\n    version int NOT NULL,\n    data json NOT NULL\n);\n"
  },
  {
    "path": "db/migrations-wstore/000010_merge_pinned_tabs.down.sql",
    "content": "-- This migration cannot be reversed as pinned tab state is lost\n-- during the merge operation\n"
  },
  {
    "path": "db/migrations-wstore/000010_merge_pinned_tabs.up.sql",
    "content": "-- Merge PinnedTabIds into TabIds, preserving tab order\nUPDATE db_workspace\nSET data = json_set(\n  data,\n  '$.tabids',\n  (\n    SELECT json_group_array(value)\n    FROM (\n      SELECT value, 0 AS src, CAST(key AS INT) AS k\n      FROM json_each(data, '$.pinnedtabids')\n      UNION ALL\n      SELECT value, 1 AS src, CAST(key AS INT) AS k\n      FROM json_each(data, '$.tabids')\n      ORDER BY src, k\n    )\n  )\n)\nWHERE json_type(data, '$.pinnedtabids') = 'array'\n  AND json_array_length(data, '$.pinnedtabids') > 0;\n\nUPDATE db_workspace\nSET data = json_remove(data, '$.pinnedtabids')\nWHERE json_type(data, '$.pinnedtabids') IS NOT NULL;\n"
  },
  {
    "path": "db/migrations-wstore/000011_job.down.sql",
    "content": "DROP TABLE IF EXISTS db_job;\n"
  },
  {
    "path": "db/migrations-wstore/000011_job.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS db_job (\n    oid varchar(36) PRIMARY KEY,\n    version int NOT NULL,\n    data json NOT NULL\n);\n"
  },
  {
    "path": "docs/.editorconfig",
    "content": "root = true\n\n[*]\nend_of_line = lf\ninsert_final_newline = true\n\n[*.{js,jsx,ts,tsx,cjs,json,yml,yaml,css,less}]\ncharset = utf-8\nindent_style = space\nindent_size = 4\n\n[CNAME]\ninsert_final_newline = false\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "# Dependencies\n/node_modules\n/.yarn\n\n# Production\n/build\nbuild.zip\n\n# Generated files\n.docusaurus\n.cache-loader\n\n# Misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n"
  },
  {
    "path": "docs/.prettierignore",
    "content": "build\n.git\nnode_modules\n*.min.*\n*.mdx\nCNAME\n"
  },
  {
    "path": "docs/.remarkrc",
    "content": "{\n  \"plugins\": [\n    \"remark-preset-lint-consistent\",\n    \"remark-preset-lint-recommended\",\n    \"remark-mdx\",\n    \"remark-frontmatter\"\n  ]\n}\n"
  },
  {
    "path": "docs/README.md",
    "content": "# Wave Terminal Documentation\n\nThis 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\n\n### Installation\n\nOur docs are built using [Docusaurus](https://docusaurus.io/), a modern static website generator.\n\n### Local Development\n\n```sh\ntask docsite\n```\n\nThis command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.\n\n### Build\n\n```sh\ntask docsite:build:public\n```\n\nThis command generates static content into the `build` directory and can be served using any static contents hosting service.\n\n### Deployment\n\nDeployments are handled automatically by the [Docsite CI/CD workflow](../.github/workflows/deploy-docsite.yml)\n"
  },
  {
    "path": "docs/babel.config.js",
    "content": "module.exports = {\n    presets: [require.resolve(\"@docusaurus/core/lib/babel/preset\")],\n};\n"
  },
  {
    "path": "docs/docs/ai-presets.mdx",
    "content": "---\nsidebar_position: 3.6\nid: \"ai-presets\"\ntitle: \"AI Presets (Deprecated)\"\n---\n:::warning Deprecation Notice\nThe 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.\n:::\n\n\n![AI Presets Menu](./img/ai-presets.png#right)\n\nWave'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.\n\n## How AI Presets Work\n\nAI presets are defined in `~/.config/waveterm/presets/ai.json`. You can easily edit this file using:\n\n```bash\nwsh editconfig presets/ai.json\n```\n\nEach 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`.\n\nHere's a basic example using Claude:\n\n```json\n{\n  \"ai@claude-sonnet\": {\n    \"display:name\": \"Claude 3 Sonnet\",\n    \"display:order\": 1,\n    \"ai:*\": true,\n    \"ai:apitype\": \"anthropic\",\n    \"ai:model\": \"claude-3-5-sonnet-latest\",\n    \"ai:apitoken\": \"<your anthropic API key>\"\n  }\n}\n```\n\nTo make a preset your default, add this single line to your `settings.json`:\n\n```json\n{\n  \"ai:preset\": \"ai@claude-sonnet\"\n}\n```\n\n:::info\nYou can quickly set your default preset using the `setconfig` command:\n\n```bash\nwsh setconfig ai:preset=ai@claude-sonnet\n```\n\nThis is easier than editing settings.json directly!\n:::\n\n## Provider-Specific Configurations\n\n### Anthropic (Claude)\n\nTo use Claude models, create a preset like this:\n\n```json\n{\n  \"ai@claude-sonnet\": {\n    \"display:name\": \"Claude 3 Sonnet\",\n    \"display:order\": 1,\n    \"ai:*\": true,\n    \"ai:apitype\": \"anthropic\",\n    \"ai:model\": \"claude-3-5-sonnet-latest\",\n    \"ai:apitoken\": \"<your anthropic API key>\"\n  }\n}\n```\n\n### OpenAI\n\nTo use OpenAI's models:\n\n```json\n{\n  \"ai@openai-gpt41\": {\n    \"display:name\": \"GPT-4.1\",\n    \"display:order\": 2,\n    \"ai:*\": true,\n    \"ai:model\": \"gpt-4.1\",\n    \"ai:apitoken\": \"<your OpenAI API key>\"\n  }\n}\n```\n\n### Local LLMs (Ollama)\n\nTo connect to a local Ollama instance:\n\n```json\n{\n  \"ai@ollama-llama\": {\n    \"display:name\": \"Ollama - Llama2\",\n    \"display:order\": 3,\n    \"ai:*\": true,\n    \"ai:baseurl\": \"http://localhost:11434/v1\",\n    \"ai:name\": \"llama2\",\n    \"ai:model\": \"llama2\",\n    \"ai:apitoken\": \"ollama\"\n  }\n}\n```\n\nNote: 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.\n\n### Azure OpenAI\n\nTo connect to Azure AI services:\n\n```json\n{\n  \"ai@azure-gpt4\": {\n    \"display:name\": \"Azure GPT-4\",\n    \"display:order\": 4,\n    \"ai:*\": true,\n    \"ai:apitype\": \"azure\",\n    \"ai:baseurl\": \"<your Azure AI base URL>\",\n    \"ai:model\": \"<your model deployment name>\",\n    \"ai:apitoken\": \"<your Azure API key>\"\n  }\n}\n```\n\nNote: Do not include query parameters or `api-version` in the `ai:baseurl`. The `ai:model` should be your model deployment name in Azure.\n\n### Perplexity\n\nTo use Perplexity's models:\n\n```json\n{\n  \"ai@perplexity-sonar\": {\n    \"display:name\": \"Perplexity Sonar\",\n    \"display:order\": 5,\n    \"ai:*\": true,\n    \"ai:apitype\": \"perplexity\",\n    \"ai:model\": \"llama-3.1-sonar-small-128k-online\",\n    \"ai:apitoken\": \"<your perplexity API key>\"\n  }\n}\n```\n\n### Google (Gemini)\n\nTo use Google's Gemini models from [Google AI Studio](https://aistudio.google.com):\n\n```json\n{\n  \"ai@gemini-2.0\": {\n    \"display:name\": \"Gemini 2.0\",\n    \"display:order\": 6,\n    \"ai:*\": true,\n    \"ai:apitype\": \"google\",\n    \"ai:model\": \"gemini-2.0-flash-exp\",\n    \"ai:apitoken\": \"<your Google AI API key>\"\n  }\n}\n```\n\n### OpenRouter\n\nTo use OpenRouter's models:\n\n```json\n{\n  \"ai@openrouter\": {\n    \"display:name\": \"OpenRouter (Qwen)\",\n    \"display:order\": 7,\n    \"ai:*\": true,\n    \"ai:model\": \"qwen/qwen3-next-80b-a3b-thinking\",\n    \"ai:apitoken\": \"<openrouter-key>\",\n    \"ai:baseurl\": \"https://openrouter.ai/api/v1\"\n  }\n}\n```\n\n## Multiple Presets Example\n\nYou can define multiple presets in your `ai.json` file:\n\n```json\n{\n  \"ai@claude-sonnet\": {\n    \"display:name\": \"Claude 3 Sonnet\",\n    \"display:order\": 1,\n    \"ai:*\": true,\n    \"ai:apitype\": \"anthropic\",\n    \"ai:model\": \"claude-3-5-sonnet-latest\",\n    \"ai:apitoken\": \"<your anthropic API key>\"\n  },\n  \"ai@openai-gpt41\": {\n    \"display:name\": \"GPT-4.1\",\n    \"display:order\": 2,\n    \"ai:*\": true,\n    \"ai:model\": \"gpt-4.1\",\n    \"ai:apitoken\": \"<your OpenAI API key>\"\n  },\n  \"ai@ollama-llama\": {\n    \"display:name\": \"Ollama - Llama2\",\n    \"display:order\": 3,\n    \"ai:*\": true,\n    \"ai:baseurl\": \"http://localhost:11434/v1\",\n    \"ai:name\": \"llama2\",\n    \"ai:model\": \"llama2\",\n    \"ai:apitoken\": \"ollama\"\n  },\n  \"ai@perplexity-sonar\": {\n    \"display:name\": \"Perplexity Sonar\",\n    \"display:order\": 4,\n    \"ai:*\": true,\n    \"ai:apitype\": \"perplexity\",\n    \"ai:model\": \"llama-3.1-sonar-small-128k-online\",\n    \"ai:apitoken\": \"<your perplexity API key>\"\n  }\n}\n```\n\nThe `display:order` value determines the order in which presets appear in the dropdown menu.\n\nRemember to set your default preset in `settings.json`:\n\n```json\n{\n  \"ai:preset\": \"ai@claude-sonnet\"\n}\n```\n\n## Using a Proxy\n\nIf you need to route AI requests through an HTTP proxy, you can add the `ai:proxyurl` setting to any preset:\n\n```json\n{\n  \"ai@claude-with-proxy\": {\n    \"display:name\": \"Claude 3 Sonnet (via Proxy)\",\n    \"display:order\": 1,\n    \"ai:*\": true,\n    \"ai:apitype\": \"anthropic\",\n    \"ai:model\": \"claude-3-5-sonnet-latest\",\n    \"ai:apitoken\": \"<your anthropic API key>\",\n    \"ai:proxyurl\": \"http://proxy.example.com:8080\"\n  }\n}\n```\n\nThe 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).\n"
  },
  {
    "path": "docs/docs/claude-code.mdx",
    "content": "---\nsidebar_position: 1.9\nid: \"claude-code\"\ntitle: \"Claude Code Integration\"\n---\n\nimport { VersionBadge } from \"@site/src/components/versionbadge\";\n\n# Claude Code Tab Badges <VersionBadge version=\"v0.14.2\" />\n\nWhen 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.\n\n:::info\ntl;dr You can copy and paste this page directly into Claude Code and it will help you set everything up!\n:::\n\n## How it works\n\nClaude 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.\n\nBadges 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.\n\nWave 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.\n\n### Badge rollup\n\nIf 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.\n\n## Setup\n\nThese hooks go in your global Claude Code settings so they apply to every session on your machine, not just one project.\n\nAdd the following to `~/.claude/settings.json`. If you already have a `hooks` key, merge the entries in:\n\n```json\n{\n  \"hooks\": {\n    \"Notification\": [\n      {\n        \"matcher\": \"permission_prompt\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"wsh badge bell-exclamation --color '#e0b956' --priority 20 --beep\"\n          }\n        ]\n      },\n      {\n        \"matcher\": \"elicitation_dialog\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"wsh badge message-question --color '#e0b956' --priority 20 --beep\"\n          }\n        ]\n      }\n    ],\n    \"Stop\": [\n      {\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"wsh badge check --color '#58c142' --priority 10\"\n          }\n        ]\n      }\n    ],\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"AskUserQuestion\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"wsh badge message-question --color '#e0b956' --priority 20 --beep\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nThat's it. Restart any running Claude Code sessions for the hooks to take effect.\n\n:::warning Known Issue\nThere 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.\n:::\n\n## What each hook does\n\n### Permission prompt — `bell-exclamation` gold, priority 20\n\nClaude 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.\n\nThis 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.\n\nWhen you click into the tab and approve or deny the request, the badge clears automatically.\n\n### Session complete — `check` green, priority 10\n\nWhen 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.\n\n### AskUserQuestion — `message-question` gold, priority 20\n\nWhen 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.\n\n`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.\n\n## Choosing your own icons and colors\n\nIcon 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.\n\nSome icon and color ideas:\n\n| Situation | Icon | Color |\n|-----------|------|-------|\n| Custom high-priority alert | `triangle-exclamation` | `#FF453A` |\n| Blocked / waiting on input | `hourglass-half` | `#FF9500` |\n| Neutral / informational | `circle-info` | `#429DFF` |\n| Background task running | `spinner` | `#00FFDB` |\n\nSee the [`wsh badge` reference](/wsh-reference#badge) for all available flags.\n\n## Adjusting priorities\n\nPriority controls which badge wins when multiple blocks in a tab each have one. Higher numbers take precedence. The defaults above use:\n\n- **20** for permission prompts — always surfaces above everything else\n- **10** for session complete — visible when nothing more urgent is active\n\nIf you add more hooks, keep permission-blocking signals at the high end (15–25) and informational signals at the low end (5–10)."
  },
  {
    "path": "docs/docs/config.mdx",
    "content": "---\nsidebar_position: 3.45\nid: \"config\"\ntitle: \"Configuration\"\n---\n\nimport { Kbd } from \"@site/src/components/kbd\";\nimport { PlatformProvider, PlatformSelectorButton } from \"@site/src/components/platformcontext\";\nimport { VersionBadge } from \"@site/src/components/versionbadge\";\n\n<PlatformProvider>\n\n<PlatformSelectorButton />\n<div style={{ marginBottom: 20 }}></div>\n\nWave's configuration files are located at `~/.config/waveterm/`.\n\nThe main configuration file is `settings.json` (`~/.config/waveterm/settings.json`).\n\nThe file is structured as a mostly flat JSON file. Instead of using sub-objects we prefer to\nuse \":\" as level separators.\n\n:::info\n\nThe 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.\n\n```\nwsh editconfig\n```\n\n:::\n\n## Configuration Keys\n\n| Key Name                             | Type     | Function                                                                                                                                                                                                                                                      |\n| ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| 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)                                                 |\n| 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).              |\n| app:defaultnewblock                  | string   | Sets the default new block (Cmd:n, Cmd:d). \"term\" for terminal block, \"launcher\" for launcher block (default = \"term\")                                                                                                                                        |\n| app:showoverlayblocknums             | bool     | Set to false to disable the Ctrl+Shift block number overlay that appears when holding Ctrl+Shift (defaults to true)                                                                                                                                           |\n| 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                                     |\n| 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)                                                                                                                                      |\n| app:hideaibutton <VersionBadge version=\"v0.14\" />                    | bool     | Set to true to hide the AI button in the tab bar (defaults to false)                                                                                                                                                                                          |\n| 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)                                                                                                                                                    |\n| app:disablectrlshiftdisplay <VersionBadge version=\"v0.14\" />         | bool     | Set to true to disable the Ctrl+Shift visual indicator display (defaults to false)                                                                                                                                                                            |\n| app:focusfollowscursor <VersionBadge version=\"v0.14\" />              | string   | Controls whether block focus follows cursor movement: `\"off\"` (default), `\"on\"` (all blocks), or `\"term\"` (terminal blocks only)                                                                                                                             |\n| 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                                                                                |\n| ai:preset                            | string   | the default AI preset to use                                                                                                                                                                                                                                  |\n| ai:baseurl                           | string   | Set the AI Base Url (must be OpenAI compatible)                                                                                                                                                                                                               |\n| ai:apitoken                          | string   | your AI api token                                                                                                                                                                                                                                             |\n| ai:apitype                           | string   | defaults to \"open_ai\", but can also set to \"azure\" (forspecial Azure AI handling), \"anthropic\", or \"perplexity\"                                                                                                                                               |\n| ai:name                              | string   | string to display in the Wave AI block header                                                                                                                                                                                                                 |\n| ai:model                             | string   | model name to pass to API                                                                                                                                                                                                                                     |\n| ai:apiversion                        | string   | for Azure AI only (when apitype is \"azure\", this will default to \"2023-05-15\")                                                                                                                                                                                |\n| ai:orgid                             | string   |                                                                                                                                                                                                                                                               |\n| ai:maxtokens                         | int      | max tokens to pass to API                                                                                                                                                     |\n| ai:timeoutms                         | int      | timeout (in milliseconds) for AI calls                                                                                                                                        |\n| ai:proxyurl                          | string   | HTTP proxy URL for AI API requests (does not apply to Wave Cloud AI)                                                                                                          |\n| conn:askbeforewshinstall             | bool     | set to false to disable popup asking if you want to install wsh extensions on new machines                                                                                                                                                                    |\n| 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)                                                                                                                                                                       |\n| term:fontsize                        | float    | the fontsize for the terminal block                                                                                                                                                                                                                           |\n| term:fontfamily                      | string   | font family to use for terminal block                                                                                                                                                                                                                         |\n| term:disablewebgl                    | bool     | set to false to disable WebGL acceleration in terminal                                                                                                                                                                                                        |\n| term:localshellpath                  | string   | set to override the default shell path for local terminals                                                                                                                                                                                                    |\n| term:localshellopts                  | string[] | set to pass additional parameters to the term:localshellpath (example: `[\"-NoLogo\"]` for PowerShell will remove the copyright notice)                                                                                                                         |\n| term:copyonselect                    | bool     | set to false to disable terminal copy-on-select                                                                                                                                                                                                               |\n| term:scrollback                      | int      | size of terminal scrollback buffer, max is 10000                                                                                                                                                                                                              |\n| term:theme                           | string   | preset name of terminal theme to apply by default (default is \"default-dark\")                                                                                                                                                                                 |\n| term:transparency                    | float64  | set the background transparency of terminal theme (default 0.5, 0 = not transparent, 1.0 = fully transparent)                                                                                                                                                 |\n| term:allowbracketedpaste             | bool     | allow bracketed paste mode in terminal (default false)                                                                                                                                                                                                        |\n| 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)                                                                                         |\n| term:macoptionismeta                 | bool     | on macOS, treat the Option key as Meta key for terminal keybindings (default false)                                                                                                                                                                          |\n| term:cursor <VersionBadge version=\"v0.14\" />                        | string   | terminal cursor style. valid values are `block` (default), `underline`, and `bar`                                                                                                                                                                            |\n| term:cursorblink <VersionBadge version=\"v0.14\" />                   | bool     | when enabled, terminal cursor blinks (default false)                                                                                                                                                                                                          |\n| term:bellsound <VersionBadge version=\"v0.14\" />                      | bool     | when enabled, plays the system beep sound when the terminal bell (BEL character) is received (default false)                                                                                                                                                  |\n| term:bellindicator <VersionBadge version=\"v0.14\" />                  | bool     | when enabled, shows a visual indicator in the tab when the terminal bell is received (default false)                                                                                                                                                           |\n| 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)                                                                                                                     |\n| term:durable <VersionBadge version=\"v0.14\" />                        | bool     | makes remote terminal sessions durable across network disconnects (defaults to false)                                                                                                                                                                           |\n| editor:minimapenabled                | bool     | set to false to disable editor minimap                                                                                                                                                                                                                        |\n| editor:stickyscrollenabled           | bool     | enables monaco editor's stickyScroll feature (pinning headers of current context, e.g. class names, method names, etc.), defaults to false                                                                                                                    |\n| editor:wordwrap                      | bool     | set to true to enable word wrapping in the editor (defaults to false)                                                                                                                                                                                         |\n| editor:fontsize                      | float64  | set the font size for the editor (defaults to 12px)                                                                                                                                                                                                           |\n| 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)                                                                                                              |\n| preview:showhiddenfiles              | bool     | set to false to disable showing hidden files in the directory preview (defaults to true)                                                                                                                                                                      |\n| 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)                                                                               |\n| markdown:fontsize                    | float64  | font size for the normal text when rendering markdown in preview. headers are scaled up from this size, (default 14px)                                                                                                                                        |\n| markdown:fixedfontsize               | float64  | font size for the code blocks when rendering markdown in preview (default is 12px)                                                                                                                                                                            |\n| web:openlinksinternally              | bool     | set to false to open web links in external browser                                                                                                                                                                                                            |\n| web:defaulturl                       | string   | default web page to open in the web widget when no url is provided (homepage)                                                                                                                                                                                 |\n| web:defaultsearch                    | string   | search template for web searches. e.g. `https://www.google.com/search?q={query}`. \"\\{query}\" gets replaced by search term                                                                                                                                     |\n| autoupdate:enabled                   | bool     | enable/disable checking for updates (requires app restart)                                                                                                                                                                                                    |\n| autoupdate:intervalms                | float64  | time in milliseconds to wait between update checks (requires app restart)                                                                                                                                                                                     |\n| autoupdate:installonquit             | bool     | whether to automatically install updates on quit (requires app restart)                                                                                                                                                                                       |\n| autoupdate:channel                   | string   | the auto update channel \"latest\" (stable builds), or \"beta\" (updated more frequently) (requires app restart)                                                                                                                                                  |\n| tab:preset                           | string   | a \"bg@\" preset to automatically apply to new tabs. e.g. `bg@green`. should match the preset key                                                                                                                                                               |\n| tab:confirmclose                     | bool     | if set to true, a confirmation dialog will be shown before closing a tab (defaults to false)                                                                                                                                                                   |\n| widget:showhelp                      | bool     | whether to show help/tips widgets in right sidebar                                                                                                                                                                                                            |\n| 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))       |\n| 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)) |\n| window:opacity                       | float64  | 0-1, window opacity when `window:transparent` or `window:blur` are set                                                                                                                                                                                        |\n| window:bgcolor                       | string   | set the window background color (should be hex: #xxxxxx)                                                                                                                                                                                                      |\n| window:reducedmotion                 | bool     | set to true to disable most animations                                                                                                                                                                                                                        |\n| window:tilegapsize                   | int      | set to change override default gap size (in CSS pixels) between blocks                                                                                                                                                                                        |\n| window:magnifiedblockopacity         | float64  | change the opacity of a magnified block (must be between 0 and 1, defaults to 0.6)                                                                                                                                                                            |\n| 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)                                                                                                                        |\n| 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)                                              |\n| 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)      |\n| window:maxtabcachesize               | int      | number of tabs to cache. when tabs are cached, switching between them is very fast. (defaults to 10)                                                                                                                                                          |\n| window:showmenubar                   | bool     | set to use the OS-native menu bar (Windows and Linux only, requires app restart)                                                                                                                                                                              |\n| window:nativetitlebar                | bool     | set to use the OS-native title bar, rather than the overlay (Windows and Linux only, requires app restart)                                                                                                                                                    |\n| window:disablehardwareacceleration   | bool     | set to disable Chromium hardware acceleration to resolve graphical bugs (requires app restart)                                                                                                                                                                |\n| window:fullscreenonlaunch            | bool     | set to true to launch the foreground window in fullscreen mode (defaults to false)                                                                                                                                                                            |\n| 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`)                                                                                                                               |\n| 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`)                                                                                                  |\n| 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.                       |\n| telemetry:enabled                    | bool     | set to enable/disable telemetry                                                                                                                                                                                                                               |\n\nFor reference, this is the current default configuration (v0.14.0):\n\n```json\n{\n    \"ai:preset\": \"ai@global\",\n    \"ai:model\": \"gpt-5-mini\",\n    \"ai:maxtokens\": 4000,\n    \"ai:timeoutms\": 60000,\n    \"app:defaultnewblock\": \"term\",\n    \"app:confirmquit\": true,\n    \"app:hideaibutton\": false,\n    \"app:disablectrlshiftarrows\": false,\n    \"app:disablectrlshiftdisplay\": false,\n    \"app:focusfollowscursor\": \"off\",\n    \"autoupdate:enabled\": true,\n    \"autoupdate:installonquit\": true,\n    \"autoupdate:intervalms\": 3600000,\n    \"conn:askbeforewshinstall\": true,\n    \"conn:wshenabled\": true,\n    \"editor:minimapenabled\": true,\n    \"web:defaulturl\": \"https://github.com/wavetermdev/waveterm\",\n    \"web:defaultsearch\": \"https://www.google.com/search?q={query}\",\n    \"window:tilegapsize\": 3,\n    \"window:maxtabcachesize\": 10,\n    \"window:nativetitlebar\": true,\n    \"window:magnifiedblockopacity\": 0.6,\n    \"window:magnifiedblocksize\": 0.9,\n    \"window:magnifiedblockblurprimarypx\": 10,\n    \"window:fullscreenonlaunch\": false,\n    \"window:magnifiedblockblursecondarypx\": 2,\n    \"window:confirmclose\": true,\n    \"window:savelastwindow\": true,\n    \"telemetry:enabled\": true,\n    \"term:bellsound\": false,\n    \"term:bellindicator\": false,\n    \"term:osc52\": \"always\",\n    \"term:cursor\": \"block\",\n    \"term:cursorblink\": false,\n    \"term:copyonselect\": true,\n    \"term:durable\": false,\n    \"waveai:showcloudmodes\": true,\n    \"waveai:defaultmode\": \"waveai@balanced\",\n    \"preview:defaultsort\": \"name\"\n}\n```\n\n:::warning\n\nIf you installed Wave pre-v0.9.0 your configuration file will be located at\n`~/.waveterm/config/settings.json`. This includes all of the other configuration\nfiles as well: `termthemes.json`, `presets.json`, and `widgets.json`.\n\n:::\n\n## Environment Variable Resolution\n\nTo 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.).\n\n```json\n{\n  \"ai:apitoken\": \"$ENV:OPENAI_APIKEY\",\n  \"ai:baseurl\": \"$ENV:AI_BASEURL:https://api.openai.com/v1\"\n}\n```\n\n## WebBookmarks Configuration\n\nWebBookmarks 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\"/>\n\n### Bookmark Structure\n\nEach bookmark follows this structure (only `url` is required):\n\n```json\n{\n  \"url\": \"https://example.com\",\n  \"title\": \"Example Site\",\n  \"iconurl\": \"https://example.com/custom-icon.png\",\n  \"display:order\": 1\n}\n```\n\n### Fields\n\n| Field         | Type    | Description                                                                                                       |\n| ------------- | ------- | ----------------------------------------------------------------------------------------------------------------- |\n| url           | string  | **Required.** The URL of the bookmark.                                                                            |\n| title         | string  | **Optional.** A display title for the bookmark.                                                                   |\n| icon          | string  | **Optional, rarely used.** Overrides the default favicon with an icon name.                                       |\n| iconcolor     | string  | **Optional, rarely used.** Sets a custom color for the specified icon.                                            |\n| iconurl       | string  | **Optional.** Provides a custom icon URL, useful if the favicon is incorrect (e.g., for dark mode compatibility). |\n| display:order | float64 | **Optional.** Defines the order in which bookmarks appear.                                                        |\n\n### Example `bookmarks.json`\n\n```json\n{\n  \"bookmark@google\": {\n    \"url\": \"https://www.google.com\",\n    \"title\": \"Google\"\n  },\n  \"bookmark@claude\": {\n    \"url\": \"https://claude.ai\",\n    \"title\": \"Claude AI\"\n  },\n  \"bookmark@wave\": {\n    \"url\": \"https://waveterm.dev\",\n    \"title\": \"Wave Terminal\",\n    \"display:order\": -1\n  },\n  \"bookmark@wave-github\": {\n    \"url\": \"https://github.com/wavetermdev/waveterm\",\n    \"title\": \"Wave Github\",\n    \"iconurl\": \"https://github.githubassets.com/favicons/favicon-dark.png\"\n  },\n  \"bookmark@chatgpt\": {\n    \"url\": \"https://chatgpt.com\",\n    \"iconurl\": \"https://cdn.oaistatic.com/assets/favicon-miwirzcw.ico\"\n  },\n  \"bookmark@wave-pulls\": {\n    \"url\": \"https://github.com/wavetermdev/waveterm/pulls\",\n    \"title\": \"Wave Pull Requests\",\n    \"iconurl\": \"https://github.githubassets.com/favicons/favicon-dark.png\"\n  }\n}\n```\n\n### Behavior\n\n- If `iconurl` is set, it fetches the icon from the specified URL instead of the site's default favicon.\n- Bookmarks are sorted based on `display:order` (if provided), otherwise by id.\n- `icon` and `iconcolor` are rarely needed since the default behavior fetches the site's favicon.\n- favicons are refreshed every 24-hours\n\n## Terminal Theming\n\nUser-defined terminal themes are located in `~/.config/waveterm/termthemes.json`.\n\nThis JSON file is structured as an object, with each sub-key defining a theme.\nThemes 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\nthe 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).\n\n```\nwsh setmeta this term:theme=\"default-dark\"\n```\n\nHere 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!)\n\n```json\n{\n  \"default-dark\": {\n    \"display:name\": \"Default Dark\",\n    \"display:order\": 1,\n    \"black\": \"#757575\",\n    \"red\": \"#cc685c\",\n    \"green\": \"#76c266\",\n    \"yellow\": \"#cbca9b\",\n    \"blue\": \"#85aacb\",\n    \"magenta\": \"#cc72ca\",\n    \"cyan\": \"#74a7cb\",\n    \"white\": \"#c1c1c1\",\n    \"brightBlack\": \"#727272\",\n    \"brightRed\": \"#cc9d97\",\n    \"brightGreen\": \"#a3dd97\",\n    \"brightYellow\": \"#cbcaaa\",\n    \"brightBlue\": \"#9ab6cb\",\n    \"brightMagenta\": \"#cc8ecb\",\n    \"brightCyan\": \"#b7b8cb\",\n    \"brightWhite\": \"#f0f0f0\",\n    \"gray\": \"#8b918a\",\n    \"cmdtext\": \"#f0f0f0\",\n    \"foreground\": \"#c1c1c1\",\n    \"selectionBackground\": \"\",\n    \"background\": \"#00000077\",\n    \"cursorAccent\": \"\"\n  }\n}\n```\n\n:::info\n\nYou can easily open the termthemes.json config file by running:\n\n```\nwsh editconfig termthemes.json\n```\n\n:::\n\n| Key Name            | Type      | ANSI FG# | ANSI BG# | Function                                                                                                                                 |\n| ------------------- | --------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |\n| display:name        | string    |          |          | the name as it will appear in the UI context menu                                                                                        |\n| display:order       | float     |          |          | entries in the context menu are sorted by display:order                                                                                  |\n| black               | CSS color | 30       | 40       | color for black                                                                                                                          |\n| red                 | CSS color | 31       | 41       | color for red                                                                                                                            |\n| green               | CSS color | 32       | 42       | color for green                                                                                                                          |\n| yellow              | CSS color | 33       | 43       | color for yellow                                                                                                                         |\n| blue                | CSS color | 34       | 44       | color for blue                                                                                                                           |\n| magenta             | CSS color | 35       | 45       | color for magenta                                                                                                                        |\n| cyan                | CSS color | 36       | 46       | color for cyan                                                                                                                           |\n| white               | CSS color | 37       | 47       | color for white                                                                                                                          |\n| brightBlack         | CSS color | 90       | 100      | color for bright black                                                                                                                   |\n| brightRed           | CSS color | 91       | 101      | color for bright red                                                                                                                     |\n| brightGreen         | CSS color | 92       | 102      | color for bright green                                                                                                                   |\n| brightYellow        | CSS color | 93       | 103      | color for bright yellow                                                                                                                  |\n| brightBlue          | CSS color | 94       | 104      | color for bright blue                                                                                                                    |\n| brightMagenta       | CSS color | 95       | 105      | color for bright magenta                                                                                                                 |\n| brightCyan          | CSS color | 96       | 106      | color for bright cyan                                                                                                                    |\n| brightWhite         | CSS color | 97       | 107      | color for bright white                                                                                                                   |\n| gray                | CSS color |          |          | currently unused                                                                                                                         |\n| cmdtext             | CSS color |          |          | currently unused                                                                                                                         |\n| foreground          | CSS color |          |          | foreground color (default when no color code is applied)                                                                                 |\n| 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 |\n| cursorAccent        | CSS color |          |          | color for cursor                                                                                                                         |\n| selectionBackground | CSS color |          |          | background color for selected text                                                                                                       |\n\n## Customizable Systemwide Global Hotkey\n\nWave 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.\n\n### Examples\n\nAs 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.\n\nAs 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\"`.\n\n### Allowed Key Names\n\nWe support the following key names:\n\n- `Ctrl`\n- `Cmd`\n- `Shift`\n- `Alt`\n- `Option`\n- `Meta`\n- `Super`\n- Digits (non-numpad) represented by `c{Digit0}` through `c{Digit9}`\n- Letters `a` though `z`\n- F keys `F1` through `F20`\n- Soft keys `Soft1` through `Soft4`. These are essentially the same as `F21` through `F24`.\n- Space represented as either `Space` or a literal space &nbsp;<code>&nbsp;</code>\n- `Enter` (This is labeled as return on Mac)\n- `Tab`\n- `CapsLock`\n- `NumLock`\n- `Backspace` (This is labeled as delete on Mac)\n- `Delete`\n- `Insert`\n- The arrow keys `ArrowUp`, `ArrowDown`, `ArrowLeft`, and `ArrowRight`\n- `Home`\n- `End`\n- `PageUp`\n- `PageDown`\n- `Esc`\n- Volume controls `AudioVolumeUp`, `AudioVolumeDown`, `AudioVolumeMute`\n- Media controls `MediaTrackNext`, `MediaTrackPrevious`, `MediaPlayPause`, and `MediaStop`\n- `PrintScreen`\n- Numpad keys represented by `c{Numpad0}` through `c{Numpad9}`\n- The numpad decimal represented by `Decimal`\n- The numpad plus/add represented by `Add`\n- The numpad minus/subtract represented by `Subtract`\n- The numpad star/multiply represented by `Multiply`\n- The numpad slash/divide represented by `Divide`\n\n</PlatformProvider>\n"
  },
  {
    "path": "docs/docs/connections.mdx",
    "content": "---\nsidebar_position: 3.1\nid: \"connections\"\ntitle: \"Connections\"\n---\n\nimport { VersionBadge } from \"@site/src/components/versionbadge\";\n\n# Connections\n\nWave 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.\n\n## Access a Connection in a Block\n\nThe 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:\n\nFor SSH Connections:\n\n- `[user]@[host]`\n- `[host]`\n- `[user]@[host]:[port]`\n\nFor WSL Connections:\n\n- `wsl://<distribution name>`\n\nAlternatively, 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.\n\n![a dropdown showing a list of connections that already exist](./img/connection-dropdown.png)\n\n## Different Types of Connections\n\nAs 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.\n\n## What are wsh Shell Extensions?\n\n`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:\n\n- `~/.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.\n- Several environment variables are injected into the session to make certain tasks with `wsh` easier. These are [listed below](#additional-environment-variables).\n- The user-defined environment variables in the `cmd:env` entry of`connections.json` are injected into the session.\n- The user-defined initialization scripts located in `connections.json` are run. For more information on these scripts, see the section below.\n\nIf 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).\n\nWith `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).\n\n### Additional Environment Variables\n\nAs mentioned above, `wsh` injects a few environment variables in remote sessions for the user's convenience. These are listed below:\n\n| Variable Name        | Description                                                                   |\n| -------------------- | ----------------------------------------------------------------------------- |\n| TERM_PROGRAM         | Set to `waveterm` in wave.                                                    |\n| WAVETERM             | This is set to 1 in wave.                                                     |\n| WAVETERM_BLOCKID     | The id of the block containing your current terminal widget.                  |\n| WAVETERM_CLIENTID    | The id of the RPC Client being used by your current terminal widget.          |\n| WAVETERM_CONN        | The name of the remote connection being used by your current terminal widget. |\n| WAVETERM_TABID       | The id of the tab containing your current terminal widget.                    |\n| WAVETERM_VERSION     | The current semver version of wave.                                           |\n| WAVETERM_WORKSPACEID | The id of thw workspace containing your current terminal widget.              |\n\n# Initialization Scripts\n\nWave 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:\n\n| Script Keyword      | Shells Where Applied |\n| ------------------- | -------------------- |\n| cmd:initscript      | all shells           |\n| cmd:initscript.sh   | bash and zsh         |\n| cmd:initscript.bash | bash                 |\n| cmd:initscript.zsh  | zsh                  |\n| cmd:initscript.pwsh | pwsh                 |\n| cmd:initscript.fish | fish                 |\n\n## Add a New Connection to the Dropdown\n\nThe 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:\n\n- adding a new `Host` to one of your ssh config files, typically the `~/.ssh/config` file\n- adding a new entry in the internal `config/connections.json` file\n- manually typing your connection into the connection box (if this successfully connects, the connection will be added to the internal `config/connections.json` file)\n- use `wsh ssh [user]@[host]` in your terminal (if this successfully connects, the connection will be added to the internal `config/connections.json` file)\n\nWSL 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.\n\n## SSH Config Parsing\n\nAt 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:\n| Keyword | Description |\n|---------|-------------|\n| 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.|\n| User | The user of the SSH remote connection. This will default to the current user on the local machine if not specified.|\n|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.\n| Port | The port to connect to the remote on. `22` is the default if not specified.|\n| 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.|\n|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.|\n|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`.|\n|PasswordAuthentication| This is used to specify if password authentication should be attempted. The default is `yes`.|\n|KbdInteractiveAuthentication| This is used to specify if keyboard-interactive authentication should be attempted. The default is `yes`.|\n|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`|\n|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`.|\n|IdentityAgent| Specifies the Unix Domain Socket used to communicate with the SSH Agent. This is used to overwrite the SSH_AUTH_SOCK identity agent.|\n|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`.|\n|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.|\n|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\"`.|\n|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\"`.|\n\n### Example SSH Config Host\n\nFor a quick example, a host in your config file may look like:\n\n```\nHost myhost\n   User username\n   HostName 203.0.113.254\n   IdentityFile ~/.ssh/id_rsa\n   AddKeysToAgent yes\n```\n\nYou 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`.\n\n## Internal SSH Configuration\n\nIn addition to the regular ssh config file, wave also has its own config file to manage separate variables. These include\n| Keyword | Description |\n|---------|-------------|\n| 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`.|\n| 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`.|\n| conn:wshpath | A string indicating the path to the `wsh` executable on the connection. It defaults to `\"~/.waveterm/bin/wsh\"`.|\n| 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.|\n| 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.|\n| display:hidden | This boolean hides the connection from the dropdown list. It defaults to `false` |\n| display:order | This float determines the order of connections in the connection dropdown. It defaults to `0`.|\n| 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. |\n| 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. |\n| 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. |\n| 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.\n| 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. |\n| 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.\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n| 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.|\n| 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.|\n| 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.|\n| 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.|\n| 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.|\n| 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.|\n| 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.|\n| 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. |\n| 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\" /> |\n| 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. |\n| 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.|\n| 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.|\n| 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.|\n| 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.|\n| 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.|\n| 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.|\n\n### SSH Agent Detection\n\nWave resolves the identity agent path in this order:\n\n- If `ssh:identityagent` (or `IdentityAgent` in SSH config) is set for the connection, that socket or pipe is used.\n- 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.\n- If not set on macOS/Linux, Wave queries your shell environment for `SSH_AUTH_SOCK` to detect the agent path automatically.\n\n### Example Internal Configurations\n\nHere are a couple examples of things you can do using the internal configuration file `connections.json`:\n\n#### Hiding a Connection\n\nSuppose 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:\n\n```json\n{\n    <... other connections go here ...>,\n    \"git@github.com\" : {\n        \"display:hidden\": true\n    },\n    <... other connections go here ...>\n}\n```\n\n#### Moving a Connection\n\nSuppose 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:\n\n```json\n{\n    <... other connections go here ...>,\n    \"myusername@rarelyused:9999\" : {\n        \"display:order\": 100\n    },\n    <... other connections go here ...>\n}\n```\n\n#### Theming a Connection\n\nSuppose 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:\n\n```json\n{\n    <... other connections go here ...>,\n    \"myusername@myhost\" : {\n        \"term:theme\": \"warmyellow\",\n        \"term:fontsize\": 16,\n        \"term:fontfamily\": \"menlo\"\n    },\n    <... other connections go here ...>\n}\n```\n\nThis style, font size, and font family will then only apply to the widgets that are using this connection.\n\n### Entirely Defined Internally\n\nSuppose 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:\n\n```json\n{\n    <... other connections go here ...>,\n    \"myusername@myhost\" : {\n        \"ssh:hostname\": \"190.0.2.0\",\n        \"ssh:identityfile\": [\"~/.ssh/myidentityfile\"],\n        \"ssh:identitiesonly\": true,\n        \"ssh:addkeystoagent\": true\n    },\n    <... other connections go here ...>\n}\n```\n\nThis 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.\n\n### Disabling wsh for a Connection\n\nWhile 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:\n\n```json\n{\n    <... other connections go here ...>,\n    \"root@wshless\" : {\n        \"conn:enablewsh\": false,\n    },\n    <... other connections go here ...>\n}\n```\n\nNote that this same line gets added to your `connections.json` file automatically when you choose to disable `wsh` in gui when initially connecting.\n\n## Managing Connections with the CLI\n\nThe `wsh` command gives some commands specifically for interacting with the connections. You can view these [here](/wsh-reference#conn).\n\n## Troubleshooting Connections\n\n### Log Files\n\nIf 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.\n\nIf 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.\n\n### Known Limitations\n\nIn 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.\n\n#### Shell Type\n\nWave is capable of injecting `wsh` in the following shells:\n\n- bash\n- zsh\n- pwsh (powershell)\n- fish\n\nIf 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.\n\n#### AllowTcpForwarding in sshd\n\nSome 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.\n"
  },
  {
    "path": "docs/docs/customization.mdx",
    "content": "---\nsidebar_position: 3.2\nid: \"customization\"\ntitle: \"Customization\"\n---\n\n## Tab Themes\n\n![Tab Context Menu](./img/tab-context-menu.png#right)\n\nRight click on any tab to bring up a menu which allows you to rename the tab and select different backgrounds.\n\nIt is also possible to create your own themes using custom colors, gradients, images and more by editing your presets.json config file. To see how Wave's built in tab themes are defined, you can check out our [default presets file](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/presets.json).\n\n## Terminal Customization\n\n#### Terminal Theme\n\n![Terminal Context Menu](./img/terminal-context-menu.png#right)\n\nRight click in the header area of any terminal block to bring up a menu which allows you to set a terminal\ntheme for that terminal.\n\nYou 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\nin the [default termthemes.json file](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/termthemes.json).\n\nIf 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).\n\nYou can set the key `tab:preset` in your [Wave Config File](/config) to apply a theme to all new tabs.\n\n#### Font Size\n\nFrom 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}`.\n\n#### Font Family\n\nThere 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.\n\n## Widgets Sidebar\n\n![Terminal Context Menu](./img/custom-widgets.png#right)\n\nSee [Custom Widgets](/customwidgets) for detailed documentation around changing what appears in your right widget sidebar.\n\nUsing 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.\n\nYou can also suppress the help widgets in the bottom right by setting the config key `widget:showhelp` to `false`.\n\n## Tab Backgrounds\n\nWave 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:\n\n```bash\n# Set an image background with 50% opacity (default)\nwsh setbg ~/pictures/background.jpg\n\n# Set a color background (use quotes to prevent # being interpreted as a shell comment)\nwsh setbg \"#ff0000\"          # hex color\nwsh setbg forestgreen        # CSS color name\n\n# Adjust opacity\nwsh setbg --opacity 0.3 ~/pictures/light-pattern.png\nwsh setbg --opacity 0.7      # change only opacity of current background\n\n# Image positioning options\nwsh setbg --tile ~/pictures/texture.png          # create tiled pattern\nwsh setbg --center ~/pictures/logo.png           # center without scaling\nwsh setbg --center --size 200px ~/pictures/logo.png  # center with specific size (px, %, auto)\n\n# Remove background\nwsh setbg --clear\n```\n\nYou 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.\n\nTo preview the metadata for any background without applying it, use the `--print` flag:\n\n```bash\nwsh setbg --print \"#ff0000\"\n```\n\nFor more advanced customization options including gradients, colors, and saving your own background presets, check out our [Background Configuration](/presets#background-configurations) documentation.\n\n\n"
  },
  {
    "path": "docs/docs/customwidgets.mdx",
    "content": "---\nsidebar_position: 6\nid: \"customwidgets\"\ntitle: \"Custom Widgets\"\n---\n\n# Custom Widgets\n\nWave 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:\n![The default widget bar](./img/all-widgets-default.webp)\n\nBy adding additional widgets, it is possible to get a widget bar that looks like this:\n\n![A widget bar with custom widgets added](./img/all-widgets-extra.webp)\n\n## The Structure of a Widget\n\nAll widgets share a similar structure that roughly looks like the example below:\n\n```json\n\"<widget name>\": {\n    \"icon\": \"<font awesome icon name>\",\n    \"label\": \"<the text label of the widget>\",\n    \"color\": \"<the color of the label>\",\n    \"blockdef\": {\n        \"meta\": {\n            \"view\": \"term\",\n            \"controller\": \"cmd\",\n            \"cmd\": \"<the actual cli command>\"\n        }\n    }\n}\n```\n\nThis 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:\n\n![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)\n\nThis `WidgetConfigType` is shared between all types of widgets. That is to say, all widgets&mdash;regardless of type&mdash; will use the same keys for this. The accepted keys are:\n\n| Key             | Description                                                                                                                                                                                                                                                                                |\n| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| \"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.                                                                                                                     |\n| \"icon\"          | (optional) The name of a [font awesome icon](#font-awesome-icons). Defaults to `\"browser\"`.                                                                                                                                                                                                |\n| \"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\"`. |\n| \"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.                                                                                                  |\n| \"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.                                                                                                                                                            |\n| \"magnified\"     | (optional) A boolean indicating whether or not the widget should launch magnfied. It is false by default.                                                                                                                                                                                  |\n| \"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.                                                                                                                                      |\n\n<a name=\"font-awesome-icons\" />\n:::info\n\n**Font Awesome Icons**\n\n[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:\n\n| format                         | description                                                                                                                                                                                           |\n| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| &lt;icon&nbsp;name&gt;         | The plain icon with no additional styles applied.                                                                                                                                                     |\n| solid@&lt;icon&nbsp;name&gt;   | Adds the `fa-solid` class to the icon to fill in the content with a fill color rather than leaving it a background.                                                                                   |\n| regular@&lt;icon&nbsp;name&gt; | 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.                                                                     |\n| brands@&lt;icon&nbsp;name&gt;  | 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. |\n\n:::\n\nThe 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.\n\n## Terminal and CLI Widgets\n\nA 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:\n\n```json\n{\n    <... other widgets go here ...>,\n    \"<widget name>\": {\n        \"icon\": \"<font awesome icon name>\",\n        \"label\": \"<the text label of the widget>\",\n        \"color\": \"<the color of the label>\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"term\",\n                \"controller\": \"cmd\",\n                \"cmd\": \"<the actual cli command>\"\n            }\n        }\n    },\n    <... other widgets go here ...>\n}\n```\n\nThe `WidgetConfigType` takes the usual options common to all widgets. The `MetaTSType` can include the keys listed below:\n\n| Key                    | Description                                                                                                                                                                                                                                                                        |\n| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| \"view\"                 | A string that specifies the general type of widget. In the case of custom terminal widgets, this must be set to `\"term\"`.                                                                                                                                                          |\n| \"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.       |\n| \"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. |\n| \"cmd:args\"             | (optional, array of strings) arguments to pass to the `cmd`                                                                                                                                                                                                                        |\n| \"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.)                                           |\n| \"cmd:interactive\"      | (optional) When the `\"controller\"` is set to `\"term\", this boolean adds the interactive flag to the launched terminal. Defaults to false.                                                                                                                                          |\n| \"cmd:login\"            | (optional) When the `\"controller\"` is set to `\"term\"`, this boolean adds the login flag to the term command. Defaults to false.                                                                                                                                                    |\n| \"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.                                                                                                                                |\n| \"cmd:runonce\"          | (optional) Runs on start, but then sets \"cmd:runonce\" and \"cmd:runonstart\" to false (so future runs require manual restarts)                                                                                                                                                       |\n| \"cmd:clearonstart\"     | (optional) When the cmd runs, the contents of the block are cleared out. Defaults to false.                                                                                                                                                                                        |\n| \"cmd:closeonexit\"      | (optional) Automatically closes the block if the command successfully exits (exit code = 0)                                                                                                                                                                                        |\n| \"cmd:closeonexitforce\" | (optional) Automatically closes the block if when the command exits (success or failure)                                                                                                                                                                                           |\n| \"cmd:closeonexitdelay  | (optional) Change the delay between when the command exits and when the block gets closed, in milliseconds, default 2000                                                                                                                                                           |\n| \"cmd:env\"              | (optional) A key-value object represting environment variables to be run with the command. Defaults to an empty object.                                                                                                                                                            |\n| \"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.                                                                                                                           |\n| \"cmd:nowsh\"            | (optional) A boolean that will turn off wsh integration for the command. Defaults to false.                                                                                                                                                                                        |\n| \"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.                                                                                                                        |\n| \"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.                                                                                                                                |\n| \"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.                                  |\n| \"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)                                                                                                                                   |\n| cmd:initscript.sh\"     | (optional) same as `cmd:initscript` but applies to bash/zsh shells only                                                                                                                                                                                                            |\n| cmd:initscript.bash\"   | (optional) same as `cmd:initscript` but applies to bash shells only                                                                                                                                                                                                                |\n| cmd:initscript.zsh\"    | (optional) same as `cmd:initscript` but applies to zsh shells only                                                                                                                                                                                                                 |\n| cmd:initscript.pwsh\"   | (optional) same as `cmd:initscript` but applies to pwsh/powershell shells only                                                                                                                                                                                                     |\n| cmd:initscript.fish\"   | (optional) same as `cmd:initscript` but applies to fish shells only                                                                                                                                                                                                                |\n\n### Example Local Shell Widgets\n\nIf 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.\n\nSuppose you want a widget to launch a `fish` shell. Once you have `fish` installed on your system, you can define a widget as\n\n```json\n{\n    <... other widgets go here ...>,\n    \"fish\" : {\n        \"icon\": \"fish\",\n        \"color\": \"#4abc39\",\n        \"label\": \"fish\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"term\",\n                \"controller\": \"shell\",\n                \"term:localshellpath\": \"/usr/local/bin/fish\",\n                \"term:localshellopts\": \"-i -l\"\n            }\n        }\n    },\n    <... other widgets go here ...>\n}\n```\n\nThis adds an icon to the widget bar that you can press to launch a terminal running the `fish` shell.\n![The example fish widget](./img/widget-example-fish.webp)\n\n:::info\nIt 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.\n:::\n\nIf you want to do the same for something like Powershell Core, or `pwsh`, you can define the widget as\n\n```json\n{\n    <... other widgets go here ...>,\n    \"pwsh\" : {\n        \"icon\": \"rectangle-terminal\",\n        \"color\": \"#2671be\",\n        \"label\": \"pwsh\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"term\",\n                \"controller\": \"shell\",\n                \"term:localshellpath\": \"pwsh\"\n            }\n        }\n    },\n    <... other widgets go here ...>\n}\n```\n\nThis adds an icon to the widget bar that you can press to launch a terminal running the `pwsh` shell.\n![The example pwsh widget](./img/widget-example-pwsh.webp)\n\n:::info\nIt 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&nbsp;Files\\\\PowerShell\\\\7\\\\pwsh.exe\"</code> on\nWindows. 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.\n:::\n\n### Example Remote Shell Widgets\n\nIf 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`.\n\n```json\n{\n\t<... other widgets go here ...>,\n\t\"remote-term\": {\n\t\t\"icon\": \"rectangle-terminal\",\n\t\t\"label\": \"remote\",\n\t\t\"blockdef\": {\n\t\t\t\"meta\": {\n\t\t\t\t\"view\": \"term\",\n\t\t\t\t\"controller\": \"shell\",\n\t\t\t\t\"connection\": \"<connection>\"\n\t\t\t}\n\t\t}\n\t},\n\t<... other widgets go here ...>\n}\n```\n\n### Example Cmd Widgets\n\nHere are a few simple cmd widgets to serve as examples.\n\nSuppose I want a widget that will run speedtest-go when opened. Then, I can define a widget as\n\n```json\n{\n    <... other widgets go here ...>,\n    \"speedtest\" : {\n        \"icon\": \"gauge-high\",\n        \"label\": \"speed\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"term\",\n                \"controller\": \"cmd\",\n                \"cmd\": \"speedtest-go --unix\",\n                \"cmd:clearonstart\": true\n            }\n        }\n    },\n    <... other widgets go here ...>\n}\n```\n\nThis adds an icon to the widget bar that you can press to launch a terminal running the `speedtest-go --unix` command.\n![The example speedtest widget](./img/widget-example-speed.webp)\n\nUsing `\"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.\n\nNow 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:\n\n```json\n{\n    <... other widgets go here ...>,\n    \"dua\" : {\n        \"icon\": \"brands@linux\",\n        \"label\": \"dua\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"term\",\n                \"controller\": \"cmd\",\n                \"cmd\": \"dua\"\n            }\n        }\n    },\n    <... other widgets go here ...>\n}\n```\n\nThis adds an icon to the widget bar that you can press to launch a terminal running the `dua` command.\n![The example speedtest widget](./img/widget-example-dua.webp)\n\nBecause 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.\n\n## Web Widgets\n\nSometimes, 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:\n\n```json\n{\n    <... other widgets go here ...>,\n    \"<widget name>\": {\n        \"icon\": \"<font awesome icon name>\",\n        \"label\": \"<the text label of the widget>\",\n        \"color\": \"<the color of the label>\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"web\",\n                \"url\": \"<url of the first webpage>\"\n            }\n        }\n    },\n    <... other widgets go here ...>\n}\n```\n\nThe `WidgetConfigType` takes the usual options common to all widgets. The `MetaTSType` can include the keys listed below:\n| Key | Description |\n|-----|-------------|\n| \"view\" | A string that specifies the general type of widget. In the case of custom web widgets, this must be set to `\"web\"`.|\n| \"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. |\n| \"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. |\n\n### Example Web Widgets\n\nSay you want a widget that automatically starts at YouTube and will use YouTube as the home page. This can be done using:\n\n```json\n{\n    <... other widgets go here ...>,\n    \"youtube\" : {\n        \"icon\": \"brands@youtube\",\n        \"label\": \"youtube\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"web\",\n                \"url\": \"https://youtube.com\",\n                \"pinnedurl\": \"https://youtube.com\"\n            }\n        }\n    },\n    <... other widgets go here ...>\n}\n```\n\nThis adds an icon to the widget bar that you can press to launch a web widget on the youtube homepage.\n![The example speedtest widget](./img/widget-example-youtube.webp)\n\nAlternatively, 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:\n\n```json\n{\n    <... other widgets go here ...>,\n    \"github\" : {\n        \"icon\": \"brands@github\",\n        \"label\": \"github\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"web\",\n                \"url\": \"https://github.com\",\n                \"pinnedurl\": \"https://google.com\"\n            }\n        }\n    },\n    <... other widgets go here ...>\n}\n```\n\nThis adds an icon to the widget bar that you can press to launch a web widget on the github homepage.\n![The example speedtest widget](./img/widget-example-github.webp)\n\n## Sysinfo Widgets\n\nThe 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&mdash;for instance, if you want to load a different plot by default. The general form of this widget is:\n\n```json\n{\n    <... other widgets go here ...>,\n    \"<widget name>\": {\n        \"icon\": \"<font awesome icon name>\",\n        \"label\": \"<the text label of the widget>\",\n        \"color\": \"<the color of the label>\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"sysinfo\",\n                \"graph:numpoints\": <the max number of points in the graph>,\n                \"sysinfo:type\": <the name of the plot collection>,\n            }\n        }\n    },\n    <... other widgets go here ...>\n}\n```\n\nThe `WidgetConfigType` takes the usual options common to all widgets. The `MetaTSType` can include the keys listed below:\n| Key | Description |\n|-----|-------------|\n| \"view\" | A string that specifies the general type of widget. In the case of custom sysinfo widgets, this must be set to `\"sysinfo\"`.|\n| \"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.|\n| \"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\"`.|\n\n### Example Sysinfo Widgets\n\nSuppose 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:\n\n```json\n{\n    <... other widgets go here ...>,\n    \"3min-info\" : {\n        \"icon\": \"circle-3\",\n        \"label\": \"3mininfo\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"sysinfo\",\n                \"graph:numpoints\": 180,\n                \"sysinfo:type\": \"CPU + Mem\"\n            }\n        }\n    },\n    <... other widgets go here ...>\n}\n```\n\nThis 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.\n![The example speedtest widget](./img/widget-example-3mininfo.webp)\n\nNow, 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:\n\n```json\n{\n    <... other widgets go here ...>,\n    \"all-cpu\" : {\n        \"icon\": \"chart-scatter\",\n        \"label\": \"all-cpu\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"sysinfo\",\n                \"sysinfo:type\": \"All CPU\"\n            }\n        }\n    },\n    <... other widgets go here ...>\n}\n```\n\nThis adds an icon to the widget bar that you can press to launch All CPU plots by default.\n\n![The example speedtest widget](./img/widget-example-all-cpu.webp)\n\n## Overriding Default Widgets\n\nWave 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:\n\n- `defwidget@terminal`\n- `defwidget@files`\n- `defwidget@web`\n- `defwidget@ai`\n- `defwidget@sysinfo`\n\nTo remove any of them, just set that key to `null` in your widgets.json file.\n\nTo 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)\n"
  },
  {
    "path": "docs/docs/durable-sessions.mdx",
    "content": "---\nsidebar_position: 3.5\nid: \"durable-sessions\"\ntitle: \"Durable Sessions\"\n---\n\nimport { VersionBadge } from \"@site/src/components/versionbadge\";\n\n# Durable Sessions <VersionBadge version=\"v0.14\" />\n\nKeep your remote SSH shell sessions alive through network changes, computer sleep, and Wave restarts.\n\n## Overview\n\nDurable 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:\n\n- **Shell state** - Current directory, environment variables, and shell history\n- **Running programs** - Background jobs and long-running commands continue executing\n- **Terminal history** - Full scrollback buffer preserved across reconnections\n\nDurable sessions automatically reconnect when your connection is restored, picking up right where you left off.\n\n:::info Remote Connections Only\nDurable 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.\n:::\n\n## How It Works\n\nWhen you start a durable session, Wave launches a lightweight job manager on the remote server. Similar to how tmux and screen work, this manager:\n\n1. Keeps your shell process running independently of the Wave connection\n2. Buffers terminal output while disconnected\n3. Enables Wave to seamlessly reattach when you reconnect\n4. Survives Wave restarts and network interruptions\n\nThe session continues running on the remote server even if you close Wave, put your computer to sleep, or switch networks.\n\n## Session Status Indicator\n\nThe shield icon in your terminal header shows the current session status:\n\n| Icon | Status | Description |\n|------|--------|-------------|\n| <i className=\"fa-sharp fa-regular fa-shield\" style={{color: 'rgb(140, 145, 140)'}}></i> | Standard Session | Connection drops will end the session |\n| <i className=\"fa-sharp fa-solid fa-shield\" style={{color: '#0ea5e9'}}></i> | Durable (Attached) | Session is protected and connected |\n| <i className=\"fa-sharp fa-solid fa-shield\" style={{color: '#7dd3fc'}}></i> | Durable (Detached) | Session running, currently disconnected |\n| <i className=\"fa-sharp fa-solid fa-shield\" style={{color: 'rgb(140, 145, 140)'}}></i> | Durable (Awaiting) | Configured but not yet started |\n\nHover over the shield icon to see detailed status information and available actions.\n\n## Configuration\n\nDurable sessions can be configured at three levels, with more specific settings overriding general ones:\n\n### Global Settings (Lowest Priority)\n\nSet the default for all SSH connections in your `settings.json`:\n\n```json\n{\n  \"term:durable\": true\n}\n```\n\n### Connection Settings (Medium Priority)\n\nConfigure durability per connection in your `connections.json`:\n\n```json\n{\n  \"connections\": {\n    \"user@host\": {\n      \"term:durable\": true\n    }\n  }\n}\n```\n\n### Block Settings (Highest Priority)\n\nOverride for individual terminal blocks through:\n\n- **Context Menu**: Right-click terminal → Advanced → Session Durability\n- **Flyover Actions**: Click shield icon → \"Restart as Durable\" or \"Restart as Standard\"\n- **Command Line**: Use `wsh setmeta term:durable=true` or `wsh setmeta term:durable=false`\n\nConfiguration hierarchy (highest to lowest priority):\n1. Block-level setting\n2. Connection-level setting  \n3. Global setting\n\n### Default Behavior\n\n- **SSH connections**: Durable sessions disabled by default (opt-in via configuration)\n- **Local terminals**: Always use standard sessions (durability not applicable)\n- **WSL connections**: Always use standard sessions (durability not applicable)\n\n## Switching Between Modes\n\n### Standard to Durable\n\n1. Hover over the regular shield icon\n2. Click **\"Restart as Durable\"** in the flyover\n3. Your session will restart with durability enabled\n\nOr use the context menu:\n- Right-click terminal → Advanced → Session Durability → Restart Session in Durable Mode\n\n### Durable to Standard\n\n1. Access the terminal context menu (right-click)\n2. Navigate to Advanced → Session Durability\n3. Select **\"Restart Session in Standard Mode\"**\n\n:::warning Switching Modes Restarts the Session\nConverting between standard and durable modes requires restarting the shell. Any running processes in the current session will be terminated.\n:::\n\n## Session States\n\n### Attached\nYour terminal is connected to the remote session. You can interact with the shell and see real-time output.\n\n### Detached\nConnection lost, but the session continues running on the remote server. Wave will automatically reconnect when possible. Any commands you ran continue executing.\n\n### Awaiting Start\nSession configured for durability but not yet started. Click \"Start Session\" or run a command to begin.\n\n### Starting\nJob manager is initializing on the remote server. The session will become attached shortly.\n\n### Ended\nSession has terminated. Common reasons:\n- **Exited**: Shell was closed normally (e.g., typed `exit`)\n- **Lost**: Session not found on server (may have been terminated or system rebooted)\n- **Failed to Start**: Job manager encountered an error during initialization\n\nClick \"Restart Session\" to start a new durable session, or \"Restart as Standard\" to switch modes.\n\n## Use Cases\n\n### Long-Running Commands\nStart a build, deployment, or data processing job and close your laptop. The command continues executing, and you can check on it later.\n\n```bash\n# Start a long build\n./build.sh\n\n# Close your laptop, get coffee\n# Later: reconnect and see the completed output\n```\n\n### Unstable Networks\nWork from a café, train, or cellular connection. Brief disconnections won't terminate your session or lose your work.\n\n### Multiple Locations\nStart work on your desktop, continue on your laptop. Your session and its state are preserved on the remote server.\n\n### System Maintenance\nWave updates, restarts, or crashes won't interrupt your remote work. Reconnect and resume immediately.\n\n## Session Lifecycle\n\nDurable sessions are tied to the terminal block in Wave. The session will be terminated when you:\n\n- **Close the block**: Closes the terminal and terminates the remote session\n- **Switch connections**: Changing the connection on a block terminates the old session\n- **Delete the workspace/tab**: Removes the block and terminates associated sessions\n\n### Cleanup Behavior\n\nIf 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.\n\nThis ensures that remote sessions don't accumulate on your servers when you close terminals while offline.\n\n## Limitations\n\n- **Local terminals**: Not applicable (already persistent with your local machine)\n- **WSL connections**: Not applicable (WSL sessions managed by Windows)\n- **Network latency**: Detached sessions buffer output; reconnecting may take a moment to sync\n- **Server resources**: Each durable session maintains a lightweight Go process on the remote server for session management\n\n## Troubleshooting\n\n### Session Shows as \"Lost\"\nThe session was terminated on the remote server, possibly due to:\n- Server reboot\n- Manual termination of the job manager process\n- Remote system running out of resources\n\n**Solution**: Click \"Restart Session\" to start a new durable session.\n\n### Session Won't Reconnect\nVerify that:\n- Your SSH connection to the server is working (check the connection status)\n- The job manager process is still running on the remote server\n\n**Try**: Right-click terminal → Advanced → Force Restart Controller\n\n### \"Failed to Start\" Error\nThe job manager couldn't initialize on the remote server. Check the error message for specific details.\n\n**Try**: Restart the session. If the issue persists, file a bug report with the error details.\n\n:::info Technical Details\nDurable 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.\n:::\n\n## Privacy & Security\n\n- Durable sessions run entirely on your remote servers\n- All data is transmitted over SSH between your local Wave instance and the remote machine\n- No open ports on the remote machine - communication happens through your existing SSH connection\n- When disconnected, output is buffered locally on the remote machine until you reconnect\n- Sessions are isolated per user and use your remote user's permissions\n"
  },
  {
    "path": "docs/docs/faq.mdx",
    "content": "---\nsidebar_position: 101\nid: \"faq\"\ntitle: \"FAQ\"\n---\n\nimport { VersionBadge } from \"@site/src/components/versionbadge\";\n\n# FAQ\n\n### How can I see the block numbers?\n\nThe block numbers will appear when you hold down Ctrl-Shift (and disappear once you release the key combo).\n\n### How do I make a remote connection?\n\nThere is a button in the header. Click the <i className=\"fa-sharp fa-laptop\"/> or <i className=\"fa-sharp fa-arrow-right-arrow-left\"/>\nand type the `[user]@[host]` that you wish to connect to.\n\n### On Windows, how can I use Git Bash as my default shell?\n\nWave 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.\n\nAlternatively, you can manually set Git Bash as your default shell by setting the configuration variable `term:localshellpath` to\nthe location of the Git Bash \"bash.exe\" binary. By default it is located at \"C:\\Program Files\\Git\\bin\\bash.exe\".\nJust remember in JSON, backslashes need to be escaped. So add this to your [settings.json](./config) file:\n\n```json\n\"term:localshellpath\": \"C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe\"\n```\n\n### Can I use WSH outside of Wave?\n\n`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.\n\n\n## Why does Wave warn me about ARM64 translation when it launches?\n\nmacOS 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.\n\nTo 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).\n\nYou can disable this warning by setting `app:dismissarchitecturewarning=true` in [your configurations](./config).\n\n## How do I join the beta builds of Wave?\n\nWave 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):\n\n```json\n\"autoupdate:enabled\": true,\n\"autoupdate:channel\": \"beta\"\n```\n\nIf you've installed via Snap, you can use the following command:\n\n```sh\nsudo snap install waveterm --classic --beta\n```\n\n## Can I use Wave AI without enabling telemetry?\n\n<VersionBadge version=\"v0.13.1\" noLeftMargin={true}/>\n\nYes! 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).\n\nTo enable Wave AI without telemetry:\n1. Configure a custom AI mode (see [Wave AI documentation](./waveai-modes))\n2. Set `waveai:defaultmode` to your custom mode's key in your Wave settings\n\nOnce 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.\n"
  },
  {
    "path": "docs/docs/gettingstarted.mdx",
    "content": "---\nsidebar_position: 1\nid: \"gettingstarted\"\ntitle: \"Getting Started\"\n---\n\nimport { PlatformProvider, PlatformSelectorButton, PlatformItem } from \"@site/src/components/platformcontext\";\nimport { Kbd } from \"@site/src/components/kbd\";\n\nWave 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.\n\n## Installation\n\n<PlatformProvider>\n  <PlatformSelectorButton />\n\n### Platform requirements\n\n<PlatformItem platforms={[\"mac\"]}>\n\n- Supported architectures: Apple Silicon, x64\n- Supported OS version: macOS 11 Big Sur or later\n\n</PlatformItem>\n\n<PlatformItem platforms={[\"windows\"]}>\n\n- Supported architectures: x64\n- Supported OS version: Windows 10 1809 or later, Windows 11\n\n:::note\n\nARM64 is planned, but is currently blocked by upstream dependencies (see [Windows ARM Support](https://github.com/wavetermdev/waveterm/issues/928)).\n\n:::\n\n</PlatformItem>\n\n<PlatformItem platforms={[\"linux\"]}>\n\n- Supported architectures: x64, ARM64\n- Supported OS version: must have glibc-2.28 or later (Debian >=10, RHEL >=8, Ubuntu >=20.04, etc.)\n\n</PlatformItem>\n\n### Package managers\n\n<PlatformItem platforms={[\"mac\"]}>\n\n#### Homebrew\n\n```bash\nbrew install --cask wave\n```\n\n</PlatformItem>\n\n<PlatformItem platforms={[\"windows\"]}>\n\n#### Windows Package Manager\n\n```powershell\nwinget install CommandLine.Wave\n```\n\n#### Chocolatey\n\n```powershell\nchoco install wave\n```\n\n</PlatformItem>\n\n<PlatformItem platforms={[\"linux\"]}>\n\n#### Snap\n\n```bash\nsudo snap install --classic waveterm\n```\n\nOther 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)\n\n</PlatformItem>\n\nYou can also download installers directly from our [Downloads page](https://www.waveterm.dev/download).\n\n## Core Concepts\n\n### Tabs and Blocks\n\n- **Tabs**: Like browser tabs, these help organize your work. Create new tabs with <Kbd k=\"Cmd:t\"/>.\n- **Blocks**: The building blocks of Wave. Each block can be a terminal, web browser, file preview, or other widget.\n- **Layout**: Blocks can be dragged, dropped, and resized to create your ideal layout.\n\n### Key Features\n\n1. **Terminal Features**\n\n   - Works with common shells (bash, zsh, fish)\n   - Supports standard terminal features (readline, control sequences, etc)\n   - Includes the `wsh` command for interacting with Wave's GUI features\n   - GPU accelerated (on most platforms)\n\n2. **Graphical Widgets**\n\n   - Preview files (images, video, markdown, code with syntax highlighting)\n   - Browse web pages\n   - Ask questions and get AI help directly from the terminal (set up multiple AI models)\n   - Basic system monitoring graphs\n\n3. **Remote Connections**\n   - Easy SSH connections with the connection button <i className=\"fa-sharp fa-laptop\"/>\n   - WSL integration on Windows\n   - Consistent experience across local and remote sessions\n\n## Quick Start Guide\n\n1. **Open Your First New Tab**\n\n   - New Wave tabs start with a single terminal block\n   - Use it just like your regular terminal\n   - Create additional terminal blocks with <Kbd k=\"Cmd:n\"/>\n\n2. **Try Some Basic Commands**\n\n   ```bash\n   # View a file or directory\n   wsh view ~/Documents\n\n   # Open a webpage\n   wsh web open github.com\n\n   # Get AI assistance\n   wsh ai -m \"how do I find large files in my current directory?\" -s\n   ```\n\n3. **Customize Your Layout**\n\n   - Drag block headers to rearrange them\n   - Hover between blocks to resize them\n   - Right-click tab headers for background options\n   - Right-click block headers for block-specific options\n\n4. **Connect to Remote Machines**\n   - Click the <i className=\"fa-sharp fa-laptop\"/> button\n   - Enter `username@hostname` for SSH connections\n   - Or select a WSL distribution on Windows\n\n## Next Steps\n\n- Explore [Key Bindings](./keybindings) to work more efficiently\n- Learn about [Tab Layouts](./layout) to organize your workspace\n- Set up [Custom Widgets](./customwidgets) for quick access to your tools\n- Configure [Wave AI](./waveai) to use your preferred AI models\n- Check out [Configuration](./config) for detailed customization options\n\n## Getting Help\n\n- Join our [Discord community](https://discord.gg/XfvZ334gwU) for help and discussions\n- Report issues on [GitHub](https://github.com/wavetermdev/waveterm/issues)\n- Check our [FAQ](./faq) for common questions\n\n</PlatformProvider>\n"
  },
  {
    "path": "docs/docs/index.mdx",
    "content": "---\nsidebar_position: -1\nid: \"index\"\ntitle: \"Home\"\nhide_title: true\nhide_table_of_contents: true\n---\n\nimport { Card, CardGroup } from \"@site/src/components/card\";\n\n# Welcome to Wave Terminal\n\nWave 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.\n\nModern 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.\n\nCheck out [Getting Started](./gettingstarted) for installation instructions.\n\n![Wave Screenshot](./img/wave-screenshot.webp)\n\n<CardGroup>\n  <Card\n    href=\"./waveai\"\n    icon=\"fa-sparkles\"\n    title=\"Wave AI\"\n    description=\"Context-aware terminal assistant with access to terminal output, widgets, and filesystem.\"\n  />\n  <Card\n    href=\"./customization\"\n    icon=\"fa-paintbrush\"\n    title=\"Customization\"\n    description=\"Set up tabs and terminals to match your workflow needs.\"\n  />\n  <Card\n    href=\"./keybindings\"\n    icon=\"fa-keyboard\"\n    title=\"Key Bindings\"\n    description=\"Boost efficiency with keyboard shortcuts for faster navigation.\"\n  />\n  <Card\n    href=\"./layout\"\n    icon=\"fa-grid-2\"\n    title=\"Layout\"\n    description=\"Organize your workspace using our layout system.\"\n  />\n  <Card\n    href=\"./connections\"\n    icon=\"fa-network-wired\"\n    title=\"Remote Connections\"\n    description=\"Quickly SSH or connect to remote machines in one step.\"\n  />\n  <Card\n    href=\"./widgets\"\n    icon=\"fa-rocket\"\n    title=\"Widgets\"\n    description=\"Explore built-in tools to extend your terminal’s functionality.\"\n  />\n  <Card\n    href=\"./wsh\"\n    icon=\"fa-rectangle-terminal\"\n    title=\"wsh Command\"\n    description=\"Control Wave and launch widgets directly from the command line.\"\n  />\n</CardGroup>\n\n<div style={{ marginBottom: 30 }} />\n\n:::info\n\nIf 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.\n\n:::\n\n<div className=\"reference-links\">\n\nOther References:\n\n- [Configuration](./config)\n- [Custom Widgets](./customwidgets)\n- [Full wsh reference](./wsh-reference)\n- [Telemetry](./telemetry)\n- [FAQ](./faq)\n- [Release Notes](./releasenotes)\n\n</div>\n\n## Roadmap\n\nWant 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)!\n\n## Links\n\n- **Homepage** https://waveterm.dev\n- **Download** https://waveterm.dev/download\n- **Discord** https://discord.gg/XfvZ334gwU\n- **GitHub** https://github.com/wavetermdev/waveterm/\n\n## Looking for WaveLegacy documentation?\n\nWaveLegacy docs can be found at [legacydocs.waveterm.dev](https://legacydocs.waveterm.dev).\n"
  },
  {
    "path": "docs/docs/keybindings.mdx",
    "content": "---\nsidebar_position: 2\nid: \"keybindings\"\ntitle: \"Key Bindings\"\n---\n\nimport { Kbd, KbdChord } from \"@site/src/components/kbd\";\nimport { PlatformProvider, PlatformSelectorButton } from \"@site/src/components/platformcontext\";\n\n<PlatformProvider>\n\nHere's the set of default keybindings available in Wave. It is split into sections.\nSome keybindings are always active. Others are only active for certain types of blocks.\n\nNote that these are the MacOS keybindings (they use \"Cmd\"). For Windows and Linux,\nreplace \"Cmd\" with \"Alt\" (note that \"Ctrl\" is \"Ctrl\" on both Mac, Windows, and Linux).\n\nChords 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.\n\n## Global Keybindings\n\n<PlatformSelectorButton />\n<div style={{ marginBottom: 20 }}></div>\n\n| Key                                               | Function                                                                                                                                               |\n| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| <Kbd k=\"Cmd:t\"/>                                  | Open a new tab                                                                                                                                         |\n| <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 |\n| <Kbd k=\"Cmd:Shift:a\"/>                            | Toggle WaveAI panel visibility                                                                                                                         |\n| <Kbd k=\"Cmd:d\"/>                                  | Split horizontally, open a new block to the right                                                                                                      |\n| <Kbd k=\"Cmd:Shift:d\"/>                            | Split vertically, open a new block below                                                                                                               |\n| <KbdChord karr={[\"Ctrl:Shift:s\", \"ArrowUp\"]}/>    | Split vertically, open a new block above                                                                                                               |\n| <KbdChord karr={[\"Ctrl:Shift:s\", \"ArrowDown\"]}/>  | Split vertically, open a new block below                                                                                                               |\n| <KbdChord karr={[\"Ctrl:Shift:s\", \"ArrowLeft\"]}/>  | Split horizontally, open a new block to the left                                                                                                       |\n| <KbdChord karr={[\"Ctrl:Shift:s\", \"ArrowRight\"]}/> | Split horizontally, open a new block to the right                                                                                                      |\n| <Kbd k=\"Cmd:Shift:n\"/>                            | Open a new window                                                                                                                                      |\n| <Kbd k=\"Cmd:w\"/>                                  | Close the current block                                                                                                                                |\n| <Kbd k=\"Cmd:Shift:w\"/>                            | Close the current tab                                                                                                                                  |\n| <Kbd k=\"Cmd:m\"/>                                  | Magnify / Un-Magnify the current block                                                                                                                 |\n| <Kbd k=\"Cmd:g\"/>                                  | Open the \"connection\" switcher                                                                                                                         |\n| <Kbd k=\"Cmd:i\"/>                                  | Refocus the current block (useful if the block has lost input focus)                                                                                   |\n| <Kbd k=\"Ctrl:Shift\"/>                             | Show block numbers                                                                                                                                     |\n| <Kbd k=\"Ctrl:Shift:0\"/>                           | Focus WaveAI input                                                                                                                                     |\n| <Kbd k=\"Ctrl:Shift:1-9\"/>                         | Switch to block number                                                                                                                                 |\n| <Kbd k=\"Ctrl:Shift:Arrows\"/> / <Kbd k=\"Ctrl:Shift:h/j/k/l\"/> | Move left, right, up, down between blocks                                                                                                              |\n| <Kbd k=\"Ctrl:Shift:x\"/>                           | Replace the current block with a launcher block                                                                                                        |\n| <Kbd k=\"Cmd:1-9\"/>                                | Switch to tab number                                                                                                                                   |\n| <Kbd k=\"Cmd:[\"/> / <Kbd k=\"Shift:Cmd:[\"/>         | Switch tab left                                                                                                                                        |\n| <Kbd k=\"Cmd:]\"/> / <Kbd k=\"Shift:Cmd:]\"/>         | Switch tab right                                                                                                                                       |\n| <Kbd k=\"Cmd:Ctrl:1-9\"/>                           | Switch to workspace number                                                                                                                             |\n| <Kbd k=\"Cmd:Shift:r\"/>                            | Refresh the UI                                                                                                                                         |\n| <Kbd k=\"Ctrl:Shift:i\"/>                           | Toggle terminal multi-input mode                                                                                                                       |\n\n## File Preview Keybindings\n\n| Key                                       | Function                                                                                           |\n| ----------------------------------------- | -------------------------------------------------------------------------------------------------- |\n| <Kbd k=\"[text]\"/>                         | Any regular character (e.g. \"a\", \"b\") will filter the file list                                    |\n| <Kbd k=\"Escape\"/>                         | Clears the filter                                                                                  |\n| <Kbd k=\"ArrowUp\"/> / <Kbd k=\"ArrowDown\"/> | Change file selection up/down                                                                      |\n| <Kbd k=\"Enter\"/>                          | Open the currently selected file/directory                                                         |\n| <Kbd k=\"Cmd:ArrowUp\"/>                    | Move \"up\" a directory (parent directory)                                                           |\n| <Kbd k=\"Cmd:ArrowLeft\"/>                  | Back, move to the previously selected file/directory                                               |\n| <Kbd k=\"Cmd:ArrowRight\"/>                 | Forward (opposite of back)                                                                         |\n| <Kbd k=\"Cmd:o\"/>                          | Open a new file (accepts relative paths to the current directory)                                  |\n| <Kbd k=\"Cmd:s\"/>                          | When file editor is open, save file                                                                |\n| <Kbd k=\"Cmd:e\"/>                          | For files that can be previewed or edited (markdown, CSVs), switches between preview and edit mode |\n| <Kbd k=\"Cmd:r\"/>                          | When file editor is open, revert changes                                                           |\n\n## Web Keybindings\n\n| Key                       | Function                                                      |\n| ------------------------- | ------------------------------------------------------------- |\n| <Kbd k=\"Cmd:l\"/>          | Focus the URL input bar                                       |\n| <Kbd k=\"Escape\"/>         | When the URL input bar is focused, will focus the web content |\n| <Kbd k=\"Cmd:r\"/>          | Reload webpage                                                |\n| <Kbd k=\"Cmd:ArrowLeft\"/>  | Back                                                          |\n| <Kbd k=\"Cmd:ArrowRight\"/> | Forward                                                       |\n| <Kbd k=\"Cmd:f\"/>          | Find in webpage                                               |\n| <Kbd k=\"Cmd:o\"/>          | Open a bookmark                                               |\n\n## WaveAI Keybindings\n\n| Key                     | Function                |\n| ----------------------- | ----------------------- |\n| <Kbd k=\"Cmd:Shift:a\"/>  | Toggle WaveAI panel     |\n| <Kbd k=\"Ctrl:Shift:0\" windows=\"Alt:0\"/> | Focus WaveAI input      |\n| <Kbd k=\"Cmd:k\"/>        | Clear AI Chat           |\n\n## Terminal Keybindings\n\n| Key                     | Function                     |\n| ----------------------- | ---------------------------- |\n| <Kbd k=\"Ctrl:Shift:c\"/> | Copy                         |\n| <Kbd k=\"Ctrl:Shift:v\"/> | Paste                        |\n| <Kbd k=\"Ctrl:v\" mac=\"N/A\" linux=\"N/A\"/> | Paste (Windows Only) |\n| <Kbd k=\"Cmd:k\"/>        | Clear Terminal               |\n| <Kbd k=\"Cmd:f\"/>        | Find in Terminal             |\n| <Kbd k=\"Shift:Home\"/>   | Scroll to top                |\n| <Kbd k=\"Shift:End\"/>    | Scroll to bottom             |\n| <Kbd k=\"Cmd:Home\" windows=\"N/A\" linux=\"N/A\"/>     | Scroll to top (macOS only)   |\n| <Kbd k=\"Cmd:End\" windows=\"N/A\" linux=\"N/A\"/>      | Scroll to bottom (macOS only)|\n| <Kbd k=\"Cmd:ArrowLeft\" windows=\"N/A\" linux=\"N/A\"/> | Move to beginning of line (macOS only) |\n| <Kbd k=\"Cmd:ArrowRight\" windows=\"N/A\" linux=\"N/A\"/> | Move to end of line (macOS only) |\n| <Kbd k=\"Shift:PageUp\"/> | Scroll up one page           |\n| <Kbd k=\"Shift:PageDown\"/>| Scroll down one page        |\n\n## Customizeable Systemwide Global Hotkey\n\nWave 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).\n\n</PlatformProvider>\n"
  },
  {
    "path": "docs/docs/layout.mdx",
    "content": "---\nsidebar_class_name: hidden\nid: \"layout\"\n---\n\nimport { Redirect } from \"@docusaurus/router\";\n\n<Redirect to=\"/tabs#tab-layout-system\" />\n\n<!-- The contents of this page has moved to the tabs.mdx file under the \"Tab Layout System\" section. -->\n"
  },
  {
    "path": "docs/docs/presets.mdx",
    "content": "---\nsidebar_position: 3.5\nid: \"presets\"\ntitle: \"Presets\"\n---\n\n# Presets\n\nWave's preset system allows you to save and apply multiple configuration settings at once. Presets are used for:\n\n- Tab backgrounds: Apply visual styles to your tabs\n\n## Managing Presets\n\nYou can store presets in two locations:\n\n- `~/.config/waveterm/presets.json`: Main presets file\n- `~/.config/waveterm/presets/`: Directory for organizing presets into separate files\n\nAll presets are aggregated regardless of which file they're in, so you can use the `presets` directory to organize them (e.g., `presets/bg.json`).\n\n:::info\nYou can easily edit your presets using the built-in editor:\n\n```bash\nwsh editconfig presets.json        # Edit main presets file\nwsh editconfig presets/bg.json     # Edit background presets\n```\n\n:::\n\n## File Format\n\nPresets follow this format:\n\n```json\n{\n  \"<preset-type>@<preset-key>\": {\n    \"display:name\": \"<Preset name>\",\n    \"display:order\": \"<number>\", // optional\n    \"<overridden-config-key-1>\": \"<overridden-config-value-1>\"\n    ...\n  }\n}\n```\n\nThe `preset-type` determines where the preset appears in Wave's interface:\n\n- `bg`: Appears in the \"Backgrounds\" submenu when right-clicking a tab\n\n### Common Keys\n\n| Key Name      | Type   | Function                                  |\n| ------------- | ------ | ----------------------------------------- |\n| display:name  | string | Name shown in the UI menu (required)      |\n| display:order | float  | Controls the order in the menu (optional) |\n\n:::info\nWhen a preset is applied, it overrides the default configuration values for that tab or block. Using `bg:*` will clear any previously overridden values, setting them back to defaults. It's recommended to include this key in your presets to ensure a clean slate.\n:::\n\n## Background Presets\n\nWave'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.\n\n### Configuration Keys\n\n| Key Name             | Type   | Function                                                                                                |\n| -------------------- | ------ | ------------------------------------------------------------------------------------------------------- |\n| bg:\\*                | bool   | Reset all existing bg keys (recommended to prevent any existing background settings from carrying over) |\n| bg                   | string | CSS `background` attribute for the tab (supports colors, gradients images, etc.)                        |\n| bg:opacity           | float  | The opacity of the background (defaults to 0.5)                                                         |\n| bg:blendmode         | string | The [blend mode](https://developer.mozilla.org/en-US/docs/Web/CSS/blend-mode) of the background         |\n| bg:bordercolor       | string | The color of the border when a block is not active (rarely used)                                        |\n| bg:activebordercolor | string | The color of the border when a block is active                                                          |\n\n### Examples\n\n#### Simple solid color:\n\n```json\n{\n  \"bg@blue\": {\n    \"display:name\": \"Blue\",\n    \"bg:*\": true,\n    \"bg\": \"blue\",\n    \"bg:opacity\": 0.3,\n    \"bg:activebordercolor\": \"rgba(0, 0, 255, 1.0)\"\n  }\n}\n```\n\n#### Complex gradient:\n\n```json\n{\n  \"bg@duskhorizon\": {\n    \"display:name\": \"Dusk Horizon\",\n    \"bg:*\": true,\n    \"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%)\",\n    \"bg:opacity\": 0.9,\n    \"bg:blendmode\": \"overlay\"\n  }\n}\n```\n\n#### Background image:\n\n```json\n{\n  \"bg@ocean\": {\n    \"display:name\": \"Ocean Scene\",\n    \"bg:*\": true,\n    \"bg\": \"url('/path/to/ocean.jpg') center/cover no-repeat\",\n    \"bg:opacity\": 0.2\n  }\n}\n```\n\n:::info\nBackground 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.\n:::\n\n:::tip\nThe `setbg` command can help generate background preset JSON:\n\n```bash\n# Preview a solid color preset\nwsh setbg --print \"#ff0000\"\n{\n  \"bg:*\": true,\n  \"bg\": \"#ff0000\",\n  \"bg:opacity\": 0.5\n}\n\n# Preview a centered image preset\nwsh setbg --print --center --opacity 0.3 ~/logo.png\n{\n  \"bg:*\": true,\n  \"bg\": \"url('/absolute/path/to/logo.png') no-repeat center/auto\",\n  \"bg:opacity\": 0.3\n}\n```\n\nJust add the required `display:name` field to complete your preset!\n:::\n"
  },
  {
    "path": "docs/docs/releasenotes.mdx",
    "content": "---\nid: \"releasenotes\"\ntitle: \"Release Notes\"\nsidebar_position: 200\n---\n\n# Release Notes\n\n### v0.14.2 &mdash; Mar 12, 2026\n\nWave v0.14.2 adds block/tab badges, directory preview improvements, and assorted bug fixes.\n\n**Block/Tab Badges:**\n\n- **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\n- **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`)\n- **`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))\n\n**Other Changes:**\n\n- **Directory Preview Improvements** - Improved mod time formatting, zebra-striped rows, better default sort, YAML file support, and context menu improvements\n- **Search Bar** - Clipboard and focus improvements in the search bar\n- [bugfix] Fixed \"New Window\" hanging/not working on GNOME desktops\n- [bugfix] Fixed \"Save Session As...\" (focused window tracking bug)\n- [bugfix] Zoom change notifications were not being properly sent to all tabs (layout inconsistencies)\n- Added a Release Notes link in the settings menu\n- Working on anthropic-messages Wave AI backend (for native Claude integration)\n- Lots of internal work on testing/mock infrastructure to enable quicker async AI edits\n- Documention updates\n- Package updates and dependency upgrades\n\n### v0.14.1 &mdash; Mar 3, 2026\n\nWave 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.\n\n**Terminal Improvements:**\n\n- **Claude Code Scroll Fix** - Fixed a long-standing bug that caused terminal windows to jump to the top unexpectedly, affecting many Claude Code users\n- **IME Fix** - Fixed Korean/CJK input where characters were lost or stuck in composition and only committed on Space\n- **Scroll Position Preserved on Resize** - Terminal now stays scrolled to the bottom across resizes when it was already at the bottom\n- **Better Link Handling** - Terminal URLs now have improved context menus and tooltips for easier navigation\n- **Terminal Scrollback Save** - New context menu item and `wsh` command to save terminal scrollback to a file\n\n**New Features:**\n\n- **Focus Follows Cursor** - New `app:focusfollowscursor` setting (off/on/term) for hover-based block focus\n- **Terminal Cursor Style & Blink** - New settings for cursor style (block/bar/underline) and blink, configurable per-block\n- **Tab Close Confirmation** - New `tab:confirmclose` setting to prompt before closing a tab\n- **Workspace-Scoped Widgets** - New optional `workspaces` field in `widgets.json` to show/hide widgets per-workspace\n- **Vim-Style Block Navigation** - Added Ctrl+Shift+H/J/K/L to navigate between blocks\n- **New AI Providers** - Added Groq and NanoGPT as built-in AI provider presets\n\n**Other Changes:**\n\n- Fixed intermittant bugs with connection switching in terminal blocks\n- Widgets.json schema improvements for better editor validation\n- Package updates and dependency upgrades\n- Internal code cleanup and refactoring\n\n### v0.14.0 &mdash; Feb 10, 2026\n\n**Durable SSH Sessions and Enhanced Connection Monitoring**\n\nWave 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.\n\n**Durable Sessions (Remote SSH Only):**\n- **Survive Interruptions** - SSH terminal sessions persist through network changes, computer sleep, and Wave restarts, automatically reconnecting when the connection is restored\n- **Session Protection** - Shell state, running programs, and terminal history are maintained even when Wave is closed or disconnected\n- **Visual Status Indicators** - Shield icons in terminal headers show session status (Standard, Durable Attached, Durable Detached, Durable Awaiting) with detailed flyover information\n- **Flexible Configuration** - Configure at global, per-connection, or per-block level with easy switching between standard and durable modes\n- See the new [Durable Sessions documentation](https://docs.waveterm.dev/durable-sessions) for setup and usage\n\n**Enhanced Connection Monitoring:**\n- **Connection Keepalives** - Active monitoring of SSH connections with automatic keepalive probes\n- **Stalled Connection Detection** - New connection monitor detects and displays \"stalled\" connection states when network issues occur, providing clear visual feedback\n- **Better Error Handling** - Improved connection status tracking and user-facing connection state indicators\n\n**Terminal Improvements:**\n- **OSC 52 Clipboard Support** - Terminal applications can now copy directly to your system clipboard using OSC 52 escape sequences\n- **Enhanced Context Menu** - Right-click terminals for quick access to splits, URL opening, themes, file browser, and more\n- **Streamlined Header Layout** - Terminal headers now focus on connection info without redundant view type labels\n\n**Wave AI Updates:**\n- **Image/Vision Support** - Added image support for OpenAI chat completions API, enabling vision capabilities with compatible models\n- **Stop Generation** - New ability to stop AI responses mid-generation across OpenAI and Gemini backends\n- **AI Panel Scroll Latch** - Improved auto-scrolling behavior in Wave AI panel\n- **Configurable Verbosity** - Control verbosity levels for OpenAI Responses API\n- Deprecated old AI-widget proxy endpoint\n\n**RPC and Performance:**\n- **RPC Streaming with Flow Control** - New streaming primitives with built-in flow control for better performance and reliability\n- **WSH Router Refactor** - Major routing architecture improvements to prevent hangs on connection interruptions\n- **RPC Client/Server Cleanup** - Improved RPC implementation and error handling\n\n**Configuration Updates:**\n- **Hide AI Button** - New `app:hideaibutton` setting to hide the AI button from the UI\n- **Disable Ctrl+Shift Arrows** - New `app:disablectrlshiftarrows` setting for keyboard shortcut conflicts\n- **Disable Ctrl+Shift Display** - New `app:disablectrlshiftdisplay` setting to disable overlay block numbers\n\n**Breaking Changes:**\n- **Removed Pinned Tabs** - Pinned tabs feature has been removed from the UI\n- **Removed S3 and WaveFile** - S3 filesystem and wavefile implementations removed to prevent large/recursive file transfer issues and simplify codebase\n\n**Other Changes:**\n- **Confirm on Quit** - Added confirmation dialog when closing Wave with active sessions\n- Monaco Editor upgrade removing `monaco-editor/loader` and `monaco-editor/react` dependencies for better performance and stability\n- New Tab model with React provider for improved state management\n- Removed OSC 23198 and OSC 9283 legacy handlers\n- Updated contribution guidelines\n- Upgraded Go toolchain to 1.25.6\n- Enhanced OpenAI-compatible API provider documentation\n- [bugfix] Fixed empty data handling in sysinfo view\n- [bugfix] Fixed `app:ctrlvpaste` setting on Windows (can now be disabled)\n- [bugfix] Fixed duplicated Wave AI system prompt for some providers\n- [bugfix] Fixed disconnect hanging issue - disconnects now happen immediately\n- [bugfix] Fixed tool approval lifecycle to match SSE connection timing\n- [bugfix] Increased WSL connection timeout to handle slow initial WSL startup\n- [bugfix] Improved terminal shutdown with SIGHUP for graceful shell exit\n- Package updates and dependency upgrades\n\n### v0.13.1 &mdash; Dec 16, 2025\n\n**Windows Improvements and Wave AI Enhancements**\n\nThis release focuses on significant Windows platform improvements, Wave AI visual updates, and better flexibility for local AI usage.\n\n**Windows Platform Enhancements:**\n- **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\n- **Git Bash Auto-Detection** - Wave now automatically detects Git Bash installations and adds them to the connection dropdown for easy access\n- **SSH Agent Fallback** - Improved SSH agent support with automatic fallback to `\\\\.\\pipe\\openssh-ssh-agent` on Windows\n- **Updated Focus Keybinding** - Wave AI focus key changed to Alt:0 on Windows for better consistency\n- **Config Schemas** - Improved configuration validation and schema support\n- Ctrl-V now works as standard paste in terminal on Windows\n\n**Wave AI Updates:**\n- **Refreshed Visual Design** - Complete UI refresh removing blue accents and adding transparency support for better integration with custom backgrounds\n- **BYOK Without Telemetry** - Wave AI now works with bring-your-own-key and local models without requiring telemetry to be enabled\n- [bugfix] Fixed tool type \"function\" compatibility with providers like Mistral\n\n**Terminal Improvements:**\n- **New Scrolling Keybindings** - Added Shift+Home, Shift+End, Shift+PageUp, and Shift+PageDown for better terminal navigation\n\n**Other Changes:**\n- Package updates and dependency upgrades\n\n### v0.13.0 &mdash; Dec 8, 2025\n\n**Wave v0.13 Brings Local AI Support, BYOK, and Unified Configuration**\n\nWave 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.\n\n**Local AI & BYOK Support:**\n- **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\n- **Google Gemini Integration** - Native support for Google's Gemini models with a dedicated API adapter\n- **Provider Presets** - Simplified configuration with built-in presets for OpenAI, OpenRouter, Google, Azure, and custom endpoints\n- **Multiple AI Modes** - Easily switch between different models and providers with a unified interface\n- See the new [Wave AI Modes documentation](https://docs.waveterm.dev/waveai-modes) for configuration examples and setup guides\n\n**Unified Configuration Widget:**\n- **New Config Interface** - Replaced the basic JSON editor with a dedicated configuration widget accessible from the sidebar\n- **Better Organization** - Browse and edit different configuration types (general settings, AI modes, secrets) with improved validation and error handling\n- **Integrated Secrets Management** - Access Wave's secret store directly from the config widget for secure credential management\n\n**Terminal Improvements:**\n- **Bracketed Paste Mode** - Now enabled by default to improve multi-line paste behavior and compatibility with tools like Claude Code\n- **Windows Paste Fix** - Ctrl+V now works as a standard paste accelerator on Windows\n- **SSH Password Management** - Store SSH connection passwords in Wave's secret store to avoid re-typing credentials\n\n**Other Changes:**\n- Package updates and dependency upgrades\n- Various bug fixes and stability improvements\n\n### v0.12.5 &mdash; Nov 24, 2025\n\nQuick patch release to fix paste behavior on Linux (prevent raw HTML from getting pasted to the terminal).\n\n### v0.12.4 &mdash; Nov 21, 2025\n\nQuick patch release with bug fixes and minor improvements.\n\n- New `term:macoptionismeta` setting for macOS to treat Option key as Meta key in terminal\n- Fixed directory tracking for zsh shells\n- Fixed editor copy operations\n- Minor Wave AI improvements (image handling, scrolling, focus)\n- Package updates and dependency upgrades\n- WIP: WaveApps builder framework (not yet released)\n\n### v0.12.3 &mdash; Nov 17, 2025\n\nPatch release with Wave AI model upgrade, new secret management features, and improved terminal input handling.\n\n**Wave AI Updates:**\n- **GPT-5.1 Model** - Upgraded to use OpenAI's GPT-5.1 model for improved responses\n- **Thinking Mode Toggle** - New dropdown to select between Quick, Balanced, and Deep thinking modes for optimal response quality vs speed\n- [bugfix] Fixed path mismatch issue when restoring AI write file backups\n\n**New Features:**\n- **Secret Store** - New secret management widget for storing and managing sensitive credentials. Access secrets via CLI with `wsh secret list/get/set` commands\n\n**Terminal Improvements:**\n- **Enhanced Input Handling** - Better support for interactive CLI tools like Claude Code. Shift+Enter now inserts newlines by default for multi-line commands\n- **Image Paste Support** - Paste images directly into terminal (saved to temp files with path inserted). Works great in Claude Code!\n- **IME Fix** - Fixed duplicate text issue when switching input methods during Chinese/Japanese/Korean composition\n\n**Other Changes:**\n- Improved backend panic tracking for better debugging\n- Fixed memory leak around sysinfo events\n- WIP: New WaveApps builder framework (not yet released)\n- Package updates and dependency bumps\n\n### v0.12.2 &mdash; Nov 4, 2025\n\nWave 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.\n\n**Wave AI Updates:**\n- **File Write Tool** - Wave AI can now create and modify files with your approval\n- **Visual Diff Preview** - See exactly what will change before approving edits, rendered in Monaco Editor\n- **Easy Rollback** - Revert file changes with a simple \"Revert File\" button\n- **Drag & Drop Files** - Drag files from the preview viewer directly to Wave AI\n- **Directory Listings** - `wsh ai` can now attach directory listings to chats\n- **Adjustable Settings** - Control thinking level and max output tokens per chat\n\n**Bug Fixes & Improvements:**\n- Fixed a significant memory leak in the RPC system\n- Schema validation working again for config files\n- Improved tool descriptions and input validations (run before tool approvals)\n- Fixed issue with premature tool timeouts\n- Fixed regression with PowerShell 5.x\n- Fixed prompt caching issue when attaching files\n\n### v0.12.1 &mdash; Oct 20, 2025\n\nPatch 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.\n\n**Shell Integration & Context:**\n- **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.\n- **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.\n\n**Wave AI Improvements:**\n- Display reasoning summaries in the UI while waiting for AI responses\n- Added enhanced terminal context - Wave AI now has access to shell state including current directory, command history, and exit codes\n- Added feedback buttons (thumbs up/down) for AI responses to help improve the experience\n- Added copy button to easily copy AI responses to clipboard\n\n**Other Changes:**\n- Mobile user agent emulation support for web widgets [#2442](https://github.com/wavetermdev/waveterm/issues/2442)\n- [bugfix] Fixed padding for header buttons in code editor (Tailwind regression)\n- [bugfix] Restored syntax highlighting in code editor preview blocks [#2427](https://github.com/wavetermdev/waveterm/issues/2427)\n- Package updates and dependency bumps\n\n### v0.12.0 &mdash; Oct 16, 2025\n\n**Wave v0.12 Has Arrived with Wave AI (beta)!**\n\nWave 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.\n\n**Wave AI Features:**\n- **New Slide-Out Chat Panel** - Access Wave AI via hotkeys (Cmd-Shift-A or Ctrl-Shift-0) from the left side of your screen\n- **Multi-Modal Input** - Support for images, PDFs, and text file attachments\n- **Drag & Drop Files** - Simply drag files into the chat to attach them\n- **Command-Line Integration** - Send files and command output directly to Wave AI using `wsh ai`\n- **Smart Context Switching** - Enable Wave AI to see into your widgets and file system\n- **Built-in Tools:**\n  - Web search capabilities\n  - Local file and directory operations\n  - Widget screenshots\n  - Terminal scrollback access\n  - Web navigation\n\nWave 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).\n\nFor more information and upcoming features, visit our [Wave AI documentation](https://docs.waveterm.dev/waveai).\n\n**Other Improvements:**\n- New onboarding flow showcasing block magnification, Wave AI, and wsh view/edit capabilities\n- New `wsh blocks list` command for listing and filtering blocks by workspace, tab, or view type\n- Continued migration from SCSS to Tailwind v4\n- Package upgrades and dependency updates\n- Internal code cleanup and refactoring\n\n### v0.11.6 &mdash; Sep 22, 2025\n\nPatch release to address an editor bug when you open two files in separate edit widgets. Also adds Mermaid support to markdown blocks.\n\n* WIP: Big AI overhaul coming (multi-modal support, premium models, and tool support)\n* WIP: Integrating new Tsunami widget framework to make writing and running Wave widgets easier\n* Lots of package updates\n* Much internal cleanup (preview widget)\n* More migration to Tailwind v4 CSS\n* Build updates, switched to npm from yarn\n\n### v0.11.5 &mdash; Aug 28, 2025\n\nAnother housekeeping release to modernize Wave and bring it more up to date.\n\n* Wave AI Cloud Proxy now uses gpt-5-mini (upgraded from gpt-4o-mini)\n* Fixed JWT issue with running \"Wave Apps\" from widgets\n* 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)\n* New setting to disable showing overlay blocknums when holding Ctrl:Shift (`app:showoverlayblocknums`)\n* New setting to allow Shift-Enter to work with tools like Claude Code (`term:shiftenternewline`)\n* Upgraded frontend to React 19\n* Migrated more of the frontend to Tailwind v4 (work in progress)\n* 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).\n* [bugfix] Removed build-ids in RPM build to try to fix conflicts with Slack\n* Removed some Wave v7 aware upgrades and old code paths\n* Internal cleanup, TypeScript errors, linting fixes, etc.\n* Other assorted Go/npm package bumps\n\n### v0.11.4 &mdash; Aug 19, 2025\n\nQuick patch release to update packages, fix some security issues (with dependent packages), and some small bug fixes.\n\n* Update AI Libraries, GPT-5 now supported in WaveAI\n* Added `ai:proxyurl` setting to allow proxy access (e.g. SOCKS) for AI access\n\n### v0.11.3 &mdash; May 2, 2025\n\nQuick patch release to update packages, fix some security issues (with dependent packages), and some small bug fixes.\n\n### v0.11.2 &mdash; March 8, 2025\n\nQuick patch release to fix a backend panic, and revert a change that caused WSL connections to hang.\n\n### v0.11.1 &mdash; Feb 28, 2025\n\nWave Terminal v0.11.1 adds a lot of new functionality over v0.11.0 (it could have almost been a v0.12)!\n\nThe 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.\n\nLots of other features and bug fixes as well:\n\n- **S3 Bucket** directory viewing and file previews\n- **Drag and Drop Files and Directories** between Wave directory views. This works across machines and between remote machines and S3 conections.\n- 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.\n- 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.\n- 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.\n- `wsh file` now supports copying files between your local machine, remote machines, and to/from S3\n- New analytics framework (event based as opposed to counter based). See Telemetry Docs for more information.\n- Web bookmarks! Edit in your bookmarks.json file, can open them in the web widget using Cmd+O\n- Edits to your ai.json presets file will now take effect _immediately_ in AI widgets\n- Much better error handling and messaging when errors occur in the preview or editor widget\n- `wsh ssh --new` added to open the new ssh connection in a new widget\n- new `wsh launch` command to open any custom widget defined in widget.json\n- When using terminal multi-input (Ctrl-Shift-I), pasting text will now be sent to all terminals\n- [bugfix] Fix some hanging goroutines when commands failed or timed out\n- [bugfix] Fix some file extension mimetypes to enable the editor for more file types\n- [bugfix] Hitting \"tab\" would sometimes scroll a widget off screen making it unusable\n- [bugfix] XDG variables will no longer leak to terminal widgets\n- Added tailwind CSS and shadcn support to help build new widgets faster\n- Better internal widget abstractions\n\n### v0.11.0 &mdash; Jan 24, 2025\n\nWave 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.\n\nA 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.\n\nFor 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.\n\nBehind 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.\n\n- **Shell Environment Customization** -- Configure your shell environment using environment variables and init scripts, with support for both local and remote connections\n- **Connection Backend Improvements** -- Major rewrite with improved shell detection, better error logging, and reduced 2FA prompts when using ForceCommand\n- **Multi-Shell Support** -- Enhanced support for bash, zsh, pwsh, and fish shells, with shell-specific initialization capabilities\n- **Terminal Search** -- use Cmd-F to search for text in terminal widgets\n- **Web Search** -- use Cmd-F to search for text in web views\n- **Terminal Multi-Input** -- Use Ctrl-Shift-I to allow multi-input to all terminals in the same tab\n- **Wave AI now supports Google Gemini**\n- Improved WSL support with wsh-free connection options\n- Added inline connection debugging information\n- Fixed file permission handling issues on Windows systems\n- Connection related popups are now delivered only to the initiating window\n- Improved timeout handling for SSH connections which require 2FA prompts\n- Fixed escape key handling in global event handlers (closing modals)\n- Directory preview now fills the entire block width\n- Custom widgets can now be launched in magnified mode\n- Various workspace UX improvements around closing/deleting\n- file:/// urls now work in web widget\n- Increased max size of files allowed in `wsh ai` to 50k\n- Increased maximum allowed term:scrollback to 50k lines\n- Allow connections to entirely be defined in connections.json without relying on ~/.ssh/config\n- Added an option to reveal files in external file viewer for local connection\n- Added a New Window option when right clicking the MacOS dock icon button\n- [build] Switched to free Ubuntu ARM runners for better ARM64 build support\n- [build] Windows builds now use zig, simplifying Windows dev setup\n- [bugfix] Connections dropdown now populated even when ssh config is missing or invalid\n- [bugfix] Disabled bracketed paste mode by default (configuration option to turn it back on)\n- [bugfix] Timeout for `wsh ssh` increased to 60s\n- [bugfix] Fix for sysinfo widget when displaying a huge number of CPU graphs\n- [bugfix] Fixes XDG variables for Snap installs\n- [bugfix] Honor SSH IdentitiesOnly flag (useful when many keys are loaded into ssh-agent)\n- [bugfix] Better shell environment variable setup when running local shells\n- [bugfix] Fix preview for large text files\n- [bugfix] Fix URLs in terminal (now clickable again)\n- [bugfix] Windows URLs now work properly for Wave background images\n- [bugfix] Connections launch without wsh if the unix domain socket can't be opened\n- [bugfix] Connection status list lights up correctly with currently connected connections\n- [bugfix] Use en_US.UTF-8 if the requested LANG is not available in your terminal\n- Other bug fixes, performance improvements, and dependency updates\n\n### v0.10.4 &mdash; Dec 20, 2024\n\nQuick update with bug fixes and new configuration options\n\n- Added \"window:confirmclose\" and \"window:savelastwindow\" configuration options\n- [bugfix] Fixed broken scroll bar in the AI widget\n- [bugfix] Fixed default path for wsh shell detection (used in remote connections)\n- Dependency updates\n\n### v0.10.3 &mdash; Dec 19, 2024\n\nQuick update to v0.10 with new features and bug fixes.\n\n- Global hotkey support [docs](https://docs.waveterm.dev/config#customizable-systemwide-global-hotkey)\n- Added configuration to override the font size for markdown, AI-chat, and preview editor [docs](https://docs.waveterm.dev/config)\n- Added ability to set independent zoom level for the web view (right click block header)\n- New `wsh wavepath` command to open the config directory, data directory, and log file\n- [bugfix] Fixed crash when /etc/sshd_config contained an unsupported Match directive (most common on Fedora)\n- [bugfix] Workspaces are now more consistent across windows, closes associated window when Workspaces are deleted\n- [bugfix] Fixed zsh on WSL\n- [bugfix] Fixed long-standing bug around control sequences sometimes showing up in terminal output when switching tabs\n- Lots of new examples in the docs for shell overrides, presets, widgets, and connections\n- Other bug fixes and UI updates\n\n(note, v0.10.2 and v0.10.3's release notes have been merged together)\n\n### v0.10.1 &mdash; Dec 12, 2024\n\nQuick 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.\n\n### v0.10.0 &mdash; Dec 11, 2024\n\nWave 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.\n\n- **Workspaces**: Organize your work into separate environments, each with their own tabs, layouts, and settings\n- **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))\n- **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))\n- Improved tab management with better switching behavior and context menus (many bug fixes)\n- New tab features including pinned tabs and drag-and-drop improvements\n- Create, rename, and delete files/directories directly in directory preview\n- Attempt wsh-free connection as a fallback if wsh installation or execution fails\n- New `-i` flag to add identity files with the `wsh ssh` command\n- Added Perplexity API integration ([docs](https://docs.waveterm.dev/faq#perplexity))\n- `wsh setbg` command for background handling ([docs](https://docs.waveterm.dev/wsh-reference#setbg))\n- Switched from Less to SCSS for styling\n- [bugfix] Fixed tab flickering issues during tab switches\n- [bugfix] Corrected WaveAI text area resize behavior\n- [bugfix] Fixed concurrent block controller start issues\n- [bugfix] Fixed Preview Blocks for uninitialized connections\n- [bugfix] Fixed unresponsive context menus\n- [bugfix] Fixed connection errors in Help block\n- Upgraded Go toolchain to 1.23.4\n- 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).\n- Other bug fixes, performance improvements, and dependency updates\n\n### v0.9.3 &mdash; Nov 20, 2024\n\nNew 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).\n\n- `wsh setvar/getvar` to get and set variables -- [Docs](https://docs.waveterm.dev/wsh-reference#getvarsetvar)\n- `wsh file` operations (cat, write, append, rm, info, cp, and ls) -- [Docs](https://docs.waveterm.dev/wsh-reference#file)\n- Improved golang panic handling to prevent backend crashes\n- Improved SSH config logging and fixes a reused connection bug\n- Updated telemetry to track additional counters\n- New configuration settings (under \"window:magnifiedblock\") to control magnified block margins and display\n- New block/zone aliases (client, global, block, workspace, temp)\n- `wsh ai` file attachments are now rendered with special handling in the AI block\n- New ephemeral block type for creating modal widgets which will not disturb the underlying layout\n- Editing the AI presets file from the Wave AI block now brings up an ephemeral editor\n- Clicking outside of a magnified bglock will now un-magnify it\n- New button to clear the AI chat (also bound to Cmd-L)\n- New button to reset terminal commands in custom cmd widgets\n- [bugfix] Presets directory was not loading correctly on Windows\n- [bugfix] Magnified blocks were not showing correct on startup\n- [bugfix] Window opacity and background color was not getting applied properly in all cases\n- [bugfix] Fix terminal theming when applying global defaults [#1287](https://github.com/wavetermdev/waveterm/issues/1287)\n- MacOS 10.15 (Catalina) is no longer supported\n- Other bug fixes, docs improvements, and dependency bumps\n\n### v0.9.2 &mdash; Nov 11, 2024\n\nNew 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](./presets) work!\n\n- Updated documentation\n- Wave AI now supports the Anthropic API! Checkout the [FAQ](./faq) for how to use the Claude models with Wave AI.\n- Removed defaultwidgets.json and unified it to widgets.json. Makes it more straightforward to override the default widgets.\n- 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.\n- New `wsh ai` command to send AI chats (and files) directly to a new or existing AI block\n- 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).\n- [bugfix] Set max completion tokens in the OpenAI API so we can now work with o1 models (also fallback to non-streaming mode)\n- [bugfix] Fixed content resizing when entering \"full screen\" mode. This bug also affected certain window managers (like Hyprland)\n- Lots of other small bug fixes, docs updates, and dependency bumps\n\n### v0.9.1 &mdash; Nov 1, 2024\n\nMinor bug fix release to follow-up on the v0.9.0 build. Lots of issues fixed (especially for Windows).\n\n- 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)\n- Can now use `wsh version -v` to print out the new data/config directories\n- Restores the old T1, T2, T3, ... tab naming logic\n- 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)\n- There is a new setting in the editor to enable/disable word wrapping [#1038](https://github.com/wavetermdev/waveterm/issues/1038)\n- Ctrl-S will now save files in codeedit [#1081](https://github.com/wavetermdev/waveterm/issues/1081)\n- [#1020](https://github.com/wavetermdev/waveterm/issues/1020) there is now a preset config option to change the active border color in tab themes\n- [bugfix] Multiple fixes for [#1167](https://github.com/wavetermdev/waveterm/issues/1167) to try to address tab loss while updating\n- [bugfix] Windows app crashed on opening View menu because of a bad accelerator key\n- [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\n- [bugfix] Full-screen mode now actually shows tabs in full screen\n- [bugfix] [#1175](https://github.com/wavetermdev/waveterm/issues/1175) can now edit .awk files\n- [bugfix] [#1066](https://github.com/wavetermdev/waveterm/issues/1066) applying a default theme now updates the background appropriately without a refresh\n\n### v0.9.0 &mdash; Oct 28, 2024\n\nNew major Wave Terminal release! Wave tabs are now cached. Tab switching performance is\nnow much faster and webview state, editor state, and scroll positions are now persisted\nacross tab changes. We also have native WSL2 support. You can create native Wave connections\nto your Windows WSL2 distributions using the connection button.\n\nWe've also laid the groundwork for some big features that will be released over the\nnext couple of weeks, including Workspaces, AI improvments, and custom widgets.\n\nLots of other smaller changes and bug fixes. See full list of PRs at https://github.com/wavetermdev/waveterm/releases/tag/v0.9.0\n\n### v0.8.13 &mdash; Oct 24, 2024\n\n- Wave is now available as a Snap for Linux users! You can find it [in the Snap Store](https://snapcraft.io/waveterm).\n- Wave is now available via the Windows Package Manager! You can install it via `winget install CommandLine.Wave`\n- can now use \"term:fontsize\" to override an individual terminal block's font size (also in context menu)\n- we now allow mixed case hostnames for connections to be compatible with ssh config\n- The Linux app icon is now updated to match the Windows icon\n- [bugfix] fixed a bug that sometimes caused escape sequences to be printed when switching between tabs\n- [bugfix] fixed an issue where the preview block was not cleaning up temp files (Windows only)\n- [bugfix] fixed chrome sandbox permissions errors in linux\n- [bugfix] fixed shutdown logic on MacOS/Linux which sometimes allowed orphaned processes to survive\n\n### v0.8.12 &mdash; Oct 18, 2024\n\n- 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\n- Fix WebSocket reconnection error. this sometimes caused the terminal to hang when waking up from sleep\n- Added memory graphs, and per-CPU graphs to the sysinfo widget (and renamed it from cpuplot)\n- Added a new huge red \"Config Error\" button when there are parse errors in the config JSON file\n- Preview/CodeEdit widget now shows errors (squiggly lines) when JSON or YAML files fail to parse\n- New app icon for Windows to better match Fluent UI standards\n- Added copy-on-select to the terminal (on by default, can disable using \"term:copyonselect\")\n- Added a button to mute audio in webviews\n- Added a right-click \"Open Clipboard URL\" to easily open a webview from an URL stored in your system clipboard\n- [bugfix] fixed blank \"help\" pages when waking from sleep or restarting the app\n\n### v0.8.11 &mdash; Oct 10, 2024\n\nHotfix release to address a couple of bugs introduced in v0.8.10\n\n- Fixes a regression in v0.8.10 which caused new tabs to sometimes come up blank and broken\n- Layout fixes to the AI widget spacing\n- Terminal scrollbar is now semi-transparent and overlays last column\n- Fixes initial window size (on first startup) for both smaller and larger screens\n- Added a \"Don't Ask Again\" checkbox for installing `wsh` on remote machines (sets a new config flag)\n- Prevent the app from downgrading when you install a beta build. Installing a beta-build will now switch you to the beta-update channel.\n\n### v0.8.10 &mdash; Oct 9, 2024\n\nMinor big fix release (but there are some new features).\n\n- added support for Azure AI [See FAQ](https://docs.waveterm.dev/faq#how-can-i-connect-to-azure-ai)\n- AI errors now appear in the chat\n- on MacOS, hitting \"Space\" in directorypreview will open selected file in Quick Look\n- [bugfix] fixed transparency settings\n- [bugfix] fixed issue with non-standard port numbers in connection dropdown\n- [bugfix] fixed issue with embedded docsite (returned 404 after refresh)\n\n### v0.8.9 &mdash; Oct 8, 2024\n\nLots of bug fixes and new features!\n\n- New \"help\" view -- uses an embedded version of our doc site -- https://docs.waveterm.dev\n- [breaking] wsh getmeta, wsh setmeta, and wsh deleteblock now take a blockid using a `-b` parameter instead of as a positional parameter\n- allow metadata to override the block icon, header, and text (frame:title, frame:icon, and frame:text)\n- 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\n- checkpoint the terminal less often to reduce frequency of output bug (still working on a full fix)\n- new terminal themes -- Warm Yellow, and One Dark Pro\n- we now support github flavored markdown alerts\n- `wsh notify` command to send a desktop notification\n- `wsh createblock` to create any block via the CLI\n- right click to \"Save Image\" in webview\n- `wsh edit` will now allow you to open new files (as long as the parent directly exists)\n- added 8 new fun tab background presets (right click on any tab and select \"Backgrounds\" to try them out)\n- [config] new config key \"term:scrollback\" to set the number of lines of scrollback for terminals. Use \"-1\" to set 0, max is 10000.\n- [config] new config key \"term:theme\" to set the default terminal theme for all new terminals\n- [config] new config key \"preview:showhiddenfiles\" to set the default \"show hidden files\" setting for preview\n- [bugfix] fixed an formatting issue with `wsh getmeta`\n- [bugfix] fix for startup issue on Linux when home directory is an NFS mount\n- [bugfix] fix cursor color in terminal themes to work\n- [bugfix] fix some double scrollbars when showing markdown content\n- [bugfix] improved shutdown sequence to better capture wavesrv logs\n- [bugfix] fix Alt+G keyboard accelerator for Linux/Windows\n- other assorted bug fixes, cleanups, and security fixes\n\n### v0.8.8 &mdash; Oct 1, 2024\n\nQuick patch release to fix Windows/Linux \"Alt\" keybindings. Also brings a huge performance improvement to AI streaming speed.\n\n### v0.8.7 &mdash; Sep 30, 2024\n\nQuick patch release to fix bugs:\n\n- Fixes windows SSH connections (invalid path while trying to install wsh tools)\n- Fixes an issue resolving `~` in windows paths `~\\` now works instead of just `~/`\n- 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)\n- Fixed our useDimensions hook to fire correctly. Fixes some sizing issues including allowing error messages to show consistently when SSH connections fail.\n- Allow \"data:\" urls in custom tab backgrounds\n- All the alias \"tab\" for the current tab's UUID when using wsh\n- [BUILD] conditional write generated files only if they are updated\n\n### v0.8.6 &mdash; Sep 26, 2024\n\nAnother 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.\n\nAlso, as a bonus, we added fish shell support!\n\n### v0.8.5 &mdash; Sep 25, 2024\n\nHot fix, dowgrade `jotai` library. Upgrading caused a major regression in codeedit which did not allow\nusers to edit files.\n\n### v0.8.4 &mdash; Sep 25, 2024\n\n- Added a setting `window:disablehardwareacceleration` to disable native hardware acceleration\n- New startup model for legacy users given them the option to download the WaveLegacy\n- Use WAVETERM_HOME for the home directory consistently\n\n### v0.8.3 &mdash; Sep 25, 2024\n\nMore hotfixes for Linux users. We now link against an older version of glibc and use\nthe zig compiler on linux (the newer version caused us not to run on older distros).  \nAlso fixes a permissions issue when installing via .deb. There is also a new config value\n`window:nativetitlebar` which restores the native titlebar on windows/linux.\n\n### v0.8.2 &mdash; Sep 24, 2024\n\nHot fix, fixes a nasty crash on startup for Linux users (dynamic linking but with netcgo DNS library)\n\n### v0.8.1 &mdash; Sep 23, 2024\n\nMinor cleanup release.\n\n- fix number parsing for certain config file values\n- add link to docs site\n- add new back button for directory view\n- telemetry fixes\n\n### v0.8.0 &mdash; Sep 20, 2024\n\n**Major New Release of Wave Terminal**\n\nThe 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.\n\nRelease Artificats and source code diffs can be found on (Github)[https://github.com/wavetermdev/waveterm].\n"
  },
  {
    "path": "docs/docs/secrets.mdx",
    "content": "---\nsidebar_position: 3.2\nid: \"secrets\"\ntitle: \"Secrets\"\n---\n\nimport { VersionBadge } from \"@site/src/components/versionbadge\";\n\n# Secrets\n\n<VersionBadge version=\"v0.13\" noLeftMargin={true} />\n\nWave 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.\n\n## Why Use Secrets?\n\nSecrets in Wave Terminal allow you to:\n\n- **Store SSH passwords** - Automatically authenticate to SSH connections without typing passwords\n- **Manage API keys** - Keep API tokens, keys, and credentials secure\n- **Share across sessions** - Access your secrets from any terminal block or remote connection\n- **Avoid plaintext storage** - Never store sensitive data in configuration files or scripts\n\n## Opening the Secrets UI\n\nThere are several ways to access the secrets management interface:\n\n1. **From the widgets bar** (recommended):\n   - Click the **<i className=\"fa-gear fa-solid fa-sharp\"/>** settings icon on the widgets bar\n   - Select **Secrets** from the menu\n\n2. **From the command line:**\n   ```bash\n   wsh secret ui\n   ```\n\n\nThe secrets UI provides a visual interface to view, add, edit, and delete secrets.\n\n## Managing Secrets via CLI\n\nWave Terminal provides a complete CLI for managing secrets from any terminal block:\n\n```bash\n# List all secret names (not values)\nwsh secret list\n\n# Get a specific secret value\nwsh secret get MY_SECRET_NAME\n\n# Set a secret (format: name=value, no spaces around =)\nwsh secret set GITHUB_TOKEN=ghp_xxxxxxxxxx\nwsh secret set DB_PASSWORD=super_secure_password\n\n# Delete a secret\nwsh secret delete MY_SECRET_NAME\n```\n\n## Secret Naming Rules\n\nSecret names must match the pattern: `^[A-Za-z][A-Za-z0-9_]*$`\n\nThis means:\n- Must start with a letter (A-Z or a-z)\n- Can only contain letters, numbers, and underscores\n- Cannot contain spaces or special characters\n\n**Valid names:** `MY_SECRET`, `ApiKey`, `ssh_password_1`\n**Invalid names:** `123_SECRET`, `my-secret`, `secret name`\n\n## Using Secrets with SSH Connections\n\n<VersionBadge version=\"v0.13\" />\n\nSecrets can be used to automatically provide passwords for SSH connections, eliminating the need to type passwords repeatedly.\n\n### Configure in connections.json\n\nAdd the `ssh:passwordsecretname` field to your connection configuration:\n\n```json\n{\n    \"myserver\": {\n        \"ssh:hostname\": \"example.com\",\n        \"ssh:user\": \"myuser\",\n        \"ssh:passwordsecretname\": \"SERVER_PASSWORD\"\n    }\n}\n```\n\nThen store your password as a secret:\n\n```bash\nwsh secret set SERVER_PASSWORD=my_actual_password\n```\n\nNow when Wave connects to `myserver`, it will automatically use the password from your secret store instead of prompting you.\n\n### Benefits\n\n- **Security**: Password stored encrypted in your system keychain\n- **Convenience**: No need to type passwords for each connection\n- **Flexibility**: Update passwords by changing the secret, not the configuration\n\n## Security Considerations\n\n- **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).\n\n- **No Plaintext**: Secrets are never stored unencrypted in logs or accessible files.\n\n- **Access Control**: Secrets are only accessible to Wave Terminal.\n\n\n## Storage Backend\n\nWave Terminal automatically detects and uses the appropriate secret storage backend for your operating system:\n\n- **macOS**: Uses the macOS Keychain\n- **Windows**: Uses Windows Credential Manager\n- **Linux**: Uses the Secret Service API (freedesktop.org specification)\n\n:::warning Linux Secret Storage\nOn 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.\n:::\n\n## Troubleshooting\n\n### \"No appropriate secret manager found\"\n\nThis error occurs on Linux when no compatible secret service backend is available. Install GNOME Keyring or KWallet and ensure the secret service is running.\n\n### Secret not found\n\nEnsure the secret name is spelled correctly (names are case-sensitive) and that the secret exists:\n\n```bash\nwsh secret list\n```\n\n### Permission denied on Linux\n\nThe secret service may require you to unlock your keyring. This typically happens after login. Consult your desktop environment's documentation for keyring management.\n\n## Related Documentation\n\n- [Connections](/connections) - Learn about SSH connections and configuration\n- [wsh Command Reference](/wsh-reference#secret) - Complete CLI command documentation for secrets"
  },
  {
    "path": "docs/docs/tabs.mdx",
    "content": "---\nsidebar_position: 3.25\nid: \"tabs\"\ntitle: \"Tabs\"\n---\n\nimport { PlatformProvider, PlatformSelectorButton } from \"@site/src/components/platformcontext\";\nimport { Kbd } from \"@site/src/components/kbd\";\n\n<PlatformProvider>\n\nTabs 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.\n\n## Tab Bar\n\nThe 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.\n\n<PlatformSelectorButton />\n\n### Creating a new tab\n\nYou 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.\n\n### Closing a tab\n\nYou 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.\n\nClosing a block is a destructive action that will stop any running processes and discard any unsaved work. This cannot be undone.\n\n### Rearranging tabs\n\nYou can rearrange tabs by dragging them around within the tab bar.\n\n### Switching tabs\n\nYou can switch to an existing tab by clicking on it in the tab bar. You can also use the following shortcuts:\n\n| Key                | Function             |\n| ------------------ | -------------------- |\n| <Kbd k=\"Cmd:1-9\"/> | Switch to tab number |\n| <Kbd k=\"Cmd:[\"/>   | Switch tab left      |\n| <Kbd k=\"Cmd:]\"/>   | Switch tab right     |\n\n### Pinning a tab\n\nPinning 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.\n\n## Tab Layout System\n\nThe 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.\n\n![screenshot showing a block being dragged over another block, with the placeholder depicting a out-of-line before outer drop](./img/drag-edge.png)\n\n### Layout system under the hood\n\n:::info\n\n**Definitions**\n\n- Layout tree: the in-memory representation of a tab layout, comprised of nodes\n- 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\n- Block: The contents of a leaf in the layout tree, defines what contents is displayed at the given layout location\n\n:::\n\nOur 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.).\n\n### Layout actions\n\n<PlatformSelectorButton />\n\n#### Add a new block\n\nYou can add new blocks by selecting a widget from the right sidebar.\n\nStarting 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.\n\nWhile 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\n\n#### Delete a block\n\nYou 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\"/>\n\nWhen you delete a block, the layout tree will be automatically adjusted to minimize the tree depth.\n\n#### Move a block\n\nYou 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.\n\nThere 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.\n\n<video width=\"100%\" height=\"100%\" playsinline autoplay muted controls>\n  <source src=\"./img/drag-move-24fps-crf43.mp4\" type=\"video/mp4\" />\n</video>\n\n##### Possible block movements\n\n:::note\nAll block movements except for Swap will cause the rest of the layout to shift to accommodate the block's new displacement.\n:::\n\n![screenshot showing a block being dragged over another block, with the placeholder depicting a swap movement](./img/drag-swap.png)\n![annotated example showing the drop targets within a block](./img/block-drag-example.jpg)\n\n1. Inline before: Drops the block under the same node as the target block, placing it before the target in the same tiling direction\n2. Inline after: Drops the block under the same node as the target block, placing it after the target in the same tiling direction\n3. Out-of-line before outer: Drops the block before the target block's parent node in the opposite tiling direction\n4. 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.\n5. 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.\n6. Out-of-line after outer: Drops the block after the target block's parent node in the opposite tiling direction\n7. Swap: Swaps the position of the dropped block and the targeted block in the layout, preserving the rest of the layout\n\n#### Resize a block\n\n<video width=\"100%\" height=\"100%\" playsinline autoplay muted controls>\n  <source src=\"./img/resize-24fps-crf43.mp4\" type=\"video/mp4\" />\n</video>\n\n![screenshot showing the line that appears when the cursor hovers over the margin of a block, indicating which blocks\nwill be resized by dragging the margin](./img/node-resize.png)\n\nYou 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.\n\nNode 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.\n\n### Magnify a block\n\nYou 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.\n\n### Change the gap size between blocks\n\nThe 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.\n\n</PlatformProvider>\n"
  },
  {
    "path": "docs/docs/telemetry-old.mdx",
    "content": "---\nid: \"telemetry-old\"\ntitle: \"Legacy Telemetry\"\nsidebar_class_name: hidden\n---\n\nWave 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.\n\nIf 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.\n\n:::info\n\nYou can also change your telemetry setting by running the wsh command:\n\n```\nwsh setconfig telemetry:enabled=true\n```\n\n:::\n\n---\n\n## Sending Telemetry\n\nProvided 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.\n\n### Sending Once Telemetry is Enabled\n\nAs 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.\n\n### Notifying that Telemetry is Disabled\n\nAs 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.\n\n### When Waveterm is Closed\n\nProvided that telemetry is enabled, it will be sent when Waveterm is closed.\n\n---\n\n## Telemetry Data\n\nWhen telemetry is active, we collect the following data. It is stored in the `telemetry.TelemetryData` type in the source code.\n\n| Name          | Description                                                                                                                                                                     |\n| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| 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. |\n| 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.                 |\n| 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.                             |\n| NumBlocks     | The number of existing blocks open on a given day                                                                                                                               |\n| NumTabs       | The number of existing tabs open on a given day.                                                                                                                                |\n| NewTab        | The number of new tabs created on a given day                                                                                                                                   |\n| NumWindows    | The number of existing windows open on a given day.                                                                                                                             |\n| NumWS         | The number of existing workspaces on a given day.                                                                                                                               |\n| NumWSNamed    | The number of named workspaces on a give day.                                                                                                                                   |\n| NewTab        | The number of new tabs opened on a given day.                                                                                                                                   |\n| NumStartup    | The number of times waveterm has been started on a given day.                                                                                                                   |\n| NumShutdown   | The number of times waveterm has been shut down on a given day.                                                                                                                 |\n| SetTabTheme   | The number of times the tab theme is changed from the context menu                                                                                                              |\n| NumMagnify    | The number of times any block is magnified                                                                                                                                      |\n| NumPanics     | The number of backend (golang) panics caught in the current day                                                                                                                 |\n| NumAIReqs     | The number of AI requests made in the current day                                                                                                                               |\n| NumSSHConn    | The number of distinct SSH connections that have been made to distinct hosts                                                                                                    |\n| NumWSLConns   | The number of distinct WSL connections that have been made to distinct distros                                                                                                  |\n| Renderers     | The number of new block views of each type are open on a given day.                                                                                                             |\n| WshCmds       | The number of wsh commands of each type run on a given day                                                                                                                      |\n| Blocks        | The number of blocks of different view types open on a given day                                                                                                                |\n| Conn          | The number of successful remote connections made (and errors) on a given day                                                                                                    |\n\n## Associated Data\n\nIn addition to the telemetry data collected, the following is also reported. It is stored in the `telemetry.ActivityType` type in the source code.\n\n| Name          | Description                                                                                                                                                                                                                                                                                      |\n| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| Day           | The date the telemetry is associated with. It does not include the time.                                                                                                                                                                                                                         |\n| 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. |\n| TzName        | The code for the timezone the user's OS is reporting (e.g. PST, GMT, JST)                                                                                                                                                                                                                        |\n| TzOffset      | The offset for the timezone the user's OS is reporting (e.g. -08:00, +00:00, +09:00)                                                                                                                                                                                                             |\n| ClientVersion | Which version of Waveterm is installed.                                                                                                                                                                                                                                                          |\n| 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.                                                                                                                             |\n| 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.                                                                                                                                        |\n| OSRelease     | This lists the version of the operating system the user has installed.                                                                                                                                                                                                                           |\n| Displays      | Display resolutions (added in v0.9.3 to help us understand what screen resolutions to optimize for)                                                                                                                                                                                              |\n\n## Telemetry Metadata\n\nLastly, 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.\n\n| Name              | Description                                                                                                                 |\n| ----------------- | --------------------------------------------------------------------------------------------------------------------------- |\n| UserId            | Currently Unused. This is an anonymous UUID intended for use in future features.                                            |\n| ClientId          | This is an anonymous UUID created when Waveterm is first launched. It is used for telemetry and sending prompts to Open AI. |\n| AppType           | This is used to differentiate the current version of waveterm from the legacy app.                                          |\n| AutoUpdateEnabled | Whether or not auto update is turned on.                                                                                    |\n| AutoUpdateChannel | The type of auto update in use. This specifically refers to whether a latest or beta channel is selected.                   |\n| CurDay            | The current day (in your time zone) when telemetry is sent. It does not include the time of day.                            |\n\n## Geo Data\n\nWe do not store IP addresses in our telemetry table. However, CloudFlare passes us Geo-Location headers. We store these two header values:\n\n| Name         | Description                                                       |\n| ------------ | ----------------------------------------------------------------- |\n| CFCountry    | 2-letter country code (e.g. \"US\", \"FR\", or \"JP\")                  |\n| CFRegionCode | region code (often a provence, region, or state within a country) |\n\n---\n\n## When Telemetry is Turned Off\n\nWhen 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.\n\n---\n\n## A Note on IP Addresses\n\nTelemetry 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_.\n\n---\n\n## Previously Collected Telemetry Data\n\nWhile 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.\n\n---\n\n## Privacy Policy\n\nFor a summary of the above, you can take a look at our [Privacy Policy](https://www.waveterm.dev/privacy).\n"
  },
  {
    "path": "docs/docs/telemetry.mdx",
    "content": "---\nsidebar_position: 100\ntitle: Telemetry\nid: \"telemetry\"\n---\n\n## tl;dr\n\nWave 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.\n\nHere's a quick summary of what is collected:\n\n- Basic App/System Info - OS, architecture, app version, update settings\n- Usage Metrics - App start/shutdown, active minutes, foreground time, tab/block counts/usage\n- Feature Interactions - When you create tabs, run commands, change settings, etc.\n- Display Info - Monitor resolution, number of displays\n- Connection Events - SSH/WSL connection attempts (but NOT hostnames/IPs)\n- Wave AI Usage - Model/provider selection, token counts, request metrics, latency (but NOT prompts or responses)\n- Error Reports - Crash/panic events with minimal debugging info, but no stack traces or detailed errors\n\nTelemetry can be disabled at any time in settings. If not disabled it is sent on startup, on shutdown, and every 4-hours.\n\n## How to Disable Telemetry\n\nTelemetry 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`.\n\n:::info\n\nThis 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.\n\n:::\n\n## Diagnostics Ping\n\nWave 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.\n\nThe 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.\n\nIt does not include usage data, commands, files, or any telemetry events.\n\nThis 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.\n\n## Sending Telemetry\n\nProvided 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.\n\n### Sending Once Telemetry is Enabled\n\nAs 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.\n\n### When Wave is Closed\n\nProvided that telemetry is enabled, it will be sent when Waveterm is closed.\n\n## Event Types and Properties\n\nWave 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.\n\nFor 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)\n\n## GDPR Opt-Out Compliance\n\nWhen 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.\n\n## Deleting Your Data\n\nIf you want your previously collected telemetry data deleted, email us at support (at) waveterm.dev with your _ClientId_ and we'll remove it.\n\n## Privacy Policy\n\nFor a summary of the above, you can take a look at our [Privacy Policy](https://www.waveterm.dev/privacy).\n"
  },
  {
    "path": "docs/docs/waveai-modes.mdx",
    "content": "---\nsidebar_position: 1.6\nid: \"waveai-modes\"\ntitle: \"Wave AI (Local Models + BYOK)\"\n---\n\nimport { VersionBadge } from \"@site/src/components/versionbadge\";\n\n<VersionBadge version=\"v0.13\" noLeftMargin={true}/>\n\nWave 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.\n\n## Configuration Overview\n\nAI modes are configured in `~/.config/waveterm/waveai.json`.\n\n**To edit using the UI:**\n1. Click the settings (gear) icon in the widget bar\n2. Select \"Settings\" from the menu\n3. Choose \"Wave AI Modes\" from the settings sidebar\n\n**Or launch from the command line:**\n```bash\nwsh editconfig waveai.json\n```\n\nEach mode defines a complete AI configuration including the model, API endpoint, authentication, and display properties.\n\n## Provider-Based Configuration\n\nWave 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.\n\n### Supported Providers\n\n- **`openai`** - OpenAI API (automatically configures endpoint and secret name) [[see example](#openai)]\n- **`openrouter`** - OpenRouter API (automatically configures endpoint and secret name) [[see example](#openrouter)]\n- **`nanogpt`** - NanoGPT API (automatically configures endpoint and secret name) [[see example](#nanogpt)]\n- **`groq`** - Groq API (automatically configures endpoint and secret name) [[see example](#groq)]\n- **`google`** - Google AI (Gemini) [[see example](#google-ai-gemini)]\n- **`azure`** - Azure OpenAI Service (modern API) [[see example](#azure-openai-modern-api)]\n- **`azure-legacy`** - Azure OpenAI Service (legacy deployment API) [[see example](#azure-openai-legacy-deployment-api)]\n- **`custom`** - Custom API endpoint (fully manual configuration) [[see examples](#local-model-examples)]\n\n### Supported API Types\n\nWave AI supports the following API types:\n\n- **`openai-chat`**: Uses the `/v1/chat/completions` endpoint (most common)\n- **`openai-responses`**: Uses the `/v1/responses` endpoint (modern API for GPT-5+ models)\n- **`google-gemini`**: Google's Gemini API format (automatically set when using `ai:provider: \"google\"`, not typically used directly)\n\n## Global Wave AI Settings\n\nYou can configure global Wave AI behavior in your Wave Terminal settings (separate from the mode configurations in `waveai.json`).\n\n### Setting a Default AI Mode\n\nAfter configuring a local model or custom mode, you can make it the default by setting `waveai:defaultmode` in your Wave Terminal settings.\n\n:::important\nUse 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).\n:::\n\n**Using the settings command:**\n```bash\nwsh setconfig waveai:defaultmode=\"ollama-llama\"\n```\n\n**Or edit settings.json directly:**\n1. Click the settings (gear) icon in the widget bar\n2. Select \"Settings\" from the menu\n3. Add the `waveai:defaultmode` key to your settings.json:\n```json\n  \"waveai:defaultmode\": \"ollama-llama\"\n```\n\nThis will make the specified mode the default selection when opening Wave AI features.\n\n:::note\nWave 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\"/>\n:::\n\n### Hiding Wave Cloud Modes\n\nIf 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`:\n\n**Using the settings command:**\n```bash\nwsh setconfig waveai:showcloudmodes=false\n```\n\n**Or edit settings.json directly:**\n1. Click the settings (gear) icon in the widget bar\n2. Select \"Settings\" from the menu\n3. Add the `waveai:showcloudmodes` key to your settings.json:\n```json\n  \"waveai:showcloudmodes\": false\n```\n\nThis will hide Wave's built-in cloud AI modes, showing only your custom configured modes.\n\n## Local Model Examples\n\n### Ollama\n\n[Ollama](https://ollama.ai) provides an OpenAI-compatible API for running models locally:\n\n```json\n{\n  \"ollama-llama\": {\n    \"display:name\": \"Ollama - Llama 3.3\",\n    \"display:order\": 1,\n    \"display:icon\": \"microchip\",\n    \"display:description\": \"Local Llama 3.3 70B model via Ollama\",\n    \"ai:apitype\": \"openai-chat\",\n    \"ai:model\": \"llama3.3:70b\",\n    \"ai:thinkinglevel\": \"medium\",\n    \"ai:endpoint\": \"http://localhost:11434/v1/chat/completions\",\n    \"ai:apitoken\": \"ollama\"\n  }\n}\n```\n\n:::tip\nThe `ai:apitoken` field is required but Ollama ignores it - you can set it to any value like `\"ollama\"`.\n:::\n\n### LM Studio\n\n[LM Studio](https://lmstudio.ai) provides a local server that can run various models:\n\n```json\n{\n  \"lmstudio-qwen\": {\n    \"display:name\": \"LM Studio - Qwen\",\n    \"display:order\": 2,\n    \"display:icon\": \"server\",\n    \"display:description\": \"Local Qwen model via LM Studio\",\n    \"ai:apitype\": \"openai-chat\",\n    \"ai:model\": \"qwen/qwen-2.5-coder-32b-instruct\",\n    \"ai:thinkinglevel\": \"medium\",\n    \"ai:endpoint\": \"http://localhost:1234/v1/chat/completions\",\n    \"ai:apitoken\": \"not-needed\"\n  }\n}\n```\n\n### vLLM\n\n[vLLM](https://docs.vllm.ai) is a high-performance inference server with OpenAI API compatibility:\n\n```json\n{\n  \"vllm-local\": {\n    \"display:name\": \"vLLM\",\n    \"display:order\": 3,\n    \"display:icon\": \"server\",\n    \"display:description\": \"Local model via vLLM\",\n    \"ai:apitype\": \"openai-chat\",\n    \"ai:model\": \"your-model-name\",\n    \"ai:thinkinglevel\": \"medium\",\n    \"ai:endpoint\": \"http://localhost:8000/v1/chat/completions\",\n    \"ai:apitoken\": \"not-needed\"\n  }\n}\n```\n\n## Cloud Provider Examples\n\n### OpenAI\n\nUsing the `openai` provider automatically configures the endpoint and secret name:\n\n```json\n{\n  \"openai-gpt4o\": {\n    \"display:name\": \"GPT-4o\",\n    \"ai:provider\": \"openai\",\n    \"ai:model\": \"gpt-4o\"\n  }\n}\n```\n\nThe provider automatically sets:\n- `ai:endpoint` to `https://api.openai.com/v1/chat/completions`\n- `ai:apitype` to `openai-chat` (or `openai-responses` for GPT-5+ models)\n- `ai:apitokensecretname` to `OPENAI_KEY` (store your OpenAI API key with this name)\n- `ai:capabilities` to `[\"tools\", \"images\", \"pdfs\"]` (automatically determined based on model)\n\nFor newer models like GPT-4.1 or GPT-5, the API type is automatically determined:\n\n```json\n{\n  \"openai-gpt41\": {\n    \"display:name\": \"GPT-4.1\",\n    \"ai:provider\": \"openai\",\n    \"ai:model\": \"gpt-4.1\"\n  }\n}\n```\n\n### OpenAI Compatible\n\nTo use an OpenAI compatible API provider, you need to provide the ai:endpoint, ai:apitokensecretname, ai:model parameters,\nand use \"openai-chat\" as the ai:apitype.\n\n:::note\nThe ai:endpoint is *NOT* a baseurl. The endpoint should contain the full endpoint, not just the baseurl.\nFor example: https://api.x.ai/v1/chat/completions\n\nIf you provide only the baseurl, you are likely to get a 404 message.\n:::\n\n```json\n{\n  \"xai-grokfast\": {\n    \"display:name\": \"xAI Grok Fast\",\n    \"display:order\": 2,\n    \"display:icon\": \"server\",\n    \"ai:apitype\": \"openai-chat\",\n    \"ai:model\": \"grok-4-1-fast-reasoning\",\n    \"ai:endpoint\": \"https://api.x.ai/v1/chat/completions\",\n    \"ai:apitokensecretname\": \"XAI_KEY\",\n    \"ai:capabilities\": [\"tools\", \"images\", \"pdfs\"]\n  }\n}\n```\n\nThe `ai:apitokensecretname` should be the name of an environment variable that contains your API key. Set this environment variable before running Wave Terminal.\n\n\n### OpenRouter\n\n[OpenRouter](https://openrouter.ai) provides access to multiple AI models. Using the `openrouter` provider simplifies configuration:\n\n```json\n{\n  \"openrouter-qwen\": {\n    \"display:name\": \"OpenRouter - Qwen\",\n    \"ai:provider\": \"openrouter\",\n    \"ai:model\": \"qwen/qwen-2.5-coder-32b-instruct\"\n  }\n}\n```\n\nThe provider automatically sets:\n- `ai:endpoint` to `https://openrouter.ai/api/v1/chat/completions`\n- `ai:apitype` to `openai-chat`\n- `ai:apitokensecretname` to `OPENROUTER_KEY` (store your OpenRouter API key with this name)\n\n:::note\nFor OpenRouter, you must manually specify `ai:capabilities` based on your model's features. Example:\n```json\n{\n  \"openrouter-qwen\": {\n    \"display:name\": \"OpenRouter - Qwen\",\n    \"ai:provider\": \"openrouter\",\n    \"ai:model\": \"qwen/qwen-2.5-coder-32b-instruct\",\n    \"ai:capabilities\": [\"tools\"]\n  }\n}\n```\n:::\n\n### NanoGPT\n\n[NanoGPT](https://nano-gpt.com) provides access to multiple AI models at competitive prices. Using the `nanogpt` provider simplifies configuration:\n\n```json\n{\n  \"nanogpt-glm47\": {\n    \"display:name\": \"NanoGPT - GLM 4.7\",\n    \"ai:provider\": \"nanogpt\",\n    \"ai:model\": \"zai-org/glm-4.7\"\n  }\n}\n```\n\nThe provider automatically sets:\n- `ai:endpoint` to `https://nano-gpt.com/api/v1/chat/completions`\n- `ai:apitype` to `openai-chat`\n- `ai:apitokensecretname` to `NANOGPT_KEY` (store your NanoGPT API key with this name)\n\n:::note\nNanoGPT 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:\n```json\n{\n  \"nanogpt-glm47\": {\n    \"display:name\": \"NanoGPT - GLM 4.7\",\n    \"ai:provider\": \"nanogpt\",\n    \"ai:model\": \"zai-org/glm-4.7\",\n    \"ai:capabilities\": [\"tools\"]\n  }\n}\n```\nFor vision-capable models like `openai/gpt-5`, add `\"images\"` to capabilities.\n:::\n\n### Groq\n\n[Groq](https://groq.com) provides fast inference for open models through an OpenAI-compatible API. Using the `groq` provider simplifies configuration:\n\n```json\n{\n  \"groq-kimi-k2\": {\n    \"display:name\": \"Groq - Kimi K2\",\n    \"ai:provider\": \"groq\",\n    \"ai:model\": \"moonshotai/kimi-k2-instruct\"\n  }\n}\n```\n\nThe provider automatically sets:\n- `ai:endpoint` to `https://api.groq.com/openai/v1/chat/completions`\n- `ai:apitype` to `openai-chat`\n- `ai:apitokensecretname` to `GROQ_KEY` (store your Groq API key with this name)\n\n:::note\nFor Groq, you must manually specify `ai:capabilities` based on your model's features.\n:::\n\n### Google AI (Gemini)\n\n[Google AI](https://ai.google.dev) provides the Gemini family of models. Using the `google` provider simplifies configuration:\n\n```json\n{\n  \"google-gemini\": {\n    \"display:name\": \"Gemini 3 Pro\",\n    \"ai:provider\": \"google\",\n    \"ai:model\": \"gemini-3-pro-preview\"\n  }\n}\n```\n\nThe provider automatically sets:\n- `ai:endpoint` to `https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent`\n- `ai:apitype` to `google-gemini`\n- `ai:apitokensecretname` to `GOOGLE_AI_KEY` (store your Google AI API key with this name)\n- `ai:capabilities` to `[\"tools\", \"images\", \"pdfs\"]` (automatically configured)\n\n### Azure OpenAI (Modern API)\n\nFor the modern Azure OpenAI API, use the `azure` provider:\n\n```json\n{\n  \"azure-gpt4\": {\n    \"display:name\": \"Azure GPT-4\",\n    \"ai:provider\": \"azure\",\n    \"ai:model\": \"gpt-4\",\n    \"ai:azureresourcename\": \"your-resource-name\"\n  }\n}\n```\n\nThe provider automatically sets:\n- `ai:endpoint` to `https://your-resource-name.openai.azure.com/openai/v1/chat/completions` (or `/responses` for newer models)\n- `ai:apitype` based on the model\n- `ai:apitokensecretname` to `AZURE_OPENAI_KEY` (store your Azure OpenAI key with this name)\n\n:::note\nFor Azure providers, you must manually specify `ai:capabilities` based on your model's features. Example:\n```json\n{\n  \"azure-gpt4\": {\n    \"display:name\": \"Azure GPT-4\",\n    \"ai:provider\": \"azure\",\n    \"ai:model\": \"gpt-4\",\n    \"ai:azureresourcename\": \"your-resource-name\",\n    \"ai:capabilities\": [\"tools\", \"images\"]\n  }\n}\n```\n:::\n\n### Azure OpenAI (Legacy Deployment API)\n\nFor legacy Azure deployments, use the `azure-legacy` provider:\n\n```json\n{\n  \"azure-legacy-gpt4\": {\n    \"display:name\": \"Azure GPT-4 (Legacy)\",\n    \"ai:provider\": \"azure-legacy\",\n    \"ai:azureresourcename\": \"your-resource-name\",\n    \"ai:azuredeployment\": \"your-deployment-name\"\n  }\n}\n```\n\nThe 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.\n\n:::note\nFor Azure Legacy provider, you must manually specify `ai:capabilities` based on your model's features.\n:::\n\n## Using Secrets for API Keys\n\nInstead 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.\n\n### Storing an API Key\n\n**Using the Secrets UI (recommended):**\n1. Click the settings (gear) icon in the widget bar\n2. Select \"Secrets\" from the menu\n3. Click \"Add New Secret\"\n4. Enter the secret name (e.g., `OPENAI_API_KEY`) and your API key\n5. Click \"Save\"\n\n**Or from the command line:**\n```bash\nwsh secret set OPENAI_KEY=sk-xxxxxxxxxxxxxxxx\nwsh secret set OPENROUTER_KEY=sk-xxxxxxxxxxxxxxxx\n```\n\n### Referencing the Secret\n\nWhen using providers like `openai` or `openrouter`, the secret name is automatically set. Just ensure the secret exists with the correct name:\n\n```json\n{\n  \"my-openai-mode\": {\n    \"display:name\": \"OpenAI GPT-4o\",\n    \"ai:provider\": \"openai\",\n    \"ai:model\": \"gpt-4o\"\n  }\n}\n```\n\nThe `openai` provider automatically looks for the `OPENAI_KEY` secret. See the [Secrets documentation](./secrets.mdx) for more information on managing secrets securely in Wave.\n\n## Multiple Modes Example\n\nYou can define multiple AI modes and switch between them easily:\n\n```json\n{\n  \"ollama-llama\": {\n    \"display:name\": \"Ollama - Llama 3.3\",\n    \"display:order\": 1,\n    \"ai:model\": \"llama3.3:70b\",\n    \"ai:endpoint\": \"http://localhost:11434/v1/chat/completions\",\n    \"ai:apitoken\": \"ollama\"\n  },\n  \"ollama-codellama\": {\n    \"display:name\": \"Ollama - CodeLlama\",\n    \"display:order\": 2,\n    \"ai:model\": \"codellama:34b\",\n    \"ai:endpoint\": \"http://localhost:11434/v1/chat/completions\",\n    \"ai:apitoken\": \"ollama\"\n  },\n  \"openai-gpt4o\": {\n    \"display:name\": \"GPT-4o\",\n    \"display:order\": 10,\n    \"ai:provider\": \"openai\",\n    \"ai:model\": \"gpt-4o\"\n  }\n}\n```\n\n## Troubleshooting\n\n### Connection Issues\n\nIf Wave can't connect to your model server:\n\n1. **For cloud providers with `ai:provider` set**: Ensure you have the correct secret stored (e.g., `OPENAI_KEY`, `OPENROUTER_KEY`)\n2. **For local/custom endpoints**: Verify the server is running (`curl http://localhost:11434/v1/models` for Ollama)\n3. Check the `ai:endpoint` is the complete endpoint URL including the path (e.g., `http://localhost:11434/v1/chat/completions`)\n4. Verify the `ai:apitype` matches your server's API (defaults are usually correct when using providers)\n5. Check firewall settings if using a non-localhost address\n\n### Model Not Found\n\nIf you get \"model not found\" errors:\n\n1. Verify the model name matches exactly what your server expects\n2. For Ollama, use `ollama list` to see available models\n3. Some servers require prefixes or specific naming formats\n\n### API Type Selection\n\n- The API type defaults to `openai-chat` if not specified, which works for most providers\n- Use `openai-chat` for Ollama, LM Studio, custom endpoints, and most cloud providers\n- Use `openai-responses` for newer OpenAI models (GPT-5+) or when your provider specifically requires it\n- Provider presets automatically set the correct API type when needed\n\n## Configuration Reference\n\n### Minimal Configuration (with Provider)\n\n```json\n{\n  \"mode-key\": {\n    \"display:name\": \"Qwen (OpenRouter)\",\n    \"ai:provider\": \"openrouter\",\n    \"ai:model\": \"qwen/qwen-2.5-coder-32b-instruct\"\n  }\n}\n```\n\n### Full Configuration (all fields)\n\n```json\n{\n  \"mode-key\": {\n    \"display:name\": \"Display Name\",\n    \"display:order\": 1,\n    \"display:icon\": \"icon-name\",\n    \"display:description\": \"Full description\",\n    \"ai:provider\": \"custom\",\n    \"ai:apitype\": \"openai-chat\",\n    \"ai:model\": \"model-name\",\n    \"ai:thinkinglevel\": \"medium\",\n    \"ai:endpoint\": \"http://localhost:11434/v1/chat/completions\",\n    \"ai:azureapiversion\": \"v1\",\n    \"ai:apitoken\": \"your-token\",\n    \"ai:apitokensecretname\": \"PROVIDER_KEY\",\n    \"ai:azureresourcename\": \"your-resource\",\n    \"ai:azuredeployment\": \"your-deployment\",\n    \"ai:capabilities\": [\"tools\", \"images\", \"pdfs\"]\n  }\n}\n```\n\n### Field Reference\n\n| Field | Required | Description |\n|-------|----------|-------------|\n| `display:name` | Yes | Name shown in the AI mode selector |\n| `display:order` | No | Sort order in the selector (lower numbers first) |\n| `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\" |\n| `display:description` | No | Full description of the mode |\n| `ai:provider` | No | Provider preset: `openai`, `openrouter`, `nanogpt`, `groq`, `google`, `azure`, `azure-legacy`, `custom` |\n| `ai:apitype` | No | API type: `openai-chat`, `openai-responses`, or `google-gemini` (defaults to `openai-chat` if not specified) |\n| `ai:model` | No | Model identifier (required for most providers) |\n| `ai:thinkinglevel` | No | Thinking level: `low`, `medium`, or `high` |\n| `ai:endpoint` | No | *Full* API endpoint URL (auto-set by provider when available) |\n| `ai:azureapiversion` | No | Azure API version (for `azure-legacy` provider, defaults to `2025-04-01-preview`) |\n| `ai:apitoken` | No | API key/token (not recommended - use secrets instead) |\n| `ai:apitokensecretname` | No | Name of secret containing API token (auto-set by provider) |\n| `ai:azureresourcename` | No | Azure resource name (for Azure providers) |\n| `ai:azuredeployment` | No | Azure deployment name (for `azure-legacy` provider) |\n| `ai:capabilities` | No | Array of supported capabilities: `\"tools\"`, `\"images\"`, `\"pdfs\"` |\n| `waveai:cloud` | No | Internal - for Wave Cloud AI configuration only |\n| `waveai:premium` | No | Internal - for Wave Cloud AI configuration only |\n\n### AI Capabilities\n\nThe `ai:capabilities` field specifies what features the AI mode supports:\n\n- **`tools`** - Enables AI tool usage for file reading/writing, shell integration, and widget interaction\n- **`images`** - Allows image attachments in chat (model can view uploaded images)\n- **`pdfs`** - Allows PDF file attachments in chat (model can read PDF content)\n\n**Provider-specific behavior:**\n- **OpenAI and Google providers**: Capabilities are automatically configured based on the model. You don't need to specify them.\n- **OpenRouter, NanoGPT, Groq, Azure, Azure-Legacy, and Custom providers**: You must manually specify capabilities based on your model's features.\n\n:::warning\nIf 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.\n:::\n\nMost 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.\n"
  },
  {
    "path": "docs/docs/waveai.mdx",
    "content": "---\nsidebar_position: 1.5\nid: \"waveai\"\ntitle: \"Wave AI\"\n---\n\nimport { Kbd } from \"@site/src/components/kbd\";\nimport { PlatformProvider, PlatformSelectorButton } from \"@site/src/components/platformcontext\";\n\n<PlatformProvider>\n\n<PlatformSelectorButton />\n\n<br/><br/>\nContext-aware terminal assistant with access to terminal output, widgets, and filesystem.\n\n## Keyboard Shortcuts\n\n| Shortcut | Action |\n|----------|--------|\n| <Kbd k=\"Cmd:Shift:a\"/> | Toggle AI panel |\n| <Kbd k=\"Ctrl:Shift:0\" windows=\"Alt:0\"/> | Focus AI input |\n| <Kbd k=\"Cmd:k\"/> | Clear chat / start new |\n| <Kbd k=\"Enter\"/> | Send message |\n| <Kbd k=\"Shift:Enter\"/> | New line |\n\n## Widget Context Toggle\n\nControls AI's access to your workspace:\n\n**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.\n\n**OFF**: AI only sees your messages and attached files. Standard chat mode for general questions.\n\n## File Attachments\n\nDrag files onto the AI panel to attach (not supported with all models):\n\n| Type | Formats | Size Limit | Notes |\n|------|---------|------------|-------|\n| Images | JPEG, PNG, GIF, WebP, SVG | 10 MB | Auto-resized to 4096px max, converted to WebP |\n| PDFs | `.pdf` | 5 MB | Text extraction for analysis |\n| Text/Code | `.js`, `.ts`, `.py`, `.go`, `.md`, `.json`, `.yaml`, etc. | 200 KB | All common languages and configs |\n\n## CLI Integration\n\nUse `wsh ai` to send files and prompts from the command line:\n\n```bash\ngit diff | wsh ai -                          # Pipe to AI\nwsh ai main.go -m \"find bugs\"                # Attach files with message\nwsh ai $(tail -n 500 my.log) -m \"review\" -s  # Auto-submit with output\n```\n\nSupports text files, images, PDFs, and directories. Use `-n` for new chat, `-s` to auto-submit.\n\n## AI Tools (Widget Context Enabled)\n\n### Terminal\n- **Read Terminal Output**: Fetches scrollback from terminal widgets, supports line ranges\n\n### File System\n- **Read Files**: Reads text files with line range support (requires approval)\n- **List Directories**: Returns file info, sizes, permissions, timestamps (requires approval)\n- **Write Text Files**: Create or modify files with diff preview and approval (requires approval)\n\n### Web\n- **Navigate Web**: Changes URLs in web browser widgets\n\n### All Widgets\n- **Capture Screenshots**: Takes screenshots of any widget for visual analysis (not supported on all models)\n\n:::warning Security\nFile system operations require explicit approval. You control all file access.\n:::\n\n## Local Models & BYOK\n\nWave AI supports using your own AI models and API keys:\n\n- **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\n- **BYOK (Bring Your Own Key)**: Use your own API keys with OpenAI, OpenRouter, Google AI (Gemini), Azure OpenAI, and other cloud providers\n- **Multiple Modes**: Configure and switch between multiple AI providers and models\n- **Privacy**: Keep your data local or use your preferred cloud provider\n\nSee the [**Local Models & BYOK guide**](./waveai-modes.mdx) for complete configuration instructions, examples, and troubleshooting.\n\n## Privacy\n\n**Default Wave AI Service:**\n- 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.\n- Wave does not store your chats, attachments, or use them for training\n- Usage counters included in anonymous telemetry\n- File access requires explicit approval\n\n**Local Models & BYOK:**\n- When using local models, your chat data never leaves your machine\n- When using BYOK with cloud providers, requests are sent directly to your chosen provider\n- Refer to your provider's privacy policy for details on how they handle your data\n\n:::info Under Active Development\nWave AI is in active beta with included AI credits while we refine the experience. Share feedback in our [Discord](https://discord.gg/XfvZ334gwU).\n\n**Coming Soon:**\n- **Remote File Access**: Read files on SSH-connected systems\n- **Command Execution**: Run terminal commands with approval\n- **Web Content**: Extract text from web pages (currently screenshots only)\n:::\n\n</PlatformProvider>"
  },
  {
    "path": "docs/docs/widgets.mdx",
    "content": "---\nsidebar_position: 3.3\nid: \"widgets\"\ntitle: \"Widgets\"\n---\n\nimport { Kbd } from \"@site/src/components/kbd\";\nimport { PlatformProvider, PlatformSelectorButton } from \"@site/src/components/platformcontext\";\n\n<PlatformProvider>\n\n# Widgets\n\nEvery 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.\n\n<PlatformSelectorButton />\n\n### How to Add a Widget\n\nAdding 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.\n\n### How to Close a Widget\n\nWidgets 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\"/>\n\n### How to Navigate Widgets\n\nAt 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.\n\n### How to Magnify Widgets\n\nMagnifying 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\"/>.\n\n### How to Reorganize Widgets\n\nBy 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.\n\nSee [Tab Layout System](./layout#move-a-block) for more information.\n\n### How to Resize Widgets\n\nHovering 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.\n\nSee [Tab Layout System](./layout#resize-a-block) for more information.\n\n## Types of Widgets\n\n### Term\n\nThe usual terminal you know and love. We add a few plugins via the `wsh` command that you can read more about further below.\n\n### Preview\n\nPreview is the generic type of widget used for viewing files. This can take many different forms based on the type of file being viewed.\nYou 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`).\n\n#### Directory\n\nWhen 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.\n\n##### View a New File\n\nThe 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.\n\n##### Copy a File\n\nIf 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.\n\n##### View the Parent Directory\n\nIn 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\"/>.\n\n##### Navigate Back and Forward\n\nWhen 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\" />.\n\n##### Filter the List of Files\n\nWhile 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\" />.\n\n##### Sort by a File Column\n\nTo 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.\n\n##### Hide and Show Hidden Files\n\nAt 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.\n\n##### Refresh the Directory\n\nAt 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.\n\n##### Navigate to Common Directories\n\nAt 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_.\n\n##### Open a New Terminal in the Current Directory\n\nIf 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.\n\n##### Open a New Terminal in a Child Directory\n\nIf 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.\n\n##### Open a New Preview for a Child\n\nTo 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.\n\n##### Quick Look (MacOS only)\n\nOn 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.\n\n#### Markdown\n\nOpening a markdown file will bring up a view of the rendered markdown. These files cannot be edited in the preview at this time.\n\n#### Images/Media\n\nOpening a picture will bring up the image of that picture. Opening a video will bring up a player that lets you watch the video.\n\n### Codeedit\n\nOpening 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.\nAfter 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!).\n\n#### Switch to Edit Mode\n\nTo 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.\nYou can also switch to edit mode by pressing <Kbd k=\"Cmd:e\" />.\n\n#### Save an Edit\n\nOnce an edit has been made in **edit mode**, click the save button to the right of the header to save the contents.\nYou can also save by pressing <Kbd k=\"Cmd:s\" />.\n\n#### Exit Edit Mode Without Saving\n\nTo exit **edit mode** without saving, click the cancel button to the right of the header.\nYou can also exit without saving by pressing <Kbd k=\"Cmd:r\" />.\n\n</PlatformProvider>\n"
  },
  {
    "path": "docs/docs/workspaces.mdx",
    "content": "---\nsidebar_position: 3\nid: \"workspaces\"\ntitle: \"Workspaces\"\n---\n\n# Workspaces\n\nWorkspaces are a powerful way to organize your workflows into separate environments, which you can tailor and optimize.\n\n## Workspace Switcher\n\n![Workspace switcher screenshot](./img/workspace-switcher.png#right)\n\nThe primary mechanism to interact with workspaces is via the Workspace Switcher, located to the left of the tab bar.\n\nThis is where you can create a new workspace, edit how a workspace entry appears, and delete a workspace.\n\nThe 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.\n\nThe Switcher contains a list of all saved workspaces for your installation, each with a customizable icon, icon color, and name.\n\nThe 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.\n\nHovering 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.\n\n## Creating a new workspace\n\nEvery 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:\n\n1. 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.\n2. Create a new workspace via the `Workspace` app menu. This will create a new workspace and switch the current window to that workspace.\n3. 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.\n\n## Saving a workspace\n\n:::info\n\nA new workspace is ephemeral. When a window closes, its workspace, along with all its tabs, is deleted unless the workspace is saved.\n\nThe 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.\n\n:::\n\nTo 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.\n\nIf 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\"/>.\n\nOnce 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.\n\n## Switching workspaces\n\nThere are two ways to switch workspaces:\n\n1. From an open window, you can open the Workspace Switcher and click on a workspace from the list.\n2. From the Workspace app menu, click on a workspace from the list.\n\nIf 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.\n\nIf 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.**\n\nThe Workspace Switcher button will update with the colored icon for your new active workspace.\n\n## Edit a workspace\n\n:::info\n\nThe 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.\n\n:::\n\nTo 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.\n"
  },
  {
    "path": "docs/docs/wsh-reference.mdx",
    "content": "---\nsidebar_position: 4.1\nid: \"wsh-reference\"\ntitle: \"wsh reference\"\n---\n\nimport { Kbd } from \"@site/src/components/kbd\";\nimport { PlatformProvider, PlatformSelectorButton } from \"@site/src/components/platformcontext\";\nimport { VersionBadge } from \"@site/src/components/versionbadge\";\n\n<PlatformProvider>\n\n# wsh command\n\nThe `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.\n\nThis is the detailed wsh reference documention. For an overview of `wsh` functionality, please see our [wsh command docs](/wsh).\n\n---\n\n## view\n\nYou can open a preview block with the contents of any file or directory by running:\n\n```sh\nwsh view [path]\nwsh view -m [path]           # opens in magnified block\n```\n\nYou can use this command to easily preview images, markdown files, and directories. For code/text files this will open\na codeedit block which you can use to quickly edit the file using Wave's embedded graphical editor.\n\n---\n\n## edit\n\n```sh\nwsh edit [path]\nwsh edit -m [path]           # opens in magnified block\n```\n\nThis 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. \n\nFor `$EDITOR` integration (e.g. with `git commit`), see [`wsh editor`](#editor) which blocks until the editor is closed.\n\n---\n\n## editor\n\n```sh\nwsh editor [path]\nwsh editor -m [path]         # opens in magnified block\n```\n\nThis 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:\n\n```sh\nexport EDITOR=\"wsh editor\"\n```\n\nThe file must already exist. Use `-m` to open the editor in magnified mode.\n\n---\n\n## getmeta\n\nYou can view the metadata of any block or tab by running:\n\n```sh\n# get the metadata for the current terminal block\nwsh getmeta\n\n# get the metadata for block num 2 (see block numbers by holidng down Ctrl+Shift)\nwsh getmeta -b 2\n\n# get the metadata for a blockid (get block ids by right clicking any block header \"Copy Block Id\")\nwsh getmeta -b [blockid]\n\n# get the metadata for a tab\nwsh getmeta -b tab\n\n# dump a single metadata key\nwsh getmeta [-b [blockid]] [key]\n\n# dump a set of keys with a certain prefix\nwsh getmeta -b tab \"bg:*\"\n\n# dump a set of keys with prefix (and include the 'clear' key)\nwsh getmeta -b tab --clear-prefix \"bg:*\"\n```\n\nThis 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.\n\nblockid format:\n\n- `this` -- the current block (this is also the default)\n- `tab` -- the id of the current tab\n- `d6ff4966-231a-4074-b78a-20acc7226b41` -- a full blockid is a UUID\n- `a67f55a3` -- blockids may be truncated to the first 8 characters\n- `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.\n\n---\n\n## setmeta\n\nYou 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.\n\n```sh\nwsh setmeta -b [blockid] [key]=[value]\nwsh setmeta -b [blockid] file=~/myfile.txt\nwsh setmeta -b [blockid] url=https://waveterm.dev/\n\n# set the metadata for the current tab using the given json file\nwsh setmeta -b tab --json [jsonfile]\n\n# set the metadata for the current tab using a json file read from stdin\nwsh setmeta -b tab --json\n```\n\nYou 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\nupdate the metadata for a preview or web block you'll see the changes reflected instantly in the block.\n\nOther useful metadata values to override block titles, icons, colors, themes, etc.\n\nHere's a complex command that will copy the background (bg:\\* keys) from one tab to the current tab:\n\n```sh\nwsh getmeta -b [other-tab-id] \"bg:*\" --clear-prefix | wsh setmeta -b tab --json -\n```\n\n---\n\n## ai\n\nAppend 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.\n\nYou 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.\n\n```sh\n# Pipe command output to AI (ask question in UI)\ngit diff | wsh ai -\ndocker logs mycontainer | wsh ai -\n\n# Attach files without auto-submit (review in UI first)\nwsh ai main.go utils.go\nwsh ai screenshot.png logs.txt\n\n# Attach files with message\nwsh ai app.py -m \"find potential bugs\"\nwsh ai *.log -m \"analyze these error logs\"\n\n# Auto-submit immediately\nwsh ai config.json -s -m \"explain this configuration\"\ntail -n 50 app.log | wsh ai -s - -m \"what's causing these errors?\"\n\n# Start new chat and attach files\nwsh ai -n report.pdf data.csv -m \"summarize these reports\"\n\n# Attach different file types (images, PDFs, code)\nwsh ai architecture.png api-spec.pdf server.go -m \"review the system design\"\n```\n\n**File Size Limits:**\n- Text files: 200KB maximum\n- PDF files: 5MB maximum\n- Image files: 7MB maximum (accounts for base64 encoding overhead)\n- Maximum 15 files per command\n\n**Flags:**\n- `-m, --message <text>` - Add message text along with files\n- `-s, --submit` - Auto-submit immediately (default waits for user)\n- `-n, --new` - Clear current chat and start fresh conversation\n\n---\n\n## editconfig\n\nYou can easily open up any of Wave's config files using this command.\n\n```sh\nwsh editconfig [config-file-name]\n\n# opens the default settings.json file\nwsh editconfig\n\n# opens presets.json\nwsh editconfig presets.json\n\n# opens widgets.json\nwsh editconfig widgets.json\n\n# opens ai presets\nwsh editconfig presets/ai.json\n```\n\n---\n\n## setbg\n\nThe `setbg` command allows you to set a background image or color for the current tab with various customization options.\n\n```sh\nwsh setbg [--opacity value] [--tile|--center] [--size value] (image-path|\"#color\"|color-name)\n```\n\nYou can set a background using:\n\n- An image file (displayed as cover, tiled, or centered)\n- A hex color (must be quoted like \"#ff0000\")\n- A CSS color name (like \"blue\" or \"forestgreen\")\n\nFlags:\n\n- `--opacity value` - set the background opacity (0.0-1.0, default 0.5)\n- `--tile` - tile the background image instead of using cover mode\n- `--center` - center the image without scaling (good for logos)\n- `--size` - size for centered images (px, %, or auto)\n- `--clear` - remove the background\n- `--print` - show the metadata without applying it\n\nSupported image formats: JPEG, PNG, GIF, WebP, and SVG.\n\nExamples:\n\n```sh\n# Set an image background with default settings\nwsh setbg ~/pictures/background.jpg\n\n# Set a background with custom opacity\nwsh setbg --opacity 0.3 ~/pictures/light-pattern.png\n\n# Set a tiled background\nwsh setbg --tile --opacity 0.2 ~/pictures/texture.png\n\n# Center an image (good for logos)\nwsh setbg --center ~/pictures/logo.png\nwsh setbg --center --size 200px ~/pictures/logo.png\n\n# Set color backgrounds\nwsh setbg \"#ff0000\"          # hex color (requires quotes)\nwsh setbg forestgreen        # CSS color name\n\n# Change just the opacity of current background\nwsh setbg --opacity 0.7\n\n# Remove background\nwsh setbg --clear\n\n# Preview the metadata\nwsh setbg --print \"#ff0000\"\n```\n\nThe command validates that:\n\n- Color values are valid hex codes or CSS color names\n- Image paths point to accessible, supported image files\n- The opacity value is between 0.0 and 1.0\n- The center and tile options are not used together\n\n:::tip\nUse `--print` to preview the metadata for any background configuration without applying it. You can then copy this JSON representation to use as a [Background Preset](/presets#background-configurations)\n:::\n\n---\n\n## badge\n\n<VersionBadge version=\"v0.14.2\" />\n\nThe `badge` command sets or clears a visual badge indicator on a block or tab header.\n\n```sh\nwsh badge [icon]\nwsh badge --clear\n```\n\nBadges 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).\n\nFlags:\n\n- `--color string` - set the badge color (CSS color name or hex)\n- `--priority float` - set the badge priority (default 10; higher priority badges take precedence)\n- `--clear` - remove the badge from the block or tab\n- `--beep` - play the system bell sound when setting the badge\n- `--pid int` - watch a PID and automatically clear the badge when it exits (sets default priority to 5)\n- `-b, --block` - target a specific block or tab (same format as `getmeta`)\n\nExamples:\n\n```sh\n# Set a default badge on the current block\nwsh badge\n\n# Set a badge with a custom icon and color\nwsh badge circle-check --color green\n\n# Set a high-priority badge on a specific block\nwsh badge triangle-exclamation --color red --priority 20 -b 2\n\n# Set a badge that clears when a process exits\nwsh badge --pid 12345\n\n# Play the bell and set a badge when done\nwsh badge circle-check --beep\n\n# Clear the badge on the current block\nwsh badge --clear\n\n# Clear the badge on a specific tab\nwsh badge --clear -b tab\n```\n\n:::note\nThe `--pid` flag is not supported on Windows.\n:::\n\n---\n\n## run\n\nThe `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.\n\n```sh\n# Run a command specified after --\nwsh run -- ls -la\n\n# Run a command using -c flag\nwsh run -c \"ls -la\"\n\n# Run with working directory specified\nwsh run --cwd /path/to/dir -- ./script.sh\n\n# Run in magnified mode\nwsh run -m -- make build\n\n# Run and auto-close on successful completion\nwsh run -x -- npm test\n\n# Run and auto-close regardless of exit status\nwsh run -X -- ./long-running-task.sh\n```\n\nThe command inherits the current environment variables and working directory by default.\n\nFlags:\n\n- `-m, --magnified` - open the block in magnified mode\n- `-c, --command string` - run a command string in _shell_\n- `-x, --exit` - close block if command exits successfully (stays open if there was an error)\n- `-X, --forceexit` - close block when command exits, regardless of exit status\n- `--delay int` - if using -x/-X, delay in milliseconds before closing block (default 2000)\n- `-p, --paused` - create block in paused state\n- `-a, --append` - append output on command restart instead of clearing\n- `--cwd string` - set working directory for command\n\nExamples:\n\n```sh\n# Run a build command in magnified mode\nwsh run -m -- npm run build\n\n# Execute a script and auto-close after success\nwsh run -x -- ./backup-script.sh\n\n# Run a command in a specific directory\nwsh run --cwd ./project -- make test\n\n# Run a shell command and force close after completion\nwsh run -X -c \"find . -name '*.log' -delete\"\n\n# Start a command in paused state\nwsh run -p -- ./server --dev\n\n# Run with custom close delay\nwsh run -x --delay 5000 -- ./deployment.sh\n```\n\nWhen 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).\n\nThe `-p` flag creates the block in a paused state, allowing you to review the command before execution.\n\n:::tip\nYou 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.\n:::\n\n---\n\n## deleteblock\n\n```sh\nwsh deleteblock -b [blockid]\n```\n\nThis will delete the block with the specified id.\n\n---\n\n## ssh\n\n```sh\nwsh ssh [user@host]\n```\n\nThis 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.\n\n---\n\n## wsl\n\n```sh\nwsh wsl [-d <distribution-name>]\n```\n\nThis will connect to a WSL distribution on the local machine. It will use the default if no distribution is provided.\n\n---\n\n## web\n\nThe `web` command opens URLs in a web block within Wave Terminal.\n\n```sh\nwsh web open [url] [-m] [-r blockid]\n```\n\nYou can open a specific URL or perform a search using the configured search engine.\n\nFlags:\n\n- `-m, --magnified` - open the web block in magnified mode\n- `-r, --replace <blockid>` - replace an existing block instead of creating a new one\n\nExamples:\n\n```sh\n# Open a URL\nwsh web open https://waveterm.dev\n\n# Search with the configured search engine\nwsh web open \"wave terminal documentation\"\n\n# Open in magnified mode\nwsh web open -m https://github.com\n\n# Replace an existing block\nwsh web open -r 2 https://example.com\n```\n\nThe 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.\n\n---\n\n## notify\n\nThe `notify` command creates a desktop notification from Wave Terminal.\n\n```sh\nwsh notify [message] [-t title] [-s]\n```\n\nThis 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.\n\nFlags:\n\n- `-t, --title string` - set the notification title (default \"Wsh Notify\")\n- `-s, --silent` - disable the notification sound\n\nExamples:\n\n```sh\n# Basic notification\nwsh notify \"Build completed successfully\"\n\n# Notification with custom title\nwsh notify -t \"Deployment Status\" \"Production deployment finished\"\n\n# Silent notification\nwsh notify -s \"Background task completed\"\n```\n\nThis is particularly useful for long-running commands where you want to be notified of completion or status changes.\n\n---\n\n## conn\n\nThis has several subcommands which all perform various features related to connections.\n\n### status\n\n```sh\nwsh conn status\n```\n\nThis command gives the status of all connections made since waveterm started.\n\n### reinstall\n\nFor ssh connections,\n\n```sh\nwsh conn reinstall [user@host]\n```\n\nFor wsl connections,\n\n```sh\nwsh conn reinstall [wsl://<distribution-name>]\n```\n\nThis command reinstalls the Wave Shell Extensions on the specified connection.\n\n### disconnect\n\nFor ssh connections,\n\n```sh\nwsh conn disconnect [user@host]\n```\n\nFor wsl connections,\n\n```sh\nwsh conn disconnect [wsl://<distribution name>]\n```\n\nThis command completely disconnects the specified connection. This will apply to all blocks where the connection is being used\n\n### connect\n\nFor ssh connections,\n\n```sh\nwsh conn connect [user@host]\n```\n\nFor wsl connections,\n\n```sh\nwsh conn connect [wsl://<distribution-name>]\n```\n\nThis command connects to the specified connection but does not create a block for it.\n\n### ensure\n\nFor ssh connections,\n\n```sh\nwsh conn ensure [user@host]\n```\n\nFor wsl connections,\n\n```sh\nwsh conn ensure [wsl://<distribution-name>]\n```\n\nThis command connects to the specified connection if it isn't already connected.\n\n---\n\n## setconfig\n\n```sh\nwsh setconfig [<config-name>=<config-value>]\n```\n\nThis allows setting various options in the `config/settings.json` file. It will check to be sure a valid config option was provided.\n\n---\n\n## file\n\nThe `file` command provides a set of subcommands for managing files across different storage systems, such as `wsh` remote servers.\n\n:::note\n\nWave Terminal is capable of managing files from remote SSH hosts. Files are addressed via URIs, which\nvary depending on the storage system. If no scheme is specified, the file will be treated as a local connection.\n\nURI format: `[profile]:[uri-scheme]://[connection]/[path]`\n\nSupported URI schemes:\n\n- `wsh` - Used to access files on remote hosts over SSH via the WSH helper. Allows for file streaming to Wave and other remotes.\n\n  Profiles are optional for WSH URIs, provided that you have configured the remote host in your \"connections.json\" or \"~/.ssh/config\" file.\n\n  If a profile is provided, it must be defined in \"profiles.json\" in the Wave configuration directory.\n\n  Format: `wsh://[remote]/[path]`\n\n  Shorthands can be used for the current remote and your local computer:\n  `[path]` a relative or absolute path on the current remote\n  `//[remote]/[path]` a path on a remote\n  `/~/[path]` a path relative to the home directory on your local computer\n\n:::\n\n### cat\n\n```sh\nwsh file cat [file-uri]\n```\n\nDisplay the contents of a file (maximum file size 10MB). For example:\n\n```sh\nwsh file cat wsh://user@ec2/home/user/config.txt\nwsh file cat ./local-config.txt\n```\n\n### write\n\n```sh\nwsh file write [file-uri]\n```\n\nWrite data from stdin to a file. The maximum file size is 10MB. For example:\n\n```sh\necho \"hello\" | wsh file write ./greeting.txt\ncat config.json | wsh file write //ec2-user@remote01/~/config.json\n```\n\n### append\n\n```sh\nwsh file append [file-uri]\n```\n\nAppend data from stdin to a file. Input is buffered locally (up to 10MB total file size limit) before being written. For example:\n\n```sh\ncat additional-content.txt | wsh file append ./notes.txt\necho \"new line\" | wsh file append //user@remote/~/notes.txt\n```\n\n### rm\n\n```sh\nwsh file rm [flag] [file-uri]\n```\n\nRemove a file. For example:\n\n```sh\nwsh file rm wsh://user@ec2/home/user/config.txt\nwsh file rm ./local-config.txt\n```\n\nFlags:\n\n- `-r, --recursive` - recursively deletes directory entries\n\n### info\n\n```sh\nwsh file info [file-uri]\n```\n\nDisplay information about a file including size, creation time, modification time, and metadata. For example:\n\n```sh\nwsh file info wsh://user@ec2/home/user/config.txt\nwsh file info ./local-config.txt\n```\n\n### cp\n\n```sh\nwsh file cp [flags] [source-uri] [destination-uri]\n```\n\nCopy files between different storage systems (maximum file size 10MB). For example:\n\n```sh\n# Copy a remote file to your local filesystem\nwsh file cp wsh://user@ec2/home/user/config.txt ./local-config.txt\n\n# Copy a local file to a remote system\nwsh file cp ./local-config.txt wsh://user@ec2/home/user/config.txt\n\n# Copy between remote systems\nwsh file cp wsh://user@ec2/home/user/config.txt wsh://user@server2/home/user/backup.txt\n```\n\nFlags:\n\n- `-f, --force` - overwrites any conflicts when copying\n- `-m, --merge` - does not clear existing directory entries when copying a directory, instead merging its contents with the destination's\n\n### mv\n\n```sh\nwsh file mv [flags] [source-uri] [destination-uri]\n```\n\nMove files between different storage systems (maximum file size 10MB). The source file will be deleted once the operation completes successfully. For example:\n\n```sh\n# Move a remote file to your local filesystem\nwsh file mv wsh://user@ec2/home/user/config.txt ./local-config.txt\n\n# Move a local file to a remote system\nwsh file mv ./local-config.txt wsh://user@ec2/home/user/config.txt\n\n# Move between remote systems\nwsh file mv wsh://user@ec2/home/user/config.txt wsh://user@server2/home/user/backup.txt\n```\n\nFlags:\n\n- `-f, --force` - overwrites any conflicts when moving\n\n### ls\n\n```sh\nwsh file ls [flags] [file-uri]\n```\n\nList files in a directory. By default, lists files in the current directory for the current terminal session.\n\nExamples:\n\n```sh\nwsh file ls wsh://user@ec2/home/user/\nwsh file ls ./local-dir/\n```\n\nFlags:\n\n- `-l, --long` - use long listing format showing size, timestamps, and metadata\n- `-1, --one` - list one file per line\n- `-f, --files` - list only files (no directories)\n\nWhen output is piped to another command, automatically switches to one-file-per-line format:\n\n```sh\n# Easy to process with grep, awk, etc.\nwsh file ls ./ | grep \".json$\"\n```\n\n---\n\n## launch\n\nThe `wsh launch` command allows you to open pre-configured widgets directly from your terminal.\n\n```sh\nwsh launch [flags] widget-id\n```\n\nThe 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.\n\nFlags:\n\n- `-m, --magnify` - open the widget in magnified mode, overriding the widget's default magnification setting\n\nExamples:\n\n```sh\n# Launch a widget with its default settings\nwsh launch my-custom-widget\n\n# Launch a widget in magnified mode\nwsh launch -m system-monitor\n```\n\nThe 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.\n\n:::tip\nWidget configurations can be customized in your `widgets.json` configuration file, which you can edit using `wsh editconfig widgets.json`\n:::\n\n---\n\n## getvar/setvar\n\nWave Terminal provides commands for managing persistent variables at different scopes (block, tab, workspace, or client-wide).\n\n### setvar\n\n```sh\nwsh setvar [flags] KEY=VALUE...\n```\n\nSet one or more variables. By default, variables are set at the client (global) level. Use `-l` for block-local variables.\n\nExamples:\n\n```sh\n# Set a single variable\nwsh setvar API_KEY=abc123\n\n# Set multiple variables at once\nwsh setvar HOST=localhost PORT=8080 DEBUG=true\n\n# Set a block-local variable\nwsh setvar -l BLOCK_SPECIFIC=value\n\n# Remove variables\nwsh setvar -r API_KEY PORT\n```\n\nFlags:\n\n- `-l, --local` - set variables local to the current block\n- `-r, --remove` - remove the specified variables instead of setting them\n- `--varfile string` - use a different variable file (default \"var\")\n- `-b [blockid]` - used to set a specific zone (block, tab, workspace, client, or UUID)\n\n### getvar\n\n```sh\nwsh getvar [flags] [key]\n```\n\nGet the value of a variable. Returns exit code 0 if the variable exists, 1 if it doesn't. This allows for shell scripting like:\n\n```sh\n# Check if a variable exists\nif wsh getvar API_KEY >/dev/null; then\n    echo \"API key is set\"\nfi\n\n# Use a variable in a command\ncurl -H \"Authorization: $(wsh getvar API_KEY)\" https://api.example.com\n\n# Get a block-local variable\nwsh getvar -l BLOCK_SPECIFIC\n\n# List all variables\nwsh getvar --all\n\n# List all variables with null terminators (for scripting)\nwsh getvar --all -0\n```\n\nFlags:\n\n- `-l, --local` - get variables local to the current block\n- `--all` - list all variables\n- `-0, --null` - use null terminators in output instead of newlines\n- `--varfile string` - use a different variable file (default \"var\")\n\nVariables can be accessed at different scopes using the `-b` flag:\n\n```sh\n# Get/set at block level\nwsh getvar -b block MYVAR\nwsh setvar -b block MYVAR=value\n\n# Get/set at tab level\nwsh getvar -b tab MYVAR\nwsh setvar -b tab MYVAR=value\n\n# Get/set at workspace level\nwsh getvar -b workspace MYVAR\nwsh setvar -b workspace MYVAR=value\n\n# Get/set at client (global) level\nwsh getvar -b client MYVAR\nwsh setvar -b client MYVAR=value\n```\n\nVariables 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.\n\n---\n\n## termscrollback\n\nGet the terminal scrollback from a terminal block. This is useful for capturing terminal output for processing or archiving.\n\n```sh\nwsh termscrollback [-b blockid] [flags]\n```\n\nBy default, retrieves all lines from the current terminal block. You can specify line ranges or get only the output of the last command.\n\nFlags:\n\n- `-b, --block <blockid>` - specify target terminal block (default: current block)\n- `--start <line>` - starting line number (0 = beginning, default: 0)\n- `--end <line>` - ending line number (0 = all lines, default: 0)\n- `--lastcommand` - get output of last command (requires shell integration)\n- `-o, --output <file>` - write output to file instead of stdout\n\nExamples:\n\n```sh\n# Get all scrollback from current terminal\nwsh termscrollback\n\n# Get scrollback from a specific terminal block\nwsh termscrollback -b 2\n\n# Get only the last command's output\nwsh termscrollback --lastcommand\n\n# Get a specific line range (lines 100-200)\nwsh termscrollback --start 100 --end 200\n\n# Save scrollback to a file\nwsh termscrollback -o terminal-log.txt\n\n# Save last command output to a file\nwsh termscrollback --lastcommand -o last-output.txt\n\n# Process last command output with grep\nwsh termscrollback --lastcommand | grep \"ERROR\"\n```\n\n:::note\nThe `--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.\n:::\n\n---\n\n## wavepath\n\nThe `wavepath` command lets you get the paths to various Wave Terminal directories and files, including configuration, data storage, and logs.\n\n```sh\nwsh wavepath {config|data|log}\n```\n\nThis 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.\n\nFlags:\n\n- `-o, --open` - open the path in a new block\n- `-O, --open-external` - open the path in the default external application\n- `-t, --tail` - show the last ~100 lines of the log file (only valid for log path)\n\nExamples:\n\n```sh\n# Get path to config directory\nwsh wavepath config\n\n# Get path to data directory\nwsh wavepath data\n\n# Get path to log file\nwsh wavepath log\n\n# Open log file in a new block\nwsh wavepath -o log\n\n# Open config directory in system file explorer\nwsh wavepath -O config\n\n# View recent log entries\nwsh wavepath -t log\n```\n\nThe command will show you the full path to:\n\n- `config` - Where Wave Terminal stores its configuration files\n- `data` - Where Wave Terminal stores its persistent data\n- `log` - The main Wave Terminal log file\n\n:::tip\nUse 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.\n:::\n\n---\n\n## blocks\n\nThe `blocks` command provides operations for listing and querying blocks across workspaces, windows, and tabs. Primarily useful for debugging and scripting.\n\n### list\n\n```sh\nwsh blocks list [flags]\n```\n\nList all blocks with optional filtering by workspace, window, tab, or view type. Output can be formatted as a table (default) or JSON for scripting.\n\nFlags:\n- `--workspace <id>` - restrict to specific workspace id\n- `--window <id>` - restrict to specific window id\n- `--tab <id>` - restrict to specific tab id\n- `--view <type>` - filter by view type (term, web, preview, edit, sysinfo, waveai)\n- `--json` - output results as JSON\n- `--timeout <ms>` - RPC timeout in milliseconds (default: 5000)\n\nExamples:\n\n```sh\n# List all blocks\nwsh blocks list\n\n# List only terminal blocks\nwsh blocks list --view=term\n\n# Filter by workspace\nwsh blocks list --workspace=12d0c067-378e-454c-872e-77a314248114\n\n# Output as JSON for scripting\nwsh blocks list --json\n```\n\n\n---\n\n## secret\n\nThe `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).\n\nSecret names must start with a letter and contain only letters, numbers, and underscores.\n\n### get\n\n```sh\nwsh secret get [name]\n```\n\nRetrieve and display the value of a stored secret.\n\nExamples:\n\n```sh\n# Get an API key\nwsh secret get github_token\n\n# Use in scripts\nexport API_KEY=$(wsh secret get my_api_key)\n```\n\n### set\n\n```sh\nwsh secret set [name]=[value]\n```\n\nStore 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.\n\nExamples:\n\n```sh\n# Set an API token\nwsh secret set github_token=ghp_abc123xyz\n\n# Set a database password\nwsh secret set db_password=mySecurePassword123\n```\n\n:::warning\nThe `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.\n:::\n\n### list\n\n```sh\nwsh secret list\n```\n\nDisplay all stored secret names (values are not shown).\n\nExample:\n\n```sh\n# List all secrets\nwsh secret list\n```\n\n### delete\n\n```sh\nwsh secret delete [name]\n```\n\nRemove a secret from secure storage.\n\nExamples:\n\n```sh\n# Delete an API key\nwsh secret delete github_token\n\n# Delete multiple secrets\nwsh secret delete old_api_key\nwsh secret delete temp_token\n```\n\n### ui\n\n```sh\nwsh secret ui [-m]\n```\n\nOpen the secrets management interface in a new block. This provides a graphical interface for viewing and managing all your secrets.\n\nFlags:\n\n- `-m, --magnified` - open the secrets UI in magnified mode\n\nExamples:\n\n```sh\n# Open the secrets UI\nwsh secret ui\n\n# Open the secrets UI in magnified mode\nwsh secret ui -m\n```\n\nThe secrets UI provides a convenient visual way to browse, add, edit, and delete secrets without needing to use the command-line interface.\n\n:::tip\nUse 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.\n:::\n</PlatformProvider>\n"
  },
  {
    "path": "docs/docs/wsh.mdx",
    "content": "---\nsidebar_position: 4\nid: \"wsh\"\ntitle: \"wsh overview\"\n---\n\nThe `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.\n\nSee the [wsh reference](/wsh-reference) for a list of all wsh commands and their arguments.\n\n## Overview\n\nAt its core, `wsh` enables seamless interaction between your terminal commands and Wave's graphical blocks. It allows you to:\n\n- Control graphical widgets directly from the command line\n- Share data between terminal sessions and GUI components\n- Manage your workspace programmatically\n- Connect remote and local environments\n- Send CLI output and files directly to AI conversations\n- Run terminal commands in separate, isolated blocks\n\n## Key Concepts\n\n### Interacting with Blocks\n\n`wsh` provides direct interaction with Wave's graphical blocks through the command line. For example:\n\n```bash\n# Open a file in the editor\nwsh edit config.json\n\n# Get the current file path from a preview block\nwsh getmeta -b 2 file\n\n# Send output to an AI assistant (the \"-\" reads from stdin)\nls -la | wsh ai - \"what are the largest files here?\"\n```\n\n### Persistent State\n\n`wsh` can maintain state across terminal sessions through its variable system:\n\n```bash\n# Store a variable that persists across sessions\nwsh setvar API_KEY=abc123\n\n# Store globally\nwsh setvar DEPLOY_ENV=prod\n# Or store in the current workspace\nwsh setvar -b workspace DEPLOY_ENV=staging\n\n# Use stored variables in commands\ncurl -H \"Authorization: $(wsh getvar API_KEY)\" https://api.example.com\n```\n\n### Accessing Local Files from Remote\n\nWhen 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/~/`:\n\n```bash\n# Read a local file from a remote machine\nwsh file cat wsh://local/~/config/app.json\n\n# Run a local script on the remote machine using shell process substitution\nbash <(wsh file cat wsh://local/~/scripts/deploy.sh)\npython <(wsh file cat wsh://local/~/scripts/deploy.py)\n\n# Append remote output to a local log file\necho \"Remote machine log entry\" | wsh file append wsh://local/~/app.log\n\n# Copy a local file to the remote machine\nwsh file cp wsh://local/~/data.csv ./remote-data.csv\n\n# Copy remote file back to local machine\nwsh file cp ./results.txt wsh://local/~/results.txt\n\n# You can also use the shorthand /~/ instead of wsh://local/~/\nwsh file cat /~/config/app.json\n```\n\n### Block Management\n\nEvery visual element in Wave is a block, and `wsh` gives you complete control over them (hold Ctrl+Shift to see block numbers):\n\n```bash\n# Create a new block showing a webpage\nwsh web open github.com\n\n# Do a web search in a new block\nwsh web open \"wave terminal\"\n\n# Run a command in a new block and auto-close when done\nwsh run -x -- npm test\n\n# Get information about the current block\nwsh getmeta\n```\n\n## Common Workflows\n\nHere are some common ways to use `wsh`:\n\n### Development Workflow\n\n```bash\n# Open directory or markdown files\nwsh view .\nwsh view README.md\n\n# add a -m to open the block in \"magnified\" mode\nwsh view -m README.md\n\n# Start development server in a new block (-m will magnify the block on startup)\nwsh run -m -- npm run dev\n\n# Open documentation in a web block\nwsh web open http://localhost:3000\n```\n\n### Remote Development\n\n```bash\n# Connect to remote server with optional key\nwsh ssh -i ~/.ssh/mykey.pem dev@server\n\n# Edit remote files\nwsh edit /etc/nginx/nginx.conf\n\n# Monitor remote logs\nwsh run -- tail -f /var/log/app.log\n\n# Share variables between sessions\nwsh setvar -b tab SHARED_ENV=staging\n```\n\n### AI-Assisted Development\n\nThe `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.\n\n```bash\n# Pipe output to AI sidebar (ask question in UI)\ngit diff | wsh ai -\n\n# Attach files with a message\nwsh ai main.go utils.go -m \"find bugs in these files\"\n\n# Auto-submit with message\nwsh ai config.json -s -m \"explain this config\"\n\n# Start new chat with attached files\nwsh ai -n *.log -m \"analyze these logs\"\n\n# Attach multiple file types (images, PDFs, code)\nwsh ai screenshot.png report.pdf app.py -m \"review these\"\n\n# Debug with stdin and auto-submit\ndmesg | wsh ai -s - -m \"help me understand these errors\"\n```\n\n**Flags:**\n- `-` - Read from stdin instead of a file\n- `-m, --message` - Add message text along with files\n- `-s, --submit` - Auto-submit immediately (default is to wait for user)\n- `-n, --new` - Clear chat and start fresh conversation\n\n**File Limits:**\n- Text files: 200KB max\n- PDFs: 5MB max\n- Images: 7MB max\n- Maximum 15 files per command\n\n## Tips & Features\n\n1. **Working with Blocks**\n\n   - Use block numbers (1-9) to target specific blocks within a tab (hold Ctrl+Shift to see block numbers)\n   - Can get full block ids by right click a block's header and selecting \"Copy Block Id\" (useful for scripting)\n   - Use references like \"this\", \"tab\", \"workspace\", or \"global\" for different scopes\n\n2. **Data Storage**\n\n   - Use `wsh setvar/getvar` for configuration and secrets\n   - Store file data using `wsh file`, which can be easily referenced in all terminals (local and remote)\n   - Use appropriate storage scopes (block, tab, workspace, global)\n\n3. **Command Execution**\n   - Use `wsh run` to execute commands in new blocks\n   - Send command output and files quickly to AI blocks with `wsh ai`\n\n## Scripting with wsh\n\nwsh 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:\n\n```bash\n#!/bin/bash\n# Setup development environment\nwsh run -- docker-compose up -d\nwsh web open localhost:8080\nwsh view ./src\nwsh run -- npm run test:watch\n\n# Get notified when long-running tasks complete using wsh notify\nnpm run build && wsh notify \"Build complete\" || wsh notify \"Build failed\"\n```\n\n## Getting Help\n\nYou can get help on available commands by running `wsh` with no arguments, or get detailed help for a specific command using `wsh [command] -h`.\n\nFor a complete reference of all `wsh` functionality, see the [WSH Command Reference](./wsh-reference).\n"
  },
  {
    "path": "docs/docusaurus.config.ts",
    "content": "import type { Config } from \"@docusaurus/types\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport { docOgRenderer } from \"./src/renderer/image-renderers\";\n\nconst baseUrl = process.env.EMBEDDED ? \"/docsite/\" : \"/\";\n\nconst config: Config = {\n    title: \"Wave Terminal Documentation\",\n    tagline: \"Level Up Your Terminal With Graphical Widgets\",\n    favicon: \"img/logo/wave-logo_appicon.svg\",\n\n    // Set the production url of your site here\n    url: \"https://docs.waveterm.dev/\",\n    // Set the /<baseUrl>/ pathname under which your site is served\n    // For GitHub pages deployment, it is often '/<projectName>/'\n    baseUrl,\n\n    // GitHub pages deployment config.\n    // If you aren't using GitHub pages, you don't need these.\n    organizationName: \"wavetermdev\", // Usually your GitHub org/user name.\n    projectName: \"waveterm-docs\", // Usually your repo name.\n    deploymentBranch: \"main\",\n\n    onBrokenAnchors: \"ignore\",\n    onBrokenLinks: \"throw\",\n    onBrokenMarkdownLinks: \"warn\",\n    trailingSlash: false,\n\n    // Even if you don't use internationalization, you can use this field to set\n    // useful metadata like html lang. For example, if your site is Chinese, you\n    // may want to replace \"en\" with \"zh-Hans\".\n    i18n: {\n        defaultLocale: \"en\",\n        locales: [\"en\"],\n    },\n    plugins: [\n        [\n            \"content-docs\",\n            {\n                path: \"docs\",\n                routeBasePath: \"/\",\n                exclude: [\"features/**\"],\n                editUrl: !process.env.EMBEDDED ? \"https://github.com/wavetermdev/waveterm/edit/main/docs/\" : undefined,\n                rehypePlugins: [rehypeHighlight],\n            } as import(\"@docusaurus/plugin-content-docs\").Options,\n        ],\n        \"ideal-image\",\n        [\n            \"@docusaurus/plugin-sitemap\",\n            {\n                changefreq: \"daily\",\n                filename: \"sitemap.xml\",\n            },\n        ],\n        !process.env.EMBEDDED && [\n            \"@waveterm/docusaurus-og\",\n            {\n                path: \"./preview-images\", // relative to the build directory\n                imageRenderers: {\n                    \"docusaurus-plugin-content-docs\": docOgRenderer,\n                },\n            },\n        ],\n        \"docusaurus-plugin-sass\",\n        \"@docusaurus/plugin-svgr\",\n    ].filter((v) => v),\n    themes: [\n        [\"classic\", { customCss: \"src/css/custom.scss\" }],\n        !process.env.EMBEDDED && \"@docusaurus/theme-search-algolia\",\n    ].filter((v) => v),\n    themeConfig: {\n        docs: {\n            sidebar: {\n                hideable: false,\n                autoCollapseCategories: false,\n            },\n        },\n        colorMode: {\n            defaultMode: \"light\",\n            disableSwitch: false,\n            respectPrefersColorScheme: true,\n        },\n        navbar: {\n            logo: {\n                src: \"img/logo/wave-light.png\",\n                srcDark: \"img/logo/wave-dark.png\",\n                href: \"https://www.waveterm.dev/\",\n            },\n            hideOnScroll: true,\n            items: [\n                {\n                    type: \"doc\",\n                    position: \"left\",\n                    docId: \"index\",\n                    label: \"Docs\",\n                },\n                !process.env.EMBEDDED\n                    ? [\n                          {\n                              position: \"left\",\n                              href: \"https://docs.waveterm.dev/storybook\",\n                              label: \"Storybook\",\n                          },\n                          {\n                              href: \"https://discord.gg/zUeP2aAjaP\",\n                              position: \"right\",\n                              className: \"header-link-custom custom-icon-discord\",\n                              \"aria-label\": \"Discord invite\",\n                          },\n                          {\n                              href: \"https://github.com/wavetermdev/waveterm\",\n                              position: \"right\",\n                              className: \"header-link-custom custom-icon-github\",\n                              \"aria-label\": \"GitHub repository\",\n                          },\n                      ]\n                    : [],\n            ].flat(),\n        },\n        metadata: [\n            {\n                name: \"keywords\",\n                content:\n                    \"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\",\n            },\n            {\n                name: \"og:type\",\n                content: \"website\",\n            },\n            {\n                name: \"og:site_name\",\n                content: \"Wave Terminal Documentation\",\n            },\n            {\n                name: \"application-name\",\n                content: \"Wave Terminal Documentation\",\n            },\n            {\n                name: \"apple-mobile-web-app-title\",\n                content: \"Wave Terminal Documentation\",\n            },\n        ],\n        footer: {\n            copyright: `Copyright © ${new Date().getFullYear()} Command Line Inc. Built with Docusaurus.`,\n        },\n        algolia: {\n            appId: \"B6A8512SN4\",\n            apiKey: \"e879cd8663f109b2822cd004d9cd468c\",\n            indexName: \"waveterm\",\n        },\n    },\n    headTags: [\n        {\n            tagName: \"link\",\n            attributes: {\n                rel: \"preload\",\n                as: \"font\",\n                type: \"font/woff2\",\n                \"data-next-font\": \"size-adjust\",\n                href: `${baseUrl}fontawesome/webfonts/fa-sharp-regular-400.woff2`,\n            },\n        },\n        {\n            tagName: \"link\",\n            attributes: {\n                rel: \"preload\",\n                as: \"font\",\n                type: \"font/woff2\",\n                \"data-next-font\": \"size-adjust\",\n                href: `${baseUrl}fontawesome/webfonts/fa-sharp-solid-900.woff2`,\n            },\n        },\n        {\n            tagName: \"link\",\n            attributes: {\n                rel: \"sitemap\",\n                type: \"application/xml\",\n                title: \"Sitemap\",\n                href: `${baseUrl}sitemap.xml`,\n            },\n        },\n        !process.env.EMBEDDED && {\n            tagName: \"script\",\n            attributes: {\n                defer: \"true\",\n                \"data-domain\": \"docs.waveterm.dev\",\n                src: \"https://plausible.io/js/script.file-downloads.outbound-links.tagged-events.js\",\n            },\n        },\n    ].filter((v) => v),\n    stylesheets: [\n        `${baseUrl}fontawesome/css/fontawesome.min.css`,\n        `${baseUrl}fontawesome/css/sharp-regular.min.css`,\n        `${baseUrl}fontawesome/css/sharp-solid.min.css`,\n    ],\n    staticDirectories: [\"static\", \"storybook\"],\n};\n\nexport default config;\n"
  },
  {
    "path": "docs/eslint.config.js",
    "content": "// @ts-check\n\nimport eslint from \"@eslint/js\";\nimport eslintConfigPrettier from \"eslint-config-prettier\";\nimport * as mdx from \"eslint-plugin-mdx\";\nimport tseslint from \"typescript-eslint\";\n\nconst baseConfig = tseslint.config(\n    eslint.configs.recommended,\n    ...tseslint.configs.recommended,\n    mdx.flat,\n    mdx.flatCodeBlocks\n);\n\nconst customConfig = {\n    ...baseConfig,\n    overrides: [\n        {\n            files: [\"emain/emain.ts\", \"electron.vite.config.ts\"],\n            env: {\n                node: true,\n            },\n        },\n    ],\n};\n\nexport default [customConfig, eslintConfigPrettier];\n"
  },
  {
    "path": "docs/package.json",
    "content": "{\n    \"name\": \"waveterm-docs\",\n    \"version\": \"0.0.0\",\n    \"scripts\": {\n        \"docusaurus\": \"docusaurus\",\n        \"start\": \"docusaurus start\",\n        \"build\": \"docusaurus build\",\n        \"swizzle\": \"docusaurus swizzle\",\n        \"deploy\": \"docusaurus deploy\",\n        \"clear\": \"docusaurus clear\",\n        \"serve\": \"docusaurus serve\",\n        \"write-translations\": \"docusaurus write-translations\",\n        \"write-heading-ids\": \"docusaurus write-heading-ids\",\n        \"typecheck\": \"tsc\"\n    },\n    \"dependencies\": {\n        \"@docusaurus/core\": \"^3.9.2\",\n        \"@docusaurus/plugin-content-docs\": \"^3.9.2\",\n        \"@docusaurus/plugin-debug\": \"^3.9.2\",\n        \"@docusaurus/plugin-ideal-image\": \"^3.9.2\",\n        \"@docusaurus/plugin-sitemap\": \"^3.9.2\",\n        \"@docusaurus/plugin-svgr\": \"^3.9.2\",\n        \"@docusaurus/theme-classic\": \"^3.9.2\",\n        \"@docusaurus/theme-search-algolia\": \"^3.9.2\",\n        \"@mdx-js/react\": \"^3.0.0\",\n        \"@waveterm/docusaurus-og\": \"https://codeload.github.com/wavetermdev/docusaurus-og/tar.gz/2156619012b8970d922c1ef47789d2f14e47e283\",\n        \"clsx\": \"^2.1.1\",\n        \"docusaurus-plugin-sass\": \"^0.2.6\",\n        \"prism-react-renderer\": \"^2.4.1\",\n        \"react\": \"^18.0.0\",\n        \"react-dom\": \"^18.0.0\",\n        \"rehype-highlight\": \"^7.0.2\",\n        \"remark-gfm\": \"^4.0.1\",\n        \"remark-typescript-code-import\": \"^1.0.1\",\n        \"sass\": \"^1.93.2\"\n    },\n    \"devDependencies\": {\n        \"@docusaurus/module-type-aliases\": \"3.9.2\",\n        \"@docusaurus/tsconfig\": \"3.9.2\",\n        \"@docusaurus/types\": \"3.9.2\",\n        \"@eslint/js\": \"^9.39\",\n        \"@mdx-js/typescript-plugin\": \"^0.1.3\",\n        \"@types/react\": \"^18.3.0\",\n        \"@types/react-dom\": \"^18.3.0\",\n        \"eslint\": \"^9.39\",\n        \"eslint-config-prettier\": \"^10.1.8\",\n        \"eslint-plugin-mdx\": \"^3.7.0\",\n        \"prettier\": \"^3.8.1\",\n        \"prettier-plugin-jsdoc\": \"^1.8.0\",\n        \"prettier-plugin-organize-imports\": \"^4.3.0\",\n        \"remark-cli\": \"^12.0.1\",\n        \"remark-frontmatter\": \"^5.0.0\",\n        \"remark-mdx\": \"^3.1.0\",\n        \"remark-preset-lint-consistent\": \"^6.0.1\",\n        \"remark-preset-lint-recommended\": \"^7.0.1\",\n        \"typescript\": \"^5.9.3\",\n        \"typescript-eslint\": \"^8.56\"\n    },\n    \"resolutions\": {\n        \"path-to-regexp@npm:2.2.1\": \"^3\",\n        \"cookie@0.6.0\": \"^0.7.0\"\n    },\n    \"browserslist\": {\n        \"production\": [\n            \">0.5%\",\n            \"not dead\",\n            \"not op_mini all\"\n        ],\n        \"development\": [\n            \"last 3 chrome version\",\n            \"last 3 firefox version\",\n            \"last 5 safari version\"\n        ]\n    },\n    \"engines\": {\n        \"node\": \">=18.0\"\n    }\n}\n"
  },
  {
    "path": "docs/prettier.config.cjs",
    "content": "/** @type {import(\"prettier\").Config} */\nmodule.exports = {\n    plugins: [\"prettier-plugin-jsdoc\", \"prettier-plugin-organize-imports\"],\n    printWidth: 120,\n    trailingComma: \"es5\",\n    useTabs: false,\n    singleQuote: false,\n    jsdocVerticalAlignment: true,\n    jsdocSeparateReturnsFromParam: true,\n    jsdocSeparateTagGroups: true,\n    jsdocPreferCodeFences: true,\n};\n"
  },
  {
    "path": "docs/src/components/card.css",
    "content": ".card-group {\n    display: grid;\n    grid-template-columns: repeat(3, 1fr);\n    grid-template-rows: auto;\n    gap: 1rem;\n}\n\n@media (max-width: 450px) {\n    .card-group {\n        grid-template-columns: 1fr;\n    }\n}\n\n@media (min-width: 451px) and (max-width: 995px) {\n    .card-group {\n        grid-template-columns: repeat(2, 1fr);\n    }\n}\n\n@media (min-width: 996px) {\n    .card-group {\n        grid-template-columns: repeat(3, 1fr);\n    }\n}\n\n.card {\n    display: grid;\n    grid-template-columns: 1.5rem 1rem 1fr;\n    grid-template-rows: subgrid;\n    grid-column: span 1;\n    grid-row: span 2;\n    padding: 1rem;\n\n    .icon {\n        grid-column: 1;\n        grid-row: 1;\n        font-size: 1.5rem;\n        line-height: 1.5rem;\n    }\n\n    .title {\n        grid-column: 3;\n        grid-row: 1;\n        font-weight: bold;\n        font-size: 1.2rem;\n        line-height: 1.5rem;\n    }\n\n    .description {\n        color: var(--ifm-font-color-base);\n        grid-column: span 3;\n        grid-row: 2;\n    }\n\n    border: 2px solid var(--ifm-color-primary-lightest);\n    transition: transform 0.1s ease;\n    transform-origin: 50% 50%;\n}\n\n.card:hover {\n    text-decoration: none;\n    transform: translateZ(0) scale(1.024);\n    -webkit-transform: translateZ(0) scale(1.024);\n}\n"
  },
  {
    "path": "docs/src/components/card.tsx",
    "content": "import clsx from \"clsx\";\nimport \"./card.css\";\n\ninterface CardProps {\n    icon: string;\n    title: string;\n    description: string;\n    href: string;\n}\n\nexport function Card({ icon, title, description, href }: CardProps) {\n    return (\n        <a className=\"card\" href={href}>\n            <div className={clsx(\"icon\", \"fa-sharp fa-regular\", icon)} />\n            <div className=\"title\">{title}</div>\n            <div className=\"description\">{description}</div>\n        </a>\n    );\n}\n\nexport function CardGroup({ children }) {\n    return <div className=\"card-group\">{children}</div>;\n}\n"
  },
  {
    "path": "docs/src/components/kbd.css",
    "content": "@font-face {\n    font-family: \"JetBrains Mono\";\n    src: url(\"/static/fonts/JetBrainsMono-Regular.woff2\") format(\"woff2\");\n    font-weight: normal;\n    font-style: normal;\n}\n\n@font-face {\n    font-family: \"JetBrains Mono\";\n    src: url(\"/static/fonts/JetBrainsMono-Bold.woff2\") format(\"woff2\");\n    font-weight: bold;\n    font-style: normal;\n}\n\n.kbd-group {\n    display: inline-flex;\n    gap: 4px;\n    align-items: center;\n}\n\nkbd {\n    background-color: var(--ifm-color-primary-contrast-background);\n    border-radius: 4px;\n    border: 1px solid var(--ifm-color-secondary-darker);\n    color: var(--ifm-color-primary-contrast-foreground);\n    padding: 2px 6px;\n    font-size: 0.8em;\n    font-family: \"JetBrains Mono\", monospace;\n    display: inline-flex;\n    justify-content: center;\n    align-items: center;\n    height: 24px;\n    line-height: 24px;\n\n    .spaced {\n        letter-spacing: 0.2em;\n    }\n}\n\n.kbd-group kbd.symbol {\n    font-size: 0.8em;\n    line-height: 24px;\n}\n"
  },
  {
    "path": "docs/src/components/kbd.tsx",
    "content": "import BrowserOnly from \"@docusaurus/BrowserOnly\";\nimport { useContext } from \"react\";\nimport \"./kbd.css\";\nimport type { Platform } from \"./platformcontext\";\nimport { PlatformContext } from \"./platformcontext\";\n\nfunction convertKey(platform: Platform, key: string): [any, string, boolean] {\n    if (key == \"Arrows\") {\n        return [<span className=\"spaced\">↑→↓←</span>, \"Arrow Keys\", true];\n    }\n    if (key == \"ArrowUp\") {\n        return [\"↑\", \"Arrow Up\", true];\n    }\n    if (key == \"ArrowRight\") {\n        return [\"→\", \"Arrow Right\", true];\n    }\n    if (key == \"ArrowDown\") {\n        return [\"↓\", \"Arrow Down\", true];\n    }\n    if (key == \"ArrowLeft\") {\n        return [\"←\", \"Arrow Left\", true];\n    }\n    if (key == \"Cmd\") {\n        if (platform === \"mac\") {\n            return [\"⌘\", \"Command\", true];\n        } else {\n            return [\"Alt\", \"Alt\", false];\n        }\n    }\n    if (key == \"Ctrl\") {\n        if (platform === \"mac\") {\n            return [\"⌃\", \"Control\", true];\n        } else {\n            return [\"Ctrl\", \"Control\", false];\n        }\n    }\n    if (key == \"Shift\") {\n        return [\"⇧\", \"Shift\", true];\n    }\n    if (key == \"Escape\") {\n        return [\"Esc\", \"Escape\", false];\n    }\n    return [key.length > 1 ? key : key.toUpperCase(), key, false];\n}\n\n// Custom KBD component\nconst KbdInternal = ({ k, windows, mac, linux }: { k: string; windows?: string; mac?: string; linux?: string }) => {\n    const { platform } = useContext(PlatformContext);\n\n    // Determine which key binding to use based on platform overrides\n    let keyBinding = k;\n    if (platform === \"windows\" && windows) {\n        keyBinding = windows;\n    } else if (platform === \"mac\" && mac) {\n        keyBinding = mac;\n    } else if (platform === \"linux\" && linux) {\n        keyBinding = linux;\n    }\n\n    if (keyBinding == \"N/A\") {\n        return \"N/A\";\n    }\n\n    const keys = keyBinding.split(\":\");\n    const keyElems = keys.map((key, i) => {\n        const [displayKey, title, symbol] = convertKey(platform, key);\n        return (\n            <kbd key={i} title={title} aria-label={title} className={symbol ? \"symbol\" : null}>\n                {displayKey}\n            </kbd>\n        );\n    });\n    return <div className=\"kbd-group\">{keyElems}</div>;\n};\n\nexport const Kbd = ({ k, windows, mac, linux }: { k: string; windows?: string; mac?: string; linux?: string }) => {\n    return (\n        <BrowserOnly fallback={<kbd>{k}</kbd>}>\n            {() => <KbdInternal k={k} windows={windows} mac={mac} linux={linux} />}\n        </BrowserOnly>\n    );\n};\n\nexport const KbdChord = ({ karr }: { karr: string[] }) => {\n    const elems: React.ReactNode[] = [];\n    for (let i = 0; i < karr.length; i++) {\n        if (i > 0) {\n            elems.push(<span style={{ padding: \"0 2px\" }}>+</span>);\n        }\n        elems.push(<Kbd key={i} k={karr[i]} />);\n    }\n    const fullElem = <span style={{ whiteSpace: \"nowrap\" }}>{elems}</span>;\n    return <BrowserOnly fallback={null}>{() => fullElem}</BrowserOnly>;\n};\n"
  },
  {
    "path": "docs/src/components/platformcontext.css",
    "content": ".pill-toggle {\n    display: inline-flex;\n    border: 1px solid var(--ifm-scrollbar-thumb-background-color);\n    border-radius: 20px;\n    overflow: hidden;\n    background-color: var(--ifm-scrollbar-track-background-color);\n}\n\n.pill-option {\n    padding: 8px 16px;\n    font-size: 0.9em;\n    font-weight: 500;\n    color: var(--ifm-color-secondary-contrast-foreground);\n    background-color: transparent;\n    border: none;\n    cursor: pointer;\n    transition:\n        background-color 0.2s ease,\n        color 0.2s ease;\n    outline: none;\n    font-weight: bold;\n\n    &:not(:first-of-type) {\n        border-left: 1px solid var(--ifm-scrollbar-thumb-background-color);\n    }\n}\n\n.pill-option.active {\n    background-color: var(--ifm-color-primary);\n    color: var(--ifm-color-secondary-contrast-background);\n}\n\n.pill-option:not(.active):hover {\n    background-color: var(--ifm-scrollbar-thumb-background-color);\n}\n"
  },
  {
    "path": "docs/src/components/platformcontext.tsx",
    "content": "import BrowserOnly from \"@docusaurus/BrowserOnly\";\nimport { createContext, ReactNode, useCallback, useContext, useState } from \"react\";\n\nimport clsx from \"clsx\";\nimport \"./platformcontext.css\";\n\nexport type Platform = \"mac\" | \"linux\" | \"windows\";\n\ninterface PlatformContextProps {\n    platform: Platform;\n    setPlatform: (platform: Platform) => void;\n}\n\nexport const PlatformContext = createContext<PlatformContextProps | undefined>(undefined);\n\nfunction getOS(): Platform {\n    const platform = window.navigator.platform;\n    const macosPlatforms = [\"Macintosh\", \"MacIntel\", \"MacPPC\", \"Mac68K\"];\n    const windowsPlatforms = [\"Win32\", \"Win64\", \"Windows\", \"WinCE\"];\n    const iosPlatforms = [\"iPhone\", \"iPad\", \"iPod\"];\n\n    if (macosPlatforms.includes(platform) || iosPlatforms.includes(platform)) {\n        return \"mac\";\n    } else if (windowsPlatforms.includes(platform)) {\n        return \"windows\";\n    } else {\n        return \"linux\";\n    }\n}\n\nconst PlatformProviderInternal = ({ children }: { children: ReactNode }) => {\n    const [platform, setPlatform] = useState<Platform>(getOS());\n\n    const setPlatformCallback = useCallback((newPlatform: Platform) => {\n        setPlatform(newPlatform);\n        localStorage.setItem(\"platform\", newPlatform); // Store in localStorage\n    }, []);\n\n    return (\n        <PlatformContext.Provider value={{ platform, setPlatform: setPlatformCallback }}>\n            {children}\n        </PlatformContext.Provider>\n    );\n};\n\nexport function PlatformProvider({ children }: { children: ReactNode }) {\n    return (\n        <BrowserOnly fallback={<div />}>\n            {() => <PlatformProviderInternal>{children}</PlatformProviderInternal>}\n        </BrowserOnly>\n    );\n}\n\nexport const usePlatform = (): PlatformContextProps => {\n    const context = useContext(PlatformContext);\n    if (!context) {\n        throw new Error(\"usePlatform must be used within a PlatformProvider\");\n    }\n    return context;\n};\n\nfunction PlatformSelectorButtonInternal() {\n    const { platform, setPlatform } = usePlatform();\n\n    return (\n        <div className=\"pill-toggle\">\n            <button className={clsx(\"pill-option\", { active: platform === \"mac\" })} onClick={() => setPlatform(\"mac\")}>\n                macOS\n            </button>\n            <button\n                className={clsx(\"pill-option\", { active: platform === \"linux\" })}\n                onClick={() => setPlatform(\"linux\")}\n            >\n                Linux\n            </button>\n            <button\n                className={clsx(\"pill-option\", { active: platform === \"windows\" })}\n                onClick={() => setPlatform(\"windows\")}\n            >\n                Windows\n            </button>\n        </div>\n    );\n}\n\nexport function PlatformSelectorButton() {\n    return <BrowserOnly fallback={<div />}>{() => <PlatformSelectorButtonInternal />}</BrowserOnly>;\n}\n\ninterface PlatformItemProps {\n    children: ReactNode;\n    platforms: Platform[];\n}\n\nconst PlatformItemInternal = ({ children, platforms }: PlatformItemProps) => {\n    const platform = usePlatform();\n\n    return platforms.includes(platform.platform) && children;\n};\n\nexport const PlatformItem = (props: PlatformItemProps) => {\n    return <BrowserOnly fallback={<div />}>{() => <PlatformItemInternal {...props} />}</BrowserOnly>;\n};\n"
  },
  {
    "path": "docs/src/components/versionbadge.css",
    "content": ".version-badge {\n    display: inline-block;\n    padding: 0.125rem 0.5rem;\n    margin-left: 0.25rem;\n    font-size: 0.75rem;\n    font-weight: 600;\n    line-height: 1.5;\n    border-radius: 0.25rem;\n    background-color: var(--ifm-color-primary-lightest);\n    color: var(--ifm-background-color);\n    vertical-align: middle;\n    white-space: nowrap;\n}\n\n.version-badge.no-left-margin {\n    margin-left: 0;\n}\n\n[data-theme=\"dark\"] .version-badge {\n    background-color: var(--ifm-color-primary-dark);\n    color: var(--ifm-background-color);\n}\n"
  },
  {
    "path": "docs/src/components/versionbadge.tsx",
    "content": "import \"./versionbadge.css\";\n\ninterface VersionBadgeProps {\n    version: string;\n    noLeftMargin?: boolean;\n}\n\nexport function VersionBadge({ version, noLeftMargin }: VersionBadgeProps) {\n    return <span className={`version-badge${noLeftMargin ? \" no-left-margin\" : \"\"}`}>{version}</span>;\n}"
  },
  {
    "path": "docs/src/css/custom.scss",
    "content": "@import url(\"../../../node_modules/highlight.js/scss/github-dark-dimmed.scss\");\n\n:root {\n  --ifm-background-color: #ffffff;\n  --ifm-color-primary: #1a660b;\n  --ifm-color-primary-dark: #175c0a;\n  --ifm-color-primary-darker: #165709;\n  --ifm-color-primary-darkest: #124708;\n  --ifm-color-primary-light: #1d700c;\n  --ifm-color-primary-lighter: #1e750d;\n  --ifm-color-primary-lightest: #22850e;\n}\n\n[data-theme=\"dark\"] {\n  --ifm-background-color: #1b1b1d;\n  --ifm-color-primary: #58c142;\n  --ifm-color-primary-dark: #4eb03a;\n  --ifm-color-primary-darker: #4aa636;\n  --ifm-color-primary-darkest: #429431;\n  --ifm-color-primary-light: #69c756;\n  --ifm-color-primary-lighter: #72cb5f;\n  --ifm-color-primary-lightest: #8cd47d;\n}\n\n.docs-doc-id-index article nav {\n  display: none;\n}\n\nbody .markdown h1:first-child {\n  --ifm-h1-font-size: 2rem;\n}\n\nbody .markdown h2 {\n  --ifm-h2-font-size: 1.75rem;\n}\n\n@media (min-width: 996px) {\n  .reference-links {\n    display: none;\n  }\n}\n\n/* Adds extra margin between last navbar item and the dark mode toggle. */\n.navbar__items--right .navbar__item:last-of-type {\n  margin-right: 4px;\n}\n\n.header-link-custom:before {\n  display: block;\n  height: 24px;\n  width: 24px;\n  background-color: var(--ifm-navbar-link-color);\n  transition: background-color 0.15s linear;\n}\n\n.header-link-custom:hover:before {\n  background-color: var(--ifm-navbar-link-hover-color);\n}\n\n.custom-icon-inline:before {\n  display: inline-block;\n  height: 16px;\n  width: 16px;\n  background-color: var(--ifm-color-primary-contrast-foreground);\n  transition: background-color 0.15s linear;\n}\n\n.custom-icon-github:before {\n  content: \"\";\n  mask: url(/img/github.svg) no-repeat center / contain;\n  -webkit-mask: url(/img/github.svg) no-repeat center / contain;\n}\n\n.custom-icon-discord:before {\n  content: \"\";\n  mask: url(/img/discord.svg) no-repeat center / contain;\n  -webkit-mask: url(/img/discord.svg) no-repeat center / contain;\n}\n\n.custom-icon-workspace:before {\n  content: \"\";\n  mask: url(/img/workspace.svg) no-repeat center / contain;\n  -webkit-mask: url(/img/workspace.svg) no-repeat center / contain;\n}\n\n.custom-icon-magnify-enabled:before {\n  content: \"\";\n  mask: url(/img/magnify-enabled.svg) no-repeat center / contain;\n  -webkit-mask: url(/img/magnify-enabled.svg) no-repeat center / contain;\n  margin-bottom: -2px;\n}\n\n.custom-icon-magnify-disabled:before {\n  content: \"\";\n  mask: url(/img/magnify-disabled.svg) no-repeat center / contain;\n  -webkit-mask: url(/img/magnify-disabled.svg) no-repeat center / contain;\n  margin-bottom: -2px;\n}\n\nimg[src*=\"#left\"] {\n  float: left;\n  margin: 0 10px 10px 0;\n  max-width: 300px;\n}\nimg[src*=\"#right\"] {\n  float: right;\n  margin: 0 0 10px 10px;\n  max-width: 300px;\n}\nimg[src*=\"#center\"] {\n  display: block;\n  margin: auto;\n}\n\n.hidden {\n  display: none;\n}\n"
  },
  {
    "path": "docs/src/renderer/image-renderers.ts",
    "content": "import type { DocsPageData, ImageGeneratorOptions, ImageRenderer } from \"@waveterm/docusaurus-og\";\nimport { readFileSync } from \"fs\";\nimport { join } from \"path\";\nimport React, { ReactNode } from \"react\";\n\nconst waveLogo = join(__dirname, \"../../static/img/logo/wave-dark.png\");\nconst waveLogoBase64 = `data:image/png;base64,${readFileSync(waveLogo).toString(\"base64\")}`;\n\nconst titleElement = ({ children }) =>\n    React.createElement(\n        \"label\",\n        {\n            style: {\n                fontSize: 72,\n                fontWeight: 800,\n                letterSpacing: 1,\n                margin: \"25px 225px 10px 0px\",\n                color: \"#e3e3e3\",\n                wordBreak: \"break-word\",\n            },\n        },\n        children\n    );\n\nconst waveLogoElement = React.createElement(\"img\", {\n    src: waveLogoBase64,\n    style: {\n        width: 300,\n    },\n});\n\nconst headerElement = (header: string, svg: ReactNode) =>\n    React.createElement(\n        \"div\",\n        {\n            style: {\n                display: \"flex\",\n                alignItems: \"center\",\n                marginTop: \"50px\",\n            },\n        },\n        svg,\n        React.createElement(\n            \"label\",\n            {\n                style: {\n                    fontSize: 30,\n                    fontWeight: 600,\n                    letterSpacing: 1,\n                    color: \"#58c142\",\n                },\n            },\n            header\n        )\n    );\n\nconst rootDivStyle: React.CSSProperties = {\n    display: \"flex\",\n    flexDirection: \"column\",\n    height: \"100%\",\n    width: \"100%\",\n    padding: \"50px 50px\",\n    justifyContent: \"center\",\n    fontFamily: \"Roboto\",\n    fontSize: 32,\n    fontWeight: 400,\n    backgroundColor: \"#1b1b1d\",\n    color: \"#e3e3e3\",\n    borderBottom: \"2rem solid #58c142\",\n    zIndex: \"2 !important\",\n};\n\nexport const docOgRenderer: ImageRenderer<DocsPageData> = async (data, context) => {\n    const element = React.createElement(\n        \"div\",\n        { style: rootDivStyle },\n        waveLogoElement,\n        headerElement(\"Documentation\", null),\n        React.createElement(titleElement, null, data.metadata.title),\n        React.createElement(\"div\", null, data.metadata.description.replace(\"&mdash;\", \"-\"))\n    );\n\n    return [element, await imageGeneratorOptions()];\n};\n\nconst imageGeneratorOptions = async (): Promise<ImageGeneratorOptions> => {\n    return {\n        width: 1200,\n        height: 600,\n        fonts: [\n            {\n                name: \"Roboto\",\n                data: await getTtfFont(\"Roboto\", [\"ital\", \"wght\"], [0, 400]),\n                weight: 400,\n                style: \"normal\",\n            },\n        ],\n    };\n};\n\nfunction docSectionPath(slug: string, title: string) {\n    let section = slug.split(\"/\")[1].toString();\n\n    // Override some sections by slug\n    switch (section) {\n        case \"api\":\n            section = \"REST APIs\";\n            break;\n    }\n\n    section = section.charAt(0).toUpperCase() + section.slice(1);\n\n    return `${title} / ${section}`;\n}\n\nasync function getTtfFont(family: string, axes: string[], value: number[]): Promise<ArrayBuffer> {\n    const familyParam = axes.join(\",\") + \"@\" + value.join(\",\");\n\n    // Get css style sheet with user agent Mozilla/5.0 Firefox/1.0 to ensure TTF is returned\n    const cssCall = await fetch(`https://fonts.googleapis.com/css2?family=${family}:${familyParam}&display=swap`, {\n        headers: {\n            \"User-Agent\": \"Mozilla/5.0 Firefox/1.0\",\n        },\n    });\n\n    const css = await cssCall.text();\n    const ttfUrl = css.match(/url\\(([^)]+)\\)/)?.[1];\n\n    return await fetch(ttfUrl).then((res) => res.arrayBuffer());\n}\n"
  },
  {
    "path": "docs/src/theme/MDXComponents/Heading.tsx",
    "content": "import type { WrapperProps } from \"@docusaurus/types\";\nimport Heading from \"@theme-original/MDXComponents/Heading\";\nimport type HeadingType from \"@theme/MDXComponents/Heading\";\n\ntype Props = WrapperProps<typeof HeadingType>;\n\nexport default function HeadingWrapper(props: Props): JSX.Element {\n    return (\n        <>\n            <div style={{ clear: \"both\" }} />\n            <Heading {...props} />\n        </>\n    );\n}\n"
  },
  {
    "path": "docs/static/.nojekyll",
    "content": ""
  },
  {
    "path": "docs/tsconfig.json",
    "content": "{\n    // This file is not used in compilation. It is here just for a nice editor experience.\n    \"extends\": \"@docusaurus/tsconfig\",\n    \"compilerOptions\": {\n        \"baseUrl\": \".\",\n        \"plugins\": [\n            {\n                \"name\": \"@mdx-js/typescript-plugin\"\n            }\n        ]\n    },\n    \"mdx\": {\n        // Enable strict type checking in MDX files.\n        \"checkMdx\": true\n    }\n}\n"
  },
  {
    "path": "electron-builder.config.cjs",
    "content": "const { Arch } = require(\"electron-builder\");\nconst pkg = require(\"./package.json\");\nconst fs = require(\"fs\");\nconst path = require(\"path\");\n\nconst windowsShouldSign = !!process.env.SM_CODE_SIGNING_CERT_SHA1_HASH;\n\n/**\n * @type {import('electron-builder').Configuration}\n * @see https://www.electron.build/configuration/configuration\n */\nconst config = {\n    appId: pkg.build.appId,\n    productName: pkg.productName,\n    executableName: pkg.productName,\n    artifactName: \"${productName}-${platform}-${arch}-${version}.${ext}\",\n    generateUpdatesFilesForAllChannels: true,\n    npmRebuild: false,\n    nodeGypRebuild: false,\n    electronCompile: false,\n    files: [\n        {\n            from: \"./dist\",\n            to: \"./dist\",\n            filter: [\"**/*\", \"!bin/*\", \"bin/wavesrv.${arch}*\", \"bin/wsh*\", \"!tsunamiscaffold/**/*\"],\n        },\n        {\n            from: \".\",\n            to: \".\",\n            filter: [\"package.json\"],\n        },\n        \"!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.\n    ],\n    extraResources: [\n        {\n            from: \"dist/tsunamiscaffold\",\n            to: \"tsunamiscaffold\",\n        },\n    ],\n    directories: {\n        output: \"make\",\n    },\n    asarUnpack: [\n        \"dist/bin/**/*\", // wavesrv and wsh binaries\n        \"dist/schema/**/*\", // schema files for Monaco editor\n    ],\n    mac: {\n        target: [\n            {\n                target: \"zip\",\n                arch: [\"arm64\", \"x64\"],\n            },\n            {\n                target: \"dmg\",\n                arch: [\"arm64\", \"x64\"],\n            },\n        ],\n        category: \"public.app-category.developer-tools\",\n        minimumSystemVersion: \"10.15.0\",\n        mergeASARs: true,\n        singleArchFiles: \"**/dist/bin/wavesrv.*\",\n        entitlements: \"build/entitlements.mac.plist\",\n        entitlementsInherit: \"build/entitlements.mac.plist\",\n        extendInfo: {\n            NSContactsUsageDescription: \"A CLI application running in Wave wants to use your contacts.\",\n            NSRemindersUsageDescription: \"A CLI application running in Wave wants to use your reminders.\",\n            NSLocationWhenInUseUsageDescription:\n                \"A CLI application running in Wave wants to use your location information while active.\",\n            NSLocationAlwaysUsageDescription:\n                \"A CLI application running in Wave wants to use your location information, even in the background.\",\n            NSCameraUsageDescription: \"A CLI application running in Wave wants to use the camera.\",\n            NSMicrophoneUsageDescription: \"A CLI application running in Wave wants to use your microphone.\",\n            NSCalendarsUsageDescription: \"A CLI application running in Wave wants to use Calendar data.\",\n            NSLocationUsageDescription: \"A CLI application running in Wave wants to use your location information.\",\n            NSAppleEventsUsageDescription: \"A CLI application running in Wave wants to use AppleScript.\",\n        },\n    },\n    linux: {\n        artifactName: \"${name}-${platform}-${arch}-${version}.${ext}\",\n        category: \"TerminalEmulator\",\n        executableName: pkg.name,\n        target: [\"zip\", \"deb\", \"rpm\", \"snap\", \"AppImage\", \"pacman\"],\n        synopsis: pkg.description,\n        description: null,\n        desktop: {\n            entry: {\n                Name: pkg.productName,\n                Comment: pkg.description,\n                Keywords: \"developer;terminal;emulator;\",\n                Categories: \"Development;Utility;\",\n            },\n        },\n        executableArgs: [\"--enable-features\", \"UseOzonePlatform\", \"--ozone-platform-hint\", \"auto\"], // Hint Electron to use Ozone abstraction layer for native Wayland support\n    },\n    deb: {\n        afterInstall: \"build/deb-postinstall.tpl\",\n    },\n    win: {\n        target: [\"nsis\", \"msi\", \"zip\"],\n        signtoolOptions: windowsShouldSign && {\n            signingHashAlgorithms: [\"sha256\"],\n            publisherName: \"Command Line Inc\",\n            certificateSubjectName: \"Command Line Inc\",\n            certificateSha1: process.env.SM_CODE_SIGNING_CERT_SHA1_HASH,\n        },\n    },\n    appImage: {\n        license: \"LICENSE\",\n    },\n    snap: {\n        base: \"core22\",\n        confinement: \"classic\",\n        allowNativeWayland: true,\n        artifactName: \"${name}_${version}_${arch}.${ext}\",\n    },\n    rpm: {\n        // this should remove /usr/lib/.build-id/ links which can conflict with other electron apps like slack\n        fpm: [\"--rpm-rpmbuild-define\", \"_build_id_links none\"],\n    },\n    publish: {\n        provider: \"generic\",\n        url: \"https://dl.waveterm.dev/releases-w2\",\n    },\n    afterPack: (context) => {\n        // This is a workaround to restore file permissions to the wavesrv binaries on macOS after packaging the universal binary.\n        if (context.electronPlatformName === \"darwin\" && context.arch === Arch.universal) {\n            const packageBinDir = path.resolve(\n                context.appOutDir,\n                `${pkg.productName}.app/Contents/Resources/app.asar.unpacked/dist/bin`\n            );\n\n            // Reapply file permissions to the wavesrv binaries in the final app package\n            fs.readdirSync(packageBinDir, {\n                recursive: true,\n                withFileTypes: true,\n            })\n                .filter((f) => f.isFile() && f.name.startsWith(\"wavesrv\"))\n                .forEach((f) => fs.chmodSync(path.resolve(f.parentPath ?? f.path, f.name), 0o755)); // 0o755 corresponds to -rwxr-xr-x\n        }\n    },\n};\n\nmodule.exports = config;\n"
  },
  {
    "path": "electron.vite.config.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport tailwindcss from \"@tailwindcss/vite\";\nimport react from \"@vitejs/plugin-react-swc\";\nimport { defineConfig } from \"electron-vite\";\nimport { ViteImageOptimizer } from \"vite-plugin-image-optimizer\";\nimport svgr from \"vite-plugin-svgr\";\nimport tsconfigPaths from \"vite-tsconfig-paths\";\n\n// from our electron build\nconst CHROME = \"chrome140\";\nconst NODE = \"node22\";\n\n// for debugging\n// target is like -- path.resolve(__dirname, \"frontend/app/workspace/workspace-layout-model.ts\");\nfunction whoImportsTarget(target: string) {\n    return {\n        name: \"who-imports-target\",\n        buildEnd() {\n            // Build reverse graph: child -> [importers...]\n            const parents = new Map<string, string[]>();\n            for (const id of (this as any).getModuleIds()) {\n                const info = (this as any).getModuleInfo(id);\n                if (!info) continue;\n                for (const child of [...info.importedIds, ...info.dynamicallyImportedIds]) {\n                    const arr = parents.get(child) ?? [];\n                    arr.push(id);\n                    parents.set(child, arr);\n                }\n            }\n\n            // Walk upward from TARGET and print paths to entries\n            const entries = [...parents.keys()].filter((id) => {\n                const m = (this as any).getModuleInfo(id);\n                return m?.isEntry;\n            });\n\n            const seen = new Set<string>();\n            const stack: string[] = [];\n            const dfs = (node: string) => {\n                if (seen.has(node)) return;\n                seen.add(node);\n                stack.push(node);\n                const ps = parents.get(node) || [];\n                if (ps.length === 0) {\n                    // hit a root (likely main entry or plugin virtual)\n                    console.log(\"\\nImporter chain:\");\n                    stack\n                        .slice()\n                        .reverse()\n                        .forEach((s) => console.log(\"  ↳\", s));\n                } else {\n                    for (const p of ps) dfs(p);\n                }\n                stack.pop();\n            };\n\n            if (!parents.has(target)) {\n                console.log(`[who-imports] TARGET not in MAIN graph: ${target}`);\n            } else {\n                dfs(target);\n            }\n        },\n        async resolveId(id: any, importer: any) {\n            const r = await (this as any).resolve(id, importer, { skipSelf: true });\n            if (r?.id === target) {\n                console.log(`[resolve] ${importer} -> ${id} -> ${r.id}`);\n            }\n            return null;\n        },\n    };\n}\n\nexport default defineConfig({\n    main: {\n        root: \".\",\n        build: {\n            target: NODE,\n            rollupOptions: {\n                input: {\n                    index: \"emain/emain.ts\",\n                },\n            },\n            outDir: \"dist/main\",\n            externalizeDeps: false,\n        },\n        plugins: [tsconfigPaths()],\n        resolve: {\n            alias: {\n                \"@\": \"frontend\",\n            },\n        },\n        server: {\n            open: false,\n        },\n        define: {\n            \"process.env.WS_NO_BUFFER_UTIL\": \"true\",\n            \"process.env.WS_NO_UTF_8_VALIDATE\": \"true\",\n        },\n    },\n    preload: {\n        root: \".\",\n        build: {\n            target: NODE,\n            sourcemap: true,\n            rollupOptions: {\n                input: {\n                    index: \"emain/preload.ts\",\n                    \"preload-webview\": \"emain/preload-webview.ts\",\n                },\n                output: {\n                    format: \"cjs\",\n                },\n            },\n            outDir: \"dist/preload\",\n            externalizeDeps: false,\n        },\n        server: {\n            open: false,\n        },\n        plugins: [tsconfigPaths()],\n    },\n    renderer: {\n        root: \".\",\n        build: {\n            target: CHROME,\n            sourcemap: true,\n            outDir: \"dist/frontend\",\n            rollupOptions: {\n                input: {\n                    index: \"index.html\",\n                },\n                output: {\n                    manualChunks(id) {\n                        const p = id.replace(/\\\\/g, \"/\");\n                        if (p.includes(\"node_modules/monaco\") || p.includes(\"node_modules/@monaco\")) return \"monaco\";\n                        if (p.includes(\"node_modules/mermaid\") || p.includes(\"node_modules/@mermaid\")) return \"mermaid\";\n                        if (p.includes(\"node_modules/katex\") || p.includes(\"node_modules/@katex\")) return \"katex\";\n                        if (p.includes(\"node_modules/shiki\") || p.includes(\"node_modules/@shiki\")) {\n                            return \"shiki\";\n                        }\n                        if (p.includes(\"node_modules/cytoscape\") || p.includes(\"node_modules/@cytoscape\"))\n                            return \"cytoscape\";\n                        return undefined;\n                    },\n                },\n            },\n        },\n        optimizeDeps: {\n            include: [\"monaco-yaml/yaml.worker.js\"],\n        },\n        server: {\n            open: false,\n            watch: {\n                ignored: [\n                    \"dist/**\",\n                    \"**/*.go\",\n                    \"**/go.mod\",\n                    \"**/go.sum\",\n                    \"**/*.md\",\n                    \"**/*.mdx\",\n                    \"**/*.json\",\n                    \"emain/**\",\n                    \"**/*.txt\",\n                    \"**/*.log\",\n                ],\n            },\n        },\n        css: {\n            preprocessorOptions: {\n                scss: {\n                    silenceDeprecations: [\"mixed-decls\"],\n                },\n            },\n        },\n        plugins: [\n            tsconfigPaths(),\n            { ...ViteImageOptimizer(), apply: \"build\" },\n            svgr({\n                svgrOptions: { exportType: \"default\", ref: true, svgo: false, titleProp: true },\n                include: \"**/*.svg\",\n            }),\n            react({}),\n            tailwindcss(),\n        ],\n    },\n});\n"
  },
  {
    "path": "emain/authkey.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { ipcMain } from \"electron\";\nimport { getWebServerEndpoint, getWSServerEndpoint } from \"../frontend/util/endpoints\";\n\nconst AuthKeyHeader = \"X-AuthKey\";\nexport const WaveAuthKeyEnv = \"WAVETERM_AUTH_KEY\";\nexport const AuthKey = crypto.randomUUID();\n\nipcMain.on(\"get-auth-key\", (event) => {\n    event.returnValue = AuthKey;\n});\n\nexport function configureAuthKeyRequestInjection(session: Electron.Session) {\n    const filter: Electron.WebRequestFilter = {\n        urls: [`${getWebServerEndpoint()}/*`, `${getWSServerEndpoint()}/*`],\n    };\n    session.webRequest.onBeforeSendHeaders(filter, (details, callback) => {\n        details.requestHeaders[AuthKeyHeader] = AuthKey;\n        callback({ requestHeaders: details.requestHeaders });\n    });\n}\n"
  },
  {
    "path": "emain/emain-activity.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// for activity updates\nlet wasActive = true;\nlet wasInFg = true;\nlet globalIsQuitting = false;\nlet globalIsStarting = true;\nlet globalIsRelaunching = false;\nlet forceQuit = false;\nlet userConfirmedQuit = false;\nlet termCommandsRun = 0;\nlet termCommandsRemote = 0;\nlet termCommandsWsl = 0;\nlet termCommandsDurable = 0;\n\nexport function setWasActive(val: boolean) {\n    wasActive = val;\n}\n\nexport function setWasInFg(val: boolean) {\n    wasInFg = val;\n}\n\nexport function getActivityState(): { wasActive: boolean; wasInFg: boolean } {\n    return { wasActive, wasInFg };\n}\n\nexport function setGlobalIsQuitting(val: boolean) {\n    globalIsQuitting = val;\n}\n\nexport function getGlobalIsQuitting(): boolean {\n    return globalIsQuitting;\n}\n\nexport function setGlobalIsStarting(val: boolean) {\n    globalIsStarting = val;\n}\n\nexport function getGlobalIsStarting(): boolean {\n    return globalIsStarting;\n}\n\nexport function setGlobalIsRelaunching(val: boolean) {\n    globalIsRelaunching = val;\n}\n\nexport function getGlobalIsRelaunching(): boolean {\n    return globalIsRelaunching;\n}\n\nexport function setForceQuit(val: boolean) {\n    forceQuit = val;\n}\n\nexport function getForceQuit(): boolean {\n    return forceQuit;\n}\n\nexport function setUserConfirmedQuit(val: boolean) {\n    userConfirmedQuit = val;\n}\n\nexport function getUserConfirmedQuit(): boolean {\n    return userConfirmedQuit;\n}\n\nexport function incrementTermCommandsRun() {\n    termCommandsRun++;\n}\n\nexport function getAndClearTermCommandsRun(): number {\n    const count = termCommandsRun;\n    termCommandsRun = 0;\n    return count;\n}\n\nexport function incrementTermCommandsRemote() {\n    termCommandsRemote++;\n}\n\nexport function getAndClearTermCommandsRemote(): number {\n    const count = termCommandsRemote;\n    termCommandsRemote = 0;\n    return count;\n}\n\nexport function incrementTermCommandsWsl() {\n    termCommandsWsl++;\n}\n\nexport function getAndClearTermCommandsWsl(): number {\n    const count = termCommandsWsl;\n    termCommandsWsl = 0;\n    return count;\n}\n\nexport function incrementTermCommandsDurable() {\n    termCommandsDurable++;\n}\n\nexport function getAndClearTermCommandsDurable(): number {\n    const count = termCommandsDurable;\n    termCommandsDurable = 0;\n    return count;\n}\n"
  },
  {
    "path": "emain/emain-builder.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { ClientService } from \"@/app/store/services\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { randomUUID } from \"crypto\";\nimport { BrowserWindow } from \"electron\";\nimport { globalEvents } from \"emain/emain-events\";\nimport path from \"path\";\nimport { getElectronAppBasePath, isDevVite, unamePlatform } from \"./emain-platform\";\nimport { calculateWindowBounds, MinWindowHeight, MinWindowWidth } from \"./emain-window\";\nimport { ElectronWshClient } from \"./emain-wsh\";\n\nexport type BuilderWindowType = BrowserWindow & {\n    builderId: string;\n    builderAppId?: string;\n    savedInitOpts: BuilderInitOpts;\n};\n\nconst builderWindows: BuilderWindowType[] = [];\nexport let focusedBuilderWindow: BuilderWindowType = null;\n\nexport function getBuilderWindowById(builderId: string): BuilderWindowType {\n    return builderWindows.find((win) => win.builderId === builderId);\n}\n\nexport function getBuilderWindowByWebContentsId(webContentsId: number): BuilderWindowType {\n    return builderWindows.find((win) => win.webContents.id === webContentsId);\n}\n\nexport function getAllBuilderWindows(): BuilderWindowType[] {\n    return builderWindows;\n}\n\nexport async function createBuilderWindow(appId: string): Promise<BuilderWindowType> {\n    const builderId = randomUUID();\n\n    const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient);\n    const clientData = await ClientService.GetClientData();\n    const clientId = clientData?.oid;\n    const windowId = randomUUID();\n\n    if (appId) {\n        const oref = `builder:${builderId}`;\n        await RpcApi.SetRTInfoCommand(ElectronWshClient, {\n            oref,\n            data: { \"builder:appid\": appId },\n        });\n    }\n\n    const winBounds = calculateWindowBounds(undefined, undefined, fullConfig.settings);\n\n    const builderWindow = new BrowserWindow({\n        x: winBounds.x,\n        y: winBounds.y,\n        width: winBounds.width,\n        height: winBounds.height,\n        minWidth: MinWindowWidth,\n        minHeight: MinWindowHeight,\n        titleBarStyle: unamePlatform === \"darwin\" ? \"hiddenInset\" : \"default\",\n        icon:\n            unamePlatform === \"linux\"\n                ? path.join(getElectronAppBasePath(), \"public/logos/wave-logo-dark.png\")\n                : undefined,\n        show: false,\n        backgroundColor: \"#222222\",\n        webPreferences: {\n            preload: path.join(getElectronAppBasePath(), \"preload\", \"index.cjs\"),\n            webviewTag: true,\n        },\n    });\n\n    if (isDevVite) {\n        await builderWindow.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html`);\n    } else {\n        await builderWindow.loadFile(path.join(getElectronAppBasePath(), \"frontend\", \"index.html\"));\n    }\n\n    const initOpts: BuilderInitOpts = {\n        builderId,\n        clientId,\n        windowId,\n    };\n\n    const typedBuilderWindow = builderWindow as BuilderWindowType;\n    typedBuilderWindow.builderId = builderId;\n    typedBuilderWindow.builderAppId = appId;\n    typedBuilderWindow.savedInitOpts = initOpts;\n\n    typedBuilderWindow.on(\"focus\", () => {\n        focusedBuilderWindow = typedBuilderWindow;\n        console.log(\"builder window focused\", builderId);\n        setTimeout(() => globalEvents.emit(\"windows-updated\"), 50);\n    });\n\n    typedBuilderWindow.on(\"blur\", () => {\n        if (focusedBuilderWindow === typedBuilderWindow) {\n            focusedBuilderWindow = null;\n        }\n        setTimeout(() => globalEvents.emit(\"windows-updated\"), 50);\n    });\n\n    typedBuilderWindow.on(\"closed\", () => {\n        console.log(\"builder window closed\", builderId);\n        const index = builderWindows.indexOf(typedBuilderWindow);\n        if (index !== -1) {\n            builderWindows.splice(index, 1);\n        }\n        if (focusedBuilderWindow === typedBuilderWindow) {\n            focusedBuilderWindow = null;\n        }\n        RpcApi.DeleteBuilderCommand(ElectronWshClient, builderId, { noresponse: true });\n        setTimeout(() => globalEvents.emit(\"windows-updated\"), 50);\n    });\n\n    builderWindows.push(typedBuilderWindow);\n    typedBuilderWindow.show();\n\n    console.log(\"created builder window\", builderId, appId);\n    return typedBuilderWindow;\n}\n"
  },
  {
    "path": "emain/emain-events.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { EventEmitter } from \"events\";\n\ninterface GlobalEvents {\n    \"windows-updated\": () => void; // emitted whenever a window is opened/closed\n}\n\nclass GlobalEventEmitter extends EventEmitter {\n    emit<K extends keyof GlobalEvents>(event: K, ...args: Parameters<GlobalEvents[K]>): boolean {\n        return super.emit(event, ...args);\n    }\n\n    on<K extends keyof GlobalEvents>(event: K, listener: GlobalEvents[K]): this {\n        return super.on(event, listener);\n    }\n\n    once<K extends keyof GlobalEvents>(event: K, listener: GlobalEvents[K]): this {\n        return super.once(event, listener);\n    }\n\n    off<K extends keyof GlobalEvents>(event: K, listener: GlobalEvents[K]): this {\n        return super.off(event, listener);\n    }\n}\n\nconst globalEvents = new GlobalEventEmitter();\n\nexport { globalEvents };\n"
  },
  {
    "path": "emain/emain-ipc.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport * as electron from \"electron\";\nimport { FastAverageColor } from \"fast-average-color\";\nimport fs from \"fs\";\nimport * as child_process from \"node:child_process\";\nimport * as path from \"path\";\nimport { PNG } from \"pngjs\";\nimport { Readable } from \"stream\";\nimport { RpcApi } from \"../frontend/app/store/wshclientapi\";\nimport { getWebServerEndpoint } from \"../frontend/util/endpoints\";\nimport * as keyutil from \"../frontend/util/keyutil\";\nimport { fireAndForget, parseDataUrl } from \"../frontend/util/util\";\nimport {\n    incrementTermCommandsDurable,\n    incrementTermCommandsRemote,\n    incrementTermCommandsRun,\n    incrementTermCommandsWsl,\n    setWasActive,\n} from \"./emain-activity\";\nimport { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from \"./emain-builder\";\nimport { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from \"./emain-platform\";\nimport { getWaveTabViewByWebContentsId } from \"./emain-tabview\";\nimport { handleCtrlShiftState } from \"./emain-util\";\nimport { getWaveVersion } from \"./emain-wavesrv\";\nimport { createNewWaveWindow, getWaveWindowByWebContentsId } from \"./emain-window\";\nimport { ElectronWshClient } from \"./emain-wsh\";\n\nconst electronApp = electron.app;\n\nlet webviewFocusId: number = null;\nlet webviewKeys: string[] = [];\n\nexport function openBuilderWindow(appId?: string) {\n    const normalizedAppId = appId || \"\";\n    const existingBuilderWindows = getAllBuilderWindows();\n    const existingWindow = existingBuilderWindows.find((win) => win.builderAppId === normalizedAppId);\n    if (existingWindow) {\n        existingWindow.focus();\n        return;\n    }\n    fireAndForget(() => createBuilderWindow(normalizedAppId));\n}\n\ntype UrlInSessionResult = {\n    stream: Readable;\n    mimeType: string;\n    fileName: string;\n};\n\nfunction getSingleHeaderVal(headers: Record<string, string | string[]>, key: string): string {\n    const val = headers[key];\n    if (val == null) {\n        return null;\n    }\n    if (Array.isArray(val)) {\n        return val[0];\n    }\n    return val;\n}\n\nfunction cleanMimeType(mimeType: string): string {\n    if (mimeType == null) {\n        return null;\n    }\n    const parts = mimeType.split(\";\");\n    return parts[0].trim();\n}\n\nfunction getFileNameFromUrl(url: string): string {\n    try {\n        const pathname = new URL(url).pathname;\n        const filename = pathname.substring(pathname.lastIndexOf(\"/\") + 1);\n        return filename;\n    } catch (e) {\n        return null;\n    }\n}\n\nfunction getUrlInSession(session: Electron.Session, url: string): Promise<UrlInSessionResult> {\n    return new Promise((resolve, reject) => {\n        if (url.startsWith(\"data:\")) {\n            try {\n                const parsed = parseDataUrl(url);\n                const buffer = Buffer.from(parsed.buffer);\n                const readable = Readable.from(buffer);\n                resolve({ stream: readable, mimeType: parsed.mimeType, fileName: \"image\" });\n            } catch (err) {\n                return reject(err);\n            }\n            return;\n        }\n        const request = electron.net.request({\n            url,\n            method: \"GET\",\n            session,\n        });\n        const readable = new Readable({\n            read() {},\n        });\n        request.on(\"response\", (response) => {\n            const statusCode = response.statusCode;\n            if (statusCode < 200 || statusCode >= 300) {\n                readable.destroy();\n                request.abort();\n                reject(new Error(`HTTP request failed with status ${statusCode}: ${response.statusMessage || \"\"}`));\n                return;\n            }\n\n            const mimeType = cleanMimeType(getSingleHeaderVal(response.headers, \"content-type\"));\n            const fileName = getFileNameFromUrl(url) || \"image\";\n            response.on(\"data\", (chunk) => {\n                readable.push(chunk);\n            });\n            response.on(\"end\", () => {\n                readable.push(null);\n                resolve({ stream: readable, mimeType, fileName });\n            });\n            response.on(\"error\", (err) => {\n                readable.destroy(err);\n                reject(err);\n            });\n        });\n        request.on(\"error\", (err) => {\n            readable.destroy(err);\n            reject(err);\n        });\n        request.end();\n    });\n}\n\nfunction saveImageFileWithNativeDialog(\n    sender: electron.WebContents,\n    defaultFileName: string,\n    mimeType: string,\n    readStream: Readable\n) {\n    if (defaultFileName == null || defaultFileName == \"\") {\n        defaultFileName = \"image\";\n    }\n    const ww = electron.BrowserWindow.fromWebContents(sender);\n    if (ww == null) {\n        readStream.destroy();\n        return;\n    }\n    const mimeToExtension: { [key: string]: string } = {\n        \"image/png\": \"png\",\n        \"image/jpeg\": \"jpg\",\n        \"image/gif\": \"gif\",\n        \"image/webp\": \"webp\",\n        \"image/bmp\": \"bmp\",\n        \"image/tiff\": \"tiff\",\n        \"image/heic\": \"heic\",\n        \"image/svg+xml\": \"svg\",\n    };\n    function addExtensionIfNeeded(fileName: string, mimeType: string): string {\n        const extension = mimeToExtension[mimeType];\n        if (!path.extname(fileName) && extension) {\n            return `${fileName}.${extension}`;\n        }\n        return fileName;\n    }\n    defaultFileName = addExtensionIfNeeded(defaultFileName, mimeType);\n    electron.dialog\n        .showSaveDialog(ww, {\n            title: \"Save Image\",\n            defaultPath: defaultFileName,\n            filters: [{ name: \"Images\", extensions: [\"png\", \"jpg\", \"jpeg\", \"gif\", \"webp\", \"bmp\", \"tiff\", \"heic\"] }],\n        })\n        .then((file) => {\n            if (file.canceled) {\n                readStream.destroy();\n                return;\n            }\n            const writeStream = fs.createWriteStream(file.filePath);\n            readStream.pipe(writeStream);\n            writeStream.on(\"finish\", () => {\n                console.log(\"saved file\", file.filePath);\n            });\n            writeStream.on(\"error\", (err) => {\n                console.log(\"error saving file (writeStream)\", err);\n                readStream.destroy();\n            });\n            readStream.on(\"error\", (err) => {\n                console.error(\"error saving file (readStream)\", err);\n                writeStream.destroy();\n            });\n        })\n        .catch((err) => {\n            console.log(\"error trying to save file\", err);\n        });\n}\n\nexport function initIpcHandlers() {\n    electron.ipcMain.on(\"open-external\", (event, url) => {\n        if (url && typeof url === \"string\") {\n            fireAndForget(() =>\n                callWithOriginalXdgCurrentDesktopAsync(() =>\n                    electron.shell.openExternal(url).catch((err) => {\n                        console.error(`Failed to open URL ${url}:`, err);\n                    })\n                )\n            );\n        } else {\n            console.error(\"Invalid URL received in open-external event:\", url);\n        }\n    });\n\n    electron.ipcMain.on(\"webview-image-contextmenu\", (event: electron.IpcMainEvent, payload: { src: string }) => {\n        const menu = new electron.Menu();\n        const win = getWaveWindowByWebContentsId(event.sender.hostWebContents?.id);\n        if (win == null) {\n            return;\n        }\n        menu.append(\n            new electron.MenuItem({\n                label: \"Save Image\",\n                click: () => {\n                    const resultP = getUrlInSession(event.sender.session, payload.src);\n                    resultP\n                        .then((result) => {\n                            saveImageFileWithNativeDialog(\n                                event.sender.hostWebContents,\n                                result.fileName,\n                                result.mimeType,\n                                result.stream\n                            );\n                        })\n                        .catch((e) => {\n                            console.log(\"error getting image\", e);\n                        });\n                },\n            })\n        );\n        menu.popup();\n    });\n\n    electron.ipcMain.on(\"download\", (event, payload) => {\n        const baseName = encodeURIComponent(path.basename(payload.filePath));\n        const streamingUrl =\n            getWebServerEndpoint() + \"/wave/stream-file/\" + baseName + \"?path=\" + encodeURIComponent(payload.filePath);\n        event.sender.downloadURL(streamingUrl);\n    });\n\n    electron.ipcMain.on(\"get-cursor-point\", (event) => {\n        const tabView = getWaveTabViewByWebContentsId(event.sender.id);\n        if (tabView == null) {\n            event.returnValue = null;\n            return;\n        }\n        const screenPoint = electron.screen.getCursorScreenPoint();\n        const windowRect = tabView.getBounds();\n        const retVal: Electron.Point = {\n            x: screenPoint.x - windowRect.x,\n            y: screenPoint.y - windowRect.y,\n        };\n        event.returnValue = retVal;\n    });\n\n    electron.ipcMain.handle(\"capture-screenshot\", async (event, rect) => {\n        const tabView = getWaveTabViewByWebContentsId(event.sender.id);\n        if (!tabView) {\n            throw new Error(\"No tab view found for the given webContents id\");\n        }\n        const image = await tabView.webContents.capturePage(rect);\n        const base64String = image.toPNG().toString(\"base64\");\n        return `data:image/png;base64,${base64String}`;\n    });\n\n    electron.ipcMain.on(\"get-env\", (event, varName) => {\n        event.returnValue = process.env[varName] ?? null;\n    });\n\n    electron.ipcMain.on(\"get-about-modal-details\", (event) => {\n        event.returnValue = getWaveVersion() as AboutModalDetails;\n    });\n\n    electron.ipcMain.on(\"get-zoom-factor\", (event) => {\n        event.returnValue = event.sender.getZoomFactor();\n    });\n\n    const hasBeforeInputRegisteredMap = new Map<number, boolean>();\n\n    electron.ipcMain.on(\"webview-focus\", (event: Electron.IpcMainEvent, focusedId: number) => {\n        webviewFocusId = focusedId;\n        console.log(\"webview-focus\", focusedId);\n        if (focusedId == null) {\n            return;\n        }\n        const parentWc = event.sender;\n        const webviewWc = electron.webContents.fromId(focusedId);\n        if (webviewWc == null) {\n            webviewFocusId = null;\n            return;\n        }\n        if (!hasBeforeInputRegisteredMap.get(focusedId)) {\n            hasBeforeInputRegisteredMap.set(focusedId, true);\n            webviewWc.on(\"before-input-event\", (e, input) => {\n                let waveEvent = keyutil.adaptFromElectronKeyEvent(input);\n                handleCtrlShiftState(parentWc, waveEvent);\n                if (webviewFocusId != focusedId) {\n                    return;\n                }\n                if (input.type != \"keyDown\") {\n                    return;\n                }\n                for (let keyDesc of webviewKeys) {\n                    if (keyutil.checkKeyPressed(waveEvent, keyDesc)) {\n                        e.preventDefault();\n                        parentWc.send(\"reinject-key\", waveEvent);\n                        console.log(\"webview reinject-key\", keyDesc);\n                        return;\n                    }\n                }\n            });\n            webviewWc.on(\"destroyed\", () => {\n                hasBeforeInputRegisteredMap.delete(focusedId);\n            });\n        }\n    });\n\n    electron.ipcMain.on(\"register-global-webview-keys\", (event, keys: string[]) => {\n        webviewKeys = keys ?? [];\n    });\n\n    electron.ipcMain.on(\"set-keyboard-chord-mode\", (event) => {\n        event.returnValue = null;\n        const tabView = getWaveTabViewByWebContentsId(event.sender.id);\n        tabView?.setKeyboardChordMode(true);\n    });\n\n    electron.ipcMain.handle(\"set-is-active\", () => {\n        setWasActive(true);\n    });\n\n    const fac = new FastAverageColor();\n    electron.ipcMain.on(\"update-window-controls-overlay\", async (event, rect: Dimensions) => {\n        if (unamePlatform === \"darwin\") return;\n        try {\n            const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient);\n            if (fullConfig?.settings?.[\"window:nativetitlebar\"] && unamePlatform !== \"win32\") return;\n\n            const zoomFactor = event.sender.getZoomFactor();\n            const electronRect: Electron.Rectangle = {\n                x: rect.left * zoomFactor,\n                y: rect.top * zoomFactor,\n                height: rect.height * zoomFactor,\n                width: rect.width * zoomFactor,\n            };\n            const overlay = await event.sender.capturePage(electronRect);\n            const overlayBuffer = overlay.toPNG();\n            const png = PNG.sync.read(overlayBuffer);\n            const color = fac.prepareResult(fac.getColorFromArray4(png.data));\n            const ww = getWaveWindowByWebContentsId(event.sender.id);\n            if (ww == null) return;\n            ww.setTitleBarOverlay({\n                color: unamePlatform === \"linux\" ? color.rgba : \"#00000000\",\n                symbolColor: color.isDark ? \"white\" : \"black\",\n            });\n        } catch (e) {\n            console.error(\"Error updating window controls overlay:\", e);\n        }\n    });\n\n    electron.ipcMain.on(\"quicklook\", (event, filePath: string) => {\n        if (unamePlatform !== \"darwin\") return;\n        child_process.execFile(\"/usr/bin/qlmanage\", [\"-p\", filePath], (error, stdout, stderr) => {\n            if (error) {\n                console.error(`Error opening Quick Look: ${error}`);\n            }\n        });\n    });\n\n    electron.ipcMain.handle(\"clear-webview-storage\", async (event, webContentsId: number) => {\n        try {\n            const wc = electron.webContents.fromId(webContentsId);\n            if (wc && wc.session) {\n                await wc.session.clearStorageData();\n                console.log(\"Cleared cookies and storage for webContentsId:\", webContentsId);\n            }\n        } catch (e) {\n            console.error(\"Failed to clear cookies and storage:\", e);\n            throw e;\n        }\n    });\n\n    electron.ipcMain.on(\"open-native-path\", (event, filePath: string) => {\n        console.log(\"open-native-path\", filePath);\n        filePath = filePath.replace(\"~\", electronApp.getPath(\"home\"));\n        fireAndForget(() =>\n            callWithOriginalXdgCurrentDesktopAsync(() =>\n                electron.shell.openPath(filePath).then((excuse) => {\n                    if (excuse) console.error(`Failed to open ${filePath} in native application: ${excuse}`);\n                })\n            )\n        );\n    });\n\n    electron.ipcMain.on(\"set-window-init-status\", (event, status: \"ready\" | \"wave-ready\") => {\n        const tabView = getWaveTabViewByWebContentsId(event.sender.id);\n        if (tabView != null && tabView.initResolve != null) {\n            if (status === \"ready\") {\n                tabView.initResolve();\n                if (tabView.savedInitOpts) {\n                    console.log(\"savedInitOpts calling wave-init\", tabView.waveTabId);\n                    tabView.webContents.send(\"wave-init\", tabView.savedInitOpts);\n                }\n            } else if (status === \"wave-ready\") {\n                tabView.waveReadyResolve();\n            }\n            return;\n        }\n\n        const builderWindow = getBuilderWindowByWebContentsId(event.sender.id);\n        if (builderWindow != null) {\n            if (status === \"ready\") {\n                if (builderWindow.savedInitOpts) {\n                    console.log(\"savedInitOpts calling builder-init\", builderWindow.savedInitOpts.builderId);\n                    builderWindow.webContents.send(\"builder-init\", builderWindow.savedInitOpts);\n                }\n            }\n            return;\n        }\n\n        console.log(\"set-window-init-status: no window found for webContentsId\", event.sender.id);\n    });\n\n    electron.ipcMain.on(\"fe-log\", (event, logStr: string) => {\n        console.log(\"fe-log\", logStr);\n    });\n\n    electron.ipcMain.on(\n        \"increment-term-commands\",\n        (event, opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => {\n            incrementTermCommandsRun();\n            if (opts?.isRemote) {\n                incrementTermCommandsRemote();\n            }\n            if (opts?.isWsl) {\n                incrementTermCommandsWsl();\n            }\n            if (opts?.isDurable) {\n                incrementTermCommandsDurable();\n            }\n        }\n    );\n\n    electron.ipcMain.on(\"native-paste\", (event) => {\n        event.sender.paste();\n    });\n\n    electron.ipcMain.on(\"open-builder\", (event, appId?: string) => {\n        openBuilderWindow(appId);\n    });\n\n    electron.ipcMain.on(\"set-builder-window-appid\", (event, appId: string) => {\n        const bw = getBuilderWindowByWebContentsId(event.sender.id);\n        if (bw == null) {\n            return;\n        }\n        bw.builderAppId = appId;\n        console.log(\"set-builder-window-appid\", bw.builderId, appId);\n    });\n\n    electron.ipcMain.on(\"open-new-window\", () => fireAndForget(createNewWaveWindow));\n\n    electron.ipcMain.on(\"close-builder-window\", async (event) => {\n        const bw = getBuilderWindowByWebContentsId(event.sender.id);\n        if (bw == null) {\n            return;\n        }\n        const builderId = bw.builderId;\n        if (builderId) {\n            try {\n                await RpcApi.SetRTInfoCommand(ElectronWshClient, {\n                    oref: `builder:${builderId}`,\n                    data: {} as ObjRTInfo,\n                    delete: true,\n                });\n            } catch (e) {\n                console.error(\"Error deleting builder rtinfo:\", e);\n            }\n        }\n        bw.destroy();\n    });\n\n    electron.ipcMain.on(\"do-refresh\", (event) => {\n        event.sender.reloadIgnoringCache();\n    });\n\n    electron.ipcMain.handle(\"save-text-file\", async (event, fileName: string, content: string) => {\n        const ww = electron.BrowserWindow.fromWebContents(event.sender);\n        if (ww == null) {\n            return false;\n        }\n        const result = await electron.dialog.showSaveDialog(ww, {\n            title: \"Save Scrollback\",\n            defaultPath: fileName || \"session.log\",\n            filters: [{ name: \"Text Files\", extensions: [\"txt\", \"log\"] }],\n        });\n        if (result.canceled || !result.filePath) {\n            return false;\n        }\n        try {\n            await fs.promises.writeFile(result.filePath, content, \"utf-8\");\n            console.log(\"saved scrollback to\", result.filePath);\n            return true;\n        } catch (err) {\n            console.error(\"error saving scrollback file\", err);\n            return false;\n        }\n    });\n}\n"
  },
  {
    "path": "emain/emain-log.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport fs from \"fs\";\nimport path from \"path\";\nimport { format } from \"util\";\nimport winston from \"winston\";\nimport { getWaveDataDir, isDev } from \"./emain-platform\";\n\nconst oldConsoleLog = console.log;\n\nfunction findHighestLogNumber(logsDir: string): number {\n    if (!fs.existsSync(logsDir)) {\n        return 0;\n    }\n    const files = fs.readdirSync(logsDir);\n    let maxNum = 0;\n    for (const file of files) {\n        const match = file.match(/^waveapp\\.(\\d+)\\.log$/);\n        if (match) {\n            const num = parseInt(match[1], 10);\n            if (num > maxNum) {\n                maxNum = num;\n            }\n        }\n    }\n    return maxNum;\n}\n\nfunction pruneOldLogs(logsDir: string): { pruned: string[]; error: any } {\n    if (!fs.existsSync(logsDir)) {\n        return { pruned: [], error: null };\n    }\n\n    const files = fs.readdirSync(logsDir);\n    const logFiles: { name: string; num: number }[] = [];\n\n    for (const file of files) {\n        const match = file.match(/^waveapp\\.(\\d+)\\.log$/);\n        if (match) {\n            logFiles.push({ name: file, num: parseInt(match[1], 10) });\n        }\n    }\n\n    if (logFiles.length <= 5) {\n        return { pruned: [], error: null };\n    }\n\n    logFiles.sort((a, b) => b.num - a.num);\n    const toDelete = logFiles.slice(5);\n    const pruned: string[] = [];\n    let firstError: any = null;\n\n    for (const logFile of toDelete) {\n        try {\n            fs.unlinkSync(path.join(logsDir, logFile.name));\n            pruned.push(logFile.name);\n        } catch (e) {\n            if (firstError == null) {\n                firstError = e;\n            }\n        }\n    }\n\n    return { pruned, error: firstError };\n}\n\nfunction rotateLogIfNeeded(): string | null {\n    const waveDataDir = getWaveDataDir();\n    const logFile = path.join(waveDataDir, \"waveapp.log\");\n    const logsDir = path.join(waveDataDir, \"logs\");\n\n    if (!fs.existsSync(logsDir)) {\n        fs.mkdirSync(logsDir, { recursive: true });\n    }\n\n    if (!fs.existsSync(logFile)) {\n        return null;\n    }\n\n    const stats = fs.statSync(logFile);\n    if (stats.size > 10 * 1024 * 1024) {\n        const nextNum = findHighestLogNumber(logsDir) + 1;\n        const rotatedPath = path.join(logsDir, `waveapp.${nextNum}.log`);\n        fs.renameSync(logFile, rotatedPath);\n        return rotatedPath;\n    }\n    return null;\n}\n\nlet logRotateError: any = null;\nlet rotatedPath: string | null = null;\nlet prunedFiles: string[] = [];\nlet pruneError: any = null;\ntry {\n    rotatedPath = rotateLogIfNeeded();\n    const logsDir = path.join(getWaveDataDir(), \"logs\");\n    const pruneResult = pruneOldLogs(logsDir);\n    prunedFiles = pruneResult.pruned;\n    pruneError = pruneResult.error;\n} catch (e) {\n    logRotateError = e;\n}\n\nconst loggerTransports: winston.transport[] = [\n    new winston.transports.File({ filename: path.join(getWaveDataDir(), \"waveapp.log\"), level: \"info\" }),\n];\nif (isDev) {\n    loggerTransports.push(new winston.transports.Console());\n}\nconst loggerConfig = {\n    level: \"info\",\n    format: winston.format.combine(\n        winston.format.timestamp({ format: \"YYYY-MM-DD HH:mm:ss.SSS\" }),\n        winston.format.printf((info) => `${info.timestamp} ${info.message}`)\n    ),\n    transports: loggerTransports,\n};\nconst logger = winston.createLogger(loggerConfig);\n\nfunction log(...msg: any[]) {\n    try {\n        logger.info(format(...msg));\n    } catch (e) {\n        oldConsoleLog(...msg);\n    }\n}\n\nif (logRotateError != null) {\n    log(\"error rotating/pruning logs (non-fatal):\", logRotateError);\n}\nif (rotatedPath != null) {\n    log(\"rotated old log file to:\", rotatedPath);\n}\nif (prunedFiles.length > 0) {\n    log(\"pruned old log files:\", prunedFiles.join(\", \"));\n}\nif (pruneError != null) {\n    log(\"error pruning some log files (non-fatal):\", pruneError);\n}\n\nexport { log };\n"
  },
  {
    "path": "emain/emain-menu.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { waveEventSubscribeSingle } from \"@/app/store/wps\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport * as electron from \"electron\";\nimport { fireAndForget } from \"../frontend/util/util\";\nimport { focusedBuilderWindow, getBuilderWindowById } from \"./emain-builder\";\nimport { openBuilderWindow } from \"./emain-ipc\";\nimport { isDev, unamePlatform } from \"./emain-platform\";\nimport { clearTabCache } from \"./emain-tabview\";\nimport { decreaseZoomLevel, increaseZoomLevel, resetZoomLevel } from \"./emain-util\";\nimport {\n    createNewWaveWindow,\n    createWorkspace,\n    focusedWaveWindow,\n    getAllWaveWindows,\n    getWaveWindowByWorkspaceId,\n    relaunchBrowserWindows,\n    WaveBrowserWindow,\n} from \"./emain-window\";\nimport { ElectronWshClient } from \"./emain-wsh\";\nimport { updater } from \"./updater\";\n\ntype AppMenuCallbacks = {\n    createNewWaveWindow: () => Promise<void>;\n    relaunchBrowserWindows: () => Promise<void>;\n};\n\nfunction getWindowWebContents(window: electron.BaseWindow): electron.WebContents {\n    if (window == null) {\n        return null;\n    }\n    // Check BrowserWindow first (for Tsunami Builder windows)\n    if (window instanceof electron.BrowserWindow) {\n        return window.webContents;\n    }\n    // Check WaveBrowserWindow (for main Wave windows with tab views)\n    if (window instanceof WaveBrowserWindow) {\n        if (window.activeTabView) {\n            return window.activeTabView.webContents;\n        }\n        return null;\n    }\n    return null;\n}\n\nasync function getWorkspaceMenu(ww?: WaveBrowserWindow): Promise<Electron.MenuItemConstructorOptions[]> {\n    const workspaceList = await RpcApi.WorkspaceListCommand(ElectronWshClient);\n    const workspaceMenu: Electron.MenuItemConstructorOptions[] = [\n        {\n            label: \"Create Workspace\",\n            click: (_, window) => fireAndForget(() => createWorkspace((window as WaveBrowserWindow) ?? ww)),\n        },\n    ];\n    function getWorkspaceSwitchAccelerator(i: number): string {\n        if (i < 9) {\n            return unamePlatform == \"darwin\" ? `Command+Control+${i + 1}` : `Alt+Control+${i + 1}`;\n        }\n    }\n    if (workspaceList?.length) {\n        workspaceMenu.push(\n            { type: \"separator\" },\n            ...workspaceList.map<Electron.MenuItemConstructorOptions>((workspace, i) => {\n                return {\n                    label: `${workspace.workspacedata.name}`,\n                    click: (_, window) => {\n                        ((window as WaveBrowserWindow) ?? ww)?.switchWorkspace(workspace.workspacedata.oid);\n                    },\n                    accelerator: getWorkspaceSwitchAccelerator(i),\n                };\n            })\n        );\n    }\n    return workspaceMenu;\n}\n\nfunction makeEditMenu(fullConfig?: FullConfigType): Electron.MenuItemConstructorOptions[] {\n    let pasteAccelerator: string;\n    if (unamePlatform === \"darwin\") {\n        pasteAccelerator = \"Command+V\";\n    } else {\n        const ctrlVPaste = fullConfig?.settings?.[\"app:ctrlvpaste\"];\n        if (ctrlVPaste == null) {\n            pasteAccelerator = unamePlatform === \"win32\" ? \"Control+V\" : \"\";\n        } else if (ctrlVPaste) {\n            pasteAccelerator = \"Control+V\";\n        } else {\n            pasteAccelerator = \"\";\n        }\n    }\n    return [\n        {\n            role: \"undo\",\n            accelerator: unamePlatform === \"darwin\" ? \"Command+Z\" : \"\",\n        },\n        {\n            role: \"redo\",\n            accelerator: unamePlatform === \"darwin\" ? \"Command+Shift+Z\" : \"\",\n        },\n        { type: \"separator\" },\n        {\n            role: \"cut\",\n            accelerator: unamePlatform === \"darwin\" ? \"Command+X\" : \"\",\n        },\n        {\n            role: \"copy\",\n            accelerator: unamePlatform === \"darwin\" ? \"Command+C\" : \"\",\n        },\n        {\n            role: \"paste\",\n            accelerator: pasteAccelerator,\n        },\n        {\n            role: \"pasteAndMatchStyle\",\n            accelerator: unamePlatform === \"darwin\" ? \"Command+Shift+V\" : \"\",\n        },\n        {\n            role: \"delete\",\n        },\n        {\n            role: \"selectAll\",\n            accelerator: unamePlatform === \"darwin\" ? \"Command+A\" : \"\",\n        },\n    ];\n}\n\nfunction makeFileMenu(\n    numWaveWindows: number,\n    callbacks: AppMenuCallbacks,\n    fullConfig: FullConfigType\n): Electron.MenuItemConstructorOptions[] {\n    const fileMenu: Electron.MenuItemConstructorOptions[] = [\n        {\n            label: \"New Window\",\n            accelerator: \"CommandOrControl+Shift+N\",\n            click: () => fireAndForget(callbacks.createNewWaveWindow),\n        },\n        {\n            role: \"close\",\n            accelerator: \"\",\n            click: () => {\n                focusedWaveWindow?.close();\n            },\n        },\n    ];\n    const featureWaveAppBuilder = fullConfig?.settings?.[\"feature:waveappbuilder\"];\n    if (isDev || featureWaveAppBuilder) {\n        fileMenu.splice(1, 0, {\n            label: \"New WaveApp Builder Window\",\n            accelerator: unamePlatform === \"darwin\" ? \"Command+Shift+B\" : \"Alt+Shift+B\",\n            click: () => openBuilderWindow(\"\"),\n        });\n    }\n    if (numWaveWindows == 0) {\n        fileMenu.push({\n            label: \"New Window (hidden-1)\",\n            accelerator: unamePlatform === \"darwin\" ? \"Command+N\" : \"Alt+N\",\n            acceleratorWorksWhenHidden: true,\n            visible: false,\n            click: () => fireAndForget(callbacks.createNewWaveWindow),\n        });\n        fileMenu.push({\n            label: \"New Window (hidden-2)\",\n            accelerator: unamePlatform === \"darwin\" ? \"Command+T\" : \"Alt+T\",\n            acceleratorWorksWhenHidden: true,\n            visible: false,\n            click: () => fireAndForget(callbacks.createNewWaveWindow),\n        });\n    }\n    return fileMenu;\n}\n\nfunction makeAppMenuItems(webContents: electron.WebContents): Electron.MenuItemConstructorOptions[] {\n    const appMenuItems: Electron.MenuItemConstructorOptions[] = [\n        {\n            label: \"About Wave Terminal\",\n            click: (_, window) => {\n                (getWindowWebContents(window) ?? webContents)?.send(\"menu-item-about\");\n            },\n        },\n        {\n            label: \"Check for Updates\",\n            click: () => {\n                fireAndForget(() => updater?.checkForUpdates(true));\n            },\n        },\n        { type: \"separator\" },\n    ];\n    if (unamePlatform === \"darwin\") {\n        appMenuItems.push(\n            { role: \"services\" },\n            { type: \"separator\" },\n            { role: \"hide\" },\n            { role: \"hideOthers\" },\n            { type: \"separator\" }\n        );\n    }\n    appMenuItems.push({ role: \"quit\" });\n    return appMenuItems;\n}\n\nfunction makeViewMenu(\n    webContents: electron.WebContents,\n    callbacks: AppMenuCallbacks,\n    isBuilderWindowFocused: boolean,\n    fullscreenOnLaunch: boolean\n): Electron.MenuItemConstructorOptions[] {\n    const devToolsAccel = unamePlatform === \"darwin\" ? \"Option+Command+I\" : \"Alt+Shift+I\";\n    return [\n        {\n            label: isBuilderWindowFocused ? \"Reload Window\" : \"Reload Tab\",\n            accelerator: \"Shift+CommandOrControl+R\",\n            click: (_, window) => {\n                (getWindowWebContents(window) ?? webContents)?.reloadIgnoringCache();\n            },\n        },\n        {\n            label: \"Relaunch All Windows\",\n            click: () => callbacks.relaunchBrowserWindows(),\n        },\n        {\n            label: \"Clear Tab Cache\",\n            click: () => clearTabCache(),\n        },\n        {\n            label: \"Toggle DevTools\",\n            accelerator: devToolsAccel,\n            click: (_, window) => {\n                const wc = getWindowWebContents(window) ?? webContents;\n                wc?.toggleDevTools();\n            },\n        },\n        { type: \"separator\" },\n        {\n            label: \"Reset Zoom\",\n            accelerator: \"CommandOrControl+0\",\n            click: (_, window) => {\n                const wc = getWindowWebContents(window) ?? webContents;\n                if (wc) {\n                    resetZoomLevel(wc);\n                }\n            },\n        },\n        {\n            label: \"Zoom In\",\n            accelerator: \"CommandOrControl+=\",\n            click: (_, window) => {\n                const wc = getWindowWebContents(window) ?? webContents;\n                if (wc) {\n                    increaseZoomLevel(wc);\n                }\n            },\n        },\n        {\n            label: \"Zoom In (hidden)\",\n            accelerator: \"CommandOrControl+Shift+=\",\n            click: (_, window) => {\n                const wc = getWindowWebContents(window) ?? webContents;\n                if (wc) {\n                    increaseZoomLevel(wc);\n                }\n            },\n            visible: false,\n            acceleratorWorksWhenHidden: true,\n        },\n        {\n            label: \"Zoom Out\",\n            accelerator: \"CommandOrControl+-\",\n            click: (_, window) => {\n                const wc = getWindowWebContents(window) ?? webContents;\n                if (wc) {\n                    decreaseZoomLevel(wc);\n                }\n            },\n        },\n        {\n            label: \"Zoom Out (hidden)\",\n            accelerator: \"CommandOrControl+Shift+-\",\n            click: (_, window) => {\n                const wc = getWindowWebContents(window) ?? webContents;\n                if (wc) {\n                    decreaseZoomLevel(wc);\n                }\n            },\n            visible: false,\n            acceleratorWorksWhenHidden: true,\n        },\n        {\n            label: \"Launch On Full Screen\",\n            submenu: [\n                {\n                    label: \"On\",\n                    type: \"radio\",\n                    checked: fullscreenOnLaunch,\n                    click: () => {\n                        RpcApi.SetConfigCommand(ElectronWshClient, { \"window:fullscreenonlaunch\": true });\n                    },\n                },\n                {\n                    label: \"Off\",\n                    type: \"radio\",\n                    checked: !fullscreenOnLaunch,\n                    click: () => {\n                        RpcApi.SetConfigCommand(ElectronWshClient, { \"window:fullscreenonlaunch\": false });\n                    },\n                },\n            ],\n        },\n        { type: \"separator\" },\n        {\n            role: \"togglefullscreen\",\n        },\n    ];\n}\n\nasync function makeFullAppMenu(callbacks: AppMenuCallbacks, workspaceOrBuilderId?: string): Promise<Electron.Menu> {\n    const numWaveWindows = getAllWaveWindows().length;\n    const webContents = workspaceOrBuilderId && getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId);\n    const appMenuItems = makeAppMenuItems(webContents);\n\n    const isBuilderWindowFocused = focusedBuilderWindow != null;\n    let fullscreenOnLaunch = false;\n    let fullConfig: FullConfigType = null;\n    try {\n        fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient);\n        fullscreenOnLaunch = fullConfig?.settings[\"window:fullscreenonlaunch\"];\n    } catch (e) {\n        console.error(\"Error fetching config:\", e);\n    }\n    const editMenu = makeEditMenu(fullConfig);\n    const fileMenu = makeFileMenu(numWaveWindows, callbacks, fullConfig);\n    const viewMenu = makeViewMenu(webContents, callbacks, isBuilderWindowFocused, fullscreenOnLaunch);\n    let workspaceMenu: Electron.MenuItemConstructorOptions[] = null;\n    try {\n        workspaceMenu = await getWorkspaceMenu();\n    } catch (e) {\n        console.error(\"getWorkspaceMenu error:\", e);\n    }\n    const windowMenu: Electron.MenuItemConstructorOptions[] = [\n        { role: \"minimize\", accelerator: \"\" },\n        { role: \"zoom\" },\n        { type: \"separator\" },\n        { role: \"front\" },\n    ];\n    const menuTemplate: Electron.MenuItemConstructorOptions[] = [\n        { role: \"appMenu\", submenu: appMenuItems },\n        { role: \"fileMenu\", submenu: fileMenu },\n        { role: \"editMenu\", submenu: editMenu },\n        { role: \"viewMenu\", submenu: viewMenu },\n    ];\n    if (workspaceMenu != null && !isBuilderWindowFocused) {\n        menuTemplate.push({\n            label: \"Workspace\",\n            id: \"workspace-menu\",\n            submenu: workspaceMenu,\n        });\n    }\n    menuTemplate.push({\n        role: \"windowMenu\",\n        submenu: windowMenu,\n    });\n    return electron.Menu.buildFromTemplate(menuTemplate);\n}\n\nexport function instantiateAppMenu(workspaceOrBuilderId?: string): Promise<electron.Menu> {\n    return makeFullAppMenu(\n        {\n            createNewWaveWindow,\n            relaunchBrowserWindows,\n        },\n        workspaceOrBuilderId\n    );\n}\n\n// does not a set a menu on windows\nexport function makeAndSetAppMenu() {\n    if (unamePlatform === \"win32\") {\n        return;\n    }\n    fireAndForget(async () => {\n        const menu = await instantiateAppMenu();\n        electron.Menu.setApplicationMenu(menu);\n    });\n}\n\nfunction initMenuEventSubscriptions() {\n    waveEventSubscribeSingle({\n        eventType: \"workspace:update\",\n        handler: makeAndSetAppMenu,\n    });\n}\n\nfunction getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId: string): electron.WebContents {\n    const ww = getWaveWindowByWorkspaceId(workspaceOrBuilderId);\n    if (ww) {\n        return ww.activeTabView?.webContents;\n    }\n\n    const bw = getBuilderWindowById(workspaceOrBuilderId);\n    if (bw) {\n        return bw.webContents;\n    }\n\n    return null;\n}\n\nfunction convertMenuDefArrToMenu(\n    webContents: electron.WebContents,\n    menuDefArr: ElectronContextMenuItem[],\n    menuState: { hasClick: boolean }\n): electron.Menu {\n    const menuItems: electron.MenuItem[] = [];\n    for (const menuDef of menuDefArr) {\n        const menuItemTemplate: electron.MenuItemConstructorOptions = {\n            role: menuDef.role as any,\n            label: menuDef.label,\n            type: menuDef.type,\n            click: () => {\n                menuState.hasClick = true;\n                webContents.send(\"contextmenu-click\", menuDef.id);\n            },\n            checked: menuDef.checked,\n            enabled: menuDef.enabled,\n        };\n        if (menuDef.submenu != null) {\n            menuItemTemplate.submenu = convertMenuDefArrToMenu(webContents, menuDef.submenu, menuState);\n        }\n        const menuItem = new electron.MenuItem(menuItemTemplate);\n        menuItems.push(menuItem);\n    }\n    return electron.Menu.buildFromTemplate(menuItems);\n}\n\nelectron.ipcMain.on(\n    \"contextmenu-show\",\n    (event, workspaceOrBuilderId: string, menuDefArr: ElectronContextMenuItem[]) => {\n        const webContents = getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId);\n        if (!webContents) {\n            console.error(\"invalid window for context menu:\", workspaceOrBuilderId);\n            event.returnValue = true;\n            return;\n        }\n        if (menuDefArr.length === 0) {\n            webContents.send(\"contextmenu-click\", null);\n            event.returnValue = true;\n            return;\n        }\n        fireAndForget(async () => {\n            const menuState = { hasClick: false };\n            const menu = convertMenuDefArrToMenu(webContents, menuDefArr, menuState);\n            menu.popup({\n                callback: () => {\n                    if (!menuState.hasClick) {\n                        webContents.send(\"contextmenu-click\", null);\n                    }\n                },\n            });\n        });\n        event.returnValue = true;\n    }\n);\n\nelectron.ipcMain.on(\"workspace-appmenu-show\", (event, workspaceId: string) => {\n    fireAndForget(async () => {\n        const webContents = getWebContentsByWorkspaceOrBuilderId(workspaceId);\n        if (!webContents) {\n            console.error(\"invalid window for workspace app menu:\", workspaceId);\n            return;\n        }\n        const menu = await instantiateAppMenu(workspaceId);\n        menu.popup();\n    });\n    event.returnValue = true;\n});\n\nelectron.ipcMain.on(\"builder-appmenu-show\", (event, builderId: string) => {\n    fireAndForget(async () => {\n        const webContents = getWebContentsByWorkspaceOrBuilderId(builderId);\n        if (!webContents) {\n            console.error(\"invalid window for builder app menu:\", builderId);\n            return;\n        }\n        const menu = await instantiateAppMenu(builderId);\n        menu.popup();\n    });\n    event.returnValue = true;\n});\n\nconst dockMenu = electron.Menu.buildFromTemplate([\n    {\n        label: \"New Window\",\n        click() {\n            fireAndForget(createNewWaveWindow);\n        },\n    },\n]);\n\nfunction makeDockTaskbar() {\n    if (unamePlatform == \"darwin\") {\n        electron.app.dock.setMenu(dockMenu);\n    }\n}\n\nexport { initMenuEventSubscriptions, makeDockTaskbar };\n"
  },
  {
    "path": "emain/emain-platform.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { fireAndForget } from \"@/util/util\";\nimport { app, dialog, ipcMain, shell } from \"electron\";\nimport envPaths from \"env-paths\";\nimport { existsSync, mkdirSync } from \"fs\";\nimport os from \"os\";\nimport path from \"path\";\nimport { WaveDevVarName, WaveDevViteVarName } from \"../frontend/util/isdev\";\nimport * as keyutil from \"../frontend/util/keyutil\";\n\n// This is a little trick to ensure that Electron puts all its runtime data into a subdirectory to avoid conflicts with our own data.\n// On macOS, it will store to ~/Library/Application \\Support/waveterm/electron\n// On Linux, it will store to ~/.config/waveterm/electron\n// On Windows, it will store to %LOCALAPPDATA%/waveterm/electron\napp.setName(\"waveterm/electron\");\n\nconst isDev = !app.isPackaged;\nconst isDevVite = isDev && process.env.ELECTRON_RENDERER_URL;\nconsole.log(`Running in ${isDev ? \"development\" : \"production\"} mode`);\nif (isDev) {\n    process.env[WaveDevVarName] = \"1\";\n}\nif (isDevVite) {\n    process.env[WaveDevViteVarName] = \"1\";\n}\n\nconst waveDirNamePrefix = \"waveterm\";\nconst waveDirNameSuffix = isDev ? \"dev\" : \"\";\nconst waveDirName = `${waveDirNamePrefix}${waveDirNameSuffix ? `-${waveDirNameSuffix}` : \"\"}`;\n\nconst paths = envPaths(\"waveterm\", { suffix: waveDirNameSuffix });\n\napp.setName(isDev ? \"Wave (Dev)\" : \"Wave\");\nconst unamePlatform = process.platform;\nconst unameArch: string = process.arch;\nkeyutil.setKeyUtilPlatform(unamePlatform);\n\nconst WaveConfigHomeVarName = \"WAVETERM_CONFIG_HOME\";\nconst WaveDataHomeVarName = \"WAVETERM_DATA_HOME\";\nconst WaveHomeVarName = \"WAVETERM_HOME\";\n\nexport function checkIfRunningUnderARM64Translation(fullConfig: FullConfigType) {\n    if (!fullConfig.settings[\"app:dismissarchitecturewarning\"] && app.runningUnderARM64Translation) {\n        console.log(\"Running under ARM64 translation, alerting user\");\n        const dialogOpts: Electron.MessageBoxOptions = {\n            type: \"warning\",\n            buttons: [\"Dismiss\", \"Learn More\"],\n            title: \"Wave has detected a performance issue\",\n            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.`,\n        };\n\n        const choice = dialog.showMessageBoxSync(null, dialogOpts);\n        if (choice === 1) {\n            // Open the documentation URL\n            console.log(\"User chose to learn more\");\n            fireAndForget(() =>\n                shell.openExternal(\n                    \"https://docs.waveterm.dev/faq#why-does-wave-warn-me-about-arm64-translation-when-it-launches\"\n                )\n            );\n            throw new Error(\"User redirected to docsite to learn more about ARM64 translation, exiting\");\n        } else {\n            console.log(\"User dismissed the dialog\");\n        }\n    }\n}\n\n/**\n * Gets the path to the old Wave home directory (defaults to `~/.waveterm`).\n * @returns The path to the directory if it exists and contains valid data for the current app, otherwise null.\n */\nfunction getWaveHomeDir(): string {\n    let home = process.env[WaveHomeVarName];\n    if (!home) {\n        const homeDir = app.getPath(\"home\");\n        if (homeDir) {\n            home = path.join(homeDir, `.${waveDirName}`);\n        }\n    }\n    // 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)\n    if (home && existsSync(home) && existsSync(path.join(home, \"wave.lock\"))) {\n        return home;\n    }\n    return null;\n}\n\n/**\n * Ensure the given path exists, creating it recursively if it doesn't.\n * @param path The path to ensure.\n * @returns The same path, for chaining.\n */\nfunction ensurePathExists(path: string): string {\n    if (!existsSync(path)) {\n        mkdirSync(path, { recursive: true });\n    }\n    return path;\n}\n\n/**\n * Gets the path to the directory where Wave configurations are stored. Creates the directory if it does not exist.\n * Handles backwards compatibility with the old Wave Home directory model, where configurations and data were stored together.\n * @returns The path where configurations should be stored.\n */\nfunction getWaveConfigDir(): string {\n    // If wave home dir exists, use it for backwards compatibility\n    const waveHomeDir = getWaveHomeDir();\n    if (waveHomeDir) {\n        return path.join(waveHomeDir, \"config\");\n    }\n\n    const override = process.env[WaveConfigHomeVarName];\n    const xdgConfigHome = process.env.XDG_CONFIG_HOME;\n    let retVal: string;\n    if (override) {\n        retVal = override;\n    } else if (xdgConfigHome) {\n        retVal = path.join(xdgConfigHome, waveDirName);\n    } else {\n        retVal = path.join(app.getPath(\"home\"), \".config\", waveDirName);\n    }\n    return ensurePathExists(retVal);\n}\n\n/**\n * Gets the path to the directory where Wave data is stored. Creates the directory if it does not exist.\n * Handles backwards compatibility with the old Wave Home directory model, where configurations and data were stored together.\n * @returns The path where data should be stored.\n */\nfunction getWaveDataDir(): string {\n    // If wave home dir exists, use it for backwards compatibility\n    const waveHomeDir = getWaveHomeDir();\n    if (waveHomeDir) {\n        return waveHomeDir;\n    }\n\n    const override = process.env[WaveDataHomeVarName];\n    const xdgDataHome = process.env.XDG_DATA_HOME;\n    let retVal: string;\n    if (override) {\n        retVal = override;\n    } else if (xdgDataHome) {\n        retVal = path.join(xdgDataHome, waveDirName);\n    } else {\n        retVal = paths.data;\n    }\n    return ensurePathExists(retVal);\n}\n\nfunction getElectronAppBasePath(): string {\n    // import.meta.dirname in dev points to waveterm/dist/main\n    return path.dirname(import.meta.dirname);\n}\n\nfunction getElectronAppUnpackedBasePath(): string {\n    return getElectronAppBasePath().replace(\"app.asar\", \"app.asar.unpacked\");\n}\n\nfunction getElectronAppResourcesPath(): string {\n    if (isDev) {\n        // import.meta.dirname in dev points to waveterm/dist/main\n        return path.dirname(import.meta.dirname);\n    }\n    return process.resourcesPath;\n}\n\nconst wavesrvBinName = `wavesrv.${unameArch}`;\n\nfunction getWaveSrvPath(): string {\n    if (process.platform === \"win32\") {\n        const winBinName = `${wavesrvBinName}.exe`;\n        const appPath = path.join(getElectronAppUnpackedBasePath(), \"bin\", winBinName);\n        return `${appPath}`;\n    }\n    return path.join(getElectronAppUnpackedBasePath(), \"bin\", wavesrvBinName);\n}\n\nfunction getWaveSrvCwd(): string {\n    return getWaveDataDir();\n}\n\nipcMain.on(\"get-is-dev\", (event) => {\n    event.returnValue = isDev;\n});\nipcMain.on(\"get-platform\", (event, url) => {\n    event.returnValue = unamePlatform;\n});\nipcMain.on(\"get-user-name\", (event) => {\n    const userInfo = os.userInfo();\n    event.returnValue = userInfo.username;\n});\nipcMain.on(\"get-host-name\", (event) => {\n    event.returnValue = os.hostname();\n});\nipcMain.on(\"get-webview-preload\", (event) => {\n    event.returnValue = path.join(getElectronAppBasePath(), \"preload\", \"preload-webview.cjs\");\n});\nipcMain.on(\"get-data-dir\", (event) => {\n    event.returnValue = getWaveDataDir();\n});\nipcMain.on(\"get-config-dir\", (event) => {\n    event.returnValue = getWaveConfigDir();\n});\nipcMain.on(\"get-home-dir\", (event) => {\n    event.returnValue = app.getPath(\"home\");\n});\n\n/**\n * Gets the value of the XDG_CURRENT_DESKTOP environment variable. If ORIGINAL_XDG_CURRENT_DESKTOP is set, it will be returned instead.\n * This corrects for a strange behavior in Electron, where it sets its own value for XDG_CURRENT_DESKTOP to improve Chromium compatibility.\n * @see https://www.electronjs.org/docs/latest/api/environment-variables#original_xdg_current_desktop\n * @returns The value of the XDG_CURRENT_DESKTOP environment variable, or ORIGINAL_XDG_CURRENT_DESKTOP if set, or undefined if neither are set.\n */\nfunction getXdgCurrentDesktop(): string {\n    if (process.env.ORIGINAL_XDG_CURRENT_DESKTOP) {\n        return process.env.ORIGINAL_XDG_CURRENT_DESKTOP;\n    } else if (process.env.XDG_CURRENT_DESKTOP) {\n        return process.env.XDG_CURRENT_DESKTOP;\n    } else {\n        return undefined;\n    }\n}\n\n/**\n * Calls the given callback with the value of the XDG_CURRENT_DESKTOP environment variable set to ORIGINAL_XDG_CURRENT_DESKTOP if it is set.\n * @see https://www.electronjs.org/docs/latest/api/environment-variables#original_xdg_current_desktop\n * @param callback The callback to call.\n */\nfunction callWithOriginalXdgCurrentDesktop(callback: () => void) {\n    const currXdgCurrentDesktopDefined = \"XDG_CURRENT_DESKTOP\" in process.env;\n    const currXdgCurrentDesktop = process.env.XDG_CURRENT_DESKTOP;\n    const originalXdgCurrentDesktop = getXdgCurrentDesktop();\n    if (originalXdgCurrentDesktop) {\n        process.env.XDG_CURRENT_DESKTOP = originalXdgCurrentDesktop;\n    }\n    callback();\n    if (originalXdgCurrentDesktop) {\n        if (currXdgCurrentDesktopDefined) {\n            process.env.XDG_CURRENT_DESKTOP = currXdgCurrentDesktop;\n        } else {\n            delete process.env.XDG_CURRENT_DESKTOP;\n        }\n    }\n}\n\n/**\n * 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.\n * @see https://www.electronjs.org/docs/latest/api/environment-variables#original_xdg_current_desktop\n * @param callback The async callback to call.\n */\nasync function callWithOriginalXdgCurrentDesktopAsync(callback: () => Promise<void>) {\n    const currXdgCurrentDesktopDefined = \"XDG_CURRENT_DESKTOP\" in process.env;\n    const currXdgCurrentDesktop = process.env.XDG_CURRENT_DESKTOP;\n    const originalXdgCurrentDesktop = getXdgCurrentDesktop();\n    if (originalXdgCurrentDesktop) {\n        process.env.XDG_CURRENT_DESKTOP = originalXdgCurrentDesktop;\n    }\n    await callback();\n    if (originalXdgCurrentDesktop) {\n        if (currXdgCurrentDesktopDefined) {\n            process.env.XDG_CURRENT_DESKTOP = currXdgCurrentDesktop;\n        } else {\n            delete process.env.XDG_CURRENT_DESKTOP;\n        }\n    }\n}\n\nexport {\n    callWithOriginalXdgCurrentDesktop,\n    callWithOriginalXdgCurrentDesktopAsync,\n    getElectronAppBasePath,\n    getElectronAppResourcesPath,\n    getElectronAppUnpackedBasePath,\n    getWaveConfigDir,\n    getWaveDataDir,\n    getWaveSrvCwd,\n    getWaveSrvPath,\n    getXdgCurrentDesktop,\n    isDev,\n    isDevVite,\n    unameArch,\n    unamePlatform,\n    WaveConfigHomeVarName,\n    WaveDataHomeVarName,\n};\n"
  },
  {
    "path": "emain/emain-tabview.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { adaptFromElectronKeyEvent, checkKeyPressed } from \"@/util/keyutil\";\nimport { CHORD_TIMEOUT } from \"@/util/sharedconst\";\nimport { Rectangle, shell, WebContentsView } from \"electron\";\nimport { createNewWaveWindow, getWaveWindowById } from \"emain/emain-window\";\nimport path from \"path\";\nimport { configureAuthKeyRequestInjection } from \"./authkey\";\nimport { setWasActive } from \"./emain-activity\";\nimport { getElectronAppBasePath, isDevVite, unamePlatform } from \"./emain-platform\";\nimport {\n    decreaseZoomLevel,\n    handleCtrlShiftFocus,\n    handleCtrlShiftState,\n    increaseZoomLevel,\n    resetZoomLevel,\n    shFrameNavHandler,\n    shNavHandler,\n} from \"./emain-util\";\nimport { ElectronWshClient } from \"./emain-wsh\";\n\nfunction handleWindowsMenuAccelerators(\n    waveEvent: WaveKeyboardEvent,\n    tabView: WaveTabView,\n    fullConfig: FullConfigType\n): boolean {\n    const waveWindow = getWaveWindowById(tabView.waveWindowId);\n\n    if (checkKeyPressed(waveEvent, \"Ctrl:Shift:n\")) {\n        createNewWaveWindow();\n        return true;\n    }\n\n    if (checkKeyPressed(waveEvent, \"Ctrl:Shift:r\")) {\n        tabView.webContents.reloadIgnoringCache();\n        return true;\n    }\n\n    if (checkKeyPressed(waveEvent, \"Ctrl:v\")) {\n        const ctrlVPaste = fullConfig?.settings?.[\"app:ctrlvpaste\"];\n        const shouldPaste = ctrlVPaste ?? true;\n        if (!shouldPaste) {\n            return false;\n        }\n        tabView.webContents.paste();\n        return true;\n    }\n\n    if (checkKeyPressed(waveEvent, \"Ctrl:0\")) {\n        resetZoomLevel(tabView.webContents);\n        return true;\n    }\n\n    if (checkKeyPressed(waveEvent, \"Ctrl:=\") || checkKeyPressed(waveEvent, \"Ctrl:Shift:=\")) {\n        increaseZoomLevel(tabView.webContents);\n        return true;\n    }\n\n    if (checkKeyPressed(waveEvent, \"Ctrl:-\") || checkKeyPressed(waveEvent, \"Ctrl:Shift:-\")) {\n        decreaseZoomLevel(tabView.webContents);\n        return true;\n    }\n\n    if (checkKeyPressed(waveEvent, \"F11\")) {\n        if (waveWindow) {\n            waveWindow.setFullScreen(!waveWindow.isFullScreen());\n        }\n        return true;\n    }\n\n    for (let i = 1; i <= 9; i++) {\n        if (checkKeyPressed(waveEvent, `Alt:Ctrl:${i}`)) {\n            const workspaceNum = i - 1;\n            RpcApi.WorkspaceListCommand(ElectronWshClient).then((workspaceList) => {\n                if (workspaceList && workspaceNum < workspaceList.length) {\n                    const workspace = workspaceList[workspaceNum];\n                    if (waveWindow) {\n                        waveWindow.switchWorkspace(workspace.workspacedata.oid);\n                    }\n                }\n            });\n            return true;\n        }\n    }\n\n    if (checkKeyPressed(waveEvent, \"Alt:Shift:i\")) {\n        tabView.webContents.toggleDevTools();\n        return true;\n    }\n\n    return false;\n}\n\nfunction computeBgColor(fullConfig: FullConfigType): string {\n    const settings = fullConfig?.settings;\n    const isTransparent = settings?.[\"window:transparent\"] ?? false;\n    const isBlur = !isTransparent && (settings?.[\"window:blur\"] ?? false);\n    if (isTransparent) {\n        return \"#00000000\";\n    } else if (isBlur) {\n        return \"#00000000\";\n    } else {\n        return \"#222222\";\n    }\n}\n\nconst wcIdToWaveTabMap = new Map<number, WaveTabView>();\n\nexport function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabView {\n    if (webContentsId == null) {\n        return null;\n    }\n    return wcIdToWaveTabMap.get(webContentsId);\n}\n\nexport class WaveTabView extends WebContentsView {\n    waveWindowId: string; // this will be set for any tabviews that are initialized. (unset for the hot spare)\n    isActiveTab: boolean;\n    isWaveAIOpen: boolean;\n    private _waveTabId: string; // always set, WaveTabViews are unique per tab\n    lastUsedTs: number; // ts milliseconds\n    createdTs: number; // ts milliseconds\n    initPromise: Promise<void>;\n    initResolve: () => void;\n    savedInitOpts: WaveInitOpts;\n    waveReadyPromise: Promise<void>;\n    waveReadyResolve: () => void;\n    isInitialized: boolean = false;\n    isWaveReady: boolean = false;\n    isDestroyed: boolean = false;\n    keyboardChordMode: boolean = false;\n    resetChordModeTimeout: NodeJS.Timeout = null;\n\n    constructor(fullConfig: FullConfigType) {\n        console.log(\"createBareTabView\");\n        super({\n            webPreferences: {\n                preload: path.join(getElectronAppBasePath(), \"preload\", \"index.cjs\"),\n                webviewTag: true,\n            },\n        });\n        this.createdTs = Date.now();\n        this.isWaveAIOpen = false;\n        this.savedInitOpts = null;\n        this.initPromise = new Promise((resolve, _) => {\n            this.initResolve = resolve;\n        });\n        this.initPromise.then(() => {\n            this.isInitialized = true;\n            console.log(\"tabview init\", Date.now() - this.createdTs + \"ms\");\n        });\n        this.waveReadyPromise = new Promise((resolve, _) => {\n            this.waveReadyResolve = resolve;\n        });\n        this.waveReadyPromise.then(() => {\n            this.isWaveReady = true;\n        });\n        const wcId = this.webContents.id;\n        wcIdToWaveTabMap.set(wcId, this);\n        if (isDevVite) {\n            this.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html`);\n        } else {\n            this.webContents.loadFile(path.join(getElectronAppBasePath(), \"frontend\", \"index.html\"));\n        }\n        this.webContents.on(\"destroyed\", () => {\n            wcIdToWaveTabMap.delete(wcId);\n            removeWaveTabView(this.waveTabId);\n            this.isDestroyed = true;\n        });\n        this.setBackgroundColor(computeBgColor(fullConfig));\n    }\n\n    get waveTabId(): string {\n        return this._waveTabId;\n    }\n\n    set waveTabId(waveTabId: string) {\n        this._waveTabId = waveTabId;\n    }\n\n    setKeyboardChordMode(mode: boolean) {\n        this.keyboardChordMode = mode;\n        if (mode) {\n            if (this.resetChordModeTimeout) {\n                clearTimeout(this.resetChordModeTimeout);\n            }\n            this.resetChordModeTimeout = setTimeout(() => {\n                this.keyboardChordMode = false;\n            }, CHORD_TIMEOUT);\n        } else {\n            if (this.resetChordModeTimeout) {\n                clearTimeout(this.resetChordModeTimeout);\n                this.resetChordModeTimeout = null;\n            }\n        }\n    }\n\n    positionTabOnScreen(winBounds: Rectangle) {\n        const curBounds = this.getBounds();\n        if (\n            curBounds.width == winBounds.width &&\n            curBounds.height == winBounds.height &&\n            curBounds.x == 0 &&\n            curBounds.y == 0\n        ) {\n            return;\n        }\n        this.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height });\n    }\n\n    positionTabOffScreen(winBounds: Rectangle) {\n        this.setBounds({\n            x: -15000,\n            y: -15000,\n            width: winBounds.width,\n            height: winBounds.height,\n        });\n    }\n\n    isOnScreen() {\n        const bounds = this.getBounds();\n        return bounds.x == 0 && bounds.y == 0;\n    }\n\n    destroy() {\n        console.log(\"destroy tab\", this.waveTabId);\n        removeWaveTabView(this.waveTabId);\n        if (!this.isDestroyed) {\n            this.webContents?.close();\n        }\n        this.isDestroyed = true;\n    }\n}\n\nlet MaxCacheSize = 10;\nconst wcvCache = new Map<string, WaveTabView>();\n\nexport function setMaxTabCacheSize(size: number) {\n    console.log(\"setMaxTabCacheSize\", size);\n    MaxCacheSize = size;\n}\n\nexport function getWaveTabView(waveTabId: string): WaveTabView | undefined {\n    const rtn = wcvCache.get(waveTabId);\n    if (rtn) {\n        rtn.lastUsedTs = Date.now();\n    }\n    return rtn;\n}\n\nfunction tryEvictEntry(waveTabId: string): boolean {\n    const tabView = wcvCache.get(waveTabId);\n    if (!tabView) {\n        return false;\n    }\n    if (tabView.isActiveTab) {\n        return false;\n    }\n    const lastUsedDiff = Date.now() - tabView.lastUsedTs;\n    if (lastUsedDiff < 1000) {\n        return false;\n    }\n    const ww = getWaveWindowById(tabView.waveWindowId);\n    if (!ww) {\n        // this shouldn't happen, but if it does, just destroy the tabview\n        console.log(\"[error] WaveWindow not found for WaveTabView\", tabView.waveTabId);\n        tabView.destroy();\n        return true;\n    } else {\n        // will trigger a destroy on the tabview\n        ww.removeTabView(tabView.waveTabId, false);\n        return true;\n    }\n}\n\nfunction checkAndEvictCache(): void {\n    if (wcvCache.size <= MaxCacheSize) {\n        return;\n    }\n    const sorted = Array.from(wcvCache.values()).sort((a, b) => {\n        // Prioritize entries which are active\n        if (a.isActiveTab && !b.isActiveTab) {\n            return -1;\n        }\n        // Otherwise, sort by lastUsedTs\n        return a.lastUsedTs - b.lastUsedTs;\n    });\n    for (let i = 0; i < sorted.length - MaxCacheSize; i++) {\n        tryEvictEntry(sorted[i].waveTabId);\n    }\n}\n\nexport function clearTabCache() {\n    const wcVals = Array.from(wcvCache.values());\n    for (let i = 0; i < wcVals.length; i++) {\n        const tabView = wcVals[i];\n        tryEvictEntry(tabView.waveTabId);\n    }\n}\n\n// returns [tabview, initialized]\nexport async function getOrCreateWebViewForTab(waveWindowId: string, tabId: string): Promise<[WaveTabView, boolean]> {\n    let tabView = getWaveTabView(tabId);\n    if (tabView) {\n        return [tabView, true];\n    }\n    const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient);\n    tabView = getSpareTab(fullConfig);\n    tabView.waveWindowId = waveWindowId;\n    tabView.lastUsedTs = Date.now();\n    setWaveTabView(tabId, tabView);\n    tabView.waveTabId = tabId;\n    tabView.webContents.on(\"will-navigate\", shNavHandler);\n    tabView.webContents.on(\"will-frame-navigate\", shFrameNavHandler);\n    tabView.webContents.on(\"did-attach-webview\", (event, wc) => {\n        wc.setWindowOpenHandler((details) => {\n            if (wc == null || wc.isDestroyed() || tabView.webContents == null || tabView.webContents.isDestroyed()) {\n                return { action: \"deny\" };\n            }\n            tabView.webContents.send(\"webview-new-window\", wc.id, details);\n            return { action: \"deny\" };\n        });\n    });\n    tabView.webContents.on(\"before-input-event\", (e, input) => {\n        const waveEvent = adaptFromElectronKeyEvent(input);\n        // console.log(\"WIN bie\", tabView.waveTabId.substring(0, 8), waveEvent.type, waveEvent.code);\n        handleCtrlShiftState(tabView.webContents, waveEvent);\n        setWasActive(true);\n        if (input.type == \"keyDown\" && tabView.keyboardChordMode) {\n            e.preventDefault();\n            tabView.setKeyboardChordMode(false);\n            tabView.webContents.send(\"reinject-key\", waveEvent);\n            return;\n        }\n\n        if (unamePlatform === \"win32\" && input.type == \"keyDown\") {\n            if (handleWindowsMenuAccelerators(waveEvent, tabView, fullConfig)) {\n                e.preventDefault();\n                return;\n            }\n        }\n    });\n    tabView.webContents.setWindowOpenHandler(({ url, frameName }) => {\n        if (url.startsWith(\"http://\") || url.startsWith(\"https://\") || url.startsWith(\"file://\")) {\n            console.log(\"openExternal fallback\", url);\n            shell.openExternal(url);\n        }\n        console.log(\"window-open denied\", url);\n        return { action: \"deny\" };\n    });\n    tabView.webContents.on(\"blur\", () => {\n        handleCtrlShiftFocus(tabView.webContents, false);\n    });\n    configureAuthKeyRequestInjection(tabView.webContents.session);\n    return [tabView, false];\n}\n\nexport function setWaveTabView(waveTabId: string, wcv: WaveTabView): void {\n    if (waveTabId == null) {\n        return;\n    }\n    wcvCache.set(waveTabId, wcv);\n    checkAndEvictCache();\n}\n\nfunction removeWaveTabView(waveTabId: string): void {\n    if (waveTabId == null) {\n        return;\n    }\n    wcvCache.delete(waveTabId);\n}\n\nlet HotSpareTab: WaveTabView = null;\n\nexport function ensureHotSpareTab(fullConfig: FullConfigType) {\n    console.log(\"ensureHotSpareTab\");\n    if (HotSpareTab == null) {\n        HotSpareTab = new WaveTabView(fullConfig);\n    }\n}\n\nexport function getSpareTab(fullConfig: FullConfigType): WaveTabView {\n    setTimeout(() => ensureHotSpareTab(fullConfig), 500);\n    if (HotSpareTab != null) {\n        const rtn = HotSpareTab;\n        HotSpareTab = null;\n        console.log(\"getSpareTab: returning hotspare\");\n        return rtn;\n    } else {\n        console.log(\"getSpareTab: creating new tab\");\n        return new WaveTabView(fullConfig);\n    }\n}\n"
  },
  {
    "path": "emain/emain-util.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport * as electron from \"electron\";\nimport { getWebServerEndpoint } from \"../frontend/util/endpoints\";\n\nexport const WaveAppPathVarName = \"WAVETERM_APP_PATH\";\nexport const WaveAppResourcesPathVarName = \"WAVETERM_RESOURCES_PATH\";\nexport const WaveAppElectronExecPath = \"WAVETERM_ELECTRONEXECPATH\";\n\nconst MinZoomLevel = 0.4;\nconst MaxZoomLevel = 2.6;\nconst ZoomDelta = 0.2;\n\n// Note: Chromium automatically syncs zoom factor across all WebContents\n// sharing the same origin/session, so we only need to notify renderers\n// to update their CSS/state — not call setZoomFactor on each one.\n// We broadcast to all WebContents (including devtools, webviews, etc.) but\n// that is safe because \"zoom-factor-change\" is a custom app-defined event\n// that only our renderers listen to; unrecognized IPC messages are ignored.\nfunction broadcastZoomFactorChanged(newZoomFactor: number): void {\n    for (const wc of electron.webContents.getAllWebContents()) {\n        if (wc.isDestroyed()) {\n            continue;\n        }\n        wc.send(\"zoom-factor-change\", newZoomFactor);\n    }\n}\n\nexport function increaseZoomLevel(webContents: electron.WebContents): void {\n    const newZoom = Math.min(MaxZoomLevel, webContents.getZoomFactor() + ZoomDelta);\n    webContents.setZoomFactor(newZoom);\n    broadcastZoomFactorChanged(newZoom);\n}\n\nexport function decreaseZoomLevel(webContents: electron.WebContents): void {\n    const newZoom = Math.max(MinZoomLevel, webContents.getZoomFactor() - ZoomDelta);\n    webContents.setZoomFactor(newZoom);\n    broadcastZoomFactorChanged(newZoom);\n}\n\nexport function resetZoomLevel(webContents: electron.WebContents): void {\n    webContents.setZoomFactor(1);\n    broadcastZoomFactorChanged(1);\n}\n\nexport function getElectronExecPath(): string {\n    return process.execPath;\n}\n\n// not necessarily exact, but we use this to help get us unstuck in certain cases\nlet lastCtrlShiftSate: boolean = false;\n\nexport function delay(ms): Promise<void> {\n    return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction setCtrlShift(wc: Electron.WebContents, state: boolean) {\n    lastCtrlShiftSate = state;\n    wc.send(\"control-shift-state-update\", state);\n}\n\nexport function handleCtrlShiftFocus(sender: Electron.WebContents, focused: boolean) {\n    if (!focused) {\n        setCtrlShift(sender, false);\n    }\n}\n\nexport function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: WaveKeyboardEvent) {\n    if (waveEvent.type == \"keyup\") {\n        if (waveEvent.key === \"Control\" || waveEvent.key === \"Shift\") {\n            setCtrlShift(sender, false);\n        }\n        if (waveEvent.key == \"Meta\") {\n            if (waveEvent.control && waveEvent.shift) {\n                setCtrlShift(sender, true);\n            }\n        }\n        if (lastCtrlShiftSate) {\n            if (!waveEvent.control || !waveEvent.shift) {\n                setCtrlShift(sender, false);\n            }\n        }\n        return;\n    }\n    if (waveEvent.type == \"keydown\") {\n        if (waveEvent.key === \"Control\" || waveEvent.key === \"Shift\" || waveEvent.key === \"Meta\") {\n            if (waveEvent.control && waveEvent.shift && !waveEvent.meta) {\n                // Set the control and shift without the Meta key\n                setCtrlShift(sender, true);\n            } else {\n                // Unset if Meta is pressed\n                setCtrlShift(sender, false);\n            }\n        }\n        return;\n    }\n}\n\nexport function shNavHandler(event: Electron.Event<Electron.WebContentsWillNavigateEventParams>, url: string) {\n    const isDev = !electron.app.isPackaged;\n    if (\n        isDev &&\n        (url.startsWith(\"http://127.0.0.1:5173/index.html\") ||\n            url.startsWith(\"http://localhost:5173/index.html\") ||\n            url.startsWith(\"http://127.0.0.1:5174/index.html\") ||\n            url.startsWith(\"http://localhost:5174/index.html\"))\n    ) {\n        // this is a dev-mode hot-reload, ignore it\n        console.log(\"allowing hot-reload of index.html\");\n        return;\n    }\n    event.preventDefault();\n    if (url.startsWith(\"https://\") || url.startsWith(\"http://\") || url.startsWith(\"file://\")) {\n        console.log(\"open external, shNav\", url);\n        electron.shell.openExternal(url);\n    } else {\n        console.log(\"navigation canceled\", url);\n    }\n}\n\nfunction frameOrAncestorHasName(frame: Electron.WebFrameMain, name: string): boolean {\n    let cur: Electron.WebFrameMain = frame;\n    while (cur != null) {\n        if (cur.name === name) {\n            return true;\n        }\n        cur = cur.parent;\n    }\n    return false;\n}\n\nexport function shFrameNavHandler(event: Electron.Event<Electron.WebContentsWillFrameNavigateEventParams>) {\n    if (!event.frame?.parent) {\n        // only use this handler to process iframe events (non-iframe events go to shNavHandler)\n        return;\n    }\n    const url = event.url;\n    console.log(`frame-navigation url=${url} frame=${event.frame.name}`);\n    if (event.frame.name == \"webview\") {\n        // \"webview\" links always open in new window\n        // this will *not* effect the initial load because srcdoc does not count as an electron navigation\n        console.log(\"open external, frameNav\", url);\n        event.preventDefault();\n        electron.shell.openExternal(url);\n        return;\n    }\n    if (\n        frameOrAncestorHasName(event.frame, \"pdfview\") &&\n        (url.startsWith(\"blob:file:///\") ||\n            url.startsWith(\"chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/\") ||\n            url.startsWith(getWebServerEndpoint() + \"/wave/stream-file?\") ||\n            url.startsWith(getWebServerEndpoint() + \"/wave/stream-file/\") ||\n            url.startsWith(getWebServerEndpoint() + \"/wave/stream-local-file?\"))\n    ) {\n        // allowed\n        return;\n    }\n    if (event.frame.name != null && event.frame.name.startsWith(\"tsunami:\")) {\n        // Parse port from frame name: tsunami:[port]:[blockid]\n        const nameParts = event.frame.name.split(\":\");\n        const expectedPort = nameParts.length >= 2 ? nameParts[1] : null;\n\n        try {\n            const tsunamiUrl = new URL(url);\n            if (\n                tsunamiUrl.protocol === \"http:\" &&\n                tsunamiUrl.hostname === \"localhost\" &&\n                expectedPort &&\n                tsunamiUrl.port === expectedPort\n            ) {\n                // allowed\n                return;\n            }\n            // If navigation is not to expected port, open externally\n            event.preventDefault();\n            electron.shell.openExternal(url);\n            return;\n        } catch (e) {\n            // Invalid URL, fall through to prevent navigation\n        }\n    }\n    event.preventDefault();\n    console.log(\"frame navigation canceled\", event.frame.name, url);\n}\n\nfunction isWindowFullyVisible(bounds: electron.Rectangle): boolean {\n    const displays = electron.screen.getAllDisplays();\n\n    // Helper function to check if a point is inside any display\n    function isPointInDisplay(x: number, y: number) {\n        for (const display of displays) {\n            const { x: dx, y: dy, width, height } = display.bounds;\n            if (x >= dx && x < dx + width && y >= dy && y < dy + height) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    // Check all corners of the window\n    const topLeft = isPointInDisplay(bounds.x, bounds.y);\n    const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y);\n    const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height);\n    const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height);\n\n    return topLeft && topRight && bottomLeft && bottomRight;\n}\n\nfunction findDisplayWithMostArea(bounds: electron.Rectangle): electron.Display {\n    const displays = electron.screen.getAllDisplays();\n    let maxArea = 0;\n    let bestDisplay = null;\n\n    for (let display of displays) {\n        const { x, y, width, height } = display.bounds;\n        const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x));\n        const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y));\n        const overlapArea = overlapX * overlapY;\n\n        if (overlapArea > maxArea) {\n            maxArea = overlapArea;\n            bestDisplay = display;\n        }\n    }\n\n    return bestDisplay;\n}\n\nfunction adjustBoundsToFitDisplay(bounds: electron.Rectangle, display: electron.Display): electron.Rectangle {\n    const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea;\n    let { x, y, width, height } = bounds;\n\n    // Adjust width and height to fit within the display's work area\n    width = Math.min(width, dWidth);\n    height = Math.min(height, dHeight);\n\n    // Adjust x to ensure the window fits within the display\n    if (x < dx) {\n        x = dx;\n    } else if (x + width > dx + dWidth) {\n        x = dx + dWidth - width;\n    }\n\n    // Adjust y to ensure the window fits within the display\n    if (y < dy) {\n        y = dy;\n    } else if (y + height > dy + dHeight) {\n        y = dy + dHeight - height;\n    }\n    return { x, y, width, height };\n}\n\nexport function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle {\n    if (!isWindowFullyVisible(bounds)) {\n        let targetDisplay = findDisplayWithMostArea(bounds);\n\n        if (!targetDisplay) {\n            targetDisplay = electron.screen.getPrimaryDisplay();\n        }\n\n        return adjustBoundsToFitDisplay(bounds, targetDisplay);\n    }\n    return bounds;\n}\n\nexport function waveKeyToElectronKey(waveKey: string): string {\n    const waveParts = waveKey.split(\":\");\n    const electronParts: Array<string> = waveParts.map((part: string) => {\n        const digitRegexpMatch = new RegExp(\"^c{Digit([0-9])}$\").exec(part);\n        const numpadRegexpMatch = new RegExp(\"^c{Numpad([0-9])}$\").exec(part);\n        const lowercaseCharMatch = new RegExp(\"^([a-z])$\").exec(part);\n        if (part == \"ArrowUp\") {\n            return \"Up\";\n        }\n        if (part == \"ArrowDown\") {\n            return \"Down\";\n        }\n        if (part == \"ArrowLeft\") {\n            return \"Left\";\n        }\n        if (part == \"ArrowRight\") {\n            return \"Right\";\n        }\n        if (part == \"Soft1\") {\n            return \"F21\";\n        }\n        if (part == \"Soft2\") {\n            return \"F22\";\n        }\n        if (part == \"Soft3\") {\n            return \"F23\";\n        }\n        if (part == \"Soft4\") {\n            return \"F24\";\n        }\n        if (part == \" \") {\n            return \"Space\";\n        }\n        if (part == \"CapsLock\") {\n            return \"Capslock\";\n        }\n        if (part == \"NumLock\") {\n            return \"Numlock\";\n        }\n        if (part == \"ScrollLock\") {\n            return \"Scrolllock\";\n        }\n        if (part == \"AudioVolumeUp\") {\n            return \"VolumeUp\";\n        }\n        if (part == \"AudioVolumeDown\") {\n            return \"VolumeDown\";\n        }\n        if (part == \"AudioVolumeMute\") {\n            return \"VolumeMute\";\n        }\n        if (part == \"MediaTrackNext\") {\n            return \"MediaNextTrack\";\n        }\n        if (part == \"MediaTrackPrevious\") {\n            return \"MediaPreviousTrack\";\n        }\n        if (part == \"Decimal\") {\n            return \"numdec\";\n        }\n        if (part == \"Add\") {\n            return \"numadd\";\n        }\n        if (part == \"Subtract\") {\n            return \"numsub\";\n        }\n        if (part == \"Multiply\") {\n            return \"nummult\";\n        }\n        if (part == \"Divide\") {\n            return \"numdiv\";\n        }\n        if (digitRegexpMatch && digitRegexpMatch.length > 1) {\n            return digitRegexpMatch[1];\n        }\n        if (numpadRegexpMatch && numpadRegexpMatch.length > 1) {\n            return `num${numpadRegexpMatch[1]}`;\n        }\n        if (lowercaseCharMatch && lowercaseCharMatch.length > 1) {\n            return lowercaseCharMatch[1].toUpperCase();\n        }\n\n        return part;\n    });\n    return electronParts.join(\"+\");\n}\n"
  },
  {
    "path": "emain/emain-wavesrv.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport * as electron from \"electron\";\nimport * as child_process from \"node:child_process\";\nimport * as readline from \"readline\";\nimport { WebServerEndpointVarName, WSServerEndpointVarName } from \"../frontend/util/endpoints\";\nimport { AuthKey, WaveAuthKeyEnv } from \"./authkey\";\nimport { setForceQuit, setUserConfirmedQuit } from \"./emain-activity\";\nimport {\n    getElectronAppResourcesPath,\n    getElectronAppUnpackedBasePath,\n    getWaveConfigDir,\n    getWaveDataDir,\n    getWaveSrvCwd,\n    getWaveSrvPath,\n    getXdgCurrentDesktop,\n    WaveConfigHomeVarName,\n    WaveDataHomeVarName,\n} from \"./emain-platform\";\nimport {\n    getElectronExecPath,\n    WaveAppElectronExecPath,\n    WaveAppPathVarName,\n    WaveAppResourcesPathVarName,\n} from \"./emain-util\";\nimport { updater } from \"./updater\";\n\nlet isWaveSrvDead = false;\nlet waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null;\nlet WaveVersion = \"unknown\"; // set by WAVESRV-ESTART\nlet WaveBuildTime = 0; // set by WAVESRV-ESTART\n\nexport function getWaveVersion(): { version: string; buildTime: number } {\n    return { version: WaveVersion, buildTime: WaveBuildTime };\n}\n\nlet waveSrvReadyResolve = (value: boolean) => {};\nconst waveSrvReady: Promise<boolean> = new Promise((resolve, _) => {\n    waveSrvReadyResolve = resolve;\n});\n\nexport function getWaveSrvReady(): Promise<boolean> {\n    return waveSrvReady;\n}\n\nexport function getWaveSrvProc(): child_process.ChildProcessWithoutNullStreams | null {\n    return waveSrvProc;\n}\n\nexport function getIsWaveSrvDead(): boolean {\n    return isWaveSrvDead;\n}\n\nexport function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promise<boolean> {\n    let pResolve: (value: boolean) => void;\n    let pReject: (reason?: any) => void;\n    const rtnPromise = new Promise<boolean>((argResolve, argReject) => {\n        pResolve = argResolve;\n        pReject = argReject;\n    });\n    const envCopy = { ...process.env };\n    const xdgCurrentDesktop = getXdgCurrentDesktop();\n    if (xdgCurrentDesktop != null) {\n        envCopy[\"XDG_CURRENT_DESKTOP\"] = xdgCurrentDesktop;\n    }\n    envCopy[WaveAppPathVarName] = getElectronAppUnpackedBasePath();\n    envCopy[WaveAppResourcesPathVarName] = getElectronAppResourcesPath();\n    envCopy[WaveAppElectronExecPath] = getElectronExecPath();\n    envCopy[WaveAuthKeyEnv] = AuthKey;\n    envCopy[WaveDataHomeVarName] = getWaveDataDir();\n    envCopy[WaveConfigHomeVarName] = getWaveConfigDir();\n    const waveSrvCmd = getWaveSrvPath();\n    console.log(\"trying to run local server\", waveSrvCmd);\n    const proc = child_process.spawn(getWaveSrvPath(), {\n        cwd: getWaveSrvCwd(),\n        env: envCopy,\n    });\n    proc.on(\"exit\", (e) => {\n        if (updater?.status == \"installing\") {\n            return;\n        }\n        console.log(\"wavesrv exited, shutting down\");\n        setForceQuit(true);\n        isWaveSrvDead = true;\n        electron.app.quit();\n    });\n    proc.on(\"spawn\", (e) => {\n        console.log(\"spawned wavesrv\");\n        waveSrvProc = proc;\n        pResolve(true);\n    });\n    proc.on(\"error\", (e) => {\n        console.log(\"error running wavesrv\", e);\n        pReject(e);\n    });\n    const rlStdout = readline.createInterface({\n        input: proc.stdout,\n        terminal: false,\n    });\n    rlStdout.on(\"line\", (line) => {\n        console.log(line);\n    });\n    const rlStderr = readline.createInterface({\n        input: proc.stderr,\n        terminal: false,\n    });\n    rlStderr.on(\"line\", (line) => {\n        if (line.includes(\"WAVESRV-ESTART\")) {\n            const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.-]+) buildtime:(\\d+)/gm.exec(\n                line\n            );\n            if (startParams == null) {\n                console.log(\"error parsing WAVESRV-ESTART line\", line);\n                setUserConfirmedQuit(true);\n                electron.app.quit();\n                return;\n            }\n            process.env[WSServerEndpointVarName] = startParams[1];\n            process.env[WebServerEndpointVarName] = startParams[2];\n            WaveVersion = startParams[3];\n            WaveBuildTime = parseInt(startParams[4]);\n            waveSrvReadyResolve(true);\n            return;\n        }\n        if (line.startsWith(\"WAVESRV-EVENT:\")) {\n            const evtJson = line.slice(\"WAVESRV-EVENT:\".length);\n            try {\n                const evtMsg: WSEventType = JSON.parse(evtJson);\n                handleWSEvent(evtMsg);\n            } catch (e) {\n                console.log(\"error handling WAVESRV-EVENT\", e);\n            }\n            return;\n        }\n        console.log(line);\n    });\n    return rtnPromise;\n}\n"
  },
  {
    "path": "emain/emain-web.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { ipcMain, webContents, WebContents } from \"electron\";\nimport { WaveBrowserWindow } from \"./emain-window\";\n\nexport function getWebContentsByBlockId(ww: WaveBrowserWindow, tabId: string, blockId: string): Promise<WebContents> {\n    const prtn = new Promise<WebContents>((resolve, reject) => {\n        const randId = Math.floor(Math.random() * 1000000000).toString();\n        const respCh = `getWebContentsByBlockId-${randId}`;\n        ww?.activeTabView?.webContents.send(\"webcontentsid-from-blockid\", blockId, respCh);\n        ipcMain.once(respCh, (event, webContentsId) => {\n            if (webContentsId == null) {\n                resolve(null);\n                return;\n            }\n            const wc = webContents.fromId(parseInt(webContentsId));\n            resolve(wc);\n        });\n        setTimeout(() => {\n            reject(new Error(\"timeout waiting for response\"));\n        }, 2000);\n    });\n    return prtn;\n}\n\nfunction escapeSelector(selector: string): string {\n    return selector\n        .replace(/\\\\/g, \"\\\\\\\\\")\n        .replace(/\"/g, '\\\\\"')\n        .replace(/'/g, \"\\\\'\")\n        .replace(/\\n/g, \"\\\\n\")\n        .replace(/\\r/g, \"\\\\r\")\n        .replace(/\\t/g, \"\\\\t\");\n}\n\nexport type WebGetOpts = {\n    all?: boolean;\n    inner?: boolean;\n};\n\nexport async function webGetSelector(wc: WebContents, selector: string, opts?: WebGetOpts): Promise<string[]> {\n    if (!wc || !selector) {\n        return null;\n    }\n    const escapedSelector = escapeSelector(selector);\n    const queryMethod = opts?.all ? \"querySelectorAll\" : \"querySelector\";\n    const prop = opts?.inner ? \"innerHTML\" : \"outerHTML\";\n    const execExpr = `\n    (() => {\n        const toArr = x => (x instanceof NodeList) ? Array.from(x) : (x ? [x] : []);\n        try {\n            const result = document.${queryMethod}(\"${escapedSelector}\");\n            const value = toArr(result).map(el => el.${prop});\n            return { value };\n        } catch (error) {\n            return { error: error.message };\n        }\n    })()`;\n    const results = await wc.executeJavaScript(execExpr);\n    if (results.error) {\n        throw new Error(results.error);\n    }\n    return results.value;\n}\n"
  },
  {
    "path": "emain/emain-window.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { ClientService, ObjectService, WindowService, WorkspaceService } from \"@/app/store/services\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { fireAndForget } from \"@/util/util\";\nimport { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen } from \"electron\";\nimport { globalEvents } from \"emain/emain-events\";\nimport path from \"path\";\nimport { debounce } from \"throttle-debounce\";\nimport {\n    getGlobalIsQuitting,\n    getGlobalIsRelaunching,\n    setGlobalIsRelaunching,\n    setWasActive,\n    setWasInFg,\n} from \"./emain-activity\";\nimport { log } from \"./emain-log\";\nimport { getElectronAppBasePath, isDev, unamePlatform } from \"./emain-platform\";\nimport { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from \"./emain-tabview\";\nimport { delay, ensureBoundsAreVisible, waveKeyToElectronKey } from \"./emain-util\";\nimport { ElectronWshClient } from \"./emain-wsh\";\nimport { updater } from \"./updater\";\n\nconst DevInitTimeoutMs = 5000;\n\nexport type WindowOpts = {\n    unamePlatform: NodeJS.Platform;\n    isPrimaryStartupWindow?: boolean;\n    foregroundWindow?: boolean;\n};\n\nexport const MinWindowWidth = 800;\nexport const MinWindowHeight = 500;\n\nexport function calculateWindowBounds(\n    winSize?: { width?: number; height?: number },\n    pos?: { x?: number; y?: number },\n    settings?: any\n): { x: number; y: number; width: number; height: number } {\n    let winWidth = winSize?.width;\n    let winHeight = winSize?.height;\n    let winPosX = pos?.x ?? 100;\n    let winPosY = pos?.y ?? 100;\n\n    if (\n        (winWidth == null || winWidth === 0 || winHeight == null || winHeight === 0) &&\n        settings?.[\"window:dimensions\"]\n    ) {\n        const dimensions = settings[\"window:dimensions\"];\n        const match = dimensions.match(/^(\\d+)[xX](\\d+)$/);\n\n        if (match) {\n            const [, dimensionWidth, dimensionHeight] = match;\n            const parsedWidth = parseInt(dimensionWidth, 10);\n            const parsedHeight = parseInt(dimensionHeight, 10);\n\n            if ((!winWidth || winWidth === 0) && Number.isFinite(parsedWidth) && parsedWidth > 0) {\n                winWidth = parsedWidth;\n            }\n            if ((!winHeight || winHeight === 0) && Number.isFinite(parsedHeight) && parsedHeight > 0) {\n                winHeight = parsedHeight;\n            }\n        } else {\n            console.warn('Invalid window:dimensions format. Expected \"widthxheight\".');\n        }\n    }\n\n    if (winWidth == null || winWidth == 0) {\n        const primaryDisplay = screen.getPrimaryDisplay();\n        const { width } = primaryDisplay.workAreaSize;\n        winWidth = width - winPosX - 100;\n        if (winWidth > 2000) {\n            winWidth = 2000;\n        }\n    }\n    if (winHeight == null || winHeight == 0) {\n        const primaryDisplay = screen.getPrimaryDisplay();\n        const { height } = primaryDisplay.workAreaSize;\n        winHeight = height - winPosY - 100;\n        if (winHeight > 1200) {\n            winHeight = 1200;\n        }\n    }\n\n    winWidth = Math.max(winWidth, MinWindowWidth);\n    winHeight = Math.max(winHeight, MinWindowHeight);\n\n    let winBounds = {\n        x: winPosX,\n        y: winPosY,\n        width: winWidth,\n        height: winHeight,\n    };\n    return ensureBoundsAreVisible(winBounds);\n}\n\nexport const waveWindowMap = new Map<string, WaveBrowserWindow>(); // waveWindowId -> WaveBrowserWindow\n\n// on blur we do not set this to null (but on destroy we do), so this tracks the *last* focused window\n// e.g. it persists when the app itself is not focused\nexport let focusedWaveWindow: WaveBrowserWindow = null;\n\nlet cachedClientId: string = null;\nlet hasCompletedFirstRelaunch = false;\n\nasync function getClientId() {\n    if (cachedClientId != null) {\n        return cachedClientId;\n    }\n    const clientData = await ClientService.GetClientData();\n    cachedClientId = clientData?.oid;\n    return cachedClientId;\n}\n\ntype WindowActionQueueEntry =\n    | {\n          op: \"switchtab\";\n          tabId: string;\n          setInBackend: boolean;\n          primaryStartupTab?: boolean;\n      }\n    | {\n          op: \"createtab\";\n      }\n    | {\n          op: \"closetab\";\n          tabId: string;\n      }\n    | {\n          op: \"switchworkspace\";\n          workspaceId: string;\n      };\n\nfunction isNonEmptyUnsavedWorkspace(workspace: Workspace): boolean {\n    return !workspace.name && !workspace.icon && workspace.tabids?.length > 1;\n}\n\nexport class WaveBrowserWindow extends BaseWindow {\n    waveWindowId: string;\n    workspaceId: string;\n    allLoadedTabViews: Map<string, WaveTabView>;\n    activeTabView: WaveTabView;\n    private canClose: boolean;\n    private deleteAllowed: boolean;\n    private actionQueue: WindowActionQueueEntry[];\n\n    constructor(waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts) {\n        const settings = fullConfig?.settings;\n\n        console.log(\"create win\", waveWindow.oid);\n        const winBounds = calculateWindowBounds(waveWindow.winsize, waveWindow.pos, settings);\n        const winOpts: BaseWindowConstructorOptions = {\n            x: winBounds.x,\n            y: winBounds.y,\n            width: winBounds.width,\n            height: winBounds.height,\n            minWidth: MinWindowWidth,\n            minHeight: MinWindowHeight,\n            show: false,\n        };\n\n        const isTransparent = settings?.[\"window:transparent\"] ?? false;\n        const isBlur = !isTransparent && (settings?.[\"window:blur\"] ?? false);\n\n        if (opts.unamePlatform === \"darwin\") {\n            winOpts.titleBarStyle = \"hiddenInset\";\n            winOpts.titleBarOverlay = false;\n            winOpts.autoHideMenuBar = !settings?.[\"window:showmenubar\"];\n            if (isTransparent) {\n                winOpts.transparent = true;\n            } else if (isBlur) {\n                winOpts.vibrancy = \"fullscreen-ui\";\n            } else {\n                winOpts.backgroundColor = \"#222222\";\n            }\n        } else if (opts.unamePlatform === \"linux\") {\n            winOpts.titleBarStyle = settings[\"window:nativetitlebar\"] ? \"default\" : \"hidden\";\n            winOpts.titleBarOverlay = {\n                symbolColor: \"white\",\n                color: \"#00000000\",\n            };\n            winOpts.icon = path.join(getElectronAppBasePath(), \"public/logos/wave-logo-dark.png\");\n            winOpts.autoHideMenuBar = !settings?.[\"window:showmenubar\"];\n            if (isTransparent) {\n                winOpts.transparent = true;\n            } else {\n                winOpts.backgroundColor = \"#222222\";\n            }\n        } else if (opts.unamePlatform === \"win32\") {\n            winOpts.titleBarStyle = \"hidden\";\n            winOpts.titleBarOverlay = {\n                color: \"#222222\",\n                symbolColor: \"#c3c8c2\",\n                height: 32,\n            };\n            if (isTransparent) {\n                winOpts.transparent = true;\n            } else if (isBlur) {\n                winOpts.backgroundMaterial = \"acrylic\";\n            } else {\n                winOpts.backgroundColor = \"#222222\";\n            }\n        }\n\n        super(winOpts);\n\n        if (opts.unamePlatform === \"win32\") {\n            this.setMenu(null);\n        }\n\n        const fullscreenOnLaunch = fullConfig?.settings[\"window:fullscreenonlaunch\"];\n        if (fullscreenOnLaunch && opts.foregroundWindow) {\n            this.once(\"show\", () => {\n                this.setFullScreen(true);\n            });\n        }\n        this.actionQueue = [];\n        this.waveWindowId = waveWindow.oid;\n        this.workspaceId = waveWindow.workspaceid;\n        this.allLoadedTabViews = new Map<string, WaveTabView>();\n        const winBoundsPoller = setInterval(() => {\n            if (this.isDestroyed()) {\n                clearInterval(winBoundsPoller);\n                return;\n            }\n            if (this.actionQueue.length > 0) {\n                return;\n            }\n            this.finalizePositioning();\n        }, 1000);\n        this.on(\n            // @ts-expect-error -- \"resize\" event with debounce handler not in Electron type definitions\n            \"resize\",\n            debounce(400, (e) => this.mainResizeHandler(e))\n        );\n        this.on(\"resize\", () => {\n            if (this.isDestroyed()) {\n                return;\n            }\n            this.activeTabView?.positionTabOnScreen(this.getContentBounds());\n        });\n        this.on(\n            // @ts-expect-error -- \"move\" event with debounce handler not in Electron type definitions\n            \"move\",\n            debounce(400, (e) => this.mainResizeHandler(e))\n        );\n        this.on(\"enter-full-screen\", async () => {\n            if (this.isDestroyed()) {\n                return;\n            }\n            console.log(\"enter-full-screen event\", this.getContentBounds());\n            const tabView = this.activeTabView;\n            if (tabView) {\n                tabView.webContents.send(\"fullscreen-change\", true);\n            }\n            this.activeTabView?.positionTabOnScreen(this.getContentBounds());\n        });\n        this.on(\"leave-full-screen\", async () => {\n            if (this.isDestroyed()) {\n                return;\n            }\n            const tabView = this.activeTabView;\n            if (tabView) {\n                tabView.webContents.send(\"fullscreen-change\", false);\n            }\n            this.activeTabView?.positionTabOnScreen(this.getContentBounds());\n        });\n        this.on(\"focus\", () => {\n            if (this.isDestroyed()) {\n                return;\n            }\n            if (getGlobalIsRelaunching()) {\n                return;\n            }\n            focusedWaveWindow = this; // eslint-disable-line @typescript-eslint/no-this-alias\n            console.log(\"focus win\", this.waveWindowId);\n            fireAndForget(() => ClientService.FocusWindow(this.waveWindowId));\n            setWasInFg(true);\n            setWasActive(true);\n            setTimeout(() => globalEvents.emit(\"windows-updated\"), 50);\n        });\n        this.on(\"blur\", () => {\n            setTimeout(() => globalEvents.emit(\"windows-updated\"), 50);\n        });\n        this.on(\"close\", (e) => {\n            if (this.canClose) {\n                return;\n            }\n            if (this.isDestroyed()) {\n                return;\n            }\n            console.log(\"win 'close' handler fired\", this.waveWindowId);\n            if (getGlobalIsQuitting() || updater?.status == \"installing\" || getGlobalIsRelaunching()) {\n                return;\n            }\n            e.preventDefault();\n            fireAndForget(async () => {\n                const numWindows = waveWindowMap.size;\n                const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient);\n                if (numWindows > 1 || !fullConfig.settings[\"window:savelastwindow\"]) {\n                    if (fullConfig.settings[\"window:confirmclose\"]) {\n                        const workspace = await WorkspaceService.GetWorkspace(this.workspaceId);\n                        if (isNonEmptyUnsavedWorkspace(workspace)) {\n                            const choice = dialog.showMessageBoxSync(this, {\n                                type: \"question\",\n                                buttons: [\"Cancel\", \"Close Window\"],\n                                title: \"Confirm\",\n                                message:\n                                    \"Window has unsaved tabs, closing window will delete existing tabs.\\n\\nContinue?\",\n                            });\n                            if (choice === 0) {\n                                return;\n                            }\n                        }\n                    }\n                    this.deleteAllowed = true;\n                }\n                this.canClose = true;\n                this.close();\n            });\n        });\n        this.on(\"closed\", () => {\n            console.log(\"win 'closed' handler fired\", this.waveWindowId);\n            if (getGlobalIsQuitting() || updater?.status == \"installing\") {\n                console.log(\"win quitting or updating\", this.waveWindowId);\n                return;\n            }\n            setTimeout(() => globalEvents.emit(\"windows-updated\"), 50);\n            waveWindowMap.delete(this.waveWindowId);\n            if (focusedWaveWindow == this) {\n                focusedWaveWindow = null;\n            }\n            this.removeAllChildViews();\n            if (getGlobalIsRelaunching()) {\n                console.log(\"win relaunching\", this.waveWindowId);\n                this.destroy();\n                return;\n            }\n            if (this.deleteAllowed) {\n                console.log(\"win removing window from backend DB\", this.waveWindowId);\n                fireAndForget(() => WindowService.CloseWindow(this.waveWindowId, true));\n            }\n        });\n        waveWindowMap.set(waveWindow.oid, this);\n        setTimeout(() => globalEvents.emit(\"windows-updated\"), 50);\n    }\n\n    private removeAllChildViews() {\n        for (const tabView of this.allLoadedTabViews.values()) {\n            if (!this.isDestroyed()) {\n                this.contentView.removeChildView(tabView);\n            }\n            tabView?.destroy();\n        }\n    }\n\n    async switchWorkspace(workspaceId: string) {\n        console.log(\"switchWorkspace\", workspaceId, this.waveWindowId);\n        if (workspaceId == this.workspaceId) {\n            console.log(\"switchWorkspace already on this workspace\", this.waveWindowId);\n            return;\n        }\n\n        // 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.\n        const workspaceList = await WorkspaceService.ListWorkspaces();\n        if (!workspaceList?.find((wse) => wse.workspaceid === workspaceId)?.windowid) {\n            const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId);\n\n            if (curWorkspace && isNonEmptyUnsavedWorkspace(curWorkspace)) {\n                console.log(\n                    `existing unsaved workspace ${this.workspaceId} has content, opening workspace ${workspaceId} in new window`\n                );\n                await createWindowForWorkspace(workspaceId);\n                return;\n            }\n        }\n        await this._queueActionInternal({ op: \"switchworkspace\", workspaceId });\n    }\n\n    async setActiveTab(tabId: string, setInBackend: boolean, primaryStartupTab = false) {\n        console.log(\n            \"setActiveTab\",\n            tabId,\n            this.waveWindowId,\n            this.workspaceId,\n            setInBackend,\n            primaryStartupTab ? \"(primary startup)\" : \"\"\n        );\n        await this._queueActionInternal({ op: \"switchtab\", tabId, setInBackend, primaryStartupTab });\n    }\n\n    private async initializeTab(tabView: WaveTabView, primaryStartupTab: boolean) {\n        const clientId = await getClientId();\n        await this.awaitWithDevTimeout(tabView.initPromise, \"initPromise\", tabView.waveTabId);\n        this.contentView.addChildView(tabView);\n        const initOpts: WaveInitOpts = {\n            tabId: tabView.waveTabId,\n            clientId: clientId,\n            windowId: this.waveWindowId,\n            activate: true,\n        };\n        if (primaryStartupTab) {\n            initOpts.primaryTabStartup = true;\n        }\n        tabView.savedInitOpts = { ...initOpts };\n        tabView.savedInitOpts.activate = false;\n        delete tabView.savedInitOpts.primaryTabStartup;\n        let startTime = Date.now();\n        console.log(\n            \"before wave ready, init tab, sending wave-init\",\n            tabView.waveTabId,\n            primaryStartupTab ? \"(primary startup)\" : \"\"\n        );\n        tabView.webContents.send(\"wave-init\", initOpts);\n        await this.awaitWithDevTimeout(tabView.waveReadyPromise, \"waveReadyPromise\", tabView.waveTabId);\n        console.log(\"wave-ready init time\", Date.now() - startTime + \"ms\");\n    }\n\n    private async awaitWithDevTimeout<T>(promise: Promise<T>, name: string, tabId: string): Promise<T> {\n        if (!isDev) {\n            return promise;\n        }\n        let timeoutHandle: ReturnType<typeof setTimeout> = null;\n        const timeoutPromise = new Promise<never>((_, reject) => {\n            timeoutHandle = setTimeout(() => {\n                console.log(\n                    `[dev] ${name} timed out after ${DevInitTimeoutMs}ms for tab ${tabId}, showing window for devtools`\n                );\n                if (!this.isDestroyed() && !this.isVisible()) {\n                    this.show();\n                }\n                if (this.activeTabView?.webContents && !this.activeTabView.webContents.isDevToolsOpened()) {\n                    this.activeTabView.webContents.openDevTools();\n                }\n                reject(new Error(`[dev] ${name} timed out after ${DevInitTimeoutMs}ms`));\n            }, DevInitTimeoutMs);\n        });\n        try {\n            return await Promise.race([promise, timeoutPromise]);\n        } finally {\n            clearTimeout(timeoutHandle);\n        }\n    }\n\n    private async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean, primaryStartupTab = false) {\n        if (this.activeTabView == tabView) {\n            return;\n        }\n        const oldActiveView = this.activeTabView;\n        tabView.isActiveTab = true;\n        if (oldActiveView != null) {\n            oldActiveView.isActiveTab = false;\n        }\n        this.activeTabView = tabView;\n        this.allLoadedTabViews.set(tabView.waveTabId, tabView);\n        if (!tabInitialized) {\n            console.log(\"initializing a new tab\", primaryStartupTab ? \"(primary startup)\" : \"\");\n            const p1 = this.initializeTab(tabView, primaryStartupTab);\n            const p2 = this.repositionTabsSlowly(100);\n            await Promise.all([p1, p2]);\n        } else {\n            console.log(\"reusing an existing tab, calling wave-init\", tabView.waveTabId);\n            const p1 = this.repositionTabsSlowly(35);\n            const p2 = tabView.webContents.send(\"wave-init\", tabView.savedInitOpts); // reinit\n            await Promise.all([p1, p2]);\n        }\n\n        // something is causing the new tab to lose focus so it requires manual refocusing\n        tabView.webContents.focus();\n        setTimeout(() => {\n            if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) {\n                tabView.webContents.focus();\n            }\n        }, 10);\n        setTimeout(() => {\n            if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) {\n                tabView.webContents.focus();\n            }\n        }, 30);\n    }\n\n    private async repositionTabsSlowly(delayMs: number) {\n        const activeTabView = this.activeTabView;\n        const winBounds = this.getContentBounds();\n        if (activeTabView == null) {\n            return;\n        }\n        if (activeTabView.isOnScreen()) {\n            activeTabView.setBounds({\n                x: 0,\n                y: 0,\n                width: winBounds.width,\n                height: winBounds.height,\n            });\n        } else {\n            activeTabView.setBounds({\n                x: winBounds.width - 10,\n                y: winBounds.height - 10,\n                width: winBounds.width,\n                height: winBounds.height,\n            });\n        }\n        await delay(delayMs);\n        if (this.activeTabView != activeTabView) {\n            // another tab view has been set, do not finalize this layout\n            return;\n        }\n        this.finalizePositioning();\n    }\n\n    private finalizePositioning() {\n        if (this.isDestroyed()) {\n            return;\n        }\n        const curBounds = this.getContentBounds();\n        this.activeTabView?.positionTabOnScreen(curBounds);\n        for (const tabView of this.allLoadedTabViews.values()) {\n            if (tabView == this.activeTabView) {\n                continue;\n            }\n            tabView?.positionTabOffScreen(curBounds);\n        }\n    }\n\n    async queueCreateTab() {\n        await this._queueActionInternal({ op: \"createtab\" });\n    }\n\n    async queueCloseTab(tabId: string) {\n        await this._queueActionInternal({ op: \"closetab\", tabId });\n    }\n\n    private async _queueActionInternal(entry: WindowActionQueueEntry) {\n        if (this.actionQueue.length >= 2) {\n            this.actionQueue[1] = entry;\n            return;\n        }\n        const wasEmpty = this.actionQueue.length === 0;\n        this.actionQueue.push(entry);\n        if (wasEmpty) {\n            await this.processActionQueue();\n        }\n    }\n\n    private removeTabViewLater(tabId: string, delayMs: number) {\n        setTimeout(() => {\n            this.removeTabView(tabId, false);\n        }, 1000);\n    }\n\n    // the queue and this function are used to serialize operations that update the window contents view\n    // processActionQueue will replace [1] if it is already set\n    // we don't mess with [0] because it is \"in process\"\n    // we replace [1] because there is no point to run an action that is going to be overwritten\n    private async processActionQueue() {\n        while (this.actionQueue.length > 0) {\n            try {\n                if (this.isDestroyed()) {\n                    break;\n                }\n                const entry = this.actionQueue[0];\n                let tabId: string = null;\n                // have to use \"===\" here to get the typechecker to work :/\n                switch (entry.op) {\n                    case \"createtab\":\n                        tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true);\n                        break;\n                    case \"switchtab\":\n                        tabId = entry.tabId;\n                        if (this.activeTabView?.waveTabId == tabId) {\n                            continue;\n                        }\n                        if (entry.setInBackend) {\n                            await WorkspaceService.SetActiveTab(this.workspaceId, tabId);\n                        }\n                        break;\n                    case \"closetab\": {\n                        tabId = entry.tabId;\n                        const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true);\n                        if (rtn == null) {\n                            console.log(\n                                \"[error] closeTab: no return value\",\n                                tabId,\n                                this.workspaceId,\n                                this.waveWindowId\n                            );\n                            return;\n                        }\n                        this.removeTabViewLater(tabId, 1000);\n                        if (rtn.closewindow) {\n                            this.close();\n                            return;\n                        }\n                        if (!rtn.newactivetabid) {\n                            return;\n                        }\n                        tabId = rtn.newactivetabid;\n                        break;\n                    }\n                    case \"switchworkspace\": {\n                        const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, entry.workspaceId);\n                        if (!newWs) {\n                            return;\n                        }\n                        console.log(\"processActionQueue switchworkspace newWs\", newWs);\n                        this.removeAllChildViews();\n                        console.log(\"destroyed all tabs\", this.waveWindowId);\n                        this.workspaceId = entry.workspaceId;\n                        this.allLoadedTabViews = new Map();\n                        tabId = newWs.activetabid;\n                        break;\n                    }\n                }\n                if (tabId == null) {\n                    return;\n                }\n                const [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId);\n                const primaryStartupTabFlag = entry.op === \"switchtab\" ? (entry.primaryStartupTab ?? false) : false;\n                await this.setTabViewIntoWindow(tabView, tabInitialized, primaryStartupTabFlag);\n            } catch (e) {\n                console.log(\"error caught in processActionQueue\", e);\n            } finally {\n                this.actionQueue.shift();\n            }\n        }\n    }\n\n    private async mainResizeHandler(_: any) {\n        if (this == null || this.isDestroyed() || this.fullScreen) {\n            return;\n        }\n        const bounds = this.getBounds();\n        try {\n            await WindowService.SetWindowPosAndSize(\n                this.waveWindowId,\n                { x: bounds.x, y: bounds.y },\n                { width: bounds.width, height: bounds.height }\n            );\n        } catch (e) {\n            console.log(\"error sending new window bounds to backend\", e);\n        }\n    }\n\n    removeTabView(tabId: string, force: boolean) {\n        if (!force && this.activeTabView?.waveTabId == tabId) {\n            console.log(\"cannot remove active tab\", tabId, this.waveWindowId);\n            return;\n        }\n        const tabView = this.allLoadedTabViews.get(tabId);\n        if (tabView == null) {\n            console.log(\"removeTabView -- tabView not found\", tabId, this.waveWindowId);\n            // the tab was never loaded, so just return\n            return;\n        }\n        this.contentView.removeChildView(tabView);\n        this.allLoadedTabViews.delete(tabId);\n        tabView.destroy();\n    }\n\n    destroy() {\n        console.log(\"destroy win\", this.waveWindowId);\n        this.deleteAllowed = true;\n        super.destroy();\n    }\n}\n\nexport function getWaveWindowByTabId(tabId: string): WaveBrowserWindow {\n    for (const ww of waveWindowMap.values()) {\n        if (ww.allLoadedTabViews.has(tabId)) {\n            return ww;\n        }\n    }\n}\n\nexport function getWaveWindowByWebContentsId(webContentsId: number): WaveBrowserWindow {\n    if (webContentsId == null) {\n        return null;\n    }\n    const tabView = getWaveTabViewByWebContentsId(webContentsId);\n    if (tabView == null) {\n        return null;\n    }\n    return getWaveWindowByTabId(tabView.waveTabId);\n}\n\nexport function getWaveWindowById(windowId: string): WaveBrowserWindow {\n    return waveWindowMap.get(windowId);\n}\n\nexport function getWaveWindowByWorkspaceId(workspaceId: string): WaveBrowserWindow {\n    for (const waveWindow of waveWindowMap.values()) {\n        if (waveWindow.workspaceId === workspaceId) {\n            return waveWindow;\n        }\n    }\n}\n\nexport function getAllWaveWindows(): WaveBrowserWindow[] {\n    return Array.from(waveWindowMap.values());\n}\n\nexport async function createWindowForWorkspace(workspaceId: string) {\n    const newWin = await WindowService.CreateWindow(null, workspaceId);\n    if (!newWin) {\n        console.log(\"error creating new window\", this.waveWindowId);\n    }\n    const newBwin = await createBrowserWindow(newWin, await RpcApi.GetFullConfigCommand(ElectronWshClient), {\n        unamePlatform,\n        isPrimaryStartupWindow: false,\n    });\n    newBwin.show();\n}\n\n// note, this does not *show* the window.\n// to show, await win.readyPromise and then win.show()\nexport async function createBrowserWindow(\n    waveWindow: WaveWindow,\n    fullConfig: FullConfigType,\n    opts: WindowOpts\n): Promise<WaveBrowserWindow> {\n    if (!waveWindow) {\n        console.log(\"createBrowserWindow: no waveWindow\");\n        waveWindow = await WindowService.CreateWindow(null, \"\");\n    }\n    let workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid);\n    if (!workspace) {\n        console.log(\"createBrowserWindow: no workspace, creating new window\");\n        await WindowService.CloseWindow(waveWindow.oid, true);\n        waveWindow = await WindowService.CreateWindow(null, \"\");\n        workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid);\n    }\n    console.log(\"createBrowserWindow\", waveWindow.oid, workspace.oid, workspace);\n    const bwin = new WaveBrowserWindow(waveWindow, fullConfig, opts);\n    if (workspace.activetabid) {\n        await bwin.setActiveTab(workspace.activetabid, false, opts.isPrimaryStartupWindow ?? false);\n    }\n    return bwin;\n}\n\nipcMain.on(\"set-active-tab\", async (event, tabId) => {\n    const ww = getWaveWindowByWebContentsId(event.sender.id);\n    console.log(\"set-active-tab\", tabId, ww?.waveWindowId);\n    await ww?.setActiveTab(tabId, true);\n});\n\nipcMain.on(\"create-tab\", async (event, opts) => {\n    const senderWc = event.sender;\n    const ww = getWaveWindowByWebContentsId(senderWc.id);\n    if (ww != null) {\n        await ww.queueCreateTab();\n    }\n    event.returnValue = true;\n    return null;\n});\n\nipcMain.on(\"set-waveai-open\", (event, isOpen: boolean) => {\n    const tabView = getWaveTabViewByWebContentsId(event.sender.id);\n    if (tabView) {\n        tabView.isWaveAIOpen = isOpen;\n    }\n});\n\nipcMain.handle(\"close-tab\", async (event, workspaceId: string, tabId: string, confirmClose: boolean) => {\n    const ww = getWaveWindowByWorkspaceId(workspaceId);\n    if (ww == null) {\n        console.log(`close-tab: no window found for workspace ws=${workspaceId} tab=${tabId}`);\n        return false;\n    }\n    if (confirmClose) {\n        const choice = dialog.showMessageBoxSync(ww, {\n            type: \"question\",\n            defaultId: 1, // Enter activates \"Close Tab\"\n            cancelId: 0, // Esc activates \"Cancel\"\n            buttons: [\"Cancel\", \"Close Tab\"],\n            title: \"Confirm\",\n            message: \"Are you sure you want to close this tab?\",\n        });\n        if (choice === 0) {\n            return false;\n        }\n    }\n    await ww.queueCloseTab(tabId);\n    return true;\n});\n\nipcMain.on(\"switch-workspace\", (event, workspaceId) => {\n    fireAndForget(async () => {\n        const ww = getWaveWindowByWebContentsId(event.sender.id);\n        console.log(\"switch-workspace\", workspaceId, ww?.waveWindowId);\n        await ww?.switchWorkspace(workspaceId);\n    });\n});\n\nexport async function createWorkspace(window: WaveBrowserWindow) {\n    const newWsId = await WorkspaceService.CreateWorkspace(\"\", \"\", \"\", true);\n    if (newWsId) {\n        if (window) {\n            await window.switchWorkspace(newWsId);\n        } else {\n            await createWindowForWorkspace(newWsId);\n        }\n    }\n}\n\nipcMain.on(\"create-workspace\", (event) => {\n    fireAndForget(async () => {\n        const ww = getWaveWindowByWebContentsId(event.sender.id);\n        console.log(\"create-workspace\", ww?.waveWindowId);\n        await createWorkspace(ww);\n    });\n});\n\nipcMain.on(\"delete-workspace\", (event, workspaceId) => {\n    fireAndForget(async () => {\n        const ww = getWaveWindowByWebContentsId(event.sender.id);\n        console.log(\"delete-workspace\", workspaceId, ww?.waveWindowId);\n\n        const workspaceList = await WorkspaceService.ListWorkspaces();\n\n        const workspaceHasWindow = !!workspaceList.find((wse) => wse.workspaceid === workspaceId)?.windowid;\n\n        const choice = dialog.showMessageBoxSync(this, {\n            type: \"question\",\n            buttons: [\"Cancel\", \"Delete Workspace\"],\n            title: \"Confirm\",\n            message: `Deleting workspace will also delete its contents.\\n\\nContinue?`,\n        });\n        if (choice === 0) {\n            console.log(\"user cancelled workspace delete\", workspaceId, ww?.waveWindowId);\n            return;\n        }\n\n        const newWorkspaceId = await WorkspaceService.DeleteWorkspace(workspaceId);\n        console.log(\"delete-workspace done\", workspaceId, ww?.waveWindowId);\n        if (ww?.workspaceId == workspaceId) {\n            if (newWorkspaceId) {\n                await ww.switchWorkspace(newWorkspaceId);\n            } else {\n                console.log(\"delete-workspace closing window\", workspaceId, ww?.waveWindowId);\n                ww.destroy();\n            }\n        }\n    });\n});\n\nexport async function createNewWaveWindow() {\n    log(\"createNewWaveWindow\");\n    const clientData = await ClientService.GetClientData();\n    const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient);\n    let recreatedWindow = false;\n    const allWindows = getAllWaveWindows();\n    if (allWindows.length === 0 && clientData?.windowids?.length >= 1) {\n        console.log(\"no windows, but clientData has windowids, recreating first window\");\n        // reopen the first window\n        const existingWindowId = clientData.windowids[0];\n        const existingWindowData = (await ObjectService.GetObject(\"window:\" + existingWindowId)) as WaveWindow;\n        if (existingWindowData != null) {\n            const win = await createBrowserWindow(existingWindowData, fullConfig, {\n                unamePlatform,\n                isPrimaryStartupWindow: false,\n            });\n            win.show();\n            recreatedWindow = true;\n        }\n    }\n    if (recreatedWindow) {\n        console.log(\"recreated window, returning\");\n        return;\n    }\n    console.log(\"creating new window\");\n    const newBrowserWindow = await createBrowserWindow(null, fullConfig, {\n        unamePlatform,\n        isPrimaryStartupWindow: false,\n    });\n    newBrowserWindow.show();\n}\n\nexport async function relaunchBrowserWindows() {\n    console.log(\"relaunchBrowserWindows\");\n    setGlobalIsRelaunching(true);\n    const windows = getAllWaveWindows();\n    if (windows.length > 0) {\n        for (const window of windows) {\n            console.log(\"relaunch -- closing window\", window.waveWindowId);\n            window.close();\n        }\n        await delay(1200);\n    }\n    setGlobalIsRelaunching(false);\n\n    const clientData = await ClientService.GetClientData();\n    const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient);\n    const windowIds = clientData.windowids ?? [];\n    const wins: WaveBrowserWindow[] = [];\n    const isFirstRelaunch = !hasCompletedFirstRelaunch;\n    const primaryWindowId = windowIds.length > 0 ? windowIds[0] : null;\n    for (const windowId of windowIds.slice().reverse()) {\n        const windowData: WaveWindow = await WindowService.GetWindow(windowId);\n        if (windowData == null) {\n            console.log(\"relaunch -- window data not found, closing window\", windowId);\n            await WindowService.CloseWindow(windowId, true);\n            continue;\n        }\n        const isPrimaryStartupWindow = isFirstRelaunch && windowId === primaryWindowId;\n        console.log(\n            \"relaunch -- creating window\",\n            windowId,\n            windowData,\n            isPrimaryStartupWindow ? \"(primary startup)\" : \"\"\n        );\n        const win = await createBrowserWindow(windowData, fullConfig, {\n            unamePlatform,\n            isPrimaryStartupWindow,\n            foregroundWindow: windowId === primaryWindowId,\n        });\n        wins.push(win);\n    }\n    hasCompletedFirstRelaunch = true;\n    for (const win of wins) {\n        console.log(\"show window\", win.waveWindowId);\n        win.show();\n    }\n}\n\nexport function registerGlobalHotkey(rawGlobalHotKey: string) {\n    try {\n        const electronHotKey = waveKeyToElectronKey(rawGlobalHotKey);\n        console.log(\"registering globalhotkey of \", electronHotKey);\n        globalShortcut.register(electronHotKey, () => {\n            const selectedWindow = focusedWaveWindow;\n            const firstWaveWindow = getAllWaveWindows()[0];\n            if (focusedWaveWindow) {\n                selectedWindow.focus();\n            } else if (firstWaveWindow) {\n                firstWaveWindow.focus();\n            } else {\n                fireAndForget(createNewWaveWindow);\n            }\n        });\n    } catch (e) {\n        console.log(\"error registering global hotkey: \", e);\n    }\n}\n"
  },
  {
    "path": "emain/emain-wsh.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { WindowService } from \"@/app/store/services\";\nimport { RpcResponseHelper, WshClient } from \"@/app/store/wshclient\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { Notification, net, safeStorage, shell } from \"electron\";\nimport { getResolvedUpdateChannel } from \"emain/updater\";\nimport { unamePlatform } from \"./emain-platform\";\nimport { getWebContentsByBlockId, webGetSelector } from \"./emain-web\";\nimport { createBrowserWindow, getWaveWindowById, getWaveWindowByWorkspaceId } from \"./emain-window\";\n\nexport class ElectronWshClientType extends WshClient {\n    constructor() {\n        super(\"electron\");\n    }\n\n    async handle_webselector(rh: RpcResponseHelper, data: CommandWebSelectorData): Promise<string[]> {\n        if (!data.tabid || !data.blockid || !data.workspaceid) {\n            throw new Error(\"tabid and blockid are required\");\n        }\n        const ww = getWaveWindowByWorkspaceId(data.workspaceid);\n        if (ww == null) {\n            throw new Error(`no window found with workspace ${data.workspaceid}`);\n        }\n        const wc = await getWebContentsByBlockId(ww, data.tabid, data.blockid);\n        if (wc == null) {\n            throw new Error(`no webcontents found with blockid ${data.blockid}`);\n        }\n        const rtn = await webGetSelector(wc, data.selector, data.opts);\n        return rtn;\n    }\n\n    async handle_notify(rh: RpcResponseHelper, notificationOptions: WaveNotificationOptions) {\n        new Notification({\n            title: notificationOptions.title,\n            body: notificationOptions.body,\n            silent: notificationOptions.silent,\n        }).show();\n    }\n\n    async handle_getupdatechannel(rh: RpcResponseHelper): Promise<string> {\n        return getResolvedUpdateChannel();\n    }\n\n    async handle_focuswindow(rh: RpcResponseHelper, windowId: string) {\n        console.log(`focuswindow ${windowId}`);\n        const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient);\n        let ww = getWaveWindowById(windowId);\n        if (ww == null) {\n            const window = await WindowService.GetWindow(windowId);\n            if (window == null) {\n                throw new Error(`window ${windowId} not found`);\n            }\n            ww = await createBrowserWindow(window, fullConfig, {\n                unamePlatform,\n                isPrimaryStartupWindow: false,\n            });\n        }\n        ww.focus();\n    }\n\n    async handle_electronencrypt(\n        rh: RpcResponseHelper,\n        data: CommandElectronEncryptData\n    ): Promise<CommandElectronEncryptRtnData> {\n        if (!safeStorage.isEncryptionAvailable()) {\n            throw new Error(\"encryption is not available\");\n        }\n        const encrypted = safeStorage.encryptString(data.plaintext);\n        const ciphertext = encrypted.toString(\"base64\");\n\n        let storagebackend = \"\";\n        if (process.platform === \"linux\") {\n            storagebackend = safeStorage.getSelectedStorageBackend();\n        }\n\n        return {\n            ciphertext,\n            storagebackend,\n        };\n    }\n\n    async handle_electrondecrypt(\n        rh: RpcResponseHelper,\n        data: CommandElectronDecryptData\n    ): Promise<CommandElectronDecryptRtnData> {\n        if (!safeStorage.isEncryptionAvailable()) {\n            throw new Error(\"encryption is not available\");\n        }\n        const encrypted = Buffer.from(data.ciphertext, \"base64\");\n        const plaintext = safeStorage.decryptString(encrypted);\n\n        let storagebackend = \"\";\n        if (process.platform === \"linux\") {\n            storagebackend = safeStorage.getSelectedStorageBackend();\n        }\n\n        return {\n            plaintext,\n            storagebackend,\n        };\n    }\n\n    async handle_networkonline(rh: RpcResponseHelper): Promise<boolean> {\n        return net.isOnline();\n    }\n\n    async handle_electronsystembell(rh: RpcResponseHelper): Promise<void> {\n        shell.beep();\n    }\n\n    // async handle_workspaceupdate(rh: RpcResponseHelper) {\n    //     console.log(\"workspaceupdate\");\n    //     fireAndForget(async () => {\n    //         console.log(\"workspace menu clicked\");\n    //         const updatedWorkspaceMenu = await getWorkspaceMenu();\n    //         const workspaceMenu = Menu.getApplicationMenu().getMenuItemById(\"workspace-menu\");\n    //         workspaceMenu.submenu = Menu.buildFromTemplate(updatedWorkspaceMenu);\n    //     });\n    // }\n}\n\nexport let ElectronWshClient: ElectronWshClientType;\n\nexport function initElectronWshClient() {\n    ElectronWshClient = new ElectronWshClientType();\n}\n"
  },
  {
    "path": "emain/emain.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport * as electron from \"electron\";\nimport { focusedBuilderWindow, getAllBuilderWindows } from \"emain/emain-builder\";\nimport { globalEvents } from \"emain/emain-events\";\nimport { sprintf } from \"sprintf-js\";\nimport * as services from \"../frontend/app/store/services\";\nimport { initElectronWshrpc, shutdownWshrpc } from \"../frontend/app/store/wshrpcutil-base\";\nimport { fireAndForget, sleep } from \"../frontend/util/util\";\nimport { AuthKey, configureAuthKeyRequestInjection } from \"./authkey\";\nimport {\n    getActivityState,\n    getAndClearTermCommandsDurable,\n    getAndClearTermCommandsRemote,\n    getAndClearTermCommandsRun,\n    getAndClearTermCommandsWsl,\n    getForceQuit,\n    getGlobalIsRelaunching,\n    getUserConfirmedQuit,\n    setForceQuit,\n    setGlobalIsQuitting,\n    setGlobalIsStarting,\n    setUserConfirmedQuit,\n    setWasActive,\n    setWasInFg,\n} from \"./emain-activity\";\nimport { initIpcHandlers } from \"./emain-ipc\";\nimport { log } from \"./emain-log\";\nimport { initMenuEventSubscriptions, makeAndSetAppMenu, makeDockTaskbar } from \"./emain-menu\";\nimport {\n    checkIfRunningUnderARM64Translation,\n    getElectronAppBasePath,\n    getElectronAppUnpackedBasePath,\n    getWaveConfigDir,\n    getWaveDataDir,\n    isDev,\n    unameArch,\n    unamePlatform,\n} from \"./emain-platform\";\nimport { ensureHotSpareTab, setMaxTabCacheSize } from \"./emain-tabview\";\nimport { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, runWaveSrv } from \"./emain-wavesrv\";\nimport {\n    createBrowserWindow,\n    createNewWaveWindow,\n    focusedWaveWindow,\n    getAllWaveWindows,\n    getWaveWindowById,\n    getWaveWindowByWorkspaceId,\n    registerGlobalHotkey,\n    relaunchBrowserWindows,\n    WaveBrowserWindow,\n} from \"./emain-window\";\nimport { ElectronWshClient, initElectronWshClient } from \"./emain-wsh\";\nimport { getLaunchSettings } from \"./launchsettings\";\nimport { configureAutoUpdater, updater } from \"./updater\";\n\nconst electronApp = electron.app;\n\nlet confirmQuit = true;\n\nconst waveDataDir = getWaveDataDir();\nconst waveConfigDir = getWaveConfigDir();\n\nelectron.nativeTheme.themeSource = \"dark\";\n\nconsole.log = log;\nconsole.log(\n    sprintf(\n        \"waveterm-app starting, data_dir=%s, config_dir=%s electronpath=%s gopath=%s arch=%s/%s electron=%s\",\n        waveDataDir,\n        waveConfigDir,\n        getElectronAppBasePath(),\n        getElectronAppUnpackedBasePath(),\n        unamePlatform,\n        unameArch,\n        process.versions.electron\n    )\n);\nif (isDev) {\n    console.log(\"waveterm-app WAVETERM_DEV set\");\n}\n\nfunction handleWSEvent(evtMsg: WSEventType) {\n    fireAndForget(async () => {\n        console.log(\"handleWSEvent\", evtMsg?.eventtype);\n        if (evtMsg.eventtype == \"electron:newwindow\") {\n            console.log(\"electron:newwindow\", evtMsg.data);\n            const windowId: string = evtMsg.data;\n            const windowData: WaveWindow = (await services.ObjectService.GetObject(\"window:\" + windowId)) as WaveWindow;\n            if (windowData == null) {\n                return;\n            }\n            const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient);\n            const newWin = await createBrowserWindow(windowData, fullConfig, {\n                unamePlatform,\n                isPrimaryStartupWindow: false,\n            });\n            newWin.show();\n        } else if (evtMsg.eventtype == \"electron:closewindow\") {\n            console.log(\"electron:closewindow\", evtMsg.data);\n            if (evtMsg.data === undefined) return;\n            const ww = getWaveWindowById(evtMsg.data);\n            if (ww != null) {\n                ww.destroy(); // bypass the \"are you sure?\" dialog\n            }\n        } else if (evtMsg.eventtype == \"electron:updateactivetab\") {\n            const activeTabUpdate: { workspaceid: string; newactivetabid: string } = evtMsg.data;\n            console.log(\"electron:updateactivetab\", activeTabUpdate);\n            const ww = getWaveWindowByWorkspaceId(activeTabUpdate.workspaceid);\n            if (ww == null) {\n                return;\n            }\n            await ww.setActiveTab(activeTabUpdate.newactivetabid, false);\n        } else {\n            console.log(\"unhandled electron ws eventtype\", evtMsg.eventtype);\n        }\n    });\n}\n\n// we try to set the primary display as index [0]\nfunction getActivityDisplays(): ActivityDisplayType[] {\n    const displays = electron.screen.getAllDisplays();\n    const primaryDisplay = electron.screen.getPrimaryDisplay();\n    const rtn: ActivityDisplayType[] = [];\n    for (const display of displays) {\n        const adt = {\n            width: display.size.width,\n            height: display.size.height,\n            dpr: display.scaleFactor,\n            internal: display.internal,\n        };\n        if (display.id === primaryDisplay?.id) {\n            rtn.unshift(adt);\n        } else {\n            rtn.push(adt);\n        }\n    }\n    return rtn;\n}\n\nasync function sendDisplaysTDataEvent() {\n    const displays = getActivityDisplays();\n    if (displays.length === 0) {\n        return;\n    }\n    const props: TEventProps = {};\n    props[\"display:count\"] = displays.length;\n    props[\"display:height\"] = displays[0].height;\n    props[\"display:width\"] = displays[0].width;\n    props[\"display:dpr\"] = displays[0].dpr;\n    props[\"display:all\"] = displays;\n    try {\n        await RpcApi.RecordTEventCommand(\n            ElectronWshClient,\n            {\n                event: \"app:display\",\n                props,\n            },\n            { noresponse: true }\n        );\n    } catch (e) {\n        console.log(\"error sending display tdata event\", e);\n    }\n}\n\nfunction logActiveState() {\n    fireAndForget(async () => {\n        const astate = getActivityState();\n        const activity: ActivityUpdate = { openminutes: 1 };\n        const ww = focusedWaveWindow;\n        const activeTabView = ww?.activeTabView;\n        const isWaveAIOpen = activeTabView?.isWaveAIOpen ?? false;\n\n        if (astate.wasInFg) {\n            activity.fgminutes = 1;\n        }\n        if (astate.wasActive) {\n            activity.activeminutes = 1;\n        }\n        activity.displays = getActivityDisplays();\n\n        const termCmdCount = getAndClearTermCommandsRun();\n        if (termCmdCount > 0) {\n            activity.termcommandsrun = termCmdCount;\n        }\n        const termCmdRemoteCount = getAndClearTermCommandsRemote();\n        const termCmdWslCount = getAndClearTermCommandsWsl();\n        const termCmdDurableCount = getAndClearTermCommandsDurable();\n\n        const props: TEventProps = {\n            \"activity:activeminutes\": activity.activeminutes,\n            \"activity:fgminutes\": activity.fgminutes,\n            \"activity:openminutes\": activity.openminutes,\n        };\n        if (termCmdCount > 0) {\n            props[\"activity:termcommandsrun\"] = termCmdCount;\n        }\n        if (termCmdRemoteCount > 0) {\n            props[\"activity:termcommands:remote\"] = termCmdRemoteCount;\n        }\n        if (termCmdWslCount > 0) {\n            props[\"activity:termcommands:wsl\"] = termCmdWslCount;\n        }\n        if (termCmdDurableCount > 0) {\n            props[\"activity:termcommands:durable\"] = termCmdDurableCount;\n        }\n        if (astate.wasActive && isWaveAIOpen) {\n            props[\"activity:waveaiactiveminutes\"] = 1;\n        }\n        if (astate.wasInFg && isWaveAIOpen) {\n            props[\"activity:waveaifgminutes\"] = 1;\n        }\n\n        try {\n            await RpcApi.ActivityCommand(ElectronWshClient, activity, { noresponse: true });\n            await RpcApi.RecordTEventCommand(\n                ElectronWshClient,\n                {\n                    event: \"app:activity\",\n                    props,\n                },\n                { noresponse: true }\n            );\n        } catch (e) {\n            console.log(\"error logging active state\", e);\n        } finally {\n            setWasInFg(ww?.isFocused() ?? false);\n            setWasActive(false);\n        }\n    });\n}\n\n// this isn't perfect, but gets the job done without being complicated\nfunction runActiveTimer() {\n    logActiveState();\n    setTimeout(runActiveTimer, 60000);\n}\n\nfunction hideWindowWithCatch(window: WaveBrowserWindow) {\n    if (window == null) {\n        return;\n    }\n    try {\n        if (window.isDestroyed()) {\n            return;\n        }\n        window.hide();\n    } catch (e) {\n        console.log(\"error hiding window\", e);\n    }\n}\n\nelectronApp.on(\"window-all-closed\", () => {\n    if (getGlobalIsRelaunching()) {\n        return;\n    }\n    if (unamePlatform !== \"darwin\") {\n        setUserConfirmedQuit(true);\n        electronApp.quit();\n    }\n});\nelectronApp.on(\"before-quit\", (e) => {\n    const allWindows = getAllWaveWindows();\n    const allBuilders = getAllBuilderWindows();\n    if (\n        confirmQuit &&\n        !getForceQuit() &&\n        !getUserConfirmedQuit() &&\n        (allWindows.length > 0 || allBuilders.length > 0) &&\n        !getIsWaveSrvDead() &&\n        !process.env.WAVETERM_NOCONFIRMQUIT\n    ) {\n        e.preventDefault();\n        const choice = electron.dialog.showMessageBoxSync(null, {\n            type: \"question\",\n            buttons: [\"Cancel\", \"Quit\"],\n            title: \"Confirm Quit\",\n            message: \"Are you sure you want to quit Wave Terminal?\",\n            defaultId: 0,\n            cancelId: 0,\n        });\n        if (choice === 0) {\n            return;\n        }\n        setUserConfirmedQuit(true);\n        electronApp.quit();\n        return;\n    }\n    setGlobalIsQuitting(true);\n    updater?.stop();\n    if (unamePlatform == \"win32\") {\n        // win32 doesn't have a SIGINT, so we just let electron die, which\n        // ends up killing wavesrv via closing it's stdin.\n        return;\n    }\n    getWaveSrvProc()?.kill(\"SIGINT\");\n    shutdownWshrpc();\n    if (getForceQuit()) {\n        return;\n    }\n    e.preventDefault();\n    for (const window of allWindows) {\n        hideWindowWithCatch(window);\n    }\n    for (const builder of allBuilders) {\n        builder.hide();\n    }\n    if (getIsWaveSrvDead()) {\n        console.log(\"wavesrv is dead, quitting immediately\");\n        setForceQuit(true);\n        electronApp.quit();\n        return;\n    }\n    setTimeout(() => {\n        console.log(\"waiting for wavesrv to exit...\");\n        setForceQuit(true);\n        electronApp.quit();\n    }, 3000);\n});\nprocess.on(\"SIGINT\", () => {\n    console.log(\"Caught SIGINT, shutting down\");\n    setUserConfirmedQuit(true);\n    electronApp.quit();\n});\nprocess.on(\"SIGHUP\", () => {\n    console.log(\"Caught SIGHUP, shutting down\");\n    setUserConfirmedQuit(true);\n    electronApp.quit();\n});\nprocess.on(\"SIGTERM\", () => {\n    console.log(\"Caught SIGTERM, shutting down\");\n    setUserConfirmedQuit(true);\n    electronApp.quit();\n});\nlet caughtException = false;\nprocess.on(\"uncaughtException\", (error) => {\n    if (caughtException) {\n        return;\n    }\n\n    // Check if the error is related to QUIC protocol, if so, ignore (can happen with the updater)\n    if (error?.message?.includes(\"net::ERR_QUIC_PROTOCOL_ERROR\")) {\n        console.log(\"Ignoring QUIC protocol error:\", error.message);\n        console.log(\"Stack Trace:\", error.stack);\n        return;\n    }\n\n    caughtException = true;\n    console.log(\"Uncaught Exception, shutting down: \", error);\n    console.log(\"Stack Trace:\", error.stack);\n    // Optionally, handle cleanup or exit the app\n    setUserConfirmedQuit(true);\n    electronApp.quit();\n});\n\nlet lastWaveWindowCount = 0;\nlet lastIsBuilderWindowActive = false;\nglobalEvents.on(\"windows-updated\", () => {\n    const wwCount = getAllWaveWindows().length;\n    const isBuilderActive = focusedBuilderWindow != null;\n    if (wwCount == lastWaveWindowCount && isBuilderActive == lastIsBuilderWindowActive) {\n        return;\n    }\n    lastWaveWindowCount = wwCount;\n    lastIsBuilderWindowActive = isBuilderActive;\n    console.log(\"windows-updated\", wwCount, \"builder-active:\", isBuilderActive);\n    makeAndSetAppMenu();\n});\n\nasync function appMain() {\n    // Set disableHardwareAcceleration as early as possible, if required.\n    const launchSettings = getLaunchSettings();\n    if (launchSettings?.[\"window:disablehardwareacceleration\"]) {\n        console.log(\"disabling hardware acceleration, per launch settings\");\n        electronApp.disableHardwareAcceleration();\n    }\n    const startTs = Date.now();\n    const instanceLock = electronApp.requestSingleInstanceLock();\n    if (!instanceLock) {\n        console.log(\"waveterm-app could not get single-instance-lock, shutting down\");\n        setUserConfirmedQuit(true);\n        electronApp.quit();\n        return;\n    }\n    electronApp.on(\"second-instance\", (_event, argv, workingDirectory) => {\n        console.log(\"second-instance event, argv:\", argv, \"workingDirectory:\", workingDirectory);\n        fireAndForget(createNewWaveWindow);\n    });\n    try {\n        await runWaveSrv(handleWSEvent);\n    } catch (e) {\n        console.log(e.toString());\n    }\n    const ready = await getWaveSrvReady();\n    console.log(\"wavesrv ready signal received\", ready, Date.now() - startTs, \"ms\");\n    await electronApp.whenReady();\n    configureAuthKeyRequestInjection(electron.session.defaultSession);\n    initIpcHandlers();\n\n    await sleep(10); // wait a bit for wavesrv to be ready\n    try {\n        initElectronWshClient();\n        initElectronWshrpc(ElectronWshClient, { authKey: AuthKey });\n        initMenuEventSubscriptions();\n    } catch (e) {\n        console.log(\"error initializing wshrpc\", e);\n    }\n    const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient);\n    checkIfRunningUnderARM64Translation(fullConfig);\n    if (fullConfig?.settings?.[\"app:confirmquit\"] != null) {\n        confirmQuit = fullConfig.settings[\"app:confirmquit\"];\n    }\n    ensureHotSpareTab(fullConfig);\n    await relaunchBrowserWindows();\n    setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe\n    setTimeout(sendDisplaysTDataEvent, 5000);\n\n    makeAndSetAppMenu();\n    makeDockTaskbar();\n    await configureAutoUpdater();\n    setGlobalIsStarting(false);\n    if (fullConfig?.settings?.[\"window:maxtabcachesize\"] != null) {\n        setMaxTabCacheSize(fullConfig.settings[\"window:maxtabcachesize\"]);\n    }\n\n    electronApp.on(\"activate\", () => {\n        const allWindows = getAllWaveWindows();\n        if (allWindows.length === 0) {\n            fireAndForget(createNewWaveWindow);\n        }\n    });\n    electron.powerMonitor.on(\"resume\", () => {\n        console.log(\"system resumed from sleep, notifying server\");\n        fireAndForget(async () => {\n            try {\n                await RpcApi.NotifySystemResumeCommand(ElectronWshClient, { noresponse: true });\n            } catch (e) {\n                console.log(\"error calling NotifySystemResumeCommand\", e);\n            }\n        });\n    });\n    const rawGlobalHotKey = launchSettings?.[\"app:globalhotkey\"];\n    if (rawGlobalHotKey) {\n        registerGlobalHotkey(rawGlobalHotKey);\n    }\n}\n\nappMain().catch((e) => {\n    console.log(\"appMain error\", e);\n    setUserConfirmedQuit(true);\n    electronApp.quit();\n});\n"
  },
  {
    "path": "emain/launchsettings.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport fs from \"fs\";\nimport path from \"path\";\nimport { getWaveConfigDir } from \"./emain-platform\";\n\n/**\n * Get settings directly from the Wave Home directory on launch.\n * Only use this when the app is first starting up. Otherwise, prefer the settings.GetFullConfig function.\n * @returns The initial launch settings for the application.\n */\nexport function getLaunchSettings(): SettingsType {\n    const settingsPath = path.join(getWaveConfigDir(), \"settings.json\");\n    try {\n        const settingsContents = fs.readFileSync(settingsPath, \"utf8\");\n        return JSON.parse(settingsContents);\n    } catch (_) {\n        // fail silently\n    }\n}\n"
  },
  {
    "path": "emain/preload-webview.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { ipcRenderer } from \"electron\";\n\ndocument.addEventListener(\"contextmenu\", (event) => {\n    console.log(\"contextmenu event\", event);\n    if (event.target == null) {\n        return;\n    }\n    const targetElement = event.target as HTMLElement;\n    // Check if the right-click is on an image\n    if (targetElement.tagName === \"IMG\") {\n        setTimeout(() => {\n            if (event.defaultPrevented) {\n                return;\n            }\n            event.preventDefault();\n            const imgElem = targetElement as HTMLImageElement;\n            const imageUrl = imgElem.src;\n            ipcRenderer.send(\"webview-image-contextmenu\", { src: imageUrl });\n        }, 50);\n        return;\n    }\n    // do nothing\n});\n\nconsole.log(\"loaded wave preload-webview.ts\");\n"
  },
  {
    "path": "emain/preload.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { contextBridge, ipcRenderer, Rectangle, WebviewTag } from \"electron\";\n\n// update type in custom.d.ts (ElectronApi type)\ncontextBridge.exposeInMainWorld(\"api\", {\n    getAuthKey: () => ipcRenderer.sendSync(\"get-auth-key\"),\n    getIsDev: () => ipcRenderer.sendSync(\"get-is-dev\"),\n    getPlatform: () => ipcRenderer.sendSync(\"get-platform\"),\n    getCursorPoint: () => ipcRenderer.sendSync(\"get-cursor-point\"),\n    getUserName: () => ipcRenderer.sendSync(\"get-user-name\"),\n    getHostName: () => ipcRenderer.sendSync(\"get-host-name\"),\n    getDataDir: () => ipcRenderer.sendSync(\"get-data-dir\"),\n    getConfigDir: () => ipcRenderer.sendSync(\"get-config-dir\"),\n    getHomeDir: () => ipcRenderer.sendSync(\"get-home-dir\"),\n    getAboutModalDetails: () => ipcRenderer.sendSync(\"get-about-modal-details\"),\n    getWebviewPreload: () => ipcRenderer.sendSync(\"get-webview-preload\"),\n    getZoomFactor: () => ipcRenderer.sendSync(\"get-zoom-factor\"),\n    openNewWindow: () => ipcRenderer.send(\"open-new-window\"),\n    showWorkspaceAppMenu: (workspaceId) => ipcRenderer.send(\"workspace-appmenu-show\", workspaceId),\n    showBuilderAppMenu: (builderId) => ipcRenderer.send(\"builder-appmenu-show\", builderId),\n    showContextMenu: (workspaceId, menu) => ipcRenderer.send(\"contextmenu-show\", workspaceId, menu),\n    onContextMenuClick: (callback: (id: string | null) => void) =>\n        ipcRenderer.on(\"contextmenu-click\", (_event, id: string | null) => callback(id)),\n    downloadFile: (filePath) => ipcRenderer.send(\"download\", { filePath }),\n    openExternal: (url) => {\n        if (url && typeof url === \"string\") {\n            ipcRenderer.send(\"open-external\", url);\n        } else {\n            console.error(\"Invalid URL passed to openExternal:\", url);\n        }\n    },\n    getEnv: (varName) => ipcRenderer.sendSync(\"get-env\", varName),\n    onFullScreenChange: (callback) =>\n        ipcRenderer.on(\"fullscreen-change\", (_event, isFullScreen) => callback(isFullScreen)),\n    onZoomFactorChange: (callback) =>\n        ipcRenderer.on(\"zoom-factor-change\", (_event, zoomFactor) => callback(zoomFactor)),\n    onUpdaterStatusChange: (callback) => ipcRenderer.on(\"app-update-status\", (_event, status) => callback(status)),\n    getUpdaterStatus: () => ipcRenderer.sendSync(\"get-app-update-status\"),\n    getUpdaterChannel: () => ipcRenderer.sendSync(\"get-updater-channel\"),\n    installAppUpdate: () => ipcRenderer.send(\"install-app-update\"),\n    onMenuItemAbout: (callback) => ipcRenderer.on(\"menu-item-about\", callback),\n    updateWindowControlsOverlay: (rect) => ipcRenderer.send(\"update-window-controls-overlay\", rect),\n    onReinjectKey: (callback) => ipcRenderer.on(\"reinject-key\", (_event, waveEvent) => callback(waveEvent)),\n    setWebviewFocus: (focused: number) => ipcRenderer.send(\"webview-focus\", focused),\n    registerGlobalWebviewKeys: (keys) => ipcRenderer.send(\"register-global-webview-keys\", keys),\n    onControlShiftStateUpdate: (callback) =>\n        ipcRenderer.on(\"control-shift-state-update\", (_event, state) => callback(state)),\n    createWorkspace: () => ipcRenderer.send(\"create-workspace\"),\n    switchWorkspace: (workspaceId) => ipcRenderer.send(\"switch-workspace\", workspaceId),\n    deleteWorkspace: (workspaceId) => ipcRenderer.send(\"delete-workspace\", workspaceId),\n    setActiveTab: (tabId) => ipcRenderer.send(\"set-active-tab\", tabId),\n    createTab: () => ipcRenderer.send(\"create-tab\"),\n    closeTab: (workspaceId, tabId, confirmClose) => ipcRenderer.invoke(\"close-tab\", workspaceId, tabId, confirmClose),\n    setWindowInitStatus: (status) => ipcRenderer.send(\"set-window-init-status\", status),\n    onWaveInit: (callback) => ipcRenderer.on(\"wave-init\", (_event, initOpts) => callback(initOpts)),\n    onBuilderInit: (callback) => ipcRenderer.on(\"builder-init\", (_event, initOpts) => callback(initOpts)),\n    sendLog: (log) => ipcRenderer.send(\"fe-log\", log),\n    onQuicklook: (filePath: string) => ipcRenderer.send(\"quicklook\", filePath),\n    openNativePath: (filePath: string) => ipcRenderer.send(\"open-native-path\", filePath),\n    captureScreenshot: (rect: Rectangle) => ipcRenderer.invoke(\"capture-screenshot\", rect),\n    setKeyboardChordMode: () => ipcRenderer.send(\"set-keyboard-chord-mode\"),\n    clearWebviewStorage: (webContentsId: number) => ipcRenderer.invoke(\"clear-webview-storage\", webContentsId),\n    setWaveAIOpen: (isOpen: boolean) => ipcRenderer.send(\"set-waveai-open\", isOpen),\n    closeBuilderWindow: () => ipcRenderer.send(\"close-builder-window\"),\n    incrementTermCommands: (opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) =>\n        ipcRenderer.send(\"increment-term-commands\", opts),\n    nativePaste: () => ipcRenderer.send(\"native-paste\"),\n    openBuilder: (appId?: string) => ipcRenderer.send(\"open-builder\", appId),\n    setBuilderWindowAppId: (appId: string) => ipcRenderer.send(\"set-builder-window-appid\", appId),\n    doRefresh: () => ipcRenderer.send(\"do-refresh\"),\n    saveTextFile: (fileName: string, content: string) => ipcRenderer.invoke(\"save-text-file\", fileName, content),\n    setIsActive: () => ipcRenderer.invoke(\"set-is-active\"),\n});\n\n// Custom event for \"new-window\"\nipcRenderer.on(\"webview-new-window\", (e, webContentsId, details) => {\n    const event = new CustomEvent(\"new-window\", { detail: details });\n    document.getElementById(\"webview\").dispatchEvent(event);\n});\n\nipcRenderer.on(\"webcontentsid-from-blockid\", (e, blockId, responseCh) => {\n    const webviewElem: WebviewTag = document.querySelector(\"div[data-blockid='\" + blockId + \"'] webview\");\n    const wcId = webviewElem?.dataset?.webcontentsid;\n    ipcRenderer.send(responseCh, wcId);\n});\n"
  },
  {
    "path": "emain/updater.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { dialog, ipcMain, Notification } from \"electron\";\nimport { autoUpdater } from \"electron-updater\";\nimport { readFileSync } from \"fs\";\nimport path from \"path\";\nimport YAML from \"yaml\";\nimport { RpcApi } from \"../frontend/app/store/wshclientapi\";\nimport { isDev } from \"../frontend/util/isdev\";\nimport { fireAndForget } from \"../frontend/util/util\";\nimport { setUserConfirmedQuit } from \"./emain-activity\";\nimport { delay } from \"./emain-util\";\nimport { focusedWaveWindow, getAllWaveWindows } from \"./emain-window\";\nimport { ElectronWshClient } from \"./emain-wsh\";\n\nexport let updater: Updater;\n\nfunction getUpdateChannel(settings: SettingsType): string {\n    const updaterConfigPath = path.join(process.resourcesPath!, \"app-update.yml\");\n    const updaterConfig = YAML.parse(readFileSync(updaterConfigPath, { encoding: \"utf8\" }).toString());\n    console.log(\"Updater config from binary:\", updaterConfig);\n    const updaterChannel: string = updaterConfig.channel ?? \"latest\";\n    const settingsChannel = settings[\"autoupdate:channel\"];\n    let retVal = settingsChannel;\n\n    // If the user setting doesn't exist yet, set it to the value of the updater config.\n    // If the user was previously on the `latest` channel and has downloaded a `beta` version, update their configured channel to `beta` to prevent downgrading.\n    if (!settingsChannel || (settingsChannel == \"latest\" && updaterChannel == \"beta\")) {\n        console.log(\"Update channel setting does not exist, setting to value from updater config.\");\n        RpcApi.SetConfigCommand(ElectronWshClient, { \"autoupdate:channel\": updaterChannel });\n        retVal = updaterChannel;\n    }\n    console.log(\"Update channel:\", retVal);\n    return retVal;\n}\n\nexport class Updater {\n    autoCheckInterval: NodeJS.Timeout | null;\n    intervalms: number;\n    autoCheckEnabled: boolean;\n    availableUpdateReleaseName: string | null;\n    availableUpdateReleaseNotes: string | null;\n    private _status: UpdaterStatus;\n    lastUpdateCheck: Date;\n\n    constructor(settings: SettingsType) {\n        this.intervalms = settings[\"autoupdate:intervalms\"];\n        console.log(\"Update check interval in milliseconds:\", this.intervalms);\n        this.autoCheckEnabled = settings[\"autoupdate:enabled\"];\n        console.log(\"Update check enabled:\", this.autoCheckEnabled);\n\n        this._status = \"up-to-date\";\n        this.lastUpdateCheck = new Date(0);\n        this.autoCheckInterval = null;\n        this.availableUpdateReleaseName = null;\n\n        autoUpdater.autoInstallOnAppQuit = settings[\"autoupdate:installonquit\"];\n        console.log(\"Install update on quit:\", settings[\"autoupdate:installonquit\"]);\n\n        // Only update the release channel if it's specified, otherwise use the one configured in the updater.\n        autoUpdater.channel = getUpdateChannel(settings);\n        autoUpdater.allowDowngrade = false;\n\n        autoUpdater.removeAllListeners();\n\n        autoUpdater.on(\"error\", (err) => {\n            console.log(\"updater error\");\n            console.log(err);\n            if (!err.toString()?.includes(\"net::ERR_INTERNET_DISCONNECTED\")) this.status = \"error\";\n        });\n\n        autoUpdater.on(\"checking-for-update\", () => {\n            console.log(\"checking-for-update\");\n            this.status = \"checking\";\n        });\n\n        autoUpdater.on(\"update-available\", () => {\n            console.log(\"update-available; downloading...\");\n            this.status = \"downloading\";\n        });\n\n        autoUpdater.on(\"update-not-available\", () => {\n            console.log(\"update-not-available\");\n            this.status = \"up-to-date\";\n        });\n\n        autoUpdater.on(\"update-downloaded\", (event) => {\n            console.log(\"update-downloaded\", [event]);\n            this.availableUpdateReleaseName = event.releaseName;\n            this.availableUpdateReleaseNotes = event.releaseNotes as string | null;\n\n            // Display the update banner and create a system notification\n            this.status = \"ready\";\n            const updateNotification = new Notification({\n                title: \"Wave Terminal\",\n                body: \"A new version of Wave Terminal is ready to install.\",\n            });\n            updateNotification.on(\"click\", () => {\n                fireAndForget(this.promptToInstallUpdate.bind(this));\n            });\n            updateNotification.show();\n        });\n    }\n\n    /**\n     * The status of the Updater.\n     */\n    get status(): UpdaterStatus {\n        return this._status;\n    }\n\n    private set status(value: UpdaterStatus) {\n        this._status = value;\n        getAllWaveWindows().forEach((window) => {\n            const allTabs = Array.from(window.allLoadedTabViews.values());\n            allTabs.forEach((tab) => {\n                tab.webContents.send(\"app-update-status\", value);\n            });\n        });\n    }\n\n    /**\n     * Check for updates and start the background update check, if configured.\n     */\n    async start() {\n        if (this.autoCheckEnabled) {\n            console.log(\"starting updater\");\n            this.autoCheckInterval = setInterval(() => {\n                fireAndForget(() => this.checkForUpdates(false));\n            }, 600000); // intervals are unreliable when an app is suspended so we will check every 10 mins if the interval has passed.\n            await this.checkForUpdates(false);\n        }\n    }\n\n    /**\n     * Stop the background update check, if configured.\n     */\n    stop() {\n        console.log(\"stopping updater\");\n        if (this.autoCheckInterval) {\n            clearInterval(this.autoCheckInterval);\n            this.autoCheckInterval = null;\n        }\n    }\n\n    /**\n     * Checks if the configured interval time has passed since the last update check, and if so, checks for updates using the `autoUpdater` object\n     * @param userInput Whether the user is requesting this. If so, an alert will report the result of the check.\n     */\n    async checkForUpdates(userInput: boolean) {\n        const now = new Date();\n\n        // 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.\n        if (\n            userInput ||\n            (this.autoCheckInterval &&\n                (!this.lastUpdateCheck || Math.abs(now.getTime() - this.lastUpdateCheck.getTime()) > this.intervalms))\n        ) {\n            const result = await autoUpdater.checkForUpdates();\n\n            // 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.\n            if (userInput && !result.downloadPromise) {\n                const dialogOpts: Electron.MessageBoxOptions = {\n                    type: \"info\",\n                    message: \"There are currently no updates available.\",\n                };\n                if (focusedWaveWindow) {\n                    dialog.showMessageBox(focusedWaveWindow, dialogOpts);\n                }\n            }\n\n            // Only update the last check time if this is an automatic check. This ensures the interval remains consistent.\n            if (!userInput) this.lastUpdateCheck = now;\n        }\n    }\n\n    /**\n     * Prompts the user to install the downloaded application update and restarts the application\n     */\n    async promptToInstallUpdate() {\n        const dialogOpts: Electron.MessageBoxOptions = {\n            type: \"info\",\n            buttons: [\"Restart\", \"Later\"],\n            title: \"Application Update\",\n            message: process.platform === \"win32\" ? this.availableUpdateReleaseNotes : this.availableUpdateReleaseName,\n            detail: \"A new version has been downloaded. Restart the application to apply the updates.\",\n        };\n\n        const allWindows = getAllWaveWindows();\n        if (allWindows.length > 0) {\n            await dialog.showMessageBox(focusedWaveWindow ?? allWindows[0], dialogOpts).then(({ response }) => {\n                if (response === 0) {\n                    fireAndForget(this.installUpdate.bind(this));\n                }\n            });\n        }\n    }\n\n    /**\n     * Restarts the app and installs an update if it is available.\n     */\n    async installUpdate() {\n        if (this.status == \"ready\") {\n            this.status = \"installing\";\n            await delay(1000);\n            setUserConfirmedQuit(true);\n            autoUpdater.quitAndInstall();\n        }\n    }\n}\n\nexport function getResolvedUpdateChannel(): string {\n    return isDev() ? \"dev\" : (autoUpdater.channel ?? \"latest\");\n}\n\nipcMain.on(\"install-app-update\", () => fireAndForget(updater?.promptToInstallUpdate.bind(updater)));\nipcMain.on(\"get-app-update-status\", (event) => {\n    event.returnValue = updater?.status;\n});\nipcMain.on(\"get-updater-channel\", (event) => {\n    event.returnValue = getResolvedUpdateChannel();\n});\n\nlet autoUpdateLock = false;\n\n/**\n * Configures the auto-updater based on the user's preference\n */\nexport async function configureAutoUpdater() {\n    if (isDev()) {\n        console.log(\"skipping auto-updater in dev mode\");\n        return;\n    }\n\n    // simple lock to prevent multiple auto-update configuration attempts, this should be very rare\n    if (autoUpdateLock) {\n        console.log(\"auto-update configuration already in progress, skipping\");\n        return;\n    }\n    autoUpdateLock = true;\n\n    try {\n        console.log(\"Configuring updater\");\n        const settings = (await RpcApi.GetFullConfigCommand(ElectronWshClient)).settings;\n        updater = new Updater(settings);\n        await updater.start();\n    } catch (e) {\n        console.warn(\"error configuring updater\", e.toString());\n    }\n\n    autoUpdateLock = false;\n}\n"
  },
  {
    "path": "eslint.config.js",
    "content": "// @ts-check\n\nimport eslint from \"@eslint/js\";\nimport eslintConfigPrettier from \"eslint-config-prettier\";\nimport globals from \"globals\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport tseslint from \"typescript-eslint\";\n\nconst tsconfigRootDir = path.dirname(fileURLToPath(new URL(import.meta.url)));\n\nexport default [\n    {\n        languageOptions: {\n            parserOptions: {\n                tsconfigRootDir,\n            },\n        },\n    },\n\n    {\n        ignores: [\n            \"**/node_modules/**\",\n            \"**/dist/**\",\n            \"**/build/**\",\n            \"**/make/**\",\n            \"tsunami/frontend/scaffold/**\",\n            \"docs/.docusaurus/**\",\n        ],\n    },\n\n    {\n        files: [\"frontend/**/*.{ts,tsx}\", \"emain/**/*.{ts,tsx}\"],\n        languageOptions: {\n            parserOptions: {\n                tsconfigRootDir,\n                project: \"./tsconfig.json\",\n            },\n        },\n    },\n\n    {\n        files: [\"docs/**/*.{ts,tsx}\"],\n        languageOptions: {\n            parserOptions: { tsconfigRootDir, project: \"./docs/tsconfig.json\" },\n        },\n    },\n\n    eslint.configs.recommended,\n    ...tseslint.configs.recommended,\n\n    {\n        rules: {\n            \"@typescript-eslint/no-explicit-any\": \"off\",\n        },\n    },\n\n    {\n        files: [\"emain/**/*.ts\", \"electron.vite.config.ts\", \"**/*.cjs\", \"eslint.config.js\", \"docs/babel.config.js\"],\n        languageOptions: {\n            globals: {\n                ...globals.node,\n            },\n        },\n    },\n\n    {\n        files: [\"**/*.js\", \"**/*.cjs\"],\n        rules: {\n            \"@typescript-eslint/no-require-imports\": \"off\",\n        },\n    },\n\n    {\n        rules: {\n            \"@typescript-eslint/no-unused-vars\": [\n                \"warn\",\n                {\n                    argsIgnorePattern: \"^(_[a-zA-Z0-9_]*|e|get)$\",\n                    varsIgnorePattern: \"^(_[a-zA-Z0-9_]*|dlog|e)$\",\n                    caughtErrorsIgnorePattern: \"^(_[a-zA-Z0-9_]*|e)$\",\n                },\n            ],\n            \"prefer-const\": \"warn\",\n            \"no-empty\": \"warn\",\n        },\n    },\n\n    {\n        files: [\"frontend/app/store/services.ts\"],\n        rules: {\n            \"@typescript-eslint/no-unused-vars\": \"off\",\n            \"prefer-rest-params\": \"off\",\n        },\n    },\n\n    eslintConfigPrettier,\n];\n"
  },
  {
    "path": "frontend/app/aipanel/ai-utils.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { sortByDisplayOrder } from \"@/util/util\";\n\nconst TextFileLimit = 200 * 1024; // 200KB\nconst PdfLimit = 5 * 1024 * 1024; // 5MB\nconst ImageLimit = 10 * 1024 * 1024; // 10MB\nconst ImagePreviewSize = 128;\nconst ImagePreviewWebPQuality = 0.8;\nconst ImageMaxEdge = 4096;\n\nexport const isAcceptableFile = (file: File): boolean => {\n    const acceptableTypes = [\n        // Images\n        \"image/jpeg\",\n        \"image/jpg\",\n        \"image/png\",\n        \"image/gif\",\n        \"image/webp\",\n        \"image/svg+xml\",\n        // PDFs\n        \"application/pdf\",\n        // Text files\n        \"text/plain\",\n        \"text/markdown\",\n        \"text/html\",\n        \"text/css\",\n        \"text/javascript\",\n        \"text/typescript\",\n        // Application types for code files\n        \"application/javascript\",\n        \"application/typescript\",\n        \"application/json\",\n        \"application/xml\",\n    ];\n\n    if (acceptableTypes.includes(file.type)) {\n        return true;\n    }\n\n    // Check file extensions for files without proper MIME types\n    const extension = file.name.split(\".\").pop()?.toLowerCase();\n    const acceptableExtensions = [\n        \"txt\",\n        \"log\",\n        \"md\",\n        \"js\",\n        \"mjs\",\n        \"cjs\",\n        \"jsx\",\n        \"ts\",\n        \"mts\",\n        \"cts\",\n        \"tsx\",\n        \"go\",\n        \"py\",\n        \"java\",\n        \"c\",\n        \"cpp\",\n        \"h\",\n        \"hpp\",\n        \"html\",\n        \"htm\",\n        \"css\",\n        \"scss\",\n        \"sass\",\n        \"json\",\n        \"jsonc\",\n        \"json5\",\n        \"jsonl\",\n        \"ndjson\",\n        \"xml\",\n        \"yaml\",\n        \"yml\",\n        \"sh\",\n        \"bat\",\n        \"sql\",\n        \"php\",\n        \"rb\",\n        \"rs\",\n        \"swift\",\n        \"kt\",\n        \"cs\",\n        \"vb\",\n        \"r\",\n        \"scala\",\n        \"clj\",\n        \"ex\",\n        \"exs\",\n        \"ini\",\n        \"toml\",\n        \"conf\",\n        \"cfg\",\n        \"env\",\n        \"zsh\",\n        \"fish\",\n        \"ps1\",\n        \"psm1\",\n        \"bazel\",\n        \"bzl\",\n        \"csv\",\n        \"tsv\",\n        \"properties\",\n        \"ipynb\",\n        \"rmd\",\n        \"gradle\",\n        \"groovy\",\n        \"cmake\",\n    ];\n\n    if (extension && acceptableExtensions.includes(extension)) {\n        return true;\n    }\n\n    // Check for specific filenames (case-insensitive)\n    const fileName = file.name.toLowerCase();\n    const acceptableFilenames = [\n        \"makefile\",\n        \"dockerfile\",\n        \"containerfile\",\n        \"go.mod\",\n        \"go.sum\",\n        \"go.work\",\n        \"go.work.sum\",\n        \"package.json\",\n        \"package-lock.json\",\n        \"yarn.lock\",\n        \"pnpm-lock.yaml\",\n        \"composer.json\",\n        \"composer.lock\",\n        \"gemfile\",\n        \"gemfile.lock\",\n        \"podfile\",\n        \"podfile.lock\",\n        \"cargo.toml\",\n        \"cargo.lock\",\n        \"pipfile\",\n        \"pipfile.lock\",\n        \"requirements.txt\",\n        \"setup.py\",\n        \"pyproject.toml\",\n        \"poetry.lock\",\n        \"build.gradle\",\n        \"settings.gradle\",\n        \"pom.xml\",\n        \"build.xml\",\n        \"readme\",\n        \"readme.md\",\n        \"license\",\n        \"license.md\",\n        \"changelog\",\n        \"changelog.md\",\n        \"contributing\",\n        \"contributing.md\",\n        \"authors\",\n        \"codeowners\",\n        \"procfile\",\n        \"jenkinsfile\",\n        \"vagrantfile\",\n        \"rakefile\",\n        \"gruntfile.js\",\n        \"gulpfile.js\",\n        \"webpack.config.js\",\n        \"rollup.config.js\",\n        \"vite.config.js\",\n        \"jest.config.js\",\n        \"vitest.config.js\",\n        \".dockerignore\",\n        \".gitignore\",\n        \".gitattributes\",\n        \".gitmodules\",\n        \".editorconfig\",\n        \".eslintrc\",\n        \".prettierrc\",\n        \".pylintrc\",\n        \".bashrc\",\n        \".bash_profile\",\n        \".bash_login\",\n        \".bash_logout\",\n        \".profile\",\n        \".zshrc\",\n        \".zprofile\",\n        \".zshenv\",\n        \".zlogin\",\n        \".zlogout\",\n        \".kshrc\",\n        \".cshrc\",\n        \".tcshrc\",\n        \".xonshrc\",\n        \".shrc\",\n        \".aliases\",\n        \".functions\",\n        \".exports\",\n        \".direnvrc\",\n        \".vimrc\",\n        \".gvimrc\",\n    ];\n\n    return acceptableFilenames.includes(fileName);\n};\n\nexport const getFileIcon = (fileName: string, fileType: string): string => {\n    if (fileType === \"directory\") {\n        return \"fa-folder\";\n    }\n\n    if (fileType.startsWith(\"image/\")) {\n        return \"fa-image\";\n    }\n\n    if (fileType === \"application/pdf\") {\n        return \"fa-file-pdf\";\n    }\n\n    // Check file extensions for code files\n    const ext = fileName.split(\".\").pop()?.toLowerCase();\n    switch (ext) {\n        case \"js\":\n        case \"jsx\":\n        case \"ts\":\n        case \"tsx\":\n            return \"fa-file-code\";\n        case \"go\":\n            return \"fa-file-code\";\n        case \"py\":\n            return \"fa-file-code\";\n        case \"java\":\n        case \"c\":\n        case \"cpp\":\n        case \"h\":\n        case \"hpp\":\n            return \"fa-file-code\";\n        case \"html\":\n        case \"css\":\n        case \"scss\":\n        case \"sass\":\n            return \"fa-file-code\";\n        case \"json\":\n        case \"xml\":\n        case \"yaml\":\n        case \"yml\":\n            return \"fa-file-code\";\n        case \"md\":\n        case \"txt\":\n            return \"fa-file-text\";\n        default:\n            return \"fa-file\";\n    }\n};\n\nexport const formatFileSize = (bytes: number): string => {\n    if (bytes === 0) return \"0 B\";\n    const k = 1024;\n    const sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n    const i = Math.floor(Math.log(bytes) / Math.log(k));\n    return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + \" \" + sizes[i];\n};\n\n// Normalize MIME type for AI processing\nexport const normalizeMimeType = (file: File): string => {\n    const fileType = file.type;\n\n    // Images keep their real mimetype\n    if (fileType.startsWith(\"image/\")) {\n        return fileType;\n    }\n\n    // PDFs keep their mimetype\n    if (fileType === \"application/pdf\") {\n        return fileType;\n    }\n\n    // Everything else (code files, markdown, text, etc.) becomes text/plain\n    return \"text/plain\";\n};\n\n// Helper function to read file as base64 for AIMessage\nexport const readFileAsBase64 = (file: File): Promise<string> => {\n    return new Promise((resolve, reject) => {\n        const reader = new FileReader();\n        reader.onload = () => {\n            const result = reader.result as string;\n            // Remove data URL prefix to get just base64\n            const base64 = result.split(\",\")[1];\n            resolve(base64);\n        };\n        reader.onerror = reject;\n        reader.readAsDataURL(file);\n    });\n};\n\n// Helper function to create data URL for UIMessage\nexport const createDataUrl = (file: File): Promise<string> => {\n    return new Promise((resolve, reject) => {\n        const reader = new FileReader();\n        reader.onload = () => resolve(reader.result as string);\n        reader.onerror = reject;\n        reader.readAsDataURL(file);\n    });\n};\n\nexport interface FileSizeError {\n    fileName: string;\n    fileSize: number;\n    maxSize: number;\n    fileType: \"text\" | \"pdf\" | \"image\";\n}\n\nexport const validateFileSize = (file: File): FileSizeError | null => {\n    if (file.type.startsWith(\"image/\")) {\n        if (file.size > ImageLimit) {\n            return {\n                fileName: file.name,\n                fileSize: file.size,\n                maxSize: ImageLimit,\n                fileType: \"image\",\n            };\n        }\n    } else if (file.type === \"application/pdf\") {\n        if (file.size > PdfLimit) {\n            return {\n                fileName: file.name,\n                fileSize: file.size,\n                maxSize: PdfLimit,\n                fileType: \"pdf\",\n            };\n        }\n    } else {\n        if (file.size > TextFileLimit) {\n            return {\n                fileName: file.name,\n                fileSize: file.size,\n                maxSize: TextFileLimit,\n                fileType: \"text\",\n            };\n        }\n    }\n\n    return null;\n};\n\nexport const validateFileSizeFromInfo = (\n    fileName: string,\n    fileSize: number,\n    mimeType: string\n): FileSizeError | null => {\n    let maxSize: number;\n    let fileType: \"text\" | \"pdf\" | \"image\";\n\n    if (mimeType.startsWith(\"image/\")) {\n        maxSize = ImageLimit;\n        fileType = \"image\";\n    } else if (mimeType === \"application/pdf\") {\n        maxSize = PdfLimit;\n        fileType = \"pdf\";\n    } else {\n        maxSize = TextFileLimit;\n        fileType = \"text\";\n    }\n\n    if (fileSize > maxSize) {\n        return {\n            fileName,\n            fileSize,\n            maxSize,\n            fileType,\n        };\n    }\n\n    return null;\n};\n\nexport const formatFileSizeError = (error: FileSizeError): string => {\n    const typeLabel = error.fileType === \"image\" ? \"Image\" : error.fileType === \"pdf\" ? \"PDF\" : \"Text file\";\n    return `${typeLabel} \"${error.fileName}\" is too large (${formatFileSize(error.fileSize)}). Maximum size is ${formatFileSize(error.maxSize)}.`;\n};\n\n/**\n * Resize an image to have a maximum edge of 4096px and convert to WebP format\n * Returns the optimized image if it's smaller than the original, otherwise returns the original\n */\nexport const resizeImage = async (file: File): Promise<File> => {\n    // Only process actual image files (not SVG)\n    if (!file.type.startsWith(\"image/\") || file.type === \"image/svg+xml\") {\n        return file;\n    }\n\n    return new Promise((resolve) => {\n        const img = new Image();\n        const url = URL.createObjectURL(file);\n\n        img.onload = async () => {\n            URL.revokeObjectURL(url);\n\n            let { width, height } = img;\n\n            // Check if resizing is needed\n            if (width <= ImageMaxEdge && height <= ImageMaxEdge) {\n                // Image is already small enough, just try WebP conversion\n                const canvas = document.createElement(\"canvas\");\n                canvas.width = width;\n                canvas.height = height;\n                const ctx = canvas.getContext(\"2d\");\n                ctx?.drawImage(img, 0, 0);\n\n                canvas.toBlob(\n                    (blob) => {\n                        if (blob && blob.size < file.size) {\n                            const webpFile = new File([blob], file.name.replace(/\\.[^.]+$/, \".webp\"), {\n                                type: \"image/webp\",\n                            });\n                            console.log(\n                                `Image resized (no dimension change): ${file.name} - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}`\n                            );\n                            resolve(webpFile);\n                        } else {\n                            console.log(\n                                `Image kept original (WebP not smaller): ${file.name} - ${formatFileSize(file.size)}`\n                            );\n                            resolve(file);\n                        }\n                    },\n                    \"image/webp\",\n                    ImagePreviewWebPQuality\n                );\n                return;\n            }\n\n            // Calculate new dimensions while maintaining aspect ratio\n            if (width > height) {\n                height = Math.round((height * ImageMaxEdge) / width);\n                width = ImageMaxEdge;\n            } else {\n                width = Math.round((width * ImageMaxEdge) / height);\n                height = ImageMaxEdge;\n            }\n\n            // Create canvas and resize\n            const canvas = document.createElement(\"canvas\");\n            canvas.width = width;\n            canvas.height = height;\n            const ctx = canvas.getContext(\"2d\");\n            ctx?.drawImage(img, 0, 0, width, height);\n\n            // Convert to WebP\n            canvas.toBlob(\n                (blob) => {\n                    if (blob && blob.size < file.size) {\n                        const webpFile = new File([blob], file.name.replace(/\\.[^.]+$/, \".webp\"), {\n                            type: \"image/webp\",\n                        });\n                        console.log(\n                            `Image resized: ${file.name} (${img.width}x${img.height} → ${width}x${height}) - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}`\n                        );\n                        resolve(webpFile);\n                    } else {\n                        console.log(\n                            `Image kept original (WebP not smaller): ${file.name} (${img.width}x${img.height} → ${width}x${height}) - ${formatFileSize(file.size)}`\n                        );\n                        resolve(file);\n                    }\n                },\n                \"image/webp\",\n                ImagePreviewWebPQuality\n            );\n        };\n\n        img.onerror = () => {\n            URL.revokeObjectURL(url);\n            resolve(file);\n        };\n\n        img.src = url;\n    });\n};\n\n/**\n * Create a 128x128 preview data URL for an image file\n */\nexport const createImagePreview = async (file: File): Promise<string | null> => {\n    if (!file.type.startsWith(\"image/\") || file.type === \"image/svg+xml\") {\n        return null;\n    }\n\n    return new Promise((resolve) => {\n        const img = new Image();\n        const url = URL.createObjectURL(file);\n\n        img.onload = () => {\n            URL.revokeObjectURL(url);\n\n            let { width, height } = img;\n\n            if (width > height) {\n                height = Math.round((height * ImagePreviewSize) / width);\n                width = ImagePreviewSize;\n            } else {\n                width = Math.round((width * ImagePreviewSize) / height);\n                height = ImagePreviewSize;\n            }\n\n            const canvas = document.createElement(\"canvas\");\n            canvas.width = width;\n            canvas.height = height;\n            const ctx = canvas.getContext(\"2d\");\n            ctx?.drawImage(img, 0, 0, width, height);\n\n            canvas.toBlob(\n                (blob) => {\n                    if (blob) {\n                        const reader = new FileReader();\n                        reader.onloadend = () => {\n                            resolve(reader.result as string);\n                        };\n                        reader.readAsDataURL(blob);\n                    } else {\n                        resolve(null);\n                    }\n                },\n                \"image/webp\",\n                ImagePreviewWebPQuality\n            );\n        };\n\n        img.onerror = () => {\n            URL.revokeObjectURL(url);\n            resolve(null);\n        };\n\n        img.src = url;\n    });\n};\n\n\n/**\n * Filter and organize AI mode configs into Wave and custom provider groups\n * Returns organized configs that should be displayed based on settings and premium status\n */\nexport interface FilteredAIModeConfigs {\n    waveProviderConfigs: Array<{ mode: string } & AIModeConfigType>;\n    otherProviderConfigs: Array<{ mode: string } & AIModeConfigType>;\n    shouldShowCloudModes: boolean;\n}\n\nexport const getFilteredAIModeConfigs = (\n    aiModeConfigs: Record<string, AIModeConfigType>,\n    showCloudModes: boolean,\n    inBuilder: boolean,\n    hasPremium: boolean,\n    currentMode?: string\n): FilteredAIModeConfigs => {\n    const hideQuick = inBuilder && hasPremium;\n\n    const allConfigs = Object.entries(aiModeConfigs)\n        .map(([mode, config]) => ({ mode, ...config }))\n        .filter((config) => !(hideQuick && config.mode === \"waveai@quick\"));\n\n    const otherProviderConfigs = allConfigs\n        .filter((config) => config[\"ai:provider\"] !== \"wave\")\n        .sort(sortByDisplayOrder);\n\n    const hasCustomModels = otherProviderConfigs.length > 0;\n    const isCurrentModeCloud = currentMode?.startsWith(\"waveai@\") ?? false;\n    const shouldShowCloudModes = showCloudModes || !hasCustomModels || isCurrentModeCloud;\n\n    const waveProviderConfigs = shouldShowCloudModes\n        ? allConfigs.filter((config) => config[\"ai:provider\"] === \"wave\").sort(sortByDisplayOrder)\n        : [];\n\n    return {\n        waveProviderConfigs,\n        otherProviderConfigs,\n        shouldShowCloudModes,\n    };\n};\n\n/**\n * Get the display name for an AI mode configuration.\n * If display:name is set, use that. Otherwise, construct from model/provider.\n * For azure-legacy, show \"azureresourcename (azure)\".\n * For other providers, show \"model (provider)\".\n */\nexport function getModeDisplayName(config: AIModeConfigType): string {\n    if (config[\"display:name\"]) {\n        return config[\"display:name\"];\n    }\n\n    const provider = config[\"ai:provider\"];\n    const model = config[\"ai:model\"];\n    const azureResourceName = config[\"ai:azureresourcename\"];\n\n    if (provider === \"azure-legacy\") {\n        return `${azureResourceName || \"unknown\"} (azure)`;\n    }\n\n    return `${model || \"unknown\"} (${provider || \"custom\"})`;\n}\n"
  },
  {
    "path": "frontend/app/aipanel/aidroppedfiles.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { cn } from \"@/util/util\";\nimport { useAtomValue } from \"jotai\";\nimport { memo } from \"react\";\nimport { formatFileSize, getFileIcon } from \"./ai-utils\";\nimport type { WaveAIModel } from \"./waveai-model\";\n\ninterface AIDroppedFilesProps {\n    model: WaveAIModel;\n}\n\nexport const AIDroppedFiles = memo(({ model }: AIDroppedFilesProps) => {\n    const droppedFiles = useAtomValue(model.droppedFiles);\n\n    if (droppedFiles.length === 0) {\n        return null;\n    }\n\n    return (\n        <div className=\"p-2 border-b border-gray-600\">\n            <div className=\"flex gap-2 overflow-x-auto pb-1\">\n                {droppedFiles.map((file) => (\n                    <div key={file.id} className=\"relative bg-zinc-700 rounded-lg p-2 min-w-20 flex-shrink-0 group\">\n                        <button\n                            onClick={() => model.removeFile(file.id)}\n                            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\"\n                        >\n                            <i className=\"fa fa-times text-xs\"></i>\n                        </button>\n\n                        <div className=\"flex flex-col items-center text-center\">\n                            {file.previewUrl ? (\n                                <div className=\"w-12 h-12 mb-1\">\n                                    <img\n                                        src={file.previewUrl}\n                                        alt={file.name}\n                                        className=\"w-full h-full object-cover rounded\"\n                                    />\n                                </div>\n                            ) : (\n                                <div className=\"w-12 h-12 mb-1 flex items-center justify-center bg-zinc-600 rounded\">\n                                    <i\n                                        className={cn(\"fa text-lg text-gray-300\", getFileIcon(file.name, file.type))}\n                                    ></i>\n                                </div>\n                            )}\n\n                            <div className=\"text-[10px] text-gray-200 truncate w-full max-w-16\" title={file.name}>\n                                {file.name}\n                            </div>\n                            <div className=\"text-[9px] text-gray-400\">{formatFileSize(file.size)}</div>\n                        </div>\n                    </div>\n                ))}\n            </div>\n        </div>\n    );\n});\n\nAIDroppedFiles.displayName = \"AIDroppedFiles\";\n"
  },
  {
    "path": "frontend/app/aipanel/aifeedbackbuttons.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { cn, makeIconClass } from \"@/util/util\";\nimport { memo, useState } from \"react\";\nimport { WaveAIModel } from \"./waveai-model\";\n\ninterface AIFeedbackButtonsProps {\n    messageText: string;\n}\n\nexport const AIFeedbackButtons = memo(({ messageText }: AIFeedbackButtonsProps) => {\n    const [thumbsUpClicked, setThumbsUpClicked] = useState(false);\n    const [thumbsDownClicked, setThumbsDownClicked] = useState(false);\n    const [copied, setCopied] = useState(false);\n\n    const handleThumbsUp = () => {\n        setThumbsUpClicked(!thumbsUpClicked);\n        if (thumbsDownClicked) {\n            setThumbsDownClicked(false);\n        }\n        if (!thumbsUpClicked) {\n            WaveAIModel.getInstance().handleAIFeedback(\"good\");\n        }\n    };\n\n    const handleThumbsDown = () => {\n        setThumbsDownClicked(!thumbsDownClicked);\n        if (thumbsUpClicked) {\n            setThumbsUpClicked(false);\n        }\n        if (!thumbsDownClicked) {\n            WaveAIModel.getInstance().handleAIFeedback(\"bad\");\n        }\n    };\n\n    const handleCopy = () => {\n        navigator.clipboard.writeText(messageText);\n        setCopied(true);\n        setTimeout(() => setCopied(false), 2000);\n    };\n\n    return (\n        <div className=\"flex items-center gap-0.5 mt-2\">\n            <button\n                onClick={handleThumbsUp}\n                className={cn(\n                    \"p-1.5 rounded cursor-pointer transition-colors\",\n                    thumbsUpClicked\n                        ? \"text-accent\"\n                        : \"text-secondary hover:bg-zinc-700 hover:text-primary\"\n                )}\n                title=\"Good Response\"\n            >\n                <i className={makeIconClass(thumbsUpClicked ? \"solid@thumbs-up\" : \"regular@thumbs-up\", false)} />\n            </button>\n            <button\n                onClick={handleThumbsDown}\n                className={cn(\n                    \"p-1.5 rounded cursor-pointer transition-colors\",\n                    thumbsDownClicked\n                        ? \"text-accent\"\n                        : \"text-secondary hover:bg-zinc-700 hover:text-primary\"\n                )}\n                title=\"Bad Response\"\n            >\n                <i className={makeIconClass(thumbsDownClicked ? \"solid@thumbs-down\" : \"regular@thumbs-down\", false)} />\n            </button>\n            {messageText?.trim() && (\n                <button\n                    onClick={handleCopy}\n                    className={cn(\n                        \"p-1.5 rounded cursor-pointer transition-colors\",\n                        copied\n                            ? \"text-success\"\n                            : \"text-secondary hover:bg-zinc-700 hover:text-primary\"\n                    )}\n                    title=\"Copy Message\"\n                >\n                    <i className={makeIconClass(copied ? \"solid@check\" : \"regular@copy\", false)} />\n                </button>\n            )}\n        </div>\n    );\n});\n\nAIFeedbackButtons.displayName = \"AIFeedbackButtons\";"
  },
  {
    "path": "frontend/app/aipanel/aimessage.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { WaveStreamdown } from \"@/app/element/streamdown\";\nimport { cn } from \"@/util/util\";\nimport { memo, useEffect, useRef } from \"react\";\nimport { getFileIcon } from \"./ai-utils\";\nimport { AIFeedbackButtons } from \"./aifeedbackbuttons\";\nimport { AIToolUseGroup } from \"./aitooluse\";\nimport { WaveUIMessage, WaveUIMessagePart } from \"./aitypes\";\nimport { WaveAIModel } from \"./waveai-model\";\n\nconst AIThinking = memo(\n    ({\n        message = \"AI is thinking...\",\n        reasoningText,\n        isWaitingApproval = false,\n    }: {\n        message?: string;\n        reasoningText?: string;\n        isWaitingApproval?: boolean;\n    }) => {\n        const scrollRef = useRef<HTMLDivElement>(null);\n\n        useEffect(() => {\n            if (scrollRef.current && reasoningText) {\n                scrollRef.current.scrollTop = scrollRef.current.scrollHeight;\n            }\n        }, [reasoningText]);\n\n        const displayText = reasoningText\n            ? (() => {\n                  const lastDoubleNewline = reasoningText.lastIndexOf(\"\\n\\n\");\n                  return lastDoubleNewline !== -1 ? reasoningText.substring(lastDoubleNewline + 2) : reasoningText;\n              })()\n            : \"\";\n\n        return (\n            <div className=\"flex flex-col gap-1\">\n                <div className=\"flex items-center gap-2\">\n                    {isWaitingApproval ? (\n                        <i className=\"fa fa-clock text-base text-yellow-500\"></i>\n                    ) : (\n                        <div className=\"animate-pulse flex items-center\">\n                            <i className=\"fa fa-circle text-[10px]\"></i>\n                            <i className=\"fa fa-circle text-[10px] mx-1\"></i>\n                            <i className=\"fa fa-circle text-[10px]\"></i>\n                        </div>\n                    )}\n                    {message && <span className=\"text-sm text-gray-400\">{message}</span>}\n                </div>\n                <div ref={scrollRef} className=\"text-sm text-gray-500 overflow-y-auto h-[3lh] max-w-[600px] pl-9\">\n                    {displayText}\n                </div>\n            </div>\n        );\n    }\n);\n\nAIThinking.displayName = \"AIThinking\";\n\ninterface UserMessageFilesProps {\n    fileParts: Array<WaveUIMessagePart & { type: \"data-userfile\" }>;\n}\n\nconst UserMessageFiles = memo(({ fileParts }: UserMessageFilesProps) => {\n    if (fileParts.length === 0) return null;\n\n    return (\n        <div className=\"mt-2 pt-2 border-t border-gray-600\">\n            <div className=\"flex gap-2 overflow-x-auto pb-1\">\n                {fileParts.map((file, index) => (\n                    <div key={index} className=\"relative bg-zinc-700 rounded-lg p-2 min-w-20 flex-shrink-0\">\n                        <div className=\"flex flex-col items-center text-center\">\n                            <div className=\"w-12 h-12 mb-1 flex items-center justify-center bg-zinc-600 rounded overflow-hidden\">\n                                {file.data?.previewurl ? (\n                                    <img\n                                        src={file.data.previewurl}\n                                        alt={file.data?.filename || \"File\"}\n                                        className=\"w-full h-full object-cover\"\n                                    />\n                                ) : (\n                                    <i\n                                        className={cn(\n                                            \"fa text-lg text-gray-300\",\n                                            getFileIcon(file.data?.filename || \"\", file.data?.mimetype || \"\")\n                                        )}\n                                    ></i>\n                                )}\n                            </div>\n                            <div\n                                className=\"text-[10px] text-gray-200 truncate w-full max-w-16\"\n                                title={file.data?.filename || \"File\"}\n                            >\n                                {file.data?.filename || \"File\"}\n                            </div>\n                        </div>\n                    </div>\n                ))}\n            </div>\n        </div>\n    );\n});\n\nUserMessageFiles.displayName = \"UserMessageFiles\";\n\ninterface AIMessagePartProps {\n    part: WaveUIMessagePart;\n    role: string;\n    isStreaming: boolean;\n}\n\nconst AIMessagePart = memo(({ part, role, isStreaming }: AIMessagePartProps) => {\n    const model = WaveAIModel.getInstance();\n\n    if (part.type === \"text\") {\n        const content = part.text ?? \"\";\n\n        if (role === \"user\") {\n            return <div className=\"whitespace-pre-wrap break-words\">{content}</div>;\n        } else {\n            return (\n                <WaveStreamdown\n                    text={content}\n                    parseIncompleteMarkdown={isStreaming}\n                    className=\"text-gray-100\"\n                    codeBlockMaxWidthAtom={model.codeBlockMaxWidth}\n                />\n            );\n        }\n    }\n\n    return null;\n});\n\nAIMessagePart.displayName = \"AIMessagePart\";\n\ninterface AIMessageProps {\n    message: WaveUIMessage;\n    isStreaming: boolean;\n}\n\nconst isDisplayPart = (part: WaveUIMessagePart): boolean => {\n    return (\n        part.type === \"text\" ||\n        part.type === \"data-tooluse\" ||\n        part.type === \"data-toolprogress\" ||\n        (part.type.startsWith(\"tool-\") && \"state\" in part && part.state === \"input-available\")\n    );\n};\n\ntype MessagePart =\n    | { type: \"single\"; part: WaveUIMessagePart }\n    | { type: \"toolgroup\"; parts: Array<WaveUIMessagePart & { type: \"data-tooluse\" | \"data-toolprogress\" }> };\n\nconst groupMessageParts = (parts: WaveUIMessagePart[]): MessagePart[] => {\n    const grouped: MessagePart[] = [];\n    let currentToolGroup: Array<WaveUIMessagePart & { type: \"data-tooluse\" | \"data-toolprogress\" }> = [];\n\n    for (const part of parts) {\n        if (part.type === \"data-tooluse\" || part.type === \"data-toolprogress\") {\n            currentToolGroup.push(part as WaveUIMessagePart & { type: \"data-tooluse\" | \"data-toolprogress\" });\n        } else {\n            if (currentToolGroup.length > 0) {\n                grouped.push({ type: \"toolgroup\", parts: currentToolGroup });\n                currentToolGroup = [];\n            }\n            grouped.push({ type: \"single\", part });\n        }\n    }\n\n    if (currentToolGroup.length > 0) {\n        grouped.push({ type: \"toolgroup\", parts: currentToolGroup });\n    }\n\n    return grouped;\n};\n\nconst getThinkingMessage = (\n    parts: WaveUIMessagePart[],\n    isStreaming: boolean,\n    role: string\n): { message: string; reasoningText?: string; isWaitingApproval?: boolean } | null => {\n    if (!isStreaming || role !== \"assistant\") {\n        return null;\n    }\n\n    const hasPendingApprovals = parts.some(\n        (part) => part.type === \"data-tooluse\" && part.data?.approval === \"needs-approval\"\n    );\n\n    if (hasPendingApprovals) {\n        return { message: \"Waiting for Tool Approvals...\", isWaitingApproval: true };\n    }\n\n    const lastPart = parts[parts.length - 1];\n\n    if (lastPart?.type === \"reasoning\") {\n        const reasoningContent = lastPart.text || \"\";\n        return { message: \"AI is thinking...\", reasoningText: reasoningContent };\n    }\n\n    if (lastPart?.type === \"text\" && lastPart.text) {\n        return null;\n    }\n\n    return { message: \"\" };\n};\n\nexport const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => {\n    const parts = message.parts || [];\n    const displayParts = parts.filter(isDisplayPart);\n    const fileParts = parts.filter(\n        (part): part is WaveUIMessagePart & { type: \"data-userfile\" } => part.type === \"data-userfile\"\n    );\n\n    const thinkingData = getThinkingMessage(parts, isStreaming, message.role);\n    const groupedParts = groupMessageParts(displayParts);\n\n    return (\n        <div className={cn(\"flex\", message.role === \"user\" ? \"justify-end\" : \"justify-start\")}>\n            <div\n                className={cn(\n                    \"px-2 rounded-lg [&>*:first-child]:!mt-0\",\n                    message.role === \"user\"\n                        ? \"py-2 bg-zinc-700/60 text-white max-w-[calc(100%-50px)]\"\n                        : \"min-w-[min(100%,500px)]\"\n                )}\n            >\n                {displayParts.length === 0 && !isStreaming && !thinkingData ? (\n                    <div className=\"whitespace-pre-wrap break-words\">(no text content)</div>\n                ) : (\n                    <>\n                        {groupedParts.map((group, index: number) =>\n                            group.type === \"toolgroup\" ? (\n                                <AIToolUseGroup key={index} parts={group.parts} isStreaming={isStreaming} />\n                            ) : (\n                                <div key={index} className=\"mt-2\">\n                                    <AIMessagePart part={group.part} role={message.role} isStreaming={isStreaming} />\n                                </div>\n                            )\n                        )}\n                        {thinkingData != null && (\n                            <div className=\"mt-2\">\n                                <AIThinking\n                                    message={thinkingData.message}\n                                    reasoningText={thinkingData.reasoningText}\n                                    isWaitingApproval={thinkingData.isWaitingApproval}\n                                />\n                            </div>\n                        )}\n                    </>\n                )}\n\n                {message.role === \"user\" && <UserMessageFiles fileParts={fileParts} />}\n                {message.role === \"assistant\" && !isStreaming && displayParts.length > 0 && (\n                    <AIFeedbackButtons\n                        messageText={parts\n                            .filter((p) => p.type === \"text\")\n                            .map((p) => p.text || \"\")\n                            .join(\"\\n\\n\")}\n                    />\n                )}\n            </div>\n        </div>\n    );\n});\n\nAIMessage.displayName = \"AIMessage\";\n"
  },
  {
    "path": "frontend/app/aipanel/aimode.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Tooltip } from \"@/app/element/tooltip\";\nimport { atoms, getSettingsKeyAtom } from \"@/app/store/global\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { cn, fireAndForget, makeIconClass } from \"@/util/util\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useRef, useState } from \"react\";\nimport { getFilteredAIModeConfigs, getModeDisplayName } from \"./ai-utils\";\nimport { WaveAIModel } from \"./waveai-model\";\n\ninterface AIModeMenuItemProps {\n    config: AIModeConfigWithMode;\n    isSelected: boolean;\n    isDisabled: boolean;\n    isPremiumDisabled: boolean;\n    onClick: () => void;\n    isFirst?: boolean;\n    isLast?: boolean;\n}\n\nconst AIModeMenuItem = memo(({ config, isSelected, isDisabled, isPremiumDisabled, onClick, isFirst, isLast }: AIModeMenuItemProps) => {\n    return (\n        <button\n            key={config.mode}\n            onClick={onClick}\n            disabled={isDisabled}\n            className={cn(\n                \"w-full flex flex-col gap-0.5 px-3 transition-colors text-left\",\n                isFirst ? \"pt-1 pb-0.5\" : isLast ? \"pt-0.5 pb-1\" : \"pt-0.5 pb-0.5\",\n                isDisabled ? \"text-zinc-500\" : \"text-zinc-300 hover:bg-zinc-700 cursor-pointer\"\n            )}\n        >\n            <div className=\"flex items-center gap-2 w-full\">\n                <i className={makeIconClass(config[\"display:icon\"] || \"sparkles\", false)}></i>\n                <span className={cn(\"text-sm\", isSelected && \"font-bold\")}>\n                    {getModeDisplayName(config)}\n                    {isPremiumDisabled && \" (premium)\"}\n                </span>\n                {isSelected && <i className=\"fa fa-check ml-auto\"></i>}\n            </div>\n            {config[\"display:description\"] && (\n                <div\n                    className={cn(\"text-xs pl-5\", isDisabled ? \"text-gray-500\" : \"text-muted\")}\n                    style={{ whiteSpace: \"pre-line\" }}\n                >\n                    {config[\"display:description\"]}\n                </div>\n            )}\n        </button>\n    );\n});\n\nAIModeMenuItem.displayName = \"AIModeMenuItem\";\n\ninterface ConfigSection {\n    sectionName: string;\n    configs: AIModeConfigWithMode[];\n    isIncompatible?: boolean;\n    noTelemetry?: boolean;\n}\n\nfunction computeCompatibleSections(\n    currentMode: string,\n    aiModeConfigs: Record<string, AIModeConfigType>,\n    waveProviderConfigs: AIModeConfigWithMode[],\n    otherProviderConfigs: AIModeConfigWithMode[]\n): ConfigSection[] {\n    const currentConfig = aiModeConfigs[currentMode];\n    const allConfigs = [...waveProviderConfigs, ...otherProviderConfigs];\n\n    if (!currentConfig) {\n        return [{ sectionName: \"Incompatible Modes\", configs: allConfigs, isIncompatible: true }];\n    }\n\n    const currentSwitchCompat = currentConfig[\"ai:switchcompat\"] || [];\n    const compatibleConfigs: AIModeConfigWithMode[] = [{ ...currentConfig, mode: currentMode }];\n    const incompatibleConfigs: AIModeConfigWithMode[] = [];\n\n    if (currentSwitchCompat.length === 0) {\n        allConfigs.forEach((config) => {\n            if (config.mode !== currentMode) {\n                incompatibleConfigs.push(config);\n            }\n        });\n    } else {\n        allConfigs.forEach((config) => {\n            if (config.mode === currentMode) return;\n\n            const configSwitchCompat = config[\"ai:switchcompat\"] || [];\n            const hasMatch = currentSwitchCompat.some((currentTag: string) => configSwitchCompat.includes(currentTag));\n\n            if (hasMatch) {\n                compatibleConfigs.push(config);\n            } else {\n                incompatibleConfigs.push(config);\n            }\n        });\n    }\n\n    const sections: ConfigSection[] = [];\n    const compatibleSectionName = compatibleConfigs.length === 1 ? \"Current\" : \"Compatible Modes\";\n    sections.push({ sectionName: compatibleSectionName, configs: compatibleConfigs });\n\n    if (incompatibleConfigs.length > 0) {\n        sections.push({ sectionName: \"Incompatible Modes\", configs: incompatibleConfigs, isIncompatible: true });\n    }\n\n    return sections;\n}\n\nfunction computeWaveCloudSections(\n    waveProviderConfigs: AIModeConfigWithMode[],\n    otherProviderConfigs: AIModeConfigWithMode[],\n    telemetryEnabled: boolean\n): ConfigSection[] {\n    const sections: ConfigSection[] = [];\n\n    if (waveProviderConfigs.length > 0) {\n        sections.push({\n            sectionName: \"Wave AI Cloud\",\n            configs: waveProviderConfigs,\n            noTelemetry: !telemetryEnabled,\n        });\n    }\n    if (otherProviderConfigs.length > 0) {\n        sections.push({ sectionName: \"Custom\", configs: otherProviderConfigs });\n    }\n\n    return sections;\n}\n\ninterface AIModeDropdownProps {\n    compatibilityMode?: boolean;\n}\n\nexport const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdownProps) => {\n    const model = WaveAIModel.getInstance();\n    const currentMode = useAtomValue(model.currentAIMode);\n    const aiModeConfigs = useAtomValue(model.aiModeConfigs);\n    const waveaiModeConfigs = useAtomValue(atoms.waveaiModeConfigAtom);\n    const widgetContextEnabled = useAtomValue(model.widgetAccessAtom);\n    const hasPremium = useAtomValue(model.hasPremiumAtom);\n    const showCloudModes = useAtomValue(getSettingsKeyAtom(\"waveai:showcloudmodes\"));\n    const telemetryEnabled = useAtomValue(getSettingsKeyAtom(\"telemetry:enabled\")) ?? false;\n    const [isOpen, setIsOpen] = useState(false);\n    const dropdownRef = useRef<HTMLDivElement>(null);\n\n    const { waveProviderConfigs, otherProviderConfigs } = getFilteredAIModeConfigs(\n        aiModeConfigs,\n        showCloudModes,\n        model.inBuilder,\n        hasPremium,\n        currentMode\n    );\n\n    const sections: ConfigSection[] = compatibilityMode\n        ? computeCompatibleSections(currentMode, aiModeConfigs, waveProviderConfigs, otherProviderConfigs)\n        : computeWaveCloudSections(waveProviderConfigs, otherProviderConfigs, telemetryEnabled);\n\n    const showSectionHeaders = compatibilityMode || sections.length > 1;\n\n    const handleSelect = (mode: string) => {\n        const config = aiModeConfigs[mode];\n        if (!config) return;\n        if (!hasPremium && config[\"waveai:premium\"]) {\n            return;\n        }\n        model.setAIMode(mode);\n        setIsOpen(false);\n    };\n\n    const displayConfig = aiModeConfigs[currentMode];\n    const displayName = displayConfig ? getModeDisplayName(displayConfig) : `Invalid (${currentMode})`;\n    const displayIcon = displayConfig ? displayConfig[\"display:icon\"] || \"sparkles\" : \"question\";\n    const resolvedConfig = waveaiModeConfigs[currentMode];\n    const hasToolsSupport = resolvedConfig && resolvedConfig[\"ai:capabilities\"]?.includes(\"tools\");\n    const showNoToolsWarning = widgetContextEnabled && resolvedConfig && !hasToolsSupport;\n\n    const handleNewChatClick = () => {\n        model.clearChat();\n        setIsOpen(false);\n    };\n\n    const handleConfigureClick = () => {\n        fireAndForget(async () => {\n            RpcApi.RecordTEventCommand(\n                TabRpcClient,\n                {\n                    event: \"action:other\",\n                    props: {\n                        \"action:type\": \"waveai:configuremodes:contextmenu\",\n                    },\n                },\n                { noresponse: true }\n            );\n            await model.openWaveAIConfig();\n            setIsOpen(false);\n        });\n    };\n\n    const handleEnableTelemetry = () => {\n        fireAndForget(async () => {\n            await RpcApi.WaveAIEnableTelemetryCommand(TabRpcClient);\n            setTimeout(() => {\n                model.focusInput();\n            }, 100);\n        });\n    };\n\n    return (\n        <div className=\"relative\" ref={dropdownRef}>\n            <button\n                onClick={() => setIsOpen(!isOpen)}\n                className={cn(\n                    \"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\",\n                    isOpen ? \"bg-zinc-700\" : \"bg-zinc-800/50 hover:bg-zinc-700\"\n                )}\n                title={`AI Mode: ${displayName}`}\n            >\n                <i className={cn(makeIconClass(displayIcon, false), \"text-[10px]\")}></i>\n                <span className={`text-[11px]`}>{displayName}</span>\n                <i className=\"fa fa-chevron-down text-[8px]\"></i>\n            </button>\n\n            {showNoToolsWarning && (\n                <Tooltip\n                    content={\n                        <div className=\"max-w-xs\">\n                            Warning: This custom mode was configured without the \"tools\" capability in the\n                            \"ai:capabilities\" array. Without tool support, Wave AI will not be able to interact with\n                            widgets or files.\n                        </div>\n                    }\n                    placement=\"bottom\"\n                >\n                    <div className=\"flex items-center gap-1 text-[10px] text-yellow-600 mt-1 ml-1 cursor-default\">\n                        <i className=\"fa fa-triangle-exclamation\"></i>\n                        <span>No Tools Support</span>\n                    </div>\n                </Tooltip>\n            )}\n\n            {isOpen && (\n                <>\n                    <div className=\"fixed inset-0 z-40\" onClick={() => setIsOpen(false)} />\n                    <div className=\"absolute top-full left-0 mt-1 bg-zinc-800 border border-zinc-600 rounded shadow-lg z-50 min-w-[280px]\">\n                        {sections.map((section, sectionIndex) => {\n                            const isFirstSection = sectionIndex === 0;\n                            const isLastSection = sectionIndex === sections.length - 1;\n\n                            return (\n                                <div key={section.sectionName}>\n                                    {!isFirstSection && <div className=\"border-t border-gray-600 my-2\" />}\n                                    {showSectionHeaders && (\n                                        <>\n                                            <div\n                                                className={cn(\n                                                    \"pb-1 text-center text-[10px] text-gray-400 uppercase tracking-wide\",\n                                                    isFirstSection ? \"pt-2\" : \"pt-0\"\n                                                )}\n                                            >\n                                                {section.sectionName}\n                                            </div>\n                                            {section.isIncompatible && (\n                                                <div className=\"text-center text-[11px] text-red-300 pb-1\">\n                                                    (Start a New Chat to Switch)\n                                                </div>\n                                            )}\n                                            {section.noTelemetry && (\n                                                <button\n                                                    onClick={handleEnableTelemetry}\n                                                    className=\"text-center text-[11px] text-green-300 hover:text-green-200 pb-1 cursor-pointer transition-colors w-full\"\n                                                >\n                                                    (enable telemetry to unlock Wave AI Cloud)\n                                                </button>\n                                            )}\n                                        </>\n                                    )}\n                                    {section.configs.map((config, index) => {\n                                        const isFirst = index === 0 && isFirstSection && !showSectionHeaders;\n                                        const isLast = index === section.configs.length - 1 && isLastSection;\n                                        const isPremiumDisabled = !hasPremium && config[\"waveai:premium\"];\n                                        const isIncompatibleDisabled = section.isIncompatible || false;\n                                        const isTelemetryDisabled = section.noTelemetry || false;\n                                        const isDisabled =\n                                            isPremiumDisabled || isIncompatibleDisabled || isTelemetryDisabled;\n                                        const isSelected = currentMode === config.mode;\n                                        return (\n                                            <AIModeMenuItem\n                                                key={config.mode}\n                                                config={config}\n                                                isSelected={isSelected}\n                                                isDisabled={isDisabled}\n                                                isPremiumDisabled={isPremiumDisabled}\n                                                onClick={() => handleSelect(config.mode)}\n                                                isFirst={isFirst}\n                                                isLast={isLast}\n                                            />\n                                        );\n                                    })}\n                                </div>\n                            );\n                        })}\n                        <div className=\"border-t border-gray-600 my-1\" />\n                        <button\n                            onClick={handleNewChatClick}\n                            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\"\n                        >\n                            <i className={makeIconClass(\"plus\", false)}></i>\n                            <span className=\"text-sm\">New Chat</span>\n                        </button>\n                        <button\n                            onClick={handleConfigureClick}\n                            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\"\n                        >\n                            <i className={makeIconClass(\"gear\", false)}></i>\n                            <span className=\"text-sm\">Configure Modes</span>\n                        </button>\n                    </div>\n                </>\n            )}\n        </div>\n    );\n});\n\nAIModeDropdown.displayName = \"AIModeDropdown\";\n"
  },
  {
    "path": "frontend/app/aipanel/aipanel-contextmenu.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { waveAIHasSelection } from \"@/app/aipanel/waveai-focus-utils\";\nimport { ContextMenuModel } from \"@/app/store/contextmenu\";\nimport { isDev } from \"@/app/store/global\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { WaveAIModel } from \"./waveai-model\";\n\nexport async function handleWaveAIContextMenu(e: React.MouseEvent, showCopy: boolean): Promise<void> {\n    e.preventDefault();\n    e.stopPropagation();\n\n    const model = WaveAIModel.getInstance();\n    const menu: ContextMenuItem[] = [];\n\n    if (showCopy) {\n        const hasSelection = waveAIHasSelection();\n        if (hasSelection) {\n            menu.push({\n                role: \"copy\",\n            });\n            menu.push({ type: \"separator\" });\n        }\n    }\n\n    menu.push({\n        label: \"New Chat\",\n        click: () => {\n            model.clearChat();\n        },\n    });\n\n    menu.push({ type: \"separator\" });\n\n    const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, {\n        oref: model.orefContext,\n    });\n\n    const defaultTokens = model.inBuilder ? 24576 : 4096;\n    const currentMaxTokens = rtInfo?.[\"waveai:maxoutputtokens\"] ?? defaultTokens;\n\n    const maxTokensSubmenu: ContextMenuItem[] = [];\n\n    if (model.inBuilder) {\n        maxTokensSubmenu.push(\n            {\n                label: \"24k\",\n                type: \"checkbox\",\n                checked: currentMaxTokens === 24576,\n                click: () => {\n                    RpcApi.SetRTInfoCommand(TabRpcClient, {\n                        oref: model.orefContext,\n                        data: { \"waveai:maxoutputtokens\": 24576 },\n                    });\n                },\n            },\n            {\n                label: \"64k (Pro)\",\n                type: \"checkbox\",\n                checked: currentMaxTokens === 65536,\n                click: () => {\n                    RpcApi.SetRTInfoCommand(TabRpcClient, {\n                        oref: model.orefContext,\n                        data: { \"waveai:maxoutputtokens\": 65536 },\n                    });\n                },\n            }\n        );\n    } else {\n        if (isDev()) {\n            maxTokensSubmenu.push({\n                label: \"1k (Dev Testing)\",\n                type: \"checkbox\",\n                checked: currentMaxTokens === 1024,\n                click: () => {\n                    RpcApi.SetRTInfoCommand(TabRpcClient, {\n                        oref: model.orefContext,\n                        data: { \"waveai:maxoutputtokens\": 1024 },\n                    });\n                },\n            });\n        }\n        maxTokensSubmenu.push(\n            {\n                label: \"4k\",\n                type: \"checkbox\",\n                checked: currentMaxTokens === 4096,\n                click: () => {\n                    RpcApi.SetRTInfoCommand(TabRpcClient, {\n                        oref: model.orefContext,\n                        data: { \"waveai:maxoutputtokens\": 4096 },\n                    });\n                },\n            },\n            {\n                label: \"16k (Pro)\",\n                type: \"checkbox\",\n                checked: currentMaxTokens === 16384,\n                click: () => {\n                    RpcApi.SetRTInfoCommand(TabRpcClient, {\n                        oref: model.orefContext,\n                        data: { \"waveai:maxoutputtokens\": 16384 },\n                    });\n                },\n            },\n            {\n                label: \"64k (Pro)\",\n                type: \"checkbox\",\n                checked: currentMaxTokens === 65536,\n                click: () => {\n                    RpcApi.SetRTInfoCommand(TabRpcClient, {\n                        oref: model.orefContext,\n                        data: { \"waveai:maxoutputtokens\": 65536 },\n                    });\n                },\n            }\n        );\n    }\n\n    menu.push({\n        label: \"Max Output Tokens\",\n        submenu: maxTokensSubmenu,\n    });\n\n    menu.push({ type: \"separator\" });\n\n    menu.push({\n        label: \"Configure Modes\",\n        click: () => {\n            RpcApi.RecordTEventCommand(\n                TabRpcClient,\n                {\n                    event: \"action:other\",\n                    props: {\n                        \"action:type\": \"waveai:configuremodes:contextmenu\",\n                    },\n                },\n                { noresponse: true }\n            );\n            model.openWaveAIConfig();\n        },\n    });\n\n    if (model.canCloseWaveAIPanel()) {\n        menu.push({ type: \"separator\" });\n\n        menu.push({\n            label: \"Hide Wave AI\",\n            click: () => {\n                model.closeWaveAIPanel();\n            },\n        });\n    }\n\n    ContextMenuModel.getInstance().showContextMenu(menu, e);\n}\n"
  },
  {
    "path": "frontend/app/aipanel/aipanel.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { handleWaveAIContextMenu } from \"@/app/aipanel/aipanel-contextmenu\";\nimport { waveAIHasSelection } from \"@/app/aipanel/waveai-focus-utils\";\nimport { ErrorBoundary } from \"@/app/element/errorboundary\";\nimport { atoms, getSettingsKeyAtom } from \"@/app/store/global\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { useTabModelMaybe } from \"@/app/store/tab-model\";\nimport { isBuilderWindow } from \"@/app/store/windowtype\";\nimport { checkKeyPressed, keydownWrapper } from \"@/util/keyutil\";\nimport { isMacOS, isWindows } from \"@/util/platformutil\";\nimport { cn } from \"@/util/util\";\nimport { useChat } from \"@ai-sdk/react\";\nimport { DefaultChatTransport } from \"ai\";\nimport * as jotai from \"jotai\";\nimport { memo, useCallback, useEffect, useRef, useState } from \"react\";\nimport { useDrop } from \"react-dnd\";\nimport { formatFileSizeError, isAcceptableFile, validateFileSize } from \"./ai-utils\";\nimport { AIDroppedFiles } from \"./aidroppedfiles\";\nimport { AIModeDropdown } from \"./aimode\";\nimport { AIPanelHeader } from \"./aipanelheader\";\nimport { AIPanelInput } from \"./aipanelinput\";\nimport { AIPanelMessages } from \"./aipanelmessages\";\nimport { AIRateLimitStrip } from \"./airatelimitstrip\";\nimport { WaveUIMessage } from \"./aitypes\";\nimport { BYOKAnnouncement } from \"./byokannouncement\";\nimport { TelemetryRequiredMessage } from \"./telemetryrequired\";\nimport { WaveAIModel } from \"./waveai-model\";\n\nconst AIBlockMask = memo(() => {\n    return (\n        <div\n            key=\"block-mask\"\n            className=\"absolute top-0 left-0 right-0 bottom-0 border-1 border-transparent pointer-events-auto select-none p-0.5\"\n            style={{\n                borderRadius: \"var(--block-border-radius)\",\n                zIndex: \"var(--zindex-block-mask-inner)\",\n            }}\n        >\n            <div\n                className=\"w-full mt-[44px] h-[calc(100%-44px)] flex items-center justify-center\"\n                style={{\n                    backgroundColor: \"rgb(from var(--block-bg-color) r g b / 50%)\",\n                }}\n            >\n                <div className=\"font-bold opacity-70 mt-[-25%] text-[60px]\">0</div>\n            </div>\n        </div>\n    );\n});\n\nAIBlockMask.displayName = \"AIBlockMask\";\n\nconst AIDragOverlay = memo(() => {\n    return (\n        <div\n            key=\"drag-overlay\"\n            className=\"absolute inset-0 bg-accent/20 border-2 border-dashed border-accent rounded-lg flex items-center justify-center z-10 p-4\"\n        >\n            <div className=\"text-accent text-center\">\n                <i className=\"fa fa-upload text-3xl mb-2\"></i>\n                <div className=\"text-lg font-semibold\">Drop files here</div>\n                <div className=\"text-sm\">Images, PDFs, and text/code files supported</div>\n            </div>\n        </div>\n    );\n});\n\nAIDragOverlay.displayName = \"AIDragOverlay\";\n\nconst KeyCap = memo(({ children, className }: { children: React.ReactNode; className?: string }) => {\n    return (\n        <kbd\n            className={cn(\n                \"px-1.5 py-0.5 text-xs bg-zinc-700 border border-zinc-600 rounded-sm shadow-sm font-mono\",\n                className\n            )}\n        >\n            {children}\n        </kbd>\n    );\n});\n\nKeyCap.displayName = \"KeyCap\";\n\nconst AIWelcomeMessage = memo(() => {\n    const modKey = isMacOS() ? \"⌘\" : \"Alt\";\n    const aiModeConfigs = jotai.useAtomValue(atoms.waveaiModeConfigAtom);\n    const hasCustomModes = Object.keys(aiModeConfigs).some((key) => !key.startsWith(\"waveai@\"));\n    return (\n        <div className=\"text-secondary py-8\">\n            <div className=\"text-center\">\n                <i className=\"fa fa-sparkles text-4xl text-accent mb-2 block\"></i>\n                <p className=\"text-lg font-bold text-primary\">Welcome to Wave AI</p>\n            </div>\n            <div className=\"mt-4 text-left max-w-md mx-auto\">\n                <p className=\"text-sm mb-6\">\n                    Wave AI is your terminal assistant with context. I can read your terminal output, analyze widgets,\n                    access files, and help you solve problems faster.\n                </p>\n                <div className=\"bg-accent/10 border border-accent/30 rounded-lg p-4\">\n                    <div className=\"text-sm font-semibold mb-3 text-accent\">Getting Started:</div>\n                    <div className=\"space-y-3 text-sm\">\n                        <div className=\"flex items-start gap-3\">\n                            <div className=\"w-4 text-center flex-shrink-0\">\n                                <i className=\"fa-solid fa-plug text-accent\"></i>\n                            </div>\n                            <div>\n                                <span className=\"font-bold\">Widget Context</span>\n                                <div className=\"\">When ON, I can read your terminal and analyze widgets.</div>\n                                <div className=\"\">When OFF, I'm sandboxed with no system access.</div>\n                            </div>\n                        </div>\n                        <div className=\"flex items-start gap-3\">\n                            <div className=\"w-4 text-center flex-shrink-0\">\n                                <i className=\"fa-solid fa-file-import text-accent\"></i>\n                            </div>\n                            <div>Drag & drop files or images for analysis</div>\n                        </div>\n                        <div className=\"flex items-start gap-3\">\n                            <div className=\"w-4 text-center flex-shrink-0\">\n                                <i className=\"fa-solid fa-keyboard text-accent\"></i>\n                            </div>\n                            <div className=\"space-y-1\">\n                                <div>\n                                    <KeyCap>{modKey}</KeyCap>\n                                    <KeyCap className=\"ml-1\">K</KeyCap>\n                                    <span className=\"ml-1.5\">to start a new chat</span>\n                                </div>\n                                <div>\n                                    <KeyCap>{modKey}</KeyCap>\n                                    <KeyCap className=\"ml-1\">Shift</KeyCap>\n                                    <KeyCap className=\"ml-1\">A</KeyCap>\n                                    <span className=\"ml-1.5\">to toggle panel</span>\n                                </div>\n                                <div>\n                                    {isWindows() ? (\n                                        <>\n                                            <KeyCap>Alt</KeyCap>\n                                            <KeyCap className=\"ml-1\">0</KeyCap>\n                                            <span className=\"ml-1.5\">to focus</span>\n                                        </>\n                                    ) : (\n                                        <>\n                                            <KeyCap>Ctrl</KeyCap>\n                                            <KeyCap className=\"ml-1\">Shift</KeyCap>\n                                            <KeyCap className=\"ml-1\">0</KeyCap>\n                                            <span className=\"ml-1.5\">to focus</span>\n                                        </>\n                                    )}\n                                </div>\n                            </div>\n                        </div>\n                        <div className=\"flex items-start gap-3\">\n                            <div className=\"w-4 text-center flex-shrink-0\">\n                                <i className=\"fa-brands fa-discord text-accent\"></i>\n                            </div>\n                            <div>\n                                Questions or feedback?{\" \"}\n                                <a\n                                    target=\"_blank\"\n                                    href=\"https://discord.gg/XfvZ334gwU\"\n                                    rel=\"noopener\"\n                                    className=\"text-accent hover:underline cursor-pointer\"\n                                >\n                                    Join our Discord\n                                </a>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n                {!hasCustomModes && <BYOKAnnouncement />}\n                <div className=\"mt-4 text-center text-[12px] text-muted\">\n                    BETA: Free to use. Daily limits keep our costs in check.\n                </div>\n            </div>\n        </div>\n    );\n});\n\nAIWelcomeMessage.displayName = \"AIWelcomeMessage\";\n\nconst AIBuilderWelcomeMessage = memo(() => {\n    return (\n        <div className=\"text-secondary py-8\">\n            <div className=\"text-center\">\n                <i className=\"fa fa-sparkles text-4xl text-accent mb-4 block\"></i>\n                <p className=\"text-lg font-bold text-primary\">WaveApp Builder</p>\n            </div>\n            <div className=\"mt-4 text-left max-w-md mx-auto\">\n                <p className=\"text-sm mb-6\">\n                    The WaveApp builder helps create wave widgets that integrate seamlessly into Wave Terminal.\n                </p>\n            </div>\n        </div>\n    );\n});\n\nAIBuilderWelcomeMessage.displayName = \"AIBuilderWelcomeMessage\";\n\nconst AIErrorMessage = memo(() => {\n    const model = WaveAIModel.getInstance();\n    const errorMessage = jotai.useAtomValue(model.errorMessage);\n\n    if (!errorMessage) {\n        return null;\n    }\n\n    return (\n        <div className=\"px-4 py-2 text-red-400 bg-red-900/20 border-l-4 border-red-500 mx-2 mb-2 relative\">\n            <button\n                onClick={() => model.clearError()}\n                className=\"absolute top-2 right-2 text-red-400 hover:text-red-300 cursor-pointer z-10\"\n                aria-label=\"Close error\"\n            >\n                <i className=\"fa fa-times text-sm\"></i>\n            </button>\n            <div className=\"text-sm pr-6 max-h-[100px] overflow-y-auto\">\n                {errorMessage}\n                <button\n                    onClick={() => model.clearChat()}\n                    className=\"ml-2 text-xs text-red-300 hover:text-red-200 cursor-pointer underline\"\n                >\n                    New Chat\n                </button>\n            </div>\n        </div>\n    );\n});\n\nAIErrorMessage.displayName = \"AIErrorMessage\";\n\nconst ConfigChangeModeFixer = memo(() => {\n    const model = WaveAIModel.getInstance();\n    const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom(\"telemetry:enabled\")) ?? false;\n    const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs);\n\n    useEffect(() => {\n        model.fixModeAfterConfigChange();\n    }, [telemetryEnabled, aiModeConfigs, model]);\n\n    return null;\n});\n\nConfigChangeModeFixer.displayName = \"ConfigChangeModeFixer\";\n\ntype AIPanelComponentInnerProps = {\n    roundTopLeft: boolean;\n};\n\nconst AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps) => {\n    const [isDragOver, setIsDragOver] = useState(false);\n    const [isReactDndDragOver, setIsReactDndDragOver] = useState(false);\n    const [initialLoadDone, setInitialLoadDone] = useState(false);\n    const model = WaveAIModel.getInstance();\n    const containerRef = useRef<HTMLDivElement>(null);\n    const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom);\n    const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom(\"app:showoverlayblocknums\")) ?? true;\n    const isFocused = jotai.useAtomValue(model.isWaveAIFocusedAtom);\n    const focusFollowsCursorMode = jotai.useAtomValue(getSettingsKeyAtom(\"app:focusfollowscursor\")) ?? \"off\";\n    const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom(\"telemetry:enabled\")) ?? false;\n    const isPanelVisible = jotai.useAtomValue(model.getPanelVisibleAtom());\n    const tabModel = useTabModelMaybe();\n    const defaultMode = jotai.useAtomValue(getSettingsKeyAtom(\"waveai:defaultmode\")) ?? \"waveai@balanced\";\n    const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs);\n\n    const hasCustomModes = Object.keys(aiModeConfigs).some((key) => !key.startsWith(\"waveai@\"));\n    const isUsingCustomMode = !defaultMode.startsWith(\"waveai@\");\n    const allowAccess = telemetryEnabled || (hasCustomModes && isUsingCustomMode);\n\n    const { messages, sendMessage, status, setMessages, error, stop } = useChat<WaveUIMessage>({\n        transport: new DefaultChatTransport({\n            api: model.getUseChatEndpointUrl(),\n            prepareSendMessagesRequest: (_opts) => {\n                const msg = model.getAndClearMessage();\n                const body: any = {\n                    msg,\n                    chatid: globalStore.get(model.chatId),\n                    widgetaccess: globalStore.get(model.widgetAccessAtom),\n                    aimode: globalStore.get(model.currentAIMode),\n                };\n                if (isBuilderWindow()) {\n                    body.builderid = globalStore.get(atoms.builderId);\n                    body.builderappid = globalStore.get(atoms.builderAppId);\n                } else {\n                    body.tabid = tabModel.tabId;\n                }\n                return { body };\n            },\n        }),\n        onError: (error) => {\n            console.error(\"AI Chat error:\", error);\n            model.setError(error.message || \"An error occurred\");\n        },\n    });\n\n    model.registerUseChatData(sendMessage, setMessages, status, stop);\n\n    // console.log(\"AICHAT messages\", messages);\n    (window as any).aichatmessages = messages;\n    (window as any).aichatstatus = status;\n\n    const handleKeyDown = (waveEvent: WaveKeyboardEvent): boolean => {\n        if (checkKeyPressed(waveEvent, \"Cmd:k\")) {\n            model.clearChat();\n            return true;\n        }\n        return false;\n    };\n\n    useEffect(() => {\n        globalStore.set(model.isAIStreaming, status === \"streaming\" || status === \"submitted\");\n    }, [status]);\n\n    useEffect(() => {\n        const keyHandler = keydownWrapper(handleKeyDown);\n        document.addEventListener(\"keydown\", keyHandler);\n        return () => {\n            document.removeEventListener(\"keydown\", keyHandler);\n        };\n    }, []);\n\n    useEffect(() => {\n        const loadChat = async () => {\n            await model.uiLoadInitialChat();\n            setInitialLoadDone(true);\n        };\n        loadChat();\n    }, [model]);\n\n    useEffect(() => {\n        const updateWidth = () => {\n            if (containerRef.current) {\n                globalStore.set(model.containerWidth, containerRef.current.offsetWidth);\n            }\n        };\n\n        updateWidth();\n\n        const resizeObserver = new ResizeObserver(updateWidth);\n        if (containerRef.current) {\n            resizeObserver.observe(containerRef.current);\n        }\n\n        return () => {\n            resizeObserver.disconnect();\n        };\n    }, [model]);\n\n    useEffect(() => {\n        model.ensureRateLimitSet();\n    }, [model]);\n\n    const handleSubmit = async (e: React.FormEvent) => {\n        e.preventDefault();\n        await model.handleSubmit();\n        setTimeout(() => {\n            model.focusInput();\n        }, 100);\n    };\n\n    const hasFilesDragged = (dataTransfer: DataTransfer): boolean => {\n        // Check if the drag operation contains files by looking at the types\n        return dataTransfer.types.includes(\"Files\");\n    };\n\n    const handleDragOver = (e: React.DragEvent) => {\n        if (!allowAccess) {\n            return;\n        }\n\n        const hasFiles = hasFilesDragged(e.dataTransfer);\n\n        // Only handle native file drags here, let react-dnd handle FILE_ITEM drags\n        if (!hasFiles) {\n            return;\n        }\n\n        e.preventDefault();\n        e.stopPropagation();\n\n        if (!isDragOver) {\n            setIsDragOver(true);\n        }\n    };\n\n    const handleDragEnter = (e: React.DragEvent) => {\n        if (!allowAccess) {\n            return;\n        }\n\n        const hasFiles = hasFilesDragged(e.dataTransfer);\n\n        // Only handle native file drags here, let react-dnd handle FILE_ITEM drags\n        if (!hasFiles) {\n            return;\n        }\n\n        e.preventDefault();\n        e.stopPropagation();\n\n        setIsDragOver(true);\n    };\n\n    const handleDragLeave = (e: React.DragEvent) => {\n        if (!allowAccess) {\n            return;\n        }\n\n        const hasFiles = hasFilesDragged(e.dataTransfer);\n\n        // Only handle native file drags here, let react-dnd handle FILE_ITEM drags\n        if (!hasFiles) {\n            return;\n        }\n\n        e.preventDefault();\n        e.stopPropagation();\n\n        // Only set drag over to false if we're actually leaving the drop zone\n        const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();\n        const x = e.clientX;\n        const y = e.clientY;\n\n        if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {\n            setIsDragOver(false);\n        }\n    };\n\n    const handleDrop = async (e: React.DragEvent) => {\n        if (!allowAccess) {\n            e.preventDefault();\n            e.stopPropagation();\n            setIsDragOver(false);\n            return;\n        }\n\n        // Check if this is a FILE_ITEM drag from react-dnd\n        // If so, let react-dnd handle it instead\n        if (!e.dataTransfer.files.length) {\n            return; // Let react-dnd handle FILE_ITEM drags\n        }\n\n        e.preventDefault();\n        e.stopPropagation();\n        setIsDragOver(false);\n\n        const files = Array.from(e.dataTransfer.files);\n        const acceptableFiles = files.filter(isAcceptableFile);\n\n        for (const file of acceptableFiles) {\n            const sizeError = validateFileSize(file);\n            if (sizeError) {\n                model.setError(formatFileSizeError(sizeError));\n                return;\n            }\n            await model.addFile(file);\n        }\n\n        if (acceptableFiles.length < files.length) {\n            const rejectedCount = files.length - acceptableFiles.length;\n            const rejectedFiles = files.filter((f) => !isAcceptableFile(f));\n            const fileNames = rejectedFiles.map((f) => f.name).join(\", \");\n            model.setError(\n                `${rejectedCount} file${rejectedCount > 1 ? \"s\" : \"\"} rejected (unsupported type): ${fileNames}. Supported: images, PDFs, and text/code files.`\n            );\n        }\n    };\n\n    const handleFileItemDrop = useCallback(\n        (draggedFile: DraggedFile) => {\n            if (!allowAccess) {\n                return;\n            }\n            model.addFileFromRemoteUri(draggedFile);\n        },\n        [model, allowAccess]\n    );\n\n    const [{ isOver, canDrop }, drop] = useDrop(\n        () => ({\n            accept: \"FILE_ITEM\",\n            drop: handleFileItemDrop,\n            collect: (monitor) => ({\n                isOver: monitor.isOver(),\n                canDrop: monitor.canDrop(),\n            }),\n        }),\n        [handleFileItemDrop]\n    );\n\n    // Update drag over state for FILE_ITEM drags\n    useEffect(() => {\n        if (isOver && canDrop) {\n            setIsReactDndDragOver(true);\n        } else {\n            setIsReactDndDragOver(false);\n        }\n    }, [isOver, canDrop]);\n\n    // Attach the drop ref to the container\n    useEffect(() => {\n        if (containerRef.current) {\n            drop(containerRef.current);\n        }\n    }, [drop]);\n\n    const handleFocusCapture = useCallback(\n        (_event: React.FocusEvent) => {\n            // console.log(\"Wave AI focus capture\", getElemAsStr(event.target));\n            model.requestWaveAIFocus();\n        },\n        [model]\n    );\n\n    const handlePointerEnter = useCallback(\n        (event: React.PointerEvent<HTMLDivElement>) => {\n            if (focusFollowsCursorMode !== \"on\") return;\n            if (event.pointerType === \"touch\" || event.buttons > 0) return;\n            if (isFocused) return;\n            model.focusInput();\n        },\n        [focusFollowsCursorMode, isFocused, model]\n    );\n\n    const handleClick = (e: React.MouseEvent) => {\n        const target = e.target as HTMLElement;\n        const isInteractive = target.closest('button, a, input, textarea, select, [role=\"button\"], [tabindex]');\n\n        if (isInteractive) {\n            return;\n        }\n\n        const hasSelection = waveAIHasSelection();\n        if (hasSelection) {\n            model.requestWaveAIFocus();\n            return;\n        }\n\n        setTimeout(() => {\n            if (!waveAIHasSelection()) {\n                model.focusInput();\n            }\n        }, 0);\n    };\n\n    const showBlockMask = isLayoutMode && showOverlayBlockNums;\n\n    return (\n        <div\n            ref={containerRef}\n            data-waveai-panel=\"true\"\n            className={cn(\n                \"@container bg-zinc-900/70 flex flex-col relative\",\n                model.inBuilder ? \"mt-0 h-full\" : \"mt-1 h-[calc(100%-4px)]\",\n                (isDragOver || isReactDndDragOver) && \"bg-zinc-800 border-accent\",\n                isFocused ? \"border-2 border-accent\" : \"border-2 border-transparent\"\n            )}\n            style={{\n                borderTopLeftRadius: roundTopLeft ? 10 : 0,\n                borderTopRightRadius: model.inBuilder ? 0 : 10,\n                borderBottomRightRadius: model.inBuilder ? 0 : 10,\n                borderBottomLeftRadius: 10,\n            }}\n            onFocusCapture={handleFocusCapture}\n            onPointerEnter={handlePointerEnter}\n            onDragOver={handleDragOver}\n            onDragEnter={handleDragEnter}\n            onDragLeave={handleDragLeave}\n            onDrop={handleDrop}\n            onClick={handleClick}\n            inert={!isPanelVisible ? true : undefined}\n        >\n            <ConfigChangeModeFixer />\n            {(isDragOver || isReactDndDragOver) && allowAccess && <AIDragOverlay />}\n            {showBlockMask && <AIBlockMask />}\n            <AIPanelHeader />\n            <AIRateLimitStrip />\n\n            <div key=\"main-content\" className=\"flex-1 flex flex-col min-h-0\">\n                {!allowAccess ? (\n                    <TelemetryRequiredMessage />\n                ) : (\n                    <>\n                        {messages.length === 0 && initialLoadDone ? (\n                            <div\n                                className=\"flex-1 overflow-y-auto p-2 relative\"\n                                onContextMenu={(e) => handleWaveAIContextMenu(e, true)}\n                            >\n                                <div className=\"absolute top-2 left-2 z-10\">\n                                    <AIModeDropdown />\n                                </div>\n                                {model.inBuilder ? <AIBuilderWelcomeMessage /> : <AIWelcomeMessage />}\n                            </div>\n                        ) : (\n                            <AIPanelMessages\n                                messages={messages}\n                                status={status}\n                                onContextMenu={(e) => handleWaveAIContextMenu(e, true)}\n                            />\n                        )}\n                        <AIErrorMessage />\n                        <AIDroppedFiles model={model} />\n                        <AIPanelInput onSubmit={handleSubmit} status={status} model={model} />\n                    </>\n                )}\n            </div>\n        </div>\n    );\n});\n\nAIPanelComponentInner.displayName = \"AIPanelInner\";\n\ntype AIPanelComponentProps = {\n    roundTopLeft: boolean;\n};\n\nconst AIPanelComponent = ({ roundTopLeft }: AIPanelComponentProps) => {\n    return (\n        <ErrorBoundary>\n            <AIPanelComponentInner roundTopLeft={roundTopLeft} />\n        </ErrorBoundary>\n    );\n};\n\nAIPanelComponent.displayName = \"AIPanel\";\n\nexport { AIPanelComponent as AIPanel };\n"
  },
  {
    "path": "frontend/app/aipanel/aipanelheader.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { handleWaveAIContextMenu } from \"@/app/aipanel/aipanel-contextmenu\";\nimport { useAtomValue } from \"jotai\";\nimport { memo } from \"react\";\nimport { WaveAIModel } from \"./waveai-model\";\n\nexport const AIPanelHeader = memo(() => {\n    const model = WaveAIModel.getInstance();\n    const widgetAccess = useAtomValue(model.widgetAccessAtom);\n    const inBuilder = model.inBuilder;\n\n    const handleKebabClick = (e: React.MouseEvent) => {\n        handleWaveAIContextMenu(e, false);\n    };\n\n    const handleContextMenu = (e: React.MouseEvent) => {\n        handleWaveAIContextMenu(e, false);\n    };\n\n    return (\n        <div\n            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\"\n            onContextMenu={handleContextMenu}\n        >\n            <h2 className=\"text-white text-sm @xs:text-lg font-semibold flex items-center gap-2 flex-shrink-0 whitespace-nowrap\">\n                <i className=\"fa fa-sparkles text-accent\"></i>\n                Wave AI\n            </h2>\n\n            <div className=\"flex items-center flex-shrink-0 whitespace-nowrap\">\n                {!inBuilder && (\n                    <div className=\"flex items-center text-sm whitespace-nowrap\">\n                        <span className=\"text-gray-300 @xs:hidden mr-1 text-[12px]\">Context</span>\n                        <span className=\"text-gray-300 hidden @xs:inline mr-2 text-[12px]\">Widget Context</span>\n                        <button\n                            onClick={() => {\n                                model.setWidgetAccess(!widgetAccess);\n                                setTimeout(() => {\n                                    model.focusInput();\n                                }, 0);\n                            }}\n                            className={`relative inline-flex h-6 w-14 items-center rounded-full transition-colors cursor-pointer ${\n                                widgetAccess ? \"bg-accent-600\" : \"bg-zinc-600\"\n                            }`}\n                            title={`Widget Access ${widgetAccess ? \"ON\" : \"OFF\"}`}\n                        >\n                            <span\n                                className={`absolute inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${\n                                    widgetAccess ? \"translate-x-8\" : \"translate-x-1\"\n                                }`}\n                            />\n                            <span\n                                className={`relative z-10 text-xs text-white transition-all ${\n                                    widgetAccess ? \"ml-2.5 mr-6 text-left\" : \"ml-6 mr-1 text-right\"\n                                }`}\n                            >\n                                {widgetAccess ? \"ON\" : \"OFF\"}\n                            </span>\n                        </button>\n                    </div>\n                )}\n\n                <button\n                    onClick={handleKebabClick}\n                    className=\"text-gray-400 hover:text-white cursor-pointer transition-colors p-1 rounded flex-shrink-0 ml-2 focus:outline-none\"\n                    title=\"More options\"\n                >\n                    <i className=\"fa fa-ellipsis-vertical\"></i>\n                </button>\n            </div>\n        </div>\n    );\n});\n\nAIPanelHeader.displayName = \"AIPanelHeader\";\n"
  },
  {
    "path": "frontend/app/aipanel/aipanelinput.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { formatFileSizeError, isAcceptableFile, validateFileSize } from \"@/app/aipanel/ai-utils\";\nimport { waveAIHasFocusWithin } from \"@/app/aipanel/waveai-focus-utils\";\nimport { type WaveAIModel } from \"@/app/aipanel/waveai-model\";\nimport { Tooltip } from \"@/element/tooltip\";\nimport { cn } from \"@/util/util\";\nimport { useAtom, useAtomValue } from \"jotai\";\nimport { memo, useCallback, useEffect, useRef } from \"react\";\n\ninterface AIPanelInputProps {\n    onSubmit: (e: React.FormEvent) => void;\n    status: string;\n    model: WaveAIModel;\n}\n\nexport interface AIPanelInputRef {\n    focus: () => void;\n    resize: () => void;\n    scrollToBottom: () => void;\n}\n\nexport const AIPanelInput = memo(({ onSubmit, status, model }: AIPanelInputProps) => {\n    const [input, setInput] = useAtom(model.inputAtom);\n    const isFocused = useAtomValue(model.isWaveAIFocusedAtom);\n    const isChatEmpty = useAtomValue(model.isChatEmptyAtom);\n    const textareaRef = useRef<HTMLTextAreaElement>(null);\n    const fileInputRef = useRef<HTMLInputElement>(null);\n    const isPanelOpen = useAtomValue(model.getPanelVisibleAtom());\n\n    let placeholder: string;\n    if (!isChatEmpty) {\n        placeholder = \"Continue...\";\n    } else if (model.inBuilder) {\n        placeholder = \"What would you like to build...\";\n    } else {\n        placeholder = \"Ask Wave AI anything...\";\n    }\n\n    const resizeTextarea = useCallback(() => {\n        const textarea = textareaRef.current;\n        if (!textarea) return;\n\n        textarea.style.height = \"auto\";\n        const scrollHeight = textarea.scrollHeight;\n        const maxHeight = 7 * 24;\n        textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`;\n    }, []);\n\n    useEffect(() => {\n        const inputRefObject: React.RefObject<AIPanelInputRef> = {\n            current: {\n                focus: () => {\n                    textareaRef.current?.focus();\n                },\n                resize: resizeTextarea,\n                scrollToBottom: () => {\n                    const textarea = textareaRef.current;\n                    if (textarea) {\n                        textarea.scrollTop = textarea.scrollHeight;\n                    }\n                },\n            },\n        };\n        model.registerInputRef(inputRefObject);\n    }, [model, resizeTextarea]);\n\n    const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n        const isComposing = e.nativeEvent?.isComposing || e.keyCode == 229;\n        if (e.key === \"Enter\" && !e.shiftKey && !isComposing) {\n            e.preventDefault();\n            onSubmit(e as any);\n        }\n    };\n\n    const handleFocus = useCallback(() => {\n        model.requestWaveAIFocus();\n    }, [model]);\n\n    const handleBlur = useCallback(\n        (e: React.FocusEvent) => {\n            if (e.relatedTarget === null) {\n                return;\n            }\n\n            if (waveAIHasFocusWithin(e.relatedTarget)) {\n                return;\n            }\n\n            model.requestNodeFocus();\n        },\n        [model]\n    );\n\n    useEffect(() => {\n        resizeTextarea();\n    }, [input, resizeTextarea]);\n\n    useEffect(() => {\n        if (isPanelOpen) {\n            resizeTextarea();\n        }\n    }, [isPanelOpen, resizeTextarea]);\n\n    const handleUploadClick = () => {\n        fileInputRef.current?.click();\n    };\n\n    const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {\n        const files = Array.from(e.target.files || []);\n        const acceptableFiles = files.filter(isAcceptableFile);\n\n        for (const file of acceptableFiles) {\n            const sizeError = validateFileSize(file);\n            if (sizeError) {\n                model.setError(formatFileSizeError(sizeError));\n                if (e.target) {\n                    e.target.value = \"\";\n                }\n                return;\n            }\n            await model.addFile(file);\n        }\n\n        if (acceptableFiles.length < files.length) {\n            console.warn(`${files.length - acceptableFiles.length} files were rejected due to unsupported file types`);\n        }\n\n        if (e.target) {\n            e.target.value = \"\";\n        }\n    };\n\n    return (\n        <div className={cn(\"border-t\", isFocused ? \"border-accent/50\" : \"border-gray-600\")}>\n            <input\n                ref={fileInputRef}\n                type=\"file\"\n                multiple\n                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\"\n                onChange={handleFileChange}\n                className=\"hidden\"\n            />\n            <form onSubmit={onSubmit}>\n                <div className=\"relative\">\n                    <textarea\n                        ref={textareaRef}\n                        value={input}\n                        onChange={(e) => setInput(e.target.value)}\n                        onKeyDown={handleKeyDown}\n                        onFocus={handleFocus}\n                        onBlur={handleBlur}\n                        placeholder={placeholder}\n                        className={cn(\n                            \"w-full  text-white px-2 py-2 pr-5 focus:outline-none resize-none overflow-auto bg-zinc-800/50\"\n                        )}\n                        style={{ fontSize: \"13px\" }}\n                        rows={2}\n                    />\n                    <Tooltip content=\"Attach files\" placement=\"top\" divClassName=\"absolute bottom-6.5 right-1\">\n                        <button\n                            type=\"button\"\n                            onClick={handleUploadClick}\n                            className={cn(\n                                \"w-5 h-5 transition-colors flex items-center justify-center text-gray-400 hover:text-accent cursor-pointer\"\n                            )}\n                        >\n                            <i className=\"fa fa-paperclip text-sm\"></i>\n                        </button>\n                    </Tooltip>\n                    {status === \"streaming\" ? (\n                        <Tooltip content=\"Stop Response\" placement=\"top\" divClassName=\"absolute bottom-1.5 right-1\">\n                            <button\n                                type=\"button\"\n                                onClick={() => model.stopResponse()}\n                                className={cn(\n                                    \"w-5 h-5 transition-colors flex items-center justify-center\",\n                                    \"text-green-500 hover:text-green-400 cursor-pointer\"\n                                )}\n                            >\n                                <i className=\"fa fa-square text-sm\"></i>\n                            </button>\n                        </Tooltip>\n                    ) : (\n                        <Tooltip content=\"Send message (Enter)\" placement=\"top\" divClassName=\"absolute bottom-1.5 right-1\">\n                            <button\n                                type=\"submit\"\n                                disabled={status !== \"ready\" || !input.trim()}\n                                className={cn(\n                                    \"w-5 h-5 transition-colors flex items-center justify-center\",\n                                    status !== \"ready\" || !input.trim()\n                                        ? \"text-gray-400\"\n                                        : \"text-accent/80 hover:text-accent cursor-pointer\"\n                                )}\n                            >\n                                <i className=\"fa fa-paper-plane text-sm\"></i>\n                            </button>\n                        </Tooltip>\n                    )}\n                </div>\n            </form>\n        </div>\n    );\n});\n\nAIPanelInput.displayName = \"AIPanelInput\";\n"
  },
  {
    "path": "frontend/app/aipanel/aipanelmessages.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { useAtomValue } from \"jotai\";\nimport { memo, useEffect, useRef, useState } from \"react\";\nimport { AIMessage } from \"./aimessage\";\nimport { AIModeDropdown } from \"./aimode\";\nimport { type WaveUIMessage } from \"./aitypes\";\nimport { WaveAIModel } from \"./waveai-model\";\n\ninterface AIPanelMessagesProps {\n    messages: WaveUIMessage[];\n    status: string;\n    onContextMenu?: (e: React.MouseEvent) => void;\n}\n\nexport const AIPanelMessages = memo(({ messages, status, onContextMenu }: AIPanelMessagesProps) => {\n    const model = WaveAIModel.getInstance();\n    const isPanelOpen = useAtomValue(model.getPanelVisibleAtom());\n    const messagesEndRef = useRef<HTMLDivElement>(null);\n    const messagesContainerRef = useRef<HTMLDivElement>(null);\n    const prevStatusRef = useRef<string>(status);\n    const [shouldAutoScroll, setShouldAutoScroll] = useState(true);\n\n    const checkIfAtBottom = () => {\n        const container = messagesContainerRef.current;\n        if (!container) return true;\n\n        const threshold = 50;\n        const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight;\n        return scrollBottom <= threshold;\n    };\n\n    const handleScroll = () => {\n        const atBottom = checkIfAtBottom();\n        setShouldAutoScroll(atBottom);\n    };\n\n    const scrollToBottom = () => {\n        const container = messagesContainerRef.current;\n        if (container) {\n            container.scrollTop = container.scrollHeight;\n            container.scrollLeft = 0;\n            setShouldAutoScroll(true);\n        }\n    };\n\n    useEffect(() => {\n        const container = messagesContainerRef.current;\n        if (!container) return;\n\n        container.addEventListener(\"scroll\", handleScroll);\n        return () => container.removeEventListener(\"scroll\", handleScroll);\n    }, []);\n\n    useEffect(() => {\n        model.registerScrollToBottom(scrollToBottom);\n    }, [model]);\n\n    useEffect(() => {\n        if (shouldAutoScroll) {\n            scrollToBottom();\n        }\n    }, [messages, shouldAutoScroll]);\n\n    useEffect(() => {\n        if (isPanelOpen) {\n            scrollToBottom();\n        }\n    }, [isPanelOpen]);\n\n    useEffect(() => {\n        const wasStreaming = prevStatusRef.current === \"streaming\";\n        const isNowNotStreaming = status !== \"streaming\";\n\n        if (wasStreaming && isNowNotStreaming) {\n            requestAnimationFrame(() => {\n                scrollToBottom();\n            });\n        }\n\n        prevStatusRef.current = status;\n    }, [status]);\n\n    return (\n        <div ref={messagesContainerRef} className=\"flex-1 overflow-y-auto p-2 space-y-4\" onContextMenu={onContextMenu}>\n            <div className=\"mb-2\">\n                <AIModeDropdown compatibilityMode={true} />\n            </div>\n            {messages.map((message, index) => {\n                const isLastMessage = index === messages.length - 1;\n                const isStreaming = status === \"streaming\" && isLastMessage && message.role === \"assistant\";\n                return <AIMessage key={message.id} message={message} isStreaming={isStreaming} />;\n            })}\n\n            {status === \"streaming\" &&\n                (messages.length === 0 || messages[messages.length - 1].role !== \"assistant\") && (\n                    <AIMessage\n                        key=\"last-message\"\n                        message={{ role: \"assistant\", parts: [], id: \"last-message\" } as any}\n                        isStreaming={true}\n                    />\n                )}\n\n            <div ref={messagesEndRef} />\n        </div>\n    );\n});\n\nAIPanelMessages.displayName = \"AIPanelMessages\";\n"
  },
  {
    "path": "frontend/app/aipanel/airatelimitstrip.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { atoms } from \"@/app/store/global\";\nimport * as jotai from \"jotai\";\nimport { memo, useEffect, useState } from \"react\";\n\nconst GetMoreButton = memo(({ variant, showClose = true }: { variant: \"yellow\" | \"red\"; showClose?: boolean }) => {\n    const isYellow = variant === \"yellow\";\n    const bgColor = isYellow ? \"bg-yellow-900/30\" : \"bg-red-900/30\";\n    const hoverBg = isYellow ? \"hover:bg-yellow-700/60\" : \"hover:bg-red-700/60\";\n    const borderColor = isYellow ? \"border-yellow-700/50\" : \"border-red-700/50\";\n    const textColor = isYellow ? \"text-yellow-200\" : \"text-red-200\";\n    const iconColor = isYellow ? \"text-yellow-400\" : \"text-red-400\";\n    const iconHoverBg =\n        showClose && isYellow\n            ? \"hover:has-[.close:hover]:bg-yellow-900/30\"\n            : showClose\n              ? \"hover:has-[.close:hover]:bg-red-900/30\"\n              : \"\";\n\n    if (true as boolean) {\n        // disable now until we have modal\n        return null;\n    }\n\n    return (\n        <div className=\"pl-2 pb-1.5\">\n            <button\n                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`}\n            >\n                {showClose && (\n                    <i className={`close fa fa-xmark ${iconColor}/60 hover:${iconColor} transition-colors`}></i>\n                )}\n                <span>Get More</span>\n                <i className={`fa fa-arrow-right ${iconColor}`}></i>\n            </button>\n        </div>\n    );\n});\n\nGetMoreButton.displayName = \"GetMoreButton\";\n\nfunction formatTimeRemaining(expirationEpoch: number): string {\n    const now = Math.floor(Date.now() / 1000);\n    const secondsRemaining = expirationEpoch - now;\n\n    if (secondsRemaining <= 0) {\n        return \"soon\";\n    }\n\n    const hours = Math.floor(secondsRemaining / 3600);\n    const minutes = Math.floor((secondsRemaining % 3600) / 60);\n\n    if (hours > 0) {\n        return `${hours}h`;\n    }\n    return `${minutes}m`;\n}\n\nconst AIRateLimitStripComponent = memo(() => {\n    let rateLimitInfo = jotai.useAtomValue(atoms.waveAIRateLimitInfoAtom);\n    // rateLimitInfo = { req: 0, reqlimit: 200, preq: 0, preqlimit: 50, resetepoch: 1759374575 + 45 * 60 }; // testing\n    const [, forceUpdate] = useState({});\n\n    const shouldShow = rateLimitInfo && !rateLimitInfo.unknown && (rateLimitInfo.preq <= 5 || rateLimitInfo.req === 0);\n\n    useEffect(() => {\n        if (!shouldShow) {\n            return;\n        }\n\n        const interval = setInterval(() => {\n            forceUpdate({});\n        }, 60000);\n\n        return () => clearInterval(interval);\n    }, [shouldShow]);\n\n    if (!rateLimitInfo || rateLimitInfo.unknown || !shouldShow) {\n        return null;\n    }\n\n    const { req, reqlimit, preq, preqlimit, resetepoch } = rateLimitInfo;\n    const timeRemaining = formatTimeRemaining(resetepoch);\n    const totalLimit = preqlimit + reqlimit;\n\n    if (preq > 0 && preq <= 5) {\n        return (\n            <div>\n                <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\">\n                    <i className=\"fa fa-sparkles text-yellow-400\"></i>\n                    <span>\n                        {preqlimit - preq}/{preqlimit} Premium Used\n                    </span>\n                    <div className=\"flex-1\"></div>\n                    <span className=\"text-yellow-300/80\">Resets in {timeRemaining}</span>\n                </div>\n                <GetMoreButton variant=\"yellow\" />\n            </div>\n        );\n    }\n\n    if (preq === 0 && req > 0) {\n        return (\n            <div>\n                <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\">\n                    <i className=\"fa fa-check text-yellow-400\"></i>\n                    <span>\n                        {preqlimit}/{preqlimit} Premium\n                    </span>\n                    <span className=\"text-yellow-400\">•</span>\n                    <span className=\"font-medium\">Now on Basic</span>\n                    <div className=\"flex-1\"></div>\n                    <span className=\"text-yellow-300/80\">Resets in {timeRemaining}</span>\n                </div>\n                <GetMoreButton variant=\"yellow\" />\n            </div>\n        );\n    }\n\n    if (req === 0 && preq === 0) {\n        return (\n            <div>\n                <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\">\n                    <i className=\"fa fa-check text-red-400\"></i>\n                    <span>\n                        {totalLimit}/{totalLimit} Reqs\n                    </span>\n                    <span className=\"text-red-400\">•</span>\n                    <span className=\"font-medium\">Limit Reached</span>\n                    <div className=\"flex-1\"></div>\n                    <span className=\"text-red-300/80\">Resets in {timeRemaining}</span>\n                </div>\n                <GetMoreButton variant=\"red\" showClose={false} />\n            </div>\n        );\n    }\n\n    return null;\n});\n\nAIRateLimitStripComponent.displayName = \"AIRateLimitStrip\";\n\nexport { AIRateLimitStripComponent as AIRateLimitStrip };\n"
  },
  {
    "path": "frontend/app/aipanel/aitooluse.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { BlockModel } from \"@/app/block/block-model\";\nimport { Modal } from \"@/app/modals/modal\";\nimport { recordTEvent } from \"@/app/store/global\";\nimport { cn, fireAndForget } from \"@/util/util\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useEffect, useRef, useState } from \"react\";\nimport { WaveUIMessagePart } from \"./aitypes\";\nimport { RestoreBackupModal } from \"./restorebackupmodal\";\nimport { WaveAIModel } from \"./waveai-model\";\n\n// matches pkg/filebackup/filebackup.go\nconst BackupRetentionDays = 5;\n\ninterface ToolDescLineProps {\n    text: string;\n}\n\nconst ToolDescLine = memo(({ text }: ToolDescLineProps) => {\n    let displayText = text;\n    if (displayText.startsWith(\"* \")) {\n        displayText = \"• \" + displayText.slice(2);\n    }\n\n    const parts: React.ReactNode[] = [];\n    let lastIndex = 0;\n    const regex = /(?<!\\w)([+-])(\\d+)(?!\\w)/g;\n    let match;\n\n    while ((match = regex.exec(displayText)) !== null) {\n        if (match.index > lastIndex) {\n            parts.push(displayText.slice(lastIndex, match.index));\n        }\n\n        const sign = match[1];\n        const number = match[2];\n        const colorClass = sign === \"+\" ? \"text-green-600\" : \"text-red-600\";\n        parts.push(\n            <span key={match.index} className={colorClass}>\n                {sign}\n                {number}\n            </span>\n        );\n\n        lastIndex = match.index + match[0].length;\n    }\n\n    if (lastIndex < displayText.length) {\n        parts.push(displayText.slice(lastIndex));\n    }\n\n    return <div>{parts.length > 0 ? parts : displayText}</div>;\n});\n\nToolDescLine.displayName = \"ToolDescLine\";\n\ninterface ToolDescProps {\n    text: string | string[];\n    className?: string;\n}\n\nconst ToolDesc = memo(({ text, className }: ToolDescProps) => {\n    const lines = Array.isArray(text) ? text : text.split(\"\\n\");\n\n    if (lines.length === 0) return null;\n\n    return (\n        <div className={className}>\n            {lines.map((line, idx) => (\n                <ToolDescLine key={idx} text={line} />\n            ))}\n        </div>\n    );\n});\n\nToolDesc.displayName = \"ToolDesc\";\n\nfunction getEffectiveApprovalStatus(baseApproval: string, isStreaming: boolean): string {\n    return !isStreaming && baseApproval === \"needs-approval\" ? \"timeout\" : baseApproval;\n}\n\ninterface AIToolApprovalButtonsProps {\n    count: number;\n    onApprove: () => void;\n    onDeny: () => void;\n}\n\nconst AIToolApprovalButtons = memo(({ count, onApprove, onDeny }: AIToolApprovalButtonsProps) => {\n    const approveText = count > 1 ? `Approve All (${count})` : \"Approve\";\n    const denyText = count > 1 ? \"Deny All\" : \"Deny\";\n\n    return (\n        <div className=\"mt-2 flex gap-2\">\n            <button\n                onClick={onApprove}\n                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\"\n            >\n                {approveText}\n            </button>\n            <button\n                onClick={onDeny}\n                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\"\n            >\n                {denyText}\n            </button>\n        </div>\n    );\n});\n\nAIToolApprovalButtons.displayName = \"AIToolApprovalButtons\";\n\ninterface AIToolUseBatchItemProps {\n    part: WaveUIMessagePart & { type: \"data-tooluse\" };\n    effectiveApproval: string;\n}\n\nconst AIToolUseBatchItem = memo(({ part, effectiveApproval }: AIToolUseBatchItemProps) => {\n    const statusIcon = part.data.status === \"completed\" ? \"✓\" : part.data.status === \"error\" ? \"✗\" : \"•\";\n    const statusColor =\n        part.data.status === \"completed\"\n            ? \"text-success\"\n            : part.data.status === \"error\"\n              ? \"text-error\"\n              : \"text-gray-400\";\n    const effectiveErrorMessage = part.data.errormessage || (effectiveApproval === \"timeout\" ? \"Not approved\" : null);\n\n    return (\n        <div className=\"text-sm pl-2 flex items-start gap-1.5\">\n            <span className={cn(\"font-bold flex-shrink-0\", statusColor)}>{statusIcon}</span>\n            <div className=\"flex-1\">\n                <span className=\"text-gray-400\">{part.data.tooldesc}</span>\n                {effectiveErrorMessage && <div className=\"text-red-300 mt-0.5\">{effectiveErrorMessage}</div>}\n            </div>\n        </div>\n    );\n});\n\nAIToolUseBatchItem.displayName = \"AIToolUseBatchItem\";\n\ninterface AIToolUseBatchProps {\n    parts: Array<WaveUIMessagePart & { type: \"data-tooluse\" }>;\n    isStreaming: boolean;\n}\n\nconst AIToolUseBatch = memo(({ parts, isStreaming }: AIToolUseBatchProps) => {\n    const [userApprovalOverride, setUserApprovalOverride] = useState<string | null>(null);\n\n    const firstTool = parts[0].data;\n    const baseApproval = userApprovalOverride || firstTool.approval;\n    const effectiveApproval = getEffectiveApprovalStatus(baseApproval, isStreaming);\n\n    const handleApprove = () => {\n        setUserApprovalOverride(\"user-approved\");\n        parts.forEach((part) => {\n            WaveAIModel.getInstance().toolUseSendApproval(part.data.toolcallid, \"user-approved\");\n        });\n    };\n\n    const handleDeny = () => {\n        setUserApprovalOverride(\"user-denied\");\n        parts.forEach((part) => {\n            WaveAIModel.getInstance().toolUseSendApproval(part.data.toolcallid, \"user-denied\");\n        });\n    };\n\n    return (\n        <div className=\"flex items-start gap-2 p-2 rounded bg-zinc-800/60 border border-zinc-700\">\n            <div className=\"flex-1\">\n                <div className=\"font-semibold\">Reading Files</div>\n                <div className=\"mt-1 space-y-0.5\">\n                    {parts.map((part, idx) => (\n                        <AIToolUseBatchItem key={idx} part={part} effectiveApproval={effectiveApproval} />\n                    ))}\n                </div>\n                {effectiveApproval === \"needs-approval\" && (\n                    <AIToolApprovalButtons count={parts.length} onApprove={handleApprove} onDeny={handleDeny} />\n                )}\n            </div>\n        </div>\n    );\n});\n\nAIToolUseBatch.displayName = \"AIToolUseBatch\";\n\ninterface AIToolUseProps {\n    part: WaveUIMessagePart & { type: \"data-tooluse\" };\n    isStreaming: boolean;\n}\n\nconst AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => {\n    const toolData = part.data;\n    const [userApprovalOverride, setUserApprovalOverride] = useState<string | null>(null);\n    const model = WaveAIModel.getInstance();\n    const restoreModalToolCallId = useAtomValue(model.restoreBackupModalToolCallId);\n    const showRestoreModal = restoreModalToolCallId === toolData.toolcallid;\n    const highlightTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n    const highlightedBlockIdRef = useRef<string | null>(null);\n\n    const statusIcon = toolData.status === \"completed\" ? \"✓\" : toolData.status === \"error\" ? \"✗\" : \"•\";\n    const statusColor =\n        toolData.status === \"completed\" ? \"text-success\" : toolData.status === \"error\" ? \"text-error\" : \"text-gray-400\";\n\n    const baseApproval = userApprovalOverride || toolData.approval;\n    const effectiveApproval = getEffectiveApprovalStatus(baseApproval, isStreaming);\n\n    const isFileWriteTool = toolData.toolname === \"write_text_file\" || toolData.toolname === \"edit_text_file\";\n\n    useEffect(() => {\n        return () => {\n            if (highlightTimeoutRef.current) {\n                clearTimeout(highlightTimeoutRef.current);\n            }\n        };\n    }, []);\n\n    const handleApprove = () => {\n        setUserApprovalOverride(\"user-approved\");\n        WaveAIModel.getInstance().toolUseSendApproval(toolData.toolcallid, \"user-approved\");\n    };\n\n    const handleDeny = () => {\n        setUserApprovalOverride(\"user-denied\");\n        WaveAIModel.getInstance().toolUseSendApproval(toolData.toolcallid, \"user-denied\");\n    };\n\n    const handleMouseEnter = () => {\n        if (!toolData.blockid) return;\n\n        if (highlightTimeoutRef.current) {\n            clearTimeout(highlightTimeoutRef.current);\n        }\n\n        highlightedBlockIdRef.current = toolData.blockid;\n        BlockModel.getInstance().setBlockHighlight({\n            blockId: toolData.blockid,\n            icon: \"sparkles\",\n        });\n\n        highlightTimeoutRef.current = setTimeout(() => {\n            if (highlightedBlockIdRef.current === toolData.blockid) {\n                BlockModel.getInstance().setBlockHighlight(null);\n                highlightedBlockIdRef.current = null;\n            }\n        }, 2000);\n    };\n\n    const handleMouseLeave = () => {\n        if (!toolData.blockid) return;\n\n        if (highlightTimeoutRef.current) {\n            clearTimeout(highlightTimeoutRef.current);\n            highlightTimeoutRef.current = null;\n        }\n\n        if (highlightedBlockIdRef.current === toolData.blockid) {\n            BlockModel.getInstance().setBlockHighlight(null);\n            highlightedBlockIdRef.current = null;\n        }\n    };\n\n    const handleOpenDiff = () => {\n        recordTEvent(\"waveai:showdiff\");\n        fireAndForget(() => WaveAIModel.getInstance().openDiff(toolData.inputfilename, toolData.toolcallid));\n    };\n\n    return (\n        <div\n            className={cn(\"flex flex-col gap-1 p-2 rounded bg-zinc-800/60 border border-zinc-700\", statusColor)}\n            onMouseEnter={handleMouseEnter}\n            onMouseLeave={handleMouseLeave}\n        >\n            <div className=\"flex items-center gap-2\">\n                <span className=\"font-bold\">{statusIcon}</span>\n                <div className=\"font-semibold\">{toolData.toolname}</div>\n                <div className=\"flex-1\" />\n                {isFileWriteTool &&\n                    toolData.inputfilename &&\n                    toolData.writebackupfilename &&\n                    toolData.runts &&\n                    Date.now() - toolData.runts < BackupRetentionDays * 24 * 60 * 60 * 1000 && (\n                        <button\n                            onClick={() => {\n                                recordTEvent(\"waveai:revertfile\", { \"waveai:action\": \"revertfile:open\" });\n                                model.openRestoreBackupModal(toolData.toolcallid);\n                            }}\n                            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\"\n                            title=\"Restore backup file\"\n                        >\n                            <span className=\"text-xs\">Revert File</span>\n                            <i className=\"fa fa-clock-rotate-left text-xs\"></i>\n                        </button>\n                    )}\n                {isFileWriteTool && toolData.inputfilename && (\n                    <button\n                        onClick={handleOpenDiff}\n                        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\"\n                        title=\"Open in diff viewer\"\n                    >\n                        <span className=\"text-xs\">Show Diff</span>\n                        <i className=\"fa fa-arrow-up-right-from-square text-xs\"></i>\n                    </button>\n                )}\n            </div>\n            {toolData.tooldesc && <ToolDesc text={toolData.tooldesc} className=\"text-sm text-gray-400 pl-6\" />}\n            {(toolData.errormessage || effectiveApproval === \"timeout\") && (\n                <div className=\"text-sm text-red-300 pl-6\">{toolData.errormessage || \"Not approved\"}</div>\n            )}\n            {effectiveApproval === \"needs-approval\" && (\n                <div className=\"pl-6\">\n                    <AIToolApprovalButtons count={1} onApprove={handleApprove} onDeny={handleDeny} />\n                </div>\n            )}\n            {showRestoreModal && <RestoreBackupModal part={part} />}\n        </div>\n    );\n});\n\nAIToolUse.displayName = \"AIToolUse\";\n\ninterface AIToolProgressProps {\n    part: WaveUIMessagePart & { type: \"data-toolprogress\" };\n}\n\nconst AIToolProgress = memo(({ part }: AIToolProgressProps) => {\n    const progressData = part.data;\n\n    return (\n        <div className=\"flex flex-col gap-1 p-2 rounded bg-zinc-800/60 border border-zinc-700\">\n            <div className=\"flex items-center gap-2\">\n                <i className=\"fa fa-spinner fa-spin text-gray-400\"></i>\n                <div className=\"font-semibold\">{progressData.toolname}</div>\n            </div>\n            {progressData.statuslines && progressData.statuslines.length > 0 && (\n                <ToolDesc text={progressData.statuslines} className=\"text-sm text-gray-400 pl-6 space-y-0.5\" />\n            )}\n        </div>\n    );\n});\n\nAIToolProgress.displayName = \"AIToolProgress\";\n\ninterface AIToolUseGroupProps {\n    parts: Array<WaveUIMessagePart & { type: \"data-tooluse\" | \"data-toolprogress\" }>;\n    isStreaming: boolean;\n}\n\ntype ToolGroupItem =\n    | { type: \"batch\"; parts: Array<WaveUIMessagePart & { type: \"data-tooluse\" }> }\n    | { type: \"single\"; part: WaveUIMessagePart & { type: \"data-tooluse\" } }\n    | { type: \"progress\"; part: WaveUIMessagePart & { type: \"data-toolprogress\" } };\n\nexport const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) => {\n    const tooluseParts = parts.filter((p) => p.type === \"data-tooluse\") as Array<\n        WaveUIMessagePart & { type: \"data-tooluse\" }\n    >;\n    const toolprogressParts = parts.filter((p) => p.type === \"data-toolprogress\") as Array<\n        WaveUIMessagePart & { type: \"data-toolprogress\" }\n    >;\n\n    const tooluseCallIds = new Set(tooluseParts.map((p) => p.data.toolcallid));\n    const filteredProgressParts = toolprogressParts.filter((p) => !tooluseCallIds.has(p.data.toolcallid));\n\n    const isFileOp = (part: WaveUIMessagePart & { type: \"data-tooluse\" }) => {\n        const toolName = part.data?.toolname;\n        return toolName === \"read_text_file\" || toolName === \"read_dir\";\n    };\n\n    const needsApproval = (part: WaveUIMessagePart & { type: \"data-tooluse\" }) => {\n        return getEffectiveApprovalStatus(part.data?.approval, isStreaming) === \"needs-approval\";\n    };\n\n    const readFileNeedsApproval: Array<WaveUIMessagePart & { type: \"data-tooluse\" }> = [];\n    const readFileOther: Array<WaveUIMessagePart & { type: \"data-tooluse\" }> = [];\n\n    for (const part of tooluseParts) {\n        if (isFileOp(part)) {\n            if (needsApproval(part)) {\n                readFileNeedsApproval.push(part);\n            } else {\n                readFileOther.push(part);\n            }\n        }\n    }\n\n    const groupedItems: ToolGroupItem[] = [];\n    let addedApprovalBatch = false;\n    let addedOtherBatch = false;\n\n    for (const part of tooluseParts) {\n        const isFileOpPart = isFileOp(part);\n        const partNeedsApproval = needsApproval(part);\n\n        if (isFileOpPart && partNeedsApproval) {\n            if (!addedApprovalBatch) {\n                groupedItems.push({ type: \"batch\", parts: readFileNeedsApproval });\n                addedApprovalBatch = true;\n            }\n        } else if (isFileOpPart && !partNeedsApproval) {\n            if (!addedOtherBatch) {\n                groupedItems.push({ type: \"batch\", parts: readFileOther });\n                addedOtherBatch = true;\n            }\n        } else {\n            groupedItems.push({ type: \"single\", part });\n        }\n    }\n\n    filteredProgressParts.forEach((part) => {\n        groupedItems.push({ type: \"progress\", part });\n    });\n\n    return (\n        <>\n            {groupedItems.map((item, idx) => {\n                if (item.type === \"batch\") {\n                    return (\n                        <div key={idx} className=\"mt-2\">\n                            <AIToolUseBatch parts={item.parts} isStreaming={isStreaming} />\n                        </div>\n                    );\n                } else if (item.type === \"progress\") {\n                    return (\n                        <div key={idx} className=\"mt-2\">\n                            <AIToolProgress part={item.part} />\n                        </div>\n                    );\n                } else {\n                    return (\n                        <div key={idx} className=\"mt-2\">\n                            <AIToolUse part={item.part} isStreaming={isStreaming} />\n                        </div>\n                    );\n                }\n            })}\n        </>\n    );\n});\n\nAIToolUseGroup.displayName = \"AIToolUseGroup\";\n"
  },
  {
    "path": "frontend/app/aipanel/aitypes.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { ChatRequestOptions, FileUIPart, UIMessage, UIMessagePart } from \"ai\";\n\ntype WaveUIDataTypes = {\n    // pkg/aiusechat/uctypes/uctypes.go UIMessageDataUserFile\n    userfile: {\n        filename: string;\n        size: number;\n        mimetype: string;\n        previewurl?: string;\n    };\n    // pkg/aiusechat/uctypes/uctypes.go UIMessageDataToolUse\n    tooluse: {\n        toolcallid: string;\n        toolname: string;\n        tooldesc: string;\n        status: \"pending\" | \"error\" | \"completed\";\n        runts?: number;\n        errormessage?: string;\n        approval?: \"needs-approval\" | \"user-approved\" | \"user-denied\" | \"auto-approved\" | \"timeout\";\n        blockid?: string;\n        writebackupfilename?: string;\n        inputfilename?: string;\n    };\n\n    toolprogress: {\n        toolcallid: string;\n        toolname: string;\n        statuslines: string[];\n    };\n};\n\nexport type WaveUIMessage = UIMessage<unknown, WaveUIDataTypes, any>;\nexport type WaveUIMessagePart = UIMessagePart<WaveUIDataTypes, any>;\n\nexport type UseChatSetMessagesType = (\n    messages: WaveUIMessage[] | ((messages: WaveUIMessage[]) => WaveUIMessage[])\n) => void;\n\nexport type UseChatSendMessageType = (\n    message?:\n        | (Omit<WaveUIMessage, \"id\" | \"role\"> & {\n              id?: string;\n              role?: \"system\" | \"user\" | \"assistant\";\n          } & {\n              text?: never;\n              files?: never;\n              messageId?: string;\n          })\n        | {\n              text: string;\n              files?: FileList | FileUIPart[];\n              metadata?: unknown;\n              parts?: never;\n              messageId?: string;\n          }\n        | {\n              files: FileList | FileUIPart[];\n              metadata?: unknown;\n              parts?: never;\n              messageId?: string;\n          },\n    options?: ChatRequestOptions\n) => Promise<void>;\n"
  },
  {
    "path": "frontend/app/aipanel/byokannouncement.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { WaveAIModel } from \"./waveai-model\";\n\nconst BYOKAnnouncement = () => {\n    const model = WaveAIModel.getInstance();\n\n    const handleOpenConfig = async () => {\n        RpcApi.RecordTEventCommand(\n            TabRpcClient,\n            {\n                event: \"action:other\",\n                props: {\n                    \"action:type\": \"waveai:configuremodes:panel\",\n                },\n            },\n            { noresponse: true }\n        );\n        await model.openWaveAIConfig();\n    };\n\n    const handleViewDocs = () => {\n        RpcApi.RecordTEventCommand(\n            TabRpcClient,\n            {\n                event: \"action:other\",\n                props: {\n                    \"action:type\": \"waveai:viewdocs:panel\",\n                },\n            },\n            { noresponse: true }\n        );\n    };\n\n    return (\n        <div className=\"bg-blue-900/20 border border-blue-800 rounded-lg p-4 mt-4\">\n            <div className=\"flex items-start gap-3\">\n                <i className=\"fa fa-key text-blue-400 text-lg mt-0.5\"></i>\n                <div className=\"text-left flex-1\">\n                    <div className=\"text-blue-400 font-medium mb-1\">New: BYOK & Local AI Support</div>\n                    <div className=\"text-secondary text-sm mb-3\">\n                        Wave AI now supports bring-your-own-key (BYOK) with OpenAI, Google Gemini, Azure, and\n                        OpenRouter, plus local models via Ollama, LM Studio, and other OpenAI-compatible providers.\n                    </div>\n                    <div className=\"flex items-center gap-3\">\n                        <button\n                            onClick={handleOpenConfig}\n                            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\"\n                        >\n                            Configure AI Modes\n                        </button>\n                        <a\n                            href=\"https://docs.waveterm.dev/waveai-modes\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            onClick={handleViewDocs}\n                            className=\"text-blue-400! hover:text-blue-300! hover:underline text-sm cursor-pointer transition-colors flex items-center gap-1\"\n                        >\n                            View Docs <i className=\"fa fa-external-link text-xs\"></i>\n                        </a>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n\nBYOKAnnouncement.displayName = \"BYOKAnnouncement\";\n\nexport { BYOKAnnouncement };\n"
  },
  {
    "path": "frontend/app/aipanel/restorebackupmodal.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Modal } from \"@/app/modals/modal\";\nimport { recordTEvent } from \"@/app/store/global\";\nimport { useAtomValue } from \"jotai\";\nimport { memo } from \"react\";\nimport { WaveUIMessagePart } from \"./aitypes\";\nimport { WaveAIModel } from \"./waveai-model\";\n\ninterface RestoreBackupModalProps {\n    part: WaveUIMessagePart & { type: \"data-tooluse\" };\n}\n\nexport const RestoreBackupModal = memo(({ part }: RestoreBackupModalProps) => {\n    const model = WaveAIModel.getInstance();\n    const toolData = part.data;\n    const status = useAtomValue(model.restoreBackupStatus);\n    const error = useAtomValue(model.restoreBackupError);\n\n    const formatTimestamp = (ts: number) => {\n        if (!ts) return \"\";\n        const date = new Date(ts);\n        return date.toLocaleString();\n    };\n\n    const handleConfirm = () => {\n        recordTEvent(\"waveai:revertfile\", { \"waveai:action\": \"revertfile:confirm\" });\n        model.restoreBackup(toolData.toolcallid, toolData.writebackupfilename, toolData.inputfilename);\n    };\n\n    const handleCancel = () => {\n        recordTEvent(\"waveai:revertfile\", { \"waveai:action\": \"revertfile:cancel\" });\n        model.closeRestoreBackupModal();\n    };\n\n    const handleClose = () => {\n        model.closeRestoreBackupModal();\n    };\n\n    if (status === \"success\") {\n        return (\n            <Modal className=\"restore-backup-modal pb-5 pr-5\" onClose={handleClose} onOk={handleClose} okLabel=\"Close\">\n                <div className=\"flex flex-col gap-4 pt-4 pb-4 max-w-xl\">\n                    <div className=\"font-semibold text-lg text-green-500\">Backup Successfully Restored</div>\n                    <div className=\"text-sm text-gray-300 leading-relaxed\">\n                        The file <span className=\"font-mono text-white break-all\">{toolData.inputfilename}</span> has\n                        been restored to its previous state.\n                    </div>\n                </div>\n            </Modal>\n        );\n    }\n\n    if (status === \"error\") {\n        return (\n            <Modal className=\"restore-backup-modal pb-5 pr-5\" onClose={handleClose} onOk={handleClose} okLabel=\"Close\">\n                <div className=\"flex flex-col gap-4 pt-4 pb-4 max-w-xl\">\n                    <div className=\"font-semibold text-lg text-red-500\">Failed to Restore Backup</div>\n                    <div className=\"text-sm text-gray-300 leading-relaxed\">\n                        An error occurred while restoring the backup:\n                    </div>\n                    <div className=\"text-sm text-red-400 font-mono bg-zinc-800 p-3 rounded break-all\">{error}</div>\n                </div>\n            </Modal>\n        );\n    }\n\n    const isProcessing = status === \"processing\";\n\n    return (\n        <Modal\n            className=\"restore-backup-modal pb-5 pr-5\"\n            onClose={handleCancel}\n            onCancel={handleCancel}\n            onOk={handleConfirm}\n            okLabel={isProcessing ? \"Restoring...\" : \"Confirm Restore\"}\n            cancelLabel=\"Cancel\"\n            okDisabled={isProcessing}\n            cancelDisabled={isProcessing}\n        >\n            <div className=\"flex flex-col gap-4 pt-4 pb-4 max-w-xl\">\n                <div className=\"font-semibold text-lg\">Restore File Backup</div>\n                <div className=\"text-sm text-gray-300 leading-relaxed\">\n                    This will restore <span className=\"font-mono text-white break-all\">{toolData.inputfilename}</span>{\" \"}\n                    to its state before this edit was made\n                    {toolData.runts && <span> ({formatTimestamp(toolData.runts)})</span>}.\n                </div>\n                <div className=\"text-sm text-gray-300 leading-relaxed\">\n                    Any changes made by this edit and subsequent edits will be lost.\n                </div>\n            </div>\n        </Modal>\n    );\n});\n\nRestoreBackupModal.displayName = \"RestoreBackupModal\";"
  },
  {
    "path": "frontend/app/aipanel/telemetryrequired.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { cn } from \"@/util/util\";\nimport { useState } from \"react\";\nimport { WaveAIModel } from \"./waveai-model\";\n\ninterface TelemetryRequiredMessageProps {\n    className?: string;\n}\n\nconst TelemetryRequiredMessage = ({ className }: TelemetryRequiredMessageProps) => {\n    const [isEnabling, setIsEnabling] = useState(false);\n\n    const handleEnableTelemetry = async () => {\n        setIsEnabling(true);\n        try {\n            await RpcApi.WaveAIEnableTelemetryCommand(TabRpcClient);\n            setTimeout(() => {\n                WaveAIModel.getInstance().focusInput();\n            }, 100);\n        } catch (error) {\n            console.error(\"Failed to enable telemetry:\", error);\n            setIsEnabling(false);\n        }\n    };\n\n    return (\n        <div className={cn(\"flex flex-col h-full\", className)}>\n            <div className=\"flex-grow\"></div>\n            <div className=\"flex items-center justify-center p-8 text-center\">\n                <div className=\"max-w-md space-y-6\">\n                    <div className=\"space-y-4\">\n                        <i className=\"fa fa-sparkles text-accent text-5xl\"></i>\n                        <h2 className=\"text-2xl font-semibold text-foreground\">Wave AI</h2>\n                        <p className=\"text-secondary leading-relaxed\">\n                            Wave AI is free to use and provides integrated AI chat that can interact with your widgets,\n                            help you with code, analyze files, and assist with your terminal workflows.\n                        </p>\n                    </div>\n\n                    <div className=\"bg-blue-900/20 border border-blue-500 rounded-lg p-4\">\n                        <div className=\"flex items-start gap-3\">\n                            <i className=\"fa fa-info-circle text-blue-400 text-lg mt-0.5\"></i>\n                            <div className=\"text-left\">\n                                <div className=\"text-blue-400 font-medium mb-1\">Telemetry keeps Wave AI free</div>\n                                <div className=\"text-secondary text-sm mb-3\">\n                                    <p className=\"mb-2\">\n                                        To keep Wave AI free for everyone, we require a small amount of <i>anonymous</i>{\" \"}\n                                        usage data (app version, feature usage, system info).\n                                    </p>\n                                    <p className=\"mb-2\">\n                                        This helps us block abuse by automated systems and ensure it's used by real\n                                        people like you.\n                                    </p>\n                                    <p className=\"mb-2\">\n                                        We never collect your files, prompts, keystrokes, hostnames, or personally\n                                        identifying information. Wave AI is powered by OpenAI's APIs, please refer to\n                                        OpenAI's privacy policy for details on how they handle your data.\n                                    </p>\n                                    <p>\n                                        For information about BYOK and local model support, see{\" \"}\n                                        <a\n                                            href=\"https://docs.waveterm.dev/waveai-modes\"\n                                            target=\"_blank\"\n                                            rel=\"noopener noreferrer\"\n                                            className=\"!text-secondary hover:!text-accent/80 cursor-pointer\"\n                                        >\n                                            https://docs.waveterm.dev/waveai-modes\n                                        </a>\n                                        .\n                                    </p>\n                                </div>\n                                <button\n                                    onClick={handleEnableTelemetry}\n                                    disabled={isEnabling}\n                                    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\"\n                                >\n                                    {isEnabling ? \"Enabling...\" : \"Enable Telemetry and Continue\"}\n                                </button>\n                            </div>\n                        </div>\n                    </div>\n\n                    <div className=\"text-xs text-secondary\">\n                        <a\n                            href=\"https://waveterm.dev/privacy\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"!text-secondary hover:!text-accent/80 cursor-pointer\"\n                        >\n                            Privacy Policy\n                        </a>\n                    </div>\n                </div>\n            </div>\n            <div className=\"flex-grow-[2]\"></div>\n        </div>\n    );\n};\n\nTelemetryRequiredMessage.displayName = \"TelemetryRequiredMessage\";\n\nexport { TelemetryRequiredMessage };\n"
  },
  {
    "path": "frontend/app/aipanel/waveai-focus-utils.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nexport function findWaveAIPanel(element: HTMLElement): HTMLElement | null {\n    let current: HTMLElement = element;\n    while (current) {\n        if (current.hasAttribute(\"data-waveai-panel\")) {\n            return current;\n        }\n        current = current.parentElement;\n    }\n    return null;\n}\n\nexport function waveAIHasFocusWithin(focusTarget?: Element | null): boolean {\n    if (focusTarget !== undefined) {\n        if (focusTarget instanceof HTMLElement) {\n            return findWaveAIPanel(focusTarget) != null;\n        }\n        return false;\n    }\n\n    const focused = document.activeElement;\n    if (focused instanceof HTMLElement) {\n        const waveAIPanel = findWaveAIPanel(focused);\n        if (waveAIPanel) return true;\n    }\n\n    const sel = document.getSelection();\n    if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) {\n        let anchor = sel.anchorNode;\n        if (anchor instanceof Text) {\n            anchor = anchor.parentElement;\n        }\n        if (anchor instanceof HTMLElement) {\n            const waveAIPanel = findWaveAIPanel(anchor);\n            if (waveAIPanel) return true;\n        }\n    }\n\n    return false;\n}\n\nexport function waveAIHasSelection(): boolean {\n    const sel = document.getSelection();\n    if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {\n        return false;\n    }\n\n    let anchor = sel.anchorNode;\n    if (anchor instanceof Text) {\n        anchor = anchor.parentElement;\n    }\n    if (anchor instanceof HTMLElement) {\n        return findWaveAIPanel(anchor) != null;\n    }\n\n    return false;\n}"
  },
  {
    "path": "frontend/app/aipanel/waveai-model.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport {\n    UseChatSendMessageType,\n    UseChatSetMessagesType,\n    WaveUIMessage,\n    WaveUIMessagePart,\n} from \"@/app/aipanel/aitypes\";\nimport { FocusManager } from \"@/app/store/focusManager\";\nimport { atoms, createBlock, getOrefMetaKeyAtom, getSettingsKeyAtom } from \"@/app/store/global\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { isBuilderWindow } from \"@/app/store/windowtype\";\nimport * as WOS from \"@/app/store/wos\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { WorkspaceLayoutModel } from \"@/app/workspace/workspace-layout-model\";\nimport { BuilderFocusManager } from \"@/builder/store/builder-focusmanager\";\nimport { getWebServerEndpoint } from \"@/util/endpoints\";\nimport { base64ToArrayBuffer } from \"@/util/util\";\nimport { ChatStatus } from \"ai\";\nimport * as jotai from \"jotai\";\nimport type React from \"react\";\nimport {\n    createDataUrl,\n    createImagePreview,\n    formatFileSizeError,\n    isAcceptableFile,\n    normalizeMimeType,\n    resizeImage,\n    validateFileSizeFromInfo,\n} from \"./ai-utils\";\nimport type { AIPanelInputRef } from \"./aipanelinput\";\n\nexport interface DroppedFile {\n    id: string;\n    file: File;\n    name: string;\n    type: string;\n    size: number;\n    previewUrl?: string;\n}\n\nexport class WaveAIModel {\n    private static instance: WaveAIModel | null = null;\n    inputRef: React.RefObject<AIPanelInputRef> | null = null;\n    scrollToBottomCallback: (() => void) | null = null;\n    useChatSendMessage: UseChatSendMessageType | null = null;\n    useChatSetMessages: UseChatSetMessagesType | null = null;\n    useChatStatus: ChatStatus = \"ready\";\n    useChatStop: (() => void) | null = null;\n    // Used for injecting Wave-specific message data into DefaultChatTransport's prepareSendMessagesRequest\n    realMessage: AIMessage | null = null;\n    orefContext: ORef;\n    inBuilder: boolean = false;\n    isAIStreaming = jotai.atom(false);\n\n    widgetAccessAtom!: jotai.Atom<boolean>;\n    droppedFiles: jotai.PrimitiveAtom<DroppedFile[]> = jotai.atom([]);\n    chatId!: jotai.PrimitiveAtom<string>;\n    currentAIMode!: jotai.PrimitiveAtom<string>;\n    aiModeConfigs!: jotai.Atom<Record<string, AIModeConfigType>>;\n    hasPremiumAtom!: jotai.Atom<boolean>;\n    defaultModeAtom!: jotai.Atom<string>;\n    errorMessage: jotai.PrimitiveAtom<string> = jotai.atom(null) as jotai.PrimitiveAtom<string>;\n    containerWidth: jotai.PrimitiveAtom<number> = jotai.atom(0);\n    codeBlockMaxWidth!: jotai.Atom<number>;\n    inputAtom: jotai.PrimitiveAtom<string> = jotai.atom(\"\");\n    isLoadingChatAtom: jotai.PrimitiveAtom<boolean> = jotai.atom(false);\n    isChatEmptyAtom: jotai.PrimitiveAtom<boolean> = jotai.atom(true);\n    isWaveAIFocusedAtom!: jotai.Atom<boolean>;\n    panelVisibleAtom!: jotai.Atom<boolean>;\n    restoreBackupModalToolCallId: jotai.PrimitiveAtom<string | null> = jotai.atom(null) as jotai.PrimitiveAtom<\n        string | null\n    >;\n    restoreBackupStatus: jotai.PrimitiveAtom<\"idle\" | \"processing\" | \"success\" | \"error\"> = jotai.atom(\"idle\");\n    restoreBackupError: jotai.PrimitiveAtom<string> = jotai.atom(null) as jotai.PrimitiveAtom<string>;\n\n    private constructor(orefContext: ORef, inBuilder: boolean) {\n        this.orefContext = orefContext;\n        this.inBuilder = inBuilder;\n        this.chatId = jotai.atom(null) as jotai.PrimitiveAtom<string>;\n        this.aiModeConfigs = atoms.waveaiModeConfigAtom;\n\n        this.hasPremiumAtom = jotai.atom((get) => {\n            const rateLimitInfo = get(atoms.waveAIRateLimitInfoAtom);\n            return !rateLimitInfo || rateLimitInfo.unknown || rateLimitInfo.preq > 0;\n        });\n\n        this.widgetAccessAtom = jotai.atom((get) => {\n            if (this.inBuilder) {\n                return true;\n            }\n            const widgetAccessMetaAtom = getOrefMetaKeyAtom(this.orefContext, \"waveai:widgetcontext\");\n            const value = get(widgetAccessMetaAtom);\n            return value ?? true;\n        });\n\n        this.codeBlockMaxWidth = jotai.atom((get) => {\n            const width = get(this.containerWidth);\n            return width > 0 ? width - 35 : 0;\n        });\n\n        this.isWaveAIFocusedAtom = jotai.atom((get) => {\n            if (this.inBuilder) {\n                return get(BuilderFocusManager.getInstance().focusType) === \"waveai\";\n            }\n            return get(FocusManager.getInstance().focusType) === \"waveai\";\n        });\n\n        this.panelVisibleAtom = jotai.atom((get) => {\n            if (this.inBuilder) {\n                return true;\n            }\n            return get(WorkspaceLayoutModel.getInstance().panelVisibleAtom);\n        });\n\n        this.defaultModeAtom = jotai.atom((get) => {\n            const telemetryEnabled = get(getSettingsKeyAtom(\"telemetry:enabled\")) ?? false;\n            if (this.inBuilder) {\n                return telemetryEnabled ? \"waveai@balanced\" : \"invalid\";\n            }\n            const aiModeConfigs = get(this.aiModeConfigs);\n            if (!telemetryEnabled) {\n                let mode = get(getSettingsKeyAtom(\"waveai:defaultmode\"));\n                if (mode == null || mode.startsWith(\"waveai@\")) {\n                    return \"unknown\";\n                }\n                return mode;\n            }\n            const hasPremium = get(this.hasPremiumAtom);\n            const waveFallback = hasPremium ? \"waveai@balanced\" : \"waveai@quick\";\n            let mode = get(getSettingsKeyAtom(\"waveai:defaultmode\")) ?? waveFallback;\n            if (!hasPremium && mode.startsWith(\"waveai@\")) {\n                mode = \"waveai@quick\";\n            }\n            const modeExists = aiModeConfigs != null && mode in aiModeConfigs;\n            if (!modeExists) {\n                mode = waveFallback;\n            }\n            return mode;\n        });\n\n        const defaultMode = globalStore.get(this.defaultModeAtom);\n        this.currentAIMode = jotai.atom(defaultMode);\n    }\n\n    getPanelVisibleAtom(): jotai.Atom<boolean> {\n        return this.panelVisibleAtom;\n    }\n\n    static getInstance(): WaveAIModel {\n        if (!WaveAIModel.instance) {\n            let orefContext: ORef;\n            if (isBuilderWindow()) {\n                const builderId = globalStore.get(atoms.builderId);\n                orefContext = WOS.makeORef(\"builder\", builderId);\n            } else {\n                const tabId = globalStore.get(atoms.staticTabId);\n                orefContext = WOS.makeORef(\"tab\", tabId);\n            }\n            WaveAIModel.instance = new WaveAIModel(orefContext, isBuilderWindow());\n            (window as any).WaveAIModel = WaveAIModel.instance;\n        }\n        return WaveAIModel.instance;\n    }\n\n    static resetInstance(): void {\n        WaveAIModel.instance = null;\n    }\n\n    getUseChatEndpointUrl(): string {\n        return `${getWebServerEndpoint()}/api/post-chat-message`;\n    }\n\n    async addFile(file: File): Promise<DroppedFile> {\n        // Resize images before storing\n        const processedFile = await resizeImage(file);\n\n        const droppedFile: DroppedFile = {\n            id: crypto.randomUUID(),\n            file: processedFile,\n            name: processedFile.name,\n            type: processedFile.type,\n            size: processedFile.size,\n        };\n\n        // Create 128x128 preview data URL for images\n        if (processedFile.type.startsWith(\"image/\")) {\n            const previewDataUrl = await createImagePreview(processedFile);\n            if (previewDataUrl) {\n                droppedFile.previewUrl = previewDataUrl;\n            }\n        }\n\n        const currentFiles = globalStore.get(this.droppedFiles);\n        globalStore.set(this.droppedFiles, [...currentFiles, droppedFile]);\n\n        return droppedFile;\n    }\n\n    async addFileFromRemoteUri(draggedFile: DraggedFile): Promise<void> {\n        if (draggedFile.isDir) {\n            this.setError(\"Cannot add directories to Wave AI. Please select a file.\");\n            return;\n        }\n\n        try {\n            const fileInfo = await RpcApi.FileInfoCommand(TabRpcClient, { info: { path: draggedFile.uri } }, null);\n            if (fileInfo.notfound) {\n                this.setError(`File not found: ${draggedFile.relName}`);\n                return;\n            }\n            if (fileInfo.isdir) {\n                this.setError(\"Cannot add directories to Wave AI. Please select a file.\");\n                return;\n            }\n\n            const mimeType = fileInfo.mimetype || \"application/octet-stream\";\n            const fileSize = fileInfo.size || 0;\n            const sizeError = validateFileSizeFromInfo(draggedFile.relName, fileSize, mimeType);\n            if (sizeError) {\n                this.setError(formatFileSizeError(sizeError));\n                return;\n            }\n\n            const fileData = await RpcApi.FileReadCommand(TabRpcClient, { info: { path: draggedFile.uri } }, null);\n            if (!fileData.data64) {\n                this.setError(`Failed to read file: ${draggedFile.relName}`);\n                return;\n            }\n\n            const buffer = base64ToArrayBuffer(fileData.data64);\n            const file = new File([buffer], draggedFile.relName, { type: mimeType });\n            if (!isAcceptableFile(file)) {\n                this.setError(\n                    `File type not supported: ${draggedFile.relName}. Supported: images, PDFs, and text/code files.`\n                );\n                return;\n            }\n\n            await this.addFile(file);\n        } catch (error) {\n            console.error(\"Error handling FILE_ITEM drop:\", error);\n            const errorMsg = error instanceof Error ? error.message : String(error);\n            this.setError(`Failed to add file: ${errorMsg}`);\n        }\n    }\n\n    removeFile(fileId: string) {\n        const currentFiles = globalStore.get(this.droppedFiles);\n        const updatedFiles = currentFiles.filter((f) => f.id !== fileId);\n        globalStore.set(this.droppedFiles, updatedFiles);\n    }\n\n    clearFiles() {\n        const currentFiles = globalStore.get(this.droppedFiles);\n\n        // Cleanup all preview URLs\n        currentFiles.forEach((file) => {\n            if (file.previewUrl) {\n                URL.revokeObjectURL(file.previewUrl);\n            }\n        });\n\n        globalStore.set(this.droppedFiles, []);\n    }\n\n    clearChat() {\n        this.useChatStop?.();\n        this.clearFiles();\n        this.clearError();\n        globalStore.set(this.isChatEmptyAtom, true);\n        const newChatId = crypto.randomUUID();\n        globalStore.set(this.chatId, newChatId);\n\n        RpcApi.SetRTInfoCommand(TabRpcClient, {\n            oref: this.orefContext,\n            data: { \"waveai:chatid\": newChatId },\n        });\n\n        this.useChatSetMessages?.([]);\n    }\n\n    setError(message: string) {\n        globalStore.set(this.errorMessage, message);\n    }\n\n    clearError() {\n        globalStore.set(this.errorMessage, null);\n    }\n\n    registerInputRef(ref: React.RefObject<AIPanelInputRef>) {\n        this.inputRef = ref;\n    }\n\n    registerScrollToBottom(callback: () => void) {\n        this.scrollToBottomCallback = callback;\n    }\n\n    registerUseChatData(\n        sendMessage: UseChatSendMessageType,\n        setMessages: UseChatSetMessagesType,\n        status: ChatStatus,\n        stop: () => void\n    ) {\n        this.useChatSendMessage = sendMessage;\n        this.useChatSetMessages = setMessages;\n        this.useChatStatus = status;\n        this.useChatStop = stop;\n    }\n\n    scrollToBottom() {\n        this.scrollToBottomCallback?.();\n    }\n\n    focusInput() {\n        if (!this.inBuilder && !WorkspaceLayoutModel.getInstance().getAIPanelVisible()) {\n            WorkspaceLayoutModel.getInstance().setAIPanelVisible(true);\n        }\n        if (this.inputRef?.current) {\n            this.inputRef.current.focus();\n        }\n    }\n\n    async reloadChatFromBackend(chatIdValue: string): Promise<WaveUIMessage[]> {\n        const chatData = await RpcApi.GetWaveAIChatCommand(TabRpcClient, { chatid: chatIdValue });\n        const messages: UIMessage[] = chatData?.messages ?? [];\n        globalStore.set(this.isChatEmptyAtom, messages.length === 0);\n        return messages as WaveUIMessage[];\n    }\n\n    async stopResponse() {\n        this.useChatStop?.();\n        await new Promise((resolve) => setTimeout(resolve, 500));\n\n        const chatIdValue = globalStore.get(this.chatId);\n        if (!chatIdValue) {\n            return;\n        }\n        try {\n            const messages = await this.reloadChatFromBackend(chatIdValue);\n            this.useChatSetMessages?.(messages);\n        } catch (error) {\n            console.error(\"Failed to reload chat after stop:\", error);\n        }\n    }\n\n    getAndClearMessage(): AIMessage | null {\n        const msg = this.realMessage;\n        this.realMessage = null;\n        return msg;\n    }\n\n    hasNonEmptyInput(): boolean {\n        const input = globalStore.get(this.inputAtom);\n        return input != null && input.trim().length > 0;\n    }\n\n    appendText(text: string, newLine?: boolean, opts?: { scrollToBottom?: boolean }) {\n        const currentInput = globalStore.get(this.inputAtom);\n        let newInput = currentInput;\n\n        if (newInput.length > 0) {\n            if (newLine) {\n                if (!newInput.endsWith(\"\\n\")) {\n                    newInput += \"\\n\";\n                }\n            } else if (!newInput.endsWith(\" \") && !newInput.endsWith(\"\\n\")) {\n                newInput += \" \";\n            }\n        }\n\n        newInput += text;\n        globalStore.set(this.inputAtom, newInput);\n\n        if (opts?.scrollToBottom && this.inputRef?.current) {\n            setTimeout(() => this.inputRef.current.scrollToBottom(), 10);\n        }\n    }\n\n    setModel(model: string) {\n        RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: this.orefContext,\n            meta: { \"waveai:model\": model },\n        });\n    }\n\n    setWidgetAccess(enabled: boolean) {\n        RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: this.orefContext,\n            meta: { \"waveai:widgetcontext\": enabled },\n        });\n    }\n\n    isValidMode(mode: string): boolean {\n        const telemetryEnabled = globalStore.get(getSettingsKeyAtom(\"telemetry:enabled\")) ?? false;\n        if (mode.startsWith(\"waveai@\") && !telemetryEnabled) {\n            return false;\n        }\n\n        const aiModeConfigs = globalStore.get(this.aiModeConfigs);\n        if (aiModeConfigs == null || !(mode in aiModeConfigs)) {\n            return false;\n        }\n\n        return true;\n    }\n\n    setAIMode(mode: string) {\n        if (!this.isValidMode(mode)) {\n            this.setAIModeToDefault();\n        } else {\n            globalStore.set(this.currentAIMode, mode);\n            RpcApi.SetRTInfoCommand(TabRpcClient, {\n                oref: this.orefContext,\n                data: { \"waveai:mode\": mode },\n            });\n        }\n    }\n\n    setAIModeToDefault() {\n        const defaultMode = globalStore.get(this.defaultModeAtom);\n        globalStore.set(this.currentAIMode, defaultMode);\n        RpcApi.SetRTInfoCommand(TabRpcClient, {\n            oref: this.orefContext,\n            data: { \"waveai:mode\": null },\n        });\n    }\n\n    async fixModeAfterConfigChange(): Promise<void> {\n        const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, {\n            oref: this.orefContext,\n        });\n        const mode = rtInfo?.[\"waveai:mode\"];\n        if (mode == null || !this.isValidMode(mode)) {\n            this.setAIModeToDefault();\n        }\n    }\n\n    async getRTInfo(): Promise<Record<string, any>> {\n        const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, {\n            oref: this.orefContext,\n        });\n        return rtInfo ?? {};\n    }\n\n    async loadInitialChat(): Promise<WaveUIMessage[]> {\n        const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, {\n            oref: this.orefContext,\n        });\n        let chatIdValue = rtInfo?.[\"waveai:chatid\"];\n        if (chatIdValue == null) {\n            chatIdValue = crypto.randomUUID();\n            RpcApi.SetRTInfoCommand(TabRpcClient, {\n                oref: this.orefContext,\n                data: { \"waveai:chatid\": chatIdValue },\n            });\n        }\n        globalStore.set(this.chatId, chatIdValue);\n\n        const aiModeValue = rtInfo?.[\"waveai:mode\"];\n        if (aiModeValue == null) {\n            const defaultMode = globalStore.get(this.defaultModeAtom);\n            globalStore.set(this.currentAIMode, defaultMode);\n        } else if (this.isValidMode(aiModeValue)) {\n            globalStore.set(this.currentAIMode, aiModeValue);\n        } else {\n            this.setAIModeToDefault();\n        }\n\n        try {\n            return await this.reloadChatFromBackend(chatIdValue);\n        } catch (error) {\n            console.error(\"Failed to load chat:\", error);\n            this.setError(\"Failed to load chat. Starting new chat...\");\n\n            this.clearChat();\n            return [];\n        }\n    }\n\n    async handleSubmit() {\n        const input = globalStore.get(this.inputAtom);\n        const droppedFiles = globalStore.get(this.droppedFiles);\n\n        if (input.trim() === \"/clear\" || input.trim() === \"/new\") {\n            this.clearChat();\n            globalStore.set(this.inputAtom, \"\");\n            return;\n        }\n\n        if (\n            (!input.trim() && droppedFiles.length === 0) ||\n            (this.useChatStatus !== \"ready\" && this.useChatStatus !== \"error\") ||\n            globalStore.get(this.isLoadingChatAtom)\n        ) {\n            return;\n        }\n\n        this.clearError();\n\n        const aiMessageParts: AIMessagePart[] = [];\n        const uiMessageParts: WaveUIMessagePart[] = [];\n\n        if (input.trim()) {\n            aiMessageParts.push({ type: \"text\", text: input.trim() });\n            uiMessageParts.push({ type: \"text\", text: input.trim() });\n        }\n\n        for (const droppedFile of droppedFiles) {\n            const normalizedMimeType = normalizeMimeType(droppedFile.file);\n            const dataUrl = await createDataUrl(droppedFile.file);\n\n            aiMessageParts.push({\n                type: \"file\",\n                filename: droppedFile.name,\n                mimetype: normalizedMimeType,\n                url: dataUrl,\n                size: droppedFile.file.size,\n                previewurl: droppedFile.previewUrl,\n            });\n\n            uiMessageParts.push({\n                type: \"data-userfile\",\n                data: {\n                    filename: droppedFile.name,\n                    mimetype: normalizedMimeType,\n                    size: droppedFile.file.size,\n                    previewurl: droppedFile.previewUrl,\n                },\n            });\n        }\n\n        const realMessage: AIMessage = {\n            messageid: crypto.randomUUID(),\n            parts: aiMessageParts,\n        };\n        this.realMessage = realMessage;\n\n        // console.log(\"SUBMIT MESSAGE\", realMessage);\n\n        this.useChatSendMessage?.({ parts: uiMessageParts });\n\n        globalStore.set(this.isChatEmptyAtom, false);\n        globalStore.set(this.inputAtom, \"\");\n        this.clearFiles();\n    }\n\n    async uiLoadInitialChat() {\n        globalStore.set(this.isLoadingChatAtom, true);\n        const messages = await this.loadInitialChat();\n        this.useChatSetMessages?.(messages);\n        globalStore.set(this.isLoadingChatAtom, false);\n        setTimeout(() => {\n            this.scrollToBottom();\n        }, 100);\n    }\n\n    async ensureRateLimitSet() {\n        const currentInfo = globalStore.get(atoms.waveAIRateLimitInfoAtom);\n        if (currentInfo != null) {\n            return;\n        }\n        try {\n            const rateLimitInfo = await RpcApi.GetWaveAIRateLimitCommand(TabRpcClient);\n            if (rateLimitInfo != null) {\n                globalStore.set(atoms.waveAIRateLimitInfoAtom, rateLimitInfo);\n            }\n        } catch (error) {\n            console.error(\"Failed to fetch rate limit info:\", error);\n        }\n    }\n\n    handleAIFeedback(feedback: \"good\" | \"bad\") {\n        RpcApi.RecordTEventCommand(\n            TabRpcClient,\n            {\n                event: \"waveai:feedback\",\n                props: {\n                    \"waveai:feedback\": feedback,\n                },\n            },\n            { noresponse: true }\n        );\n    }\n\n    requestWaveAIFocus() {\n        if (this.inBuilder) {\n            BuilderFocusManager.getInstance().setWaveAIFocused();\n        } else {\n            FocusManager.getInstance().requestWaveAIFocus();\n        }\n    }\n\n    requestNodeFocus() {\n        if (this.inBuilder) {\n            BuilderFocusManager.getInstance().setAppFocused();\n        } else {\n            FocusManager.getInstance().requestNodeFocus();\n        }\n    }\n\n    getChatId(): string {\n        return globalStore.get(this.chatId);\n    }\n\n    toolUseSendApproval(toolcallid: string, approval: string) {\n        RpcApi.WaveAIToolApproveCommand(TabRpcClient, {\n            toolcallid: toolcallid,\n            approval: approval,\n        });\n    }\n\n    async openDiff(fileName: string, toolcallid: string) {\n        const chatId = this.getChatId();\n\n        if (!chatId || !fileName) {\n            console.error(\"Missing chatId or fileName for opening diff\", chatId, fileName);\n            return;\n        }\n\n        const blockDef: BlockDef = {\n            meta: {\n                view: \"aifilediff\",\n                file: fileName,\n                \"aifilediff:chatid\": chatId,\n                \"aifilediff:toolcallid\": toolcallid,\n            },\n        };\n        await createBlock(blockDef, false, true);\n    }\n\n    async openWaveAIConfig() {\n        const blockDef: BlockDef = {\n            meta: {\n                view: \"waveconfig\",\n                file: \"waveai.json\",\n            },\n        };\n        await createBlock(blockDef, false, true);\n    }\n\n    openRestoreBackupModal(toolcallid: string) {\n        globalStore.set(this.restoreBackupModalToolCallId, toolcallid);\n    }\n\n    closeRestoreBackupModal() {\n        globalStore.set(this.restoreBackupModalToolCallId, null);\n        globalStore.set(this.restoreBackupStatus, \"idle\");\n        globalStore.set(this.restoreBackupError, null);\n    }\n\n    async restoreBackup(toolcallid: string, backupFilePath: string, restoreToFileName: string) {\n        globalStore.set(this.restoreBackupStatus, \"processing\");\n        globalStore.set(this.restoreBackupError, null);\n        try {\n            await RpcApi.FileRestoreBackupCommand(TabRpcClient, {\n                backupfilepath: backupFilePath,\n                restoretofilename: restoreToFileName,\n            });\n            console.log(\"Backup restored successfully:\", { toolcallid, backupFilePath, restoreToFileName });\n            globalStore.set(this.restoreBackupStatus, \"success\");\n        } catch (error) {\n            console.error(\"Failed to restore backup:\", error);\n            const errorMsg = error?.message || String(error);\n            globalStore.set(this.restoreBackupError, errorMsg);\n            globalStore.set(this.restoreBackupStatus, \"error\");\n        }\n    }\n\n    canCloseWaveAIPanel(): boolean {\n        if (this.inBuilder) {\n            return false;\n        }\n        return true;\n    }\n\n    closeWaveAIPanel() {\n        if (this.inBuilder) {\n            return;\n        }\n        WorkspaceLayoutModel.getInstance().setAIPanelVisible(false);\n    }\n}\n"
  },
  {
    "path": "frontend/app/app-bg.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { PLATFORM, PlatformMacOS } from \"@/util/platformutil\";\nimport { computeBgStyleFromMeta } from \"@/util/waveutil\";\nimport useResizeObserver from \"@react-hook/resize-observer\";\nimport { useAtomValue } from \"jotai\";\nimport { CSSProperties, useCallback, useLayoutEffect, useRef } from \"react\";\nimport { debounce } from \"throttle-debounce\";\nimport { atoms, getApi, WOS } from \"./store/global\";\nimport { useWaveObjectValue } from \"./store/wos\";\n\nexport function AppBackground() {\n    const bgRef = useRef<HTMLDivElement>(null);\n    const tabId = useAtomValue(atoms.staticTabId);\n    const [tabData] = useWaveObjectValue<Tab>(WOS.makeORef(\"tab\", tabId));\n    const style: CSSProperties = computeBgStyleFromMeta(tabData?.meta, 0.5) ?? {};\n    const getAvgColor = useCallback(\n        debounce(30, () => {\n            if (\n                bgRef.current &&\n                PLATFORM !== PlatformMacOS &&\n                bgRef.current &&\n                \"windowControlsOverlay\" in window.navigator\n            ) {\n                const titlebarRect: Dimensions = (window.navigator.windowControlsOverlay as any).getTitlebarAreaRect();\n                const bgRect = bgRef.current.getBoundingClientRect();\n                if (titlebarRect && bgRect) {\n                    const windowControlsLeft = titlebarRect.width - titlebarRect.height;\n                    const windowControlsRect: Dimensions = {\n                        top: titlebarRect.top,\n                        left: windowControlsLeft,\n                        height: titlebarRect.height,\n                        width: bgRect.width - bgRect.left - windowControlsLeft,\n                    };\n                    getApi().updateWindowControlsOverlay(windowControlsRect);\n                }\n            }\n        }),\n        [bgRef, style]\n    );\n    useLayoutEffect(getAvgColor, [getAvgColor]);\n    useResizeObserver(bgRef, getAvgColor);\n\n    return <div ref={bgRef} className=\"pointer-events-none absolute top-0 left-0 w-full h-full z-[var(--zindex-app-background)]\" style={style} />;\n}\n"
  },
  {
    "path": "frontend/app/app.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n@use \"reset.scss\";\n@use \"theme.scss\";\n\nhtml {\n    overflow: hidden;\n}\n\nbody {\n    display: flex;\n    flex-direction: row;\n    width: 100vw;\n    height: 100vh;\n    color: var(--main-text-color);\n    font: var(--base-font);\n    overflow: hidden;\n    background: rgb(from var(--main-bg-color) r g b / var(--window-opacity));\n    -webkit-font-smoothing: auto;\n    backface-visibility: hidden;\n    transform: translateZ(0);\n}\n\n.is-transparent {\n    background-color: transparent;\n}\n\n*::-webkit-scrollbar {\n    width: 4px;\n    height: 4px;\n}\n\n*::-webkit-scrollbar-track {\n    background-color: var(--scrollbar-background-color);\n}\n\n*::-webkit-scrollbar-thumb {\n    background-color: var(--scrollbar-thumb-color);\n    border-radius: 4px;\n    margin: 0 1px 0 1px;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n    background-color: var(--scrollbar-thumb-hover-color);\n}\n\n.flex-spacer {\n    flex-grow: 1;\n}\n\n.text-fixed {\n    font: var(--fixed-font);\n}\n\n.error-boundary {\n    white-space: pre-wrap;\n    color: var(--error-color);\n}\n\n.error-color {\n    color: var(--error-color);\n}\n\n/* OverlayScrollbars styling */\n.os-scrollbar {\n    --os-handle-bg: var(--scrollbar-thumb-color);\n    --os-handle-bg-hover: var(--scrollbar-thumb-hover-color);\n    --os-handle-bg-active: var(--scrollbar-thumb-active-color);\n}\n\n.scrollbar-hide-until-hover {\n    *::-webkit-scrollbar-thumb,\n    *::-webkit-scrollbar-track {\n        display: none;\n    }\n\n    *::-webkit-scrollbar-corner {\n        display: none;\n    }\n\n    *:hover::-webkit-scrollbar-thumb {\n        display: block;\n    }\n}\n\na {\n    color: var(--accent-color);\n}\n\n.prefers-reduced-motion {\n    * {\n        transition-duration: none !important;\n        transition-timing-function: none !important;\n        transition-property: none !important;\n        transition-delay: none !important;\n    }\n}\n"
  },
  {
    "path": "frontend/app/app.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport {\n    clearBadgesForBlockOnFocus,\n    clearBadgesForTabOnFocus,\n    getBadgeAtom,\n    getBlockBadgeAtom,\n} from \"@/app/store/badge\";\nimport { ClientModel } from \"@/app/store/client-model\";\nimport { GlobalModel } from \"@/app/store/global-model\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { getTabModelByTabId, TabModelContext } from \"@/app/store/tab-model\";\nimport { WaveEnvContext } from \"@/app/waveenv/waveenv\";\nimport { makeWaveEnvImpl } from \"@/app/waveenv/waveenvimpl\";\nimport { Workspace } from \"@/app/workspace/workspace\";\nimport { getLayoutModelForStaticTab } from \"@/layout/index\";\nimport { ContextMenuModel } from \"@/store/contextmenu\";\nimport { atoms, createBlock, getSettingsPrefixAtom } from \"@/store/global\";\nimport { appHandleKeyDown, keyboardMouseDownHandler } from \"@/store/keymodel\";\nimport { getElemAsStr } from \"@/util/focusutil\";\nimport * as keyutil from \"@/util/keyutil\";\nimport { PLATFORM } from \"@/util/platformutil\";\nimport * as util from \"@/util/util\";\nimport clsx from \"clsx\";\nimport debug from \"debug\";\nimport { Provider, useAtomValue } from \"jotai\";\nimport \"overlayscrollbars/overlayscrollbars.css\";\nimport { useEffect, useRef } from \"react\";\nimport { DndProvider } from \"react-dnd\";\nimport { HTML5Backend } from \"react-dnd-html5-backend\";\nimport { AppBackground } from \"./app-bg\";\nimport { CenteredDiv } from \"./element/quickelems\";\n\nimport \"./app.scss\";\n\n// tailwindsetup.css should come *after* app.scss (don't remove the newline above otherwise prettier will reorder these imports)\nimport \"../tailwindsetup.css\";\n\nconst dlog = debug(\"wave:app\");\nconst focusLog = debug(\"wave:focus\");\n\nconst App = ({ onFirstRender }: { onFirstRender: () => void }) => {\n    const tabId = useAtomValue(atoms.staticTabId);\n    const waveEnvRef = useRef(makeWaveEnvImpl());\n    useEffect(() => {\n        onFirstRender();\n    }, []);\n    return (\n        <Provider store={globalStore}>\n            <WaveEnvContext.Provider value={waveEnvRef.current}>\n                <TabModelContext.Provider value={getTabModelByTabId(tabId)}>\n                    <AppInner />\n                </TabModelContext.Provider>\n            </WaveEnvContext.Provider>\n        </Provider>\n    );\n};\n\nfunction isContentEditableBeingEdited(): boolean {\n    const activeElement = document.activeElement;\n    return (\n        activeElement &&\n        activeElement.getAttribute(\"contenteditable\") !== null &&\n        activeElement.getAttribute(\"contenteditable\") !== \"false\"\n    );\n}\n\nfunction canEnablePaste(): boolean {\n    const activeElement = document.activeElement;\n    return activeElement.tagName === \"INPUT\" || activeElement.tagName === \"TEXTAREA\" || isContentEditableBeingEdited();\n}\n\nfunction canEnableCopy(): boolean {\n    const sel = window.getSelection();\n    return !util.isBlank(sel?.toString());\n}\n\nfunction canEnableCut(): boolean {\n    const sel = window.getSelection();\n    if (document.activeElement?.classList.contains(\"xterm-helper-textarea\")) {\n        return false;\n    }\n    return !util.isBlank(sel?.toString()) && canEnablePaste();\n}\n\nasync function getClipboardURL(): Promise<URL> {\n    try {\n        const clipboardText = await navigator.clipboard.readText();\n        if (clipboardText == null) {\n            return null;\n        }\n        const url = new URL(clipboardText);\n        if (!url.protocol.startsWith(\"http\")) {\n            return null;\n        }\n        return url;\n    } catch (e) {\n        return null;\n    }\n}\n\nasync function handleContextMenu(e: React.MouseEvent<HTMLDivElement>) {\n    e.preventDefault();\n    const canPaste = canEnablePaste();\n    const canCopy = canEnableCopy();\n    const canCut = canEnableCut();\n    const clipboardURL = await getClipboardURL();\n    if (!canPaste && !canCopy && !canCut && !clipboardURL) {\n        return;\n    }\n    const menu: ContextMenuItem[] = [];\n    if (canCut) {\n        menu.push({ label: \"Cut\", role: \"cut\" });\n    }\n    if (canCopy) {\n        menu.push({ label: \"Copy\", role: \"copy\" });\n    }\n    if (canPaste) {\n        menu.push({ label: \"Paste\", role: \"paste\" });\n    }\n    if (clipboardURL) {\n        menu.push({ type: \"separator\" });\n        menu.push({\n            label: \"Open Clipboard URL (\" + clipboardURL.hostname + \")\",\n            click: () => {\n                createBlock({\n                    meta: {\n                        view: \"web\",\n                        url: clipboardURL.toString(),\n                    },\n                });\n            },\n        });\n    }\n    ContextMenuModel.getInstance().showContextMenu(menu, e);\n}\n\nfunction AppSettingsUpdater() {\n    const windowSettingsAtom = getSettingsPrefixAtom(\"window\");\n    const windowSettings = useAtomValue(windowSettingsAtom);\n    useEffect(() => {\n        const isTransparentOrBlur =\n            (windowSettings?.[\"window:transparent\"] || windowSettings?.[\"window:blur\"]) ?? false;\n        const opacity = util.boundNumber(windowSettings?.[\"window:opacity\"] ?? 0.8, 0, 1);\n        const baseBgColor = windowSettings?.[\"window:bgcolor\"];\n        const mainDiv = document.getElementById(\"main\");\n        // console.log(\"window settings\", windowSettings, isTransparentOrBlur, opacity, baseBgColor, mainDiv);\n        if (isTransparentOrBlur) {\n            mainDiv.classList.add(\"is-transparent\");\n            if (opacity != null) {\n                document.body.style.setProperty(\"--window-opacity\", `${opacity}`);\n            } else {\n                document.body.style.removeProperty(\"--window-opacity\");\n            }\n        } else {\n            mainDiv.classList.remove(\"is-transparent\");\n            document.body.style.removeProperty(\"--window-opacity\");\n        }\n        if (baseBgColor != null) {\n            document.body.style.setProperty(\"--main-bg-color\", baseBgColor);\n        } else {\n            document.body.style.removeProperty(\"--main-bg-color\");\n        }\n    }, [windowSettings]);\n    return null;\n}\n\nfunction appFocusIn(e: FocusEvent) {\n    focusLog(\"focusin\", getElemAsStr(e.target), \"<=\", getElemAsStr(e.relatedTarget));\n}\n\nfunction appFocusOut(e: FocusEvent) {\n    focusLog(\"focusout\", getElemAsStr(e.target), \"=>\", getElemAsStr(e.relatedTarget));\n}\n\nfunction appSelectionChange(e: Event) {\n    const selection = document.getSelection();\n    focusLog(\"selectionchange\", getElemAsStr(selection.anchorNode));\n}\n\nfunction AppFocusHandler() {\n    return null;\n\n    // for debugging\n    useEffect(() => {\n        document.addEventListener(\"focusin\", appFocusIn);\n        document.addEventListener(\"focusout\", appFocusOut);\n        document.addEventListener(\"selectionchange\", appSelectionChange);\n        const ivId = setInterval(() => {\n            const activeElement = document.activeElement;\n            if (activeElement instanceof HTMLElement) {\n                focusLog(\"activeElement\", getElemAsStr(activeElement));\n            }\n        }, 2000);\n        return () => {\n            document.removeEventListener(\"focusin\", appFocusIn);\n            document.removeEventListener(\"focusout\", appFocusOut);\n            document.removeEventListener(\"selectionchange\", appSelectionChange);\n            clearInterval(ivId);\n        };\n    });\n    return null;\n}\n\nconst AppKeyHandlers = () => {\n    useEffect(() => {\n        const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown);\n        const staticMouseDownHandler = (e: MouseEvent) => {\n            keyboardMouseDownHandler(e);\n            GlobalModel.getInstance().setIsActive();\n        };\n        document.addEventListener(\"keydown\", staticKeyDownHandler);\n        document.addEventListener(\"mousedown\", staticMouseDownHandler);\n\n        return () => {\n            document.removeEventListener(\"keydown\", staticKeyDownHandler);\n            document.removeEventListener(\"mousedown\", staticMouseDownHandler);\n        };\n    }, []);\n    return null;\n};\n\nconst BadgeAutoClearing = () => {\n    const tabId = useAtomValue(atoms.staticTabId);\n    const documentHasFocus = useAtomValue(atoms.documentHasFocus);\n    const layoutModel = getLayoutModelForStaticTab();\n    const focusedNode = useAtomValue(layoutModel.focusedNode);\n    const focusedBlockId = focusedNode?.data?.blockId;\n    const badge = useAtomValue(getBlockBadgeAtom(focusedBlockId));\n    const tabTransientBadge = useAtomValue(getBadgeAtom(tabId != null ? `tab:${tabId}` : null));\n    const prevFocusedBlockIdRef = useRef<string>(null);\n    const prevDocHasFocusRef = useRef<boolean>(false);\n    const prevTabDocHasFocusRef = useRef<boolean>(false);\n\n    useEffect(() => {\n        if (!focusedBlockId || !badge || !documentHasFocus) {\n            prevFocusedBlockIdRef.current = focusedBlockId;\n            prevDocHasFocusRef.current = documentHasFocus;\n            return;\n        }\n        const focusSwitched =\n            prevFocusedBlockIdRef.current !== focusedBlockId || prevDocHasFocusRef.current !== documentHasFocus;\n        prevFocusedBlockIdRef.current = focusedBlockId;\n        prevDocHasFocusRef.current = documentHasFocus;\n        const delay = focusSwitched ? 500 : 3000;\n        const timeoutId = setTimeout(() => {\n            if (!document.hasFocus()) {\n                return;\n            }\n            const currentFocusedNode = globalStore.get(layoutModel.focusedNode);\n            if (currentFocusedNode?.data?.blockId === focusedBlockId) {\n                clearBadgesForBlockOnFocus(focusedBlockId);\n            }\n        }, delay);\n        return () => clearTimeout(timeoutId);\n    }, [focusedBlockId, badge, documentHasFocus]);\n\n    useEffect(() => {\n        if (!tabId || !tabTransientBadge || !documentHasFocus) {\n            prevTabDocHasFocusRef.current = documentHasFocus;\n            return;\n        }\n        const focusSwitched = prevTabDocHasFocusRef.current !== documentHasFocus;\n        prevTabDocHasFocusRef.current = documentHasFocus;\n        const delay = focusSwitched ? 500 : 3000;\n        const timeoutId = setTimeout(() => {\n            if (!document.hasFocus()) {\n                return;\n            }\n            clearBadgesForTabOnFocus(tabId);\n        }, delay);\n        return () => clearTimeout(timeoutId);\n    }, [tabId, tabTransientBadge, documentHasFocus]);\n\n    return null;\n};\n\nconst AppInner = () => {\n    const prefersReducedMotion = useAtomValue(atoms.prefersReducedMotionAtom);\n    const client = useAtomValue(ClientModel.getInstance().clientAtom);\n    const windowData = useAtomValue(GlobalModel.getInstance().windowDataAtom);\n    const isFullScreen = useAtomValue(atoms.isFullScreen);\n\n    if (client == null || windowData == null) {\n        return (\n            <div className=\"flex flex-col w-full h-full\">\n                <AppBackground />\n                <CenteredDiv>invalid configuration, client or window was not loaded</CenteredDiv>\n            </div>\n        );\n    }\n\n    return (\n        <div\n            className={clsx(\"flex flex-col w-full h-full\", PLATFORM, {\n                fullscreen: isFullScreen,\n                \"prefers-reduced-motion\": prefersReducedMotion,\n            })}\n            onContextMenu={handleContextMenu}\n        >\n            <AppBackground />\n            <AppKeyHandlers />\n            <AppFocusHandler />\n            <AppSettingsUpdater />\n            <BadgeAutoClearing />\n            <DndProvider backend={HTML5Backend}>\n                <Workspace />\n            </DndProvider>\n        </div>\n    );\n};\n\nexport { App };\n"
  },
  {
    "path": "frontend/app/block/block-model.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport * as jotai from \"jotai\";\n\nexport interface BlockHighlightType {\n    blockId: string;\n    icon: string;\n}\n\nexport class BlockModel {\n    private static instance: BlockModel | null = null;\n    private blockHighlightAtomCache = new Map<string, jotai.Atom<BlockHighlightType | null>>();\n\n    blockHighlightAtom: jotai.PrimitiveAtom<BlockHighlightType> = jotai.atom(null) as jotai.PrimitiveAtom<BlockHighlightType>;\n\n    private constructor() {\n        // Empty for now\n    }\n\n    getBlockHighlightAtom(blockId: string): jotai.Atom<BlockHighlightType | null> {\n        let atom = this.blockHighlightAtomCache.get(blockId);\n        if (!atom) {\n            atom = jotai.atom((get) => {\n                const highlight = get(this.blockHighlightAtom);\n                if (highlight?.blockId === blockId) {\n                    return highlight;\n                }\n                return null;\n            });\n            this.blockHighlightAtomCache.set(blockId, atom);\n        }\n        return atom;\n    }\n\n    setBlockHighlight(highlight: BlockHighlightType | null) {\n        globalStore.set(this.blockHighlightAtom, highlight);\n    }\n\n    static getInstance(): BlockModel {\n        if (!BlockModel.instance) {\n            BlockModel.instance = new BlockModel();\n        }\n        return BlockModel.instance;\n    }\n\n    static resetInstance(): void {\n        BlockModel.instance = null;\n    }\n}"
  },
  {
    "path": "frontend/app/block/block.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.block {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    min-height: 0;\n    border-radius: var(--block-border-radius);\n\n    .block-frame-icon {\n        margin-right: 0.5em;\n    }\n\n    .block-content {\n        position: relative;\n        display: flex;\n        flex-grow: 1;\n        width: 100%;\n        overflow: hidden;\n        min-height: 0;\n        padding: 5px;\n\n        &.block-no-padding {\n            padding: 0;\n        }\n    }\n\n    .block-focuselem {\n        height: 0;\n        width: 0;\n        input {\n            width: 0;\n            height: 0;\n            opacity: 0;\n            pointer-events: none;\n        }\n    }\n\n    .block-header-animation-wrap {\n        max-height: 0;\n        transition:\n            max-height 0.3s ease-out,\n            opacity 0.3s ease-out;\n        overflow: hidden;\n        position: absolute;\n        top: 0;\n        width: 100%;\n        height: 30px;\n        z-index: var(--zindex-header-hover);\n\n        &.is-showing {\n            max-height: 30px;\n        }\n    }\n\n    &.block-preview.block-frame-default .block-frame-default-inner .block-frame-default-header {\n        background-color: rgb(from var(--block-bg-color) r g b / 70%);\n    }\n\n    &.block-frame-default {\n        position: relative;\n        padding: 1px;\n\n        .block-frame-default-inner {\n            background-color: var(--block-bg-color);\n            width: 100%;\n            height: 100%;\n            border-radius: var(--block-border-radius);\n            display: flex;\n            flex-direction: column;\n\n            .block-frame-default-header {\n                max-height: var(--header-height);\n                min-height: var(--header-height);\n                display: flex;\n                padding: 4px 5px 4px 7px;\n                align-items: center;\n                gap: 8px;\n                font: var(--header-font);\n                border-bottom: 1px solid var(--border-color);\n                border-radius: var(--block-border-radius) var(--block-border-radius) 0 0;\n\n                .block-frame-default-header-iconview {\n                    display: flex;\n                    flex-shrink: 3;\n                    min-width: 17px;\n                    align-items: center;\n                    gap: 8px;\n                    overflow-x: hidden;\n\n                    .block-frame-view-icon {\n                        font-size: var(--header-icon-size);\n                        opacity: 0.5;\n                        width: var(--header-icon-width);\n                        i {\n                            margin-right: 0;\n                        }\n                    }\n\n                    .block-frame-view-type {\n                        overflow-x: hidden;\n                        text-wrap: nowrap;\n                        text-overflow: ellipsis;\n                        flex-shrink: 1;\n                        min-width: 0;\n                    }\n\n                    .block-frame-blockid {\n                        opacity: 0.5;\n                    }\n                }\n\n                .block-frame-text {\n                    font: var(--fixed-font);\n                    font-size: 11px;\n                    opacity: 0.7;\n                    flex-grow: 1;\n\n                    &.flex-nogrow {\n                        flex-grow: 0;\n                    }\n\n                    &.preview-filename {\n                        direction: rtl;\n                        text-align: left;\n                        span {\n                            cursor: pointer;\n\n                            &:hover {\n                                background: var(--highlight-bg-color);\n                            }\n                        }\n                    }\n                }\n\n                .connection-button {\n                    display: flex;\n                    align-items: center;\n                    flex-wrap: nowrap;\n                    overflow: hidden;\n                    text-overflow: ellipsis;\n                    min-width: 0;\n                    font-weight: 400;\n                    color: var(--main-text-color);\n                    border-radius: 2px;\n                    padding: auto;\n\n                    &:hover {\n                        background-color: var(--highlight-bg-color);\n                    }\n\n                    .connection-icon-box {\n                        flex: 1 1 auto;\n                        overflow: hidden;\n                    }\n\n                    .connection-name {\n                        flex: 1 2 auto;\n                        overflow: hidden;\n                        padding-right: 4px;\n                    }\n\n                    .connecting-svg {\n                        position: relative;\n                        top: 5px;\n                        left: 9px;\n                        svg {\n                            fill: var(--warning-color);\n                        }\n                    }\n                }\n\n                .block-frame-textelems-wrapper {\n                    display: flex;\n                    flex: 1 2 auto;\n                    min-width: 0;\n                    gap: 8px;\n                    align-items: center;\n\n                    .block-frame-div {\n                        display: flex;\n                        width: 100%;\n                        height: 100%;\n                        justify-content: space-between;\n                        align-items: center;\n\n                        .input-wrapper {\n                            flex-grow: 1;\n\n                            input {\n                                background-color: transparent;\n                                outline: none;\n                                border: none;\n                                color: var(--main-text-color);\n                                width: 100%;\n                                white-space: nowrap;\n                                overflow: hidden;\n                                text-overflow: ellipsis;\n                                box-sizing: border-box;\n                                opacity: 0.7;\n                                font-weight: 500;\n                            }\n                        }\n\n                        .wave-button {\n                            margin-left: 3px;\n                        }\n\n                        // webview specific. for refresh button\n                        .wave-iconbutton {\n                            height: 100%;\n                            width: 27px;\n                            display: flex;\n                            align-items: center;\n                            justify-content: center;\n                        }\n                    }\n\n                    .menubutton {\n                        .wave-button {\n                            font-size: 11px;\n                        }\n                    }\n                }\n\n                .block-frame-end-icons {\n                    display: flex;\n                    flex-shrink: 0;\n\n                    .wave-iconbutton {\n                        display: flex;\n                        width: 24px;\n                        padding: 4px 6px;\n                        align-items: center;\n                    }\n\n                    .block-frame-magnify {\n                        justify-content: center;\n                        align-items: center;\n                        padding: 0;\n\n                        svg {\n                            #arrow1,\n                            #arrow2 {\n                                fill: var(--main-text-color);\n                            }\n                        }\n                    }\n                }\n            }\n\n            .block-frame-preview {\n                background-color: rgb(from var(--block-bg-color) r g b / 70%);\n                width: 100%;\n                flex-grow: 1;\n                border-bottom-left-radius: var(--block-border-radius);\n                border-bottom-right-radius: var(--block-border-radius);\n                display: flex;\n                align-items: center;\n                justify-content: center;\n\n                .wave-iconbutton {\n                    opacity: 0.7;\n                    font-size: 45px;\n                    margin: -30px 0 0 0;\n                }\n            }\n        }\n\n        --magnified-block-opacity: 0.6;\n        --magnified-block-blur: 10px;\n\n        &.magnified,\n        &.ephemeral {\n            background-color: rgb(from var(--block-bg-color) r g b / var(--magnified-block-opacity));\n            backdrop-filter: blur(var(--magnified-block-blur));\n        }\n\n        .connstatus-overlay {\n            position: absolute;\n            top: calc(var(--header-height) + 6px);\n            left: 8px;\n            right: 8px;\n            z-index: var(--zindex-block-mask-inner);\n            display: flex;\n            align-items: center;\n            justify-content: flex-start;\n            flex-direction: column;\n            overflow: hidden;\n            background: var(--conn-status-overlay-bg-color);\n            backdrop-filter: blur(50px);\n            border-radius: 6px;\n            box-shadow: 0px 13px 16px 0px rgb(from var(--block-bg-color) r g b / 40%);\n            opacity: 0.9;\n\n            .connstatus-content {\n                display: flex;\n                flex-direction: row;\n                justify-content: space-between;\n                padding: 10px 8px 10px 12px;\n                width: 100%;\n                font: var(--base-font);\n                color: var(--secondary-text-color);\n\n                .connstatus-status-icon-wrapper {\n                    display: flex;\n                    flex-direction: row;\n                    align-items: center;\n                    gap: 12px;\n                    flex-grow: 1;\n                    min-width: 0;\n\n                    &.has-error {\n                        align-items: flex-start;\n                    }\n\n                    > i {\n                        color: #e6ba1e;\n                        font-size: 16px;\n                    }\n\n                    .connstatus-status {\n                        display: flex;\n                        flex-direction: column;\n                        align-items: flex-start;\n                        gap: 4px;\n                        flex-grow: 1;\n                        width: 100%;\n\n                        .connstatus-status-text {\n                            max-width: 100%;\n                            font-size: 11px;\n                            font-style: normal;\n                            font-weight: 600;\n                            line-height: 16px;\n                            letter-spacing: 0.11px;\n                            color: white;\n                        }\n\n                        .connstatus-error {\n                            font-size: 11px;\n                            font-style: normal;\n                            font-weight: 400;\n                            line-height: 15px;\n                            letter-spacing: 0.11px;\n                            text-wrap: wrap;\n                            max-height: 80px;\n                            border-radius: 8px;\n                            padding: 5px;\n                            padding-left: 0;\n                            position: relative;\n\n                            .copy-button {\n                                visibility: hidden;\n                                display: flex;\n                                position: sticky;\n                                top: 0;\n                                right: 4px;\n                                float: right;\n                                border-radius: 4px;\n                                backdrop-filter: blur(8px);\n                                padding: 0.286em;\n                                align-items: center;\n                                justify-content: flex-end;\n                                gap: 0.286em;\n                            }\n\n                            &:hover .copy-button {\n                                visibility: visible;\n                            }\n                        }\n                    }\n                }\n\n                .connstatus-actions {\n                    display: flex;\n                    align-items: flex-start;\n                    justify-content: center;\n                    gap: 6px;\n\n                    button {\n                        i {\n                            font-size: 11px;\n                            opacity: 0.7;\n                        }\n                    }\n\n                    .wave-button:last-child {\n                        margin-top: 1.5px;\n                    }\n                }\n            }\n        }\n\n        .block-mask {\n            position: absolute;\n            top: 0;\n            left: 0;\n            right: 0;\n            bottom: 0;\n            border: 2px solid transparent;\n            pointer-events: none;\n            padding: 2px;\n            border-radius: var(--block-border-radius);\n            z-index: var(--zindex-block-mask-inner);\n\n            &.show-block-mask {\n                user-select: none;\n                pointer-events: auto;\n            }\n\n            &.show-block-mask .block-mask-inner {\n                margin-top: var(--header-height); // TODO fix this magic\n                background-color: rgb(from var(--block-bg-color) r g b / 50%);\n                height: calc(100% - var(--header-height));\n                width: 100%;\n                display: flex;\n                align-items: center;\n                justify-content: center;\n\n                .bignum {\n                    margin-top: -15%;\n                    font-size: 60px;\n                    font-weight: bold;\n                    opacity: 0.7;\n                }\n            }\n        }\n\n        &.block-focused {\n            .block-mask {\n                border: 2px solid var(--accent-color);\n            }\n\n            &.block-no-highlight,\n            &.block-preview {\n                .block-mask {\n                    border: 2px solid rgb(from var(--border-color) r g b / 10%) !important;\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/block/block.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport {\n    BlockComponentModel2,\n    BlockNodeModel,\n    BlockProps,\n    FullBlockProps,\n    FullSubBlockProps,\n    SubBlockProps,\n} from \"@/app/block/blocktypes\";\nimport type { TabModel } from \"@/app/store/tab-model\";\nimport { useTabModel } from \"@/app/store/tab-model\";\nimport { AiFileDiffViewModel } from \"@/app/view/aifilediff/aifilediff\";\nimport { LauncherViewModel } from \"@/app/view/launcher/launcher\";\nimport { PreviewModel } from \"@/app/view/preview/preview-model\";\nimport { SysinfoViewModel } from \"@/app/view/sysinfo/sysinfo\";\nimport { TsunamiViewModel } from \"@/app/view/tsunami/tsunami\";\nimport { VDomModel } from \"@/app/view/vdom/vdom-model\";\nimport { useWaveEnv, WaveEnv } from \"@/app/waveenv/waveenv\";\nimport { ErrorBoundary } from \"@/element/errorboundary\";\nimport { CenteredDiv } from \"@/element/quickelems\";\nimport { useDebouncedNodeInnerRect } from \"@/layout/index\";\nimport { counterInc } from \"@/store/counters\";\nimport { getBlockComponentModel, registerBlockComponentModel, unregisterBlockComponentModel } from \"@/store/global\";\nimport { makeORef } from \"@/store/wos\";\nimport { focusedBlockId, getElemAsStr } from \"@/util/focusutil\";\nimport { isBlank, useAtomValueSafe } from \"@/util/util\";\nimport { HelpViewModel } from \"@/view/helpview/helpview\";\nimport { TermViewModel } from \"@/view/term/term-model\";\nimport { WaveAiModel } from \"@/view/waveai/waveai\";\nimport { WebViewModel } from \"@/view/webview/webview\";\nimport clsx from \"clsx\";\nimport { atom, useAtomValue } from \"jotai\";\nimport { memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { QuickTipsViewModel } from \"../view/quicktipsview/quicktipsview\";\nimport { WaveConfigViewModel } from \"../view/waveconfig/waveconfig-model\";\nimport \"./block.scss\";\nimport { BlockEnv } from \"./blockenv\";\nimport { BlockFrame } from \"./blockframe\";\nimport { blockViewToIcon, blockViewToName } from \"./blockutil\";\n\nconst BlockRegistry: Map<string, ViewModelClass> = new Map();\nBlockRegistry.set(\"term\", TermViewModel);\nBlockRegistry.set(\"preview\", PreviewModel);\nBlockRegistry.set(\"web\", WebViewModel);\nBlockRegistry.set(\"waveai\", WaveAiModel);\nBlockRegistry.set(\"cpuplot\", SysinfoViewModel);\nBlockRegistry.set(\"sysinfo\", SysinfoViewModel);\nBlockRegistry.set(\"vdom\", VDomModel);\nBlockRegistry.set(\"tips\", QuickTipsViewModel);\nBlockRegistry.set(\"help\", HelpViewModel);\nBlockRegistry.set(\"launcher\", LauncherViewModel);\nBlockRegistry.set(\"tsunami\", TsunamiViewModel);\nBlockRegistry.set(\"aifilediff\", AiFileDiffViewModel);\nBlockRegistry.set(\"waveconfig\", WaveConfigViewModel);\n\nfunction makeViewModel(\n    blockId: string,\n    blockView: string,\n    nodeModel: BlockNodeModel,\n    tabModel: TabModel,\n    waveEnv: WaveEnv\n): ViewModel {\n    const ctor = BlockRegistry.get(blockView);\n    if (ctor != null) {\n        return new ctor({ blockId, nodeModel, tabModel, waveEnv });\n    }\n    return makeDefaultViewModel(blockView);\n}\n\nfunction getViewElem(\n    blockId: string,\n    blockRef: React.RefObject<HTMLDivElement>,\n    contentRef: React.RefObject<HTMLDivElement>,\n    blockView: string,\n    viewModel: ViewModel\n): React.ReactElement {\n    if (isBlank(blockView)) {\n        return <CenteredDiv>No View</CenteredDiv>;\n    }\n    if (viewModel.viewComponent == null) {\n        return <CenteredDiv>No View Component</CenteredDiv>;\n    }\n    const VC = viewModel.viewComponent;\n    return <VC key={blockId} blockId={blockId} blockRef={blockRef} contentRef={contentRef} model={viewModel} />;\n}\n\nfunction makeDefaultViewModel(viewType: string): ViewModel {\n    const viewModel: ViewModel = {\n        viewType: viewType,\n        viewIcon: atom(blockViewToIcon(viewType)),\n        viewName: atom(blockViewToName(viewType)),\n        preIconButton: atom(null),\n        endIconButtons: atom(null),\n        viewComponent: null,\n    };\n    return viewModel;\n}\n\nconst BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => {\n    const waveEnv = useWaveEnv<BlockEnv>();\n    const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef(\"block\", nodeModel.blockId)));\n    if (blockIsNull) {\n        return null;\n    }\n    return (\n        <BlockFrame\n            key={nodeModel.blockId}\n            nodeModel={nodeModel}\n            preview={true}\n            blockModel={null}\n            viewModel={viewModel}\n        />\n    );\n});\n\nconst BlockSubBlock = memo(({ nodeModel, viewModel }: FullSubBlockProps) => {\n    const waveEnv = useWaveEnv<BlockEnv>();\n    const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef(\"block\", nodeModel.blockId)));\n    const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, \"view\")) ?? \"\";\n    const blockRef = useRef<HTMLDivElement>(null);\n    const contentRef = useRef<HTMLDivElement>(null);\n    const viewElem = useMemo(\n        () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockView, viewModel),\n        [nodeModel.blockId, blockView, viewModel]\n    );\n    const noPadding = useAtomValueSafe(viewModel.noPadding);\n    if (blockIsNull) {\n        return null;\n    }\n    return (\n        <div key=\"content\" className={clsx(\"block-content\", { \"block-no-padding\": noPadding })} ref={contentRef}>\n            <ErrorBoundary>\n                <Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{viewElem}</Suspense>\n            </ErrorBoundary>\n        </div>\n    );\n});\n\nconst BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {\n    counterInc(\"render-BlockFull\");\n    const waveEnv = useWaveEnv<BlockEnv>();\n    const focusElemRef = useRef<HTMLInputElement>(null);\n    const blockRef = useRef<HTMLDivElement>(null);\n    const contentRef = useRef<HTMLDivElement>(null);\n    const [blockClicked, setBlockClicked] = useState(false);\n    const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, \"view\")) ?? \"\";\n    const isFocused = useAtomValue(nodeModel.isFocused);\n    const disablePointerEvents = useAtomValue(nodeModel.disablePointerEvents);\n    const isResizing = useAtomValue(nodeModel.isResizing);\n    const isMagnified = useAtomValue(nodeModel.isMagnified);\n    const anyMagnified = useAtomValue(nodeModel.anyMagnified);\n    const modalOpen = useAtomValue(waveEnv.atoms.modalOpen);\n    const focusFollowsCursorMode = useAtomValue(waveEnv.getSettingsKeyAtom(\"app:focusfollowscursor\")) ?? \"off\";\n    const innerRect = useDebouncedNodeInnerRect(nodeModel);\n    const noPadding = useAtomValueSafe(viewModel.noPadding);\n\n    useLayoutEffect(() => {\n        setBlockClicked(isFocused);\n    }, [isFocused]);\n\n    useLayoutEffect(() => {\n        if (!blockClicked) {\n            return;\n        }\n        setBlockClicked(false);\n        const focusWithin = focusedBlockId() == nodeModel.blockId;\n        if (!focusWithin) {\n            setFocusTarget();\n        }\n        if (!isFocused) {\n            nodeModel.focusNode();\n        }\n    }, [blockClicked, isFocused]);\n\n    const setBlockClickedTrue = useCallback(() => {\n        setBlockClicked(true);\n    }, []);\n\n    const [blockContentOffset, setBlockContentOffset] = useState<Dimensions>();\n\n    useEffect(() => {\n        if (blockRef.current && contentRef.current) {\n            const blockRect = blockRef.current.getBoundingClientRect();\n            const contentRect = contentRef.current.getBoundingClientRect();\n            setBlockContentOffset({\n                top: 0,\n                left: 0,\n                width: blockRect.width - contentRect.width,\n                height: blockRect.height - contentRect.height,\n            });\n        }\n    }, [blockRef, contentRef]);\n\n    const blockContentStyle = useMemo<React.CSSProperties>(() => {\n        const retVal: React.CSSProperties = {\n            pointerEvents: disablePointerEvents ? \"none\" : undefined,\n        };\n        if (innerRect?.width && innerRect.height && blockContentOffset) {\n            retVal.width = `calc(${innerRect?.width} - ${blockContentOffset.width}px)`;\n            retVal.height = `calc(${innerRect?.height} - ${blockContentOffset.height}px)`;\n        }\n        return retVal;\n    }, [innerRect, disablePointerEvents, blockContentOffset]);\n\n    const viewElem = useMemo(\n        () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockView, viewModel),\n        [nodeModel.blockId, blockView, viewModel]\n    );\n\n    const handleChildFocus = useCallback(\n        (event: React.FocusEvent<HTMLDivElement, Element>) => {\n            console.log(\"setFocusedChild\", nodeModel.blockId, getElemAsStr(event.target));\n            if (!isFocused) {\n                console.log(\"focusedChild focus\", nodeModel.blockId);\n                nodeModel.focusNode();\n            }\n        },\n        [isFocused]\n    );\n\n    const setFocusTarget = useCallback(() => {\n        const ok = viewModel?.giveFocus?.();\n        if (ok) {\n            return;\n        }\n        focusElemRef.current?.focus({ preventScroll: true });\n    }, [viewModel]);\n\n    const focusFromPointerEnter = useCallback(\n        (event: React.PointerEvent<HTMLDivElement>) => {\n            const focusFollowsCursorEnabled =\n                focusFollowsCursorMode === \"on\" ||\n                (focusFollowsCursorMode === \"term\" && blockView === \"term\");\n            if (!focusFollowsCursorEnabled || event.pointerType === \"touch\" || event.buttons > 0) {\n                return;\n            }\n            if (modalOpen || disablePointerEvents || isResizing || (anyMagnified && !isMagnified)) {\n                return;\n            }\n            if (isFocused && focusedBlockId() === nodeModel.blockId) {\n                return;\n            }\n            setFocusTarget();\n            if (!isFocused) {\n                nodeModel.focusNode();\n            }\n        },\n        [\n            focusFollowsCursorMode,\n            blockView,\n            modalOpen,\n            disablePointerEvents,\n            isResizing,\n            isMagnified,\n            anyMagnified,\n            isFocused,\n            nodeModel,\n            setFocusTarget,\n        ]\n    );\n\n    const blockModel = useMemo<BlockComponentModel2>(\n        () => ({\n            onClick: setBlockClickedTrue,\n            onPointerEnter: focusFromPointerEnter,\n            onFocusCapture: handleChildFocus,\n            blockRef: blockRef,\n        }),\n        [setBlockClickedTrue, focusFromPointerEnter, handleChildFocus, blockRef]\n    );\n\n    return (\n        <BlockFrame\n            key={nodeModel.blockId}\n            nodeModel={nodeModel}\n            preview={false}\n            blockModel={blockModel}\n            viewModel={viewModel}\n        >\n            <div key=\"focuselem\" className=\"block-focuselem\">\n                <input\n                    type=\"text\"\n                    value=\"\"\n                    ref={focusElemRef}\n                    id={`${nodeModel.blockId}-dummy-focus`} // don't change this name (used in refocusNode)\n                    className=\"dummy-focus\"\n                    onChange={() => {}}\n                />\n            </div>\n            <div\n                key=\"content\"\n                className={clsx(\"block-content\", { \"block-no-padding\": noPadding })}\n                ref={contentRef}\n                style={blockContentStyle}\n            >\n                <ErrorBoundary>\n                    <Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{viewElem}</Suspense>\n                </ErrorBoundary>\n            </div>\n        </BlockFrame>\n    );\n});\n\nconst BlockInner = memo((props: BlockProps & { viewType: string }) => {\n    counterInc(\"render-Block\");\n    counterInc(\"render-Block-\" + props.nodeModel?.blockId?.substring(0, 8));\n    const tabModel = useTabModel();\n    const waveEnv = useWaveEnv();\n    const bcm = getBlockComponentModel(props.nodeModel.blockId);\n    let viewModel = bcm?.viewModel;\n    if (viewModel == null) {\n        // viewModel gets the full waveEnv\n        viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv);\n        registerBlockComponentModel(props.nodeModel.blockId, { viewModel });\n    }\n    useEffect(() => {\n        return () => {\n            unregisterBlockComponentModel(props.nodeModel.blockId);\n            viewModel?.dispose?.();\n        };\n    }, []);\n    if (props.preview) {\n        return <BlockPreview {...props} viewModel={viewModel} />;\n    }\n    return <BlockFull {...props} viewModel={viewModel} />;\n});\nBlockInner.displayName = \"BlockInner\";\n\nconst Block = memo((props: BlockProps) => {\n    const waveEnv = useWaveEnv<BlockEnv>();\n    const isNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef(\"block\", props.nodeModel.blockId)));\n    const viewType = useAtomValue(waveEnv.getBlockMetaKeyAtom(props.nodeModel.blockId, \"view\")) ?? \"\";\n    if (isNull || isBlank(props.nodeModel.blockId)) {\n        return null;\n    }\n    return <BlockInner key={props.nodeModel.blockId + \":\" + viewType} {...props} viewType={viewType} />;\n});\n\nconst SubBlockInner = memo((props: SubBlockProps & { viewType: string }) => {\n    counterInc(\"render-Block\");\n    counterInc(\"render-Block-\" + props.nodeModel.blockId?.substring(0, 8));\n    const tabModel = useTabModel();\n    const waveEnv = useWaveEnv();\n    const bcm = getBlockComponentModel(props.nodeModel.blockId);\n    let viewModel = bcm?.viewModel;\n    if (viewModel == null) {\n        // viewModel gets the full waveEnv\n        viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv);\n        registerBlockComponentModel(props.nodeModel.blockId, { viewModel });\n    }\n    useEffect(() => {\n        return () => {\n            unregisterBlockComponentModel(props.nodeModel.blockId);\n            viewModel?.dispose?.();\n        };\n    }, []);\n    return <BlockSubBlock {...props} viewModel={viewModel} />;\n});\nSubBlockInner.displayName = \"SubBlockInner\";\n\nconst SubBlock = memo((props: SubBlockProps) => {\n    const waveEnv = useWaveEnv<BlockEnv>();\n    const isNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef(\"block\", props.nodeModel.blockId)));\n    const viewType = useAtomValue(waveEnv.getBlockMetaKeyAtom(props.nodeModel.blockId, \"view\")) ?? \"\";\n    if (isNull || isBlank(props.nodeModel.blockId)) {\n        return null;\n    }\n    return <SubBlockInner key={props.nodeModel.blockId + \":\" + viewType} {...props} viewType={viewType} />;\n});\n\nexport { Block, SubBlock };\n"
  },
  {
    "path": "frontend/app/block/blockenv.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport {\n    BlockMetaKeyAtomFnType,\n    ConnConfigKeyAtomFnType,\n    SettingsKeyAtomFnType,\n    WaveEnv,\n    WaveEnvSubset,\n} from \"@/app/waveenv/waveenv\";\n\nexport type BlockEnv = WaveEnvSubset<{\n    getSettingsKeyAtom: SettingsKeyAtomFnType<\n        | \"app:focusfollowscursor\"\n        | \"app:showoverlayblocknums\"\n        | \"window:magnifiedblockblurprimarypx\"\n        | \"window:magnifiedblockopacity\"\n    >;\n    showContextMenu: WaveEnv[\"showContextMenu\"];\n    atoms: {\n        modalOpen: WaveEnv[\"atoms\"][\"modalOpen\"];\n        controlShiftDelayAtom: WaveEnv[\"atoms\"][\"controlShiftDelayAtom\"];\n    };\n    electron: {\n        openExternal: WaveEnv[\"electron\"][\"openExternal\"];\n    };\n    rpc: {\n        ActivityCommand: WaveEnv[\"rpc\"][\"ActivityCommand\"];\n        ConnEnsureCommand: WaveEnv[\"rpc\"][\"ConnEnsureCommand\"];\n        ConnDisconnectCommand: WaveEnv[\"rpc\"][\"ConnDisconnectCommand\"];\n        ConnConnectCommand: WaveEnv[\"rpc\"][\"ConnConnectCommand\"];\n        SetConnectionsConfigCommand: WaveEnv[\"rpc\"][\"SetConnectionsConfigCommand\"];\n        DismissWshFailCommand: WaveEnv[\"rpc\"][\"DismissWshFailCommand\"];\n    };\n    wos: WaveEnv[\"wos\"];\n    getConnStatusAtom: WaveEnv[\"getConnStatusAtom\"];\n    getLocalHostDisplayNameAtom: WaveEnv[\"getLocalHostDisplayNameAtom\"];\n    getConnConfigKeyAtom: ConnConfigKeyAtomFnType<\"conn:wshenabled\">;\n    getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<\n        | \"frame:text\"\n        | \"frame:activebordercolor\"\n        | \"frame:bordercolor\"\n        | \"view\"\n        | \"connection\"\n        | \"icon:color\"\n        | \"frame:title\"\n        | \"frame:icon\"\n    >;\n}>;\n"
  },
  {
    "path": "frontend/app/block/blockframe-header.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport {\n    blockViewToIcon,\n    blockViewToName,\n    getViewIconElem,\n    OptMagnifyButton,\n    renderHeaderElements,\n} from \"@/app/block/blockutil\";\nimport { ConnectionButton } from \"@/app/block/connectionbutton\";\nimport { DurableSessionFlyover } from \"@/app/block/durable-session-flyover\";\nimport { getBlockBadgeAtom } from \"@/app/store/badge\";\nimport { recordTEvent, refocusNode } from \"@/app/store/global\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { uxCloseBlock } from \"@/app/store/keymodel\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport { IconButton } from \"@/element/iconbutton\";\nimport { NodeModel } from \"@/layout/index\";\nimport * as util from \"@/util/util\";\nimport { cn, makeIconClass } from \"@/util/util\";\nimport * as jotai from \"jotai\";\nimport * as React from \"react\";\nimport { BlockEnv } from \"./blockenv\";\nimport { BlockFrameProps } from \"./blocktypes\";\n\nfunction handleHeaderContextMenu(\n    e: React.MouseEvent<HTMLDivElement>,\n    blockId: string,\n    viewModel: ViewModel,\n    nodeModel: NodeModel,\n    blockEnv: BlockEnv\n) {\n    e.preventDefault();\n    e.stopPropagation();\n    const magnified = globalStore.get(nodeModel.isMagnified);\n    const menu: ContextMenuItem[] = [\n        {\n            label: magnified ? \"Un-Magnify Block\" : \"Magnify Block\",\n            click: () => {\n                nodeModel.toggleMagnify();\n            },\n        },\n        { type: \"separator\" },\n        {\n            label: \"Copy BlockId\",\n            click: () => {\n                navigator.clipboard.writeText(blockId);\n            },\n        },\n    ];\n    const extraItems = viewModel?.getSettingsMenuItems?.();\n    if (extraItems && extraItems.length > 0) menu.push({ type: \"separator\" }, ...extraItems);\n    menu.push(\n        { type: \"separator\" },\n        {\n            label: \"Close Block\",\n            click: () => uxCloseBlock(blockId),\n        }\n    );\n    blockEnv.showContextMenu(menu, e);\n}\n\ntype HeaderTextElemsProps = {\n    viewModel: ViewModel;\n    blockId: string;\n    preview: boolean;\n    error?: Error;\n};\n\nconst HeaderTextElems = React.memo(({ viewModel, blockId, preview, error }: HeaderTextElemsProps) => {\n    const waveEnv = useWaveEnv<BlockEnv>();\n    const frameTextAtom = waveEnv.getBlockMetaKeyAtom(blockId, \"frame:text\");\n    const frameText = jotai.useAtomValue(frameTextAtom);\n    let headerTextUnion = util.useAtomValueSafe(viewModel?.viewText);\n    headerTextUnion = frameText ?? headerTextUnion;\n\n    const headerTextElems: React.ReactElement[] = [];\n    if (typeof headerTextUnion === \"string\") {\n        if (!util.isBlank(headerTextUnion)) {\n            headerTextElems.push(\n                <div key=\"text\" className=\"block-frame-text ellipsis\">\n                    &lrm;{headerTextUnion}\n                </div>\n            );\n        }\n    } else if (Array.isArray(headerTextUnion)) {\n        headerTextElems.push(...renderHeaderElements(headerTextUnion, preview));\n    }\n    if (error != null) {\n        const copyHeaderErr = () => {\n            navigator.clipboard.writeText(error.message + \"\\n\" + error.stack);\n        };\n        headerTextElems.push(\n            <div className=\"iconbutton disabled\" key=\"controller-status\" onClick={copyHeaderErr}>\n                <i\n                    className=\"fa-sharp fa-solid fa-triangle-exclamation\"\n                    title={\"Error Rendering View Header: \" + error.message}\n                />\n            </div>\n        );\n    }\n\n    return <div className=\"block-frame-textelems-wrapper\">{headerTextElems}</div>;\n});\nHeaderTextElems.displayName = \"HeaderTextElems\";\n\ntype HeaderEndIconsProps = {\n    viewModel: ViewModel;\n    nodeModel: NodeModel;\n    blockId: string;\n};\n\nconst HeaderEndIcons = React.memo(({ viewModel, nodeModel, blockId }: HeaderEndIconsProps) => {\n    const blockEnv = useWaveEnv<BlockEnv>();\n    const endIconButtons = util.useAtomValueSafe(viewModel?.endIconButtons);\n    const magnified = jotai.useAtomValue(nodeModel.isMagnified);\n    const ephemeral = jotai.useAtomValue(nodeModel.isEphemeral);\n    const numLeafs = jotai.useAtomValue(nodeModel.numLeafs);\n    const magnifyDisabled = numLeafs <= 1;\n\n    const endIconsElem: React.ReactElement[] = [];\n\n    if (endIconButtons && endIconButtons.length > 0) {\n        endIconsElem.push(...endIconButtons.map((button, idx) => <IconButton key={idx} decl={button} />));\n    }\n    const settingsDecl: IconButtonDecl = {\n        elemtype: \"iconbutton\",\n        icon: \"cog\",\n        title: \"Settings\",\n        click: (e) => handleHeaderContextMenu(e, blockId, viewModel, nodeModel, blockEnv),\n    };\n    endIconsElem.push(<IconButton key=\"settings\" decl={settingsDecl} className=\"block-frame-settings\" />);\n    if (ephemeral) {\n        const addToLayoutDecl: IconButtonDecl = {\n            elemtype: \"iconbutton\",\n            icon: \"circle-plus\",\n            title: \"Add to Layout\",\n            click: () => {\n                nodeModel.addEphemeralNodeToLayout();\n            },\n        };\n        endIconsElem.push(<IconButton key=\"add-to-layout\" decl={addToLayoutDecl} />);\n    } else {\n        endIconsElem.push(\n            <OptMagnifyButton\n                key=\"unmagnify\"\n                magnified={magnified}\n                toggleMagnify={() => {\n                    nodeModel.toggleMagnify();\n                    setTimeout(() => refocusNode(blockId), 50);\n                }}\n                disabled={magnifyDisabled}\n            />\n        );\n    }\n\n    const closeDecl: IconButtonDecl = {\n        elemtype: \"iconbutton\",\n        icon: \"xmark-large\",\n        title: \"Close\",\n        click: () => uxCloseBlock(nodeModel.blockId),\n    };\n    endIconsElem.push(<IconButton key=\"close\" decl={closeDecl} className=\"block-frame-default-close\" />);\n\n    return <div className=\"block-frame-end-icons\">{endIconsElem}</div>;\n});\nHeaderEndIcons.displayName = \"HeaderEndIcons\";\n\nconst BlockFrame_Header = ({\n    nodeModel,\n    viewModel,\n    preview,\n    connBtnRef,\n    changeConnModalAtom,\n    error,\n}: BlockFrameProps & { changeConnModalAtom: jotai.PrimitiveAtom<boolean>; error?: Error }) => {\n    const waveEnv = useWaveEnv<BlockEnv>();\n    const metaView = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, \"view\"));\n    const metaFrameTitle = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, \"frame:title\"));\n    const metaFrameIcon = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, \"frame:icon\"));\n    const metaConnection = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, \"connection\"));\n    let viewName = util.useAtomValueSafe(viewModel?.viewName) ?? blockViewToName(metaView);\n    let viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(metaView);\n    const preIconButton = util.useAtomValueSafe(viewModel?.preIconButton);\n    const useTermHeader = util.useAtomValueSafe(viewModel?.useTermHeader);\n    const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable);\n    const hideViewName = util.useAtomValueSafe(viewModel?.hideViewName);\n    const badge = jotai.useAtomValue(getBlockBadgeAtom(useTermHeader ? nodeModel.blockId : null));\n    const magnified = jotai.useAtomValue(nodeModel.isMagnified);\n    const prevMagifiedState = React.useRef(magnified);\n    const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection);\n    const iconColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, \"icon:color\"));\n    const dragHandleRef = preview ? null : nodeModel.dragHandleRef;\n    const isTerminalBlock = metaView === \"term\";\n    viewName = metaFrameTitle ?? viewName;\n    viewIconUnion = metaFrameIcon ?? viewIconUnion;\n\n    React.useEffect(() => {\n        if (magnified && !preview && !prevMagifiedState.current) {\n            waveEnv.rpc.ActivityCommand(TabRpcClient, { nummagnify: 1 });\n            recordTEvent(\"action:magnify\", { \"block:view\": viewName });\n        }\n        prevMagifiedState.current = magnified;\n    }, [magnified]);\n\n    const viewIconElem = getViewIconElem(viewIconUnion, iconColor);\n\n    return (\n        <div\n            className={cn(\"block-frame-default-header\", useTermHeader && \"!pl-[2px]\")}\n            data-role=\"block-header\"\n            ref={dragHandleRef}\n            onContextMenu={(e) => handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel, waveEnv)}\n        >\n            {!useTermHeader && (\n                <>\n                    {preIconButton && <IconButton decl={preIconButton} className=\"block-frame-preicon-button\" />}\n                    <div className=\"block-frame-default-header-iconview\">\n                        {viewIconElem}\n                        {viewName && !hideViewName && <div className=\"block-frame-view-type\">{viewName}</div>}\n                    </div>\n                </>\n            )}\n            {manageConnection && (\n                <ConnectionButton\n                    ref={connBtnRef}\n                    key=\"connbutton\"\n                    connection={metaConnection}\n                    changeConnModalAtom={changeConnModalAtom}\n                    isTerminalBlock={isTerminalBlock}\n                />\n            )}\n            {useTermHeader && termConfigedDurable != null && (\n                <DurableSessionFlyover\n                    key=\"durable-status\"\n                    blockId={nodeModel.blockId}\n                    viewModel={viewModel}\n                    placement=\"bottom\"\n                    divClassName=\"iconbutton disabled text-[13px] ml-[-4px]\"\n                />\n            )}\n            {useTermHeader && badge && (\n                <div className=\"pointer-events-none flex items-center px-1\" style={{ color: badge.color || \"#fbbf24\" }}>\n                    <i className={makeIconClass(badge.icon, true, { defaultIcon: \"circle-small\" })} />\n                </div>\n            )}\n            <HeaderTextElems viewModel={viewModel} blockId={nodeModel.blockId} preview={preview} error={error} />\n            <HeaderEndIcons viewModel={viewModel} nodeModel={nodeModel} blockId={nodeModel.blockId} />\n        </div>\n    );\n};\n\nexport { BlockFrame_Header };\n"
  },
  {
    "path": "frontend/app/block/blockframe.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { BlockModel } from \"@/app/block/block-model\";\nimport { BlockFrame_Header } from \"@/app/block/blockframe-header\";\nimport { blockViewToIcon, getViewIconElem } from \"@/app/block/blockutil\";\nimport { ConnStatusOverlay } from \"@/app/block/connstatusoverlay\";\nimport { ChangeConnectionBlockModal } from \"@/app/modals/conntypeahead\";\nimport { getBlockComponentModel, globalStore, useBlockAtom } from \"@/app/store/global\";\nimport { useTabModel } from \"@/app/store/tab-model\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport { WorkspaceLayoutModel } from \"@/app/workspace/workspace-layout-model\";\nimport { ErrorBoundary } from \"@/element/errorboundary\";\nimport { NodeModel } from \"@/layout/index\";\nimport { makeORef } from \"@/store/wos\";\nimport * as util from \"@/util/util\";\nimport { makeIconClass } from \"@/util/util\";\nimport { computeBgStyleFromMeta } from \"@/util/waveutil\";\nimport clsx from \"clsx\";\nimport * as jotai from \"jotai\";\nimport * as React from \"react\";\nimport { BlockEnv } from \"./blockenv\";\nimport { BlockFrameProps } from \"./blocktypes\";\n\nconst BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => {\n    const waveEnv = useWaveEnv<BlockEnv>();\n    const tabModel = useTabModel();\n    const isFocused = jotai.useAtomValue(nodeModel.isFocused);\n    const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral);\n    const blockNum = jotai.useAtomValue(nodeModel.blockNum);\n    const isLayoutMode = jotai.useAtomValue(waveEnv.atoms.controlShiftDelayAtom);\n    const showOverlayBlockNums = jotai.useAtomValue(waveEnv.getSettingsKeyAtom(\"app:showoverlayblocknums\")) ?? true;\n    const blockHighlight = jotai.useAtomValue(BlockModel.getInstance().getBlockHighlightAtom(nodeModel.blockId));\n    const frameActiveBorderColor = jotai.useAtomValue(\n        waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, \"frame:activebordercolor\")\n    );\n    const frameBorderColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, \"frame:bordercolor\"));\n    const tabActiveBorderColor = jotai.useAtomValue(tabModel.getTabMetaAtom(\"bg:activebordercolor\"));\n    const tabBorderColor = jotai.useAtomValue(tabModel.getTabMetaAtom(\"bg:bordercolor\"));\n    const style: React.CSSProperties = {};\n    let showBlockMask = false;\n\n    if (isFocused) {\n        if (tabActiveBorderColor) {\n            style.borderColor = tabActiveBorderColor;\n        }\n        if (frameActiveBorderColor) {\n            style.borderColor = frameActiveBorderColor;\n        }\n    } else {\n        if (tabBorderColor) {\n            style.borderColor = tabBorderColor;\n        }\n        if (frameBorderColor) {\n            style.borderColor = frameBorderColor;\n        }\n        if (isEphemeral && !style.borderColor) {\n            style.borderColor = \"rgba(255, 255, 255, 0.7)\";\n        }\n    }\n\n    if (blockHighlight && !style.borderColor) {\n        style.borderColor = \"rgb(59, 130, 246)\";\n    }\n\n    let innerElem = null;\n    if (isLayoutMode && showOverlayBlockNums) {\n        showBlockMask = true;\n        innerElem = (\n            <div className=\"block-mask-inner\">\n                <div className=\"bignum\">{blockNum}</div>\n            </div>\n        );\n    } else if (blockHighlight) {\n        showBlockMask = true;\n        const iconClass = makeIconClass(blockHighlight.icon, false);\n        innerElem = (\n            <div className=\"block-mask-inner\">\n                <i className={iconClass} style={{ fontSize: \"48px\", opacity: 0.5 }} />\n            </div>\n        );\n    }\n\n    return (\n        <div\n            className={clsx(\"block-mask\", { \"show-block-mask\": showBlockMask, \"bg-blue-500/10\": blockHighlight })}\n            style={style}\n        >\n            {innerElem}\n        </div>\n    );\n});\n\nconst BlockFrame_Default_Component = (props: BlockFrameProps) => {\n    const waveEnv = useWaveEnv<BlockEnv>();\n    const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props;\n    const isFocused = jotai.useAtomValue(nodeModel.isFocused);\n    const aiPanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom);\n    const metaView = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, \"view\"));\n    const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(metaView);\n    const customBg = util.useAtomValueSafe(viewModel?.blockBg);\n    const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection);\n    const changeConnModalAtom = useBlockAtom(nodeModel.blockId, \"changeConn\", () => {\n        return jotai.atom(false);\n    }) as jotai.PrimitiveAtom<boolean>;\n    const connModalOpen = jotai.useAtomValue(changeConnModalAtom);\n    const isMagnified = jotai.useAtomValue(nodeModel.isMagnified);\n    const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral);\n    const [magnifiedBlockBlurAtom] = React.useState(() => waveEnv.getSettingsKeyAtom(\"window:magnifiedblockblurprimarypx\"));\n    const magnifiedBlockBlur = jotai.useAtomValue(magnifiedBlockBlurAtom);\n    const [magnifiedBlockOpacityAtom] = React.useState(() => waveEnv.getSettingsKeyAtom(\"window:magnifiedblockopacity\"));\n    const magnifiedBlockOpacity = jotai.useAtomValue(magnifiedBlockOpacityAtom);\n    const connBtnRef = React.useRef<HTMLDivElement>(null);\n    const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, \"connection\"));\n    const iconColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, \"icon:color\"));\n    const noHeader = util.useAtomValueSafe(viewModel?.noHeader);\n\n    React.useEffect(() => {\n        if (!manageConnection) {\n            return;\n        }\n        const bcm = getBlockComponentModel(nodeModel.blockId);\n        if (bcm != null) {\n            bcm.openSwitchConnection = () => {\n                globalStore.set(changeConnModalAtom, true);\n            };\n        }\n        return () => {\n            const bcm = getBlockComponentModel(nodeModel.blockId);\n            if (bcm != null) {\n                bcm.openSwitchConnection = null;\n            }\n        };\n    }, [manageConnection]);\n    React.useEffect(() => {\n        // on mount, if manageConnection, call ConnEnsure\n        if (!manageConnection || preview) {\n            return;\n        }\n        if (!util.isLocalConnName(connName)) {\n            console.log(\"ensure conn\", nodeModel.blockId, connName);\n            waveEnv.rpc\n                .ConnEnsureCommand(TabRpcClient, { connname: connName, logblockid: nodeModel.blockId }, { timeout: 60000 })\n                .catch((e) => {\n                    console.log(\"error ensuring connection\", nodeModel.blockId, connName, e);\n                });\n        }\n    }, [manageConnection, connName]);\n\n    const viewIconElem = getViewIconElem(viewIconUnion, iconColor);\n    let innerStyle: React.CSSProperties = {};\n    if (!preview) {\n        innerStyle = computeBgStyleFromMeta(customBg);\n    }\n    const previewElem = <div className=\"block-frame-preview\">{viewIconElem}</div>;\n    const headerElem = (\n        <BlockFrame_Header {...props} connBtnRef={connBtnRef} changeConnModalAtom={changeConnModalAtom} />\n    );\n    const headerElemNoView = React.cloneElement(headerElem, { viewModel: null });\n    return (\n        <div\n            className={clsx(\"block\", \"block-frame-default\", \"block-\" + nodeModel.blockId, {\n                \"block-focused\": isFocused || preview,\n                \"block-preview\": preview,\n                \"block-no-highlight\": numBlocksInTab === 1 && !aiPanelVisible,\n                ephemeral: isEphemeral,\n                magnified: isMagnified,\n            })}\n            data-blockid={nodeModel.blockId}\n            onClick={blockModel?.onClick}\n            onPointerEnter={blockModel?.onPointerEnter}\n            onFocusCapture={blockModel?.onFocusCapture}\n            ref={blockModel?.blockRef}\n            style={\n                {\n                    \"--magnified-block-opacity\": magnifiedBlockOpacity,\n                    \"--magnified-block-blur\": `${magnifiedBlockBlur}px`,\n                } as React.CSSProperties\n            }\n            inert={preview || undefined}\n        >\n            <BlockMask nodeModel={nodeModel} />\n            {preview || viewModel == null || !manageConnection ? null : (\n                <ConnStatusOverlay\n                    nodeModel={nodeModel}\n                    viewModel={viewModel}\n                    changeConnModalAtom={changeConnModalAtom}\n                />\n            )}\n            <div className=\"block-frame-default-inner\" style={innerStyle}>\n                {noHeader || <ErrorBoundary fallback={headerElemNoView}>{headerElem}</ErrorBoundary>}\n                {preview ? previewElem : children}\n            </div>\n            {preview || viewModel == null || !connModalOpen ? null : (\n                <ChangeConnectionBlockModal\n                    blockId={nodeModel.blockId}\n                    nodeModel={nodeModel}\n                    viewModel={viewModel}\n                    blockRef={blockModel?.blockRef}\n                    changeConnModalAtom={changeConnModalAtom}\n                    connBtnRef={connBtnRef}\n                />\n            )}\n        </div>\n    );\n};\n\nconst BlockFrame_Default = React.memo(BlockFrame_Default_Component) as typeof BlockFrame_Default_Component;\n\nconst BlockFrame = React.memo((props: BlockFrameProps) => {\n    const waveEnv = useWaveEnv<BlockEnv>();\n    const tabModel = useTabModel();\n    const blockId = props.nodeModel.blockId;\n    const blockIsNull = jotai.useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef(\"block\", blockId)));\n    const numBlocks = jotai.useAtomValue(tabModel.tabNumBlocksAtom);\n    if (!blockId || blockIsNull) {\n        return null;\n    }\n    return <BlockFrame_Default {...props} numBlocksInTab={numBlocks} />;\n});\n\nexport { BlockFrame };\n"
  },
  {
    "path": "frontend/app/block/blocktypes.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { NodeModel } from \"@/layout/index\";\nimport { Atom } from \"jotai\";\n\nexport interface BlockNodeModel {\n    blockId: string;\n    isFocused: Atom<boolean>;\n    isMagnified: Atom<boolean>;\n    onClose: () => void;\n    focusNode: () => void;\n    toggleMagnify: () => void;\n}\n\nexport type FullBlockProps = {\n    preview: boolean;\n    nodeModel: NodeModel;\n    viewModel: ViewModel;\n};\n\nexport interface BlockProps {\n    preview: boolean;\n    nodeModel: NodeModel;\n}\n\nexport type FullSubBlockProps = {\n    nodeModel: BlockNodeModel;\n    viewModel: ViewModel;\n};\n\nexport interface SubBlockProps {\n    nodeModel: BlockNodeModel;\n}\n\nexport interface BlockComponentModel2 {\n    onClick?: () => void;\n    onPointerEnter?: React.PointerEventHandler<HTMLDivElement>;\n    onFocusCapture?: React.FocusEventHandler<HTMLDivElement>;\n    blockRef?: React.RefObject<HTMLDivElement>;\n}\n\nexport interface BlockFrameProps {\n    blockModel?: BlockComponentModel2;\n    nodeModel?: NodeModel;\n    viewModel?: ViewModel;\n    preview: boolean;\n    numBlocksInTab?: number;\n    children?: React.ReactNode;\n    connBtnRef?: React.RefObject<HTMLDivElement>;\n}\n"
  },
  {
    "path": "frontend/app/block/blockutil.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Button } from \"@/app/element/button\";\nimport { IconButton, ToggleIconButton } from \"@/element/iconbutton\";\nimport { MagnifyIcon } from \"@/element/magnify\";\nimport { MenuButton } from \"@/element/menubutton\";\nimport * as util from \"@/util/util\";\nimport clsx from \"clsx\";\nimport * as React from \"react\";\n\nexport const colorRegex = /^((#[0-9a-f]{6,8})|([a-z]+))$/;\nexport const NumActiveConnColors = 8;\n\nexport function blockViewToIcon(view: string): string {\n    if (view == \"term\") {\n        return \"terminal\";\n    }\n    if (view == \"preview\") {\n        return \"file\";\n    }\n    if (view == \"web\") {\n        return \"globe\";\n    }\n    if (view == \"waveai\") {\n        return \"sparkles\";\n    }\n    if (view == \"help\") {\n        return \"circle-question\";\n    }\n    if (view == \"tips\") {\n        return \"lightbulb\";\n    }\n    return \"square\";\n}\n\nexport function blockViewToName(view: string): string {\n    if (util.isBlank(view)) {\n        return \"(No View)\";\n    }\n    if (view == \"term\") {\n        return \"Terminal\";\n    }\n    if (view == \"preview\") {\n        return \"Preview\";\n    }\n    if (view == \"web\") {\n        return \"Web\";\n    }\n    if (view == \"waveai\") {\n        return \"WaveAI\";\n    }\n    if (view == \"help\") {\n        return \"Help\";\n    }\n    if (view == \"tips\") {\n        return \"Tips\";\n    }\n    return view;\n}\n\nexport function processTitleString(titleString: string): React.ReactNode[] {\n    if (titleString == null) {\n        return null;\n    }\n    const tagRegex = /<(\\/)?([a-z]+)(?::([#a-z0-9@-]+))?>/g;\n    let lastIdx = 0;\n    let match;\n    const partsStack = [[]];\n    while ((match = tagRegex.exec(titleString)) != null) {\n        const lastPart = partsStack[partsStack.length - 1];\n        const before = titleString.substring(lastIdx, match.index);\n        lastPart.push(before);\n        lastIdx = match.index + match[0].length;\n        const [_, isClosing, tagName, tagParam] = match;\n        if (tagName == \"icon\" && !isClosing) {\n            if (tagParam == null) {\n                continue;\n            }\n            const iconClass = util.makeIconClass(tagParam, false);\n            if (iconClass == null) {\n                continue;\n            }\n            lastPart.push(<i key={match.index} className={iconClass} />);\n            continue;\n        }\n        if (tagName == \"c\" || tagName == \"color\") {\n            if (isClosing) {\n                if (partsStack.length <= 1) {\n                    continue;\n                }\n                partsStack.pop();\n                continue;\n            }\n            if (tagParam == null) {\n                continue;\n            }\n            if (!tagParam.match(colorRegex)) {\n                continue;\n            }\n            const children = [];\n            const rtag = React.createElement(\"span\", { key: match.index, style: { color: tagParam } }, children);\n            lastPart.push(rtag);\n            partsStack.push(children);\n            continue;\n        }\n        if (tagName == \"i\" || tagName == \"b\") {\n            if (isClosing) {\n                if (partsStack.length <= 1) {\n                    continue;\n                }\n                partsStack.pop();\n                continue;\n            }\n            const children = [];\n            const rtag = React.createElement(tagName, { key: match.index }, children);\n            lastPart.push(rtag);\n            partsStack.push(children);\n            continue;\n        }\n    }\n    partsStack[partsStack.length - 1].push(titleString.substring(lastIdx));\n    return partsStack[0];\n}\n\nexport function getBlockHeaderIcon(blockIcon: string, overrideIconColor?: string): React.ReactNode {\n    let blockIconElem: React.ReactNode = null;\n    if (util.isBlank(blockIcon)) {\n        blockIcon = \"square\";\n    }\n    let iconColor = overrideIconColor;\n    if (iconColor && !iconColor.match(colorRegex)) {\n        iconColor = null;\n    }\n    let iconStyle = null;\n    if (!util.isBlank(iconColor)) {\n        iconStyle = { color: iconColor };\n    }\n    const iconClass = util.makeIconClass(blockIcon, true);\n    if (iconClass != null) {\n        blockIconElem = <i key=\"icon\" style={iconStyle} className={clsx(`block-frame-icon`, iconClass)} />;\n    }\n    return blockIconElem;\n}\n\nexport function getViewIconElem(\n    viewIconUnion: string | IconButtonDecl,\n    overrideIconColor?: string\n): React.ReactElement {\n    if (viewIconUnion == null || typeof viewIconUnion === \"string\") {\n        const viewIcon = viewIconUnion as string;\n        return <div className=\"block-frame-view-icon\">{getBlockHeaderIcon(viewIcon, overrideIconColor)}</div>;\n    } else {\n        return <IconButton decl={viewIconUnion} className=\"block-frame-view-icon\" />;\n    }\n}\n\nexport const Input = React.memo(\n    ({ decl, className, preview }: { decl: HeaderInput; className: string; preview: boolean }) => {\n        const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl;\n        return (\n            <div className=\"input-wrapper\">\n                <input\n                    ref={\n                        !preview\n                            ? ref\n                            : undefined /* don't wire up the input field if the preview block is being rendered */\n                    }\n                    disabled={isDisabled}\n                    className={className}\n                    value={value}\n                    onChange={(e) => onChange(e)}\n                    onKeyDown={(e) => onKeyDown(e)}\n                    onFocus={(e) => onFocus(e)}\n                    onBlur={(e) => onBlur(e)}\n                    onDragStart={(e) => e.preventDefault()}\n                />\n            </div>\n        );\n    }\n);\n\nexport const OptMagnifyButton = React.memo(\n    ({ magnified, toggleMagnify, disabled }: { magnified: boolean; toggleMagnify: () => void; disabled: boolean }) => {\n        const magnifyDecl: IconButtonDecl = {\n            elemtype: \"iconbutton\",\n            icon: <MagnifyIcon enabled={magnified} />,\n            title: magnified ? \"Minimize\" : \"Magnify\",\n            click: toggleMagnify,\n            disabled,\n        };\n        return <IconButton key=\"magnify\" decl={magnifyDecl} className=\"block-frame-magnify\" />;\n    }\n);\n\nexport const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; preview: boolean }) => {\n    if (elem.elemtype == \"iconbutton\") {\n        return <IconButton decl={elem} className={clsx(\"block-frame-header-iconbutton\", elem.className)} />;\n    } else if (elem.elemtype == \"toggleiconbutton\") {\n        return <ToggleIconButton decl={elem} className={clsx(\"block-frame-header-iconbutton\", elem.className)} />;\n    } else if (elem.elemtype == \"input\") {\n        return <Input decl={elem} className={clsx(\"block-frame-input\", elem.className)} preview={preview} />;\n    } else if (elem.elemtype == \"text\") {\n        return (\n            <div className={clsx(\"block-frame-text ellipsis\", elem.className, { \"flex-nogrow\": elem.noGrow })}>\n                <span ref={preview ? null : elem.ref} onClick={(e) => elem?.onClick(e)}>\n                    &lrm;{elem.text}\n                </span>\n            </div>\n        );\n    } else if (elem.elemtype == \"textbutton\") {\n        return (\n            <Button className={elem.className} onClick={(e) => elem.onClick(e)} title={elem.title}>\n                {elem.text}\n            </Button>\n        );\n    } else if (elem.elemtype == \"div\") {\n        return (\n            <div\n                className={clsx(\"block-frame-div\", elem.className)}\n                onMouseOver={elem.onMouseOver}\n                onMouseOut={elem.onMouseOut}\n            >\n                {elem.children.map((child, childIdx) => (\n                    <HeaderTextElem elem={child} key={childIdx} preview={preview} />\n                ))}\n            </div>\n        );\n    } else if (elem.elemtype == \"menubutton\") {\n        return <MenuButton className=\"block-frame-menubutton\" {...(elem as MenuButtonProps)} />;\n    }\n    return null;\n});\n\nexport function renderHeaderElements(headerTextUnion: HeaderElem[], preview: boolean): React.ReactElement[] {\n    const headerTextElems: React.ReactElement[] = [];\n    for (let idx = 0; idx < headerTextUnion.length; idx++) {\n        const elem = headerTextUnion[idx];\n        const renderedElement = <HeaderTextElem elem={elem} key={idx} preview={preview} />;\n        if (renderedElement) {\n            headerTextElems.push(renderedElement);\n        }\n    }\n    return headerTextElems;\n}\n\nexport function computeConnColorNum(connStatus: ConnStatus): number {\n    const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors;\n    if (connColorNum == 0) {\n        return NumActiveConnColors;\n    }\n    return connColorNum;\n}\n"
  },
  {
    "path": "frontend/app/block/connectionbutton.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { computeConnColorNum } from \"@/app/block/blockutil\";\nimport { recordTEvent } from \"@/app/store/global\";\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport { IconButton } from \"@/element/iconbutton\";\nimport * as util from \"@/util/util\";\nimport * as jotai from \"jotai\";\nimport * as React from \"react\";\nimport DotsSvg from \"../asset/dots-anim-4.svg\";\nimport { BlockEnv } from \"./blockenv\";\n\ninterface ConnectionButtonProps {\n    connection: string;\n    changeConnModalAtom: jotai.PrimitiveAtom<boolean>;\n    isTerminalBlock?: boolean;\n}\n\nexport const ConnectionButton = React.memo(\n    React.forwardRef<HTMLDivElement, ConnectionButtonProps>(\n        ({ connection, changeConnModalAtom, isTerminalBlock }: ConnectionButtonProps, ref) => {\n            const waveEnv = useWaveEnv<BlockEnv>();\n            const [_connModalOpen, setConnModalOpen] = jotai.useAtom(changeConnModalAtom);\n            const isLocal = util.isLocalConnName(connection);\n            const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connection));\n            const localName = jotai.useAtomValue(waveEnv.getLocalHostDisplayNameAtom());\n            let showDisconnectedSlash = false;\n            let connIconElem: React.ReactNode = null;\n            const connColorNum = computeConnColorNum(connStatus);\n            let color = `var(--conn-icon-color-${connColorNum})`;\n            const clickHandler = function () {\n                recordTEvent(\"action:other\", { \"action:type\": \"conndropdown\", \"action:initiator\": \"mouse\" });\n                setConnModalOpen(true);\n            };\n            let titleText = null;\n            let shouldSpin = false;\n            let connDisplayName: string = null;\n            let extraDisplayNameClassName = \"\";\n            if (isLocal) {\n                color = \"var(--color-secondary)\";\n                if (connection === \"local:gitbash\") {\n                    titleText = \"Connected to Git Bash\";\n                    connDisplayName = \"Git Bash\";\n                } else {\n                    titleText = \"Connected to Local Machine\";\n                    if (localName) {\n                        titleText += ` (${localName})`;\n                    }\n                    if (isTerminalBlock) {\n                        connDisplayName = localName;\n                        extraDisplayNameClassName = \"text-muted group-hover:text-secondary\";\n                    }\n                }\n                connIconElem = (\n                    <i\n                        className={util.cn(util.makeIconClass(\"laptop\", false), \"fa-stack-1x mr-[2px]\")}\n                        style={{ color: color }}\n                    />\n                );\n            } else {\n                titleText = \"Connected to \" + connection;\n                let iconName = \"arrow-right-arrow-left\";\n                let iconSvg = null;\n                if (connStatus?.status == \"connecting\") {\n                    color = \"var(--warning-color)\";\n                    titleText = \"Connecting to \" + connection;\n                    shouldSpin = false;\n                    iconSvg = (\n                        <div className=\"relative top-[5px] left-[9px] [&_svg]:fill-warning\">\n                            <DotsSvg />\n                        </div>\n                    );\n                } else if (connStatus?.status == \"error\") {\n                    color = \"var(--error-color)\";\n                    titleText = \"Error connecting to \" + connection;\n                    if (connStatus?.error != null) {\n                        titleText += \" (\" + connStatus.error + \")\";\n                    }\n                    showDisconnectedSlash = true;\n                } else if (!connStatus?.connected) {\n                    color = \"var(--grey-text-color)\";\n                    titleText = \"Disconnected from \" + connection;\n                    showDisconnectedSlash = true;\n                } else if (connStatus?.connhealthstatus === \"degraded\" || connStatus?.connhealthstatus === \"stalled\") {\n                    color = \"var(--warning-color)\";\n                    iconName = \"signal-bars-slash\";\n                    if (connStatus.connhealthstatus === \"degraded\") {\n                        titleText = \"Connection degraded: \" + connection;\n                    } else {\n                        titleText = \"Connection stalled: \" + connection;\n                    }\n                }\n                if (iconSvg != null) {\n                    connIconElem = iconSvg;\n                } else {\n                    connIconElem = (\n                        <i\n                            className={util.cn(util.makeIconClass(iconName, false), \"fa-stack-1x mr-[2px]\")}\n                            style={{ color: color }}\n                        />\n                    );\n                }\n            }\n\n            const wshProblem = connection && !connStatus?.wshenabled && connStatus?.status == \"connected\";\n            const showNoWshButton = wshProblem && !isLocal;\n\n            return (\n                <>\n                    <div\n                        ref={ref}\n                        className=\"group flex items-center flex-nowrap overflow-hidden text-ellipsis min-w-0 font-normal text-primary rounded-sm hover:bg-highlightbg cursor-pointer\"\n                        onClick={clickHandler}\n                        title={titleText}\n                    >\n                        <span\n                            className={util.cn(\n                                \"fa-stack flex-[1_1_auto] overflow-hidden\",\n                                shouldSpin ? \"fa-spin\" : null\n                            )}\n                        >\n                            {connIconElem}\n                            <i\n                                className={util.cn(\n                                    \"fa-slash fa-solid fa-stack-1x mr-[2px] [text-shadow:0_1px_black,0_1.5px_black]\",\n                                    showDisconnectedSlash ? \"opacity-100\" : \"opacity-0\"\n                                )}\n                                style={{ color: color }}\n                            />\n                        </span>\n                        {connDisplayName ? (\n                            <div\n                                className={util.cn(\n                                    \"flex-[1_2_auto] overflow-hidden pr-1 ellipsis\",\n                                    extraDisplayNameClassName\n                                )}\n                            >\n                                {connDisplayName}\n                            </div>\n                        ) : isLocal ? null : (\n                            <div className=\"flex-[1_2_auto] overflow-hidden pr-1 ellipsis\">{connection}</div>\n                        )}\n                    </div>\n                    {showNoWshButton && (\n                        <IconButton\n                            decl={{\n                                elemtype: \"iconbutton\",\n                                icon: \"link-slash\",\n                                title: \"wsh is not installed for this connection\",\n                            }}\n                        />\n                    )}\n                </>\n            );\n        }\n    )\n);\nConnectionButton.displayName = \"ConnectionButton\";\n"
  },
  {
    "path": "frontend/app/block/connstatusoverlay.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Button } from \"@/app/element/button\";\nimport { CopyButton } from \"@/app/element/copybutton\";\nimport { useDimensionsWithCallbackRef } from \"@/app/hook/useDimensions\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport { NodeModel } from \"@/layout/index\";\nimport * as util from \"@/util/util\";\nimport clsx from \"clsx\";\nimport * as jotai from \"jotai\";\nimport { OverlayScrollbarsComponent } from \"overlayscrollbars-react\";\nimport * as React from \"react\";\nimport { BlockEnv } from \"./blockenv\";\n\nfunction formatElapsedTime(elapsedMs: number): string {\n    if (elapsedMs <= 0) {\n        return \"\";\n    }\n\n    const elapsedSeconds = Math.floor(elapsedMs / 1000);\n\n    if (elapsedSeconds < 60) {\n        return `${elapsedSeconds}s`;\n    }\n\n    const elapsedMinutes = Math.floor(elapsedSeconds / 60);\n    if (elapsedMinutes < 60) {\n        return `${elapsedMinutes}m`;\n    }\n\n    const elapsedHours = Math.floor(elapsedMinutes / 60);\n    const remainingMinutes = elapsedMinutes % 60;\n\n    if (elapsedHours < 24) {\n        if (remainingMinutes === 0) {\n            return `${elapsedHours}h`;\n        }\n        return `${elapsedHours}h${remainingMinutes}m`;\n    }\n\n    return \"more than a day\";\n}\n\nconst StalledOverlay = React.memo(\n    ({\n        connName,\n        connStatus,\n        overlayRefCallback,\n    }: {\n        connName: string;\n        connStatus: ConnStatus;\n        overlayRefCallback: (el: HTMLDivElement | null) => void;\n    }) => {\n        const [elapsedTime, setElapsedTime] = React.useState<string>(\"\");\n\n        const waveEnv = useWaveEnv<BlockEnv>();\n        const handleDisconnect = React.useCallback(() => {\n            const prtn = waveEnv.rpc.ConnDisconnectCommand(TabRpcClient, connName, { timeout: 5000 });\n            prtn.catch((e) => console.log(\"error disconnecting\", connName, e));\n        }, [connName, waveEnv]);\n\n        React.useEffect(() => {\n            if (!connStatus.lastactivitybeforestalledtime) {\n                return;\n            }\n\n            const updateElapsed = () => {\n                const now = Date.now();\n                const lastActivity = connStatus.lastactivitybeforestalledtime!;\n                const elapsed = now - lastActivity;\n                setElapsedTime(formatElapsedTime(elapsed));\n            };\n\n            updateElapsed();\n            const interval = setInterval(updateElapsed, 1000);\n\n            return () => clearInterval(interval);\n        }, [connStatus.lastactivitybeforestalledtime]);\n\n        return (\n            <div\n                className=\"@container absolute top-[calc(var(--header-height)+6px)] left-1.5 right-1.5 z-[var(--zindex-block-mask-inner)] overflow-hidden rounded-md bg-[var(--conn-status-overlay-bg-color)] backdrop-blur-[50px] shadow-lg opacity-90\"\n                ref={overlayRefCallback}\n            >\n                <div className=\"flex items-center gap-3 w-full pt-2.5 pb-2.5 pr-2 pl-3\">\n                    <i\n                        className=\"fa-solid fa-triangle-exclamation text-warning text-base shrink-0\"\n                        title=\"Connection Stalled\"\n                    ></i>\n                    <div className=\"text-[11px] font-semibold leading-4 tracking-[0.11px] text-white min-w-0 flex-1 break-words @max-xxs:hidden\">\n                        Connection to \"{connName}\" is stalled\n                        {elapsedTime && ` (no activity for ${elapsedTime})`}\n                    </div>\n                    <div className=\"flex-1 hidden @max-xxs:block\"></div>\n                    <Button\n                        className=\"outlined grey text-[11px] py-[3px] px-[7px] @max-w350:text-[12px] @max-w350:py-[5px] @max-w350:px-[6px]\"\n                        onClick={handleDisconnect}\n                        title=\"Disconnect\"\n                    >\n                        <span className=\"@max-w350:hidden!\">Disconnect</span>\n                        <i className=\"fa-solid fa-link-slash hidden! @max-w350:inline!\"></i>\n                    </Button>\n                </div>\n            </div>\n        );\n    }\n);\nStalledOverlay.displayName = \"StalledOverlay\";\n\nexport const ConnStatusOverlay = React.memo(\n    ({\n        nodeModel,\n        viewModel,\n        changeConnModalAtom,\n    }: {\n        nodeModel: NodeModel;\n        viewModel: ViewModel;\n        changeConnModalAtom: jotai.PrimitiveAtom<boolean>;\n    }) => {\n        const waveEnv = useWaveEnv<BlockEnv>();\n        const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, \"connection\"));\n        const [connModalOpen] = jotai.useAtom(changeConnModalAtom);\n        const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connName));\n        const isLayoutMode = jotai.useAtomValue(waveEnv.atoms.controlShiftDelayAtom);\n        const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30);\n        const width = domRect?.width;\n        const [showError, setShowError] = React.useState(false);\n        const wshConfigEnabled =\n            jotai.useAtomValue(waveEnv.getConnConfigKeyAtom(connName, \"conn:wshenabled\")) ?? true;\n        const [showWshError, setShowWshError] = React.useState(false);\n\n        React.useEffect(() => {\n            if (width) {\n                const hasError = !util.isBlank(connStatus.error);\n                const showError = hasError && width >= 250 && connStatus.status == \"error\";\n                setShowError(showError);\n            }\n        }, [width, connStatus, setShowError]);\n\n        const handleTryReconnect = React.useCallback(() => {\n            const prtn = waveEnv.rpc.ConnConnectCommand(\n                TabRpcClient,\n                { host: connName, logblockid: nodeModel.blockId },\n                { timeout: 60000 }\n            );\n            prtn.catch((e) => console.log(\"error reconnecting\", connName, e));\n        }, [connName, nodeModel.blockId, waveEnv]);\n\n        const handleDisableWsh = React.useCallback(async () => {\n            const metamaptype: unknown = {\n                \"conn:wshenabled\": false,\n            };\n            const data: ConnConfigRequest = {\n                host: connName,\n                metamaptype: metamaptype,\n            };\n            try {\n                await waveEnv.rpc.SetConnectionsConfigCommand(TabRpcClient, data);\n            } catch (e) {\n                console.log(\"problem setting connection config: \", e);\n            }\n        }, [connName, waveEnv]);\n\n        const handleRemoveWshError = React.useCallback(async () => {\n            try {\n                await waveEnv.rpc.DismissWshFailCommand(TabRpcClient, connName);\n            } catch (e) {\n                console.log(\"unable to dismiss wsh error: \", e);\n            }\n        }, [connName, waveEnv]);\n\n        let statusText = `Disconnected from \"${connName}\"`;\n        let showReconnect = true;\n        if (connStatus.status == \"connecting\") {\n            statusText = `Connecting to \"${connName}\"...`;\n            showReconnect = false;\n        }\n        if (connStatus.status == \"connected\") {\n            showReconnect = false;\n        }\n        let reconDisplay = null;\n        let reconClassName = \"outlined grey\";\n        if (width && width < 350) {\n            reconDisplay = <i className=\"fa-sharp fa-solid fa-rotate-right\"></i>;\n            reconClassName = clsx(reconClassName, \"text-[12px] py-[5px] px-[6px]\");\n        } else {\n            reconDisplay = \"Reconnect\";\n            reconClassName = clsx(reconClassName, \"text-[11px] py-[3px] px-[7px]\");\n        }\n        const showIcon = connStatus.status != \"connecting\";\n\n        React.useEffect(() => {\n            const showWshErrorTemp =\n                connStatus.status == \"connected\" &&\n                connStatus.wsherror &&\n                connStatus.wsherror != \"\" &&\n                wshConfigEnabled;\n\n            setShowWshError(showWshErrorTemp);\n        }, [connStatus, wshConfigEnabled]);\n\n        const handleCopy = React.useCallback(\n            async (e: React.MouseEvent) => {\n                const errTexts = [];\n                if (showError) {\n                    errTexts.push(`error: ${connStatus.error}`);\n                }\n                if (showWshError) {\n                    errTexts.push(`unable to use wsh: ${connStatus.wsherror}`);\n                }\n                const textToCopy = errTexts.join(\"\\n\");\n                await navigator.clipboard.writeText(textToCopy);\n            },\n            [showError, showWshError, connStatus.error, connStatus.wsherror]\n        );\n\n        const showStalled = connStatus.status == \"connected\" && connStatus.connhealthstatus == \"stalled\";\n        if (!showWshError && !showStalled && (isLayoutMode || connStatus.status == \"connected\" || connModalOpen)) {\n            return null;\n        }\n\n        if (showStalled && !showWshError) {\n            return (\n                <StalledOverlay connName={connName} connStatus={connStatus} overlayRefCallback={overlayRefCallback} />\n            );\n        }\n\n        return (\n            <div className=\"connstatus-overlay\" ref={overlayRefCallback}>\n                <div className=\"connstatus-content\">\n                    <div className={clsx(\"connstatus-status-icon-wrapper\", { \"has-error\": showError || showWshError })}>\n                        {showIcon && <i className=\"fa-solid fa-triangle-exclamation\"></i>}\n                        <div className=\"connstatus-status ellipsis\">\n                            <div className=\"connstatus-status-text\">{statusText}</div>\n                            {(showError || showWshError) && (\n                                <OverlayScrollbarsComponent\n                                    className=\"connstatus-error\"\n                                    options={{ scrollbars: { autoHide: \"leave\" } }}\n                                >\n                                    <CopyButton className=\"copy-button\" onClick={handleCopy} title=\"Copy\" />\n                                    {showError ? <div>error: {connStatus.error}</div> : null}\n                                    {showWshError ? <div>unable to use wsh: {connStatus.wsherror}</div> : null}\n                                </OverlayScrollbarsComponent>\n                            )}\n                            {showWshError && (\n                                <Button className={reconClassName} onClick={handleDisableWsh}>\n                                    always disable wsh\n                                </Button>\n                            )}\n                        </div>\n                    </div>\n                    {showReconnect ? (\n                        <div className=\"connstatus-actions\">\n                            <Button className={reconClassName} onClick={handleTryReconnect}>\n                                {reconDisplay}\n                            </Button>\n                        </div>\n                    ) : null}\n                    {showWshError ? (\n                        <div className=\"connstatus-actions\">\n                            <Button className={`fa-xmark fa-solid ${reconClassName}`} onClick={handleRemoveWshError} />\n                        </div>\n                    ) : null}\n                </div>\n            </div>\n        );\n    }\n);\nConnStatusOverlay.displayName = \"ConnStatusOverlay\";\n"
  },
  {
    "path": "frontend/app/block/durable-session-flyover.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { recordTEvent } from \"@/app/store/global\";\nimport { TermViewModel } from \"@/app/view/term/term-model\";\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport * as util from \"@/util/util\";\nimport { cn } from \"@/util/util\";\nimport {\n    autoUpdate,\n    flip,\n    FloatingPortal,\n    offset,\n    safePolygon,\n    shift,\n    useFloating,\n    useHover,\n    useInteractions,\n} from \"@floating-ui/react\";\nimport * as jotai from \"jotai\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { BlockEnv } from \"./blockenv\";\n\nfunction isTermViewModel(viewModel: ViewModel): viewModel is TermViewModel {\n    return viewModel?.viewType === \"term\";\n}\n\nfunction LearnMoreButton() {\n    const waveEnv = useWaveEnv<BlockEnv>();\n    return (\n        <button\n            className=\"text-muted text-xs hover:underline cursor-pointer text-left\"\n            onClick={() => waveEnv.electron.openExternal(\"https://docs.waveterm.dev/durable-sessions\")}\n        >\n            Learn More\n        </button>\n    );\n}\n\ninterface StandardSessionContentProps {\n    viewModel: TermViewModel;\n    onClose: () => void;\n}\n\nfunction StandardSessionContent({ viewModel, onClose }: StandardSessionContentProps) {\n    const handleRestartAsDurable = () => {\n        recordTEvent(\"action:termdurable\", { \"action:type\": \"restartdurable\" });\n        onClose();\n        util.fireAndForget(() => viewModel.restartSessionWithDurability(true));\n    };\n\n    return (\n        <div className=\"flex flex-col gap-2 max-w-[280px]\">\n            <div className=\"font-semibold text-sm flex items-center gap-2 text-secondary\">\n                <i className=\"fa-sharp fa-regular fa-shield text-muted\" />\n                Standard SSH Session\n            </div>\n            <div className=\"text-xs text-secondary leading-relaxed\">\n                Standard SSH sessions end when the connection drops. Durable sessions keep your shell state, running\n                programs, and history alive through network changes, computer sleep, and Wave restarts.\n            </div>\n            <button\n                className=\"bg-zinc-700 text-foreground rounded px-3 py-1.5 text-xs font-medium hover:bg-zinc-600 transition-colors cursor-pointer flex items-center justify-center gap-2 mt-1\"\n                onClick={handleRestartAsDurable}\n            >\n                <i className=\"fa-solid fa-shield text-sky-500\" />\n                Restart as Durable\n            </button>\n            <LearnMoreButton />\n        </div>\n    );\n}\n\ninterface DurableAttachedContentProps {\n    onClose: () => void;\n}\n\nfunction DurableAttachedContent({ onClose }: DurableAttachedContentProps) {\n    return (\n        <div className=\"flex flex-col gap-2 max-w-[280px]\">\n            <div className=\"font-semibold text-sm flex items-center gap-2 text-secondary\">\n                <i className=\"fa-sharp fa-solid fa-shield text-sky-500\" />\n                Durable Session (Attached)\n            </div>\n            <div className=\"text-xs text-secondary leading-relaxed\">\n                Your shell state, running programs, and history are protected. This session will survive network\n                disconnects.\n            </div>\n            <LearnMoreButton />\n        </div>\n    );\n}\n\ninterface DurableDetachedContentProps {\n    onClose: () => void;\n}\n\nfunction DurableDetachedContent({ onClose }: DurableDetachedContentProps) {\n    return (\n        <div className=\"flex flex-col gap-2 max-w-[280px]\">\n            <div className=\"font-semibold text-sm flex items-center gap-2 text-secondary\">\n                <i className=\"fa-sharp fa-solid fa-shield text-sky-300\" />\n                Durable Session (Detached)\n            </div>\n            <div className=\"text-xs text-secondary leading-relaxed\">\n                Connection lost, but your session is still running on the remote server. Wave will automatically\n                reconnect when the connection is restored.\n            </div>\n            <LearnMoreButton />\n        </div>\n    );\n}\n\ninterface DurableAwaitingStartProps {\n    connected: boolean;\n    viewModel: TermViewModel;\n    onClose: () => void;\n}\n\nfunction DurableAwaitingStart({ connected, viewModel, onClose }: DurableAwaitingStartProps) {\n    const handleStartSession = () => {\n        onClose();\n        util.fireAndForget(() => viewModel.forceRestartController());\n    };\n\n    if (!connected) {\n        return (\n            <div className=\"flex flex-col gap-2 max-w-[280px]\">\n                <div className=\"font-semibold text-sm flex items-center gap-2 text-secondary whitespace-nowrap\">\n                    <i className=\"fa-sharp fa-solid fa-shield text-muted\" />\n                    Durable Session (Awaiting Connection)\n                </div>\n                <div className=\"text-xs text-secondary leading-relaxed\">\n                    Configured for a durable session. The session will start when the connection is established.\n                </div>\n                <LearnMoreButton />\n            </div>\n        );\n    }\n\n    return (\n        <div className=\"flex flex-col gap-2 max-w-[280px]\">\n            <div className=\"font-semibold text-sm flex items-center gap-2 text-secondary whitespace-nowrap\">\n                <i className=\"fa-sharp fa-solid fa-shield text-muted\" />\n                Durable Session (Awaiting Start)\n            </div>\n            <div className=\"text-xs text-secondary leading-relaxed\">\n                Configured for a durable session, but session hasn't started yet. Click below to start it manually.\n            </div>\n            <button\n                className=\"bg-zinc-700 text-foreground rounded px-3 py-1.5 text-xs font-medium hover:bg-zinc-600 transition-colors cursor-pointer flex items-center justify-center gap-2 mt-1\"\n                onClick={handleStartSession}\n            >\n                <i className=\"fa-solid fa-shield text-sky-500\" />\n                Start Session\n            </button>\n            <LearnMoreButton />\n        </div>\n    );\n}\n\ninterface DurableStartingContentProps {\n    onClose: () => void;\n}\n\nfunction DurableStartingContent({ onClose }: DurableStartingContentProps) {\n    return (\n        <div className=\"flex flex-col gap-2 max-w-[280px]\">\n            <div className=\"font-semibold text-sm flex items-center gap-2 text-secondary\">\n                <i className=\"fa-sharp fa-solid fa-shield text-sky-300\" />\n                Durable Session (Starting)\n            </div>\n            <div className=\"text-xs text-secondary leading-relaxed\">The durable session is starting.</div>\n            <LearnMoreButton />\n        </div>\n    );\n}\n\ninterface DurableEndedContentProps {\n    doneReason: string;\n    startupError?: string;\n    viewModel: TermViewModel;\n    onClose: () => void;\n}\n\nfunction DurableEndedContent({ doneReason, startupError, viewModel, onClose }: DurableEndedContentProps) {\n    const handleRestartSession = () => {\n        onClose();\n        util.fireAndForget(() => viewModel.forceRestartController());\n    };\n\n    const handleRestartAsStandard = () => {\n        onClose();\n        util.fireAndForget(() => viewModel.restartSessionWithDurability(false));\n    };\n\n    let titleText = \"Durable Session (Ended)\";\n    let descriptionText = \"The durable session has ended. This block is still configured for durable sessions.\";\n    const showRestartButton = true;\n\n    if (doneReason === \"terminated\") {\n        titleText = \"Durable Session (Ended, Exited)\";\n        descriptionText =\n            \"The shell was terminated and is no longer running. This block is still configured for durable sessions.\";\n    } else if (doneReason === \"gone\") {\n        titleText = \"Durable Session (Ended, Lost)\";\n        descriptionText =\n            \"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.\";\n    } else if (doneReason === \"startuperror\") {\n        titleText = \"Durable Session (Failed to Start)\";\n        descriptionText = \"The durable session failed to start.\";\n        return (\n            <div className=\"flex flex-col gap-2 max-w-[280px]\">\n                <div className=\"font-semibold text-sm flex items-center gap-2 text-secondary\">\n                    <i className=\"fa-sharp fa-solid fa-shield text-muted\" />\n                    {titleText}\n                </div>\n                <div className=\"text-xs text-secondary leading-relaxed\">{descriptionText}</div>\n                {startupError && (\n                    <div className=\"text-[11px] text-error leading-relaxed max-h-[3.5rem] overflow-y-auto\">\n                        {startupError}\n                    </div>\n                )}\n                <button\n                    className=\"bg-zinc-700 text-foreground rounded px-3 py-1.5 text-xs font-medium hover:bg-zinc-600 transition-colors cursor-pointer flex items-center justify-center gap-2 mt-1\"\n                    onClick={handleRestartSession}\n                >\n                    <i className=\"fa-solid fa-shield text-sky-500\" />\n                    Restart Session\n                </button>\n                <button\n                    className=\"bg-zinc-700 text-foreground rounded px-3 py-1.5 text-xs font-medium hover:bg-zinc-600 transition-colors cursor-pointer flex items-center justify-center gap-2\"\n                    onClick={handleRestartAsStandard}\n                >\n                    <i className=\"fa-sharp fa-regular fa-shield text-muted\" />\n                    Restart as Standard\n                </button>\n                <LearnMoreButton />\n            </div>\n        );\n    }\n\n    return (\n        <div className=\"flex flex-col gap-2 max-w-[280px]\">\n            <div className=\"font-semibold text-sm flex items-center gap-2 text-secondary\">\n                <i className=\"fa-sharp fa-solid fa-shield text-muted\" />\n                {titleText}\n            </div>\n            <div className=\"text-xs text-secondary leading-relaxed\">{descriptionText}</div>\n            {showRestartButton && (\n                <button\n                    className=\"bg-zinc-700 text-foreground rounded px-3 py-1.5 text-xs font-medium hover:bg-zinc-600 transition-colors cursor-pointer flex items-center justify-center gap-2 mt-1\"\n                    onClick={handleRestartSession}\n                >\n                    <i className=\"fa-solid fa-shield text-sky-500\" />\n                    Restart Session\n                </button>\n            )}\n            <LearnMoreButton />\n        </div>\n    );\n}\n\nfunction getContentToRender(\n    viewModel: TermViewModel,\n    onClose: () => void,\n    jobStatus: BlockJobStatusData,\n    connStatus: ConnStatus,\n    isConfigedDurable?: boolean | null\n): string | React.ReactNode {\n    if (isConfigedDurable === false) {\n        return <StandardSessionContent viewModel={viewModel} onClose={onClose} />;\n    }\n\n    const status = jobStatus?.status;\n    if (status === \"connected\") {\n        return <DurableAttachedContent onClose={onClose} />;\n    } else if (status === \"disconnected\") {\n        return <DurableDetachedContent onClose={onClose} />;\n    } else if (status === \"init\") {\n        return <DurableStartingContent onClose={onClose} />;\n    } else if (status === \"done\") {\n        const doneReason = jobStatus?.donereason;\n        const startupError = jobStatus?.startuperror;\n        return (\n            <DurableEndedContent\n                doneReason={doneReason}\n                startupError={startupError}\n                viewModel={viewModel}\n                onClose={onClose}\n            />\n        );\n    } else if (status == null) {\n        return <DurableAwaitingStart connected={!!connStatus?.connected} viewModel={viewModel} onClose={onClose} />;\n    }\n    console.log(\"DurableSessionFlyover: unexpected jobStatus\", jobStatus);\n    return null;\n}\n\nfunction getIconProps(jobStatus: BlockJobStatusData, connStatus: ConnStatus, isConfigedDurable?: boolean | null) {\n    let color = \"text-muted\";\n    let iconType: \"fa-solid\" | \"fa-regular\" = \"fa-solid\";\n\n    if (isConfigedDurable === false) {\n        color = \"text-muted\";\n        iconType = \"fa-regular\";\n        return { color, iconType };\n    }\n\n    const status = jobStatus?.status;\n    if (status === \"connected\") {\n        color = \"text-sky-500\";\n    } else if (status === \"disconnected\") {\n        color = \"text-sky-300\";\n    } else if (status === \"init\") {\n        color = \"text-sky-300\";\n    } else if (status === \"done\") {\n        color = \"text-muted\";\n    } else if (status == null) {\n        color = \"text-muted\";\n    }\n    return { color, iconType };\n}\n\ninterface DurableSessionFlyoverProps {\n    blockId: string;\n    viewModel: ViewModel;\n    placement?: \"top\" | \"bottom\" | \"left\" | \"right\";\n    divClassName?: string;\n}\n\nexport function DurableSessionFlyover({\n    blockId,\n    viewModel,\n    placement = \"bottom\",\n    divClassName,\n}: DurableSessionFlyoverProps) {\n    const waveEnv = useWaveEnv<BlockEnv>();\n    const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(blockId, \"connection\"));\n    const termDurableStatus = util.useAtomValueSafe(viewModel?.termDurableStatus);\n    const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable);\n    const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connName));\n\n    const { color: durableIconColor, iconType: durableIconType } = getIconProps(\n        termDurableStatus,\n        connStatus,\n        termConfigedDurable\n    );\n\n    const [isOpen, setIsOpen] = useState(false);\n    const [isVisible, setIsVisible] = useState(false);\n    const timeoutRef = useRef<number | null>(null);\n\n    const handleClose = () => {\n        setIsVisible(false);\n        if (timeoutRef.current !== null) {\n            window.clearTimeout(timeoutRef.current);\n        }\n        timeoutRef.current = window.setTimeout(() => {\n            setIsOpen(false);\n        }, 300);\n    };\n\n    const { refs, floatingStyles, context } = useFloating({\n        open: isOpen,\n        onOpenChange: (open) => {\n            if (open) {\n                setIsOpen(true);\n                if (timeoutRef.current !== null) {\n                    window.clearTimeout(timeoutRef.current);\n                }\n                timeoutRef.current = window.setTimeout(() => {\n                    setIsVisible(true);\n                }, 300);\n            } else {\n                setIsVisible(false);\n                if (timeoutRef.current !== null) {\n                    window.clearTimeout(timeoutRef.current);\n                }\n                timeoutRef.current = window.setTimeout(() => {\n                    setIsOpen(false);\n                }, 300);\n            }\n        },\n        placement,\n        middleware: [offset(10), flip(), shift({ padding: 12 })],\n        whileElementsMounted: autoUpdate,\n    });\n\n    useEffect(() => {\n        return () => {\n            if (timeoutRef.current !== null) {\n                window.clearTimeout(timeoutRef.current);\n            }\n        };\n    }, []);\n\n    const hover = useHover(context, {\n        handleClose: safePolygon(),\n    });\n    const { getReferenceProps, getFloatingProps } = useInteractions([hover]);\n\n    if (!isTermViewModel(viewModel)) {\n        return null;\n    }\n\n    const content = getContentToRender(viewModel, handleClose, termDurableStatus, connStatus, termConfigedDurable);\n    if (content == null) {\n        return null;\n    }\n\n    return (\n        <>\n            <div ref={refs.setReference} {...getReferenceProps()} className={divClassName}>\n                <i className={`fa-sharp ${durableIconType} fa-shield ${durableIconColor}`} />\n            </div>\n            {isOpen && (\n                <FloatingPortal>\n                    <div\n                        ref={refs.setFloating}\n                        style={{\n                            ...floatingStyles,\n                            opacity: isVisible ? 1 : 0,\n                            transition: \"opacity 200ms ease\",\n                        }}\n                        {...getFloatingProps()}\n                        className={cn(\n                            \"bg-zinc-800 border border-border rounded-md px-3 py-2.5 text-xs text-foreground shadow-xl z-50\"\n                        )}\n                        onMouseDown={(e) => e.stopPropagation()}\n                        onFocusCapture={(e) => e.stopPropagation()}\n                        onClick={(e) => e.stopPropagation()}\n                    >\n                        {content}\n                    </div>\n                </FloatingPortal>\n            )}\n        </>\n    );\n}\n"
  },
  {
    "path": "frontend/app/element/ansiline.tsx",
    "content": "export const ANSI_TAILWIND_MAP = {\n    // Reset and modifiers\n    0: \"reset\", // special: clear state\n    1: \"font-bold\",\n    2: \"opacity-75\",\n    3: \"italic\",\n    4: \"underline\",\n    8: \"invisible\",\n    9: \"line-through\",\n\n    // Foreground standard colors\n    30: \"text-ansi-black\",\n    31: \"text-ansi-red\",\n    32: \"text-ansi-green\",\n    33: \"text-ansi-yellow\",\n    34: \"text-ansi-blue\",\n    35: \"text-ansi-magenta\",\n    36: \"text-ansi-cyan\",\n    37: \"text-ansi-white\",\n\n    // Foreground bright colors\n    90: \"text-ansi-brightblack\",\n    91: \"text-ansi-brightred\",\n    92: \"text-ansi-brightgreen\",\n    93: \"text-ansi-brightyellow\",\n    94: \"text-ansi-brightblue\",\n    95: \"text-ansi-brightmagenta\",\n    96: \"text-ansi-brightcyan\",\n    97: \"text-ansi-brightwhite\",\n\n    // Background standard colors\n    40: \"bg-ansi-black\",\n    41: \"bg-ansi-red\",\n    42: \"bg-ansi-green\",\n    43: \"bg-ansi-yellow\",\n    44: \"bg-ansi-blue\",\n    45: \"bg-ansi-magenta\",\n    46: \"bg-ansi-cyan\",\n    47: \"bg-ansi-white\",\n\n    // Background bright colors\n    100: \"bg-ansi-brightblack\",\n    101: \"bg-ansi-brightred\",\n    102: \"bg-ansi-brightgreen\",\n    103: \"bg-ansi-brightyellow\",\n    104: \"bg-ansi-brightblue\",\n    105: \"bg-ansi-brightmagenta\",\n    106: \"bg-ansi-brightcyan\",\n    107: \"bg-ansi-brightwhite\",\n};\n\ntype InternalStateType = {\n    modifiers: Set<string>;\n    textColor: string | null;\n    bgColor: string | null;\n    reverse: boolean;\n};\n\ntype SegmentType = {\n    text: string;\n    classes: string;\n};\n\nconst makeInitialState: () => InternalStateType = () => ({\n    modifiers: new Set<string>(),\n    textColor: null,\n    bgColor: null,\n    reverse: false,\n});\n\nconst updateStateWithCodes = (state, codes) => {\n    codes.forEach((code) => {\n        if (code === 0) {\n            // Reset state\n            state.modifiers.clear();\n            state.textColor = null;\n            state.bgColor = null;\n            state.reverse = false;\n            return;\n        }\n        // Instead of swapping immediately, we set a flag\n        if (code === 7) {\n            state.reverse = true;\n            return;\n        }\n        const tailwindClass = ANSI_TAILWIND_MAP[code];\n        if (tailwindClass && tailwindClass !== \"reset\") {\n            if (tailwindClass.startsWith(\"text-\")) {\n                state.textColor = tailwindClass;\n            } else if (tailwindClass.startsWith(\"bg-\")) {\n                state.bgColor = tailwindClass;\n            } else {\n                state.modifiers.add(tailwindClass);\n            }\n        }\n    });\n    return state;\n};\n\nconst stateToClasses = (state: InternalStateType) => {\n    const classes = [];\n    classes.push(...Array.from(state.modifiers));\n\n    // Apply reverse: swap text and background colors if flag is set.\n    let textColor = state.textColor;\n    let bgColor = state.bgColor;\n    if (state.reverse) {\n        [textColor, bgColor] = [bgColor, textColor];\n    }\n    if (textColor) classes.push(textColor);\n    if (bgColor) classes.push(bgColor);\n\n    return classes.join(\" \");\n};\n\n// eslint-disable-next-line no-control-regex\nconst ansiRegex = /\\x1b\\[([0-9;]+)m/g;\n\nconst AnsiLine = ({ line }) => {\n    const segments: SegmentType[] = [];\n    let lastIndex = 0;\n    let currentState = makeInitialState();\n\n    let match: RegExpExecArray;\n    while ((match = ansiRegex.exec(line)) !== null) {\n        if (match.index > lastIndex) {\n            segments.push({\n                text: line.substring(lastIndex, match.index),\n                classes: stateToClasses(currentState),\n            });\n        }\n        const codes = match[1].split(\";\").map(Number);\n        updateStateWithCodes(currentState, codes);\n        lastIndex = ansiRegex.lastIndex;\n    }\n\n    if (lastIndex < line.length) {\n        segments.push({\n            text: line.substring(lastIndex),\n            classes: stateToClasses(currentState),\n        });\n    }\n\n    return (\n        <div>\n            {segments.map((seg, idx) => (\n                <span key={idx} className={seg.classes}>\n                    {seg.text}\n                </span>\n            ))}\n        </div>\n    );\n};\n\nexport default AnsiLine;\n"
  },
  {
    "path": "frontend/app/element/button.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.wave-button {\n    // override default button appearance\n    border: 1px solid transparent;\n    outline: 1px solid transparent;\n    border: 1px solid transparent;\n\n    cursor: pointer;\n    display: flex;\n    padding-top: 8px;\n    padding-bottom: 8px;\n    padding-left: 20px;\n    padding-right: 20px;\n    align-items: center;\n    gap: 4px;\n    border-radius: 6px;\n    height: auto;\n    line-height: 16px;\n    white-space: nowrap;\n    user-select: none;\n    font-size: 14px;\n    font-weight: normal;\n    transition: all 0.3s ease;\n\n    &.solid {\n        &.green {\n            color: var(--button-text-color);\n            background-color: var(--accent-color);\n            border: 1px solid var(--button-green-border-color);\n            &:hover {\n                color: var(--button-text-color);\n                background-color: var(--button-green-border-color);\n            }\n        }\n\n        &.grey {\n            background-color: var(--button-grey-bg);\n            border: 1px solid var(--button-grey-bg);\n            color: var(--main-text-color);\n            &:hover {\n                color: var(--main-text-color);\n                background-color: var(--button-grey-hover-bg);\n            }\n        }\n\n        &.red {\n            background-color: var(--button-red-bg);\n            border: 1px solid var(--button-red-border-color);\n            color: var(--main-text-color);\n            &:hover {\n                background-color: var(--button-red-hover-bg);\n            }\n        }\n\n        &.yellow {\n            color: var(--button-text-color);\n            background-color: var(--button-yellow-bg);\n            border: 1px solid var(--button-yellow-hover-bg);\n            &:hover {\n                color: var(--button-text-color);\n                background-color: var(--button-yellow-hover-bg);\n            }\n        }\n    }\n\n    &.outlined {\n        background-color: transparent;\n        &.green {\n            color: var(--accent-color);\n            border: 1px solid var(--accent-color);\n            &:hover {\n                color: var(--button-green-border-color);\n                border: 1px solid var(--button-green-border-color);\n            }\n        }\n\n        &.grey {\n            border: 1px solid var(--button-grey-outlined-color);\n            color: var(--button-grey-outlined-color);\n            &:hover {\n                color: var(--main-text-color);\n                border: 1px solid var(--main-text-color);\n            }\n        }\n\n        &.red {\n            border: 1px solid var(--button-red-bg);\n            color: var(--button-red-bg);\n            &:hover {\n                color: var(--button-red-outlined-color);\n                border: 1px solid var(--button-red-outlined-color);\n            }\n        }\n\n        &.yellow {\n            color: var(--button-yellow-bg);\n            border: 1px solid var(--button-yellow-bg);\n            &:hover {\n                color: var(--button-yellow-hover-bg);\n                border: 1px solid var(--button-yellow-hover-bg);\n            }\n        }\n    }\n\n    &.ghost {\n        background-color: transparent;\n        padding-top: 8px;\n        padding-bottom: 8px;\n        padding-left: 8px;\n        padding-right: 8px;\n\n        &.green {\n            border: none;\n            color: var(--accent-color);\n            &:hover {\n                color: var(--button-green-border-color);\n            }\n        }\n\n        &.grey {\n            border: none;\n            color: var(--button-grey-outlined-color);\n            &:hover {\n                color: var(--main-text-color);\n            }\n        }\n\n        &.red {\n            border: none;\n            color: var(--button-red-bg);\n            &:hover {\n                color: var(--button-red-border-color);\n            }\n        }\n\n        &.yellow {\n            border: none;\n            color: var(--button-yellow-bg);\n            &:hover {\n                color: var(--button-yellow-hover-bg);\n            }\n        }\n    }\n\n    &.bold {\n        font-weight: bold;\n    }\n\n    &:disabled {\n        cursor: default;\n        opacity: 0.5;\n        pointer-events: none;\n    }\n\n    &:focus-visible {\n        outline: 1px solid var(--success-color);\n        outline-offset: 2px;\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/button.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport clsx from \"clsx\";\nimport { forwardRef, memo, ReactNode, useImperativeHandle, useRef } from \"react\";\n\nimport \"./button.scss\";\n\ninterface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n    className?: string;\n    children?: ReactNode;\n    as?: keyof React.JSX.IntrinsicElements | React.ComponentType<any>;\n}\n\nconst Button = memo(\n    forwardRef<HTMLButtonElement, ButtonProps>(\n        ({ children, disabled, className = \"\", as: Component = \"button\", ...props }: ButtonProps, ref) => {\n            const btnRef = useRef<HTMLButtonElement>(null);\n            useImperativeHandle(ref, () => btnRef.current as HTMLButtonElement);\n\n            // Check if the className contains any of the categories: solid, outlined, or ghost\n            const containsButtonCategory = /(solid|outline|ghost)/.test(className);\n            // If no category is present, default to 'solid'\n            const categoryClassName = containsButtonCategory ? className : `solid ${className}`;\n\n            // Check if the className contains any of the color options: green, grey, red, or yellow\n            const containsColor = /(green|grey|red|yellow)/.test(categoryClassName);\n            // If no color is present, default to 'green'\n            const finalClassName = containsColor ? categoryClassName : `green ${categoryClassName}`;\n\n            return (\n                <Component\n                    ref={btnRef}\n                    tabIndex={disabled ? -1 : 0}\n                    className={clsx(\"wave-button\", finalClassName)}\n                    disabled={disabled}\n                    {...props}\n                >\n                    {children}\n                </Component>\n            );\n        }\n    )\n);\n\nButton.displayName = \"Button\";\n\nexport { Button };\n"
  },
  {
    "path": "frontend/app/element/copybutton.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.copy-button {\n    &.copied {\n        opacity: 1;\n        i {\n            color: var(--success-color);\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/copybutton.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport clsx from \"clsx\";\nimport { useEffect, useRef, useState } from \"react\";\nimport \"./copybutton.scss\";\nimport { IconButton } from \"./iconbutton\";\n\ntype CopyButtonProps = {\n    title: string;\n    className?: string;\n    onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;\n};\n\nconst CopyButton = ({ title, className, onClick }: CopyButtonProps) => {\n    const [isCopied, setIsCopied] = useState(false);\n    const timeoutRef = useRef<NodeJS.Timeout | null>(null);\n\n    const handleOnClick = (e: React.MouseEvent<HTMLButtonElement>) => {\n        if (isCopied) {\n            return;\n        }\n        setIsCopied(true);\n        if (timeoutRef.current) {\n            clearTimeout(timeoutRef.current);\n        }\n        timeoutRef.current = setTimeout(() => {\n            setIsCopied(false);\n            timeoutRef.current = null;\n        }, 2000);\n\n        if (onClick) {\n            onClick(e);\n        }\n    };\n\n    useEffect(() => {\n        return () => {\n            if (timeoutRef.current) {\n                clearTimeout(timeoutRef.current);\n            }\n        };\n    }, []);\n\n    return (\n        <IconButton\n            decl={{\n                elemtype: \"iconbutton\",\n                icon: isCopied ? \"check\" : \"copy\",\n                title,\n                className: clsx(\"copy-button\", { copied: isCopied }),\n                click: handleOnClick,\n            }}\n            className={className}\n        ></IconButton>\n    );\n};\n\nexport { CopyButton };\n"
  },
  {
    "path": "frontend/app/element/emojibutton.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { cn, makeIconClass } from \"@/util/util\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\n\nexport const EmojiButton = ({\n    emoji,\n    icon,\n    isClicked,\n    onClick,\n    className,\n    suppressFlyUp,\n}: {\n    emoji?: string;\n    icon?: string;\n    isClicked: boolean;\n    onClick: () => void;\n    className?: string;\n    suppressFlyUp?: boolean;\n}) => {\n    const [showFloating, setShowFloating] = useState(false);\n    const prevClickedRef = useRef(isClicked);\n\n    useLayoutEffect(() => {\n        if (isClicked && !prevClickedRef.current && !suppressFlyUp) {\n            setShowFloating(true);\n            setTimeout(() => setShowFloating(false), 600);\n        }\n        prevClickedRef.current = isClicked;\n    }, [isClicked, suppressFlyUp]);\n\n    const content = icon ? <i className={makeIconClass(icon, false)} /> : emoji;\n\n    return (\n        <div className=\"relative inline-block\">\n            <button\n                onClick={onClick}\n                className={cn(\n                    \"px-2 py-1 rounded border cursor-pointer transition-colors\",\n                    isClicked\n                        ? \"bg-accent/20 border-accent text-accent\"\n                        : \"bg-transparent border-border/50 text-foreground/70 hover:border-border\",\n                    className\n                )}\n            >\n                {content}\n            </button>\n            {showFloating && (\n                <span\n                    className=\"absolute pointer-events-none animate-[float-up_0.6s_ease-out_forwards]\"\n                    style={{\n                        left: \"50%\",\n                        bottom: \"100%\",\n                    }}\n                >\n                    {content}\n                </span>\n            )}\n        </div>\n    );\n};\n"
  },
  {
    "path": "frontend/app/element/emojipalette.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.emoji-palette-content {\n    padding: 10px;\n    max-height: 350px;\n    width: 300px;\n    display: flex;\n    flex-direction: column;\n}\n\n.emoji-grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fit, minmax(35px, 1fr));\n    gap: 10px;\n    padding: 10px 0;\n    width: 100%;\n    height: 300px;\n    overflow-y: auto;\n}\n\n.emoji-button {\n    font-size: 24px;\n    padding: 5px;\n    cursor: pointer;\n    background: none;\n    border: none;\n    transition: background-color 0.3s ease;\n\n    &:hover {\n        background-color: rgba(0, 0, 0, 0.1);\n        border-radius: 5px;\n    }\n}\n\n.no-emojis {\n    font-size: 14px;\n    color: #888;\n    text-align: center;\n}\n"
  },
  {
    "path": "frontend/app/element/emojipalette.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { type Placement } from \"@floating-ui/react\";\nimport clsx from \"clsx\";\nimport { memo, useState } from \"react\";\nimport { Button } from \"./button\";\nimport { Input, InputGroup, InputLeftElement } from \"./input\";\nimport { Popover, PopoverButton, PopoverContent } from \"./popover\";\n\nimport \"./emojipalette.scss\";\n\ntype EmojiItem = { emoji: string; name: string };\n\nconst emojiList: EmojiItem[] = [\n    // Smileys & Emotion\n    { emoji: \"😀\", name: \"grinning face\" },\n    { emoji: \"😁\", name: \"beaming face with smiling eyes\" },\n    { emoji: \"😂\", name: \"face with tears of joy\" },\n    { emoji: \"🤣\", name: \"rolling on the floor laughing\" },\n    { emoji: \"😃\", name: \"grinning face with big eyes\" },\n    { emoji: \"😄\", name: \"grinning face with smiling eyes\" },\n    { emoji: \"😅\", name: \"grinning face with sweat\" },\n    { emoji: \"😆\", name: \"grinning squinting face\" },\n    { emoji: \"😉\", name: \"winking face\" },\n    { emoji: \"😊\", name: \"smiling face with smiling eyes\" },\n    { emoji: \"😋\", name: \"face savoring food\" },\n    { emoji: \"😎\", name: \"smiling face with sunglasses\" },\n    { emoji: \"😍\", name: \"smiling face with heart-eyes\" },\n    { emoji: \"😘\", name: \"face blowing a kiss\" },\n    { emoji: \"😗\", name: \"kissing face\" },\n    { emoji: \"😙\", name: \"kissing face with smiling eyes\" },\n    { emoji: \"😚\", name: \"kissing face with closed eyes\" },\n    { emoji: \"🙂\", name: \"slightly smiling face\" },\n    { emoji: \"🤗\", name: \"hugging face\" },\n    { emoji: \"🤔\", name: \"thinking face\" },\n    { emoji: \"😐\", name: \"neutral face\" },\n    { emoji: \"😑\", name: \"expressionless face\" },\n    { emoji: \"😶\", name: \"face without mouth\" },\n    { emoji: \"🙄\", name: \"face with rolling eyes\" },\n    { emoji: \"😏\", name: \"smirking face\" },\n    { emoji: \"😣\", name: \"persevering face\" },\n    { emoji: \"😥\", name: \"sad but relieved face\" },\n    { emoji: \"😮\", name: \"face with open mouth\" },\n    { emoji: \"🤐\", name: \"zipper-mouth face\" },\n    { emoji: \"😯\", name: \"hushed face\" },\n    { emoji: \"😪\", name: \"sleepy face\" },\n    { emoji: \"😫\", name: \"tired face\" },\n    { emoji: \"🥱\", name: \"yawning face\" },\n    { emoji: \"😴\", name: \"sleeping face\" },\n    { emoji: \"😌\", name: \"relieved face\" },\n    { emoji: \"😛\", name: \"face with tongue\" },\n    { emoji: \"😜\", name: \"winking face with tongue\" },\n    { emoji: \"😝\", name: \"squinting face with tongue\" },\n    { emoji: \"🤤\", name: \"drooling face\" },\n    { emoji: \"😒\", name: \"unamused face\" },\n    { emoji: \"😓\", name: \"downcast face with sweat\" },\n    { emoji: \"😔\", name: \"pensive face\" },\n    { emoji: \"😕\", name: \"confused face\" },\n    { emoji: \"🙃\", name: \"upside-down face\" },\n    { emoji: \"🫠\", name: \"melting face\" },\n    { emoji: \"😲\", name: \"astonished face\" },\n    { emoji: \"☹️\", name: \"frowning face\" },\n    { emoji: \"🙁\", name: \"slightly frowning face\" },\n    { emoji: \"😖\", name: \"confounded face\" },\n    { emoji: \"😞\", name: \"disappointed face\" },\n    { emoji: \"😟\", name: \"worried face\" },\n    { emoji: \"😤\", name: \"face with steam from nose\" },\n    { emoji: \"😢\", name: \"crying face\" },\n    { emoji: \"😭\", name: \"loudly crying face\" },\n    { emoji: \"😦\", name: \"frowning face with open mouth\" },\n    { emoji: \"😧\", name: \"anguished face\" },\n    { emoji: \"😨\", name: \"fearful face\" },\n    { emoji: \"😩\", name: \"weary face\" },\n    { emoji: \"🤯\", name: \"exploding head\" },\n    { emoji: \"😬\", name: \"grimacing face\" },\n    { emoji: \"😰\", name: \"anxious face with sweat\" },\n    { emoji: \"😱\", name: \"face screaming in fear\" },\n    { emoji: \"🥵\", name: \"hot face\" },\n    { emoji: \"🥶\", name: \"cold face\" },\n    { emoji: \"😳\", name: \"flushed face\" },\n    { emoji: \"🤪\", name: \"zany face\" },\n    { emoji: \"😵\", name: \"dizzy face\" },\n    { emoji: \"🥴\", name: \"woozy face\" },\n    { emoji: \"😠\", name: \"angry face\" },\n    { emoji: \"😡\", name: \"pouting face\" },\n    { emoji: \"🤬\", name: \"face with symbols on mouth\" },\n    { emoji: \"🤮\", name: \"face vomiting\" },\n    { emoji: \"🤢\", name: \"nauseated face\" },\n    { emoji: \"😷\", name: \"face with medical mask\" },\n\n    // Gestures & Hand Signs\n    { emoji: \"👋\", name: \"waving hand\" },\n    { emoji: \"🤚\", name: \"raised back of hand\" },\n    { emoji: \"🖐️\", name: \"hand with fingers splayed\" },\n    { emoji: \"✋\", name: \"raised hand\" },\n    { emoji: \"👌\", name: \"OK hand\" },\n    { emoji: \"✌️\", name: \"victory hand\" },\n    { emoji: \"🤞\", name: \"crossed fingers\" },\n    { emoji: \"🤟\", name: \"love-you gesture\" },\n    { emoji: \"🤘\", name: \"sign of the horns\" },\n    { emoji: \"🤙\", name: \"call me hand\" },\n    { emoji: \"👈\", name: \"backhand index pointing left\" },\n    { emoji: \"👉\", name: \"backhand index pointing right\" },\n    { emoji: \"👆\", name: \"backhand index pointing up\" },\n    { emoji: \"👇\", name: \"backhand index pointing down\" },\n    { emoji: \"👍\", name: \"thumbs up\" },\n    { emoji: \"👎\", name: \"thumbs down\" },\n    { emoji: \"👏\", name: \"clapping hands\" },\n    { emoji: \"🙌\", name: \"raising hands\" },\n    { emoji: \"👐\", name: \"open hands\" },\n    { emoji: \"🙏\", name: \"folded hands\" },\n\n    // Animals & Nature\n    { emoji: \"🐶\", name: \"dog face\" },\n    { emoji: \"🐱\", name: \"cat face\" },\n    { emoji: \"🐭\", name: \"mouse face\" },\n    { emoji: \"🐹\", name: \"hamster face\" },\n    { emoji: \"🐰\", name: \"rabbit face\" },\n    { emoji: \"🦊\", name: \"fox face\" },\n    { emoji: \"🐻\", name: \"bear face\" },\n    { emoji: \"🐼\", name: \"panda face\" },\n    { emoji: \"🐨\", name: \"koala\" },\n    { emoji: \"🐯\", name: \"tiger face\" },\n    { emoji: \"🦁\", name: \"lion\" },\n    { emoji: \"🐮\", name: \"cow face\" },\n    { emoji: \"🐷\", name: \"pig face\" },\n    { emoji: \"🐸\", name: \"frog face\" },\n    { emoji: \"🐵\", name: \"monkey face\" },\n    { emoji: \"🦄\", name: \"unicorn face\" },\n    { emoji: \"🐢\", name: \"turtle\" },\n    { emoji: \"🐍\", name: \"snake\" },\n    { emoji: \"🦋\", name: \"butterfly\" },\n    { emoji: \"🐝\", name: \"honeybee\" },\n    { emoji: \"🐞\", name: \"lady beetle\" },\n    { emoji: \"🦀\", name: \"crab\" },\n    { emoji: \"🐠\", name: \"tropical fish\" },\n    { emoji: \"🐟\", name: \"fish\" },\n    { emoji: \"🐬\", name: \"dolphin\" },\n    { emoji: \"🐳\", name: \"spouting whale\" },\n    { emoji: \"🐋\", name: \"whale\" },\n    { emoji: \"🦈\", name: \"shark\" },\n\n    // Food & Drink\n    { emoji: \"🍏\", name: \"green apple\" },\n    { emoji: \"🍎\", name: \"red apple\" },\n    { emoji: \"🍐\", name: \"pear\" },\n    { emoji: \"🍊\", name: \"tangerine\" },\n    { emoji: \"🍋\", name: \"lemon\" },\n    { emoji: \"🍌\", name: \"banana\" },\n    { emoji: \"🍉\", name: \"watermelon\" },\n    { emoji: \"🍇\", name: \"grapes\" },\n    { emoji: \"🍓\", name: \"strawberry\" },\n    { emoji: \"🫐\", name: \"blueberries\" },\n    { emoji: \"🍈\", name: \"melon\" },\n    { emoji: \"🍒\", name: \"cherries\" },\n    { emoji: \"🍑\", name: \"peach\" },\n    { emoji: \"🥭\", name: \"mango\" },\n    { emoji: \"🍍\", name: \"pineapple\" },\n    { emoji: \"🥥\", name: \"coconut\" },\n    { emoji: \"🥑\", name: \"avocado\" },\n    { emoji: \"🥦\", name: \"broccoli\" },\n    { emoji: \"🥕\", name: \"carrot\" },\n    { emoji: \"🌽\", name: \"corn\" },\n    { emoji: \"🌶️\", name: \"hot pepper\" },\n    { emoji: \"🍔\", name: \"hamburger\" },\n    { emoji: \"🍟\", name: \"french fries\" },\n    { emoji: \"🍕\", name: \"pizza\" },\n    { emoji: \"🌭\", name: \"hot dog\" },\n    { emoji: \"🥪\", name: \"sandwich\" },\n    { emoji: \"🍿\", name: \"popcorn\" },\n    { emoji: \"🥓\", name: \"bacon\" },\n    { emoji: \"🥚\", name: \"egg\" },\n    { emoji: \"🍰\", name: \"cake\" },\n    { emoji: \"🎂\", name: \"birthday cake\" },\n    { emoji: \"🍦\", name: \"ice cream\" },\n    { emoji: \"🍩\", name: \"doughnut\" },\n    { emoji: \"🍪\", name: \"cookie\" },\n    { emoji: \"🍫\", name: \"chocolate bar\" },\n    { emoji: \"🍬\", name: \"candy\" },\n    { emoji: \"🍭\", name: \"lollipop\" },\n\n    // Activities\n    { emoji: \"⚽\", name: \"soccer ball\" },\n    { emoji: \"🏀\", name: \"basketball\" },\n    { emoji: \"🏈\", name: \"american football\" },\n    { emoji: \"⚾\", name: \"baseball\" },\n    { emoji: \"🥎\", name: \"softball\" },\n    { emoji: \"🎾\", name: \"tennis\" },\n    { emoji: \"🏐\", name: \"volleyball\" },\n    { emoji: \"🎳\", name: \"bowling\" },\n    { emoji: \"⛳\", name: \"flag in hole\" },\n    { emoji: \"🚴\", name: \"person biking\" },\n    { emoji: \"🎮\", name: \"video game\" },\n    { emoji: \"🎲\", name: \"game die\" },\n    { emoji: \"🎸\", name: \"guitar\" },\n    { emoji: \"🎺\", name: \"trumpet\" },\n\n    // Miscellaneous\n    { emoji: \"🚀\", name: \"rocket\" },\n    { emoji: \"💖\", name: \"sparkling heart\" },\n    { emoji: \"🎉\", name: \"party popper\" },\n    { emoji: \"🔥\", name: \"fire\" },\n    { emoji: \"🎁\", name: \"gift\" },\n    { emoji: \"❤️\", name: \"red heart\" },\n    { emoji: \"🧡\", name: \"orange heart\" },\n    { emoji: \"💛\", name: \"yellow heart\" },\n    { emoji: \"💚\", name: \"green heart\" },\n    { emoji: \"💙\", name: \"blue heart\" },\n    { emoji: \"💜\", name: \"purple heart\" },\n    { emoji: \"🤍\", name: \"white heart\" },\n    { emoji: \"🤎\", name: \"brown heart\" },\n    { emoji: \"💔\", name: \"broken heart\" },\n];\n\ninterface EmojiPaletteProps {\n    className?: string;\n    placement?: Placement;\n    onSelect?: (_: EmojiItem) => void;\n}\n\nconst EmojiPalette = memo(({ className, placement, onSelect }: EmojiPaletteProps) => {\n    const [searchTerm, setSearchTerm] = useState(\"\");\n\n    const handleSearchChange = (val: string) => {\n        setSearchTerm(val.toLowerCase());\n    };\n\n    const handleSelect = (item: { name: string; emoji: string }) => {\n        onSelect?.(item);\n    };\n\n    const filteredEmojis = emojiList.filter((item) => item.name.includes(searchTerm));\n\n    return (\n        <div className={clsx(\"emoji-palette\", className)}>\n            <Popover placement={placement}>\n                <PopoverButton className=\"ghost grey\">\n                    <i className=\"fa-sharp fa-solid fa-face-smile\"></i>\n                </PopoverButton>\n                <PopoverContent className=\"emoji-palette-content\">\n                    <InputGroup>\n                        <InputLeftElement>\n                            <i className=\"fa-sharp fa-solid fa-magnifying-glass\"></i>\n                        </InputLeftElement>\n                        <Input placeholder=\"Search emojis...\" value={searchTerm} onChange={handleSearchChange} />\n                    </InputGroup>\n                    <div className=\"emoji-grid\">\n                        {filteredEmojis.length > 0 ? (\n                            filteredEmojis.map((item, index) => (\n                                <Button key={index} className=\"ghost emoji-button\" onClick={() => handleSelect(item)}>\n                                    {item.emoji}\n                                </Button>\n                            ))\n                        ) : (\n                            <div className=\"no-emojis\">No emojis found</div>\n                        )}\n                    </div>\n                </PopoverContent>\n            </Popover>\n        </div>\n    );\n});\n\nEmojiPalette.displayName = \"EmojiPalette\";\n\nexport { EmojiPalette };\nexport type { EmojiItem };\n"
  },
  {
    "path": "frontend/app/element/errorboundary.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport React, { ReactNode } from \"react\";\n\nexport class ErrorBoundary extends React.Component<\n    { children: ReactNode; fallback?: React.ReactElement & { error?: Error } },\n    { error: Error }\n> {\n    constructor(props) {\n        super(props);\n        this.state = { error: null };\n    }\n\n    componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {\n        console.error(\"ErrorBoundary caught an error:\", error, errorInfo);\n        this.setState({ error: error });\n    }\n\n    render() {\n        const { fallback } = this.props;\n        const { error } = this.state;\n        if (error) {\n            if (fallback != null) {\n                return React.cloneElement(fallback as any, { error });\n            }\n            const errorMsg = `Error: ${error?.message}\\n\\n${error?.stack}`;\n            return <pre className=\"error-boundary\">{errorMsg}</pre>;\n        } else {\n            return <>{this.props.children}</>;\n        }\n    }\n}\n\nexport class NullErrorBoundary extends React.Component<\n    { children: React.ReactNode; debugName?: string },\n    { hasError: boolean }\n> {\n    constructor(props: { children: React.ReactNode; debugName?: string }) {\n        super(props);\n        this.state = { hasError: false };\n    }\n\n    static getDerivedStateFromError() {\n        return { hasError: true };\n    }\n\n    componentDidCatch(error: Error, info: React.ErrorInfo) {\n        console.error(`${this.props.debugName ?? \"NullErrorBoundary\"} error boundary caught error`, error, info);\n    }\n\n    render() {\n        if (this.state.hasError) {\n            return null;\n        }\n        return this.props.children;\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/expandablemenu.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.expandable-menu {\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n    overflow: visible;\n}\n\n.expandable-menu-item,\n.expandable-menu-item-group-title {\n    display: flex;\n    align-items: center;\n    padding: 8px 12px; /* Left and right padding, we'll adjust this for the right side */\n    cursor: pointer;\n    box-sizing: border-box;\n    border-radius: 4px;\n\n    .label {\n        display: block;\n        white-space: nowrap;\n        overflow: hidden;\n        text-overflow: ellipsis;\n    }\n}\n\n.expandable-menu-item-group-title {\n    &:hover {\n        background-color: var(--button-grey-hover-bg);\n    }\n}\n\n.expandable-menu-item {\n    &.with-hover-effect {\n        &:hover {\n            background-color: var(--button-grey-hover-bg);\n        }\n    }\n}\n\n.expandable-menu-item-left,\n.expandable-menu-item-right {\n    display: flex;\n    align-items: center;\n}\n\n.expandable-menu-item-left {\n    margin-right: 8px; /* Space for the left element */\n}\n\n.expandable-menu-item-right {\n    margin-left: auto; /* This keeps the right element (if any) on the far right */\n    white-space: nowrap;\n}\n\n.expandable-menu-item-content {\n    flex-grow: 1; /* Ensures the content grows to fill available space between left and right elements */\n}\n\n.expandable-menu-item-group-content {\n    max-height: 0;\n    overflow: hidden;\n    margin-left: 16px; /* Retaining left indentation */\n    margin-right: 0; /* Removing right padding */\n\n    &.open {\n        max-height: 1000px; /* Ensure large enough max-height for expansion */\n    }\n}\n\n.no-indent .expandable-menu-item-group-content {\n    margin-left: 0; // Remove left indentation when noIndent is true\n}\n"
  },
  {
    "path": "frontend/app/element/expandablemenu.tsx",
    "content": "// Copyright 2025, Command Line\n// SPDX-License-Identifier: Apache-2.0\n\nimport clsx from \"clsx\";\nimport { atom, useAtom } from \"jotai\";\nimport { Children, ReactElement, ReactNode, cloneElement, isValidElement, useRef } from \"react\";\n\nimport \"./expandablemenu.scss\";\n\n// Define the global atom for managing open groups\nconst openGroupsAtom = atom<{ [key: string]: boolean }>({});\n\ntype BaseExpandableMenuItem = {\n    type: \"item\" | \"group\";\n    id?: string;\n};\n\ninterface ExpandableMenuItemType extends BaseExpandableMenuItem {\n    type: \"item\";\n    leftElement?: string | ReactNode;\n    rightElement?: string | ReactNode;\n    content?: React.ReactNode | ((props: any) => React.ReactNode);\n}\n\ninterface ExpandableMenuItemGroupTitleType {\n    leftElement?: string | ReactNode;\n    label: string;\n    rightElement?: string | ReactNode;\n}\n\ninterface ExpandableMenuItemGroupType extends BaseExpandableMenuItem {\n    type: \"group\";\n    title: ExpandableMenuItemGroupTitleType;\n    isOpen?: boolean;\n    children?: ExpandableMenuItemData[];\n}\n\ntype ExpandableMenuItemData = ExpandableMenuItemType | ExpandableMenuItemGroupType;\n\ntype ExpandableMenuProps = {\n    children: React.ReactNode;\n    className?: string;\n    noIndent?: boolean;\n    singleOpen?: boolean;\n};\n\nconst ExpandableMenu = ({ children, className, noIndent = false, singleOpen = false }: ExpandableMenuProps) => {\n    return (\n        <div className={clsx(\"expandable-menu\", className, { \"no-indent\": noIndent })}>\n            {Children.map(children, (child) => {\n                if (isValidElement(child) && child.type === ExpandableMenuItemGroup) {\n                    return cloneElement(child as any, { singleOpen });\n                }\n                return child;\n            })}\n        </div>\n    );\n};\n\ntype ExpandableMenuItemProps = {\n    children: ReactNode;\n    className?: string;\n    withHoverEffect?: boolean;\n    onClick?: () => void;\n};\n\nconst ExpandableMenuItem = ({ children, className, withHoverEffect = true, onClick }: ExpandableMenuItemProps) => {\n    return (\n        <div\n            className={clsx(\"expandable-menu-item\", className, {\n                \"with-hover-effect\": withHoverEffect,\n            })}\n            onClick={onClick}\n        >\n            {children}\n        </div>\n    );\n};\n\ntype ExpandableMenuItemGroupTitleProps = {\n    children: ReactNode;\n    className?: string;\n    onClick?: () => void;\n};\n\nconst ExpandableMenuItemGroupTitle = ({ children, className, onClick }: ExpandableMenuItemGroupTitleProps) => {\n    return (\n        <div className={clsx(\"expandable-menu-item-group-title\", className)} onClick={onClick}>\n            {children}\n        </div>\n    );\n};\n\ntype ExpandableMenuItemGroupProps = {\n    children: React.ReactNode;\n    className?: string;\n    isOpen?: boolean;\n    onToggle?: (isOpen: boolean) => void;\n    singleOpen?: boolean;\n};\n\nconst ExpandableMenuItemGroup = ({\n    children,\n    className,\n    isOpen,\n    onToggle,\n    singleOpen = false,\n}: ExpandableMenuItemGroupProps) => {\n    const [openGroups, setOpenGroups] = useAtom(openGroupsAtom);\n\n    // Generate a unique ID for this group using useRef\n    const idRef = useRef<string>(null);\n\n    if (!idRef.current) {\n        // Generate a unique ID when the component is first rendered\n        idRef.current = `group-${Math.random().toString(36).substr(2, 9)}`;\n    }\n\n    const id = idRef.current;\n\n    // Determine if the component is controlled or uncontrolled\n    const isControlled = isOpen !== undefined;\n\n    // Get the open state from global atom in uncontrolled mode\n    const actualIsOpen = isControlled ? isOpen : (openGroups[id] ?? false);\n\n    const toggleOpen = () => {\n        const newIsOpen = !actualIsOpen;\n\n        if (isControlled) {\n            // If controlled, call the onToggle callback\n            onToggle?.(newIsOpen);\n        } else {\n            // If uncontrolled, update global atom\n            setOpenGroups((prevOpenGroups) => {\n                if (singleOpen) {\n                    // Close all other groups and open this one\n                    return { [id]: newIsOpen };\n                } else {\n                    // Toggle this group\n                    return { ...prevOpenGroups, [id]: newIsOpen };\n                }\n            });\n        }\n    };\n\n    const renderChildren = Children.map(children, (child: ReactElement) => {\n        if (child && child.type === ExpandableMenuItemGroupTitle) {\n            const childProps = child.props as ExpandableMenuItemGroupTitleProps;\n            return cloneElement(child as ReactElement<ExpandableMenuItemGroupTitleProps>, {\n                ...childProps,\n                onClick: () => {\n                    childProps.onClick?.();\n                    toggleOpen();\n                },\n            });\n        } else {\n            return <div className={clsx(\"expandable-menu-item-group-content\", { open: actualIsOpen })}>{child}</div>;\n        }\n    });\n\n    return (\n        <div className={clsx(\"expandable-menu-item-group\", className, { open: actualIsOpen })}>{renderChildren}</div>\n    );\n};\n\ntype ExpandableMenuItemLeftElementProps = {\n    children: ReactNode;\n    onClick?: () => void;\n};\n\nconst ExpandableMenuItemLeftElement = ({ children, onClick }: ExpandableMenuItemLeftElementProps) => {\n    return (\n        <div className=\"expandable-menu-item-left\" onClick={onClick}>\n            {children}\n        </div>\n    );\n};\n\ntype ExpandableMenuItemRightElementProps = {\n    children: ReactNode;\n    onClick?: () => void;\n};\n\nconst ExpandableMenuItemRightElement = ({ children, onClick }: ExpandableMenuItemRightElementProps) => {\n    return (\n        <div className=\"expandable-menu-item-right\" onClick={onClick}>\n            {children}\n        </div>\n    );\n};\n\nexport {\n    ExpandableMenu,\n    ExpandableMenuItem,\n    ExpandableMenuItemGroup,\n    ExpandableMenuItemGroupTitle,\n    ExpandableMenuItemLeftElement,\n    ExpandableMenuItemRightElement,\n};\nexport type { ExpandableMenuItemData, ExpandableMenuItemGroupTitleType };\n"
  },
  {
    "path": "frontend/app/element/flyoutmenu.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.menu {\n    position: absolute;\n    z-index: 1000;\n    display: flex;\n    max-width: 400px;\n    min-width: 125px;\n    padding: 2px;\n    flex-direction: column;\n    justify-content: flex-end;\n    align-items: flex-start;\n    gap: 1px;\n    border-radius: 4px;\n    border: 1px solid rgba(255, 255, 255, 0.15);\n    background: #212121;\n    box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3);\n}\n\n.menu-item {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    padding: 4px 6px;\n    cursor: pointer;\n    color: var(--main-text-color);\n    font-size: 12px;\n    font-style: normal;\n    font-weight: 400;\n    line-height: normal;\n    letter-spacing: -0.12px;\n    width: 100%;\n    border-radius: 2px;\n\n    .label {\n        display: block;\n        white-space: nowrap;\n        overflow: hidden;\n        text-overflow: ellipsis;\n        text-decoration: none;\n    }\n}\n\n.menu-item {\n    color: var(--main-text-color);\n\n    &:hover {\n        background-color: var(--accent-color);\n        color: var(--button-text-color);\n        border-radius: 2px;\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/flyoutmenu.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { FloatingPortal, type Placement, useDismiss, useFloating, useInteractions } from \"@floating-ui/react\";\nimport clsx from \"clsx\";\nimport { createRef, Fragment, memo, ReactNode, useRef, useState } from \"react\";\nimport ReactDOM from \"react-dom\";\n\nimport \"./flyoutmenu.scss\";\n\ntype MenuProps = {\n    items: MenuItem[];\n    className?: string;\n    placement?: Placement;\n    onOpenChange?: (isOpen: boolean) => void;\n    children: ReactNode | ReactNode[];\n    renderMenu?: (subMenu: React.ReactElement, props: any) => React.ReactElement;\n    renderMenuItem?: (item: MenuItem, props: any) => React.ReactElement;\n};\n\nconst FlyoutMenuComponent = memo(\n    ({ items, children, className, placement, onOpenChange, renderMenu, renderMenuItem }: MenuProps) => {\n        const [visibleSubMenus, setVisibleSubMenus] = useState<{ [key: string]: any }>({});\n        const [hoveredItems, setHoveredItems] = useState<string[]>([]);\n        const [subMenuPosition, setSubMenuPosition] = useState<{\n            [key: string]: { top: number; left: number; label: string };\n        }>({});\n        const subMenuRefs = useRef<{ [key: string]: React.RefObject<HTMLDivElement> }>({});\n\n        const [isOpen, setIsOpen] = useState(false);\n        const onOpenChangeMenu = (isOpen: boolean) => {\n            setIsOpen(isOpen);\n            onOpenChange?.(isOpen);\n        };\n        const { refs, floatingStyles, context } = useFloating({\n            placement: placement ?? \"bottom-start\",\n            open: isOpen,\n            onOpenChange: onOpenChangeMenu,\n        });\n        const dismiss = useDismiss(context);\n        const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);\n\n        items.forEach((_, idx) => {\n            const key = `${idx}`;\n            if (!subMenuRefs.current[key]) {\n                subMenuRefs.current[key] = createRef<HTMLDivElement>();\n            }\n        });\n\n        // Position submenus based on available space and scroll position\n        const handleSubMenuPosition = (key: string, itemRect: DOMRect, label: string) => {\n            setTimeout(() => {\n                const subMenuRef = subMenuRefs.current[key]?.current;\n                if (!subMenuRef) return;\n\n                const scrollTop = window.scrollY || document.documentElement.scrollTop;\n                const scrollLeft = window.scrollX || document.documentElement.scrollLeft;\n\n                const submenuWidth = subMenuRef.offsetWidth;\n                const submenuHeight = subMenuRef.offsetHeight;\n\n                let left = itemRect.right + scrollLeft - 2; // Adjust for horizontal scroll\n                let top = itemRect.top - 2 + scrollTop; // Adjust for vertical scroll\n\n                // Adjust to the left if overflowing the right boundary\n                if (left + submenuWidth > window.innerWidth + scrollLeft) {\n                    left = itemRect.left + scrollLeft - submenuWidth;\n                }\n\n                // Adjust if the submenu overflows the bottom boundary\n                if (top + submenuHeight > window.innerHeight + scrollTop) {\n                    top = window.innerHeight + scrollTop - submenuHeight - 10;\n                }\n\n                setSubMenuPosition((prev) => ({\n                    ...prev,\n                    [key]: { top, left, label },\n                }));\n            }, 0);\n        };\n\n        const handleMouseEnterItem = (\n            event: React.MouseEvent<HTMLDivElement, MouseEvent>,\n            parentKey: string | null,\n            index: number,\n            item: MenuItem\n        ) => {\n            event.stopPropagation();\n\n            const key = parentKey ? `${parentKey}-${index}` : `${index}`;\n\n            setVisibleSubMenus((prev) => {\n                const updatedState = { ...prev };\n                updatedState[key] = { visible: true, label: item.label };\n\n                const ancestors = key.split(\"-\").reduce((acc, part, idx) => {\n                    if (idx === 0) return [part];\n                    return [...acc, `${acc[idx - 1]}-${part}`];\n                }, [] as string[]);\n\n                ancestors.forEach((ancestorKey) => {\n                    if (updatedState[ancestorKey]) {\n                        updatedState[ancestorKey].visible = true;\n                    }\n                });\n\n                for (const pkey in updatedState) {\n                    if (!ancestors.includes(pkey) && pkey !== key) {\n                        updatedState[pkey].visible = false;\n                    }\n                }\n\n                return updatedState;\n            });\n\n            const newHoveredItems = key.split(\"-\").reduce((acc, part, idx) => {\n                if (idx === 0) return [part];\n                return [...acc, `${acc[idx - 1]}-${part}`];\n            }, [] as string[]);\n\n            setHoveredItems(newHoveredItems);\n\n            const itemRect = event.currentTarget.getBoundingClientRect();\n            handleSubMenuPosition(key, itemRect, item.label);\n        };\n\n        const handleOnClick = (e: React.MouseEvent<HTMLDivElement>, item: MenuItem) => {\n            e.stopPropagation();\n            onOpenChangeMenu(false);\n            item.onClick?.(e);\n        };\n\n        return (\n            <>\n                <div\n                    className=\"menu-anchor\"\n                    ref={refs.setReference}\n                    {...getReferenceProps()}\n                    onClick={() => onOpenChangeMenu(!isOpen)}\n                >\n                    {children}\n                </div>\n                {isOpen && (\n                    <FloatingPortal>\n                        <div\n                            className={clsx(\"menu\", className)}\n                            ref={refs.setFloating}\n                            style={floatingStyles}\n                            {...getFloatingProps()}\n                        >\n                            {items.map((item, index) => {\n                                const key = `${index}`;\n                                const isActive = hoveredItems.includes(key);\n\n                                const menuItemProps = {\n                                    className: clsx(\"menu-item\", { active: isActive }),\n                                    onMouseEnter: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) =>\n                                        handleMouseEnterItem(event, null, index, item),\n                                    onClick: (e: React.MouseEvent<HTMLDivElement>) => handleOnClick(e, item),\n                                };\n\n                                const renderedItem = renderMenuItem ? (\n                                    renderMenuItem(item, menuItemProps)\n                                ) : (\n                                    <div key={key} {...menuItemProps}>\n                                        <span className=\"label\">{item.label}</span>\n                                        {item.subItems && <i className=\"fa-sharp fa-solid fa-chevron-right\"></i>}\n                                    </div>\n                                );\n\n                                return (\n                                    <Fragment key={key}>\n                                        {renderedItem}\n                                        {visibleSubMenus[key]?.visible && item.subItems && (\n                                            <SubMenu\n                                                subItems={item.subItems}\n                                                parentKey={key}\n                                                subMenuPosition={subMenuPosition}\n                                                visibleSubMenus={visibleSubMenus}\n                                                hoveredItems={hoveredItems}\n                                                handleMouseEnterItem={handleMouseEnterItem}\n                                                handleOnClick={handleOnClick}\n                                                subMenuRefs={subMenuRefs}\n                                                renderMenu={renderMenu}\n                                                renderMenuItem={renderMenuItem}\n                                            />\n                                        )}\n                                    </Fragment>\n                                );\n                            })}\n                        </div>\n                    </FloatingPortal>\n                )}\n            </>\n        );\n    }\n);\n\nconst FlyoutMenu = memo(FlyoutMenuComponent) as typeof FlyoutMenuComponent;\n\ntype SubMenuProps = {\n    subItems: MenuItem[];\n    parentKey: string;\n    subMenuPosition: {\n        [key: string]: { top: number; left: number; label: string };\n    };\n    visibleSubMenus: { [key: string]: any };\n    hoveredItems: string[];\n    subMenuRefs: React.RefObject<{ [key: string]: React.RefObject<HTMLDivElement> }>;\n    handleMouseEnterItem: (\n        event: React.MouseEvent<HTMLDivElement, MouseEvent>,\n        parentKey: string | null,\n        index: number,\n        item: MenuItem\n    ) => void;\n    handleOnClick: (e: React.MouseEvent<HTMLDivElement>, item: MenuItem) => void;\n    renderMenu?: (subMenu: React.ReactElement, props: any) => React.ReactElement;\n    renderMenuItem?: (item: MenuItem, props: any) => React.ReactElement;\n};\n\nconst SubMenu = memo(\n    ({\n        subItems,\n        parentKey,\n        subMenuPosition,\n        visibleSubMenus,\n        hoveredItems,\n        subMenuRefs,\n        handleMouseEnterItem,\n        handleOnClick,\n        renderMenu,\n        renderMenuItem,\n    }: SubMenuProps) => {\n        subItems.forEach((_, idx) => {\n            const newKey = `${parentKey}-${idx}`;\n            if (!subMenuRefs.current[newKey]) {\n                subMenuRefs.current[newKey] = createRef<HTMLDivElement>();\n            }\n        });\n\n        const position = subMenuPosition[parentKey];\n        const isPositioned = position && position.top !== undefined && position.left !== undefined;\n\n        const subMenu = (\n            <div\n                className=\"menu sub-menu\"\n                ref={subMenuRefs.current[parentKey]}\n                style={{\n                    top: position?.top || 0,\n                    left: position?.left || 0,\n                    position: \"absolute\",\n                    zIndex: 1000,\n                    visibility: visibleSubMenus[parentKey]?.visible && isPositioned ? \"visible\" : \"hidden\",\n                }}\n            >\n                {subItems.map((item, idx) => {\n                    const newKey = `${parentKey}-${idx}`;\n                    const isActive = hoveredItems.includes(newKey);\n\n                    const menuItemProps = {\n                        className: clsx(\"menu-item\", { active: isActive }),\n                        onMouseEnter: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) =>\n                            handleMouseEnterItem(event, parentKey, idx, item),\n                        onClick: (e: React.MouseEvent<HTMLDivElement>) => handleOnClick(e, item),\n                    };\n\n                    const renderedItem = renderMenuItem ? (\n                        renderMenuItem(item, menuItemProps) // Remove portal here\n                    ) : (\n                        <div key={newKey} {...menuItemProps}>\n                            <span className=\"label\">{item.label}</span>\n                            {item.subItems && <i className=\"fa-sharp fa-solid fa-chevron-right\"></i>}\n                        </div>\n                    );\n\n                    return (\n                        <Fragment key={newKey}>\n                            {renderedItem}\n                            {visibleSubMenus[newKey]?.visible && item.subItems && (\n                                <SubMenu\n                                    subItems={item.subItems}\n                                    parentKey={newKey}\n                                    subMenuPosition={subMenuPosition}\n                                    visibleSubMenus={visibleSubMenus}\n                                    hoveredItems={hoveredItems}\n                                    handleMouseEnterItem={handleMouseEnterItem}\n                                    handleOnClick={handleOnClick}\n                                    subMenuRefs={subMenuRefs}\n                                    renderMenu={renderMenu}\n                                    renderMenuItem={renderMenuItem}\n                                />\n                            )}\n                        </Fragment>\n                    );\n                })}\n            </div>\n        );\n\n        return ReactDOM.createPortal(renderMenu ? renderMenu(subMenu, { parentKey }) : subMenu, document.body);\n    }\n);\n\nexport { FlyoutMenu };\n"
  },
  {
    "path": "frontend/app/element/iconbutton.scss",
    "content": ".wave-iconbutton {\n    display: flex;\n    cursor: pointer;\n    opacity: 0.7;\n    align-items: center;\n    background: none;\n    border: none;\n    padding: 0;\n    font: inherit;\n    outline: inherit;\n\n    &.bulb {\n        color: var(--bulb-color);\n        opacity: 1;\n\n        &:hover i::before {\n            content: \"\\f672\";\n            position: relative;\n            left: -1px;\n        }\n    }\n\n    &:hover {\n        opacity: 1;\n    }\n\n    &.no-action {\n        cursor: default;\n    }\n\n    &.disabled {\n        cursor: default;\n        opacity: 0.45 !important;\n    }\n\n    &.toggle {\n        border-radius: 3px;\n        padding: 1px;\n        &.active {\n            opacity: 1;\n            border: 1px solid var(--accent-color);\n            padding: 0;\n        }\n        &:hover {\n            background: var(--highlight-bg-color);\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/iconbutton.tsx",
    "content": "// Copyright 2023, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { useLongClick } from \"@/app/hook/useLongClick\";\nimport { makeIconClass } from \"@/util/util\";\nimport clsx from \"clsx\";\nimport { atom, useAtom } from \"jotai\";\nimport { CSSProperties, forwardRef, memo, useMemo, useRef } from \"react\";\nimport \"./iconbutton.scss\";\n\ntype IconButtonProps = { decl: IconButtonDecl; className?: string };\nexport const IconButton = memo(\n    forwardRef<HTMLButtonElement, IconButtonProps>(({ decl, className }, ref) => {\n        ref = ref ?? useRef<HTMLButtonElement>(null);\n        const spin = decl.iconSpin ?? false;\n        useLongClick(ref, decl.click, decl.longClick, decl.disabled);\n        const disabled = decl.disabled ?? false;\n        const styleVal: CSSProperties = {};\n        if (decl.iconColor) {\n            styleVal.color = decl.iconColor;\n        }\n        return (\n            <button\n                ref={ref}\n                className={clsx(\"wave-iconbutton\", className, decl.className, {\n                    disabled,\n                    \"no-action\": decl.noAction,\n                })}\n                title={decl.title}\n                aria-label={decl.title}\n                style={styleVal}\n                disabled={disabled}\n            >\n                {typeof decl.icon === \"string\" ? <i className={makeIconClass(decl.icon, true, { spin })} /> : decl.icon}\n            </button>\n        );\n    })\n);\n\ntype ToggleIconButtonProps = { decl: ToggleIconButtonDecl; className?: string };\n\nexport const ToggleIconButton = memo(\n    forwardRef<HTMLButtonElement, ToggleIconButtonProps>(({ decl, className }, ref) => {\n        const activeAtom = useMemo(() => decl.active ?? atom(false), [decl.active]);\n        const [active, setActive] = useAtom(activeAtom);\n        ref = ref ?? useRef<HTMLButtonElement>(null);\n        const spin = decl.iconSpin ?? false;\n        const title = `${decl.title}${active ? \" (Active)\" : \"\"}`;\n        const disabled = decl.disabled ?? false;\n        return (\n            <button\n                ref={ref}\n                className={clsx(\"wave-iconbutton\", \"toggle\", className, decl.className, {\n                    active,\n                    disabled,\n                    \"no-action\": decl.noAction,\n                })}\n                title={title}\n                aria-label={title}\n                style={{ color: decl.iconColor ?? \"inherit\" }}\n                onClick={() => setActive(!active)}\n                disabled={disabled}\n            >\n                {typeof decl.icon === \"string\" ? <i className={makeIconClass(decl.icon, true, { spin })} /> : decl.icon}\n            </button>\n        );\n    })\n);\n"
  },
  {
    "path": "frontend/app/element/input.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.input {\n    width: 100%;\n    border: none;\n    font-size: 12px;\n    outline: none;\n    background-color: transparent;\n    color: var(--form-element-text-color);\n    background: var(--form-element-bg-color);\n    border: 2px solid var(--form-element-border-color);\n    border-radius: 6px;\n    padding: 4px 7px;\n\n    &:focus {\n        border-color: var(--form-element-primary-color);\n    }\n\n    &.disabled {\n        opacity: 0.75;\n    }\n\n    &.error {\n        border-color: var(--form-element-error-color);\n    }\n}\n\n/* Styles when an InputGroup is present */\n.input-group {\n    display: flex;\n    align-items: center;\n    border-radius: 6px;\n    position: relative;\n    width: 100%;\n    border: 2px solid var(--form-element-border-color);\n    background: var(--form-element-bg-color);\n\n    /* Focus style for InputGroup */\n    &.focused {\n        border-color: var(--form-element-primary-color);\n    }\n\n    /* Error state for InputGroup */\n    &.error {\n        border-color: var(--form-element-error-color);\n    }\n\n    /* Disabled state for InputGroup */\n    &.disabled {\n        opacity: 0.75;\n    }\n\n    &:hover {\n        cursor: text;\n    }\n\n    .input-left-element,\n    .input-right-element {\n        padding: 0 5px;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n    }\n\n    .input {\n        border: none;\n        flex-grow: 1;\n        border-radius: none;\n\n        &:focus {\n            border-color: transparent;\n        }\n\n        &.error {\n            border-color: transparent;\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/input.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport clsx from \"clsx\";\nimport React, { forwardRef, memo, useImperativeHandle, useRef, useState } from \"react\";\n\nimport \"./input.scss\";\n\ninterface InputGroupProps {\n    children: React.ReactNode;\n    className?: string;\n}\n\nconst InputGroup = memo(\n    forwardRef<HTMLDivElement, InputGroupProps>(({ children, className }: InputGroupProps, ref) => {\n        const [isFocused, setIsFocused] = useState(false);\n\n        const manageFocus = (focused: boolean) => {\n            setIsFocused(focused);\n        };\n\n        return (\n            <div\n                ref={ref}\n                className={clsx(\"input-group\", className, {\n                    focused: isFocused,\n                })}\n            >\n                {React.Children.map(children, (child) => {\n                    if (React.isValidElement(child)) {\n                        return React.cloneElement(child as any, { manageFocus });\n                    }\n                    return child;\n                })}\n            </div>\n        );\n    })\n);\n\ninterface InputLeftElementProps {\n    children: React.ReactNode;\n    className?: string;\n}\n\nconst InputLeftElement = memo(({ children, className }: InputLeftElementProps) => {\n    return <div className={clsx(\"input-left-element\", className)}>{children}</div>;\n});\n\ninterface InputRightElementProps {\n    children: React.ReactNode;\n    className?: string;\n}\n\nconst InputRightElement = memo(({ children, className }: InputRightElementProps) => {\n    return <div className={clsx(\"input-right-element\", className)}>{children}</div>;\n});\n\ninterface InputProps {\n    value?: string;\n    className?: string;\n    onChange?: (value: string) => void;\n    onKeyDown?: (event: React.KeyboardEvent<any>) => void;\n    onFocus?: () => void;\n    onBlur?: () => void;\n    placeholder?: string;\n    defaultValue?: string;\n    required?: boolean;\n    maxLength?: number;\n    autoFocus?: boolean;\n    autoSelect?: boolean;\n    disabled?: boolean;\n    isNumber?: boolean;\n    inputRef?: React.RefObject<any>;\n    manageFocus?: (isFocused: boolean) => void;\n}\n\nconst Input = memo(\n    forwardRef<HTMLInputElement, InputProps>(\n        (\n            {\n                value,\n                className,\n                onChange,\n                onKeyDown,\n                onFocus,\n                onBlur,\n                placeholder,\n                defaultValue = \"\",\n                required,\n                maxLength,\n                autoFocus,\n                autoSelect,\n                disabled,\n                isNumber,\n                manageFocus,\n            }: InputProps,\n            ref\n        ) => {\n            const [internalValue, setInternalValue] = useState(defaultValue);\n            const inputRef = useRef<HTMLInputElement>(null);\n\n            useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);\n\n            const handleInputChange = (e: React.ChangeEvent<any>) => {\n                const inputValue = e.target.value;\n\n                if (isNumber && inputValue !== \"\" && !/^\\d*$/.test(inputValue)) {\n                    return;\n                }\n\n                if (value === undefined) {\n                    setInternalValue(inputValue);\n                }\n\n                onChange?.(inputValue);\n            };\n\n            const handleFocus = () => {\n                if (autoSelect) {\n                    inputRef.current?.select();\n                }\n                manageFocus?.(true);\n                onFocus?.();\n            };\n\n            const handleBlur = () => {\n                manageFocus?.(false);\n                onBlur?.();\n            };\n\n            const inputValue = value ?? internalValue;\n\n            return (\n                <input\n                    className={clsx(\"input\", className, {\n                        disabled: disabled,\n                    })}\n                    ref={inputRef}\n                    value={inputValue}\n                    onChange={handleInputChange}\n                    onKeyDown={onKeyDown}\n                    onFocus={handleFocus}\n                    onBlur={handleBlur}\n                    placeholder={placeholder}\n                    maxLength={maxLength}\n                    autoFocus={autoFocus}\n                    disabled={disabled}\n                />\n            );\n        }\n    )\n);\n\nexport { Input, InputGroup, InputLeftElement, InputRightElement };\nexport type { InputGroupProps, InputLeftElementProps, InputProps, InputRightElementProps };\n"
  },
  {
    "path": "frontend/app/element/linkbutton.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.link-button {\n    text-decoration: none;\n}\n"
  },
  {
    "path": "frontend/app/element/linkbutton.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport clsx from \"clsx\";\nimport * as React from \"react\";\n\nimport \"./linkbutton.scss\";\n\ninterface LinkButtonProps {\n    href: string;\n    rel?: string;\n    target?: string;\n    children: React.ReactNode;\n    disabled?: boolean;\n    style?: React.CSSProperties;\n    autoFocus?: boolean;\n    className?: string;\n    termInline?: boolean;\n    title?: string;\n    onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;\n}\n\nconst LinkButton = ({ children, className, ...rest }: LinkButtonProps) => {\n    return (\n        <a {...rest} className={clsx(\"button grey solid link-button\", className)}>\n            <span className=\"button-inner\">{children}</span>\n        </a>\n    );\n};\n\nexport { LinkButton };\n"
  },
  {
    "path": "frontend/app/element/magnify.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.magnify-icon {\n    display: inline-block;\n    width: 15px;\n    height: 15px;\n    svg {\n        #arrow1 {\n            transform: rotate(180deg);\n            transform-origin: calc(29.167% + 4px) calc(70.833% + 4px); // account for path offset in the svg itself\n        }\n        #arrow2 {\n            transform: rotate(-180deg);\n            transform-origin: calc(70.833% + 4px) calc(29.167% + 4px);\n        }\n        #arrow1,\n        #arrow2 {\n            transition: transform 300ms ease-in;\n            transition-delay: 100ms;\n        }\n    }\n    &.enabled {\n        svg {\n            #arrow1,\n            #arrow2 {\n                transform: rotate(0deg);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/magnify.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport clsx from \"clsx\";\nimport MagnifySVG from \"../asset/magnify.svg\";\nimport \"./magnify.scss\";\n\ninterface MagnifyIconProps {\n    enabled: boolean;\n}\n\nexport function MagnifyIcon({ enabled }: MagnifyIconProps) {\n    return (\n        <div className={clsx(\"magnify-icon\", { enabled })}>\n            <MagnifySVG />\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/app/element/markdown-contentblock-plugin.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { Paragraph, Root, Text } from \"mdast\";\nimport { visit } from \"unist-util-visit\";\nimport { type MarkdownContentBlockType } from \"./markdown-util\";\n\ninterface ContentBlockPluginOptions {\n    blocks: Map<string, MarkdownContentBlockType>;\n}\n\nexport function createContentBlockPlugin(opts: ContentBlockPluginOptions) {\n    const { blocks } = opts;\n\n    return function transformer(tree: Root) {\n        visit(tree, \"paragraph\", (node: Paragraph) => {\n            if (!node.children?.length) return;\n\n            const newChildren = [];\n            for (const child of node.children) {\n                if (child.type !== \"text\") {\n                    newChildren.push(child);\n                    continue;\n                }\n\n                const text = (child as Text).value;\n                let lastIndex = 0;\n                const parts = [];\n\n                // Find all inline blocks\n                const regex = /!!!(\\w+\\[.*?\\])!!!/g;\n                let match;\n\n                while ((match = regex.exec(text)) !== null) {\n                    // Add text before the match\n                    if (match.index > lastIndex) {\n                        parts.push({\n                            type: \"text\",\n                            value: text.slice(lastIndex, match.index),\n                        });\n                    }\n\n                    const key = match[1];\n                    const block = blocks.get(key);\n\n                    if (block) {\n                        parts.push({\n                            type: \"waveblock\",\n                            data: {\n                                hName: \"waveblock\",\n                                hProperties: {\n                                    blockkey: key,\n                                },\n                            },\n                            block: block,\n                        });\n                    } else {\n                        parts.push({\n                            type: \"text\",\n                            value: match[0],\n                        });\n                    }\n\n                    lastIndex = match.index + match[0].length;\n                }\n\n                // Add remaining text\n                if (lastIndex < text.length) {\n                    parts.push({\n                        type: \"text\",\n                        value: text.slice(lastIndex),\n                    });\n                }\n\n                newChildren.push(...parts);\n            }\n\n            node.children = newChildren;\n        });\n    };\n}\n"
  },
  {
    "path": "frontend/app/element/markdown-util.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { getWebServerEndpoint } from \"@/util/endpoints\";\nimport { formatRemoteUri } from \"@/util/waveutil\";\nimport parseSrcSet from \"parse-srcset\";\n\nexport type MarkdownContentBlockType = {\n    type: string;\n    id: string;\n    content: string;\n    opts?: Record<string, any>;\n};\n\nconst idMatchRe = /^(\"(?:[^\"\\\\]|\\\\.)*\")/;\n\nfunction formatInlineContentBlock(block: MarkdownContentBlockType): string {\n    return `!!!${block.type}[${block.id}]!!!`;\n}\n\nfunction parseOptions(str: string): Record<string, any> {\n    const trimmed = str.trim();\n    if (!trimmed) return null;\n\n    try {\n        const parsed = JSON.parse(trimmed);\n        // Ensure it's an object (not array or primitive)\n        if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n            return null;\n        }\n        return parsed;\n    } catch {\n        return null;\n    }\n}\n\nfunction makeMarkdownWaveBlockKey(block: MarkdownContentBlockType): string {\n    return `${block.type}[${block.id}]`;\n}\n\nexport function transformBlocks(content: string): { content: string; blocks: Map<string, MarkdownContentBlockType> } {\n    const lines = content.split(\"\\n\");\n    const blocks = new Map();\n    let currentBlock = null;\n    let currentContent = [];\n    let processedLines = [];\n\n    for (const line of lines) {\n        // Check for start marker\n        if (line.startsWith(\"@@@start \")) {\n            // Already in a block? Add as content\n            if (currentBlock) {\n                processedLines.push(line);\n                continue;\n            }\n\n            // Parse the start line\n            const [, type, rest] = line.slice(9).match(/^(\\w+)\\s+(.*)/) || [];\n            if (!type || !rest) {\n                // Invalid format - treat as regular content\n                processedLines.push(line);\n                continue;\n            }\n\n            // Get the ID (everything between first set of quotes)\n            const idMatch = rest.match(idMatchRe);\n            if (!idMatch) {\n                processedLines.push(line);\n                continue;\n            }\n\n            // Parse options if any exist after the ID\n            const afterId = rest.slice(idMatch[0].length).trim();\n            const opts = parseOptions(afterId);\n\n            currentBlock = {\n                type,\n                id: idMatch[1],\n                opts,\n            };\n            continue;\n        }\n\n        // Check for end marker\n        if (line.startsWith(\"@@@end \")) {\n            // If we're not in a block, treat as content\n            if (!currentBlock) {\n                processedLines.push(line);\n                continue;\n            }\n\n            // Parse the end line\n            const [, type, rest] = line.slice(7).match(/^(\\w+)\\s+(.*)/) || [];\n            if (!type || !rest) {\n                currentContent.push(line);\n                continue;\n            }\n\n            // Get the ID\n            const idMatch = rest.match(idMatchRe);\n            if (!idMatch) {\n                currentContent.push(line);\n                continue;\n            }\n\n            const endId = idMatch[1];\n\n            // If this doesn't match our current block, treat as content\n            if (type !== currentBlock.type || endId !== currentBlock.id) {\n                currentContent.push(line);\n                continue;\n            }\n\n            // Found matching end - store block and add placeholder\n            const key = makeMarkdownWaveBlockKey(currentBlock);\n            blocks.set(key, {\n                type: currentBlock.type,\n                id: currentBlock.id,\n                opts: currentBlock.opts,\n                content: currentContent.join(\"\\n\"),\n            });\n\n            processedLines.push(formatInlineContentBlock(currentBlock));\n            currentBlock = null;\n            currentContent = [];\n            continue;\n        }\n\n        // Regular line - add to current block or processed lines\n        if (currentBlock) {\n            currentContent.push(line);\n        } else {\n            processedLines.push(line);\n        }\n    }\n\n    // Handle unclosed block - add what we have so far\n    if (currentBlock) {\n        const key = makeMarkdownWaveBlockKey(currentBlock);\n        blocks.set(key, {\n            type: currentBlock.type,\n            id: currentBlock.id,\n            opts: currentBlock.opts,\n            content: currentContent.join(\"\\n\"),\n        });\n        processedLines.push(formatInlineContentBlock(currentBlock));\n    }\n\n    return {\n        content: processedLines.join(\"\\n\"),\n        blocks: blocks,\n    };\n}\n\nexport const resolveRemoteFile = async (filepath: string, resolveOpts: MarkdownResolveOpts): Promise<string | null> => {\n    if (!filepath || filepath.startsWith(\"http://\") || filepath.startsWith(\"https://\")) {\n        return filepath;\n    }\n    try {\n        const baseDirUri = formatRemoteUri(resolveOpts.baseDir, resolveOpts.connName);\n        const fileInfo = await RpcApi.FileJoinCommand(TabRpcClient, [baseDirUri, filepath]);\n        const remoteUri = formatRemoteUri(fileInfo.path, resolveOpts.connName);\n        // console.log(\"markdown resolve\", resolveOpts, filepath, \"=>\", baseDirUri, remoteUri);\n        const usp = new URLSearchParams();\n        usp.set(\"path\", remoteUri);\n        return getWebServerEndpoint() + \"/wave/stream-file?\" + usp.toString();\n    } catch (err) {\n        console.warn(\"Failed to resolve remote file:\", filepath, err);\n        return null;\n    }\n};\n\nexport const resolveSrcSet = async (srcSet: string, resolveOpts: MarkdownResolveOpts): Promise<string> => {\n    if (!srcSet) return null;\n\n    // Parse the srcset\n    const candidates = parseSrcSet(srcSet);\n\n    // Resolve each URL in the array of candidates\n    const resolvedCandidates = await Promise.all(\n        candidates.map(async (candidate) => {\n            const resolvedUrl = await resolveRemoteFile(candidate.url, resolveOpts);\n            return {\n                ...candidate,\n                url: resolvedUrl,\n            };\n        })\n    );\n\n    // Reconstruct the srcset string\n    return resolvedCandidates\n        .map((candidate) => {\n            let part = candidate.url;\n            if (candidate.w) part += ` ${candidate.w}w`;\n            if (candidate.h) part += ` ${candidate.h}h`;\n            if (candidate.d) part += ` ${candidate.d}x`;\n            return part;\n        })\n        .join(\", \");\n};\n"
  },
  {
    "path": "frontend/app/element/markdown.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n@import url(\"../../../node_modules/highlight.js/scss/github-dark-dimmed.scss\");\n\n.markdown {\n    display: flex;\n    flex-direction: row;\n    overflow: hidden;\n    height: 100%;\n    width: 100%;\n\n    .content {\n        height: 100%;\n        width: 100%;\n        overflow: scroll;\n        line-height: 1.5;\n        color: var(--main-text-color);\n        font-family: var(--markdown-font-family);\n        font-size: var(--markdown-font-size);\n        overflow-wrap: break-word;\n\n        &.non-scrollable {\n            overflow: hidden;\n        }\n\n        *:last-child {\n            margin-bottom: 0 !important;\n        }\n\n        .heading:not(.heading ~ .heading) {\n            margin-top: 0 !important;\n        }\n\n        .heading {\n            color: var(--main-text-color);\n            margin-top: 1.143em;\n            margin-bottom: 0.571em;\n            font-weight: semibold;\n            padding-top: 0.429em;\n\n            &.is-1 {\n                border-bottom: 1px solid var(--border-color);\n                padding-bottom: 0.429em;\n                font-size: 2em;\n            }\n            &.is-2 {\n                border-bottom: 1px solid var(--border-color);\n                padding-bottom: 0.429em;\n                font-size: 1.5em;\n            }\n            &.is-3 {\n                font-size: 1.25em;\n            }\n            &.is-4 {\n                font-size: 1em;\n            }\n            &.is-5 {\n                font-size: 0.875em;\n            }\n            &.is-6 {\n                font-size: 0.85em;\n            }\n        }\n\n        .paragraph {\n            margin-top: 0;\n            margin-bottom: 10px;\n        }\n\n        img {\n            border-style: none;\n            max-width: 100%;\n            box-sizing: content-box;\n\n            &[align=\"right\"] {\n                padding-left: 20px;\n            }\n\n            &[align=\"left\"] {\n                padding-right: 20px;\n            }\n        }\n\n        strong {\n            color: var(--main-text-color);\n        }\n\n        a {\n            color: #32afff;\n        }\n\n        ul {\n            list-style-type: disc;\n            list-style-position: outside;\n            margin-left: 1em;\n        }\n\n        ol {\n            list-style-position: outside;\n            margin-left: 1.2em;\n        }\n\n        blockquote {\n            margin: 0.286em 0.714em;\n            border-radius: 4px;\n            background-color: var(--panel-bg-color);\n            padding: 0.143em 0.286em 0.143em 0.429em;\n        }\n\n        pre.codeblock {\n            background-color: var(--panel-bg-color);\n            margin: 0.286em 0.714em;\n            padding: 0.4em 0.7em;\n            border-radius: 4px;\n            position: relative;\n\n            code {\n                line-height: 1.5;\n                white-space: pre-wrap;\n                word-wrap: break-word;\n                overflow: auto;\n                overflow: hidden;\n                background-color: transparent;\n            }\n\n            .codeblock-actions {\n                visibility: hidden;\n                display: flex;\n                position: absolute;\n                top: 0;\n                right: 0;\n                border-radius: 4px;\n                backdrop-filter: blur(8px);\n                margin: 0.143em;\n                padding: 0.286em;\n                align-items: center;\n                justify-content: flex-end;\n                gap: 0.286em;\n            }\n\n            &:hover .codeblock-actions {\n                visibility: visible;\n            }\n        }\n\n        code {\n            color: var(--main-text-color);\n            font: var(--fixed-font);\n            font-size: var(--markdown-fixed-font-size);\n            border-radius: 4px;\n        }\n\n        pre.selected {\n            outline: 2px solid var(--accent-color);\n        }\n\n        .waveblock {\n            margin: 1.143em 0;\n\n            .wave-block-content {\n                display: flex;\n                align-items: center;\n                padding: 0.857em;\n                background-color: var(--highlight-bg-color);\n                border: 1px solid var(--border-color);\n                border-radius: 8px;\n                transition: background-color 0.2s ease;\n            }\n\n            .wave-block-icon {\n                display: flex;\n                align-items: center;\n                justify-content: center;\n                width: 2.857em;\n                height: 2.857em;\n                background-color: black;\n                border-radius: 8px;\n                margin-right: 0.857em;\n            }\n\n            .wave-block-icon i {\n                font-size: 1.125em;\n                color: var(--secondary-text-color);\n            }\n\n            .wave-block-info {\n                display: flex;\n                flex-direction: column;\n            }\n\n            .wave-block-filename {\n                font-size: 1em;\n                font-weight: 500;\n                color: var(--main-text-color);\n            }\n\n            .wave-block-size {\n                font-size: 0.857em;\n                color: var(--secondary-text-color);\n            }\n        }\n    }\n\n    .toc {\n        max-width: 40%;\n        height: 100%;\n        overflow: scroll;\n        border-left: 1px solid var(--border-color);\n        .toc-inner {\n            height: fit-content;\n            position: sticky;\n            top: 0;\n            display: flex;\n            flex-direction: column;\n            gap: 0.357em;\n            text-wrap: wrap;\n\n            h4 {\n                padding-left: 0.357em;\n            }\n\n            .toc-item {\n                cursor: pointer;\n                --indent-factor: 1;\n                // The offset in the padding will ensure that when the text in the item wraps, it indents slightly.\n                // The indent factor is set in the React code and denotes the depth of the item in the TOC tree.\n                padding-left: calc((var(--indent-factor) - 1) * 0.714em + 0.357em);\n                text-indent: -0.357em;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/markdown.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { CopyButton } from \"@/app/element/copybutton\";\nimport { createContentBlockPlugin } from \"@/app/element/markdown-contentblock-plugin\";\nimport {\n    MarkdownContentBlockType,\n    resolveRemoteFile,\n    resolveSrcSet,\n    transformBlocks,\n} from \"@/app/element/markdown-util\";\nimport remarkMermaidToTag from \"@/app/element/remark-mermaid-to-tag\";\nimport { boundNumber, useAtomValueSafe, cn } from \"@/util/util\";\nimport clsx from \"clsx\";\nimport { Atom } from \"jotai\";\nimport { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from \"overlayscrollbars-react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport ReactMarkdown, { Components } from \"react-markdown\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeRaw from \"rehype-raw\";\nimport rehypeSanitize, { defaultSchema } from \"rehype-sanitize\";\nimport rehypeSlug from \"rehype-slug\";\nimport RemarkFlexibleToc, { TocItem } from \"remark-flexible-toc\";\nimport remarkGfm from \"remark-gfm\";\nimport { openLink } from \"../store/global\";\nimport { IconButton } from \"./iconbutton\";\nimport \"./markdown.scss\";\n\nlet mermaidInitialized = false;\nlet mermaidInstance: any = null;\n\nconst initializeMermaid = async () => {\n    if (!mermaidInitialized) {\n        const mermaid = await import(\"mermaid\");\n        mermaidInstance = mermaid.default;\n        mermaidInstance.initialize({ startOnLoad: false, theme: \"dark\", securityLevel: \"strict\" });\n        mermaidInitialized = true;\n    }\n};\n\nconst Link = ({\n    setFocusedHeading,\n    props,\n}: {\n    props: React.AnchorHTMLAttributes<HTMLAnchorElement>;\n    setFocusedHeading: (href: string) => void;\n}) => {\n    const onClick = (e: React.MouseEvent) => {\n        e.preventDefault();\n        if (props.href.startsWith(\"#\")) {\n            setFocusedHeading(props.href);\n        } else {\n            openLink(props.href);\n        }\n    };\n    return (\n        <a href={props.href} onClick={onClick} className=\"text-accent hover:underline\">\n            {props.children}\n        </a>\n    );\n};\n\nconst Heading = ({ props, hnum }: { props: React.HTMLAttributes<HTMLHeadingElement>; hnum: number }) => {\n    return (\n        <div id={props.id} className={clsx(\"heading\", `is-${hnum}`)}>\n            {props.children}\n        </div>\n    );\n};\n\nconst Mermaid = ({ chart }: { chart: string }) => {\n    const ref = useRef<HTMLDivElement>(null);\n    const [isLoading, setIsLoading] = useState(true);\n    const [error, setError] = useState<string | null>(null);\n\n    useEffect(() => {\n        const renderMermaid = async () => {\n            try {\n                setIsLoading(true);\n                setError(null);\n\n                await initializeMermaid();\n                if (!ref.current || !mermaidInstance) {\n                    return;\n                }\n\n                // Normalize the chart text\n                let normalizedChart = chart\n                    .replace(/<br\\s*\\/?>/gi, \"\\n\") // Convert <br/> and <br> to newlines\n                    .replace(/\\r\\n?/g, \"\\n\") // Normalize \\r \\r\\n to \\n\n                    .replace(/\\n+$/, \"\"); // Remove final newline\n\n                ref.current.removeAttribute(\"data-processed\");\n                ref.current.textContent = normalizedChart;\n                // console.log(\"mermaid\", normalizedChart);\n                await mermaidInstance.run({ nodes: [ref.current] });\n                setIsLoading(false);\n            } catch (err) {\n                console.error(\"Error rendering mermaid diagram:\", err);\n                setError(`Failed to render diagram: ${err.message || err}`);\n                setIsLoading(false);\n            }\n        };\n\n        renderMermaid();\n    }, [chart]);\n\n    useEffect(() => {\n        if (!ref.current) return;\n\n        if (error) {\n            ref.current.textContent = `Error: ${error}`;\n            ref.current.className = \"mermaid error\";\n        } else if (isLoading) {\n            ref.current.textContent = \"Loading diagram...\";\n            ref.current.className = \"mermaid\";\n        } else {\n            ref.current.className = \"mermaid\";\n        }\n    }, [isLoading, error]);\n\n    return <div className=\"mermaid\" ref={ref} />;\n};\n\nconst Code = ({ className = \"\", children }: { className?: string; children: React.ReactNode }) => {\n    if (/\\blanguage-mermaid\\b/.test(className)) {\n        const text = Array.isArray(children) ? children.join(\"\") : String(children ?? \"\");\n        return <Mermaid chart={text} />;\n    }\n    return <code className={className}>{children}</code>;\n};\n\ntype CodeBlockProps = {\n    children: React.ReactNode;\n    onClickExecute?: (cmd: string) => void;\n};\n\nconst CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => {\n    const getTextContent = (children: any): string => {\n        if (typeof children === \"string\") {\n            return children;\n        } else if (Array.isArray(children)) {\n            return children.map(getTextContent).join(\"\");\n        } else if (children.props && children.props.children) {\n            return getTextContent(children.props.children);\n        }\n        return \"\";\n    };\n\n    const handleCopy = async (e: React.MouseEvent) => {\n        let textToCopy = getTextContent(children);\n        textToCopy = textToCopy.replace(/\\n$/, \"\"); // remove trailing newline\n        await navigator.clipboard.writeText(textToCopy);\n    };\n\n    const handleExecute = (e: React.MouseEvent) => {\n        let textToCopy = getTextContent(children);\n        textToCopy = textToCopy.replace(/\\n$/, \"\"); // remove trailing newline\n        if (onClickExecute) {\n            onClickExecute(textToCopy);\n            return;\n        }\n    };\n\n    return (\n        <pre className=\"codeblock\">\n            {children}\n            <div className=\"codeblock-actions\">\n                <CopyButton onClick={handleCopy} title=\"Copy\" />\n                {onClickExecute && (\n                    <IconButton\n                        decl={{\n                            elemtype: \"iconbutton\",\n                            icon: \"regular@square-terminal\",\n                            click: handleExecute,\n                        }}\n                    />\n                )}\n            </div>\n        </pre>\n    );\n};\n\nconst MarkdownSource = ({\n    props,\n    resolveOpts,\n}: {\n    props: React.HTMLAttributes<HTMLSourceElement> & {\n        srcSet?: string;\n        media?: string;\n    };\n    resolveOpts: MarkdownResolveOpts;\n}) => {\n    const [resolvedSrcSet, setResolvedSrcSet] = useState<string>(props.srcSet);\n    const [resolving, setResolving] = useState<boolean>(true);\n\n    useEffect(() => {\n        const resolvePath = async () => {\n            const resolved = await resolveSrcSet(props.srcSet, resolveOpts);\n            setResolvedSrcSet(resolved);\n            setResolving(false);\n        };\n\n        resolvePath();\n    }, [props.srcSet]);\n\n    if (resolving) {\n        return null;\n    }\n\n    return <source srcSet={resolvedSrcSet} media={props.media} />;\n};\n\ninterface WaveBlockProps {\n    blockkey: string;\n    blockmap: Map<string, MarkdownContentBlockType>;\n}\n\nfunction WaveBlock(props: WaveBlockProps) {\n    const { blockkey, blockmap } = props;\n    const block = blockmap.get(blockkey);\n    if (block == null) {\n        return null;\n    }\n    const sizeInKB = Math.round((block.content.length / 1024) * 10) / 10;\n    const displayName = block.id.replace(/^\"|\"$/g, \"\");\n    return (\n        <div className=\"waveblock\">\n            <div className=\"wave-block-content\">\n                <div className=\"wave-block-icon\">\n                    <i className=\"fas fa-file-code\"></i>\n                </div>\n                <div className=\"wave-block-info\">\n                    <span className=\"wave-block-filename\">{displayName}</span>\n                    <span className=\"wave-block-size\">{sizeInKB} KB</span>\n                </div>\n            </div>\n        </div>\n    );\n}\n\nconst MarkdownImg = ({\n    props,\n    resolveOpts,\n}: {\n    props: React.ImgHTMLAttributes<HTMLImageElement>;\n    resolveOpts: MarkdownResolveOpts;\n}) => {\n    const [resolvedSrc, setResolvedSrc] = useState<string>(props.src);\n    const [resolvedSrcSet, setResolvedSrcSet] = useState<string>(props.srcSet);\n    const [resolvedStr, setResolvedStr] = useState<string>(null);\n    const [resolving, setResolving] = useState<boolean>(true);\n\n    useEffect(() => {\n        if (props.src.startsWith(\"data:image/\")) {\n            setResolving(false);\n            setResolvedSrc(props.src);\n            setResolvedStr(null);\n            return;\n        }\n        if (resolveOpts == null) {\n            setResolving(false);\n            setResolvedSrc(null);\n            setResolvedStr(`[img:${props.src}]`);\n            return;\n        }\n\n        const resolveFn = async () => {\n            const [resolvedSrc, resolvedSrcSet] = await Promise.all([\n                resolveRemoteFile(props.src, resolveOpts),\n                resolveSrcSet(props.srcSet, resolveOpts),\n            ]);\n\n            setResolvedSrc(resolvedSrc);\n            setResolvedSrcSet(resolvedSrcSet);\n            setResolvedStr(null);\n            setResolving(false);\n        };\n        resolveFn();\n    }, [props.src, props.srcSet]);\n\n    if (resolving) {\n        return null;\n    }\n    if (resolvedStr != null) {\n        return <span>{resolvedStr}</span>;\n    }\n    if (resolvedSrc != null) {\n        return <img {...props} src={resolvedSrc} srcSet={resolvedSrcSet} />;\n    }\n    return <span>[img]</span>;\n};\n\ntype MarkdownProps = {\n    text?: string;\n    textAtom?: Atom<string> | Atom<Promise<string>>;\n    showTocAtom?: Atom<boolean>;\n    style?: React.CSSProperties;\n    className?: string;\n    contentClassName?: string;\n    onClickExecute?: (cmd: string) => void;\n    resolveOpts?: MarkdownResolveOpts;\n    scrollable?: boolean;\n    rehype?: boolean;\n    fontSizeOverride?: number;\n    fixedFontSizeOverride?: number;\n};\n\nconst Markdown = ({\n    text,\n    textAtom,\n    showTocAtom,\n    style,\n    className,\n    contentClassName,\n    resolveOpts,\n    fontSizeOverride,\n    fixedFontSizeOverride,\n    scrollable = true,\n    rehype = true,\n    onClickExecute,\n}: MarkdownProps) => {\n    const textAtomValue = useAtomValueSafe<string>(textAtom);\n    const tocRef = useRef<TocItem[]>([]);\n    const showToc = useAtomValueSafe(showTocAtom) ?? false;\n    const contentsOsRef = useRef<OverlayScrollbarsComponentRef>(null);\n    const [focusedHeading, setFocusedHeading] = useState<string>(null);\n\n    // Ensure uniqueness of ids between MD preview instances.\n    const [idPrefix] = useState<string>(crypto.randomUUID());\n\n    text = textAtomValue ?? text ?? \"\";\n    const transformedOutput = transformBlocks(text);\n    const transformedText = transformedOutput.content;\n    const contentBlocksMap = transformedOutput.blocks;\n\n    useEffect(() => {\n        if (focusedHeading && contentsOsRef.current && contentsOsRef.current.osInstance()) {\n            const { viewport } = contentsOsRef.current.osInstance().elements();\n            const heading = document.getElementById(idPrefix + focusedHeading.slice(1));\n            if (heading) {\n                const headingBoundingRect = heading.getBoundingClientRect();\n                const viewportBoundingRect = viewport.getBoundingClientRect();\n                const headingTop = headingBoundingRect.top - viewportBoundingRect.top;\n                viewport.scrollBy({ top: headingTop });\n            }\n        }\n    }, [focusedHeading]);\n\n    const markdownComponents: Partial<Components> = {\n        a: (props: React.HTMLAttributes<HTMLAnchorElement>) => (\n            <Link props={props} setFocusedHeading={setFocusedHeading} />\n        ),\n        p: (props: React.HTMLAttributes<HTMLParagraphElement>) => <div className=\"paragraph\" {...props} />,\n        h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={1} />,\n        h2: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={2} />,\n        h3: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={3} />,\n        h4: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={4} />,\n        h5: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={5} />,\n        h6: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={6} />,\n        img: (props: React.HTMLAttributes<HTMLImageElement>) => <MarkdownImg props={props} resolveOpts={resolveOpts} />,\n        source: (props: React.HTMLAttributes<HTMLSourceElement>) => (\n            <MarkdownSource props={props} resolveOpts={resolveOpts} />\n        ),\n        code: Code,\n        pre: (props: React.HTMLAttributes<HTMLPreElement>) => (\n            <CodeBlock children={props.children} onClickExecute={onClickExecute} />\n        ),\n    };\n    markdownComponents[\"waveblock\"] = (props: any) => <WaveBlock {...props} blockmap={contentBlocksMap} />;\n    markdownComponents[\"mermaidblock\"] = (props: any) => {\n        const getTextContent = (children: any): string => {\n            if (typeof children === \"string\") {\n                return children;\n            } else if (Array.isArray(children)) {\n                return children.map(getTextContent).join(\"\");\n            } else if (children && typeof children === \"object\" && children.props && children.props.children) {\n                return getTextContent(children.props.children);\n            }\n            return String(children || \"\");\n        };\n\n        const chartText = getTextContent(props.children);\n        return <Mermaid chart={chartText} />;\n    };\n\n    const toc = useMemo(() => {\n        if (showToc) {\n            if (tocRef.current.length > 0) {\n                return tocRef.current.map((item) => {\n                    return (\n                        <a\n                            key={item.href}\n                            className=\"toc-item text-accent hover:underline\"\n                            style={{ \"--indent-factor\": item.depth } as React.CSSProperties}\n                            onClick={() => setFocusedHeading(item.href)}\n                        >\n                            {item.value}\n                        </a>\n                    );\n                });\n            } else {\n                return (\n                    <div\n                        className=\"toc-item toc-empty text-secondary\"\n                        style={{ \"--indent-factor\": 2 } as React.CSSProperties}\n                    >\n                        No sub-headings found\n                    </div>\n                );\n            }\n        }\n    }, [showToc, tocRef]);\n\n    let rehypePlugins = null;\n    if (rehype) {\n        rehypePlugins = [\n            rehypeRaw,\n            rehypeHighlight,\n            () =>\n                rehypeSanitize({\n                    ...defaultSchema,\n                    attributes: {\n                        ...defaultSchema.attributes,\n                        span: [\n                            ...(defaultSchema.attributes?.span || []),\n                            // Allow all class names starting with `hljs-`.\n                            [\"className\", /^hljs-./],\n                            [\"srcset\"],\n                            [\"media\"],\n                            [\"type\"],\n                            // Alternatively, to allow only certain class names:\n                            // ['className', 'hljs-number', 'hljs-title', 'hljs-variable']\n                        ],\n                        waveblock: [[\"blockkey\"]],\n                    },\n                    tagNames: [\n                        ...(defaultSchema.tagNames || []),\n                        \"span\",\n                        \"waveblock\",\n                        \"picture\",\n                        \"source\",\n                        \"mermaidblock\",\n                    ],\n                }),\n            () => rehypeSlug({ prefix: idPrefix }),\n        ];\n    }\n    const remarkPlugins: any = [\n        remarkMermaidToTag,\n        remarkGfm,\n        [RemarkFlexibleToc, { tocRef: tocRef.current }],\n        [createContentBlockPlugin, { blocks: contentBlocksMap }],\n    ];\n\n    const ScrollableMarkdown = () => {\n        return (\n            <OverlayScrollbarsComponent\n                ref={contentsOsRef}\n                className={cn(\"content\", contentClassName)}\n                options={{ scrollbars: { autoHide: \"leave\" } }}\n            >\n                <ReactMarkdown\n                    remarkPlugins={remarkPlugins}\n                    rehypePlugins={rehypePlugins}\n                    components={markdownComponents}\n                >\n                    {transformedText}\n                </ReactMarkdown>\n            </OverlayScrollbarsComponent>\n        );\n    };\n\n    const NonScrollableMarkdown = () => {\n        return (\n            <div className={cn(\"content non-scrollable\", contentClassName)}>\n                <ReactMarkdown\n                    remarkPlugins={remarkPlugins}\n                    rehypePlugins={rehypePlugins}\n                    components={markdownComponents}\n                >\n                    {transformedText}\n                </ReactMarkdown>\n            </div>\n        );\n    };\n\n    const mergedStyle = { ...style };\n    if (fontSizeOverride != null) {\n        mergedStyle[\"--markdown-font-size\"] = `${boundNumber(fontSizeOverride, 6, 64)}px`;\n    }\n    if (fixedFontSizeOverride != null) {\n        mergedStyle[\"--markdown-fixed-font-size\"] = `${boundNumber(fixedFontSizeOverride, 6, 64)}px`;\n    }\n    return (\n        <div className={clsx(\"markdown\", className)} style={mergedStyle}>\n            {scrollable ? <ScrollableMarkdown /> : <NonScrollableMarkdown />}\n            {toc && (\n                <OverlayScrollbarsComponent className=\"toc mt-1\" options={{ scrollbars: { autoHide: \"leave\" } }}>\n                    <div className=\"toc-inner\">\n                        <h4 className=\"font-bold\">Table of Contents</h4>\n                        {toc}\n                    </div>\n                </OverlayScrollbarsComponent>\n            )}\n        </div>\n    );\n};\n\nexport { Markdown };\n"
  },
  {
    "path": "frontend/app/element/menubutton.scss",
    "content": ".menubutton {\n    overflow: hidden;\n    .menu-anchor {\n        width: 100%;\n        .wave-button {\n            width: 100%;\n            div {\n                max-width: 100%;\n                text-overflow: ellipsis;\n                overflow: hidden;\n                flex-shrink: 1;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/menubutton.tsx",
    "content": "import clsx from \"clsx\";\nimport { memo, useState } from \"react\";\nimport { Button } from \"./button\";\nimport { FlyoutMenu } from \"./flyoutmenu\";\nimport \"./menubutton.scss\";\n\nconst MenuButtonComponent = ({ items, className, text, title }: MenuButtonProps) => {\n    const [isOpen, setIsOpen] = useState(false);\n    return (\n        <div className={clsx(\"menubutton\", className)}>\n            <FlyoutMenu items={items} onOpenChange={setIsOpen}>\n                <Button\n                    className=\"grey rounded-[3px] py-[2px] px-[2px]\"\n                    style={{ borderColor: isOpen ? \"var(--accent-color)\" : \"transparent\" }}\n                    title={title}\n                >\n                    <div>{text}</div>\n                    <i className=\"fa-sharp fa-solid fa-angle-down\"></i>\n                </Button>\n            </FlyoutMenu>\n        </div>\n    );\n};\n\nexport const MenuButton = memo(MenuButtonComponent) as typeof MenuButtonComponent;\n"
  },
  {
    "path": "frontend/app/element/modal.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.modal-container {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100vw;\n    height: 100%;\n    z-index: var(--zindex-elem-modal);\n    background-color: rgba(21, 23, 21, 0.7);\n\n    .modal {\n        display: flex;\n        flex-direction: column;\n        border-radius: 10px;\n        padding: 0;\n        width: 80%;\n        margin-top: 25vh;\n        margin-left: auto;\n        margin-right: auto;\n        background: var(--main-bg-color);\n        border: 1px solid var(--border-color);\n\n        .modal-header {\n            display: flex;\n            flex-direction: column;\n            padding: 20px 20px 10px;\n            border-bottom: 1px solid var(--border-color);\n\n            .modal-title {\n                margin: 0 0 5px;\n                color: var(--main-text-color);\n                font-size: var(--title-font-size);\n            }\n\n            p {\n                margin: 0;\n                font-size: 0.8rem;\n                color: var(--secondary-text-color);\n            }\n        }\n\n        .modal-content {\n            padding: 20px;\n            overflow: auto;\n        }\n\n        .modal-footer {\n            display: flex;\n            flex-direction: row;\n            justify-content: flex-end;\n            padding: 15px 20px;\n            gap: 20px;\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/modal.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Button } from \"@/element/button\";\nimport React from \"react\";\n\nimport \"./modal.scss\";\n\ninterface ModalProps {\n    id?: string;\n    children: React.ReactNode;\n    onClickOut: () => void;\n}\n\nfunction Modal({ children, onClickOut, id = \"modal\", ...otherProps }: ModalProps) {\n    const handleOutsideClick = (e: React.SyntheticEvent<HTMLDivElement>) => {\n        if (typeof onClickOut === \"function\" && (e.target as Element).className === \"modal-container\") {\n            onClickOut();\n        }\n    };\n\n    return (\n        <div className=\"modal-container\" onClick={handleOutsideClick}>\n            <dialog {...otherProps} id={id} className=\"modal\">\n                {children}\n            </dialog>\n        </div>\n    );\n}\n\ninterface ModalContentProps {\n    children: React.ReactNode;\n}\n\nfunction ModalContent({ children }: ModalContentProps) {\n    return <div className=\"modal-content\">{children}</div>;\n}\n\ninterface ModalHeaderProps {\n    title: React.ReactNode;\n    description?: string;\n}\n\nfunction ModalHeader({ title, description }: ModalHeaderProps) {\n    return (\n        <header className=\"modal-header\">\n            {typeof title === \"string\" ? <h3 className=\"modal-title\">{title}</h3> : title}\n            {description && <p>{description}</p>}\n        </header>\n    );\n}\n\ninterface ModalFooterProps {\n    children: React.ReactNode;\n}\n\nfunction ModalFooter({ children }: ModalFooterProps) {\n    return <footer className=\"modal-footer\">{children}</footer>;\n}\n\ninterface WaveModalProps {\n    title: string;\n    description?: string;\n    id?: string;\n    onSubmit: () => void;\n    onCancel: () => void;\n    buttonLabel?: string;\n    children: React.ReactNode;\n}\n\nfunction WaveModal({ title, description, onSubmit, onCancel, buttonLabel = \"Ok\", children }: WaveModalProps) {\n    return (\n        <Modal onClickOut={onCancel}>\n            <ModalHeader title={title} description={description} />\n            <ModalContent>{children}</ModalContent>\n            <ModalFooter>\n                <Button onClick={onSubmit}>{buttonLabel}</Button>\n            </ModalFooter>\n        </Modal>\n    );\n}\n\nexport { WaveModal };\n"
  },
  {
    "path": "frontend/app/element/multilineinput.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.multiline-input {\n    flex-grow: 1;\n    box-shadow: none;\n    box-sizing: border-box;\n    background-color: transparent;\n    resize: none;\n    overflow-y: auto;\n    line-height: 1.5;\n    color: var(--form-element-text-color);\n    vertical-align: top;\n    height: auto;\n    padding: 0;\n    border: 1px solid var(--form-element-border-color);\n    padding: 5px;\n    border-radius: 4px;\n    min-height: 26px;\n\n    &:focus-visible {\n        outline: none;\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/multilineinput.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport clsx from \"clsx\";\nimport React, { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from \"react\";\n\nimport \"./multilineinput.scss\";\n\ninterface MultiLineInputProps {\n    value?: string;\n    className?: string;\n    onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;\n    onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;\n    onFocus?: () => void;\n    onBlur?: () => void;\n    placeholder?: string;\n    defaultValue?: string;\n    maxLength?: number;\n    autoFocus?: boolean;\n    disabled?: boolean;\n    rows?: number;\n    maxRows?: number;\n    manageFocus?: (isFocused: boolean) => void;\n}\n\nconst MultiLineInput = memo(\n    forwardRef<HTMLTextAreaElement, MultiLineInputProps>(\n        (\n            {\n                value,\n                className,\n                onChange,\n                onKeyDown,\n                onFocus,\n                onBlur,\n                placeholder,\n                defaultValue = \"\",\n                maxLength,\n                autoFocus,\n                disabled,\n                rows = 1,\n                maxRows = 5,\n                manageFocus,\n            }: MultiLineInputProps,\n            ref\n        ) => {\n            const textareaRef = useRef<HTMLTextAreaElement>(null);\n            const [internalValue, setInternalValue] = useState(defaultValue);\n            const [lineHeight, setLineHeight] = useState(24); // Default line height fallback of 24px\n            const [paddingTop, setPaddingTop] = useState(0);\n            const [paddingBottom, setPaddingBottom] = useState(0);\n\n            useImperativeHandle(ref, () => textareaRef.current as HTMLTextAreaElement);\n\n            // Function to count the number of lines in the textarea value\n            const countLines = (text: string) => {\n                return text.split(\"\\n\").length;\n            };\n\n            const adjustTextareaHeight = () => {\n                if (textareaRef.current) {\n                    textareaRef.current.style.height = \"auto\"; // Reset height to auto first\n\n                    const maxHeight = maxRows * lineHeight + paddingTop + paddingBottom; // Max height based on maxRows\n                    const currentLines = countLines(textareaRef.current.value); // Count the number of lines\n                    const newHeight = Math.min(textareaRef.current.scrollHeight, maxHeight); // Calculate new height\n\n                    // If the number of lines is less than or equal to maxRows, set height accordingly\n                    const calculatedHeight =\n                        currentLines <= maxRows\n                            ? `${lineHeight * currentLines + paddingTop + paddingBottom}px`\n                            : `${newHeight}px`;\n\n                    textareaRef.current.style.height = calculatedHeight;\n                }\n            };\n\n            const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n                setInternalValue(e.target.value);\n                onChange?.(e);\n\n                // Adjust the height of the textarea after text change\n                adjustTextareaHeight();\n            };\n\n            const handleFocus = () => {\n                manageFocus?.(true);\n                onFocus?.();\n            };\n\n            const handleBlur = () => {\n                manageFocus?.(false);\n                onBlur?.();\n            };\n\n            useEffect(() => {\n                if (textareaRef.current) {\n                    const computedStyle = window.getComputedStyle(textareaRef.current);\n                    const detectedLineHeight = parseFloat(computedStyle.lineHeight);\n                    const detectedPaddingTop = parseFloat(computedStyle.paddingTop);\n                    const detectedPaddingBottom = parseFloat(computedStyle.paddingBottom);\n\n                    setLineHeight(detectedLineHeight);\n                    setPaddingTop(detectedPaddingTop);\n                    setPaddingBottom(detectedPaddingBottom);\n                }\n            }, [textareaRef]);\n\n            useEffect(() => {\n                adjustTextareaHeight();\n            }, [value, maxRows, lineHeight, paddingTop, paddingBottom]);\n\n            const inputValue = value ?? internalValue;\n\n            return (\n                <textarea\n                    className={clsx(\"multiline-input\", className)}\n                    ref={textareaRef}\n                    value={inputValue}\n                    onChange={handleInputChange}\n                    onKeyDown={onKeyDown}\n                    onFocus={handleFocus}\n                    onBlur={handleBlur}\n                    placeholder={placeholder}\n                    maxLength={maxLength}\n                    autoFocus={autoFocus}\n                    disabled={disabled}\n                    rows={rows}\n                    style={{\n                        overflowY:\n                            textareaRef.current &&\n                            textareaRef.current.scrollHeight > maxRows * lineHeight + paddingTop + paddingBottom\n                                ? \"auto\"\n                                : \"hidden\",\n                    }}\n                />\n            );\n        }\n    )\n);\n\nexport { MultiLineInput };\nexport type { MultiLineInputProps };\n"
  },
  {
    "path": "frontend/app/element/popover.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.popover-content {\n    min-width: 100px;\n    min-height: 150px;\n    position: absolute;\n    z-index: 1000; // TODO: put this in theme.scss\n    display: flex;\n    padding: 2px;\n    gap: 1px;\n    border-radius: 4px;\n    border: 1px solid rgba(255, 255, 255, 0.15);\n    background: #212121;\n    box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3);\n}\n"
  },
  {
    "path": "frontend/app/element/popover.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Button } from \"@/element/button\";\nimport {\n    autoUpdate,\n    FloatingPortal,\n    Middleware,\n    offset as offsetMiddleware,\n    useClick,\n    useDismiss,\n    useFloating,\n    useInteractions,\n    type OffsetOptions,\n    type Placement,\n} from \"@floating-ui/react\";\nimport clsx from \"clsx\";\nimport {\n    Children,\n    cloneElement,\n    forwardRef,\n    isValidElement,\n    JSXElementConstructor,\n    memo,\n    ReactElement,\n    ReactNode,\n    useState,\n} from \"react\";\n\nimport \"./popover.scss\";\n\ninterface PopoverProps {\n    children: ReactNode;\n    className?: string;\n    placement?: Placement;\n    offset?: OffsetOptions;\n    onDismiss?: () => void;\n    middleware?: Middleware[];\n}\n\nconst isPopoverButton = (\n    element: ReactElement\n): element is ReactElement<PopoverButtonProps, JSXElementConstructor<PopoverButtonProps>> => {\n    return element.type === PopoverButton;\n};\n\nconst isPopoverContent = (\n    element: ReactElement\n): element is ReactElement<PopoverContentProps, JSXElementConstructor<PopoverContentProps>> => {\n    return element.type === PopoverContent;\n};\n\nconst Popover = memo(\n    forwardRef<HTMLDivElement, PopoverProps>(\n        ({ children, className, placement = \"bottom-start\", offset = 3, onDismiss, middleware }, ref) => {\n            const [isOpen, setIsOpen] = useState(false);\n\n            const handleOpenChange = (open: boolean) => {\n                setIsOpen(open);\n                if (!open && onDismiss) {\n                    onDismiss();\n                }\n            };\n\n            if (offset === undefined) {\n                offset = 3;\n            }\n\n            middleware ??= [];\n            middleware.push(offsetMiddleware(offset));\n\n            const { refs, floatingStyles, context } = useFloating({\n                placement,\n                open: isOpen,\n                onOpenChange: handleOpenChange,\n                middleware: middleware,\n                whileElementsMounted: autoUpdate,\n            });\n\n            const click = useClick(context);\n            const dismiss = useDismiss(context);\n            const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);\n\n            const renderChildren = Children.map(children, (child) => {\n                if (isValidElement(child)) {\n                    if (isPopoverButton(child)) {\n                        return cloneElement(child as any, {\n                            isActive: isOpen,\n                            ref: refs.setReference,\n                            getReferenceProps,\n                            // Do not overwrite onClick\n                        });\n                    }\n\n                    if (isPopoverContent(child)) {\n                        return isOpen\n                            ? cloneElement(child as any, {\n                                  ref: refs.setFloating,\n                                  style: floatingStyles,\n                                  getFloatingProps,\n                              })\n                            : null;\n                    }\n                }\n                return child;\n            });\n\n            return (\n                <div ref={ref} className={clsx(\"popover\", className)}>\n                    {renderChildren}\n                </div>\n            );\n        }\n    )\n);\n\nPopover.displayName = \"Popover\";\n\ninterface PopoverButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n    isActive?: boolean;\n    children: React.ReactNode;\n    getReferenceProps?: () => any;\n    as?: keyof React.JSX.IntrinsicElements | React.ComponentType<any>;\n}\n\nconst PopoverButton = forwardRef<HTMLButtonElement | HTMLDivElement, PopoverButtonProps>(\n    (\n        {\n            isActive,\n            children,\n            onClick: userOnClick, // Destructured from props\n            getReferenceProps,\n            className,\n            as: Component = \"button\",\n            ...props // The rest of the props, without onClick\n        },\n        ref\n    ) => {\n        const referenceProps = getReferenceProps?.() || {};\n        const popoverOnClick = referenceProps.onClick;\n\n        // Remove onClick from referenceProps to prevent it from overwriting our combinedOnClick\n        const { onClick: refOnClick, ...restReferenceProps } = referenceProps;\n\n        const combinedOnClick = (event: React.MouseEvent) => {\n            if (userOnClick) {\n                userOnClick(event as any); // Our custom onClick logic\n            }\n            if (popoverOnClick) {\n                popoverOnClick(event); // Popover's onClick logic\n            }\n        };\n\n        return (\n            <Button\n                ref={ref}\n                className={clsx(\"popover-button\", className, { \"is-active\": isActive })}\n                {...props} // Spread the rest of the props\n                {...restReferenceProps} // Spread referenceProps without onClick\n                onClick={combinedOnClick} // Assign combined onClick after spreading\n            >\n                {children}\n            </Button>\n        );\n    }\n);\n\ninterface PopoverContentProps extends React.HTMLAttributes<HTMLDivElement> {\n    children: React.ReactNode;\n    getFloatingProps?: () => any;\n}\n\nconst PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(\n    ({ children, className, getFloatingProps, style, ...props }, ref) => {\n        return (\n            <FloatingPortal>\n                <div\n                    ref={ref}\n                    className={clsx(\"popover-content\", className)}\n                    style={style}\n                    {...getFloatingProps?.()}\n                    {...props}\n                >\n                    {children}\n                </div>\n            </FloatingPortal>\n        );\n    }\n);\n\nPopover.displayName = \"Popover\";\nPopoverButton.displayName = \"PopoverButton\";\nPopoverContent.displayName = \"PopoverContent\";\n\nexport { Popover, PopoverButton, PopoverContent };\nexport type { PopoverButtonProps, PopoverContentProps };\n"
  },
  {
    "path": "frontend/app/element/progressbar.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.progress-bar-container {\n    width: 100%;\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n\n    --progress-bar-radius: 9px;\n\n    .outer {\n        position: relative;\n        border: 1px solid rgb(from var(--main-text-color) r g b / 15%);\n        border-radius: var(--progress-bar-radius);\n        background-color: var(--main-bg-color);\n        flex-grow: 1;\n        height: 10px;\n        .progress-bar-fill {\n            position: absolute;\n            top: 0;\n            left: 0;\n            height: 100%;\n            transition: width 0.3s ease-in-out;\n            background-color: var(--accent-color);\n            border-radius: var(--progress-bar-radius);\n            width: 100%;\n        }\n    }\n\n    .progress-bar-label {\n        width: 40px;\n        flex-shrink: 0;\n        font-size: 0.9rem;\n        color: var(--main-text-color);\n        font-size: 12px;\n        font-style: normal;\n        font-weight: 400;\n        line-height: normal;\n        text-align: right;\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/progressbar.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { boundNumber } from \"@/util/util\";\nimport \"./progressbar.scss\";\n\ntype ProgressBarProps = {\n    progress: number;\n    label?: string;\n};\n\nconst ProgressBar = ({ progress, label = \"Progress\" }: ProgressBarProps) => {\n    const progressWidth = boundNumber(progress, 0, 100);\n\n    return (\n        <div\n            className=\"progress-bar-container\"\n            role=\"progressbar\"\n            aria-valuenow={progressWidth}\n            aria-valuemin={0}\n            aria-valuemax={100}\n            aria-label={label}\n        >\n            <div className=\"outer\">\n                <div className=\"progress-bar-fill\" style={{ width: `${progressWidth}%` }}></div>\n            </div>\n            <span className=\"progress-bar-label\">{progressWidth}%</span>\n        </div>\n    );\n};\n\nexport { ProgressBar };\n"
  },
  {
    "path": "frontend/app/element/quickelems.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.centered-div {\n    display: flex;\n    flex-direction: row;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n}\n"
  },
  {
    "path": "frontend/app/element/quickelems.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport React from \"react\";\nimport \"./quickelems.scss\";\n\nfunction CenteredLoadingDiv() {\n    return <CenteredDiv>loading...</CenteredDiv>;\n}\n\nfunction CenteredDiv({ children }: { children: React.ReactNode }) {\n    return (\n        <div className=\"centered-div\">\n            <div>{children}</div>\n        </div>\n    );\n}\n\nexport { CenteredDiv, CenteredLoadingDiv };\n"
  },
  {
    "path": "frontend/app/element/quicktips.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { MagnifyIcon } from \"@/app/element/magnify\";\nimport { PLATFORM, PlatformMacOS } from \"@/util/platformutil\";\nimport { cn } from \"@/util/util\";\n\nconst KeyCap = ({ children }: { children: React.ReactNode }) => {\n    return (\n        <div className=\"inline-block px-2 py-1 mx-[1px] font-mono text-[0.85em] text-foreground bg-highlightbg rounded-[3px] border border-gray-700 whitespace-nowrap\">\n            {children}\n        </div>\n    );\n};\n\nconst IconBox = ({ children, variant = \"accent\" }: { children: React.ReactNode; variant?: \"accent\" | \"secondary\" }) => {\n    const colorClasses =\n        variant === \"secondary\"\n            ? \"text-secondary bg-white/5 border-white/10 [&_svg]:fill-secondary [&_svg_#arrow1]:fill-primary [&_svg_#arrow2]:fill-primary\"\n            : \"text-accent-400 bg-accent-400/10 border-accent-400/20 [&_svg]:fill-accent-400 [&_svg_#arrow1]:fill-accent-400 [&_svg_#arrow2]:fill-accent-400\";\n\n    return (\n        <div\n            className={cn(\n                \"text-[20px] min-w-[32px] h-[32px] flex items-center justify-center rounded-md border [&_svg]:h-[16px]\",\n                colorClasses\n            )}\n        >\n            {children}\n        </div>\n    );\n};\n\nconst KeyBinding = ({ keyDecl }: { keyDecl: string }) => {\n    const chordParts = keyDecl.split(\"+\");\n    const chordElems: React.ReactNode[] = [];\n\n    for (let chordIdx = 0; chordIdx < chordParts.length; chordIdx++) {\n        const parts = chordParts[chordIdx].trim().split(\":\");\n        const elems: React.ReactNode[] = [];\n\n        for (let part of parts) {\n            if (part === \"Cmd\") {\n                if (PLATFORM === PlatformMacOS) {\n                    elems.push(<KeyCap key={`${chordIdx}-cmd`}>⌘ Cmd</KeyCap>);\n                } else {\n                    elems.push(<KeyCap key={`${chordIdx}-alt`}>Alt</KeyCap>);\n                }\n                continue;\n            }\n            if (part == \"Ctrl\") {\n                elems.push(<KeyCap key={`${chordIdx}-ctrl`}>^ Ctrl</KeyCap>);\n                continue;\n            }\n            if (part == \"Shift\") {\n                elems.push(<KeyCap key={`${chordIdx}-shift`}>⇧ Shift</KeyCap>);\n                continue;\n            }\n            if (part == \"Arrows\") {\n                elems.push(<KeyCap key={`${chordIdx}-arrows1`}>←</KeyCap>);\n                elems.push(<KeyCap key={`${chordIdx}-arrows2`}>→</KeyCap>);\n                elems.push(<KeyCap key={`${chordIdx}-arrows3`}>↑</KeyCap>);\n                elems.push(<KeyCap key={`${chordIdx}-arrows4`}>↓</KeyCap>);\n                continue;\n            }\n            if (part == \"Digit\") {\n                elems.push(<KeyCap key={`${chordIdx}-digit`}>Number (1-9)</KeyCap>);\n                continue;\n            }\n            if (part == \"[\" || part == \"]\") {\n                elems.push(<KeyCap key={`${chordIdx}-${part}`}>{part}</KeyCap>);\n                continue;\n            }\n            elems.push(<KeyCap key={`${chordIdx}-${part}`}>{part.toUpperCase()}</KeyCap>);\n        }\n\n        chordElems.push(\n            <div key={`chord-${chordIdx}`} className=\"flex flex-row items-center gap-1\">\n                {elems}\n            </div>\n        );\n\n        if (chordIdx < chordParts.length - 1) {\n            chordElems.push(\n                <span key={`plus-${chordIdx}`} className=\"text-secondary mx-1\">\n                    +\n                </span>\n            );\n        }\n    }\n\n    return <div className=\"flex flex-row items-center\">{chordElems}</div>;\n};\n\nconst QuickTips = () => {\n    return (\n        <div className=\"flex flex-col w-full gap-6 @container\">\n            <div className=\"flex flex-col gap-4 p-5 bg-gradient-to-br from-highlightbg/30 to-transparent hover:from-accent-400/5 rounded-lg border border-white/10 hover:border-accent-400/20 transition-all duration-300\">\n                <div className=\"flex items-center gap-2 text-xl font-bold\">\n                    <div className=\"w-1 h-6 bg-accent-400 rounded-full\"></div>\n                    <span className=\"text-foreground\">Header Icons</span>\n                </div>\n                <div className=\"grid grid-cols-1 @lg:grid-cols-2 gap-3\">\n                    <div className=\"flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                        <IconBox variant=\"secondary\">\n                            <MagnifyIcon enabled={false} />\n                        </IconBox>\n                        <div className=\"flex flex-col gap-0.5 flex-1\">\n                            <span className=\"text-[15px]\">Magnify a Block</span>\n                            <KeyBinding keyDecl=\"Cmd:m\" />\n                        </div>\n                    </div>\n                    <div className=\"flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                        <IconBox variant=\"secondary\">\n                            <i className=\"fa-solid fa-sharp fa-laptop fa-fw\" />\n                        </IconBox>\n                        <div className=\"flex flex-col gap-0.5 flex-1\">\n                            <span className=\"text-[15px]\">Connect to a remote server</span>\n                            <KeyBinding keyDecl=\"Cmd:g\" />\n                        </div>\n                    </div>\n                    <div className=\"flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                        <IconBox variant=\"secondary\">\n                            <i className=\"fa-solid fa-sharp fa-cog fa-fw\" />\n                        </IconBox>\n                        <span className=\"text-[15px]\">Block Settings</span>\n                    </div>\n                    <div className=\"flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                        <IconBox variant=\"secondary\">\n                            <i className=\"fa-solid fa-sharp fa-xmark-large fa-fw\" />\n                        </IconBox>\n                        <div className=\"flex flex-col gap-0.5 flex-1\">\n                            <span className=\"text-[15px]\">Close Block</span>\n                            <KeyBinding keyDecl=\"Cmd:w\" />\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex flex-col gap-4 p-5 bg-gradient-to-br from-highlightbg/30 to-transparent hover:from-accent-400/5 rounded-lg border border-white/10 hover:border-accent-400/20 transition-all duration-300\">\n                <div className=\"flex items-center gap-2 text-xl font-bold\">\n                    <div className=\"w-1 h-6 bg-accent-400 rounded-full\"></div>\n                    <span className=\"text-foreground\">Important Keybindings</span>\n                </div>\n\n                <div className=\"grid grid-cols-1 @lg:grid-cols-2 gap-x-5 gap-y-6\">\n                    <div className=\"flex flex-col gap-1.5\">\n                        <div className=\"text-sm text-accent-400 font-semibold uppercase tracking-wide mb-1\">\n                            Main Keybindings\n                        </div>\n                        <div className=\"flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                            <span className=\"text-[15px]\">New Tab</span>\n                            <KeyBinding keyDecl=\"Cmd:t\" />\n                        </div>\n                        <div className=\"flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                            <span className=\"text-[15px]\">New Terminal Block</span>\n                            <KeyBinding keyDecl=\"Cmd:n\" />\n                        </div>\n                        <div className=\"flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                            <span className=\"text-[15px]\">Open Wave AI Panel</span>\n                            <KeyBinding keyDecl=\"Cmd:Shift:a\" />\n                        </div>\n                    </div>\n\n                    <div className=\"flex flex-col gap-1.5\">\n                        <div className=\"text-sm text-accent-400 font-semibold uppercase tracking-wide mb-1\">\n                            Tab Switching ({PLATFORM === PlatformMacOS ? \"Cmd\" : \"Alt\"})\n                        </div>\n                        <div className=\"flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                            <span className=\"text-[15px]\">Switch To Nth Tab</span>\n                            <KeyBinding keyDecl=\"Cmd:Digit\" />\n                        </div>\n                        <div className=\"flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                            <span className=\"text-[15px]\">Previous Tab</span>\n                            <KeyBinding keyDecl=\"Cmd:[\" />\n                        </div>\n                        <div className=\"flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                            <span className=\"text-[15px]\">Next Tab</span>\n                            <KeyBinding keyDecl=\"Cmd:]\" />\n                        </div>\n                    </div>\n\n                    <div className=\"flex flex-col gap-1.5\">\n                        <div className=\"text-sm text-accent-400 font-semibold uppercase tracking-wide mb-1\">\n                            Block Navigation (Ctrl-Shift)\n                        </div>\n                        <div className=\"flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                            <span className=\"text-[15px]\">Navigate Between Blocks</span>\n                            <KeyBinding keyDecl=\"Ctrl:Shift:Arrows\" />\n                        </div>\n                        <div className=\"flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                            <span className=\"text-[15px]\">Focus Nth Block</span>\n                            <KeyBinding keyDecl=\"Ctrl:Shift:Digit\" />\n                        </div>\n                        <div className=\"flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                            <span className=\"text-[15px]\">Focus Wave AI</span>\n                            <KeyBinding keyDecl=\"Ctrl:Shift:0\" />\n                        </div>\n                    </div>\n\n                    <div className=\"flex flex-col gap-1.5\">\n                        <div className=\"text-sm text-accent-400 font-semibold uppercase tracking-wide mb-1\">\n                            Split Blocks\n                        </div>\n                        <div className=\"flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                            <span className=\"text-[15px]\">Split Right</span>\n                            <KeyBinding keyDecl=\"Cmd:d\" />\n                        </div>\n                        <div className=\"flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                            <span className=\"text-[15px]\">Split Below</span>\n                            <KeyBinding keyDecl=\"Cmd:Shift:d\" />\n                        </div>\n                        <div className=\"flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                            <span className=\"text-[15px]\">Split in Direction</span>\n                            <KeyBinding keyDecl=\"Ctrl:Shift:s + Arrows\" />\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex flex-col gap-4 p-5 bg-gradient-to-br from-highlightbg/30 to-transparent hover:from-accent-400/5 rounded-lg border border-white/10 hover:border-accent-400/20 transition-all duration-300\">\n                <div className=\"flex items-center gap-2 text-xl font-bold\">\n                    <div className=\"w-1 h-6 bg-accent-400 rounded-full\"></div>\n                    <span className=\"text-foreground\">wsh commands</span>\n                </div>\n                <div className=\"grid grid-cols-1 @md:grid-cols-2 gap-4\">\n                    <div className=\"flex flex-col gap-2 p-4 bg-black/20 rounded-lg border border-accent-400/30 hover:border-accent-400/50 transition-colors\">\n                        <code className=\"font-mono text-sm\">\n                            <span className=\"text-secondary\">&gt; </span>\n                            <span className=\"text-accent-400 font-semibold\">wsh view</span>\n                            <span className=\"text-muted\"> [filename|url]</span>\n                        </code>\n                        <div className=\"text-secondary text-sm mt-1\">Preview files, directories, or web URLs</div>\n                    </div>\n                    <div className=\"flex flex-col gap-2 p-4 bg-black/20 rounded-lg border border-accent-400/30 hover:border-accent-400/50 transition-colors\">\n                        <code className=\"font-mono text-sm\">\n                            <span className=\"text-secondary\">&gt; </span>\n                            <span className=\"text-accent-400 font-semibold\">wsh edit</span>\n                            <span className=\"text-muted\"> [filename]</span>\n                        </code>\n                        <div className=\"text-secondary text-sm mt-1\">Edit config and code files</div>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex flex-col gap-4 p-5 bg-gradient-to-br from-highlightbg/30 to-transparent hover:from-accent-400/5 rounded-lg border border-white/10 hover:border-accent-400/20 transition-all duration-300\">\n                <div className=\"flex items-center gap-2 text-xl font-bold\">\n                    <div className=\"w-1 h-6 bg-accent-400 rounded-full\"></div>\n                    <span className=\"text-foreground\">More Tips</span>\n                </div>\n                <div className=\"flex flex-col gap-2\">\n                    <div className=\"flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                        <IconBox variant=\"secondary\">\n                            <i className=\"fa-solid fa-sharp fa-computer-mouse fa-fw\" />\n                        </IconBox>\n                        <span>\n                            <b>Tabs</b> - Right click any tab to change backgrounds or rename.\n                        </span>\n                    </div>\n                    <div className=\"flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                        <IconBox variant=\"secondary\">\n                            <i className=\"fa-solid fa-sharp fa-cog fa-fw\" />\n                        </IconBox>\n                        <span>\n                            <b>Web View</b> - Click the gear in the web view to set your homepage\n                        </span>\n                    </div>\n                    <div className=\"flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors\">\n                        <IconBox variant=\"secondary\">\n                            <i className=\"fa-solid fa-sharp fa-cog fa-fw\" />\n                        </IconBox>\n                        <span>\n                            <b>Terminal</b> - Click the gear in the terminal to set your terminal theme and font size\n                        </span>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex flex-col gap-4 p-5 bg-gradient-to-br from-highlightbg/30 to-transparent hover:from-accent-400/5 rounded-lg border border-white/10 hover:border-accent-400/20 transition-all duration-300\">\n                <div className=\"flex items-center gap-2 text-xl font-bold\">\n                    <div className=\"w-1 h-6 bg-accent-400 rounded-full\"></div>\n                    <span className=\"text-foreground\">Need More Help?</span>\n                </div>\n                <div className=\"grid grid-cols-1 @sm:grid-cols-2 gap-2\">\n                    <div className=\"flex items-center gap-3 p-3 rounded-md bg-black/20 hover:bg-black/30 transition-colors cursor-pointer\">\n                        <IconBox variant=\"secondary\">\n                            <i className=\"fa-brands fa-discord fa-fw\" />\n                        </IconBox>\n                        <a\n                            target=\"_blank\"\n                            href=\"https://discord.gg/XfvZ334gwU\"\n                            rel=\"noopener\"\n                            className=\"hover:text-accent-400 hover:underline transition-colors font-medium\"\n                        >\n                            Join Our Discord\n                        </a>\n                    </div>\n                    <div className=\"flex items-center gap-3 p-3 rounded-md bg-black/20 hover:bg-black/30 transition-colors cursor-pointer\">\n                        <IconBox variant=\"secondary\">\n                            <i className=\"fa-solid fa-sharp fa-sliders fa-fw\" />\n                        </IconBox>\n                        <a\n                            target=\"_blank\"\n                            href=\"https://docs.waveterm.dev/config\"\n                            rel=\"noopener\"\n                            className=\"hover:text-accent-400 hover:underline transition-colors font-medium\"\n                        >\n                            Configuration Options\n                        </a>\n                    </div>\n                    <div className=\"flex items-center gap-3 p-3 rounded-md bg-black/20 hover:bg-black/30 transition-colors cursor-pointer\">\n                        <IconBox variant=\"secondary\">\n                            <i className=\"fa-solid fa-sharp fa-keyboard fa-fw\" />\n                        </IconBox>\n                        <a\n                            target=\"_blank\"\n                            href=\"https://docs.waveterm.dev/keybindings\"\n                            rel=\"noopener\"\n                            className=\"hover:text-accent-400 hover:underline transition-colors font-medium\"\n                        >\n                            All Keybindings\n                        </a>\n                    </div>\n                    <div className=\"flex items-center gap-3 p-3 rounded-md bg-black/20 hover:bg-black/30 transition-colors cursor-pointer\">\n                        <IconBox variant=\"secondary\">\n                            <i className=\"fa-solid fa-sharp fa-book fa-fw\" />\n                        </IconBox>\n                        <a\n                            target=\"_blank\"\n                            href=\"https://docs.waveterm.dev\"\n                            rel=\"noopener\"\n                            className=\"hover:text-accent-400 hover:underline transition-colors font-medium\"\n                        >\n                            Full Documentation\n                        </a>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n\nexport { KeyBinding, QuickTips };\n"
  },
  {
    "path": "frontend/app/element/remark-mermaid-to-tag.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { Code, Content, Html, Root } from \"mdast\";\nimport type { Plugin } from \"unified\";\nimport type { Parent } from \"unist\";\nimport { SKIP, visit } from \"unist-util-visit\";\nimport type { VFile } from \"vfile\";\n\nconst escapeHTML = (s: string) => s.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n\nconst remarkMermaidToTag: Plugin<[], Root> = function () {\n    return (tree: Root, _file: VFile) => {\n        visit(tree, \"code\", (node: Code, index: number | null, parent: Parent | null) => {\n            if (!parent || index === null) return;\n            if ((node.lang ?? \"\").toLowerCase() !== \"mermaid\") return;\n\n            const htmlNode: Html = {\n                type: \"html\",\n                value: `<mermaidblock>${escapeHTML(node.value ?? \"\")}</mermaidblock>`,\n            };\n\n            (parent.children as Content[])[index] = htmlNode as Content;\n            return SKIP;\n        });\n    };\n};\n\nexport default remarkMermaidToTag;\n"
  },
  {
    "path": "frontend/app/element/search.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.search-container {\n    display: flex;\n    flex-direction: row;\n    background-color: var(--modal-bg-color);\n    border: 1px solid var(--accent-color);\n    border-radius: var(--modal-border-radius);\n    box-shadow: var(--modal-box-shadow);\n    color: var(--main-text-color);\n    padding: 5px 5px 5px 10px;\n    gap: 5px;\n    width: 50%;\n    max-width: 300px;\n    min-width: 200px;\n\n    input {\n        flex: 1 1 auto;\n        border: none;\n        font-size: 14px;\n        height: 100%;\n        padding: 0;\n        border-radius: 0;\n    }\n\n    .search-results {\n        font-size: 12px;\n        margin: auto 0;\n        color: var(--secondary-text-color);\n\n        &.hidden {\n            display: none;\n        }\n    }\n\n    .right-buttons,\n    .additional-buttons {\n        display: flex;\n        border-left: 1px solid var(--modal-border-color);\n    }\n\n    .right-buttons {\n        gap: 5px;\n        padding-left: 4px;\n        button {\n            font-size: 12px;\n        }\n    }\n\n    .additional-buttons {\n        gap: 1px;\n        padding-left: 5px;\n        button {\n            font-size: 14px;\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/search.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { autoUpdate, FloatingPortal, Middleware, offset, useFloating } from \"@floating-ui/react\";\nimport clsx from \"clsx\";\nimport { atom, useAtom, WritableAtom } from \"jotai\";\nimport { memo, useCallback, useEffect, useMemo, useRef } from \"react\";\nimport { IconButton, ToggleIconButton } from \"./iconbutton\";\nimport { Input } from \"./input\";\nimport \"./search.scss\";\n\ntype SearchProps = SearchAtoms & {\n    anchorRef?: React.RefObject<HTMLElement>;\n    offsetX?: number;\n    offsetY?: number;\n    onSearch?: (search: string) => void;\n    onNext?: () => void;\n    onPrev?: () => void;\n};\n\nconst SearchComponent = ({\n    searchValue: searchAtom,\n    resultsIndex: indexAtom,\n    resultsCount: numResultsAtom,\n    regex: regexAtom,\n    caseSensitive: caseSensitiveAtom,\n    wholeWord: wholeWordAtom,\n    isOpen: isOpenAtom,\n    focusInput: focusInputAtom,\n    anchorRef,\n    offsetX = 10,\n    offsetY = 10,\n    onSearch,\n    onNext,\n    onPrev,\n}: SearchProps) => {\n    const [isOpen, setIsOpen] = useAtom<boolean>(isOpenAtom);\n    const [search, setSearch] = useAtom<string>(searchAtom);\n    const [index, setIndex] = useAtom<number>(indexAtom);\n    const [numResults, setNumResults] = useAtom<number>(numResultsAtom);\n    const [focusInputCounter, setFocusInputCounter] = useAtom<number>(focusInputAtom);\n    const inputRef = useRef<HTMLInputElement>(null);\n\n    const handleOpenChange = useCallback((open: boolean) => {\n        setIsOpen(open);\n    }, []);\n\n    useEffect(() => {\n        if (!isOpen) {\n            setSearch(\"\");\n            setIndex(0);\n            setNumResults(0);\n            setFocusInputCounter(0);\n        }\n    }, [isOpen]);\n\n    useEffect(() => {\n        setIndex(0);\n        setNumResults(0);\n        onSearch?.(search);\n    }, [search]);\n\n    // When activateSearch fires while already open, it increments focusInputCounter\n    // to signal this specific instance to grab focus (avoids global DOM queries).\n    useEffect(() => {\n        if (focusInputCounter > 0 && isOpen) {\n            inputRef.current?.focus();\n            inputRef.current?.select();\n        }\n    }, [focusInputCounter]);\n\n    const middleware: Middleware[] = [];\n    const offsetCallback = useCallback(\n        ({ rects }) => {\n            const docRect = document.documentElement.getBoundingClientRect();\n            let yOffsetCalc = -rects.floating.height - offsetY;\n            let xOffsetCalc = -offsetX;\n            const floatingBottom = rects.reference.y + rects.floating.height + offsetY;\n            const floatingLeft = rects.reference.x + rects.reference.width - (rects.floating.width + offsetX);\n            if (floatingBottom > docRect.bottom) {\n                yOffsetCalc -= docRect.bottom - floatingBottom;\n            }\n            if (floatingLeft < 5) {\n                xOffsetCalc += 5 - floatingLeft;\n            }\n            return {\n                mainAxis: yOffsetCalc,\n                crossAxis: xOffsetCalc,\n            };\n        },\n        [offsetX, offsetY]\n    );\n    middleware.push(offset(offsetCallback));\n\n    const { refs, floatingStyles } = useFloating({\n        placement: \"top-end\",\n        open: isOpen,\n        onOpenChange: handleOpenChange,\n        whileElementsMounted: autoUpdate,\n        middleware,\n        elements: {\n            reference: anchorRef!.current,\n        },\n    });\n\n    const onPrevWrapper = useCallback(\n        () => (onPrev ? onPrev() : setIndex((index - 1) % numResults)),\n        [onPrev, index, numResults]\n    );\n    const onNextWrapper = useCallback(\n        () => (onNext ? onNext() : setIndex((index + 1) % numResults)),\n        [onNext, index, numResults]\n    );\n\n    const onKeyDown = useCallback(\n        (e: React.KeyboardEvent) => {\n            if (e.key === \"Enter\") {\n                if (e.shiftKey) {\n                    onPrevWrapper();\n                } else {\n                    onNextWrapper();\n                }\n                e.preventDefault();\n            }\n        },\n        [onPrevWrapper, onNextWrapper, setIsOpen]\n    );\n\n    const prevDecl: IconButtonDecl = {\n        elemtype: \"iconbutton\",\n        icon: \"chevron-up\",\n        title: \"Previous Result (Shift+Enter)\",\n        disabled: numResults === 0,\n        click: onPrevWrapper,\n    };\n\n    const nextDecl: IconButtonDecl = {\n        elemtype: \"iconbutton\",\n        icon: \"chevron-down\",\n        title: \"Next Result (Enter)\",\n        disabled: numResults === 0,\n        click: onNextWrapper,\n    };\n\n    const closeDecl: IconButtonDecl = {\n        elemtype: \"iconbutton\",\n        icon: \"xmark-large\",\n        title: \"Close (Esc)\",\n        click: () => setIsOpen(false),\n    };\n\n    const regexDecl = createToggleButtonDecl(regexAtom, \"custom@regex\", \"Regular Expression\");\n    const wholeWordDecl = createToggleButtonDecl(wholeWordAtom, \"custom@whole-word\", \"Whole Word\");\n    const caseSensitiveDecl = createToggleButtonDecl(caseSensitiveAtom, \"custom@case-sensitive\", \"Case Sensitive\");\n\n    return (\n        <>\n            {isOpen && (\n                <FloatingPortal>\n                    <div className=\"search-container\" style={{ ...floatingStyles }} ref={refs.setFloating}>\n                        <Input\n                            ref={inputRef}\n                            placeholder=\"Search\"\n                            value={search}\n                            onChange={setSearch}\n                            onKeyDown={onKeyDown}\n                            autoFocus\n                        />\n                        <div\n                            className={clsx(\"search-results\", { hidden: numResults === 0 })}\n                            aria-live=\"polite\"\n                            aria-label=\"Search Results\"\n                        >\n                            {index + 1}/{numResults}\n                        </div>\n\n                        {(caseSensitiveDecl || wholeWordDecl || regexDecl) && (\n                            <div className=\"additional-buttons\">\n                                {caseSensitiveDecl && <ToggleIconButton decl={caseSensitiveDecl} />}\n                                {wholeWordDecl && <ToggleIconButton decl={wholeWordDecl} />}\n                                {regexDecl && <ToggleIconButton decl={regexDecl} />}\n                            </div>\n                        )}\n\n                        <div className=\"right-buttons\">\n                            <IconButton decl={prevDecl} />\n                            <IconButton decl={nextDecl} />\n                            <IconButton decl={closeDecl} />\n                        </div>\n                    </div>\n                </FloatingPortal>\n            )}\n        </>\n    );\n};\n\nexport const Search = memo(SearchComponent) as typeof SearchComponent;\n\ntype SearchOptions = {\n    anchorRef?: React.RefObject<HTMLElement>;\n    viewModel?: ViewModel;\n    regex?: boolean;\n    caseSensitive?: boolean;\n    wholeWord?: boolean;\n};\n\nexport function useSearch(options?: SearchOptions): SearchProps {\n    const searchAtoms: SearchAtoms = useMemo(\n        () => ({\n            searchValue: atom(\"\"),\n            resultsIndex: atom(0),\n            resultsCount: atom(0),\n            isOpen: atom(false),\n            focusInput: atom(0),\n            regex: options?.regex !== undefined ? atom(options.regex) : undefined,\n            caseSensitive: options?.caseSensitive !== undefined ? atom(options.caseSensitive) : undefined,\n            wholeWord: options?.wholeWord !== undefined ? atom(options.wholeWord) : undefined,\n        }),\n        []\n    );\n    const anchorRef = options?.anchorRef ?? useRef(null);\n    useEffect(() => {\n        if (options?.viewModel) {\n            options.viewModel.searchAtoms = searchAtoms;\n            return () => {\n                options.viewModel.searchAtoms = undefined;\n            };\n        }\n    }, [options?.viewModel]);\n    return { ...searchAtoms, anchorRef };\n}\n\nconst createToggleButtonDecl = (\n    atom: WritableAtom<boolean, [boolean], void> | undefined,\n    icon: string,\n    title: string\n): ToggleIconButtonDecl =>\n    atom\n        ? {\n              elemtype: \"toggleiconbutton\",\n              icon,\n              title,\n              active: atom,\n          }\n        : null;\n"
  },
  {
    "path": "frontend/app/element/streamdown.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { CopyButton } from \"@/app/element/copybutton\";\nimport { IconButton } from \"@/app/element/iconbutton\";\nimport { cn, useAtomValueSafe } from \"@/util/util\";\nimport type { Atom } from \"jotai\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { bundledLanguages, codeToHtml } from \"shiki/bundle/web\";\nimport { Streamdown } from \"streamdown\";\nimport { throttle } from \"throttle-debounce\";\n\nconst ShikiTheme = \"github-dark-high-contrast\";\n\nfunction extractText(node: React.ReactNode): string {\n    if (node == null || typeof node === \"boolean\") return \"\";\n    if (typeof node === \"string\" || typeof node === \"number\") return String(node);\n    if (Array.isArray(node)) return node.map(extractText).join(\"\");\n    // @ts-expect-error props exists on ReactElement\n    if (typeof node === \"object\" && node.props) return extractText(node.props.children);\n    return \"\";\n}\n\nfunction CodePlain({ className = \"\", isCodeBlock, text }: { className?: string; isCodeBlock: boolean; text: string }) {\n    if (isCodeBlock) {\n        return <code className={cn(\"font-mono text-[12px]\", className)}>{text}</code>;\n    }\n\n    return (\n        <code className={cn(\"text-secondary font-mono text-[12px] rounded-sm bg-zinc-800/80 px-1.5 py-0.5\", className)}>\n            {text}\n        </code>\n    );\n}\n\nfunction CodeHighlight({ className = \"\", lang, text }: { className?: string; lang: string; text: string }) {\n    const [html, setHtml] = useState<string>(\"\");\n    const [hasError, setHasError] = useState(false);\n    const codeRef = useRef<HTMLElement>(null);\n    const seqRef = useRef(0);\n\n    const highlightCode = useCallback(\n        async (textToHighlight: string, language: string, disposedRef: { current: boolean }, seq: number) => {\n            try {\n                const full = await codeToHtml(textToHighlight, { lang: language, theme: ShikiTheme });\n                const start = full.indexOf(\"<code\");\n                const open = full.indexOf(\">\", start);\n                const end = full.lastIndexOf(\"</code>\");\n                const inner = start !== -1 && open !== -1 && end !== -1 ? full.slice(open + 1, end) : \"\";\n                if (!disposedRef.current && seq === seqRef.current) {\n                    setHtml(inner);\n                    setHasError(false);\n                }\n            } catch (e) {\n                if (!disposedRef.current && seq === seqRef.current) {\n                    setHasError(true);\n                }\n                console.warn(`Shiki highlight failed for ${language}`, e);\n            }\n        },\n        []\n    );\n\n    const throttledHighlight = useMemo(() => throttle(300, highlightCode, { noLeading: false }), [highlightCode]);\n\n    useEffect(() => {\n        const disposedRef = { current: false };\n\n        if (!text) {\n            setHtml(\"\");\n            return;\n        }\n\n        seqRef.current++;\n        const currentSeq = seqRef.current;\n        throttledHighlight(text, lang, disposedRef, currentSeq);\n\n        return () => {\n            disposedRef.current = true;\n        };\n    }, [text, lang, throttledHighlight]);\n\n    if (hasError) {\n        return (\n            <code ref={codeRef} className={cn(\"font-mono text-[12px]\", className)}>\n                {text}\n            </code>\n        );\n    }\n\n    if (!html && text) {\n        return (\n            <code ref={codeRef} className={cn(\"font-mono text-[12px] text-transparent\", className)}>\n                {text}\n            </code>\n        );\n    }\n\n    return (\n        <code\n            ref={codeRef}\n            className={cn(\"font-mono text-[12px]\", className)}\n            dangerouslySetInnerHTML={{ __html: html }}\n        />\n    );\n}\n\nexport function Code({ className = \"\", children }: { className?: string; children: React.ReactNode }) {\n    const m = className?.match(/language-([\\w+-]+)/i);\n    const isCodeBlock = !!m;\n    const lang = m?.[1] || \"text\";\n    const text = extractText(children);\n\n    if (isCodeBlock && lang in bundledLanguages) {\n        return <CodeHighlight className={className} lang={lang} text={text} />;\n    }\n\n    return <CodePlain className={className} isCodeBlock={isCodeBlock} text={text} />;\n}\n\ntype CodeBlockProps = {\n    children: React.ReactNode;\n    onClickExecute?: (cmd: string) => void;\n    codeBlockMaxWidthAtom?: Atom<number>;\n};\n\nconst CodeBlock = ({ children, onClickExecute, codeBlockMaxWidthAtom }: CodeBlockProps) => {\n    const codeBlockMaxWidth = useAtomValueSafe(codeBlockMaxWidthAtom);\n    const getLanguage = (children: any): string => {\n        if (children?.props?.className) {\n            const match = children.props.className.match(/language-([\\w+-]+)/i);\n            if (match) return match[1];\n        }\n        return \"text\";\n    };\n\n    const handleCopy = async (e: React.MouseEvent) => {\n        const textToCopy = extractText(children).replace(/\\n$/, \"\");\n        await navigator.clipboard.writeText(textToCopy);\n    };\n\n    const handleExecute = (e: React.MouseEvent) => {\n        const cmd = extractText(children).replace(/\\n$/, \"\");\n        if (onClickExecute) {\n            onClickExecute(cmd);\n            return;\n        }\n    };\n\n    const language = getLanguage(children);\n\n    return (\n        <div\n            className={cn(\"rounded-lg overflow-hidden bg-black my-4\", codeBlockMaxWidth && \"max-w-full\")}\n            style={\n                codeBlockMaxWidth\n                    ? { maxWidth: codeBlockMaxWidth, minWidth: Math.min(400, codeBlockMaxWidth) }\n                    : undefined\n            }\n        >\n            <div className=\"flex items-center justify-between pl-3 pr-2 pt-2 pb-1.5\">\n                <span className=\"text-[11px] text-white/50\">{language}</span>\n                <div className=\"flex items-center gap-2\">\n                    <CopyButton onClick={handleCopy} title=\"Copy\" />\n                    {onClickExecute && (\n                        <IconButton\n                            decl={{\n                                elemtype: \"iconbutton\",\n                                icon: \"regular@square-terminal\",\n                                click: handleExecute,\n                            }}\n                        />\n                    )}\n                </div>\n            </div>\n            <pre className=\"px-4 pb-2 pt-0 overflow-x-auto m-0 text-secondary max-w-full\">{children}</pre>\n        </div>\n    );\n};\n\nfunction Collapsible({ title, children, defaultOpen = false }) {\n    const [isOpen, setIsOpen] = useState(defaultOpen);\n\n    return (\n        <div className=\"my-3\">\n            <button\n                className=\"flex items-center gap-2 cursor-pointer bg-transparent border-0 p-0 font-medium text-secondary hover:text-primary\"\n                onClick={() => setIsOpen(!isOpen)}\n            >\n                <span className=\"text-[0.65rem] text-primary transition-transform duration-200 inline-block w-3\">\n                    {isOpen ? \"\\u25BC\" : \"\\u25B6\"} {/* ▼ ▶ */}\n                </span>\n                <span>{title}</span>\n            </button>\n            {isOpen && <div className=\"mt-2 ml-1 pl-3.5 border-l-2 border-border text-secondary\">{children}</div>}\n        </div>\n    );\n}\n\ninterface WaveStreamdownProps {\n    text: string;\n    parseIncompleteMarkdown?: boolean;\n    className?: string;\n    onClickExecute?: (cmd: string) => void;\n    codeBlockMaxWidthAtom?: Atom<number>;\n}\n\nexport const WaveStreamdown = ({\n    text,\n    parseIncompleteMarkdown,\n    className,\n    onClickExecute,\n    codeBlockMaxWidthAtom,\n}: WaveStreamdownProps) => {\n    const components = useMemo(\n        () => ({\n            code: Code,\n            pre: (props: React.HTMLAttributes<HTMLPreElement>) => (\n                <CodeBlock\n                    children={props.children}\n                    onClickExecute={onClickExecute}\n                    codeBlockMaxWidthAtom={codeBlockMaxWidthAtom}\n                />\n            ),\n            p: (props: React.HTMLAttributes<HTMLParagraphElement>) => <p {...props} className=\"text-secondary\" />,\n            h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => (\n                <h1 {...props} className=\"text-2xl font-bold text-primary mt-6 mb-3\" />\n            ),\n            h2: (props: React.HTMLAttributes<HTMLHeadingElement>) => (\n                <h2 {...props} className=\"text-xl font-bold text-primary mt-5 mb-2\" />\n            ),\n            h3: (props: React.HTMLAttributes<HTMLHeadingElement>) => (\n                <h3 {...props} className=\"text-lg font-bold text-primary mt-4 mb-2\" />\n            ),\n            h4: (props: React.HTMLAttributes<HTMLHeadingElement>) => (\n                <h4 {...props} className=\"text-base font-semibold text-primary mt-3 mb-1\" />\n            ),\n            h5: (props: React.HTMLAttributes<HTMLHeadingElement>) => (\n                <h5 {...props} className=\"text-sm font-semibold text-primary mt-2 mb-1\" />\n            ),\n            h6: (props: React.HTMLAttributes<HTMLHeadingElement>) => (\n                <h6 {...props} className=\"text-sm text-primary mt-2 mb-1\" />\n            ),\n            table: (props: React.HTMLAttributes<HTMLTableElement>) => (\n                <table {...props} className=\"w-full border-collapse my-4\" />\n            ),\n            thead: (props: React.HTMLAttributes<HTMLTableSectionElement>) => (\n                <thead {...props} className=\"border-b border-border\" />\n            ),\n            tbody: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tbody {...props} />,\n            tr: (props: React.HTMLAttributes<HTMLTableRowElement>) => (\n                <tr {...props} className=\"border-b border-border/50 last:border-0\" />\n            ),\n            th: (props: React.HTMLAttributes<HTMLTableCellElement>) => (\n                <th {...props} className=\"text-left font-semibold px-2 py-1.5 text-sm text-primary\" />\n            ),\n            td: (props: React.HTMLAttributes<HTMLTableCellElement>) => (\n                <td {...props} className=\"px-2 py-1.5 text-sm text-secondary\" />\n            ),\n            ul: (props: React.HTMLAttributes<HTMLUListElement>) => (\n                <ul\n                    {...props}\n                    className=\"list-disc list-outside pl-6 mt-1 mb-2 text-secondary [&_ul]:my-1 [&_ol]:my-1\"\n                />\n            ),\n            ol: (props: React.HTMLAttributes<HTMLOListElement>) => (\n                <ol\n                    {...props}\n                    className=\"list-decimal list-outside pl-6 mt-1 mb-2 text-secondary [&_ul]:my-1 [&_ol]:my-1\"\n                />\n            ),\n            li: (props: React.HTMLAttributes<HTMLLIElement>) => (\n                <li {...props} className=\"text-secondary leading-snug\" />\n            ),\n            blockquote: (props: React.HTMLAttributes<HTMLQuoteElement>) => (\n                <blockquote {...props} className=\"border-l-2 border-border pl-4 my-2 text-secondary italic\" />\n            ),\n            details: ({ children, ...props }) => {\n                const childArray = Array.isArray(children) ? children : [children];\n\n                // Extract summary text and content\n                const summary = childArray.find((c) => c?.props?.node?.tagName === \"summary\");\n                const summaryText = summary?.props?.children || \"Details\";\n                const content = childArray.filter((c) => c?.props?.node?.tagName !== \"summary\");\n\n                return (\n                    <Collapsible title={summaryText} defaultOpen={props.open}>\n                        {content}\n                    </Collapsible>\n                );\n            },\n            summary: () => null, // Don't render summary separately\n            a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (\n                <a {...props} className=\"text-accent hover:underline\" />\n            ),\n            strong: (props: React.HTMLAttributes<HTMLElement>) => (\n                <strong {...props} className=\"font-semibold text-secondary\" />\n            ),\n            em: (props: React.HTMLAttributes<HTMLElement>) => <em {...props} className=\"italic text-secondary\" />,\n        }),\n        [onClickExecute, codeBlockMaxWidthAtom]\n    );\n\n    return (\n        <Streamdown\n            parseIncompleteMarkdown={parseIncompleteMarkdown}\n            className={cn(\n                \"wave-streamdown text-secondary [&>*:first-child]:mt-0 [&>*:first-child>*:first-child]:mt-0 space-y-2\",\n                className\n            )}\n            shikiTheme={[ShikiTheme, ShikiTheme]}\n            controls={{\n                code: false,\n                table: false,\n                mermaid: true,\n            }}\n            mermaid={{\n                config: {\n                    theme: \"dark\",\n                    darkMode: true,\n                },\n            }}\n            components={components}\n        >\n            {text}\n        </Streamdown>\n    );\n};\n"
  },
  {
    "path": "frontend/app/element/toggle.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.check-toggle-wrapper {\n    user-select: none;\n    display: flex;\n    height: 100%;\n    align-items: center;\n    justify-content: center;\n\n    .checkbox-toggle {\n        position: relative;\n        display: inline-block;\n        width: 32px;\n        height: 20px;\n\n        input {\n            opacity: 0;\n            width: 0;\n            height: 0;\n        }\n\n        .slider {\n            position: absolute;\n            cursor: pointer;\n            content: \"\";\n            top: 0;\n            bottom: 0;\n            left: 0;\n            right: 0;\n            background-color: var(--toggle-bg-color);\n            transition: 0.5s;\n            border-radius: 33px;\n        }\n\n        .slider:before {\n            position: absolute;\n            content: \"\";\n            height: 16px;\n            width: 16px;\n            left: 3px;\n            bottom: 2px;\n            background-color: var(--toggle-thumb-color);\n            transition: 0.5s;\n            border-radius: 50%;\n        }\n\n        input:checked + .slider {\n            background-color: var(--toggle-checked-bg-color);\n        }\n\n        input:checked + .slider:before {\n            transform: translateX(11px);\n        }\n    }\n\n    label,\n    .toggle-label {\n        cursor: pointer;\n        padding: 0 5px;\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/toggle.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { useRef } from \"react\";\nimport { cn } from \"@/util/util\";\nimport \"./toggle.scss\";\n\ninterface ToggleProps {\n    checked: boolean;\n    onChange: (value: boolean) => void;\n    label?: string;\n    id?: string;\n    className?: string;\n}\n\nconst Toggle = ({ checked, onChange, label, id, className }: ToggleProps) => {\n    const inputRef = useRef<HTMLInputElement>(null);\n\n    const handleChange = (e: any) => {\n        if (onChange != null) {\n            onChange(e.target.checked);\n        }\n    };\n\n    const handleLabelClick = () => {\n        if (inputRef.current) {\n            inputRef.current.click();\n        }\n    };\n\n    const inputId = id || `toggle-${Math.random().toString(36).substr(2, 9)}`;\n\n    return (\n        <div className={cn(\"check-toggle-wrapper\", className)}>\n            <label htmlFor={inputId} className=\"checkbox-toggle\">\n                <input id={inputId} type=\"checkbox\" checked={checked} onChange={handleChange} ref={inputRef} />\n                <span className=\"slider\" />\n            </label>\n            {label && (\n                <span className=\"toggle-label\" onClick={handleLabelClick}>\n                    {label}\n                </span>\n            )}\n        </div>\n    );\n};\n\nexport { Toggle };\n"
  },
  {
    "path": "frontend/app/element/tooltip.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { cn } from \"@/util/util\";\nimport {\n    FloatingPortal,\n    autoUpdate,\n    flip,\n    offset,\n    shift,\n    useFloating,\n    useHover,\n    useInteractions,\n} from \"@floating-ui/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\ninterface TooltipProps {\n    children: React.ReactNode;\n    content: React.ReactNode;\n    placement?: \"top\" | \"bottom\" | \"left\" | \"right\";\n    forceOpen?: boolean;\n    disable?: boolean;\n    openDelay?: number;\n    divClassName?: string;\n    divStyle?: React.CSSProperties;\n    divOnClick?: (e: React.MouseEvent<HTMLDivElement>) => void;\n    divRef?: React.RefObject<HTMLDivElement>;\n    hideOnClick?: boolean;\n}\n\nfunction TooltipInner({\n    children,\n    content,\n    placement = \"top\",\n    forceOpen = false,\n    openDelay = 300,\n    divClassName,\n    divStyle,\n    divOnClick,\n    divRef,\n    hideOnClick = false,\n}: Omit<TooltipProps, \"disable\">) {\n    const [isOpen, setIsOpen] = useState(forceOpen);\n    const [isVisible, setIsVisible] = useState(false);\n    const [clickDisabled, setClickDisabled] = useState(false);\n    const timeoutRef = useRef<number | null>(null);\n    const prevForceOpenRef = useRef<boolean>(forceOpen);\n\n    const { refs, floatingStyles, context } = useFloating({\n        open: isOpen,\n        onOpenChange: (open) => {\n            if (!open && forceOpen) {\n                return;\n            }\n            if (open) {\n                setIsOpen(true);\n                if (timeoutRef.current !== null) {\n                    window.clearTimeout(timeoutRef.current);\n                }\n                timeoutRef.current = window.setTimeout(() => {\n                    setIsVisible(true);\n                }, openDelay);\n            } else {\n                setIsVisible(false);\n                if (timeoutRef.current !== null) {\n                    window.clearTimeout(timeoutRef.current);\n                }\n                timeoutRef.current = window.setTimeout(() => {\n                    setIsOpen(false);\n                }, 300);\n            }\n        },\n        placement,\n        middleware: [offset(10), flip(), shift({ padding: 12 })],\n        whileElementsMounted: autoUpdate,\n    });\n\n    useEffect(() => {\n        if (forceOpen) {\n            setIsOpen(true);\n            setIsVisible(true);\n\n            if (timeoutRef.current !== null) {\n                window.clearTimeout(timeoutRef.current);\n                timeoutRef.current = null;\n            }\n        } else {\n            if (context.open && !prevForceOpenRef.current) {\n                // Keep it open if it's being hovered and wasn't forced open before\n            } else {\n                setIsVisible(false);\n\n                if (timeoutRef.current !== null) {\n                    window.clearTimeout(timeoutRef.current);\n                }\n\n                timeoutRef.current = window.setTimeout(() => {\n                    setIsOpen(false);\n                }, 300);\n            }\n        }\n\n        prevForceOpenRef.current = forceOpen;\n    }, [forceOpen, context.open]);\n\n    useEffect(() => {\n        return () => {\n            if (timeoutRef.current !== null) {\n                window.clearTimeout(timeoutRef.current);\n            }\n        };\n    }, []);\n\n    const hover = useHover(context, { enabled: !clickDisabled });\n    const { getReferenceProps, getFloatingProps } = useInteractions([hover]);\n\n    const handleClick = useCallback(\n        (e: React.MouseEvent<HTMLDivElement>) => {\n            if (hideOnClick) {\n                setIsVisible(false);\n                setIsOpen(false);\n                if (timeoutRef.current !== null) {\n                    window.clearTimeout(timeoutRef.current);\n                }\n                setClickDisabled(true);\n            }\n            divOnClick?.(e);\n        },\n        [hideOnClick, divOnClick]\n    );\n\n    const handlePointerEnter = useCallback(() => {\n        if (hideOnClick && clickDisabled) {\n            setClickDisabled(false);\n        }\n    }, [hideOnClick, clickDisabled]);\n\n    return (\n        <>\n            <div\n                ref={(node) => {\n                    refs.setReference(node);\n                    if (divRef) {\n                        divRef.current = node;\n                    }\n                }}\n                {...getReferenceProps({ onClick: handleClick, onPointerEnter: handlePointerEnter })}\n                className={divClassName}\n                style={divStyle}\n            >\n                {children}\n            </div>\n            {isOpen && (\n                <FloatingPortal>\n                    <div\n                        ref={refs.setFloating}\n                        style={{\n                            ...floatingStyles,\n                            opacity: isVisible ? 1 : 0,\n                            transition: \"opacity 200ms ease\",\n                        }}\n                        {...getFloatingProps()}\n                        className={cn(\n                            \"bg-zinc-800 border border-border rounded-md px-2 py-1 text-xs text-foreground shadow-xl z-50\"\n                        )}\n                    >\n                        {content}\n                    </div>\n                </FloatingPortal>\n            )}\n        </>\n    );\n}\n\nexport function Tooltip({\n    children,\n    content,\n    placement = \"top\",\n    forceOpen = false,\n    disable = false,\n    openDelay = 300,\n    divClassName,\n    divStyle,\n    divOnClick,\n    divRef,\n    hideOnClick = false,\n}: TooltipProps) {\n    if (disable) {\n        return (\n            <div ref={divRef} className={divClassName} style={divStyle} onClick={divOnClick}>\n                {children}\n            </div>\n        );\n    }\n\n    return (\n        <TooltipInner\n            children={children}\n            content={content}\n            placement={placement}\n            forceOpen={forceOpen}\n            openDelay={openDelay}\n            divClassName={divClassName}\n            divStyle={divStyle}\n            divOnClick={divOnClick}\n            divRef={divRef}\n            hideOnClick={hideOnClick}\n        />\n    );\n}\n"
  },
  {
    "path": "frontend/app/element/typingindicator.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n$dot-width: 11px;\n$dot-color: var(--success-color);\n$speed: 1.5s;\n\n.typing {\n    position: relative;\n    height: $dot-width;\n\n    span {\n        content: \"\";\n        animation: blink $speed infinite;\n        animation-fill-mode: both;\n        height: $dot-width;\n        width: $dot-width;\n        background: $dot-color;\n        position: absolute;\n        left: 0;\n        top: 0;\n        border-radius: 50%;\n\n        &:nth-child(2) {\n            animation-delay: 0.2s;\n            margin-left: $dot-width * 1.5;\n        }\n\n        &:nth-child(3) {\n            animation-delay: 0.4s;\n            margin-left: $dot-width * 3;\n        }\n    }\n}\n\n@keyframes blink {\n    0% {\n        opacity: 0.1;\n    }\n    20% {\n        opacity: 1;\n    }\n    100% {\n        opacity: 0.1;\n    }\n}\n"
  },
  {
    "path": "frontend/app/element/typingindicator.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport clsx from \"clsx\";\n\nimport \"./typingindicator.scss\";\n\ntype TypingIndicatorProps = {\n    className?: string;\n};\nconst TypingIndicator = ({ className }: TypingIndicatorProps) => {\n    return (\n        <div className={clsx(\"typing\", className)}>\n            <span></span>\n            <span></span>\n            <span></span>\n        </div>\n    );\n};\n\nexport { TypingIndicator };\n"
  },
  {
    "path": "frontend/app/hook/useDimensions.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport * as React from \"react\";\nimport { useCallback, useState } from \"react\";\nimport { debounce } from \"throttle-debounce\";\n\n// returns a callback ref, a ref object (that is set from the callback), and the width\n// pass debounceMs of null to not debounce\nexport function useDimensionsWithCallbackRef<T extends HTMLElement>(\n    debounceMs: number = null\n): [(node: T) => void, React.RefObject<T>, DOMRectReadOnly] {\n    const [domRect, setDomRect] = useState<DOMRectReadOnly>(null);\n    const [htmlElem, setHtmlElem] = useState<T>(null);\n    const rszObjRef = React.useRef<ResizeObserver>(null);\n    const oldHtmlElem = React.useRef<T>(null);\n    const ref = React.useRef<T>(null);\n    const refCallback = useCallback(\n        (node: T) => {\n            if (ref) {\n                setHtmlElem(node);\n                ref.current = node;\n            }\n        },\n        [ref]\n    );\n    const setDomRectDebounced = React.useCallback(debounceMs == null ? setDomRect : debounce(debounceMs, setDomRect), [\n        debounceMs,\n        setDomRect,\n    ]);\n    React.useEffect(() => {\n        if (!rszObjRef.current) {\n            rszObjRef.current = new ResizeObserver((entries) => {\n                for (const entry of entries) {\n                    if (domRect == null) {\n                        setDomRect(entry.contentRect);\n                    } else {\n                        setDomRectDebounced(entry.contentRect);\n                    }\n                }\n            });\n        }\n        if (htmlElem) {\n            rszObjRef.current.observe(htmlElem);\n            oldHtmlElem.current = htmlElem;\n        }\n        return () => {\n            if (oldHtmlElem.current) {\n                rszObjRef.current?.unobserve(oldHtmlElem.current);\n                oldHtmlElem.current = null;\n            }\n        };\n    }, [htmlElem]);\n    React.useEffect(() => {\n        return () => {\n            rszObjRef.current?.disconnect();\n        };\n    }, []);\n    return [refCallback, ref, domRect];\n}\n\nexport function useOnResize<T extends HTMLElement>(\n    ref: React.RefObject<T>,\n    callback: (domRect: DOMRectReadOnly) => void,\n    debounceMs: number = null\n) {\n    const isFirst = React.useRef(true);\n    const rszObjRef = React.useRef<ResizeObserver>(null);\n    const oldHtmlElem = React.useRef<T>(null);\n    const setDomRectDebounced = React.useCallback(debounceMs == null ? callback : debounce(debounceMs, callback), [\n        debounceMs,\n        callback,\n    ]);\n    React.useEffect(() => {\n        if (!rszObjRef.current) {\n            rszObjRef.current = new ResizeObserver((entries) => {\n                for (const entry of entries) {\n                    if (isFirst.current) {\n                        isFirst.current = false;\n                        callback(entry.contentRect);\n                    } else {\n                        setDomRectDebounced(entry.contentRect);\n                    }\n                }\n            });\n        }\n        if (ref.current) {\n            rszObjRef.current.observe(ref.current);\n            oldHtmlElem.current = ref.current;\n        }\n        return () => {\n            if (oldHtmlElem.current) {\n                rszObjRef.current?.unobserve(oldHtmlElem.current);\n                oldHtmlElem.current = null;\n            }\n        };\n    }, [ref.current, callback]);\n    React.useEffect(() => {\n        return () => {\n            rszObjRef.current?.disconnect();\n        };\n    }, []);\n}\n\n// will not react to ref changes\n// pass debounceMs of null to not debounce\nexport function useDimensionsWithExistingRef<T extends HTMLElement>(\n    ref?: React.RefObject<T>,\n    debounceMs: number = null\n): DOMRectReadOnly {\n    const [domRect, setDomRect] = useState<DOMRectReadOnly>(null);\n    const rszObjRef = React.useRef<ResizeObserver>(null);\n    const oldHtmlElem = React.useRef<T>(null);\n    const setDomRectDebounced = React.useCallback(debounceMs == null ? setDomRect : debounce(debounceMs, setDomRect), [\n        debounceMs,\n        setDomRect,\n    ]);\n    React.useEffect(() => {\n        if (!rszObjRef.current) {\n            rszObjRef.current = new ResizeObserver((entries) => {\n                for (const entry of entries) {\n                    if (domRect == null) {\n                        setDomRect(entry.contentRect);\n                    } else {\n                        setDomRectDebounced(entry.contentRect);\n                    }\n                }\n            });\n        }\n        if (ref?.current) {\n            rszObjRef.current.observe(ref.current);\n            oldHtmlElem.current = ref.current;\n        }\n        return () => {\n            if (oldHtmlElem.current) {\n                rszObjRef.current?.unobserve(oldHtmlElem.current);\n                oldHtmlElem.current = null;\n            }\n        };\n    }, [ref?.current]);\n    React.useEffect(() => {\n        return () => {\n            rszObjRef.current?.disconnect();\n        };\n    }, []);\n    if (ref?.current != null) {\n        return ref.current.getBoundingClientRect();\n    }\n    return null;\n}\n"
  },
  {
    "path": "frontend/app/hook/useLongClick.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport const useLongClick = (ref, onClick, onLongClick, disabled = false, ms = 300) => {\n    const timerRef = useRef(null);\n    const [longClickTriggered, setLongClickTriggered] = useState(false);\n\n    const startPress = useCallback(\n        (e: React.MouseEvent<any>) => {\n            if (onLongClick == null) {\n                return;\n            }\n            setLongClickTriggered(false);\n            timerRef.current = setTimeout(() => {\n                setLongClickTriggered(true);\n                onLongClick?.(e);\n            }, ms);\n        },\n        [onLongClick, ms]\n    );\n\n    const stopPress = useCallback(() => {\n        clearTimeout(timerRef.current);\n    }, []);\n\n    const handleClick = useCallback(\n        (e: React.MouseEvent<any>) => {\n            if (longClickTriggered) {\n                e.preventDefault();\n                e.stopPropagation();\n                return;\n            }\n            onClick?.(e);\n        },\n        [longClickTriggered, onClick]\n    );\n\n    useEffect(() => {\n        const element = ref.current;\n\n        if (!element || disabled) return;\n\n        element.addEventListener(\"mousedown\", startPress);\n        element.addEventListener(\"mouseup\", stopPress);\n        element.addEventListener(\"mouseleave\", stopPress);\n        element.addEventListener(\"click\", handleClick);\n\n        return () => {\n            element.removeEventListener(\"mousedown\", startPress);\n            element.removeEventListener(\"mouseup\", stopPress);\n            element.removeEventListener(\"mouseleave\", stopPress);\n            element.removeEventListener(\"click\", handleClick);\n        };\n    }, [ref.current, startPress, stopPress, handleClick]);\n\n    return ref;\n};\n"
  },
  {
    "path": "frontend/app/modals/about.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport Logo from \"@/app/asset/logo.svg\";\nimport { OnboardingGradientBg } from \"@/app/onboarding/onboarding-common\";\nimport { modalsModel } from \"@/app/store/modalmodel\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { isDev } from \"@/util/isdev\";\nimport { fireAndForget } from \"@/util/util\";\nimport { useEffect, useState } from \"react\";\nimport { getApi } from \"../store/global\";\nimport { Modal } from \"./modal\";\n\ninterface AboutModalVProps {\n    versionString: string;\n    updaterChannel: string;\n    onClose: () => void;\n}\n\nconst AboutModalV = ({ versionString, updaterChannel, onClose }: AboutModalVProps) => {\n    const currentDate = new Date();\n\n    return (\n        <Modal className=\"pt-[34px] pb-[34px] overflow-hidden w-[450px]\" onClose={onClose}>\n            <OnboardingGradientBg />\n            <div className=\"flex flex-col gap-[26px] w-full relative z-10\">\n                <div className=\"flex flex-col items-center justify-center gap-4 self-stretch w-full text-center\">\n                    <Logo />\n                    <div className=\"text-[25px]\">Wave Terminal</div>\n                    <div className=\"leading-5\">\n                        Open-Source AI-Integrated Terminal\n                        <br />\n                        Built for Seamless Workflows\n                    </div>\n                </div>\n                <div className=\"items-center gap-4 self-stretch w-full text-center\">\n                    Client Version {versionString}\n                    <br />\n                    Update Channel: {updaterChannel}\n                </div>\n                <div className=\"grid grid-cols-2 gap-[10px] self-stretch w-full\">\n                    <a\n                        href=\"https://github.com/wavetermdev/waveterm?ref=about\"\n                        target=\"_blank\"\n                        rel=\"noopener\"\n                        className=\"inline-flex items-center justify-center px-4 py-2 rounded border border-border hover:bg-hoverbg transition-colors duration-200\"\n                    >\n                        <i className=\"fa-brands fa-github mr-2\"></i>GitHub\n                    </a>\n                    <a\n                        href=\"https://www.waveterm.dev/?ref=about\"\n                        target=\"_blank\"\n                        rel=\"noopener\"\n                        className=\"inline-flex items-center justify-center px-4 py-2 rounded border border-border hover:bg-hoverbg transition-colors duration-200\"\n                    >\n                        <i className=\"fa-sharp fa-light fa-globe mr-2\"></i>Website\n                    </a>\n                    <a\n                        href=\"https://github.com/wavetermdev/waveterm/blob/main/ACKNOWLEDGEMENTS.md\"\n                        target=\"_blank\"\n                        rel=\"noopener\"\n                        className=\"inline-flex items-center justify-center px-4 py-2 rounded border border-border hover:bg-hoverbg transition-colors duration-200\"\n                    >\n                        <i className=\"fa-sharp fa-light fa-book mr-2\"></i>Open Source\n                    </a>\n                    <a\n                        href=\"https://github.com/sponsors/wavetermdev\"\n                        target=\"_blank\"\n                        rel=\"noopener\"\n                        className=\"inline-flex items-center justify-center px-4 py-2 rounded border border-border hover:bg-hoverbg transition-colors duration-200\"\n                    >\n                        <i className=\"fa-sharp fa-light fa-heart mr-2\"></i>Sponsor\n                    </a>\n                </div>\n                <div className=\"items-center gap-4 self-stretch w-full text-center\">\n                    &copy; {currentDate.getFullYear()} Command Line Inc.\n                </div>\n            </div>\n        </Modal>\n    );\n};\n\nAboutModalV.displayName = \"AboutModalV\";\n\nconst AboutModal = () => {\n    const [details] = useState(() => getApi().getAboutModalDetails());\n    const [updaterChannel] = useState(() => getApi().getUpdaterChannel());\n    const versionString = `${details.version} (${isDev() ? \"dev-\" : \"\"}${details.buildTime})`;\n\n    useEffect(() => {\n        fireAndForget(async () => {\n            RpcApi.RecordTEventCommand(\n                TabRpcClient,\n                { event: \"action:other\", props: { \"action:type\": \"about\" } },\n                { noresponse: true }\n            );\n        });\n    }, []);\n\n    return (\n        <AboutModalV\n            versionString={versionString}\n            updaterChannel={updaterChannel}\n            onClose={() => modalsModel.popModal()}\n        />\n    );\n};\n\nAboutModal.displayName = \"AboutModal\";\n\nexport { AboutModal, AboutModalV };\n"
  },
  {
    "path": "frontend/app/modals/conntypeahead.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { computeConnColorNum } from \"@/app/block/blockutil\";\nimport { TypeAheadModal } from \"@/app/modals/typeaheadmodal\";\nimport { ConnectionsModel } from \"@/app/store/connections-model\";\nimport {\n    atoms,\n    createBlock,\n    getConnStatusAtom,\n    getLocalHostDisplayNameAtom,\n    globalStore,\n    WOS,\n} from \"@/app/store/global\";\nimport { globalRefocusWithTimeout } from \"@/app/store/keymodel\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { NodeModel } from \"@/layout/index\";\nimport * as keyutil from \"@/util/keyutil\";\nimport * as util from \"@/util/util\";\nimport * as jotai from \"jotai\";\nimport * as React from \"react\";\n\n// newConnList -> connList => filteredList -> remoteItems -> sortedRemoteItems => remoteSuggestion\n// filteredList -> createNew\n\nfunction filterConnections(\n    connList: Array<string>,\n    connSelected: string,\n    fullConfig: FullConfigType,\n    filterOutNowsh: boolean\n): Array<string> {\n    const connectionsConfig = fullConfig.connections;\n    return connList.filter((conn) => {\n        const hidden = connectionsConfig?.[conn]?.[\"display:hidden\"] ?? false;\n        const wshEnabled = connectionsConfig?.[conn]?.[\"conn:wshenabled\"] ?? true;\n        return conn.includes(connSelected) && !hidden && (wshEnabled || !filterOutNowsh);\n    });\n}\n\nfunction sortConnSuggestionItems(\n    connSuggestions: Array<SuggestionConnectionItem>,\n    fullConfig: FullConfigType\n): Array<SuggestionConnectionItem> {\n    const connectionsConfig = fullConfig.connections;\n    return connSuggestions.sort((itemA: SuggestionConnectionItem, itemB: SuggestionConnectionItem) => {\n        const connNameA = itemA.value;\n        const connNameB = itemB.value;\n        const valueA = connectionsConfig?.[connNameA]?.[\"display:order\"] ?? 0;\n        const valueB = connectionsConfig?.[connNameB]?.[\"display:order\"] ?? 0;\n        return valueA - valueB;\n    });\n}\n\nfunction createRemoteSuggestionItems(\n    filteredList: Array<string>,\n    connection: string,\n    connStatusMap: Map<string, ConnStatus>\n): Array<SuggestionConnectionItem> {\n    return filteredList.map((connName) => {\n        const connStatus = connStatusMap.get(connName);\n        const connColorNum = computeConnColorNum(connStatus);\n        const item: SuggestionConnectionItem = {\n            status: \"connected\",\n            icon: \"arrow-right-arrow-left\",\n            iconColor:\n                connStatus?.status == \"connected\" ? `var(--conn-icon-color-${connColorNum})` : \"var(--grey-text-color)\",\n            value: connName,\n            label: connName,\n            current: connName == connection,\n        };\n        return item;\n    });\n}\n\nfunction createWslSuggestionItems(\n    filteredList: Array<string>,\n    connection: string,\n    connStatusMap: Map<string, ConnStatus>\n): Array<SuggestionConnectionItem> {\n    return filteredList.map((connName) => {\n        const connStatus = connStatusMap.get(`wsl://${connName}`);\n        const connColorNum = computeConnColorNum(connStatus);\n        const item: SuggestionConnectionItem = {\n            status: \"connected\",\n            icon: \"arrow-right-arrow-left\",\n            iconColor:\n                connStatus?.status == \"connected\" ? `var(--conn-icon-color-${connColorNum})` : \"var(--grey-text-color)\",\n            value: \"wsl://\" + connName,\n            label: \"wsl://\" + connName,\n            current: \"wsl://\" + connName == connection,\n        };\n        return item;\n    });\n}\n\nfunction createFilteredLocalSuggestionItem(\n    localName: string,\n    connection: string,\n    connSelected: string\n): Array<SuggestionConnectionItem> {\n    if (localName.includes(connSelected)) {\n        const localSuggestion: SuggestionConnectionItem = {\n            status: \"connected\",\n            icon: \"laptop\",\n            iconColor: \"var(--grey-text-color)\",\n            value: \"\",\n            label: localName,\n            current: util.isBlank(connection),\n        };\n        return [localSuggestion];\n    }\n    return [];\n}\n\nfunction getReconnectItem(\n    connStatus: ConnStatus,\n    connSelected: string,\n    blockId: string,\n    changeConnModalAtom: jotai.PrimitiveAtom<boolean>\n): SuggestionConnectionItem | null {\n    if (connSelected != \"\" || (connStatus.status != \"disconnected\" && connStatus.status != \"error\")) {\n        return null;\n    }\n    const reconnectSuggestionItem: SuggestionConnectionItem = {\n        status: \"connected\",\n        icon: \"arrow-right-arrow-left\",\n        iconColor: \"var(--grey-text-color)\",\n        label: `Reconnect to ${connStatus.connection}`,\n        value: \"\",\n        onSelect: async (_: string) => {\n            globalStore.set(changeConnModalAtom, false);\n            const prtn = RpcApi.ConnConnectCommand(\n                TabRpcClient,\n                { host: connStatus.connection, logblockid: blockId },\n                { timeout: 60000 }\n            );\n            prtn.catch((e) => console.log(\"error reconnecting\", connStatus.connection, e));\n        },\n    };\n    return reconnectSuggestionItem;\n}\n\nfunction getLocalSuggestions(\n    localName: string,\n    connList: Array<string>,\n    connection: string,\n    connSelected: string,\n    connStatusMap: Map<string, ConnStatus>,\n    fullConfig: FullConfigType,\n    filterOutNowsh: boolean,\n    hasGitBash: boolean\n): SuggestionConnectionScope | null {\n    const wslFiltered = filterConnections(connList, connSelected, fullConfig, filterOutNowsh);\n    const wslSuggestionItems = createWslSuggestionItems(wslFiltered, connection, connStatusMap);\n    const localSuggestionItem = createFilteredLocalSuggestionItem(localName, connection, connSelected);\n\n    const gitBashItems: Array<SuggestionConnectionItem> = [];\n    if (hasGitBash && \"Git Bash\".toLowerCase().includes(connSelected.toLowerCase())) {\n        gitBashItems.push({\n            status: \"connected\",\n            icon: \"laptop\",\n            iconColor: \"var(--grey-text-color)\",\n            value: \"local:gitbash\",\n            label: \"Git Bash\",\n            current: connection === \"local:gitbash\",\n        });\n    }\n\n    const combinedSuggestionItems = [...localSuggestionItem, ...gitBashItems, ...wslSuggestionItems];\n    const sortedSuggestionItems = sortConnSuggestionItems(combinedSuggestionItems, fullConfig);\n    if (sortedSuggestionItems.length == 0) {\n        return null;\n    }\n    const localSuggestions: SuggestionConnectionScope = {\n        headerText: \"Local\",\n        items: sortedSuggestionItems,\n    };\n    return localSuggestions;\n}\n\nfunction getRemoteSuggestions(\n    connList: Array<string>,\n    connection: string,\n    connSelected: string,\n    connStatusMap: Map<string, ConnStatus>,\n    fullConfig: FullConfigType,\n    filterOutNowsh: boolean\n): SuggestionConnectionScope | null {\n    const filtered = filterConnections(connList, connSelected, fullConfig, filterOutNowsh);\n    const suggestionItems = createRemoteSuggestionItems(filtered, connection, connStatusMap);\n    const sortedSuggestionItems = sortConnSuggestionItems(suggestionItems, fullConfig);\n    if (sortedSuggestionItems.length == 0) {\n        return null;\n    }\n    const remoteSuggestions: SuggestionConnectionScope = {\n        headerText: \"Remote\",\n        items: sortedSuggestionItems,\n    };\n    return remoteSuggestions;\n}\n\nfunction getDisconnectItem(\n    connection: string,\n    connStatusMap: Map<string, ConnStatus>,\n    changeConnModalAtom: jotai.PrimitiveAtom<boolean>\n): SuggestionConnectionItem | null {\n    if (util.isLocalConnName(connection)) {\n        return null;\n    }\n    const connStatus = connStatusMap.get(connection);\n    if (!connStatus || connStatus.status != \"connected\") {\n        return null;\n    }\n    const disconnectSuggestionItem: SuggestionConnectionItem = {\n        status: \"connected\",\n        icon: \"xmark\",\n        iconColor: \"var(--grey-text-color)\",\n        label: `Disconnect ${connStatus.connection}`,\n        value: \"\",\n        onSelect: async (_: string) => {\n            globalStore.set(changeConnModalAtom, false);\n            const prtn = RpcApi.ConnDisconnectCommand(TabRpcClient, connection, { timeout: 60000 });\n            prtn.catch((e) => console.log(\"error disconnecting\", connStatus.connection, e));\n        },\n    };\n    return disconnectSuggestionItem;\n}\n\nfunction getConnectionsEditItem(\n    changeConnModalAtom: jotai.PrimitiveAtom<boolean>,\n    connSelected: string\n): SuggestionConnectionItem | null {\n    if (connSelected != \"\") {\n        return null;\n    }\n    const connectionsEditItem: SuggestionConnectionItem = {\n        status: \"disconnected\",\n        icon: \"gear\",\n        iconColor: \"var(--grey-text-color)\",\n        value: \"Edit Connections\",\n        label: \"Edit Connections\",\n        onSelect: () => {\n            util.fireAndForget(async () => {\n                globalStore.set(changeConnModalAtom, false);\n                const blockDef: BlockDef = {\n                    meta: {\n                        view: \"waveconfig\",\n                        file: \"connections.json\",\n                    },\n                };\n                await createBlock(blockDef, false, true);\n            });\n        },\n    };\n    return connectionsEditItem;\n}\n\nfunction getNewConnectionSuggestionItem(\n    connSelected: string,\n    localName: string,\n    remoteConns: Array<string>,\n    wslConns: Array<string>,\n    changeConnection: (connName: string) => Promise<void>,\n    changeConnModalAtom: jotai.PrimitiveAtom<boolean>\n): SuggestionConnectionItem | null {\n    const allCons = [\"\", localName, ...remoteConns, ...wslConns];\n    if (allCons.includes(connSelected)) {\n        // do not offer to create a new connection if one\n        // with the exact name already exists\n        return null;\n    }\n    const newConnectionSuggestion: SuggestionConnectionItem = {\n        status: \"connected\",\n        icon: \"plus\",\n        iconColor: \"var(--grey-text-color)\",\n        label: `${connSelected} (New Connection)`,\n        value: \"\",\n        onSelect: (_: string) => {\n            changeConnection(connSelected);\n            globalStore.set(changeConnModalAtom, false);\n        },\n    };\n    return newConnectionSuggestion;\n}\n\nconst ChangeConnectionBlockModal = React.memo(\n    ({\n        blockId,\n        viewModel,\n        blockRef,\n        connBtnRef,\n        changeConnModalAtom,\n        nodeModel,\n    }: {\n        blockId: string;\n        viewModel: ViewModel;\n        blockRef: React.RefObject<HTMLDivElement>;\n        connBtnRef: React.RefObject<HTMLDivElement>;\n        changeConnModalAtom: jotai.PrimitiveAtom<boolean>;\n        nodeModel: NodeModel;\n    }) => {\n        const [connSelected, setConnSelected] = React.useState(\"\");\n        const changeConnModalOpen = jotai.useAtomValue(changeConnModalAtom);\n        const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef(\"block\", blockId));\n        const isNodeFocused = jotai.useAtomValue(nodeModel.isFocused);\n        const connection = blockData?.meta?.connection;\n        const connStatusAtom = getConnStatusAtom(connection);\n        const connStatus = jotai.useAtomValue(connStatusAtom);\n        const [connList, setConnList] = React.useState<Array<string>>([]);\n        const [wslList, setWslList] = React.useState<Array<string>>([]);\n        const allConnStatus = jotai.useAtomValue(atoms.allConnStatus);\n        const [rowIndex, setRowIndex] = React.useState(0);\n        const connStatusMap = new Map<string, ConnStatus>();\n        const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom);\n        let filterOutNowsh = util.useAtomValueSafe(viewModel.filterOutNowsh) ?? true;\n        const hasGitBash = jotai.useAtomValue(ConnectionsModel.getInstance().hasGitBashAtom);\n        const localName = jotai.useAtomValue(getLocalHostDisplayNameAtom());\n\n        let maxActiveConnNum = 1;\n        for (const conn of allConnStatus) {\n            if (conn.activeconnnum > maxActiveConnNum) {\n                maxActiveConnNum = conn.activeconnnum;\n            }\n            connStatusMap.set(conn.connection, conn);\n        }\n        React.useEffect(() => {\n            if (!changeConnModalOpen) {\n                setConnList([]);\n                return;\n            }\n            const prtn = RpcApi.ConnListCommand(TabRpcClient, { timeout: 2000 });\n            prtn.then((newConnList) => {\n                setConnList(newConnList ?? []);\n            }).catch((e) => console.log(\"unable to load conn list from backend. using blank list: \", e));\n            const p2rtn = RpcApi.WslListCommand(TabRpcClient, { timeout: 2000 });\n            p2rtn\n                .then((newWslList) => {\n                    console.log(newWslList);\n                    setWslList(newWslList ?? []);\n                })\n                .catch((e) => {\n                    // removing this log and failing silentyly since it will happen\n                    // if a system isn't using the wsl. and would happen every time the\n                    // typeahead was opened. good candidate for verbose log level.\n                    //console.log(\"unable to load wsl list from backend. using blank list: \", e)\n                });\n        }, [changeConnModalOpen]);\n\n        const changeConnection = React.useCallback(\n            async (connName: string) => {\n                if (connName == \"\") {\n                    connName = null;\n                }\n                if (connName == blockData?.meta?.connection) {\n                    return;\n                }\n                const oldFile = blockData?.meta?.file ?? \"\";\n                const newFile = oldFile == \"\" ? \"\" : \"~\";\n                await RpcApi.SetMetaCommand(TabRpcClient, {\n                    oref: WOS.makeORef(\"block\", blockId),\n                    meta: { connection: connName, file: newFile, \"cmd:cwd\": null },\n                });\n\n                try {\n                    await RpcApi.ConnEnsureCommand(\n                        TabRpcClient,\n                        { connname: connName, logblockid: blockId },\n                        { timeout: 60000 }\n                    );\n                } catch (e) {\n                    console.log(\"error connecting\", blockId, connName, e);\n                }\n            },\n            [blockId, blockData]\n        );\n\n        const reconnectSuggestionItem = getReconnectItem(connStatus, connSelected, blockId, changeConnModalAtom);\n        const localSuggestions = getLocalSuggestions(\n            localName,\n            wslList,\n            connection,\n            connSelected,\n            connStatusMap,\n            fullConfig,\n            filterOutNowsh,\n            hasGitBash\n        );\n        const remoteSuggestions = getRemoteSuggestions(\n            connList,\n            connection,\n            connSelected,\n            connStatusMap,\n            fullConfig,\n            filterOutNowsh\n        );\n        const connectionsEditItem = getConnectionsEditItem(changeConnModalAtom, connSelected);\n        const disconnectItem = getDisconnectItem(connection, connStatusMap, changeConnModalAtom);\n        const newConnectionSuggestionItem = getNewConnectionSuggestionItem(\n            connSelected,\n            localName,\n            connList,\n            wslList,\n            changeConnection,\n            changeConnModalAtom\n        );\n\n        const suggestions: Array<SuggestionsType> = [\n            ...(reconnectSuggestionItem ? [reconnectSuggestionItem] : []),\n            ...(localSuggestions ? [localSuggestions] : []),\n            ...(remoteSuggestions ? [remoteSuggestions] : []),\n            ...(disconnectItem ? [disconnectItem] : []),\n            ...(connectionsEditItem ? [connectionsEditItem] : []),\n            ...(newConnectionSuggestionItem ? [newConnectionSuggestionItem] : []),\n        ];\n\n        let selectionList: Array<SuggestionConnectionItem> = suggestions.flatMap((item) => {\n            if (\"items\" in item) {\n                return item.items;\n            }\n            return item;\n        });\n\n        // quick way to change icon color when highlighted\n        selectionList = selectionList.map((item, index) => {\n            if (index == rowIndex && item.iconColor == \"var(--grey-text-color)\") {\n                item.iconColor = \"var(--main-text-color)\";\n            }\n            return item;\n        });\n\n        const handleTypeAheadKeyDown = React.useCallback(\n            (waveEvent: WaveKeyboardEvent): boolean => {\n                if (keyutil.checkKeyPressed(waveEvent, \"Enter\")) {\n                    const rowItem = selectionList[rowIndex];\n                    if (\"onSelect\" in rowItem && rowItem.onSelect) {\n                        rowItem.onSelect(rowItem.value);\n                    } else {\n                        changeConnection(rowItem.value);\n                        globalStore.set(changeConnModalAtom, false);\n                        globalRefocusWithTimeout(10);\n                    }\n                    setRowIndex(0);\n                    return true;\n                }\n                if (keyutil.checkKeyPressed(waveEvent, \"Escape\")) {\n                    globalStore.set(changeConnModalAtom, false);\n                    setConnSelected(\"\");\n                    globalRefocusWithTimeout(10);\n                    return true;\n                }\n                if (keyutil.checkKeyPressed(waveEvent, \"ArrowUp\")) {\n                    setRowIndex((idx) => Math.max(idx - 1, 0));\n                    return true;\n                }\n                if (keyutil.checkKeyPressed(waveEvent, \"ArrowDown\")) {\n                    setRowIndex((idx) => Math.min(idx + 1, selectionList.length - 1));\n                    return true;\n                }\n                setRowIndex(0);\n                return false;\n            },\n            [changeConnModalAtom, viewModel, blockId, connSelected, selectionList]\n        );\n        React.useEffect(() => {\n            // this is specifically for the case when the list shrinks due\n            // to a search filter\n            setRowIndex((idx) => Math.min(idx, selectionList.flat().length - 1));\n        }, [selectionList, setRowIndex]);\n        // this check was also moved to BlockFrame to prevent all the above code from running unnecessarily\n        if (!changeConnModalOpen) {\n            return null;\n        }\n        return (\n            <TypeAheadModal\n                blockRef={blockRef}\n                anchorRef={connBtnRef}\n                suggestions={suggestions}\n                onSelect={(selected: string) => {\n                    changeConnection(selected);\n                    globalStore.set(changeConnModalAtom, false);\n                    globalRefocusWithTimeout(10);\n                }}\n                selectIndex={rowIndex}\n                autoFocus={isNodeFocused}\n                onKeyDown={(e) => keyutil.keydownWrapper(handleTypeAheadKeyDown)(e)}\n                onChange={(current: string) => setConnSelected(current)}\n                value={connSelected}\n                label=\"Connect to (username@host)...\"\n                onClickBackdrop={() => globalStore.set(changeConnModalAtom, false)}\n            />\n        );\n    }\n);\n\nexport { ChangeConnectionBlockModal };\n"
  },
  {
    "path": "frontend/app/modals/messagemodal.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.message-modal {\n    min-width: 400px;\n\n    footer {\n        padding: 10px;\n    }\n}\n"
  },
  {
    "path": "frontend/app/modals/messagemodal.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Modal } from \"@/app/modals/modal\";\nimport { modalsModel } from \"@/app/store/modalmodel\";\n\nimport { ReactNode } from \"react\";\nimport \"./messagemodal.scss\";\n\nconst MessageModal = ({ children }: { children: ReactNode }) => {\n    function closeModal() {\n        modalsModel.popModal();\n    }\n\n    return (\n        <Modal className=\"message-modal\" onOk={() => closeModal()} onClose={() => closeModal()}>\n            {children}\n        </Modal>\n    );\n};\n\nMessageModal.displayName = \"MessageModal\";\n\nexport { MessageModal };\n"
  },
  {
    "path": "frontend/app/modals/modal.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.modal-wrapper {\n    position: fixed;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    z-index: var(--zindex-modal-wrapper);\n\n    .modal-backdrop {\n        position: fixed;\n        top: 36px;\n        left: 0;\n        right: 0;\n        bottom: 0;\n        background-color: rgba(21, 23, 21, 0.7);\n        z-index: var(--zindex-modal-backdrop);\n    }\n}\n\n.modal {\n    position: relative;\n    z-index: var(--zindex-modal);\n    display: flex;\n    flex-direction: column;\n    align-items: flex-start;\n    border-radius: 8px;\n    border: 0.5px solid var(--modal-border-color);\n    background: var(--modal-bg-color);\n    box-shadow: 0px 8px 32px 0px rgba(0, 0, 0, 0.25);\n\n    .modal-close-btn {\n        position: absolute;\n        right: 8px;\n        top: 8px;\n        padding: 8px 12px;\n\n        i {\n            font-size: 18px;\n        }\n    }\n\n    .content-wrapper {\n        display: flex;\n        flex-direction: column;\n        gap: 8px;\n        width: 100%;\n\n        .modal-content {\n            width: 100%;\n            padding: 0px 20px;\n        }\n    }\n\n    .modal-footer {\n        display: flex;\n        justify-content: flex-end;\n        width: 100%;\n        padding-top: 16px;\n        border-top: 1px solid rgba(255, 255, 255, 0.1);\n\n        .wave-button:last-child {\n            margin-left: 8px;\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/modals/modal.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Button } from \"@/app/element/button\";\nimport { cn } from \"@/util/util\";\nimport clsx from \"clsx\";\nimport { forwardRef } from \"react\";\nimport ReactDOM from \"react-dom\";\n\nimport \"./modal.scss\";\n\ninterface ModalProps {\n    children?: React.ReactNode;\n    okLabel?: string;\n    cancelLabel?: string;\n    className?: string;\n    onClickBackdrop?: () => void;\n    onOk?: () => void;\n    onCancel?: () => void;\n    onClose?: () => void;\n    okDisabled?: boolean;\n    cancelDisabled?: boolean;\n}\n\nconst Modal = forwardRef<HTMLDivElement, ModalProps>(\n    (\n        {\n            children,\n            className,\n            cancelLabel,\n            okLabel,\n            onCancel,\n            onOk,\n            onClose,\n            onClickBackdrop,\n            okDisabled,\n            cancelDisabled,\n        }: ModalProps,\n        ref\n    ) => {\n        const renderBackdrop = (onClick) => <div className=\"modal-backdrop\" onClick={onClick}></div>;\n\n        const renderFooter = () => {\n            return onOk || onCancel;\n        };\n\n        const renderModal = () => (\n            <div className=\"modal-wrapper\">\n                {renderBackdrop(onClickBackdrop)}\n                <div ref={ref} className={clsx(`modal`, className)}>\n                    <Button className=\"grey ghost modal-close-btn\" onClick={onClose} title=\"Close (ESC)\">\n                        <i className=\"fa-sharp fa-solid fa-xmark\"></i>\n                    </Button>\n                    <div className=\"content-wrapper\">\n                        <ModalContent>{children}</ModalContent>\n                    </div>\n                    {renderFooter() && (\n                        <ModalFooter\n                            onCancel={onCancel}\n                            onOk={onOk}\n                            cancelLabel={cancelLabel}\n                            okLabel={okLabel}\n                            okDisabled={okDisabled}\n                            cancelDisabled={cancelDisabled}\n                        />\n                    )}\n                </div>\n            </div>\n        );\n\n        return ReactDOM.createPortal(renderModal(), document.getElementById(\"main\"));\n    }\n);\n\ninterface ModalContentProps {\n    children: React.ReactNode;\n}\n\nfunction ModalContent({ children }: ModalContentProps) {\n    return <div className=\"modal-content\">{children}</div>;\n}\n\ninterface ModalFooterProps {\n    okLabel?: string;\n    cancelLabel?: string;\n    onOk?: () => void;\n    onCancel?: () => void;\n    okDisabled?: boolean;\n    cancelDisabled?: boolean;\n}\n\nconst ModalFooter = ({\n    onCancel,\n    onOk,\n    cancelLabel = \"Cancel\",\n    okLabel = \"Ok\",\n    okDisabled,\n    cancelDisabled,\n}: ModalFooterProps) => {\n    return (\n        <footer className=\"modal-footer\">\n            {onCancel && (\n                <Button className=\"grey ghost\" onClick={onCancel} disabled={cancelDisabled}>\n                    {cancelLabel}\n                </Button>\n            )}\n            {onOk && (\n                <Button onClick={onOk} disabled={okDisabled}>\n                    {okLabel}\n                </Button>\n            )}\n        </footer>\n    );\n};\n\ninterface FlexiModalProps {\n    children?: React.ReactNode;\n    className?: string;\n    onClickBackdrop?: () => void;\n}\n\ninterface FlexiModalComponent extends React.ForwardRefExoticComponent<\n    FlexiModalProps & React.RefAttributes<HTMLDivElement>\n> {\n    Content: typeof ModalContent;\n    Footer: typeof ModalFooter;\n}\n\nconst FlexiModal = forwardRef<HTMLDivElement, FlexiModalProps>(\n    ({ children, className, onClickBackdrop }: FlexiModalProps, ref) => {\n        const renderBackdrop = (onClick: () => void) => <div className=\"modal-backdrop\" onClick={onClick}></div>;\n\n        const renderModal = () => (\n            <div className=\"modal-wrapper\">\n                {renderBackdrop(onClickBackdrop)}\n                <div className={cn(\"modal pt-6 px-4 pb-4\", className)} ref={ref}>\n                    {children}\n                </div>\n            </div>\n        );\n\n        return ReactDOM.createPortal(renderModal(), document.getElementById(\"main\")!);\n    }\n);\n\n(FlexiModal as FlexiModalComponent).Content = ModalContent;\n(FlexiModal as FlexiModalComponent).Footer = ModalFooter;\n\nexport { FlexiModal, Modal };\n"
  },
  {
    "path": "frontend/app/modals/modalregistry.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { MessageModal } from \"@/app/modals/messagemodal\";\nimport { NewInstallOnboardingModal } from \"@/app/onboarding/onboarding\";\nimport { UpgradeOnboardingModal } from \"@/app/onboarding/onboarding-upgrade\";\nimport { UpgradeOnboardingPatch } from \"@/app/onboarding/onboarding-upgrade-patch\";\nimport { DeleteFileModal, PublishAppModal, RenameFileModal } from \"@/builder/builder-apppanel\";\nimport { SetSecretDialog } from \"@/builder/tabs/builder-secrettab\";\nimport { AboutModal } from \"./about\";\nimport { UserInputModal } from \"./userinputmodal\";\n\nconst modalRegistry: { [key: string]: React.ComponentType<any> } = {\n    [NewInstallOnboardingModal.displayName || \"NewInstallOnboardingModal\"]: NewInstallOnboardingModal,\n    [UpgradeOnboardingModal.displayName || \"UpgradeOnboardingModal\"]: UpgradeOnboardingModal,\n    [UpgradeOnboardingPatch.displayName || \"UpgradeOnboardingPatch\"]: UpgradeOnboardingPatch,\n    [UserInputModal.displayName || \"UserInputModal\"]: UserInputModal,\n    [AboutModal.displayName || \"AboutModal\"]: AboutModal,\n    [MessageModal.displayName || \"MessageModal\"]: MessageModal,\n    [PublishAppModal.displayName || \"PublishAppModal\"]: PublishAppModal,\n    [RenameFileModal.displayName || \"RenameFileModal\"]: RenameFileModal,\n    [DeleteFileModal.displayName || \"DeleteFileModal\"]: DeleteFileModal,\n    [SetSecretDialog.displayName || \"SetSecretDialog\"]: SetSecretDialog,\n};\n\nexport const getModalComponent = (key: string): React.ComponentType<any> | undefined => {\n    return modalRegistry[key];\n};\n"
  },
  {
    "path": "frontend/app/modals/modalsrenderer.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { NewInstallOnboardingModal } from \"@/app/onboarding/onboarding\";\nimport { CurrentOnboardingVersion } from \"@/app/onboarding/onboarding-common\";\nimport { UpgradeOnboardingModal } from \"@/app/onboarding/onboarding-upgrade\";\nimport { ClientModel } from \"@/app/store/client-model\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { atoms, globalPrimaryTabStartup } from \"@/store/global\";\nimport { modalsModel } from \"@/store/modalmodel\";\nimport * as jotai from \"jotai\";\nimport { useEffect } from \"react\";\nimport * as semver from \"semver\";\nimport { getModalComponent } from \"./modalregistry\";\n\nconst ModalsRenderer = () => {\n    const clientData = jotai.useAtomValue(ClientModel.getInstance().clientAtom);\n    const [newInstallOnboardingOpen, setNewInstallOnboardingOpen] = jotai.useAtom(modalsModel.newInstallOnboardingOpen);\n    const [upgradeOnboardingOpen, setUpgradeOnboardingOpen] = jotai.useAtom(modalsModel.upgradeOnboardingOpen);\n    const [modals] = jotai.useAtom(modalsModel.modalsAtom);\n    const rtn: React.ReactElement[] = [];\n    for (const modal of modals) {\n        const ModalComponent = getModalComponent(modal.displayName);\n        if (ModalComponent) {\n            rtn.push(<ModalComponent key={modal.displayName} {...modal.props} />);\n        }\n    }\n    if (newInstallOnboardingOpen) {\n        rtn.push(<NewInstallOnboardingModal key={NewInstallOnboardingModal.displayName} />);\n    }\n    if (upgradeOnboardingOpen) {\n        rtn.push(<UpgradeOnboardingModal key={UpgradeOnboardingModal.displayName} />);\n    }\n    useEffect(() => {\n        if (!clientData.tosagreed) {\n            setNewInstallOnboardingOpen(true);\n        }\n    }, [clientData]);\n\n    useEffect(() => {\n        if (!globalPrimaryTabStartup) {\n            return;\n        }\n        if (!clientData.tosagreed) {\n            return;\n        }\n        const lastVersion = clientData.meta?.[\"onboarding:lastversion\"] ?? \"v0.0.0\";\n        if (semver.lt(lastVersion, CurrentOnboardingVersion)) {\n            setUpgradeOnboardingOpen(true);\n        }\n    }, []);\n    useEffect(() => {\n        globalStore.set(atoms.modalOpen, rtn.length > 0);\n    }, [rtn]);\n\n    return <>{rtn}</>;\n};\n\nexport { ModalsRenderer };\n"
  },
  {
    "path": "frontend/app/modals/typeaheadmodal.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.type-ahead-modal-backdrop {\n    position: absolute;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    background-color: transparent;\n    z-index: var(--zindex-typeahead-modal-backdrop);\n}\n\n.type-ahead-modal {\n    position: absolute;\n    z-index: var(--zindex-typeahead-modal);\n    display: flex;\n    flex-direction: column;\n    align-items: flex-start;\n    border-radius: 6px;\n    border: 1px solid var(--modal-border-color);\n    background: var(--modal-bg-color);\n    box-shadow: 0px 13px 16px 0px rgba(0, 0, 0, 0.4);\n    padding: 6px;\n    flex-direction: column;\n\n    .label {\n        opacity: 0.5;\n        font-size: 13px;\n        white-space: nowrap;\n    }\n\n    .input {\n        border: none;\n        border-bottom: none;\n        height: 24px;\n        border-radius: 0;\n\n        input {\n            width: 100%;\n            flex-shrink: 0;\n            padding: 4px 6px;\n            height: 24px;\n        }\n\n        .input-decoration.end-position {\n            margin: 6px;\n\n            i {\n                opacity: 0.3;\n            }\n        }\n    }\n\n    &.has-suggestions {\n        .input {\n            border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n        }\n    }\n\n    .suggestions-wrapper {\n        width: 100%;\n        overflow: hidden;\n        display: flex;\n        flex-direction: column;\n        gap: 10px;\n\n        .suggestion-header {\n            font-size: 11px;\n            font-style: normal;\n            font-weight: 500;\n            line-height: 12px;\n            opacity: 0.7;\n            letter-spacing: 0.11px;\n            padding: 4px 0px 0px 4px;\n        }\n\n        .suggestion-item {\n            width: 100%;\n            cursor: pointer;\n            display: flex;\n            padding: 6px 8px;\n            align-items: center;\n            gap: 8px;\n            align-self: stretch;\n            border-radius: 4px;\n\n            &.selected {\n                background-color: rgb(from var(--accent-color) r g b / 0.5);\n                color: var(--main-text-color);\n            }\n\n            &:hover:not(.selected) {\n                background-color: var(--highlight-bg-color);\n            }\n\n            .typeahead-item-name {\n                display: flex;\n                gap: 8px;\n                font-size: 11px;\n                font-weight: 400;\n                line-height: 14px;\n\n                i {\n                    display: inline-block;\n                    position: relative;\n                    top: 2px;\n                }\n            }\n\n            .typeahead-current-checkbox {\n                margin-left: auto;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/modals/typeaheadmodal.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Input, InputGroup, InputRightElement } from \"@/app/element/input\";\nimport { useDimensionsWithExistingRef } from \"@/app/hook/useDimensions\";\nimport { makeIconClass } from \"@/util/util\";\nimport clsx from \"clsx\";\nimport React, { forwardRef, useLayoutEffect, useRef } from \"react\";\nimport ReactDOM from \"react-dom\";\n\nimport \"./typeaheadmodal.scss\";\n\ninterface SuggestionsProps {\n    suggestions?: SuggestionsType[];\n    onSelect?: (_: string) => void;\n    selectIndex: number;\n}\n\nconst Suggestions = forwardRef<HTMLDivElement, SuggestionsProps>(\n    ({ suggestions, onSelect, selectIndex }: SuggestionsProps, ref) => {\n        const renderIcon = (icon: string | React.ReactNode, color: string) => {\n            if (typeof icon === \"string\") {\n                return <i className={makeIconClass(icon, false)} style={{ color: color }}></i>;\n            }\n            return icon;\n        };\n\n        const renderItem = (item: SuggestionBaseItem | SuggestionConnectionItem, index: number) => (\n            <div\n                key={index}\n                onClick={() => {\n                    if (\"onSelect\" in item && item.onSelect) {\n                        item.onSelect(item.value);\n                    } else {\n                        onSelect(item.value);\n                    }\n                }}\n                className={clsx(\"suggestion-item\", { selected: selectIndex === index })}\n            >\n                <div className=\"typeahead-item-name ellipsis\">\n                    {item.icon &&\n                        renderIcon(item.icon, \"iconColor\" in item && item.iconColor ? item.iconColor : \"inherit\")}\n                    {item.label}\n                </div>\n                {\"current\" in item && item.current && (\n                    <i className={clsx(makeIconClass(\"check\", false), \"typeahead-current-checkbox\")} />\n                )}\n            </div>\n        );\n\n        let fullIndex = -1;\n        return (\n            <div ref={ref} className=\"suggestions\">\n                {suggestions.map((item, index) => {\n                    if (\"headerText\" in item) {\n                        return (\n                            <div key={index}>\n                                {item.headerText && <div className=\"suggestion-header\">{item.headerText}</div>}\n                                {item.items.map((subItem, subIndex) => {\n                                    fullIndex += 1;\n                                    return renderItem(subItem, fullIndex);\n                                })}\n                            </div>\n                        );\n                    }\n                    fullIndex += 1;\n                    return renderItem(item as SuggestionBaseItem, fullIndex);\n                })}\n            </div>\n        );\n    }\n);\n\ninterface TypeAheadModalProps {\n    anchorRef: React.RefObject<HTMLElement>;\n    blockRef?: React.RefObject<HTMLDivElement>;\n    suggestions?: SuggestionsType[];\n    label?: string;\n    className?: string;\n    value?: string;\n    onChange?: (_: string) => void;\n    onSelect?: (_: string) => void;\n    onClickBackdrop?: () => void;\n    onKeyDown?: (_) => void;\n    giveFocusRef?: React.RefObject<() => boolean>;\n    autoFocus?: boolean;\n    selectIndex?: number;\n}\n\nconst TypeAheadModal = ({\n    className,\n    suggestions,\n    label,\n    anchorRef,\n    blockRef,\n    value,\n    onChange,\n    onSelect,\n    onKeyDown,\n    onClickBackdrop,\n    giveFocusRef,\n    autoFocus,\n    selectIndex,\n}: TypeAheadModalProps) => {\n    const domRect = useDimensionsWithExistingRef(blockRef, 30);\n    const width = domRect?.width ?? 0;\n    const height = domRect?.height ?? 0;\n    const modalRef = useRef<HTMLDivElement>(null);\n    const inputRef = useRef<HTMLInputElement>(null);\n    const inputGroupRef = useRef<HTMLDivElement>(null);\n    const suggestionsWrapperRef = useRef<HTMLDivElement>(null);\n    const suggestionsRef = useRef<HTMLDivElement>(null);\n\n    useLayoutEffect(() => {\n        if (!modalRef.current || !inputGroupRef.current || !suggestionsRef.current || !suggestionsWrapperRef.current) {\n            return;\n        }\n\n        const modalStyles = window.getComputedStyle(modalRef.current);\n        const paddingTop = parseFloat(modalStyles.paddingTop) || 0;\n        const paddingBottom = parseFloat(modalStyles.paddingBottom) || 0;\n        const borderTop = parseFloat(modalStyles.borderTopWidth) || 0;\n        const borderBottom = parseFloat(modalStyles.borderBottomWidth) || 0;\n        const modalPadding = paddingTop + paddingBottom;\n        const modalBorder = borderTop + borderBottom;\n\n        const suggestionsWrapperStyles = window.getComputedStyle(suggestionsWrapperRef.current);\n        const suggestionsWrapperMarginTop = parseFloat(suggestionsWrapperStyles.marginTop) || 0;\n\n        const inputHeight = inputGroupRef.current.getBoundingClientRect().height;\n        let suggestionsTotalHeight = 0;\n\n        const suggestionItems = suggestionsRef.current.children;\n        for (let i = 0; i < suggestionItems.length; i++) {\n            suggestionsTotalHeight += suggestionItems[i].getBoundingClientRect().height;\n        }\n\n        const totalHeight =\n            modalPadding + modalBorder + inputHeight + suggestionsTotalHeight + suggestionsWrapperMarginTop;\n        const maxHeight = height * 0.8;\n        const computedHeight = totalHeight > maxHeight ? maxHeight : totalHeight;\n\n        modalRef.current.style.height = `${computedHeight}px`;\n\n        suggestionsWrapperRef.current.style.height = `${computedHeight - inputHeight - modalPadding - modalBorder - suggestionsWrapperMarginTop}px`;\n    }, [height, suggestions]);\n\n    useLayoutEffect(() => {\n        if (!blockRef.current || !modalRef.current) return;\n\n        const blockRect = blockRef.current.getBoundingClientRect();\n        const anchorRect = anchorRef.current.getBoundingClientRect();\n\n        const minGap = 20;\n\n        const availableWidth = blockRect.width - minGap * 2;\n        let modalWidth = 300;\n\n        if (modalWidth > availableWidth) {\n            modalWidth = availableWidth;\n        }\n\n        let leftPosition = anchorRect.left - blockRect.left;\n\n        const modalRightEdge = leftPosition + modalWidth;\n        const blockRightEdge = blockRect.width - (minGap - 4);\n\n        if (modalRightEdge > blockRightEdge) {\n            leftPosition -= modalRightEdge - blockRightEdge;\n        }\n\n        if (leftPosition < minGap) {\n            leftPosition = minGap;\n        }\n\n        modalRef.current.style.width = `${modalWidth}px`;\n        modalRef.current.style.left = `${leftPosition}px`;\n    }, [width]);\n\n    useLayoutEffect(() => {\n        if (giveFocusRef) {\n            giveFocusRef.current = () => {\n                inputRef.current?.focus();\n                return true;\n            };\n        }\n        return () => {\n            if (giveFocusRef) {\n                giveFocusRef.current = null;\n            }\n        };\n    }, []);\n\n    useLayoutEffect(() => {\n        if (anchorRef.current && modalRef.current) {\n            const parentElement = anchorRef.current.closest(\".block-frame-default-header\");\n            modalRef.current.style.top = `${parentElement?.getBoundingClientRect().height}px`;\n        }\n    }, []);\n\n    const renderBackdrop = (onClick) => <div className=\"type-ahead-modal-backdrop\" onClick={onClick}></div>;\n\n    const handleKeyDown = (e) => {\n        onKeyDown?.(e);\n    };\n\n    const handleChange = (value) => {\n        onChange?.(value);\n    };\n\n    const handleSelect = (value) => {\n        onSelect?.(value);\n    };\n\n    const renderModal = () => (\n        <div className=\"type-ahead-modal-wrapper\" onKeyDown={handleKeyDown}>\n            {renderBackdrop(onClickBackdrop)}\n            <div\n                ref={modalRef}\n                className={clsx(\"type-ahead-modal\", className, { \"has-suggestions\": suggestions?.length > 0 })}\n            >\n                <InputGroup ref={inputGroupRef}>\n                    <Input\n                        ref={inputRef}\n                        onChange={handleChange}\n                        value={value}\n                        autoFocus={autoFocus}\n                        placeholder={label}\n                    />\n                    <InputRightElement>\n                        <i className=\"fa-regular fa-magnifying-glass\"></i>\n                    </InputRightElement>\n                </InputGroup>\n                <div\n                    ref={suggestionsWrapperRef}\n                    className=\"suggestions-wrapper\"\n                    style={{\n                        marginTop: suggestions?.length > 0 ? \"8px\" : \"0\",\n                        overflowY: \"auto\",\n                    }}\n                >\n                    {suggestions?.length > 0 && (\n                        <Suggestions\n                            ref={suggestionsRef}\n                            suggestions={suggestions}\n                            onSelect={handleSelect}\n                            selectIndex={selectIndex}\n                        />\n                    )}\n                </div>\n            </div>\n        </div>\n    );\n\n    if (blockRef && blockRef.current == null) {\n        return null;\n    }\n\n    return ReactDOM.createPortal(renderModal(), blockRef.current);\n};\n\nexport { TypeAheadModal };\n"
  },
  {
    "path": "frontend/app/modals/userinputmodal.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Modal } from \"@/app/modals/modal\";\nimport { Markdown } from \"@/element/markdown\";\nimport { modalsModel } from \"@/store/modalmodel\";\nimport * as keyutil from \"@/util/keyutil\";\nimport { fireAndForget } from \"@/util/util\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { UserInputService } from \"../store/services\";\n\nconst UserInputModal = (userInputRequest: UserInputRequest) => {\n    const [responseText, setResponseText] = useState(\"\");\n    const [countdown, setCountdown] = useState(Math.floor(userInputRequest.timeoutms / 1000));\n    const checkboxRef = useRef<HTMLInputElement>(null);\n\n    const handleSendErrResponse = useCallback(() => {\n        fireAndForget(() =>\n            UserInputService.SendUserInputResponse({\n                type: \"userinputresp\",\n                requestid: userInputRequest.requestid,\n                errormsg: \"Canceled by the user\",\n            })\n        );\n        modalsModel.popModal();\n    }, [responseText, userInputRequest]);\n\n    const handleSendText = useCallback(() => {\n        fireAndForget(() =>\n            UserInputService.SendUserInputResponse({\n                type: \"userinputresp\",\n                requestid: userInputRequest.requestid,\n                text: responseText,\n                checkboxstat: checkboxRef?.current?.checked ?? false,\n            })\n        );\n        modalsModel.popModal();\n    }, [responseText, userInputRequest]);\n\n    const handleSendConfirm = useCallback(\n        (response: boolean) => {\n            fireAndForget(() =>\n                UserInputService.SendUserInputResponse({\n                    type: \"userinputresp\",\n                    requestid: userInputRequest.requestid,\n                    confirm: response,\n                    checkboxstat: checkboxRef?.current?.checked ?? false,\n                })\n            );\n            modalsModel.popModal();\n        },\n        [userInputRequest]\n    );\n\n    const handleSubmit = useCallback(() => {\n        switch (userInputRequest.responsetype) {\n            case \"text\":\n                handleSendText();\n                break;\n            case \"confirm\":\n                handleSendConfirm(true);\n                break;\n        }\n    }, [handleSendConfirm, handleSendText, userInputRequest.responsetype]);\n\n    const handleKeyDown = useCallback(\n        (waveEvent: WaveKeyboardEvent): boolean => {\n            if (keyutil.checkKeyPressed(waveEvent, \"Escape\")) {\n                handleSendErrResponse();\n                return true;\n            }\n            if (keyutil.checkKeyPressed(waveEvent, \"Enter\")) {\n                handleSubmit();\n                return true;\n            }\n\t\t\treturn false;\n        },\n        [handleSendErrResponse, handleSubmit]\n    );\n\n    const queryText = useMemo(() => {\n        if (userInputRequest.markdown) {\n            return <Markdown text={userInputRequest.querytext} />;\n        }\n        return <span>{userInputRequest.querytext}</span>;\n    }, [userInputRequest.markdown, userInputRequest.querytext]);\n\n    const inputBox = useMemo(() => {\n        if (userInputRequest.responsetype === \"confirm\") {\n            return <></>;\n        }\n        return (\n            <input\n                type={userInputRequest.publictext ? \"text\" : \"password\"}\n                onChange={(e) => setResponseText(e.target.value)}\n                value={responseText}\n                maxLength={400}\n                className=\"resize-none bg-panel rounded-md border border-border py-1.5 pl-4 min-h-[30px] text-inherit cursor-text focus:ring-2 focus:ring-accent focus:outline-none\"\n                autoFocus={true}\n                onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)}\n            />\n        );\n    }, [userInputRequest.responsetype, userInputRequest.publictext, responseText, handleKeyDown, setResponseText]);\n\n    const optionalCheckbox = useMemo(() => {\n        if (userInputRequest.checkboxmsg == \"\") {\n            return <></>;\n        }\n        return (\n            <div className=\"flex flex-col gap-1.5\">\n                <div className=\"flex items-center gap-1.5\">\n                    <input\n                        type=\"checkbox\"\n                        id={`uicheckbox-${userInputRequest.requestid}`}\n                        className=\"accent-accent cursor-pointer\"\n                        ref={checkboxRef}\n                    />\n                    <label htmlFor={`uicheckbox-${userInputRequest.requestid}`} className=\"cursor-pointer\">{userInputRequest.checkboxmsg}</label>\n                </div>\n            </div>\n        );\n    }, []);\n\n    useEffect(() => {\n        let timeout: ReturnType<typeof setTimeout>;\n        if (countdown <= 0) {\n            timeout = setTimeout(() => {\n                handleSendErrResponse();\n            }, 300);\n        } else {\n            timeout = setTimeout(() => {\n                setCountdown(countdown - 1);\n            }, 1000);\n        }\n        return () => clearTimeout(timeout);\n    }, [countdown]);\n\n    const handleNegativeResponse = useCallback(() => {\n        switch (userInputRequest.responsetype) {\n            case \"text\":\n                handleSendErrResponse();\n                break;\n            case \"confirm\":\n                handleSendConfirm(false);\n                break;\n        }\n    }, [userInputRequest.responsetype, handleSendErrResponse, handleSendConfirm]);\n\n    return (\n        <Modal\n            className=\"pt-6 pb-4 px-5\"\n            onOk={() => handleSubmit()}\n            onCancel={() => handleNegativeResponse()}\n            onClose={() => handleSendErrResponse()}\n            okLabel={userInputRequest.oklabel}\n            cancelLabel={userInputRequest.cancellabel}\n        >\n            <div className=\"font-bold text-primary mx-4 pb-2.5\">{userInputRequest.title + ` (${countdown}s)`}</div>\n            <div className=\"flex flex-col justify-between gap-4 mx-4 mb-4 max-w-[500px] font-mono text-primary\">\n                {queryText}\n                {inputBox}\n                {optionalCheckbox}\n            </div>\n        </Modal>\n    );\n};\n\nUserInputModal.displayName = \"UserInputModal\";\n\nexport { UserInputModal };\n"
  },
  {
    "path": "frontend/app/monaco/monaco-env.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport * as monaco from \"monaco-editor\";\nimport \"monaco-editor/esm/vs/language/css/monaco.contribution\";\nimport \"monaco-editor/esm/vs/language/html/monaco.contribution\";\nimport \"monaco-editor/esm/vs/language/json/monaco.contribution\";\nimport \"monaco-editor/esm/vs/language/typescript/monaco.contribution\";\nimport { configureMonacoYaml } from \"monaco-yaml\";\n\nimport { MonacoSchemas } from \"@/app/monaco/schemaendpoints\";\nimport editorWorker from \"monaco-editor/esm/vs/editor/editor.worker?worker\";\nimport cssWorker from \"monaco-editor/esm/vs/language/css/css.worker?worker\";\nimport htmlWorker from \"monaco-editor/esm/vs/language/html/html.worker?worker\";\nimport jsonWorker from \"monaco-editor/esm/vs/language/json/json.worker?worker\";\nimport tsWorker from \"monaco-editor/esm/vs/language/typescript/ts.worker?worker\";\nimport ymlWorker from \"./yamlworker?worker\";\n\nlet monacoConfigured = false;\n\nwindow.MonacoEnvironment = {\n    getWorker(_, label) {\n        if (label === \"json\") {\n            return new jsonWorker();\n        }\n        if (label === \"css\" || label === \"scss\" || label === \"less\") {\n            return new cssWorker();\n        }\n        if (label === \"yaml\" || label === \"yml\") {\n            return new ymlWorker();\n        }\n        if (label === \"html\" || label === \"handlebars\" || label === \"razor\") {\n            return new htmlWorker();\n        }\n        if (label === \"typescript\" || label === \"javascript\") {\n            return new tsWorker();\n        }\n        return new editorWorker();\n    },\n};\n\nexport function loadMonaco() {\n    if (monacoConfigured) {\n        return;\n    }\n    monacoConfigured = true;\n    monaco.editor.defineTheme(\"wave-theme-dark\", {\n        base: \"vs-dark\",\n        inherit: true,\n        rules: [],\n        colors: {\n            \"editor.background\": \"#00000000\",\n            \"editorStickyScroll.background\": \"#00000055\",\n            \"minimap.background\": \"#00000077\",\n            focusBorder: \"#00000000\",\n        },\n    });\n    monaco.editor.defineTheme(\"wave-theme-light\", {\n        base: \"vs\",\n        inherit: true,\n        rules: [],\n        colors: {\n            \"editor.background\": \"#fefefe\",\n            focusBorder: \"#00000000\",\n        },\n    });\n    configureMonacoYaml(monaco, {\n        validate: true,\n        schemas: [],\n    });\n    monaco.editor.setTheme(\"wave-theme-dark\");\n    // Disable default validation errors for typescript and javascript\n    monaco.typescript.typescriptDefaults.setDiagnosticsOptions({\n        noSemanticValidation: true,\n    });\n    monaco.json.jsonDefaults.setDiagnosticsOptions({\n        validate: true,\n        allowComments: false,\n        enableSchemaRequest: true,\n        schemas: MonacoSchemas,\n    });\n}\n"
  },
  {
    "path": "frontend/app/monaco/monaco-react.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { loadMonaco } from \"@/app/monaco/monaco-env\";\nimport type * as MonacoTypes from \"monaco-editor\";\nimport * as monaco from \"monaco-editor\";\nimport { useEffect, useRef } from \"react\";\nimport { debounce } from \"throttle-debounce\";\n\nfunction createModel(value: string, path: string, language?: string) {\n    const uri = monaco.Uri.parse(`wave://editor/${encodeURIComponent(path)}`);\n    return monaco.editor.createModel(value, language, uri);\n}\n\ntype CodeEditorProps = {\n    text: string;\n    readonly: boolean;\n    language?: string;\n    onChange?: (text: string) => void;\n    onMount?: (editor: MonacoTypes.editor.IStandaloneCodeEditor, monacoApi: typeof monaco) => () => void;\n    path: string;\n    options: MonacoTypes.editor.IEditorOptions;\n};\n\nexport function MonacoCodeEditor({ text, readonly, language, onChange, onMount, path, options }: CodeEditorProps) {\n    const divRef = useRef<HTMLDivElement>(null);\n    const editorRef = useRef<MonacoTypes.editor.IStandaloneCodeEditor | null>(null);\n    const onUnmountRef = useRef<(() => void) | null>(null);\n    const applyingFromProps = useRef(false);\n\n    useEffect(() => {\n        loadMonaco();\n\n        const el = divRef.current;\n        if (!el) return;\n\n        const model = createModel(text, path, language);\n        console.log(\"[monaco] CREATE MODEL\", path, model);\n\n        const editor = monaco.editor.create(el, {\n            ...options,\n            readOnly: readonly,\n            model,\n        });\n        editorRef.current = editor;\n\n        const sub = model.onDidChangeContent(() => {\n            if (applyingFromProps.current) return;\n            onChange?.(model.getValue());\n        });\n\n        if (onMount) {\n            onUnmountRef.current = onMount(editor, monaco);\n        }\n\n        return () => {\n            sub.dispose();\n            if (onUnmountRef.current) onUnmountRef.current();\n            editor.setModel(null);\n            editor.dispose();\n            model.dispose();\n            console.log(\"[monaco] dispose model\");\n            editorRef.current = null;\n        };\n        // mount/unmount only\n    }, []);\n\n    useEffect(() => {\n        const editor = editorRef.current;\n        const el = divRef.current;\n        if (!editor || !el) return;\n\n        const debouncedLayout = debounce(100, () => {\n            editor.layout();\n        });\n        const resizeObserver = new ResizeObserver(debouncedLayout);\n        resizeObserver.observe(el);\n\n        return () => {\n            resizeObserver.disconnect();\n            debouncedLayout.cancel();\n        };\n    }, []);\n\n    // Keep model value in sync with props\n    useEffect(() => {\n        const editor = editorRef.current;\n        if (!editor) return;\n        const model = editor.getModel();\n        if (!model) return;\n\n        const current = model.getValue();\n        if (current === text) return;\n\n        applyingFromProps.current = true;\n        model.pushEditOperations([], [{ range: model.getFullModelRange(), text }], () => null);\n        applyingFromProps.current = false;\n    }, [text]);\n\n    // Keep options in sync\n    useEffect(() => {\n        const editor = editorRef.current;\n        if (!editor) return;\n        editor.updateOptions({ ...options, readOnly: readonly });\n    }, [options, readonly]);\n\n    // Keep language in sync\n    useEffect(() => {\n        const editor = editorRef.current;\n        if (!editor) return;\n        const model = editor.getModel();\n        if (!model || !language) return;\n        monaco.editor.setModelLanguage(model, language);\n    }, [language]);\n\n    return <div className=\"flex flex-col h-full w-full\" ref={divRef} />;\n}\n\ntype DiffViewerProps = {\n    original: string;\n    modified: string;\n    language?: string;\n    path: string;\n    options: MonacoTypes.editor.IDiffEditorOptions;\n};\n\nexport function MonacoDiffViewer({ original, modified, language, path, options }: DiffViewerProps) {\n    const divRef = useRef<HTMLDivElement>(null);\n    const diffRef = useRef<MonacoTypes.editor.IStandaloneDiffEditor | null>(null);\n\n    // Create once\n    useEffect(() => {\n        loadMonaco();\n\n        const el = divRef.current;\n        if (!el) return;\n\n        const origUri = monaco.Uri.parse(`wave://diff/${encodeURIComponent(path)}.orig`);\n        const modUri = monaco.Uri.parse(`wave://diff/${encodeURIComponent(path)}.mod`);\n\n        const originalModel = monaco.editor.createModel(original, language, origUri);\n        const modifiedModel = monaco.editor.createModel(modified, language, modUri);\n\n        const diff = monaco.editor.createDiffEditor(el, options);\n        diffRef.current = diff;\n\n        diff.setModel({ original: originalModel, modified: modifiedModel });\n\n        return () => {\n            diff.setModel(null);\n            diff.dispose();\n            originalModel.dispose();\n            modifiedModel.dispose();\n            diffRef.current = null;\n        };\n    }, []);\n\n    useEffect(() => {\n        const diff = diffRef.current;\n        const el = divRef.current;\n        if (!diff || !el) return;\n\n        const debouncedLayout = debounce(100, () => {\n            diff.layout();\n        });\n        const resizeObserver = new ResizeObserver(debouncedLayout);\n        resizeObserver.observe(el);\n\n        return () => {\n            resizeObserver.disconnect();\n            debouncedLayout.cancel();\n        };\n    }, []);\n\n    // Update models on prop change\n    useEffect(() => {\n        const diff = diffRef.current;\n        if (!diff) return;\n        const model = diff.getModel();\n        if (!model) return;\n\n        if (model.original.getValue() !== original) model.original.setValue(original);\n        if (model.modified.getValue() !== modified) model.modified.setValue(modified);\n\n        if (language) {\n            monaco.editor.setModelLanguage(model.original, language);\n            monaco.editor.setModelLanguage(model.modified, language);\n        }\n    }, [original, modified, language]);\n\n    useEffect(() => {\n        const diff = diffRef.current;\n        if (!diff) return;\n        diff.updateOptions(options);\n    }, [options]);\n\n    return <div className=\"flex flex-col h-full w-full\" ref={divRef} />;\n}\n"
  },
  {
    "path": "frontend/app/monaco/schemaendpoints.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport settingsSchema from \"../../../schema/settings.json\";\nimport connectionsSchema from \"../../../schema/connections.json\";\nimport aipresetsSchema from \"../../../schema/aipresets.json\";\nimport bgpresetsSchema from \"../../../schema/bgpresets.json\";\nimport waveaiSchema from \"../../../schema/waveai.json\";\nimport widgetsSchema from \"../../../schema/widgets.json\";\n\ntype SchemaInfo = {\n    uri: string;\n    fileMatch: Array<string>;\n    schema: object;\n};\n\nconst MonacoSchemas: SchemaInfo[] = [\n    {\n        uri: \"wave://schema/settings.json\",\n        fileMatch: [\"*/WAVECONFIGPATH/settings.json\"],\n        schema: settingsSchema,\n    },\n    {\n        uri: \"wave://schema/connections.json\",\n        fileMatch: [\"*/WAVECONFIGPATH/connections.json\"],\n        schema: connectionsSchema,\n    },\n    {\n        uri: \"wave://schema/aipresets.json\",\n        fileMatch: [\"*/WAVECONFIGPATH/presets/ai.json\"],\n        schema: aipresetsSchema,\n    },\n    {\n        uri: \"wave://schema/bgpresets.json\",\n        fileMatch: [\"*/WAVECONFIGPATH/presets/bg.json\"],\n        schema: bgpresetsSchema,\n    },\n    {\n        uri: \"wave://schema/waveai.json\",\n        fileMatch: [\"*/WAVECONFIGPATH/waveai.json\"],\n        schema: waveaiSchema,\n    },\n    {\n        uri: \"wave://schema/widgets.json\",\n        fileMatch: [\"*/WAVECONFIGPATH/widgets.json\"],\n        schema: widgetsSchema,\n    },\n];\n\nexport { MonacoSchemas };\n"
  },
  {
    "path": "frontend/app/monaco/yamlworker.js",
    "content": "import \"monaco-yaml/yaml.worker.js\";\n"
  },
  {
    "path": "frontend/app/onboarding/fakechat.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { WaveStreamdown } from \"@/app/element/streamdown\";\nimport { memo, useEffect, useRef, useState } from \"react\";\n\ninterface ChatConfig {\n    userPrompt: string;\n    toolName: string;\n    toolDescription: string;\n    markdownResponse: string;\n}\n\nconst chatConfigs: ChatConfig[] = [\n    {\n        userPrompt: \"Check out ~/waveterm and summarize the project — what it does and how it's organized.\",\n        toolName: \"read_dir\",\n        toolDescription: 'reading directory \"~/waveterm\"',\n        markdownResponse: `Here's a quick, file-structure–driven overview of this repo (Wave Terminal):\n\n## What it is\n- Electron + React front end with a Go backend (\"wavesrv\"). Provides a terminal with GUI widgets, previews, web, and AI. (README.md)\n- Licensed Apache-2.0. (LICENSE)\n\n## Architecture at a glance\n- **Electron main process:** \\`emain/*.ts\\` configures windows, menus, preload scripts, updater, and ties into the Go backend via local RPC. (\\`emain/\\`)\n- **Renderer UI:** React/TS built with Vite, Tailwind. (\\`frontend/\\`, \\`index.html\\`, \\`electron.vite.config.ts\\`)\n- **Go backend (\"wavesrv\"):** starts services, web and websocket listeners, telemetry loops, config watcher, local RPC, filestore and SQLite-backed object store. (\\`cmd/server/main-server.go\\`, \\`pkg/*\\`)\n- **CLI/helper (\"wsh\"):** built for multiple OS/arch; used for shell integration and remote operations. (\\`cmd/wsh/\\`, \\`Taskfile.yml build:wsh\\`)\n\n## Key directories\n- **cmd/:** entrypoints and generators\n  - \\`server/\\`: wavesrv main\n  - \\`generategs/\\`, \\`generatego/\\`: TS/Go bindings generation\n  - \\`wsh/\\`: shell helper\n\n- **pkg/:** backend packages\n  - \\`wcore/\\`: startup coordination, initial data, window/workspace creation\n  - \\`web/\\`: HTTP+WS servers, Unix listener\n  - \\`waveai/\\`: AI backends (OpenAI, Anthropic, Google, Perplexity)\n  - \\`wshrpc\\`, \\`wshutil\\`, \\`wslconn\\`, \\`remote/*\\`: local/remote RPC, WSL, SSH, fileshare\n  - \\`wstore/\\`: persistent store (SQLite via sqlx); \\`waveobj/\\`: object model\n  - \\`filestore/\\`: local file cache/storage\n  - \\`telemetry/\\`: metrics/events, periodic reporting`,\n    },\n    {\n        userPrompt: \"can you analyze the log output in my terminal?\",\n        toolName: \"term_get_scrollback\",\n        toolDescription: \"reading terminal output from widget\",\n        markdownResponse: `Here's what your log shows:\n\n- Normal startup/init at 18:06:51 (router/register, DBGetAllObjsByType, Stat/Read).\n- Many FocusWindow events — normal UI activity.\n- 18:08:28.558 WebSocket ReadPump error: close 1001 (going away) — usually a tab/app reload, sleep, or brief network hiccup.\n- 18:08:29 re-init + new WebSocket connection — successful auto-reconnect.\n- SetMetaCommand and periodic filestore flushes with err: — normal; no failures.\n- No panics/fatal errors; just the transient disconnect.\n\nIf that disconnect was unexpected, check for a tab reload/sleep at 18:08:28, and watch for repeats.\n\nWant deeper triage? Run these and paste results:\n\n\\`\\`\\`bash\n# 1) Scan recent logs for problems\ngrep -Ei 'error|panic|fatal|websocket|1006|1011' \\\\\n  \"$HOME/Library/Application Support/waveterm-dev/waveapp.log\" | tail -n 200\n\n# 2) Inspect around the disconnect window\nawk '($0 ~ /2025-10-10 18:08:2[0-9]/){print}' \\\\\n  \"$HOME/Library/Application Support/waveterm-dev/waveapp.log\"\n\n# 3) Live follow for recurring drops\ntail -f \"$HOME/Library/Application Support/waveterm-dev/waveapp.log\" \\\\\n  | grep -Ei 'error|panic|fatal|websocket|close'\n\\`\\`\\`\n\nNeed me to look at a longer slice (e.g., last 1000 lines) or a different time range?`,\n    },\n];\n\nconst AIThinking = memo(() => (\n    <div className=\"flex items-center gap-2\">\n        <div className=\"animate-pulse flex items-center\">\n            <i className=\"fa fa-circle text-[10px]\"></i>\n            <i className=\"fa fa-circle text-[10px] mx-1\"></i>\n            <i className=\"fa fa-circle text-[10px]\"></i>\n        </div>\n        <span className=\"text-sm text-gray-400\">AI is thinking...</span>\n    </div>\n));\n\nAIThinking.displayName = \"AIThinking\";\n\nconst FakeToolCall = memo(({ toolName, toolDescription }: { toolName: string; toolDescription: string }) => {\n    return (\n        <div className=\"flex items-start gap-1 p-2 rounded bg-zinc-800 border border-gray-700 text-success\">\n            <span className=\"font-bold\">✓</span>\n            <div className=\"flex-1\">\n                <div className=\"font-semibold\">{toolName}</div>\n                <div className=\"text-sm text-gray-400\">{toolDescription}</div>\n            </div>\n        </div>\n    );\n});\n\nFakeToolCall.displayName = \"FakeToolCall\";\n\nconst FakeUserMessage = memo(({ userPrompt }: { userPrompt: string }) => {\n    return (\n        <div className=\"flex justify-end\">\n            <div className=\"px-2 py-2 rounded-lg bg-zinc-700 text-white max-w-[calc(100%-20px)]\">\n                <div className=\"whitespace-pre-wrap break-words\">{userPrompt}</div>\n            </div>\n        </div>\n    );\n});\n\nFakeUserMessage.displayName = \"FakeUserMessage\";\n\nconst FakeAssistantMessage = memo(({ config, onComplete }: { config: ChatConfig; onComplete?: () => void }) => {\n    const [phase, setPhase] = useState<\"thinking\" | \"tool\" | \"streaming\">(\"thinking\");\n    const [streamedText, setStreamedText] = useState(\"\");\n\n    useEffect(() => {\n        const timeouts: NodeJS.Timeout[] = [];\n        let streamInterval: NodeJS.Timeout | null = null;\n\n        const runAnimation = () => {\n            setPhase(\"thinking\");\n            setStreamedText(\"\");\n\n            timeouts.push(\n                setTimeout(() => {\n                    setPhase(\"tool\");\n                }, 2000)\n            );\n\n            timeouts.push(\n                setTimeout(() => {\n                    setPhase(\"streaming\");\n                }, 4000)\n            );\n\n            timeouts.push(\n                setTimeout(() => {\n                    let currentIndex = 0;\n                    streamInterval = setInterval(() => {\n                        if (currentIndex >= config.markdownResponse.length) {\n                            if (streamInterval) {\n                                clearInterval(streamInterval);\n                                streamInterval = null;\n                            }\n                            if (onComplete) {\n                                onComplete();\n                            }\n                            return;\n                        }\n                        currentIndex += 10;\n                        setStreamedText(config.markdownResponse.slice(0, currentIndex));\n                    }, 100);\n                }, 4000)\n            );\n        };\n\n        runAnimation();\n\n        return () => {\n            timeouts.forEach(clearTimeout);\n            if (streamInterval) {\n                clearInterval(streamInterval);\n            }\n        };\n    }, [config.markdownResponse, onComplete]);\n\n    return (\n        <div className=\"flex justify-start\">\n            <div className=\"px-2 py-2 rounded-lg\">\n                {phase === \"thinking\" && <AIThinking />}\n                {phase === \"tool\" && (\n                    <>\n                        <div className=\"mb-2\">\n                            <FakeToolCall toolName={config.toolName} toolDescription={config.toolDescription} />\n                        </div>\n                        <AIThinking />\n                    </>\n                )}\n                {phase === \"streaming\" && (\n                    <>\n                        <div className=\"mb-2\">\n                            <FakeToolCall toolName={config.toolName} toolDescription={config.toolDescription} />\n                        </div>\n                        <WaveStreamdown text={streamedText} parseIncompleteMarkdown={true} className=\"text-gray-100\" />\n                    </>\n                )}\n            </div>\n        </div>\n    );\n});\n\nFakeAssistantMessage.displayName = \"FakeAssistantMessage\";\n\nconst FakeAIPanelHeader = memo(() => {\n    return (\n        <div className=\"py-2 pl-3 pr-1 border-b border-gray-600 flex items-center justify-between min-w-0 bg-zinc-900\">\n            <h2 className=\"text-white text-sm font-semibold flex items-center gap-2 flex-shrink-0 whitespace-nowrap\">\n                <i className=\"fa fa-sparkles text-accent\"></i>\n                Wave AI\n            </h2>\n\n            <div className=\"flex items-center flex-shrink-0 whitespace-nowrap\">\n                <div className=\"flex items-center text-sm whitespace-nowrap\">\n                    <span className=\"text-gray-300 mr-1 text-[12px]\">Context</span>\n                    <button\n                        className=\"relative inline-flex h-6 w-14 items-center rounded-full transition-colors bg-accent-600\"\n                        title=\"Widget Access ON\"\n                    >\n                        <span className=\"absolute inline-block h-4 w-4 transform rounded-full bg-white transition-transform translate-x-8\" />\n                        <span className=\"relative z-10 text-xs text-white transition-all ml-2.5 mr-6 text-left font-bold\">\n                            ON\n                        </span>\n                    </button>\n                </div>\n\n                <button\n                    className=\"text-gray-400 transition-colors p-1 rounded flex-shrink-0 ml-2 focus:outline-none\"\n                    title=\"More options\"\n                >\n                    <i className=\"fa fa-ellipsis-vertical\"></i>\n                </button>\n            </div>\n        </div>\n    );\n});\n\nFakeAIPanelHeader.displayName = \"FakeAIPanelHeader\";\n\nexport const FakeChat = memo(() => {\n    const scrollRef = useRef<HTMLDivElement>(null);\n    const [chatIndex, setChatIndex] = useState(1);\n    const config = chatConfigs[chatIndex] || chatConfigs[0];\n\n    useEffect(() => {\n        const interval = setInterval(() => {\n            if (scrollRef.current) {\n                scrollRef.current.scrollTop = scrollRef.current.scrollHeight;\n            }\n        }, 1000);\n\n        return () => clearInterval(interval);\n    }, []);\n\n    const handleComplete = () => {\n        setTimeout(() => {\n            setChatIndex((prev) => (prev + 1) % chatConfigs.length);\n        }, 2000);\n    };\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <FakeAIPanelHeader />\n            <div className=\"flex-1 overflow-hidden\">\n                <div ref={scrollRef} className=\"flex flex-col gap-1 p-2 h-full overflow-y-auto bg-zinc-900\">\n                    <FakeUserMessage userPrompt={config.userPrompt} />\n                    <FakeAssistantMessage config={config} onComplete={handleComplete} />\n                </div>\n            </div>\n        </div>\n    );\n});\n\nFakeChat.displayName = \"FakeChat\";\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-command.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { useCallback, useLayoutEffect, useRef, useState } from \"react\";\nimport { FakeBlock } from \"./onboarding-layout\";\nimport { FakeTermBlock } from \"./onboarding-layout-term\";\nimport waveLogo from \"/logos/wave-logo.png\";\n\nexport type CommandRevealProps = {\n    command: string;\n    typeIntervalMs?: number;\n    onComplete?: () => void;\n    showCursor?: boolean;\n};\n\nexport const CommandReveal = ({\n    command,\n    typeIntervalMs = 100,\n    onComplete,\n    showCursor: showCursorProp = true,\n}: CommandRevealProps) => {\n    const [displayedText, setDisplayedText] = useState(\"\");\n    const [showCursor, setShowCursor] = useState(true);\n    const [isComplete, setIsComplete] = useState(false);\n\n    useLayoutEffect(() => {\n        let charIndex = 0;\n        const typeInterval = setInterval(() => {\n            if (charIndex < command.length) {\n                setDisplayedText(command.slice(0, charIndex + 1));\n                charIndex++;\n            } else {\n                clearInterval(typeInterval);\n                setIsComplete(true);\n                setShowCursor(false);\n                if (onComplete) {\n                    onComplete();\n                }\n            }\n        }, typeIntervalMs);\n\n        const cursorInterval = setInterval(() => {\n            setShowCursor((prev) => !prev);\n        }, 500);\n\n        return () => {\n            clearInterval(typeInterval);\n            clearInterval(cursorInterval);\n        };\n    }, [command, typeIntervalMs, onComplete]);\n\n    return (\n        <div className=\"flex items-center gap-2 font-mono text-sm\">\n            <span className=\"text-accent\">&gt;</span>\n            <span className=\"text-foreground/80\">\n                {displayedText}\n                {showCursorProp && !isComplete && showCursor && (\n                    <span className=\"inline-block w-2 h-4 bg-foreground/80 ml-0.5 align-middle\"></span>\n                )}\n            </span>\n        </div>\n    );\n};\n\nexport type FakeCommandProps = {\n    command: string;\n    typeIntervalMs?: number;\n    onComplete?: () => void;\n    children: React.ReactNode;\n};\n\nexport const FakeCommand = ({ command, typeIntervalMs = 100, onComplete, children }: FakeCommandProps) => {\n    const [commandComplete, setCommandComplete] = useState(false);\n\n    const handleCommandComplete = useCallback(() => {\n        setCommandComplete(true);\n        if (onComplete) {\n            onComplete();\n        }\n    }, [onComplete]);\n\n    return (\n        <div className=\"w-full h-[400px] bg-background rounded border border-border/50 p-4 flex flex-col gap-4\">\n            <CommandReveal command={command} onComplete={handleCommandComplete} typeIntervalMs={typeIntervalMs} />\n            {commandComplete && <div className=\"flex-1 min-h-0\">{children}</div>}\n        </div>\n    );\n};\n\nexport const ViewShortcutsCommand = ({ isMac, onComplete }: { isMac: boolean; onComplete?: () => void }) => {\n    const modKey = isMac ? \"⌘ Cmd\" : \"Alt\";\n    const markdown = `### Keyboard Shortcuts\n\n**Switch Tabs**\nPress ${modKey} + Number (1-9) to quickly switch between tabs.\n\n**Navigate Blocks**\nUse Ctrl-Shift + Arrow Keys (←→↑↓) to move between blocks in the current tab.\n\nUse Ctrl-Shift + Number (1-9) to focus a specific block by its position.`;\n\n    return (\n        <FakeCommand command=\"wsh view keyboard-shortcuts.md\" onComplete={onComplete}>\n            <FakeBlock icon=\"file-lines\" name=\"keyboard-shortcuts.md\" markdown={markdown} />\n        </FakeCommand>\n    );\n};\n\nexport const ViewLogoCommand = ({ onComplete }: { onComplete?: () => void }) => {\n    return (\n        <FakeCommand command=\"wsh view public/wave-logo.png\" onComplete={onComplete}>\n            <FakeBlock icon=\"image\" name=\"wave-logo.png\" imgsrc={waveLogo} />\n        </FakeCommand>\n    );\n};\n\nexport const EditBashrcCommand = ({ onComplete }: { onComplete?: () => void }) => {\n    const fileNameRef = useRef(`${crypto.randomUUID()}/.bashrc`);\n    const bashrcContent = `# Aliases\nalias ll=\"ls -lah\"\nalias gst=\"git status\"\nalias wave=\"wsh\"\n\n# Custom prompt\nPS1=\"\\\\[\\\\e[32m\\\\]\\\\u@\\\\h\\\\[\\\\e[0m\\\\]:\\\\[\\\\e[34m\\\\]\\\\w\\\\[\\\\e[0m\\\\]\\\\$ \"\n\n# PATH\nexport PATH=\"$HOME/.local/bin:$PATH\"`;\n\n    return (\n        <FakeCommand command=\"wsh edit ~/.bashrc\" onComplete={onComplete}>\n            <FakeBlock\n                icon=\"file-lines\"\n                name=\".bashrc\"\n                editorText={bashrcContent}\n                editorFileName={fileNameRef.current}\n                editorLanguage=\"shell\"\n            />\n        </FakeCommand>\n    );\n};\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-common.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nexport const CurrentOnboardingVersion = \"v0.14.3\";\n\nexport function OnboardingGradientBg() {\n    return (\n        <div className=\"absolute inset-0 bg-gradient-to-br from-accent/[0.25] via-transparent to-accent/[0.05] pointer-events-none rounded-[10px]\" />\n    );\n}\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-durable.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport Logo from \"@/app/asset/logo.svg\";\nimport { EmojiButton } from \"@/app/element/emojibutton\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { useState } from \"react\";\nimport { CurrentOnboardingVersion } from \"./onboarding-common\";\nimport { OnboardingFooter } from \"./onboarding-features-footer\";\nimport { TailDeployLogCommand } from \"./onboarding-layout-term\";\n\nexport const DurableSessionPage = ({\n    onNext,\n    onSkip,\n    onPrev,\n}: {\n    onNext: () => void;\n    onSkip: () => void;\n    onPrev?: () => void;\n}) => {\n    const [fireClicked, setFireClicked] = useState(false);\n\n    const handleFireClick = () => {\n        setFireClicked(!fireClicked);\n        if (!fireClicked) {\n            RpcApi.RecordTEventCommand(TabRpcClient, {\n                event: \"onboarding:fire\",\n                props: {\n                    \"onboarding:feature\": \"durable\",\n                    \"onboarding:version\": CurrentOnboardingVersion,\n                },\n            });\n        }\n    };\n\n    return (\n        <div className=\"flex flex-col h-full\">\n            <header className=\"flex items-center gap-4 mb-6 w-full unselectable flex-shrink-0\">\n                <div>\n                    <Logo />\n                </div>\n                <div className=\"text-[25px] font-normal text-foreground\">Durable SSH Sessions</div>\n            </header>\n            <div className=\"flex-1 flex flex-row gap-0 min-h-0\">\n                <div className=\"flex-1 flex flex-col items-center justify-center gap-8 pr-3 unselectable\">\n                    <div className=\"flex flex-col items-start gap-3 max-w-md\">\n                        <div className=\"flex h-[52px] ml-[-4px] pl-3 pr-3 items-center rounded-lg bg-hover text-[15px]\">\n                            <i className=\"fa-sharp fa-solid fa-shield text-sky-500\" />\n                            <span className=\"font-bold ml-2 text-primary\">SSH Sessions, Protected</span>\n                        </div>\n\n                        <div className=\"flex flex-col items-start gap-4 text-secondary\">\n                            <p>Close your laptop, switch networks, restart Wave — your remote sessions keep running.</p>\n\n                            <div className=\"flex items-start gap-3 w-full\">\n                                <i className=\"fa-sharp fa-solid fa-link text-accent text-lg mt-1 flex-shrink-0\" />\n                                <p>Shell state, running programs, and terminal history are all preserved</p>\n                            </div>\n\n                            <div className=\"flex items-start gap-3 w-full\">\n                                <i className=\"fa-sharp fa-solid fa-rotate text-accent text-lg mt-1 flex-shrink-0\" />\n                                <p>Sessions automatically reconnect when your connection is restored</p>\n                            </div>\n\n                            <div className=\"flex items-start gap-3 w-full\">\n                                <i className=\"fa-sharp fa-solid fa-box text-accent text-lg mt-1 flex-shrink-0\" />\n                                <p>Buffered output streams back in, never miss a line</p>\n                            </div>\n\n                            <p className=\"italic\">\n                                All the persistence of tmux, built into your terminal. Look for the shield icon to\n                                enable durability on any SSH session.\n                            </p>\n\n                            <EmojiButton emoji=\"🔥\" isClicked={fireClicked} onClick={handleFireClick} />\n                        </div>\n                    </div>\n                </div>\n                <div className=\"w-[2px] bg-border flex-shrink-0\"></div>\n                <div className=\"flex items-center justify-center pl-6 flex-shrink-0 w-[500px]\">\n                    <TailDeployLogCommand />\n                </div>\n            </div>\n            <OnboardingFooter currentStep={2} totalSteps={4} onNext={onNext} onPrev={onPrev} onSkip={onSkip} />\n        </div>\n    );\n};\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-features-footer.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Button } from \"@/app/element/button\";\n\nexport const OnboardingFooter = ({\n    currentStep,\n    totalSteps,\n    onNext,\n    onPrev,\n    onSkip,\n}: {\n    currentStep: number;\n    totalSteps: number;\n    onNext: () => void;\n    onPrev?: () => void;\n    onSkip?: () => void;\n}) => {\n    const isLastStep = currentStep === totalSteps;\n    const buttonText = isLastStep ? \"Get Started\" : \"Next\";\n\n    return (\n        <footer className=\"unselectable flex-shrink-0 mt-5 relative\">\n            <div className=\"absolute left-0 top-1/2 -translate-y-1/2 flex items-center gap-2\">\n                {currentStep > 1 && onPrev && (\n                    <button className=\"text-muted cursor-pointer hover:text-foreground text-[13px]\" onClick={onPrev}>\n                        &lt; Prev\n                    </button>\n                )}\n                <span className=\"text-muted text-[13px]\">\n                    {currentStep} of {totalSteps}\n                </span>\n            </div>\n            <div className=\"flex flex-row items-center justify-center [&>button]:!px-5 [&>button]:!py-2 [&>button]:text-sm\">\n                <Button className=\"font-[600]\" onClick={onNext}>\n                    {buttonText}\n                </Button>\n            </div>\n            {!isLastStep && onSkip && (\n                <button\n                    className=\"absolute right-0 top-1/2 -translate-y-1/2 text-muted cursor-pointer hover:text-muted-hover text-[13px]\"\n                    onClick={onSkip}\n                >\n                    Skip Feature Tour &gt;\n                </button>\n            )}\n        </footer>\n    );\n};\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-features.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport Logo from \"@/app/asset/logo.svg\";\nimport { EmojiButton } from \"@/app/element/emojibutton\";\nimport { MagnifyIcon } from \"@/app/element/magnify\";\nimport { ClientModel } from \"@/app/store/client-model\";\nimport * as WOS from \"@/app/store/wos\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { isMacOS } from \"@/util/platformutil\";\nimport { useEffect, useState } from \"react\";\nimport { FakeChat } from \"./fakechat\";\nimport { EditBashrcCommand, ViewLogoCommand, ViewShortcutsCommand } from \"./onboarding-command\";\nimport { CurrentOnboardingVersion } from \"./onboarding-common\";\nimport { DurableSessionPage } from \"./onboarding-durable\";\nimport { OnboardingFooter } from \"./onboarding-features-footer\";\nimport { FakeLayout } from \"./onboarding-layout\";\n\ntype FeaturePageName = \"waveai\" | \"durable\" | \"magnify\" | \"files\";\n\nexport const WaveAIPage = ({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) => {\n    const isMac = isMacOS();\n    const shortcutKey = isMac ? \"⌘-Shift-A\" : \"Alt-Shift-A\";\n    const [fireClicked, setFireClicked] = useState(false);\n\n    const handleFireClick = () => {\n        setFireClicked(!fireClicked);\n        if (!fireClicked) {\n            RpcApi.RecordTEventCommand(TabRpcClient, {\n                event: \"onboarding:fire\",\n                props: {\n                    \"onboarding:feature\": \"waveai\",\n                    \"onboarding:version\": CurrentOnboardingVersion,\n                },\n            });\n        }\n    };\n\n    return (\n        <div className=\"flex flex-col h-full\">\n            <header className=\"flex items-center gap-4 mb-6 w-full unselectable flex-shrink-0\">\n                <div>\n                    <Logo />\n                </div>\n                <div className=\"text-[25px] font-normal text-foreground\">Wave AI</div>\n            </header>\n            <div className=\"flex-1 flex flex-row gap-0 min-h-0\">\n                <div className=\"flex-1 flex flex-col items-center justify-center gap-8 pr-6 unselectable\">\n                    <div className=\"flex flex-col items-start gap-6 max-w-md\">\n                        <div className=\"flex h-[52px] px-3 items-center rounded-lg bg-hover text-accent text-[24px]\">\n                            <i className=\"fa fa-sparkles\" />\n                            <span className=\"font-bold ml-2 font-mono\">AI</span>\n                        </div>\n\n                        <div className=\"flex flex-col items-start gap-4 text-secondary\">\n                            <p>\n                                Wave AI is your terminal assistant with context. I can read your terminal output,\n                                analyze widgets, read/write files, and help you solve problems faster.\n                            </p>\n\n                            <div className=\"flex items-start gap-3 w-full\">\n                                <i className=\"fa fa-sparkles text-accent text-lg mt-1 flex-shrink-0\" />\n                                <p>\n                                    Toggle the Wave AI panel with the{\" \"}\n                                    <span className=\"inline-flex h-[26px] px-1.5 items-center rounded-md box-border bg-hover text-accent text-[12px] align-middle\">\n                                        <i className=\"fa fa-sparkles\" />\n                                        <span className=\"font-bold ml-1 font-mono\">AI</span>\n                                    </span>{\" \"}\n                                    button in the header (top left)\n                                </p>\n                            </div>\n\n                            <div className=\"flex items-start gap-3 w-full\">\n                                <i className=\"fa fa-keyboard text-accent text-lg mt-1 flex-shrink-0\" />\n                                <p>\n                                    Or use the keyboard shortcut{\" \"}\n                                    <span className=\"font-mono font-semibold text-foreground whitespace-nowrap\">\n                                        {shortcutKey}\n                                    </span>{\" \"}\n                                    to quickly toggle\n                                </p>\n                            </div>\n\n                            <div className=\"flex items-start gap-3 w-full\">\n                                <i className=\"fa fa-key text-accent text-lg mt-1 flex-shrink-0\" />\n                                <p>\n                                    Bring your own API keys or run local models with Ollama, LM Studio, and other\n                                    OpenAI-compatible providers\n                                </p>\n                            </div>\n\n                            <EmojiButton emoji=\"🔥\" isClicked={fireClicked} onClick={handleFireClick} />\n                        </div>\n                    </div>\n                </div>\n                <div className=\"w-[2px] bg-border flex-shrink-0\"></div>\n                <div className=\"flex items-center justify-center pl-6 flex-shrink-0 w-[400px]\">\n                    <div className=\"w-full h-[400px] bg-background rounded border border-border/50 overflow-hidden\">\n                        <FakeChat />\n                    </div>\n                </div>\n            </div>\n            <OnboardingFooter currentStep={1} totalSteps={4} onNext={onNext} onSkip={onSkip} />\n        </div>\n    );\n};\n\nexport const MagnifyBlocksPage = ({\n    onNext,\n    onSkip,\n    onPrev,\n}: {\n    onNext: () => void;\n    onSkip: () => void;\n    onPrev?: () => void;\n}) => {\n    const isMac = isMacOS();\n    const shortcutKey = isMac ? \"⌘\" : \"Alt\";\n    const [fireClicked, setFireClicked] = useState(false);\n\n    const handleFireClick = () => {\n        setFireClicked(!fireClicked);\n        if (!fireClicked) {\n            RpcApi.RecordTEventCommand(TabRpcClient, {\n                event: \"onboarding:fire\",\n                props: {\n                    \"onboarding:feature\": \"magnify\",\n                    \"onboarding:version\": CurrentOnboardingVersion,\n                },\n            });\n        }\n    };\n\n    return (\n        <div className=\"flex flex-col h-full\">\n            <header className=\"flex items-center gap-4 mb-6 w-full unselectable flex-shrink-0\">\n                <div>\n                    <Logo />\n                </div>\n                <div className=\"text-[25px] font-normal text-foreground\">Magnify Blocks</div>\n            </header>\n            <div className=\"flex-1 flex flex-row gap-0 min-h-0\">\n                <div className=\"flex-1 flex flex-col items-center justify-center gap-8 pr-6 unselectable\">\n                    <div className=\"text-6xl font-semibold text-foreground\">{shortcutKey}-M</div>\n                    <div className=\"flex flex-col items-start gap-4 text-secondary max-w-md\">\n                        <p>\n                            Magnify any block to focus on what matters. Expand terminals, editors, and previews for a\n                            better view.\n                        </p>\n                        <p>Use the magnify feature to work with complex outputs and large files more efficiently.</p>\n                        <div>\n                            You can also magnify a block by clicking on the{\" \"}\n                            <span className=\"inline-block align-middle [&_svg_path]:!fill-foreground\">\n                                <MagnifyIcon enabled={false} />\n                            </span>{\" \"}\n                            icon in the block header.\n                        </div>\n                        <p>\n                            A quick {shortcutKey}-M to magnify and another {shortcutKey}-M to unmagnify\n                        </p>\n                        <EmojiButton emoji=\"🔥\" isClicked={fireClicked} onClick={handleFireClick} />\n                    </div>\n                </div>\n                <div className=\"w-[2px] bg-border flex-shrink-0\"></div>\n                <div className=\"flex items-center justify-center pl-6 flex-shrink-0 w-[400px]\">\n                    <FakeLayout />\n                </div>\n            </div>\n            <OnboardingFooter currentStep={3} totalSteps={4} onNext={onNext} onPrev={onPrev} onSkip={onSkip} />\n        </div>\n    );\n};\n\nexport const FilesPage = ({ onFinish, onPrev }: { onFinish: () => void; onPrev?: () => void }) => {\n    const [fireClicked, setFireClicked] = useState(false);\n    const isMac = isMacOS();\n    const [commandIndex, setCommandIndex] = useState(0);\n\n    const handleFireClick = () => {\n        setFireClicked(!fireClicked);\n        if (!fireClicked) {\n            RpcApi.RecordTEventCommand(TabRpcClient, {\n                event: \"onboarding:fire\",\n                props: {\n                    \"onboarding:feature\": \"wsh\",\n                    \"onboarding:version\": CurrentOnboardingVersion,\n                },\n            });\n        }\n    };\n\n    const commands = [\n        (onComplete: () => void) => <EditBashrcCommand onComplete={onComplete} />,\n        (onComplete: () => void) => <ViewShortcutsCommand isMac={isMac} onComplete={onComplete} />,\n        (onComplete: () => void) => <ViewLogoCommand onComplete={onComplete} />,\n    ];\n\n    const handleCommandComplete = () => {\n        setTimeout(() => {\n            setCommandIndex((prev) => (prev + 1) % commands.length);\n        }, 2500);\n    };\n\n    return (\n        <div className=\"flex flex-col h-full\">\n            <header className=\"flex items-center gap-4 mb-6 w-full unselectable flex-shrink-0\">\n                <div>\n                    <Logo />\n                </div>\n                <div className=\"text-[25px] font-normal text-foreground\">Viewing/Editing Files</div>\n            </header>\n            <div className=\"flex-1 flex flex-row gap-0 min-h-0\">\n                <div className=\"flex-1 flex flex-col items-center justify-center gap-8 pr-6 unselectable\">\n                    <div className=\"flex flex-col items-start gap-6 max-w-md\">\n                        <div className=\"flex flex-col items-start gap-4 text-secondary\">\n                            <p>\n                                Wave can preview markdown, images, and video files on both local <i>and remote</i>{\" \"}\n                                machines.\n                            </p>\n\n                            <div className=\"flex items-start gap-3 w-full\">\n                                <i className=\"fa fa-eye text-accent text-lg mt-1 flex-shrink-0\" />\n                                <div>\n                                    <p className=\"mb-2\">\n                                        Use{\" \"}\n                                        <span className=\"font-mono font-semibold text-foreground\">\n                                            wsh view [filename]\n                                        </span>{\" \"}\n                                        to preview files in Wave's graphical viewer\n                                    </p>\n                                </div>\n                            </div>\n\n                            <div className=\"flex items-start gap-3 w-full\">\n                                <i className=\"fa fa-pen-to-square text-accent text-lg mt-1 flex-shrink-0\" />\n                                <div>\n                                    <p className=\"mb-2\">\n                                        Use{\" \"}\n                                        <span className=\"font-mono font-semibold text-foreground\">\n                                            wsh edit [filename]\n                                        </span>{\" \"}\n                                        to open config files or code files in Wave's graphical editor\n                                    </p>\n                                </div>\n                            </div>\n\n                            <p>\n                                These commands work seamlessly on both local and remote machines, making it easy to view\n                                and edit files wherever they are.\n                            </p>\n\n                            <EmojiButton emoji=\"🔥\" isClicked={fireClicked} onClick={handleFireClick} />\n                        </div>\n                    </div>\n                </div>\n                <div className=\"w-[2px] bg-border flex-shrink-0\"></div>\n                <div className=\"flex items-center justify-center pl-6 flex-shrink-0 w-[400px]\">\n                    {commands[commandIndex](handleCommandComplete)}\n                </div>\n            </div>\n            <OnboardingFooter currentStep={4} totalSteps={4} onNext={onFinish} onPrev={onPrev} />\n        </div>\n    );\n};\n\nexport const OnboardingFeatures = ({ onComplete }: { onComplete: () => void }) => {\n    const [currentPage, setCurrentPage] = useState<FeaturePageName>(\"waveai\");\n\n    useEffect(() => {\n        const clientId = ClientModel.getInstance().clientId;\n        RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"client\", clientId),\n            meta: { \"onboarding:lastversion\": CurrentOnboardingVersion },\n        });\n        RpcApi.RecordTEventCommand(TabRpcClient, {\n            event: \"onboarding:start\",\n            props: {\n                \"onboarding:version\": CurrentOnboardingVersion,\n            },\n        });\n    }, []);\n\n    const handleNext = () => {\n        if (currentPage === \"waveai\") {\n            setCurrentPage(\"durable\");\n        } else if (currentPage === \"durable\") {\n            setCurrentPage(\"magnify\");\n        } else if (currentPage === \"magnify\") {\n            setCurrentPage(\"files\");\n        }\n    };\n\n    const handlePrev = () => {\n        if (currentPage === \"durable\") {\n            setCurrentPage(\"waveai\");\n        } else if (currentPage === \"magnify\") {\n            setCurrentPage(\"durable\");\n        } else if (currentPage === \"files\") {\n            setCurrentPage(\"magnify\");\n        }\n    };\n\n    const handleSkip = () => {\n        RpcApi.RecordTEventCommand(TabRpcClient, {\n            event: \"onboarding:skip\",\n            props: {},\n        });\n        onComplete();\n    };\n\n    const handleFinish = () => {\n        onComplete();\n    };\n\n    let pageComp: React.JSX.Element = null;\n    switch (currentPage) {\n        case \"waveai\":\n            pageComp = <WaveAIPage onNext={handleNext} onSkip={handleSkip} />;\n            break;\n        case \"durable\":\n            pageComp = <DurableSessionPage onNext={handleNext} onSkip={handleSkip} onPrev={handlePrev} />;\n            break;\n        case \"magnify\":\n            pageComp = <MagnifyBlocksPage onNext={handleNext} onSkip={handleSkip} onPrev={handlePrev} />;\n            break;\n        case \"files\":\n            pageComp = <FilesPage onFinish={handleFinish} onPrev={handlePrev} />;\n            break;\n    }\n\n    return <div className=\"flex flex-col w-full h-full\">{pageComp}</div>;\n};\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-layout-term.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { MagnifyIcon } from \"@/app/element/magnify\";\nimport { cn, makeIconClass } from \"@/util/util\";\nimport { useCallback, useLayoutEffect, useState } from \"react\";\nimport { CommandReveal } from \"./onboarding-command\";\n\nexport type FakeTermBlockProps = {\n    connectionName?: string;\n    durableStatus?: \"connected\" | \"detached\" | null;\n    className?: string;\n    command?: string;\n    typeIntervalMs?: number;\n    onComplete?: () => void;\n    children?: React.ReactNode;\n};\n\nexport const FakeTermBlock = ({\n    connectionName = \"ubuntu@remoteserver\",\n    durableStatus = null,\n    className,\n    command,\n    typeIntervalMs = 80,\n    onComplete,\n    children,\n}: FakeTermBlockProps) => {\n    const color = \"var(--conn-icon-color-1)\";\n\n    const durableIconColor = durableStatus === \"connected\" ? \"text-sky-500\" : \"text-sky-300\";\n\n    return (\n        <div\n            className={cn(\n                \"w-full h-full bg-background rounded flex flex-col overflow-hidden border-2 border-accent\",\n                className\n            )}\n        >\n            <div className=\"flex items-center gap-2 px-2 py-1.5 bg-border/20 border-b border-border/50 pl-[2px]\">\n                <div className=\"group flex items-center flex-nowrap overflow-hidden text-ellipsis min-w-0 font-normal text-primary rounded-sm\">\n                    <span className=\"fa-stack flex-[1_1_auto] overflow-hidden\">\n                        <i\n                            className={cn(makeIconClass(\"arrow-right-arrow-left\", false), \"fa-stack-1x mr-[2px]\")}\n                            style={{ color: color }}\n                        />\n                    </span>\n                    <div className=\"flex-[1_2_auto] overflow-hidden pr-1 ellipsis\">{connectionName}</div>\n                </div>\n                {durableStatus && (\n                    <div className=\"iconbutton disabled text-[13px] ml-[-4px]\">\n                        <i className={`fa-sharp fa-solid fa-shield ${durableIconColor}`} />\n                    </div>\n                )}\n                <div className=\"flex-1\" />\n                <span className=\"inline-block [&_svg]:fill-foreground/50 [&_svg_path]:!fill-foreground/50\">\n                    <MagnifyIcon enabled={false} />\n                </span>\n                <i className={makeIconClass(\"xmark-large\", false) + \" text-xs text-foreground/50\"} />\n            </div>\n            <div className=\"flex-1 overflow-auto p-4\">\n                {children ? (\n                    children\n                ) : command ? (\n                    <div className=\"font-mono text-sm\">\n                        <CommandReveal command={command} typeIntervalMs={typeIntervalMs} onComplete={onComplete} />\n                    </div>\n                ) : (\n                    <div className=\"flex items-center justify-center h-full\">\n                        <i className={makeIconClass(\"terminal\", false) + \" text-4xl text-foreground/50\"} />\n                    </div>\n                )}\n            </div>\n        </div>\n    );\n};\n\nconst deployMessages = [\n    \"[1/8] Installing dependencies...\",\n    \"[2/8] Generating TypeScript types from Go...\",\n    \"[3/8] Building Go backend (wavesrv)...\",\n    \"[4/8] Compiling TypeScript frontend...\",\n    \"[5/8] Bundling Electron renderer...\",\n    \"[6/8] Packaging application artifacts...\",\n    \"[7/8] Code signing binaries...\",\n    \"[8/8] Deploy complete ✓\",\n];\n\ntype OverlayState = null | \"disconnected\" | \"connected\";\n\nconst ConnectionOverlay = ({ state }: { state: OverlayState }) => {\n    if (!state) return null;\n\n    const isConnected = state === \"connected\";\n\n    return (\n        <div className=\"absolute inset-0 flex items-center justify-center z-10\">\n            <div className=\"bg-white/20 backdrop-blur-[2px] rounded-lg flex flex-col items-center justify-center gap-4 px-12 py-8 w-[50%]\">\n                <i\n                    className={cn(\n                        \"fa-sharp fa-solid\",\n                        isConnected ? \"fa-wifi text-green-400\" : \"fa-wifi-slash text-red-400\",\n                        \"text-6xl\"\n                    )}\n                />\n                <div className=\"text-2xl font-semibold text-foreground\">\n                    {isConnected ? \"Connected\" : \"Disconnected\"}\n                </div>\n            </div>\n        </div>\n    );\n};\n\nconst DeployLogOutput = ({\n    onComplete,\n    onOverlayStateChange,\n}: {\n    onComplete?: () => void;\n    onOverlayStateChange?: (state: OverlayState) => void;\n}) => {\n    const [key, setKey] = useState(0);\n    const [commandComplete, setCommandComplete] = useState(false);\n    const [visibleLines, setVisibleLines] = useState(0);\n    const [showPrompt, setShowPrompt] = useState(false);\n    const [showCursor, setShowCursor] = useState(false);\n    const [overlayState, setOverlayState] = useState<OverlayState>(null);\n\n    useLayoutEffect(() => {\n        if (onOverlayStateChange) {\n            onOverlayStateChange(overlayState);\n        }\n    }, [overlayState, onOverlayStateChange]);\n\n    const handleCommandComplete = useCallback(() => {\n        setCommandComplete(true);\n    }, []);\n\n    const resetAnimation = useCallback(() => {\n        setCommandComplete(false);\n        setVisibleLines(0);\n        setShowPrompt(false);\n        setShowCursor(false);\n        setOverlayState(null);\n        setKey((prev) => prev + 1);\n    }, []);\n\n    useLayoutEffect(() => {\n        if (!commandComplete) return;\n\n        let timeoutId: NodeJS.Timeout;\n\n        const runSequence = async () => {\n            // Show message 1\n            setVisibleLines(1);\n            await new Promise((resolve) => {\n                timeoutId = setTimeout(resolve, 1000);\n            });\n\n            // Show message 2\n            setVisibleLines(2);\n            await new Promise((resolve) => {\n                timeoutId = setTimeout(resolve, 1000);\n            });\n\n            // Show disconnected overlay\n            setOverlayState(\"disconnected\");\n            await new Promise((resolve) => {\n                timeoutId = setTimeout(resolve, 2500);\n            });\n\n            // Change to connected\n            setOverlayState(\"connected\");\n            await new Promise((resolve) => {\n                timeoutId = setTimeout(resolve, 1000);\n            });\n\n            // Remove overlay and show messages 3-7 instantly\n            setOverlayState(null);\n            setVisibleLines(7);\n\n            // Show message 8\n            setVisibleLines(8);\n            await new Promise((resolve) => {\n                timeoutId = setTimeout(resolve, 1000);\n            });\n\n            // Show prompt\n            setShowPrompt(true);\n            setShowCursor(true);\n            if (onComplete) {\n                onComplete();\n            }\n\n            // Wait 6 seconds then restart\n            await new Promise((resolve) => {\n                timeoutId = setTimeout(resolve, 6000);\n            });\n\n            resetAnimation();\n        };\n\n        runSequence();\n\n        return () => {\n            if (timeoutId) {\n                clearTimeout(timeoutId);\n            }\n        };\n    }, [commandComplete, onComplete, resetAnimation]);\n\n    useLayoutEffect(() => {\n        if (!showPrompt) return;\n\n        const cursorInterval = setInterval(() => {\n            setShowCursor((prev) => !prev);\n        }, 500);\n\n        return () => clearInterval(cursorInterval);\n    }, [showPrompt]);\n\n    return (\n        <>\n            <div className=\"font-mono text-sm flex flex-col gap-1\">\n                <CommandReveal\n                    key={key}\n                    command=\"tail -f deploy.log\"\n                    typeIntervalMs={80}\n                    onComplete={handleCommandComplete}\n                />\n                {commandComplete && (\n                    <>\n                        {deployMessages.slice(0, visibleLines).map((msg, idx) => (\n                            <div key={idx} className=\"text-foreground/70\">\n                                {msg}\n                            </div>\n                        ))}\n                        {showPrompt && (\n                            <div className=\"flex items-center gap-2\">\n                                <span className=\"text-accent\">&gt;</span>\n                                {showCursor && (\n                                    <span className=\"inline-block w-2 h-4 bg-foreground/80 align-middle\"></span>\n                                )}\n                            </div>\n                        )}\n                    </>\n                )}\n            </div>\n            {overlayState && <ConnectionOverlay state={overlayState} />}\n        </>\n    );\n};\n\nexport const TailDeployLogCommand = ({ onComplete }: { onComplete?: () => void }) => {\n    const [overlayState, setOverlayState] = useState<OverlayState>(null);\n\n    const durableStatus = overlayState === \"disconnected\" ? \"detached\" : \"connected\";\n\n    return (\n        <FakeTermBlock connectionName=\"ubuntu@remoteserver\" durableStatus={durableStatus} className=\"relative\">\n            <DeployLogOutput onComplete={onComplete} onOverlayStateChange={setOverlayState} />\n        </FakeTermBlock>\n    );\n};\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-layout.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { MagnifyIcon } from \"@/app/element/magnify\";\nimport { WaveStreamdown } from \"@/app/element/streamdown\";\nimport { CodeEditor } from \"@/app/view/codeeditor/codeeditor\";\nimport { cn, makeIconClass } from \"@/util/util\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\n\nexport type FakeBlockProps = {\n    icon: string;\n    name: string;\n    highlighted?: boolean;\n    className?: string;\n    markdown?: string;\n    imgsrc?: string;\n    editorText?: string;\n    editorFileName?: string;\n    editorLanguage?: string;\n};\n\nexport const FakeBlock = ({\n    icon,\n    name,\n    highlighted,\n    className,\n    markdown,\n    imgsrc,\n    editorText,\n    editorFileName,\n    editorLanguage,\n}: FakeBlockProps) => {\n    return (\n        <div\n            className={cn(\n                \"w-full h-full bg-background rounded flex flex-col overflow-hidden border-2\",\n                highlighted ? \"border-accent\" : \"border-border/50\",\n                className\n            )}\n        >\n            <div className=\"flex items-center gap-2 px-2 py-1.5 bg-border/20 border-b border-border/50\">\n                <i className={makeIconClass(icon, false) + \" text-xs text-foreground/70\"} />\n                <span className=\"text-xs text-foreground/70 flex-1\">{name}</span>\n                <span className=\"inline-block [&_svg]:fill-foreground/50 [&_svg_path]:!fill-foreground/50\">\n                    <MagnifyIcon enabled={false} />\n                </span>\n                <i className={makeIconClass(\"xmark-large\", false) + \" text-xs text-foreground/50\"} />\n            </div>\n            <div className=\"flex-1 flex items-center justify-center overflow-auto p-4\">\n                {editorText ? (\n                    <div className=\"w-full h-full\">\n                        <CodeEditor\n                            blockId=\"fake-block\"\n                            text={editorText}\n                            readonly={true}\n                            fileName={editorFileName}\n                            language={editorLanguage ?? \"shell\"}\n                        />\n                    </div>\n                ) : imgsrc ? (\n                    <img src={imgsrc} alt={name} className=\"max-w-full max-h-full object-contain\" />\n                ) : markdown ? (\n                    <div className=\"w-full\">\n                        <WaveStreamdown text={markdown} />\n                    </div>\n                ) : (\n                    <i className={makeIconClass(icon, false) + \" text-4xl text-foreground/50\"} />\n                )}\n            </div>\n        </div>\n    );\n};\n\nexport const FakeLayout = () => {\n    const layoutRef = useRef<HTMLDivElement>(null);\n    const highlightedContainerRef = useRef<HTMLDivElement>(null);\n    const [blockRect, setBlockRect] = useState<{ left: number; top: number; width: number; height: number } | null>(\n        null\n    );\n    const [isExpanded, setIsExpanded] = useState(false);\n\n    useLayoutEffect(() => {\n        if (highlightedContainerRef.current) {\n            const elem = highlightedContainerRef.current;\n            setBlockRect({\n                left: elem.offsetLeft,\n                top: elem.offsetTop,\n                width: elem.offsetWidth,\n                height: elem.offsetHeight,\n            });\n        }\n    }, []);\n\n    useLayoutEffect(() => {\n        if (!blockRect) return;\n\n        const timeouts: NodeJS.Timeout[] = [];\n\n        const addTimeout = (callback: () => void, delay: number) => {\n            const id = setTimeout(callback, delay);\n            timeouts.push(id);\n        };\n\n        const runAnimationCycle = (isFirstRun: boolean) => {\n            const initialDelay = isFirstRun ? 1500 : 3000;\n\n            addTimeout(() => {\n                setIsExpanded(true);\n                addTimeout(() => {\n                    setIsExpanded(false);\n                    addTimeout(() => runAnimationCycle(false), 3000);\n                }, 3200);\n            }, initialDelay);\n        };\n\n        runAnimationCycle(true);\n\n        return () => {\n            timeouts.forEach(clearTimeout);\n        };\n    }, [blockRect]);\n\n    const getAnimatedStyle = () => {\n        if (!blockRect || !layoutRef.current) {\n            return {\n                left: blockRect?.left ?? 0,\n                top: blockRect?.top ?? 0,\n                width: blockRect?.width ?? 0,\n                height: blockRect?.height ?? 0,\n            };\n        }\n\n        if (isExpanded) {\n            const layoutWidth = layoutRef.current.offsetWidth;\n            const layoutHeight = layoutRef.current.offsetHeight;\n            const targetWidth = layoutWidth * 0.85;\n            const targetHeight = layoutHeight * 0.85;\n\n            return {\n                left: (layoutWidth - targetWidth) / 2,\n                top: (layoutHeight - targetHeight) / 2,\n                width: targetWidth,\n                height: targetHeight,\n            };\n        }\n\n        return {\n            left: blockRect.left,\n            top: blockRect.top,\n            width: blockRect.width,\n            height: blockRect.height,\n        };\n    };\n\n    return (\n        <div ref={layoutRef} className=\"w-full h-[400px] flex flex-row gap-2 relative\">\n            <div className=\"flex-1\">\n                <FakeBlock icon=\"terminal\" name=\"Terminal\" />\n            </div>\n            <div className=\"flex-1 flex flex-col gap-2\">\n                <div className=\"flex-1\">\n                    <FakeBlock icon=\"globe\" name=\"Web\" />\n                </div>\n                <div className=\"flex-1\" ref={highlightedContainerRef}>\n                    <FakeBlock icon=\"terminal\" name=\"Terminal\" highlighted={true} className=\"opacity-0\" />\n                </div>\n            </div>\n            {blockRect && (\n                <>\n                    <div\n                        className={cn(\n                            \"absolute inset-0 bg-black/50 transition-opacity duration-200\",\n                            isExpanded ? \"opacity-100\" : \"opacity-0\"\n                        )}\n                    />\n                    <div className=\"absolute transition-all duration-200 ease-in-out\" style={getAnimatedStyle()}>\n                        <FakeBlock icon=\"terminal\" name=\"Terminal\" highlighted={true} />\n                    </div>\n                </>\n            )}\n        </div>\n    );\n};\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-starask.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport Logo from \"@/app/asset/logo.svg\";\nimport { Button } from \"@/app/element/button\";\nimport { ClientModel } from \"@/app/store/client-model\";\nimport * as WOS from \"@/app/store/wos\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\n\ntype StarAskPageProps = {\n    onClose: () => void;\n    page?: string;\n};\n\nexport function StarAskPage({ onClose, page = \"upgrade\" }: StarAskPageProps) {\n    const handleStarClick = async () => {\n        RpcApi.RecordTEventCommand(\n            TabRpcClient,\n            {\n                event: \"onboarding:githubstar\",\n                props: { \"onboarding:githubstar\": \"star\", \"onboarding:page\": page },\n            },\n            { noresponse: true }\n        );\n        const clientId = ClientModel.getInstance().clientId;\n        await RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"client\", clientId),\n            meta: { \"onboarding:githubstar\": true },\n        });\n        window.open(`https://github.com/wavetermdev/waveterm?ref=${page}`, \"_blank\");\n        onClose();\n    };\n\n    const handleAlreadyStarred = async () => {\n        RpcApi.RecordTEventCommand(\n            TabRpcClient,\n            {\n                event: \"onboarding:githubstar\",\n                props: { \"onboarding:githubstar\": \"already\", \"onboarding:page\": page },\n            },\n            { noresponse: true }\n        );\n        const clientId = ClientModel.getInstance().clientId;\n        await RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"client\", clientId),\n            meta: { \"onboarding:githubstar\": true },\n        });\n        onClose();\n    };\n\n    const handleRepoLinkClick = () => {\n        RpcApi.RecordTEventCommand(\n            TabRpcClient,\n            {\n                event: \"action:link\",\n                props: { \"action:type\": \"githubrepo\", \"onboarding:page\": page },\n            },\n            { noresponse: true }\n        );\n        window.open(\"https://github.com/wavetermdev/waveterm\", \"_blank\");\n    };\n\n    const handleMaybeLater = async () => {\n        RpcApi.RecordTEventCommand(\n            TabRpcClient,\n            {\n                event: \"onboarding:githubstar\",\n                props: { \"onboarding:githubstar\": \"later\", \"onboarding:page\": page },\n            },\n            { noresponse: true }\n        );\n        const clientId = ClientModel.getInstance().clientId;\n        await RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"client\", clientId),\n            meta: { \"onboarding:githubstar\": false },\n        });\n        onClose();\n    };\n\n    return (\n        <div className=\"flex flex-col h-full\">\n            <header className=\"flex flex-col gap-2 border-b-0 p-0 mt-1 mb-6 w-full unselectable flex-shrink-0\">\n                <div className=\"flex justify-center\">\n                    <Logo />\n                </div>\n                <div className=\"text-center text-[25px] font-normal text-foreground\">Support open-source. Star Wave. ⭐</div>\n            </header>\n            <div className=\"flex-1 flex flex-col items-center justify-center gap-5 unselectable\">\n                <div className=\"flex flex-col items-center gap-4 max-w-[460px] text-center\">\n                    <div className=\"text-secondary text-sm leading-relaxed\">\n                        Wave is free, open-source, and open-model. Stars help us stay visible against closed\n                        alternatives. One click makes a difference.\n                    </div>\n                    <div\n                        className=\"group flex items-center justify-center gap-2 text-secondary text-sm mt-1 cursor-pointer transition-colors\"\n                        onClick={handleRepoLinkClick}\n                    >\n                        <i className=\"fa-brands fa-github text-foreground text-lg group-hover:text-accent transition-colors\" />\n                        <span className=\"text-foreground font-mono text-sm group-hover:text-accent group-hover:underline transition-colors\">\n                            wavetermdev/waveterm\n                        </span>\n                    </div>\n                </div>\n            </div>\n            <footer className=\"unselectable flex-shrink-0 mt-6\">\n                <div className=\"flex flex-row items-center justify-center gap-2.5 [&>button]:!px-5 [&>button]:!py-2 [&>button]:text-sm [&>button]:!h-[37px]\">\n                    <Button className=\"outlined grey font-[600]\" onClick={handleAlreadyStarred}>\n                        🙏 Already Starred\n                    </Button>\n                    <Button className=\"outlined green font-[600]\" onClick={handleStarClick}>\n                        ⭐ Star Now\n                    </Button>\n                    <Button className=\"outlined grey font-[600]\" onClick={handleMaybeLater}>\n                        Maybe Later\n                    </Button>\n                </div>\n            </footer>\n        </div>\n    );\n}\n\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-upgrade-minor.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport Logo from \"@/app/asset/logo.svg\";\nimport { Button } from \"@/app/element/button\";\nimport { FlexiModal } from \"@/app/modals/modal\";\nimport { CurrentOnboardingVersion, OnboardingGradientBg } from \"@/app/onboarding/onboarding-common\";\nimport { OnboardingFeatures } from \"@/app/onboarding/onboarding-features\";\nimport { ClientModel } from \"@/app/store/client-model\";\nimport { globalStore } from \"@/app/store/global\";\nimport { disableGlobalKeybindings, enableGlobalKeybindings, globalRefocus } from \"@/app/store/keymodel\";\nimport { modalsModel } from \"@/app/store/modalmodel\";\nimport * as WOS from \"@/app/store/wos\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { OverlayScrollbarsComponent } from \"overlayscrollbars-react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { debounce } from \"throttle-debounce\";\n\ntype UpgradeMinorWelcomePageProps = {\n    onStarClick: () => void;\n    onAlreadyStarred: () => void;\n    onMaybeLater: () => void;\n};\n\nconst UpgradeMinorWelcomePage = ({ onStarClick, onAlreadyStarred, onMaybeLater }: UpgradeMinorWelcomePageProps) => {\n    return (\n        <div className=\"flex flex-col h-full\">\n            <header className=\"flex flex-col gap-2 border-b-0 p-0 mt-1 mb-4 w-full unselectable flex-shrink-0\">\n                <div className=\"flex justify-center\">\n                    <Logo />\n                </div>\n                <div className=\"text-center text-[25px] font-normal text-foreground\">Welcome to Wave v0.14!</div>\n            </header>\n            <OverlayScrollbarsComponent\n                className=\"flex-1 overflow-y-auto min-h-0\"\n                options={{ scrollbars: { autoHide: \"never\" } }}\n            >\n                <div className=\"flex flex-col items-center gap-3 w-full mb-2 unselectable\">\n                    <div className=\"flex flex-col items-center gap-4\">\n                        <div className=\"flex flex-row gap-4 items-center\">\n                            <div className=\"flex h-[52px] px-3 items-center rounded-lg bg-hover text-accent text-[24px]\">\n                                <i className=\"fa fa-sparkles\" />\n                                <span className=\"font-bold ml-2 font-mono\">Wave AI</span>\n                            </div>\n                            <div className=\"flex h-[52px] px-3 items-center rounded-lg bg-hover text-[18px]\">\n                                <i className=\"fa-sharp fa-solid fa-shield text-sky-500\" />\n                                <span className=\"font-bold ml-2 text-accent\">Durable SSH Sessions</span>\n                            </div>\n                        </div>\n                        <div className=\"text-secondary leading-relaxed max-w-[600px] text-left\">\n                            <p className=\"mb-4\">\n                                Wave AI is your terminal assistant with full context. It can read your terminal output,\n                                analyze widgets, read and write files, and help you solve problems&nbsp;faster.\n                            </p>\n                            <p className=\"mb-4\">\n                                <span className=\"font-semibold text-foreground\">New in v0.13:</span> Wave AI now\n                                supports local models and bring-your-own-key! Use Ollama, LM Studio, vLLM, OpenRouter,\n                                or any OpenAI-compatible provider.\n                            </p>\n                            <p className=\"mb-4\">\n                                <span className=\"font-semibold text-foreground\">New in v0.14:</span> Durable SSH\n                                sessions survive network drops, laptop sleep, and restarts — all without tmux or screen.\n                            </p>\n                        </div>\n                    </div>\n\n                    <div className=\"w-full max-w-[550px] border-t border-border my-2\"></div>\n\n                    <div className=\"flex flex-col items-center gap-3 text-center max-w-[550px]\">\n                        <div className=\"text-foreground text-base\">Thanks for being an early Wave adopter! ⭐</div>\n                        <div className=\"text-secondary text-sm text-left\">\n                            A GitHub star shows your support for Wave (and open-source) and helps us reach more\n                            developers.\n                        </div>\n                    </div>\n                </div>\n            </OverlayScrollbarsComponent>\n            <footer className=\"unselectable flex-shrink-0 mt-4\">\n                <div className=\"flex flex-row items-center justify-center gap-2.5 [&>button]:!px-5 [&>button]:!py-2 [&>button]:text-sm [&>button]:!h-[37px]\">\n                    <Button className=\"outlined grey font-[600]\" onClick={onAlreadyStarred}>\n                        🙏 Already Starred\n                    </Button>\n                    <Button className=\"outlined green font-[600]\" onClick={onStarClick}>\n                        ⭐ Star Now\n                    </Button>\n                    <Button className=\"outlined grey font-[600]\" onClick={onMaybeLater}>\n                        Maybe Later\n                    </Button>\n                </div>\n            </footer>\n        </div>\n    );\n};\n\nUpgradeMinorWelcomePage.displayName = \"UpgradeMinorWelcomePage\";\n\nconst UpgradeOnboardingMinor = () => {\n    const modalRef = useRef<HTMLDivElement | null>(null);\n    const [pageName, setPageName] = useState<\"welcome\" | \"features\">(\"welcome\");\n    const [isCompact, setIsCompact] = useState<boolean>(window.innerHeight < 800);\n\n    const updateModalHeight = () => {\n        const windowHeight = window.innerHeight;\n        setIsCompact(windowHeight < 800);\n        if (modalRef.current) {\n            const modalHeight = modalRef.current.offsetHeight;\n            const maxHeight = windowHeight * 0.9;\n            if (maxHeight < modalHeight) {\n                modalRef.current.style.height = `${maxHeight}px`;\n            } else {\n                modalRef.current.style.height = \"auto\";\n            }\n        }\n    };\n\n    useEffect(() => {\n        updateModalHeight();\n        const debouncedUpdateModalHeight = debounce(150, updateModalHeight);\n        window.addEventListener(\"resize\", debouncedUpdateModalHeight);\n        return () => {\n            window.removeEventListener(\"resize\", debouncedUpdateModalHeight);\n        };\n    }, []);\n\n    useEffect(() => {\n        disableGlobalKeybindings();\n        return () => {\n            enableGlobalKeybindings();\n        };\n    }, []);\n\n    const handleStarClick = async () => {\n        RpcApi.RecordTEventCommand(\n            TabRpcClient,\n            {\n                event: \"onboarding:githubstar\",\n                props: { \"onboarding:githubstar\": \"star\", \"onboarding:page\": \"minorupgrade\" },\n            },\n            { noresponse: true }\n        );\n        const clientId = ClientModel.getInstance().clientId;\n        await RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"client\", clientId),\n            meta: { \"onboarding:githubstar\": true },\n        });\n        window.open(\"https://github.com/wavetermdev/waveterm?ref=upgrade\", \"_blank\");\n        setPageName(\"features\");\n    };\n\n    const handleAlreadyStarred = async () => {\n        RpcApi.RecordTEventCommand(\n            TabRpcClient,\n            {\n                event: \"onboarding:githubstar\",\n                props: { \"onboarding:githubstar\": \"already\", \"onboarding:page\": \"minorupgrade\" },\n            },\n            { noresponse: true }\n        );\n        const clientId = ClientModel.getInstance().clientId;\n        await RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"client\", clientId),\n            meta: { \"onboarding:githubstar\": true },\n        });\n        setPageName(\"features\");\n    };\n\n    const handleMaybeLater = async () => {\n        RpcApi.RecordTEventCommand(\n            TabRpcClient,\n            {\n                event: \"onboarding:githubstar\",\n                props: { \"onboarding:githubstar\": \"later\", \"onboarding:page\": \"minorupgrade\" },\n            },\n            { noresponse: true }\n        );\n        const clientId = ClientModel.getInstance().clientId;\n        await RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"client\", clientId),\n            meta: { \"onboarding:githubstar\": false },\n        });\n        setPageName(\"features\");\n    };\n\n    const handleFeaturesComplete = () => {\n        const clientId = ClientModel.getInstance().clientId;\n        RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"client\", clientId),\n            meta: { \"onboarding:lastversion\": CurrentOnboardingVersion },\n        });\n        globalStore.set(modalsModel.upgradeOnboardingOpen, false);\n        setTimeout(() => {\n            globalRefocus();\n        }, 10);\n    };\n\n    let pageComp: React.JSX.Element = null;\n    if (pageName === \"welcome\") {\n        pageComp = (\n            <UpgradeMinorWelcomePage\n                onStarClick={handleStarClick}\n                onAlreadyStarred={handleAlreadyStarred}\n                onMaybeLater={handleMaybeLater}\n            />\n        );\n    } else if (pageName === \"features\") {\n        pageComp = <OnboardingFeatures onComplete={handleFeaturesComplete} />;\n    }\n\n    if (pageComp == null) {\n        return null;\n    }\n\n    const paddingClass = isCompact ? \"!py-3 !px-[30px]\" : \"!p-[30px]\";\n    const widthClass = pageName === \"features\" ? \"w-[800px]\" : \"w-[600px]\";\n\n    return (\n        <FlexiModal className={`${widthClass} rounded-[10px] ${paddingClass} relative overflow-hidden`} ref={modalRef}>\n            <OnboardingGradientBg />\n            <div className=\"flex flex-col w-full h-full relative z-10\">{pageComp}</div>\n        </FlexiModal>\n    );\n};\n\nUpgradeOnboardingMinor.displayName = \"UpgradeOnboardingMinor\";\n\nexport { UpgradeMinorWelcomePage, UpgradeOnboardingMinor };\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-upgrade-patch.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport Logo from \"@/app/asset/logo.svg\";\nimport { Button } from \"@/app/element/button\";\nimport { FlexiModal } from \"@/app/modals/modal\";\nimport { CurrentOnboardingVersion, OnboardingGradientBg } from \"@/app/onboarding/onboarding-common\";\nimport { StarAskPage } from \"@/app/onboarding/onboarding-starask\";\nimport { ClientModel } from \"@/app/store/client-model\";\nimport { globalStore } from \"@/app/store/global\";\nimport { disableGlobalKeybindings, enableGlobalKeybindings, globalRefocus } from \"@/app/store/keymodel\";\nimport { modalsModel } from \"@/app/store/modalmodel\";\nimport * as WOS from \"@/app/store/wos\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { useAtomValue } from \"jotai\";\nimport { OverlayScrollbarsComponent } from \"overlayscrollbars-react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { debounce } from \"throttle-debounce\";\nimport { UpgradeOnboardingModal_v0_12_1_Content } from \"./onboarding-upgrade-v0121\";\nimport { UpgradeOnboardingModal_v0_12_2_Content } from \"./onboarding-upgrade-v0122\";\nimport { UpgradeOnboardingModal_v0_12_3_Content } from \"./onboarding-upgrade-v0123\";\nimport { UpgradeOnboardingModal_v0_13_0_Content } from \"./onboarding-upgrade-v0130\";\nimport { UpgradeOnboardingModal_v0_13_1_Content } from \"./onboarding-upgrade-v0131\";\nimport { UpgradeOnboardingModal_v0_14_0_Content } from \"./onboarding-upgrade-v0140\";\nimport { UpgradeOnboardingModal_v0_14_1_Content } from \"./onboarding-upgrade-v0141\";\nimport { UpgradeOnboardingModal_v0_14_2_Content } from \"./onboarding-upgrade-v0142\";\n\ninterface VersionConfig {\n    version: string;\n    content: () => React.ReactNode;\n    prevText?: string;\n    nextText?: string;\n}\n\ninterface UpgradeOnboardingPatchProps {\n    isReleaseNotes?: boolean;\n}\n\ninterface UpgradeOnboardingFooterProps {\n    hasPrev: boolean;\n    hasNext: boolean;\n    prevText?: string;\n    nextText?: string;\n    onPrev?: () => void;\n    onNext?: () => void;\n    onClose: () => void;\n}\n\nexport function UpgradeOnboardingFooter({\n    hasPrev,\n    hasNext,\n    prevText,\n    nextText,\n    onPrev,\n    onNext,\n    onClose,\n}: UpgradeOnboardingFooterProps) {\n    return (\n        <footer className=\"unselectable flex-shrink-0 mt-4\">\n            <div className=\"flex flex-row items-center justify-between w-full\">\n                <div className=\"flex-1 flex justify-start\">\n                    {hasPrev && (\n                        <div className=\"text-sm text-secondary\">\n                            <button\n                                onClick={onPrev}\n                                className=\"cursor-pointer hover:text-foreground transition-colors\"\n                            >\n                                &lt; {prevText}\n                            </button>\n                        </div>\n                    )}\n                </div>\n                <div className=\"flex flex-row items-center justify-center [&>button]:!px-5 [&>button]:!py-2 [&>button]:text-sm\">\n                    <Button className=\"font-[600]\" onClick={onClose}>\n                        Continue\n                    </Button>\n                </div>\n                <div className=\"flex-1 flex justify-end\">\n                    {hasNext && (\n                        <div className=\"text-sm text-secondary\">\n                            <button\n                                onClick={onNext}\n                                className=\"cursor-pointer hover:text-foreground transition-colors\"\n                            >\n                                {nextText} &gt;\n                            </button>\n                        </div>\n                    )}\n                </div>\n            </div>\n        </footer>\n    );\n}\n\nexport const UpgradeOnboardingVersions: VersionConfig[] = [\n    {\n        version: \"v0.12.1\",\n        content: () => <UpgradeOnboardingModal_v0_12_1_Content />,\n        nextText: \"Next (v0.12.2)\",\n    },\n    {\n        version: \"v0.12.2\",\n        content: () => <UpgradeOnboardingModal_v0_12_2_Content />,\n        prevText: \"Prev (v0.12.1)\",\n        nextText: \"Next (v0.12.3)\",\n    },\n    {\n        version: \"v0.12.5\",\n        content: () => <UpgradeOnboardingModal_v0_12_3_Content />,\n        prevText: \"Prev (v0.12.2)\",\n        nextText: \"Next (v0.13.0)\",\n    },\n    {\n        version: \"v0.13.0\",\n        content: () => <UpgradeOnboardingModal_v0_13_0_Content />,\n        prevText: \"Prev (v0.12.5)\",\n        nextText: \"Next (v0.13.1)\",\n    },\n    {\n        version: \"v0.13.1\",\n        content: () => <UpgradeOnboardingModal_v0_13_1_Content />,\n        prevText: \"Prev (v0.13.0)\",\n        nextText: \"Next (v0.14.0)\",\n    },\n    {\n        version: \"v0.14.0\",\n        content: () => <UpgradeOnboardingModal_v0_14_0_Content />,\n        prevText: \"Prev (v0.13.1)\",\n        nextText: \"Next (v0.14.1)\",\n    },\n    {\n        version: \"v0.14.1\",\n        content: () => <UpgradeOnboardingModal_v0_14_1_Content />,\n        prevText: \"Prev (v0.14.0)\",\n        nextText: \"Next (v0.14.3)\",\n    },\n    {\n        version: \"v0.14.3\",\n        content: () => <UpgradeOnboardingModal_v0_14_2_Content />,\n        prevText: \"Prev (v0.14.1)\",\n    },\n];\n\nconst UpgradeOnboardingPatch = ({ isReleaseNotes = false }: UpgradeOnboardingPatchProps) => {\n    const modalRef = useRef<HTMLDivElement | null>(null);\n    const [isCompact, setIsCompact] = useState<boolean>(window.innerHeight < 800);\n    const [currentIndex, setCurrentIndex] = useState<number>(UpgradeOnboardingVersions.length - 1);\n    const [showStarAsk, setShowStarAsk] = useState<boolean>(false);\n    const clientData = useAtomValue(ClientModel.getInstance().clientAtom);\n    const alreadyStarred = clientData?.meta?.[\"onboarding:githubstar\"] === true;\n\n    const currentVersion = UpgradeOnboardingVersions[currentIndex];\n    const hasPrev = currentIndex > 0;\n    const hasNext = currentIndex < UpgradeOnboardingVersions.length - 1;\n\n    const updateModalHeight = () => {\n        const windowHeight = window.innerHeight;\n        setIsCompact(windowHeight < 800);\n        if (modalRef.current) {\n            const modalHeight = modalRef.current.offsetHeight;\n            const maxHeight = windowHeight * 0.9;\n            if (maxHeight < modalHeight) {\n                modalRef.current.style.height = `${maxHeight}px`;\n            } else {\n                modalRef.current.style.height = \"auto\";\n            }\n        }\n    };\n\n    useEffect(() => {\n        updateModalHeight();\n        const debouncedUpdateModalHeight = debounce(150, updateModalHeight);\n        window.addEventListener(\"resize\", debouncedUpdateModalHeight);\n        return () => {\n            window.removeEventListener(\"resize\", debouncedUpdateModalHeight);\n        };\n    }, []);\n\n    useEffect(() => {\n        disableGlobalKeybindings();\n        return () => {\n            enableGlobalKeybindings();\n        };\n    }, []);\n\n    const doClose = () => {\n        if (isReleaseNotes) {\n            modalsModel.popModal();\n        } else {\n            globalStore.set(modalsModel.upgradeOnboardingOpen, false);\n        }\n        setTimeout(() => {\n            globalRefocus();\n        }, 10);\n    };\n\n    const handleClose = () => {\n        if (isReleaseNotes) {\n            doClose();\n            return;\n        }\n        const clientId = ClientModel.getInstance().clientId;\n        RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"client\", clientId),\n            meta: { \"onboarding:lastversion\": CurrentOnboardingVersion },\n        });\n        if (alreadyStarred) {\n            doClose();\n        } else {\n            setShowStarAsk(true);\n        }\n    };\n\n    const paddingClass = isCompact ? \"!py-3 !px-[30px]\" : \"!p-[30px]\";\n\n    const handlePrev = () => {\n        if (hasPrev) {\n            setCurrentIndex(currentIndex - 1);\n        }\n    };\n\n    const handleNext = () => {\n        if (hasNext) {\n            setCurrentIndex(currentIndex + 1);\n        }\n    };\n\n    if (showStarAsk) {\n        return (\n            <FlexiModal\n                className=\"w-[500px] rounded-[10px] !p-[30px] relative overflow-hidden bg-panel\"\n                ref={modalRef}\n            >\n                <OnboardingGradientBg />\n                <div className=\"relative z-10 flex flex-col w-full h-full\">\n                    <StarAskPage onClose={doClose} page=\"upgrade\" />\n                </div>\n            </FlexiModal>\n        );\n    }\n\n    return (\n        <FlexiModal className={`w-[650px] rounded-[10px] ${paddingClass} relative overflow-hidden`} ref={modalRef}>\n            <OnboardingGradientBg />\n            <div className=\"flex flex-col w-full h-full relative z-10\">\n                <div className=\"flex flex-col h-full\">\n                    <header className=\"flex flex-col gap-2 border-b-0 p-0 mt-1 mb-6 w-full unselectable flex-shrink-0\">\n                        <div className=\"flex justify-center\">\n                            <Logo />\n                        </div>\n                        <div className=\"text-center text-[25px] font-normal text-foreground\">\n                            Wave {currentVersion.version} Update\n                        </div>\n                    </header>\n                    <OverlayScrollbarsComponent\n                        className=\"flex-1 overflow-y-auto min-h-0\"\n                        options={{ scrollbars: { autoHide: \"never\" } }}\n                    >\n                        {currentVersion.content()}\n                    </OverlayScrollbarsComponent>\n                    <UpgradeOnboardingFooter\n                        hasPrev={hasPrev}\n                        hasNext={hasNext}\n                        prevText={currentVersion.prevText}\n                        nextText={currentVersion.nextText}\n                        onPrev={handlePrev}\n                        onNext={handleNext}\n                        onClose={handleClose}\n                    />\n                </div>\n            </div>\n        </FlexiModal>\n    );\n};\n\nUpgradeOnboardingPatch.displayName = \"UpgradeOnboardingPatch\";\n\nexport { UpgradeOnboardingPatch };\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-upgrade-v0121.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nconst UpgradeOnboardingModal_v0_12_1_Content = () => {\n    return (\n        <div className=\"flex flex-col items-start gap-6 w-full mb-4 unselectable\">\n            <div className=\"text-secondary leading-relaxed\">\n                <p className=\"mb-0\">\n                    Patch release focused on shell integration improvements, Wave AI enhancements, and restoring syntax\n                    highlighting in code editor blocks.\n                </p>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-terminal\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">\n                        Shell Integration & Context\n                    </div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>OSC 7 Support</strong> - Wave now automatically tracks and restores your current\n                                directory across restarts for bash, zsh, fish, and pwsh shells\n                            </li>\n                            <li>\n                                <strong>Shell Context Tracking</strong> - Tracks when your shell is ready, last command\n                                executed, and exit codes for better terminal management\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-sparkles\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">\n                        Wave AI Improvements\n                    </div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>Display reasoning summaries while waiting for AI responses</li>\n                            <li>\n                                Enhanced terminal context - AI now has access to shell state, current directory, command\n                                history, and exit codes\n                            </li>\n                            <li>Added feedback buttons (thumbs up/down) for AI responses</li>\n                            <li>Added copy button to easily copy AI responses to clipboard</li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-wrench\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Other Changes</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>Mobile user agent emulation support for web widgets</li>\n                            <li>Fixed padding for header buttons in code editor</li>\n                            <li>Restored syntax highlighting in code editor preview blocks</li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n\nUpgradeOnboardingModal_v0_12_1_Content.displayName = \"UpgradeOnboardingModal_v0_12_1_Content\";\n\nexport { UpgradeOnboardingModal_v0_12_1_Content };\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-upgrade-v0122.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nconst UpgradeOnboardingModal_v0_12_2_Content = () => {\n    return (\n        <div className=\"flex flex-col items-start gap-6 w-full mb-4 unselectable\">\n            <div className=\"text-secondary leading-relaxed\">\n                <p className=\"mb-0\">\n                    Wave AI can now create and modify files with visual diff previews and easy rollback capabilities.\n                    Plus performance improvements and bug fixes.\n                </p>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-file-pen\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Wave AI File Editing</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>File Write Tool</strong> - Wave AI can now create and modify files with your\n                                approval\n                            </li>\n                            <li>\n                                <strong>Visual Diff Preview</strong> - See exactly what will change before approving\n                                edits\n                            </li>\n                            <li>\n                                <strong>Easy Rollback</strong> - Revert file changes with a simple \"Revert File\" button\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-sparkles\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">\n                        Additional AI Improvements\n                    </div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>Drag & drop files from preview viewer directly to Wave AI</li>\n                            <li>\n                                Directory listings support in <span className=\"font-mono\">`wsh ai`</span> commands\n                            </li>\n                            <li>Adjustable thinking level and max output tokens per chat</li>\n                            <li>Improved tool descriptions and input validations</li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-wrench\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">\n                        Bug Fixes & Improvements\n                    </div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>Fixed significant memory leak in the RPC system</li>\n                            <li>Config file schema validation restored</li>\n                            <li>Fixed PowerShell 5.x regression</li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n\nUpgradeOnboardingModal_v0_12_2_Content.displayName = \"UpgradeOnboardingModal_v0_12_2_Content\";\n\nexport { UpgradeOnboardingModal_v0_12_2_Content };\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-upgrade-v0123.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nconst UpgradeOnboardingModal_v0_12_3_Content = () => {\n    return (\n        <div className=\"flex flex-col items-start gap-6 w-full mb-4 unselectable\">\n            <div className=\"text-secondary leading-relaxed\">\n                <p className=\"mb-0\">\n                    Wave AI model upgrade to GPT-5.1, new secret management features, and improved terminal input\n                    handling for interactive CLI tools.\n                </p>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-sparkles\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Wave AI Updates</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>GPT-5.1 Model</strong> - Upgraded to OpenAI's GPT-5.1 model for improved\n                                responses\n                            </li>\n                            <li>\n                                <strong>Thinking Mode Toggle</strong> - New dropdown to select between Quick, Balanced,\n                                and Deep thinking modes\n                            </li>\n                            <li>Fixed path mismatch issue when restoring AI write file backups</li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-terminal\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Terminal Improvements</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>Enhanced Input Handling</strong> - Better support for CLI tools like Claude Code\n                            </li>\n                            <li>\n                                <strong>Image Paste Support</strong> - Paste images directly into terminal (saved to\n                                temp files)\n                            </li>\n                            <li>Shift+Enter now inserts newlines by default for multi-line commands</li>\n                            <li>Fixed duplicate text issue when switching input methods (IME)</li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-key\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Secret Store</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>Secret Management Widget</strong> - Store and manage sensitive credentials\n                                securely\n                            </li>\n                            <li>\n                                Access secrets via CLI with <span className=\"font-mono\">wsh secret list/get/set</span>{\" \"}\n                                commands\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n\nUpgradeOnboardingModal_v0_12_3_Content.displayName = \"UpgradeOnboardingModal_v0_12_3_Content\";\n\nexport { UpgradeOnboardingModal_v0_12_3_Content };\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-upgrade-v0130.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nconst UpgradeOnboardingModal_v0_13_0_Content = () => {\n    return (\n        <div className=\"flex flex-col items-start gap-6 w-full mb-4 unselectable\">\n            <div className=\"text-secondary leading-relaxed\">\n                <p className=\"mb-0\">\n                    Wave v0.13 brings local AI support, bring-your-own-key (BYOK), a redesigned configuration system,\n                    and improved terminal functionality.\n                </p>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-sparkles\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Local AI & BYOK</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>OpenAI-Compatible API</strong> - Connect to Ollama, LM Studio, vLLM, OpenRouter,\n                                and other local or hosted models\n                            </li>\n                            <li>\n                                <strong>Google Gemini</strong> - Native support for Gemini models\n                            </li>\n                            <li>\n                                <strong>Provider Presets</strong> - Built-in configs for OpenAI, OpenRouter, Google,\n                                Azure, and custom endpoints\n                            </li>\n                            <li>\n                                <strong>Multiple AI Modes</strong> - Easily switch between models and providers\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-sliders\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Configuration Widget</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>New Config Interface</strong> - Dedicated widget accessible from the sidebar\n                            </li>\n                            <li>\n                                <strong>Better Organization</strong> - Browse and edit settings with improved validation\n                                and error handling\n                            </li>\n                            <li>\n                                <strong>Integrated Secrets</strong> - Manage API keys and credentials from the config\n                                widget\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-terminal\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Terminal Updates</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>Bracketed Paste Mode</strong> - Enabled by default for better multi-line paste\n                                behavior\n                            </li>\n                            <li>\n                                <strong>Windows Paste Fix</strong> - Ctrl+V now works as standard paste on Windows\n                            </li>\n                            <li>\n                                <strong>SSH Password Storage</strong> - Store SSH passwords in Wave's secret store\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n\nUpgradeOnboardingModal_v0_13_0_Content.displayName = \"UpgradeOnboardingModal_v0_13_0_Content\";\n\nexport { UpgradeOnboardingModal_v0_13_0_Content };\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-upgrade-v0131.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nconst UpgradeOnboardingModal_v0_13_1_Content = () => {\n    return (\n        <div className=\"flex flex-col items-start gap-6 w-full mb-4 unselectable\">\n            <div className=\"text-secondary leading-relaxed\">\n                <p className=\"mb-0\">\n                    Wave v0.13.1 focuses on Windows platform improvements, Wave AI visual updates, and enhanced\n                    terminal navigation.\n                </p>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-brands fa-windows\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">\n                        Windows Platform Enhancements\n                    </div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>Integrated Window Layout</strong> - Cleaner interface with controls integrated\n                                into the tab-bar header\n                            </li>\n                            <li>\n                                <strong>Git Bash Auto-Detection</strong> - Automatically detects Git Bash installations\n                            </li>\n                            <li>\n                                <strong>SSH Agent Fallback</strong> - Improved SSH agent support on Windows\n                            </li>\n                            <li>\n                                <strong>Updated Focus Keybinding</strong> - Wave AI focus key changed to Alt:0 on\n                                Windows\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-sparkles\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Wave AI Updates</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>Refreshed Visual Design</strong> - Complete UI refresh with transparency\n                                support for custom backgrounds\n                            </li>\n                            <li>\n                                <strong>BYOK Without Telemetry</strong> - Wave AI now works with bring-your-own-key and\n                                local models without requiring telemetry\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-terminal\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Terminal Improvements</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>New Scrolling Keybindings</strong> - Added Shift+Home, Shift+End,\n                                Shift+PageUp, and Shift+PageDown for better navigation\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n\nUpgradeOnboardingModal_v0_13_1_Content.displayName = \"UpgradeOnboardingModal_v0_13_1_Content\";\n\nexport { UpgradeOnboardingModal_v0_13_1_Content };"
  },
  {
    "path": "frontend/app/onboarding/onboarding-upgrade-v0140.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\n\nconst UpgradeOnboardingModal_v0_14_0_Content = () => {\n    const waveEnv = useWaveEnv();\n    return (\n        <div className=\"flex flex-col items-start w-full mb-2 unselectable\">\n            <div className=\"text-secondary leading-relaxed mb-4\">\n                <p className=\"mb-0\">\n                    Wave v0.14 introduces Durable Sessions. Enable them to keep your remote sessions alive through\n                    network interruptions, computer sleep, and restarts — they'll automatically reconnect when your\n                    connection is restored.\n                </p>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4 mb-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-sky-500 fa-sharp fa-solid fa-shield\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">\n                        Durable SSH Sessions{\" \"}\n                        <button\n                            onClick={() => waveEnv.electron.openExternal(\"https://docs.waveterm.dev/durable-sessions\")}\n                            className=\"text-accent text-sm font-normal cursor-pointer hover:underline\"\n                        >\n                            [see docs]\n                        </button>\n                    </div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>Session Protection</strong> - Programs and shell state survive disconnects\n                            </li>\n                            <li>\n                                <strong>Visual Status Indicators</strong> - Shield icons show status\n                            </li>\n                            <li>\n                                <strong>Flexible Configuration</strong> - Enable globally, per-connection, or\n                                per-terminal\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4 mb-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-network-wired\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">\n                        Enhanced Connection Monitoring\n                    </div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>Connection Keepalives</strong> - Active monitoring with keepalive probes\n                            </li>\n                            <li>\n                                <strong>Stalled Connection Detection</strong> - Visual feedback for network issues\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4 mb-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-sparkles\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Wave AI Updates</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>Image Support</strong> - Vision capabilities for BYOK providers\n                            </li>\n                            <li>\n                                <strong>Stop Generation</strong> - Ability to stop AI responses mid-generation\n                            </li>\n                            <li>\n                                <strong>Improved Auto-scrolling</strong>\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-terminal\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Terminal Improvements</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>Enhanced Context Menu</strong> - Quick access to splits, themes, and more\n                            </li>\n                            <li>\n                                <strong>OSC 52 Clipboard Support</strong> - CLI apps can copy to system clipboard\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n\nUpgradeOnboardingModal_v0_14_0_Content.displayName = \"UpgradeOnboardingModal_v0_14_0_Content\";\n\nexport { UpgradeOnboardingModal_v0_14_0_Content };\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-upgrade-v0141.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nconst UpgradeOnboardingModal_v0_14_1_Content = () => {\n    return (\n        <div className=\"flex flex-col items-start w-full mb-2 unselectable\">\n            <div className=\"text-secondary leading-relaxed mb-4\">\n                <p className=\"mb-0\">\n                    Wave v0.14.1 fixes several high-impact terminal bugs and adds new config options for focus, cursor\n                    style, and block navigation.\n                </p>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4 mb-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-terminal\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Terminal Fixes</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>Claude Code Scroll Fix</strong> - Fixed unexpected terminal scroll jumps\n                            </li>\n                            <li>\n                                <strong>IME Fix</strong> - Fixed Korean/CJK input losing or sticking characters\n                            </li>\n                            <li>\n                                <strong>Scroll Position on Resize</strong> - Terminal stays at bottom across resizes\n                            </li>\n                            <li>\n                                <strong>Terminal Scrollback Save</strong> - New context menu item and{\" \"}\n                                <code>wsh</code> command to save scrollback to a file\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-sliders\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">New Config Options</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>Focus Follows Cursor</strong> - New <code>app:focusfollowscursor</code> setting\n                                (off/on/term)\n                            </li>\n                            <li>\n                                <strong>Terminal Cursor Style &amp; Blink</strong> - Configure cursor shape and blink\n                                per-block\n                            </li>\n                            <li>\n                                <strong>Vim-Style Block Navigation</strong> - Ctrl+Shift+H/J/K/L to navigate blocks\n                            </li>\n                            <li>\n                                <strong>New AI Providers</strong> - Added Groq and NanoGPT as built-in presets\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n\nUpgradeOnboardingModal_v0_14_1_Content.displayName = \"UpgradeOnboardingModal_v0_14_1_Content\";\n\nexport { UpgradeOnboardingModal_v0_14_1_Content };\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-upgrade-v0142.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\n\nconst UpgradeOnboardingModal_v0_14_2_Content = () => {\n    const waveEnv = useWaveEnv();\n    return (\n        <div className=\"flex flex-col items-start w-full mb-2 unselectable\">\n            <div className=\"text-secondary leading-relaxed mb-4\">\n                <p className=\"mb-0\">\n                    Wave v0.14.2 introduces a new block badge system for at-a-glance status, along with directory\n                    preview improvements and bug fixes. v0.14.3 is a patch release fixing a showstopper bug in\n                    onboarding.\n                </p>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4 mb-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-bell\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Block &amp; Tab Badges</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>Block Badges Roll Up to Tabs</strong> - Blocks can display icon badges (with\n                                color and priority) that are visible in the tab bar for at-a-glance status\n                            </li>\n                            <li>\n                                <strong>Bell Indicator On by Default</strong> - Terminal bell badge now lights up the\n                                block and tab when your terminal rings (controlled by <code>term:bellindicator</code>)\n                            </li>\n                            <li>\n                                <strong>\n                                    <code>wsh badge</code>\n                                </strong>{\" \"}\n                                - New command to set or clear badges from the CLI. Supports icons, colors, priorities,\n                                and PID-linked badges\n                            </li>\n                            <li>\n                                <strong>Claude Code Integration</strong> - Use <code>wsh badge</code> with Claude Code\n                                hooks to surface AI task status as tab bar notifications{\" \"}\n                                <button\n                                    onClick={() =>\n                                        waveEnv.electron.openExternal(\"https://docs.waveterm.dev/claude-code\")\n                                    }\n                                    className=\"text-accent text-sm font-normal cursor-pointer hover:underline\"\n                                >\n                                    [see docs]\n                                </button>\n                            </li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"flex w-full items-start gap-4\">\n                <div className=\"flex-shrink-0\">\n                    <i className=\"text-[24px] text-accent fa-solid fa-folder-open\"></i>\n                </div>\n                <div className=\"flex flex-col items-start gap-2 flex-1\">\n                    <div className=\"text-foreground text-base font-semibold leading-[18px]\">Other Changes</div>\n                    <div className=\"text-secondary leading-5\">\n                        <ul className=\"list-disc list-outside space-y-1 pl-5\">\n                            <li>\n                                <strong>[v0.14.3] </strong>[bugfix] Fixed a showstopper onboarding bug\n                            </li>\n                            <li>\n                                <strong>Directory Preview</strong> - Improved mod time formatting, zebra-striped rows,\n                                better default sort, and YAML file support\n                            </li>\n                            <li>\n                                <strong>Search Bar</strong> - Clipboard and focus improvements\n                            </li>\n                            <li>[bugfix] Fixed \"New Window\" hanging on GNOME desktops</li>\n                            <li>[bugfix] Fixed \"Save Session As...\" focused window tracking bug</li>\n                        </ul>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n};\n\nUpgradeOnboardingModal_v0_14_2_Content.displayName = \"UpgradeOnboardingModal_v0_14_2_Content\";\n\nexport { UpgradeOnboardingModal_v0_14_2_Content };\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding-upgrade.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { ClientModel } from \"@/app/store/client-model\";\nimport { globalStore } from \"@/app/store/global\";\nimport { modalsModel } from \"@/app/store/modalmodel\";\nimport { useAtomValue } from \"jotai\";\nimport { useEffect, useRef } from \"react\";\nimport * as semver from \"semver\";\nimport { CurrentOnboardingVersion } from \"./onboarding-common\";\nimport { UpgradeOnboardingMinor } from \"./onboarding-upgrade-minor\";\nimport { UpgradeOnboardingPatch } from \"./onboarding-upgrade-patch\";\n\nconst UpgradeOnboardingModal = () => {\n    const clientData = useAtomValue(ClientModel.getInstance().clientAtom);\n    const initialVersionRef = useRef<string | null>(null);\n\n    if (initialVersionRef.current == null) {\n        initialVersionRef.current = clientData.meta?.[\"onboarding:lastversion\"] ?? \"v0.0.0\";\n    }\n\n    const lastVersion = initialVersionRef.current;\n\n    useEffect(() => {\n        if (semver.gte(lastVersion, CurrentOnboardingVersion)) {\n            globalStore.set(modalsModel.upgradeOnboardingOpen, false);\n        }\n    }, [lastVersion]);\n\n    if (semver.gte(lastVersion, CurrentOnboardingVersion)) {\n        return null;\n    }\n\n    if (semver.gte(lastVersion, \"v0.12.0\")) {\n        return <UpgradeOnboardingPatch />;\n    }\n\n    return <UpgradeOnboardingMinor />;\n};\n\nUpgradeOnboardingModal.displayName = \"UpgradeOnboardingModal\";\n\nexport { UpgradeOnboardingModal };\n"
  },
  {
    "path": "frontend/app/onboarding/onboarding.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport Logo from \"@/app/asset/logo.svg\";\nimport { Button } from \"@/app/element/button\";\nimport { FlexiModal } from \"@/app/modals/modal\";\nimport { OnboardingGradientBg } from \"@/app/onboarding/onboarding-common\";\nimport { OnboardingFeatures } from \"@/app/onboarding/onboarding-features\";\nimport { ClientModel } from \"@/app/store/client-model\";\nimport { useSettingsKeyAtom } from \"@/app/store/global\";\nimport { disableGlobalKeybindings, enableGlobalKeybindings, globalRefocus } from \"@/app/store/keymodel\";\nimport { modalsModel } from \"@/app/store/modalmodel\";\nimport * as WOS from \"@/app/store/wos\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { WorkspaceLayoutModel } from \"@/app/workspace/workspace-layout-model\";\nimport * as services from \"@/store/services\";\nimport { fireAndForget } from \"@/util/util\";\nimport { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from \"jotai\";\nimport { OverlayScrollbarsComponent } from \"overlayscrollbars-react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { debounce } from \"throttle-debounce\";\n\n// Page flow:\n//   init -> (telemetry enabled) -> features\n//   init -> (telemetry disabled) -> notelemetrystar -> features\n\ntype PageName = \"init\" | \"notelemetrystar\" | \"features\";\n\nconst pageNameAtom: PrimitiveAtom<PageName> = atom<PageName>(\"init\");\n\nconst InitPage = ({\n    isCompact,\n    telemetryUpdateFn,\n}: {\n    isCompact: boolean;\n    telemetryUpdateFn: (value: boolean) => Promise<void>;\n}) => {\n    const telemetrySetting = useSettingsKeyAtom(\"telemetry:enabled\");\n    const clientData = useAtomValue(ClientModel.getInstance().clientAtom);\n    const [telemetryEnabled, setTelemetryEnabled] = useState<boolean>(!!telemetrySetting);\n    const setPageName = useSetAtom(pageNameAtom);\n\n    const handleStarClick = async () => {\n        RpcApi.RecordTEventCommand(\n            TabRpcClient,\n            {\n                event: \"onboarding:githubstar\",\n                props: { \"onboarding:githubstar\": \"star\", \"onboarding:page\": \"init\" },\n            },\n            { noresponse: true }\n        );\n        const clientId = ClientModel.getInstance().clientId;\n        await RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"client\", clientId),\n            meta: { \"onboarding:githubstar\": true },\n        });\n    };\n\n    const acceptTos = () => {\n        if (!clientData?.tosagreed) {\n            fireAndForget(() => services.ClientService.AgreeTos());\n        }\n        if (telemetryEnabled) {\n            WorkspaceLayoutModel.getInstance().setAIPanelVisible(true);\n        }\n        setPageName(telemetryEnabled ? \"features\" : \"notelemetrystar\");\n    };\n\n    const setTelemetry = (value: boolean) => {\n        fireAndForget(() =>\n            telemetryUpdateFn(value).then(() => {\n                setTelemetryEnabled(value);\n            })\n        );\n    };\n\n    const label = telemetryEnabled ? \"Enabled\" : \"Disabled\";\n\n    return (\n        <div className=\"flex flex-col h-full\">\n            <header\n                className={`flex flex-col gap-2 border-b-0 p-0 ${isCompact ? \"mt-1 mb-4\" : \"mb-9\"} w-full unselectable flex-shrink-0`}\n            >\n                <div className={`${isCompact ? \"\" : \"mb-2.5\"} flex justify-center`}>\n                    <Logo />\n                </div>\n                <div className=\"text-center text-[25px] font-normal text-foreground\">Welcome to Wave Terminal</div>\n            </header>\n            <OverlayScrollbarsComponent\n                className=\"flex-1 overflow-y-auto min-h-0\"\n                options={{ scrollbars: { autoHide: \"never\" } }}\n            >\n                <div className=\"flex flex-col items-start gap-8 w-full mb-5 unselectable\">\n                    <div className=\"flex w-full items-center gap-[18px]\">\n                        <div>\n                            <a\n                                target=\"_blank\"\n                                href=\"https://github.com/wavetermdev/waveterm?ref=install\"\n                                rel=\"noopener\"\n                                className=\"text-accent\"\n                                onClick={handleStarClick}\n                            >\n                                <i className=\"text-[32px] text-white/50 fa-brands fa-github\"></i>\n                            </a>\n                        </div>\n                        <div className=\"flex flex-col items-start gap-1 flex-1\">\n                            <div className=\"text-foreground text-base leading-[18px]\">Support us on GitHub</div>\n                            <div className=\"text-secondary leading-5\">\n                                We're <i>open source</i>, <i>open-model</i>, and committed to providing a free terminal\n                                for individual users. Please show your support by giving us a star on{\" \"}\n                                <a\n                                    target=\"_blank\"\n                                    href=\"https://github.com/wavetermdev/waveterm?ref=install\"\n                                    rel=\"noopener\"\n                                    className=\"text-accent\"\n                                    onClick={handleStarClick}\n                                >\n                                    Github&nbsp;(wavetermdev/waveterm)\n                                </a>\n                            </div>\n                        </div>\n                    </div>\n                    <div className=\"flex w-full items-center gap-[18px]\">\n                        <div>\n                            <a\n                                target=\"_blank\"\n                                href=\"https://discord.gg/XfvZ334gwU\"\n                                rel=\"noopener\"\n                                className=\"text-accent\"\n                            >\n                                <i className=\"text-[25px] text-white/50 fa-solid fa-people-group\"></i>\n                            </a>\n                        </div>\n                        <div className=\"flex flex-col items-start gap-1 flex-1\">\n                            <div className=\"text-foreground text-base leading-[18px]\">Join our Community</div>\n                            <div className=\"text-secondary leading-5\">\n                                Get help, submit feature requests, report bugs, or just chat with fellow terminal\n                                enthusiasts.\n                                <br />\n                                <a\n                                    target=\"_blank\"\n                                    href=\"https://discord.gg/XfvZ334gwU\"\n                                    rel=\"noopener\"\n                                    className=\"text-accent\"\n                                >\n                                    Join the Wave&nbsp;Discord&nbsp;Channel\n                                </a>\n                            </div>\n                        </div>\n                    </div>\n                    <div className=\"flex w-full items-center gap-[18px]\">\n                        <div>\n                            <i className=\"text-[32px] text-white/50 fa-solid fa-chart-line\"></i>\n                        </div>\n                        <div className=\"flex flex-col items-start gap-1 flex-1\">\n                            <div className=\"text-secondary leading-5\">\n                                Anonymous usage data helps us improve features you use.\n                                <br />\n                                <a\n                                    className=\"text-secondary! hover:underline!\"\n                                    target=\"_blank\"\n                                    href=\"https://waveterm.dev/privacy\"\n                                    rel=\"noopener\"\n                                >\n                                    Privacy Policy\n                                </a>\n                            </div>\n                            <label className=\"flex items-center gap-2 cursor-pointer text-secondary\">\n                                <input\n                                    type=\"checkbox\"\n                                    checked={telemetryEnabled}\n                                    onChange={(e) => setTelemetry(e.target.checked)}\n                                    className=\"cursor-pointer accent-gray-500\"\n                                />\n                                <span>{label}</span>\n                            </label>\n                        </div>\n                    </div>\n                </div>\n            </OverlayScrollbarsComponent>\n            <footer className={`unselectable flex-shrink-0 ${isCompact ? \"mt-2\" : \"mt-5\"}`}>\n                <div className=\"flex flex-row items-center justify-center [&>button]:!px-5 [&>button]:!py-2 [&>button]:text-sm [&>button:not(:first-child)]:ml-2.5\">\n                    <Button className=\"font-[600]\" onClick={acceptTos}>\n                        Continue\n                    </Button>\n                </div>\n            </footer>\n        </div>\n    );\n};\n\nconst NoTelemetryStarPage = ({ isCompact }: { isCompact: boolean }) => {\n    const setPageName = useSetAtom(pageNameAtom);\n\n    const handleStarClick = async () => {\n        RpcApi.RecordTEventCommand(\n            TabRpcClient,\n            {\n                event: \"onboarding:githubstar\",\n                props: { \"onboarding:githubstar\": \"star\", \"onboarding:page\": \"notelemetry\" },\n            },\n            { noresponse: true }\n        );\n        const clientId = ClientModel.getInstance().clientId;\n        await RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"client\", clientId),\n            meta: { \"onboarding:githubstar\": true },\n        });\n        window.open(\"https://github.com/wavetermdev/waveterm?ref=not\", \"_blank\");\n        setPageName(\"features\");\n    };\n\n    const handleMaybeLater = async () => {\n        RpcApi.RecordTEventCommand(\n            TabRpcClient,\n            {\n                event: \"onboarding:githubstar\",\n                props: { \"onboarding:githubstar\": \"later\", \"onboarding:page\": \"notelemetry\" },\n            },\n            { noresponse: true }\n        );\n        const clientId = ClientModel.getInstance().clientId;\n        await RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"client\", clientId),\n            meta: { \"onboarding:githubstar\": false },\n        });\n        setPageName(\"features\");\n    };\n\n    return (\n        <div className=\"flex flex-col h-full\">\n            <header className={`flex flex-col gap-2 border-b-0 p-0 mt-1 mb-4 w-full unselectable flex-shrink-0`}>\n                <div className={`flex justify-center`}>\n                    <Logo />\n                </div>\n                <div className=\"text-center text-[25px] font-normal text-foreground\">Telemetry Disabled ✓</div>\n            </header>\n            <OverlayScrollbarsComponent\n                className=\"flex-1 overflow-y-auto min-h-0\"\n                options={{ scrollbars: { autoHide: \"never\" } }}\n            >\n                <div className=\"flex flex-col items-center gap-6 w-full mb-2 unselectable\">\n                    <div className=\"text-center text-secondary leading-relaxed max-w-md\">\n                        <p className=\"mb-4\">No problem, we respect your privacy.</p>\n                        <p className=\"mb-4\">\n                            But, without usage data, we're flying blind. A GitHub star helps us know Wave is useful and\n                            worth maintaining.\n                        </p>\n                    </div>\n                </div>\n            </OverlayScrollbarsComponent>\n            <footer className={`unselectable flex-shrink-0 mt-2`}>\n                <div className=\"flex flex-row items-center justify-center gap-2.5 [&>button]:!px-5 [&>button]:!py-2 [&>button]:text-sm [&>button]:!h-[37px]\">\n                    <Button className=\"outlined green font-[600]\" onClick={handleStarClick}>\n                        ⭐ Star on GitHub\n                    </Button>\n                    <Button className=\"outlined grey font-[600]\" onClick={handleMaybeLater}>\n                        Maybe Later\n                    </Button>\n                </div>\n            </footer>\n        </div>\n    );\n};\n\nconst FeaturesPage = () => {\n    const [newInstallOnboardingOpen, setNewInstallOnboardingOpen] = useAtom(modalsModel.newInstallOnboardingOpen);\n\n    const handleComplete = () => {\n        setNewInstallOnboardingOpen(false);\n        setTimeout(() => {\n            globalRefocus();\n        }, 10);\n    };\n\n    return <OnboardingFeatures onComplete={handleComplete} />;\n};\n\nconst NewInstallOnboardingModal = () => {\n    const modalRef = useRef<HTMLDivElement | null>(null);\n    const [pageName, setPageName] = useAtom(pageNameAtom);\n    const clientData = useAtomValue(ClientModel.getInstance().clientAtom);\n    const [isCompact, setIsCompact] = useState<boolean>(window.innerHeight < 800);\n\n    const updateModalHeight = () => {\n        const windowHeight = window.innerHeight;\n        setIsCompact(windowHeight < 800);\n        if (modalRef.current) {\n            const modalHeight = modalRef.current.offsetHeight;\n            const maxHeight = windowHeight * 0.9;\n            if (maxHeight < modalHeight) {\n                modalRef.current.style.height = `${maxHeight}px`;\n            } else {\n                modalRef.current.style.height = \"auto\";\n            }\n        }\n    };\n\n    useEffect(() => {\n        if (clientData.tosagreed) {\n            setPageName(\"features\");\n        }\n        return () => {\n            setPageName(\"init\");\n        };\n    }, []);\n\n    useEffect(() => {\n        updateModalHeight();\n        const debouncedUpdateModalHeight = debounce(150, updateModalHeight);\n        window.addEventListener(\"resize\", debouncedUpdateModalHeight);\n        return () => {\n            window.removeEventListener(\"resize\", debouncedUpdateModalHeight);\n        };\n    }, []);\n\n    useEffect(() => {\n        disableGlobalKeybindings();\n        return () => {\n            enableGlobalKeybindings();\n        };\n    }, []);\n\n    let pageComp: React.JSX.Element = null;\n    switch (pageName) {\n        case \"init\":\n            pageComp = <InitPage isCompact={isCompact} telemetryUpdateFn={(value) => services.ClientService.TelemetryUpdate(value)} />;\n            break;\n        case \"notelemetrystar\":\n            pageComp = <NoTelemetryStarPage isCompact={isCompact} />;\n            break;\n        case \"features\":\n            pageComp = <FeaturesPage />;\n            break;\n    }\n    if (pageComp == null) {\n        return null;\n    }\n\n    const paddingClass = isCompact ? \"!py-3 !px-[30px]\" : \"!p-[30px]\";\n    const widthClass = pageName === \"features\" ? \"w-[800px]\" : \"w-[560px]\";\n\n    return (\n        <FlexiModal className={`${widthClass} rounded-[10px] ${paddingClass} relative overflow-hidden`} ref={modalRef}>\n            <OnboardingGradientBg />\n            <div className=\"flex flex-col w-full h-full relative z-10\">{pageComp}</div>\n        </FlexiModal>\n    );\n};\n\nNewInstallOnboardingModal.displayName = \"NewInstallOnboardingModal\";\n\nexport { InitPage, NewInstallOnboardingModal, NoTelemetryStarPage };\n"
  },
  {
    "path": "frontend/app/reset.scss",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n@layer base {\n    *,\n    ::after,\n    ::before,\n    ::backdrop,\n    ::file-selector-button {\n        box-sizing: border-box;\n        border: 0 solid;\n    }\n\n    *,\n    ::after,\n    ::before,\n    ::backdrop,\n    ::file-selector-button {\n        margin: 0;\n        padding: 0;\n    }\n\n    html,\n    :host {\n        -webkit-text-size-adjust: 100%;\n        tab-size: 4;\n        font-feature-settings: var(--default-font-feature-settings, normal);\n        font-variation-settings: var(--default-font-variation-settings, normal);\n        -webkit-tap-highlight-color: transparent;\n    }\n\n    h1,\n    h2,\n    h3,\n    h4,\n    h5,\n    h6 {\n        font-size: inherit;\n        font-weight: inherit;\n    }\n\n    ol,\n    ul,\n    menu {\n        list-style: none;\n    }\n\n    img,\n    svg,\n    video,\n    canvas,\n    audio,\n    iframe,\n    embed,\n    object {\n        display: block;\n        // keep this vertical-align (applies if you change the display attribute)\n        vertical-align: middle;\n    }\n\n    hr {\n        height: 0;\n        color: inherit;\n        border-top-width: 1px;\n    }\n\n    b,\n    strong {\n        font-weight: bolder;\n    }\n\n    small {\n        font-size: 80%;\n    }\n\n    sub,\n    sup {\n        font-size: 75%;\n        line-height: 0;\n        position: relative;\n        vertical-align: baseline;\n    }\n\n    sub {\n        bottom: -0.25em;\n    }\n\n    sup {\n        top: -0.5em;\n    }\n\n    progress {\n        vertical-align: baseline;\n    }\n\n    abbr:where([title]) {\n        -webkit-text-decoration: underline dotted;\n        text-decoration: underline dotted;\n    }\n\n    summary {\n        display: list-item;\n    }\n\n    img,\n    video {\n        max-width: 100%;\n        height: auto;\n    }\n\n    :where(select:is([multiple], [size])) optgroup {\n        font-weight: bolder;\n    }\n\n    ::file-selector-button {\n        margin-inline-end: 4px;\n    }\n\n    ::placeholder {\n        opacity: 1; /* 1 */\n        color: color-mix(in oklab, currentColor 50%, transparent); /* 2 */\n    }\n\n    textarea {\n        resize: vertical;\n    }\n\n    ::-webkit-date-and-time-value {\n        min-height: 1lh;\n        text-align: inherit;\n    }\n\n    ::-webkit-datetime-edit {\n        display: inline-flex;\n    }\n\n    ::-webkit-datetime-edit-fields-wrapper {\n        padding: 0;\n    }\n\n    ::-webkit-datetime-edit,\n    ::-webkit-datetime-edit-year-field,\n    ::-webkit-datetime-edit-month-field,\n    ::-webkit-datetime-edit-day-field,\n    ::-webkit-datetime-edit-hour-field,\n    ::-webkit-datetime-edit-minute-field,\n    ::-webkit-datetime-edit-second-field,\n    ::-webkit-datetime-edit-millisecond-field,\n    ::-webkit-datetime-edit-meridiem-field {\n        padding-block: 0;\n    }\n\n    :-moz-ui-invalid {\n        box-shadow: none;\n    }\n\n    button,\n    input:where([type=\"button\"], [type=\"reset\"], [type=\"submit\"]),\n    ::file-selector-button {\n        appearance: button;\n    }\n\n    ::-webkit-inner-spin-button,\n    ::-webkit-outer-spin-button {\n        height: auto;\n    }\n\n    [hidden]:where(:not([hidden=\"until-found\"])) {\n        display: none !important;\n    }\n\n    table {\n        text-indent: 0;\n        border-color: inherit;\n        border-collapse: collapse;\n    }\n\n    body {\n        line-height: 1.2;\n        -webkit-font-smoothing: antialiased;\n    }\n\n    img,\n    picture,\n    video,\n    canvas,\n    svg {\n        display: block;\n    }\n\n    input,\n    button,\n    textarea,\n    select {\n        font: inherit;\n    }\n}\n"
  },
  {
    "path": "frontend/app/shadcn/lib/utils.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n//\n// This file is based on components from shadcn/ui, which is licensed under the MIT License.\n// Original source: https://github.com/shadcn/ui\n// Modifications made by Command Line Inc.\n\nimport { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n    return twMerge(clsx(inputs));\n}\n\nexport function formatDate(input: string | number): string {\n    const date = new Date(input);\n    return date.toLocaleDateString(\"en-US\", {\n        month: \"long\",\n        day: \"numeric\",\n        year: \"numeric\",\n    });\n}\n\nexport function absoluteUrl(path: string) {\n    return `${process.env.NEXT_PUBLIC_APP_URL}${path}`;\n}\n"
  },
  {
    "path": "frontend/app/store/badge.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { WaveEnv, WaveEnvSubset } from \"@/app/waveenv/waveenv\";\nimport { fireAndForget, NullAtom } from \"@/util/util\";\nimport { atom, Atom, PrimitiveAtom } from \"jotai\";\nimport { v7 as uuidv7, version as uuidVersion } from \"uuid\";\nimport { globalStore } from \"./jotaiStore\";\nimport * as WOS from \"./wos\";\nimport { waveEventSubscribeSingle } from \"./wps\";\n\nexport type BadgeEnv = WaveEnvSubset<{\n    rpc: {\n        EventPublishCommand: WaveEnv[\"rpc\"][\"EventPublishCommand\"];\n    };\n}>;\n\nexport type LoadBadgesEnv = WaveEnvSubset<{\n    rpc: {\n        GetAllBadgesCommand: WaveEnv[\"rpc\"][\"GetAllBadgesCommand\"];\n    };\n}>;\n\nexport type TabBadgesEnv = WaveEnvSubset<{\n    wos: WaveEnv[\"wos\"];\n}>;\n\nconst BadgeMap = new Map<string, PrimitiveAtom<Badge>>();\nconst TabBadgeAtomCache = new Map<string, Atom<Badge[]>>();\n\nfunction publishBadgeEvent(eventData: WaveEvent, env?: BadgeEnv) {\n    if (env != null) {\n        fireAndForget(() => env.rpc.EventPublishCommand(TabRpcClient, eventData));\n    } else {\n        fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));\n    }\n}\n\nfunction clearBadgeInternal(oref: string, env?: BadgeEnv) {\n    const eventData: WaveEvent = {\n        event: \"badge\",\n        scopes: [oref],\n        data: {\n            oref: oref,\n            clear: true,\n        } as BadgeEvent,\n    };\n    publishBadgeEvent(eventData, env);\n}\n\nfunction clearBadgesForBlockOnFocus(blockId: string, env?: BadgeEnv) {\n    const oref = WOS.makeORef(\"block\", blockId);\n    const badgeAtom = BadgeMap.get(oref);\n    const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null;\n    if (badge != null && !badge.pidlinked) {\n        clearBadgeInternal(oref, env);\n    }\n}\n\nfunction clearBadgesForTabOnFocus(tabId: string, env?: BadgeEnv) {\n    const oref = WOS.makeORef(\"tab\", tabId);\n    const badgeAtom = BadgeMap.get(oref);\n    const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null;\n    if (badge != null && !badge.pidlinked) {\n        clearBadgeInternal(oref, env);\n    }\n}\n\nfunction clearAllBadges(env?: BadgeEnv) {\n    const eventData: WaveEvent = {\n        event: \"badge\",\n        scopes: [],\n        data: {\n            oref: \"\",\n            clearall: true,\n        } as BadgeEvent,\n    };\n    publishBadgeEvent(eventData, env);\n}\n\nfunction clearBadgesForTab(tabId: string, env?: BadgeEnv) {\n    const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef(\"tab\", tabId));\n    const tab = globalStore.get(tabAtom);\n    const blockIds = (tab as Tab)?.blockids ?? [];\n    for (const blockId of blockIds) {\n        const oref = WOS.makeORef(\"block\", blockId);\n        const badgeAtom = BadgeMap.get(oref);\n        if (badgeAtom != null && globalStore.get(badgeAtom) != null) {\n            clearBadgeInternal(oref, env);\n        }\n    }\n}\n\nfunction getBadgeAtom(oref: string): PrimitiveAtom<Badge> {\n    if (oref == null) {\n        return NullAtom as PrimitiveAtom<Badge>;\n    }\n    let rtn = BadgeMap.get(oref);\n    if (rtn == null) {\n        rtn = atom(null) as PrimitiveAtom<Badge>;\n        BadgeMap.set(oref, rtn);\n    }\n    return rtn;\n}\n\nfunction getBlockBadgeAtom(blockId: string): Atom<Badge> {\n    if (blockId == null) {\n        return NullAtom as Atom<Badge>;\n    }\n    const oref = WOS.makeORef(\"block\", blockId);\n    return getBadgeAtom(oref);\n}\n\nfunction getTabBadgeAtom(tabId: string, env?: TabBadgesEnv): Atom<Badge[]> {\n    if (tabId == null) {\n        return NullAtom as Atom<Badge[]>;\n    }\n    let rtn = TabBadgeAtomCache.get(tabId);\n    if (rtn != null) {\n        return rtn;\n    }\n    const tabOref = WOS.makeORef(\"tab\", tabId);\n    const tabBadgeAtom = getBadgeAtom(tabOref);\n    const tabAtom = env != null ? env.wos.getWaveObjectAtom<Tab>(tabOref) : WOS.getWaveObjectAtom<Tab>(tabOref);\n    rtn = atom((get) => {\n        const tab = get(tabAtom);\n        const blockIds = tab?.blockids ?? [];\n        const badges: Badge[] = [];\n        for (const blockId of blockIds) {\n            const badge = get(getBadgeAtom(WOS.makeORef(\"block\", blockId)));\n            if (badge != null) {\n                badges.push(badge);\n            }\n        }\n        const tabBadge = get(tabBadgeAtom);\n        if (tabBadge != null) {\n            badges.push(tabBadge);\n        }\n        return sortBadgesForTab(badges);\n    });\n    TabBadgeAtomCache.set(tabId, rtn);\n    return rtn;\n}\n\nasync function loadBadges(env?: LoadBadgesEnv) {\n    const rpc = env != null ? env.rpc : RpcApi;\n    const badges = await rpc.GetAllBadgesCommand(TabRpcClient);\n    if (badges == null) {\n        return;\n    }\n    for (const badgeEvent of badges) {\n        if (badgeEvent.oref == null) {\n            continue;\n        }\n        const curAtom = getBadgeAtom(badgeEvent.oref);\n        globalStore.set(curAtom, badgeEvent.badge ?? null);\n    }\n}\n\nfunction setBadge(blockId: string, badge: Omit<Badge, \"badgeid\"> & { badgeid?: string }, env?: BadgeEnv) {\n    if (!badge.badgeid) {\n        badge = { ...badge, badgeid: uuidv7() };\n    } else if (uuidVersion(badge.badgeid) !== 7) {\n        throw new Error(`setBadge: badgeid must be a v7 UUID, got version ${uuidVersion(badge.badgeid)}`);\n    }\n    const oref = WOS.makeORef(\"block\", blockId);\n    const eventData: WaveEvent = {\n        event: \"badge\",\n        scopes: [oref],\n        data: {\n            oref: oref,\n            badge: badge,\n        } as BadgeEvent,\n    };\n    publishBadgeEvent(eventData, env);\n}\n\nfunction clearBadgeById(blockId: string, badgeId: string, env?: BadgeEnv) {\n    const oref = WOS.makeORef(\"block\", blockId);\n    const eventData: WaveEvent = {\n        event: \"badge\",\n        scopes: [oref],\n        data: {\n            oref: oref,\n            clearbyid: badgeId,\n        } as BadgeEvent,\n    };\n    publishBadgeEvent(eventData, env);\n}\n\nfunction setupBadgesSubscription() {\n    waveEventSubscribeSingle({\n        eventType: \"badge\",\n        handler: (event) => {\n            const data = event.data;\n            if (data?.clearall) {\n                for (const atom of BadgeMap.values()) {\n                    globalStore.set(atom, null);\n                }\n                return;\n            }\n            if (data?.oref == null) {\n                return;\n            }\n            const curAtom = getBadgeAtom(data.oref);\n            if (data.clearbyid) {\n                const existing = globalStore.get(curAtom);\n                if (existing?.badgeid === data.clearbyid) {\n                    globalStore.set(curAtom, null);\n                }\n                return;\n            }\n            if (data.clear) {\n                globalStore.set(curAtom, null);\n                return;\n            }\n            if (data.badge == null) {\n                return;\n            }\n            const existing = globalStore.get(curAtom);\n            if (existing == null || cmpBadge(data.badge, existing) > 0) {\n                globalStore.set(curAtom, data.badge);\n            }\n        },\n    });\n}\n\nfunction cmpBadge(a: Badge, b: Badge): number {\n    if (a.priority !== b.priority) {\n        return a.priority > b.priority ? 1 : -1;\n    }\n    if (a.badgeid !== b.badgeid) {\n        return a.badgeid > b.badgeid ? 1 : -1;\n    }\n    return 0;\n}\n\nfunction sortBadges(badges: Badge[]): Badge[] {\n    return [...badges].sort((a, b) => cmpBadge(b, a));\n}\n\nfunction sortBadgesForTab(badges: Badge[]): Badge[] {\n    return [...badges].sort((a, b) => {\n        if (a.priority !== b.priority) {\n            return b.priority - a.priority;\n        }\n        return a.badgeid < b.badgeid ? -1 : a.badgeid > b.badgeid ? 1 : 0;\n    });\n}\n\nexport {\n    clearAllBadges,\n    clearBadgeById,\n    clearBadgesForBlockOnFocus,\n    clearBadgesForTab,\n    clearBadgesForTabOnFocus,\n    getBadgeAtom,\n    getBlockBadgeAtom,\n    getTabBadgeAtom,\n    loadBadges,\n    setBadge,\n    setupBadgesSubscription,\n    sortBadges,\n    sortBadgesForTab,\n};\n"
  },
  {
    "path": "frontend/app/store/client-model.ts",
    "content": "// Copyright 2026, Command Line Inc\n// SPDX-License-Identifier: Apache-2.0\n\nimport * as WOS from \"@/app/store/wos\";\nimport { atom, Atom } from \"jotai\";\n\nclass ClientModel {\n    private static instance: ClientModel;\n\n    clientId: string;\n    clientAtom!: Atom<Client>;\n\n    private constructor() {\n        // private constructor for singleton pattern\n    }\n\n    static getInstance(): ClientModel {\n        if (!ClientModel.instance) {\n            ClientModel.instance = new ClientModel();\n        }\n        return ClientModel.instance;\n    }\n\n    initialize(clientId: string): void {\n        this.clientId = clientId;\n\n        this.clientAtom = atom((get) => {\n            if (this.clientId == null) {\n                return null;\n            }\n            return WOS.getObjectValue(WOS.makeORef(\"client\", this.clientId), get);\n        });\n    }\n}\n\nexport { ClientModel };\n"
  },
  {
    "path": "frontend/app/store/connections-model.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { isWindows } from \"@/util/platformutil\";\nimport { atom, type Atom, type PrimitiveAtom } from \"jotai\";\nimport { globalStore } from \"./jotaiStore\";\n\nclass ConnectionsModel {\n    private static instance: ConnectionsModel;\n    gitBashPathAtom: PrimitiveAtom<string> = atom(\"\") as PrimitiveAtom<string>;\n    hasGitBashAtom: Atom<boolean>;\n\n    private constructor() {\n        this.hasGitBashAtom = atom((get) => {\n            if (!isWindows()) {\n                return false;\n            }\n            const path = get(this.gitBashPathAtom);\n            return path !== \"\";\n        });\n        this.loadGitBashPath();\n    }\n\n    static getInstance(): ConnectionsModel {\n        if (!ConnectionsModel.instance) {\n            ConnectionsModel.instance = new ConnectionsModel();\n        }\n        return ConnectionsModel.instance;\n    }\n\n    async loadGitBashPath(rescan: boolean = false): Promise<void> {\n        if (!isWindows()) {\n            return;\n        }\n        try {\n            const path = await RpcApi.FindGitBashCommand(TabRpcClient, rescan, { timeout: 2000 });\n            globalStore.set(this.gitBashPathAtom, path);\n        } catch (error) {\n            console.error(\"Failed to find git bash path:\", error);\n            globalStore.set(this.gitBashPathAtom, \"\");\n        }\n    }\n\n    getGitBashPath(): string {\n        return globalStore.get(this.gitBashPathAtom);\n    }\n}\n\nexport { ConnectionsModel };\n"
  },
  {
    "path": "frontend/app/store/contextmenu.test.ts",
    "content": "import { describe, expect, it, vi } from \"vitest\";\n\ndescribe(\"ContextMenuModel\", () => {\n    it(\"initializes only when getInstance is called\", async () => {\n        let contextMenuCallback: (id: string | null) => void;\n        const onContextMenuClick = vi.fn();\n        onContextMenuClick.mockImplementation((callback) => {\n            contextMenuCallback = callback;\n        });\n        const getApi = vi.fn(() => ({\n            onContextMenuClick,\n            showContextMenu: vi.fn(),\n        }));\n\n        vi.resetModules();\n        vi.doMock(\"./global\", () => ({\n            atoms: {},\n            getApi,\n            globalStore: { get: vi.fn() },\n        }));\n\n        const { ContextMenuModel } = await import(\"./contextmenu\");\n        expect(getApi).not.toHaveBeenCalled();\n\n        const firstInstance = ContextMenuModel.getInstance();\n        const secondInstance = ContextMenuModel.getInstance();\n\n        expect(firstInstance).toBe(secondInstance);\n        expect(getApi).toHaveBeenCalledTimes(1);\n        expect(onContextMenuClick).toHaveBeenCalledTimes(1);\n        expect(contextMenuCallback).toBeTypeOf(\"function\");\n    });\n\n    it(\"runs select and close callbacks after item handler\", async () => {\n        let contextMenuCallback: (id: string | null) => void;\n        const showContextMenu = vi.fn();\n        const onContextMenuClick = vi.fn((callback) => {\n            contextMenuCallback = callback;\n        });\n        const getApi = vi.fn(() => ({\n            onContextMenuClick,\n            showContextMenu,\n        }));\n        const workspace = { oid: \"workspace-1\" };\n\n        vi.resetModules();\n        vi.doMock(\"./global\", () => ({\n            atoms: { workspace: \"workspace\", builderId: \"builderId\" },\n            getApi,\n            globalStore: {\n                get: vi.fn((atom) => {\n                    if (atom === \"workspace\") {\n                        return workspace;\n                    }\n                    return \"builder-1\";\n                }),\n            },\n        }));\n\n        const { ContextMenuModel } = await import(\"./contextmenu\");\n        const model = ContextMenuModel.getInstance();\n        const order: string[] = [];\n        const itemClick = vi.fn(() => {\n            order.push(\"item\");\n        });\n        const onSelect = vi.fn((item) => {\n            order.push(`select:${item.label}`);\n        });\n        const onClose = vi.fn((item) => {\n            order.push(`close:${item?.label ?? \"null\"}`);\n        });\n\n        model.showContextMenu(\n            [{ label: \"Open\", click: itemClick }],\n            { stopPropagation: vi.fn() } as any,\n            { onSelect, onClose }\n        );\n        const menuId = showContextMenu.mock.calls[0][1][0].id;\n        contextMenuCallback(menuId);\n\n        expect(order).toEqual([\"item\", \"select:Open\", \"close:Open\"]);\n        expect(itemClick).toHaveBeenCalledTimes(1);\n        expect(onSelect).toHaveBeenCalledTimes(1);\n        expect(onClose).toHaveBeenCalledTimes(1);\n    });\n\n    it(\"runs cancel and close callbacks when no item is selected\", async () => {\n        let contextMenuCallback: (id: string | null) => void;\n        const showContextMenu = vi.fn();\n        const onContextMenuClick = vi.fn((callback) => {\n            contextMenuCallback = callback;\n        });\n        const getApi = vi.fn(() => ({\n            onContextMenuClick,\n            showContextMenu,\n        }));\n        const workspace = { oid: \"workspace-1\" };\n\n        vi.resetModules();\n        vi.doMock(\"./global\", () => ({\n            atoms: { workspace: \"workspace\", builderId: \"builderId\" },\n            getApi,\n            globalStore: {\n                get: vi.fn((atom) => {\n                    if (atom === \"workspace\") {\n                        return workspace;\n                    }\n                    return \"builder-1\";\n                }),\n            },\n        }));\n\n        const { ContextMenuModel } = await import(\"./contextmenu\");\n        const model = ContextMenuModel.getInstance();\n        const order: string[] = [];\n        const onCancel = vi.fn(() => {\n            order.push(\"cancel\");\n        });\n        const onClose = vi.fn((item) => {\n            order.push(`close:${item == null ? \"null\" : item.label}`);\n        });\n\n        model.showContextMenu(\n            [{ label: \"Open\", click: vi.fn() }],\n            { stopPropagation: vi.fn() } as any,\n            { onCancel, onClose }\n        );\n        contextMenuCallback(null);\n\n        expect(order).toEqual([\"cancel\", \"close:null\"]);\n        expect(onCancel).toHaveBeenCalledTimes(1);\n        expect(onClose).toHaveBeenCalledTimes(1);\n    });\n});\n"
  },
  {
    "path": "frontend/app/store/contextmenu.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { atoms, getApi, globalStore } from \"./global\";\n\ntype ShowContextMenuOpts = {\n    onSelect?: (item: ContextMenuItem) => void;\n    onCancel?: () => void;\n    onClose?: (item: ContextMenuItem | null) => void;\n};\n\nclass ContextMenuModel {\n    private static instance: ContextMenuModel;\n    handlers: Map<string, ContextMenuItem> = new Map(); // id -> item\n    activeOpts: ShowContextMenuOpts | null = null;\n\n    private constructor() {\n        getApi().onContextMenuClick(this.handleContextMenuClick.bind(this));\n    }\n\n    static getInstance(): ContextMenuModel {\n        if (ContextMenuModel.instance == null) {\n            ContextMenuModel.instance = new ContextMenuModel();\n        }\n        return ContextMenuModel.instance;\n    }\n\n    handleContextMenuClick(id: string | null): void {\n        const opts = this.activeOpts;\n        this.activeOpts = null;\n        const item = id != null ? this.handlers.get(id) : null;\n        this.handlers.clear();\n        if (item == null) {\n            opts?.onCancel?.();\n            opts?.onClose?.(null);\n            return;\n        }\n        item.click?.();\n        opts?.onSelect?.(item);\n        opts?.onClose?.(item);\n    }\n\n    _convertAndRegisterMenu(menu: ContextMenuItem[]): ElectronContextMenuItem[] {\n        const electronMenuItems: ElectronContextMenuItem[] = [];\n        for (const item of menu) {\n            const electronItem: ElectronContextMenuItem = {\n                role: item.role,\n                type: item.type,\n                label: item.label,\n                sublabel: item.sublabel,\n                id: crypto.randomUUID(),\n                checked: item.checked,\n            };\n            if (item.visible === false) {\n                electronItem.visible = false;\n            }\n            if (item.enabled === false) {\n                electronItem.enabled = false;\n            }\n            if (item.click) {\n                this.handlers.set(electronItem.id, item);\n            }\n            if (item.submenu) {\n                electronItem.submenu = this._convertAndRegisterMenu(item.submenu);\n            }\n            electronMenuItems.push(electronItem);\n        }\n        return electronMenuItems;\n    }\n\n    showContextMenu(menu: ContextMenuItem[], ev: React.MouseEvent<any>, opts?: ShowContextMenuOpts): void {\n        ev.stopPropagation();\n        this.handlers.clear();\n        this.activeOpts = opts;\n        const electronMenuItems = this._convertAndRegisterMenu(menu);\n        \n        const workspaceId = globalStore.get(atoms.workspaceId);\n        let oid: string;\n        \n        if (workspaceId != null) {\n            oid = workspaceId;\n        } else {\n            oid = globalStore.get(atoms.builderId);\n        }\n        \n        getApi().showContextMenu(oid, electronMenuItems);\n    }\n}\n\nexport { ContextMenuModel };\n"
  },
  {
    "path": "frontend/app/store/counters.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nconst Counters = new Map<string, number>();\n\nfunction countersClear() {\n    Counters.clear();\n}\n\nfunction counterInc(name: string, incAmt: number = 1) {\n    let count = Counters.get(name) ?? 0;\n    count += incAmt;\n    Counters.set(name, count);\n}\n\nfunction countersPrint() {\n    let outStr = \"\";\n    for (const [name, count] of Counters.entries()) {\n        outStr += `${name}: ${count}\\n`;\n    }\n    console.log(outStr);\n}\n\nexport { counterInc, countersClear, countersPrint };\n"
  },
  {
    "path": "frontend/app/store/focusManager.ts",
    "content": "import { waveAIHasFocusWithin } from \"@/app/aipanel/waveai-focus-utils\";\nimport { WaveAIModel } from \"@/app/aipanel/waveai-model\";\nimport { atoms, getBlockComponentModel } from \"@/app/store/global\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { focusedBlockId } from \"@/util/focusutil\";\nimport { getLayoutModelForStaticTab } from \"@/layout/index\";\nimport { Atom, atom, type PrimitiveAtom } from \"jotai\";\n\nexport type FocusStrType = \"node\" | \"waveai\";\n\nexport class FocusManager {\n    private static instance: FocusManager | null = null;\n\n    focusType: PrimitiveAtom<FocusStrType> = atom(\"node\");\n    blockFocusAtom: Atom<string | null>;\n\n    private constructor() {\n        this.blockFocusAtom = atom((get) => {\n            if (get(this.focusType) == \"waveai\") {\n                return null;\n            }\n            const layoutModel = getLayoutModelForStaticTab();\n            const lnode = get(layoutModel.focusedNode);\n            return lnode?.data?.blockId;\n        });\n    }\n\n    static getInstance(): FocusManager {\n        if (!FocusManager.instance) {\n            FocusManager.instance = new FocusManager();\n        }\n        return FocusManager.instance;\n    }\n\n    setWaveAIFocused(force: boolean = false) {\n        const isAlreadyFocused = globalStore.get(this.focusType) == \"waveai\";\n        if (!force && isAlreadyFocused) {\n            return;\n        }\n        globalStore.set(this.focusType, \"waveai\");\n        this.refocusNode();\n    }\n\n    setBlockFocus(force: boolean = false) {\n        const ftype = globalStore.get(this.focusType);\n        if (!force && ftype == \"node\") {\n            return;\n        }\n        globalStore.set(this.focusType, \"node\");\n        this.refocusNode();\n    }\n\n    waveAIFocusWithin(): boolean {\n        return waveAIHasFocusWithin();\n    }\n\n    nodeFocusWithin(): boolean {\n        return focusedBlockId() != null;\n    }\n\n    requestNodeFocus(): void {\n        globalStore.set(this.focusType, \"node\");\n    }\n\n    requestWaveAIFocus(): void {\n        globalStore.set(this.focusType, \"waveai\");\n    }\n\n    getFocusType(): FocusStrType {\n        return globalStore.get(this.focusType);\n    }\n\n    refocusNode() {\n        const ftype = globalStore.get(this.focusType);\n        if (ftype == \"waveai\") {\n            WaveAIModel.getInstance().focusInput();\n            return;\n        }\n        const layoutModel = getLayoutModelForStaticTab();\n        const lnode = globalStore.get(layoutModel.focusedNode);\n        if (lnode == null || lnode.data?.blockId == null) {\n            return;\n        }\n        layoutModel.focusNode(lnode.id);\n        const blockId = lnode.data.blockId;\n        const bcm = getBlockComponentModel(blockId);\n        const ok = bcm?.viewModel?.giveFocus?.();\n        if (!ok) {\n            const inputElem = document.getElementById(`${blockId}-dummy-focus`);\n            inputElem?.focus();\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/store/global-atoms.test.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { describe, expect, it } from \"vitest\";\nimport { getAtoms } from \"./global-atoms\";\n\ndescribe(\"global-atoms\", () => {\n    it(\"throws before initialization\", () => {\n        expect(() => getAtoms()).toThrow(\"Global atoms accessed before initialization\");\n    });\n});\n"
  },
  {
    "path": "frontend/app/store/global-atoms.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { atom, Atom, PrimitiveAtom } from \"jotai\";\nimport { globalStore } from \"./jotaiStore\";\nimport { setWaveWindowType } from \"./windowtype\";\nimport * as WOS from \"./wos\";\n\nlet atoms!: GlobalAtomsType;\nconst blockComponentModelMap = new Map<string, BlockComponentModel>();\nconst ConnStatusMapAtom = atom(new Map<string, PrimitiveAtom<ConnStatus>>());\nconst orefAtomCache = new Map<string, Map<string, Atom<any>>>();\n\nfunction initGlobalAtoms(initOpts: GlobalInitOptions) {\n    const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom<string>;\n    const builderIdAtom = atom(initOpts.builderId) as PrimitiveAtom<string>;\n    const builderAppIdAtom = atom<string>(null) as PrimitiveAtom<string>;\n    setWaveWindowType(initOpts.isPreview ? \"preview\" : initOpts.builderId != null ? \"builder\" : \"tab\");\n    const uiContextAtom = atom((get) => {\n        const uiContext: UIContext = {\n            windowid: initOpts.windowId,\n            activetabid: initOpts.tabId,\n        };\n        return uiContext;\n    }) as Atom<UIContext>;\n\n    const isFullScreenAtom = atom(false) as PrimitiveAtom<boolean>;\n    try {\n        getApi().onFullScreenChange((isFullScreen) => {\n            globalStore.set(isFullScreenAtom, isFullScreen);\n        });\n    } catch (e) {\n        console.log(\"failed to initialize isFullScreenAtom\", e);\n    }\n\n    const zoomFactorAtom = atom(1.0) as PrimitiveAtom<number>;\n    try {\n        globalStore.set(zoomFactorAtom, getApi().getZoomFactor());\n        getApi().onZoomFactorChange((zoomFactor) => {\n            globalStore.set(zoomFactorAtom, zoomFactor);\n        });\n    } catch (e) {\n        console.log(\"failed to initialize zoomFactorAtom\", e);\n    }\n\n    const workspaceIdAtom: Atom<string> = atom((get) => {\n        const windowData = WOS.getObjectValue<WaveWindow>(WOS.makeORef(\"window\", get(windowIdAtom)), get);\n        return windowData?.workspaceid ?? null;\n    });\n    const workspaceAtom: Atom<Workspace> = atom((get) => {\n        const workspaceId = get(workspaceIdAtom);\n        if (workspaceId == null) {\n            return null;\n        }\n        return WOS.getObjectValue(WOS.makeORef(\"workspace\", workspaceId), get);\n    });\n    const fullConfigAtom = atom(null) as PrimitiveAtom<FullConfigType>;\n    const waveaiModeConfigAtom = atom(null) as PrimitiveAtom<Record<string, AIModeConfigType>>;\n    const settingsAtom = atom((get) => {\n        return get(fullConfigAtom)?.settings ?? {};\n    }) as Atom<SettingsType>;\n    const hasCustomAIPresetsAtom = atom((get) => {\n        const fullConfig = get(fullConfigAtom);\n        if (!fullConfig?.presets) {\n            return false;\n        }\n        for (const presetId in fullConfig.presets) {\n            if (presetId.startsWith(\"ai@\") && presetId !== \"ai@global\" && presetId !== \"ai@wave\") {\n                return true;\n            }\n        }\n        return false;\n    }) as Atom<boolean>;\n    const hasConfigErrors = atom((get) => {\n        const fullConfig = get(fullConfigAtom);\n        return fullConfig?.configerrors != null && fullConfig.configerrors.length > 0;\n    }) as Atom<boolean>;\n    // this is *the* tab that this tabview represents.  it should never change.\n    const staticTabIdAtom: Atom<string> = atom(initOpts.tabId);\n    const controlShiftDelayAtom = atom(false);\n    const updaterStatusAtom = atom<UpdaterStatus>(\"up-to-date\") as PrimitiveAtom<UpdaterStatus>;\n    try {\n        globalStore.set(updaterStatusAtom, getApi().getUpdaterStatus());\n        getApi().onUpdaterStatusChange((status) => {\n            globalStore.set(updaterStatusAtom, status);\n        });\n    } catch (e) {\n        console.log(\"failed to initialize updaterStatusAtom\", e);\n    }\n\n    const reducedMotionSettingAtom = atom((get) => get(settingsAtom)?.[\"window:reducedmotion\"]);\n    const reducedMotionSystemPreferenceAtom = atom(false);\n\n    // Composite of the prefers-reduced-motion media query and the window:reducedmotion user setting.\n    const prefersReducedMotionAtom = atom((get) => {\n        const reducedMotionSetting = get(reducedMotionSettingAtom);\n        const reducedMotionSystemPreference = get(reducedMotionSystemPreferenceAtom);\n        return reducedMotionSetting || reducedMotionSystemPreference;\n    });\n\n    // Set up a handler for changes to the prefers-reduced-motion media query.\n    if (globalThis.window != null) {\n        const reducedMotionQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n        globalStore.set(reducedMotionSystemPreferenceAtom, !reducedMotionQuery || reducedMotionQuery.matches);\n        reducedMotionQuery?.addEventListener(\"change\", () => {\n            globalStore.set(reducedMotionSystemPreferenceAtom, reducedMotionQuery.matches);\n        });\n    }\n\n    const documentHasFocusAtom = atom(true) as PrimitiveAtom<boolean>;\n    if (globalThis.window != null) {\n        globalStore.set(documentHasFocusAtom, document.hasFocus());\n        window.addEventListener(\"focus\", () => {\n            globalStore.set(documentHasFocusAtom, true);\n        });\n        window.addEventListener(\"blur\", () => {\n            globalStore.set(documentHasFocusAtom, false);\n        });\n    }\n\n    const modalOpen = atom(false);\n    const allConnStatusAtom = atom<ConnStatus[]>((get) => {\n        const connStatusMap = get(ConnStatusMapAtom);\n        const connStatuses = Array.from(connStatusMap.values()).map((atom) => get(atom));\n        return connStatuses;\n    });\n    const reinitVersion = atom(0);\n    const rateLimitInfoAtom = atom(null) as PrimitiveAtom<RateLimitInfo>;\n    atoms = {\n        // initialized in wave.ts (will not be null inside of application)\n        builderId: builderIdAtom,\n        builderAppId: builderAppIdAtom,\n        uiContext: uiContextAtom,\n        workspaceId: workspaceIdAtom,\n        workspace: workspaceAtom,\n        fullConfigAtom,\n        waveaiModeConfigAtom,\n        settingsAtom,\n        hasCustomAIPresetsAtom,\n        hasConfigErrors,\n        staticTabId: staticTabIdAtom,\n        isFullScreen: isFullScreenAtom,\n        zoomFactorAtom,\n        controlShiftDelayAtom,\n        updaterStatusAtom,\n        prefersReducedMotionAtom,\n        documentHasFocus: documentHasFocusAtom,\n        modalOpen,\n        allConnStatus: allConnStatusAtom,\n        reinitVersion,\n        waveAIRateLimitInfoAtom: rateLimitInfoAtom,\n    } as GlobalAtomsType;\n}\n\nfunction getAtoms(): GlobalAtomsType {\n    if (atoms == null) {\n        throw new Error(\"Global atoms accessed before initialization\");\n    }\n    return atoms;\n}\n\nfunction getApi(): ElectronApi {\n    return (window as any).api;\n}\n\nexport { atoms, blockComponentModelMap, ConnStatusMapAtom, getAtoms, initGlobalAtoms, orefAtomCache };\n"
  },
  {
    "path": "frontend/app/store/global-model.ts",
    "content": "// Copyright 2025, Command Line Inc\n// SPDX-License-Identifier: Apache-2.0\n\nimport * as WOS from \"@/app/store/wos\";\nimport { ClientModel } from \"@/app/store/client-model\";\nimport { getApi } from \"@/store/global\";\nimport * as util from \"@/util/util\";\nimport { atom, Atom } from \"jotai\";\n\nclass GlobalModel {\n    private static instance: GlobalModel;\n    static readonly IsActiveThrottleMs = 5000;\n\n    windowId: string;\n    builderId: string;\n    platform: NodeJS.Platform;\n    lastSetIsActiveTs = 0;\n\n    windowDataAtom!: Atom<WaveWindow>;\n    workspaceAtom!: Atom<Workspace>;\n\n    private constructor() {\n        // private constructor for singleton pattern\n    }\n\n    static getInstance(): GlobalModel {\n        if (!GlobalModel.instance) {\n            GlobalModel.instance = new GlobalModel();\n        }\n        return GlobalModel.instance;\n    }\n\n    async initialize(initOpts: GlobalInitOptions): Promise<void> {\n        ClientModel.getInstance().initialize(initOpts.clientId);\n        this.windowId = initOpts.windowId;\n        this.builderId = initOpts.builderId;\n        this.platform = initOpts.platform;\n\n        this.windowDataAtom = atom((get) => {\n            if (this.windowId == null) {\n                return null;\n            }\n            return WOS.getObjectValue<WaveWindow>(WOS.makeORef(\"window\", this.windowId), get);\n        });\n\n        this.workspaceAtom = atom((get) => {\n            const windowData = get(this.windowDataAtom);\n            if (windowData == null) {\n                return null;\n            }\n            return WOS.getObjectValue(WOS.makeORef(\"workspace\", windowData.workspaceid), get);\n        });\n    }\n\n    setIsActive(): void {\n        const now = Date.now();\n        if (now - this.lastSetIsActiveTs < GlobalModel.IsActiveThrottleMs) {\n            return;\n        }\n        this.lastSetIsActiveTs = now;\n        util.fireAndForget(() => getApi().setIsActive());\n    }\n}\n\nexport { GlobalModel };\n"
  },
  {
    "path": "frontend/app/store/global.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport {\n    getLayoutModelForStaticTab,\n    LayoutTreeActionType,\n    LayoutTreeInsertNodeAction,\n    newLayoutNode,\n} from \"@/layout/index\";\nimport {\n    LayoutTreeReplaceNodeAction,\n    LayoutTreeSplitHorizontalAction,\n    LayoutTreeSplitVerticalAction,\n} from \"@/layout/lib/types\";\nimport { getWebServerEndpoint } from \"@/util/endpoints\";\nimport { fetch } from \"@/util/fetchutil\";\nimport { setPlatform } from \"@/util/platformutil\";\nimport {\n    base64ToString,\n    deepCompareReturnPrev,\n    fireAndForget,\n    getPrefixedSettings,\n    isBlank,\n    isLocalConnName,\n    isWslConnName,\n    NullAtom,\n} from \"@/util/util\";\nimport { atom, Atom, PrimitiveAtom, useAtomValue } from \"jotai\";\nimport { setupBadgesSubscription } from \"./badge\";\nimport { atoms, blockComponentModelMap, ConnStatusMapAtom, initGlobalAtoms, orefAtomCache } from \"./global-atoms\";\nimport { globalStore } from \"./jotaiStore\";\nimport { modalsModel } from \"./modalmodel\";\nimport { ClientService, ObjectService } from \"./services\";\nimport { isPreviewWindow } from \"./windowtype\";\nimport * as WOS from \"./wos\";\nimport { getFileSubject, waveEventSubscribeSingle } from \"./wps\";\n\nlet globalPrimaryTabStartup: boolean = false;\n\nfunction initGlobal(initOpts: GlobalInitOptions) {\n    globalPrimaryTabStartup = initOpts.primaryTabStartup ?? false;\n    setPlatform(initOpts.platform);\n    initGlobalAtoms(initOpts);\n    try {\n        getApi().onMenuItemAbout(() => {\n            modalsModel.pushModal(\"AboutModal\");\n        });\n    } catch (e) {\n        console.log(\"failed to initialize onMenuItemAbout handler\", e);\n    }\n}\n\nfunction initGlobalWaveEventSubs(initOpts: WaveInitOpts) {\n    waveEventSubscribeSingle({\n        eventType: \"waveobj:update\",\n        handler: (event) => {\n            // console.log(\"waveobj:update wave event handler\", event);\n            WOS.updateWaveObject(event.data);\n        },\n    });\n    waveEventSubscribeSingle({\n        eventType: \"config\",\n        handler: (event) => {\n            // console.log(\"config wave event handler\", event);\n            globalStore.set(atoms.fullConfigAtom, event.data.fullconfig);\n        },\n    });\n    waveEventSubscribeSingle({\n        eventType: \"waveai:modeconfig\",\n        handler: (event) => {\n            globalStore.set(atoms.waveaiModeConfigAtom, event.data.configs);\n        },\n    });\n    waveEventSubscribeSingle({\n        eventType: \"userinput\",\n        handler: (event) => {\n            // console.log(\"userinput event handler\", event);\n            modalsModel.pushModal(\"UserInputModal\", { ...event.data });\n        },\n        scope: initOpts.windowId,\n    });\n    waveEventSubscribeSingle({\n        eventType: \"blockfile\",\n        handler: (event) => {\n            // console.log(\"blockfile event update\", event);\n            const fileSubject = getFileSubject(event.data.zoneid, event.data.filename);\n            if (fileSubject != null) {\n                fileSubject.next(event.data);\n            }\n        },\n    });\n    waveEventSubscribeSingle({\n        eventType: \"waveai:ratelimit\",\n        handler: (event) => {\n            globalStore.set(atoms.waveAIRateLimitInfoAtom, event.data);\n        },\n    });\n    setupBadgesSubscription();\n}\n\nconst blockCache = new Map<string, Map<string, any>>();\n\nfunction useBlockCache<T>(blockId: string, name: string, makeFn: () => T): T {\n    let blockMap = blockCache.get(blockId);\n    if (blockMap == null) {\n        blockMap = new Map<string, any>();\n        blockCache.set(blockId, blockMap);\n    }\n    let value = blockMap.get(name);\n    if (value == null) {\n        value = makeFn();\n        blockMap.set(name, value);\n    }\n    return value as T;\n}\n\nfunction getBlockMetaKeyAtom<T extends keyof MetaType>(blockId: string, key: T): Atom<MetaType[T]> {\n    const blockCache = getSingleBlockAtomCache(blockId);\n    const metaAtomName = \"#meta-\" + key;\n    let metaAtom = blockCache.get(metaAtomName);\n    if (metaAtom != null) {\n        return metaAtom;\n    }\n    metaAtom = atom((get) => {\n        const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef(\"block\", blockId));\n        const blockData = get(blockAtom);\n        return blockData?.meta?.[key];\n    });\n    blockCache.set(metaAtomName, metaAtom);\n    return metaAtom;\n}\n\nfunction getOrefMetaKeyAtom<T extends keyof MetaType>(oref: string, key: T): Atom<MetaType[T]> {\n    const orefCache = getSingleOrefAtomCache(oref);\n    const metaAtomName = \"#meta-\" + key;\n    let metaAtom = orefCache.get(metaAtomName);\n    if (metaAtom != null) {\n        return metaAtom;\n    }\n    metaAtom = atom((get) => {\n        const objAtom = WOS.getWaveObjectAtom(oref);\n        const objData = get(objAtom);\n        return objData?.meta?.[key];\n    });\n    orefCache.set(metaAtomName, metaAtom);\n    return metaAtom;\n}\n\nfunction useOrefMetaKeyAtom<T extends keyof MetaType>(oref: string, key: T): MetaType[T] {\n    return useAtomValue(getOrefMetaKeyAtom(oref, key));\n}\n\nfunction getConnConfigKeyAtom<T extends keyof ConnKeywords>(connName: string, key: T): Atom<ConnKeywords[T]> {\n    if (isPreviewWindow()) return NullAtom as Atom<ConnKeywords[T]>;\n    const connCache = getSingleConnAtomCache(connName);\n    const keyAtomName = \"#conn-\" + key;\n    let keyAtom = connCache.get(keyAtomName);\n    if (keyAtom != null) {\n        return keyAtom;\n    }\n    keyAtom = atom((get) => {\n        const fullConfig = get(atoms.fullConfigAtom);\n        return fullConfig.connections?.[connName]?.[key];\n    });\n    connCache.set(keyAtomName, keyAtom);\n    return keyAtom;\n}\n\nconst settingsAtomCache = new Map<string, Atom<any>>();\n\nfunction getOverrideConfigAtom<T extends keyof SettingsType>(blockId: string, key: T): Atom<SettingsType[T]> {\n    if (isPreviewWindow()) return NullAtom as Atom<SettingsType[T]>;\n    const blockCache = getSingleBlockAtomCache(blockId);\n    const overrideAtomName = \"#settingsoverride-\" + key;\n    let overrideAtom = blockCache.get(overrideAtomName);\n    if (overrideAtom != null) {\n        return overrideAtom;\n    }\n    overrideAtom = atom((get) => {\n        const blockMetaKeyAtom = getBlockMetaKeyAtom(blockId, key as any);\n        const metaKeyVal = get(blockMetaKeyAtom);\n        if (metaKeyVal != null) {\n            return metaKeyVal;\n        }\n        const connNameAtom = getBlockMetaKeyAtom(blockId, \"connection\");\n        const connName = get(connNameAtom);\n        const connConfigKeyAtom = getConnConfigKeyAtom(connName, key as any);\n        const connConfigKeyVal = get(connConfigKeyAtom);\n        if (connConfigKeyVal != null) {\n            return connConfigKeyVal;\n        }\n        const settingsKeyAtom = getSettingsKeyAtom(key);\n        const settingsVal = get(settingsKeyAtom);\n        if (settingsVal != null) {\n            return settingsVal;\n        }\n        return null;\n    });\n    blockCache.set(overrideAtomName, overrideAtom);\n    return overrideAtom;\n}\n\nfunction useOverrideConfigAtom<T extends keyof SettingsType>(blockId: string | null, key: T): SettingsType[T] {\n    if (blockId == null) {\n        return useAtomValue(getSettingsKeyAtom(key));\n    }\n    return useAtomValue(getOverrideConfigAtom(blockId, key));\n}\n\nfunction getSettingsKeyAtom<T extends keyof SettingsType>(key: T): Atom<SettingsType[T]> {\n    if (isPreviewWindow()) return NullAtom as Atom<SettingsType[T]>;\n    let settingsKeyAtom = settingsAtomCache.get(key) as Atom<SettingsType[T]>;\n    if (settingsKeyAtom == null) {\n        settingsKeyAtom = atom((get) => {\n            const settings = get(atoms.settingsAtom);\n            if (settings == null) {\n                return null;\n            }\n            return settings[key];\n        });\n        settingsAtomCache.set(key, settingsKeyAtom);\n    }\n    return settingsKeyAtom;\n}\n\nfunction useSettingsKeyAtom<T extends keyof SettingsType>(key: T): SettingsType[T] {\n    return useAtomValue(getSettingsKeyAtom(key));\n}\n\nfunction getSettingsPrefixAtom(prefix: string): Atom<SettingsType> {\n    if (isPreviewWindow()) return NullAtom as Atom<SettingsType>;\n    let settingsPrefixAtom = settingsAtomCache.get(prefix + \":\");\n    if (settingsPrefixAtom == null) {\n        // create a stable, closured reference to use as the deepCompareReturnPrev key\n        const cacheKey = {};\n        settingsPrefixAtom = atom((get) => {\n            const settings = get(atoms.settingsAtom);\n            const newValue = getPrefixedSettings(settings, prefix);\n            return deepCompareReturnPrev(cacheKey, newValue);\n        });\n        settingsAtomCache.set(prefix + \":\", settingsPrefixAtom);\n    }\n    return settingsPrefixAtom;\n}\n\nfunction getSingleBlockAtomCache(blockId: string): Map<string, Atom<any>> {\n    const blockORef = WOS.makeORef(\"block\", blockId);\n    return getSingleOrefAtomCache(blockORef);\n}\n\nfunction getSingleConnAtomCache(connName: string): Map<string, Atom<any>> {\n    // this is not a real \"oref\", but it will work for the cache.\n    const connORef = WOS.makeORef(\"conn\", connName);\n    return getSingleOrefAtomCache(connORef);\n}\n\nfunction getSingleOrefAtomCache(oref: string): Map<string, Atom<any>> {\n    let orefCache = orefAtomCache.get(oref);\n    if (orefCache == null) {\n        orefCache = new Map<string, Atom<any>>();\n        orefAtomCache.set(oref, orefCache);\n    }\n    return orefCache;\n}\n\n// this function should be kept up to date with IsBlockTermDurable in pkg/jobcontroller/jobcontroller.go\n// Note: null/false both map to false in the Go code, but this returns a special null value\n// to indicate when the block is not even eligible to be durable\nfunction getBlockTermDurableAtom(blockId: string): Atom<null | boolean> {\n    const blockCache = getSingleBlockAtomCache(blockId);\n    const durableAtomName = \"#termdurable\";\n    let durableAtom = blockCache.get(durableAtomName);\n    if (durableAtom != null) {\n        return durableAtom;\n    }\n    durableAtom = atom((get) => {\n        const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef(\"block\", blockId));\n        const block = get(blockAtom);\n\n        if (block == null) {\n            return null;\n        }\n\n        // Check if view is \"term\", and controller is \"shell\"\n        if (block.meta?.view != \"term\" || block.meta?.controller != \"shell\") {\n            return null;\n        }\n\n        // 1. Check if block has a JobId\n        if (block.jobid != null && block.jobid != \"\") {\n            return true;\n        }\n\n        // 2. Check if connection is local or WSL (not eligible for durability)\n        const connName = block.meta?.connection ?? \"\";\n        if (isLocalConnName(connName) || isWslConnName(connName)) {\n            return null;\n        }\n\n        // 3. Check config hierarchy: blockmeta → connection → global (default true)\n        const durableConfigAtom = getOverrideConfigAtom(blockId, \"term:durable\");\n        const durableConfig = get(durableConfigAtom);\n        if (durableConfig != null) {\n            return durableConfig;\n        }\n\n        // Default to true for non-local connections\n        return true;\n    });\n    blockCache.set(durableAtomName, durableAtom);\n    return durableAtom;\n}\n\nfunction useBlockAtom<T>(blockId: string, name: string, makeFn: () => Atom<T>): Atom<T> {\n    const blockCache = getSingleBlockAtomCache(blockId);\n    let atom = blockCache.get(name);\n    if (atom == null) {\n        atom = makeFn();\n        blockCache.set(name, atom);\n        console.log(\"New BlockAtom\", blockId, name);\n    }\n    return atom as Atom<T>;\n}\n\n/**\n * Safely read an atom value, returning null if the atom is null.\n */\nfunction readAtom<T>(atom: Atom<T>): T {\n    if (atom == null) {\n        return null;\n    }\n    return globalStore.get(atom);\n}\n\n/**\n * Get the preload api.\n */\nfunction getApi(): ElectronApi {\n    return (window as any).api;\n}\n\nasync function createBlockSplitHorizontally(\n    blockDef: BlockDef,\n    targetBlockId: string,\n    position: \"before\" | \"after\"\n): Promise<string> {\n    const layoutModel = getLayoutModelForStaticTab();\n    const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };\n    const newBlockId = await ObjectService.CreateBlock(blockDef, rtOpts);\n    const targetNodeId = layoutModel.getNodeByBlockId(targetBlockId)?.id;\n    if (targetNodeId == null) {\n        throw new Error(`targetNodeId not found for blockId: ${targetBlockId}`);\n    }\n    const splitAction: LayoutTreeSplitHorizontalAction = {\n        type: LayoutTreeActionType.SplitHorizontal,\n        targetNodeId: targetNodeId,\n        newNode: newLayoutNode(undefined, undefined, undefined, { blockId: newBlockId }),\n        position: position,\n        focused: true,\n    };\n    layoutModel.treeReducer(splitAction);\n    return newBlockId;\n}\n\nasync function createBlockSplitVertically(\n    blockDef: BlockDef,\n    targetBlockId: string,\n    position: \"before\" | \"after\"\n): Promise<string> {\n    const layoutModel = getLayoutModelForStaticTab();\n    const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };\n    const newBlockId = await ObjectService.CreateBlock(blockDef, rtOpts);\n    const targetNodeId = layoutModel.getNodeByBlockId(targetBlockId)?.id;\n    if (targetNodeId == null) {\n        throw new Error(`targetNodeId not found for blockId: ${targetBlockId}`);\n    }\n    const splitAction: LayoutTreeSplitVerticalAction = {\n        type: LayoutTreeActionType.SplitVertical,\n        targetNodeId: targetNodeId,\n        newNode: newLayoutNode(undefined, undefined, undefined, { blockId: newBlockId }),\n        position: position,\n        focused: true,\n    };\n    layoutModel.treeReducer(splitAction);\n    return newBlockId;\n}\n\nasync function createBlock(blockDef: BlockDef, magnified = false, ephemeral = false): Promise<string> {\n    const layoutModel = getLayoutModelForStaticTab();\n    const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };\n    const blockId = await ObjectService.CreateBlock(blockDef, rtOpts);\n    if (ephemeral) {\n        layoutModel.newEphemeralNode(blockId);\n        return blockId;\n    }\n    const insertNodeAction: LayoutTreeInsertNodeAction = {\n        type: LayoutTreeActionType.InsertNode,\n        node: newLayoutNode(undefined, undefined, undefined, { blockId }),\n        magnified,\n        focused: true,\n    };\n    layoutModel.treeReducer(insertNodeAction);\n    return blockId;\n}\n\nasync function replaceBlock(blockId: string, blockDef: BlockDef, focus: boolean): Promise<string> {\n    const layoutModel = getLayoutModelForStaticTab();\n    const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };\n    const newBlockId = await ObjectService.CreateBlock(blockDef, rtOpts);\n    setTimeout(() => {\n        fireAndForget(() => ObjectService.DeleteBlock(blockId));\n    }, 300);\n    const targetNodeId = layoutModel.getNodeByBlockId(blockId)?.id;\n    if (targetNodeId == null) {\n        throw new Error(`targetNodeId not found for blockId: ${blockId}`);\n    }\n    const replaceNodeAction: LayoutTreeReplaceNodeAction = {\n        type: LayoutTreeActionType.ReplaceNode,\n        targetNodeId: targetNodeId,\n        newNode: newLayoutNode(undefined, undefined, undefined, { blockId: newBlockId }),\n        focused: focus,\n    };\n    layoutModel.treeReducer(replaceNodeAction);\n    return newBlockId;\n}\n\n// when file is not found, returns {data: null, fileInfo: null}\nasync function fetchWaveFile(\n    zoneId: string,\n    fileName: string,\n    offset?: number\n): Promise<{ data: Uint8Array; fileInfo: WaveFile }> {\n    const usp = new URLSearchParams();\n    usp.set(\"zoneid\", zoneId);\n    usp.set(\"name\", fileName);\n    if (offset != null) {\n        usp.set(\"offset\", offset.toString());\n    }\n    const resp = await fetch(getWebServerEndpoint() + \"/wave/file?\" + usp.toString());\n    if (!resp.ok) {\n        if (resp.status === 404) {\n            return { data: null, fileInfo: null };\n        }\n        throw new Error(\"error getting wave file: \" + resp.statusText);\n    }\n    if (resp.status == 204) {\n        return { data: null, fileInfo: null };\n    }\n    const fileInfo64 = resp.headers.get(\"X-ZoneFileInfo\");\n    if (fileInfo64 == null) {\n        throw new Error(`missing zone file info for ${zoneId}:${fileName}`);\n    }\n    const fileInfo = JSON.parse(base64ToString(fileInfo64));\n    const data = await resp.arrayBuffer();\n    return { data: new Uint8Array(data), fileInfo };\n}\n\nfunction setNodeFocus(nodeId: string) {\n    const layoutModel = getLayoutModelForStaticTab();\n    layoutModel.focusNode(nodeId);\n}\n\nconst objectIdWeakMap = new WeakMap();\nlet objectIdCounter = 0;\nfunction getObjectId(obj: any): number {\n    if (!objectIdWeakMap.has(obj)) {\n        objectIdWeakMap.set(obj, objectIdCounter++);\n    }\n    return objectIdWeakMap.get(obj);\n}\n\nlet cachedIsDev: boolean = null;\n\nfunction isDev() {\n    if (cachedIsDev == null) {\n        cachedIsDev = getApi().getIsDev();\n    }\n    return cachedIsDev;\n}\n\nlet cachedUserName: string = null;\n\nfunction getUserName(): string {\n    if (cachedUserName == null) {\n        cachedUserName = getApi().getUserName();\n    }\n    return cachedUserName;\n}\n\nlet cachedHostName: string = null;\n\nfunction getHostName(): string {\n    if (cachedHostName == null) {\n        cachedHostName = getApi().getHostName();\n    }\n    return cachedHostName;\n}\n\nconst LocalHostDisplayNameAtom: Atom<string> = atom((get) => {\n    const configValue = get(getSettingsKeyAtom(\"conn:localhostdisplayname\"));\n    if (configValue != null) {\n        return configValue;\n    }\n    return getUserName() + \"@\" + getHostName();\n});\n\nfunction getLocalHostDisplayNameAtom(): Atom<string> {\n    return LocalHostDisplayNameAtom;\n}\n\n/**\n * Open a link in a new window, or in a new web widget. The user can set all links to open in a new web widget using the `web:openlinksinternally` setting.\n * @param uri The link to open.\n * @param forceOpenInternally Force the link to open in a new web widget.\n */\nasync function openLink(uri: string, forceOpenInternally = false) {\n    if (forceOpenInternally || globalStore.get(atoms.settingsAtom)?.[\"web:openlinksinternally\"]) {\n        const blockDef: BlockDef = {\n            meta: {\n                view: \"web\",\n                url: uri,\n            },\n        };\n        await createBlock(blockDef);\n    } else {\n        getApi().openExternal(uri);\n    }\n}\n\nfunction registerBlockComponentModel(blockId: string, bcm: BlockComponentModel) {\n    blockComponentModelMap.set(blockId, bcm);\n}\n\nfunction unregisterBlockComponentModel(blockId: string) {\n    blockComponentModelMap.delete(blockId);\n}\n\nfunction getBlockComponentModel(blockId: string): BlockComponentModel {\n    return blockComponentModelMap.get(blockId);\n}\n\nfunction getAllBlockComponentModels(): BlockComponentModel[] {\n    return Array.from(blockComponentModelMap.values());\n}\n\nfunction getFocusedBlockId(): string {\n    const layoutModel = getLayoutModelForStaticTab();\n    if (layoutModel?.focusedNode == null) return null;\n    const focusedLayoutNode = globalStore.get(layoutModel.focusedNode);\n    return focusedLayoutNode?.data?.blockId;\n}\n\n// pass null to refocus the currently focused block\nfunction refocusNode(blockId: string) {\n    if (blockId == null) {\n        blockId = getFocusedBlockId();\n        if (blockId == null) {\n            return;\n        }\n    }\n    const layoutModel = getLayoutModelForStaticTab();\n    const layoutNodeId = layoutModel.getNodeByBlockId(blockId);\n    if (layoutNodeId?.id == null) {\n        return;\n    }\n    layoutModel.focusNode(layoutNodeId.id);\n    const bcm = getBlockComponentModel(blockId);\n    const ok = bcm?.viewModel?.giveFocus?.();\n    if (!ok) {\n        const inputElem = document.getElementById(`${blockId}-dummy-focus`);\n        inputElem?.focus();\n    }\n}\n\nasync function loadConnStatus() {\n    const connStatusArr = await ClientService.GetAllConnStatus();\n    if (connStatusArr == null) {\n        return;\n    }\n    for (const connStatus of connStatusArr) {\n        const curAtom = getConnStatusAtom(connStatus.connection);\n        globalStore.set(curAtom, connStatus);\n    }\n}\n\nfunction subscribeToConnEvents() {\n    waveEventSubscribeSingle({\n        eventType: \"connchange\",\n        handler: (event) => {\n            try {\n                const connStatus = event.data;\n                if (connStatus == null || isBlank(connStatus.connection)) {\n                    return;\n                }\n                console.log(\"connstatus update\", connStatus);\n                const curAtom = getConnStatusAtom(connStatus.connection);\n                globalStore.set(curAtom, connStatus);\n            } catch (e) {\n                console.log(\"connchange error\", e);\n            }\n        },\n    });\n}\n\nfunction makeDefaultConnStatus(conn: string): ConnStatus {\n    if (isLocalConnName(conn)) {\n        return {\n            connection: conn,\n            connected: true,\n            error: null,\n            status: \"connected\",\n            hasconnected: true,\n            activeconnnum: 0,\n            wshenabled: false,\n        };\n    }\n    return {\n        connection: conn,\n        connected: false,\n        error: null,\n        status: \"disconnected\",\n        hasconnected: false,\n        activeconnnum: 0,\n        wshenabled: false,\n    };\n}\n\nfunction getConnStatusAtom(conn: string): PrimitiveAtom<ConnStatus> {\n    const connStatusMap = globalStore.get(ConnStatusMapAtom);\n    let rtn = connStatusMap.get(conn);\n    if (rtn == null) {\n        rtn = atom(makeDefaultConnStatus(conn));\n        const newConnStatusMap = new Map(connStatusMap);\n        newConnStatusMap.set(conn, rtn);\n        globalStore.set(ConnStatusMapAtom, newConnStatusMap);\n    }\n    return rtn;\n}\n\nfunction createTab() {\n    getApi().createTab();\n}\n\nfunction setActiveTab(tabId: string) {\n    getApi().setActiveTab(tabId);\n}\n\nfunction recordTEvent(event: string, props?: TEventProps) {\n    if (isPreviewWindow()) return;\n    if (props == null) {\n        props = {};\n    }\n    RpcApi.RecordTEventCommand(TabRpcClient, { event, props }, { noresponse: true });\n}\n\nexport {\n    atoms,\n    createBlock,\n    createBlockSplitHorizontally,\n    createBlockSplitVertically,\n    createTab,\n    fetchWaveFile,\n    getAllBlockComponentModels,\n    getApi,\n    getBlockComponentModel,\n    getBlockMetaKeyAtom,\n    getConnConfigKeyAtom,\n    getBlockTermDurableAtom,\n    getConnStatusAtom,\n    getFocusedBlockId,\n    getHostName,\n    getLocalHostDisplayNameAtom,\n    getObjectId,\n    getOrefMetaKeyAtom,\n    getOverrideConfigAtom,\n    getSettingsKeyAtom,\n    getSettingsPrefixAtom,\n    getUserName,\n    globalPrimaryTabStartup,\n    globalStore,\n    initGlobal,\n    initGlobalWaveEventSubs,\n    isDev,\n    loadConnStatus,\n    makeDefaultConnStatus,\n    openLink,\n    readAtom,\n    recordTEvent,\n    refocusNode,\n    registerBlockComponentModel,\n    replaceBlock,\n    setActiveTab,\n    setNodeFocus,\n    setPlatform,\n    subscribeToConnEvents,\n    unregisterBlockComponentModel,\n    useBlockAtom,\n    useBlockCache,\n    useOrefMetaKeyAtom,\n    useOverrideConfigAtom,\n    useSettingsKeyAtom,\n    WOS,\n};\n"
  },
  {
    "path": "frontend/app/store/jotaiStore.ts",
    "content": "import { createStore } from \"jotai\";\n\nexport const globalStore = createStore();\n"
  },
  {
    "path": "frontend/app/store/keymodel.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { WaveAIModel } from \"@/app/aipanel/waveai-model\";\nimport { FocusManager } from \"@/app/store/focusManager\";\nimport {\n    atoms,\n    createBlock,\n    createBlockSplitHorizontally,\n    createBlockSplitVertically,\n    createTab,\n    getAllBlockComponentModels,\n    getApi,\n    getBlockComponentModel,\n    getFocusedBlockId,\n    getSettingsKeyAtom,\n    globalStore,\n    recordTEvent,\n    refocusNode,\n    replaceBlock,\n    WOS,\n} from \"@/app/store/global\";\nimport { getActiveTabModel } from \"@/app/store/tab-model\";\nimport { WorkspaceLayoutModel } from \"@/app/workspace/workspace-layout-model\";\nimport { deleteLayoutModelForTab, getLayoutModelForStaticTab, NavigateDirection } from \"@/layout/index\";\nimport * as keyutil from \"@/util/keyutil\";\nimport { isWindows } from \"@/util/platformutil\";\nimport { CHORD_TIMEOUT } from \"@/util/sharedconst\";\nimport { fireAndForget } from \"@/util/util\";\nimport * as jotai from \"jotai\";\nimport { modalsModel } from \"./modalmodel\";\nimport { isBuilderWindow, isTabWindow } from \"./windowtype\";\n\ntype KeyHandler = (event: WaveKeyboardEvent) => boolean;\n\nconst simpleControlShiftAtom = jotai.atom(false);\nconst globalKeyMap = new Map<string, (waveEvent: WaveKeyboardEvent) => boolean>();\nconst globalChordMap = new Map<string, Map<string, KeyHandler>>();\nlet globalKeybindingsDisabled = false;\n\n// track current chord state and timeout (for resetting)\nlet activeChord: string | null = null;\nlet chordTimeout: NodeJS.Timeout = null;\n\nfunction resetChord() {\n    activeChord = null;\n    if (chordTimeout) {\n        clearTimeout(chordTimeout);\n        chordTimeout = null;\n    }\n}\n\nfunction setActiveChord(activeChordArg: string) {\n    getApi().setKeyboardChordMode();\n    if (chordTimeout) {\n        clearTimeout(chordTimeout);\n    }\n    activeChord = activeChordArg;\n    chordTimeout = setTimeout(() => resetChord(), CHORD_TIMEOUT);\n}\n\nexport function keyboardMouseDownHandler(e: MouseEvent) {\n    if (!e.ctrlKey || !e.shiftKey) {\n        unsetControlShift();\n    }\n}\n\nfunction getFocusedBlockInStaticTab(): string {\n    const layoutModel = getLayoutModelForStaticTab();\n    const focusedNode = globalStore.get(layoutModel.focusedNode);\n    return focusedNode.data?.blockId;\n}\n\nfunction getSimpleControlShiftAtom() {\n    return simpleControlShiftAtom;\n}\n\nfunction setControlShift() {\n    globalStore.set(simpleControlShiftAtom, true);\n    const disableDisplay = globalStore.get(getSettingsKeyAtom(\"app:disablectrlshiftdisplay\"));\n    if (!disableDisplay) {\n        setTimeout(() => {\n            const simpleState = globalStore.get(simpleControlShiftAtom);\n            if (simpleState) {\n                globalStore.set(atoms.controlShiftDelayAtom, true);\n            }\n        }, 400);\n    }\n}\n\nfunction unsetControlShift() {\n    globalStore.set(simpleControlShiftAtom, false);\n    globalStore.set(atoms.controlShiftDelayAtom, false);\n}\n\nfunction disableGlobalKeybindings() {\n    globalKeybindingsDisabled = true;\n}\n\nfunction enableGlobalKeybindings() {\n    globalKeybindingsDisabled = false;\n}\n\nfunction shouldDispatchToBlock(e: WaveKeyboardEvent): boolean {\n    if (globalStore.get(atoms.modalOpen)) {\n        return false;\n    }\n    const activeElem = document.activeElement;\n    if (activeElem != null && activeElem instanceof HTMLElement) {\n        if (activeElem.tagName == \"INPUT\" || activeElem.tagName == \"TEXTAREA\" || activeElem.contentEditable == \"true\") {\n            if (activeElem.classList.contains(\"dummy-focus\") || activeElem.classList.contains(\"dummy\")) {\n                return true;\n            }\n            if (keyutil.isInputEvent(e)) {\n                return false;\n            }\n            return true;\n        }\n    }\n    return true;\n}\n\nfunction getStaticTabBlockCount(): number {\n    const tabId = globalStore.get(atoms.staticTabId);\n    const tabORef = WOS.makeORef(\"tab\", tabId);\n    const tabAtom = WOS.getWaveObjectAtom<Tab>(tabORef);\n    const tabData = globalStore.get(tabAtom);\n    return tabData?.blockids?.length ?? 0;\n}\n\nfunction simpleCloseStaticTab() {\n    const workspaceId = globalStore.get(atoms.workspaceId);\n    const tabId = globalStore.get(atoms.staticTabId);\n    const confirmClose = globalStore.get(getSettingsKeyAtom(\"tab:confirmclose\")) ?? false;\n    getApi()\n        .closeTab(workspaceId, tabId, confirmClose)\n        .then((didClose) => {\n            if (didClose) {\n                deleteLayoutModelForTab(tabId);\n            }\n        })\n        .catch((e) => {\n            console.log(\"error closing tab\", e);\n        });\n}\n\nfunction uxCloseBlock(blockId: string) {\n    const workspaceLayoutModel = WorkspaceLayoutModel.getInstance();\n    const isAIPanelOpen = workspaceLayoutModel.getAIPanelVisible();\n    if (isAIPanelOpen && getStaticTabBlockCount() === 1) {\n        const aiModel = WaveAIModel.getInstance();\n        const shouldSwitchToAI = !globalStore.get(aiModel.isChatEmptyAtom) || aiModel.hasNonEmptyInput();\n        if (shouldSwitchToAI) {\n            replaceBlock(blockId, { meta: { view: \"launcher\" } }, false);\n            setTimeout(() => WaveAIModel.getInstance().focusInput(), 50);\n            return;\n        }\n    }\n\n    const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef(\"block\", blockId));\n    const blockData = globalStore.get(blockAtom);\n    const isAIFileDiff = blockData?.meta?.view === \"aifilediff\";\n\n    // If this is the last block, closing it will close the tab — route through simpleCloseStaticTab\n    // so the tab:confirmclose setting is respected.\n    if (getStaticTabBlockCount() === 1) {\n        simpleCloseStaticTab();\n        return;\n    }\n\n    const layoutModel = getLayoutModelForStaticTab();\n    const node = layoutModel.getNodeByBlockId(blockId);\n    if (node) {\n        fireAndForget(() => layoutModel.closeNode(node.id));\n\n        if (isAIFileDiff && isAIPanelOpen) {\n            setTimeout(() => WaveAIModel.getInstance().focusInput(), 50);\n        }\n    }\n}\n\nfunction genericClose() {\n    const focusType = FocusManager.getInstance().getFocusType();\n    if (focusType === \"waveai\") {\n        WorkspaceLayoutModel.getInstance().setAIPanelVisible(false);\n        return;\n    }\n\n    const workspaceLayoutModel = WorkspaceLayoutModel.getInstance();\n    const isAIPanelOpen = workspaceLayoutModel.getAIPanelVisible();\n    if (isAIPanelOpen && getStaticTabBlockCount() === 1) {\n        const aiModel = WaveAIModel.getInstance();\n        const shouldSwitchToAI = !globalStore.get(aiModel.isChatEmptyAtom) || aiModel.hasNonEmptyInput();\n        if (shouldSwitchToAI) {\n            const layoutModel = getLayoutModelForStaticTab();\n            const focusedNode = globalStore.get(layoutModel.focusedNode);\n            if (focusedNode) {\n                replaceBlock(focusedNode.data.blockId, { meta: { view: \"launcher\" } }, false);\n                setTimeout(() => WaveAIModel.getInstance().focusInput(), 50);\n                return;\n            }\n        }\n    }\n    const blockCount = getStaticTabBlockCount();\n    if (blockCount === 0) {\n        simpleCloseStaticTab();\n        return;\n    }\n\n    // If this is the last block, closing it will close the tab — route through simpleCloseStaticTab\n    // so the tab:confirmclose setting is respected.\n    if (blockCount === 1) {\n        simpleCloseStaticTab();\n        return;\n    }\n\n    const layoutModel = getLayoutModelForStaticTab();\n    const focusedNode = globalStore.get(layoutModel.focusedNode);\n    const blockId = focusedNode?.data?.blockId;\n    const blockAtom = blockId ? WOS.getWaveObjectAtom<Block>(WOS.makeORef(\"block\", blockId)) : null;\n    const blockData = blockAtom ? globalStore.get(blockAtom) : null;\n    const isAIFileDiff = blockData?.meta?.view === \"aifilediff\";\n\n    fireAndForget(layoutModel.closeFocusedNode.bind(layoutModel));\n\n    if (isAIFileDiff && isAIPanelOpen) {\n        setTimeout(() => WaveAIModel.getInstance().focusInput(), 50);\n    }\n}\n\nfunction switchBlockByBlockNum(index: number) {\n    const layoutModel = getLayoutModelForStaticTab();\n    if (!layoutModel) {\n        return;\n    }\n    layoutModel.switchNodeFocusByBlockNum(index);\n    setTimeout(() => {\n        globalRefocus();\n    }, 10);\n}\n\nfunction switchBlockInDirection(direction: NavigateDirection) {\n    const layoutModel = getLayoutModelForStaticTab();\n    const focusType = FocusManager.getInstance().getFocusType();\n\n    if (direction === NavigateDirection.Left) {\n        const numBlocks = globalStore.get(layoutModel.numLeafs);\n        if (focusType === \"waveai\") {\n            return;\n        }\n        if (numBlocks === 1) {\n            FocusManager.getInstance().requestWaveAIFocus();\n            setTimeout(() => {\n                FocusManager.getInstance().refocusNode();\n            }, 10);\n            return;\n        }\n    }\n\n    if (direction === NavigateDirection.Right && focusType === \"waveai\") {\n        FocusManager.getInstance().requestNodeFocus();\n        return;\n    }\n\n    const inWaveAI = focusType === \"waveai\";\n    const navResult = layoutModel.switchNodeFocusInDirection(direction, inWaveAI);\n    if (navResult.atLeft) {\n        FocusManager.getInstance().requestWaveAIFocus();\n        setTimeout(() => {\n            FocusManager.getInstance().refocusNode();\n        }, 10);\n        return;\n    }\n    setTimeout(() => {\n        globalRefocus();\n    }, 10);\n}\n\nfunction getAllTabs(ws: Workspace): string[] {\n    return ws.tabids ?? [];\n}\n\nfunction switchTabAbs(index: number) {\n    console.log(\"switchTabAbs\", index);\n    const ws = globalStore.get(atoms.workspace);\n    const newTabIdx = index - 1;\n    const tabids = getAllTabs(ws);\n    if (newTabIdx < 0 || newTabIdx >= tabids.length) {\n        return;\n    }\n    const newActiveTabId = tabids[newTabIdx];\n    getApi().setActiveTab(newActiveTabId);\n}\n\nfunction switchTab(offset: number) {\n    console.log(\"switchTab\", offset);\n    const ws = globalStore.get(atoms.workspace);\n    const curTabId = globalStore.get(atoms.staticTabId);\n    let tabIdx = -1;\n    const tabids = getAllTabs(ws);\n    for (let i = 0; i < tabids.length; i++) {\n        if (tabids[i] == curTabId) {\n            tabIdx = i;\n            break;\n        }\n    }\n    if (tabIdx == -1) {\n        return;\n    }\n    const newTabIdx = (tabIdx + offset + tabids.length) % tabids.length;\n    const newActiveTabId = tabids[newTabIdx];\n    getApi().setActiveTab(newActiveTabId);\n}\n\nfunction handleCmdI() {\n    globalRefocus();\n}\n\nfunction globalRefocusWithTimeout(timeoutVal: number) {\n    setTimeout(() => {\n        globalRefocus();\n    }, timeoutVal);\n}\n\nfunction globalRefocus() {\n    if (isBuilderWindow()) {\n        return;\n    }\n\n    const layoutModel = getLayoutModelForStaticTab();\n    const focusedNode = globalStore.get(layoutModel.focusedNode);\n    if (focusedNode == null) {\n        // focus a node\n        layoutModel.focusFirstNode();\n        return;\n    }\n    const blockId = focusedNode?.data?.blockId;\n    if (blockId == null) {\n        return;\n    }\n    refocusNode(blockId);\n}\n\nfunction getDefaultNewBlockDef(): BlockDef {\n    const adnbAtom = getSettingsKeyAtom(\"app:defaultnewblock\");\n    const adnb = globalStore.get(adnbAtom) ?? \"term\";\n    if (adnb == \"launcher\") {\n        return {\n            meta: {\n                view: \"launcher\",\n            },\n        };\n    }\n    // \"term\", blank, anything else, fall back to terminal\n    const termBlockDef: BlockDef = {\n        meta: {\n            view: \"term\",\n            controller: \"shell\",\n        },\n    };\n    const layoutModel = getLayoutModelForStaticTab();\n    const focusedNode = globalStore.get(layoutModel.focusedNode);\n    if (focusedNode != null) {\n        const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef(\"block\", focusedNode.data?.blockId));\n        const blockData = globalStore.get(blockAtom);\n        if (blockData?.meta?.view == \"term\") {\n            if (blockData?.meta?.[\"cmd:cwd\"] != null) {\n                termBlockDef.meta[\"cmd:cwd\"] = blockData.meta[\"cmd:cwd\"];\n            }\n        }\n        if (blockData?.meta?.connection != null) {\n            termBlockDef.meta.connection = blockData.meta.connection;\n        }\n    }\n    return termBlockDef;\n}\n\nasync function handleCmdN() {\n    const blockDef = getDefaultNewBlockDef();\n    await createBlock(blockDef);\n}\n\nasync function handleSplitHorizontal(position: \"before\" | \"after\") {\n    const layoutModel = getLayoutModelForStaticTab();\n    const focusedNode = globalStore.get(layoutModel.focusedNode);\n    if (focusedNode == null) {\n        return;\n    }\n    const blockDef = getDefaultNewBlockDef();\n    await createBlockSplitHorizontally(blockDef, focusedNode.data.blockId, position);\n}\n\nasync function handleSplitVertical(position: \"before\" | \"after\") {\n    const layoutModel = getLayoutModelForStaticTab();\n    const focusedNode = globalStore.get(layoutModel.focusedNode);\n    if (focusedNode == null) {\n        return;\n    }\n    const blockDef = getDefaultNewBlockDef();\n    await createBlockSplitVertically(blockDef, focusedNode.data.blockId, position);\n}\n\nlet lastHandledEvent: KeyboardEvent | null = null;\n\n// returns [keymatch, T]\nfunction checkKeyMap<T>(waveEvent: WaveKeyboardEvent, keyMap: Map<string, T>): [string, T] {\n    for (const key of keyMap.keys()) {\n        if (keyutil.checkKeyPressed(waveEvent, key)) {\n            const val = keyMap.get(key);\n            return [key, val];\n        }\n    }\n    return [null, null];\n}\n\nfunction appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {\n    if (globalKeybindingsDisabled) {\n        return false;\n    }\n    const nativeEvent = (waveEvent as any).nativeEvent;\n    if (lastHandledEvent != null && nativeEvent != null && lastHandledEvent === nativeEvent) {\n        return false;\n    }\n    lastHandledEvent = nativeEvent;\n    if (activeChord) {\n        console.log(\"handle activeChord\", activeChord);\n        // If we're in chord mode, look for the second key.\n        const chordBindings = globalChordMap.get(activeChord);\n        const [, handler] = checkKeyMap(waveEvent, chordBindings);\n        if (handler) {\n            resetChord();\n            return handler(waveEvent);\n        } else {\n            // invalid chord; reset state and consume key\n            resetChord();\n            return true;\n        }\n    }\n    const [chordKeyMatch] = checkKeyMap(waveEvent, globalChordMap);\n    if (chordKeyMatch) {\n        setActiveChord(chordKeyMatch);\n        return true;\n    }\n\n    const [, globalHandler] = checkKeyMap(waveEvent, globalKeyMap);\n    if (globalHandler) {\n        const handled = globalHandler(waveEvent);\n        if (handled) {\n            return true;\n        }\n    }\n    if (isTabWindow()) {\n        const layoutModel = getLayoutModelForStaticTab();\n        const focusedNode = globalStore.get(layoutModel.focusedNode);\n        const blockId = focusedNode?.data?.blockId;\n        if (blockId != null && shouldDispatchToBlock(waveEvent)) {\n            const bcm = getBlockComponentModel(blockId);\n            const viewModel = bcm?.viewModel;\n            if (viewModel?.keyDownHandler) {\n                const handledByBlock = viewModel.keyDownHandler(waveEvent);\n                if (handledByBlock) {\n                    return true;\n                }\n            }\n        }\n    }\n    return false;\n}\n\nfunction registerControlShiftStateUpdateHandler() {\n    getApi().onControlShiftStateUpdate((state: boolean) => {\n        if (state) {\n            setControlShift();\n        } else {\n            unsetControlShift();\n        }\n    });\n}\n\nfunction registerElectronReinjectKeyHandler() {\n    getApi().onReinjectKey((event: WaveKeyboardEvent) => {\n        appHandleKeyDown(event);\n    });\n}\n\nfunction tryReinjectKey(event: WaveKeyboardEvent): boolean {\n    return appHandleKeyDown(event);\n}\n\nfunction countTermBlocks(): number {\n    const allBCMs = getAllBlockComponentModels();\n    let count = 0;\n    const gsGetBound = globalStore.get.bind(globalStore);\n    for (const bcm of allBCMs) {\n        const viewModel = bcm.viewModel;\n        if (viewModel.viewType == \"term\" && viewModel.isBasicTerm?.(gsGetBound)) {\n            count++;\n        }\n    }\n    return count;\n}\n\nfunction registerGlobalKeys() {\n    globalKeyMap.set(\"Cmd:]\", () => {\n        switchTab(1);\n        return true;\n    });\n    globalKeyMap.set(\"Shift:Cmd:]\", () => {\n        switchTab(1);\n        return true;\n    });\n    globalKeyMap.set(\"Cmd:[\", () => {\n        switchTab(-1);\n        return true;\n    });\n    globalKeyMap.set(\"Shift:Cmd:[\", () => {\n        switchTab(-1);\n        return true;\n    });\n    globalKeyMap.set(\"Cmd:n\", () => {\n        handleCmdN();\n        return true;\n    });\n    globalKeyMap.set(\"Cmd:d\", () => {\n        handleSplitHorizontal(\"after\");\n        return true;\n    });\n    globalKeyMap.set(\"Shift:Cmd:d\", () => {\n        handleSplitVertical(\"after\");\n        return true;\n    });\n    globalKeyMap.set(\"Cmd:i\", () => {\n        handleCmdI();\n        return true;\n    });\n    globalKeyMap.set(\"Cmd:t\", () => {\n        createTab();\n        return true;\n    });\n    globalKeyMap.set(\"Cmd:w\", () => {\n        genericClose();\n        return true;\n    });\n    globalKeyMap.set(\"Cmd:Shift:w\", () => {\n        simpleCloseStaticTab();\n        return true;\n    });\n    globalKeyMap.set(\"Cmd:m\", () => {\n        const layoutModel = getLayoutModelForStaticTab();\n        const focusedNode = globalStore.get(layoutModel.focusedNode);\n        if (focusedNode != null) {\n            layoutModel.magnifyNodeToggle(focusedNode.id);\n        }\n        return true;\n    });\n    globalKeyMap.set(\"Ctrl:Shift:ArrowUp\", () => {\n        const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom(\"app:disablectrlshiftarrows\"));\n        if (disableCtrlShiftArrows) {\n            return false;\n        }\n        switchBlockInDirection(NavigateDirection.Up);\n        return true;\n    });\n    globalKeyMap.set(\"Ctrl:Shift:ArrowDown\", () => {\n        const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom(\"app:disablectrlshiftarrows\"));\n        if (disableCtrlShiftArrows) {\n            return false;\n        }\n        switchBlockInDirection(NavigateDirection.Down);\n        return true;\n    });\n    globalKeyMap.set(\"Ctrl:Shift:ArrowLeft\", () => {\n        const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom(\"app:disablectrlshiftarrows\"));\n        if (disableCtrlShiftArrows) {\n            return false;\n        }\n        switchBlockInDirection(NavigateDirection.Left);\n        return true;\n    });\n    globalKeyMap.set(\"Ctrl:Shift:ArrowRight\", () => {\n        const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom(\"app:disablectrlshiftarrows\"));\n        if (disableCtrlShiftArrows) {\n            return false;\n        }\n        switchBlockInDirection(NavigateDirection.Right);\n        return true;\n    });\n    // Vim-style aliases for block focus navigation.\n    globalKeyMap.set(\"Ctrl:Shift:h\", () => {\n        const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom(\"app:disablectrlshiftarrows\"));\n        if (disableCtrlShiftArrows) {\n            return false;\n        }\n        switchBlockInDirection(NavigateDirection.Left);\n        return true;\n    });\n    globalKeyMap.set(\"Ctrl:Shift:j\", () => {\n        const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom(\"app:disablectrlshiftarrows\"));\n        if (disableCtrlShiftArrows) {\n            return false;\n        }\n        switchBlockInDirection(NavigateDirection.Down);\n        return true;\n    });\n    globalKeyMap.set(\"Ctrl:Shift:k\", () => {\n        const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom(\"app:disablectrlshiftarrows\"));\n        if (disableCtrlShiftArrows) {\n            return false;\n        }\n        switchBlockInDirection(NavigateDirection.Up);\n        return true;\n    });\n    globalKeyMap.set(\"Ctrl:Shift:l\", () => {\n        const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom(\"app:disablectrlshiftarrows\"));\n        if (disableCtrlShiftArrows) {\n            return false;\n        }\n        switchBlockInDirection(NavigateDirection.Right);\n        return true;\n    });\n    globalKeyMap.set(\"Ctrl:Shift:x\", () => {\n        const blockId = getFocusedBlockId();\n        if (blockId == null) {\n            return true;\n        }\n        replaceBlock(\n            blockId,\n            {\n                meta: {\n                    view: \"launcher\",\n                },\n            },\n            true\n        );\n        return true;\n    });\n    globalKeyMap.set(\"Cmd:g\", () => {\n        const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());\n        if (bcm.openSwitchConnection != null) {\n            recordTEvent(\"action:other\", { \"action:type\": \"conndropdown\", \"action:initiator\": \"keyboard\" });\n            bcm.openSwitchConnection();\n            return true;\n        }\n    });\n    globalKeyMap.set(\"Ctrl:Shift:i\", () => {\n        const tabModel = getActiveTabModel();\n        if (tabModel == null) {\n            return true;\n        }\n        const curMI = globalStore.get(tabModel.isTermMultiInput);\n        if (!curMI && countTermBlocks() <= 1) {\n            // don't turn on multi-input unless there are 2 or more basic term blocks\n            return true;\n        }\n        globalStore.set(tabModel.isTermMultiInput, !curMI);\n        return true;\n    });\n    for (let idx = 1; idx <= 9; idx++) {\n        globalKeyMap.set(`Cmd:${idx}`, () => {\n            switchTabAbs(idx);\n            return true;\n        });\n        globalKeyMap.set(`Ctrl:Shift:c{Digit${idx}}`, () => {\n            switchBlockByBlockNum(idx);\n            return true;\n        });\n        globalKeyMap.set(`Ctrl:Shift:c{Numpad${idx}}`, () => {\n            switchBlockByBlockNum(idx);\n            return true;\n        });\n    }\n    if (isWindows()) {\n        globalKeyMap.set(\"Alt:c{Digit0}\", () => {\n            WaveAIModel.getInstance().focusInput();\n            return true;\n        });\n        globalKeyMap.set(\"Alt:c{Numpad0}\", () => {\n            WaveAIModel.getInstance().focusInput();\n            return true;\n        });\n    } else {\n        globalKeyMap.set(\"Ctrl:Shift:c{Digit0}\", () => {\n            WaveAIModel.getInstance().focusInput();\n            return true;\n        });\n        globalKeyMap.set(\"Ctrl:Shift:c{Numpad0}\", () => {\n            WaveAIModel.getInstance().focusInput();\n            return true;\n        });\n    }\n    function activateSearch(event: WaveKeyboardEvent): boolean {\n        const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());\n        // Ctrl+f is reserved in most shells\n        if (event.control && bcm.viewModel.viewType == \"term\") {\n            return false;\n        }\n        if (bcm.viewModel.searchAtoms) {\n            if (globalStore.get(bcm.viewModel.searchAtoms.isOpen)) {\n                // Already open — increment the focusInput counter so this block's\n                // SearchComponent focuses its own input (avoids a global DOM query\n                // that could target the wrong block when multiple searches are open).\n                const cur = globalStore.get(bcm.viewModel.searchAtoms.focusInput) as number;\n                globalStore.set(bcm.viewModel.searchAtoms.focusInput, cur + 1);\n            } else {\n                globalStore.set(bcm.viewModel.searchAtoms.isOpen, true);\n            }\n            return true;\n        }\n        return false;\n    }\n    function deactivateSearch(): boolean {\n        const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());\n        if (bcm.viewModel.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpen)) {\n            globalStore.set(bcm.viewModel.searchAtoms.isOpen, false);\n            return true;\n        }\n        return false;\n    }\n    globalKeyMap.set(\"Cmd:f\", activateSearch);\n    globalKeyMap.set(\"Escape\", () => {\n        if (modalsModel.hasOpenModals()) {\n            modalsModel.popModal();\n            return true;\n        }\n        if (deactivateSearch()) {\n            return true;\n        }\n        return false;\n    });\n    globalKeyMap.set(\"Cmd:Shift:a\", () => {\n        const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible();\n        WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible);\n        return true;\n    });\n    const allKeys = Array.from(globalKeyMap.keys());\n    // special case keys, handled by web view\n    allKeys.push(\"Cmd:l\", \"Cmd:r\", \"Cmd:ArrowRight\", \"Cmd:ArrowLeft\", \"Cmd:o\");\n    getApi().registerGlobalWebviewKeys(allKeys);\n\n    const splitBlockKeys = new Map<string, KeyHandler>();\n    splitBlockKeys.set(\"ArrowUp\", () => {\n        handleSplitVertical(\"before\");\n        return true;\n    });\n    splitBlockKeys.set(\"ArrowDown\", () => {\n        handleSplitVertical(\"after\");\n        return true;\n    });\n    splitBlockKeys.set(\"ArrowLeft\", () => {\n        handleSplitHorizontal(\"before\");\n        return true;\n    });\n    splitBlockKeys.set(\"ArrowRight\", () => {\n        handleSplitHorizontal(\"after\");\n        return true;\n    });\n    globalChordMap.set(\"Ctrl:Shift:s\", splitBlockKeys);\n}\n\nfunction registerBuilderGlobalKeys() {\n    globalKeyMap.set(\"Cmd:w\", () => {\n        getApi().closeBuilderWindow();\n        return true;\n    });\n    const allKeys = Array.from(globalKeyMap.keys());\n    getApi().registerGlobalWebviewKeys(allKeys);\n}\n\nfunction getAllGlobalKeyBindings(): string[] {\n    const allKeys = Array.from(globalKeyMap.keys());\n    return allKeys;\n}\n\nexport {\n    appHandleKeyDown,\n    disableGlobalKeybindings,\n    enableGlobalKeybindings,\n    getSimpleControlShiftAtom,\n    globalRefocus,\n    globalRefocusWithTimeout,\n    registerBuilderGlobalKeys,\n    registerControlShiftStateUpdateHandler,\n    registerElectronReinjectKeyHandler,\n    registerGlobalKeys,\n    tryReinjectKey,\n    unsetControlShift,\n    uxCloseBlock,\n};\n"
  },
  {
    "path": "frontend/app/store/modalmodel.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport * as jotai from \"jotai\";\nimport { globalStore } from \"./jotaiStore\";\n\nclass ModalsModel {\n    modalsAtom: jotai.PrimitiveAtom<Array<{ displayName: string; props?: any }>>;\n    newInstallOnboardingOpen: jotai.PrimitiveAtom<boolean>;\n    upgradeOnboardingOpen: jotai.PrimitiveAtom<boolean>;\n\n    constructor() {\n        this.newInstallOnboardingOpen = jotai.atom(false);\n        this.upgradeOnboardingOpen = jotai.atom(false);\n        this.modalsAtom = jotai.atom([]);\n    }\n\n    pushModal = (displayName: string, props?: any) => {\n        const modals = globalStore.get(this.modalsAtom);\n        globalStore.set(this.modalsAtom, [...modals, { displayName, props }]);\n    };\n\n    popModal = (callback?: () => void) => {\n        const modals = globalStore.get(this.modalsAtom);\n        if (modals.length > 0) {\n            const updatedModals = modals.slice(0, -1);\n            globalStore.set(this.modalsAtom, updatedModals);\n            if (callback) callback();\n        }\n    };\n\n    hasOpenModals(): boolean {\n        const modals = globalStore.get(this.modalsAtom);\n        return modals.length > 0;\n    }\n\n    isModalOpen(displayName: string): boolean {\n        const modals = globalStore.get(this.modalsAtom);\n        return modals.some((modal) => modal.displayName === displayName);\n    }\n}\n\nconst modalsModel = new ModalsModel();\n\nexport { modalsModel };\n"
  },
  {
    "path": "frontend/app/store/services.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// generated by cmd/generate/main-generatets.go\n\nimport * as WOS from \"./wos\";\nimport type { WaveEnv } from \"@/app/waveenv/waveenv\";\n\nfunction callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise<any> {\n    if (waveEnv != null) {\n        return waveEnv.callBackendService(service, method, args, noUIContext)\n    }\n    return WOS.callBackendService(service, method, args, noUIContext);\n}\n\n// blockservice.BlockService (block)\nexport class BlockServiceType {\n    waveEnv: WaveEnv;\n\n    constructor(waveEnv?: WaveEnv) {\n        this.waveEnv = waveEnv;\n    }\n\n    // queue a layout action to cleanup orphaned blocks in the tab\n    // @returns object updates\n    CleanupOrphanedBlocks(tabId: string): Promise<void> {\n        return callBackendService(this?.waveEnv, \"block\", \"CleanupOrphanedBlocks\", Array.from(arguments))\n    }\n    GetControllerStatus(arg2: string): Promise<BlockControllerRuntimeStatus> {\n        return callBackendService(this?.waveEnv, \"block\", \"GetControllerStatus\", Array.from(arguments))\n    }\n\n    // save the terminal state to a blockfile\n    SaveTerminalState(blockId: string, state: string, stateType: string, ptyOffset: number, termSize: TermSize): Promise<void> {\n        return callBackendService(this?.waveEnv, \"block\", \"SaveTerminalState\", Array.from(arguments))\n    }\n    SaveWaveAiData(arg2: string, arg3: WaveAIPromptMessageType[]): Promise<void> {\n        return callBackendService(this?.waveEnv, \"block\", \"SaveWaveAiData\", Array.from(arguments))\n    }\n}\n\nexport const BlockService = new BlockServiceType();\n\n// clientservice.ClientService (client)\nexport class ClientServiceType {\n    waveEnv: WaveEnv;\n\n    constructor(waveEnv?: WaveEnv) {\n        this.waveEnv = waveEnv;\n    }\n\n    // @returns object updates\n    AgreeTos(): Promise<void> {\n        return callBackendService(this?.waveEnv, \"client\", \"AgreeTos\", Array.from(arguments))\n    }\n    FocusWindow(arg2: string): Promise<void> {\n        return callBackendService(this?.waveEnv, \"client\", \"FocusWindow\", Array.from(arguments))\n    }\n    GetAllConnStatus(): Promise<ConnStatus[]> {\n        return callBackendService(this?.waveEnv, \"client\", \"GetAllConnStatus\", Array.from(arguments))\n    }\n    GetClientData(): Promise<Client> {\n        return callBackendService(this?.waveEnv, \"client\", \"GetClientData\", Array.from(arguments))\n    }\n    GetTab(arg1: string): Promise<Tab> {\n        return callBackendService(this?.waveEnv, \"client\", \"GetTab\", Array.from(arguments))\n    }\n    TelemetryUpdate(arg2: boolean): Promise<void> {\n        return callBackendService(this?.waveEnv, \"client\", \"TelemetryUpdate\", Array.from(arguments))\n    }\n}\n\nexport const ClientService = new ClientServiceType();\n\n// objectservice.ObjectService (object)\nexport class ObjectServiceType {\n    waveEnv: WaveEnv;\n\n    constructor(waveEnv?: WaveEnv) {\n        this.waveEnv = waveEnv;\n    }\n\n    // @returns blockId (and object updates)\n    CreateBlock(blockDef: BlockDef, rtOpts: RuntimeOpts): Promise<string> {\n        return callBackendService(this?.waveEnv, \"object\", \"CreateBlock\", Array.from(arguments))\n    }\n\n    // @returns object updates\n    DeleteBlock(blockId: string): Promise<void> {\n        return callBackendService(this?.waveEnv, \"object\", \"DeleteBlock\", Array.from(arguments))\n    }\n\n    // get wave object by oref\n    GetObject(oref: string): Promise<WaveObj> {\n        return callBackendService(this?.waveEnv, \"object\", \"GetObject\", Array.from(arguments))\n    }\n\n    // @returns objects\n    GetObjects(orefs: string[]): Promise<WaveObj[]> {\n        return callBackendService(this?.waveEnv, \"object\", \"GetObjects\", Array.from(arguments))\n    }\n\n    // @returns object updates\n    UpdateObject(waveObj: WaveObj, returnUpdates: boolean): Promise<void> {\n        return callBackendService(this?.waveEnv, \"object\", \"UpdateObject\", Array.from(arguments))\n    }\n\n    // @returns object updates\n    UpdateObjectMeta(oref: string, meta: MetaType): Promise<void> {\n        return callBackendService(this?.waveEnv, \"object\", \"UpdateObjectMeta\", Array.from(arguments))\n    }\n}\n\nexport const ObjectService = new ObjectServiceType();\n\n// userinputservice.UserInputService (userinput)\nexport class UserInputServiceType {\n    waveEnv: WaveEnv;\n\n    constructor(waveEnv?: WaveEnv) {\n        this.waveEnv = waveEnv;\n    }\n\n    SendUserInputResponse(arg1: UserInputResponse): Promise<void> {\n        return callBackendService(this?.waveEnv, \"userinput\", \"SendUserInputResponse\", Array.from(arguments))\n    }\n}\n\nexport const UserInputService = new UserInputServiceType();\n\n// windowservice.WindowService (window)\nexport class WindowServiceType {\n    waveEnv: WaveEnv;\n\n    constructor(waveEnv?: WaveEnv) {\n        this.waveEnv = waveEnv;\n    }\n\n    CloseWindow(windowId: string, fromElectron: boolean): Promise<void> {\n        return callBackendService(this?.waveEnv, \"window\", \"CloseWindow\", Array.from(arguments))\n    }\n    CreateWindow(winSize: WinSize, workspaceId: string): Promise<WaveWindow> {\n        return callBackendService(this?.waveEnv, \"window\", \"CreateWindow\", Array.from(arguments))\n    }\n    GetWindow(windowId: string): Promise<WaveWindow> {\n        return callBackendService(this?.waveEnv, \"window\", \"GetWindow\", Array.from(arguments))\n    }\n\n    // set window position and size\n    // @returns object updates\n    SetWindowPosAndSize(windowId: string, pos: Point, size: WinSize): Promise<void> {\n        return callBackendService(this?.waveEnv, \"window\", \"SetWindowPosAndSize\", Array.from(arguments))\n    }\n    SwitchWorkspace(windowId: string, workspaceId: string): Promise<Workspace> {\n        return callBackendService(this?.waveEnv, \"window\", \"SwitchWorkspace\", Array.from(arguments))\n    }\n}\n\nexport const WindowService = new WindowServiceType();\n\n// workspaceservice.WorkspaceService (workspace)\nexport class WorkspaceServiceType {\n    waveEnv: WaveEnv;\n\n    constructor(waveEnv?: WaveEnv) {\n        this.waveEnv = waveEnv;\n    }\n\n    // @returns CloseTabRtn (and object updates)\n    CloseTab(workspaceId: string, tabId: string, fromElectron: boolean): Promise<CloseTabRtnType> {\n        return callBackendService(this?.waveEnv, \"workspace\", \"CloseTab\", Array.from(arguments))\n    }\n\n    // @returns tabId (and object updates)\n    CreateTab(workspaceId: string, tabName: string, activateTab: boolean): Promise<string> {\n        return callBackendService(this?.waveEnv, \"workspace\", \"CreateTab\", Array.from(arguments))\n    }\n\n    // @returns workspaceId\n    CreateWorkspace(name: string, icon: string, color: string, applyDefaults: boolean): Promise<string> {\n        return callBackendService(this?.waveEnv, \"workspace\", \"CreateWorkspace\", Array.from(arguments))\n    }\n\n    // @returns object updates\n    DeleteWorkspace(workspaceId: string): Promise<string> {\n        return callBackendService(this?.waveEnv, \"workspace\", \"DeleteWorkspace\", Array.from(arguments))\n    }\n\n    // @returns colors\n    GetColors(): Promise<string[]> {\n        return callBackendService(this?.waveEnv, \"workspace\", \"GetColors\", Array.from(arguments))\n    }\n\n    // @returns icons\n    GetIcons(): Promise<string[]> {\n        return callBackendService(this?.waveEnv, \"workspace\", \"GetIcons\", Array.from(arguments))\n    }\n\n    // @returns workspace\n    GetWorkspace(workspaceId: string): Promise<Workspace> {\n        return callBackendService(this?.waveEnv, \"workspace\", \"GetWorkspace\", Array.from(arguments))\n    }\n    ListWorkspaces(): Promise<WorkspaceListEntry[]> {\n        return callBackendService(this?.waveEnv, \"workspace\", \"ListWorkspaces\", Array.from(arguments))\n    }\n\n    // @returns object updates\n    SetActiveTab(workspaceId: string, tabId: string): Promise<void> {\n        return callBackendService(this?.waveEnv, \"workspace\", \"SetActiveTab\", Array.from(arguments))\n    }\n\n    // @returns object updates\n    UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, applyDefaults: boolean): Promise<void> {\n        return callBackendService(this?.waveEnv, \"workspace\", \"UpdateWorkspace\", Array.from(arguments))\n    }\n}\n\nexport const WorkspaceService = new WorkspaceServiceType();\n\nexport const AllServiceTypes = {\n    \"block\": BlockServiceType,\n    \"client\": ClientServiceType,\n    \"object\": ObjectServiceType,\n    \"userinput\": UserInputServiceType,\n    \"window\": WindowServiceType,\n    \"workspace\": WorkspaceServiceType,\n};\n\nexport const AllServiceImpls = {\n    \"block\": BlockService,\n    \"client\": ClientService,\n    \"object\": ObjectService,\n    \"userinput\": UserInputService,\n    \"window\": WindowService,\n    \"workspace\": WorkspaceService,\n};\n"
  },
  {
    "path": "frontend/app/store/tab-model.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { WaveEnv, WaveEnvSubset } from \"@/app/waveenv/waveenv\";\nimport { atom, Atom, PrimitiveAtom } from \"jotai\";\nimport { createContext, useContext } from \"react\";\nimport { globalStore } from \"./jotaiStore\";\nimport * as WOS from \"./wos\";\n\nexport type TabModelEnv = WaveEnvSubset<{\n    wos: WaveEnv[\"wos\"];\n}>;\n\nconst tabModelCache = new Map<string, TabModel>();\nexport const activeTabIdAtom = atom<string>(null) as PrimitiveAtom<string>;\n\nexport class TabModel {\n    tabId: string;\n    waveEnv: TabModelEnv;\n    tabAtom: Atom<Tab>;\n    tabNumBlocksAtom: Atom<number>;\n    isTermMultiInput = atom(false) as PrimitiveAtom<boolean>;\n    metaCache: Map<string, Atom<any>> = new Map();\n\n    constructor(tabId: string, waveEnv?: TabModelEnv) {\n        this.tabId = tabId;\n        this.waveEnv = waveEnv;\n        this.tabAtom = atom((get) => {\n            if (this.waveEnv != null) {\n                return get(this.waveEnv.wos.getWaveObjectAtom<Tab>(WOS.makeORef(\"tab\", this.tabId)));\n            }\n            return WOS.getObjectValue(WOS.makeORef(\"tab\", this.tabId), get);\n        });\n        this.tabNumBlocksAtom = atom((get) => {\n            const tabData = get(this.tabAtom);\n            return tabData?.blockids?.length ?? 0;\n        });\n    }\n\n    getTabMetaAtom<T extends keyof MetaType>(metaKey: T): Atom<MetaType[T]> {\n        let metaAtom = this.metaCache.get(metaKey);\n        if (metaAtom == null) {\n            metaAtom = atom((get) => {\n                const tabData = get(this.tabAtom);\n                return tabData?.meta?.[metaKey];\n            });\n            this.metaCache.set(metaKey, metaAtom);\n        }\n        return metaAtom;\n    }\n}\n\nexport function getTabModelByTabId(tabId: string, waveEnv?: TabModelEnv): TabModel {\n    if (!waveEnv?.isMock) {\n        let model = tabModelCache.get(tabId);\n        if (model == null) {\n            model = new TabModel(tabId, waveEnv);\n            tabModelCache.set(tabId, model);\n        }\n        return model;\n    }\n    const key = `TabModel:${tabId}`;\n    let model = waveEnv.mockModels.get(key);\n    if (model == null) {\n        model = new TabModel(tabId, waveEnv);\n        waveEnv.mockModels.set(key, model);\n    }\n    return model;\n}\n\nexport function getActiveTabModel(waveEnv?: TabModelEnv): TabModel | null {\n    const activeTabId = globalStore.get(activeTabIdAtom);\n    if (activeTabId == null) {\n        return null;\n    }\n    return getTabModelByTabId(activeTabId, waveEnv);\n}\n\nexport const TabModelContext = createContext<TabModel | undefined>(undefined);\n\nexport function useTabModel(): TabModel {\n    const ctxModel = useContext(TabModelContext);\n    if (ctxModel == null) {\n        throw new Error(\"useTabModel must be used within a TabModelProvider\");\n    }\n    return ctxModel;\n}\n\nexport function useTabModelMaybe(): TabModel {\n    return useContext(TabModelContext);\n}\n"
  },
  {
    "path": "frontend/app/store/tabrpcclient.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { WaveAIModel } from \"@/app/aipanel/waveai-model\";\nimport { getApi, getBlockComponentModel, getConnStatusAtom, globalStore, WOS } from \"@/app/store/global\";\nimport type { TermViewModel } from \"@/app/view/term/term-model\";\nimport { WorkspaceLayoutModel } from \"@/app/workspace/workspace-layout-model\";\nimport { getLayoutModelForStaticTab } from \"@/layout/index\";\nimport { base64ToArrayBuffer } from \"@/util/util\";\nimport { RpcResponseHelper, WshClient } from \"./wshclient\";\nimport { RpcApi } from \"./wshclientapi\";\n\nexport class TabClient extends WshClient {\n    constructor(routeId: string) {\n        super(routeId);\n    }\n\n    handle_captureblockscreenshot(rh: RpcResponseHelper, data: CommandCaptureBlockScreenshotData): Promise<string> {\n        return this.captureBlockScreenshot(data.blockid);\n    }\n\n    async captureBlockScreenshot(blockId: string): Promise<string> {\n        const layoutModel = getLayoutModelForStaticTab();\n        if (!layoutModel) {\n            throw new Error(\"Layout model not found\");\n        }\n\n        const node = layoutModel.getNodeByBlockId(blockId);\n        if (!node) {\n            throw new Error(`Block not found: ${blockId}`);\n        }\n\n        const displayContainer = layoutModel.displayContainerRef.current;\n        if (!displayContainer) {\n            throw new Error(\"Display container not found\");\n        }\n\n        const containerRect = displayContainer.getBoundingClientRect();\n        const additionalProps = layoutModel.getNodeAdditionalProperties(node);\n\n        let electronRect: Electron.Rectangle;\n\n        if (!additionalProps?.rect) {\n            // Bug: rect is not set when there is only one block in the layout\n            // In this case, use the full container rect\n            electronRect = {\n                x: Math.round(containerRect.x),\n                y: Math.round(containerRect.y),\n                width: Math.round(containerRect.width),\n                height: Math.round(containerRect.height),\n            };\n        } else {\n            const blockRect = additionalProps.rect;\n            electronRect = {\n                x: Math.round(containerRect.x + blockRect.left),\n                y: Math.round(containerRect.y + blockRect.top),\n                width: Math.round(blockRect.width),\n                height: Math.round(blockRect.height),\n            };\n        }\n\n        return await getApi().captureScreenshot(electronRect);\n    }\n\n    async handle_waveaiaddcontext(rh: RpcResponseHelper, data: CommandWaveAIAddContextData): Promise<void> {\n        const workspaceLayoutModel = WorkspaceLayoutModel.getInstance();\n        if (!workspaceLayoutModel.getAIPanelVisible()) {\n            workspaceLayoutModel.setAIPanelVisible(true, { nofocus: true });\n        }\n\n        const model = WaveAIModel.getInstance();\n\n        if (data.newchat) {\n            model.clearChat();\n        }\n\n        if (data.files && data.files.length > 0) {\n            for (const fileData of data.files) {\n                const decodedData = base64ToArrayBuffer(fileData.data64);\n                const blob = new Blob([decodedData], { type: fileData.type });\n                const file = new File([blob], fileData.name, { type: fileData.type });\n                await model.addFile(file);\n            }\n        }\n\n        if (data.text) {\n            model.appendText(data.text);\n        }\n\n        if (data.submit) {\n            await model.handleSubmit();\n        }\n    }\n\n    async handle_setblockfocus(rh: RpcResponseHelper, blockId: string): Promise<void> {\n        const layoutModel = getLayoutModelForStaticTab();\n        if (!layoutModel) {\n            throw new Error(\"Layout model not found\");\n        }\n\n        const node = layoutModel.getNodeByBlockId(blockId);\n        if (!node) {\n            throw new Error(`Block not found in tab: ${blockId}`);\n        }\n\n        layoutModel.focusNode(node.id);\n    }\n\n    async handle_getfocusedblockdata(rh: RpcResponseHelper): Promise<FocusedBlockData> {\n        const layoutModel = getLayoutModelForStaticTab();\n        if (!layoutModel) {\n            throw new Error(\"Layout model not found\");\n        }\n\n        const focusedNode = globalStore.get(layoutModel.focusedNode);\n        const blockId = focusedNode?.data?.blockId;\n\n        if (!blockId) {\n            return null;\n        }\n\n        const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef(\"block\", blockId));\n        const blockData = globalStore.get(blockAtom);\n\n        if (!blockData) {\n            return null;\n        }\n\n        const viewType = blockData.meta?.view ?? \"\";\n        const controller = blockData.meta?.controller ?? \"\";\n        const connName = blockData.meta?.connection ?? \"\";\n\n        const result: FocusedBlockData = {\n            blockid: blockId,\n            viewtype: viewType,\n            controller: controller,\n            connname: connName,\n            blockmeta: blockData.meta ?? {},\n        };\n\n        if (viewType === \"term\" && controller === \"shell\") {\n            const jobStatus = await RpcApi.BlockJobStatusCommand(this, blockId);\n            if (jobStatus) {\n                result.termjobstatus = jobStatus;\n            }\n        }\n\n        if (connName) {\n            const connStatusAtom = getConnStatusAtom(connName);\n            const connStatus = globalStore.get(connStatusAtom);\n            if (connStatus) {\n                result.connstatus = connStatus;\n            }\n        }\n\n        if (viewType === \"term\") {\n            try {\n                const bcm = getBlockComponentModel(blockId);\n                if (bcm?.viewModel) {\n                    const termViewModel = bcm.viewModel as TermViewModel;\n                    if (termViewModel.termRef?.current?.shellIntegrationStatusAtom) {\n                        const shellIntegrationStatus = globalStore.get(termViewModel.termRef.current.shellIntegrationStatusAtom);\n                        result.termshellintegrationstatus = shellIntegrationStatus || \"\";\n                    }\n                    if (termViewModel.termRef?.current?.lastCommandAtom) {\n                        const lastCommand = globalStore.get(termViewModel.termRef.current.lastCommandAtom);\n                        result.termlastcommand = lastCommand || \"\";\n                    }\n                }\n            } catch (e) {\n                console.log(\"error getting term-specific data\", e);\n            }\n        }\n\n        return result;\n    }\n}\n"
  },
  {
    "path": "frontend/app/store/windowtype.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// waveWindowType is set once at startup and never changes.\nlet waveWindowType: \"tab\" | \"builder\" | \"preview\" = \"tab\";\n\nfunction getWaveWindowType(): \"tab\" | \"builder\" | \"preview\" {\n    return waveWindowType;\n}\n\nfunction isBuilderWindow(): boolean {\n    return waveWindowType === \"builder\";\n}\n\nfunction isTabWindow(): boolean {\n    return waveWindowType === \"tab\";\n}\n\nfunction isPreviewWindow(): boolean {\n    return waveWindowType === \"preview\";\n}\n\nfunction setWaveWindowType(windowType: \"tab\" | \"builder\" | \"preview\") {\n    waveWindowType = windowType;\n}\n\nexport { getWaveWindowType, isBuilderWindow, isPreviewWindow, isTabWindow, setWaveWindowType };\n"
  },
  {
    "path": "frontend/app/store/wos.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// WaveObjectStore\n\nimport { isPreviewWindow } from \"@/app/store/windowtype\";\nimport { waveEventSubscribeSingle } from \"@/app/store/wps\";\nimport { getWebServerEndpoint } from \"@/util/endpoints\";\nimport { fetch } from \"@/util/fetchutil\";\nimport { fireAndForget } from \"@/util/util\";\nimport { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from \"jotai\";\nimport { globalStore } from \"./jotaiStore\";\nimport { ObjectService } from \"./services\";\n\ntype WaveObjectDataItemType<T extends WaveObj> = {\n    value: T;\n    loading: boolean;\n};\n\ntype WaveObjectValue<T extends WaveObj> = {\n    pendingPromise: Promise<T>;\n    dataAtom: PrimitiveAtom<WaveObjectDataItemType<T>>;\n};\n\nfunction splitORef(oref: string): [string, string] {\n    const parts = oref.split(\":\");\n    if (parts.length != 2) {\n        throw new Error(\"invalid oref\");\n    }\n    return [parts[0], parts[1]];\n}\n\nfunction isBlank(str: string): boolean {\n    return str == null || str == \"\";\n}\n\nfunction isBlankNum(num: number): boolean {\n    return num == null || isNaN(num) || num == 0;\n}\n\nfunction isValidWaveObj(val: WaveObj): boolean {\n    if (val == null) {\n        return false;\n    }\n    if (isBlank(val.otype) || isBlank(val.oid) || isBlankNum(val.version)) {\n        return false;\n    }\n    return true;\n}\n\nfunction makeORef(otype: string, oid: string): string {\n    if (isBlank(otype) || isBlank(oid)) {\n        return null;\n    }\n    return `${otype}:${oid}`;\n}\n\nconst previewMockObjects: Map<string, WaveObj> = new Map();\n\nfunction mockObjectForPreview<T extends WaveObj>(oref: string, obj: T): void {\n    if (!isPreviewWindow()) {\n        throw new Error(\"mockObjectForPreview can only be called in a preview window\");\n    }\n    previewMockObjects.set(oref, obj);\n}\n\nfunction GetObject<T>(oref: string): Promise<T> {\n    if (isPreviewWindow()) {\n        return Promise.resolve((previewMockObjects.get(oref) as T) ?? null);\n    }\n    return callBackendService(\"object\", \"GetObject\", [oref], true);\n}\n\nfunction debugLogBackendCall(methodName: string, durationStr: string, args: any[]) {\n    durationStr = \"| \" + durationStr;\n    if (methodName == \"object.UpdateObject\" && args.length > 0) {\n        console.log(\"[service] object.UpdateObject\", args[0].otype, args[0].oid, durationStr, args[0]);\n        return;\n    }\n    if (methodName == \"object.GetObject\" && args.length > 0) {\n        console.log(\"[service] object.GetObject\", args[0], durationStr);\n        return;\n    }\n    if (methodName == \"file.StatFile\" && args.length >= 2) {\n        console.log(\"[service] file.StatFile\", args[1], durationStr);\n        return;\n    }\n    console.log(\"[service]\", methodName, durationStr);\n}\n\nfunction wpsSubscribeToObject(oref: string): () => void {\n    return waveEventSubscribeSingle({\n        eventType: \"waveobj:update\",\n        scope: oref,\n        handler: (event) => {\n            updateWaveObject(event.data);\n        },\n    });\n}\n\nfunction callBackendService(service: string, method: string, args: any[], noUIContext?: boolean): Promise<any> {\n    const startTs = Date.now();\n    let uiContext: UIContext = null;\n    if (!noUIContext && globalThis.window != null) {\n        uiContext = globalStore.get(((window as any).globalAtoms as GlobalAtomsType).uiContext);\n    }\n    const waveCall: WebCallType = {\n        service: service,\n        method: method,\n        args: args,\n        uicontext: uiContext,\n    };\n    // usp is just for debugging (easier to filter URLs)\n    const methodName = `${service}.${method}`;\n    const usp = new URLSearchParams();\n    usp.set(\"service\", service);\n    usp.set(\"method\", method);\n    const webEndpoint = getWebServerEndpoint();\n    if (webEndpoint == null) throw new Error(`cannot call ${methodName}: no web endpoint`);\n    const url = webEndpoint + \"/wave/service?\" + usp.toString();\n    const fetchPromise = fetch(url, {\n        method: \"POST\",\n        body: JSON.stringify(waveCall),\n    });\n    const prtn = fetchPromise\n        .then((resp) => {\n            if (!resp.ok) {\n                throw new Error(`call ${methodName} failed: ${resp.status} ${resp.statusText}`);\n            }\n            return resp.json();\n        })\n        .then((respData: WebReturnType) => {\n            if (respData == null) {\n                return null;\n            }\n            if (respData.updates != null) {\n                updateWaveObjects(respData.updates);\n            }\n            if (respData.error != null) {\n                throw new Error(`call ${methodName} error: ${respData.error}`);\n            }\n            const durationStr = Date.now() - startTs + \"ms\";\n            debugLogBackendCall(methodName, durationStr, args);\n            return respData.data;\n        });\n    return prtn;\n}\n\nconst waveObjectValueCache = new Map<string, WaveObjectValue<any>>();\n\nfunction reloadWaveObject<T extends WaveObj>(oref: string): Promise<T> {\n    let wov = waveObjectValueCache.get(oref);\n    if (wov === undefined) {\n        wov = getWaveObjectValue<T>(oref, true);\n        return wov.pendingPromise;\n    }\n    const prtn = GetObject<T>(oref);\n    prtn.then((val) => {\n        globalStore.set(wov.dataAtom, { value: val, loading: false });\n    });\n    return prtn;\n}\n\nfunction createWaveValueObject<T extends WaveObj>(oref: string, shouldFetch: boolean): WaveObjectValue<T> {\n    const wov = { pendingPromise: null, dataAtom: null };\n    wov.dataAtom = atom({ value: null, loading: true });\n    if (!shouldFetch) {\n        return wov;\n    }\n    const startTs = Date.now();\n    const localPromise = GetObject<T>(oref);\n    wov.pendingPromise = localPromise;\n    localPromise.then((val) => {\n        if (wov.pendingPromise != localPromise) {\n            return;\n        }\n        const [otype, oid] = splitORef(oref);\n        if (val != null) {\n            if (val[\"otype\"] != otype) {\n                throw new Error(\"GetObject returned wrong type\");\n            }\n            if (val[\"oid\"] != oid) {\n                throw new Error(\"GetObject returned wrong id\");\n            }\n        }\n        wov.pendingPromise = null;\n        globalStore.set(wov.dataAtom, { value: val, loading: false });\n        console.log(\"WaveObj resolved\", oref, Date.now() - startTs + \"ms\");\n    });\n    return wov;\n}\n\nfunction getWaveObjectValue<T extends WaveObj>(oref: string, createIfMissing = true): WaveObjectValue<T> {\n    let wov = waveObjectValueCache.get(oref);\n    if (wov === undefined && createIfMissing) {\n        wov = createWaveValueObject(oref, true);\n        waveObjectValueCache.set(oref, wov);\n    }\n    return wov;\n}\n\nfunction loadAndPinWaveObject<T extends WaveObj>(oref: string): Promise<T> {\n    const wov = getWaveObjectValue<T>(oref);\n    if (wov.pendingPromise == null) {\n        const dataValue = globalStore.get(wov.dataAtom);\n        return Promise.resolve(dataValue.value);\n    }\n    return wov.pendingPromise;\n}\n\nconst waveObjectDerivedAtomCache = new Map<string, Atom<any>>();\n\nfunction getWaveObjectAtom<T extends WaveObj>(oref: string): Atom<T> {\n    const cacheKey = oref + \":value\";\n    let cachedAtom = waveObjectDerivedAtomCache.get(cacheKey) as Atom<T>;\n    if (cachedAtom != null) {\n        return cachedAtom;\n    }\n    const wov = getWaveObjectValue<T>(oref);\n    cachedAtom = atom((get) => get(wov.dataAtom).value);\n    waveObjectDerivedAtomCache.set(cacheKey, cachedAtom);\n    return cachedAtom;\n}\n\nfunction getWaveObjectLoadingAtom(oref: string): Atom<boolean> {\n    const cacheKey = oref + \":loading\";\n    let cachedAtom = waveObjectDerivedAtomCache.get(cacheKey) as Atom<boolean>;\n    if (cachedAtom != null) {\n        return cachedAtom;\n    }\n    const wov = getWaveObjectValue(oref);\n    cachedAtom = atom((get) => {\n        const dataValue = get(wov.dataAtom);\n        return dataValue.loading;\n    });\n    waveObjectDerivedAtomCache.set(cacheKey, cachedAtom);\n    return cachedAtom;\n}\n\nfunction isWaveObjectNullAtom(oref: string): Atom<boolean> {\n    const cacheKey = oref + \":isnull\";\n    let cachedAtom = waveObjectDerivedAtomCache.get(cacheKey) as Atom<boolean>;\n    if (cachedAtom != null) {\n        return cachedAtom;\n    }\n    cachedAtom = atom((get) => get(getWaveObjectAtom(oref)) == null);\n    waveObjectDerivedAtomCache.set(cacheKey, cachedAtom);\n    return cachedAtom;\n}\n\nfunction useWaveObjectValue<T extends WaveObj>(oref: string): [T, boolean] {\n    const wov = getWaveObjectValue<T>(oref);\n    const atomVal = useAtomValue(wov.dataAtom);\n    return [atomVal.value, atomVal.loading];\n}\n\nfunction updateWaveObject(update: WaveObjUpdate) {\n    if (update == null) {\n        return;\n    }\n    const oref = makeORef(update.otype, update.oid);\n    const wov = getWaveObjectValue(oref);\n    if (update.updatetype == \"delete\") {\n        console.log(\"WaveObj deleted\", oref);\n        globalStore.set(wov.dataAtom, { value: null, loading: false });\n    } else {\n        if (!isValidWaveObj(update.obj)) {\n            console.log(\"invalid wave object update\", update);\n            return;\n        }\n        const curValue: WaveObjectDataItemType<WaveObj> = globalStore.get(wov.dataAtom);\n        if (curValue.value != null && curValue.value.version >= update.obj.version) {\n            return;\n        }\n        console.log(\"WaveObj updated\", oref);\n        globalStore.set(wov.dataAtom, { value: update.obj, loading: false });\n    }\n    return;\n}\n\nfunction updateWaveObjects(vals: WaveObjUpdate[]) {\n    for (const val of vals) {\n        updateWaveObject(val);\n    }\n}\n\n// gets the value of a WaveObject from the cache.\n// should provide getFn if it is available (e.g. inside of a jotai atom)\n// otherwise it will use the globalStore.get function\nfunction getObjectValue<T extends WaveObj>(oref: string, getFn?: Getter): T {\n    const wov = getWaveObjectValue<T>(oref);\n    if (getFn == null) {\n        getFn = globalStore.get;\n    }\n    const atomVal = getFn(wov.dataAtom);\n    return atomVal.value;\n}\n\n// sets the value of a WaveObject in the cache.\n// should provide setFn if it is available (e.g. inside of a jotai atom)\n// otherwise it will use the globalStore.set function\nfunction setObjectValue<T extends WaveObj>(value: T, setFn?: Setter, pushToServer?: boolean) {\n    const oref = makeORef(value.otype, value.oid);\n    const wov = getWaveObjectValue(oref, false);\n    if (wov === undefined) {\n        return;\n    }\n    if (setFn === undefined) {\n        setFn = globalStore.set;\n    }\n    setFn(wov.dataAtom, { value: value, loading: false });\n    if (pushToServer) {\n        fireAndForget(() => ObjectService.UpdateObject(value, false));\n    }\n}\n\nexport {\n    callBackendService,\n    getObjectValue,\n    getWaveObjectAtom,\n    getWaveObjectLoadingAtom,\n    isWaveObjectNullAtom,\n    loadAndPinWaveObject,\n    makeORef,\n    mockObjectForPreview,\n    reloadWaveObject,\n    setObjectValue,\n    splitORef,\n    updateWaveObject,\n    updateWaveObjects,\n    useWaveObjectValue,\n    wpsSubscribeToObject,\n};\n"
  },
  {
    "path": "frontend/app/store/wps.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { WshClient } from \"@/app/store/wshclient\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { isPreviewWindow } from \"@/app/store/windowtype\";\nimport { isBlank } from \"@/util/util\";\nimport { Subject } from \"rxjs\";\n\nlet WpsRpcClient: WshClient;\n\nfunction setWpsRpcClient(client: WshClient) {\n    WpsRpcClient = client;\n}\n\ntype WaveEventSubject<T extends WaveEventName = WaveEventName> = {\n    handler: (event: Extract<WaveEvent, { event: T }>) => void;\n    scope?: string;\n};\n\ntype WaveEventSubjectContainer = {\n    handler: (event: WaveEvent) => void;\n    scope?: string;\n    id: string;\n};\n\ntype WaveEventSubscription<T extends WaveEventName = WaveEventName> = WaveEventSubject<T> & {\n    eventType: T;\n};\n\ntype WaveEventUnsubscribe = {\n    id: string;\n    eventType: string;\n};\n\n// key is \"eventType\" or \"eventType|oref\"\nconst fileSubjects = new Map<string, SubjectWithRef<WSFileEventData>>();\nconst waveEventSubjects = new Map<string, WaveEventSubjectContainer[]>();\n\nfunction wpsReconnectHandler() {\n    for (const eventType of waveEventSubjects.keys()) {\n        updateWaveEventSub(eventType);\n    }\n}\n\nfunction updateWaveEventSub(eventType: string) {\n    if (isPreviewWindow()) {\n        return;\n    }\n    const subjects = waveEventSubjects.get(eventType);\n    if (subjects == null) {\n        RpcApi.EventUnsubCommand(WpsRpcClient, eventType, { noresponse: true });\n        return;\n    }\n    const subreq: SubscriptionRequest = { event: eventType, scopes: [], allscopes: false };\n    for (const scont of subjects) {\n        if (isBlank(scont.scope)) {\n            subreq.allscopes = true;\n            subreq.scopes = [];\n            break;\n        }\n        subreq.scopes.push(scont.scope);\n    }\n    RpcApi.EventSubCommand(WpsRpcClient, subreq, { noresponse: true });\n}\n\nfunction waveEventSubscribeSingle<T extends WaveEventName>(subscription: WaveEventSubscription<T>): () => void {\n    // console.log(\"waveEventSubscribeSingle\", subscription);\n    if (subscription.handler == null) {\n        return () => {};\n    }\n    const id: string = crypto.randomUUID();\n    let subjects = waveEventSubjects.get(subscription.eventType);\n    if (subjects == null) {\n        subjects = [];\n        waveEventSubjects.set(subscription.eventType, subjects);\n    }\n    const subcont: WaveEventSubjectContainer = {\n        id,\n        handler: subscription.handler as (event: WaveEvent) => void,\n        scope: subscription.scope,\n    };\n    subjects.push(subcont);\n    updateWaveEventSub(subscription.eventType);\n    return () => waveEventUnsubscribe({ id, eventType: subscription.eventType });\n}\n\nfunction waveEventUnsubscribe(...unsubscribes: WaveEventUnsubscribe[]) {\n    const eventTypeSet = new Set<string>();\n    for (const unsubscribe of unsubscribes) {\n        const subjects = waveEventSubjects.get(unsubscribe.eventType);\n        if (subjects == null) {\n            return;\n        }\n        const idx = subjects.findIndex((s) => s.id === unsubscribe.id);\n        if (idx === -1) {\n            return;\n        }\n        subjects.splice(idx, 1);\n        if (subjects.length === 0) {\n            waveEventSubjects.delete(unsubscribe.eventType);\n        }\n        eventTypeSet.add(unsubscribe.eventType);\n    }\n\n    for (const eventType of eventTypeSet) {\n        updateWaveEventSub(eventType);\n    }\n}\n\nfunction getFileSubject(zoneId: string, fileName: string): SubjectWithRef<WSFileEventData> {\n    const subjectKey = zoneId + \"|\" + fileName;\n    let subject = fileSubjects.get(subjectKey);\n    if (subject == null) {\n        subject = new Subject<any>() as any;\n        subject.refCount = 0;\n        subject.release = () => {\n            subject.refCount--;\n            if (subject.refCount === 0) {\n                subject.complete();\n                fileSubjects.delete(subjectKey);\n            }\n        };\n        fileSubjects.set(subjectKey, subject);\n    }\n    subject.refCount++;\n    return subject;\n}\n\nfunction handleWaveEvent(event: WaveEvent) {\n    // console.log(\"handleWaveEvent\", event);\n    const subjects = waveEventSubjects.get(event.event);\n    if (subjects == null) {\n        return;\n    }\n    for (const scont of subjects) {\n        if (isBlank(scont.scope)) {\n            scont.handler(event);\n            continue;\n        }\n        if (event.scopes == null) {\n            continue;\n        }\n        if (event.scopes.includes(scont.scope)) {\n            scont.handler(event);\n        }\n    }\n}\n\nexport {\n    getFileSubject,\n    handleWaveEvent,\n    setWpsRpcClient,\n    waveEventSubscribeSingle,\n    waveEventUnsubscribe,\n    wpsReconnectHandler,\n};\n"
  },
  {
    "path": "frontend/app/store/ws.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { type WebSocket, newWebSocket } from \"@/util/wsutil\";\nimport debug from \"debug\";\nimport { sprintf } from \"sprintf-js\";\n\nconst AuthKeyHeader = \"X-AuthKey\";\n\nconst dlog = debug(\"wave:ws\");\n\nconst WarnWebSocketSendSize = 1024 * 1024; // 1MB\nconst MaxWebSocketSendSize = 5 * 1024 * 1024; // 5MB\nconst reconnectHandlers: (() => void)[] = [];\nconst StableConnTime = 2000;\n\nfunction addWSReconnectHandler(handler: () => void) {\n    reconnectHandlers.push(handler);\n}\n\nfunction removeWSReconnectHandler(handler: () => void) {\n    const index = reconnectHandlers.indexOf(handler);\n    if (index > -1) {\n        reconnectHandlers.splice(index, 1);\n    }\n}\n\ntype WSEventCallback = (arg0: WSEventType) => void;\n\ntype ElectronOverrideOpts = {\n    authKey: string;\n};\n\nclass WSControl {\n    wsConn: WebSocket;\n    open: boolean;\n    opening: boolean = false;\n    reconnectTimes: number = 0;\n    msgQueue: any[] = [];\n    stableId: string;\n    messageCallback: WSEventCallback;\n    watchSessionId: string = null;\n    watchScreenId: string = null;\n    wsLog: string[] = [];\n    baseHostPort: string;\n    lastReconnectTime: number = 0;\n    eoOpts: ElectronOverrideOpts;\n    noReconnect: boolean = false;\n    onOpenTimeoutId: NodeJS.Timeout = null;\n\n    constructor(\n        baseHostPort: string,\n        stableId: string,\n        messageCallback: WSEventCallback,\n        electronOverrideOpts?: ElectronOverrideOpts\n    ) {\n        this.baseHostPort = baseHostPort;\n        this.messageCallback = messageCallback;\n        this.stableId = stableId;\n        this.open = false;\n        this.eoOpts = electronOverrideOpts;\n        setInterval(this.sendPing.bind(this), 5000);\n    }\n\n    shutdown() {\n        this.noReconnect = true;\n        this.wsConn.close();\n    }\n\n    connectNow(desc: string) {\n        if (this.open || this.noReconnect) {\n            return;\n        }\n        this.lastReconnectTime = Date.now();\n        dlog(\"try reconnect:\", desc);\n        this.opening = true;\n        this.wsConn = newWebSocket(\n            this.baseHostPort + \"/ws?stableid=\" + encodeURIComponent(this.stableId),\n            this.eoOpts\n                ? {\n                      [AuthKeyHeader]: this.eoOpts.authKey,\n                  }\n                : null\n        );\n        this.wsConn.onopen = (e: Event) => {\n            this.onopen(e);\n        };\n        this.wsConn.onmessage = (e: MessageEvent) => {\n            this.onmessage(e);\n        };\n        this.wsConn.onclose = (e: CloseEvent) => {\n            this.onclose(e);\n        };\n        // turns out onerror is not necessary (onclose always follows onerror)\n        // this.wsConn.onerror = this.onerror;\n    }\n\n    reconnect(forceClose?: boolean) {\n        if (this.noReconnect) {\n            return;\n        }\n        if (this.open) {\n            if (forceClose) {\n                this.wsConn.close(); // this will force a reconnect\n            }\n            return;\n        }\n        this.reconnectTimes++;\n        if (this.reconnectTimes > 20) {\n            dlog(\"cannot connect, giving up\");\n            return;\n        }\n        const timeoutArr = [0, 0, 2, 5, 10, 10, 30, 60];\n        let timeout = 60;\n        if (this.reconnectTimes < timeoutArr.length) {\n            timeout = timeoutArr[this.reconnectTimes];\n        }\n        if (Date.now() - this.lastReconnectTime < 500) {\n            timeout = 1;\n        }\n        if (timeout > 0) {\n            dlog(sprintf(\"sleeping %ds\", timeout));\n        }\n        setTimeout(() => {\n            this.connectNow(String(this.reconnectTimes));\n        }, timeout * 1000);\n    }\n\n    onclose(event: CloseEvent) {\n        // console.log(\"close\", event);\n        if (this.onOpenTimeoutId) {\n            clearTimeout(this.onOpenTimeoutId);\n        }\n        if (event.wasClean) {\n            dlog(\"connection closed\");\n        } else {\n            dlog(\"connection error/disconnected\");\n        }\n        if (this.open || this.opening) {\n            this.open = false;\n            this.opening = false;\n            this.reconnect();\n        }\n    }\n\n    onopen(e: Event) {\n        dlog(\"connection open\");\n        this.open = true;\n        this.opening = false;\n        this.onOpenTimeoutId = setTimeout(() => {\n            this.reconnectTimes = 0;\n            dlog(\"clear reconnect times\");\n        }, StableConnTime);\n        for (let handler of reconnectHandlers) {\n            handler();\n        }\n        this.runMsgQueue();\n    }\n\n    runMsgQueue() {\n        if (!this.open) {\n            return;\n        }\n        if (this.msgQueue.length == 0) {\n            return;\n        }\n        const msg = this.msgQueue.shift();\n        this.sendMessage(msg);\n        setTimeout(() => {\n            this.runMsgQueue();\n        }, 100);\n    }\n\n    onmessage(event: MessageEvent) {\n        let eventData = null;\n        if (event.data != null) {\n            eventData = JSON.parse(event.data);\n        }\n        if (eventData == null) {\n            return;\n        }\n        if (eventData.type == \"ping\") {\n            this.wsConn.send(JSON.stringify({ type: \"pong\", stime: Date.now() }));\n            return;\n        }\n        if (eventData.type == \"pong\") {\n            // nothing\n            return;\n        }\n        if (this.messageCallback) {\n            try {\n                this.messageCallback(eventData);\n            } catch (e) {\n                console.log(\"[error] messageCallback\", e);\n            }\n        }\n    }\n\n    sendPing() {\n        if (!this.open) {\n            return;\n        }\n        this.wsConn.send(JSON.stringify({ type: \"ping\", stime: Date.now() }));\n    }\n\n    sendMessage(data: WSCommandType) {\n        if (!this.open) {\n            return;\n        }\n        const msg = JSON.stringify(data);\n        const byteSize = new Blob([msg]).size;\n        if (byteSize > MaxWebSocketSendSize) {\n            console.log(\"ws message too large\", byteSize, data.wscommand, msg.substring(0, 100));\n            return;\n        }\n        if (byteSize > WarnWebSocketSendSize) {\n            console.log(\"ws message large\", byteSize, data.wscommand, msg.substring(0, 100));\n        }\n        this.wsConn.send(msg);\n    }\n\n    pushMessage(data: WSCommandType) {\n        if (!this.open) {\n            if (data.wscommand === \"rpc\" && data.message) {\n                const cmd = data.message.command;\n                if (cmd === \"routeannounce\" || cmd === \"routeunannounce\") {\n                    return;\n                }\n            }\n            this.msgQueue.push(data);\n            return;\n        }\n        this.sendMessage(data);\n    }\n}\n\nlet globalWS: WSControl;\nfunction initGlobalWS(\n    baseHostPort: string,\n    stableId: string,\n    messageCallback: WSEventCallback,\n    electronOverrideOpts?: ElectronOverrideOpts\n) {\n    globalWS = new WSControl(baseHostPort, stableId, messageCallback, electronOverrideOpts);\n}\n\nfunction sendRawRpcMessage(msg: RpcMessage) {\n    const wsMsg: WSRpcCommand = { wscommand: \"rpc\", message: msg };\n    sendWSCommand(wsMsg);\n}\n\nfunction sendWSCommand(cmd: WSCommandType) {\n    globalWS?.pushMessage(cmd);\n}\n\nexport {\n    WSControl,\n    addWSReconnectHandler,\n    globalWS,\n    initGlobalWS,\n    removeWSReconnectHandler,\n    sendRawRpcMessage,\n    sendWSCommand,\n    type ElectronOverrideOpts,\n};\n"
  },
  {
    "path": "frontend/app/store/wshclient.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { sendRpcCommand, sendRpcResponse } from \"@/app/store/wshrpcutil-base\";\nimport * as util from \"@/util/util\";\n\nconst notFoundLogMap = new Map<string, boolean>();\n\nclass RpcResponseHelper {\n    client: WshClient;\n    cmdMsg: RpcMessage;\n    done: boolean;\n\n    constructor(client: WshClient, cmdMsg: RpcMessage) {\n        this.client = client;\n        this.cmdMsg = cmdMsg;\n        // if reqid is null, no response required\n        this.done = cmdMsg.reqid == null;\n    }\n\n    getSource(): string {\n        return this.cmdMsg?.source;\n    }\n\n    sendResponse(msg: RpcMessage) {\n        if (this.done || util.isBlank(this.cmdMsg.reqid)) {\n            return;\n        }\n        if (msg == null) {\n            msg = {};\n        }\n        msg.resid = this.cmdMsg.reqid;\n        msg.source = this.client.routeId;\n        sendRpcResponse(msg);\n        if (!msg.cont) {\n            this.done = true;\n            this.client.openRpcs.delete(this.cmdMsg.reqid);\n        }\n    }\n}\n\nclass WshClient {\n    routeId: string;\n    openRpcs: Map<string, ClientRpcEntry> = new Map();\n\n    constructor(routeId: string) {\n        this.routeId = routeId;\n    }\n\n    wshRpcCall(command: string, data: any, opts: RpcOpts): Promise<any> {\n        const msg: RpcMessage = {\n            command: command,\n            data: data,\n            source: this.routeId,\n        };\n        if (!opts?.noresponse) {\n            msg.reqid = crypto.randomUUID();\n        }\n        if (opts?.timeout) {\n            msg.timeout = opts.timeout;\n        }\n        if (opts?.route) {\n            msg.route = opts.route;\n        }\n        const rpcGen = sendRpcCommand(this.openRpcs, msg);\n        if (rpcGen == null) {\n            return null;\n        }\n        const respMsgPromise = rpcGen.next(true); // pass true to force termination of rpc after 1 response (not streaming)\n        return respMsgPromise.then((msg: IteratorResult<any, void>) => {\n            return msg.value;\n        });\n    }\n\n    wshRpcStream(command: string, data: any, opts: RpcOpts): AsyncGenerator<any, void, boolean> {\n        if (opts?.noresponse) {\n            throw new Error(\"noresponse not supported for responsestream calls\");\n        }\n        const msg: RpcMessage = {\n            command: command,\n            data: data,\n            reqid: crypto.randomUUID(),\n            source: this.routeId,\n        };\n        if (opts?.timeout) {\n            msg.timeout = opts.timeout;\n        }\n        if (opts?.route) {\n            msg.route = opts.route;\n        }\n        const rpcGen = sendRpcCommand(this.openRpcs, msg);\n        return rpcGen;\n    }\n\n    async handleIncomingCommand(msg: RpcMessage) {\n        // TODO implement a timeout (setTimeout + sendResponse)\n        const helper = new RpcResponseHelper(this, msg);\n        const handlerName = `handle_${msg.command}`;\n        try {\n            let result: any = null;\n            let prtn: any = null;\n            if (handlerName in this) {\n                prtn = this[handlerName](helper, msg.data);\n            } else {\n                prtn = this.handle_default(helper, msg);\n            }\n            if (prtn instanceof Promise) {\n                result = await prtn;\n            } else {\n                result = prtn;\n            }\n            if (!helper.done) {\n                helper.sendResponse({ data: result });\n            }\n        } catch (e) {\n            if (!helper.done) {\n                helper.sendResponse({ error: e.message });\n            } else {\n                console.log(`rpc-client[${this.routeId}] command[${msg.command}] error`, e.message);\n            }\n        } finally {\n            if (!helper.done) {\n                helper.sendResponse({});\n            }\n        }\n        return;\n    }\n\n    recvRpcMessage(msg: RpcMessage) {\n        const isRequest = msg.command != null || msg.reqid != null;\n        if (isRequest) {\n            this.handleIncomingCommand(msg);\n            return;\n        }\n        if (msg.resid == null) {\n            console.log(\"rpc response missing resid\", msg);\n            return;\n        }\n        const entry = this.openRpcs.get(msg.resid);\n        if (entry == null) {\n            if (!notFoundLogMap.has(msg.resid)) {\n                notFoundLogMap.set(msg.resid, true);\n                console.log(\"rpc response generator not found\", msg);\n            }\n            return;\n        }\n        entry.msgFn(msg);\n    }\n\n    async handle_message(helper: RpcResponseHelper, data: CommandMessageData): Promise<void> {\n        console.log(`rpc:message[${this.routeId}]`, data?.message);\n    }\n\n    async handle_default(helper: RpcResponseHelper, msg: RpcMessage): Promise<void> {\n        throw new Error(`rpc command \"${msg.command}\" not supported by [${this.routeId}]`);\n    }\n}\n\nexport { RpcResponseHelper, WshClient };\n"
  },
  {
    "path": "frontend/app/store/wshclientapi.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// generated by cmd/generate/main-generatets.go\n\nimport { WshClient } from \"./wshclient\";\n\nexport interface MockRpcClient {\n    mockWshRpcCall(client: WshClient, command: string, data: any, opts?: RpcOpts): Promise<any>;\n    mockWshRpcStream(client: WshClient, command: string, data: any, opts?: RpcOpts): AsyncGenerator<any, void, boolean>;\n}\n\n// WshServerCommandToDeclMap\nexport class RpcApiType {\n    mockClient: MockRpcClient = null;\n\n    setMockRpcClient(client: MockRpcClient): void {\n        this.mockClient = client;\n    }\n\n    // command \"activity\" [call]\n    ActivityCommand(client: WshClient, data: ActivityUpdate, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"activity\", data, opts);\n        return client.wshRpcCall(\"activity\", data, opts);\n    }\n\n    // command \"aisendmessage\" [call]\n    AiSendMessageCommand(client: WshClient, data: AiMessageData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"aisendmessage\", data, opts);\n        return client.wshRpcCall(\"aisendmessage\", data, opts);\n    }\n\n    // command \"authenticate\" [call]\n    AuthenticateCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<CommandAuthenticateRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"authenticate\", data, opts);\n        return client.wshRpcCall(\"authenticate\", data, opts);\n    }\n\n    // command \"authenticatejobmanager\" [call]\n    AuthenticateJobManagerCommand(client: WshClient, data: CommandAuthenticateJobManagerData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"authenticatejobmanager\", data, opts);\n        return client.wshRpcCall(\"authenticatejobmanager\", data, opts);\n    }\n\n    // command \"authenticatejobmanagerverify\" [call]\n    AuthenticateJobManagerVerifyCommand(client: WshClient, data: CommandAuthenticateJobManagerData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"authenticatejobmanagerverify\", data, opts);\n        return client.wshRpcCall(\"authenticatejobmanagerverify\", data, opts);\n    }\n\n    // command \"authenticatetojobmanager\" [call]\n    AuthenticateToJobManagerCommand(client: WshClient, data: CommandAuthenticateToJobData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"authenticatetojobmanager\", data, opts);\n        return client.wshRpcCall(\"authenticatetojobmanager\", data, opts);\n    }\n\n    // command \"authenticatetoken\" [call]\n    AuthenticateTokenCommand(client: WshClient, data: CommandAuthenticateTokenData, opts?: RpcOpts): Promise<CommandAuthenticateRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"authenticatetoken\", data, opts);\n        return client.wshRpcCall(\"authenticatetoken\", data, opts);\n    }\n\n    // command \"authenticatetokenverify\" [call]\n    AuthenticateTokenVerifyCommand(client: WshClient, data: CommandAuthenticateTokenData, opts?: RpcOpts): Promise<CommandAuthenticateRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"authenticatetokenverify\", data, opts);\n        return client.wshRpcCall(\"authenticatetokenverify\", data, opts);\n    }\n\n    // command \"badgewatchpid\" [call]\n    BadgeWatchPidCommand(client: WshClient, data: CommandBadgeWatchPidData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"badgewatchpid\", data, opts);\n        return client.wshRpcCall(\"badgewatchpid\", data, opts);\n    }\n\n    // command \"blockinfo\" [call]\n    BlockInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<BlockInfoData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"blockinfo\", data, opts);\n        return client.wshRpcCall(\"blockinfo\", data, opts);\n    }\n\n    // command \"blockjobstatus\" [call]\n    BlockJobStatusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<BlockJobStatusData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"blockjobstatus\", data, opts);\n        return client.wshRpcCall(\"blockjobstatus\", data, opts);\n    }\n\n    // command \"blockslist\" [call]\n    BlocksListCommand(client: WshClient, data: BlocksListRequest, opts?: RpcOpts): Promise<BlocksListEntry[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"blockslist\", data, opts);\n        return client.wshRpcCall(\"blockslist\", data, opts);\n    }\n\n    // command \"captureblockscreenshot\" [call]\n    CaptureBlockScreenshotCommand(client: WshClient, data: CommandCaptureBlockScreenshotData, opts?: RpcOpts): Promise<string> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"captureblockscreenshot\", data, opts);\n        return client.wshRpcCall(\"captureblockscreenshot\", data, opts);\n    }\n\n    // command \"checkgoversion\" [call]\n    CheckGoVersionCommand(client: WshClient, opts?: RpcOpts): Promise<CommandCheckGoVersionRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"checkgoversion\", null, opts);\n        return client.wshRpcCall(\"checkgoversion\", null, opts);\n    }\n\n    // command \"connconnect\" [call]\n    ConnConnectCommand(client: WshClient, data: ConnRequest, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"connconnect\", data, opts);\n        return client.wshRpcCall(\"connconnect\", data, opts);\n    }\n\n    // command \"conndisconnect\" [call]\n    ConnDisconnectCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"conndisconnect\", data, opts);\n        return client.wshRpcCall(\"conndisconnect\", data, opts);\n    }\n\n    // command \"connensure\" [call]\n    ConnEnsureCommand(client: WshClient, data: ConnExtData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"connensure\", data, opts);\n        return client.wshRpcCall(\"connensure\", data, opts);\n    }\n\n    // command \"connlist\" [call]\n    ConnListCommand(client: WshClient, opts?: RpcOpts): Promise<string[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"connlist\", null, opts);\n        return client.wshRpcCall(\"connlist\", null, opts);\n    }\n\n    // command \"connreinstallwsh\" [call]\n    ConnReinstallWshCommand(client: WshClient, data: ConnExtData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"connreinstallwsh\", data, opts);\n        return client.wshRpcCall(\"connreinstallwsh\", data, opts);\n    }\n\n    // command \"connserverinit\" [call]\n    ConnServerInitCommand(client: WshClient, data: CommandConnServerInitData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"connserverinit\", data, opts);\n        return client.wshRpcCall(\"connserverinit\", data, opts);\n    }\n\n    // command \"connstatus\" [call]\n    ConnStatusCommand(client: WshClient, opts?: RpcOpts): Promise<ConnStatus[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"connstatus\", null, opts);\n        return client.wshRpcCall(\"connstatus\", null, opts);\n    }\n\n    // command \"connupdatewsh\" [call]\n    ConnUpdateWshCommand(client: WshClient, data: RemoteInfo, opts?: RpcOpts): Promise<boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"connupdatewsh\", data, opts);\n        return client.wshRpcCall(\"connupdatewsh\", data, opts);\n    }\n\n    // command \"controlgetrouteid\" [call]\n    ControlGetRouteIdCommand(client: WshClient, opts?: RpcOpts): Promise<string> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"controlgetrouteid\", null, opts);\n        return client.wshRpcCall(\"controlgetrouteid\", null, opts);\n    }\n\n    // command \"controllerappendoutput\" [call]\n    ControllerAppendOutputCommand(client: WshClient, data: CommandControllerAppendOutputData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"controllerappendoutput\", data, opts);\n        return client.wshRpcCall(\"controllerappendoutput\", data, opts);\n    }\n\n    // command \"controllerdestroy\" [call]\n    ControllerDestroyCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"controllerdestroy\", data, opts);\n        return client.wshRpcCall(\"controllerdestroy\", data, opts);\n    }\n\n    // command \"controllerinput\" [call]\n    ControllerInputCommand(client: WshClient, data: CommandBlockInputData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"controllerinput\", data, opts);\n        return client.wshRpcCall(\"controllerinput\", data, opts);\n    }\n\n    // command \"controllerresync\" [call]\n    ControllerResyncCommand(client: WshClient, data: CommandControllerResyncData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"controllerresync\", data, opts);\n        return client.wshRpcCall(\"controllerresync\", data, opts);\n    }\n\n    // command \"createblock\" [call]\n    CreateBlockCommand(client: WshClient, data: CommandCreateBlockData, opts?: RpcOpts): Promise<ORef> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"createblock\", data, opts);\n        return client.wshRpcCall(\"createblock\", data, opts);\n    }\n\n    // command \"createsubblock\" [call]\n    CreateSubBlockCommand(client: WshClient, data: CommandCreateSubBlockData, opts?: RpcOpts): Promise<ORef> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"createsubblock\", data, opts);\n        return client.wshRpcCall(\"createsubblock\", data, opts);\n    }\n\n    // command \"debugterm\" [call]\n    DebugTermCommand(client: WshClient, data: CommandDebugTermData, opts?: RpcOpts): Promise<CommandDebugTermRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"debugterm\", data, opts);\n        return client.wshRpcCall(\"debugterm\", data, opts);\n    }\n\n    // command \"deleteappfile\" [call]\n    DeleteAppFileCommand(client: WshClient, data: CommandDeleteAppFileData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"deleteappfile\", data, opts);\n        return client.wshRpcCall(\"deleteappfile\", data, opts);\n    }\n\n    // command \"deleteblock\" [call]\n    DeleteBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"deleteblock\", data, opts);\n        return client.wshRpcCall(\"deleteblock\", data, opts);\n    }\n\n    // command \"deletebuilder\" [call]\n    DeleteBuilderCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"deletebuilder\", data, opts);\n        return client.wshRpcCall(\"deletebuilder\", data, opts);\n    }\n\n    // command \"deletesubblock\" [call]\n    DeleteSubBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"deletesubblock\", data, opts);\n        return client.wshRpcCall(\"deletesubblock\", data, opts);\n    }\n\n    // command \"dismisswshfail\" [call]\n    DismissWshFailCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"dismisswshfail\", data, opts);\n        return client.wshRpcCall(\"dismisswshfail\", data, opts);\n    }\n\n    // command \"dispose\" [call]\n    DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"dispose\", data, opts);\n        return client.wshRpcCall(\"dispose\", data, opts);\n    }\n\n    // command \"disposesuggestions\" [call]\n    DisposeSuggestionsCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"disposesuggestions\", data, opts);\n        return client.wshRpcCall(\"disposesuggestions\", data, opts);\n    }\n\n    // command \"electrondecrypt\" [call]\n    ElectronDecryptCommand(client: WshClient, data: CommandElectronDecryptData, opts?: RpcOpts): Promise<CommandElectronDecryptRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"electrondecrypt\", data, opts);\n        return client.wshRpcCall(\"electrondecrypt\", data, opts);\n    }\n\n    // command \"electronencrypt\" [call]\n    ElectronEncryptCommand(client: WshClient, data: CommandElectronEncryptData, opts?: RpcOpts): Promise<CommandElectronEncryptRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"electronencrypt\", data, opts);\n        return client.wshRpcCall(\"electronencrypt\", data, opts);\n    }\n\n    // command \"electronsystembell\" [call]\n    ElectronSystemBellCommand(client: WshClient, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"electronsystembell\", null, opts);\n        return client.wshRpcCall(\"electronsystembell\", null, opts);\n    }\n\n    // command \"eventpublish\" [call]\n    EventPublishCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"eventpublish\", data, opts);\n        return client.wshRpcCall(\"eventpublish\", data, opts);\n    }\n\n    // command \"eventreadhistory\" [call]\n    EventReadHistoryCommand(client: WshClient, data: CommandEventReadHistoryData, opts?: RpcOpts): Promise<WaveEvent[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"eventreadhistory\", data, opts);\n        return client.wshRpcCall(\"eventreadhistory\", data, opts);\n    }\n\n    // command \"eventrecv\" [call]\n    EventRecvCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"eventrecv\", data, opts);\n        return client.wshRpcCall(\"eventrecv\", data, opts);\n    }\n\n    // command \"eventsub\" [call]\n    EventSubCommand(client: WshClient, data: SubscriptionRequest, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"eventsub\", data, opts);\n        return client.wshRpcCall(\"eventsub\", data, opts);\n    }\n\n    // command \"eventunsub\" [call]\n    EventUnsubCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"eventunsub\", data, opts);\n        return client.wshRpcCall(\"eventunsub\", data, opts);\n    }\n\n    // command \"eventunsuball\" [call]\n    EventUnsubAllCommand(client: WshClient, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"eventunsuball\", null, opts);\n        return client.wshRpcCall(\"eventunsuball\", null, opts);\n    }\n\n    // command \"fetchsuggestions\" [call]\n    FetchSuggestionsCommand(client: WshClient, data: FetchSuggestionsData, opts?: RpcOpts): Promise<FetchSuggestionsResponse> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"fetchsuggestions\", data, opts);\n        return client.wshRpcCall(\"fetchsuggestions\", data, opts);\n    }\n\n    // command \"fileappend\" [call]\n    FileAppendCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"fileappend\", data, opts);\n        return client.wshRpcCall(\"fileappend\", data, opts);\n    }\n\n    // command \"filecopy\" [call]\n    FileCopyCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"filecopy\", data, opts);\n        return client.wshRpcCall(\"filecopy\", data, opts);\n    }\n\n    // command \"filecreate\" [call]\n    FileCreateCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"filecreate\", data, opts);\n        return client.wshRpcCall(\"filecreate\", data, opts);\n    }\n\n    // command \"filedelete\" [call]\n    FileDeleteCommand(client: WshClient, data: CommandDeleteFileData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"filedelete\", data, opts);\n        return client.wshRpcCall(\"filedelete\", data, opts);\n    }\n\n    // command \"fileinfo\" [call]\n    FileInfoCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<FileInfo> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"fileinfo\", data, opts);\n        return client.wshRpcCall(\"fileinfo\", data, opts);\n    }\n\n    // command \"filejoin\" [call]\n    FileJoinCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<FileInfo> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"filejoin\", data, opts);\n        return client.wshRpcCall(\"filejoin\", data, opts);\n    }\n\n    // command \"filelist\" [call]\n    FileListCommand(client: WshClient, data: FileListData, opts?: RpcOpts): Promise<FileInfo[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"filelist\", data, opts);\n        return client.wshRpcCall(\"filelist\", data, opts);\n    }\n\n    // command \"fileliststream\" [responsestream]\n\tFileListStreamCommand(client: WshClient, data: FileListData, opts?: RpcOpts): AsyncGenerator<CommandRemoteListEntriesRtnData, void, boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcStream(client, \"fileliststream\", data, opts);\n        return client.wshRpcStream(\"fileliststream\", data, opts);\n    }\n\n    // command \"filemkdir\" [call]\n    FileMkdirCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"filemkdir\", data, opts);\n        return client.wshRpcCall(\"filemkdir\", data, opts);\n    }\n\n    // command \"filemove\" [call]\n    FileMoveCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"filemove\", data, opts);\n        return client.wshRpcCall(\"filemove\", data, opts);\n    }\n\n    // command \"fileread\" [call]\n    FileReadCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<FileData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"fileread\", data, opts);\n        return client.wshRpcCall(\"fileread\", data, opts);\n    }\n\n    // command \"filereadstream\" [responsestream]\n\tFileReadStreamCommand(client: WshClient, data: FileData, opts?: RpcOpts): AsyncGenerator<FileData, void, boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcStream(client, \"filereadstream\", data, opts);\n        return client.wshRpcStream(\"filereadstream\", data, opts);\n    }\n\n    // command \"filerestorebackup\" [call]\n    FileRestoreBackupCommand(client: WshClient, data: CommandFileRestoreBackupData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"filerestorebackup\", data, opts);\n        return client.wshRpcCall(\"filerestorebackup\", data, opts);\n    }\n\n    // command \"filestream\" [call]\n    FileStreamCommand(client: WshClient, data: CommandFileStreamData, opts?: RpcOpts): Promise<FileInfo> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"filestream\", data, opts);\n        return client.wshRpcCall(\"filestream\", data, opts);\n    }\n\n    // command \"filewrite\" [call]\n    FileWriteCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"filewrite\", data, opts);\n        return client.wshRpcCall(\"filewrite\", data, opts);\n    }\n\n    // command \"findgitbash\" [call]\n    FindGitBashCommand(client: WshClient, data: boolean, opts?: RpcOpts): Promise<string> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"findgitbash\", data, opts);\n        return client.wshRpcCall(\"findgitbash\", data, opts);\n    }\n\n    // command \"focuswindow\" [call]\n    FocusWindowCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"focuswindow\", data, opts);\n        return client.wshRpcCall(\"focuswindow\", data, opts);\n    }\n\n    // command \"getallbadges\" [call]\n    GetAllBadgesCommand(client: WshClient, opts?: RpcOpts): Promise<BadgeEvent[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getallbadges\", null, opts);\n        return client.wshRpcCall(\"getallbadges\", null, opts);\n    }\n\n    // command \"getallvars\" [call]\n    GetAllVarsCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise<CommandVarResponseData[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getallvars\", data, opts);\n        return client.wshRpcCall(\"getallvars\", data, opts);\n    }\n\n    // command \"getbuilderoutput\" [call]\n    GetBuilderOutputCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<string[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getbuilderoutput\", data, opts);\n        return client.wshRpcCall(\"getbuilderoutput\", data, opts);\n    }\n\n    // command \"getbuilderstatus\" [call]\n    GetBuilderStatusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<BuilderStatusData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getbuilderstatus\", data, opts);\n        return client.wshRpcCall(\"getbuilderstatus\", data, opts);\n    }\n\n    // command \"getfocusedblockdata\" [call]\n    GetFocusedBlockDataCommand(client: WshClient, opts?: RpcOpts): Promise<FocusedBlockData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getfocusedblockdata\", null, opts);\n        return client.wshRpcCall(\"getfocusedblockdata\", null, opts);\n    }\n\n    // command \"getfullconfig\" [call]\n    GetFullConfigCommand(client: WshClient, opts?: RpcOpts): Promise<FullConfigType> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getfullconfig\", null, opts);\n        return client.wshRpcCall(\"getfullconfig\", null, opts);\n    }\n\n    // command \"getjwtpublickey\" [call]\n    GetJwtPublicKeyCommand(client: WshClient, opts?: RpcOpts): Promise<string> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getjwtpublickey\", null, opts);\n        return client.wshRpcCall(\"getjwtpublickey\", null, opts);\n    }\n\n    // command \"getmeta\" [call]\n    GetMetaCommand(client: WshClient, data: CommandGetMetaData, opts?: RpcOpts): Promise<MetaType> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getmeta\", data, opts);\n        return client.wshRpcCall(\"getmeta\", data, opts);\n    }\n\n    // command \"getrtinfo\" [call]\n    GetRTInfoCommand(client: WshClient, data: CommandGetRTInfoData, opts?: RpcOpts): Promise<ObjRTInfo> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getrtinfo\", data, opts);\n        return client.wshRpcCall(\"getrtinfo\", data, opts);\n    }\n\n    // command \"getsecrets\" [call]\n    GetSecretsCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<{[key: string]: string}> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getsecrets\", data, opts);\n        return client.wshRpcCall(\"getsecrets\", data, opts);\n    }\n\n    // command \"getsecretslinuxstoragebackend\" [call]\n    GetSecretsLinuxStorageBackendCommand(client: WshClient, opts?: RpcOpts): Promise<string> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getsecretslinuxstoragebackend\", null, opts);\n        return client.wshRpcCall(\"getsecretslinuxstoragebackend\", null, opts);\n    }\n\n    // command \"getsecretsnames\" [call]\n    GetSecretsNamesCommand(client: WshClient, opts?: RpcOpts): Promise<string[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getsecretsnames\", null, opts);\n        return client.wshRpcCall(\"getsecretsnames\", null, opts);\n    }\n\n    // command \"gettab\" [call]\n    GetTabCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<Tab> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"gettab\", data, opts);\n        return client.wshRpcCall(\"gettab\", data, opts);\n    }\n\n    // command \"gettempdir\" [call]\n    GetTempDirCommand(client: WshClient, data: CommandGetTempDirData, opts?: RpcOpts): Promise<string> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"gettempdir\", data, opts);\n        return client.wshRpcCall(\"gettempdir\", data, opts);\n    }\n\n    // command \"getupdatechannel\" [call]\n    GetUpdateChannelCommand(client: WshClient, opts?: RpcOpts): Promise<string> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getupdatechannel\", null, opts);\n        return client.wshRpcCall(\"getupdatechannel\", null, opts);\n    }\n\n    // command \"getvar\" [call]\n    GetVarCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise<CommandVarResponseData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getvar\", data, opts);\n        return client.wshRpcCall(\"getvar\", data, opts);\n    }\n\n    // command \"getwaveaichat\" [call]\n    GetWaveAIChatCommand(client: WshClient, data: CommandGetWaveAIChatData, opts?: RpcOpts): Promise<UIChat> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getwaveaichat\", data, opts);\n        return client.wshRpcCall(\"getwaveaichat\", data, opts);\n    }\n\n    // command \"getwaveaimodeconfig\" [call]\n    GetWaveAIModeConfigCommand(client: WshClient, opts?: RpcOpts): Promise<AIModeConfigUpdate> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getwaveaimodeconfig\", null, opts);\n        return client.wshRpcCall(\"getwaveaimodeconfig\", null, opts);\n    }\n\n    // command \"getwaveairatelimit\" [call]\n    GetWaveAIRateLimitCommand(client: WshClient, opts?: RpcOpts): Promise<RateLimitInfo> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"getwaveairatelimit\", null, opts);\n        return client.wshRpcCall(\"getwaveairatelimit\", null, opts);\n    }\n\n    // command \"jobcmdexited\" [call]\n    JobCmdExitedCommand(client: WshClient, data: CommandJobCmdExitedData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobcmdexited\", data, opts);\n        return client.wshRpcCall(\"jobcmdexited\", data, opts);\n    }\n\n    // command \"jobcontrollerattachjob\" [call]\n    JobControllerAttachJobCommand(client: WshClient, data: CommandJobControllerAttachJobData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobcontrollerattachjob\", data, opts);\n        return client.wshRpcCall(\"jobcontrollerattachjob\", data, opts);\n    }\n\n    // command \"jobcontrollerconnectedjobs\" [call]\n    JobControllerConnectedJobsCommand(client: WshClient, opts?: RpcOpts): Promise<string[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobcontrollerconnectedjobs\", null, opts);\n        return client.wshRpcCall(\"jobcontrollerconnectedjobs\", null, opts);\n    }\n\n    // command \"jobcontrollerdeletejob\" [call]\n    JobControllerDeleteJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobcontrollerdeletejob\", data, opts);\n        return client.wshRpcCall(\"jobcontrollerdeletejob\", data, opts);\n    }\n\n    // command \"jobcontrollerdetachjob\" [call]\n    JobControllerDetachJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobcontrollerdetachjob\", data, opts);\n        return client.wshRpcCall(\"jobcontrollerdetachjob\", data, opts);\n    }\n\n    // command \"jobcontrollerdisconnectjob\" [call]\n    JobControllerDisconnectJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobcontrollerdisconnectjob\", data, opts);\n        return client.wshRpcCall(\"jobcontrollerdisconnectjob\", data, opts);\n    }\n\n    // command \"jobcontrollerexitjob\" [call]\n    JobControllerExitJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobcontrollerexitjob\", data, opts);\n        return client.wshRpcCall(\"jobcontrollerexitjob\", data, opts);\n    }\n\n    // command \"jobcontrollergetalljobmanagerstatus\" [call]\n    JobControllerGetAllJobManagerStatusCommand(client: WshClient, opts?: RpcOpts): Promise<JobManagerStatusUpdate[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobcontrollergetalljobmanagerstatus\", null, opts);\n        return client.wshRpcCall(\"jobcontrollergetalljobmanagerstatus\", null, opts);\n    }\n\n    // command \"jobcontrollerlist\" [call]\n    JobControllerListCommand(client: WshClient, opts?: RpcOpts): Promise<Job[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobcontrollerlist\", null, opts);\n        return client.wshRpcCall(\"jobcontrollerlist\", null, opts);\n    }\n\n    // command \"jobcontrollerreconnectjob\" [call]\n    JobControllerReconnectJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobcontrollerreconnectjob\", data, opts);\n        return client.wshRpcCall(\"jobcontrollerreconnectjob\", data, opts);\n    }\n\n    // command \"jobcontrollerreconnectjobsforconn\" [call]\n    JobControllerReconnectJobsForConnCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobcontrollerreconnectjobsforconn\", data, opts);\n        return client.wshRpcCall(\"jobcontrollerreconnectjobsforconn\", data, opts);\n    }\n\n    // command \"jobcontrollerstartjob\" [call]\n    JobControllerStartJobCommand(client: WshClient, data: CommandJobControllerStartJobData, opts?: RpcOpts): Promise<string> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobcontrollerstartjob\", data, opts);\n        return client.wshRpcCall(\"jobcontrollerstartjob\", data, opts);\n    }\n\n    // command \"jobinput\" [call]\n    JobInputCommand(client: WshClient, data: CommandJobInputData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobinput\", data, opts);\n        return client.wshRpcCall(\"jobinput\", data, opts);\n    }\n\n    // command \"jobprepareconnect\" [call]\n    JobPrepareConnectCommand(client: WshClient, data: CommandJobPrepareConnectData, opts?: RpcOpts): Promise<CommandJobConnectRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobprepareconnect\", data, opts);\n        return client.wshRpcCall(\"jobprepareconnect\", data, opts);\n    }\n\n    // command \"jobstartstream\" [call]\n    JobStartStreamCommand(client: WshClient, data: CommandJobStartStreamData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"jobstartstream\", data, opts);\n        return client.wshRpcCall(\"jobstartstream\", data, opts);\n    }\n\n    // command \"listallappfiles\" [call]\n    ListAllAppFilesCommand(client: WshClient, data: CommandListAllAppFilesData, opts?: RpcOpts): Promise<CommandListAllAppFilesRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"listallappfiles\", data, opts);\n        return client.wshRpcCall(\"listallappfiles\", data, opts);\n    }\n\n    // command \"listallapps\" [call]\n    ListAllAppsCommand(client: WshClient, opts?: RpcOpts): Promise<AppInfo[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"listallapps\", null, opts);\n        return client.wshRpcCall(\"listallapps\", null, opts);\n    }\n\n    // command \"listalleditableapps\" [call]\n    ListAllEditableAppsCommand(client: WshClient, opts?: RpcOpts): Promise<AppInfo[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"listalleditableapps\", null, opts);\n        return client.wshRpcCall(\"listalleditableapps\", null, opts);\n    }\n\n    // command \"macosversion\" [call]\n    MacOSVersionCommand(client: WshClient, opts?: RpcOpts): Promise<string> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"macosversion\", null, opts);\n        return client.wshRpcCall(\"macosversion\", null, opts);\n    }\n\n    // command \"makedraftfromlocal\" [call]\n    MakeDraftFromLocalCommand(client: WshClient, data: CommandMakeDraftFromLocalData, opts?: RpcOpts): Promise<CommandMakeDraftFromLocalRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"makedraftfromlocal\", data, opts);\n        return client.wshRpcCall(\"makedraftfromlocal\", data, opts);\n    }\n\n    // command \"message\" [call]\n    MessageCommand(client: WshClient, data: CommandMessageData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"message\", data, opts);\n        return client.wshRpcCall(\"message\", data, opts);\n    }\n\n    // command \"networkonline\" [call]\n    NetworkOnlineCommand(client: WshClient, opts?: RpcOpts): Promise<boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"networkonline\", null, opts);\n        return client.wshRpcCall(\"networkonline\", null, opts);\n    }\n\n    // command \"notify\" [call]\n    NotifyCommand(client: WshClient, data: WaveNotificationOptions, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"notify\", data, opts);\n        return client.wshRpcCall(\"notify\", data, opts);\n    }\n\n    // command \"notifysystemresume\" [call]\n    NotifySystemResumeCommand(client: WshClient, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"notifysystemresume\", null, opts);\n        return client.wshRpcCall(\"notifysystemresume\", null, opts);\n    }\n\n    // command \"path\" [call]\n    PathCommand(client: WshClient, data: PathCommandData, opts?: RpcOpts): Promise<string> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"path\", data, opts);\n        return client.wshRpcCall(\"path\", data, opts);\n    }\n\n    // command \"publishapp\" [call]\n    PublishAppCommand(client: WshClient, data: CommandPublishAppData, opts?: RpcOpts): Promise<CommandPublishAppRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"publishapp\", data, opts);\n        return client.wshRpcCall(\"publishapp\", data, opts);\n    }\n\n    // command \"readappfile\" [call]\n    ReadAppFileCommand(client: WshClient, data: CommandReadAppFileData, opts?: RpcOpts): Promise<CommandReadAppFileRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"readappfile\", data, opts);\n        return client.wshRpcCall(\"readappfile\", data, opts);\n    }\n\n    // command \"recordtevent\" [call]\n    RecordTEventCommand(client: WshClient, data: TEvent, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"recordtevent\", data, opts);\n        return client.wshRpcCall(\"recordtevent\", data, opts);\n    }\n\n    // command \"remotedisconnectfromjobmanager\" [call]\n    RemoteDisconnectFromJobManagerCommand(client: WshClient, data: CommandRemoteDisconnectFromJobManagerData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotedisconnectfromjobmanager\", data, opts);\n        return client.wshRpcCall(\"remotedisconnectfromjobmanager\", data, opts);\n    }\n\n    // command \"remotefilecopy\" [call]\n    RemoteFileCopyCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise<boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotefilecopy\", data, opts);\n        return client.wshRpcCall(\"remotefilecopy\", data, opts);\n    }\n\n    // command \"remotefiledelete\" [call]\n    RemoteFileDeleteCommand(client: WshClient, data: CommandDeleteFileData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotefiledelete\", data, opts);\n        return client.wshRpcCall(\"remotefiledelete\", data, opts);\n    }\n\n    // command \"remotefileinfo\" [call]\n    RemoteFileInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<FileInfo> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotefileinfo\", data, opts);\n        return client.wshRpcCall(\"remotefileinfo\", data, opts);\n    }\n\n    // command \"remotefilejoin\" [call]\n    RemoteFileJoinCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<FileInfo> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotefilejoin\", data, opts);\n        return client.wshRpcCall(\"remotefilejoin\", data, opts);\n    }\n\n    // command \"remotefilemove\" [call]\n    RemoteFileMoveCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotefilemove\", data, opts);\n        return client.wshRpcCall(\"remotefilemove\", data, opts);\n    }\n\n    // command \"remotefilemultiinfo\" [call]\n    RemoteFileMultiInfoCommand(client: WshClient, data: CommandRemoteFileMultiInfoData, opts?: RpcOpts): Promise<{[key: string]: FileInfo}> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotefilemultiinfo\", data, opts);\n        return client.wshRpcCall(\"remotefilemultiinfo\", data, opts);\n    }\n\n    // command \"remotefilestream\" [call]\n    RemoteFileStreamCommand(client: WshClient, data: CommandRemoteFileStreamData, opts?: RpcOpts): Promise<FileInfo> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotefilestream\", data, opts);\n        return client.wshRpcCall(\"remotefilestream\", data, opts);\n    }\n\n    // command \"remotefiletouch\" [call]\n    RemoteFileTouchCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotefiletouch\", data, opts);\n        return client.wshRpcCall(\"remotefiletouch\", data, opts);\n    }\n\n    // command \"remotegetinfo\" [call]\n    RemoteGetInfoCommand(client: WshClient, opts?: RpcOpts): Promise<RemoteInfo> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotegetinfo\", null, opts);\n        return client.wshRpcCall(\"remotegetinfo\", null, opts);\n    }\n\n    // command \"remoteinstallrcfiles\" [call]\n    RemoteInstallRcFilesCommand(client: WshClient, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remoteinstallrcfiles\", null, opts);\n        return client.wshRpcCall(\"remoteinstallrcfiles\", null, opts);\n    }\n\n    // command \"remotelistentries\" [responsestream]\n\tRemoteListEntriesCommand(client: WshClient, data: CommandRemoteListEntriesData, opts?: RpcOpts): AsyncGenerator<CommandRemoteListEntriesRtnData, void, boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcStream(client, \"remotelistentries\", data, opts);\n        return client.wshRpcStream(\"remotelistentries\", data, opts);\n    }\n\n    // command \"remotemkdir\" [call]\n    RemoteMkdirCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotemkdir\", data, opts);\n        return client.wshRpcCall(\"remotemkdir\", data, opts);\n    }\n\n    // command \"remotereconnecttojobmanager\" [call]\n    RemoteReconnectToJobManagerCommand(client: WshClient, data: CommandRemoteReconnectToJobManagerData, opts?: RpcOpts): Promise<CommandRemoteReconnectToJobManagerRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotereconnecttojobmanager\", data, opts);\n        return client.wshRpcCall(\"remotereconnecttojobmanager\", data, opts);\n    }\n\n    // command \"remotestartjob\" [call]\n    RemoteStartJobCommand(client: WshClient, data: CommandRemoteStartJobData, opts?: RpcOpts): Promise<CommandStartJobRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotestartjob\", data, opts);\n        return client.wshRpcCall(\"remotestartjob\", data, opts);\n    }\n\n    // command \"remotestreamcpudata\" [responsestream]\n\tRemoteStreamCpuDataCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator<TimeSeriesData, void, boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcStream(client, \"remotestreamcpudata\", null, opts);\n        return client.wshRpcStream(\"remotestreamcpudata\", null, opts);\n    }\n\n    // command \"remotestreamfile\" [responsestream]\n\tRemoteStreamFileCommand(client: WshClient, data: CommandRemoteStreamFileData, opts?: RpcOpts): AsyncGenerator<FileData, void, boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcStream(client, \"remotestreamfile\", data, opts);\n        return client.wshRpcStream(\"remotestreamfile\", data, opts);\n    }\n\n    // command \"remoteterminatejobmanager\" [call]\n    RemoteTerminateJobManagerCommand(client: WshClient, data: CommandRemoteTerminateJobManagerData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remoteterminatejobmanager\", data, opts);\n        return client.wshRpcCall(\"remoteterminatejobmanager\", data, opts);\n    }\n\n    // command \"remotewritefile\" [call]\n    RemoteWriteFileCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"remotewritefile\", data, opts);\n        return client.wshRpcCall(\"remotewritefile\", data, opts);\n    }\n\n    // command \"renameappfile\" [call]\n    RenameAppFileCommand(client: WshClient, data: CommandRenameAppFileData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"renameappfile\", data, opts);\n        return client.wshRpcCall(\"renameappfile\", data, opts);\n    }\n\n    // command \"resolveids\" [call]\n    ResolveIdsCommand(client: WshClient, data: CommandResolveIdsData, opts?: RpcOpts): Promise<CommandResolveIdsRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"resolveids\", data, opts);\n        return client.wshRpcCall(\"resolveids\", data, opts);\n    }\n\n    // command \"restartbuilderandwait\" [call]\n    RestartBuilderAndWaitCommand(client: WshClient, data: CommandRestartBuilderAndWaitData, opts?: RpcOpts): Promise<RestartBuilderAndWaitResult> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"restartbuilderandwait\", data, opts);\n        return client.wshRpcCall(\"restartbuilderandwait\", data, opts);\n    }\n\n    // command \"routeannounce\" [call]\n    RouteAnnounceCommand(client: WshClient, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"routeannounce\", null, opts);\n        return client.wshRpcCall(\"routeannounce\", null, opts);\n    }\n\n    // command \"routeunannounce\" [call]\n    RouteUnannounceCommand(client: WshClient, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"routeunannounce\", null, opts);\n        return client.wshRpcCall(\"routeunannounce\", null, opts);\n    }\n\n    // command \"sendtelemetry\" [call]\n    SendTelemetryCommand(client: WshClient, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"sendtelemetry\", null, opts);\n        return client.wshRpcCall(\"sendtelemetry\", null, opts);\n    }\n\n    // command \"setblockfocus\" [call]\n    SetBlockFocusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"setblockfocus\", data, opts);\n        return client.wshRpcCall(\"setblockfocus\", data, opts);\n    }\n\n    // command \"setconfig\" [call]\n    SetConfigCommand(client: WshClient, data: SettingsType, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"setconfig\", data, opts);\n        return client.wshRpcCall(\"setconfig\", data, opts);\n    }\n\n    // command \"setconnectionsconfig\" [call]\n    SetConnectionsConfigCommand(client: WshClient, data: ConnConfigRequest, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"setconnectionsconfig\", data, opts);\n        return client.wshRpcCall(\"setconnectionsconfig\", data, opts);\n    }\n\n    // command \"setmeta\" [call]\n    SetMetaCommand(client: WshClient, data: CommandSetMetaData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"setmeta\", data, opts);\n        return client.wshRpcCall(\"setmeta\", data, opts);\n    }\n\n    // command \"setpeerinfo\" [call]\n    SetPeerInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"setpeerinfo\", data, opts);\n        return client.wshRpcCall(\"setpeerinfo\", data, opts);\n    }\n\n    // command \"setrtinfo\" [call]\n    SetRTInfoCommand(client: WshClient, data: CommandSetRTInfoData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"setrtinfo\", data, opts);\n        return client.wshRpcCall(\"setrtinfo\", data, opts);\n    }\n\n    // command \"setsecrets\" [call]\n    SetSecretsCommand(client: WshClient, data: {[key: string]: string}, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"setsecrets\", data, opts);\n        return client.wshRpcCall(\"setsecrets\", data, opts);\n    }\n\n    // command \"setvar\" [call]\n    SetVarCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"setvar\", data, opts);\n        return client.wshRpcCall(\"setvar\", data, opts);\n    }\n\n    // command \"startbuilder\" [call]\n    StartBuilderCommand(client: WshClient, data: CommandStartBuilderData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"startbuilder\", data, opts);\n        return client.wshRpcCall(\"startbuilder\", data, opts);\n    }\n\n    // command \"startjob\" [call]\n    StartJobCommand(client: WshClient, data: CommandStartJobData, opts?: RpcOpts): Promise<CommandStartJobRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"startjob\", data, opts);\n        return client.wshRpcCall(\"startjob\", data, opts);\n    }\n\n    // command \"stopbuilder\" [call]\n    StopBuilderCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"stopbuilder\", data, opts);\n        return client.wshRpcCall(\"stopbuilder\", data, opts);\n    }\n\n    // command \"streamcpudata\" [responsestream]\n\tStreamCpuDataCommand(client: WshClient, data: CpuDataRequest, opts?: RpcOpts): AsyncGenerator<TimeSeriesData, void, boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcStream(client, \"streamcpudata\", data, opts);\n        return client.wshRpcStream(\"streamcpudata\", data, opts);\n    }\n\n    // command \"streamdata\" [call]\n    StreamDataCommand(client: WshClient, data: CommandStreamData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"streamdata\", data, opts);\n        return client.wshRpcCall(\"streamdata\", data, opts);\n    }\n\n    // command \"streamdataack\" [call]\n    StreamDataAckCommand(client: WshClient, data: CommandStreamAckData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"streamdataack\", data, opts);\n        return client.wshRpcCall(\"streamdataack\", data, opts);\n    }\n\n    // command \"streamtest\" [responsestream]\n\tStreamTestCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator<number, void, boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcStream(client, \"streamtest\", null, opts);\n        return client.wshRpcStream(\"streamtest\", null, opts);\n    }\n\n    // command \"streamwaveai\" [responsestream]\n\tStreamWaveAiCommand(client: WshClient, data: WaveAIStreamRequest, opts?: RpcOpts): AsyncGenerator<WaveAIPacketType, void, boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcStream(client, \"streamwaveai\", data, opts);\n        return client.wshRpcStream(\"streamwaveai\", data, opts);\n    }\n\n    // command \"termgetscrollbacklines\" [call]\n    TermGetScrollbackLinesCommand(client: WshClient, data: CommandTermGetScrollbackLinesData, opts?: RpcOpts): Promise<CommandTermGetScrollbackLinesRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"termgetscrollbacklines\", data, opts);\n        return client.wshRpcCall(\"termgetscrollbacklines\", data, opts);\n    }\n\n    // command \"test\" [call]\n    TestCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"test\", data, opts);\n        return client.wshRpcCall(\"test\", data, opts);\n    }\n\n    // command \"testmultiarg\" [call]\n    TestMultiArgCommand(client: WshClient, arg1: string, arg2: number, arg3: boolean, opts?: RpcOpts): Promise<string> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"testmultiarg\", { args: [arg1, arg2, arg3] }, opts);\n        return client.wshRpcCall(\"testmultiarg\", { args: [arg1, arg2, arg3] }, opts);\n    }\n\n    // command \"updatetabname\" [call]\n    UpdateTabNameCommand(client: WshClient, arg1: string, arg2: string, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"updatetabname\", { args: [arg1, arg2] }, opts);\n        return client.wshRpcCall(\"updatetabname\", { args: [arg1, arg2] }, opts);\n    }\n\n    // command \"updateworkspacetabids\" [call]\n    UpdateWorkspaceTabIdsCommand(client: WshClient, arg1: string, arg2: string[], opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"updateworkspacetabids\", { args: [arg1, arg2] }, opts);\n        return client.wshRpcCall(\"updateworkspacetabids\", { args: [arg1, arg2] }, opts);\n    }\n\n    // command \"vdomasyncinitiation\" [call]\n    VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"vdomasyncinitiation\", data, opts);\n        return client.wshRpcCall(\"vdomasyncinitiation\", data, opts);\n    }\n\n    // command \"vdomcreatecontext\" [call]\n    VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise<ORef> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"vdomcreatecontext\", data, opts);\n        return client.wshRpcCall(\"vdomcreatecontext\", data, opts);\n    }\n\n    // command \"vdomrender\" [responsestream]\n\tVDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): AsyncGenerator<VDomBackendUpdate, void, boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcStream(client, \"vdomrender\", data, opts);\n        return client.wshRpcStream(\"vdomrender\", data, opts);\n    }\n\n    // command \"vdomurlrequest\" [responsestream]\n\tVDomUrlRequestCommand(client: WshClient, data: VDomUrlRequestData, opts?: RpcOpts): AsyncGenerator<VDomUrlRequestResponse, void, boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcStream(client, \"vdomurlrequest\", data, opts);\n        return client.wshRpcStream(\"vdomurlrequest\", data, opts);\n    }\n\n    // command \"waitforroute\" [call]\n    WaitForRouteCommand(client: WshClient, data: CommandWaitForRouteData, opts?: RpcOpts): Promise<boolean> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"waitforroute\", data, opts);\n        return client.wshRpcCall(\"waitforroute\", data, opts);\n    }\n\n    // command \"waveaiaddcontext\" [call]\n    WaveAIAddContextCommand(client: WshClient, data: CommandWaveAIAddContextData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"waveaiaddcontext\", data, opts);\n        return client.wshRpcCall(\"waveaiaddcontext\", data, opts);\n    }\n\n    // command \"waveaienabletelemetry\" [call]\n    WaveAIEnableTelemetryCommand(client: WshClient, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"waveaienabletelemetry\", null, opts);\n        return client.wshRpcCall(\"waveaienabletelemetry\", null, opts);\n    }\n\n    // command \"waveaigettooldiff\" [call]\n    WaveAIGetToolDiffCommand(client: WshClient, data: CommandWaveAIGetToolDiffData, opts?: RpcOpts): Promise<CommandWaveAIGetToolDiffRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"waveaigettooldiff\", data, opts);\n        return client.wshRpcCall(\"waveaigettooldiff\", data, opts);\n    }\n\n    // command \"waveaitoolapprove\" [call]\n    WaveAIToolApproveCommand(client: WshClient, data: CommandWaveAIToolApproveData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"waveaitoolapprove\", data, opts);\n        return client.wshRpcCall(\"waveaitoolapprove\", data, opts);\n    }\n\n    // command \"wavefilereadstream\" [call]\n    WaveFileReadStreamCommand(client: WshClient, data: CommandWaveFileReadStreamData, opts?: RpcOpts): Promise<WaveFileInfo> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"wavefilereadstream\", data, opts);\n        return client.wshRpcCall(\"wavefilereadstream\", data, opts);\n    }\n\n    // command \"waveinfo\" [call]\n    WaveInfoCommand(client: WshClient, opts?: RpcOpts): Promise<WaveInfoData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"waveinfo\", null, opts);\n        return client.wshRpcCall(\"waveinfo\", null, opts);\n    }\n\n    // command \"webselector\" [call]\n    WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise<string[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"webselector\", data, opts);\n        return client.wshRpcCall(\"webselector\", data, opts);\n    }\n\n    // command \"workspacelist\" [call]\n    WorkspaceListCommand(client: WshClient, opts?: RpcOpts): Promise<WorkspaceInfoData[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"workspacelist\", null, opts);\n        return client.wshRpcCall(\"workspacelist\", null, opts);\n    }\n\n    // command \"writeappfile\" [call]\n    WriteAppFileCommand(client: WshClient, data: CommandWriteAppFileData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"writeappfile\", data, opts);\n        return client.wshRpcCall(\"writeappfile\", data, opts);\n    }\n\n    // command \"writeappgofile\" [call]\n    WriteAppGoFileCommand(client: WshClient, data: CommandWriteAppGoFileData, opts?: RpcOpts): Promise<CommandWriteAppGoFileRtnData> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"writeappgofile\", data, opts);\n        return client.wshRpcCall(\"writeappgofile\", data, opts);\n    }\n\n    // command \"writeappsecretbindings\" [call]\n    WriteAppSecretBindingsCommand(client: WshClient, data: CommandWriteAppSecretBindingsData, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"writeappsecretbindings\", data, opts);\n        return client.wshRpcCall(\"writeappsecretbindings\", data, opts);\n    }\n\n    // command \"writetempfile\" [call]\n    WriteTempFileCommand(client: WshClient, data: CommandWriteTempFileData, opts?: RpcOpts): Promise<string> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"writetempfile\", data, opts);\n        return client.wshRpcCall(\"writetempfile\", data, opts);\n    }\n\n    // command \"wshactivity\" [call]\n    WshActivityCommand(client: WshClient, data: {[key: string]: number}, opts?: RpcOpts): Promise<void> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"wshactivity\", data, opts);\n        return client.wshRpcCall(\"wshactivity\", data, opts);\n    }\n\n    // command \"wsldefaultdistro\" [call]\n    WslDefaultDistroCommand(client: WshClient, opts?: RpcOpts): Promise<string> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"wsldefaultdistro\", null, opts);\n        return client.wshRpcCall(\"wsldefaultdistro\", null, opts);\n    }\n\n    // command \"wsllist\" [call]\n    WslListCommand(client: WshClient, opts?: RpcOpts): Promise<string[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"wsllist\", null, opts);\n        return client.wshRpcCall(\"wsllist\", null, opts);\n    }\n\n    // command \"wslstatus\" [call]\n    WslStatusCommand(client: WshClient, opts?: RpcOpts): Promise<ConnStatus[]> {\n        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, \"wslstatus\", null, opts);\n        return client.wshRpcCall(\"wslstatus\", null, opts);\n    }\n\n}\n\nexport const RpcApi = new RpcApiType();\n"
  },
  {
    "path": "frontend/app/store/wshrouter.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { handleWaveEvent } from \"@/app/store/wps\";\nimport * as util from \"@/util/util\";\nimport debug from \"debug\";\n\nconst dlog = debug(\"wave:router\");\n\nconst SysRouteName = \"sys\";\nconst ControlRouteName = \"$control\";\n\ntype RouteInfo = {\n    rpcId: string;\n    sourceRouteId: string;\n    destRouteId: string;\n};\n\nfunction makeFeBlockRouteId(feBlockId: string): string {\n    return `feblock:${feBlockId}`;\n}\n\nfunction makeTabRouteId(tabId: string): string {\n    return `tab:${tabId}`;\n}\n\nfunction makeBuilderRouteId(builderId: string): string {\n    return `builder:${builderId}`;\n}\n\nclass WshRouter {\n    routeMap: Map<string, AbstractWshClient>; // routeid -> client\n    upstreamClient: AbstractWshClient;\n    rpcMap: Map<string, RouteInfo>; // rpcid -> routeinfo\n\n    constructor(upstreamClient: AbstractWshClient) {\n        this.routeMap = new Map();\n        this.rpcMap = new Map();\n        if (upstreamClient == null) {\n            throw new Error(\"upstream client cannot be null\");\n        }\n        this.upstreamClient = upstreamClient;\n    }\n\n    reannounceRoutes() {\n        for (const [routeId, client] of this.routeMap) {\n            const announceMsg: RpcMessage = {\n                command: \"routeannounce\",\n                data: routeId,\n                source: routeId,\n                route: ControlRouteName,\n            };\n            this.upstreamClient.recvRpcMessage(announceMsg);\n        }\n    }\n\n    // returns true if the message was sent\n    _sendRoutedMessage(msg: RpcMessage, destRouteId: string) {\n        const client = this.routeMap.get(destRouteId);\n        if (client) {\n            client.recvRpcMessage(msg);\n            return;\n        }\n        // there should always an upstream client\n        if (!this.upstreamClient) {\n            throw new Error(`no upstream client for message: ${msg}`);\n        }\n        this.upstreamClient?.recvRpcMessage(msg);\n    }\n\n    _registerRouteInfo(reqid: string, sourceRouteId: string, destRouteId: string) {\n        dlog(\"registering route info\", reqid, sourceRouteId, destRouteId);\n        if (util.isBlank(reqid)) {\n            return;\n        }\n        const routeInfo: RouteInfo = {\n            rpcId: reqid,\n            sourceRouteId: sourceRouteId,\n            destRouteId: destRouteId,\n        };\n        this.rpcMap.set(reqid, routeInfo);\n    }\n\n    recvRpcMessage(msg: RpcMessage) {\n        dlog(\"router received message\", msg);\n        // we are a terminal node by definition, so we don't need to process with announce/unannounce messages\n        if (msg.command == \"routeannounce\" || msg.command == \"routeunannounce\") {\n            return;\n        }\n        // handle events\n        if (msg.command == \"eventrecv\") {\n            handleWaveEvent(msg.data);\n            return;\n        }\n        if (!util.isBlank(msg.command)) {\n            // send + register routeinfo\n            if (!util.isBlank(msg.reqid)) {\n                this._registerRouteInfo(msg.reqid, msg.source, msg.route);\n            }\n            this._sendRoutedMessage(msg, msg.route);\n            return;\n        }\n        if (!util.isBlank(msg.reqid)) {\n            const routeInfo = this.rpcMap.get(msg.reqid);\n            if (!routeInfo) {\n                // no route info, discard\n                dlog(\"no route info for reqid, discarding\", msg);\n                return;\n            }\n            this._sendRoutedMessage(msg, routeInfo.destRouteId);\n            return;\n        }\n        if (!util.isBlank(msg.resid)) {\n            const routeInfo = this.rpcMap.get(msg.resid);\n            if (!routeInfo) {\n                // no route info, discard\n                dlog(\"no route info for resid, discarding\", msg);\n                return;\n            }\n            this._sendRoutedMessage(msg, routeInfo.sourceRouteId);\n            if (!msg.cont) {\n                dlog(\"deleting route info\", msg.resid);\n                this.rpcMap.delete(msg.resid);\n            }\n            return;\n        }\n        dlog(\"bad rpc message recevied by router, no command, reqid, or resid (discarding)\", msg);\n    }\n\n    registerRoute(routeId: string, client: AbstractWshClient) {\n        if (routeId == SysRouteName) {\n            throw new Error(`Cannot register route with reserved name (${routeId})`);\n        }\n        dlog(\"registering route: \", routeId);\n        // announce\n        const announceMsg: RpcMessage = {\n            command: \"routeannounce\",\n            data: routeId,\n            source: routeId,\n            route: ControlRouteName,\n        };\n        this.upstreamClient.recvRpcMessage(announceMsg);\n        this.routeMap.set(routeId, client);\n    }\n\n    unregisterRoute(routeId: string) {\n        dlog(\"unregister route: \", routeId);\n        // unannounce\n        const unannounceMsg: RpcMessage = {\n            command: \"routeunannounce\",\n            data: routeId,\n            source: routeId,\n            route: ControlRouteName,\n        };\n        this.upstreamClient?.recvRpcMessage(unannounceMsg);\n        this.routeMap.delete(routeId);\n    }\n}\n\nexport { makeBuilderRouteId, makeFeBlockRouteId, makeTabRouteId, WshRouter };\n"
  },
  {
    "path": "frontend/app/store/wshrpcutil-base.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { setWpsRpcClient, wpsReconnectHandler } from \"@/app/store/wps\";\nimport { WshClient } from \"@/app/store/wshclient\";\nimport { WshRouter } from \"@/app/store/wshrouter\";\nimport { getWSServerEndpoint } from \"@/util/endpoints\";\nimport { addWSReconnectHandler, ElectronOverrideOpts, globalWS, initGlobalWS } from \"./ws\";\n\nlet DefaultRouter: WshRouter;\n\nfunction setDefaultRouter(router: WshRouter) {\n    DefaultRouter = router;\n}\n\nasync function* rpcResponseGenerator(\n    openRpcs: Map<string, ClientRpcEntry>,\n    command: string,\n    reqid: string,\n    timeout: number\n): AsyncGenerator<any, void, boolean> {\n    const msgQueue: RpcMessage[] = [];\n    let signalFn: () => void;\n    let signalPromise = new Promise<void>((resolve) => (signalFn = resolve));\n    let timeoutId: NodeJS.Timeout = null;\n    if (timeout > 0) {\n        timeoutId = setTimeout(() => {\n            msgQueue.push({ resid: reqid, error: \"EC-TIME: timeout waiting for response\" });\n            signalFn();\n        }, timeout);\n    }\n    const msgFn = (msg: RpcMessage) => {\n        msgQueue.push(msg);\n        signalFn();\n        // reset signal promise\n        signalPromise = new Promise<void>((resolve) => (signalFn = resolve));\n    };\n    openRpcs.set(reqid, {\n        reqId: reqid,\n        startTs: Date.now(),\n        command: command,\n        msgFn: msgFn,\n    });\n    yield null;\n    try {\n        while (true) {\n            while (msgQueue.length > 0) {\n                const msg = msgQueue.shift()!;\n                if (msg.error != null) {\n                    throw new Error(msg.error);\n                }\n                if (!msg.cont && msg.data == null) {\n                    return;\n                }\n                const shouldTerminate = yield msg.data;\n                if (shouldTerminate) {\n                    sendRpcCancel(reqid);\n                    return;\n                }\n                if (!msg.cont) {\n                    return;\n                }\n            }\n            await signalPromise;\n        }\n    } finally {\n        openRpcs.delete(reqid);\n        if (timeoutId != null) {\n            clearTimeout(timeoutId);\n        }\n    }\n}\n\nfunction sendRpcCancel(reqid: string) {\n    const rpcMsg: RpcMessage = { reqid: reqid, cancel: true };\n    DefaultRouter.recvRpcMessage(rpcMsg);\n}\n\nfunction sendRpcResponse(msg: RpcMessage) {\n    DefaultRouter.recvRpcMessage(msg);\n}\n\nfunction sendRpcCommand(\n    openRpcs: Map<string, ClientRpcEntry>,\n    msg: RpcMessage\n): AsyncGenerator<RpcMessage, void, boolean> {\n    DefaultRouter.recvRpcMessage(msg);\n    if (msg.reqid == null) {\n        return null;\n    }\n    const rtnGen = rpcResponseGenerator(openRpcs, msg.command, msg.reqid, msg.timeout);\n    rtnGen.next();\n    return rtnGen;\n}\n\nasync function consumeGenerator(gen: AsyncGenerator<any, any, any>) {\n    let idx = 0;\n    try {\n        for await (const msg of gen) {\n            console.log(\"gen\", idx, msg);\n            idx++;\n        }\n        const result = await gen.return(undefined);\n        console.log(\"gen done\", result.value);\n    } catch (e) {\n        console.log(\"gen error\", e);\n    }\n}\n\nif (globalThis.window != null) {\n    globalThis[\"consumeGenerator\"] = consumeGenerator;\n}\n\nfunction initElectronWshrpc(electronClient: WshClient, eoOpts: ElectronOverrideOpts) {\n    setDefaultRouter(new WshRouter(new UpstreamWshRpcProxy()));\n    const handleFn = (event: WSEventType) => {\n        DefaultRouter.recvRpcMessage(event.data);\n    };\n    initGlobalWS(getWSServerEndpoint(), \"electron\", handleFn, eoOpts);\n    globalWS.connectNow(\"connectWshrpc\");\n    setWpsRpcClient(electronClient);\n    DefaultRouter.registerRoute(electronClient.routeId, electronClient);\n    addWSReconnectHandler(() => {\n        DefaultRouter.reannounceRoutes();\n    });\n    addWSReconnectHandler(wpsReconnectHandler);\n}\n\nfunction shutdownWshrpc() {\n    globalWS?.shutdown();\n}\n\nclass UpstreamWshRpcProxy implements AbstractWshClient {\n    recvRpcMessage(msg: RpcMessage): void {\n        const wsMsg: WSRpcCommand = { wscommand: \"rpc\", message: msg };\n        globalWS?.pushMessage(wsMsg);\n    }\n}\n\nexport { DefaultRouter, initElectronWshrpc, sendRpcCommand, sendRpcResponse, setDefaultRouter, shutdownWshrpc };\n"
  },
  {
    "path": "frontend/app/store/wshrpcutil.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { setWpsRpcClient, wpsReconnectHandler } from \"@/app/store/wps\";\nimport { TabClient } from \"@/app/store/tabrpcclient\";\nimport { WshRouter } from \"@/app/store/wshrouter\";\nimport { getWSServerEndpoint } from \"@/util/endpoints\";\nimport { addWSReconnectHandler, globalWS, initGlobalWS, WSControl } from \"./ws\";\nimport { DefaultRouter, setDefaultRouter } from \"./wshrpcutil-base\";\n\nlet TabRpcClient: TabClient;\n\nfunction initWshrpc(routeId: string): WSControl {\n    const router = new WshRouter(new UpstreamWshRpcProxy());\n    setDefaultRouter(router);\n    const handleFn = (event: WSEventType) => {\n        DefaultRouter.recvRpcMessage(event.data);\n    };\n    initGlobalWS(getWSServerEndpoint(), routeId, handleFn);\n    globalWS.connectNow(\"connectWshrpc\");\n    TabRpcClient = new TabClient(routeId);\n    setWpsRpcClient(TabRpcClient);\n    DefaultRouter.registerRoute(TabRpcClient.routeId, TabRpcClient);\n    addWSReconnectHandler(() => {\n        DefaultRouter.reannounceRoutes();\n    });\n    addWSReconnectHandler(wpsReconnectHandler);\n    return globalWS;\n}\n\nclass UpstreamWshRpcProxy implements AbstractWshClient {\n    recvRpcMessage(msg: RpcMessage): void {\n        const wsMsg: WSRpcCommand = { wscommand: \"rpc\", message: msg };\n        globalWS?.pushMessage(wsMsg);\n    }\n}\n\nexport { DefaultRouter, initWshrpc, TabRpcClient };\nexport { initElectronWshrpc, sendRpcCommand, sendRpcResponse, shutdownWshrpc } from \"./wshrpcutil-base\";\n"
  },
  {
    "path": "frontend/app/suggestion/suggestion.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { atoms } from \"@/app/store/global\";\nimport { isBlank, makeIconClass } from \"@/util/util\";\nimport { offset, useFloating } from \"@floating-ui/react\";\nimport clsx from \"clsx\";\nimport { Atom, useAtomValue } from \"jotai\";\nimport React, { ReactNode, useEffect, useId, useRef, useState } from \"react\";\n\ninterface SuggestionControlProps {\n    anchorRef: React.RefObject<HTMLElement>;\n    isOpen: boolean;\n    onClose: () => void;\n    onSelect: (item: SuggestionType, queryStr: string) => boolean;\n    onTab?: (item: SuggestionType, queryStr: string) => string;\n    fetchSuggestions: SuggestionsFnType;\n    className?: string;\n    placeholderText?: string;\n    children?: React.ReactNode;\n}\n\ntype BlockHeaderSuggestionControlProps = Omit<SuggestionControlProps, \"anchorRef\" | \"isOpen\"> & {\n    blockRef: React.RefObject<HTMLElement>;\n    openAtom: Atom<boolean>;\n};\n\nfunction SuggestionControl({\n    anchorRef,\n    isOpen,\n    onClose,\n    onSelect,\n    onTab,\n    fetchSuggestions,\n    className,\n    children,\n}: SuggestionControlProps) {\n    if (!isOpen || !anchorRef.current || !fetchSuggestions) return null;\n\n    return (\n        <SuggestionControlInner {...{ anchorRef, onClose, onSelect, onTab, fetchSuggestions, className, children }} />\n    );\n}\n\nfunction highlightPositions(target: string, positions: number[]): ReactNode[] {\n    if (target == null) {\n        return [];\n    }\n    if (positions == null) {\n        return [target];\n    }\n    const result: ReactNode[] = [];\n    let targetIndex = 0;\n    let posIndex = 0;\n\n    while (targetIndex < target.length) {\n        if (posIndex < positions.length && targetIndex === positions[posIndex]) {\n            result.push(\n                <span key={`h-${targetIndex}`} className=\"text-blue-500 font-bold\">\n                    {target[targetIndex]}\n                </span>\n            );\n            posIndex++;\n        } else {\n            result.push(target[targetIndex]);\n        }\n        targetIndex++;\n    }\n    return result;\n}\n\nfunction getMimeTypeIconAndColor(fullConfig: FullConfigType, mimeType: string): [string, string] {\n    if (mimeType == null) {\n        return [null, null];\n    }\n    while (mimeType.length > 0) {\n        const icon = fullConfig.mimetypes?.[mimeType]?.icon ?? null;\n        const iconColor = fullConfig.mimetypes?.[mimeType]?.color ?? null;\n        if (icon != null) {\n            return [icon, iconColor];\n        }\n        mimeType = mimeType.slice(0, -1);\n    }\n    return [null, null];\n}\n\nfunction SuggestionIcon({ suggestion }: { suggestion: SuggestionType }) {\n    if (suggestion.iconsrc) {\n        return <img src={suggestion.iconsrc} alt=\"favicon\" className=\"w-4 h-4 object-contain\" />;\n    }\n    if (suggestion.icon) {\n        const iconClass = makeIconClass(suggestion.icon, true);\n        const iconColor = suggestion.iconcolor;\n        return <i className={iconClass} style={{ color: iconColor }} />;\n    }\n    if (suggestion.type === \"url\") {\n        const iconClass = makeIconClass(\"globe\", true);\n        const iconColor = suggestion.iconcolor;\n        return <i className={iconClass} style={{ color: iconColor }} />;\n    } else if (suggestion.type === \"file\") {\n        // For file suggestions, use the existing logic.\n        const fullConfig = useAtomValue(atoms.fullConfigAtom);\n        let icon: string = null;\n        let iconColor: string = null;\n        if (icon == null && suggestion[\"file:mimetype\"] != null) {\n            [icon, iconColor] = getMimeTypeIconAndColor(fullConfig, suggestion[\"file:mimetype\"]);\n        }\n        const iconClass = makeIconClass(icon, true, { defaultIcon: \"file\" });\n        return <i className={iconClass} style={{ color: iconColor }} />;\n    }\n    const iconClass = makeIconClass(\"file\", true);\n    return <i className={iconClass} />;\n}\n\nfunction SuggestionContent({ suggestion }: { suggestion: SuggestionType }) {\n    if (!isBlank(suggestion.subtext)) {\n        return (\n            <div className=\"flex flex-col\">\n                {/* Title on the first line, with highlighting */}\n                <div className=\"truncate text-white\">{highlightPositions(suggestion.display, suggestion.matchpos)}</div>\n                {/* Subtext on the second line in a smaller, grey style */}\n                <div className=\"truncate text-sm text-secondary\">\n                    {highlightPositions(suggestion.subtext, suggestion.submatchpos)}\n                </div>\n            </div>\n        );\n    }\n    return <span className=\"truncate\">{highlightPositions(suggestion.display, suggestion.matchpos)}</span>;\n}\n\nfunction BlockHeaderSuggestionControl(props: BlockHeaderSuggestionControlProps) {\n    const [headerElem, setHeaderElem] = useState<HTMLElement>(null);\n    const isOpen = useAtomValue(props.openAtom);\n\n    useEffect(() => {\n        if (props.blockRef.current == null) {\n            setHeaderElem(null);\n            return;\n        }\n        const headerElem = props.blockRef.current.querySelector(\"[data-role='block-header']\");\n        setHeaderElem(headerElem as HTMLElement);\n    }, [props.blockRef.current]);\n\n    const newClass = clsx(props.className, \"rounded-t-none\");\n    return <SuggestionControl {...props} anchorRef={{ current: headerElem }} isOpen={isOpen} className={newClass} />;\n}\n\n/**\n * The empty state component that can be used as a child of SuggestionControl.\n * If no children are provided to SuggestionControl, this default empty state will be used.\n */\nfunction SuggestionControlNoResults({ children }: { children?: React.ReactNode }) {\n    return (\n        <div className=\"flex items-center justify-center min-h-[120px] p-4\">\n            {children ?? <span className=\"text-gray-500\">No Suggestions</span>}\n        </div>\n    );\n}\n\nfunction SuggestionControlNoData({ children }: { children?: React.ReactNode }) {\n    return (\n        <div className=\"flex items-center justify-center min-h-[120px] p-4\">\n            {children ?? <span className=\"text-gray-500\">No Suggestions</span>}\n        </div>\n    );\n}\n\ntype SuggestionControlInnerProps = Omit<SuggestionControlProps, \"isOpen\">;\n\nfunction SuggestionControlInner({\n    anchorRef,\n    onClose,\n    onSelect,\n    onTab,\n    fetchSuggestions,\n    className,\n    placeholderText,\n    children,\n}: SuggestionControlInnerProps) {\n    const widgetId = useId();\n    const [query, setQuery] = useState(\"\");\n    const reqNumRef = useRef(0);\n    let [suggestions, setSuggestions] = useState<SuggestionType[]>([]);\n    const [selectedIndex, setSelectedIndex] = useState(0);\n    const [fetched, setFetched] = useState(false);\n    const inputRef = useRef<HTMLInputElement>(null);\n    const dropdownRef = useRef<HTMLDivElement>(null);\n    const { refs, floatingStyles, middlewareData } = useFloating({\n        placement: \"bottom\",\n        strategy: \"absolute\",\n        middleware: [offset(-1)],\n    });\n    const emptyStateChild = React.Children.toArray(children).find(\n        (child) => React.isValidElement(child) && child.type === SuggestionControlNoResults\n    );\n    const noDataChild = React.Children.toArray(children).find(\n        (child) => React.isValidElement(child) && child.type === SuggestionControlNoData\n    );\n\n    useEffect(() => {\n        refs.setReference(anchorRef.current);\n    }, [anchorRef.current]);\n\n    useEffect(() => {\n        reqNumRef.current++;\n        fetchSuggestions(query, { widgetid: widgetId, reqnum: reqNumRef.current }).then((results) => {\n            if (results.reqnum !== reqNumRef.current) {\n                return;\n            }\n            setSuggestions(results.suggestions ?? []);\n            setFetched(true);\n        });\n    }, [query, fetchSuggestions]);\n\n    useEffect(() => {\n        return () => {\n            reqNumRef.current++;\n            fetchSuggestions(\"\", { widgetid: widgetId, reqnum: reqNumRef.current, dispose: true });\n        };\n    }, []);\n\n    useEffect(() => {\n        inputRef.current?.focus();\n    }, []);\n\n    useEffect(() => {\n        const handleClickOutside = (event: MouseEvent) => {\n            if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {\n                onClose();\n            }\n        };\n        document.addEventListener(\"mousedown\", handleClickOutside);\n        return () => document.removeEventListener(\"mousedown\", handleClickOutside);\n    }, [onClose, anchorRef]);\n\n    useEffect(() => {\n        if (dropdownRef.current) {\n            const children = dropdownRef.current.children;\n            if (children[selectedIndex]) {\n                (children[selectedIndex] as HTMLElement).scrollIntoView({\n                    behavior: \"auto\",\n                    block: \"nearest\",\n                });\n            }\n        }\n    }, [selectedIndex]);\n\n    const handleKeyDown = (e: React.KeyboardEvent) => {\n        if (e.key === \"ArrowDown\") {\n            e.preventDefault();\n            e.stopPropagation();\n            setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));\n        } else if (e.key === \"ArrowUp\") {\n            e.preventDefault();\n            e.stopPropagation();\n            setSelectedIndex((prev) => Math.max(prev - 1, 0));\n        } else if (e.key === \"Enter\") {\n            e.preventDefault();\n            e.stopPropagation();\n            let suggestion: SuggestionType = null;\n            if (selectedIndex >= 0 && selectedIndex < suggestions.length) {\n                suggestion = suggestions[selectedIndex];\n            }\n            if (onSelect(suggestion, query)) {\n                onClose();\n            }\n        } else if (e.key === \"Escape\") {\n            e.preventDefault();\n            e.stopPropagation();\n            onClose();\n        } else if (e.key === \"Tab\") {\n            e.preventDefault();\n            e.stopPropagation();\n            const suggestion = suggestions[selectedIndex];\n            if (suggestion != null) {\n                const tabResult = onTab?.(suggestion, query);\n                if (tabResult != null) {\n                    setQuery(tabResult);\n                }\n            }\n        } else if (e.key === \"PageDown\") {\n            e.preventDefault();\n            e.stopPropagation();\n            setSelectedIndex((prev) => Math.min(prev + 10, suggestions.length - 1));\n        } else if (e.key === \"PageUp\") {\n            e.preventDefault();\n            e.stopPropagation();\n            setSelectedIndex((prev) => Math.max(prev - 10, 0));\n        }\n    };\n    return (\n        <div\n            className={clsx(\n                \"w-96 rounded-lg bg-modalbg shadow-lg border border-gray-700 z-[var(--zindex-typeahead-modal)] absolute\",\n                middlewareData?.offset == null ? \"opacity-0\" : null,\n                className\n            )}\n            ref={refs.setFloating}\n            style={floatingStyles}\n        >\n            <div className=\"p-2\">\n                <input\n                    ref={inputRef}\n                    type=\"text\"\n                    value={query}\n                    onChange={(e) => {\n                        setQuery(e.target.value);\n                        setSelectedIndex(0);\n                    }}\n                    onKeyDown={handleKeyDown}\n                    className=\"w-full bg-zinc-900 text-gray-100 px-4 py-2 rounded-md border border-gray-700 focus:outline-none focus:border-accent placeholder-secondary\"\n                    placeholder={placeholderText}\n                />\n            </div>\n            {fetched &&\n                (suggestions.length > 0 ? (\n                    <div ref={dropdownRef} className=\"max-h-96 overflow-y-auto divide-y divide-gray-700\">\n                        {suggestions.map((suggestion, index) => (\n                            <div\n                                key={suggestion.suggestionid}\n                                className={clsx(\n                                    \"flex items-center gap-3 px-4 py-2 cursor-pointer\",\n                                    index === selectedIndex ? \"bg-accentbg\" : \"hover:bg-hoverbg\",\n                                    \"text-gray-100\"\n                                )}\n                                onClick={() => {\n                                    onSelect(suggestion, query);\n                                    onClose();\n                                }}\n                            >\n                                <SuggestionIcon suggestion={suggestion} />\n                                <SuggestionContent suggestion={suggestion} />\n                            </div>\n                        ))}\n                    </div>\n                ) : (\n                    // Render the empty state (either a provided child or the default)\n                    <div key=\"empty\" className=\"flex items-center justify-center min-h-[120px] p-4\">\n                        {query === \"\"\n                            ? (noDataChild ?? <SuggestionControlNoData />)\n                            : (emptyStateChild ?? <SuggestionControlNoResults />)}\n                    </div>\n                ))}\n        </div>\n    );\n}\n\nexport { BlockHeaderSuggestionControl, SuggestionControl, SuggestionControlNoData, SuggestionControlNoResults };\n"
  },
  {
    "path": "frontend/app/tab/tab.scss",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.tab {\n    position: absolute;\n    width: 130px;\n    height: calc(100% - 3px);\n    padding: 0 0 0 0;\n    box-sizing: border-box;\n    font-weight: bold;\n    color: var(--secondary-text-color);\n    opacity: 0;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n\n    .tab-divider {\n        position: absolute;\n        left: 0;\n        width: 1px;\n        height: 14px;\n        background: rgb(from var(--main-text-color) r g b / 0.2);\n    }\n\n    .tab-inner {\n        position: relative;\n        width: calc(100% - 6px);\n        height: 100%;\n        white-space: nowrap;\n        border-radius: 6px;\n    }\n\n    &.animate {\n        transition:\n            transform 0.3s ease,\n            background-color 0.3s ease-in-out;\n    }\n\n    &.active {\n        .tab-inner {\n            border-color: transparent;\n            border-radius: 6px;\n            background: rgb(from var(--main-text-color) r g b / 0.1);\n        }\n\n        .name {\n            color: rgba(255, 255, 255, 1);\n            font-weight: 600;\n        }\n    }\n\n    .name {\n        position: absolute;\n        top: 50%;\n        left: 50%;\n        transform: translate3d(-50%, -50%, 0);\n        user-select: none;\n        z-index: var(--zindex-tab-name);\n        font-size: 11px;\n        font-weight: 500;\n        text-shadow: 0px 0px 4px rgb(from var(--main-bg-color) r g b / 0.25);\n        overflow: hidden;\n        width: calc(100% - 10px);\n        text-overflow: ellipsis;\n        text-align: center;\n\n        &.focused {\n            outline: none;\n            border: 1px solid rgb(from var(--main-text-color) r g b / 0.179);\n            padding: 2px 6px;\n            border-radius: 2px;\n        }\n    }\n\n    .wave-button {\n        position: absolute;\n        top: 50%;\n        right: 4px;\n        transform: translate3d(0, -50%, 0);\n        width: 20px;\n        height: 20px;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        cursor: pointer;\n        z-index: var(--zindex-tab-name);\n        padding: 1px 2px;\n        transition: none !important;\n    }\n\n    .close {\n        visibility: hidden;\n    }\n}\n\n// Only apply hover effects when not in nohover mode. This prevents the previously-hovered tab from remaining hovered while a tab view is not mounted.\nbody:not(.nohover) .tab:hover + .tab,\nbody:not(.nohover) .tab.dragging + .tab {\n    .tab-divider {\n        display: none;\n    }\n}\n\nbody:not(.nohover) .tab:hover,\nbody:not(.nohover) .tab.dragging {\n    .tab-divider {\n        display: none;\n    }\n\n    .tab-inner {\n        border-color: transparent;\n        background: rgb(from var(--main-text-color) r g b / 0.1);\n    }\n    .close {\n        visibility: visible;\n        &:hover {\n            color: var(--main-text-color);\n        }\n    }\n}\n\n// When in nohover mode, always show the close button on the active tab. This prevents the close button of the active tab from flickering when nohover is toggled.\nbody.nohover .tab.active .close {\n    visibility: visible;\n}\n\n@keyframes expandWidthAndFadeIn {\n    from {\n        width: var(--initial-tab-width);\n        opacity: 0;\n    }\n    to {\n        width: var(--final-tab-width);\n        opacity: 1;\n    }\n}\n\n.tab.new-tab {\n    animation: expandWidthAndFadeIn 0.1s forwards;\n}\n\n"
  },
  {
    "path": "frontend/app/tab/tab.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { getTabBadgeAtom } from \"@/app/store/badge\";\nimport { refocusNode } from \"@/app/store/global\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { WaveEnv, WaveEnvSubset, useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport { Button } from \"@/element/button\";\nimport { validateCssColor } from \"@/util/color-validator\";\nimport { fireAndForget } from \"@/util/util\";\nimport clsx from \"clsx\";\nimport { useAtomValue } from \"jotai\";\nimport { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from \"react\";\nimport { makeORef } from \"../store/wos\";\nimport { TabBadges } from \"./tabbadges\";\nimport \"./tab.scss\";\nimport { buildTabContextMenu } from \"./tabcontextmenu\";\n\nexport type TabEnv = WaveEnvSubset<{\n    rpc: {\n        ActivityCommand: WaveEnv[\"rpc\"][\"ActivityCommand\"];\n        SetConfigCommand: WaveEnv[\"rpc\"][\"SetConfigCommand\"];\n        SetMetaCommand: WaveEnv[\"rpc\"][\"SetMetaCommand\"];\n        UpdateTabNameCommand: WaveEnv[\"rpc\"][\"UpdateTabNameCommand\"];\n    };\n    atoms: {\n        fullConfigAtom: WaveEnv[\"atoms\"][\"fullConfigAtom\"];\n    };\n    wos: WaveEnv[\"wos\"];\n    getSettingsKeyAtom: WaveEnv[\"getSettingsKeyAtom\"];\n    showContextMenu: WaveEnv[\"showContextMenu\"];\n}>;\n\ninterface TabVProps {\n    tabId: string;\n    tabName: string;\n    active: boolean;\n    showDivider: boolean;\n    isDragging: boolean;\n    tabWidth: number;\n    isNew: boolean;\n    badges?: Badge[] | null;\n    flagColor?: string | null;\n    onClick: () => void;\n    onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void;\n    onDragStart: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;\n    onContextMenu: (e: React.MouseEvent<HTMLDivElement>) => void;\n    onRename: (newName: string) => void;\n    /** Optional ref that TabV populates with a startRename() function for external callers */\n    renameRef?: React.RefObject<(() => void) | null>;\n}\n\nconst TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {\n    const {\n        tabId,\n        tabName,\n        active,\n        showDivider,\n        isDragging,\n        tabWidth,\n        isNew,\n        badges,\n        flagColor,\n        onClick,\n        onClose,\n        onDragStart,\n        onContextMenu,\n        onRename,\n        renameRef,\n    } = props;\n    const MaxTabNameLength = 14;\n    const truncateTabName = (name: string) => [...(name ?? \"\")].slice(0, MaxTabNameLength).join(\"\");\n    const displayName = truncateTabName(tabName);\n    const [originalName, setOriginalName] = useState(displayName);\n    const [isEditable, setIsEditable] = useState(false);\n\n    const editableRef = useRef<HTMLDivElement>(null);\n    const editableTimeoutRef = useRef<NodeJS.Timeout>(null);\n    const tabRef = useRef<HTMLDivElement>(null);\n\n    useImperativeHandle(ref, () => tabRef.current as HTMLDivElement);\n\n    useEffect(() => {\n        setOriginalName(truncateTabName(tabName));\n    }, [tabName]);\n\n    useEffect(() => {\n        return () => {\n            if (editableTimeoutRef.current) {\n                clearTimeout(editableTimeoutRef.current);\n            }\n        };\n    }, []);\n\n    const selectEditableText = useCallback(() => {\n        if (!editableRef.current) {\n            return;\n        }\n        editableRef.current.focus();\n        const range = document.createRange();\n        const selection = window.getSelection();\n        if (!selection) {\n            return;\n        }\n        range.selectNodeContents(editableRef.current);\n        selection.removeAllRanges();\n        selection.addRange(range);\n    }, []);\n\n    const startRename = useCallback(() => {\n        setIsEditable(true);\n        editableTimeoutRef.current = setTimeout(() => {\n            selectEditableText();\n        }, 50);\n    }, [selectEditableText]);\n\n    const handleRenameTab: React.MouseEventHandler<HTMLDivElement> = useCallback(\n        (event) => {\n            event?.stopPropagation();\n            startRename();\n        },\n        [startRename]\n    );\n\n    // Expose startRename to external callers (e.g. context menu in TabInner)\n    if (renameRef != null) {\n        renameRef.current = startRename;\n    }\n\n    const handleBlur = () => {\n        if (!editableRef.current) return;\n        let newText = editableRef.current.innerText.trim();\n        newText = newText || originalName;\n        editableRef.current.innerText = newText;\n        setIsEditable(false);\n        onRename(newText);\n    };\n\n    const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => {\n        if ((event.metaKey || event.ctrlKey) && event.key === \"a\") {\n            event.preventDefault();\n            selectEditableText();\n            return;\n        }\n        if (!editableRef.current) return;\n        const curLen = Array.from(editableRef.current.innerText).length;\n        if (event.key === \"Enter\") {\n            event.preventDefault();\n            event.stopPropagation();\n            if (editableRef.current.innerText.trim() === \"\") {\n                editableRef.current.innerText = originalName;\n            }\n            editableRef.current.blur();\n        } else if (event.key === \"Escape\") {\n            editableRef.current.innerText = originalName;\n            editableRef.current.blur();\n            event.preventDefault();\n            event.stopPropagation();\n        } else if (curLen >= 14 && ![\"Backspace\", \"Delete\", \"ArrowLeft\", \"ArrowRight\"].includes(event.key)) {\n            const selection = window.getSelection();\n            if (!selection || selection.isCollapsed) {\n                event.preventDefault();\n                event.stopPropagation();\n            }\n        }\n    };\n\n    useEffect(() => {\n        if (tabRef.current && isNew) {\n            const initialWidth = `${(tabWidth / 3) * 2}px`;\n            tabRef.current.style.setProperty(\"--initial-tab-width\", initialWidth);\n            tabRef.current.style.setProperty(\"--final-tab-width\", `${tabWidth}px`);\n        }\n    }, [isNew, tabWidth]);\n\n    const handleMouseDownOnClose = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {\n        event.stopPropagation();\n    };\n\n    return (\n        <div\n            ref={tabRef}\n            className={clsx(\"tab\", {\n                active,\n                dragging: isDragging,\n                \"new-tab\": isNew,\n            })}\n            onMouseDown={onDragStart}\n            onClick={onClick}\n            onContextMenu={onContextMenu}\n            data-tab-id={tabId}\n        >\n            {showDivider && <div className=\"tab-divider\" />}\n            <div className=\"tab-inner\">\n                <div\n                    ref={editableRef}\n                    className={clsx(\"name\", { focused: isEditable })}\n                    contentEditable={isEditable}\n                    onDoubleClick={handleRenameTab}\n                    onBlur={handleBlur}\n                    onKeyDown={handleKeyDown}\n                    suppressContentEditableWarning={true}\n                >\n                    {displayName}\n                </div>\n                <TabBadges badges={badges} flagColor={flagColor} />\n                <Button\n                    className=\"ghost grey close\"\n                    onClick={onClose}\n                    onMouseDown={handleMouseDownOnClose}\n                    title=\"Close Tab\"\n                >\n                    <i className=\"fa fa-solid fa-xmark\" />\n                </Button>\n            </div>\n        </div>\n    );\n});\n\nTabV.displayName = \"TabV\";\n\ninterface TabProps {\n    id: string;\n    active: boolean;\n    showDivider: boolean;\n    isDragging: boolean;\n    tabWidth: number;\n    isNew: boolean;\n    onSelect: () => void;\n    onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void;\n    onDragStart: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;\n    onLoaded: () => void;\n}\n\nconst TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => {\n    const { id, active, showDivider, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart } = props;\n    const env = useWaveEnv<TabEnv>();\n    const [tabData, _] = env.wos.useWaveObjectValue<Tab>(makeORef(\"tab\", id));\n    const badges = useAtomValue(getTabBadgeAtom(id, env));\n\n    const rawFlagColor = tabData?.meta?.[\"tab:flagcolor\"];\n    let flagColor: string | null = null;\n    if (rawFlagColor) {\n        try {\n            validateCssColor(rawFlagColor);\n            flagColor = rawFlagColor;\n        } catch {\n            flagColor = null;\n        }\n    }\n\n    const loadedRef = useRef(false);\n    const renameRef = useRef<(() => void) | null>(null);\n\n    useEffect(() => {\n        if (!loadedRef.current) {\n            onLoaded();\n            loadedRef.current = true;\n        }\n    }, [onLoaded]);\n\n    const handleTabClick = () => {\n        onSelect();\n    };\n\n    const handleContextMenu = useCallback(\n        (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {\n            e.preventDefault();\n            const menu = buildTabContextMenu(id, renameRef, onClose, env);\n            env.showContextMenu(menu, e);\n        },\n        [id, onClose, env]\n    );\n\n    const handleRename = useCallback(\n        (newName: string) => {\n            fireAndForget(() => env.rpc.UpdateTabNameCommand(TabRpcClient, id, newName));\n            setTimeout(() => refocusNode(null), 10);\n        },\n        [id, env]\n    );\n\n    return (\n        <TabV\n            ref={ref}\n            tabId={id}\n            tabName={tabData?.name ?? \"\"}\n            active={active}\n            showDivider={showDivider}\n            isDragging={isDragging}\n            tabWidth={tabWidth}\n            isNew={isNew}\n            badges={badges}\n            flagColor={flagColor}\n            onClick={handleTabClick}\n            onClose={onClose}\n            onDragStart={onDragStart}\n            onContextMenu={handleContextMenu}\n            onRename={handleRename}\n            renameRef={renameRef}\n        />\n    );\n});\nconst Tab = memo(TabInner);\nTab.displayName = \"Tab\";\n\nexport { Tab, TabV };\n"
  },
  {
    "path": "frontend/app/tab/tabbadges.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { sortBadgesForTab } from \"@/app/store/badge\";\nimport { cn, makeIconClass } from \"@/util/util\";\nimport { useMemo } from \"react\";\nimport { v7 as uuidv7 } from \"uuid\";\n\nexport interface TabBadgesProps {\n    badges?: Badge[] | null;\n    flagColor?: string | null;\n    className?: string;\n}\n\nconst DefaultClassName =\n    \"pointer-events-none absolute left-[4px] top-1/2 z-[3] flex h-[20px] w-[20px] -translate-y-1/2 items-center justify-center px-[2px] py-[1px]\";\n\nexport function TabBadges({ badges, flagColor, className }: TabBadgesProps) {\n    const flagBadgeId = useMemo(() => uuidv7(), []);\n    const allBadges = useMemo(() => {\n        const base = badges ?? [];\n        if (!flagColor) {\n            return base;\n        }\n        const flagBadge: Badge = { icon: \"flag\", color: flagColor, priority: 0, badgeid: flagBadgeId };\n        return sortBadgesForTab([...base, flagBadge]);\n    }, [badges, flagColor, flagBadgeId]);\n    if (!allBadges[0]) {\n        return null;\n    }\n    const firstBadge = allBadges[0];\n    const extraBadges = allBadges.slice(1, 3);\n    return (\n        <div className={cn(DefaultClassName, className)}>\n            <i\n                className={makeIconClass(firstBadge.icon, true, { defaultIcon: \"circle-small\" }) + \" text-[12px]\"}\n                style={{ color: firstBadge.color || \"#fbbf24\" }}\n            />\n            {extraBadges.length > 0 && (\n                <div className=\"ml-[2px] flex flex-col items-center justify-center gap-[2px]\">\n                    {extraBadges.map((badge, idx) => (\n                        <div\n                            key={idx}\n                            className=\"h-[4px] w-[4px] rounded-full\"\n                            style={{ backgroundColor: badge.color || \"#fbbf24\" }}\n                        />\n                    ))}\n                </div>\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/app/tab/tabbar-model.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nexport class TabBarModel {\n    private static instance: TabBarModel | null = null;\n\n    private constructor() {}\n\n    static getInstance(): TabBarModel {\n        if (!TabBarModel.instance) {\n            TabBarModel.instance = new TabBarModel();\n        }\n        return TabBarModel.instance;\n    }\n}"
  },
  {
    "path": "frontend/app/tab/tabbar.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.tab-bar-wrapper {\n    padding-top: 3px;\n    position: relative;\n    user-select: none;\n    display: flex;\n    flex-direction: row;\n    align-items: end;\n    width: 100vw;\n    -webkit-app-region: drag;\n    height: max(33px, calc(33px * var(--zoomfactor-inv)));\n    backdrop-filter: blur(20px);\n    background: rgba(0, 0, 0, 0.35);\n    flex-shrink: 0;\n\n    button {\n        -webkit-app-region: no-drag;\n    }\n\n    .tabs-wrapper {\n        transition: var(--tabs-wrapper-transition);\n        height: 26px;\n    }\n\n    .tab-bar {\n        position: relative; // Needed for absolute positioning of child tabs\n        display: flex;\n        flex-direction: row;\n        height: 27px;\n        -webkit-app-region: no-drag;\n        margin-bottom: 1px;\n    }\n\n    .pinned-tab-spacer {\n        display: block;\n        height: 100%;\n        margin: 2px;\n        border: 1px solid var(--border-color);\n    }\n\n    .add-tab {\n        padding: 0 10px;\n        height: 27px;\n        margin-bottom: 2px;\n    }\n\n    // Customize scrollbar styles\n    .os-theme-dark,\n    .os-theme-light {\n        box-sizing: border-box;\n        --os-size: 2px;\n        --os-padding-perpendicular: 0px;\n        --os-padding-axis: 0px;\n        --os-track-border-radius: 2px;\n        --os-handle-interactive-area-offset: 0px;\n        --os-handle-border-radius: 2px;\n    }\n}\n"
  },
  {
    "path": "frontend/app/tab/tabbar.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Tooltip } from \"@/app/element/tooltip\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport { WorkspaceLayoutModel } from \"@/app/workspace/workspace-layout-model\";\nimport { deleteLayoutModelForTab } from \"@/layout/index\";\nimport { isMacOSTahoeOrLater } from \"@/util/platformutil\";\nimport { fireAndForget } from \"@/util/util\";\nimport { useAtomValue } from \"jotai\";\nimport { OverlayScrollbars } from \"overlayscrollbars\";\nimport { createRef, memo, useCallback, useEffect, useRef, useState } from \"react\";\nimport { debounce } from \"throttle-debounce\";\nimport { Tab } from \"./tab\";\nimport \"./tabbar.scss\";\nimport { TabBarEnv } from \"./tabbarenv\";\nimport { UpdateStatusBanner } from \"./updatebanner\";\nimport { WorkspaceSwitcher } from \"./workspaceswitcher\";\n\nconst TabDefaultWidth = 130;\nconst TabMinWidth = 100;\nconst MacOSTrafficLightsWidth = 74;\nconst MacOSTahoeTrafficLightsWidth = 80;\n\nconst OSOptions = {\n    overflow: {\n        x: \"scroll\",\n        y: \"hidden\",\n    },\n    scrollbars: {\n        theme: \"os-theme-dark\",\n        visibility: \"auto\",\n        autoHide: \"leave\",\n        autoHideDelay: 1300,\n        autoHideSuspend: false,\n        dragScroll: true,\n        clickScroll: false,\n        pointers: [\"mouse\", \"touch\", \"pen\"],\n    },\n};\n\ninterface TabBarProps {\n    workspace: Workspace;\n    noTabs?: boolean;\n}\n\nconst WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject<HTMLDivElement> }) => {\n    const env = useWaveEnv<TabBarEnv>();\n    const aiPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom);\n    const hideAiButton = useAtomValue(env.getSettingsKeyAtom(\"app:hideaibutton\"));\n\n    const onClick = () => {\n        const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible();\n        WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible);\n    };\n\n    if (hideAiButton) {\n        return null;\n    }\n\n    return (\n        <Tooltip\n            content=\"Toggle Wave AI Panel\"\n            placement=\"bottom\"\n            hideOnClick\n            divClassName={`flex h-[22px] px-3.5 justify-end mb-1 items-center rounded-md mr-1 box-border cursor-pointer bg-hover hover:bg-hoverbg transition-colors text-[12px] ${aiPanelOpen ? \"text-accent\" : \"text-secondary\"}`}\n            divStyle={{ WebkitAppRegion: \"no-drag\" } as React.CSSProperties}\n            divOnClick={onClick}\n            divRef={divRef}\n        >\n            <i className=\"fa fa-sparkles\" />\n        </Tooltip>\n    );\n});\nWaveAIButton.displayName = \"WaveAIButton\";\n\nfunction strArrayIsEqual(a: string[], b: string[]) {\n    // null check\n    if (a == null && b == null) {\n        return true;\n    }\n    if (a == null || b == null) {\n        return false;\n    }\n    if (a.length !== b.length) {\n        return false;\n    }\n    for (let i = 0; i < a.length; i++) {\n        if (a[i] !== b[i]) {\n            return false;\n        }\n    }\n    return true;\n}\n\nconst TabBar = memo(({ workspace, noTabs }: TabBarProps) => {\n    const env = useWaveEnv<TabBarEnv>();\n    const [tabIds, setTabIds] = useState<string[]>([]);\n    const [dragStartPositions, setDragStartPositions] = useState<number[]>([]);\n    const [draggingTab, setDraggingTab] = useState<string>();\n    const [tabsLoaded, setTabsLoaded] = useState({});\n    const [newTabId, setNewTabId] = useState<string | null>(null);\n\n    const tabbarWrapperRef = useRef<HTMLDivElement>(null);\n    const tabBarRef = useRef<HTMLDivElement>(null);\n    const tabsWrapperRef = useRef<HTMLDivElement>(null);\n    const tabRefs = useRef<React.RefObject<HTMLDivElement>[]>([]);\n    const addBtnRef = useRef<HTMLButtonElement>(null);\n    const draggingRemovedRef = useRef(false);\n    const draggingTabDataRef = useRef({\n        tabId: \"\",\n        ref: { current: null },\n        tabStartX: 0,\n        tabStartIndex: 0,\n        tabIndex: 0,\n        initialOffsetX: null,\n        totalScrollOffset: null,\n        dragged: false,\n    });\n    const osInstanceRef = useRef<OverlayScrollbars>(null);\n    const draggerLeftRef = useRef<HTMLDivElement>(null);\n    const rightContainerRef = useRef<HTMLDivElement>(null);\n    const workspaceSwitcherRef = useRef<HTMLDivElement>(null);\n    const waveAIButtonRef = useRef<HTMLDivElement>(null);\n    const appMenuButtonRef = useRef<HTMLDivElement>(null);\n    const tabWidthRef = useRef<number>(TabDefaultWidth);\n    const scrollableRef = useRef<boolean>(false);\n    const prevAllLoadedRef = useRef<boolean>(false);\n    const activeTabId = useAtomValue(env.atoms.staticTabId);\n    const isFullScreen = useAtomValue(env.atoms.isFullScreen);\n    const zoomFactor = useAtomValue(env.atoms.zoomFactorAtom);\n    const showMenuBar = useAtomValue(env.getSettingsKeyAtom(\"window:showmenubar\"));\n    const confirmClose = useAtomValue(env.getSettingsKeyAtom(\"tab:confirmclose\")) ?? false;\n    const hideAiButton = useAtomValue(env.getSettingsKeyAtom(\"app:hideaibutton\"));\n    const appUpdateStatus = useAtomValue(env.atoms.updaterStatusAtom);\n\n    let prevDelta: number;\n    let prevDragDirection: string;\n\n    // Update refs when tabIds change\n    useEffect(() => {\n        tabRefs.current = tabIds.map((_, index) => tabRefs.current[index] || createRef());\n    }, [tabIds]);\n\n    useEffect(() => {\n        if (!workspace) {\n            return;\n        }\n        const newTabIdsArr = workspace.tabids ?? [];\n\n        const areEqual = strArrayIsEqual(tabIds, newTabIdsArr);\n\n        if (!areEqual) {\n            setTabIds(newTabIdsArr);\n        }\n    }, [workspace, tabIds]);\n\n    const saveTabsPosition = useCallback(() => {\n        const tabs = tabRefs.current;\n        if (tabs === null) return;\n\n        const newStartPositions: number[] = [];\n        let cumulativeLeft = 0; // Start from the left edge\n\n        tabRefs.current.forEach((ref) => {\n            if (ref.current) {\n                newStartPositions.push(cumulativeLeft);\n                cumulativeLeft += ref.current.getBoundingClientRect().width; // Add each tab's actual width to the cumulative position\n            }\n        });\n\n        setDragStartPositions(newStartPositions);\n    }, []);\n\n    const setSizeAndPosition = (animate?: boolean) => {\n        const tabBar = tabBarRef.current;\n        if (tabBar === null) return;\n\n        const getOuterWidth = (el: HTMLElement): number => {\n            const rect = el.getBoundingClientRect();\n            const style = getComputedStyle(el);\n            return rect.width + parseFloat(style.marginLeft) + parseFloat(style.marginRight);\n        };\n\n        const tabbarWrapperWidth = tabbarWrapperRef.current.getBoundingClientRect().width;\n        const windowDragLeftWidth = draggerLeftRef.current.getBoundingClientRect().width;\n        const rightContainerWidth = rightContainerRef.current?.getBoundingClientRect().width ?? 0;\n        const addBtnWidth = getOuterWidth(addBtnRef.current);\n        const appMenuButtonWidth = appMenuButtonRef.current?.getBoundingClientRect().width ?? 0;\n        const workspaceSwitcherWidth = workspaceSwitcherRef.current?.getBoundingClientRect().width ?? 0;\n        const waveAIButtonWidth = waveAIButtonRef.current != null ? getOuterWidth(waveAIButtonRef.current) : 0;\n\n        const nonTabElementsWidth =\n            windowDragLeftWidth +\n            rightContainerWidth +\n            addBtnWidth +\n            appMenuButtonWidth +\n            workspaceSwitcherWidth +\n            waveAIButtonWidth;\n        const spaceForTabs = tabbarWrapperWidth - nonTabElementsWidth;\n\n        const numberOfTabs = tabIds.length;\n\n        // Compute the ideal width per tab by dividing the available space by the number of tabs\n        let idealTabWidth = spaceForTabs / numberOfTabs;\n\n        // Apply min/max constraints\n        idealTabWidth = Math.max(TabMinWidth, Math.min(idealTabWidth, TabDefaultWidth));\n\n        // Determine if the tab bar needs to be scrollable\n        const newScrollable = idealTabWidth * numberOfTabs > spaceForTabs;\n\n        // Apply the calculated width and position to all tabs\n        tabRefs.current.forEach((ref, index) => {\n            if (ref.current) {\n                if (animate) {\n                    ref.current.classList.add(\"animate\");\n                } else {\n                    ref.current.classList.remove(\"animate\");\n                }\n                ref.current.style.width = `${idealTabWidth}px`;\n                ref.current.style.transform = `translate3d(${index * idealTabWidth}px,0,0)`;\n                ref.current.style.opacity = \"1\";\n            }\n        });\n\n        // Update the state with the new tab width if it has changed\n        if (idealTabWidth !== tabWidthRef.current) {\n            tabWidthRef.current = idealTabWidth;\n        }\n\n        // Update the state with the new scrollable state if it has changed\n        if (newScrollable !== scrollableRef.current) {\n            scrollableRef.current = newScrollable;\n        }\n\n        // Initialize/destroy overlay scrollbars\n        if (newScrollable) {\n            osInstanceRef.current = OverlayScrollbars(tabBarRef.current, { ...(OSOptions as any) });\n        } else {\n            if (osInstanceRef.current) {\n                osInstanceRef.current.destroy();\n            }\n        }\n    };\n\n    const saveTabsPositionDebounced = useCallback(\n        debounce(100, () => saveTabsPosition()),\n        [saveTabsPosition]\n    );\n\n    const handleResizeTabs = useCallback(() => {\n        setSizeAndPosition();\n        saveTabsPositionDebounced();\n    }, [tabIds, newTabId, isFullScreen]);\n\n    // update layout on reinit version\n    const reinitVersion = useAtomValue(env.atoms.reinitVersion);\n    useEffect(() => {\n        if (reinitVersion > 0) {\n            setSizeAndPosition();\n        }\n    }, [reinitVersion]);\n\n    // update layout on resize\n    useEffect(() => {\n        window.addEventListener(\"resize\", handleResizeTabs);\n        return () => {\n            window.removeEventListener(\"resize\", handleResizeTabs);\n        };\n    }, [handleResizeTabs]);\n\n    // update layout on changed tabIds, tabsLoaded, newTabId, hideAiButton, appUpdateStatus, or zoomFactor\n    useEffect(() => {\n        // Check if all tabs are loaded\n        const allLoaded = tabIds.length > 0 && tabIds.every((id) => tabsLoaded[id]);\n        if (allLoaded) {\n            setSizeAndPosition(newTabId === null && prevAllLoadedRef.current);\n            saveTabsPosition();\n            if (!prevAllLoadedRef.current) {\n                prevAllLoadedRef.current = true;\n            }\n        }\n    }, [\n        tabIds,\n        tabsLoaded,\n        newTabId,\n        saveTabsPosition,\n        hideAiButton,\n        appUpdateStatus,\n        zoomFactor,\n        showMenuBar,\n    ]);\n\n    const getDragDirection = (currentX: number) => {\n        let dragDirection: string;\n        if (currentX - prevDelta > 0) {\n            dragDirection = \"+\";\n        } else if (currentX - prevDelta === 0) {\n            dragDirection = prevDragDirection;\n        } else {\n            dragDirection = \"-\";\n        }\n        prevDelta = currentX;\n        prevDragDirection = dragDirection;\n        return dragDirection;\n    };\n\n    const getNewTabIndex = (currentX: number, tabIndex: number, dragDirection: string) => {\n        let newTabIndex = tabIndex;\n        const tabWidth = tabWidthRef.current;\n        if (dragDirection === \"+\") {\n            // Dragging to the right\n            for (let i = tabIndex + 1; i < tabIds.length; i++) {\n                const otherTabStart = dragStartPositions[i];\n                if (currentX + tabWidth > otherTabStart + tabWidth / 2) {\n                    newTabIndex = i;\n                }\n            }\n        } else {\n            // Dragging to the left\n            for (let i = tabIndex - 1; i >= 0; i--) {\n                const otherTabEnd = dragStartPositions[i] + tabWidth;\n                if (currentX < otherTabEnd - tabWidth / 2) {\n                    newTabIndex = i;\n                }\n            }\n        }\n        return newTabIndex;\n    };\n\n    const handleMouseMove = (event: MouseEvent) => {\n        const { tabId, ref, tabStartX } = draggingTabDataRef.current;\n\n        let initialOffsetX = draggingTabDataRef.current.initialOffsetX;\n        let totalScrollOffset = draggingTabDataRef.current.totalScrollOffset;\n        if (initialOffsetX === null) {\n            initialOffsetX = event.clientX - tabStartX;\n            draggingTabDataRef.current.initialOffsetX = initialOffsetX;\n        }\n        let currentX = event.clientX - initialOffsetX - totalScrollOffset;\n        let tabBarRectWidth = tabBarRef.current.getBoundingClientRect().width;\n        // for macos, it's offset to make space for the window buttons\n        const tabBarRectLeftOffset = tabBarRef.current.getBoundingClientRect().left;\n        const incrementDecrement = tabBarRectLeftOffset * 0.05;\n        const dragDirection = getDragDirection(currentX);\n        const scrollable = scrollableRef.current;\n        const tabWidth = tabWidthRef.current;\n\n        // Scroll the tab bar if the dragged tab overflows the container bounds\n        if (scrollable) {\n            const { viewport } = osInstanceRef.current.elements();\n            const currentScrollLeft = viewport.scrollLeft;\n\n            if (event.clientX <= tabBarRectLeftOffset) {\n                viewport.scrollLeft = Math.max(0, currentScrollLeft - incrementDecrement); // Scroll left\n                if (viewport.scrollLeft !== currentScrollLeft) {\n                    // Only adjust if the scroll actually changed\n                    draggingTabDataRef.current.totalScrollOffset += currentScrollLeft - viewport.scrollLeft;\n                }\n            } else if (event.clientX >= tabBarRectWidth + tabBarRectLeftOffset) {\n                viewport.scrollLeft = Math.min(viewport.scrollWidth, currentScrollLeft + incrementDecrement); // Scroll right\n                if (viewport.scrollLeft !== currentScrollLeft) {\n                    // Only adjust if the scroll actually changed\n                    draggingTabDataRef.current.totalScrollOffset -= viewport.scrollLeft - currentScrollLeft;\n                }\n            }\n        }\n\n        // Re-calculate currentX after potential scroll adjustment\n        initialOffsetX = draggingTabDataRef.current.initialOffsetX;\n        totalScrollOffset = draggingTabDataRef.current.totalScrollOffset;\n        currentX = event.clientX - initialOffsetX - totalScrollOffset;\n\n        setDraggingTab((prev) => (prev !== tabId ? tabId : prev));\n\n        // Check if the tab has moved 5 pixels\n        if (Math.abs(currentX - tabStartX) >= 50) {\n            draggingTabDataRef.current.dragged = true;\n        }\n\n        // Constrain movement within the container bounds\n        if (tabBarRef.current) {\n            const numberOfTabs = tabIds.length;\n            const totalDefaultTabWidth = numberOfTabs * TabDefaultWidth;\n            if (totalDefaultTabWidth < tabBarRectWidth) {\n                // Set to the total default tab width if there's vacant space\n                tabBarRectWidth = totalDefaultTabWidth;\n            } else if (scrollable) {\n                // Set to the scrollable width if the tab bar is scrollable\n                tabBarRectWidth = tabsWrapperRef.current.scrollWidth;\n            }\n\n            const minLeft = 0;\n            const maxRight = tabBarRectWidth - tabWidth;\n\n            // Adjust currentX to stay within bounds\n            currentX = Math.min(Math.max(currentX, minLeft), maxRight);\n        }\n\n        ref.current!.style.transform = `translate3d(${currentX}px,0,0)`;\n        ref.current!.style.zIndex = \"100\";\n\n        const tabIndex = draggingTabDataRef.current.tabIndex;\n        const newTabIndex = getNewTabIndex(currentX, tabIndex, dragDirection);\n\n        if (newTabIndex !== tabIndex) {\n            // Remove the dragged tab if not already done\n            if (!draggingRemovedRef.current) {\n                tabIds.splice(tabIndex, 1);\n                draggingRemovedRef.current = true;\n            }\n\n            // Find current index of the dragged tab in tempTabs\n            const currentIndexOfDraggingTab = tabIds.indexOf(tabId);\n\n            // Move the dragged tab to its new position\n            if (currentIndexOfDraggingTab !== -1) {\n                tabIds.splice(currentIndexOfDraggingTab, 1);\n            }\n            tabIds.splice(newTabIndex, 0, tabId);\n\n            // Update visual positions of the tabs\n            tabIds.forEach((localTabId, index) => {\n                const ref = tabRefs.current.find((ref) => ref.current.dataset.tabId === localTabId);\n                if (ref.current && localTabId !== tabId) {\n                    ref.current.style.transform = `translate3d(${index * tabWidth}px,0,0)`;\n                    ref.current.classList.add(\"animate\");\n                }\n            });\n\n            draggingTabDataRef.current.tabIndex = newTabIndex;\n        }\n    };\n\n    const setUpdatedTabsDebounced = useCallback(\n        debounce(300, (tabIds: string[]) => {\n            // Reset styles\n            tabRefs.current.forEach((ref) => {\n                ref.current.style.zIndex = \"0\";\n                ref.current.classList.remove(\"animate\");\n            });\n            // Reset dragging state\n            setDraggingTab(null);\n            // Update workspace tab ids\n            fireAndForget(() => env.rpc.UpdateWorkspaceTabIdsCommand(TabRpcClient, workspace.oid, tabIds));\n        }),\n        []\n    );\n\n    const handleMouseUp = (_event: MouseEvent) => {\n        const { tabIndex, dragged } = draggingTabDataRef.current;\n\n        // Update the final position of the dragged tab\n        const draggingTab = tabIds[tabIndex];\n        const tabWidth = tabWidthRef.current;\n        const finalLeftPosition = tabIndex * tabWidth;\n        const ref = tabRefs.current.find((ref) => ref.current.dataset.tabId === draggingTab);\n        if (ref.current) {\n            ref.current.classList.add(\"animate\");\n            ref.current.style.transform = `translate3d(${finalLeftPosition}px,0,0)`;\n        }\n\n        if (dragged) {\n            setUpdatedTabsDebounced(tabIds);\n        } else {\n            // Reset styles\n            tabRefs.current.forEach((ref) => {\n                ref.current.style.zIndex = \"0\";\n                ref.current.classList.remove(\"animate\");\n            });\n            // Reset dragging state\n            setDraggingTab(null);\n        }\n\n        document.removeEventListener(\"mouseup\", handleMouseUp);\n        document.removeEventListener(\"mousemove\", handleMouseMove);\n        draggingRemovedRef.current = false;\n    };\n\n    const handleDragStart = useCallback(\n        (event: React.MouseEvent<HTMLDivElement, MouseEvent>, tabId: string, ref: React.RefObject<HTMLDivElement>) => {\n            if (event.button !== 0) return;\n\n            const tabIndex = tabIds.indexOf(tabId);\n            const tabStartX = dragStartPositions[tabIndex]; // Starting X position of the tab\n\n            console.log(\"handleDragStart\", tabId, tabIndex, tabStartX);\n            if (ref.current) {\n                draggingTabDataRef.current = {\n                    tabId: ref.current.dataset.tabId,\n                    ref,\n                    tabStartX,\n                    tabIndex,\n                    tabStartIndex: tabIndex,\n                    initialOffsetX: null,\n                    totalScrollOffset: 0,\n                    dragged: false,\n                };\n\n                document.addEventListener(\"mousemove\", handleMouseMove);\n                document.addEventListener(\"mouseup\", handleMouseUp);\n            }\n        },\n        [tabIds, dragStartPositions]\n    );\n\n    const handleSelectTab = (tabId: string) => {\n        if (!draggingTabDataRef.current.dragged) {\n            env.electron.setActiveTab(tabId);\n        }\n    };\n\n    const updateScrollDebounced = useCallback(\n        debounce(30, () => {\n            if (scrollableRef.current) {\n                const { viewport } = osInstanceRef.current.elements();\n                viewport.scrollLeft = tabIds.length * tabWidthRef.current;\n            }\n        }),\n        [tabIds]\n    );\n\n    const setNewTabIdDebounced = useCallback(\n        debounce(100, (tabId: string) => {\n            setNewTabId(tabId);\n        }),\n        []\n    );\n\n    const handleAddTab = () => {\n        env.electron.createTab();\n        tabsWrapperRef.current.style.setProperty(\"--tabs-wrapper-transition\", \"width 0.1s ease\");\n\n        updateScrollDebounced();\n\n        setNewTabIdDebounced(null);\n    };\n\n    const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => {\n        event?.stopPropagation();\n        env.electron\n            .closeTab(workspace.oid, tabId, confirmClose)\n            .then((didClose) => {\n                if (didClose) {\n                    tabsWrapperRef.current?.style.setProperty(\"--tabs-wrapper-transition\", \"width 0.3s ease\");\n                    deleteLayoutModelForTab(tabId);\n                }\n            })\n            .catch((e) => {\n                console.log(\"error closing tab\", e);\n            });\n    };\n\n    const handleTabLoaded = useCallback((tabId: string) => {\n        setTabsLoaded((prev) => {\n            if (!prev[tabId]) {\n                // Only update if the tab isn't already marked as loaded\n                return { ...prev, [tabId]: true };\n            }\n            return prev;\n        });\n    }, []);\n\n    const activeTabIndex = tabIds.indexOf(activeTabId);\n\n    function onEllipsisClick() {\n        env.electron.showWorkspaceAppMenu(workspace.oid);\n    }\n\n    const tabsWrapperWidth = tabIds.length * tabWidthRef.current;\n    const showAppMenuButton = env.isWindows() || (!env.isMacOS() && !showMenuBar);\n\n    // Calculate window drag left width based on platform and state\n    let windowDragLeftWidth = 10;\n    if (env.isMacOS() && !isFullScreen) {\n        const trafficLightsWidth = isMacOSTahoeOrLater()\n            ? MacOSTahoeTrafficLightsWidth\n            : MacOSTrafficLightsWidth;\n        if (zoomFactor > 0) {\n            windowDragLeftWidth = trafficLightsWidth / zoomFactor;\n        } else {\n            windowDragLeftWidth = trafficLightsWidth;\n        }\n    }\n\n    // Calculate window drag right width\n    let windowDragRightWidth = 12;\n    if (env.isWindows()) {\n        if (zoomFactor > 0) {\n            windowDragRightWidth = 139 / zoomFactor;\n        } else {\n            windowDragRightWidth = 139;\n        }\n    }\n\n    return (\n        <div ref={tabbarWrapperRef} className=\"tab-bar-wrapper\">\n            <div\n                ref={draggerLeftRef}\n                className=\"h-full shrink-0 z-window-drag\"\n                style={{ width: windowDragLeftWidth, WebkitAppRegion: \"drag\" } as any}\n            />\n            {showAppMenuButton && (\n                <div\n                    ref={appMenuButtonRef}\n                    className=\"flex items-center justify-center pr-1.5 text-[26px] select-none cursor-pointer text-secondary hover:text-primary\"\n                    style={{ WebkitAppRegion: \"no-drag\" } as React.CSSProperties}\n                    onClick={onEllipsisClick}\n                >\n                    <i className=\"fa fa-ellipsis\" />\n                </div>\n            )}\n            <WaveAIButton divRef={waveAIButtonRef} />\n            <Tooltip\n                content=\"Workspace Switcher\"\n                placement=\"bottom\"\n                hideOnClick\n                divRef={workspaceSwitcherRef}\n                divClassName=\"flex items-center\"\n            >\n                <WorkspaceSwitcher />\n            </Tooltip>\n            <div className=\"tab-bar\" ref={tabBarRef} data-overlayscrollbars-initialize>\n                <div\n                    className=\"tabs-wrapper\"\n                    ref={tabsWrapperRef}\n                    style={{\n                        width: noTabs ? 0 : tabsWrapperWidth,\n                        ...(noTabs ? ({ WebkitAppRegion: \"drag\" } as React.CSSProperties) : {}),\n                    }}\n                >\n                    {!noTabs &&\n                        tabIds.map((tabId, index) => {\n                            const isActive = activeTabId === tabId;\n                            const showDivider = index !== 0 && !isActive && index !== activeTabIndex + 1;\n                            return (\n                                <Tab\n                                    key={tabId}\n                                    ref={tabRefs.current[index]}\n                                    id={tabId}\n                                    showDivider={showDivider}\n                                    onSelect={() => handleSelectTab(tabId)}\n                                    active={isActive}\n                                    onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])}\n                                    onClose={(event) => handleCloseTab(event, tabId)}\n                                    onLoaded={() => handleTabLoaded(tabId)}\n                                    isDragging={draggingTab === tabId}\n                                    tabWidth={tabWidthRef.current}\n                                    isNew={tabId === newTabId}\n                                />\n                            );\n                        })}\n                </div>\n            </div>\n            <button\n                ref={addBtnRef}\n                title=\"Add Tab\"\n                className={`flex h-[22px] px-2 mb-1 mx-1 items-center rounded-md box-border cursor-pointer hover:bg-hoverbg transition-colors text-[12px] text-secondary hover:text-primary${noTabs ? \" invisible\" : \"\"}`}\n                style={{ WebkitAppRegion: \"no-drag\" } as React.CSSProperties}\n                onClick={handleAddTab}\n            >\n                <i className=\"fa fa-solid fa-plus\" />\n            </button>\n            <div className=\"flex-1\" />\n            <div ref={rightContainerRef} className=\"flex flex-row gap-1 items-end\">\n                <UpdateStatusBanner />\n                <div\n                    className=\"h-full shrink-0 z-window-drag\"\n                    style={{ width: windowDragRightWidth, WebkitAppRegion: \"drag\" } as any}\n                />\n            </div>\n        </div>\n    );\n});\n\nexport { TabBar, WaveAIButton };\n"
  },
  {
    "path": "frontend/app/tab/tabbarenv.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from \"@/app/waveenv/waveenv\";\n\nexport type TabBarEnv = WaveEnvSubset<{\n    electron: {\n        createTab: WaveEnv[\"electron\"][\"createTab\"];\n        closeTab: WaveEnv[\"electron\"][\"closeTab\"];\n        setActiveTab: WaveEnv[\"electron\"][\"setActiveTab\"];\n        showWorkspaceAppMenu: WaveEnv[\"electron\"][\"showWorkspaceAppMenu\"];\n        installAppUpdate: WaveEnv[\"electron\"][\"installAppUpdate\"];\n    };\n    rpc: {\n        ActivityCommand: WaveEnv[\"rpc\"][\"ActivityCommand\"];\n        SetConfigCommand: WaveEnv[\"rpc\"][\"SetConfigCommand\"];\n        SetMetaCommand: WaveEnv[\"rpc\"][\"SetMetaCommand\"];\n        UpdateTabNameCommand: WaveEnv[\"rpc\"][\"UpdateTabNameCommand\"];\n        UpdateWorkspaceTabIdsCommand: WaveEnv[\"rpc\"][\"UpdateWorkspaceTabIdsCommand\"];\n    };\n    atoms: {\n        fullConfigAtom: WaveEnv[\"atoms\"][\"fullConfigAtom\"];\n        hasConfigErrors: WaveEnv[\"atoms\"][\"hasConfigErrors\"];\n        staticTabId: WaveEnv[\"atoms\"][\"staticTabId\"];\n        isFullScreen: WaveEnv[\"atoms\"][\"isFullScreen\"];\n        zoomFactorAtom: WaveEnv[\"atoms\"][\"zoomFactorAtom\"];\n        reinitVersion: WaveEnv[\"atoms\"][\"reinitVersion\"];\n        updaterStatusAtom: WaveEnv[\"atoms\"][\"updaterStatusAtom\"];\n    };\n    wos: WaveEnv[\"wos\"];\n    getSettingsKeyAtom: SettingsKeyAtomFnType<\"app:hideaibutton\" | \"app:tabbar\" | \"tab:confirmclose\" | \"window:showmenubar\">;\n    showContextMenu: WaveEnv[\"showContextMenu\"];\n    mockSetWaveObj: WaveEnv[\"mockSetWaveObj\"];\n    isWindows: WaveEnv[\"isWindows\"];\n    isMacOS: WaveEnv[\"isMacOS\"];\n}>;\n"
  },
  {
    "path": "frontend/app/tab/tabcontent.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Block } from \"@/app/block/block\";\nimport { CenteredDiv } from \"@/element/quickelems\";\nimport { ContentRenderer, NodeModel, PreviewRenderer, TileLayout } from \"@/layout/index\";\nimport { TileLayoutContents } from \"@/layout/lib/types\";\nimport { atoms, getApi } from \"@/store/global\";\nimport * as services from \"@/store/services\";\nimport * as WOS from \"@/store/wos\";\nimport { atom, useAtomValue } from \"jotai\";\nimport * as React from \"react\";\nimport { useMemo } from \"react\";\n\nconst tileGapSizeAtom = atom((get) => {\n    const settings = get(atoms.settingsAtom);\n    return settings[\"window:tilegapsize\"];\n});\n\nconst TabContent = React.memo(({ tabId, noTopPadding }: { tabId: string; noTopPadding?: boolean }) => {\n    const oref = useMemo(() => WOS.makeORef(\"tab\", tabId), [tabId]);\n    const loadingAtom = useMemo(() => WOS.getWaveObjectLoadingAtom(oref), [oref]);\n    const tabLoading = useAtomValue(loadingAtom);\n    const tabAtom = useMemo(() => WOS.getWaveObjectAtom<Tab>(oref), [oref]);\n    const tabData = useAtomValue(tabAtom);\n    const tileGapSize = useAtomValue(tileGapSizeAtom);\n\n    const tileLayoutContents = useMemo(() => {\n        const renderContent: ContentRenderer = (nodeModel: NodeModel) => {\n            return <Block key={nodeModel.blockId} nodeModel={nodeModel} preview={false} />;\n        };\n\n        const renderPreview: PreviewRenderer = (nodeModel: NodeModel) => {\n            return <Block key={nodeModel.blockId} nodeModel={nodeModel} preview={true} />;\n        };\n\n        function onNodeDelete(data: TabLayoutData) {\n            return services.ObjectService.DeleteBlock(data.blockId);\n        }\n\n        return {\n            renderContent,\n            renderPreview,\n            tabId,\n            onNodeDelete,\n            gapSizePx: tileGapSize,\n        } as TileLayoutContents;\n    }, [tabId, tileGapSize]);\n\n    let innerContent;\n\n    if (tabLoading) {\n        innerContent = <CenteredDiv>Tab Loading</CenteredDiv>;\n    } else if (!tabData) {\n        innerContent = <CenteredDiv>Tab Not Found</CenteredDiv>;\n    } else if (tabData?.blockids?.length == 0) {\n        innerContent = null;\n    } else {\n        innerContent = (\n            <TileLayout\n                key={tabId}\n                contents={tileLayoutContents}\n                tabAtom={tabAtom}\n                getCursorPoint={getApi().getCursorPoint}\n            />\n        );\n    }\n\n    return (\n        <div className={`flex flex-row flex-grow min-h-0 w-full items-center justify-center overflow-hidden relative ${noTopPadding ? \"\" : \"pt-[3px]\"} pr-[3px]`}>\n            {innerContent}\n        </div>\n    );\n});\n\nexport { TabContent };\n"
  },
  {
    "path": "frontend/app/tab/tabcontextmenu.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { getOrefMetaKeyAtom, globalStore, recordTEvent } from \"@/app/store/global\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { fireAndForget } from \"@/util/util\";\nimport { makeORef } from \"../store/wos\";\nimport type { TabEnv } from \"./tab\";\n\nconst FlagColors: { label: string; value: string }[] = [\n    { label: \"Green\", value: \"#58C142\" },\n    { label: \"Teal\", value: \"#00FFDB\" },\n    { label: \"Blue\", value: \"#429DFF\" },\n    { label: \"Purple\", value: \"#BF55EC\" },\n    { label: \"Red\", value: \"#FF453A\" },\n    { label: \"Orange\", value: \"#FF9500\" },\n    { label: \"Yellow\", value: \"#FFE900\" },\n];\n\nexport function buildTabBarContextMenu(env: TabEnv): ContextMenuItem[] {\n    const currentTabBar = globalStore.get(env.getSettingsKeyAtom(\"app:tabbar\")) ?? \"top\";\n    const tabBarSubmenu: ContextMenuItem[] = [\n        {\n            label: \"Top\",\n            type: \"checkbox\",\n            checked: currentTabBar === \"top\",\n            click: () => fireAndForget(() => env.rpc.SetConfigCommand(TabRpcClient, { \"app:tabbar\": \"top\" })),\n        },\n        {\n            label: \"Left\",\n            type: \"checkbox\",\n            checked: currentTabBar === \"left\",\n            click: () => fireAndForget(() => env.rpc.SetConfigCommand(TabRpcClient, { \"app:tabbar\": \"left\" })),\n        },\n    ];\n    return [{ label: \"Tab Bar Position\", type: \"submenu\", submenu: tabBarSubmenu }];\n}\n\nexport function buildTabContextMenu(\n    id: string,\n    renameRef: React.RefObject<(() => void) | null>,\n    onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void,\n    env: TabEnv\n): ContextMenuItem[] {\n    const menu: ContextMenuItem[] = [];\n    menu.push(\n        { label: \"Rename Tab\", click: () => renameRef.current?.() },\n        {\n            label: \"Copy TabId\",\n            click: () => fireAndForget(() => navigator.clipboard.writeText(id)),\n        },\n        { type: \"separator\" }\n    );\n    const tabORef = makeORef(\"tab\", id);\n    const currentFlagColor = globalStore.get(getOrefMetaKeyAtom(tabORef, \"tab:flagcolor\")) ?? null;\n    const flagSubmenu: ContextMenuItem[] = [\n        {\n            label: \"None\",\n            type: \"checkbox\",\n            checked: currentFlagColor == null,\n            click: () =>\n                fireAndForget(() =>\n                    env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { \"tab:flagcolor\": null } })\n                ),\n        },\n        ...FlagColors.map((fc) => ({\n            label: fc.label,\n            type: \"checkbox\" as const,\n            checked: currentFlagColor === fc.value,\n            click: () =>\n                fireAndForget(() =>\n                    env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { \"tab:flagcolor\": fc.value } })\n                ),\n        })),\n    ];\n    menu.push({ label: \"Flag Tab\", type: \"submenu\", submenu: flagSubmenu }, { type: \"separator\" });\n    const fullConfig = globalStore.get(env.atoms.fullConfigAtom);\n    const bgPresets: string[] = [];\n    for (const key in fullConfig?.presets ?? {}) {\n        if (key.startsWith(\"bg@\") && fullConfig.presets[key] != null) {\n            bgPresets.push(key);\n        }\n    }\n    bgPresets.sort((a, b) => {\n        const aOrder = fullConfig.presets[a][\"display:order\"] ?? 0;\n        const bOrder = fullConfig.presets[b][\"display:order\"] ?? 0;\n        return aOrder - bOrder;\n    });\n    if (bgPresets.length > 0) {\n        const submenu: ContextMenuItem[] = [];\n        const oref = makeORef(\"tab\", id);\n        for (const presetName of bgPresets) {\n            // preset cannot be null (filtered above)\n            const preset = fullConfig.presets[presetName];\n            submenu.push({\n                label: preset[\"display:name\"] ?? presetName,\n                click: () =>\n                    fireAndForget(async () => {\n                        await env.rpc.SetMetaCommand(TabRpcClient, { oref, meta: preset });\n                        env.rpc.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });\n                        recordTEvent(\"action:settabtheme\");\n                    }),\n            });\n        }\n        menu.push({ label: \"Backgrounds\", type: \"submenu\", submenu }, { type: \"separator\" });\n    }\n    menu.push(...buildTabBarContextMenu(env), { type: \"separator\" });\n    menu.push({ label: \"Close Tab\", click: () => onClose(null) });\n    return menu;\n}\n"
  },
  {
    "path": "frontend/app/tab/updatebanner.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Tooltip } from \"@/element/tooltip\";\nimport { WaveEnv, WaveEnvSubset, useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useCallback } from \"react\";\n\ntype UpdateBannerEnv = WaveEnvSubset<{\n    electron: {\n        installAppUpdate: WaveEnv[\"electron\"][\"installAppUpdate\"];\n    };\n    atoms: {\n        updaterStatusAtom: WaveEnv[\"atoms\"][\"updaterStatusAtom\"];\n    };\n}>;\n\nfunction getUpdateStatusMessage(status: string): string {\n    switch (status) {\n        case \"ready\":\n            return \"Update\";\n        case \"downloading\":\n            return \"Downloading\";\n        case \"installing\":\n            return \"Installing\";\n        default:\n            return null;\n    }\n}\n\nconst UpdateStatusBannerComponent = () => {\n    const env = useWaveEnv<UpdateBannerEnv>();\n    const appUpdateStatus = useAtomValue(env.atoms.updaterStatusAtom);\n    const updateStatusMessage = getUpdateStatusMessage(appUpdateStatus);\n\n    const onClick = useCallback(() => {\n        env.electron.installAppUpdate();\n    }, [env]);\n\n    if (!updateStatusMessage) {\n        return null;\n    }\n\n    const isReady = appUpdateStatus === \"ready\";\n    const tooltipContent = isReady ? \"Click to Install Update\" : updateStatusMessage;\n\n    return (\n        <Tooltip\n            content={tooltipContent}\n            placement=\"bottom\"\n            divOnClick={isReady ? onClick : undefined}\n            divClassName={`flex items-center gap-1 px-2 mb-1 h-[22px] text-xs font-medium text-black bg-accent rounded-sm transition-all ${isReady ? \"cursor-pointer hover:bg-[var(--button-green-border-color)]\" : \"\"}`}\n            divStyle={{ WebkitAppRegion: \"no-drag\" } as any}\n        >\n            <i className=\"fa fa-download\" />\n            {updateStatusMessage}\n        </Tooltip>\n    );\n};\nUpdateStatusBannerComponent.displayName = \"UpdateStatusBannerComponent\";\n\nexport const UpdateStatusBanner = memo(UpdateStatusBannerComponent);\n"
  },
  {
    "path": "frontend/app/tab/vtab.test.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { renderToStaticMarkup } from \"react-dom/server\";\nimport { afterAll, beforeAll, describe, expect, it } from \"vitest\";\nimport { VTab, VTabItem } from \"./vtab\";\n\nconst OriginalCss = globalThis.CSS;\nconst HexColorRegex = /^#([\\da-f]{3}|[\\da-f]{4}|[\\da-f]{6}|[\\da-f]{8})$/i;\n\nfunction renderVTab(tab: VTabItem): string {\n    return renderToStaticMarkup(\n        <VTab\n            tab={tab}\n            active={false}\n            isDragging={false}\n            isReordering={false}\n            onSelect={() => null}\n            onDragStart={() => null}\n            onDragOver={() => null}\n            onDrop={() => null}\n            onDragEnd={() => null}\n        />\n    );\n}\n\ndescribe(\"VTab badges\", () => {\n    beforeAll(() => {\n        globalThis.CSS = {\n            supports: (_property: string, value: string) => HexColorRegex.test(value),\n        } as typeof CSS;\n    });\n\n    afterAll(() => {\n        globalThis.CSS = OriginalCss;\n    });\n\n    it(\"renders shared badges and a validated flag badge\", () => {\n        const markup = renderVTab({\n            id: \"tab-1\",\n            name: \"Build Logs\",\n            badges: [{ badgeid: \"badge-1\", icon: \"bell\", color: \"#f59e0b\", priority: 2 }],\n            flagColor: \"#429DFF\",\n        });\n\n        expect(markup).toContain(\"#429DFF\");\n        expect(markup).toContain(\"#f59e0b\");\n        expect(markup).toContain(\"rounded-full\");\n    });\n\n    it(\"ignores invalid flag colors\", () => {\n        const markup = renderVTab({\n            id: \"tab-2\",\n            name: \"Deploy\",\n            badges: [{ badgeid: \"badge-2\", icon: \"bell\", color: \"#4ade80\", priority: 2 }],\n            flagColor: \"definitely-not-a-color\",\n        });\n\n        expect(markup).not.toContain(\"definitely-not-a-color\");\n        expect(markup).not.toContain(\"fa-flag\");\n        expect(markup).toContain(\"#4ade80\");\n    });\n});\n"
  },
  {
    "path": "frontend/app/tab/vtab.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { validateCssColor } from \"@/util/color-validator\";\nimport { cn } from \"@/util/util\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { TabBadges } from \"./tabbadges\";\n\nconst RenameFocusDelayMs = 50;\n\nexport interface VTabItem {\n    id: string;\n    name: string;\n    badge?: Badge | null;\n    badges?: Badge[] | null;\n    flagColor?: string | null;\n}\n\ninterface VTabProps {\n    tab: VTabItem;\n    active: boolean;\n    showDivider?: boolean;\n    isDragging: boolean;\n    isReordering: boolean;\n    onSelect: () => void;\n    onClose?: () => void;\n    onRename?: (newName: string) => void;\n    onContextMenu?: (event: React.MouseEvent<HTMLDivElement>) => void;\n    onDragStart: (event: React.DragEvent<HTMLDivElement>) => void;\n    onDragOver: (event: React.DragEvent<HTMLDivElement>) => void;\n    onDrop: (event: React.DragEvent<HTMLDivElement>) => void;\n    onDragEnd: () => void;\n    onHoverChanged?: (isHovered: boolean) => void;\n    renameRef?: React.RefObject<(() => void) | null>;\n}\n\nexport function VTab({\n    tab,\n    active,\n    showDivider = true,\n    isDragging,\n    isReordering,\n    onSelect,\n    onClose,\n    onRename,\n    onContextMenu,\n    onDragStart,\n    onDragOver,\n    onDrop,\n    onDragEnd,\n    onHoverChanged,\n    renameRef,\n}: VTabProps) {\n    const [originalName, setOriginalName] = useState(tab.name);\n    const [isEditable, setIsEditable] = useState(false);\n    const editableRef = useRef<HTMLDivElement>(null);\n    const editableTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n    const badges = tab.badges ?? (tab.badge ? [tab.badge] : null);\n\n    const rawFlagColor = tab.flagColor;\n    let flagColor: string | null = null;\n    if (rawFlagColor) {\n        try {\n            validateCssColor(rawFlagColor);\n            flagColor = rawFlagColor;\n        } catch {\n            flagColor = null;\n        }\n    }\n\n    useEffect(() => {\n        setOriginalName(tab.name);\n    }, [tab.name]);\n\n    useEffect(() => {\n        return () => {\n            if (editableTimeoutRef.current) {\n                clearTimeout(editableTimeoutRef.current);\n            }\n        };\n    }, []);\n\n    const selectEditableText = useCallback(() => {\n        if (!editableRef.current) {\n            return;\n        }\n        editableRef.current.focus();\n        const range = document.createRange();\n        const selection = window.getSelection();\n        if (!selection) {\n            return;\n        }\n        range.selectNodeContents(editableRef.current);\n        selection.removeAllRanges();\n        selection.addRange(range);\n    }, []);\n\n    const startRename = useCallback(() => {\n        if (onRename == null || isReordering) {\n            return;\n        }\n        if (editableTimeoutRef.current) {\n            clearTimeout(editableTimeoutRef.current);\n        }\n        setIsEditable(true);\n        editableTimeoutRef.current = setTimeout(() => {\n            selectEditableText();\n        }, RenameFocusDelayMs);\n    }, [isReordering, onRename, selectEditableText]);\n\n    if (renameRef != null) {\n        renameRef.current = startRename;\n    }\n\n    const handleBlur = () => {\n        if (!editableRef.current) {\n            return;\n        }\n        const newText = editableRef.current.textContent?.trim() || originalName;\n        editableRef.current.textContent = newText;\n        setIsEditable(false);\n        if (newText !== originalName) {\n            onRename?.(newText);\n        }\n    };\n\n    const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => {\n        if (!editableRef.current) {\n            return;\n        }\n        if (event.key === \"Enter\") {\n            event.preventDefault();\n            event.stopPropagation();\n            editableRef.current.blur();\n            return;\n        }\n        if (event.key !== \"Escape\") {\n            return;\n        }\n        editableRef.current.textContent = originalName;\n        editableRef.current.blur();\n        event.preventDefault();\n        event.stopPropagation();\n    };\n\n    return (\n        <div\n            draggable\n            data-tabid={tab.id}\n            onClick={onSelect}\n            onDoubleClick={(event) => {\n                event.stopPropagation();\n                startRename();\n            }}\n            onContextMenu={onContextMenu}\n            onDragStart={onDragStart}\n            onDragOver={onDragOver}\n            onDrop={onDrop}\n            onDragEnd={onDragEnd}\n            onMouseEnter={() => onHoverChanged?.(true)}\n            onMouseLeave={() => onHoverChanged?.(false)}\n            className={cn(\n                \"group relative flex h-9 w-full shrink-0 cursor-pointer items-center pl-3 text-xs transition-colors select-none\",\n                \"whitespace-nowrap\",\n                active ? \"text-primary\" : isReordering ? \"text-secondary\" : \"text-secondary hover:text-primary\",\n                isDragging && \"opacity-50\"\n            )}\n        >\n            {active && (\n                <div className=\"pointer-events-none absolute inset-x-1 inset-y-[4px] rounded-sm bg-foreground/10\" />\n            )}\n            {!active && !isReordering && (\n                <div className=\"pointer-events-none absolute inset-x-1 inset-y-[4px] rounded-sm bg-transparent transition-colors group-hover:bg-foreground/10\" />\n            )}\n            <div\n                className={cn(\n                    \"pointer-events-none absolute bottom-0 left-[5%] right-[5%] h-px bg-border/70\",\n                    !showDivider && \"opacity-0\"\n                )}\n            />\n            <TabBadges\n                badges={badges}\n                flagColor={flagColor}\n                className=\"mr-1 min-w-[16px] shrink-0 static top-auto left-auto z-auto h-[16px] w-auto translate-y-0 justify-start px-[2px] py-[1px] [&_i]:text-[10px]\"\n            />\n            <div\n                ref={editableRef}\n                className={cn(\n                    \"min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-[padding-right] pr-3\",\n                    onClose && !isReordering && \"group-hover:pr-6\",\n                    isEditable && \"rounded-[2px] bg-white/15 outline-none\"\n                )}\n                contentEditable={isEditable}\n                role=\"textbox\"\n                aria-label=\"Tab name\"\n                aria-readonly={!isEditable}\n                onBlur={handleBlur}\n                onKeyDown={handleKeyDown}\n                suppressContentEditableWarning={true}\n            >\n                {tab.name}\n            </div>\n            {onClose && (\n                <button\n                    type=\"button\"\n                    className={cn(\n                        \"absolute top-1/2 right-0 shrink-0 -translate-y-1/2 cursor-pointer py-1 pl-1 pr-3 text-secondary transition\",\n                        isReordering ? \"opacity-0\" : \"opacity-0 group-hover:opacity-100 hover:text-primary\"\n                    )}\n                    onClick={(event) => {\n                        event.stopPropagation();\n                        onClose();\n                    }}\n                    aria-label=\"Close tab\"\n                >\n                    <i className=\"fa fa-solid fa-xmark\" />\n                </button>\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/app/tab/vtabbar.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Tooltip } from \"@/app/element/tooltip\";\nimport { getTabBadgeAtom } from \"@/app/store/badge\";\nimport { makeORef } from \"@/app/store/wos\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport { WorkspaceLayoutModel } from \"@/app/workspace/workspace-layout-model\";\nimport { validateCssColor } from \"@/util/color-validator\";\nimport { cn, fireAndForget } from \"@/util/util\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useCallback, useEffect, useRef, useState } from \"react\";\nimport { buildTabBarContextMenu, buildTabContextMenu } from \"./tabcontextmenu\";\nimport { UpdateStatusBanner } from \"./updatebanner\";\nimport { VTab, VTabItem } from \"./vtab\";\nimport { VTabBarEnv } from \"./vtabbarenv\";\nimport { WorkspaceSwitcher } from \"./workspaceswitcher\";\nexport type { VTabItem } from \"./vtab\";\n\nconst VTabBarAIButton = memo(() => {\n    const env = useWaveEnv<VTabBarEnv>();\n    const aiPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom);\n    const hideAiButton = useAtomValue(env.getSettingsKeyAtom(\"app:hideaibutton\"));\n\n    const onClick = () => {\n        const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible();\n        WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible);\n    };\n\n    if (hideAiButton) {\n        return null;\n    }\n\n    return (\n        <Tooltip\n            content=\"Toggle Wave AI Panel\"\n            placement=\"bottom\"\n            hideOnClick\n            divClassName={`flex h-[22px] px-3.5 justify-end mb-1 items-center rounded-md mr-1 box-border cursor-pointer bg-hover hover:bg-hoverbg transition-colors text-[12px] ${aiPanelOpen ? \"text-accent\" : \"text-secondary\"}`}\n            divStyle={{ WebkitAppRegion: \"no-drag\" } as React.CSSProperties}\n            divOnClick={onClick}\n        >\n            <i className=\"fa fa-sparkles\" />\n        </Tooltip>\n    );\n});\nVTabBarAIButton.displayName = \"VTabBarAIButton\";\n\nconst MacOSHeader = memo(() => {\n    const env = useWaveEnv<VTabBarEnv>();\n    const isFullScreen = useAtomValue(env.atoms.isFullScreen);\n    return (\n        <>\n            {!isFullScreen && (\n                <div\n                    className=\"w-full shrink-0\"\n                    style={\n                        {\n                            height: \"calc(25px * var(--zoomfactor-inv))\",\n                            WebkitAppRegion: \"drag\",\n                        } as React.CSSProperties\n                    }\n                />\n            )}\n            <div\n                className=\"flex shrink-0 flex-row flex-wrap items-end px-1 pb-1 pl-2\"\n                style={{ WebkitAppRegion: \"no-drag\" } as React.CSSProperties}\n            >\n                <VTabBarAIButton />\n                <Tooltip content=\"Workspace Switcher\" placement=\"bottom\" hideOnClick divClassName=\"flex items-center\">\n                    <WorkspaceSwitcher />\n                </Tooltip>\n                <UpdateStatusBanner />\n            </div>\n        </>\n    );\n});\nMacOSHeader.displayName = \"MacOSHeader\";\n\ninterface VTabBarProps {\n    workspace: Workspace;\n    className?: string;\n}\n\ninterface VTabWrapperProps {\n    tabId: string;\n    active: boolean;\n    showDivider: boolean;\n    isDragging: boolean;\n    isReordering: boolean;\n    hoverResetVersion: number;\n    index: number;\n    onSelect: () => void;\n    onClose: () => void;\n    onRename: (newName: string) => void;\n    onDragStart: (event: React.DragEvent<HTMLDivElement>) => void;\n    onDragOver: (event: React.DragEvent<HTMLDivElement>) => void;\n    onDrop: (event: React.DragEvent<HTMLDivElement>) => void;\n    onDragEnd: () => void;\n    onHoverChanged: (isHovered: boolean) => void;\n}\n\nfunction VTabWrapper({\n    tabId,\n    active,\n    showDivider,\n    isDragging,\n    isReordering,\n    hoverResetVersion,\n    onSelect,\n    onClose,\n    onRename,\n    onDragStart,\n    onDragOver,\n    onDrop,\n    onDragEnd,\n    onHoverChanged,\n}: VTabWrapperProps) {\n    const env = useWaveEnv<VTabBarEnv>();\n    const [tabData] = env.wos.useWaveObjectValue<Tab>(makeORef(\"tab\", tabId));\n    const badges = useAtomValue(getTabBadgeAtom(tabId, env));\n    const renameRef = useRef<(() => void) | null>(null);\n\n    const rawFlagColor = tabData?.meta?.[\"tab:flagcolor\"];\n    let flagColor: string | null = null;\n    if (rawFlagColor) {\n        try {\n            validateCssColor(rawFlagColor);\n            flagColor = rawFlagColor;\n        } catch {\n            flagColor = null;\n        }\n    }\n\n    const tab: VTabItem = {\n        id: tabId,\n        name: tabData?.name ?? \"\",\n        badges,\n        flagColor,\n    };\n\n    const handleContextMenu = useCallback(\n        (e: React.MouseEvent<HTMLDivElement>) => {\n            e.preventDefault();\n            e.stopPropagation();\n            const menu = buildTabContextMenu(tabId, renameRef, () => onClose(), env);\n            env.showContextMenu(menu, e);\n        },\n        [tabId, onClose, env]\n    );\n\n    return (\n        <VTab\n            key={`${tabId}:${hoverResetVersion}`}\n            tab={tab}\n            active={active}\n            showDivider={showDivider}\n            isDragging={isDragging}\n            isReordering={isReordering}\n            onSelect={onSelect}\n            onClose={onClose}\n            onRename={onRename}\n            onContextMenu={handleContextMenu}\n            onDragStart={onDragStart}\n            onDragOver={onDragOver}\n            onDrop={onDrop}\n            onDragEnd={onDragEnd}\n            onHoverChanged={onHoverChanged}\n            renameRef={renameRef}\n        />\n    );\n}\n\nexport function VTabBar({ workspace, className }: VTabBarProps) {\n    const env = useWaveEnv<VTabBarEnv>();\n    const activeTabId = useAtomValue(env.atoms.staticTabId);\n    const reinitVersion = useAtomValue(env.atoms.reinitVersion);\n    const documentHasFocus = useAtomValue(env.atoms.documentHasFocus);\n    const tabIds = workspace?.tabids ?? [];\n\n    const [orderedTabIds, setOrderedTabIds] = useState<string[]>(tabIds);\n    const [dragTabId, setDragTabId] = useState<string | null>(null);\n    const [dropIndex, setDropIndex] = useState<number | null>(null);\n    const [dropLineTop, setDropLineTop] = useState<number | null>(null);\n    const [hoverResetVersion, setHoverResetVersion] = useState(0);\n    const [hoveredTabId, setHoveredTabId] = useState<string | null>(null);\n    const [isNewTabHovered, setIsNewTabHovered] = useState(false);\n    const dragSourceRef = useRef<string | null>(null);\n    const didResetHoverForDragRef = useRef(false);\n    const scrollContainerRef = useRef<HTMLDivElement>(null);\n    const scrollAnimFrameRef = useRef<number | null>(null);\n    const scrollDirectionRef = useRef<number>(0);\n    const scrollSpeedRef = useRef<number>(0);\n\n    useEffect(() => {\n        setOrderedTabIds(tabIds);\n    }, [workspace?.tabids]);\n\n    useEffect(() => {\n        if (reinitVersion > 0) {\n            setOrderedTabIds(workspace?.tabids ?? []);\n        }\n    }, [reinitVersion]);\n\n    useEffect(() => {\n        if (activeTabId == null || scrollContainerRef.current == null) {\n            return;\n        }\n        const el = scrollContainerRef.current.querySelector(`[data-tabid=\"${activeTabId}\"]`);\n        el?.scrollIntoView({ block: \"nearest\" });\n    }, [activeTabId]);\n\n    useEffect(() => {\n        if (!documentHasFocus || activeTabId == null || scrollContainerRef.current == null) {\n            return;\n        }\n        const el = scrollContainerRef.current.querySelector(`[data-tabid=\"${activeTabId}\"]`);\n        el?.scrollIntoView({ block: \"nearest\" });\n    }, [documentHasFocus]);\n\n    const stopScrollLoop = useCallback(() => {\n        if (scrollAnimFrameRef.current != null) {\n            cancelAnimationFrame(scrollAnimFrameRef.current);\n            scrollAnimFrameRef.current = null;\n        }\n        scrollDirectionRef.current = 0;\n    }, []);\n\n    const startScrollLoop = useCallback(() => {\n        if (scrollAnimFrameRef.current != null) {\n            return;\n        }\n        const loop = () => {\n            const container = scrollContainerRef.current;\n            if (container == null || scrollDirectionRef.current === 0) {\n                scrollAnimFrameRef.current = null;\n                return;\n            }\n            container.scrollTop += scrollDirectionRef.current * scrollSpeedRef.current;\n            scrollAnimFrameRef.current = requestAnimationFrame(loop);\n        };\n        scrollAnimFrameRef.current = requestAnimationFrame(loop);\n    }, []);\n\n    const updateScrollFromDragY = useCallback(\n        (clientY: number) => {\n            const container = scrollContainerRef.current;\n            if (container == null) {\n                return;\n            }\n            const EdgeZone = 60;\n            const MaxScrollSpeed = 12;\n            const rect = container.getBoundingClientRect();\n            const relY = clientY - rect.top;\n            const height = rect.height;\n            if (relY < EdgeZone) {\n                scrollDirectionRef.current = -1;\n                scrollSpeedRef.current = MaxScrollSpeed * (1 - relY / EdgeZone);\n                startScrollLoop();\n            } else if (relY > height - EdgeZone) {\n                scrollDirectionRef.current = 1;\n                scrollSpeedRef.current = MaxScrollSpeed * (1 - (height - relY) / EdgeZone);\n                startScrollLoop();\n            } else {\n                scrollDirectionRef.current = 0;\n                stopScrollLoop();\n            }\n        },\n        [startScrollLoop, stopScrollLoop]\n    );\n\n    const clearDragState = () => {\n        stopScrollLoop();\n        if (dragSourceRef.current != null && !didResetHoverForDragRef.current) {\n            didResetHoverForDragRef.current = true;\n            setHoverResetVersion((version) => version + 1);\n        }\n        dragSourceRef.current = null;\n        setDragTabId(null);\n        setDropIndex(null);\n        setDropLineTop(null);\n    };\n\n    const reorder = (targetIndex: number) => {\n        const sourceTabId = dragSourceRef.current;\n        if (sourceTabId == null) {\n            return;\n        }\n        const sourceIndex = orderedTabIds.findIndex((id) => id === sourceTabId);\n        if (sourceIndex === -1) {\n            return;\n        }\n        const boundedTargetIndex = Math.max(0, Math.min(targetIndex, orderedTabIds.length));\n        const adjustedTargetIndex = sourceIndex < boundedTargetIndex ? boundedTargetIndex - 1 : boundedTargetIndex;\n        if (sourceIndex === adjustedTargetIndex) {\n            return;\n        }\n        const nextTabIds = [...orderedTabIds];\n        const [movedId] = nextTabIds.splice(sourceIndex, 1);\n        nextTabIds.splice(adjustedTargetIndex, 0, movedId);\n        setOrderedTabIds(nextTabIds);\n        fireAndForget(() => env.rpc.UpdateWorkspaceTabIdsCommand(TabRpcClient, workspace.oid, nextTabIds));\n    };\n\n    const handleTabBarContextMenu = useCallback(\n        (e: React.MouseEvent<HTMLDivElement>) => {\n            e.preventDefault();\n            const menu = buildTabBarContextMenu(env);\n            env.showContextMenu(menu, e);\n        },\n        [env]\n    );\n\n    return (\n        <div\n            className={cn(\"flex h-full flex-col overflow-hidden\", className)}\n            style={{ backdropFilter: \"blur(20px)\", background: \"rgba(0, 0, 0, 0.35)\" }}\n            onContextMenu={handleTabBarContextMenu}\n        >\n            {env.isMacOS() && <MacOSHeader />}\n            <div\n                ref={scrollContainerRef}\n                className=\"relative flex min-h-0 flex-col overflow-y-auto\"\n                onDragOver={(event) => {\n                    event.preventDefault();\n                    updateScrollFromDragY(event.clientY);\n                    if (event.target === event.currentTarget) {\n                        setDropIndex(orderedTabIds.length);\n                        setDropLineTop(event.currentTarget.scrollHeight);\n                    }\n                }}\n                onDrop={(event) => {\n                    event.preventDefault();\n                    if (dropIndex != null) {\n                        reorder(dropIndex);\n                    }\n                    clearDragState();\n                }}\n            >\n                {orderedTabIds.map((tabId, index) => {\n                    const isActive = tabId === activeTabId;\n                    const isHovered = tabId === hoveredTabId;\n                    const isLast = index === orderedTabIds.length - 1;\n                    const nextTabId = orderedTabIds[index + 1];\n                    const isNextActive = nextTabId === activeTabId;\n                    const isNextHovered = nextTabId === hoveredTabId;\n                    return (\n                        <VTabWrapper\n                            key={`${tabId}:${hoverResetVersion}`}\n                            tabId={tabId}\n                            active={isActive}\n                            showDivider={\n                                !isActive &&\n                                !isNextActive &&\n                                !isHovered &&\n                                !isNextHovered &&\n                                !(isLast && isNewTabHovered)\n                            }\n                            isDragging={dragTabId === tabId}\n                            isReordering={dragTabId != null}\n                            hoverResetVersion={hoverResetVersion}\n                            index={index}\n                            onSelect={() => env.electron.setActiveTab(tabId)}\n                            onClose={() => fireAndForget(() => env.electron.closeTab(workspace.oid, tabId, false))}\n                            onRename={(newName) =>\n                                fireAndForget(() => env.rpc.UpdateTabNameCommand(TabRpcClient, tabId, newName))\n                            }\n                            onDragStart={(event) => {\n                                didResetHoverForDragRef.current = false;\n                                dragSourceRef.current = tabId;\n                                event.dataTransfer.effectAllowed = \"move\";\n                                event.dataTransfer.setData(\"text/plain\", tabId);\n                                setDragTabId(tabId);\n                                setDropIndex(index);\n                                setDropLineTop(event.currentTarget.offsetTop);\n                            }}\n                            onDragOver={(event) => {\n                                event.preventDefault();\n                                const rect = event.currentTarget.getBoundingClientRect();\n                                const relativeY = event.clientY - rect.top;\n                                const midpoint = event.currentTarget.offsetHeight / 2;\n                                const insertBefore = relativeY < midpoint;\n                                setDropIndex(insertBefore ? index : index + 1);\n                                setDropLineTop(\n                                    insertBefore\n                                        ? event.currentTarget.offsetTop\n                                        : event.currentTarget.offsetTop + event.currentTarget.offsetHeight\n                                );\n                            }}\n                            onDrop={(event) => {\n                                event.preventDefault();\n                                if (dropIndex != null) {\n                                    reorder(dropIndex);\n                                }\n                                clearDragState();\n                            }}\n                            onDragEnd={clearDragState}\n                            onHoverChanged={(isHovered) => setHoveredTabId(isHovered ? tabId : null)}\n                        />\n                    );\n                })}\n                {dragTabId != null && dropIndex != null && dropLineTop != null && (\n                    <div\n                        className=\"pointer-events-none absolute left-0 right-0 border-t-2 border-accent/80\"\n                        style={{ top: dropLineTop, transform: \"translateY(-1px)\" }}\n                    />\n                )}\n            </div>\n            <button\n                type=\"button\"\n                className=\"group relative flex h-9 w-full shrink-0 cursor-pointer items-center gap-1.5 pl-3 pr-3 text-xs text-secondary/60 transition-colors hover:text-primary select-none whitespace-nowrap\"\n                onClick={() => env.electron.createTab()}\n                onMouseEnter={() => setIsNewTabHovered(true)}\n                onMouseLeave={() => setIsNewTabHovered(false)}\n                aria-label=\"New Tab\"\n            >\n                <div className=\"pointer-events-none absolute inset-x-1 inset-y-[4px] rounded-sm bg-transparent transition-colors group-hover:bg-hover\" />\n                <i className=\"fa fa-solid fa-plus\" style={{ fontSize: \"10px\" }} />\n                <span>New Tab</span>\n            </button>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/app/tab/vtabbarenv.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from \"@/app/waveenv/waveenv\";\n\nexport type VTabBarEnv = WaveEnvSubset<{\n    electron: {\n        createTab: WaveEnv[\"electron\"][\"createTab\"];\n        closeTab: WaveEnv[\"electron\"][\"closeTab\"];\n        setActiveTab: WaveEnv[\"electron\"][\"setActiveTab\"];\n        deleteWorkspace: WaveEnv[\"electron\"][\"deleteWorkspace\"];\n        createWorkspace: WaveEnv[\"electron\"][\"createWorkspace\"];\n        switchWorkspace: WaveEnv[\"electron\"][\"switchWorkspace\"];\n        installAppUpdate: WaveEnv[\"electron\"][\"installAppUpdate\"];\n    };\n    rpc: {\n        UpdateWorkspaceTabIdsCommand: WaveEnv[\"rpc\"][\"UpdateWorkspaceTabIdsCommand\"];\n        UpdateTabNameCommand: WaveEnv[\"rpc\"][\"UpdateTabNameCommand\"];\n        ActivityCommand: WaveEnv[\"rpc\"][\"ActivityCommand\"];\n        SetConfigCommand: WaveEnv[\"rpc\"][\"SetConfigCommand\"];\n        SetMetaCommand: WaveEnv[\"rpc\"][\"SetMetaCommand\"];\n    };\n    atoms: {\n        staticTabId: WaveEnv[\"atoms\"][\"staticTabId\"];\n        fullConfigAtom: WaveEnv[\"atoms\"][\"fullConfigAtom\"];\n        reinitVersion: WaveEnv[\"atoms\"][\"reinitVersion\"];\n        documentHasFocus: WaveEnv[\"atoms\"][\"documentHasFocus\"];\n        workspace: WaveEnv[\"atoms\"][\"workspace\"];\n        updaterStatusAtom: WaveEnv[\"atoms\"][\"updaterStatusAtom\"];\n        isFullScreen: WaveEnv[\"atoms\"][\"isFullScreen\"];\n    };\n    services: {\n        workspace: WaveEnv[\"services\"][\"workspace\"];\n    };\n    wos: WaveEnv[\"wos\"];\n    showContextMenu: WaveEnv[\"showContextMenu\"];\n    getSettingsKeyAtom: SettingsKeyAtomFnType<\"tab:confirmclose\" | \"app:tabbar\" | \"app:hideaibutton\">;\n    mockSetWaveObj: WaveEnv[\"mockSetWaveObj\"];\n    isWindows: WaveEnv[\"isWindows\"];\n    isMacOS: WaveEnv[\"isMacOS\"];\n}>;\n"
  },
  {
    "path": "frontend/app/tab/workspaceeditor.scss",
    "content": ".workspace-editor {\n    width: 100%;\n    .input {\n        margin: 5px 0 10px;\n    }\n\n    .color-selector {\n        display: grid;\n        grid-template-columns: repeat(auto-fit, minmax(15px, 15px)); // Ensures each color circle has a fixed 14px size\n        grid-gap: 18.5px; // Space between items\n        justify-content: center;\n        align-items: center;\n        margin-top: 5px;\n        padding-bottom: 15px;\n        border-bottom: 1px solid var(--modal-border-color);\n\n        .color-circle {\n            width: 15px;\n            height: 15px;\n            border-radius: 50%;\n            cursor: pointer;\n            position: relative;\n\n            // Border offset outward\n            &:before {\n                content: \"\";\n                position: absolute;\n                top: -3px;\n                left: -3px;\n                right: -3px;\n                bottom: -3px;\n                border-radius: 50%;\n                border: 1px solid transparent;\n            }\n\n            &.selected:before {\n                border-color: var(--main-text-color); // Highlight for the selected circle\n            }\n        }\n    }\n\n    .icon-selector {\n        display: grid;\n        grid-template-columns: repeat(auto-fit, minmax(16px, 16px)); // Ensures each color circle has a fixed 14px size\n        grid-column-gap: 17.5px; // Space between items\n        grid-row-gap: 13px; // Space between items\n        justify-content: center;\n        align-items: center;\n        margin-top: 15px;\n\n        .icon-item {\n            font-size: 15px;\n            color: oklch(from var(--modal-bg-color) calc(l * 1.5) c h);\n            cursor: pointer;\n            transition: color 0.3s ease;\n\n            &.selected,\n            &:hover {\n                color: var(--main-text-color);\n            }\n        }\n    }\n\n    .delete-ws-btn-wrapper {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        margin-top: 10px;\n    }\n}\n"
  },
  {
    "path": "frontend/app/tab/workspaceeditor.tsx",
    "content": "import { fireAndForget, makeIconClass } from \"@/util/util\";\nimport clsx from \"clsx\";\nimport { memo, useEffect, useRef, useState } from \"react\";\nimport { Button } from \"../element/button\";\nimport { Input } from \"../element/input\";\nimport { WorkspaceService } from \"../store/services\";\nimport \"./workspaceeditor.scss\";\n\ninterface ColorSelectorProps {\n    colors: string[];\n    selectedColor?: string;\n    onSelect: (color: string) => void;\n    className?: string;\n}\n\nconst ColorSelector = memo(({ colors, selectedColor, onSelect, className }: ColorSelectorProps) => {\n    const handleColorClick = (color: string) => {\n        onSelect(color);\n    };\n\n    return (\n        <div className={clsx(\"color-selector\", className)}>\n            {colors.map((color) => (\n                <div\n                    key={color}\n                    className={clsx(\"color-circle\", { selected: selectedColor === color })}\n                    style={{ backgroundColor: color }}\n                    onClick={() => handleColorClick(color)}\n                />\n            ))}\n        </div>\n    );\n});\n\ninterface IconSelectorProps {\n    icons: string[];\n    selectedIcon?: string;\n    onSelect: (icon: string) => void;\n    className?: string;\n}\n\nconst IconSelector = memo(({ icons, selectedIcon, onSelect, className }: IconSelectorProps) => {\n    const handleIconClick = (icon: string) => {\n        onSelect(icon);\n    };\n\n    return (\n        <div className={clsx(\"icon-selector\", className)}>\n            {icons.map((icon) => {\n                const iconClass = makeIconClass(icon, true);\n                return (\n                    <i\n                        key={icon}\n                        className={clsx(iconClass, \"icon-item\", { selected: selectedIcon === icon })}\n                        onClick={() => handleIconClick(icon)}\n                    />\n                );\n            })}\n        </div>\n    );\n});\n\ninterface WorkspaceEditorProps {\n    title: string;\n    icon: string;\n    color: string;\n    focusInput: boolean;\n    onTitleChange: (newTitle: string) => void;\n    onColorChange: (newColor: string) => void;\n    onIconChange: (newIcon: string) => void;\n    onDeleteWorkspace: () => void;\n}\nconst WorkspaceEditorComponent = ({\n    title,\n    icon,\n    color,\n    focusInput,\n    onTitleChange,\n    onColorChange,\n    onIconChange,\n    onDeleteWorkspace,\n}: WorkspaceEditorProps) => {\n    const inputRef = useRef<HTMLInputElement>(null);\n\n    const [colors, setColors] = useState<string[]>([]);\n    const [icons, setIcons] = useState<string[]>([]);\n\n    useEffect(() => {\n        fireAndForget(async () => {\n            const colors = await WorkspaceService.GetColors();\n            const icons = await WorkspaceService.GetIcons();\n            setColors(colors);\n            setIcons(icons);\n        });\n    }, []);\n\n    useEffect(() => {\n        if (focusInput && inputRef.current) {\n            inputRef.current.focus();\n            inputRef.current.select();\n        }\n    }, [focusInput]);\n\n    return (\n        <div className=\"workspace-editor\">\n            <Input\n                ref={inputRef}\n                className={clsx(\"py-[3px]\", { error: title === \"\" })}\n                onChange={onTitleChange}\n                value={title}\n                autoFocus\n                autoSelect\n            />\n            <ColorSelector selectedColor={color} colors={colors} onSelect={onColorChange} />\n            <IconSelector selectedIcon={icon} icons={icons} onSelect={onIconChange} />\n            <div className=\"delete-ws-btn-wrapper\">\n                <Button className=\"ghost red text-[12px] bold\" onClick={onDeleteWorkspace}>\n                    Delete workspace\n                </Button>\n            </div>\n        </div>\n    );\n};\n\nexport const WorkspaceEditor = memo(WorkspaceEditorComponent) as typeof WorkspaceEditorComponent;\n"
  },
  {
    "path": "frontend/app/tab/workspaceswitcher.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.workspace-switcher-button {\n    display: flex;\n    height: 22px;\n    padding: 0px 12px;\n    justify-content: flex-end;\n    align-items: center;\n    gap: 12px;\n    border-radius: 6px;\n    margin-right: 6px;\n    margin-bottom: 4px;\n    box-sizing: border-box;\n    background-color: rgb(from var(--main-text-color) r g b / 0.1) !important;\n\n    &:hover {\n        background-color: rgb(from var(--main-text-color) r g b / 0.14) !important;\n    }\n\n    .workspace-icon {\n        width: 15px;\n        height: 15px;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n    }\n}\n\n.workspace-switcher-content {\n    min-height: auto;\n    display: flex;\n    width: 256px;\n    padding: 0;\n    flex-direction: column;\n    align-items: center;\n    border-radius: 8px;\n    box-shadow: 0px 8px 24px 0px var(--modal-shadow-color);\n\n    .icon-left,\n    .icon-right {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        font-size: 20px;\n    }\n\n    .divider {\n        width: 1px;\n        height: 20px;\n        background: rgba(255, 255, 255, 0.08);\n    }\n\n    .scrollable {\n        max-height: 400px;\n        width: 100%;\n    }\n\n    .title {\n        font-size: 12px;\n        line-height: 19px;\n        font-weight: 600;\n        margin-bottom: 5px;\n        width: 100%;\n        padding: 6px 8px 0px;\n    }\n\n    .expandable-menu {\n        gap: 5px;\n    }\n\n    .expandable-menu-item {\n        margin: 3px 8px;\n    }\n\n    .expandable-menu-item-group {\n        margin: 0 8px;\n        border: 1px solid transparent;\n        border-radius: 4px;\n\n        --workspace-color: var(--main-bg-color);\n\n        &:last-child {\n            margin-bottom: 4px;\n            border-bottom-left-radius: 8px;\n            border-bottom-right-radius: 8px;\n        }\n\n        .expandable-menu-item {\n            margin: 0;\n        }\n\n        .menu-group-title-wrapper {\n            display: flex;\n            width: 100%;\n            padding: 5px 8px;\n            border-radius: 4px;\n            .icons {\n                display: flex;\n                flex-direction: row;\n                gap: 5px;\n            }\n\n            .wave-iconbutton.edit {\n                visibility: hidden;\n            }\n\n            .wave-iconbutton.window {\n                cursor: default;\n                opacity: 1 !important;\n            }\n        }\n\n        &:hover .wave-iconbutton.edit {\n            visibility: visible;\n        }\n\n        &.open {\n            background-color: var(--modal-bg-color);\n            border: 1px solid var(--modal-border-color);\n        }\n\n        &.is-current .menu-group-title-wrapper {\n            background-color: rgb(from var(--workspace-color) r g b / 0.1);\n        }\n    }\n\n    .expandable-menu-item,\n    .expandable-menu-item-group-title {\n        font-size: 12px;\n        line-height: 19px;\n        padding: 5px 8px;\n\n        .content {\n            width: 100%;\n        }\n\n        &:hover {\n            background-color: transparent;\n        }\n    }\n\n    .expandable-menu-item-group-title {\n        height: 29px;\n        padding: 0;\n    }\n\n    .actions {\n        width: 100%;\n        padding: 3px 0;\n        border-top: 1px solid var(--modal-border-color);\n    }\n}\n"
  },
  {
    "path": "frontend/app/tab/workspaceswitcher.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { useWaveEnv, WaveEnv, WaveEnvSubset } from \"@/app/waveenv/waveenv\";\nimport {\n    ExpandableMenu,\n    ExpandableMenuItem,\n    ExpandableMenuItemGroup,\n    ExpandableMenuItemGroupTitle,\n    ExpandableMenuItemLeftElement,\n    ExpandableMenuItemRightElement,\n} from \"@/element/expandablemenu\";\nimport { Popover, PopoverButton, PopoverContent } from \"@/element/popover\";\nimport { fireAndForget, makeIconClass, useAtomValueSafe } from \"@/util/util\";\nimport clsx from \"clsx\";\nimport { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from \"jotai\";\nimport { splitAtom } from \"jotai/utils\";\nimport { OverlayScrollbarsComponent } from \"overlayscrollbars-react\";\nimport { CSSProperties, forwardRef, useCallback, useEffect } from \"react\";\nimport WorkspaceSVG from \"../asset/workspace.svg\";\nimport { IconButton } from \"../element/iconbutton\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { makeORef } from \"../store/wos\";\nimport { waveEventSubscribeSingle } from \"../store/wps\";\nimport { WorkspaceEditor } from \"./workspaceeditor\";\nimport \"./workspaceswitcher.scss\";\n\nexport type WorkspaceSwitcherEnv = WaveEnvSubset<{\n    electron: {\n        deleteWorkspace: WaveEnv[\"electron\"][\"deleteWorkspace\"];\n        createWorkspace: WaveEnv[\"electron\"][\"createWorkspace\"];\n        switchWorkspace: WaveEnv[\"electron\"][\"switchWorkspace\"];\n    };\n    atoms: {\n        workspace: WaveEnv[\"atoms\"][\"workspace\"];\n    };\n    services: {\n        workspace: WaveEnv[\"services\"][\"workspace\"];\n    };\n    wos: WaveEnv[\"wos\"];\n}>;\n\ntype WorkspaceListEntry = {\n    windowId: string;\n    workspace: Workspace;\n};\n\ntype WorkspaceList = WorkspaceListEntry[];\nconst workspaceMapAtom = atom<WorkspaceList>([]);\nconst workspaceSplitAtom = splitAtom(workspaceMapAtom);\nconst editingWorkspaceAtom = atom<string>();\nconst WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => {\n    const env = useWaveEnv<WorkspaceSwitcherEnv>();\n    const setWorkspaceList = useSetAtom(workspaceMapAtom);\n    const activeWorkspace = useAtomValueSafe(env.atoms.workspace);\n    const workspaceList = useAtomValue(workspaceSplitAtom);\n    const setEditingWorkspace = useSetAtom(editingWorkspaceAtom);\n\n    const updateWorkspaceList = useCallback(async () => {\n        const workspaceList = await env.services.workspace.ListWorkspaces();\n        if (!workspaceList) {\n            return;\n        }\n        const newList: WorkspaceList = [];\n        for (const entry of workspaceList) {\n            // This just ensures that the atom exists for easier setting of the object\n            globalStore.get(env.wos.getWaveObjectAtom(makeORef(\"workspace\", entry.workspaceid)));\n            newList.push({\n                windowId: entry.windowid,\n                workspace: await env.services.workspace.GetWorkspace(entry.workspaceid),\n            });\n        }\n        setWorkspaceList(newList);\n    }, []);\n\n    useEffect(\n        () =>\n            waveEventSubscribeSingle({\n                eventType: \"workspace:update\",\n                handler: () => fireAndForget(updateWorkspaceList),\n            }),\n        []\n    );\n\n    useEffect(() => {\n        fireAndForget(updateWorkspaceList);\n    }, []);\n\n    const onDeleteWorkspace = useCallback((workspaceId: string) => {\n        env.electron.deleteWorkspace(workspaceId);\n    }, []);\n\n    const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon);\n\n    const workspaceIcon = isActiveWorkspaceSaved ? (\n        <i className={makeIconClass(activeWorkspace.icon, false)} style={{ color: activeWorkspace.color }}></i>\n    ) : (\n        <WorkspaceSVG />\n    );\n\n    const saveWorkspace = () => {\n        fireAndForget(async () => {\n            await env.services.workspace.UpdateWorkspace(activeWorkspace.oid, \"\", \"\", \"\", true);\n            await updateWorkspaceList();\n            setEditingWorkspace(activeWorkspace.oid);\n        });\n    };\n\n    return (\n        <Popover\n            className=\"workspace-switcher-popover\"\n            placement=\"bottom-start\"\n            onDismiss={() => setEditingWorkspace(null)}\n            ref={ref}\n        >\n            <PopoverButton\n                className=\"workspace-switcher-button grey\"\n                as=\"div\"\n                onClick={() => {\n                    fireAndForget(updateWorkspaceList);\n                }}\n            >\n                <span className=\"workspace-icon\">{workspaceIcon}</span>\n            </PopoverButton>\n            <PopoverContent className=\"workspace-switcher-content\">\n                <div className=\"title\">{isActiveWorkspaceSaved ? \"Switch workspace\" : \"Open workspace\"}</div>\n                <OverlayScrollbarsComponent className={\"scrollable\"} options={{ scrollbars: { autoHide: \"leave\" } }}>\n                    <ExpandableMenu noIndent singleOpen>\n                        {workspaceList.map((entry, i) => (\n                            <WorkspaceSwitcherItem key={i} entryAtom={entry} onDeleteWorkspace={onDeleteWorkspace} />\n                        ))}\n                    </ExpandableMenu>\n                </OverlayScrollbarsComponent>\n\n                <div className=\"actions\">\n                    {isActiveWorkspaceSaved ? (\n                        <ExpandableMenuItem onClick={() => env.electron.createWorkspace()}>\n                            <ExpandableMenuItemLeftElement>\n                                <i className=\"fa-sharp fa-solid fa-plus\"></i>\n                            </ExpandableMenuItemLeftElement>\n                            <div className=\"content\">Create new workspace</div>\n                        </ExpandableMenuItem>\n                    ) : (\n                        <ExpandableMenuItem onClick={() => saveWorkspace()}>\n                            <ExpandableMenuItemLeftElement>\n                                <i className=\"fa-sharp fa-solid fa-floppy-disk\"></i>\n                            </ExpandableMenuItemLeftElement>\n                            <div className=\"content\">Save workspace</div>\n                        </ExpandableMenuItem>\n                    )}\n                </div>\n            </PopoverContent>\n        </Popover>\n    );\n});\n\nconst WorkspaceSwitcherItem = ({\n    entryAtom,\n    onDeleteWorkspace,\n}: {\n    entryAtom: PrimitiveAtom<WorkspaceListEntry>;\n    onDeleteWorkspace: (workspaceId: string) => void;\n}) => {\n    const env = useWaveEnv<WorkspaceSwitcherEnv>();\n    const activeWorkspace = useAtomValueSafe(env.atoms.workspace);\n    const [workspaceEntry, setWorkspaceEntry] = useAtom(entryAtom);\n    const [editingWorkspace, setEditingWorkspace] = useAtom(editingWorkspaceAtom);\n\n    const workspace = workspaceEntry.workspace;\n    const isCurrentWorkspace = activeWorkspace.oid === workspace.oid;\n\n    const setWorkspace = useCallback((newWorkspace: Workspace) => {\n        setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace });\n        if (newWorkspace.name != \"\") {\n            fireAndForget(() =>\n                env.services.workspace.UpdateWorkspace(\n                    workspace.oid,\n                    newWorkspace.name,\n                    newWorkspace.icon,\n                    newWorkspace.color,\n                    false\n                )\n            );\n        }\n    }, []);\n\n    const isActive = !!workspaceEntry.windowId;\n    const editIconDecl: IconButtonDecl = {\n        elemtype: \"iconbutton\",\n        className: \"edit\",\n        icon: \"pencil\",\n        title: \"Edit workspace\",\n        click: (e) => {\n            e.stopPropagation();\n            if (editingWorkspace === workspace.oid) {\n                setEditingWorkspace(null);\n            } else {\n                setEditingWorkspace(workspace.oid);\n            }\n        },\n    };\n    const windowIconDecl: IconButtonDecl = {\n        elemtype: \"iconbutton\",\n        className: \"window\",\n        noAction: true,\n        icon: isCurrentWorkspace ? \"check\" : \"window\",\n        title: isCurrentWorkspace ? \"This is your current workspace\" : \"This workspace is open\",\n    };\n\n    const isEditing = editingWorkspace === workspace.oid;\n\n    return (\n        <ExpandableMenuItemGroup\n            key={workspace.oid}\n            isOpen={isEditing}\n            className={clsx({ \"is-current\": isCurrentWorkspace })}\n        >\n            <ExpandableMenuItemGroupTitle\n                onClick={() => {\n                    env.electron.switchWorkspace(workspace.oid);\n                    // Create a fake escape key event to close the popover\n                    document.dispatchEvent(new KeyboardEvent(\"keydown\", { key: \"Escape\" }));\n                }}\n            >\n                <div\n                    className=\"menu-group-title-wrapper\"\n                    style={\n                        {\n                            \"--workspace-color\": workspace.color,\n                        } as CSSProperties\n                    }\n                >\n                    <ExpandableMenuItemLeftElement>\n                        <i\n                            className={clsx(\"left-icon\", makeIconClass(workspace.icon, true))}\n                            style={{ color: workspace.color }}\n                        />\n                    </ExpandableMenuItemLeftElement>\n                    <div className=\"label\">{workspace.name}</div>\n                    <ExpandableMenuItemRightElement>\n                        <div className=\"icons\">\n                            <IconButton decl={editIconDecl} />\n                            {isActive && <IconButton decl={windowIconDecl} />}\n                        </div>\n                    </ExpandableMenuItemRightElement>\n                </div>\n            </ExpandableMenuItemGroupTitle>\n            <ExpandableMenuItem>\n                <WorkspaceEditor\n                    title={workspace.name}\n                    icon={workspace.icon}\n                    color={workspace.color}\n                    focusInput={isEditing}\n                    onTitleChange={(title) => setWorkspace({ ...workspace, name: title })}\n                    onColorChange={(color) => setWorkspace({ ...workspace, color })}\n                    onIconChange={(icon) => setWorkspace({ ...workspace, icon })}\n                    onDeleteWorkspace={() => onDeleteWorkspace(workspace.oid)}\n                />\n            </ExpandableMenuItem>\n        </ExpandableMenuItemGroup>\n    );\n};\n\nexport { WorkspaceSwitcher };\n"
  },
  {
    "path": "frontend/app/theme.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n:root {\n    --main-text-color: #f7f7f7;\n    --title-font-size: 18px;\n    --window-opacity: 1;\n    --secondary-text-color: rgb(195, 200, 194);\n    --grey-text-color: #666;\n    --main-bg-color: rgb(34, 34, 34);\n    --border-color: rgba(255, 255, 255, 0.16);\n    --base-font: normal 14px / normal \"Inter\", sans-serif;\n    --fixed-font: normal 12px / normal \"Hack\", monospace;\n    --accent-color: rgb(88, 193, 66);\n    --panel-bg-color: rgba(31, 33, 31, 0.5);\n    --highlight-bg-color: rgba(255, 255, 255, 0.2);\n    --markdown-font-family:\n        -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif, \"Apple Color Emoji\",\n        \"Segoe UI Emoji\";\n    --markdown-font-size: 14px;\n    --markdown-fixed-font-size: 12px;\n    --error-color: rgb(229, 77, 46);\n    --warning-color: rgb(224, 185, 86);\n    --success-color: rgb(78, 154, 6);\n    --hover-bg-color: rgba(255, 255, 255, 0.1);\n    --block-bg-color: rgba(0, 0, 0, 0.5);\n    --block-bg-solid-color: rgb(0, 0, 0);\n    --block-border-radius: 8px;\n\n    --keybinding-color: #e0e0e0;\n    --keybinding-bg-color: #333;\n    --keybinding-border-color: #444;\n\n    /* scrollbar colors */\n    --scrollbar-background-color: transparent;\n    --scrollbar-thumb-color: rgba(255, 255, 255, 0.15);\n    --scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.5);\n    --scrollbar-thumb-active-color: rgba(255, 255, 255, 0.6);\n\n    --header-font: 700 11px / normal \"Inter\", sans-serif;\n    --header-icon-size: 14px;\n    --header-icon-width: 16px;\n    --header-height: 30px;\n\n    --tab-green: rgb(88, 193, 66);\n\n    /* z-index values */\n    --zindex-header-hover: 100;\n    --zindex-termstickers: 20;\n    --zindex-modal: 2;\n    --zindex-modal-wrapper: 500;\n    --zindex-modal-backdrop: 1;\n    --zindex-typeahead-modal: 100;\n    --zindex-typeahead-modal-backdrop: 90;\n    --zindex-elem-modal: 100;\n    --zindex-window-drag: 100;\n    --zindex-tab-name: 3;\n    --zindex-layout-display-container: 0;\n    --zindex-layout-last-magnified-node: 1;\n    --zindex-layout-last-ephemeral-node: 2;\n    --zindex-layout-resize-handle: 3;\n    --zindex-layout-placeholder-container: 4;\n    --zindex-layout-overlay-container: 5;\n    --zindex-layout-magnified-node-backdrop: 6;\n    --zindex-layout-magnified-node: 7;\n    --zindex-layout-ephemeral-node-backdrop: 8;\n    --zindex-layout-ephemeral-node: 9;\n    --zindex-block-mask-inner: 10;\n    --zindex-app-background: -1;\n    // z-indexes in xterm.css\n    // xterm-helpers: 5\n    // xterm-helper-textarea: -5\n    // composition-view: 1\n    // xterm-message: 10\n    // xterm-decoration: 6\n    // xterm-decoration-top-layer: 7\n    // xterm-decoration-overview-ruler: 8\n    // xterm-decoration-top: 2\n\n    // modal colors\n    --modal-bg-color: #232323;\n    --modal-header-bottom-border-color: rgba(241, 246, 243, 0.15);\n    --modal-border-color: rgba(255, 255, 255, 0.12); /* toggle colors */\n    --modal-border-radius: 6px;\n    --toggle-bg-color: var(--border-color);\n    --modal-shadow-color: rgba(0, 0, 0, 0.8);\n    --modal-box-shadow: box-shadow: 0px 8px 24px 0px var(--modal-shadow-color);\n\n    --toggle-thumb-color: var(--main-text-color);\n    --toggle-checked-bg-color: var(--accent-color);\n\n    // link color\n    --link-color: #58c142;\n\n    // form colors\n    --form-element-border-color: rgba(241, 246, 243, 0.15);\n    --form-element-bg-color: var(--main-bg-color);\n    --form-element-text-color: var(--main-text-color);\n    --form-element-primary-text-color: var(--main-text-color);\n    --form-element-primary-color: var(--accent-color);\n    --form-element-secondary-color: rgba(255, 255, 255, 0.2);\n    --form-element-error-color: var(--error-color);\n\n    --conn-icon-color: #53b4ea;\n    --conn-icon-color-1: #53b4ea;\n    --conn-icon-color-2: #aa67ff;\n    --conn-icon-color-3: #fda7fd;\n    --conn-icon-color-4: #ef476f;\n    --conn-icon-color-5: #497bf8;\n    --conn-icon-color-6: #ffa24e;\n    --conn-icon-color-7: #dbde52;\n    --conn-icon-color-8: #58c142;\n    --conn-status-overlay-bg-color: rgba(230, 186, 30, 0.2);\n\n    --sysinfo-cpu-color: #58c142;\n    --sysinfo-mem-color: #53b4ea;\n\n    --bulb-color: rgb(255, 221, 51);\n\n    // term colors (16 + 6) form the base terminal theme\n    // for consistency these colors should be used by plugins/applications\n    --term-black: #000000;\n    --term-red: #cc0000;\n    --term-green: #4e9a06;\n    --term-yellow: #c4a000;\n    --term-blue: #3465a4;\n    --term-magenta: #bc3fbc;\n    --term-cyan: #06989a;\n    --term-white: #d0d0d0;\n    --term-bright-black: #555753;\n    --term-bright-red: #ef2929;\n    --term-bright-green: #58c142;\n    --term-bright-yellow: #fce94f;\n    --term-bright-blue: #32afff;\n    --term-bright-magenta: #ad7fa8;\n    --term-bright-cyan: #34e2e2;\n    --term-bright-white: #e7e7e7;\n\n    --term-gray: #8b918a; // not an official terminal color\n    --term-cmdtext: #ffffff;\n    --term-foreground: #d3d7cf;\n    --term-background: #000000;\n    --term-selection-background: #ffffff60;\n    --term-cursor-accent: #000000;\n\n    // button colors\n    --button-text-color: #000000;\n    --button-green-bg: var(--term-green);\n    --button-green-border-color: #29f200;\n    --button-grey-bg: rgba(255, 255, 255, 0.04);\n    --button-grey-hover-bg: rgba(255, 255, 255, 0.09);\n    --button-grey-border-color: rgba(255, 255, 255, 0.1);\n    --button-grey-outlined-color: rgba(255, 255, 255, 0.6);\n    --button-red-bg: #cc0000;\n    --button-red-hover-bg: #f93939;\n    --button-red-border-color: #fc3131;\n    --button-red-outlined-color: #ff3c3c;\n    --button-yellow-bg: #c4a000;\n    --button-yellow-hover-bg: #fce94f;\n}\n"
  },
  {
    "path": "frontend/app/treeview/treeview.test.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { buildVisibleRows, TreeNodeData } from \"@/app/treeview/treeview\";\nimport { describe, expect, it } from \"vitest\";\n\nfunction makeNodes(entries: TreeNodeData[]): Map<string, TreeNodeData> {\n    return new Map(entries.map((entry) => [entry.id, entry]));\n}\n\ndescribe(\"treeview visible rows\", () => {\n    it(\"sorts directories before files and alphabetically\", () => {\n        const nodes = makeNodes([\n            {\n                id: \"root\",\n                isDirectory: true,\n                childrenStatus: \"loaded\",\n                childrenIds: [\"c\", \"a\", \"b\"],\n            },\n            { id: \"a\", parentId: \"root\", isDirectory: false, label: \"z-last.txt\" },\n            { id: \"b\", parentId: \"root\", isDirectory: true, label: \"docs\", childrenStatus: \"loaded\", childrenIds: [] },\n            { id: \"c\", parentId: \"root\", isDirectory: false, label: \"a-first.txt\" },\n        ]);\n        const rows = buildVisibleRows(nodes, [\"root\"], new Set([\"root\"]));\n        expect(rows.map((row) => row.id)).toEqual([\"root\", \"b\", \"c\", \"a\"]);\n    });\n\n    it(\"renders loading and capped synthetic rows\", () => {\n        const nodes = makeNodes([\n            { id: \"root\", isDirectory: true, childrenStatus: \"loading\" },\n            {\n                id: \"dir\",\n                isDirectory: true,\n                childrenStatus: \"capped\",\n                childrenIds: [\"f1\"],\n                capInfo: { max: 1 },\n            },\n            { id: \"f1\", parentId: \"dir\", isDirectory: false, label: \"one.txt\" },\n        ]);\n        const loadingRows = buildVisibleRows(nodes, [\"root\"], new Set([\"root\"]));\n        expect(loadingRows.map((row) => row.kind)).toEqual([\"node\", \"loading\"]);\n\n        const cappedRows = buildVisibleRows(nodes, [\"dir\"], new Set([\"dir\"]));\n        expect(cappedRows.map((row) => row.kind)).toEqual([\"node\", \"node\", \"capped\"]);\n    });\n});\n"
  },
  {
    "path": "frontend/app/treeview/treeview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { makeIconClass } from \"@/util/util\";\nimport { useVirtualizer } from \"@tanstack/react-virtual\";\nimport clsx from \"clsx\";\nimport React, {\n    CSSProperties,\n    KeyboardEvent,\n    MouseEvent,\n    forwardRef,\n    useEffect,\n    useImperativeHandle,\n    useMemo,\n    useRef,\n    useState,\n} from \"react\";\n\ntype TreeNodeChildrenStatus = \"unloaded\" | \"loading\" | \"loaded\" | \"error\" | \"capped\";\n\nexport interface TreeNodeData {\n    id: string;\n    parentId?: string;\n    label?: string;\n    path?: string;\n    isDirectory: boolean;\n    mimeType?: string;\n    icon?: string;\n    isReadonly?: boolean;\n    notfound?: boolean;\n    staterror?: string;\n    childrenStatus?: TreeNodeChildrenStatus;\n    childrenIds?: string[];\n    capInfo?: { max: number; totalKnown?: number };\n}\n\ninterface FetchDirResult {\n    nodes: TreeNodeData[];\n    capped?: boolean;\n    totalKnown?: number;\n}\n\nexport interface TreeViewVisibleRow {\n    id: string;\n    parentId?: string;\n    depth: number;\n    kind: \"node\" | \"loading\" | \"error\" | \"capped\";\n    label: string;\n    isDirectory?: boolean;\n    isExpanded?: boolean;\n    hasChildren?: boolean;\n    icon?: string;\n    node?: TreeNodeData;\n}\n\nexport interface TreeViewProps {\n    rootIds: string[];\n    initialNodes: Record<string, TreeNodeData>;\n    fetchDir?: (id: string, limit: number) => Promise<FetchDirResult>;\n    maxDirEntries?: number;\n    rowHeight?: number;\n    indentWidth?: number;\n    overscan?: number;\n    minWidth?: number;\n    maxWidth?: number;\n    width?: number | string;\n    height?: number | string;\n    className?: string;\n    onOpenFile?: (id: string, node: TreeNodeData) => void;\n    onSelectionChange?: (id: string, node: TreeNodeData) => void;\n}\n\nexport interface TreeViewRef {\n    scrollToId: (id: string) => void;\n}\n\nconst DefaultRowHeight = 24;\nconst DefaultIndentWidth = 16;\nconst DefaultOverscan = 10;\nconst ChevronWidth = 16;\n\nfunction normalizeLabel(node: TreeNodeData): string {\n    if (node.label?.trim()) {\n        return node.label;\n    }\n    const path = node.path ?? node.id;\n    const chunks = path.split(\"/\").filter(Boolean);\n    return chunks[chunks.length - 1] ?? path;\n}\n\nfunction sortIdsByNode(nodesById: Map<string, TreeNodeData>, ids: string[]): string[] {\n    return [...ids].sort((leftId, rightId) => {\n        const left = nodesById.get(leftId);\n        const right = nodesById.get(rightId);\n        const leftDir = left?.isDirectory ? 0 : 1;\n        const rightDir = right?.isDirectory ? 0 : 1;\n        if (leftDir !== rightDir) {\n            return leftDir - rightDir;\n        }\n        const leftLabel = normalizeLabel(left ?? { id: leftId, isDirectory: false }).toLocaleLowerCase();\n        const rightLabel = normalizeLabel(right ?? { id: rightId, isDirectory: false }).toLocaleLowerCase();\n        if (leftLabel !== rightLabel) {\n            return leftLabel.localeCompare(rightLabel);\n        }\n        return leftId.localeCompare(rightId);\n    });\n}\n\nexport function buildVisibleRows(\n    nodesById: Map<string, TreeNodeData>,\n    rootIds: string[],\n    expandedIds: Set<string>\n): TreeViewVisibleRow[] {\n    const rows: TreeViewVisibleRow[] = [];\n\n    const appendNode = (id: string, depth: number) => {\n        const node = nodesById.get(id);\n        if (node == null) {\n            return;\n        }\n        const childIds = node.childrenIds ?? [];\n        const hasChildren = node.isDirectory && (childIds.length > 0 || node.childrenStatus !== \"loaded\");\n        const isExpanded = expandedIds.has(id);\n        rows.push({\n            id,\n            parentId: node.parentId,\n            depth,\n            kind: \"node\",\n            label: normalizeLabel(node),\n            isDirectory: node.isDirectory,\n            isExpanded,\n            hasChildren,\n            icon: node.icon,\n            node,\n        });\n        if (!isExpanded || !node.isDirectory) {\n            return;\n        }\n        const status = node.childrenStatus ?? \"unloaded\";\n        if (status === \"loading\") {\n            rows.push({\n                id: `${id}::__loading`,\n                parentId: id,\n                depth: depth + 1,\n                kind: \"loading\",\n                label: \"Loading…\",\n            });\n            return;\n        }\n        if (status === \"error\") {\n            rows.push({\n                id: `${id}::__error`,\n                parentId: id,\n                depth: depth + 1,\n                kind: \"error\",\n                label: node.staterror ? `Error: ${node.staterror}` : \"Unable to load directory\",\n            });\n            return;\n        }\n\n        const sortedChildren = sortIdsByNode(nodesById, childIds);\n        sortedChildren.forEach((childId) => appendNode(childId, depth + 1));\n        if (status === \"capped\") {\n            const capMax = node.capInfo?.max ?? childIds.length;\n            rows.push({\n                id: `${id}::__capped`,\n                parentId: id,\n                depth: depth + 1,\n                kind: \"capped\",\n                label: `Showing first ${capMax} entries`,\n            });\n        }\n    };\n\n    sortIdsByNode(nodesById, rootIds).forEach((id) => appendNode(id, 0));\n    return rows;\n}\n\nfunction getNodeIcon(node: TreeNodeData, isExpanded: boolean): string {\n    if (node.notfound || node.staterror) {\n        return \"triangle-exclamation\";\n    }\n    if (node.icon) {\n        return node.icon;\n    }\n    if (node.isDirectory) {\n        return isExpanded ? \"folder-open\" : \"folder\";\n    }\n    const mime = node.mimeType ?? \"\";\n    if (mime.startsWith(\"image/\")) {\n        return \"image\";\n    }\n    if (mime === \"application/pdf\") {\n        return \"file-pdf\";\n    }\n    const extension = normalizeLabel(node).split(\".\").pop()?.toLocaleLowerCase();\n    if ([\"js\", \"jsx\", \"ts\", \"tsx\", \"go\", \"py\", \"java\", \"c\", \"cpp\", \"h\", \"hpp\", \"json\", \"yaml\", \"yml\"].includes(extension)) {\n        return \"file-code\";\n    }\n    if ([\"md\", \"txt\", \"log\"].includes(extension)) {\n        return \"file-lines\";\n    }\n    return \"file\";\n}\n\nexport const TreeView = forwardRef<TreeViewRef, TreeViewProps>((props, ref) => {\n    const {\n        rootIds,\n        initialNodes,\n        fetchDir,\n        maxDirEntries = 500,\n        rowHeight = DefaultRowHeight,\n        indentWidth = DefaultIndentWidth,\n        overscan = DefaultOverscan,\n        minWidth = 100,\n        maxWidth = 400,\n        width = \"100%\",\n        height = 360,\n        className,\n        onOpenFile,\n        onSelectionChange,\n    } = props;\n    const [nodesById, setNodesById] = useState<Map<string, TreeNodeData>>(\n        () =>\n            new Map(\n                Object.entries(initialNodes).map(([id, node]) => [id, { ...node, childrenStatus: node.childrenStatus ?? \"unloaded\" }])\n            )\n    );\n    const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());\n    const [selectedId, setSelectedId] = useState<string>(rootIds[0]);\n    const scrollRef = useRef<HTMLDivElement>(null);\n\n    useEffect(() => {\n        setNodesById(\n            new Map(\n                Object.entries(initialNodes).map(([id, node]) => [\n                    id,\n                    {\n                        ...node,\n                        childrenStatus: node.childrenStatus ?? \"unloaded\",\n                    },\n                ])\n            )\n        );\n    }, [initialNodes]);\n\n    const visibleRows = useMemo(() => buildVisibleRows(nodesById, rootIds, expandedIds), [nodesById, rootIds, expandedIds]);\n    const idToIndex = useMemo(\n        () => new Map(visibleRows.map((row, index) => [row.id, index])),\n        [visibleRows]\n    );\n    const virtualizer = useVirtualizer({\n        count: visibleRows.length,\n        getScrollElement: () => scrollRef.current,\n        estimateSize: () => rowHeight,\n        overscan,\n    });\n\n    const commitSelection = (id: string) => {\n        const node = nodesById.get(id);\n        if (node == null) {\n            return;\n        }\n        setSelectedId(id);\n        onSelectionChange?.(id, node);\n    };\n\n    const scrollToId = (id: string) => {\n        const index = idToIndex.get(id);\n        if (index == null) {\n            return;\n        }\n        virtualizer.scrollToIndex(index, { align: \"auto\" });\n    };\n\n    useImperativeHandle(\n        ref,\n        () => ({\n            scrollToId,\n        }),\n        [idToIndex, virtualizer]\n    );\n\n    const loadChildren = async (id: string) => {\n        const currentNode = nodesById.get(id);\n        if (currentNode == null || !currentNode.isDirectory || currentNode.notfound || currentNode.staterror || fetchDir == null) {\n            return;\n        }\n        const status = currentNode.childrenStatus ?? \"unloaded\";\n        if (status !== \"unloaded\") {\n            return;\n        }\n        setNodesById((prev) => {\n            const next = new Map(prev);\n            next.set(id, { ...currentNode, childrenStatus: \"loading\" });\n            return next;\n        });\n        try {\n            const result = await fetchDir(id, maxDirEntries);\n            setNodesById((prev) => {\n                const next = new Map(prev);\n                result.nodes.forEach((node) => {\n                    const merged: TreeNodeData = {\n                        ...node,\n                        parentId: node.parentId ?? id,\n                        childrenStatus: node.childrenStatus ?? (node.isDirectory ? \"unloaded\" : \"loaded\"),\n                    };\n                    next.set(merged.id, merged);\n                });\n                const childrenIds = sortIdsByNode(\n                    next,\n                    result.nodes.map((entry) => entry.id)\n                );\n                const source = next.get(id) ?? currentNode;\n                next.set(id, {\n                    ...source,\n                    childrenIds,\n                    childrenStatus: result.capped ? \"capped\" : \"loaded\",\n                    capInfo: result.capped ? { max: maxDirEntries, totalKnown: result.totalKnown } : undefined,\n                });\n                return next;\n            });\n        } catch (error) {\n            setNodesById((prev) => {\n                const next = new Map(prev);\n                const source = next.get(id) ?? currentNode;\n                next.set(id, {\n                    ...source,\n                    childrenStatus: \"error\",\n                    staterror: error instanceof Error ? error.message : \"Unknown error\",\n                });\n                return next;\n            });\n        }\n    };\n\n    const toggleExpand = (id: string) => {\n        const node = nodesById.get(id);\n        if (node == null || !node.isDirectory || node.notfound || node.staterror) {\n            return;\n        }\n        const expanded = expandedIds.has(id);\n        if (!expanded) {\n            loadChildren(id);\n        }\n        setExpandedIds((prev) => {\n            const next = new Set(prev);\n            if (expanded) {\n                next.delete(id);\n            } else {\n                next.add(id);\n            }\n            return next;\n        });\n        scrollToId(id);\n    };\n\n    const selectVisibleNodeAt = (index: number) => {\n        if (index < 0 || index >= visibleRows.length) {\n            return;\n        }\n        const row = visibleRows[index];\n        if (row.kind !== \"node\") {\n            return;\n        }\n        commitSelection(row.id);\n        scrollToId(row.id);\n    };\n\n    const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n        const selectedIndex = selectedId != null ? idToIndex.get(selectedId) : undefined;\n        if (event.key === \"ArrowDown\") {\n            event.preventDefault();\n            const nextIndex = (selectedIndex ?? -1) + 1;\n            for (let idx = nextIndex; idx < visibleRows.length; idx++) {\n                if (visibleRows[idx].kind === \"node\") {\n                    selectVisibleNodeAt(idx);\n                    break;\n                }\n            }\n            return;\n        }\n        if (event.key === \"ArrowUp\") {\n            event.preventDefault();\n            const previousIndex = (selectedIndex ?? visibleRows.length) - 1;\n            for (let idx = previousIndex; idx >= 0; idx--) {\n                if (visibleRows[idx].kind === \"node\") {\n                    selectVisibleNodeAt(idx);\n                    break;\n                }\n            }\n            return;\n        }\n        const node = selectedId ? nodesById.get(selectedId) : null;\n        if (node == null) {\n            return;\n        }\n        if (event.key === \"ArrowLeft\") {\n            event.preventDefault();\n            if (node.isDirectory && expandedIds.has(node.id)) {\n                toggleExpand(node.id);\n                return;\n            }\n            if (node.parentId != null) {\n                commitSelection(node.parentId);\n                scrollToId(node.parentId);\n            }\n            return;\n        }\n        if (event.key === \"ArrowRight\") {\n            event.preventDefault();\n            if (node.isDirectory && !expandedIds.has(node.id)) {\n                toggleExpand(node.id);\n                return;\n            }\n            if (node.isDirectory && expandedIds.has(node.id) && node.childrenIds?.[0]) {\n                commitSelection(node.childrenIds[0]);\n                scrollToId(node.childrenIds[0]);\n            }\n        }\n    };\n\n    const containerStyle: CSSProperties = {\n        width,\n        minWidth,\n        maxWidth,\n        height,\n    };\n\n    return (\n        <div\n            className={clsx(\"rounded-md border border-border bg-panel\", className)}\n            style={containerStyle}\n            tabIndex={0}\n            onKeyDown={onKeyDown}\n        >\n            <div ref={scrollRef} className=\"h-full overflow-auto\">\n                <div className=\"relative w-max min-w-full\" style={{ height: virtualizer.getTotalSize() }}>\n                    {virtualizer.getVirtualItems().map((virtualRow) => {\n                        const row = visibleRows[virtualRow.index];\n                        if (row.kind === \"node\" && row.node == null) {\n                            return null;\n                        }\n                        const selected = row.id === selectedId;\n                        return (\n                            <div\n                                key={row.id}\n                                className={clsx(\n                                    \"absolute left-0 right-0 flex items-center whitespace-nowrap text-sm\",\n                                    row.kind === \"node\" ? \"cursor-pointer\" : \"text-muted\",\n                                    selected ? \"bg-accent/25 text-foreground\" : \"text-foreground hover:bg-muted/50\"\n                                )}\n                                style={{\n                                    top: 0,\n                                    height: rowHeight,\n                                    transform: `translateY(${virtualRow.start}px)`,\n                                }}\n                                onClick={() => row.kind === \"node\" && commitSelection(row.id)}\n                                onDoubleClick={() => {\n                                    if (row.kind !== \"node\") {\n                                        return;\n                                    }\n                                    if (row.isDirectory) {\n                                        toggleExpand(row.id);\n                                        return;\n                                    }\n                                    if (row.node != null) {\n                                        onOpenFile?.(row.id, row.node);\n                                    }\n                                }}\n                            >\n                                <div\n                                    className=\"flex items-center\"\n                                    style={{ paddingLeft: row.depth * indentWidth, width: ChevronWidth + row.depth * indentWidth }}\n                                >\n                                    {row.kind === \"node\" && row.isDirectory && row.hasChildren ? (\n                                        <button\n                                            className=\"h-4 w-4 rounded text-muted hover:text-foreground cursor-pointer\"\n                                            onClick={(event: MouseEvent<HTMLButtonElement>) => {\n                                                event.stopPropagation();\n                                                toggleExpand(row.id);\n                                            }}\n                                        >\n                                            <i\n                                                className={clsx(\n                                                    \"fa-sharp fa-solid text-[11px]\",\n                                                    row.isExpanded ? \"fa-chevron-down\" : \"fa-chevron-right\"\n                                                )}\n                                            />\n                                        </button>\n                                    ) : (\n                                        <span className=\"inline-block h-4 w-4\" />\n                                    )}\n                                </div>\n                                {row.kind === \"node\" ? (\n                                    <>\n                                        <i\n                                            className={makeIconClass(getNodeIcon(row.node, row.isExpanded), true)}\n                                            style={{\n                                                color: row.node.notfound || row.node.staterror ? \"var(--color-error)\" : \"inherit\",\n                                            }}\n                                        />\n                                        <span\n                                            className={clsx(\"ml-2 pr-3\", row.node.isReadonly && \"text-muted\")}\n                                            title={row.label}\n                                        >\n                                            {row.label}\n                                        </span>\n                                    </>\n                                ) : (\n                                    <span className=\"ml-6 pr-3 text-xs\">{row.label}</span>\n                                )}\n                            </div>\n                        );\n                    })}\n                </div>\n            </div>\n        </div>\n    );\n});\n\nTreeView.displayName = \"TreeView\";\n"
  },
  {
    "path": "frontend/app/view/aifilediff/aifilediff.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { BlockNodeModel } from \"@/app/block/blocktypes\";\nimport type { TabModel } from \"@/app/store/tab-model\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { DiffViewer } from \"@/app/view/codeeditor/diffviewer\";\nimport type { WaveEnv, WaveEnvSubset } from \"@/app/waveenv/waveenv\";\nimport { globalStore } from \"@/store/jotaiStore\";\nimport { base64ToString } from \"@/util/util\";\nimport * as jotai from \"jotai\";\nimport { useEffect } from \"react\";\n\ntype DiffData = {\n    original: string;\n    modified: string;\n    fileName: string;\n};\n\nexport type AiFileDiffEnv = WaveEnvSubset<{\n    rpc: {\n        WaveAIGetToolDiffCommand: WaveEnv[\"rpc\"][\"WaveAIGetToolDiffCommand\"];\n    };\n    wos: WaveEnv[\"wos\"];\n}>;\n\nexport class AiFileDiffViewModel implements ViewModel {\n    blockId: string;\n    nodeModel: BlockNodeModel;\n    tabModel: TabModel;\n    env: AiFileDiffEnv;\n    viewType = \"aifilediff\";\n    blockAtom: jotai.Atom<Block>;\n    diffDataAtom: jotai.PrimitiveAtom<DiffData | null>;\n    errorAtom: jotai.PrimitiveAtom<string | null>;\n    loadingAtom: jotai.PrimitiveAtom<boolean>;\n    viewIcon: jotai.Atom<string>;\n    viewName: jotai.Atom<string>;\n    viewText: jotai.Atom<string>;\n\n    constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) {\n        this.blockId = blockId;\n        this.nodeModel = nodeModel;\n        this.tabModel = tabModel;\n        this.env = waveEnv as AiFileDiffEnv;\n        this.blockAtom = this.env.wos.getWaveObjectAtom<Block>(`block:${blockId}`);\n        this.diffDataAtom = jotai.atom(null) as jotai.PrimitiveAtom<DiffData | null>;\n        this.errorAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;\n        this.loadingAtom = jotai.atom<boolean>(true);\n        this.viewIcon = jotai.atom(\"file-lines\");\n        this.viewName = jotai.atom(\"AI Diff Viewer\");\n        this.viewText = jotai.atom((get) => {\n            const diffData = get(this.diffDataAtom);\n            return diffData?.fileName ?? \"\";\n        });\n    }\n\n    get viewComponent(): ViewComponent {\n        return AiFileDiffView;\n    }\n}\n\nfunction AiFileDiffView({ blockId, model }: ViewComponentProps<AiFileDiffViewModel>) {\n    const blockData = jotai.useAtomValue(model.blockAtom);\n    const diffData = jotai.useAtomValue(model.diffDataAtom);\n    const error = jotai.useAtomValue(model.errorAtom);\n    const loading = jotai.useAtomValue(model.loadingAtom);\n\n    useEffect(() => {\n        async function loadDiffData() {\n            const chatId = blockData?.meta?.[\"aifilediff:chatid\"];\n            const toolCallId = blockData?.meta?.[\"aifilediff:toolcallid\"];\n            const fileName = blockData?.meta?.file;\n\n            if (!chatId || !toolCallId) {\n                globalStore.set(model.errorAtom, \"Missing chatId or toolCallId in block metadata\");\n                globalStore.set(model.loadingAtom, false);\n                return;\n            }\n\n            if (!fileName) {\n                globalStore.set(model.errorAtom, \"Missing file name in block metadata\");\n                globalStore.set(model.loadingAtom, false);\n                return;\n            }\n\n            try {\n                const result = await model.env.rpc.WaveAIGetToolDiffCommand(TabRpcClient, {\n                    chatid: chatId,\n                    toolcallid: toolCallId,\n                });\n\n                if (!result) {\n                    globalStore.set(model.errorAtom, \"No diff data returned from server\");\n                    globalStore.set(model.loadingAtom, false);\n                    return;\n                }\n\n                const originalContent = base64ToString(result.originalcontents64);\n                const modifiedContent = base64ToString(result.modifiedcontents64);\n\n                globalStore.set(model.diffDataAtom, {\n                    original: originalContent,\n                    modified: modifiedContent,\n                    fileName: fileName,\n                });\n                globalStore.set(model.loadingAtom, false);\n            } catch (e) {\n                console.error(\"Error loading diff data:\", e);\n                globalStore.set(model.errorAtom, `Error loading diff data: ${e.message}`);\n                globalStore.set(model.loadingAtom, false);\n            }\n        }\n\n        loadDiffData();\n    }, [blockData?.meta?.[\"aifilediff:chatid\"], blockData?.meta?.[\"aifilediff:toolcallid\"], blockData?.meta?.file]);\n\n    if (loading) {\n        return (\n            <div className=\"flex items-center justify-center w-full h-full\">\n                <div className=\"text-secondary\">Loading diff...</div>\n            </div>\n        );\n    }\n\n    if (error) {\n        return (\n            <div className=\"flex items-center justify-center w-full h-full\">\n                <div className=\"text-red-500\">{error}</div>\n            </div>\n        );\n    }\n\n    if (!diffData) {\n        return (\n            <div className=\"flex items-center justify-center w-full h-full\">\n                <div className=\"text-secondary\">No diff data available</div>\n            </div>\n        );\n    }\n\n    return (\n        <DiffViewer\n            blockId={blockId}\n            original={diffData.original}\n            modified={diffData.modified}\n            fileName={diffData.fileName}\n        />\n    );\n}\n\nexport default AiFileDiffView;\n"
  },
  {
    "path": "frontend/app/view/codeeditor/codeeditor.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { MonacoCodeEditor } from \"@/app/monaco/monaco-react\";\nimport { useOverrideConfigAtom } from \"@/app/store/global\";\nimport { boundNumber } from \"@/util/util\";\nimport type * as MonacoTypes from \"monaco-editor\";\nimport * as MonacoModule from \"monaco-editor\";\nimport React, { useMemo, useRef } from \"react\";\n\nfunction defaultEditorOptions(): MonacoTypes.editor.IEditorOptions {\n    const opts: MonacoTypes.editor.IEditorOptions = {\n        scrollBeyondLastLine: false,\n        fontSize: 12,\n        fontFamily: \"Hack\",\n        smoothScrolling: true,\n        scrollbar: {\n            useShadows: false,\n            verticalScrollbarSize: 5,\n            horizontalScrollbarSize: 5,\n        },\n        minimap: {\n            enabled: true,\n        },\n        stickyScroll: {\n            enabled: false,\n        },\n    };\n    return opts;\n}\n\ninterface CodeEditorProps {\n    blockId: string;\n    text: string;\n    readonly: boolean;\n    language?: string;\n    fileName?: string;\n    onChange?: (text: string) => void;\n    onMount?: (monacoPtr: MonacoTypes.editor.IStandaloneCodeEditor, monaco: typeof MonacoModule) => () => void;\n}\n\nexport function CodeEditor({ blockId, text, language, fileName, readonly, onChange, onMount }: CodeEditorProps) {\n    const divRef = useRef<HTMLDivElement>(null);\n    const unmountRef = useRef<() => void>(null);\n    const minimapEnabled = useOverrideConfigAtom(blockId, \"editor:minimapenabled\") ?? false;\n    const stickyScrollEnabled = useOverrideConfigAtom(blockId, \"editor:stickyscrollenabled\") ?? false;\n    const wordWrap = useOverrideConfigAtom(blockId, \"editor:wordwrap\") ?? false;\n    const fontSize = boundNumber(useOverrideConfigAtom(blockId, \"editor:fontsize\"), 6, 64);\n    const uuidRef = useRef(crypto.randomUUID()).current;\n    let editorPath: string;\n    if (fileName) {\n        const separator = fileName.startsWith(\"/\") ? \"\" : \"/\";\n        editorPath = blockId + separator + fileName;\n    } else {\n        editorPath = uuidRef;\n    }\n\n    React.useEffect(() => {\n        return () => {\n            // unmount function\n            if (unmountRef.current) {\n                unmountRef.current();\n            }\n        };\n    }, []);\n\n    function handleEditorChange(text: string) {\n        if (onChange) {\n            onChange(text);\n        }\n    }\n\n    function handleEditorOnMount(\n        editor: MonacoTypes.editor.IStandaloneCodeEditor,\n        monaco: typeof MonacoModule\n    ): () => void {\n        if (onMount) {\n            const cleanup = onMount(editor, monaco);\n            unmountRef.current = cleanup;\n            return cleanup;\n        }\n        return undefined;\n    }\n\n    const editorOpts = useMemo(() => {\n        const opts = defaultEditorOptions();\n        opts.minimap.enabled = minimapEnabled;\n        opts.stickyScroll.enabled = stickyScrollEnabled;\n        opts.wordWrap = wordWrap ? \"on\" : \"off\";\n        opts.fontSize = fontSize;\n        opts.copyWithSyntaxHighlighting = false;\n        return opts;\n    }, [minimapEnabled, stickyScrollEnabled, wordWrap, fontSize, readonly]);\n\n    return (\n        <div className=\"flex flex-col w-full h-full overflow-hidden items-center justify-center\">\n            <div className=\"flex flex-col h-full w-full\" ref={divRef}>\n                <MonacoCodeEditor\n                    readonly={readonly}\n                    text={text}\n                    options={editorOpts}\n                    onChange={handleEditorChange}\n                    onMount={handleEditorOnMount}\n                    path={editorPath}\n                    language={language}\n                />\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/app/view/codeeditor/diffviewer.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { MonacoDiffViewer } from \"@/app/monaco/monaco-react\";\nimport { useOverrideConfigAtom } from \"@/app/store/global\";\nimport { boundNumber } from \"@/util/util\";\nimport type * as MonacoTypes from \"monaco-editor\";\nimport { useMemo, useRef } from \"react\";\n\ninterface DiffViewerProps {\n    blockId: string;\n    original: string;\n    modified: string;\n    language?: string;\n    fileName: string;\n}\n\nfunction defaultDiffEditorOptions(): MonacoTypes.editor.IDiffEditorOptions {\n    const opts: MonacoTypes.editor.IDiffEditorOptions = {\n        scrollBeyondLastLine: false,\n        fontSize: 12,\n        fontFamily: \"Hack\",\n        smoothScrolling: true,\n        scrollbar: {\n            useShadows: false,\n            verticalScrollbarSize: 5,\n            horizontalScrollbarSize: 5,\n        },\n        minimap: {\n            enabled: true,\n        },\n        readOnly: true,\n        renderSideBySide: true,\n        originalEditable: false,\n    };\n    return opts;\n}\n\nexport function DiffViewer({ blockId, original, modified, language, fileName }: DiffViewerProps) {\n    const minimapEnabled = useOverrideConfigAtom(blockId, \"editor:minimapenabled\") ?? false;\n    const fontSize = boundNumber(useOverrideConfigAtom(blockId, \"editor:fontsize\"), 6, 64);\n    const inlineDiff = useOverrideConfigAtom(blockId, \"editor:inlinediff\");\n    const uuidRef = useRef(crypto.randomUUID()).current;\n    let editorPath: string;\n    if (fileName) {\n        const separator = fileName.startsWith(\"/\") ? \"\" : \"/\";\n        editorPath = blockId + separator + fileName;\n    } else {\n        editorPath = uuidRef;\n    }\n\n    const editorOpts = useMemo(() => {\n        const opts = defaultDiffEditorOptions();\n        opts.minimap.enabled = minimapEnabled;\n        opts.fontSize = fontSize;\n        if (inlineDiff != null) {\n            opts.renderSideBySide = !inlineDiff;\n        }\n        return opts;\n    }, [minimapEnabled, fontSize, inlineDiff]);\n\n    return (\n        <div className=\"flex flex-col w-full h-full overflow-hidden items-center justify-center\">\n            <div className=\"flex flex-col h-full w-full\">\n                <MonacoDiffViewer\n                    path={editorPath}\n                    original={original}\n                    modified={modified}\n                    options={editorOpts}\n                    language={language}\n                />\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/app/view/helpview/helpview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { globalStore, WOS } from \"@/app/store/global\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { WebView, WebViewModel } from \"@/app/view/webview/webview\";\nimport { atom } from \"jotai\";\n\nconst docsiteUrl = \"https://docs.waveterm.dev/?ref=app\";\n\nclass HelpViewModel extends WebViewModel {\n    get viewComponent(): ViewComponent {\n        return HelpView;\n    }\n\n    constructor(initOpts: ViewModelInitType) {\n        super(initOpts);\n        this.viewText = atom((get) => {\n            // force a dependency on meta.url so we re-render the buttons when the url changes\n            void (get(this.blockAtom)?.meta?.url || get(this.homepageUrl));\n            return [\n                {\n                    elemtype: \"iconbutton\",\n                    icon: \"chevron-left\",\n                    click: this.handleBack.bind(this),\n                    disabled: this.shouldDisableBackButton(),\n                },\n                {\n                    elemtype: \"iconbutton\",\n                    icon: \"chevron-right\",\n                    click: this.handleForward.bind(this),\n                    disabled: this.shouldDisableForwardButton(),\n                },\n                {\n                    elemtype: \"iconbutton\",\n                    icon: \"house\",\n                    click: this.handleHome.bind(this),\n                    disabled: this.shouldDisableHomeButton(),\n                },\n            ];\n        });\n        this.homepageUrl = atom(docsiteUrl);\n        this.viewType = \"help\";\n        this.viewIcon = atom(\"circle-question\");\n        this.viewName = atom(\"Help\");\n    }\n\n    setZoomFactor(factor: number | null) {\n        // null is ok (will reset to default)\n        if (factor != null && factor < 0.1) {\n            factor = 0.1;\n        }\n        if (factor != null && factor > 5) {\n            factor = 5;\n        }\n        const domReady = globalStore.get(this.domReady);\n        if (!domReady) {\n            return;\n        }\n        this.webviewRef.current?.setZoomFactor(factor || 1);\n        RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"block\", this.blockId),\n            meta: { \"web:zoom\": factor }, // allow null so we can remove the zoom factor here\n        });\n    }\n\n    getSettingsMenuItems(): ContextMenuItem[] {\n        const zoomSubMenu: ContextMenuItem[] = [];\n        let curZoom = 1;\n        if (globalStore.get(this.domReady)) {\n            curZoom = this.webviewRef.current?.getZoomFactor() || 1;\n        }\n        // eslint-disable-next-line @typescript-eslint/no-this-alias\n        const model = this; // for the closure to work (this is getting unset)\n        function makeZoomFactorMenuItem(label: string, factor: number): ContextMenuItem {\n            return {\n                label: label,\n                type: \"checkbox\",\n                click: () => {\n                    model.setZoomFactor(factor);\n                },\n                checked: curZoom == factor,\n            };\n        }\n        zoomSubMenu.push({\n            label: \"Reset\",\n            click: () => {\n                model.setZoomFactor(null);\n            },\n        });\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"25%\", 0.25));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"50%\", 0.5));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"70%\", 0.7));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"80%\", 0.8));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"90%\", 0.9));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"100%\", 1));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"110%\", 1.1));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"120%\", 1.2));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"130%\", 1.3));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"150%\", 1.5));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"175%\", 1.75));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"200%\", 2));\n\n        return [\n            {\n                label: this.webviewRef.current?.isDevToolsOpened() ? \"Close DevTools\" : \"Open DevTools\",\n                click: async () => {\n                    if (this.webviewRef.current) {\n                        if (this.webviewRef.current.isDevToolsOpened()) {\n                            this.webviewRef.current.closeDevTools();\n                        } else {\n                            this.webviewRef.current.openDevTools();\n                        }\n                    }\n                },\n            },\n            {\n                label: \"Set Zoom Factor\",\n                submenu: zoomSubMenu,\n            },\n        ];\n    }\n}\n\nfunction HelpView(props: ViewComponentProps<HelpViewModel>) {\n    return (\n        <div className=\"w-full h-full\">\n            <WebView {...props} />\n        </div>\n    );\n}\n\nexport { HelpViewModel };\n"
  },
  {
    "path": "frontend/app/view/launcher/launcher.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport logoUrl from \"@/app/asset/logo.svg?url\";\nimport type { BlockNodeModel } from \"@/app/block/blocktypes\";\nimport { atoms, globalStore, replaceBlock } from \"@/app/store/global\";\nimport type { TabModel } from \"@/app/store/tab-model\";\nimport { checkKeyPressed, keydownWrapper } from \"@/util/keyutil\";\nimport { isBlank, makeIconClass } from \"@/util/util\";\nimport clsx from \"clsx\";\nimport { atom, useAtom, useAtomValue } from \"jotai\";\nimport React, { useEffect, useLayoutEffect, useRef } from \"react\";\n\nfunction sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType } | null | undefined): WidgetConfigType[] {\n    if (!wmap) return [];\n    const wlist = Object.values(wmap);\n    wlist.sort((a, b) => (a[\"display:order\"] ?? 0) - (b[\"display:order\"] ?? 0));\n    return wlist;\n}\n\ntype GridLayoutType = { columns: number; tileWidth: number; tileHeight: number; showLabel: boolean };\n\nexport class LauncherViewModel implements ViewModel {\n    blockId: string;\n    nodeModel: BlockNodeModel;\n    tabModel: TabModel;\n    viewType = \"launcher\";\n    viewIcon = atom(\"shapes\");\n    viewName = atom(\"Widget Launcher\");\n    viewComponent = LauncherView;\n    noHeader = atom(true);\n    inputRef = { current: null } as React.RefObject<HTMLInputElement>;\n    searchTerm = atom(\"\");\n    selectedIndex = atom(0);\n    containerSize = atom({ width: 0, height: 0 });\n    gridLayout: GridLayoutType = null;\n\n    constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) {\n        this.blockId = blockId;\n        this.nodeModel = nodeModel;\n        this.tabModel = tabModel;\n    }\n\n    filteredWidgetsAtom = atom((get) => {\n        const searchTerm = get(this.searchTerm);\n        const widgets = sortByDisplayOrder(get(atoms.fullConfigAtom)?.widgets || {});\n        return widgets.filter(\n            (widget) =>\n                !widget[\"display:hidden\"] &&\n                (!searchTerm || widget.label?.toLowerCase().includes(searchTerm.toLowerCase()))\n        );\n    });\n\n    giveFocus(): boolean {\n        if (this.inputRef.current) {\n            this.inputRef.current.focus();\n            return true;\n        }\n        return false;\n    }\n\n    keyDownHandler(e: WaveKeyboardEvent): boolean {\n        if (this.gridLayout == null) {\n            return;\n        }\n        const gridLayout = this.gridLayout;\n        const filteredWidgets = globalStore.get(this.filteredWidgetsAtom);\n        const selectedIndex = globalStore.get(this.selectedIndex);\n        const rows = Math.ceil(filteredWidgets.length / gridLayout.columns);\n        const currentRow = Math.floor(selectedIndex / gridLayout.columns);\n        const currentCol = selectedIndex % gridLayout.columns;\n        if (checkKeyPressed(e, \"ArrowUp\")) {\n            if (filteredWidgets.length == 0) {\n                return true;\n            }\n            if (currentRow > 0) {\n                const newIndex = selectedIndex - gridLayout.columns;\n                if (newIndex >= 0) {\n                    globalStore.set(this.selectedIndex, newIndex);\n                }\n            }\n            return true;\n        }\n        if (checkKeyPressed(e, \"ArrowDown\")) {\n            if (filteredWidgets.length == 0) {\n                return true;\n            }\n            if (currentRow < rows - 1) {\n                const newIndex = selectedIndex + gridLayout.columns;\n                if (newIndex < filteredWidgets.length) {\n                    globalStore.set(this.selectedIndex, newIndex);\n                }\n            }\n            return true;\n        }\n        if (checkKeyPressed(e, \"ArrowLeft\")) {\n            if (filteredWidgets.length == 0) {\n                return true;\n            }\n            if (currentCol > 0) {\n                globalStore.set(this.selectedIndex, selectedIndex - 1);\n            }\n            return true;\n        }\n        if (checkKeyPressed(e, \"ArrowRight\")) {\n            if (filteredWidgets.length == 0) {\n                return true;\n            }\n            if (currentCol < gridLayout.columns - 1 && selectedIndex + 1 < filteredWidgets.length) {\n                globalStore.set(this.selectedIndex, selectedIndex + 1);\n            }\n            return true;\n        }\n        if (checkKeyPressed(e, \"Enter\")) {\n            if (filteredWidgets.length == 0) {\n                return true;\n            }\n            if (filteredWidgets[selectedIndex]) {\n                this.handleWidgetSelect(filteredWidgets[selectedIndex]);\n            }\n            return true;\n        }\n        if (checkKeyPressed(e, \"Escape\")) {\n            globalStore.set(this.searchTerm, \"\");\n            globalStore.set(this.selectedIndex, 0);\n            return true;\n        }\n        return false;\n    }\n\n    async handleWidgetSelect(widget: WidgetConfigType) {\n        try {\n            await replaceBlock(this.blockId, widget.blockdef, true);\n        } catch (error) {\n            console.error(\"Error replacing block:\", error);\n        }\n    }\n}\n\nfunction LauncherView({ blockId, model }: ViewComponentProps<LauncherViewModel>) {\n    // Search and selection state\n    const [searchTerm, setSearchTerm] = useAtom(model.searchTerm);\n    const [selectedIndex, setSelectedIndex] = useAtom(model.selectedIndex);\n    const filteredWidgets = useAtomValue(model.filteredWidgetsAtom);\n\n    // Container measurement\n    const containerRef = useRef<HTMLDivElement>(null);\n    const [containerSize, setContainerSize] = useAtom(model.containerSize);\n\n    useLayoutEffect(() => {\n        if (!containerRef.current) return;\n        const resizeObserver = new ResizeObserver((entries) => {\n            for (let entry of entries) {\n                setContainerSize({\n                    width: entry.contentRect.width,\n                    height: entry.contentRect.height,\n                });\n            }\n        });\n        resizeObserver.observe(containerRef.current);\n        return () => {\n            resizeObserver.disconnect();\n        };\n    }, []);\n\n    // Layout constants\n    const GAP = 16;\n    const LABEL_THRESHOLD = 60;\n    const MARGIN_BOTTOM = 24;\n    const MAX_TILE_SIZE = 120;\n\n    const calculatedLogoWidth = containerSize.width * 0.3;\n    const logoWidth = containerSize.width >= 100 ? Math.min(Math.max(calculatedLogoWidth, 100), 300) : 0;\n    const showLogo = logoWidth >= 100;\n    const availableHeight = containerSize.height - (showLogo ? logoWidth + MARGIN_BOTTOM : 0);\n\n    // Determine optimal grid layout\n    const gridLayout: GridLayoutType = React.useMemo(() => {\n        if (containerSize.width === 0 || availableHeight <= 0 || filteredWidgets.length === 0) {\n            return { columns: 1, tileWidth: 90, tileHeight: 90, showLabel: true };\n        }\n        let bestColumns = 1;\n        let bestTileSize = 0;\n        let bestTileWidth = 90;\n        let bestTileHeight = 90;\n        let showLabel = true;\n        for (let cols = 1; cols <= filteredWidgets.length; cols++) {\n            const rows = Math.ceil(filteredWidgets.length / cols);\n            const tileWidth = (containerSize.width - (cols - 1) * GAP) / cols;\n            const tileHeight = (availableHeight - (rows - 1) * GAP) / rows;\n            const currentTileSize = Math.min(tileWidth, tileHeight);\n            if (currentTileSize > bestTileSize) {\n                bestTileSize = currentTileSize;\n                bestColumns = cols;\n                bestTileWidth = tileWidth;\n                bestTileHeight = tileHeight;\n                showLabel = tileHeight >= LABEL_THRESHOLD;\n            }\n        }\n        return { columns: bestColumns, tileWidth: bestTileWidth, tileHeight: bestTileHeight, showLabel };\n    }, [containerSize, availableHeight, filteredWidgets.length]);\n    model.gridLayout = gridLayout;\n\n    const finalTileWidth = Math.min(gridLayout.tileWidth, MAX_TILE_SIZE);\n    const finalTileHeight = gridLayout.showLabel ? Math.min(gridLayout.tileHeight, MAX_TILE_SIZE) : finalTileWidth;\n\n    // Reset selection when search term changes\n    useEffect(() => {\n        setSelectedIndex(0);\n    }, [searchTerm]);\n\n    return (\n        <div ref={containerRef} className=\"w-full h-full p-4 box-border flex flex-col items-center justify-center\">\n            {/* Hidden input for search */}\n            <input\n                ref={model.inputRef}\n                type=\"text\"\n                value={searchTerm}\n                onKeyDown={keydownWrapper(model.keyDownHandler.bind(model))}\n                onChange={(e) => setSearchTerm(e.target.value)}\n                className=\"sr-only dummy\"\n                aria-label=\"Search widgets\"\n            />\n\n            {/* Logo */}\n            {showLogo && (\n                <div className=\"mb-6\" style={{ width: logoWidth, maxWidth: 300 }}>\n                    <img src={logoUrl} className=\"w-full h-auto filter grayscale brightness-70 opacity-70\" alt=\"Logo\" />\n                </div>\n            )}\n\n            {/* Grid of widgets */}\n            <div\n                className=\"grid gap-4 justify-center\"\n                style={{\n                    gridTemplateColumns: `repeat(${gridLayout.columns}, ${finalTileWidth}px)`,\n                }}\n            >\n                {filteredWidgets.map((widget, index) => (\n                    <div\n                        key={index}\n                        onClick={() => model.handleWidgetSelect(widget)}\n                        title={widget.description || widget.label}\n                        className={clsx(\n                            \"flex flex-col items-center justify-center cursor-pointer rounded-md p-2 text-center\",\n                            \"transition-colors duration-150\",\n                            index === selectedIndex\n                                ? \"bg-white/20 text-white\"\n                                : \"bg-white/5 hover:bg-white/10 text-secondary hover:text-white\"\n                        )}\n                        style={{\n                            width: finalTileWidth,\n                            height: finalTileHeight,\n                        }}\n                    >\n                        <div style={{ color: widget.color }}>\n                            <i\n                                className={makeIconClass(widget.icon, true, {\n                                    defaultIcon: \"browser\",\n                                })}\n                            />\n                        </div>\n                        {gridLayout.showLabel && !isBlank(widget.label) && (\n                            <div className=\"mt-1 w-full text-[11px] leading-4 overflow-hidden text-ellipsis whitespace-nowrap\">\n                                {widget.label}\n                            </div>\n                        )}\n                    </div>\n                ))}\n            </div>\n\n            {/* Search instructions */}\n            <div className=\"mt-4 text-secondary text-xs\">\n                {filteredWidgets.length === 0 ? (\n                    <span>No widgets found. Press Escape to clear search.</span>\n                ) : (\n                    <span>\n                        {searchTerm == \"\" ? \"Type to Filter\" : \"Searching \" + '\"' + searchTerm + '\"'}, Enter to Launch,\n                        {searchTerm == \"\" ? \"Arrow Keys to Navigate\" : null}\n                    </span>\n                )}\n            </div>\n        </div>\n    );\n}\n\nexport default LauncherView;\n"
  },
  {
    "path": "frontend/app/view/preview/csvview.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.csv-view {\n    opacity: 0; /* Start with an opacity of 0, meaning it's invisible */\n\n    overflow-x: auto;\n    overflow-y: hidden;\n\n    .cursor-pointer {\n        cursor: pointer;\n    }\n\n    .select-none {\n        user-select: none;\n    }\n\n    table.probe {\n        position: absolute;\n        visibility: hidden;\n    }\n\n    table {\n        border-collapse: collapse;\n        overflow-x: auto;\n        border: 1px solid var(--scrollbar-thumb-hover-color);\n\n        thead {\n            position: relative;\n            display: block;\n            width: 100%;\n            overflow-y: scroll;\n\n            tr {\n                border-bottom: 1px solid var(--scrollbar-thumb-hover-color);\n\n                th {\n                    color: var(--main-text-color);\n                    border-right: 1px solid var(--scrollbar-thumb-hover-color);\n                    border-bottom: none;\n                    padding: 2px 10px;\n                    flex-basis: 100%;\n                    flex-grow: 2;\n                    display: block;\n                    text-align: left;\n                    position: relative;\n\n                    .inner {\n                        text-align: left;\n                        padding-right: 15px;\n                        position: relative;\n\n                        .sort-icon {\n                            position: absolute;\n                            right: 0px;\n                            top: 2px;\n                            width: 9px;\n                        }\n                    }\n                }\n            }\n        }\n\n        tbody {\n            display: block;\n            position: relative;\n            overflow-y: scroll;\n            overscroll-behavior: contain;\n        }\n\n        tr {\n            width: 100%;\n            display: flex;\n\n            td {\n                border-right: 1px solid var(--scrollbar-thumb-hover-color);\n                border-left: 1px solid var(--scrollbar-thumb-hover-color);\n                padding: 3px 10px;\n                flex-basis: 100%;\n                flex-grow: 2;\n                display: block;\n                text-align: left;\n            }\n        }\n    }\n}\n\n.csv-view.show {\n    opacity: 1;\n}\n"
  },
  {
    "path": "frontend/app/view/preview/csvview.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { useTableNav } from \"@table-nav/react\";\nimport {\n    createColumnHelper,\n    flexRender,\n    getCoreRowModel,\n    getSortedRowModel,\n    useReactTable,\n} from \"@tanstack/react-table\";\nimport clsx from \"clsx\";\nimport Papa from \"papaparse\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\nimport { useDimensionsWithExistingRef } from \"@/app/hook/useDimensions\";\nimport \"./csvview.scss\";\n\nconst MAX_DATA_SIZE = 10 * 1024 * 1024; // 10MB in bytes\n\ntype CSVRow = {\n    [key: string]: string | number;\n};\n\ninterface CSVViewProps {\n    parentRef: React.RefObject<HTMLDivElement>;\n    content: string;\n    filename: string;\n    readonly: boolean;\n}\n\ninterface State {\n    content: string | null;\n    showReadonly: boolean;\n    tbodyHeight: number;\n}\n\nconst columnHelper = createColumnHelper<any>();\n\n// TODO remove parentRef dependency -- use own height\nconst CSVView = ({ parentRef, filename, content }: CSVViewProps) => {\n    const csvCacheRef = useRef(new Map<string, string>());\n    const rowRef = useRef<(HTMLTableRowElement | null)[]>([]);\n    const headerRef = useRef<HTMLTableRowElement | null>(null);\n    const probeRef = useRef<HTMLTableRowElement | null>(null);\n    const tbodyRef = useRef<HTMLTableSectionElement | null>(null);\n\n    const [state, setState] = useState<State>({\n        content,\n        showReadonly: true,\n        tbodyHeight: 0,\n    });\n\n    const [tableLoaded, setTableLoaded] = useState(false);\n\n    const { listeners } = useTableNav();\n    const domRect = useDimensionsWithExistingRef(parentRef, 30);\n    const parentHeight = domRect?.height ?? 0;\n\n    const cacheKey = `${filename}`;\n    csvCacheRef.current.set(cacheKey, content);\n\n    // Parse the CSV data\n    const parsedData = useMemo<CSVRow[]>(() => {\n        if (!state.content) return [];\n\n        // Trim the content and then check for headers based on the first row's content.\n        const trimmedContent = state.content.trim();\n        const firstRow = trimmedContent.split(\"\\n\")[0];\n\n        // This checks if the first row starts with a letter or a quote\n        const hasHeaders = !!firstRow.match(/^[a-zA-Z\"]/);\n\n        const results = Papa.parse(trimmedContent, { header: hasHeaders });\n\n        // Check for non-header CSVs\n        if (!hasHeaders && Array.isArray(results.data) && Array.isArray(results.data[0])) {\n            const dataArray = results.data as string[][]; // Asserting the type\n            const headers = Array.from({ length: dataArray[0].length }, (_, i) => `Column ${i + 1}`);\n            results.data = dataArray.map((row) => {\n                const newRow: CSVRow = {};\n                row.forEach((value, index) => {\n                    newRow[headers[index]] = value;\n                });\n                return newRow;\n            });\n        }\n\n        return results.data.map((row) => {\n            return Object.fromEntries(\n                Object.entries(row as CSVRow).map(([key, value]) => {\n                    if (typeof value === \"string\") {\n                        const numberValue = parseFloat(value);\n                        if (!isNaN(numberValue) && String(numberValue) === value) {\n                            return [key, numberValue];\n                        }\n                    }\n                    return [key, value];\n                })\n            ) as CSVRow;\n        });\n    }, [state.content]);\n\n    // Column Definitions\n    const columns = useMemo(() => {\n        if (parsedData.length === 0) {\n            return [];\n        }\n        const headers = Object.keys(parsedData[0]);\n        return headers.map((header) =>\n            columnHelper.accessor(header, {\n                header: () => header,\n                cell: (info) => info.renderValue(),\n            })\n        );\n    }, [parsedData]);\n\n    useEffect(() => {\n        if (probeRef.current && headerRef.current && parsedData.length && parentRef.current) {\n            const rowHeight = probeRef.current.offsetHeight;\n            const fullTBodyHeight = rowHeight * parsedData.length;\n            const headerHeight = headerRef.current.offsetHeight;\n            const maxHeightLessHeader = parentHeight - headerHeight;\n            const tbodyHeight = Math.min(maxHeightLessHeader, fullTBodyHeight) - 3; // 3 for the borders\n\n            setState((prevState) => ({ ...prevState, tbodyHeight }));\n        }\n    }, [parentHeight, parsedData]);\n\n    // Makes sure rows are rendered before setting the renderer as loaded\n    useEffect(() => {\n        let tid: NodeJS.Timeout;\n\n        if (rowRef.current.length === parsedData.length) {\n            tid = setTimeout(() => {\n                setTableLoaded(true);\n            }, 50); // Delay a bit to make sure the rows are rendered\n        }\n\n        return () => clearTimeout(tid);\n    }, [rowRef, parsedData]);\n\n    const table = useReactTable({\n        manualPagination: true,\n        data: parsedData,\n        columns,\n        getCoreRowModel: getCoreRowModel(),\n        getSortedRowModel: getSortedRowModel(),\n    });\n\n    return (\n        <div className={clsx(\"csv-view ellipsis\", { show: tableLoaded })} style={{ height: \"auto\" }}>\n            <table className=\"probe\">\n                <tbody>\n                    <tr ref={probeRef}>\n                        <td>dummy data</td>\n                    </tr>\n                </tbody>\n            </table>\n            <table {...listeners}>\n                <thead>\n                    {table.getHeaderGroups().map((headerGroup, index) => (\n                        <tr key={headerGroup.id} ref={headerRef} id={headerGroup.id} tabIndex={index}>\n                            {headerGroup.headers.map((header, index) => (\n                                <th\n                                    key={header.id}\n                                    colSpan={header.colSpan}\n                                    id={header.id}\n                                    tabIndex={index}\n                                    style={{ width: header.getSize() }}\n                                >\n                                    {header.isPlaceholder ? null : (\n                                        <div\n                                            {...{\n                                                className: header.column.getCanSort()\n                                                    ? \"inner cursor-pointer select-none ellipsis\"\n                                                    : \"\",\n                                                onClick: header.column.getToggleSortingHandler(),\n                                            }}\n                                        >\n                                            {flexRender(header.column.columnDef.header, header.getContext())}\n                                            {header.column.getIsSorted() === \"asc\" ? (\n                                                <i className=\"sort-icon fa-sharp fa-solid fa-sort-up\"></i>\n                                            ) : header.column.getIsSorted() === \"desc\" ? (\n                                                <i className=\"sort-icon fa-sharp fa-solid fa-sort-down\"></i>\n                                            ) : null}\n                                        </div>\n                                    )}\n                                </th>\n                            ))}\n                        </tr>\n                    ))}\n                </thead>\n                <tbody style={{ height: `${state.tbodyHeight}px` }} ref={tbodyRef}>\n                    {table.getRowModel().rows.map((row, index) => (\n                        <tr\n                            key={row.id}\n                            ref={(el) => {\n                                rowRef.current[index] = el;\n                            }}\n                            id={row.id}\n                            tabIndex={index}\n                        >\n                            {row.getVisibleCells().map((cell) => (\n                                <td className=\"ellipsis\" key={cell.id} id={cell.id} tabIndex={index}>\n                                    {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                                </td>\n                            ))}\n                        </tr>\n                    ))}\n                </tbody>\n            </table>\n        </div>\n    );\n};\n\nexport { CSVView };\n"
  },
  {
    "path": "frontend/app/view/preview/directorypreview.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.dir-table-container {\n    display: flex;\n    flex-direction: column;\n    height: 100%;\n    --min-row-width: 35rem;\n    .dir-table {\n        height: 100%;\n        width: 100%;\n        --col-size-size: 0.2rem;\n        display: flex;\n        flex-direction: column;\n\n        &:not([data-scroll-height=\"0\"]) .dir-table-head::after {\n            background: oklch(from var(--block-bg-color) calc(l + 0.5) c h);\n            backdrop-filter: blur(2px);\n            content: \"\";\n            z-index: -1;\n            position: absolute;\n            top: 0;\n            bottom: 0;\n            left: 0;\n            right: 0;\n        }\n\n        .dir-table-head {\n            position: sticky;\n            top: 0;\n            z-index: 10;\n            width: 100%;\n            min-width: fit-content;\n            border-bottom: 1px solid var(--border-color);\n\n            .dir-table-head-row {\n                display: flex;\n                min-width: var(--min-row-width);\n                padding: 4px 6px;\n                font-size: 0.75rem;\n\n                .dir-table-head-cell {\n                    flex: 0 0 auto;\n                    user-select: none;\n                }\n                .dir-table-head-cell:not(:first-child) {\n                    position: relative;\n                    display: flex;\n                    white-space: nowrap;\n                    overflow: hidden;\n\n                    .dir-table-head-cell-content {\n                        padding: 2px 4px;\n                        display: flex;\n                        gap: 0.3rem;\n                        flex: 1 1 auto;\n                        overflow-x: hidden;\n                        letter-spacing: -0.12px;\n\n                        .dir-table-head-direction {\n                            margin-right: 0.2rem;\n                            margin-top: 0.2rem;\n                        }\n\n                        .dir-table-head-size {\n                            align-self: flex-end;\n                        }\n                    }\n\n                    .dir-table-head-resize-box {\n                        width: 12px;\n                        display: flex;\n                        justify-content: center;\n                        flex: 0 0 auto;\n                        position: relative;\n                        &::before {\n                            content: \"\";\n                            position: absolute;\n                            left: 50%;\n                            top: 10%;\n                            height: 80%;\n                            width: 1px;\n                            background-color: var(--border-color);\n                            pointer-events: none;\n                        }\n                        .dir-table-head-resize {\n                            cursor: col-resize;\n                            user-select: none;\n                            -webkit-user-select: none;\n                            touch-action: none;\n                            width: 4px;\n                        }\n                    }\n                }\n            }\n        }\n\n        .dir-table-body {\n            display: flex;\n            flex-direction: column;\n            padding: 0 5px 5px 5px;\n\n            .dir-table-body-scroll-box {\n                position: relative;\n                .dummy {\n                    position: absolute;\n                    visibility: hidden;\n                }\n                .dir-table-body-row {\n                    display: flex;\n                    align-items: center;\n                    border-radius: 5px;\n                    padding: 0 6px;\n                    min-width: var(--min-row-width);\n\n                    &.focused {\n                        background-color: rgb(from var(--accent-color) r g b / 0.5);\n                        color: var(--main-text-color);\n\n                        .dir-table-body-cell {\n                            .dir-table-lastmod,\n                            .dir-table-modestr,\n                            .dir-table-size,\n                            .dir-table-type {\n                                color: var(--main-text-color);\n                            }\n                        }\n                    }\n\n                    &:focus {\n                        background-color: rgb(from var(--accent-color) r g b / 0.5);\n                        color: var(--main-text-color);\n\n                        .dir-table-body-cell {\n                            .dir-table-lastmod,\n                            .dir-table-modestr,\n                            .dir-table-size,\n                            .dir-table-type {\n                                color: var(--main-text-color);\n                            }\n                        }\n                    }\n\n                    &:nth-child(odd):not(.focused):not(:focus) {\n                        background-color: rgba(255, 255, 255, 0.06);\n                    }\n\n                    &:hover:not(:focus):not(.focused) {\n                        background-color: var(--highlight-bg-color);\n                    }\n\n                    .dir-table-body-cell {\n                        overflow: hidden;\n                        white-space: nowrap;\n                        padding: 0.25rem;\n                        cursor: default;\n                        font-size: 12px;\n                        flex: 0 0 auto;\n\n                        &.col-size {\n                            text-align: right;\n                        }\n\n                        .dir-table-lastmod,\n                        .dir-table-modestr,\n                        .dir-table-type {\n                            color: var(--secondary-text-color);\n                            margin-right: 12px;\n                        }\n\n                        .dir-table-modestr,\n                        .dir-table-size,\n                        .dir-table-lastmod {\n                            color: var(--secondary-text-color);\n                            font-family: Hack;\n                            font-size: 11px;\n                        }\n\n                        .dir-table-name {\n                            font-weight: 500;\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n\n.entry-manager-overlay {\n    display: flex;\n    flex-direction: column;\n    max-width: 90%;\n    max-height: fit-content;\n    padding: 10px;\n    gap: 10px;\n    border-radius: 4px;\n    border: 1px solid rgba(255, 255, 255, 0.15);\n    background: #212121;\n    box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3);\n\n    .entry-manager-buttons {\n        display: flex;\n        flex-direction: row;\n        gap: 10px;\n    }\n}\n"
  },
  {
    "path": "frontend/app/view/preview/entry-manager.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Button } from \"@/app/element/button\";\nimport { Input } from \"@/app/element/input\";\nimport React, { memo, useState } from \"react\";\n\nexport enum EntryManagerType {\n    NewFile = \"New File\",\n    NewDirectory = \"New Folder\",\n    EditName = \"Rename\",\n}\n\nexport type EntryManagerOverlayProps = {\n    forwardRef?: React.Ref<HTMLDivElement>;\n    entryManagerType: EntryManagerType;\n    startingValue?: string;\n    onSave: (newValue: string) => void;\n    onCancel?: () => void;\n    style?: React.CSSProperties;\n    getReferenceProps?: () => any;\n};\n\nexport const EntryManagerOverlay = memo(\n    ({\n        entryManagerType,\n        startingValue,\n        onSave,\n        onCancel,\n        forwardRef,\n        style,\n        getReferenceProps,\n    }: EntryManagerOverlayProps) => {\n        const [value, setValue] = useState(startingValue);\n        return (\n            <div className=\"entry-manager-overlay\" ref={forwardRef} style={style} {...(getReferenceProps?.() ?? {})}>\n                <div className=\"entry-manager-type\">{entryManagerType}</div>\n                <div className=\"entry-manager-input\">\n                    <Input\n                        value={value}\n                        onChange={setValue}\n                        autoFocus={true}\n                        onKeyDown={(e) => {\n                            if (e.key === \"Enter\") {\n                                e.preventDefault();\n                                e.stopPropagation();\n                                onSave(value);\n                            }\n                        }}\n                    />\n                </div>\n                <div className=\"entry-manager-buttons\">\n                    <Button className=\"py-[4px]\" onClick={() => onSave(value)}>\n                        Save\n                    </Button>\n                    <Button className=\"py-[4px] red outlined\" onClick={onCancel}>\n                        Cancel\n                    </Button>\n                </div>\n            </div>\n        );\n    }\n);\n\nEntryManagerOverlay.displayName = \"EntryManagerOverlay\";\n"
  },
  {
    "path": "frontend/app/view/preview/preview-directory-utils.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { fireAndForget, isBlank } from \"@/util/util\";\nimport dayjs from \"dayjs\";\nimport React from \"react\";\nimport { type PreviewModel } from \"./preview-model\";\n\nexport const recursiveError = \"recursive flag must be set for directory operations\";\nexport const overwriteError = \"set overwrite flag to delete the existing file\";\nexport const mergeError = \"set overwrite flag to delete the existing contents or set merge flag to merge the contents\";\n\nexport const displaySuffixes = {\n    B: \"b\",\n    kB: \"k\",\n    MB: \"m\",\n    GB: \"g\",\n    TB: \"t\",\n    KiB: \"k\",\n    MiB: \"m\",\n    GiB: \"g\",\n    TiB: \"t\",\n};\n\nexport function getBestUnit(bytes: number, si = false, sigfig = 3): string {\n    if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return \"-\";\n    if (bytes === 0) return \"0B\";\n\n    const units = si ? [\"kB\", \"MB\", \"GB\", \"TB\"] : [\"KiB\", \"MiB\", \"GiB\", \"TiB\"];\n    const divisor = si ? 1000 : 1024;\n\n    const idx = Math.min(Math.floor(Math.log(bytes) / Math.log(divisor)), units.length);\n    const unit = idx === 0 ? \"B\" : units[idx - 1];\n    const value = bytes / Math.pow(divisor, idx);\n\n    return `${parseFloat(value.toPrecision(sigfig))}${displaySuffixes[unit] ?? unit}`;\n}\n\nfunction padDay(day: number) {\n    return String(day).padStart(2, \" \");\n}\n\nexport function getLastModifiedTime(unixMillis: number): string {\n    const file = dayjs(unixMillis);\n    const now = dayjs();\n\n    const day = padDay(file.date());\n    const time = file.format(\"HH:mm\");\n\n    if (now.isSame(file, \"year\")) {\n        return `${file.format(\"MMM\")} ${day} ${time}`;\n    }\n\n    return `${file.format(\"YYYY-MM-DD\")}`;\n}\n\nconst iconRegex = /^[a-z0-9- ]+$/;\n\nexport function isIconValid(icon: string): boolean {\n    if (isBlank(icon)) {\n        return false;\n    }\n    return icon.match(iconRegex) != null;\n}\n\nexport function getSortIcon(sortType: string | boolean): React.ReactNode {\n    switch (sortType) {\n        case \"asc\":\n            return <i className=\"fa-solid fa-chevron-up dir-table-head-direction\"></i>;\n        case \"desc\":\n            return <i className=\"fa-solid fa-chevron-down dir-table-head-direction\"></i>;\n        default:\n            return null;\n    }\n}\n\nexport function cleanMimetype(input: string): string {\n    const truncated = input.split(\";\")[0];\n    return truncated.trim();\n}\n\nexport function handleRename(\n    model: PreviewModel,\n    path: string,\n    newPath: string,\n    isDir: boolean,\n    setErrorMsg: (msg: ErrorMsg) => void\n) {\n    fireAndForget(async () => {\n        try {\n            let srcuri = await model.formatRemoteUri(path, globalStore.get);\n            if (isDir) {\n                srcuri += \"/\";\n            }\n            await model.env.rpc.FileMoveCommand(TabRpcClient, {\n                srcuri,\n                desturi: await model.formatRemoteUri(newPath, globalStore.get),\n            });\n        } catch (e) {\n            const errorText = `${e}`;\n            console.warn(`Rename failed: ${errorText}`);\n            const errorMsg: ErrorMsg = {\n                status: \"Rename Failed\",\n                text: `${e}`,\n            };\n            setErrorMsg(errorMsg);\n        }\n        model.refreshCallback();\n    });\n}\n\nexport function handleFileDelete(\n    model: PreviewModel,\n    path: string,\n    recursive: boolean,\n    setErrorMsg: (msg: ErrorMsg) => void\n) {\n    fireAndForget(async () => {\n        const formattedPath = await model.formatRemoteUri(path, globalStore.get);\n        try {\n            await model.env.rpc.FileDeleteCommand(TabRpcClient, {\n                path: formattedPath,\n                recursive,\n            });\n        } catch (e) {\n            const errorText = `${e}`;\n            console.warn(`Delete failed: ${errorText}`);\n            let errorMsg: ErrorMsg;\n            if (errorText.includes(recursiveError) && !recursive) {\n                errorMsg = {\n                    status: \"Confirm Delete Directory\",\n                    text: \"Deleting a directory requires the recursive flag. Proceed?\",\n                    level: \"warning\",\n                    buttons: [\n                        {\n                            text: \"Delete Recursively\",\n                            onClick: () => handleFileDelete(model, path, true, setErrorMsg),\n                        },\n                    ],\n                };\n            } else {\n                errorMsg = {\n                    status: \"Delete Failed\",\n                    text: `${e}`,\n                };\n            }\n            setErrorMsg(errorMsg);\n        }\n        model.refreshCallback();\n    });\n}\n\nexport function makeDirectoryDefaultMenuItems(model: PreviewModel): ContextMenuItem[] {\n    const defaultSort = globalStore.get(model.env.getSettingsKeyAtom(\"preview:defaultsort\")) ?? \"name\";\n    const showHiddenFiles = globalStore.get(model.showHiddenFiles) ?? true;\n    return [\n        {\n            label: \"Directory Sort Order\",\n            submenu: [\n                {\n                    label: \"Name\",\n                    type: \"checkbox\",\n                    checked: defaultSort === \"name\",\n                    click: () =>\n                        fireAndForget(() =>\n                            model.env.rpc.SetConfigCommand(TabRpcClient, { \"preview:defaultsort\": \"name\" })\n                        ),\n                },\n                {\n                    label: \"Last Modified\",\n                    type: \"checkbox\",\n                    checked: defaultSort === \"modtime\",\n                    click: () =>\n                        fireAndForget(() =>\n                            model.env.rpc.SetConfigCommand(TabRpcClient, { \"preview:defaultsort\": \"modtime\" })\n                        ),\n                },\n            ],\n        },\n        {\n            label: \"Show Hidden Files\",\n            submenu: [\n                {\n                    label: \"On\",\n                    type: \"checkbox\",\n                    checked: showHiddenFiles,\n                    click: () => {\n                        globalStore.set(model.showHiddenFiles, true);\n                        fireAndForget(() =>\n                            model.env.rpc.SetConfigCommand(TabRpcClient, { \"preview:showhiddenfiles\": true })\n                        );\n                    },\n                },\n                {\n                    label: \"Off\",\n                    type: \"checkbox\",\n                    checked: !showHiddenFiles,\n                    click: () => {\n                        globalStore.set(model.showHiddenFiles, false);\n                        fireAndForget(() =>\n                            model.env.rpc.SetConfigCommand(TabRpcClient, { \"preview:showhiddenfiles\": false })\n                        );\n                    },\n                },\n            ],\n        },\n    ];\n}\n"
  },
  {
    "path": "frontend/app/view/preview/preview-directory.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { ContextMenuModel } from \"@/app/store/contextmenu\";\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { checkKeyPressed, isCharacterKeyEvent } from \"@/util/keyutil\";\nimport { PLATFORM, PlatformMacOS } from \"@/util/platformutil\";\nimport { addOpenMenuItems } from \"@/util/previewutil\";\nimport { fireAndForget } from \"@/util/util\";\nimport { formatRemoteUri } from \"@/util/waveutil\";\nimport { offset, useDismiss, useFloating, useInteractions } from \"@floating-ui/react\";\nimport {\n    Header,\n    Row,\n    RowData,\n    Table,\n    createColumnHelper,\n    flexRender,\n    getCoreRowModel,\n    getSortedRowModel,\n    useReactTable,\n} from \"@tanstack/react-table\";\nimport clsx from \"clsx\";\nimport { PrimitiveAtom, atom, useAtom, useAtomValue, useSetAtom } from \"jotai\";\nimport { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from \"overlayscrollbars-react\";\nimport React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useDrag, useDrop } from \"react-dnd\";\nimport { quote as shellQuote } from \"shell-quote\";\nimport { debounce } from \"throttle-debounce\";\nimport \"./directorypreview.scss\";\nimport { EntryManagerOverlay, EntryManagerOverlayProps, EntryManagerType } from \"./entry-manager\";\nimport {\n    cleanMimetype,\n    getBestUnit,\n    getLastModifiedTime,\n    getSortIcon,\n    handleFileDelete,\n    handleRename,\n    isIconValid,\n    makeDirectoryDefaultMenuItems,\n    mergeError,\n    overwriteError,\n} from \"./preview-directory-utils\";\nimport { type PreviewModel } from \"./preview-model\";\nimport type { PreviewEnv } from \"./previewenv\";\n\nconst PageJumpSize = 20;\n\ninterface DirectoryTableHeaderCellProps {\n    header: Header<FileInfo, unknown>;\n}\n\nfunction DirectoryTableHeaderCell({ header }: DirectoryTableHeaderCellProps) {\n    return (\n        <div\n            className=\"dir-table-head-cell\"\n            key={header.id}\n            style={{ width: `calc(var(--header-${header.id}-size) * 1px)` }}\n        >\n            <div className=\"dir-table-head-cell-content\" onClick={() => header.column.toggleSorting()}>\n                {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}\n                {getSortIcon(header.column.getIsSorted())}\n            </div>\n            <div className=\"dir-table-head-resize-box\">\n                <div\n                    className=\"dir-table-head-resize\"\n                    onMouseDown={header.getResizeHandler()}\n                    onTouchStart={header.getResizeHandler()}\n                />\n            </div>\n        </div>\n    );\n}\n\ndeclare module \"@tanstack/react-table\" {\n    interface TableMeta<TData extends RowData> {\n        updateName: (path: string, isDir: boolean) => void;\n        newFile: () => void;\n        newDirectory: () => void;\n    }\n}\n\ninterface DirectoryTableProps {\n    model: PreviewModel;\n    data: FileInfo[];\n    search: string;\n    focusIndex: number;\n    setFocusIndex: (_: number) => void;\n    setSearch: (_: string) => void;\n    setSelectedPath: (_: string) => void;\n    setRefreshVersion: React.Dispatch<React.SetStateAction<number>>;\n    entryManagerOverlayPropsAtom: PrimitiveAtom<EntryManagerOverlayProps>;\n    newFile: () => void;\n    newDirectory: () => void;\n}\n\nconst columnHelper = createColumnHelper<FileInfo>();\n\nfunction DirectoryTable({\n    model,\n    data,\n    search,\n    focusIndex,\n    setFocusIndex,\n    setSearch,\n    setSelectedPath,\n    setRefreshVersion,\n    entryManagerOverlayPropsAtom,\n    newFile,\n    newDirectory,\n}: DirectoryTableProps) {\n    const env = useWaveEnv<PreviewEnv>();\n    const searchActive = useAtomValue(model.directorySearchActive);\n    const fullConfig = useAtomValue(env.atoms.fullConfigAtom);\n    const defaultSort = useAtomValue(env.getSettingsKeyAtom(\"preview:defaultsort\")) ?? \"name\";\n    const setErrorMsg = useSetAtom(model.errorMsgAtom);\n    const getIconFromMimeType = useCallback(\n        (mimeType: string): string => {\n            while (mimeType.length > 0) {\n                const icon = fullConfig.mimetypes?.[mimeType]?.icon ?? null;\n                if (isIconValid(icon)) {\n                    return `fa fa-solid fa-${icon} fa-fw`;\n                }\n                mimeType = mimeType.slice(0, -1);\n            }\n            return \"fa fa-solid fa-file fa-fw\";\n        },\n        [fullConfig.mimetypes]\n    );\n    const getIconColor = useCallback(\n        (mimeType: string): string => fullConfig.mimetypes?.[mimeType]?.color ?? \"inherit\",\n        [fullConfig.mimetypes]\n    );\n    const columns = useMemo(\n        () => [\n            columnHelper.accessor(\"mimetype\", {\n                cell: (info) => (\n                    <i\n                        className={getIconFromMimeType(info.getValue() ?? \"\")}\n                        style={{ color: getIconColor(info.getValue() ?? \"\") }}\n                    ></i>\n                ),\n                header: () => <span></span>,\n                id: \"logo\",\n                size: 25,\n                enableSorting: false,\n            }),\n            columnHelper.accessor(\"name\", {\n                cell: (info) => <span className=\"dir-table-name ellipsis\">{info.getValue()}</span>,\n                header: () => <span className=\"dir-table-head-name\">Name</span>,\n                sortingFn: \"alphanumeric\",\n                size: 200,\n                minSize: 90,\n            }),\n            columnHelper.accessor(\"modestr\", {\n                cell: (info) => <span className=\"dir-table-modestr\">{info.getValue()}</span>,\n                header: () => <span>Perm</span>,\n                size: 91,\n                minSize: 90,\n                sortingFn: \"alphanumeric\",\n            }),\n            columnHelper.accessor(\"modtime\", {\n                cell: (info) => <span className=\"dir-table-lastmod\">{getLastModifiedTime(info.getValue())}</span>,\n                header: () => <span>Last Modified</span>,\n                size: 91,\n                minSize: 65,\n                sortingFn: \"datetime\",\n            }),\n            columnHelper.accessor(\"size\", {\n                cell: (info) => <span className=\"dir-table-size\">{getBestUnit(info.getValue())}</span>,\n                header: () => <span className=\"dir-table-head-size\">Size</span>,\n                size: 55,\n                minSize: 50,\n                sortingFn: \"auto\",\n            }),\n            columnHelper.accessor(\"mimetype\", {\n                cell: (info) => <span className=\"dir-table-type ellipsis\">{cleanMimetype(info.getValue() ?? \"\")}</span>,\n                header: () => <span className=\"dir-table-head-type\">Type</span>,\n                size: 97,\n                minSize: 97,\n                sortingFn: \"alphanumeric\",\n            }),\n            columnHelper.accessor(\"path\", {}),\n        ],\n        [fullConfig]\n    );\n\n    const setEntryManagerProps = useSetAtom(entryManagerOverlayPropsAtom);\n\n    const updateName = useCallback(\n        (path: string, isDir: boolean) => {\n            const fileName = path.split(\"/\").at(-1);\n            setEntryManagerProps({\n                entryManagerType: EntryManagerType.EditName,\n                startingValue: fileName,\n                onSave: (newName: string) => {\n                    let newPath: string;\n                    if (newName !== fileName) {\n                        const lastInstance = path.lastIndexOf(fileName);\n                        newPath = path.substring(0, lastInstance) + newName;\n                        console.log(`replacing ${fileName} with ${newName}: ${path}`);\n                        handleRename(model, path, newPath, isDir, setErrorMsg);\n                    }\n                    setEntryManagerProps(undefined);\n                },\n            });\n        },\n        [model, setErrorMsg]\n    );\n\n    const initialSorting = defaultSort === \"modtime\" ? [{ id: \"modtime\", desc: true }] : [{ id: \"name\", desc: false }];\n\n    const table = useReactTable({\n        data,\n        columns,\n        columnResizeMode: \"onChange\",\n        getSortedRowModel: getSortedRowModel(),\n        getCoreRowModel: getCoreRowModel(),\n\n        initialState: {\n            sorting: initialSorting,\n            columnVisibility: {\n                path: false,\n            },\n        },\n        enableMultiSort: false,\n        enableSortingRemoval: false,\n        meta: {\n            updateName,\n            newFile,\n            newDirectory,\n        },\n    });\n    const sortingState = table.getState().sorting;\n    useEffect(() => {\n        const allRows = table.getRowModel()?.flatRows || [];\n        setSelectedPath((allRows[focusIndex]?.getValue(\"path\") as string) ?? null);\n    }, [focusIndex, data, setSelectedPath, sortingState]);\n\n    const columnSizeVars = useMemo(() => {\n        const headers = table.getFlatHeaders();\n        const colSizes: { [key: string]: number } = {};\n        for (let i = 0; i < headers.length; i++) {\n            const header = headers[i]!;\n            colSizes[`--header-${header.id}-size`] = header.getSize();\n            colSizes[`--col-${header.column.id}-size`] = header.column.getSize();\n        }\n        return colSizes;\n    }, [table.getState().columnSizingInfo]);\n\n    const osRef = useRef<OverlayScrollbarsComponentRef>(null);\n    const bodyRef = useRef<HTMLDivElement>(null);\n    const [scrollHeight, setScrollHeight] = useState(0);\n\n    const onScroll = useCallback(\n        debounce(2, () => {\n            setScrollHeight(osRef.current.osInstance().elements().viewport.scrollTop);\n        }),\n        []\n    );\n\n    const TableComponent = table.getState().columnSizingInfo.isResizingColumn ? MemoizedTableBody : TableBody;\n\n    return (\n        <OverlayScrollbarsComponent\n            options={{ scrollbars: { autoHide: \"leave\" } }}\n            events={{ scroll: onScroll }}\n            className=\"dir-table\"\n            style={{ ...columnSizeVars }}\n            ref={osRef}\n            data-scroll-height={scrollHeight}\n        >\n            <div className=\"dir-table-head\">\n                {table.getHeaderGroups().map((headerGroup) => (\n                    <div className=\"dir-table-head-row\" key={headerGroup.id}>\n                        {headerGroup.headers.map((header) => (\n                            <DirectoryTableHeaderCell key={header.id} header={header} />\n                        ))}\n                    </div>\n                ))}\n            </div>\n            <TableComponent\n                bodyRef={bodyRef}\n                model={model}\n                data={data}\n                table={table}\n                search={search}\n                focusIndex={focusIndex}\n                setFocusIndex={setFocusIndex}\n                setSearch={setSearch}\n                setSelectedPath={setSelectedPath}\n                setRefreshVersion={setRefreshVersion}\n                osRef={osRef.current}\n            />\n        </OverlayScrollbarsComponent>\n    );\n}\n\ninterface TableBodyProps {\n    bodyRef: React.RefObject<HTMLDivElement>;\n    model: PreviewModel;\n    data: Array<FileInfo>;\n    table: Table<FileInfo>;\n    search: string;\n    focusIndex: number;\n    setFocusIndex: (_: number) => void;\n    setSearch: (_: string) => void;\n    setSelectedPath: (_: string) => void;\n    setRefreshVersion: React.Dispatch<React.SetStateAction<number>>;\n    osRef: OverlayScrollbarsComponentRef;\n}\n\nfunction TableBody({\n    bodyRef,\n    model,\n    table,\n    search,\n    focusIndex,\n    setFocusIndex,\n    setSearch,\n    setRefreshVersion,\n    osRef,\n}: TableBodyProps) {\n    const searchActive = useAtomValue(model.directorySearchActive);\n    const dummyLineRef = useRef<HTMLDivElement>(null);\n    const warningBoxRef = useRef<HTMLDivElement>(null);\n    const conn = useAtomValue(model.connection);\n    const setErrorMsg = useSetAtom(model.errorMsgAtom);\n\n    useEffect(() => {\n        if (focusIndex === null || !bodyRef.current || !osRef) {\n            return;\n        }\n\n        const rowElement = bodyRef.current.querySelector(`[data-rowindex=\"${focusIndex}\"]`) as HTMLDivElement;\n        if (!rowElement) {\n            return;\n        }\n\n        const viewport = osRef.osInstance().elements().viewport;\n        const viewportHeight = viewport.offsetHeight;\n        const rowRect = rowElement.getBoundingClientRect();\n        const parentRect = viewport.getBoundingClientRect();\n        const viewportScrollTop = viewport.scrollTop;\n        const rowTopRelativeToViewport = rowRect.top - parentRect.top + viewport.scrollTop;\n        const rowBottomRelativeToViewport = rowRect.bottom - parentRect.top + viewport.scrollTop;\n\n        if (rowTopRelativeToViewport - 30 < viewportScrollTop) {\n            // Row is above the visible area\n            let topVal = rowTopRelativeToViewport - 30;\n            if (topVal < 0) {\n                topVal = 0;\n            }\n            viewport.scrollTo({ top: topVal });\n        } else if (rowBottomRelativeToViewport + 5 > viewportScrollTop + viewportHeight) {\n            // Row is below the visible area\n            const topVal = rowBottomRelativeToViewport - viewportHeight + 5;\n            viewport.scrollTo({ top: topVal });\n        }\n    }, [focusIndex]);\n\n    const handleFileContextMenu = useCallback(\n        async (e: any, finfo: FileInfo) => {\n            e.preventDefault();\n            e.stopPropagation();\n            if (finfo == null) {\n                return;\n            }\n            const fileName = finfo.path.split(\"/\").pop();\n            const menu: ContextMenuItem[] = [\n                {\n                    label: \"New File\",\n                    click: () => {\n                        table.options.meta.newFile();\n                    },\n                },\n                {\n                    label: \"New Folder\",\n                    click: () => {\n                        table.options.meta.newDirectory();\n                    },\n                },\n                {\n                    label: \"Rename\",\n                    click: () => {\n                        table.options.meta.updateName(finfo.path, finfo.isdir);\n                    },\n                },\n                {\n                    type: \"separator\",\n                },\n                {\n                    label: \"Copy File Name\",\n                    click: () => fireAndForget(() => navigator.clipboard.writeText(fileName)),\n                },\n                {\n                    label: \"Copy Full File Name\",\n                    click: () => fireAndForget(() => navigator.clipboard.writeText(finfo.path)),\n                },\n                {\n                    label: \"Copy File Name (Shell Quoted)\",\n                    click: () => fireAndForget(() => navigator.clipboard.writeText(shellQuote([fileName]))),\n                },\n                {\n                    label: \"Copy Full File Name (Shell Quoted)\",\n                    click: () => fireAndForget(() => navigator.clipboard.writeText(shellQuote([finfo.path]))),\n                },\n            ];\n            addOpenMenuItems(menu, conn, finfo);\n            menu.push(\n                {\n                    type: \"separator\",\n                },\n                {\n                    label: \"Default Settings\",\n                    submenu: makeDirectoryDefaultMenuItems(model),\n                },\n                {\n                    type: \"separator\",\n                },\n                {\n                    label: \"Delete\",\n                    click: () => handleFileDelete(model, finfo.path, false, setErrorMsg),\n                }\n            );\n            ContextMenuModel.getInstance().showContextMenu(menu, e);\n        },\n        [setRefreshVersion, conn]\n    );\n\n    const allRows = table.getRowModel().flatRows;\n    const dotdotRow = allRows.find((row) => row.getValue(\"name\") === \"..\");\n    const otherRows = allRows.filter((row) => row.getValue(\"name\") !== \"..\");\n\n    return (\n        <div className=\"dir-table-body\" ref={bodyRef}>\n            {(searchActive || search !== \"\") && (\n                <div className=\"flex rounded-[3px] py-1 px-2 bg-warning text-black\" ref={warningBoxRef}>\n                    <span>{search === \"\" ? \"Type to search (Esc to cancel)\" : `Searching for \"${search}\"`}</span>\n                    <div\n                        className=\"ml-auto bg-transparent flex justify-center items-center flex-col p-0.5 rounded-md hover:bg-hoverbg focus:bg-hoverbg focus-within:bg-hoverbg cursor-pointer\"\n                        onClick={() => {\n                            setSearch(\"\");\n                            globalStore.set(model.directorySearchActive, false);\n                        }}\n                    >\n                        <i className=\"fa-solid fa-xmark\" />\n                        <input\n                            type=\"text\"\n                            value={search}\n                            onChange={() => {}}\n                            className=\"w-0 h-0 opacity-0 p-0 border-none pointer-events-none\"\n                        />\n                    </div>\n                </div>\n            )}\n            <div className=\"dir-table-body-scroll-box\">\n                <div className=\"dummy dir-table-body-row\" ref={dummyLineRef}>\n                    <div className=\"dir-table-body-cell\">dummy-data</div>\n                </div>\n                {dotdotRow && (\n                    <TableRow\n                        model={model}\n                        row={dotdotRow}\n                        focusIndex={focusIndex}\n                        setFocusIndex={setFocusIndex}\n                        setSearch={setSearch}\n                        idx={0}\n                        handleFileContextMenu={handleFileContextMenu}\n                        key=\"dotdot\"\n                    />\n                )}\n                {otherRows.map((row, idx) => (\n                    <TableRow\n                        model={model}\n                        row={row}\n                        focusIndex={focusIndex}\n                        setFocusIndex={setFocusIndex}\n                        setSearch={setSearch}\n                        idx={dotdotRow ? idx + 1 : idx}\n                        handleFileContextMenu={handleFileContextMenu}\n                        key={idx}\n                    />\n                ))}\n            </div>\n        </div>\n    );\n}\n\ntype TableRowProps = {\n    model: PreviewModel;\n    row: Row<FileInfo>;\n    focusIndex: number;\n    setFocusIndex: (_: number) => void;\n    setSearch: (_: string) => void;\n    idx: number;\n    handleFileContextMenu: (e: any, finfo: FileInfo) => Promise<void>;\n};\n\nfunction TableRow({ model, row, focusIndex, setFocusIndex, setSearch, idx, handleFileContextMenu }: TableRowProps) {\n    const dirPath = useAtomValue(model.statFilePath);\n    const connection = useAtomValue(model.connection);\n\n    const dragItem: DraggedFile = {\n        relName: row.getValue(\"name\") as string,\n        absParent: dirPath,\n        uri: formatRemoteUri(row.getValue(\"path\") as string, connection),\n        isDir: row.original.isdir,\n    };\n    const [_, drag] = useDrag(\n        () => ({\n            type: \"FILE_ITEM\",\n            canDrag: true,\n            item: () => dragItem,\n        }),\n        [dragItem]\n    );\n\n    const dragRef = useCallback(\n        (node: HTMLDivElement | null) => {\n            drag(node);\n        },\n        [drag]\n    );\n\n    return (\n        <div\n            className={clsx(\"dir-table-body-row\", { focused: focusIndex === idx })}\n            data-rowindex={idx}\n            onDoubleClick={() => {\n                const newFileName = row.getValue(\"path\") as string;\n                model.goHistory(newFileName);\n                setSearch(\"\");\n                globalStore.set(model.directorySearchActive, false);\n            }}\n            onClick={() => setFocusIndex(idx)}\n            onContextMenu={(e) => handleFileContextMenu(e, row.original)}\n            ref={dragRef}\n        >\n            {row.getVisibleCells().map((cell) => (\n                <div\n                    className={clsx(\"dir-table-body-cell\", \"col-\" + cell.column.id)}\n                    key={cell.id}\n                    style={{ width: `calc(var(--col-${cell.column.id}-size) * 1px)` }}\n                >\n                    {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                </div>\n            ))}\n        </div>\n    );\n}\n\nconst MemoizedTableBody = React.memo(\n    TableBody,\n    (prev, next) => prev.table.options.data == next.table.options.data\n) as typeof TableBody;\n\ninterface DirectoryPreviewProps {\n    model: PreviewModel;\n}\n\nfunction DirectoryPreview({ model }: DirectoryPreviewProps) {\n    const env = useWaveEnv<PreviewEnv>();\n    const [searchText, setSearchText] = useState(\"\");\n    const [focusIndex, setFocusIndex] = useState(0);\n    const [unfilteredData, setUnfilteredData] = useState<FileInfo[]>([]);\n    const showHiddenFiles = useAtomValue(model.showHiddenFiles);\n    const [selectedPath, setSelectedPath] = useState(\"\");\n    const [refreshVersion, setRefreshVersion] = useAtom(model.refreshVersion);\n    const conn = useAtomValue(model.connection);\n    const blockData = useAtomValue(model.blockAtom);\n    const finfo = useAtomValue(model.statFile);\n    const dirPath = finfo?.path;\n    const setErrorMsg = useSetAtom(model.errorMsgAtom);\n\n    useEffect(() => {\n        model.refreshCallback = () => {\n            setRefreshVersion((refreshVersion) => refreshVersion + 1);\n        };\n        return () => {\n            model.refreshCallback = null;\n        };\n    }, [setRefreshVersion]);\n\n    useEffect(\n        () =>\n            fireAndForget(async () => {\n                let entries: FileInfo[];\n                try {\n                    const file = await env.rpc.FileReadCommand(\n                        TabRpcClient,\n                        {\n                            info: {\n                                path: await model.formatRemoteUri(dirPath, globalStore.get),\n                            },\n                        },\n                        null\n                    );\n                    entries = file.entries ?? [];\n                    if (file?.info && file.info.dir && file.info?.path !== file.info?.dir) {\n                        entries.unshift({\n                            name: \"..\",\n                            path: file?.info?.dir,\n                            isdir: true,\n                            modtime: new Date().getTime(),\n                            mimetype: \"directory\",\n                        });\n                    }\n                } catch (e) {\n                    setErrorMsg({\n                        status: \"Cannot Read Directory\",\n                        text: `${e}`,\n                    });\n                }\n                setUnfilteredData(entries);\n            }),\n        [conn, dirPath, refreshVersion]\n    );\n\n    const filteredData = useMemo(\n        () =>\n            unfilteredData?.filter((fileInfo) => {\n                if (fileInfo.name == null) {\n                    console.log(\"fileInfo.name is null\", fileInfo);\n                    return false;\n                }\n                if (!showHiddenFiles && fileInfo.name.startsWith(\".\") && fileInfo.name != \"..\") {\n                    return false;\n                }\n                return fileInfo.name.toLowerCase().includes(searchText);\n            }) ?? [],\n        [unfilteredData, showHiddenFiles, searchText]\n    );\n\n    useEffect(() => {\n        model.directoryKeyDownHandler = (waveEvent: WaveKeyboardEvent): boolean => {\n            if (checkKeyPressed(waveEvent, \"Cmd:f\")) {\n                globalStore.set(model.directorySearchActive, true);\n                return true;\n            }\n            if (checkKeyPressed(waveEvent, \"Escape\")) {\n                setSearchText(\"\");\n                globalStore.set(model.directorySearchActive, false);\n                return;\n            }\n            if (checkKeyPressed(waveEvent, \"ArrowUp\")) {\n                setFocusIndex((idx) => Math.max(idx - 1, 0));\n                return true;\n            }\n            if (checkKeyPressed(waveEvent, \"ArrowDown\")) {\n                setFocusIndex((idx) => Math.min(idx + 1, filteredData.length - 1));\n                return true;\n            }\n            if (checkKeyPressed(waveEvent, \"PageUp\")) {\n                setFocusIndex((idx) => Math.max(idx - PageJumpSize, 0));\n                return true;\n            }\n            if (checkKeyPressed(waveEvent, \"PageDown\")) {\n                setFocusIndex((idx) => Math.min(idx + PageJumpSize, filteredData.length - 1));\n                return true;\n            }\n            if (checkKeyPressed(waveEvent, \"Enter\")) {\n                if (filteredData.length == 0) {\n                    return;\n                }\n                model.goHistory(selectedPath);\n                setSearchText(\"\");\n                globalStore.set(model.directorySearchActive, false);\n                return true;\n            }\n            if (checkKeyPressed(waveEvent, \"Backspace\")) {\n                if (searchText.length == 0) {\n                    return true;\n                }\n                setSearchText((current) => current.slice(0, -1));\n                return true;\n            }\n            if (\n                checkKeyPressed(waveEvent, \"Space\") &&\n                searchText == \"\" &&\n                PLATFORM == PlatformMacOS &&\n                !blockData?.meta?.connection\n            ) {\n                env.electron.onQuicklook(selectedPath);\n                return true;\n            }\n            if (isCharacterKeyEvent(waveEvent)) {\n                setSearchText((current) => current + waveEvent.key);\n                return true;\n            }\n            return false;\n        };\n        return () => {\n            model.directoryKeyDownHandler = null;\n        };\n    }, [filteredData, selectedPath, searchText]);\n\n    useEffect(() => {\n        if (filteredData.length != 0 && focusIndex > filteredData.length - 1) {\n            setFocusIndex(filteredData.length - 1);\n        }\n    }, [filteredData]);\n\n    const entryManagerPropsAtom = useState(\n        atom<EntryManagerOverlayProps>(null) as PrimitiveAtom<EntryManagerOverlayProps>\n    )[0];\n    const [entryManagerProps, setEntryManagerProps] = useAtom(entryManagerPropsAtom);\n\n    const { refs, floatingStyles, context } = useFloating({\n        open: !!entryManagerProps,\n        onOpenChange: () => setEntryManagerProps(undefined),\n        middleware: [offset(({ rects }) => -rects.reference.height / 2 - rects.floating.height / 2)],\n    });\n\n    const handleDropCopy = useCallback(\n        async (data: CommandFileCopyData, isDir: boolean) => {\n            try {\n                await env.rpc.FileCopyCommand(TabRpcClient, data, { timeout: data.opts.timeout });\n            } catch (e) {\n                console.warn(\"Copy failed:\", e);\n                const copyError = `${e}`;\n                const allowRetry = copyError.includes(overwriteError) || copyError.includes(mergeError);\n                let errorMsg: ErrorMsg;\n                if (allowRetry) {\n                    errorMsg = {\n                        status: \"Confirm Overwrite File(s)\",\n                        text: \"This copy operation will overwrite an existing file. Would you like to continue?\",\n                        level: \"warning\",\n                        buttons: [\n                            {\n                                text: \"Delete Then Copy\",\n                                onClick: async () => {\n                                    data.opts.overwrite = true;\n                                    await handleDropCopy(data, isDir);\n                                },\n                            },\n                            {\n                                text: \"Sync\",\n                                onClick: async () => {\n                                    data.opts.merge = true;\n                                    await handleDropCopy(data, isDir);\n                                },\n                            },\n                        ],\n                    };\n                } else {\n                    errorMsg = {\n                        status: \"Copy Failed\",\n                        text: copyError,\n                        level: \"error\",\n                    };\n                }\n                setErrorMsg(errorMsg);\n            }\n            model.refreshCallback();\n        },\n        [model.refreshCallback]\n    );\n\n    const [, drop] = useDrop(\n        () => ({\n            accept: \"FILE_ITEM\", //a name of file drop type\n            canDrop: (_, monitor) => {\n                const dragItem = monitor.getItem<DraggedFile>();\n                // drop if not current dir is the parent directory of the dragged item\n                // requires absolute path\n                if (monitor.isOver({ shallow: false }) && dragItem.absParent !== dirPath) {\n                    return true;\n                }\n                return false;\n            },\n            drop: async (draggedFile: DraggedFile, monitor) => {\n                if (!monitor.didDrop()) {\n                    const timeoutYear = 31536000000; // one year\n                    const opts: FileCopyOpts = {\n                        timeout: timeoutYear,\n                    };\n                    const desturi = await model.formatRemoteUri(dirPath, globalStore.get);\n                    const data: CommandFileCopyData = {\n                        srcuri: draggedFile.uri,\n                        desturi,\n                        opts,\n                    };\n                    await handleDropCopy(data, draggedFile.isDir);\n                }\n            },\n            // TODO: mabe add a hover option?\n        }),\n        [dirPath, model.formatRemoteUri, model.refreshCallback]\n    );\n\n    useEffect(() => {\n        drop(refs.reference);\n    }, [refs.reference]);\n\n    const dismiss = useDismiss(context);\n    const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);\n\n    const newFile = useCallback(() => {\n        setEntryManagerProps({\n            entryManagerType: EntryManagerType.NewFile,\n            onSave: (newName: string) => {\n                console.log(`newFile: ${newName}`);\n                fireAndForget(async () => {\n                    await env.rpc.FileCreateCommand(\n                        TabRpcClient,\n                        {\n                            info: {\n                                path: await model.formatRemoteUri(`${dirPath}/${newName}`, globalStore.get),\n                            },\n                        },\n                        null\n                    );\n                    model.refreshCallback();\n                });\n                setEntryManagerProps(undefined);\n            },\n        });\n    }, [dirPath]);\n    const newDirectory = useCallback(() => {\n        setEntryManagerProps({\n            entryManagerType: EntryManagerType.NewDirectory,\n            onSave: (newName: string) => {\n                console.log(`newDirectory: ${newName}`);\n                fireAndForget(async () => {\n                    await env.rpc.FileMkdirCommand(TabRpcClient, {\n                        info: {\n                            path: await model.formatRemoteUri(`${dirPath}/${newName}`, globalStore.get),\n                        },\n                    });\n                    model.refreshCallback();\n                });\n                setEntryManagerProps(undefined);\n            },\n        });\n    }, [dirPath]);\n\n    const handleFileContextMenu = useCallback(\n        (e: any) => {\n            e.preventDefault();\n            e.stopPropagation();\n            const menu: ContextMenuItem[] = [\n                {\n                    label: \"New File\",\n                    click: () => {\n                        newFile();\n                    },\n                },\n                {\n                    label: \"New Folder\",\n                    click: () => {\n                        newDirectory();\n                    },\n                },\n                {\n                    type: \"separator\",\n                },\n            ];\n            addOpenMenuItems(menu, conn, finfo);\n\n            ContextMenuModel.getInstance().showContextMenu(menu, e);\n        },\n        [setRefreshVersion, conn, newFile, newDirectory, dirPath]\n    );\n\n    return (\n        <Fragment>\n            <div\n                ref={refs.setReference}\n                className=\"dir-table-container\"\n                onChangeCapture={(e) => {\n                    const event = e as React.ChangeEvent<HTMLInputElement>;\n                    if (!entryManagerProps) {\n                        setSearchText(event.target.value.toLowerCase());\n                    }\n                }}\n                {...getReferenceProps()}\n                onContextMenu={(e) => handleFileContextMenu(e)}\n                onClick={() => setEntryManagerProps(undefined)}\n            >\n                <DirectoryTable\n                    model={model}\n                    data={filteredData}\n                    search={searchText}\n                    focusIndex={focusIndex}\n                    setFocusIndex={setFocusIndex}\n                    setSearch={setSearchText}\n                    setSelectedPath={setSelectedPath}\n                    setRefreshVersion={setRefreshVersion}\n                    entryManagerOverlayPropsAtom={entryManagerPropsAtom}\n                    newFile={newFile}\n                    newDirectory={newDirectory}\n                />\n            </div>\n            {entryManagerProps && (\n                <EntryManagerOverlay\n                    {...entryManagerProps}\n                    forwardRef={refs.setFloating}\n                    style={floatingStyles}\n                    getReferenceProps={getFloatingProps}\n                    onCancel={() => setEntryManagerProps(undefined)}\n                />\n            )}\n        </Fragment>\n    );\n}\n\nexport { DirectoryPreview };\n"
  },
  {
    "path": "frontend/app/view/preview/preview-edit.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { tryReinjectKey } from \"@/app/store/keymodel\";\nimport { CodeEditor } from \"@/app/view/codeeditor/codeeditor\";\nimport { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from \"@/util/keyutil\";\nimport { fireAndForget } from \"@/util/util\";\nimport { useAtomValue, useSetAtom } from \"jotai\";\nimport type * as MonacoTypes from \"monaco-editor\";\nimport * as monaco from \"monaco-editor\";\nimport { useEffect } from \"react\";\nimport type { SpecializedViewProps } from \"./preview\";\n\nexport const shellFileMap: Record<string, string> = {\n    \".bashrc\": \"shell\",\n    \".bash_profile\": \"shell\",\n    \".bash_login\": \"shell\",\n    \".bash_logout\": \"shell\",\n    \".profile\": \"shell\",\n    \".zshrc\": \"shell\",\n    \".zprofile\": \"shell\",\n    \".zshenv\": \"shell\",\n    \".zlogin\": \"shell\",\n    \".zlogout\": \"shell\",\n    \".kshrc\": \"shell\",\n    \".cshrc\": \"shell\",\n    \".tcshrc\": \"shell\",\n    \".xonshrc\": \"python\",\n    \".shrc\": \"shell\",\n    \".aliases\": \"shell\",\n    \".functions\": \"shell\",\n    \".exports\": \"shell\",\n    \".direnvrc\": \"shell\",\n    \".vimrc\": \"shell\",\n    \".gvimrc\": \"shell\",\n};\n\nfunction CodeEditPreview({ model }: SpecializedViewProps) {\n    const fileContent = useAtomValue(model.fileContent);\n    const setNewFileContent = useSetAtom(model.newFileContent);\n    const fileInfo = useAtomValue(model.statFile);\n    const fileName = fileInfo?.path || fileInfo?.name;\n\n    const baseName = fileName ? fileName.split(\"/\").pop() : null;\n    const language = baseName && shellFileMap[baseName] ? shellFileMap[baseName] : undefined;\n\n    function codeEditKeyDownHandler(e: WaveKeyboardEvent): boolean {\n        if (checkKeyPressed(e, \"Cmd:e\")) {\n            fireAndForget(() => model.setEditMode(false));\n            return true;\n        }\n        if (checkKeyPressed(e, \"Cmd:s\") || checkKeyPressed(e, \"Ctrl:s\")) {\n            fireAndForget(model.handleFileSave.bind(model));\n            return true;\n        }\n        if (checkKeyPressed(e, \"Cmd:r\")) {\n            fireAndForget(model.handleFileRevert.bind(model));\n            return true;\n        }\n        return false;\n    }\n\n    useEffect(() => {\n        model.codeEditKeyDownHandler = codeEditKeyDownHandler;\n        model.refreshCallback = () => {\n            globalStore.set(model.refreshVersion, (v) => v + 1);\n        };\n        return () => {\n            model.codeEditKeyDownHandler = null;\n            model.monacoRef.current = null;\n            model.refreshCallback = null;\n        };\n    }, []);\n\n    function onMount(editor: MonacoTypes.editor.IStandaloneCodeEditor, monacoApi: typeof monaco): () => void {\n        model.monacoRef.current = editor;\n\n        const keyDownDisposer = editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => {\n            const waveEvent = adaptFromReactOrNativeKeyEvent(e.browserEvent);\n            const handled = tryReinjectKey(waveEvent);\n            if (handled) {\n                e.stopPropagation();\n                e.preventDefault();\n            }\n        });\n\n        const isFocused = globalStore.get(model.nodeModel.isFocused);\n        if (isFocused) {\n            editor.focus();\n        }\n\n        return () => {\n            keyDownDisposer.dispose();\n        };\n    }\n\n    return (\n        <CodeEditor\n            blockId={model.blockId}\n            text={fileContent}\n            fileName={fileName}\n            language={language}\n            readonly={fileInfo.readonly}\n            onChange={(text) => setNewFileContent(text)}\n            onMount={onMount}\n        />\n    );\n}\n\nexport { CodeEditPreview };\n"
  },
  {
    "path": "frontend/app/view/preview/preview-error-overlay.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Button } from \"@/app/element/button\";\nimport { CopyButton } from \"@/app/element/copybutton\";\nimport clsx from \"clsx\";\nimport { OverlayScrollbarsComponent } from \"overlayscrollbars-react\";\nimport { memo, useCallback } from \"react\";\n\nexport const ErrorOverlay = memo(({ errorMsg, resetOverlay }: { errorMsg: ErrorMsg; resetOverlay: () => void }) => {\n    const showDismiss = errorMsg.showDismiss ?? true;\n    const buttonClassName = \"outlined grey text-[11px] py-[3px] px-[7px]\";\n\n    let iconClass = \"fa-solid fa-circle-exclamation text-error text-base\";\n    if (errorMsg.level == \"warning\") {\n        iconClass = \"fa-solid fa-triangle-exclamation text-warning text-base\";\n    }\n\n    const handleCopyToClipboard = useCallback(async () => {\n        await navigator.clipboard.writeText(errorMsg.text);\n    }, [errorMsg.text]);\n\n    return (\n        <div className=\"absolute top-[0] left-1.5 right-1.5 z-[var(--zindex-block-mask-inner)] overflow-hidden bg-[var(--conn-status-overlay-bg-color)] backdrop-blur-[50px] rounded-md shadow-lg\">\n            <div className=\"flex flex-row justify-between p-2.5 pl-3 font-normal text-sm leading-normal font-sans text-secondary\">\n                <div\n                    className={clsx(\"flex flex-row items-center gap-3 grow min-w-0 shrink\", {\n                        \"items-start\": true,\n                    })}\n                >\n                    <i className={iconClass}></i>\n\n                    <div className=\"flex flex-col items-start gap-1 grow w-full shrink min-w-0\">\n                        <div className=\"max-w-full text-xs font-semibold leading-4 tracking-[0.11px] text-white overflow-hidden\">\n                            {errorMsg.status}\n                        </div>\n\n                        <OverlayScrollbarsComponent\n                            className=\"group text-xs font-normal leading-[15px] tracking-[0.11px] text-wrap max-h-20 rounded-lg py-1.5 pl-0 relative w-full\"\n                            options={{ scrollbars: { autoHide: \"leave\" } }}\n                        >\n                            <CopyButton\n                                className=\"invisible group-hover:visible flex absolute top-0 right-1 rounded backdrop-blur-lg p-1 items-center justify-end gap-1\"\n                                onClick={handleCopyToClipboard}\n                                title=\"Copy\"\n                            />\n                            <div>{errorMsg.text}</div>\n                        </OverlayScrollbarsComponent>\n                        {!!errorMsg.buttons && (\n                            <div className=\"flex flex-row gap-2\">\n                                {errorMsg.buttons?.map((buttonDef) => (\n                                    <Button\n                                        className={buttonClassName}\n                                        onClick={() => {\n                                            buttonDef.onClick();\n                                            resetOverlay();\n                                        }}\n                                        key={crypto.randomUUID()}\n                                    >\n                                        {buttonDef.text}\n                                    </Button>\n                                ))}\n                            </div>\n                        )}\n                    </div>\n\n                    {showDismiss && (\n                        <div className=\"flex items-start\">\n                            <Button\n                                className={clsx(buttonClassName, \"fa-xmark fa-solid\")}\n                                onClick={() => {\n                                    if (errorMsg.closeAction) {\n                                        errorMsg.closeAction();\n                                    }\n                                    resetOverlay();\n                                }}\n                            />\n                        </div>\n                    )}\n                </div>\n            </div>\n        </div>\n    );\n});\n"
  },
  {
    "path": "frontend/app/view/preview/preview-markdown.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { Markdown } from \"@/element/markdown\";\nimport { getOverrideConfigAtom } from \"@/store/global\";\nimport { useAtomValue } from \"jotai\";\nimport { useEffect, useMemo } from \"react\";\nimport type { SpecializedViewProps } from \"./preview\";\n\nfunction MarkdownPreview({ model }: SpecializedViewProps) {\n    useEffect(() => {\n        model.refreshCallback = () => {\n            globalStore.set(model.refreshVersion, (v) => v + 1);\n        };\n        return () => {\n            model.refreshCallback = null;\n        };\n    }, []);\n    const connName = useAtomValue(model.connection);\n    const fileInfo = useAtomValue(model.statFile);\n    const fontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, \"markdown:fontsize\"));\n    const fixedFontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, \"markdown:fixedfontsize\"));\n    const resolveOpts: MarkdownResolveOpts = useMemo<MarkdownResolveOpts>(() => {\n        return {\n            connName: connName,\n            baseDir: fileInfo.dir,\n        };\n    }, [connName, fileInfo.dir]);\n    return (\n        <div className=\"flex flex-row h-full overflow-auto items-start justify-start\">\n            <Markdown\n                textAtom={model.fileContent}\n                showTocAtom={model.markdownShowToc}\n                resolveOpts={resolveOpts}\n                fontSizeOverride={fontSizeOverride}\n                fixedFontSizeOverride={fixedFontSizeOverride}\n                contentClassName=\"pt-[5px] pr-[15px] pb-[10px] pl-[15px]\"\n            />\n        </div>\n    );\n}\n\nexport { MarkdownPreview };\n"
  },
  {
    "path": "frontend/app/view/preview/preview-model.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { BlockNodeModel } from \"@/app/block/blocktypes\";\nimport { ContextMenuModel } from \"@/app/store/contextmenu\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport type { TabModel } from \"@/app/store/tab-model\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { getOverrideConfigAtom, refocusNode } from \"@/store/global\";\nimport * as WOS from \"@/store/wos\";\nimport { goHistory, goHistoryBack, goHistoryForward } from \"@/util/historyutil\";\nimport { checkKeyPressed } from \"@/util/keyutil\";\nimport { addOpenMenuItems } from \"@/util/previewutil\";\nimport { base64ToString, fireAndForget, isBlank, jotaiLoadableValue, stringToBase64 } from \"@/util/util\";\nimport { formatRemoteUri } from \"@/util/waveutil\";\nimport clsx from \"clsx\";\nimport { Atom, atom, Getter, PrimitiveAtom, WritableAtom } from \"jotai\";\nimport { loadable } from \"jotai/utils\";\nimport type * as MonacoTypes from \"monaco-editor\";\nimport { createRef } from \"react\";\nimport { PreviewView } from \"./preview\";\nimport { makeDirectoryDefaultMenuItems } from \"./preview-directory-utils\";\nimport type { PreviewEnv } from \"./previewenv\";\n\n// TODO drive this using config\nconst BOOKMARKS: { label: string; path: string }[] = [\n    { label: \"Home\", path: \"~\" },\n    { label: \"Desktop\", path: \"~/Desktop\" },\n    { label: \"Downloads\", path: \"~/Downloads\" },\n    { label: \"Documents\", path: \"~/Documents\" },\n    { label: \"Root\", path: \"/\" },\n];\n\nconst MaxFileSize = 1024 * 1024 * 10; // 10MB\nconst MaxCSVSize = 1024 * 1024 * 1; // 1MB\n\nconst textApplicationMimetypes = [\n    \"application/sql\",\n    \"application/x-php\",\n    \"application/x-pem-file\",\n    \"application/x-httpd-php\",\n    \"application/liquid\",\n    \"application/graphql\",\n    \"application/javascript\",\n    \"application/typescript\",\n    \"application/x-javascript\",\n    \"application/x-typescript\",\n    \"application/dart\",\n    \"application/vnd.dart\",\n    \"application/x-ruby\",\n    \"application/sql\",\n    \"application/wasm\",\n    \"application/x-latex\",\n    \"application/x-sh\",\n    \"application/x-python\",\n    \"application/x-awk\",\n];\n\nfunction isTextFile(mimeType: string): boolean {\n    if (mimeType == null) {\n        return false;\n    }\n    return (\n        mimeType.startsWith(\"text/\") ||\n        textApplicationMimetypes.includes(mimeType) ||\n        (mimeType.startsWith(\"application/\") &&\n            (mimeType.includes(\"json\") || mimeType.includes(\"yaml\") || mimeType.includes(\"toml\"))) ||\n        mimeType.includes(\"xml\")\n    );\n}\n\nfunction isStreamingType(mimeType: string): boolean {\n    if (mimeType == null) {\n        return false;\n    }\n    return (\n        mimeType.startsWith(\"application/pdf\") ||\n        mimeType.startsWith(\"video/\") ||\n        mimeType.startsWith(\"audio/\") ||\n        mimeType.startsWith(\"image/\")\n    );\n}\n\nfunction isMarkdownLike(mimeType: string): boolean {\n    if (mimeType == null) {\n        return false;\n    }\n    return mimeType.startsWith(\"text/markdown\") || mimeType.startsWith(\"text/mdx\");\n}\n\nfunction iconForFile(mimeType: string): string {\n    if (mimeType == null) {\n        mimeType = \"unknown\";\n    }\n    if (mimeType == \"application/pdf\") {\n        return \"file-pdf\";\n    } else if (mimeType.startsWith(\"image/\")) {\n        return \"image\";\n    } else if (mimeType.startsWith(\"video/\")) {\n        return \"film\";\n    } else if (mimeType.startsWith(\"audio/\")) {\n        return \"headphones\";\n    } else if (isMarkdownLike(mimeType)) {\n        return \"file-lines\";\n    } else if (mimeType == \"text/csv\") {\n        return \"file-csv\";\n    } else if (\n        mimeType.startsWith(\"text/\") ||\n        mimeType == \"application/sql\" ||\n        (mimeType.startsWith(\"application/\") &&\n            (mimeType.includes(\"json\") || mimeType.includes(\"yaml\") || mimeType.includes(\"toml\")))\n    ) {\n        return \"file-code\";\n    } else {\n        return \"file\";\n    }\n}\n\nexport class PreviewModel implements ViewModel {\n    viewType: string;\n    blockId: string;\n    nodeModel: BlockNodeModel;\n    tabModel: TabModel;\n    noPadding?: Atom<boolean>;\n    blockAtom: Atom<Block>;\n    viewIcon: Atom<string | IconButtonDecl>;\n    viewName: Atom<string>;\n    viewText: Atom<HeaderElem[]>;\n    preIconButton: Atom<IconButtonDecl>;\n    endIconButtons: Atom<IconButtonDecl[]>;\n    hideViewName: Atom<boolean>;\n    previewTextRef: React.RefObject<HTMLDivElement>;\n    editMode: Atom<boolean>;\n    canPreview: PrimitiveAtom<boolean>;\n    specializedView: Atom<Promise<{ specializedView?: string; errorStr?: string }>>;\n    loadableSpecializedView: Atom<Loadable<{ specializedView?: string; errorStr?: string }>>;\n    manageConnection: Atom<boolean>;\n    connStatus: Atom<ConnStatus>;\n    filterOutNowsh?: Atom<boolean>;\n\n    metaFilePath: Atom<string>;\n    statFilePath: Atom<Promise<string>>;\n    loadableFileInfo: Atom<Loadable<FileInfo>>;\n    connection: Atom<Promise<string>>;\n    connectionImmediate: Atom<string>;\n    statFile: Atom<Promise<FileInfo>>;\n    fullFile: Atom<Promise<FileData>>;\n    fileMimeType: Atom<Promise<string>>;\n    fileMimeTypeLoadable: Atom<Loadable<string>>;\n    fileContentSaved: PrimitiveAtom<string | null>;\n    fileContent: WritableAtom<Promise<string>, [string], void>;\n    newFileContent: PrimitiveAtom<string | null>;\n    connectionError: PrimitiveAtom<string>;\n    errorMsgAtom: PrimitiveAtom<ErrorMsg>;\n\n    openFileModal: PrimitiveAtom<boolean>;\n    openFileModalDelay: PrimitiveAtom<boolean>;\n    openFileError: PrimitiveAtom<string>;\n    openFileModalGiveFocusRef: React.RefObject<() => boolean>;\n\n    markdownShowToc: PrimitiveAtom<boolean>;\n\n    monacoRef: React.RefObject<MonacoTypes.editor.IStandaloneCodeEditor>;\n\n    showHiddenFiles: PrimitiveAtom<boolean>;\n    refreshVersion: PrimitiveAtom<number>;\n    directorySearchActive: PrimitiveAtom<boolean>;\n    refreshCallback: () => void;\n    directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean;\n    codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean;\n    env: PreviewEnv;\n\n    constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) {\n        this.viewType = \"preview\";\n        this.blockId = blockId;\n        this.nodeModel = nodeModel;\n        this.tabModel = tabModel;\n        this.env = waveEnv;\n        let showHiddenFiles = globalStore.get(this.env.getSettingsKeyAtom(\"preview:showhiddenfiles\")) ?? true;\n        this.showHiddenFiles = atom<boolean>(showHiddenFiles);\n        this.refreshVersion = atom(0);\n        this.directorySearchActive = atom(false);\n        this.previewTextRef = createRef();\n        this.openFileModal = atom(false);\n        this.openFileModalDelay = atom(false);\n        this.openFileError = atom(null) as PrimitiveAtom<string>;\n        this.openFileModalGiveFocusRef = createRef();\n        this.manageConnection = atom(true);\n        this.blockAtom = this.env.wos.getWaveObjectAtom<Block>(`block:${blockId}`);\n        this.markdownShowToc = atom(false);\n        this.filterOutNowsh = atom(true);\n        this.monacoRef = createRef();\n        this.connectionError = atom(\"\");\n        this.errorMsgAtom = atom(null) as PrimitiveAtom<ErrorMsg | null>;\n        this.viewIcon = atom((get) => {\n            const blockData = get(this.blockAtom);\n            if (blockData?.meta?.icon) {\n                return blockData.meta.icon;\n            }\n            const connStatus = get(this.connStatus);\n            if (connStatus?.status != \"connected\") {\n                return null;\n            }\n            const mimeTypeLoadable = get(this.fileMimeTypeLoadable);\n            const mimeType = jotaiLoadableValue(mimeTypeLoadable, \"\");\n            if (mimeType == \"directory\") {\n                return {\n                    elemtype: \"iconbutton\",\n                    icon: \"folder-open\",\n                    longClick: (e: React.MouseEvent<any>) => {\n                        const menuItems: ContextMenuItem[] = BOOKMARKS.map((bookmark) => ({\n                            label: `Go to ${bookmark.label} (${bookmark.path})`,\n                            click: () => this.goHistory(bookmark.path),\n                        }));\n                        ContextMenuModel.getInstance().showContextMenu(menuItems, e);\n                    },\n                };\n            }\n            return iconForFile(mimeType);\n        });\n        this.editMode = atom((get) => {\n            const blockData = get(this.blockAtom);\n            return blockData?.meta?.edit ?? false;\n        });\n        this.viewName = atom(\"Preview\");\n        this.hideViewName = atom(true);\n        this.viewText = atom((get) => {\n            let headerPath = get(this.metaFilePath);\n            const connStatus = get(this.connStatus);\n            if (connStatus?.status != \"connected\") {\n                return [\n                    {\n                        elemtype: \"text\",\n                        text: headerPath,\n                        className: \"preview-filename\",\n                    },\n                ];\n            }\n            const loadableSV = get(this.loadableSpecializedView);\n            const isCeView = loadableSV.state == \"hasData\" && loadableSV.data.specializedView == \"codeedit\";\n            const loadableFileInfo = get(this.loadableFileInfo);\n            if (loadableFileInfo.state == \"hasData\") {\n                headerPath = loadableFileInfo.data?.path;\n                if (headerPath == \"~\") {\n                    headerPath = `~ (${loadableFileInfo.data?.dir + \"/\" + loadableFileInfo.data?.name})`;\n                }\n            }\n            if (!isBlank(headerPath) && headerPath != \"/\" && headerPath.endsWith(\"/\")) {\n                headerPath = headerPath.slice(0, -1);\n            }\n            const viewTextChildren: HeaderElem[] = [\n                {\n                    elemtype: \"text\",\n                    text: headerPath,\n                    ref: this.previewTextRef,\n                    className: \"preview-filename\",\n                    onClick: () => this.toggleOpenFileModal(),\n                },\n            ];\n            let saveClassName = \"grey\";\n            if (get(this.newFileContent) !== null) {\n                saveClassName = \"green\";\n            }\n            if (isCeView) {\n                const fileInfo = globalStore.get(this.loadableFileInfo);\n                if (fileInfo.state != \"hasData\") {\n                    viewTextChildren.push({\n                        elemtype: \"textbutton\",\n                        text: \"Loading ...\",\n                        className: clsx(`grey rounded-[4px] !py-[2px] !px-[10px] text-[11px] font-[500]`),\n                        onClick: () => {},\n                    });\n                } else if (fileInfo.data.readonly) {\n                    viewTextChildren.push({\n                        elemtype: \"textbutton\",\n                        text: \"Read Only\",\n                        className: clsx(`yellow rounded-[4px] !py-[2px] !px-[10px] text-[11px] font-[500]`),\n                        onClick: () => {},\n                    });\n                } else {\n                    viewTextChildren.push({\n                        elemtype: \"textbutton\",\n                        text: \"Save\",\n                        className: clsx(`${saveClassName} rounded-[4px] !py-[2px] !px-[10px] text-[11px] font-[500]`),\n                        onClick: () => fireAndForget(this.handleFileSave.bind(this)),\n                    });\n                }\n                if (get(this.canPreview)) {\n                    viewTextChildren.push({\n                        elemtype: \"textbutton\",\n                        text: \"Preview\",\n                        className: \"grey rounded-[4px] !py-[2px] !px-[10px] text-[11px] font-[500]\",\n                        onClick: () => fireAndForget(() => this.setEditMode(false)),\n                    });\n                }\n            } else if (get(this.canPreview)) {\n                viewTextChildren.push({\n                    elemtype: \"textbutton\",\n                    text: \"Edit\",\n                    className: \"grey rounded-[4px] !py-[2px] !px-[10px] text-[11px] font-[500]\",\n                    onClick: () => fireAndForget(() => this.setEditMode(true)),\n                });\n            }\n            return [\n                {\n                    elemtype: \"div\",\n                    children: viewTextChildren,\n                },\n            ] as HeaderElem[];\n        });\n        this.preIconButton = atom((get) => {\n            const connStatus = get(this.connStatus);\n            if (connStatus?.status != \"connected\") {\n                return null;\n            }\n            const mimeType = jotaiLoadableValue(get(this.fileMimeTypeLoadable), \"\");\n            const metaPath = get(this.metaFilePath);\n            if (mimeType == \"directory\" && metaPath == \"/\") {\n                return null;\n            }\n            return {\n                elemtype: \"iconbutton\",\n                icon: \"chevron-left\",\n                click: this.goParentDirectory.bind(this),\n            };\n        });\n        this.endIconButtons = atom((get) => {\n            const connStatus = get(this.connStatus);\n            if (connStatus?.status != \"connected\") {\n                return null;\n            }\n            const mimeType = jotaiLoadableValue(get(this.fileMimeTypeLoadable), \"\");\n            const loadableSV = get(this.loadableSpecializedView);\n            const isCeView = loadableSV.state == \"hasData\" && loadableSV.data.specializedView == \"codeedit\";\n            if (mimeType == \"directory\") {\n                const showHiddenFiles = get(this.showHiddenFiles);\n                return [\n                    {\n                        elemtype: \"iconbutton\",\n                        icon: showHiddenFiles ? \"eye\" : \"eye-slash\",\n                        title: showHiddenFiles ? \"Hide Hidden Files\" : \"Show Hidden Files\",\n                        click: () => {\n                            globalStore.set(this.showHiddenFiles, (prev) => !prev);\n                        },\n                    },\n                    {\n                        elemtype: \"iconbutton\",\n                        icon: \"arrows-rotate\",\n                        click: () => this.refreshCallback?.(),\n                    },\n                ] as IconButtonDecl[];\n            } else if (!isCeView && isMarkdownLike(mimeType)) {\n                return [\n                    {\n                        elemtype: \"iconbutton\",\n                        icon: \"book\",\n                        title: \"Table of Contents\",\n                        click: () => this.markdownShowTocToggle(),\n                    },\n                    {\n                        elemtype: \"iconbutton\",\n                        icon: \"arrows-rotate\",\n                        title: \"Refresh\",\n                        click: () => this.refreshCallback?.(),\n                    },\n                ] as IconButtonDecl[];\n            } else if (!isCeView && mimeType) {\n                // For all other file types (text, code, etc.), add refresh button\n                return [\n                    {\n                        elemtype: \"iconbutton\",\n                        icon: \"arrows-rotate\",\n                        title: \"Refresh\",\n                        click: () => this.refreshCallback?.(),\n                    },\n                ] as IconButtonDecl[];\n            }\n            return null;\n        });\n        this.metaFilePath = atom<string>((get) => {\n            const file = get(this.blockAtom)?.meta?.file;\n            if (isBlank(file)) {\n                return \"~\";\n            }\n            return file;\n        });\n        this.statFilePath = atom<Promise<string>>(async (get) => {\n            const fileInfo = await get(this.statFile);\n            return fileInfo?.path;\n        });\n        this.connection = atom<Promise<string>>(async (get) => {\n            const connName = get(this.blockAtom)?.meta?.connection;\n            try {\n                await this.env.rpc.ConnEnsureCommand(TabRpcClient, { connname: connName }, { timeout: 60000 });\n                globalStore.set(this.connectionError, \"\");\n            } catch (e) {\n                globalStore.set(this.connectionError, e as string);\n            }\n            return connName;\n        });\n        this.connectionImmediate = atom<string>((get) => {\n            return get(this.blockAtom)?.meta?.connection;\n        });\n        this.statFile = atom<Promise<FileInfo>>(async (get) => {\n            const fileName = get(this.metaFilePath);\n            const path = await this.formatRemoteUri(fileName, get);\n            if (fileName == null) {\n                return null;\n            }\n            try {\n                const statFile = await this.env.rpc.FileInfoCommand(TabRpcClient, {\n                    info: {\n                        path,\n                    },\n                });\n                return statFile;\n            } catch (e) {\n                const errorStatus: ErrorMsg = {\n                    status: \"File Read Failed\",\n                    text: `${e}`,\n                };\n                globalStore.set(this.errorMsgAtom, errorStatus);\n            }\n        });\n        this.fileMimeType = atom<Promise<string>>(async (get) => {\n            const fileInfo = await get(this.statFile);\n            return fileInfo?.mimetype;\n        });\n        this.fileMimeTypeLoadable = loadable(this.fileMimeType);\n        this.newFileContent = atom(null) as PrimitiveAtom<string | null>;\n        this.goParentDirectory = this.goParentDirectory.bind(this);\n\n        const fullFileAtom = atom<Promise<FileData>>(async (get) => {\n            get(this.refreshVersion); // Subscribe to refreshVersion to trigger re-fetch\n            const fileName = get(this.metaFilePath);\n            const path = await this.formatRemoteUri(fileName, get);\n            if (fileName == null) {\n                return null;\n            }\n            try {\n                const file = await this.env.rpc.FileReadCommand(TabRpcClient, {\n                    info: {\n                        path,\n                    },\n                });\n                return file;\n            } catch (e) {\n                const errorStatus: ErrorMsg = {\n                    status: \"File Read Failed\",\n                    text: `${e}`,\n                };\n                globalStore.set(this.errorMsgAtom, errorStatus);\n            }\n        });\n\n        this.fileContentSaved = atom(null) as PrimitiveAtom<string | null>;\n        const fileContentAtom = atom(\n            async (get) => {\n                const newContent = get(this.newFileContent);\n                if (newContent != null) {\n                    return newContent;\n                }\n                const savedContent = get(this.fileContentSaved);\n                if (savedContent != null) {\n                    return savedContent;\n                }\n                const fullFile = await get(fullFileAtom);\n                return base64ToString(fullFile?.data64);\n            },\n            (_, set, update: string) => {\n                set(this.fileContentSaved, update);\n            }\n        );\n\n        this.fullFile = fullFileAtom;\n        this.fileContent = fileContentAtom;\n\n        this.specializedView = atom<Promise<{ specializedView?: string; errorStr?: string }>>(async (get) => {\n            return this.getSpecializedView(get);\n        });\n        this.loadableSpecializedView = loadable(this.specializedView);\n        this.canPreview = atom(false);\n        this.loadableFileInfo = loadable(this.statFile);\n        this.connStatus = atom((get) => {\n            const blockData = get(this.blockAtom);\n            const connName = blockData?.meta?.connection;\n            const connAtom = this.env.getConnStatusAtom(connName);\n            return get(connAtom);\n        });\n\n        this.noPadding = atom(true);\n    }\n\n    markdownShowTocToggle() {\n        globalStore.set(this.markdownShowToc, !globalStore.get(this.markdownShowToc));\n    }\n\n    get viewComponent(): ViewComponent {\n        return PreviewView;\n    }\n\n    async getSpecializedView(getFn: Getter): Promise<{ specializedView?: string; errorStr?: string }> {\n        const mimeType = await getFn(this.fileMimeType);\n        const fileInfo = await getFn(this.statFile);\n        const fileName = fileInfo?.name;\n        const connErr = getFn(this.connectionError);\n        const editMode = getFn(this.editMode);\n        const genErr = getFn(this.errorMsgAtom);\n\n        if (!fileInfo) {\n            return { errorStr: `Load Error: ${genErr?.text}` };\n        }\n        if (connErr != \"\") {\n            return { errorStr: `Connection Error: ${connErr}` };\n        }\n        if (fileInfo?.notfound) {\n            return { specializedView: \"codeedit\" };\n        }\n        if (mimeType == null) {\n            return { errorStr: `Unable to determine mimetype for: ${fileInfo.path}` };\n        }\n        if (isStreamingType(mimeType)) {\n            return { specializedView: \"streaming\" };\n        }\n        if (!fileInfo) {\n            const fileNameStr = fileName ? \" \" + JSON.stringify(fileName) : \"\";\n            return { errorStr: \"File Not Found\" + fileNameStr };\n        }\n        if (fileInfo.size > MaxFileSize) {\n            return { errorStr: \"File Too Large to Preview (10 MB Max)\" };\n        }\n        if (mimeType == \"text/csv\" && fileInfo.size > MaxCSVSize) {\n            return { errorStr: \"CSV File Too Large to Preview (1 MB Max)\" };\n        }\n        if (mimeType == \"directory\") {\n            return { specializedView: \"directory\" };\n        }\n        if (mimeType == \"text/csv\") {\n            if (editMode) {\n                return { specializedView: \"codeedit\" };\n            }\n            return { specializedView: \"csv\" };\n        }\n        if (isMarkdownLike(mimeType)) {\n            if (editMode) {\n                return { specializedView: \"codeedit\" };\n            }\n            return { specializedView: \"markdown\" };\n        }\n        if (isTextFile(mimeType) || fileInfo.size == 0) {\n            return { specializedView: \"codeedit\" };\n        }\n        return { errorStr: `Preview (${mimeType})` };\n    }\n\n    updateOpenFileModalAndError(isOpen, errorMsg = null) {\n        globalStore.set(this.openFileModal, isOpen);\n        globalStore.set(this.openFileError, errorMsg);\n        if (isOpen) {\n            globalStore.set(this.openFileModalDelay, true);\n        } else {\n            const delayVal = globalStore.get(this.openFileModalDelay);\n            if (delayVal) {\n                setTimeout(() => {\n                    globalStore.set(this.openFileModalDelay, false);\n                }, 200);\n            }\n        }\n    }\n\n    toggleOpenFileModal() {\n        const modalOpen = globalStore.get(this.openFileModal);\n        const delayVal = globalStore.get(this.openFileModalDelay);\n        if (!modalOpen && delayVal) {\n            return;\n        }\n        this.updateOpenFileModalAndError(!modalOpen);\n    }\n\n    async goHistory(newPath: string) {\n        let fileName = globalStore.get(this.metaFilePath);\n        if (fileName == null) {\n            fileName = \"\";\n        }\n        const blockMeta = globalStore.get(this.blockAtom)?.meta;\n        const updateMeta = goHistory(\"file\", fileName, newPath, blockMeta);\n        if (updateMeta == null) {\n            return;\n        }\n        const blockOref = WOS.makeORef(\"block\", this.blockId);\n        await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta);\n\n        // Clear the saved file buffers\n        globalStore.set(this.fileContentSaved, null);\n        globalStore.set(this.newFileContent, null);\n    }\n\n    async goParentDirectory({ fileInfo = null }: { fileInfo?: FileInfo | null }) {\n        // optional parameter needed for recursive case\n        const defaultFileInfo = await globalStore.get(this.statFile);\n        if (fileInfo === null) {\n            fileInfo = defaultFileInfo;\n        }\n        if (fileInfo == null) {\n            this.updateOpenFileModalAndError(false);\n            return true;\n        }\n        try {\n            this.updateOpenFileModalAndError(false);\n            await this.goHistory(fileInfo.dir);\n            refocusNode(this.blockId);\n        } catch (e) {\n            globalStore.set(this.openFileError, e.message);\n            console.error(\"Error opening file\", fileInfo.dir, e);\n        }\n    }\n\n    async goHistoryBack() {\n        const blockMeta = globalStore.get(this.blockAtom)?.meta;\n        const curPath = globalStore.get(this.metaFilePath);\n        const updateMeta = goHistoryBack(\"file\", curPath, blockMeta, true);\n        if (updateMeta == null) {\n            return;\n        }\n        updateMeta.edit = false;\n        const blockOref = WOS.makeORef(\"block\", this.blockId);\n        await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta);\n    }\n\n    async goHistoryForward() {\n        const blockMeta = globalStore.get(this.blockAtom)?.meta;\n        const curPath = globalStore.get(this.metaFilePath);\n        const updateMeta = goHistoryForward(\"file\", curPath, blockMeta);\n        if (updateMeta == null) {\n            return;\n        }\n        updateMeta.edit = false;\n        const blockOref = WOS.makeORef(\"block\", this.blockId);\n        await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta);\n    }\n\n    async setEditMode(edit: boolean) {\n        const blockMeta = globalStore.get(this.blockAtom)?.meta;\n        const blockOref = WOS.makeORef(\"block\", this.blockId);\n        await this.env.services.object.UpdateObjectMeta(blockOref, { ...blockMeta, edit });\n    }\n\n    async handleFileSave() {\n        const filePath = await globalStore.get(this.statFilePath);\n        if (filePath == null) {\n            return;\n        }\n        const newFileContent = globalStore.get(this.newFileContent);\n        if (newFileContent == null) {\n            console.log(\"not saving file, newFileContent is null\");\n            return;\n        }\n        try {\n            await this.env.rpc.FileWriteCommand(TabRpcClient, {\n                info: {\n                    path: await this.formatRemoteUri(filePath, globalStore.get),\n                },\n                data64: stringToBase64(newFileContent),\n            });\n            globalStore.set(this.fileContent, newFileContent);\n            globalStore.set(this.newFileContent, null);\n            console.log(\"saved file\", filePath);\n        } catch (e) {\n            const errorStatus: ErrorMsg = {\n                status: \"Save Failed\",\n                text: `${e}`,\n            };\n            globalStore.set(this.errorMsgAtom, errorStatus);\n        }\n    }\n\n    async handleFileRevert() {\n        const fileContent = await globalStore.get(this.fileContent);\n        this.monacoRef.current?.setValue(fileContent);\n        globalStore.set(this.newFileContent, null);\n    }\n\n    async handleOpenFile(filePath: string) {\n        const fileInfo = await globalStore.get(this.statFile);\n        this.updateOpenFileModalAndError(false);\n        if (fileInfo == null) {\n            return true;\n        }\n        try {\n            this.goHistory(filePath);\n            refocusNode(this.blockId);\n        } catch (e) {\n            globalStore.set(this.openFileError, e.message);\n            console.error(\"Error opening file\", filePath, e);\n        }\n    }\n\n    isSpecializedView(sv: string): boolean {\n        const loadableSV = globalStore.get(this.loadableSpecializedView);\n        return loadableSV.state == \"hasData\" && loadableSV.data.specializedView == sv;\n    }\n\n    getSettingsMenuItems(): ContextMenuItem[] {\n        const defaultFontSize = globalStore.get(this.env.getSettingsKeyAtom(\"editor:fontsize\")) ?? 12;\n        const blockData = globalStore.get(this.blockAtom);\n        const overrideFontSize = blockData?.meta?.[\"editor:fontsize\"];\n        const menuItems: ContextMenuItem[] = [];\n        menuItems.push({\n            label: \"Copy Full Path\",\n            click: () =>\n                fireAndForget(async () => {\n                    const filePath = await globalStore.get(this.statFilePath);\n                    if (filePath == null) {\n                        return;\n                    }\n                    const conn = await globalStore.get(this.connection);\n                    if (conn) {\n                        // remote path\n                        await navigator.clipboard.writeText(formatRemoteUri(filePath, conn));\n                    } else {\n                        // local path\n                        await navigator.clipboard.writeText(filePath);\n                    }\n                }),\n        });\n        menuItems.push({\n            label: \"Copy File Name\",\n            click: () =>\n                fireAndForget(async () => {\n                    const fileInfo = await globalStore.get(this.statFile);\n                    if (fileInfo == null || fileInfo.name == null) {\n                        return;\n                    }\n                    await navigator.clipboard.writeText(fileInfo.name);\n                }),\n        });\n        menuItems.push({ type: \"separator\" });\n        const finfo = jotaiLoadableValue(globalStore.get(this.loadableFileInfo), null);\n        addOpenMenuItems(menuItems, globalStore.get(this.connectionImmediate), finfo);\n        const loadableSV = globalStore.get(this.loadableSpecializedView);\n        const wordWrapAtom = getOverrideConfigAtom(this.blockId, \"editor:wordwrap\");\n        const wordWrap = globalStore.get(wordWrapAtom) ?? false;\n        menuItems.push({ type: \"separator\" });\n        if (loadableSV.state == \"hasData\" && loadableSV.data.specializedView == \"codeedit\") {\n            const fontSizeSubMenu: ContextMenuItem[] = [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].map(\n                (fontSize: number) => {\n                    return {\n                        label: fontSize.toString() + \"px\",\n                        type: \"checkbox\",\n                        checked: overrideFontSize == fontSize,\n                        click: () => {\n                            this.env.rpc.SetMetaCommand(TabRpcClient, {\n                                oref: WOS.makeORef(\"block\", this.blockId),\n                                meta: { \"editor:fontsize\": fontSize },\n                            });\n                        },\n                    };\n                }\n            );\n            fontSizeSubMenu.unshift({\n                label: \"Default (\" + defaultFontSize + \"px)\",\n                type: \"checkbox\",\n                checked: overrideFontSize == null,\n                click: () => {\n                    this.env.rpc.SetMetaCommand(TabRpcClient, {\n                        oref: WOS.makeORef(\"block\", this.blockId),\n                        meta: { \"editor:fontsize\": null },\n                    });\n                },\n            });\n            menuItems.push({\n                label: \"Editor Font Size\",\n                submenu: fontSizeSubMenu,\n            });\n            if (globalStore.get(this.newFileContent) != null) {\n                menuItems.push({ type: \"separator\" });\n                menuItems.push({\n                    label: \"Save File\",\n                    click: () => fireAndForget(this.handleFileSave.bind(this)),\n                });\n                menuItems.push({\n                    label: \"Revert File\",\n                    click: () => fireAndForget(this.handleFileRevert.bind(this)),\n                });\n            }\n            menuItems.push({ type: \"separator\" });\n            menuItems.push({\n                label: \"Word Wrap\",\n                type: \"checkbox\",\n                checked: wordWrap,\n                click: () =>\n                    fireAndForget(async () => {\n                        const blockOref = WOS.makeORef(\"block\", this.blockId);\n                        await this.env.services.object.UpdateObjectMeta(blockOref, {\n                            \"editor:wordwrap\": !wordWrap,\n                        });\n                    }),\n            });\n        }\n        if (loadableSV.state == \"hasData\" && loadableSV.data.specializedView == \"directory\") {\n            menuItems.push({ type: \"separator\" });\n            menuItems.push({ label: \"Default Settings\", enabled: false });\n            menuItems.push(...makeDirectoryDefaultMenuItems(this));\n        }\n        return menuItems;\n    }\n\n    giveFocus(): boolean {\n        const openModalOpen = globalStore.get(this.openFileModal);\n        if (openModalOpen) {\n            this.openFileModalGiveFocusRef.current?.();\n            return true;\n        }\n        if (this.monacoRef.current) {\n            this.monacoRef.current.focus();\n            return true;\n        }\n        return false;\n    }\n\n    keyDownHandler(e: WaveKeyboardEvent): boolean {\n        if (checkKeyPressed(e, \"Cmd:ArrowLeft\")) {\n            fireAndForget(this.goHistoryBack.bind(this));\n            return true;\n        }\n        if (checkKeyPressed(e, \"Cmd:ArrowRight\")) {\n            fireAndForget(this.goHistoryForward.bind(this));\n            return true;\n        }\n        if (checkKeyPressed(e, \"Cmd:ArrowUp\")) {\n            // handle up directory\n            fireAndForget(() => this.goParentDirectory({}));\n            return true;\n        }\n        if (checkKeyPressed(e, \"Cmd:o\")) {\n            this.toggleOpenFileModal();\n            return true;\n        }\n        const canPreview = globalStore.get(this.canPreview);\n        if (canPreview) {\n            if (checkKeyPressed(e, \"Cmd:e\")) {\n                const editMode = globalStore.get(this.editMode);\n                fireAndForget(() => this.setEditMode(!editMode));\n                return true;\n            }\n        }\n        if (this.directoryKeyDownHandler) {\n            const handled = this.directoryKeyDownHandler(e);\n            if (handled) {\n                return true;\n            }\n        }\n        if (this.codeEditKeyDownHandler) {\n            const handled = this.codeEditKeyDownHandler(e);\n            if (handled) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    async formatRemoteUri(path: string, get: Getter): Promise<string> {\n        return formatRemoteUri(path, await get(this.connection));\n    }\n}\n"
  },
  {
    "path": "frontend/app/view/preview/preview-streaming.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Button } from \"@/app/element/button\";\nimport { CenteredDiv } from \"@/app/element/quickelems\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { getWebServerEndpoint } from \"@/util/endpoints\";\nimport { formatRemoteUri } from \"@/util/waveutil\";\nimport { useAtomValue } from \"jotai\";\nimport { useEffect } from \"react\";\nimport { TransformComponent, TransformWrapper, useControls } from \"react-zoom-pan-pinch\";\nimport type { SpecializedViewProps } from \"./preview\";\n\nfunction ImageZoomControls() {\n    const { zoomIn, zoomOut, resetTransform } = useControls();\n\n    return (\n        <div className=\"absolute flex flex-row z-[2] top-0 right-0 p-[5px] gap-1\">\n            <Button onClick={() => zoomIn()} title=\"Zoom In\" className=\"py-1 px-[5px]\">\n                <i className=\"fa-sharp fa-plus\" />\n            </Button>\n            <Button onClick={() => zoomOut()} title=\"Zoom Out\" className=\"py-1 px-[5px]\">\n                <i className=\"fa-sharp fa-minus\" />\n            </Button>\n            <Button onClick={() => resetTransform()} title=\"Reset Zoom\" className=\"py-1 px-[5px]\">\n                <i className=\"fa-sharp fa-rotate-left\" />\n            </Button>\n        </div>\n    );\n}\n\nfunction StreamingImagePreview({ url }: { url: string }) {\n    return (\n        <div className=\"flex flex-row h-full overflow-hidden items-center justify-center relative\">\n            <TransformWrapper initialScale={1} centerOnInit pinch={{ step: 10 }}>\n                {({ zoomIn, zoomOut, resetTransform, ...rest }) => (\n                    <>\n                        <ImageZoomControls />\n                        <TransformComponent wrapperClass=\"!h-full !w-full\">\n                            <img src={url} className=\"z-[1]\" />\n                        </TransformComponent>\n                    </>\n                )}\n            </TransformWrapper>\n        </div>\n    );\n}\n\nfunction StreamingPreview({ model }: SpecializedViewProps) {\n    useEffect(() => {\n        model.refreshCallback = () => {\n            globalStore.set(model.refreshVersion, (v) => v + 1);\n        };\n        return () => {\n            model.refreshCallback = null;\n        };\n    }, []);\n    const conn = useAtomValue(model.connection);\n    const fileInfo = useAtomValue(model.statFile);\n    const filePath = fileInfo.path;\n    const remotePath = formatRemoteUri(filePath, conn);\n    const usp = new URLSearchParams();\n    usp.set(\"path\", remotePath);\n    const streamingUrl = `${getWebServerEndpoint()}/wave/stream-file?${usp.toString()}`;\n    if (fileInfo.mimetype === \"application/pdf\") {\n        return (\n            <div className=\"flex flex-row h-full overflow-hidden items-center justify-center p-[5px]\">\n                <iframe src={streamingUrl} width=\"100%\" height=\"100%\" name=\"pdfview\" />\n            </div>\n        );\n    }\n    if (fileInfo.mimetype.startsWith(\"video/\")) {\n        return (\n            <div className=\"flex flex-row h-full overflow-hidden items-center justify-center\">\n                <video controls src={streamingUrl} className=\"w-full h-full p-[10px] object-contain\" />\n            </div>\n        );\n    }\n    if (fileInfo.mimetype.startsWith(\"audio/\")) {\n        return (\n            <div className=\"flex flex-row h-full overflow-hidden items-center justify-center\">\n                <audio controls src={streamingUrl} className=\"w-full h-full p-[10px] object-contain\" />\n            </div>\n        );\n    }\n    if (fileInfo.mimetype.startsWith(\"image/\")) {\n        return <StreamingImagePreview url={streamingUrl} />;\n    }\n    return <CenteredDiv>Preview Not Supported</CenteredDiv>;\n}\n\nexport { StreamingPreview };\n"
  },
  {
    "path": "frontend/app/view/preview/preview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { CenteredDiv } from \"@/app/element/quickelems\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { BlockHeaderSuggestionControl } from \"@/app/suggestion/suggestion\";\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport { isBlank, makeConnRoute } from \"@/util/util\";\nimport { useAtom, useAtomValue, useSetAtom } from \"jotai\";\nimport { memo, useEffect } from \"react\";\nimport { CSVView } from \"./csvview\";\nimport { DirectoryPreview } from \"./preview-directory\";\nimport { CodeEditPreview } from \"./preview-edit\";\nimport { ErrorOverlay } from \"./preview-error-overlay\";\nimport { MarkdownPreview } from \"./preview-markdown\";\nimport type { PreviewModel } from \"./preview-model\";\nimport { StreamingPreview } from \"./preview-streaming\";\nimport type { PreviewEnv } from \"./previewenv\";\n\nexport type SpecializedViewProps = {\n    model: PreviewModel;\n    parentRef: React.RefObject<HTMLDivElement>;\n};\n\nconst SpecializedViewMap: { [view: string]: ({ model }: SpecializedViewProps) => React.JSX.Element } = {\n    streaming: StreamingPreview,\n    markdown: MarkdownPreview,\n    codeedit: CodeEditPreview,\n    csv: CSVViewPreview,\n    directory: DirectoryPreview,\n};\n\nfunction canPreview(mimeType: string): boolean {\n    if (mimeType == null) {\n        return false;\n    }\n    return mimeType.startsWith(\"text/markdown\") || mimeType.startsWith(\"text/csv\");\n}\n\nfunction CSVViewPreview({ model, parentRef }: SpecializedViewProps) {\n    const fileContent = useAtomValue(model.fileContent);\n    const fileName = useAtomValue(model.statFilePath);\n    return <CSVView parentRef={parentRef} readonly={true} content={fileContent} filename={fileName} />;\n}\n\nconst SpecializedView = memo(({ parentRef, model }: SpecializedViewProps) => {\n    const specializedView = useAtomValue(model.specializedView);\n    const mimeType = useAtomValue(model.fileMimeType);\n    const setCanPreview = useSetAtom(model.canPreview);\n    const path = useAtomValue(model.statFilePath);\n\n    useEffect(() => {\n        setCanPreview(canPreview(mimeType));\n    }, [mimeType, setCanPreview]);\n\n    if (specializedView.errorStr != null) {\n        return <CenteredDiv>{specializedView.errorStr}</CenteredDiv>;\n    }\n    const SpecializedViewComponent = SpecializedViewMap[specializedView.specializedView];\n    if (!SpecializedViewComponent) {\n        return <CenteredDiv>Invalid Specialized View Component ({specializedView.specializedView})</CenteredDiv>;\n    }\n    return <SpecializedViewComponent key={path} model={model} parentRef={parentRef} />;\n});\n\nconst fetchSuggestions = async (\n    env: PreviewEnv,\n    model: PreviewModel,\n    query: string,\n    reqContext: SuggestionRequestContext\n): Promise<FetchSuggestionsResponse> => {\n    const conn = await globalStore.get(model.connection);\n    let route = makeConnRoute(conn);\n    if (isBlank(conn)) {\n        route = null;\n    }\n    if (reqContext?.dispose) {\n        env.rpc.DisposeSuggestionsCommand(TabRpcClient, reqContext.widgetid, { noresponse: true, route: route });\n        return null;\n    }\n    const fileInfo = await globalStore.get(model.statFile);\n    if (fileInfo == null) {\n        return null;\n    }\n    const sdata = {\n        suggestiontype: \"file\",\n        \"file:cwd\": fileInfo.path,\n        query: query,\n        widgetid: reqContext.widgetid,\n        reqnum: reqContext.reqnum,\n        \"file:connection\": conn,\n    };\n    return await env.rpc.FetchSuggestionsCommand(TabRpcClient, sdata, {\n        route: route,\n    });\n};\n\nfunction PreviewView({\n    blockRef,\n    contentRef,\n    model,\n}: {\n    blockId: string;\n    blockRef: React.RefObject<HTMLDivElement>;\n    contentRef: React.RefObject<HTMLDivElement>;\n    model: PreviewModel;\n}) {\n    const env = useWaveEnv<PreviewEnv>();\n    const connStatus = useAtomValue(model.connStatus);\n    const [errorMsg, setErrorMsg] = useAtom(model.errorMsgAtom);\n    const connection = useAtomValue(model.connectionImmediate);\n    const fileInfo = useAtomValue(model.statFile);\n\n    useEffect(() => {\n        console.log(\"fileInfo or connection changed\", fileInfo, connection);\n        if (!fileInfo) {\n            return;\n        }\n        setErrorMsg(null);\n    }, [connection, fileInfo]);\n\n    if (connStatus?.status != \"connected\") {\n        return null;\n    }\n    const handleSelect = (s: SuggestionType, queryStr: string): boolean => {\n        if (s == null) {\n            if (isBlank(queryStr)) {\n                globalStore.set(model.openFileModal, false);\n                return true;\n            }\n            model.handleOpenFile(queryStr);\n            return true;\n        }\n        model.handleOpenFile(s[\"file:path\"]);\n        return true;\n    };\n    const handleTab = (s: SuggestionType, query: string): string => {\n        if (s[\"file:mimetype\"] == \"directory\") {\n            return s[\"file:name\"] + \"/\";\n        } else {\n            return s[\"file:name\"];\n        }\n    };\n    const fetchSuggestionsFn = async (query, ctx) => {\n        return await fetchSuggestions(env, model, query, ctx);\n    };\n\n    return (\n        <>\n            <div key=\"fullpreview\" className=\"flex flex-col w-full overflow-hidden scrollbar-hide-until-hover\">\n                {errorMsg && <ErrorOverlay errorMsg={errorMsg} resetOverlay={() => setErrorMsg(null)} />}\n                <div ref={contentRef} className=\"flex-grow overflow-hidden\">\n                    <SpecializedView parentRef={contentRef} model={model} />\n                </div>\n            </div>\n            <BlockHeaderSuggestionControl\n                blockRef={blockRef}\n                openAtom={model.openFileModal}\n                onClose={() => model.updateOpenFileModalAndError(false)}\n                onSelect={handleSelect}\n                onTab={handleTab}\n                fetchSuggestions={fetchSuggestionsFn}\n                placeholderText=\"Open File...\"\n            />\n        </>\n    );\n}\n\nexport { PreviewView };\n"
  },
  {
    "path": "frontend/app/view/preview/previewenv.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from \"@/app/waveenv/waveenv\";\n\nexport type PreviewEnv = WaveEnvSubset<{\n    electron: {\n        onQuicklook: WaveEnv[\"electron\"][\"onQuicklook\"];\n    };\n    rpc: {\n        ConnEnsureCommand: WaveEnv[\"rpc\"][\"ConnEnsureCommand\"];\n        FileInfoCommand: WaveEnv[\"rpc\"][\"FileInfoCommand\"];\n        FileReadCommand: WaveEnv[\"rpc\"][\"FileReadCommand\"];\n        FileWriteCommand: WaveEnv[\"rpc\"][\"FileWriteCommand\"];\n        FileMoveCommand: WaveEnv[\"rpc\"][\"FileMoveCommand\"];\n        FileDeleteCommand: WaveEnv[\"rpc\"][\"FileDeleteCommand\"];\n        SetConfigCommand: WaveEnv[\"rpc\"][\"SetConfigCommand\"];\n        SetMetaCommand: WaveEnv[\"rpc\"][\"SetMetaCommand\"];\n        FetchSuggestionsCommand: WaveEnv[\"rpc\"][\"FetchSuggestionsCommand\"];\n        DisposeSuggestionsCommand: WaveEnv[\"rpc\"][\"DisposeSuggestionsCommand\"];\n        FileCopyCommand: WaveEnv[\"rpc\"][\"FileCopyCommand\"];\n        FileCreateCommand: WaveEnv[\"rpc\"][\"FileCreateCommand\"];\n        FileMkdirCommand: WaveEnv[\"rpc\"][\"FileMkdirCommand\"];\n    };\n    atoms: {\n        fullConfigAtom: WaveEnv[\"atoms\"][\"fullConfigAtom\"];\n    };\n    services: {\n        object: WaveEnv[\"services\"][\"object\"];\n    };\n    wos: WaveEnv[\"wos\"];\n    getSettingsKeyAtom: SettingsKeyAtomFnType<\"preview:showhiddenfiles\" | \"editor:fontsize\" | \"preview:defaultsort\">;\n    getConnStatusAtom: WaveEnv[\"getConnStatusAtom\"];\n}>;\n"
  },
  {
    "path": "frontend/app/view/quicktipsview/quicktipsview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { BlockNodeModel } from \"@/app/block/blocktypes\";\nimport { QuickTips } from \"@/app/element/quicktips\";\nimport { globalStore } from \"@/app/store/global\";\nimport type { TabModel } from \"@/app/store/tab-model\";\nimport { Atom, atom, PrimitiveAtom } from \"jotai\";\n\nclass QuickTipsViewModel implements ViewModel {\n    viewType: string;\n    blockId: string;\n    nodeModel: BlockNodeModel;\n    tabModel: TabModel;\n    showTocAtom: PrimitiveAtom<boolean>;\n    endIconButtons: Atom<IconButtonDecl[]>;\n\n    constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) {\n        this.blockId = blockId;\n        this.nodeModel = nodeModel;\n        this.tabModel = tabModel;\n        this.viewType = \"tips\";\n        this.showTocAtom = atom(false);\n    }\n\n    get viewComponent(): ViewComponent {\n        return QuickTipsView;\n    }\n\n    showTocToggle() {\n        globalStore.set(this.showTocAtom, !globalStore.get(this.showTocAtom));\n    }\n}\n\nfunction QuickTipsView({ model }: { model: QuickTipsViewModel }) {\n    return (\n        <div className=\"px-[5px] py-[10px] overflow-auto w-full\">\n            <QuickTips />\n        </div>\n    );\n}\n\nexport { QuickTipsViewModel };\n"
  },
  {
    "path": "frontend/app/view/sysinfo/sysinfo.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { makeORef } from \"@/app/store/wos\";\nimport * as util from \"@/util/util\";\nimport * as Plot from \"@observablehq/plot\";\nimport clsx from \"clsx\";\nimport dayjs from \"dayjs\";\nimport * as htl from \"htl\";\nimport * as jotai from \"jotai\";\nimport * as React from \"react\";\n\nimport { useDimensionsWithExistingRef } from \"@/app/hook/useDimensions\";\nimport { waveEventSubscribeSingle } from \"@/app/store/wps\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport type { BlockMetaKeyAtomFnType, WaveEnv, WaveEnvSubset } from \"@/app/waveenv/waveenv\";\nimport { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from \"overlayscrollbars-react\";\n\nexport type SysinfoEnv = WaveEnvSubset<{\n    rpc: {\n        EventReadHistoryCommand: WaveEnv[\"rpc\"][\"EventReadHistoryCommand\"];\n        SetMetaCommand: WaveEnv[\"rpc\"][\"SetMetaCommand\"];\n    };\n    atoms: {\n        fullConfigAtom: WaveEnv[\"atoms\"][\"fullConfigAtom\"];\n    };\n    getConnStatusAtom: WaveEnv[\"getConnStatusAtom\"];\n    getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<\"graph:numpoints\" | \"sysinfo:type\" | \"connection\" | \"count\">;\n}>;\n\nconst DefaultNumPoints = 120;\n\ntype DataItem = {\n    ts: number;\n    [k: string]: number;\n};\n\nfunction defaultCpuMeta(name: string): TimeSeriesMeta {\n    return {\n        name: name,\n        label: \"%\",\n        miny: 0,\n        maxy: 100,\n        color: \"var(--sysinfo-cpu-color)\",\n        decimalPlaces: 0,\n    };\n}\n\nfunction defaultMemMeta(name: string, maxY: string): TimeSeriesMeta {\n    return {\n        name: name,\n        label: \"GB\",\n        miny: 0,\n        maxy: maxY,\n        color: \"var(--sysinfo-mem-color)\",\n        decimalPlaces: 1,\n    };\n}\n\nconst PlotTypes: object = {\n    CPU: function (_dataItem: DataItem): Array<string> {\n        return [\"cpu\"];\n    },\n    Mem: function (_dataItem: DataItem): Array<string> {\n        return [\"mem:used\"];\n    },\n    \"CPU + Mem\": function (_dataItem: DataItem): Array<string> {\n        return [\"cpu\", \"mem:used\"];\n    },\n    \"All CPU\": function (dataItem: DataItem): Array<string> {\n        return Object.keys(dataItem)\n            .filter((item) => item.startsWith(\"cpu\") && item != \"cpu\")\n            .sort((a, b) => {\n                const valA = parseInt(a.replace(\"cpu:\", \"\"));\n                const valB = parseInt(b.replace(\"cpu:\", \"\"));\n                return valA - valB;\n            });\n    },\n};\n\nconst DefaultPlotMeta = {\n    cpu: defaultCpuMeta(\"CPU %\"),\n    \"mem:total\": defaultMemMeta(\"Memory Total\", \"mem:total\"),\n    \"mem:used\": defaultMemMeta(\"Memory Used\", \"mem:total\"),\n    \"mem:free\": defaultMemMeta(\"Memory Free\", \"mem:total\"),\n    \"mem:available\": defaultMemMeta(\"Memory Available\", \"mem:total\"),\n};\nfor (let i = 0; i < 32; i++) {\n    DefaultPlotMeta[`cpu:${i}`] = defaultCpuMeta(`Core ${i}`);\n}\n\nfunction convertWaveEventToDataItem(event: Extract<WaveEvent, { event: \"sysinfo\" }>): DataItem {\n    const eventData = event.data;\n    if (eventData == null || eventData.ts == null || eventData.values == null) {\n        return null;\n    }\n    const dataItem = { ts: eventData.ts };\n    for (const key in eventData.values) {\n        dataItem[key] = eventData.values[key];\n    }\n    return dataItem;\n}\n\nclass SysinfoViewModel implements ViewModel {\n    viewType: string;\n    termMode: jotai.Atom<string>;\n    htmlElemFocusRef: React.RefObject<HTMLInputElement>;\n    blockId: string;\n    viewIcon: jotai.Atom<string>;\n    viewText: jotai.Atom<string>;\n    viewName: jotai.Atom<string>;\n    dataAtom: jotai.PrimitiveAtom<Array<DataItem>>;\n    addInitialDataAtom: jotai.WritableAtom<unknown, [DataItem[]], void>;\n    addContinuousDataAtom: jotai.WritableAtom<unknown, [DataItem], void>;\n    incrementCount: jotai.WritableAtom<unknown, [], Promise<void>>;\n    loadingAtom: jotai.PrimitiveAtom<boolean>;\n    numPoints: jotai.Atom<number>;\n    metrics: jotai.Atom<string[]>;\n    connection: jotai.Atom<string>;\n    manageConnection: jotai.Atom<boolean>;\n    filterOutNowsh: jotai.Atom<boolean>;\n    connStatus: jotai.Atom<ConnStatus>;\n    plotMetaAtom: jotai.PrimitiveAtom<Map<string, TimeSeriesMeta>>;\n    endIconButtons: jotai.Atom<IconButtonDecl[]>;\n    plotTypeSelectedAtom: jotai.Atom<string>;\n    env: SysinfoEnv;\n\n    constructor({ blockId, waveEnv }: ViewModelInitType) {\n        this.viewType = \"sysinfo\";\n        this.blockId = blockId;\n        this.env = waveEnv;\n        this.addInitialDataAtom = jotai.atom(null, (get, set, points) => {\n            const targetLen = get(this.numPoints) + 1;\n            try {\n                const newDataRaw = [...points];\n                if (newDataRaw.length == 0) {\n                    return;\n                }\n                const latestItemTs = newDataRaw[newDataRaw.length - 1]?.ts ?? 0;\n                const cutoffTs = latestItemTs - 1000 * targetLen;\n                const blankItemTemplate = { ...newDataRaw[newDataRaw.length - 1] };\n                for (const key in blankItemTemplate) {\n                    blankItemTemplate[key] = NaN;\n                }\n\n                const newDataFiltered = newDataRaw.filter((dataItem) => dataItem.ts >= cutoffTs);\n                if (newDataFiltered.length == 0) {\n                    return;\n                }\n                const newDataWithGaps: Array<DataItem> = [];\n                if (newDataFiltered[0].ts > cutoffTs) {\n                    const blankItemStart = { ...blankItemTemplate, ts: cutoffTs };\n                    const blankItemEnd = { ...blankItemTemplate, ts: newDataFiltered[0].ts - 1 };\n                    newDataWithGaps.push(blankItemStart);\n                    newDataWithGaps.push(blankItemEnd);\n                }\n                newDataWithGaps.push(newDataFiltered[0]);\n                for (let i = 1; i < newDataFiltered.length; i++) {\n                    const prevIdxItem = newDataFiltered[i - 1];\n                    const curIdxItem = newDataFiltered[i];\n                    const timeDiff = curIdxItem.ts - prevIdxItem.ts;\n                    if (timeDiff > 2000) {\n                        const blankItemStart = { ...blankItemTemplate, ts: prevIdxItem.ts + 1, blank: 1 };\n                        const blankItemEnd = { ...blankItemTemplate, ts: curIdxItem.ts - 1, blank: 1 };\n                        newDataWithGaps.push(blankItemStart);\n                        newDataWithGaps.push(blankItemEnd);\n                    }\n                    newDataWithGaps.push(curIdxItem);\n                }\n                set(this.dataAtom, newDataWithGaps);\n            } catch (e) {\n                console.log(\"Error adding data to sysinfo\", e);\n            }\n        });\n        this.addContinuousDataAtom = jotai.atom(null, (get, set, newPoint) => {\n            const targetLen = get(this.numPoints) + 1;\n            const data = get(this.dataAtom);\n            try {\n                const latestItemTs = newPoint?.ts ?? 0;\n                const cutoffTs = latestItemTs - 1000 * targetLen;\n                data.push(newPoint);\n                const newData = data.filter((dataItem) => dataItem.ts >= cutoffTs);\n                set(this.dataAtom, newData);\n            } catch (e) {\n                console.log(\"Error adding data to sysinfo\", e);\n            }\n        });\n        this.plotMetaAtom = jotai.atom(new Map(Object.entries(DefaultPlotMeta)));\n        this.manageConnection = jotai.atom(true);\n        this.filterOutNowsh = jotai.atom(true);\n        this.loadingAtom = jotai.atom(true);\n        this.numPoints = jotai.atom((get) => {\n            const metaNumPoints = get(this.env.getBlockMetaKeyAtom(blockId, \"graph:numpoints\"));\n            if (metaNumPoints == null || metaNumPoints <= 0) {\n                return DefaultNumPoints;\n            }\n            return metaNumPoints;\n        });\n        this.metrics = jotai.atom((get) => {\n            const plotType = get(this.plotTypeSelectedAtom);\n            const plotData = get(this.dataAtom);\n            try {\n                const metrics = PlotTypes[plotType](plotData[plotData.length - 1]);\n                if (metrics == null || !Array.isArray(metrics)) {\n                    return [\"cpu\"];\n                }\n                return metrics;\n            } catch (e) {\n                return [\"cpu\"];\n            }\n        });\n        this.plotTypeSelectedAtom = jotai.atom((get) => {\n            const plotType = get(this.env.getBlockMetaKeyAtom(blockId, \"sysinfo:type\"));\n            if (plotType == null || typeof plotType != \"string\") {\n                return \"CPU\";\n            }\n            return plotType;\n        });\n        this.viewIcon = jotai.atom((get) => {\n            return \"chart-line\"; // should not be hardcoded\n        });\n        this.viewName = jotai.atom((get) => {\n            return get(this.plotTypeSelectedAtom);\n        });\n        this.incrementCount = jotai.atom(null, async (get, _set) => {\n            const count = get(this.env.getBlockMetaKeyAtom(blockId, \"count\")) ?? 0;\n            await this.env.rpc.SetMetaCommand(TabRpcClient, {\n                oref: makeORef(\"block\", this.blockId),\n                meta: { count: count + 1 },\n            });\n        });\n        this.connection = jotai.atom((get) => {\n            const connValue = get(this.env.getBlockMetaKeyAtom(blockId, \"connection\"));\n            if (util.isBlank(connValue)) {\n                return \"local\";\n            }\n            return connValue;\n        });\n        this.dataAtom = jotai.atom([]);\n        this.loadInitialData();\n        this.connStatus = jotai.atom((get) => {\n            const connName = get(this.env.getBlockMetaKeyAtom(blockId, \"connection\"));\n            const connAtom = this.env.getConnStatusAtom(connName);\n            return get(connAtom);\n        });\n    }\n\n    get viewComponent(): ViewComponent {\n        return SysinfoView;\n    }\n\n    async loadInitialData() {\n        globalStore.set(this.loadingAtom, true);\n        try {\n            const numPoints = globalStore.get(this.numPoints);\n            const connName = globalStore.get(this.connection);\n            const initialData = await this.env.rpc.EventReadHistoryCommand(TabRpcClient, {\n                event: \"sysinfo\",\n                scope: connName,\n                maxitems: numPoints,\n            });\n            if (initialData == null) {\n                return;\n            }\n            this.getDefaultData();\n            const initialDataItems: DataItem[] = initialData.map(convertWaveEventToDataItem);\n            // splice the initial data into the default data (replacing the newest points)\n            //newData.splice(newData.length - initialDataItems.length, initialDataItems.length, ...initialDataItems);\n            globalStore.set(this.addInitialDataAtom, initialDataItems);\n        } catch (e) {\n            console.log(\"Error loading initial data for sysinfo\", e);\n        } finally {\n            globalStore.set(this.loadingAtom, false);\n        }\n    }\n\n    getSettingsMenuItems(): ContextMenuItem[] {\n        const fullConfig = globalStore.get(this.env.atoms.fullConfigAtom);\n        const termThemes = fullConfig?.termthemes ?? {};\n        const termThemeKeys = Object.keys(termThemes);\n        const plotData = globalStore.get(this.dataAtom);\n\n        termThemeKeys.sort((a, b) => {\n            return (termThemes[a][\"display:order\"] ?? 0) - (termThemes[b][\"display:order\"] ?? 0);\n        });\n        const fullMenu: ContextMenuItem[] = [];\n        let submenu: ContextMenuItem[];\n        if (plotData.length == 0) {\n            submenu = [];\n        } else {\n            submenu = Object.keys(PlotTypes).map((plotType) => {\n                const dataTypes = PlotTypes[plotType](plotData[plotData.length - 1]);\n                const currentlySelected = globalStore.get(this.plotTypeSelectedAtom);\n                const menuItem: ContextMenuItem = {\n                    label: plotType,\n                    type: \"radio\",\n                    checked: currentlySelected == plotType,\n                    click: async () => {\n                        await this.env.rpc.SetMetaCommand(TabRpcClient, {\n                            oref: makeORef(\"block\", this.blockId),\n                            meta: { \"graph:metrics\": dataTypes, \"sysinfo:type\": plotType },\n                        });\n                    },\n                };\n                return menuItem;\n            });\n        }\n\n        fullMenu.push({\n            label: \"Plot Type\",\n            submenu: submenu,\n        });\n        fullMenu.push({ type: \"separator\" });\n        return fullMenu;\n    }\n\n    getDefaultData(): DataItem[] {\n        // set it back one to avoid backwards line being possible\n        const numPoints = globalStore.get(this.numPoints);\n        const currentTime = Date.now() - 1000;\n        const points: DataItem[] = [];\n        for (let i = numPoints; i > -1; i--) {\n            points.push({ ts: currentTime - i * 1000 });\n        }\n        return points;\n    }\n}\n\nconst _plotColors = [\"#58C142\", \"#FFC107\", \"#FF5722\", \"#2196F3\", \"#9C27B0\", \"#00BCD4\", \"#FFEB3B\", \"#795548\"];\n\ntype SysinfoViewProps = {\n    blockId: string;\n    model: SysinfoViewModel;\n};\n\nfunction resolveDomainBound(value: number | string, dataItem: DataItem): number | undefined {\n    if (typeof value == \"number\") {\n        return value;\n    } else if (typeof value == \"string\") {\n        return dataItem?.[value];\n    } else {\n        return undefined;\n    }\n}\n\nfunction SysinfoView({ model, blockId }: SysinfoViewProps) {\n    const connName = jotai.useAtomValue(model.connection);\n    const lastConnName = React.useRef(connName);\n    const connStatus = jotai.useAtomValue(model.connStatus);\n    const addContinuousData = jotai.useSetAtom(model.addContinuousDataAtom);\n    const loading = jotai.useAtomValue(model.loadingAtom);\n\n    React.useEffect(() => {\n        if (connStatus?.status != \"connected\") {\n            return;\n        }\n        if (lastConnName.current !== connName) {\n            lastConnName.current = connName;\n            model.loadInitialData();\n        }\n    }, [connStatus.status, connName]);\n    React.useEffect(() => {\n        const unsubFn = waveEventSubscribeSingle({\n            eventType: \"sysinfo\",\n            scope: connName,\n            handler: (event) => {\n                const loading = globalStore.get(model.loadingAtom);\n                if (loading) {\n                    return;\n                }\n                const dataItem = convertWaveEventToDataItem(event);\n                const prevData = globalStore.get(model.dataAtom);\n                const prevLastTs = prevData[prevData.length - 1]?.ts ?? 0;\n                if (dataItem.ts - prevLastTs > 2000) {\n                    model.loadInitialData();\n                } else {\n                    addContinuousData(dataItem);\n                }\n            },\n        });\n        console.log(\"subscribe to sysinfo\", connName);\n        return () => {\n            unsubFn();\n        };\n    }, [connName, addContinuousData]);\n    if (connStatus?.status != \"connected\") {\n        return null;\n    }\n    if (loading) {\n        return null;\n    }\n    return <SysinfoViewInner key={connStatus?.connection ?? \"local\"} blockId={blockId} model={model} />;\n}\n\ntype SingleLinePlotProps = {\n    plotData: Array<DataItem>;\n    yval: string;\n    yvalMeta: TimeSeriesMeta;\n    blockId: string;\n    defaultColor: string;\n    title?: boolean;\n    sparkline?: boolean;\n    targetLen: number;\n};\n\nfunction SingleLinePlot({\n    plotData,\n    yval,\n    yvalMeta,\n    blockId,\n    defaultColor,\n    title = false,\n    sparkline = false,\n    targetLen,\n}: SingleLinePlotProps) {\n    const containerRef = React.useRef<HTMLInputElement>(null);\n    const domRect = useDimensionsWithExistingRef(containerRef, 300);\n    const plotHeight = domRect?.height ?? 0;\n    const plotWidth = domRect?.width ?? 0;\n    const marks: Plot.Markish[] = [];\n    const decimalPlaces = yvalMeta?.decimalPlaces ?? 0;\n    let color = yvalMeta?.color;\n    if (!color) {\n        color = defaultColor;\n    }\n    marks.push(\n        () => htl.svg`<defs>\n      <linearGradient id=\"gradient-${blockId}-${yval}\" gradientTransform=\"rotate(90)\">\n        <stop offset=\"0%\" stop-color=\"${color}\" stop-opacity=\"0.7\" />\n        <stop offset=\"100%\" stop-color=\"${color}\" stop-opacity=\"0\" />\n      </linearGradient>\n\t      </defs>`\n    );\n\n    marks.push(\n        Plot.lineY(plotData, {\n            stroke: color,\n            strokeWidth: 2,\n            x: \"ts\",\n            y: yval,\n        })\n    );\n\n    // only add the gradient for single items\n    marks.push(\n        Plot.areaY(plotData, {\n            fill: `url(#gradient-${blockId}-${yval})`,\n            x: \"ts\",\n            y: yval,\n        })\n    );\n    if (title) {\n        marks.push(\n            Plot.text([yvalMeta?.name], {\n                frameAnchor: \"top-left\",\n                dx: 4,\n                fill: \"var(--grey-text-color)\",\n            })\n        );\n    }\n    const labelY = yvalMeta?.label ?? \"?\";\n    marks.push(\n        Plot.ruleX(\n            plotData,\n            Plot.pointerX({ x: \"ts\", py: yval, stroke: \"var(--grey-text-color)\", strokeWidth: 1, strokeDasharray: 2 })\n        )\n    );\n    marks.push(\n        Plot.ruleY(\n            plotData,\n            Plot.pointerX({ px: \"ts\", y: yval, stroke: \"var(--grey-text-color)\", strokeWidth: 1, strokeDasharray: 2 })\n        )\n    );\n    marks.push(\n        Plot.tip(\n            plotData,\n            Plot.pointerX({\n                x: \"ts\",\n                y: yval,\n                fill: \"var(--main-bg-color)\",\n                anchor: \"middle\",\n                dy: -30,\n                title: (d) =>\n                    `${dayjs.unix(d.ts / 1000).format(\"HH:mm:ss\")} ${Number(d[yval]).toFixed(decimalPlaces)}${labelY}`,\n                textPadding: 3,\n            })\n        )\n    );\n    marks.push(\n        Plot.dot(\n            plotData,\n            Plot.pointerX({ x: \"ts\", y: yval, fill: color, r: 3, stroke: \"var(--main-text-color)\", strokeWidth: 1 })\n        )\n    );\n    const maxY = resolveDomainBound(yvalMeta?.maxy, plotData[plotData.length - 1]) ?? 100;\n    const minY = resolveDomainBound(yvalMeta?.miny, plotData[plotData.length - 1]) ?? 0;\n    const maxX = plotData[plotData.length - 1].ts;\n    const minX = maxX - targetLen * 1000;\n    const plot = Plot.plot({\n        axis: !sparkline,\n        x: {\n            grid: true,\n            label: \"time\",\n            tickFormat: (d) => `${dayjs.unix(d / 1000).format(\"HH:mm:ss\")}`,\n            domain: [minX, maxX],\n        },\n        y: { label: labelY, domain: [minY, maxY] },\n        width: plotWidth,\n        height: plotHeight,\n        marks: marks,\n    });\n\n    React.useEffect(() => {\n        containerRef.current.append(plot);\n\n        return () => {\n            plot.remove();\n        };\n    }, [plot, plotWidth, plotHeight]);\n\n    return <div ref={containerRef} className=\"min-h-[100px]\" />;\n}\n\nconst SysinfoViewInner = React.memo(({ model }: SysinfoViewProps) => {\n    const plotData = jotai.useAtomValue(model.dataAtom);\n    const yvals = jotai.useAtomValue(model.metrics);\n    const plotMeta = jotai.useAtomValue(model.plotMetaAtom);\n    const osRef = React.useRef<OverlayScrollbarsComponentRef>(null);\n    const targetLen = jotai.useAtomValue(model.numPoints) + 1;\n    let title = false;\n    let cols2 = false;\n    if (yvals.length > 1) {\n        title = true;\n    }\n    if (yvals.length > 2) {\n        cols2 = true;\n    }\n\n    return (\n        <OverlayScrollbarsComponent\n            ref={osRef}\n            className=\"flex flex-col flex-grow mb-0 overflow-y-auto\"\n            options={{ scrollbars: { autoHide: \"leave\" } }}\n        >\n            <div\n                className={clsx(\"w-full h-full grid grid-rows-[repeat(auto-fit,minmax(100px,1fr))] gap-[10px]\", {\n                    \"grid-cols-2\": cols2,\n                })}\n            >\n                {plotData &&\n                    plotData.length > 0 &&\n                    yvals.map((yval, _idx) => {\n                        return (\n                            <SingleLinePlot\n                                key={`plot-${model.blockId}-${yval}`}\n                                plotData={plotData}\n                                yval={yval}\n                                yvalMeta={plotMeta.get(yval)}\n                                blockId={model.blockId}\n                                defaultColor={\"var(--accent-color)\"}\n                                title={title}\n                                targetLen={targetLen}\n                            />\n                        );\n                    })}\n            </div>\n        </OverlayScrollbarsComponent>\n    );\n});\n\nexport { SysinfoViewModel };\n"
  },
  {
    "path": "frontend/app/view/term/fitaddon.ts",
    "content": "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// This file is a copy of the original xterm.js file, with the following changes:\n// - removed the allowance for the scrollbar\n\nimport type { FitAddon as IFitApi } from \"@xterm/addon-fit\";\nimport type { ITerminalAddon, Terminal } from \"@xterm/xterm\";\nimport { IRenderDimensions } from \"@xterm/xterm/src/browser/renderer/shared/Types\";\n\ninterface ITerminalDimensions {\n    /**\n     * The number of rows in the terminal.\n     */\n    rows: number;\n\n    /**\n     * The number of columns in the terminal.\n     */\n    cols: number;\n}\n\nconst MINIMUM_COLS = 2;\nconst MINIMUM_ROWS = 1;\n\nexport class FitAddon implements ITerminalAddon, IFitApi {\n    private _terminal: Terminal | undefined;\n    public scrollbarWidth: number | null = null;\n\n    public activate(terminal: Terminal): void {\n        this._terminal = terminal;\n    }\n\n    public dispose(): void {}\n\n    public fit(): void {\n        const dims = this.proposeDimensions();\n        if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {\n            return;\n        }\n\n        // TODO: Remove reliance on private API\n        const core = (this._terminal as any)._core;\n\n        // Force a full render\n        if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) {\n            core._renderService.clear();\n            this._terminal.resize(dims.cols, dims.rows);\n        }\n    }\n\n    public proposeDimensions(): ITerminalDimensions | undefined {\n        if (!this._terminal) {\n            return undefined;\n        }\n\n        if (!this._terminal.element || !this._terminal.element.parentElement) {\n            return undefined;\n        }\n\n        // TODO: Remove reliance on private API\n        const core = (this._terminal as any)._core;\n        const dims: IRenderDimensions = core._renderService.dimensions;\n\n        if (dims.css.cell.width === 0 || dims.css.cell.height === 0) {\n            return undefined;\n        }\n\n        // UPDATED CODE (removed reliance on FALLBACK_SCROLL_BAR_WIDTH in viewport, allow just setting the scrollbar width when known)\n        let scrollbarWidth: number;\n        if (this.scrollbarWidth != null) {\n            scrollbarWidth = this.scrollbarWidth;\n        } else {\n            scrollbarWidth = core.viewport._viewportElement.offsetWidth - core.viewport._scrollArea.offsetWidth;\n        }\n        // END UPDATED CODE\n\n        const parentElementStyle = window.getComputedStyle(this._terminal.element.parentElement);\n        const parentElementHeight = parseInt(parentElementStyle.getPropertyValue(\"height\"));\n        const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue(\"width\")));\n        const elementStyle = window.getComputedStyle(this._terminal.element);\n        const elementPadding = {\n            top: parseInt(elementStyle.getPropertyValue(\"padding-top\")),\n            bottom: parseInt(elementStyle.getPropertyValue(\"padding-bottom\")),\n            right: parseInt(elementStyle.getPropertyValue(\"padding-right\")),\n            left: parseInt(elementStyle.getPropertyValue(\"padding-left\")),\n        };\n        const elementPaddingVer = elementPadding.top + elementPadding.bottom;\n        const elementPaddingHor = elementPadding.right + elementPadding.left;\n        const availableHeight = parentElementHeight - elementPaddingVer;\n        const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth;\n        const geometry = {\n            cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),\n            rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height)),\n        };\n        return geometry;\n    }\n}\n"
  },
  {
    "path": "frontend/app/view/term/ijson.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport * as React from \"react\";\nimport Frame from \"react-frame-component\";\n\ntype IJsonNode = {\n    tag: string;\n    props?: Record<string, any>;\n    children?: (IJsonNode | string)[];\n};\n\nconst TagMap: Record<string, React.ComponentType<{ node: IJsonNode }>> = {};\n\nfunction convertNodeToTag(node: IJsonNode | string, idx?: number): React.ReactNode {\n    if (node == null) {\n        return null;\n    }\n    if (idx == null) {\n        idx = 0;\n    }\n    if (typeof node === \"string\") {\n        return node;\n    }\n    let key = node.props?.key ?? \"child-\" + idx;\n    let TagComp = TagMap[node.tag];\n    if (!TagComp) {\n        return <div key={key}>Unknown tag:{node.tag}</div>;\n    }\n    return <TagComp key={key} node={node} />;\n}\n\nfunction IJsonHtmlTag({ node }: { node: IJsonNode }) {\n    let { tag, props, children } = node;\n    let divProps = {};\n    if (props != null) {\n        for (let [key, val] of Object.entries(props)) {\n            if (key.startsWith(\"on\")) {\n                divProps[key] = (e: any) => {\n                    console.log(\"handler\", key, val);\n                };\n            } else {\n                divProps[key] = val;\n            }\n        }\n    }\n    let childrenComps: React.ReactNode[] = [];\n    if (children != null) {\n        for (let idx = 0; idx < children.length; idx++) {\n            let comp = convertNodeToTag(children[idx], idx);\n            if (comp != null) {\n                childrenComps.push(comp);\n            }\n        }\n    }\n    return React.createElement(tag, divProps, childrenComps);\n}\n\nTagMap[\"div\"] = IJsonHtmlTag;\nTagMap[\"b\"] = IJsonHtmlTag;\nTagMap[\"i\"] = IJsonHtmlTag;\nTagMap[\"p\"] = IJsonHtmlTag;\nTagMap[\"s\"] = IJsonHtmlTag;\nTagMap[\"span\"] = IJsonHtmlTag;\nTagMap[\"a\"] = IJsonHtmlTag;\nTagMap[\"img\"] = IJsonHtmlTag;\nTagMap[\"h1\"] = IJsonHtmlTag;\nTagMap[\"h2\"] = IJsonHtmlTag;\nTagMap[\"h3\"] = IJsonHtmlTag;\nTagMap[\"h4\"] = IJsonHtmlTag;\nTagMap[\"h5\"] = IJsonHtmlTag;\nTagMap[\"h6\"] = IJsonHtmlTag;\nTagMap[\"ul\"] = IJsonHtmlTag;\nTagMap[\"ol\"] = IJsonHtmlTag;\nTagMap[\"li\"] = IJsonHtmlTag;\nTagMap[\"input\"] = IJsonHtmlTag;\nTagMap[\"button\"] = IJsonHtmlTag;\nTagMap[\"textarea\"] = IJsonHtmlTag;\nTagMap[\"select\"] = IJsonHtmlTag;\nTagMap[\"option\"] = IJsonHtmlTag;\nTagMap[\"form\"] = IJsonHtmlTag;\n\nfunction IJsonView({ rootNode }: { rootNode: IJsonNode }) {\n    // TODO fix this huge inline style\n    return (\n        <div className=\"ijson\">\n            <Frame>\n                <style>\n                    {`\n*::before, *::after { box-sizing: border-box; }\n* { margin: 0; }\nbody { line-height: 1.2; -webkit-font-smoothing: antialiased; }\nimg, picture, video, canvas, sgv { display: block; }\ninput, button, textarea, select { font: inherit; }\n\nbody {\n\tdisplay: flex;\n\tflex-direction: column;\n\twidth: 100vw;\n\theight: 100vh;\n\tbackground-color: #000;\n\tcolor: #fff;\n\tfont: normal 15px / normal \"Lato\", sans-serif;\n}\n\n.fixed-font {\n\tnormal 12px / normal \"Hack\", monospace;\n}\n\t\t\t\t\t`}\n                </style>\n                {convertNodeToTag(rootNode)}\n            </Frame>\n        </div>\n    );\n}\n\nexport { IJsonView };\n"
  },
  {
    "path": "frontend/app/view/term/osc-handlers.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport {\n    getApi,\n    getBlockMetaKeyAtom,\n    getBlockTermDurableAtom,\n    getOverrideConfigAtom,\n    globalStore,\n    recordTEvent,\n    WOS,\n} from \"@/store/global\";\nimport { base64ToString, fireAndForget, isSshConnName, isWslConnName } from \"@/util/util\";\nimport debug from \"debug\";\nimport type { TermWrap } from \"./termwrap\";\n\nconst dlog = debug(\"wave:termwrap\");\n\nconst Osc52MaxDecodedSize = 75 * 1024; // max clipboard size for OSC 52 (matches common terminal implementations)\nconst Osc52MaxRawLength = 128 * 1024; // includes selector + base64 + whitespace (rough check)\n\n// OSC 16162 - Shell Integration Commands\n// See aiprompts/wave-osc-16162.md for full documentation\nexport type ShellIntegrationStatus = \"ready\" | \"running-command\";\n\ntype Osc16162Command =\n    | { command: \"A\"; data: Record<string, never> }\n    | { command: \"C\"; data: { cmd64?: string } }\n    | {\n          command: \"M\";\n          data: {\n              shell?: string;\n              shellversion?: string;\n              uname?: string;\n              integration?: boolean;\n              omz?: boolean;\n              comp?: string;\n          };\n      }\n    | { command: \"D\"; data: { exitcode?: number } }\n    | { command: \"I\"; data: { inputempty?: boolean } }\n    | { command: \"R\"; data: Record<string, never> };\n\nfunction checkCommandForTelemetry(decodedCmd: string) {\n    if (!decodedCmd) {\n        return;\n    }\n\n    if (decodedCmd.startsWith(\"ssh \")) {\n        recordTEvent(\"conn:connect\", { \"conn:conntype\": \"ssh-manual\" });\n        return;\n    }\n\n    const editorsRegex = /^(vim|vi|nano|nvim)\\b/;\n    if (editorsRegex.test(decodedCmd)) {\n        recordTEvent(\"action:term\", { \"action:type\": \"cli-edit\" });\n        return;\n    }\n\n    const tailFollowRegex = /(^|\\|\\s*)tail\\s+-[fF]\\b/;\n    if (tailFollowRegex.test(decodedCmd)) {\n        recordTEvent(\"action:term\", { \"action:type\": \"cli-tailf\" });\n        return;\n    }\n\n    const claudeRegex = /^claude\\b/;\n    if (claudeRegex.test(decodedCmd)) {\n        recordTEvent(\"action:term\", { \"action:type\": \"claude\" });\n        return;\n    }\n\n    const opencodeRegex = /^opencode\\b/;\n    if (opencodeRegex.test(decodedCmd)) {\n        recordTEvent(\"action:term\", { \"action:type\": \"opencode\" });\n        return;\n    }\n}\n\nfunction handleShellIntegrationCommandStart(\n    termWrap: TermWrap,\n    blockId: string,\n    cmd: { command: \"C\"; data: { cmd64?: string } },\n    rtInfo: ObjRTInfo // this is passed by reference and modified inside of this function\n): void {\n    rtInfo[\"shell:state\"] = \"running-command\";\n    globalStore.set(termWrap.shellIntegrationStatusAtom, \"running-command\");\n    const connName = globalStore.get(getBlockMetaKeyAtom(blockId, \"connection\")) ?? \"\";\n    const isRemote = isSshConnName(connName);\n    const isWsl = isWslConnName(connName);\n    const isDurable = globalStore.get(getBlockTermDurableAtom(blockId)) ?? false;\n    getApi().incrementTermCommands({ isRemote, isWsl, isDurable });\n    if (cmd.data.cmd64) {\n        const decodedLen = Math.ceil(cmd.data.cmd64.length * 0.75);\n        if (decodedLen > 8192) {\n            rtInfo[\"shell:lastcmd\"] = `# command too large (${decodedLen} bytes)`;\n            globalStore.set(termWrap.lastCommandAtom, rtInfo[\"shell:lastcmd\"]);\n        } else {\n            try {\n                const decodedCmd = base64ToString(cmd.data.cmd64);\n                rtInfo[\"shell:lastcmd\"] = decodedCmd;\n                globalStore.set(termWrap.lastCommandAtom, decodedCmd);\n                checkCommandForTelemetry(decodedCmd);\n            } catch (e) {\n                console.error(\"Error decoding cmd64:\", e);\n                rtInfo[\"shell:lastcmd\"] = null;\n                globalStore.set(termWrap.lastCommandAtom, null);\n            }\n        }\n    } else {\n        rtInfo[\"shell:lastcmd\"] = null;\n        globalStore.set(termWrap.lastCommandAtom, null);\n    }\n    rtInfo[\"shell:lastcmdexitcode\"] = null;\n}\n\n// for xterm OSC handlers, we return true always because we \"own\" the OSC number.\n// even if data is invalid we don't want to propagate to other handlers.\nexport function handleOsc52Command(data: string, blockId: string, loaded: boolean, termWrap: TermWrap): boolean {\n    if (!loaded) {\n        return true;\n    }\n    const osc52Mode = globalStore.get(getOverrideConfigAtom(blockId, \"term:osc52\")) ?? \"always\";\n    if (osc52Mode === \"focus\") {\n        const isBlockFocused = termWrap.nodeModel ? globalStore.get(termWrap.nodeModel.isFocused) : false;\n        if (!document.hasFocus() || !isBlockFocused) {\n            console.log(\"OSC 52: rejected, window or block not focused\");\n            return true;\n        }\n    }\n    if (!data || data.length === 0) {\n        console.log(\"OSC 52: empty data received\");\n        return true;\n    }\n    if (data.length > Osc52MaxRawLength) {\n        console.log(\"OSC 52: raw data too large\", data.length);\n        return true;\n    }\n\n    const semicolonIndex = data.indexOf(\";\");\n    if (semicolonIndex === -1) {\n        console.log(\"OSC 52: invalid format (no semicolon)\", data.substring(0, 50));\n        return true;\n    }\n\n    const clipboardSelection = data.substring(0, semicolonIndex);\n    const base64Data = data.substring(semicolonIndex + 1);\n\n    // clipboard query (\"?\") is not supported for security (prevents clipboard theft)\n    if (base64Data === \"?\") {\n        console.log(\"OSC 52: clipboard query not supported\");\n        return true;\n    }\n\n    if (base64Data.length === 0) {\n        return true;\n    }\n\n    if (clipboardSelection.length > 10) {\n        console.log(\"OSC 52: clipboard selection too long\", clipboardSelection);\n        return true;\n    }\n\n    const estimatedDecodedSize = Math.ceil(base64Data.length * 0.75);\n    if (estimatedDecodedSize > Osc52MaxDecodedSize) {\n        console.log(\"OSC 52: data too large\", estimatedDecodedSize, \"bytes\");\n        return true;\n    }\n\n    try {\n        // strip whitespace from base64 data (some terminals chunk with newlines per RFC 4648)\n        const cleanBase64Data = base64Data.replace(/\\s+/g, \"\");\n        const decodedText = base64ToString(cleanBase64Data);\n\n        // validate actual decoded size (base64 estimate can be off for multi-byte UTF-8)\n        const actualByteSize = new TextEncoder().encode(decodedText).length;\n        if (actualByteSize > Osc52MaxDecodedSize) {\n            console.log(\"OSC 52: decoded text too large\", actualByteSize, \"bytes\");\n            return true;\n        }\n\n        fireAndForget(async () => {\n            try {\n                await navigator.clipboard.writeText(decodedText);\n                dlog(\"OSC 52: copied\", decodedText.length, \"characters to clipboard\");\n            } catch (err) {\n                console.error(\"OSC 52: clipboard write failed:\", err);\n            }\n        });\n    } catch (e) {\n        console.error(\"OSC 52: base64 decode error:\", e);\n    }\n\n    return true;\n}\n\n// for xterm handlers, we return true always because we \"own\" OSC 7.\n// even if it is invalid we dont want to propagate to other handlers\nexport function handleOsc7Command(data: string, blockId: string, loaded: boolean): boolean {\n    if (!loaded) {\n        return true;\n    }\n    if (data == null || data.length == 0) {\n        console.log(\"Invalid OSC 7 command received (empty)\");\n        return true;\n    }\n    if (data.length > 1024) {\n        console.log(\"Invalid OSC 7, data length too long\", data.length);\n        return true;\n    }\n\n    let pathPart: string;\n    try {\n        const url = new URL(data);\n        if (url.protocol !== \"file:\") {\n            console.log(\"Invalid OSC 7 command received (non-file protocol)\", data);\n            return true;\n        }\n        pathPart = decodeURIComponent(url.pathname);\n\n        // Normalize double slashes at the beginning to single slash\n        if (pathPart.startsWith(\"//\")) {\n            pathPart = pathPart.substring(1);\n        }\n\n        // Handle Windows paths (e.g., /C:/... or /D:\\...)\n        if (/^\\/[a-zA-Z]:[\\\\/]/.test(pathPart)) {\n            // Strip leading slash and normalize to forward slashes\n            pathPart = pathPart.substring(1).replace(/\\\\/g, \"/\");\n        }\n\n        // Handle UNC paths (e.g., /\\\\server\\share)\n        if (pathPart.startsWith(\"/\\\\\\\\\")) {\n            // Strip leading slash but keep backslashes for UNC\n            pathPart = pathPart.substring(1);\n        }\n    } catch (e) {\n        console.log(\"Invalid OSC 7 command received (parse error)\", data, e);\n        return true;\n    }\n\n    setTimeout(() => {\n        fireAndForget(async () => {\n            await RpcApi.SetMetaCommand(TabRpcClient, {\n                oref: WOS.makeORef(\"block\", blockId),\n                meta: { \"cmd:cwd\": pathPart },\n            });\n\n            const rtInfo = { \"shell:hascurcwd\": true };\n            const rtInfoData: CommandSetRTInfoData = {\n                oref: WOS.makeORef(\"block\", blockId),\n                data: rtInfo,\n            };\n            await RpcApi.SetRTInfoCommand(TabRpcClient, rtInfoData).catch((e) =>\n                console.log(\"error setting RT info\", e)\n            );\n        });\n    }, 0);\n    return true;\n}\n\nexport function handleOsc16162Command(data: string, blockId: string, loaded: boolean, termWrap: TermWrap): boolean {\n    const terminal = termWrap.terminal;\n    if (!loaded) {\n        return true;\n    }\n    if (!data || data.length === 0) {\n        return true;\n    }\n\n    const parts = data.split(\";\");\n    const commandStr = parts[0];\n    const jsonDataStr = parts.length > 1 ? parts.slice(1).join(\";\") : null;\n    let parsedData: Record<string, any> = {};\n    if (jsonDataStr) {\n        try {\n            parsedData = JSON.parse(jsonDataStr);\n        } catch (e) {\n            console.error(\"Error parsing OSC 16162 JSON data:\", e);\n        }\n    }\n\n    const cmd: Osc16162Command = { command: commandStr, data: parsedData } as Osc16162Command;\n    const rtInfo: ObjRTInfo = {};\n    switch (cmd.command) {\n        case \"A\": {\n            rtInfo[\"shell:state\"] = \"ready\";\n            globalStore.set(termWrap.shellIntegrationStatusAtom, \"ready\");\n            const marker = terminal.registerMarker(0);\n            if (marker) {\n                termWrap.promptMarkers.push(marker);\n                // addTestMarkerDecoration(terminal, marker, termWrap);\n                marker.onDispose(() => {\n                    const idx = termWrap.promptMarkers.indexOf(marker);\n                    if (idx !== -1) {\n                        termWrap.promptMarkers.splice(idx, 1);\n                    }\n                });\n            }\n            break;\n        }\n        case \"C\":\n            handleShellIntegrationCommandStart(termWrap, blockId, cmd, rtInfo);\n            break;\n        case \"M\":\n            if (cmd.data.shell) {\n                rtInfo[\"shell:type\"] = cmd.data.shell;\n            }\n            if (cmd.data.shellversion) {\n                rtInfo[\"shell:version\"] = cmd.data.shellversion;\n            }\n            if (cmd.data.uname) {\n                rtInfo[\"shell:uname\"] = cmd.data.uname;\n            }\n            if (cmd.data.integration != null) {\n                rtInfo[\"shell:integration\"] = cmd.data.integration;\n            }\n            if (cmd.data.omz != null) {\n                rtInfo[\"shell:omz\"] = cmd.data.omz;\n            }\n            if (cmd.data.comp != null) {\n                rtInfo[\"shell:comp\"] = cmd.data.comp;\n            }\n            break;\n        case \"D\":\n            if (cmd.data.exitcode != null) {\n                rtInfo[\"shell:lastcmdexitcode\"] = cmd.data.exitcode;\n            } else {\n                rtInfo[\"shell:lastcmdexitcode\"] = null;\n            }\n            break;\n        case \"I\":\n            if (cmd.data.inputempty != null) {\n                rtInfo[\"shell:inputempty\"] = cmd.data.inputempty;\n            }\n            break;\n        case \"R\":\n            globalStore.set(termWrap.shellIntegrationStatusAtom, null);\n            if (terminal.buffer.active.type === \"alternate\") {\n                terminal.write(\"\\x1b[?1049l\");\n            }\n            break;\n    }\n\n    if (Object.keys(rtInfo).length > 0) {\n        setTimeout(() => {\n            fireAndForget(async () => {\n                const rtInfoData: CommandSetRTInfoData = {\n                    oref: WOS.makeORef(\"block\", blockId),\n                    data: rtInfo,\n                };\n                await RpcApi.SetRTInfoCommand(TabRpcClient, rtInfoData).catch((e) =>\n                    console.log(\"error setting RT info (OSC 16162)\", e)\n                );\n            });\n        }, 0);\n    }\n\n    return true;\n}\n"
  },
  {
    "path": "frontend/app/view/term/shellblocking.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// Always block (TUIs / pagers / multiplexers / known interactive UIs)\nconst ALWAYS_BLOCK = [\n    // multiplexers\n    \"tmux\", \"screen\", \"byobu\", \"dtach\", \"abduco\", \"tmate\",\n    // editors/pagers\n    \"vim\", \"nvim\", \"emacs\", \"nano\", \"less\", \"more\", \"man\", \"most\", \"view\",\n    // TUIs / tools\n    \"htop\", \"top\", \"btop\", \"fzf\", \"ranger\", \"mc\", \"nnn\", \"k9s\", \"nmtui\", \"alsamixer\",\n    \"tig\", \"gdb\", \"lldb\",\n    // mail/irc\n    \"mutt\", \"neomutt\", \"alpine\", \"weechat\", \"irssi\",\n    // dialog UIs\n    \"dialog\", \"whiptail\",\n    // DB shells\n    \"psql\", \"mysql\", \"sqlite3\", \"mongo\", \"redis-cli\",\n];\n\n// Bare REPLs only block when no args\nconst BARE_REPLS = [\n    \"python\", \"python3\", \"python2\", \"node\", \"ruby\", \"perl\", \"php\", \"lua\", \"ipython\", \"bpython\", \"irb\",\n];\n\n// Shells: block only if interactive/new shell\nconst SHELLS = [\n    \"bash\", \"sh\", \"zsh\", \"fish\", \"ksh\", \"mksh\", \"dash\", \"ash\", \"tcsh\", \"csh\",\n    \"xonsh\", \"elvish\", \"nu\", \"nushell\", \"pwsh\", \"powershell\", \"cmd\",\n];\n\n// Wrappers to skip\nconst WRAPPERS = [\n    \"sudo\", \"doas\", \"pkexec\", \"rlwrap\", \"env\", \"time\", \"nice\", \"nohup\",\n    \"chrt\", \"stdbuf\", \"script\", \"scriptreplay\", \"sshpass\",\n];\n\nfunction looksInteractiveShellArgs(args: string[]): boolean {\n    return (\n        args.length === 0 ||\n        args.includes(\"-i\") ||\n        args.includes(\"--login\") ||\n        args.includes(\"-l\") ||\n        args.includes(\"-s\")\n    );\n}\n\nfunction isNonInteractiveShellExec(args: string[]): boolean {\n    return (\n        args.includes(\"-c\") ||\n        args.some((a) => a === \"-Command\" || a.startsWith(\"-Command\")) ||\n        args.some((a) => a.endsWith(\".sh\") || a.includes(\"/\"))\n    );\n}\n\nfunction isAttachLike(cmd: string, args: string[]): boolean {\n    if (cmd === \"docker\" || cmd === \"podman\") {\n        if (args[0] === \"attach\") return true;\n        if (args[0] === \"exec\") return args.some((a) => a === \"-it\" || a === \"-i\" || a === \"-t\");\n    }\n    if (cmd === \"kubectl\" || cmd === \"k3s\" || cmd === \"oc\") {\n        if (args[0] === \"attach\") return true;\n        if (args[0] === \"exec\") return args.some((a) => a === \"-it\" || a === \"-i\" || a === \"-t\");\n    }\n    if (cmd === \"lxc\" && args[0] === \"exec\") return args.some((a) => a === \"-t\" || a === \"-T\");\n    return false;\n}\n\nfunction isSshInteractive(args: string[]): boolean {\n    const hasForcedTty = args.includes(\"-t\") || args.includes(\"-tt\");\n    const hasRemoteCmd = args.some((a) => !a.startsWith(\"-\") && a.includes(\" \"));\n    return hasForcedTty || !hasRemoteCmd;\n}\n\nexport function getBlockingCommand(lastCommand: string | null, inAltBuffer: boolean): string | null {\n    if (!lastCommand) return null;\n\n    let words = lastCommand.trim().split(/\\s+/);\n    if (words.length === 0) return null;\n\n    while (words.length && WRAPPERS.includes(words[0])) {\n        words.shift();\n    }\n    if (!words.length) return null;\n\n    const first = words[0].split(\"/\").pop()!;\n    const args = words.slice(1);\n\n    if (inAltBuffer) return first;\n\n    if (ALWAYS_BLOCK.includes(first)) return first;\n\n    if (isAttachLike(first, args)) return first;\n\n    if (first === \"ssh\" || first === \"mosh\" || first === \"telnet\" || first === \"rlogin\") {\n        if (isSshInteractive(args)) return first;\n        return null;\n    }\n\n    if (first === \"su\" || first === \"machinectl\" || first === \"chroot\" || first === \"nsenter\" || first === \"lxc\") {\n        if (!args.length || SHELLS.includes(args[args.length - 1]?.split(\"/\").pop() || \"\")) return first;\n        return null;\n    }\n\n    if (SHELLS.includes(first)) {\n        if (looksInteractiveShellArgs(args)) return first;\n        if (isNonInteractiveShellExec(args)) return null;\n        return null;\n    }\n\n    if (BARE_REPLS.includes(first)) {\n        if (args.length === 0) return first;\n        return null;\n    }\n\n    return null;\n}"
  },
  {
    "path": "frontend/app/view/term/term-model.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { WaveAIModel } from \"@/app/aipanel/waveai-model\";\nimport { BlockNodeModel } from \"@/app/block/blocktypes\";\nimport { appHandleKeyDown } from \"@/app/store/keymodel\";\nimport { modalsModel } from \"@/app/store/modalmodel\";\nimport type { TabModel } from \"@/app/store/tab-model\";\nimport { waveEventSubscribeSingle } from \"@/app/store/wps\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { makeFeBlockRouteId } from \"@/app/store/wshrouter\";\nimport { DefaultRouter, TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { TerminalView } from \"@/app/view/term/term\";\nimport { TermWshClient } from \"@/app/view/term/term-wsh\";\nimport { VDomModel } from \"@/app/view/vdom/vdom-model\";\nimport { WorkspaceLayoutModel } from \"@/app/workspace/workspace-layout-model\";\nimport {\n    atoms,\n    createBlock,\n    createBlockSplitHorizontally,\n    createBlockSplitVertically,\n    getAllBlockComponentModels,\n    getApi,\n    getBlockComponentModel,\n    getBlockMetaKeyAtom,\n    getBlockTermDurableAtom,\n    getConnStatusAtom,\n    getOverrideConfigAtom,\n    getSettingsKeyAtom,\n    globalStore,\n    readAtom,\n    recordTEvent,\n    useBlockAtom,\n    WOS,\n} from \"@/store/global\";\nimport * as services from \"@/store/services\";\nimport * as keyutil from \"@/util/keyutil\";\nimport { isMacOS, isWindows } from \"@/util/platformutil\";\nimport { boundNumber, fireAndForget, stringToBase64 } from \"@/util/util\";\nimport * as jotai from \"jotai\";\nimport * as React from \"react\";\nimport { getBlockingCommand } from \"./shellblocking\";\nimport { computeTheme, DefaultTermTheme } from \"./termutil\";\nimport { TermWrap, WebGLSupported } from \"./termwrap\";\n\nexport class TermViewModel implements ViewModel {\n    viewType: string;\n    nodeModel: BlockNodeModel;\n    tabModel: TabModel;\n    connected: boolean;\n    termRef: React.RefObject<TermWrap> = { current: null };\n    blockAtom: jotai.Atom<Block>;\n    termMode: jotai.Atom<string>;\n    blockId: string;\n    viewIcon: jotai.Atom<IconButtonDecl>;\n    viewName: jotai.Atom<string>;\n    viewText: jotai.Atom<HeaderElem[]>;\n    blockBg: jotai.Atom<MetaType>;\n    manageConnection: jotai.Atom<boolean>;\n    filterOutNowsh?: jotai.Atom<boolean>;\n    connStatus: jotai.Atom<ConnStatus>;\n    useTermHeader: jotai.Atom<boolean>;\n    termWshClient: TermWshClient;\n    vdomBlockId: jotai.Atom<string>;\n    vdomToolbarBlockId: jotai.Atom<string>;\n    vdomToolbarTarget: jotai.PrimitiveAtom<VDomTargetToolbar>;\n    fontSizeAtom: jotai.Atom<number>;\n    termThemeNameAtom: jotai.Atom<string>;\n    termTransparencyAtom: jotai.Atom<number>;\n    termBPMAtom: jotai.Atom<boolean>;\n    noPadding: jotai.PrimitiveAtom<boolean>;\n    endIconButtons: jotai.Atom<IconButtonDecl[]>;\n    shellProcFullStatus: jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;\n    shellProcStatus: jotai.Atom<string>;\n    shellProcStatusUnsubFn: () => void;\n    blockJobStatusAtom: jotai.PrimitiveAtom<BlockJobStatusData>;\n    blockJobStatusVersionTs: number;\n    blockJobStatusUnsubFn: () => void;\n    termBPMUnsubFn: () => void;\n    termCursorUnsubFn: () => void;\n    termCursorBlinkUnsubFn: () => void;\n    isCmdController: jotai.Atom<boolean>;\n    isRestarting: jotai.PrimitiveAtom<boolean>;\n    termDurableStatus: jotai.Atom<BlockJobStatusData | null>;\n    termConfigedDurable: jotai.Atom<null | boolean>;\n    searchAtoms?: SearchAtoms;\n\n    constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) {\n        this.viewType = \"term\";\n        this.blockId = blockId;\n        this.tabModel = tabModel;\n        this.termWshClient = new TermWshClient(blockId, this);\n        DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.termWshClient);\n        this.nodeModel = nodeModel;\n        this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);\n        this.vdomBlockId = jotai.atom((get) => {\n            const blockData = get(this.blockAtom);\n            return blockData?.meta?.[\"term:vdomblockid\"];\n        });\n        this.vdomToolbarBlockId = jotai.atom((get) => {\n            const blockData = get(this.blockAtom);\n            return blockData?.meta?.[\"term:vdomtoolbarblockid\"];\n        });\n        this.vdomToolbarTarget = jotai.atom<VDomTargetToolbar>(null) as jotai.PrimitiveAtom<VDomTargetToolbar>;\n        this.termMode = jotai.atom((get) => {\n            const blockData = get(this.blockAtom);\n            return blockData?.meta?.[\"term:mode\"] ?? \"term\";\n        });\n        this.isRestarting = jotai.atom(false);\n        this.viewIcon = jotai.atom((get) => {\n            const termMode = get(this.termMode);\n            if (termMode == \"vdom\") {\n                return { elemtype: \"iconbutton\", icon: \"bolt\" };\n            }\n            return { elemtype: \"iconbutton\", icon: \"terminal\" };\n        });\n        this.viewName = jotai.atom((get) => {\n            const blockData = get(this.blockAtom);\n            const termMode = get(this.termMode);\n            if (termMode == \"vdom\") {\n                return \"Wave App\";\n            }\n            if (blockData?.meta?.controller == \"cmd\") {\n                return \"\";\n            }\n            return \"\";\n        });\n        this.viewText = jotai.atom((get) => {\n            const termMode = get(this.termMode);\n            if (termMode == \"vdom\") {\n                return [\n                    {\n                        elemtype: \"iconbutton\",\n                        icon: \"square-terminal\",\n                        title: \"Switch back to Terminal\",\n                        click: () => {\n                            this.setTermMode(\"term\");\n                        },\n                    },\n                ];\n            }\n            const vdomBlockId = get(this.vdomBlockId);\n            const rtn: HeaderElem[] = [];\n            if (vdomBlockId) {\n                rtn.push({\n                    elemtype: \"iconbutton\",\n                    icon: \"bolt\",\n                    title: \"Switch to Wave App\",\n                    click: () => {\n                        this.setTermMode(\"vdom\");\n                    },\n                });\n            }\n            const isCmd = get(this.isCmdController);\n            if (isCmd) {\n                const blockMeta = get(this.blockAtom)?.meta;\n                let cmdText = blockMeta?.[\"cmd\"];\n                const cmdArgs = blockMeta?.[\"cmd:args\"];\n                if (cmdArgs != null && Array.isArray(cmdArgs) && cmdArgs.length > 0) {\n                    cmdText += \" \" + cmdArgs.join(\" \");\n                }\n                rtn.push({\n                    elemtype: \"text\",\n                    text: cmdText,\n                    noGrow: true,\n                });\n                const isRestarting = get(this.isRestarting);\n                if (isRestarting) {\n                    rtn.push({\n                        elemtype: \"iconbutton\",\n                        icon: \"refresh\",\n                        iconColor: \"var(--success-color)\",\n                        iconSpin: true,\n                        title: \"Restarting Command\",\n                        noAction: true,\n                    });\n                } else {\n                    const fullShellProcStatus = get(this.shellProcFullStatus);\n                    if (fullShellProcStatus?.shellprocstatus == \"done\") {\n                        if (fullShellProcStatus?.shellprocexitcode == 0) {\n                            rtn.push({\n                                elemtype: \"iconbutton\",\n                                icon: \"check\",\n                                iconColor: \"var(--success-color)\",\n                                title: \"Command Exited Successfully\",\n                                noAction: true,\n                            });\n                        } else {\n                            rtn.push({\n                                elemtype: \"iconbutton\",\n                                icon: \"xmark-large\",\n                                iconColor: \"var(--error-color)\",\n                                title: \"Exit Code: \" + fullShellProcStatus?.shellprocexitcode,\n                                noAction: true,\n                            });\n                        }\n                    }\n                }\n            }\n            const isMI = get(this.tabModel.isTermMultiInput);\n            if (isMI && this.isBasicTerm(get)) {\n                rtn.push({\n                    elemtype: \"textbutton\",\n                    text: \"Multi Input ON\",\n                    className: \"yellow !py-[2px] !px-[10px] text-[11px] font-[500]\",\n                    title: \"Input will be sent to all connected terminals (click to disable)\",\n                    onClick: () => {\n                        globalStore.set(this.tabModel.isTermMultiInput, false);\n                    },\n                });\n            }\n            return rtn;\n        });\n        this.manageConnection = jotai.atom((get) => {\n            const termMode = get(this.termMode);\n            if (termMode == \"vdom\") {\n                return false;\n            }\n            const isCmd = get(this.isCmdController);\n            if (isCmd) {\n                return false;\n            }\n            return true;\n        });\n        this.useTermHeader = jotai.atom((get) => {\n            const termMode = get(this.termMode);\n            if (termMode == \"vdom\") {\n                return false;\n            }\n            const isCmd = get(this.isCmdController);\n            if (isCmd) {\n                return false;\n            }\n            return true;\n        });\n        this.filterOutNowsh = jotai.atom(false);\n        this.termBPMAtom = getOverrideConfigAtom(blockId, \"term:allowbracketedpaste\");\n        this.termThemeNameAtom = useBlockAtom(blockId, \"termthemeatom\", () => {\n            return jotai.atom<string>((get) => {\n                return get(getOverrideConfigAtom(this.blockId, \"term:theme\")) ?? DefaultTermTheme;\n            });\n        });\n        this.termTransparencyAtom = useBlockAtom(blockId, \"termtransparencyatom\", () => {\n            return jotai.atom<number>((get) => {\n                const value = get(getOverrideConfigAtom(this.blockId, \"term:transparency\")) ?? 0.5;\n                return boundNumber(value, 0, 1);\n            });\n        });\n        this.blockBg = jotai.atom((get) => {\n            const fullConfig = get(atoms.fullConfigAtom);\n            const themeName = get(this.termThemeNameAtom);\n            const termTransparency = get(this.termTransparencyAtom);\n            const [_, bgcolor] = computeTheme(fullConfig, themeName, termTransparency);\n            if (bgcolor != null) {\n                return { bg: bgcolor };\n            }\n            return null;\n        });\n        this.connStatus = jotai.atom((get) => {\n            const blockData = get(this.blockAtom);\n            const connName = blockData?.meta?.connection;\n            const connAtom = getConnStatusAtom(connName);\n            return get(connAtom);\n        });\n        this.fontSizeAtom = useBlockAtom(blockId, \"fontsizeatom\", () => {\n            return jotai.atom<number>((get) => {\n                const blockData = get(this.blockAtom);\n                const fsSettingsAtom = getSettingsKeyAtom(\"term:fontsize\");\n                const settingsFontSize = get(fsSettingsAtom);\n                const connName = blockData?.meta?.connection;\n                const fullConfig = get(atoms.fullConfigAtom);\n                const connFontSize = fullConfig?.connections?.[connName]?.[\"term:fontsize\"];\n                const rtnFontSize = blockData?.meta?.[\"term:fontsize\"] ?? connFontSize ?? settingsFontSize ?? 12;\n                if (typeof rtnFontSize != \"number\" || isNaN(rtnFontSize) || rtnFontSize < 4 || rtnFontSize > 64) {\n                    return 12;\n                }\n                return rtnFontSize;\n            });\n        });\n        this.noPadding = jotai.atom(true);\n        this.endIconButtons = jotai.atom((get) => {\n            const blockData = get(this.blockAtom);\n            const shellProcStatus = get(this.shellProcStatus);\n            const connStatus = get(this.connStatus);\n            const isCmd = get(this.isCmdController);\n            const rtn: IconButtonDecl[] = [];\n\n            const isAIPanelOpen = get(WorkspaceLayoutModel.getInstance().panelVisibleAtom);\n            if (isAIPanelOpen) {\n                const shellIntegrationButton = this.getShellIntegrationIconButton(get);\n                if (shellIntegrationButton) {\n                    rtn.push(shellIntegrationButton);\n                }\n            }\n\n            if (get(getSettingsKeyAtom(\"debug:webglstatus\"))) {\n                const webglButton = this.getWebGlIconButton(get);\n                if (webglButton) {\n                    rtn.push(webglButton);\n                }\n            }\n\n            if (blockData?.meta?.[\"controller\"] != \"cmd\" && shellProcStatus != \"done\") {\n                return rtn;\n            }\n            if (connStatus?.status != \"connected\") {\n                return rtn;\n            }\n            let iconName: string = null;\n            let title: string = null;\n            const noun = isCmd ? \"Command\" : \"Shell\";\n            if (shellProcStatus == \"init\") {\n                iconName = \"play\";\n                title = \"Click to Start \" + noun;\n            } else if (shellProcStatus == \"running\") {\n                iconName = \"refresh\";\n                title = noun + \" Running. Click to Restart\";\n            } else if (shellProcStatus == \"done\") {\n                iconName = \"refresh\";\n                title = noun + \" Exited. Click to Restart\";\n            }\n            if (iconName != null) {\n                const buttonDecl: IconButtonDecl = {\n                    elemtype: \"iconbutton\",\n                    icon: iconName,\n                    click: () => fireAndForget(() => this.forceRestartController()),\n                    title: title,\n                };\n                rtn.push(buttonDecl);\n            }\n            return rtn;\n        });\n        this.isCmdController = jotai.atom((get) => {\n            const controllerMetaAtom = getBlockMetaKeyAtom(this.blockId, \"controller\");\n            return get(controllerMetaAtom) == \"cmd\";\n        });\n        this.shellProcFullStatus = jotai.atom(null) as jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;\n        const initialShellProcStatus = services.BlockService.GetControllerStatus(blockId);\n        initialShellProcStatus.then((rts) => {\n            this.updateShellProcStatus(rts);\n        });\n        this.shellProcStatusUnsubFn = waveEventSubscribeSingle({\n            eventType: \"controllerstatus\",\n            scope: WOS.makeORef(\"block\", blockId),\n            handler: (event) => {\n                this.updateShellProcStatus(event.data);\n            },\n        });\n        this.shellProcStatus = jotai.atom((get) => {\n            const fullStatus = get(this.shellProcFullStatus);\n            return fullStatus?.shellprocstatus ?? \"init\";\n        });\n        this.termDurableStatus = jotai.atom((get) => {\n            const isDurable = get(getBlockTermDurableAtom(this.blockId));\n            if (!isDurable) {\n                return null;\n            }\n            const blockJobStatus = get(this.blockJobStatusAtom);\n            if (blockJobStatus?.jobid == null || blockJobStatus?.status == null) {\n                return null;\n            }\n            return blockJobStatus;\n        });\n        this.termConfigedDurable = getBlockTermDurableAtom(this.blockId);\n        this.blockJobStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<BlockJobStatusData>;\n        this.blockJobStatusVersionTs = 0;\n        const initialBlockJobStatus = RpcApi.BlockJobStatusCommand(TabRpcClient, blockId);\n        initialBlockJobStatus\n            .then((status) => {\n                this.handleBlockJobStatusUpdate(status);\n            })\n            .catch((error) => {\n                console.log(\"error getting initial block job status\", error);\n            });\n        this.blockJobStatusUnsubFn = waveEventSubscribeSingle({\n            eventType: \"block:jobstatus\",\n            scope: `block:${blockId}`,\n            handler: (event) => {\n                this.handleBlockJobStatusUpdate(event.data);\n            },\n        });\n        this.termBPMUnsubFn = globalStore.sub(this.termBPMAtom, () => {\n            if (this.termRef.current?.terminal) {\n                const allowBPM = globalStore.get(this.termBPMAtom) ?? true;\n                this.termRef.current.terminal.options.ignoreBracketedPasteMode = !allowBPM;\n            }\n        });\n        const termCursorAtom = getOverrideConfigAtom(blockId, \"term:cursor\");\n        this.termCursorUnsubFn = globalStore.sub(termCursorAtom, () => {\n            if (this.termRef.current?.terminal) {\n                this.termRef.current.setCursorStyle(globalStore.get(termCursorAtom));\n            }\n        });\n        const termCursorBlinkAtom = getOverrideConfigAtom(blockId, \"term:cursorblink\");\n        this.termCursorBlinkUnsubFn = globalStore.sub(termCursorBlinkAtom, () => {\n            if (this.termRef.current?.terminal) {\n                this.termRef.current.setCursorBlink(globalStore.get(termCursorBlinkAtom) ?? false);\n            }\n        });\n    }\n\n    getShellIntegrationIconButton(get: jotai.Getter): IconButtonDecl | null {\n        if (!this.termRef.current?.shellIntegrationStatusAtom) {\n            return null;\n        }\n        const shellIntegrationStatus = get(this.termRef.current.shellIntegrationStatusAtom);\n        if (shellIntegrationStatus == null) {\n            return {\n                elemtype: \"iconbutton\",\n                icon: \"sparkles\",\n                className: \"text-muted\",\n                title: \"No shell integration — Wave AI unable to run commands.\",\n                noAction: true,\n            };\n        }\n        if (shellIntegrationStatus === \"ready\") {\n            return {\n                elemtype: \"iconbutton\",\n                icon: \"sparkles\",\n                className: \"text-accent\",\n                title: \"Shell ready — Wave AI can run commands in this terminal.\",\n                noAction: true,\n            };\n        }\n        if (shellIntegrationStatus === \"running-command\") {\n            let title = \"Shell busy — Wave AI unable to run commands while another command is running.\";\n\n            if (this.termRef.current) {\n                const inAltBuffer = this.termRef.current.terminal?.buffer?.active?.type === \"alternate\";\n                const lastCommand = get(this.termRef.current.lastCommandAtom);\n                const blockingCmd = getBlockingCommand(lastCommand, inAltBuffer);\n                if (blockingCmd) {\n                    title = `Wave AI integration disabled while you're inside ${blockingCmd}.`;\n                }\n            }\n\n            return {\n                elemtype: \"iconbutton\",\n                icon: \"sparkles\",\n                className: \"text-warning\",\n                title: title,\n                noAction: true,\n            };\n        }\n        return null;\n    }\n\n    getWebGlIconButton(get: jotai.Getter): IconButtonDecl | null {\n        if (!WebGLSupported) {\n            return {\n                elemtype: \"iconbutton\",\n                icon: \"microchip\",\n                iconColor: \"var(--error-color)\",\n                title: \"WebGL not supported\",\n                noAction: true,\n            };\n        }\n        if (!this.termRef.current?.webglEnabledAtom) {\n            return null;\n        }\n        const webglEnabled = get(this.termRef.current.webglEnabledAtom);\n        if (webglEnabled) {\n            return {\n                elemtype: \"iconbutton\",\n                icon: \"microchip\",\n                iconColor: \"var(--success-color)\",\n                title: \"WebGL enabled (click to disable)\",\n                click: () => this.toggleWebGl(),\n            };\n        }\n        return {\n            elemtype: \"iconbutton\",\n            icon: \"microchip\",\n            iconColor: \"var(--secondary-text-color)\",\n            title: \"WebGL disabled (click to enable)\",\n            click: () => this.toggleWebGl(),\n        };\n    }\n\n    get viewComponent(): ViewComponent {\n        return TerminalView as ViewComponent;\n    }\n\n    isBasicTerm(getFn: jotai.Getter): boolean {\n        const termMode = getFn(this.termMode);\n        if (termMode == \"vdom\") {\n            return false;\n        }\n        const blockData = getFn(this.blockAtom);\n        if (blockData?.meta?.controller == \"cmd\") {\n            return false;\n        }\n        return true;\n    }\n\n    multiInputHandler(data: string) {\n        const tvms = getAllBasicTermModels();\n        for (const tvm of tvms) {\n            if (tvm != this) {\n                tvm.sendDataToController(data);\n            }\n        }\n    }\n\n    sendDataToController(data: string) {\n        const b64data = stringToBase64(data);\n        RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data });\n    }\n\n    setTermMode(mode: \"term\" | \"vdom\") {\n        if (mode == \"term\") {\n            mode = null;\n        }\n        RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"block\", this.blockId),\n            meta: { \"term:mode\": mode },\n        });\n    }\n\n    getTermRenderer(): \"webgl\" | \"canvas\" {\n        return this.termRef.current?.getTermRenderer() ?? \"canvas\";\n    }\n\n    isWebGlEnabled(): boolean {\n        return this.termRef.current?.isWebGlEnabled() ?? false;\n    }\n\n    toggleWebGl() {\n        if (!this.termRef.current) {\n            return;\n        }\n        const renderer = this.termRef.current.getTermRenderer() === \"webgl\" ? \"canvas\" : \"webgl\";\n        this.termRef.current.setTermRenderer(renderer);\n    }\n\n    triggerRestartAtom() {\n        globalStore.set(this.isRestarting, true);\n        setTimeout(() => {\n            globalStore.set(this.isRestarting, false);\n        }, 300);\n    }\n\n    handleBlockJobStatusUpdate(status: BlockJobStatusData) {\n        if (status?.versionts == null) {\n            return;\n        }\n        if (status.versionts <= this.blockJobStatusVersionTs) {\n            return;\n        }\n        this.blockJobStatusVersionTs = status.versionts;\n        globalStore.set(this.blockJobStatusAtom, status);\n    }\n\n    updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) {\n        if (fullStatus == null) {\n            return;\n        }\n        const curStatus = globalStore.get(this.shellProcFullStatus);\n        if (curStatus == null || curStatus.version < fullStatus.version) {\n            globalStore.set(this.shellProcFullStatus, fullStatus);\n        }\n    }\n\n    getVDomModel(): VDomModel {\n        const vdomBlockId = globalStore.get(this.vdomBlockId);\n        if (!vdomBlockId) {\n            return null;\n        }\n        const bcm = getBlockComponentModel(vdomBlockId);\n        if (!bcm) {\n            return null;\n        }\n        return bcm.viewModel as VDomModel;\n    }\n\n    getVDomToolbarModel(): VDomModel {\n        const vdomToolbarBlockId = globalStore.get(this.vdomToolbarBlockId);\n        if (!vdomToolbarBlockId) {\n            return null;\n        }\n        const bcm = getBlockComponentModel(vdomToolbarBlockId);\n        if (!bcm) {\n            return null;\n        }\n        return bcm.viewModel as VDomModel;\n    }\n\n    dispose() {\n        DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId));\n        this.shellProcStatusUnsubFn?.();\n        this.blockJobStatusUnsubFn?.();\n        this.termBPMUnsubFn?.();\n        this.termCursorUnsubFn?.();\n        this.termCursorBlinkUnsubFn?.();\n    }\n\n    giveFocus(): boolean {\n        if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) {\n            console.log(\"search is open, not giving focus\");\n            return true;\n        }\n        const termMode = globalStore.get(this.termMode);\n        if (termMode == \"term\") {\n            if (this.termRef?.current?.terminal) {\n                this.termRef.current.terminal.focus();\n                return true;\n            }\n        }\n        return false;\n    }\n\n    keyDownHandler(waveEvent: WaveKeyboardEvent): boolean {\n        if (keyutil.checkKeyPressed(waveEvent, \"Ctrl:r\")) {\n            const shellIntegrationStatus = readAtom(this.termRef?.current?.shellIntegrationStatusAtom);\n            if (shellIntegrationStatus === \"ready\") {\n                recordTEvent(\"action:term\", { \"action:type\": \"term:ctrlr\" });\n            }\n            // just for telemetry, we allow this keybinding through, back to the terminal\n            return false;\n        }\n        if (keyutil.checkKeyPressed(waveEvent, \"Cmd:Escape\")) {\n            const blockAtom = WOS.getWaveObjectAtom<Block>(`block:${this.blockId}`);\n            const blockData = globalStore.get(blockAtom);\n            const newTermMode = blockData?.meta?.[\"term:mode\"] == \"vdom\" ? null : \"vdom\";\n            const vdomBlockId = globalStore.get(this.vdomBlockId);\n            if (newTermMode == \"vdom\" && !vdomBlockId) {\n                return;\n            }\n            this.setTermMode(newTermMode);\n            return true;\n        }\n        if (keyutil.checkKeyPressed(waveEvent, \"Shift:End\")) {\n            if (this.termRef?.current?.terminal) {\n                this.termRef.current.terminal.scrollToBottom();\n            }\n            return true;\n        }\n        if (keyutil.checkKeyPressed(waveEvent, \"Shift:Home\")) {\n            if (this.termRef?.current?.terminal) {\n                this.termRef.current.terminal.scrollToLine(0);\n            }\n            return true;\n        }\n        if (isMacOS() && keyutil.checkKeyPressed(waveEvent, \"Cmd:End\")) {\n            if (this.termRef?.current?.terminal) {\n                this.termRef.current.terminal.scrollToBottom();\n            }\n            return true;\n        }\n        if (isMacOS() && keyutil.checkKeyPressed(waveEvent, \"Cmd:Home\")) {\n            if (this.termRef?.current?.terminal) {\n                this.termRef.current.terminal.scrollToLine(0);\n            }\n            return true;\n        }\n        if (keyutil.checkKeyPressed(waveEvent, \"Shift:PageDown\")) {\n            if (this.termRef?.current?.terminal) {\n                this.termRef.current.terminal.scrollPages(1);\n            }\n            return true;\n        }\n        if (keyutil.checkKeyPressed(waveEvent, \"Shift:PageUp\")) {\n            if (this.termRef?.current?.terminal) {\n                this.termRef.current.terminal.scrollPages(-1);\n            }\n            return true;\n        }\n        const blockData = globalStore.get(this.blockAtom);\n        if (blockData.meta?.[\"term:mode\"] == \"vdom\") {\n            const vdomModel = this.getVDomModel();\n            return vdomModel?.keyDownHandler(waveEvent);\n        }\n        return false;\n    }\n\n    shouldHandleCtrlVPaste(): boolean {\n        // macOS never uses Ctrl-V for paste (uses Cmd-V)\n        if (isMacOS()) {\n            return false;\n        }\n\n        // Get the app:ctrlvpaste setting\n        const ctrlVPasteAtom = getSettingsKeyAtom(\"app:ctrlvpaste\");\n        const ctrlVPasteSetting = globalStore.get(ctrlVPasteAtom);\n\n        // If setting is explicitly set, use it\n        if (ctrlVPasteSetting != null) {\n            return ctrlVPasteSetting;\n        }\n\n        // Default behavior: Windows=true, Linux/other=false\n        return isWindows();\n    }\n\n    handleTerminalKeydown(event: KeyboardEvent): boolean {\n        const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event);\n        if (waveEvent.type != \"keydown\") {\n            return true;\n        }\n\n        // Handle Escape key during IME composition\n        if (keyutil.checkKeyPressed(waveEvent, \"Escape\")) {\n            if (this.termRef.current?.isComposing) {\n                // Reset composition state when Escape is pressed during composition\n                this.termRef.current.resetCompositionState();\n            }\n        }\n\n        if (this.keyDownHandler(waveEvent)) {\n            event.preventDefault();\n            event.stopPropagation();\n            return false;\n        }\n\n        if (isMacOS()) {\n            if (keyutil.checkKeyPressed(waveEvent, \"Cmd:ArrowLeft\")) {\n                this.sendDataToController(\"\\x01\"); // Ctrl-A (beginning of line)\n                event.preventDefault();\n                event.stopPropagation();\n                return false;\n            }\n            if (keyutil.checkKeyPressed(waveEvent, \"Cmd:ArrowRight\")) {\n                this.sendDataToController(\"\\x05\"); // Ctrl-E (end of line)\n                event.preventDefault();\n                event.stopPropagation();\n                return false;\n            }\n        }\n        if (keyutil.checkKeyPressed(waveEvent, \"Shift:Enter\")) {\n            const shiftEnterNewlineAtom = getOverrideConfigAtom(this.blockId, \"term:shiftenternewline\");\n            const shiftEnterNewlineEnabled = globalStore.get(shiftEnterNewlineAtom) ?? true;\n            if (shiftEnterNewlineEnabled) {\n                this.sendDataToController(\"\\n\");\n                event.preventDefault();\n                event.stopPropagation();\n                return false;\n            }\n        }\n\n        // Check for Ctrl-V paste (platform-dependent)\n        if (this.shouldHandleCtrlVPaste() && keyutil.checkKeyPressed(waveEvent, \"Ctrl:v\")) {\n            event.preventDefault();\n            event.stopPropagation();\n            getApi().nativePaste();\n            return false;\n        }\n\n        if (keyutil.checkKeyPressed(waveEvent, \"Ctrl:Shift:v\")) {\n            event.preventDefault();\n            event.stopPropagation();\n            getApi().nativePaste();\n            // this.termRef.current?.pasteHandler();\n            return false;\n        } else if (keyutil.checkKeyPressed(waveEvent, \"Ctrl:Shift:c\")) {\n            event.preventDefault();\n            event.stopPropagation();\n            const sel = this.termRef.current?.terminal.getSelection();\n            if (!sel) {\n                return false;\n            }\n            navigator.clipboard.writeText(sel);\n            return false;\n        } else if (keyutil.checkKeyPressed(waveEvent, \"Cmd:k\")) {\n            event.preventDefault();\n            event.stopPropagation();\n            this.termRef.current?.terminal?.clear();\n            return false;\n        }\n        const shellProcStatus = globalStore.get(this.shellProcStatus);\n        if ((shellProcStatus == \"done\" || shellProcStatus == \"init\") && keyutil.checkKeyPressed(waveEvent, \"Enter\")) {\n            fireAndForget(() => this.forceRestartController());\n            return false;\n        }\n        const appHandled = appHandleKeyDown(waveEvent);\n        if (appHandled) {\n            event.preventDefault();\n            event.stopPropagation();\n            return false;\n        }\n        return true;\n    }\n\n    setTerminalTheme(themeName: string) {\n        RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"block\", this.blockId),\n            meta: { \"term:theme\": themeName },\n        });\n    }\n\n    async forceRestartController() {\n        if (globalStore.get(this.isRestarting)) {\n            return;\n        }\n        this.triggerRestartAtom();\n        await RpcApi.ControllerDestroyCommand(TabRpcClient, this.blockId);\n        const termsize = {\n            rows: this.termRef.current?.terminal?.rows,\n            cols: this.termRef.current?.terminal?.cols,\n        };\n        await RpcApi.ControllerResyncCommand(TabRpcClient, {\n            tabid: globalStore.get(atoms.staticTabId),\n            blockid: this.blockId,\n            forcerestart: true,\n            rtopts: { termsize: termsize },\n        });\n    }\n\n    async restartSessionWithDurability(isDurable: boolean) {\n        await RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"block\", this.blockId),\n            meta: { \"term:durable\": isDurable },\n        });\n        await RpcApi.ControllerDestroyCommand(TabRpcClient, this.blockId);\n        const termsize = {\n            rows: this.termRef.current?.terminal?.rows,\n            cols: this.termRef.current?.terminal?.cols,\n        };\n        await RpcApi.ControllerResyncCommand(TabRpcClient, {\n            tabid: globalStore.get(atoms.staticTabId),\n            blockid: this.blockId,\n            forcerestart: true,\n            rtopts: { termsize: termsize },\n        });\n    }\n\n    getContextMenuItems(): ContextMenuItem[] {\n        const menu: ContextMenuItem[] = [];\n        const hasSelection = this.termRef.current?.terminal?.hasSelection();\n        const selection = hasSelection ? this.termRef.current?.terminal.getSelection() : null;\n\n        if (hasSelection) {\n            menu.push({\n                label: \"Copy\",\n                click: () => {\n                    if (selection) {\n                        navigator.clipboard.writeText(selection);\n                    }\n                },\n            });\n            menu.push({ type: \"separator\" });\n            menu.push({\n                label: \"Send to Wave AI\",\n                click: () => {\n                    if (selection) {\n                        const aiModel = WaveAIModel.getInstance();\n                        aiModel.appendText(selection, true, { scrollToBottom: true });\n                        const layoutModel = WorkspaceLayoutModel.getInstance();\n                        if (!layoutModel.getAIPanelVisible()) {\n                            layoutModel.setAIPanelVisible(true);\n                        }\n                        aiModel.focusInput();\n                    }\n                },\n            });\n\n            menu.push({ type: \"separator\" });\n        }\n\n        const hoveredLinkUri = this.termRef.current?.hoveredLinkUri;\n        if (hoveredLinkUri) {\n            let hoveredURL: URL = null;\n            try {\n                hoveredURL = new URL(hoveredLinkUri);\n            } catch (e) {\n                // not a valid URL\n            }\n            if (hoveredURL) {\n                menu.push({\n                    label: hoveredURL.hostname ? \"Open URL (\" + hoveredURL.hostname + \")\" : \"Open URL\",\n                    click: () => {\n                        createBlock({\n                            meta: {\n                                view: \"web\",\n                                url: hoveredURL.toString(),\n                            },\n                        });\n                    },\n                });\n                menu.push({\n                    label: \"Open URL in External Browser\",\n                    click: () => {\n                        getApi().openExternal(hoveredURL.toString());\n                    },\n                });\n                menu.push({ type: \"separator\" });\n            }\n        }\n\n        menu.push({\n            label: \"Paste\",\n            click: () => {\n                getApi().nativePaste();\n            },\n        });\n\n        menu.push({ type: \"separator\" });\n\n        const magnified = globalStore.get(this.nodeModel.isMagnified);\n        menu.push({\n            label: magnified ? \"Un-Magnify Block\" : \"Magnify Block\",\n            click: () => {\n                this.nodeModel.toggleMagnify();\n            },\n        });\n\n        menu.push({ type: \"separator\" });\n\n        const settingsItems = this.getSettingsMenuItems();\n        menu.push(...settingsItems);\n\n        return menu;\n    }\n\n    getSettingsMenuItems(): ContextMenuItem[] {\n        const fullConfig = globalStore.get(atoms.fullConfigAtom);\n        const termThemes = fullConfig?.termthemes ?? {};\n        const termThemeKeys = Object.keys(termThemes);\n        const curThemeName = globalStore.get(getBlockMetaKeyAtom(this.blockId, \"term:theme\"));\n        const defaultFontSize = globalStore.get(getSettingsKeyAtom(\"term:fontsize\")) ?? 12;\n        const defaultAllowBracketedPaste = globalStore.get(getSettingsKeyAtom(\"term:allowbracketedpaste\")) ?? true;\n        const transparencyMeta = globalStore.get(getBlockMetaKeyAtom(this.blockId, \"term:transparency\"));\n        const blockData = globalStore.get(this.blockAtom);\n        const overrideFontSize = blockData?.meta?.[\"term:fontsize\"];\n\n        termThemeKeys.sort((a, b) => {\n            return (termThemes[a][\"display:order\"] ?? 0) - (termThemes[b][\"display:order\"] ?? 0);\n        });\n        const defaultTermBlockDef: BlockDef = {\n            meta: {\n                view: \"term\",\n                controller: \"shell\",\n            },\n        };\n\n        const fullMenu: ContextMenuItem[] = [];\n        fullMenu.push({\n            label: \"Split Horizontally\",\n            click: () => {\n                const blockData = globalStore.get(this.blockAtom);\n                const blockDef: BlockDef = {\n                    meta: blockData?.meta || defaultTermBlockDef.meta,\n                };\n                createBlockSplitHorizontally(blockDef, this.blockId, \"after\");\n            },\n        });\n        fullMenu.push({\n            label: \"Split Vertically\",\n            click: () => {\n                const blockData = globalStore.get(this.blockAtom);\n                const blockDef: BlockDef = {\n                    meta: blockData?.meta || defaultTermBlockDef.meta,\n                };\n                createBlockSplitVertically(blockDef, this.blockId, \"after\");\n            },\n        });\n        fullMenu.push({ type: \"separator\" });\n\n        const shellIntegrationStatus = globalStore.get(this.termRef?.current?.shellIntegrationStatusAtom);\n        const cwd = blockData?.meta?.[\"cmd:cwd\"];\n        const canShowFileBrowser = shellIntegrationStatus === \"ready\" && cwd != null;\n\n        if (canShowFileBrowser) {\n            fullMenu.push({\n                label: \"File Browser\",\n                click: () => {\n                    const blockData = globalStore.get(this.blockAtom);\n                    const connection = blockData?.meta?.connection;\n                    const cwd = blockData?.meta?.[\"cmd:cwd\"];\n                    const meta: Record<string, any> = {\n                        view: \"preview\",\n                        file: cwd,\n                    };\n                    if (connection) {\n                        meta.connection = connection;\n                    }\n                    const blockDef: BlockDef = { meta };\n                    createBlock(blockDef);\n                },\n            });\n            fullMenu.push({ type: \"separator\" });\n        }\n\n        fullMenu.push({\n            label: \"Save Session As...\",\n            click: () => {\n                if (this.termRef.current) {\n                    const content = this.termRef.current.getScrollbackContent();\n                    if (content) {\n                        fireAndForget(async () => {\n                            try {\n                                const success = await getApi().saveTextFile(\"session.log\", content);\n                                if (!success) {\n                                    console.log(\"Save scrollback cancelled by user\");\n                                }\n                            } catch (error) {\n                                console.error(\"Failed to save scrollback:\", error);\n                                const errorMessage = error?.message || \"An unknown error occurred\";\n                                modalsModel.pushModal(\"MessageModal\", {\n                                    children: `Failed to save session scrollback: ${errorMessage}`,\n                                });\n                            }\n                        });\n                    } else {\n                        modalsModel.pushModal(\"MessageModal\", {\n                            children: \"No scrollback content to save.\",\n                        });\n                    }\n                }\n            },\n        });\n        fullMenu.push({ type: \"separator\" });\n\n        const submenu: ContextMenuItem[] = termThemeKeys.map((themeName) => {\n            return {\n                label: termThemes[themeName][\"display:name\"] ?? themeName,\n                type: \"checkbox\",\n                checked: curThemeName == themeName,\n                click: () => this.setTerminalTheme(themeName),\n            };\n        });\n        submenu.unshift({\n            label: \"Default\",\n            type: \"checkbox\",\n            checked: curThemeName == null,\n            click: () => this.setTerminalTheme(null),\n        });\n        const transparencySubMenu: ContextMenuItem[] = [];\n        transparencySubMenu.push({\n            label: \"Default\",\n            type: \"checkbox\",\n            checked: transparencyMeta == null,\n            click: () => {\n                RpcApi.SetMetaCommand(TabRpcClient, {\n                    oref: WOS.makeORef(\"block\", this.blockId),\n                    meta: { \"term:transparency\": null },\n                });\n            },\n        });\n        transparencySubMenu.push({\n            label: \"Transparent Background\",\n            type: \"checkbox\",\n            checked: transparencyMeta == 0.5,\n            click: () => {\n                RpcApi.SetMetaCommand(TabRpcClient, {\n                    oref: WOS.makeORef(\"block\", this.blockId),\n                    meta: { \"term:transparency\": 0.5 },\n                });\n            },\n        });\n        transparencySubMenu.push({\n            label: \"No Transparency\",\n            type: \"checkbox\",\n            checked: transparencyMeta == 0,\n            click: () => {\n                RpcApi.SetMetaCommand(TabRpcClient, {\n                    oref: WOS.makeORef(\"block\", this.blockId),\n                    meta: { \"term:transparency\": 0 },\n                });\n            },\n        });\n\n        const fontSizeSubMenu: ContextMenuItem[] = [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].map(\n            (fontSize: number) => {\n                return {\n                    label: fontSize.toString() + \"px\",\n                    type: \"checkbox\",\n                    checked: overrideFontSize == fontSize,\n                    click: () => {\n                        RpcApi.SetMetaCommand(TabRpcClient, {\n                            oref: WOS.makeORef(\"block\", this.blockId),\n                            meta: { \"term:fontsize\": fontSize },\n                        });\n                    },\n                };\n            }\n        );\n        fontSizeSubMenu.unshift({\n            label: \"Default (\" + defaultFontSize + \"px)\",\n            type: \"checkbox\",\n            checked: overrideFontSize == null,\n            click: () => {\n                RpcApi.SetMetaCommand(TabRpcClient, {\n                    oref: WOS.makeORef(\"block\", this.blockId),\n                    meta: { \"term:fontsize\": null },\n                });\n            },\n        });\n        const overrideCursor = blockData?.meta?.[\"term:cursor\"] as string | null | undefined;\n        const overrideCursorBlink = blockData?.meta?.[\"term:cursorblink\"] as boolean | null | undefined;\n        const isCursorDefault = overrideCursor == null && overrideCursorBlink == null;\n        // normalize for comparison: null/undefined/\"block\" all mean \"block\"\n        const effectiveCursor = overrideCursor === \"underline\" || overrideCursor === \"bar\" ? overrideCursor : \"block\";\n        const effectiveCursorBlink = overrideCursorBlink === true;\n        const cursorSubMenu: ContextMenuItem[] = [\n            {\n                label: \"Default\",\n                type: \"checkbox\",\n                checked: isCursorDefault,\n                click: () => {\n                    RpcApi.SetMetaCommand(TabRpcClient, {\n                        oref: WOS.makeORef(\"block\", this.blockId),\n                        meta: { \"term:cursor\": null, \"term:cursorblink\": null },\n                    });\n                },\n            },\n            {\n                label: \"Block\",\n                type: \"checkbox\",\n                checked: !isCursorDefault && effectiveCursor === \"block\" && !effectiveCursorBlink,\n                click: () => {\n                    RpcApi.SetMetaCommand(TabRpcClient, {\n                        oref: WOS.makeORef(\"block\", this.blockId),\n                        meta: { \"term:cursor\": \"block\", \"term:cursorblink\": false },\n                    });\n                },\n            },\n            {\n                label: \"Block (Blinking)\",\n                type: \"checkbox\",\n                checked: !isCursorDefault && effectiveCursor === \"block\" && effectiveCursorBlink,\n                click: () => {\n                    RpcApi.SetMetaCommand(TabRpcClient, {\n                        oref: WOS.makeORef(\"block\", this.blockId),\n                        meta: { \"term:cursor\": \"block\", \"term:cursorblink\": true },\n                    });\n                },\n            },\n            {\n                label: \"Bar\",\n                type: \"checkbox\",\n                checked: !isCursorDefault && effectiveCursor === \"bar\" && !effectiveCursorBlink,\n                click: () => {\n                    RpcApi.SetMetaCommand(TabRpcClient, {\n                        oref: WOS.makeORef(\"block\", this.blockId),\n                        meta: { \"term:cursor\": \"bar\", \"term:cursorblink\": false },\n                    });\n                },\n            },\n            {\n                label: \"Bar (Blinking)\",\n                type: \"checkbox\",\n                checked: !isCursorDefault && effectiveCursor === \"bar\" && effectiveCursorBlink,\n                click: () => {\n                    RpcApi.SetMetaCommand(TabRpcClient, {\n                        oref: WOS.makeORef(\"block\", this.blockId),\n                        meta: { \"term:cursor\": \"bar\", \"term:cursorblink\": true },\n                    });\n                },\n            },\n            {\n                label: \"Underline\",\n                type: \"checkbox\",\n                checked: !isCursorDefault && effectiveCursor === \"underline\" && !effectiveCursorBlink,\n                click: () => {\n                    RpcApi.SetMetaCommand(TabRpcClient, {\n                        oref: WOS.makeORef(\"block\", this.blockId),\n                        meta: { \"term:cursor\": \"underline\", \"term:cursorblink\": false },\n                    });\n                },\n            },\n            {\n                label: \"Underline (Blinking)\",\n                type: \"checkbox\",\n                checked: !isCursorDefault && effectiveCursor === \"underline\" && effectiveCursorBlink,\n                click: () => {\n                    RpcApi.SetMetaCommand(TabRpcClient, {\n                        oref: WOS.makeORef(\"block\", this.blockId),\n                        meta: { \"term:cursor\": \"underline\", \"term:cursorblink\": true },\n                    });\n                },\n            },\n        ];\n        fullMenu.push({\n            label: \"Themes\",\n            submenu: submenu,\n        });\n        fullMenu.push({\n            label: \"Font Size\",\n            submenu: fontSizeSubMenu,\n        });\n        fullMenu.push({\n            label: \"Cursor\",\n            submenu: cursorSubMenu,\n        });\n        fullMenu.push({\n            label: \"Transparency\",\n            submenu: transparencySubMenu,\n        });\n        fullMenu.push({ type: \"separator\" });\n        const advancedSubmenu: ContextMenuItem[] = [];\n        const allowBracketedPaste = blockData?.meta?.[\"term:allowbracketedpaste\"];\n        advancedSubmenu.push({\n            label: \"Allow Bracketed Paste Mode\",\n            submenu: [\n                {\n                    label: \"Default (\" + (defaultAllowBracketedPaste ? \"On\" : \"Off\") + \")\",\n                    type: \"checkbox\",\n                    checked: allowBracketedPaste == null,\n                    click: () => {\n                        RpcApi.SetMetaCommand(TabRpcClient, {\n                            oref: WOS.makeORef(\"block\", this.blockId),\n                            meta: { \"term:allowbracketedpaste\": null },\n                        });\n                    },\n                },\n                {\n                    label: \"On\",\n                    type: \"checkbox\",\n                    checked: allowBracketedPaste === true,\n                    click: () => {\n                        RpcApi.SetMetaCommand(TabRpcClient, {\n                            oref: WOS.makeORef(\"block\", this.blockId),\n                            meta: { \"term:allowbracketedpaste\": true },\n                        });\n                    },\n                },\n                {\n                    label: \"Off\",\n                    type: \"checkbox\",\n                    checked: allowBracketedPaste === false,\n                    click: () => {\n                        RpcApi.SetMetaCommand(TabRpcClient, {\n                            oref: WOS.makeORef(\"block\", this.blockId),\n                            meta: { \"term:allowbracketedpaste\": false },\n                        });\n                    },\n                },\n            ],\n        });\n        advancedSubmenu.push({\n            label: \"Force Restart Controller\",\n            click: () => fireAndForget(() => this.forceRestartController()),\n        });\n        const isClearOnStart = blockData?.meta?.[\"cmd:clearonstart\"];\n        advancedSubmenu.push({\n            label: \"Clear Output On Restart\",\n            submenu: [\n                {\n                    label: \"On\",\n                    type: \"checkbox\",\n                    checked: isClearOnStart,\n                    click: () => {\n                        RpcApi.SetMetaCommand(TabRpcClient, {\n                            oref: WOS.makeORef(\"block\", this.blockId),\n                            meta: { \"cmd:clearonstart\": true },\n                        });\n                    },\n                },\n                {\n                    label: \"Off\",\n                    type: \"checkbox\",\n                    checked: !isClearOnStart,\n                    click: () => {\n                        RpcApi.SetMetaCommand(TabRpcClient, {\n                            oref: WOS.makeORef(\"block\", this.blockId),\n                            meta: { \"cmd:clearonstart\": false },\n                        });\n                    },\n                },\n            ],\n        });\n        const runOnStart = blockData?.meta?.[\"cmd:runonstart\"];\n        advancedSubmenu.push({\n            label: \"Run On Startup\",\n            submenu: [\n                {\n                    label: \"On\",\n                    type: \"checkbox\",\n                    checked: runOnStart,\n                    click: () => {\n                        RpcApi.SetMetaCommand(TabRpcClient, {\n                            oref: WOS.makeORef(\"block\", this.blockId),\n                            meta: { \"cmd:runonstart\": true },\n                        });\n                    },\n                },\n                {\n                    label: \"Off\",\n                    type: \"checkbox\",\n                    checked: !runOnStart,\n                    click: () => {\n                        RpcApi.SetMetaCommand(TabRpcClient, {\n                            oref: WOS.makeORef(\"block\", this.blockId),\n                            meta: { \"cmd:runonstart\": false },\n                        });\n                    },\n                },\n            ],\n        });\n        const debugConn = blockData?.meta?.[\"term:conndebug\"];\n        advancedSubmenu.push({\n            label: \"Debug Connection\",\n            submenu: [\n                {\n                    label: \"Off\",\n                    type: \"checkbox\",\n                    checked: !debugConn,\n                    click: () => {\n                        RpcApi.SetMetaCommand(TabRpcClient, {\n                            oref: WOS.makeORef(\"block\", this.blockId),\n                            meta: { \"term:conndebug\": null },\n                        });\n                    },\n                },\n                {\n                    label: \"Info\",\n                    type: \"checkbox\",\n                    checked: debugConn == \"info\",\n                    click: () => {\n                        RpcApi.SetMetaCommand(TabRpcClient, {\n                            oref: WOS.makeORef(\"block\", this.blockId),\n                            meta: { \"term:conndebug\": \"info\" },\n                        });\n                    },\n                },\n                {\n                    label: \"Verbose\",\n                    type: \"checkbox\",\n                    checked: debugConn == \"debug\",\n                    click: () => {\n                        RpcApi.SetMetaCommand(TabRpcClient, {\n                            oref: WOS.makeORef(\"block\", this.blockId),\n                            meta: { \"term:conndebug\": \"debug\" },\n                        });\n                    },\n                },\n            ],\n        });\n\n        const isDurable = globalStore.get(getBlockTermDurableAtom(this.blockId));\n        if (isDurable) {\n            advancedSubmenu.push({\n                label: \"Session Durability\",\n                submenu: [\n                    {\n                        label: \"Restart Session in Standard Mode\",\n                        click: () => fireAndForget(() => this.restartSessionWithDurability(false)),\n                    },\n                ],\n            });\n        } else if (isDurable === false) {\n            advancedSubmenu.push({\n                label: \"Session Durability\",\n                submenu: [\n                    {\n                        label: \"Restart Session in Durable Mode\",\n                        click: () => fireAndForget(() => this.restartSessionWithDurability(true)),\n                    },\n                ],\n            });\n        }\n\n        fullMenu.push({\n            label: \"Advanced\",\n            submenu: advancedSubmenu,\n        });\n        if (blockData?.meta?.[\"term:vdomtoolbarblockid\"]) {\n            fullMenu.push({ type: \"separator\" });\n            fullMenu.push({\n                label: \"Close Toolbar\",\n                click: () => {\n                    RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: blockData.meta[\"term:vdomtoolbarblockid\"] });\n                },\n            });\n        }\n        return fullMenu;\n    }\n}\n\nexport function getAllBasicTermModels(): TermViewModel[] {\n    const termModels: TermViewModel[] = [];\n    const bcms = getAllBlockComponentModels();\n    for (const bcm of bcms) {\n        if (bcm?.viewModel?.viewType == \"term\") {\n            const tvm = bcm.viewModel as TermViewModel;\n            if (tvm.isBasicTerm((atom) => globalStore.get(atom))) {\n                termModels.push(tvm);\n            }\n        }\n    }\n    return termModels;\n}\n"
  },
  {
    "path": "frontend/app/view/term/term-tooltip.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { PLATFORM, PlatformMacOS } from \"@/util/platformutil\";\nimport { FloatingPortal, VirtualElement, flip, offset, shift, useFloating } from \"@floating-ui/react\";\nimport * as React from \"react\";\nimport type { TermWrap } from \"./termwrap\";\n\n// ── low-level primitive ──────────────────────────────────────────────────────\n\ninterface TermTooltipProps {\n    /** Screen-space mouse position (clientX/clientY). null means hidden. */\n    mousePos: { x: number; y: number } | null;\n    content: React.ReactNode;\n}\n\n/**\n * A floating tooltip anchored to the current mouse position.\n * Uses a floating-ui virtual element (via refs.setPositionReference) so no\n * real DOM reference is required.  Renders into a FloatingPortal.\n */\nexport const TermTooltip = React.memo(function TermTooltip({ mousePos, content }: TermTooltipProps) {\n    const isOpen = mousePos != null;\n\n    // Keep latest mousePos in a ref so the virtual element always reflects it.\n    const mousePosRef = React.useRef(mousePos);\n    mousePosRef.current = mousePos;\n\n    const { refs, floatingStyles } = useFloating({\n        open: isOpen,\n        placement: \"top-start\",\n        middleware: [offset({ mainAxis: 12, crossAxis: -20 }), flip(), shift({ padding: 0 })],\n    });\n\n    // Update the position reference whenever mousePos changes.\n    React.useLayoutEffect(() => {\n        if (!isOpen) {\n            return;\n        }\n        const virtualEl: VirtualElement = {\n            getBoundingClientRect() {\n                const pos = mousePosRef.current ?? { x: 0, y: 0 };\n                return new DOMRect(pos.x, pos.y, 0, 0);\n            },\n        };\n        refs.setPositionReference(virtualEl);\n    }, [isOpen, mousePos?.x, mousePos?.y]);\n\n    if (!isOpen) {\n        return null;\n    }\n\n    return (\n        <FloatingPortal>\n            <div\n                ref={refs.setFloating}\n                style={floatingStyles}\n                className=\"bg-zinc-800/70 rounded-md px-2 py-1 text-xs text-secondary shadow-xl z-50 pointer-events-none select-none\"\n            >\n                {content}\n            </div>\n        </FloatingPortal>\n    );\n});\n\n// ── wired-up sub-component ───────────────────────────────────────────────────\n\nfunction clearTimeoutRef(ref: React.RefObject<number | null>) {\n    if (ref.current == null) {\n        return;\n    }\n    window.clearTimeout(ref.current);\n    ref.current = null;\n}\n\nconst HoverDelayMs = 600;\nconst MaxHoverTimeMs = 2200;\nconst modKey = PLATFORM === PlatformMacOS ? \"Cmd\" : \"Ctrl\";\n\ninterface TermLinkTooltipProps {\n    /**\n     * The live TermWrap instance. Pass the instance directly (not a ref) so\n     * React re-runs the effect when it changes (e.g. on terminal recreate).\n     */\n    termWrap: TermWrap | null;\n}\n\n/**\n * Self-contained sub-component that subscribes to the termWrap link-hover\n * callback and renders a tooltip after a short delay.  Keeping state here\n * prevents unnecessary re-renders of the parent TerminalView.\n */\nexport const TermLinkTooltip = React.memo(function TermLinkTooltip({ termWrap }: TermLinkTooltipProps) {\n    const [mousePos, setMousePos] = React.useState<{ x: number; y: number } | null>(null);\n    const timeoutRef = React.useRef<number | null>(null);\n    const maxTimeoutRef = React.useRef<number | null>(null);\n\n    React.useEffect(() => {\n        if (termWrap == null) {\n            return;\n        }\n\n        termWrap.onLinkHover = (uri: string | null, mouseX: number, mouseY: number) => {\n            clearTimeoutRef(timeoutRef);\n\n            if (uri == null) {\n                clearTimeoutRef(maxTimeoutRef);\n                setMousePos(null);\n                return;\n            }\n\n            // Show after a short delay so fast mouse movements don't flicker.\n            timeoutRef.current = window.setTimeout(() => {\n                timeoutRef.current = null;\n                setMousePos({ x: mouseX, y: mouseY });\n                // Auto-dismiss after MaxHoverTimeMs so the tooltip doesn't linger forever.\n                clearTimeoutRef(maxTimeoutRef);\n                maxTimeoutRef.current = window.setTimeout(() => {\n                    maxTimeoutRef.current = null;\n                    setMousePos(null);\n                }, MaxHoverTimeMs);\n            }, HoverDelayMs);\n        };\n\n        return () => {\n            termWrap.onLinkHover = null;\n            clearTimeoutRef(timeoutRef);\n            clearTimeoutRef(maxTimeoutRef);\n            setMousePos(null);\n        };\n    }, [termWrap]);\n\n    return <TermTooltip mousePos={mousePos} content={<span>{modKey}-click to open link</span>} />;\n});\n"
  },
  {
    "path": "frontend/app/view/term/term-wsh.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { atoms, globalStore } from \"@/app/store/global\";\nimport { makeORef, splitORef } from \"@/app/store/wos\";\nimport { RpcResponseHelper, WshClient } from \"@/app/store/wshclient\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { makeFeBlockRouteId } from \"@/app/store/wshrouter\";\nimport { TermViewModel } from \"@/app/view/term/term-model\";\nimport { bufferLinesToText } from \"@/app/view/term/termutil\";\nimport { isBlank } from \"@/util/util\";\nimport debug from \"debug\";\n\nconst dlog = debug(\"wave:vdom\");\n\nexport class TermWshClient extends WshClient {\n    blockId: string;\n    model: TermViewModel;\n\n    constructor(blockId: string, model: TermViewModel) {\n        super(makeFeBlockRouteId(blockId));\n        this.blockId = blockId;\n        this.model = model;\n    }\n\n    async handle_vdomcreatecontext(rh: RpcResponseHelper, data: VDomCreateContext) {\n        const source = rh.getSource();\n        if (isBlank(source)) {\n            throw new Error(\"source cannot be blank\");\n        }\n        console.log(\"vdom-create\", source, data);\n        const tabId = globalStore.get(atoms.staticTabId);\n        if (data.target?.newblock) {\n            const oref = await RpcApi.CreateBlockCommand(this, {\n                tabid: tabId,\n                blockdef: {\n                    meta: {\n                        view: \"vdom\",\n                        \"vdom:route\": rh.getSource(),\n                    },\n                },\n                magnified: data.target?.magnified,\n                focused: true,\n            });\n            return oref;\n        } else if (data.target?.toolbar?.toolbar) {\n            const oldVDomBlockId = globalStore.get(this.model.vdomToolbarBlockId);\n            console.log(\"vdom:toolbar\", data.target.toolbar);\n            globalStore.set(this.model.vdomToolbarTarget, data.target.toolbar);\n            const oref = await RpcApi.CreateSubBlockCommand(this, {\n                parentblockid: this.blockId,\n                blockdef: {\n                    meta: {\n                        view: \"vdom\",\n                        \"vdom:route\": rh.getSource(),\n                    },\n                },\n            });\n            const [_, newVDomBlockId] = splitORef(oref);\n            if (!isBlank(oldVDomBlockId)) {\n                // dispose of the old vdom block\n                setTimeout(() => {\n                    RpcApi.DeleteSubBlockCommand(this, { blockid: oldVDomBlockId });\n                }, 500);\n            }\n            setTimeout(() => {\n                RpcApi.SetMetaCommand(this, {\n                    oref: makeORef(\"block\", this.model.blockId),\n                    meta: {\n                        \"term:vdomtoolbarblockid\": newVDomBlockId,\n                    },\n                });\n            }, 50);\n            return oref;\n        } else {\n            // in the terminal\n            // check if there is a current active vdom block\n            const oldVDomBlockId = globalStore.get(this.model.vdomBlockId);\n            const oref = await RpcApi.CreateSubBlockCommand(this, {\n                parentblockid: this.blockId,\n                blockdef: {\n                    meta: {\n                        view: \"vdom\",\n                        \"vdom:route\": rh.getSource(),\n                    },\n                },\n            });\n            const [_, newVDomBlockId] = splitORef(oref);\n            if (!isBlank(oldVDomBlockId)) {\n                // dispose of the old vdom block\n                setTimeout(() => {\n                    RpcApi.DeleteSubBlockCommand(this, { blockid: oldVDomBlockId });\n                }, 500);\n            }\n            setTimeout(() => {\n                RpcApi.SetMetaCommand(this, {\n                    oref: makeORef(\"block\", this.model.blockId),\n                    meta: {\n                        \"term:mode\": \"vdom\",\n                        \"term:vdomblockid\": newVDomBlockId,\n                    },\n                });\n            }, 50);\n            return oref;\n        }\n    }\n\n    async handle_termgetscrollbacklines(\n        rh: RpcResponseHelper,\n        data: CommandTermGetScrollbackLinesData\n    ): Promise<CommandTermGetScrollbackLinesRtnData> {\n        const termWrap = this.model.termRef.current;\n        if (!termWrap || !termWrap.terminal) {\n            return {\n                totallines: 0,\n                linestart: data.linestart,\n                lines: [],\n                lastupdated: 0,\n            };\n        }\n\n        const buffer = termWrap.terminal.buffer.active;\n        const totalLines = buffer.length;\n\n        if (data.lastcommand) {\n            if (globalStore.get(termWrap.shellIntegrationStatusAtom) == null) {\n                throw new Error(\"Cannot get last command data without shell integration\");\n            }\n\n            let startBufferIndex = 0;\n            let endBufferIndex = totalLines;\n            if (termWrap.promptMarkers.length > 0) {\n                // The last marker is the current prompt, so we want the second-to-last for the previous command\n                // If there's only one marker, use it (edge case for first command)\n                const markerIndex =\n                    termWrap.promptMarkers.length > 1\n                        ? termWrap.promptMarkers.length - 2\n                        : termWrap.promptMarkers.length - 1;\n                const commandStartMarker = termWrap.promptMarkers[markerIndex];\n                startBufferIndex = commandStartMarker.line;\n\n                // End at the last marker (current prompt) if there are multiple markers\n                if (termWrap.promptMarkers.length > 1) {\n                    const currentPromptMarker = termWrap.promptMarkers[termWrap.promptMarkers.length - 1];\n                    endBufferIndex = currentPromptMarker.line;\n                }\n            }\n\n            const lines = bufferLinesToText(buffer, startBufferIndex, endBufferIndex);\n\n            // Convert buffer indices to \"from bottom\" line numbers.\n            // \"from bottom\" 0 = most recent line; higher numbers = older lines.\n            // The buffer range [startBufferIndex, endBufferIndex) maps to\n            // \"from bottom\" range [totalLines - endBufferIndex, totalLines - startBufferIndex).\n            // The first returned line is at \"from bottom\" position: totalLines - endBufferIndex.\n            let returnLines = lines;\n            let returnStartLine = totalLines - endBufferIndex;\n            if (lines.length > 1000) {\n                // there is a small bug here since this is computing a physical start line\n                // after the lines have already been combined (because of potential wrapping)\n                // for now this isn't worth fixing, just noted\n                returnLines = lines.slice(lines.length - 1000);\n                returnStartLine = (totalLines - endBufferIndex) + (lines.length - 1000);\n            }\n\n            return {\n                totallines: totalLines,\n                linestart: returnStartLine,\n                lines: returnLines,\n                lastupdated: termWrap.lastUpdated,\n            };\n        }\n\n        const startLine = Math.max(0, data.linestart);\n        const endLine = data.lineend === 0 ? totalLines : Math.min(totalLines, data.lineend);\n\n        const startBufferIndex = totalLines - endLine;\n        const endBufferIndex = totalLines - startLine;\n        const lines = bufferLinesToText(buffer, startBufferIndex, endBufferIndex);\n\n        return {\n            totallines: totalLines,\n            linestart: startLine,\n            lines: lines,\n            lastupdated: termWrap.lastUpdated,\n        };\n    }\n}\n"
  },
  {
    "path": "frontend/app/view/term/term.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.connection-btn {\n    min-height: 0;\n    overflow: hidden;\n    line-height: 1;\n    display: flex;\n    background-color: orangered;\n    justify-content: flex-start;\n    width: 200px;\n}\n\n.view-term {\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    position: relative;\n\n    .term-header {\n        display: flex;\n        flex-direction: row;\n        padding: 4px 10px;\n        height: 35px;\n        gap: 10px;\n        align-items: center;\n        flex-shrink: 0;\n        border-bottom: 1px solid var(--border-color);\n    }\n\n    .term-toolbar {\n        height: 20px;\n        border-bottom: 1px solid var(--border-color);\n        overflow: hidden;\n    }\n\n    .term-cmd-toolbar {\n        display: flex;\n        flex-direction: row;\n        height: 24px;\n        border-bottom: 1px solid var(--border-color);\n        overflow: hidden;\n        align-items: center;\n\n        .term-cmd-toolbar-text {\n            font: var(--fixed-font);\n            flex-grow: 1;\n            overflow: hidden;\n            text-overflow: ellipsis;\n            white-space: nowrap;\n            padding: 0 5px;\n        }\n    }\n\n    .term-connectelem {\n        flex-grow: 1;\n        min-height: 0;\n        overflow: hidden;\n        line-height: 1;\n        margin: 5px 1px 5px 4px;\n    }\n\n    .term-htmlelem {\n        display: flex;\n        flex-direction: column;\n        width: 100%;\n        flex-grow: 1;\n        min-height: 0;\n        overflow: hidden;\n\n        .block-content {\n            padding: 0;\n        }\n    }\n\n    &.term-mode-term {\n        .term-connectelem {\n            display: flex;\n        }\n        .term-htmlelem {\n            display: none;\n        }\n    }\n\n    &.term-mode-vdom {\n        .term-connectelem {\n            display: none;\n        }\n        .term-htmlelem {\n            display: flex;\n        }\n\n        .ijson iframe {\n            width: 100%;\n            height: 100%;\n            border: none;\n        }\n    }\n\n    .term-stickers {\n        position: absolute;\n        top: 0;\n        left: 0;\n        width: 100%;\n        height: 100%;\n        z-index: var(--zindex-termstickers);\n        pointer-events: none;\n\n        .term-sticker-image {\n            img {\n                object-fit: contain;\n                width: 100%;\n                height: 100%;\n            }\n        }\n\n        .term-sticker-svg {\n            svg {\n                object-fit: contain;\n                width: 100%;\n                height: 100%;\n            }\n        }\n    }\n\n    .terminal {\n        width: 100%;\n\n        .xterm-viewport {\n            &::-webkit-scrollbar {\n                width: 6px; /* this needs to match fitAddon.scrollbarWidth in termwrap.ts */\n                height: 6px;\n            }\n\n            &::-webkit-scrollbar-track {\n                background-color: var(--scrollbar-background-color);\n            }\n\n            &::-webkit-scrollbar-thumb {\n                background-color: transparent;\n                border-radius: 4px;\n                margin: 0 1px 0 1px;\n\n                &:hover {\n                    background-color: var(--scrollbar-thumb-hover-color) !important;\n                }\n\n                &:active {\n                    background-color: var(--scrollbar-thumb-active-color) !important;\n                }\n            }\n        }\n\n        &:hover {\n            .xterm-viewport::-webkit-scrollbar-thumb {\n                background-color: var(--scrollbar-thumb-color);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/view/term/term.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { SubBlock } from \"@/app/block/block\";\nimport type { BlockNodeModel } from \"@/app/block/blocktypes\";\nimport { NullErrorBoundary } from \"@/app/element/errorboundary\";\nimport { Search, useSearch } from \"@/app/element/search\";\nimport { ContextMenuModel } from \"@/app/store/contextmenu\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { useTabModel } from \"@/app/store/tab-model\";\nimport { waveEventSubscribeSingle } from \"@/app/store/wps\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport type { TermViewModel } from \"@/app/view/term/term-model\";\nimport { atoms, getOverrideConfigAtom, getSettingsPrefixAtom, WOS } from \"@/store/global\";\nimport { fireAndForget, useAtomValueSafe } from \"@/util/util\";\nimport { computeBgStyleFromMeta } from \"@/util/waveutil\";\nimport { ISearchOptions } from \"@xterm/addon-search\";\nimport clsx from \"clsx\";\nimport debug from \"debug\";\nimport * as jotai from \"jotai\";\nimport * as React from \"react\";\nimport { TermLinkTooltip } from \"./term-tooltip\";\nimport { TermStickers } from \"./termsticker\";\nimport { TermThemeUpdater } from \"./termtheme\";\nimport { computeTheme, normalizeCursorStyle } from \"./termutil\";\nimport { TermWrap } from \"./termwrap\";\nimport \"./xterm.css\";\n\nconst dlog = debug(\"wave:term\");\n\ninterface TerminalViewProps {\n    blockId: string;\n    model: TermViewModel;\n}\n\nconst TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => {\n    const connStatus = jotai.useAtomValue(model.connStatus);\n    const [lastConnStatus, setLastConnStatus] = React.useState<ConnStatus>(connStatus);\n\n    React.useEffect(() => {\n        if (!model.termRef.current?.hasResized) {\n            return;\n        }\n        const isConnected = connStatus?.status == \"connected\";\n        const wasConnected = lastConnStatus?.status == \"connected\";\n        const curConnName = connStatus?.connection;\n        const lastConnName = lastConnStatus?.connection;\n        if (isConnected == wasConnected && curConnName == lastConnName) {\n            return;\n        }\n        model.termRef.current?.resyncController(\"resync handler\");\n        setLastConnStatus(connStatus);\n    }, [connStatus]);\n\n    return null;\n});\n\nconst TermVDomToolbarNode = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => {\n    React.useEffect(() => {\n        const unsub = waveEventSubscribeSingle({\n            eventType: \"blockclose\",\n            scope: WOS.makeORef(\"block\", vdomBlockId),\n            handler: (event) => {\n                RpcApi.SetMetaCommand(TabRpcClient, {\n                    oref: WOS.makeORef(\"block\", blockId),\n                    meta: {\n                        \"term:mode\": null,\n                        \"term:vdomtoolbarblockid\": null,\n                    },\n                });\n            },\n        });\n        return () => {\n            unsub();\n        };\n    }, []);\n    const vdomNodeModel: BlockNodeModel = React.useMemo(\n        () => ({\n            blockId: vdomBlockId,\n            isFocused: jotai.atom(false),\n            isMagnified: jotai.atom(false),\n            focusNode: () => {},\n            toggleMagnify: () => {},\n            onClose: () => {\n                if (vdomBlockId != null) {\n                    RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId });\n                }\n            },\n        }),\n        [vdomBlockId]\n    );\n    const toolbarTarget = jotai.useAtomValue(model.vdomToolbarTarget);\n    const heightStr = toolbarTarget?.height ?? \"1.5em\";\n    return (\n        <div key=\"vdomToolbar\" className=\"term-toolbar\" style={{ height: heightStr }}>\n            <SubBlock key=\"vdom\" nodeModel={vdomNodeModel} />\n        </div>\n    );\n};\n\nconst TermVDomNodeSingleId = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => {\n    React.useEffect(() => {\n        const unsub = waveEventSubscribeSingle({\n            eventType: \"blockclose\",\n            scope: WOS.makeORef(\"block\", vdomBlockId),\n            handler: (event) => {\n                RpcApi.SetMetaCommand(TabRpcClient, {\n                    oref: WOS.makeORef(\"block\", blockId),\n                    meta: {\n                        \"term:mode\": null,\n                        \"term:vdomblockid\": null,\n                    },\n                });\n            },\n        });\n        return () => {\n            unsub();\n        };\n    }, []);\n    const vdomNodeModel: BlockNodeModel = React.useMemo(() => {\n        const isFocusedAtom = jotai.atom((get) => {\n            return get(model.nodeModel.isFocused) && get(model.termMode) == \"vdom\";\n        });\n        return {\n            blockId: vdomBlockId,\n            isFocused: isFocusedAtom,\n            isMagnified: jotai.atom(false),\n            focusNode: () => {\n                model.nodeModel.focusNode();\n            },\n            toggleMagnify: () => {},\n            onClose: () => {\n                if (vdomBlockId != null) {\n                    RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId });\n                }\n            },\n        };\n    }, [vdomBlockId, model]);\n    return (\n        <div key=\"htmlElem\" className=\"term-htmlelem\">\n            <SubBlock key=\"vdom\" nodeModel={vdomNodeModel} />\n        </div>\n    );\n};\n\nconst TermVDomNode = ({ blockId, model }: TerminalViewProps) => {\n    const vdomBlockId = jotai.useAtomValue(model.vdomBlockId);\n    if (vdomBlockId == null) {\n        return null;\n    }\n    return <TermVDomNodeSingleId key={vdomBlockId} vdomBlockId={vdomBlockId} blockId={blockId} model={model} />;\n};\n\nconst TermToolbarVDomNode = ({ blockId, model }: TerminalViewProps) => {\n    const vdomToolbarBlockId = jotai.useAtomValue(model.vdomToolbarBlockId);\n    if (vdomToolbarBlockId == null) {\n        return null;\n    }\n    return (\n        <TermVDomToolbarNode\n            key={vdomToolbarBlockId}\n            vdomBlockId={vdomToolbarBlockId}\n            blockId={blockId}\n            model={model}\n        />\n    );\n};\n\nconst TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) => {\n    const viewRef = React.useRef<HTMLDivElement>(null);\n    const connectElemRef = React.useRef<HTMLDivElement>(null);\n    const [termWrapInst, setTermWrapInst] = React.useState<TermWrap | null>(null);\n    const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef(\"block\", blockId));\n    const termSettingsAtom = getSettingsPrefixAtom(\"term\");\n    const termSettings = jotai.useAtomValue(termSettingsAtom);\n    let termMode = blockData?.meta?.[\"term:mode\"] ?? \"term\";\n    if (termMode != \"term\" && termMode != \"vdom\") {\n        termMode = \"term\";\n    }\n    const termModeRef = React.useRef(termMode);\n\n    const tabModel = useTabModel();\n    const termFontSize = jotai.useAtomValue(model.fontSizeAtom);\n    const fullConfig = globalStore.get(atoms.fullConfigAtom);\n    const connFontFamily = fullConfig.connections?.[blockData?.meta?.connection]?.[\"term:fontfamily\"];\n    const isFocused = jotai.useAtomValue(model.nodeModel.isFocused);\n    const isMI = jotai.useAtomValue(tabModel.isTermMultiInput);\n    const isBasicTerm = termMode != \"vdom\" && blockData?.meta?.controller != \"cmd\"; // needs to match isBasicTerm\n\n    // search\n    const searchProps = useSearch({\n        anchorRef: viewRef,\n        viewModel: model,\n        caseSensitive: false,\n        wholeWord: false,\n        regex: false,\n    });\n    const searchIsOpen = jotai.useAtomValue<boolean>(searchProps.isOpen);\n    const caseSensitive = useAtomValueSafe<boolean>(searchProps.caseSensitive);\n    const wholeWord = useAtomValueSafe<boolean>(searchProps.wholeWord);\n    const regex = useAtomValueSafe<boolean>(searchProps.regex);\n    const searchVal = jotai.useAtomValue<string>(searchProps.searchValue);\n    const searchDecorations = React.useMemo(\n        () => ({\n            matchOverviewRuler: \"#000000\",\n            activeMatchColorOverviewRuler: \"#000000\",\n            activeMatchBorder: \"#FF9632\",\n            matchBorder: \"#FFFF00\",\n        }),\n        []\n    );\n    const searchOpts = React.useMemo<ISearchOptions>(\n        () => ({\n            regex,\n            wholeWord,\n            caseSensitive,\n            decorations: searchDecorations,\n        }),\n        [regex, wholeWord, caseSensitive]\n    );\n    const handleSearchError = React.useCallback((e: Error) => {\n        console.warn(\"search error:\", e);\n    }, []);\n    const executeSearch = React.useCallback(\n        (searchText: string, direction: \"next\" | \"previous\") => {\n            if (searchText === \"\") {\n                model.termRef.current?.searchAddon.clearDecorations();\n                return;\n            }\n            try {\n                model.termRef.current?.searchAddon[direction === \"next\" ? \"findNext\" : \"findPrevious\"](\n                    searchText,\n                    searchOpts\n                );\n            } catch (e) {\n                handleSearchError(e);\n            }\n        },\n        [searchOpts, handleSearchError]\n    );\n    searchProps.onSearch = React.useCallback(\n        (searchText: string) => executeSearch(searchText, \"previous\"),\n        [executeSearch]\n    );\n    searchProps.onPrev = React.useCallback(() => executeSearch(searchVal, \"previous\"), [executeSearch, searchVal]);\n    searchProps.onNext = React.useCallback(() => executeSearch(searchVal, \"next\"), [executeSearch, searchVal]);\n    // Return input focus to the terminal when the search is closed\n    React.useEffect(() => {\n        if (!searchIsOpen) {\n            model.giveFocus();\n        }\n    }, [searchIsOpen]);\n    // rerun search when the searchOpts change\n    React.useEffect(() => {\n        model.termRef.current?.searchAddon.clearDecorations();\n        searchProps.onSearch(searchVal);\n    }, [searchOpts]);\n    // end search\n\n    React.useEffect(() => {\n        const fullConfig = globalStore.get(atoms.fullConfigAtom);\n        const termThemeName = globalStore.get(model.termThemeNameAtom);\n        const termTransparency = globalStore.get(model.termTransparencyAtom);\n        const termMacOptionIsMetaAtom = getOverrideConfigAtom(blockId, \"term:macoptionismeta\");\n        const [termTheme, _] = computeTheme(fullConfig, termThemeName, termTransparency);\n        let termScrollback = 2000;\n        if (termSettings?.[\"term:scrollback\"]) {\n            termScrollback = Math.floor(termSettings[\"term:scrollback\"]);\n        }\n        if (blockData?.meta?.[\"term:scrollback\"]) {\n            termScrollback = Math.floor(blockData.meta[\"term:scrollback\"]);\n        }\n        if (termScrollback < 0) {\n            termScrollback = 0;\n        }\n        if (termScrollback > 50000) {\n            termScrollback = 50000;\n        }\n        const termAllowBPM = globalStore.get(model.termBPMAtom) ?? true;\n        const termMacOptionIsMeta = globalStore.get(termMacOptionIsMetaAtom) ?? false;\n        const termCursorStyle = normalizeCursorStyle(globalStore.get(getOverrideConfigAtom(blockId, \"term:cursor\")));\n        const termCursorBlink = globalStore.get(getOverrideConfigAtom(blockId, \"term:cursorblink\")) ?? false;\n        const wasFocused = model.termRef.current != null && globalStore.get(model.nodeModel.isFocused);\n        const termWrap = new TermWrap(\n            tabModel.tabId,\n            blockId,\n            connectElemRef.current,\n            {\n                theme: termTheme,\n                fontSize: termFontSize,\n                fontFamily: termSettings?.[\"term:fontfamily\"] ?? connFontFamily ?? \"Hack\",\n                drawBoldTextInBrightColors: false,\n                fontWeight: \"normal\",\n                fontWeightBold: \"bold\",\n                allowTransparency: true,\n                scrollback: termScrollback,\n                allowProposedApi: true, // Required by @xterm/addon-search to enable search functionality and decorations\n                ignoreBracketedPasteMode: !termAllowBPM,\n                macOptionIsMeta: termMacOptionIsMeta,\n                cursorStyle: termCursorStyle,\n                cursorBlink: termCursorBlink,\n            },\n            {\n                keydownHandler: model.handleTerminalKeydown.bind(model),\n                useWebGl: !termSettings?.[\"term:disablewebgl\"],\n                sendDataHandler: model.sendDataToController.bind(model),\n                nodeModel: model.nodeModel,\n            }\n        );\n        (window as any).term = termWrap;\n        model.termRef.current = termWrap;\n        setTermWrapInst(termWrap);\n        const rszObs = new ResizeObserver(() => {\n            if (termWrap.cachedAtBottomForResize == null) {\n                termWrap.cachedAtBottomForResize = termWrap.wasRecentlyAtBottom();\n            }\n            termWrap.handleResize_debounced();\n        });\n        rszObs.observe(connectElemRef.current);\n        termWrap.onSearchResultsDidChange = (results) => {\n            globalStore.set(searchProps.resultsIndex, results.resultIndex);\n            globalStore.set(searchProps.resultsCount, results.resultCount);\n        };\n        fireAndForget(termWrap.initTerminal.bind(termWrap));\n        if (wasFocused) {\n            setTimeout(() => {\n                model.giveFocus();\n            }, 10);\n        }\n        return () => {\n            termWrap.dispose();\n            rszObs.disconnect();\n            setTermWrapInst(null);\n        };\n    }, [blockId, termSettings, termFontSize, connFontFamily]);\n\n    React.useEffect(() => {\n        if (termModeRef.current == \"vdom\" && termMode == \"term\") {\n            // focus the terminal\n            model.giveFocus();\n        }\n        termModeRef.current = termMode;\n    }, [termMode]);\n\n    React.useEffect(() => {\n        if (isMI && isBasicTerm && isFocused && model.termRef.current != null) {\n            model.termRef.current.multiInputCallback = (data: string) => {\n                model.multiInputHandler(data);\n            };\n        } else {\n            if (model.termRef.current != null) {\n                model.termRef.current.multiInputCallback = null;\n            }\n        }\n    }, [isMI, isBasicTerm, isFocused]);\n\n    const stickerConfig = {\n        charWidth: 8,\n        charHeight: 16,\n        rows: model.termRef.current?.terminal.rows ?? 24,\n        cols: model.termRef.current?.terminal.cols ?? 80,\n        blockId: blockId,\n    };\n\n    const termBg = computeBgStyleFromMeta(blockData?.meta);\n\n    const handleContextMenu = React.useCallback(\n        (e: React.MouseEvent<HTMLDivElement>) => {\n            e.preventDefault();\n            e.stopPropagation();\n            const menuItems = model.getContextMenuItems();\n            ContextMenuModel.getInstance().showContextMenu(menuItems, e);\n        },\n        [model]\n    );\n\n    return (\n        <div className={clsx(\"view-term\", \"term-mode-\" + termMode)} ref={viewRef} onContextMenu={handleContextMenu}>\n            {termBg && <div key=\"term-bg\" className=\"absolute inset-0 z-0 pointer-events-none\" style={termBg} />}\n            <TermResyncHandler blockId={blockId} model={model} />\n            <TermThemeUpdater blockId={blockId} model={model} termRef={model.termRef} />\n            <TermStickers config={stickerConfig} />\n            <TermToolbarVDomNode key=\"vdom-toolbar\" blockId={blockId} model={model} />\n            <TermVDomNode key=\"vdom\" blockId={blockId} model={model} />\n            <div key=\"connect-elem\" className=\"term-connectelem\" ref={connectElemRef} />\n            <NullErrorBoundary debugName=\"TermLinkTooltip\">\n                <TermLinkTooltip termWrap={termWrapInst} />\n            </NullErrorBoundary>\n            <Search {...searchProps} />\n        </div>\n    );\n};\n\nexport { TerminalView };\n"
  },
  {
    "path": "frontend/app/view/term/termsticker.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { createBlock } from \"@/store/global\";\nimport { getWebServerEndpoint } from \"@/util/endpoints\";\nimport { stringToBase64 } from \"@/util/util\";\nimport clsx from \"clsx\";\nimport * as React from \"react\";\nimport \"./term.scss\";\n\ntype StickerType = {\n    position: \"absolute\";\n    top?: number;\n    left?: number;\n    right?: number;\n    bottom?: number;\n    width?: number;\n    height?: number;\n    color?: string;\n    opacity?: number;\n    pointerevents?: boolean;\n    fontsize?: number;\n    transform?: string;\n\n    stickertype: \"icon\" | \"image\" | \"gauge\";\n    icon?: string;\n    imgsrc?: string;\n    clickcmd?: string;\n    clickblockdef?: BlockDef;\n};\n\ntype StickerTermConfig = {\n    charWidth: number;\n    charHeight: number;\n    rows: number;\n    cols: number;\n    blockId: string;\n};\n\nfunction convertWidthDimToPx(dim: number, config: StickerTermConfig) {\n    if (dim == null) {\n        return null;\n    }\n    return dim * config.charWidth;\n}\n\nfunction convertHeightDimToPx(dim: number, config: StickerTermConfig) {\n    if (dim == null) {\n        return null;\n    }\n    return dim * config.charHeight;\n}\n\nfunction TermSticker({ sticker, config }: { sticker: StickerType; config: StickerTermConfig }) {\n    const style: React.CSSProperties = {\n        position: sticker.position,\n        top: convertHeightDimToPx(sticker.top, config),\n        left: convertWidthDimToPx(sticker.left, config),\n        right: convertWidthDimToPx(sticker.right, config),\n        bottom: convertHeightDimToPx(sticker.bottom, config),\n        width: convertWidthDimToPx(sticker.width, config),\n        height: convertHeightDimToPx(sticker.height, config),\n        color: sticker.color,\n        fontSize: sticker.fontsize,\n        transform: sticker.transform,\n        opacity: sticker.opacity,\n        fill: sticker.color,\n        stroke: sticker.color,\n    };\n    if (sticker.pointerevents) {\n        style.pointerEvents = \"auto\";\n    }\n    if (style.width != null) {\n        style.overflowX = \"hidden\";\n    }\n    if (style.height != null) {\n        style.overflowY = \"hidden\";\n    }\n    let clickHandler = null;\n    if (sticker.pointerevents && (sticker.clickcmd || sticker.clickblockdef)) {\n        style.cursor = \"pointer\";\n        clickHandler = () => {\n            console.log(\"clickHandler\", sticker.clickcmd, sticker.clickblockdef);\n            if (sticker.clickcmd) {\n                const b64data = stringToBase64(sticker.clickcmd);\n                RpcApi.ControllerInputCommand(TabRpcClient, { blockid: config.blockId, inputdata64: b64data });\n            }\n            if (sticker.clickblockdef) {\n                createBlock(sticker.clickblockdef);\n            }\n        };\n    }\n    if (sticker.stickertype == \"icon\") {\n        return (\n            <div className=\"term-sticker\" style={style} onClick={clickHandler}>\n                <i className={clsx(\"fa\", \"fa-\" + sticker.icon)} />\n            </div>\n        );\n    }\n    if (sticker.stickertype == \"image\") {\n        if (sticker.imgsrc == null) {\n            return null;\n        }\n        const streamingUrl =\n            getWebServerEndpoint() + \"/wave/stream-local-file?path=\" + encodeURIComponent(sticker.imgsrc);\n        return (\n            <div className=\"term-sticker term-sticker-image\" style={style} onClick={clickHandler}>\n                <img src={streamingUrl} />\n            </div>\n        );\n    }\n    return null;\n}\n\nexport function TermStickers({ config }: { config: StickerTermConfig }) {\n    let stickers: StickerType[] = [];\n    if (config.blockId.startsWith(\"d1eaddcb\")) {\n        stickers.push({\n            position: \"absolute\",\n            top: 5,\n            right: 7,\n            stickertype: \"icon\",\n            icon: \"paw\",\n            color: \"#40cc40aa\",\n            fontsize: 30,\n            transform: \"rotate(-18deg)\",\n            pointerevents: true,\n            clickcmd: \"ls\\n\",\n        });\n        stickers.push({\n            position: \"absolute\",\n            top: 8,\n            right: 8,\n            stickertype: \"icon\",\n            icon: \"paw\",\n            color: \"#4040ccaa\",\n            fontsize: 30,\n            transform: \"rotate(-20deg)\",\n            pointerevents: true,\n            clickcmd: \"git status\\n\",\n        });\n        stickers.push({\n            position: \"absolute\",\n            top: 2,\n            right: 25,\n            width: 20,\n            stickertype: \"gauge\",\n            opacity: 0.7,\n        });\n    }\n    return (\n        <div className=\"term-stickers\">\n            {stickers.map((sticker, i) => (\n                <TermSticker key={i} sticker={sticker} config={config} />\n            ))}\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/app/view/term/termtheme.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { TermViewModel } from \"@/app/view/term/term-model\";\nimport { computeTheme } from \"@/app/view/term/termutil\";\nimport { TermWrap } from \"@/app/view/term/termwrap\";\nimport { atoms } from \"@/store/global\";\nimport { useAtomValue } from \"jotai\";\nimport { useEffect } from \"react\";\n\ninterface TermThemeProps {\n    blockId: string;\n    termRef: React.RefObject<TermWrap>;\n    model: TermViewModel;\n}\n\nconst TermThemeUpdater = ({ blockId, model, termRef }: TermThemeProps) => {\n    const fullConfig = useAtomValue(atoms.fullConfigAtom);\n    const blockTermTheme = useAtomValue(model.termThemeNameAtom);\n    const transparency = useAtomValue(model.termTransparencyAtom);\n    const [theme, _] = computeTheme(fullConfig, blockTermTheme, transparency);\n    useEffect(() => {\n        if (termRef.current?.terminal) {\n            termRef.current.terminal.options.theme = theme;\n        }\n    }, [theme]);\n    return null;\n};\n\nexport { TermThemeUpdater };\n"
  },
  {
    "path": "frontend/app/view/term/termutil.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nexport const DefaultTermTheme = \"default-dark\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport * as TermTypes from \"@xterm/xterm\";\nimport base64 from \"base64-js\";\nimport { colord } from \"colord\";\n\nexport type GenClipboardItem = { text?: string; image?: Blob };\n\nexport function normalizeCursorStyle(cursorStyle: string): TermTypes.Terminal[\"options\"][\"cursorStyle\"] {\n    if (cursorStyle === \"underline\" || cursorStyle === \"bar\") {\n        return cursorStyle;\n    }\n    return \"block\";\n}\n\nfunction applyTransparencyToColor(hexColor: string, transparency: number): string {\n    const alpha = 1 - transparency; // transparency is already 0-1\n    return colord(hexColor).alpha(alpha).toHex();\n}\n\n// returns (theme, bgcolor, transparency (0 - 1.0))\nexport function computeTheme(\n    fullConfig: FullConfigType,\n    themeName: string,\n    termTransparency: number\n): [TermThemeType, string] {\n    let theme: TermThemeType = fullConfig?.termthemes?.[themeName];\n    if (theme == null) {\n        theme = fullConfig?.termthemes?.[DefaultTermTheme] || ({} as any);\n    }\n    const themeCopy = { ...theme };\n    if (termTransparency != null && termTransparency > 0) {\n        if (themeCopy.background) {\n            themeCopy.background = applyTransparencyToColor(themeCopy.background, termTransparency);\n        }\n        if (themeCopy.selectionBackground) {\n            themeCopy.selectionBackground = applyTransparencyToColor(themeCopy.selectionBackground, termTransparency);\n        }\n    }\n    const bgcolor = themeCopy.background;\n    themeCopy.background = \"#00000000\";\n    return [themeCopy, bgcolor];\n}\n\nexport const MIME_TO_EXT: Record<string, string> = {\n    \"image/png\": \"png\",\n    \"image/jpeg\": \"jpg\",\n    \"image/jpg\": \"jpg\",\n    \"image/gif\": \"gif\",\n    \"image/webp\": \"webp\",\n    \"image/bmp\": \"bmp\",\n    \"image/svg+xml\": \"svg\",\n    \"image/tiff\": \"tiff\",\n    \"image/heic\": \"heic\",\n    \"image/heif\": \"heif\",\n    \"image/avif\": \"avif\",\n    \"image/x-icon\": \"ico\",\n    \"image/vnd.microsoft.icon\": \"ico\",\n};\n\n/**\n * Creates a temporary file from a Blob (typically an image).\n * Validates size, generates a unique filename, saves to temp directory,\n * and returns the file path.\n *\n * @param blob - The Blob to save\n * @returns The path to the created temporary file\n * @throws Error if blob is too large (>5MB) or data URL is invalid\n */\nexport async function createTempFileFromBlob(blob: Blob): Promise<string> {\n    // Check size limit (5MB)\n    if (blob.size > 5 * 1024 * 1024) {\n        throw new Error(\"Image too large (>5MB)\");\n    }\n\n    // Get file extension from MIME type\n    if (!blob.type.startsWith(\"image/\") || !MIME_TO_EXT[blob.type]) {\n        throw new Error(`Unsupported or invalid image type: ${blob.type}`);\n    }\n    const ext = MIME_TO_EXT[blob.type];\n\n    // Generate unique filename with timestamp and random component\n    const timestamp = Date.now();\n    const random = Math.random().toString(36).substring(2, 8);\n    const filename = `waveterm_paste_${timestamp}_${random}.${ext}`;\n\n    const arrayBuffer = await new Promise<ArrayBuffer>((resolve, reject) => {\n        const reader = new FileReader();\n        reader.onload = () => resolve(reader.result as ArrayBuffer);\n        reader.onerror = reject;\n        reader.readAsArrayBuffer(blob);\n    });\n\n    const base64Data = base64.fromByteArray(new Uint8Array(arrayBuffer));\n\n    // Write image to temp file and get path\n    const tempPath = await RpcApi.WriteTempFileCommand(TabRpcClient, {\n        filename,\n        data64: base64Data,\n    });\n\n    return tempPath;\n}\n\n/**\n * Extracts text or image data from a ClipboardItem using prioritized extraction modes.\n *\n * Mode 1 (Images): If image types are present, returns the first image\n * Mode 2 (Plain Text): If text/plain, text/plain;*, or \"text\" is found\n * Mode 3 (HTML): If text/html is found, extracts text content via DOM\n * Mode 4 (Generic): If empty string or null type exists\n *\n * @param item - ClipboardItem to extract data from\n * @returns Object with either text or image, or null if no supported content found\n */\nexport async function extractClipboardData(item: ClipboardItem): Promise<GenClipboardItem | null> {\n    // Mode #1: Check for image first\n    const imageTypes = item.types.filter((type) => type.startsWith(\"image/\"));\n    if (imageTypes.length > 0) {\n        const blob = await item.getType(imageTypes[0]);\n        return { image: blob };\n    }\n\n    // Mode #2: Try text/plain, text/plain;*, or \"text\"\n    const plainTextType = item.types.find((t) => t === \"text\" || t === \"text/plain\" || t.startsWith(\"text/plain;\"));\n    if (plainTextType) {\n        const blob = await item.getType(plainTextType);\n        const text = await blob.text();\n        return text ? { text } : null;\n    }\n\n    // Mode #3: Try text/html - extract text via DOM\n    const htmlType = item.types.find((t) => t === \"text/html\" || t.startsWith(\"text/html;\"));\n    if (htmlType) {\n        const blob = await item.getType(htmlType);\n        const html = await blob.text();\n        if (!html) {\n            return null;\n        }\n        const tempDiv = document.createElement(\"div\");\n        tempDiv.innerHTML = html;\n        const text = tempDiv.textContent || \"\";\n        return text ? { text } : null;\n    }\n\n    // Mode #4: Try empty string or null type\n    const genericType = item.types.find((t) => t === \"\");\n    if (genericType != null) {\n        const blob = await item.getType(genericType);\n        const text = await blob.text();\n        return text ? { text } : null;\n    }\n\n    return null;\n}\n\n/**\n * Finds the first DataTransferItem matching the specified kind and type predicate.\n *\n * @param items - The DataTransferItemList to search\n * @param kind - The kind to match (\"file\" or \"string\")\n * @param typePredicate - Function that returns true if the type matches\n * @returns The first matching DataTransferItem, or null if none found\n */\nfunction findFirstDataTransferItem(\n    items: DataTransferItemList,\n    kind: string,\n    typePredicate: (type: string) => boolean\n): DataTransferItem | null {\n    for (let i = 0; i < items.length; i++) {\n        const item = items[i];\n        if (item.kind === kind && typePredicate(item.type)) {\n            return item;\n        }\n    }\n    return null;\n}\n\n/**\n * Finds all DataTransferItems matching the specified kind and type predicate.\n *\n * @param items - The DataTransferItemList to search\n * @param kind - The kind to match (\"file\" or \"string\")\n * @param typePredicate - Function that returns true if the type matches\n * @returns Array of matching DataTransferItems\n */\nfunction findAllDataTransferItems(\n    items: DataTransferItemList,\n    kind: string,\n    typePredicate: (type: string) => boolean\n): DataTransferItem[] {\n    const results: DataTransferItem[] = [];\n    for (let i = 0; i < items.length; i++) {\n        const item = items[i];\n        if (item.kind === kind && typePredicate(item.type)) {\n            results.push(item);\n        }\n    }\n    return results;\n}\n\n/**\n * Extracts clipboard data from a DataTransferItemList using prioritized extraction modes.\n *\n * The function uses a hierarchical approach to determine what data to extract:\n *\n * Mode 1 (Image Files): If any image file items are present, extracts only image files\n * - Returns array of {image: Blob} for each image/* MIME type\n * - Ignores all non-image items when image files are present\n * - Non-image files (e.g., PDFs) allow fallthrough to text modes\n *\n * Mode 2 (Plain Text): If text/plain is found (and no image files)\n * - Returns single-item array with first text/plain content as {text: string}\n * - Matches: \"text\", \"text/plain\", or types starting with \"text/plain\"\n *\n * Mode 3 (HTML): If text/html is found (and no image files or plain text)\n * - Extracts text content from first HTML item using DOM parsing\n * - Returns single-item array as {text: string}\n *\n * Mode 4 (Generic String): If string item with empty/null type exists\n * - Returns first string item with no type identifier\n * - Returns single-item array as {text: string}\n *\n * @param items - The DataTransferItemList to process\n * @returns Array of GenClipboardItem objects, or empty array if no supported content found\n */\nexport async function extractDataTransferItems(items: DataTransferItemList): Promise<GenClipboardItem[]> {\n    // Mode #1: If image files are present, only extract image files\n    const imageFiles = findAllDataTransferItems(items, \"file\", (type) => type.startsWith(\"image/\"));\n    if (imageFiles.length > 0) {\n        const results: GenClipboardItem[] = [];\n        for (const item of imageFiles) {\n            const blob = item.getAsFile();\n            if (blob) {\n                results.push({ image: blob });\n            }\n        }\n        return results;\n    }\n\n    // Mode #2: If text/plain is present, only extract the first text/plain\n    const plainTextItem = findFirstDataTransferItem(\n        items,\n        \"string\",\n        (type) => type === \"text\" || type === \"text/plain\" || type.startsWith(\"text/plain;\")\n    );\n    if (plainTextItem) {\n        return new Promise((resolve) => {\n            plainTextItem.getAsString((text) => {\n                resolve(text ? [{ text }] : []);\n            });\n        });\n    }\n\n    // Mode #3: If text/html is present, extract text from first HTML\n    const htmlItem = findFirstDataTransferItem(\n        items,\n        \"string\",\n        (type) => type === \"text/html\" || type.startsWith(\"text/html;\")\n    );\n    if (htmlItem) {\n        return new Promise((resolve) => {\n            htmlItem.getAsString((html) => {\n                if (!html) {\n                    resolve([]);\n                    return;\n                }\n                const tempDiv = document.createElement(\"div\");\n                tempDiv.innerHTML = html;\n                const text = tempDiv.textContent || \"\";\n                resolve(text ? [{ text }] : []);\n            });\n        });\n    }\n\n    // Mode #4: If there's a string item with empty/null type, extract first one\n    const genericStringItem = findFirstDataTransferItem(items, \"string\", (type) => type === \"\" || type == null);\n    if (genericStringItem) {\n        return new Promise((resolve) => {\n            genericStringItem.getAsString((text) => {\n                resolve(text ? [{ text }] : []);\n            });\n        });\n    }\n\n    return [];\n}\n\n/**\n * Extracts all clipboard data from a ClipboardEvent using multiple fallback methods.\n * Tries ClipboardEvent.clipboardData.items first, then Clipboard API, then simple getData().\n *\n * @param e - The ClipboardEvent (optional)\n * @returns Array of objects containing text and/or image data\n */\nexport async function extractAllClipboardData(e?: ClipboardEvent): Promise<Array<GenClipboardItem>> {\n    const results: Array<GenClipboardItem> = [];\n\n    try {\n        // First try using ClipboardEvent.clipboardData.items\n        if (e?.clipboardData?.items) {\n            return await extractDataTransferItems(e.clipboardData.items);\n        }\n\n        // Fallback: Try Clipboard API\n        const clipboardItems = await navigator.clipboard.read();\n        for (const item of clipboardItems) {\n            const data = await extractClipboardData(item);\n            if (data) {\n                results.push(data);\n            }\n        }\n        return results;\n    } catch (err) {\n        console.error(\"Clipboard read error:\", err);\n        // Final fallback: simple text paste\n        if (e?.clipboardData) {\n            const text = e.clipboardData.getData(\"text/plain\");\n            if (text) {\n                results.push({ text });\n            }\n        }\n        return results;\n    }\n}\n\n/**\n * Converts terminal buffer lines to text, properly handling wrapped lines.\n * Wrapped lines (long lines split across multiple buffer rows) are concatenated\n * without adding newlines between them, while preserving actual line breaks.\n *\n * @param buffer - The xterm.js buffer to extract lines from\n * @param startIndex - Starting buffer index (inclusive, 0-based)\n * @param endIndex - Ending buffer index (exclusive, 0-based)\n * @returns Array of logical lines (with wrapped lines concatenated)\n */\nexport function bufferLinesToText(buffer: TermTypes.IBuffer, startIndex: number, endIndex: number): string[] {\n    const lines: string[] = [];\n    let currentLine = \"\";\n    let isFirstLine = true;\n\n    // Clamp indices to valid buffer range to avoid out-of-bounds access on the\n    // underlying circular buffer, which could return stale/wrong data.\n    const clampedStart = Math.max(0, Math.min(startIndex, buffer.length));\n    const clampedEnd = Math.max(0, Math.min(endIndex, buffer.length));\n\n    for (let i = clampedStart; i < clampedEnd; i++) {\n        const line = buffer.getLine(i);\n        if (line) {\n            const lineText = line.translateToString(true);\n            // If this line is wrapped (continuation of previous line), concatenate without newline\n            if (line.isWrapped && !isFirstLine) {\n                currentLine += lineText;\n            } else {\n                // This is a new logical line\n                if (!isFirstLine) {\n                    lines.push(currentLine);\n                }\n                currentLine = lineText;\n                isFirstLine = false;\n            }\n        }\n    }\n\n    // Don't forget the last line\n    if (!isFirstLine) {\n        lines.push(currentLine);\n    }\n\n    // Trim trailing blank lines only when the requested range extends to the\n    // actual end of the buffer.  A terminal allocates a fixed number of rows\n    // (e.g. 80) but only the first few may contain real content; the rest are\n    // empty placeholder rows.  We strip those so callers don't receive a wall\n    // of empty strings.\n    //\n    // Crucially, if the caller requested a specific sub-range (e.g. lines\n    // 100-150) and lines 140-150 happen to be blank, those blanks are\n    // intentional and must NOT be removed.  We only trim when the range\n    // reaches the very end of the buffer.\n    if (clampedEnd >= buffer.length) {\n        while (lines.length > 0 && lines[lines.length - 1] === \"\") {\n            lines.pop();\n        }\n    }\n\n    return lines;\n}\n"
  },
  {
    "path": "frontend/app/view/term/termwrap.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { BlockNodeModel } from \"@/app/block/blocktypes\";\nimport { setBadge } from \"@/app/store/badge\";\nimport { getFileSubject } from \"@/app/store/wps\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport {\n    fetchWaveFile,\n    getOverrideConfigAtom,\n    getSettingsKeyAtom,\n    globalStore,\n    isDev,\n    openLink,\n    WOS,\n} from \"@/store/global\";\nimport * as services from \"@/store/services\";\nimport { PLATFORM, PlatformMacOS } from \"@/util/platformutil\";\nimport { base64ToArray, fireAndForget } from \"@/util/util\";\nimport { CanvasAddon } from \"@xterm/addon-canvas\";\nimport { SearchAddon } from \"@xterm/addon-search\";\nimport { SerializeAddon } from \"@xterm/addon-serialize\";\nimport { WebLinksAddon } from \"@xterm/addon-web-links\";\nimport { WebglAddon } from \"@xterm/addon-webgl\";\nimport * as TermTypes from \"@xterm/xterm\";\nimport { Terminal } from \"@xterm/xterm\";\nimport debug from \"debug\";\nimport * as jotai from \"jotai\";\nimport { debounce } from \"throttle-debounce\";\nimport { FitAddon } from \"./fitaddon\";\nimport {\n    handleOsc16162Command,\n    handleOsc52Command,\n    handleOsc7Command,\n    type ShellIntegrationStatus,\n} from \"./osc-handlers\";\nimport { bufferLinesToText, createTempFileFromBlob, extractAllClipboardData, normalizeCursorStyle } from \"./termutil\";\n\nconst dlog = debug(\"wave:termwrap\");\n\nconst TermFileName = \"term\";\nconst TermCacheFileName = \"cache:term:full\";\nconst MinDataProcessedForCache = 100 * 1024;\nexport const SupportsImageInput = true;\nconst IMEDedupWindowMs = 20;\nconst MaxRepaintTransactionMs = 2000;\n\n// detect webgl support\nfunction detectWebGLSupport(): boolean {\n    try {\n        const canvas = document.createElement(\"canvas\");\n        const ctx = canvas.getContext(\"webgl2\");\n        return !!ctx;\n    } catch (e) {\n        return false;\n    }\n}\n\nexport const WebGLSupported = detectWebGLSupport();\nlet loggedWebGL = false;\n\ntype TermWrapOptions = {\n    keydownHandler?: (e: KeyboardEvent) => boolean;\n    useWebGl?: boolean;\n    sendDataHandler?: (data: string) => void;\n    nodeModel?: BlockNodeModel;\n};\n\nexport class TermWrap {\n    tabId: string;\n    blockId: string;\n    ptyOffset: number;\n    dataBytesProcessed: number;\n    terminal: Terminal;\n    connectElem: HTMLDivElement;\n    fitAddon: FitAddon;\n    searchAddon: SearchAddon;\n    serializeAddon: SerializeAddon;\n    mainFileSubject: SubjectWithRef<WSFileEventData>;\n    loaded: boolean;\n    heldData: Uint8Array[];\n    handleResize_debounced: () => void;\n    hasResized: boolean;\n    multiInputCallback: (data: string) => void;\n    sendDataHandler: (data: string) => void;\n    onSearchResultsDidChange?: (result: { resultIndex: number; resultCount: number }) => void;\n    toDispose: TermTypes.IDisposable[] = [];\n    webglAddon: WebglAddon | null = null;\n    canvasAddon: CanvasAddon | null = null;\n    webglContextLossDisposable: TermTypes.IDisposable | null = null;\n    webglEnabledAtom: jotai.PrimitiveAtom<boolean>;\n    pasteActive: boolean = false;\n    lastUpdated: number;\n    promptMarkers: TermTypes.IMarker[] = [];\n    shellIntegrationStatusAtom: jotai.PrimitiveAtom<ShellIntegrationStatus | null>;\n    lastCommandAtom: jotai.PrimitiveAtom<string | null>;\n    nodeModel: BlockNodeModel; // this can be null\n    hoveredLinkUri: string | null = null;\n    onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void;\n\n    // IME composition state tracking\n    isComposing: boolean = false;\n    composingData: string = \"\";\n    lastCompositionEnd: number = 0;\n    lastComposedText: string = \"\";\n    firstDataAfterCompositionSent: boolean = false;\n\n    // Paste deduplication\n    // xterm.js paste() method triggers onData event, which can cause duplicate sends\n    lastPasteData: string = \"\";\n    lastPasteTime: number = 0;\n\n    // for scrollToBottom support during a resize\n    lastAtBottomTime: number = Date.now();\n    lastScrollAtBottom: boolean = true;\n    cachedAtBottomForResize: boolean | null = null;\n    viewportScrollTop: number = 0;\n\n    // dev only (for debugging)\n    recentWrites: { idx: number; data: string; ts: number }[] = [];\n    recentWritesCounter: number = 0;\n\n    // for repaint transaction scrolling behavior\n    lastClearScrollbackTs: number = 0;\n    lastMode2026SetTs: number = 0;\n    lastMode2026ResetTs: number = 0;\n    inSyncTransaction: boolean = false;\n    inRepaintTransaction: boolean = false;\n\n    constructor(\n        tabId: string,\n        blockId: string,\n        connectElem: HTMLDivElement,\n        options: TermTypes.ITerminalOptions & TermTypes.ITerminalInitOnlyOptions,\n        waveOptions: TermWrapOptions\n    ) {\n        this.loaded = false;\n        this.tabId = tabId;\n        this.blockId = blockId;\n        this.sendDataHandler = waveOptions.sendDataHandler;\n        this.nodeModel = waveOptions.nodeModel;\n        this.ptyOffset = 0;\n        this.dataBytesProcessed = 0;\n        this.hasResized = false;\n        this.lastUpdated = Date.now();\n        this.promptMarkers = [];\n        this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<ShellIntegrationStatus | null>;\n        this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;\n        this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom<boolean>;\n        this.terminal = new Terminal(options);\n        this.fitAddon = new FitAddon();\n        this.fitAddon.scrollbarWidth = 6; // this needs to match scrollbar width in term.scss\n        this.serializeAddon = new SerializeAddon();\n        this.searchAddon = new SearchAddon();\n        this.terminal.loadAddon(this.searchAddon);\n        this.terminal.loadAddon(this.fitAddon);\n        this.terminal.loadAddon(this.serializeAddon);\n        this.terminal.loadAddon(\n            new WebLinksAddon(\n                (e, uri) => {\n                    e.preventDefault();\n                    switch (PLATFORM) {\n                        case PlatformMacOS:\n                            if (e.metaKey) {\n                                fireAndForget(() => openLink(uri));\n                            }\n                            break;\n                        default:\n                            if (e.ctrlKey) {\n                                fireAndForget(() => openLink(uri));\n                            }\n                            break;\n                    }\n                },\n                {\n                    hover: (e, uri) => {\n                        this.hoveredLinkUri = uri;\n                        this.onLinkHover?.(uri, e.clientX, e.clientY);\n                    },\n                    leave: () => {\n                        this.hoveredLinkUri = null;\n                        this.onLinkHover?.(null, 0, 0);\n                    },\n                }\n            )\n        );\n        this.setTermRenderer(WebGLSupported && waveOptions.useWebGl ? \"webgl\" : \"canvas\");\n        // Register OSC handlers\n        this.terminal.parser.registerOscHandler(7, (data: string) => {\n            return handleOsc7Command(data, this.blockId, this.loaded);\n        });\n        this.terminal.parser.registerOscHandler(52, (data: string) => {\n            return handleOsc52Command(data, this.blockId, this.loaded, this);\n        });\n        this.terminal.parser.registerOscHandler(16162, (data: string) => {\n            return handleOsc16162Command(data, this.blockId, this.loaded, this);\n        });\n        this.toDispose.push(\n            this.terminal.parser.registerCsiHandler({ final: \"J\" }, (params) => {\n                if (params[0] === 3) {\n                    this.lastClearScrollbackTs = Date.now();\n                    if (this.inSyncTransaction) {\n                        console.log(\"[termwrap] repaint transaction starting\");\n                        this.inRepaintTransaction = true;\n                    }\n                }\n                return false;\n            })\n        );\n        this.toDispose.push(\n            this.terminal.parser.registerCsiHandler({ prefix: \"?\", final: \"h\" }, (params) => {\n                if (params[0] === 2026) {\n                    this.lastMode2026SetTs = Date.now();\n                    this.inSyncTransaction = true;\n                }\n                return false;\n            })\n        );\n        this.toDispose.push(\n            this.terminal.parser.registerCsiHandler({ prefix: \"?\", final: \"l\" }, (params) => {\n                if (params[0] === 2026) {\n                    this.lastMode2026ResetTs = Date.now();\n                    this.inSyncTransaction = false;\n                    const wasRepaint = this.inRepaintTransaction;\n                    this.inRepaintTransaction = false;\n                    if (wasRepaint && Date.now() - this.lastClearScrollbackTs <= MaxRepaintTransactionMs) {\n                        setTimeout(() => {\n                            console.log(\"[termwrap] repaint transaction complete, scrolling to bottom\");\n                            this.terminal.scrollToBottom();\n                        }, 20);\n                    }\n                }\n                return false;\n            })\n        );\n        this.toDispose.push(\n            this.terminal.onBell(() => {\n                if (!this.loaded) {\n                    return true;\n                }\n                console.log(\"BEL received in terminal\", this.blockId);\n                const bellSoundEnabled =\n                    globalStore.get(getOverrideConfigAtom(this.blockId, \"term:bellsound\")) ?? false;\n                if (bellSoundEnabled) {\n                    fireAndForget(() => RpcApi.ElectronSystemBellCommand(TabRpcClient, { route: \"electron\" }));\n                }\n                const bellIndicatorEnabled =\n                    globalStore.get(getOverrideConfigAtom(this.blockId, \"term:bellindicator\")) ?? false;\n                if (bellIndicatorEnabled) {\n                    setBadge(this.blockId, { icon: \"bell\", color: \"#fbbf24\", priority: 1 });\n                }\n                return true;\n            })\n        );\n        this.terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {\n            if (e.isComposing && !e.ctrlKey && !e.altKey && !e.metaKey) {\n                return true;\n            }\n            if (!waveOptions.keydownHandler) {\n                return true;\n            }\n            return waveOptions.keydownHandler(e);\n        });\n        this.connectElem = connectElem;\n        this.mainFileSubject = null;\n        this.heldData = [];\n        this.handleResize_debounced = debounce(50, this.handleResize.bind(this));\n        this.terminal.open(this.connectElem);\n        this.handleResize();\n        const pasteHandler = this.pasteHandler.bind(this);\n        this.connectElem.addEventListener(\"paste\", pasteHandler, true);\n        this.toDispose.push({\n            dispose: () => {\n                this.connectElem.removeEventListener(\"paste\", pasteHandler, true);\n            },\n        });\n        const viewportElem = this.connectElem.querySelector(\".xterm-viewport\") as HTMLElement;\n        if (viewportElem) {\n            const scrollHandler = (e: any) => {\n                this.handleViewportScroll(viewportElem);\n            };\n            viewportElem.addEventListener(\"scroll\", scrollHandler);\n            this.toDispose.push({\n                dispose: () => {\n                    viewportElem.removeEventListener(\"scroll\", scrollHandler);\n                },\n            });\n        }\n    }\n\n    getZoneId(): string {\n        return this.blockId;\n    }\n\n    setCursorStyle(cursorStyle: string) {\n        this.terminal.options.cursorStyle = normalizeCursorStyle(cursorStyle);\n    }\n\n    setCursorBlink(cursorBlink: boolean) {\n        this.terminal.options.cursorBlink = cursorBlink ?? false;\n    }\n\n    setTermRenderer(renderer: \"webgl\" | \"canvas\") {\n        if (renderer === \"webgl\") {\n            if (this.webglAddon != null) {\n                return;\n            }\n            if (!WebGLSupported) {\n                renderer = \"canvas\";\n                if (this.canvasAddon != null) {\n                    return;\n                }\n            }\n        } else {\n            if (this.canvasAddon != null) {\n                return;\n            }\n        }\n        if (this.webglAddon != null) {\n            this.webglContextLossDisposable?.dispose();\n            this.webglContextLossDisposable = null;\n            this.webglAddon.dispose();\n            this.webglAddon = null;\n            globalStore.set(this.webglEnabledAtom, false);\n        }\n        if (this.canvasAddon != null) {\n            this.canvasAddon.dispose();\n            this.canvasAddon = null;\n        }\n        if (renderer === \"webgl\") {\n            const addon = new WebglAddon();\n            this.webglContextLossDisposable = addon.onContextLoss(() => {\n                this.setTermRenderer(\"canvas\");\n            });\n            this.terminal.loadAddon(addon);\n            this.webglAddon = addon;\n            globalStore.set(this.webglEnabledAtom, true);\n            if (!loggedWebGL) {\n                console.log(\"loaded webgl!\");\n                loggedWebGL = true;\n            }\n        } else {\n            const addon = new CanvasAddon();\n            this.terminal.loadAddon(addon);\n            this.canvasAddon = addon;\n        }\n    }\n\n    getTermRenderer(): \"webgl\" | \"canvas\" {\n        return this.webglAddon != null ? \"webgl\" : \"canvas\";\n    }\n\n    isWebGlEnabled(): boolean {\n        return this.webglAddon != null;\n    }\n\n    resetCompositionState() {\n        this.isComposing = false;\n        this.composingData = \"\";\n        this.lastComposedText = \"\";\n        this.lastCompositionEnd = 0;\n        this.firstDataAfterCompositionSent = false;\n    }\n\n    private handleCompositionStart = (e: CompositionEvent) => {\n        dlog(\"compositionstart\", e.data);\n        this.isComposing = true;\n        this.composingData = \"\";\n    };\n\n    private handleCompositionUpdate = (e: CompositionEvent) => {\n        dlog(\"compositionupdate\", e.data);\n        this.composingData = e.data || \"\";\n    };\n\n    private handleCompositionEnd = (e: CompositionEvent) => {\n        dlog(\"compositionend\", e.data);\n        this.isComposing = false;\n        this.lastComposedText = e.data || \"\";\n        this.lastCompositionEnd = Date.now();\n        this.firstDataAfterCompositionSent = false;\n    };\n\n    async initTerminal() {\n        const copyOnSelectAtom = getSettingsKeyAtom(\"term:copyonselect\");\n        this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this)));\n        this.toDispose.push(\n            this.terminal.onSelectionChange(\n                debounce(50, () => {\n                    if (!globalStore.get(copyOnSelectAtom)) {\n                        return;\n                    }\n                    // Don't copy-on-select when the search bar has focus — navigating\n                    // search results changes the terminal selection programmatically.\n                    const active = document.activeElement;\n                    if (active != null && active.closest(\".search-container\") != null) {\n                        return;\n                    }\n                    const selectedText = this.terminal.getSelection();\n                    if (selectedText.length > 0) {\n                        navigator.clipboard.writeText(selectedText);\n                    }\n                })\n            )\n        );\n        if (this.onSearchResultsDidChange != null) {\n            this.toDispose.push(this.searchAddon.onDidChangeResults(this.onSearchResultsDidChange.bind(this)));\n        }\n\n        // Register IME composition event listeners on the xterm.js textarea\n        const textareaElem = this.connectElem.querySelector(\"textarea\");\n        if (textareaElem) {\n            textareaElem.addEventListener(\"compositionstart\", this.handleCompositionStart);\n            textareaElem.addEventListener(\"compositionupdate\", this.handleCompositionUpdate);\n            textareaElem.addEventListener(\"compositionend\", this.handleCompositionEnd);\n\n            // Handle blur during composition - reset state to avoid stale data\n            const blurHandler = () => {\n                if (this.isComposing) {\n                    dlog(\"Terminal lost focus during composition, resetting IME state\");\n                    this.resetCompositionState();\n                }\n            };\n            textareaElem.addEventListener(\"blur\", blurHandler);\n\n            this.toDispose.push({\n                dispose: () => {\n                    textareaElem.removeEventListener(\"compositionstart\", this.handleCompositionStart);\n                    textareaElem.removeEventListener(\"compositionupdate\", this.handleCompositionUpdate);\n                    textareaElem.removeEventListener(\"compositionend\", this.handleCompositionEnd);\n                    textareaElem.removeEventListener(\"blur\", blurHandler);\n                },\n            });\n        }\n\n        this.mainFileSubject = getFileSubject(this.getZoneId(), TermFileName);\n        this.mainFileSubject.subscribe(this.handleNewFileSubjectData.bind(this));\n\n        try {\n            const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, {\n                oref: WOS.makeORef(\"block\", this.blockId),\n            });\n\n            if (rtInfo && rtInfo[\"shell:integration\"]) {\n                const shellState = rtInfo[\"shell:state\"] as ShellIntegrationStatus;\n                globalStore.set(this.shellIntegrationStatusAtom, shellState || null);\n            } else {\n                globalStore.set(this.shellIntegrationStatusAtom, null);\n            }\n\n            const lastCmd = rtInfo ? rtInfo[\"shell:lastcmd\"] : null;\n            globalStore.set(this.lastCommandAtom, lastCmd || null);\n        } catch (e) {\n            console.log(\"Error loading runtime info:\", e);\n        }\n\n        try {\n            await this.loadInitialTerminalData();\n        } finally {\n            this.loaded = true;\n        }\n        this.runProcessIdleTimeout();\n    }\n\n    dispose() {\n        this.promptMarkers.forEach((marker) => {\n            try {\n                marker.dispose();\n            } catch (_) {}\n        });\n        this.promptMarkers = [];\n        this.webglContextLossDisposable?.dispose();\n        this.webglContextLossDisposable = null;\n        this.terminal.dispose();\n        this.toDispose.forEach((d) => {\n            try {\n                d.dispose();\n            } catch (_) {}\n        });\n        this.mainFileSubject.release();\n    }\n\n    handleTermData(data: string) {\n        if (!this.loaded) {\n            return;\n        }\n\n        // IME fix: suppress isComposing=true events unless they immediately follow\n        // a compositionend (within 20ms). This handles CapsLock input method switching\n        // where the composition buffer gets flushed as a spurious isComposing=true event\n        if (this.isComposing) {\n            const timeSinceCompositionEnd = Date.now() - this.lastCompositionEnd;\n            if (timeSinceCompositionEnd > IMEDedupWindowMs) {\n                dlog(\"Suppressed IME data (composing, not near compositionend):\", data);\n                return;\n            }\n        }\n\n        this.sendDataHandler?.(data);\n        this.multiInputCallback?.(data);\n    }\n\n    addFocusListener(focusFn: () => void) {\n        this.terminal.textarea.addEventListener(\"focus\", focusFn);\n    }\n\n    handleNewFileSubjectData(msg: WSFileEventData) {\n        if (msg.fileop == \"truncate\") {\n            this.terminal.clear();\n            this.heldData = [];\n        } else if (msg.fileop == \"append\") {\n            const decodedData = base64ToArray(msg.data64);\n            if (this.loaded) {\n                this.doTerminalWrite(decodedData, null);\n            } else {\n                this.heldData.push(decodedData);\n            }\n        } else {\n            console.log(\"bad fileop for terminal\", msg);\n            return;\n        }\n    }\n\n    doTerminalWrite(data: string | Uint8Array, setPtyOffset?: number): Promise<void> {\n        if (isDev() && this.loaded) {\n            const dataStr = data instanceof Uint8Array ? new TextDecoder().decode(data) : data;\n            this.recentWrites.push({ idx: this.recentWritesCounter++, ts: Date.now(), data: dataStr });\n            if (this.recentWrites.length > 50) {\n                this.recentWrites.shift();\n            }\n        }\n        let resolve: () => void = null;\n        const prtn = new Promise<void>((presolve, _) => {\n            resolve = presolve;\n        });\n        this.terminal.write(data, () => {\n            if (setPtyOffset != null) {\n                this.ptyOffset = setPtyOffset;\n            } else {\n                this.ptyOffset += data.length;\n                this.dataBytesProcessed += data.length;\n            }\n            this.lastUpdated = Date.now();\n            resolve();\n        });\n        return prtn;\n    }\n\n    async loadInitialTerminalData(): Promise<void> {\n        const startTs = Date.now();\n        const zoneId = this.getZoneId();\n        const { data: cacheData, fileInfo: cacheFile } = await fetchWaveFile(zoneId, TermCacheFileName);\n        let ptyOffset = 0;\n        if (cacheFile != null) {\n            ptyOffset = cacheFile.meta[\"ptyoffset\"] ?? 0;\n            if (cacheData.byteLength > 0) {\n                const curTermSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };\n                const fileTermSize: TermSize = cacheFile.meta[\"termsize\"];\n                let didResize = false;\n                if (\n                    fileTermSize != null &&\n                    (fileTermSize.rows != curTermSize.rows || fileTermSize.cols != curTermSize.cols)\n                ) {\n                    console.log(\"terminal restore size mismatch, temp resize\", fileTermSize, curTermSize);\n                    this.terminal.resize(fileTermSize.cols, fileTermSize.rows);\n                    didResize = true;\n                }\n                this.doTerminalWrite(cacheData, ptyOffset);\n                if (didResize) {\n                    this.terminal.resize(curTermSize.cols, curTermSize.rows);\n                }\n            }\n        }\n        const { data: mainData, fileInfo: mainFile } = await fetchWaveFile(zoneId, TermFileName, ptyOffset);\n        console.log(\n            `terminal loaded cachefile:${cacheData?.byteLength ?? 0} main:${mainData?.byteLength ?? 0} bytes, ${Date.now() - startTs}ms`\n        );\n        if (mainFile != null) {\n            await this.doTerminalWrite(mainData, null);\n        }\n    }\n\n    async resyncController(reason: string) {\n        dlog(\"resync controller\", this.blockId, reason);\n        const rtOpts: RuntimeOpts = { termsize: { rows: this.terminal.rows, cols: this.terminal.cols } };\n        try {\n            await RpcApi.ControllerResyncCommand(TabRpcClient, {\n                tabid: this.tabId,\n                blockid: this.blockId,\n                rtopts: rtOpts,\n            });\n        } catch (e) {\n            console.log(`error controller resync (${reason})`, this.blockId, e);\n        }\n    }\n\n    setAtBottom(atBottom: boolean) {\n        if (this.lastScrollAtBottom && !atBottom) {\n            this.lastAtBottomTime = Date.now();\n        }\n        this.lastScrollAtBottom = atBottom;\n        if (atBottom) {\n            this.lastAtBottomTime = Date.now();\n        }\n    }\n\n    wasRecentlyAtBottom(): boolean {\n        if (this.lastScrollAtBottom) {\n            return true;\n        }\n        return Date.now() - this.lastAtBottomTime <= 1000;\n    }\n\n    handleViewportScroll(viewportElem: HTMLElement) {\n        const { scrollTop, scrollHeight, clientHeight } = viewportElem;\n        const atBottom = scrollTop + clientHeight >= scrollHeight - clientHeight * 0.5;\n        this.setAtBottom(atBottom);\n        const delta = this.viewportScrollTop - scrollTop;\n        if (isDev() && delta >= 500) {\n            console.log(\n                `[termwrap] large-scroll blockId=${this.blockId} delta=${Math.round(delta)}px scrollTop=${scrollTop} wasNearBottom=${atBottom}`\n            );\n        }\n        this.viewportScrollTop = scrollTop;\n    }\n\n    handleResize() {\n        const oldRows = this.terminal.rows;\n        const oldCols = this.terminal.cols;\n        const atBottom = this.cachedAtBottomForResize ?? this.wasRecentlyAtBottom();\n        if (!atBottom) {\n            this.cachedAtBottomForResize = null;\n        }\n        this.fitAddon.fit();\n        if (oldRows !== this.terminal.rows || oldCols !== this.terminal.cols) {\n            const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };\n            console.log(\n                \"[termwrap] resize\",\n                `${oldRows}x${oldCols}`,\n                \"->\",\n                `${this.terminal.rows}x${this.terminal.cols}`,\n                \"atBottom:\",\n                atBottom\n            );\n            RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, termsize: termSize });\n        }\n        dlog(\"resize\", `${this.terminal.rows}x${this.terminal.cols}`, `${oldRows}x${oldCols}`, this.hasResized);\n        if (!this.hasResized) {\n            this.hasResized = true;\n            this.resyncController(\"initial resize\");\n        }\n        if (atBottom) {\n            setTimeout(() => {\n                console.log(\"[termwrap] resize scroll-to-bottom\");\n                this.cachedAtBottomForResize = null;\n                this.terminal.scrollToBottom();\n                this.setAtBottom(true);\n            }, 20);\n        }\n    }\n\n    processAndCacheData() {\n        if (this.dataBytesProcessed < MinDataProcessedForCache) {\n            return;\n        }\n        const serializedOutput = this.serializeAddon.serialize();\n        const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };\n        console.log(\"idle timeout term\", this.dataBytesProcessed, serializedOutput.length, termSize);\n        fireAndForget(() =>\n            services.BlockService.SaveTerminalState(this.blockId, serializedOutput, \"full\", this.ptyOffset, termSize)\n        );\n        this.dataBytesProcessed = 0;\n    }\n\n    runProcessIdleTimeout() {\n        setTimeout(() => {\n            window.requestIdleCallback(() => {\n                this.processAndCacheData();\n                this.runProcessIdleTimeout();\n            });\n        }, 5000);\n    }\n\n    async pasteHandler(e?: ClipboardEvent): Promise<void> {\n        this.pasteActive = true;\n        e?.preventDefault();\n        e?.stopPropagation();\n\n        try {\n            const clipboardData = await extractAllClipboardData(e);\n            let firstImage = true;\n            for (const data of clipboardData) {\n                if (data.image && SupportsImageInput) {\n                    if (!firstImage) {\n                        await new Promise((r) => setTimeout(r, 150));\n                    }\n                    const tempPath = await createTempFileFromBlob(data.image);\n                    this.terminal.paste(tempPath + \" \");\n                    firstImage = false;\n                }\n                if (data.text) {\n                    this.terminal.paste(data.text);\n                }\n            }\n        } catch (err) {\n            console.error(\"Paste error:\", err);\n        } finally {\n            setTimeout(() => {\n                this.pasteActive = false;\n            }, 30);\n        }\n    }\n\n    getScrollbackContent(): string {\n        if (!this.terminal) {\n            return \"\";\n        }\n        const buffer = this.terminal.buffer.active;\n        const lines = bufferLinesToText(buffer, 0, buffer.length);\n        return lines.join(\"\\n\");\n    }\n}\n"
  },
  {
    "path": "frontend/app/view/term/xterm.css",
    "content": "/**\n * Copyright (c) 2014 The xterm.js authors. All rights reserved.\n * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)\n * https://github.com/chjj/term.js\n * @license MIT\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in\n * all copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n * THE SOFTWARE.\n *\n * Originally forked from (with the author's permission):\n *   Fabrice Bellard's javascript vt100 for jslinux:\n *   http://bellard.org/jslinux/\n *   Copyright (c) 2011 Fabrice Bellard\n *   The original design remains. The terminal itself\n *   has been extended to include xterm CSI codes, among\n *   other features.\n */\n\n/**\n *  Default styles for xterm.js\n */\n\n.xterm {\n    cursor: text;\n    position: relative;\n    user-select: none;\n    -ms-user-select: none;\n    -webkit-user-select: none;\n}\n\n.xterm.focus,\n.xterm:focus {\n    outline: none;\n}\n\n.xterm .xterm-helpers {\n    position: absolute;\n    top: 0;\n    /**\n     * The z-index of the helpers must be higher than the canvases in order for\n     * IMEs to appear on top.\n     */\n    z-index: 5;\n}\n\n.xterm .xterm-helper-textarea {\n    padding: 0;\n    border: 0;\n    margin: 0;\n    /* Move textarea out of the screen to the far left, so that the cursor is not visible */\n    position: absolute;\n    opacity: 0;\n    left: -9999em;\n    top: 0;\n    width: 0;\n    height: 0;\n    z-index: -5;\n    /** Prevent wrapping so the IME appears against the textarea at the correct position */\n    white-space: nowrap;\n    overflow: hidden;\n    resize: none;\n}\n\n.xterm .composition-view {\n    /* TODO: Composition position got messed up somewhere */\n    background: #000;\n    color: #fff;\n    display: none;\n    position: absolute;\n    white-space: nowrap;\n    z-index: 1;\n}\n\n.xterm .composition-view.active {\n    display: block;\n}\n\n.xterm .xterm-viewport {\n    /* On OS X this is required in order for the scroll bar to appear fully opaque */\n    background-color: #000;\n    overflow-y: scroll;\n    cursor: default;\n    position: absolute;\n    right: 0; /* if this gets updated, must update fitaddon.ts */\n    left: 0;\n    top: 0;\n    bottom: 0;\n}\n\n.xterm .xterm-screen {\n    position: relative;\n}\n\n.xterm .xterm-screen canvas {\n    position: absolute;\n    left: 0;\n    top: 0;\n}\n\n.xterm .xterm-scroll-area {\n    visibility: hidden;\n}\n\n.xterm-char-measure-element {\n    display: inline-block;\n    visibility: hidden;\n    position: absolute;\n    top: 0;\n    left: -9999em;\n    line-height: normal;\n}\n\n.xterm.enable-mouse-events {\n    /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */\n    cursor: default;\n}\n\n.xterm.xterm-cursor-pointer,\n.xterm .xterm-cursor-pointer {\n    cursor: pointer;\n}\n\n.xterm.column-select.focus {\n    /* Column selection mode */\n    cursor: crosshair;\n}\n\n.xterm .xterm-accessibility:not(.debug),\n.xterm .xterm-message {\n    position: absolute;\n    left: 0;\n    top: 0;\n    bottom: 0;\n    right: 0;\n    z-index: 10;\n    color: transparent;\n    pointer-events: none;\n}\n\n.xterm .xterm-accessibility-tree:not(.debug) *::selection {\n    color: transparent;\n}\n\n.xterm .xterm-accessibility-tree {\n    user-select: text;\n    white-space: pre;\n}\n\n.xterm .live-region {\n    position: absolute;\n    left: -9999px;\n    width: 1px;\n    height: 1px;\n    overflow: hidden;\n}\n\n.xterm-dim {\n    /* Dim should not apply to background, so the opacity of the foreground color is applied\n     * explicitly in the generated class and reset to 1 here */\n    opacity: 1 !important;\n}\n\n.xterm-underline-1 {\n    text-decoration: underline;\n}\n.xterm-underline-2 {\n    text-decoration: double underline;\n}\n.xterm-underline-3 {\n    text-decoration: wavy underline;\n}\n.xterm-underline-4 {\n    text-decoration: dotted underline;\n}\n.xterm-underline-5 {\n    text-decoration: dashed underline;\n}\n\n.xterm-overline {\n    text-decoration: overline;\n}\n\n.xterm-overline.xterm-underline-1 {\n    text-decoration: overline underline;\n}\n.xterm-overline.xterm-underline-2 {\n    text-decoration: overline double underline;\n}\n.xterm-overline.xterm-underline-3 {\n    text-decoration: overline wavy underline;\n}\n.xterm-overline.xterm-underline-4 {\n    text-decoration: overline dotted underline;\n}\n.xterm-overline.xterm-underline-5 {\n    text-decoration: overline dashed underline;\n}\n\n.xterm-strikethrough {\n    text-decoration: line-through;\n}\n\n.xterm-screen .xterm-decoration-container .xterm-decoration {\n    z-index: 6;\n    position: absolute;\n}\n\n.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {\n    z-index: 7;\n}\n\n.xterm-decoration-overview-ruler {\n    z-index: 8;\n    position: absolute;\n    top: 0;\n    right: 0;\n    pointer-events: none;\n}\n\n.xterm-decoration-top {\n    z-index: 2;\n    position: relative;\n}\n"
  },
  {
    "path": "frontend/app/view/tsunami/tsunami.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { getApi, globalStore, WOS } from \"@/app/store/global\";\nimport { waveEventSubscribeSingle } from \"@/app/store/wps\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { WebView, WebViewModel } from \"@/app/view/webview/webview\";\nimport * as services from \"@/store/services\";\nimport * as jotai from \"jotai\";\nimport { memo, useEffect } from \"react\";\n\nclass TsunamiViewModel extends WebViewModel {\n    shellProcFullStatus: jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;\n    shellProcStatusUnsubFn: () => void;\n    appMeta: jotai.PrimitiveAtom<AppMeta>;\n    appMetaUnsubFn: () => void;\n    isRestarting: jotai.PrimitiveAtom<boolean>;\n    viewIcon: jotai.Atom<IconButtonDecl>;\n    viewName: jotai.Atom<string>;\n\n    constructor(initOpts: ViewModelInitType) {\n        super(initOpts);\n        this.viewType = \"tsunami\";\n        this.isRestarting = jotai.atom(false);\n\n        // Hide navigation bar (URL bar, back/forward/home buttons)\n        this.hideNav = jotai.atom(true);\n\n        // Set custom partition for tsunami WebView isolation\n        this.partitionOverride = jotai.atom(`tsunami:${this.blockId}`);\n\n        this.shellProcFullStatus = jotai.atom(null) as jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;\n        const initialShellProcStatus = services.BlockService.GetControllerStatus(this.blockId);\n        initialShellProcStatus.then((rts) => {\n            this.updateShellProcStatus(rts);\n        });\n        this.shellProcStatusUnsubFn = waveEventSubscribeSingle({\n            eventType: \"controllerstatus\",\n            scope: WOS.makeORef(\"block\", this.blockId),\n            handler: (event) => {\n                this.updateShellProcStatus(event.data);\n            },\n        });\n\n        this.appMeta = jotai.atom(null) as jotai.PrimitiveAtom<AppMeta>;\n        this.viewIcon = jotai.atom((get) => {\n            const meta = get(this.appMeta);\n            const icon = meta?.icon || \"cube\";\n            const iconColor = meta?.iconcolor;\n            return {\n                elemtype: \"iconbutton\" as const,\n                icon: icon,\n                iconColor: iconColor,\n            };\n        });\n        this.viewName = jotai.atom((get) => {\n            const meta = get(this.appMeta);\n            return meta?.title || \"WaveApp\";\n        });\n        const initialRTInfo = RpcApi.GetRTInfoCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"block\", this.blockId),\n        });\n        initialRTInfo.then((rtInfo) => {\n            if (rtInfo && rtInfo[\"tsunami:appmeta\"]) {\n                globalStore.set(this.appMeta, rtInfo[\"tsunami:appmeta\"]);\n            }\n        });\n        this.appMetaUnsubFn = waveEventSubscribeSingle({\n            eventType: \"tsunami:updatemeta\",\n            scope: WOS.makeORef(\"block\", this.blockId),\n            handler: (event) => {\n                globalStore.set(this.appMeta, event.data);\n            },\n        });\n    }\n\n    get viewComponent(): ViewComponent {\n        return TsunamiView;\n    }\n\n    updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) {\n        console.log(\"tsunami-status\", fullStatus);\n        if (fullStatus == null) {\n            return;\n        }\n        const curStatus = globalStore.get(this.shellProcFullStatus);\n        if (curStatus == null || curStatus.version < fullStatus.version) {\n            globalStore.set(this.shellProcFullStatus, fullStatus);\n        }\n    }\n\n    triggerRestartAtom() {\n        globalStore.set(this.isRestarting, true);\n        setTimeout(() => {\n            globalStore.set(this.isRestarting, false);\n        }, 300);\n    }\n\n    private doControllerResync(forceRestart: boolean, logContext: string, triggerRestart: boolean = true) {\n        if (triggerRestart) {\n            if (globalStore.get(this.isRestarting)) {\n                return;\n            }\n            this.triggerRestartAtom();\n        }\n        const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, {\n            tabid: this.tabModel.tabId,\n            blockid: this.blockId,\n            forcerestart: forceRestart,\n        });\n        prtn.catch((e) => console.log(`error controller resync (${logContext})`, e));\n    }\n\n    resyncController() {\n        this.doControllerResync(false, \"resync\", false);\n    }\n\n    destroyController() {\n        const prtn = RpcApi.ControllerDestroyCommand(TabRpcClient, this.blockId);\n        prtn.catch((e) => console.log(\"error destroying controller\", e));\n    }\n\n    async restartController() {\n        if (globalStore.get(this.isRestarting)) {\n            return;\n        }\n        this.triggerRestartAtom();\n        try {\n            // Stop the controller first\n            await RpcApi.ControllerDestroyCommand(TabRpcClient, this.blockId);\n            // Wait a bit for the controller to fully stop\n            await new Promise((resolve) => setTimeout(resolve, 300));\n            // Then resync to restart it\n            await RpcApi.ControllerResyncCommand(TabRpcClient, {\n                tabid: this.tabModel.tabId,\n                blockid: this.blockId,\n                forcerestart: false,\n            });\n        } catch (e) {\n            console.log(\"error restarting controller\", e);\n        }\n    }\n\n    restartAndForceRebuild() {\n        this.doControllerResync(true, \"force rebuild\");\n    }\n\n    forceRestartController() {\n        // Keep this for backward compatibility with the Start button\n        this.doControllerResync(true, \"force restart\");\n    }\n\n    async remixInBuilder() {\n        const blockData = globalStore.get(this.blockAtom);\n        const appId = blockData?.meta?.[\"tsunami:appid\"];\n\n        if (!appId || !appId.startsWith(\"local/\")) {\n            return;\n        }\n\n        try {\n            const result = await RpcApi.MakeDraftFromLocalCommand(TabRpcClient, { localappid: appId });\n            const draftAppId = result.draftappid;\n\n            getApi().openBuilder(draftAppId);\n        } catch (err) {\n            console.error(\"Failed to create draft from local app:\", err);\n        }\n    }\n\n    dispose() {\n        if (this.shellProcStatusUnsubFn) {\n            this.shellProcStatusUnsubFn();\n        }\n        if (this.appMetaUnsubFn) {\n            this.appMetaUnsubFn();\n        }\n    }\n\n    getSettingsMenuItems(): ContextMenuItem[] {\n        const items = super.getSettingsMenuItems();\n        // Filter out homepage and navigation-related menu items for tsunami view\n        const filteredItems = items.filter((item) => {\n            const label = item.label?.toLowerCase() || \"\";\n            return (\n                !label.includes(\"homepage\") &&\n                !label.includes(\"home page\") &&\n                !label.includes(\"navigation\") &&\n                !label.includes(\"nav\")\n            );\n        });\n\n        // Check if we should show the Remix option\n        const blockData = globalStore.get(this.blockAtom);\n        const appId = blockData?.meta?.[\"tsunami:appid\"];\n        const showRemixOption = appId && appId.startsWith(\"local/\");\n\n        // Add tsunami-specific menu items at the beginning\n        const tsunamiItems: ContextMenuItem[] = [\n            {\n                label: \"Stop WaveApp\",\n                click: () => this.destroyController(),\n            },\n            {\n                label: \"Restart WaveApp\",\n                click: () => this.restartController(),\n            },\n            {\n                label: \"Restart WaveApp and Force Rebuild\",\n                click: () => this.restartAndForceRebuild(),\n            },\n            {\n                type: \"separator\",\n            },\n        ];\n\n        if (showRemixOption) {\n            tsunamiItems.push(\n                {\n                    label: \"Remix WaveApp in Builder\",\n                    click: () => this.remixInBuilder(),\n                },\n                {\n                    type: \"separator\",\n                }\n            );\n        }\n\n        return [...tsunamiItems, ...filteredItems];\n    }\n}\n\nconst TsunamiView = memo((props: ViewComponentProps<TsunamiViewModel>) => {\n    const { model } = props;\n    const shellProcFullStatus = jotai.useAtomValue(model.shellProcFullStatus);\n    const blockData = jotai.useAtomValue(model.blockAtom);\n    const isRestarting = jotai.useAtomValue(model.isRestarting);\n    const domReady = jotai.useAtomValue(model.domReady);\n\n    useEffect(() => {\n        model.resyncController();\n    }, [model]);\n\n    const appPath = blockData?.meta?.[\"tsunami:apppath\"];\n    const appId = blockData?.meta?.[\"tsunami:appid\"];\n    const controller = blockData?.meta?.controller;\n\n    // Check for configuration errors\n    const errors = [];\n    if (!appPath && !appId) {\n        errors.push(\"App path or app ID must be set (tsunami:apppath or tsunami:appid)\");\n    }\n    if (controller !== \"tsunami\") {\n        errors.push(\"Invalid controller (must be 'tsunami')\");\n    }\n\n    // Show errors if any exist\n    if (errors.length > 0) {\n        return (\n            <div className=\"w-full h-full flex flex-col items-center justify-center gap-4\">\n                <h1 className=\"text-4xl font-bold text-main-text-color\">Tsunami</h1>\n                <div className=\"flex flex-col gap-2\">\n                    {errors.map((error, index) => (\n                        <div key={index} className=\"text-sm\" style={{ color: \"var(--color-error)\" }}>\n                            {error}\n                        </div>\n                    ))}\n                </div>\n            </div>\n        );\n    }\n\n    // Check if we should show the webview\n    const shouldShowWebView =\n        shellProcFullStatus?.shellprocstatus === \"running\" &&\n        shellProcFullStatus?.tsunamiport &&\n        shellProcFullStatus.tsunamiport !== 0;\n\n    if (shouldShowWebView) {\n        const tsunamiUrl = `http://localhost:${shellProcFullStatus.tsunamiport}/?clientid=wave:${model.blockId}`;\n        return (\n            <div className=\"w-full h-full\">\n                <WebView {...props} initialSrc={tsunamiUrl} />\n            </div>\n        );\n    }\n\n    const status = shellProcFullStatus?.shellprocstatus ?? \"init\";\n    const isNotRunning = status === \"done\" || status === \"init\";\n\n    return (\n        <div className=\"w-full h-full flex flex-col items-center justify-center gap-4\">\n            <h1 className=\"text-4xl font-bold text-main-text-color\">Tsunami</h1>\n            {(appPath || appId) && <div className=\"text-sm text-main-text-color opacity-70\">{appPath || appId}</div>}\n            {isNotRunning && !isRestarting && (\n                <button\n                    onClick={() => model.forceRestartController()}\n                    className=\"px-4 py-2 bg-accent-color text-primary-text-color rounded hover:bg-accent-color/80 transition-colors cursor-pointer\"\n                >\n                    Start\n                </button>\n            )}\n            {isRestarting && <div className=\"text-sm text-success-color\">Starting...</div>}\n        </div>\n    );\n});\n\nTsunamiView.displayName = \"TsunamiView\";\n\nexport { TsunamiViewModel };\n"
  },
  {
    "path": "frontend/app/view/vdom/vdom-model.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { BlockNodeModel } from \"@/app/block/blocktypes\";\nimport { getBlockMetaKeyAtom, globalStore, WOS } from \"@/app/store/global\";\nimport type { TabModel } from \"@/app/store/tab-model\";\nimport { makeORef } from \"@/app/store/wos\";\nimport { waveEventSubscribeSingle } from \"@/app/store/wps\";\nimport { RpcResponseHelper, WshClient } from \"@/app/store/wshclient\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { makeFeBlockRouteId } from \"@/app/store/wshrouter\";\nimport { DefaultRouter, TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { VDomView } from \"@/app/view/vdom/vdom\";\nimport { applyCanvasOp, mergeBackendUpdates, restoreVDomElems } from \"@/app/view/vdom/vdom-utils\";\nimport { getWebServerEndpoint } from \"@/util/endpoints\";\nimport { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from \"@/util/keyutil\";\nimport { PLATFORM, PlatformMacOS } from \"@/util/platformutil\";\nimport debug from \"debug\";\nimport * as jotai from \"jotai\";\n\nconst dlog = debug(\"wave:vdom\");\n\ntype AtomContainer = {\n    val: any;\n    beVal: any;\n    usedBy: Set<string>;\n};\n\ntype RefContainer = {\n    refFn: (elem: HTMLElement) => void;\n    vdomRef: VDomRef;\n    elem: HTMLElement;\n    updated: boolean;\n};\n\nfunction makeVDomIdMap(vdom: VDomElem, idMap: Map<string, VDomElem>) {\n    if (vdom == null) {\n        return;\n    }\n    if (vdom.waveid != null) {\n        idMap.set(vdom.waveid, vdom);\n    }\n    if (vdom.children == null) {\n        return;\n    }\n    for (let child of vdom.children) {\n        makeVDomIdMap(child, idMap);\n    }\n}\n\nfunction annotateEvent(event: VDomEvent, propName: string, reactEvent: React.SyntheticEvent) {\n    if (reactEvent == null) {\n        return;\n    }\n    if (propName == \"onChange\") {\n        const changeEvent = reactEvent as React.ChangeEvent<any>;\n        event.targetvalue = changeEvent.target?.value;\n        event.targetchecked = changeEvent.target?.checked;\n    }\n    if (propName == \"onClick\" || propName == \"onMouseDown\") {\n        const mouseEvent = reactEvent as React.MouseEvent<any>;\n        event.mousedata = {\n            button: mouseEvent.button,\n            buttons: mouseEvent.buttons,\n            alt: mouseEvent.altKey,\n            control: mouseEvent.ctrlKey,\n            shift: mouseEvent.shiftKey,\n            meta: mouseEvent.metaKey,\n            clientx: mouseEvent.clientX,\n            clienty: mouseEvent.clientY,\n            pagex: mouseEvent.pageX,\n            pagey: mouseEvent.pageY,\n            screenx: mouseEvent.screenX,\n            screeny: mouseEvent.screenY,\n            movementx: mouseEvent.movementX,\n            movementy: mouseEvent.movementY,\n        };\n        if (PLATFORM == PlatformMacOS) {\n            event.mousedata.cmd = event.mousedata.meta;\n            event.mousedata.option = event.mousedata.alt;\n        } else {\n            event.mousedata.cmd = event.mousedata.alt;\n            event.mousedata.option = event.mousedata.meta;\n        }\n    }\n    if (propName == \"onKeyDown\") {\n        const waveKeyEvent = adaptFromReactOrNativeKeyEvent(reactEvent as React.KeyboardEvent);\n        event.keydata = waveKeyEvent;\n    }\n}\n\nclass VDomWshClient extends WshClient {\n    model: VDomModel;\n\n    constructor(model: VDomModel) {\n        super(makeFeBlockRouteId(model.blockId));\n        this.model = model;\n    }\n\n    handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) {\n        dlog(\"async-initiation\", rh.getSource(), data);\n        this.model.queueUpdate(true);\n    }\n}\n\nexport class VDomModel {\n    blockId: string;\n    nodeModel: BlockNodeModel;\n    tabModel: TabModel;\n    viewType: string;\n    viewIcon: jotai.Atom<string>;\n    viewName: jotai.Atom<string>;\n    viewRef: React.RefObject<HTMLDivElement> = { current: null };\n    vdomRoot: jotai.PrimitiveAtom<VDomElem> = jotai.atom();\n    atoms: Map<string, AtomContainer> = new Map(); // key is atomname\n    refs: Map<string, RefContainer> = new Map(); // key is refid\n    batchedEvents: VDomEvent[] = [];\n    messages: VDomMessage[] = [];\n    needsResync: boolean = true;\n    vdomNodeVersion: WeakMap<VDomElem, jotai.PrimitiveAtom<number>> = new WeakMap();\n    compoundAtoms: Map<string, jotai.PrimitiveAtom<{ [key: string]: any }>> = new Map();\n    rootRefId: string = crypto.randomUUID();\n    backendRoute: jotai.Atom<string>;\n    backendOpts: VDomBackendOpts;\n    shouldDispose: boolean;\n    disposed: boolean;\n    hasPendingRequest: boolean;\n    needsUpdate: boolean;\n    maxNormalUpdateIntervalMs: number = 100;\n    needsImmediateUpdate: boolean;\n    lastUpdateTs: number = 0;\n    queuedUpdate: { timeoutId: any; ts: number; quick: boolean };\n    contextActive: jotai.PrimitiveAtom<boolean>;\n    wshClient: VDomWshClient;\n    persist: jotai.Atom<boolean>;\n    routeGoneUnsub: () => void;\n    routeConfirmed: boolean = false;\n    refOutputStore: Map<string, any> = new Map();\n    globalVersion: jotai.PrimitiveAtom<number> = jotai.atom(0);\n    hasBackendWork: boolean = false;\n    noPadding: jotai.PrimitiveAtom<boolean>;\n\n    constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) {\n        this.viewType = \"vdom\";\n        this.blockId = blockId;\n        this.nodeModel = nodeModel;\n        this.tabModel = tabModel;\n        this.contextActive = jotai.atom(false);\n        this.reset();\n        this.viewIcon = jotai.atom(\"bolt\");\n        this.viewName = jotai.atom(\"Wave App\");\n        this.backendRoute = jotai.atom((get) => {\n            const blockData = get(WOS.getWaveObjectAtom<Block>(makeORef(\"block\", this.blockId)));\n            return blockData?.meta?.[\"vdom:route\"];\n        });\n        this.noPadding = jotai.atom(true);\n        this.persist = getBlockMetaKeyAtom(this.blockId, \"vdom:persist\");\n        this.wshClient = new VDomWshClient(this);\n        DefaultRouter.registerRoute(this.wshClient.routeId, this.wshClient);\n        const curBackendRoute = globalStore.get(this.backendRoute);\n        if (curBackendRoute) {\n            this.queueUpdate(true);\n        }\n        this.routeGoneUnsub = waveEventSubscribeSingle({\n            eventType: \"route:down\",\n            scope: curBackendRoute,\n            handler: (_event) => {\n                this.disposed = true;\n                const shouldPersist = globalStore.get(this.persist);\n                if (!shouldPersist) {\n                    this.nodeModel?.onClose?.();\n                }\n            },\n        });\n        RpcApi.WaitForRouteCommand(TabRpcClient, { routeid: curBackendRoute, waitms: 4000 }, { timeout: 5000 }).then(\n            (routeOk: boolean) => {\n                if (routeOk) {\n                    this.routeConfirmed = true;\n                    this.queueUpdate(true);\n                } else {\n                    this.disposed = true;\n                    const shouldPersist = globalStore.get(this.persist);\n                    if (!shouldPersist) {\n                        this.nodeModel?.onClose?.();\n                    }\n                }\n            }\n        );\n    }\n\n    get viewComponent(): ViewComponent {\n        return VDomView;\n    }\n\n    dispose() {\n        DefaultRouter.unregisterRoute(this.wshClient.routeId);\n        this.routeGoneUnsub?.();\n    }\n\n    reset() {\n        globalStore.set(this.vdomRoot, null);\n        this.atoms.clear();\n        this.refs.clear();\n        this.batchedEvents = [];\n        this.messages = [];\n        this.needsResync = true;\n        this.vdomNodeVersion = new WeakMap();\n        this.compoundAtoms.clear();\n        this.rootRefId = crypto.randomUUID();\n        this.backendOpts = {};\n        this.shouldDispose = false;\n        this.disposed = false;\n        this.hasPendingRequest = false;\n        this.needsUpdate = false;\n        this.maxNormalUpdateIntervalMs = 100;\n        this.needsImmediateUpdate = false;\n        this.lastUpdateTs = 0;\n        this.queuedUpdate = null;\n        this.refOutputStore.clear();\n        this.globalVersion = jotai.atom(0);\n        this.hasBackendWork = false;\n        globalStore.set(this.contextActive, false);\n    }\n\n    getBackendRoute(): string {\n        const blockData = globalStore.get(WOS.getWaveObjectAtom<Block>(makeORef(\"block\", this.blockId)));\n        return blockData?.meta?.[\"vdom:route\"];\n    }\n\n    transformVDomUrl(url: string): string {\n        if (url == null || url == \"\") {\n            return null;\n        }\n        if (!url.startsWith(\"vdom://\")) {\n            return url;\n        }\n        const absUrl = url.substring(7);\n        return this.makeVDomUrl(absUrl);\n    }\n\n    makeVDomUrl(path: string): string {\n        if (path == null || path == \"\") {\n            return null;\n        }\n        if (!path.startsWith(\"/\")) {\n            return null;\n        }\n        const backendRouteId = this.getBackendRouteId();\n        if (backendRouteId == null) {\n            return null;\n        }\n        const wsEndpoint = getWebServerEndpoint();\n        const fullUrl = wsEndpoint + \"/vdom/\" + backendRouteId + path;\n        return fullUrl;\n    }\n\n    keyDownHandler(e: WaveKeyboardEvent): boolean {\n        if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, \"Ctrl:c\")) {\n            this.shouldDispose = true;\n            this.queueUpdate(true);\n            return true;\n        }\n        if (this.backendOpts?.globalkeyboardevents) {\n            if (e.cmd || e.meta) {\n                return false;\n            }\n            this.batchedEvents.push({\n                globaleventtype: \"onKeyDown\",\n                waveid: null,\n                eventtype: \"onKeyDown\",\n                keydata: e,\n            });\n            this.queueUpdate();\n            return true;\n        }\n        return false;\n    }\n\n    hasRefUpdates() {\n        for (let ref of this.refs.values()) {\n            if (ref.updated) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    getRefUpdates(): VDomRefUpdate[] {\n        let updates: VDomRefUpdate[] = [];\n        for (let ref of this.refs.values()) {\n            if (ref.updated || (ref.vdomRef.trackposition && ref.elem != null)) {\n                const ru: VDomRefUpdate = {\n                    refid: ref.vdomRef.refid,\n                    hascurrent: ref.vdomRef.hascurrent,\n                };\n                if (ref.vdomRef.trackposition && ref.elem != null) {\n                    ru.position = {\n                        offsetheight: ref.elem.offsetHeight,\n                        offsetwidth: ref.elem.offsetWidth,\n                        scrollheight: ref.elem.scrollHeight,\n                        scrollwidth: ref.elem.scrollWidth,\n                        scrolltop: ref.elem.scrollTop,\n                        boundingclientrect: ref.elem.getBoundingClientRect(),\n                    };\n                }\n                updates.push(ru);\n                ref.updated = false;\n            }\n        }\n        return updates;\n    }\n\n    queueUpdate(quick: boolean = false, delay: number = 10) {\n        if (this.disposed) {\n            return;\n        }\n        this.needsUpdate = true;\n        let nowTs = Date.now();\n        if (delay > this.maxNormalUpdateIntervalMs) {\n            delay = this.maxNormalUpdateIntervalMs;\n        }\n        if (quick) {\n            if (this.queuedUpdate) {\n                if (this.queuedUpdate.quick || this.queuedUpdate.ts <= nowTs) {\n                    return;\n                }\n                clearTimeout(this.queuedUpdate.timeoutId);\n                this.queuedUpdate = null;\n            }\n            let timeoutId = setTimeout(() => {\n                this._sendRenderRequest(true);\n            }, 0);\n            this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs, quick: true };\n            return;\n        }\n        if (this.queuedUpdate) {\n            return;\n        }\n        let lastUpdateDiff = nowTs - this.lastUpdateTs;\n        let timeoutMs: number = null;\n        if (lastUpdateDiff >= this.maxNormalUpdateIntervalMs) {\n            // it has been a while since the last update, so use delay\n            timeoutMs = delay;\n        } else {\n            timeoutMs = this.maxNormalUpdateIntervalMs - lastUpdateDiff;\n        }\n        if (timeoutMs < delay) {\n            timeoutMs = delay;\n        }\n        let timeoutId = setTimeout(() => {\n            this._sendRenderRequest(false);\n        }, timeoutMs);\n        this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs + timeoutMs, quick: false };\n    }\n\n    async _sendRenderRequest(force: boolean) {\n        this.queuedUpdate = null;\n        if (this.disposed || !this.routeConfirmed) {\n            return;\n        }\n        if (this.hasPendingRequest) {\n            if (force) {\n                this.needsImmediateUpdate = true;\n            }\n            return;\n        }\n        if (!force && !this.needsUpdate) {\n            return;\n        }\n        const backendRoute = globalStore.get(this.backendRoute);\n        if (backendRoute == null) {\n            console.log(\"vdom-model\", \"no backend route\");\n            return;\n        }\n        this.hasPendingRequest = true;\n        this.needsImmediateUpdate = false;\n        try {\n            const feUpdate = this.createFeUpdate();\n            dlog(\"fe-update\", feUpdate);\n            const beUpdateGen = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: backendRoute });\n            let baseUpdate: VDomBackendUpdate = null;\n            for await (const beUpdate of beUpdateGen) {\n                if (baseUpdate === null) {\n                    baseUpdate = beUpdate;\n                } else {\n                    mergeBackendUpdates(baseUpdate, beUpdate);\n                }\n            }\n            if (baseUpdate !== null) {\n                restoreVDomElems(baseUpdate);\n                dlog(\"be-update\", baseUpdate);\n                this.handleBackendUpdate(baseUpdate);\n            }\n            dlog(\"update cycle done\");\n        } finally {\n            this.lastUpdateTs = Date.now();\n            this.hasPendingRequest = false;\n        }\n        if (this.needsImmediateUpdate) {\n            this.queueUpdate(true);\n        }\n    }\n\n    getAtomContainer(atomName: string): AtomContainer {\n        let container = this.atoms.get(atomName);\n        if (container == null) {\n            container = {\n                val: null,\n                beVal: null,\n                usedBy: new Set(),\n            };\n            this.atoms.set(atomName, container);\n        }\n        return container;\n    }\n\n    getOrCreateRefContainer(vdomRef: VDomRef): RefContainer {\n        let container = this.refs.get(vdomRef.refid);\n        if (container == null) {\n            container = {\n                refFn: (elem: HTMLElement) => {\n                    container.elem = elem;\n                    const hasElem = elem != null;\n                    if (vdomRef.hascurrent != hasElem) {\n                        container.updated = true;\n                        vdomRef.hascurrent = hasElem;\n                    }\n                },\n                vdomRef: vdomRef,\n                elem: null,\n                updated: false,\n            };\n            this.refs.set(vdomRef.refid, container);\n        }\n        return container;\n    }\n\n    tagUseAtoms(waveId: string, atomNames: Set<string>) {\n        for (let atomName of atomNames) {\n            let container = this.getAtomContainer(atomName);\n            container.usedBy.add(waveId);\n        }\n    }\n\n    tagUnuseAtoms(waveId: string, atomNames: Set<string>) {\n        for (let atomName of atomNames) {\n            let container = this.getAtomContainer(atomName);\n            container.usedBy.delete(waveId);\n        }\n    }\n\n    getVDomNodeVersionAtom(vdom: VDomElem) {\n        let atom = this.vdomNodeVersion.get(vdom);\n        if (atom == null) {\n            atom = jotai.atom(0);\n            this.vdomNodeVersion.set(vdom, atom);\n        }\n        return atom;\n    }\n\n    incVDomNodeVersion(vdom: VDomElem) {\n        if (vdom == null) {\n            return;\n        }\n        const atom = this.getVDomNodeVersionAtom(vdom);\n        globalStore.set(atom, globalStore.get(atom) + 1);\n    }\n\n    addErrorMessage(message: string) {\n        this.messages.push({\n            messagetype: \"error\",\n            message: message,\n        });\n    }\n\n    handleRenderUpdates(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) {\n        if (!update.renderupdates) {\n            return;\n        }\n        for (let renderUpdate of update.renderupdates) {\n            if (renderUpdate.updatetype == \"root\") {\n                globalStore.set(this.vdomRoot, renderUpdate.vdom);\n                continue;\n            }\n            if (renderUpdate.updatetype == \"append\") {\n                let parent = idMap.get(renderUpdate.waveid);\n                if (parent == null) {\n                    this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);\n                    continue;\n                }\n                if (parent.children == null) {\n                    parent.children = [];\n                }\n                parent.children.push(renderUpdate.vdom);\n                this.incVDomNodeVersion(parent);\n                continue;\n            }\n            if (renderUpdate.updatetype == \"replace\") {\n                let parent = idMap.get(renderUpdate.waveid);\n                if (parent == null) {\n                    this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);\n                    continue;\n                }\n                if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) {\n                    this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`);\n                    continue;\n                }\n                parent.children[renderUpdate.index] = renderUpdate.vdom;\n                this.incVDomNodeVersion(parent);\n                continue;\n            }\n            if (renderUpdate.updatetype == \"remove\") {\n                let parent = idMap.get(renderUpdate.waveid);\n                if (parent == null) {\n                    this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);\n                    continue;\n                }\n                if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) {\n                    this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`);\n                    continue;\n                }\n                parent.children.splice(renderUpdate.index, 1);\n                this.incVDomNodeVersion(parent);\n                continue;\n            }\n            if (renderUpdate.updatetype == \"insert\") {\n                let parent = idMap.get(renderUpdate.waveid);\n                if (parent == null) {\n                    this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);\n                    continue;\n                }\n                if (parent.children == null) {\n                    parent.children = [];\n                }\n                if (renderUpdate.index < 0 || parent.children.length < renderUpdate.index) {\n                    this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`);\n                    continue;\n                }\n                parent.children.splice(renderUpdate.index, 0, renderUpdate.vdom);\n                this.incVDomNodeVersion(parent);\n                continue;\n            }\n            this.addErrorMessage(`Unknown updatetype ${renderUpdate.updatetype}`);\n        }\n    }\n\n    setAtomValue(atomName: string, value: any, fromBe: boolean, idMap: Map<string, VDomElem>) {\n        dlog(\"setAtomValue\", atomName, value, fromBe);\n        let container = this.getAtomContainer(atomName);\n        container.val = value;\n        if (fromBe) {\n            container.beVal = value;\n        }\n        for (let id of container.usedBy) {\n            this.incVDomNodeVersion(idMap.get(id));\n        }\n    }\n\n    handleStateSync(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) {\n        if (update.statesync == null) {\n            return;\n        }\n        for (let sync of update.statesync) {\n            this.setAtomValue(sync.atom, sync.value, true, idMap);\n        }\n    }\n\n    getRefElem(refId: string): HTMLElement {\n        if (refId == this.rootRefId) {\n            return this.viewRef.current;\n        }\n        const ref = this.refs.get(refId);\n        return ref?.elem;\n    }\n\n    handleRefOperations(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) {\n        if (update.refoperations == null) {\n            return;\n        }\n        for (let refOp of update.refoperations) {\n            const elem = this.getRefElem(refOp.refid);\n            if (elem == null) {\n                this.addErrorMessage(`Could not find ref with id ${refOp.refid}`);\n                continue;\n            }\n            if (elem instanceof HTMLCanvasElement) {\n                applyCanvasOp(elem, refOp, this.refOutputStore);\n                continue;\n            }\n            if (refOp.op == \"focus\") {\n                if (elem == null) {\n                    this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`);\n                    continue;\n                }\n                try {\n                    elem.focus();\n                } catch (e) {\n                    this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: ${e.message}`);\n                }\n            } else {\n                this.addErrorMessage(`Unknown ref operation ${refOp.refid} ${refOp.op}`);\n            }\n        }\n    }\n\n    handleBackendUpdate(update: VDomBackendUpdate) {\n        if (update == null) {\n            return;\n        }\n        globalStore.set(this.contextActive, true);\n        const idMap = new Map<string, VDomElem>();\n        const vdomRoot = globalStore.get(this.vdomRoot);\n        if (update.opts != null) {\n            this.backendOpts = update.opts;\n        }\n        makeVDomIdMap(vdomRoot, idMap);\n        this.handleRenderUpdates(update, idMap);\n        this.handleStateSync(update, idMap);\n        this.handleRefOperations(update, idMap);\n        if (update.messages) {\n            for (let message of update.messages) {\n                console.log(\"vdom-message\", this.blockId, message.messagetype, message.message);\n                if (message.stacktrace) {\n                    console.log(\"vdom-message-stacktrace\", message.stacktrace);\n                }\n            }\n        }\n        globalStore.set(this.globalVersion, globalStore.get(this.globalVersion) + 1);\n        if (update.haswork) {\n            this.hasBackendWork = true;\n        }\n    }\n\n    renderDone(version: number) {\n        // called when the render is done\n        dlog(\"renderDone\", version);\n        if (this.hasRefUpdates() || this.hasBackendWork) {\n            this.hasBackendWork = false;\n            this.queueUpdate(true);\n        }\n    }\n\n    callVDomFunc(fnDecl: VDomFunc, e: React.SyntheticEvent, compId: string, propName: string) {\n        const vdomEvent: VDomEvent = {\n            waveid: compId,\n            eventtype: propName,\n        };\n        if (fnDecl.globalevent) {\n            vdomEvent.globaleventtype = fnDecl.globalevent;\n        }\n        annotateEvent(vdomEvent, propName, e);\n        this.batchedEvents.push(vdomEvent);\n        this.queueUpdate(true);\n    }\n\n    createFeUpdate(): VDomFrontendUpdate {\n        const blockORef = makeORef(\"block\", this.blockId);\n        const blockAtom = WOS.getWaveObjectAtom<Block>(blockORef);\n        const blockData = globalStore.get(blockAtom);\n        const isBlockFocused = globalStore.get(this.nodeModel.isFocused);\n        const renderContext: VDomRenderContext = {\n            blockid: this.blockId,\n            focused: isBlockFocused,\n            width: this.viewRef?.current?.offsetWidth ?? 0,\n            height: this.viewRef?.current?.offsetHeight ?? 0,\n            rootrefid: this.rootRefId,\n            background: false,\n        };\n        const feUpdate: VDomFrontendUpdate = {\n            type: \"frontendupdate\",\n            ts: Date.now(),\n            blockid: this.blockId,\n            rendercontext: renderContext,\n            dispose: this.shouldDispose,\n            resync: this.needsResync,\n            events: this.batchedEvents,\n            refupdates: this.getRefUpdates(),\n        };\n        this.needsResync = false;\n        this.batchedEvents = [];\n        if (this.shouldDispose) {\n            this.disposed = true;\n        }\n        return feUpdate;\n    }\n\n    getBackendRouteId(): string {\n        const fullRoute = globalStore.get(this.backendRoute);\n        if (fullRoute == null || !fullRoute.startsWith(\"proc:\")) {\n            return null;\n        }\n        return fullRoute?.split(\":\")[1];\n    }\n}\n"
  },
  {
    "path": "frontend/app/view/vdom/vdom-utils.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { VDomModel } from \"@/app/view/vdom/vdom-model\";\nimport type { CssNode, List, ListItem } from \"css-tree\";\nimport * as csstree from \"css-tree\";\n\nconst TextTag = \"#text\";\n\n// TODO support binding\nexport function getTextChildren(elem: VDomElem): string {\n    if (elem.tag == TextTag) {\n        return elem.text;\n    }\n    if (!elem.children) {\n        return null;\n    }\n    const textArr = elem.children.map((child) => {\n        return getTextChildren(child);\n    });\n    return textArr.join(\"\");\n}\n\nexport function convertVDomId(model: VDomModel, id: string): string {\n    return model.blockId + \"::\" + id;\n}\n\nexport function validateAndWrapCss(model: VDomModel, cssText: string, wrapperClassName: string) {\n    try {\n        const ast = csstree.parse(cssText);\n        csstree.walk(ast, {\n            enter(node: CssNode, item: ListItem<CssNode>, list: List<CssNode>) {\n                // Remove disallowed @rules\n                const blockedRules = [\"import\", \"font-face\", \"keyframes\", \"namespace\", \"supports\"];\n                if (node.type === \"Atrule\" && blockedRules.includes(node.name)) {\n                    list.remove(item);\n                }\n                // Remove :root selectors\n                if (\n                    node.type === \"Selector\" &&\n                    node.children.some((child) => child.type === \"PseudoClassSelector\" && child.name === \"root\")\n                ) {\n                    list.remove(item);\n                }\n\n                if (node.type === \"IdSelector\") {\n                    node.name = convertVDomId(model, node.name);\n                }\n\n                // Transform url(#id) references in filter and mask properties (svg)\n                if (node.type === \"Declaration\" && [\"filter\", \"mask\"].includes(node.property)) {\n                    if (node.value && node.value.type === \"Value\" && \"children\" in node.value) {\n                        const urlNode = node.value.children\n                            .toArray()\n                            .find(\n                                (child: CssNode): child is CssNode & { value: string } =>\n                                    child && child.type === \"Url\" && typeof (child as any).value === \"string\"\n                            );\n                        if (urlNode && urlNode.value && urlNode.value.startsWith(\"#\")) {\n                            urlNode.value = \"#\" + convertVDomId(model, urlNode.value.substring(1));\n                        }\n                    }\n                }\n                // transform url(vdom:///foo.jpg)\n                if (node.type === \"Url\" && node.value != null && node.value.startsWith(\"vdom://\")) {\n                    const newUrl = model.transformVDomUrl(node.value);\n                    if (newUrl == null) {\n                        list.remove(item);\n                    } else {\n                        node.value = newUrl;\n                    }\n                }\n            },\n        });\n        const sanitizedCss = csstree.generate(ast);\n        return `.${wrapperClassName} { ${sanitizedCss} }`;\n    } catch (error) {\n        // TODO better error handling\n        console.error(\"CSS processing error:\", error);\n        return null;\n    }\n}\n\nfunction cssTransformStyleValue(model: VDomModel, property: string, value: string): string {\n    try {\n        const ast = csstree.parse(value, { context: \"value\" });\n        csstree.walk(ast, {\n            enter(node: CssNode, item: ListItem<CssNode>, list: List<CssNode>) {\n                // Transform url(#id) in filter/mask properties\n                if (node.type === \"Url\" && (property === \"filter\" || property === \"mask\")) {\n                    if (node.value.startsWith(\"#\")) {\n                        node.value = `#${convertVDomId(model, node.value.substring(1))}`;\n                    }\n                }\n                // transform vdom:// urls\n                if (node.type === \"Url\" && node.value != null && node.value.startsWith(\"vdom://\")) {\n                    const newUrl = model.transformVDomUrl(node.value);\n                    if (newUrl == null) {\n                        list.remove(item);\n                    } else {\n                        node.value = newUrl;\n                    }\n                }\n            },\n        });\n\n        return csstree.generate(ast);\n    } catch (error) {\n        console.error(\"Error processing style value:\", error);\n        return value;\n    }\n}\n\nexport function validateAndWrapReactStyle(model: VDomModel, style: Record<string, any>): Record<string, any> {\n    const sanitizedStyle: Record<string, any> = {};\n    let updated = false;\n    for (const [property, value] of Object.entries(style)) {\n        if (value == null || value === \"\") {\n            continue;\n        }\n        if (typeof value !== \"string\") {\n            sanitizedStyle[property] = value; // For non-string values, just copy as-is\n            continue;\n        }\n        if (value.includes(\"vdom://\") || value.includes(\"url(#\")) {\n            updated = true;\n            sanitizedStyle[property] = cssTransformStyleValue(model, property, value);\n        } else {\n            sanitizedStyle[property] = value;\n        }\n    }\n    if (!updated) {\n        return style;\n    }\n    return sanitizedStyle;\n}\n\nexport function restoreVDomElems(backendUpdate: VDomBackendUpdate) {\n    if (!backendUpdate.transferelems || !backendUpdate.renderupdates) {\n        return;\n    }\n\n    // Step 1: Map of waveid to VDomElem, skipping any without a waveid\n    const elemMap = new Map<string, VDomElem>();\n    backendUpdate.transferelems.forEach((transferElem) => {\n        if (!transferElem.waveid) {\n            return;\n        }\n        elemMap.set(transferElem.waveid, {\n            waveid: transferElem.waveid,\n            tag: transferElem.tag,\n            props: transferElem.props,\n            children: [], // Will populate children later\n            text: transferElem.text,\n        });\n    });\n\n    // Step 2: Build VDomElem trees by linking children\n    backendUpdate.transferelems.forEach((transferElem) => {\n        const parent = elemMap.get(transferElem.waveid);\n        if (!parent || !transferElem.children || transferElem.children.length === 0) {\n            return;\n        }\n        parent.children = transferElem.children.map((childId) => elemMap.get(childId)).filter((child) => child != null); // Explicit null check\n    });\n\n    // Step 3: Update renderupdates with rebuilt VDomElem trees\n    backendUpdate.renderupdates.forEach((update) => {\n        if (update.vdomwaveid) {\n            update.vdom = elemMap.get(update.vdomwaveid);\n        }\n    });\n}\n\nexport function mergeBackendUpdates(baseUpdate: VDomBackendUpdate, nextUpdate: VDomBackendUpdate) {\n    // Verify the updates are from the same block/sequence\n    if (baseUpdate.blockid !== nextUpdate.blockid || baseUpdate.ts !== nextUpdate.ts) {\n        console.error(\"Attempted to merge updates from different blocks or timestamps\");\n        return;\n    }\n\n    // Merge TransferElems\n    if (nextUpdate.transferelems?.length > 0) {\n        if (!baseUpdate.transferelems) {\n            baseUpdate.transferelems = [];\n        }\n        baseUpdate.transferelems.push(...nextUpdate.transferelems);\n    }\n\n    // Merge StateSync\n    if (nextUpdate.statesync?.length > 0) {\n        if (!baseUpdate.statesync) {\n            baseUpdate.statesync = [];\n        }\n        baseUpdate.statesync.push(...nextUpdate.statesync);\n    }\n}\n\nexport function applyCanvasOp(canvas: HTMLCanvasElement, canvasOp: VDomRefOperation, refStore: Map<string, any>) {\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) {\n        console.error(\"Canvas 2D context not available.\");\n        return;\n    }\n\n    let { op, params, outputref } = canvasOp;\n    if (params == null) {\n        params = [];\n    }\n    if (op == null || op == \"\") {\n        return;\n    }\n    // Resolve any reference parameters in params\n    const resolvedParams: any[] = [];\n    params.forEach((param) => {\n        if (typeof param === \"string\" && param.startsWith(\"#ref:\")) {\n            const refId = param.slice(5); // Remove \"#ref:\" prefix\n            resolvedParams.push(refStore.get(refId));\n        } else if (typeof param === \"string\" && param.startsWith(\"#spreadRef:\")) {\n            const refId = param.slice(11); // Remove \"#spreadRef:\" prefix\n            const arrayRef = refStore.get(refId);\n            if (Array.isArray(arrayRef)) {\n                resolvedParams.push(...arrayRef); // Spread array elements\n            } else {\n                console.error(`Reference ${refId} is not an array and cannot be spread.`);\n            }\n        } else {\n            resolvedParams.push(param);\n        }\n    });\n\n    // Apply the operation on the canvas context\n    if (op === \"dropRef\" && params.length > 0 && typeof params[0] === \"string\") {\n        refStore.delete(params[0]);\n    } else if (op === \"addRef\" && outputref) {\n        refStore.set(outputref, resolvedParams[0]);\n    } else if (typeof ctx[op as keyof CanvasRenderingContext2D] === \"function\") {\n        (ctx[op as keyof CanvasRenderingContext2D] as (...args: unknown[]) => unknown).apply(ctx, resolvedParams);\n    } else if (op in ctx) {\n        (ctx as any)[op] = resolvedParams[0];\n    } else {\n        console.error(`Unsupported canvas operation: ${op}`);\n    }\n}\n"
  },
  {
    "path": "frontend/app/view/vdom/vdom.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Markdown } from \"@/app/element/markdown\";\nimport { VDomModel } from \"@/app/view/vdom/vdom-model\";\nimport { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from \"@/util/keyutil\";\nimport clsx from \"clsx\";\nimport debug from \"debug\";\nimport * as jotai from \"jotai\";\nimport * as React from \"react\";\n\nimport {\n    convertVDomId,\n    getTextChildren,\n    validateAndWrapCss,\n    validateAndWrapReactStyle,\n} from \"@/app/view/vdom/vdom-utils\";\n\nconst TextTag = \"#text\";\nconst FragmentTag = \"#fragment\";\nconst WaveTextTag = \"wave:text\";\nconst WaveNullTag = \"wave:null\";\nconst StyleTagName = \"style\";\nconst WaveStyleTagName = \"wave:style\";\n\nconst VDomObjType_Ref = \"ref\";\nconst VDomObjType_Binding = \"binding\";\nconst VDomObjType_Func = \"func\";\n\nconst dlog = debug(\"wave:vdom\");\n\ntype VDomReactTagType = (props: { elem: VDomElem; model: VDomModel }) => React.ReactElement;\n\nconst WaveTagMap: Record<string, VDomReactTagType> = {\n    \"wave:markdown\": WaveMarkdown,\n};\n\nconst AllowedSimpleTags: { [tagName: string]: boolean } = {\n    div: true,\n    b: true,\n    i: true,\n    p: true,\n    s: true,\n    span: true,\n    a: true,\n    img: true,\n    h1: true,\n    h2: true,\n    h3: true,\n    h4: true,\n    h5: true,\n    h6: true,\n    ul: true,\n    ol: true,\n    li: true,\n    input: true,\n    button: true,\n    textarea: true,\n    select: true,\n    option: true,\n    form: true,\n    label: true,\n    table: true,\n    thead: true,\n    tbody: true,\n    tr: true,\n    th: true,\n    td: true,\n    hr: true,\n    br: true,\n    pre: true,\n    code: true,\n    canvas: true,\n};\n\nconst AllowedSvgTags = {\n    // SVG tags\n    svg: true,\n    circle: true,\n    ellipse: true,\n    line: true,\n    path: true,\n    polygon: true,\n    polyline: true,\n    rect: true,\n    g: true,\n    text: true,\n    tspan: true,\n    textPath: true,\n    use: true,\n    defs: true,\n    linearGradient: true,\n    radialGradient: true,\n    stop: true,\n    clipPath: true,\n    mask: true,\n    pattern: true,\n    image: true,\n    marker: true,\n    symbol: true,\n    filter: true,\n    feBlend: true,\n    feColorMatrix: true,\n    feComponentTransfer: true,\n    feComposite: true,\n    feConvolveMatrix: true,\n    feDiffuseLighting: true,\n    feDisplacementMap: true,\n    feFlood: true,\n    feGaussianBlur: true,\n    feImage: true,\n    feMerge: true,\n    feMorphology: true,\n    feOffset: true,\n    feSpecularLighting: true,\n    feTile: true,\n    feTurbulence: true,\n};\n\nconst IdAttributes = {\n    id: true,\n    for: true,\n    \"aria-labelledby\": true,\n    \"aria-describedby\": true,\n    \"aria-controls\": true,\n    \"aria-owns\": true,\n    form: true,\n    headers: true,\n    usemap: true,\n    list: true,\n};\n\nconst SvgUrlIdAttributes = {\n    \"clip-path\": true,\n    mask: true,\n    filter: true,\n    fill: true,\n    stroke: true,\n    \"marker-start\": true,\n    \"marker-mid\": true,\n    \"marker-end\": true,\n    \"text-decoration\": true,\n};\n\nfunction convertVDomFunc(model: VDomModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void {\n    return (e: any) => {\n        if ((propName == \"onKeyDown\" || propName == \"onKeyDownCapture\") && fnDecl[\"#keys\"]) {\n            dlog(\"key event\", fnDecl, e);\n            let waveEvent = adaptFromReactOrNativeKeyEvent(e);\n            for (let keyDesc of fnDecl[\"#keys\"] || []) {\n                if (checkKeyPressed(waveEvent, keyDesc)) {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    model.callVDomFunc(fnDecl, e, compId, propName);\n                    return;\n                }\n            }\n            return;\n        }\n        if (fnDecl.preventdefault) {\n            e.preventDefault();\n        }\n        if (fnDecl.stoppropagation) {\n            e.stopPropagation();\n        }\n        model.callVDomFunc(fnDecl, e, compId, propName);\n    };\n}\n\nfunction convertElemToTag(elem: VDomElem, model: VDomModel): React.ReactNode {\n    if (elem == null) {\n        return null;\n    }\n    if (elem.tag == TextTag) {\n        return elem.text;\n    }\n    return React.createElement(VDomTag, { key: elem.waveid, elem, model });\n}\n\nfunction isObject(v: any): boolean {\n    return v != null && !Array.isArray(v) && typeof v === \"object\";\n}\n\nfunction isArray(v: any): boolean {\n    return Array.isArray(v);\n}\n\nfunction resolveBinding(binding: VDomBinding, model: VDomModel): [any, string[]] {\n    const bindName = binding.bind;\n    if (bindName == null || bindName == \"\") {\n        return [null, []];\n    }\n    // for now we only recognize $.[atomname] bindings\n    if (!bindName.startsWith(\"$.\")) {\n        return [null, []];\n    }\n    const atomName = bindName.substring(2);\n    if (atomName == \"\") {\n        return [null, []];\n    }\n    const atom = model.getAtomContainer(atomName);\n    if (atom == null) {\n        return [null, []];\n    }\n    return [atom.val, [atomName]];\n}\n\ntype GenericPropsType = { [key: string]: any };\n\n// returns props, and a set of atom keys used in the props\nfunction convertProps(elem: VDomElem, model: VDomModel): [GenericPropsType, Set<string>] {\n    let props: GenericPropsType = {};\n    let atomKeys = new Set<string>();\n    if (elem.props == null) {\n        return [props, atomKeys];\n    }\n    for (let key in elem.props) {\n        let val = elem.props[key];\n        if (val == null) {\n            continue;\n        }\n        if (key == \"ref\") {\n            if (val == null) {\n                continue;\n            }\n            if (isObject(val) && val.type == VDomObjType_Ref) {\n                const valRef = val as VDomRef;\n                const refContainer = model.getOrCreateRefContainer(valRef);\n                props[key] = refContainer.refFn;\n            }\n            continue;\n        }\n        if (isObject(val) && val.type == VDomObjType_Func) {\n            const valFunc = val as VDomFunc;\n            props[key] = convertVDomFunc(model, valFunc, elem.waveid, key);\n            continue;\n        }\n        if (isObject(val) && val.type == VDomObjType_Binding) {\n            const [propVal, atomDeps] = resolveBinding(val as VDomBinding, model);\n            props[key] = propVal;\n            for (let atomDep of atomDeps) {\n                atomKeys.add(atomDep);\n            }\n            continue;\n        }\n        if (key == \"style\" && isObject(val)) {\n            // assuming the entire style prop wasn't bound, look through the individual keys and bind them\n            for (let styleKey in val) {\n                let styleVal = val[styleKey];\n                if (isObject(styleVal) && styleVal.type == VDomObjType_Binding) {\n                    const [stylePropVal, styleAtomDeps] = resolveBinding(styleVal as VDomBinding, model);\n                    val[styleKey] = stylePropVal;\n                    for (let styleAtomDep of styleAtomDeps) {\n                        atomKeys.add(styleAtomDep);\n                    }\n                }\n            }\n            val = validateAndWrapReactStyle(model, val);\n            props[key] = val;\n            continue;\n        }\n        if (IdAttributes[key]) {\n            props[key] = convertVDomId(model, val);\n            continue;\n        }\n        if (AllowedSvgTags[elem.tag]) {\n            if ((elem.tag == \"use\" && key == \"href\") || (elem.tag == \"textPath\" && key == \"href\")) {\n                if (val == null || !val.startsWith(\"#\")) {\n                    continue;\n                }\n                props[key] = convertVDomId(model, \"#\" + val.substring(1));\n                continue;\n            }\n            if (SvgUrlIdAttributes[key]) {\n                if (val == null || !val.startsWith(\"url(#\") || !val.endsWith(\")\")) {\n                    continue;\n                }\n                props[key] = \"url(#\" + convertVDomId(model, val.substring(4, val.length - 1)) + \")\";\n                continue;\n            }\n        }\n        if (key == \"src\" && val != null && val.startsWith(\"vdom://\")) {\n            // transform vdom:// urls\n            const newUrl = model.transformVDomUrl(val);\n            if (newUrl == null) {\n                continue;\n            }\n            props[key] = newUrl;\n            continue;\n        }\n        props[key] = val;\n    }\n    return [props, atomKeys];\n}\n\nfunction convertChildren(elem: VDomElem, model: VDomModel): React.ReactNode[] {\n    if (elem.children == null || elem.children.length == 0) {\n        return null;\n    }\n    let childrenComps: React.ReactNode[] = [];\n    for (let child of elem.children) {\n        if (child == null) {\n            continue;\n        }\n        childrenComps.push(convertElemToTag(child, model));\n    }\n    if (childrenComps.length == 0) {\n        return null;\n    }\n    return childrenComps;\n}\n\nfunction stringSetsEqual(set1: Set<string>, set2: Set<string>): boolean {\n    if (set1.size != set2.size) {\n        return false;\n    }\n    for (let elem of set1) {\n        if (!set2.has(elem)) {\n            return false;\n        }\n    }\n    return true;\n}\n\nfunction useVDom(model: VDomModel, elem: VDomElem): GenericPropsType {\n    const version = jotai.useAtomValue(model.getVDomNodeVersionAtom(elem));\n    const [oldAtomKeys, setOldAtomKeys] = React.useState<Set<string>>(new Set());\n    let [props, atomKeys] = convertProps(elem, model);\n    React.useEffect(() => {\n        if (stringSetsEqual(atomKeys, oldAtomKeys)) {\n            return;\n        }\n        model.tagUnuseAtoms(elem.waveid, oldAtomKeys);\n        model.tagUseAtoms(elem.waveid, atomKeys);\n        setOldAtomKeys(atomKeys);\n    }, [atomKeys]);\n    React.useEffect(() => {\n        return () => {\n            model.tagUnuseAtoms(elem.waveid, oldAtomKeys);\n        };\n    }, []);\n    return props;\n}\n\nfunction WaveMarkdown({ elem, model }: { elem: VDomElem; model: VDomModel }) {\n    const props = useVDom(model, elem);\n    return (\n        <Markdown\n            text={props?.text}\n            style={props?.style}\n            className={props?.className}\n            scrollable={props?.scrollable}\n            rehype={props?.rehype}\n        />\n    );\n}\n\nfunction StyleTag({ elem, model }: { elem: VDomElem; model: VDomModel }) {\n    const styleText = getTextChildren(elem);\n    if (styleText == null) {\n        return null;\n    }\n    const wrapperClassName = \"vdom-\" + model.blockId;\n    // TODO handle errors\n    const sanitizedCss = validateAndWrapCss(model, styleText, wrapperClassName);\n    if (sanitizedCss == null) {\n        return null;\n    }\n    return <style>{sanitizedCss}</style>;\n}\n\nfunction WaveStyle({ src, model, onMount }: { src: string; model: VDomModel; onMount?: () => void }) {\n    const [styleContent, setStyleContent] = React.useState<string | null>(null);\n    React.useEffect(() => {\n        async function fetchAndSanitizeCss() {\n            try {\n                const response = await fetch(src);\n                if (!response.ok) {\n                    console.error(`Failed to load CSS from ${src}`);\n                    return;\n                }\n                const cssText = await response.text();\n                const wrapperClassName = \"vdom-\" + model.blockId;\n                const sanitizedCss = validateAndWrapCss(model, cssText, wrapperClassName);\n                if (sanitizedCss) {\n                    setStyleContent(sanitizedCss);\n                } else {\n                    onMount?.();\n                    console.error(\"Failed to sanitize CSS\");\n                }\n            } catch (error) {\n                console.error(\"Error fetching CSS:\", error);\n                onMount?.();\n            }\n        }\n        fetchAndSanitizeCss();\n    }, [src, model]);\n    // Trigger onMount after styleContent has been set and mounted\n    React.useEffect(() => {\n        if (styleContent) {\n            onMount?.();\n        }\n    }, [styleContent, onMount]);\n    if (!styleContent) {\n        return null;\n    }\n    return <style>{styleContent}</style>;\n}\n\nfunction VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) {\n    const props = useVDom(model, elem);\n    if (elem.tag == WaveNullTag) {\n        return null;\n    }\n    if (elem.tag == WaveTextTag) {\n        return props.text;\n    }\n    const waveTag = WaveTagMap[elem.tag];\n    if (waveTag) {\n        return waveTag({ elem, model });\n    }\n    if (elem.tag == StyleTagName) {\n        return <StyleTag elem={elem} model={model} />;\n    }\n    if (elem.tag == WaveStyleTagName) {\n        return <WaveStyle src={props.src} model={model} />;\n    }\n    if (!AllowedSimpleTags[elem.tag] && !AllowedSvgTags[elem.tag]) {\n        return <div>{\"Invalid Tag <\" + elem.tag + \">\"}</div>;\n    }\n    let childrenComps = convertChildren(elem, model);\n    if (elem.tag == FragmentTag) {\n        return childrenComps;\n    }\n    props.key = \"e-\" + elem.waveid;\n    return React.createElement(elem.tag, props, childrenComps);\n}\n\nfunction vdomText(text: string): VDomElem {\n    return {\n        tag: \"#text\",\n        text: text,\n    };\n}\n\nconst testVDom: VDomElem = {\n    waveid: \"testid1\",\n    tag: \"div\",\n    children: [\n        {\n            waveid: \"testh1\",\n            tag: \"h1\",\n            children: [vdomText(\"Hello World\")],\n        },\n        {\n            waveid: \"testp\",\n            tag: \"p\",\n            children: [vdomText(\"This is a paragraph (from VDOM)\")],\n        },\n    ],\n};\n\nfunction VDomRoot({ model }: { model: VDomModel }) {\n    let version = jotai.useAtomValue(model.globalVersion);\n    let rootNode = jotai.useAtomValue(model.vdomRoot);\n    React.useEffect(() => {\n        model.renderDone(version);\n    }, [version]);\n    if (model.viewRef.current == null || rootNode == null) {\n        return null;\n    }\n    dlog(\"render\", version, rootNode);\n    let rtn = convertElemToTag(rootNode, model);\n    return <div className=\"vdom\">{rtn}</div>;\n}\n\ntype VDomViewProps = {\n    model: VDomModel;\n    blockId: string;\n};\n\nfunction VDomInnerView({ blockId, model }: VDomViewProps) {\n    let [styleMounted, setStyleMounted] = React.useState(!model.backendOpts?.globalstyles);\n    const handleStylesMounted = () => {\n        setStyleMounted(true);\n    };\n    return (\n        <>\n            {model.backendOpts?.globalstyles ? (\n                <WaveStyle src={model.makeVDomUrl(\"/wave/global.css\")} model={model} onMount={handleStylesMounted} />\n            ) : null}\n            {styleMounted ? <VDomRoot model={model} /> : null}\n        </>\n    );\n}\n\nfunction VDomView({ blockId, model }: VDomViewProps) {\n    let viewRef = React.useRef(null);\n    let contextActive = jotai.useAtomValue(model.contextActive);\n    model.viewRef = viewRef;\n    const vdomClass = \"vdom-\" + blockId;\n    return (\n        <div className={clsx(\"overflow-auto w-full min-h-full\", vdomClass)} ref={viewRef}>\n            {contextActive ? <VDomInnerView blockId={blockId} model={model} /> : null}\n        </div>\n    );\n}\n\nexport { VDomView };\n"
  },
  {
    "path": "frontend/app/view/waveai/waveai.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.waveai {\n    display: flex;\n    flex-direction: column;\n    height: 100%;\n    width: 100%;\n\n    .waveai-chat {\n        flex: 1 1 auto;\n        overflow: hidden;\n        .chat-window-container {\n            overflow-y: auto;\n            margin-bottom: 0;\n            height: 100%;\n\n            .chat-window {\n                flex-flow: column nowrap;\n                display: flex;\n                gap: 8px;\n\n                // This is the filler that will push the chat messages to the bottom until the chat window is full\n                .filler {\n                    flex: 1 1 auto;\n                }\n\n                .chat-msg-container {\n                    display: flex;\n                    gap: 8px;\n                    .chat-msg {\n                        margin: 10px 0;\n                        display: flex;\n                        align-items: flex-start;\n                        border-radius: 8px;\n\n                        &.chat-msg-header {\n                            display: flex;\n                            flex-direction: column;\n                            justify-content: flex-start;\n\n                            .icon-box {\n                                padding-top: 0;\n                                border-radius: 4px;\n                                background-color: rgb(from var(--highlight-bg-color) r g b / 0.05);\n                                display: flex;\n                                padding: 6px;\n                            }\n                        }\n\n                        &.chat-msg-assistant {\n                            color: var(--main-text-color);\n                            background-color: rgb(from var(--highlight-bg-color) r g b / 0.1);\n                            margin-right: auto;\n                            padding: 10px;\n                            max-width: 85%;\n\n                            .markdown {\n                                width: 100%;\n\n                                pre {\n                                    white-space: pre-wrap;\n                                    word-break: break-word;\n                                    max-width: 100%;\n                                    overflow-x: auto;\n                                    margin-left: 0;\n                                }\n                            }\n                        }\n                        &.chat-msg-user {\n                            margin-left: auto;\n                            padding: 10px;\n                            max-width: 85%;\n                            background-color: rgb(from var(--accent-color) r g b / 0.15);\n                        }\n\n                        &.chat-msg-error {\n                            color: var(--main-text-color);\n                            background-color: rgb(from var(--error-color) r g b / 0.25);\n                            margin-right: auto;\n                            padding: 10px;\n                            max-width: 85%;\n\n                            .markdown {\n                                width: 100%;\n\n                                pre {\n                                    white-space: pre-wrap;\n                                    word-break: break-word;\n                                    max-width: 100%;\n                                    overflow-x: auto;\n                                    margin-left: 0;\n                                }\n                            }\n                        }\n\n                        &.typing-indicator {\n                            margin-top: 4px;\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    .waveai-controls {\n        flex: 0 0 auto;\n        display: flex;\n        flex-direction: row;\n        align-items: center;\n        justify-content: flex-start;\n        gap: 10px;\n        padding: 8px 6px;\n\n        .waveai-input-wrapper {\n            padding: 8px 12px;\n            flex: 1 1 auto;\n            display: flex;\n            flex-direction: column;\n            justify-content: center;\n            align-items: flex-start;\n            border-radius: 6px;\n            border: 1px solid rgb(from var(--highlight-bg-color) r g b / 0.42);\n\n            .waveai-input {\n                color: var(--main-text-color);\n                background-color: inherit;\n                resize: none;\n                width: 100%;\n                border: transparent;\n                outline: none;\n                overflow: auto;\n                overflow-wrap: anywhere;\n                height: 21px;\n            }\n        }\n\n        .waveai-submit-button {\n            border-radius: 100%;\n            width: 27px;\n            aspect-ratio: 1 /1;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            flex: 0 0 auto;\n            padding: 0;\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/app/view/waveai/waveai.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { BlockNodeModel } from \"@/app/block/blocktypes\";\nimport { Button } from \"@/app/element/button\";\nimport { Markdown } from \"@/app/element/markdown\";\nimport { TypingIndicator } from \"@/app/element/typingindicator\";\nimport { ClientModel } from \"@/app/store/client-model\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport type { TabModel } from \"@/app/store/tab-model\";\nimport { RpcResponseHelper, WshClient } from \"@/app/store/wshclient\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { makeFeBlockRouteId } from \"@/app/store/wshrouter\";\nimport { DefaultRouter, TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { WorkspaceLayoutModel } from \"@/app/workspace/workspace-layout-model\";\nimport { atoms, createBlock, fetchWaveFile, getApi, WOS } from \"@/store/global\";\nimport { BlockService, ObjectService } from \"@/store/services\";\nimport { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from \"@/util/keyutil\";\nimport { fireAndForget, isBlank, makeIconClass, mergeMeta } from \"@/util/util\";\nimport { atom, Atom, PrimitiveAtom, useAtomValue, WritableAtom } from \"jotai\";\nimport { splitAtom } from \"jotai/utils\";\nimport type { OverlayScrollbars } from \"overlayscrollbars\";\nimport { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from \"overlayscrollbars-react\";\nimport { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from \"react\";\nimport { debounce, throttle } from \"throttle-debounce\";\nimport \"./waveai.scss\";\n\ninterface ChatMessageType {\n    id: string;\n    user: string;\n    text: string;\n    isUpdating?: boolean;\n}\n\nconst outline = \"2px solid var(--accent-color)\";\nconst slidingWindowSize = 30;\n\ninterface ChatItemProps {\n    chatItemAtom: Atom<ChatMessageType>;\n    model: WaveAiModel;\n}\n\nfunction promptToMsg(prompt: WaveAIPromptMessageType): ChatMessageType {\n    return {\n        id: crypto.randomUUID(),\n        user: prompt.role,\n        text: prompt.content,\n    };\n}\n\nclass AiWshClient extends WshClient {\n    blockId: string;\n    model: WaveAiModel;\n\n    constructor(blockId: string, model: WaveAiModel) {\n        super(makeFeBlockRouteId(blockId));\n        this.blockId = blockId;\n        this.model = model;\n    }\n\n    handle_aisendmessage(rh: RpcResponseHelper, data: AiMessageData) {\n        if (isBlank(data.message)) {\n            return;\n        }\n        this.model.sendMessage(data.message);\n    }\n}\n\nexport class WaveAiModel implements ViewModel {\n    viewType: string;\n    blockId: string;\n    nodeModel: BlockNodeModel;\n    tabModel: TabModel;\n    blockAtom: Atom<Block>;\n    presetKey: Atom<string>;\n    presetMap: Atom<{ [k: string]: MetaType }>;\n    mergedPresets: Atom<MetaType>;\n    aiOpts: Atom<WaveAIOptsType>;\n    viewIcon?: Atom<string | IconButtonDecl>;\n    viewName?: Atom<string>;\n    viewText?: Atom<string | HeaderElem[]>;\n    preIconButton?: Atom<IconButtonDecl>;\n    endIconButtons?: Atom<IconButtonDecl[]>;\n    messagesAtom: PrimitiveAtom<Array<ChatMessageType>>;\n    messagesSplitAtom: SplitAtom<Array<ChatMessageType>>;\n    latestMessageAtom: Atom<ChatMessageType>;\n    addMessageAtom: WritableAtom<unknown, [message: ChatMessageType], void>;\n    updateLastMessageAtom: WritableAtom<unknown, [text: string, isUpdating: boolean], void>;\n    removeLastMessageAtom: WritableAtom<unknown, [], void>;\n    simulateAssistantResponseAtom: WritableAtom<unknown, [userMessage: ChatMessageType], Promise<void>>;\n    textAreaRef: React.RefObject<HTMLTextAreaElement>;\n    locked: PrimitiveAtom<boolean>;\n    cancel: boolean;\n    aiWshClient: AiWshClient;\n\n    constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) {\n        this.blockId = blockId;\n        this.nodeModel = nodeModel;\n        this.tabModel = tabModel;\n        this.aiWshClient = new AiWshClient(blockId, this);\n        DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.aiWshClient);\n        this.locked = atom(false);\n        this.cancel = false;\n        this.viewType = \"waveai\";\n        this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);\n        this.viewIcon = atom(\"sparkles\");\n        this.viewName = atom(\"Wave AI\");\n        this.messagesAtom = atom([]);\n        this.messagesSplitAtom = splitAtom(this.messagesAtom);\n        this.latestMessageAtom = atom((get) => get(this.messagesAtom).slice(-1)[0]);\n        this.presetKey = atom((get) => {\n            const metaPresetKey = get(this.blockAtom).meta[\"ai:preset\"];\n            const globalPresetKey = get(atoms.settingsAtom)[\"ai:preset\"];\n            return metaPresetKey ?? globalPresetKey;\n        });\n        this.presetMap = atom((get) => {\n            const fullConfig = get(atoms.fullConfigAtom);\n            const presets = fullConfig.presets;\n            const settings = fullConfig.settings;\n            return Object.fromEntries(\n                Object.entries(presets)\n                    .filter(([k]) => k.startsWith(\"ai@\"))\n                    .map(([k, v]) => {\n                        const aiPresetKeys = Object.keys(v).filter((k) => k.startsWith(\"ai:\"));\n                        const newV = { ...v };\n                        newV[\"display:name\"] =\n                            aiPresetKeys.length == 1 && aiPresetKeys.includes(\"ai:*\")\n                                ? `${newV[\"display:name\"] ?? \"Default\"} (${settings[\"ai:model\"]})`\n                                : newV[\"display:name\"];\n                        return [k, newV];\n                    })\n            );\n        });\n\n        this.addMessageAtom = atom(null, (get, set, message: ChatMessageType) => {\n            const messages = get(this.messagesAtom);\n            set(this.messagesAtom, [...messages, message]);\n        });\n\n        this.updateLastMessageAtom = atom(null, (get, set, text: string, isUpdating: boolean) => {\n            const messages = get(this.messagesAtom);\n            const lastMessage = messages[messages.length - 1];\n            if (lastMessage.user == \"assistant\") {\n                const updatedMessage = { ...lastMessage, text: lastMessage.text + text, isUpdating };\n                set(this.messagesAtom, [...messages.slice(0, -1), updatedMessage]);\n            }\n        });\n        this.removeLastMessageAtom = atom(null, (get, set) => {\n            const messages = get(this.messagesAtom);\n            messages.pop();\n            set(this.messagesAtom, [...messages]);\n        });\n        this.simulateAssistantResponseAtom = atom(null, async (_, set, userMessage: ChatMessageType) => {\n            // unused at the moment. can replace the temp() function in the future\n            const typingMessage: ChatMessageType = {\n                id: crypto.randomUUID(),\n                user: \"assistant\",\n                text: \"\",\n            };\n\n            // Add a typing indicator\n            set(this.addMessageAtom, typingMessage);\n            const parts = userMessage.text.split(\" \");\n            let currentPart = 0;\n            while (currentPart < parts.length) {\n                const part = parts[currentPart] + \" \";\n                set(this.updateLastMessageAtom, part, true);\n                currentPart++;\n            }\n            set(this.updateLastMessageAtom, \"\", false);\n        });\n\n        this.mergedPresets = atom((get) => {\n            const meta = get(this.blockAtom).meta;\n            let settings = get(atoms.settingsAtom);\n            let presetKey = get(this.presetKey);\n            let presets = get(atoms.fullConfigAtom).presets;\n            let selectedPresets = presets?.[presetKey] ?? {};\n\n            let mergedPresets: MetaType = {};\n            mergedPresets = mergeMeta(settings, selectedPresets, \"ai\");\n            mergedPresets = mergeMeta(mergedPresets, meta, \"ai\");\n\n            return mergedPresets;\n        });\n\n        this.aiOpts = atom((get) => {\n            const mergedPresets = get(this.mergedPresets);\n\n            const opts: WaveAIOptsType = {\n                model: mergedPresets[\"ai:model\"] ?? null,\n                apitype: mergedPresets[\"ai:apitype\"] ?? null,\n                orgid: mergedPresets[\"ai:orgid\"] ?? null,\n                apitoken: mergedPresets[\"ai:apitoken\"] ?? null,\n                apiversion: mergedPresets[\"ai:apiversion\"] ?? null,\n                maxtokens: mergedPresets[\"ai:maxtokens\"] ?? null,\n                timeoutms: mergedPresets[\"ai:timeoutms\"] ?? 60000,\n                baseurl: mergedPresets[\"ai:baseurl\"] ?? null,\n                proxyurl: mergedPresets[\"ai:proxyurl\"] ?? null,\n            };\n            return opts;\n        });\n\n        this.viewText = atom((get) => {\n            const viewTextChildren: HeaderElem[] = [];\n            const aiOpts = get(this.aiOpts);\n            const presets = get(this.presetMap);\n            const presetKey = get(this.presetKey);\n            const presetName = presets[presetKey]?.[\"display:name\"] ?? \"\";\n            const isCloud = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl);\n\n            // Handle known API providers\n            switch (aiOpts?.apitype) {\n                case \"anthropic\":\n                    viewTextChildren.push({\n                        elemtype: \"iconbutton\",\n                        icon: \"globe\",\n                        title: `Using Remote Anthropic API (${aiOpts.model})`,\n                        noAction: true,\n                    });\n                    break;\n                case \"perplexity\":\n                    viewTextChildren.push({\n                        elemtype: \"iconbutton\",\n                        icon: \"globe\",\n                        title: `Using Remote Perplexity API (${aiOpts.model})`,\n                        noAction: true,\n                    });\n                    break;\n                default:\n                    if (isCloud) {\n                        viewTextChildren.push({\n                            elemtype: \"iconbutton\",\n                            icon: \"cloud\",\n                            title: \"Using Wave's AI Proxy (gpt-5-mini)\",\n                            noAction: true,\n                        });\n                    } else {\n                        const baseUrl = aiOpts.baseurl ?? \"OpenAI Default Endpoint\";\n                        const modelName = aiOpts.model;\n                        if (baseUrl.startsWith(\"http://localhost\") || baseUrl.startsWith(\"http://127.0.0.1\")) {\n                            viewTextChildren.push({\n                                elemtype: \"iconbutton\",\n                                icon: \"location-dot\",\n                                title: `Using Local Model @ ${baseUrl} (${modelName})`,\n                                noAction: true,\n                            });\n                        } else {\n                            viewTextChildren.push({\n                                elemtype: \"iconbutton\",\n                                icon: \"globe\",\n                                title: `Using Remote Model @ ${baseUrl} (${modelName})`,\n                                noAction: true,\n                            });\n                        }\n                    }\n            }\n\n            const dropdownItems = Object.entries(presets)\n                .sort((a, b) => ((a[1][\"display:order\"] ?? 0) > (b[1][\"display:order\"] ?? 0) ? 1 : -1))\n                .map(\n                    (preset) =>\n                        ({\n                            label: preset[1][\"display:name\"],\n                            onClick: () =>\n                                fireAndForget(() =>\n                                    ObjectService.UpdateObjectMeta(WOS.makeORef(\"block\", this.blockId), {\n                                        \"ai:preset\": preset[0],\n                                    })\n                                ),\n                        }) as MenuItem\n                );\n            dropdownItems.push({\n                label: \"Add AI preset...\",\n                onClick: () => {\n                    fireAndForget(async () => {\n                        const path = `${getApi().getConfigDir()}/presets/ai.json`;\n                        const blockDef: BlockDef = {\n                            meta: {\n                                view: \"preview\",\n                                file: path,\n                            },\n                        };\n                        await createBlock(blockDef, false, true);\n                    });\n                },\n            });\n            viewTextChildren.push({\n                elemtype: \"menubutton\",\n                text: presetName,\n                title: \"Select AI Configuration\",\n                items: dropdownItems,\n            });\n            return viewTextChildren;\n        });\n        this.endIconButtons = atom((_) => {\n            let clearButton: IconButtonDecl = {\n                elemtype: \"iconbutton\",\n                icon: \"delete-left\",\n                title: \"Clear Chat History\",\n                click: this.clearMessages.bind(this),\n            };\n            return [clearButton];\n        });\n    }\n\n    get viewComponent(): ViewComponent {\n        return WaveAi;\n    }\n\n    dispose() {\n        DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId));\n    }\n\n    async populateMessages(): Promise<void> {\n        const history = await this.fetchAiData();\n        globalStore.set(this.messagesAtom, history.map(promptToMsg));\n    }\n\n    async fetchAiData(): Promise<Array<WaveAIPromptMessageType>> {\n        const { data } = await fetchWaveFile(this.blockId, \"aidata\");\n        if (!data) {\n            return [];\n        }\n        const history: Array<WaveAIPromptMessageType> = JSON.parse(new TextDecoder().decode(data));\n        return history.slice(Math.max(history.length - slidingWindowSize, 0));\n    }\n\n    giveFocus(): boolean {\n        if (this?.textAreaRef?.current) {\n            this.textAreaRef.current?.focus();\n            return true;\n        }\n        return false;\n    }\n\n    getAiName(): string {\n        const blockMeta = globalStore.get(this.blockAtom)?.meta ?? {};\n        const settings = globalStore.get(atoms.settingsAtom) ?? {};\n        const name = blockMeta[\"ai:name\"] ?? settings[\"ai:name\"] ?? null;\n        return name;\n    }\n\n    setLocked(locked: boolean) {\n        globalStore.set(this.locked, locked);\n    }\n\n    sendMessage(text: string, user: string = \"user\") {\n        const clientId = ClientModel.getInstance().clientId;\n        this.setLocked(true);\n\n        const newMessage: ChatMessageType = {\n            id: crypto.randomUUID(),\n            user,\n            text,\n        };\n        globalStore.set(this.addMessageAtom, newMessage);\n        // send message to backend and get response\n        const opts = globalStore.get(this.aiOpts);\n        const newPrompt: WaveAIPromptMessageType = {\n            role: \"user\",\n            content: text,\n        };\n        const handleAiStreamingResponse = async () => {\n            const typingMessage: ChatMessageType = {\n                id: crypto.randomUUID(),\n                user: \"assistant\",\n                text: \"\",\n            };\n\n            // Add a typing indicator\n            globalStore.set(this.addMessageAtom, typingMessage);\n            const history = await this.fetchAiData();\n            const beMsg: WaveAIStreamRequest = {\n                clientid: clientId,\n                opts: opts,\n                prompt: [...history, newPrompt],\n            };\n            let fullMsg = \"\";\n            try {\n                const aiGen = RpcApi.StreamWaveAiCommand(TabRpcClient, beMsg, { timeout: opts.timeoutms });\n                for await (const msg of aiGen) {\n                    fullMsg += msg.text ?? \"\";\n                    globalStore.set(this.updateLastMessageAtom, msg.text ?? \"\", true);\n                    if (this.cancel) {\n                        break;\n                    }\n                }\n                if (fullMsg == \"\") {\n                    // remove a message if empty\n                    globalStore.set(this.removeLastMessageAtom);\n                    // only save the author's prompt\n                    await BlockService.SaveWaveAiData(this.blockId, [...history, newPrompt]);\n                } else {\n                    const responsePrompt: WaveAIPromptMessageType = {\n                        role: \"assistant\",\n                        content: fullMsg,\n                    };\n                    //mark message as complete\n                    globalStore.set(this.updateLastMessageAtom, \"\", false);\n                    // save a complete message prompt and response\n                    await BlockService.SaveWaveAiData(this.blockId, [...history, newPrompt, responsePrompt]);\n                }\n            } catch (error) {\n                const updatedHist = [...history, newPrompt];\n                if (fullMsg == \"\") {\n                    globalStore.set(this.removeLastMessageAtom);\n                } else {\n                    globalStore.set(this.updateLastMessageAtom, \"\", false);\n                    const responsePrompt: WaveAIPromptMessageType = {\n                        role: \"assistant\",\n                        content: fullMsg,\n                    };\n                    updatedHist.push(responsePrompt);\n                }\n                const errMsg: string = (error as Error).message;\n                const errorMessage: ChatMessageType = {\n                    id: crypto.randomUUID(),\n                    user: \"error\",\n                    text: errMsg,\n                };\n                globalStore.set(this.addMessageAtom, errorMessage);\n                globalStore.set(this.updateLastMessageAtom, \"\", false);\n                const errorPrompt: WaveAIPromptMessageType = {\n                    role: \"error\",\n                    content: errMsg,\n                };\n                updatedHist.push(errorPrompt);\n                await BlockService.SaveWaveAiData(this.blockId, updatedHist);\n            }\n            this.setLocked(false);\n            this.cancel = false;\n        };\n        fireAndForget(handleAiStreamingResponse);\n    }\n\n    useWaveAi() {\n        return {\n            sendMessage: this.sendMessage.bind(this) as (text: string) => void,\n        };\n    }\n\n    async clearMessages() {\n        await BlockService.SaveWaveAiData(this.blockId, []);\n        globalStore.set(this.messagesAtom, []);\n    }\n\n    keyDownHandler(waveEvent: WaveKeyboardEvent): boolean {\n        if (checkKeyPressed(waveEvent, \"Cmd:l\")) {\n            fireAndForget(this.clearMessages.bind(this));\n            return true;\n        }\n        return false;\n    }\n}\n\nconst ChatItem = ({ chatItemAtom, model }: ChatItemProps) => {\n    const chatItem = useAtomValue(chatItemAtom);\n    const { user, text } = chatItem;\n    const fontSize = useAtomValue(model.mergedPresets)?.[\"ai:fontsize\"];\n    const fixedFontSize = useAtomValue(model.mergedPresets)?.[\"ai:fixedfontsize\"];\n    const renderContent = useMemo(() => {\n        if (user == \"error\") {\n            return (\n                <>\n                    <div className=\"chat-msg chat-msg-header\">\n                        <div className=\"icon-box\">\n                            <i className=\"fa-sharp fa-solid fa-circle-exclamation\"></i>\n                        </div>\n                    </div>\n                    <div className=\"chat-msg chat-msg-error\">\n                        <Markdown\n                            text={text}\n                            scrollable={false}\n                            fontSizeOverride={fontSize}\n                            fixedFontSizeOverride={fixedFontSize}\n                        />\n                    </div>\n                </>\n            );\n        }\n        if (user == \"assistant\") {\n            return text ? (\n                <>\n                    <div className=\"chat-msg chat-msg-header\">\n                        <div className=\"icon-box\">\n                            <i className=\"fa-sharp fa-solid fa-sparkles\"></i>\n                        </div>\n                    </div>\n                    <div className=\"chat-msg chat-msg-assistant\">\n                        <Markdown\n                            text={text}\n                            scrollable={false}\n                            fontSizeOverride={fontSize}\n                            fixedFontSizeOverride={fixedFontSize}\n                        />\n                    </div>\n                </>\n            ) : (\n                <>\n                    <div className=\"chat-msg-header\">\n                        <i className=\"fa-sharp fa-solid fa-sparkles\"></i>\n                    </div>\n                    <TypingIndicator className=\"chat-msg typing-indicator\" />\n                </>\n            );\n        }\n        return (\n            <>\n                <div className=\"chat-msg chat-msg-user\">\n                    <Markdown\n                        className=\"msg-text\"\n                        text={text}\n                        scrollable={false}\n                        fontSizeOverride={fontSize}\n                        fixedFontSizeOverride={fixedFontSize}\n                    />\n                </div>\n            </>\n        );\n    }, [text, user, fontSize, fixedFontSize]);\n\n    return <div className={\"chat-msg-container\"}>{renderContent}</div>;\n};\n\ninterface ChatWindowProps {\n    chatWindowRef: React.RefObject<HTMLDivElement>;\n    msgWidths: object;\n    model: WaveAiModel;\n}\n\nconst ChatWindow = memo(\n    forwardRef<OverlayScrollbarsComponentRef, ChatWindowProps>(({ chatWindowRef, msgWidths, model }, ref) => {\n        const isUserScrolling = useRef(false);\n        const osRef = useRef<OverlayScrollbarsComponentRef>(null);\n        const splitMessages = useAtomValue(model.messagesSplitAtom) as Atom<ChatMessageType>[];\n        const latestMessage = useAtomValue(model.latestMessageAtom);\n        const prevMessagesLenRef = useRef(splitMessages.length);\n\n        useImperativeHandle(ref, () => osRef.current as OverlayScrollbarsComponentRef);\n\n        const handleNewMessage = useCallback(\n            throttle(100, (messagesLen: number) => {\n                if (osRef.current?.osInstance()) {\n                    const { viewport } = osRef.current.osInstance().elements();\n                    if (prevMessagesLenRef.current !== messagesLen || !isUserScrolling.current) {\n                        viewport.scrollTo({\n                            behavior: \"auto\",\n                            top: chatWindowRef.current?.scrollHeight || 0,\n                        });\n                    }\n\n                    prevMessagesLenRef.current = messagesLen;\n                }\n            }),\n            []\n        );\n\n        useEffect(() => {\n            handleNewMessage(splitMessages.length);\n        }, [splitMessages, latestMessage]);\n\n        // Wait 300 ms after the user stops scrolling to determine if the user is within 300px of the bottom of the chat window.\n        // If so, unset the user scrolling flag.\n        const determineUnsetScroll = useCallback(\n            debounce(300, () => {\n                const { viewport } = osRef.current.osInstance().elements();\n                if (viewport.scrollTop > chatWindowRef.current?.clientHeight - viewport.clientHeight - 100) {\n                    isUserScrolling.current = false;\n                }\n            }),\n            []\n        );\n\n        const handleUserScroll = useCallback(\n            throttle(100, () => {\n                isUserScrolling.current = true;\n                determineUnsetScroll();\n            }),\n            []\n        );\n\n        useEffect(() => {\n            if (osRef.current?.osInstance()) {\n                const { viewport } = osRef.current.osInstance().elements();\n\n                viewport.addEventListener(\"wheel\", handleUserScroll, { passive: true });\n                viewport.addEventListener(\"touchmove\", handleUserScroll, { passive: true });\n\n                return () => {\n                    viewport.removeEventListener(\"wheel\", handleUserScroll);\n                    viewport.removeEventListener(\"touchmove\", handleUserScroll);\n                    if (osRef.current && osRef.current.osInstance()) {\n                        osRef.current.osInstance().destroy();\n                    }\n                };\n            }\n        }, []);\n\n        const handleScrollbarInitialized = (instance: OverlayScrollbars) => {\n            const { viewport } = instance.elements();\n            viewport.removeAttribute(\"tabindex\");\n            viewport.scrollTo({\n                behavior: \"auto\",\n                top: chatWindowRef.current?.scrollHeight || 0,\n            });\n        };\n\n        const handleScrollbarUpdated = (instance: OverlayScrollbars) => {\n            const { viewport } = instance.elements();\n            viewport.removeAttribute(\"tabindex\");\n        };\n\n        return (\n            <OverlayScrollbarsComponent\n                ref={osRef}\n                className=\"chat-window-container\"\n                options={{ scrollbars: { autoHide: \"leave\" } }}\n                events={{ initialized: handleScrollbarInitialized, updated: handleScrollbarUpdated }}\n            >\n                <div ref={chatWindowRef} className=\"chat-window\" style={msgWidths}>\n                    <div className=\"filler\"></div>\n                    {splitMessages.map((chitem, idx) => (\n                        <ChatItem key={idx} chatItemAtom={chitem} model={model} />\n                    ))}\n                </div>\n            </OverlayScrollbarsComponent>\n        );\n    })\n);\n\ninterface ChatInputProps {\n    value: string;\n    baseFontSize: number;\n    onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;\n    onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;\n    onMouseDown: (e: React.MouseEvent<HTMLTextAreaElement>) => void;\n    model: WaveAiModel;\n}\n\nconst ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(\n    ({ value, onChange, onKeyDown, onMouseDown, baseFontSize, model }, ref) => {\n        const textAreaRef = useRef<HTMLTextAreaElement>(null);\n\n        useImperativeHandle(ref, () => textAreaRef.current as HTMLTextAreaElement);\n\n        useEffect(() => {\n            model.textAreaRef = textAreaRef;\n        }, []);\n\n        const adjustTextAreaHeight = useCallback(\n            (value: string) => {\n                if (textAreaRef.current == null) {\n                    return;\n                }\n\n                // Adjust the height of the textarea to fit the text\n                const textAreaMaxLines = 5;\n                const textAreaLineHeight = baseFontSize * 1.5;\n                const textAreaMinHeight = textAreaLineHeight;\n                const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines;\n\n                if (value === \"\") {\n                    textAreaRef.current.style.height = `${textAreaLineHeight}px`;\n                    return;\n                }\n\n                textAreaRef.current.style.height = `${textAreaLineHeight}px`;\n                const scrollHeight = textAreaRef.current.scrollHeight;\n                const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight);\n                textAreaRef.current.style.height = newHeight + \"px\";\n            },\n            [baseFontSize]\n        );\n\n        useEffect(() => {\n            adjustTextAreaHeight(value);\n        }, [value]);\n\n        return (\n            <textarea\n                ref={textAreaRef}\n                autoComplete=\"off\"\n                autoCorrect=\"off\"\n                className=\"waveai-input\"\n                onMouseDown={onMouseDown} // When the user clicks on the textarea\n                onChange={onChange}\n                onKeyDown={onKeyDown}\n                style={{ fontSize: baseFontSize }}\n                placeholder=\"Ask anything...\"\n                value={value}\n            ></textarea>\n        );\n    }\n);\n\nconst WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => {\n    const { sendMessage } = model.useWaveAi();\n    const waveaiRef = useRef<HTMLDivElement>(null);\n    const chatWindowRef = useRef<HTMLDivElement>(null);\n    const osRef = useRef<OverlayScrollbarsComponentRef>(null);\n    const inputRef = useRef<HTMLTextAreaElement>(null);\n\n    const [value, setValue] = useState(\"\");\n    const [selectedBlockIdx, setSelectedBlockIdx] = useState<number | null>(null);\n\n    const baseFontSize: number = 14;\n    const msgWidths = {};\n    const locked = useAtomValue(model.locked);\n    const aiOpts = useAtomValue(model.aiOpts);\n    const isUsingProxy = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl);\n\n    // a weird workaround to initialize ansynchronously\n    useEffect(() => {\n        fireAndForget(model.populateMessages.bind(model));\n    }, []);\n\n    const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n        setValue(e.target.value);\n    };\n\n    const updatePreTagOutline = (clickedPre?: HTMLElement | null) => {\n        const pres = chatWindowRef.current?.querySelectorAll(\"pre\");\n        if (!pres) return;\n\n        pres.forEach((preElement, idx) => {\n            if (preElement === clickedPre) {\n                setSelectedBlockIdx(idx);\n            } else {\n                preElement.style.outline = \"none\";\n            }\n        });\n\n        if (clickedPre) {\n            clickedPre.style.outline = outline;\n        }\n    };\n\n    useEffect(() => {\n        if (selectedBlockIdx !== null) {\n            const pres = chatWindowRef.current?.querySelectorAll(\"pre\");\n            if (pres && pres[selectedBlockIdx]) {\n                pres[selectedBlockIdx].style.outline = outline;\n            }\n        }\n    }, [selectedBlockIdx]);\n\n    const handleTextAreaMouseDown = () => {\n        updatePreTagOutline();\n        setSelectedBlockIdx(null);\n    };\n\n    const handleEnterKeyPressed = useCallback(() => {\n        // using globalStore to avoid potential timing problems\n        // useAtom means the component must rerender once before\n        // the unlock is detected. this automatically checks on the\n        // callback firing instead\n        const locked = globalStore.get(model.locked);\n        if (locked || value === \"\") return;\n\n        sendMessage(value);\n        setValue(\"\");\n        setSelectedBlockIdx(null);\n    }, [value]);\n\n    const updateScrollTop = () => {\n        const pres = chatWindowRef.current?.querySelectorAll(\"pre\");\n        if (!pres || selectedBlockIdx === null) return;\n\n        const block = pres[selectedBlockIdx];\n        if (!block || !osRef.current?.osInstance()) return;\n\n        const { viewport, scrollOffsetElement } = osRef.current.osInstance().elements();\n        const chatWindowTop = scrollOffsetElement.scrollTop;\n        const chatWindowHeight = chatWindowRef.current.clientHeight;\n        const chatWindowBottom = chatWindowTop + chatWindowHeight;\n        const elemTop = block.offsetTop;\n        const elemBottom = elemTop + block.offsetHeight;\n        const elementIsInView = elemBottom <= chatWindowBottom && elemTop >= chatWindowTop;\n\n        if (!elementIsInView) {\n            let scrollPosition;\n            if (elemBottom > chatWindowBottom) {\n                scrollPosition = elemTop - chatWindowHeight + block.offsetHeight + 15;\n            } else if (elemTop < chatWindowTop) {\n                scrollPosition = elemTop - 15;\n            }\n            viewport.scrollTo({\n                behavior: \"auto\",\n                top: scrollPosition,\n            });\n        }\n    };\n\n    const shouldSelectCodeBlock = (key: \"ArrowUp\" | \"ArrowDown\") => {\n        const textarea = inputRef.current;\n        const cursorPosition = textarea?.selectionStart || 0;\n        const textBeforeCursor = textarea?.value.slice(0, cursorPosition) || \"\";\n\n        return (\n            (textBeforeCursor.indexOf(\"\\n\") === -1 && cursorPosition === 0 && key === \"ArrowUp\") ||\n            selectedBlockIdx !== null\n        );\n    };\n\n    const handleArrowUpPressed = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n        if (shouldSelectCodeBlock(\"ArrowUp\")) {\n            e.preventDefault();\n            const pres = chatWindowRef.current?.querySelectorAll(\"pre\");\n            let blockIndex = selectedBlockIdx;\n            if (!pres) return;\n            if (blockIndex === null) {\n                setSelectedBlockIdx(pres.length - 1);\n            } else if (blockIndex > 0) {\n                blockIndex--;\n                setSelectedBlockIdx(blockIndex);\n            }\n            updateScrollTop();\n        }\n    };\n\n    const handleArrowDownPressed = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n        if (shouldSelectCodeBlock(\"ArrowDown\")) {\n            e.preventDefault();\n            const pres = chatWindowRef.current?.querySelectorAll(\"pre\");\n            let blockIndex = selectedBlockIdx;\n            if (!pres) return;\n            if (blockIndex === null) return;\n            if (blockIndex < pres.length - 1 && blockIndex >= 0) {\n                setSelectedBlockIdx(++blockIndex);\n                updateScrollTop();\n            } else {\n                inputRef.current.focus();\n                setSelectedBlockIdx(null);\n            }\n            updateScrollTop();\n        }\n    };\n\n    const handleTextAreaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n        const waveEvent = adaptFromReactOrNativeKeyEvent(e);\n        if (checkKeyPressed(waveEvent, \"Enter\")) {\n            e.preventDefault();\n            handleEnterKeyPressed();\n        } else if (checkKeyPressed(waveEvent, \"ArrowUp\")) {\n            handleArrowUpPressed(e);\n        } else if (checkKeyPressed(waveEvent, \"ArrowDown\")) {\n            handleArrowDownPressed(e);\n        }\n    };\n\n    let buttonClass = \"waveai-submit-button\";\n    let buttonIcon = makeIconClass(\"arrow-up\", false);\n    let buttonTitle = \"run\";\n    if (locked) {\n        buttonClass = \"waveai-submit-button stop\";\n        buttonIcon = makeIconClass(\"stop\", false);\n        buttonTitle = \"stop\";\n    }\n    const handleButtonPress = useCallback(() => {\n        if (locked) {\n            model.cancel = true;\n        } else {\n            handleEnterKeyPressed();\n        }\n    }, [locked, handleEnterKeyPressed]);\n\n    const handleOpenAIPanel = useCallback(() => {\n        WorkspaceLayoutModel.getInstance().setAIPanelVisible(true);\n    }, []);\n\n    return (\n        <div ref={waveaiRef} className=\"waveai\">\n            {isUsingProxy && (\n                <div className=\"flex items-start gap-3 px-4 py-2 bg-orange-500/25 border-b border-orange-500/50 text-sm\">\n                    <i className=\"fa-sharp fa-solid fa-triangle-exclamation text-orange-300 mt-0.5\"></i>\n                    <span className=\"text-primary/90\">\n                        Wave AI Proxy is deprecated and will be removed. Please use the new{\" \"}\n                        <button\n                            onClick={handleOpenAIPanel}\n                            className=\"text-accent hover:text-accent/80 underline cursor-pointer\"\n                        >\n                            Wave AI panel\n                        </button>{\" \"}\n                        instead (better model, terminal integration, tool support, image uploads).\n                    </span>\n                </div>\n            )}\n            <div className=\"waveai-chat\">\n                <ChatWindow ref={osRef} chatWindowRef={chatWindowRef} msgWidths={msgWidths} model={model} />\n            </div>\n            <div className=\"waveai-controls\">\n                <div className=\"waveai-input-wrapper\">\n                    <ChatInput\n                        ref={inputRef}\n                        value={value}\n                        model={model}\n                        onChange={handleTextAreaChange}\n                        onKeyDown={handleTextAreaKeyDown}\n                        onMouseDown={handleTextAreaMouseDown}\n                        baseFontSize={baseFontSize}\n                    />\n                </div>\n                <Button className={buttonClass} onClick={handleButtonPress}>\n                    <i className={buttonIcon} title={buttonTitle} />\n                </Button>\n            </div>\n        </div>\n    );\n};\n\nexport { WaveAi };\n"
  },
  {
    "path": "frontend/app/view/waveconfig/secretscontent.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { SecretNameRegex, type WaveConfigViewModel } from \"@/app/view/waveconfig/waveconfig-model\";\nimport { cn } from \"@/util/util\";\nimport { useAtomValue, useSetAtom } from \"jotai\";\nimport { memo, useMemo } from \"react\";\n\ninterface ErrorDisplayProps {\n    message: string;\n    variant?: \"error\" | \"warning\";\n}\n\nconst ErrorDisplay = memo(({ message, variant = \"error\" }: ErrorDisplayProps) => {\n    const icon = variant === \"error\" ? \"fa-circle-exclamation\" : \"fa-triangle-exclamation\";\n    const baseClasses = \"flex items-center gap-2 p-4 border rounded-lg\";\n    const variantClasses =\n        variant === \"error\"\n            ? \"bg-red-500/10 border-red-500/20 text-red-400\"\n            : \"bg-yellow-500/10 border-yellow-500/20 text-yellow-400\";\n\n    return (\n        <div className={`${baseClasses} ${variantClasses}`}>\n            <i className={`fa-sharp fa-solid ${icon}`} />\n            <span>{message}</span>\n        </div>\n    );\n});\nErrorDisplay.displayName = \"ErrorDisplay\";\n\nconst LoadingSpinner = memo(({ message }: { message: string }) => {\n    return (\n        <div className=\"flex flex-col items-center justify-center gap-3 py-12\">\n            <i className=\"fa-sharp fa-solid fa-spinner fa-spin text-2xl text-zinc-400\" />\n            <span className=\"text-zinc-400\">{message}</span>\n        </div>\n    );\n});\nLoadingSpinner.displayName = \"LoadingSpinner\";\n\nconst EmptyState = memo(({ onAddSecret }: { onAddSecret: () => void }) => {\n    return (\n        <div className=\"flex flex-col items-center justify-center gap-4 py-12 h-full bg-zinc-800/50 rounded-lg\">\n            <i className=\"fa-sharp fa-solid fa-key text-4xl text-zinc-600\" />\n            <h3 className=\"text-lg font-semibold text-zinc-400\">No Secrets</h3>\n            <p className=\"text-zinc-500\">Add a secret to get started</p>\n            <button\n                className=\"flex items-center gap-2 px-4 py-2 bg-accent-600 hover:bg-accent-500 rounded cursor-pointer transition-colors\"\n                onClick={onAddSecret}\n            >\n                <i className=\"fa-sharp fa-solid fa-plus\" />\n                <span className=\"font-medium\">Add New Secret</span>\n            </button>\n        </div>\n    );\n});\nEmptyState.displayName = \"EmptyState\";\n\nconst CLIInfoBubble = memo(() => {\n    return (\n        <div className=\"flex flex-col gap-2 p-4 m-4 bg-zinc-800/50 rounded-lg\">\n            <div className=\"flex items-center gap-2\">\n                <i className=\"fa-sharp fa-solid fa-terminal text-zinc-400\" />\n                <div className=\"text-sm font-medium text-zinc-300\">CLI Access</div>\n            </div>\n            <div className=\"font-mono text-xs bg-black/20 px-3 py-2 rounded leading-relaxed text-zinc-300\">\n                wsh secret list\n                <br />\n                wsh secret get [name]\n                <br />\n                wsh secret set [name]=[value]\n            </div>\n        </div>\n    );\n});\nCLIInfoBubble.displayName = \"CLIInfoBubble\";\n\ninterface SecretListViewProps {\n    secretNames: string[];\n    onSelectSecret: (name: string) => void;\n    onAddSecret: () => void;\n}\n\nconst SecretListView = memo(({ secretNames, onSelectSecret, onAddSecret }: SecretListViewProps) => {\n    return (\n        <div className=\"flex flex-col h-full w-full rounded-lg\">\n            <div className=\"flex flex-col divide-y divide-zinc-700\">\n                {secretNames.map((name) => (\n                    <div\n                        key={name}\n                        className={cn(\n                            \"flex items-center gap-3 p-4 hover:bg-zinc-700/50 cursor-pointer transition-colors\"\n                        )}\n                        onClick={() => onSelectSecret(name)}\n                    >\n                        <i className=\"fa-sharp fa-solid fa-key text-accent-500\" />\n                        <span className=\"flex-1 font-mono\">{name}</span>\n                        <i className=\"fa-sharp fa-solid fa-chevron-right text-zinc-500 text-sm\" />\n                    </div>\n                ))}\n                <div\n                    className={cn(\n                        \"flex items-center justify-center gap-2 p-4 hover:bg-zinc-700/50 cursor-pointer transition-colors border-t-2 border-zinc-600\"\n                    )}\n                    onClick={onAddSecret}\n                >\n                    <i className=\"fa-sharp fa-solid fa-plus text-accent-500\" />\n                    <span className=\"font-medium text-accent-500\">Add New Secret</span>\n                </div>\n            </div>\n            <CLIInfoBubble />\n        </div>\n    );\n});\nSecretListView.displayName = \"SecretListView\";\n\ninterface AddSecretFormProps {\n    newSecretName: string;\n    newSecretValue: string;\n    isLoading: boolean;\n    onNameChange: (name: string) => void;\n    onValueChange: (value: string) => void;\n    onCancel: () => void;\n    onSubmit: () => void;\n}\n\nconst AddSecretForm = memo(\n    ({\n        newSecretName,\n        newSecretValue,\n        isLoading,\n        onNameChange,\n        onValueChange,\n        onCancel,\n        onSubmit,\n    }: AddSecretFormProps) => {\n        const isNameInvalid = newSecretName !== \"\" && !SecretNameRegex.test(newSecretName);\n\n        return (\n            <div className=\"flex flex-col gap-4 min-h-full p-6 bg-zinc-800/50 rounded-lg\">\n                <h3 className=\"text-lg font-semibold\">Add New Secret</h3>\n                <div className=\"flex flex-col gap-2\">\n                    <label className=\"text-sm font-medium\">Secret Name</label>\n                    <input\n                        type=\"text\"\n                        className={cn(\n                            \"px-3 py-2 bg-zinc-800 border rounded focus:outline-none\",\n                            isNameInvalid\n                                ? \"border-red-500 focus:border-red-500\"\n                                : \"border-zinc-600 focus:border-accent-500\"\n                        )}\n                        value={newSecretName}\n                        onChange={(e) => onNameChange(e.target.value)}\n                        placeholder=\"MY_SECRET_NAME\"\n                        disabled={isLoading}\n                    />\n                    <div className=\"text-xs text-zinc-400\">\n                        Must start with a letter and contain only letters, numbers, and underscores\n                    </div>\n                </div>\n                <div className=\"flex flex-col gap-2\">\n                    <label className=\"text-sm font-medium\">Secret Value</label>\n                    <textarea\n                        className=\"px-3 py-2 bg-zinc-800 border border-zinc-600 rounded focus:outline-none focus:border-accent-500 font-mono text-sm\"\n                        value={newSecretValue}\n                        onChange={(e) => onValueChange(e.target.value)}\n                        placeholder=\"Enter secret value...\"\n                        disabled={isLoading}\n                        rows={4}\n                    />\n                </div>\n                <div className=\"flex gap-2 justify-end\">\n                    <button\n                        className=\"px-4 py-2 bg-zinc-700 hover:bg-zinc-600 rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed\"\n                        onClick={onCancel}\n                        disabled={isLoading}\n                    >\n                        Cancel\n                    </button>\n                    <button\n                        className=\"px-4 py-2 bg-accent-600 hover:bg-accent-500 rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n                        onClick={onSubmit}\n                        disabled={isLoading || isNameInvalid || newSecretName.trim() === \"\"}\n                    >\n                        {isLoading ? (\n                            <>\n                                <i className=\"fa-sharp fa-solid fa-spinner fa-spin\" />\n                                Adding...\n                            </>\n                        ) : (\n                            \"Add Secret\"\n                        )}\n                    </button>\n                </div>\n            </div>\n        );\n    }\n);\nAddSecretForm.displayName = \"AddSecretForm\";\n\ninterface SecretDetailViewProps {\n    model: WaveConfigViewModel;\n}\n\nconst SecretDetailView = memo(({ model }: SecretDetailViewProps) => {\n    const secretName = useAtomValue(model.selectedSecretAtom);\n    const secretValue = useAtomValue(model.secretValueAtom);\n    const secretShown = useAtomValue(model.secretShownAtom);\n    const isLoading = useAtomValue(model.isLoadingAtom);\n    const setSecretValue = useSetAtom(model.secretValueAtom);\n\n    if (!secretName) {\n        return null;\n    }\n\n    return (\n        <div className=\"flex flex-col gap-4 min-h-full p-6 bg-zinc-800/50 rounded-lg\">\n            <div className=\"flex items-center gap-2\">\n                <i className=\"fa-sharp fa-solid fa-key text-accent-500\" />\n                <h3 className=\"text-lg font-semibold\">{secretName}</h3>\n            </div>\n            <div className=\"flex flex-col gap-2\">\n                <label className=\"text-sm font-medium\">Secret Value</label>\n                <textarea\n                    ref={(ref) => {\n                        model.secretValueRef = ref;\n                        if (ref) {\n                            ref.focus();\n                        }\n                    }}\n                    className=\"px-3 py-2 bg-zinc-800 border border-zinc-600 rounded focus:outline-none focus:border-accent-500 font-mono text-sm\"\n                    value={secretValue}\n                    onChange={(e) => setSecretValue(e.target.value)}\n                    onKeyDown={(e) => {\n                        if (e.key === \"Escape\") {\n                            model.closeSecretView();\n                        }\n                    }}\n                    disabled={isLoading}\n                    rows={6}\n                    placeholder={!secretShown ? \"Enter new secret value...\" : \"\"}\n                />\n                {!secretShown && (\n                    <div className=\"text-sm text-zinc-400\">\n                        The current secret value is not shown by default for security purposes.{\" \"}\n                        {isLoading ? (\n                            <span className=\"text-zinc-500\">\n                                <i className=\"fa-sharp fa-solid fa-spinner fa-spin\" /> Loading...\n                            </span>\n                        ) : (\n                            <button\n                                className=\"text-accent-500 underline hover:text-accent-400 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed\"\n                                onClick={() => model.showSecret()}\n                                disabled={isLoading}\n                            >\n                                Show Secret\n                            </button>\n                        )}\n                    </div>\n                )}\n            </div>\n            <div className=\"flex gap-2 justify-between\">\n                <button\n                    className=\"px-4 py-2 bg-red-600 hover:bg-red-500 rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n                    onClick={() => model.deleteSecret()}\n                    disabled={isLoading}\n                    title=\"Delete this secret\"\n                >\n                    {isLoading ? (\n                        <>\n                            <i className=\"fa-sharp fa-solid fa-spinner fa-spin\" />\n                            Deleting...\n                        </>\n                    ) : (\n                        <>\n                            <i className=\"fa-sharp fa-solid fa-trash\" />\n                            Delete\n                        </>\n                    )}\n                </button>\n                <div className=\"flex gap-2\">\n                    <button\n                        className=\"px-4 py-2 bg-zinc-700 hover:bg-zinc-600 rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed\"\n                        onClick={() => model.closeSecretView()}\n                        disabled={isLoading}\n                    >\n                        Cancel\n                    </button>\n                    <button\n                        className=\"px-4 py-2 bg-accent-600 hover:bg-accent-500 rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n                        onClick={() => model.saveSecret()}\n                        disabled={isLoading}\n                    >\n                        {isLoading ? (\n                            <>\n                                <i className=\"fa-sharp fa-solid fa-spinner fa-spin\" />\n                                Saving...\n                            </>\n                        ) : (\n                            \"Save\"\n                        )}\n                    </button>\n                </div>\n            </div>\n        </div>\n    );\n});\nSecretDetailView.displayName = \"SecretDetailView\";\n\ninterface SecretsContentProps {\n    model: WaveConfigViewModel;\n}\n\nexport const SecretsContent = memo(({ model }: SecretsContentProps) => {\n    const secretNames = useAtomValue(model.secretNamesAtom);\n    const selectedSecret = useAtomValue(model.selectedSecretAtom);\n    const isLoading = useAtomValue(model.isLoadingAtom);\n    const errorMessage = useAtomValue(model.errorMessageAtom);\n    const storageBackendError = useAtomValue(model.storageBackendErrorAtom);\n    const isAddingNew = useAtomValue(model.isAddingNewAtom);\n    const newSecretName = useAtomValue(model.newSecretNameAtom);\n    const newSecretValue = useAtomValue(model.newSecretValueAtom);\n\n    const setNewSecretName = useSetAtom(model.newSecretNameAtom);\n    const setNewSecretValue = useSetAtom(model.newSecretValueAtom);\n\n    const sortedSecretNames = useMemo(() => {\n        return [...secretNames].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));\n    }, [secretNames]);\n\n    if (storageBackendError) {\n        return (\n            <div className=\"w-full h-full\">\n                <div className=\"p-4\">\n                    <ErrorDisplay message={storageBackendError} variant=\"warning\" />\n                </div>\n            </div>\n        );\n    }\n\n    if (isLoading && secretNames.length === 0 && !selectedSecret) {\n        return (\n            <div className=\"w-full h-full\">\n                <div>\n                    <LoadingSpinner message=\"Loading secrets...\" />\n                </div>\n            </div>\n        );\n    }\n\n    const renderContent = () => {\n        if (isAddingNew) {\n            return (\n                <AddSecretForm\n                    newSecretName={newSecretName}\n                    newSecretValue={newSecretValue}\n                    isLoading={isLoading}\n                    onNameChange={setNewSecretName}\n                    onValueChange={setNewSecretValue}\n                    onCancel={() => model.cancelAddingSecret()}\n                    onSubmit={() => model.addNewSecret()}\n                />\n            );\n        }\n\n        if (selectedSecret) {\n            return <SecretDetailView model={model} />;\n        }\n\n        if (secretNames.length === 0) {\n            return <EmptyState onAddSecret={() => model.startAddingSecret()} />;\n        }\n\n        return (\n            <SecretListView\n                secretNames={sortedSecretNames}\n                onSelectSecret={(name) => model.viewSecret(name)}\n                onAddSecret={() => model.startAddingSecret()}\n            />\n        );\n    };\n\n    return (\n        <div className=\"w-full h-full\">\n            {errorMessage && (\n                <div className=\"p-4\">\n                    <ErrorDisplay message={errorMessage} />\n                </div>\n            )}\n            {renderContent()}\n        </div>\n    );\n});\n\nSecretsContent.displayName = \"SecretsContent\";\n"
  },
  {
    "path": "frontend/app/view/waveconfig/waveaivisual.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { WaveConfigViewModel } from \"@/app/view/waveconfig/waveconfig-model\";\nimport { memo } from \"react\";\n\ninterface WaveAIVisualContentProps {\n    model: WaveConfigViewModel;\n}\n\nexport const WaveAIVisualContent = memo(({ model }: WaveAIVisualContentProps) => {\n    return (\n        <div className=\"flex flex-col gap-4 p-6 h-full\">\n            <div className=\"text-lg font-semibold\">Wave AI Modes - Visual Editor</div>\n            <div className=\"text-muted-foreground\">Visual editor coming soon...</div>\n        </div>\n    );\n});\n\nWaveAIVisualContent.displayName = \"WaveAIVisualContent\";"
  },
  {
    "path": "frontend/app/view/waveconfig/waveconfig-model.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { BlockNodeModel } from \"@/app/block/blocktypes\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport type { TabModel } from \"@/app/store/tab-model\";\nimport { makeORef } from \"@/app/store/wos\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { SecretsContent } from \"@/app/view/waveconfig/secretscontent\";\nimport { WaveConfigView } from \"@/app/view/waveconfig/waveconfig\";\nimport type { WaveConfigEnv } from \"@/app/view/waveconfig/waveconfigenv\";\nimport { base64ToString, stringToBase64 } from \"@/util/util\";\nimport { atom, type Atom, type PrimitiveAtom } from \"jotai\";\nimport type * as MonacoTypes from \"monaco-editor\";\nimport * as React from \"react\";\n\ntype ValidationResult = { success: true } | { error: string };\ntype ConfigValidator = (parsed: any) => ValidationResult;\n\nexport type ConfigFile = {\n    name: string;\n    path: string;\n    language?: string;\n    deprecated?: boolean;\n    description?: string;\n    docsUrl?: string;\n    validator?: ConfigValidator;\n    isSecrets?: boolean;\n    hasJsonView?: boolean;\n    visualComponent?: React.ComponentType<{ model: WaveConfigViewModel }>;\n};\n\nexport const SecretNameRegex = /^[A-Za-z][A-Za-z0-9_]*$/;\n\nfunction validateBgJson(parsed: any): ValidationResult {\n    const keys = Object.keys(parsed);\n    for (const key of keys) {\n        if (!key.startsWith(\"bg@\")) {\n            return { error: `Invalid key \"${key}\": all top-level keys must start with \"bg@\"` };\n        }\n    }\n    return { success: true };\n}\n\nfunction validateAiJson(parsed: any): ValidationResult {\n    const keys = Object.keys(parsed);\n    for (const key of keys) {\n        if (!key.startsWith(\"ai@\")) {\n            return { error: `Invalid key \"${key}\": all top-level keys must start with \"ai@\"` };\n        }\n    }\n    return { success: true };\n}\n\nfunction validateWaveAiJson(parsed: any): ValidationResult {\n    const keys = Object.keys(parsed);\n    const keyPattern = /^[a-zA-Z0-9_@.-]+$/;\n    for (const key of keys) {\n        if (!keyPattern.test(key)) {\n            return {\n                error: `Invalid key \"${key}\": keys must only contain letters, numbers, underscores, @, dots, and hyphens`,\n            };\n        }\n    }\n    return { success: true };\n}\n\nfunction makeConfigFiles(isWindows: boolean): ConfigFile[] {\n    return [\n        {\n            name: \"General\",\n            path: \"settings.json\",\n            language: \"json\",\n            docsUrl: \"https://docs.waveterm.dev/config\",\n            hasJsonView: true,\n        },\n        {\n            name: \"Connections\",\n            path: \"connections.json\",\n            language: \"json\",\n            docsUrl: \"https://docs.waveterm.dev/connections\",\n            description: isWindows ? \"SSH hosts and WSL distros\" : \"SSH hosts\",\n            hasJsonView: true,\n        },\n        {\n            name: \"Sidebar Widgets\",\n            path: \"widgets.json\",\n            language: \"json\",\n            docsUrl: \"https://docs.waveterm.dev/customwidgets\",\n            hasJsonView: true,\n        },\n        {\n            name: \"Wave AI Modes\",\n            path: \"waveai.json\",\n            language: \"json\",\n            description: \"Local models and BYOK\",\n            docsUrl: \"https://docs.waveterm.dev/waveai-modes\",\n            validator: validateWaveAiJson,\n            hasJsonView: true,\n            // visualComponent: WaveAIVisualContent,\n        },\n        {\n            name: \"Tab Backgrounds\",\n            path: \"presets/bg.json\",\n            language: \"json\",\n            docsUrl: \"https://docs.waveterm.dev/presets#background-configurations\",\n            validator: validateBgJson,\n            hasJsonView: true,\n        },\n        {\n            name: \"Secrets\",\n            path: \"secrets\",\n            isSecrets: true,\n            hasJsonView: false,\n            visualComponent: SecretsContent,\n        },\n    ];\n}\n\nconst deprecatedConfigFiles: ConfigFile[] = [\n    {\n        name: \"Presets\",\n        path: \"presets.json\",\n        language: \"json\",\n        deprecated: true,\n        hasJsonView: true,\n    },\n    {\n        name: \"AI Presets\",\n        path: \"presets/ai.json\",\n        language: \"json\",\n        deprecated: true,\n        docsUrl: \"https://docs.waveterm.dev/ai-presets\",\n        validator: validateAiJson,\n        hasJsonView: true,\n    },\n];\n\nexport class WaveConfigViewModel implements ViewModel {\n    blockId: string;\n    viewType = \"waveconfig\";\n    viewIcon = atom(\"gear\");\n    viewName = atom(\"Wave Config\");\n    viewComponent = WaveConfigView;\n    noPadding = atom(true);\n    nodeModel: BlockNodeModel;\n    tabModel: TabModel;\n    env: WaveConfigEnv;\n\n    selectedFileAtom: PrimitiveAtom<ConfigFile>;\n    fileContentAtom: PrimitiveAtom<string>;\n    originalContentAtom: PrimitiveAtom<string>;\n    hasEditedAtom: PrimitiveAtom<boolean>;\n    isLoadingAtom: PrimitiveAtom<boolean>;\n    isSavingAtom: PrimitiveAtom<boolean>;\n    errorMessageAtom: PrimitiveAtom<string>;\n    validationErrorAtom: PrimitiveAtom<string>;\n    isMenuOpenAtom: PrimitiveAtom<boolean>;\n    presetsJsonExistsAtom: PrimitiveAtom<boolean>;\n    activeTabAtom: PrimitiveAtom<\"visual\" | \"json\">;\n    configErrorFilesAtom: Atom<Set<string>>;\n    configDir: string;\n    saveShortcut: string;\n    editorRef: React.RefObject<MonacoTypes.editor.IStandaloneCodeEditor>;\n\n    secretNamesAtom: PrimitiveAtom<string[]>;\n    selectedSecretAtom: PrimitiveAtom<string | null>;\n    secretValueAtom: PrimitiveAtom<string>;\n    secretShownAtom: PrimitiveAtom<boolean>;\n    isAddingNewAtom: PrimitiveAtom<boolean>;\n    newSecretNameAtom: PrimitiveAtom<string>;\n    newSecretValueAtom: PrimitiveAtom<string>;\n    storageBackendErrorAtom: PrimitiveAtom<string | null>;\n    secretValueRef: HTMLTextAreaElement | null = null;\n\n    constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) {\n        this.blockId = blockId;\n        this.nodeModel = nodeModel;\n        this.tabModel = tabModel;\n        this.env = waveEnv as WaveConfigEnv;\n        this.configDir = this.env.electron.getConfigDir();\n        const platform = this.env.electron.getPlatform();\n        this.saveShortcut = platform === \"darwin\" ? \"Cmd+S\" : \"Alt+S\";\n\n        this.selectedFileAtom = atom(null) as PrimitiveAtom<ConfigFile>;\n        this.fileContentAtom = atom(\"\");\n        this.originalContentAtom = atom(\"\");\n        this.hasEditedAtom = atom(false);\n        this.isLoadingAtom = atom(false);\n        this.isSavingAtom = atom(false);\n        this.errorMessageAtom = atom(null) as PrimitiveAtom<string>;\n        this.validationErrorAtom = atom(null) as PrimitiveAtom<string>;\n        this.isMenuOpenAtom = atom(false);\n        this.presetsJsonExistsAtom = atom(false);\n        this.activeTabAtom = atom<\"visual\" | \"json\">(\"visual\");\n        this.configErrorFilesAtom = atom((get) => {\n            const fullConfig = get(this.env.atoms.fullConfigAtom);\n            const errorSet = new Set<string>();\n            for (const cerr of fullConfig?.configerrors ?? []) {\n                errorSet.add(cerr.file);\n            }\n            return errorSet;\n        });\n        this.editorRef = React.createRef();\n\n        this.secretNamesAtom = atom<string[]>([]);\n        this.selectedSecretAtom = atom<string | null>(null) as PrimitiveAtom<string | null>;\n        this.secretValueAtom = atom<string>(\"\");\n        this.secretShownAtom = atom<boolean>(false);\n        this.isAddingNewAtom = atom<boolean>(false);\n        this.newSecretNameAtom = atom<string>(\"\");\n        this.newSecretValueAtom = atom<string>(\"\");\n        this.storageBackendErrorAtom = atom<string | null>(null) as PrimitiveAtom<string | null>;\n\n        this.checkPresetsJsonExists();\n        this.initialize();\n    }\n\n    async checkPresetsJsonExists() {\n        try {\n            const fullPath = `${this.configDir}/presets.json`;\n            const fileInfo = await this.env.rpc.FileInfoCommand(TabRpcClient, {\n                info: { path: fullPath },\n            });\n            if (!fileInfo.notfound) {\n                globalStore.set(this.presetsJsonExistsAtom, true);\n            }\n        } catch {\n            // File doesn't exist\n        }\n    }\n\n    initialize() {\n        const selectedFile = globalStore.get(this.selectedFileAtom);\n        if (!selectedFile) {\n            const metaFileAtom = this.env.getBlockMetaKeyAtom(this.blockId, \"file\");\n            const savedFilePath = globalStore.get(metaFileAtom);\n            const configFiles = this.getConfigFiles();\n            const deprecatedConfigFiles = this.getDeprecatedConfigFiles();\n\n            let fileToLoad: ConfigFile | null = null;\n            if (savedFilePath) {\n                fileToLoad =\n                    configFiles.find((f) => f.path === savedFilePath) ||\n                    deprecatedConfigFiles.find((f) => f.path === savedFilePath) ||\n                    null;\n            }\n\n            if (!fileToLoad) {\n                fileToLoad = configFiles[0];\n            }\n\n            if (fileToLoad) {\n                this.loadFile(fileToLoad);\n            }\n        }\n    }\n\n    getConfigFiles(): ConfigFile[] {\n        return makeConfigFiles(this.env.isWindows());\n    }\n\n    getDeprecatedConfigFiles(): ConfigFile[] {\n        const presetsJsonExists = globalStore.get(this.presetsJsonExistsAtom);\n        return deprecatedConfigFiles.filter((f) => {\n            if (f.path === \"presets.json\") {\n                return presetsJsonExists;\n            }\n            return true;\n        });\n    }\n\n    hasChanges(): boolean {\n        return globalStore.get(this.hasEditedAtom);\n    }\n\n    markAsEdited() {\n        globalStore.set(this.hasEditedAtom, true);\n    }\n\n    async loadFile(file: ConfigFile) {\n        globalStore.set(this.isLoadingAtom, true);\n        globalStore.set(this.errorMessageAtom, null);\n        globalStore.set(this.hasEditedAtom, false);\n\n        if (file.isSecrets) {\n            globalStore.set(this.selectedFileAtom, file);\n            this.env.rpc.SetMetaCommand(TabRpcClient, {\n                oref: makeORef(\"block\", this.blockId),\n                meta: { file: file.path },\n            });\n            globalStore.set(this.isLoadingAtom, false);\n            this.checkStorageBackend();\n            this.refreshSecrets();\n            return;\n        }\n\n        try {\n            const fullPath = `${this.configDir}/${file.path}`;\n            const fileData = await this.env.rpc.FileReadCommand(TabRpcClient, {\n                info: { path: fullPath },\n            });\n            const content = fileData?.data64 ? base64ToString(fileData.data64) : \"\";\n            globalStore.set(this.originalContentAtom, content);\n            if (content.trim() === \"\") {\n                globalStore.set(this.fileContentAtom, \"{\\n\\n}\");\n            } else {\n                globalStore.set(this.fileContentAtom, content);\n            }\n            globalStore.set(this.selectedFileAtom, file);\n            this.env.rpc.SetMetaCommand(TabRpcClient, {\n                oref: makeORef(\"block\", this.blockId),\n                meta: { file: file.path },\n            });\n        } catch (err) {\n            globalStore.set(this.errorMessageAtom, `Failed to load ${file.name}: ${err.message || String(err)}`);\n            globalStore.set(this.fileContentAtom, \"\");\n            globalStore.set(this.originalContentAtom, \"\");\n        } finally {\n            globalStore.set(this.isLoadingAtom, false);\n        }\n    }\n\n    async saveFile() {\n        const selectedFile = globalStore.get(this.selectedFileAtom);\n        if (!selectedFile) return;\n\n        const fileContent = globalStore.get(this.fileContentAtom);\n\n        if (fileContent.trim() === \"\") {\n            globalStore.set(this.isSavingAtom, true);\n            globalStore.set(this.errorMessageAtom, null);\n            globalStore.set(this.validationErrorAtom, null);\n\n            try {\n                const fullPath = `${this.configDir}/${selectedFile.path}`;\n                await this.env.rpc.FileWriteCommand(TabRpcClient, {\n                    info: { path: fullPath },\n                    data64: stringToBase64(\"\"),\n                });\n                globalStore.set(this.fileContentAtom, \"\");\n                globalStore.set(this.originalContentAtom, \"\");\n                globalStore.set(this.hasEditedAtom, false);\n            } catch (err) {\n                globalStore.set(\n                    this.errorMessageAtom,\n                    `Failed to save ${selectedFile.name}: ${err.message || String(err)}`\n                );\n            } finally {\n                globalStore.set(this.isSavingAtom, false);\n            }\n            return;\n        }\n\n        try {\n            const parsed = JSON.parse(fileContent);\n\n            if (typeof parsed !== \"object\" || parsed == null || Array.isArray(parsed)) {\n                globalStore.set(this.validationErrorAtom, \"JSON must be an object, not an array, primitive, or null\");\n                return;\n            }\n\n            if (selectedFile.validator) {\n                const validationResult = selectedFile.validator(parsed);\n                if (\"error\" in validationResult) {\n                    globalStore.set(this.validationErrorAtom, validationResult.error);\n                    return;\n                }\n            }\n\n            const formatted = JSON.stringify(parsed, null, 2);\n\n            globalStore.set(this.isSavingAtom, true);\n            globalStore.set(this.errorMessageAtom, null);\n            globalStore.set(this.validationErrorAtom, null);\n\n            try {\n                const fullPath = `${this.configDir}/${selectedFile.path}`;\n                await this.env.rpc.FileWriteCommand(TabRpcClient, {\n                    info: { path: fullPath },\n                    data64: stringToBase64(formatted),\n                });\n                globalStore.set(this.fileContentAtom, formatted);\n                globalStore.set(this.originalContentAtom, formatted);\n                globalStore.set(this.hasEditedAtom, false);\n            } catch (err) {\n                globalStore.set(\n                    this.errorMessageAtom,\n                    `Failed to save ${selectedFile.name}: ${err.message || String(err)}`\n                );\n            } finally {\n                globalStore.set(this.isSavingAtom, false);\n            }\n        } catch (err) {\n            globalStore.set(this.validationErrorAtom, `Invalid JSON: ${err.message || String(err)}`);\n        }\n    }\n\n    clearError() {\n        globalStore.set(this.errorMessageAtom, null);\n    }\n\n    clearValidationError() {\n        globalStore.set(this.validationErrorAtom, null);\n    }\n\n    async checkStorageBackend() {\n        try {\n            const backend = await this.env.rpc.GetSecretsLinuxStorageBackendCommand(TabRpcClient);\n            if (backend === \"basic_text\" || backend === \"unknown\") {\n                globalStore.set(\n                    this.storageBackendErrorAtom,\n                    \"No appropriate secret manager found. Cannot manage secrets securely.\"\n                );\n            } else {\n                globalStore.set(this.storageBackendErrorAtom, null);\n            }\n        } catch (error) {\n            globalStore.set(this.storageBackendErrorAtom, `Error checking storage backend: ${error.message}`);\n        }\n    }\n\n    async refreshSecrets() {\n        globalStore.set(this.isLoadingAtom, true);\n        globalStore.set(this.errorMessageAtom, null);\n\n        try {\n            const names = await this.env.rpc.GetSecretsNamesCommand(TabRpcClient);\n            globalStore.set(this.secretNamesAtom, names || []);\n        } catch (error) {\n            globalStore.set(this.errorMessageAtom, `Failed to load secrets: ${error.message}`);\n        } finally {\n            globalStore.set(this.isLoadingAtom, false);\n        }\n    }\n\n    async viewSecret(name: string) {\n        globalStore.set(this.errorMessageAtom, null);\n        globalStore.set(this.selectedSecretAtom, name);\n        globalStore.set(this.secretShownAtom, false);\n        globalStore.set(this.secretValueAtom, \"\");\n    }\n\n    closeSecretView() {\n        globalStore.set(this.selectedSecretAtom, null);\n        globalStore.set(this.secretValueAtom, \"\");\n        globalStore.set(this.errorMessageAtom, null);\n    }\n\n    async showSecret() {\n        const selectedSecret = globalStore.get(this.selectedSecretAtom);\n        if (!selectedSecret) {\n            return;\n        }\n\n        globalStore.set(this.isLoadingAtom, true);\n        globalStore.set(this.errorMessageAtom, null);\n\n        try {\n            const secrets = await this.env.rpc.GetSecretsCommand(TabRpcClient, [selectedSecret]);\n            const value = secrets[selectedSecret];\n            if (value !== undefined) {\n                globalStore.set(this.secretValueAtom, value);\n                globalStore.set(this.secretShownAtom, true);\n            } else {\n                globalStore.set(this.errorMessageAtom, `Secret not found: ${selectedSecret}`);\n            }\n        } catch (error) {\n            globalStore.set(this.errorMessageAtom, `Failed to load secret: ${error.message}`);\n        } finally {\n            globalStore.set(this.isLoadingAtom, false);\n        }\n    }\n\n    async saveSecret() {\n        const selectedSecret = globalStore.get(this.selectedSecretAtom);\n        const secretValue = globalStore.get(this.secretValueAtom);\n\n        if (!selectedSecret) {\n            return;\n        }\n\n        globalStore.set(this.isLoadingAtom, true);\n        globalStore.set(this.errorMessageAtom, null);\n\n        try {\n            await this.env.rpc.SetSecretsCommand(TabRpcClient, { [selectedSecret]: secretValue });\n            this.env.rpc.RecordTEventCommand(\n                TabRpcClient,\n                {\n                    event: \"action:other\",\n                    props: {\n                        \"action:type\": \"waveconfig:savesecret\",\n                    },\n                },\n                { noresponse: true }\n            );\n            this.closeSecretView();\n        } catch (error) {\n            globalStore.set(this.errorMessageAtom, `Failed to save secret: ${error.message}`);\n        } finally {\n            globalStore.set(this.isLoadingAtom, false);\n        }\n    }\n\n    async deleteSecret() {\n        const selectedSecret = globalStore.get(this.selectedSecretAtom);\n\n        if (!selectedSecret) {\n            return;\n        }\n\n        globalStore.set(this.isLoadingAtom, true);\n        globalStore.set(this.errorMessageAtom, null);\n\n        try {\n            await this.env.rpc.SetSecretsCommand(TabRpcClient, { [selectedSecret]: null });\n            this.closeSecretView();\n            await this.refreshSecrets();\n        } catch (error) {\n            globalStore.set(this.errorMessageAtom, `Failed to delete secret: ${error.message}`);\n        } finally {\n            globalStore.set(this.isLoadingAtom, false);\n        }\n    }\n\n    startAddingSecret() {\n        globalStore.set(this.isAddingNewAtom, true);\n        globalStore.set(this.newSecretNameAtom, \"\");\n        globalStore.set(this.newSecretValueAtom, \"\");\n        globalStore.set(this.errorMessageAtom, null);\n    }\n\n    cancelAddingSecret() {\n        globalStore.set(this.isAddingNewAtom, false);\n        globalStore.set(this.newSecretNameAtom, \"\");\n        globalStore.set(this.newSecretValueAtom, \"\");\n        globalStore.set(this.errorMessageAtom, null);\n    }\n\n    async addNewSecret() {\n        const name = globalStore.get(this.newSecretNameAtom).trim();\n        const value = globalStore.get(this.newSecretValueAtom);\n\n        if (!name) {\n            globalStore.set(this.errorMessageAtom, \"Secret name cannot be empty\");\n            return;\n        }\n\n        if (!SecretNameRegex.test(name)) {\n            globalStore.set(\n                this.errorMessageAtom,\n                \"Invalid secret name: must start with a letter and contain only letters, numbers, and underscores\"\n            );\n            return;\n        }\n\n        const existingNames = globalStore.get(this.secretNamesAtom);\n        if (existingNames.includes(name)) {\n            globalStore.set(this.errorMessageAtom, `Secret \"${name}\" already exists`);\n            return;\n        }\n\n        globalStore.set(this.isLoadingAtom, true);\n        globalStore.set(this.errorMessageAtom, null);\n\n        try {\n            await this.env.rpc.SetSecretsCommand(TabRpcClient, { [name]: value });\n            this.env.rpc.RecordTEventCommand(\n                TabRpcClient,\n                {\n                    event: \"action:other\",\n                    props: {\n                        \"action:type\": \"waveconfig:savesecret\",\n                    },\n                },\n                { noresponse: true }\n            );\n            globalStore.set(this.isAddingNewAtom, false);\n            globalStore.set(this.newSecretNameAtom, \"\");\n            globalStore.set(this.newSecretValueAtom, \"\");\n            await this.refreshSecrets();\n        } catch (error) {\n            globalStore.set(this.errorMessageAtom, `Failed to add secret: ${error.message}`);\n        } finally {\n            globalStore.set(this.isLoadingAtom, false);\n        }\n    }\n\n    giveFocus(): boolean {\n        const selectedFile = globalStore.get(this.selectedFileAtom);\n        if (selectedFile?.isSecrets && this.secretValueRef) {\n            this.secretValueRef.focus();\n            return true;\n        }\n        if (this.editorRef?.current) {\n            this.editorRef.current.focus();\n            return true;\n        }\n        return false;\n    }\n}\n"
  },
  {
    "path": "frontend/app/view/waveconfig/waveconfig.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Tooltip } from \"@/app/element/tooltip\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { tryReinjectKey } from \"@/app/store/keymodel\";\nimport { CodeEditor } from \"@/app/view/codeeditor/codeeditor\";\nimport type { ConfigFile, WaveConfigViewModel } from \"@/app/view/waveconfig/waveconfig-model\";\nimport type { WaveConfigEnv } from \"@/app/view/waveconfig/waveconfigenv\";\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport { adaptFromReactOrNativeKeyEvent, checkKeyPressed, keydownWrapper } from \"@/util/keyutil\";\nimport { cn } from \"@/util/util\";\nimport { useAtom, useAtomValue } from \"jotai\";\nimport type * as MonacoTypes from \"monaco-editor\";\nimport { memo, useCallback, useEffect } from \"react\";\n\ninterface ConfigSidebarProps {\n    model: WaveConfigViewModel;\n}\n\nconst ConfigSidebar = memo(({ model }: ConfigSidebarProps) => {\n    const selectedFile = useAtomValue(model.selectedFileAtom);\n    const [isMenuOpen, setIsMenuOpen] = useAtom(model.isMenuOpenAtom);\n    const configFiles = model.getConfigFiles();\n    const deprecatedConfigFiles = model.getDeprecatedConfigFiles();\n    const configErrorFiles = useAtomValue(model.configErrorFilesAtom);\n\n    const handleFileSelect = (file: ConfigFile) => {\n        model.loadFile(file);\n        setIsMenuOpen(false);\n    };\n\n    return (\n        <div className=\"flex flex-col w-48 border-r border-border @w600:h-full @max-w600:absolute @max-w600:left-0.5 @max-w600:top-0 @max-w600:bottom-0.5 @max-w600:z-10 @max-w600:bg-background @max-w600:shadow-xl @max-w600:rounded-bl\">\n            <div className=\"flex items-center justify-between px-4 py-2 border-b border-border @w600:hidden\">\n                <span className=\"font-semibold\">Config Files</span>\n                <button\n                    onClick={() => setIsMenuOpen(false)}\n                    className=\"hover:bg-secondary/50 rounded p-1 cursor-pointer transition-colors\"\n                >\n                    ✕\n                </button>\n            </div>\n            {configFiles.map((file) => (\n                <div\n                    key={file.path}\n                    onClick={() => handleFileSelect(file)}\n                    className={`px-4 py-2 border-b border-border cursor-pointer transition-colors ${\n                        selectedFile?.path === file.path ? \"bg-accentbg text-primary\" : \"hover:bg-secondary/50\"\n                    }`}\n                >\n                    <div className=\"flex items-center gap-1\">\n                        <div className=\"whitespace-nowrap overflow-hidden text-ellipsis flex-1\">{file.name}</div>\n                        {configErrorFiles.has(file.path) && (\n                            <i className=\"fa fa-solid fa-circle-exclamation text-error text-[14px] shrink-0\" />\n                        )}\n                    </div>\n                    {file.description && (\n                        <div className=\"text-xs text-muted mt-0.5 whitespace-nowrap overflow-hidden text-ellipsis\">\n                            {file.description}\n                        </div>\n                    )}\n                </div>\n            ))}\n            {deprecatedConfigFiles.length > 0 && (\n                <>\n                    {deprecatedConfigFiles.map((file) => (\n                        <div\n                            key={file.path}\n                            onClick={() => handleFileSelect(file)}\n                            className={`px-4 py-2 border-b border-border cursor-pointer transition-colors ${\n                                selectedFile?.path === file.path ? \"bg-accentbg text-primary\" : \"hover:bg-secondary/50\"\n                            }`}\n                        >\n                            <div className=\"flex items-center gap-2 overflow-hidden\">\n                                <span className=\"text-secondary truncate\">{file.name}</span>\n                                <span\n                                    className={`text-xs px-1.5 py-0.5 rounded shrink-0 ${\n                                        selectedFile?.path === file.path\n                                            ? \"text-primary/80 bg-secondary/50\"\n                                            : \"text-muted-foreground/70 bg-secondary/30\"\n                                    }`}\n                                >\n                                    deprecated\n                                </span>\n                                {configErrorFiles.has(file.path) && (\n                                    <i className=\"fa fa-solid fa-circle-exclamation text-error text-[14px] ml-auto shrink-0\" />\n                                )}\n                            </div>\n                        </div>\n                    ))}\n                </>\n            )}\n        </div>\n    );\n});\n\nConfigSidebar.displayName = \"ConfigSidebar\";\n\nconst WaveConfigView = memo(({ blockId, model }: ViewComponentProps<WaveConfigViewModel>) => {\n    const env = useWaveEnv<WaveConfigEnv>();\n    const selectedFile = useAtomValue(model.selectedFileAtom);\n    const [fileContent, setFileContent] = useAtom(model.fileContentAtom);\n    const isLoading = useAtomValue(model.isLoadingAtom);\n    const isSaving = useAtomValue(model.isSavingAtom);\n    const errorMessage = useAtomValue(model.errorMessageAtom);\n    const validationError = useAtomValue(model.validationErrorAtom);\n    const [isMenuOpen, setIsMenuOpen] = useAtom(model.isMenuOpenAtom);\n    const hasChanges = useAtomValue(model.hasEditedAtom);\n    const [activeTab, setActiveTab] = useAtom(model.activeTabAtom);\n    const fullConfig = useAtomValue(env.atoms.fullConfigAtom);\n    const configErrors = fullConfig?.configerrors;\n\n    const handleContentChange = useCallback(\n        (newContent: string) => {\n            setFileContent(newContent);\n            model.markAsEdited();\n        },\n        [setFileContent, model]\n    );\n\n    const handleEditorMount = useCallback(\n        (editor: MonacoTypes.editor.IStandaloneCodeEditor) => {\n            model.editorRef.current = editor;\n\n            const keyDownDisposer = editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => {\n                const waveEvent = adaptFromReactOrNativeKeyEvent(e.browserEvent);\n                const handled = tryReinjectKey(waveEvent);\n                if (handled) {\n                    e.stopPropagation();\n                    e.preventDefault();\n                }\n            });\n\n            const isFocused = globalStore.get(model.nodeModel.isFocused);\n            if (isFocused) {\n                editor.focus();\n            }\n            return () => {\n                keyDownDisposer.dispose();\n                model.editorRef.current = null;\n            };\n        },\n        [model]\n    );\n\n    useEffect(() => {\n        const handleKeyDown = keydownWrapper((e: WaveKeyboardEvent) => {\n            if (checkKeyPressed(e, \"Cmd:s\")) {\n                if (hasChanges && !isSaving) {\n                    model.saveFile();\n                }\n                return true;\n            }\n            return false;\n        });\n\n        window.addEventListener(\"keydown\", handleKeyDown);\n        return () => window.removeEventListener(\"keydown\", handleKeyDown);\n    }, [hasChanges, isSaving, model]);\n\n    const saveTooltip = `Save (${model.saveShortcut})`;\n\n    return (\n        <div className=\"@container flex flex-col w-full h-full\">\n            <div className=\"flex flex-row flex-1 min-h-0\">\n            {isMenuOpen && (\n                <div className=\"absolute inset-0 bg-black/50 z-5 @w600:hidden\" onClick={() => setIsMenuOpen(false)} />\n            )}\n            <div className={`h-full ${isMenuOpen ? \"\" : \"@max-w600:hidden\"}`}>\n                <ConfigSidebar model={model} />\n            </div>\n            <div className=\"flex flex-col flex-1 min-w-0\">\n                {selectedFile && (\n                    <>\n                        <div className=\"flex flex-row items-center justify-between px-4 py-2 border-b border-border\">\n                            <div className=\"flex items-baseline gap-2 min-w-0\">\n                                <button\n                                    onClick={() => setIsMenuOpen(true)}\n                                    className=\"@w600:hidden hover:bg-secondary/50 rounded p-1 cursor-pointer transition-colors mr-2 shrink-0\"\n                                >\n                                    <i className=\"fa fa-bars\" />\n                                </button>\n                                <div className=\"text-lg font-semibold whitespace-nowrap shrink-0\">\n                                    {selectedFile.name}\n                                </div>\n                                {selectedFile.docsUrl && (\n                                    <Tooltip content=\"View documentation\">\n                                        <a\n                                            href={`${selectedFile.docsUrl}?ref=waveconfig`}\n                                            target=\"_blank\"\n                                            rel=\"noopener noreferrer\"\n                                            className=\"!text-muted-foreground hover:!text-primary transition-colors ml-1 shrink-0 cursor-pointer\"\n                                        >\n                                            <i className=\"fa fa-book text-sm\" />\n                                        </a>\n                                    </Tooltip>\n                                )}\n                                <div className=\"text-xs text-muted-foreground font-mono pb-0.5 ml-1 truncate @max-w450:hidden\">\n                                    {selectedFile.path}\n                                </div>\n                            </div>\n                            <div className=\"flex gap-2 items-baseline shrink-0\">\n                                {selectedFile.hasJsonView && (\n                                    <>\n                                        {hasChanges && (\n                                            <span className=\"text-xs text-warning pb-0.5 @max-w450:hidden\">\n                                                Unsaved changes\n                                            </span>\n                                        )}\n                                        <Tooltip content={saveTooltip} placement=\"bottom\" divClassName=\"shrink-0\">\n                                            <button\n                                                onClick={() => model.saveFile()}\n                                                disabled={!hasChanges || isSaving}\n                                                className={`px-3 py-1 rounded transition-colors text-sm ${\n                                                    !hasChanges || isSaving\n                                                        ? \"border border-border text-muted-foreground opacity-50\"\n                                                        : \"bg-accent/80 text-primary hover:bg-accent cursor-pointer\"\n                                                }`}\n                                            >\n                                                {isSaving ? \"Saving...\" : \"Save\"}\n                                            </button>\n                                        </Tooltip>\n                                    </>\n                                )}\n                            </div>\n                        </div>\n                        {selectedFile.visualComponent && selectedFile.hasJsonView && (\n                            <div className=\"flex gap-0 border-b border-border\">\n                                <button\n                                    onClick={() => setActiveTab(\"visual\")}\n                                    className={cn(\n                                        \"px-4 pt-1 pb-1.5 cursor-pointer transition-colors text-secondary\",\n                                        activeTab === \"visual\"\n                                            ? \"bg-highlightbg text-primary\"\n                                            : \"bg-transparent hover:bg-hover\"\n                                    )}\n                                >\n                                    Visual\n                                </button>\n                                <button\n                                    onClick={() => setActiveTab(\"json\")}\n                                    className={cn(\n                                        \"px-4 pt-1 pb-1.5 cursor-pointer transition-colors text-secondary\",\n                                        activeTab === \"json\"\n                                            ? \"bg-highlightbg text-primary\"\n                                            : \"bg-transparent hover:bg-hover\"\n                                    )}\n                                >\n                                    Raw JSON\n                                </button>\n                            </div>\n                        )}\n                        {errorMessage && (\n                            <div className=\"bg-error text-primary px-4 py-2 border-b border-error flex items-center justify-between\">\n                                <span>{errorMessage}</span>\n                                <button\n                                    onClick={() => model.clearError()}\n                                    className=\"ml-2 hover:bg-black/20 rounded p-1 cursor-pointer transition-colors\"\n                                >\n                                    ✕\n                                </button>\n                            </div>\n                        )}\n                        {validationError && (\n                            <div className=\"bg-error text-primary px-4 py-2 border-b border-error flex items-center justify-between\">\n                                <span>{validationError}</span>\n                                <button\n                                    onClick={() => model.clearValidationError()}\n                                    className=\"ml-2 hover:bg-black/20 rounded p-1 cursor-pointer transition-colors\"\n                                >\n                                    ✕\n                                </button>\n                            </div>\n                        )}\n                        <div className=\"flex-1 overflow-hidden\">\n                            {isLoading ? (\n                                <div className=\"flex items-center justify-center h-full text-muted-foreground\">\n                                    Loading...\n                                </div>\n                            ) : selectedFile.visualComponent &&\n                              (!selectedFile.hasJsonView || activeTab === \"visual\") ? (\n                                (() => {\n                                    const VisualComponent = selectedFile.visualComponent;\n                                    return <VisualComponent model={model} />;\n                                })()\n                            ) : (\n                                <CodeEditor\n                                    blockId={blockId}\n                                    text={fileContent}\n                                    fileName={`WAVECONFIGPATH/${selectedFile.path}`}\n                                    language={selectedFile.language}\n                                    readonly={false}\n                                    onChange={handleContentChange}\n                                    onMount={handleEditorMount}\n                                />\n                            )}\n                        </div>\n                    </>\n                )}\n            </div>\n            </div>\n            {configErrors?.length > 0 && (\n                <div className=\"bg-error text-primary px-4 py-1 max-h-12 overflow-y-auto border-t border-error/50 shrink-0\">\n                    {configErrors.map((cerr, i) => (\n                        <div key={i} className=\"text-sm\">\n                            <span className=\"font-semibold\">Config Error: </span>\n                            {cerr.file}: {cerr.err}\n                        </div>\n                    ))}\n                </div>\n            )}\n        </div>\n    );\n});\n\nWaveConfigView.displayName = \"WaveConfigView\";\n\nexport { WaveConfigView };\n"
  },
  {
    "path": "frontend/app/view/waveconfig/waveconfigenv.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { BlockMetaKeyAtomFnType, WaveEnv, WaveEnvSubset } from \"@/app/waveenv/waveenv\";\n\nexport type WaveConfigEnv = WaveEnvSubset<{\n    electron: {\n        getConfigDir: WaveEnv[\"electron\"][\"getConfigDir\"];\n        getPlatform: WaveEnv[\"electron\"][\"getPlatform\"];\n    };\n    rpc: {\n        FileInfoCommand: WaveEnv[\"rpc\"][\"FileInfoCommand\"];\n        FileReadCommand: WaveEnv[\"rpc\"][\"FileReadCommand\"];\n        FileWriteCommand: WaveEnv[\"rpc\"][\"FileWriteCommand\"];\n        SetMetaCommand: WaveEnv[\"rpc\"][\"SetMetaCommand\"];\n        GetSecretsLinuxStorageBackendCommand: WaveEnv[\"rpc\"][\"GetSecretsLinuxStorageBackendCommand\"];\n        GetSecretsNamesCommand: WaveEnv[\"rpc\"][\"GetSecretsNamesCommand\"];\n        GetSecretsCommand: WaveEnv[\"rpc\"][\"GetSecretsCommand\"];\n        SetSecretsCommand: WaveEnv[\"rpc\"][\"SetSecretsCommand\"];\n        RecordTEventCommand: WaveEnv[\"rpc\"][\"RecordTEventCommand\"];\n    };\n    atoms: {\n        fullConfigAtom: WaveEnv[\"atoms\"][\"fullConfigAtom\"];\n    };\n    getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<\"file\">;\n    isWindows: WaveEnv[\"isWindows\"];\n}>;\n"
  },
  {
    "path": "frontend/app/view/webview/webview.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.webview,\n.webview-container {\n    height: 100%;\n    width: 100%;\n    border: none !important;\n    outline: none !important;\n    overflow: hidden;\n    padding: 0;\n    margin: 0;\n    user-select: none;\n    border-radius: 0 0 var(--block-border-radius) var(--block-border-radius);\n\n    // try to force pixel alignment to prevent\n    // subpixel rendering artifacts\n    transform: translate3d(0, 0, 0);\n    will-change: transform;\n}\n\n.webview-error {\n    display: flex;\n    position: absolute;\n    background-color: black;\n    top: 0;\n    left: 0;\n    height: 100%;\n    width: 100%;\n    z-index: 100;\n    div {\n        font-size: x-large;\n        color: var(--error-color);\n        display: flex;\n        margin: auto;\n        padding: 30px;\n    }\n}\n\n.block-frame-div-url {\n    background: rgba(255, 255, 255, 0.1);\n\n    input {\n        opacity: 1;\n    }\n\n    .wave-iconbutton {\n        width: fit-content !important;\n        margin-right: 5px;\n    }\n}\n"
  },
  {
    "path": "frontend/app/view/webview/webview.test.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { makeMockWaveEnv } from \"@/preview/mock/mockwaveenv\";\nimport { renderToStaticMarkup } from \"react-dom/server\";\nimport { describe, expect, it } from \"vitest\";\nimport { atom } from \"jotai\";\nimport { getWebPreviewDisplayUrl, WebViewModel, WebViewPreviewFallback } from \"./webview\";\n\ndescribe(\"webview preview fallback\", () => {\n    it(\"shows the requested URL\", () => {\n        const markup = renderToStaticMarkup(<WebViewPreviewFallback url=\"https://waveterm.dev/docs\" />);\n\n        expect(markup).toContain(\"electron webview unavailable\");\n        expect(markup).toContain(\"https://waveterm.dev/docs\");\n    });\n\n    it(\"falls back to about:blank when no URL is available\", () => {\n        expect(getWebPreviewDisplayUrl(\"\")).toBe(\"about:blank\");\n        expect(getWebPreviewDisplayUrl(null)).toBe(\"about:blank\");\n    });\n\n    it(\"uses the supplied env for homepage atoms and config updates\", async () => {\n        const blockId = \"webview-env-block\";\n        const env = makeMockWaveEnv({\n            settings: {\n                \"web:defaulturl\": \"https://default.example\",\n            },\n            mockWaveObjs: {\n                [`block:${blockId}`]: {\n                    otype: \"block\",\n                    oid: blockId,\n                    version: 1,\n                    meta: {\n                        pinnedurl: \"https://block.example\",\n                    },\n                } as Block,\n            },\n        });\n        const model = new WebViewModel({\n            blockId,\n            nodeModel: {\n                isFocused: atom(true),\n                focusNode: () => {},\n            } as any,\n            tabModel: {} as any,\n            waveEnv: env,\n        });\n\n        expect(globalStore.get(model.homepageUrl)).toBe(\"https://block.example\");\n\n        await model.setHomepageUrl(\"https://global.example\", \"global\");\n\n        expect(globalStore.get(model.homepageUrl)).toBe(\"https://global.example\");\n        expect(globalStore.get(env.getSettingsKeyAtom(\"web:defaulturl\"))).toBe(\"https://global.example\");\n        expect(globalStore.get(env.wos.getWaveObjectAtom<Block>(`block:${blockId}`))?.meta?.pinnedurl).toBeUndefined();\n    });\n});\n"
  },
  {
    "path": "frontend/app/view/webview/webview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { BlockNodeModel } from \"@/app/block/blocktypes\";\nimport { Search, useSearch } from \"@/app/element/search\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { getSimpleControlShiftAtom } from \"@/app/store/keymodel\";\nimport type { TabModel } from \"@/app/store/tab-model\";\nimport { makeORef } from \"@/app/store/wos\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport {\n    BlockHeaderSuggestionControl,\n    SuggestionControlNoData,\n    SuggestionControlNoResults,\n} from \"@/app/suggestion/suggestion\";\nimport { MockBoundary } from \"@/app/waveenv/mockboundary\";\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport { openLink } from \"@/store/global\";\nimport { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from \"@/util/keyutil\";\nimport { fireAndForget, useAtomValueSafe } from \"@/util/util\";\nimport clsx from \"clsx\";\nimport { WebviewTag } from \"electron\";\nimport { Atom, PrimitiveAtom, atom, useAtomValue, useSetAtom } from \"jotai\";\nimport { Fragment, createRef, memo, useCallback, useEffect, useRef, useState } from \"react\";\nimport \"./webview.scss\";\nimport type { WebViewEnv } from \"./webviewenv\";\n\n// User agent strings for mobile emulation\nconst USER_AGENT_IPHONE =\n    \"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1\";\nconst USER_AGENT_ANDROID =\n    \"Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Mobile Safari/537.36\";\n\nlet webviewPreloadUrl = null;\n\nfunction getWebviewPreloadUrl(env: WebViewEnv) {\n    if (webviewPreloadUrl == null) {\n        webviewPreloadUrl = env.electron.getWebviewPreload();\n        console.log(\"webviewPreloadUrl\", webviewPreloadUrl);\n    }\n    if (webviewPreloadUrl == null) {\n        return null;\n    }\n    return \"file://\" + webviewPreloadUrl;\n}\n\nexport class WebViewModel implements ViewModel {\n    viewType: string;\n    blockId: string;\n    tabModel: TabModel;\n    noPadding?: Atom<boolean>;\n    blockAtom: Atom<Block>;\n    viewIcon: Atom<string | IconButtonDecl>;\n    viewName: Atom<string>;\n    viewText: Atom<HeaderElem[]>;\n    hideViewName: Atom<boolean>;\n    url: PrimitiveAtom<string>;\n    homepageUrl: Atom<string>;\n    urlInputFocused: PrimitiveAtom<boolean>;\n    isLoading: PrimitiveAtom<boolean>;\n    urlWrapperClassName: PrimitiveAtom<string>;\n    refreshIcon: PrimitiveAtom<string>;\n    webviewRef: React.RefObject<WebviewTag>;\n    urlInputRef: React.RefObject<HTMLInputElement>;\n    nodeModel: BlockNodeModel;\n    endIconButtons?: Atom<IconButtonDecl[]>;\n    mediaPlaying: PrimitiveAtom<boolean>;\n    mediaMuted: PrimitiveAtom<boolean>;\n    modifyExternalUrl?: (url: string) => string;\n    domReady: PrimitiveAtom<boolean>;\n    hideNav: Atom<boolean>;\n    searchAtoms?: SearchAtoms;\n    typeaheadOpen: PrimitiveAtom<boolean>;\n    partitionOverride: PrimitiveAtom<string> | null;\n    userAgentType: Atom<string>;\n    env: WebViewEnv;\n\n    constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) {\n        this.nodeModel = nodeModel;\n        this.tabModel = tabModel;\n        this.viewType = \"web\";\n        this.blockId = blockId;\n        this.env = waveEnv;\n        this.noPadding = atom(true);\n        this.blockAtom = this.env.wos.getWaveObjectAtom<Block>(`block:${blockId}`);\n        this.url = atom();\n        const defaultUrlAtom = this.env.getSettingsKeyAtom(\"web:defaulturl\");\n        this.homepageUrl = atom((get) => {\n            const defaultUrl = get(defaultUrlAtom);\n            const pinnedUrl = get(this.blockAtom)?.meta?.pinnedurl;\n            return pinnedUrl ?? defaultUrl;\n        });\n        this.urlWrapperClassName = atom(\"\");\n        this.urlInputFocused = atom(false);\n        this.isLoading = atom(false);\n        this.refreshIcon = atom(\"rotate-right\");\n        this.viewIcon = atom(\"globe\");\n        this.viewName = atom(\"Web\");\n        this.hideViewName = atom(true);\n        this.urlInputRef = createRef<HTMLInputElement>();\n        this.webviewRef = createRef<WebviewTag>();\n        this.domReady = atom(false);\n        this.hideNav = this.env.getBlockMetaKeyAtom(blockId, \"web:hidenav\");\n        this.typeaheadOpen = atom(false);\n        this.partitionOverride = null;\n        this.userAgentType = this.env.getBlockMetaKeyAtom(blockId, \"web:useragenttype\");\n\n        this.mediaPlaying = atom(false);\n        this.mediaMuted = atom(false);\n\n        this.viewText = atom((get) => {\n            const homepageUrl = get(this.homepageUrl);\n            const metaUrl = get(this.blockAtom)?.meta?.url;\n            const currUrl = get(this.url);\n            const urlWrapperClassName = get(this.urlWrapperClassName);\n            const refreshIcon = get(this.refreshIcon);\n            const mediaPlaying = get(this.mediaPlaying);\n            const mediaMuted = get(this.mediaMuted);\n            const url = currUrl ?? metaUrl ?? homepageUrl ?? \"\";\n            const rtn: HeaderElem[] = [];\n            if (get(this.hideNav)) {\n                return rtn;\n            }\n\n            rtn.push({\n                elemtype: \"iconbutton\",\n                icon: \"chevron-left\",\n                click: this.handleBack.bind(this),\n                disabled: this.shouldDisableBackButton(),\n            });\n            rtn.push({\n                elemtype: \"iconbutton\",\n                icon: \"chevron-right\",\n                click: this.handleForward.bind(this),\n                disabled: this.shouldDisableForwardButton(),\n            });\n            rtn.push({\n                elemtype: \"iconbutton\",\n                icon: \"house\",\n                click: this.handleHome.bind(this),\n                disabled: this.shouldDisableHomeButton(),\n            });\n            const divChildren: HeaderElem[] = [];\n            divChildren.push({\n                elemtype: \"input\",\n                value: url,\n                ref: this.urlInputRef,\n                className: \"url-input\",\n                onChange: this.handleUrlChange.bind(this),\n                onKeyDown: this.handleKeyDown.bind(this),\n                onFocus: this.handleFocus.bind(this),\n                onBlur: this.handleBlur.bind(this),\n            });\n            if (mediaPlaying) {\n                divChildren.push({\n                    elemtype: \"iconbutton\",\n                    icon: mediaMuted ? \"volume-slash\" : \"volume\",\n                    click: this.handleMuteChange.bind(this),\n                });\n            }\n            divChildren.push({\n                elemtype: \"iconbutton\",\n                icon: refreshIcon,\n                click: this.handleRefresh.bind(this),\n            });\n            rtn.push({\n                elemtype: \"div\",\n                className: clsx(\"block-frame-div-url\", urlWrapperClassName),\n                onMouseOver: this.handleUrlWrapperMouseOver.bind(this),\n                onMouseOut: this.handleUrlWrapperMouseOut.bind(this),\n                children: divChildren,\n            });\n            return rtn;\n        });\n\n        this.endIconButtons = atom((get) => {\n            if (get(this.hideNav)) {\n                return null;\n            }\n            const url = get(this.url);\n            const userAgentType = get(this.userAgentType);\n            const buttons: IconButtonDecl[] = [];\n\n            // Add mobile indicator icon if using mobile user agent\n            if (userAgentType === \"mobile:iphone\" || userAgentType === \"mobile:android\") {\n                const mobileIcon = userAgentType === \"mobile:iphone\" ? \"mobile-screen\" : \"mobile-screen-button\";\n                const mobileTitle =\n                    userAgentType === \"mobile:iphone\" ? \"Mobile User Agent: iPhone\" : \"Mobile User Agent: Android\";\n                buttons.push({\n                    elemtype: \"iconbutton\",\n                    icon: mobileIcon,\n                    title: mobileTitle,\n                    noAction: true,\n                });\n            }\n\n            buttons.push({\n                elemtype: \"iconbutton\",\n                icon: \"arrow-up-right-from-square\",\n                title: \"Open in External Browser\",\n                click: () => {\n                    console.log(\"open external\", url);\n                    if (url != null && url != \"\") {\n                        const externalUrl = this.modifyExternalUrl?.(url) ?? url;\n                        return this.env.electron.openExternal(externalUrl);\n                    }\n                },\n            });\n\n            return buttons;\n        });\n    }\n\n    get viewComponent(): ViewComponent {\n        return WebView;\n    }\n\n    /**\n     * Whether the back button in the header should be disabled.\n     * @returns True if the WebView cannot go back or if the WebView call fails. False otherwise.\n     */\n    shouldDisableBackButton() {\n        try {\n            return !this.webviewRef.current?.canGoBack();\n        } catch (_) {}\n        return true;\n    }\n\n    /**\n     * Whether the forward button in the header should be disabled.\n     * @returns True if the WebView cannot go forward or if the WebView call fails. False otherwise.\n     */\n    shouldDisableForwardButton() {\n        try {\n            return !this.webviewRef.current?.canGoForward();\n        } catch (_) {}\n        return true;\n    }\n\n    /**\n     * Whether the home button in the header should be disabled.\n     * @returns True if the current url is the pinned url or the pinned url is not set. False otherwise.\n     */\n    shouldDisableHomeButton() {\n        try {\n            const homepageUrl = globalStore.get(this.homepageUrl);\n            return !homepageUrl || this.getUrl() === homepageUrl;\n        } catch (_) {}\n        return true;\n    }\n\n    handleHome(e?: React.MouseEvent<HTMLDivElement, MouseEvent>) {\n        if (e) {\n            e.preventDefault();\n            e.stopPropagation();\n        }\n        this.loadUrl(globalStore.get(this.homepageUrl), \"home\");\n    }\n\n    setMediaPlaying(isPlaying: boolean) {\n        globalStore.set(this.mediaPlaying, isPlaying);\n    }\n\n    handleMuteChange(e: React.ChangeEvent<HTMLInputElement>) {\n        if (e) {\n            e.preventDefault();\n            e.stopPropagation();\n        }\n        try {\n            const newMutedVal = !this.webviewRef.current?.isAudioMuted();\n            globalStore.set(this.mediaMuted, newMutedVal);\n            this.webviewRef.current?.setAudioMuted(newMutedVal);\n        } catch (e) {\n            console.error(\"Failed to change mute value\", e);\n        }\n    }\n\n    setTypeaheadOpen(open: boolean) {\n        globalStore.set(this.typeaheadOpen, open);\n    }\n\n    async fetchBookmarkSuggestions(\n        query: string,\n        reqContext: SuggestionRequestContext\n    ): Promise<FetchSuggestionsResponse> {\n        const result = await this.env.rpc.FetchSuggestionsCommand(TabRpcClient, {\n            suggestiontype: \"bookmark\",\n            query,\n            widgetid: reqContext.widgetid,\n            reqnum: reqContext.reqnum,\n        });\n        return result;\n    }\n\n    handleUrlWrapperMouseOver(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {\n        const urlInputFocused = globalStore.get(this.urlInputFocused);\n        if (e.type === \"mouseover\" && !urlInputFocused) {\n            globalStore.set(this.urlWrapperClassName, \"hovered\");\n        }\n    }\n\n    handleUrlWrapperMouseOut(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {\n        const urlInputFocused = globalStore.get(this.urlInputFocused);\n        if (e.type === \"mouseout\" && !urlInputFocused) {\n            globalStore.set(this.urlWrapperClassName, \"\");\n        }\n    }\n\n    handleBack(e?: React.MouseEvent<HTMLDivElement, MouseEvent>) {\n        if (e) {\n            e.preventDefault();\n            e.stopPropagation();\n        }\n        this.webviewRef.current?.goBack();\n    }\n\n    handleForward(e?: React.MouseEvent<HTMLDivElement, MouseEvent>) {\n        if (e) {\n            e.preventDefault();\n            e.stopPropagation();\n        }\n        this.webviewRef.current?.goForward();\n    }\n\n    handleRefresh(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {\n        e.preventDefault();\n        e.stopPropagation();\n        try {\n            if (this.webviewRef.current) {\n                if (globalStore.get(this.isLoading)) {\n                    this.webviewRef.current.stop();\n                } else {\n                    this.webviewRef.current.reload();\n                }\n            }\n        } catch (e) {\n            console.warn(\"handleRefresh catch\", e);\n        }\n    }\n\n    handleUrlChange(event: React.ChangeEvent<HTMLInputElement>) {\n        globalStore.set(this.url, event.target.value);\n    }\n\n    handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n        const waveEvent = adaptFromReactOrNativeKeyEvent(event);\n        if (checkKeyPressed(waveEvent, \"Enter\")) {\n            const url = globalStore.get(this.url);\n            this.loadUrl(url, \"enter\");\n            this.urlInputRef.current?.blur();\n            return;\n        }\n        if (checkKeyPressed(waveEvent, \"Escape\")) {\n            this.webviewRef.current?.focus();\n        }\n    }\n\n    handleFocus(event: React.FocusEvent<HTMLInputElement>) {\n        globalStore.set(this.urlWrapperClassName, \"focused\");\n        globalStore.set(this.urlInputFocused, true);\n        this.urlInputRef.current.focus();\n        event.target.select();\n    }\n\n    handleBlur(event: React.FocusEvent<HTMLInputElement>) {\n        globalStore.set(this.urlWrapperClassName, \"\");\n        globalStore.set(this.urlInputFocused, false);\n    }\n\n    /**\n     * Update the URL in the state when a navigation event has occurred.\n     * @param url The URL that has been navigated to.\n     */\n    handleNavigate(url: string) {\n        fireAndForget(() =>\n            this.env.rpc.SetMetaCommand(TabRpcClient, {\n                oref: makeORef(\"block\", this.blockId),\n                meta: { url },\n            })\n        );\n        globalStore.set(this.url, url);\n        if (this.searchAtoms) {\n            globalStore.set(this.searchAtoms.isOpen, false);\n        }\n    }\n\n    ensureUrlScheme(url: string, searchTemplate: string) {\n        if (url == null) {\n            url = \"\";\n        }\n\n        if (/^(http|https|file):/.test(url)) {\n            // If the URL starts with http: or https:, return it as is\n            return url;\n        }\n\n        // Check if the URL looks like a local URL\n        const isLocal = /^(localhost|(\\d{1,3}\\.){3}\\d{1,3})(:\\d+)?$/.test(url.split(\"/\")[0]);\n\n        if (isLocal) {\n            // If it is a local URL, ensure it has http:// scheme\n            return `http://${url}`;\n        }\n\n        // Check if the URL looks like a domain\n        const domainRegex = /^[a-z0-9.-]+\\.[a-z]{2,}$/i;\n        const isDomain = domainRegex.test(url.split(\"/\")[0]);\n\n        if (isDomain) {\n            // If it looks like a domain, ensure it has https:// scheme\n            return `https://${url}`;\n        }\n\n        // Otherwise, treat it as a search query\n        if (searchTemplate == null) {\n            return `https://www.google.com/search?q=${encodeURIComponent(url)}`;\n        }\n        return searchTemplate.replace(\"{query}\", encodeURIComponent(url));\n    }\n\n    /**\n     * Load a new URL in the webview.\n     * @param newUrl The new URL to load in the webview.\n     */\n    loadUrl(newUrl: string, reason: string) {\n        const defaultSearchAtom = this.env.getSettingsKeyAtom(\"web:defaultsearch\");\n        const searchTemplate = globalStore.get(defaultSearchAtom);\n        const nextUrl = this.ensureUrlScheme(newUrl, searchTemplate);\n        console.log(\"webview loadUrl\", reason, nextUrl, \"cur=\", this.webviewRef.current.getURL());\n        if (!this.webviewRef.current) {\n            return;\n        }\n        if (this.webviewRef.current.getURL() != nextUrl) {\n            fireAndForget(() => this.webviewRef.current.loadURL(nextUrl));\n        }\n        if (newUrl != nextUrl) {\n            globalStore.set(this.url, nextUrl);\n        }\n    }\n\n    /**\n     * Load a new URL in the webview and return a promise.\n     * @param newUrl The new URL to load in the webview.\n     * @param reason The reason for loading the URL.\n     * @returns Promise that resolves when the URL is loaded.\n     */\n    loadUrlPromise(newUrl: string, reason: string): Promise<void> {\n        const defaultSearchAtom = this.env.getSettingsKeyAtom(\"web:defaultsearch\");\n        const searchTemplate = globalStore.get(defaultSearchAtom);\n        const nextUrl = this.ensureUrlScheme(newUrl, searchTemplate);\n        console.log(\"webview loadUrlPromise\", reason, nextUrl, \"cur=\", this.webviewRef.current?.getURL());\n\n        if (!this.webviewRef.current) {\n            return Promise.reject(new Error(\"WebView ref not available\"));\n        }\n\n        if (newUrl != nextUrl) {\n            globalStore.set(this.url, nextUrl);\n        }\n\n        if (this.webviewRef.current.getURL() != nextUrl) {\n            return this.webviewRef.current.loadURL(nextUrl);\n        }\n\n        return Promise.resolve();\n    }\n\n    /**\n     * Get the current URL from the state.\n     * @returns The URL from the state.\n     */\n    getUrl() {\n        return globalStore.get(this.url);\n    }\n\n    setRefreshIcon(refreshIcon: string) {\n        globalStore.set(this.refreshIcon, refreshIcon);\n    }\n\n    setIsLoading(isLoading: boolean) {\n        globalStore.set(this.isLoading, isLoading);\n    }\n\n    async setHomepageUrl(url: string, scope: \"global\" | \"block\") {\n        if (url != null && url != \"\") {\n            switch (scope) {\n                case \"block\":\n                    await this.env.rpc.SetMetaCommand(TabRpcClient, {\n                        oref: makeORef(\"block\", this.blockId),\n                        meta: { pinnedurl: url },\n                    });\n                    break;\n                case \"global\":\n                    await this.env.rpc.SetMetaCommand(TabRpcClient, {\n                        oref: makeORef(\"block\", this.blockId),\n                        meta: { pinnedurl: null },\n                    });\n                    await this.env.rpc.SetConfigCommand(TabRpcClient, { \"web:defaulturl\": url });\n                    break;\n            }\n        }\n    }\n\n    giveFocus(): boolean {\n        console.log(\"webview giveFocus\");\n        if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) {\n            console.log(\"search is open, not giving focus\");\n            return true;\n        }\n        const ctrlShiftState = globalStore.get(getSimpleControlShiftAtom());\n        if (ctrlShiftState) {\n            // this is really weird, we don't get keyup events from webview\n            const unsubFn = globalStore.sub(getSimpleControlShiftAtom(), () => {\n                const state = globalStore.get(getSimpleControlShiftAtom());\n                if (!state) {\n                    unsubFn();\n                    const isStillFocused = globalStore.get(this.nodeModel.isFocused);\n                    if (isStillFocused) {\n                        this.webviewRef.current?.focus();\n                    }\n                }\n            });\n            return false;\n        }\n        this.webviewRef.current?.focus();\n        return true;\n    }\n\n    copyUrlToClipboard() {\n        const url = this.getUrl();\n        if (url != null && url != \"\") {\n            fireAndForget(() => navigator.clipboard.writeText(url));\n        }\n    }\n\n    clearHistory() {\n        try {\n            this.webviewRef.current?.clearHistory();\n        } catch (e) {\n            console.error(\"Failed to clear history\", e);\n        }\n    }\n\n    async clearCookiesAndStorage() {\n        try {\n            const webContentsId = this.webviewRef.current?.getWebContentsId();\n            if (webContentsId) {\n                await this.env.electron.clearWebviewStorage(webContentsId);\n            }\n        } catch (e) {\n            console.error(\"Failed to clear cookies and storage\", e);\n        }\n    }\n\n    keyDownHandler(e: WaveKeyboardEvent): boolean {\n        if (checkKeyPressed(e, \"Cmd:l\")) {\n            this.urlInputRef?.current?.focus();\n            this.urlInputRef?.current?.select();\n            return true;\n        }\n        if (checkKeyPressed(e, \"Cmd:r\")) {\n            this.webviewRef.current?.reload();\n            return true;\n        }\n        if (checkKeyPressed(e, \"Cmd:ArrowLeft\")) {\n            this.handleBack(null);\n            return true;\n        }\n        if (checkKeyPressed(e, \"Cmd:ArrowRight\")) {\n            this.handleForward(null);\n            return true;\n        }\n        if (checkKeyPressed(e, \"Cmd:o\")) {\n            const curVal = globalStore.get(this.typeaheadOpen);\n            globalStore.set(this.typeaheadOpen, !curVal);\n            return true;\n        }\n        return false;\n    }\n\n    setZoomFactor(factor: number | null) {\n        // null is ok (will reset to default)\n        if (factor != null && factor < 0.1) {\n            factor = 0.1;\n        }\n        if (factor != null && factor > 5) {\n            factor = 5;\n        }\n        const domReady = globalStore.get(this.domReady);\n        if (!domReady) {\n            return;\n        }\n        this.webviewRef.current?.setZoomFactor(factor || 1);\n        this.env.rpc.SetMetaCommand(TabRpcClient, {\n            oref: makeORef(\"block\", this.blockId),\n            meta: { \"web:zoom\": factor }, // allow null so we can remove the zoom factor here\n        });\n    }\n\n    getSettingsMenuItems(): ContextMenuItem[] {\n        const zoomSubMenu: ContextMenuItem[] = [];\n        let curZoom = 1;\n        if (globalStore.get(this.domReady)) {\n            curZoom = this.webviewRef.current?.getZoomFactor() || 1;\n        }\n        const makeZoomFactorMenuItem = (label: string, factor: number): ContextMenuItem => {\n            return {\n                label: label,\n                type: \"checkbox\",\n                click: () => {\n                    this.setZoomFactor(factor);\n                },\n                checked: curZoom == factor,\n            };\n        };\n        zoomSubMenu.push({\n            label: \"Reset\",\n            click: () => {\n                this.setZoomFactor(null);\n            },\n        });\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"25%\", 0.25));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"50%\", 0.5));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"70%\", 0.7));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"80%\", 0.8));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"90%\", 0.9));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"100%\", 1));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"110%\", 1.1));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"120%\", 1.2));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"130%\", 1.3));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"150%\", 1.5));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"175%\", 1.75));\n        zoomSubMenu.push(makeZoomFactorMenuItem(\"200%\", 2));\n\n        // User Agent Type submenu\n        const curUserAgentType = globalStore.get(this.userAgentType) || \"default\";\n        const userAgentSubMenu: ContextMenuItem[] = [\n            {\n                label: \"Default\",\n                type: \"checkbox\",\n                click: () => {\n                    fireAndForget(() => {\n                        return this.env.rpc.SetMetaCommand(TabRpcClient, {\n                            oref: makeORef(\"block\", this.blockId),\n                            meta: { \"web:useragenttype\": null },\n                        });\n                    });\n                },\n                checked: curUserAgentType === \"default\" || curUserAgentType === \"\",\n            },\n            {\n                label: \"Mobile: iPhone\",\n                type: \"checkbox\",\n                click: () => {\n                    fireAndForget(() => {\n                        return this.env.rpc.SetMetaCommand(TabRpcClient, {\n                            oref: makeORef(\"block\", this.blockId),\n                            meta: { \"web:useragenttype\": \"mobile:iphone\" },\n                        });\n                    });\n                },\n                checked: curUserAgentType === \"mobile:iphone\",\n            },\n            {\n                label: \"Mobile: Android\",\n                type: \"checkbox\",\n                click: () => {\n                    fireAndForget(() => {\n                        return this.env.rpc.SetMetaCommand(TabRpcClient, {\n                            oref: makeORef(\"block\", this.blockId),\n                            meta: { \"web:useragenttype\": \"mobile:android\" },\n                        });\n                    });\n                },\n                checked: curUserAgentType === \"mobile:android\",\n            },\n        ];\n\n        const isNavHidden = globalStore.get(this.hideNav);\n        return [\n            {\n                label: \"Copy URL to Clipboard\",\n                click: () => this.copyUrlToClipboard(),\n            },\n            {\n                label: \"Set Block Homepage\",\n                click: () => fireAndForget(() => this.setHomepageUrl(this.getUrl(), \"block\")),\n            },\n            {\n                label: \"Set Default Homepage\",\n                click: () => fireAndForget(() => this.setHomepageUrl(this.getUrl(), \"global\")),\n            },\n            {\n                type: \"separator\",\n            },\n            {\n                label: \"User Agent Type\",\n                submenu: userAgentSubMenu,\n            },\n            {\n                type: \"separator\",\n            },\n            {\n                label: isNavHidden ? \"Un-Hide Navigation\" : \"Hide Navigation\",\n                click: () =>\n                    fireAndForget(() => {\n                        return this.env.rpc.SetMetaCommand(TabRpcClient, {\n                            oref: makeORef(\"block\", this.blockId),\n                            meta: { \"web:hidenav\": !isNavHidden },\n                        });\n                    }),\n            },\n            {\n                label: \"Set Zoom Factor\",\n                submenu: zoomSubMenu,\n            },\n            {\n                label: this.webviewRef.current?.isDevToolsOpened() ? \"Close DevTools\" : \"Open DevTools\",\n                click: () => {\n                    if (this.webviewRef.current) {\n                        if (this.webviewRef.current.isDevToolsOpened()) {\n                            this.webviewRef.current.closeDevTools();\n                        } else {\n                            this.webviewRef.current.openDevTools();\n                        }\n                    }\n                },\n            },\n            {\n                type: \"separator\",\n            },\n            {\n                label: \"Clear History\",\n                click: () => this.clearHistory(),\n            },\n            {\n                label: \"Clear Cookies and Storage (All Web Widgets)\",\n                click: () => fireAndForget(() => this.clearCookiesAndStorage()),\n            },\n        ];\n    }\n}\n\nconst BookmarkTypeahead = memo(\n    ({ model, blockRef }: { model: WebViewModel; blockRef: React.RefObject<HTMLDivElement> }) => {\n        const env = useWaveEnv<WebViewEnv>();\n        const openBookmarksJson = () => {\n            fireAndForget(async () => {\n                const path = `${env.electron.getConfigDir()}/presets/bookmarks.json`;\n                const blockDef: BlockDef = {\n                    meta: {\n                        view: \"preview\",\n                        file: path,\n                    },\n                };\n                await env.createBlock(blockDef, false, true);\n                model.setTypeaheadOpen(false);\n            });\n        };\n        return (\n            <BlockHeaderSuggestionControl\n                blockRef={blockRef}\n                openAtom={model.typeaheadOpen}\n                onClose={() => model.setTypeaheadOpen(false)}\n                onSelect={(suggestion) => {\n                    if (suggestion == null || suggestion.type != \"url\") {\n                        return true;\n                    }\n                    model.loadUrl(suggestion[\"url:url\"], \"bookmark-typeahead\");\n                    return true;\n                }}\n                fetchSuggestions={model.fetchBookmarkSuggestions}\n                placeholderText=\"Open Bookmark...\"\n            >\n                <SuggestionControlNoData>\n                    <div className=\"text-center\">\n                        <p className=\"text-lg font-bold text-gray-100\">No Bookmarks Configured</p>\n                        <p className=\"text-sm text-gray-400 mt-1\">\n                            Edit your <code className=\"font-mono\">bookmarks.json</code> file to configure bookmarks.\n                        </p>\n                        <button\n                            onClick={openBookmarksJson}\n                            className=\"mt-3 px-4 py-2 text-sm font-medium text-black bg-accent hover:bg-accenthover rounded-lg cursor-pointer\"\n                        >\n                            Open bookmarks.json\n                        </button>\n                    </div>\n                </SuggestionControlNoData>\n\n                <SuggestionControlNoResults>\n                    <div className=\"text-center\">\n                        <p className=\"text-sm text-gray-400\">No matching bookmarks</p>\n                        <button\n                            onClick={openBookmarksJson}\n                            className=\"mt-3 px-4 py-2 text-sm font-medium text-black bg-accent hover:bg-accenthover rounded-lg cursor-pointer\"\n                        >\n                            Edit bookmarks.json\n                        </button>\n                    </div>\n                </SuggestionControlNoResults>\n            </BlockHeaderSuggestionControl>\n        );\n    }\n);\n\ninterface WebViewProps {\n    blockId: string;\n    model: WebViewModel;\n    onFailLoad?: (url: string) => void;\n    blockRef: React.RefObject<HTMLDivElement>;\n    contentRef: React.RefObject<HTMLDivElement>;\n    initialSrc?: string;\n}\n\nfunction getWebPreviewDisplayUrl(url?: string | null): string {\n    return url?.trim() || \"about:blank\";\n}\n\nfunction WebViewPreviewFallback({ url }: { url?: string | null }) {\n    const displayUrl = getWebPreviewDisplayUrl(url);\n\n    return (\n        <div className=\"flex h-full w-full items-center justify-center bg-panel\">\n            <div className=\"mx-6 flex max-w-[720px] flex-col gap-3 rounded-lg border border-dashed border-border bg-background px-6 py-5 shadow-sm\">\n                <div className=\"text-xs font-mono text-muted\">preview mock · electron webview unavailable</div>\n                <div className=\"text-sm text-foreground\">web widget placeholder</div>\n                <div className=\"rounded-md border border-border bg-panel px-3 py-2 font-mono text-xs text-foreground break-all\">\n                    {displayUrl}\n                </div>\n            </div>\n        </div>\n    );\n}\n\nconst WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) => {\n    const env = useWaveEnv<WebViewEnv>();\n    const blockData = useAtomValue(model.blockAtom);\n    const defaultUrl = useAtomValue(model.homepageUrl);\n    const defaultSearchAtom = env.getSettingsKeyAtom(\"web:defaultsearch\");\n    const defaultSearch = useAtomValue(defaultSearchAtom);\n    let metaUrl = blockData?.meta?.url || defaultUrl || \"\";\n    if (metaUrl) {\n        metaUrl = model.ensureUrlScheme(metaUrl, defaultSearch);\n    }\n    const metaUrlRef = useRef(metaUrl);\n    const zoomFactor = useAtomValue(env.getBlockMetaKeyAtom(model.blockId, \"web:zoom\")) || 1;\n    const partitionOverride = useAtomValueSafe(model.partitionOverride);\n    const metaPartition = useAtomValue(env.getBlockMetaKeyAtom(model.blockId, \"web:partition\"));\n    const webPartition = partitionOverride || metaPartition || undefined;\n    const userAgentType = useAtomValue(model.userAgentType) || \"default\";\n\n    // Determine user agent string based on type\n    let userAgent: string | undefined = undefined;\n    if (userAgentType === \"mobile:iphone\") {\n        userAgent = USER_AGENT_IPHONE;\n    } else if (userAgentType === \"mobile:android\") {\n        userAgent = USER_AGENT_ANDROID;\n    }\n\n    // Search\n    const searchProps = useSearch({ anchorRef: model.webviewRef, viewModel: model });\n    const searchVal = useAtomValue<string>(searchProps.searchValue);\n    const setSearchIndex = useSetAtom(searchProps.resultsIndex);\n    const setNumSearchResults = useSetAtom(searchProps.resultsCount);\n    searchProps.onSearch = useCallback((search: string) => {\n        if (!globalStore.get(model.domReady)) {\n            return;\n        }\n        try {\n            if (search) {\n                model.webviewRef.current?.findInPage(search, { findNext: true });\n            } else {\n                model.webviewRef.current?.stopFindInPage(\"clearSelection\");\n            }\n        } catch (e) {\n            console.error(\"Failed to search\", e);\n        }\n    }, []);\n    searchProps.onNext = useCallback(() => {\n        if (!globalStore.get(model.domReady)) {\n            return;\n        }\n        try {\n            console.log(\"search next\", searchVal);\n            model.webviewRef.current?.findInPage(searchVal, { findNext: false, forward: true });\n        } catch (e) {\n            console.error(\"Failed to search next\", e);\n        }\n    }, [searchVal]);\n    searchProps.onPrev = useCallback(() => {\n        if (!globalStore.get(model.domReady)) {\n            return;\n        }\n        try {\n            console.log(\"search prev\", searchVal);\n            model.webviewRef.current?.findInPage(searchVal, { findNext: false, forward: false });\n        } catch (e) {\n            console.error(\"Failed to search prev\", e);\n        }\n    }, [searchVal]);\n    const onFoundInPage = useCallback((event: any) => {\n        const result = event.result;\n        console.log(\"found in page\", result);\n        if (!result) {\n            return;\n        }\n        setNumSearchResults(result.matches);\n        setSearchIndex(result.activeMatchOrdinal - 1);\n    }, []);\n    // End Search\n\n    // The initial value of the block metadata URL when the component first renders. Used to set the starting src value for the webview.\n    const [metaUrlInitial] = useState(initialSrc || metaUrl);\n    const prevUserAgentTypeRef = useRef(userAgentType);\n\n    const [webContentsId, setWebContentsId] = useState(null);\n    const domReady = useAtomValue(model.domReady);\n\n    const [errorText, setErrorText] = useState(\"\");\n\n    function setBgColor() {\n        const webview = model.webviewRef.current;\n        if (!webview) {\n            return;\n        }\n        setTimeout(() => {\n            webview\n                .executeJavaScript(\n                    `!!document.querySelector('meta[name=\"color-scheme\"]') && document.querySelector('meta[name=\"color-scheme\"]').content?.includes('dark') || false`\n                )\n                .then((hasDarkMode) => {\n                    if (hasDarkMode) {\n                        webview.style.backgroundColor = \"black\"; // Dark mode background\n                    } else {\n                        webview.style.backgroundColor = \"white\"; // Light mode background\n                    }\n                })\n                .catch((e) => {\n                    webview.style.backgroundColor = \"black\"; // Dark mode background\n                    console.log(\"Error getting color scheme, defaulting to dark\", e);\n                });\n        }, 100);\n    }\n\n    useEffect(() => {\n        return () => {\n            globalStore.set(model.domReady, false);\n        };\n    }, []);\n\n    useEffect(() => {\n        if (model.webviewRef.current == null || !domReady) {\n            return;\n        }\n        try {\n            const wcId = model.webviewRef.current.getWebContentsId?.();\n            if (wcId) {\n                setWebContentsId(wcId);\n                if (model.webviewRef.current.getZoomFactor() != zoomFactor) {\n                    model.webviewRef.current.setZoomFactor(zoomFactor);\n                }\n            }\n        } catch (e) {\n            console.error(\"Failed to get webcontentsid / setzoomlevel (webview)\", e);\n        }\n    }, [model.webviewRef.current, domReady, zoomFactor]);\n\n    // Load a new URL if the block metadata is updated.\n    useEffect(() => {\n        if (initialSrc) {\n            // Skip URL loading if initialSrc is provided (it's already loaded via src attribute)\n            return;\n        }\n        if (metaUrlRef.current != metaUrl) {\n            metaUrlRef.current = metaUrl;\n            model.loadUrl(metaUrl, \"meta\");\n        }\n    }, [metaUrl, initialSrc]);\n\n    // Reload webview when user agent type changes\n    useEffect(() => {\n        if (prevUserAgentTypeRef.current !== userAgentType && domReady && model.webviewRef.current) {\n            let newUserAgent: string | undefined = undefined;\n            if (userAgentType === \"mobile:iphone\") {\n                newUserAgent = USER_AGENT_IPHONE;\n            } else if (userAgentType === \"mobile:android\") {\n                newUserAgent = USER_AGENT_ANDROID;\n            }\n\n            if (newUserAgent) {\n                model.webviewRef.current.setUserAgent(newUserAgent);\n            } else {\n                model.webviewRef.current.setUserAgent(\"\");\n            }\n            model.webviewRef.current.reload();\n        }\n        prevUserAgentTypeRef.current = userAgentType;\n    }, [userAgentType, domReady]);\n\n    useEffect(() => {\n        const webview = model.webviewRef.current;\n        if (!webview) {\n            return;\n        }\n        const navigateListener = (e: any) => {\n            setErrorText(\"\");\n            if (e.isMainFrame) {\n                model.handleNavigate(e.url);\n            }\n        };\n        const newWindowHandler = (e: any) => {\n            e.preventDefault();\n            const newUrl = e.detail.url;\n            fireAndForget(() => openLink(newUrl, true));\n        };\n        const startLoadingHandler = () => {\n            model.setRefreshIcon(\"xmark-large\");\n            model.setIsLoading(true);\n            webview.style.backgroundColor = \"transparent\";\n        };\n        const stopLoadingHandler = () => {\n            model.setRefreshIcon(\"rotate-right\");\n            model.setIsLoading(false);\n            setBgColor();\n        };\n        const failLoadHandler = (e: any) => {\n            if (e.errorCode === -3) {\n                console.warn(\"Suppressed ERR_ABORTED error\", e);\n            } else {\n                const errorMessage = `Failed to load ${e.validatedURL}: ${e.errorDescription}`;\n                console.error(errorMessage);\n                setErrorText(errorMessage);\n                if (onFailLoad) {\n                    const curUrl = model.webviewRef.current.getURL();\n                    onFailLoad(curUrl);\n                }\n            }\n        };\n        const webviewFocus = () => {\n            env.electron.setWebviewFocus(webview.getWebContentsId());\n            model.nodeModel.focusNode();\n        };\n        const webviewBlur = () => {\n            env.electron.setWebviewFocus(null);\n        };\n        const handleDomReady = () => {\n            globalStore.set(model.domReady, true);\n            setBgColor();\n        };\n        const handleMediaPlaying = () => {\n            model.setMediaPlaying(true);\n        };\n        const handleMediaPaused = () => {\n            model.setMediaPlaying(false);\n        };\n\n        webview.addEventListener(\"did-frame-navigate\", navigateListener);\n        webview.addEventListener(\"did-navigate-in-page\", navigateListener);\n        webview.addEventListener(\"did-navigate\", navigateListener);\n        webview.addEventListener(\"did-start-loading\", startLoadingHandler);\n        webview.addEventListener(\"did-stop-loading\", stopLoadingHandler);\n        webview.addEventListener(\"new-window\", newWindowHandler);\n        webview.addEventListener(\"did-fail-load\", failLoadHandler);\n        webview.addEventListener(\"focus\", webviewFocus);\n        webview.addEventListener(\"blur\", webviewBlur);\n        webview.addEventListener(\"dom-ready\", handleDomReady);\n        webview.addEventListener(\"media-started-playing\", handleMediaPlaying);\n        webview.addEventListener(\"media-paused\", handleMediaPaused);\n        webview.addEventListener(\"found-in-page\", onFoundInPage);\n\n        // Clean up event listeners on component unmount\n        return () => {\n            webview.removeEventListener(\"did-frame-navigate\", navigateListener);\n            webview.removeEventListener(\"did-navigate\", navigateListener);\n            webview.removeEventListener(\"did-navigate-in-page\", navigateListener);\n            webview.removeEventListener(\"new-window\", newWindowHandler);\n            webview.removeEventListener(\"did-fail-load\", failLoadHandler);\n            webview.removeEventListener(\"did-start-loading\", startLoadingHandler);\n            webview.removeEventListener(\"did-stop-loading\", stopLoadingHandler);\n            webview.removeEventListener(\"focus\", webviewFocus);\n            webview.removeEventListener(\"blur\", webviewBlur);\n            webview.removeEventListener(\"dom-ready\", handleDomReady);\n            webview.removeEventListener(\"media-started-playing\", handleMediaPlaying);\n            webview.removeEventListener(\"media-paused\", handleMediaPaused);\n            webview.removeEventListener(\"found-in-page\", onFoundInPage);\n        };\n    }, []);\n\n    return (\n        <Fragment>\n            <MockBoundary fallback={<WebViewPreviewFallback url={metaUrl} />}>\n                <webview\n                    id=\"webview\"\n                    className=\"webview\"\n                    ref={model.webviewRef}\n                    src={metaUrlInitial}\n                    data-blockid={model.blockId}\n                    data-webcontentsid={webContentsId} // needed for emain\n                    preload={getWebviewPreloadUrl(env)}\n                    // @ts-expect-error This is a discrepancy between the React typing and the Chromium impl for webviewTag. Chrome webviewTag expects a string, while React expects a boolean.\n                    allowpopups=\"true\"\n                    partition={webPartition}\n                    useragent={userAgent}\n                />\n            </MockBoundary>\n            {errorText && (\n                <div className=\"webview-error\">\n                    <div>{errorText}</div>\n                </div>\n            )}\n            <Search {...searchProps} />\n            <BookmarkTypeahead model={model} blockRef={blockRef} />\n        </Fragment>\n    );\n});\n\nexport { WebView, WebViewPreviewFallback, getWebPreviewDisplayUrl };\n"
  },
  {
    "path": "frontend/app/view/webview/webviewenv.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { BlockMetaKeyAtomFnType, SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from \"@/app/waveenv/waveenv\";\n\nexport type WebViewEnv = WaveEnvSubset<{\n    electron: {\n        openExternal: WaveEnv[\"electron\"][\"openExternal\"];\n        getWebviewPreload: WaveEnv[\"electron\"][\"getWebviewPreload\"];\n        clearWebviewStorage: WaveEnv[\"electron\"][\"clearWebviewStorage\"];\n        getConfigDir: WaveEnv[\"electron\"][\"getConfigDir\"];\n        setWebviewFocus: WaveEnv[\"electron\"][\"setWebviewFocus\"];\n    };\n    rpc: {\n        FetchSuggestionsCommand: WaveEnv[\"rpc\"][\"FetchSuggestionsCommand\"];\n        SetMetaCommand: WaveEnv[\"rpc\"][\"SetMetaCommand\"];\n        SetConfigCommand: WaveEnv[\"rpc\"][\"SetConfigCommand\"];\n    };\n    wos: WaveEnv[\"wos\"];\n    createBlock: WaveEnv[\"createBlock\"];\n    getSettingsKeyAtom: SettingsKeyAtomFnType<\"web:defaulturl\" | \"web:defaultsearch\">;\n    getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<\n        \"web:hidenav\" | \"web:useragenttype\" | \"web:zoom\" | \"web:partition\"\n    >;\n}>;\n"
  },
  {
    "path": "frontend/app/waveenv/mockboundary.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { isPreviewWindow } from \"@/app/store/windowtype\";\nimport React from \"react\";\n\ntype MockBoundaryProps = {\n    fallback: React.ReactNode;\n    children: React.ReactNode;\n};\n\nexport function MockBoundary({ fallback, children }: MockBoundaryProps) {\n    if (isPreviewWindow()) {\n        return <>{fallback}</>;\n    }\n    return <>{children}</>;\n}\n"
  },
  {
    "path": "frontend/app/waveenv/waveenv.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { AllServiceImpls } from \"@/app/store/services\";\nimport { RpcApiType } from \"@/app/store/wshclientapi\";\nimport { Atom, PrimitiveAtom } from \"jotai\";\nimport React from \"react\";\n\nexport type BlockMetaKeyAtomFnType<Keys extends keyof MetaType = keyof MetaType> = <T extends Keys>(\n    blockId: string,\n    key: T\n) => Atom<MetaType[T]>;\n\nexport type ConnConfigKeyAtomFnType<Keys extends keyof ConnKeywords = keyof ConnKeywords> = <T extends Keys>(\n    connName: string,\n    key: T\n) => Atom<ConnKeywords[T]>;\n\nexport type SettingsKeyAtomFnType<Keys extends keyof SettingsType = keyof SettingsType> = <T extends Keys>(\n    key: T\n) => Atom<SettingsType[T]>;\n\ntype OmitNever<T> = {\n    [K in keyof T as [T[K]] extends [never] ? never : K]: T[K];\n};\n\ntype Subset<T, U> = OmitNever<{\n    [K in keyof T]: K extends keyof U ? T[K] : never;\n}>;\n\ntype ComplexWaveEnvKeys = {\n    rpc: WaveEnv[\"rpc\"];\n    electron: WaveEnv[\"electron\"];\n    atoms: WaveEnv[\"atoms\"];\n    wos: WaveEnv[\"wos\"];\n    services: WaveEnv[\"services\"];\n};\n\ntype WaveEnvMockFields = {\n    isMock: WaveEnv[\"isMock\"];\n    mockSetWaveObj: WaveEnv[\"mockSetWaveObj\"];\n    mockModels: WaveEnv[\"mockModels\"];\n};\n\nexport type WaveEnvSubset<T> = WaveEnvMockFields &\n    OmitNever<{\n        [K in keyof T]: K extends keyof ComplexWaveEnvKeys\n            ? Subset<T[K], ComplexWaveEnvKeys[K]>\n            : K extends keyof WaveEnv\n              ? T[K]\n              : never;\n    }>;\n\n// default implementation for production is in ./waveenvimpl.ts\nexport type WaveEnv = {\n    isMock: boolean;\n    electron: ElectronApi;\n    rpc: RpcApiType;\n    platform: NodeJS.Platform;\n    isDev: () => boolean;\n    isWindows: () => boolean;\n    isMacOS: () => boolean;\n    atoms: GlobalAtomsType;\n    createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => Promise<string>;\n    services: typeof AllServiceImpls;\n    callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => Promise<any>;\n    showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => void;\n    getConnStatusAtom: (conn: string) => PrimitiveAtom<ConnStatus>;\n    getLocalHostDisplayNameAtom: () => Atom<string>;\n    wos: {\n        getWaveObjectAtom: <T extends WaveObj>(oref: string) => Atom<T>;\n        getWaveObjectLoadingAtom: (oref: string) => Atom<boolean>;\n        isWaveObjectNullAtom: (oref: string) => Atom<boolean>;\n        useWaveObjectValue: <T extends WaveObj>(oref: string) => [T, boolean];\n    };\n    getSettingsKeyAtom: SettingsKeyAtomFnType;\n    getBlockMetaKeyAtom: BlockMetaKeyAtomFnType;\n    getConnConfigKeyAtom: ConnConfigKeyAtomFnType;\n\n    // the mock fields are only usable in the preview server (may be be null or throw errors in production)\n    mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => void;\n    mockModels: Map<any, any>;\n};\n\nexport const WaveEnvContext = React.createContext<WaveEnv>(null);\n\ntype EnvContract<T> = {\n    [K in keyof T]?: T[K] extends (...args: any[]) => any ? T[K] : T[K] extends object ? EnvContract<T[K]> : T[K];\n};\n\nexport function useWaveEnv<T extends EnvContract<WaveEnv> = WaveEnv>(): T {\n    return React.useContext(WaveEnvContext) as T;\n}\n"
  },
  {
    "path": "frontend/app/waveenv/waveenvimpl.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { ContextMenuModel } from \"@/app/store/contextmenu\";\nimport { AllServiceImpls } from \"@/app/store/services\";\nimport {\n    atoms,\n    createBlock,\n    getBlockMetaKeyAtom,\n    getConnConfigKeyAtom,\n    getConnStatusAtom,\n    getLocalHostDisplayNameAtom,\n    getSettingsKeyAtom,\n    isDev,\n    WOS,\n} from \"@/app/store/global\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { WaveEnv } from \"@/app/waveenv/waveenv\";\nimport { isMacOS, isWindows, PLATFORM } from \"@/util/platformutil\";\n\nexport function makeWaveEnvImpl(): WaveEnv {\n    return {\n        isMock: false,\n        electron: (window as any).api,\n        rpc: RpcApi,\n        getSettingsKeyAtom,\n        platform: PLATFORM,\n        isDev,\n        isWindows,\n        isMacOS,\n        atoms,\n        createBlock,\n        services: AllServiceImpls,\n        callBackendService: WOS.callBackendService,\n        showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => {\n            ContextMenuModel.getInstance().showContextMenu(menu, e);\n        },\n        getConnStatusAtom,\n        getLocalHostDisplayNameAtom,\n        wos: {\n            getWaveObjectAtom: WOS.getWaveObjectAtom,\n            getWaveObjectLoadingAtom: WOS.getWaveObjectLoadingAtom,\n            isWaveObjectNullAtom: WOS.isWaveObjectNullAtom,\n            useWaveObjectValue: WOS.useWaveObjectValue,\n        },\n        getBlockMetaKeyAtom,\n        getConnConfigKeyAtom,\n\n        mockSetWaveObj: <T extends WaveObj>(_oref: string, _obj: T) => {\n            throw new Error(\"mockSetWaveObj is only available in the preview server\");\n        },\n        mockModels: new Map<any, any>(),\n    };\n}\n"
  },
  {
    "path": "frontend/app/workspace/widgetfilter.test.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { assert, test } from \"vitest\";\nimport { shouldIncludeWidgetForWorkspace } from \"./widgetfilter\";\n\ntest(\"shouldIncludeWidgetForWorkspace includes widgets with missing or empty workspaces\", () => {\n    assert(shouldIncludeWidgetForWorkspace({ blockdef: { meta: {} } }, \"ws-1\"));\n    assert(shouldIncludeWidgetForWorkspace({ blockdef: { meta: {} }, workspaces: [] }, \"ws-1\"));\n});\n\ntest(\"shouldIncludeWidgetForWorkspace only includes configured workspace IDs\", () => {\n    assert(shouldIncludeWidgetForWorkspace({ blockdef: { meta: {} }, workspaces: [\"ws-1\", \"ws-2\"] }, \"ws-1\"));\n    assert(!shouldIncludeWidgetForWorkspace({ blockdef: { meta: {} }, workspaces: [\"ws-1\", \"ws-2\"] }, \"ws-3\"));\n    assert(!shouldIncludeWidgetForWorkspace({ blockdef: { meta: {} }, workspaces: [\"ws-1\"] }, null));\n});\n"
  },
  {
    "path": "frontend/app/workspace/widgetfilter.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nfunction shouldIncludeWidgetForWorkspace(widget: WidgetConfigType, workspaceId?: string): boolean {\n    const workspaces = widget.workspaces;\n    return !Array.isArray(workspaces) || workspaces.length === 0 || (workspaceId != null && workspaces.includes(workspaceId));\n}\n\nexport { shouldIncludeWidgetForWorkspace };\n"
  },
  {
    "path": "frontend/app/workspace/widgets.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Tooltip } from \"@/app/element/tooltip\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { useWaveEnv, WaveEnv, WaveEnvSubset } from \"@/app/waveenv/waveenv\";\nimport { shouldIncludeWidgetForWorkspace } from \"@/app/workspace/widgetfilter\";\nimport { modalsModel } from \"@/store/modalmodel\";\nimport { fireAndForget, isBlank, makeIconClass } from \"@/util/util\";\nimport {\n    autoUpdate,\n    FloatingPortal,\n    offset,\n    shift,\n    useDismiss,\n    useFloating,\n    useInteractions,\n} from \"@floating-ui/react\";\nimport clsx from \"clsx\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useCallback, useEffect, useRef, useState } from \"react\";\n\nexport type WidgetsEnv = WaveEnvSubset<{\n    isDev: WaveEnv[\"isDev\"];\n    electron: {\n        openBuilder: WaveEnv[\"electron\"][\"openBuilder\"];\n    };\n    rpc: {\n        ListAllAppsCommand: WaveEnv[\"rpc\"][\"ListAllAppsCommand\"];\n    };\n    atoms: {\n        fullConfigAtom: WaveEnv[\"atoms\"][\"fullConfigAtom\"];\n        hasConfigErrors: WaveEnv[\"atoms\"][\"hasConfigErrors\"];\n        workspaceId: WaveEnv[\"atoms\"][\"workspaceId\"];\n        hasCustomAIPresetsAtom: WaveEnv[\"atoms\"][\"hasCustomAIPresetsAtom\"];\n    };\n    createBlock: WaveEnv[\"createBlock\"];\n    showContextMenu: WaveEnv[\"showContextMenu\"];\n}>;\n\nfunction sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType }): WidgetConfigType[] {\n    if (wmap == null) {\n        return [];\n    }\n    const wlist = Object.values(wmap);\n    wlist.sort((a, b) => {\n        return (a[\"display:order\"] ?? 0) - (b[\"display:order\"] ?? 0);\n    });\n    return wlist;\n}\n\ntype WidgetPropsType = {\n    widget: WidgetConfigType;\n    mode: \"normal\" | \"compact\" | \"supercompact\";\n    env: WidgetsEnv;\n};\n\nasync function handleWidgetSelect(widget: WidgetConfigType, env: WidgetsEnv) {\n    const blockDef = widget.blockdef;\n    env.createBlock(blockDef, widget.magnified);\n}\n\nconst Widget = memo(({ widget, mode, env }: WidgetPropsType) => {\n    const [isTruncated, setIsTruncated] = useState(false);\n    const labelRef = useRef<HTMLDivElement>(null);\n\n    useEffect(() => {\n        if (mode === \"normal\" && labelRef.current) {\n            const element = labelRef.current;\n            setIsTruncated(element.scrollWidth > element.clientWidth);\n        }\n    }, [mode, widget.label]);\n\n    const shouldDisableTooltip = mode !== \"normal\" ? false : !isTruncated;\n\n    return (\n        <Tooltip\n            content={widget.description || widget.label}\n            placement=\"left\"\n            disable={shouldDisableTooltip}\n            divClassName={clsx(\n                \"flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer\",\n                mode === \"supercompact\" ? \"text-sm\" : \"text-lg\",\n                widget[\"display:hidden\"] && \"hidden\"\n            )}\n            divOnClick={() => handleWidgetSelect(widget, env)}\n        >\n            <div style={{ color: widget.color }}>\n                <i className={makeIconClass(widget.icon, true, { defaultIcon: \"browser\" })}></i>\n            </div>\n            {mode === \"normal\" && !isBlank(widget.label) ? (\n                <div\n                    ref={labelRef}\n                    className=\"text-xxs mt-0.5 w-full px-0.5 text-center whitespace-nowrap overflow-hidden text-ellipsis\"\n                >\n                    {widget.label}\n                </div>\n            ) : null}\n        </Tooltip>\n    );\n});\n\nfunction calculateGridSize(appCount: number): number {\n    if (appCount <= 4) return 2;\n    if (appCount <= 9) return 3;\n    if (appCount <= 16) return 4;\n    if (appCount <= 25) return 5;\n    return 6;\n}\n\nfunction SettingsTooltipContent({ hasConfigErrors }: { hasConfigErrors: boolean }) {\n    if (!hasConfigErrors) {\n        return \"Settings & Help\";\n    }\n    return (\n        <div className=\"flex flex-col p-1\">\n            <div className=\"mb-1\">Settings &amp; Help</div>\n            <div className=\"flex items-center gap-1 mt-0.5 text-error\">\n                <i className=\"fa fa-solid fa-circle-exclamation\"></i>\n                <span>Config Errors</span>\n            </div>\n        </div>\n    );\n}\n\ntype FloatingWindowPropsType = {\n    isOpen: boolean;\n    onClose: () => void;\n    referenceElement: HTMLElement;\n    hasConfigErrors?: boolean;\n};\n\nconst AppsFloatingWindow = memo(({ isOpen, onClose, referenceElement }: FloatingWindowPropsType) => {\n    const [apps, setApps] = useState<AppInfo[]>([]);\n    const [loading, setLoading] = useState(true);\n    const env = useWaveEnv<WidgetsEnv>();\n\n    const { refs, floatingStyles, context } = useFloating({\n        open: isOpen,\n        onOpenChange: onClose,\n        placement: \"left-start\",\n        middleware: [offset(-2), shift({ padding: 12 })],\n        whileElementsMounted: autoUpdate,\n        elements: {\n            reference: referenceElement,\n        },\n    });\n\n    const dismiss = useDismiss(context);\n    const { getFloatingProps } = useInteractions([dismiss]);\n    const handleOpenBuilder = useCallback(() => {\n        env.electron.openBuilder(null);\n        onClose();\n    }, [onClose, env]);\n\n    useEffect(() => {\n        if (!isOpen) return;\n\n        const fetchApps = async () => {\n            setLoading(true);\n            try {\n                const allApps = await env.rpc.ListAllAppsCommand(TabRpcClient);\n                const localApps = allApps\n                    .filter((app) => !app.appid.startsWith(\"draft/\"))\n                    .sort((a, b) => {\n                        const aName = a.appid.replace(/^local\\//, \"\");\n                        const bName = b.appid.replace(/^local\\//, \"\");\n                        return aName.localeCompare(bName);\n                    });\n                setApps(localApps);\n            } catch (error) {\n                console.error(\"Failed to fetch apps:\", error);\n                setApps([]);\n            } finally {\n                setLoading(false);\n            }\n        };\n\n        fetchApps();\n    }, [isOpen]);\n\n    if (!isOpen) return null;\n\n    const gridSize = calculateGridSize(apps.length);\n\n    return (\n        <FloatingPortal>\n            <div\n                ref={refs.setFloating}\n                style={floatingStyles}\n                {...getFloatingProps()}\n                className=\"bg-modalbg border border-border rounded-lg shadow-xl z-50 overflow-hidden\"\n            >\n                <div className=\"p-4\">\n                    {loading ? (\n                        <div className=\"flex items-center justify-center p-8\">\n                            <i className=\"fa fa-solid fa-spinner fa-spin text-2xl text-muted\"></i>\n                        </div>\n                    ) : apps.length === 0 ? (\n                        <div className=\"text-muted text-sm p-4 text-center\">No local apps found</div>\n                    ) : (\n                        <div\n                            className=\"grid gap-3\"\n                            style={{\n                                gridTemplateColumns: `repeat(${gridSize}, minmax(0, 1fr))`,\n                                maxWidth: `${gridSize * 80}px`,\n                            }}\n                        >\n                            {apps.map((app) => {\n                                const appMeta = app.manifest?.appmeta;\n                                const displayName = app.appid.replace(/^local\\//, \"\");\n                                const icon = appMeta?.icon || \"cube\";\n                                const iconColor = appMeta?.iconcolor || \"white\";\n\n                                return (\n                                    <div\n                                        key={app.appid}\n                                        className=\"flex flex-col items-center justify-center p-2 rounded hover:bg-hoverbg cursor-pointer transition-colors\"\n                                        onClick={() => {\n                                            const blockDef: BlockDef = {\n                                                meta: {\n                                                    view: \"tsunami\",\n                                                    controller: \"tsunami\",\n                                                    \"tsunami:appid\": app.appid,\n                                                },\n                                            };\n                                            env.createBlock(blockDef);\n                                            onClose();\n                                        }}\n                                    >\n                                        <div style={{ color: iconColor }} className=\"text-3xl mb-1\">\n                                            <i className={makeIconClass(icon, false)}></i>\n                                        </div>\n                                        <div className=\"text-xxs text-center text-secondary break-words w-full px-1\">\n                                            {displayName}\n                                        </div>\n                                    </div>\n                                );\n                            })}\n                        </div>\n                    )}\n                </div>\n                <button\n                    type=\"button\"\n                    className=\"w-full px-4 py-2 border-t border-border text-xs text-secondary text-center hover:bg-hoverbg hover:text-white transition-colors cursor-pointer flex items-center justify-center gap-2\"\n                    onClick={handleOpenBuilder}\n                >\n                    <i className=\"fa fa-solid fa-hammer\"></i>\n                    Build/Edit Apps\n                </button>\n            </div>\n        </FloatingPortal>\n    );\n});\n\nconst SettingsFloatingWindow = memo(\n    ({ isOpen, onClose, referenceElement, hasConfigErrors }: FloatingWindowPropsType) => {\n        const env = useWaveEnv<WidgetsEnv>();\n        const { refs, floatingStyles, context } = useFloating({\n            open: isOpen,\n            onOpenChange: onClose,\n            placement: \"left-start\",\n            middleware: [offset(-2), shift({ padding: 12 })],\n            whileElementsMounted: autoUpdate,\n            elements: {\n                reference: referenceElement,\n            },\n        });\n\n        const dismiss = useDismiss(context);\n        const { getFloatingProps } = useInteractions([dismiss]);\n\n        if (!isOpen) return null;\n\n        const menuItems = [\n            {\n                icon: \"gear\",\n                label: \"Settings\",\n                hasError: hasConfigErrors,\n                onClick: () => {\n                    const blockDef: BlockDef = {\n                        meta: {\n                            view: \"waveconfig\",\n                        },\n                    };\n                    env.createBlock(blockDef, false, true);\n                    onClose();\n                },\n            },\n            {\n                icon: \"lightbulb\",\n                label: \"Tips\",\n                onClick: () => {\n                    const blockDef: BlockDef = {\n                        meta: {\n                            view: \"tips\",\n                        },\n                    };\n                    env.createBlock(blockDef, true, true);\n                    onClose();\n                },\n            },\n            {\n                icon: \"lock\",\n                label: \"Secrets\",\n                onClick: () => {\n                    const blockDef: BlockDef = {\n                        meta: {\n                            view: \"waveconfig\",\n                            file: \"secrets\",\n                        },\n                    };\n                    env.createBlock(blockDef, false, true);\n                    onClose();\n                },\n            },\n            {\n                icon: \"book-open\",\n                label: \"Release Notes\",\n                onClick: () => {\n                    modalsModel.pushModal(\"UpgradeOnboardingPatch\", { isReleaseNotes: true });\n                    onClose();\n                },\n            },\n            {\n                icon: \"circle-question\",\n                label: \"Help\",\n                onClick: () => {\n                    const blockDef: BlockDef = {\n                        meta: {\n                            view: \"help\",\n                        },\n                    };\n                    env.createBlock(blockDef);\n                    onClose();\n                },\n            },\n        ];\n\n        return (\n            <FloatingPortal>\n                <div\n                    ref={refs.setFloating}\n                    style={floatingStyles}\n                    {...getFloatingProps()}\n                    className=\"bg-modalbg border border-border rounded-lg shadow-xl p-2 z-50\"\n                >\n                    {menuItems.map((item, idx) => (\n                        <div\n                            key={idx}\n                            className=\"flex items-center gap-3 px-3 py-2 rounded hover:bg-hoverbg cursor-pointer transition-colors text-secondary hover:text-white\"\n                            onClick={item.onClick}\n                        >\n                            <div className=\"text-lg w-5 flex justify-center\">\n                                <i className={makeIconClass(item.icon, false)}></i>\n                            </div>\n                            <div className=\"text-sm whitespace-nowrap\">{item.label}</div>\n                            {item.hasError && (\n                                <i className=\"fa fa-solid fa-circle-exclamation text-error text-[14px] ml-auto\"></i>\n                            )}\n                        </div>\n                    ))}\n                </div>\n            </FloatingPortal>\n        );\n    }\n);\n\nSettingsFloatingWindow.displayName = \"SettingsFloatingWindow\";\n\nconst Widgets = memo(() => {\n    const env = useWaveEnv<WidgetsEnv>();\n    const fullConfig = useAtomValue(env.atoms.fullConfigAtom);\n    const hasConfigErrors = useAtomValue(env.atoms.hasConfigErrors);\n    const workspaceId = useAtomValue(env.atoms.workspaceId);\n    const hasCustomAIPresets = useAtomValue(env.atoms.hasCustomAIPresetsAtom);\n    const [mode, setMode] = useState<\"normal\" | \"compact\" | \"supercompact\">(\"normal\");\n    const containerRef = useRef<HTMLDivElement>(null);\n    const measurementRef = useRef<HTMLDivElement>(null);\n\n    const featureWaveAppBuilder = fullConfig?.settings?.[\"feature:waveappbuilder\"] ?? false;\n    const widgetsMap = fullConfig?.widgets ?? {};\n    const filteredWidgets = Object.fromEntries(\n        Object.entries(widgetsMap).filter(([key, widget]) => {\n            if (!hasCustomAIPresets && key === \"defwidget@ai\") {\n                return false;\n            }\n            return shouldIncludeWidgetForWorkspace(widget, workspaceId);\n        })\n    );\n    const widgets = sortByDisplayOrder(filteredWidgets);\n\n    const [isAppsOpen, setIsAppsOpen] = useState(false);\n    const appsButtonRef = useRef<HTMLDivElement>(null);\n    const [isSettingsOpen, setIsSettingsOpen] = useState(false);\n    const settingsButtonRef = useRef<HTMLDivElement>(null);\n\n    const checkModeNeeded = useCallback(() => {\n        if (!containerRef.current || !measurementRef.current) return;\n\n        const containerHeight = containerRef.current.clientHeight;\n        const normalHeight = measurementRef.current.scrollHeight;\n        const gracePeriod = 10;\n\n        let newMode: \"normal\" | \"compact\" | \"supercompact\" = \"normal\";\n\n        if (normalHeight > containerHeight - gracePeriod) {\n            newMode = \"compact\";\n\n            // Calculate total widget count for supercompact check\n            const totalWidgets = (widgets?.length || 0) + 1;\n            const minHeightPerWidget = 32;\n            const requiredHeight = totalWidgets * minHeightPerWidget;\n\n            if (requiredHeight > containerHeight) {\n                newMode = \"supercompact\";\n            }\n        }\n\n        if (newMode !== mode) {\n            setMode(newMode);\n        }\n    }, [mode, widgets]);\n\n    useEffect(() => {\n        const resizeObserver = new ResizeObserver(() => {\n            checkModeNeeded();\n        });\n\n        if (containerRef.current) {\n            resizeObserver.observe(containerRef.current);\n        }\n\n        return () => {\n            resizeObserver.disconnect();\n        };\n    }, [checkModeNeeded]);\n\n    useEffect(() => {\n        checkModeNeeded();\n    }, [widgets, checkModeNeeded]);\n\n    const handleWidgetsBarContextMenu = (e: React.MouseEvent) => {\n        e.preventDefault();\n        const menu: ContextMenuItem[] = [\n            {\n                label: \"Edit widgets.json\",\n                click: () => {\n                    fireAndForget(async () => {\n                        const blockDef: BlockDef = {\n                            meta: {\n                                view: \"waveconfig\",\n                                file: \"widgets.json\",\n                            },\n                        };\n                        await env.createBlock(blockDef, false, true);\n                    });\n                },\n            },\n        ];\n        env.showContextMenu(menu, e);\n    };\n\n    return (\n        <>\n            <div\n                ref={containerRef}\n                className=\"flex flex-col w-12 overflow-hidden py-1 -ml-1 select-none shrink-0\"\n                onContextMenu={handleWidgetsBarContextMenu}\n            >\n                {mode === \"supercompact\" ? (\n                    <>\n                        <div className=\"grid grid-cols-2 gap-0 w-full\">\n                            {widgets?.map((data, idx) => (\n                                <Widget key={`widget-${idx}`} widget={data} mode={mode} env={env} />\n                            ))}\n                        </div>\n                        <div className=\"flex-grow\" />\n                        <div className=\"grid grid-cols-2 gap-0 w-full\">\n                            {env.isDev() || featureWaveAppBuilder ? (\n                                <div\n                                    ref={appsButtonRef}\n                                    className=\"flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-sm overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer\"\n                                    onClick={() => setIsAppsOpen(!isAppsOpen)}\n                                >\n                                    <Tooltip content=\"Local WaveApps\" placement=\"left\" disable={isAppsOpen}>\n                                        <div>\n                                            <i className={makeIconClass(\"cube\", true)}></i>\n                                        </div>\n                                    </Tooltip>\n                                </div>\n                            ) : null}\n                            <div\n                                ref={settingsButtonRef}\n                                className=\"flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-sm overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer\"\n                                onClick={() => setIsSettingsOpen(!isSettingsOpen)}\n                            >\n                                <Tooltip\n                                    content={<SettingsTooltipContent hasConfigErrors={hasConfigErrors} />}\n                                    placement=\"left\"\n                                    disable={isSettingsOpen}\n                                >\n                                    <div className=\"relative\">\n                                        <i className={makeIconClass(\"gear\", true)}></i>\n                                        {hasConfigErrors && (\n                                            <i className=\"fa fa-solid fa-circle-exclamation text-error absolute top-0 right-0 text-[10px] pointer-events-none\"></i>\n                                        )}\n                                    </div>\n                                </Tooltip>\n                            </div>\n                        </div>\n                    </>\n                ) : (\n                    <>\n                        {widgets?.map((data, idx) => (\n                            <Widget key={`widget-${idx}`} widget={data} mode={mode} env={env} />\n                        ))}\n                        <div className=\"flex-grow\" />\n                        {env.isDev() || featureWaveAppBuilder ? (\n                            <div\n                                ref={appsButtonRef}\n                                className=\"flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-lg overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer\"\n                                onClick={() => setIsAppsOpen(!isAppsOpen)}\n                            >\n                                <Tooltip content=\"Local WaveApps\" placement=\"left\" disable={isAppsOpen}>\n                                    <div className=\"flex flex-col items-center w-full\">\n                                        <div>\n                                            <i className={makeIconClass(\"cube\", true)}></i>\n                                        </div>\n                                        {mode === \"normal\" && (\n                                            <div className=\"text-xxs mt-0.5 w-full px-0.5 text-center whitespace-nowrap overflow-hidden text-ellipsis\">\n                                                apps\n                                            </div>\n                                        )}\n                                    </div>\n                                </Tooltip>\n                            </div>\n                        ) : null}\n                        <div\n                            ref={settingsButtonRef}\n                            className=\"flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-lg overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer\"\n                            onClick={() => setIsSettingsOpen(!isSettingsOpen)}\n                        >\n                            <Tooltip\n                                content={<SettingsTooltipContent hasConfigErrors={hasConfigErrors} />}\n                                placement=\"left\"\n                                disable={isSettingsOpen}\n                            >\n                                <div className=\"flex flex-col items-center w-full\">\n                                    <div className=\"relative\">\n                                        <i className={makeIconClass(\"gear\", true)}></i>\n                                        {hasConfigErrors && (\n                                            <i\n                                                className={`fa fa-solid fa-circle-exclamation text-error absolute top-0 right-[-4px] pointer-events-none ${mode === \"normal\" ? \"text-[14px]\" : \"text-[12px]\"}`}\n                                            ></i>\n                                        )}\n                                    </div>\n                                    {mode === \"normal\" && (\n                                        <div className=\"text-xxs mt-0.5 w-full px-0.5 text-center whitespace-nowrap overflow-hidden text-ellipsis\">\n                                            settings\n                                        </div>\n                                    )}\n                                </div>\n                            </Tooltip>\n                        </div>\n                    </>\n                )}\n                {env.isDev() ? (\n                    <div\n                        className=\"flex justify-center items-center w-full py-1 text-accent text-[30px]\"\n                        title=\"Running Wave Dev Build\"\n                    >\n                        <i className=\"fa fa-brands fa-dev fa-fw\" />\n                    </div>\n                ) : null}\n            </div>\n            {(env.isDev() || featureWaveAppBuilder) && appsButtonRef.current && (\n                <AppsFloatingWindow\n                    isOpen={isAppsOpen}\n                    onClose={() => setIsAppsOpen(false)}\n                    referenceElement={appsButtonRef.current}\n                />\n            )}\n            {settingsButtonRef.current && (\n                <SettingsFloatingWindow\n                    isOpen={isSettingsOpen}\n                    onClose={() => setIsSettingsOpen(false)}\n                    referenceElement={settingsButtonRef.current}\n                    hasConfigErrors={hasConfigErrors}\n                />\n            )}\n\n            <div\n                ref={measurementRef}\n                className=\"flex flex-col w-12 py-1 -ml-1 select-none absolute -z-10 opacity-0 pointer-events-none\"\n            >\n                {widgets?.map((data, idx) => (\n                    <Widget key={`measurement-widget-${idx}`} widget={data} mode=\"normal\" env={env} />\n                ))}\n                <div className=\"flex-grow\" />\n                <div className=\"flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-lg\">\n                    <div>\n                        <i className={makeIconClass(\"gear\", true)}></i>\n                    </div>\n                    <div className=\"text-xxs mt-0.5 w-full px-0.5 text-center\">settings</div>\n                </div>\n                {env.isDev() ? (\n                    <div className=\"flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-lg\">\n                        <div>\n                            <i className={makeIconClass(\"cube\", true)}></i>\n                        </div>\n                        <div className=\"text-xxs mt-0.5 w-full px-0.5 text-center\">apps</div>\n                    </div>\n                ) : null}\n                {env.isDev() ? (\n                    <div\n                        className=\"flex justify-center items-center w-full py-1 text-accent text-[30px]\"\n                        title=\"Running Wave Dev Build\"\n                    >\n                        <i className=\"fa fa-brands fa-dev fa-fw\" />\n                    </div>\n                ) : null}\n            </div>\n        </>\n    );\n});\n\nexport { Widgets };\n"
  },
  {
    "path": "frontend/app/workspace/workspace-layout-model.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { WaveAIModel } from \"@/app/aipanel/waveai-model\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { isBuilderWindow } from \"@/app/store/windowtype\";\nimport * as WOS from \"@/app/store/wos\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { getLayoutModelForStaticTab } from \"@/layout/lib/layoutModelHooks\";\nimport { atoms, getApi, getOrefMetaKeyAtom, recordTEvent, refocusNode } from \"@/store/global\";\nimport debug from \"debug\";\nimport * as jotai from \"jotai\";\nimport { debounce } from \"lodash-es\";\nimport { ImperativePanelGroupHandle, ImperativePanelHandle } from \"react-resizable-panels\";\n\nconst dlog = debug(\"wave:workspace\");\n\nconst AIPanel_DefaultWidth = 300;\nconst AIPanel_DefaultWidthRatio = 0.33;\nconst AIPanel_MinWidth = 300;\nconst AIPanel_MaxWidthRatio = 0.66;\n\nconst VTabBar_DefaultWidth = 220;\nconst VTabBar_MinWidth = 110;\nconst VTabBar_MaxWidth = 280;\n\nfunction clampVTabWidth(w: number): number {\n    return Math.max(VTabBar_MinWidth, Math.min(w, VTabBar_MaxWidth));\n}\n\nfunction clampAIPanelWidth(w: number, windowWidth: number): number {\n    const maxWidth = Math.floor(windowWidth * AIPanel_MaxWidthRatio);\n    if (AIPanel_MinWidth > maxWidth) return AIPanel_MinWidth;\n    return Math.max(AIPanel_MinWidth, Math.min(w, maxWidth));\n}\n\nclass WorkspaceLayoutModel {\n    private static instance: WorkspaceLayoutModel | null = null;\n\n    aiPanelRef: ImperativePanelHandle | null;\n    vtabPanelRef: ImperativePanelHandle | null;\n    outerPanelGroupRef: ImperativePanelGroupHandle | null;\n    innerPanelGroupRef: ImperativePanelGroupHandle | null;\n    panelContainerRef: HTMLDivElement | null;\n    aiPanelWrapperRef: HTMLDivElement | null;\n    panelVisibleAtom: jotai.PrimitiveAtom<boolean>;\n    vtabVisibleAtom: jotai.PrimitiveAtom<boolean>;\n\n    private inResize: boolean;\n    private aiPanelVisible: boolean;\n    private aiPanelWidth: number | null;\n    private vtabWidth: number;\n    private vtabVisible: boolean;\n    private initialized: boolean = false;\n    private transitionTimeoutRef: NodeJS.Timeout | null = null;\n    private focusTimeoutRef: NodeJS.Timeout | null = null;\n    private debouncedPersistAIWidth: (width: number) => void;\n    private debouncedPersistVTabWidth: (width: number) => void;\n\n    private constructor() {\n        this.aiPanelRef = null;\n        this.vtabPanelRef = null;\n        this.outerPanelGroupRef = null;\n        this.innerPanelGroupRef = null;\n        this.panelContainerRef = null;\n        this.aiPanelWrapperRef = null;\n        this.inResize = false;\n        this.aiPanelVisible = false;\n        this.aiPanelWidth = null;\n        this.vtabWidth = VTabBar_DefaultWidth;\n        this.vtabVisible = false;\n        this.panelVisibleAtom = jotai.atom(false);\n        this.vtabVisibleAtom = jotai.atom(false);\n\n        this.handleWindowResize = this.handleWindowResize.bind(this);\n        this.handleOuterPanelLayout = this.handleOuterPanelLayout.bind(this);\n        this.handleInnerPanelLayout = this.handleInnerPanelLayout.bind(this);\n\n        this.debouncedPersistAIWidth = debounce((width: number) => {\n            try {\n                RpcApi.SetMetaCommand(TabRpcClient, {\n                    oref: WOS.makeORef(\"tab\", this.getTabId()),\n                    meta: { \"waveai:panelwidth\": width },\n                });\n            } catch (e) {\n                console.warn(\"Failed to persist AI panel width:\", e);\n            }\n        }, 300);\n\n        this.debouncedPersistVTabWidth = debounce((width: number) => {\n            try {\n                RpcApi.SetMetaCommand(TabRpcClient, {\n                    oref: WOS.makeORef(\"workspace\", this.getWorkspaceId()),\n                    meta: { \"layout:vtabbarwidth\": width },\n                });\n            } catch (e) {\n                console.warn(\"Failed to persist vtabbar width:\", e);\n            }\n        }, 300);\n    }\n\n    static getInstance(): WorkspaceLayoutModel {\n        if (!WorkspaceLayoutModel.instance) {\n            WorkspaceLayoutModel.instance = new WorkspaceLayoutModel();\n        }\n        return WorkspaceLayoutModel.instance;\n    }\n\n    // ---- Meta / persistence helpers ----\n\n    private getTabId(): string {\n        return globalStore.get(atoms.staticTabId);\n    }\n\n    private getWorkspaceId(): string {\n        return globalStore.get(atoms.workspace)?.oid ?? \"\";\n    }\n\n    private getPanelOpenAtom(): jotai.Atom<boolean> {\n        return getOrefMetaKeyAtom(WOS.makeORef(\"tab\", this.getTabId()), \"waveai:panelopen\");\n    }\n\n    private getPanelWidthAtom(): jotai.Atom<number> {\n        return getOrefMetaKeyAtom(WOS.makeORef(\"tab\", this.getTabId()), \"waveai:panelwidth\");\n    }\n\n    private getVTabBarWidthAtom(): jotai.Atom<number> {\n        return getOrefMetaKeyAtom(WOS.makeORef(\"workspace\", this.getWorkspaceId()), \"layout:vtabbarwidth\");\n    }\n\n    private initializeFromMeta(): void {\n        if (this.initialized) return;\n        this.initialized = true;\n        try {\n            const savedVisible = globalStore.get(this.getPanelOpenAtom());\n            const savedAIWidth = globalStore.get(this.getPanelWidthAtom());\n            const savedVTabWidth = globalStore.get(this.getVTabBarWidthAtom());\n            if (savedVisible != null) {\n                this.aiPanelVisible = savedVisible;\n                globalStore.set(this.panelVisibleAtom, savedVisible);\n            }\n            if (savedAIWidth != null) {\n                this.aiPanelWidth = savedAIWidth;\n            }\n            if (savedVTabWidth != null && savedVTabWidth > 0) {\n                this.vtabWidth = savedVTabWidth;\n            }\n        } catch (e) {\n            console.warn(\"Failed to initialize from tab meta:\", e);\n        }\n    }\n\n    // ---- Resolved width getters (always clamped) ----\n\n    private getResolvedAIWidth(windowWidth: number): number {\n        this.initializeFromMeta();\n        let w = this.aiPanelWidth;\n        if (w == null) {\n            w = Math.max(AIPanel_DefaultWidth, windowWidth * AIPanel_DefaultWidthRatio);\n            this.aiPanelWidth = w;\n        }\n        return clampAIPanelWidth(w, windowWidth);\n    }\n\n    private getResolvedVTabWidth(): number {\n        this.initializeFromMeta();\n        return clampVTabWidth(this.vtabWidth);\n    }\n\n    // ---- Core layout computation ----\n    // All layout decisions flow through computeLayout.\n    // It takes the current state (visibility flags + stored px widths)\n    // and produces the two percentage arrays for the panel groups.\n\n    private computeLayout(windowWidth: number): { outer: number[]; inner: number[] } {\n        const vtabW = this.vtabVisible ? this.getResolvedVTabWidth() : 0;\n        const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0;\n        const leftGroupW = vtabW + aiW;\n\n        // outer: [leftGroupPct, contentPct]\n        const leftPct = windowWidth > 0 ? (leftGroupW / windowWidth) * 100 : 0;\n        const contentPct = Math.max(0, 100 - leftPct);\n\n        // inner: [vtabPct, aiPanelPct] relative to leftGroupW\n        let vtabPct: number;\n        let aiPct: number;\n        if (leftGroupW > 0) {\n            vtabPct = (vtabW / leftGroupW) * 100;\n            aiPct = 100 - vtabPct;\n        } else {\n            vtabPct = 50;\n            aiPct = 50;\n        }\n\n        return { outer: [leftPct, contentPct], inner: [vtabPct, aiPct] };\n    }\n\n    private commitLayouts(windowWidth: number): void {\n        if (!this.outerPanelGroupRef || !this.innerPanelGroupRef) return;\n        const { outer, inner } = this.computeLayout(windowWidth);\n        this.inResize = true;\n        this.outerPanelGroupRef.setLayout(outer);\n        this.innerPanelGroupRef.setLayout(inner);\n        this.inResize = false;\n        this.updateWrapperWidth();\n    }\n\n    // ---- Drag handlers ----\n    // These convert the percentage-based callback from react-resizable-panels\n    // back into pixel widths, update stored state, then re-commit.\n\n    handleOuterPanelLayout(sizes: number[]): void {\n        if (this.inResize) return;\n        const windowWidth = window.innerWidth;\n        const newLeftGroupPx = (sizes[0] / 100) * windowWidth;\n\n        if (this.vtabVisible && this.aiPanelVisible) {\n            // vtab stays constant, aipanel absorbs the change\n            const vtabW = this.getResolvedVTabWidth();\n            const newAIW = clampAIPanelWidth(newLeftGroupPx - vtabW, windowWidth);\n            this.aiPanelWidth = newAIW;\n            this.debouncedPersistAIWidth(newAIW);\n        } else if (this.vtabVisible) {\n            const clamped = clampVTabWidth(newLeftGroupPx);\n            this.vtabWidth = clamped;\n            this.debouncedPersistVTabWidth(clamped);\n        } else if (this.aiPanelVisible) {\n            const clamped = clampAIPanelWidth(newLeftGroupPx, windowWidth);\n            this.aiPanelWidth = clamped;\n            this.debouncedPersistAIWidth(clamped);\n        }\n\n        this.commitLayouts(windowWidth);\n    }\n\n    handleInnerPanelLayout(sizes: number[]): void {\n        if (this.inResize) return;\n        if (!this.vtabVisible || !this.aiPanelVisible) return;\n\n        const windowWidth = window.innerWidth;\n        const vtabW = this.getResolvedVTabWidth();\n        const aiW = this.getResolvedAIWidth(windowWidth);\n        const leftGroupW = vtabW + aiW;\n\n        const newVTabW = (sizes[0] / 100) * leftGroupW;\n        const clampedVTab = clampVTabWidth(newVTabW);\n        const newAIW = clampAIPanelWidth(leftGroupW - clampedVTab, windowWidth);\n\n        if (clampedVTab !== this.vtabWidth) {\n            this.vtabWidth = clampedVTab;\n            this.debouncedPersistVTabWidth(clampedVTab);\n        }\n        if (newAIW !== this.aiPanelWidth) {\n            this.aiPanelWidth = newAIW;\n            this.debouncedPersistAIWidth(newAIW);\n        }\n\n        this.commitLayouts(windowWidth);\n    }\n\n    handleWindowResize(): void {\n        this.commitLayouts(window.innerWidth);\n    }\n\n    // ---- Registration & sync ----\n\n    syncVTabWidthFromMeta(): void {\n        const savedVTabWidth = globalStore.get(this.getVTabBarWidthAtom());\n        if (savedVTabWidth != null && savedVTabWidth > 0 && savedVTabWidth !== this.vtabWidth) {\n            this.vtabWidth = savedVTabWidth;\n            this.commitLayouts(window.innerWidth);\n        }\n    }\n\n    registerRefs(\n        aiPanelRef: ImperativePanelHandle,\n        outerPanelGroupRef: ImperativePanelGroupHandle,\n        innerPanelGroupRef: ImperativePanelGroupHandle,\n        panelContainerRef: HTMLDivElement,\n        aiPanelWrapperRef: HTMLDivElement,\n        vtabPanelRef?: ImperativePanelHandle,\n        showLeftTabBar?: boolean\n    ): void {\n        this.aiPanelRef = aiPanelRef;\n        this.vtabPanelRef = vtabPanelRef ?? null;\n        this.outerPanelGroupRef = outerPanelGroupRef;\n        this.innerPanelGroupRef = innerPanelGroupRef;\n        this.panelContainerRef = panelContainerRef;\n        this.aiPanelWrapperRef = aiPanelWrapperRef;\n        this.vtabVisible = showLeftTabBar ?? false;\n        globalStore.set(this.vtabVisibleAtom, this.vtabVisible);\n        this.syncPanelCollapse();\n        this.commitLayouts(window.innerWidth);\n    }\n\n    private syncPanelCollapse(): void {\n        if (this.aiPanelRef) {\n            if (this.aiPanelVisible) {\n                this.aiPanelRef.expand();\n            } else {\n                this.aiPanelRef.collapse();\n            }\n        }\n        if (this.vtabPanelRef) {\n            if (this.vtabVisible) {\n                this.vtabPanelRef.expand();\n            } else {\n                this.vtabPanelRef.collapse();\n            }\n        }\n    }\n\n    // ---- Transitions ----\n\n    enableTransitions(duration: number): void {\n        if (!this.panelContainerRef) return;\n        const panels = this.panelContainerRef.querySelectorAll(\"[data-panel]\");\n        panels.forEach((panel: HTMLElement) => {\n            panel.style.transition = \"flex 0.2s ease-in-out\";\n        });\n        if (this.transitionTimeoutRef) {\n            clearTimeout(this.transitionTimeoutRef);\n        }\n        this.transitionTimeoutRef = setTimeout(() => {\n            if (!this.panelContainerRef) return;\n            const panels = this.panelContainerRef.querySelectorAll(\"[data-panel]\");\n            panels.forEach((panel: HTMLElement) => {\n                panel.style.transition = \"none\";\n            });\n        }, duration);\n    }\n\n    // ---- Wrapper width (AI panel inner content width) ----\n\n    updateWrapperWidth(): void {\n        if (!this.aiPanelWrapperRef) return;\n        const width = this.getResolvedAIWidth(window.innerWidth);\n        this.aiPanelWrapperRef.style.width = `${width}px`;\n    }\n\n    // ---- Public getters ----\n\n    getAIPanelVisible(): boolean {\n        this.initializeFromMeta();\n        return this.aiPanelVisible;\n    }\n\n    getAIPanelWidth(): number {\n        return this.getResolvedAIWidth(window.innerWidth);\n    }\n\n    // ---- Initial percentage helpers (used by workspace.tsx for defaultSize) ----\n\n    getLeftGroupInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number {\n        this.initializeFromMeta();\n        const vtabW = showLeftTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0;\n        const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0;\n        return ((vtabW + aiW) / windowWidth) * 100;\n    }\n\n    getInnerVTabInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number {\n        if (!showLeftTabBar || isBuilderWindow()) return 0;\n        this.initializeFromMeta();\n        const vtabW = this.getResolvedVTabWidth();\n        const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0;\n        const total = vtabW + aiW;\n        if (total === 0) return 50;\n        return (vtabW / total) * 100;\n    }\n\n    getInnerAIPanelInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number {\n        this.initializeFromMeta();\n        const vtabW = showLeftTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0;\n        const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0;\n        const total = vtabW + aiW;\n        if (total === 0) return 50;\n        return (aiW / total) * 100;\n    }\n\n    // ---- Toggle visibility ----\n\n    setAIPanelVisible(visible: boolean, opts?: { nofocus?: boolean }): void {\n        if (this.focusTimeoutRef != null) {\n            clearTimeout(this.focusTimeoutRef);\n            this.focusTimeoutRef = null;\n        }\n        const wasVisible = this.aiPanelVisible;\n        this.aiPanelVisible = visible;\n        if (visible && !wasVisible) {\n            recordTEvent(\"action:openwaveai\");\n        }\n        globalStore.set(this.panelVisibleAtom, visible);\n        getApi().setWaveAIOpen(visible);\n        RpcApi.SetMetaCommand(TabRpcClient, {\n            oref: WOS.makeORef(\"tab\", this.getTabId()),\n            meta: { \"waveai:panelopen\": visible },\n        });\n        this.enableTransitions(250);\n        this.syncPanelCollapse();\n        this.commitLayouts(window.innerWidth);\n\n        if (visible) {\n            if (!opts?.nofocus) {\n                this.focusTimeoutRef = setTimeout(() => {\n                    WaveAIModel.getInstance().focusInput();\n                    this.focusTimeoutRef = null;\n                }, 350);\n            }\n        } else {\n            const layoutModel = getLayoutModelForStaticTab();\n            const focusedNode = globalStore.get(layoutModel.focusedNode);\n            if (focusedNode == null) {\n                layoutModel.focusFirstNode();\n                return;\n            }\n            const blockId = focusedNode?.data?.blockId;\n            if (blockId != null) {\n                refocusNode(blockId);\n            }\n        }\n    }\n\n    setShowLeftTabBar(showLeftTabBar: boolean): void {\n        if (this.vtabVisible === showLeftTabBar) return;\n        this.vtabVisible = showLeftTabBar;\n        globalStore.set(this.vtabVisibleAtom, showLeftTabBar);\n        this.enableTransitions(250);\n        this.syncPanelCollapse();\n        this.commitLayouts(window.innerWidth);\n    }\n}\n\nexport { WorkspaceLayoutModel };\n"
  },
  {
    "path": "frontend/app/workspace/workspace.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { AIPanel } from \"@/app/aipanel/aipanel\";\nimport { ErrorBoundary } from \"@/app/element/errorboundary\";\nimport { CenteredDiv } from \"@/app/element/quickelems\";\nimport { ModalsRenderer } from \"@/app/modals/modalsrenderer\";\nimport { TabBar } from \"@/app/tab/tabbar\";\nimport { TabContent } from \"@/app/tab/tabcontent\";\nimport { VTabBar } from \"@/app/tab/vtabbar\";\nimport { Widgets } from \"@/app/workspace/widgets\";\nimport { WorkspaceLayoutModel } from \"@/app/workspace/workspace-layout-model\";\nimport { atoms, getApi, getSettingsKeyAtom } from \"@/store/global\";\nimport { isMacOS } from \"@/util/platformutil\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useEffect, useRef } from \"react\";\nimport {\n    ImperativePanelGroupHandle,\n    ImperativePanelHandle,\n    Panel,\n    PanelGroup,\n    PanelResizeHandle,\n} from \"react-resizable-panels\";\n\nconst MacOSTabBarSpacer = memo(() => {\n    return (\n        <div\n            className=\"w-full shrink-0\"\n            style={\n                {\n                    height: \"calc(8px * var(--zoomfactor-inv))\",\n                    WebkitAppRegion: \"drag\",\n                    backdropFilter: \"blur(20px)\",\n                    background: \"rgba(0, 0, 0, 0.35)\",\n                } as React.CSSProperties\n            }\n        />\n    );\n});\nMacOSTabBarSpacer.displayName = \"MacOSTabBarSpacer\";\n\nconst WorkspaceElem = memo(() => {\n    const workspaceLayoutModel = WorkspaceLayoutModel.getInstance();\n    const tabId = useAtomValue(atoms.staticTabId);\n    const ws = useAtomValue(atoms.workspace);\n    const tabBarPosition = useAtomValue(getSettingsKeyAtom(\"app:tabbar\")) ?? \"top\";\n    const showLeftTabBar = tabBarPosition === \"left\";\n    const aiPanelVisible = useAtomValue(workspaceLayoutModel.panelVisibleAtom);\n    const vtabVisible = useAtomValue(workspaceLayoutModel.vtabVisibleAtom);\n    const windowWidth = window.innerWidth;\n    const leftGroupInitialPct = workspaceLayoutModel.getLeftGroupInitialPercentage(windowWidth, showLeftTabBar);\n    const innerVTabInitialPct = workspaceLayoutModel.getInnerVTabInitialPercentage(windowWidth, showLeftTabBar);\n    const innerAIPanelInitialPct = workspaceLayoutModel.getInnerAIPanelInitialPercentage(windowWidth, showLeftTabBar);\n    const outerPanelGroupRef = useRef<ImperativePanelGroupHandle>(null);\n    const innerPanelGroupRef = useRef<ImperativePanelGroupHandle>(null);\n    const aiPanelRef = useRef<ImperativePanelHandle>(null);\n    const vtabPanelRef = useRef<ImperativePanelHandle>(null);\n    const panelContainerRef = useRef<HTMLDivElement>(null);\n    const aiPanelWrapperRef = useRef<HTMLDivElement>(null);\n\n    // showLeftTabBar is passed as a seed value only; subsequent changes are handled by setShowLeftTabBar below.\n    // Do NOT add showLeftTabBar as a dep here — re-registering refs on config changes would redundantly re-run commitLayouts.\n    useEffect(() => {\n        if (\n            aiPanelRef.current &&\n            outerPanelGroupRef.current &&\n            innerPanelGroupRef.current &&\n            panelContainerRef.current &&\n            aiPanelWrapperRef.current\n        ) {\n            workspaceLayoutModel.registerRefs(\n                aiPanelRef.current,\n                outerPanelGroupRef.current,\n                innerPanelGroupRef.current,\n                panelContainerRef.current,\n                aiPanelWrapperRef.current,\n                vtabPanelRef.current ?? undefined,\n                showLeftTabBar\n            );\n        }\n    }, []);\n\n    useEffect(() => {\n        const isVisible = workspaceLayoutModel.getAIPanelVisible();\n        getApi().setWaveAIOpen(isVisible);\n    }, []);\n\n    useEffect(() => {\n        workspaceLayoutModel.setShowLeftTabBar(showLeftTabBar);\n    }, [showLeftTabBar]);\n\n    useEffect(() => {\n        window.addEventListener(\"resize\", workspaceLayoutModel.handleWindowResize);\n        return () => window.removeEventListener(\"resize\", workspaceLayoutModel.handleWindowResize);\n    }, []);\n\n    useEffect(() => {\n        const handleFocus = () => workspaceLayoutModel.syncVTabWidthFromMeta();\n        window.addEventListener(\"focus\", handleFocus);\n        return () => window.removeEventListener(\"focus\", handleFocus);\n    }, []);\n\n    const innerHandleVisible = vtabVisible && aiPanelVisible;\n    const innerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${innerHandleVisible ? \"w-0.5\" : \"w-0 pointer-events-none\"}`;\n    const outerHandleVisible = vtabVisible || aiPanelVisible;\n    const outerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${outerHandleVisible ? \"w-0.5\" : \"w-0 pointer-events-none\"}`;\n\n    return (\n        <div className=\"flex flex-col w-full flex-grow overflow-hidden\">\n            {!(showLeftTabBar && isMacOS()) && <TabBar key={ws.oid} workspace={ws} noTabs={showLeftTabBar} />}\n            {showLeftTabBar && isMacOS() && <MacOSTabBarSpacer />}\n            <div ref={panelContainerRef} className=\"flex flex-row flex-grow overflow-hidden\">\n                <ErrorBoundary key={tabId}>\n                    <PanelGroup\n                        direction=\"horizontal\"\n                        onLayout={workspaceLayoutModel.handleOuterPanelLayout}\n                        ref={outerPanelGroupRef}\n                    >\n                        <Panel order={0} defaultSize={leftGroupInitialPct} className=\"overflow-hidden\">\n                            <PanelGroup\n                                direction=\"horizontal\"\n                                onLayout={workspaceLayoutModel.handleInnerPanelLayout}\n                                ref={innerPanelGroupRef}\n                            >\n                                <Panel\n                                    ref={vtabPanelRef}\n                                    collapsible\n                                    defaultSize={innerVTabInitialPct}\n                                    order={0}\n                                    className=\"overflow-hidden\"\n                                >\n                                    {showLeftTabBar && <VTabBar workspace={ws} />}\n                                </Panel>\n                                <PanelResizeHandle className={innerHandleClass} />\n                                <Panel\n                                    ref={aiPanelRef}\n                                    collapsible\n                                    defaultSize={innerAIPanelInitialPct}\n                                    order={1}\n                                    className=\"overflow-hidden\"\n                                >\n                                    <div\n                                        ref={aiPanelWrapperRef}\n                                        className={`w-full h-full pr-0.5 ${aiPanelVisible ? \"\" : \"opacity-0\"}`}\n                                    >\n                                        {tabId !== \"\" && <AIPanel roundTopLeft={showLeftTabBar} />}\n                                    </div>\n                                </Panel>\n                            </PanelGroup>\n                        </Panel>\n                        <PanelResizeHandle className={outerHandleClass} />\n                        <Panel order={1} defaultSize={100 - leftGroupInitialPct}>\n                            {tabId === \"\" ? (\n                                <CenteredDiv>No Active Tab</CenteredDiv>\n                            ) : (\n                                <div className=\"flex flex-row h-full\">\n                                    <TabContent key={tabId} tabId={tabId} noTopPadding={showLeftTabBar && isMacOS()} />\n                                    <Widgets />\n                                </div>\n                            )}\n                        </Panel>\n                    </PanelGroup>\n                    <ModalsRenderer />\n                </ErrorBoundary>\n            </div>\n        </div>\n    );\n});\n\nWorkspaceElem.displayName = \"WorkspaceElem\";\n\nexport { WorkspaceElem as Workspace };\n"
  },
  {
    "path": "frontend/builder/app-selection-modal.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { FlexiModal } from \"@/app/modals/modal\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { atoms, getApi } from \"@/store/global\";\nimport * as WOS from \"@/store/wos\";\nimport { formatRelativeTime } from \"@/util/util\";\nimport { useEffect, useState } from \"react\";\n\nconst MaxAppNameLength = 50;\nconst AppNameRegex = /^[a-zA-Z0-9_-]+$/;\n\nfunction CreateNewWaveApp({ onCreateApp }: { onCreateApp: (appName: string) => Promise<void> }) {\n    const [newAppName, setNewAppName] = useState(\"\");\n    const [inputError, setInputError] = useState(\"\");\n    const [isCreating, setIsCreating] = useState(false);\n\n    const validateAppName = (name: string) => {\n        if (!name.trim()) {\n            setInputError(\"\");\n            return false;\n        }\n        if (name.length > MaxAppNameLength) {\n            setInputError(`Name must be ${MaxAppNameLength} characters or less`);\n            return false;\n        }\n        if (!AppNameRegex.test(name)) {\n            setInputError(\"Only letters, numbers, hyphens, and underscores allowed\");\n            return false;\n        }\n        setInputError(\"\");\n        return true;\n    };\n\n    const handleCreate = async () => {\n        const trimmedName = newAppName.trim();\n        if (!validateAppName(trimmedName)) {\n            return;\n        }\n\n        setIsCreating(true);\n        try {\n            await onCreateApp(trimmedName);\n        } finally {\n            setIsCreating(false);\n        }\n    };\n\n    return (\n        <div className=\"min-h-[80px]\">\n            <h3 className=\"text-base font-medium mb-1 text-muted-foreground\">Create New WaveApp</h3>\n            <div className=\"relative\">\n                <div className=\"flex w-full\">\n                    <input\n                        type=\"text\"\n                        value={newAppName}\n                        onChange={(e) => {\n                            const value = e.target.value;\n                            setNewAppName(value);\n                            validateAppName(value);\n                        }}\n                        onKeyDown={(e) => {\n                            if (e.key === \"Enter\" && !e.nativeEvent.isComposing && newAppName.trim() && !inputError) {\n                                handleCreate();\n                            }\n                        }}\n                        placeholder=\"my-app\"\n                        maxLength={MaxAppNameLength}\n                        className={`flex-1 px-3 py-2 bg-panel border rounded-l focus:outline-none transition-colors ${\n                            inputError ? \"border-error\" : \"border-border focus:border-accent\"\n                        }`}\n                        autoFocus\n                        disabled={isCreating}\n                    />\n                    <button\n                        onClick={handleCreate}\n                        disabled={!newAppName.trim() || !!inputError || isCreating}\n                        className={`px-4 py-2 rounded-r transition-colors font-medium whitespace-nowrap ${\n                            !newAppName.trim() || inputError || isCreating\n                                ? \"bg-panel border border-l-0 border-border text-muted cursor-not-allowed\"\n                                : \"bg-accent text-black hover:bg-accent-hover cursor-pointer\"\n                        }`}\n                    >\n                        Create\n                    </button>\n                </div>\n                {inputError && (\n                    <div className=\"absolute left-0 top-full mt-1 text-xs text-error flex items-center gap-1.5 whitespace-nowrap\">\n                        <i className=\"fa-solid fa-circle-exclamation\"></i>\n                        <span>{inputError}</span>\n                    </div>\n                )}\n            </div>\n        </div>\n    );\n}\n\nexport function AppSelectionModal() {\n    const [apps, setApps] = useState<AppInfo[]>([]);\n    const [loading, setLoading] = useState(true);\n    const [error, setError] = useState(\"\");\n\n    useEffect(() => {\n        loadApps();\n    }, []);\n\n    const loadApps = async () => {\n        try {\n            const appList = await RpcApi.ListAllEditableAppsCommand(TabRpcClient);\n            const sortedApps = (appList || []).sort((a, b) => b.modtime - a.modtime);\n            setApps(sortedApps);\n        } catch (err) {\n            console.error(\"Failed to load apps:\", err);\n            setError(\"Failed to load apps\");\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    const handleSelectApp = async (appId: string) => {\n        let appIdToUse = appId;\n\n        // If selecting a local app, convert it to a draft first\n        if (appId.startsWith(\"local/\")) {\n            try {\n                const result = await RpcApi.MakeDraftFromLocalCommand(TabRpcClient, { localappid: appId });\n                appIdToUse = result.draftappid;\n            } catch (err) {\n                console.error(\"Failed to create draft from local app:\", err);\n                setError(`Failed to create draft from ${appId}: ${err.message || String(err)}`);\n                return;\n            }\n        }\n\n        const builderId = globalStore.get(atoms.builderId);\n        const oref = WOS.makeORef(\"builder\", builderId);\n        await RpcApi.SetRTInfoCommand(TabRpcClient, {\n            oref,\n            data: { \"builder:appid\": appIdToUse },\n        });\n        globalStore.set(atoms.builderAppId, appIdToUse);\n        document.title = `WaveApp Builder (${appIdToUse})`;\n        getApi().setBuilderWindowAppId(appIdToUse);\n    };\n\n    const handleCreateNew = async (appName: string) => {\n        const draftAppId = `draft/${appName}`;\n        const builderId = globalStore.get(atoms.builderId);\n        const oref = WOS.makeORef(\"builder\", builderId);\n        await RpcApi.SetRTInfoCommand(TabRpcClient, {\n            oref,\n            data: { \"builder:appid\": draftAppId },\n        });\n        globalStore.set(atoms.builderAppId, draftAppId);\n        document.title = `WaveApp Builder (${draftAppId})`;\n        getApi().setBuilderWindowAppId(draftAppId);\n    };\n\n    const isDraftApp = (appId: string) => {\n        return appId.startsWith(\"draft/\");\n    };\n\n    const getAppDisplayName = (appId: string) => {\n        const parts = appId.split(\"/\");\n        if (parts.length === 2) {\n            const isDraft = parts[0] === \"draft\";\n            return isDraft ? `${parts[1]} (draft)` : parts[1];\n        }\n        return appId;\n    };\n\n    if (loading) {\n        return (\n            <FlexiModal className=\"min-w-[600px] w-[600px]\">\n                <div className=\"text-center py-8\">Loading apps...</div>\n            </FlexiModal>\n        );\n    }\n\n    return (\n        <FlexiModal className=\"min-w-[600px] w-[600px] max-h-[90vh] overflow-y-auto\">\n            <div className=\"w-full px-2 pt-0 pb-4\">\n                <h2 className=\"text-2xl mb-2\">Select a WaveApp to Edit</h2>\n\n                {error && (\n                    <div className=\"mb-6 px-4 py-3 bg-panel rounded\">\n                        <div className=\"flex items-center gap-3\">\n                            <i className=\"fa-solid fa-circle-exclamation text-warning\"></i>\n                            <span>{error}</span>\n                        </div>\n                    </div>\n                )}\n\n                {apps.length > 0 && (\n                    <div className=\"mb-2\">\n                        <h3 className=\"text-base font-medium mb-1 text-muted-foreground\">Existing WaveApps</h3>\n                        <div className=\"space-y-2 max-h-[220px] overflow-y-auto\">\n                            {apps.map((appInfo) => (\n                                <button\n                                    key={appInfo.appid}\n                                    onClick={() => handleSelectApp(appInfo.appid)}\n                                    className=\"w-full text-left px-4 py-1.5 bg-panel hover:bg-hover border border-border rounded transition-colors cursor-pointer\"\n                                >\n                                    <div className=\"flex items-center gap-3\">\n                                        <i className=\"fa-solid fa-cube self-center\"></i>\n                                        <div className=\"flex flex-col\">\n                                            <span>{getAppDisplayName(appInfo.appid)}</span>\n                                            <span className=\"text-[11px] text-muted mt-0.5\">\n                                                Last updated: {formatRelativeTime(appInfo.modtime)}\n                                            </span>\n                                        </div>\n                                    </div>\n                                </button>\n                            ))}\n                        </div>\n                    </div>\n                )}\n\n                {apps.length > 0 && (\n                    <div className=\"flex items-center gap-4 my-2\">\n                        <div className=\"flex-1 border-t border-border\"></div>\n                        <span className=\"text-muted-foreground text-sm\">or</span>\n                        <div className=\"flex-1 border-t border-border\"></div>\n                    </div>\n                )}\n\n                <CreateNewWaveApp onCreateApp={handleCreateNew} />\n            </div>\n        </FlexiModal>\n    );\n}\n"
  },
  {
    "path": "frontend/builder/builder-app.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { ModalsRenderer } from \"@/app/modals/modalsrenderer\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { AppSelectionModal } from \"@/builder/app-selection-modal\";\nimport { BuilderWorkspace } from \"@/builder/builder-workspace\";\nimport { atoms, isDev } from \"@/store/global\";\nimport { appHandleKeyDown } from \"@/store/keymodel\";\nimport * as keyutil from \"@/util/keyutil\";\nimport { isBlank } from \"@/util/util\";\nimport { Provider, useAtomValue } from \"jotai\";\nimport { useEffect } from \"react\";\nimport { DndProvider } from \"react-dnd\";\nimport { HTML5Backend } from \"react-dnd-html5-backend\";\n\ntype BuilderAppProps = {\n    initOpts: BuilderInitOpts;\n    onFirstRender: () => void;\n};\n\nconst BuilderKeyHandlers = () => {\n    useEffect(() => {\n        const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown);\n        document.addEventListener(\"keydown\", staticKeyDownHandler);\n\n        return () => {\n            document.removeEventListener(\"keydown\", staticKeyDownHandler);\n        };\n    }, []);\n    return null;\n};\n\nfunction BuilderAppInner() {\n    const builderAppId = useAtomValue(atoms.builderAppId);\n    const hasDraftApp = !isBlank(builderAppId) && builderAppId.startsWith(\"draft/\");\n\n    return (\n        <div className=\"w-full h-full flex flex-col bg-main-bg text-main-text\">\n            <BuilderKeyHandlers />\n            <div\n                className=\"h-9 shrink-0 border-b border-b-border flex items-center justify-center gap-2\"\n                style={{ WebkitAppRegion: \"drag\" } as React.CSSProperties}\n            >\n                {isDev() ? (\n                    <div className=\"text-accent text-xl\" title=\"Running Wave Dev Build\">\n                        <i className=\"fa fa-brands fa-dev fa-fw\" />\n                    </div>\n                ) : null}\n                <div className=\"text-sm font-medium\">\n                    WaveApp Builder{!isBlank(builderAppId) && ` (${builderAppId})`}\n                </div>\n            </div>\n            <DndProvider backend={HTML5Backend}>\n                {hasDraftApp ? <BuilderWorkspace /> : <AppSelectionModal />}\n            </DndProvider>\n            <ModalsRenderer />\n        </div>\n    );\n}\n\nexport function BuilderApp({ initOpts, onFirstRender }: BuilderAppProps) {\n    useEffect(() => {\n        onFirstRender();\n    }, []);\n\n    return (\n        <Provider store={globalStore}>\n            <BuilderAppInner />\n        </Provider>\n    );\n}\n"
  },
  {
    "path": "frontend/builder/builder-apppanel.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Modal } from \"@/app/modals/modal\";\nimport { ContextMenuModel } from \"@/app/store/contextmenu\";\nimport { modalsModel } from \"@/app/store/modalmodel\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { BuilderAppPanelModel, type TabType } from \"@/builder/store/builder-apppanel-model\";\nimport { BuilderFocusManager } from \"@/builder/store/builder-focusmanager\";\nimport { BuilderCodeTab } from \"@/builder/tabs/builder-codetab\";\nimport { BuilderConfigDataTab } from \"@/builder/tabs/builder-configdatatab\";\nimport { BuilderFilesTab, DeleteFileModal, RenameFileModal } from \"@/builder/tabs/builder-filestab\";\nimport { BuilderPreviewTab } from \"@/builder/tabs/builder-previewtab\";\nimport { BuilderSecretTab } from \"@/builder/tabs/builder-secrettab\";\nimport { builderAppHasSelection } from \"@/builder/utils/builder-focus-utils\";\nimport { ErrorBoundary } from \"@/element/errorboundary\";\nimport { atoms } from \"@/store/global\";\nimport { cn } from \"@/util/util\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useCallback, useEffect, useRef, useState } from \"react\";\n\nconst StatusDot = memo(() => {\n    const model = BuilderAppPanelModel.getInstance();\n    const builderStatus = useAtomValue(model.builderStatusAtom);\n\n    const getStatusDotColor = (status: string | null | undefined): string => {\n        if (!status) return \"bg-gray-500\";\n        switch (status) {\n            case \"init\":\n            case \"stopped\":\n                return \"bg-gray-500\";\n            case \"building\":\n                return \"bg-warning\";\n            case \"running\":\n                return \"bg-success\";\n            case \"error\":\n                return \"bg-error\";\n            default:\n                return \"bg-gray-500\";\n        }\n    };\n\n    const statusDotColor = getStatusDotColor(builderStatus?.status);\n\n    return <span className={cn(\"w-2 h-2 rounded-full\", statusDotColor)} />;\n});\n\nStatusDot.displayName = \"StatusDot\";\n\ntype TabButtonProps = {\n    label: string;\n    tabType: TabType;\n    isActive: boolean;\n    isAppFocused: boolean;\n    onClick: () => void;\n    showStatusDot?: boolean;\n};\n\nconst TabButton = memo(({ label, tabType, isActive, isAppFocused, onClick, showStatusDot }: TabButtonProps) => {\n    return (\n        <button\n            className={cn(\n                \"px-4 py-2 text-sm font-medium transition-colors cursor-pointer\",\n                isActive\n                    ? `text-primary border-b-2 ${isAppFocused ? \"border-accent\" : \"border-gray-500\"}`\n                    : \"text-secondary hover:text-primary border-b-2 border-transparent\"\n            )}\n            onClick={onClick}\n        >\n            <span className=\"flex items-center gap-2\">\n                {showStatusDot && <StatusDot />}\n                {label}\n            </span>\n        </button>\n    );\n});\n\nTabButton.displayName = \"TabButton\";\n\nconst ErrorStrip = memo(() => {\n    const model = BuilderAppPanelModel.getInstance();\n    const errorMsg = useAtomValue(model.errorAtom);\n\n    if (!errorMsg) return null;\n    return (\n        <div className=\"shrink-0 bg-error/10 border-b border-error/30 px-4 py-2 flex items-center justify-between gap-4\">\n            <div className=\"flex items-center gap-3 flex-1 min-w-0\">\n                <i className=\"fa fa-triangle-exclamation text-error text-sm\" />\n                <span className=\"text-error text-sm flex-1 truncate\">{errorMsg}</span>\n            </div>\n            <button\n                onClick={() => model.clearError()}\n                className=\"shrink-0 text-error hover:text-error/80 transition-colors cursor-pointer\"\n                aria-label=\"Close error\"\n            >\n                <i className=\"fa fa-xmark-large text-sm\" />\n            </button>\n        </div>\n    );\n});\n\nErrorStrip.displayName = \"ErrorStrip\";\n\nconst PublishAppModal = memo(({ appName }: { appName: string }) => {\n    const builderAppId = useAtomValue(atoms.builderAppId);\n    const [state, setState] = useState<\"confirm\" | \"success\" | \"error\">(\"confirm\");\n    const [errorMessage, setErrorMessage] = useState<string>(\"\");\n    const [publishedAppId, setPublishedAppId] = useState<string>(\"\");\n\n    const handlePublish = async () => {\n        if (!builderAppId) {\n            setErrorMessage(\"No builder app ID found\");\n            setState(\"error\");\n            return;\n        }\n\n        try {\n            const result = await RpcApi.PublishAppCommand(TabRpcClient, { appid: builderAppId });\n            setPublishedAppId(result.publishedappid);\n            setState(\"success\");\n        } catch (error) {\n            setErrorMessage(error instanceof Error ? error.message : String(error));\n            setState(\"error\");\n        }\n    };\n\n    const handleClose = () => {\n        modalsModel.popModal();\n    };\n\n    if (state === \"success\") {\n        return (\n            <Modal className=\"p-4\" onOk={handleClose} onClose={handleClose} okLabel=\"OK\" cancelLabel=\"\">\n                <div className=\"flex flex-col gap-4 mb-4\">\n                    <h2 className=\"text-xl font-semibold flex items-center gap-2\">\n                        <i className=\"fa fa-check-circle text-success\" />\n                        App Published Successfully\n                    </h2>\n                    <div className=\"flex flex-col gap-3\">\n                        <p className=\"text-primary\">\n                            Your app has been published to <span className=\"font-mono\">{publishedAppId}</span>\n                        </p>\n                    </div>\n                </div>\n            </Modal>\n        );\n    }\n\n    if (state === \"error\") {\n        return (\n            <Modal className=\"p-4\" onOk={handleClose} onClose={handleClose} okLabel=\"OK\" cancelLabel=\"\">\n                <div className=\"flex flex-col gap-4 mb-4\">\n                    <h2 className=\"text-xl font-semibold flex items-center gap-2\">\n                        <i className=\"fa fa-triangle-exclamation text-error\" />\n                        Publish Failed\n                    </h2>\n                    <div className=\"flex flex-col gap-3\">\n                        <p className=\"text-error\">{errorMessage}</p>\n                    </div>\n                </div>\n            </Modal>\n        );\n    }\n\n    return (\n        <Modal\n            className=\"p-4\"\n            onOk={handlePublish}\n            onCancel={handleClose}\n            onClose={handleClose}\n            okLabel=\"Publish\"\n            cancelLabel=\"Cancel\"\n        >\n            <div className=\"flex flex-col gap-4 mb-4\">\n                <h2 className=\"text-xl font-semibold\">Publish App</h2>\n                <div className=\"flex flex-col gap-3\">\n                    <p className=\"text-primary\">\n                        This will publish your app to <span className=\"font-mono\">local/{appName}</span>\n                    </p>\n                    <p className=\"text-warning\">\n                        <i className=\"fa fa-triangle-exclamation mr-2\" />\n                        This will overwrite any existing app with the same name. Are you sure?\n                    </p>\n                </div>\n            </div>\n        </Modal>\n    );\n});\n\nPublishAppModal.displayName = \"PublishAppModal\";\n\nconst BuilderAppPanel = memo(() => {\n    const model = BuilderAppPanelModel.getInstance();\n    const focusElemRef = useRef<HTMLInputElement>(null);\n    const activeTab = useAtomValue(model.activeTab);\n    const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType);\n    const isAppFocused = focusType === \"app\";\n    const builderAppId = useAtomValue(atoms.builderAppId);\n    const builderId = useAtomValue(atoms.builderId);\n    const hasSecrets = useAtomValue(model.hasSecretsAtom);\n\n    useEffect(() => {\n        model.initialize();\n    }, []);\n\n    if (focusElemRef.current) {\n        model.setFocusElemRef(focusElemRef.current);\n    }\n\n    const handleTabClick = (tab: TabType) => {\n        model.setActiveTab(tab);\n        BuilderFocusManager.getInstance().setAppFocused();\n        model.giveFocus();\n    };\n\n    const handleFocusCapture = useCallback((event: React.FocusEvent) => {\n        BuilderFocusManager.getInstance().setAppFocused();\n    }, []);\n\n    const handlePanelClick = useCallback(\n        (e: React.MouseEvent) => {\n            const target = e.target as HTMLElement;\n            const isInteractive = target.closest('button, a, input, textarea, select, [role=\"button\"], [tabindex]');\n\n            if (isInteractive) {\n                return;\n            }\n\n            const hasSelection = builderAppHasSelection();\n            if (hasSelection) {\n                BuilderFocusManager.getInstance().setAppFocused();\n                return;\n            }\n\n            setTimeout(() => {\n                if (!builderAppHasSelection()) {\n                    BuilderFocusManager.getInstance().setAppFocused();\n                    model.giveFocus();\n                }\n            }, 0);\n        },\n        [model]\n    );\n\n    const handleRestart = useCallback(() => {\n        model.restartBuilder();\n    }, [model]);\n\n    const handlePublishClick = useCallback(() => {\n        if (!builderAppId) return;\n        const appName = builderAppId.replace(\"draft/\", \"\");\n        modalsModel.pushModal(\"PublishAppModal\", { appName });\n    }, [builderAppId]);\n\n    const handleSwitchAppClick = useCallback(() => {\n        model.switchBuilderApp();\n    }, [model]);\n\n    const handleKebabClick = useCallback(\n        (e: React.MouseEvent) => {\n            const menu: ContextMenuItem[] = [\n                {\n                    label: \"Publish App\",\n                    click: handlePublishClick,\n                },\n                {\n                    type: \"separator\",\n                },\n                {\n                    label: \"Switch App\",\n                    click: handleSwitchAppClick,\n                },\n            ];\n            ContextMenuModel.getInstance().showContextMenu(menu, e);\n        },\n        [handleSwitchAppClick, handlePublishClick]\n    );\n\n    return (\n        <div\n            className=\"w-full h-full flex flex-col border-b-3 border-border shadow-[0_2px_4px_rgba(0,0,0,0.1)]\"\n            data-builder-app-panel=\"true\"\n            onClick={handlePanelClick}\n            onFocusCapture={handleFocusCapture}\n        >\n            <div key=\"focuselem\" className=\"h-0 w-0\">\n                <input\n                    type=\"text\"\n                    value=\"\"\n                    ref={focusElemRef}\n                    className=\"h-0 w-0 opacity-0 pointer-events-none\"\n                    onChange={() => {}}\n                />\n            </div>\n            <div className=\"shrink-0 border-b border-border\">\n                <div className=\"flex items-center justify-between\">\n                    <div className=\"flex\">\n                        <TabButton\n                            label=\"Preview\"\n                            tabType=\"preview\"\n                            isActive={activeTab === \"preview\"}\n                            isAppFocused={isAppFocused}\n                            onClick={() => handleTabClick(\"preview\")}\n                            showStatusDot={true}\n                        />\n                        <TabButton\n                            label=\"Code\"\n                            tabType=\"code\"\n                            isActive={activeTab === \"code\"}\n                            isAppFocused={isAppFocused}\n                            onClick={() => handleTabClick(\"code\")}\n                        />\n                        <TabButton\n                            label=\"Config/Data\"\n                            tabType=\"configdata\"\n                            isActive={activeTab === \"configdata\"}\n                            isAppFocused={isAppFocused}\n                            onClick={() => handleTabClick(\"configdata\")}\n                        />\n                        <TabButton\n                            label=\"Files\"\n                            tabType=\"files\"\n                            isActive={activeTab === \"files\"}\n                            isAppFocused={isAppFocused}\n                            onClick={() => handleTabClick(\"files\")}\n                        />\n                        {hasSecrets && (\n                            <TabButton\n                                label=\"Secrets\"\n                                tabType=\"secrets\"\n                                isActive={activeTab === \"secrets\"}\n                                isAppFocused={isAppFocused}\n                                onClick={() => handleTabClick(\"secrets\")}\n                            />\n                        )}\n                    </div>\n                    <div className=\"flex items-center gap-2 mr-2\">\n                        <button\n                            className=\"px-3 py-1 text-sm font-medium rounded bg-accent/80 text-primary hover:bg-accent transition-colors cursor-pointer\"\n                            onClick={handlePublishClick}\n                        >\n                            Publish App\n                        </button>\n                        <button\n                            className=\"px-2 py-1 text-sm font-medium rounded hover:bg-secondary/10 transition-colors cursor-pointer\"\n                            onClick={handleKebabClick}\n                            aria-label=\"More options\"\n                        >\n                            <i className=\"fa fa-ellipsis-vertical\" />\n                        </button>\n                    </div>\n                </div>\n            </div>\n            <ErrorStrip />\n            <div className=\"flex-1 overflow-auto py-1\">\n                <div className=\"w-full h-full\" style={{ display: activeTab === \"preview\" ? \"block\" : \"none\" }}>\n                    <ErrorBoundary>\n                        <BuilderPreviewTab />\n                    </ErrorBoundary>\n                </div>\n                <div className=\"w-full h-full\" style={{ display: activeTab === \"code\" ? \"block\" : \"none\" }}>\n                    <ErrorBoundary>\n                        <BuilderCodeTab />\n                    </ErrorBoundary>\n                </div>\n                <div className=\"w-full h-full\" style={{ display: activeTab === \"files\" ? \"block\" : \"none\" }}>\n                    <ErrorBoundary>\n                        <BuilderFilesTab />\n                    </ErrorBoundary>\n                </div>\n                <div className=\"w-full h-full\" style={{ display: activeTab === \"secrets\" ? \"block\" : \"none\" }}>\n                    <ErrorBoundary>\n                        <BuilderSecretTab />\n                    </ErrorBoundary>\n                </div>\n                <div className=\"w-full h-full\" style={{ display: activeTab === \"configdata\" ? \"block\" : \"none\" }}>\n                    <ErrorBoundary>\n                        <BuilderConfigDataTab />\n                    </ErrorBoundary>\n                </div>\n            </div>\n        </div>\n    );\n});\n\nBuilderAppPanel.displayName = \"BuilderAppPanel\";\n\nexport { BuilderAppPanel, DeleteFileModal, PublishAppModal, RenameFileModal };\n"
  },
  {
    "path": "frontend/builder/builder-buildpanel.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { WaveAIModel } from \"@/app/aipanel/waveai-model\";\nimport { ContextMenuModel } from \"@/app/store/contextmenu\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { BuilderAppPanelModel } from \"@/builder/store/builder-apppanel-model\";\nimport { BuilderBuildPanelModel } from \"@/builder/store/builder-buildpanel-model\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useCallback, useEffect, useRef } from \"react\";\nimport { debounce } from \"throttle-debounce\";\n\nfunction handleBuildPanelContextMenu(e: React.MouseEvent, selectedText: string): void {\n    e.preventDefault();\n    e.stopPropagation();\n\n    if (!selectedText) {\n        return;\n    }\n\n    const menu: ContextMenuItem[] = [\n        { role: \"copy\" },\n        { type: \"separator\" },\n        {\n            label: \"Add to Context\",\n            click: () => {\n                const model = WaveAIModel.getInstance();\n                const formattedText = `from builder output:\\n\\`\\`\\`\\n${selectedText}\\n\\`\\`\\``;\n                model.appendText(formattedText, true);\n                model.focusInput();\n            },\n        },\n    ];\n    ContextMenuModel.getInstance().showContextMenu(menu, e);\n}\n\nconst BuilderBuildPanel = memo(() => {\n    const model = BuilderBuildPanelModel.getInstance();\n    const outputLines = useAtomValue(model.outputLines);\n    const showDebug = useAtomValue(model.showDebug);\n    const scrollRef = useRef<HTMLDivElement>(null);\n    const preRef = useRef<HTMLPreElement>(null);\n\n    useEffect(() => {\n        model.initialize();\n        return () => {\n            model.dispose();\n        };\n    }, []);\n\n    useEffect(() => {\n        if (scrollRef.current) {\n            scrollRef.current.scrollTop = scrollRef.current.scrollHeight;\n        }\n    }, [outputLines]);\n\n    const debouncedCopyOnSelect = useCallback(\n        debounce(50, () => {\n            const selection = window.getSelection();\n            if (selection && selection.toString().length > 0) {\n                navigator.clipboard.writeText(selection.toString());\n            }\n        }),\n        []\n    );\n\n    const handleMouseUp = useCallback(() => {\n        debouncedCopyOnSelect();\n    }, [debouncedCopyOnSelect]);\n\n    const handleContextMenu = useCallback((e: React.MouseEvent) => {\n        const selection = window.getSelection();\n        const selectedText = selection ? selection.toString() : \"\";\n        handleBuildPanelContextMenu(e, selectedText);\n    }, []);\n\n    const handleDebugToggle = useCallback(() => {\n        globalStore.set(model.showDebug, !showDebug);\n    }, [model, showDebug]);\n\n    const handleRestart = useCallback(() => {\n        BuilderAppPanelModel.getInstance().restartBuilder();\n    }, []);\n\n    const handleSendToAI = useCallback(() => {\n        const currentShowDebug = globalStore.get(model.showDebug);\n        const currentOutputLines = globalStore.get(model.outputLines);\n        const filtered = currentShowDebug\n            ? currentOutputLines\n            : currentOutputLines.filter((line) => !line.startsWith(\"[debug]\") && line.trim().length > 0);\n\n        const linesToSend = filtered.slice(-200);\n        const text = linesToSend.join(\"\\n\");\n        const aiModel = WaveAIModel.getInstance();\n        const formattedText = `from builder output:\\n\\`\\`\\`\\n${text}\\n\\`\\`\\`\\n`;\n        aiModel.appendText(formattedText, true, { scrollToBottom: true });\n        aiModel.focusInput();\n    }, [model]);\n\n    const filteredLines = showDebug\n        ? outputLines\n        : outputLines.filter((line) => !line.startsWith(\"[debug]\") && line.trim().length > 0);\n\n    return (\n        <div className=\"w-full h-full flex flex-col bg-black rounded-br-2\">\n            <div className=\"flex-shrink-0 px-3 py-2 border-b border-gray-700 flex items-center justify-between\">\n                <span className=\"text-sm font-semibold text-gray-300\">Build Output</span>\n                <div className=\"flex items-center gap-4\">\n                    <label className=\"flex items-center gap-2 text-sm text-gray-300 cursor-pointer\">\n                        <input\n                            type=\"checkbox\"\n                            checked={showDebug}\n                            onChange={handleDebugToggle}\n                            className=\"cursor-pointer\"\n                        />\n                        Debug\n                    </label>\n                    <button\n                        className=\"px-3 py-1 text-sm font-medium rounded transition-colors bg-accent/80 text-white hover:bg-accent cursor-pointer\"\n                        onClick={handleSendToAI}\n                    >\n                        Send Output to AI\n                    </button>\n                    <button\n                        className=\"px-3 py-1 text-sm font-medium rounded transition-colors bg-accent/80 text-white hover:bg-accent cursor-pointer\"\n                        onClick={handleRestart}\n                    >\n                        Restart App\n                    </button>\n                </div>\n            </div>\n            <div ref={scrollRef} className=\"flex-1 overflow-y-auto overflow-x-auto p-2\">\n                <pre\n                    ref={preRef}\n                    className=\"font-mono text-xs text-gray-100 whitespace-pre\"\n                    onMouseUp={handleMouseUp}\n                    onContextMenu={handleContextMenu}\n                >\n                    {/* this comment fixes JSX blank line in pre tag */}\n                    {filteredLines.length === 0 ? (\n                        <span className=\"text-secondary\">Waiting for output...</span>\n                    ) : (\n                        filteredLines.join(\"\\n\")\n                    )}\n                </pre>\n            </div>\n        </div>\n    );\n});\n\nBuilderBuildPanel.displayName = \"BuilderBuildPanel\";\n\nexport { BuilderBuildPanel };\n"
  },
  {
    "path": "frontend/builder/builder-workspace.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { AIPanel } from \"@/app/aipanel/aipanel\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { BuilderAppPanel } from \"@/builder/builder-apppanel\";\nimport { BuilderBuildPanel } from \"@/builder/builder-buildpanel\";\nimport { BuilderFocusManager } from \"@/builder/store/builder-focusmanager\";\nimport { atoms } from \"@/store/global\";\nimport { cn } from \"@/util/util\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useCallback, useEffect, useState } from \"react\";\nimport { Panel, PanelGroup, PanelResizeHandle } from \"react-resizable-panels\";\nimport { debounce } from \"throttle-debounce\";\n\nconst DefaultLayoutPercentages = {\n    chat: 50,\n    app: 80,\n    build: 20,\n};\n\nconst BuilderWorkspace = memo(() => {\n    const builderId = useAtomValue(atoms.builderId);\n    const [layout, setLayout] = useState<Record<string, number>>(null);\n    const [isLoading, setIsLoading] = useState(true);\n    const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType);\n    const isAppFocused = focusType === \"app\";\n\n    useEffect(() => {\n        const loadLayout = async () => {\n            if (!builderId) {\n                setLayout(DefaultLayoutPercentages);\n                setIsLoading(false);\n                return;\n            }\n\n            try {\n                const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, {\n                    oref: `builder:${builderId}`,\n                });\n                if (rtInfo?.[\"builder:layout\"]) {\n                    setLayout(rtInfo[\"builder:layout\"] as Record<string, number>);\n                } else {\n                    setLayout(DefaultLayoutPercentages);\n                }\n            } catch (error) {\n                console.error(\"Failed to load builder layout:\", error);\n                setLayout(DefaultLayoutPercentages);\n            } finally {\n                setIsLoading(false);\n            }\n        };\n\n        loadLayout();\n    }, [builderId]);\n\n    const saveLayout = useCallback(\n        debounce(500, (newLayout: Record<string, number>) => {\n            if (!builderId) return;\n\n            RpcApi.SetRTInfoCommand(TabRpcClient, {\n                oref: `builder:${builderId}`,\n                data: {\n                    \"builder:layout\": newLayout,\n                },\n            }).catch((error) => {\n                console.error(\"Failed to save builder layout:\", error);\n            });\n        }),\n        [builderId]\n    );\n\n    const handleHorizontalLayout = useCallback(\n        (sizes: number[]) => {\n            const newLayout = { ...layout, chat: sizes[0] };\n            setLayout(newLayout);\n            saveLayout(newLayout);\n        },\n        [layout, saveLayout]\n    );\n\n    const handleVerticalLayout = useCallback(\n        (sizes: number[]) => {\n            const newLayout = { ...layout, app: sizes[0], build: sizes[1] };\n            setLayout(newLayout);\n            saveLayout(newLayout);\n        },\n        [layout, saveLayout]\n    );\n\n    if (isLoading || !layout) {\n        return null;\n    }\n\n    return (\n        <div className=\"flex-1 overflow-hidden\">\n            <PanelGroup direction=\"horizontal\" onLayout={handleHorizontalLayout}>\n                <Panel defaultSize={layout.chat} minSize={20}>\n                    <AIPanel roundTopLeft={false} />\n                </Panel>\n                <PanelResizeHandle className=\"w-0.5 bg-transparent hover:bg-gray-500/20 transition-colors\" />\n                <Panel defaultSize={100 - layout.chat} minSize={20}>\n                    <div\n                        className={cn(\n                            \"flex flex-col relative h-full\",\n                            isAppFocused ? \"border-2 border-accent\" : \"border-2 border-transparent\"\n                        )}\n                        style={{\n                            borderBottomRightRadius: 8,\n                        }}\n                    >\n                        <PanelGroup direction=\"vertical\" onLayout={handleVerticalLayout}>\n                            <Panel defaultSize={layout.app} minSize={20}>\n                                <BuilderAppPanel />\n                            </Panel>\n                            <PanelResizeHandle className=\"h-0.5 bg-transparent hover:bg-gray-500/20 transition-colors\" />\n                            <Panel\n                                defaultSize={layout.build}\n                                minSize={20}\n                                maxSize={50}\n                                style={{ borderBottomRightRadius: 8 }}\n                            >\n                                <BuilderBuildPanel />\n                            </Panel>\n                        </PanelGroup>\n                    </div>\n                </Panel>\n            </PanelGroup>\n        </div>\n    );\n});\n\nBuilderWorkspace.displayName = \"BuilderWorkspace\";\n\nexport { BuilderWorkspace };\n"
  },
  {
    "path": "frontend/builder/store/builder-apppanel-model.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { waveEventSubscribeSingle } from \"@/app/store/wps\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { atoms, getApi, WOS } from \"@/store/global\";\nimport { base64ToString, stringToBase64 } from \"@/util/util\";\nimport { atom, type Atom, type PrimitiveAtom } from \"jotai\";\nimport type * as MonacoTypes from \"monaco-editor\";\nimport { debounce } from \"throttle-debounce\";\n\nexport type TabType = \"preview\" | \"files\" | \"code\" | \"secrets\" | \"configdata\";\n\nexport type EnvVar = {\n    name: string;\n    value: string;\n    visible?: boolean;\n};\n\nexport class BuilderAppPanelModel {\n    private static instance: BuilderAppPanelModel | null = null;\n\n    activeTab: PrimitiveAtom<TabType> = atom<TabType>(\"preview\");\n    codeContentAtom: PrimitiveAtom<string> = atom<string>(\"\");\n    originalContentAtom: PrimitiveAtom<string> = atom<string>(\"\");\n    envVarsArrayAtom: PrimitiveAtom<EnvVar[]> = atom<EnvVar[]>([]);\n    envVarIndexAtoms: Atom<EnvVar | null>[] = [];\n    envVarsDirtyAtom: PrimitiveAtom<boolean> = atom<boolean>(false);\n    isLoadingAtom: PrimitiveAtom<boolean> = atom<boolean>(false);\n    errorAtom: PrimitiveAtom<string> = atom<string>(\"\");\n    builderStatusAtom = atom<BuilderStatusData>(null) as PrimitiveAtom<BuilderStatusData>;\n    hasSecretsAtom: PrimitiveAtom<boolean> = atom<boolean>(false);\n    saveNeededAtom!: Atom<boolean>;\n    focusElemRef: { current: HTMLInputElement | null } = { current: null };\n    monacoEditorRef: { current: MonacoTypes.editor.IStandaloneCodeEditor | null } = { current: null };\n    statusUnsubFn: (() => void) | null = null;\n    appGoUpdateUnsubFn: (() => void) | null = null;\n    debouncedRestart: (() => void) & { cancel: () => void };\n    initialized = false;\n\n    private constructor() {\n        this.debouncedRestart = debounce(800, () => {\n            this.restartBuilder();\n        });\n        this.saveNeededAtom = atom((get) => {\n            return get(this.codeContentAtom) !== get(this.originalContentAtom);\n        });\n    }\n\n    static getInstance(): BuilderAppPanelModel {\n        if (!BuilderAppPanelModel.instance) {\n            BuilderAppPanelModel.instance = new BuilderAppPanelModel();\n        }\n        return BuilderAppPanelModel.instance;\n    }\n\n    setActiveTab(tab: TabType) {\n        globalStore.set(this.activeTab, tab);\n    }\n\n    getActiveTab(): TabType {\n        return globalStore.get(this.activeTab);\n    }\n\n    setCodeContent(content: string) {\n        globalStore.set(this.codeContentAtom, content);\n    }\n\n    async initialize() {\n        if (this.initialized) return;\n        this.initialized = true;\n\n        // builderId is set in initialization so is always available\n        const builderId = globalStore.get(atoms.builderId);\n\n        if (this.statusUnsubFn) {\n            this.statusUnsubFn();\n        }\n\n        this.statusUnsubFn = waveEventSubscribeSingle({\n            eventType: \"builderstatus\",\n            scope: WOS.makeORef(\"builder\", builderId),\n            handler: (event) => {\n                const status = event.data;\n                const currentStatus = globalStore.get(this.builderStatusAtom);\n                if (!currentStatus || !currentStatus.version || status.version > currentStatus.version) {\n                    globalStore.set(this.builderStatusAtom, status);\n                    this.updateSecretsLatch(status);\n                }\n            },\n        });\n\n        try {\n            const status = await RpcApi.GetBuilderStatusCommand(TabRpcClient, builderId);\n            globalStore.set(this.builderStatusAtom, status);\n            this.updateSecretsLatch(status);\n        } catch (err) {\n            console.error(\"Failed to load builder status:\", err);\n        }\n\n        // the apppanel does not render until appId is set, so this will never be null during initialization\n        const appId = globalStore.get(atoms.builderAppId);\n        await this.loadAppFile(appId);\n        await this.loadEnvVars(builderId);\n\n        this.appGoUpdateUnsubFn = waveEventSubscribeSingle({\n            eventType: \"waveapp:appgoupdated\",\n            scope: appId,\n            handler: () => {\n                this.loadAppFile(appId);\n            },\n        });\n    }\n\n    updateSecretsLatch(status: BuilderStatusData) {\n        if (!status?.manifest?.secrets) return;\n        const secrets = status.manifest.secrets;\n        if (Object.keys(secrets).length > 0) {\n            globalStore.set(this.hasSecretsAtom, true);\n        }\n    }\n\n    updateSecretBindings(newBindings: { [key: string]: string }) {\n        const currentStatus = globalStore.get(this.builderStatusAtom);\n        if (currentStatus) {\n            globalStore.set(this.builderStatusAtom, {\n                ...currentStatus,\n                secretbindings: newBindings,\n            });\n        }\n    }\n\n    async loadEnvVars(builderId: string) {\n        try {\n            const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, {\n                oref: WOS.makeORef(\"builder\", builderId),\n            });\n            const envVars = rtInfo?.[\"builder:env\"] || {};\n            const envVarsArray = Object.entries(envVars).map(([name, value]) => ({ name, value, visible: false }));\n            globalStore.set(this.envVarsArrayAtom, envVarsArray);\n            globalStore.set(this.envVarsDirtyAtom, false);\n        } catch (err) {\n            console.error(\"Failed to load environment variables:\", err);\n        }\n    }\n\n    async saveEnvVars(builderId: string) {\n        try {\n            const envVarsArray = globalStore.get(this.envVarsArrayAtom);\n            const envVars: Record<string, string> = {};\n            envVarsArray.forEach((v) => {\n                const trimmedName = v.name.trim();\n                if (trimmedName) {\n                    envVars[trimmedName] = v.value;\n                }\n            });\n            const cleanedArray = Object.entries(envVars).map(([name, value]) => ({ name, value, visible: false }));\n            await RpcApi.SetRTInfoCommand(TabRpcClient, {\n                oref: WOS.makeORef(\"builder\", builderId),\n                data: {\n                    \"builder:env\": envVars,\n                },\n            });\n            globalStore.set(this.envVarsArrayAtom, cleanedArray);\n            globalStore.set(this.envVarsDirtyAtom, false);\n            globalStore.set(this.errorAtom, \"\");\n            this.debouncedRestart();\n        } catch (err) {\n            console.error(\"Failed to save environment variables:\", err);\n            globalStore.set(this.errorAtom, `Failed to save environment variables: ${err.message || \"Unknown error\"}`);\n        }\n    }\n\n    getEnvVarIndexAtom(index: number): Atom<EnvVar | null> {\n        if (!this.envVarIndexAtoms[index]) {\n            this.envVarIndexAtoms[index] = atom((get) => {\n                const array = get(this.envVarsArrayAtom);\n                return array[index] ?? null;\n            });\n        }\n        return this.envVarIndexAtoms[index];\n    }\n\n    addEnvVar() {\n        const current = globalStore.get(this.envVarsArrayAtom);\n        globalStore.set(this.envVarsArrayAtom, [...current, { name: \"\", value: \"\", visible: false }]);\n        globalStore.set(this.envVarsDirtyAtom, true);\n    }\n\n    removeEnvVar(index: number) {\n        const current = globalStore.get(this.envVarsArrayAtom);\n        const newArray = current.filter((_, i) => i !== index);\n        globalStore.set(this.envVarsArrayAtom, newArray);\n        globalStore.set(this.envVarsDirtyAtom, true);\n    }\n\n    setEnvVarAtIndex(index: number, envVar: EnvVar, dirty: boolean) {\n        const current = globalStore.get(this.envVarsArrayAtom);\n        const newArray = [...current];\n        newArray[index] = envVar;\n        globalStore.set(this.envVarsArrayAtom, newArray);\n        if (dirty) {\n            globalStore.set(this.envVarsDirtyAtom, true);\n        }\n    }\n\n    async startBuilder() {\n        const builderId = globalStore.get(atoms.builderId);\n        try {\n            await RpcApi.StartBuilderCommand(TabRpcClient, {\n                builderid: builderId,\n            });\n        } catch (err) {\n            console.error(\"Failed to start builder:\", err);\n            globalStore.set(this.errorAtom, `Failed to start builder: ${err.message || \"Unknown error\"}`);\n        }\n    }\n\n    async restartBuilder() {\n        // the RPC call that starts the builder actually forces a restart, so this works\n        return this.startBuilder();\n    }\n\n    async switchBuilderApp() {\n        const builderId = globalStore.get(atoms.builderId);\n        try {\n            await RpcApi.DeleteBuilderCommand(TabRpcClient, builderId);\n            await new Promise((resolve) => setTimeout(resolve, 500));\n            await RpcApi.SetRTInfoCommand(TabRpcClient, {\n                oref: WOS.makeORef(\"builder\", builderId),\n                data: { \"builder:appid\": null },\n            });\n            getApi().setBuilderWindowAppId(null);\n            await new Promise((resolve) => setTimeout(resolve, 100));\n            getApi().doRefresh();\n        } catch (err) {\n            console.error(\"Failed to switch builder app:\", err);\n            globalStore.set(this.errorAtom, `Failed to switch builder app: ${err.message || \"Unknown error\"}`);\n        }\n    }\n\n    async loadAppFile(appId: string) {\n        try {\n            globalStore.set(this.isLoadingAtom, true);\n            globalStore.set(this.errorAtom, \"\");\n\n            const result = await RpcApi.ReadAppFileCommand(TabRpcClient, {\n                appid: appId,\n                filename: \"app.go\",\n            });\n\n            if (result.notfound) {\n                globalStore.set(this.codeContentAtom, \"\");\n                globalStore.set(this.originalContentAtom, \"\");\n            } else {\n                const decoded = base64ToString(result.data64);\n                globalStore.set(this.codeContentAtom, decoded);\n                globalStore.set(this.originalContentAtom, decoded);\n\n                if (decoded.trim() !== \"\") {\n                    const currentStatus = globalStore.get(this.builderStatusAtom);\n                    if (currentStatus?.status !== \"running\" && currentStatus?.status !== \"building\") {\n                        await this.startBuilder();\n                    }\n                }\n            }\n        } catch (err) {\n            console.error(\"Failed to load app.go:\", err);\n            globalStore.set(this.errorAtom, `Failed to load app.go: ${err.message || \"Unknown error\"}`);\n        } finally {\n            globalStore.set(this.isLoadingAtom, false);\n        }\n    }\n\n    async saveAppFile(appId: string) {\n        try {\n            const content = globalStore.get(this.codeContentAtom);\n            const encoded = stringToBase64(content);\n            const result = await RpcApi.WriteAppGoFileCommand(TabRpcClient, {\n                appid: appId,\n                data64: encoded,\n            });\n            const formattedContent = base64ToString(result.data64);\n            globalStore.set(this.codeContentAtom, formattedContent);\n            globalStore.set(this.originalContentAtom, formattedContent);\n            globalStore.set(this.errorAtom, \"\");\n            this.debouncedRestart();\n        } catch (err) {\n            console.error(\"Failed to save app.go:\", err);\n            globalStore.set(this.errorAtom, `Failed to save app.go: ${err.message || \"Unknown error\"}`);\n        }\n    }\n\n    clearError() {\n        globalStore.set(this.errorAtom, \"\");\n    }\n\n    giveFocus() {\n        const activeTab = globalStore.get(this.activeTab);\n        if (activeTab === \"code\" && this.monacoEditorRef.current) {\n            this.monacoEditorRef.current.focus();\n        } else {\n            this.focusElemRef.current?.focus();\n        }\n    }\n\n    setFocusElemRef(ref: HTMLInputElement | null) {\n        this.focusElemRef.current = ref;\n    }\n\n    setMonacoEditorRef(ref: MonacoTypes.editor.IStandaloneCodeEditor | null) {\n        this.monacoEditorRef.current = ref;\n    }\n\n    dispose() {\n        if (this.statusUnsubFn) {\n            this.statusUnsubFn();\n            this.statusUnsubFn = null;\n        }\n        if (this.appGoUpdateUnsubFn) {\n            this.appGoUpdateUnsubFn();\n            this.appGoUpdateUnsubFn = null;\n        }\n        this.debouncedRestart.cancel();\n    }\n}\n"
  },
  {
    "path": "frontend/builder/store/builder-buildpanel-model.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { waveEventSubscribeSingle } from \"@/app/store/wps\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { atoms, WOS } from \"@/store/global\";\nimport { atom, type PrimitiveAtom } from \"jotai\";\n\nexport class BuilderBuildPanelModel {\n    private static instance: BuilderBuildPanelModel | null = null;\n\n    outputLines: PrimitiveAtom<string[]> = atom<string[]>([]);\n    showDebug: PrimitiveAtom<boolean> = atom<boolean>(false);\n    outputUnsubFn: (() => void) | null = null;\n    initialized = false;\n\n    private constructor() {}\n\n    static getInstance(): BuilderBuildPanelModel {\n        if (!BuilderBuildPanelModel.instance) {\n            BuilderBuildPanelModel.instance = new BuilderBuildPanelModel();\n        }\n        return BuilderBuildPanelModel.instance;\n    }\n\n    async initialize() {\n        if (this.initialized) return;\n        this.initialized = true;\n\n        const builderId = globalStore.get(atoms.builderId);\n        if (!builderId) return;\n\n        if (this.outputUnsubFn) {\n            this.outputUnsubFn();\n        }\n\n        this.outputUnsubFn = waveEventSubscribeSingle({\n            eventType: \"builderoutput\",\n            scope: WOS.makeORef(\"builder\", builderId),\n            handler: (event) => {\n                const data = event.data as { lines?: string[]; reset?: boolean };\n                if (!data) return;\n\n                if (data.reset) {\n                    globalStore.set(this.outputLines, data.lines || []);\n                } else if (data.lines && data.lines.length > 0) {\n                    globalStore.set(this.outputLines, (prev) => [...prev, ...data.lines]);\n                }\n            },\n        });\n\n        try {\n            const output = await RpcApi.GetBuilderOutputCommand(TabRpcClient, builderId);\n            globalStore.set(this.outputLines, output || []);\n        } catch (err) {\n            console.error(\"Failed to load builder output:\", err);\n        }\n    }\n\n    clearOutput() {\n        globalStore.set(this.outputLines, []);\n    }\n\n    dispose() {\n        if (this.outputUnsubFn) {\n            this.outputUnsubFn();\n            this.outputUnsubFn = null;\n        }\n        this.initialized = false;\n    }\n}"
  },
  {
    "path": "frontend/builder/store/builder-focusmanager.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { atom, type PrimitiveAtom } from \"jotai\";\n\nexport type BuilderFocusType = \"waveai\" | \"app\";\n\nexport class BuilderFocusManager {\n    private static instance: BuilderFocusManager | null = null;\n\n    focusType: PrimitiveAtom<BuilderFocusType> = atom(\"app\");\n\n    private constructor() {}\n\n    static getInstance(): BuilderFocusManager {\n        if (!BuilderFocusManager.instance) {\n            BuilderFocusManager.instance = new BuilderFocusManager();\n        }\n        return BuilderFocusManager.instance;\n    }\n\n    setWaveAIFocused() {\n        globalStore.set(this.focusType, \"waveai\");\n    }\n\n    setAppFocused() {\n        globalStore.set(this.focusType, \"app\");\n    }\n\n    getFocusType(): BuilderFocusType {\n        return globalStore.get(this.focusType);\n    }\n}\n"
  },
  {
    "path": "frontend/builder/tabs/builder-codetab.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { CodeEditor } from \"@/app/view/codeeditor/codeeditor\";\nimport { BuilderAppPanelModel } from \"@/builder/store/builder-apppanel-model\";\nimport { atoms } from \"@/store/global\";\nimport * as keyutil from \"@/util/keyutil\";\nimport { cn } from \"@/util/util\";\nimport { useAtomValue } from \"jotai\";\nimport type * as MonacoTypes from \"monaco-editor\";\nimport { memo, useEffect } from \"react\";\n\nconst BuilderCodeTab = memo(() => {\n    const model = BuilderAppPanelModel.getInstance();\n    const builderAppId = useAtomValue(atoms.builderAppId);\n    const codeContent = useAtomValue(model.codeContentAtom);\n    const isLoading = useAtomValue(model.isLoadingAtom);\n    const error = useAtomValue(model.errorAtom);\n    const saveNeeded = useAtomValue(model.saveNeededAtom);\n    const activeTab = useAtomValue(model.activeTab);\n\n    useEffect(() => {\n        if (activeTab === \"code\" && model.monacoEditorRef.current) {\n            setTimeout(() => {\n                model.monacoEditorRef.current?.layout();\n            }, 0);\n        }\n    }, [activeTab, model.monacoEditorRef]);\n\n    const handleCodeChange = (newText: string) => {\n        model.setCodeContent(newText);\n    };\n\n    const handleEditorMount = (editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: typeof MonacoTypes) => {\n        model.setMonacoEditorRef(editor);\n        return () => {\n            model.setMonacoEditorRef(null);\n        };\n    };\n\n    const handleSave = () => {\n        if (builderAppId) {\n            model.saveAppFile(builderAppId);\n        }\n    };\n\n    const handleKeyDown = keyutil.keydownWrapper((waveEvent: WaveKeyboardEvent) => {\n        if (keyutil.checkKeyPressed(waveEvent, \"Cmd:s\")) {\n            handleSave();\n            return true;\n        }\n        return false;\n    });\n\n    if (!builderAppId) {\n        return (\n            <div className=\"w-full h-full flex items-center justify-center\">\n                <div className=\"text-secondary\">No builder app selected</div>\n            </div>\n        );\n    }\n\n    if (isLoading) {\n        return (\n            <div className=\"w-full h-full flex items-center justify-center\">\n                <div className=\"text-secondary\">Loading app.go...</div>\n            </div>\n        );\n    }\n\n    if (error) {\n        return (\n            <div className=\"w-full h-full flex items-center justify-center\">\n                <div className=\"text-red-500\">{error}</div>\n            </div>\n        );\n    }\n\n    return (\n        <div className=\"w-full h-full relative\" onKeyDown={handleKeyDown}>\n            <button\n                className={cn(\n                    \"absolute top-1 right-4 z-50 px-3 py-1 text-sm font-medium rounded transition-colors shadow-lg\",\n                    saveNeeded\n                        ? \"bg-accent/80 text-primary hover:bg-accent cursor-pointer\"\n                        : \"bg-gray-600 text-gray-400 cursor-default\"\n                )}\n                onClick={saveNeeded ? handleSave : undefined}\n            >\n                Save\n            </button>\n            <CodeEditor\n                blockId={builderAppId}\n                text={codeContent}\n                readonly={false}\n                language=\"go\"\n                fileName=\"app.go\"\n                onChange={handleCodeChange}\n                onMount={handleEditorMount}\n            />\n        </div>\n    );\n});\n\nBuilderCodeTab.displayName = \"BuilderCodeTab\";\n\nexport { BuilderCodeTab };\n"
  },
  {
    "path": "frontend/builder/tabs/builder-configdatatab.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { BuilderAppPanelModel } from \"@/builder/store/builder-apppanel-model\";\nimport { CopyButton } from \"@/element/copybutton\";\nimport { atoms } from \"@/store/global\";\nimport { cn } from \"@/util/util\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useCallback, useEffect, useState } from \"react\";\n\nconst NotRunningView = memo(() => {\n    return (\n        <div className=\"w-full h-full flex items-center justify-center bg-background\">\n            <div className=\"flex flex-col items-center gap-6 max-w-[500px] text-center px-8\">\n                <i className=\"fa fa-triangle-exclamation text-6xl text-warning\" />\n                <div className=\"flex flex-col gap-3\">\n                    <h2 className=\"text-2xl font-semibold text-primary\">App Not Running</h2>\n                    <p className=\"text-base text-secondary leading-relaxed\">\n                        The tsunami app must be running to view config and data. Please start the app from the Preview\n                        tab first.\n                    </p>\n                </div>\n            </div>\n        </div>\n    );\n});\n\nNotRunningView.displayName = \"NotRunningView\";\n\nconst ErrorView = memo(({ errorMsg }: { errorMsg: string }) => {\n    return (\n        <div className=\"w-full h-full flex items-center justify-center bg-background\">\n            <div className=\"flex flex-col items-center gap-6 max-w-2xl text-center px-8\">\n                <i className=\"fa fa-circle-xmark text-6xl text-error\" />\n                <div className=\"flex flex-col gap-3\">\n                    <h2 className=\"text-2xl font-semibold text-error\">Error Loading Data</h2>\n                    <div className=\"text-left bg-panel border border-error/30 rounded-lg p-4\">\n                        <pre className=\"text-sm text-secondary whitespace-pre-wrap font-mono\">{errorMsg}</pre>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n});\n\nErrorView.displayName = \"ErrorView\";\n\nconst LoadingView = memo(() => {\n    return (\n        <div className=\"w-full h-full flex items-center justify-center bg-background\">\n            <div className=\"flex flex-col items-center gap-6\">\n                <i className=\"fa fa-spinner fa-spin text-6xl text-secondary\" />\n                <p className=\"text-base text-secondary\">Loading data...</p>\n            </div>\n        </div>\n    );\n});\n\nLoadingView.displayName = \"LoadingView\";\n\ntype ConfigDataState = {\n    config: any;\n    data: any;\n    error: string | null;\n    isLoading: boolean;\n};\n\nconst BuilderConfigDataTab = memo(() => {\n    const model = BuilderAppPanelModel.getInstance();\n    const builderStatus = useAtomValue(model.builderStatusAtom);\n    const builderId = useAtomValue(atoms.builderId);\n    const activeTab = useAtomValue(model.activeTab);\n    const [state, setState] = useState<ConfigDataState>({\n        config: null,\n        data: null,\n        error: null,\n        isLoading: false,\n    });\n\n    const isRunning = builderStatus?.status === \"running\" && builderStatus?.port && builderStatus.port !== 0;\n\n    const fetchData = useCallback(async () => {\n        if (!isRunning || !builderStatus?.port) {\n            return;\n        }\n\n        setState((prev) => ({ ...prev, isLoading: true, error: null }));\n\n        try {\n            const baseUrl = `http://localhost:${builderStatus.port}`;\n\n            const [configResponse, dataResponse] = await Promise.all([\n                fetch(`${baseUrl}/api/config`),\n                fetch(`${baseUrl}/api/data`),\n            ]);\n\n            if (!configResponse.ok) {\n                throw new Error(`Failed to fetch config: ${configResponse.statusText}`);\n            }\n            if (!dataResponse.ok) {\n                throw new Error(`Failed to fetch data: ${dataResponse.statusText}`);\n            }\n\n            const config = await configResponse.json();\n            const data = await dataResponse.json();\n\n            setState({\n                config,\n                data,\n                error: null,\n                isLoading: false,\n            });\n        } catch (err) {\n            setState({\n                config: null,\n                data: null,\n                error: err instanceof Error ? err.message : String(err),\n                isLoading: false,\n            });\n        }\n    }, [isRunning, builderStatus?.port]);\n\n    const handleRefresh = useCallback(async () => {\n        setState({\n            config: null,\n            data: null,\n            error: null,\n            isLoading: true,\n        });\n\n        await new Promise((resolve) => setTimeout(resolve, 200));\n        await fetchData();\n    }, [fetchData]);\n\n    const handleCopyConfig = useCallback(() => {\n        if (state.config) {\n            navigator.clipboard.writeText(JSON.stringify(state.config, null, 2));\n        }\n    }, [state.config]);\n\n    const handleCopyData = useCallback(() => {\n        if (state.data) {\n            navigator.clipboard.writeText(JSON.stringify(state.data, null, 2));\n        }\n    }, [state.data]);\n\n    useEffect(() => {\n        if (activeTab === \"configdata\" && isRunning) {\n            fetchData();\n        } else if (!isRunning) {\n            setState({\n                config: null,\n                data: null,\n                error: null,\n                isLoading: false,\n            });\n        }\n    }, [activeTab, isRunning, fetchData]);\n\n    if (!isRunning) {\n        return <NotRunningView />;\n    }\n\n    if (state.isLoading) {\n        return <LoadingView />;\n    }\n\n    if (state.error) {\n        return <ErrorView errorMsg={state.error} />;\n    }\n\n    if (!state.config && !state.data) {\n        return <LoadingView />;\n    }\n\n    return (\n        <div className=\"w-full h-full flex flex-col bg-background\">\n            <div className=\"shrink-0 flex items-center justify-between px-4 py-2 border-b border-border\">\n                <h3 className=\"text-lg font-semibold text-primary\">Config & Data</h3>\n                <button\n                    onClick={handleRefresh}\n                    className=\"px-3 py-1 text-sm font-medium rounded bg-accent/80 text-primary hover:bg-accent transition-colors cursor-pointer flex items-center gap-2\"\n                >\n                    <i className=\"fa fa-refresh\" />\n                    Refresh\n                </button>\n            </div>\n            <div className=\"flex-1 overflow-auto p-4\">\n                <div className=\"flex flex-col gap-6\">\n                    <div className=\"flex flex-col gap-2\">\n                        <div className=\"flex items-center justify-between\">\n                            <h4 className=\"text-base font-semibold text-primary flex items-center gap-2\">\n                                <i className=\"fa fa-gear\" />\n                                Config\n                            </h4>\n                            <CopyButton title=\"Copy Config\" onClick={handleCopyConfig} />\n                        </div>\n                        <div className=\"bg-panel border border-border rounded-lg p-4 overflow-auto\">\n                            <pre className=\"text-xs text-primary font-mono whitespace-pre\">\n                                {JSON.stringify(state.config, null, 2)}\n                            </pre>\n                        </div>\n                    </div>\n                    <div className=\"flex flex-col gap-2\">\n                        <div className=\"flex items-center justify-between\">\n                            <h4 className=\"text-base font-semibold text-primary flex items-center gap-2\">\n                                <i className=\"fa fa-database\" />\n                                Data\n                            </h4>\n                            <CopyButton title=\"Copy Data\" onClick={handleCopyData} />\n                        </div>\n                        <div className=\"bg-panel border border-border rounded-lg p-4 overflow-auto\">\n                            <pre className=\"text-xs text-primary font-mono whitespace-pre\">\n                                {JSON.stringify(state.data, null, 2)}\n                            </pre>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n});\n\nBuilderConfigDataTab.displayName = \"BuilderConfigDataTab\";\n\nexport { BuilderConfigDataTab };"
  },
  {
    "path": "frontend/builder/tabs/builder-filestab.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { formatFileSize } from \"@/app/aipanel/ai-utils\";\nimport { Modal } from \"@/app/modals/modal\";\nimport { ContextMenuModel } from \"@/app/store/contextmenu\";\nimport { modalsModel } from \"@/app/store/modalmodel\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { arrayToBase64 } from \"@/util/util\";\nimport { atoms } from \"@/store/global\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useCallback, useEffect, useRef, useState } from \"react\";\n\nconst MaxFileSize = 5 * 1024 * 1024; // 5MB\nconst ReadOnlyFileNames = [\"static/tw.css\"];\n\ntype FileEntry = {\n    name: string;\n    size: number;\n    modified: string;\n    isReadOnly: boolean;\n};\n\nconst RenameFileModal = memo(\n    ({ appId, fileName, onSuccess }: { appId: string; fileName: string; onSuccess: () => void }) => {\n        const displayName = fileName.replace(\"static/\", \"\");\n        const [newName, setNewName] = useState(displayName);\n        const [error, setError] = useState(\"\");\n        const [isRenaming, setIsRenaming] = useState(false);\n\n        const handleRename = async () => {\n            const trimmedName = newName.trim();\n            if (!trimmedName) {\n                setError(\"File name cannot be empty\");\n                return;\n            }\n            if (trimmedName.includes(\"/\") || trimmedName.includes(\"\\\\\")) {\n                setError(\"File name cannot contain / or \\\\\");\n                return;\n            }\n            if (trimmedName === displayName) {\n                modalsModel.popModal();\n                return;\n            }\n\n            setIsRenaming(true);\n            try {\n                await RpcApi.RenameAppFileCommand(TabRpcClient, {\n                    appid: appId,\n                    fromfilename: fileName,\n                    tofilename: `static/${trimmedName}`,\n                });\n                onSuccess();\n                modalsModel.popModal();\n            } catch (err) {\n                console.log(\"Error renaming file:\", err);\n                setError(err instanceof Error ? err.message : String(err));\n            } finally {\n                setIsRenaming(false);\n            }\n        };\n\n        const handleClose = () => {\n            modalsModel.popModal();\n        };\n\n        return (\n            <Modal\n                className=\"p-4 min-w-[500px]\"\n                onOk={handleRename}\n                onCancel={handleClose}\n                onClose={handleClose}\n                okLabel=\"Rename\"\n                cancelLabel=\"Cancel\"\n                okDisabled={isRenaming || !newName.trim()}\n            >\n                <div className=\"flex flex-col gap-4 mb-4\">\n                    <h2 className=\"text-xl font-semibold\">Rename File</h2>\n                    <div className=\"flex flex-col gap-2\">\n                        <div className=\"text-sm text-secondary mb-1\">\n                            Current name: <span className=\"font-medium text-primary\">{displayName}</span>\n                        </div>\n                        <input\n                            type=\"text\"\n                            value={newName}\n                            onChange={(e) => {\n                                setNewName(e.target.value);\n                                setError(\"\");\n                            }}\n                            onKeyDown={(e) => {\n                                if (e.key === \"Enter\" && !e.nativeEvent.isComposing && newName.trim() && !error) {\n                                    handleRename();\n                                }\n                            }}\n                            className=\"px-3 py-2 bg-panel border border-border rounded focus:outline-none focus:border-accent\"\n                            autoFocus\n                            disabled={isRenaming}\n                            spellCheck={false}\n                        />\n                        {error && <div className=\"text-sm text-error\">{error}</div>}\n                    </div>\n                </div>\n            </Modal>\n        );\n    }\n);\n\nRenameFileModal.displayName = \"RenameFileModal\";\n\nconst DeleteFileModal = memo(\n    ({ appId, fileName, onSuccess }: { appId: string; fileName: string; onSuccess: () => void }) => {\n        const [isDeleting, setIsDeleting] = useState(false);\n        const [error, setError] = useState(\"\");\n\n        const handleDelete = async () => {\n            setIsDeleting(true);\n            setError(\"\");\n            try {\n                await RpcApi.DeleteAppFileCommand(TabRpcClient, {\n                    appid: appId,\n                    filename: fileName,\n                });\n                onSuccess();\n                modalsModel.popModal();\n            } catch (err) {\n                console.log(\"Error deleting file:\", err);\n                setError(err instanceof Error ? err.message : String(err));\n            } finally {\n                setIsDeleting(false);\n            }\n        };\n\n        const handleClose = () => {\n            modalsModel.popModal();\n        };\n\n        useEffect(() => {\n            const handleKeyDown = (e: KeyboardEvent) => {\n                if (e.key === \"Enter\" && !isDeleting) {\n                    e.preventDefault();\n                    handleDelete();\n                } else if (e.key === \"Escape\") {\n                    e.preventDefault();\n                    handleClose();\n                }\n            };\n\n            document.addEventListener(\"keydown\", handleKeyDown);\n            return () => document.removeEventListener(\"keydown\", handleKeyDown);\n        }, [isDeleting]);\n\n        return (\n            <Modal\n                className=\"p-4 min-w-[500px]\"\n                onOk={handleDelete}\n                onCancel={handleClose}\n                onClose={handleClose}\n                okLabel=\"Delete\"\n                cancelLabel=\"Cancel\"\n                okDisabled={isDeleting}\n            >\n                <div className=\"flex flex-col gap-4 mb-4\">\n                    <h2 className=\"text-xl font-semibold\">Delete File</h2>\n                    <p>\n                        Are you sure you want to delete <strong>{fileName.replace(\"static/\", \"\")}</strong>?\n                    </p>\n                    <p className=\"text-sm text-secondary\">This action cannot be undone.</p>\n                    {error && <div className=\"text-sm text-error\">{error}</div>}\n                </div>\n            </Modal>\n        );\n    }\n);\n\nDeleteFileModal.displayName = \"DeleteFileModal\";\n\nconst BuilderFilesTab = memo(() => {\n    const builderAppId = useAtomValue(atoms.builderAppId);\n    const [files, setFiles] = useState<FileEntry[]>([]);\n    const [loading, setLoading] = useState(false);\n    const [error, setError] = useState(\"\");\n    const [isDragging, setIsDragging] = useState(false);\n    const [contextMenu, setContextMenu] = useState<{ x: number; y: number; fileName: string } | null>(null);\n    const fileInputRef = useRef<HTMLInputElement>(null);\n\n    const loadFiles = useCallback(async () => {\n        if (!builderAppId) return;\n\n        setLoading(true);\n        setError(\"\");\n        try {\n            const result = await RpcApi.ListAllAppFilesCommand(TabRpcClient, { appid: builderAppId });\n            const fileEntries: FileEntry[] = result.entries\n                .filter((entry) => !entry.dir && entry.name.startsWith(\"static/\"))\n                .map((entry) => ({\n                    name: entry.name,\n                    size: entry.size || 0,\n                    modified: entry.modified,\n                    isReadOnly: ReadOnlyFileNames.includes(entry.name),\n                }))\n                .sort((a, b) => a.name.localeCompare(b.name));\n            setFiles(fileEntries);\n        } catch (err) {\n            setError(err instanceof Error ? err.message : String(err));\n        } finally {\n            setLoading(false);\n        }\n    }, [builderAppId]);\n\n    const handleRefresh = useCallback(async () => {\n        // Clear files and add delay so UX shows the refresh is happening\n        setFiles([]);\n        await new Promise((resolve) => setTimeout(resolve, 100));\n        await loadFiles();\n    }, [loadFiles]);\n\n    useEffect(() => {\n        loadFiles();\n    }, [loadFiles]);\n\n    useEffect(() => {\n        const handleClickOutside = () => setContextMenu(null);\n        if (contextMenu) {\n            document.addEventListener(\"click\", handleClickOutside);\n            return () => document.removeEventListener(\"click\", handleClickOutside);\n        }\n    }, [contextMenu]);\n\n    const handleFileUpload = async (fileList: FileList) => {\n        if (!builderAppId || fileList.length === 0) return;\n\n        const file = fileList[0];\n        if (file.size > MaxFileSize) {\n            setError(`File size exceeds maximum allowed size of ${formatFileSize(MaxFileSize)}`);\n            return;\n        }\n\n        setError(\"\");\n        setLoading(true);\n\n        try {\n            const arrayBuffer = await file.arrayBuffer();\n            const uint8Array = new Uint8Array(arrayBuffer);\n            const base64Encoded = arrayToBase64(uint8Array);\n\n            await RpcApi.WriteAppFileCommand(TabRpcClient, {\n                appid: builderAppId,\n                filename: `static/${file.name}`,\n                data64: base64Encoded,\n            });\n\n            await loadFiles();\n        } catch (err) {\n            console.error(\"Error uploading file:\", err);\n            setError(err instanceof Error ? err.message : String(err));\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    const handleDrop = (e: React.DragEvent) => {\n        e.preventDefault();\n        setIsDragging(false);\n        handleFileUpload(e.dataTransfer.files);\n    };\n\n    const handleDragOver = (e: React.DragEvent) => {\n        e.preventDefault();\n        setIsDragging(true);\n    };\n\n    const handleDragLeave = (e: React.DragEvent) => {\n        e.preventDefault();\n        setIsDragging(false);\n    };\n\n    const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n        if (e.target.files) {\n            handleFileUpload(e.target.files);\n        }\n    };\n\n    const handleContextMenu = (e: React.MouseEvent, fileName: string) => {\n        const menu: ContextMenuItem[] = [\n            {\n                label: \"Rename File\",\n                click: () => {\n                    modalsModel.pushModal(\"RenameFileModal\", { appId: builderAppId, fileName, onSuccess: loadFiles });\n                },\n            },\n            {\n                type: \"separator\",\n            },\n            {\n                label: \"Delete File\",\n                click: () => {\n                    modalsModel.pushModal(\"DeleteFileModal\", { appId: builderAppId, fileName, onSuccess: loadFiles });\n                },\n            },\n        ];\n\n        ContextMenuModel.getInstance().showContextMenu(menu, e);\n    };\n\n    return (\n        <div\n            className={`w-full h-full flex flex-col p-4 border-2 border-dashed transition-colors ${\n                isDragging ? \"bg-accent/5 border-accent\" : \"border-transparent\"\n            }`}\n            onDrop={handleDrop}\n            onDragOver={handleDragOver}\n            onDragLeave={handleDragLeave}\n        >\n            <div className=\"flex items-center justify-between mb-4\">\n                <h2 className=\"text-lg font-semibold\">Static Files</h2>\n                <div className=\"flex gap-2\">\n                    <button\n                        className=\"px-3 py-1 text-sm font-medium rounded bg-panel border border-border hover:bg-hover transition-colors cursor-pointer\"\n                        onClick={handleRefresh}\n                        disabled={loading}\n                        title=\"Refresh file list\"\n                    >\n                        <i className=\"fa fa-refresh\" />\n                    </button>\n                    <button\n                        className=\"px-3 py-1 text-sm font-medium rounded bg-accent/80 text-primary hover:bg-accent transition-colors cursor-pointer\"\n                        onClick={() => fileInputRef.current?.click()}\n                        disabled={loading}\n                    >\n                        <i className=\"fa fa-plus mr-2\" />\n                        Add File\n                    </button>\n                </div>\n                <input ref={fileInputRef} type=\"file\" onChange={handleFileInputChange} className=\"hidden\" />\n            </div>\n\n            {error && (\n                <div className=\"mb-4 p-3 bg-error/10 border border-error/30 rounded text-sm text-error flex items-center gap-2\">\n                    <i className=\"fa fa-triangle-exclamation\" />\n                    <span>{error}</span>\n                </div>\n            )}\n\n            <div className=\"mb-3 p-2 bg-blue-500/10 border border-blue-500/30 rounded text-sm text-secondary\">\n                Drag and drop files here or click \"Add File\". Maximum file size: {formatFileSize(MaxFileSize)}\n            </div>\n\n            <div className=\"flex-1 overflow-auto\">\n                {loading && files.length === 0 ? (\n                    <div className=\"text-center text-secondary py-8\">Loading files...</div>\n                ) : files.length === 0 ? (\n                    <div className=\"text-center text-secondary py-12\">\n                        <i className=\"fa fa-file text-4xl mb-3 opacity-50\" />\n                        <p>No files yet. Drag and drop files here or click \"Add File\" to get started.</p>\n                    </div>\n                ) : (\n                    <div className=\"space-y-1\">\n                        {files.map((file) => (\n                            <div\n                                key={file.name}\n                                className=\"flex items-center gap-3 p-2 bg-panel hover:bg-hover border border-border rounded transition-colors\"\n                                onContextMenu={(e) => !file.isReadOnly && handleContextMenu(e, file.name)}\n                            >\n                                <i className=\"fa fa-file text-secondary\" />\n                                <div className=\"flex-1 min-w-0\">\n                                    <div className=\"font-medium truncate\">{file.name.replace(\"static/\", \"\")}</div>\n                                    <div className=\"text-xs text-secondary\">\n                                        {formatFileSize(file.size)}\n                                        {file.isReadOnly && (\n                                            <span className=\"ml-2 text-warning\">\n                                                <i className=\"fa fa-lock mr-1\" />\n                                                Generated by framework (read-only)\n                                            </span>\n                                        )}\n                                    </div>\n                                </div>\n                                <div className=\"text-xs text-secondary\">{file.modified}</div>\n                                {!file.isReadOnly && (\n                                    <button\n                                        className=\"px-2 py-1 hover:bg-hover rounded transition-colors cursor-pointer\"\n                                        onClick={(e) => handleContextMenu(e, file.name)}\n                                        title=\"File options\"\n                                    >\n                                        <i className=\"fa fa-ellipsis-vertical\" />\n                                    </button>\n                                )}\n                            </div>\n                        ))}\n                    </div>\n                )}\n            </div>\n        </div>\n    );\n});\n\nBuilderFilesTab.displayName = \"BuilderFilesTab\";\n\nexport { BuilderFilesTab, DeleteFileModal, RenameFileModal };\n"
  },
  {
    "path": "frontend/builder/tabs/builder-previewtab.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { WaveAIModel } from \"@/app/aipanel/waveai-model\";\nimport { BuilderAppPanelModel } from \"@/builder/store/builder-apppanel-model\";\nimport { BuilderBuildPanelModel } from \"@/builder/store/builder-buildpanel-model\";\nimport { atoms } from \"@/store/global\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useState } from \"react\";\n\nconst EmptyStateView = memo(() => {\n    return (\n        <div className=\"w-full h-full flex items-center justify-center bg-background\">\n            <div className=\"flex flex-col items-center gap-6 max-w-[500px] text-center px-8\">\n                <div className=\"text-6xl\">🏗️</div>\n                <div className=\"flex flex-col gap-3\">\n                    <h2 className=\"text-2xl font-semibold text-primary\">No App to Preview</h2>\n                    <p className=\"text-base text-secondary leading-relaxed\">\n                        Get started by using the AI chat interface on the left to create your WaveApp. Describe what you\n                        want to build, and the AI will help you generate the code.\n                    </p>\n                </div>\n                <div className=\"text-base text-secondary mt-2\">\n                    Your app will appear here once <span className=\"font-mono\">app.go</span> is created\n                </div>\n            </div>\n        </div>\n    );\n});\n\nEmptyStateView.displayName = \"EmptyStateView\";\n\nconst ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => {\n    const displayMsg = errorMsg && errorMsg.trim() ? errorMsg : \"Unknown Error\";\n    const waveAIModel = WaveAIModel.getInstance();\n    const buildPanelModel = BuilderBuildPanelModel.getInstance();\n    const appPanelModel = BuilderAppPanelModel.getInstance();\n    const outputLines = useAtomValue(buildPanelModel.outputLines);\n    const isStreaming = useAtomValue(waveAIModel.isAIStreaming);\n\n    const isSecretError = displayMsg.includes(\"ERR-SECRET\");\n\n    const getBuildContext = () => {\n        const filteredLines = outputLines.filter((line) => !line.startsWith(\"[debug]\"));\n        const buildOutput = filteredLines.join(\"\\n\").trim();\n        return `Build Error:\\n\\`\\`\\`\\n${displayMsg}\\n\\`\\`\\`\\n\\nBuild Output:\\n\\`\\`\\`\\n${buildOutput}\\n\\`\\`\\``;\n    };\n\n    const handleAddToContext = () => {\n        const context = getBuildContext();\n        waveAIModel.appendText(context, true);\n        waveAIModel.focusInput();\n    };\n\n    const handleAskAIToFix = async () => {\n        const context = getBuildContext();\n        waveAIModel.appendText(\"Please help me fix this build error:\\n\\n\" + context, true);\n        await waveAIModel.handleSubmit();\n    };\n\n    const handleGoToSecrets = () => {\n        appPanelModel.setActiveTab(\"secrets\");\n    };\n\n    if (isSecretError) {\n        return (\n            <div className=\"w-full h-full flex items-center justify-center bg-background\">\n                <div className=\"flex flex-col items-center gap-6 max-w-2xl text-center px-8\">\n                    <div className=\"text-6xl\">🔐</div>\n                    <div className=\"flex flex-col gap-3\">\n                        <h2 className=\"text-2xl font-semibold text-error\">Secrets Required</h2>\n                        <p className=\"text-base text-secondary leading-relaxed\">\n                            This app requires secrets that must be configured. Please use the Secrets tab to set and bind\n                            the required secrets for your app to run.\n                        </p>\n                        <div className=\"text-left bg-panel border border-error/30 rounded-lg p-4 max-h-96 overflow-auto mt-2\">\n                            <pre className=\"text-sm text-secondary whitespace-pre-wrap font-mono\">{displayMsg}</pre>\n                        </div>\n                        <button\n                            onClick={handleGoToSecrets}\n                            className=\"px-6 py-2 mt-2 bg-accent/80 text-primary font-semibold rounded hover:bg-accent transition-colors cursor-pointer\"\n                        >\n                            Go to Secrets Tab\n                        </button>\n                    </div>\n                </div>\n            </div>\n        );\n    }\n\n    return (\n        <div className=\"w-full h-full flex items-center justify-center bg-background\">\n            <div className=\"flex flex-col items-center gap-6 max-w-2xl text-center px-8\">\n                <div className=\"flex flex-col gap-3\">\n                    <h2 className=\"text-2xl font-semibold text-error\">Build Error</h2>\n                    <div className=\"text-left bg-panel border border-error/30 rounded-lg p-4 max-h-96 overflow-auto\">\n                        <pre className=\"text-sm text-secondary whitespace-pre-wrap font-mono\">{displayMsg}</pre>\n                    </div>\n                    {!isStreaming && (\n                        <div className=\"flex gap-3 mt-2 justify-center\">\n                            <button\n                                onClick={handleAddToContext}\n                                className=\"px-4 py-2 bg-panel text-primary border border-border rounded hover:bg-panel/80 transition-colors cursor-pointer\"\n                            >\n                                Add Error to AI Context\n                            </button>\n                            <button\n                                onClick={handleAskAIToFix}\n                                className=\"px-4 py-2 bg-accent/80 text-primary font-semibold rounded hover:bg-accent transition-colors cursor-pointer\"\n                            >\n                                Ask AI to Fix\n                            </button>\n                        </div>\n                    )}\n                </div>\n            </div>\n        </div>\n    );\n});\n\nErrorStateView.displayName = \"ErrorStateView\";\n\nconst BuildingStateView = memo(() => {\n    return (\n        <div className=\"w-full h-full flex items-center justify-center bg-background\">\n            <div className=\"flex flex-col items-center gap-6 max-w-[500px] text-center px-8\">\n                <div className=\"text-6xl\">⚙️</div>\n                <div className=\"flex flex-col gap-3\">\n                    <h2 className=\"text-2xl font-semibold text-primary\">App is Building...</h2>\n                    <p className=\"text-base text-secondary leading-relaxed\">\n                        Your WaveApp is being compiled and prepared. This may take a few moments.\n                    </p>\n                </div>\n            </div>\n        </div>\n    );\n});\n\nBuildingStateView.displayName = \"BuildingStateView\";\n\nconst StoppedStateView = memo(({ onStart }: { onStart: () => void }) => {\n    const [isStarting, setIsStarting] = useState(false);\n\n    const handleStart = () => {\n        setIsStarting(true);\n        onStart();\n        setTimeout(() => setIsStarting(false), 2000);\n    };\n\n    return (\n        <div className=\"w-full h-full flex items-center justify-center bg-background\">\n            <div className=\"flex flex-col items-center gap-6 max-w-[500px] text-center px-8\">\n                <div className=\"flex flex-col gap-3\">\n                    <h2 className=\"text-2xl font-semibold text-primary\">App is Not Running</h2>\n                    <p className=\"text-base text-secondary leading-relaxed\">\n                        Your WaveApp is currently not running. Click the button below to start it.\n                    </p>\n                </div>\n                {!isStarting && (\n                    <button\n                        onClick={handleStart}\n                        className=\"px-6 py-2 bg-accent text-primary font-semibold rounded hover:bg-accent/80 transition-colors cursor-pointer\"\n                    >\n                        Start App\n                    </button>\n                )}\n                {isStarting && <div className=\"text-base text-success\">Starting...</div>}\n            </div>\n        </div>\n    );\n});\n\nStoppedStateView.displayName = \"StoppedStateView\";\n\nconst BuilderPreviewTab = memo(() => {\n    const model = BuilderAppPanelModel.getInstance();\n    const isLoading = useAtomValue(model.isLoadingAtom);\n    const originalContent = useAtomValue(model.originalContentAtom);\n    const builderStatus = useAtomValue(model.builderStatusAtom);\n    const builderId = useAtomValue(atoms.builderId);\n\n    const fileExists = originalContent.length > 0;\n\n    if (isLoading) {\n        return null;\n    }\n\n    if (builderStatus?.status === \"error\") {\n        return <ErrorStateView errorMsg={builderStatus?.errormsg || \"\"} />;\n    }\n\n    if (!fileExists) {\n        return <EmptyStateView />;\n    }\n\n    const status = builderStatus?.status || \"init\";\n\n    if (status === \"init\") {\n        return null;\n    }\n\n    if (status === \"building\") {\n        return <BuildingStateView />;\n    }\n\n    if (status === \"stopped\") {\n        return <StoppedStateView onStart={() => model.startBuilder()} />;\n    }\n\n    const shouldShowWebView = status === \"running\" && builderStatus?.port && builderStatus.port !== 0;\n\n    if (shouldShowWebView) {\n        const previewUrl = `http://localhost:${builderStatus.port}/?clientid=wave:${builderId}`;\n        return (\n            <div className=\"w-full h-full\">\n                <webview src={previewUrl} className=\"w-full h-full\" />\n            </div>\n        );\n    }\n\n    return null;\n});\n\nBuilderPreviewTab.displayName = \"BuilderPreviewTab\";\n\nexport { BuilderPreviewTab };\n"
  },
  {
    "path": "frontend/builder/tabs/builder-secrettab.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { BuilderAppPanelModel } from \"@/builder/store/builder-apppanel-model\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { atoms } from \"@/store/global\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useState, useEffect } from \"react\";\nimport { Check, AlertTriangle } from \"lucide-react\";\nimport { Tooltip } from \"@/app/element/tooltip\";\nimport { Modal } from \"@/app/modals/modal\";\nimport { modalsModel } from \"@/app/store/modalmodel\";\n\ntype SecretRowProps = {\n    secretName: string;\n    secretMeta: SecretMeta;\n    currentBinding: string;\n    availableSecrets: string[];\n    onMapDefault: (secretName: string) => void;\n    onSetAndMapDefault: (secretName: string) => void;\n};\n\nconst SecretRow = memo(({ secretName, secretMeta, currentBinding, availableSecrets, onMapDefault, onSetAndMapDefault }: SecretRowProps) => {\n    const isMapped = currentBinding.trim().length > 0;\n    const isValid = isMapped && availableSecrets.includes(currentBinding);\n    const isInvalid = isMapped && !isValid;\n    const hasMatchingSecret = availableSecrets.includes(secretName);\n\n    return (\n        <div className=\"flex items-center gap-4 py-2 border-b border-border\">\n            <Tooltip content={!isMapped ? \"Secret is Not Mapped\" : isValid ? \"Secret Has a Valid Mapping\" : \"Secret Binding is Invalid\"}>\n                <div className=\"flex items-center\">\n                    {!isMapped && <AlertTriangle className=\"w-5 h-5 text-yellow-500\" />}\n                    {isInvalid && <AlertTriangle className=\"w-5 h-5 text-red-500\" />}\n                    {isValid && <Check className=\"w-5 h-5 text-green-500\" />}\n                </div>\n            </Tooltip>\n            <div className=\"flex-1 flex items-center gap-2\">\n                <span className=\"font-medium text-primary\">{secretName}</span>\n                {!secretMeta.optional && (\n                    <span className=\"px-2 py-0.5 text-xs bg-red-500/20 text-red-500 rounded\">Required</span>\n                )}\n                {secretMeta.optional && (\n                    <span className=\"px-2 py-0.5 text-xs bg-blue-500/20 text-blue-500 rounded\">Optional</span>\n                )}\n                {secretMeta.desc && <span className=\"text-sm text-secondary\">— {secretMeta.desc}</span>}\n            </div>\n            <div className=\"flex items-center gap-2\">\n                {!isMapped && hasMatchingSecret && (\n                    <button\n                        onClick={() => onMapDefault(secretName)}\n                        className=\"px-3 py-1 text-sm font-medium rounded bg-accent/80 text-primary hover:bg-accent transition-colors cursor-pointer whitespace-nowrap\"\n                    >\n                        Map Default\n                    </button>\n                )}\n                {!isMapped && !hasMatchingSecret && (\n                    <button\n                        onClick={() => onSetAndMapDefault(secretName)}\n                        className=\"px-3 py-1 text-sm font-medium rounded bg-accent/80 text-primary hover:bg-accent transition-colors cursor-pointer whitespace-nowrap\"\n                    >\n                        Set and Map Default\n                    </button>\n                )}\n            </div>\n        </div>\n    );\n});\n\nSecretRow.displayName = \"SecretRow\";\n\ntype SetSecretDialogProps = {\n    secretName: string;\n    onSetAndMap: (secretName: string, secretValue: string) => Promise<void>;\n};\n\nconst SetSecretDialog = memo(({ secretName, onSetAndMap }: SetSecretDialogProps) => {\n    const [secretValue, setSecretValue] = useState(\"\");\n    const [isSubmitting, setIsSubmitting] = useState(false);\n    const [error, setError] = useState(\"\");\n\n    const handleSubmit = async () => {\n        if (!secretValue.trim()) return;\n        setIsSubmitting(true);\n        setError(\"\");\n        try {\n            await onSetAndMap(secretName, secretValue);\n            modalsModel.popModal();\n        } catch (err) {\n            console.error(\"Failed to set secret:\", err);\n            setError(err instanceof Error ? err.message : String(err));\n        } finally {\n            setIsSubmitting(false);\n        }\n    };\n\n    const handleClose = () => {\n        modalsModel.popModal();\n    };\n\n    useEffect(() => {\n        const handleKeyDown = (e: KeyboardEvent) => {\n            if (e.key === \"Escape\") {\n                e.preventDefault();\n                handleClose();\n            }\n        };\n\n        document.addEventListener(\"keydown\", handleKeyDown);\n        return () => document.removeEventListener(\"keydown\", handleKeyDown);\n    }, []);\n\n    if (error) {\n        return (\n            <Modal className=\"p-4 min-w-[500px]\" onOk={handleClose} onClose={handleClose} okLabel=\"OK\">\n                <div className=\"flex flex-col gap-4 mb-4\">\n                    <h2 className=\"text-xl font-semibold\">Error Setting Secret</h2>\n                    <div className=\"text-sm text-error\">{error}</div>\n                </div>\n            </Modal>\n        );\n    }\n\n    return (\n        <Modal\n            className=\"p-4 min-w-[500px]\"\n            onOk={handleSubmit}\n            onCancel={handleClose}\n            onClose={handleClose}\n            okLabel=\"Set and Map\"\n            cancelLabel=\"Cancel\"\n            okDisabled={!secretValue.trim() || isSubmitting}\n        >\n            <div className=\"flex flex-col gap-4 mb-4\">\n                <h2 className=\"text-xl font-semibold\">Set and Map Secret</h2>\n                <div className=\"flex flex-col gap-2\">\n                    <div className=\"text-sm font-medium mb-1\">\n                        Secret Name: <span className=\"text-accent\">{secretName}</span>\n                    </div>\n                    <textarea\n                        value={secretValue}\n                        onChange={(e) => setSecretValue(e.target.value)}\n                        placeholder=\"Paste secret value here...\"\n                        className=\"w-full px-3 py-2 bg-panel border border-border rounded focus:outline-none focus:border-accent resize-none\"\n                        rows={4}\n                        autoFocus\n                        disabled={isSubmitting}\n                    />\n                    <div className=\"text-xs text-secondary\">\n                        Secrets are stored securely in Wave's secret store\n                    </div>\n                </div>\n            </div>\n        </Modal>\n    );\n});\n\nSetSecretDialog.displayName = \"SetSecretDialog\";\n\nconst BuilderSecretTab = memo(() => {\n    const model = BuilderAppPanelModel.getInstance();\n    const builderStatus = useAtomValue(model.builderStatusAtom);\n    const error = useAtomValue(model.errorAtom);\n\n    const [availableSecrets, setAvailableSecrets] = useState<string[]>([]);\n\n    const manifest = builderStatus?.manifest;\n    const secrets = manifest?.secrets || {};\n    const secretBindings = builderStatus?.secretbindings || {};\n\n    useEffect(() => {\n        const fetchSecrets = async () => {\n            try {\n                const secrets = await RpcApi.GetSecretsNamesCommand(TabRpcClient);\n                setAvailableSecrets(secrets || []);\n            } catch (err) {\n                console.error(\"Failed to fetch secrets:\", err);\n            }\n        };\n        fetchSecrets();\n    }, []);\n\n    if (!builderStatus || !manifest) {\n        return (\n            <div className=\"w-full h-full flex items-center justify-center\">\n                <div className=\"text-secondary text-center\">\n                    App manifest not available. Secrets will be shown once the app builds successfully.\n                </div>\n            </div>\n        );\n    }\n\n    const sortedSecretEntries = Object.entries(secrets).sort(([nameA, metaA], [nameB, metaB]) => {\n        if (!metaA.optional && metaB.optional) return -1;\n        if (metaA.optional && !metaB.optional) return 1;\n        return nameA.localeCompare(nameB);\n    });\n\n    const handleMapDefault = async (secretName: string) => {\n        const newBindings = { ...secretBindings, [secretName]: secretName };\n        \n        try {\n            const appId = globalStore.get(atoms.builderAppId);\n            await RpcApi.WriteAppSecretBindingsCommand(TabRpcClient, {\n                appid: appId,\n                bindings: newBindings,\n            });\n            model.updateSecretBindings(newBindings);\n            globalStore.set(model.errorAtom, \"\");\n            model.restartBuilder();\n        } catch (err) {\n            console.error(\"Failed to save secret bindings:\", err);\n            globalStore.set(model.errorAtom, `Failed to save secret bindings: ${err.message || \"Unknown error\"}`);\n        }\n    };\n\n    const handleSetAndMapDefault = (secretName: string) => {\n        modalsModel.pushModal(\"SetSecretDialog\", { secretName, onSetAndMap: handleSetAndMap });\n    };\n\n    const handleSetAndMap = async (secretName: string, secretValue: string) => {\n        await RpcApi.SetSecretsCommand(TabRpcClient, { [secretName]: secretValue });\n        setAvailableSecrets((prev) => [...prev, secretName]);\n        \n        const newBindings = { ...secretBindings, [secretName]: secretName };\n        \n        try {\n            const appId = globalStore.get(atoms.builderAppId);\n            await RpcApi.WriteAppSecretBindingsCommand(TabRpcClient, {\n                appid: appId,\n                bindings: newBindings,\n            });\n            model.updateSecretBindings(newBindings);\n            globalStore.set(model.errorAtom, \"\");\n            model.restartBuilder();\n        } catch (err) {\n            console.error(\"Failed to save secret bindings:\", err);\n            globalStore.set(model.errorAtom, `Failed to save secret bindings: ${err.message || \"Unknown error\"}`);\n        }\n    };\n\n    const allRequiredBound =\n        sortedSecretEntries.filter(([_, meta]) => !meta.optional).every(([name]) => secretBindings[name]?.trim()) ||\n        false;\n\n    return (\n        <div className=\"w-full h-full flex flex-col p-4\">\n            <h2 className=\"text-lg font-semibold mb-2\">Secret Bindings</h2>\n\n            <div className=\"mb-4 p-2 bg-blue-500/10 border border-blue-500/30 rounded text-sm text-secondary\">\n                Map app secrets to Wave secret store names. Required secrets must be bound before the app can run\n                successfully. Changes are saved automatically.\n            </div>\n\n            {!allRequiredBound && (\n                <div className=\"mb-4 p-2 bg-yellow-500/10 border border-yellow-500/30 rounded text-sm text-yellow-600\">\n                    Some required secrets are not bound yet.\n                </div>\n            )}\n\n            {error && <div className=\"mb-4 p-2 bg-red-500/20 text-red-500 rounded text-sm\">{error}</div>}\n\n            <div className=\"flex-1 overflow-auto\">\n                {sortedSecretEntries.length === 0 ? (\n                    <div className=\"text-secondary text-center py-8\">\n                        No secrets defined in this app manifest.\n                    </div>\n                ) : (\n                    <div className=\"space-y-1\">\n                        {sortedSecretEntries.map(([secretName, secretMeta]) => (\n                            <SecretRow\n                                key={secretName}\n                                secretName={secretName}\n                                secretMeta={secretMeta}\n                                currentBinding={secretBindings[secretName] || \"\"}\n                                availableSecrets={availableSecrets}\n                                onMapDefault={handleMapDefault}\n                                onSetAndMapDefault={handleSetAndMapDefault}\n                            />\n                        ))}\n                    </div>\n                )}\n            </div>\n        </div>\n    );\n});\n\nBuilderSecretTab.displayName = \"BuilderSecretTab\";\n\nexport { BuilderSecretTab, SetSecretDialog };\n"
  },
  {
    "path": "frontend/builder/utils/builder-focus-utils.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nexport function findBuilderAppPanel(element: HTMLElement): HTMLElement | null {\n    let current: HTMLElement = element;\n    while (current) {\n        if (current.hasAttribute(\"data-builder-app-panel\")) {\n            return current;\n        }\n        current = current.parentElement;\n    }\n    return null;\n}\n\nexport function builderAppHasFocusWithin(focusTarget?: Element | null): boolean {\n    if (focusTarget !== undefined) {\n        if (focusTarget instanceof HTMLElement) {\n            return findBuilderAppPanel(focusTarget) != null;\n        }\n        return false;\n    }\n\n    const focused = document.activeElement;\n    if (focused instanceof HTMLElement) {\n        const appPanel = findBuilderAppPanel(focused);\n        if (appPanel) return true;\n    }\n\n    const sel = document.getSelection();\n    if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) {\n        let anchor = sel.anchorNode;\n        if (anchor instanceof Text) {\n            anchor = anchor.parentElement;\n        }\n        if (anchor instanceof HTMLElement) {\n            const appPanel = findBuilderAppPanel(anchor);\n            if (appPanel) return true;\n        }\n    }\n\n    return false;\n}\n\nexport function builderAppHasSelection(): boolean {\n    const sel = document.getSelection();\n    if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {\n        return false;\n    }\n\n    let anchor = sel.anchorNode;\n    if (anchor instanceof Text) {\n        anchor = anchor.parentElement;\n    }\n    if (anchor instanceof HTMLElement) {\n        return findBuilderAppPanel(anchor) != null;\n    }\n\n    return false;\n}"
  },
  {
    "path": "frontend/layout/index.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { TileLayout } from \"./lib/TileLayout\";\nimport { LayoutModel } from \"./lib/layoutModel\";\nimport { deleteLayoutModelForTab, getLayoutModelForStaticTab, useDebouncedNodeInnerRect } from \"./lib/layoutModelHooks\";\nimport { newLayoutNode } from \"./lib/layoutNode\";\nimport type {\n    ContentRenderer,\n    LayoutNode,\n    LayoutTreeAction,\n    LayoutTreeClearPendingAction,\n    LayoutTreeCommitPendingAction,\n    LayoutTreeComputeMoveNodeAction,\n    LayoutTreeDeleteNodeAction,\n    LayoutTreeFocusNodeAction,\n    LayoutTreeInsertNodeAction,\n    LayoutTreeInsertNodeAtIndexAction,\n    LayoutTreeMagnifyNodeToggleAction,\n    LayoutTreeMoveNodeAction,\n    LayoutTreeResizeNodeAction,\n    LayoutTreeSetPendingAction,\n    LayoutTreeStateSetter,\n    LayoutTreeSwapNodeAction,\n    NodeModel,\n    PreviewRenderer,\n} from \"./lib/types\";\nimport { DropDirection, LayoutTreeActionType, NavigateDirection } from \"./lib/types\";\n\nexport {\n    deleteLayoutModelForTab,\n    DropDirection,\n    getLayoutModelForStaticTab,\n    LayoutModel,\n    LayoutTreeActionType,\n    NavigateDirection,\n    newLayoutNode,\n    TileLayout,\n    useDebouncedNodeInnerRect,\n};\nexport type {\n    ContentRenderer,\n    LayoutNode,\n    LayoutTreeAction,\n    LayoutTreeClearPendingAction,\n    LayoutTreeCommitPendingAction,\n    LayoutTreeComputeMoveNodeAction,\n    LayoutTreeDeleteNodeAction,\n    LayoutTreeFocusNodeAction,\n    LayoutTreeInsertNodeAction,\n    LayoutTreeInsertNodeAtIndexAction,\n    LayoutTreeMagnifyNodeToggleAction,\n    LayoutTreeMoveNodeAction,\n    LayoutTreeResizeNodeAction,\n    LayoutTreeSetPendingAction,\n    LayoutTreeStateSetter,\n    LayoutTreeSwapNodeAction,\n    NodeModel,\n    PreviewRenderer,\n};\n"
  },
  {
    "path": "frontend/layout/lib/TileLayout.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { getSettingsKeyAtom } from \"@/app/store/global\";\nimport clsx from \"clsx\";\nimport { toPng } from \"html-to-image\";\nimport { Atom, useAtomValue, useSetAtom } from \"jotai\";\nimport React, {\n    CSSProperties,\n    ReactNode,\n    Suspense,\n    memo,\n    useCallback,\n    useEffect,\n    useMemo,\n    useRef,\n    useState,\n} from \"react\";\nimport { DropTargetMonitor, XYCoord, useDrag, useDragLayer, useDrop } from \"react-dnd\";\nimport { debounce, throttle } from \"throttle-debounce\";\nimport { useDevicePixelRatio } from \"use-device-pixel-ratio\";\nimport { LayoutModel } from \"./layoutModel\";\nimport { useNodeModel, useTileLayout } from \"./layoutModelHooks\";\nimport \"./tilelayout.scss\";\nimport {\n    LayoutNode,\n    LayoutTreeActionType,\n    LayoutTreeComputeMoveNodeAction,\n    ResizeHandleProps,\n    TileLayoutContents,\n} from \"./types\";\nimport { determineDropDirection } from \"./utils\";\n\nconst tileItemType = \"TILE_ITEM\";\n\nexport interface TileLayoutProps {\n    /**\n     * The atom containing the layout tree state.\n     */\n    tabAtom: Atom<Tab>;\n\n    /**\n     * callbacks and information about the contents (or styling) of the TileLayout or contents\n     */\n    contents: TileLayoutContents;\n\n    /**\n     * A callback for getting the cursor point in reference to the current window. This removes Electron as a runtime dependency, allowing for better integration with Storybook.\n     * @returns The cursor position relative to the current window.\n     */\n    getCursorPoint?: () => Point;\n}\n\nconst DragPreviewWidth = 300;\nconst DragPreviewHeight = 300;\n\nfunction TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutProps) {\n    const layoutModel = useTileLayout(tabAtom, contents);\n    const overlayTransform = useAtomValue(layoutModel.overlayTransform);\n    const setActiveDrag = useSetAtom(layoutModel.activeDrag);\n    const setReady = useSetAtom(layoutModel.ready);\n    const isResizing = useAtomValue(layoutModel.isResizing);\n\n    const { activeDrag, dragClientOffset, dragItemType } = useDragLayer((monitor) => ({\n        activeDrag: monitor.isDragging(),\n        dragClientOffset: monitor.getClientOffset(),\n        dragItemType: monitor.getItemType(),\n    }));\n\n    useEffect(() => {\n        const activeTileDrag = activeDrag && dragItemType == tileItemType;\n        setActiveDrag(activeTileDrag);\n    }, [activeDrag, dragItemType]);\n\n    const checkForCursorBounds = useCallback(\n        debounce(100, (dragClientOffset: XYCoord) => {\n            const cursorPoint = dragClientOffset ?? getCursorPoint?.();\n            if (cursorPoint && layoutModel.displayContainerRef?.current) {\n                const displayContainerRect = layoutModel.displayContainerRef.current.getBoundingClientRect();\n                const normalizedX = cursorPoint.x - displayContainerRect.x;\n                const normalizedY = cursorPoint.y - displayContainerRect.y;\n                if (\n                    normalizedX <= 0 ||\n                    normalizedX >= displayContainerRect.width ||\n                    normalizedY <= 0 ||\n                    normalizedY >= displayContainerRect.height\n                ) {\n                    layoutModel.treeReducer({ type: LayoutTreeActionType.ClearPendingAction });\n                }\n            }\n        }),\n        [getCursorPoint]\n    );\n\n    // Effect to detect when the cursor leaves the TileLayout hit trap so we can remove any placeholders. This cannot be done using pointer capture\n    // because that conflicts with the DnD layer.\n    useEffect(() => checkForCursorBounds(dragClientOffset), [dragClientOffset]);\n\n    // Ensure that we don't see any jostling in the layout when we're rendering it the first time.\n    // `animate` will be disabled until after the transforms have all applied the first time.\n    const [animate, setAnimate] = useState(false);\n    useEffect(() => {\n        setTimeout(() => {\n            setAnimate(true);\n            setReady(true);\n        }, 50);\n    }, []);\n\n    const gapSizePx = useAtomValue(layoutModel.gapSizePx);\n    const animationTimeS = useAtomValue(layoutModel.animationTimeS);\n    const tileStyle = useMemo(\n        () =>\n            ({\n                \"--gap-size-px\": `${gapSizePx}px`,\n                \"--animation-time-s\": `${animationTimeS}s`,\n            }) as CSSProperties,\n        [gapSizePx, animationTimeS]\n    );\n\n    return (\n        <Suspense>\n            <div\n                className={clsx(\"tile-layout\", contents.className, { animate: animate && !isResizing })}\n                style={tileStyle}\n            >\n                <div key=\"display\" ref={layoutModel.displayContainerRef} className=\"display-container\">\n                    <ResizeHandleWrapper layoutModel={layoutModel} />\n                    <DisplayNodesWrapper layoutModel={layoutModel} />\n                    <NodeBackdrops layoutModel={layoutModel} />\n                </div>\n                <Placeholder key=\"placeholder\" layoutModel={layoutModel} style={{ top: 10000, ...overlayTransform }} />\n                <OverlayNodeWrapper layoutModel={layoutModel} />\n            </div>\n        </Suspense>\n    );\n}\nexport const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent;\n\nfunction NodeBackdrops({ layoutModel }: { layoutModel: LayoutModel }) {\n    const [blockBlurAtom] = useState(() => getSettingsKeyAtom(\"window:magnifiedblockblursecondarypx\"));\n    const blockBlur = useAtomValue(blockBlurAtom);\n    const ephemeralNode = useAtomValue(layoutModel.ephemeralNode);\n    const magnifiedNodeId = useAtomValue(layoutModel.magnifiedNodeIdAtom);\n\n    const [showMagnifiedBackdrop, setShowMagnifiedBackdrop] = useState(!!ephemeralNode);\n    const [showEphemeralBackdrop, setShowEphemeralBackdrop] = useState(!!magnifiedNodeId);\n\n    const debouncedSetMagnifyBackdrop = useCallback(\n        debounce(100, () => setShowMagnifiedBackdrop(true)),\n        []\n    );\n\n    useEffect(() => {\n        if (magnifiedNodeId && !showMagnifiedBackdrop) {\n            debouncedSetMagnifyBackdrop();\n        }\n        if (!magnifiedNodeId) {\n            setShowMagnifiedBackdrop(false);\n        }\n        if (ephemeralNode && !showEphemeralBackdrop) {\n            setShowEphemeralBackdrop(true);\n        }\n        if (!ephemeralNode) {\n            setShowEphemeralBackdrop(false);\n        }\n    }, [ephemeralNode, magnifiedNodeId]);\n\n    const blockBlurStr = `${blockBlur}px`;\n\n    return (\n        <>\n            {showMagnifiedBackdrop && (\n                <div\n                    className=\"magnified-node-backdrop\"\n                    onClick={() => {\n                        layoutModel.magnifyNodeToggle(magnifiedNodeId);\n                    }}\n                    style={{ \"--block-blur\": blockBlurStr } as CSSProperties}\n                />\n            )}\n            {showEphemeralBackdrop && (\n                <div\n                    className=\"ephemeral-node-backdrop\"\n                    onClick={() => {\n                        layoutModel.closeNode(ephemeralNode?.id);\n                    }}\n                    style={{ \"--block-blur\": blockBlurStr } as CSSProperties}\n                />\n            )}\n        </>\n    );\n}\n\ninterface DisplayNodesWrapperProps {\n    /**\n     * The layout tree state.\n     */\n    layoutModel: LayoutModel;\n}\n\nconst DisplayNodesWrapper = ({ layoutModel }: DisplayNodesWrapperProps) => {\n    const leafs = useAtomValue(layoutModel.leafs);\n\n    return useMemo(\n        () =>\n            leafs.map((node) => {\n                return <DisplayNode key={node.id} layoutModel={layoutModel} node={node} />;\n            }),\n        [leafs]\n    );\n};\n\ninterface DisplayNodeProps {\n    layoutModel: LayoutModel;\n    /**\n     * The leaf node object, containing the data needed to display the leaf contents to the user.\n     */\n    node: LayoutNode;\n}\n\n/**\n * The draggable and displayable portion of a leaf node in a layout tree.\n */\nconst DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => {\n    const nodeModel = useNodeModel(layoutModel, node);\n    const tileNodeRef = useRef<HTMLDivElement>(null);\n    const previewRef = useRef<HTMLDivElement>(null);\n    const addlProps = useAtomValue(nodeModel.additionalProps);\n    const devicePixelRatio = useDevicePixelRatio();\n    const isEphemeral = useAtomValue(nodeModel.isEphemeral);\n    const isMagnified = useAtomValue(nodeModel.isMagnified);\n\n    const [{ isDragging }, drag, dragPreview] = useDrag(\n        () => ({\n            type: tileItemType,\n            canDrag: () => !(isEphemeral || isMagnified),\n            item: () => node,\n            collect: (monitor) => ({\n                isDragging: monitor.isDragging(),\n            }),\n        }),\n        [node, addlProps, isEphemeral, isMagnified]\n    );\n\n    const [previewElementGeneration, setPreviewElementGeneration] = useState(0);\n    const previewElement = useMemo(() => {\n        setPreviewElementGeneration(previewElementGeneration + 1);\n        return (\n            <div key=\"preview\" className=\"tile-preview-container\">\n                <div\n                    className=\"tile-preview\"\n                    ref={previewRef}\n                    style={{\n                        width: DragPreviewWidth,\n                        height: DragPreviewHeight,\n                        transform: `scale(${1 / devicePixelRatio})`,\n                    }}\n                >\n                    {layoutModel.renderPreview?.(nodeModel)}\n                </div>\n            </div>\n        );\n    }, [devicePixelRatio, nodeModel]);\n\n    const [previewImage, setPreviewImage] = useState<HTMLImageElement>(null);\n    const [previewImageGeneration, setPreviewImageGeneration] = useState(0);\n    const generatePreviewImage = useCallback(() => {\n        const offsetX = (DragPreviewWidth * devicePixelRatio - DragPreviewWidth) / 2 + 10;\n        const offsetY = (DragPreviewHeight * devicePixelRatio - DragPreviewHeight) / 2 + 10;\n        if (previewImage !== null && previewElementGeneration === previewImageGeneration) {\n            dragPreview(previewImage, { offsetY, offsetX });\n        } else if (previewRef.current) {\n            setPreviewImageGeneration(previewElementGeneration);\n            toPng(previewRef.current).then((url) => {\n                const img = new Image();\n                img.src = url;\n                setPreviewImage(img);\n                dragPreview(img, { offsetY, offsetX });\n            });\n        }\n    }, [\n        dragPreview,\n        previewRef.current,\n        previewElementGeneration,\n        previewImageGeneration,\n        previewImage,\n        devicePixelRatio,\n    ]);\n\n    const leafContent = useMemo(() => {\n        return (\n            <div key=\"leaf\" className=\"tile-leaf\">\n                {layoutModel.renderContent(nodeModel)}\n            </div>\n        );\n    }, [nodeModel]);\n\n    // Register the display node as a draggable item\n    useEffect(() => {\n        drag(nodeModel.dragHandleRef);\n    }, [drag, nodeModel.dragHandleRef.current]);\n\n    return (\n        <div\n            className={clsx(\"tile-node\", {\n                dragging: isDragging,\n            })}\n            key={node.id}\n            ref={tileNodeRef}\n            id={node.id}\n            style={addlProps?.transform}\n            onPointerEnter={generatePreviewImage}\n            onPointerOver={(event) => event.stopPropagation()}\n        >\n            {leafContent}\n            {previewElement}\n        </div>\n    );\n};\n\ninterface OverlayNodeWrapperProps {\n    layoutModel: LayoutModel;\n}\n\nconst OverlayNodeWrapper = memo(({ layoutModel }: OverlayNodeWrapperProps) => {\n    const leafs = useAtomValue(layoutModel.leafs);\n    const overlayTransform = useAtomValue(layoutModel.overlayTransform);\n\n    const overlayNodes = useMemo(\n        () =>\n            leafs.map((node) => {\n                return <OverlayNode key={node.id} layoutModel={layoutModel} node={node} />;\n            }),\n        [leafs]\n    );\n\n    return (\n        <div key=\"overlay\" className=\"overlay-container\" style={{ top: 10000, ...overlayTransform }}>\n            {overlayNodes}\n        </div>\n    );\n});\n\ninterface OverlayNodeProps {\n    /**\n     * The layout tree state.\n     */\n    layoutModel: LayoutModel;\n    node: LayoutNode;\n}\n\n/**\n * An overlay representing the true flexbox layout of the LayoutTreeState. This holds the drop targets for moving around nodes and is used to calculate the\n * dimensions of the corresponding DisplayNode for each LayoutTreeState leaf.\n */\nconst OverlayNode = memo(({ node, layoutModel }: OverlayNodeProps) => {\n    const nodeModel = useNodeModel(layoutModel, node);\n    const additionalProps = useAtomValue(nodeModel.additionalProps);\n    const overlayRef = useRef<HTMLDivElement>(null);\n\n    const [, drop] = useDrop(\n        () => ({\n            accept: tileItemType,\n            canDrop: (_, monitor) => {\n                const dragItem = monitor.getItem<LayoutNode>();\n                if (monitor.isOver({ shallow: true }) && dragItem.id !== node.id) {\n                    return true;\n                }\n                return false;\n            },\n            drop: (_, monitor) => {\n                if (!monitor.didDrop()) {\n                    layoutModel.onDrop();\n                }\n            },\n            hover: throttle(50, (_, monitor: DropTargetMonitor<unknown, unknown>) => {\n                if (monitor.isOver({ shallow: true })) {\n                    if (monitor.canDrop() && layoutModel.displayContainerRef?.current && additionalProps?.rect) {\n                        const dragItem = monitor.getItem<LayoutNode>();\n                        // console.log(\"computing operation\", layoutNode, dragItem, additionalProps.rect);\n                        const offset = monitor.getClientOffset();\n                        const containerRect = layoutModel.displayContainerRef.current.getBoundingClientRect();\n                        offset.x -= containerRect.x;\n                        offset.y -= containerRect.y;\n                        layoutModel.treeReducer({\n                            type: LayoutTreeActionType.ComputeMove,\n                            nodeId: node.id,\n                            nodeToMoveId: dragItem.id,\n                            direction: determineDropDirection(additionalProps.rect, offset),\n                        } as LayoutTreeComputeMoveNodeAction);\n                    } else {\n                        layoutModel.treeReducer({\n                            type: LayoutTreeActionType.ClearPendingAction,\n                        });\n                    }\n                }\n            }),\n        }),\n        [node.id, additionalProps?.rect, layoutModel.displayContainerRef, layoutModel.onDrop, layoutModel.treeReducer]\n    );\n\n    // Register the overlay node as a drop target\n    useEffect(() => {\n        drop(overlayRef);\n    }, []);\n\n    return <div ref={overlayRef} className=\"overlay-node\" id={node.id} style={additionalProps?.transform} />;\n});\n\ninterface ResizeHandleWrapperProps {\n    layoutModel: LayoutModel;\n}\n\nconst ResizeHandleWrapper = memo(({ layoutModel }: ResizeHandleWrapperProps) => {\n    const resizeHandles = useAtomValue(layoutModel.resizeHandles) as Atom<ResizeHandleProps>[];\n\n    return resizeHandles.map((resizeHandleAtom, i) => (\n        <ResizeHandle key={`resize-handle-${i}`} layoutModel={layoutModel} resizeHandleAtom={resizeHandleAtom} />\n    ));\n});\n\ninterface ResizeHandleComponentProps {\n    resizeHandleAtom: Atom<ResizeHandleProps>;\n    layoutModel: LayoutModel;\n}\n\nconst ResizeHandle = memo(({ resizeHandleAtom, layoutModel }: ResizeHandleComponentProps) => {\n    const resizeHandleProps = useAtomValue(resizeHandleAtom);\n    const resizeHandleRef = useRef<HTMLDivElement>(null);\n\n    // The pointer currently captured, or undefined.\n    const [trackingPointer, setTrackingPointer] = useState<number>(undefined);\n\n    // Calculates the new size of the two nodes on either side of the handle, based on the position of the cursor\n    const handlePointerMove = useCallback(\n        throttle(10, (event: React.PointerEvent<HTMLDivElement>) => {\n            if (trackingPointer === event.pointerId) {\n                const { clientX, clientY } = event;\n                layoutModel.onResizeMove(resizeHandleProps, clientX, clientY);\n            }\n        }),\n        [trackingPointer, layoutModel.onResizeMove, resizeHandleProps]\n    );\n\n    // We want to use pointer capture so the operation continues even if the pointer leaves the bounds of the handle\n    function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {\n        resizeHandleRef.current?.setPointerCapture(event.pointerId);\n    }\n\n    // This indicates that we're ready to start tracking the resize operation via the pointer\n    function onPointerCapture(event: React.PointerEvent<HTMLDivElement>) {\n        setTrackingPointer(event.pointerId);\n    }\n\n    // We want to wait a bit before committing the pending resize operation in case some events haven't arrived yet.\n    const onPointerRelease = useCallback(\n        debounce(30, (event: React.PointerEvent<HTMLDivElement>) => {\n            setTrackingPointer(undefined);\n            layoutModel.onResizeEnd();\n        }),\n        [layoutModel]\n    );\n\n    return (\n        <div\n            ref={resizeHandleRef}\n            className={clsx(\"resize-handle\", `flex-${resizeHandleProps.flexDirection}`)}\n            onPointerDown={onPointerDown}\n            onGotPointerCapture={onPointerCapture}\n            onLostPointerCapture={onPointerRelease}\n            style={resizeHandleProps.transform}\n            onPointerMove={handlePointerMove}\n        >\n            <div className=\"line\" />\n        </div>\n    );\n});\n\ninterface PlaceholderProps {\n    /**\n     * The layout tree state.\n     */\n    layoutModel: LayoutModel;\n    /**\n     * Any styling to apply to the placeholder container div.\n     */\n    style: React.CSSProperties;\n}\n\n/**\n * An overlay to preview pending actions on the layout tree.\n */\nconst Placeholder = memo(({ layoutModel, style }: PlaceholderProps) => {\n    const [placeholderOverlay, setPlaceholderOverlay] = useState<ReactNode>(null);\n    const placeholderTransform = useAtomValue(layoutModel.placeholderTransform);\n\n    useEffect(() => {\n        if (placeholderTransform) {\n            setPlaceholderOverlay(<div className=\"placeholder\" style={placeholderTransform} />);\n        } else {\n            setPlaceholderOverlay(null);\n        }\n    }, [placeholderTransform]);\n\n    return (\n        <div className=\"placeholder-container\" style={style}>\n            {placeholderOverlay}\n        </div>\n    );\n});\n"
  },
  {
    "path": "frontend/layout/lib/layoutAtom.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { WOS } from \"@/app/store/global\";\nimport { Atom, Getter } from \"jotai\";\n\nexport function getLayoutStateAtomFromTab(tabAtom: Atom<Tab>, get: Getter): Atom<LayoutState> {\n    const tabData = get(tabAtom);\n    if (!tabData) return;\n    const layoutStateOref = WOS.makeORef(\"layout\", tabData.layoutstate);\n    const layoutStateAtom = WOS.getWaveObjectAtom<LayoutState>(layoutStateOref);\n    return layoutStateAtom;\n}\n"
  },
  {
    "path": "frontend/layout/lib/layoutModel.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { FocusManager } from \"@/app/store/focusManager\";\nimport { getSettingsKeyAtom } from \"@/app/store/global\";\nimport { BlockService } from \"@/app/store/services\";\nimport * as WOS from \"@/app/store/wos\";\nimport { atomWithThrottle, boundNumber, fireAndForget } from \"@/util/util\";\nimport { Atom, atom, Getter, PrimitiveAtom, Setter } from \"jotai\";\nimport { splitAtom } from \"jotai/utils\";\nimport { createRef, CSSProperties } from \"react\";\nimport { debounce } from \"throttle-debounce\";\nimport { getLayoutStateAtomFromTab } from \"./layoutAtom\";\nimport { balanceNode, findNode, newLayoutNode, walkNodes } from \"./layoutNode\";\nimport {\n    clearTree,\n    computeMoveNode,\n    deleteNode,\n    focusNode,\n    insertNode,\n    insertNodeAtIndex,\n    magnifyNodeToggle,\n    moveNode,\n    replaceNode,\n    resizeNode,\n    splitHorizontal,\n    splitVertical,\n    swapNode,\n} from \"./layoutTree\";\nimport {\n    ContentRenderer,\n    FlexDirection,\n    LayoutNode,\n    LayoutNodeAdditionalProps,\n    LayoutTreeAction,\n    LayoutTreeActionType,\n    LayoutTreeClearTreeAction,\n    LayoutTreeComputeMoveNodeAction,\n    LayoutTreeDeleteNodeAction,\n    LayoutTreeFocusNodeAction,\n    LayoutTreeInsertNodeAction,\n    LayoutTreeInsertNodeAtIndexAction,\n    LayoutTreeMagnifyNodeToggleAction,\n    LayoutTreeMoveNodeAction,\n    LayoutTreeReplaceNodeAction,\n    LayoutTreeResizeNodeAction,\n    LayoutTreeSetPendingAction,\n    LayoutTreeSplitHorizontalAction,\n    LayoutTreeSplitVerticalAction,\n    LayoutTreeState,\n    LayoutTreeSwapNodeAction,\n    NavigateDirection,\n    NavigationResult,\n    NodeModel,\n    PreviewRenderer,\n    ResizeHandleProps,\n    TileLayoutContents,\n} from \"./types\";\nimport { getCenter, navigateDirectionToOffset, setTransform } from \"./utils\";\n\ninterface ResizeContext {\n    handleId: string;\n    pixelToSizeRatio: number;\n    displayContainerRect?: Dimensions;\n    resizeHandleStartPx: number;\n    beforeNodeId: string;\n    beforeNodeStartSize: number;\n    afterNodeId: string;\n    afterNodeStartSize: number;\n}\n\nconst DefaultGapSizePx = 3;\nconst MinNodeSizePx = 40;\nconst DefaultAnimationTimeS = 0.15;\n\nexport class LayoutModel {\n    /**\n     * Local atom holding the current tree state (source of truth during runtime)\n     */\n    private localTreeStateAtom: PrimitiveAtom<LayoutTreeState>;\n    /**\n     * The tree state (local cache)\n     */\n    treeState: LayoutTreeState;\n    /**\n     * Reference to the tab atom for accessing WaveObject\n     */\n    private tabAtom: Atom<Tab>;\n    /**\n     * WaveObject atom for persistence\n     */\n    private waveObjectAtom: Atom<LayoutState>;\n    /**\n     * Debounce timer for persistence\n     */\n    private persistDebounceTimer: NodeJS.Timeout | null;\n    /**\n     * Set of action IDs that have been processed (prevents duplicate processing)\n     */\n    private processedActionIds: Set<string>;\n    /**\n     * The jotai getter that is used to read atom values.\n     */\n    getter: Getter;\n    /**\n     * The jotai setter that is used to update atom values.\n     */\n    setter: Setter;\n    /**\n     * Callback that is invoked to render the block associated with a leaf node.\n     */\n    renderContent?: ContentRenderer;\n    /**\n     * Callback that is invoked to render the drag preview for a leaf node.\n     */\n    renderPreview?: PreviewRenderer;\n    /**\n     * Callback that is invoked when a node is closed.\n     */\n    onNodeDelete?: (data: TabLayoutData) => Promise<void>;\n    /**\n     * The size of the gap between nodes in CSS pixels.\n     */\n    gapSizePx: PrimitiveAtom<number>;\n\n    /**\n     * The time a transition animation takes, in seconds.\n     */\n    animationTimeS: PrimitiveAtom<number>;\n\n    /**\n     * List of nodes that are leafs and should be rendered as a DisplayNode.\n     */\n    leafs: PrimitiveAtom<LayoutNode[]>;\n    /**\n     * An ordered list of node ids starting from the top left corner to the bottom right corner.\n     */\n    leafOrder: PrimitiveAtom<LeafOrderEntry[]>;\n    /**\n     * Atom representing the number of leaf nodes in a layout.\n     */\n    numLeafs: Atom<number>;\n    /**\n     * A map of node models for currently-active leafs.\n     */\n    private nodeModels: Map<string, NodeModel>;\n\n    /**\n     * Split atom containing the properties of all of the resize handles that should be placed in the layout.\n     */\n    resizeHandles: SplitAtom<ResizeHandleProps>;\n    /**\n     * Layout node derived properties that are not persisted to the backend.\n     * @see updateTreeHelper for the logic to update these properties.\n     */\n    additionalProps: PrimitiveAtom<Record<string, LayoutNodeAdditionalProps>>;\n    /**\n     * Set if there is currently an uncommitted action pending on the layout tree.\n     * @see LayoutTreeActionType for the different types of actions.\n     */\n    pendingTreeAction: AtomWithThrottle<LayoutTreeAction>;\n    /**\n     * Whether a node is currently being dragged.\n     */\n    activeDrag: PrimitiveAtom<boolean>;\n    /**\n     * Whether the overlay container should be shown.\n     * @see overlayTransform contains the actual CSS transform that moves the overlay into view.\n     */\n    showOverlay: PrimitiveAtom<boolean>;\n    /**\n     * Whether the nodes within the layout should be displaying content.\n     */\n    ready: PrimitiveAtom<boolean>;\n\n    /**\n     * RefObject for the display container, that holds the display nodes. This is used to get the size of the whole layout.\n     */\n    displayContainerRef: React.RefObject<HTMLDivElement>;\n    /**\n     * CSS properties for the placeholder element.\n     */\n    placeholderTransform: Atom<CSSProperties>;\n    /**\n     * CSS properties for the overlay container.\n     */\n    overlayTransform: Atom<CSSProperties>;\n\n    /**\n     * The currently focused node.\n     */\n    private focusedNodeIdStack: string[];\n    /**\n     * Atom pointing to the currently focused node.\n     */\n    focusedNode: Atom<LayoutNode>;\n\n    // TODO: Nodes that need to be placed at higher z-indices should probably be handled by an ordered list, rather than individual properties.\n    /**\n     * The currently magnified node.\n     */\n    magnifiedNodeId: string;\n    /**\n     * Atom for the magnified node ID (derived from local tree state)\n     */\n    magnifiedNodeIdAtom: Atom<string>;\n    /**\n     * The last node to be magnified, other than the current magnified node, if set. This node should sit at a higher z-index than the others so that it floats above the other nodes as it returns to its original position.\n     */\n    lastMagnifiedNodeId: string;\n    /**\n     * Atom holding an ephemeral node that is not part of the layout tree. This node displays above all other nodes.\n     */\n    ephemeralNode: PrimitiveAtom<LayoutNode>;\n    /**\n     * The last node to be an ephemeral node. This node should sit at a higher z-index than the others so that it floats above the other nodes as it returns to its original position.\n     */\n    lastEphemeralNodeId: string;\n    magnifiedNodeSizeAtom: Atom<number>;\n\n    /**\n     * The size of the resize handles, in CSS pixels.\n     * The resize handle size is double the gap size, or double the default gap size, whichever is greater.\n     * @see gapSizePx @see DefaultGapSizePx\n     */\n    private resizeHandleSizePx: Atom<number>;\n    /**\n     * A context used by the resize handles to keep track of precomputed values for the current resize operation.\n     */\n    private resizeContext?: ResizeContext;\n    /**\n     * True if a resize handle is currently being dragged or the whole TileLayout container is being resized.\n     */\n    isResizing: Atom<boolean>;\n    /**\n     * True if the whole TileLayout container is being resized.\n     */\n    private isContainerResizing: PrimitiveAtom<boolean>;\n\n    constructor(\n        tabAtom: Atom<Tab>,\n        getter: Getter,\n        setter: Setter,\n        renderContent?: ContentRenderer,\n        renderPreview?: PreviewRenderer,\n        onNodeDelete?: (data: TabLayoutData) => Promise<void>,\n        gapSizePx?: number,\n        animationTimeS?: number\n    ) {\n        this.tabAtom = tabAtom;\n        this.getter = getter;\n        this.setter = setter;\n        this.renderContent = renderContent;\n        this.renderPreview = renderPreview;\n        this.onNodeDelete = onNodeDelete;\n        this.gapSizePx = atom(gapSizePx ?? DefaultGapSizePx);\n        this.resizeHandleSizePx = atom((get) => {\n            const gapSizePx = get(this.gapSizePx);\n            return 2 * (gapSizePx > 5 ? gapSizePx : DefaultGapSizePx);\n        });\n        this.animationTimeS = atom(animationTimeS ?? DefaultAnimationTimeS);\n        this.persistDebounceTimer = null;\n        this.processedActionIds = new Set();\n\n        this.waveObjectAtom = getLayoutStateAtomFromTab(tabAtom, getter);\n\n        this.localTreeStateAtom = atom<LayoutTreeState>({\n            rootNode: undefined,\n            focusedNodeId: undefined,\n            magnifiedNodeId: undefined,\n            leafOrder: undefined,\n            pendingBackendActions: undefined,\n        });\n\n        this.treeState = {\n            rootNode: undefined,\n            focusedNodeId: undefined,\n            magnifiedNodeId: undefined,\n            leafOrder: undefined,\n            pendingBackendActions: undefined,\n        };\n\n        this.leafs = atom([]);\n        this.leafOrder = atom([]);\n        this.numLeafs = atom((get) => get(this.leafOrder).length);\n\n        this.nodeModels = new Map();\n        this.additionalProps = atom({});\n\n        const resizeHandleListAtom = atom((get) => {\n            const addlProps = get(this.additionalProps);\n            return Object.values(addlProps)\n                .flatMap((props) => props.resizeHandles)\n                .filter((v) => v);\n        });\n        this.resizeHandles = splitAtom(resizeHandleListAtom);\n        this.isContainerResizing = atom(false);\n        this.isResizing = atom((get) => {\n            const pendingAction = get(this.pendingTreeAction.throttledValueAtom);\n            const isWindowResizing = get(this.isContainerResizing);\n            return isWindowResizing || pendingAction?.type === LayoutTreeActionType.ResizeNode;\n        });\n\n        this.displayContainerRef = createRef();\n        this.activeDrag = atom(false);\n        this.showOverlay = atom(false);\n        this.ready = atom(false);\n        this.overlayTransform = atom<CSSProperties>((get) => {\n            const activeDrag = get(this.activeDrag);\n            const showOverlay = get(this.showOverlay);\n            if (this.displayContainerRef.current) {\n                const displayBoundingRect = this.displayContainerRef.current.getBoundingClientRect();\n                const newOverlayOffset = displayBoundingRect.top + 2 * displayBoundingRect.height;\n                const newTransform = setTransform(\n                    {\n                        top: activeDrag || showOverlay ? 0 : newOverlayOffset,\n                        left: 0,\n                        width: displayBoundingRect.width,\n                        height: displayBoundingRect.height,\n                    },\n                    false\n                );\n                return newTransform;\n            }\n        });\n\n        this.ephemeralNode = atom();\n        this.magnifiedNodeSizeAtom = getSettingsKeyAtom(\"window:magnifiedblocksize\");\n\n        this.magnifiedNodeIdAtom = atom((get) => {\n            const treeState = get(this.localTreeStateAtom);\n            return treeState.magnifiedNodeId;\n        });\n\n        this.focusedNode = atom((get) => {\n            const ephemeralNode = get(this.ephemeralNode);\n            const treeState = get(this.localTreeStateAtom);\n            if (ephemeralNode) {\n                return ephemeralNode;\n            }\n            if (treeState.focusedNodeId == null) {\n                return null;\n            }\n            return findNode(treeState.rootNode, treeState.focusedNodeId);\n        });\n        this.focusedNodeIdStack = [];\n\n        this.pendingTreeAction = atomWithThrottle<LayoutTreeAction>(null, 10);\n        this.placeholderTransform = atom<CSSProperties>((get: Getter) => {\n            const pendingAction = get(this.pendingTreeAction.throttledValueAtom);\n            return this.getPlaceholderTransform(pendingAction);\n        });\n\n        this.initializeFromWaveObject();\n    }\n\n    private initializeFromWaveObject() {\n        const waveObjState = this.getter(this.waveObjectAtom);\n\n        const initialState: LayoutTreeState = {\n            rootNode: waveObjState?.rootnode,\n            focusedNodeId: waveObjState?.focusednodeid,\n            magnifiedNodeId: waveObjState?.magnifiednodeid,\n            leafOrder: undefined,\n            pendingBackendActions: waveObjState?.pendingbackendactions,\n        };\n\n        this.treeState = initialState;\n        this.magnifiedNodeId = initialState.magnifiedNodeId;\n        this.setter(this.localTreeStateAtom, { ...initialState });\n\n        if (initialState.pendingBackendActions?.length) {\n            fireAndForget(() => this.processPendingBackendActions());\n        } else {\n            this.updateTree();\n        }\n    }\n\n    onBackendUpdate() {\n        const waveObj = this.getter(this.waveObjectAtom);\n        const pendingActions = waveObj?.pendingbackendactions;\n        if (pendingActions?.length) {\n            fireAndForget(() => this.processPendingBackendActions());\n        }\n    }\n\n    private async processPendingBackendActions() {\n        const waveObj = this.getter(this.waveObjectAtom);\n        const actions = waveObj?.pendingbackendactions;\n        if (!actions?.length) return;\n\n        this.treeState.pendingBackendActions = undefined;\n\n        for (const action of actions) {\n            if (!action.actionid) {\n                console.warn(\"Dropping layout action without actionid:\", action);\n                continue;\n            }\n            if (this.processedActionIds.has(action.actionid)) {\n                continue;\n            }\n            this.processedActionIds.add(action.actionid);\n            await this.handleBackendAction(action);\n        }\n\n        this.updateTree();\n        this.setter(this.localTreeStateAtom, { ...this.treeState });\n        this.persistToBackend();\n    }\n\n    private async cleanupOrphanedBlocks() {\n        const tab = this.getter(this.tabAtom);\n        const layoutBlockIds = new Set<string>();\n\n        if (this.treeState.rootNode == null) {\n            return;\n        }\n\n        walkNodes(this.treeState.rootNode, (node) => {\n            if (node.data?.blockId) {\n                layoutBlockIds.add(node.data.blockId);\n            }\n        });\n\n        for (const blockId of tab.blockids || []) {\n            if (!layoutBlockIds.has(blockId)) {\n                console.log(\"Cleaning up orphaned block:\", blockId);\n                if (this.onNodeDelete) {\n                    await this.onNodeDelete({ blockId });\n                }\n            }\n        }\n    }\n\n    private async handleBackendAction(action: LayoutActionData) {\n        switch (action.actiontype) {\n            case LayoutTreeActionType.InsertNode: {\n                if (action.ephemeral) {\n                    this.newEphemeralNode(action.blockid);\n                    break;\n                }\n                const insertNodeAction: LayoutTreeInsertNodeAction = {\n                    type: LayoutTreeActionType.InsertNode,\n                    node: newLayoutNode(undefined, undefined, undefined, {\n                        blockId: action.blockid,\n                    }),\n                    magnified: action.magnified,\n                    focused: action.focused,\n                };\n                this.treeReducer(insertNodeAction, false);\n                break;\n            }\n            case LayoutTreeActionType.DeleteNode: {\n                const leaf = this?.getNodeByBlockId(action.blockid);\n                if (leaf) {\n                    await this.closeNode(leaf.id);\n                } else {\n                    console.error(\n                        \"Cannot apply eventbus layout action DeleteNode, could not find leaf node with blockId\",\n                        action.blockid\n                    );\n                }\n                break;\n            }\n            case LayoutTreeActionType.InsertNodeAtIndex: {\n                if (!action.indexarr) {\n                    console.error(\"Cannot apply eventbus layout action InsertNodeAtIndex, indexarr field is missing.\");\n                    break;\n                }\n                const insertAction: LayoutTreeInsertNodeAtIndexAction = {\n                    type: LayoutTreeActionType.InsertNodeAtIndex,\n                    node: newLayoutNode(undefined, action.nodesize, undefined, {\n                        blockId: action.blockid,\n                    }),\n                    indexArr: action.indexarr,\n                    magnified: action.magnified,\n                    focused: action.focused,\n                };\n                this.treeReducer(insertAction, false);\n                break;\n            }\n            case LayoutTreeActionType.ClearTree: {\n                this.treeReducer(\n                    {\n                        type: LayoutTreeActionType.ClearTree,\n                    } as LayoutTreeClearTreeAction,\n                    false\n                );\n                break;\n            }\n            case LayoutTreeActionType.ReplaceNode: {\n                const targetNode = this?.getNodeByBlockId(action.targetblockid);\n                if (!targetNode) {\n                    console.error(\n                        \"Cannot apply eventbus layout action ReplaceNode, could not find target node with blockId\",\n                        action.targetblockid\n                    );\n                    break;\n                }\n                const replaceAction: LayoutTreeReplaceNodeAction = {\n                    type: LayoutTreeActionType.ReplaceNode,\n                    targetNodeId: targetNode.id,\n                    newNode: newLayoutNode(undefined, action.nodesize, undefined, {\n                        blockId: action.blockid,\n                    }),\n                };\n                this.treeReducer(replaceAction, false);\n                break;\n            }\n            case LayoutTreeActionType.SplitHorizontal: {\n                const targetNode = this?.getNodeByBlockId(action.targetblockid);\n                if (!targetNode) {\n                    console.error(\n                        \"Cannot apply eventbus layout action SplitHorizontal, could not find target node with blockId\",\n                        action.targetblockid\n                    );\n                    break;\n                }\n                if (action.position != \"before\" && action.position != \"after\") {\n                    console.error(\n                        \"Cannot apply eventbus layout action SplitHorizontal, invalid position\",\n                        action.position\n                    );\n                    break;\n                }\n                const newNode = newLayoutNode(undefined, action.nodesize, undefined, {\n                    blockId: action.blockid,\n                });\n                const splitAction: LayoutTreeSplitHorizontalAction = {\n                    type: LayoutTreeActionType.SplitHorizontal,\n                    targetNodeId: targetNode.id,\n                    newNode: newNode,\n                    position: action.position,\n                };\n                this.treeReducer(splitAction, false);\n                break;\n            }\n            case LayoutTreeActionType.SplitVertical: {\n                const targetNode = this?.getNodeByBlockId(action.targetblockid);\n                if (!targetNode) {\n                    console.error(\n                        \"Cannot apply eventbus layout action SplitVertical, could not find target node with blockId\",\n                        action.targetblockid\n                    );\n                    break;\n                }\n                if (action.position != \"before\" && action.position != \"after\") {\n                    console.error(\n                        \"Cannot apply eventbus layout action SplitVertical, invalid position\",\n                        action.position\n                    );\n                    break;\n                }\n                const newNode = newLayoutNode(undefined, action.nodesize, undefined, {\n                    blockId: action.blockid,\n                });\n                const splitAction: LayoutTreeSplitVerticalAction = {\n                    type: LayoutTreeActionType.SplitVertical,\n                    targetNodeId: targetNode.id,\n                    newNode: newNode,\n                    position: action.position,\n                };\n                this.treeReducer(splitAction, false);\n                break;\n            }\n            case \"cleanuporphaned\": {\n                await this.cleanupOrphanedBlocks();\n                break;\n            }\n            default:\n                console.warn(\"unsupported layout action\", action);\n                break;\n        }\n    }\n\n    private persistToBackend() {\n        if (this.persistDebounceTimer) {\n            clearTimeout(this.persistDebounceTimer);\n        }\n\n        this.persistDebounceTimer = setTimeout(() => {\n            const waveObj = this.getter(this.waveObjectAtom);\n            if (!waveObj) return;\n\n            waveObj.rootnode = this.treeState.rootNode;\n            waveObj.focusednodeid = this.treeState.focusedNodeId;\n            waveObj.magnifiednodeid = this.treeState.magnifiedNodeId;\n            waveObj.leaforder = this.treeState.leafOrder;\n            waveObj.pendingbackendactions = this.treeState.pendingBackendActions;\n\n            WOS.setObjectValue(waveObj, this.setter, true);\n            this.persistDebounceTimer = null;\n        }, 100);\n    }\n\n    /**\n     * Register TileLayout callbacks that should be called on various state changes.\n     * @param contents Contains callbacks provided by the TileLayout component.\n     */\n    registerTileLayout(contents: TileLayoutContents) {\n        this.renderContent = contents.renderContent;\n        this.renderPreview = contents.renderPreview;\n        this.onNodeDelete = contents.onNodeDelete;\n        if (contents.gapSizePx !== undefined) {\n            this.setter(this.gapSizePx, contents.gapSizePx);\n        }\n        const tab = this.getter(this.tabAtom);\n        fireAndForget(() => BlockService.CleanupOrphanedBlocks(tab.oid));\n    }\n\n    /**\n     * Perform an action against the layout tree state.\n     * @param action The action to perform.\n     */\n    treeReducer(action: LayoutTreeAction, setState = true) {\n        switch (action.type) {\n            case LayoutTreeActionType.ComputeMove:\n                this.setter(\n                    this.pendingTreeAction.throttledValueAtom,\n                    computeMoveNode(this.treeState, action as LayoutTreeComputeMoveNodeAction)\n                );\n                break;\n            case LayoutTreeActionType.Move:\n                moveNode(this.treeState, action as LayoutTreeMoveNodeAction);\n                break;\n            case LayoutTreeActionType.InsertNode:\n                insertNode(this.treeState, action as LayoutTreeInsertNodeAction);\n                if ((action as LayoutTreeInsertNodeAction).focused) {\n                    FocusManager.getInstance().requestNodeFocus();\n                }\n                break;\n            case LayoutTreeActionType.InsertNodeAtIndex:\n                insertNodeAtIndex(this.treeState, action as LayoutTreeInsertNodeAtIndexAction);\n                if ((action as LayoutTreeInsertNodeAtIndexAction).focused) {\n                    FocusManager.getInstance().requestNodeFocus();\n                }\n                break;\n            case LayoutTreeActionType.DeleteNode:\n                deleteNode(this.treeState, action as LayoutTreeDeleteNodeAction);\n                break;\n            case LayoutTreeActionType.Swap:\n                swapNode(this.treeState, action as LayoutTreeSwapNodeAction);\n                break;\n            case LayoutTreeActionType.ResizeNode:\n                resizeNode(this.treeState, action as LayoutTreeResizeNodeAction);\n                break;\n            case LayoutTreeActionType.SetPendingAction: {\n                const pendingAction = (action as LayoutTreeSetPendingAction).action;\n                if (pendingAction) {\n                    this.setter(this.pendingTreeAction.throttledValueAtom, pendingAction);\n                } else {\n                    console.warn(\"No new pending action provided\");\n                }\n                break;\n            }\n            case LayoutTreeActionType.ClearPendingAction:\n                this.setter(this.pendingTreeAction.throttledValueAtom, undefined);\n                break;\n            case LayoutTreeActionType.CommitPendingAction: {\n                const pendingAction = this.getter(this.pendingTreeAction.currentValueAtom);\n                if (!pendingAction) {\n                    console.error(\"unable to commit pending action, does not exist\");\n                    break;\n                }\n                this.treeReducer(pendingAction);\n                this.setter(this.pendingTreeAction.throttledValueAtom, undefined);\n                break;\n            }\n            case LayoutTreeActionType.FocusNode:\n                focusNode(this.treeState, action as LayoutTreeFocusNodeAction);\n                FocusManager.getInstance().requestNodeFocus();\n                break;\n            case LayoutTreeActionType.MagnifyNodeToggle:\n                magnifyNodeToggle(this.treeState, action as LayoutTreeMagnifyNodeToggleAction);\n                FocusManager.getInstance().requestNodeFocus();\n                break;\n            case LayoutTreeActionType.ClearTree:\n                clearTree(this.treeState);\n                break;\n            case LayoutTreeActionType.ReplaceNode:\n                replaceNode(this.treeState, action as LayoutTreeReplaceNodeAction);\n                break;\n            case LayoutTreeActionType.SplitHorizontal:\n                splitHorizontal(this.treeState, action as LayoutTreeSplitHorizontalAction);\n                break;\n            case LayoutTreeActionType.SplitVertical:\n                splitVertical(this.treeState, action as LayoutTreeSplitVerticalAction);\n                break;\n            default:\n                console.error(\"Invalid reducer action\", this.treeState, action);\n        }\n        if (this.magnifiedNodeId !== this.treeState.magnifiedNodeId) {\n            this.lastMagnifiedNodeId = this.magnifiedNodeId;\n            this.lastEphemeralNodeId = undefined;\n            this.magnifiedNodeId = this.treeState.magnifiedNodeId;\n        }\n        if (setState) {\n            this.updateTree();\n            this.setter(this.localTreeStateAtom, { ...this.treeState });\n            this.persistToBackend();\n        }\n    }\n\n    /**\n     * Callback that is invoked when the upstream tree state has been updated. This ensures the model is updated if the atom is not fully loaded when the model is first instantiated.\n     * @param force Whether to force the local tree state to update, regardless of whether the state is already up to date.\n     */\n    async onTreeStateAtomUpdated(force = false) {\n        if (force) {\n            this.updateTree();\n            this.setter(this.localTreeStateAtom, { ...this.treeState });\n        }\n    }\n\n    /**\n     * Set the upstream tree state atom to the value of the local tree state.\n     * @param bumpGeneration Whether to bump the generation of the tree state before setting the atom.\n     */\n\n    /**\n     * Recursively walks the tree to find leaf nodes, update the resize handles, and compute additional properties for each node.\n     * @param balanceTree Whether the tree should also be balanced as it is walked. This should be done if the tree state has just been updated. Defaults to true.\n     */\n    updateTree(balanceTree = true) {\n        if (this.displayContainerRef.current) {\n            const newLeafs: LayoutNode[] = [];\n            const newAdditionalProps = {};\n\n            const pendingAction = this.getter(this.pendingTreeAction.currentValueAtom);\n            const resizeAction =\n                pendingAction?.type === LayoutTreeActionType.ResizeNode\n                    ? (pendingAction as LayoutTreeResizeNodeAction)\n                    : null;\n            const resizeHandleSizePx = this.getter(this.resizeHandleSizePx);\n\n            const boundingRect = this.getBoundingRect();\n\n            const magnifiedNodeSize = this.getter(this.magnifiedNodeSizeAtom);\n\n            const callback = (node: LayoutNode) =>\n                this.updateTreeHelper(\n                    node,\n                    newAdditionalProps,\n                    newLeafs,\n                    resizeHandleSizePx,\n                    magnifiedNodeSize,\n                    boundingRect,\n                    resizeAction\n                );\n            if (balanceTree) this.treeState.rootNode = balanceNode(this.treeState.rootNode, callback);\n            else walkNodes(this.treeState.rootNode, callback);\n\n            // Process ephemeral node, if present.\n            const ephemeralNode = this.getter(this.ephemeralNode);\n            if (ephemeralNode) {\n                this.updateEphemeralNodeProps(\n                    ephemeralNode,\n                    newAdditionalProps,\n                    newLeafs,\n                    magnifiedNodeSize,\n                    boundingRect\n                );\n            }\n\n            this.treeState.leafOrder = getLeafOrder(newLeafs, newAdditionalProps);\n            this.validateFocusedNode(this.treeState.leafOrder);\n            this.validateMagnifiedNode(this.treeState.leafOrder, newAdditionalProps);\n            this.cleanupNodeModels(this.treeState.leafOrder);\n            this.setter(\n                this.leafs,\n                newLeafs.sort((a, b) => a.id.localeCompare(b.id))\n            );\n            this.setter(this.leafOrder, this.treeState.leafOrder);\n            this.setter(this.additionalProps, newAdditionalProps);\n        }\n    }\n\n    /**\n     * Per-node callback that is invoked recursively to find leaf nodes, update the resize handles, and compute additional properties associated with the given node.\n     * @param node The node for which to update the resize handles and additional properties.\n     * @param additionalPropsMap The new map that will contain the updated additional properties for all nodes in the tree.\n     * @param leafs The new list that will contain all the leaf nodes in the tree.\n     * @param resizeAction The pending resize action, if any. Used to set temporary size values on nodes that are being resized.\n     */\n    private updateTreeHelper(\n        node: LayoutNode,\n        additionalPropsMap: Record<string, LayoutNodeAdditionalProps>,\n        leafs: LayoutNode[],\n        resizeHandleSizePx: number,\n        magnifiedNodeSizePct: number,\n        boundingRect: Dimensions,\n        resizeAction?: LayoutTreeResizeNodeAction\n    ) {\n        if (!node.children?.length) {\n            leafs.push(node);\n            const addlProps = additionalPropsMap[node.id];\n            if (addlProps) {\n                if (this.magnifiedNodeId === node.id) {\n                    const magnifiedNodeMarginPct = (1 - magnifiedNodeSizePct) / 2;\n                    const transform = setTransform(\n                        {\n                            top: boundingRect.height * magnifiedNodeMarginPct,\n                            left: boundingRect.width * magnifiedNodeMarginPct,\n                            width: boundingRect.width * magnifiedNodeSizePct,\n                            height: boundingRect.height * magnifiedNodeSizePct,\n                        },\n                        true,\n                        true,\n                        \"var(--zindex-layout-magnified-node)\"\n                    );\n                    addlProps.transform = transform;\n                }\n                if (this.lastMagnifiedNodeId === node.id) {\n                    addlProps.transform.zIndex = \"var(--zindex-layout-last-magnified-node)\";\n                } else if (this.lastEphemeralNodeId === node.id) {\n                    addlProps.transform.zIndex = \"var(--zindex-layout-last-ephemeral-node)\";\n                }\n            }\n            return;\n        }\n\n        function getNodeSize(node: LayoutNode) {\n            return resizeAction?.resizeOperations.find((op) => op.nodeId === node.id)?.size ?? node.size;\n        }\n\n        const additionalProps: LayoutNodeAdditionalProps = node.id in additionalPropsMap\n            ? additionalPropsMap[node.id]\n            : { treeKey: \"0\" };\n\n        const nodeRect: Dimensions = node.id === this.treeState.rootNode.id ? boundingRect : additionalProps.rect;\n        const nodeIsRow = node.flexDirection === FlexDirection.Row;\n        const nodePixels = nodeIsRow ? nodeRect.width : nodeRect.height;\n        const totalChildrenSize = node.children.reduce((acc, child) => acc + getNodeSize(child), 0);\n        const pixelToSizeRatio = totalChildrenSize / nodePixels;\n\n        let lastChildRect: Dimensions;\n        const resizeHandles: ResizeHandleProps[] = [];\n        node.children.forEach((child, i) => {\n            const childSize = getNodeSize(child);\n            const rect: Dimensions = {\n                top: !nodeIsRow && lastChildRect ? lastChildRect.top + lastChildRect.height : nodeRect.top,\n                left: nodeIsRow && lastChildRect ? lastChildRect.left + lastChildRect.width : nodeRect.left,\n                width: nodeIsRow ? childSize / pixelToSizeRatio : nodeRect.width,\n                height: nodeIsRow ? nodeRect.height : childSize / pixelToSizeRatio,\n            };\n            const transform = setTransform(rect);\n            additionalPropsMap[child.id] = {\n                rect,\n                transform,\n                treeKey: additionalProps.treeKey + i,\n            };\n\n            // We only want the resize handles in between nodes, this ensures we have n-1 handles.\n            if (lastChildRect) {\n                const resizeHandleIndex = resizeHandles.length;\n                const halfResizeHandleSizePx = resizeHandleSizePx / 2;\n                const resizeHandleDimensions: Dimensions = {\n                    top: nodeIsRow\n                        ? lastChildRect.top\n                        : lastChildRect.top + lastChildRect.height - halfResizeHandleSizePx,\n                    left: nodeIsRow\n                        ? lastChildRect.left + lastChildRect.width - halfResizeHandleSizePx\n                        : lastChildRect.left,\n                    width: nodeIsRow ? resizeHandleSizePx : lastChildRect.width,\n                    height: nodeIsRow ? lastChildRect.height : resizeHandleSizePx,\n                };\n                resizeHandles.push({\n                    id: `${node.id}-${resizeHandleIndex}`,\n                    parentNodeId: node.id,\n                    parentIndex: resizeHandleIndex,\n                    transform: setTransform(resizeHandleDimensions, true, false),\n                    flexDirection: node.flexDirection,\n                    centerPx:\n                        (nodeIsRow ? resizeHandleDimensions.left : resizeHandleDimensions.top) + halfResizeHandleSizePx,\n                });\n            }\n            lastChildRect = rect;\n        });\n\n        additionalPropsMap[node.id] = {\n            ...additionalProps,\n            ...(node.data?.blockId ? { rect: nodeRect } : {}),\n            pixelToSizeRatio,\n            resizeHandles,\n        };\n    }\n\n    /**\n     * Gets normalized dimensions for the TileLayout container.\n     * @returns The normalized dimensions for the TileLayout container.\n     */\n    getBoundingRect: () => Dimensions = () => {\n        const boundingRect = this.displayContainerRef.current.getBoundingClientRect();\n        return { top: 0, left: 0, width: boundingRect.width, height: boundingRect.height };\n    };\n\n    /**\n     * The id of the focused node in the layout.\n     */\n    get focusedNodeId(): string {\n        return this.focusedNodeIdStack[0];\n    }\n\n    /**\n     * Checks whether the focused node id has changed and, if so, whether to update the focused node stack. If the focused node was deleted, will pop the latest value from the stack.\n     * @param leafOrder The new leaf order array to use when searching for stale nodes in the stack.\n     */\n    private validateFocusedNode(leafOrder: LeafOrderEntry[]) {\n        if (this.treeState.focusedNodeId !== this.focusedNodeId) {\n            // Remove duplicates and stale entries from focus stack.\n            const newFocusedNodeIdStack: string[] = [];\n            for (const id of this.focusedNodeIdStack) {\n                if (leafOrder.find((leafEntry) => leafEntry?.nodeid === id) && !newFocusedNodeIdStack.includes(id))\n                    newFocusedNodeIdStack.push(id);\n            }\n            this.focusedNodeIdStack = newFocusedNodeIdStack;\n\n            // Update the focused node and stack based on the changes in the tree state.\n            if (!this.treeState.focusedNodeId) {\n                if (this.focusedNodeIdStack.length > 0) {\n                    this.treeState.focusedNodeId = this.focusedNodeIdStack.shift();\n                } else if (leafOrder.length > 0) {\n                    // If no nodes are in the stack, use the top left node in the layout.\n                    this.treeState.focusedNodeId = leafOrder[0].nodeid;\n                }\n            }\n            this.focusedNodeIdStack.unshift(this.treeState.focusedNodeId);\n        }\n    }\n\n    /**\n     * When a layout is modified and only one leaf is remaining, we need to make sure it is no longer magnified.\n     * @param leafOrder The new leaf order array to use when validating the number of leafs remaining.\n     * @param addlProps The new additional properties object for all leafs in the layout.\n     */\n    private validateMagnifiedNode(leafOrder: LeafOrderEntry[], addlProps: Record<string, LayoutNodeAdditionalProps>) {\n        if (leafOrder.length == 1) {\n            const lastLeafId = leafOrder[0].nodeid;\n            this.treeState.magnifiedNodeId = undefined;\n            this.magnifiedNodeId = undefined;\n\n            // Unset the transform for the sole leaf.\n            if (lastLeafId in addlProps) addlProps[lastLeafId].transform = undefined;\n        }\n    }\n\n    /**\n     * Helper function for the placeholderTransform atom, which computes the new transform value when the pending action changes.\n     * @param pendingAction The new pending action value.\n     * @returns The computed placeholder transform.\n     *\n     * @see placeholderTransform the atom that invokes this function and persists the updated value.\n     */\n    private getPlaceholderTransform(pendingAction: LayoutTreeAction): CSSProperties {\n        if (pendingAction) {\n            switch (pendingAction.type) {\n                case LayoutTreeActionType.Move: {\n                    const action = pendingAction as LayoutTreeMoveNodeAction;\n                    let parentId: string;\n                    if (action.insertAtRoot) {\n                        parentId = this.treeState.rootNode.id;\n                    } else {\n                        parentId = action.parentId;\n                    }\n\n                    const parentNode = findNode(this.treeState.rootNode, parentId);\n                    if (action.index !== undefined && parentNode) {\n                        const targetIndex = boundNumber(\n                            action.index - 1,\n                            0,\n                            parentNode.children ? parentNode.children.length - 1 : 0\n                        );\n                        const targetNode = parentNode?.children?.at(targetIndex) ?? parentNode;\n                        if (targetNode) {\n                            const targetBoundingRect = this.getNodeRect(targetNode);\n\n                            // Placeholder should be either half the height or half the width of the targetNode, depending on the flex direction of the targetNode's parent.\n                            // Default to placing the placeholder in the first half of the target node.\n                            const placeholderDimensions: Dimensions = {\n                                height:\n                                    parentNode.flexDirection === FlexDirection.Column\n                                        ? targetBoundingRect.height / 2\n                                        : targetBoundingRect.height,\n                                width:\n                                    parentNode.flexDirection === FlexDirection.Row\n                                        ? targetBoundingRect.width / 2\n                                        : targetBoundingRect.width,\n                                top: targetBoundingRect.top,\n                                left: targetBoundingRect.left,\n                            };\n\n                            if (action.index > targetIndex) {\n                                if (action.index >= (parentNode.children?.length ?? 1)) {\n                                    // If there are no more nodes after the specified index, place the placeholder in the second half of the target node (either right or bottom).\n                                    placeholderDimensions.top +=\n                                        parentNode.flexDirection === FlexDirection.Column &&\n                                        targetBoundingRect.height / 2;\n                                    placeholderDimensions.left +=\n                                        parentNode.flexDirection === FlexDirection.Row && targetBoundingRect.width / 2;\n                                } else {\n                                    // Otherwise, place the placeholder between the target node (the one after which it will be inserted) and the next node\n                                    placeholderDimensions.top +=\n                                        parentNode.flexDirection === FlexDirection.Column &&\n                                        (3 * targetBoundingRect.height) / 4;\n                                    placeholderDimensions.left +=\n                                        parentNode.flexDirection === FlexDirection.Row &&\n                                        (3 * targetBoundingRect.width) / 4;\n                                }\n                            }\n\n                            return setTransform(placeholderDimensions);\n                        }\n                    }\n                    break;\n                }\n                case LayoutTreeActionType.Swap: {\n                    const action = pendingAction as LayoutTreeSwapNodeAction;\n                    const targetNodeId = action.node1Id;\n                    const targetBoundingRect = this.getNodeRectById(targetNodeId);\n                    const placeholderDimensions: Dimensions = {\n                        top: targetBoundingRect.top,\n                        left: targetBoundingRect.left,\n                        height: targetBoundingRect.height,\n                        width: targetBoundingRect.width,\n                    };\n\n                    return setTransform(placeholderDimensions);\n                }\n                default:\n                    // No-op\n                    break;\n            }\n        }\n        return;\n    }\n\n    /**\n     * Gets the node model for the given node.\n     * @param node The node for which to retrieve the node model.\n     * @returns The node model for the given node.\n     */\n    getNodeModel(node: LayoutNode): NodeModel {\n        const nodeid = node.id;\n        const blockId = node.data.blockId;\n        const addlPropsAtom = this.getNodeAdditionalPropertiesAtom(nodeid);\n        if (!this.nodeModels.has(nodeid)) {\n            this.nodeModels.set(nodeid, {\n                additionalProps: addlPropsAtom,\n                innerRect: atom((get) => {\n                    const addlProps = get(addlPropsAtom);\n                    const numLeafs = get(this.numLeafs);\n                    const gapSizePx = get(this.gapSizePx);\n                    if (numLeafs > 1 && addlProps?.rect) {\n                        return {\n                            width: `${addlProps.transform.width} - ${gapSizePx}px`,\n                            height: `${addlProps.transform.height} - ${gapSizePx}px`,\n                        } as CSSProperties;\n                    } else {\n                        return null;\n                    }\n                }),\n                nodeId: nodeid,\n                blockId,\n                blockNum: atom((get) => get(this.leafOrder).findIndex((leafEntry) => leafEntry.nodeid === nodeid) + 1),\n                isFocused: atom((get) => {\n                    const treeState = get(this.localTreeStateAtom);\n                    const isFocused = treeState.focusedNodeId === nodeid;\n                    const focusType = get(FocusManager.getInstance().focusType);\n                    return isFocused && focusType === \"node\";\n                }),\n                numLeafs: this.numLeafs,\n                isResizing: this.isResizing,\n                isMagnified: atom((get) => {\n                    const treeState = get(this.localTreeStateAtom);\n                    return treeState.magnifiedNodeId === nodeid;\n                }),\n                anyMagnified: atom((get) => {\n                    const treeState = get(this.localTreeStateAtom);\n                    return treeState.magnifiedNodeId != null;\n                }),\n                isEphemeral: atom((get) => {\n                    const ephemeralNode = get(this.ephemeralNode);\n                    return ephemeralNode?.id === nodeid;\n                }),\n                addEphemeralNodeToLayout: () => this.addEphemeralNodeToLayout(),\n                animationTimeS: this.animationTimeS,\n                ready: this.ready,\n                disablePointerEvents: this.activeDrag,\n                onClose: () => fireAndForget(() => this.closeNode(nodeid)), // no longer used (instead we use keymodel uxCloseBlock)\n                toggleMagnify: () => this.magnifyNodeToggle(nodeid),\n                focusNode: () => this.focusNode(nodeid),\n                dragHandleRef: createRef(),\n                displayContainerRef: this.displayContainerRef,\n            });\n        }\n        const nodeModel = this.nodeModels.get(nodeid);\n        return nodeModel;\n    }\n\n    /**\n     * Remove orphaned node models when their corresponding leaf is deleted.\n     * @param leafOrder The new leaf order array to use when locating orphaned nodes.\n     */\n    private cleanupNodeModels(leafOrder: LeafOrderEntry[]) {\n        const orphanedNodeModels = [...this.nodeModels.keys()].filter(\n            (id) => !leafOrder.find((leafEntry) => leafEntry.nodeid == id)\n        );\n        for (const id of orphanedNodeModels) {\n            this.nodeModels.delete(id);\n        }\n    }\n\n    /**\n     * Switch focus to the next node in the given direction in the layout.\n     * @param direction The direction in which to switch focus.\n     */\n    switchNodeFocusInDirection(direction: NavigateDirection, inWaveAI: boolean): NavigationResult {\n        const curNodeId = this.focusedNodeId;\n\n        // If no node is focused, set focus to the first leaf.\n        if (!curNodeId) {\n            this.focusNode(this.getter(this.leafOrder)[0].nodeid);\n            return { success: true };\n        }\n\n        const offset = navigateDirectionToOffset(direction);\n        const nodePositions: Map<string, Dimensions> = new Map();\n        const leafs = this.getter(this.leafs);\n        const addlProps = this.getter(this.additionalProps);\n        for (const leaf of leafs) {\n            const pos = addlProps[leaf.id]?.rect;\n            if (pos) {\n                nodePositions.set(leaf.id, pos);\n            }\n        }\n        let curNodePos: Dimensions;\n        if (inWaveAI) {\n            // For WaveAI, use a fake position to the left of all nodes\n            curNodePos = { left: -10, top: 10, width: 0, height: 0 };\n\n            // Only allow \"right\" navigation from WaveAI\n            if (direction !== NavigateDirection.Right) {\n                const result: NavigationResult = { success: false };\n                if (direction === NavigateDirection.Up) {\n                    result.atTop = true;\n                } else if (direction === NavigateDirection.Down) {\n                    result.atBottom = true;\n                } else if (direction === NavigateDirection.Left) {\n                    result.atLeft = true;\n                }\n                return result;\n            }\n        } else {\n            curNodePos = nodePositions.get(curNodeId);\n            if (!curNodePos) {\n                return { success: false };\n            }\n            nodePositions.delete(curNodeId);\n        }\n        const boundingRect = this.displayContainerRef?.current.getBoundingClientRect();\n        if (!boundingRect) {\n            return { success: false };\n        }\n        const maxX = boundingRect.left + boundingRect.width;\n        const maxY = boundingRect.top + boundingRect.height;\n        const moveAmount = 10;\n        const curPoint = getCenter(curNodePos);\n\n        function findNodeAtPoint(m: Map<string, Dimensions>, p: Point): string {\n            for (const [blockId, dimension] of m.entries()) {\n                if (\n                    p.x >= dimension.left &&\n                    p.x <= dimension.left + dimension.width &&\n                    p.y >= dimension.top &&\n                    p.y <= dimension.top + dimension.height\n                ) {\n                    return blockId;\n                }\n            }\n            return null;\n        }\n\n        while (true) {\n            curPoint.x += offset.x * moveAmount;\n            curPoint.y += offset.y * moveAmount;\n            if (curPoint.x < 0 || curPoint.x > maxX || curPoint.y < 0 || curPoint.y > maxY) {\n                // Determine which boundary was hit\n                const result: NavigationResult = { success: false };\n                if (curPoint.x < 0) {\n                    result.atLeft = true;\n                }\n                if (curPoint.x > maxX) {\n                    result.atRight = true;\n                }\n                if (curPoint.y < 0) {\n                    result.atTop = true;\n                }\n                if (curPoint.y > maxY) {\n                    result.atBottom = true;\n                }\n                return result;\n            }\n            const nodeId = findNodeAtPoint(nodePositions, curPoint);\n            if (nodeId != null) {\n                this.focusNode(nodeId);\n                return { success: true };\n            }\n        }\n    }\n\n    /**\n     * Switch focus to a node using the given BlockNum\n     * @param newBlockNum The BlockNum of the node to which focus should switch.\n     * @see leafOrder - the indices in this array determine BlockNum\n     */\n    switchNodeFocusByBlockNum(newBlockNum: number) {\n        const leafOrder = this.getter(this.leafOrder);\n        const newLeafIdx = newBlockNum - 1;\n        if (newLeafIdx < 0 || newLeafIdx >= leafOrder.length) {\n            return;\n        }\n        const leaf = leafOrder[newLeafIdx];\n        this.focusNode(leaf.nodeid);\n    }\n\n    /**\n     * Set the layout to focus on the given node.\n     * @param nodeId The id of the node that is being focused.\n     */\n    focusNode(nodeId: string) {\n        if (this.focusedNodeId === nodeId) return;\n        let layoutNode = findNode(this.treeState?.rootNode, nodeId);\n        if (!layoutNode) {\n            const ephemeralNode = this.getter(this.ephemeralNode);\n            if (ephemeralNode?.id === nodeId) {\n                layoutNode = ephemeralNode;\n            } else {\n                console.error(\"unable to focus node, cannot find it in tree\", nodeId);\n                return;\n            }\n        }\n        const action: LayoutTreeFocusNodeAction = {\n            type: LayoutTreeActionType.FocusNode,\n            nodeId: nodeId,\n        };\n\n        this.treeReducer(action);\n    }\n\n    focusFirstNode() {\n        const leafOrder = this.getter(this.leafOrder);\n        if (leafOrder.length > 0) {\n            this.focusNode(leafOrder[0].nodeid);\n        }\n    }\n\n    getFirstBlockId(): string | undefined {\n        const leafOrder = this.getter(this.leafOrder);\n        if (leafOrder.length > 0) {\n            return leafOrder[0].blockid;\n        }\n        return undefined;\n    }\n\n    /**\n     * Toggle magnification of a given node.\n     * @param nodeId The id of the node that is being magnified.\n     */\n    magnifyNodeToggle(nodeId: string, setState = true) {\n        const action: LayoutTreeMagnifyNodeToggleAction = {\n            type: LayoutTreeActionType.MagnifyNodeToggle,\n            nodeId: nodeId,\n        };\n\n        // Unset the last ephemeral node id to ensure the magnify animation sits on top of the layout.\n        this.lastEphemeralNodeId = undefined;\n\n        this.treeReducer(action, setState);\n    }\n\n    /**\n     * Close a given node and update the tree state.\n     * @param nodeId The id of the node that is being closed.\n     */\n    async closeNode(nodeId: string) {\n        const nodeToDelete = findNode(this.treeState.rootNode, nodeId);\n        if (!nodeToDelete) {\n            // TODO: clean up the ephemeral node handling\n            // The ephemeral node is not in the tree, so we need to handle it separately.\n            const ephemeralNode = this.getter(this.ephemeralNode);\n            if (ephemeralNode?.id === nodeId) {\n                this.setter(this.ephemeralNode, undefined);\n                this.treeState.focusedNodeId = undefined;\n                this.updateTree(false);\n                this.setter(this.localTreeStateAtom, { ...this.treeState });\n                this.persistToBackend();\n                await this.onNodeDelete?.(ephemeralNode.data);\n                return;\n            }\n            console.error(\"unable to close node, cannot find it in tree\", nodeId);\n            return;\n        }\n        if (nodeId === this.magnifiedNodeId) {\n            this.magnifyNodeToggle(nodeId);\n        }\n        const deleteAction: LayoutTreeDeleteNodeAction = {\n            type: LayoutTreeActionType.DeleteNode,\n            nodeId: nodeId,\n        };\n        this.treeReducer(deleteAction);\n        await this.onNodeDelete?.(nodeToDelete.data);\n    }\n\n    /**\n     * Shorthand function for closing the focused node in a layout.\n     */\n    async closeFocusedNode() {\n        await this.closeNode(this.focusedNodeId);\n    }\n\n    newEphemeralNode(blockId: string) {\n        if (this.getter(this.ephemeralNode)) {\n            this.closeNode(this.getter(this.ephemeralNode).id);\n        }\n\n        const ephemeralNode = newLayoutNode(undefined, undefined, undefined, { blockId });\n        this.setter(this.ephemeralNode, ephemeralNode);\n\n        const addlProps = this.getter(this.additionalProps);\n        const leafs = this.getter(this.leafs);\n        const boundingRect = this.getBoundingRect();\n        const magnifiedNodeSizePct = this.getter(this.magnifiedNodeSizeAtom);\n        this.updateEphemeralNodeProps(ephemeralNode, addlProps, leafs, magnifiedNodeSizePct, boundingRect);\n        this.setter(this.additionalProps, addlProps);\n        this.focusNode(ephemeralNode.id);\n    }\n\n    addEphemeralNodeToLayout() {\n        const ephemeralNode = this.getter(this.ephemeralNode);\n        this.setter(this.ephemeralNode, undefined);\n        if (this.magnifiedNodeId) {\n            this.magnifyNodeToggle(this.magnifiedNodeId, false);\n        }\n        this.lastEphemeralNodeId = ephemeralNode.id;\n        if (ephemeralNode) {\n            const action: LayoutTreeInsertNodeAction = {\n                type: LayoutTreeActionType.InsertNode,\n                node: ephemeralNode,\n                magnified: false,\n                focused: false,\n            };\n            this.treeReducer(action);\n        }\n    }\n\n    updateEphemeralNodeProps(\n        node: LayoutNode,\n        addlPropsMap: Record<string, LayoutNodeAdditionalProps>,\n        leafs: LayoutNode[],\n        magnifiedNodeSizePct: number,\n        boundingRect: Dimensions\n    ) {\n        const ephemeralNodeSizePct = this.magnifiedNodeId\n            ? magnifiedNodeSizePct * magnifiedNodeSizePct\n            : magnifiedNodeSizePct;\n        const ephemeralNodeMarginPct = (1 - ephemeralNodeSizePct) / 2;\n        const transform = setTransform(\n            {\n                top: boundingRect.height * ephemeralNodeMarginPct,\n                left: boundingRect.width * ephemeralNodeMarginPct,\n                width: boundingRect.width * ephemeralNodeSizePct,\n                height: boundingRect.height * ephemeralNodeSizePct,\n            },\n            true,\n            true,\n            \"var(--zindex-layout-ephemeral-node)\"\n        );\n        addlPropsMap[node.id] = { treeKey: \"-1\", transform };\n        leafs.push(node);\n    }\n\n    /**\n     * Callback that is invoked when a drag operation completes and the pending action should be committed.\n     */\n    onDrop() {\n        if (this.getter(this.pendingTreeAction.currentValueAtom)) {\n            this.treeReducer({\n                type: LayoutTreeActionType.CommitPendingAction,\n            });\n        }\n    }\n\n    /**\n     * Callback that is invoked when the TileLayout container is being resized.\n     */\n    onContainerResize = () => {\n        this.updateTree();\n        this.setter(this.isContainerResizing, true);\n        this.stopContainerResizing();\n    };\n\n    /**\n     * Deferred action to restore animations once the TileLayout container is no longer being resized.\n     */\n    stopContainerResizing = debounce(30, () => {\n        this.setter(this.isContainerResizing, false);\n    });\n\n    /**\n     * Callback to update pending node sizes when a resize handle is dragged.\n     * @param resizeHandle The resize handle that is being dragged.\n     * @param x The X coordinate of the pointer device, in CSS pixels.\n     * @param y The Y coordinate of the pointer device, in CSS pixels.\n     */\n    onResizeMove(resizeHandle: ResizeHandleProps, x: number, y: number) {\n        const parentIsRow = resizeHandle.flexDirection === FlexDirection.Row;\n\n        // If the resize context is out of date, update it and save it for future events.\n        if (this.resizeContext?.handleId !== resizeHandle.id) {\n            const parentNode = findNode(this.treeState.rootNode, resizeHandle.parentNodeId);\n            const beforeNode = parentNode.children![resizeHandle.parentIndex];\n            const afterNode = parentNode.children![resizeHandle.parentIndex + 1];\n\n            const addlProps = this.getter(this.additionalProps);\n            const pixelToSizeRatio = addlProps[resizeHandle.parentNodeId]?.pixelToSizeRatio;\n            if (beforeNode && afterNode && pixelToSizeRatio) {\n                this.resizeContext = {\n                    handleId: resizeHandle.id,\n                    displayContainerRect: this.displayContainerRef.current?.getBoundingClientRect(),\n                    resizeHandleStartPx: resizeHandle.centerPx,\n                    beforeNodeId: beforeNode.id,\n                    afterNodeId: afterNode.id,\n                    beforeNodeStartSize: beforeNode.size,\n                    afterNodeStartSize: afterNode.size,\n                    pixelToSizeRatio,\n                };\n            } else {\n                console.error(\n                    \"Invalid resize handle, cannot get the additional properties for the nodes in the resize handle properties.\"\n                );\n                return;\n            }\n        }\n\n        const clientPoint = parentIsRow\n            ? x - this.resizeContext.displayContainerRect?.left\n            : y - this.resizeContext.displayContainerRect?.top;\n        const clientDiff = (this.resizeContext.resizeHandleStartPx - clientPoint) * this.resizeContext.pixelToSizeRatio;\n        const minNodeSize = MinNodeSizePx * this.resizeContext.pixelToSizeRatio;\n        const beforeNodeSize = this.resizeContext.beforeNodeStartSize - clientDiff;\n        const afterNodeSize = this.resizeContext.afterNodeStartSize + clientDiff;\n\n        // If either node will be too small after this resize, don't let it happen.\n        if (beforeNodeSize < minNodeSize || afterNodeSize < minNodeSize) {\n            return;\n        }\n\n        const resizeAction: LayoutTreeResizeNodeAction = {\n            type: LayoutTreeActionType.ResizeNode,\n            resizeOperations: [\n                {\n                    nodeId: this.resizeContext.beforeNodeId,\n                    size: beforeNodeSize,\n                },\n                {\n                    nodeId: this.resizeContext.afterNodeId,\n                    size: afterNodeSize,\n                },\n            ],\n        };\n        const setPendingAction: LayoutTreeSetPendingAction = {\n            type: LayoutTreeActionType.SetPendingAction,\n            action: resizeAction,\n        };\n\n        this.treeReducer(setPendingAction);\n        this.updateTree(false);\n    }\n\n    /**\n     * Callback to end the current resize operation and commit its pending action.\n     */\n    onResizeEnd() {\n        if (this.resizeContext) {\n            this.resizeContext = undefined;\n            this.treeReducer({ type: LayoutTreeActionType.CommitPendingAction });\n        }\n    }\n\n    /**\n     * Get the layout node matching the specified blockId.\n     * @param blockId The blockId that the returned node should contain.\n     * @returns The node containing the specified blockId, null if not found.\n     */\n    getNodeByBlockId(blockId: string): LayoutNode {\n        for (const leaf of this.getter(this.leafs)) {\n            if (leaf.data.blockId === blockId) {\n                return leaf;\n            }\n        }\n        return null;\n    }\n\n    /**\n     * Get a jotai atom containing the additional properties associated with a given node.\n     * @param nodeId The ID of the node for which to retrieve the additional properties.\n     * @returns An atom containing the additional properties associated with the given node.\n     */\n    getNodeAdditionalPropertiesAtom(nodeId: string): Atom<LayoutNodeAdditionalProps> {\n        return atom((get) => {\n            const addlProps = get(this.additionalProps);\n            if (nodeId in addlProps) return addlProps[nodeId];\n        });\n    }\n\n    /**\n     * Get additional properties associated with a given node.\n     * @param nodeId The ID of the node for which to retrieve the additional properties.\n     * @returns The additional properties associated with the given node.\n     */\n    getNodeAdditionalPropertiesById(nodeId: string): LayoutNodeAdditionalProps {\n        const addlProps = this.getter(this.additionalProps);\n        if (nodeId in addlProps) return addlProps[nodeId];\n    }\n\n    /**\n     * Get additional properties associated with a given node.\n     * @param node The node for which to retrieve the additional properties.\n     * @returns The additional properties associated with the given node.\n     */\n    getNodeAdditionalProperties(node: LayoutNode): LayoutNodeAdditionalProps {\n        return this.getNodeAdditionalPropertiesById(node.id);\n    }\n\n    /**\n     * Get the CSS transform associated with a given node.\n     * @param nodeId The ID of the node for which to retrieve the CSS transform.\n     * @returns The CSS transform associated with the given node.\n     */\n    getNodeTransformById(nodeId: string): CSSProperties {\n        return this.getNodeAdditionalPropertiesById(nodeId)?.transform;\n    }\n\n    /**\n     * Get the CSS transform associated with a given node.\n     * @param node The node for which to retrieve the CSS transform.\n     * @returns The CSS transform associated with the given node.\n     */\n    getNodeTransform(node: LayoutNode): CSSProperties {\n        return this.getNodeTransformById(node.id);\n    }\n\n    /**\n     * Get the computed dimensions in CSS pixels of a given node.\n     * @param nodeId The ID of the node for which to retrieve the computed dimensions.\n     * @returns The computed dimensions of the given node, in CSS pixels.\n     */\n    getNodeRectById(nodeId: string): Dimensions {\n        return this.getNodeAdditionalPropertiesById(nodeId)?.rect;\n    }\n\n    /**\n     * Get the computed dimensions in CSS pixels of a given node.\n     * @param node The node for which to retrieve the computed dimensions.\n     * @returns The computed dimensions of the given node, in CSS pixels.\n     */\n    getNodeRect(node: LayoutNode): Dimensions {\n        return this.getNodeRectById(node.id);\n    }\n}\n\nfunction getLeafOrder(\n    leafs: LayoutNode[],\n    additionalProps: Record<string, LayoutNodeAdditionalProps>\n): LeafOrderEntry[] {\n    return leafs\n        .map((node) => ({ nodeid: node.id, blockid: node.data.blockId }) as LeafOrderEntry)\n        .sort((a, b) => {\n            const treeKeyA = additionalProps[a.nodeid]?.treeKey;\n            const treeKeyB = additionalProps[b.nodeid]?.treeKey;\n            if (!treeKeyA || !treeKeyB) return;\n            return treeKeyA.localeCompare(treeKeyB);\n        });\n}\n"
  },
  {
    "path": "frontend/layout/lib/layoutModelHooks.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { useOnResize } from \"@/app/hook/useDimensions\";\nimport { atoms, globalStore, WOS } from \"@/app/store/global\";\nimport { fireAndForget } from \"@/util/util\";\nimport { Atom, useAtomValue } from \"jotai\";\nimport { CSSProperties, useCallback, useEffect, useState } from \"react\";\nimport { getLayoutStateAtomFromTab } from \"./layoutAtom\";\nimport { LayoutModel } from \"./layoutModel\";\nimport { LayoutNode, NodeModel, TileLayoutContents } from \"./types\";\n\nconst layoutModelMap: Map<string, LayoutModel> = new Map();\n\nfunction getLayoutModelForTab(tabAtom: Atom<Tab>): LayoutModel {\n    const tabData = globalStore.get(tabAtom);\n    if (!tabData) return;\n    const tabId = tabData.oid;\n    if (layoutModelMap.has(tabId)) {\n        const layoutModel = layoutModelMap.get(tabData.oid);\n        if (layoutModel) {\n            return layoutModel;\n        }\n    }\n    const layoutModel = new LayoutModel(tabAtom, globalStore.get, globalStore.set);\n    \n    const staticTabId = globalStore.get(atoms.staticTabId);\n    if (tabId === staticTabId) {\n        const layoutStateAtom = getLayoutStateAtomFromTab(tabAtom, globalStore.get);\n        globalStore.sub(layoutStateAtom, () => {\n            layoutModel.onBackendUpdate();\n        });\n    }\n    \n    layoutModelMap.set(tabId, layoutModel);\n    return layoutModel;\n}\n\nfunction getLayoutModelForTabById(tabId: string) {\n    const tabOref = WOS.makeORef(\"tab\", tabId);\n    const tabAtom = WOS.getWaveObjectAtom<Tab>(tabOref);\n    return getLayoutModelForTab(tabAtom);\n}\n\nexport function getLayoutModelForStaticTab() {\n    const tabId = globalStore.get(atoms.staticTabId);\n    return getLayoutModelForTabById(tabId);\n}\n\nexport function deleteLayoutModelForTab(tabId: string) {\n    if (layoutModelMap.has(tabId)) layoutModelMap.delete(tabId);\n}\n\nfunction useLayoutModel(tabAtom: Atom<Tab>): LayoutModel {\n    return getLayoutModelForTab(tabAtom);\n}\n\nexport function useTileLayout(tabAtom: Atom<Tab>, tileContent: TileLayoutContents): LayoutModel {\n    // Use tab data to ensure we can reload if the tab is disposed and remade (such as during Hot Module Reloading)\n    useAtomValue(tabAtom);\n    const layoutModel = useLayoutModel(tabAtom);\n\n    useOnResize(layoutModel?.displayContainerRef, layoutModel?.onContainerResize);\n\n    // Once the TileLayout is mounted, re-run the state update to get all the nodes to flow in the layout.\n    useEffect(() => fireAndForget(() => layoutModel.onTreeStateAtomUpdated(true)), []);\n\n    useEffect(() => layoutModel.registerTileLayout(tileContent), [tileContent]);\n    return layoutModel;\n}\n\nexport function useNodeModel(layoutModel: LayoutModel, layoutNode: LayoutNode): NodeModel {\n    return layoutModel.getNodeModel(layoutNode);\n}\n\nexport function useDebouncedNodeInnerRect(nodeModel: NodeModel): CSSProperties {\n    const nodeInnerRect = useAtomValue(nodeModel.innerRect);\n    const animationTimeS = useAtomValue(nodeModel.animationTimeS);\n    const isMagnified = useAtomValue(nodeModel.isMagnified);\n    const isResizing = useAtomValue(nodeModel.isResizing);\n    const prefersReducedMotion = useAtomValue(atoms.prefersReducedMotionAtom);\n    const [innerRect, setInnerRect] = useState<CSSProperties>();\n    const [innerRectDebounceTimeout, setInnerRectDebounceTimeout] = useState<NodeJS.Timeout>();\n\n    const setInnerRectDebounced = useCallback(\n        (nodeInnerRect: CSSProperties) => {\n            clearInnerRectDebounce();\n            setInnerRectDebounceTimeout(\n                setTimeout(() => {\n                    setInnerRect(nodeInnerRect);\n                }, animationTimeS * 1000)\n            );\n        },\n        [animationTimeS]\n    );\n    const clearInnerRectDebounce = useCallback(() => {\n        if (innerRectDebounceTimeout) {\n            clearTimeout(innerRectDebounceTimeout);\n            setInnerRectDebounceTimeout(undefined);\n        }\n    }, [innerRectDebounceTimeout]);\n\n    useEffect(() => {\n        if (prefersReducedMotion || isMagnified || isResizing) {\n            clearInnerRectDebounce();\n            setInnerRect(nodeInnerRect);\n        } else {\n            setInnerRectDebounced(nodeInnerRect);\n        }\n    }, [nodeInnerRect]);\n\n    return innerRect;\n}\n"
  },
  {
    "path": "frontend/layout/lib/layoutNode.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { DEFAULT_MAX_CHILDREN } from \"./layoutTree\";\nimport { DefaultNodeSize, FlexDirection, LayoutNode } from \"./types\";\nimport { reverseFlexDirection } from \"./utils\";\n\n/**\n * Creates a new node.\n * @param flexDirection The flex direction for the new node.\n * @param size The size for the new node.\n * @param children The children for the new node.\n * @param data The data for the new node.\n * @returns The new node.\n */\nexport function newLayoutNode(\n    flexDirection?: FlexDirection,\n    size?: number,\n    children?: LayoutNode[],\n    data?: TabLayoutData\n): LayoutNode {\n    const newNode: LayoutNode = {\n        id: crypto.randomUUID(),\n        flexDirection: flexDirection ?? FlexDirection.Row,\n        size: size ?? DefaultNodeSize,\n        children,\n        data,\n    };\n\n    if (!validateNode(newNode)) {\n        throw new Error(\"Invalid node\");\n    }\n    return newNode;\n}\n\n/**\n * Adds new nodes to the tree at the given index.\n * @param node The parent node.\n * @param idx The index to insert at.\n * @param children The nodes to insert.\n * @returns The updated parent node.\n */\nexport function addChildAt(node: LayoutNode, idx: number, ...children: LayoutNode[]) {\n    // console.log(\"adding\", children, \"to\", node, \"at index\", idx);\n    if (children.length === 0) return;\n\n    if (!node.children) {\n        addIntermediateNode(node);\n    }\n    const childrenToAdd = children.flatMap((v) => {\n        if (v.flexDirection !== node.flexDirection) {\n            return v;\n        } else if (v.children) {\n            return v.children;\n        } else {\n            v.flexDirection = reverseFlexDirection(node.flexDirection);\n            return v;\n        }\n    });\n\n    if (node.children.length <= idx) {\n        node.children.push(...childrenToAdd);\n    } else if (idx >= 0) {\n        node.children.splice(idx, 0, ...childrenToAdd);\n    }\n}\n\n/**\n * Adds an intermediate node as a direct child of the given node, moving the given node's children or data into it.\n *\n * If the node contains children, they are moved two levels deeper to preserve their flex direction. If the node only has data, it is moved one level deeper.\n * @param node The node to add the intermediate node to.\n * @returns The updated node and the node that was added.\n */\nexport function addIntermediateNode(node: LayoutNode): LayoutNode {\n    let intermediateNode: LayoutNode;\n\n    if (node.data) {\n        intermediateNode = newLayoutNode(reverseFlexDirection(node.flexDirection), undefined, undefined, node.data);\n        node.children = [intermediateNode];\n        node.data = undefined;\n    } else {\n        const intermediateNodeInner = newLayoutNode(node.flexDirection, undefined, node.children);\n        intermediateNode = newLayoutNode(reverseFlexDirection(node.flexDirection), undefined, [intermediateNodeInner]);\n        node.children = [intermediateNode];\n    }\n    const intermediateNodeId = intermediateNode.id;\n    intermediateNode.id = node.id;\n    node.id = intermediateNodeId;\n    return intermediateNode;\n}\n\n/**\n * Attempts to remove the specified node from its parent.\n * @param parent The parent node.\n * @param childToRemove The node to remove.\n * @param startingIndex The index in children to start the search from.\n * @returns The updated parent node, or undefined if the node was not found.\n */\nexport function removeChild(parent: LayoutNode, childToRemove: LayoutNode, startingIndex: number = 0) {\n    if (!parent.children) return;\n    const idx = parent.children.indexOf(childToRemove, startingIndex);\n    if (idx === -1) return;\n    parent.children?.splice(idx, 1);\n}\n\n/**\n * Finds the node with the given id.\n * @param node The node to search in.\n * @param id The id to search for.\n * @returns The node with the given id or undefined if no node with the given id was found.\n */\nexport function findNode(node: LayoutNode, id: string): LayoutNode | undefined {\n    if (!node) return;\n    if (node.id === id) return node;\n    if (!node.children) return;\n    for (const child of node.children) {\n        const result = findNode(child, id);\n        if (result) return result;\n    }\n    return;\n}\n\n/**\n * Finds the node whose children contains the node with the given id.\n * @param node The node to start the search from.\n * @param id The id to search for.\n * @returns The parent node, or undefined if no node with the given id was found.\n */\nexport function findParent(node: LayoutNode, id: string): LayoutNode | undefined {\n    if (node.id === id || !node.children) return;\n    for (const child of node.children) {\n        if (child.id === id) return node;\n        const retVal = findParent(child, id);\n        if (retVal) return retVal;\n    }\n    return;\n}\n\n/**\n * Determines whether a node is valid.\n * @param node The node to validate.\n * @returns True if the node is valid, false otherwise.\n */\nexport function validateNode(node: LayoutNode): boolean {\n    if (!node.children == !node.data) {\n        console.error(\"Either children or data must be defined for node, not both\");\n        return false;\n    }\n\n    if (node.children?.length === 0) {\n        console.error(\"Node cannot define an empty array of children\");\n        return false;\n    }\n    return true;\n}\n\n/**\n * Recursively walk the layout tree starting at the specified node. Run the specified callbacks, if any.\n * @param node The node from which to start the walk.\n * @param beforeWalkCallback An optional callback to run before walking a node's children.\n * @param afterWalkCallback An optional callback to run after walking a node's children.\n */\nexport function walkNodes(\n    node: LayoutNode,\n    beforeWalkCallback?: (node: LayoutNode) => void,\n    afterWalkCallback?: (node: LayoutNode) => void\n) {\n    if (!node) return;\n    beforeWalkCallback?.(node);\n    node.children?.forEach((child) => walkNodes(child, beforeWalkCallback, afterWalkCallback));\n    afterWalkCallback?.(node);\n}\n\n/**\n * Recursively corrects the tree to minimize nested single-child nodes, remove invalid nodes, and correct invalid flex direction order.\n * @param node The node to start the balancing from.\n * @param beforeWalkCallback Any optional callback to run before walking a node's children.\n * @param afterWalkCallback An optional callback to run after walking a node's children.\n * @returns The corrected node.\n */\nexport function balanceNode(\n    node: LayoutNode,\n    beforeWalkCallback?: (node: LayoutNode) => void,\n    afterWalkCallback?: (node: LayoutNode) => void\n): LayoutNode {\n    walkNodes(\n        node,\n        (node) => {\n            if (!validateNode(node)) throw new Error(\"Invalid node\");\n            node.children = node.children?.flatMap((child) => {\n                if (child.flexDirection === node.flexDirection) {\n                    child.flexDirection = reverseFlexDirection(node.flexDirection);\n                }\n                if (child.children?.length == 1 && child.children[0].children) {\n                    return child.children[0].children;\n                }\n                if (child.children?.length === 0) return;\n                return child;\n            });\n            beforeWalkCallback?.(node);\n        },\n        (node) => {\n            node.children = node.children?.filter((v) => v);\n            if (node.children?.length === 1 && !node.children[0].children) {\n                node.data = node.children[0].data;\n                node.id = node.children[0].id;\n                node.children = undefined;\n            }\n            afterWalkCallback?.(node);\n        }\n    );\n    return node;\n}\n\n/**\n * Finds the first node in the tree where a new node can be inserted.\n *\n * This will attempt to fill each node until it has maxChildren children. If a node is full, it will move to its children and\n * fill each of them until it has maxChildren children. It will ensure that each child fills evenly before moving to the next\n * layer down.\n *\n * @param node The node to start the search from.\n * @param maxChildren The maximum number of children a node can have.\n * @returns The node to insert into and the index at which to insert.\n */\nexport function findNextInsertLocation(\n    node: LayoutNode,\n    maxChildren = DEFAULT_MAX_CHILDREN\n): { node: LayoutNode; index: number } {\n    const insertLoc = findNextInsertLocationHelper(node, maxChildren, 1);\n    return { node: insertLoc?.node, index: insertLoc?.index };\n}\n\n/**\n * Traverse the layout tree using the supplied index array to find the node to insert at.\n * @param node The node to start the search from.\n * @param indexArr The array of indices to aid in the traversal.\n * @returns The node to insert into and the index at which to insert.\n */\nexport function findInsertLocationFromIndexArr(\n    node: LayoutNode,\n    indexArr: number[]\n): { node: LayoutNode; index: number } {\n    function normalizeIndex(index: number) {\n        const childrenLength = node.children?.length ?? 1;\n        const lastChildIndex = childrenLength - 1;\n        if (index < 0) {\n            return childrenLength - Math.max(index, -childrenLength);\n        }\n        return Math.min(index, lastChildIndex);\n    }\n    if (indexArr.length == 0) {\n        return;\n    }\n    const nextIndex = normalizeIndex(indexArr.shift());\n    if (indexArr.length == 0 || !node.children) {\n        return { node, index: nextIndex };\n    }\n    return findInsertLocationFromIndexArr(node.children[nextIndex], indexArr);\n}\n\nfunction findNextInsertLocationHelper(\n    node: LayoutNode,\n    maxChildren: number,\n    curDepth: number = 1\n): { node: LayoutNode; index: number; depth: number } {\n    if (!node) return;\n    if (!node.children) return { node, index: 1, depth: curDepth };\n    let insertLocs: { node: LayoutNode; index: number; depth: number }[] = [];\n    if (node.children.length < maxChildren) {\n        insertLocs.push({ node, index: node.children.length, depth: curDepth });\n    }\n    for (const child of node.children.slice().reverse()) {\n        insertLocs.push(findNextInsertLocationHelper(child, maxChildren, curDepth + 1));\n    }\n    insertLocs = insertLocs\n        .filter((a) => a)\n        .sort((a, b) => Math.pow(a.depth, a.index + maxChildren) - Math.pow(b.depth, b.index + maxChildren));\n    return insertLocs[0];\n}\n\nexport function totalChildrenSize(node: LayoutNode): number {\n    return node.children?.reduce((partialSum, child) => partialSum + child.size, 0);\n}\n"
  },
  {
    "path": "frontend/layout/lib/layoutTree.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { lazy } from \"@/util/util\";\nimport {\n    addChildAt,\n    addIntermediateNode,\n    findInsertLocationFromIndexArr,\n    findNextInsertLocation,\n    findNode,\n    findParent,\n    removeChild,\n} from \"./layoutNode\";\nimport {\n    DefaultNodeSize,\n    DropDirection,\n    FlexDirection,\n    LayoutTreeActionType,\n    LayoutTreeComputeMoveNodeAction,\n    LayoutTreeDeleteNodeAction,\n    LayoutTreeFocusNodeAction,\n    LayoutTreeInsertNodeAction,\n    LayoutTreeInsertNodeAtIndexAction,\n    LayoutTreeMagnifyNodeToggleAction,\n    LayoutTreeMoveNodeAction,\n    LayoutTreeResizeNodeAction,\n    LayoutTreeState,\n    LayoutTreeSwapNodeAction,\n    MoveOperation,\n} from \"./types\";\n\nimport { newLayoutNode } from \"./layoutNode\";\nimport { LayoutTreeReplaceNodeAction, LayoutTreeSplitHorizontalAction, LayoutTreeSplitVerticalAction } from \"./types\";\n\nexport const DEFAULT_MAX_CHILDREN = 5;\n\n/**\n * Computes an operation for inserting a new node into the tree in the given direction relative to the specified node.\n *\n * @param layoutState The state of the tree.\n * @param computeInsertAction The operation to compute.\n */\nexport function computeMoveNode(layoutState: LayoutTreeState, computeInsertAction: LayoutTreeComputeMoveNodeAction) {\n    const rootNode = layoutState.rootNode;\n    const { nodeId, nodeToMoveId, direction } = computeInsertAction;\n    if (!nodeId || !nodeToMoveId) {\n        console.warn(\"either nodeId or nodeToMoveId not set\", nodeId, nodeToMoveId);\n        return;\n    }\n    if (direction === undefined) {\n        console.warn(\"No direction provided for insertItemInDirection\");\n        return;\n    }\n\n    if (nodeId === nodeToMoveId) {\n        console.warn(\"Cannot compute move node action since both nodes are equal\");\n        return;\n    }\n\n    let newMoveOperation: MoveOperation;\n    const parent = lazy(() => findParent(rootNode, nodeId));\n    const grandparent = lazy(() => findParent(rootNode, parent().id));\n    const indexInParent = lazy(() => parent()?.children.findIndex((child) => nodeId === child.id));\n    const indexInGrandparent = lazy(() => grandparent()?.children.findIndex((child) => parent().id === child.id));\n    const nodeToMoveParent = lazy(() => findParent(rootNode, nodeToMoveId));\n    const nodeToMoveIndexInParent = lazy(() =>\n        nodeToMoveParent()?.children.findIndex((child) => nodeToMoveId === child.id)\n    );\n    const isRoot = rootNode.id === nodeId;\n\n    // TODO: this should not be necessary. The drag layer is having trouble tracking changes to the LayoutNode fields, so I need to grab the node again here to get the latest data.\n    const node = findNode(rootNode, nodeId);\n    const nodeToMove = findNode(rootNode, nodeToMoveId);\n\n    if (!node || !nodeToMove) {\n        console.warn(\"node or nodeToMove not set\", nodeId, nodeToMoveId);\n        return;\n    }\n\n    switch (direction) {\n        case DropDirection.OuterTop:\n            if (node.flexDirection === FlexDirection.Column) {\n                const grandparentNode = grandparent();\n                if (grandparentNode) {\n                    const index = indexInGrandparent();\n                    newMoveOperation = {\n                        parentId: grandparentNode.id,\n                        node: nodeToMove,\n                        index,\n                    };\n                    break;\n                }\n            }\n        // falls through\n        case DropDirection.Top:\n            if (node.flexDirection === FlexDirection.Column) {\n                newMoveOperation = { parentId: nodeId, index: 0, node: nodeToMove };\n            } else {\n                if (isRoot)\n                    newMoveOperation = {\n                        node: nodeToMove,\n                        index: 0,\n                        insertAtRoot: true,\n                    };\n\n                const parentNode = parent();\n                if (parentNode)\n                    newMoveOperation = {\n                        parentId: parentNode.id,\n                        index: indexInParent() ?? 0,\n                        node: nodeToMove,\n                    };\n            }\n            break;\n        case DropDirection.OuterBottom:\n            if (node.flexDirection === FlexDirection.Column) {\n                const grandparentNode = grandparent();\n                if (grandparentNode) {\n                    const index = indexInGrandparent() + 1;\n                    newMoveOperation = {\n                        parentId: grandparentNode.id,\n                        node: nodeToMove,\n                        index,\n                    };\n                    break;\n                }\n            }\n        // falls through\n        case DropDirection.Bottom:\n            if (node.flexDirection === FlexDirection.Column) {\n                newMoveOperation = { parentId: nodeId, index: 1, node: nodeToMove };\n            } else {\n                if (isRoot)\n                    newMoveOperation = {\n                        node: nodeToMove,\n                        index: 1,\n                        insertAtRoot: true,\n                    };\n\n                const parentNode = parent();\n                if (parentNode)\n                    newMoveOperation = {\n                        parentId: parentNode.id,\n                        index: indexInParent() + 1,\n                        node: nodeToMove,\n                    };\n            }\n            break;\n        case DropDirection.OuterLeft:\n            if (node.flexDirection === FlexDirection.Row) {\n                const grandparentNode = grandparent();\n                if (grandparentNode) {\n                    const index = indexInGrandparent();\n                    newMoveOperation = {\n                        parentId: grandparentNode.id,\n                        node: nodeToMove,\n                        index,\n                    };\n                    break;\n                }\n            }\n        // falls through\n        case DropDirection.Left:\n            if (node.flexDirection === FlexDirection.Row) {\n                newMoveOperation = { parentId: nodeId, index: 0, node: nodeToMove };\n            } else {\n                const parentNode = parent();\n                if (parentNode)\n                    newMoveOperation = {\n                        parentId: parentNode.id,\n                        index: indexInParent(),\n                        node: nodeToMove,\n                    };\n            }\n            break;\n        case DropDirection.OuterRight:\n            if (node.flexDirection === FlexDirection.Row) {\n                const grandparentNode = grandparent();\n                if (grandparentNode) {\n                    const index = indexInGrandparent() + 1;\n                    newMoveOperation = {\n                        parentId: grandparentNode.id,\n                        node: nodeToMove,\n                        index,\n                    };\n                    break;\n                }\n            }\n        // falls through\n        case DropDirection.Right:\n            if (node.flexDirection === FlexDirection.Row) {\n                newMoveOperation = { parentId: nodeId, index: 1, node: nodeToMove };\n            } else {\n                const parentNode = parent();\n                if (parentNode)\n                    newMoveOperation = {\n                        parentId: parentNode.id,\n                        index: indexInParent() + 1,\n                        node: nodeToMove,\n                    };\n            }\n            break;\n        case DropDirection.Center:\n            if (nodeId !== rootNode.id && nodeToMoveId !== rootNode.id) {\n                const swapAction: LayoutTreeSwapNodeAction = {\n                    type: LayoutTreeActionType.Swap,\n                    node1Id: nodeId,\n                    node2Id: nodeToMoveId,\n                };\n                return swapAction;\n            } else {\n                console.warn(\"cannot swap\");\n            }\n            break;\n        default:\n            throw new Error(`Invalid direction: ${direction}`);\n    }\n\n    if (\n        newMoveOperation?.parentId !== nodeToMoveParent()?.id ||\n        (newMoveOperation.index !== nodeToMoveIndexInParent() &&\n            newMoveOperation.index !== nodeToMoveIndexInParent() + 1)\n    )\n        return {\n            type: LayoutTreeActionType.Move,\n            ...newMoveOperation,\n        } as LayoutTreeMoveNodeAction;\n}\n\nexport function moveNode(layoutState: LayoutTreeState, action: LayoutTreeMoveNodeAction) {\n    console.log(\"moveNode\", layoutState, action);\n    const rootNode = layoutState.rootNode;\n    if (!action) {\n        console.error(\"no move node action provided\");\n        return;\n    }\n    if (action.parentId && action.insertAtRoot) {\n        console.error(\"parent and insertAtRoot cannot both be defined in a move node action\");\n        return;\n    }\n\n    const node = findNode(rootNode, action.node.id) ?? action.node;\n    const parent = findNode(rootNode, action.parentId);\n    const oldParent = findParent(rootNode, action.node.id);\n\n    let startingIndex = 0;\n\n    // If moving under the same parent, we need to make sure that we are removing the child from its old position, not its new one.\n    // If the new index is before the old index, we need to start our search for the node to delete after the new index position.\n    // If a node is being moved under the same parent, it can keep its size. Otherwise, it should get reset.\n    if (oldParent && parent) {\n        if (oldParent.id === parent.id) {\n            const curIndexInParent = parent.children!.indexOf(node);\n            if (curIndexInParent >= action.index) {\n                startingIndex = action.index + 1;\n            }\n        } else {\n            node.size = DefaultNodeSize;\n        }\n    }\n\n    if (!parent && action.insertAtRoot) {\n        if (!rootNode.children) {\n            addIntermediateNode(rootNode);\n        }\n        addChildAt(rootNode, action.index, node);\n    } else if (parent) {\n        addChildAt(parent, action.index, node);\n    } else {\n        throw new Error(\"Invalid InsertOperation\");\n    }\n\n    // Remove nodeToInsert from its old parent\n    if (oldParent) {\n        removeChild(oldParent, node, startingIndex);\n    }\n}\n\nexport function insertNode(layoutState: LayoutTreeState, action: LayoutTreeInsertNodeAction) {\n    if (!action?.node) {\n        console.error(\"insertNode cannot run, no insert node action provided\");\n        return;\n    }\n    if (!layoutState.rootNode) {\n        layoutState.rootNode = action.node;\n    } else {\n        const insertLoc = findNextInsertLocation(layoutState.rootNode, DEFAULT_MAX_CHILDREN);\n        addChildAt(insertLoc.node, insertLoc.index, action.node);\n        if (action.magnified) {\n            layoutState.magnifiedNodeId = action.node.id;\n            layoutState.focusedNodeId = action.node.id;\n        }\n    }\n    if (action.focused) {\n        layoutState.focusedNodeId = action.node.id;\n    }\n}\n\nexport function insertNodeAtIndex(layoutState: LayoutTreeState, action: LayoutTreeInsertNodeAtIndexAction) {\n    if (!action?.node || !action?.indexArr) {\n        console.error(\"insertNodeAtIndex cannot run, either node or indexArr field is missing\");\n        return;\n    }\n    if (!layoutState.rootNode) {\n        layoutState.rootNode = action.node;\n    } else {\n        const insertLoc = findInsertLocationFromIndexArr(layoutState.rootNode, action.indexArr);\n        if (!insertLoc) {\n            console.error(\"insertNodeAtIndex unable to find insert location\");\n            return;\n        }\n        addChildAt(insertLoc.node, insertLoc.index + 1, action.node);\n        if (action.magnified) {\n            layoutState.magnifiedNodeId = action.node.id;\n            layoutState.focusedNodeId = action.node.id;\n        }\n    }\n    if (action.focused) {\n        layoutState.focusedNodeId = action.node.id;\n    }\n}\n\nexport function swapNode(layoutState: LayoutTreeState, action: LayoutTreeSwapNodeAction) {\n    if (!action.node1Id || !action.node2Id) {\n        console.error(\"invalid swapNode action, both node1 and node2 must be defined\");\n        return;\n    }\n\n    if (action.node1Id === layoutState.rootNode.id || action.node2Id === layoutState.rootNode.id) {\n        console.error(\"invalid swapNode action, the root node cannot be swapped\");\n        return;\n    }\n    if (action.node1Id === action.node2Id) {\n        console.error(\"invalid swapNode action, node1 and node2 are equal\");\n        return;\n    }\n\n    const parentNode1 = findParent(layoutState.rootNode, action.node1Id);\n    const parentNode2 = findParent(layoutState.rootNode, action.node2Id);\n    const parentNode1Index = parentNode1.children!.findIndex((child) => child.id === action.node1Id);\n    const parentNode2Index = parentNode2.children!.findIndex((child) => child.id === action.node2Id);\n\n    const node1 = parentNode1.children![parentNode1Index];\n    const node2 = parentNode2.children![parentNode2Index];\n\n    const node1Size = node1.size;\n    node1.size = node2.size;\n    node2.size = node1Size;\n\n    parentNode1.children[parentNode1Index] = node2;\n    parentNode2.children[parentNode2Index] = node1;\n}\n\nexport function deleteNode(layoutState: LayoutTreeState, action: LayoutTreeDeleteNodeAction) {\n    if (!action?.nodeId) {\n        console.error(\"no delete node action provided\");\n        return;\n    }\n    if (!layoutState.rootNode) {\n        console.error(\"no root node\");\n        return;\n    }\n    if (layoutState.rootNode.id === action.nodeId) {\n        layoutState.rootNode = undefined;\n    } else {\n        const parent = findParent(layoutState.rootNode, action.nodeId);\n        if (parent) {\n            const node = parent.children.find((child) => child.id === action.nodeId);\n            removeChild(parent, node);\n            if (layoutState.focusedNodeId === node.id) {\n                layoutState.focusedNodeId = undefined;\n            }\n        } else {\n            console.error(\"unable to delete node, not found in tree\");\n        }\n    }\n}\n\nexport function resizeNode(layoutState: LayoutTreeState, action: LayoutTreeResizeNodeAction) {\n    if (!action.resizeOperations) {\n        console.error(\"invalid resizeNode operation. nodeSizes array must be defined.\");\n    }\n    for (const resize of action.resizeOperations) {\n        if (!resize.nodeId || resize.size < 0 || resize.size > 100) {\n            console.error(\"invalid resizeNode operation. nodeId must be defined and size must be between 0 and 100\");\n            return;\n        }\n        const node = findNode(layoutState.rootNode, resize.nodeId);\n        node.size = resize.size;\n    }\n}\n\nexport function focusNode(layoutState: LayoutTreeState, action: LayoutTreeFocusNodeAction) {\n    if (!action.nodeId) {\n        console.error(\"invalid focusNode operation, nodeId must be defined.\");\n        return;\n    }\n\n    layoutState.focusedNodeId = action.nodeId;\n}\n\nexport function magnifyNodeToggle(layoutState: LayoutTreeState, action: LayoutTreeMagnifyNodeToggleAction) {\n    if (!action.nodeId) {\n        console.error(\"invalid magnifyNodeToggle operation. nodeId must be defined.\");\n        return;\n    }\n    if (layoutState.rootNode.id === action.nodeId) {\n        console.warn(`cannot toggle magnification of node ${action.nodeId} because it is the root node.`);\n        return;\n    }\n    if (layoutState.magnifiedNodeId === action.nodeId) {\n        layoutState.magnifiedNodeId = undefined;\n    } else {\n        layoutState.magnifiedNodeId = action.nodeId;\n        layoutState.focusedNodeId = action.nodeId;\n    }\n}\n\nexport function clearTree(layoutState: LayoutTreeState) {\n    layoutState.rootNode = undefined;\n    layoutState.leafOrder = undefined;\n    layoutState.focusedNodeId = undefined;\n    layoutState.magnifiedNodeId = undefined;\n}\n\nexport function replaceNode(layoutState: LayoutTreeState, action: LayoutTreeReplaceNodeAction) {\n    const { targetNodeId, newNode } = action;\n    if (layoutState.rootNode.id === targetNodeId) {\n        newNode.size = layoutState.rootNode.size; // preserve size\n        layoutState.rootNode = newNode;\n    } else {\n        const parent = findParent(layoutState.rootNode, targetNodeId);\n        if (!parent) {\n            console.error(\"replaceNode: Parent not found for\", targetNodeId);\n            return;\n        }\n        const index = parent.children.findIndex((child) => child.id === targetNodeId);\n        if (index === -1) {\n            console.error(\"replaceNode: Target node not found in parent's children\", targetNodeId);\n            return;\n        }\n        // Preserve the old node's size.\n        const targetNode = parent.children[index];\n        newNode.size = targetNode.size;\n        parent.children[index] = newNode;\n    }\n    if (action.focused) {\n        layoutState.focusedNodeId = newNode.id;\n    }\n}\n\n// ─── SPLIT HORIZONTAL ─────────────────────────────────────────────────────────────\n\nexport function splitHorizontal(layoutState: LayoutTreeState, action: LayoutTreeSplitHorizontalAction) {\n    const { targetNodeId, newNode, position } = action;\n    const targetNode = findNode(layoutState.rootNode, targetNodeId);\n    if (!targetNode) {\n        console.error(\"splitHorizontal: Target node not found\", targetNodeId);\n        return;\n    }\n\n    const parent = findParent(layoutState.rootNode, targetNodeId);\n    if (parent && parent.flexDirection === FlexDirection.Row) {\n        const index = parent.children.findIndex((child) => child.id === targetNodeId);\n        if (index === -1) {\n            console.error(\"splitHorizontal: Target node not found in parent's children\", targetNodeId);\n            return;\n        }\n        const insertIndex = position === \"before\" ? index : index + 1;\n        // Directly splice in the new node instead of calling addChildAt (which may flatten nodes)\n        parent.children.splice(insertIndex, 0, newNode);\n    } else {\n        // Otherwise, if no parent or parent's flexDirection is not Row, we need to wrap\n        // Create a new group node with horizontal layout.\n        // IMPORTANT: pass an initial children array so the new node is valid.\n        const groupNode = newLayoutNode(FlexDirection.Row, targetNode.size, [targetNode], undefined);\n        // Now decide the ordering based on the \"position\"\n        groupNode.children = position === \"before\" ? [newNode, targetNode] : [targetNode, newNode];\n        if (parent) {\n            const index = parent.children.findIndex((child) => child.id === targetNodeId);\n            if (index === -1) {\n                console.error(\"splitHorizontal (wrap): Target node not found in parent's children\", targetNodeId);\n                return;\n            }\n            parent.children[index] = groupNode;\n        } else {\n            layoutState.rootNode = groupNode;\n        }\n    }\n    if (action.focused) {\n        layoutState.focusedNodeId = newNode.id;\n    }\n}\n\n// ─── SPLIT VERTICAL ─────────────────────────────────────────────────────────────\n\nexport function splitVertical(layoutState: LayoutTreeState, action: LayoutTreeSplitVerticalAction) {\n    const { targetNodeId, newNode, position } = action;\n    const targetNode = findNode(layoutState.rootNode, targetNodeId);\n    if (!targetNode) {\n        console.error(\"splitVertical: Target node not found\", targetNodeId);\n        return;\n    }\n\n    const parent = findParent(layoutState.rootNode, targetNodeId);\n    if (parent && parent.flexDirection === FlexDirection.Column) {\n        const index = parent.children.findIndex((child) => child.id === targetNodeId);\n        if (index === -1) {\n            console.error(\"splitVertical: Target node not found in parent's children\", targetNodeId);\n            return;\n        }\n        const insertIndex = position === \"before\" ? index : index + 1;\n        // For vertical splits in an already vertical parent, splice directly.\n        parent.children.splice(insertIndex, 0, newNode);\n    } else {\n        // Wrap target node in a new vertical group.\n        // Create group node with an initial children array so that validation passes.\n        const groupNode = newLayoutNode(FlexDirection.Column, targetNode.size, [targetNode], undefined);\n        groupNode.children = position === \"before\" ? [newNode, targetNode] : [targetNode, newNode];\n        if (parent) {\n            const index = parent.children.findIndex((child) => child.id === targetNodeId);\n            if (index === -1) {\n                console.error(\"splitVertical (wrap): Target node not found in parent's children\", targetNodeId);\n                return;\n            }\n            parent.children[index] = groupNode;\n        } else {\n            layoutState.rootNode = groupNode;\n        }\n    }\n    if (action.focused) {\n        layoutState.focusedNodeId = newNode.id;\n    }\n}\n"
  },
  {
    "path": "frontend/layout/lib/nodeRefMap.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nexport class NodeRefMap {\n    private map: Map<string, React.RefObject<HTMLDivElement>> = new Map();\n    generation: number = 0;\n\n    set(id: string, ref: React.RefObject<HTMLDivElement>) {\n        this.map.set(id, ref);\n        this.generation++;\n    }\n\n    delete(id: string) {\n        if (this.map.has(id)) {\n            this.map.delete(id);\n            this.generation++;\n        }\n    }\n\n    get(id: string): React.RefObject<HTMLDivElement> {\n        if (this.map.has(id)) {\n            return this.map.get(id);\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/layout/lib/tilelayout.scss",
    "content": "// Copyright 2024, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n.tile-layout {\n    position: relative;\n    height: 100%;\n    width: 100%;\n    overflow: hidden;\n\n    --gap-size-px: 5px;\n\n    .overlay-container,\n    .display-container,\n    .placeholder-container {\n        position: absolute;\n        display: flex;\n        top: 0;\n        left: 0;\n        height: 100%;\n        width: 100%;\n        min-height: 4rem;\n        min-width: 4rem;\n    }\n\n    .display-container {\n        z-index: var(--zindex-layout-display-container);\n    }\n\n    .placeholder-container {\n        z-index: var(--zindex-layout-placeholder-container);\n    }\n\n    .overlay-container {\n        z-index: var(--zindex-layout-overlay-container);\n    }\n\n    .overlay-node {\n        display: flex;\n        flex: 0 1 auto;\n    }\n\n    .resize-handle {\n        z-index: var(--zindex-layout-resize-handle);\n\n        .line {\n            visibility: hidden;\n        }\n        &.flex-row {\n            cursor: ew-resize;\n            .line {\n                height: 100%;\n                width: calc(50% + 1px);\n                border-right: 2px solid var(--accent-color);\n            }\n        }\n        &.flex-column {\n            cursor: ns-resize;\n            .line {\n                height: calc(50% + 1px);\n                border-bottom: 2px solid var(--accent-color);\n            }\n        }\n        &:hover .line {\n            visibility: visible;\n\n            // Ignore the prefers-reduced-motion override, since we are not applying a true animation here, just a delay.\n            transition-property: visibility !important;\n            transition-delay: var(--animation-time-s) !important;\n        }\n    }\n\n    .tile-node {\n        border-radius: calc(var(--block-border-radius) + 2px);\n        overflow: hidden;\n        width: 100%;\n        height: 100%;\n\n        &.dragging {\n            filter: blur(8px);\n        }\n\n        &.resizing {\n            border: 1px solid var(--accent-color);\n            backdrop-filter: blur(8px);\n        }\n\n        .tile-leaf {\n            overflow: hidden;\n        }\n\n        .tile-preview-container {\n            position: absolute;\n            top: 10000px;\n            white-space: nowrap !important;\n            user-select: none;\n            -webkit-user-select: none;\n\n            .tile-preview {\n                width: 100%;\n                height: 100%;\n            }\n        }\n\n        &:not(:only-child) .tile-leaf {\n            padding: calc(var(--gap-size-px) / 2);\n        }\n    }\n\n    --block-blur: 2px;\n\n    .magnified-node-backdrop,\n    .ephemeral-node-backdrop {\n        position: absolute;\n        top: 0;\n        left: 0;\n        width: 100%;\n        height: 100%;\n        backdrop-filter: blur(var(--block-blur));\n    }\n\n    .magnified-node-backdrop {\n        z-index: var(--zindex-layout-magnified-node-backdrop);\n    }\n\n    .ephemeral-node-backdrop {\n        z-index: var(--zindex-layout-ephemeral-node-backdrop);\n    }\n\n    &.animate {\n        .tile-node,\n        .placeholder {\n            transition-duration: var(--animation-time-s);\n            transition-timing-function: linear;\n            transition-property: transform, width, height, background-color;\n        }\n    }\n\n    .tile-leaf,\n    .overlay-leaf {\n        height: 100%;\n        width: 100%;\n    }\n\n    .placeholder {\n        background-color: var(--accent-color);\n        opacity: 0.5;\n        border-radius: calc(var(--block-border-radius) + 2px);\n    }\n}\n"
  },
  {
    "path": "frontend/layout/lib/types.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Atom, WritableAtom } from \"jotai\";\nimport { CSSProperties } from \"react\";\n\nexport enum NavigateDirection {\n    Up = 0,\n    Right = 1,\n    Down = 2,\n    Left = 3,\n}\n\nexport function navigateDirectionToString(dir: NavigateDirection): string {\n    switch (dir) {\n        case NavigateDirection.Up:\n            return \"up\";\n        case NavigateDirection.Right:\n            return \"right\";\n        case NavigateDirection.Down:\n            return \"down\";\n        case NavigateDirection.Left:\n            return \"left\";\n        default:\n            return \"unknown\";\n    }\n}\n\nexport enum DropDirection {\n    Top = 0,\n    Right = 1,\n    Bottom = 2,\n    Left = 3,\n    OuterTop = 4,\n    OuterRight = 5,\n    OuterBottom = 6,\n    OuterLeft = 7,\n    Center = 8,\n}\n\nexport enum FlexDirection {\n    Row = \"row\",\n    Column = \"column\",\n}\n\n/**\n * Represents an operation to insert a node into a tree.\n */\nexport type MoveOperation = {\n    /**\n     * The index at which the node will be inserted in the parent.\n     */\n    index: number;\n\n    /**\n     * The parent node. Undefined if inserting at root.\n     */\n    parentId?: string;\n\n    /**\n     * Whether the node will be inserted at the root of the tree.\n     */\n    insertAtRoot?: boolean;\n\n    /**\n     * The node to insert.\n     */\n    node: LayoutNode;\n};\n\n/**\n * Types of actions that modify the layout tree.\n */\nexport enum LayoutTreeActionType {\n    ComputeMove = \"computemove\",\n    Move = \"move\",\n    Swap = \"swap\",\n    SetPendingAction = \"setpending\",\n    CommitPendingAction = \"commitpending\",\n    ClearPendingAction = \"clearpending\",\n    ResizeNode = \"resize\",\n    InsertNode = \"insert\",\n    InsertNodeAtIndex = \"insertatindex\",\n    DeleteNode = \"delete\",\n    FocusNode = \"focus\",\n    MagnifyNodeToggle = \"magnify\",\n    ClearTree = \"clear\",\n    ReplaceNode = \"replace\",\n    SplitHorizontal = \"splithorizontal\",\n    SplitVertical = \"splitvertical\",\n}\n\n/**\n * Base class for actions that modify the layout tree.\n */\nexport interface LayoutTreeAction {\n    type: LayoutTreeActionType;\n}\n\n/**\n * Action for computing a move operation and saving it as a pending action in the tree state.\n *\n * @see MoveOperation\n * @see LayoutTreeMoveNodeAction\n */\nexport interface LayoutTreeComputeMoveNodeAction extends LayoutTreeAction {\n    type: LayoutTreeActionType.ComputeMove;\n    nodeId: string;\n    nodeToMoveId: string;\n    direction: DropDirection;\n}\n\n/**\n * Action for moving a node within the layout tree.\n *\n * @see MoveOperation\n */\nexport interface LayoutTreeMoveNodeAction extends LayoutTreeAction, MoveOperation {\n    type: LayoutTreeActionType.Move;\n}\n\n/**\n * Action for swapping two nodes within the layout tree.\n *\n */\nexport interface LayoutTreeSwapNodeAction extends LayoutTreeAction {\n    type: LayoutTreeActionType.Swap;\n\n    /**\n     * The node that node2 will replace.\n     */\n    node1Id: string;\n    /**\n     * The node that node1 will replace.\n     */\n    node2Id: string;\n}\n\ninterface InsertNodeOperation {\n    /**\n     * The node to insert.\n     */\n    node: LayoutNode;\n    /**\n     * Whether the inserted node should be magnified.\n     */\n    magnified: boolean;\n    /**\n     * Whether the inserted node should be focused.\n     */\n    focused: boolean;\n}\n\n/**\n * Action for inserting a new node to the layout tree.\n *\n */\nexport interface LayoutTreeInsertNodeAction extends LayoutTreeAction, InsertNodeOperation {\n    type: LayoutTreeActionType.InsertNode;\n}\n\n/**\n * Action for inserting a node into the layout tree at the specified index.\n */\nexport interface LayoutTreeInsertNodeAtIndexAction extends LayoutTreeAction, InsertNodeOperation {\n    type: LayoutTreeActionType.InsertNodeAtIndex;\n    /**\n     * The array of indices to traverse when inserting the node.\n     * The last index is the index within the parent node where the node should be inserted.\n     */\n    indexArr: number[];\n}\n\n/**\n * Action for deleting a node from the layout tree.\n */\nexport interface LayoutTreeDeleteNodeAction extends LayoutTreeAction {\n    type: LayoutTreeActionType.DeleteNode;\n    nodeId: string;\n}\n\n/**\n * Action for setting the pendingAction field of the layout tree state.\n */\nexport interface LayoutTreeSetPendingAction extends LayoutTreeAction {\n    type: LayoutTreeActionType.SetPendingAction;\n\n    /**\n     * The new value for the pending action field.\n     */\n    action: LayoutTreeAction;\n}\n\n/**\n * Action for committing the action in the pendingAction field of the layout tree state.\n */\nexport interface LayoutTreeCommitPendingAction extends LayoutTreeAction {\n    type: LayoutTreeActionType.CommitPendingAction;\n}\n\n/**\n * Action for clearing the pendingAction field from the layout tree state.\n */\nexport interface LayoutTreeClearPendingAction extends LayoutTreeAction {\n    type: LayoutTreeActionType.ClearPendingAction;\n}\n\n// ReplaceNode: replace an existing node in place with a new one.\nexport interface LayoutTreeReplaceNodeAction extends LayoutTreeAction {\n    type: LayoutTreeActionType.ReplaceNode;\n    targetNodeId: string;\n    newNode: LayoutNode;\n    focused?: boolean;\n}\n\n// SplitHorizontal: split the current block horizontally.\n// The \"position\" field indicates whether the new node should be inserted before (to the left)\n// or after (to the right) of the target node.\nexport interface LayoutTreeSplitHorizontalAction extends LayoutTreeAction {\n    type: LayoutTreeActionType.SplitHorizontal;\n    targetNodeId: string;\n    newNode: LayoutNode;\n    position: \"before\" | \"after\";\n    focused?: boolean;\n}\n\n// SplitVertical: similar to split horizontal but along the vertical axis.\nexport interface LayoutTreeSplitVerticalAction extends LayoutTreeAction {\n    type: LayoutTreeActionType.SplitVertical;\n    targetNodeId: string;\n    newNode: LayoutNode;\n    position: \"before\" | \"after\";\n    focused?: boolean;\n}\n\n/**\n * An operation to resize a node.\n */\nexport interface ResizeNodeOperation {\n    /**\n     * The id of the node to resize.\n     */\n    nodeId: string;\n    /**\n     * The new size for the node.\n     */\n    size: number;\n}\n\n/**\n * Action for resizing a node from the layout tree.\n */\nexport interface LayoutTreeResizeNodeAction extends LayoutTreeAction {\n    type: LayoutTreeActionType.ResizeNode;\n\n    /**\n     * A list of node ids to update and their respective new sizes.\n     */\n    resizeOperations: ResizeNodeOperation[];\n}\n\n/**\n * Action for focusing a node from the layout tree.\n */\nexport interface LayoutTreeFocusNodeAction extends LayoutTreeAction {\n    type: LayoutTreeActionType.FocusNode;\n\n    /**\n     * The id of the node to focus;\n     */\n    nodeId: string;\n}\n\n/**\n * Action for toggling magnification of a node from the layout tree.\n */\nexport interface LayoutTreeMagnifyNodeToggleAction extends LayoutTreeAction {\n    type: LayoutTreeActionType.MagnifyNodeToggle;\n\n    /**\n     * The id of the node to maximize;\n     */\n    nodeId: string;\n}\n\n/**\n * Action for clearing all nodes from the layout tree.\n */\nexport interface LayoutTreeClearTreeAction extends LayoutTreeAction {\n    type: LayoutTreeActionType.ClearTree;\n}\n\n/**\n * Represents a single node in the layout tree.\n */\nexport interface LayoutNode {\n    id: string;\n    data?: TabLayoutData;\n    children?: LayoutNode[];\n    flexDirection: FlexDirection;\n    size: number;\n}\n\nexport type LayoutTreeStateSetter = (value: LayoutState) => void;\n\nexport type LayoutTreeState = {\n    rootNode: LayoutNode;\n    focusedNodeId?: string;\n    magnifiedNodeId?: string;\n    /**\n     * A computed ordered list of leafs in the layout. This value is driven by the LayoutModel and should not be read when updated from the backend.\n     */\n    leafOrder?: LeafOrderEntry[];\n    pendingBackendActions: LayoutActionData[];\n};\n\nexport type WritableLayoutTreeStateAtom = WritableAtom<LayoutTreeState, [value: LayoutTreeState], void>;\n\nexport type ContentRenderer = (nodeModel: NodeModel) => React.ReactNode;\n\nexport type PreviewRenderer = (nodeModel: NodeModel) => React.ReactElement;\n\nexport const DefaultNodeSize = 10;\n\n/**\n * contains callbacks and information about the contents (or styling) of of the TileLayout\n * nothing in here is specific to the TileLayout itself\n */\nexport interface TileLayoutContents {\n    /**\n     * The tabId with which this TileLayout is associated.\n     */\n    tabId?: string;\n\n    /**\n     * The class name to use for the top-level div of the tile layout.\n     */\n    className?: string;\n\n    /**\n     * The gap between tiles in a layout, in CSS pixels.\n     */\n    gapSizePx?: number;\n\n    /**\n     * A callback that accepts the data from the leaf node and displays the leaf contents to the user.\n     */\n    renderContent: ContentRenderer;\n    /**\n     * A callback that accepts the data from the leaf node and returns a preview that can be shown when the user drags a node.\n     */\n    renderPreview?: PreviewRenderer;\n    /**\n     * A callback that is called when a node gets deleted from the LayoutTreeState.\n     * @param data The contents of the node that was deleted.\n     */\n    onNodeDelete?: (data: TabLayoutData) => Promise<void>;\n    /**\n     * A callback for getting the cursor point in reference to the current window. This removes Electron as a runtime dependency, allowing for better integration with Storybook.\n     * @returns The cursor position relative to the current window.\n     */\n    getCursorPoint?: () => Point;\n}\n\nexport interface ResizeHandleProps {\n    id: string;\n    parentNodeId: string;\n    parentIndex: number;\n    centerPx: number;\n    transform: CSSProperties;\n    flexDirection: FlexDirection;\n}\n\nexport interface LayoutNodeAdditionalProps {\n    treeKey: string;\n    transform?: CSSProperties;\n    rect?: Dimensions;\n    pixelToSizeRatio?: number;\n    resizeHandles?: ResizeHandleProps[];\n}\n\nexport interface NodeModel {\n    additionalProps: Atom<LayoutNodeAdditionalProps>;\n    innerRect: Atom<CSSProperties>;\n    blockNum: Atom<number>;\n    numLeafs: Atom<number>;\n    nodeId: string;\n    blockId: string;\n    addEphemeralNodeToLayout: () => void;\n    animationTimeS: Atom<number>;\n    isResizing: Atom<boolean>;\n    isFocused: Atom<boolean>;\n    isMagnified: Atom<boolean>;\n    anyMagnified: Atom<boolean>;\n    isEphemeral: Atom<boolean>;\n    ready: Atom<boolean>;\n    disablePointerEvents: Atom<boolean>;\n    toggleMagnify: () => void;\n    focusNode: () => void;\n    onClose: () => void;\n    dragHandleRef?: React.RefObject<HTMLDivElement>;\n    displayContainerRef: React.RefObject<HTMLDivElement>;\n}\n\n/**\n * Result object returned by switchNodeFocusInDirection method.\n */\nexport interface NavigationResult {\n    success: boolean;\n    atLeft?: boolean;\n    atTop?: boolean;\n    atBottom?: boolean;\n    atRight?: boolean;\n}\n"
  },
  {
    "path": "frontend/layout/lib/utils.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { CSSProperties } from \"react\";\nimport { XYCoord } from \"react-dnd\";\nimport { DropDirection, FlexDirection, NavigateDirection } from \"./types\";\n\nexport function reverseFlexDirection(flexDirection: FlexDirection): FlexDirection {\n    return flexDirection === FlexDirection.Row ? FlexDirection.Column : FlexDirection.Row;\n}\n\nexport function determineDropDirection(dimensions?: Dimensions, offset?: XYCoord | null): DropDirection | undefined {\n    // console.log(\"determineDropDirection\", dimensions, offset);\n    if (!offset || !dimensions) return undefined;\n    const { width, height, left, top } = dimensions;\n    let { x, y } = offset;\n    x -= left;\n    y -= top;\n\n    // Lies outside of the box\n    if (y < 0 || y > height || x < 0 || x > width) return undefined;\n\n    // Determines if a drop point falls within the center fifth of the box, meaning we should return Center.\n    const centerX1 = (2 * width) / 5;\n    const centerX2 = (3 * width) / 5;\n    const centerY1 = (2 * height) / 5;\n    const centerY2 = (3 * height) / 5;\n\n    if (x > centerX1 && x < centerX2 && y > centerY1 && y < centerY2) return DropDirection.Center;\n\n    const diagonal1 = y * width - x * height;\n    const diagonal2 = y * width + x * height - height * width;\n\n    // Lies on diagonal\n    if (diagonal1 == 0 || diagonal2 == 0) return undefined;\n\n    let code = 0;\n\n    if (diagonal2 > 0) {\n        code += 1;\n    }\n\n    if (diagonal1 > 0) {\n        code += 2;\n        code = 5 - code;\n    }\n\n    // Determines whether a drop is close to an edge of the box, meaning drop direction should be OuterX, instead of X\n    const xOuter1 = width / 5;\n    const xOuter2 = width - width / 5;\n    const yOuter1 = height / 5;\n    const yOuter2 = height - height / 5;\n\n    if (y < yOuter1 || y > yOuter2 || x < xOuter1 || x > xOuter2) {\n        code += 4;\n    }\n\n    return code;\n}\n\nexport function setTransform(\n    { top, left, width, height }: Dimensions,\n    setSize = true,\n    roundVals = true,\n    zIndex?: number | string\n): CSSProperties {\n    // Replace unitless items with px\n    const topRounded = roundVals ? Math.floor(top) : top;\n    const leftRounded = roundVals ? Math.floor(left) : left;\n    const widthRounded = roundVals ? Math.ceil(width) : width;\n    const heightRounded = roundVals ? Math.ceil(height) : height;\n    const translate = `translate3d(${leftRounded}px,${topRounded}px, 0)`;\n    return {\n        top: 0,\n        left: 0,\n        transform: translate,\n        width: setSize ? `${widthRounded}px` : undefined,\n        height: setSize ? `${heightRounded}px` : undefined,\n        position: \"absolute\",\n        zIndex: zIndex,\n    };\n}\n\nexport function getCenter(dimensions: Dimensions): Point {\n    return {\n        x: dimensions.left + dimensions.width / 2,\n        y: dimensions.top + dimensions.height / 2,\n    };\n}\n\nexport function navigateDirectionToOffset(direction: NavigateDirection): Point {\n    switch (direction) {\n        case NavigateDirection.Up:\n            return { x: 0, y: -1 };\n        case NavigateDirection.Down:\n            return { x: 0, y: 1 };\n        case NavigateDirection.Left:\n            return { x: -1, y: 0 };\n        case NavigateDirection.Right:\n            return { x: 1, y: 0 };\n    }\n}\n"
  },
  {
    "path": "frontend/layout/tests/layoutNode.test.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { assert, test } from \"vitest\";\nimport { addChildAt, addIntermediateNode, balanceNode, findNextInsertLocation, newLayoutNode } from \"../lib/layoutNode\";\nimport { FlexDirection, LayoutNode } from \"../lib/types\";\n\ntest(\"newLayoutNode\", () => {\n    assert.throws(\n        () => newLayoutNode(FlexDirection.Column),\n        \"Invalid node\",\n        undefined,\n        \"calls to the constructor without data or children should fail\"\n    );\n    assert.throws(\n        () => newLayoutNode(FlexDirection.Column, undefined, [], { blockId: \"hello\" }),\n        \"Invalid node\",\n        undefined,\n        \"calls to the constructor with both data and children should fail\"\n    );\n    assert.doesNotThrow(\n        () => newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: \"hello\" }),\n        \"Invalid node\",\n        undefined,\n        \"calls to the constructor with only data defined should succeed\"\n    );\n    assert.throws(\n        () => newLayoutNode(FlexDirection.Column, undefined, [], undefined),\n        \"Invalid node\",\n        undefined,\n        \"calls to the constructor with empty children array should fail\"\n    );\n    assert.doesNotThrow(\n        () =>\n            newLayoutNode(\n                FlexDirection.Column,\n                undefined,\n                [newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: \"hello\" })],\n                undefined\n            ),\n        \"Invalid node\",\n        undefined,\n        \"calls to the constructor with children array containing at least one child should succeed\"\n    );\n});\n\ntest(\"addIntermediateNode\", () => {\n    let node1: LayoutNode = newLayoutNode(FlexDirection.Column, undefined, [\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"hello\" }),\n    ]);\n    assert(node1.children![0].data!.blockId === \"hello\", \"node1 should have one child which should have data\");\n    const intermediateNode1 = addIntermediateNode(node1);\n    assert(\n        node1.children !== undefined && node1.children.length === 1 && node1.children?.includes(intermediateNode1),\n        \"node1 should have a single child intermediateNode1\"\n    );\n    assert(intermediateNode1.flexDirection === FlexDirection.Row, \"intermediateNode1 should have flexDirection Row\");\n    assert(\n        intermediateNode1.children![0].children![0].data!.blockId === \"hello\" &&\n            intermediateNode1.children![0].children![0].flexDirection === FlexDirection.Row,\n        \"intermediateNode1 should have a nested child which should have data and flexDirection Row\"\n    );\n    let node2: LayoutNode = newLayoutNode(FlexDirection.Column, undefined, undefined, {\n        blockId: \"hello\",\n    });\n    const intermediateNode2 = addIntermediateNode(node2);\n    assert(\n        node2.children !== undefined &&\n            node2.data === undefined &&\n            node2.children.length === 1 &&\n            node2.children.includes(intermediateNode2),\n        \"node2 should have no data and a single child intermediateNode2\"\n    );\n    assert(\n        intermediateNode2.data.blockId === \"hello\" && intermediateNode2.children === undefined,\n        \"intermediateNode2 should have no children and should have data matching the old value of node2\"\n    );\n});\n\ntest(\"addChildAt - same flexDirection, no children\", () => {\n    let node1 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" });\n    let node2 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node2\" });\n    addChildAt(node1, 1, node2);\n    assert(node1.data === undefined, \"node1 should have no data\");\n    assert(node1.children!.length === 2, \"node1 should have two children\");\n    assert(node1.children![0].data!.blockId === \"node1\", \"node1's first child should have node1's data\");\n    assert(node1.children![1].id === node2.id, \"node1's second child should be node2\");\n    assert(node1.children![1].flexDirection === FlexDirection.Column, \"node2 should now have flexDirection Column\");\n});\n\ntest(\"addChildAt - different flexDirection, no children\", () => {\n    let node1 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" });\n    let node2 = newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: \"node2\" });\n    addChildAt(node1, 1, node2);\n    assert(node1.data === undefined, \"node1 should have no data\");\n    assert(node1.children!.length === 2, \"node1 should have two children\");\n    assert(node1.children![0].data!.blockId === \"node1\", \"node1's first child should have node1's data\");\n    assert(node1.children![0].data!.blockId === \"node1\", \"node1's first child should have flexDirection Column\");\n    assert(node1.children![1].id === node2.id, \"node1's second child should be node2\");\n    assert(node1.children![1].flexDirection === FlexDirection.Column, \"node2 should have flexDirection Row\");\n});\n\ntest(\"addChildAt - same flexDirection, first node has children, second doesn't\", () => {\n    let node1 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: \"node1\" }),\n    ]);\n    let node2 = newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: \"node2\" });\n    addChildAt(node1, 1, node2);\n    assert(node1.data === undefined, \"node1 should have no data\");\n    assert(node1.children!.length === 2, \"node1 should have two children\");\n    assert(node1.children![0].data!.blockId === \"node1\", \"node1's first child should have node1's data\");\n    assert(\n        node1.children![0].flexDirection === FlexDirection.Column,\n        \"node1's first child should have flexDirection Column\"\n    );\n    assert(node1.children![1].id === node2.id, \"node1's second child should be node2\");\n    assert(node1.children![1].flexDirection === FlexDirection.Column, \"node2 should have flexDirection Column\");\n});\n\ntest(\"addChildAt - different flexDirection, first node has children, second doesn't\", () => {\n    let node1 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: \"node1\" }),\n    ]);\n    let node2 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node2\" });\n    addChildAt(node1, 1, node2);\n    assert(node1.data === undefined, \"node1 should have no data\");\n    assert(node1.children!.length === 2, \"node1 should have two children\");\n    assert(node1.children![0].data!.blockId === \"node1\", \"node1's first child should have node1's data\");\n    assert(node1.children![1].id === node2.id, \"node1's second child should be node2\");\n    assert(node1.children![1].flexDirection === FlexDirection.Column, \"node2 should now have flexDirection Column\");\n});\n\ntest(\"addChildAt - same flexDirection, first node has children, second has children\", () => {\n    let node1 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: \"node1\" }),\n    ]);\n    let node2 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: \"node2\" }),\n    ]);\n    addChildAt(node1, 1, node2);\n    assert(node1.data === undefined, \"node1 should have no data\");\n    assert(node1.children!.length === 2, \"node1 should have two children\");\n    assert(node1.children![0].data!.blockId === \"node1\", \"node1's first child should have node1's data\");\n    assert(\n        node1.children![0].flexDirection === FlexDirection.Column,\n        \"node1's first child should have flexDirection Column\"\n    );\n    assert(node1.children![1].id === node2.children![0].id, \"node1's second child should be node2's child\");\n    assert(\n        node1.children![1].flexDirection === FlexDirection.Column,\n        \"node1's second child should have flexDirection Column\"\n    );\n});\n\ntest(\"addChildAt - different flexDirection, first node has children, second has children\", () => {\n    let node1 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: \"node1\" }),\n    ]);\n    let node2 = newLayoutNode(FlexDirection.Column, undefined, [\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node2\" }),\n    ]);\n    addChildAt(node1, 1, node2);\n    assert(node1.data === undefined, \"node1 should have no data\");\n    assert(node1.children!.length === 2, \"node1 should have two children\");\n    assert(node1.children![0].data!.blockId === \"node1\", \"node1's first child should have node1's data\");\n    assert(\n        node1.children![0].flexDirection === FlexDirection.Column,\n        \"node1's first child should have flexDirection Column\"\n    );\n    assert(node1.children![1].id === node2.id, \"node1's second child should be node2\");\n    assert(\n        node1.children![1].flexDirection === FlexDirection.Column,\n        \"node1's second child should have flexDirection Column\"\n    );\n});\n\ntest(\"balanceNode - corrects flex directions\", () => {\n    let node1 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1Inner1\" }),\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1Inner2\" }),\n    ]);\n    const newNode1 = balanceNode(node1);\n    assert(newNode1 !== undefined, \"newNode1 should not be undefined\");\n    node1 = newNode1;\n    assert(node1.data === undefined, \"node1 should have no data\");\n    assert(node1.children![0].flexDirection !== node1.flexDirection);\n});\n\ntest(\"balanceNode - collapses nodes with single grandchild 1\", () => {\n    let node1 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Column, undefined, [\n            newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n        ]),\n    ]);\n    const newNode1 = balanceNode(node1);\n    assert(newNode1 !== undefined, \"newNode1 should not be undefined\");\n    node1 = newNode1;\n    assert(node1.children === undefined, \"node1 should have no children\");\n    assert(node1.data!.blockId === \"node1\", \"node1 should have data 'node1'\");\n});\n\ntest(\"balanceNode - collapses nodes with single grandchild 2\", () => {\n    let node2 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Column, undefined, [\n            newLayoutNode(FlexDirection.Row, undefined, [\n                newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: \"node2Inner1\" }),\n                newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: \"node2Inner2\" }),\n            ]),\n        ]),\n    ]);\n    const newNode2 = balanceNode(node2);\n    assert(newNode2 !== undefined, \"newNode2 should not be undefined\");\n    node2 = newNode2;\n    assert(node2.children!.length === 2, \"node2 should have two children\");\n    assert(node2.children[0].data!.blockId === \"node2Inner1\", \"node2's first child should have data 'node2Inner1'\");\n    // assert(leafs.length === 2, \"leafs should have two leafs\");\n    // assert(leafs[0].data!.blockId === \"node2Inner1\", \"leafs[0] should have data 'node2Inner1'\");\n    // assert(leafs[1].data!.blockId === \"node2Inner2\", \"leafs[1] should have data 'node2Inner2'\");\n});\n\ntest(\"balanceNode - collapses nodes with single grandchild 3\", () => {\n    let node3 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Column, undefined, [\n            newLayoutNode(FlexDirection.Row, undefined, [\n                newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: \"node3\" }),\n            ]),\n        ]),\n    ]);\n    const newNode3 = balanceNode(node3);\n    assert(newNode3 !== undefined, \"newNode3 should not be undefined\");\n    node3 = newNode3;\n    assert(node3.children === undefined, \"node3 should have no children\");\n    assert(node3.data!.blockId === \"node3\", \"node3 should have data 'node3'\");\n});\n\ntest(\"balanceNode - collapses nodes with single grandchild 4\", () => {\n    let node4 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Column, undefined, [\n            newLayoutNode(FlexDirection.Row, undefined, [\n                newLayoutNode(FlexDirection.Column, undefined, [\n                    newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node4Inner1\" }),\n                    newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node4Inner2\" }),\n                ]),\n            ]),\n        ]),\n    ]);\n    const newNode4 = balanceNode(node4);\n    assert(newNode4 !== undefined, \"newNode4 should not be undefined\");\n    node4 = newNode4;\n    assert(node4.children!.length === 1, \"node4 should have one child\");\n    assert(node4.children![0].children!.length === 2, \"node4 should have two grandchildren\");\n    assert(\n        node4.children[0].children![0].data!.blockId === \"node4Inner1\",\n        \"node4's first child should have data 'node4Inner1'\"\n    );\n});\n\ntest(\"findNextInsertLocation\", () => {\n    const node1 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n    ]);\n\n    const insertLoc1 = findNextInsertLocation(node1, 5);\n    assert(insertLoc1.node.id === node1.id, \"should insert into node1\");\n    assert(insertLoc1.index === 4, \"should insert into index 4 of node1\");\n\n    const node2Inner5 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node2Inner5\" });\n    const node2 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n        node2Inner5,\n    ]);\n\n    const insertLoc2 = findNextInsertLocation(node2, 5);\n    assert(insertLoc2.node.id === node2Inner5.id, \"should insert into node2Inner5\");\n    assert(insertLoc2.index === 1, \"should insert into index 1 of node2Inner1\");\n\n    const node3Inner5 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n    ]);\n    const node3Inner4 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node3Inner4\" });\n    const node3 = newLayoutNode(FlexDirection.Row, undefined, [\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n        newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: \"node1\" }),\n        node3Inner4,\n        node3Inner5,\n    ]);\n\n    const insertLoc3 = findNextInsertLocation(node3, 5);\n    assert(insertLoc3.node.id === node3Inner4.id, \"should insert into node3Inner4\");\n    assert(insertLoc3.index === 1, \"should insert into index 1 of node3Inner4\");\n});\n"
  },
  {
    "path": "frontend/layout/tests/layoutTree.test.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { assert, test } from \"vitest\";\nimport { newLayoutNode } from \"../lib/layoutNode\";\nimport { computeMoveNode, moveNode } from \"../lib/layoutTree\";\nimport {\n    DropDirection,\n    LayoutTreeActionType,\n    LayoutTreeComputeMoveNodeAction,\n    LayoutTreeMoveNodeAction,\n} from \"../lib/types\";\nimport { newLayoutTreeState } from \"./model\";\n\ntest(\"layoutTreeStateReducer - compute move\", () => {\n    const nodeA = newLayoutNode(undefined, undefined, undefined, { blockId: \"nodeA\" });\n    const node1 = newLayoutNode(undefined, undefined, undefined, { blockId: \"node1\" });\n    const node2 = newLayoutNode(undefined, undefined, undefined, { blockId: \"node2\" });\n    const treeState = newLayoutTreeState(newLayoutNode(undefined, undefined, [nodeA, node1, node2]));\n    assert(treeState.rootNode.children!.length === 3, \"root should have three children\");\n    let pendingAction = computeMoveNode(treeState, {\n        type: LayoutTreeActionType.ComputeMove,\n        nodeId: treeState.rootNode.id,\n        nodeToMoveId: node1.id,\n        direction: DropDirection.Bottom,\n    });\n    const insertOperation = pendingAction as LayoutTreeMoveNodeAction;\n    assert(insertOperation.node === node1, \"insert operation node should equal node1\");\n    assert(!insertOperation.parentId, \"insert operation parent should not be defined\");\n    assert(insertOperation.index === 1, \"insert operation index should equal 1\");\n    assert(insertOperation.insertAtRoot, \"insert operation insertAtRoot should be true\");\n    moveNode(treeState, insertOperation);\n    assert(\n        treeState.rootNode.data === undefined && treeState.rootNode.children!.length === 3,\n        \"root node should still have three children\"\n    );\n    assert(treeState.rootNode.children![1].data!.blockId === \"node1\", \"root's second child should be node1\");\n\n    pendingAction = computeMoveNode(treeState, {\n        type: LayoutTreeActionType.ComputeMove,\n        nodeId: node1.id,\n        nodeToMoveId: node2.id,\n        direction: DropDirection.Bottom,\n    });\n    const insertOperation2 = pendingAction as LayoutTreeMoveNodeAction;\n    assert(insertOperation2.node === node2, \"insert operation node should equal node2\");\n    assert(insertOperation2.parentId === node1.id, \"insert operation parent id should be node1 id\");\n    assert(insertOperation2.index === 1, \"insert operation index should equal 1\");\n    assert(!insertOperation2.insertAtRoot, \"insert operation insertAtRoot should be false\");\n    moveNode(treeState, insertOperation2);\n    assert(\n        treeState.rootNode.data === undefined && (treeState.rootNode.children!.length as number) === 2,\n        \"root node should now have two children after node2 moved into node1\"\n    );\n    assert(treeState.rootNode.children![1].children!.length === 2, \"root's second child should now have two children\");\n});\n\ntest(\"computeMove - noop action\", () => {\n    const nodeToMove = newLayoutNode(undefined, undefined, undefined, { blockId: \"nodeToMove\" });\n    const treeState = newLayoutTreeState(\n        newLayoutNode(undefined, undefined, [\n            nodeToMove,\n            newLayoutNode(undefined, undefined, undefined, { blockId: \"otherNode\" }),\n        ])\n    );\n    let moveAction: LayoutTreeComputeMoveNodeAction = {\n        type: LayoutTreeActionType.ComputeMove,\n        nodeId: treeState.rootNode.id,\n        nodeToMoveId: nodeToMove.id,\n        direction: DropDirection.Left,\n    };\n    let pendingAction = computeMoveNode(treeState, moveAction);\n\n    assert(pendingAction === undefined, \"inserting a node to the left of itself should not produce a pendingAction\");\n\n    moveAction = {\n        type: LayoutTreeActionType.ComputeMove,\n        nodeId: treeState.rootNode.id,\n        nodeToMoveId: nodeToMove.id,\n        direction: DropDirection.Right,\n    };\n\n    pendingAction = computeMoveNode(treeState, moveAction);\n    assert(pendingAction === undefined, \"inserting a node to the right of itself should not produce a pendingAction\");\n});\n"
  },
  {
    "path": "frontend/layout/tests/model.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { LayoutNode, LayoutTreeState } from \"../lib/types\";\n\nexport function newLayoutTreeState(rootNode: LayoutNode): LayoutTreeState {\n    return {\n        rootNode,\n        pendingBackendActions: [],\n    };\n}\n"
  },
  {
    "path": "frontend/layout/tests/utils.test.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { assert, test } from \"vitest\";\nimport { DropDirection, FlexDirection } from \"../lib/types\";\nimport { determineDropDirection, reverseFlexDirection } from \"../lib/utils\";\n\ntest(\"determineDropDirection\", () => {\n    const dimensions: Dimensions = {\n        top: 0,\n        left: 0,\n        height: 5,\n        width: 5,\n    };\n\n    assert.equal(\n        determineDropDirection(dimensions, {\n            x: 2.5,\n            y: 1.5,\n        }),\n        DropDirection.Top\n    );\n\n    assert.equal(\n        determineDropDirection(dimensions, {\n            x: 2.5,\n            y: 3.5,\n        }),\n        DropDirection.Bottom\n    );\n\n    assert.equal(\n        determineDropDirection(dimensions, {\n            x: 3.5,\n            y: 2.5,\n        }),\n        DropDirection.Right\n    );\n\n    assert.equal(\n        determineDropDirection(dimensions, {\n            x: 1.5,\n            y: 2.5,\n        }),\n        DropDirection.Left\n    );\n\n    assert.equal(\n        determineDropDirection(dimensions, {\n            x: 2.5,\n            y: 0.5,\n        }),\n        DropDirection.OuterTop\n    );\n\n    assert.equal(\n        determineDropDirection(dimensions, {\n            x: 4.5,\n            y: 2.5,\n        }),\n        DropDirection.OuterRight\n    );\n\n    assert.equal(\n        determineDropDirection(dimensions, {\n            x: 2.5,\n            y: 4.5,\n        }),\n        DropDirection.OuterBottom\n    );\n\n    assert.equal(\n        determineDropDirection(dimensions, {\n            x: 0.5,\n            y: 2.5,\n        }),\n        DropDirection.OuterLeft\n    );\n\n    assert.equal(\n        determineDropDirection(dimensions, {\n            x: 2.5,\n            y: 2.5,\n        }),\n        DropDirection.Center\n    );\n\n    assert.equal(\n        determineDropDirection(dimensions, {\n            x: 2.51,\n            y: 2.51,\n        }),\n        DropDirection.Center\n    );\n\n    assert.equal(\n        determineDropDirection(dimensions, {\n            x: 1.5,\n            y: 1.5,\n        }),\n        undefined\n    );\n});\n\ntest(\"reverseFlexDirection\", () => {\n    assert.equal(reverseFlexDirection(FlexDirection.Row), FlexDirection.Column);\n    assert.equal(reverseFlexDirection(FlexDirection.Column), FlexDirection.Row);\n});\n"
  },
  {
    "path": "frontend/preview/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n        <meta name=\"color-scheme\" content=\"dark\" />\n        <title>Wave Preview Server</title>\n        <link rel=\"icon\" type=\"image/png\" href=\"/logos/wave-logo-256.png\" />\n        <link rel=\"stylesheet\" href=\"/fontawesome/css/fontawesome.min.css\" />\n        <link rel=\"stylesheet\" href=\"/fontawesome/css/brands.min.css\" />\n        <link rel=\"stylesheet\" href=\"/fontawesome/css/solid.min.css\" />\n        <link rel=\"stylesheet\" href=\"/fontawesome/css/sharp-solid.min.css\" />\n        <link rel=\"stylesheet\" href=\"/fontawesome/css/sharp-regular.min.css\" />\n        <link rel=\"stylesheet\" href=\"/fontawesome/css/custom-icons.min.css\" />\n    </head>\n    <body class=\"init\" data-colorscheme=\"dark\">\n        <div id=\"main\" class=\"flex flex-col w-full h-full\"></div>\n        <script type=\"module\" src=\"./preview.tsx\"></script>\n    </body>\n</html>\n"
  },
  {
    "path": "frontend/preview/mock/defaultconfig.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport mimetypesJson from \"../../../pkg/wconfig/defaultconfig/mimetypes.json\";\nimport presetsJson from \"../../../pkg/wconfig/defaultconfig/presets.json\";\nimport settingsJson from \"../../../pkg/wconfig/defaultconfig/settings.json\";\nimport termthemesJson from \"../../../pkg/wconfig/defaultconfig/termthemes.json\";\nimport waveaiJson from \"../../../pkg/wconfig/defaultconfig/waveai.json\";\nimport widgetsJson from \"../../../pkg/wconfig/defaultconfig/widgets.json\";\n\nexport const DefaultFullConfig: FullConfigType = {\n    settings: settingsJson as SettingsType,\n    mimetypes: mimetypesJson as unknown as { [key: string]: MimeTypeConfigType },\n    defaultwidgets: widgetsJson as unknown as { [key: string]: WidgetConfigType },\n    widgets: {},\n    presets: presetsJson as unknown as { [key: string]: MetaType },\n    termthemes: termthemesJson as unknown as { [key: string]: TermThemeType },\n    connections: {},\n    bookmarks: {},\n    waveai: waveaiJson as unknown as { [key: string]: AIModeConfigType },\n    configerrors: [],\n};\n"
  },
  {
    "path": "frontend/preview/mock/mock-node-model.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport type { NodeModel } from \"@/layout/index\";\nimport { atom } from \"jotai\";\n\nexport type MockNodeModelOpts = {\n    nodeId: string;\n    blockId: string;\n    innerRect?: { width: string; height: string };\n    numLeafs?: number;\n};\n\nexport function makeMockNodeModel(opts: MockNodeModelOpts): NodeModel {\n    const isFocusedAtom = atom(true);\n    const isMagnifiedAtom = atom(false);\n\n    return {\n        additionalProps: atom({} as any),\n        innerRect: atom(opts.innerRect ?? { width: \"1000px\", height: \"640px\" }),\n        blockNum: atom(1),\n        numLeafs: atom(opts.numLeafs ?? 1),\n        nodeId: opts.nodeId,\n        blockId: opts.blockId,\n        addEphemeralNodeToLayout: () => {},\n        animationTimeS: atom(0),\n        isResizing: atom(false),\n        isFocused: isFocusedAtom,\n        isMagnified: isMagnifiedAtom,\n        anyMagnified: atom((get) => get(isMagnifiedAtom)),\n        isEphemeral: atom(false),\n        ready: atom(true),\n        disablePointerEvents: atom(false),\n        toggleMagnify: () => {\n            globalStore.set(isMagnifiedAtom, !globalStore.get(isMagnifiedAtom));\n        },\n        focusNode: () => {\n            globalStore.set(isFocusedAtom, true);\n        },\n        onClose: () => {},\n        dragHandleRef: { current: null },\n        displayContainerRef: { current: null },\n    };\n}\n"
  },
  {
    "path": "frontend/preview/mock/mockfilesystem.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { arrayToBase64 } from \"@/util/util\";\n\nconst MockHomePath = \"/Users/mike\";\nconst MockDirMimeType = \"directory\";\nconst MockDirMode = 0o040755;\nconst MockFileMode = 0o100644;\nconst MockDirectoryChunkSize = 128;\nconst MockFileChunkSize = 64 * 1024;\nconst MockBaseModTime = Date.parse(\"2026-03-10T09:00:00.000Z\");\nconst TinyPngBytes = Uint8Array.from([\n    0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,\n    0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x04, 0x00, 0x00, 0x00, 0xb5, 0x1c, 0x0c,\n    0x02, 0x00, 0x00, 0x00, 0x0b, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xfc, 0xff, 0x1f, 0x00,\n    0x03, 0x03, 0x01, 0xff, 0xa5, 0xf8, 0x8f, 0xb1, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44,\n    0xae, 0x42, 0x60, 0x82,\n]);\nconst TinyJpegBytes = Uint8Array.from([\n    0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01,\n    0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03,\n    0x03, 0x03, 0x03, 0x04, 0x03, 0x03, 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0a, 0x07,\n    0x07, 0x06, 0x08, 0x0c, 0x0a, 0x0c, 0x0c, 0x0b, 0x0a, 0x0b, 0x0b, 0x0d, 0x0e, 0x12, 0x10, 0x0d,\n    0x0e, 0x11, 0x0e, 0x0b, 0x0b, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15, 0x15, 0x15, 0x0c, 0x0f,\n    0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01,\n    0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,\n    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xff, 0xc4, 0x00, 0x14,\n    0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n    0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0xbf, 0xff, 0xd9,\n]);\n\ntype MockFsEntry = {\n    path: string;\n    dir: string;\n    name: string;\n    isdir: boolean;\n    mimetype: string;\n    modtime: number;\n    mode: number;\n    size: number;\n    readonly?: boolean;\n    supportsmkdir?: boolean;\n    content?: Uint8Array;\n};\n\ntype MockFsEntryInput = {\n    path: string;\n    isdir?: boolean;\n    mimetype?: string;\n    readonly?: boolean;\n    content?: string | Uint8Array;\n};\n\nexport type MockFilesystem = {\n    homePath: string;\n    fileCount: number;\n    directoryCount: number;\n    entryCount: number;\n    fileInfo: (data: FileData) => Promise<FileInfo>;\n    fileRead: (data: FileData) => Promise<FileData>;\n    fileList: (data: FileListData) => Promise<FileInfo[]>;\n    fileJoin: (paths: string[]) => Promise<FileInfo>;\n    fileReadStream: (data: FileData) => AsyncGenerator<FileData, void, boolean>;\n    fileListStream: (data: FileListData) => AsyncGenerator<CommandRemoteListEntriesRtnData, void, boolean>;\n};\n\nfunction normalizeMockPath(path: string, basePath = MockHomePath): string {\n    if (path == null || path === \"\") {\n        return basePath;\n    }\n    if (path.startsWith(\"wsh://\")) {\n        const url = new URL(path);\n        path = url.pathname.replace(/^\\/+/, \"/\");\n    }\n    if (path === \"~\") {\n        path = MockHomePath;\n    } else if (path.startsWith(\"~/\")) {\n        path = MockHomePath + path.slice(1);\n    }\n    if (!path.startsWith(\"/\")) {\n        path = `${basePath}/${path}`;\n    }\n    const parts = path.split(\"/\");\n    const resolvedParts: string[] = [];\n    for (const part of parts) {\n        if (!part || part === \".\") {\n            continue;\n        }\n        if (part === \"..\") {\n            resolvedParts.pop();\n            continue;\n        }\n        resolvedParts.push(part);\n    }\n    const resolvedPath = \"/\" + resolvedParts.join(\"/\");\n    return resolvedPath === \"\" ? \"/\" : resolvedPath;\n}\n\nfunction getDirName(path: string): string {\n    if (path === \"/\") {\n        return \"/\";\n    }\n    const idx = path.lastIndexOf(\"/\");\n    if (idx <= 0) {\n        return \"/\";\n    }\n    return path.slice(0, idx);\n}\n\nfunction getBaseName(path: string): string {\n    if (path === \"/\") {\n        return \"/\";\n    }\n    const idx = path.lastIndexOf(\"/\");\n    return idx < 0 ? path : path.slice(idx + 1);\n}\n\nfunction getMimeType(path: string, isdir: boolean): string {\n    if (isdir) {\n        return MockDirMimeType;\n    }\n    if (path.endsWith(\".md\")) {\n        return \"text/markdown\";\n    }\n    if (path.endsWith(\".json\")) {\n        return \"application/json\";\n    }\n    if (path.endsWith(\".ts\")) {\n        return \"text/typescript\";\n    }\n    if (path.endsWith(\".tsx\")) {\n        return \"text/tsx\";\n    }\n    if (path.endsWith(\".js\")) {\n        return \"text/javascript\";\n    }\n    if (path.endsWith(\".txt\") || path.endsWith(\".log\") || path.endsWith(\".bashrc\") || path.endsWith(\".zprofile\")) {\n        return \"text/plain\";\n    }\n    if (path.endsWith(\".png\")) {\n        return \"image/png\";\n    }\n    if (path.endsWith(\".jpg\") || path.endsWith(\".jpeg\")) {\n        return \"image/jpeg\";\n    }\n    if (path.endsWith(\".pdf\")) {\n        return \"application/pdf\";\n    }\n    if (path.endsWith(\".zip\")) {\n        return \"application/zip\";\n    }\n    if (path.endsWith(\".dmg\")) {\n        return \"application/x-apple-diskimage\";\n    }\n    if (path.endsWith(\".svg\")) {\n        return \"image/svg+xml\";\n    }\n    if (path.endsWith(\".yaml\") || path.endsWith(\".yml\")) {\n        return \"application/yaml\";\n    }\n    return \"application/octet-stream\";\n}\n\nfunction makeContentBytes(content: string | Uint8Array): Uint8Array {\n    if (content instanceof Uint8Array) {\n        return content;\n    }\n    return new TextEncoder().encode(content);\n}\n\nfunction makeMockFsInput(path: string, content?: string | Uint8Array, mimetype?: string): MockFsEntryInput {\n    return { path, content, mimetype };\n}\n\nfunction createMockFilesystemEntries(): MockFsEntryInput[] {\n    const entries: MockFsEntryInput[] = [\n        { path: \"/\", isdir: true },\n        { path: \"/Users\", isdir: true },\n        { path: MockHomePath, isdir: true },\n        { path: `${MockHomePath}/Desktop`, isdir: true },\n        { path: `${MockHomePath}/Documents`, isdir: true },\n        { path: `${MockHomePath}/Downloads`, isdir: true },\n        { path: `${MockHomePath}/Pictures`, isdir: true },\n        { path: `${MockHomePath}/Projects`, isdir: true },\n        { path: `${MockHomePath}/waveterm`, isdir: true },\n        { path: `${MockHomePath}/waveterm/docs`, isdir: true },\n        { path: `${MockHomePath}/waveterm/images`, isdir: true },\n        { path: `${MockHomePath}/.config`, isdir: true },\n        makeMockFsInput(\n            `${MockHomePath}/.bashrc`,\n            `export PATH=\"$HOME/bin:$PATH\"\\nalias gs=\"git status -sb\"\\nexport WAVETERM_THEME=\"midnight\"\\n`,\n            \"text/plain\"\n        ),\n        makeMockFsInput(`${MockHomePath}/.gitconfig`),\n        makeMockFsInput(`${MockHomePath}/.zprofile`),\n        makeMockFsInput(`${MockHomePath}/todo.txt`),\n        makeMockFsInput(`${MockHomePath}/notes.txt`),\n        makeMockFsInput(`${MockHomePath}/shell-aliases`),\n        makeMockFsInput(`${MockHomePath}/archive.log`),\n        makeMockFsInput(`${MockHomePath}/session.txt`),\n        makeMockFsInput(`${MockHomePath}/Desktop/launch-plan.md`),\n        makeMockFsInput(`${MockHomePath}/Desktop/coffee.txt`),\n        makeMockFsInput(`${MockHomePath}/Desktop/daily-standup.txt`),\n        makeMockFsInput(`${MockHomePath}/Desktop/snippets.txt`),\n        makeMockFsInput(`${MockHomePath}/Desktop/terminal-theme.png`),\n        makeMockFsInput(`${MockHomePath}/Desktop/macos-shortcuts.txt`),\n        makeMockFsInput(`${MockHomePath}/Desktop/bug-scrub.txt`),\n        makeMockFsInput(`${MockHomePath}/Desktop/parking-receipt.pdf`),\n        makeMockFsInput(`${MockHomePath}/Desktop/demo-script.md`),\n        makeMockFsInput(`${MockHomePath}/Desktop/roadmap-draft.txt`),\n        makeMockFsInput(`${MockHomePath}/Desktop/pairing-notes.txt`),\n        makeMockFsInput(`${MockHomePath}/Desktop/wave-window.jpg`),\n        makeMockFsInput(\n            `${MockHomePath}/Documents/meeting-notes.md`,\n            `# File Preview Notes\\n\\n- Build a richer preview mock environment.\\n- Add a fake filesystem rooted at \\`${MockHomePath}\\`.\\n- Make markdown previews resolve relative assets.\\n`,\n            \"text/markdown\"\n        ),\n        makeMockFsInput(`${MockHomePath}/Documents/architecture-overview.md`),\n        makeMockFsInput(`${MockHomePath}/Documents/release-checklist.md`),\n        makeMockFsInput(`${MockHomePath}/Documents/ideas.txt`),\n        makeMockFsInput(`${MockHomePath}/Documents/customer-feedback.txt`),\n        makeMockFsInput(`${MockHomePath}/Documents/cli-ux-notes.txt`),\n        makeMockFsInput(`${MockHomePath}/Documents/migration-plan.md`),\n        makeMockFsInput(`${MockHomePath}/Documents/design-review.md`),\n        makeMockFsInput(`${MockHomePath}/Documents/ops-runbook.md`),\n        makeMockFsInput(`${MockHomePath}/Documents/troubleshooting.txt`),\n        makeMockFsInput(`${MockHomePath}/Documents/preview-fixtures.txt`),\n        makeMockFsInput(`${MockHomePath}/Documents/backlog.txt`),\n        makeMockFsInput(`${MockHomePath}/Documents/feature-flags.yaml`),\n        makeMockFsInput(`${MockHomePath}/Documents/connections.csv`),\n        makeMockFsInput(`${MockHomePath}/Documents/ssh-hosts.txt`),\n        makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-01.md`),\n        makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-05.md`),\n        makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-09.md`),\n        makeMockFsInput(`${MockHomePath}/Downloads/waveterm-nightly.dmg`),\n        makeMockFsInput(`${MockHomePath}/Downloads/screenshot-pack.zip`),\n        makeMockFsInput(`${MockHomePath}/Downloads/cli-reference.pdf`),\n        makeMockFsInput(`${MockHomePath}/Downloads/ssh-cheatsheet.pdf`),\n        makeMockFsInput(`${MockHomePath}/Downloads/perf-trace.json`),\n        makeMockFsInput(`${MockHomePath}/Downloads/terminal-icons.zip`),\n        makeMockFsInput(`${MockHomePath}/Downloads/demo-data.csv`),\n        makeMockFsInput(`${MockHomePath}/Downloads/deploy-plan.txt`),\n        makeMockFsInput(`${MockHomePath}/Downloads/customer-audio.m4a`),\n        makeMockFsInput(`${MockHomePath}/Downloads/mock-shell-history.txt`),\n        makeMockFsInput(`${MockHomePath}/Downloads/design-assets.zip`),\n        makeMockFsInput(`${MockHomePath}/Downloads/old-preview-build.dmg`),\n        makeMockFsInput(`${MockHomePath}/Downloads/testing-samples.tar`),\n        makeMockFsInput(`${MockHomePath}/Downloads/workflow-failure.log`),\n        makeMockFsInput(`${MockHomePath}/Downloads/team-photo.jpg`),\n        makeMockFsInput(`${MockHomePath}/Downloads/preview-recording.mov`),\n        makeMockFsInput(`${MockHomePath}/Downloads/standup-notes.txt`),\n        makeMockFsInput(`${MockHomePath}/Downloads/metadata.json`),\n        makeMockFsInput(`${MockHomePath}/Pictures/beach-sunrise.png`, TinyPngBytes, \"image/png\"),\n        makeMockFsInput(`${MockHomePath}/Pictures/terminal-screenshot.jpg`, TinyJpegBytes, \"image/jpeg\"),\n        makeMockFsInput(`${MockHomePath}/Pictures/diagram.png`),\n        makeMockFsInput(`${MockHomePath}/Pictures/launch-party.jpg`),\n        makeMockFsInput(`${MockHomePath}/Pictures/icon-sketch.png`),\n        makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-01.png`),\n        makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-02.png`),\n        makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-03.png`),\n        makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-04.png`),\n        makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-05.png`),\n        makeMockFsInput(`${MockHomePath}/Pictures/product-shot-01.jpg`),\n        makeMockFsInput(`${MockHomePath}/Pictures/product-shot-02.jpg`),\n        makeMockFsInput(`${MockHomePath}/Pictures/product-shot-03.jpg`),\n        makeMockFsInput(`${MockHomePath}/Pictures/product-shot-04.jpg`),\n        makeMockFsInput(`${MockHomePath}/Pictures/product-shot-05.jpg`),\n        makeMockFsInput(`${MockHomePath}/Pictures/ui-concept.png`),\n        makeMockFsInput(`${MockHomePath}/Projects/local.env`),\n        makeMockFsInput(`${MockHomePath}/Projects/db-migration.sql`),\n        makeMockFsInput(`${MockHomePath}/Projects/prompt-lab.txt`),\n        makeMockFsInput(`${MockHomePath}/Projects/ui-spikes.tsx`),\n        makeMockFsInput(`${MockHomePath}/Projects/file-browser.tsx`),\n        makeMockFsInput(`${MockHomePath}/Projects/mock-data.json`),\n        makeMockFsInput(`${MockHomePath}/Projects/preview-api.ts`),\n        makeMockFsInput(`${MockHomePath}/Projects/bug-181.txt`),\n        makeMockFsInput(\n            `${MockHomePath}/waveterm/README.md`,\n            `# Mock WaveTerm Repo\\n\\nThis fake repo exists only in the preview environment.\\nIt gives file previews something realistic to browse.\\n`,\n            \"text/markdown\"\n        ),\n        makeMockFsInput(`${MockHomePath}/waveterm/package.json`),\n        makeMockFsInput(`${MockHomePath}/waveterm/tsconfig.json`),\n        makeMockFsInput(`${MockHomePath}/waveterm/Taskfile.yml`),\n        makeMockFsInput(`${MockHomePath}/waveterm/preview-model.tsx`),\n        makeMockFsInput(`${MockHomePath}/waveterm/mockwaveenv.ts`),\n        makeMockFsInput(`${MockHomePath}/waveterm/vite.config.ts`),\n        makeMockFsInput(`${MockHomePath}/waveterm/CHANGELOG.md`),\n        makeMockFsInput(\n            `${MockHomePath}/waveterm/docs/preview-notes.md`,\n            `# Preview Mocking\\n\\nUse the preview server to iterate on file previews without Electron.\\nRelative markdown assets should resolve through \\`FileJoinCommand\\`.\\n`,\n            \"text/markdown\"\n        ),\n        makeMockFsInput(`${MockHomePath}/waveterm/docs/filesystem-rpc.md`),\n        makeMockFsInput(`${MockHomePath}/waveterm/docs/test-plan.md`),\n        makeMockFsInput(`${MockHomePath}/waveterm/docs/connections.md`),\n        makeMockFsInput(`${MockHomePath}/waveterm/docs/preview-gallery.md`),\n        makeMockFsInput(`${MockHomePath}/waveterm/docs/release-notes.md`),\n        makeMockFsInput(`${MockHomePath}/waveterm/images/wave-logo.png`, TinyPngBytes, \"image/png\"),\n        makeMockFsInput(`${MockHomePath}/waveterm/images/hero.png`),\n        makeMockFsInput(`${MockHomePath}/waveterm/images/avatar.jpg`),\n        makeMockFsInput(`${MockHomePath}/waveterm/images/icon-16.png`),\n        makeMockFsInput(`${MockHomePath}/waveterm/images/icon-32.png`),\n        makeMockFsInput(`${MockHomePath}/waveterm/images/splash.jpg`),\n        makeMockFsInput(\n            `${MockHomePath}/.config/settings.json`,\n            JSON.stringify(\n                {\n                    \"app:theme\": \"wave-dark\",\n                    \"preview:lastpath\": `${MockHomePath}/Documents/meeting-notes.md`,\n                    \"window:magnifiedblockopacity\": 0.92,\n                },\n                null,\n                2\n            ),\n            \"application/json\"\n        ),\n        makeMockFsInput(`${MockHomePath}/.config/preview-cache.json`),\n        makeMockFsInput(`${MockHomePath}/.config/recent-workspaces.json`),\n        makeMockFsInput(`${MockHomePath}/.config/telemetry.log`),\n    ];\n    return entries;\n}\n\nfunction buildEntries(): Map<string, MockFsEntry> {\n    const inputs = createMockFilesystemEntries();\n    const entries = new Map<string, MockFsEntry>();\n    const ensureDir = (path: string) => {\n        const normalizedPath = normalizeMockPath(path, \"/\");\n        if (entries.has(normalizedPath)) {\n            return;\n        }\n        const dir = getDirName(normalizedPath);\n        if (normalizedPath !== \"/\") {\n            ensureDir(dir);\n        }\n        entries.set(normalizedPath, {\n            path: normalizedPath,\n            dir: normalizedPath === \"/\" ? \"/\" : dir,\n            name: normalizedPath === \"/\" ? \"/\" : getBaseName(normalizedPath),\n            isdir: true,\n            mimetype: MockDirMimeType,\n            modtime: MockBaseModTime + entries.size * 60000,\n            mode: MockDirMode,\n            size: 0,\n            supportsmkdir: true,\n        });\n    };\n    for (const input of inputs) {\n        const normalizedPath = normalizeMockPath(input.path, \"/\");\n        const isdir = input.isdir ?? false;\n        const dir = getDirName(normalizedPath);\n        if (normalizedPath !== \"/\") {\n            ensureDir(dir);\n        }\n        const content = input.content == null ? undefined : makeContentBytes(input.content);\n        entries.set(normalizedPath, {\n            path: normalizedPath,\n            dir: normalizedPath === \"/\" ? \"/\" : dir,\n            name: normalizedPath === \"/\" ? \"/\" : getBaseName(normalizedPath),\n            isdir,\n            mimetype: input.mimetype ?? getMimeType(normalizedPath, isdir),\n            modtime: MockBaseModTime + entries.size * 60000,\n            mode: isdir ? MockDirMode : MockFileMode,\n            size: content?.byteLength ?? 0,\n            readonly: input.readonly,\n            supportsmkdir: isdir,\n            content,\n        });\n    }\n    return entries;\n}\n\nfunction toFileInfo(entry: MockFsEntry): FileInfo {\n    return {\n        path: entry.path,\n        dir: entry.dir,\n        name: entry.name,\n        size: entry.size,\n        mode: entry.mode,\n        modtime: entry.modtime,\n        isdir: entry.isdir,\n        supportsmkdir: entry.supportsmkdir,\n        mimetype: entry.mimetype,\n        readonly: entry.readonly,\n    };\n}\n\nfunction makeNotFoundInfo(path: string): FileInfo {\n    const normalizedPath = normalizeMockPath(path);\n    return {\n        path: normalizedPath,\n        dir: getDirName(normalizedPath),\n        name: getBaseName(normalizedPath),\n        notfound: true,\n        supportsmkdir: true,\n    };\n}\n\nfunction sliceEntries(entries: FileInfo[], opts?: FileListOpts): FileInfo[] {\n    let filteredEntries = entries;\n    if (!opts?.all) {\n        filteredEntries = filteredEntries.filter((entry) => entry.name != null && !entry.name.startsWith(\".\"));\n    }\n    const offset = Math.max(opts?.offset ?? 0, 0);\n    const end = opts?.limit != null && opts.limit >= 0 ? offset + opts.limit : undefined;\n    return filteredEntries.slice(offset, end);\n}\n\nfunction joinPaths(paths: string[]): string {\n    if (paths.length === 0) {\n        return MockHomePath;\n    }\n    let currentPath = normalizeMockPath(paths[0]);\n    for (const part of paths.slice(1)) {\n        currentPath = normalizeMockPath(part, currentPath);\n    }\n    return currentPath;\n}\n\nfunction getReadRange(data: FileData, size: number): { offset: number; end: number } {\n    const offset = Math.max(data?.at?.offset ?? 0, 0);\n    const end = data?.at?.size != null ? Math.min(offset + data.at.size, size) : size;\n    return { offset, end: Math.max(offset, end) };\n}\n\nexport function makeMockFilesystem(): MockFilesystem {\n    const entries = buildEntries();\n    const childrenByDir = new Map<string, MockFsEntry[]>();\n    for (const entry of entries.values()) {\n        if (entry.path === \"/\") {\n            continue;\n        }\n        if (!childrenByDir.has(entry.dir)) {\n            childrenByDir.set(entry.dir, []);\n        }\n        childrenByDir.get(entry.dir).push(entry);\n    }\n    for (const childEntries of childrenByDir.values()) {\n        childEntries.sort((a, b) => {\n            if (a.isdir !== b.isdir) {\n                return a.isdir ? -1 : 1;\n            }\n            return a.name.localeCompare(b.name);\n        });\n    }\n    const getEntry = (path: string): MockFsEntry => {\n        return entries.get(normalizeMockPath(path));\n    };\n    const fileInfo = async (data: FileData): Promise<FileInfo> => {\n        const entry = getEntry(data?.info?.path ?? MockHomePath);\n        if (!entry) {\n            return makeNotFoundInfo(data?.info?.path ?? MockHomePath);\n        }\n        return toFileInfo(entry);\n    };\n    const fileRead = async (data: FileData): Promise<FileData> => {\n        const info = await fileInfo(data);\n        if (info.notfound) {\n            return { info };\n        }\n        const entry = getEntry(info.path);\n        if (entry.isdir) {\n            const childEntries = (childrenByDir.get(entry.path) ?? []).map((child) => toFileInfo(child));\n            return { info, entries: childEntries };\n        }\n        if (entry.content == null || entry.content.byteLength === 0) {\n            return { info };\n        }\n        const { offset, end } = getReadRange(data, entry.content.byteLength);\n        return {\n            info,\n            data64: arrayToBase64(entry.content.slice(offset, end)),\n            at: { offset, size: end - offset },\n        };\n    };\n    const fileList = async (data: FileListData): Promise<FileInfo[]> => {\n        const dirPath = normalizeMockPath(data?.path ?? MockHomePath);\n        const entry = getEntry(dirPath);\n        if (entry == null || !entry.isdir) {\n            return [];\n        }\n        const dirEntries = (childrenByDir.get(dirPath) ?? []).map((child) => toFileInfo(child));\n        return sliceEntries(dirEntries, data?.opts);\n    };\n    const fileJoin = async (paths: string[]): Promise<FileInfo> => {\n        const path = paths.length === 1 ? normalizeMockPath(paths[0]) : joinPaths(paths);\n        const entry = getEntry(path);\n        if (!entry) {\n            return makeNotFoundInfo(path);\n        }\n        return toFileInfo(entry);\n    };\n    const fileReadStream = async function* (data: FileData): AsyncGenerator<FileData, void, boolean> {\n        const info = await fileInfo(data);\n        yield { info };\n        if (info.notfound) {\n            return;\n        }\n        const entry = getEntry(info.path);\n        if (entry.isdir) {\n            const dirEntries = (childrenByDir.get(entry.path) ?? []).map((child) => toFileInfo(child));\n            for (let idx = 0; idx < dirEntries.length; idx += MockDirectoryChunkSize) {\n                yield { entries: dirEntries.slice(idx, idx + MockDirectoryChunkSize) };\n            }\n            return;\n        }\n        if (entry.content == null || entry.content.byteLength === 0) {\n            return;\n        }\n        const { offset, end } = getReadRange(data, entry.content.byteLength);\n        for (let currentOffset = offset; currentOffset < end; currentOffset += MockFileChunkSize) {\n            const chunkEnd = Math.min(currentOffset + MockFileChunkSize, end);\n            yield {\n                data64: arrayToBase64(entry.content.slice(currentOffset, chunkEnd)),\n                at: { offset: currentOffset, size: chunkEnd - currentOffset },\n            };\n        }\n    };\n    const fileListStream = async function* (data: FileListData): AsyncGenerator<CommandRemoteListEntriesRtnData, void, boolean> {\n        const fileInfos = await fileList(data);\n        for (let idx = 0; idx < fileInfos.length; idx += MockDirectoryChunkSize) {\n            yield { fileinfo: fileInfos.slice(idx, idx + MockDirectoryChunkSize) };\n        }\n    };\n    const fileCount = Array.from(entries.values()).filter((entry) => !entry.isdir).length;\n    const directoryCount = Array.from(entries.values()).filter((entry) => entry.isdir).length;\n    return {\n        homePath: MockHomePath,\n        fileCount,\n        directoryCount,\n        entryCount: entries.size,\n        fileInfo,\n        fileRead,\n        fileList,\n        fileJoin,\n        fileReadStream,\n        fileListStream,\n    };\n}\n\nexport const DefaultMockFilesystem = makeMockFilesystem();\n"
  },
  {
    "path": "frontend/preview/mock/mockwaveenv.test.ts",
    "content": "import { base64ToArray, base64ToString } from \"@/util/util\";\nimport { describe, expect, it, vi } from \"vitest\";\nimport { DefaultMockFilesystem } from \"./mockfilesystem\";\n\nconst { showPreviewContextMenu } = vi.hoisted(() => ({\n    showPreviewContextMenu: vi.fn(),\n}));\n\nvi.mock(\"../preview-contextmenu\", () => ({\n    showPreviewContextMenu,\n}));\n\ndescribe(\"makeMockWaveEnv\", () => {\n    it(\"uses the preview context menu by default\", async () => {\n        const { makeMockWaveEnv } = await import(\"./mockwaveenv\");\n        const env = makeMockWaveEnv();\n        const menu = [{ label: \"Open\" }];\n        const event = { stopPropagation: vi.fn() } as any;\n\n        env.showContextMenu(menu, event);\n\n        expect(showPreviewContextMenu).toHaveBeenCalledWith(menu, event);\n    });\n\n    it(\"provides a populated mock filesystem rooted at /Users/mike\", () => {\n        expect(DefaultMockFilesystem.homePath).toBe(\"/Users/mike\");\n        expect(DefaultMockFilesystem.fileCount).toBeGreaterThanOrEqual(100);\n        expect(DefaultMockFilesystem.directoryCount).toBeGreaterThanOrEqual(10);\n    });\n\n    it(\"implements file info, read, list, and join commands\", async () => {\n        const { makeMockWaveEnv } = await import(\"./mockwaveenv\");\n        const env = makeMockWaveEnv();\n\n        const bashrcInfo = await env.rpc.FileInfoCommand(null as any, {\n            info: { path: \"wsh://local//Users/mike/.bashrc\" },\n        });\n        expect(bashrcInfo.path).toBe(\"/Users/mike/.bashrc\");\n        expect(bashrcInfo.mimetype).toBe(\"text/plain\");\n\n        const bashrcData = await env.rpc.FileReadCommand(null as any, {\n            info: { path: \"wsh://local//Users/mike/.bashrc\" },\n        });\n        expect(base64ToString(bashrcData.data64)).toContain('alias gs=\"git status -sb\"');\n\n        const visibleHomeEntries = await env.rpc.FileListCommand(null as any, {\n            path: \"/Users/mike\",\n        });\n        expect(visibleHomeEntries.some((entry) => entry.name === \".bashrc\")).toBe(false);\n        expect(visibleHomeEntries.some((entry) => entry.name === \"waveterm\")).toBe(true);\n\n        const allHomeEntries = await env.rpc.FileListCommand(null as any, {\n            path: \"/Users/mike\",\n            opts: { all: true },\n        });\n        expect(allHomeEntries.some((entry) => entry.name === \".bashrc\")).toBe(true);\n\n        const dirRead = await env.rpc.FileReadCommand(null as any, {\n            info: { path: \"/Users/mike/waveterm\" },\n        });\n        expect(dirRead.entries.some((entry) => entry.name === \"docs\" && entry.isdir)).toBe(true);\n\n        const joined = await env.rpc.FileJoinCommand(null as any, [\n            \"wsh://local//Users/mike/Documents\",\n            \"../waveterm/docs\",\n            \"preview-notes.md\",\n        ]);\n        expect(joined.path).toBe(\"/Users/mike/waveterm/docs/preview-notes.md\");\n        expect(joined.mimetype).toBe(\"text/markdown\");\n    });\n\n    it(\"implements file list and read stream commands\", async () => {\n        const { makeMockWaveEnv } = await import(\"./mockwaveenv\");\n        const env = makeMockWaveEnv();\n\n        const listPackets: CommandRemoteListEntriesRtnData[] = [];\n        for await (const packet of env.rpc.FileListStreamCommand(null as any, {\n            path: \"/Users/mike\",\n            opts: { all: true, limit: 4 },\n        })) {\n            listPackets.push(packet);\n        }\n        expect(listPackets).toHaveLength(1);\n        expect(listPackets[0].fileinfo).toHaveLength(4);\n\n        const readPackets: FileData[] = [];\n        for await (const packet of env.rpc.FileReadStreamCommand(null as any, {\n            info: { path: \"/Users/mike/Pictures/beach-sunrise.png\" },\n        })) {\n            readPackets.push(packet);\n        }\n        expect(readPackets[0].info?.path).toBe(\"/Users/mike/Pictures/beach-sunrise.png\");\n        const imageBytes = base64ToArray(readPackets[1].data64);\n        expect(Array.from(imageBytes.slice(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]);\n    });\n\n    it(\"implements secrets commands with in-memory storage\", async () => {\n        const { makeMockWaveEnv } = await import(\"./mockwaveenv\");\n        const env = makeMockWaveEnv({ platform: \"linux\" });\n\n        await env.rpc.SetSecretsCommand(null as any, {\n            OPENAI_API_KEY: \"sk-test\",\n            ANTHROPIC_API_KEY: \"anthropic-test\",\n        } as any);\n\n        expect(await env.rpc.GetSecretsLinuxStorageBackendCommand(null as any)).toBe(\"libsecret\");\n        expect(await env.rpc.GetSecretsNamesCommand(null as any)).toEqual([\"ANTHROPIC_API_KEY\", \"OPENAI_API_KEY\"]);\n        expect(await env.rpc.GetSecretsCommand(null as any, [\"OPENAI_API_KEY\", \"MISSING_SECRET\"])).toEqual({\n            OPENAI_API_KEY: \"sk-test\",\n        });\n\n        await env.rpc.SetSecretsCommand(null as any, { OPENAI_API_KEY: null } as any);\n\n        expect(await env.rpc.GetSecretsNamesCommand(null as any)).toEqual([\"ANTHROPIC_API_KEY\"]);\n        expect(await env.rpc.GetSecretsCommand(null as any, [\"OPENAI_API_KEY\", \"ANTHROPIC_API_KEY\"])).toEqual({\n            ANTHROPIC_API_KEY: \"anthropic-test\",\n        });\n    });\n});\n"
  },
  {
    "path": "frontend/preview/mock/mockwaveenv.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { makeDefaultConnStatus } from \"@/app/store/global\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { AllServiceTypes } from \"@/app/store/services\";\nimport { handleWaveEvent } from \"@/app/store/wps\";\nimport { RpcApiType } from \"@/app/store/wshclientapi\";\nimport { WaveEnv } from \"@/app/waveenv/waveenv\";\nimport { PlatformLinux, PlatformMacOS, PlatformWindows } from \"@/util/platformutil\";\nimport { Atom, atom, PrimitiveAtom, useAtomValue } from \"jotai\";\nimport { showPreviewContextMenu } from \"../preview-contextmenu\";\nimport { MockSysinfoConnection } from \"../previews/sysinfo.preview-util\";\nimport { DefaultFullConfig } from \"./defaultconfig\";\nimport { DefaultMockFilesystem } from \"./mockfilesystem\";\nimport { previewElectronApi } from \"./preview-electron-api\";\n\nexport const PreviewTabId = crypto.randomUUID();\nexport const PreviewWindowId = crypto.randomUUID();\nexport const PreviewWorkspaceId = crypto.randomUUID();\nexport const PreviewClientId = crypto.randomUUID();\nexport const WebBlockId = crypto.randomUUID();\nexport const SysinfoBlockId = crypto.randomUUID();\n\n// What works \"out of the box\" in the mock environment (no MockEnv overrides needed):\n//\n// RPC calls (handled in makeMockRpc):\n//   - rpc.EventPublishCommand           -- dispatches to handleWaveEvent(); works when the subscriber\n//                                          is purely FE-based (registered via WPS on the frontend)\n//   - rpc.GetMetaCommand                -- reads .meta from the mock WOS atom for the given oref\n//   - rpc.GetSecretsCommand             -- reads secrets from an in-memory mock secret store\n//   - rpc.GetSecretsLinuxStorageBackendCommand\n//                                        returns \"libsecret\" on Linux previews and \"\" elsewhere\n//   - rpc.GetSecretsNamesCommand        -- lists secret names from the in-memory mock secret store\n//   - rpc.SetMetaCommand                -- writes .meta to the mock WOS atom (null values delete keys)\n//   - rpc.SetConfigCommand              -- merges settings into fullConfigAtom (null values delete keys)\n//   - rpc.SetSecretsCommand             -- writes/deletes secrets in the in-memory mock secret store\n//   - rpc.UpdateTabNameCommand          -- updates .name on the Tab WaveObj in the mock WOS\n//   - rpc.UpdateWorkspaceTabIdsCommand  -- updates .tabids on the Workspace WaveObj in the mock WOS\n//\n// Any other RPC call falls through to a console.log and resolves null.\n// Override specific calls via MockEnv.rpc (keys are Command method names, e.g. \"GetMetaCommand\").\n// Override specific streaming calls via MockEnv.rpcStreaming (same key names, handler returns AsyncGenerator).\n//\n// Backend service calls (handled in callBackendService):\n//   Any call falls through to a console.log and resolves null.\n//   Override specific calls via MockEnv.services: { Service: { Method: impl } }\n//   e.g. { \"block\": { \"GetControllerStatus\": (blockId) => myStatus } }\n\nexport type RpcHandlerType = (...args: any[]) => Promise<any>;\nexport type RpcStreamHandlerType = (...args: any[]) => AsyncGenerator<any, void, boolean>;\n\nexport type RpcOverrides = {\n    [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: RpcHandlerType;\n};\n\nexport type RpcStreamOverrides = {\n    [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: RpcStreamHandlerType;\n};\n\ntype ServiceOverrides = {\n    [Service: string]: {\n        [Method: string]: (...args: any[]) => Promise<any>;\n    };\n};\n\nexport type MockEnv = {\n    isDev?: boolean;\n    tabId?: string;\n    platform?: NodeJS.Platform;\n    settings?: Partial<SettingsType>;\n    rpc?: RpcOverrides;\n    rpcStreaming?: RpcStreamOverrides;\n    services?: ServiceOverrides;\n    atoms?: Partial<GlobalAtomsType>;\n    electron?: Partial<ElectronApi>;\n    createBlock?: WaveEnv[\"createBlock\"];\n    showContextMenu?: WaveEnv[\"showContextMenu\"];\n    connStatus?: Record<string, ConnStatus>;\n    mockWaveObjs?: Record<string, WaveObj>;\n};\n\nexport type MockWaveEnv = WaveEnv & {\n    mockEnv: MockEnv;\n    addRpcOverride: <K extends keyof RpcOverrides>(command: K, handler: RpcHandlerType) => void;\n    addRpcStreamOverride: <K extends keyof RpcStreamOverrides>(command: K, handler: RpcStreamHandlerType) => void;\n};\n\nfunction mergeRecords<T>(base: Record<string, T>, overrides: Record<string, T>): Record<string, T> {\n    if (base == null && overrides == null) {\n        return undefined;\n    }\n    return { ...(base ?? {}), ...(overrides ?? {}) };\n}\n\nexport function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv {\n    let mergedServices: ServiceOverrides;\n    if (base.services != null || overrides.services != null) {\n        mergedServices = {};\n        for (const svc of Object.keys(base.services ?? {})) {\n            mergedServices[svc] = { ...(base.services[svc] ?? {}) };\n        }\n        for (const svc of Object.keys(overrides.services ?? {})) {\n            mergedServices[svc] = { ...(mergedServices[svc] ?? {}), ...(overrides.services[svc] ?? {}) };\n        }\n    }\n    return {\n        isDev: overrides.isDev ?? base.isDev,\n        tabId: overrides.tabId ?? base.tabId,\n        platform: overrides.platform ?? base.platform,\n        settings: mergeRecords(base.settings, overrides.settings),\n        rpc: mergeRecords(base.rpc as any, overrides.rpc as any) as RpcOverrides,\n        rpcStreaming: mergeRecords(base.rpcStreaming as any, overrides.rpcStreaming as any) as RpcStreamOverrides,\n        services: mergedServices,\n        atoms: overrides.atoms != null || base.atoms != null ? { ...base.atoms, ...overrides.atoms } : undefined,\n        electron:\n            overrides.electron != null || base.electron != null\n                ? { ...(base.electron ?? {}), ...(overrides.electron ?? {}) }\n                : undefined,\n        createBlock: overrides.createBlock ?? base.createBlock,\n        showContextMenu: overrides.showContextMenu ?? base.showContextMenu,\n        connStatus: mergeRecords(base.connStatus, overrides.connStatus),\n        mockWaveObjs: mergeRecords(base.mockWaveObjs, overrides.mockWaveObjs),\n    };\n}\n\nfunction makeMockSettingsKeyAtom(settingsAtom: Atom<SettingsType>): WaveEnv[\"getSettingsKeyAtom\"] {\n    const keyAtomCache = new Map<keyof SettingsType, Atom<any>>();\n    return <T extends keyof SettingsType>(key: T) => {\n        if (!keyAtomCache.has(key)) {\n            keyAtomCache.set(\n                key,\n                atom((get) => get(settingsAtom)?.[key])\n            );\n        }\n        return keyAtomCache.get(key) as Atom<SettingsType[T]>;\n    };\n}\n\nfunction makeMockGlobalAtoms(\n    settingsOverrides: Partial<SettingsType>,\n    atomOverrides: Partial<GlobalAtomsType>,\n    tabId: string,\n    getWaveObjectAtom: <T extends WaveObj>(oref: string) => PrimitiveAtom<T>\n): GlobalAtomsType {\n    let fullConfig = DefaultFullConfig;\n    if (settingsOverrides) {\n        fullConfig = {\n            ...DefaultFullConfig,\n            settings: { ...DefaultFullConfig.settings, ...settingsOverrides },\n        };\n    }\n    const fullConfigAtom = atom(fullConfig) as PrimitiveAtom<FullConfigType>;\n    const settingsAtom = atom((get) => get(fullConfigAtom)?.settings ?? {}) as Atom<SettingsType>;\n    const workspaceIdAtom: Atom<string> = atomOverrides?.workspaceId ?? (atom(null as string) as Atom<string>);\n    const workspaceAtom: Atom<Workspace> = atom((get) => {\n        const wsId = get(workspaceIdAtom);\n        if (wsId == null) {\n            return null;\n        }\n        return get(getWaveObjectAtom<Workspace>(\"workspace:\" + wsId));\n    });\n    const defaults: GlobalAtomsType = {\n        builderId: atom(\"\"),\n        builderAppId: atom(\"\") as any,\n        uiContext: atom({ windowid: \"\", activetabid: tabId ?? \"\" } as UIContext),\n        workspaceId: workspaceIdAtom,\n        workspace: workspaceAtom,\n        fullConfigAtom,\n        waveaiModeConfigAtom: atom({}) as any,\n        settingsAtom,\n        hasCustomAIPresetsAtom: atom(false),\n        hasConfigErrors: atom((get) => {\n            const c = get(fullConfigAtom);\n            return c?.configerrors != null && c.configerrors.length > 0;\n        }),\n        staticTabId: atom(tabId ?? \"\"),\n        isFullScreen: atom(false) as any,\n        zoomFactorAtom: atom(1.0) as any,\n        controlShiftDelayAtom: atom(false) as any,\n        prefersReducedMotionAtom: atom(false),\n        documentHasFocus: atom(true) as any,\n        updaterStatusAtom: atom(\"up-to-date\" as UpdaterStatus) as any,\n        modalOpen: atom(false) as any,\n        allConnStatus: atom([] as ConnStatus[]),\n        reinitVersion: atom(0) as any,\n        waveAIRateLimitInfoAtom: atom(null) as any,\n    };\n    if (!atomOverrides) {\n        return defaults;\n    }\n    const merged = { ...defaults, ...atomOverrides };\n    if (!atomOverrides.workspace) {\n        merged.workspace = workspaceAtom;\n    }\n    return merged;\n}\n\ntype MockWosFns = {\n    getWaveObjectAtom: <T extends WaveObj>(oref: string) => PrimitiveAtom<T>;\n    mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => void;\n    fullConfigAtom: PrimitiveAtom<FullConfigType>;\n    platform: NodeJS.Platform;\n};\n\nexport function makeMockRpc(\n    overrides: RpcOverrides,\n    streamOverrides: RpcStreamOverrides,\n    wos: MockWosFns\n): {\n    rpc: RpcApiType;\n    setRpcHandler: (command: string, fn: RpcHandlerType) => void;\n    setRpcStreamHandler: (command: string, fn: RpcStreamHandlerType) => void;\n} {\n    const callDispatchMap = new Map<string, (...args: any[]) => Promise<any>>();\n    const streamDispatchMap = new Map<string, (...args: any[]) => AsyncGenerator<any, void, boolean>>();\n    const secrets = new Map<string, string>();\n    const setCallHandler = (command: string, fn: (...args: any[]) => Promise<any>) => {\n        callDispatchMap.set(command, fn);\n    };\n    const setStreamHandler = (command: string, fn: (...args: any[]) => AsyncGenerator<any, void, boolean>) => {\n        streamDispatchMap.set(command, fn);\n    };\n    setCallHandler(\"eventpublish\", async (_client, data: WaveEvent) => {\n        console.log(\"[mock eventpublish]\", data);\n        handleWaveEvent(data);\n        return null;\n    });\n    setCallHandler(\"getmeta\", async (_client, data: CommandGetMetaData) => {\n        const objAtom = wos.getWaveObjectAtom(data.oref);\n        const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType };\n        return current?.meta ?? {};\n    });\n    setCallHandler(\"setmeta\", async (_client, data: CommandSetMetaData) => {\n        const objAtom = wos.getWaveObjectAtom(data.oref);\n        const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType };\n        const updatedMeta = { ...(current?.meta ?? {}) };\n        for (const [key, value] of Object.entries(data.meta)) {\n            if (value === null) {\n                delete updatedMeta[key];\n            } else {\n                (updatedMeta as any)[key] = value;\n            }\n        }\n        const updated = { ...current, meta: updatedMeta };\n        wos.mockSetWaveObj(data.oref, updated);\n        return null;\n    });\n    setCallHandler(\"updatetabname\", async (_client, data: { args: [string, string] }) => {\n        const [tabId, newName] = data.args;\n        const tabORef = \"tab:\" + tabId;\n        const objAtom = wos.getWaveObjectAtom(tabORef);\n        const current = globalStore.get(objAtom) as Tab;\n        const updated = { ...current, name: newName };\n        wos.mockSetWaveObj(tabORef, updated);\n        return null;\n    });\n    setCallHandler(\"setconfig\", async (_client, data: SettingsType) => {\n        const current = globalStore.get(wos.fullConfigAtom);\n        const updatedSettings = { ...(current?.settings ?? {}) };\n        for (const [key, value] of Object.entries(data)) {\n            if (value === null) {\n                delete (updatedSettings as any)[key];\n            } else {\n                (updatedSettings as any)[key] = value;\n            }\n        }\n        globalStore.set(wos.fullConfigAtom, { ...current, settings: updatedSettings as SettingsType });\n        return null;\n    });\n    setCallHandler(\"getsecretslinuxstoragebackend\", async () => {\n        if (wos.platform !== PlatformLinux) {\n            return \"\";\n        }\n        return \"libsecret\";\n    });\n    setCallHandler(\"getsecretsnames\", async () => {\n        return Array.from(secrets.keys()).sort();\n    });\n    setCallHandler(\"getsecrets\", async (_client, data: string[]) => {\n        const foundSecrets: Record<string, string> = {};\n        for (const name of data ?? []) {\n            const value = secrets.get(name);\n            if (value != null) {\n                foundSecrets[name] = value;\n            }\n        }\n        return foundSecrets;\n    });\n    setCallHandler(\"setsecrets\", async (_client, data: Record<string, string>) => {\n        for (const [name, value] of Object.entries(data ?? {})) {\n            if (value == null) {\n                secrets.delete(name);\n                continue;\n            }\n            secrets.set(name, value);\n        }\n        return null;\n    });\n    setCallHandler(\"updateworkspacetabids\", async (_client, data: { args: [string, string[]] }) => {\n        const [workspaceId, tabIds] = data.args;\n        const wsORef = \"workspace:\" + workspaceId;\n        const objAtom = wos.getWaveObjectAtom(wsORef);\n        const current = globalStore.get(objAtom) as Workspace;\n        const updated = { ...current, tabids: tabIds };\n        wos.mockSetWaveObj(wsORef, updated);\n        return null;\n    });\n    setCallHandler(\"fileinfo\", async (_client, data: FileData) => DefaultMockFilesystem.fileInfo(data));\n    setCallHandler(\"fileread\", async (_client, data: FileData) => DefaultMockFilesystem.fileRead(data));\n    setCallHandler(\"filelist\", async (_client, data: FileListData) => DefaultMockFilesystem.fileList(data));\n    setCallHandler(\"filejoin\", async (_client, data: string[]) => DefaultMockFilesystem.fileJoin(data));\n    setStreamHandler(\"filereadstream\", async function* (_client, data: FileData) {\n        yield* DefaultMockFilesystem.fileReadStream(data);\n    });\n    setStreamHandler(\"fileliststream\", async function* (_client, data: FileListData) {\n        yield* DefaultMockFilesystem.fileListStream(data);\n    });\n    if (overrides) {\n        for (const key of Object.keys(overrides) as (keyof RpcOverrides)[]) {\n            const cmdName = key.slice(0, -\"Command\".length).toLowerCase();\n            setCallHandler(cmdName, overrides[key] as RpcHandlerType);\n        }\n    }\n    if (streamOverrides) {\n        for (const key of Object.keys(streamOverrides) as (keyof RpcStreamOverrides)[]) {\n            const cmdName = key.slice(0, -\"Command\".length).toLowerCase();\n            setStreamHandler(cmdName, streamOverrides[key] as RpcStreamHandlerType);\n        }\n    }\n    const rpc = new RpcApiType();\n    rpc.setMockRpcClient({\n        mockWshRpcCall(_client, command, data, _opts) {\n            const fn = callDispatchMap.get(command);\n            if (fn) {\n                return fn(_client, data, _opts);\n            }\n            console.log(\"[mock rpc call]\", command, data);\n            return Promise.resolve(null);\n        },\n        async *mockWshRpcStream(_client, command, data, _opts) {\n            const streamFn = streamDispatchMap.get(command);\n            if (streamFn) {\n                yield* streamFn(_client, data, _opts);\n                return;\n            }\n            const callFn = callDispatchMap.get(command);\n            if (callFn) {\n                yield await callFn(_client, data, _opts);\n                return;\n            }\n            console.log(\"[mock rpc stream]\", command, data);\n            yield null;\n        },\n    });\n    return {\n        rpc,\n        setRpcHandler: (command: string, fn: RpcHandlerType) => {\n            const cmdName = command.endsWith(\"Command\") ? command.slice(0, -\"Command\".length).toLowerCase() : command;\n            setCallHandler(cmdName, fn);\n        },\n        setRpcStreamHandler: (command: string, fn: RpcStreamHandlerType) => {\n            const cmdName = command.endsWith(\"Command\") ? command.slice(0, -\"Command\".length).toLowerCase() : command;\n            setStreamHandler(cmdName, fn);\n        },\n    };\n}\n\nexport function applyMockEnvOverrides(env: WaveEnv, newOverrides: MockEnv): MockWaveEnv {\n    const existing = (env as MockWaveEnv).mockEnv;\n    const merged = existing != null ? mergeMockEnv(existing, newOverrides) : newOverrides;\n    return makeMockWaveEnv(merged);\n}\n\nexport function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv {\n    const overrides: MockEnv = mockEnv ?? {};\n    const tabId = overrides.tabId ?? PreviewTabId;\n    const defaultMockWaveObjs: Record<string, WaveObj> = {\n        [`workspace:${PreviewWorkspaceId}`]: {\n            otype: \"workspace\",\n            oid: PreviewWorkspaceId,\n            version: 1,\n            name: \"Preview Workspace\",\n            tabids: [PreviewTabId],\n            activetabid: PreviewTabId,\n            meta: {},\n        } as Workspace,\n        [`tab:${PreviewTabId}`]: {\n            otype: \"tab\",\n            oid: PreviewTabId,\n            version: 1,\n            name: \"Preview Tab\",\n            blockids: [WebBlockId, SysinfoBlockId],\n            meta: {},\n        } as Tab,\n        [`block:${WebBlockId}`]: {\n            otype: \"block\",\n            oid: WebBlockId,\n            version: 1,\n            meta: {\n                view: \"web\",\n            },\n        } as Block,\n        [`block:${SysinfoBlockId}`]: {\n            otype: \"block\",\n            oid: SysinfoBlockId,\n            version: 1,\n            meta: {\n                view: \"sysinfo\",\n                connection: MockSysinfoConnection,\n                \"sysinfo:type\": \"CPU + Mem\",\n                \"graph:numpoints\": 90,\n            },\n        } as Block,\n    };\n    const defaultAtoms: Partial<GlobalAtomsType> = {\n        uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext),\n        staticTabId: atom(PreviewTabId),\n        workspaceId: atom(PreviewWorkspaceId),\n    };\n    const mergedOverrides: MockEnv = {\n        ...overrides,\n        tabId,\n        mockWaveObjs: { ...defaultMockWaveObjs, ...(overrides.mockWaveObjs ?? {}) },\n        atoms: { ...defaultAtoms, ...(overrides.atoms ?? {}) },\n    };\n    const platform = mergedOverrides.platform ?? PlatformMacOS;\n    const connStatusAtomCache = new Map<string, PrimitiveAtom<ConnStatus>>();\n    const waveObjectValueAtomCache = new Map<string, PrimitiveAtom<any>>();\n    const waveObjectDerivedAtomCache = new Map<string, Atom<any>>();\n    const blockMetaKeyAtomCache = new Map<string, Atom<any>>();\n    const connConfigKeyAtomCache = new Map<string, Atom<any>>();\n    const getWaveObjectAtom = <T extends WaveObj>(oref: string): PrimitiveAtom<T> => {\n        if (!waveObjectValueAtomCache.has(oref)) {\n            const obj = (mergedOverrides.mockWaveObjs?.[oref] ?? null) as T;\n            waveObjectValueAtomCache.set(oref, atom(obj) as PrimitiveAtom<T>);\n        }\n        return waveObjectValueAtomCache.get(oref) as PrimitiveAtom<T>;\n    };\n    const atoms = makeMockGlobalAtoms(\n        mergedOverrides.settings,\n        mergedOverrides.atoms,\n        mergedOverrides.tabId,\n        getWaveObjectAtom\n    );\n    const localHostDisplayNameAtom = atom<string>((get) => {\n        const configValue = get(atoms.settingsAtom)?.[\"conn:localhostdisplayname\"];\n        if (configValue != null) {\n            return configValue;\n        }\n        return \"user@localhost\";\n    });\n    const mockWosFns: MockWosFns = {\n        getWaveObjectAtom,\n        fullConfigAtom: atoms.fullConfigAtom,\n        platform,\n        mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => {\n            if (!waveObjectValueAtomCache.has(oref)) {\n                waveObjectValueAtomCache.set(oref, atom(null as WaveObj));\n            }\n            globalStore.set(waveObjectValueAtomCache.get(oref), obj);\n        },\n    };\n    const { rpc, setRpcHandler, setRpcStreamHandler } = makeMockRpc(mergedOverrides.rpc, mergedOverrides.rpcStreaming, mockWosFns);\n    const env = {\n        isMock: true,\n        mockEnv: mergedOverrides,\n        electron: {\n            ...previewElectronApi,\n            getPlatform: () => platform,\n            openExternal: (url: string) => {\n                window.open(url, \"_blank\");\n            },\n            ...mergedOverrides.electron,\n        },\n        rpc,\n        atoms,\n        getSettingsKeyAtom: makeMockSettingsKeyAtom(atoms.settingsAtom),\n        platform,\n        isDev: () => mergedOverrides.isDev ?? true,\n        isWindows: () => platform === PlatformWindows,\n        isMacOS: () => platform === PlatformMacOS,\n        createBlock:\n            mergedOverrides.createBlock ??\n            ((blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => {\n                console.log(\"[mock createBlock]\", blockDef, { magnified, ephemeral });\n                const newBlockId = crypto.randomUUID();\n                const newBlock: Block = {\n                    otype: \"block\",\n                    oid: newBlockId,\n                    version: 1,\n                    meta: blockDef.meta ?? {},\n                };\n                mockWosFns.mockSetWaveObj(`block:${newBlockId}`, newBlock);\n                const tabORef = `tab:${tabId}`;\n                const tabAtom = getWaveObjectAtom<Tab>(tabORef);\n                const currentTab = globalStore.get(tabAtom);\n                if (currentTab != null) {\n                    mockWosFns.mockSetWaveObj(tabORef, {\n                        ...currentTab,\n                        blockids: [...(currentTab.blockids ?? []), newBlockId],\n                    });\n                }\n                return Promise.resolve(newBlockId);\n            }),\n        showContextMenu: mergedOverrides.showContextMenu ?? showPreviewContextMenu,\n        getLocalHostDisplayNameAtom: () => {\n            return localHostDisplayNameAtom;\n        },\n        getConnStatusAtom: (conn: string) => {\n            if (!connStatusAtomCache.has(conn)) {\n                const connStatus = mergedOverrides.connStatus?.[conn] ?? makeDefaultConnStatus(conn);\n                connStatusAtomCache.set(conn, atom(connStatus));\n            }\n            return connStatusAtomCache.get(conn);\n        },\n        wos: {\n            getWaveObjectAtom: mockWosFns.getWaveObjectAtom,\n            getWaveObjectLoadingAtom: (oref: string) => {\n                const cacheKey = oref + \":loading\";\n                if (!waveObjectDerivedAtomCache.has(cacheKey)) {\n                    waveObjectDerivedAtomCache.set(cacheKey, atom(false));\n                }\n                return waveObjectDerivedAtomCache.get(cacheKey) as Atom<boolean>;\n            },\n            isWaveObjectNullAtom: (oref: string) => {\n                const cacheKey = oref + \":isnull\";\n                if (!waveObjectDerivedAtomCache.has(cacheKey)) {\n                    waveObjectDerivedAtomCache.set(\n                        cacheKey,\n                        atom((get) => get(env.wos.getWaveObjectAtom(oref)) == null)\n                    );\n                }\n                return waveObjectDerivedAtomCache.get(cacheKey) as Atom<boolean>;\n            },\n            useWaveObjectValue: <T extends WaveObj>(oref: string): [T, boolean] => {\n                const objAtom = env.wos.getWaveObjectAtom<T>(oref);\n                return [useAtomValue(objAtom), false];\n            },\n        },\n        getBlockMetaKeyAtom: <T extends keyof MetaType>(blockId: string, key: T) => {\n            const cacheKey = blockId + \"#meta-\" + key;\n            if (!blockMetaKeyAtomCache.has(cacheKey)) {\n                const metaAtom = atom<MetaType[T]>((get) => {\n                    const blockORef = \"block:\" + blockId;\n                    const blockAtom = env.wos.getWaveObjectAtom<Block>(blockORef);\n                    const blockData = get(blockAtom);\n                    return blockData?.meta?.[key] as MetaType[T];\n                });\n                blockMetaKeyAtomCache.set(cacheKey, metaAtom);\n            }\n            return blockMetaKeyAtomCache.get(cacheKey) as Atom<MetaType[T]>;\n        },\n        getConnConfigKeyAtom: <T extends keyof ConnKeywords>(connName: string, key: T) => {\n            const cacheKey = connName + \"#conn-\" + key;\n            if (!connConfigKeyAtomCache.has(cacheKey)) {\n                const keyAtom = atom<ConnKeywords[T]>((get) => {\n                    const fullConfig = get(atoms.fullConfigAtom);\n                    return fullConfig.connections?.[connName]?.[key];\n                });\n                connConfigKeyAtomCache.set(cacheKey, keyAtom);\n            }\n            return connConfigKeyAtomCache.get(cacheKey) as Atom<ConnKeywords[T]>;\n        },\n        services: null as any,\n        callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => {\n            const fn = mergedOverrides.services?.[service]?.[method];\n            if (fn) {\n                return fn(...args);\n            }\n            console.log(\"[mock callBackendService]\", service, method, args, noUIContext);\n            return Promise.resolve(null);\n        },\n        mockSetWaveObj: mockWosFns.mockSetWaveObj,\n        mockModels: new Map<any, any>(),\n        addRpcOverride: <K extends keyof RpcOverrides>(command: K, handler: RpcHandlerType) => {\n            setRpcHandler(command as string, handler);\n        },\n        addRpcStreamOverride: <K extends keyof RpcStreamOverrides>(command: K, handler: RpcStreamHandlerType) => {\n            setRpcStreamHandler(command as string, handler);\n        },\n    } as MockWaveEnv;\n    env.services = Object.fromEntries(\n        Object.entries(AllServiceTypes).map(([key, ServiceClass]) => [key, new ServiceClass(env)])\n    ) as any;\n    return env;\n}\n"
  },
  {
    "path": "frontend/preview/mock/preview-electron-api.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nconst previewElectronApi: ElectronApi = {\n    getAuthKey: () => \"\",\n    getIsDev: () => false,\n    getCursorPoint: () => ({ x: 0, y: 0 }) as Electron.Point,\n    getPlatform: () => \"darwin\",\n    getEnv: (_varName: string) => \"\",\n    getUserName: () => \"\",\n    getHostName: () => \"\",\n    getDataDir: () => \"\",\n    getConfigDir: () => \"\",\n    getHomeDir: () => \"\",\n    getWebviewPreload: () => \"\",\n    getAboutModalDetails: () => ({}) as AboutModalDetails,\n    getZoomFactor: () => 1.0,\n    showWorkspaceAppMenu: (_workspaceId: string) => {},\n    showBuilderAppMenu: (_builderId: string) => {},\n    showContextMenu: (_workspaceId: string, _menu: ElectronContextMenuItem[]) => {},\n    onContextMenuClick: (_callback: (id: string | null) => void) => {},\n    onNavigate: (_callback: (url: string) => void) => {},\n    onIframeNavigate: (_callback: (url: string) => void) => {},\n    downloadFile: (_path: string) => {},\n    openExternal: (_url: string) => {},\n    onFullScreenChange: (_callback: (isFullScreen: boolean) => void) => {},\n    onZoomFactorChange: (_callback: (zoomFactor: number) => void) => {},\n    onUpdaterStatusChange: (_callback: (status: UpdaterStatus) => void) => {},\n    getUpdaterStatus: () => \"up-to-date\",\n    getUpdaterChannel: () => \"\",\n    installAppUpdate: () => {},\n    onMenuItemAbout: (_callback: () => void) => {},\n    updateWindowControlsOverlay: (_rect: Dimensions) => {},\n    onReinjectKey: (_callback: (waveEvent: WaveKeyboardEvent) => void) => {},\n    setWebviewFocus: (_focusedId: number) => {},\n    registerGlobalWebviewKeys: (_keys: string[]) => {},\n    onControlShiftStateUpdate: (_callback: (state: boolean) => void) => {},\n    createWorkspace: () => {},\n    switchWorkspace: (_workspaceId: string) => {},\n    deleteWorkspace: (_workspaceId: string) => {},\n    setActiveTab: (_tabId: string) => {},\n    createTab: () => {},\n    closeTab: (_workspaceId: string, _tabId: string, _confirmClose: boolean) => Promise.resolve(false),\n    setWindowInitStatus: (_status: \"ready\" | \"wave-ready\") => {},\n    onWaveInit: (_callback: (initOpts: WaveInitOpts) => void) => {},\n    onBuilderInit: (_callback: (initOpts: BuilderInitOpts) => void) => {},\n    sendLog: (_log: string) => {},\n    onQuicklook: (_filePath: string) => {},\n    openNativePath: (_filePath: string) => {},\n    captureScreenshot: (_rect: Electron.Rectangle) => Promise.resolve(\"\"),\n    setKeyboardChordMode: () => {},\n    clearWebviewStorage: (_webContentsId: number) => Promise.resolve(),\n    setWaveAIOpen: (_isOpen: boolean) => {},\n    closeBuilderWindow: () => {},\n    incrementTermCommands: (_opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => {},\n    nativePaste: () => {},\n    openBuilder: (_appId?: string) => {},\n    setBuilderWindowAppId: (_appId: string) => {},\n    doRefresh: () => {},\n    saveTextFile: (_fileName: string, _content: string) => Promise.resolve(false),\n    setIsActive: async () => {},\n};\n\nfunction installPreviewElectronApi() {\n    (window as any).api = previewElectronApi;\n}\n\nexport { installPreviewElectronApi, previewElectronApi };\n"
  },
  {
    "path": "frontend/preview/mock/tabbar-mock.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { useWaveEnv, WaveEnv, WaveEnvContext } from \"@/app/waveenv/waveenv\";\nimport { applyMockEnvOverrides, MockWaveEnv } from \"@/preview/mock/mockwaveenv\";\nimport { PlatformMacOS } from \"@/util/platformutil\";\nimport { atom } from \"jotai\";\nimport React, { useMemo, useRef } from \"react\";\n\ntype PreviewTabEntry = {\n    tabId: string;\n    tabName: string;\n    badges?: Badge[] | null;\n    flagColor?: string | null;\n};\n\nfunction badgeBlockId(tabId: string, badgeId: string): string {\n    return `${tabId}-badge-${badgeId}`;\n}\n\nfunction makeTabWaveObj(tab: PreviewTabEntry): Tab {\n    const blockids = (tab.badges ?? []).map((b) => badgeBlockId(tab.tabId, b.badgeid));\n    return {\n        otype: \"tab\",\n        oid: tab.tabId,\n        version: 1,\n        name: tab.tabName,\n        blockids,\n        meta: tab.flagColor ? { \"tab:flagcolor\": tab.flagColor } : {},\n    } as Tab;\n}\n\nfunction makeMockBadgeEvents(): BadgeEvent[] {\n    const events: BadgeEvent[] = [];\n    for (const tab of TabBarMockTabs) {\n        for (const badge of tab.badges ?? []) {\n            events.push({ oref: `block:${badgeBlockId(tab.tabId, badge.badgeid)}`, badge });\n        }\n    }\n    return events;\n}\n\nexport const TabBarMockWorkspaceId = \"preview-workspace-1\";\n\nexport const TabBarMockTabs: PreviewTabEntry[] = [\n    { tabId: \"preview-tab-1\", tabName: \"Terminal\" },\n    {\n        tabId: \"preview-tab-2\",\n        tabName: \"Build Logs\",\n        badges: [\n            {\n                badgeid: \"01958000-0000-7000-0000-000000000001\",\n                icon: \"triangle-exclamation\",\n                color: \"#f59e0b\",\n                priority: 2,\n            },\n        ],\n    },\n    {\n        tabId: \"preview-tab-3\",\n        tabName: \"Deploy\",\n        badges: [\n            { badgeid: \"01958000-0000-7000-0000-000000000002\", icon: \"circle-check\", color: \"#4ade80\", priority: 3 },\n        ],\n        flagColor: \"#429dff\",\n    },\n    {\n        tabId: \"preview-tab-4\",\n        tabName: \"A Very Long Tab Name To Show Truncation\",\n        badges: [\n            { badgeid: \"01958000-0000-7000-0000-000000000003\", icon: \"bell\", color: \"#f87171\", priority: 2 },\n            { badgeid: \"01958000-0000-7000-0000-000000000004\", icon: \"circle-small\", color: \"#fbbf24\", priority: 1 },\n        ],\n    },\n    { tabId: \"preview-tab-5\", tabName: \"Wave AI\" },\n    { tabId: \"preview-tab-6\", tabName: \"Preview\", flagColor: \"#bf55ec\" },\n];\n\nfunction makeMockWorkspace(tabIds: string[]): Workspace {\n    return {\n        otype: \"workspace\",\n        oid: TabBarMockWorkspaceId,\n        version: 1,\n        name: \"Preview Workspace\",\n        tabids: tabIds,\n        activetabid: tabIds[1] ?? tabIds[0] ?? \"\",\n        meta: {},\n    } as Workspace;\n}\n\nexport function makeTabBarMockEnv(\n    baseEnv: WaveEnv,\n    envRef: React.RefObject<MockWaveEnv>,\n    platform: NodeJS.Platform\n): MockWaveEnv {\n    const initialTabIds = TabBarMockTabs.map((t) => t.tabId);\n    const mockWaveObjs: Record<string, WaveObj> = {\n        [`workspace:${TabBarMockWorkspaceId}`]: makeMockWorkspace(initialTabIds),\n    };\n    for (const tab of TabBarMockTabs) {\n        mockWaveObjs[`tab:${tab.tabId}`] = makeTabWaveObj(tab);\n    }\n    const env = applyMockEnvOverrides(baseEnv, {\n        tabId: TabBarMockTabs[1].tabId,\n        platform,\n        mockWaveObjs,\n        atoms: {\n            workspaceId: atom(TabBarMockWorkspaceId),\n            staticTabId: atom(TabBarMockTabs[1].tabId),\n        },\n        rpc: {\n            GetAllBadgesCommand: () => Promise.resolve(makeMockBadgeEvents()),\n        },\n        electron: {\n            createTab: () => {\n                const e = envRef.current;\n                if (e == null) return;\n                const newTabId = `preview-tab-${crypto.randomUUID()}`;\n                e.mockSetWaveObj(`tab:${newTabId}`, {\n                    otype: \"tab\",\n                    oid: newTabId,\n                    version: 1,\n                    name: \"New Tab\",\n                    blockids: [],\n                    meta: {},\n                } as Tab);\n                const ws = globalStore.get(e.wos.getWaveObjectAtom<Workspace>(`workspace:${TabBarMockWorkspaceId}`));\n                e.mockSetWaveObj(`workspace:${TabBarMockWorkspaceId}`, {\n                    ...ws,\n                    tabids: [...(ws.tabids ?? []), newTabId],\n                });\n                globalStore.set(e.atoms.staticTabId as any, newTabId);\n            },\n            closeTab: (_workspaceId: string, tabId: string) => {\n                const e = envRef.current;\n                if (e == null) return Promise.resolve(false);\n                const ws = globalStore.get(e.wos.getWaveObjectAtom<Workspace>(`workspace:${TabBarMockWorkspaceId}`));\n                const newTabIds = (ws.tabids ?? []).filter((id) => id !== tabId);\n                if (newTabIds.length === 0) {\n                    return Promise.resolve(false);\n                }\n                e.mockSetWaveObj(`workspace:${TabBarMockWorkspaceId}`, { ...ws, tabids: newTabIds });\n                if (globalStore.get(e.atoms.staticTabId) === tabId) {\n                    globalStore.set(e.atoms.staticTabId as any, newTabIds[0]);\n                }\n                return Promise.resolve(true);\n            },\n            setActiveTab: (tabId: string) => {\n                const e = envRef.current;\n                if (e == null) return;\n                globalStore.set(e.atoms.staticTabId as any, tabId);\n            },\n            showWorkspaceAppMenu: () => {\n                console.log(\"[preview] showWorkspaceAppMenu\");\n            },\n        },\n    });\n    envRef.current = env;\n    return env;\n}\n\ntype TabBarMockEnvProviderProps = {\n    children: React.ReactNode;\n};\n\nexport function TabBarMockEnvProvider({ children }: TabBarMockEnvProviderProps) {\n    const baseEnv = useWaveEnv();\n    const envRef = useRef<MockWaveEnv>(null);\n    const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, PlatformMacOS), []);\n    return <WaveEnvContext.Provider value={tabEnv}>{children}</WaveEnvContext.Provider>;\n}\nTabBarMockEnvProvider.displayName = \"TabBarMockEnvProvider\";\n"
  },
  {
    "path": "frontend/preview/mock/use-rpc-override.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport * as React from \"react\";\nimport { MockWaveEnv, RpcHandlerType, RpcOverrides, RpcStreamHandlerType, RpcStreamOverrides } from \"./mockwaveenv\";\n\nexport function useRpcOverride<K extends keyof RpcOverrides>(command: K, handler: RpcHandlerType): void {\n    const mockEnv = useWaveEnv() as MockWaveEnv;\n    const registeredRef = React.useRef(false);\n    if (!registeredRef.current) {\n        registeredRef.current = true;\n        mockEnv.addRpcOverride(command, handler);\n    }\n}\n\nexport function useRpcStreamOverride<K extends keyof RpcStreamOverrides>(command: K, handler: RpcStreamHandlerType): void {\n    const mockEnv = useWaveEnv() as MockWaveEnv;\n    const registeredRef = React.useRef(false);\n    if (!registeredRef.current) {\n        registeredRef.current = true;\n        mockEnv.addRpcStreamOverride(command, handler);\n    }\n}\n"
  },
  {
    "path": "frontend/preview/preview-contextmenu.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport {\n    autoUpdate,\n    flip,\n    FloatingPortal,\n    offset,\n    shift,\n    type Placement,\n    type VirtualElement,\n    useFloating,\n} from \"@floating-ui/react\";\nimport { cn } from \"@/util/util\";\nimport { memo, useEffect, useMemo, useRef, useState } from \"react\";\n\ntype PreviewContextMenuState = {\n    items: ContextMenuItem[];\n    x: number;\n    y: number;\n};\n\ntype PreviewContextMenuPanelProps = {\n    items: ContextMenuItem[];\n    point?: { x: number; y: number };\n    referenceElement?: HTMLElement;\n    placement: Placement;\n    depth: number;\n    parentPath: number[];\n    openPath: number[];\n    setOpenPath: (path: number[]) => void;\n    closeMenu: () => void;\n};\n\ntype PreviewContextMenuItemProps = {\n    item: ContextMenuItem;\n    itemPath: number[];\n    depth: number;\n    parentPath: number[];\n    openPath: number[];\n    setOpenPath: (path: number[]) => void;\n    closeMenu: () => void;\n};\n\nlet previewContextMenuListener: ((state: PreviewContextMenuState) => void) | null = null;\nconst previewContextMenuItemIds = new WeakMap<ContextMenuItem, string>();\n\nfunction makeVirtualElement(x: number, y: number): VirtualElement {\n    return {\n        getBoundingClientRect() {\n            return {\n                x,\n                y,\n                width: 0,\n                height: 0,\n                top: y,\n                right: x,\n                bottom: y,\n                left: x,\n                toJSON: () => ({}),\n            } as DOMRect;\n        },\n    };\n}\n\nfunction isPathOpen(openPath: number[], path: number[]): boolean {\n    if (path.length > openPath.length) {\n        return false;\n    }\n    return path.every((segment, index) => openPath[index] === segment);\n}\n\nfunction getVisibleItems(items: ContextMenuItem[]): ContextMenuItem[] {\n    return items.filter((item) => item.visible !== false);\n}\n\nfunction activateItem(item: ContextMenuItem, closeMenu: () => void): void {\n    closeMenu();\n    item.click?.();\n}\n\nfunction getPreviewContextMenuItemId(item: ContextMenuItem): string {\n    const existingId = previewContextMenuItemIds.get(item);\n    if (existingId != null) {\n        return existingId;\n    }\n    const newId = crypto.randomUUID();\n    previewContextMenuItemIds.set(item, newId);\n    return newId;\n}\n\nconst PreviewContextMenuItem = memo(\n    ({ item, itemPath, depth, parentPath, openPath, setOpenPath, closeMenu }: PreviewContextMenuItemProps) => {\n        const rowRef = useRef<HTMLDivElement>(null);\n        const submenuItems = getVisibleItems(item.submenu ?? []);\n        const hasSubmenu = submenuItems.length > 0;\n        const isDisabled = item.enabled === false;\n        const isHeader = item.type === \"header\";\n        const isSeparator = item.type === \"separator\";\n        const isChecked = item.type === \"checkbox\" || item.type === \"radio\" ? item.checked === true : false;\n        const isSubmenuOpen = hasSubmenu && isPathOpen(openPath, itemPath);\n\n        if (isSeparator) {\n            return <div className=\"my-0.5 border-t border-border\" role=\"separator\" />;\n        }\n\n        const handleMouseEnter = () => {\n            if (hasSubmenu) {\n                setOpenPath(itemPath);\n                return;\n            }\n            setOpenPath(parentPath);\n        };\n\n        const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {\n            e.stopPropagation();\n            if (isDisabled || isHeader) {\n                return;\n            }\n            if (hasSubmenu) {\n                setOpenPath(itemPath);\n                return;\n            }\n            activateItem(item, closeMenu);\n        };\n\n        return (\n            <>\n                <div\n                    ref={rowRef}\n                    role={item.type === \"checkbox\" ? \"menuitemcheckbox\" : item.type === \"radio\" ? \"menuitemradio\" : \"menuitem\"}\n                    aria-disabled={isDisabled}\n                    aria-checked={item.type === \"checkbox\" || item.type === \"radio\" ? isChecked : undefined}\n                    data-context-menu-item={item.label ?? item.type ?? \"item\"}\n                    className={cn(\n                        \"flex min-h-7 items-center gap-2 px-2.5 text-xs text-foreground select-none\",\n                        !isHeader && \"cursor-pointer\",\n                        isHeader && \"px-2.5 py-0.5 text-[10px] uppercase tracking-[0.08em] text-muted\",\n                        !isHeader && !isDisabled && \"hover:bg-hoverbg\",\n                        isDisabled && \"text-muted\",\n                        isSubmenuOpen && \"bg-hoverbg\"\n                    )}\n                    onMouseEnter={handleMouseEnter}\n                    onClick={handleClick}\n                >\n                    {isHeader ? (\n                        <span className=\"truncate\">{item.label}</span>\n                    ) : (\n                        <>\n                            <span className=\"flex w-3.5 items-center justify-center text-center text-[10px]\">\n                                {isChecked ? <i className=\"fa fa-check\" /> : null}\n                            </span>\n                            <div className=\"flex min-w-0 flex-1 flex-col\">\n                                <span className=\"truncate\">{item.label}</span>\n                                {item.sublabel ? <span className=\"truncate text-[10px] text-muted\">{item.sublabel}</span> : null}\n                            </div>\n                            {hasSubmenu ? (\n                                <span className=\"ml-2 text-[10px] text-muted\">\n                                    <i className=\"fa fa-chevron-right\" />\n                                </span>\n                            ) : null}\n                        </>\n                    )}\n                </div>\n                {hasSubmenu && isSubmenuOpen && rowRef.current != null ? (\n                    <PreviewContextMenuPanel\n                        items={submenuItems}\n                        referenceElement={rowRef.current}\n                        placement=\"right-start\"\n                        depth={depth + 1}\n                        parentPath={itemPath}\n                        openPath={openPath}\n                        setOpenPath={setOpenPath}\n                        closeMenu={closeMenu}\n                    />\n                ) : null}\n            </>\n        );\n    }\n);\n\nPreviewContextMenuItem.displayName = \"PreviewContextMenuItem\";\n\nconst PreviewContextMenuPanel = memo(\n    ({ items, point, referenceElement, placement, depth, parentPath, openPath, setOpenPath, closeMenu }: PreviewContextMenuPanelProps) => {\n        const visibleItems = getVisibleItems(items);\n        const virtualReference = useMemo(() => {\n            if (point == null) {\n                return null;\n            }\n            return makeVirtualElement(point.x, point.y);\n        }, [point]);\n        const { refs, floatingStyles } = useFloating({\n            open: true,\n            placement,\n            strategy: \"fixed\",\n            whileElementsMounted: autoUpdate,\n            middleware: [\n                offset(depth === 0 ? 4 : { mainAxis: -4, crossAxis: -4 }),\n                flip({ padding: 8 }),\n                shift({ padding: 8 }),\n            ],\n        });\n\n        useEffect(() => {\n            if (referenceElement != null) {\n                refs.setReference(referenceElement);\n                return;\n            }\n            refs.setPositionReference(virtualReference);\n        }, [referenceElement, refs, virtualReference]);\n\n        if (visibleItems.length === 0) {\n            return null;\n        }\n\n        return (\n            <div\n                ref={refs.setFloating}\n                style={floatingStyles}\n                className=\"min-w-[180px] overflow-visible rounded-md border border-border bg-modalbg py-0.5 shadow-2xl\"\n                role=\"menu\"\n            >\n                {visibleItems.map((item, index) => (\n                    <PreviewContextMenuItem\n                        key={getPreviewContextMenuItemId(item)}\n                        item={item}\n                        itemPath={[...parentPath, index]}\n                        depth={depth}\n                        parentPath={parentPath}\n                        openPath={openPath}\n                        setOpenPath={setOpenPath}\n                        closeMenu={closeMenu}\n                    />\n                ))}\n            </div>\n        );\n    }\n);\n\nPreviewContextMenuPanel.displayName = \"PreviewContextMenuPanel\";\n\nexport function showPreviewContextMenu(menu: ContextMenuItem[], e: React.MouseEvent): void {\n    e.stopPropagation();\n    e.preventDefault();\n    previewContextMenuListener?.({\n        items: menu,\n        x: e.clientX,\n        y: e.clientY,\n    });\n}\n\nexport const PreviewContextMenu = memo(() => {\n    const [menuState, setMenuState] = useState<PreviewContextMenuState | null>(null);\n    const [openPath, setOpenPath] = useState<number[]>([]);\n    const portalRef = useRef<HTMLDivElement>(null);\n\n    const closeMenu = () => {\n        setMenuState(null);\n        setOpenPath([]);\n    };\n\n    useEffect(() => {\n        previewContextMenuListener = (state) => {\n            setMenuState(state);\n            setOpenPath([]);\n        };\n        return () => {\n            previewContextMenuListener = null;\n        };\n    }, []);\n\n    useEffect(() => {\n        if (menuState == null) {\n            return;\n        }\n\n        const handlePointerDown = (event: PointerEvent) => {\n            if (portalRef.current?.contains(event.target as Node)) {\n                return;\n            }\n            closeMenu();\n        };\n        const handleKeyDown = (event: KeyboardEvent) => {\n            if (event.key === \"Escape\") {\n                closeMenu();\n            }\n        };\n\n        document.addEventListener(\"pointerdown\", handlePointerDown, true);\n        document.addEventListener(\"keydown\", handleKeyDown);\n        window.addEventListener(\"blur\", closeMenu);\n        window.addEventListener(\"resize\", closeMenu);\n        window.addEventListener(\"scroll\", closeMenu, true);\n        return () => {\n            document.removeEventListener(\"pointerdown\", handlePointerDown, true);\n            document.removeEventListener(\"keydown\", handleKeyDown);\n            window.removeEventListener(\"blur\", closeMenu);\n            window.removeEventListener(\"resize\", closeMenu);\n            window.removeEventListener(\"scroll\", closeMenu, true);\n        };\n    }, [menuState]);\n\n    if (menuState == null) {\n        return null;\n    }\n\n    return (\n        <FloatingPortal>\n            <div ref={portalRef}>\n                <PreviewContextMenuPanel\n                    items={menuState.items}\n                    point={{ x: menuState.x, y: menuState.y }}\n                    placement=\"bottom-start\"\n                    depth={0}\n                    parentPath={[]}\n                    openPath={openPath}\n                    setOpenPath={setOpenPath}\n                    closeMenu={closeMenu}\n                />\n            </div>\n        </FloatingPortal>\n    );\n});\n\nPreviewContextMenu.displayName = \"PreviewContextMenu\";\n"
  },
  {
    "path": "frontend/preview/preview.css",
    "content": "/* Copyright 2026, Command Line Inc.\n   SPDX-License-Identifier: Apache-2.0 */\n\n/* Re-export the main tailwind setup, adding extra @source so Tailwind v4\n   scans the full frontend/app tree (the preview vite root is frontend/preview/,\n   so the automatic scan would otherwise miss frontend/app/**). */\n@import \"../tailwindsetup.css\";\n@source \"../app\";\n"
  },
  {
    "path": "frontend/preview/preview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport Logo from \"@/app/asset/logo.svg\";\nimport { ErrorBoundary } from \"@/app/element/errorboundary\";\nimport { getAtoms, initGlobalAtoms } from \"@/app/store/global-atoms\";\nimport { GlobalModel } from \"@/app/store/global-model\";\nimport { globalStore } from \"@/app/store/jotaiStore\";\nimport { getTabModelByTabId, TabModelContext } from \"@/app/store/tab-model\";\nimport { WaveEnvContext } from \"@/app/waveenv/waveenv\";\nimport { loadFonts } from \"@/util/fontutil\";\nimport { Provider } from \"jotai\";\nimport React, { lazy, Suspense, useRef } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport { makeMockWaveEnv, PreviewClientId, PreviewTabId, PreviewWindowId } from \"./mock/mockwaveenv\";\nimport { installPreviewElectronApi } from \"./mock/preview-electron-api\";\nimport { PreviewContextMenu } from \"./preview-contextmenu\";\n\nimport \"overlayscrollbars/overlayscrollbars.css\";\nimport \"../app/app.scss\";\n\n// preview.css should come *after* app.scss (don't remove the newline above otherwise prettier will reorder these imports)\n// preview.css re-exports tailwindsetup.css and adds @source \"../app\" so Tailwind v4 scans frontend/app/** for class names\nimport \"./preview.css\";\n\n// Vite glob import — statically analyzed at build time, lazily loaded at runtime.\n// Each *.preview.tsx file is auto-discovered; its filename (minus the suffix) becomes the key.\n// Files may use a default export or any named export — the first export found is used as the component.\nconst previewModules = import.meta.glob<{ default?: React.ComponentType; [key: string]: unknown }>(\n    \"./previews/*.preview.tsx\"\n);\n\n// Derive a human-readable key from the file path, e.g.:\n//   \"./previews/modal-about.preview.tsx\"  →  \"modal-about\"\nfunction pathToKey(path: string): string {\n    return path.replace(/^\\.\\/previews\\//, \"\").replace(/\\.preview\\.tsx$/, \"\");\n}\n\n// Build a map of key → lazy React component.\n// Each preview file is expected to have a default export that is the preview component.\nconst previews: Record<string, React.LazyExoticComponent<React.ComponentType>> = Object.fromEntries(\n    Object.entries(previewModules).map(([path, loader]) => [\n        pathToKey(path),\n        lazy(() =>\n            loader().then((mod) => ({ default: (mod.default ?? Object.values(mod)[0]) as React.ComponentType }))\n        ),\n    ])\n);\n\nfunction PreviewIndex() {\n    return (\n        <div className=\"min-h-screen bg-background text-foreground font-sans flex flex-col items-center justify-center gap-6\">\n            <div className=\"flex flex-col items-center gap-3\">\n                <Logo />\n                <h1 className=\"text-title font-semibold tracking-tight text-foreground\">Wave Preview Server</h1>\n            </div>\n\n            <div className=\"w-px h-8 bg-border\" />\n\n            <div className=\"flex flex-col items-center gap-3 max-w-[1200px] w-full px-4\">\n                <p className=\"text-muted text-xs mb-1\">Available previews:</p>\n                <div className=\"flex flex-wrap gap-2.5 justify-center\">\n                    {Object.keys(previews).map((name) => (\n                        <a\n                            key={name}\n                            href={`?preview=${name}`}\n                            className=\"w-[220px] font-mono bg-accentbg px-3 py-1.5 rounded text-sm hover:bg-accent/80 transition-colors overflow-hidden text-ellipsis whitespace-nowrap block text-foreground!\"\n                        >\n                            {name}\n                        </a>\n                    ))}\n                </div>\n            </div>\n        </div>\n    );\n}\n\nfunction PreviewHeader({ previewName }: { previewName: string }) {\n    return (\n        <div\n            className=\"fixed top-0 left-0 right-0 flex items-center gap-3 px-4 py-2 bg-panel border-b border-border\"\n            style={{ zIndex: 100000 }}\n        >\n            <a\n                href=\"/\"\n                className=\"flex items-center gap-1.5 text-accent text-sm hover:opacity-80 transition-opacity font-mono\"\n            >\n                ← index\n            </a>\n            <div className=\"w-px h-4 bg-border\" />\n            <span className=\"text-muted text-xs font-mono\">{previewName}</span>\n        </div>\n    );\n}\n\nfunction PreviewRoot() {\n    const waveEnvRef = useRef(makeMockWaveEnv());\n    return (\n        <Provider store={globalStore}>\n            <WaveEnvContext.Provider value={waveEnvRef.current}>\n                <TabModelContext.Provider value={getTabModelByTabId(PreviewTabId, waveEnvRef.current)}>\n                    <PreviewApp />\n                    <PreviewContextMenu />\n                </TabModelContext.Provider>\n            </WaveEnvContext.Provider>\n        </Provider>\n    );\n}\n\nfunction PreviewApp() {\n    const params = new URLSearchParams(window.location.search);\n    const previewName = params.get(\"preview\");\n\n    if (previewName) {\n        const PreviewComponent = previews[previewName];\n        if (PreviewComponent) {\n            return (\n                <>\n                    <PreviewHeader previewName={previewName} />\n                    <div className=\"h-screen overflow-y-auto bg-background text-foreground font-sans flex flex-col items-center pt-12 pb-8\">\n                        <ErrorBoundary>\n                            <Suspense fallback={null}>\n                                <PreviewComponent />\n                            </Suspense>\n                        </ErrorBoundary>\n                    </div>\n                </>\n            );\n        }\n        return (\n            <>\n                <PreviewHeader previewName={previewName} />\n                <div className=\"min-h-screen bg-background text-foreground font-sans flex flex-col items-center justify-center gap-4\">\n                    <p className=\"text-error\">Preview not found: {previewName}</p>\n                    <a href=\"/\" className=\"text-accent text-sm hover:opacity-80\">\n                        ← Back to index\n                    </a>\n                </div>\n            </>\n        );\n    }\n\n    return <PreviewIndex />;\n}\n\nfunction initPreview() {\n    installPreviewElectronApi();\n    const initOpts = {\n        tabId: PreviewTabId,\n        windowId: PreviewWindowId,\n        clientId: PreviewClientId,\n        environment: \"renderer\",\n        platform: \"darwin\",\n        isPreview: true,\n    } as GlobalInitOptions;\n    initGlobalAtoms(initOpts);\n    globalStore.set(getAtoms().fullConfigAtom, {} as FullConfigType);\n    GlobalModel.getInstance().initialize(initOpts);\n    loadFonts();\n    const container = document.getElementById(\"main\")!;\n    let root = (container as any).__reactRoot;\n    if (!root) {\n        root = createRoot(container);\n        (container as any).__reactRoot = root;\n    }\n    root.render(<PreviewRoot />);\n}\n\ninitPreview();\n"
  },
  {
    "path": "frontend/preview/previews/.gitkeep",
    "content": ""
  },
  {
    "path": "frontend/preview/previews/aifilediff.preview-util.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { stringToBase64 } from \"@/util/util\";\n\nexport const DefaultAiFileDiffChatId = \"preview-aifilediff-chat\";\nexport const DefaultAiFileDiffToolCallId = \"preview-aifilediff-toolcall\";\nexport const DefaultAiFileDiffFileName = \"src/lib/greeting.ts\";\n\nexport const DefaultAiFileDiffOriginal = `export function greet(name: string) {\n    return \"Hello \" + name;\n}\n\nexport function greetAll(names: string[]) {\n    return names.map(greet).join(\"\\\\n\");\n}\n`;\n\nexport const DefaultAiFileDiffModified = `export function greet(name: string) {\n    const normalizedName = name.trim() || \"friend\";\n    return \\`Hello, \\${normalizedName}!\\`;\n}\n\nexport function greetAll(names: string[]) {\n    return names.map(greet).join(\"\\\\n\");\n}\n`;\n\nexport function makeMockAiFileDiffResponse(\n    original = DefaultAiFileDiffOriginal,\n    modified = DefaultAiFileDiffModified\n): CommandWaveAIGetToolDiffRtnData {\n    return {\n        originalcontents64: stringToBase64(original),\n        modifiedcontents64: stringToBase64(modified),\n    };\n}\n"
  },
  {
    "path": "frontend/preview/previews/aifilediff.preview.test.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { base64ToString } from \"@/util/util\";\nimport { describe, expect, it } from \"vitest\";\nimport {\n    DefaultAiFileDiffModified,\n    DefaultAiFileDiffOriginal,\n    makeMockAiFileDiffResponse,\n} from \"./aifilediff.preview-util\";\n\ndescribe(\"aifilediff preview helpers\", () => {\n    it(\"encodes the default diff content for the mock rpc response\", () => {\n        const response = makeMockAiFileDiffResponse();\n\n        expect(base64ToString(response.originalcontents64)).toBe(DefaultAiFileDiffOriginal);\n        expect(base64ToString(response.modifiedcontents64)).toBe(DefaultAiFileDiffModified);\n    });\n\n    it(\"accepts custom original and modified content\", () => {\n        const response = makeMockAiFileDiffResponse(\"before\", \"after\");\n\n        expect(base64ToString(response.originalcontents64)).toBe(\"before\");\n        expect(base64ToString(response.modifiedcontents64)).toBe(\"after\");\n    });\n});\n"
  },
  {
    "path": "frontend/preview/previews/aifilediff.preview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Block } from \"@/app/block/block\";\nimport { useWaveEnv } from \"@/app/waveenv/waveenv\";\nimport * as React from \"react\";\nimport { makeMockNodeModel } from \"../mock/mock-node-model\";\nimport { useRpcOverride } from \"../mock/use-rpc-override\";\nimport {\n    DefaultAiFileDiffChatId,\n    DefaultAiFileDiffFileName,\n    DefaultAiFileDiffToolCallId,\n    makeMockAiFileDiffResponse,\n} from \"./aifilediff.preview-util\";\n\nconst PreviewNodeId = \"preview-aifilediff-node\";\n\nexport function AiFileDiffPreview() {\n    const env = useWaveEnv();\n    const [blockId, setBlockId] = React.useState<string>(null);\n\n    useRpcOverride(\"WaveAIGetToolDiffCommand\", async (_client, data) => {\n        if (data.chatid !== DefaultAiFileDiffChatId || data.toolcallid !== DefaultAiFileDiffToolCallId) {\n            return null;\n        }\n        return makeMockAiFileDiffResponse();\n    });\n\n    React.useEffect(() => {\n        env.createBlock(\n            {\n                meta: {\n                    view: \"aifilediff\",\n                    file: DefaultAiFileDiffFileName,\n                    \"aifilediff:chatid\": DefaultAiFileDiffChatId,\n                    \"aifilediff:toolcallid\": DefaultAiFileDiffToolCallId,\n                },\n            },\n            false,\n            false\n        ).then((id) => setBlockId(id));\n    }, []);\n\n    const nodeModel = React.useMemo(\n        () => (blockId != null ? makeMockNodeModel({ nodeId: PreviewNodeId, blockId }) : null),\n        [blockId]\n    );\n\n    if (blockId == null || nodeModel == null) {\n        return null;\n    }\n\n    return (\n        <div className=\"flex w-full max-w-[1120px] flex-col gap-2 px-6 py-6\">\n            <div className=\"text-xs text-muted font-mono\">full aifilediff block (mock WOS + mock WaveAI diff RPC)</div>\n            <div className=\"rounded-md border border-border bg-panel p-4\">\n                <div className=\"h-[720px]\">\n                    <Block preview={false} nodeModel={nodeModel} />\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/preview/previews/modal-about.preview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { AboutModalV } from \"@/app/modals/about\";\n\nexport function AboutModalPreview() {\n    return (\n        <AboutModalV\n            versionString=\"0.11.0 (1740000000)\"\n            updaterChannel=\"stable\"\n            onClose={() => console.log(\"close\")}\n        />\n    );\n}\n"
  },
  {
    "path": "frontend/preview/previews/onboarding.preview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport Logo from \"@/app/asset/logo.svg\";\nimport { InitPage, NoTelemetryStarPage } from \"@/app/onboarding/onboarding\";\nimport { OnboardingGradientBg } from \"@/app/onboarding/onboarding-common\";\nimport { DurableSessionPage } from \"@/app/onboarding/onboarding-durable\";\nimport { FilesPage, MagnifyBlocksPage, WaveAIPage } from \"@/app/onboarding/onboarding-features\";\nimport { StarAskPage } from \"@/app/onboarding/onboarding-starask\";\nimport { UpgradeMinorWelcomePage } from \"@/app/onboarding/onboarding-upgrade-minor\";\nimport { UpgradeOnboardingFooter, UpgradeOnboardingVersions } from \"@/app/onboarding/onboarding-upgrade-patch\";\n\nfunction OnboardingModalWrapper({ width, children }: { width: string; children: React.ReactNode }) {\n    return (\n        <div className={`${width} rounded-[10px] p-[30px] relative overflow-hidden bg-panel`}>\n            <OnboardingGradientBg />\n            <div className=\"relative z-10 flex flex-col w-full h-full\">{children}</div>\n        </div>\n    );\n}\n\nfunction OnboardingFeaturesV() {\n    const noop = () => {};\n    return (\n        <div className=\"flex flex-col w-full gap-8\">\n            <OnboardingModalWrapper width=\"w-[560px]\">\n                <InitPage isCompact={false} telemetryUpdateFn={async () => {}} />\n            </OnboardingModalWrapper>\n            <OnboardingModalWrapper width=\"w-[560px]\">\n                <NoTelemetryStarPage isCompact={false} />\n            </OnboardingModalWrapper>\n            <OnboardingModalWrapper width=\"w-[800px]\">\n                <WaveAIPage onNext={noop} onSkip={noop} />\n            </OnboardingModalWrapper>\n            <OnboardingModalWrapper width=\"w-[800px]\">\n                <DurableSessionPage onNext={noop} onSkip={noop} onPrev={noop} />\n            </OnboardingModalWrapper>\n            <OnboardingModalWrapper width=\"w-[800px]\">\n                <MagnifyBlocksPage onNext={noop} onSkip={noop} onPrev={noop} />\n            </OnboardingModalWrapper>\n            <OnboardingModalWrapper width=\"w-[800px]\">\n                <FilesPage onFinish={noop} onPrev={noop} />\n            </OnboardingModalWrapper>\n        </div>\n    );\n}\n\nfunction UpgradeOnboardingPatchV() {\n    const noop = () => {};\n    return (\n        <div className=\"flex flex-col gap-6 w-full max-w-[900px]\">\n            {UpgradeOnboardingVersions.map((version, idx) => {\n                const hasPrev = idx > 0;\n                const hasNext = idx < UpgradeOnboardingVersions.length - 1;\n                return (\n                    <OnboardingModalWrapper key={version.version} width=\"w-[650px]\">\n                        <header className=\"flex flex-col gap-2 border-b-0 p-0 mt-1 mb-6 w-full unselectable flex-shrink-0\">\n                            <div className=\"flex justify-center\">\n                                <Logo />\n                            </div>\n                            <div className=\"text-center text-[25px] font-normal text-foreground\">\n                                Wave {version.version} Update\n                            </div>\n                        </header>\n                        <div className=\"flex-1\">{version.content()}</div>\n                        <UpgradeOnboardingFooter\n                            hasPrev={hasPrev}\n                            hasNext={hasNext}\n                            prevText={version.prevText}\n                            nextText={version.nextText}\n                            onPrev={noop}\n                            onNext={noop}\n                            onClose={noop}\n                        />\n                    </OnboardingModalWrapper>\n                );\n            })}\n        </div>\n    );\n}\n\nfunction UpgradeOnboardingMinorV() {\n    const noop = () => {};\n    return (\n        <OnboardingModalWrapper width=\"w-[600px]\">\n            <UpgradeMinorWelcomePage onStarClick={noop} onAlreadyStarred={noop} onMaybeLater={noop} />\n        </OnboardingModalWrapper>\n    );\n}\n\nfunction StarAskV() {\n    const noop = () => {};\n    return (\n        <OnboardingModalWrapper width=\"w-[500px]\">\n            <StarAskPage onClose={noop} />\n        </OnboardingModalWrapper>\n    );\n}\n\nexport function OnboardingPreview() {\n    return (\n        <div className=\"w-full max-w-[1300px] py-10 px-4 flex flex-col gap-8\">\n            <div className=\"text-sm font-mono text-muted\">Onboarding features</div>\n            <OnboardingFeaturesV />\n            <div className=\"text-sm font-mono text-muted mt-6\">Onboarding minor upgrade</div>\n            <UpgradeOnboardingMinorV />\n            <div className=\"text-sm font-mono text-muted mt-6\">Onboarding star ask</div>\n            <StarAskV />\n            <div className=\"text-sm font-mono text-muted mt-6\">Onboarding patch updates</div>\n            <UpgradeOnboardingPatchV />\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/preview/previews/sysinfo.preview-util.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nexport const DefaultSysinfoHistoryPoints = 140;\nexport const MockSysinfoConnection = \"local\";\n\nconst MockMemoryTotal = 32;\nconst MockCoreCount = 6;\n\nfunction clamp(value: number, minValue: number, maxValue: number): number {\n    return Math.min(maxValue, Math.max(minValue, value));\n}\n\nfunction round1(value: number): number {\n    return Math.round(value * 10) / 10;\n}\n\nexport function makeMockSysinfoEvent(\n    ts: number,\n    step: number,\n    scope = MockSysinfoConnection\n): Extract<WaveEvent, { event: \"sysinfo\" }> {\n    const baseCpu = clamp(42 + 18 * Math.sin(step / 6) + 8 * Math.cos(step / 3.5), 8, 96);\n    const memUsed = clamp(12 + 4 * Math.sin(step / 10) + 2 * Math.cos(step / 7), 6, MockMemoryTotal - 4);\n    const memAvailable = clamp(MockMemoryTotal - memUsed + 1.5, 0, MockMemoryTotal);\n    const values: Record<string, number> = {\n        cpu: round1(baseCpu),\n        \"mem:total\": MockMemoryTotal,\n        \"mem:used\": round1(memUsed),\n        \"mem:free\": round1(MockMemoryTotal - memUsed),\n        \"mem:available\": round1(memAvailable),\n    };\n\n    for (let i = 0; i < MockCoreCount; i++) {\n        const coreCpu = clamp(baseCpu + 10 * Math.sin(step / 4 + i) + i - 3, 2, 100);\n        values[`cpu:${i}`] = round1(coreCpu);\n    }\n\n    return {\n        event: \"sysinfo\",\n        scopes: [scope],\n        data: {\n            ts,\n            values,\n        },\n    };\n}\n\nexport function makeMockSysinfoHistory(\n    numPoints = DefaultSysinfoHistoryPoints,\n    endTs = Date.now()\n): Extract<WaveEvent, { event: \"sysinfo\" }>[] {\n    const history: Extract<WaveEvent, { event: \"sysinfo\" }>[] = [];\n    const startTs = endTs - (numPoints - 1) * 1000;\n\n    for (let i = 0; i < numPoints; i++) {\n        history.push(makeMockSysinfoEvent(startTs + i * 1000, i));\n    }\n\n    return history;\n}\n"
  },
  {
    "path": "frontend/preview/previews/sysinfo.preview.test.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { describe, expect, it } from \"vitest\";\nimport { DefaultSysinfoHistoryPoints, makeMockSysinfoEvent, makeMockSysinfoHistory } from \"./sysinfo.preview-util\";\n\ndescribe(\"sysinfo preview helpers\", () => {\n    it(\"creates sysinfo events with the expected metrics\", () => {\n        const event = makeMockSysinfoEvent(1000, 3);\n\n        expect(event.event).toBe(\"sysinfo\");\n        expect(event.scopes).toEqual([\"local\"]);\n        expect(event.data.ts).toBe(1000);\n        expect(event.data.values.cpu).toBeGreaterThanOrEqual(0);\n        expect(event.data.values.cpu).toBeLessThanOrEqual(100);\n        expect(event.data.values[\"mem:used\"]).toBeGreaterThan(0);\n        expect(event.data.values[\"mem:total\"]).toBeGreaterThan(event.data.values[\"mem:used\"]);\n        expect(event.data.values[\"cpu:0\"]).toBeTypeOf(\"number\");\n    });\n\n    it(\"creates evenly spaced sysinfo history\", () => {\n        const history = makeMockSysinfoHistory(4, 4000);\n\n        expect(history).toHaveLength(4);\n        expect(history.map((event) => event.data.ts)).toEqual([1000, 2000, 3000, 4000]);\n    });\n\n    it(\"uses the default history length\", () => {\n        expect(makeMockSysinfoHistory()).toHaveLength(DefaultSysinfoHistoryPoints);\n    });\n});\n"
  },
  {
    "path": "frontend/preview/previews/sysinfo.preview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Block } from \"@/app/block/block\";\nimport { handleWaveEvent } from \"@/app/store/wps\";\nimport * as React from \"react\";\nimport { makeMockNodeModel } from \"../mock/mock-node-model\";\nimport { SysinfoBlockId } from \"../mock/mockwaveenv\";\nimport { useRpcOverride } from \"../mock/use-rpc-override\";\nimport {\n    DefaultSysinfoHistoryPoints,\n    makeMockSysinfoEvent,\n    makeMockSysinfoHistory,\n    MockSysinfoConnection,\n} from \"./sysinfo.preview-util\";\n\nconst PreviewNodeId = \"preview-sysinfo-node\";\n\nexport default function SysinfoPreview() {\n    const historyRef = React.useRef(makeMockSysinfoHistory());\n    const nodeModel = React.useMemo(\n        () => makeMockNodeModel({ nodeId: PreviewNodeId, blockId: SysinfoBlockId, innerRect: { width: \"920px\", height: \"560px\" }, numLeafs: 2 }),\n        []\n    );\n\n    useRpcOverride(\"EventReadHistoryCommand\", async (_client, data) => {\n        if (data.event !== \"sysinfo\" || data.scope !== MockSysinfoConnection) {\n            return [];\n        }\n        const maxItems = data.maxitems ?? historyRef.current.length;\n        return historyRef.current.slice(-maxItems);\n    });\n\n    React.useEffect(() => {\n        let nextStep = historyRef.current.length;\n        let nextTs = (historyRef.current[historyRef.current.length - 1]?.data?.ts ?? Date.now()) + 1000;\n        const intervalId = window.setInterval(() => {\n            const nextEvent = makeMockSysinfoEvent(nextTs, nextStep);\n            historyRef.current = [...historyRef.current.slice(-(DefaultSysinfoHistoryPoints - 1)), nextEvent];\n            handleWaveEvent(nextEvent);\n            nextStep++;\n            nextTs += 1000;\n        }, 1000);\n\n        return () => {\n            window.clearInterval(intervalId);\n        };\n    }, []);\n\n    return (\n        <div className=\"flex w-full max-w-[980px] flex-col gap-2 px-6 py-6\">\n            <div className=\"text-xs text-muted font-mono\">full sysinfo block (mock WOS + FE-only WPS events)</div>\n            <div className=\"rounded-md border border-border bg-panel p-4\">\n                <div className=\"h-[620px]\">\n                    <Block preview={false} nodeModel={nodeModel} />\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/preview/previews/tab.preview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { TabV } from \"@/app/tab/tab\";\nimport { useEffect, useRef, useState } from \"react\";\n\nconst TAB_WIDTH = 130;\nconst TAB_HEIGHT = 26;\n\ninterface PreviewTabEntry {\n    tabId: string;\n    tabName: string;\n    active: boolean;\n    badges?: Badge[] | null;\n    flagColor?: string | null;\n}\n\nconst tabDefs: PreviewTabEntry[] = [\n    { tabId: \"preview-tab-1\", tabName: \"Terminal\", active: false },\n    {\n        tabId: \"preview-tab-2\",\n        tabName: \"My Tab\",\n        active: true,\n        badges: [\n            { badgeid: \"b2\", icon: \"circle-check\", color: \"#4ade80\", priority: 3 },\n            { badgeid: \"b1\", icon: \"circle-small\", color: \"#fbbf24\", priority: 1 },\n            { badgeid: \"b3\", icon: \"circle-small\", color: \"red\", priority: 1 },\n        ],\n    },\n    {\n        tabId: \"preview-tab-2b\",\n        tabName: \"My Tab 2\",\n        active: false,\n        badges: [\n            { badgeid: \"b2\", icon: \"bell\", color: \"#4ade80\", priority: 3 },\n            { badgeid: \"b1\", icon: \"circle-small\", color: \"red\", priority: 1 },\n        ],\n    },\n    { tabId: \"preview-tab-3\", tabName: \"T3\", active: false, flagColor: \"#4ade80\" },\n    {\n        tabId: \"preview-tab-4\",\n        tabName: \"1 Badge\",\n        active: false,\n        badges: [{ badgeid: \"b1\", icon: \"circle-small\", color: \"#fbbf24\", priority: 1 }],\n        flagColor: \"#fbbf24\",\n    },\n    {\n        tabId: \"preview-tab-5\",\n        tabName: \"3 Badges\",\n        active: false,\n        badges: [\n            { badgeid: \"b1\", icon: \"circle-small\", color: \"#fbbf24\", priority: 1 },\n            { badgeid: \"b2\", icon: \"circle-check\", color: \"#4ade80\", priority: 3 },\n            { badgeid: \"b3\", icon: \"triangle-exclamation\", color: \"#f87171\", priority: 2 },\n            { badgeid: \"b4\", icon: \"bell\", color: \"#f87171\", priority: 2 },\n        ],\n    },\n];\n\nexport function TabPreview() {\n    const [tabNames, setTabNames] = useState<Record<string, string>>(\n        Object.fromEntries(tabDefs.map((t) => [t.tabId, t.tabName]))\n    );\n    const [activeTabId, setActiveTabId] = useState<string>(tabDefs.find((t) => t.active)?.tabId ?? tabDefs[0].tabId);\n    const tabRefs = useRef<Record<string, HTMLDivElement | null>>({});\n\n    // The real tabbar imperatively sets opacity: 1 and transform after calculating\n    // tab positions. Tabs start at opacity: 0 in CSS, so we mirror that here.\n    useEffect(() => {\n        tabDefs.forEach((tab, index) => {\n            const el = tabRefs.current[tab.tabId];\n            if (el) {\n                el.style.opacity = \"1\";\n                el.style.transform = `translate3d(${index * TAB_WIDTH}px, 0, 0)`;\n            }\n        });\n    }, []);\n\n    return (\n        <div style={{ position: \"relative\", width: TAB_WIDTH * tabDefs.length, height: TAB_HEIGHT }}>\n            {tabDefs.map((tab, index) => {\n                const activeIndex = tabDefs.findIndex((t) => t.tabId === activeTabId);\n                const isActive = tab.tabId === activeTabId;\n                const showDivider = index !== 0 && !isActive && index !== activeIndex + 1;\n                return (\n                    <TabV\n                        key={tab.tabId}\n                        ref={(el) => {\n                            tabRefs.current[tab.tabId] = el;\n                        }}\n                        tabId={tab.tabId}\n                        tabName={tabNames[tab.tabId]}\n                        active={isActive}\n                        showDivider={showDivider}\n                        isDragging={false}\n                        tabWidth={TAB_WIDTH}\n                        isNew={false}\n                        badges={tab.badges ?? null}\n                        flagColor={tab.flagColor ?? null}\n                        onClick={() => setActiveTabId(tab.tabId)}\n                        onClose={() => console.log(\"close\", tab.tabId)}\n                        onDragStart={() => {}}\n                        onContextMenu={() => {}}\n                        onRename={(newName) => {\n                            console.log(\"rename\", tab.tabId, newName);\n                            setTabNames((prev) => ({ ...prev, [tab.tabId]: newName }));\n                        }}\n                    />\n                );\n            })}\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/preview/previews/tabbar.preview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { loadBadges, LoadBadgesEnv } from \"@/app/store/badge\";\nimport { TabBar } from \"@/app/tab/tabbar\";\nimport { TabBarEnv } from \"@/app/tab/tabbarenv\";\nimport { useWaveEnv, WaveEnvContext } from \"@/app/waveenv/waveenv\";\nimport { makeTabBarMockEnv, TabBarMockWorkspaceId } from \"@/preview/mock/tabbar-mock\";\nimport { MockWaveEnv } from \"@/preview/mock/mockwaveenv\";\nimport { PlatformLinux, PlatformMacOS, PlatformWindows } from \"@/util/platformutil\";\nimport { useAtom, useAtomValue } from \"jotai\";\nimport { CSSProperties, useEffect, useMemo, useRef, useState } from \"react\";\n\nconst MockConfigErrors: ConfigError[] = [\n    { file: \"~/.waveterm/config.json\", err: 'unknown preset \"bg@aurora\"' },\n    { file: \"~/.waveterm/settings.json\", err: \"invalid color for tab theme\" },\n];\n\nexport function TabBarPreview() {\n    const baseEnv = useWaveEnv();\n    const envRef = useRef<MockWaveEnv>(null);\n    const [platform, setPlatform] = useState<NodeJS.Platform>(PlatformMacOS);\n\n    const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, platform), [platform]);\n\n    return (\n        <WaveEnvContext.Provider value={tabEnv}>\n            <TabBarPreviewInner platform={platform} setPlatform={setPlatform} />\n        </WaveEnvContext.Provider>\n    );\n}\n\ntype TabBarPreviewInnerProps = {\n    platform: NodeJS.Platform;\n    setPlatform: (platform: NodeJS.Platform) => void;\n};\n\nfunction TabBarPreviewInner({ platform, setPlatform }: TabBarPreviewInnerProps) {\n    const env = useWaveEnv<TabBarEnv>();\n    const loadBadgesEnv = useWaveEnv<LoadBadgesEnv>();\n    const [showConfigErrors, setShowConfigErrors] = useState(false);\n    const [hideAiButton, setHideAiButton] = useState(false);\n    const [showMenuBar, setShowMenuBar] = useState(false);\n    const [isFullScreen, setIsFullScreen] = useAtom(env.atoms.isFullScreen);\n    const [zoomFactor, setZoomFactor] = useAtom(env.atoms.zoomFactorAtom);\n    const [fullConfig, setFullConfig] = useAtom(env.atoms.fullConfigAtom);\n    const [updaterStatus, setUpdaterStatus] = useAtom(env.atoms.updaterStatusAtom);\n    const workspace = useAtomValue(env.wos.getWaveObjectAtom<Workspace>(`workspace:${TabBarMockWorkspaceId}`));\n\n    useEffect(() => {\n        loadBadges(loadBadgesEnv);\n    }, []);\n\n    useEffect(() => {\n        setFullConfig((prev) => ({\n            ...(prev ?? ({} as FullConfigType)),\n            settings: {\n                ...(prev?.settings ?? {}),\n                \"app:hideaibutton\": hideAiButton,\n                \"window:showmenubar\": showMenuBar,\n            },\n            configerrors: showConfigErrors ? MockConfigErrors : [],\n        }));\n    }, [hideAiButton, showMenuBar, setFullConfig, showConfigErrors]);\n\n    return (\n        <div className=\"flex w-full flex-col gap-6\">\n            <div className=\"grid gap-4 rounded-md border border-border bg-panel p-4 md:grid-cols-3 mx-6 mt-6\">\n                <label className=\"flex flex-col gap-2 text-xs text-muted\">\n                    <span>Platform</span>\n                    <select\n                        value={platform}\n                        onChange={(event) => setPlatform(event.target.value as NodeJS.Platform)}\n                        className=\"rounded border border-border bg-background px-2 py-1 text-foreground cursor-pointer\"\n                    >\n                        <option value={PlatformMacOS}>macOS</option>\n                        <option value={PlatformWindows}>Windows</option>\n                        <option value={PlatformLinux}>Linux</option>\n                    </select>\n                </label>\n                <label className=\"flex flex-col gap-2 text-xs text-muted\">\n                    <span>Updater banner</span>\n                    <select\n                        value={updaterStatus}\n                        onChange={(event) => setUpdaterStatus(event.target.value as UpdaterStatus)}\n                        className=\"rounded border border-border bg-background px-2 py-1 text-foreground\"\n                    >\n                        <option value=\"up-to-date\">Hidden</option>\n                        <option value=\"ready\">Update Available</option>\n                        <option value=\"downloading\">Downloading</option>\n                        <option value=\"installing\">Installing</option>\n                        <option value=\"error\">Error</option>\n                    </select>\n                </label>\n                <label className=\"flex items-center gap-2 text-xs text-muted\">\n                    <input\n                        type=\"checkbox\"\n                        checked={showConfigErrors}\n                        onChange={(event) => setShowConfigErrors(event.target.checked)}\n                        className=\"cursor-pointer\"\n                    />\n                    Show config error button\n                </label>\n                <label className=\"flex items-center gap-2 text-xs text-muted\">\n                    <input\n                        type=\"checkbox\"\n                        checked={hideAiButton}\n                        onChange={(event) => setHideAiButton(event.target.checked)}\n                        className=\"cursor-pointer\"\n                    />\n                    Hide Wave AI button\n                </label>\n                <label className=\"flex items-center gap-2 text-xs text-muted\">\n                    <input\n                        type=\"checkbox\"\n                        checked={showMenuBar}\n                        onChange={(event) => setShowMenuBar(event.target.checked)}\n                        className=\"cursor-pointer\"\n                    />\n                    Show menu bar\n                </label>\n                <label className=\"flex items-center gap-2 text-xs text-muted\">\n                    <input\n                        type=\"checkbox\"\n                        checked={isFullScreen}\n                        onChange={(event) => setIsFullScreen(event.target.checked)}\n                        className=\"cursor-pointer\"\n                    />\n                    Full screen\n                </label>\n                <label className=\"flex flex-col gap-2 text-xs text-muted\">\n                    <span>Zoom factor: {zoomFactor.toFixed(2)}</span>\n                    <input\n                        type=\"range\"\n                        min={0.8}\n                        max={1.5}\n                        step={0.05}\n                        value={zoomFactor}\n                        onChange={(event) => setZoomFactor(Number(event.target.value))}\n                        className=\"cursor-pointer\"\n                    />\n                </label>\n                <div className=\"flex items-end text-xs text-muted\">\n                    Double-click a tab name to rename it. Close/add buttons and drag reordering are fully functional.\n                </div>\n            </div>\n\n            <div\n                className=\"w-full border-y border-border shadow-xl overflow-hidden\"\n                style={{ \"--zoomfactor-inv\": zoomFactor > 0 ? 1 / zoomFactor : 1 } as CSSProperties}\n            >\n                {workspace != null && <TabBar key={platform} workspace={workspace} />}\n            </div>\n\n            <div className=\"mx-6 mb-6 text-xs text-muted\">\n                Tabs: {workspace?.tabids?.length ?? 0} · Config errors: {fullConfig?.configerrors?.length ?? 0}\n            </div>\n        </div>\n    );\n}\nTabBarPreviewInner.displayName = \"TabBarPreviewInner\";\n"
  },
  {
    "path": "frontend/preview/previews/treeview.preview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { TreeNodeData, TreeView } from \"@/app/treeview/treeview\";\nimport { useMemo, useState } from \"react\";\n\nconst RootId = \"workspace:/\";\nconst RootNode: TreeNodeData = {\n    id: RootId,\n    path: RootId,\n    label: \"workspace\",\n    isDirectory: true,\n    childrenStatus: \"unloaded\",\n};\n\nconst DirectoryData: Record<string, TreeNodeData[]> = {\n    [RootId]: [\n        { id: \"workspace:/src\", path: \"workspace:/src\", label: \"src\", parentId: RootId, isDirectory: true },\n        { id: \"workspace:/docs\", path: \"workspace:/docs\", label: \"docs\", parentId: RootId, isDirectory: true },\n        { id: \"workspace:/README.md\", path: \"workspace:/README.md\", label: \"README.md\", parentId: RootId, isDirectory: false, mimeType: \"text/markdown\" },\n        { id: \"workspace:/package.json\", path: \"workspace:/package.json\", label: \"package.json\", parentId: RootId, isDirectory: false, mimeType: \"application/json\" },\n    ],\n    \"workspace:/src\": [\n        { id: \"workspace:/src/app\", path: \"workspace:/src/app\", label: \"app\", parentId: \"workspace:/src\", isDirectory: true },\n        { id: \"workspace:/src/styles\", path: \"workspace:/src/styles\", label: \"styles\", parentId: \"workspace:/src\", isDirectory: true },\n        ...Array.from({ length: 200 }).map((_, idx) => ({\n            id: `workspace:/src/file-${idx.toString().padStart(3, \"0\")}.tsx`,\n            path: `workspace:/src/file-${idx.toString().padStart(3, \"0\")}.tsx`,\n            label: `file-${idx.toString().padStart(3, \"0\")}.tsx`,\n            parentId: \"workspace:/src\",\n            isDirectory: false,\n            mimeType: \"text/typescript\",\n        })),\n    ],\n    \"workspace:/src/app\": [\n        { id: \"workspace:/src/app/main.tsx\", path: \"workspace:/src/app/main.tsx\", label: \"main.tsx\", parentId: \"workspace:/src/app\", isDirectory: false, mimeType: \"text/typescript\" },\n        { id: \"workspace:/src/app/router.ts\", path: \"workspace:/src/app/router.ts\", label: \"router.ts\", parentId: \"workspace:/src/app\", isDirectory: false, mimeType: \"text/typescript\" },\n    ],\n    \"workspace:/src/styles\": [\n        { id: \"workspace:/src/styles/app.css\", path: \"workspace:/src/styles/app.css\", label: \"app.css\", parentId: \"workspace:/src/styles\", isDirectory: false, mimeType: \"text/css\" },\n    ],\n    \"workspace:/docs\": Array.from({ length: 25 }).map((_, idx) => ({\n        id: `workspace:/docs/page-${idx + 1}.md`,\n        path: `workspace:/docs/page-${idx + 1}.md`,\n        label: `page-${idx + 1}.md`,\n        parentId: \"workspace:/docs\",\n        isDirectory: false,\n        mimeType: \"text/markdown\",\n    })),\n};\n\nexport function TreeViewPreview() {\n    const [width, setWidth] = useState(260);\n    const [selection, setSelection] = useState<string>(RootId);\n    const initialNodes = useMemo(() => ({ [RootId]: RootNode }), []);\n\n    return (\n        <div className=\"w-full max-w-[900px] px-6\">\n            <div className=\"mb-4 rounded-md border border-border bg-panel p-4\">\n                <div className=\"text-xs text-muted\">Tree width: {width}px</div>\n                <input\n                    type=\"range\"\n                    min={100}\n                    max={400}\n                    value={width}\n                    onChange={(event) => setWidth(Number(event.target.value))}\n                    className=\"mt-2 w-full cursor-pointer\"\n                />\n                <div className=\"mt-3 text-xs text-muted\">Selection: {selection}</div>\n            </div>\n            <TreeView\n                rootIds={[RootId]}\n                initialNodes={initialNodes}\n                width={width}\n                minWidth={100}\n                maxWidth={400}\n                height={420}\n                maxDirEntries={120}\n                fetchDir={async (id, limit) => {\n                    await new Promise((resolve) => setTimeout(resolve, 220));\n                    const entries = DirectoryData[id] ?? [];\n                    return {\n                        nodes: entries.slice(0, limit),\n                        capped: entries.length > limit,\n                        totalKnown: entries.length,\n                    };\n                }}\n                onOpenFile={(id) => {\n                    setSelection(`open:${id}`);\n                }}\n                onSelectionChange={(id) => {\n                    setSelection(id);\n                }}\n            />\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/preview/previews/vtabbar.preview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { loadBadges, LoadBadgesEnv } from \"@/app/store/badge\";\nimport { VTabBar } from \"@/app/tab/vtabbar\";\nimport { VTabBarEnv } from \"@/app/tab/vtabbarenv\";\nimport { useWaveEnv, WaveEnvContext } from \"@/app/waveenv/waveenv\";\nimport { MockWaveEnv } from \"@/preview/mock/mockwaveenv\";\nimport { makeTabBarMockEnv, TabBarMockWorkspaceId } from \"@/preview/mock/tabbar-mock\";\nimport { PlatformLinux, PlatformMacOS, PlatformWindows } from \"@/util/platformutil\";\nimport { useAtom, useAtomValue } from \"jotai\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\nexport function VTabBarPreview() {\n    const baseEnv = useWaveEnv();\n    const envRef = useRef<MockWaveEnv>(null);\n    const [platform, setPlatform] = useState<NodeJS.Platform>(PlatformMacOS);\n\n    const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, platform), [platform]);\n\n    return (\n        <WaveEnvContext.Provider value={tabEnv}>\n            <VTabBarPreviewInner platform={platform} setPlatform={setPlatform} />\n        </WaveEnvContext.Provider>\n    );\n}\n\ntype VTabBarPreviewInnerProps = {\n    platform: NodeJS.Platform;\n    setPlatform: (platform: NodeJS.Platform) => void;\n};\n\nfunction VTabBarPreviewInner({ platform, setPlatform }: VTabBarPreviewInnerProps) {\n    const env = useWaveEnv<VTabBarEnv>();\n    const loadBadgesEnv = useWaveEnv<LoadBadgesEnv>();\n    const [hideAiButton, setHideAiButton] = useState(false);\n    const [isFullScreen, setIsFullScreen] = useAtom(env.atoms.isFullScreen);\n    const [fullConfig, setFullConfig] = useAtom(env.atoms.fullConfigAtom);\n    const [updaterStatus, setUpdaterStatus] = useAtom(env.atoms.updaterStatusAtom);\n    const [width, setWidth] = useState<number>(220);\n    const workspace = useAtomValue(env.wos.getWaveObjectAtom<Workspace>(`workspace:${TabBarMockWorkspaceId}`));\n\n    useEffect(() => {\n        loadBadges(loadBadgesEnv);\n    }, []);\n\n    useEffect(() => {\n        setFullConfig((prev) => ({\n            ...(prev ?? ({} as FullConfigType)),\n            settings: {\n                ...(prev?.settings ?? {}),\n                \"app:hideaibutton\": hideAiButton,\n            },\n        }));\n    }, [hideAiButton, setFullConfig]);\n\n    return (\n        <div className=\"flex w-full flex-col gap-6\">\n            <div className=\"grid gap-4 rounded-md border border-border bg-panel p-4 md:grid-cols-3 mx-6 mt-6\">\n                <label className=\"flex flex-col gap-2 text-xs text-muted\">\n                    <span>Platform</span>\n                    <select\n                        value={platform}\n                        onChange={(event) => setPlatform(event.target.value as NodeJS.Platform)}\n                        className=\"rounded border border-border bg-background px-2 py-1 text-foreground cursor-pointer\"\n                    >\n                        <option value={PlatformMacOS}>macOS</option>\n                        <option value={PlatformWindows}>Windows</option>\n                        <option value={PlatformLinux}>Linux</option>\n                    </select>\n                </label>\n                <label className=\"flex flex-col gap-2 text-xs text-muted\">\n                    <span>Updater banner</span>\n                    <select\n                        value={updaterStatus}\n                        onChange={(event) => setUpdaterStatus(event.target.value as UpdaterStatus)}\n                        className=\"rounded border border-border bg-background px-2 py-1 text-foreground\"\n                    >\n                        <option value=\"up-to-date\">Hidden</option>\n                        <option value=\"ready\">Update Available</option>\n                        <option value=\"downloading\">Downloading</option>\n                        <option value=\"installing\">Installing</option>\n                        <option value=\"error\">Error</option>\n                    </select>\n                </label>\n                <label className=\"flex flex-col gap-2 text-xs text-muted\">\n                    <span>Width: {width}px</span>\n                    <input\n                        type=\"range\"\n                        min={110}\n                        max={400}\n                        value={width}\n                        onChange={(event) => setWidth(Math.max(100, Math.min(400, Number(event.target.value))))}\n                        className=\"cursor-pointer\"\n                    />\n                </label>\n                <label className=\"flex items-center gap-2 text-xs text-muted\">\n                    <input\n                        type=\"checkbox\"\n                        checked={hideAiButton}\n                        onChange={(event) => setHideAiButton(event.target.checked)}\n                        className=\"cursor-pointer\"\n                    />\n                    Hide Wave AI button\n                </label>\n                <label className=\"flex items-center gap-2 text-xs text-muted\">\n                    <input\n                        type=\"checkbox\"\n                        checked={isFullScreen}\n                        onChange={(event) => setIsFullScreen(event.target.checked)}\n                        className=\"cursor-pointer\"\n                    />\n                    Full screen\n                </label>\n            </div>\n\n            <div className=\"flex items-start px-6\">\n                <div\n                    className=\"h-[360px] overflow-hidden rounded-md border border-border bg-background\"\n                    style={{ width }}\n                >\n                    {workspace != null && <VTabBar key={platform} workspace={workspace} />}\n                </div>\n            </div>\n        </div>\n    );\n}\nVTabBarPreviewInner.displayName = \"VTabBarPreviewInner\";\n"
  },
  {
    "path": "frontend/preview/previews/web.preview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { Block } from \"@/app/block/block\";\nimport * as React from \"react\";\nimport { makeMockNodeModel } from \"../mock/mock-node-model\";\nimport { WebBlockId } from \"../mock/mockwaveenv\";\n\nconst PreviewNodeId = \"preview-web-node\";\n\nexport function WebPreview() {\n    const nodeModel = React.useMemo(\n        () => makeMockNodeModel({ nodeId: PreviewNodeId, blockId: WebBlockId, innerRect: { width: \"1040px\", height: \"620px\" } }),\n        []\n    );\n\n    return (\n        <div className=\"flex w-full max-w-[1100px] flex-col gap-2 px-6 py-6\">\n            <div className=\"text-xs text-muted font-mono\">full web block using preview mock fallback</div>\n            <div className=\"rounded-md border border-border bg-panel p-4\">\n                <div className=\"h-[680px]\">\n                    <Block preview={false} nodeModel={nodeModel} />\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/preview/previews/widgets.preview.tsx",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { useWaveEnv, WaveEnv, WaveEnvContext } from \"@/app/waveenv/waveenv\";\nimport { Widgets } from \"@/app/workspace/widgets\";\nimport { atom, useAtom, useAtomValue } from \"jotai\";\nimport { useRef } from \"react\";\nimport { applyMockEnvOverrides } from \"../mock/mockwaveenv\";\n\nconst resizableHeightAtom = atom(250);\nconst hasConfigErrorsAtom = atom(false);\nconst isDevAtom = atom(true);\nconst mockVersionAtom = atom(0);\n\nfunction makeMockApp(name: string, icon: string, iconcolor: string): AppInfo {\n    return {\n        appid: `local/${name.toLowerCase().replace(/\\s+/g, \"-\")}`,\n        modtime: 0,\n        manifest: {\n            appmeta: { title: name, shortdesc: \"\", icon, iconcolor },\n            configschema: {},\n            dataschema: {},\n            secrets: {},\n        },\n    };\n}\n\nconst mockApps: AppInfo[] = [\n    makeMockApp(\"Weather\", \"cloud-sun\", \"#60a5fa\"),\n    makeMockApp(\"Stocks\", \"chart-line\", \"#34d399\"),\n    makeMockApp(\"Notes\", \"note-sticky\", \"#fbbf24\"),\n    makeMockApp(\"Pomodoro\", \"clock\", \"#f87171\"),\n    makeMockApp(\"GitHub PRs\", \"code-pull-request\", \"#a78bfa\"),\n    makeMockApp(\"Server Monitor\", \"server\", \"#4ade80\"),\n];\n\nconst mockWidgets: { [key: string]: WidgetConfigType } = {\n    \"defwidget@term\": {\n        icon: \"terminal\",\n        color: \"#4ade80\",\n        label: \"Terminal\",\n        description: \"Open a terminal\",\n        \"display:order\": 0,\n        blockdef: { meta: { view: \"term\", controller: \"shell\" } },\n    },\n    \"defwidget@editor\": {\n        icon: \"code\",\n        color: \"#60a5fa\",\n        label: \"Editor\",\n        description: \"Open a code editor\",\n        \"display:order\": 1,\n        blockdef: { meta: { view: \"codeeditor\" } },\n    },\n    \"defwidget@web\": {\n        icon: \"globe\",\n        color: \"#f472b6\",\n        label: \"Web\",\n        description: \"Open a web browser\",\n        \"display:order\": 2,\n        blockdef: { meta: { view: \"web\", url: \"https://waveterm.dev\" } },\n    },\n    \"defwidget@ai\": {\n        icon: \"sparkles\",\n        color: \"#a78bfa\",\n        label: \"AI\",\n        description: \"Open Wave AI\",\n        \"display:order\": 3,\n        blockdef: { meta: { view: \"waveai\" } },\n    },\n    \"defwidget@files\": {\n        icon: \"folder\",\n        color: \"#fbbf24\",\n        label: \"Files\",\n        description: \"Open file browser\",\n        \"display:order\": 4,\n        blockdef: { meta: { view: \"preview\", connection: \"local\" } },\n    },\n    \"defwidget@sysinfo\": {\n        icon: \"chart-line\",\n        color: \"#34d399\",\n        label: \"Sysinfo\",\n        description: \"Open system info\",\n        \"display:order\": 5,\n        blockdef: { meta: { view: \"sysinfo\" } },\n    },\n};\n\nconst fullConfigAtom = atom<FullConfigType>({ settings: {}, widgets: mockWidgets } as unknown as FullConfigType);\n\nfunction makeWidgetsEnv(\n    baseEnv: WaveEnv,\n    isDev: boolean,\n    hasCustomAIPresets: boolean,\n    apps?: AppInfo[],\n    atomOverrides?: Partial<GlobalAtomsType>\n) {\n    return applyMockEnvOverrides(baseEnv, {\n        isDev,\n        rpc: { ListAllAppsCommand: () => Promise.resolve(apps ?? []) },\n        atoms: {\n            fullConfigAtom,\n            hasCustomAIPresetsAtom: atom(hasCustomAIPresets),\n            ...atomOverrides,\n        },\n    });\n}\n\nfunction WidgetsScenario({\n    label,\n    isDev = false,\n    hasCustomAIPresets = true,\n    height,\n    apps,\n}: {\n    label: string;\n    isDev?: boolean;\n    hasCustomAIPresets?: boolean;\n    height?: number;\n    apps?: AppInfo[];\n}) {\n    const baseEnv = useWaveEnv();\n    const envRef = useRef<WaveEnv>(null);\n    if (envRef.current == null) {\n        envRef.current = makeWidgetsEnv(baseEnv, isDev, hasCustomAIPresets, apps, {\n            hasConfigErrors: hasConfigErrorsAtom,\n        });\n    }\n\n    return (\n        <div className=\"flex flex-col gap-2\">\n            <div className=\"text-xs text-muted font-mono\">{label}</div>\n            <WaveEnvContext.Provider value={envRef.current}>\n                <div\n                    className=\"flex flex-row bg-panel border border-border rounded overflow-hidden\"\n                    style={height != null ? { height } : undefined}\n                >\n                    <div className=\"flex-1\" style={{ padding: 3 }}>\n                        <div className=\"w-full h-full border border-accent rounded-sm\" />\n                    </div>\n                    <Widgets />\n                </div>\n            </WaveEnvContext.Provider>\n        </div>\n    );\n}\n\nfunction WidgetsResizable({ isDev }: { isDev: boolean }) {\n    const [height, setHeight] = useAtom(resizableHeightAtom);\n    const baseEnv = useWaveEnv();\n    const envRef = useRef<WaveEnv>(null);\n    if (envRef.current == null) {\n        envRef.current = makeWidgetsEnv(baseEnv, isDev, true, mockApps, { hasConfigErrors: hasConfigErrorsAtom });\n    }\n\n    return (\n        <div className=\"flex flex-col gap-2 items-start\">\n            <div className=\"flex items-center gap-2 text-xs text-muted font-mono\">\n                <span>compact/supercompact — resizable (height: {height}px)</span>\n                <input\n                    type=\"range\"\n                    min={80}\n                    max={600}\n                    value={height}\n                    onChange={(e) => setHeight(Number(e.target.value))}\n                    className=\"cursor-pointer\"\n                />\n            </div>\n            <WaveEnvContext.Provider value={envRef.current}>\n                <div\n                    className=\"flex flex-row bg-panel border border-border rounded overflow-hidden\"\n                    style={{ height, width: 300 }}\n                >\n                    <div className=\"flex-1\" style={{ padding: 3 }}>\n                        <div className=\"w-full h-full border border-accent rounded-sm\" />\n                    </div>\n                    <Widgets />\n                </div>\n            </WaveEnvContext.Provider>\n        </div>\n    );\n}\n\nfunction PreviewControls() {\n    const [hasConfigErrors, setHasConfigErrors] = useAtom(hasConfigErrorsAtom);\n    const [isDev, setIsDev] = useAtom(isDevAtom);\n    const [, setMockVersion] = useAtom(mockVersionAtom);\n\n    function applyAndBump(fn: () => void) {\n        fn();\n        setMockVersion((v) => v + 1);\n    }\n\n    return (\n        <div className=\"flex items-center gap-4 text-xs text-muted font-mono\">\n            <span className=\"font-semibold\">preview controls:</span>\n            <label className=\"flex items-center gap-1 cursor-pointer select-none\">\n                <input\n                    type=\"checkbox\"\n                    checked={hasConfigErrors}\n                    onChange={(e) => applyAndBump(() => setHasConfigErrors(e.target.checked))}\n                    className=\"cursor-pointer\"\n                />\n                hasConfigErrors\n            </label>\n            <label className=\"flex items-center gap-1 cursor-pointer select-none\">\n                <input\n                    type=\"checkbox\"\n                    checked={isDev}\n                    onChange={(e) => applyAndBump(() => setIsDev(e.target.checked))}\n                    className=\"cursor-pointer\"\n                />\n                isDev\n            </label>\n        </div>\n    );\n}\n\nexport function WidgetsPreview() {\n    const isDev = useAtomValue(isDevAtom);\n    const mockVersion = useAtomValue(mockVersionAtom);\n\n    return (\n        <div className=\"flex flex-col gap-8 p-6\">\n            <PreviewControls />\n            <div key={mockVersion} className=\"flex flex-col gap-8\">\n                <div className=\"flex flex-row gap-8 items-start flex-wrap\">\n                    <WidgetsScenario label=\"normal (with AI presets)\" height={550} isDev={isDev} />\n                    <WidgetsScenario label=\"no custom AI presets\" hasCustomAIPresets={false} height={550} isDev={isDev} />\n                    <WidgetsScenario label=\"dev mode (apps button)\" height={550} isDev={isDev} apps={mockApps} />\n                    <WidgetsScenario label=\"compact (200px)\" height={200} isDev={isDev} apps={mockApps} />\n                </div>\n                <WidgetsResizable isDev={isDev} />\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/preview/vite.config.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport tailwindcss from \"@tailwindcss/vite\";\nimport react from \"@vitejs/plugin-react-swc\";\nimport path from \"path\";\nimport { defineConfig } from \"vite\";\nimport svgr from \"vite-plugin-svgr\";\nimport tsconfigPaths from \"vite-tsconfig-paths\";\n\nexport default defineConfig({\n    root: __dirname,\n    base: \"./\",\n    // Serve the workspace-root public/ directory so Font Awesome and other\n    // static assets (served by Electron in the real app) are available here too.\n    publicDir: path.resolve(__dirname, \"../../public\"),\n    plugins: [\n        tsconfigPaths(),\n        svgr({\n            svgrOptions: { exportType: \"default\", ref: true, svgo: false, titleProp: true },\n            include: \"**/*.svg\",\n        }),\n        react(),\n        tailwindcss(),\n    ],\n    build: {\n        minify: false,\n    },\n    server: {\n        port: 7007,\n    },\n});\n"
  },
  {
    "path": "frontend/tailwindsetup.css",
    "content": "/* Copyright 2026, Command Line Inc.\n   SPDX-License-Identifier: Apache-2.0 */\n\n@import \"tailwindcss\";\n\n@source \"../node_modules/streamdown/dist/index.js\";\n\n@theme {\n    --color-background: rgb(34, 34, 34);\n    --color-foreground: #f7f7f7;\n    --color-white: #f7f7f7;\n    --color-primary: #f7f7f7;\n    --color-muted-foreground: rgb(195, 200, 194);\n    --color-secondary: rgb(195, 200, 194);\n    --color-muted: rgb(140, 145, 140);\n    --color-accent-50: rgb(236, 253, 232);\n    --color-accent-100: rgb(209, 250, 202);\n    --color-accent-200: rgb(167, 243, 168);\n    --color-accent-300: rgb(110, 231, 133);\n    --color-accent-400: rgb(88, 193, 66); /* main accent color */\n    --color-accent-500: rgb(63, 162, 51);\n    --color-accent-600: rgb(47, 133, 47);\n    --color-accent-700: rgb(34, 104, 43);\n    --color-accent-800: rgb(22, 81, 35);\n    --color-accent-900: rgb(15, 61, 29);\n    --color-error: rgb(229, 77, 46);\n    --color-warning: rgb(224, 185, 86);\n    --color-success: rgb(78, 154, 6);\n    --color-panel: rgba(31, 33, 31, 0.5);\n    --color-hover: rgba(255, 255, 255, 0.1);\n    --color-border: rgba(255, 255, 255, 0.16);\n    --color-modalbg: #232323;\n    --color-accentbg: rgba(88, 193, 66, 0.5);\n    --color-hoverbg: rgba(255, 255, 255, 0.2);\n    --color-highlightbg: rgba(255, 255, 255, 0.2);\n    --color-accent: rgb(88, 193, 66);\n    --color-accenthover: rgb(118, 223, 96);\n\n    --font-sans: \"Inter\", sans-serif;\n    --font-mono: \"Hack\", monospace;\n    --font-markdown:\n        -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif, \"Apple Color Emoji\",\n        \"Segoe UI Emoji\";\n\n    --text-xxs: 10px;\n    --text-title: 18px;\n    --text-default: 14px;\n\n    --radius: 8px;\n\n    /* ANSI Colors (Default Dark Palette) */\n    --ansi-black: #757575;\n    --ansi-red: #cc685c;\n    --ansi-green: #76c266;\n    --ansi-yellow: #cbca9b;\n    --ansi-blue: #85aacb;\n    --ansi-magenta: #cc72ca;\n    --ansi-cyan: #74a7cb;\n    --ansi-white: #c1c1c1;\n    --ansi-brightblack: #727272;\n    --ansi-brightred: #cc9d97;\n    --ansi-brightgreen: #a3dd97;\n    --ansi-brightyellow: #cbcaaa;\n    --ansi-brightblue: #9ab6cb;\n    --ansi-brightmagenta: #cc8ecb;\n    --ansi-brightcyan: #b7b8cb;\n    --ansi-brightwhite: #f0f0f0;\n\n    --container-w600: 600px;\n    --container-w450: 450px;\n    --container-w350: 350px;\n    --container-xs: 300px;\n    --container-xxs: 200px;\n    --container-tiny: 120px;\n\n    --z-window-drag: 100;\n}\n\n/* Applied when body.nohover is set — used to suppress hover effects during tab remount to prevent ghost-hover flicker */\n@custom-variant nohover {\n    body.nohover & {\n        @slot;\n    }\n}\n\n:root {\n    --zoomfactor: 1;\n    --zoomfactor-inv: 1;\n}\n\n/* Chart tooltip styling for sysinfo plots */\nsvg [aria-label=\"tip\"] g path {\n    color: var(--border-color);\n}\n\n/* Monaco editor scrollbar styling */\n.monaco-editor .slider {\n    background: rgba(255, 255, 255, 0.4);\n    border-radius: 4px;\n    transition: background 0.2s ease;\n}\n\n.monaco-editor .slider:hover {\n    background: rgba(255, 255, 255, 0.6);\n}\n\n.ellipsis {\n    display: block;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}\n\n@keyframes float-up {\n    0% {\n        transform: translate(-50%, 0);\n        opacity: 1;\n    }\n    100% {\n        transform: translate(-50%, -40px);\n        opacity: 0;\n    }\n}\n"
  },
  {
    "path": "frontend/types/custom.d.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { WaveEnv } from \"@/app/waveenv/waveenv\";\nimport { type Placement } from \"@floating-ui/react\";\nimport type * as jotai from \"jotai\";\nimport type * as rxjs from \"rxjs\";\n\ndeclare global {\n    type GlobalAtomsType = {\n        builderId: jotai.Atom<string>; // readonly (for builder mode)\n        builderAppId: jotai.PrimitiveAtom<string>; // app being edited in builder mode\n        uiContext: jotai.Atom<UIContext>; // driven from windowId, tabId\n        workspaceId: jotai.Atom<string>; // derived from window WOS object\n        workspace: jotai.Atom<Workspace>; // driven from workspaceId via WOS\n        fullConfigAtom: jotai.PrimitiveAtom<FullConfigType>; // driven from WOS, settings -- updated via WebSocket\n        waveaiModeConfigAtom: jotai.PrimitiveAtom<Record<string, AIModeConfigType>>; // resolved AI mode configs -- updated via WebSocket\n        settingsAtom: jotai.Atom<SettingsType>; // derrived from fullConfig\n        hasCustomAIPresetsAtom: jotai.Atom<boolean>; // derived from fullConfig\n        hasConfigErrors: jotai.Atom<boolean>; // derived from fullConfig\n        staticTabId: jotai.Atom<string>;\n        isFullScreen: jotai.PrimitiveAtom<boolean>;\n        zoomFactorAtom: jotai.PrimitiveAtom<number>;\n        controlShiftDelayAtom: jotai.PrimitiveAtom<boolean>;\n        prefersReducedMotionAtom: jotai.Atom<boolean>;\n        documentHasFocus: jotai.PrimitiveAtom<boolean>;\n        updaterStatusAtom: jotai.PrimitiveAtom<UpdaterStatus>;\n        modalOpen: jotai.PrimitiveAtom<boolean>;\n        allConnStatus: jotai.Atom<ConnStatus[]>;\n        reinitVersion: jotai.PrimitiveAtom<number>;\n        waveAIRateLimitInfoAtom: jotai.PrimitiveAtom<RateLimitInfo>;\n    };\n\n    type ThrottledValueAtom<T> = jotai.WritableAtom<T, [update: jotai.SetStateAction<T>], void>;\n\n    type AtomWithThrottle<T> = {\n        currentValueAtom: jotai.Atom<T>;\n        throttledValueAtom: ThrottledValueAtom<T>;\n    };\n\n    type DebouncedValueAtom<T> = jotai.WritableAtom<T, [update: jotai.SetStateAction<T>], void>;\n\n    type AtomWithDebounce<T> = {\n        currentValueAtom: jotai.Atom<T>;\n        debouncedValueAtom: DebouncedValueAtom<T>;\n    };\n\n    type SplitAtom<Item> = Atom<Atom<Item>[]>;\n    type WritableSplitAtom<Item> = WritableAtom<PrimitiveAtom<Item>[], [SplitAtomAction<Item>], void>;\n\n    type TabLayoutData = {\n        blockId: string;\n    };\n\n    type GlobalInitOptions = {\n        tabId?: string;\n        platform: NodeJS.Platform;\n        windowId: string;\n        clientId: string;\n        environment: \"electron\" | \"renderer\";\n        primaryTabStartup?: boolean;\n        builderId?: string;\n        isPreview?: boolean;\n    };\n\n    type WaveInitOpts = {\n        tabId: string;\n        clientId: string;\n        windowId: string;\n        activate: boolean;\n        primaryTabStartup?: boolean;\n    };\n\n    type BuilderInitOpts = {\n        builderId: string;\n        clientId: string;\n        windowId: string;\n    };\n\n    type ElectronApi = {\n        getAuthKey(): string; // get-auth-key\n        getIsDev(): boolean; // get-is-dev\n        getCursorPoint: () => Electron.Point; // get-cursor-point\n        getPlatform: () => NodeJS.Platform; // get-platform\n        getEnv: (varName: string) => string; // get-env\n        getUserName: () => string; // get-user-name\n        getHostName: () => string; // get-host-name\n        getDataDir: () => string; // get-data-dir\n        getConfigDir: () => string; // get-config-dir\n        getHomeDir: () => string; // get-home-dir\n        getWebviewPreload: () => string; // get-webview-preload\n        getAboutModalDetails: () => AboutModalDetails; // get-about-modal-details\n        getZoomFactor: () => number; // get-zoom-factor\n        showWorkspaceAppMenu: (workspaceId: string) => void; // workspace-appmenu-show\n        showBuilderAppMenu: (builderId: string) => void; // builder-appmenu-show\n        showContextMenu: (workspaceId: string, menu: ElectronContextMenuItem[]) => void; // contextmenu-show\n        onContextMenuClick: (callback: (id: string | null) => void) => void; // contextmenu-click\n        onNavigate: (callback: (url: string) => void) => void;\n        onIframeNavigate: (callback: (url: string) => void) => void;\n        downloadFile: (path: string) => void; // download\n        openExternal: (url: string) => void; // open-external\n        onFullScreenChange: (callback: (isFullScreen: boolean) => void) => void; // fullscreen-change\n        onZoomFactorChange: (callback: (zoomFactor: number) => void) => void; // zoom-factor-change\n        onUpdaterStatusChange: (callback: (status: UpdaterStatus) => void) => void; // app-update-status\n        getUpdaterStatus: () => UpdaterStatus; // get-app-update-status\n        getUpdaterChannel: () => string; // get-updater-channel\n        installAppUpdate: () => void; // install-app-update\n        onMenuItemAbout: (callback: () => void) => void; // menu-item-about\n        updateWindowControlsOverlay: (rect: Dimensions) => void; // update-window-controls-overlay\n        onReinjectKey: (callback: (waveEvent: WaveKeyboardEvent) => void) => void; // reinject-key\n        setWebviewFocus: (focusedId: number) => void; // webview-focus, focusedId is the getWebContentsId of the webview\n        registerGlobalWebviewKeys: (keys: string[]) => void; // register-global-webview-keys\n        onControlShiftStateUpdate: (callback: (state: boolean) => void) => void; // control-shift-state-update\n        createWorkspace: () => void; // create-workspace\n        switchWorkspace: (workspaceId: string) => void; // switch-workspace\n        deleteWorkspace: (workspaceId: string) => void; // delete-workspace\n        setActiveTab: (tabId: string) => void; // set-active-tab\n        createTab: () => void; // create-tab\n        closeTab: (workspaceId: string, tabId: string, confirmClose: boolean) => Promise<boolean>; // close-tab\n        setWindowInitStatus: (status: \"ready\" | \"wave-ready\") => void; // set-window-init-status\n        onWaveInit: (callback: (initOpts: WaveInitOpts) => void) => void; // wave-init\n        onBuilderInit: (callback: (initOpts: BuilderInitOpts) => void) => void; // builder-init\n        sendLog: (log: string) => void; // fe-log\n        onQuicklook: (filePath: string) => void; // quicklook\n        openNativePath(filePath: string): void; // open-native-path\n        captureScreenshot(rect: Electron.Rectangle): Promise<string>; // capture-screenshot\n        setKeyboardChordMode: () => void; // set-keyboard-chord-mode\n        clearWebviewStorage: (webContentsId: number) => Promise<void>; // clear-webview-storage\n        setWaveAIOpen: (isOpen: boolean) => void; // set-waveai-open\n        closeBuilderWindow: () => void; // close-builder-window\n        incrementTermCommands: (opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => void; // increment-term-commands\n        nativePaste: () => void; // native-paste\n        openBuilder: (appId?: string) => void; // open-builder\n        setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid\n        doRefresh: () => void; // do-refresh\n        saveTextFile: (fileName: string, content: string) => Promise<boolean>; // save-text-file\n        setIsActive: () => Promise<void>; // set-is-active\n    };\n\n    type ElectronContextMenuItem = {\n        id: string; // unique id, used for communication\n        label: string;\n        role?: string; // electron role (optional)\n        type?: \"separator\" | \"normal\" | \"submenu\" | \"checkbox\" | \"radio\" | \"header\";\n        submenu?: ElectronContextMenuItem[];\n        checked?: boolean;\n        visible?: boolean;\n        enabled?: boolean;\n        sublabel?: string;\n    };\n\n    type ContextMenuItem = {\n        label?: string;\n        type?: \"separator\" | \"normal\" | \"submenu\" | \"checkbox\" | \"radio\" | \"header\";\n        role?: string; // electron role (optional)\n        click?: () => void; // not required if role is set\n        submenu?: ContextMenuItem[];\n        checked?: boolean;\n        visible?: boolean;\n        enabled?: boolean;\n        sublabel?: string;\n    };\n\n    type KeyPressDecl = {\n        mods: {\n            Cmd?: boolean;\n            Option?: boolean;\n            Shift?: boolean;\n            Ctrl?: boolean;\n            Alt?: boolean;\n            Meta?: boolean;\n        };\n        key: string;\n        keyType: string;\n    };\n\n    type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void };\n\n    type HeaderElem =\n        | IconButtonDecl\n        | ToggleIconButtonDecl\n        | HeaderText\n        | HeaderInput\n        | HeaderDiv\n        | HeaderTextButton\n        | ConnectionButton\n        | MenuButton;\n\n    type IconButtonCommon = {\n        icon: string | React.ReactNode;\n        iconColor?: string;\n        iconSpin?: boolean;\n        className?: string;\n        title?: string;\n        disabled?: boolean;\n        noAction?: boolean;\n    };\n\n    type IconButtonDecl = IconButtonCommon & {\n        elemtype: \"iconbutton\";\n        click?: (e: React.MouseEvent<any>) => void;\n        longClick?: (e: React.MouseEvent<any>) => void;\n    };\n\n    type ToggleIconButtonDecl = IconButtonCommon & {\n        elemtype: \"toggleiconbutton\";\n        active: jotai.WritableAtom<boolean, [boolean], void>;\n    };\n\n    type HeaderTextButton = {\n        elemtype: \"textbutton\";\n        text: string;\n        className?: string;\n        title?: string;\n        onClick?: (e: React.MouseEvent<any>) => void;\n    };\n\n    type HeaderText = {\n        elemtype: \"text\";\n        text: string;\n        ref?: React.RefObject<HTMLDivElement>;\n        className?: string;\n        noGrow?: boolean;\n        onClick?: (e: React.MouseEvent<any>) => void;\n    };\n\n    type HeaderInput = {\n        elemtype: \"input\";\n        value: string;\n        className?: string;\n        isDisabled?: boolean;\n        ref?: React.RefObject<HTMLInputElement>;\n        onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;\n        onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;\n        onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;\n        onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;\n    };\n\n    type HeaderDiv = {\n        elemtype: \"div\";\n        className?: string;\n        children: HeaderElem[];\n        onMouseOver?: (e: React.MouseEvent<any>) => void;\n        onMouseOut?: (e: React.MouseEvent<any>) => void;\n        onClick?: (e: React.MouseEvent<any>) => void;\n    };\n\n    type ConnectionButton = {\n        elemtype: \"connectionbutton\";\n        icon: string;\n        text: string;\n        iconColor: string;\n        onClick?: (e: React.MouseEvent<any>) => void;\n        connected: boolean;\n    };\n\n    type MenuItem = {\n        label: string;\n        icon?: string | React.ReactNode;\n        subItems?: MenuItem[];\n        onClick?: (e: React.MouseEvent<any>) => void;\n    };\n\n    type MenuButtonProps = {\n        items: MenuItem[];\n        className?: string;\n        text: string;\n        title?: string;\n        menuPlacement?: Placement;\n    };\n\n    type MenuButton = {\n        elemtype: \"menubutton\";\n    } & MenuButtonProps;\n\n    type SearchAtoms = {\n        searchValue: PrimitiveAtom<string>;\n        resultsIndex: PrimitiveAtom<number>;\n        resultsCount: PrimitiveAtom<number>;\n        isOpen: PrimitiveAtom<boolean>;\n        focusInput: PrimitiveAtom<number>;\n        regex?: PrimitiveAtom<boolean>;\n        caseSensitive?: PrimitiveAtom<boolean>;\n        wholeWord?: PrimitiveAtom<boolean>;\n    };\n\n    declare type ViewComponentProps<T extends ViewModel> = {\n        blockId: string;\n        blockRef: React.RefObject<HTMLDivElement>;\n        contentRef: React.RefObject<HTMLDivElement>;\n        model: T;\n    };\n\n    declare type ViewComponent = React.FC<ViewComponentProps>;\n\n    type ViewModelInitType = {\n        blockId: string;\n        nodeModel: BlockNodeModel;\n        tabModel: TabModel;\n        waveEnv: WaveEnv;\n    };\n\n    type ViewModelClass = new (initOpts: ViewModelInitType) => ViewModel;\n\n    interface ViewModel {\n        // The type of view, used for identifying and rendering the appropriate component.\n        viewType: string;\n\n        useTermHeader?: jotai.Atom<boolean>;\n\n        hideViewName?: jotai.Atom<boolean>;\n\n        // Icon representing the view, can be a string or an IconButton declaration.\n        viewIcon?: jotai.Atom<string | IconButtonDecl>;\n\n        // Display name for the view, used in UI headers.\n        viewName?: jotai.Atom<string>;\n\n        // Optional header text or elements for the view.\n        viewText?: jotai.Atom<string | HeaderElem[]>;\n\n        termDurableStatus?: jotai.Atom<BlockJobStatusData | null>;\n        termConfigedDurable?: jotai.Atom<null | boolean>;\n\n        // Icon button displayed before the title in the header.\n        preIconButton?: jotai.Atom<IconButtonDecl>;\n\n        // Icon buttons displayed at the end of the block header.\n        endIconButtons?: jotai.Atom<IconButtonDecl[]>;\n\n        // Background styling metadata for the block.\n        blockBg?: jotai.Atom<MetaType>;\n\n        noHeader?: jotai.Atom<boolean>;\n\n        // Whether the block manages its own connection (e.g., for remote access).\n        manageConnection?: jotai.Atom<boolean>;\n\n        // If true, filters out 'nowsh' connections (when managing connections)\n        filterOutNowsh?: jotai.Atom<boolean>;\n\n        // If true, removes padding inside the block content area.\n        noPadding?: jotai.Atom<boolean>;\n\n        // Atoms used for managing search functionality within the block.\n        searchAtoms?: SearchAtoms;\n\n        // The main view component associated with this ViewModel.\n        viewComponent: ViewComponent<ViewModel>;\n\n        // Function to determine if this is a basic terminal block.\n        isBasicTerm?: (getFn: jotai.Getter) => boolean;\n\n        // Returns menu items for the settings dropdown.\n        getSettingsMenuItems?: () => ContextMenuItem[];\n\n        // Attempts to give focus to the block, returning true if successful.\n        giveFocus?: () => boolean;\n\n        // Handles keydown events within the block.\n        keyDownHandler?: (e: WaveKeyboardEvent) => boolean;\n\n        // Cleans up resources when the block is disposed.\n        dispose?: () => void;\n    }\n\n    type UpdaterStatus = \"up-to-date\" | \"checking\" | \"downloading\" | \"ready\" | \"error\" | \"installing\";\n\n    // jotai doesn't export this type :/\n    type Loadable<T> = { state: \"loading\" } | { state: \"hasData\"; data: T } | { state: \"hasError\"; error: unknown };\n\n    interface Dimensions {\n        width: number;\n        height: number;\n        left: number;\n        top: number;\n    }\n\n    type TypeAheadModalType = { [key: string]: boolean };\n\n    interface AboutModalDetails {\n        version: string;\n        buildTime: number;\n    }\n\n    type BlockComponentModel = {\n        openSwitchConnection?: () => void;\n        viewModel: ViewModel;\n    };\n\n    type ConnStatusType = \"connected\" | \"connecting\" | \"disconnected\" | \"error\" | \"init\";\n\n    interface SuggestionBaseItem {\n        label: string;\n        value: string;\n        icon?: string | React.ReactNode;\n    }\n\n    interface SuggestionConnectionItem extends SuggestionBaseItem {\n        status: ConnStatusType;\n        iconColor: string;\n        onSelect?: (_: string) => void;\n        current?: boolean;\n    }\n\n    interface SuggestionConnectionScope {\n        headerText?: string;\n        items: SuggestionConnectionItem[];\n    }\n\n    type SuggestionsType = SuggestionConnectionItem | SuggestionConnectionScope;\n\n    type MarkdownResolveOpts = {\n        connName: string;\n        baseDir: string;\n    };\n\n    interface AbstractWshClient {\n        recvRpcMessage(msg: RpcMessage): void;\n    }\n\n    type ClientRpcEntry = {\n        reqId: string;\n        startTs: number;\n        command: string;\n        msgFn: (msg: RpcMessage) => void;\n    };\n\n    type TimeSeriesMeta = {\n        name?: string;\n        color?: string;\n        label?: string;\n        maxy?: string | number;\n        miny?: string | number;\n        decimalPlaces?: number;\n    };\n\n    interface SuggestionRequestContext {\n        widgetid: string;\n        reqnum: number;\n        dispose?: boolean;\n    }\n\n    type SuggestionsFnType = (query: string, reqContext: SuggestionRequestContext) => Promise<FetchSuggestionsResponse>;\n\n    type DraggedFile = {\n        uri: string;\n        absParent: string;\n        relName: string;\n        isDir: boolean;\n    };\n\n    type ErrorButtonDef = {\n        text: string;\n        onClick: () => void;\n    };\n\n    type ErrorMsg = {\n        status: string;\n        text: string;\n        level?: \"error\" | \"warning\";\n        buttons?: Array<ErrorButtonDef>;\n        closeAction?: () => void;\n        showDismiss?: boolean;\n    };\n\n    type AIMessage = {\n        messageid: string;\n        parts: AIMessagePart[];\n    };\n\n    type AIMessagePart =\n        | {\n              type: \"text\";\n              text: string;\n          }\n        | {\n              type: \"file\";\n              mimetype: string; // required\n              filename?: string;\n              data?: string; // base64 encoded data\n              url?: string;\n              size?: number;\n              previewurl?: string;\n          };\n\n    type AIModeConfigWithMode = { mode: string } & AIModeConfigType;\n}\n\nexport {};\n"
  },
  {
    "path": "frontend/types/gotypes.d.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// generated by cmd/generate/main-generatets.go\n\ndeclare global {\n\n    // wshrpc.AIAttachedFile\n    type AIAttachedFile = {\n        name: string;\n        type: string;\n        size: number;\n        data64: string;\n    };\n\n    // wconfig.AIModeConfigType\n    type AIModeConfigType = {\n        \"display:name\": string;\n        \"display:order\"?: number;\n        \"display:icon\"?: string;\n        \"display:description\"?: string;\n        \"ai:provider\"?: string;\n        \"ai:apitype\"?: string;\n        \"ai:model\"?: string;\n        \"ai:thinkinglevel\"?: string;\n        \"ai:verbosity\"?: string;\n        \"ai:endpoint\"?: string;\n        \"ai:proxyurl\"?: string;\n        \"ai:azureapiversion\"?: string;\n        \"ai:apitoken\"?: string;\n        \"ai:apitokensecretname\"?: string;\n        \"ai:azureresourcename\"?: string;\n        \"ai:azuredeployment\"?: string;\n        \"ai:capabilities\"?: string[];\n        \"ai:switchcompat\"?: string[];\n        \"waveai:cloud\"?: boolean;\n        \"waveai:premium\"?: boolean;\n    };\n\n    // wconfig.AIModeConfigUpdate\n    type AIModeConfigUpdate = {\n        configs: {[key: string]: AIModeConfigType};\n    };\n\n    // wshrpc.ActivityDisplayType\n    type ActivityDisplayType = {\n        width: number;\n        height: number;\n        dpr: number;\n        internal?: boolean;\n    };\n\n    // wshrpc.ActivityUpdate\n    type ActivityUpdate = {\n        fgminutes?: number;\n        activeminutes?: number;\n        openminutes?: number;\n        waveaifgminutes?: number;\n        waveaiactiveminutes?: number;\n        numtabs?: number;\n        newtab?: number;\n        numblocks?: number;\n        numwindows?: number;\n        numws?: number;\n        numwsnamed?: number;\n        numsshconn?: number;\n        numwslconn?: number;\n        nummagnify?: number;\n        termcommandsrun?: number;\n        numpanics?: number;\n        numaireqs?: number;\n        startup?: number;\n        shutdown?: number;\n        settabtheme?: number;\n        buildtime?: string;\n        displays?: ActivityDisplayType[];\n        renderers?: {[key: string]: number};\n        blocks?: {[key: string]: number};\n        wshcmds?: {[key: string]: number};\n        conn?: {[key: string]: number};\n    };\n\n    // wshrpc.AiMessageData\n    type AiMessageData = {\n        message?: string;\n    };\n\n    // wshrpc.AppInfo\n    type AppInfo = {\n        appid: string;\n        modtime: number;\n        manifest?: AppManifest;\n    };\n\n    // wshrpc.AppManifest\n    type AppManifest = {\n        appmeta: AppMeta;\n        configschema: {[key: string]: any};\n        dataschema: {[key: string]: any};\n        secrets: {[key: string]: SecretMeta};\n    };\n\n    // wshrpc.AppMeta\n    type AppMeta = {\n        title: string;\n        shortdesc: string;\n        icon: string;\n        iconcolor: string;\n    };\n\n    // baseds.Badge\n    type Badge = {\n        badgeid: string;\n        icon: string;\n        color?: string;\n        priority: number;\n        pidlinked?: boolean;\n    };\n\n    // baseds.BadgeEvent\n    type BadgeEvent = {\n        oref: string;\n        clear?: boolean;\n        clearall?: boolean;\n        clearbyid?: string;\n        badge?: Badge;\n    };\n\n    // waveobj.Block\n    type Block = WaveObj & {\n        parentoref?: string;\n        runtimeopts?: RuntimeOpts;\n        stickers?: StickerType[];\n        subblockids?: string[];\n        jobid?: string;\n    };\n\n    // blockcontroller.BlockControllerRuntimeStatus\n    type BlockControllerRuntimeStatus = {\n        blockid: string;\n        version: number;\n        shellprocstatus?: string;\n        shellprocconnname?: string;\n        shellprocexitcode: number;\n        tsunamiport?: number;\n    };\n\n    // waveobj.BlockDef\n    type BlockDef = {\n        files?: {[key: string]: FileDef};\n        meta?: MetaType;\n    };\n\n    // wshrpc.BlockInfoData\n    type BlockInfoData = {\n        blockid: string;\n        tabid: string;\n        workspaceid: string;\n        block: Block;\n        files: WaveFileInfo[];\n    };\n\n    // wshrpc.BlockJobStatusData\n    type BlockJobStatusData = {\n        blockid: string;\n        jobid: string;\n        status?: null | \"init\" | \"connected\" | \"disconnected\" | \"done\";\n        versionts: number;\n        donereason?: string;\n        startuperror?: string;\n        cmdexitts?: number;\n        cmdexitcode?: number;\n        cmdexitsignal?: string;\n    };\n\n    // wshrpc.BlocksListEntry\n    type BlocksListEntry = {\n        windowid: string;\n        workspaceid: string;\n        tabid: string;\n        blockid: string;\n        meta: MetaType;\n    };\n\n    // wshrpc.BlocksListRequest\n    type BlocksListRequest = {\n        windowid?: string;\n        workspaceid?: string;\n    };\n\n    // wshrpc.BuilderStatusData\n    type BuilderStatusData = {\n        status: string;\n        port?: number;\n        exitcode?: number;\n        errormsg?: string;\n        version: number;\n        manifest?: AppManifest;\n        secretbindings?: {[key: string]: string};\n        secretbindingscomplete: boolean;\n    };\n\n    // waveobj.Client\n    type Client = WaveObj & {\n        windowids: string[];\n        tosagreed?: number;\n        hasoldhistory?: boolean;\n        tempoid?: string;\n        installid?: string;\n    };\n\n    // workspaceservice.CloseTabRtnType\n    type CloseTabRtnType = {\n        closewindow?: boolean;\n        newactivetabid?: string;\n    };\n\n    // wshrpc.CommandAuthenticateJobManagerData\n    type CommandAuthenticateJobManagerData = {\n        jobid: string;\n        jobauthtoken: string;\n    };\n\n    // wshrpc.CommandAuthenticateRtnData\n    type CommandAuthenticateRtnData = {\n        routeid: string;\n        env?: {[key: string]: string};\n        initscripttext?: string;\n        rpccontext?: RpcContext;\n    };\n\n    // wshrpc.CommandAuthenticateToJobData\n    type CommandAuthenticateToJobData = {\n        jobaccesstoken: string;\n    };\n\n    // wshrpc.CommandAuthenticateTokenData\n    type CommandAuthenticateTokenData = {\n        token: string;\n    };\n\n    // wshrpc.CommandBadgeWatchPidData\n    type CommandBadgeWatchPidData = {\n        pid: number;\n        oref: ORef;\n        badgeid: string;\n    };\n\n    // wshrpc.CommandBlockInputData\n    type CommandBlockInputData = {\n        blockid: string;\n        inputdata64?: string;\n        signame?: string;\n        termsize?: TermSize;\n    };\n\n    // wshrpc.CommandCaptureBlockScreenshotData\n    type CommandCaptureBlockScreenshotData = {\n        blockid: string;\n    };\n\n    // wshrpc.CommandCheckGoVersionRtnData\n    type CommandCheckGoVersionRtnData = {\n        gostatus: string;\n        gopath: string;\n        goversion: string;\n        errorstring?: string;\n    };\n\n    // wshrpc.CommandConnServerInitData\n    type CommandConnServerInitData = {\n        clientid: string;\n    };\n\n    // wshrpc.CommandControllerAppendOutputData\n    type CommandControllerAppendOutputData = {\n        blockid: string;\n        data64: string;\n    };\n\n    // wshrpc.CommandControllerResyncData\n    type CommandControllerResyncData = {\n        forcerestart?: boolean;\n        tabid: string;\n        blockid: string;\n        rtopts?: RuntimeOpts;\n    };\n\n    // wshrpc.CommandCreateBlockData\n    type CommandCreateBlockData = {\n        tabid: string;\n        blockdef: BlockDef;\n        rtopts?: RuntimeOpts;\n        magnified?: boolean;\n        ephemeral?: boolean;\n        focused?: boolean;\n        targetblockid?: string;\n        targetaction?: string;\n    };\n\n    // wshrpc.CommandCreateSubBlockData\n    type CommandCreateSubBlockData = {\n        parentblockid: string;\n        blockdef: BlockDef;\n    };\n\n    // wshrpc.CommandDebugTermData\n    type CommandDebugTermData = {\n        blockid: string;\n        size: number;\n    };\n\n    // wshrpc.CommandDebugTermRtnData\n    type CommandDebugTermRtnData = {\n        offset: number;\n        data64: string;\n    };\n\n    // wshrpc.CommandDeleteAppFileData\n    type CommandDeleteAppFileData = {\n        appid: string;\n        filename: string;\n    };\n\n    // wshrpc.CommandDeleteBlockData\n    type CommandDeleteBlockData = {\n        blockid: string;\n    };\n\n    // wshrpc.CommandDeleteFileData\n    type CommandDeleteFileData = {\n        path: string;\n        recursive: boolean;\n    };\n\n    // wshrpc.CommandDisposeData\n    type CommandDisposeData = {\n        routeid: string;\n    };\n\n    // wshrpc.CommandElectronDecryptData\n    type CommandElectronDecryptData = {\n        ciphertext: string;\n    };\n\n    // wshrpc.CommandElectronDecryptRtnData\n    type CommandElectronDecryptRtnData = {\n        plaintext: string;\n        storagebackend: string;\n    };\n\n    // wshrpc.CommandElectronEncryptData\n    type CommandElectronEncryptData = {\n        plaintext: string;\n    };\n\n    // wshrpc.CommandElectronEncryptRtnData\n    type CommandElectronEncryptRtnData = {\n        ciphertext: string;\n        storagebackend: string;\n    };\n\n    // wshrpc.CommandEventReadHistoryData\n    type CommandEventReadHistoryData = {\n        event: string;\n        scope: string;\n        maxitems: number;\n    };\n\n    // wshrpc.CommandFileCopyData\n    type CommandFileCopyData = {\n        srcuri: string;\n        desturi: string;\n        opts?: FileCopyOpts;\n    };\n\n    // wshrpc.CommandFileRestoreBackupData\n    type CommandFileRestoreBackupData = {\n        backupfilepath: string;\n        restoretofilename: string;\n    };\n\n    // wshrpc.CommandFileStreamData\n    type CommandFileStreamData = {\n        info: FileInfo;\n        byterange?: string;\n        streammeta: StreamMeta;\n    };\n\n    // wshrpc.CommandGetMetaData\n    type CommandGetMetaData = {\n        oref: ORef;\n    };\n\n    // wshrpc.CommandGetRTInfoData\n    type CommandGetRTInfoData = {\n        oref: ORef;\n    };\n\n    // wshrpc.CommandGetTempDirData\n    type CommandGetTempDirData = {\n        filename?: string;\n    };\n\n    // wshrpc.CommandGetWaveAIChatData\n    type CommandGetWaveAIChatData = {\n        chatid: string;\n    };\n\n    // wshrpc.CommandJobCmdExitedData\n    type CommandJobCmdExitedData = {\n        jobid: string;\n        exitcode?: number;\n        exitsignal?: string;\n        exiterr?: string;\n        exitts?: number;\n    };\n\n    // wshrpc.CommandJobConnectRtnData\n    type CommandJobConnectRtnData = {\n        seq: number;\n        streamdone?: boolean;\n        streamerror?: string;\n        hasexited?: boolean;\n        exitcode?: number;\n        exitsignal?: string;\n        exiterr?: string;\n    };\n\n    // wshrpc.CommandJobControllerAttachJobData\n    type CommandJobControllerAttachJobData = {\n        jobid: string;\n        blockid: string;\n    };\n\n    // wshrpc.CommandJobControllerStartJobData\n    type CommandJobControllerStartJobData = {\n        connname: string;\n        jobkind: string;\n        cmd: string;\n        args: string[];\n        env: {[key: string]: string};\n        termsize?: TermSize;\n    };\n\n    // wshrpc.CommandJobInputData\n    type CommandJobInputData = {\n        jobid: string;\n        inputsessionid?: string;\n        seqnum?: number;\n        inputdata64?: string;\n        signame?: string;\n        termsize?: TermSize;\n    };\n\n    // wshrpc.CommandJobPrepareConnectData\n    type CommandJobPrepareConnectData = {\n        streammeta: StreamMeta;\n        seq: number;\n        termsize: TermSize;\n    };\n\n    // wshrpc.CommandJobStartStreamData\n    type CommandJobStartStreamData = object;\n\n    // wshrpc.CommandListAllAppFilesData\n    type CommandListAllAppFilesData = {\n        appid: string;\n    };\n\n    // wshrpc.CommandListAllAppFilesRtnData\n    type CommandListAllAppFilesRtnData = {\n        path: string;\n        absolutepath: string;\n        parentdir?: string;\n        entries: DirEntryOut[];\n        entrycount: number;\n        totalentries: number;\n        truncated?: boolean;\n    };\n\n    // wshrpc.CommandMakeDraftFromLocalData\n    type CommandMakeDraftFromLocalData = {\n        localappid: string;\n    };\n\n    // wshrpc.CommandMakeDraftFromLocalRtnData\n    type CommandMakeDraftFromLocalRtnData = {\n        draftappid: string;\n    };\n\n    // wshrpc.CommandMessageData\n    type CommandMessageData = {\n        message: string;\n    };\n\n    // wshrpc.CommandPublishAppData\n    type CommandPublishAppData = {\n        appid: string;\n    };\n\n    // wshrpc.CommandPublishAppRtnData\n    type CommandPublishAppRtnData = {\n        publishedappid: string;\n    };\n\n    // wshrpc.CommandReadAppFileData\n    type CommandReadAppFileData = {\n        appid: string;\n        filename: string;\n    };\n\n    // wshrpc.CommandReadAppFileRtnData\n    type CommandReadAppFileRtnData = {\n        data64: string;\n        notfound?: boolean;\n        modts?: number;\n    };\n\n    // wshrpc.CommandRemoteDisconnectFromJobManagerData\n    type CommandRemoteDisconnectFromJobManagerData = {\n        jobid: string;\n    };\n\n    // wshrpc.CommandRemoteFileMultiInfoData\n    type CommandRemoteFileMultiInfoData = {\n        cwd: string;\n        paths: string[];\n    };\n\n    // wshrpc.CommandRemoteFileStreamData\n    type CommandRemoteFileStreamData = {\n        path: string;\n        byterange?: string;\n        streammeta: StreamMeta;\n    };\n\n    // wshrpc.CommandRemoteListEntriesData\n    type CommandRemoteListEntriesData = {\n        path: string;\n        opts?: FileListOpts;\n    };\n\n    // wshrpc.CommandRemoteListEntriesRtnData\n    type CommandRemoteListEntriesRtnData = {\n        fileinfo?: FileInfo[];\n    };\n\n    // wshrpc.CommandRemoteReconnectToJobManagerData\n    type CommandRemoteReconnectToJobManagerData = {\n        jobid: string;\n        jobauthtoken: string;\n        mainserverjwttoken: string;\n        jobmanagerpid: number;\n        jobmanagerstartts: number;\n    };\n\n    // wshrpc.CommandRemoteReconnectToJobManagerRtnData\n    type CommandRemoteReconnectToJobManagerRtnData = {\n        success: boolean;\n        jobmanagergone: boolean;\n        error?: string;\n    };\n\n    // wshrpc.CommandRemoteStartJobData\n    type CommandRemoteStartJobData = {\n        cmd: string;\n        args: string[];\n        env: {[key: string]: string};\n        termsize: TermSize;\n        streammeta?: StreamMeta;\n        jobauthtoken: string;\n        jobid: string;\n        mainserverjwttoken: string;\n        clientid: string;\n        publickeybase64: string;\n    };\n\n    // wshrpc.CommandRemoteStreamFileData\n    type CommandRemoteStreamFileData = {\n        path: string;\n        byterange?: string;\n    };\n\n    // wshrpc.CommandRemoteTerminateJobManagerData\n    type CommandRemoteTerminateJobManagerData = {\n        jobid: string;\n        jobmanagerpid: number;\n        jobmanagerstartts: number;\n    };\n\n    // wshrpc.CommandRenameAppFileData\n    type CommandRenameAppFileData = {\n        appid: string;\n        fromfilename: string;\n        tofilename: string;\n    };\n\n    // wshrpc.CommandResolveIdsData\n    type CommandResolveIdsData = {\n        blockid: string;\n        ids: string[];\n    };\n\n    // wshrpc.CommandResolveIdsRtnData\n    type CommandResolveIdsRtnData = {\n        resolvedids: {[key: string]: ORef};\n    };\n\n    // wshrpc.CommandRestartBuilderAndWaitData\n    type CommandRestartBuilderAndWaitData = {\n        builderid: string;\n    };\n\n    // wshrpc.CommandSetMetaData\n    type CommandSetMetaData = {\n        oref: ORef;\n        meta: MetaType;\n    };\n\n    // wshrpc.CommandSetRTInfoData\n    type CommandSetRTInfoData = {\n        oref: ORef;\n        data: ObjRTInfo;\n        delete?: boolean;\n    };\n\n    // wshrpc.CommandStartBuilderData\n    type CommandStartBuilderData = {\n        builderid: string;\n    };\n\n    // wshrpc.CommandStartJobData\n    type CommandStartJobData = {\n        cmd: string;\n        args: string[];\n        env: {[key: string]: string};\n        termsize: TermSize;\n        streammeta?: StreamMeta;\n    };\n\n    // wshrpc.CommandStartJobRtnData\n    type CommandStartJobRtnData = {\n        cmdpid: number;\n        cmdstartts: number;\n        jobmanagerpid: number;\n        jobmanagerstartts: number;\n    };\n\n    // wshrpc.CommandStreamAckData\n    type CommandStreamAckData = {\n        id: string;\n        seq: number;\n        rwnd: number;\n        fin?: boolean;\n        delay?: number;\n        cancel?: boolean;\n        error?: string;\n    };\n\n    // wshrpc.CommandStreamData\n    type CommandStreamData = {\n        id: string;\n        seq: number;\n        data64?: string;\n        eof?: boolean;\n        error?: string;\n    };\n\n    // wshrpc.CommandTermGetScrollbackLinesData\n    type CommandTermGetScrollbackLinesData = {\n        linestart: number;\n        lineend: number;\n        lastcommand: boolean;\n    };\n\n    // wshrpc.CommandTermGetScrollbackLinesRtnData\n    type CommandTermGetScrollbackLinesRtnData = {\n        totallines: number;\n        linestart: number;\n        lines: string[];\n        lastupdated: number;\n    };\n\n    // wshrpc.CommandVarData\n    type CommandVarData = {\n        key: string;\n        val?: string;\n        remove?: boolean;\n        zoneid: string;\n        filename: string;\n    };\n\n    // wshrpc.CommandVarResponseData\n    type CommandVarResponseData = {\n        key: string;\n        val: string;\n        exists: boolean;\n    };\n\n    // wshrpc.CommandWaitForRouteData\n    type CommandWaitForRouteData = {\n        routeid: string;\n        waitms: number;\n    };\n\n    // wshrpc.CommandWaveAIAddContextData\n    type CommandWaveAIAddContextData = {\n        files?: AIAttachedFile[];\n        text?: string;\n        submit?: boolean;\n        newchat?: boolean;\n    };\n\n    // wshrpc.CommandWaveAIGetToolDiffData\n    type CommandWaveAIGetToolDiffData = {\n        chatid: string;\n        toolcallid: string;\n    };\n\n    // wshrpc.CommandWaveAIGetToolDiffRtnData\n    type CommandWaveAIGetToolDiffRtnData = {\n        originalcontents64: string;\n        modifiedcontents64: string;\n    };\n\n    // wshrpc.CommandWaveAIToolApproveData\n    type CommandWaveAIToolApproveData = {\n        toolcallid: string;\n        approval?: string;\n    };\n\n    // wshrpc.CommandWaveFileReadStreamData\n    type CommandWaveFileReadStreamData = {\n        zoneid: string;\n        name: string;\n        streammeta: StreamMeta;\n    };\n\n    // wshrpc.CommandWebSelectorData\n    type CommandWebSelectorData = {\n        workspaceid: string;\n        blockid: string;\n        tabid: string;\n        selector: string;\n        opts?: WebSelectorOpts;\n    };\n\n    // wshrpc.CommandWriteAppFileData\n    type CommandWriteAppFileData = {\n        appid: string;\n        filename: string;\n        data64: string;\n    };\n\n    // wshrpc.CommandWriteAppGoFileData\n    type CommandWriteAppGoFileData = {\n        appid: string;\n        data64: string;\n    };\n\n    // wshrpc.CommandWriteAppGoFileRtnData\n    type CommandWriteAppGoFileRtnData = {\n        data64: string;\n    };\n\n    // wshrpc.CommandWriteAppSecretBindingsData\n    type CommandWriteAppSecretBindingsData = {\n        appid: string;\n        bindings: {[key: string]: string};\n    };\n\n    // wshrpc.CommandWriteTempFileData\n    type CommandWriteTempFileData = {\n        filename: string;\n        data64: string;\n    };\n\n    // wconfig.ConfigError\n    type ConfigError = {\n        file: string;\n        err: string;\n    };\n\n    // wshrpc.ConnConfigRequest\n    type ConnConfigRequest = {\n        host: string;\n        metamaptype: MetaType;\n    };\n\n    // wshrpc.ConnExtData\n    type ConnExtData = {\n        connname: string;\n        logblockid?: string;\n    };\n\n    // wconfig.ConnKeywords\n    type ConnKeywords = {\n        \"conn:wshenabled\"?: boolean;\n        \"conn:askbeforewshinstall\"?: boolean;\n        \"conn:wshpath\"?: string;\n        \"conn:shellpath\"?: string;\n        \"conn:ignoresshconfig\"?: boolean;\n        \"display:hidden\"?: boolean;\n        \"display:order\"?: number;\n        \"term:*\"?: boolean;\n        \"term:fontsize\"?: number;\n        \"term:fontfamily\"?: string;\n        \"term:theme\"?: string;\n        \"term:durable\"?: boolean;\n        \"cmd:env\"?: {[key: string]: string};\n        \"cmd:initscript\"?: string;\n        \"cmd:initscript.sh\"?: string;\n        \"cmd:initscript.bash\"?: string;\n        \"cmd:initscript.zsh\"?: string;\n        \"cmd:initscript.pwsh\"?: string;\n        \"cmd:initscript.fish\"?: string;\n        \"ssh:user\"?: string;\n        \"ssh:hostname\"?: string;\n        \"ssh:port\"?: string;\n        \"ssh:identityfile\"?: string[];\n        \"ssh:passwordsecretname\"?: string;\n        \"ssh:batchmode\"?: boolean;\n        \"ssh:pubkeyauthentication\"?: boolean;\n        \"ssh:passwordauthentication\"?: boolean;\n        \"ssh:kbdinteractiveauthentication\"?: boolean;\n        \"ssh:preferredauthentications\"?: string[];\n        \"ssh:addkeystoagent\"?: boolean;\n        \"ssh:identityagent\"?: string;\n        \"ssh:identitiesonly\"?: boolean;\n        \"ssh:proxyjump\"?: string[];\n        \"ssh:userknownhostsfile\"?: string[];\n        \"ssh:globalknownhostsfile\"?: string[];\n    };\n\n    // wshrpc.ConnRequest\n    type ConnRequest = {\n        host: string;\n        keywords?: ConnKeywords;\n        logblockid?: string;\n    };\n\n    // wshrpc.ConnStatus\n    type ConnStatus = {\n        status: string;\n        connhealthstatus?: string;\n        wshenabled: boolean;\n        connection: string;\n        connected: boolean;\n        hasconnected: boolean;\n        activeconnnum: number;\n        error?: string;\n        wsherror?: string;\n        nowshreason?: string;\n        wshversion?: string;\n        lastactivitybeforestalledtime?: number;\n        keepalivesenttime?: number;\n    };\n\n    // wshrpc.CpuDataRequest\n    type CpuDataRequest = {\n        id: string;\n        count: number;\n    };\n\n    // wshrpc.DirEntryOut\n    type DirEntryOut = {\n        name: string;\n        dir?: boolean;\n        symlink?: boolean;\n        size?: number;\n        mode: string;\n        modified: string;\n        modifiedtime: string;\n    };\n\n    // vdom.DomRect\n    type DomRect = {\n        top: number;\n        left: number;\n        right: number;\n        bottom: number;\n        width: number;\n        height: number;\n    };\n\n    // wshrpc.FetchSuggestionsData\n    type FetchSuggestionsData = {\n        suggestiontype: string;\n        query: string;\n        widgetid: string;\n        reqnum: number;\n        \"file:cwd\"?: string;\n        \"file:dironly\"?: boolean;\n        \"file:connection\"?: string;\n    };\n\n    // wshrpc.FetchSuggestionsResponse\n    type FetchSuggestionsResponse = {\n        reqnum: number;\n        suggestions: SuggestionType[];\n    };\n\n    // wshrpc.FileCopyOpts\n    type FileCopyOpts = {\n        overwrite?: boolean;\n        recursive?: boolean;\n        merge?: boolean;\n        timeout?: number;\n    };\n\n    // wshrpc.FileData\n    type FileData = {\n        info?: FileInfo;\n        data64?: string;\n        entries?: FileInfo[];\n        at?: FileDataAt;\n    };\n\n    // wshrpc.FileDataAt\n    type FileDataAt = {\n        offset: number;\n        size?: number;\n    };\n\n    // waveobj.FileDef\n    type FileDef = {\n        content?: string;\n        meta?: {[key: string]: any};\n    };\n\n    // wshrpc.FileInfo\n    type FileInfo = {\n        path: string;\n        dir?: string;\n        name?: string;\n        staterror?: string;\n        notfound?: boolean;\n        opts?: FileOpts;\n        size?: number;\n        meta?: {[key: string]: any};\n        mode?: number;\n        modestr?: string;\n        modtime?: number;\n        isdir?: boolean;\n        supportsmkdir?: boolean;\n        mimetype?: string;\n        readonly?: boolean;\n    };\n\n    // wshrpc.FileListData\n    type FileListData = {\n        path: string;\n        opts?: FileListOpts;\n    };\n\n    // wshrpc.FileListOpts\n    type FileListOpts = {\n        all?: boolean;\n        offset?: number;\n        limit?: number;\n    };\n\n    // wshrpc.FileOpts\n    type FileOpts = {\n        maxsize?: number;\n        circular?: boolean;\n        ijson?: boolean;\n        ijsonbudget?: number;\n        truncate?: boolean;\n        append?: boolean;\n    };\n\n    // wshrpc.FocusedBlockData\n    type FocusedBlockData = {\n        blockid: string;\n        viewtype: string;\n        controller: string;\n        connname: string;\n        blockmeta: MetaType;\n        termjobstatus?: BlockJobStatusData;\n        connstatus?: ConnStatus;\n        termshellintegrationstatus?: string;\n        termlastcommand?: string;\n    };\n\n    // wconfig.FullConfigType\n    type FullConfigType = {\n        settings: SettingsType;\n        mimetypes: {[key: string]: MimeTypeConfigType};\n        defaultwidgets: {[key: string]: WidgetConfigType};\n        widgets: {[key: string]: WidgetConfigType};\n        presets: {[key: string]: MetaType};\n        termthemes: {[key: string]: TermThemeType};\n        connections: {[key: string]: ConnKeywords};\n        bookmarks: {[key: string]: WebBookmark};\n        waveai: {[key: string]: AIModeConfigType};\n        configerrors: ConfigError[];\n    };\n\n    // waveobj.Job\n    type Job = WaveObj & {\n        connection: string;\n        jobkind: string;\n        cmd: string;\n        cmdargs?: string[];\n        cmdenv?: {[key: string]: string};\n        jobauthtoken: string;\n        attachedblockid?: string;\n        waveversion?: string;\n        terminateonreconnect?: boolean;\n        jobmanagerstatus: string;\n        jobmanagerdonereason?: string;\n        jobmanagerstartuperror?: string;\n        jobmanagerpid?: number;\n        jobmanagerstartts?: number;\n        cmdpid?: number;\n        cmdstartts?: number;\n        cmdtermsize: TermSize;\n        cmdexitts?: number;\n        cmdexitcode?: number;\n        cmdexitsignal?: string;\n        cmdexiterror?: string;\n        streamdone?: boolean;\n        streamerror?: string;\n    };\n\n    // wshrpc.JobManagerStatusUpdate\n    type JobManagerStatusUpdate = {\n        jobid: string;\n        jobmanagerstatus: string;\n    };\n\n    // waveobj.LayoutActionData\n    type LayoutActionData = {\n        actiontype: string;\n        actionid: string;\n        blockid: string;\n        nodesize?: number;\n        indexarr?: number[];\n        focused: boolean;\n        magnified: boolean;\n        ephemeral: boolean;\n        targetblockid?: string;\n        position?: string;\n    };\n\n    // waveobj.LayoutState\n    type LayoutState = WaveObj & {\n        rootnode?: any;\n        magnifiednodeid?: string;\n        focusednodeid?: string;\n        leaforder?: LeafOrderEntry[];\n        pendingbackendactions?: LayoutActionData[];\n    };\n\n    // waveobj.LeafOrderEntry\n    type LeafOrderEntry = {\n        nodeid: string;\n        blockid: string;\n    };\n\n    // waveobj.MetaTSType\n    type MetaType = {\n        view?: string;\n        controller?: string;\n        file?: string;\n        url?: string;\n        pinnedurl?: string;\n        connection?: string;\n        edit?: boolean;\n        history?: string[];\n        \"history:forward\"?: string[];\n        \"display:name\"?: string;\n        \"display:order\"?: number;\n        icon?: string;\n        \"icon:color\"?: string;\n        \"frame:*\"?: boolean;\n        frame?: boolean;\n        \"frame:bordercolor\"?: string;\n        \"frame:activebordercolor\"?: string;\n        \"frame:title\"?: string;\n        \"frame:icon\"?: string;\n        \"frame:text\"?: string;\n        \"cmd:*\"?: boolean;\n        cmd?: string;\n        \"cmd:interactive\"?: boolean;\n        \"cmd:login\"?: boolean;\n        \"cmd:persistent\"?: boolean;\n        \"cmd:runonstart\"?: boolean;\n        \"cmd:clearonstart\"?: boolean;\n        \"cmd:runonce\"?: boolean;\n        \"cmd:closeonexit\"?: boolean;\n        \"cmd:closeonexitforce\"?: boolean;\n        \"cmd:closeonexitdelay\"?: number;\n        \"cmd:nowsh\"?: boolean;\n        \"cmd:args\"?: string[];\n        \"cmd:shell\"?: boolean;\n        \"cmd:allowconnchange\"?: boolean;\n        \"cmd:jwt\"?: boolean;\n        \"cmd:env\"?: {[key: string]: string};\n        \"cmd:cwd\"?: string;\n        \"cmd:initscript\"?: string;\n        \"cmd:initscript.sh\"?: string;\n        \"cmd:initscript.bash\"?: string;\n        \"cmd:initscript.zsh\"?: string;\n        \"cmd:initscript.pwsh\"?: string;\n        \"cmd:initscript.fish\"?: string;\n        \"ai:*\"?: boolean;\n        \"ai:preset\"?: string;\n        \"ai:apitype\"?: string;\n        \"ai:baseurl\"?: string;\n        \"ai:apitoken\"?: string;\n        \"ai:name\"?: string;\n        \"ai:model\"?: string;\n        \"ai:orgid\"?: string;\n        \"ai:apiversion\"?: string;\n        \"ai:maxtokens\"?: number;\n        \"ai:timeoutms\"?: number;\n        \"aifilediff:chatid\"?: string;\n        \"aifilediff:toolcallid\"?: string;\n        \"editor:*\"?: boolean;\n        \"editor:minimapenabled\"?: boolean;\n        \"editor:stickyscrollenabled\"?: boolean;\n        \"editor:wordwrap\"?: boolean;\n        \"editor:fontsize\"?: number;\n        \"graph:*\"?: boolean;\n        \"graph:numpoints\"?: number;\n        \"graph:metrics\"?: string[];\n        \"sysinfo:type\"?: string;\n        \"tab:flagcolor\"?: string;\n        \"bg:*\"?: boolean;\n        bg?: string;\n        \"bg:opacity\"?: number;\n        \"bg:blendmode\"?: string;\n        \"bg:bordercolor\"?: string;\n        \"bg:activebordercolor\"?: string;\n        \"layout:vtabbarwidth\"?: number;\n        \"waveai:panelopen\"?: boolean;\n        \"waveai:panelwidth\"?: number;\n        \"waveai:model\"?: string;\n        \"waveai:chatid\"?: string;\n        \"waveai:widgetcontext\"?: boolean;\n        \"term:*\"?: boolean;\n        \"term:fontsize\"?: number;\n        \"term:fontfamily\"?: string;\n        \"term:mode\"?: string;\n        \"term:theme\"?: string;\n        \"term:localshellpath\"?: string;\n        \"term:localshellopts\"?: string[];\n        \"term:scrollback\"?: number;\n        \"term:vdomblockid\"?: string;\n        \"term:vdomtoolbarblockid\"?: string;\n        \"term:transparency\"?: number;\n        \"term:allowbracketedpaste\"?: boolean;\n        \"term:shiftenternewline\"?: boolean;\n        \"term:macoptionismeta\"?: boolean;\n        \"term:cursor\"?: string;\n        \"term:cursorblink\"?: boolean;\n        \"term:conndebug\"?: string;\n        \"term:bellsound\"?: boolean;\n        \"term:bellindicator\"?: boolean;\n        \"term:osc52\"?: string;\n        \"term:durable\"?: boolean;\n        \"web:zoom\"?: number;\n        \"web:hidenav\"?: boolean;\n        \"web:partition\"?: string;\n        \"web:useragenttype\"?: string;\n        \"markdown:fontsize\"?: number;\n        \"markdown:fixedfontsize\"?: number;\n        \"tsunami:*\"?: boolean;\n        \"tsunami:sdkreplacepath\"?: string;\n        \"tsunami:apppath\"?: string;\n        \"tsunami:appid\"?: string;\n        \"tsunami:scaffoldpath\"?: string;\n        \"tsunami:env\"?: {[key: string]: string};\n        \"vdom:*\"?: boolean;\n        \"vdom:initialized\"?: boolean;\n        \"vdom:correlationid\"?: string;\n        \"vdom:route\"?: string;\n        \"vdom:persist\"?: boolean;\n        \"onboarding:githubstar\"?: boolean;\n        \"onboarding:lastversion\"?: string;\n        count?: number;\n    };\n\n    // tsgenmeta.MethodMeta\n    type MethodMeta = {\n        Desc: string;\n        ArgNames: string[];\n        ReturnDesc: string;\n    };\n\n    // wconfig.MimeTypeConfigType\n    type MimeTypeConfigType = {\n        icon: string;\n        color: string;\n    };\n\n    // waveobj.ORef\n    type ORef = string;\n\n    // waveobj.ObjRTInfo\n    type ObjRTInfo = {\n        \"tsunami:appmeta\"?: AppMeta;\n        \"tsunami:schemas\"?: any;\n        \"shell:hascurcwd\"?: boolean;\n        \"shell:state\"?: string;\n        \"shell:type\"?: string;\n        \"shell:version\"?: string;\n        \"shell:uname\"?: string;\n        \"shell:integration\"?: boolean;\n        \"shell:omz\"?: boolean;\n        \"shell:comp\"?: string;\n        \"shell:inputempty\"?: boolean;\n        \"shell:lastcmd\"?: string;\n        \"shell:lastcmdexitcode\"?: number;\n        \"builder:layout\"?: {[key: string]: number};\n        \"builder:appid\"?: string;\n        \"builder:env\"?: {[key: string]: string};\n        \"waveai:chatid\"?: string;\n        \"waveai:mode\"?: string;\n        \"waveai:maxoutputtokens\"?: number;\n    };\n\n    // wshrpc.PathCommandData\n    type PathCommandData = {\n        pathtype: string;\n        open: boolean;\n        openexternal: boolean;\n        tabid: string;\n    };\n\n    // waveobj.Point\n    type Point = {\n        x: number;\n        y: number;\n    };\n\n    // uctypes.RateLimitInfo\n    type RateLimitInfo = {\n        req: number;\n        reqlimit: number;\n        preq: number;\n        preqlimit: number;\n        resetepoch: number;\n        unknown?: boolean;\n    };\n\n    // wshrpc.RemoteInfo\n    type RemoteInfo = {\n        clientarch: string;\n        clientos: string;\n        clientversion: string;\n        shell: string;\n        homedir: string;\n    };\n\n    // wshrpc.RestartBuilderAndWaitResult\n    type RestartBuilderAndWaitResult = {\n        success: boolean;\n        errormessage?: string;\n        buildoutput: string;\n    };\n\n    // wshrpc.RpcContext\n    type RpcContext = {\n        sockname?: string;\n        routeid: string;\n        procroute?: boolean;\n        blockid?: string;\n        conn?: string;\n        isrouter?: boolean;\n    };\n\n    // wshutil.RpcMessage\n    type RpcMessage = {\n        command?: string;\n        reqid?: string;\n        resid?: string;\n        timeout?: number;\n        route?: string;\n        source?: string;\n        cont?: boolean;\n        cancel?: boolean;\n        error?: string;\n        datatype?: string;\n        data?: any;\n    };\n\n    // wshrpc.RpcOpts\n    type RpcOpts = {\n        timeout?: number;\n        noresponse?: boolean;\n        route?: string;\n    };\n\n    // waveobj.RuntimeOpts\n    type RuntimeOpts = {\n        termsize?: TermSize;\n        winsize?: WinSize;\n    };\n\n    // wshrpc.SecretMeta\n    type SecretMeta = {\n        desc: string;\n        optional: boolean;\n    };\n\n    // wconfig.SettingsType\n    type SettingsType = {\n        \"app:*\"?: boolean;\n        \"app:globalhotkey\"?: string;\n        \"app:dismissarchitecturewarning\"?: boolean;\n        \"app:defaultnewblock\"?: string;\n        \"app:showoverlayblocknums\"?: boolean;\n        \"app:ctrlvpaste\"?: boolean;\n        \"app:confirmquit\"?: boolean;\n        \"app:hideaibutton\"?: boolean;\n        \"app:disablectrlshiftarrows\"?: boolean;\n        \"app:disablectrlshiftdisplay\"?: boolean;\n        \"app:focusfollowscursor\"?: string;\n        \"app:tabbar\"?: string;\n        \"feature:waveappbuilder\"?: boolean;\n        \"ai:*\"?: boolean;\n        \"ai:preset\"?: string;\n        \"ai:apitype\"?: string;\n        \"ai:baseurl\"?: string;\n        \"ai:apitoken\"?: string;\n        \"ai:name\"?: string;\n        \"ai:model\"?: string;\n        \"ai:orgid\"?: string;\n        \"ai:apiversion\"?: string;\n        \"ai:maxtokens\"?: number;\n        \"ai:timeoutms\"?: number;\n        \"ai:proxyurl\"?: string;\n        \"ai:fontsize\"?: number;\n        \"ai:fixedfontsize\"?: number;\n        \"waveai:showcloudmodes\"?: boolean;\n        \"waveai:defaultmode\"?: string;\n        \"term:*\"?: boolean;\n        \"term:fontsize\"?: number;\n        \"term:fontfamily\"?: string;\n        \"term:theme\"?: string;\n        \"term:disablewebgl\"?: boolean;\n        \"term:localshellpath\"?: string;\n        \"term:localshellopts\"?: string[];\n        \"term:gitbashpath\"?: string;\n        \"term:scrollback\"?: number;\n        \"term:copyonselect\"?: boolean;\n        \"term:transparency\"?: number;\n        \"term:allowbracketedpaste\"?: boolean;\n        \"term:shiftenternewline\"?: boolean;\n        \"term:macoptionismeta\"?: boolean;\n        \"term:cursor\"?: string;\n        \"term:cursorblink\"?: boolean;\n        \"term:bellsound\"?: boolean;\n        \"term:bellindicator\"?: boolean;\n        \"term:osc52\"?: string;\n        \"term:durable\"?: boolean;\n        \"editor:minimapenabled\"?: boolean;\n        \"editor:stickyscrollenabled\"?: boolean;\n        \"editor:wordwrap\"?: boolean;\n        \"editor:fontsize\"?: number;\n        \"editor:inlinediff\"?: boolean;\n        \"web:*\"?: boolean;\n        \"web:openlinksinternally\"?: boolean;\n        \"web:defaulturl\"?: string;\n        \"web:defaultsearch\"?: string;\n        \"autoupdate:*\"?: boolean;\n        \"autoupdate:enabled\"?: boolean;\n        \"autoupdate:intervalms\"?: number;\n        \"autoupdate:installonquit\"?: boolean;\n        \"autoupdate:channel\"?: string;\n        \"markdown:fontsize\"?: number;\n        \"markdown:fixedfontsize\"?: number;\n        \"preview:showhiddenfiles\"?: boolean;\n        \"preview:defaultsort\"?: string;\n        \"tab:preset\"?: string;\n        \"tab:confirmclose\"?: boolean;\n        \"widget:*\"?: boolean;\n        \"widget:showhelp\"?: boolean;\n        \"window:*\"?: boolean;\n        \"window:fullscreenonlaunch\"?: boolean;\n        \"window:transparent\"?: boolean;\n        \"window:blur\"?: boolean;\n        \"window:opacity\"?: number;\n        \"window:bgcolor\"?: string;\n        \"window:reducedmotion\"?: boolean;\n        \"window:tilegapsize\"?: number;\n        \"window:showmenubar\"?: boolean;\n        \"window:nativetitlebar\"?: boolean;\n        \"window:disablehardwareacceleration\"?: boolean;\n        \"window:maxtabcachesize\"?: number;\n        \"window:magnifiedblockopacity\"?: number;\n        \"window:magnifiedblocksize\"?: number;\n        \"window:magnifiedblockblurprimarypx\"?: number;\n        \"window:magnifiedblockblursecondarypx\"?: number;\n        \"window:confirmclose\"?: boolean;\n        \"window:savelastwindow\"?: boolean;\n        \"window:dimensions\"?: string;\n        \"window:zoom\"?: number;\n        \"telemetry:*\"?: boolean;\n        \"telemetry:enabled\"?: boolean;\n        \"conn:*\"?: boolean;\n        \"conn:askbeforewshinstall\"?: boolean;\n        \"conn:wshenabled\"?: boolean;\n        \"conn:localhostdisplayname\"?: string;\n        \"debug:*\"?: boolean;\n        \"debug:pprofport\"?: number;\n        \"debug:pprofmemprofilerate\"?: number;\n        \"debug:webglstatus\"?: boolean;\n        \"tsunami:*\"?: boolean;\n        \"tsunami:scaffoldpath\"?: string;\n        \"tsunami:sdkreplacepath\"?: string;\n        \"tsunami:sdkversion\"?: string;\n        \"tsunami:gopath\"?: string;\n    };\n\n    // waveobj.StickerClickOptsType\n    type StickerClickOptsType = {\n        sendinput?: string;\n        createblock?: BlockDef;\n    };\n\n    // waveobj.StickerDisplayOptsType\n    type StickerDisplayOptsType = {\n        icon: string;\n        imgsrc: string;\n        svgblob?: string;\n    };\n\n    // waveobj.StickerType\n    type StickerType = {\n        stickertype: string;\n        style: {[key: string]: any};\n        clickopts?: StickerClickOptsType;\n        display: StickerDisplayOptsType;\n    };\n\n    // wshrpc.StreamMeta\n    type StreamMeta = {\n        id: string;\n        rwnd: number;\n        readerrouteid: string;\n        writerrouteid: string;\n    };\n\n    // wps.SubscriptionRequest\n    type SubscriptionRequest = {\n        event: string;\n        scopes?: string[];\n        allscopes?: boolean;\n    };\n\n    // wshrpc.SuggestionType\n    type SuggestionType = {\n        type: string;\n        suggestionid: string;\n        display: string;\n        subtext?: string;\n        icon?: string;\n        iconcolor?: string;\n        iconsrc?: string;\n        matchpos?: number[];\n        submatchpos?: number[];\n        score?: number;\n        \"file:mimetype\"?: string;\n        \"file:path\"?: string;\n        \"file:name\"?: string;\n        \"url:url\"?: string;\n    };\n\n    // telemetrydata.TEvent\n    type TEvent = {\n        uuid?: string;\n        ts?: number;\n        tslocal?: string;\n        event: string;\n        props: TEventProps;\n    };\n\n    // telemetrydata.TEventProps\n    type TEventProps = {\n        \"client:arch\"?: string;\n        \"client:version\"?: string;\n        \"client:initial_version\"?: string;\n        \"client:buildtime\"?: string;\n        \"client:osrelease\"?: string;\n        \"client:isdev\"?: boolean;\n        \"client:packagetype\"?: string;\n        \"client:macos\"?: string;\n        \"cohort:month\"?: string;\n        \"cohort:isoweek\"?: string;\n        \"autoupdate:channel\"?: string;\n        \"autoupdate:enabled\"?: boolean;\n        \"localshell:type\"?: string;\n        \"localshell:version\"?: string;\n        \"loc:countrycode\"?: string;\n        \"loc:regioncode\"?: string;\n        \"settings:customwidgets\"?: number;\n        \"settings:customaipresets\"?: number;\n        \"settings:customsettings\"?: number;\n        \"settings:customaimodes\"?: number;\n        \"settings:secretscount\"?: number;\n        \"settings:transparent\"?: boolean;\n        \"activity:activeminutes\"?: number;\n        \"activity:fgminutes\"?: number;\n        \"activity:openminutes\"?: number;\n        \"activity:waveaiactiveminutes\"?: number;\n        \"activity:waveaifgminutes\"?: number;\n        \"activity:termcommandsrun\"?: number;\n        \"activity:termcommands:remote\"?: number;\n        \"activity:termcommands:durable\"?: number;\n        \"activity:termcommands:wsl\"?: number;\n        \"app:firstday\"?: boolean;\n        \"app:firstlaunch\"?: boolean;\n        \"action:initiator\"?: \"keyboard\" | \"mouse\";\n        \"action:type\"?: string;\n        \"debug:panictype\"?: string;\n        \"block:view\"?: string;\n        \"block:controller\"?: string;\n        \"ai:backendtype\"?: string;\n        \"ai:local\"?: boolean;\n        \"wsh:cmd\"?: string;\n        \"wsh:haderror\"?: boolean;\n        \"conn:conntype\"?: string;\n        \"conn:wsherrorcode\"?: string;\n        \"conn:errorcode\"?: string;\n        \"conn:suberrorcode\"?: string;\n        \"conn:contexterror\"?: boolean;\n        \"onboarding:feature\"?: \"waveai\" | \"durable\" | \"magnify\" | \"wsh\";\n        \"onboarding:version\"?: string;\n        \"onboarding:githubstar\"?: \"already\" | \"star\" | \"later\";\n        \"onboarding:page\"?: string;\n        \"display:height\"?: number;\n        \"display:width\"?: number;\n        \"display:dpr\"?: number;\n        \"display:count\"?: number;\n        \"display:all\"?: any;\n        \"count:blocks\"?: number;\n        \"count:tabs\"?: number;\n        \"count:windows\"?: number;\n        \"count:workspaces\"?: number;\n        \"count:sshconn\"?: number;\n        \"count:wslconn\"?: number;\n        \"count:jobs\"?: number;\n        \"count:jobsconnected\"?: number;\n        \"count:views\"?: {[key: string]: number};\n        \"waveai:apitype\"?: string;\n        \"waveai:model\"?: string;\n        \"waveai:chatid\"?: string;\n        \"waveai:stepnum\"?: number;\n        \"waveai:inputtokens\"?: number;\n        \"waveai:outputtokens\"?: number;\n        \"waveai:nativewebsearchcount\"?: number;\n        \"waveai:requestcount\"?: number;\n        \"waveai:toolusecount\"?: number;\n        \"waveai:tooluseerrorcount\"?: number;\n        \"waveai:tooldetail\"?: {[key: string]: number};\n        \"waveai:premiumreq\"?: number;\n        \"waveai:proxyreq\"?: number;\n        \"waveai:haderror\"?: boolean;\n        \"waveai:imagecount\"?: number;\n        \"waveai:pdfcount\"?: number;\n        \"waveai:textdoccount\"?: number;\n        \"waveai:textlen\"?: number;\n        \"waveai:firstbytems\"?: number;\n        \"waveai:requestdurms\"?: number;\n        \"waveai:widgetaccess\"?: boolean;\n        \"waveai:thinkinglevel\"?: string;\n        \"waveai:mode\"?: string;\n        \"waveai:provider\"?: string;\n        \"waveai:islocal\"?: boolean;\n        \"waveai:feedback\"?: \"good\" | \"bad\";\n        \"waveai:action\"?: string;\n        \"job:donereason\"?: string;\n        \"job:kind\"?: string;\n        $set?: TEventUserProps;\n        $set_once?: TEventUserProps;\n    };\n\n    // telemetrydata.TEventUserProps\n    type TEventUserProps = {\n        \"client:arch\"?: string;\n        \"client:version\"?: string;\n        \"client:initial_version\"?: string;\n        \"client:buildtime\"?: string;\n        \"client:osrelease\"?: string;\n        \"client:isdev\"?: boolean;\n        \"client:packagetype\"?: string;\n        \"client:macos\"?: string;\n        \"cohort:month\"?: string;\n        \"cohort:isoweek\"?: string;\n        \"autoupdate:channel\"?: string;\n        \"autoupdate:enabled\"?: boolean;\n        \"localshell:type\"?: string;\n        \"localshell:version\"?: string;\n        \"loc:countrycode\"?: string;\n        \"loc:regioncode\"?: string;\n        \"settings:customwidgets\"?: number;\n        \"settings:customaipresets\"?: number;\n        \"settings:customsettings\"?: number;\n        \"settings:customaimodes\"?: number;\n        \"settings:secretscount\"?: number;\n        \"settings:transparent\"?: boolean;\n    };\n\n    // waveobj.Tab\n    type Tab = WaveObj & {\n        name: string;\n        layoutstate: string;\n        blockids: string[];\n    };\n\n    // waveobj.TermSize\n    type TermSize = {\n        rows: number;\n        cols: number;\n    };\n\n    // wconfig.TermThemeType\n    type TermThemeType = {\n        \"display:name\": string;\n        \"display:order\": number;\n        black: string;\n        red: string;\n        green: string;\n        yellow: string;\n        blue: string;\n        magenta: string;\n        cyan: string;\n        white: string;\n        brightBlack: string;\n        brightRed: string;\n        brightGreen: string;\n        brightYellow: string;\n        brightBlue: string;\n        brightMagenta: string;\n        brightCyan: string;\n        brightWhite: string;\n        gray: string;\n        cmdtext: string;\n        foreground: string;\n        selectionBackground: string;\n        background: string;\n        cursor: string;\n    };\n\n    // wshrpc.TimeSeriesData\n    type TimeSeriesData = {\n        ts: number;\n        values: {[key: string]: number};\n    };\n\n    // uctypes.UIChat\n    type UIChat = {\n        chatid: string;\n        apitype: string;\n        model: string;\n        apiversion: string;\n        messages: UIMessage[];\n    };\n\n    // waveobj.UIContext\n    type UIContext = {\n        windowid: string;\n        activetabid: string;\n    };\n\n    // uctypes.UIMessage\n    type UIMessage = {\n        id: string;\n        role: string;\n        metadata?: any;\n        parts?: UIMessagePart[];\n    };\n\n    // uctypes.UIMessagePart\n    type UIMessagePart = {\n        type: string;\n        text?: string;\n        state?: string;\n        toolCallId?: string;\n        input?: any;\n        output?: any;\n        errorText?: string;\n        providerExecuted?: boolean;\n        sourceId?: string;\n        url?: string;\n        title?: string;\n        filename?: string;\n        mediaType?: string;\n        id?: string;\n        data?: any;\n        providerMetadata?: {[key: string]: any};\n    };\n\n    // userinput.UserInputRequest\n    type UserInputRequest = {\n        requestid: string;\n        querytext: string;\n        responsetype: string;\n        title: string;\n        markdown: boolean;\n        timeoutms: number;\n        checkboxmsg: string;\n        publictext: boolean;\n        oklabel?: string;\n        cancellabel?: string;\n    };\n\n    // userinput.UserInputResponse\n    type UserInputResponse = {\n        type: string;\n        requestid: string;\n        text?: string;\n        confirm?: boolean;\n        errormsg?: string;\n        checkboxstat?: boolean;\n    };\n\n    // vdom.VDomAsyncInitiationRequest\n    type VDomAsyncInitiationRequest = {\n        type: \"asyncinitiationrequest\";\n        ts: number;\n        blockid?: string;\n    };\n\n    // vdom.VDomBackendOpts\n    type VDomBackendOpts = {\n        closeonctrlc?: boolean;\n        globalkeyboardevents?: boolean;\n        globalstyles?: boolean;\n    };\n\n    // vdom.VDomBackendUpdate\n    type VDomBackendUpdate = {\n        type: \"backendupdate\";\n        ts: number;\n        blockid: string;\n        opts?: VDomBackendOpts;\n        haswork?: boolean;\n        renderupdates?: VDomRenderUpdate[];\n        transferelems?: VDomTransferElem[];\n        statesync?: VDomStateSync[];\n        refoperations?: VDomRefOperation[];\n        messages?: VDomMessage[];\n    };\n\n    // vdom.VDomBinding\n    type VDomBinding = {\n        type: \"binding\";\n        bind: string;\n    };\n\n    // vdom.VDomCreateContext\n    type VDomCreateContext = {\n        type: \"createcontext\";\n        ts: number;\n        meta?: MetaType;\n        target?: VDomTarget;\n        persist?: boolean;\n    };\n\n    // vdom.VDomElem\n    type VDomElem = {\n        waveid?: string;\n        tag: string;\n        props?: {[key: string]: any};\n        children?: VDomElem[];\n        text?: string;\n    };\n\n    // vdom.VDomEvent\n    type VDomEvent = {\n        waveid: string;\n        eventtype: string;\n        globaleventtype?: string;\n        targetvalue?: string;\n        targetchecked?: boolean;\n        targetname?: string;\n        targetid?: string;\n        keydata?: WaveKeyboardEvent;\n        mousedata?: WavePointerData;\n    };\n\n    // vdom.VDomFrontendUpdate\n    type VDomFrontendUpdate = {\n        type: \"frontendupdate\";\n        ts: number;\n        blockid: string;\n        correlationid?: string;\n        dispose?: boolean;\n        resync?: boolean;\n        rendercontext?: VDomRenderContext;\n        events?: VDomEvent[];\n        statesync?: VDomStateSync[];\n        refupdates?: VDomRefUpdate[];\n        messages?: VDomMessage[];\n    };\n\n    // vdom.VDomFunc\n    type VDomFunc = {\n        type: \"func\";\n        stoppropagation?: boolean;\n        preventdefault?: boolean;\n        globalevent?: string;\n        #keys?: string[];\n    };\n\n    // vdom.VDomMessage\n    type VDomMessage = {\n        messagetype: string;\n        message: string;\n        stacktrace?: string;\n        params?: any[];\n    };\n\n    // vdom.VDomRef\n    type VDomRef = {\n        type: \"ref\";\n        refid: string;\n        trackposition?: boolean;\n        position?: VDomRefPosition;\n        hascurrent?: boolean;\n    };\n\n    // vdom.VDomRefOperation\n    type VDomRefOperation = {\n        refid: string;\n        op: string;\n        params?: any[];\n        outputref?: string;\n    };\n\n    // vdom.VDomRefPosition\n    type VDomRefPosition = {\n        offsetheight: number;\n        offsetwidth: number;\n        scrollheight: number;\n        scrollwidth: number;\n        scrolltop: number;\n        boundingclientrect: DomRect;\n    };\n\n    // vdom.VDomRefUpdate\n    type VDomRefUpdate = {\n        refid: string;\n        hascurrent: boolean;\n        position?: VDomRefPosition;\n    };\n\n    // vdom.VDomRenderContext\n    type VDomRenderContext = {\n        blockid: string;\n        focused: boolean;\n        width: number;\n        height: number;\n        rootrefid: string;\n        background?: boolean;\n    };\n\n    // vdom.VDomRenderUpdate\n    type VDomRenderUpdate = {\n        updatetype: \"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\";\n        waveid?: string;\n        vdomwaveid?: string;\n        vdom?: VDomElem;\n        index?: number;\n    };\n\n    // vdom.VDomStateSync\n    type VDomStateSync = {\n        atom: string;\n        value: any;\n    };\n\n    // vdom.VDomTarget\n    type VDomTarget = {\n        newblock?: boolean;\n        magnified?: boolean;\n        toolbar?: VDomTargetToolbar;\n    };\n\n    // vdom.VDomTargetToolbar\n    type VDomTargetToolbar = {\n        toolbar: boolean;\n        height?: string;\n    };\n\n    // vdom.VDomTransferElem\n    type VDomTransferElem = {\n        waveid?: string;\n        tag: string;\n        props?: {[key: string]: any};\n        children?: string[];\n        text?: string;\n    };\n\n    // wshrpc.VDomUrlRequestData\n    type VDomUrlRequestData = {\n        method: string;\n        url: string;\n        headers: {[key: string]: string};\n        body?: string;\n    };\n\n    // wshrpc.VDomUrlRequestResponse\n    type VDomUrlRequestResponse = {\n        statuscode?: number;\n        headers?: {[key: string]: string};\n        body?: string;\n    };\n\n    type WSCommandType = {\n        wscommand: string;\n    } & ( WSRpcCommand );\n\n    // eventbus.WSEventType\n    type WSEventType = {\n        eventtype: string;\n        oref?: string;\n        data: any;\n    };\n\n    // wps.WSFileEventData\n    type WSFileEventData = {\n        zoneid: string;\n        filename: string;\n        fileop: string;\n        data64: string;\n    };\n\n    // webcmd.WSRpcCommand\n    type WSRpcCommand = {\n        wscommand: \"rpc\";\n        message: RpcMessage;\n    };\n\n    // wconfig.WatcherUpdate\n    type WatcherUpdate = {\n        fullconfig: FullConfigType;\n    };\n\n    // wshrpc.WaveAIOptsType\n    type WaveAIOptsType = {\n        model: string;\n        apitype?: string;\n        apitoken: string;\n        orgid?: string;\n        apiversion?: string;\n        baseurl?: string;\n        proxyurl?: string;\n        maxtokens?: number;\n        maxchoices?: number;\n        timeoutms?: number;\n    };\n\n    // wshrpc.WaveAIPacketType\n    type WaveAIPacketType = {\n        type: string;\n        model?: string;\n        created?: number;\n        finish_reason?: string;\n        usage?: WaveAIUsageType;\n        index?: number;\n        text?: string;\n        error?: string;\n    };\n\n    // wshrpc.WaveAIPromptMessageType\n    type WaveAIPromptMessageType = {\n        role: string;\n        content: string;\n        name?: string;\n    };\n\n    // wshrpc.WaveAIStreamRequest\n    type WaveAIStreamRequest = {\n        clientid?: string;\n        opts: WaveAIOptsType;\n        prompt: WaveAIPromptMessageType[];\n    };\n\n    // wshrpc.WaveAIUsageType\n    type WaveAIUsageType = {\n        prompt_tokens?: number;\n        completion_tokens?: number;\n        total_tokens?: number;\n    };\n\n\n    // filestore.WaveFile\n    type WaveFile = {\n        zoneid: string;\n        name: string;\n        opts: FileOpts;\n        createdts: number;\n        size: number;\n        modts: number;\n        meta: {[key: string]: any};\n    };\n\n    // wshrpc.WaveFileInfo\n    type WaveFileInfo = {\n        zoneid: string;\n        name: string;\n        opts: FileOpts;\n        createdts: number;\n        size: number;\n        modts: number;\n        meta: {[key: string]: any};\n    };\n\n    // wshrpc.WaveInfoData\n    type WaveInfoData = {\n        version: string;\n        clientid: string;\n        buildtime: string;\n        configdir: string;\n        datadir: string;\n    };\n\n    // vdom.WaveKeyboardEvent\n    type WaveKeyboardEvent = {\n        type: \"keydown\"|\"keyup\"|\"keypress\"|\"unknown\";\n        key: string;\n        code: string;\n        repeat?: boolean;\n        location?: number;\n        shift?: boolean;\n        control?: boolean;\n        alt?: boolean;\n        meta?: boolean;\n        cmd?: boolean;\n        option?: boolean;\n    };\n\n    // wshrpc.WaveNotificationOptions\n    type WaveNotificationOptions = {\n        title?: string;\n        body?: string;\n        silent?: boolean;\n    };\n\n    // waveobj.WaveObj\n    type WaveObj = {\n        otype: string;\n        oid: string;\n        version: number;\n        meta: MetaType;\n    };\n\n    // waveobj.WaveObjUpdate\n    type WaveObjUpdate = {\n        updatetype: string;\n        otype: string;\n        oid: string;\n        obj?: WaveObj;\n    };\n\n    // vdom.WavePointerData\n    type WavePointerData = {\n        button: number;\n        buttons: number;\n        clientx?: number;\n        clienty?: number;\n        pagex?: number;\n        pagey?: number;\n        screenx?: number;\n        screeny?: number;\n        movementx?: number;\n        movementy?: number;\n        shift?: boolean;\n        control?: boolean;\n        alt?: boolean;\n        meta?: boolean;\n        cmd?: boolean;\n        option?: boolean;\n    };\n\n    // waveobj.Window\n    type WaveWindow = WaveObj & {\n        workspaceid: string;\n        isnew?: boolean;\n        pos: Point;\n        winsize: WinSize;\n        lastfocusts: number;\n    };\n\n    // wconfig.WebBookmark\n    type WebBookmark = {\n        url: string;\n        title?: string;\n        icon?: string;\n        iconcolor?: string;\n        iconurl?: string;\n        \"display:order\"?: number;\n    };\n\n    // service.WebCallType\n    type WebCallType = {\n        service: string;\n        method: string;\n        uicontext?: UIContext;\n        args: any[];\n    };\n\n    // service.WebReturnType\n    type WebReturnType = {\n        success?: boolean;\n        error?: string;\n        data?: any;\n        updates?: WaveObjUpdate[];\n    };\n\n    // wshrpc.WebSelectorOpts\n    type WebSelectorOpts = {\n        all?: boolean;\n        inner?: boolean;\n    };\n\n    // wconfig.WidgetConfigType\n    type WidgetConfigType = {\n        \"display:order\"?: number;\n        \"display:hidden\"?: boolean;\n        icon?: string;\n        color?: string;\n        label?: string;\n        description?: string;\n        workspaces?: string[];\n        magnified?: boolean;\n        blockdef: BlockDef;\n    };\n\n    // waveobj.WinSize\n    type WinSize = {\n        width: number;\n        height: number;\n    };\n\n    // waveobj.Workspace\n    type Workspace = WaveObj & {\n        name?: string;\n        icon?: string;\n        color?: string;\n        tabids: string[];\n        activetabid: string;\n    };\n\n    // wshrpc.WorkspaceInfoData\n    type WorkspaceInfoData = {\n        windowid: string;\n        workspacedata: Workspace;\n    };\n\n    // waveobj.WorkspaceListEntry\n    type WorkspaceListEntry = {\n        workspaceid: string;\n        windowid: string;\n    };\n\n    // wshrpc.WshServerCommandMeta\n    type WshServerCommandMeta = {\n        commandtype: string;\n    };\n\n}\n\nexport {}\n"
  },
  {
    "path": "frontend/types/jsx.d.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n/// <reference types=\"react/jsx-runtime\" />"
  },
  {
    "path": "frontend/types/media.d.ts",
    "content": "// this comes from vite/client.d.ts\n// removed the CSS types for easier VSCode \"Go to Definition\" support.\n\n// CSS modules\ntype CSSModuleClasses = { readonly [key: string]: string };\n\ndeclare module \"*.module.css\" {\n    const classes: CSSModuleClasses;\n    export default classes;\n}\ndeclare module \"*.module.scss\" {\n    const classes: CSSModuleClasses;\n    export default classes;\n}\ndeclare module \"*.module.sass\" {\n    const classes: CSSModuleClasses;\n    export default classes;\n}\ndeclare module \"*.module.less\" {\n    const classes: CSSModuleClasses;\n    export default classes;\n}\ndeclare module \"*.module.styl\" {\n    const classes: CSSModuleClasses;\n    export default classes;\n}\ndeclare module \"*.module.stylus\" {\n    const classes: CSSModuleClasses;\n    export default classes;\n}\ndeclare module \"*.module.pcss\" {\n    const classes: CSSModuleClasses;\n    export default classes;\n}\ndeclare module \"*.module.sss\" {\n    const classes: CSSModuleClasses;\n    export default classes;\n}\n\n// Built-in asset types\n// see `src/node/constants.ts`\n\n// images\ndeclare module \"*.apng\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.bmp\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.png\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.jpg\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.jpeg\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.jfif\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.pjpeg\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.pjp\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.gif\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.svg\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.ico\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.webp\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.avif\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.cur\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.jxl\" {\n    const src: string;\n    export default src;\n}\n\n// media\ndeclare module \"*.mp4\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.webm\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.ogg\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.mp3\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.wav\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.flac\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.aac\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.opus\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.mov\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.m4a\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.vtt\" {\n    const src: string;\n    export default src;\n}\n\n// fonts\ndeclare module \"*.woff\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.woff2\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.eot\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.ttf\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.otf\" {\n    const src: string;\n    export default src;\n}\n\n// other\ndeclare module \"*.webmanifest\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.pdf\" {\n    const src: string;\n    export default src;\n}\ndeclare module \"*.txt\" {\n    const src: string;\n    export default src;\n}\n\n// wasm?init\ndeclare module \"*.wasm?init\" {\n    const initWasm: (options?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;\n    export default initWasm;\n}\n\n// web worker\ndeclare module \"*?worker\" {\n    const workerConstructor: {\n        new (options?: { name?: string }): Worker;\n    };\n    export default workerConstructor;\n}\n\ndeclare module \"*?worker&inline\" {\n    const workerConstructor: {\n        new (options?: { name?: string }): Worker;\n    };\n    export default workerConstructor;\n}\n\ndeclare module \"*?worker&url\" {\n    const src: string;\n    export default src;\n}\n\ndeclare module \"*?sharedworker\" {\n    const sharedWorkerConstructor: {\n        new (options?: { name?: string }): SharedWorker;\n    };\n    export default sharedWorkerConstructor;\n}\n\ndeclare module \"*?sharedworker&inline\" {\n    const sharedWorkerConstructor: {\n        new (options?: { name?: string }): SharedWorker;\n    };\n    export default sharedWorkerConstructor;\n}\n\ndeclare module \"*?sharedworker&url\" {\n    const src: string;\n    export default src;\n}\n\ndeclare module \"*?raw\" {\n    const src: string;\n    export default src;\n}\n\ndeclare module \"*?url\" {\n    const src: string;\n    export default src;\n}\n\ndeclare module \"*?inline\" {\n    const src: string;\n    export default src;\n}\n\ndeclare module \"*?no-inline\" {\n    const src: string;\n    export default src;\n}\n\ndeclare module \"*?url&inline\" {\n    const src: string;\n    export default src;\n}\n\ndeclare module \"*?url&no-inline\" {\n    const src: string;\n    export default src;\n}\n\ndeclare interface VitePreloadErrorEvent extends Event {\n    payload: Error;\n}\n\ndeclare interface WindowEventMap {\n    \"vite:preloadError\": VitePreloadErrorEvent;\n}\n\n// import.meta.glob — provided by Vite at build time\ninterface ImportMeta {\n    glob<T = Record<string, unknown>>(\n        pattern: string | string[],\n        options?: { eager?: boolean; import?: string; query?: string | Record<string, string> }\n    ): Record<string, () => Promise<T>>;\n    glob<T = Record<string, unknown>>(\n        pattern: string | string[],\n        options: { eager: true; import?: string; query?: string | Record<string, string> }\n    ): Record<string, T>;\n}\n"
  },
  {
    "path": "frontend/types/vite-env.d.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n/// <reference types=\"vite-plugin-svgr/client\" />\n"
  },
  {
    "path": "frontend/types/waveevent.d.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// generated by cmd/generate/main-generatets.go\n\ndeclare global {\n\n    // wps.WaveEvent\n    type WaveEventName =\n        | \"blockclose\"\n        | \"connchange\"\n        | \"sysinfo\"\n        | \"controllerstatus\"\n        | \"builderstatus\"\n        | \"builderoutput\"\n        | \"waveobj:update\"\n        | \"blockfile\"\n        | \"config\"\n        | \"userinput\"\n        | \"route:down\"\n        | \"route:up\"\n        | \"workspace:update\"\n        | \"waveai:ratelimit\"\n        | \"waveapp:appgoupdated\"\n        | \"tsunami:updatemeta\"\n        | \"waveai:modeconfig\"\n        | \"block:jobstatus\"\n        | \"badge\"\n    ;\n\n    type WaveEvent = {\n        event: WaveEventName;\n        scopes?: string[];\n        sender?: string;\n        persist?: number;\n        data?: unknown;\n    } & (\n        { event: \"blockclose\"; data?: string; } | \n        { event: \"connchange\"; data?: ConnStatus; } | \n        { event: \"sysinfo\"; data?: TimeSeriesData; } | \n        { event: \"controllerstatus\"; data?: BlockControllerRuntimeStatus; } | \n        { event: \"builderstatus\"; data?: BuilderStatusData; } | \n        { event: \"builderoutput\"; data?: {[key: string]: any}; } | \n        { event: \"waveobj:update\"; data?: WaveObjUpdate; } | \n        { event: \"blockfile\"; data?: WSFileEventData; } | \n        { event: \"config\"; data?: WatcherUpdate; } | \n        { event: \"userinput\"; data?: UserInputRequest; } | \n        { event: \"route:down\"; data?: null; } | \n        { event: \"route:up\"; data?: null; } | \n        { event: \"workspace:update\"; data?: null; } | \n        { event: \"waveai:ratelimit\"; data?: RateLimitInfo; } | \n        { event: \"waveapp:appgoupdated\"; data?: null; } | \n        { event: \"tsunami:updatemeta\"; data?: AppMeta; } | \n        { event: \"waveai:modeconfig\"; data?: AIModeConfigUpdate; } | \n        { event: \"block:jobstatus\"; data?: BlockJobStatusData; } | \n        { event: \"badge\"; data?: BadgeEvent; }\n    );\n\n}\n\nexport {}\n"
  },
  {
    "path": "frontend/util/color-validator.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { validateCssColor } from \"./color-validator\";\n\ndescribe(\"validateCssColor\", () => {\n    beforeEach(() => {\n        vi.stubGlobal(\"CSS\", {\n            supports: (_property: string, value: string) => {\n                return [\n                    \"red\",\n                    \"#aabbcc\",\n                    \"#aabbccdd\",\n                    \"rgb(255, 0, 0)\",\n                    \"rgba(255, 0, 0, 0.5)\",\n                    \"hsl(120 100% 50%)\",\n                    \"transparent\",\n                    \"currentColor\",\n                ].includes(value);\n            },\n        });\n    });\n\n    afterEach(() => {\n        vi.unstubAllGlobals();\n    });\n\n    it(\"returns type for supported CSS color formats\", () => {\n        expect(validateCssColor(\"red\")).toBe(\"keyword\");\n        expect(validateCssColor(\"#aabbcc\")).toBe(\"hex\");\n        expect(validateCssColor(\"#aabbccdd\")).toBe(\"hex8\");\n        expect(validateCssColor(\"rgb(255, 0, 0)\")).toBe(\"rgb\");\n        expect(validateCssColor(\"rgba(255, 0, 0, 0.5)\")).toBe(\"rgba\");\n        expect(validateCssColor(\"hsl(120 100% 50%)\")).toBe(\"hsl\");\n        expect(validateCssColor(\"transparent\")).toBe(\"transparent\");\n        expect(validateCssColor(\"currentColor\")).toBe(\"currentcolor\");\n    });\n\n    it(\"throws for invalid CSS colors\", () => {\n        expect(() => validateCssColor(\":not-a-color:\")).toThrow(\"Invalid CSS color\");\n        expect(() => validateCssColor(\"#12\")).toThrow(\"Invalid CSS color\");\n        expect(() => validateCssColor(\"rgb(255, 0)\")).toThrow(\"Invalid CSS color\");\n    });\n});\n"
  },
  {
    "path": "frontend/util/color-validator.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nconst HexColorRegex = /^#([\\da-f]{3}|[\\da-f]{4}|[\\da-f]{6}|[\\da-f]{8})$/i;\nconst FunctionalColorRegex = /^([a-z-]+)\\(/i;\nconst NamedColorRegex = /^[a-z]+$/i;\n\nfunction isValidCssColor(color: string): boolean {\n    if (typeof CSS == \"undefined\" || typeof CSS.supports != \"function\") {\n        return false;\n    }\n    return CSS.supports(\"color\", color);\n}\n\nfunction getCssColorType(color: string): string {\n    const normalizedColor = color.toLowerCase();\n    if (HexColorRegex.test(normalizedColor)) {\n        if (normalizedColor.length === 4) {\n            return \"hex3\";\n        }\n        if (normalizedColor.length === 5) {\n            return \"hex4\";\n        }\n        if (normalizedColor.length === 9) {\n            return \"hex8\";\n        }\n        return \"hex\";\n    }\n    if (normalizedColor === \"transparent\") {\n        return \"transparent\";\n    }\n    if (normalizedColor === \"currentcolor\") {\n        return \"currentcolor\";\n    }\n    const functionMatch = normalizedColor.match(FunctionalColorRegex);\n    if (functionMatch) {\n        return functionMatch[1];\n    }\n    if (NamedColorRegex.test(normalizedColor)) {\n        return \"keyword\";\n    }\n    return \"color\";\n}\n\nexport function validateCssColor(color: string): string {\n    if (typeof color != \"string\") {\n        throw new Error(`Invalid CSS color: ${String(color)}`);\n    }\n    const normalizedColor = color.trim();\n    if (normalizedColor === \"\" || !isValidCssColor(normalizedColor)) {\n        throw new Error(`Invalid CSS color: ${color}`);\n    }\n    return getCssColorType(normalizedColor);\n}\n"
  },
  {
    "path": "frontend/util/endpoints.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { isPreviewWindow } from \"@/app/store/windowtype\";\nimport { getEnv } from \"./getenv\";\nimport { lazy } from \"./util\";\n\nexport const WebServerEndpointVarName = \"WAVE_SERVER_WEB_ENDPOINT\";\nexport const WSServerEndpointVarName = \"WAVE_SERVER_WS_ENDPOINT\";\n\nexport const getWebServerEndpoint = lazy(() => {\n    if (isPreviewWindow()) return null;\n    return `http://${getEnv(WebServerEndpointVarName)}`;\n});\n\nexport const getWSServerEndpoint = lazy(() => {\n    if (isPreviewWindow()) return null;\n    return `ws://${getEnv(WSServerEndpointVarName)}`;\n});\n"
  },
  {
    "path": "frontend/util/fetchutil.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// Utility to abstract the fetch function so the Electron net module can be used when available.\n\nlet net: Electron.Net;\n\nif (typeof window === \"undefined\") {\n    try {\n        import(\"electron\").then(({ net: electronNet }) => (net = electronNet));\n    } catch (e) {\n        // do nothing\n    }\n}\n\nexport function fetch(input: string | GlobalRequest | URL, init?: RequestInit): Promise<Response> {\n    if (net) {\n        return net.fetch(input.toString(), init);\n    } else {\n        return globalThis.fetch(input, init);\n    }\n}\n"
  },
  {
    "path": "frontend/util/focusutil.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0s\n\nimport * as util from \"./util\";\n\nexport function findBlockId(element: HTMLElement): string | null {\n    let current: HTMLElement = element;\n    while (current) {\n        if (current.hasAttribute(\"data-blockid\")) {\n            return current.getAttribute(\"data-blockid\");\n        }\n        current = current.parentElement;\n    }\n    return null;\n}\n\nexport function getElemAsStr(elem: EventTarget) {\n    if (elem == null) {\n        return \"null\";\n    }\n    if (!(elem instanceof HTMLElement)) {\n        if (elem instanceof Text) {\n            elem = elem.parentElement;\n        }\n        if (!(elem instanceof HTMLElement)) {\n            return \"unknown\";\n        }\n    }\n    const blockId = findBlockId(elem);\n    let rtn = elem.tagName.toLowerCase();\n    if (!util.isBlank(elem.id)) {\n        rtn += \"#\" + elem.id;\n    }\n    if (!util.isBlank(elem.className)) {\n        rtn += \".\" + elem.className;\n    }\n    if (blockId != null) {\n        rtn += ` [${blockId.substring(0, 8)}]`;\n    }\n    return rtn;\n}\n\nexport function hasSelection() {\n    const sel = document.getSelection();\n    return sel && sel.rangeCount > 0 && !sel.isCollapsed;\n}\n\nexport function focusedBlockId(): string {\n    const focused = document.activeElement;\n    if (focused instanceof HTMLElement) {\n        const blockId = findBlockId(focused);\n        if (blockId) {\n            return blockId;\n        }\n    }\n    const sel = document.getSelection();\n    if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) {\n        let anchor = sel.anchorNode;\n        if (anchor instanceof Text) {\n            anchor = anchor.parentElement;\n        }\n        if (anchor instanceof HTMLElement) {\n            const blockId = findBlockId(anchor);\n            if (blockId) {\n                return blockId;\n            }\n        }\n    }\n    return null;\n}\n"
  },
  {
    "path": "frontend/util/fontutil.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nlet isJetBrainsMonoLoaded = false;\nlet isHackFontLoaded = false;\nlet isHackNerdFontLoaded = false;\nlet isInterFontLoaded = false;\n\nfunction addToFontFaceSet(fontFaceSet: FontFaceSet, fontFace: FontFace) {\n    // any cast to work around typing issue\n    (fontFaceSet as any).add(fontFace);\n}\n\nfunction loadJetBrainsMonoFont() {\n    if (isJetBrainsMonoLoaded) {\n        return;\n    }\n    isJetBrainsMonoLoaded = true;\n    const jbmFontNormal = new FontFace(\"JetBrains Mono\", \"url('fonts/jetbrains-mono-v13-latin-regular.woff2')\", {\n        style: \"normal\",\n        weight: \"400\",\n    });\n    const jbmFont200 = new FontFace(\"JetBrains Mono\", \"url('fonts/jetbrains-mono-v13-latin-200.woff2')\", {\n        style: \"normal\",\n        weight: \"200\",\n    });\n    const jbmFont700 = new FontFace(\"JetBrains Mono\", \"url('fonts/jetbrains-mono-v13-latin-700.woff2')\", {\n        style: \"normal\",\n        weight: \"700\",\n    });\n    addToFontFaceSet(document.fonts, jbmFontNormal);\n    addToFontFaceSet(document.fonts, jbmFont200);\n    addToFontFaceSet(document.fonts, jbmFont700);\n    jbmFontNormal.load();\n    jbmFont200.load();\n    jbmFont700.load();\n}\n\nfunction loadHackNerdFont() {\n    if (isHackNerdFontLoaded) {\n        return;\n    }\n    isHackFontLoaded = true;\n    const hackRegular = new FontFace(\"Hack\", \"url('fonts/hacknerdmono-regular.ttf')\", {\n        style: \"normal\",\n        weight: \"400\",\n    });\n    const hackBold = new FontFace(\"Hack\", \"url('fonts/hacknerdmono-bold.ttf')\", {\n        style: \"normal\",\n        weight: \"700\",\n    });\n    const hackItalic = new FontFace(\"Hack\", \"url('fonts/hacknerdmono-italic.ttf')\", {\n        style: \"italic\",\n        weight: \"400\",\n    });\n    const hackBoldItalic = new FontFace(\"Hack\", \"url('fonts/hacknerdmono-bolditalic.ttf')\", {\n        style: \"italic\",\n        weight: \"700\",\n    });\n    addToFontFaceSet(document.fonts, hackRegular);\n    addToFontFaceSet(document.fonts, hackBold);\n    addToFontFaceSet(document.fonts, hackItalic);\n    addToFontFaceSet(document.fonts, hackBoldItalic);\n    hackRegular.load();\n    hackBold.load();\n    hackItalic.load();\n    hackBoldItalic.load();\n}\n\nfunction loadInterFont() {\n    if (isInterFontLoaded) {\n        return;\n    }\n    isInterFontLoaded = true;\n    const interFont = new FontFace(\"Inter\", \"url('fonts/inter-variable.woff2')\", {\n        style: \"normal\",\n        weight: \"100 900\",\n    });\n    addToFontFaceSet(document.fonts, interFont);\n    interFont.load();\n}\n\nfunction loadFonts() {\n    loadInterFont();\n    loadJetBrainsMonoFont();\n    loadHackNerdFont();\n}\n\nexport { loadFonts };\n"
  },
  {
    "path": "frontend/util/getenv.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nfunction getWindow(): Window {\n    return globalThis.window;\n}\n\nfunction getProcess(): NodeJS.Process {\n    return globalThis.process;\n}\n\nfunction getApi(): ElectronApi {\n    return (window as any).api;\n}\n\n/**\n * Gets an environment variable from the host process, either directly or via IPC if called from the browser.\n * @param paramName The name of the environment variable to attempt to retrieve.\n * @returns The value of the environment variable or null if not present.\n */\nexport function getEnv(paramName: string): string {\n    const win = getWindow();\n    if (win != null) {\n        return getApi().getEnv(paramName);\n    }\n    const proc = getProcess();\n    if (proc != null) {\n        return proc.env[paramName];\n    }\n    return null;\n}\n"
  },
  {
    "path": "frontend/util/historyutil.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport * as util from \"@/util/util\";\n\nconst MaxHistory = 20;\n\n// this needs to be fixed for windows\nfunction getParentDirectory(path: string): string {\n    if (util.isBlank(path) == null) {\n        // this not great, ideally we'd never be passed a null path\n        return \"/\";\n    }\n    if (path == \"/\") {\n        return \"/\";\n    }\n    const splitPath = path.split(\"/\");\n    splitPath.pop();\n    if (splitPath.length == 1 && splitPath[0] == \"\") {\n        return \"/\";\n    }\n    const newPath = splitPath.join(\"/\");\n    return newPath;\n}\n\nfunction goHistoryBack(curValKey: \"url\" | \"file\", curVal: string, meta: MetaType, backToParent: boolean): MetaType {\n    const rtnMeta: MetaType = {};\n    const history = (meta?.history ?? []).slice();\n    const historyForward = (meta?.[\"history:forward\"] ?? []).slice();\n    if (history == null || history.length == 0) {\n        if (backToParent) {\n            const parentDir = getParentDirectory(curVal);\n            if (parentDir == curVal) {\n                return null;\n            }\n            historyForward.unshift(curVal);\n            while (historyForward.length > MaxHistory) {\n                historyForward.pop();\n            }\n            return { [curValKey]: parentDir, \"history:forward\": historyForward };\n        } else {\n            return null;\n        }\n    }\n    const lastVal = history.pop();\n    historyForward.unshift(curVal);\n    return { [curValKey]: lastVal, history: history, \"history:forward\": historyForward };\n}\n\nfunction goHistoryForward(curValKey: \"url\" | \"file\", curVal: string, meta: MetaType): MetaType {\n    const rtnMeta: MetaType = {};\n    let history = (meta?.history ?? []).slice();\n    const historyForward = (meta?.[\"history:forward\"] ?? []).slice();\n    if (historyForward == null || historyForward.length == 0) {\n        return null;\n    }\n    const lastVal = historyForward.shift();\n    history.push(curVal);\n    if (history.length > MaxHistory) {\n        history.shift();\n    }\n    return { [curValKey]: lastVal, history: history, \"history:forward\": historyForward };\n}\n\nfunction goHistory(curValKey: \"url\" | \"file\", curVal: string, newVal: string, meta: MetaType): MetaType {\n    const rtnMeta: MetaType = {};\n    const history = (meta?.history ?? []).slice();\n    history.push(curVal);\n    if (history.length > MaxHistory) {\n        history.shift();\n    }\n    return { [curValKey]: newVal, history: history, \"history:forward\": [] };\n}\n\nexport { getParentDirectory, goHistory, goHistoryBack, goHistoryForward };\n"
  },
  {
    "path": "frontend/util/ijson.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// ijson values are regular JSON values: string, number, boolean, null, object, array\n// path is an array of strings and numbers\n\ntype PathType = (string | number)[];\n\nconst simplePathStrRe = /^[a-zA-Z_][a-zA-Z0-9_]*$/;\n\nfunction formatPath(path: PathType): string {\n    if (path.length == 0) {\n        return \"$\";\n    }\n    let pathStr = \"$\";\n    for (const pathPart of path) {\n        if (typeof pathPart === \"string\") {\n            if (simplePathStrRe.test(pathPart)) {\n                pathStr += \".\" + pathPart;\n            } else {\n                pathStr += \"[\" + JSON.stringify(pathPart) + \"]\";\n            }\n        } else if (typeof pathPart === \"number\") {\n            pathStr += \"[\" + pathPart + \"]\";\n        } else {\n            pathStr += \".*\";\n        }\n    }\n    return pathStr;\n}\n\nfunction isArray(obj: any): boolean {\n    return obj != null && Array.isArray(obj);\n}\n\nfunction isObject(obj: any): boolean {\n    return obj != null && obj instanceof Object && !isArray(obj);\n}\n\nfunction getPath(obj: any, path: PathType): any {\n    let cur = obj;\n    for (const pathPart of path) {\n        if (cur == null) {\n            return null;\n        }\n        if (typeof pathPart === \"string\") {\n            if (isObject(cur)) {\n                cur = cur[pathPart];\n            } else {\n                return null;\n            }\n        } else if (typeof pathPart === \"number\") {\n            if (isArray(cur)) {\n                cur = cur[pathPart];\n            } else {\n                return null;\n            }\n        } else {\n            throw new Error(\"Invalid path part: \" + pathPart);\n        }\n    }\n    return cur;\n}\n\ntype SetPathOpts = {\n    force?: boolean;\n    remove?: boolean;\n    combinefn?: (oldVal: any, newVal: any, opts: SetPathOpts) => any;\n};\n\nfunction combineFn_arrayAppend(oldVal: any, newVal: any, opts: SetPathOpts): any {\n    if (oldVal == null) {\n        return [newVal];\n    }\n    if (!isArray(oldVal) && !opts.force) {\n        throw new Error(\"Cannot append to non-array: \" + oldVal);\n    }\n    if (!isArray(oldVal)) {\n        return [newVal];\n    }\n    oldVal.push(newVal);\n    return oldVal;\n}\n\nfunction checkPath(path: PathType): boolean {\n    if (!isArray(path)) {\n        return false;\n    }\n    for (const pathPart of path) {\n        if (typeof pathPart !== \"string\" && typeof pathPart !== \"number\") {\n            return false;\n        }\n    }\n    return true;\n}\n\nfunction setPath(obj: any, path: PathType, value: any, opts: SetPathOpts) {\n    if (opts == null) {\n        opts = {};\n    }\n    if (opts.remove && value != null) {\n        throw new Error(\"Cannot set value and remove at the same time\");\n    }\n    if (path == null) {\n        path = [];\n    }\n    if (!checkPath(path)) {\n        throw new Error(\"Invalid path: \" + formatPath(path));\n    }\n    return setPathInternal(obj, path, value, opts);\n}\n\nfunction isEmpty(obj: any): boolean {\n    if (obj == null) {\n        return true;\n    }\n    if (isArray(obj)) {\n        return obj.length == 0;\n    }\n    if (isObject(obj)) {\n        for (const _ in obj) {\n            return false;\n        }\n        return true;\n    }\n    return false;\n}\n\nfunction removeFromArr(arr: any[], idx: number): any[] {\n    console.log(\"removefromarray\", arr, idx);\n    if (idx >= arr.length) {\n        return arr;\n    }\n    if (idx == arr.length - 1) {\n        arr.pop();\n        if (arr.length == 0) {\n            return null;\n        }\n        return arr;\n    }\n    arr[idx] = null;\n    return arr;\n}\n\nfunction setPathInternal(obj: any, path: PathType, value: any, opts: SetPathOpts): any {\n    if (path.length == 0) {\n        if (opts.combinefn != null) {\n            return opts.combinefn(obj, value, opts);\n        }\n        return value;\n    }\n    const pathPart = path[0];\n    if (typeof pathPart === \"string\") {\n        if (obj == null) {\n            if (opts.remove) {\n                return null;\n            }\n            obj = {};\n        }\n        if (!isObject(obj)) {\n            if (opts.force) {\n                obj = {};\n            } else {\n                throw new Error(\"Cannot set path on non-object: \" + obj);\n            }\n        }\n        if (opts.remove && path.length == 1) {\n            delete obj[pathPart];\n            if (isEmpty(obj)) {\n                return null;\n            }\n            return obj;\n        }\n        const newVal = setPathInternal(obj[pathPart], path.slice(1), value, opts);\n        if (opts.remove && newVal == null) {\n            delete obj[pathPart];\n            if (isEmpty(obj)) {\n                return null;\n            }\n            return obj;\n        }\n        obj[pathPart] = newVal;\n        return obj;\n    } else if (typeof pathPart === \"number\") {\n        if (pathPart < 0 || !Number.isInteger(pathPart)) {\n            throw new Error(\"Invalid path part: \" + pathPart);\n        }\n        if (obj == null) {\n            if (opts.remove) {\n                return null;\n            }\n            obj = [];\n        }\n        if (!isArray(obj)) {\n            if (opts.force) {\n                obj = [];\n            } else {\n                throw new Error(\"Cannot set path on non-array: \" + obj);\n            }\n        }\n        if (opts.remove && path.length == 1) {\n            return removeFromArr(obj, pathPart);\n        }\n        const newVal = setPathInternal(obj[pathPart], path.slice(1), value, opts);\n        if (opts.remove && newVal == null) {\n            return removeFromArr(obj, pathPart);\n        }\n        obj[pathPart] = newVal;\n        return obj;\n    } else {\n        throw new Error(\"Invalid path part: \" + pathPart);\n    }\n}\n\nfunction getCommandPath(command: object): PathType {\n    if (command[\"path\"] == null) {\n        return [];\n    }\n    return command[\"path\"];\n}\n\nfunction applyCommand(data: any, command: any): any {\n    if (command == null) {\n        throw new Error(\"Invalid command (null)\");\n    }\n    if (!isObject(command)) {\n        throw new Error(\"Invalid command (not an object): \" + command);\n    }\n    const commandType = command.type;\n    if (commandType == null) {\n        throw new Error(\"Invalid command (no type): \" + command);\n    }\n    const path = getCommandPath(command);\n    if (!checkPath(path)) {\n        throw new Error(\"Invalid command path: \" + formatPath(path));\n    }\n    switch (commandType) {\n        case \"set\":\n            return setPath(data, path, command.value, null);\n\n        case \"del\":\n            return setPath(data, path, null, { remove: true });\n\n        case \"append\":\n            return setPath(data, path, command.value, { combinefn: combineFn_arrayAppend });\n\n        default:\n            throw new Error(\"Invalid command type: \" + commandType);\n    }\n}\n\nexport { applyCommand, combineFn_arrayAppend, getPath, setPath };\nexport type { PathType, SetPathOpts };\n"
  },
  {
    "path": "frontend/util/isdev.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { getEnv } from \"./getenv\";\nimport { lazy } from \"./util\";\n\nexport const WaveDevVarName = \"WAVETERM_DEV\";\nexport const WaveDevViteVarName = \"WAVETERM_DEV_VITE\";\n\n/**\n * Determines whether the current app instance is a development build.\n * @returns True if the current app instance is a development build.\n */\nexport const isDev = lazy(() => !!getEnv(WaveDevVarName));\n\n/**\n * Determines whether the current app instance is running via the Vite dev server.\n * @returns True if the app is running via the Vite dev server.\n */\nexport const isDevVite = lazy(() => !!getEnv(WaveDevViteVarName));\n"
  },
  {
    "path": "frontend/util/keyutil.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport * as util from \"./util\";\n\nconst KeyTypeCodeRegex = /c{(.*)}/;\nconst KeyTypeKey = \"key\";\nconst KeyTypeCode = \"code\";\n\nlet PLATFORM: NodeJS.Platform = \"darwin\";\nconst PlatformMacOS = \"darwin\";\n\nfunction setKeyUtilPlatform(platform: NodeJS.Platform) {\n    PLATFORM = platform;\n}\n\nfunction getKeyUtilPlatform(): NodeJS.Platform {\n    return PLATFORM;\n}\n\nfunction keydownWrapper(\n    fn: (waveEvent: WaveKeyboardEvent) => boolean\n): (event: KeyboardEvent | React.KeyboardEvent) => void {\n    return (event: KeyboardEvent | React.KeyboardEvent) => {\n        const waveEvent = adaptFromReactOrNativeKeyEvent(event);\n        const rtnVal = fn(waveEvent);\n        if (rtnVal) {\n            event.preventDefault();\n            event.stopPropagation();\n        }\n    };\n}\n\nfunction waveEventToKeyDesc(waveEvent: WaveKeyboardEvent): string {\n    let keyDesc: string[] = [];\n    if (waveEvent.cmd) {\n        keyDesc.push(\"Cmd\");\n    }\n    if (waveEvent.option) {\n        keyDesc.push(\"Option\");\n    }\n    if (waveEvent.meta) {\n        keyDesc.push(\"Meta\");\n    }\n    if (waveEvent.control) {\n        keyDesc.push(\"Ctrl\");\n    }\n    if (waveEvent.shift) {\n        keyDesc.push(\"Shift\");\n    }\n    if (waveEvent.key != null && waveEvent.key != \"\") {\n        if (waveEvent.key == \" \") {\n            keyDesc.push(\"Space\");\n        } else {\n            keyDesc.push(waveEvent.key);\n        }\n    } else {\n        keyDesc.push(\"c{\" + waveEvent.code + \"}\");\n    }\n    return keyDesc.join(\":\");\n}\n\nfunction parseKey(key: string): { key: string; type: string } {\n    let regexMatch = key.match(KeyTypeCodeRegex);\n    if (regexMatch != null && regexMatch.length > 1) {\n        let code = regexMatch[1];\n        return { key: code, type: KeyTypeCode };\n    } else if (regexMatch != null) {\n        console.log(\"error: regexMatch is not null yet there is no captured group: \", regexMatch, key);\n    }\n    return { key: key, type: KeyTypeKey };\n}\n\nfunction parseKeyDescription(keyDescription: string): KeyPressDecl {\n    let rtn = { key: \"\", mods: {} } as KeyPressDecl;\n    let keys = keyDescription.replace(/[()]/g, \"\").split(\":\");\n    for (let key of keys) {\n        if (key == \"Cmd\") {\n            if (PLATFORM == PlatformMacOS) {\n                rtn.mods.Meta = true;\n            } else {\n                rtn.mods.Alt = true;\n            }\n            rtn.mods.Cmd = true;\n        } else if (key == \"Shift\") {\n            rtn.mods.Shift = true;\n        } else if (key == \"Ctrl\") {\n            rtn.mods.Ctrl = true;\n        } else if (key == \"Option\") {\n            if (PLATFORM == PlatformMacOS) {\n                rtn.mods.Alt = true;\n            } else {\n                rtn.mods.Meta = true;\n            }\n            rtn.mods.Option = true;\n        } else if (key == \"Alt\") {\n            if (PLATFORM == PlatformMacOS) {\n                rtn.mods.Option = true;\n            } else {\n                rtn.mods.Cmd = true;\n            }\n            rtn.mods.Alt = true;\n        } else if (key == \"Meta\") {\n            if (PLATFORM == PlatformMacOS) {\n                rtn.mods.Cmd = true;\n            } else {\n                rtn.mods.Option = true;\n            }\n            rtn.mods.Meta = true;\n        } else {\n            let { key: parsedKey, type: keyType } = parseKey(key);\n            rtn.key = parsedKey;\n            rtn.keyType = keyType;\n            if (rtn.keyType == KeyTypeKey && key.length == 1) {\n                // check for if key is upper case\n                // TODO what about unicode upper case?\n                if (/[A-Z]/.test(key.charAt(0))) {\n                    // this key is an upper case A - Z - we should apply the shift key, even if it wasn't specified\n                    rtn.mods.Shift = true;\n                } else if (key == \" \") {\n                    rtn.key = \"Space\";\n                    // we allow \" \" and \"Space\" to be mapped to Space key\n                }\n            }\n        }\n    }\n    return rtn;\n}\n\nfunction notMod(keyPressMod: boolean, eventMod: boolean) {\n    return (keyPressMod && !eventMod) || (eventMod && !keyPressMod);\n}\n\nfunction isCharacterKeyEvent(event: WaveKeyboardEvent): boolean {\n    if (event.alt || event.meta || event.control) {\n        return false;\n    }\n    return util.countGraphemes(event.key) == 1;\n}\n\nconst inputKeyMap = new Map<string, boolean>([\n    [\"Backspace\", true],\n    [\"Delete\", true],\n    [\"Enter\", true],\n    [\"Space\", true],\n    [\"Tab\", true],\n    [\"ArrowLeft\", true],\n    [\"ArrowRight\", true],\n    [\"ArrowUp\", true],\n    [\"ArrowDown\", true],\n    [\"Home\", true],\n    [\"End\", true],\n    [\"PageUp\", true],\n    [\"PageDown\", true],\n    [\"Cmd:a\", true],\n    [\"Cmd:c\", true],\n    [\"Cmd:v\", true],\n    [\"Cmd:x\", true],\n    [\"Cmd:z\", true],\n    [\"Cmd:Shift:z\", true],\n    [\"Cmd:ArrowLeft\", true],\n    [\"Cmd:ArrowRight\", true],\n    [\"Cmd:Backspace\", true],\n    [\"Cmd:Delete\", true],\n    [\"Shift:ArrowLeft\", true],\n    [\"Shift:ArrowRight\", true],\n    [\"Shift:ArrowUp\", true],\n    [\"Shift:ArrowDown\", true],\n    [\"Shift:Home\", true],\n    [\"Shift:End\", true],\n    [\"Cmd:Shift:ArrowLeft\", true],\n    [\"Cmd:Shift:ArrowRight\", true],\n    [\"Cmd:Shift:ArrowUp\", true],\n    [\"Cmd:Shift:ArrowDown\", true],\n]);\n\nfunction isInputEvent(event: WaveKeyboardEvent): boolean {\n    if (isCharacterKeyEvent(event)) {\n        return true;\n    }\n    for (let key of inputKeyMap.keys()) {\n        if (checkKeyPressed(event, key)) {\n            return true;\n        }\n    }\n}\n\nfunction checkKeyPressed(event: WaveKeyboardEvent, keyDescription: string): boolean {\n    let keyPress = parseKeyDescription(keyDescription);\n    if (notMod(keyPress.mods.Option, event.option)) {\n        return false;\n    }\n    if (notMod(keyPress.mods.Cmd, event.cmd)) {\n        return false;\n    }\n    if (notMod(keyPress.mods.Shift, event.shift)) {\n        return false;\n    }\n    if (notMod(keyPress.mods.Ctrl, event.control)) {\n        return false;\n    }\n    if (notMod(keyPress.mods.Alt, event.alt)) {\n        return false;\n    }\n    if (notMod(keyPress.mods.Meta, event.meta)) {\n        return false;\n    }\n    let eventKey = \"\";\n    let descKey = keyPress.key;\n    if (keyPress.keyType == KeyTypeCode) {\n        eventKey = event.code;\n    }\n    if (keyPress.keyType == KeyTypeKey) {\n        eventKey = event.key;\n        if (eventKey != null && eventKey.length == 1 && /[A-Z]/.test(eventKey.charAt(0))) {\n            // key is upper case A-Z, this means shift is applied, we want to allow\n            // \"Shift:e\" as well as \"Shift:E\" or \"E\"\n            eventKey = eventKey.toLocaleLowerCase();\n            descKey = descKey.toLocaleLowerCase();\n        } else if (eventKey == \" \") {\n            eventKey = \"Space\";\n            // a space key is shown as \" \", we want users to be able to set space key as \"Space\" or \" \", whichever they prefer\n        }\n    }\n    if (descKey != eventKey) {\n        return false;\n    }\n    return true;\n}\n\nfunction adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEvent): WaveKeyboardEvent {\n    let rtn: WaveKeyboardEvent = {} as WaveKeyboardEvent;\n    rtn.control = event.ctrlKey;\n    rtn.shift = event.shiftKey;\n    rtn.cmd = PLATFORM == PlatformMacOS ? event.metaKey : event.altKey;\n    rtn.option = PLATFORM == PlatformMacOS ? event.altKey : event.metaKey;\n    rtn.meta = event.metaKey;\n    rtn.alt = event.altKey;\n    rtn.code = event.code;\n    rtn.key = event.key;\n    rtn.location = event.location;\n    (rtn as any).nativeEvent = event;\n    if (event.type == \"keydown\" || event.type == \"keyup\" || event.type == \"keypress\") {\n        rtn.type = event.type;\n    } else {\n        rtn.type = \"unknown\";\n    }\n    rtn.repeat = event.repeat;\n    return rtn;\n}\n\nfunction adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent {\n    let rtn: WaveKeyboardEvent = {} as WaveKeyboardEvent;\n    if (event.type == \"keyUp\") {\n        rtn.type = \"keyup\";\n    } else if (event.type == \"keyDown\") {\n        rtn.type = \"keydown\";\n    } else {\n        rtn.type = \"unknown\";\n    }\n    rtn.control = event.control;\n    rtn.cmd = PLATFORM == PlatformMacOS ? event.meta : event.alt;\n    rtn.option = PLATFORM == PlatformMacOS ? event.alt : event.meta;\n    rtn.meta = event.meta;\n    rtn.alt = event.alt;\n    rtn.shift = event.shift;\n    rtn.repeat = event.isAutoRepeat;\n    rtn.location = event.location;\n    rtn.code = event.code;\n    rtn.key = event.key;\n    return rtn;\n}\n\nconst keyMap = {\n    Enter: \"\\r\",\n    Backspace: \"\\x7f\",\n    Tab: \"\\t\",\n    Escape: \"\\x1b\",\n    ArrowUp: \"\\x1b[A\",\n    ArrowDown: \"\\x1b[B\",\n    ArrowRight: \"\\x1b[C\",\n    ArrowLeft: \"\\x1b[D\",\n    Insert: \"\\x1b[2~\",\n    Delete: \"\\x1b[3~\",\n    Home: \"\\x1b[1~\",\n    End: \"\\x1b[4~\",\n    PageUp: \"\\x1b[5~\",\n    PageDown: \"\\x1b[6~\",\n};\n\nfunction keyboardEventToASCII(event: WaveKeyboardEvent): string {\n    // check modifiers\n    // if no modifiers are set, just send the key\n    if (!event.alt && !event.control && !event.meta) {\n        if (event.key == null || event.key == \"\") {\n            return \"\";\n        }\n        if (keyMap[event.key] != null) {\n            return keyMap[event.key];\n        }\n        if (event.key.length == 1) {\n            return event.key;\n        } else {\n            console.log(\"not sending keyboard event\", event.key, event);\n        }\n    }\n    // if meta or alt is set, there is no ASCII representation\n    if (event.meta || event.alt) {\n        return \"\";\n    }\n    // if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value\n    if (event.control) {\n        if (\n            (event.key.length === 1 && event.key >= \"A\" && event.key <= \"Z\") ||\n            (event.key >= \"a\" && event.key <= \"z\")\n        ) {\n            const key = event.key.toUpperCase();\n            return String.fromCharCode(key.charCodeAt(0) - 64);\n        }\n    }\n    return \"\";\n}\n\nexport {\n    adaptFromElectronKeyEvent,\n    adaptFromReactOrNativeKeyEvent,\n    checkKeyPressed,\n    getKeyUtilPlatform,\n    isCharacterKeyEvent,\n    isInputEvent,\n    keyboardEventToASCII,\n    keydownWrapper,\n    parseKeyDescription,\n    setKeyUtilPlatform,\n    waveEventToKeyDesc,\n};\n"
  },
  {
    "path": "frontend/util/platformutil.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nexport const PlatformMacOS = \"darwin\";\nexport const PlatformWindows = \"win32\";\nexport const PlatformLinux = \"linux\";\nexport let PLATFORM: NodeJS.Platform = PlatformMacOS;\nexport let MacOSVersion: string = null;\n\nexport function setPlatform(platform: NodeJS.Platform) {\n    PLATFORM = platform;\n}\n\nexport function setMacOSVersion(version: string) {\n    MacOSVersion = version;\n}\n\nexport function isMacOSTahoeOrLater(): boolean {\n    if (!isMacOS() || MacOSVersion == null) {\n        return false;\n    }\n    const major = parseInt(MacOSVersion.split(\".\")[0], 10);\n    return major >= 16;\n}\n\nexport function isMacOS(): boolean {\n    return PLATFORM == PlatformMacOS;\n}\n\nexport function isWindows(): boolean {\n    return PLATFORM == PlatformWindows;\n}\n\nexport function makeNativeLabel(isDirectory: boolean) {\n    let managerName: string;\n    if (!isDirectory) {\n        managerName = \"Default Application\";\n    } else if (PLATFORM === PlatformMacOS) {\n        managerName = \"Finder\";\n    } else if (PLATFORM == PlatformWindows) {\n        managerName = \"Explorer\";\n    } else {\n        managerName = \"File Manager\";\n    }\n\n    let fileAction: string;\n    if (isDirectory) {\n        fileAction = \"Reveal\";\n    } else {\n        fileAction = \"Open File\";\n    }\n    return `${fileAction} in ${managerName}`;\n}\n"
  },
  {
    "path": "frontend/util/previewutil.ts",
    "content": "import { createBlock, getApi } from \"@/app/store/global\";\nimport { makeNativeLabel } from \"./platformutil\";\nimport { fireAndForget } from \"./util\";\nimport { formatRemoteUri } from \"./waveutil\";\n\nexport function addOpenMenuItems(menu: ContextMenuItem[], conn: string, finfo: FileInfo): ContextMenuItem[] {\n    if (!finfo) {\n        return menu;\n    }\n    menu.push({\n        type: \"separator\",\n    });\n    if (!conn) {\n        // TODO:  resolve correct host path if connection is WSL\n        // if the entry is a directory, reveal it in the file manager, if the entry is a file, reveal its parent directory\n        menu.push({\n            label: makeNativeLabel(true),\n            click: () => {\n                getApi().openNativePath(finfo.isdir ? finfo.path : finfo.dir);\n            },\n        });\n        // if the entry is a file, open it in the default application\n        if (!finfo.isdir) {\n            menu.push({\n                label: makeNativeLabel(false),\n                click: () => {\n                    getApi().openNativePath(finfo.path);\n                },\n            });\n        }\n    } else {\n        menu.push({\n            label: \"Download File\",\n            click: () => {\n                const remoteUri = formatRemoteUri(finfo.path, conn);\n                getApi().downloadFile(remoteUri);\n            },\n        });\n    }\n    menu.push({\n        type: \"separator\",\n    });\n    if (!finfo.isdir) {\n        menu.push({\n            label: \"Open Preview in New Block\",\n            click: () =>\n                fireAndForget(async () => {\n                    const blockDef: BlockDef = {\n                        meta: {\n                            view: \"preview\",\n                            file: finfo.path,\n                            connection: conn,\n                        },\n                    };\n                    await createBlock(blockDef);\n                }),\n        });\n    }\n    menu.push({\n        label: \"Open Terminal Here\",\n        click: () => {\n            const termBlockDef: BlockDef = {\n                meta: {\n                    controller: \"shell\",\n                    view: \"term\",\n                    \"cmd:cwd\": finfo.isdir ? finfo.path : finfo.dir,\n                    connection: conn,\n                },\n            };\n            fireAndForget(() => createBlock(termBlockDef));\n        },\n    });\n    return menu;\n}\n"
  },
  {
    "path": "frontend/util/sharedconst.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nexport const CHORD_TIMEOUT = 2000;\n"
  },
  {
    "path": "frontend/util/util.ts",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0s\n\nimport base64 from \"base64-js\";\nimport clsx, { type ClassValue } from \"clsx\";\nimport { Atom, atom, Getter, SetStateAction, Setter, useAtomValue } from \"jotai\";\nimport { twMerge } from \"tailwind-merge\";\nimport { debounce, throttle } from \"throttle-debounce\";\nconst prevValueCache = new WeakMap<any, any>(); // stores a previous value for a deep equal comparison (used with the deepCompareReturnPrev function)\n\nfunction isBlank(str: string): boolean {\n    return str == null || str == \"\";\n}\n\nfunction isLocalConnName(connName: string): boolean {\n    if (isBlank(connName)) {\n        return true;\n    }\n    return connName === \"local\" || connName.startsWith(\"local:\");\n}\n\nfunction isWslConnName(connName: string): boolean {\n    return connName != null && connName.startsWith(\"wsl://\");\n}\n\nfunction isSshConnName(connName: string): boolean {\n    return !isLocalConnName(connName) && !isWslConnName(connName);\n}\n\nfunction base64ToString(b64: string): string {\n    if (b64 == null) {\n        return null;\n    }\n    if (b64 == \"\") {\n        return \"\";\n    }\n    const stringBytes = base64.toByteArray(b64);\n    return new TextDecoder().decode(stringBytes);\n}\n\nfunction stringToBase64(input: string): string {\n    const stringBytes = new TextEncoder().encode(input);\n    return base64.fromByteArray(stringBytes);\n}\n\nfunction arrayToBase64(input: Uint8Array): string {\n    return base64.fromByteArray(input);\n}\n\nfunction base64ToArray(b64: string): Uint8Array<ArrayBufferLike> {\n    const cleanB64 = b64.replace(/\\s+/g, \"\");\n    return base64.toByteArray(cleanB64);\n}\n\nfunction base64ToArrayBuffer(b64: string): ArrayBuffer {\n    const cleanB64 = b64.replace(/\\s+/g, \"\");\n    const u8 = base64.toByteArray(cleanB64); // Uint8Array<ArrayBufferLike>\n    // Force a plain ArrayBuffer slice (no SharedArrayBuffer, no offset issues)\n    return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;\n}\n\nfunction boundNumber(num: number, min: number, max: number): number {\n    if (num == null || typeof num != \"number\" || isNaN(num)) {\n        return null;\n    }\n    return Math.min(Math.max(num, min), max);\n}\n\n// key must be a suitable weakmap key.  pass the new value\n// it will return the prevValue (for object equality) if the new value is deep equal to the prev value\nfunction deepCompareReturnPrev(key: any, newValue: any): any {\n    if (key == null) {\n        return newValue;\n    }\n    const previousValue = prevValueCache.get(key);\n    if (previousValue !== undefined && JSON.stringify(newValue) === JSON.stringify(previousValue)) {\n        return previousValue;\n    }\n    prevValueCache.set(key, newValue);\n    return newValue;\n}\n\n// works for json-like objects (arrays, objects, strings, numbers, booleans)\nfunction jsonDeepEqual(v1: any, v2: any): boolean {\n    if (v1 === v2) {\n        return true;\n    }\n    if (typeof v1 !== typeof v2) {\n        return false;\n    }\n    if ((v1 == null && v2 != null) || (v1 != null && v2 == null)) {\n        return false;\n    }\n    if (typeof v1 === \"object\") {\n        if (Array.isArray(v1) && Array.isArray(v2)) {\n            if (v1.length !== v2.length) {\n                return false;\n            }\n            for (let i = 0; i < v1.length; i++) {\n                if (!jsonDeepEqual(v1[i], v2[i])) {\n                    return false;\n                }\n            }\n            return true;\n        } else {\n            const keys1 = Object.keys(v1);\n            const keys2 = Object.keys(v2);\n            if (keys1.length !== keys2.length) {\n                return false;\n            }\n            for (const key of keys1) {\n                if (!jsonDeepEqual(v1[key], v2[key])) {\n                    return false;\n                }\n            }\n            return true;\n        }\n    }\n    return false;\n}\n\nfunction makeIconClass(icon: string, fw: boolean, opts?: { spin?: boolean; defaultIcon?: string }): string {\n    if (isBlank(icon)) {\n        if (opts?.defaultIcon != null) {\n            return makeIconClass(opts.defaultIcon, fw, { spin: opts?.spin });\n        }\n        return null;\n    }\n\n    let animation: string | null = null;\n    let hasFwModifier = false;\n\n    while (icon.match(/\\+(spin|beat|fade|fw)$/)) {\n        const modifierMatch = icon.match(/\\+(spin|beat|fade|fw)$/);\n        if (modifierMatch) {\n            const modifier = modifierMatch[1];\n            if (modifier === \"fw\") {\n                hasFwModifier = true;\n            } else {\n                animation = modifier;\n            }\n            icon = icon.replace(/\\+(spin|beat|fade|fw)$/, \"\");\n        }\n    }\n\n    let baseClass: string;\n    if (icon.match(/^(solid@)?[a-z0-9-]+$/)) {\n        icon = icon.replace(/^solid@/, \"\");\n        baseClass = `fa fa-solid fa-${icon}`;\n    } else if (icon.match(/^regular@[a-z0-9-]+$/)) {\n        icon = icon.replace(/^regular@/, \"\");\n        baseClass = `fa fa-sharp fa-regular fa-${icon}`;\n    } else if (icon.match(/^brands@[a-z0-9-]+$/)) {\n        icon = icon.replace(/^brands@/, \"\");\n        baseClass = `fa fa-brands fa-${icon}`;\n    } else if (icon.match(/^custom@[a-z0-9-]+$/)) {\n        icon = icon.replace(/^custom@/, \"\");\n        baseClass = `fa fa-kit fa-${icon}`;\n    } else {\n        if (opts?.defaultIcon != null) {\n            return makeIconClass(opts.defaultIcon, fw, { spin: opts?.spin });\n        }\n        return null;\n    }\n\n    const shouldAddFw = fw || hasFwModifier;\n    const hasSpin = animation === \"spin\" || opts?.spin;\n    const animationClass = animation && animation !== \"spin\" ? `fa-${animation}` : null;\n\n    return clsx(baseClass, shouldAddFw ? \"fa-fw\" : null, hasSpin ? \"fa-spin\" : null, animationClass);\n}\n\n/**\n * A wrapper function for running a promise and catching any errors\n * @param f The promise to run\n */\nfunction fireAndForget(f: () => Promise<any>) {\n    f()?.catch((e) => {\n        console.log(\"fireAndForget error\", e);\n    });\n}\n\nconst promiseWeakMap = new WeakMap<Promise<any>, ResolvedValue<any>>();\n\ntype ResolvedValue<T> = {\n    pending: boolean;\n    error: any;\n    value: T;\n};\n\n// returns the value, pending state, and error of a promise\nfunction getPromiseState<T>(promise: Promise<T>): [T, boolean, any] {\n    if (promise == null) {\n        return [null, false, null];\n    }\n    if (promiseWeakMap.has(promise)) {\n        const value = promiseWeakMap.get(promise);\n        return [value.value, value.pending, value.error];\n    }\n    const value: ResolvedValue<T> = {\n        pending: true,\n        error: null,\n        value: null,\n    };\n    promise.then(\n        (result) => {\n            value.pending = false;\n            value.error = null;\n            value.value = result;\n        },\n        (error) => {\n            value.pending = false;\n            value.error = error;\n        }\n    );\n    promiseWeakMap.set(promise, value);\n    return [value.value, value.pending, value.error];\n}\n\n// returns the value of a promise, or a default value if the promise is still pending (or had an error)\nfunction getPromiseValue<T>(promise: Promise<T>, def: T): T {\n    const [value, pending, error] = getPromiseState(promise);\n    if (pending || error) {\n        return def;\n    }\n    return value;\n}\n\nfunction jotaiLoadableValue<T>(value: Loadable<T>, def: T): T {\n    if (value.state === \"hasData\") {\n        return value.data;\n    }\n    return def;\n}\n\nconst NullAtom = atom(null);\n\nfunction useAtomValueSafe<T>(atom: Atom<T> | Atom<Promise<T>>): T {\n    if (atom == null) {\n        return useAtomValue(NullAtom) as T;\n    }\n    return useAtomValue(atom);\n}\n\n/**\n * Simple wrapper function that lazily evaluates the provided function and caches its result for future calls.\n * @param callback The function to lazily run.\n * @returns The result of the function.\n */\nconst lazy = <T extends (...args: any[]) => any>(callback: T) => {\n    let res: ReturnType<T>;\n    let processed = false;\n    return (...args: Parameters<T>): ReturnType<T> => {\n        if (processed) return res;\n        res = callback(...args);\n        processed = true;\n        return res;\n    };\n};\n\n/**\n * Generates an external link by appending the given URL to the \"https://extern?\" endpoint.\n *\n * @param {string} url - The URL to be encoded and appended to the external link.\n * @return {string} The generated external link.\n */\nfunction makeExternLink(url: string): string {\n    return \"https://extern?\" + encodeURIComponent(url);\n}\n\nfunction atomWithThrottle<T>(initialValue: T, delayMilliseconds = 500): AtomWithThrottle<T> {\n    // DO NOT EXPORT currentValueAtom as using this atom to set state can cause\n    // inconsistent state between currentValueAtom and throttledValueAtom\n    const _currentValueAtom = atom(initialValue);\n\n    const throttledValueAtom = atom(initialValue, (get, set, update: SetStateAction<T>) => {\n        const prevValue = get(_currentValueAtom);\n        const nextValue = typeof update === \"function\" ? (update as (prev: T) => T)(prevValue) : update;\n        set(_currentValueAtom, nextValue);\n        throttleUpdate(get, set);\n    });\n\n    const throttleUpdate = throttle(delayMilliseconds, (get: Getter, set: Setter) => {\n        const curVal = get(_currentValueAtom);\n        set(throttledValueAtom, curVal);\n    });\n\n    return {\n        currentValueAtom: atom((get) => get(_currentValueAtom)),\n        throttledValueAtom,\n    };\n}\n\nfunction atomWithDebounce<T>(initialValue: T, delayMilliseconds = 500): AtomWithDebounce<T> {\n    // DO NOT EXPORT currentValueAtom as using this atom to set state can cause\n    // inconsistent state between currentValueAtom and debouncedValueAtom\n    const _currentValueAtom = atom(initialValue);\n\n    const debouncedValueAtom = atom(initialValue, (get, set, update: SetStateAction<T>) => {\n        const prevValue = get(_currentValueAtom);\n        const nextValue = typeof update === \"function\" ? (update as (prev: T) => T)(prevValue) : update;\n        set(_currentValueAtom, nextValue);\n        debounceUpdate(get, set);\n    });\n\n    const debounceUpdate = debounce(delayMilliseconds, (get: Getter, set: Setter) => {\n        const curVal = get(_currentValueAtom);\n        set(debouncedValueAtom, curVal);\n    });\n\n    return {\n        currentValueAtom: atom((get) => get(_currentValueAtom)),\n        debouncedValueAtom,\n    };\n}\n\nfunction getPrefixedSettings(settings: SettingsType, prefix: string): SettingsType {\n    const rtn: SettingsType = {};\n    if (settings == null || isBlank(prefix)) {\n        return rtn;\n    }\n    for (const key in settings) {\n        if (key == prefix || key.startsWith(prefix + \":\")) {\n            rtn[key] = settings[key];\n        }\n    }\n    return rtn;\n}\n\nfunction countGraphemes(str: string): number {\n    if (str == null) {\n        return 0;\n    }\n    // this exists (need to hack TS to get it to not show an error)\n    const seg = new (Intl as any).Segmenter(undefined, { granularity: \"grapheme\" });\n    return Array.from(seg.segment(str)).length;\n}\n\nfunction makeConnRoute(conn: string): string {\n    if (isBlank(conn)) {\n        return \"conn:local\";\n    }\n    return \"conn:\" + conn;\n}\n\nfunction sleep(ms: number): Promise<void> {\n    return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction mergeMeta(meta: MetaType, metaUpdate: MetaType, prefix?: string): MetaType {\n    const rtn: MetaType = {};\n\n    // Helper function to check if a key matches the prefix criteria\n    const shouldIncludeKey = (key: string): boolean => {\n        if (prefix === undefined) {\n            return true;\n        }\n        if (prefix === \"\") {\n            return !key.includes(\":\");\n        }\n        return key.startsWith(prefix + \":\");\n    };\n\n    // Copy original meta (only keys matching prefix criteria)\n    for (const [k, v] of Object.entries(meta)) {\n        if (shouldIncludeKey(k)) {\n            rtn[k] = v;\n        }\n    }\n\n    // Deal with \"section:*\" keys (only if they match prefix criteria)\n    for (const k of Object.keys(metaUpdate)) {\n        if (!k.endsWith(\":*\")) {\n            continue;\n        }\n\n        if (!metaUpdate[k]) {\n            continue;\n        }\n\n        const sectionPrefix = k.slice(0, -2); // Remove ':*' suffix\n        if (sectionPrefix === \"\") {\n            continue;\n        }\n\n        // Only process if this section matches our prefix criteria\n        if (!shouldIncludeKey(sectionPrefix)) {\n            continue;\n        }\n\n        // Delete \"[sectionPrefix]\" and all keys that start with \"[sectionPrefix]:\"\n        const prefixColon = sectionPrefix + \":\";\n        for (const k2 of Object.keys(rtn)) {\n            if (k2 === sectionPrefix || k2.startsWith(prefixColon)) {\n                delete rtn[k2];\n            }\n        }\n    }\n\n    // Deal with regular keys (only if they match prefix criteria)\n    for (const [k, v] of Object.entries(metaUpdate)) {\n        if (!shouldIncludeKey(k)) {\n            continue;\n        }\n\n        if (k.endsWith(\":*\")) {\n            continue;\n        }\n\n        if (v === null || v === undefined) {\n            delete rtn[k];\n            continue;\n        }\n\n        rtn[k] = v;\n    }\n\n    return rtn;\n}\n\nfunction escapeBytes(str: string): string {\n    return str.replace(/[\\s\\S]/g, (ch) => {\n        const code = ch.charCodeAt(0);\n        switch (ch) {\n            case \"\\n\":\n                return \"\\\\n\";\n            case \"\\r\":\n                return \"\\\\r\";\n            case \"\\t\":\n                return \"\\\\t\";\n            case \"\\b\":\n                return \"\\\\b\";\n            case \"\\f\":\n                return \"\\\\f\";\n        }\n        if (code === 0x1b) return \"\\\\x1b\"; // escape\n        if (code < 0x20 || code === 0x7f) return `\\\\x${code.toString(16).padStart(2, \"0\")}`;\n        return ch;\n    });\n}\n\nfunction cn(...inputs: ClassValue[]) {\n    return twMerge(clsx(inputs));\n}\n\ntype ParsedDataUrl = {\n    mimeType: string;\n    buffer: Uint8Array;\n};\n\nfunction parseDataUrl(dataUrl: string): ParsedDataUrl {\n    if (!dataUrl.startsWith(\"data:\")) throw new Error(\"Invalid data URL\");\n    const [header, data] = dataUrl.split(\",\", 2);\n    if (data === undefined) throw new Error(\"Invalid data URL: missing data\");\n\n    const meta = header.slice(5);\n    let mimeType = \"text/plain;charset=US-ASCII\";\n    const parts = meta.split(\";\");\n    if (parts[0]) mimeType = parts[0];\n    const isBase64 = parts.some((p) => p.toLowerCase() === \"base64\");\n\n    let buffer: Uint8Array;\n    if (isBase64) {\n        buffer = base64ToArray(data);\n    } else {\n        // assume text\n        const decoded = decodeURIComponent(data);\n        buffer = new TextEncoder().encode(decoded);\n    }\n\n    return { mimeType, buffer };\n}\n\nfunction formatRelativeTime(timestamp: number): string {\n    if (!timestamp) {\n        return \"never\";\n    }\n    const now = Date.now();\n    const diffInSeconds = Math.floor((now - timestamp) / 1000);\n    const diffInMinutes = Math.floor(diffInSeconds / 60);\n    const diffInHours = Math.floor(diffInMinutes / 60);\n    const diffInDays = Math.floor(diffInHours / 24);\n\n    if (diffInMinutes <= 0) {\n        return \"Just now\";\n    } else if (diffInMinutes < 60) {\n        return `${diffInMinutes} min${diffInMinutes !== 1 ? \"s\" : \"\"} ago`;\n    } else if (diffInHours < 24) {\n        return `${diffInHours} hr${diffInHours !== 1 ? \"s\" : \"\"} ago`;\n    } else if (diffInDays < 7) {\n        return `${diffInDays} day${diffInDays !== 1 ? \"s\" : \"\"} ago`;\n    } else {\n        return new Date(timestamp).toLocaleDateString();\n    }\n}\n\n/**\n * Sort objects by display:order (ascending) and display:name (alphabetically)\n * @param a First object to compare\n * @param b Second object to compare\n * @returns Comparison result for Array.sort()\n */\nfunction sortByDisplayOrder<T extends { \"display:order\"?: number; \"display:name\"?: string }>(a: T, b: T): number {\n    const orderDiff = (a[\"display:order\"] || 0) - (b[\"display:order\"] || 0);\n    if (orderDiff !== 0) return orderDiff;\n    return (a[\"display:name\"] || \"\").localeCompare(b[\"display:name\"] || \"\");\n}\n\nexport {\n    arrayToBase64,\n    atomWithDebounce,\n    atomWithThrottle,\n    base64ToArray,\n    base64ToArrayBuffer,\n    base64ToString,\n    boundNumber,\n    cn,\n    countGraphemes,\n    deepCompareReturnPrev,\n    escapeBytes,\n    fireAndForget,\n    formatRelativeTime,\n    getPrefixedSettings,\n    getPromiseState,\n    getPromiseValue,\n    isBlank,\n    isLocalConnName,\n    isSshConnName,\n    isWslConnName,\n    jotaiLoadableValue,\n    jsonDeepEqual,\n    lazy,\n    makeConnRoute,\n    makeExternLink,\n    makeIconClass,\n    mergeMeta,\n    NullAtom,\n    parseDataUrl,\n    sleep,\n    sortByDisplayOrder,\n    stringToBase64,\n    useAtomValueSafe,\n};\n"
  },
  {
    "path": "frontend/util/waveutil.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0s\n\nimport { getWebServerEndpoint } from \"@/util/endpoints\";\nimport { boundNumber, isBlank } from \"@/util/util\";\nimport { generate as generateCSS, parse as parseCSS, walk as walkCSS } from \"css-tree\";\n\nfunction encodeFileURL(file: string) {\n    const webEndpoint = getWebServerEndpoint();\n    const fileUri = formatRemoteUri(file, \"local\");\n    const rtn = webEndpoint + `/wave/stream-file?path=${encodeURIComponent(fileUri)}&no404=1`;\n    return rtn;\n}\n\nexport function processBackgroundUrls(cssText: string): string {\n    if (isBlank(cssText)) {\n        return null;\n    }\n    cssText = cssText.trim();\n    if (cssText.endsWith(\";\")) {\n        cssText = cssText.slice(0, -1);\n    }\n    const attrRe = /^background(-image)?\\s*:\\s*/i;\n    cssText = cssText.replace(attrRe, \"\");\n    const ast = parseCSS(\"background: \" + cssText, {\n        context: \"declaration\",\n    });\n    let hasUnsafeUrl = false;\n    walkCSS(ast, {\n        visit: \"Url\",\n        enter(node) {\n            const originalUrl = node.value.trim();\n            if (\n                originalUrl.startsWith(\"http:\") ||\n                originalUrl.startsWith(\"https:\") ||\n                originalUrl.startsWith(\"data:\")\n            ) {\n                return;\n            }\n            // allow file:/// urls (if they are absolute)\n            if (originalUrl.startsWith(\"file://\")) {\n                const path = originalUrl.slice(7);\n                if (!path.startsWith(\"/\")) {\n                    console.log(`Invalid background, contains a non-absolute file URL: ${originalUrl}`);\n                    hasUnsafeUrl = true;\n                    return;\n                }\n                const newUrl = encodeFileURL(path);\n                node.value = newUrl;\n                return;\n            }\n            // allow absolute paths\n            if (originalUrl.startsWith(\"/\") || originalUrl.startsWith(\"~/\") || /^[a-zA-Z]:(\\/|\\\\)/.test(originalUrl)) {\n                const newUrl = encodeFileURL(originalUrl);\n                node.value = newUrl;\n                return;\n            }\n            hasUnsafeUrl = true;\n            console.log(`Invalid background, contains an unsafe URL scheme: ${originalUrl}`);\n        },\n    });\n    if (hasUnsafeUrl) {\n        return null;\n    }\n    const rtnStyle = generateCSS(ast);\n    if (rtnStyle == null) {\n        return null;\n    }\n    return rtnStyle.replace(/^background:\\s*/, \"\");\n}\n\nexport function computeBgStyleFromMeta(meta: MetaType, defaultOpacity: number = null): React.CSSProperties {\n    const bgAttr = meta?.[\"bg\"];\n    if (isBlank(bgAttr)) {\n        return null;\n    }\n    try {\n        const processedBg = processBackgroundUrls(bgAttr);\n        const rtn: React.CSSProperties = {};\n        rtn.background = processedBg;\n        rtn.opacity = boundNumber(meta[\"bg:opacity\"], 0, 1) ?? defaultOpacity;\n        if (!isBlank(meta?.[\"bg:blendmode\"])) {\n            rtn.backgroundBlendMode = meta[\"bg:blendmode\"];\n        }\n        return rtn;\n    } catch (e) {\n        console.error(\"error processing background\", e);\n        return null;\n    }\n}\n\nexport function formatRemoteUri(path: string, connection: string): string {\n    connection = connection ?? \"local\";\n    return `wsh://${connection}/${path}`;\n}\n"
  },
  {
    "path": "frontend/util/wsutil.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { WebSocket as NodeWebSocketType } from \"ws\";\n\nlet NodeWebSocket: typeof NodeWebSocketType = null;\n\nif (typeof window === \"undefined\") {\n    // Necessary to avoid issues with Rollup: https://github.com/websockets/ws/issues/2057\n    import(\"ws\")\n        .then((ws) => (NodeWebSocket = ws.default))\n        .catch((e) => {\n            console.log(\"Error importing 'ws':\", e);\n        });\n}\n\ntype ComboWebSocket = NodeWebSocketType | WebSocket;\n\nfunction newWebSocket(url: string, headers: { [key: string]: string }): ComboWebSocket {\n    if (NodeWebSocket) {\n        return new NodeWebSocket(url, { headers });\n    } else {\n        return new WebSocket(url);\n    }\n}\n\nexport { newWebSocket };\nexport type { ComboWebSocket as WebSocket };\n"
  },
  {
    "path": "frontend/wave.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { App } from \"@/app/app\";\nimport { loadMonaco } from \"@/app/monaco/monaco-env\";\nimport { loadBadges } from \"@/app/store/badge\";\nimport { GlobalModel } from \"@/app/store/global-model\";\nimport {\n    globalRefocus,\n    registerBuilderGlobalKeys,\n    registerControlShiftStateUpdateHandler,\n    registerElectronReinjectKeyHandler,\n    registerGlobalKeys,\n} from \"@/app/store/keymodel\";\nimport { modalsModel } from \"@/app/store/modalmodel\";\nimport { RpcApi } from \"@/app/store/wshclientapi\";\nimport { makeBuilderRouteId, makeTabRouteId } from \"@/app/store/wshrouter\";\nimport { initWshrpc, TabRpcClient } from \"@/app/store/wshrpcutil\";\nimport { BuilderApp } from \"@/builder/builder-app\";\nimport { getLayoutModelForStaticTab } from \"@/layout/index\";\nimport { countersClear, countersPrint } from \"@/store/counters\";\nimport {\n    atoms,\n    getApi,\n    globalStore,\n    initGlobal,\n    initGlobalWaveEventSubs,\n    loadConnStatus,\n    subscribeToConnEvents,\n} from \"@/store/global\";\nimport { activeTabIdAtom } from \"@/store/tab-model\";\nimport * as WOS from \"@/store/wos\";\nimport { loadFonts } from \"@/util/fontutil\";\nimport { setKeyUtilPlatform } from \"@/util/keyutil\";\nimport { isMacOS, setMacOSVersion } from \"@/util/platformutil\";\nimport { createElement } from \"react\";\nimport { createRoot } from \"react-dom/client\";\n\nconst platform = getApi().getPlatform();\ndocument.title = `Wave Terminal`;\nlet savedInitOpts: WaveInitOpts = null;\n\n(window as any).WOS = WOS;\n(window as any).globalStore = globalStore;\n(window as any).globalAtoms = atoms;\n(window as any).RpcApi = RpcApi;\n(window as any).isFullScreen = false;\n(window as any).countersPrint = countersPrint;\n(window as any).countersClear = countersClear;\n(window as any).getLayoutModelForStaticTab = getLayoutModelForStaticTab;\n(window as any).modalsModel = modalsModel;\n\nfunction updateZoomFactor(zoomFactor: number) {\n    console.log(\"update zoomfactor\", zoomFactor);\n    document.documentElement.style.setProperty(\"--zoomfactor\", String(zoomFactor));\n    document.documentElement.style.setProperty(\"--zoomfactor-inv\", String(1 / zoomFactor));\n}\n\nasync function initBare() {\n    getApi().sendLog(\"Init Bare\");\n    document.body.style.visibility = \"hidden\";\n    document.body.style.opacity = \"0\";\n    document.body.classList.add(\"is-transparent\");\n    getApi().onWaveInit(initWaveWrap);\n    getApi().onBuilderInit(initBuilderWrap);\n    setKeyUtilPlatform(platform);\n    loadFonts();\n    updateZoomFactor(getApi().getZoomFactor());\n    getApi().onZoomFactorChange((zoomFactor) => {\n        updateZoomFactor(zoomFactor);\n    });\n    document.fonts.ready.then(() => {\n        console.log(\"Init Bare Done\");\n        getApi().setWindowInitStatus(\"ready\");\n    });\n}\n\ndocument.addEventListener(\"DOMContentLoaded\", initBare);\n\nasync function initWaveWrap(initOpts: WaveInitOpts) {\n    try {\n        if (savedInitOpts) {\n            await reinitWave();\n            return;\n        }\n        savedInitOpts = initOpts;\n        await initWave(initOpts);\n    } catch (e) {\n        getApi().sendLog(\"Error in initWave \" + e.message + \"\\n\" + e.stack);\n        console.error(\"Error in initWave\", e);\n    } finally {\n        document.body.style.visibility = null;\n        document.body.style.opacity = null;\n        document.body.classList.remove(\"is-transparent\");\n    }\n}\n\nasync function reinitWave() {\n    console.log(\"Reinit Wave\");\n    getApi().sendLog(\"Reinit Wave\");\n\n    // We use this hack to prevent a flicker of the previously-hovered tab when this view was last active.\n    document.body.classList.add(\"nohover\");\n    requestAnimationFrame(() =>\n        setTimeout(() => {\n            document.body.classList.remove(\"nohover\");\n        }, 100)\n    );\n\n    await WOS.reloadWaveObject<Client>(WOS.makeORef(\"client\", savedInitOpts.clientId));\n    const waveWindow = await WOS.reloadWaveObject<WaveWindow>(WOS.makeORef(\"window\", savedInitOpts.windowId));\n    const ws = await WOS.reloadWaveObject<Workspace>(WOS.makeORef(\"workspace\", waveWindow.workspaceid));\n    const initialTab = await WOS.reloadWaveObject<Tab>(WOS.makeORef(\"tab\", savedInitOpts.tabId));\n    await WOS.reloadWaveObject<LayoutState>(WOS.makeORef(\"layout\", initialTab.layoutstate));\n    reloadAllWorkspaceTabs(ws);\n    document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change\n    getApi().setWindowInitStatus(\"wave-ready\");\n    globalStore.set(atoms.reinitVersion, globalStore.get(atoms.reinitVersion) + 1);\n    globalStore.set(atoms.updaterStatusAtom, getApi().getUpdaterStatus());\n    setTimeout(() => {\n        globalRefocus();\n    }, 50);\n}\n\nfunction reloadAllWorkspaceTabs(ws: Workspace) {\n    if (ws == null || !ws.tabids?.length) {\n        return;\n    }\n    ws.tabids?.forEach((tabid) => {\n        WOS.reloadWaveObject<Tab>(WOS.makeORef(\"tab\", tabid));\n    });\n}\n\nfunction loadAllWorkspaceTabs(ws: Workspace) {\n    if (ws == null || !ws.tabids?.length) {\n        return;\n    }\n    ws.tabids?.forEach((tabid) => {\n        WOS.getObjectValue<Tab>(WOS.makeORef(\"tab\", tabid));\n    });\n}\n\nasync function initWave(initOpts: WaveInitOpts) {\n    getApi().sendLog(\"Init Wave \" + JSON.stringify(initOpts));\n    const globalInitOpts: GlobalInitOptions = {\n        tabId: initOpts.tabId,\n        clientId: initOpts.clientId,\n        windowId: initOpts.windowId,\n        platform,\n        environment: \"renderer\",\n        primaryTabStartup: initOpts.primaryTabStartup,\n    };\n    console.log(\"Wave Init\", globalInitOpts);\n    globalStore.set(activeTabIdAtom, initOpts.tabId);\n    await GlobalModel.getInstance().initialize(globalInitOpts);\n    initGlobal(globalInitOpts);\n    (window as any).globalAtoms = atoms;\n\n    // Init WPS event handlers\n    const globalWS = initWshrpc(makeTabRouteId(initOpts.tabId));\n    (window as any).globalWS = globalWS;\n    (window as any).TabRpcClient = TabRpcClient;\n\n    // ensures client/window/workspace are loaded into the cache before rendering\n    try {\n        await loadConnStatus();\n        await loadBadges();\n        initGlobalWaveEventSubs(initOpts);\n        subscribeToConnEvents();\n        if (isMacOS()) {\n            const macOSVersion = await RpcApi.MacOSVersionCommand(TabRpcClient);\n            setMacOSVersion(macOSVersion);\n        }\n        const [_client, waveWindow, initialTab] = await Promise.all([\n            WOS.loadAndPinWaveObject<Client>(WOS.makeORef(\"client\", initOpts.clientId)),\n            WOS.loadAndPinWaveObject<WaveWindow>(WOS.makeORef(\"window\", initOpts.windowId)),\n            WOS.loadAndPinWaveObject<Tab>(WOS.makeORef(\"tab\", initOpts.tabId)),\n        ]);\n        const [ws, _layoutState] = await Promise.all([\n            WOS.loadAndPinWaveObject<Workspace>(WOS.makeORef(\"workspace\", waveWindow.workspaceid)),\n            WOS.reloadWaveObject<LayoutState>(WOS.makeORef(\"layout\", initialTab.layoutstate)),\n        ]);\n        loadAllWorkspaceTabs(ws);\n        WOS.wpsSubscribeToObject(WOS.makeORef(\"workspace\", waveWindow.workspaceid));\n        document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change\n    } catch (e) {\n        console.error(\"Failed initialization error\", e);\n        getApi().sendLog(\"Error in initialization (wave.ts, loading required objects) \" + e.message + \"\\n\" + e.stack);\n    }\n    registerGlobalKeys();\n    registerElectronReinjectKeyHandler();\n    registerControlShiftStateUpdateHandler();\n    await loadMonaco();\n    const fullConfig = await RpcApi.GetFullConfigCommand(TabRpcClient);\n    console.log(\"fullconfig\", fullConfig);\n    globalStore.set(atoms.fullConfigAtom, fullConfig);\n    const waveaiModeConfig = await RpcApi.GetWaveAIModeConfigCommand(TabRpcClient);\n    globalStore.set(atoms.waveaiModeConfigAtom, waveaiModeConfig.configs);\n    console.log(\"Wave First Render\");\n    let firstRenderResolveFn: () => void = null;\n    const firstRenderPromise = new Promise<void>((resolve) => {\n        firstRenderResolveFn = resolve;\n    });\n    const reactElem = createElement(App, { onFirstRender: firstRenderResolveFn }, null);\n    const elem = document.getElementById(\"main\");\n    const root = createRoot(elem);\n    root.render(reactElem);\n    await firstRenderPromise;\n    console.log(\"Wave First Render Done\");\n    getApi().setWindowInitStatus(\"wave-ready\");\n}\n\nasync function initBuilderWrap(initOpts: BuilderInitOpts) {\n    try {\n        await initBuilder(initOpts);\n    } catch (e) {\n        getApi().sendLog(\"Error in initBuilder \" + e.message + \"\\n\" + e.stack);\n        console.error(\"Error in initBuilder\", e);\n    } finally {\n        document.body.style.visibility = null;\n        document.body.style.opacity = null;\n        document.body.classList.remove(\"is-transparent\");\n    }\n}\n\nasync function initBuilder(initOpts: BuilderInitOpts) {\n    getApi().sendLog(\"Init Builder \" + JSON.stringify(initOpts));\n    const globalInitOpts: GlobalInitOptions = {\n        clientId: initOpts.clientId,\n        windowId: initOpts.windowId,\n        platform,\n        environment: \"renderer\",\n        builderId: initOpts.builderId,\n    };\n    console.log(\"Tsunami Builder Init\", globalInitOpts);\n    await GlobalModel.getInstance().initialize(globalInitOpts);\n    initGlobal(globalInitOpts);\n    (window as any).globalAtoms = atoms;\n\n    const globalWS = initWshrpc(makeBuilderRouteId(initOpts.builderId));\n    (window as any).globalWS = globalWS;\n    (window as any).TabRpcClient = TabRpcClient;\n    await loadConnStatus();\n\n    let appIdToUse: string = null;\n    try {\n        const oref = WOS.makeORef(\"builder\", initOpts.builderId);\n        const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref });\n        if (rtInfo && rtInfo[\"builder:appid\"]) {\n            appIdToUse = rtInfo[\"builder:appid\"];\n        }\n    } catch (e) {\n        console.log(\"Could not load saved builder appId from rtinfo:\", e);\n    }\n\n    document.title = appIdToUse ? `WaveApp Builder (${appIdToUse})` : \"WaveApp Builder\";\n\n    globalStore.set(atoms.builderAppId, appIdToUse);\n\n    const _client = await WOS.loadAndPinWaveObject<Client>(WOS.makeORef(\"client\", initOpts.clientId));\n\n    registerBuilderGlobalKeys();\n    registerElectronReinjectKeyHandler();\n    await loadMonaco();\n    const fullConfig = await RpcApi.GetFullConfigCommand(TabRpcClient);\n    console.log(\"fullconfig\", fullConfig);\n    globalStore.set(atoms.fullConfigAtom, fullConfig);\n    const waveaiModeConfig = await RpcApi.GetWaveAIModeConfigCommand(TabRpcClient);\n    globalStore.set(atoms.waveaiModeConfigAtom, waveaiModeConfig.configs);\n\n    console.log(\"Tsunami Builder First Render\");\n    let firstRenderResolveFn: () => void = null;\n    const firstRenderPromise = new Promise<void>((resolve) => {\n        firstRenderResolveFn = resolve;\n    });\n    const reactElem = createElement(BuilderApp, { initOpts, onFirstRender: firstRenderResolveFn }, null);\n    const elem = document.getElementById(\"main\");\n    const root = createRoot(elem);\n    root.render(reactElem);\n    await firstRenderPromise;\n    console.log(\"Tsunami Builder First Render Done\");\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/wavetermdev/waveterm\n\ngo 1.25.6\n\nrequire (\n\tgithub.com/Microsoft/go-winio v0.6.2\n\tgithub.com/alexflint/go-filemutex v1.3.0\n\tgithub.com/creack/pty v1.1.24\n\tgithub.com/emirpasic/gods v1.18.1\n\tgithub.com/fsnotify/fsnotify v1.9.0\n\tgithub.com/golang-jwt/jwt/v5 v5.3.1\n\tgithub.com/golang-migrate/migrate/v4 v4.19.1\n\tgithub.com/google/generative-ai-go v0.20.1\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/gorilla/mux v1.8.1\n\tgithub.com/gorilla/websocket v1.5.3\n\tgithub.com/invopop/jsonschema v0.13.0\n\tgithub.com/jmoiron/sqlx v1.4.0\n\tgithub.com/joho/godotenv v1.5.1\n\tgithub.com/junegunn/fzf v0.65.2\n\tgithub.com/kevinburke/ssh_config v1.2.0\n\tgithub.com/launchdarkly/eventsource v1.11.0\n\tgithub.com/mattn/go-sqlite3 v1.14.34\n\tgithub.com/mitchellh/mapstructure v1.5.0\n\tgithub.com/sashabaranov/go-openai v1.41.2\n\tgithub.com/sawka/txwrap v0.2.0\n\tgithub.com/shirou/gopsutil/v4 v4.26.2\n\tgithub.com/skeema/knownhosts v1.3.1\n\tgithub.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966\n\tgithub.com/spf13/cobra v1.10.2\n\tgithub.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b\n\tgithub.com/wavetermdev/htmltoken v0.2.0\n\tgithub.com/wavetermdev/waveterm/tsunami v0.12.3\n\tgolang.org/x/crypto v0.49.0\n\tgolang.org/x/mod v0.33.0\n\tgolang.org/x/sync v0.20.0\n\tgolang.org/x/sys v0.42.0\n\tgolang.org/x/term v0.41.0\n\tgoogle.golang.org/api v0.271.0\n)\n\nrequire (\n\tcloud.google.com/go v0.121.6 // indirect\n\tcloud.google.com/go/ai v0.8.0 // indirect\n\tcloud.google.com/go/auth v0.18.2 // indirect\n\tcloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect\n\tcloud.google.com/go/compute/metadata v0.9.0 // indirect\n\tcloud.google.com/go/longrunning v0.6.7 // indirect\n\tgithub.com/bahlo/generic-list-go v0.2.0 // indirect\n\tgithub.com/buger/jsonparser v1.1.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/ebitengine/purego v0.10.0 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/go-logr/logr v1.4.3 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/go-ole/go-ole v1.2.6 // indirect\n\tgithub.com/google/s2a-go v0.1.9 // indirect\n\tgithub.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect\n\tgithub.com/googleapis/gax-go/v2 v2.17.0 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect\n\tgithub.com/mailru/easyjson v0.7.7 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/sirupsen/logrus v1.9.3 // indirect\n\tgithub.com/spf13/pflag v1.0.9 // indirect\n\tgithub.com/tklauser/go-sysconf v0.3.16 // indirect\n\tgithub.com/tklauser/numcpus v0.11.0 // indirect\n\tgithub.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 // indirect\n\tgithub.com/wk8/go-ordered-map/v2 v2.1.8 // indirect\n\tgithub.com/yusufpapurcu/wmi v1.2.4 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.2.1 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect\n\tgo.opentelemetry.io/otel v1.39.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.39.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.39.0 // indirect\n\tgolang.org/x/net v0.51.0 // indirect\n\tgolang.org/x/oauth2 v0.36.0 // indirect\n\tgolang.org/x/text v0.35.0 // indirect\n\tgolang.org/x/time v0.15.0 // indirect\n\tgoogle.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect\n\tgoogle.golang.org/grpc v1.79.3 // indirect\n\tgoogle.golang.org/protobuf v1.36.11 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n\nreplace github.com/kevinburke/ssh_config => github.com/wavetermdev/ssh_config v0.0.0-20241219203747-6409e4292f34\n\nreplace github.com/creack/pty => github.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b\n\nreplace github.com/wavetermdev/waveterm/tsunami => ./tsunami\n"
  },
  {
    "path": "go.sum",
    "content": "cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c=\ncloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI=\ncloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w=\ncloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE=\ncloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=\ncloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=\ncloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=\ncloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=\ncloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=\ncloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=\ncloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=\ncloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=\nfilippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=\nfilippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=\ngithub.com/0xrawsec/golang-utils v1.3.2 h1:ww4jrtHRSnX9xrGzJYbalx5nXoZewy4zPxiY+ubJgtg=\ngithub.com/0xrawsec/golang-utils v1.3.2/go.mod h1:m7AzHXgdSAkFCD9tWWsApxNVxMlyy7anpPVOyT/yM7E=\ngithub.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=\ngithub.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=\ngithub.com/alexflint/go-filemutex v1.3.0 h1:LgE+nTUWnQCyRKbpoceKZsPQbs84LivvgwUymZXdOcM=\ngithub.com/alexflint/go-filemutex v1.3.0/go.mod h1:U0+VA/i30mGBlLCrFPGtTe9y6wGQfNAWPBTekHQ+c8A=\ngithub.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=\ngithub.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=\ngithub.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=\ngithub.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=\ngithub.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=\ngithub.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=\ngithub.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=\ngithub.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=\ngithub.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=\ngithub.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=\ngithub.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=\ngithub.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=\ngithub.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=\ngithub.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=\ngithub.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=\ngithub.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=\ngithub.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=\ngithub.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=\ngithub.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=\ngithub.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/google/generative-ai-go v0.20.1 h1:6dEIujpgN2V0PgLhr6c/M1ynRdc7ARtiIDPFzj45uNQ=\ngithub.com/google/generative-ai-go v0.20.1/go.mod h1:TjOnZJmZKzarWbjUJgy+r3Ee7HGBRVLhOIgupnwR4Bg=\ngithub.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=\ngithub.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=\ngithub.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=\ngithub.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=\ngithub.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=\ngithub.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=\ngithub.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=\ngithub.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=\ngithub.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=\ngithub.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=\ngithub.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=\ngithub.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=\ngithub.com/junegunn/fzf v0.65.2 h1:Uz6Qey1K4JoGNMskYlwRDnGuCEu/sAh+NxQ4YdX3yn0=\ngithub.com/junegunn/fzf v0.65.2/go.mod h1:0PctWYfS0aCfyLFEIUjtE+PIXD2UFKaHgbIHiECG7Bo=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/launchdarkly/eventsource v1.11.0 h1:aAdvh2XmtXA17QsRFL0XKHURMqhxg7J+CceQmhSzBas=\ngithub.com/launchdarkly/eventsource v1.11.0/go.mod h1:dU+rZxkPOlGPsyJPpiDqiepAcFwIITDUClY9+A6RrMw=\ngithub.com/launchdarkly/go-test-helpers/v3 v3.1.0 h1:E3bxJMzMoA+cJSF3xxtk2/chr1zshl1ZWa0/oR+8bvg=\ngithub.com/launchdarkly/go-test-helpers/v3 v3.1.0/go.mod h1:Ake5+hZFS/DmIGKx/cizhn5W9pGA7pplcR7xCxWiLIo=\ngithub.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=\ngithub.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\ngithub.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=\ngithub.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=\ngithub.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=\ngithub.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=\ngithub.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=\ngithub.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=\ngithub.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b h1:cLGKfKb1uk0hxI0Q8L83UAJPpeJ+gSpn3cCU/tjd3eg=\ngithub.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b/go.mod h1:KO+FcPtyLAiRC0hJwreJVvfwc7vnNz77UxBTIGHdPVk=\ngithub.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=\ngithub.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=\ngithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=\ngithub.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=\ngithub.com/sawka/txwrap v0.2.0 h1:V3LfvKVLULxcYSxdMguLwFyQFMEU9nFDJopg0ZkL+94=\ngithub.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA=\ngithub.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=\ngithub.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=\ngithub.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=\ngithub.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=\ngithub.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=\ngithub.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=\ngithub.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=\ngithub.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=\ngithub.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=\ngithub.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=\ngithub.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=\ngithub.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=\ngithub.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=\ngithub.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=\ngithub.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=\ngithub.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 h1:XQpsQG5lqRJlx4mUVHcJvyyc1rdTI9nHvwrdfcuy8aM=\ngithub.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117/go.mod h1:mx0TjbqsaDD9DUT5gA1s3hw47U6RIbbIBfvGzR85K0g=\ngithub.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b h1:wFBKF5k5xbJQU8bYgcSoQ/ScvmYyq6KHUabAuVUjOWM=\ngithub.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b/go.mod h1:N1CYNinssZru+ikvYTgVbVeSi21thHUTCoJ9xMvWe+s=\ngithub.com/wavetermdev/htmltoken v0.2.0 h1:sFVPPemlDv7/jg7n4Hx1AEF2m9MVAFjFpELWfhi/DlM=\ngithub.com/wavetermdev/htmltoken v0.2.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk=\ngithub.com/wavetermdev/ssh_config v0.0.0-20241219203747-6409e4292f34 h1:I8VZVTZEXhnzfN7jB9a7TZYpzNO48sCUWMRXHM9XWSA=\ngithub.com/wavetermdev/ssh_config v0.0.0-20241219203747-6409e4292f34/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=\ngithub.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=\ngithub.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=\ngithub.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=\ngithub.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=\ngo.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=\ngo.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=\ngo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=\ngo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=\ngo.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=\ngo.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=\ngo.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=\ngo.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=\ngo.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=\ngo.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=\ngo.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=\ngo.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=\ngo.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=\ngo.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=\ngolang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=\ngolang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=\ngolang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=\ngolang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=\ngolang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=\ngolang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=\ngolang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=\ngolang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=\ngolang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=\ngolang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=\ngolang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=\ngolang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=\ngolang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=\ngolang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=\ngolang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=\ngolang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=\ngonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=\ngoogle.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY=\ngoogle.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q=\ngoogle.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=\ngoogle.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=\ngoogle.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=\ngoogle.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=\ngoogle.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=\ngoogle.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta name=\"color scheme\" content=\"light dark\" />\n    <title>Wave</title>\n    <link rel=\"stylesheet\" href=\"/fontawesome/css/fontawesome.min.css\" />\n    <link rel=\"stylesheet\" href=\"/fontawesome/css/brands.min.css\" />\n    <link rel=\"stylesheet\" href=\"/fontawesome/css/solid.min.css\" />\n    <link rel=\"stylesheet\" href=\"/fontawesome/css/sharp-solid.min.css\" />\n    <link rel=\"stylesheet\" href=\"/fontawesome/css/sharp-regular.min.css\" />\n    <link rel=\"stylesheet\" href=\"/fontawesome/css/custom-icons.min.css\" />\n    <script type=\"module\" src=\"frontend/wave.ts\"></script>\n  </head>\n  <body class=\"init\" data-colorscheme=\"dark\">\n    <div id=\"main\" class=\"flex flex-col w-full h-full\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"name\": \"waveterm\",\n    \"author\": {\n        \"name\": \"Command Line Inc\",\n        \"email\": \"info@commandline.dev\"\n    },\n    \"productName\": \"Wave\",\n    \"description\": \"Open-Source AI-Native Terminal Built for Seamless Workflows\",\n    \"license\": \"Apache-2.0\",\n    \"version\": \"0.14.3\",\n    \"homepage\": \"https://waveterm.dev\",\n    \"build\": {\n        \"appId\": \"dev.commandline.waveterm\"\n    },\n    \"private\": true,\n    \"main\": \"./dist/main/index.js\",\n    \"type\": \"module\",\n    \"browserslist\": [\n        \"Chrome >= 128\"\n    ],\n    \"scripts\": {\n        \"dev\": \"electron-vite dev\",\n        \"start\": \"electron-vite preview\",\n        \"build:dev\": \"electron-vite build --mode development\",\n        \"build:prod\": \"electron-vite build --mode production\",\n        \"coverage\": \"vitest run --coverage\",\n        \"test\": \"vitest\",\n        \"postinstall\": \"node ./postinstall.cjs\"\n    },\n    \"devDependencies\": {\n        \"@eslint/js\": \"^9.39\",\n        \"@rollup/plugin-node-resolve\": \"^16.0.3\",\n        \"@tailwindcss/vite\": \"^4.2.1\",\n        \"@types/color\": \"^4.2.0\",\n        \"@types/css-tree\": \"^2\",\n        \"@types/debug\": \"^4\",\n        \"@types/node\": \"^22.13.17\",\n        \"@types/papaparse\": \"^5\",\n        \"@types/pngjs\": \"^6.0.5\",\n        \"@types/prop-types\": \"^15\",\n        \"@types/react\": \"19\",\n        \"@types/react-dom\": \"19\",\n        \"@types/semver\": \"^7\",\n        \"@types/shell-quote\": \"^1\",\n        \"@types/sprintf-js\": \"^1\",\n        \"@types/throttle-debounce\": \"^5\",\n        \"@types/tinycolor2\": \"^1\",\n        \"@types/ws\": \"^8\",\n        \"@vitejs/plugin-react-swc\": \"4.2.3\",\n        \"@vitest/coverage-istanbul\": \"^3.0.9\",\n        \"electron\": \"^41.0.2\",\n        \"electron-builder\": \"^26.8\",\n        \"electron-vite\": \"^5.0\",\n        \"eslint\": \"^9.39\",\n        \"eslint-config-prettier\": \"^10.1.8\",\n        \"globals\": \"^17.4.0\",\n        \"node-abi\": \"^4.26.0\",\n        \"postcss\": \"^8.5.8\",\n        \"prettier\": \"^3.8.1\",\n        \"prettier-plugin-jsdoc\": \"^1.8.0\",\n        \"prettier-plugin-organize-imports\": \"^4.3.0\",\n        \"sass\": \"1.91.0\",\n        \"tailwindcss\": \"^4.2.1\",\n        \"tailwindcss-animate\": \"^1.0.7\",\n        \"ts-node\": \"^10.9.2\",\n        \"tslib\": \"^2.8.1\",\n        \"tsx\": \"^4.21.0\",\n        \"typescript\": \"^5.9.3\",\n        \"typescript-eslint\": \"^8.56\",\n        \"vite\": \"^6.4.1\",\n        \"vite-plugin-image-optimizer\": \"^2.0.3\",\n        \"vite-plugin-svgr\": \"^4.5.0\",\n        \"vite-tsconfig-paths\": \"^5.1.4\",\n        \"vitest\": \"^3.0.9\"\n    },\n    \"dependencies\": {\n        \"@ai-sdk/react\": \"^2.0.104\",\n        \"@floating-ui/react\": \"^0.27.16\",\n        \"@observablehq/plot\": \"^0.6.17\",\n        \"@react-hook/resize-observer\": \"^2.0.2\",\n        \"@table-nav/core\": \"^0.0.7\",\n        \"@table-nav/react\": \"^0.0.7\",\n        \"@tanstack/react-table\": \"^8.21.3\",\n        \"@tanstack/react-virtual\": \"^3.13.19\",\n        \"@xterm/addon-canvas\": \"^0.7.0\",\n        \"@xterm/addon-fit\": \"^0.10.0\",\n        \"@xterm/addon-search\": \"^0.15.0\",\n        \"@xterm/addon-serialize\": \"^0.13.0\",\n        \"@xterm/addon-web-links\": \"^0.11.0\",\n        \"@xterm/addon-webgl\": \"^0.18.0\",\n        \"@xterm/xterm\": \"^5.5.0\",\n        \"ai\": \"^5.0.92\",\n        \"base64-js\": \"^1.5.1\",\n        \"class-variance-authority\": \"^0.7.1\",\n        \"clsx\": \"^2.1.1\",\n        \"color\": \"^4.2.3\",\n        \"colord\": \"^2.9.3\",\n        \"css-tree\": \"^3.1.0\",\n        \"dayjs\": \"^1.11.19\",\n        \"debug\": \"^4.4.3\",\n        \"electron-updater\": \"^6.6\",\n        \"env-paths\": \"^3.0.0\",\n        \"fast-average-color\": \"^9.5.0\",\n        \"htl\": \"^0.3.1\",\n        \"html-to-image\": \"^1.11.13\",\n        \"immer\": \"^10.1.1\",\n        \"jotai\": \"2.9.3\",\n        \"mermaid\": \"^11.12.3\",\n        \"monaco-editor\": \"^0.55.1\",\n        \"monaco-yaml\": \"^5.4.0\",\n        \"overlayscrollbars\": \"^2.14.0\",\n        \"overlayscrollbars-react\": \"^0.5.6\",\n        \"papaparse\": \"^5.5.3\",\n        \"parse-srcset\": \"^1.0.2\",\n        \"pngjs\": \"^7.0.0\",\n        \"prop-types\": \"^15.8.1\",\n        \"qs\": \"^6.15.0\",\n        \"react\": \"^19.2.0\",\n        \"react-dnd\": \"^16.0.1\",\n        \"react-dnd-html5-backend\": \"^16.0.1\",\n        \"react-dom\": \"^19.2.0\",\n        \"react-frame-component\": \"^5.2.7\",\n        \"react-markdown\": \"^9.0.3\",\n        \"react-resizable-panels\": \"^3.0.6\",\n        \"react-zoom-pan-pinch\": \"^3.7.0\",\n        \"recharts\": \"^2.15.4\",\n        \"rehype-highlight\": \"^7.0.2\",\n        \"rehype-raw\": \"^7.0.0\",\n        \"rehype-sanitize\": \"^6.0.0\",\n        \"rehype-slug\": \"^6.0.0\",\n        \"remark-flexible-toc\": \"^1.2.4\",\n        \"remark-gfm\": \"^4.0.1\",\n        \"remark-github-blockquote-alert\": \"^1.3.1\",\n        \"rxjs\": \"^7.8.2\",\n        \"semver\": \"^7.7.3\",\n        \"shell-quote\": \"^1.8.3\",\n        \"shiki\": \"^3.22.0\",\n        \"sprintf-js\": \"^1.1.3\",\n        \"streamdown\": \"^1.6.10\",\n        \"tailwind-merge\": \"^3.5.0\",\n        \"throttle-debounce\": \"^5.0.2\",\n        \"tinycolor2\": \"^1.6.0\",\n        \"unist-util-visit\": \"^5.1.0\",\n        \"use-device-pixel-ratio\": \"^1.1.2\",\n        \"uuid\": \"^13.0.0\",\n        \"winston\": \"^3.19.0\",\n        \"ws\": \"^8.19.0\",\n        \"yaml\": \"^2.7.1\"\n    },\n    \"packageManager\": \"npm@10.9.2\",\n    \"workspaces\": [\n        \"docs\",\n        \"tsunami/frontend\"\n    ]\n}\n"
  },
  {
    "path": "pkg/aiusechat/aiutil/aiutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiutil\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcore\"\n\t\"github.com/wavetermdev/waveterm/pkg/web/sse\"\n)\n\n// ExtractXmlAttribute extracts an attribute value from an XML-like tag.\n// Expects double-quoted strings where internal quotes are encoded as &quot;.\n// Returns the unquoted value and true if found, or empty string and false if not found or invalid.\nfunc ExtractXmlAttribute(tag, attrName string) (string, bool) {\n\tattrStart := strings.Index(tag, attrName+\"=\")\n\tif attrStart == -1 {\n\t\treturn \"\", false\n\t}\n\n\tpos := attrStart + len(attrName+\"=\")\n\tstart := strings.Index(tag[pos:], `\"`)\n\tif start == -1 {\n\t\treturn \"\", false\n\t}\n\tstart += pos\n\n\tend := strings.Index(tag[start+1:], `\"`)\n\tif end == -1 {\n\t\treturn \"\", false\n\t}\n\tend += start + 1\n\n\tquotedValue := tag[start : end+1]\n\tvalue, err := strconv.Unquote(quotedValue)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\n\tvalue = strings.ReplaceAll(value, \"&quot;\", `\"`)\n\treturn value, true\n}\n\n// GenerateDeterministicSuffix creates an 8-character hash from input strings\nfunc GenerateDeterministicSuffix(inputs ...string) string {\n\thasher := sha256.New()\n\tfor _, input := range inputs {\n\t\thasher.Write([]byte(input))\n\t}\n\thash := hasher.Sum(nil)\n\treturn hex.EncodeToString(hash)[:8]\n}\n\n// ExtractImageUrl extracts an image URL from either URL field (http/https/data) or raw Data\nfunc ExtractImageUrl(data []byte, url, mimeType string) (string, error) {\n\tif url != \"\" {\n\t\tif !strings.HasPrefix(url, \"data:\") &&\n\t\t\t!strings.HasPrefix(url, \"http://\") &&\n\t\t\t!strings.HasPrefix(url, \"https://\") {\n\t\t\treturn \"\", fmt.Errorf(\"unsupported URL protocol in file part: %s\", url)\n\t\t}\n\t\treturn url, nil\n\t}\n\tif len(data) > 0 {\n\t\tbase64Data := base64.StdEncoding.EncodeToString(data)\n\t\treturn fmt.Sprintf(\"data:%s;base64,%s\", mimeType, base64Data), nil\n\t}\n\treturn \"\", fmt.Errorf(\"file part missing both url and data\")\n}\n\n// ExtractTextData extracts text data from either Data field or URL field (data: URLs only)\nfunc ExtractTextData(data []byte, url string) ([]byte, error) {\n\tif len(data) > 0 {\n\t\treturn data, nil\n\t}\n\tif url != \"\" {\n\t\tif strings.HasPrefix(url, \"data:\") {\n\t\t\t_, decodedData, err := utilfn.DecodeDataURL(url)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to decode data URL for text/plain file: %w\", err)\n\t\t\t}\n\t\t\treturn decodedData, nil\n\t\t}\n\t\treturn nil, fmt.Errorf(\"dropping text/plain file with URL (must be fetched and converted to data)\")\n\t}\n\treturn nil, fmt.Errorf(\"text/plain file part missing data\")\n}\n\n// FormatAttachedTextFile formats a text file attachment with proper encoding and deterministic suffix\nfunc FormatAttachedTextFile(fileName string, textContent []byte) string {\n\tif fileName == \"\" {\n\t\tfileName = \"untitled.txt\"\n\t}\n\n\tencodedFileName := strings.ReplaceAll(fileName, `\"`, \"&quot;\")\n\tquotedFileName := strconv.Quote(encodedFileName)\n\n\ttextStr := string(textContent)\n\tdeterministicSuffix := GenerateDeterministicSuffix(textStr, fileName)\n\treturn fmt.Sprintf(\"<AttachedTextFile_%s file_name=%s>\\n%s\\n</AttachedTextFile_%s>\", deterministicSuffix, quotedFileName, textStr, deterministicSuffix)\n}\n\n// FormatAttachedDirectoryListing formats a directory listing attachment with proper encoding and deterministic suffix\nfunc FormatAttachedDirectoryListing(directoryName, jsonContent string) string {\n\tif directoryName == \"\" {\n\t\tdirectoryName = \"unnamed-directory\"\n\t}\n\n\tencodedDirName := strings.ReplaceAll(directoryName, `\"`, \"&quot;\")\n\tquotedDirName := strconv.Quote(encodedDirName)\n\n\tdeterministicSuffix := GenerateDeterministicSuffix(jsonContent, directoryName)\n\treturn fmt.Sprintf(\"<AttachedDirectoryListing_%s directory_name=%s>\\n%s\\n</AttachedDirectoryListing_%s>\", deterministicSuffix, quotedDirName, jsonContent, deterministicSuffix)\n}\n\n// ConvertDataUserFile converts OpenAI attached file/directory blocks to UIMessagePart\n// Returns (found, part) where found indicates if the prefix was matched,\n// and part is the converted UIMessagePart (can be nil if parsing failed)\nfunc ConvertDataUserFile(blockText string) (bool, *uctypes.UIMessagePart) {\n\tif strings.HasPrefix(blockText, \"<AttachedTextFile_\") {\n\t\topenTagEnd := strings.Index(blockText, \"\\n\")\n\t\tif openTagEnd == -1 || blockText[openTagEnd-1] != '>' {\n\t\t\treturn true, nil\n\t\t}\n\n\t\topenTag := blockText[:openTagEnd]\n\t\tfileName, ok := ExtractXmlAttribute(openTag, \"file_name\")\n\t\tif !ok {\n\t\t\treturn true, nil\n\t\t}\n\n\t\treturn true, &uctypes.UIMessagePart{\n\t\t\tType: \"data-userfile\",\n\t\t\tData: uctypes.UIMessageDataUserFile{\n\t\t\t\tFileName: fileName,\n\t\t\t\tMimeType: \"text/plain\",\n\t\t\t},\n\t\t}\n\t}\n\n\tif strings.HasPrefix(blockText, \"<AttachedDirectoryListing_\") {\n\t\topenTagEnd := strings.Index(blockText, \"\\n\")\n\t\tif openTagEnd == -1 || blockText[openTagEnd-1] != '>' {\n\t\t\treturn true, nil\n\t\t}\n\n\t\topenTag := blockText[:openTagEnd]\n\t\tdirectoryName, ok := ExtractXmlAttribute(openTag, \"directory_name\")\n\t\tif !ok {\n\t\t\treturn true, nil\n\t\t}\n\n\t\treturn true, &uctypes.UIMessagePart{\n\t\t\tType: \"data-userfile\",\n\t\t\tData: uctypes.UIMessageDataUserFile{\n\t\t\t\tFileName: directoryName,\n\t\t\t\tMimeType: \"directory\",\n\t\t\t},\n\t\t}\n\t}\n\n\treturn false, nil\n}\n\nfunc JsonEncodeRequestBody(reqBody any) (bytes.Buffer, error) {\n\tvar buf bytes.Buffer\n\tencoder := json.NewEncoder(&buf)\n\tencoder.SetEscapeHTML(false)\n\terr := encoder.Encode(reqBody)\n\tif err != nil {\n\t\treturn buf, err\n\t}\n\treturn buf, nil\n}\n\nfunc MakeHTTPClient(proxyURL string) (*http.Client, error) {\n\tclient := &http.Client{\n\t\tTimeout: 0, // rely on ctx; streaming can be long\n\t}\n\tif proxyURL == \"\" {\n\t\treturn client, nil\n\t}\n\n\tpURL, err := url.Parse(proxyURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid proxy URL: %w\", err)\n\t}\n\tclient.Transport = &http.Transport{\n\t\tProxy: http.ProxyURL(pURL),\n\t}\n\treturn client, nil\n}\n\nfunc IsOpenAIReasoningModel(model string) bool {\n\tm := strings.ToLower(model)\n\treturn CheckModelPrefix(m, \"o1\") ||\n\t\tCheckModelPrefix(m, \"o3\") ||\n\t\tCheckModelPrefix(m, \"o4\") ||\n\t\tCheckModelPrefix(m, \"gpt-5\") ||\n\t\tCheckModelSubPrefix(m, \"gpt-5.\") ||\n\t\tCheckModelPrefix(m, \"gpt-6\") ||\n\t\tCheckModelSubPrefix(m, \"gpt-6.\")\n}\n\nfunc CheckModelPrefix(model string, prefix string) bool {\n\treturn model == prefix || strings.HasPrefix(model, prefix+\"-\")\n}\n\nfunc CheckModelSubPrefix(model string, prefix string) bool {\n\tif strings.HasPrefix(model, prefix) && len(model) > len(prefix) {\n\t\tif model[len(prefix)] >= '0' && model[len(prefix)] <= '9' {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// GeminiSupportsImageToolResults returns true if the model supports multimodal function responses (images in tool results)\n// This is only supported by Gemini 3 Pro and later models\nfunc GeminiSupportsImageToolResults(model string) bool {\n\tm := strings.ToLower(model)\n\treturn strings.Contains(m, \"gemini-3\") || strings.Contains(m, \"gemini-4\")\n}\n\n// CreateToolUseData creates a UIMessageDataToolUse from tool call information\nfunc CreateToolUseData(toolCallID, toolName string, arguments string, chatOpts uctypes.WaveChatOpts) uctypes.UIMessageDataToolUse {\n\ttoolUseData := uctypes.UIMessageDataToolUse{\n\t\tToolCallId: toolCallID,\n\t\tToolName:   toolName,\n\t\tStatus:     uctypes.ToolUseStatusPending,\n\t}\n\n\ttoolDef := chatOpts.GetToolDefinition(toolName)\n\tif toolDef == nil {\n\t\ttoolUseData.Status = uctypes.ToolUseStatusError\n\t\ttoolUseData.ErrorMessage = \"tool not found\"\n\t\treturn toolUseData\n\t}\n\n\tvar parsedArgs any\n\tif err := json.Unmarshal([]byte(arguments), &parsedArgs); err != nil {\n\t\ttoolUseData.Status = uctypes.ToolUseStatusError\n\t\ttoolUseData.ErrorMessage = fmt.Sprintf(\"failed to parse tool arguments: %v\", err)\n\t\treturn toolUseData\n\t}\n\n\tif toolDef.ToolCallDesc != nil {\n\t\ttoolUseData.ToolDesc = toolDef.ToolCallDesc(parsedArgs, nil, nil)\n\t}\n\n\tif toolDef.ToolApproval != nil {\n\t\ttoolUseData.Approval = toolDef.ToolApproval(parsedArgs)\n\t}\n\n\tif chatOpts.TabId != \"\" {\n\t\tif argsMap, ok := parsedArgs.(map[string]any); ok {\n\t\t\tif widgetId, ok := argsMap[\"widget_id\"].(string); ok && widgetId != \"\" {\n\t\t\t\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\t\t\t\tdefer cancelFn()\n\t\t\t\tfullBlockId, err := wcore.ResolveBlockIdFromPrefix(ctx, chatOpts.TabId, widgetId)\n\t\t\t\tif err == nil {\n\t\t\t\t\ttoolUseData.BlockId = fullBlockId\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn toolUseData\n}\n\n// SendToolProgress sends tool progress updates via SSE if the tool has a progress descriptor\nfunc SendToolProgress(toolCallID, toolName string, jsonData []byte, chatOpts uctypes.WaveChatOpts, sseHandler *sse.SSEHandlerCh, usePartialParse bool) {\n\ttoolDef := chatOpts.GetToolDefinition(toolName)\n\tif toolDef == nil || toolDef.ToolProgressDesc == nil {\n\t\treturn\n\t}\n\n\tvar parsedJSON any\n\tvar err error\n\tif usePartialParse {\n\t\tparsedJSON, err = utilfn.ParsePartialJson(jsonData)\n\t} else {\n\t\terr = json.Unmarshal(jsonData, &parsedJSON)\n\t}\n\tif err != nil {\n\t\treturn\n\t}\n\n\tstatusLines, err := toolDef.ToolProgressDesc(parsedJSON)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tprogressData := &uctypes.UIMessageDataToolProgress{\n\t\tToolCallId:  toolCallID,\n\t\tToolName:    toolName,\n\t\tStatusLines: statusLines,\n\t}\n\t_ = sseHandler.AiMsgData(\"data-toolprogress\", \"progress-\"+toolCallID, progressData)\n}\n"
  },
  {
    "path": "pkg/aiusechat/anthropic/anthropic-backend.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage anthropic\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/launchdarkly/eventsource\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/logutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/web/sse\"\n)\n\nconst (\n\tAnthropicDefaultAPIVersion           = \"2023-06-01\"\n\tAnthropicDefaultMaxTokens            = 4096\n\tAnthropicThinkingBudget              = 1024\n\tAnthropicMinThinkingBudget           = 1024\n\tProviderMetadataThinkingSignatureKey = \"anthropic:signature\"\n)\n\n// ---------- Anthropic wire types (subset) ----------\n// Derived from anthropic-messages-api.md and anthropic-streaming.md. :contentReference[oaicite:6]{index=6} :contentReference[oaicite:7]{index=7}\n\ntype anthropicChatMessage struct {\n\tMessageId string                         `json:\"messageid\"`       // internal field for idempotency (cannot send to anthropic)\n\tUsage     *anthropicUsageType            `json:\"usage,omitempty\"` // internal field (cannot send to anthropic)\n\tRole      string                         `json:\"role\"`\n\tContent   []anthropicMessageContentBlock `json:\"content\"`\n}\n\nfunc (m *anthropicChatMessage) GetMessageId() string {\n\treturn m.MessageId\n}\n\nfunc (m *anthropicChatMessage) GetRole() string {\n\treturn m.Role\n}\n\nfunc (m *anthropicChatMessage) GetUsage() *uctypes.AIUsage {\n\tif m.Usage == nil {\n\t\treturn nil\n\t}\n\n\treturn &uctypes.AIUsage{\n\t\tAPIType:              uctypes.APIType_AnthropicMessages,\n\t\tModel:                m.Usage.Model,\n\t\tInputTokens:          m.Usage.InputTokens,\n\t\tOutputTokens:         m.Usage.OutputTokens,\n\t\tNativeWebSearchCount: m.Usage.NativeWebSearchCount,\n\t}\n}\n\ntype anthropicInputMessage struct {\n\tRole    string                         `json:\"role\"`\n\tContent []anthropicMessageContentBlock `json:\"content\"`\n}\n\ntype anthropicMessageContentBlock struct {\n\t// text, image, document, tool_use, tool_result, thinking, redacted_thinking,\n\t// server_tool_use, web_search_tool_result, code_execution_tool_result,\n\t// mcp_tool_use, mcp_tool_result, container_upload, search_result, web_search_result\n\tType string `json:\"type\"`\n\n\tCacheControl *anthropicCacheControl `json:\"cache_control,omitempty\"`\n\n\t// Text content\n\tText string `json:\"text,omitempty\"`\n\n\t// not going to support citations now\n\t// Citations []anthropicCitation `json:\"citations,omitempty\"`\n\n\t// Image+File content\n\tSource           *anthropicSource `json:\"source,omitempty\"`\n\tSourcePreviewUrl string           `json:\"sourcepreviewurl,omitempty\"` // internal field (cannot marshal to API, must be stripped)\n\n\t// Document content\n\tTitle   string `json:\"title,omitempty\"`\n\tContext string `json:\"context,omitempty\"`\n\n\t// Tool use content\n\tID    string      `json:\"id,omitempty\"`\n\tName  string      `json:\"name,omitempty\"`\n\tInput interface{} `json:\"input,omitempty\"`\n\n\tToolUseDisplayName      string                        `json:\"toolusedisplayname,omitempty\"`      // internal field (cannot marshal to API, must be stripped)\n\tToolUseShortDescription string                        `json:\"tooluseshortdescription,omitempty\"` // internal field (cannot marshal to API, must be stripped)\n\tToolUseData             *uctypes.UIMessageDataToolUse `json:\"toolusedata,omitempty\"`             // internal field (cannot marshal to API, must be stripped)\n\n\t// Tool result content\n\tToolUseID string      `json:\"tool_use_id,omitempty\"`\n\tIsError   bool        `json:\"is_error,omitempty\"`\n\tContent   interface{} `json:\"content,omitempty\"` // string or []blocks for tool results\n\n\t// Thinking content (extended thinking feature)\n\tThinking  string `json:\"thinking,omitempty\"`\n\tSignature string `json:\"signature,omitempty\"`\n\n\t// Server tool use/MCP (web search, code execution, MCP tools)\n\tServerName string `json:\"server_name,omitempty\"`\n\n\t// Container upload\n\tFileID string `json:\"file_id,omitempty\"`\n\n\t// Web search result (for responses)\n\tURL              string `json:\"url,omitempty\"`\n\tEncryptedContent string `json:\"encrypted_content,omitempty\"`\n\tPageAge          string `json:\"page_age,omitempty\"`\n\n\t// Code execution results\n\tReturnCode int    `json:\"return_code,omitempty\"`\n\tStdout     string `json:\"stdout,omitempty\"`\n\tStderr     string `json:\"stderr,omitempty\"`\n}\n\ntype anthropicSource struct {\n\tType      string      `json:\"type\"` // \"base64\", \"url\", \"file\", \"text\", \"content\"\n\tData      string      `json:\"data,omitempty\"`\n\tMediaType string      `json:\"media_type,omitempty\"` // MIME type\n\tURL       string      `json:\"url,omitempty\"`        // URL reference\n\tFileID    string      `json:\"file_id,omitempty\"`    // file upload ID\n\tText      string      `json:\"text,omitempty\"`       // plain text (documents only)\n\tContent   interface{} `json:\"content,omitempty\"`    // content blocks (documents only)\n\tFileName  string      `json:\"filename,omitempty\"`   // internal field (cannot marshal to API, must be stripped)\n\tSize      int         `json:\"size,omitempty\"`       // internal field (cannot marshal to API, must be stripped)\n}\n\nfunc (s *anthropicSource) Clean() *anthropicSource {\n\tif s == nil {\n\t\treturn nil\n\t}\n\trtn := *s\n\trtn.FileName = \"\"\n\trtn.Size = 0\n\treturn &rtn\n}\n\nfunc (b *anthropicMessageContentBlock) Clean() *anthropicMessageContentBlock {\n\tif b == nil {\n\t\treturn nil\n\t}\n\trtn := *b\n\trtn.SourcePreviewUrl = \"\"\n\trtn.ToolUseDisplayName = \"\"\n\trtn.ToolUseShortDescription = \"\"\n\trtn.ToolUseData = nil\n\tif rtn.Source != nil {\n\t\trtn.Source = rtn.Source.Clean()\n\t}\n\treturn &rtn\n}\n\ntype anthropicCitation struct {\n\tType           string `json:\"type\"`\n\tCitedText      string `json:\"cited_text\"`\n\tDocumentIndex  int    `json:\"document_index,omitempty\"`\n\tDocumentTitle  string `json:\"document_title,omitempty\"`\n\tStartCharIndex int    `json:\"start_char_index,omitempty\"`\n\tEndCharIndex   int    `json:\"end_char_index,omitempty\"`\n\t// ... other citation type fields\n}\n\ntype anthropicStreamRequest struct {\n\tModel      string                         `json:\"model\"`\n\tMessages   []anthropicInputMessage        `json:\"messages\"`\n\tMaxTokens  int                            `json:\"max_tokens\"`\n\tStream     bool                           `json:\"stream\"`\n\tSystem     []anthropicMessageContentBlock `json:\"system,omitempty\"`\n\tToolChoice any                            `json:\"tool_choice,omitempty\"`\n\tTools      []any                          `json:\"tools,omitempty\"` // *uctypes.ToolDefinition or *anthropicWebSearchTool\n\tThinking   *anthropicThinkingOpts         `json:\"thinking,omitempty\"`\n}\n\ntype anthropicWebSearchTool struct {\n\tType string `json:\"type\"` // \"web_search_20250305\"\n\tName string `json:\"name\"` // \"web_search\"\n}\n\ntype anthropicCacheControl struct {\n\tType string `json:\"type\"` // \"ephemeral\"\n\tTTL  string `json:\"ttl\"`  // \"5m\" or \"1h\"\n}\n\ntype anthropicMessageObj struct {\n\tID           string  `json:\"id\"`\n\tModel        string  `json:\"model\"`\n\tStopReason   *string `json:\"stop_reason\"`\n\tStopSequence *string `json:\"stop_sequence\"`\n}\n\ntype anthropicContentBlockType struct {\n\tType     string          `json:\"type\"`\n\tText     string          `json:\"text,omitempty\"`\n\tThinking string          `json:\"thinking,omitempty\"`\n\tID       string          `json:\"id,omitempty\"`\n\tName     string          `json:\"name,omitempty\"`\n\tInput    json.RawMessage `json:\"input,omitempty\"`\n}\n\ntype anthropicDeltaType struct {\n\tType        string  `json:\"type\"`\n\tText        string  `json:\"text,omitempty\"`     // text_delta.text\n\tThinking    string  `json:\"thinking,omitempty\"` // thinking_delta.thinking\n\tPartialJSON string  `json:\"partial_json,omitempty\"`\n\tSignature   string  `json:\"signature,omitempty\"`\n\tStopReason  *string `json:\"stop_reason,omitempty\"`   // message_delta.delta.stop_reason\n\tStopSeq     *string `json:\"stop_sequence,omitempty\"` // message_delta.delta.stop_sequence\n}\n\ntype anthropicCacheCreationType struct {\n\tEphemeral1hInputTokens int `json:\"ephemeral_1h_input_tokens,omitempty\"` // default: 0\n\tEphemeral5mInputTokens int `json:\"ephemeral_5m_input_tokens,omitempty\"` // default: 0\n}\n\ntype anthropicServerToolUseType struct {\n\tWebFetchRequests  int `json:\"web_fetch_requests,omitempty\"`  // default: 0\n\tWebSearchRequests int `json:\"web_search_requests,omitempty\"` // default: 0\n}\n\ntype anthropicUsageType struct {\n\tInputTokens              int `json:\"input_tokens,omitempty\"`  // cumulative\n\tOutputTokens             int `json:\"output_tokens,omitempty\"` // cumulative\n\tCacheCreationInputTokens int `json:\"cache_creation_input_tokens,omitempty\"`\n\tCacheReadInputTokens     int `json:\"cache_read_input_tokens,omitempty\"`\n\n\t// internal fields for Wave use (not sent to API)\n\tModel                string `json:\"model,omitempty\"`\n\tNativeWebSearchCount int    `json:\"nativewebsearchcount,omitempty\"`\n\n\t// for reference, but we dont keep thsese up to date or track them\n\tCacheCreation *anthropicCacheCreationType `json:\"cache_creation,omitempty\"`  // breakdown of cached tokens by TTL\n\tServerToolUse *anthropicServerToolUseType `json:\"server_tool_use,omitempty\"` // server tool requests\n\tServiceTier   *string                     `json:\"service_tier,omitempty\"`    // standard, priority, or batch\n}\n\ntype anthropicErrorType struct {\n\tType    string `json:\"type\"`\n\tMessage string `json:\"message\"`\n}\n\ntype anthropicHTTPErrorResponse struct {\n\tType  string             `json:\"type\"`\n\tError anthropicErrorType `json:\"error\"`\n}\n\ntype anthropicFullStreamEvent struct {\n\tType         string                     `json:\"type\"`\n\tMessage      *anthropicMessageObj       `json:\"message,omitempty\"`\n\tIndex        *int                       `json:\"index,omitempty\"`\n\tContentBlock *anthropicContentBlockType `json:\"content_block,omitempty\"`\n\tDelta        *anthropicDeltaType        `json:\"delta,omitempty\"`\n\tUsage        *anthropicUsageType        `json:\"usage,omitempty\"`\n\tError        *anthropicErrorType        `json:\"error,omitempty\"`\n}\n\ntype anthropicThinkingOpts struct {\n\tType         string `json:\"type\"`\n\tBudgetTokens int    `json:\"budget_tokens\"`\n}\n\n// ---------- per-index content block bookkeeping ----------\ntype blockKind int\n\nconst (\n\tblockText blockKind = iota\n\tblockThinking\n\tblockToolUse\n)\n\ntype blockState struct {\n\tkind blockKind\n\t// For text/reasoning: local SSE id\n\tlocalID string\n\t// Content block being built for rtnMessage\n\tcontentBlock *anthropicMessageContentBlock\n\t// For tool_use:\n\ttoolCallID string // Anthropic tool_use.id\n\ttoolName   string\n\taccumJSON  *partialJSON // accumulator for input_json_delta\n}\n\n// partialJSON is a minimal, allocation-friendly accumulator for Anthropic\n// input_json_delta (concat, then parse once on content_block_stop). :contentReference[oaicite:8]{index=8}\ntype partialJSON struct {\n\tbuf bytes.Buffer\n}\n\ntype streamingState struct {\n\tblockMap        map[int]*blockState\n\ttoolCalls       []uctypes.WaveToolCall\n\tstopFromDelta   string\n\tmsgID           string\n\tmodel           string\n\tstepStarted     bool\n\trtnMessage      *anthropicChatMessage\n\tusage           *anthropicUsageType\n\tchatOpts        uctypes.WaveChatOpts\n\twebSearchCount  int\n}\n\nfunc (p *partialJSON) Write(s string) {\n\t// The stream may send empty \"\" chunks; ignore if zero-length\n\tif s == \"\" {\n\t\treturn\n\t}\n\tp.buf.WriteString(s)\n}\n\nfunc (p *partialJSON) Bytes() []byte { return p.buf.Bytes() }\n\nfunc (p *partialJSON) FinalObject() (json.RawMessage, error) {\n\traw := p.buf.Bytes()\n\t// If empty, treat as \"{}\"\n\tif len(bytes.TrimSpace(raw)) == 0 {\n\t\treturn json.RawMessage(`{}`), nil\n\t}\n\t// The accumulated content should be a valid JSON object string; parse it.\n\tvar v interface{}\n\tif err := json.Unmarshal(raw, &v); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid accumulated tool input JSON: %w\", err)\n\t}\n\t// Ensure it's an object per Anthropic contract\n\tswitch v.(type) {\n\tcase map[string]interface{}:\n\t\treturn json.RawMessage(raw), nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"tool input is not an object\")\n\t}\n}\n\n// sanitizeHostnameInError removes the Wave cloud hostname from error messages\nfunc sanitizeHostnameInError(err error) error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\terrStr := err.Error()\n\tparsedURL, parseErr := url.Parse(uctypes.DefaultAIEndpoint)\n\tif parseErr == nil && parsedURL.Host != \"\" && strings.Contains(errStr, parsedURL.Host) {\n\t\terrStr = strings.ReplaceAll(errStr, uctypes.DefaultAIEndpoint, \"AI service\")\n\t\terrStr = strings.ReplaceAll(errStr, parsedURL.Host, \"host\")\n\t}\n\treturn fmt.Errorf(\"%s\", errStr)\n}\n\n// makeThinkingOpts creates thinking options based on level and max tokens\nfunc makeThinkingOpts(thinkingLevel string, maxTokens int) *anthropicThinkingOpts {\n\tif thinkingLevel != uctypes.ThinkingLevelMedium && thinkingLevel != uctypes.ThinkingLevelHigh {\n\t\treturn nil\n\t}\n\n\tmaxThinkingBudget := int(float64(maxTokens) * 0.75)\n\n\t// If 75% of maxTokens is less than minimum, disable thinking\n\tif maxThinkingBudget < AnthropicMinThinkingBudget {\n\t\treturn nil\n\t}\n\n\t// Use the smaller of our default budget or 75% of maxTokens\n\tthinkingBudget := AnthropicThinkingBudget\n\tif thinkingBudget > maxThinkingBudget {\n\t\tthinkingBudget = maxThinkingBudget\n\t}\n\n\treturn &anthropicThinkingOpts{\n\t\tType:         \"enabled\",\n\t\tBudgetTokens: thinkingBudget,\n\t}\n}\n\n// ---------- Public entrypoint ----------\n//\n// Mapping rules recap (Anthropic → AI‑SDK):\n// - message_start → AiMsgStart + AiMsgStartStep\n// - content_block_start(type=text) → AiMsgTextStart; text_delta → AiMsgTextDelta; content_block_stop → AiMsgTextEnd\n// - content_block_start(type=thinking) → AiMsgReasoningStart; thinking_delta → AiMsgReasoningDelta; stop → AiMsgReasoningEnd\n// - content_block_start(type=tool_use) → AiMsgToolInputStart; input_json_delta → AiMsgToolInputDelta; stop → AiMsgToolInputAvailable\n// - If final stop_reason == \"tool_use\": emit AiMsgFinishStep and return StopReason{Kind:ToolUse, ...} WITHOUT AiMsgFinish\n// - If message_stop with stop_reason == \"end_turn\" or nil: emit AiMsgFinish then [DONE]\n// - On Anthropic error event: AiMsgError and return StopKindError. :contentReference[oaicite:9]{index=9} :contentReference[oaicite:10]{index=10}\n\n// parseAnthropicHTTPError parses Anthropic API HTTP error responses\nfunc parseAnthropicHTTPError(resp *http.Response) error {\n\tslurp, _ := io.ReadAll(resp.Body)\n\n\t// Try to parse as Anthropic error format first\n\tvar eresp anthropicHTTPErrorResponse\n\tif err := json.Unmarshal(slurp, &eresp); err == nil && eresp.Error.Message != \"\" {\n\t\treturn sanitizeHostnameInError(fmt.Errorf(\"anthropic %s: %s\", resp.Status, eresp.Error.Message))\n\t}\n\n\t// Try to parse as proxy error format\n\tvar proxyErr uctypes.ProxyErrorResponse\n\tif err := json.Unmarshal(slurp, &proxyErr); err == nil && !proxyErr.Success && proxyErr.Error != \"\" {\n\t\treturn sanitizeHostnameInError(fmt.Errorf(\"anthropic %s: %s\", resp.Status, proxyErr.Error))\n\t}\n\n\t// Fall back to truncated raw response\n\tmsg := utilfn.TruncateString(strings.TrimSpace(string(slurp)), 120)\n\tif msg == \"\" {\n\t\tmsg = \"unknown error\"\n\t}\n\treturn sanitizeHostnameInError(fmt.Errorf(\"anthropic %s: %s\", resp.Status, msg))\n}\n\nfunc RunAnthropicChatStep(\n\tctx context.Context,\n\tsse *sse.SSEHandlerCh,\n\tchatOpts uctypes.WaveChatOpts,\n\tcont *uctypes.WaveContinueResponse,\n) (*uctypes.WaveStopReason, *anthropicChatMessage, *uctypes.RateLimitInfo, error) {\n\tif sse == nil {\n\t\treturn nil, nil, nil, errors.New(\"sse handler is nil\")\n\t}\n\n\t// Get chat from store\n\tchat := chatstore.DefaultChatStore.Get(chatOpts.ChatId)\n\tif chat == nil {\n\t\treturn nil, nil, nil, fmt.Errorf(\"chat not found: %s\", chatOpts.ChatId)\n\t}\n\n\t// Validate that chatOpts.Config match the chat's stored configuration\n\tif chat.APIType != chatOpts.Config.APIType {\n\t\treturn nil, nil, nil, fmt.Errorf(\"API type mismatch: chat has %s, chatOpts has %s\", chat.APIType, chatOpts.Config.APIType)\n\t}\n\tif !uctypes.AreModelsCompatible(chat.APIType, chat.Model, chatOpts.Config.Model) {\n\t\treturn nil, nil, nil, fmt.Errorf(\"model mismatch: chat has %s, chatOpts has %s\", chat.Model, chatOpts.Config.Model)\n\t}\n\tif chat.APIVersion != chatOpts.Config.APIVersion {\n\t\treturn nil, nil, nil, fmt.Errorf(\"API version mismatch: chat has %s, chatOpts has %s\", chat.APIVersion, chatOpts.Config.APIVersion)\n\t}\n\n\t// Context with timeout if provided.\n\tif chatOpts.Config.TimeoutMs > 0 {\n\t\tvar cancel context.CancelFunc\n\t\tctx, cancel = context.WithTimeout(ctx, time.Duration(chatOpts.Config.TimeoutMs)*time.Millisecond)\n\t\tdefer cancel()\n\t}\n\n\t// Validate continuation if provided\n\tif cont != nil {\n\t\tif !uctypes.AreModelsCompatible(chat.APIType, chatOpts.Config.Model, cont.Model) {\n\t\t\treturn nil, nil, nil, fmt.Errorf(\"cannot continue with a different model, model:%q, cont-model:%q\", chatOpts.Config.Model, cont.Model)\n\t\t}\n\t}\n\n\t// Convert GenAIMessages to anthropicInputMessages\n\tvar anthropicMsgs []anthropicInputMessage\n\tfor _, genMsg := range chat.NativeMessages {\n\t\t// Cast to anthropicChatMessage\n\t\tchatMsg, ok := genMsg.(*anthropicChatMessage)\n\t\tif !ok {\n\t\t\treturn nil, nil, nil, fmt.Errorf(\"expected anthropicChatMessage, got %T\", genMsg)\n\t\t}\n\t\t// Convert to anthropicInputMessage with copied content\n\t\tcontentCopy := make([]anthropicMessageContentBlock, len(chatMsg.Content))\n\t\tcopy(contentCopy, chatMsg.Content)\n\t\tinputMsg := anthropicInputMessage{\n\t\t\tRole:    chatMsg.Role,\n\t\t\tContent: contentCopy,\n\t\t}\n\t\tanthropicMsgs = append(anthropicMsgs, inputMsg)\n\t}\n\n\treq, err := buildAnthropicHTTPRequest(ctx, anthropicMsgs, chatOpts)\n\tif err != nil {\n\t\treturn nil, nil, nil, err\n\t}\n\n\thttpClient, err := aiutil.MakeHTTPClient(chatOpts.Config.ProxyURL)\n\tif err != nil {\n\t\treturn nil, nil, nil, err\n\t}\n\n\tresp, err := httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, nil, nil, sanitizeHostnameInError(err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Parse rate limit info from header if present (do this before error check)\n\trateLimitInfo := uctypes.ParseRateLimitHeader(resp.Header.Get(\"X-Wave-RateLimit\"))\n\n\tct := resp.Header.Get(\"Content-Type\")\n\tif resp.StatusCode != http.StatusOK || !strings.HasPrefix(ct, \"text/event-stream\") {\n\t\t// Handle 429 rate limit with special logic\n\t\tif resp.StatusCode == http.StatusTooManyRequests && rateLimitInfo != nil {\n\t\t\tif rateLimitInfo.PReq == 0 && rateLimitInfo.Req > 0 {\n\t\t\t\t// Premium requests exhausted, but regular requests available\n\t\t\t\tstopReason := &uctypes.WaveStopReason{\n\t\t\t\t\tKind: uctypes.StopKindPremiumRateLimit,\n\t\t\t\t}\n\t\t\t\treturn stopReason, nil, rateLimitInfo, nil\n\t\t\t}\n\t\t\tif rateLimitInfo.Req == 0 {\n\t\t\t\t// All requests exhausted\n\t\t\t\tstopReason := &uctypes.WaveStopReason{\n\t\t\t\t\tKind: uctypes.StopKindRateLimit,\n\t\t\t\t}\n\t\t\t\treturn stopReason, nil, rateLimitInfo, nil\n\t\t\t}\n\t\t}\n\t\treturn nil, nil, rateLimitInfo, parseAnthropicHTTPError(resp)\n\t}\n\n\t// At this point we have a valid SSE stream, so setup SSE handling\n\t// From here on, errors must be returned through the SSE stream\n\tif cont == nil {\n\t\tsse.SetupSSE()\n\t}\n\n\t// Use eventsource decoder for proper SSE parsing\n\tdecoder := eventsource.NewDecoder(resp.Body)\n\n\tstopReason, rtnMessage := handleAnthropicStreamingResp(ctx, sse, decoder, cont, chatOpts)\n\treturn stopReason, rtnMessage, rateLimitInfo, nil\n}\n\n// handleAnthropicStreamingResp processes the SSE stream after HTTP setup is complete\nfunc handleAnthropicStreamingResp(\n\tctx context.Context,\n\tsse *sse.SSEHandlerCh,\n\tdecoder *eventsource.Decoder,\n\tcont *uctypes.WaveContinueResponse,\n\tchatOpts uctypes.WaveChatOpts,\n) (*uctypes.WaveStopReason, *anthropicChatMessage) {\n\t// Per-response state\n\tstate := &streamingState{\n\t\tblockMap: map[int]*blockState{},\n\t\trtnMessage: &anthropicChatMessage{\n\t\t\tMessageId: uuid.New().String(),\n\t\t\tRole:      \"assistant\",\n\t\t\tContent:   []anthropicMessageContentBlock{},\n\t\t},\n\t\tchatOpts: chatOpts,\n\t}\n\n\tvar rtnStopReason *uctypes.WaveStopReason\n\n\t// Ensure step is closed on error/cancellation\n\tdefer func() {\n\t\t// Set usage in the returned message\n\t\tif state.usage != nil {\n\t\t\tstate.usage.Model = state.model\n\t\t\tif state.webSearchCount > 0 {\n\t\t\t\tstate.usage.NativeWebSearchCount = state.webSearchCount\n\t\t\t}\n\t\t\tstate.rtnMessage.Usage = state.usage\n\t\t}\n\n\t\tif !state.stepStarted {\n\t\t\treturn\n\t\t}\n\t\t_ = sse.AiMsgFinishStep()\n\t\tif rtnStopReason == nil || rtnStopReason.Kind != uctypes.StopKindToolUse {\n\t\t\t_ = sse.AiMsgFinish(\"\", nil)\n\t\t}\n\t}()\n\n\t// SSE event processing loop\n\tfor {\n\t\t// Check for context cancellation\n\t\tif err := ctx.Err(); err != nil {\n\t\t\t_ = sse.AiMsgError(\"request cancelled\")\n\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindCanceled,\n\t\t\t\tErrorType: \"cancelled\",\n\t\t\t\tErrorText: \"request cancelled\",\n\t\t\t}, state.rtnMessage\n\t\t}\n\n\t\tevent, err := decoder.Decode()\n\t\tif err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\t// Normal end of stream\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sse.Err() != nil {\n\t\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\t\tKind:      uctypes.StopKindCanceled,\n\t\t\t\t\tErrorType: \"client_disconnect\",\n\t\t\t\t\tErrorText: \"client disconnected\",\n\t\t\t\t}, extractPartialTextFromState(state)\n\t\t\t}\n\t\t\t// transport error mid-stream\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindError,\n\t\t\t\tErrorType: \"stream\",\n\t\t\t\tErrorText: err.Error(),\n\t\t\t}, state.rtnMessage\n\t\t}\n\n\t\tif stop, ret := handleAnthropicEvent(event, sse, state, cont); ret != nil {\n\t\t\t// Either error or message_stop triggered return\n\t\t\trtnStopReason = ret\n\t\t\treturn ret, state.rtnMessage\n\t\t} else {\n\t\t\t// maybe updated final stop reason (from message_delta)\n\t\t\tif stop != nil && *stop != \"\" {\n\t\t\t\tstate.stopFromDelta = *stop\n\t\t\t}\n\t\t}\n\t}\n\n\t// EOF - let defer handle cleanup\n\trtnStopReason = &uctypes.WaveStopReason{\n\t\tKind:      uctypes.StopKindDone,\n\t\tRawReason: state.stopFromDelta,\n\t}\n\treturn rtnStopReason, state.rtnMessage\n}\n\nfunc extractPartialTextFromState(state *streamingState) *anthropicChatMessage {\n\tvar content []anthropicMessageContentBlock\n\tfor _, block := range state.rtnMessage.Content {\n\t\tif block.Type == \"text\" && block.Text != \"\" {\n\t\t\tcontent = append(content, block)\n\t\t}\n\t}\n\tvar partialIdx []int\n\tfor idx, st := range state.blockMap {\n\t\tif st.kind == blockText && st.contentBlock != nil && st.contentBlock.Text != \"\" {\n\t\t\tpartialIdx = append(partialIdx, idx)\n\t\t}\n\t}\n\tsort.Ints(partialIdx)\n\tfor _, idx := range partialIdx {\n\t\tst := state.blockMap[idx]\n\t\tif st.kind == blockText && st.contentBlock != nil && st.contentBlock.Text != \"\" {\n\t\t\tcontent = append(content, *st.contentBlock)\n\t\t}\n\t}\n\tif len(content) == 0 {\n\t\treturn nil\n\t}\n\treturn &anthropicChatMessage{\n\t\tMessageId: state.rtnMessage.MessageId,\n\t\tRole:      \"assistant\",\n\t\tContent:   content,\n\t\tUsage:     state.rtnMessage.Usage,\n\t}\n}\n\n// handleAnthropicEvent processes one SSE event block. It may emit SSE parts\n// and/or return a StopReason when the stream is complete.\n//\n// Return tuple:\n//   - stopFromDelta: a *string with stop reason when message_delta updates stop_reason\n//   - final: a *StopReason to return immediately (e.g., after message_stop or error)\n//\n// Event model: anthropic-streaming.md. :contentReference[oaicite:16]{index=16}\nfunc handleAnthropicEvent(\n\tevent eventsource.Event,\n\tsse *sse.SSEHandlerCh,\n\tstate *streamingState,\n\tcont *uctypes.WaveContinueResponse,\n) (stopFromDelta *string, final *uctypes.WaveStopReason) {\n\tif err := sse.Err(); err != nil {\n\t\treturn nil, &uctypes.WaveStopReason{\n\t\t\tKind:      uctypes.StopKindCanceled,\n\t\t\tErrorType: \"client_disconnect\",\n\t\t\tErrorText: \"client disconnected\",\n\t\t}\n\t}\n\teventName := event.Event()\n\tdata := event.Data()\n\tswitch eventName {\n\tcase \"ping\":\n\t\treturn nil, nil // ignore\n\n\tcase \"error\":\n\t\t// Example: data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}} :contentReference[oaicite:17]{index=17}\n\t\tvar ev anthropicFullStreamEvent\n\t\tif jerr := json.Unmarshal([]byte(data), &ev); jerr != nil {\n\t\t\terr := fmt.Errorf(\"error event decode: %w\", jerr)\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}\n\t\t}\n\t\tmsg := \"unknown error\"\n\t\tetype := \"error\"\n\t\tif ev.Error != nil {\n\t\t\tmsg = ev.Error.Message\n\t\t\tetype = ev.Error.Type\n\t\t}\n\t\t_ = sse.AiMsgError(msg)\n\t\treturn nil, &uctypes.WaveStopReason{\n\t\t\tKind:      uctypes.StopKindError,\n\t\t\tErrorType: etype,\n\t\t\tErrorText: msg,\n\t\t}\n\n\tcase \"message_start\":\n\t\tvar ev anthropicFullStreamEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}\n\t\t}\n\t\tif ev.Message != nil {\n\t\t\tstate.msgID = ev.Message.ID\n\t\t\tstate.model = ev.Message.Model\n\t\t}\n\t\t// Initialize usage from message_start event\n\t\tif ev.Usage != nil {\n\t\t\tstate.usage = ev.Usage\n\t\t}\n\t\tif cont == nil {\n\t\t\t_ = sse.AiMsgStart(state.msgID)\n\t\t}\n\t\t_ = sse.AiMsgStartStep()\n\t\tstate.stepStarted = true\n\t\treturn nil, nil\n\n\tcase \"content_block_start\":\n\t\tvar ev anthropicFullStreamEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}\n\t\t}\n\t\tif ev.Index == nil || ev.ContentBlock == nil {\n\t\t\treturn nil, nil\n\t\t}\n\t\tidx := *ev.Index\n\t\tswitch ev.ContentBlock.Type {\n\t\tcase \"text\":\n\t\t\tid := uuid.New().String()\n\t\t\tstate.blockMap[idx] = &blockState{\n\t\t\t\tkind:    blockText,\n\t\t\t\tlocalID: id,\n\t\t\t\tcontentBlock: &anthropicMessageContentBlock{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: \"\",\n\t\t\t\t},\n\t\t\t}\n\t\t\t_ = sse.AiMsgTextStart(id)\n\t\tcase \"thinking\":\n\t\t\tid := uuid.New().String()\n\t\t\tstate.blockMap[idx] = &blockState{\n\t\t\t\tkind:    blockThinking,\n\t\t\t\tlocalID: id,\n\t\t\t\tcontentBlock: &anthropicMessageContentBlock{\n\t\t\t\t\tType:     \"thinking\",\n\t\t\t\t\tThinking: \"\",\n\t\t\t\t},\n\t\t\t}\n\t\t\t_ = sse.AiMsgReasoningStart(id)\n\t\tcase \"tool_use\":\n\t\t\ttcID := ev.ContentBlock.ID\n\t\t\ttName := ev.ContentBlock.Name\n\t\t\tst := &blockState{\n\t\t\t\tkind:       blockToolUse,\n\t\t\t\ttoolCallID: tcID,\n\t\t\t\ttoolName:   tName,\n\t\t\t\taccumJSON:  &partialJSON{},\n\t\t\t}\n\t\t\tstate.blockMap[idx] = st\n\t\t\t_ = sse.AiMsgToolInputStart(tcID, tName)\n\t\tcase \"server_tool_use\":\n\t\t\tif ev.ContentBlock.Name == \"web_search\" {\n\t\t\t\tstate.webSearchCount++\n\t\t\t}\n\t\tdefault:\n\t\t\t// ignore other block types gracefully per Anthropic guidance :contentReference[oaicite:18]{index=18}\n\t\t}\n\t\treturn nil, nil\n\n\tcase \"content_block_delta\":\n\t\tvar ev anthropicFullStreamEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}\n\t\t}\n\t\tif ev.Index == nil || ev.Delta == nil {\n\t\t\treturn nil, nil\n\t\t}\n\t\tst := state.blockMap[*ev.Index]\n\t\tif st == nil {\n\t\t\treturn nil, nil\n\t\t}\n\t\tswitch ev.Delta.Type {\n\t\tcase \"text_delta\":\n\t\t\tif st.kind == blockText {\n\t\t\t\t_ = sse.AiMsgTextDelta(st.localID, ev.Delta.Text)\n\t\t\t\t// Accumulate text in the content block\n\t\t\t\tif st.contentBlock != nil {\n\t\t\t\t\tst.contentBlock.Text += ev.Delta.Text\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"thinking_delta\":\n\t\t\tif st.kind == blockThinking {\n\t\t\t\t_ = sse.AiMsgReasoningDelta(st.localID, ev.Delta.Thinking)\n\t\t\t\t// Accumulate thinking content in the content block\n\t\t\t\tif st.contentBlock != nil {\n\t\t\t\t\tst.contentBlock.Thinking += ev.Delta.Thinking\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"input_json_delta\":\n\t\t\tif st.kind == blockToolUse {\n\t\t\t\tst.accumJSON.Write(ev.Delta.PartialJSON)\n\t\t\t\t_ = sse.AiMsgToolInputDelta(st.toolCallID, ev.Delta.PartialJSON)\n\t\t\t\taiutil.SendToolProgress(st.toolCallID, st.toolName, st.accumJSON.Bytes(), state.chatOpts, sse, true)\n\t\t\t}\n\t\tcase \"signature_delta\":\n\t\t\t// Accumulate signature for thinking blocks\n\t\t\tif st.kind == blockThinking && st.contentBlock != nil {\n\t\t\t\tst.contentBlock.Signature += ev.Delta.Signature\n\t\t\t}\n\t\tdefault:\n\t\t\t// ignore unknown deltas gracefully. :contentReference[oaicite:20]{index=20}\n\t\t}\n\t\treturn nil, nil\n\n\tcase \"content_block_stop\":\n\t\tvar ev anthropicFullStreamEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}\n\t\t}\n\t\tif ev.Index == nil {\n\t\t\treturn nil, nil\n\t\t}\n\t\tst := state.blockMap[*ev.Index]\n\t\tif st == nil {\n\t\t\treturn nil, nil\n\t\t}\n\t\tswitch st.kind {\n\t\tcase blockText:\n\t\t\t_ = sse.AiMsgTextEnd(st.localID)\n\t\t\t// Add completed text block to rtnMessage\n\t\t\tif st.contentBlock != nil {\n\t\t\t\tstate.rtnMessage.Content = append(state.rtnMessage.Content, *st.contentBlock)\n\t\t\t}\n\t\tcase blockThinking:\n\t\t\t_ = sse.AiMsgReasoningEnd(st.localID)\n\t\t\t// Add completed thinking block to rtnMessage\n\t\t\tif st.contentBlock != nil {\n\t\t\t\tstate.rtnMessage.Content = append(state.rtnMessage.Content, *st.contentBlock)\n\t\t\t}\n\t\tcase blockToolUse:\n\t\t\traw, jerr := st.accumJSON.FinalObject()\n\t\t\tif jerr != nil {\n\t\t\t\t_ = sse.AiMsgError(jerr.Error())\n\t\t\t\treturn nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"parse\", ErrorText: jerr.Error()}\n\t\t\t}\n\t\t\tvar input any\n\t\t\tif len(raw) > 0 {\n\t\t\t\tjerr = json.Unmarshal(raw, &input)\n\t\t\t\tif jerr != nil {\n\t\t\t\t\t_ = sse.AiMsgError(jerr.Error())\n\t\t\t\t\treturn nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"parse\", ErrorText: jerr.Error()}\n\t\t\t\t}\n\t\t\t}\n\t\t\t_ = sse.AiMsgToolInputAvailable(st.toolCallID, st.toolName, raw)\n\t\t\taiutil.SendToolProgress(st.toolCallID, st.toolName, raw, state.chatOpts, sse, false)\n\t\t\tstate.toolCalls = append(state.toolCalls, uctypes.WaveToolCall{\n\t\t\t\tID:    st.toolCallID,\n\t\t\t\tName:  st.toolName,\n\t\t\t\tInput: input,\n\t\t\t})\n\t\t\t// Add completed tool_use block to rtnMessage\n\t\t\ttoolUseBlock := anthropicMessageContentBlock{\n\t\t\t\tType:  \"tool_use\",\n\t\t\t\tID:    st.toolCallID,\n\t\t\t\tName:  st.toolName,\n\t\t\t\tInput: input,\n\t\t\t}\n\t\t\tstate.rtnMessage.Content = append(state.rtnMessage.Content, toolUseBlock)\n\t\t}\n\t\t// extractPartialTextFromState reads blockMap for still-in-flight content, so remove completed blocks\n\t\t// once they have been appended to rtnMessage.Content to avoid duplicate text on disconnect.\n\t\tdelete(state.blockMap, *ev.Index)\n\t\treturn nil, nil\n\n\tcase \"message_delta\":\n\t\tvar ev anthropicFullStreamEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}\n\t\t}\n\t\tif ev.Delta != nil && ev.Delta.StopReason != nil {\n\t\t\tstopFromDelta = ev.Delta.StopReason\n\t\t}\n\t\t// Update cumulative usage from message_delta event\n\t\tif ev.Usage != nil {\n\t\t\tif state.usage == nil {\n\t\t\t\tstate.usage = &anthropicUsageType{}\n\t\t\t}\n\t\t\t// Update the fields we track (cumulative values)\n\t\t\tif ev.Usage.InputTokens > 0 {\n\t\t\t\tstate.usage.InputTokens = ev.Usage.InputTokens\n\t\t\t}\n\t\t\tif ev.Usage.OutputTokens > 0 {\n\t\t\t\tstate.usage.OutputTokens = ev.Usage.OutputTokens\n\t\t\t}\n\t\t\tif ev.Usage.CacheCreationInputTokens > 0 {\n\t\t\t\tstate.usage.CacheCreationInputTokens = ev.Usage.CacheCreationInputTokens\n\t\t\t}\n\t\t\tif ev.Usage.CacheReadInputTokens > 0 {\n\t\t\t\tstate.usage.CacheReadInputTokens = ev.Usage.CacheReadInputTokens\n\t\t\t}\n\t\t}\n\t\treturn stopFromDelta, nil\n\n\tcase \"message_stop\":\n\t\t// Decide finalization based on last known stop_reason.\n\t\t// If we didn't capture it in message_delta, treat as end_turn.\n\t\treason := \"end_turn\"\n\t\tif state.stopFromDelta != \"\" {\n\t\t\treason = state.stopFromDelta\n\t\t}\n\t\tswitch reason {\n\t\tcase \"tool_use\":\n\t\t\treturn nil, &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindToolUse,\n\t\t\t\tRawReason: reason,\n\t\t\t\tToolCalls: state.toolCalls,\n\t\t\t}\n\t\tcase \"max_tokens\":\n\t\t\treturn nil, &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindMaxTokens,\n\t\t\t\tRawReason: reason,\n\t\t\t}\n\t\tcase \"refusal\":\n\t\t\treturn nil, &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindContent,\n\t\t\t\tRawReason: reason,\n\t\t\t}\n\t\tcase \"pause_turn\":\n\t\t\treturn nil, &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindPauseTurn,\n\t\t\t\tRawReason: reason,\n\t\t\t}\n\t\tdefault:\n\t\t\t// end_turn, stop_sequence (treat as end of this call)\n\t\t\treturn nil, &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindDone,\n\t\t\t\tRawReason: reason,\n\t\t\t}\n\t\t}\n\n\tdefault:\n\t\tlogutil.DevPrintf(\"unknown anthropic event type: %s\", eventName)\n\t\treturn nil, nil\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/anthropic/anthropic-backend_test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage anthropic\n\nimport (\n\t\"testing\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n)\n\nfunc TestConvertPartsToAnthropicBlocks_TextOnly(t *testing.T) {\n\tparts := []uctypes.UIMessagePart{\n\t\t{Type: \"text\", Text: \"Hello world\"},\n\t\t{Type: \"text\", Text: \"Default text\"},\n\t}\n\n\tblocks, err := convertPartsToAnthropicBlocks(parts, \"user\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif len(blocks) != 2 {\n\t\tt.Fatalf(\"expected 2 blocks, got %d\", len(blocks))\n\t}\n\n\t// Check first block\n\tblock1 := blocks[0]\n\tif block1.Type != \"text\" {\n\t\tt.Errorf(\"expected type 'text', got %v\", block1.Type)\n\t}\n\tif block1.Text != \"Hello world\" {\n\t\tt.Errorf(\"expected text 'Hello world', got %v\", block1.Text)\n\t}\n\n\t// Check second block (empty type defaults to text)\n\tblock2 := blocks[1]\n\tif block2.Type != \"text\" {\n\t\tt.Errorf(\"expected type 'text', got %v\", block2.Type)\n\t}\n\tif block2.Text != \"Default text\" {\n\t\tt.Errorf(\"expected text 'Default text', got %v\", block2.Text)\n\t}\n}\n\nfunc TestConvertPartsToAnthropicBlocks_SkipsUnknownTypes(t *testing.T) {\n\tparts := []uctypes.UIMessagePart{\n\t\t{Type: \"text\", Text: \"Valid text\"},\n\t\t{Type: \"unknown_type\", Text: \"Should be skipped\"},\n\t\t{Type: \"text\", Text: \"Another valid text\"},\n\t}\n\n\tblocks, err := convertPartsToAnthropicBlocks(parts, \"user\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif len(blocks) != 2 {\n\t\tt.Fatalf(\"expected 2 blocks (unknown type skipped), got %d\", len(blocks))\n\t}\n\n\tblock1 := blocks[0]\n\tif block1.Text != \"Valid text\" {\n\t\tt.Errorf(\"expected first text 'Valid text', got %v\", block1.Text)\n\t}\n\n\tblock2 := blocks[1]\n\tif block2.Text != \"Another valid text\" {\n\t\tt.Errorf(\"expected second text 'Another valid text', got %v\", block2.Text)\n\t}\n}\n\nfunc TestGetFunctionCallInputByToolCallId(t *testing.T) {\n\ttoolData := &uctypes.UIMessageDataToolUse{ToolCallId: \"call-1\", ToolName: \"read_file\", Status: uctypes.ToolUseStatusPending}\n\tchat := uctypes.AIChat{\n\t\tNativeMessages: []uctypes.GenAIMessage{\n\t\t\t&anthropicChatMessage{\n\t\t\t\tMessageId: \"m1\",\n\t\t\t\tRole:      \"assistant\",\n\t\t\t\tContent: []anthropicMessageContentBlock{\n\t\t\t\t\t{Type: \"tool_use\", ID: \"call-1\", Name: \"read_file\", Input: map[string]interface{}{\"path\": \"/tmp/a\"}, ToolUseData: toolData},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfnCall := GetFunctionCallInputByToolCallId(chat, \"call-1\")\n\tif fnCall == nil {\n\t\tt.Fatalf(\"expected function call input\")\n\t}\n\tif fnCall.CallId != \"call-1\" || fnCall.Name != \"read_file\" {\n\t\tt.Fatalf(\"unexpected function call input: %#v\", fnCall)\n\t}\n\tif fnCall.Arguments != \"{\\\"path\\\":\\\"/tmp/a\\\"}\" {\n\t\tt.Fatalf(\"unexpected arguments: %s\", fnCall.Arguments)\n\t}\n\tif fnCall.ToolUseData == nil || fnCall.ToolUseData.ToolCallId != \"call-1\" {\n\t\tt.Fatalf(\"expected tool use data\")\n\t}\n}\n\nfunc TestUpdateAndRemoveToolUseCall(t *testing.T) {\n\tchatID := \"anthropic-test-tooluse\"\n\tchatstore.DefaultChatStore.Delete(chatID)\n\tdefer chatstore.DefaultChatStore.Delete(chatID)\n\n\taiOpts := &uctypes.AIOptsType{\n\t\tAPIType:    uctypes.APIType_AnthropicMessages,\n\t\tModel:      \"claude-sonnet-4-5\",\n\t\tAPIVersion: AnthropicDefaultAPIVersion,\n\t}\n\tmsg := &anthropicChatMessage{\n\t\tMessageId: \"m1\",\n\t\tRole:      \"assistant\",\n\t\tContent: []anthropicMessageContentBlock{\n\t\t\t{Type: \"text\", Text: \"start\"},\n\t\t\t{Type: \"tool_use\", ID: \"call-1\", Name: \"read_file\", Input: map[string]interface{}{\"path\": \"/tmp/a\"}},\n\t\t},\n\t}\n\tif err := chatstore.DefaultChatStore.PostMessage(chatID, aiOpts, msg); err != nil {\n\t\tt.Fatalf(\"failed to seed chat: %v\", err)\n\t}\n\n\tnewData := uctypes.UIMessageDataToolUse{ToolCallId: \"call-1\", ToolName: \"read_file\", Status: uctypes.ToolUseStatusCompleted}\n\tif err := UpdateToolUseData(chatID, \"call-1\", newData); err != nil {\n\t\tt.Fatalf(\"update failed: %v\", err)\n\t}\n\n\tchat := chatstore.DefaultChatStore.Get(chatID)\n\tupdated := chat.NativeMessages[0].(*anthropicChatMessage)\n\tif updated.Content[1].ToolUseData == nil || updated.Content[1].ToolUseData.Status != uctypes.ToolUseStatusCompleted {\n\t\tt.Fatalf(\"tool use data not updated\")\n\t}\n\n\tif err := RemoveToolUseCall(chatID, \"call-1\"); err != nil {\n\t\tt.Fatalf(\"remove failed: %v\", err)\n\t}\n\tchat = chatstore.DefaultChatStore.Get(chatID)\n\tupdated = chat.NativeMessages[0].(*anthropicChatMessage)\n\tif len(updated.Content) != 1 || updated.Content[0].Type != \"text\" {\n\t\tt.Fatalf(\"expected tool_use block removed, got %#v\", updated.Content)\n\t}\n}\n\nfunc TestConvertToUIMessageIncludesToolUseData(t *testing.T) {\n\tmsg := &anthropicChatMessage{\n\t\tMessageId: \"m1\",\n\t\tRole:      \"assistant\",\n\t\tContent: []anthropicMessageContentBlock{\n\t\t\t{\n\t\t\t\tType:        \"tool_use\",\n\t\t\t\tID:          \"call-1\",\n\t\t\t\tName:        \"read_file\",\n\t\t\t\tInput:       map[string]interface{}{\"path\": \"/tmp/a\"},\n\t\t\t\tToolUseData: &uctypes.UIMessageDataToolUse{ToolCallId: \"call-1\", ToolName: \"read_file\", Status: uctypes.ToolUseStatusPending},\n\t\t\t},\n\t\t},\n\t}\n\tui := msg.ConvertToUIMessage()\n\tif ui == nil || len(ui.Parts) != 2 {\n\t\tt.Fatalf(\"expected tool and data-tooluse parts, got %#v\", ui)\n\t}\n\tif ui.Parts[0].Type != \"tool-read_file\" || ui.Parts[1].Type != \"data-tooluse\" {\n\t\tt.Fatalf(\"unexpected part types: %#v\", ui.Parts)\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/anthropic/anthropic-convertmessage.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage anthropic\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/logutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\n// these conversions are based off the anthropic spec\n// and the aiprompts/aisdk-uimessage-type.md doc (v5)\n\n// buildAnthropicHTTPRequest creates a complete HTTP request for the Anthropic API\nfunc buildAnthropicHTTPRequest(ctx context.Context, msgs []anthropicInputMessage, chatOpts uctypes.WaveChatOpts) (*http.Request, error) {\n\topts := chatOpts.Config\n\tif opts.Model == \"\" {\n\t\treturn nil, errors.New(\"ai:model is required\")\n\t}\n\tif chatOpts.ClientId == \"\" {\n\t\treturn nil, errors.New(\"chatOpts.ClientId is required\")\n\t}\n\n\t// Set defaults\n\tendpoint := opts.Endpoint\n\tif endpoint == \"\" {\n\t\treturn nil, errors.New(\"ai:endpoint is required\")\n\t}\n\n\tmaxTokens := opts.MaxTokens\n\tif maxTokens <= 0 {\n\t\tmaxTokens = AnthropicDefaultMaxTokens\n\t}\n\n\t// Convert messages to clear FileName fields from Source blocks\n\tconvertedMsgs := make([]anthropicInputMessage, len(msgs))\n\tfor i, msg := range msgs {\n\t\tconvertedMsgs[i] = convertMessageForAPI(msg)\n\t}\n\n\t// inject chatOpts.TabState as a \"text\" block at the END of the LAST \"user\" message found (append to Content)\n\tif chatOpts.TabState != \"\" {\n\t\t// Find the last \"user\" message\n\t\tfor i := len(convertedMsgs) - 1; i >= 0; i-- {\n\t\t\tif convertedMsgs[i].Role == \"user\" {\n\t\t\t\t// Create a text block with the TabState content\n\t\t\t\ttabStateBlock := anthropicMessageContentBlock{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: chatOpts.TabState,\n\t\t\t\t}\n\t\t\t\t// Append to the Content of this message\n\t\t\t\tconvertedMsgs[i].Content = append(convertedMsgs[i].Content, tabStateBlock)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\t// inject chatOpts.PlatformInfo, AppStaticFiles, and AppGoFile as \"text\" blocks at the END of the LAST \"user\" message found (append to Content)\n\tif chatOpts.PlatformInfo != \"\" || chatOpts.AppStaticFiles != \"\" || chatOpts.AppGoFile != \"\" {\n\t\t// Find the last \"user\" message\n\t\tfor i := len(convertedMsgs) - 1; i >= 0; i-- {\n\t\t\tif convertedMsgs[i].Role == \"user\" {\n\t\t\t\tif chatOpts.PlatformInfo != \"\" {\n\t\t\t\t\tplatformInfoBlock := anthropicMessageContentBlock{\n\t\t\t\t\t\tType: \"text\",\n\t\t\t\t\t\tText: \"<PlatformInfo>\\n\" + chatOpts.PlatformInfo + \"\\n</PlatformInfo>\",\n\t\t\t\t\t}\n\t\t\t\t\tconvertedMsgs[i].Content = append(convertedMsgs[i].Content, platformInfoBlock)\n\t\t\t\t}\n\t\t\t\tif chatOpts.AppStaticFiles != \"\" {\n\t\t\t\t\tappStaticFilesBlock := anthropicMessageContentBlock{\n\t\t\t\t\t\tType: \"text\",\n\t\t\t\t\t\tText: \"<CurrentAppStaticFiles>\\n\" + chatOpts.AppStaticFiles + \"\\n</CurrentAppStaticFiles>\",\n\t\t\t\t\t}\n\t\t\t\t\tconvertedMsgs[i].Content = append(convertedMsgs[i].Content, appStaticFilesBlock)\n\t\t\t\t}\n\t\t\t\tif chatOpts.AppGoFile != \"\" {\n\t\t\t\t\tappGoFileBlock := anthropicMessageContentBlock{\n\t\t\t\t\t\tType: \"text\",\n\t\t\t\t\t\tText: \"<CurrentAppGoFile>\\n\" + chatOpts.AppGoFile + \"\\n</CurrentAppGoFile>\",\n\t\t\t\t\t}\n\t\t\t\t\tconvertedMsgs[i].Content = append(convertedMsgs[i].Content, appGoFileBlock)\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\t// Build request body\n\treqBody := &anthropicStreamRequest{\n\t\tModel:     opts.Model,\n\t\tMaxTokens: maxTokens,\n\t\tStream:    true,\n\t\tMessages:  convertedMsgs,\n\t}\n\n\t// Add system prompt if provided\n\tif len(chatOpts.SystemPrompt) > 0 {\n\t\tsystemBlocks := make([]anthropicMessageContentBlock, len(chatOpts.SystemPrompt))\n\t\tfor i, prompt := range chatOpts.SystemPrompt {\n\t\t\tsystemBlocks[i] = anthropicMessageContentBlock{\n\t\t\t\tType: \"text\",\n\t\t\t\tText: prompt,\n\t\t\t}\n\t\t}\n\t\treqBody.System = systemBlocks\n\t}\n\n\tfor _, tool := range chatOpts.Tools {\n\t\tcleanedTool := tool.Clean()\n\t\treqBody.Tools = append(reqBody.Tools, cleanedTool)\n\t}\n\tfor _, tool := range chatOpts.TabTools {\n\t\tcleanedTool := tool.Clean()\n\t\treqBody.Tools = append(reqBody.Tools, cleanedTool)\n\t}\n\tif chatOpts.AllowNativeWebSearch {\n\t\treqBody.Tools = append(reqBody.Tools, &anthropicWebSearchTool{Type: \"web_search_20250305\", Name: \"web_search\"})\n\t}\n\n\t// Enable extended thinking based on level\n\treqBody.Thinking = makeThinkingOpts(opts.ThinkingLevel, maxTokens)\n\n\t// pretty print json of anthropicMsgs\n\tif jsonStr, err := utilfn.MarshalIndentNoHTMLString(convertedMsgs, \"\", \"  \"); err == nil {\n\t\tvar toolNames []string\n\t\tfor _, tool := range chatOpts.Tools {\n\t\t\ttoolNames = append(toolNames, tool.Name)\n\t\t}\n\t\tfor _, tool := range chatOpts.TabTools {\n\t\t\ttoolNames = append(toolNames, tool.Name)\n\t\t}\n\t\tif chatOpts.AllowNativeWebSearch {\n\t\t\ttoolNames = append(toolNames, \"web_search[server]\")\n\t\t}\n\t\tlogutil.DevPrintf(\"tools: %s\\n\", strings.Join(toolNames, \", \"))\n\t\tlogutil.DevPrintf(\"anthropicMsgs JSON:\\n%s\", jsonStr)\n\t\tlogutil.DevPrintf(\"has-api-key: %v\\n\", opts.APIToken != \"\")\n\t}\n\n\tvar buf bytes.Buffer\n\tencoder := json.NewEncoder(&buf)\n\tencoder.SetEscapeHTML(false)\n\terr := encoder.Encode(reqBody)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"content-type\", \"application/json\")\n\tif opts.APIToken != \"\" {\n\t\treq.Header.Set(\"x-api-key\", opts.APIToken)\n\t}\n\treq.Header.Set(\"anthropic-version\", AnthropicDefaultAPIVersion)\n\treq.Header.Set(\"accept\", \"text/event-stream\")\n\t// Only send Wave-specific headers when using Wave provider\n\tif opts.Provider == uctypes.AIProvider_Wave {\n\t\tif chatOpts.ClientId != \"\" {\n\t\t\treq.Header.Set(\"X-Wave-ClientId\", chatOpts.ClientId)\n\t\t}\n\t\tif chatOpts.ChatId != \"\" {\n\t\t\treq.Header.Set(\"X-Wave-ChatId\", chatOpts.ChatId)\n\t\t}\n\t\treq.Header.Set(\"X-Wave-Version\", wavebase.WaveVersion)\n\t\treq.Header.Set(\"X-Wave-APIType\", uctypes.APIType_AnthropicMessages)\n\t\treq.Header.Set(\"X-Wave-RequestType\", chatOpts.GetWaveRequestType())\n\t}\n\n\treturn req, nil\n}\n\n// convertToolUsePart converts a tool-* type UIMessagePart to an Anthropic tool_use or tool_result block\nfunc convertToolUsePart(p uctypes.UIMessagePart) (*anthropicMessageContentBlock, error) {\n\t// Sanity check that this is actually a tool-* type\n\tif !strings.HasPrefix(p.Type, \"tool-\") {\n\t\treturn nil, fmt.Errorf(\"convertToolUsePart expects 'tool-*' type, got '%s'\", p.Type)\n\t}\n\n\t// Extract tool name from type field (format: \"tool-{name}\")\n\ttoolName := strings.TrimPrefix(p.Type, \"tool-\")\n\tif toolName == \"\" {\n\t\treturn nil, fmt.Errorf(\"tool name is empty (type was '%s')\", p.Type)\n\t}\n\tif len(toolName) > 200 {\n\t\treturn nil, fmt.Errorf(\"tool name exceeds 200 character limit: %d characters\", len(toolName))\n\t}\n\tif p.ToolCallID == \"\" {\n\t\treturn nil, fmt.Errorf(\"tool call ID is required but missing\")\n\t}\n\n\t// Validate ToolCallID charset (must match ^[a-zA-Z0-9_-]+$)\n\tvalidIDPattern := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)\n\tif !validIDPattern.MatchString(p.ToolCallID) {\n\t\treturn nil, fmt.Errorf(\"tool call ID contains invalid characters (must be alphanumeric, underscore, or dash): %s\", p.ToolCallID)\n\t}\n\n\t// Handle different states\n\tif p.State == \"input-streaming\" || p.State == \"input-available\" {\n\t\t// These states represent tool calls (tool_use blocks)\n\t\t// Anthropic expects an object for input, never nil\n\t\tinput := p.Input\n\t\tif input == nil {\n\t\t\tinput = map[string]interface{}{}\n\t\t} else {\n\t\t\t// Validate that input is an object (map), not string/array\n\t\t\tif _, ok := input.(map[string]interface{}); !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"tool input must be an object/map, got %T\", input)\n\t\t\t}\n\t\t}\n\n\t\treturn &anthropicMessageContentBlock{\n\t\t\tType:  \"tool_use\",\n\t\t\tID:    p.ToolCallID,\n\t\t\tName:  toolName,\n\t\t\tInput: input,\n\t\t}, nil\n\n\t} else if p.State == \"output-available\" {\n\t\t// This state represents successful tool execution result (tool_result block)\n\t\tvar content interface{}\n\t\tif p.Output != nil {\n\t\t\t// Try to convert output to string if it's not already\n\t\t\tif outputStr, ok := p.Output.(string); ok {\n\t\t\t\tcontent = outputStr\n\t\t\t} else {\n\t\t\t\t// If it's not a string, marshal it to JSON\n\t\t\t\toutputBytes, err := json.Marshal(p.Output)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, fmt.Errorf(\"failed to marshal tool output: %w\", err)\n\t\t\t\t}\n\t\t\t\tcontent = string(outputBytes)\n\t\t\t}\n\t\t} else {\n\t\t\tcontent = \"\"\n\t\t}\n\n\t\treturn &anthropicMessageContentBlock{\n\t\t\tType:      \"tool_result\",\n\t\t\tToolUseID: p.ToolCallID,\n\t\t\tContent:   content,\n\t\t}, nil\n\n\t} else if p.State == \"output-error\" {\n\t\t// This state represents failed tool execution (tool_result block with error)\n\t\terrorContent := p.ErrorText\n\t\tif errorContent == \"\" {\n\t\t\terrorContent = \"Tool execution failed\"\n\t\t}\n\n\t\treturn &anthropicMessageContentBlock{\n\t\t\tType:      \"tool_result\",\n\t\t\tToolUseID: p.ToolCallID,\n\t\t\tContent:   errorContent,\n\t\t\tIsError:   true,\n\t\t}, nil\n\n\t} else {\n\t\treturn nil, fmt.Errorf(\"invalid tool part state '%s' (must be 'input-streaming', 'input-available', 'output-available', or 'output-error')\", p.State)\n\t}\n}\n\n// convertPartToAnthropicBlocks converts a single UIMessagePart to one or more Anthropic content blocks\nfunc convertPartToAnthropicBlocks(p uctypes.UIMessagePart, role string, blockIndex int) ([]anthropicMessageContentBlock, error) {\n\tif p.Type == \"text\" {\n\t\treturn []anthropicMessageContentBlock{{\n\t\t\tType: \"text\",\n\t\t\tText: p.Text,\n\t\t}}, nil\n\t} else if p.Type == \"reasoning\" {\n\t\t// Check if we have a signature in provider metadata\n\t\tsignature, hasSignature := p.ProviderMetadata[ProviderMetadataThinkingSignatureKey]\n\t\tif !hasSignature {\n\t\t\treturn nil, fmt.Errorf(\"reasoning part requires signature in provider metadata key '%s'\", ProviderMetadataThinkingSignatureKey)\n\t\t}\n\n\t\tsignatureStr, ok := signature.(string)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"reasoning part signature must be a string, got %T\", signature)\n\t\t}\n\n\t\treturn []anthropicMessageContentBlock{{\n\t\t\tType:      \"thinking\",\n\t\t\tThinking:  p.Text,\n\t\t\tSignature: signatureStr,\n\t\t}}, nil\n\t} else if p.Type == \"source-url\" || p.Type == \"source-document\" {\n\t\t// no longer convert citations\n\t\treturn nil, nil\n\t} else if p.Type == \"step-start\" {\n\t\t// Omit step-start parts from Anthropic\n\t\treturn nil, nil\n\t} else if strings.HasPrefix(p.Type, \"data-\") {\n\t\t// Omit data-* parts from Anthropic\n\t\treturn nil, nil\n\t} else if p.Type == \"file\" {\n\t\t// Anthropic expects files in user messages\n\t\tif role != \"user\" {\n\t\t\treturn nil, fmt.Errorf(\"dropping file part in %s message (files should be in user messages)\", role)\n\t\t}\n\t\tblock, err := convertFileUIMessagePart(p)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn []anthropicMessageContentBlock{*block}, nil\n\t} else if strings.HasPrefix(p.Type, \"tool-\") {\n\t\tblock, err := convertToolUsePart(p)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn []anthropicMessageContentBlock{*block}, nil\n\t} else {\n\t\t// Skip unknown part types\n\t\treturn nil, fmt.Errorf(\"dropping unknown part type '%s'\", p.Type)\n\t}\n}\n\n// convertPartsToAnthropicBlocks converts UseChatMessagePart array to Anthropic content blocks with role-based validation\nfunc convertPartsToAnthropicBlocks(parts []uctypes.UIMessagePart, role string) ([]anthropicMessageContentBlock, error) {\n\tvar blocks []anthropicMessageContentBlock\n\n\tfor _, p := range parts {\n\t\tpartBlocks, err := convertPartToAnthropicBlocks(p, role, len(blocks))\n\t\tif err != nil {\n\t\t\tlog.Printf(\"anthropic: %v\", err)\n\t\t\tcontinue\n\t\t}\n\t\tblocks = append(blocks, partBlocks...)\n\t}\n\n\treturn blocks, nil\n}\n\n// convertFileUIMessagePart converts a file part to Anthropic image or document block format\nfunc convertFileUIMessagePart(p uctypes.UIMessagePart) (*anthropicMessageContentBlock, error) {\n\tif p.Type != \"file\" {\n\t\treturn nil, fmt.Errorf(\"convertFileUIMessagePart expects 'file' type, got '%s'\", p.Type)\n\t}\n\tif p.URL == \"\" {\n\t\treturn nil, errors.New(\"file part missing url\")\n\t}\n\tif p.MediaType == \"\" {\n\t\treturn nil, errors.New(\"file part missing mediaType\")\n\t}\n\n\t// Validate URL protocol - only allow data:, http:, https:\n\tif !strings.HasPrefix(p.URL, \"data:\") &&\n\t\t!strings.HasPrefix(p.URL, \"http://\") &&\n\t\t!strings.HasPrefix(p.URL, \"https://\") {\n\t\treturn nil, fmt.Errorf(\"unsupported URL protocol in file part: %s\", p.URL)\n\t}\n\n\t// Branch on mediaType first to determine block type and constraints\n\tswitch {\n\tcase strings.HasPrefix(p.MediaType, \"image/\"):\n\t\t// image/* (jpeg, png, gif, webp) → Anthropic image block\n\t\tif strings.HasPrefix(p.URL, \"data:\") {\n\t\t\t// Data URL → base64 source\n\t\t\tparts := strings.SplitN(p.URL, \",\", 2)\n\t\t\tif len(parts) != 2 {\n\t\t\t\treturn nil, errors.New(\"invalid data URL format\")\n\t\t\t}\n\t\t\treturn &anthropicMessageContentBlock{\n\t\t\t\tType: \"image\",\n\t\t\t\tSource: &anthropicSource{\n\t\t\t\t\tType:      \"base64\",\n\t\t\t\t\tData:      parts[1],\n\t\t\t\t\tMediaType: p.MediaType,\n\t\t\t\t},\n\t\t\t}, nil\n\t\t} else {\n\t\t\t// HTTP/HTTPS URL → url source (no media_type for image URLs)\n\t\t\treturn &anthropicMessageContentBlock{\n\t\t\t\tType: \"image\",\n\t\t\t\tSource: &anthropicSource{\n\t\t\t\t\tType: \"url\",\n\t\t\t\t\tURL:  p.URL,\n\t\t\t\t},\n\t\t\t}, nil\n\t\t}\n\n\tcase p.MediaType == \"application/pdf\":\n\t\t// application/pdf → Anthropic document block\n\t\tif strings.HasPrefix(p.URL, \"data:\") {\n\t\t\t// Data URL → base64 source\n\t\t\tparts := strings.SplitN(p.URL, \",\", 2)\n\t\t\tif len(parts) != 2 {\n\t\t\t\treturn nil, errors.New(\"invalid data URL format\")\n\t\t\t}\n\t\t\treturn &anthropicMessageContentBlock{\n\t\t\t\tType: \"document\",\n\t\t\t\tSource: &anthropicSource{\n\t\t\t\t\tType:      \"base64\",\n\t\t\t\t\tData:      parts[1],\n\t\t\t\t\tMediaType: p.MediaType,\n\t\t\t\t},\n\t\t\t}, nil\n\t\t} else {\n\t\t\t// HTTP/HTTPS URL → url source (no media_type for URL sources)\n\t\t\treturn &anthropicMessageContentBlock{\n\t\t\t\tType: \"document\",\n\t\t\t\tSource: &anthropicSource{\n\t\t\t\t\tType: \"url\",\n\t\t\t\t\tURL:  p.URL,\n\t\t\t\t},\n\t\t\t}, nil\n\t\t}\n\n\tcase p.MediaType == \"text/plain\":\n\t\t// text/plain → Anthropic document block, but NO URL form supported\n\t\tif strings.HasPrefix(p.URL, \"data:\") {\n\t\t\t// Data URL → decode base64 data and return as document with PlainTextSource\n\t\t\tparts := strings.SplitN(p.URL, \",\", 2)\n\t\t\tif len(parts) != 2 {\n\t\t\t\treturn nil, errors.New(\"invalid data URL format\")\n\t\t\t}\n\t\t\t// Decode base64 data\n\t\t\ttextData, err := base64.StdEncoding.DecodeString(parts[1])\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to decode base64 data: %w\", err)\n\t\t\t}\n\t\t\treturn &anthropicMessageContentBlock{\n\t\t\t\tType: \"document\",\n\t\t\t\tSource: &anthropicSource{\n\t\t\t\t\tType:      \"text\",\n\t\t\t\t\tData:      string(textData),\n\t\t\t\t\tMediaType: \"text/plain\",\n\t\t\t\t},\n\t\t\t}, nil\n\t\t} else {\n\t\t\t// HTTP/HTTPS URL → not supported inline, would need to fetch\n\t\t\treturn nil, fmt.Errorf(\"dropping text/plain file with URL (must be fetched and converted to base64 or uploaded to Files API)\")\n\t\t}\n\n\tdefault:\n\t\t// Other media types → not supported inline, must upload and use file_id\n\t\treturn nil, fmt.Errorf(\"dropping file with unsupported media type '%s' (must be uploaded to Files API and sent as file_id)\", p.MediaType)\n\t}\n\n}\n\n// convertAIMessageToAnthropicChatMessage converts an AIMessage to anthropicChatMessage\n// These messages are ALWAYS role \"user\"\nfunc ConvertAIMessageToAnthropicChatMessage(aiMsg uctypes.AIMessage) (*anthropicChatMessage, error) {\n\tif err := aiMsg.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid AIMessage: %w\", err)\n\t}\n\n\tvar contentBlocks []anthropicMessageContentBlock\n\n\tfor i, part := range aiMsg.Parts {\n\t\tswitch part.Type {\n\t\tcase uctypes.AIMessagePartTypeText:\n\t\t\tif part.Text == \"\" {\n\t\t\t\treturn nil, fmt.Errorf(\"part %d: text type requires non-empty text field\", i)\n\t\t\t}\n\t\t\tcontentBlocks = append(contentBlocks, anthropicMessageContentBlock{\n\t\t\t\tType: \"text\",\n\t\t\t\tText: part.Text,\n\t\t\t})\n\n\t\tcase uctypes.AIMessagePartTypeFile:\n\t\t\tblock, err := convertFileAIMessagePart(part)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"part %d: %w\", i, err)\n\t\t\t}\n\t\t\tcontentBlocks = append(contentBlocks, *block)\n\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"part %d: unsupported part type '%s'\", i, part.Type)\n\t\t}\n\t}\n\n\treturn &anthropicChatMessage{\n\t\tMessageId: aiMsg.MessageId,\n\t\tRole:      \"user\",\n\t\tContent:   contentBlocks,\n\t}, nil\n}\n\n// hasInlineData checks if the part has data available for inline use (either Data field or data URL)\nfunc hasInlineData(part uctypes.AIMessagePart) bool {\n\thasData := len(part.Data) > 0\n\thasURL := part.URL != \"\" && strings.HasPrefix(part.URL, \"data:\")\n\treturn hasData || hasURL\n}\n\n// extractBase64Data extracts base64 data from either the Data field or a data URL\nfunc extractBase64Data(part uctypes.AIMessagePart) (string, error) {\n\thasData := len(part.Data) > 0\n\thasURL := part.URL != \"\"\n\n\tif hasData {\n\t\t// Raw data → base64 encode\n\t\treturn base64.StdEncoding.EncodeToString(part.Data), nil\n\t} else if hasURL && strings.HasPrefix(part.URL, \"data:\") {\n\t\t// Data URL → check format and extract/encode data appropriately\n\t\tparts := strings.SplitN(part.URL, \",\", 2)\n\t\tif len(parts) != 2 {\n\t\t\treturn \"\", errors.New(\"invalid data URL format\")\n\t\t}\n\n\t\theader := parts[0]\n\t\tdata := parts[1]\n\n\t\t// Check if it's already base64 encoded: data:mediatype;base64,<data>\n\t\tif strings.Contains(header, \";base64\") {\n\t\t\t// Already base64 encoded\n\t\t\treturn data, nil\n\t\t} else {\n\t\t\t// Raw data that needs base64 encoding: data:mediatype,<raw_data>\n\t\t\treturn base64.StdEncoding.EncodeToString([]byte(data)), nil\n\t\t}\n\t}\n\n\treturn \"\", errors.New(\"no data available for base64 extraction\")\n}\n\n// convertFileAIMessagePart converts a file AIMessagePart to anthropicMessageContentBlock\nfunc convertFileAIMessagePart(part uctypes.AIMessagePart) (*anthropicMessageContentBlock, error) {\n\tif part.Type != uctypes.AIMessagePartTypeFile {\n\t\treturn nil, fmt.Errorf(\"convertFileAIMessagePart expects 'file' type, got '%s'\", part.Type)\n\t}\n\n\tif err := part.Validate(); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Validate URL protocol if URL is provided - only allow data:, http:, https:\n\tif part.URL != \"\" {\n\t\tif !strings.HasPrefix(part.URL, \"data:\") &&\n\t\t\t!strings.HasPrefix(part.URL, \"http://\") &&\n\t\t\t!strings.HasPrefix(part.URL, \"https://\") {\n\t\t\treturn nil, fmt.Errorf(\"unsupported URL protocol in file part: %s\", part.URL)\n\t\t}\n\t}\n\n\t// Branch on mimetype to determine block type and constraints\n\tswitch {\n\tcase strings.HasPrefix(part.MimeType, \"image/\"):\n\t\t// image/* (jpeg, png, gif, webp) → Anthropic image block\n\t\tif hasInlineData(part) {\n\t\t\t// Data available → use base64 source\n\t\t\tbase64Data, err := extractBase64Data(part)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn &anthropicMessageContentBlock{\n\t\t\t\tType: \"image\",\n\t\t\t\tSource: &anthropicSource{\n\t\t\t\t\tType:      \"base64\",\n\t\t\t\t\tData:      base64Data,\n\t\t\t\t\tMediaType: part.MimeType,\n\t\t\t\t\tFileName:  part.FileName,\n\t\t\t\t},\n\t\t\t\tSourcePreviewUrl: part.PreviewUrl,\n\t\t\t}, nil\n\t\t} else {\n\t\t\t// HTTP/HTTPS URL → url source (no media_type for image URLs)\n\t\t\treturn &anthropicMessageContentBlock{\n\t\t\t\tType: \"image\",\n\t\t\t\tSource: &anthropicSource{\n\t\t\t\t\tType:     \"url\",\n\t\t\t\t\tURL:      part.URL,\n\t\t\t\t\tFileName: part.FileName,\n\t\t\t\t},\n\t\t\t\tSourcePreviewUrl: part.PreviewUrl,\n\t\t\t}, nil\n\t\t}\n\n\tcase part.MimeType == \"application/pdf\":\n\t\t// application/pdf → Anthropic document block\n\t\tif hasInlineData(part) {\n\t\t\t// Data available → use base64 source\n\t\t\tbase64Data, err := extractBase64Data(part)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn &anthropicMessageContentBlock{\n\t\t\t\tType: \"document\",\n\t\t\t\tSource: &anthropicSource{\n\t\t\t\t\tType:      \"base64\",\n\t\t\t\t\tData:      base64Data,\n\t\t\t\t\tMediaType: part.MimeType,\n\t\t\t\t\tFileName:  part.FileName,\n\t\t\t\t},\n\t\t\t\tSourcePreviewUrl: part.PreviewUrl,\n\t\t\t}, nil\n\t\t} else {\n\t\t\t// HTTP/HTTPS URL → url source (no media_type for URL sources)\n\t\t\treturn &anthropicMessageContentBlock{\n\t\t\t\tType: \"document\",\n\t\t\t\tSource: &anthropicSource{\n\t\t\t\t\tType:     \"url\",\n\t\t\t\t\tURL:      part.URL,\n\t\t\t\t\tFileName: part.FileName,\n\t\t\t\t},\n\t\t\t\tSourcePreviewUrl: part.PreviewUrl,\n\t\t\t}, nil\n\t\t}\n\n\tcase part.MimeType == \"text/plain\":\n\t\t// text/plain → Anthropic document block, but NO URL form supported\n\t\tif hasInlineData(part) {\n\t\t\tvar textData string\n\t\t\tif len(part.Data) > 0 {\n\t\t\t\t// Raw data → convert to string directly\n\t\t\t\ttextData = string(part.Data)\n\t\t\t} else {\n\t\t\t\t// Data URL → extract base64 data and decode back to string\n\t\t\t\tbase64Data, err := extractBase64Data(part)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\tdecoded, err := base64.StdEncoding.DecodeString(base64Data)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, fmt.Errorf(\"failed to decode base64 data: %w\", err)\n\t\t\t\t}\n\t\t\t\ttextData = string(decoded)\n\t\t\t}\n\t\t\treturn &anthropicMessageContentBlock{\n\t\t\t\tType: \"document\",\n\t\t\t\tSource: &anthropicSource{\n\t\t\t\t\tType:      \"text\",\n\t\t\t\t\tData:      textData,\n\t\t\t\t\tMediaType: part.MimeType,\n\t\t\t\t\tFileName:  part.FileName,\n\t\t\t\t},\n\t\t\t}, nil\n\t\t} else {\n\t\t\t// HTTP/HTTPS URL → not supported inline, would need to fetch\n\t\t\treturn nil, fmt.Errorf(\"text/plain file with URL not supported (must be fetched and converted to base64 or uploaded to Files API)\")\n\t\t}\n\n\tdefault:\n\t\t// Other media types → not supported inline, must upload and use file_id\n\t\treturn nil, fmt.Errorf(\"unsupported media type '%s' (must be uploaded to Files API and sent as file_id)\", part.MimeType)\n\t}\n}\n\n// ConvertToUIMessage converts an anthropicChatMessage to a UIMessage\nfunc (m *anthropicChatMessage) ConvertToUIMessage() *uctypes.UIMessage {\n\tvar parts []uctypes.UIMessagePart\n\n\t// Iterate over all content blocks\n\tfor _, block := range m.Content {\n\t\tswitch block.Type {\n\t\tcase \"text\":\n\t\t\t// Convert text blocks to UIMessagePart\n\t\t\tparts = append(parts, uctypes.UIMessagePart{\n\t\t\t\tType: \"text\",\n\t\t\t\tText: block.Text,\n\t\t\t})\n\t\tcase \"image\":\n\t\t\t// Convert image blocks to data-userfile UIMessagePart (only for user role)\n\t\t\tif m.Role == \"user\" && block.Source != nil {\n\t\t\t\tparts = append(parts, uctypes.UIMessagePart{\n\t\t\t\t\tType: \"data-userfile\",\n\t\t\t\t\tData: uctypes.UIMessageDataUserFile{\n\t\t\t\t\t\tFileName:   block.Source.FileName,\n\t\t\t\t\t\tSize:       block.Source.Size,\n\t\t\t\t\t\tMimeType:   block.Source.MediaType,\n\t\t\t\t\t\tPreviewUrl: block.SourcePreviewUrl,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\tcase \"document\":\n\t\t\t// Convert document blocks to data-userfile UIMessagePart (only for user role)\n\t\t\tif m.Role == \"user\" && block.Source != nil {\n\t\t\t\tparts = append(parts, uctypes.UIMessagePart{\n\t\t\t\t\tType: \"data-userfile\",\n\t\t\t\t\tData: uctypes.UIMessageDataUserFile{\n\t\t\t\t\t\tFileName:   block.Source.FileName,\n\t\t\t\t\t\tSize:       block.Source.Size,\n\t\t\t\t\t\tMimeType:   block.Source.MediaType,\n\t\t\t\t\t\tPreviewUrl: block.SourcePreviewUrl,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\tcase \"tool_use\":\n\t\t\t// Convert tool_use blocks to tool UIMessagePart with input-available state\n\t\t\tif block.Name != \"\" && block.ID != \"\" {\n\t\t\t\tparts = append(parts, uctypes.UIMessagePart{\n\t\t\t\t\tType:       \"tool-\" + block.Name,\n\t\t\t\t\tState:      \"input-available\",\n\t\t\t\t\tToolCallID: block.ID,\n\t\t\t\t\tInput:      block.Input,\n\t\t\t\t})\n\t\t\t\tif block.ToolUseData != nil {\n\t\t\t\t\tparts = append(parts, uctypes.UIMessagePart{\n\t\t\t\t\t\tType: \"data-tooluse\",\n\t\t\t\t\t\tID:   block.ID,\n\t\t\t\t\t\tData: *block.ToolUseData,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\tdefault:\n\t\t\t// For now, skip all other types (will implement later)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tif len(parts) == 0 {\n\t\treturn nil\n\t}\n\n\treturn &uctypes.UIMessage{\n\t\tID:    m.MessageId,\n\t\tRole:  m.Role,\n\t\tParts: parts,\n\t}\n}\n\n// convertMessageForAPI creates a copy of the anthropicInputMessage with internal fields stripped from content blocks\nfunc convertMessageForAPI(msg anthropicInputMessage) anthropicInputMessage {\n\t// Create a copy of the message\n\tconverted := anthropicInputMessage{\n\t\tRole:    msg.Role,\n\t\tContent: make([]anthropicMessageContentBlock, len(msg.Content)),\n\t}\n\n\t// Copy each content block and clean it (strips internal fields)\n\tfor i, block := range msg.Content {\n\t\tconverted.Content[i] = *block.Clean()\n\t}\n\n\treturn converted\n}\n\n// ConvertToolResultsToAnthropicChatMessage converts AIToolResult slice to anthropicChatMessage\nfunc ConvertToolResultsToAnthropicChatMessage(toolResults []uctypes.AIToolResult) (*anthropicChatMessage, error) {\n\tif len(toolResults) == 0 {\n\t\treturn nil, errors.New(\"toolResults cannot be empty\")\n\t}\n\n\tvar contentBlocks []anthropicMessageContentBlock\n\n\tfor _, result := range toolResults {\n\t\tif result.ToolUseID == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"tool result missing ToolUseID\")\n\t\t}\n\n\t\tvar content interface{}\n\t\tvar isError bool\n\n\t\tif result.ErrorText != \"\" {\n\t\t\tcontent = result.ErrorText\n\t\t\tisError = true\n\t\t} else {\n\t\t\t// Check if text looks like an image data URL\n\t\t\tif strings.HasPrefix(result.Text, \"data:image/\") {\n\t\t\t\t// Parse the data URL to extract media type and base64 data\n\t\t\t\tparts := strings.SplitN(result.Text, \",\", 2)\n\t\t\t\tif len(parts) == 2 {\n\t\t\t\t\t// Extract media type from \"data:image/png;base64\"\n\t\t\t\t\tmediaTypePart := strings.TrimPrefix(parts[0], \"data:\")\n\t\t\t\t\tmediaType := strings.Split(mediaTypePart, \";\")[0]\n\n\t\t\t\t\t// Create content as array with image block\n\t\t\t\t\tcontent = []anthropicMessageContentBlock{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tType: \"image\",\n\t\t\t\t\t\t\tSource: &anthropicSource{\n\t\t\t\t\t\t\t\tType:      \"base64\",\n\t\t\t\t\t\t\t\tData:      parts[1],\n\t\t\t\t\t\t\t\tMediaType: mediaType,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t\tisError = false\n\t\t\t\t} else {\n\t\t\t\t\t// Failed to parse data URL\n\t\t\t\t\tcontent = \"failed to parse image data URL\"\n\t\t\t\t\tisError = true\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tcontent = result.Text\n\t\t\t\tisError = false\n\t\t\t}\n\t\t}\n\n\t\tcontentBlocks = append(contentBlocks, anthropicMessageContentBlock{\n\t\t\tType:      \"tool_result\",\n\t\t\tToolUseID: result.ToolUseID,\n\t\t\tContent:   content,\n\t\t\tIsError:   isError,\n\t\t})\n\t}\n\n\treturn &anthropicChatMessage{\n\t\tMessageId: uuid.New().String(),\n\t\tRole:      \"user\",\n\t\tContent:   contentBlocks,\n\t}, nil\n}\n\n// ConvertAIChatToUIChat converts an AIChat to a UIChat for Anthropic\nfunc ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) {\n\tif aiChat.APIType != uctypes.APIType_AnthropicMessages {\n\t\treturn nil, fmt.Errorf(\"APIType must be '%s', got '%s'\", uctypes.APIType_AnthropicMessages, aiChat.APIType)\n\t}\n\n\tuiMessages := make([]uctypes.UIMessage, 0, len(aiChat.NativeMessages))\n\n\tfor i, nativeMsg := range aiChat.NativeMessages {\n\t\tanthropicMsg, ok := nativeMsg.(*anthropicChatMessage)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"message %d: expected *anthropicChatMessage, got %T\", i, nativeMsg)\n\t\t}\n\n\t\tuiMsg := anthropicMsg.ConvertToUIMessage()\n\t\tif uiMsg != nil {\n\t\t\tuiMessages = append(uiMessages, *uiMsg)\n\t\t}\n\t}\n\n\treturn &uctypes.UIChat{\n\t\tChatId:     aiChat.ChatId,\n\t\tAPIType:    aiChat.APIType,\n\t\tModel:      aiChat.Model,\n\t\tAPIVersion: aiChat.APIVersion,\n\t\tMessages:   uiMessages,\n\t}, nil\n}\n\nfunc GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput {\n\tfor _, genMsg := range aiChat.NativeMessages {\n\t\tchatMsg, ok := genMsg.(*anthropicChatMessage)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, block := range chatMsg.Content {\n\t\t\tif block.Type != \"tool_use\" || block.ID != toolCallId {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\targsInput := block.Input\n\t\t\tif argsInput == nil {\n\t\t\t\targsInput = map[string]interface{}{}\n\t\t\t}\n\t\t\targsBytes, err := json.Marshal(argsInput)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn &uctypes.AIFunctionCallInput{\n\t\t\t\tCallId:      block.ID,\n\t\t\t\tName:        block.Name,\n\t\t\t\tArguments:   string(argsBytes),\n\t\t\t\tToolUseData: block.ToolUseData,\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error {\n\tchat := chatstore.DefaultChatStore.Get(chatId)\n\tif chat == nil {\n\t\treturn fmt.Errorf(\"chat not found: %s\", chatId)\n\t}\n\tfor _, genMsg := range chat.NativeMessages {\n\t\tchatMsg, ok := genMsg.(*anthropicChatMessage)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tfor i, block := range chatMsg.Content {\n\t\t\tif block.Type != \"tool_use\" || block.ID != toolCallId {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tupdatedMsg := &anthropicChatMessage{\n\t\t\t\tMessageId: chatMsg.MessageId,\n\t\t\t\tUsage:     chatMsg.Usage,\n\t\t\t\tRole:      chatMsg.Role,\n\t\t\t\tContent:   slices.Clone(chatMsg.Content),\n\t\t\t}\n\t\t\tupdatedMsg.Content[i].ToolUseData = &toolUseData\n\t\t\taiOpts := &uctypes.AIOptsType{\n\t\t\t\tAPIType:    chat.APIType,\n\t\t\t\tModel:      chat.Model,\n\t\t\t\tAPIVersion: chat.APIVersion,\n\t\t\t}\n\t\t\treturn chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg)\n\t\t}\n\t}\n\treturn fmt.Errorf(\"tool call with ID %s not found in chat %s\", toolCallId, chatId)\n}\n\nfunc RemoveToolUseCall(chatId string, toolCallId string) error {\n\tchat := chatstore.DefaultChatStore.Get(chatId)\n\tif chat == nil {\n\t\treturn fmt.Errorf(\"chat not found: %s\", chatId)\n\t}\n\tfor _, genMsg := range chat.NativeMessages {\n\t\tchatMsg, ok := genMsg.(*anthropicChatMessage)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tfor i, block := range chatMsg.Content {\n\t\t\tif block.Type != \"tool_use\" || block.ID != toolCallId {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tupdatedMsg := &anthropicChatMessage{\n\t\t\t\tMessageId: chatMsg.MessageId,\n\t\t\t\tUsage:     chatMsg.Usage,\n\t\t\t\tRole:      chatMsg.Role,\n\t\t\t\tContent:   slices.Delete(slices.Clone(chatMsg.Content), i, i+1),\n\t\t\t}\n\t\t\tif len(updatedMsg.Content) == 0 {\n\t\t\t\tchatstore.DefaultChatStore.RemoveMessage(chatId, chatMsg.MessageId)\n\t\t\t} else {\n\t\t\t\taiOpts := &uctypes.AIOptsType{\n\t\t\t\t\tAPIType:    chat.APIType,\n\t\t\t\t\tModel:      chat.Model,\n\t\t\t\t\tAPIVersion: chat.APIVersion,\n\t\t\t\t}\n\t\t\t\tif err := chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/aiusechat/chatstore/chatstore.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage chatstore\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n)\n\ntype ChatStore struct {\n\tlock  sync.Mutex\n\tchats map[string]*uctypes.AIChat\n}\n\nvar DefaultChatStore = &ChatStore{\n\tchats: make(map[string]*uctypes.AIChat),\n}\n\nfunc (cs *ChatStore) Get(chatId string) *uctypes.AIChat {\n\tcs.lock.Lock()\n\tdefer cs.lock.Unlock()\n\n\tchat := cs.chats[chatId]\n\tif chat == nil {\n\t\treturn nil\n\t}\n\n\t// Copy the chat to prevent concurrent access issues\n\tcopyChat := &uctypes.AIChat{\n\t\tChatId:         chat.ChatId,\n\t\tAPIType:        chat.APIType,\n\t\tModel:          chat.Model,\n\t\tAPIVersion:     chat.APIVersion,\n\t\tNativeMessages: make([]uctypes.GenAIMessage, len(chat.NativeMessages)),\n\t}\n\tcopy(copyChat.NativeMessages, chat.NativeMessages)\n\n\treturn copyChat\n}\n\nfunc (cs *ChatStore) Delete(chatId string) {\n\tcs.lock.Lock()\n\tdefer cs.lock.Unlock()\n\n\tdelete(cs.chats, chatId)\n}\n\nfunc (cs *ChatStore) CountUserMessages(chatId string) int {\n\tcs.lock.Lock()\n\tdefer cs.lock.Unlock()\n\n\tchat := cs.chats[chatId]\n\tif chat == nil {\n\t\treturn 0\n\t}\n\n\tcount := 0\n\tfor _, msg := range chat.NativeMessages {\n\t\tif msg.GetRole() == \"user\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc (cs *ChatStore) PostMessage(chatId string, aiOpts *uctypes.AIOptsType, message uctypes.GenAIMessage) error {\n\tcs.lock.Lock()\n\tdefer cs.lock.Unlock()\n\n\tchat := cs.chats[chatId]\n\tif chat == nil {\n\t\t// Create new chat\n\t\tchat = &uctypes.AIChat{\n\t\t\tChatId:         chatId,\n\t\t\tAPIType:        aiOpts.APIType,\n\t\t\tModel:          aiOpts.Model,\n\t\t\tAPIVersion:     aiOpts.APIVersion,\n\t\t\tNativeMessages: make([]uctypes.GenAIMessage, 0),\n\t\t}\n\t\tcs.chats[chatId] = chat\n\t} else {\n\t\t// Verify that the AI options match\n\t\tif chat.APIType != aiOpts.APIType {\n\t\t\treturn fmt.Errorf(\"API type mismatch: expected %s, got %s (must start a new chat)\", chat.APIType, aiOpts.APIType)\n\t\t}\n\t\tif !uctypes.AreModelsCompatible(chat.APIType, chat.Model, aiOpts.Model) {\n\t\t\treturn fmt.Errorf(\"model mismatch: expected %s, got %s (must start a new chat)\", chat.Model, aiOpts.Model)\n\t\t}\n\t\tif chat.APIVersion != aiOpts.APIVersion {\n\t\t\treturn fmt.Errorf(\"API version mismatch: expected %s, got %s (must start a new chat)\", chat.APIVersion, aiOpts.APIVersion)\n\t\t}\n\t}\n\n\t// Check for existing message with same ID (idempotency)\n\tmessageId := message.GetMessageId()\n\tfor i, existingMessage := range chat.NativeMessages {\n\t\tif existingMessage.GetMessageId() == messageId {\n\t\t\t// Replace existing message with same ID\n\t\t\tchat.NativeMessages[i] = message\n\t\t\treturn nil\n\t\t}\n\t}\n\n\t// Append the new message if no duplicate found\n\tchat.NativeMessages = append(chat.NativeMessages, message)\n\n\treturn nil\n}\n\nfunc (cs *ChatStore) RemoveMessage(chatId string, messageId string) bool {\n\tcs.lock.Lock()\n\tdefer cs.lock.Unlock()\n\n\tchat := cs.chats[chatId]\n\tif chat == nil {\n\t\treturn false\n\t}\n\n\tinitialLen := len(chat.NativeMessages)\n\tchat.NativeMessages = slices.DeleteFunc(chat.NativeMessages, func(msg uctypes.GenAIMessage) bool {\n\t\treturn msg.GetMessageId() == messageId\n\t})\n\n\treturn len(chat.NativeMessages) < initialLen\n}\n"
  },
  {
    "path": "pkg/aiusechat/gemini/doc.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// Package gemini implements the Google Gemini backend for WaveTerm's AI chat system.\n//\n// This package provides a complete implementation of the UseChatBackend interface\n// for Google's Gemini API, including:\n//   - Streaming chat responses via Server-Sent Events (SSE)\n//   - Function calling (tool use) support\n//   - Multi-modal input support (text, images, PDFs)\n//   - Proper message conversion and state management\n//\n// # API Type\n//\n// The Gemini backend uses the API type constant:\n//   uctypes.APIType_GoogleGemini = \"google-gemini\"\n//\n// # Supported Features\n//\n// - Text messages\n// - Image uploads (JPEG, PNG, etc.) - inline base64 encoding\n// - PDF document uploads - inline base64 encoding\n// - Text file attachments\n// - Directory listings\n// - Function/tool calling with structured arguments\n// - Streaming responses with real-time token delivery\n//\n// # Usage\n//\n// The backend is automatically registered and can be obtained via:\n//\n//   backend, err := aiusechat.GetBackendByAPIType(uctypes.APIType_GoogleGemini)\n//\n// To use the Gemini API, you need:\n//   1. A Google AI API key\n//   2. Configure the chat with APIType_GoogleGemini\n//   3. Set the Model (e.g., \"gemini-2.0-flash-exp\")\n//   4. Provide the API key in the Config.APIToken field\n//\n// # Configuration Example\n//\n//   chatOpts := uctypes.WaveChatOpts{\n//       ChatId:   \"my-chat-id\",\n//       ClientId: \"my-client-id\",\n//       Config: uctypes.AIOptsType{\n//           APIType:      uctypes.APIType_GoogleGemini,\n//           Model:        \"gemini-2.0-flash-exp\",\n//           APIToken:     \"your-google-api-key\",\n//           MaxTokens:    8192,\n//           Capabilities: []string{\n//               uctypes.AICapabilityTools,\n//               uctypes.AICapabilityImages,\n//               uctypes.AICapabilityPdfs,\n//           },\n//       },\n//       Tools:        []uctypes.ToolDefinition{...},\n//       SystemPrompt: []string{\"You are a helpful assistant.\"},\n//   }\n//\n// # Message Format\n//\n// The Gemini backend uses the GeminiChatMessage type internally, which stores:\n//   - MessageId: Unique identifier for idempotency\n//   - Role: \"user\" or \"model\" (model is Gemini's term for assistant)\n//   - Parts: Array of message parts (text, inline data, function calls/responses)\n//   - Usage: Token usage metadata\n//\n// # Function Calling\n//\n// Function calling is supported via Gemini's native function calling feature:\n//   - Tools are converted to Gemini's FunctionDeclaration format\n//   - Function calls are streamed with real-time argument updates\n//   - Function responses are sent back as user messages with FunctionResponse parts\n//\n// # API Endpoint\n//\n// By default, the backend uses:\n//   https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent\n//\n// You can override this by setting Config.BaseURL.\n//\n// # Error Handling\n//\n// The backend properly handles:\n//   - Content blocking/safety filters\n//   - Token limit errors\n//   - Network errors\n//   - Malformed responses\n//   - Context cancellation\n//\n// All errors are properly propagated through the SSE stream.\n//\n// # Limitations\n//\n// - File uploads must be provided as base64-encoded inline data\n// - Images and PDFs use inline data, not file upload URIs\n// - Multi-turn conversations require proper role alternation (user/model)\n// - Some advanced Gemini features like caching are not yet implemented\npackage gemini\n"
  },
  {
    "path": "pkg/aiusechat/gemini/gemini-backend.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage gemini\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/launchdarkly/eventsource\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/web/sse\"\n)\n\n// ensureAltSse ensures the ?alt=sse query parameter is set on the endpoint\nfunc ensureAltSse(endpoint string) (string, error) {\n\tparsedURL, err := url.Parse(endpoint)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"invalid ai:endpoint URL: %w\", err)\n\t}\n\n\tquery := parsedURL.Query()\n\tif query.Get(\"alt\") != \"sse\" {\n\t\tquery.Set(\"alt\", \"sse\")\n\t\tparsedURL.RawQuery = query.Encode()\n\t\treturn parsedURL.String(), nil\n\t}\n\n\treturn endpoint, nil\n}\n\n// appendPartToLastUserMessage appends a text part to the last user message in the contents slice\nfunc appendPartToLastUserMessage(contents []GeminiContent, text string) {\n\tfor i := len(contents) - 1; i >= 0; i-- {\n\t\tif contents[i].Role == \"user\" {\n\t\t\tcontents[i].Parts = append(contents[i].Parts, GeminiMessagePart{\n\t\t\t\tText: text,\n\t\t\t})\n\t\t\tbreak\n\t\t}\n\t}\n}\n\n// buildGeminiHTTPRequest creates an HTTP request for the Gemini API\nfunc buildGeminiHTTPRequest(ctx context.Context, contents []GeminiContent, chatOpts uctypes.WaveChatOpts) (*http.Request, error) {\n\topts := chatOpts.Config\n\n\tif opts.Model == \"\" {\n\t\treturn nil, errors.New(\"ai:model is required\")\n\t}\n\tif opts.APIToken == \"\" {\n\t\treturn nil, errors.New(\"ai:apitoken is required\")\n\t}\n\tif opts.Endpoint == \"\" {\n\t\treturn nil, errors.New(\"ai:endpoint is required\")\n\t}\n\n\tmaxTokens := opts.MaxTokens\n\tif maxTokens <= 0 {\n\t\tmaxTokens = GeminiDefaultMaxTokens\n\t}\n\n\t// Build request body\n\treqBody := &GeminiRequest{\n\t\tContents: contents,\n\t\tGenerationConfig: &GeminiGenerationConfig{\n\t\t\tMaxOutputTokens: int32(maxTokens),\n\t\t\tTemperature:     0.7, // Default temperature\n\t\t},\n\t}\n\n\t// Map thinking level for Gemini 3+ models\n\tif opts.ThinkingLevel != \"\" && strings.Contains(opts.Model, \"gemini-3\") {\n\t\tgeminiThinkingLevel := \"high\"\n\t\tif opts.ThinkingLevel == uctypes.ThinkingLevelLow {\n\t\t\tgeminiThinkingLevel = \"low\"\n\t\t}\n\t\treqBody.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{\n\t\t\tThinkingLevel: geminiThinkingLevel,\n\t\t}\n\t}\n\n\t// Add system instruction if provided\n\tif len(chatOpts.SystemPrompt) > 0 {\n\t\tsystemText := strings.Join(chatOpts.SystemPrompt, \"\\n\\n\")\n\t\treqBody.SystemInstruction = &GeminiContent{\n\t\t\tParts: []GeminiMessagePart{\n\t\t\t\t{Text: systemText},\n\t\t\t},\n\t\t}\n\t}\n\n\t// Add tools if provided\n\tvar allTools []uctypes.ToolDefinition\n\tallTools = append(allTools, chatOpts.Tools...)\n\tallTools = append(allTools, chatOpts.TabTools...)\n\n\tif len(allTools) > 0 {\n\t\tvar functionDeclarations []GeminiFunctionDeclaration\n\t\tfor _, tool := range allTools {\n\t\t\t// Only include tools whose capabilities are met\n\t\t\tif !tool.HasRequiredCapabilities(opts.Capabilities) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfunctionDeclarations = append(functionDeclarations, ConvertToolDefinitionToGemini(tool))\n\t\t}\n\t\tif len(functionDeclarations) > 0 {\n\t\t\treqBody.Tools = []GeminiTool{\n\t\t\t\t{FunctionDeclarations: functionDeclarations},\n\t\t\t}\n\t\t\treqBody.ToolConfig = &GeminiToolConfig{\n\t\t\t\tFunctionCallingConfig: &GeminiFunctionCallingConfig{\n\t\t\t\t\tMode: \"AUTO\",\n\t\t\t\t},\n\t\t\t}\n\t\t}\n\t}\n\n\t// Injected data - append to last user message as separate parts\n\tif chatOpts.TabState != \"\" {\n\t\tappendPartToLastUserMessage(reqBody.Contents, chatOpts.TabState)\n\t}\n\tif chatOpts.PlatformInfo != \"\" {\n\t\tappendPartToLastUserMessage(reqBody.Contents, \"<PlatformInfo>\\n\"+chatOpts.PlatformInfo+\"\\n</PlatformInfo>\")\n\t}\n\tif chatOpts.AppStaticFiles != \"\" {\n\t\tappendPartToLastUserMessage(reqBody.Contents, \"<CurrentAppStaticFiles>\\n\"+chatOpts.AppStaticFiles+\"\\n</CurrentAppStaticFiles>\")\n\t}\n\tif chatOpts.AppGoFile != \"\" {\n\t\tappendPartToLastUserMessage(reqBody.Contents, \"<CurrentAppGoFile>\\n\"+chatOpts.AppGoFile+\"\\n</CurrentAppGoFile>\")\n\t}\n\n\tif wavebase.IsDevMode() {\n\t\tvar toolNames []string\n\t\tfor _, tool := range allTools {\n\t\t\ttoolNames = append(toolNames, tool.Name)\n\t\t}\n\t\tlog.Printf(\"gemini: model %s, messages: %d, tools: %s\\n\", opts.Model, len(contents), strings.Join(toolNames, \",\"))\n\t}\n\n\t// Encode request body\n\tbuf, err := aiutil.JsonEncodeRequestBody(reqBody)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Build URL\n\tendpoint, err := ensureAltSse(opts.Endpoint)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Create HTTP request\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Set headers\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"x-goog-api-key\", opts.APIToken)\n\n\treturn req, nil\n}\n\n// RunGeminiChatStep executes a chat step using the Gemini API\nfunc RunGeminiChatStep(\n\tctx context.Context,\n\tsseHandler *sse.SSEHandlerCh,\n\tchatOpts uctypes.WaveChatOpts,\n\tcont *uctypes.WaveContinueResponse,\n) (*uctypes.WaveStopReason, *GeminiChatMessage, *uctypes.RateLimitInfo, error) {\n\tif sseHandler == nil {\n\t\treturn nil, nil, nil, errors.New(\"sse handler is nil\")\n\t}\n\n\t// Get chat from store\n\tchat := chatstore.DefaultChatStore.Get(chatOpts.ChatId)\n\tif chat == nil {\n\t\treturn nil, nil, nil, fmt.Errorf(\"chat not found: %s\", chatOpts.ChatId)\n\t}\n\n\t// Validate that chatOpts.Config match the chat's stored configuration\n\tif chat.APIType != chatOpts.Config.APIType {\n\t\treturn nil, nil, nil, fmt.Errorf(\"API type mismatch: chat has %s, chatOpts has %s\", chat.APIType, chatOpts.Config.APIType)\n\t}\n\tif chat.Model != chatOpts.Config.Model {\n\t\treturn nil, nil, nil, fmt.Errorf(\"model mismatch: chat has %s, chatOpts has %s\", chat.Model, chatOpts.Config.Model)\n\t}\n\n\t// Context with timeout if provided\n\tif chatOpts.Config.TimeoutMs > 0 {\n\t\tvar cancel context.CancelFunc\n\t\tctx, cancel = context.WithTimeout(ctx, time.Duration(chatOpts.Config.TimeoutMs)*time.Millisecond)\n\t\tdefer cancel()\n\t}\n\n\t// Convert GenAIMessages to Gemini contents\n\tvar contents []GeminiContent\n\tfor _, genMsg := range chat.NativeMessages {\n\t\tchatMsg, ok := genMsg.(*GeminiChatMessage)\n\t\tif !ok {\n\t\t\treturn nil, nil, nil, fmt.Errorf(\"expected GeminiChatMessage, got %T\", genMsg)\n\t\t}\n\n\t\tcontent := GeminiContent{\n\t\t\tRole:  chatMsg.Role,\n\t\t\tParts: make([]GeminiMessagePart, len(chatMsg.Parts)),\n\t\t}\n\t\tfor i, part := range chatMsg.Parts {\n\t\t\tcontent.Parts[i] = *part.Clean()\n\t\t}\n\t\tcontents = append(contents, content)\n\t}\n\n\treq, err := buildGeminiHTTPRequest(ctx, contents, chatOpts)\n\tif err != nil {\n\t\treturn nil, nil, nil, err\n\t}\n\n\thttpClient, err := aiutil.MakeHTTPClient(chatOpts.Config.ProxyURL)\n\tif err != nil {\n\t\treturn nil, nil, nil, err\n\t}\n\n\tresp, err := httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, nil, nil, fmt.Errorf(\"HTTP request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\n\t\t// Try to parse as Gemini error\n\t\tvar geminiErr GeminiErrorResponse\n\t\tif err := json.Unmarshal(bodyBytes, &geminiErr); err == nil && geminiErr.Error != nil {\n\t\t\treturn nil, nil, nil, fmt.Errorf(\"Gemini API error (%d): %s\", geminiErr.Error.Code, geminiErr.Error.Message)\n\t\t}\n\n\t\treturn nil, nil, nil, fmt.Errorf(\"API returned status %d: %s\", resp.StatusCode, utilfn.TruncateString(string(bodyBytes), 120))\n\t}\n\n\t// Setup SSE if this is a new request (not a continuation)\n\tif cont == nil {\n\t\tif err := sseHandler.SetupSSE(); err != nil {\n\t\t\treturn nil, nil, nil, fmt.Errorf(\"failed to setup SSE: %w\", err)\n\t\t}\n\t}\n\n\t// Stream processing\n\tstopReason, assistantMsg, err := processGeminiStream(ctx, resp.Body, sseHandler, chatOpts, cont)\n\tif err != nil {\n\t\treturn nil, nil, nil, err\n\t}\n\n\treturn stopReason, assistantMsg, nil, nil\n}\n\n// processGeminiStream handles the streaming response from Gemini\nfunc processGeminiStream(\n\tctx context.Context,\n\tbody io.Reader,\n\tsseHandler *sse.SSEHandlerCh,\n\tchatOpts uctypes.WaveChatOpts,\n\tcont *uctypes.WaveContinueResponse,\n) (*uctypes.WaveStopReason, *GeminiChatMessage, error) {\n\tmsgID := uuid.New().String()\n\ttextID := uuid.New().String()\n\ttextStarted := false\n\tvar textBuilder strings.Builder\n\tvar textThoughtSignature string\n\tvar finishReason string\n\tvar functionCalls []GeminiMessagePart\n\tvar usageMetadata *GeminiUsageMetadata\n\n\tif cont == nil {\n\t\t_ = sseHandler.AiMsgStart(msgID)\n\t}\n\t_ = sseHandler.AiMsgStartStep()\n\n\tdecoder := eventsource.NewDecoder(body)\n\n\tfor {\n\t\tif err := ctx.Err(); err != nil {\n\t\t\t_ = sseHandler.AiMsgError(\"request cancelled\")\n\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindCanceled,\n\t\t\t\tErrorType: \"cancelled\",\n\t\t\t\tErrorText: \"request cancelled\",\n\t\t\t}, nil, err\n\t\t}\n\n\t\tevent, err := decoder.Decode()\n\t\tif err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sseHandler.Err() != nil {\n\t\t\t\tpartialMsg := extractPartialGeminiMessage(msgID, textBuilder.String())\n\t\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\t\tKind:      uctypes.StopKindCanceled,\n\t\t\t\t\tErrorType: \"client_disconnect\",\n\t\t\t\t\tErrorText: \"client disconnected\",\n\t\t\t\t}, partialMsg, nil\n\t\t\t}\n\t\t\t_ = sseHandler.AiMsgError(fmt.Sprintf(\"stream decode error: %v\", err))\n\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindError,\n\t\t\t\tErrorType: \"stream\",\n\t\t\t\tErrorText: err.Error(),\n\t\t\t}, nil, fmt.Errorf(\"stream decode error: %w\", err)\n\t\t}\n\n\t\tdata := event.Data()\n\t\tif data == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Parse the JSON response\n\t\tvar chunk GeminiStreamResponse\n\t\tif err := json.Unmarshal([]byte(data), &chunk); err != nil {\n\t\t\tlog.Printf(\"gemini: failed to parse chunk: %v\\n\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Check for prompt feedback (blocking)\n\t\tif chunk.PromptFeedback != nil && chunk.PromptFeedback.BlockReason != \"\" {\n\t\t\terrorMsg := fmt.Sprintf(\"Content blocked: %s\", chunk.PromptFeedback.BlockReason)\n\t\t\t_ = sseHandler.AiMsgError(errorMsg)\n\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindContent,\n\t\t\t\tErrorType: \"blocked\",\n\t\t\t\tErrorText: errorMsg,\n\t\t\t}, nil, fmt.Errorf(\"%s\", errorMsg)\n\t\t}\n\n\t\t// Store usage metadata if present\n\t\tif chunk.UsageMetadata != nil {\n\t\t\tusageMetadata = chunk.UsageMetadata\n\t\t}\n\n\t\t// Log grounding metadata (web search queries)\n\t\tif chunk.GroundingMetadata != nil && len(chunk.GroundingMetadata.WebSearchQueries) > 0 {\n\t\t\tif wavebase.IsDevMode() {\n\t\t\t\tlog.Printf(\"gemini: web search queries executed: %v\\n\", chunk.GroundingMetadata.WebSearchQueries)\n\t\t\t}\n\t\t}\n\n\t\t// Process candidates\n\t\tif len(chunk.Candidates) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcandidate := chunk.Candidates[0]\n\n\t\t// Log candidate grounding metadata if present\n\t\tif candidate.GroundingMetadata != nil && len(candidate.GroundingMetadata.WebSearchQueries) > 0 {\n\t\t\tif wavebase.IsDevMode() {\n\t\t\t\tlog.Printf(\"gemini: candidate web search queries: %v\\n\", candidate.GroundingMetadata.WebSearchQueries)\n\t\t\t}\n\t\t}\n\n\t\t// Store finish reason\n\t\tif candidate.FinishReason != \"\" {\n\t\t\tfinishReason = candidate.FinishReason\n\t\t}\n\n\t\tif candidate.Content == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Process content parts\n\t\tfor _, part := range candidate.Content.Parts {\n\t\t\tif part.Text != \"\" {\n\t\t\t\tif !textStarted {\n\t\t\t\t\t_ = sseHandler.AiMsgTextStart(textID)\n\t\t\t\t\ttextStarted = true\n\t\t\t\t}\n\t\t\t\ttextBuilder.WriteString(part.Text)\n\t\t\t\t_ = sseHandler.AiMsgTextDelta(textID, part.Text)\n\t\t\t\tif part.ThoughtSignature != \"\" {\n\t\t\t\t\ttextThoughtSignature = part.ThoughtSignature\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif part.FunctionCall != nil {\n\t\t\t\ttoolCallId := uuid.New().String()\n\n\t\t\t\targsBytes, _ := json.Marshal(part.FunctionCall.Args)\n\t\t\t\taiutil.SendToolProgress(toolCallId, part.FunctionCall.Name, argsBytes, chatOpts, sseHandler, false)\n\n\t\t\t\t// Preserve thought_signature exactly as received from API\n\t\t\t\t// It can be at part level, FunctionCall level, or both\n\t\t\t\tfunctionCalls = append(functionCalls, GeminiMessagePart{\n\t\t\t\t\tFunctionCall:     part.FunctionCall,\n\t\t\t\t\tThoughtSignature: part.ThoughtSignature,\n\t\t\t\t\tToolUseData: &uctypes.UIMessageDataToolUse{\n\t\t\t\t\t\tToolCallId: toolCallId,\n\t\t\t\t\t\tToolName:   part.FunctionCall.Name,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\t// Determine stop reason\n\tstopKind := uctypes.StopKindDone\n\tswitch finishReason {\n\tcase \"MAX_TOKENS\":\n\t\tstopKind = uctypes.StopKindMaxTokens\n\tcase \"SAFETY\":\n\t\tstopKind = uctypes.StopKindContent\n\tcase \"RECITATION\":\n\t\tstopKind = uctypes.StopKindContent\n\t}\n\n\t// Build assistant message\n\tvar parts []GeminiMessagePart\n\tif textBuilder.Len() > 0 {\n\t\tparts = append(parts, GeminiMessagePart{\n\t\t\tText:             textBuilder.String(),\n\t\t\tThoughtSignature: textThoughtSignature,\n\t\t})\n\t}\n\tparts = append(parts, functionCalls...)\n\n\t// Set usage metadata model\n\tif usageMetadata != nil {\n\t\tusageMetadata.Model = chatOpts.Config.Model\n\t}\n\n\tassistantMsg := &GeminiChatMessage{\n\t\tMessageId: msgID,\n\t\tRole:      \"model\",\n\t\tParts:     parts,\n\t\tUsage:     usageMetadata,\n\t}\n\n\t// Build tool calls for stop reason\n\tvar waveToolCalls []uctypes.WaveToolCall\n\tif len(functionCalls) > 0 {\n\t\tstopKind = uctypes.StopKindToolUse\n\t\tfor _, fcPart := range functionCalls {\n\t\t\tif fcPart.FunctionCall != nil && fcPart.ToolUseData != nil {\n\t\t\t\twaveToolCalls = append(waveToolCalls, uctypes.WaveToolCall{\n\t\t\t\t\tID:          fcPart.ToolUseData.ToolCallId,\n\t\t\t\t\tName:        fcPart.FunctionCall.Name,\n\t\t\t\t\tInput:       fcPart.FunctionCall.Args,\n\t\t\t\t\tToolUseData: fcPart.ToolUseData,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\tstopReason := &uctypes.WaveStopReason{\n\t\tKind:      stopKind,\n\t\tRawReason: finishReason,\n\t\tToolCalls: waveToolCalls,\n\t}\n\n\tif textStarted {\n\t\t_ = sseHandler.AiMsgTextEnd(textID)\n\t}\n\t_ = sseHandler.AiMsgFinishStep()\n\tif stopKind != uctypes.StopKindToolUse {\n\t\t_ = sseHandler.AiMsgFinish(finishReason, nil)\n\t}\n\n\treturn stopReason, assistantMsg, nil\n}\n\nfunc extractPartialGeminiMessage(msgID string, text string) *GeminiChatMessage {\n\tif text == \"\" {\n\t\treturn nil\n\t}\n\n\treturn &GeminiChatMessage{\n\t\tMessageId: msgID,\n\t\tRole:      \"model\",\n\t\tParts: []GeminiMessagePart{\n\t\t\t{\n\t\t\t\tText: text,\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/gemini/gemini-convertmessage.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage gemini\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n)\n\n// cleanSchemaForGemini removes fields from JSON Schema that Gemini doesn't accept\n// Gemini uses a strict subset of JSON Schema and rejects fields like $schema, units, title, etc.\nfunc cleanSchemaForGemini(schema map[string]any) map[string]any {\n\tif schema == nil {\n\t\treturn nil\n\t}\n\n\tcleaned := make(map[string]any)\n\n\t// Fields that Gemini accepts in the root schema\n\tallowedRootFields := map[string]bool{\n\t\t\"type\":        true,\n\t\t\"properties\":  true,\n\t\t\"required\":    true,\n\t\t\"description\": true,\n\t\t\"items\":       true,\n\t\t\"enum\":        true,\n\t\t\"format\":      true,\n\t\t\"minimum\":     true,\n\t\t\"maximum\":     true,\n\t\t\"pattern\":     true,\n\t\t\"default\":     true,\n\t}\n\n\tfor key, value := range schema {\n\t\tif !allowedRootFields[key] {\n\t\t\t// Skip fields like $schema, title, units, definitions, $ref, etc.\n\t\t\tcontinue\n\t\t}\n\n\t\t// Recursively clean nested schemas\n\t\tswitch key {\n\t\tcase \"properties\":\n\t\t\tif props, ok := value.(map[string]any); ok {\n\t\t\t\tcleanedProps := make(map[string]any)\n\t\t\t\tfor propName, propValue := range props {\n\t\t\t\t\tif propSchema, ok := propValue.(map[string]any); ok {\n\t\t\t\t\t\tcleanedProps[propName] = cleanSchemaForGemini(propSchema)\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Preserve non-map property values\n\t\t\t\t\t\tcleanedProps[propName] = propValue\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcleaned[key] = cleanedProps\n\t\t\t}\n\t\tcase \"items\":\n\t\t\tif items, ok := value.(map[string]any); ok {\n\t\t\t\tcleaned[key] = cleanSchemaForGemini(items)\n\t\t\t} else {\n\t\t\t\tcleaned[key] = value\n\t\t\t}\n\t\tdefault:\n\t\t\tcleaned[key] = value\n\t\t}\n\t}\n\n\treturn cleaned\n}\n\n// ConvertToolDefinitionToGemini converts a Wave ToolDefinition to Gemini format\nfunc ConvertToolDefinitionToGemini(tool uctypes.ToolDefinition) GeminiFunctionDeclaration {\n\t// Clean the schema to remove fields that Gemini doesn't accept\n\tcleanedSchema := cleanSchemaForGemini(tool.InputSchema)\n\n\treturn GeminiFunctionDeclaration{\n\t\tName:        tool.Name,\n\t\tDescription: tool.Description,\n\t\tParameters:  cleanedSchema,\n\t}\n}\n\n// convertFileAIMessagePart converts a file AIMessagePart to Gemini format\nfunc convertFileAIMessagePart(part uctypes.AIMessagePart) (*GeminiMessagePart, error) {\n\tif part.Type != uctypes.AIMessagePartTypeFile {\n\t\treturn nil, fmt.Errorf(\"convertFileAIMessagePart expects 'file' type, got '%s'\", part.Type)\n\t}\n\tif part.MimeType == \"\" {\n\t\treturn nil, fmt.Errorf(\"file part missing mimetype\")\n\t}\n\n\t// Handle different file types\n\tswitch {\n\tcase strings.HasPrefix(part.MimeType, \"image/\"):\n\t\t// For images, we need base64 data\n\t\tvar base64Data string\n\t\tif len(part.Data) > 0 {\n\t\t\tbase64Data = base64.StdEncoding.EncodeToString(part.Data)\n\t\t} else if part.URL != \"\" {\n\t\t\t// If URL is provided, it should be a data URL\n\t\t\tif strings.HasPrefix(part.URL, \"data:\") {\n\t\t\t\t// Extract base64 data from data URL\n\t\t\t\tparts := strings.SplitN(part.URL, \",\", 2)\n\t\t\t\tif len(parts) == 2 {\n\t\t\t\t\tbase64Data = parts[1]\n\t\t\t\t} else {\n\t\t\t\t\treturn nil, fmt.Errorf(\"invalid data URL format\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\treturn nil, fmt.Errorf(\"dropping image with non-data URL (must be fetched and converted to base64)\")\n\t\t\t}\n\t\t} else {\n\t\t\treturn nil, fmt.Errorf(\"image file part missing data\")\n\t\t}\n\n\t\treturn &GeminiMessagePart{\n\t\t\tInlineData: &GeminiInlineData{\n\t\t\t\tMimeType: part.MimeType,\n\t\t\t\tData:     base64Data,\n\t\t\t},\n\t\t\tFileName:   part.FileName,\n\t\t\tPreviewUrl: part.PreviewUrl,\n\t\t}, nil\n\n\tcase part.MimeType == \"application/pdf\":\n\t\t// Handle PDFs - Gemini supports base64 data for PDFs\n\t\tif len(part.Data) == 0 {\n\t\t\tif part.URL != \"\" {\n\t\t\t\treturn nil, fmt.Errorf(\"dropping PDF with URL (must be fetched and converted to base64 data)\")\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"PDF file part missing data\")\n\t\t}\n\n\t\t// Convert raw data to base64\n\t\tbase64Data := base64.StdEncoding.EncodeToString(part.Data)\n\n\t\treturn &GeminiMessagePart{\n\t\t\tInlineData: &GeminiInlineData{\n\t\t\t\tMimeType: \"application/pdf\",\n\t\t\t\tData:     base64Data,\n\t\t\t},\n\t\t\tFileName:   part.FileName,\n\t\t\tPreviewUrl: part.PreviewUrl,\n\t\t}, nil\n\n\tcase part.MimeType == \"text/plain\":\n\t\ttextData, err := aiutil.ExtractTextData(part.Data, part.URL)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tformattedText := aiutil.FormatAttachedTextFile(part.FileName, textData)\n\t\treturn &GeminiMessagePart{\n\t\t\tText: formattedText,\n\t\t}, nil\n\n\tcase part.MimeType == \"directory\":\n\t\tvar jsonContent string\n\t\tif len(part.Data) > 0 {\n\t\t\tjsonContent = string(part.Data)\n\t\t} else {\n\t\t\treturn nil, fmt.Errorf(\"directory listing part missing data\")\n\t\t}\n\n\t\tformattedText := aiutil.FormatAttachedDirectoryListing(part.FileName, jsonContent)\n\t\treturn &GeminiMessagePart{\n\t\t\tText: formattedText,\n\t\t}, nil\n\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"dropping file with unsupported mimetype '%s' (Gemini supports images, PDFs, text/plain, and directories)\", part.MimeType)\n\t}\n}\n\n// ConvertAIMessageToGeminiChatMessage converts an AIMessage to GeminiChatMessage\n// These messages are ALWAYS role \"user\"\nfunc ConvertAIMessageToGeminiChatMessage(aiMsg uctypes.AIMessage) (*GeminiChatMessage, error) {\n\tif err := aiMsg.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid AIMessage: %w\", err)\n\t}\n\n\tvar parts []GeminiMessagePart\n\n\tfor i, part := range aiMsg.Parts {\n\t\tswitch part.Type {\n\t\tcase uctypes.AIMessagePartTypeText:\n\t\t\tif part.Text == \"\" {\n\t\t\t\treturn nil, fmt.Errorf(\"part %d: text type requires non-empty text field\", i)\n\t\t\t}\n\t\t\tparts = append(parts, GeminiMessagePart{\n\t\t\t\tText: part.Text,\n\t\t\t})\n\n\t\tcase uctypes.AIMessagePartTypeFile:\n\t\t\tgeminiPart, err := convertFileAIMessagePart(part)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"gemini: %v\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparts = append(parts, *geminiPart)\n\n\t\tdefault:\n\t\t\t// Drop unknown part types\n\t\t\tlog.Printf(\"gemini: dropping unknown part type '%s'\", part.Type)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\treturn &GeminiChatMessage{\n\t\tMessageId: aiMsg.MessageId,\n\t\tRole:      \"user\",\n\t\tParts:     parts,\n\t}, nil\n}\n\n// ConvertToolResultsToGeminiChatMessage converts AIToolResult slice to GeminiChatMessage\nfunc ConvertToolResultsToGeminiChatMessage(toolResults []uctypes.AIToolResult) (*GeminiChatMessage, error) {\n\tif len(toolResults) == 0 {\n\t\treturn nil, fmt.Errorf(\"toolResults cannot be empty\")\n\t}\n\n\tvar parts []GeminiMessagePart\n\n\tfor _, result := range toolResults {\n\t\tif result.ToolUseID == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"tool result missing ToolUseID\")\n\t\t}\n\n\t\tresponse := make(map[string]any)\n\t\tvar nestedParts []GeminiMessagePart\n\n\t\tif result.ErrorText != \"\" {\n\t\t\tresponse[\"ok\"] = false\n\t\t\tresponse[\"error\"] = result.ErrorText\n\t\t} else if strings.HasPrefix(result.Text, \"data:\") {\n\t\t\tmimeType, base64Data, err := utilfn.DecodeDataURL(result.Text)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"gemini: failed to decode data URL in tool result: %v\\n\", err)\n\t\t\t\tresponse[\"ok\"] = false\n\t\t\t\tresponse[\"error\"] = fmt.Sprintf(\"failed to decode data URL: %v\", err)\n\t\t\t} else if strings.HasPrefix(mimeType, \"image/\") {\n\t\t\t\t// For image data URLs, use multimodal function response (Gemini 3 Pro+)\n\t\t\t\tdisplayName := fmt.Sprintf(\"result_%s.%s\", result.ToolUseID[:8], strings.TrimPrefix(mimeType, \"image/\"))\n\t\t\t\tresponse[\"ok\"] = true\n\t\t\t\tresponse[\"image\"] = map[string]string{\"$ref\": displayName}\n\n\t\t\t\t// Add the image data as a nested part\n\t\t\t\tnestedParts = append(nestedParts, GeminiMessagePart{\n\t\t\t\t\tInlineData: &GeminiInlineData{\n\t\t\t\t\t\tMimeType:    mimeType,\n\t\t\t\t\t\tData:        base64.StdEncoding.EncodeToString(base64Data),\n\t\t\t\t\t\tDisplayName: displayName,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"gemini: unsupported data URL mimetype in tool result: %s\\n\", mimeType)\n\t\t\t\tresponse[\"ok\"] = false\n\t\t\t\tresponse[\"error\"] = fmt.Sprintf(\"unsupported data URL mimetype: %s\", mimeType)\n\t\t\t}\n\t\t} else {\n\t\t\tresponse[\"ok\"] = true\n\t\t\tresponse[\"result\"] = result.Text\n\t\t}\n\n\t\tparts = append(parts, GeminiMessagePart{\n\t\t\tFunctionResponse: &GeminiFunctionResponse{\n\t\t\t\tName:     result.ToolName,\n\t\t\t\tResponse: response,\n\t\t\t\tParts:    nestedParts,\n\t\t\t},\n\t\t})\n\t}\n\n\treturn &GeminiChatMessage{\n\t\tMessageId: uuid.New().String(),\n\t\tRole:      \"user\", // Function responses are sent as user messages\n\t\tParts:     parts,\n\t}, nil\n}\n\n// convertContentPartToUIPart converts a Gemini content part to UIMessagePart\nfunc convertContentPartToUIPart(part GeminiMessagePart, role string) []uctypes.UIMessagePart {\n\tvar uiParts []uctypes.UIMessagePart\n\n\tif part.Text != \"\" {\n\t\tif found, dataPart := aiutil.ConvertDataUserFile(part.Text); found {\n\t\t\tif dataPart != nil {\n\t\t\t\tuiParts = append(uiParts, *dataPart)\n\t\t\t}\n\t\t} else {\n\t\t\tuiParts = append(uiParts, uctypes.UIMessagePart{\n\t\t\t\tType: \"text\",\n\t\t\t\tText: part.Text,\n\t\t\t})\n\t\t}\n\t}\n\n\tif part.InlineData != nil && role == \"user\" {\n\t\t// Show uploaded files in user messages\n\t\tvar mimeType string\n\t\tif strings.HasPrefix(part.InlineData.MimeType, \"image/\") {\n\t\t\tmimeType = \"image/*\"\n\t\t} else {\n\t\t\tmimeType = part.InlineData.MimeType\n\t\t}\n\n\t\tuiParts = append(uiParts, uctypes.UIMessagePart{\n\t\t\tType: \"data-userfile\",\n\t\t\tData: uctypes.UIMessageDataUserFile{\n\t\t\t\tFileName:   part.FileName,\n\t\t\t\tMimeType:   mimeType,\n\t\t\t\tPreviewUrl: part.PreviewUrl,\n\t\t\t},\n\t\t})\n\t}\n\n\t// Tool use parts are handled separately by the backend\n\tif part.ToolUseData != nil {\n\t\tuiParts = append(uiParts, uctypes.UIMessagePart{\n\t\t\tType: \"data-tooluse\",\n\t\t\tID:   part.ToolUseData.ToolCallId,\n\t\t\tData: *part.ToolUseData,\n\t\t})\n\t}\n\n\treturn uiParts\n}\n\n// convertToUIMessage converts a GeminiChatMessage to a UIMessage\nfunc (m *GeminiChatMessage) convertToUIMessage() *uctypes.UIMessage {\n\tvar parts []uctypes.UIMessagePart\n\n\tfor _, part := range m.Parts {\n\t\t// Skip function responses - they're not shown in UI\n\t\tif part.FunctionResponse != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tpartUIParts := convertContentPartToUIPart(part, m.Role)\n\t\tparts = append(parts, partUIParts...)\n\t}\n\n\tif len(parts) == 0 {\n\t\treturn nil\n\t}\n\n\t// Convert Gemini role to standard role\n\trole := m.Role\n\tif role == \"model\" {\n\t\trole = \"assistant\"\n\t}\n\n\treturn &uctypes.UIMessage{\n\t\tID:    m.MessageId,\n\t\tRole:  role,\n\t\tParts: parts,\n\t}\n}\n\n// ConvertAIChatToUIChat converts an AIChat to a UIChat for Gemini\nfunc ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) {\n\tif aiChat.APIType != uctypes.APIType_GoogleGemini {\n\t\treturn nil, fmt.Errorf(\"APIType must be '%s', got '%s'\", uctypes.APIType_GoogleGemini, aiChat.APIType)\n\t}\n\n\tuiMessages := make([]uctypes.UIMessage, 0, len(aiChat.NativeMessages))\n\tfor i, nativeMsg := range aiChat.NativeMessages {\n\t\tgeminiMsg, ok := nativeMsg.(*GeminiChatMessage)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"message %d: expected *GeminiChatMessage, got %T\", i, nativeMsg)\n\t\t}\n\t\tuiMsg := geminiMsg.convertToUIMessage()\n\t\tif uiMsg != nil {\n\t\t\tuiMessages = append(uiMessages, *uiMsg)\n\t\t}\n\t}\n\n\treturn &uctypes.UIChat{\n\t\tChatId:     aiChat.ChatId,\n\t\tAPIType:    aiChat.APIType,\n\t\tModel:      aiChat.Model,\n\t\tAPIVersion: aiChat.APIVersion,\n\t\tMessages:   uiMessages,\n\t}, nil\n}\n\n// GetFunctionCallInputByToolCallId returns the function call input associated with the given tool call ID\nfunc GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput {\n\tfor _, nativeMsg := range aiChat.NativeMessages {\n\t\tgeminiMsg, ok := nativeMsg.(*GeminiChatMessage)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, part := range geminiMsg.Parts {\n\t\t\tif part.FunctionCall != nil && part.ToolUseData != nil && part.ToolUseData.ToolCallId == toolCallId {\n\t\t\t\t// Convert args map to JSON string\n\t\t\t\targsBytes, err := json.Marshal(part.FunctionCall.Args)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Printf(\"gemini: error marshaling function call args: %v\", err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\treturn &uctypes.AIFunctionCallInput{\n\t\t\t\t\tCallId:      toolCallId,\n\t\t\t\t\tName:        part.FunctionCall.Name,\n\t\t\t\t\tArguments:   string(argsBytes),\n\t\t\t\t\tToolUseData: part.ToolUseData,\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// UpdateToolUseData updates the tool use data for a specific tool call in the chat\nfunc UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error {\n\tchat := chatstore.DefaultChatStore.Get(chatId)\n\tif chat == nil {\n\t\treturn fmt.Errorf(\"chat not found: %s\", chatId)\n\t}\n\n\tfor _, genMsg := range chat.NativeMessages {\n\t\tchatMsg, ok := genMsg.(*GeminiChatMessage)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tfor i, part := range chatMsg.Parts {\n\t\t\tif part.FunctionCall != nil && part.ToolUseData != nil && part.ToolUseData.ToolCallId == toolCallId {\n\t\t\t\t// Update the message with new tool use data\n\t\t\t\tupdatedMsg := &GeminiChatMessage{\n\t\t\t\t\tMessageId: chatMsg.MessageId,\n\t\t\t\t\tRole:      chatMsg.Role,\n\t\t\t\t\tParts:     make([]GeminiMessagePart, len(chatMsg.Parts)),\n\t\t\t\t\tUsage:     chatMsg.Usage,\n\t\t\t\t}\n\t\t\t\tcopy(updatedMsg.Parts, chatMsg.Parts)\n\t\t\t\tupdatedMsg.Parts[i].ToolUseData = &toolUseData\n\n\t\t\t\taiOpts := &uctypes.AIOptsType{\n\t\t\t\t\tAPIType:    chat.APIType,\n\t\t\t\t\tModel:      chat.Model,\n\t\t\t\t\tAPIVersion: chat.APIVersion,\n\t\t\t\t}\n\n\t\t\t\treturn chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"tool call with ID %s not found in chat %s\", toolCallId, chatId)\n}\n\nfunc RemoveToolUseCall(chatId string, toolCallId string) error {\n\tchat := chatstore.DefaultChatStore.Get(chatId)\n\tif chat == nil {\n\t\treturn fmt.Errorf(\"chat not found: %s\", chatId)\n\t}\n\n\tfor _, genMsg := range chat.NativeMessages {\n\t\tchatMsg, ok := genMsg.(*GeminiChatMessage)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tpartIndex := -1\n\t\tfor i, part := range chatMsg.Parts {\n\t\t\tif part.FunctionCall != nil && part.ToolUseData != nil && part.ToolUseData.ToolCallId == toolCallId {\n\t\t\t\tpartIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif partIndex == -1 {\n\t\t\tcontinue\n\t\t}\n\n\t\tupdatedMsg := &GeminiChatMessage{\n\t\t\tMessageId: chatMsg.MessageId,\n\t\t\tRole:      chatMsg.Role,\n\t\t\tParts:     slices.Delete(slices.Clone(chatMsg.Parts), partIndex, partIndex+1),\n\t\t\tUsage:     chatMsg.Usage,\n\t\t}\n\n\t\tif len(updatedMsg.Parts) == 0 {\n\t\t\tchatstore.DefaultChatStore.RemoveMessage(chatId, chatMsg.MessageId)\n\t\t} else {\n\t\t\taiOpts := &uctypes.AIOptsType{\n\t\t\t\tAPIType:    chat.APIType,\n\t\t\t\tModel:      chat.Model,\n\t\t\t\tAPIVersion: chat.APIVersion,\n\t\t\t}\n\t\t\tif err := chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/aiusechat/gemini/gemini-types.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage gemini\n\nimport (\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n)\n\nconst (\n\tGeminiDefaultMaxTokens = 8192\n)\n\n// GeminiChatMessage represents a stored chat message for Gemini backend\ntype GeminiChatMessage struct {\n\tMessageId string               `json:\"messageid\"`\n\tRole      string               `json:\"role\"` // \"user\", \"model\"\n\tParts     []GeminiMessagePart  `json:\"parts\"`\n\tUsage     *GeminiUsageMetadata `json:\"usage,omitempty\"`\n}\n\nfunc (m *GeminiChatMessage) GetMessageId() string {\n\treturn m.MessageId\n}\n\nfunc (m *GeminiChatMessage) GetRole() string {\n\treturn m.Role\n}\n\nfunc (m *GeminiChatMessage) GetUsage() *uctypes.AIUsage {\n\tif m.Usage == nil {\n\t\treturn nil\n\t}\n\treturn &uctypes.AIUsage{\n\t\tAPIType:      uctypes.APIType_GoogleGemini,\n\t\tModel:        m.Usage.Model,\n\t\tInputTokens:  m.Usage.PromptTokenCount,\n\t\tOutputTokens: m.Usage.CandidatesTokenCount,\n\t}\n}\n\n// GeminiMessagePart represents different types of content in a message\ntype GeminiMessagePart struct {\n\t// Text part\n\tText string `json:\"text,omitempty\"`\n\n\t// Inline data (images, PDFs, etc.)\n\tInlineData *GeminiInlineData `json:\"inlineData,omitempty\"`\n\n\t// File data (for uploaded files)\n\tFileData *GeminiFileData `json:\"fileData,omitempty\"`\n\n\t// Function call (assistant calling a tool)\n\tFunctionCall *GeminiFunctionCall `json:\"functionCall,omitempty\"`\n\n\t// Function response (result of tool execution)\n\tFunctionResponse *GeminiFunctionResponse `json:\"functionResponse,omitempty\"`\n\n\t// Thought signature (for thinking models - applies to text and function calls)\n\tThoughtSignature string `json:\"thoughtSignature,omitempty\"`\n\n\t// Internal fields (not sent to API)\n\tPreviewUrl  string                        `json:\"previewurl,omitempty\"`  // internal field\n\tFileName    string                        `json:\"filename,omitempty\"`    // internal field\n\tToolUseData *uctypes.UIMessageDataToolUse `json:\"toolusedata,omitempty\"` // internal field\n}\n\n// Clean removes internal fields before sending to API\nfunc (p *GeminiMessagePart) Clean() *GeminiMessagePart {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcleaned := *p\n\tcleaned.PreviewUrl = \"\"\n\tcleaned.FileName = \"\"\n\tcleaned.ToolUseData = nil\n\treturn &cleaned\n}\n\n// GeminiInlineData represents inline binary data\ntype GeminiInlineData struct {\n\tMimeType    string `json:\"mimeType\"`\n\tData        string `json:\"data\"` // base64 encoded\n\tDisplayName string `json:\"displayName,omitempty\"` // for multimodal function responses\n}\n\n// GeminiFileData represents uploaded file reference\ntype GeminiFileData struct {\n\tMimeType    string `json:\"mimeType\"`\n\tFileUri     string `json:\"fileUri\"` // gs:// URI from file upload\n\tDisplayName string `json:\"displayName,omitempty\"` // for multimodal function responses\n}\n\n// GeminiFunctionCall represents a function call from the model\ntype GeminiFunctionCall struct {\n\tName string         `json:\"name\"`\n\tArgs map[string]any `json:\"args,omitempty\"`\n}\n\n// GeminiFunctionResponse represents a function execution result\ntype GeminiFunctionResponse struct {\n\tName     string              `json:\"name\"`\n\tResponse map[string]any      `json:\"response\"`\n\tParts    []GeminiMessagePart `json:\"parts,omitempty\"` // nested parts for multimodal content (Gemini 3 Pro and later)\n}\n\n// GeminiUsageMetadata represents token usage\ntype GeminiUsageMetadata struct {\n\tModel                   string `json:\"model,omitempty\"` // internal field\n\tPromptTokenCount        int    `json:\"promptTokenCount\"`\n\tCachedContentTokenCount int    `json:\"cachedContentTokenCount,omitempty\"`\n\tCandidatesTokenCount    int    `json:\"candidatesTokenCount\"`\n\tTotalTokenCount         int    `json:\"totalTokenCount\"`\n}\n\n// GeminiThinkingConfig represents thinking configuration for Gemini 3+ models\ntype GeminiThinkingConfig struct {\n\tThinkingLevel string `json:\"thinkingLevel,omitempty\"` // \"low\" or \"high\"\n}\n\n// GeminiGenerationConfig represents generation parameters\ntype GeminiGenerationConfig struct {\n\tTemperature     float32               `json:\"temperature,omitempty\"`\n\tTopP            float32               `json:\"topP,omitempty\"`\n\tTopK            int32                 `json:\"topK,omitempty\"`\n\tCandidateCount  int32                 `json:\"candidateCount,omitempty\"`\n\tMaxOutputTokens int32                 `json:\"maxOutputTokens,omitempty\"`\n\tStopSequences   []string              `json:\"stopSequences,omitempty\"`\n\tThinkingConfig  *GeminiThinkingConfig `json:\"thinkingConfig,omitempty\"` // for Gemini 3+ models\n}\n\n// GeminiTool represents a function tool definition\ntype GeminiTool struct {\n\tFunctionDeclarations []GeminiFunctionDeclaration `json:\"functionDeclarations,omitempty\"`\n\tGoogleSearch         *GeminiGoogleSearch         `json:\"googleSearch,omitempty\"`\n}\n\n// GeminiGoogleSearch represents Google Search configuration (empty for default)\ntype GeminiGoogleSearch struct{}\n\n// GeminiFunctionDeclaration represents a function schema\ntype GeminiFunctionDeclaration struct {\n\tName        string         `json:\"name\"`\n\tDescription string         `json:\"description\"`\n\tParameters  map[string]any `json:\"parameters,omitempty\"`\n}\n\n// GeminiToolConfig represents tool choice configuration\ntype GeminiToolConfig struct {\n\tFunctionCallingConfig *GeminiFunctionCallingConfig `json:\"functionCallingConfig,omitempty\"`\n}\n\n// GeminiFunctionCallingConfig represents function calling configuration\ntype GeminiFunctionCallingConfig struct {\n\tMode string `json:\"mode,omitempty\"` // \"AUTO\", \"ANY\", \"NONE\"\n}\n\n// GeminiContent represents a content message for the API\ntype GeminiContent struct {\n\tRole  string              `json:\"role,omitempty\"`\n\tParts []GeminiMessagePart `json:\"parts\"`\n}\n\n// Clean removes internal fields from all parts\nfunc (c *GeminiContent) Clean() *GeminiContent {\n\tif c == nil {\n\t\treturn nil\n\t}\n\tcleaned := &GeminiContent{\n\t\tRole:  c.Role,\n\t\tParts: make([]GeminiMessagePart, len(c.Parts)),\n\t}\n\tfor i, part := range c.Parts {\n\t\tcleaned.Parts[i] = *part.Clean()\n\t}\n\treturn cleaned\n}\n\n// GeminiRequest represents a request to the Gemini API\ntype GeminiRequest struct {\n\tContents          []GeminiContent         `json:\"contents\"`\n\tSystemInstruction *GeminiContent          `json:\"systemInstruction,omitempty\"`\n\tGenerationConfig  *GeminiGenerationConfig `json:\"generationConfig,omitempty\"`\n\tTools             []GeminiTool            `json:\"tools,omitempty\"`\n\tToolConfig        *GeminiToolConfig       `json:\"toolConfig,omitempty\"`\n}\n\n// GeminiStreamResponse represents a streaming response chunk\ntype GeminiStreamResponse struct {\n\tCandidates        []GeminiCandidate        `json:\"candidates,omitempty\"`\n\tPromptFeedback    *GeminiPromptFeedback    `json:\"promptFeedback,omitempty\"`\n\tUsageMetadata     *GeminiUsageMetadata     `json:\"usageMetadata,omitempty\"`\n\tGroundingMetadata *GeminiGroundingMetadata `json:\"groundingMetadata,omitempty\"`\n}\n\n// GeminiCandidate represents a candidate response\ntype GeminiCandidate struct {\n\tContent           *GeminiContent           `json:\"content,omitempty\"`\n\tFinishReason      string                   `json:\"finishReason,omitempty\"`\n\tIndex             int                      `json:\"index,omitempty\"`\n\tSafetyRatings     []GeminiSafetyRating     `json:\"safetyRatings,omitempty\"`\n\tGroundingMetadata *GeminiGroundingMetadata `json:\"groundingMetadata,omitempty\"`\n}\n\n// GeminiSafetyRating represents a safety rating\ntype GeminiSafetyRating struct {\n\tCategory    string `json:\"category\"`\n\tProbability string `json:\"probability\"`\n}\n\n// GeminiPromptFeedback represents feedback about the prompt\ntype GeminiPromptFeedback struct {\n\tBlockReason   string               `json:\"blockReason,omitempty\"`\n\tSafetyRatings []GeminiSafetyRating `json:\"safetyRatings,omitempty\"`\n}\n\n// GeminiErrorResponse represents an error response\ntype GeminiErrorResponse struct {\n\tError *GeminiError `json:\"error,omitempty\"`\n}\n\n// GeminiError represents an error\ntype GeminiError struct {\n\tCode    int    `json:\"code\"`\n\tMessage string `json:\"message\"`\n\tStatus  string `json:\"status,omitempty\"`\n}\n\n// GeminiGroundingMetadata represents grounding metadata with web search results\ntype GeminiGroundingMetadata struct {\n\tWebSearchQueries []string `json:\"webSearchQueries,omitempty\"`\n}\n"
  },
  {
    "path": "pkg/aiusechat/google/doc.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// Package google provides Google Generative AI integration for WaveTerm.\n//\n// This package implements file summarization using Google's Gemini models.\n// Unlike other AI provider implementations in the aiusechat package, this\n// package does NOT implement full SSE streaming. It uses a simple\n// request-response API for file summarization.\n//\n// # Supported File Types\n//\n// The package supports the same file types as defined in wshcmd-ai.go:\n//   - Images (PNG, JPEG, etc.): up to 7MB\n//   - PDFs: up to 5MB\n//   - Text files: up to 200KB\n//\n// Binary files are rejected unless they are recognized as images or PDFs.\n//\n// # Usage\n//\n// To summarize a file:\n//\n//\tctx := context.Background()\n//\tsummary, usage, err := google.SummarizeFile(ctx, \"/path/to/file.txt\", google.SummarizeOpts{\n//\t\tAPIKey: \"YOUR_API_KEY\",\n//\t\tMode:   google.ModeQuickSummary,\n//\t})\n//\tif err != nil {\n//\t    log.Fatal(err)\n//\t}\n//\tfmt.Println(\"Summary:\", summary)\n//\tfmt.Printf(\"Tokens used: %d\\n\", usage.TotalTokenCount)\n//\n// # Configuration\n//\n// The summarization behavior can be customized by modifying the constants:\n//   - SummarizeModel: The Gemini model to use (default: \"gemini-2.5-flash-lite\")\n//   - SummarizePrompt: The prompt sent to the model\n//   - GoogleAPIURL: The base URL for the API (for reference, not currently used by the SDK)\npackage google\n"
  },
  {
    "path": "pkg/aiusechat/google/google-summarize.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage google\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/google/generative-ai-go/genai\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"google.golang.org/api/option\"\n)\n\nconst (\n\t// GoogleAPIURL is the base URL for the Google Generative AI API\n\tGoogleAPIURL = \"https://generativelanguage.googleapis.com\"\n\n\t// SummarizeModel is the model used for file summarization\n\tSummarizeModel = \"gemini-2.5-flash-lite\"\n\n\t// Mode constants\n\tModeQuickSummary = \"quick\"\n\tModeUseful       = \"useful\"\n\tModePublicCode   = \"publiccode\"\n\tModeHTMLContent  = \"htmlcontent\"\n\tModeHTMLFull     = \"htmlfull\"\n\n\t// SummarizePrompt is the default prompt used for file summarization\n\tSummarizePrompt = \"Please provide a concise summary of this file. Include the main topics, key points, and any notable information.\"\n\n\t// QuickSummaryPrompt is the prompt for quick file summaries\n\tQuickSummaryPrompt = `Summarize the following file for another AI agent that is deciding which files to read.\n\nIf the content is HTML or web page markup, ignore layout elements such as headers, footers, sidebars, navigation menus, cookie banners, pop-ups, ads, and search boxes. \nFocus only on the visible main content that describes the page’s subject or purpose.\n\nKeep the summary extremely concise — one or two sentences at most.\nExplain what the file appears to be and its main purpose or contents.\nIf it's code, mention the language and what it implements (e.g., a CLI, library, test, or config).\nAvoid speculation or verbose explanations.\nDo not include markdown, bullets, or formatting — just a plain text summary.`\n\n\t// UsefulSummaryPrompt is the prompt for useful file summaries with more detail\n\tUsefulSummaryPrompt = `You are summarizing a single file so that another AI agent can understand its purpose and structure.\n\nIf the content is HTML or web page markup, ignore layout elements such as headers, footers, sidebars, navigation menus, cookie banners, pop-ups, ads, and search boxes. \nFocus only on the visible main content that describes the page’s subject or purpose.\n\nStart with a short overview (2–4 sentences) describing the overall purpose of the file.\nIf the file is large (more than about 150 lines) or has multiple major sections or functions,\nthen briefly summarize each major section (1–2 sentences per section) and include an approximate line range in parentheses like \"(lines 80–220)\".\n\nKeep section summaries extremely concise — only include the most important parts or entry points.\nIf it's code, mention key functions or classes and what they do.\nIf it's documentation, describe key topics or sections.\nIf it's a data or config file, summarize the structure and purpose of the values.\n\nNever produce more text than would fit comfortably on one screen (roughly under 200 words total).\nPlain text only — no lists, no markdown, no JSON.`\n\n\t// PublicCodeSummaryPrompt is the prompt for public API summaries\n\tPublicCodeSummaryPrompt = `You are summarizing a SINGLE source file to expose its PUBLIC API to another AI client.\n\nGOAL\nProduce a compact, header-like listing of all PUBLIC symbols callers would use.\n\nOUTPUT FORMAT (plain text only; no bullets/markdown/JSON):\n1) Public data structures required by public functions (types/structs/interfaces/enums/const groups):\n\t  <native one-line comment> (lines A–B)\n\t  <exact single-line declaration>\n\n2) Public functions/methods in order of appearance:\n\t  <native one-line comment> (lines A–B)\n\t  <exact single-line signature>\n\nRULES\n- PUBLIC means exported/externally visible for the language (Go: capitalized; Java/C#/TS: public; Rust: pub; Python: not underscore-prefixed, etc.).\n- Include ALL public functions/methods.\n- Include public data structures ONLY if referenced by any public function OR commonly constructed/consumed by callers.\n- For multi-line declarations, emit a single-line canonical form by collapsing internal whitespace while preserving tokens and order.\n- The one-line comment is either a compressed docstring or, if absent, a concise inferred purpose (≤ 20 words).\n- Include approximate line ranges as \"(lines A–B)\".\n- Skip private helpers, tests, examples, and internal-only constants.\n- Preserve generics/annotations/modifiers as they appear (e.g., type params, async, const, noexcept).\n- No preface or epilogue text—just the listing.\n\nEXAMPLE STYLE (illustrative; use the target language's comment syntax):\n// Configuration for the proxy (lines 10–42)\ntype ProxyConfig struct { ... }\n\n// Creates and configures a new proxy instance (lines 60–92)\nfunc NewProxy(cfg ProxyConfig) (*Proxy, error)\n\n// Handles a single HTTP request (lines 95–168)\nfunc (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request)`\n\n\t// HTMLContentPrompt is the prompt for converting HTML to content-focused Markdown\n\tHTMLContentPrompt = `Convert the following stripped HTML into clean Markdown for READING CONTENT ONLY.\n\n- Output Markdown ONLY (no explanations, no JSON, no code fences).\n- Keep document title as a single H1 if present (from <title> or first <h1>).\n- Preserve headings (h1–h6), paragraphs, strong/emphasis, inline code.\n- Convert <a> to [text](absolute_url). If href is relative, resolve against BASE_URL: {{BASE_URL}}. Do not output javascript:void links.\n- Preserve lists (ul/ol, nested), blockquotes, and code blocks (<pre><code>) as fenced code (include language if obvious).\n- Convert tables to Markdown tables; keep header row; include up to 50 data rows, then append \"… (more rows)\".\n- Keep images ONLY if alt text is descriptive; render as ![alt](absolute_url). Skip tracking pixels and decorative images.\n- Discard navigation, site header/footer, sidebars, cookie banners, search bars, newsletter/signup, social share, repetitive link clouds, and legal boilerplate unless they are the ONLY content.\n- Preserve in-page structure order; do not invent content; do not summarize prose—extract faithfully.\n- Normalize whitespace, collapse repeated blank lines to one.\n`\n\n\t// HTMLFullPrompt is the prompt for converting HTML to navigation-focused Markdown\n\tHTMLFullPrompt = `Convert the following stripped HTML into Markdown optimized for SITE NAVIGATION.\n\n- Output Markdown ONLY (no explanations, no JSON, no code fences).\n- Start with a top-level title (from <title> or first <h1>) as H1.\n- Include primary navigation as a section \"## Navigation\" with bullet lists of top-level links (use visible link text; dedupe exact duplicates).\n- Include secondary nav/footer links under \"## Footer Links\".\n- Then extract the main page content as Markdown (headings, paragraphs, lists, blockquotes, code blocks).\n- Convert <a> to [text](absolute_url). If href is relative, resolve against BASE_URL: {{BASE_URL}}.\n- Convert tables to Markdown tables; keep header + up to 50 rows, then \"… (more rows)\".\n- Keep images with meaningful alt as ![alt](absolute_url); otherwise skip.\n- Preserve order as it appears in the page; do not summarize prose—extract faithfully.\n- Normalize whitespace; collapse repeated blank lines.`\n)\n\n// SummarizeOpts contains options for file summarization\ntype SummarizeOpts struct {\n\tAPIKey string\n\tMode   string\n}\n\n// GoogleUsage represents token usage information from Google's Generative AI API\ntype GoogleUsage struct {\n\tPromptTokenCount        int32 `json:\"prompt_token_count\"`\n\tCachedContentTokenCount int32 `json:\"cached_content_token_count\"`\n\tCandidatesTokenCount    int32 `json:\"candidates_token_count\"`\n\tTotalTokenCount         int32 `json:\"total_token_count\"`\n}\n\nfunc detectMimeType(data []byte) string {\n\tmimeType := http.DetectContentType(data)\n\treturn strings.Split(mimeType, \";\")[0]\n}\n\nfunc getMaxFileSize(mimeType, mode string) (int, string) {\n\tif mimeType == \"application/pdf\" {\n\t\treturn 5 * 1024 * 1024, \"5MB\"\n\t}\n\tif strings.HasPrefix(mimeType, \"image/\") {\n\t\treturn 7 * 1024 * 1024, \"7MB\"\n\t}\n\tif mode == ModeHTMLContent || mode == ModeHTMLFull {\n\t\treturn 500 * 1024, \"500KB\"\n\t}\n\treturn 200 * 1024, \"200KB\"\n}\n\n// SummarizeFile reads a file and generates a summary using Google's Generative AI.\n// It supports images, PDFs, and text files based on the limits defined in wshcmd-ai.go.\n// Returns the summary text, usage information, and any error encountered.\nfunc SummarizeFile(ctx context.Context, filename string, opts SummarizeOpts) (string, *GoogleUsage, error) {\n\tif opts.Mode == \"\" {\n\t\treturn \"\", nil, fmt.Errorf(\"mode is required\")\n\t}\n\n\t// Read the file\n\tdata, err := os.ReadFile(filename)\n\tif err != nil {\n\t\treturn \"\", nil, fmt.Errorf(\"reading file: %w\", err)\n\t}\n\n\t// Detect MIME type\n\tmimeType := detectMimeType(data)\n\n\tisPDF := mimeType == \"application/pdf\"\n\tisImage := strings.HasPrefix(mimeType, \"image/\")\n\n\tif !isPDF && !isImage {\n\t\tmimeType = \"text/plain\"\n\t\tif utilfn.ContainsBinaryData(data) {\n\t\t\treturn \"\", nil, fmt.Errorf(\"file contains binary data and cannot be summarized\")\n\t\t}\n\t}\n\n\t// Validate file size\n\tmaxSize, sizeStr := getMaxFileSize(mimeType, opts.Mode)\n\tif len(data) > maxSize {\n\t\treturn \"\", nil, fmt.Errorf(\"file exceeds maximum size of %s for %s files\", sizeStr, mimeType)\n\t}\n\n\t// Create client\n\tclient, err := genai.NewClient(ctx, option.WithAPIKey(opts.APIKey))\n\tif err != nil {\n\t\treturn \"\", nil, fmt.Errorf(\"creating Google AI client: %w\", err)\n\t}\n\tdefer client.Close()\n\n\t// Create model\n\tmodel := client.GenerativeModel(SummarizeModel)\n\n\t// Select prompt based on mode\n\tvar prompt string\n\tswitch opts.Mode {\n\tcase ModeQuickSummary:\n\t\tprompt = QuickSummaryPrompt\n\tcase ModeUseful:\n\t\tprompt = UsefulSummaryPrompt\n\tcase ModePublicCode:\n\t\tprompt = PublicCodeSummaryPrompt\n\tcase ModeHTMLContent:\n\t\tprompt = HTMLContentPrompt\n\tcase ModeHTMLFull:\n\t\tprompt = HTMLFullPrompt\n\tdefault:\n\t\tprompt = SummarizePrompt\n\t}\n\n\t// Prepare the content parts\n\tvar parts []genai.Part\n\n\t// Add the prompt\n\tparts = append(parts, genai.Text(prompt))\n\n\t// Add the file content based on type\n\tif isImage {\n\t\t// For images, use Blob\n\t\tparts = append(parts, genai.Blob{\n\t\t\tMIMEType: mimeType,\n\t\t\tData:     data,\n\t\t})\n\t} else if isPDF {\n\t\t// For PDFs, use Blob\n\t\tparts = append(parts, genai.Blob{\n\t\t\tMIMEType: mimeType,\n\t\t\tData:     data,\n\t\t})\n\t} else {\n\t\t// For text files, convert to string\n\t\tparts = append(parts, genai.Text(string(data)))\n\t}\n\n\t// Generate content\n\tresp, err := model.GenerateContent(ctx, parts...)\n\tif err != nil {\n\t\treturn \"\", nil, fmt.Errorf(\"generating content: %w\", err)\n\t}\n\n\t// Check if we got any candidates\n\tif len(resp.Candidates) == 0 {\n\t\treturn \"\", nil, fmt.Errorf(\"no response candidates returned\")\n\t}\n\n\t// Extract the text from the first candidate\n\tcandidate := resp.Candidates[0]\n\tif candidate.Content == nil || len(candidate.Content.Parts) == 0 {\n\t\treturn \"\", nil, fmt.Errorf(\"no content in response\")\n\t}\n\n\tvar summary strings.Builder\n\tfor _, part := range candidate.Content.Parts {\n\t\tif textPart, ok := part.(genai.Text); ok {\n\t\t\tsummary.WriteString(string(textPart))\n\t\t}\n\t}\n\n\t// Convert usage metadata\n\tvar usage *GoogleUsage\n\tif resp.UsageMetadata != nil {\n\t\tusage = &GoogleUsage{\n\t\t\tPromptTokenCount:        resp.UsageMetadata.PromptTokenCount,\n\t\t\tCachedContentTokenCount: resp.UsageMetadata.CachedContentTokenCount,\n\t\t\tCandidatesTokenCount:    resp.UsageMetadata.CandidatesTokenCount,\n\t\t\tTotalTokenCount:         resp.UsageMetadata.TotalTokenCount,\n\t\t}\n\t}\n\n\treturn summary.String(), usage, nil\n}\n"
  },
  {
    "path": "pkg/aiusechat/google/google-summarize_test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage google\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestDetectMimeType(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tdata     []byte\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"plain text\",\n\t\t\tdata:     []byte(\"Hello, World!\"),\n\t\t\texpected: \"text/plain\",\n\t\t},\n\t\t{\n\t\t\tname:     \"empty file\",\n\t\t\tdata:     []byte{},\n\t\t\texpected: \"text/plain\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := detectMimeType(tt.data)\n\t\t\tif !containsMimeType(result, tt.expected) {\n\t\t\t\tt.Errorf(\"detectMimeType() = %v, want to contain %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc containsMimeType(got, want string) bool {\n\t// DetectContentType may return variations like \"text/plain; charset=utf-8\"\n\treturn got == want || (want == \"text/plain\" && got == \"text/plain; charset=utf-8\")\n}\n\nfunc TestSummarizeFile_FileNotFound(t *testing.T) {\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\t_, _, err := SummarizeFile(ctx, \"/nonexistent/file.txt\", SummarizeOpts{\n\t\tAPIKey: \"fake-api-key\",\n\t\tMode:   ModeQuickSummary,\n\t})\n\tif err == nil {\n\t\tt.Error(\"SummarizeFile() expected error for nonexistent file, got nil\")\n\t}\n}\n\nfunc TestSummarizeFile_BinaryFile(t *testing.T) {\n\t// Create a temporary binary file\n\ttmpDir := t.TempDir()\n\tbinFile := filepath.Join(tmpDir, \"test.bin\")\n\n\t// Create binary data (not text, image, or PDF)\n\tbinaryData := []byte{0x00, 0x01, 0x02, 0x03, 0x7F, 0x80, 0xFF}\n\tif err := os.WriteFile(binFile, binaryData, 0644); err != nil {\n\t\tt.Fatalf(\"Failed to create test file: %v\", err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\t_, _, err := SummarizeFile(ctx, binFile, SummarizeOpts{\n\t\tAPIKey: \"fake-api-key\",\n\t\tMode:   ModeQuickSummary,\n\t})\n\tif err == nil {\n\t\tt.Error(\"SummarizeFile() expected error for binary file, got nil\")\n\t}\n\tif err != nil && !containsString(err.Error(), \"binary data\") {\n\t\tt.Errorf(\"SummarizeFile() error = %v, want error containing 'binary data'\", err)\n\t}\n}\n\nfunc TestSummarizeFile_FileTooLarge(t *testing.T) {\n\t// Create a temporary text file that exceeds the limit\n\ttmpDir := t.TempDir()\n\ttextFile := filepath.Join(tmpDir, \"large.txt\")\n\n\t// Create a file larger than 200KB (text file limit)\n\tlargeData := make([]byte, 201*1024)\n\tfor i := range largeData {\n\t\tlargeData[i] = 'a'\n\t}\n\tif err := os.WriteFile(textFile, largeData, 0644); err != nil {\n\t\tt.Fatalf(\"Failed to create test file: %v\", err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\t_, _, err := SummarizeFile(ctx, textFile, SummarizeOpts{\n\t\tAPIKey: \"fake-api-key\",\n\t\tMode:   ModeQuickSummary,\n\t})\n\tif err == nil {\n\t\tt.Error(\"SummarizeFile() expected error for file too large, got nil\")\n\t}\n\tif err != nil && !containsString(err.Error(), \"exceeds maximum size\") {\n\t\tt.Errorf(\"SummarizeFile() error = %v, want error containing 'exceeds maximum size'\", err)\n\t}\n}\n\nfunc containsString(s, substr string) bool {\n\treturn len(s) >= len(substr) && (s == substr || len(substr) == 0 ||\n\t\t(len(s) > 0 && len(substr) > 0 && stringContains(s, substr)))\n}\n\nfunc stringContains(s, substr string) bool {\n\tfor i := 0; i <= len(s)-len(substr); i++ {\n\t\tif s[i:i+len(substr)] == substr {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// Note: We don't test the actual API call without a real API key\n// Integration tests would require setting GOOGLE_API_KEY environment variable\n"
  },
  {
    "path": "pkg/aiusechat/openai/openai-backend.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/launchdarkly/eventsource\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/logutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/web/sse\"\n)\n\n// sanitizeHostnameInError removes the Wave cloud hostname from error messages\nfunc sanitizeHostnameInError(err error) error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\n\terrStr := err.Error()\n\tparsedURL, parseErr := url.Parse(uctypes.DefaultAIEndpoint)\n\tif parseErr == nil && parsedURL.Host != \"\" {\n\t\tif strings.Contains(errStr, parsedURL.Host) {\n\t\t\terrStr = strings.ReplaceAll(errStr, uctypes.DefaultAIEndpoint, \"AI service\")\n\t\t\terrStr = strings.ReplaceAll(errStr, parsedURL.Host, \"host\")\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"%s\", errStr)\n}\n\n// ---------- OpenAI wire types (subset) ----------\n\ntype OpenAIChatMessage struct {\n\tMessageId          string                         `json:\"messageid\"` // internal field for idempotency (cannot send to openai)\n\tMessage            *OpenAIMessage                 `json:\"message,omitempty\"`\n\tFunctionCall       *OpenAIFunctionCallInput       `json:\"functioncall,omitempty\"`\n\tFunctionCallOutput *OpenAIFunctionCallOutputInput `json:\"functioncalloutput,omitempty\"`\n\tUsage              *OpenAIUsage\n}\n\ntype OpenAIMessage struct {\n\tRole    string                 `json:\"role\"`\n\tContent []OpenAIMessageContent `json:\"content\"`\n}\n\ntype OpenAIFunctionCallInput struct {\n\tType        string                        `json:\"type\"`                  // Required: The type of the function tool call. Always function_call\n\tCallId      string                        `json:\"call_id\"`               // Required: The unique ID of the function tool call generated by the model\n\tName        string                        `json:\"name\"`                  // Required: The name of the function to run\n\tArguments   string                        `json:\"arguments\"`             // Required: A JSON string of the arguments to pass to the function\n\tStatus      string                        `json:\"status,omitempty\"`      // Optional: The status of the item. One of in_progress, completed, or incomplete\n\tToolUseData *uctypes.UIMessageDataToolUse `json:\"toolusedata,omitempty\"` // Internal field for UI tool use data (must be cleaned before sending to API)\n\t// removed the \"id\" field (optional to send back in inputs)\n}\n\ntype OpenAIFunctionCallOutputInput struct {\n\tType   string      `json:\"type\"`    // Required: The type of the function tool call output. Always function_call_output\n\tCallId string      `json:\"call_id\"` // Required: The unique ID of the function tool call generated by the model\n\tOutput interface{} `json:\"output\"`  // Required: Text, image, or file output of the function tool call\n\t// removed \"status\" field (not required for inputs)\n\t// removed the \"id\" field (optional to send back in inputs)\n}\n\ntype OpenAIFunctionCallErrorOutput struct {\n\tOk    string `json:\"ok\"`\n\tError string `json:\"error\"`\n}\n\ntype OpenAIMessageContent struct {\n\tType       string `json:\"type\"` // \"input_text\", \"output_text\", \"input_image\", \"input_file\", \"function_call\"\n\tText       string `json:\"text,omitempty\"`\n\tImageUrl   string `json:\"image_url,omitempty\"`\n\tPreviewUrl string `json:\"previewurl,omitempty\"` // internal field for 128x128 webp data url (cannot send to API)\n\tFilename   string `json:\"filename,omitempty\"`\n\tFileData   string `json:\"file_data,omitempty\"`\n\n\t// for Tools (type will be \"function_call\")\n\tArguments any    `json:\"arguments,omitempty\"`\n\tCallId    string `json:\"call_id,omitempty\"`\n\tName      string `json:\"name,omitempty\"`\n}\n\nfunc (c *OpenAIMessageContent) clean() *OpenAIMessageContent {\n\tif c.PreviewUrl == \"\" && (c.Type != \"input_image\" || c.Filename == \"\") {\n\t\treturn c\n\t}\n\trtn := *c\n\trtn.PreviewUrl = \"\"\n\tif c.Type == \"input_image\" {\n\t\trtn.Filename = \"\"\n\t}\n\treturn &rtn\n}\n\nfunc (m *OpenAIMessage) cleanAndCopy() *OpenAIMessage {\n\trtn := &OpenAIMessage{Role: m.Role}\n\trtn.Content = make([]OpenAIMessageContent, len(m.Content))\n\tfor idx, block := range m.Content {\n\t\tcleaned := block.clean()\n\t\trtn.Content[idx] = *cleaned\n\t}\n\treturn rtn\n}\n\nfunc (f *OpenAIFunctionCallInput) clean() *OpenAIFunctionCallInput {\n\tif f.ToolUseData == nil {\n\t\treturn f\n\t}\n\trtn := *f\n\trtn.ToolUseData = nil\n\treturn &rtn\n}\n\ntype openAIErrorResponse struct {\n\tError openAIErrorType `json:\"error\"`\n}\n\ntype openAIErrorType struct {\n\tMessage string `json:\"message\"`\n\tType    string `json:\"type\"`\n\tCode    string `json:\"code\"`\n}\n\nfunc (m *OpenAIChatMessage) GetMessageId() string {\n\treturn m.MessageId\n}\n\nfunc (m *OpenAIChatMessage) GetRole() string {\n\tif m.Message != nil {\n\t\treturn m.Message.Role\n\t}\n\treturn \"\"\n}\n\nfunc (m *OpenAIChatMessage) GetUsage() *uctypes.AIUsage {\n\tif m.Usage == nil {\n\t\treturn nil\n\t}\n\treturn &uctypes.AIUsage{\n\t\tAPIType:              uctypes.APIType_OpenAIResponses,\n\t\tModel:                m.Usage.Model,\n\t\tInputTokens:          m.Usage.InputTokens,\n\t\tOutputTokens:         m.Usage.OutputTokens,\n\t\tNativeWebSearchCount: m.Usage.NativeWebSearchCount,\n\t}\n}\n\n// ---------- OpenAI SSE Event Types ----------\n\ntype openaiResponseCreatedEvent struct {\n\tType           string         `json:\"type\"`\n\tSequenceNumber int            `json:\"sequence_number\"`\n\tResponse       openaiResponse `json:\"response\"`\n}\n\ntype openaiResponseInProgressEvent struct {\n\tType           string         `json:\"type\"`\n\tSequenceNumber int            `json:\"sequence_number\"`\n\tResponse       openaiResponse `json:\"response\"`\n}\n\ntype openaiResponseOutputItemAddedEvent struct {\n\tType           string           `json:\"type\"`\n\tSequenceNumber int              `json:\"sequence_number\"`\n\tOutputIndex    int              `json:\"output_index\"`\n\tItem           openaiOutputItem `json:\"item\"`\n}\n\ntype openaiResponseOutputItemDoneEvent struct {\n\tType           string           `json:\"type\"`\n\tSequenceNumber int              `json:\"sequence_number\"`\n\tOutputIndex    int              `json:\"output_index\"`\n\tItem           openaiOutputItem `json:\"item\"`\n}\n\ntype openaiResponseContentPartAddedEvent struct {\n\tType           string               `json:\"type\"`\n\tSequenceNumber int                  `json:\"sequence_number\"`\n\tItemId         string               `json:\"item_id\"`\n\tOutputIndex    int                  `json:\"output_index\"`\n\tContentIndex   int                  `json:\"content_index\"`\n\tPart           OpenAIMessageContent `json:\"part\"`\n}\n\ntype openaiResponseOutputTextDeltaEvent struct {\n\tType           string   `json:\"type\"`\n\tSequenceNumber int      `json:\"sequence_number\"`\n\tItemId         string   `json:\"item_id\"`\n\tOutputIndex    int      `json:\"output_index\"`\n\tContentIndex   int      `json:\"content_index\"`\n\tDelta          string   `json:\"delta\"`\n\tLogprobs       []string `json:\"logprobs\"`\n\tObfuscation    string   `json:\"obfuscation\"`\n}\n\ntype openaiResponseOutputTextDoneEvent struct {\n\tType           string   `json:\"type\"`\n\tSequenceNumber int      `json:\"sequence_number\"`\n\tItemId         string   `json:\"item_id\"`\n\tOutputIndex    int      `json:\"output_index\"`\n\tContentIndex   int      `json:\"content_index\"`\n\tText           string   `json:\"text\"`\n\tLogprobs       []string `json:\"logprobs\"`\n}\n\ntype openaiResponseContentPartDoneEvent struct {\n\tType           string               `json:\"type\"`\n\tSequenceNumber int                  `json:\"sequence_number\"`\n\tItemId         string               `json:\"item_id\"`\n\tOutputIndex    int                  `json:\"output_index\"`\n\tContentIndex   int                  `json:\"content_index\"`\n\tPart           OpenAIMessageContent `json:\"part\"`\n}\n\ntype openaiResponseCompletedEvent struct {\n\tType           string         `json:\"type\"`\n\tSequenceNumber int            `json:\"sequence_number\"`\n\tResponse       openaiResponse `json:\"response\"`\n}\n\ntype openaiResponseFunctionCallArgumentsDeltaEvent struct {\n\tType           string `json:\"type\"`\n\tSequenceNumber int    `json:\"sequence_number\"`\n\tItemId         string `json:\"item_id\"`\n\tOutputIndex    int    `json:\"output_index\"`\n\tDelta          string `json:\"delta\"`\n}\n\ntype openaiResponseFunctionCallArgumentsDoneEvent struct {\n\tType           string `json:\"type\"`\n\tSequenceNumber int    `json:\"sequence_number\"`\n\tItemId         string `json:\"item_id\"`\n\tOutputIndex    int    `json:\"output_index\"`\n\tArguments      string `json:\"arguments\"`\n}\n\ntype openaiResponseReasoningSummaryPartAddedEvent struct {\n\tType           string                     `json:\"type\"`\n\tSequenceNumber int                        `json:\"sequence_number\"`\n\tItemId         string                     `json:\"item_id\"`\n\tOutputIndex    int                        `json:\"output_index\"`\n\tSummaryIndex   int                        `json:\"summary_index\"`\n\tPart           openaiReasoningSummaryPart `json:\"part\"`\n}\n\ntype openaiResponseReasoningSummaryPartDoneEvent struct {\n\tType           string                     `json:\"type\"`\n\tSequenceNumber int                        `json:\"sequence_number\"`\n\tItemId         string                     `json:\"item_id\"`\n\tOutputIndex    int                        `json:\"output_index\"`\n\tSummaryIndex   int                        `json:\"summary_index\"`\n\tPart           openaiReasoningSummaryPart `json:\"part\"`\n}\n\ntype openaiReasoningSummaryPart struct {\n\tType string `json:\"type\"`\n\tText string `json:\"text\"`\n}\n\ntype openaiResponseReasoningSummaryTextDeltaEvent struct {\n\tType           string `json:\"type\"`\n\tSequenceNumber int    `json:\"sequence_number\"`\n\tItemId         string `json:\"item_id\"`\n\tOutputIndex    int    `json:\"output_index\"`\n\tSummaryIndex   int    `json:\"summary_index\"`\n\tDelta          string `json:\"delta\"`\n}\n\ntype openaiResponseReasoningSummaryTextDoneEvent struct {\n\tType           string `json:\"type\"`\n\tSequenceNumber int    `json:\"sequence_number\"`\n\tItemId         string `json:\"item_id\"`\n\tOutputIndex    int    `json:\"output_index\"`\n\tSummaryIndex   int    `json:\"summary_index\"`\n\tText           string `json:\"text\"`\n}\n\n// ---------- OpenAI Response Structure Types ----------\n\ntype openaiResponse struct {\n\tId                 string                 `json:\"id\"`\n\tObject             string                 `json:\"object\"`\n\tCreatedAt          int64                  `json:\"created_at\"`\n\tStatus             string                 `json:\"status\"`\n\tBackground         bool                   `json:\"background\"`\n\tError              *openaiError           `json:\"error\"`\n\tIncompleteDetails  *openaiIncompleteInfo  `json:\"incomplete_details\"`\n\tInstructions       *string                `json:\"instructions\"`\n\tMaxOutputTokens    *int                   `json:\"max_output_tokens\"`\n\tMaxToolCalls       *int                   `json:\"max_tool_calls\"`\n\tModel              string                 `json:\"model\"`\n\tOutput             []openaiOutputItem     `json:\"output\"`\n\tParallelToolCalls  bool                   `json:\"parallel_tool_calls\"`\n\tPreviousResponseId *string                `json:\"previous_response_id\"`\n\tPromptCacheKey     *string                `json:\"prompt_cache_key\"`\n\tReasoning          openaiReasoning        `json:\"reasoning\"`\n\tSafetyIdentifier   *string                `json:\"safety_identifier\"`\n\tServiceTier        string                 `json:\"service_tier\"`\n\tStore              bool                   `json:\"store\"`\n\tTemperature        float64                `json:\"temperature\"`\n\tText               openaiTextConfig       `json:\"text\"`\n\tToolChoice         string                 `json:\"tool_choice\"`\n\tTools              []OpenAIRequestTool    `json:\"tools\"`\n\tTopLogprobs        int                    `json:\"top_logprobs\"`\n\tTopP               float64                `json:\"top_p\"`\n\tTruncation         string                 `json:\"truncation\"`\n\tUsage              *OpenAIUsage           `json:\"usage\"`\n\tUser               *string                `json:\"user\"`\n\tMetadata           map[string]interface{} `json:\"metadata\"`\n}\n\ntype openaiOutputItem struct {\n\tId      string                       `json:\"id\"`\n\tType    string                       `json:\"type\"`\n\tStatus  string                       `json:\"status,omitempty\"`\n\tContent []OpenAIMessageContent       `json:\"content,omitempty\"`\n\tRole    string                       `json:\"role,omitempty\"`\n\tSummary []openaiReasoningSummaryPart `json:\"summary,omitempty\"`\n\n\t// tools (type=\"function_call\")\n\tName      string `json:\"name,omitempty\"`\n\tCallId    string `json:\"call_id,omitempty\"`\n\tArguments string `json:\"arguments,omitempty\"`\n}\n\ntype openaiReasoning struct {\n\tEffort  string  `json:\"effort\"`\n\tSummary *string `json:\"summary\"`\n}\n\ntype openaiTextConfig struct {\n\tFormat    openaiTextFormat `json:\"format\"`\n\tVerbosity string           `json:\"verbosity\"`\n}\n\ntype openaiTextFormat struct {\n\tType string `json:\"type\"`\n}\n\ntype OpenAIUsage struct {\n\tInputTokens          int                        `json:\"input_tokens,omitempty\"`\n\tOutputTokens         int                        `json:\"output_tokens,omitempty\"`\n\tTotalTokens          int                        `json:\"total_tokens,omitempty\"`\n\tInputTokensDetails   *openaiInputTokensDetails  `json:\"input_tokens_details,omitempty\"`\n\tOutputTokensDetails  *openaiOutputTokensDetails `json:\"output_tokens_details,omitempty\"`\n\tModel                string                     `json:\"model,omitempty\"`                // internal field (not from OpenAI API)\n\tNativeWebSearchCount int                        `json:\"nativewebsearchcount,omitempty\"` // internal field (not from OpenAI API)\n}\n\ntype openaiInputTokensDetails struct {\n\tCachedTokens int `json:\"cached_tokens\"`\n}\n\ntype openaiOutputTokensDetails struct {\n\tReasoningTokens int `json:\"reasoning_tokens\"`\n}\n\ntype openaiError struct {\n\t// Error details - can be expanded later\n}\n\ntype openaiIncompleteInfo struct {\n\tReason string `json:\"reason\"`\n}\n\n// ---------- OpenAI streaming state types ----------\n\ntype openaiBlockKind int\n\nconst (\n\topenaiBlockText openaiBlockKind = iota\n\topenaiBlockReasoning\n\topenaiBlockToolUse\n)\n\ntype openaiBlockState struct {\n\tkind            openaiBlockKind\n\tlocalID         string // For SSE streaming to UI\n\ttoolCallID      string // For function calls\n\ttoolName        string // For function calls\n\tsummaryCount    int    // For reasoning: number of summary parts seen\n\tpartialJSON     []byte // For function calls: accumulated JSON arguments\n\taccumulatedText string // For text blocks: accumulated text content\n}\n\ntype openaiStreamingState struct {\n\tblockMap       map[string]*openaiBlockState // Use item_id as key for UI streaming\n\tmsgID          string\n\tmodel          string\n\tstepStarted    bool\n\tchatOpts       uctypes.WaveChatOpts\n\twebSearchCount int\n}\n\n// ---------- Public entrypoint ----------\n\nfunc UpdateToolUseData(chatId string, callId string, newToolUseData uctypes.UIMessageDataToolUse) error {\n\tchat := chatstore.DefaultChatStore.Get(chatId)\n\tif chat == nil {\n\t\treturn fmt.Errorf(\"chat not found: %s\", chatId)\n\t}\n\n\tfor _, genMsg := range chat.NativeMessages {\n\t\tchatMsg, ok := genMsg.(*OpenAIChatMessage)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tif chatMsg.FunctionCall != nil && chatMsg.FunctionCall.CallId == callId {\n\t\t\tupdatedMsg := *chatMsg\n\t\t\tupdatedFunctionCall := *chatMsg.FunctionCall\n\t\t\tupdatedFunctionCall.ToolUseData = &newToolUseData\n\t\t\tupdatedMsg.FunctionCall = &updatedFunctionCall\n\n\t\t\taiOpts := &uctypes.AIOptsType{\n\t\t\t\tAPIType:    chat.APIType,\n\t\t\t\tModel:      chat.Model,\n\t\t\t\tAPIVersion: chat.APIVersion,\n\t\t\t}\n\n\t\t\treturn chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, &updatedMsg)\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"function call with callId %s not found in chat %s\", callId, chatId)\n}\n\nfunc RemoveToolUseCall(chatId string, callId string) error {\n\tchat := chatstore.DefaultChatStore.Get(chatId)\n\tif chat == nil {\n\t\treturn fmt.Errorf(\"chat not found: %s\", chatId)\n\t}\n\n\tfor _, genMsg := range chat.NativeMessages {\n\t\tchatMsg, ok := genMsg.(*OpenAIChatMessage)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tif chatMsg.FunctionCall != nil && chatMsg.FunctionCall.CallId == callId {\n\t\t\tchatstore.DefaultChatStore.RemoveMessage(chatId, chatMsg.MessageId)\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc RunOpenAIChatStep(\n\tctx context.Context,\n\tsse *sse.SSEHandlerCh,\n\tchatOpts uctypes.WaveChatOpts,\n\tcont *uctypes.WaveContinueResponse,\n) (*uctypes.WaveStopReason, []*OpenAIChatMessage, *uctypes.RateLimitInfo, error) {\n\tif sse == nil {\n\t\treturn nil, nil, nil, errors.New(\"sse handler is nil\")\n\t}\n\n\t// Get chat from store\n\tchat := chatstore.DefaultChatStore.Get(chatOpts.ChatId)\n\tif chat == nil {\n\t\treturn nil, nil, nil, fmt.Errorf(\"chat not found: %s\", chatOpts.ChatId)\n\t}\n\n\t// Validate that chatOpts.Config match the chat's stored configuration\n\tif chat.APIType != chatOpts.Config.APIType {\n\t\treturn nil, nil, nil, fmt.Errorf(\"API type mismatch: chat has %s, chatOpts has %s\", chat.APIType, chatOpts.Config.APIType)\n\t}\n\tif !uctypes.AreModelsCompatible(chat.APIType, chat.Model, chatOpts.Config.Model) {\n\t\treturn nil, nil, nil, fmt.Errorf(\"model mismatch: chat has %s, chatOpts has %s\", chat.Model, chatOpts.Config.Model)\n\t}\n\tif chat.APIVersion != chatOpts.Config.APIVersion {\n\t\treturn nil, nil, nil, fmt.Errorf(\"API version mismatch: chat has %s, chatOpts has %s\", chat.APIVersion, chatOpts.Config.APIVersion)\n\t}\n\n\t// Context with timeout if provided.\n\tif chatOpts.Config.TimeoutMs > 0 {\n\t\tvar cancel context.CancelFunc\n\t\tctx, cancel = context.WithTimeout(ctx, time.Duration(chatOpts.Config.TimeoutMs)*time.Millisecond)\n\t\tdefer cancel()\n\t}\n\n\t// Validate continuation if provided\n\tif cont != nil {\n\t\tif !uctypes.AreModelsCompatible(chat.APIType, chatOpts.Config.Model, cont.Model) {\n\t\t\treturn nil, nil, nil, fmt.Errorf(\"cannot continue with a different model, model:%q, cont-model:%q\", chatOpts.Config.Model, cont.Model)\n\t\t}\n\t}\n\n\t// Convert GenAIMessages to input objects (OpenAIMessage or OpenAIFunctionCallInput)\n\tvar inputs []any\n\tfor _, genMsg := range chat.NativeMessages {\n\t\t// Cast to OpenAIChatMessage\n\t\tchatMsg, ok := genMsg.(*OpenAIChatMessage)\n\t\tif !ok {\n\t\t\treturn nil, nil, nil, fmt.Errorf(\"expected OpenAIChatMessage, got %T\", genMsg)\n\t\t}\n\n\t\t// Convert to appropriate input type based on what's populated\n\t\tif chatMsg.Message != nil {\n\t\t\t// Clean message to remove preview URLs\n\t\t\tcleanedMsg := chatMsg.Message.cleanAndCopy()\n\t\t\tinputs = append(inputs, *cleanedMsg)\n\t\t} else if chatMsg.FunctionCall != nil {\n\t\t\tcleanedFunctionCall := chatMsg.FunctionCall.clean()\n\t\t\tinputs = append(inputs, *cleanedFunctionCall)\n\t\t} else if chatMsg.FunctionCallOutput != nil {\n\t\t\tinputs = append(inputs, *chatMsg.FunctionCallOutput)\n\t\t}\n\t}\n\n\treq, err := buildOpenAIHTTPRequest(ctx, inputs, chatOpts, cont)\n\tif err != nil {\n\t\treturn nil, nil, nil, err\n\t}\n\n\thttpClient, err := aiutil.MakeHTTPClient(chatOpts.Config.ProxyURL)\n\tif err != nil {\n\t\treturn nil, nil, nil, err\n\t}\n\n\tresp, err := httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, nil, nil, sanitizeHostnameInError(err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Parse rate limit info from header if present (do this before error check)\n\trateLimitInfo := uctypes.ParseRateLimitHeader(resp.Header.Get(\"X-Wave-RateLimit\"))\n\n\tct := resp.Header.Get(\"Content-Type\")\n\tif resp.StatusCode != http.StatusOK || !strings.HasPrefix(ct, \"text/event-stream\") {\n\t\t// Handle 429 rate limit with special logic\n\t\tif resp.StatusCode == http.StatusTooManyRequests && rateLimitInfo != nil {\n\t\t\tif rateLimitInfo.PReq == 0 && rateLimitInfo.Req > 0 {\n\t\t\t\t// Premium requests exhausted, but regular requests available\n\t\t\t\tstopReason := &uctypes.WaveStopReason{\n\t\t\t\t\tKind: uctypes.StopKindPremiumRateLimit,\n\t\t\t\t}\n\t\t\t\treturn stopReason, nil, rateLimitInfo, nil\n\t\t\t}\n\t\t\tif rateLimitInfo.Req == 0 {\n\t\t\t\t// All requests exhausted\n\t\t\t\tstopReason := &uctypes.WaveStopReason{\n\t\t\t\t\tKind: uctypes.StopKindRateLimit,\n\t\t\t\t}\n\t\t\t\treturn stopReason, nil, rateLimitInfo, nil\n\t\t\t}\n\t\t}\n\t\treturn nil, nil, rateLimitInfo, parseOpenAIHTTPError(resp)\n\t}\n\n\t// At this point we have a valid SSE stream, so setup SSE handling\n\t// From here on, errors must be returned through the SSE stream\n\tif cont == nil {\n\t\tsse.SetupSSE()\n\t}\n\n\t// Use eventsource decoder for proper SSE parsing\n\tdecoder := eventsource.NewDecoder(resp.Body)\n\n\tstopReason, rtnMessages := handleOpenAIStreamingResp(ctx, sse, decoder, cont, chatOpts)\n\treturn stopReason, rtnMessages, rateLimitInfo, nil\n}\n\n// parseOpenAIHTTPError parses OpenAI API HTTP error responses\nfunc parseOpenAIHTTPError(resp *http.Response) error {\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"openai %s: failed to read error response: %v\", resp.Status, err)\n\t}\n\n\tlogutil.DevPrintf(\"openai full error: %s\\n\", body)\n\n\t// Try to parse as OpenAI error format first\n\tvar errorResp openAIErrorResponse\n\tif err := json.Unmarshal(body, &errorResp); err == nil && errorResp.Error.Message != \"\" {\n\t\treturn fmt.Errorf(\"openai %s: %s\", resp.Status, errorResp.Error.Message)\n\t}\n\n\t// Try to parse as proxy error format\n\tvar proxyErr uctypes.ProxyErrorResponse\n\tif err := json.Unmarshal(body, &proxyErr); err == nil && !proxyErr.Success && proxyErr.Error != \"\" {\n\t\treturn fmt.Errorf(\"openai %s: %s\", resp.Status, proxyErr.Error)\n\t}\n\n\treturn fmt.Errorf(\"openai %s: %s\", resp.Status, utilfn.TruncateString(string(body), 120))\n}\n\n// handleOpenAIStreamingResp handles the OpenAI SSE streaming response\nfunc handleOpenAIStreamingResp(ctx context.Context, sse *sse.SSEHandlerCh, decoder *eventsource.Decoder, cont *uctypes.WaveContinueResponse, chatOpts uctypes.WaveChatOpts) (*uctypes.WaveStopReason, []*OpenAIChatMessage) {\n\t// Per-response state\n\tstate := &openaiStreamingState{\n\t\tblockMap: map[string]*openaiBlockState{},\n\t\tchatOpts: chatOpts,\n\t}\n\n\tvar rtnStopReason *uctypes.WaveStopReason\n\tvar rtnMessages []*OpenAIChatMessage\n\n\t// Ensure step is closed on error/cancellation\n\tdefer func() {\n\t\tif !state.stepStarted {\n\t\t\treturn\n\t\t}\n\t\t_ = sse.AiMsgFinishStep()\n\t\tif rtnStopReason == nil || rtnStopReason.Kind != uctypes.StopKindToolUse {\n\t\t\t_ = sse.AiMsgFinish(\"\", nil)\n\t\t}\n\t}()\n\n\t// SSE event processing loop\n\tfor {\n\t\tevent, err := decoder.Decode()\n\t\tif err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\t// EOF without proper completion - protocol error\n\t\t\t\t_ = sse.AiMsgError(\"stream ended unexpectedly without completion\")\n\t\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\t\tKind:      uctypes.StopKindError,\n\t\t\t\t\tErrorType: \"protocol\",\n\t\t\t\t\tErrorText: \"stream ended unexpectedly without completion\",\n\t\t\t\t}, rtnMessages\n\t\t\t}\n\t\t\t// Check if client disconnected\n\t\t\tif sse.Err() != nil {\n\t\t\t\t// SSE connection broken (client stopped/disconnected)\n\t\t\t\tpartialMessages := extractPartialTextFromState(state)\n\t\t\t\tif partialMessages != nil {\n\t\t\t\t\trtnMessages = append(rtnMessages, partialMessages...)\n\t\t\t\t}\n\t\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\t\tKind:      uctypes.StopKindCanceled,\n\t\t\t\t\tErrorType: \"client_disconnect\",\n\t\t\t\t\tErrorText: \"client disconnected\",\n\t\t\t\t}, rtnMessages\n\t\t\t}\n\t\t\t// transport error mid-stream\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindError,\n\t\t\t\tErrorType: \"stream\",\n\t\t\t\tErrorText: err.Error(),\n\t\t\t}, rtnMessages\n\t\t}\n\n\t\tif finalStopReason, finalMessages := handleOpenAIEvent(event, sse, state, cont); finalStopReason != nil {\n\t\t\trtnStopReason = finalStopReason\n\t\t\tif finalMessages != nil {\n\t\t\t\trtnMessages = finalMessages\n\t\t\t} else if finalStopReason.Kind == uctypes.StopKindCanceled {\n\t\t\t\tpartialMessages := extractPartialTextFromState(state)\n\t\t\t\tif partialMessages != nil {\n\t\t\t\t\trtnMessages = append(rtnMessages, partialMessages...)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn finalStopReason, rtnMessages\n\t\t}\n\t}\n\n\t// unreachable\n}\n\n// extractPartialTextFromState extracts accumulated text from streaming state when client disconnects\nfunc extractPartialTextFromState(state *openaiStreamingState) []*OpenAIChatMessage {\n\tvar textContent []OpenAIMessageContent\n\n\tfor _, blockState := range state.blockMap {\n\t\tif blockState.kind == openaiBlockText && blockState.accumulatedText != \"\" {\n\t\t\ttextContent = append(textContent, OpenAIMessageContent{\n\t\t\t\tType: \"output_text\",\n\t\t\t\tText: blockState.accumulatedText,\n\t\t\t})\n\t\t}\n\t}\n\n\tif len(textContent) == 0 {\n\t\treturn nil\n\t}\n\n\tassistantMessage := &OpenAIChatMessage{\n\t\tMessageId: uuid.New().String(),\n\t\tMessage: &OpenAIMessage{\n\t\t\tRole:    \"assistant\",\n\t\t\tContent: textContent,\n\t\t},\n\t}\n\n\treturn []*OpenAIChatMessage{assistantMessage}\n}\n\n// handleOpenAIEvent processes one SSE event block. It may emit SSE parts\n// and/or return a StopReason and final message when the stream is complete.\n//\n// Return tuple:\n//   - final: a *StopReason to return immediately (e.g., after response.completed or error)\n//   - message: a *OpenAIChatMessage when response is completed\nfunc handleOpenAIEvent(\n\tevent eventsource.Event,\n\tsse *sse.SSEHandlerCh,\n\tstate *openaiStreamingState,\n\tcont *uctypes.WaveContinueResponse,\n) (final *uctypes.WaveStopReason, messages []*OpenAIChatMessage) {\n\tif err := sse.Err(); err != nil {\n\t\treturn &uctypes.WaveStopReason{\n\t\t\tKind:      uctypes.StopKindCanceled,\n\t\t\tErrorType: \"client_disconnect\",\n\t\t\tErrorText: \"client disconnected\",\n\t\t}, nil\n\t}\n\n\teventName := event.Event()\n\tdata := event.Data()\n\n\tswitch eventName {\n\tcase \"response.created\":\n\t\tvar ev openaiResponseCreatedEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}, nil\n\t\t}\n\t\tstate.msgID = ev.Response.Id\n\t\tstate.model = ev.Response.Model\n\t\tif cont == nil {\n\t\t\t_ = sse.AiMsgStart(state.msgID)\n\t\t}\n\t\treturn nil, nil\n\n\tcase \"response.in_progress\":\n\t\t// Start the step on in_progress\n\t\tif !state.stepStarted {\n\t\t\t_ = sse.AiMsgStartStep()\n\t\t\tstate.stepStarted = true\n\t\t}\n\t\treturn nil, nil\n\n\tcase \"response.output_item.added\":\n\t\tvar ev openaiResponseOutputItemAddedEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}, nil\n\t\t}\n\n\t\tswitch ev.Item.Type {\n\t\tcase \"reasoning\":\n\t\t\t// Create reasoning block - emit start immediately\n\t\t\tid := uuid.New().String()\n\t\t\tstate.blockMap[ev.Item.Id] = &openaiBlockState{\n\t\t\t\tkind:         openaiBlockReasoning,\n\t\t\t\tlocalID:      id,\n\t\t\t\tsummaryCount: 0,\n\t\t\t}\n\t\t\t_ = sse.AiMsgReasoningStart(id)\n\t\tcase \"message\":\n\t\t\t// Message item - content parts will be handled in streaming events\n\t\tcase \"function_call\":\n\t\t\t// Track function call info and notify frontend\n\t\t\tid := uuid.New().String()\n\t\t\tstate.blockMap[ev.Item.Id] = &openaiBlockState{\n\t\t\t\tkind:       openaiBlockToolUse,\n\t\t\t\tlocalID:    id,\n\t\t\t\ttoolCallID: ev.Item.CallId,\n\t\t\t\ttoolName:   ev.Item.Name,\n\t\t\t}\n\t\t\t// no longer send tool inputs to FE\n\t\t\t// _ = sse.AiMsgToolInputStart(ev.Item.CallId, ev.Item.Name)\n\t\t}\n\t\treturn nil, nil\n\n\tcase \"response.output_item.done\":\n\t\tvar ev openaiResponseOutputItemDoneEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}, nil\n\t\t}\n\n\t\tif st := state.blockMap[ev.Item.Id]; st != nil {\n\t\t\tswitch st.kind {\n\t\t\tcase openaiBlockReasoning:\n\t\t\t\t_ = sse.AiMsgReasoningEnd(st.localID)\n\t\t\tcase openaiBlockToolUse:\n\t\t\t\t// Tool input completion notification was already sent in function_call_arguments.done\n\t\t\t\t// This just marks the end of the tool item itself\n\t\t\t}\n\t\t}\n\t\treturn nil, nil\n\n\tcase \"response.content_part.added\":\n\t\tvar ev openaiResponseContentPartAddedEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}, nil\n\t\t}\n\n\t\tswitch ev.Part.Type {\n\t\tcase \"output_text\":\n\t\t\t// Handle text content for UI streaming only\n\t\t\tid := uuid.New().String()\n\t\t\tstate.blockMap[ev.ItemId] = &openaiBlockState{\n\t\t\t\tkind:    openaiBlockText,\n\t\t\t\tlocalID: id,\n\t\t\t}\n\t\t\t_ = sse.AiMsgTextStart(id)\n\t\t}\n\t\treturn nil, nil\n\n\tcase \"response.output_text.delta\":\n\t\tvar ev openaiResponseOutputTextDeltaEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}, nil\n\t\t}\n\n\t\tif st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockText {\n\t\t\tst.accumulatedText += ev.Delta\n\t\t\t_ = sse.AiMsgTextDelta(st.localID, ev.Delta)\n\t\t}\n\t\treturn nil, nil\n\n\tcase \"response.output_text.done\":\n\t\treturn nil, nil\n\n\tcase \"response.content_part.done\":\n\t\tvar ev openaiResponseContentPartDoneEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}, nil\n\t\t}\n\n\t\tif st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockText {\n\t\t\t_ = sse.AiMsgTextEnd(st.localID)\n\t\t}\n\t\treturn nil, nil\n\n\tcase \"response.completed\", \"response.failed\", \"response.incomplete\":\n\t\tvar ev openaiResponseCompletedEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}, nil\n\t\t}\n\n\t\t// Handle error case\n\t\tif ev.Response.Error != nil {\n\t\t\terrorMsg := \"OpenAI API error\"\n\t\t\t_ = sse.AiMsgError(errorMsg)\n\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindError,\n\t\t\t\tErrorType: \"api\",\n\t\t\t\tErrorText: errorMsg,\n\t\t\t}, nil\n\t\t}\n\n\t\t// Handle incomplete case\n\t\tif ev.Response.IncompleteDetails != nil {\n\t\t\treason := ev.Response.IncompleteDetails.Reason\n\t\t\tvar stopKind uctypes.StopReasonKind\n\t\t\tvar errorMsg string\n\n\t\t\tswitch reason {\n\t\t\tcase \"max_output_tokens\":\n\t\t\t\tstopKind = uctypes.StopKindMaxTokens\n\t\t\t\terrorMsg = \"Maximum output tokens reached\"\n\t\t\tcase \"max_prompt_tokens\":\n\t\t\t\tstopKind = uctypes.StopKindError\n\t\t\t\terrorMsg = \"Maximum prompt tokens reached\"\n\t\t\tcase \"content_filter\":\n\t\t\t\tstopKind = uctypes.StopKindContent\n\t\t\t\terrorMsg = \"Content filtered\"\n\t\t\tdefault:\n\t\t\t\tstopKind = uctypes.StopKindError\n\t\t\t\terrorMsg = fmt.Sprintf(\"Response incomplete: %s\", reason)\n\t\t\t}\n\n\t\t\t// Extract partial message if available\n\t\t\tfinalMessages, _ := extractMessageAndToolsFromResponse(ev.Response, state)\n\n\t\t\t_ = sse.AiMsgError(errorMsg)\n\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\tKind:      stopKind,\n\t\t\t\tRawReason: reason,\n\t\t\t\tErrorText: errorMsg,\n\t\t\t}, finalMessages\n\t\t}\n\n\t\t// Extract the final message and tool calls from the response output\n\t\tfinalMessages, toolCalls := extractMessageAndToolsFromResponse(ev.Response, state)\n\n\t\tstopKind := uctypes.StopKindDone\n\t\tif len(toolCalls) > 0 {\n\t\t\tstopKind = uctypes.StopKindToolUse\n\t\t}\n\n\t\treturn &uctypes.WaveStopReason{\n\t\t\tKind:      stopKind,\n\t\t\tRawReason: ev.Response.Status,\n\t\t\tToolCalls: toolCalls,\n\t\t}, finalMessages\n\n\tcase \"response.function_call_arguments.delta\":\n\t\tvar ev openaiResponseFunctionCallArgumentsDeltaEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}, nil\n\t\t}\n\t\tif st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockToolUse {\n\t\t\tst.partialJSON = append(st.partialJSON, []byte(ev.Delta)...)\n\t\t\taiutil.SendToolProgress(st.toolCallID, st.toolName, st.partialJSON, state.chatOpts, sse, true)\n\t\t}\n\t\treturn nil, nil\n\n\tcase \"response.function_call_arguments.done\":\n\t\tvar ev openaiResponseFunctionCallArgumentsDoneEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}, nil\n\t\t}\n\n\t\t// Get the function call info from the block state\n\t\tif st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockToolUse {\n\t\t\taiutil.SendToolProgress(st.toolCallID, st.toolName, []byte(ev.Arguments), state.chatOpts, sse, false)\n\t\t}\n\t\treturn nil, nil\n\n\tcase \"response.web_search_call.in_progress\":\n\t\treturn nil, nil\n\n\tcase \"response.web_search_call.searching\":\n\t\treturn nil, nil\n\n\tcase \"response.web_search_call.completed\":\n\t\tstate.webSearchCount++\n\t\treturn nil, nil\n\n\tcase \"response.output_text.annotation.added\":\n\t\treturn nil, nil\n\n\tcase \"response.reasoning_summary_part.added\":\n\t\tvar ev openaiResponseReasoningSummaryPartAddedEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}, nil\n\t\t}\n\n\t\tif st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockReasoning {\n\t\t\tif st.summaryCount > 0 {\n\t\t\t\t// Not the first summary part, emit separator\n\t\t\t\t_ = sse.AiMsgReasoningDelta(st.localID, \"\\n\\n\")\n\t\t\t}\n\t\t\tst.summaryCount++\n\t\t}\n\t\treturn nil, nil\n\n\tcase \"response.reasoning_summary_part.done\":\n\t\treturn nil, nil\n\n\tcase \"response.reasoning_summary_text.delta\":\n\t\tvar ev openaiResponseReasoningSummaryTextDeltaEvent\n\t\tif err := json.Unmarshal([]byte(data), &ev); err != nil {\n\t\t\t_ = sse.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: \"decode\", ErrorText: err.Error()}, nil\n\t\t}\n\n\t\tif st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockReasoning {\n\t\t\t_ = sse.AiMsgReasoningDelta(st.localID, ev.Delta)\n\t\t}\n\t\treturn nil, nil\n\n\tcase \"response.reasoning_summary_text.done\":\n\t\treturn nil, nil\n\n\tdefault:\n\t\tlogutil.DevPrintf(\"OpenAI: unknown event: %s, data: %s\", eventName, data)\n\t\treturn nil, nil\n\t}\n}\n\n// extractMessageAndToolsFromResponse extracts the final OpenAI message and tool calls from the completed response\nfunc extractMessageAndToolsFromResponse(resp openaiResponse, state *openaiStreamingState) ([]*OpenAIChatMessage, []uctypes.WaveToolCall) {\n\tvar messageContent []OpenAIMessageContent\n\tvar toolCalls []uctypes.WaveToolCall\n\tvar messages []*OpenAIChatMessage\n\n\t// Process all output items in the response\n\tfor _, outputItem := range resp.Output {\n\t\tswitch outputItem.Type {\n\t\tcase \"message\":\n\t\t\tif outputItem.Role == \"assistant\" {\n\t\t\t\t// Copy ALL content parts from the output item\n\t\t\t\tfor _, contentPart := range outputItem.Content {\n\t\t\t\t\tmessageContent = append(messageContent, OpenAIMessageContent{\n\t\t\t\t\t\tType: contentPart.Type,\n\t\t\t\t\t\tText: contentPart.Text,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"function_call\":\n\t\t\t// Extract tool call information\n\t\t\ttoolCall := uctypes.WaveToolCall{\n\t\t\t\tID:   outputItem.CallId,\n\t\t\t\tName: outputItem.Name,\n\t\t\t}\n\n\t\t\t// Parse arguments JSON string if present\n\t\t\tvar parsedArguments any\n\t\t\tif outputItem.Arguments != \"\" {\n\t\t\t\tif err := json.Unmarshal([]byte(outputItem.Arguments), &parsedArguments); err == nil {\n\t\t\t\t\ttoolCall.Input = parsedArguments\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ttoolCalls = append(toolCalls, toolCall)\n\n\t\t\t// Create separate FunctionCall message\n\t\t\tvar argsStr string\n\t\t\tif outputItem.Arguments != \"\" {\n\t\t\t\targsStr = outputItem.Arguments\n\t\t\t}\n\t\t\tfunctionCallMsg := &OpenAIChatMessage{\n\t\t\t\tMessageId: uuid.New().String(),\n\t\t\t\tFunctionCall: &OpenAIFunctionCallInput{\n\t\t\t\t\tType:      \"function_call\",\n\t\t\t\t\tCallId:    outputItem.CallId,\n\t\t\t\t\tName:      outputItem.Name,\n\t\t\t\t\tArguments: argsStr,\n\t\t\t\t},\n\t\t\t}\n\t\t\tmessages = append(messages, functionCallMsg)\n\t\t}\n\t}\n\n\t// Create OpenAIChatMessage with assistant message (first in slice)\n\tusage := resp.Usage\n\tif usage != nil {\n\t\tresp.Usage.Model = resp.Model\n\t\tif state.webSearchCount > 0 {\n\t\t\tusage.NativeWebSearchCount = state.webSearchCount\n\t\t}\n\t}\n\tassistantMessage := &OpenAIChatMessage{\n\t\tMessageId: uuid.New().String(),\n\t\tMessage: &OpenAIMessage{\n\t\t\tRole:    \"assistant\",\n\t\t\tContent: messageContent,\n\t\t},\n\t\tUsage: usage,\n\t}\n\n\t// Return assistant message first, followed by function call messages\n\tallMessages := []*OpenAIChatMessage{assistantMessage}\n\tallMessages = append(allMessages, messages...)\n\n\treturn allMessages, toolCalls\n}\n"
  },
  {
    "path": "pkg/aiusechat/openai/openai-convertmessage.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage openai\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\nconst (\n\tOpenAIDefaultAPIVersion = \"2024-12-31\"\n\tOpenAIDefaultMaxTokens  = 4096\n\t// \"medium\" verbosity is more widely supported across models than \"low\"\n\tOpenAIDefaultVerbosity = \"medium\"\n)\n\n// convertContentBlockToParts converts a single content block to UIMessageParts\nfunc convertContentBlockToParts(block OpenAIMessageContent, role string) []uctypes.UIMessagePart {\n\tvar parts []uctypes.UIMessagePart\n\n\tswitch block.Type {\n\tcase \"input_text\", \"output_text\":\n\t\tif found, part := aiutil.ConvertDataUserFile(block.Text); found {\n\t\t\tif part != nil {\n\t\t\t\tparts = append(parts, *part)\n\t\t\t}\n\t\t} else {\n\t\t\tparts = append(parts, uctypes.UIMessagePart{\n\t\t\t\tType: \"text\",\n\t\t\t\tText: block.Text,\n\t\t\t})\n\t\t}\n\tcase \"input_image\":\n\t\tif role == \"user\" {\n\t\t\tparts = append(parts, uctypes.UIMessagePart{\n\t\t\t\tType: \"data-userfile\",\n\t\t\t\tData: uctypes.UIMessageDataUserFile{\n\t\t\t\t\tFileName:   block.Filename,\n\t\t\t\t\tMimeType:   \"image/*\",\n\t\t\t\t\tPreviewUrl: block.PreviewUrl,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\tcase \"input_file\":\n\t\tif role == \"user\" {\n\t\t\tparts = append(parts, uctypes.UIMessagePart{\n\t\t\t\tType: \"data-userfile\",\n\t\t\t\tData: uctypes.UIMessageDataUserFile{\n\t\t\t\t\tFileName:   block.Filename,\n\t\t\t\t\tMimeType:   \"application/pdf\",\n\t\t\t\t\tPreviewUrl: block.PreviewUrl,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t}\n\n\treturn parts\n}\n\n// appendToLastUserMessage appends a text block to the last user message in the inputs slice\nfunc appendToLastUserMessage(inputs []any, text string) {\n\tfor i := len(inputs) - 1; i >= 0; i-- {\n\t\tif msg, ok := inputs[i].(OpenAIMessage); ok && msg.Role == \"user\" {\n\t\t\tblock := OpenAIMessageContent{\n\t\t\t\tType: \"input_text\",\n\t\t\t\tText: text,\n\t\t\t}\n\t\t\tmsg.Content = append(msg.Content, block)\n\t\t\tinputs[i] = msg\n\t\t\tbreak\n\t\t}\n\t}\n}\n\n// ---------- OpenAI Request Types ----------\n\ntype StreamOptionsType struct {\n\tIncludeObfuscation bool `json:\"include_obfuscation\"`\n}\n\ntype ReasoningType struct {\n\tEffort  string `json:\"effort,omitempty\"`  // \"minimal\", \"low\", \"medium\", \"high\"\n\tSummary string `json:\"summary,omitempty\"` // \"auto\", \"concise\", \"detailed\"\n}\n\ntype TextType struct {\n\tFormat    interface{} `json:\"format,omitempty\"`    // Format object, e.g. {\"type\": \"text\"}, {\"type\": \"json_object\"}, {\"type\": \"json_schema\"}\n\tVerbosity string      `json:\"verbosity,omitempty\"` // \"low\", \"medium\", \"high\"\n}\n\ntype PromptType struct {\n\tID        string                 `json:\"id\"`\n\tVariables map[string]interface{} `json:\"variables,omitempty\"`\n\tVersion   string                 `json:\"version,omitempty\"`\n}\n\ntype OpenAIRequest struct {\n\tBackground         bool                `json:\"background,omitempty\"`\n\tConversation       string              `json:\"conversation,omitempty\"`\n\tInclude            []string            `json:\"include,omitempty\"`\n\tInput              []any               `json:\"input,omitempty\"` // either OpenAIMessage or OpenAIFunctionCallInput\n\tInstructions       string              `json:\"instructions,omitempty\"`\n\tMaxOutputTokens    int                 `json:\"max_output_tokens,omitempty\"`\n\tMaxToolCalls       int                 `json:\"max_tool_calls,omitempty\"`\n\tMetadata           map[string]string   `json:\"metadata,omitempty\"`\n\tModel              string              `json:\"model,omitempty\"`\n\tParallelToolCalls  bool                `json:\"parallel_tool_calls,omitempty\"`\n\tPreviousResponseID string              `json:\"previous_response_id,omitempty\"`\n\tPrompt             *PromptType         `json:\"prompt,omitempty\"`\n\tPromptCacheKey     string              `json:\"prompt_cache_key,omitempty\"`\n\tReasoning          *ReasoningType      `json:\"reasoning,omitempty\"`\n\tSafetyIdentifier   string              `json:\"safety_identifier,omitempty\"`\n\tServiceTier        string              `json:\"service_tier,omitempty\"` // \"auto\", \"default\", \"flex\", \"priority\"\n\tStore              bool                `json:\"store,omitempty\"`\n\tStream             bool                `json:\"stream,omitempty\"`\n\tStreamOptions      *StreamOptionsType  `json:\"stream_options,omitempty\"`\n\tTemperature        float64             `json:\"temperature,omitempty\"`\n\tText               *TextType           `json:\"text,omitempty\"`\n\tToolChoice         interface{}         `json:\"tool_choice,omitempty\"` // \"none\", \"auto\", \"required\", or object\n\tTools              []OpenAIRequestTool `json:\"tools,omitempty\"`\n\tTopLogprobs        int                 `json:\"top_logprobs,omitempty\"`\n\tTopP               float64             `json:\"top_p,omitempty\"`\n\tTruncation         string              `json:\"truncation,omitempty\"` // \"auto\", \"disabled\"\n}\n\ntype OpenAIRequestTool struct {\n\tType        string `json:\"type\"`\n\tName        string `json:\"name,omitempty\"`\n\tDescription string `json:\"description,omitempty\"`\n\tParameters  any    `json:\"parameters,omitempty\"`\n\tStrict      bool   `json:\"strict,omitempty\"`\n}\n\n// ConvertToolDefinitionToOpenAI converts a generic ToolDefinition to OpenAI format\nfunc ConvertToolDefinitionToOpenAI(tool uctypes.ToolDefinition) OpenAIRequestTool {\n\treturn OpenAIRequestTool{\n\t\tName:        tool.Name,\n\t\tDescription: tool.Description,\n\t\tParameters:  tool.InputSchema,\n\t\tStrict:      tool.Strict,\n\t\tType:        \"function\",\n\t}\n}\n\nfunc debugPrintReq(req *OpenAIRequest, endpoint string) {\n\tif !wavebase.IsDevMode() {\n\t\treturn\n\t}\n\tif endpoint != uctypes.DefaultAIEndpoint {\n\t\tlog.Printf(\"endpoint: %s\\n\", endpoint)\n\t}\n\tvar toolNames []string\n\tfor _, tool := range req.Tools {\n\t\ttoolNames = append(toolNames, tool.Name)\n\t}\n\tmodelInfo := req.Model\n\tvar details []string\n\tif req.Reasoning != nil && req.Reasoning.Effort != \"\" {\n\t\tdetails = append(details, fmt.Sprintf(\"reasoning: %s\", req.Reasoning.Effort))\n\t}\n\tif req.MaxOutputTokens > 0 {\n\t\tdetails = append(details, fmt.Sprintf(\"max_tokens: %d\", req.MaxOutputTokens))\n\t}\n\tif len(details) > 0 {\n\t\tlog.Printf(\"model %s (%s)\\n\", modelInfo, strings.Join(details, \", \"))\n\t} else {\n\t\tlog.Printf(\"model %s\\n\", modelInfo)\n\t}\n\tif len(toolNames) > 0 {\n\t\tlog.Printf(\"tools: %s\\n\", strings.Join(toolNames, \",\"))\n\t}\n\n\tlog.Printf(\"inputs (%d):\", len(req.Input))\n\tfor idx, input := range req.Input {\n\t\tdebugPrintInput(idx, input)\n\t}\n}\n\n// buildOpenAIHTTPRequest creates a complete HTTP request for the OpenAI API\nfunc buildOpenAIHTTPRequest(ctx context.Context, inputs []any, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse) (*http.Request, error) {\n\topts := chatOpts.Config\n\n\t// If continuing from premium rate limit, downgrade to default model and medium thinking\n\t// (medium is more widely supported than low across different models)\n\tif cont != nil && cont.ContinueFromKind == uctypes.StopKindPremiumRateLimit {\n\t\topts.Model = uctypes.DefaultOpenAIModel\n\t\topts.ThinkingLevel = uctypes.ThinkingLevelMedium\n\t}\n\n\tif opts.Model == \"\" {\n\t\treturn nil, errors.New(\"ai:model is required\")\n\t}\n\tif chatOpts.ClientId == \"\" {\n\t\treturn nil, errors.New(\"chatOpts.ClientId is required\")\n\t}\n\n\t// Set defaults\n\tendpoint := opts.Endpoint\n\tif endpoint == \"\" {\n\t\treturn nil, errors.New(\"ai:endpoint is required\")\n\t}\n\n\tmaxTokens := opts.MaxTokens\n\tif maxTokens <= 0 {\n\t\tmaxTokens = OpenAIDefaultMaxTokens\n\t}\n\n\t// injected data\n\tif chatOpts.TabState != \"\" {\n\t\tappendToLastUserMessage(inputs, chatOpts.TabState)\n\t}\n\tif chatOpts.PlatformInfo != \"\" {\n\t\tappendToLastUserMessage(inputs, \"<PlatformInfo>\\n\"+chatOpts.PlatformInfo+\"\\n</PlatformInfo>\")\n\t}\n\tif chatOpts.AppStaticFiles != \"\" {\n\t\tappendToLastUserMessage(inputs, \"<CurrentAppStaticFiles>\\n\"+chatOpts.AppStaticFiles+\"\\n</CurrentAppStaticFiles>\")\n\t}\n\tif chatOpts.AppGoFile != \"\" {\n\t\tappendToLastUserMessage(inputs, \"<CurrentAppGoFile>\\n\"+chatOpts.AppGoFile+\"\\n</CurrentAppGoFile>\")\n\t}\n\n\t// Build request body\n\t// Use configured verbosity, or fall back to default constant\n\tverbosity := opts.Verbosity\n\tif verbosity == \"\" {\n\t\tverbosity = OpenAIDefaultVerbosity\n\t}\n\treqBody := &OpenAIRequest{\n\t\tModel:           opts.Model,\n\t\tInput:           inputs,\n\t\tStream:          true,\n\t\tStreamOptions:   &StreamOptionsType{IncludeObfuscation: false},\n\t\tMaxOutputTokens: maxTokens,\n\t\tText:            &TextType{Verbosity: verbosity},\n\t}\n\n\t// Add system prompt as instructions if provided\n\tif len(chatOpts.SystemPrompt) > 0 {\n\t\treqBody.Instructions = strings.Join(chatOpts.SystemPrompt, \"\\n\")\n\t}\n\n\t// Add tools if provided\n\tif len(chatOpts.Tools) > 0 {\n\t\ttools := make([]OpenAIRequestTool, len(chatOpts.Tools))\n\t\tfor i, tool := range chatOpts.Tools {\n\t\t\ttools[i] = ConvertToolDefinitionToOpenAI(tool)\n\t\t}\n\t\treqBody.Tools = tools\n\t}\n\tfor _, tool := range chatOpts.TabTools {\n\t\tconvertedTool := ConvertToolDefinitionToOpenAI(tool)\n\t\treqBody.Tools = append(reqBody.Tools, convertedTool)\n\t}\n\n\t// Add native web search tool if enabled\n\tif chatOpts.AllowNativeWebSearch {\n\t\twebSearchTool := OpenAIRequestTool{\n\t\t\tType: \"web_search\",\n\t\t}\n\t\treqBody.Tools = append(reqBody.Tools, webSearchTool)\n\t}\n\n\t// Set reasoning based on thinking level from config\n\tif opts.ThinkingLevel != \"\" {\n\t\treqBody.Reasoning = &ReasoningType{\n\t\t\tEffort: opts.ThinkingLevel,\n\t\t}\n\t\tif opts.Model == \"gpt-5\" || opts.Model == \"gpt-5.1\" {\n\t\t\treqBody.Reasoning.Summary = \"auto\"\n\t\t}\n\t}\n\n\tdebugPrintReq(reqBody, endpoint)\n\n\t// Encode request body\n\tbuf, err := aiutil.JsonEncodeRequestBody(reqBody)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t// Create HTTP request\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t// Set headers\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t// Azure OpenAI uses \"api-key\" header instead of \"Authorization: Bearer\"\n\tif opts.Provider == uctypes.AIProvider_Azure || opts.Provider == uctypes.AIProvider_AzureLegacy {\n\t\treq.Header.Set(\"api-key\", opts.APIToken)\n\t} else {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+opts.APIToken)\n\t}\n\treq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t// Only send Wave-specific headers when using Wave provider\n\tif opts.Provider == uctypes.AIProvider_Wave {\n\t\tif chatOpts.ClientId != \"\" {\n\t\t\treq.Header.Set(\"X-Wave-ClientId\", chatOpts.ClientId)\n\t\t}\n\t\tif chatOpts.ChatId != \"\" {\n\t\t\treq.Header.Set(\"X-Wave-ChatId\", chatOpts.ChatId)\n\t\t}\n\t\treq.Header.Set(\"X-Wave-Version\", wavebase.WaveVersion)\n\t\treq.Header.Set(\"X-Wave-APIType\", uctypes.APIType_OpenAIResponses)\n\t\treq.Header.Set(\"X-Wave-RequestType\", chatOpts.GetWaveRequestType())\n\t}\n\n\treturn req, nil\n}\n\n// convertFileAIMessagePart converts a file AIMessagePart to OpenAI format\nfunc convertFileAIMessagePart(part uctypes.AIMessagePart) (*OpenAIMessageContent, error) {\n\tif part.Type != uctypes.AIMessagePartTypeFile {\n\t\treturn nil, fmt.Errorf(\"convertFileAIMessagePart expects 'file' type, got '%s'\", part.Type)\n\t}\n\tif part.MimeType == \"\" {\n\t\treturn nil, fmt.Errorf(\"file part missing mimetype\")\n\t}\n\n\t// Handle different file types\n\tswitch {\n\tcase strings.HasPrefix(part.MimeType, \"image/\"):\n\t\timageUrl, err := aiutil.ExtractImageUrl(part.Data, part.URL, part.MimeType)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\treturn &OpenAIMessageContent{\n\t\t\tType:       \"input_image\",\n\t\t\tImageUrl:   imageUrl,\n\t\t\tFilename:   part.FileName,\n\t\t\tPreviewUrl: part.PreviewUrl,\n\t\t}, nil\n\n\tcase part.MimeType == \"application/pdf\":\n\t\t// Handle PDFs - OpenAI only supports base64 data for PDFs, not URLs\n\t\tif len(part.Data) == 0 {\n\t\t\tif part.URL != \"\" {\n\t\t\t\treturn nil, fmt.Errorf(\"dropping PDF with URL (must be fetched and converted to base64 data)\")\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"PDF file part missing data\")\n\t\t}\n\n\t\t// Convert raw data to base64\n\t\tbase64Data := base64.StdEncoding.EncodeToString(part.Data)\n\n\t\treturn &OpenAIMessageContent{\n\t\t\tType:       \"input_file\",\n\t\t\tFilename:   part.FileName, // Optional filename\n\t\t\tFileData:   base64Data,\n\t\t\tPreviewUrl: part.PreviewUrl,\n\t\t}, nil\n\n\tcase part.MimeType == \"text/plain\":\n\t\ttextData, err := aiutil.ExtractTextData(part.Data, part.URL)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tformattedText := aiutil.FormatAttachedTextFile(part.FileName, textData)\n\t\treturn &OpenAIMessageContent{\n\t\t\tType: \"input_text\",\n\t\t\tText: formattedText,\n\t\t}, nil\n\tcase part.MimeType == \"directory\":\n\t\tvar jsonContent string\n\n\t\tif len(part.Data) > 0 {\n\t\t\tjsonContent = string(part.Data)\n\t\t} else {\n\t\t\treturn nil, fmt.Errorf(\"directory listing part missing data\")\n\t\t}\n\n\t\tformattedText := aiutil.FormatAttachedDirectoryListing(part.FileName, jsonContent)\n\n\t\treturn &OpenAIMessageContent{\n\t\t\tType: \"input_text\",\n\t\t\tText: formattedText,\n\t\t}, nil\n\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"dropping file with unsupported mimetype '%s' (OpenAI supports images, PDFs, text/plain, and directories)\", part.MimeType)\n\t}\n}\n\n// ConvertAIMessageToOpenAIChatMessage converts an AIMessage to OpenAIChatMessage\n// These messages are ALWAYS role \"user\"\n// Handles text parts, images, PDFs, and text/plain files\nfunc ConvertAIMessageToOpenAIChatMessage(aiMsg uctypes.AIMessage) (*OpenAIChatMessage, error) {\n\tif err := aiMsg.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid AIMessage: %w\", err)\n\t}\n\n\tvar contentBlocks []OpenAIMessageContent\n\timageCount := 0\n\timageFailCount := 0\n\n\tfor i, part := range aiMsg.Parts {\n\t\tswitch part.Type {\n\t\tcase uctypes.AIMessagePartTypeText:\n\t\t\tif part.Text == \"\" {\n\t\t\t\treturn nil, fmt.Errorf(\"part %d: text type requires non-empty text field\", i)\n\t\t\t}\n\t\t\tcontentBlocks = append(contentBlocks, OpenAIMessageContent{\n\t\t\t\tType: \"input_text\",\n\t\t\t\tText: part.Text,\n\t\t\t})\n\n\t\tcase uctypes.AIMessagePartTypeFile:\n\t\t\tif strings.HasPrefix(part.MimeType, \"image/\") {\n\t\t\t\timageCount++\n\t\t\t}\n\t\t\tblock, err := convertFileAIMessagePart(part)\n\t\t\tif err != nil {\n\t\t\t\tif strings.HasPrefix(part.MimeType, \"image/\") {\n\t\t\t\t\timageFailCount++\n\t\t\t\t}\n\t\t\t\tlog.Printf(\"openai: %v\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontentBlocks = append(contentBlocks, *block)\n\n\t\tdefault:\n\t\t\t// Drop unknown part types\n\t\t\tlog.Printf(\"openai: dropping unknown part type '%s'\", part.Type)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tif len(contentBlocks) == 0 {\n\t\tif imageCount > 0 && imageFailCount == imageCount {\n\t\t\treturn nil, fmt.Errorf(\"all %d image conversions failed\", imageCount)\n\t\t}\n\t\treturn nil, errors.New(\"message has no valid content after processing all parts\")\n\t}\n\n\treturn &OpenAIChatMessage{\n\t\tMessageId: aiMsg.MessageId,\n\t\tMessage: &OpenAIMessage{\n\t\t\tRole:    \"user\",\n\t\t\tContent: contentBlocks,\n\t\t},\n\t}, nil\n}\n\n// ConvertToolResultsToOpenAIChatMessage converts AIToolResult slice to OpenAIChatMessage slice\nfunc ConvertToolResultsToOpenAIChatMessage(toolResults []uctypes.AIToolResult) ([]*OpenAIChatMessage, error) {\n\tif len(toolResults) == 0 {\n\t\treturn nil, errors.New(\"toolResults cannot be empty\")\n\t}\n\n\tvar messages []*OpenAIChatMessage\n\n\tfor _, result := range toolResults {\n\t\tif result.ToolUseID == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"tool result missing ToolUseID\")\n\t\t}\n\n\t\t// Create the function call output with result data\n\t\tvar outputData any\n\t\tif result.ErrorText != \"\" {\n\t\t\t// Marshal error output to string\n\t\t\terrorOutput := OpenAIFunctionCallErrorOutput{\n\t\t\t\tOk:    \"false\",\n\t\t\t\tError: result.ErrorText,\n\t\t\t}\n\t\t\terrorBytes, err := json.Marshal(errorOutput)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to marshal error output: %w\", err)\n\t\t\t}\n\t\t\toutputData = string(errorBytes)\n\t\t} else {\n\t\t\t// Check if text looks like an image data URL\n\t\t\tif strings.HasPrefix(result.Text, \"data:image/\") {\n\t\t\t\t// Convert to output array with input_image type\n\t\t\t\toutputData = []OpenAIMessageContent{\n\t\t\t\t\t{\n\t\t\t\t\t\tType:     \"input_image\",\n\t\t\t\t\t\tImageUrl: result.Text,\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Use text result for success\n\t\t\t\toutputData = result.Text\n\t\t\t}\n\t\t}\n\n\t\tfunctionCallOutput := &OpenAIFunctionCallOutputInput{\n\t\t\tType:   \"function_call_output\",\n\t\t\tCallId: result.ToolUseID,\n\t\t\tOutput: outputData,\n\t\t}\n\n\t\tmessages = append(messages, &OpenAIChatMessage{\n\t\t\tMessageId:          uuid.New().String(),\n\t\t\tFunctionCallOutput: functionCallOutput,\n\t\t})\n\t}\n\n\treturn messages, nil\n}\n\n// convertToUIMessage converts an OpenAIChatMessage to a UIMessage\nfunc (m *OpenAIChatMessage) convertToUIMessage() *uctypes.UIMessage {\n\tvar parts []uctypes.UIMessagePart\n\tvar role string\n\n\t// Handle different message types\n\tif m.Message != nil {\n\t\trole = m.Message.Role\n\t\tfor _, block := range m.Message.Content {\n\t\t\tblockParts := convertContentBlockToParts(block, role)\n\t\t\tparts = append(parts, blockParts...)\n\t\t}\n\t} else if m.FunctionCall != nil {\n\t\t// Handle function call input\n\t\trole = \"assistant\"\n\t\tif m.FunctionCall.ToolUseData != nil {\n\t\t\tparts = append(parts, uctypes.UIMessagePart{\n\t\t\t\tType: \"data-tooluse\",\n\t\t\t\tID:   m.FunctionCall.CallId,\n\t\t\t\tData: *m.FunctionCall.ToolUseData,\n\t\t\t})\n\t\t}\n\t} else if m.FunctionCallOutput != nil {\n\t\t// FunctionCallOutput messages are not converted to UIMessage\n\t\treturn nil\n\t}\n\tif len(parts) == 0 {\n\t\treturn nil\n\t}\n\treturn &uctypes.UIMessage{\n\t\tID:    m.MessageId,\n\t\tRole:  role,\n\t\tParts: parts,\n\t}\n}\n\n// ConvertAIChatToUIChat converts an AIChat to a UIChat for OpenAI\nfunc ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) {\n\tif aiChat.APIType != uctypes.APIType_OpenAIResponses {\n\t\treturn nil, fmt.Errorf(\"APIType must be '%s', got '%s'\", uctypes.APIType_OpenAIResponses, aiChat.APIType)\n\t}\n\tuiMessages := make([]uctypes.UIMessage, 0, len(aiChat.NativeMessages))\n\tfor i, nativeMsg := range aiChat.NativeMessages {\n\t\topenaiMsg, ok := nativeMsg.(*OpenAIChatMessage)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"message %d: expected *OpenAIChatMessage, got %T\", i, nativeMsg)\n\t\t}\n\t\tuiMsg := openaiMsg.convertToUIMessage()\n\t\tif uiMsg != nil {\n\t\t\tuiMessages = append(uiMessages, *uiMsg)\n\t\t}\n\t}\n\treturn &uctypes.UIChat{\n\t\tChatId:     aiChat.ChatId,\n\t\tAPIType:    aiChat.APIType,\n\t\tModel:      aiChat.Model,\n\t\tAPIVersion: aiChat.APIVersion,\n\t\tMessages:   uiMessages,\n\t}, nil\n}\n\n// GetFunctionCallInputByToolCallId returns the OpenAIFunctionCallInput associated with the given ToolCallId,\n// or nil if not found in the AIChat\nfunc GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *OpenAIFunctionCallInput {\n\tfor _, nativeMsg := range aiChat.NativeMessages {\n\t\topenaiMsg, ok := nativeMsg.(*OpenAIChatMessage)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif openaiMsg.FunctionCall != nil && openaiMsg.FunctionCall.CallId == toolCallId {\n\t\t\treturn openaiMsg.FunctionCall\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/aiusechat/openai/openai-util.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage openai\n\nimport (\n\t\"encoding/json\"\n\t\"log\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n)\n\nfunc debugPrintInput(idx int, input any) {\n\tswitch v := input.(type) {\n\tcase OpenAIMessage:\n\t\tlog.Printf(\"  [%d] message role=%s blocks=%d\", idx, v.Role, len(v.Content))\n\t\tfor _, block := range v.Content {\n\t\t\tswitch block.Type {\n\t\t\tcase \"input_text\":\n\t\t\t\tlog.Printf(\"    - text: %q\", utilfn.TruncateString(block.Text, 40))\n\t\t\tcase \"input_image\":\n\t\t\t\tsize := len(block.ImageUrl)\n\t\t\t\tlog.Printf(\"    - image: size=%d\", size)\n\t\t\tcase \"input_file\":\n\t\t\t\tsize := len(block.FileData)\n\t\t\t\tlog.Printf(\"    - file: name=%s size=%d\", block.Filename, size)\n\t\t\tcase \"function_call\":\n\t\t\t\tlog.Printf(\"    - function_call: name=%s callid=%s\", block.Name, block.CallId)\n\t\t\tdefault:\n\t\t\t\tlog.Printf(\"    - %s\", block.Type)\n\t\t\t}\n\t\t}\n\tcase OpenAIFunctionCallInput:\n\t\tlog.Printf(\"  [%d] function_call: name=%s callid=%s args_len=%d\", idx, v.Name, v.CallId, len(v.Arguments))\n\tcase OpenAIFunctionCallOutputInput:\n\t\toutputSize := 0\n\t\tif outputBytes, err := json.Marshal(v.Output); err == nil {\n\t\t\toutputSize = len(outputBytes)\n\t\t}\n\t\tlog.Printf(\"  [%d] function_call_output: callid=%s output_size=%d\", idx, v.CallId, outputSize)\n\tdefault:\n\t\tlog.Printf(\"  [%d] unknown type: %T\", idx, input)\n\t}\n}"
  },
  {
    "path": "pkg/aiusechat/openai/stream-sample.txt",
    "content": "SSE Stream:\n---\nevent: response.created\ndata: {\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"resp_68d45a9c658c81979a8e5172ecba5f220b4ef1d7c1786ac7\",\"object\":\"response\",\"created_at\":1758747292,\"status\":\"in_progress\",\"background\":false,\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-5-mini-2025-08-07\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"prompt_cache_key\":null,\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}}}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"sequence_number\":1,\"response\":{\"id\":\"resp_68d45a9c658c81979a8e5172ecba5f220b4ef1d7c1786ac7\",\"object\":\"response\",\"created_at\":1758747292,\"status\":\"in_progress\",\"background\":false,\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-5-mini-2025-08-07\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"prompt_cache_key\":null,\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}}}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"sequence_number\":2,\"output_index\":0,\"item\":{\"id\":\"rs_68d45a9da0388197ae920ca9f2c1864e0b4ef1d7c1786ac7\",\"type\":\"reasoning\",\"summary\":[]}}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"sequence_number\":3,\"output_index\":0,\"item\":{\"id\":\"rs_68d45a9da0388197ae920ca9f2c1864e0b4ef1d7c1786ac7\",\"type\":\"reasoning\",\"summary\":[]}}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"sequence_number\":4,\"output_index\":1,\"item\":{\"id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"}}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"sequence_number\":5,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":6,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\"Hi\",\"logprobs\":[],\"obfuscation\":\"HMDbO6ayWjW1W2\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":7,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\"!\",\"logprobs\":[],\"obfuscation\":\"O51xaIoYtnbIPAD\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":8,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" How\",\"logprobs\":[],\"obfuscation\":\"xadqbnQ3oyln\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":9,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" can\",\"logprobs\":[],\"obfuscation\":\"T8A4BDNVTT9d\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":10,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" I\",\"logprobs\":[],\"obfuscation\":\"Rt0Kkzk6YBpji2\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":11,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" help\",\"logprobs\":[],\"obfuscation\":\"utZij4PIdNV\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":12,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" you\",\"logprobs\":[],\"obfuscation\":\"c0WOfWx4Zgn6\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":13,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" today\",\"logprobs\":[],\"obfuscation\":\"ZJkSB4IZKP\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":14,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\"?\",\"logprobs\":[],\"obfuscation\":\"gmnBYJ1iN9uvcOu\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":15,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" \\n\\n\",\"logprobs\":[],\"obfuscation\":\"ammzfU3E2ygBL\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":16,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\"You\",\"logprobs\":[],\"obfuscation\":\"4WNbNS8ETwBN3\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":17,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" can\",\"logprobs\":[],\"obfuscation\":\"UeG90g1fjT3j\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":18,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" ask\",\"logprobs\":[],\"obfuscation\":\"ZNtD4dweBhuK\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":19,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" me\",\"logprobs\":[],\"obfuscation\":\"KTiA4OFK7OLNF\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":20,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" to\",\"logprobs\":[],\"obfuscation\":\"sCkh2W9nJSyn4\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":21,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" answer\",\"logprobs\":[],\"obfuscation\":\"EuWGaJ7dY\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":22,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" questions\",\"logprobs\":[],\"obfuscation\":\"xBv16Y\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":23,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\",\",\"logprobs\":[],\"obfuscation\":\"lSDlvMfjmfE2fvQ\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":24,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" draft\",\"logprobs\":[],\"obfuscation\":\"WpDE4p4owZ\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":25,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" or\",\"logprobs\":[],\"obfuscation\":\"6wbphTdqVYDXt\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":26,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" edit\",\"logprobs\":[],\"obfuscation\":\"GpSyBD5TDaC\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":27,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" text\",\"logprobs\":[],\"obfuscation\":\"GmPoqcLxX7q\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":28,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\",\",\"logprobs\":[],\"obfuscation\":\"uccg3mjG8MZ6Cka\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":29,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" write\",\"logprobs\":[],\"obfuscation\":\"H1PRUtUJ69\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":30,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" code\",\"logprobs\":[],\"obfuscation\":\"puHievT1qB0\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":31,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\",\",\"logprobs\":[],\"obfuscation\":\"8GilKv17xtSWLjk\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":32,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" brainstorm\",\"logprobs\":[],\"obfuscation\":\"DbjVN\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":33,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" ideas\",\"logprobs\":[],\"obfuscation\":\"5hZX5Gyav7\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":34,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\",\",\"logprobs\":[],\"obfuscation\":\"QI3yTbBEjw6pXp0\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":35,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" summarize\",\"logprobs\":[],\"obfuscation\":\"e9BfWi\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":36,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" something\",\"logprobs\":[],\"obfuscation\":\"L2haa7\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":37,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\",\",\"logprobs\":[],\"obfuscation\":\"kEk4GIDDD2KAyJa\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":38,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" translate\",\"logprobs\":[],\"obfuscation\":\"FwqG3o\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":39,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\",\",\"logprobs\":[],\"obfuscation\":\"u86XCSwAd64WOpl\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":40,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" or\",\"logprobs\":[],\"obfuscation\":\"VucQ7yhFdQDxU\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":41,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" anything\",\"logprobs\":[],\"obfuscation\":\"gSdKLJF\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":42,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" else\",\"logprobs\":[],\"obfuscation\":\"C1tHZN4v4L8\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":43,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" —\",\"logprobs\":[],\"obfuscation\":\"a0SpHUtgndp3ag\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":44,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" what\",\"logprobs\":[],\"obfuscation\":\"3CcLqzdGwYf\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":45,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" do\",\"logprobs\":[],\"obfuscation\":\"dDtJSJMevS0aF\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":46,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" you\",\"logprobs\":[],\"obfuscation\":\"fAartZUPIxEh\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":47,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\" need\",\"logprobs\":[],\"obfuscation\":\"rAygh1fmDO6\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":48,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"delta\":\"?\",\"logprobs\":[],\"obfuscation\":\"5aooX5ENzirqMuu\"}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"sequence_number\":49,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"text\":\"Hi! How can I help you today? \\n\\nYou can ask me to answer questions, draft or edit text, write code, brainstorm ideas, summarize something, translate, or anything else — what do you need?\",\"logprobs\":[]}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"sequence_number\":50,\"item_id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"output_index\":1,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi! How can I help you today? \\n\\nYou can ask me to answer questions, draft or edit text, write code, brainstorm ideas, summarize something, translate, or anything else — what do you need?\"}}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"sequence_number\":51,\"output_index\":1,\"item\":{\"id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi! How can I help you today? \\n\\nYou can ask me to answer questions, draft or edit text, write code, brainstorm ideas, summarize something, translate, or anything else — what do you need?\"}],\"role\":\"assistant\"}}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"sequence_number\":52,\"response\":{\"id\":\"resp_68d45a9c658c81979a8e5172ecba5f220b4ef1d7c1786ac7\",\"object\":\"response\",\"created_at\":1758747292,\"status\":\"completed\",\"background\":false,\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-5-mini-2025-08-07\",\"output\":[{\"id\":\"rs_68d45a9da0388197ae920ca9f2c1864e0b4ef1d7c1786ac7\",\"type\":\"reasoning\",\"summary\":[]},{\"id\":\"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi! How can I help you today? \\n\\nYou can ask me to answer questions, draft or edit text, write code, brainstorm ideas, summarize something, translate, or anything else — what do you need?\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"prompt_cache_key\":null,\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":7,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":113,\"output_tokens_details\":{\"reasoning_tokens\":64},\"total_tokens\":120},\"user\":null,\"metadata\":{}}}\n\n"
  },
  {
    "path": "pkg/aiusechat/openai/tool-sample.txt",
    "content": "event: response.created\ndata: {\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"resp_68d72548e6f0819096e9a659a61b96c1041f8a7af432fbe9\",\"object\":\"response\",\"created_at\":1758930249,\"status\":\"in_progress\",\"background\":false,\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-5-mini-2025-08-07\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"prompt_cache_key\":null,\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Add an array of numbers together and return their sum\",\"name\":\"adder\",\"parameters\":{\"additionalProperties\":false,\"properties\":{\"values\":{\"description\":\"Array of numbers to add together\",\"items\":{\"type\":\"integer\"},\"type\":\"array\"}},\"required\":[\"values\"],\"type\":\"object\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}}}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"sequence_number\":1,\"response\":{\"id\":\"resp_68d72548e6f0819096e9a659a61b96c1041f8a7af432fbe9\",\"object\":\"response\",\"created_at\":1758930249,\"status\":\"in_progress\",\"background\":false,\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-5-mini-2025-08-07\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"prompt_cache_key\":null,\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Add an array of numbers together and return their sum\",\"name\":\"adder\",\"parameters\":{\"additionalProperties\":false,\"properties\":{\"values\":{\"description\":\"Array of numbers to add together\",\"items\":{\"type\":\"integer\"},\"type\":\"array\"}},\"required\":[\"values\"],\"type\":\"object\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}}}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"sequence_number\":2,\"output_index\":0,\"item\":{\"id\":\"rs_68d72549a9888190b9efd3f27772f130041f8a7af432fbe9\",\"type\":\"reasoning\",\"summary\":[]}}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"sequence_number\":3,\"output_index\":0,\"item\":{\"id\":\"rs_68d72549a9888190b9efd3f27772f130041f8a7af432fbe9\",\"type\":\"reasoning\",\"summary\":[]}}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"sequence_number\":4,\"output_index\":1,\"item\":{\"id\":\"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_41sXC2VsjlN9yHucWy2980Og\",\"name\":\"adder\"}}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"sequence_number\":5,\"item_id\":\"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9\",\"output_index\":1,\"delta\":\"{\\\"\"}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"sequence_number\":6,\"item_id\":\"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9\",\"output_index\":1,\"delta\":\"values\"}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"sequence_number\":7,\"item_id\":\"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9\",\"output_index\":1,\"delta\":\"\\\":[\"}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"sequence_number\":8,\"item_id\":\"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9\",\"output_index\":1,\"delta\":\"2\"}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"sequence_number\":9,\"item_id\":\"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9\",\"output_index\":1,\"delta\":\",\"}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"sequence_number\":10,\"item_id\":\"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9\",\"output_index\":1,\"delta\":\"2\"}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"sequence_number\":11,\"item_id\":\"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9\",\"output_index\":1,\"delta\":\"]}\"}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"sequence_number\":12,\"item_id\":\"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9\",\"output_index\":1,\"arguments\":\"{\\\"values\\\":[2,2]}\"}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"sequence_number\":13,\"output_index\":1,\"item\":{\"id\":\"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"values\\\":[2,2]}\",\"call_id\":\"call_41sXC2VsjlN9yHucWy2980Og\",\"name\":\"adder\"}}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"sequence_number\":14,\"response\":{\"id\":\"resp_68d72548e6f0819096e9a659a61b96c1041f8a7af432fbe9\",\"object\":\"response\",\"created_at\":1758930249,\"status\":\"completed\",\"background\":false,\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-5-mini-2025-08-07\",\"output\":[{\"id\":\"rs_68d72549a9888190b9efd3f27772f130041f8a7af432fbe9\",\"type\":\"reasoning\",\"summary\":[]},{\"id\":\"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"values\\\":[2,2]}\",\"call_id\":\"call_41sXC2VsjlN9yHucWy2980Og\",\"name\":\"adder\"}],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"prompt_cache_key\":null,\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Add an array of numbers together and return their sum\",\"name\":\"adder\",\"parameters\":{\"additionalProperties\":false,\"properties\":{\"values\":{\"description\":\"Array of numbers to add together\",\"items\":{\"type\":\"integer\"},\"type\":\"array\"}},\"required\":[\"values\"],\"type\":\"object\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":67,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":86,\"output_tokens_details\":{\"reasoning_tokens\":64},\"total_tokens\":153},\"user\":null,\"metadata\":{}}}"
  },
  {
    "path": "pkg/aiusechat/openaichat/openaichat-backend.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage openaichat\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/launchdarkly/eventsource\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/web/sse\"\n)\n\n// RunChatStep executes a chat step using the chat completions API\nfunc RunChatStep(\n\tctx context.Context,\n\tsseHandler *sse.SSEHandlerCh,\n\tchatOpts uctypes.WaveChatOpts,\n\tcont *uctypes.WaveContinueResponse,\n) (*uctypes.WaveStopReason, []*StoredChatMessage, *uctypes.RateLimitInfo, error) {\n\tif sseHandler == nil {\n\t\treturn nil, nil, nil, errors.New(\"sse handler is nil\")\n\t}\n\n\tchat := chatstore.DefaultChatStore.Get(chatOpts.ChatId)\n\tif chat == nil {\n\t\treturn nil, nil, nil, fmt.Errorf(\"chat not found: %s\", chatOpts.ChatId)\n\t}\n\n\tif chatOpts.Config.TimeoutMs > 0 {\n\t\tvar cancel context.CancelFunc\n\t\tctx, cancel = context.WithTimeout(ctx, time.Duration(chatOpts.Config.TimeoutMs)*time.Millisecond)\n\t\tdefer cancel()\n\t}\n\n\t// Convert stored messages to chat completions format\n\tvar messages []ChatRequestMessage\n\n\t// Convert native messages\n\tfor _, genMsg := range chat.NativeMessages {\n\t\tchatMsg, ok := genMsg.(*StoredChatMessage)\n\t\tif !ok {\n\t\t\treturn nil, nil, nil, fmt.Errorf(\"expected StoredChatMessage, got %T\", genMsg)\n\t\t}\n\t\tmessages = append(messages, *chatMsg.Message.clean())\n\t}\n\n\treq, err := buildChatHTTPRequest(ctx, messages, chatOpts)\n\tif err != nil {\n\t\treturn nil, nil, nil, err\n\t}\n\n\tclient, err := aiutil.MakeHTTPClient(chatOpts.Config.ProxyURL)\n\tif err != nil {\n\t\treturn nil, nil, nil, err\n\t}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, nil, nil, fmt.Errorf(\"request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\treturn nil, nil, nil, fmt.Errorf(\"API returned status %d: %s\", resp.StatusCode, string(bodyBytes))\n\t}\n\n\t// Setup SSE if this is a new request (not a continuation)\n\tif cont == nil {\n\t\tif err := sseHandler.SetupSSE(); err != nil {\n\t\t\treturn nil, nil, nil, fmt.Errorf(\"failed to setup SSE: %w\", err)\n\t\t}\n\t}\n\n\t// Stream processing\n\tstopReason, assistantMsg, err := processChatStream(ctx, resp.Body, sseHandler, chatOpts, cont)\n\tif err != nil {\n\t\treturn nil, nil, nil, err\n\t}\n\n\treturn stopReason, []*StoredChatMessage{assistantMsg}, nil, nil\n}\n\nfunc processChatStream(\n\tctx context.Context,\n\tbody io.Reader,\n\tsseHandler *sse.SSEHandlerCh,\n\tchatOpts uctypes.WaveChatOpts,\n\tcont *uctypes.WaveContinueResponse,\n) (*uctypes.WaveStopReason, *StoredChatMessage, error) {\n\tdecoder := eventsource.NewDecoder(body)\n\tvar textBuilder strings.Builder\n\tmsgID := uuid.New().String()\n\ttextID := uuid.New().String()\n\tvar finishReason string\n\ttextStarted := false\n\tvar toolCallsInProgress []ToolCall\n\n\tif cont == nil {\n\t\t_ = sseHandler.AiMsgStart(msgID)\n\t}\n\t_ = sseHandler.AiMsgStartStep()\n\n\tfor {\n\t\tif err := ctx.Err(); err != nil {\n\t\t\t_ = sseHandler.AiMsgError(\"request cancelled\")\n\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindCanceled,\n\t\t\t\tErrorType: \"cancelled\",\n\t\t\t\tErrorText: \"request cancelled\",\n\t\t\t}, nil, err\n\t\t}\n\n\t\tevent, err := decoder.Decode()\n\t\tif err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sseHandler.Err() != nil {\n\t\t\t\tpartialMsg := extractPartialTextMessage(msgID, textBuilder.String())\n\t\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\t\tKind:      uctypes.StopKindCanceled,\n\t\t\t\t\tErrorType: \"client_disconnect\",\n\t\t\t\t\tErrorText: \"client disconnected\",\n\t\t\t\t}, partialMsg, nil\n\t\t\t}\n\t\t\t_ = sseHandler.AiMsgError(err.Error())\n\t\t\treturn &uctypes.WaveStopReason{\n\t\t\t\tKind:      uctypes.StopKindError,\n\t\t\t\tErrorType: \"stream\",\n\t\t\t\tErrorText: err.Error(),\n\t\t\t}, nil, fmt.Errorf(\"stream decode error: %w\", err)\n\t\t}\n\n\t\tdata := event.Data()\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar chunk StreamChunk\n\t\tif err := json.Unmarshal([]byte(data), &chunk); err != nil {\n\t\t\tlog.Printf(\"openaichat: failed to parse chunk: %v\\n\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif len(chunk.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tchoice := chunk.Choices[0]\n\t\tif choice.Delta.Content != \"\" {\n\t\t\tif !textStarted {\n\t\t\t\t_ = sseHandler.AiMsgTextStart(textID)\n\t\t\t\ttextStarted = true\n\t\t\t}\n\t\t\ttextBuilder.WriteString(choice.Delta.Content)\n\t\t\t_ = sseHandler.AiMsgTextDelta(textID, choice.Delta.Content)\n\t\t}\n\n\t\tif len(choice.Delta.ToolCalls) > 0 {\n\t\t\tfor _, tcDelta := range choice.Delta.ToolCalls {\n\t\t\t\tidx := tcDelta.Index\n\t\t\t\tfor len(toolCallsInProgress) <= idx {\n\t\t\t\t\ttoolCallsInProgress = append(toolCallsInProgress, ToolCall{Type: \"function\"})\n\t\t\t\t}\n\n\t\t\t\ttc := &toolCallsInProgress[idx]\n\t\t\t\tif tcDelta.ID != \"\" {\n\t\t\t\t\ttc.ID = tcDelta.ID\n\t\t\t\t}\n\t\t\t\tif tcDelta.Type != \"\" {\n\t\t\t\t\ttc.Type = tcDelta.Type\n\t\t\t\t}\n\t\t\t\tif tcDelta.Function != nil {\n\t\t\t\t\tif tcDelta.Function.Name != \"\" {\n\t\t\t\t\t\ttc.Function.Name = tcDelta.Function.Name\n\t\t\t\t\t}\n\t\t\t\t\tif tcDelta.Function.Arguments != \"\" {\n\t\t\t\t\t\ttc.Function.Arguments += tcDelta.Function.Arguments\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif choice.FinishReason != nil && *choice.FinishReason != \"\" {\n\t\t\tfinishReason = *choice.FinishReason\n\t\t}\n\t}\n\n\tstopKind := uctypes.StopKindDone\n\tif finishReason == \"length\" {\n\t\tstopKind = uctypes.StopKindMaxTokens\n\t} else if finishReason == \"tool_calls\" {\n\t\tstopKind = uctypes.StopKindToolUse\n\t}\n\n\tvar validToolCalls []ToolCall\n\tfor _, tc := range toolCallsInProgress {\n\t\tif tc.ID != \"\" && tc.Function.Name != \"\" {\n\t\t\tvalidToolCalls = append(validToolCalls, tc)\n\t\t}\n\t}\n\n\tvar waveToolCalls []uctypes.WaveToolCall\n\tif len(validToolCalls) > 0 {\n\t\tfor _, tc := range validToolCalls {\n\t\t\tvar inputJSON any\n\t\t\tif tc.Function.Arguments != \"\" {\n\t\t\t\tif err := json.Unmarshal([]byte(tc.Function.Arguments), &inputJSON); err != nil {\n\t\t\t\t\tlog.Printf(\"openaichat: failed to parse tool call arguments: %v\\n\", err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\twaveToolCalls = append(waveToolCalls, uctypes.WaveToolCall{\n\t\t\t\tID:    tc.ID,\n\t\t\t\tName:  tc.Function.Name,\n\t\t\t\tInput: inputJSON,\n\t\t\t})\n\t\t}\n\t}\n\n\tstopReason := &uctypes.WaveStopReason{\n\t\tKind:      stopKind,\n\t\tRawReason: finishReason,\n\t\tToolCalls: waveToolCalls,\n\t}\n\n\tassistantMsg := &StoredChatMessage{\n\t\tMessageId: msgID,\n\t\tMessage: ChatRequestMessage{\n\t\t\tRole: \"assistant\",\n\t\t},\n\t}\n\n\tif len(validToolCalls) > 0 {\n\t\tassistantMsg.Message.ToolCalls = validToolCalls\n\t} else {\n\t\tassistantMsg.Message.Content = textBuilder.String()\n\t}\n\n\tif textStarted {\n\t\t_ = sseHandler.AiMsgTextEnd(textID)\n\t}\n\t_ = sseHandler.AiMsgFinishStep()\n\tif stopKind != uctypes.StopKindToolUse {\n\t\t_ = sseHandler.AiMsgFinish(finishReason, nil)\n\t}\n\n\treturn stopReason, assistantMsg, nil\n}\n\nfunc extractPartialTextMessage(msgID string, text string) *StoredChatMessage {\n\tif text == \"\" {\n\t\treturn nil\n\t}\n\n\treturn &StoredChatMessage{\n\t\tMessageId: msgID,\n\t\tMessage: ChatRequestMessage{\n\t\t\tRole:    \"assistant\",\n\t\t\tContent: text,\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/openaichat/openaichat-convertmessage.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage openaichat\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\nconst (\n\tOpenAIChatDefaultMaxTokens = 4096\n)\n\n// appendToLastUserMessage appends text to the last user message in the messages slice\nfunc appendToLastUserMessage(messages []ChatRequestMessage, text string) {\n\tfor i := len(messages) - 1; i >= 0; i-- {\n\t\tif messages[i].Role == \"user\" {\n\t\t\tif len(messages[i].ContentParts) > 0 {\n\t\t\t\tmessages[i].ContentParts = append(messages[i].ContentParts, ChatContentPart{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: text,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tmessages[i].Content += \"\\n\\n\" + text\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n}\n\n// convertToolDefinitions converts Wave ToolDefinitions to OpenAI format\n// Only includes tools whose required capabilities are met\nfunc convertToolDefinitions(waveTools []uctypes.ToolDefinition, capabilities []string) []ToolDefinition {\n\tif len(waveTools) == 0 {\n\t\treturn nil\n\t}\n\n\topenaiTools := make([]ToolDefinition, 0, len(waveTools))\n\tfor _, waveTool := range waveTools {\n\t\tif !waveTool.HasRequiredCapabilities(capabilities) {\n\t\t\tcontinue\n\t\t}\n\t\topenaiTool := ToolDefinition{\n\t\t\tType: \"function\",\n\t\t\tFunction: ToolFunctionDef{\n\t\t\t\tName:        waveTool.Name,\n\t\t\t\tDescription: waveTool.Description,\n\t\t\t\tParameters:  waveTool.InputSchema,\n\t\t\t},\n\t\t}\n\t\topenaiTools = append(openaiTools, openaiTool)\n\t}\n\treturn openaiTools\n}\n\n// buildChatHTTPRequest creates an HTTP request for the OpenAI chat completions API\nfunc buildChatHTTPRequest(ctx context.Context, messages []ChatRequestMessage, chatOpts uctypes.WaveChatOpts) (*http.Request, error) {\n\topts := chatOpts.Config\n\n\t// Model is required for all providers except azure-legacy (which uses deployment name in URL)\n\tif opts.Model == \"\" && opts.Provider != uctypes.AIProvider_AzureLegacy {\n\t\treturn nil, errors.New(\"ai:model is required\")\n\t}\n\tif opts.Endpoint == \"\" {\n\t\treturn nil, errors.New(\"ai:endpoint is required\")\n\t}\n\n\tmaxTokens := opts.MaxTokens\n\tif maxTokens <= 0 {\n\t\tmaxTokens = OpenAIChatDefaultMaxTokens\n\t}\n\n\tfinalMessages := messages\n\tif len(chatOpts.SystemPrompt) > 0 {\n\t\tsystemMessage := ChatRequestMessage{\n\t\t\tRole:    \"system\",\n\t\t\tContent: strings.Join(chatOpts.SystemPrompt, \"\\n\\n\"),\n\t\t}\n\t\tfinalMessages = append([]ChatRequestMessage{systemMessage}, messages...)\n\t}\n\n\t// injected data\n\tif chatOpts.TabState != \"\" {\n\t\tappendToLastUserMessage(finalMessages, chatOpts.TabState)\n\t}\n\tif chatOpts.PlatformInfo != \"\" {\n\t\tappendToLastUserMessage(finalMessages, \"<PlatformInfo>\\n\"+chatOpts.PlatformInfo+\"\\n</PlatformInfo>\")\n\t}\n\n\treqBody := &ChatRequest{\n\t\tMessages: finalMessages,\n\t\tStream:   true,\n\t}\n\n\t// Model is only added to request for non-azure-legacy providers\n\tif opts.Provider != uctypes.AIProvider_AzureLegacy {\n\t\treqBody.Model = opts.Model\n\t}\n\n\tif aiutil.IsOpenAIReasoningModel(opts.Model) {\n\t\treqBody.MaxCompletionTokens = maxTokens\n\t} else {\n\t\treqBody.MaxTokens = maxTokens\n\t}\n\n\t// Add tool definitions if tools capability is available and tools exist\n\tvar allTools []uctypes.ToolDefinition\n\tif opts.HasCapability(uctypes.AICapabilityTools) {\n\t\tallTools = append(allTools, chatOpts.Tools...)\n\t\tallTools = append(allTools, chatOpts.TabTools...)\n\t\tif len(allTools) > 0 {\n\t\t\treqBody.Tools = convertToolDefinitions(allTools, opts.Capabilities)\n\t\t}\n\t}\n\n\tif wavebase.IsDevMode() {\n\t\tlog.Printf(\"openaichat: model %s, messages: %d, tools: %d\\n\", opts.Model, len(messages), len(allTools))\n\t}\n\n\tbuf, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, opts.Endpoint, bytes.NewReader(buf))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t// Azure OpenAI uses \"api-key\" header instead of \"Authorization: Bearer\"\n\tif opts.Provider == uctypes.AIProvider_Azure || opts.Provider == uctypes.AIProvider_AzureLegacy {\n\t\treq.Header.Set(\"api-key\", opts.APIToken)\n\t} else {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+opts.APIToken)\n\t}\n\n\treq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t// Only send Wave-specific headers when using Wave provider\n\tif opts.Provider == uctypes.AIProvider_Wave {\n\t\tif chatOpts.ClientId != \"\" {\n\t\t\treq.Header.Set(\"X-Wave-ClientId\", chatOpts.ClientId)\n\t\t}\n\t\tif chatOpts.ChatId != \"\" {\n\t\t\treq.Header.Set(\"X-Wave-ChatId\", chatOpts.ChatId)\n\t\t}\n\t\treq.Header.Set(\"X-Wave-Version\", wavebase.WaveVersion)\n\t\treq.Header.Set(\"X-Wave-APIType\", uctypes.APIType_OpenAIChat)\n\t\treq.Header.Set(\"X-Wave-RequestType\", chatOpts.GetWaveRequestType())\n\t}\n\n\treturn req, nil\n}\n\n// ConvertAIMessageToStoredChatMessage converts an AIMessage to StoredChatMessage\n// These messages are ALWAYS role \"user\"\nfunc ConvertAIMessageToStoredChatMessage(aiMsg uctypes.AIMessage) (*StoredChatMessage, error) {\n\tif err := aiMsg.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid AIMessage: %w\", err)\n\t}\n\n\thasImages := false\n\tfor _, part := range aiMsg.Parts {\n\t\tif strings.HasPrefix(part.MimeType, \"image/\") {\n\t\t\thasImages = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif hasImages {\n\t\treturn convertAIMessageMultimodal(aiMsg)\n\t}\n\treturn convertAIMessageTextOnly(aiMsg)\n}\n\nfunc convertAIMessageTextOnly(aiMsg uctypes.AIMessage) (*StoredChatMessage, error) {\n\tvar textBuilder strings.Builder\n\tfirstText := true\n\tfor _, part := range aiMsg.Parts {\n\t\tvar partText string\n\n\t\tswitch {\n\t\tcase part.Type == uctypes.AIMessagePartTypeText:\n\t\t\tpartText = part.Text\n\n\t\tcase part.MimeType == \"text/plain\":\n\t\t\ttextData, err := aiutil.ExtractTextData(part.Data, part.URL)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"openaichat: error extracting text data for %s: %v\\n\", part.FileName, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tpartText = aiutil.FormatAttachedTextFile(part.FileName, textData)\n\n\t\tcase part.MimeType == \"directory\":\n\t\t\tif len(part.Data) == 0 {\n\t\t\t\tlog.Printf(\"openaichat: directory listing part missing data for %s\\n\", part.FileName)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tpartText = aiutil.FormatAttachedDirectoryListing(part.FileName, string(part.Data))\n\n\t\tdefault:\n\t\t\tcontinue\n\t\t}\n\n\t\tif partText != \"\" {\n\t\t\tif !firstText {\n\t\t\t\ttextBuilder.WriteString(\"\\n\\n\")\n\t\t\t}\n\t\t\ttextBuilder.WriteString(partText)\n\t\t\tfirstText = false\n\t\t}\n\t}\n\n\treturn &StoredChatMessage{\n\t\tMessageId: aiMsg.MessageId,\n\t\tMessage: ChatRequestMessage{\n\t\t\tRole:    \"user\",\n\t\t\tContent: textBuilder.String(),\n\t\t},\n\t}, nil\n}\n\nfunc convertAIMessageMultimodal(aiMsg uctypes.AIMessage) (*StoredChatMessage, error) {\n\tvar contentParts []ChatContentPart\n\timageCount := 0\n\timageFailCount := 0\n\n\tfor _, part := range aiMsg.Parts {\n\t\tswitch {\n\t\tcase part.Type == uctypes.AIMessagePartTypeText:\n\t\t\tif part.Text != \"\" {\n\t\t\t\tcontentParts = append(contentParts, ChatContentPart{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: part.Text,\n\t\t\t\t})\n\t\t\t}\n\n\t\tcase strings.HasPrefix(part.MimeType, \"image/\"):\n\t\t\timageCount++\n\t\t\timageUrl, err := aiutil.ExtractImageUrl(part.Data, part.URL, part.MimeType)\n\t\t\tif err != nil {\n\t\t\t\timageFailCount++\n\t\t\t\tlog.Printf(\"openaichat: error extracting image URL for %s: %v\\n\", part.FileName, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontentParts = append(contentParts, ChatContentPart{\n\t\t\t\tType:       \"image_url\",\n\t\t\t\tImageUrl:   &ChatImageUrl{Url: imageUrl},\n\t\t\t\tFileName:   part.FileName,\n\t\t\t\tPreviewUrl: part.PreviewUrl,\n\t\t\t\tMimeType:   part.MimeType,\n\t\t\t})\n\n\t\tcase part.MimeType == \"text/plain\":\n\t\t\ttextData, err := aiutil.ExtractTextData(part.Data, part.URL)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"openaichat: error extracting text data for %s: %v\\n\", part.FileName, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tformattedText := aiutil.FormatAttachedTextFile(part.FileName, textData)\n\t\t\tif formattedText != \"\" {\n\t\t\t\tcontentParts = append(contentParts, ChatContentPart{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: formattedText,\n\t\t\t\t})\n\t\t\t}\n\n\t\tcase part.MimeType == \"directory\":\n\t\t\tif len(part.Data) == 0 {\n\t\t\t\tlog.Printf(\"openaichat: directory listing part missing data for %s\\n\", part.FileName)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tformattedText := aiutil.FormatAttachedDirectoryListing(part.FileName, string(part.Data))\n\t\t\tif formattedText != \"\" {\n\t\t\t\tcontentParts = append(contentParts, ChatContentPart{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: formattedText,\n\t\t\t\t})\n\t\t\t}\n\n\t\tcase part.MimeType == \"application/pdf\":\n\t\t\tlog.Printf(\"openaichat: PDF attachments are not supported by Chat Completions API, skipping %s\\n\", part.FileName)\n\t\t\tcontinue\n\n\t\tdefault:\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tif len(contentParts) == 0 {\n\t\tif imageCount > 0 && imageFailCount == imageCount {\n\t\t\treturn nil, fmt.Errorf(\"all %d image conversions failed\", imageCount)\n\t\t}\n\t\treturn nil, errors.New(\"message has no valid content after processing all parts\")\n\t}\n\n\treturn &StoredChatMessage{\n\t\tMessageId: aiMsg.MessageId,\n\t\tMessage: ChatRequestMessage{\n\t\t\tRole:         \"user\",\n\t\t\tContentParts: contentParts,\n\t\t},\n\t}, nil\n}\n\n// ConvertToolResultsToNativeChatMessage converts tool results to OpenAI tool messages\nfunc ConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error) {\n\tif len(toolResults) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tmessages := make([]uctypes.GenAIMessage, 0, len(toolResults))\n\tfor _, toolResult := range toolResults {\n\t\tvar content string\n\t\tif toolResult.ErrorText != \"\" {\n\t\t\tcontent = fmt.Sprintf(\"Error: %s\", toolResult.ErrorText)\n\t\t} else {\n\t\t\tcontent = toolResult.Text\n\t\t}\n\n\t\tmsg := &StoredChatMessage{\n\t\t\tMessageId: toolResult.ToolUseID,\n\t\t\tMessage: ChatRequestMessage{\n\t\t\t\tRole:       \"tool\",\n\t\t\t\tToolCallID: toolResult.ToolUseID,\n\t\t\t\tName:       toolResult.ToolName,\n\t\t\t\tContent:    content,\n\t\t\t},\n\t\t}\n\t\tmessages = append(messages, msg)\n\t}\n\n\treturn messages, nil\n}\n\n// ConvertAIChatToUIChat converts stored chat to UI format\nfunc ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) {\n\tuiChat := &uctypes.UIChat{\n\t\tChatId:     aiChat.ChatId,\n\t\tAPIType:    aiChat.APIType,\n\t\tModel:      aiChat.Model,\n\t\tAPIVersion: aiChat.APIVersion,\n\t\tMessages:   make([]uctypes.UIMessage, 0, len(aiChat.NativeMessages)),\n\t}\n\n\tfor _, genMsg := range aiChat.NativeMessages {\n\t\tchatMsg, ok := genMsg.(*StoredChatMessage)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar parts []uctypes.UIMessagePart\n\n\t\tif len(chatMsg.Message.ContentParts) > 0 {\n\t\t\tfor _, cp := range chatMsg.Message.ContentParts {\n\t\t\t\tswitch cp.Type {\n\t\t\t\tcase \"text\":\n\t\t\t\t\tif found, part := aiutil.ConvertDataUserFile(cp.Text); found {\n\t\t\t\t\t\tif part != nil {\n\t\t\t\t\t\t\tparts = append(parts, *part)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tparts = append(parts, uctypes.UIMessagePart{\n\t\t\t\t\t\t\tType: \"text\",\n\t\t\t\t\t\t\tText: cp.Text,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\tcase \"image_url\":\n\t\t\t\t\tmimeType := cp.MimeType\n\t\t\t\t\tif mimeType == \"\" {\n\t\t\t\t\t\tmimeType = \"image/*\"\n\t\t\t\t\t}\n\t\t\t\t\tparts = append(parts, uctypes.UIMessagePart{\n\t\t\t\t\t\tType: \"data-userfile\",\n\t\t\t\t\t\tData: uctypes.UIMessageDataUserFile{\n\t\t\t\t\t\t\tFileName:   cp.FileName,\n\t\t\t\t\t\t\tMimeType:   mimeType,\n\t\t\t\t\t\t\tPreviewUrl: cp.PreviewUrl,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t} else if chatMsg.Message.Content != \"\" {\n\t\t\tparts = append(parts, uctypes.UIMessagePart{\n\t\t\t\tType: \"text\",\n\t\t\t\tText: chatMsg.Message.Content,\n\t\t\t})\n\t\t}\n\n\t\t// Add tool calls if present (assistant requesting tool use)\n\t\tif len(chatMsg.Message.ToolCalls) > 0 {\n\t\t\tfor _, toolCall := range chatMsg.Message.ToolCalls {\n\t\t\t\tif toolCall.Type != \"function\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Only add if ToolUseData is available\n\t\t\t\tif toolCall.ToolUseData != nil {\n\t\t\t\t\tparts = append(parts, uctypes.UIMessagePart{\n\t\t\t\t\t\tType: \"data-tooluse\",\n\t\t\t\t\t\tID:   toolCall.ID,\n\t\t\t\t\t\tData: *toolCall.ToolUseData,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Tool result messages (role \"tool\") are not converted to UIMessage\n\t\tif chatMsg.Message.Role == \"tool\" && chatMsg.Message.ToolCallID != \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Skip messages with no parts\n\t\tif len(parts) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tuiMsg := uctypes.UIMessage{\n\t\t\tID:    chatMsg.MessageId,\n\t\t\tRole:  chatMsg.Message.Role,\n\t\t\tParts: parts,\n\t\t}\n\n\t\tuiChat.Messages = append(uiChat.Messages, uiMsg)\n\t}\n\n\treturn uiChat, nil\n}\n\n// GetFunctionCallInputByToolCallId searches for a tool call by ID in the chat history\nfunc GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput {\n\tfor _, genMsg := range aiChat.NativeMessages {\n\t\tchatMsg, ok := genMsg.(*StoredChatMessage)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tidx := chatMsg.Message.FindToolCallIndex(toolCallId)\n\t\tif idx == -1 {\n\t\t\tcontinue\n\t\t}\n\t\ttoolCall := chatMsg.Message.ToolCalls[idx]\n\t\treturn &uctypes.AIFunctionCallInput{\n\t\t\tCallId:      toolCall.ID,\n\t\t\tName:        toolCall.Function.Name,\n\t\t\tArguments:   toolCall.Function.Arguments,\n\t\t\tToolUseData: toolCall.ToolUseData,\n\t\t}\n\t}\n\treturn nil\n}\n\n// UpdateToolUseData updates the ToolUseData for a specific tool call in the chat history\nfunc UpdateToolUseData(chatId string, callId string, newToolUseData uctypes.UIMessageDataToolUse) error {\n\tchat := chatstore.DefaultChatStore.Get(chatId)\n\tif chat == nil {\n\t\treturn fmt.Errorf(\"chat not found: %s\", chatId)\n\t}\n\n\tfor _, genMsg := range chat.NativeMessages {\n\t\tchatMsg, ok := genMsg.(*StoredChatMessage)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tidx := chatMsg.Message.FindToolCallIndex(callId)\n\t\tif idx == -1 {\n\t\t\tcontinue\n\t\t}\n\t\tupdatedMsg := chatMsg.Copy()\n\t\tupdatedMsg.Message.ToolCalls[idx].ToolUseData = &newToolUseData\n\t\taiOpts := &uctypes.AIOptsType{\n\t\t\tAPIType:    chat.APIType,\n\t\t\tModel:      chat.Model,\n\t\t\tAPIVersion: chat.APIVersion,\n\t\t}\n\t\treturn chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg)\n\t}\n\n\treturn fmt.Errorf(\"tool call with callId %s not found in chat %s\", callId, chatId)\n}\n\nfunc RemoveToolUseCall(chatId string, callId string) error {\n\tchat := chatstore.DefaultChatStore.Get(chatId)\n\tif chat == nil {\n\t\treturn fmt.Errorf(\"chat not found: %s\", chatId)\n\t}\n\n\tfor _, genMsg := range chat.NativeMessages {\n\t\tchatMsg, ok := genMsg.(*StoredChatMessage)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tidx := chatMsg.Message.FindToolCallIndex(callId)\n\t\tif idx == -1 {\n\t\t\tcontinue\n\t\t}\n\t\tupdatedMsg := chatMsg.Copy()\n\t\tupdatedMsg.Message.ToolCalls = slices.Delete(updatedMsg.Message.ToolCalls, idx, idx+1)\n\t\tif len(updatedMsg.Message.ToolCalls) == 0 {\n\t\t\tchatstore.DefaultChatStore.RemoveMessage(chatId, chatMsg.MessageId)\n\t\t} else {\n\t\t\taiOpts := &uctypes.AIOptsType{\n\t\t\t\tAPIType:    chat.APIType,\n\t\t\t\tModel:      chat.Model,\n\t\t\t\tAPIVersion: chat.APIVersion,\n\t\t\t}\n\t\t\tif err := chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/aiusechat/openaichat/openaichat-types.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage openaichat\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n)\n\n// OpenAI Chat Completions API types (simplified)\n\ntype ChatRequest struct {\n\tModel               string               `json:\"model\"`\n\tMessages            []ChatRequestMessage `json:\"messages\"`\n\tStream              bool                 `json:\"stream\"`\n\tMaxTokens           int                  `json:\"max_tokens,omitempty\"`            // legacy\n\tMaxCompletionTokens int                  `json:\"max_completion_tokens,omitempty\"` // newer\n\tTemperature         float64              `json:\"temperature,omitempty\"`\n\tTools               []ToolDefinition     `json:\"tools,omitempty\"`       // if you use tools\n\tToolChoice          any                  `json:\"tool_choice,omitempty\"` // \"auto\", \"none\", or struct\n}\n\ntype ChatContentPart struct {\n\tType     string        `json:\"type\"`                // \"text\" or \"image_url\"\n\tText     string        `json:\"text,omitempty\"`      // for type \"text\"\n\tImageUrl *ChatImageUrl `json:\"image_url,omitempty\"` // for type \"image_url\"\n\n\tFileName   string `json:\"filename,omitempty\"`   // internal: original filename\n\tPreviewUrl string `json:\"previewurl,omitempty\"` // internal: 128x128 webp preview\n\tMimeType   string `json:\"mimetype,omitempty\"`   // internal: original mimetype\n}\n\nfunc (cp *ChatContentPart) clean() *ChatContentPart {\n\tif cp.FileName == \"\" && cp.PreviewUrl == \"\" && cp.MimeType == \"\" {\n\t\treturn cp\n\t}\n\trtn := *cp\n\trtn.FileName = \"\"\n\trtn.PreviewUrl = \"\"\n\trtn.MimeType = \"\"\n\treturn &rtn\n}\n\ntype ChatImageUrl struct {\n\tUrl    string `json:\"url\"`\n\tDetail string `json:\"detail,omitempty\"` // \"auto\", \"low\", \"high\"\n}\n\ntype ChatRequestMessage struct {\n\tRole         string            `json:\"role\"`                   // \"system\",\"user\",\"assistant\",\"tool\"\n\tContent      string            `json:\"-\"`                      // plain text (used when ContentParts is nil)\n\tContentParts []ChatContentPart `json:\"-\"`                      // multimodal parts (used when images present)\n\tToolCalls    []ToolCall        `json:\"tool_calls,omitempty\"`   // assistant tool-call message\n\tToolCallID   string            `json:\"tool_call_id,omitempty\"` // for role:\"tool\"\n\tName         string            `json:\"name,omitempty\"`         // tool name on role:\"tool\"\n}\n\n// chatRequestMessageJSON is the wire format for ChatRequestMessage\ntype chatRequestMessageJSON struct {\n\tRole       string          `json:\"role\"`\n\tContent    json.RawMessage `json:\"content\"`\n\tToolCalls  []ToolCall      `json:\"tool_calls,omitempty\"`\n\tToolCallID string          `json:\"tool_call_id,omitempty\"`\n\tName       string          `json:\"name,omitempty\"`\n}\n\nfunc (cm ChatRequestMessage) MarshalJSON() ([]byte, error) {\n\traw := chatRequestMessageJSON{\n\t\tRole:       cm.Role,\n\t\tToolCalls:  cm.ToolCalls,\n\t\tToolCallID: cm.ToolCallID,\n\t\tName:       cm.Name,\n\t}\n\tif len(cm.ContentParts) > 0 {\n\t\tb, err := json.Marshal(cm.ContentParts)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\traw.Content = b\n\t} else if cm.Content != \"\" {\n\t\tb, err := json.Marshal(cm.Content)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\traw.Content = b\n\t}\n\treturn json.Marshal(raw)\n}\n\nfunc (cm *ChatRequestMessage) UnmarshalJSON(data []byte) error {\n\tvar raw chatRequestMessageJSON\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\treturn err\n\t}\n\tcm.Role = raw.Role\n\tcm.ToolCalls = raw.ToolCalls\n\tcm.ToolCallID = raw.ToolCallID\n\tcm.Name = raw.Name\n\tcm.Content = \"\"\n\tcm.ContentParts = nil\n\tif len(raw.Content) == 0 || bytes.Equal(raw.Content, []byte(\"null\")) {\n\t\treturn nil\n\t}\n\t// try array first\n\tvar parts []ChatContentPart\n\tif err := json.Unmarshal(raw.Content, &parts); err == nil {\n\t\tcm.ContentParts = parts\n\t\treturn nil\n\t}\n\t// fall back to string\n\tvar s string\n\tif err := json.Unmarshal(raw.Content, &s); err != nil {\n\t\treturn err\n\t}\n\tcm.Content = s\n\treturn nil\n}\n\nfunc (cm *ChatRequestMessage) clean() *ChatRequestMessage {\n\trtn := *cm\n\tif len(cm.ToolCalls) > 0 {\n\t\trtn.ToolCalls = make([]ToolCall, len(cm.ToolCalls))\n\t\tfor i, tc := range cm.ToolCalls {\n\t\t\trtn.ToolCalls[i] = *tc.clean()\n\t\t}\n\t}\n\tif len(cm.ContentParts) > 0 {\n\t\trtn.ContentParts = make([]ChatContentPart, len(cm.ContentParts))\n\t\tfor i, cp := range cm.ContentParts {\n\t\t\trtn.ContentParts[i] = *cp.clean()\n\t\t}\n\t}\n\treturn &rtn\n}\n\nfunc (cm *ChatRequestMessage) FindToolCallIndex(toolCallId string) int {\n\tfor i, tc := range cm.ToolCalls {\n\t\tif tc.ID == toolCallId {\n\t\t\treturn i\n\t\t}\n\t}\n\treturn -1\n}\n\ntype ToolDefinition struct {\n\tType     string          `json:\"type\"` // \"function\"\n\tFunction ToolFunctionDef `json:\"function\"`\n}\n\ntype ToolFunctionDef struct {\n\tName        string         `json:\"name\"`\n\tDescription string         `json:\"description,omitempty\"`\n\tParameters  map[string]any `json:\"parameters,omitempty\"` // or jsonschema struct\n}\n\ntype ToolCall struct {\n\tID          string                        `json:\"id\"`\n\tType        string                        `json:\"type\"` // \"function\"\n\tFunction    ToolFunctionCall              `json:\"function\"`\n\tToolUseData *uctypes.UIMessageDataToolUse `json:\"toolusedata,omitempty\"` // Internal field (must be cleaned before sending to API)\n}\n\nfunc (tc *ToolCall) clean() *ToolCall {\n\tif tc.ToolUseData == nil {\n\t\treturn tc\n\t}\n\trtn := *tc\n\trtn.ToolUseData = nil\n\treturn &rtn\n}\n\ntype ToolFunctionCall struct {\n\tName      string `json:\"name\"`\n\tArguments string `json:\"arguments\"` // raw JSON string\n}\n\ntype StreamChunk struct {\n\tID      string         `json:\"id\"`\n\tObject  string         `json:\"object\"`\n\tCreated int64          `json:\"created\"`\n\tModel   string         `json:\"model\"`\n\tChoices []StreamChoice `json:\"choices\"`\n}\n\ntype StreamChoice struct {\n\tIndex        int          `json:\"index\"`\n\tDelta        ContentDelta `json:\"delta\"`\n\tFinishReason *string      `json:\"finish_reason\"` // \"stop\", \"length\" | \"tool_calls\" | \"content_filter\"\n}\n\n// This is the important part:\ntype ContentDelta struct {\n\tRole      string          `json:\"role,omitempty\"`\n\tContent   string          `json:\"content,omitempty\"`\n\tToolCalls []ToolCallDelta `json:\"tool_calls,omitempty\"`\n}\n\ntype ToolCallDelta struct {\n\tIndex    int                `json:\"index\"`\n\tID       string             `json:\"id,omitempty\"`   // only on first chunk\n\tType     string             `json:\"type,omitempty\"` // \"function\"\n\tFunction *ToolFunctionDelta `json:\"function,omitempty\"`\n}\n\ntype ToolFunctionDelta struct {\n\tName      string `json:\"name,omitempty\"`      // only on first chunk\n\tArguments string `json:\"arguments,omitempty\"` // streamed, append across chunks\n}\n\n// StoredChatMessage is the stored message type\ntype StoredChatMessage struct {\n\tMessageId string             `json:\"messageid\"`\n\tMessage   ChatRequestMessage `json:\"message\"`\n\tUsage     *ChatUsage         `json:\"usage,omitempty\"`\n}\n\ntype ChatUsage struct {\n\tModel        string `json:\"model,omitempty\"`\n\tInputTokens  int    `json:\"prompt_tokens,omitempty\"`\n\tOutputTokens int    `json:\"completion_tokens,omitempty\"`\n\tTotalTokens  int    `json:\"total_tokens,omitempty\"`\n}\n\nfunc (m *StoredChatMessage) GetMessageId() string {\n\treturn m.MessageId\n}\n\nfunc (m *StoredChatMessage) GetRole() string {\n\treturn m.Message.Role\n}\n\nfunc (m *StoredChatMessage) GetUsage() *uctypes.AIUsage {\n\tif m.Usage == nil {\n\t\treturn nil\n\t}\n\treturn &uctypes.AIUsage{\n\t\tAPIType:      uctypes.APIType_OpenAIChat,\n\t\tModel:        m.Usage.Model,\n\t\tInputTokens:  m.Usage.InputTokens,\n\t\tOutputTokens: m.Usage.OutputTokens,\n\t}\n}\n\nfunc (m *StoredChatMessage) Copy() *StoredChatMessage {\n\tif m == nil {\n\t\treturn nil\n\t}\n\tcopied := *m\n\tif len(m.Message.ToolCalls) > 0 {\n\t\tcopied.Message.ToolCalls = make([]ToolCall, len(m.Message.ToolCalls))\n\t\tfor i, tc := range m.Message.ToolCalls {\n\t\t\tcopied.Message.ToolCalls[i] = tc\n\t\t\tif tc.ToolUseData != nil {\n\t\t\t\ttoolUseDataCopy := *tc.ToolUseData\n\t\t\t\tcopied.Message.ToolCalls[i].ToolUseData = &toolUseDataCopy\n\t\t\t}\n\t\t}\n\t}\n\tif len(m.Message.ContentParts) > 0 {\n\t\tcopied.Message.ContentParts = make([]ChatContentPart, len(m.Message.ContentParts))\n\t\tcopy(copied.Message.ContentParts, m.Message.ContentParts)\n\t}\n\tif m.Usage != nil {\n\t\tusageCopy := *m.Usage\n\t\tcopied.Usage = &usageCopy\n\t}\n\treturn &copied\n}\n"
  },
  {
    "path": "pkg/aiusechat/toolapproval.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"context\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/web/sse\"\n)\n\ntype ApprovalRequest struct {\n\tapproval       string\n\tdone           bool\n\tdoneChan       chan struct{}\n\tmu             sync.Mutex\n\tonCloseUnregFn func()\n}\n\nfunc (req *ApprovalRequest) updateApproval(approval string) {\n\treq.mu.Lock()\n\tdefer req.mu.Unlock()\n\n\tif req.done {\n\t\treturn\n\t}\n\n\treq.approval = approval\n\treq.done = true\n\n\tif req.onCloseUnregFn != nil {\n\t\treq.onCloseUnregFn()\n\t}\n\n\tclose(req.doneChan)\n}\n\ntype ApprovalRegistry struct {\n\tmu       sync.Mutex\n\trequests map[string]*ApprovalRequest\n}\n\nvar globalApprovalRegistry = &ApprovalRegistry{\n\trequests: make(map[string]*ApprovalRequest),\n}\n\nfunc registerToolApprovalRequest(toolCallId string, req *ApprovalRequest) {\n\tglobalApprovalRegistry.mu.Lock()\n\tdefer globalApprovalRegistry.mu.Unlock()\n\tglobalApprovalRegistry.requests[toolCallId] = req\n}\n\nfunc UnregisterToolApproval(toolCallId string) {\n\tglobalApprovalRegistry.mu.Lock()\n\tdefer globalApprovalRegistry.mu.Unlock()\n\treq := globalApprovalRegistry.requests[toolCallId]\n\tdelete(globalApprovalRegistry.requests, toolCallId)\n\tif req != nil {\n\t\treq.updateApproval(\"\")\n\t}\n}\n\nfunc getToolApprovalRequest(toolCallId string) (*ApprovalRequest, bool) {\n\tglobalApprovalRegistry.mu.Lock()\n\tdefer globalApprovalRegistry.mu.Unlock()\n\treq, exists := globalApprovalRegistry.requests[toolCallId]\n\treturn req, exists\n}\n\nfunc RegisterToolApproval(toolCallId string, sseHandler *sse.SSEHandlerCh) {\n\treq := &ApprovalRequest{\n\t\tdoneChan: make(chan struct{}),\n\t}\n\n\tonCloseId := sseHandler.RegisterOnClose(func() {\n\t\tUpdateToolApproval(toolCallId, uctypes.ApprovalCanceled)\n\t})\n\n\treq.onCloseUnregFn = func() {\n\t\tsseHandler.UnregisterOnClose(onCloseId)\n\t}\n\n\tregisterToolApprovalRequest(toolCallId, req)\n}\n\nfunc UpdateToolApproval(toolCallId string, approval string) error {\n\treq, exists := getToolApprovalRequest(toolCallId)\n\tif !exists {\n\t\treturn nil\n\t}\n\n\treq.updateApproval(approval)\n\treturn nil\n}\n\nfunc WaitForToolApproval(ctx context.Context, toolCallId string) (string, error) {\n\treq, exists := getToolApprovalRequest(toolCallId)\n\tif !exists {\n\t\treturn \"\", nil\n\t}\n\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn \"\", ctx.Err()\n\tcase <-req.doneChan:\n\t}\n\n\treq.mu.Lock()\n\tapproval := req.approval\n\treq.mu.Unlock()\n\n\tglobalApprovalRegistry.mu.Lock()\n\tdelete(globalApprovalRegistry.requests, toolCallId)\n\tglobalApprovalRegistry.mu.Unlock()\n\n\treturn approval, nil\n}\n"
  },
  {
    "path": "pkg/aiusechat/tools.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os/user\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/blockcontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nfunc makeTerminalBlockDesc(block *waveobj.Block) string {\n\tconnection, hasConnection := block.Meta[\"connection\"].(string)\n\tcwd, hasCwd := block.Meta[\"cmd:cwd\"].(string)\n\n\tblockORef := waveobj.MakeORef(waveobj.OType_Block, block.OID)\n\trtInfo := wstore.GetRTInfo(blockORef)\n\thasCurCwd := rtInfo != nil && rtInfo.ShellHasCurCwd\n\n\tvar desc string\n\tif hasConnection && connection != \"\" {\n\t\tdesc = fmt.Sprintf(\"CLI terminal connected to %q\", connection)\n\t} else {\n\t\tdesc = \"local CLI terminal\"\n\t}\n\n\tif rtInfo != nil && rtInfo.ShellType != \"\" {\n\t\tdesc += fmt.Sprintf(\" (%s\", rtInfo.ShellType)\n\t\tif rtInfo.ShellVersion != \"\" {\n\t\t\tdesc += fmt.Sprintf(\" %s\", rtInfo.ShellVersion)\n\t\t}\n\t\tdesc += \")\"\n\t}\n\n\tif rtInfo != nil {\n\t\tif rtInfo.ShellIntegration {\n\t\t\tvar stateStr string\n\t\t\tswitch rtInfo.ShellState {\n\t\t\tcase \"ready\":\n\t\t\t\tstateStr = \"waiting for input\"\n\t\t\tcase \"running-command\":\n\t\t\t\tstateStr = \"running command\"\n\t\t\t\tif rtInfo.ShellLastCmd != \"\" {\n\t\t\t\t\tcmdStr := rtInfo.ShellLastCmd\n\t\t\t\t\tif len(cmdStr) > 30 {\n\t\t\t\t\t\tcmdStr = cmdStr[:27] + \"...\"\n\t\t\t\t\t}\n\t\t\t\t\tcmdJSON := utilfn.MarshalJSONString(cmdStr)\n\t\t\t\t\tstateStr = fmt.Sprintf(\"running command %s\", cmdJSON)\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\tstateStr = \"state unknown\"\n\t\t\t}\n\t\t\tdesc += fmt.Sprintf(\", %s\", stateStr)\n\t\t} else {\n\t\t\tdesc += \", no shell integration\"\n\t\t}\n\t}\n\n\tif hasCurCwd && hasCwd && cwd != \"\" {\n\t\tdesc += fmt.Sprintf(\", in directory %q\", cwd)\n\t}\n\n\treturn desc\n}\n\nfunc MakeBlockShortDesc(block *waveobj.Block) string {\n\tif block.Meta == nil {\n\t\treturn \"\"\n\t}\n\n\tviewType, ok := block.Meta[\"view\"].(string)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\n\tswitch viewType {\n\tcase \"term\":\n\t\treturn makeTerminalBlockDesc(block)\n\tcase \"preview\":\n\t\tfile, hasFile := block.Meta[\"file\"].(string)\n\t\tconnection, hasConnection := block.Meta[\"connection\"].(string)\n\n\t\tif hasConnection && connection != \"\" {\n\t\t\tif hasFile && file != \"\" {\n\t\t\t\treturn fmt.Sprintf(\"preview widget viewing %q on %q\", file, connection)\n\t\t\t}\n\t\t\treturn fmt.Sprintf(\"preview widget viewing files on %q\", connection)\n\t\t}\n\t\tif hasFile && file != \"\" {\n\t\t\treturn fmt.Sprintf(\"preview widget viewing %q\", file)\n\t\t}\n\t\treturn \"file and directory preview widget\"\n\tcase \"web\":\n\t\tif url, hasUrl := block.Meta[\"url\"].(string); hasUrl && url != \"\" {\n\t\t\treturn fmt.Sprintf(\"web browser widget pointing at %q\", url)\n\t\t}\n\t\treturn \"web browser widget\"\n\tcase \"waveai\":\n\t\treturn \"AI chat widget\"\n\tcase \"cpuplot\":\n\t\tif connection, hasConnection := block.Meta[\"connection\"].(string); hasConnection && connection != \"\" {\n\t\t\treturn fmt.Sprintf(\"cpu graph for %q\", connection)\n\t\t}\n\t\treturn \"cpu graph\"\n\tcase \"tips\":\n\t\treturn \"Wave quick tips widget\"\n\tcase \"help\":\n\t\treturn \"Wave documentation widget\"\n\tcase \"launcher\":\n\t\treturn \"placeholder widget used to launch other widgets\"\n\tcase \"tsunami\":\n\t\treturn handleTsunamiBlockDesc(block)\n\tcase \"aifilediff\":\n\t\treturn \"\" // AI doesn't need to see these\n\tcase \"waveconfig\":\n\t\tif file, hasFile := block.Meta[\"file\"].(string); hasFile && file != \"\" {\n\t\t\treturn fmt.Sprintf(\"wave config editor for %q\", file)\n\t\t}\n\t\treturn \"wave config editor\"\n\tdefault:\n\t\treturn fmt.Sprintf(\"unknown widget with type %q\", viewType)\n\t}\n}\n\nfunc GenerateTabStateAndTools(ctx context.Context, tabid string, widgetAccess bool, chatOpts *uctypes.WaveChatOpts) (string, []uctypes.ToolDefinition, error) {\n\tif tabid == \"\" {\n\t\treturn \"\", nil, nil\n\t}\n\tvar blocks []*waveobj.Block\n\tif widgetAccess {\n\t\tif _, err := uuid.Parse(tabid); err != nil {\n\t\t\treturn \"\", nil, fmt.Errorf(\"tabid must be a valid UUID\")\n\t\t}\n\n\t\ttabObj, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabid)\n\t\tif err != nil {\n\t\t\treturn \"\", nil, fmt.Errorf(\"error getting tab: %v\", err)\n\t\t}\n\n\t\tfor _, blockId := range tabObj.BlockIds {\n\t\t\tblock, err := wstore.DBGet[*waveobj.Block](ctx, blockId)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tblocks = append(blocks, block)\n\t\t}\n\t}\n\ttabState := GenerateCurrentTabStatePrompt(blocks, widgetAccess)\n\t// for debugging\n\t// log.Printf(\"TABPROMPT %s\\n\", tabState)\n\tvar tools []uctypes.ToolDefinition\n\tif widgetAccess {\n\t\t// Only add screenshot tool for:\n\t\t// - openai-responses API type\n\t\t// - google-gemini API type with Gemini 3+ models\n\t\tif chatOpts.Config.APIType == uctypes.APIType_OpenAIResponses ||\n\t\t   (chatOpts.Config.APIType == uctypes.APIType_GoogleGemini && aiutil.GeminiSupportsImageToolResults(chatOpts.Config.Model)) {\n\t\t\ttools = append(tools, GetCaptureScreenshotToolDefinition(tabid))\n\t\t}\n\t\ttools = append(tools, GetReadTextFileToolDefinition())\n\t\ttools = append(tools, GetReadDirToolDefinition())\n\t\ttools = append(tools, GetWriteTextFileToolDefinition())\n\t\ttools = append(tools, GetEditTextFileToolDefinition())\n\t\ttools = append(tools, GetDeleteTextFileToolDefinition())\n\t\tviewTypes := make(map[string]bool)\n\t\tfor _, block := range blocks {\n\t\t\tif block.Meta == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tviewType, ok := block.Meta[\"view\"].(string)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tviewTypes[viewType] = true\n\t\t\tif viewType == \"tsunami\" {\n\t\t\t\tblockTools := generateToolsForTsunamiBlock(block)\n\t\t\t\ttools = append(tools, blockTools...)\n\t\t\t}\n\t\t}\n\t\tif viewTypes[\"term\"] {\n\t\t\ttools = append(tools, GetTermGetScrollbackToolDefinition(tabid))\n\t\t\t// tools = append(tools, GetTermCommandOutputToolDefinition(tabid))\n\t\t}\n\t\tif viewTypes[\"web\"] {\n\t\t\ttools = append(tools, GetWebNavigateToolDefinition(tabid))\n\t\t}\n\t}\n\treturn tabState, tools, nil\n}\n\nfunc GenerateCurrentTabStatePrompt(blocks []*waveobj.Block, widgetAccess bool) string {\n\tif !widgetAccess {\n\t\treturn `<current_tab_state>The user has chosen not to share widget context with you</current_tab_state>`\n\t}\n\tvar widgetDescriptions []string\n\tfor _, block := range blocks {\n\t\tdesc := MakeBlockShortDesc(block)\n\t\tif desc == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tblockIdPrefix := block.OID[:8]\n\t\tfullDesc := fmt.Sprintf(\"(%s) %s\", blockIdPrefix, desc)\n\t\twidgetDescriptions = append(widgetDescriptions, fullDesc)\n\t}\n\n\tvar prompt strings.Builder\n\tprompt.WriteString(\"<current_tab_state>\\n\")\n\tsystemInfo := wavebase.GetSystemSummary()\n\tif currentUser, err := user.Current(); err == nil && currentUser.Username != \"\" {\n\t\tprompt.WriteString(fmt.Sprintf(\"Local Machine: %s, User: %s\\n\", systemInfo, currentUser.Username))\n\t} else {\n\t\tprompt.WriteString(fmt.Sprintf(\"Local Machine: %s\\n\", systemInfo))\n\t}\n\tif len(widgetDescriptions) == 0 {\n\t\tprompt.WriteString(\"No widgets open\\n\")\n\t} else {\n\t\tprompt.WriteString(\"Open Widgets:\\n\")\n\t\tfor _, desc := range widgetDescriptions {\n\t\t\tprompt.WriteString(\"* \")\n\t\t\tprompt.WriteString(desc)\n\t\t\tprompt.WriteString(\"\\n\")\n\t\t}\n\t}\n\tprompt.WriteString(\"</current_tab_state>\")\n\trtn := prompt.String()\n\treturn rtn\n}\n\nfunc generateToolsForTsunamiBlock(block *waveobj.Block) []uctypes.ToolDefinition {\n\tvar tools []uctypes.ToolDefinition\n\n\tstatus := blockcontroller.GetBlockControllerRuntimeStatus(block.OID)\n\tif status == nil || status.ShellProcStatus != blockcontroller.Status_Running || status.TsunamiPort <= 0 {\n\t\treturn nil\n\t}\n\n\tblockORef := waveobj.MakeORef(waveobj.OType_Block, block.OID)\n\trtInfo := wstore.GetRTInfo(blockORef)\n\n\tif tool := GetTsunamiGetDataToolDefinition(block, rtInfo, status); tool != nil {\n\t\ttools = append(tools, *tool)\n\t}\n\tif tool := GetTsunamiGetConfigToolDefinition(block, rtInfo, status); tool != nil {\n\t\ttools = append(tools, *tool)\n\t}\n\tif tool := GetTsunamiSetConfigToolDefinition(block, rtInfo, status); tool != nil {\n\t\ttools = append(tools, *tool)\n\t}\n\n\treturn tools\n}\n\n// Used for internal testing of tool loops\nfunc GetAdderToolDefinition() uctypes.ToolDefinition {\n\treturn uctypes.ToolDefinition{\n\t\tName:        \"adder\",\n\t\tDisplayName: \"Adder\",\n\t\tDescription: \"Add an array of numbers together and return their sum\",\n\t\tToolLogName: \"gen:adder\",\n\t\tStrict:      true,\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"values\": map[string]any{\n\t\t\t\t\t\"type\": \"array\",\n\t\t\t\t\t\"items\": map[string]any{\n\t\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t},\n\t\t\t\t\t\"description\": \"Array of numbers to add together\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"required\":             []string{\"values\"},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\t\t\tinputMap, ok := input.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"invalid input format\")\n\t\t\t}\n\n\t\t\tvaluesInterface, ok := inputMap[\"values\"]\n\t\t\tif !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"missing values parameter\")\n\t\t\t}\n\n\t\t\tvaluesSlice, ok := valuesInterface.([]any)\n\t\t\tif !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"values must be an array\")\n\t\t\t}\n\n\t\t\tif len(valuesSlice) == 0 {\n\t\t\t\treturn 0, nil\n\t\t\t}\n\n\t\t\tsum := 0\n\t\t\tfor i, val := range valuesSlice {\n\t\t\t\tfloatVal, ok := val.(float64)\n\t\t\t\tif !ok {\n\t\t\t\t\treturn nil, fmt.Errorf(\"value at index %d is not a number\", i)\n\t\t\t\t}\n\t\t\t\tsum += int(floatVal)\n\t\t\t}\n\n\t\t\treturn sum, nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/tools_builder.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/buildercontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/fileutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveappstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveapputil\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nconst BuilderAppFileName = \"app.go\"\n\ntype builderWriteAppFileParams struct {\n\tContents string `json:\"contents\"`\n}\n\nfunc triggerBuildAndWait(builderId string, appId string) map[string]any {\n\tbc := buildercontroller.GetOrCreateController(builderId)\n\trtInfo := wstore.GetRTInfo(waveobj.MakeORef(waveobj.OType_Builder, builderId))\n\n\tvar builderEnv map[string]string\n\tif rtInfo != nil {\n\t\tbuilderEnv = rtInfo.BuilderEnv\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\n\tdefer cancel()\n\n\tresult, err := bc.RestartAndWaitForBuild(ctx, appId, builderEnv)\n\tif err != nil {\n\t\tlog.Printf(\"Build failed for %s: %v\", builderId, err)\n\t\treturn map[string]any{\n\t\t\t\"build_success\": false,\n\t\t\t\"build_error\":   err.Error(),\n\t\t\t\"build_output\":  \"\",\n\t\t}\n\t}\n\n\treturn map[string]any{\n\t\t\"build_success\": result.Success,\n\t\t\"build_error\":   result.ErrorMessage,\n\t\t\"build_output\":  result.BuildOutput,\n\t}\n}\n\nfunc parseBuilderWriteAppFileInput(input any) (*builderWriteAppFileParams, error) {\n\tresult := &builderWriteAppFileParams{}\n\n\tif input == nil {\n\t\treturn nil, fmt.Errorf(\"input is required\")\n\t}\n\n\tif err := utilfn.ReUnmarshal(result, input); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid input format: %w\", err)\n\t}\n\n\tif result.Contents == \"\" {\n\t\treturn nil, fmt.Errorf(\"missing contents parameter\")\n\t}\n\n\treturn result, nil\n}\n\nfunc GetBuilderWriteAppFileToolDefinition(appId string, builderId string) uctypes.ToolDefinition {\n\treturn uctypes.ToolDefinition{\n\t\tName:        \"builder_write_app_file\",\n\t\tDisplayName: \"Write App File\",\n\t\tDescription: fmt.Sprintf(\"Write the app.go file for app %s\", appId),\n\t\tToolLogName: \"builder:write_app\",\n\t\tStrict:      false,\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"contents\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"description\": \"The contents to write to app.go\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"required\":             []string{\"contents\"},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\tparams, err := parseBuilderWriteAppFileInput(input)\n\t\t\tif err != nil {\n\t\t\t\tif output != nil {\n\t\t\t\t\treturn \"wrote app.go\"\n\t\t\t\t}\n\t\t\t\treturn \"writing app.go\"\n\t\t\t}\n\t\t\tlineCount := len(strings.Split(params.Contents, \"\\n\"))\n\t\t\tif output != nil {\n\t\t\t\treturn fmt.Sprintf(\"wrote app.go (+%d lines)\", lineCount)\n\t\t\t}\n\t\t\treturn fmt.Sprintf(\"writing app.go (+%d lines)\", lineCount)\n\t\t},\n\t\tToolProgressDesc: func(input any) ([]string, error) {\n\t\t\tparams, err := parseBuilderWriteAppFileInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tlineCount := len(strings.Split(params.Contents, \"\\n\"))\n\t\t\treturn []string{fmt.Sprintf(\"writing app.go (+%d lines)\", lineCount)}, nil\n\t\t},\n\t\tToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\t\t\tparams, err := parseBuilderWriteAppFileInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tformattedContents := waveapputil.FormatGoCode([]byte(params.Contents))\n\t\t\terr = waveappstore.WriteAppFile(appId, BuilderAppFileName, formattedContents)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\twps.Broker.Publish(wps.WaveEvent{\n\t\t\t\tEvent:  wps.Event_WaveAppAppGoUpdated,\n\t\t\t\tScopes: []string{appId},\n\t\t\t})\n\n\t\t\tresult := map[string]any{\n\t\t\t\t\"success\": true,\n\t\t\t\t\"message\": fmt.Sprintf(\"Successfully wrote %s\", BuilderAppFileName),\n\t\t\t}\n\n\t\t\tif builderId != \"\" {\n\t\t\t\tbuildResult := triggerBuildAndWait(builderId, appId)\n\t\t\t\tresult[\"build_success\"] = buildResult[\"build_success\"]\n\t\t\t\tresult[\"build_error\"] = buildResult[\"build_error\"]\n\t\t\t\tresult[\"build_output\"] = buildResult[\"build_output\"]\n\t\t\t}\n\n\t\t\treturn result, nil\n\t\t},\n\t}\n}\n\ntype builderEditAppFileParams struct {\n\tEdits []fileutil.EditSpec `json:\"edits\"`\n}\n\nfunc parseBuilderEditAppFileInput(input any) (*builderEditAppFileParams, error) {\n\tresult := &builderEditAppFileParams{}\n\n\tif input == nil {\n\t\treturn nil, fmt.Errorf(\"input is required\")\n\t}\n\n\tif err := utilfn.ReUnmarshal(result, input); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid input format: %w\", err)\n\t}\n\n\tif len(result.Edits) == 0 {\n\t\treturn nil, fmt.Errorf(\"missing edits parameter\")\n\t}\n\n\treturn result, nil\n}\n\nfunc formatEditDescriptions(edits []fileutil.EditSpec) []string {\n\tnumEdits := len(edits)\n\teditStr := \"edits\"\n\tif numEdits == 1 {\n\t\teditStr = \"edit\"\n\t}\n\n\tresult := make([]string, len(edits)+1)\n\tresult[0] = fmt.Sprintf(\"editing app.go (%d %s)\", numEdits, editStr)\n\n\tfor i, edit := range edits {\n\t\tnewLines := len(strings.Split(edit.NewStr, \"\\n\"))\n\t\toldLines := len(strings.Split(edit.OldStr, \"\\n\"))\n\t\tdesc := edit.Desc\n\t\tif desc == \"\" {\n\t\t\tdesc = fmt.Sprintf(\"edit #%d\", i+1)\n\t\t}\n\t\tresult[i+1] = fmt.Sprintf(\"* %s (+%d -%d)\", desc, newLines, oldLines)\n\t}\n\treturn result\n}\n\nfunc GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes.ToolDefinition {\n\treturn uctypes.ToolDefinition{\n\t\tName:        \"builder_edit_app_file\",\n\t\tDisplayName: \"Edit App File\",\n\t\tDescription: \"Edit the app.go file for this app using precise search and replace. \" +\n\t\t\t\"Each old_str must appear EXACTLY ONCE in the file or the edit will fail. \" +\n\t\t\t\"Edits are applied sequentially - if an edit fails, all previous edits are kept and subsequent edits are skipped.\",\n\t\tToolLogName: \"builder:edit_app\",\n\t\tStrict:      false,\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"edits\": map[string]any{\n\t\t\t\t\t\"type\":        \"array\",\n\t\t\t\t\t\"description\": \"Array of edit specifications. Edits are applied sequentially - if one fails, previous edits are kept but remaining edits are skipped.\",\n\t\t\t\t\t\"items\": map[string]any{\n\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\"properties\": map[string]any{\n\t\t\t\t\t\t\t\"old_str\": map[string]any{\n\t\t\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\t\t\"description\": \"The exact string to find and replace. MUST appear exactly once in the file - if it appears zero times or multiple times, this edit will fail.\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"new_str\": map[string]any{\n\t\t\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\t\t\"description\": \"The string to replace with\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"desc\": map[string]any{\n\t\t\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\t\t\"description\": \"Description of what this edit does (keep short, half a line of text max)\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"required\": []string{\"old_str\", \"new_str\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"required\":             []string{\"edits\"},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\tparams, err := parseBuilderEditAppFileInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Sprintf(\"error parsing input: %v\", err)\n\t\t\t}\n\t\t\treturn strings.Join(formatEditDescriptions(params.Edits), \"\\n\")\n\t\t},\n\t\tToolProgressDesc: func(input any) ([]string, error) {\n\t\t\tparams, err := parseBuilderEditAppFileInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn formatEditDescriptions(params.Edits), nil\n\t\t},\n\t\tToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\t\t\tparams, err := parseBuilderEditAppFileInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\teditResults, err := waveappstore.ReplaceInAppFilePartial(appId, BuilderAppFileName, params.Edits)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\t// ignore format errors; gofmt can fail due to compilation errors which will be caught in the build step\n\t\t\twaveappstore.FormatGoFile(appId, BuilderAppFileName)\n\n\t\t\twps.Broker.Publish(wps.WaveEvent{\n\t\t\t\tEvent:  wps.Event_WaveAppAppGoUpdated,\n\t\t\t\tScopes: []string{appId},\n\t\t\t})\n\n\t\t\tresult := map[string]any{\n\t\t\t\t\"edits\": editResults,\n\t\t\t}\n\n\t\t\tif builderId != \"\" {\n\t\t\t\tbuildResult := triggerBuildAndWait(builderId, appId)\n\t\t\t\tresult[\"build_success\"] = buildResult[\"build_success\"]\n\t\t\t\tresult[\"build_error\"] = buildResult[\"build_error\"]\n\t\t\t\tresult[\"build_output\"] = buildResult[\"build_output\"]\n\t\t\t}\n\n\t\t\treturn result, nil\n\t\t},\n\t}\n}\n\nfunc GetBuilderListFilesToolDefinition(appId string) uctypes.ToolDefinition {\n\treturn uctypes.ToolDefinition{\n\t\tName:        \"builder_list_files\",\n\t\tDisplayName: \"List App Files\",\n\t\tDescription: fmt.Sprintf(\"List all files in app %s\", appId),\n\t\tToolLogName: \"builder:list_files\",\n\t\tStrict:      false,\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\":                 \"object\",\n\t\t\t\"properties\":           map[string]any{},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\treturn \"listing files\"\n\t\t},\n\t\tToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\t\t\tresult, err := waveappstore.ListAllAppFiles(appId)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\treturn result, nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/tools_readdir.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/fileutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\nconst ReadDirDefaultMaxEntries = 500\nconst ReadDirHardMaxEntries = 10000\n\ntype readDirParams struct {\n\tPath       string `json:\"path\"`\n\tMaxEntries *int   `json:\"max_entries\"`\n}\n\nfunc parseReadDirInput(input any) (*readDirParams, error) {\n\tresult := &readDirParams{}\n\n\tif input == nil {\n\t\treturn nil, fmt.Errorf(\"input is required\")\n\t}\n\n\tif err := utilfn.ReUnmarshal(result, input); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid input format: %w\", err)\n\t}\n\n\tif result.Path == \"\" {\n\t\treturn nil, fmt.Errorf(\"missing path parameter\")\n\t}\n\n\tif result.MaxEntries == nil {\n\t\tmaxEntries := ReadDirDefaultMaxEntries\n\t\tresult.MaxEntries = &maxEntries\n\t}\n\n\tif *result.MaxEntries < 1 {\n\t\treturn nil, fmt.Errorf(\"max_entries must be at least 1, got %d\", *result.MaxEntries)\n\t}\n\n\tif *result.MaxEntries > ReadDirHardMaxEntries {\n\t\treturn nil, fmt.Errorf(\"max_entries cannot exceed %d, got %d\", ReadDirHardMaxEntries, *result.MaxEntries)\n\t}\n\n\treturn result, nil\n}\n\nfunc verifyReadDirInput(input any, toolUseData *uctypes.UIMessageDataToolUse) error {\n\tparams, err := parseReadDirInput(input)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\texpandedPath, err := wavebase.ExpandHomeDir(params.Path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to expand path: %w\", err)\n\t}\n\n\tif !filepath.IsAbs(expandedPath) {\n\t\treturn fmt.Errorf(\"path must be absolute, got relative path: %s\", params.Path)\n\t}\n\n\tfileInfo, err := os.Stat(expandedPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to stat path: %w\", err)\n\t}\n\n\tif !fileInfo.IsDir() {\n\t\treturn fmt.Errorf(\"path is not a directory, cannot be read with the read_dir tool. use the read_text_file tool if available to read files\")\n\t}\n\n\treturn nil\n}\n\nfunc readDirCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\tparams, err := parseReadDirInput(input)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\texpandedPath, err := wavebase.ExpandHomeDir(params.Path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to expand path: %w\", err)\n\t}\n\n\tif !filepath.IsAbs(expandedPath) {\n\t\treturn nil, fmt.Errorf(\"path must be absolute, got relative path: %s\", params.Path)\n\t}\n\n\tresult, err := fileutil.ReadDir(params.Path, *params.MaxEntries)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresultMap := map[string]any{\n\t\t\"path\":          result.Path,\n\t\t\"absolute_path\": result.AbsolutePath,\n\t\t\"entry_count\":   result.EntryCount,\n\t\t\"total_entries\": result.TotalEntries,\n\t\t\"entries\":       result.Entries,\n\t}\n\n\tif result.Truncated {\n\t\tresultMap[\"truncated\"] = true\n\t\tresultMap[\"truncated_message\"] = fmt.Sprintf(\"Directory listing truncated to %d entries (out of %d total). Increase max_entries to see more.\", result.EntryCount, result.TotalEntries)\n\t}\n\n\tif result.ParentDir != \"\" {\n\t\tresultMap[\"parent_dir\"] = result.ParentDir\n\t}\n\n\treturn resultMap, nil\n}\n\nfunc GetReadDirToolDefinition() uctypes.ToolDefinition {\n\treturn uctypes.ToolDefinition{\n\t\tName:        \"read_dir\",\n\t\tDisplayName: \"Read Directory\",\n\t\tDescription: \"Read a directory from the filesystem and list its contents. Returns information about files and subdirectories including names, types, sizes, permissions, and modification times.\",\n\t\tToolLogName: \"gen:readdir\",\n\t\tStrict:      false,\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"path\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"description\": \"Absolute path to the directory to read. Supports '~' for the user's home directory. Relative paths are not supported.\",\n\t\t\t\t},\n\t\t\t\t\"max_entries\": map[string]any{\n\t\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\t\"minimum\":     1,\n\t\t\t\t\t\"maximum\":     10000,\n\t\t\t\t\t\"default\":     500,\n\t\t\t\t\t\"description\": \"Maximum number of entries to return. Defaults to 500, max 10000.\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"required\":             []string{\"path\"},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\tparsed, err := parseReadDirInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Sprintf(\"error parsing input: %v\", err)\n\t\t\t}\n\n\t\t\treadFullDir := false\n\t\t\tif output != nil {\n\t\t\t\tif outputMap, ok := output.(map[string]any); ok {\n\t\t\t\t\t_, wasTruncated := outputMap[\"truncated\"]\n\t\t\t\t\treadFullDir = !wasTruncated\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif readFullDir {\n\t\t\t\treturn fmt.Sprintf(\"reading directory %q (entire directory)\", parsed.Path)\n\t\t\t}\n\t\t\treturn fmt.Sprintf(\"reading directory %q (max_entries: %d)\", parsed.Path, *parsed.MaxEntries)\n\t\t},\n\t\tToolAnyCallback: readDirCallback,\n\t\tToolApproval: func(input any) string {\n\t\t\treturn uctypes.ApprovalNeedsApproval\n\t\t},\n\t\tToolVerifyInput: verifyReadDirInput,\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/tools_readdir_test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/fileutil\"\n)\n\nfunc TestReadDirCallback(t *testing.T) {\n\t// Create a temporary test directory\n\ttmpDir, err := os.MkdirTemp(\"\", \"readdir_test\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\t// Create test files and directories\n\ttestFile1 := filepath.Join(tmpDir, \"file1.txt\")\n\ttestFile2 := filepath.Join(tmpDir, \"file2.log\")\n\ttestSubDir := filepath.Join(tmpDir, \"subdir\")\n\n\tif err := os.WriteFile(testFile1, []byte(\"test content 1\"), 0644); err != nil {\n\t\tt.Fatalf(\"Failed to create test file 1: %v\", err)\n\t}\n\tif err := os.WriteFile(testFile2, []byte(\"test content 2\"), 0644); err != nil {\n\t\tt.Fatalf(\"Failed to create test file 2: %v\", err)\n\t}\n\tif err := os.Mkdir(testSubDir, 0755); err != nil {\n\t\tt.Fatalf(\"Failed to create test subdir: %v\", err)\n\t}\n\n\t// Test reading the directory\n\tinput := map[string]any{\n\t\t\"path\": tmpDir,\n\t}\n\n\tresult, err := readDirCallback(input, &uctypes.UIMessageDataToolUse{})\n\tif err != nil {\n\t\tt.Fatalf(\"readDirCallback failed: %v\", err)\n\t}\n\n\tresultMap, ok := result.(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"Result is not a map\")\n\t}\n\n\t// Verify the result contains expected fields\n\tif resultMap[\"path\"] != tmpDir {\n\t\tt.Errorf(\"Expected path %q, got %q\", tmpDir, resultMap[\"path\"])\n\t}\n\n\tentryCount, ok := resultMap[\"entry_count\"].(int)\n\tif !ok {\n\t\tt.Fatalf(\"entry_count is not an int\")\n\t}\n\tif entryCount != 3 {\n\t\tt.Errorf(\"Expected 3 entries, got %d\", entryCount)\n\t}\n\n\tentries, ok := resultMap[\"entries\"].([]fileutil.DirEntryOut)\n\tif !ok {\n\t\tt.Fatalf(\"entries is not a slice of DirEntryOut\")\n\t}\n\n\t// Check that we have the expected entries\n\tfoundFiles := 0\n\tfoundDirs := 0\n\tfor _, entry := range entries {\n\t\tif entry.Dir {\n\t\t\tfoundDirs++\n\t\t} else {\n\t\t\tfoundFiles++\n\t\t}\n\t}\n\n\tif foundFiles != 2 {\n\t\tt.Errorf(\"Expected 2 files, got %d\", foundFiles)\n\t}\n\tif foundDirs != 1 {\n\t\tt.Errorf(\"Expected 1 directory, got %d\", foundDirs)\n\t}\n}\n\nfunc TestReadDirOnFile(t *testing.T) {\n\t// Create a temporary test file\n\ttmpFile, err := os.CreateTemp(\"\", \"readdir_test_file\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp file: %v\", err)\n\t}\n\tdefer os.Remove(tmpFile.Name())\n\ttmpFile.Close()\n\n\t// Test reading a file (should fail)\n\tinput := map[string]any{\n\t\t\"path\": tmpFile.Name(),\n\t}\n\n\t_, err = readDirCallback(input, &uctypes.UIMessageDataToolUse{})\n\tif err == nil {\n\t\tt.Fatalf(\"Expected error when reading a file with read_dir, got nil\")\n\t}\n\n\texpectedErrSubstr := \"path is not a directory\"\n\tif err.Error()[:len(expectedErrSubstr)] != expectedErrSubstr {\n\t\tt.Errorf(\"Expected error containing %q, got %q\", expectedErrSubstr, err.Error())\n\t}\n}\n\nfunc TestReadDirMaxEntries(t *testing.T) {\n\t// Create a temporary test directory with many files\n\ttmpDir, err := os.MkdirTemp(\"\", \"readdir_test_max\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\t// Create 10 test files\n\tfor i := 0; i < 10; i++ {\n\t\ttestFile := filepath.Join(tmpDir, filepath.Base(tmpDir)+string(rune('a'+i))+\".txt\")\n\t\tif err := os.WriteFile(testFile, []byte(\"test\"), 0644); err != nil {\n\t\t\tt.Fatalf(\"Failed to create test file: %v\", err)\n\t\t}\n\t}\n\n\t// Test reading with max_entries=5\n\tmaxEntries := 5\n\tinput := map[string]any{\n\t\t\"path\":        tmpDir,\n\t\t\"max_entries\": maxEntries,\n\t}\n\n\tresult, err := readDirCallback(input, &uctypes.UIMessageDataToolUse{})\n\tif err != nil {\n\t\tt.Fatalf(\"readDirCallback failed: %v\", err)\n\t}\n\n\tresultMap := result.(map[string]any)\n\tentryCount := resultMap[\"entry_count\"].(int)\n\ttotalEntries := resultMap[\"total_entries\"].(int)\n\n\tif entryCount != maxEntries {\n\t\tt.Errorf(\"Expected %d entries, got %d\", maxEntries, entryCount)\n\t}\n\n\t// Verify total_entries reports the original count, not the truncated count\n\tif totalEntries != 10 {\n\t\tt.Errorf(\"Expected total_entries to be 10, got %d\", totalEntries)\n\t}\n\n\tif _, ok := resultMap[\"truncated\"]; !ok {\n\t\tt.Error(\"Expected truncated field to be present\")\n\t}\n\n\t// Verify the truncation message includes the correct total\n\ttruncMsg, ok := resultMap[\"truncated_message\"].(string)\n\tif !ok {\n\t\tt.Error(\"Expected truncated_message to be present\")\n\t}\n\texpectedMsg := fmt.Sprintf(\"Directory listing truncated to %d entries (out of %d total)\", maxEntries, 10)\n\tif !strings.Contains(truncMsg, expectedMsg[:len(expectedMsg)-1]) {\n\t\tt.Errorf(\"Expected truncated_message to contain %q, got %q\", expectedMsg, truncMsg)\n\t}\n}\n\nfunc TestReadDirSortBeforeTruncate(t *testing.T) {\n\t// Create a temporary test directory\n\ttmpDir, err := os.MkdirTemp(\"\", \"readdir_test_sort\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\t// Create files with names that would sort alphabetically before directories\n\t// but we want directories to appear first\n\tfor i := 0; i < 5; i++ {\n\t\ttestFile := filepath.Join(tmpDir, fmt.Sprintf(\"a_file_%d.txt\", i))\n\t\tif err := os.WriteFile(testFile, []byte(\"test\"), 0644); err != nil {\n\t\t\tt.Fatalf(\"Failed to create test file: %v\", err)\n\t\t}\n\t}\n\n\t// Create directories with names that sort alphabetically after the files\n\tfor i := 0; i < 3; i++ {\n\t\ttestDir := filepath.Join(tmpDir, fmt.Sprintf(\"z_dir_%d\", i))\n\t\tif err := os.Mkdir(testDir, 0755); err != nil {\n\t\t\tt.Fatalf(\"Failed to create test dir: %v\", err)\n\t\t}\n\t}\n\n\t// Test with max_entries=5 (less than total of 8)\n\t// All 3 directories should still appear because they're sorted first\n\tmaxEntries := 5\n\tinput := map[string]any{\n\t\t\"path\":        tmpDir,\n\t\t\"max_entries\": maxEntries,\n\t}\n\n\tresult, err := readDirCallback(input, &uctypes.UIMessageDataToolUse{})\n\tif err != nil {\n\t\tt.Fatalf(\"readDirCallback failed: %v\", err)\n\t}\n\n\tresultMap := result.(map[string]any)\n\tentries, ok := resultMap[\"entries\"].([]fileutil.DirEntryOut)\n\tif !ok {\n\t\tt.Fatalf(\"entries is not a slice of DirEntryOut\")\n\t}\n\n\t// Count directories in the result\n\tdirCount := 0\n\tfor _, entry := range entries {\n\t\tif entry.Dir {\n\t\t\tdirCount++\n\t\t}\n\t}\n\n\t// All 3 directories should be present because sorting happens before truncation\n\tif dirCount != 3 {\n\t\tt.Errorf(\"Expected 3 directories in truncated results, got %d\", dirCount)\n\t}\n\n\t// First 3 entries should be directories\n\tfor i := 0; i < 3; i++ {\n\t\tif !entries[i].Dir {\n\t\t\tt.Errorf(\"Expected entry %d to be a directory, but it was a file\", i)\n\t\t}\n\t}\n}\n\nfunc TestParseReadDirInput(t *testing.T) {\n\t// Test valid input\n\tinput := map[string]any{\n\t\t\"path\": \"/tmp/test\",\n\t}\n\n\tparams, err := parseReadDirInput(input)\n\tif err != nil {\n\t\tt.Fatalf(\"parseReadDirInput failed on valid input: %v\", err)\n\t}\n\n\tif params.Path != \"/tmp/test\" {\n\t\tt.Errorf(\"Expected path '/tmp/test', got %q\", params.Path)\n\t}\n\n\tif *params.MaxEntries != ReadDirDefaultMaxEntries {\n\t\tt.Errorf(\"Expected default max_entries %d, got %d\", ReadDirDefaultMaxEntries, *params.MaxEntries)\n\t}\n\n\t// Test missing path\n\tinput = map[string]any{}\n\t_, err = parseReadDirInput(input)\n\tif err == nil {\n\t\tt.Error(\"Expected error for missing path, got nil\")\n\t}\n\n\t// Test invalid max_entries\n\tinput = map[string]any{\n\t\t\"path\":        \"/tmp/test\",\n\t\t\"max_entries\": 0,\n\t}\n\t_, err = parseReadDirInput(input)\n\tif err == nil {\n\t\tt.Error(\"Expected error for max_entries < 1, got nil\")\n\t}\n}\n\nfunc TestGetReadDirToolDefinition(t *testing.T) {\n\ttoolDef := GetReadDirToolDefinition()\n\n\tif toolDef.Name != \"read_dir\" {\n\t\tt.Errorf(\"Expected tool name 'read_dir', got %q\", toolDef.Name)\n\t}\n\n\tif toolDef.ToolLogName != \"gen:readdir\" {\n\t\tt.Errorf(\"Expected tool log name 'gen:readdir', got %q\", toolDef.ToolLogName)\n\t}\n\n\tif toolDef.ToolAnyCallback == nil {\n\t\tt.Error(\"ToolAnyCallback should not be nil\")\n\t}\n\n\tif toolDef.ToolApproval == nil {\n\t\tt.Error(\"ToolApproval should not be nil\")\n\t}\n\n\tif toolDef.ToolCallDesc == nil {\n\t\tt.Error(\"ToolCallDesc should not be nil\")\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/tools_readfile.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/readutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\nconst ReadFileDefaultLineCount = 100\nconst ReadFileDefaultMaxBytes = 50 * 1024\nconst StopReasonMaxBytes = \"max_bytes\"\n\ntype readTextFileParams struct {\n\tFilename string  `json:\"filename\"`\n\tOrigin   *string `json:\"origin\"` // \"start\" or \"end\", defaults to \"start\"\n\tOffset   *int    `json:\"offset\"` // lines to skip, defaults to 0\n\tCount    *int    `json:\"count\"`  // number of lines to read, defaults to DefaultLineCount\n\tMaxBytes *int    `json:\"max_bytes\"`\n}\n\nfunc parseReadTextFileInput(input any) (*readTextFileParams, error) {\n\tresult := &readTextFileParams{}\n\n\tif input == nil {\n\t\treturn nil, fmt.Errorf(\"input is required\")\n\t}\n\n\tif err := utilfn.ReUnmarshal(result, input); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid input format: %w\", err)\n\t}\n\n\tif result.Filename == \"\" {\n\t\treturn nil, fmt.Errorf(\"missing filename parameter\")\n\t}\n\n\tif result.Origin == nil {\n\t\torigin := \"start\"\n\t\tresult.Origin = &origin\n\t}\n\n\tif *result.Origin != \"start\" && *result.Origin != \"end\" {\n\t\treturn nil, fmt.Errorf(\"invalid origin value '%s': must be 'start' or 'end'\", *result.Origin)\n\t}\n\n\tif result.Offset == nil {\n\t\toffset := 0\n\t\tresult.Offset = &offset\n\t}\n\n\tif *result.Offset < 0 {\n\t\treturn nil, fmt.Errorf(\"offset must be non-negative, got %d\", *result.Offset)\n\t}\n\n\tif result.Count == nil {\n\t\tcount := ReadFileDefaultLineCount\n\t\tresult.Count = &count\n\t}\n\n\tif *result.Count < 1 {\n\t\treturn nil, fmt.Errorf(\"count must be at least 1, got %d\", *result.Count)\n\t}\n\n\tif result.MaxBytes == nil {\n\t\tmaxBytes := ReadFileDefaultMaxBytes\n\t\tresult.MaxBytes = &maxBytes\n\t}\n\n\treturn result, nil\n}\n\n// truncateData truncates data to maxBytes while respecting line boundaries.\n// For origin \"start\", keeps the beginning and truncates at last newline before maxBytes.\n// For origin \"end\", keeps the end and truncates from beginning at first newline after removing excess.\nfunc truncateData(data string, origin string, maxBytes int) string {\n\tif len(data) <= maxBytes {\n\t\treturn data\n\t}\n\n\tif origin == \"end\" {\n\t\texcessBytes := len(data) - maxBytes\n\t\ttruncateIdx := strings.Index(data[excessBytes:], \"\\n\")\n\t\tif truncateIdx == -1 {\n\t\t\treturn data[excessBytes:]\n\t\t}\n\t\treturn data[excessBytes+truncateIdx+1:]\n\t}\n\n\ttruncateIdx := strings.LastIndex(data[:maxBytes], \"\\n\")\n\tif truncateIdx == -1 {\n\t\treturn data[:maxBytes]\n\t}\n\treturn data[:truncateIdx+1]\n}\n\nfunc isBlockedFile(expandedPath string) (bool, string) {\n\thomeDir := os.Getenv(\"HOME\")\n\tif homeDir == \"\" {\n\t\thomeDir = os.Getenv(\"USERPROFILE\")\n\t}\n\n\tcleanPath := filepath.Clean(expandedPath)\n\tbaseName := filepath.Base(cleanPath)\n\n\texactPaths := []struct {\n\t\tpath   string\n\t\treason string\n\t}{\n\t\t{filepath.Join(homeDir, \".aws\", \"credentials\"), \"AWS credentials file\"},\n\t\t{filepath.Join(homeDir, \".git-credentials\"), \"Git credentials file\"},\n\t\t{filepath.Join(homeDir, \".netrc\"), \"netrc credentials file\"},\n\t\t{filepath.Join(homeDir, \".pgpass\"), \"PostgreSQL password file\"},\n\t\t{filepath.Join(homeDir, \".my.cnf\"), \"MySQL credentials file\"},\n\t\t{filepath.Join(homeDir, \".kube\", \"config\"), \"Kubernetes config file\"},\n\t\t{\"/etc/shadow\", \"system password file\"},\n\t\t{\"/etc/sudoers\", \"system sudoers file\"},\n\t}\n\n\tfor _, ep := range exactPaths {\n\t\tif cleanPath == ep.path {\n\t\t\treturn true, ep.reason\n\t\t}\n\t}\n\n\tdirPrefixes := []struct {\n\t\tprefix string\n\t\treason string\n\t}{\n\t\t{filepath.Join(homeDir, \".gnupg\") + string(filepath.Separator), \"GPG directory\"},\n\t\t{filepath.Join(homeDir, \".password-store\") + string(filepath.Separator), \"password store directory\"},\n\t\t{\"/etc/sudoers.d/\", \"system sudoers directory\"},\n\t\t{\"/Library/Keychains/\", \"macOS keychain directory\"},\n\t\t{filepath.Join(homeDir, \"Library\", \"Keychains\") + string(filepath.Separator), \"macOS keychain directory\"},\n\t}\n\n\tfor _, dp := range dirPrefixes {\n\t\tif strings.HasPrefix(cleanPath, dp.prefix) {\n\t\t\treturn true, dp.reason\n\t\t}\n\t}\n\n\tif strings.Contains(cleanPath, filepath.Join(homeDir, \".secrets\")) {\n\t\treturn true, \"secrets directory\"\n\t}\n\n\tif localAppData := os.Getenv(\"LOCALAPPDATA\"); localAppData != \"\" {\n\t\tcredPath := filepath.Join(localAppData, \"Microsoft\", \"Credentials\")\n\t\tif strings.HasPrefix(cleanPath, credPath) {\n\t\t\treturn true, \"Windows credentials\"\n\t\t}\n\t}\n\tif appData := os.Getenv(\"APPDATA\"); appData != \"\" {\n\t\tcredPath := filepath.Join(appData, \"Microsoft\", \"Credentials\")\n\t\tif strings.HasPrefix(cleanPath, credPath) {\n\t\t\treturn true, \"Windows credentials\"\n\t\t}\n\t}\n\n\tif strings.HasPrefix(baseName, \"id_\") && strings.Contains(cleanPath, \".ssh\") {\n\t\treturn true, \"SSH private key\"\n\t}\n\tif strings.Contains(baseName, \"id_rsa\") {\n\t\treturn true, \"SSH private key\"\n\t}\n\tif strings.HasPrefix(baseName, \"ssh_host_\") && strings.Contains(baseName, \"key\") {\n\t\treturn true, \"SSH host key\"\n\t}\n\n\textensions := map[string]string{\n\t\t\".pem\":      \"certificate/key file\",\n\t\t\".p12\":      \"certificate file\",\n\t\t\".key\":      \"key file\",\n\t\t\".pfx\":      \"certificate file\",\n\t\t\".pkcs12\":   \"certificate file\",\n\t\t\".keystore\": \"Java keystore file\",\n\t\t\".jks\":      \"Java keystore file\",\n\t}\n\n\tif reason, exists := extensions[filepath.Ext(baseName)]; exists {\n\t\treturn true, reason\n\t}\n\n\tif baseName == \".git-credentials\" {\n\t\treturn true, \"Git credentials file\"\n\t}\n\n\treturn false, \"\"\n}\n\nfunc verifyReadTextFileInput(input any, toolUseData *uctypes.UIMessageDataToolUse) error {\n\tparams, err := parseReadTextFileInput(input)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\texpandedPath, err := wavebase.ExpandHomeDir(params.Filename)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to expand path: %w\", err)\n\t}\n\n\tif !filepath.IsAbs(expandedPath) {\n\t\treturn fmt.Errorf(\"path must be absolute, got relative path: %s\", params.Filename)\n\t}\n\n\tif blocked, reason := isBlockedFile(expandedPath); blocked {\n\t\treturn fmt.Errorf(\"access denied: potentially sensitive file: %s\", reason)\n\t}\n\n\tfileInfo, err := os.Stat(expandedPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to stat file: %w\", err)\n\t}\n\n\tif fileInfo.IsDir() {\n\t\treturn fmt.Errorf(\"path is a directory, cannot be read with the read_text_file tool. use the read_dir tool if available to read directories\")\n\t}\n\n\treturn nil\n}\n\nfunc readTextFileCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\tconst ReadLimit = 1024 * 1024 * 1024\n\n\tparams, err := parseReadTextFileInput(input)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\texpandedPath, err := wavebase.ExpandHomeDir(params.Filename)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to expand path: %w\", err)\n\t}\n\n\tif !filepath.IsAbs(expandedPath) {\n\t\treturn nil, fmt.Errorf(\"path must be absolute, got relative path: %s\", params.Filename)\n\t}\n\n\tif blocked, reason := isBlockedFile(expandedPath); blocked {\n\t\treturn nil, fmt.Errorf(\"access denied: potentially sensitive file: %s\", reason)\n\t}\n\n\tfileInfo, err := os.Stat(expandedPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to stat file: %w\", err)\n\t}\n\n\tif fileInfo.IsDir() {\n\t\treturn nil, fmt.Errorf(\"path is a directory, cannot be read with the read_text_file tool. use the read_dir tool if available to read directories\")\n\t}\n\n\tfile, err := os.Open(expandedPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to open file: %w\", err)\n\t}\n\tdefer file.Close()\n\n\ttotalSize := fileInfo.Size()\n\tmodTime := fileInfo.ModTime()\n\n\tinitialBuf := make([]byte, min(8192, int(totalSize)))\n\tn, err := file.Read(initialBuf)\n\tif err != nil && err != io.EOF {\n\t\treturn nil, fmt.Errorf(\"failed to read file: %w\", err)\n\t}\n\tinitialBuf = initialBuf[:n]\n\n\tif utilfn.IsBinaryContent(initialBuf) {\n\t\treturn nil, fmt.Errorf(\"file appears to be binary content\")\n\t}\n\n\torigin := *params.Origin\n\toffset := *params.Offset\n\tcount := *params.Count\n\tmaxBytes := *params.MaxBytes\n\n\tvar lines []string\n\tvar stopReason string\n\n\tif _, err := file.Seek(0, 0); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to seek to start of file: %w\", err)\n\t}\n\n\tif origin == \"end\" {\n\t\tlines, stopReason, err = readutil.ReadTailLines(file, count, offset, int64(ReadLimit))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error reading file from end: %w\", err)\n\t\t}\n\t} else {\n\t\tlines, stopReason, err = readutil.ReadLines(file, count, offset, ReadLimit)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error reading file: %w\", err)\n\t\t}\n\t}\n\n\tdata := strings.Join(lines, \"\")\n\tdata = strings.TrimSuffix(data, \"\\n\")\n\n\tif len(data) > maxBytes {\n\t\tdata = truncateData(data, origin, maxBytes)\n\t\tstopReason = StopReasonMaxBytes\n\t}\n\n\tresult := map[string]any{\n\t\t\"total_size\":    totalSize,\n\t\t\"data\":          data,\n\t\t\"modified\":      utilfn.FormatRelativeTime(modTime),\n\t\t\"modified_time\": modTime.UTC().Format(time.RFC3339),\n\t\t\"mode\":          fileInfo.Mode().String(),\n\t}\n\tif stopReason == \"read_limit\" || stopReason == StopReasonMaxBytes {\n\t\tresult[\"truncated\"] = stopReason\n\t}\n\n\treturn result, nil\n}\n\nfunc GetReadTextFileToolDefinition() uctypes.ToolDefinition {\n\treturn uctypes.ToolDefinition{\n\t\tName:        \"read_text_file\",\n\t\tDisplayName: \"Read Text File\",\n\t\tDescription: \"Read a text file from the filesystem. Can read specific line ranges or from the end. Detects and rejects binary files.\",\n\t\tToolLogName: \"gen:readfile\",\n\t\tStrict:      false,\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"filename\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"description\": \"Absolute path to the file to read. Supports '~' for the user's home directory. Relative paths are not supported.\",\n\t\t\t\t},\n\t\t\t\t\"origin\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"enum\":        []string{\"start\", \"end\"},\n\t\t\t\t\t\"default\":     \"start\",\n\t\t\t\t\t\"description\": \"Where to read from: 'start' (default) or 'end' of file\",\n\t\t\t\t},\n\t\t\t\t\"offset\": map[string]any{\n\t\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\t\"minimum\":     0,\n\t\t\t\t\t\"default\":     0,\n\t\t\t\t\t\"description\": \"Lines to skip. From 'start': 0-based line index. From 'end': lines to skip from the end (0 = very last line)\",\n\t\t\t\t},\n\t\t\t\t\"count\": map[string]any{\n\t\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\t\"minimum\":     1,\n\t\t\t\t\t\"default\":     ReadFileDefaultLineCount,\n\t\t\t\t\t\"description\": \"Number of lines to return\",\n\t\t\t\t},\n\t\t\t\t\"max_bytes\": map[string]any{\n\t\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\t\"minimum\":     1,\n\t\t\t\t\t\"default\":     ReadFileDefaultMaxBytes,\n\t\t\t\t\t\"description\": \"Maximum bytes to return. If the result exceeds this, it will be truncated at line boundaries\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"required\":             []string{\"filename\"},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\tparsed, err := parseReadTextFileInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Sprintf(\"error parsing input: %v\", err)\n\t\t\t}\n\n\t\t\torigin := *parsed.Origin\n\t\t\toffset := *parsed.Offset\n\t\t\tcount := *parsed.Count\n\n\t\t\treadFullFile := false\n\t\t\tif output != nil {\n\t\t\t\tif outputMap, ok := output.(map[string]any); ok {\n\t\t\t\t\t_, wasTruncated := outputMap[\"truncated\"]\n\t\t\t\t\treadFullFile = !wasTruncated\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif origin == \"start\" && offset == 0 {\n\t\t\t\tif readFullFile {\n\t\t\t\t\treturn fmt.Sprintf(\"reading %q (entire file)\", parsed.Filename)\n\t\t\t\t}\n\t\t\t\treturn fmt.Sprintf(\"reading %q (first %d lines)\", parsed.Filename, count)\n\t\t\t}\n\t\t\tif origin == \"end\" && offset == 0 {\n\t\t\t\tif readFullFile {\n\t\t\t\t\treturn fmt.Sprintf(\"reading %q (entire file)\", parsed.Filename)\n\t\t\t\t}\n\t\t\t\treturn fmt.Sprintf(\"reading %q (last %d lines)\", parsed.Filename, count)\n\t\t\t}\n\t\t\tif origin == \"end\" {\n\t\t\t\treturn fmt.Sprintf(\"reading %q (from end: offset %d lines, count %d lines)\", parsed.Filename, offset, count)\n\t\t\t}\n\t\t\treturn fmt.Sprintf(\"reading %q (from start: offset %d lines, count %d lines)\", parsed.Filename, offset, count)\n\t\t},\n\t\tToolAnyCallback: readTextFileCallback,\n\t\tToolApproval: func(input any) string {\n\t\t\treturn uctypes.ApprovalNeedsApproval\n\t\t},\n\t\tToolVerifyInput: verifyReadTextFileInput,\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/tools_screenshot.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcore\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nfunc makeTabCaptureBlockScreenshot(tabId string) func(any) (string, error) {\n\treturn func(input any) (string, error) {\n\t\tinputMap, ok := input.(map[string]any)\n\t\tif !ok {\n\t\t\treturn \"\", fmt.Errorf(\"invalid input format\")\n\t\t}\n\n\t\tblockIdPrefix, ok := inputMap[\"widget_id\"].(string)\n\t\tif !ok {\n\t\t\treturn \"\", fmt.Errorf(\"missing or invalid widget_id parameter\")\n\t\t}\n\n\t\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer cancelFn()\n\n\t\tfullBlockId, err := wcore.ResolveBlockIdFromPrefix(ctx, tabId, blockIdPrefix)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\trpcClient := wshclient.GetBareRpcClient()\n\t\tscreenshotData, err := wshclient.CaptureBlockScreenshotCommand(\n\t\t\trpcClient,\n\t\t\twshrpc.CommandCaptureBlockScreenshotData{BlockId: fullBlockId},\n\t\t\t&wshrpc.RpcOpts{Route: wshutil.MakeTabRouteId(tabId)},\n\t\t)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to capture screenshot: %w\", err)\n\t\t}\n\n\t\treturn screenshotData, nil\n\t}\n}\n\nfunc GetCaptureScreenshotToolDefinition(tabId string) uctypes.ToolDefinition {\n\treturn uctypes.ToolDefinition{\n\t\tName:        \"capture_screenshot\",\n\t\tDisplayName: \"Capture Screenshot\",\n\t\tDescription: \"Capture a screenshot of a widget and return it as an image\",\n\t\tToolLogName: \"gen:screenshot\",\n\t\tStrict:      true,\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"widget_id\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"description\": \"8-character widget ID of the widget to screenshot\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"required\":             []string{\"widget_id\"},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tRequiredCapabilities: []string{uctypes.AICapabilityImages},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\tinputMap, ok := input.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\treturn \"error parsing input: invalid format\"\n\t\t\t}\n\t\t\twidgetId, ok := inputMap[\"widget_id\"].(string)\n\t\t\tif !ok {\n\t\t\t\treturn \"error parsing input: missing widget_id\"\n\t\t\t}\n\t\t\treturn fmt.Sprintf(\"capturing screenshot of widget %s\", widgetId)\n\t\t},\n\t\tToolTextCallback: makeTabCaptureBlockScreenshot(tabId),\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/tools_term.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcore\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\ntype TermGetScrollbackToolInput struct {\n\tWidgetId  string `json:\"widget_id\"`\n\tLineStart int    `json:\"line_start,omitempty\"`\n\tCount     int    `json:\"count,omitempty\"`\n}\n\ntype CommandInfo struct {\n\tCommand  string `json:\"command\"`\n\tStatus   string `json:\"status\"`\n\tExitCode *int   `json:\"exitcode,omitempty\"`\n}\n\ntype TermGetScrollbackToolOutput struct {\n\tTotalLines         int          `json:\"totallines\"`\n\tLineStart          int          `json:\"linestart\"`\n\tLineEnd            int          `json:\"lineend\"`\n\tReturnedLines      int          `json:\"returnedlines\"`\n\tContent            string       `json:\"content\"`\n\tSinceLastOutputSec *int         `json:\"sincelastoutputsec,omitempty\"`\n\tHasMore            bool         `json:\"hasmore\"`\n\tNextStart          *int         `json:\"nextstart\"`\n\tLastCommand        *CommandInfo `json:\"lastcommand,omitempty\"`\n}\n\nfunc parseTermGetScrollbackInput(input any) (*TermGetScrollbackToolInput, error) {\n\tconst (\n\t\tDefaultCount = 200\n\t\tMaxCount     = 1000\n\t)\n\n\tresult := &TermGetScrollbackToolInput{\n\t\tLineStart: 0,\n\t\tCount:     0,\n\t}\n\n\tif input == nil {\n\t\tresult.Count = DefaultCount\n\t\treturn result, nil\n\t}\n\n\tinputBytes, err := json.Marshal(input)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal input: %w\", err)\n\t}\n\n\tif err := json.Unmarshal(inputBytes, result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal input: %w\", err)\n\t}\n\n\tif result.Count == 0 {\n\t\tresult.Count = DefaultCount\n\t}\n\n\tif result.Count < 0 {\n\t\treturn nil, fmt.Errorf(\"count must be positive\")\n\t}\n\n\tresult.Count = min(result.Count, MaxCount)\n\n\treturn result, nil\n}\n\nfunc getTermScrollbackOutput(tabId string, widgetId string, rpcData wshrpc.CommandTermGetScrollbackLinesData) (*TermGetScrollbackToolOutput, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\n\tfullBlockId, err := wcore.ResolveBlockIdFromPrefix(ctx, tabId, widgetId)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trpcClient := wshclient.GetBareRpcClient()\n\tresult, err := wshclient.TermGetScrollbackLinesCommand(\n\t\trpcClient,\n\t\trpcData,\n\t\t&wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(fullBlockId)},\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcontent := strings.Join(result.Lines, \"\\n\")\n\tvar effectiveLineEnd int\n\tif rpcData.LastCommand {\n\t\teffectiveLineEnd = result.LineStart + len(result.Lines)\n\t} else {\n\t\teffectiveLineEnd = min(rpcData.LineEnd, result.TotalLines)\n\t}\n\thasMore := effectiveLineEnd < result.TotalLines\n\n\tvar sinceLastOutputSec *int\n\tif result.LastUpdated > 0 {\n\t\tsec := max(0, int((time.Now().UnixMilli()-result.LastUpdated)/1000))\n\t\tsinceLastOutputSec = &sec\n\t}\n\n\tvar nextStart *int\n\tif hasMore {\n\t\tnextStart = &effectiveLineEnd\n\t}\n\n\tblockORef := waveobj.MakeORef(waveobj.OType_Block, fullBlockId)\n\trtInfo := wstore.GetRTInfo(blockORef)\n\n\tvar lastCommand *CommandInfo\n\tif rtInfo != nil && rtInfo.ShellIntegration && rtInfo.ShellLastCmd != \"\" {\n\t\tcmdInfo := &CommandInfo{\n\t\t\tCommand: rtInfo.ShellLastCmd,\n\t\t}\n\t\tif rtInfo.ShellState == \"running-command\" {\n\t\t\tcmdInfo.Status = \"running\"\n\t\t} else if rtInfo.ShellState == \"ready\" {\n\t\t\tcmdInfo.Status = \"completed\"\n\t\t\texitCode := rtInfo.ShellLastCmdExitCode\n\t\t\tcmdInfo.ExitCode = &exitCode\n\t\t}\n\t\tlastCommand = cmdInfo\n\t}\n\n\treturn &TermGetScrollbackToolOutput{\n\t\tTotalLines:         result.TotalLines,\n\t\tLineStart:          result.LineStart,\n\t\tLineEnd:            effectiveLineEnd,\n\t\tReturnedLines:      len(result.Lines),\n\t\tContent:            content,\n\t\tSinceLastOutputSec: sinceLastOutputSec,\n\t\tHasMore:            hasMore,\n\t\tNextStart:          nextStart,\n\t\tLastCommand:        lastCommand,\n\t}, nil\n}\n\nfunc GetTermGetScrollbackToolDefinition(tabId string) uctypes.ToolDefinition {\n\treturn uctypes.ToolDefinition{\n\t\tName:        \"term_get_scrollback\",\n\t\tDisplayName: \"Get Terminal Scrollback\",\n\t\tDescription: \"Fetch terminal scrollback from a widget as plain text. Index 0 is the most recent line; indices increase going upward (older lines). Also returns last command and exit code if shell integration is enabled.\",\n\t\tToolLogName: \"term:getscrollback\",\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"widget_id\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"description\": \"8-character widget ID of the terminal widget\",\n\t\t\t\t},\n\t\t\t\t\"line_start\": map[string]any{\n\t\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\t\"minimum\":     0,\n\t\t\t\t\t\"description\": \"Logical start index where 0 = most recent line (default: 0).\",\n\t\t\t\t},\n\t\t\t\t\"count\": map[string]any{\n\t\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\t\"minimum\":     1,\n\t\t\t\t\t\"description\": \"Number of lines to return from line_start (default: 200).\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"required\":             []string{\"widget_id\"},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\tparsed, err := parseTermGetScrollbackInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Sprintf(\"error parsing input: %v\", err)\n\t\t\t}\n\n\t\t\tif parsed.LineStart == 0 && parsed.Count == 200 {\n\t\t\t\treturn fmt.Sprintf(\"reading terminal output from %s (most recent %d lines)\", parsed.WidgetId, parsed.Count)\n\t\t\t}\n\t\t\tlineEnd := parsed.LineStart + parsed.Count\n\t\t\treturn fmt.Sprintf(\"reading terminal output from %s (lines %d-%d)\", parsed.WidgetId, parsed.LineStart, lineEnd)\n\t\t},\n\t\tToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\t\t\tparsed, err := parseTermGetScrollbackInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tlineEnd := parsed.LineStart + parsed.Count\n\t\t\toutput, err := getTermScrollbackOutput(\n\t\t\t\ttabId,\n\t\t\t\tparsed.WidgetId,\n\t\t\t\twshrpc.CommandTermGetScrollbackLinesData{\n\t\t\t\t\tLineStart:   parsed.LineStart,\n\t\t\t\t\tLineEnd:     lineEnd,\n\t\t\t\t\tLastCommand: false,\n\t\t\t\t},\n\t\t\t)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to get terminal scrollback: %w\", err)\n\t\t\t}\n\t\t\treturn output, nil\n\t\t},\n\t}\n}\n\ntype TermCommandOutputToolInput struct {\n\tWidgetId string `json:\"widget_id\"`\n}\n\nfunc parseTermCommandOutputInput(input any) (*TermCommandOutputToolInput, error) {\n\tresult := &TermCommandOutputToolInput{}\n\n\tif input == nil {\n\t\treturn nil, fmt.Errorf(\"widget_id is required\")\n\t}\n\n\tinputBytes, err := json.Marshal(input)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal input: %w\", err)\n\t}\n\n\tif err := json.Unmarshal(inputBytes, result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal input: %w\", err)\n\t}\n\n\tif result.WidgetId == \"\" {\n\t\treturn nil, fmt.Errorf(\"widget_id is required\")\n\t}\n\n\treturn result, nil\n}\n\nfunc GetTermCommandOutputToolDefinition(tabId string) uctypes.ToolDefinition {\n\treturn uctypes.ToolDefinition{\n\t\tName:        \"term_command_output\",\n\t\tDisplayName: \"Get Last Command Output\",\n\t\tDescription: \"Retrieve output from the most recent command in a terminal widget. Requires shell integration to be enabled. Returns the command text, exit code, and up to 1000 lines of output.\",\n\t\tToolLogName: \"term:commandoutput\",\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"widget_id\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"description\": \"8-character widget ID of the terminal widget\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"required\":             []string{\"widget_id\"},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\tparsed, err := parseTermCommandOutputInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Sprintf(\"error parsing input: %v\", err)\n\t\t\t}\n\t\t\treturn fmt.Sprintf(\"reading last command output from %s\", parsed.WidgetId)\n\t\t},\n\t\tToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\t\t\tparsed, err := parseTermCommandOutputInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\t\t\tdefer cancelFn()\n\n\t\t\tfullBlockId, err := wcore.ResolveBlockIdFromPrefix(ctx, tabId, parsed.WidgetId)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tblockORef := waveobj.MakeORef(waveobj.OType_Block, fullBlockId)\n\t\t\trtInfo := wstore.GetRTInfo(blockORef)\n\t\t\tif rtInfo == nil || !rtInfo.ShellIntegration {\n\t\t\t\treturn nil, fmt.Errorf(\"shell integration is not enabled for this terminal\")\n\t\t\t}\n\n\t\t\toutput, err := getTermScrollbackOutput(\n\t\t\t\ttabId,\n\t\t\t\tparsed.WidgetId,\n\t\t\t\twshrpc.CommandTermGetScrollbackLinesData{\n\t\t\t\t\tLastCommand: true,\n\t\t\t\t},\n\t\t\t)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to get command output: %w\", err)\n\t\t\t}\n\t\t\treturn output, nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/tools_tsunami.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/blockcontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nfunc getTsunamiShortDesc(rtInfo *waveobj.ObjRTInfo) string {\n\tif rtInfo == nil || rtInfo.TsunamiAppMeta == nil {\n\t\treturn \"\"\n\t}\n\tvar appMeta wshrpc.AppMeta\n\tif err := utilfn.ReUnmarshal(&appMeta, rtInfo.TsunamiAppMeta); err == nil && appMeta.ShortDesc != \"\" {\n\t\treturn appMeta.ShortDesc\n\t}\n\treturn \"\"\n}\n\nfunc handleTsunamiBlockDesc(block *waveobj.Block) string {\n\tstatus := blockcontroller.GetBlockControllerRuntimeStatus(block.OID)\n\tif status == nil || status.ShellProcStatus != blockcontroller.Status_Running {\n\t\treturn \"tsunami framework widget that is currently not running\"\n\t}\n\n\tblockORef := waveobj.MakeORef(waveobj.OType_Block, block.OID)\n\trtInfo := wstore.GetRTInfo(blockORef)\n\tif shortDesc := getTsunamiShortDesc(rtInfo); shortDesc != \"\" {\n\t\treturn fmt.Sprintf(\"tsunami widget - %s\", shortDesc)\n\t}\n\treturn \"tsunami widget - unknown description\"\n}\n\nfunc makeTsunamiGetCallback(status *blockcontroller.BlockControllerRuntimeStatus, apiPath string) func(any, *uctypes.UIMessageDataToolUse) (any, error) {\n\treturn func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\t\tif status.TsunamiPort == 0 {\n\t\t\treturn nil, fmt.Errorf(\"tsunami port not available\")\n\t\t}\n\n\t\turl := fmt.Sprintf(\"http://localhost:%d%s\", status.TsunamiPort, apiPath)\n\n\t\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer cancel()\n\n\t\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t\t}\n\n\t\tresp, err := http.DefaultClient.Do(req)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to make request to tsunami: %w\", err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\treturn nil, fmt.Errorf(\"tsunami returned status %d\", resp.StatusCode)\n\t\t}\n\n\t\tvar result any\n\t\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to decode tsunami response: %w\", err)\n\t\t}\n\n\t\treturn result, nil\n\t}\n}\n\nfunc makeTsunamiPostCallback(status *blockcontroller.BlockControllerRuntimeStatus, apiPath string) func(any, *uctypes.UIMessageDataToolUse) (any, error) {\n\treturn func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\t\tif status.TsunamiPort == 0 {\n\t\t\treturn nil, fmt.Errorf(\"tsunami port not available\")\n\t\t}\n\n\t\turl := fmt.Sprintf(\"http://localhost:%d%s\", status.TsunamiPort, apiPath)\n\n\t\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer cancel()\n\n\t\tvar reqBody []byte\n\t\tvar err error\n\t\tif input != nil {\n\t\t\treqBody, err = json.Marshal(input)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to marshal input: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\treq, err := http.NewRequestWithContext(ctx, \"POST\", url, strings.NewReader(string(reqBody)))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t\t}\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\tresp, err := http.DefaultClient.Do(req)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to make request to tsunami: %w\", err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\treturn nil, fmt.Errorf(\"tsunami returned status %d\", resp.StatusCode)\n\t\t}\n\n\t\treturn true, nil\n\t}\n}\n\nfunc GetTsunamiGetDataToolDefinition(block *waveobj.Block, rtInfo *waveobj.ObjRTInfo, status *blockcontroller.BlockControllerRuntimeStatus) *uctypes.ToolDefinition {\n\tblockIdPrefix := block.OID[:8]\n\ttoolName := fmt.Sprintf(\"tsunami_getdata_%s\", blockIdPrefix)\n\n\tdesc := \"tsunami widget\"\n\tif shortDesc := getTsunamiShortDesc(rtInfo); shortDesc != \"\" {\n\t\tdesc = shortDesc\n\t}\n\n\treturn &uctypes.ToolDefinition{\n\t\tName:        toolName,\n\t\tToolLogName: \"tsunami:getdata\",\n\t\tStrict:      true,\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\":                 \"object\",\n\t\t\t\"properties\":           map[string]any{},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\treturn fmt.Sprintf(\"getting data from %s (%s)\", desc, blockIdPrefix)\n\t\t},\n\t\tToolAnyCallback: makeTsunamiGetCallback(status, \"/api/data\"),\n\t}\n}\n\nfunc GetTsunamiGetConfigToolDefinition(block *waveobj.Block, rtInfo *waveobj.ObjRTInfo, status *blockcontroller.BlockControllerRuntimeStatus) *uctypes.ToolDefinition {\n\tblockIdPrefix := block.OID[:8]\n\ttoolName := fmt.Sprintf(\"tsunami_getconfig_%s\", blockIdPrefix)\n\n\tdesc := \"tsunami widget\"\n\tif shortDesc := getTsunamiShortDesc(rtInfo); shortDesc != \"\" {\n\t\tdesc = shortDesc\n\t}\n\n\treturn &uctypes.ToolDefinition{\n\t\tName:        toolName,\n\t\tToolLogName: \"tsunami:getconfig\",\n\t\tStrict:      true,\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\":                 \"object\",\n\t\t\t\"properties\":           map[string]any{},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\treturn fmt.Sprintf(\"getting config from %s (%s)\", desc, blockIdPrefix)\n\t\t},\n\t\tToolAnyCallback: makeTsunamiGetCallback(status, \"/api/config\"),\n\t}\n}\n\nfunc GetTsunamiSetConfigToolDefinition(block *waveobj.Block, rtInfo *waveobj.ObjRTInfo, status *blockcontroller.BlockControllerRuntimeStatus) *uctypes.ToolDefinition {\n\tblockIdPrefix := block.OID[:8]\n\ttoolName := fmt.Sprintf(\"tsunami_setconfig_%s\", blockIdPrefix)\n\n\tvar inputSchema map[string]any\n\tif rtInfo != nil && rtInfo.TsunamiSchemas != nil {\n\t\tif schemasMap, ok := rtInfo.TsunamiSchemas.(map[string]any); ok {\n\t\t\tif configSchema, exists := schemasMap[\"config\"]; exists {\n\t\t\t\tinputSchema = configSchema.(map[string]any)\n\t\t\t}\n\t\t}\n\t}\n\n\tif inputSchema == nil {\n\t\treturn nil\n\t}\n\n\tdesc := \"tsunami widget\"\n\tif shortDesc := getTsunamiShortDesc(rtInfo); shortDesc != \"\" {\n\t\tdesc = shortDesc\n\t}\n\n\treturn &uctypes.ToolDefinition{\n\t\tName:        toolName,\n\t\tToolLogName: \"tsunami:setconfig\",\n\t\tInputSchema: inputSchema,\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\treturn fmt.Sprintf(\"updating config for %s (%s)\", desc, blockIdPrefix)\n\t\t},\n\t\tToolAnyCallback: makeTsunamiPostCallback(status, \"/api/config\"),\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/tools_web.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcore\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\ntype WebNavigateToolInput struct {\n\tWidgetId string `json:\"widget_id\"`\n\tUrl      string `json:\"url\"`\n}\n\nfunc parseWebNavigateInput(input any) (*WebNavigateToolInput, error) {\n\tresult := &WebNavigateToolInput{}\n\n\tif input == nil {\n\t\treturn nil, fmt.Errorf(\"input is required\")\n\t}\n\n\tinputBytes, err := json.Marshal(input)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal input: %w\", err)\n\t}\n\n\tif err := json.Unmarshal(inputBytes, result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal input: %w\", err)\n\t}\n\n\tif result.WidgetId == \"\" {\n\t\treturn nil, fmt.Errorf(\"widget_id is required\")\n\t}\n\n\tif result.Url == \"\" {\n\t\treturn nil, fmt.Errorf(\"url is required\")\n\t}\n\n\treturn result, nil\n}\n\nfunc GetWebNavigateToolDefinition(tabId string) uctypes.ToolDefinition {\n\n\treturn uctypes.ToolDefinition{\n\t\tName:        \"web_navigate\",\n\t\tDisplayName: \"Navigate Web Widget\",\n\t\tDescription: \"Navigate a web browser widget to a new URL\",\n\t\tToolLogName: \"web:navigate\",\n\t\tStrict:      true,\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"widget_id\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"description\": \"8-character widget ID of the web browser widget\",\n\t\t\t\t},\n\t\t\t\t\"url\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"description\": \"URL to navigate to\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"required\":             []string{\"widget_id\", \"url\"},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\tparsed, err := parseWebNavigateInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Sprintf(\"error parsing input: %v\", err)\n\t\t\t}\n\t\t\treturn fmt.Sprintf(\"navigating web widget %s to %q\", parsed.WidgetId, parsed.Url)\n\t\t},\n\t\tToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\t\t\tparsed, err := parseWebNavigateInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\t\t\tdefer cancelFn()\n\n\t\t\tfullBlockId, err := wcore.ResolveBlockIdFromPrefix(ctx, tabId, parsed.WidgetId)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tblockORef := waveobj.MakeORef(waveobj.OType_Block, fullBlockId)\n\t\t\tmeta := map[string]any{\n\t\t\t\t\"url\": parsed.Url,\n\t\t\t}\n\n\t\t\terr = wstore.UpdateObjectMeta(ctx, blockORef, meta, false)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to update web block URL: %w\", err)\n\t\t\t}\n\n\t\t\twcore.SendWaveObjUpdate(blockORef)\n\t\t\treturn true, nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/tools_writefile.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/filebackup\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/fileutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\nconst MaxEditFileSize = 100 * 1024 // 100KB\n\nfunc validateTextFile(expandedPath string, verb string, mustExist bool) (os.FileInfo, error) {\n\tif blocked, reason := isBlockedFile(expandedPath); blocked {\n\t\treturn nil, fmt.Errorf(\"access denied: potentially sensitive file: %s\", reason)\n\t}\n\n\tfileInfo, err := os.Lstat(expandedPath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\tif mustExist {\n\t\t\t\treturn nil, fmt.Errorf(\"file does not exist: %s\", expandedPath)\n\t\t\t}\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to stat file: %w\", err)\n\t}\n\n\tif fileInfo.Mode()&os.ModeSymlink != 0 {\n\t\ttarget, _ := os.Readlink(expandedPath)\n\t\tif target == \"\" {\n\t\t\ttarget = \"(unknown)\"\n\t\t}\n\t\treturn nil, fmt.Errorf(\"cannot %s symlinks (target: %s). %s the target file directly if needed\", verb, utilfn.MarshalJSONString(target), verb)\n\t}\n\n\tif fileInfo.IsDir() {\n\t\treturn nil, fmt.Errorf(\"path is a directory, cannot %s it\", verb)\n\t}\n\n\tif !fileInfo.Mode().IsRegular() {\n\t\treturn nil, fmt.Errorf(\"path is not a regular file (devices, pipes, sockets not supported)\")\n\t}\n\n\tif fileInfo.Size() > MaxEditFileSize {\n\t\treturn nil, fmt.Errorf(\"file is too large (%d bytes, max %d bytes)\", fileInfo.Size(), MaxEditFileSize)\n\t}\n\n\tfileData, err := os.ReadFile(expandedPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read file: %w\", err)\n\t}\n\n\tif utilfn.HasBinaryData(fileData) {\n\t\treturn nil, fmt.Errorf(\"file appears to contain binary data\")\n\t}\n\n\tdirPath := filepath.Dir(expandedPath)\n\tdirInfo, err := os.Stat(dirPath)\n\tif err != nil && !os.IsNotExist(err) {\n\t\treturn nil, fmt.Errorf(\"failed to stat directory: %w\", err)\n\t}\n\tif err == nil && dirInfo.Mode().Perm()&0222 == 0 {\n\t\treturn nil, fmt.Errorf(\"directory is not writable (no write permission)\")\n\t}\n\n\treturn fileInfo, nil\n}\n\ntype writeTextFileParams struct {\n\tFilename string `json:\"filename\"`\n\tContents string `json:\"contents\"`\n}\n\nfunc parseWriteTextFileInput(input any) (*writeTextFileParams, error) {\n\tresult := &writeTextFileParams{}\n\n\tif input == nil {\n\t\treturn nil, fmt.Errorf(\"input is required\")\n\t}\n\n\tif err := utilfn.ReUnmarshal(result, input); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid input format: %w\", err)\n\t}\n\n\tif result.Filename == \"\" {\n\t\treturn nil, fmt.Errorf(\"missing filename parameter\")\n\t}\n\n\tif result.Contents == \"\" {\n\t\treturn nil, fmt.Errorf(\"missing contents parameter\")\n\t}\n\n\treturn result, nil\n}\n\nfunc verifyWriteTextFileInput(input any, toolUseData *uctypes.UIMessageDataToolUse) error {\n\tparams, err := parseWriteTextFileInput(input)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\texpandedPath, err := wavebase.ExpandHomeDir(params.Filename)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to expand path: %w\", err)\n\t}\n\n\tif !filepath.IsAbs(expandedPath) {\n\t\treturn fmt.Errorf(\"path must be absolute, got relative path: %s\", params.Filename)\n\t}\n\n\tcontentsBytes := []byte(params.Contents)\n\tif utilfn.HasBinaryData(contentsBytes) {\n\t\treturn fmt.Errorf(\"contents appear to contain binary data\")\n\t}\n\n\t_, err = validateTextFile(expandedPath, \"write to\", false)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttoolUseData.InputFileName = params.Filename\n\treturn nil\n}\n\nfunc writeTextFileCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\tparams, err := parseWriteTextFileInput(input)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\texpandedPath, err := wavebase.ExpandHomeDir(params.Filename)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to expand path: %w\", err)\n\t}\n\n\tif !filepath.IsAbs(expandedPath) {\n\t\treturn nil, fmt.Errorf(\"path must be absolute, got relative path: %s\", params.Filename)\n\t}\n\n\tcontentsBytes := []byte(params.Contents)\n\tif utilfn.HasBinaryData(contentsBytes) {\n\t\treturn nil, fmt.Errorf(\"contents appear to contain binary data\")\n\t}\n\n\tfileInfo, err := validateTextFile(expandedPath, \"write to\", false)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdirPath := filepath.Dir(expandedPath)\n\terr = os.MkdirAll(dirPath, 0755)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create directory: %w\", err)\n\t}\n\n\tif fileInfo != nil {\n\t\tbackupPath, err := filebackup.MakeFileBackup(expandedPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create backup: %w\", err)\n\t\t}\n\t\ttoolUseData.WriteBackupFileName = backupPath\n\t}\n\n\terr = os.WriteFile(expandedPath, contentsBytes, 0644)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to write file: %w\", err)\n\t}\n\n\treturn map[string]any{\n\t\t\"success\": true,\n\t\t\"message\": fmt.Sprintf(\"Successfully wrote %s (%d bytes)\", params.Filename, len(contentsBytes)),\n\t}, nil\n}\n\nfunc GetWriteTextFileToolDefinition() uctypes.ToolDefinition {\n\treturn uctypes.ToolDefinition{\n\t\tName:        \"write_text_file\",\n\t\tDisplayName: \"Write Text File\",\n\t\tDescription: \"Write a text file to the filesystem. Will create or overwrite the file. Maximum file size: 100KB.\",\n\t\tToolLogName: \"gen:writefile\",\n\t\tStrict:      true,\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"filename\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"description\": \"Absolute path to the file to write. Supports '~' for the user's home directory. Relative paths are not supported.\",\n\t\t\t\t},\n\t\t\t\t\"contents\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"description\": \"The contents to write to the file\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"required\":             []string{\"filename\", \"contents\"},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\tparams, err := parseWriteTextFileInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Sprintf(\"error parsing input: %v\", err)\n\t\t\t}\n\t\t\treturn fmt.Sprintf(\"writing %q\", params.Filename)\n\t\t},\n\t\tToolAnyCallback: writeTextFileCallback,\n\t\tToolApproval: func(input any) string {\n\t\t\treturn uctypes.ApprovalNeedsApproval\n\t\t},\n\t\tToolVerifyInput: verifyWriteTextFileInput,\n\t}\n}\n\ntype editTextFileParams struct {\n\tFilename string              `json:\"filename\"`\n\tEdits    []fileutil.EditSpec `json:\"edits\"`\n}\n\nfunc parseEditTextFileInput(input any) (*editTextFileParams, error) {\n\tresult := &editTextFileParams{}\n\n\tif input == nil {\n\t\treturn nil, fmt.Errorf(\"input is required\")\n\t}\n\n\tif err := utilfn.ReUnmarshal(result, input); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid input format: %w\", err)\n\t}\n\n\tif result.Filename == \"\" {\n\t\treturn nil, fmt.Errorf(\"missing filename parameter\")\n\t}\n\n\tif len(result.Edits) == 0 {\n\t\treturn nil, fmt.Errorf(\"missing edits parameter\")\n\t}\n\n\treturn result, nil\n}\n\nfunc verifyEditTextFileInput(input any, toolUseData *uctypes.UIMessageDataToolUse) error {\n\tparams, err := parseEditTextFileInput(input)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\texpandedPath, err := wavebase.ExpandHomeDir(params.Filename)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to expand path: %w\", err)\n\t}\n\n\tif !filepath.IsAbs(expandedPath) {\n\t\treturn fmt.Errorf(\"path must be absolute, got relative path: %s\", params.Filename)\n\t}\n\n\t_, err = validateTextFile(expandedPath, \"edit\", true)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttoolUseData.InputFileName = params.Filename\n\treturn nil\n}\n\n// EditTextFileDryRun applies edits to a file and returns the original and modified content\n// without writing to disk. Takes the same input format as editTextFileCallback.\nfunc EditTextFileDryRun(input any, fileOverride string) ([]byte, []byte, error) {\n\tparams, err := parseEditTextFileInput(input)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\texpandedPath, err := wavebase.ExpandHomeDir(params.Filename)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to expand path: %w\", err)\n\t}\n\n\tif !filepath.IsAbs(expandedPath) {\n\t\treturn nil, nil, fmt.Errorf(\"path must be absolute, got relative path: %s\", params.Filename)\n\t}\n\n\t_, err = validateTextFile(expandedPath, \"edit\", true)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\treadPath := expandedPath\n\tif fileOverride != \"\" {\n\t\treadPath = fileOverride\n\t}\n\n\toriginalContent, err := os.ReadFile(readPath)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to read file: %w\", err)\n\t}\n\n\tmodifiedContent, err := fileutil.ApplyEdits(originalContent, params.Edits)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\treturn originalContent, modifiedContent, nil\n}\n\nfunc editTextFileCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\tparams, err := parseEditTextFileInput(input)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\texpandedPath, err := wavebase.ExpandHomeDir(params.Filename)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to expand path: %w\", err)\n\t}\n\n\tif !filepath.IsAbs(expandedPath) {\n\t\treturn nil, fmt.Errorf(\"path must be absolute, got relative path: %s\", params.Filename)\n\t}\n\n\t_, err = validateTextFile(expandedPath, \"edit\", true)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbackupPath, err := filebackup.MakeFileBackup(expandedPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create backup: %w\", err)\n\t}\n\ttoolUseData.WriteBackupFileName = backupPath\n\n\terr = fileutil.ReplaceInFile(expandedPath, params.Edits)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn map[string]any{\n\t\t\"success\": true,\n\t\t\"message\": fmt.Sprintf(\"Successfully edited %s with %d changes\", params.Filename, len(params.Edits)),\n\t}, nil\n}\n\nfunc GetEditTextFileToolDefinition() uctypes.ToolDefinition {\n\treturn uctypes.ToolDefinition{\n\t\tName:        \"edit_text_file\",\n\t\tDisplayName: \"Edit Text File\",\n\t\tDescription: \"Edit a text file using precise search and replace. \" +\n\t\t\t\"Each old_str must appear EXACTLY ONCE in the file or the edit will fail. \" +\n\t\t\t\"All edits are applied atomically - if any single edit fails, the entire operation fails and no changes are made. \" +\n\t\t\t\"Maximum file size: 100KB.\",\n\t\tToolLogName: \"gen:editfile\",\n\t\tStrict:      true,\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"filename\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"description\": \"Absolute path to the file to edit. Supports '~' for the user's home directory. Relative paths are not supported.\",\n\t\t\t\t},\n\t\t\t\t\"edits\": map[string]any{\n\t\t\t\t\t\"type\":        \"array\",\n\t\t\t\t\t\"description\": \"Array of edit specifications. All edits are applied atomically - if any edit fails, none are applied.\",\n\t\t\t\t\t\"items\": map[string]any{\n\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\"properties\": map[string]any{\n\t\t\t\t\t\t\t\"old_str\": map[string]any{\n\t\t\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\t\t\"description\": \"The exact string to find and replace. MUST appear exactly once in the file - if it appears zero times or multiple times, the entire edit operation will fail.\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"new_str\": map[string]any{\n\t\t\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\t\t\"description\": \"The string to replace with\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"desc\": map[string]any{\n\t\t\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\t\t\"description\": \"Description of what this edit does (keep it VERY short, one sentence max)\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"required\":             []string{\"old_str\", \"new_str\", \"desc\"},\n\t\t\t\t\t\t\"additionalProperties\": false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"required\":             []string{\"filename\", \"edits\"},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\tparams, err := parseEditTextFileInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Sprintf(\"error parsing input: %v\", err)\n\t\t\t}\n\t\t\teditCount := len(params.Edits)\n\t\t\teditWord := \"edits\"\n\t\t\tif editCount == 1 {\n\t\t\t\teditWord = \"edit\"\n\t\t\t}\n\t\t\treturn fmt.Sprintf(\"editing %q (%d %s)\", params.Filename, editCount, editWord)\n\t\t},\n\t\tToolAnyCallback: editTextFileCallback,\n\t\tToolApproval: func(input any) string {\n\t\t\treturn uctypes.ApprovalNeedsApproval\n\t\t},\n\t\tToolVerifyInput: verifyEditTextFileInput,\n\t}\n}\n\ntype deleteTextFileParams struct {\n\tFilename string `json:\"filename\"`\n}\n\nfunc parseDeleteTextFileInput(input any) (*deleteTextFileParams, error) {\n\tresult := &deleteTextFileParams{}\n\n\tif input == nil {\n\t\treturn nil, fmt.Errorf(\"input is required\")\n\t}\n\n\tif err := utilfn.ReUnmarshal(result, input); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid input format: %w\", err)\n\t}\n\n\tif result.Filename == \"\" {\n\t\treturn nil, fmt.Errorf(\"missing filename parameter\")\n\t}\n\n\treturn result, nil\n}\n\nfunc verifyDeleteTextFileInput(input any, toolUseData *uctypes.UIMessageDataToolUse) error {\n\tparams, err := parseDeleteTextFileInput(input)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\texpandedPath, err := wavebase.ExpandHomeDir(params.Filename)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to expand path: %w\", err)\n\t}\n\n\tif !filepath.IsAbs(expandedPath) {\n\t\treturn fmt.Errorf(\"path must be absolute, got relative path: %s\", params.Filename)\n\t}\n\n\t_, err = validateTextFile(expandedPath, \"delete\", true)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttoolUseData.InputFileName = params.Filename\n\treturn nil\n}\n\nfunc deleteTextFileCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {\n\tparams, err := parseDeleteTextFileInput(input)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\texpandedPath, err := wavebase.ExpandHomeDir(params.Filename)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to expand path: %w\", err)\n\t}\n\n\tif !filepath.IsAbs(expandedPath) {\n\t\treturn nil, fmt.Errorf(\"path must be absolute, got relative path: %s\", params.Filename)\n\t}\n\n\t_, err = validateTextFile(expandedPath, \"delete\", true)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbackupPath, err := filebackup.MakeFileBackup(expandedPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create backup: %w\", err)\n\t}\n\ttoolUseData.WriteBackupFileName = backupPath\n\n\terr = os.Remove(expandedPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to delete file: %w\", err)\n\t}\n\n\treturn map[string]any{\n\t\t\"success\": true,\n\t\t\"message\": fmt.Sprintf(\"Successfully deleted %s\", params.Filename),\n\t}, nil\n}\n\nfunc GetDeleteTextFileToolDefinition() uctypes.ToolDefinition {\n\treturn uctypes.ToolDefinition{\n\t\tName:        \"delete_text_file\",\n\t\tDisplayName: \"Delete Text File\",\n\t\tDescription: \"Delete a text file from the filesystem. A backup is created before deletion. Maximum file size: 100KB.\",\n\t\tToolLogName: \"gen:deletefile\",\n\t\tStrict:      true,\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"filename\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"description\": \"Absolute path to the file to delete. Supports '~' for the user's home directory. Relative paths are not supported.\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"required\":             []string{\"filename\"},\n\t\t\t\"additionalProperties\": false,\n\t\t},\n\t\tToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {\n\t\t\tparams, err := parseDeleteTextFileInput(input)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Sprintf(\"error parsing input: %v\", err)\n\t\t\t}\n\t\t\treturn fmt.Sprintf(\"deleting %q\", params.Filename)\n\t\t},\n\t\tToolAnyCallback: deleteTextFileCallback,\n\t\tToolApproval: func(input any) string {\n\t\t\treturn uctypes.ApprovalNeedsApproval\n\t\t},\n\t\tToolVerifyInput: verifyDeleteTextFileInput,\n\t}\n}\n"
  },
  {
    "path": "pkg/aiusechat/uctypes/uctypes.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage uctypes\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"slices\"\n\t\"strings\"\n)\n\nconst DefaultAIEndpoint = \"https://cfapi.waveterm.dev/api/waveai\"\nconst WaveAIEndpointEnvName = \"WAVETERM_WAVEAI_ENDPOINT\"\nconst DefaultAnthropicModel = \"claude-sonnet-4-5\"\nconst DefaultOpenAIModel = \"gpt-5-mini\"\nconst PremiumOpenAIModel = \"gpt-5.1\"\n\nconst (\n\tAPIType_AnthropicMessages = \"anthropic-messages\"\n\tAPIType_OpenAIResponses   = \"openai-responses\"\n\tAPIType_OpenAIChat        = \"openai-chat\"\n\tAPIType_GoogleGemini      = \"google-gemini\"\n)\n\nconst (\n\tAIProvider_Wave        = \"wave\"\n\tAIProvider_Google      = \"google\"\n\tAIProvider_Groq        = \"groq\"\n\tAIProvider_OpenRouter  = \"openrouter\"\n\tAIProvider_NanoGPT     = \"nanogpt\"\n\tAIProvider_OpenAI      = \"openai\"\n\tAIProvider_Azure       = \"azure\"\n\tAIProvider_AzureLegacy = \"azure-legacy\"\n\tAIProvider_Custom      = \"custom\"\n)\n\ntype UseChatRequest struct {\n\tMessages []UIMessage `json:\"messages\"`\n}\n\ntype UIChat struct {\n\tChatId     string      `json:\"chatid\"`\n\tAPIType    string      `json:\"apitype\"`\n\tModel      string      `json:\"model\"`\n\tAPIVersion string      `json:\"apiversion\"`\n\tMessages   []UIMessage `json:\"messages\"`\n}\n\ntype UIMessage struct {\n\tID       string          `json:\"id\"`\n\tRole     string          `json:\"role\"` // \"system\", \"user\", \"assistant\"\n\tMetadata any             `json:\"metadata,omitempty\"`\n\tParts    []UIMessagePart `json:\"parts,omitempty\"`\n}\n\ntype UIMessagePart struct {\n\t// text, reasoning, tool-[toolname], source-url, source-document, file, data-[dataname], step-start\n\tType string `json:\"type\"`\n\n\t// TextUIPart & ReasoningUIPart\n\tText string `json:\"text,omitempty\"`\n\t// State field:\n\t// - For \"text\"/\"reasoning\" types: optional, values are \"streaming\" or \"done\"\n\t// - For \"tool-*\" types: required, values are \"input-streaming\", \"input-available\", \"output-available\", or \"output-error\"\n\tState string `json:\"state,omitempty\"`\n\n\t// ToolUIPart\n\tToolCallID       string `json:\"toolCallId,omitempty\"`\n\tInput            any    `json:\"input,omitempty\"`\n\tOutput           any    `json:\"output,omitempty\"`\n\tErrorText        string `json:\"errorText,omitempty\"`\n\tProviderExecuted *bool  `json:\"providerExecuted,omitempty\"`\n\n\t// SourceUrlUIPart & SourceDocumentUIPart\n\tSourceID  string `json:\"sourceId,omitempty\"`\n\tURL       string `json:\"url,omitempty\"`\n\tTitle     string `json:\"title,omitempty\"`\n\tFilename  string `json:\"filename,omitempty\"`\n\tMediaType string `json:\"mediaType,omitempty\"`\n\n\t// FileUIPart (uses URL and MediaType above)\n\n\t// DataUIPart\n\tID   string `json:\"id,omitempty\"`\n\tData any    `json:\"data,omitempty\"`\n\n\t// Provider metadata (ReasoningUIPart, SourceUrlUIPart, SourceDocumentUIPart)\n\tProviderMetadata map[string]any `json:\"providerMetadata,omitempty\"`\n}\n\n// when updating this struct, also modify frontend/app/aipanel/aitypes.ts WaveUIDataTypes.userfile\ntype UIMessageDataUserFile struct {\n\tFileName   string `json:\"filename,omitempty\"`\n\tSize       int    `json:\"size,omitempty\"`\n\tMimeType   string `json:\"mimetype,omitempty\"`\n\tPreviewUrl string `json:\"previewurl,omitempty\"`\n}\n\n// ToolDefinition represents a tool that can be used by the AI model\ntype ToolDefinition struct {\n\tName                 string         `json:\"name\"`\n\tDisplayName          string         `json:\"displayname,omitempty\"` // internal field (cannot marshal to API, must be stripped)\n\tDescription          string         `json:\"description\"`\n\tShortDescription     string         `json:\"shortdescription,omitempty\"` // internal field (cannot marshal to API, must be stripped)\n\tToolLogName          string         `json:\"-\"`                          // short name for telemetry (e.g., \"term:getscrollback\")\n\tInputSchema          map[string]any `json:\"input_schema\"`\n\tStrict               bool           `json:\"strict,omitempty\"`\n\tRequiredCapabilities []string       `json:\"requiredcapabilities,omitempty\"`\n\n\tToolTextCallback func(any) (string, error)                     `json:\"-\"`\n\tToolAnyCallback  func(any, *UIMessageDataToolUse) (any, error) `json:\"-\"` // *UIMessageDataToolUse will NOT be nil\n\tToolCallDesc     func(any, any, *UIMessageDataToolUse) string  `json:\"-\"` // passed input, output (may be nil), *UIMessageDataToolUse (may be nil)\n\tToolApproval     func(any) string                              `json:\"-\"`\n\tToolVerifyInput  func(any, *UIMessageDataToolUse) error        `json:\"-\"` // *UIMessageDataToolUse will NOT be nil\n\tToolProgressDesc func(any) ([]string, error)                   `json:\"-\"`\n}\n\nfunc (td *ToolDefinition) Clean() *ToolDefinition {\n\tif td == nil {\n\t\treturn nil\n\t}\n\trtn := *td\n\trtn.DisplayName = \"\"\n\trtn.ShortDescription = \"\"\n\treturn &rtn\n}\n\nfunc (td *ToolDefinition) Desc() string {\n\tif td == nil {\n\t\treturn \"\"\n\t}\n\tif td.ShortDescription != \"\" {\n\t\treturn td.ShortDescription\n\t}\n\treturn td.Description\n}\n\nfunc (td *ToolDefinition) HasRequiredCapabilities(capabilities []string) bool {\n\tif td == nil || len(td.RequiredCapabilities) == 0 {\n\t\treturn true\n\t}\n\tfor _, reqCap := range td.RequiredCapabilities {\n\t\tif !slices.Contains(capabilities, reqCap) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n//------------------\n// Wave specific types, stop reasons, tool calls, config\n// these are used internally to coordinate the calls/steps\n\nconst (\n\tThinkingLevelLow    = \"low\"\n\tThinkingLevelMedium = \"medium\"\n\tThinkingLevelHigh   = \"high\"\n\n\tVerbosityLevelLow    = \"low\"\n\tVerbosityLevelMedium = \"medium\"\n\tVerbosityLevelHigh   = \"high\"\n)\n\nconst (\n\tAIModeQuick    = \"waveai@quick\"\n\tAIModeBalanced = \"waveai@balanced\"\n\tAIModeDeep     = \"waveai@deep\"\n)\n\nconst (\n\tToolUseStatusPending   = \"pending\"\n\tToolUseStatusError     = \"error\"\n\tToolUseStatusCompleted = \"completed\"\n)\n\nconst (\n\tAICapabilityTools  = \"tools\"\n\tAICapabilityImages = \"images\"\n\tAICapabilityPdfs   = \"pdfs\"\n)\n\nconst (\n\tApprovalNeedsApproval = \"needs-approval\"\n\tApprovalUserApproved  = \"user-approved\"\n\tApprovalUserDenied    = \"user-denied\"\n\tApprovalTimeout       = \"timeout\"\n\tApprovalAutoApproved  = \"auto-approved\"\n\tApprovalCanceled      = \"canceled\"\n)\n\n// when updating this struct, also modify frontend/app/aipanel/aitypes.ts WaveUIDataTypes.tooluse\ntype UIMessageDataToolUse struct {\n\tToolCallId          string `json:\"toolcallid\"`\n\tToolName            string `json:\"toolname\"`\n\tToolDesc            string `json:\"tooldesc\"`\n\tStatus              string `json:\"status\"`\n\tRunTs               int64  `json:\"runts,omitempty\"`\n\tErrorMessage        string `json:\"errormessage,omitempty\"`\n\tApproval            string `json:\"approval,omitempty\"`\n\tBlockId             string `json:\"blockid,omitempty\"`\n\tWriteBackupFileName string `json:\"writebackupfilename,omitempty\"`\n\tInputFileName       string `json:\"inputfilename,omitempty\"`\n}\n\nfunc (d *UIMessageDataToolUse) IsApproved() bool {\n\treturn d.Approval == \"\" || d.Approval == ApprovalUserApproved || d.Approval == ApprovalAutoApproved\n}\n\n// when updating this struct, also modify frontend/app/aipanel/aitypes.ts WaveUIDataTypes.toolprogress\ntype UIMessageDataToolProgress struct {\n\tToolCallId  string   `json:\"toolcallid\"`\n\tToolName    string   `json:\"toolname\"`\n\tStatusLines []string `json:\"statuslines\"`\n}\n\ntype StopReasonKind string\n\nconst (\n\tStopKindDone             StopReasonKind = \"done\"\n\tStopKindToolUse          StopReasonKind = \"tool_use\"\n\tStopKindMaxTokens        StopReasonKind = \"max_tokens\"\n\tStopKindContent          StopReasonKind = \"content_filter\"\n\tStopKindCanceled         StopReasonKind = \"canceled\"\n\tStopKindError            StopReasonKind = \"error\"\n\tStopKindPauseTurn        StopReasonKind = \"pause_turn\"\n\tStopKindPremiumRateLimit StopReasonKind = \"premium_rate_limit\"\n\tStopKindRateLimit        StopReasonKind = \"rate_limit\"\n)\n\ntype WaveToolCall struct {\n\tID          string                `json:\"id\"`                    // Anthropic tool_use.id\n\tName        string                `json:\"name,omitempty\"`        // tool name (if provided)\n\tInput       any                   `json:\"input,omitempty\"`       // accumulated input JSON\n\tToolUseData *UIMessageDataToolUse `json:\"toolusedata,omitempty\"` // UI tool use data\n}\n\ntype WaveStopReason struct {\n\tKind      StopReasonKind `json:\"kind\"`\n\tRawReason string         `json:\"raw_reason,omitempty\"`\n\tToolCalls []WaveToolCall `json:\"tool_calls,omitempty\"`\n\tErrorType string         `json:\"error_type,omitempty\"`\n\tErrorText string         `json:\"error_text,omitempty\"`\n}\n\n// Wave Specific parameter used to signal to our step function that this is a continuation step, not an initial step\ntype WaveContinueResponse struct {\n\tModel            string         `json:\"model,omitempty\"`\n\tContinueFromKind StopReasonKind `json:\"continue_from_kind\"`\n}\n\n// Wave Specific AI opts for configuration\ntype AIOptsType struct {\n\tProvider      string   `json:\"provider,omitempty\"`\n\tAPIType       string   `json:\"apitype,omitempty\"`\n\tModel         string   `json:\"model\"`\n\tAPIToken      string   `json:\"apitoken\"`\n\tAPIVersion    string   `json:\"apiversion,omitempty\"`\n\tEndpoint      string   `json:\"endpoint,omitempty\"`\n\tProxyURL      string   `json:\"proxyurl,omitempty\"`\n\tMaxTokens     int      `json:\"maxtokens,omitempty\"`\n\tTimeoutMs     int      `json:\"timeoutms,omitempty\"`\n\tThinkingLevel string   `json:\"thinkinglevel,omitempty\"` // ThinkingLevelLow, ThinkingLevelMedium, or ThinkingLevelHigh\n\tVerbosity     string   `json:\"verbosity,omitempty\"`     // Text verbosity level (OpenAI Responses API only, ignored by other backends)\n\tAIMode        string   `json:\"aimode,omitempty\"`\n\tCapabilities  []string `json:\"capabilities,omitempty\"`\n\tWaveAIPremium bool     `json:\"waveaipremium,omitempty\"`\n}\n\nfunc (opts AIOptsType) IsWaveProxy() bool {\n\treturn opts.Provider == AIProvider_Wave\n}\n\nfunc (opts AIOptsType) IsPremiumModel() bool {\n\treturn opts.WaveAIPremium\n}\n\nfunc (opts AIOptsType) HasCapability(cap string) bool {\n\treturn slices.Contains(opts.Capabilities, cap)\n}\n\ntype AIChat struct {\n\tChatId         string         `json:\"chatid\"`\n\tAPIType        string         `json:\"apitype\"`\n\tModel          string         `json:\"model\"`\n\tAPIVersion     string         `json:\"apiversion\"`\n\tNativeMessages []GenAIMessage `json:\"nativemessages\"`\n}\n\ntype AIUsage struct {\n\tAPIType              string `json:\"apitype\"`\n\tModel                string `json:\"model\"`\n\tInputTokens          int    `json:\"inputtokens,omitempty\"`\n\tOutputTokens         int    `json:\"outputtokens,omitempty\"`\n\tNativeWebSearchCount int    `json:\"nativewebsearchcount,omitempty\"`\n}\n\ntype AIMetrics struct {\n\tChatId            string         `json:\"chatid\"`\n\tStepNum           int            `json:\"stepnum\"`\n\tUsage             AIUsage        `json:\"usage\"`\n\tRequestCount      int            `json:\"requestcount\"`\n\tToolUseCount      int            `json:\"toolusecount\"`\n\tToolUseErrorCount int            `json:\"tooluseerrorcount\"`\n\tToolDetail        map[string]int `json:\"tooldetail,omitempty\"`\n\tPremiumReqCount   int            `json:\"premiumreqcount\"`\n\tProxyReqCount     int            `json:\"proxyreqcount\"`\n\tHadError          bool           `json:\"haderror\"`\n\tImageCount        int            `json:\"imagecount\"`\n\tPDFCount          int            `json:\"pdfcount\"`\n\tTextDocCount      int            `json:\"textdoccount\"`\n\tTextLen           int            `json:\"textlen\"`\n\tFirstByteLatency  int            `json:\"firstbytelatency\"` // ms\n\tRequestDuration   int            `json:\"requestduration\"`  // ms\n\tWidgetAccess      bool           `json:\"widgetaccess\"`\n\tThinkingLevel     string         `json:\"thinkinglevel,omitempty\"`\n\tAIMode            string         `json:\"aimode,omitempty\"`\n\tAIProvider        string         `json:\"aiprovider,omitempty\"`\n\tIsLocal           bool           `json:\"islocal,omitempty\"`\n}\n\ntype AIFunctionCallInput struct {\n\tCallId      string                `json:\"call_id\"`\n\tName        string                `json:\"name\"`\n\tArguments   string                `json:\"arguments\"`\n\tToolUseData *UIMessageDataToolUse `json:\"toolusedata,omitempty\"`\n}\n\n// GenAIMessage interface for messages stored in conversations\n// All messages must have a unique identifier for idempotency checks\ntype GenAIMessage interface {\n\tGetMessageId() string\n\tGetUsage() *AIUsage\n\tGetRole() string\n}\n\nconst (\n\tAIMessagePartTypeText = \"text\"\n\tAIMessagePartTypeFile = \"file\"\n)\n\n// wave specific for POSTing a new message to a convo\ntype AIMessage struct {\n\tMessageId string          `json:\"messageid\"` // only for idempotency\n\tParts     []AIMessagePart `json:\"parts\"`\n}\n\ntype AIMessagePart struct {\n\tType string `json:\"type\"` // \"text\", \"file\"\n\n\t// for \"text\"\n\tText string `json:\"text,omitempty\"`\n\n\t// for \"file\"\n\t// mimetype is required, filename is not\n\t// either data or url (not both) must be set\n\t// url must be either an \"https\" or \"data\" url\n\tFileName   string `json:\"filename,omitempty\"`\n\tMimeType   string `json:\"mimetype,omitempty\"` // required\n\tData       []byte `json:\"data,omitempty\"`     // raw data (base64 on wire)\n\tURL        string `json:\"url,omitempty\"`\n\tSize       int    `json:\"size,omitempty\"`\n\tPreviewUrl string `json:\"previewurl,omitempty\"` // 128x128 webp data url for images\n}\n\ntype AIToolResult struct {\n\tToolName  string `json:\"toolname\"`\n\tToolUseID string `json:\"tooluseid\"`\n\tErrorText string `json:\"errortext,omitempty\"`\n\tText      string `json:\"text,omitempty\"`\n}\n\nfunc (m *AIMessage) GetMessageId() string {\n\treturn m.MessageId\n}\n\nfunc (m *AIMessage) Validate() error {\n\tif m.MessageId == \"\" {\n\t\treturn fmt.Errorf(\"messageid must be set\")\n\t}\n\n\tif len(m.Parts) == 0 {\n\t\treturn fmt.Errorf(\"parts must not be empty\")\n\t}\n\n\tfor i, part := range m.Parts {\n\t\tif err := part.Validate(); err != nil {\n\t\t\treturn fmt.Errorf(\"part %d: %w\", i, err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (p *AIMessagePart) Validate() error {\n\tif p.Type == AIMessagePartTypeText {\n\t\tif p.Text == \"\" {\n\t\t\treturn fmt.Errorf(\"text type requires non-empty text field\")\n\t\t}\n\t\t// Check that no file fields are set\n\t\tif p.FileName != \"\" || p.MimeType != \"\" || len(p.Data) > 0 || p.URL != \"\" {\n\t\t\treturn fmt.Errorf(\"text type cannot have file fields set\")\n\t\t}\n\t\treturn nil\n\t}\n\n\tif p.Type == AIMessagePartTypeFile {\n\t\tif p.Text != \"\" {\n\t\t\treturn fmt.Errorf(\"file type cannot have text field set\")\n\t\t}\n\n\t\tif p.MimeType == \"\" {\n\t\t\treturn fmt.Errorf(\"file type requires mimetype\")\n\t\t}\n\n\t\t// Either data or url (not both) must be set\n\t\thasData := len(p.Data) > 0\n\t\thasURL := p.URL != \"\"\n\n\t\tif !hasData && !hasURL {\n\t\t\treturn fmt.Errorf(\"file type requires either data or url\")\n\t\t}\n\n\t\tif hasData && hasURL {\n\t\t\treturn fmt.Errorf(\"file type cannot have both data and url set\")\n\t\t}\n\n\t\t// If URL is set, validate it's https or data URL\n\t\tif hasURL {\n\t\t\tparsedURL, err := url.Parse(p.URL)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"invalid url: %w\", err)\n\t\t\t}\n\n\t\t\tif parsedURL.Scheme != \"https\" && parsedURL.Scheme != \"data\" {\n\t\t\t\treturn fmt.Errorf(\"url must be https or data URL, got %q\", parsedURL.Scheme)\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\treturn fmt.Errorf(\"type must be %q or %q, got %q\", AIMessagePartTypeText, AIMessagePartTypeFile, p.Type)\n}\n\n// ---------------------\n// AI SDK Streaming Protocol\n\n// Type can be one of these consts...\n// text-start, text-delta, text-end,\n// reasoning-start, reasoning-delta, reasoning-end,\n// source-url, source-document,\n// file,\n// data-*,\n// tool-input-start, tool-input-delta, tool-input-available, tool-output-available,\n// error, start-step, finish-step, finish\ntype UseChatStreamPart struct {\n\tType string `json:\"type\"`\n\n\t// Text\n\tText string `json:\"text,omitempty\"`\n\n\t// Reasoning\n\tDelta string `json:\"delta,omitempty\"`\n\n\t// Source parts\n\tSourceID  string `json:\"sourceId,omitempty\"`\n\tURL       string `json:\"url,omitempty\"`       // also for file urls\n\tMediaType string `json:\"mediaType,omitempty\"` // also for file types\n\tTitle     string `json:\"title,omitempty\"`\n\n\t// Data (custom data-\\*)\n\tData any `json:\"data,omitempty\"`\n\n\t// Tool use / tool result\n\tToolCallID     string `json:\"toolCallId,omitempty\"`\n\tToolName       string `json:\"toolName,omitempty\"`\n\tInput          any    `json:\"input,omitempty\"`\n\tOutput         any    `json:\"output,omitempty\"`\n\tInputTextDelta string `json:\"inputTextDelta,omitempty\"`\n\n\t// Control parts (start/finish steps, errors, etc.)\n\tErrorText string `json:\"errorText,omitempty\"`\n}\n\n// GetContent extracts the text content from the parts array\nfunc (m *UIMessage) GetContent() string {\n\tif len(m.Parts) > 0 {\n\t\tvar content strings.Builder\n\t\tfor _, part := range m.Parts {\n\t\t\tif part.Type == \"text\" {\n\t\t\t\tcontent.WriteString(part.Text)\n\t\t\t}\n\t\t}\n\t\treturn content.String()\n\t}\n\treturn \"\"\n}\n\ntype WaveChatOpts struct {\n\tChatId               string\n\tClientId             string\n\tConfig               AIOptsType\n\tTools                []ToolDefinition\n\tSystemPrompt         []string\n\tTabStateGenerator    func() (string, []ToolDefinition, string, error)\n\tBuilderAppGenerator  func() (string, string, string, error)\n\tWidgetAccess         bool\n\tAllowNativeWebSearch bool\n\tBuilderId            string\n\tBuilderAppId         string\n\n\t// ephemeral to the step\n\tTabState       string\n\tTabTools       []ToolDefinition\n\tTabId          string\n\tAppGoFile      string\n\tAppStaticFiles string\n\tPlatformInfo   string\n}\n\nfunc (opts *WaveChatOpts) GetToolDefinition(toolName string) *ToolDefinition {\n\tfor _, tool := range opts.Tools {\n\t\tif tool.Name == toolName {\n\t\t\treturn &tool\n\t\t}\n\t}\n\tfor _, tool := range opts.TabTools {\n\t\tif tool.Name == toolName {\n\t\t\treturn &tool\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (opts *WaveChatOpts) GetWaveRequestType() string {\n\tif opts.BuilderId != \"\" {\n\t\treturn \"waveapps-builder\"\n\t} else {\n\t\treturn \"waveai\"\n\t}\n}\n\ntype ProxyErrorResponse struct {\n\tSuccess bool   `json:\"success\"`\n\tError   string `json:\"error\"`\n}\n\ntype RateLimitInfo struct {\n\tReq        int   `json:\"req\"`\n\tReqLimit   int   `json:\"reqlimit\"`\n\tPReq       int   `json:\"preq\"`\n\tPReqLimit  int   `json:\"preqlimit\"`\n\tResetEpoch int64 `json:\"resetepoch\"`\n\tUnknown    bool  `json:\"unknown,omitempty\"`\n}\n\n// ParseRateLimitHeader parses the X-Wave-RateLimit header\n// Format: X-Wave-RateLimit: req=<remaining>, reqlimit=<max_requests>, preq=<premium_remaining>, preqlimit=<max_premium>, reset=<expiration_epoch_seconds>\n// Example: X-Wave-RateLimit: req=180, reqlimit=200, preq=45, preqlimit=50, reset=1727818382\n// - req: remaining regular requests in the current window\n// - reqlimit: maximum regular requests allowed in the window\n// - preq: remaining premium requests in the current window\n// - preqlimit: maximum premium requests allowed in the window\n// - reset: unix timestamp (epoch seconds) when the rate limit window resets\nfunc ParseRateLimitHeader(header string) *RateLimitInfo {\n\tif header == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &RateLimitInfo{}\n\tparts := strings.Split(header, \",\")\n\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tkv := strings.SplitN(part, \"=\", 2)\n\t\tif len(kv) != 2 {\n\t\t\tcontinue\n\t\t}\n\n\t\tkey := strings.TrimSpace(kv[0])\n\t\tvalue := strings.TrimSpace(kv[1])\n\n\t\tswitch key {\n\t\tcase \"req\":\n\t\t\tif val, err := fmt.Sscanf(value, \"%d\", &info.Req); err == nil && val == 1 {\n\t\t\t\t// Successfully parsed\n\t\t\t}\n\t\tcase \"reqlimit\":\n\t\t\tif val, err := fmt.Sscanf(value, \"%d\", &info.ReqLimit); err == nil && val == 1 {\n\t\t\t\t// Successfully parsed\n\t\t\t}\n\t\tcase \"preq\":\n\t\t\tif val, err := fmt.Sscanf(value, \"%d\", &info.PReq); err == nil && val == 1 {\n\t\t\t\t// Successfully parsed\n\t\t\t}\n\t\tcase \"preqlimit\":\n\t\t\tif val, err := fmt.Sscanf(value, \"%d\", &info.PReqLimit); err == nil && val == 1 {\n\t\t\t\t// Successfully parsed\n\t\t\t}\n\t\tcase \"reset\":\n\t\t\tif val, err := fmt.Sscanf(value, \"%d\", &info.ResetEpoch); err == nil && val == 1 {\n\t\t\t\t// Successfully parsed\n\t\t\t}\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc AreModelsCompatible(apiType, model1, model2 string) bool {\n\tif model1 == model2 {\n\t\treturn true\n\t}\n\n\tif apiType == APIType_OpenAIResponses {\n\t\tgpt5Models := map[string]bool{\n\t\t\t\"gpt-5.2\":    true,\n\t\t\t\"gpt-5.1\":    true,\n\t\t\t\"gpt-5\":      true,\n\t\t\t\"gpt-5-mini\": true,\n\t\t\t\"gpt-5-nano\": true,\n\t\t}\n\n\t\tif gpt5Models[model1] && gpt5Models[model2] {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "pkg/aiusechat/usechat-backend.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/anthropic\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/gemini\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/openai\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/openaichat\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/web/sse\"\n)\n\n// UseChatBackend defines the interface for AI chat backend providers (OpenAI, Anthropic, etc.)\n// This interface abstracts the provider-specific API calls needed by the usechat system.\ntype UseChatBackend interface {\n\t// RunChatStep executes a single step in the chat conversation with the AI backend.\n\t// Returns the stop reason, native messages from the response, rate limit info, and any error.\n\t// The cont parameter allows continuing from a previous response (e.g., after rate limiting).\n\tRunChatStep(\n\t\tctx context.Context,\n\t\tsseHandler *sse.SSEHandlerCh,\n\t\tchatOpts uctypes.WaveChatOpts,\n\t\tcont *uctypes.WaveContinueResponse,\n\t) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, *uctypes.RateLimitInfo, error)\n\n\t// UpdateToolUseData updates the tool use data for a specific tool call in the chat.\n\t// This is used to update the UI state for tool execution (approval status, results, etc.)\n\tUpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error\n\n\t// RemoveToolUseCall removes a tool use call from the chat's native messages.\n\t// This is used to clean up incomplete or canceled tool calls when stopping execution.\n\tRemoveToolUseCall(chatId string, toolCallId string) error\n\n\t// ConvertToolResultsToNativeChatMessage converts tool execution results into native chat messages\n\t// that can be sent back to the AI backend. Returns a slice of messages (some backends may\n\t// require multiple messages per tool result).\n\tConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error)\n\n\t// ConvertAIMessageToNativeChatMessage converts a generic AIMessage (from the user)\n\t// into the backend's native message format for sending to the API.\n\tConvertAIMessageToNativeChatMessage(message uctypes.AIMessage) (uctypes.GenAIMessage, error)\n\n\t// GetFunctionCallInputByToolCallId retrieves the function call input data for a specific\n\t// tool call ID from the chat history. Returns the function call structure\n\t// or nil if not found.\n\tGetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput\n\n\t// ConvertAIChatToUIChat converts a stored AIChat (with native backend messages) into\n\t// a UI-friendly UIChat format that can be displayed in the frontend.\n\tConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error)\n}\n\n// Compile-time interface checks\nvar _ UseChatBackend = (*openaiResponsesBackend)(nil)\nvar _ UseChatBackend = (*openaiCompletionsBackend)(nil)\nvar _ UseChatBackend = (*anthropicBackend)(nil)\nvar _ UseChatBackend = (*geminiBackend)(nil)\n\n// GetBackendByAPIType returns the appropriate UseChatBackend implementation for the given API type\nfunc GetBackendByAPIType(apiType string) (UseChatBackend, error) {\n\tswitch apiType {\n\tcase uctypes.APIType_OpenAIResponses:\n\t\treturn &openaiResponsesBackend{}, nil\n\tcase uctypes.APIType_OpenAIChat:\n\t\treturn &openaiCompletionsBackend{}, nil\n\tcase uctypes.APIType_AnthropicMessages:\n\t\treturn &anthropicBackend{}, nil\n\tcase uctypes.APIType_GoogleGemini:\n\t\treturn &geminiBackend{}, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported API type: %s\", apiType)\n\t}\n}\n\n// openaiResponsesBackend implements UseChatBackend for OpenAI API\ntype openaiResponsesBackend struct{}\n\nfunc (b *openaiResponsesBackend) RunChatStep(\n\tctx context.Context,\n\tsseHandler *sse.SSEHandlerCh,\n\tchatOpts uctypes.WaveChatOpts,\n\tcont *uctypes.WaveContinueResponse,\n) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, *uctypes.RateLimitInfo, error) {\n\tstopReason, msgs, rateLimitInfo, err := openai.RunOpenAIChatStep(ctx, sseHandler, chatOpts, cont)\n\tvar genMsgs []uctypes.GenAIMessage\n\tfor _, msg := range msgs {\n\t\tgenMsgs = append(genMsgs, msg)\n\t}\n\treturn stopReason, genMsgs, rateLimitInfo, err\n}\n\nfunc (b *openaiResponsesBackend) UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error {\n\treturn openai.UpdateToolUseData(chatId, toolCallId, toolUseData)\n}\n\nfunc (b *openaiResponsesBackend) RemoveToolUseCall(chatId string, toolCallId string) error {\n\treturn openai.RemoveToolUseCall(chatId, toolCallId)\n}\n\nfunc (b *openaiResponsesBackend) ConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error) {\n\tmsgs, err := openai.ConvertToolResultsToOpenAIChatMessage(toolResults)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar genMsgs []uctypes.GenAIMessage\n\tfor _, msg := range msgs {\n\t\tgenMsgs = append(genMsgs, msg)\n\t}\n\treturn genMsgs, nil\n}\n\nfunc (b *openaiResponsesBackend) ConvertAIMessageToNativeChatMessage(message uctypes.AIMessage) (uctypes.GenAIMessage, error) {\n\treturn openai.ConvertAIMessageToOpenAIChatMessage(message)\n}\n\nfunc (b *openaiResponsesBackend) GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput {\n\topenaiInput := openai.GetFunctionCallInputByToolCallId(aiChat, toolCallId)\n\tif openaiInput == nil {\n\t\treturn nil\n\t}\n\treturn &uctypes.AIFunctionCallInput{\n\t\tCallId:      openaiInput.CallId,\n\t\tName:        openaiInput.Name,\n\t\tArguments:   openaiInput.Arguments,\n\t\tToolUseData: openaiInput.ToolUseData,\n\t}\n}\n\nfunc (b *openaiResponsesBackend) ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) {\n\treturn openai.ConvertAIChatToUIChat(aiChat)\n}\n\n// openaiCompletionsBackend implements UseChatBackend for OpenAI Completions API\ntype openaiCompletionsBackend struct{}\n\nfunc (b *openaiCompletionsBackend) RunChatStep(\n\tctx context.Context,\n\tsseHandler *sse.SSEHandlerCh,\n\tchatOpts uctypes.WaveChatOpts,\n\tcont *uctypes.WaveContinueResponse,\n) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, *uctypes.RateLimitInfo, error) {\n\tstopReason, msgs, rateLimitInfo, err := openaichat.RunChatStep(ctx, sseHandler, chatOpts, cont)\n\tvar genMsgs []uctypes.GenAIMessage\n\tfor _, msg := range msgs {\n\t\tgenMsgs = append(genMsgs, msg)\n\t}\n\treturn stopReason, genMsgs, rateLimitInfo, err\n}\n\nfunc (b *openaiCompletionsBackend) UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error {\n\treturn openaichat.UpdateToolUseData(chatId, toolCallId, toolUseData)\n}\n\nfunc (b *openaiCompletionsBackend) RemoveToolUseCall(chatId string, toolCallId string) error {\n\treturn openaichat.RemoveToolUseCall(chatId, toolCallId)\n}\n\nfunc (b *openaiCompletionsBackend) ConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error) {\n\treturn openaichat.ConvertToolResultsToNativeChatMessage(toolResults)\n}\n\nfunc (b *openaiCompletionsBackend) ConvertAIMessageToNativeChatMessage(message uctypes.AIMessage) (uctypes.GenAIMessage, error) {\n\treturn openaichat.ConvertAIMessageToStoredChatMessage(message)\n}\n\nfunc (b *openaiCompletionsBackend) GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput {\n\treturn openaichat.GetFunctionCallInputByToolCallId(aiChat, toolCallId)\n}\n\nfunc (b *openaiCompletionsBackend) ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) {\n\treturn openaichat.ConvertAIChatToUIChat(aiChat)\n}\n\n// anthropicBackend implements UseChatBackend for Anthropic API\ntype anthropicBackend struct{}\n\nfunc (b *anthropicBackend) RunChatStep(\n\tctx context.Context,\n\tsseHandler *sse.SSEHandlerCh,\n\tchatOpts uctypes.WaveChatOpts,\n\tcont *uctypes.WaveContinueResponse,\n) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, *uctypes.RateLimitInfo, error) {\n\tstopReason, msg, rateLimitInfo, err := anthropic.RunAnthropicChatStep(ctx, sseHandler, chatOpts, cont)\n\tif msg == nil {\n\t\treturn stopReason, nil, rateLimitInfo, err\n\t}\n\treturn stopReason, []uctypes.GenAIMessage{msg}, rateLimitInfo, err\n}\n\nfunc (b *anthropicBackend) UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error {\n\treturn anthropic.UpdateToolUseData(chatId, toolCallId, toolUseData)\n}\n\nfunc (b *anthropicBackend) RemoveToolUseCall(chatId string, toolCallId string) error {\n\treturn anthropic.RemoveToolUseCall(chatId, toolCallId)\n}\n\nfunc (b *anthropicBackend) ConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error) {\n\tmsg, err := anthropic.ConvertToolResultsToAnthropicChatMessage(toolResults)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn []uctypes.GenAIMessage{msg}, nil\n}\n\nfunc (b *anthropicBackend) ConvertAIMessageToNativeChatMessage(message uctypes.AIMessage) (uctypes.GenAIMessage, error) {\n\treturn anthropic.ConvertAIMessageToAnthropicChatMessage(message)\n}\n\nfunc (b *anthropicBackend) GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput {\n\treturn anthropic.GetFunctionCallInputByToolCallId(aiChat, toolCallId)\n}\n\nfunc (b *anthropicBackend) ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) {\n\treturn anthropic.ConvertAIChatToUIChat(aiChat)\n}\n\n// geminiBackend implements UseChatBackend for Google Gemini API\ntype geminiBackend struct{}\n\nfunc (b *geminiBackend) RunChatStep(\n\tctx context.Context,\n\tsseHandler *sse.SSEHandlerCh,\n\tchatOpts uctypes.WaveChatOpts,\n\tcont *uctypes.WaveContinueResponse,\n) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, *uctypes.RateLimitInfo, error) {\n\tstopReason, msg, rateLimitInfo, err := gemini.RunGeminiChatStep(ctx, sseHandler, chatOpts, cont)\n\tif msg == nil {\n\t\treturn stopReason, nil, rateLimitInfo, err\n\t}\n\treturn stopReason, []uctypes.GenAIMessage{msg}, rateLimitInfo, err\n}\n\nfunc (b *geminiBackend) UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error {\n\treturn gemini.UpdateToolUseData(chatId, toolCallId, toolUseData)\n}\n\nfunc (b *geminiBackend) RemoveToolUseCall(chatId string, toolCallId string) error {\n\treturn gemini.RemoveToolUseCall(chatId, toolCallId)\n}\n\nfunc (b *geminiBackend) ConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error) {\n\tmsg, err := gemini.ConvertToolResultsToGeminiChatMessage(toolResults)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn []uctypes.GenAIMessage{msg}, nil\n}\n\nfunc (b *geminiBackend) ConvertAIMessageToNativeChatMessage(message uctypes.AIMessage) (uctypes.GenAIMessage, error) {\n\treturn gemini.ConvertAIMessageToGeminiChatMessage(message)\n}\n\nfunc (b *geminiBackend) GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput {\n\treturn gemini.GetFunctionCallInputByToolCallId(aiChat, toolCallId)\n}\n\nfunc (b *geminiBackend) ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) {\n\treturn gemini.ConvertAIChatToUIChat(aiChat)\n}\n"
  },
  {
    "path": "pkg/aiusechat/usechat-mode.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"regexp\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n)\n\nvar AzureResourceNameRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)\n\nconst (\n\tOpenAIResponsesEndpoint        = \"https://api.openai.com/v1/responses\"\n\tOpenAIChatEndpoint             = \"https://api.openai.com/v1/chat/completions\"\n\tOpenRouterChatEndpoint         = \"https://openrouter.ai/api/v1/chat/completions\"\n\tNanoGPTChatEndpoint            = \"https://nano-gpt.com/api/v1/chat/completions\"\n\tGroqChatEndpoint               = \"https://api.groq.com/openai/v1/chat/completions\"\n\tAzureLegacyEndpointTemplate    = \"https://%s.openai.azure.com/openai/deployments/%s/chat/completions?api-version=%s\"\n\tAzureResponsesEndpointTemplate = \"https://%s.openai.azure.com/openai/v1/responses\"\n\tAzureChatEndpointTemplate      = \"https://%s.openai.azure.com/openai/v1/chat/completions\"\n\tGoogleGeminiEndpointTemplate   = \"https://generativelanguage.googleapis.com/v1beta/models/%s:streamGenerateContent\"\n\n\tAzureLegacyDefaultAPIVersion = \"2025-04-01-preview\"\n\n\tOpenAIAPITokenSecretName      = \"OPENAI_KEY\"\n\tOpenRouterAPITokenSecretName  = \"OPENROUTER_KEY\"\n\tNanoGPTAPITokenSecretName     = \"NANOGPT_KEY\"\n\tGroqAPITokenSecretName        = \"GROQ_KEY\"\n\tAzureOpenAIAPITokenSecretName = \"AZURE_OPENAI_KEY\"\n\tGoogleAIAPITokenSecretName    = \"GOOGLE_AI_KEY\"\n)\n\nfunc resolveAIMode(requestedMode string, premium bool) (string, *wconfig.AIModeConfigType, error) {\n\tmode := requestedMode\n\n\tconfig, err := getAIModeConfig(mode)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\n\tif config.WaveAICloud && !premium {\n\t\tmode = uctypes.AIModeQuick\n\t\tconfig, err = getAIModeConfig(mode)\n\t\tif err != nil {\n\t\t\treturn \"\", nil, err\n\t\t}\n\t}\n\n\treturn mode, config, nil\n}\n\nfunc applyProviderDefaults(config *wconfig.AIModeConfigType) {\n\tif config.Provider == uctypes.AIProvider_Wave {\n\t\tconfig.WaveAICloud = true\n\t\tif config.Endpoint == \"\" {\n\t\t\tconfig.Endpoint = uctypes.DefaultAIEndpoint\n\t\t\tif os.Getenv(uctypes.WaveAIEndpointEnvName) != \"\" {\n\t\t\t\tconfig.Endpoint = os.Getenv(uctypes.WaveAIEndpointEnvName)\n\t\t\t}\n\t\t}\n\t}\n\tif config.Provider == uctypes.AIProvider_OpenAI {\n\t\tif config.APIType == \"\" {\n\t\t\tconfig.APIType = getOpenAIAPIType(config.Model)\n\t\t}\n\t\tif config.Endpoint == \"\" {\n\t\t\tswitch config.APIType {\n\t\t\tcase uctypes.APIType_OpenAIResponses:\n\t\t\t\tconfig.Endpoint = OpenAIResponsesEndpoint\n\t\t\tcase uctypes.APIType_OpenAIChat:\n\t\t\t\tconfig.Endpoint = OpenAIChatEndpoint\n\t\t\tdefault:\n\t\t\t\tconfig.Endpoint = OpenAIChatEndpoint\n\t\t\t}\n\t\t}\n\t\tif config.APITokenSecretName == \"\" {\n\t\t\tconfig.APITokenSecretName = OpenAIAPITokenSecretName\n\t\t}\n\t\tif len(config.Capabilities) == 0 {\n\t\t\tif isO1Model(config.Model) {\n\t\t\t\tconfig.Capabilities = []string{}\n\t\t\t} else {\n\t\t\t\tconfig.Capabilities = []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs}\n\t\t\t}\n\t\t}\n\t}\n\tif config.Provider == uctypes.AIProvider_OpenRouter {\n\t\tif config.APIType == \"\" {\n\t\t\tconfig.APIType = uctypes.APIType_OpenAIChat\n\t\t}\n\t\tif config.Endpoint == \"\" {\n\t\t\tconfig.Endpoint = OpenRouterChatEndpoint\n\t\t}\n\t\tif config.APITokenSecretName == \"\" {\n\t\t\tconfig.APITokenSecretName = OpenRouterAPITokenSecretName\n\t\t}\n\t}\n\tif config.Provider == uctypes.AIProvider_NanoGPT {\n\t\tif config.APIType == \"\" {\n\t\t\tconfig.APIType = uctypes.APIType_OpenAIChat\n\t\t}\n\t\tif config.Endpoint == \"\" {\n\t\t\tconfig.Endpoint = NanoGPTChatEndpoint\n\t\t}\n\t\tif config.APITokenSecretName == \"\" {\n\t\t\tconfig.APITokenSecretName = NanoGPTAPITokenSecretName\n\t\t}\n\t}\n\tif config.Provider == uctypes.AIProvider_Groq {\n\t\tif config.APIType == \"\" {\n\t\t\tconfig.APIType = uctypes.APIType_OpenAIChat\n\t\t}\n\t\tif config.Endpoint == \"\" {\n\t\t\tconfig.Endpoint = GroqChatEndpoint\n\t\t}\n\t\tif config.APITokenSecretName == \"\" {\n\t\t\tconfig.APITokenSecretName = GroqAPITokenSecretName\n\t\t}\n\t}\n\tif config.Provider == uctypes.AIProvider_AzureLegacy {\n\t\tif config.AzureAPIVersion == \"\" {\n\t\t\tconfig.AzureAPIVersion = AzureLegacyDefaultAPIVersion\n\t\t}\n\t\tif config.Endpoint == \"\" && isValidAzureResourceName(config.AzureResourceName) && config.AzureDeployment != \"\" {\n\t\t\tconfig.Endpoint = fmt.Sprintf(AzureLegacyEndpointTemplate,\n\t\t\t\tconfig.AzureResourceName, config.AzureDeployment, config.AzureAPIVersion)\n\t\t}\n\t\tif config.APIType == \"\" {\n\t\t\tconfig.APIType = uctypes.APIType_OpenAIChat\n\t\t}\n\t\tif config.APITokenSecretName == \"\" {\n\t\t\tconfig.APITokenSecretName = AzureOpenAIAPITokenSecretName\n\t\t}\n\t}\n\tif config.Provider == uctypes.AIProvider_Azure {\n\t\tif config.AzureAPIVersion == \"\" {\n\t\t\tconfig.AzureAPIVersion = \"v1\" // purely informational for now\n\t\t}\n\t\tif config.APIType == \"\" {\n\t\t\tconfig.APIType = getAzureAPIType(config.Model)\n\t\t}\n\t\tif config.Endpoint == \"\" && isValidAzureResourceName(config.AzureResourceName) && isAzureAPIType(config.APIType) {\n\t\t\tswitch config.APIType {\n\t\t\tcase uctypes.APIType_OpenAIResponses:\n\t\t\t\tconfig.Endpoint = fmt.Sprintf(AzureResponsesEndpointTemplate, config.AzureResourceName)\n\t\t\tcase uctypes.APIType_OpenAIChat:\n\t\t\t\tconfig.Endpoint = fmt.Sprintf(AzureChatEndpointTemplate, config.AzureResourceName)\n\t\t\t}\n\t\t}\n\t\tif config.APITokenSecretName == \"\" {\n\t\t\tconfig.APITokenSecretName = AzureOpenAIAPITokenSecretName\n\t\t}\n\t}\n\tif config.Provider == uctypes.AIProvider_Google {\n\t\tif config.APIType == \"\" {\n\t\t\tconfig.APIType = uctypes.APIType_GoogleGemini\n\t\t}\n\t\tif config.Endpoint == \"\" && config.Model != \"\" {\n\t\t\tconfig.Endpoint = fmt.Sprintf(GoogleGeminiEndpointTemplate, config.Model)\n\t\t}\n\t\tif config.APITokenSecretName == \"\" {\n\t\t\tconfig.APITokenSecretName = GoogleAIAPITokenSecretName\n\t\t}\n\t\tif len(config.Capabilities) == 0 {\n\t\t\tconfig.Capabilities = []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs}\n\t\t}\n\t}\n\tif config.APIType == \"\" {\n\t\tconfig.APIType = uctypes.APIType_OpenAIChat\n\t}\n}\n\nfunc isAzureAPIType(apiType string) bool {\n\treturn apiType == uctypes.APIType_OpenAIChat || apiType == uctypes.APIType_OpenAIResponses\n}\n\nfunc getOpenAIAPIType(model string) string {\n\tif isLegacyOpenAIModel(model) {\n\t\treturn uctypes.APIType_OpenAIChat\n\t}\n\t// All newer OpenAI models support openai-responses API:\n\t// gpt-5*, gpt-4.1*, o1*, o3*, and any future models\n\treturn uctypes.APIType_OpenAIResponses\n}\n\nfunc getAzureAPIType(model string) string {\n\tif isNewOpenAIModel(model) {\n\t\treturn uctypes.APIType_OpenAIResponses\n\t}\n\treturn uctypes.APIType_OpenAIChat\n}\n\nfunc isNewOpenAIModel(model string) bool {\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tnewPrefixes := []string{\"gpt-6\", \"gpt-5\", \"gpt-4.1\", \"o1\", \"o3\"}\n\tfor _, prefix := range newPrefixes {\n\t\tif aiutil.CheckModelPrefix(model, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\tif aiutil.CheckModelSubPrefix(model, \"gpt-5.\") || aiutil.CheckModelSubPrefix(model, \"gpt-6.\") {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isLegacyOpenAIModel(model string) bool {\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlegacyPrefixes := []string{\"gpt-4o\", \"gpt-3.5\", \"gpt-oss\"}\n\tfor _, prefix := range legacyPrefixes {\n\t\tif aiutil.CheckModelPrefix(model, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc isO1Model(model string) bool {\n\tif model == \"\" {\n\t\treturn false\n\t}\n\to1Prefixes := []string{\"o1\", \"o1-mini\"}\n\tfor _, prefix := range o1Prefixes {\n\t\tif aiutil.CheckModelPrefix(model, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc isValidAzureResourceName(name string) bool {\n\tif name == \"\" || len(name) > 63 {\n\t\treturn false\n\t}\n\treturn AzureResourceNameRegex.MatchString(name)\n}\n\nfunc getAIModeConfig(aiMode string) (*wconfig.AIModeConfigType, error) {\n\tfullConfig := wconfig.GetWatcher().GetFullConfig()\n\tconfig, ok := fullConfig.WaveAIModes[aiMode]\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"invalid AI mode: %s\", aiMode)\n\t}\n\n\tapplyProviderDefaults(&config)\n\treturn &config, nil\n}\n\nfunc InitAIModeConfigWatcher() {\n\twatcher := wconfig.GetWatcher()\n\twatcher.RegisterUpdateHandler(handleConfigUpdate)\n\tlog.Printf(\"AI mode config watcher initialized\\n\")\n}\n\nfunc handleConfigUpdate(fullConfig wconfig.FullConfigType) {\n\tresolvedConfigs := ComputeResolvedAIModeConfigs(fullConfig)\n\tbroadcastAIModeConfigs(resolvedConfigs)\n}\n\nfunc ComputeResolvedAIModeConfigs(fullConfig wconfig.FullConfigType) map[string]wconfig.AIModeConfigType {\n\tresolvedConfigs := make(map[string]wconfig.AIModeConfigType)\n\t\n\tfor modeName, modeConfig := range fullConfig.WaveAIModes {\n\t\tresolved := modeConfig\n\t\tapplyProviderDefaults(&resolved)\n\t\tresolvedConfigs[modeName] = resolved\n\t}\n\t\n\treturn resolvedConfigs\n}\n\nfunc broadcastAIModeConfigs(configs map[string]wconfig.AIModeConfigType) {\n\tupdate := wconfig.AIModeConfigUpdate{\n\t\tConfigs: configs,\n\t}\n\t\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent: wps.Event_AIModeConfig,\n\t\tData:  update,\n\t})\n}\n"
  },
  {
    "path": "pkg/aiusechat/usechat-prompts.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport \"strings\"\n\nvar SystemPromptText_OpenAI = strings.Join([]string{\n\t`You are Wave AI, an assistant embedded in Wave Terminal (a terminal with graphical widgets).`,\n\t`You appear as a pull-out panel on the left; widgets are on the right.`,\n\n\t// Capabilities & truthfulness\n\t`Tools define your only capabilities. If a capability is not provided by a tool, you cannot do it. Never fabricate data or pretend to call tools. If you lack data or access, say so directly and suggest the next best step.`,\n\t`Use read-only tools (capture_screenshot, read_text_file, read_dir, term_get_scrollback) automatically whenever they help answer the user's request. When a user clearly expresses intent to modify something (write/edit/delete files), call the corresponding tool directly.`,\n\n\t// Crisp behavior\n\t`Be concise and direct. Prefer determinism over speculation. If a brief clarifying question eliminates guesswork, ask it.`,\n\n\t// Attached text files\n\t`User-attached text files may appear inline as <AttachedTextFile_xxxxxxxx file_name=\"...\">\\ncontent\\n</AttachedTextFile_xxxxxxxx>.`,\n\t`User-attached directories use the tag <AttachedDirectoryListing_xxxxxxxx directory_name=\"...\">JSON DirInfo</AttachedDirectoryListing_xxxxxxxx>.`,\n\t`If multiple attached files exist, treat each as a separate source file with its own file_name.`,\n\t`When the user refers to these files, use their inline content directly; do NOT call any read_text_file or file-access tools to re-read them unless asked.`,\n\n\t// Output & formatting\n\t`When presenting commands or any runnable multi-line code, always use fenced Markdown code blocks.`,\n\t`Use an appropriate language hint after the opening fence (e.g., \"bash\" for shell commands, \"go\" for Go, \"json\" for JSON).`,\n\t`For shell commands, do NOT prefix lines with \"$\" or shell prompts. Use placeholders in ALL_CAPS (e.g., PROJECT_ID) and explain them once after the block if needed.`,\n\t\"Reserve inline code (single backticks) for short references like command names (`grep`, `less`), flags, env vars, file paths, or tiny snippets not meant to be executed.\",\n\t`You may use Markdown (lists, tables, bold/italics) to improve readability.`,\n\t`Never comment on or justify your formatting choices; just follow these rules.`,\n\t`When generating code or command blocks, try to keep lines under ~100 characters wide where practical (soft wrap; do not break tokens mid-word). Favor indentation and short variable names to stay compact, but correctness always takes priority.`,\n\n\t// Safety & limits\n\t`If a request would execute dangerous or destructive actions, warn briefly and provide a safer alternative.`,\n\t`If output is very long, prefer a brief summary plus a copy-ready fenced block or offer a follow-up chunking strategy.`,\n\n\t`You can write and edit local files on disk using available tools, but you cannot execute shell commands, run code in the terminal, or read/write remote files.`,\n\t`If the user asks you to execute commands or run code, or deal with remote files say that these features aren't available yet AND make sure to say that they are coming soon (stay tuned for updates).`,\n\t`Instead, show them exactly what command or code they could copy-paste to run manually.`,\n\n\t// Final reminder\n\t`You have NO API access to widgets or Wave unless provided via an explicit tool.`,\n}, \" \")\n\nvar SystemPromptText_NoTools = strings.Join([]string{\n\t`You are Wave AI, an assistant embedded in Wave Terminal (a terminal with graphical widgets).`,\n\t`You appear as a pull-out panel on the left; widgets are on the right.`,\n\n\t// Capabilities & truthfulness\n\t`Be truthful about your capabilities. You can answer questions, explain concepts, provide code examples, and help with technical problems, but you cannot directly access files, execute commands, or interact with the terminal. If you lack specific data or access, say so directly and suggest what the user could do to provide it.`,\n\n\t// Crisp behavior\n\t`Be concise and direct. Prefer determinism over speculation. If a brief clarifying question eliminates guesswork, ask it.`,\n\n\t// Attached text files\n\t`User-attached text files may appear inline as <AttachedTextFile_xxxxxxxx file_name=\"...\">\\ncontent\\n</AttachedTextFile_xxxxxxxx>.`,\n\t`User-attached directories use the tag <AttachedDirectoryListing_xxxxxxxx directory_name=\"...\">JSON DirInfo</AttachedDirectoryListing_xxxxxxxx>.`,\n\t`If multiple attached files exist, treat each as a separate source file with its own file_name.`,\n\t`When the user refers to these files, use their inline content directly for analysis and discussion.`,\n\n\t// Output & formatting\n\t`When presenting commands or any runnable multi-line code, always use fenced Markdown code blocks.`,\n\t`Use an appropriate language hint after the opening fence (e.g., \"bash\" for shell commands, \"go\" for Go, \"json\" for JSON).`,\n\t`For shell commands, do NOT prefix lines with \"$\" or shell prompts. Use placeholders in ALL_CAPS (e.g., PROJECT_ID) and explain them once after the block if needed.`,\n\t\"Reserve inline code (single backticks) for short references like command names (`grep`, `less`), flags, env vars, file paths, or tiny snippets not meant to be executed.\",\n\t`You may use Markdown (lists, tables, bold/italics) to improve readability.`,\n\t`Never comment on or justify your formatting choices; just follow these rules.`,\n\t`When generating code or command blocks, try to keep lines under ~100 characters wide where practical (soft wrap; do not break tokens mid-word). Favor indentation and short variable names to stay compact, but correctness always takes priority.`,\n\n\t// Safety & limits\n\t`If a request would execute dangerous or destructive actions, warn briefly and provide a safer alternative.`,\n\t`If output is very long, prefer a brief summary plus a copy-ready fenced block or offer a follow-up chunking strategy.`,\n\n\t`You cannot directly write files, execute shell commands, run code in the terminal, or access remote files.`,\n\t`When users ask for code or commands, provide ready-to-use examples they can copy and execute themselves.`,\n\t`If they need file modifications, show the exact changes they should make.`,\n\n\t// Final reminder\n\t`You have NO API access to widgets or Wave Terminal internals.`,\n}, \" \")\n\nvar SystemPromptText_StrictToolAddOn = `## Tool Call Rules (STRICT)\n\nWhen you decide a file write/edit tool call is needed:\n\n- Output ONLY the tool call.\n- Do NOT include any explanation, summary, or file content in the chat.\n- Do NOT echo the file content before or after the tool call.\n- After the tool call result is returned, respond ONLY with what the user directly asked for. If they did not ask to see the file content, do NOT show it.\n`\n"
  },
  {
    "path": "pkg/aiusechat/usechat-utils.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n)\n\n// CombineConsecutiveSameRoleMessages combines consecutive UIMessages with the same role\n// by appending their Parts together. This is useful for APIs like OpenAI that may split\n// assistant messages into separate messages (e.g., one for text and one for tool calls).\nfunc CombineConsecutiveSameRoleMessages(uiChat *uctypes.UIChat) *uctypes.UIChat {\n\tif uiChat == nil || len(uiChat.Messages) == 0 {\n\t\treturn uiChat\n\t}\n\n\tcombined := make([]uctypes.UIMessage, 0, len(uiChat.Messages))\n\tvar current *uctypes.UIMessage\n\n\tfor i := range uiChat.Messages {\n\t\tmsg := &uiChat.Messages[i]\n\n\t\tif current == nil {\n\t\t\t// First message - start a new combined message\n\t\t\tcurrent = &uctypes.UIMessage{\n\t\t\t\tID:       msg.ID,\n\t\t\t\tRole:     msg.Role,\n\t\t\t\tMetadata: msg.Metadata,\n\t\t\t\tParts:    make([]uctypes.UIMessagePart, len(msg.Parts)),\n\t\t\t}\n\t\t\tcopy(current.Parts, msg.Parts)\n\t\t\tcontinue\n\t\t}\n\n\t\tif current.Role == msg.Role {\n\t\t\t// Same role - append parts to current message\n\t\t\tcurrent.Parts = append(current.Parts, msg.Parts...)\n\t\t} else {\n\t\t\t// Different role - save current and start new\n\t\t\tcombined = append(combined, *current)\n\t\t\tcurrent = &uctypes.UIMessage{\n\t\t\t\tID:       msg.ID,\n\t\t\t\tRole:     msg.Role,\n\t\t\t\tMetadata: msg.Metadata,\n\t\t\t\tParts:    make([]uctypes.UIMessagePart, len(msg.Parts)),\n\t\t\t}\n\t\t\tcopy(current.Parts, msg.Parts)\n\t\t}\n\t}\n\n\t// Don't forget the last message\n\tif current != nil {\n\t\tcombined = append(combined, *current)\n\t}\n\n\treturn &uctypes.UIChat{\n\t\tChatId:     uiChat.ChatId,\n\t\tAPIType:    uiChat.APIType,\n\t\tModel:      uiChat.Model,\n\t\tAPIVersion: uiChat.APIVersion,\n\t\tMessages:   combined,\n\t}\n}\n\n\n// ConvertAIChatToUIChat converts an AIChat to a UIChat by routing to the appropriate\n// provider-specific converter based on APIType, then combining consecutive same-role messages.\nfunc ConvertAIChatToUIChat(aiChat *uctypes.AIChat) (*uctypes.UIChat, error) {\n\tif aiChat == nil {\n\t\treturn nil, nil\n\t}\n\n\tbackend, err := GetBackendByAPIType(aiChat.APIType)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tuiChat, err := backend.ConvertAIChatToUIChat(*aiChat)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn CombineConsecutiveSameRoleMessages(uiChat), nil\n}\n"
  },
  {
    "path": "pkg/aiusechat/usechat.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"context\"\n\t_ \"embed\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/user\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/secretstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/ds\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/logutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveappstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/web/sse\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nconst DefaultAPI = uctypes.APIType_OpenAIResponses\nconst DefaultMaxTokens = 4 * 1024\nconst BuilderMaxTokens = 24 * 1024\n\nvar (\n\tglobalRateLimitInfo = &uctypes.RateLimitInfo{Unknown: true}\n\trateLimitLock       sync.Mutex\n\n\tactiveChats = ds.MakeSyncMap[bool]() // key is chatid\n)\n\nfunc getSystemPrompt(apiType string, model string, isBuilder bool, hasToolsCapability bool, widgetAccess bool) []string {\n\tif isBuilder {\n\t\treturn []string{}\n\t}\n\tuseNoToolsPrompt := !hasToolsCapability || !widgetAccess\n\tbasePrompt := SystemPromptText_OpenAI\n\tif useNoToolsPrompt {\n\t\tbasePrompt = SystemPromptText_NoTools\n\t}\n\tmodelLower := strings.ToLower(model)\n\tneedsStrictToolAddOn, _ := regexp.MatchString(`(?i)\\b(mistral|o?llama|qwen|mixtral|yi|phi|deepseek)\\b`, modelLower)\n\tif needsStrictToolAddOn && !useNoToolsPrompt {\n\t\treturn []string{basePrompt, SystemPromptText_StrictToolAddOn}\n\t}\n\treturn []string{basePrompt}\n}\n\nfunc isLocalEndpoint(endpoint string) bool {\n\tif endpoint == \"\" {\n\t\treturn false\n\t}\n\tendpointLower := strings.ToLower(endpoint)\n\treturn strings.Contains(endpointLower, \"localhost\") || strings.Contains(endpointLower, \"127.0.0.1\")\n}\n\nfunc getWaveAISettings(premium bool, builderMode bool, rtInfo waveobj.ObjRTInfo, aiModeName string) (*uctypes.AIOptsType, error) {\n\tmaxTokens := DefaultMaxTokens\n\tif builderMode {\n\t\tmaxTokens = BuilderMaxTokens\n\t}\n\tif rtInfo.WaveAIMaxOutputTokens > 0 {\n\t\tmaxTokens = rtInfo.WaveAIMaxOutputTokens\n\t}\n\taiMode, config, err := resolveAIMode(aiModeName, premium)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif config.WaveAICloud && !telemetry.IsTelemetryEnabled() {\n\t\treturn nil, fmt.Errorf(\"Wave AI cloud modes require telemetry to be enabled\")\n\t}\n\tapiToken := config.APIToken\n\tif apiToken == \"\" && config.APITokenSecretName != \"\" {\n\t\tsecret, exists, err := secretstore.GetSecret(config.APITokenSecretName)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to retrieve secret %s: %w\", config.APITokenSecretName, err)\n\t\t}\n\t\tsecret = strings.TrimSpace(secret)\n\t\tif !exists || secret == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"secret %s not found or empty\", config.APITokenSecretName)\n\t\t}\n\t\tapiToken = secret\n\t}\n\n\tvar baseUrl string\n\tif config.Endpoint != \"\" {\n\t\tbaseUrl = config.Endpoint\n\t} else {\n\t\treturn nil, fmt.Errorf(\"no ai:endpoint configured for AI mode %s\", aiMode)\n\t}\n\n\tthinkingLevel := config.ThinkingLevel\n\tif thinkingLevel == \"\" {\n\t\tthinkingLevel = uctypes.ThinkingLevelMedium\n\t}\n\tverbosity := config.Verbosity\n\tif verbosity == \"\" {\n\t\tverbosity = uctypes.VerbosityLevelMedium // default to medium\n\t}\n\topts := &uctypes.AIOptsType{\n\t\tProvider:      config.Provider,\n\t\tAPIType:       config.APIType,\n\t\tModel:         config.Model,\n\t\tMaxTokens:     maxTokens,\n\t\tThinkingLevel: thinkingLevel,\n\t\tVerbosity:     verbosity,\n\t\tAIMode:        aiMode,\n\t\tEndpoint:      baseUrl,\n\t\tProxyURL:      config.ProxyURL,\n\t\tCapabilities:  config.Capabilities,\n\t\tWaveAIPremium: config.WaveAIPremium,\n\t}\n\tif apiToken != \"\" {\n\t\topts.APIToken = apiToken\n\t}\n\treturn opts, nil\n}\n\nfunc shouldUseChatCompletionsAPI(model string) bool {\n\tm := strings.ToLower(model)\n\t// Chat Completions API is required for older models: gpt-3.5-*, gpt-4, gpt-4-turbo, o1-*\n\treturn strings.HasPrefix(m, \"gpt-3.5\") ||\n\t\tstrings.HasPrefix(m, \"gpt-4-\") ||\n\t\tm == \"gpt-4\" ||\n\t\tstrings.HasPrefix(m, \"o1-\")\n}\n\nfunc shouldUsePremium() bool {\n\tinfo := GetGlobalRateLimit()\n\tif info == nil || info.Unknown {\n\t\treturn true\n\t}\n\tif info.PReq > 0 {\n\t\treturn true\n\t}\n\tnowEpoch := time.Now().Unix()\n\tif nowEpoch >= info.ResetEpoch {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc updateRateLimit(info *uctypes.RateLimitInfo) {\n\tif info == nil {\n\t\treturn\n\t}\n\trateLimitLock.Lock()\n\tdefer rateLimitLock.Unlock()\n\tglobalRateLimitInfo = info\n\tgo func() {\n\t\twps.Broker.Publish(wps.WaveEvent{\n\t\t\tEvent: wps.Event_WaveAIRateLimit,\n\t\t\tData:  info,\n\t\t})\n\t}()\n}\n\nfunc GetGlobalRateLimit() *uctypes.RateLimitInfo {\n\trateLimitLock.Lock()\n\tdefer rateLimitLock.Unlock()\n\treturn globalRateLimitInfo\n}\n\nfunc runAIChatStep(ctx context.Context, sseHandler *sse.SSEHandlerCh, backend UseChatBackend, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, error) {\n\tif chatOpts.Config.APIType == uctypes.APIType_OpenAIResponses && shouldUseChatCompletionsAPI(chatOpts.Config.Model) {\n\t\treturn nil, nil, fmt.Errorf(\"Chat completions API not available (must use newer OpenAI models)\")\n\t}\n\tstopReason, messages, rateLimitInfo, err := backend.RunChatStep(ctx, sseHandler, chatOpts, cont)\n\tupdateRateLimit(rateLimitInfo)\n\treturn stopReason, messages, err\n}\n\nfunc getUsage(msgs []uctypes.GenAIMessage) uctypes.AIUsage {\n\tvar rtn uctypes.AIUsage\n\tvar found bool\n\tfor _, msg := range msgs {\n\t\tif usage := msg.GetUsage(); usage != nil {\n\t\t\tif !found {\n\t\t\t\trtn = *usage\n\t\t\t\tfound = true\n\t\t\t} else {\n\t\t\t\trtn.InputTokens += usage.InputTokens\n\t\t\t\trtn.OutputTokens += usage.OutputTokens\n\t\t\t\trtn.NativeWebSearchCount += usage.NativeWebSearchCount\n\t\t\t}\n\t\t}\n\t}\n\treturn rtn\n}\n\nfunc GetChatUsage(chat *uctypes.AIChat) uctypes.AIUsage {\n\tusage := getUsage(chat.NativeMessages)\n\tusage.APIType = chat.APIType\n\tusage.Model = chat.Model\n\treturn usage\n}\n\nfunc updateToolUseDataInChat(backend UseChatBackend, chatOpts uctypes.WaveChatOpts, toolCallID string, toolUseData uctypes.UIMessageDataToolUse) {\n\tif err := backend.UpdateToolUseData(chatOpts.ChatId, toolCallID, toolUseData); err != nil {\n\t\tlog.Printf(\"failed to update tool use data in chat: %v\\n\", err)\n\t}\n}\n\nfunc processToolCallInternal(backend UseChatBackend, toolCall uctypes.WaveToolCall, chatOpts uctypes.WaveChatOpts, toolDef *uctypes.ToolDefinition, sseHandler *sse.SSEHandlerCh) uctypes.AIToolResult {\n\tif toolCall.ToolUseData == nil {\n\t\treturn uctypes.AIToolResult{\n\t\t\tToolName:  toolCall.Name,\n\t\t\tToolUseID: toolCall.ID,\n\t\t\tErrorText: \"Invalid Tool Call\",\n\t\t}\n\t}\n\n\tif toolCall.ToolUseData.Status == uctypes.ToolUseStatusError {\n\t\terrorMsg := toolCall.ToolUseData.ErrorMessage\n\t\tif errorMsg == \"\" {\n\t\t\terrorMsg = \"Unspecified Tool Error\"\n\t\t}\n\t\treturn uctypes.AIToolResult{\n\t\t\tToolName:  toolCall.Name,\n\t\t\tToolUseID: toolCall.ID,\n\t\t\tErrorText: errorMsg,\n\t\t}\n\t}\n\n\tif toolDef != nil && toolDef.ToolVerifyInput != nil {\n\t\tif err := toolDef.ToolVerifyInput(toolCall.Input, toolCall.ToolUseData); err != nil {\n\t\t\terrorMsg := fmt.Sprintf(\"Input validation failed: %v\", err)\n\t\t\ttoolCall.ToolUseData.Status = uctypes.ToolUseStatusError\n\t\t\ttoolCall.ToolUseData.ErrorMessage = errorMsg\n\t\t\treturn uctypes.AIToolResult{\n\t\t\t\tToolName:  toolCall.Name,\n\t\t\t\tToolUseID: toolCall.ID,\n\t\t\t\tErrorText: errorMsg,\n\t\t\t}\n\t\t}\n\t\t// ToolVerifyInput can modify the toolusedata.  re-send it here.\n\t\t_ = sseHandler.AiMsgData(\"data-tooluse\", toolCall.ID, *toolCall.ToolUseData)\n\t\tupdateToolUseDataInChat(backend, chatOpts, toolCall.ID, *toolCall.ToolUseData)\n\t}\n\n\tif toolCall.ToolUseData.Approval == uctypes.ApprovalNeedsApproval {\n\t\tlog.Printf(\"  waiting for approval...\\n\")\n\t\tapproval, err := WaitForToolApproval(sseHandler.Context(), toolCall.ID)\n\t\tif err != nil || approval == \"\" {\n\t\t\tapproval = uctypes.ApprovalCanceled\n\t\t}\n\t\tlog.Printf(\"  approval result: %q\\n\", approval)\n\t\ttoolCall.ToolUseData.Approval = approval\n\n\t\tif !toolCall.ToolUseData.IsApproved() {\n\t\t\terrorMsg := \"Tool use denied or timed out\"\n\t\t\tif approval == uctypes.ApprovalUserDenied {\n\t\t\t\terrorMsg = \"Tool use denied by user\"\n\t\t\t} else if approval == uctypes.ApprovalTimeout {\n\t\t\t\terrorMsg = \"Tool approval timed out\"\n\t\t\t} else if approval == uctypes.ApprovalCanceled {\n\t\t\t\terrorMsg = \"Tool approval canceled\"\n\t\t\t}\n\t\t\ttoolCall.ToolUseData.Status = uctypes.ToolUseStatusError\n\t\t\ttoolCall.ToolUseData.ErrorMessage = errorMsg\n\t\t\treturn uctypes.AIToolResult{\n\t\t\t\tToolName:  toolCall.Name,\n\t\t\t\tToolUseID: toolCall.ID,\n\t\t\t\tErrorText: errorMsg,\n\t\t\t}\n\t\t}\n\n\t\t// this still happens here because we need to update the FE to say the tool call was approved\n\t\t_ = sseHandler.AiMsgData(\"data-tooluse\", toolCall.ID, *toolCall.ToolUseData)\n\t\tupdateToolUseDataInChat(backend, chatOpts, toolCall.ID, *toolCall.ToolUseData)\n\t}\n\n\ttoolCall.ToolUseData.RunTs = time.Now().UnixMilli()\n\tresult := ResolveToolCall(toolDef, toolCall, chatOpts)\n\n\tif result.ErrorText != \"\" {\n\t\ttoolCall.ToolUseData.Status = uctypes.ToolUseStatusError\n\t\ttoolCall.ToolUseData.ErrorMessage = result.ErrorText\n\t} else {\n\t\ttoolCall.ToolUseData.Status = uctypes.ToolUseStatusCompleted\n\t}\n\n\treturn result\n}\n\nfunc processToolCall(backend UseChatBackend, toolCall uctypes.WaveToolCall, chatOpts uctypes.WaveChatOpts, sseHandler *sse.SSEHandlerCh, metrics *uctypes.AIMetrics) uctypes.AIToolResult {\n\tinputJSON, _ := json.Marshal(toolCall.Input)\n\tlogutil.DevPrintf(\"TOOLUSE name=%s id=%s input=%s approval=%q\\n\", toolCall.Name, toolCall.ID, utilfn.TruncateString(string(inputJSON), 40), toolCall.ToolUseData.Approval)\n\n\ttoolDef := chatOpts.GetToolDefinition(toolCall.Name)\n\tresult := processToolCallInternal(backend, toolCall, chatOpts, toolDef, sseHandler)\n\n\tif result.ErrorText != \"\" {\n\t\tlog.Printf(\"  error=%s\\n\", result.ErrorText)\n\t\tmetrics.ToolUseErrorCount++\n\t} else {\n\t\tlog.Printf(\"  result=%s\\n\", utilfn.TruncateString(result.Text, 40))\n\t}\n\n\tif toolDef != nil && toolDef.ToolLogName != \"\" {\n\t\tmetrics.ToolDetail[toolDef.ToolLogName]++\n\t}\n\n\tif toolCall.ToolUseData != nil {\n\t\t_ = sseHandler.AiMsgData(\"data-tooluse\", toolCall.ID, *toolCall.ToolUseData)\n\t\tupdateToolUseDataInChat(backend, chatOpts, toolCall.ID, *toolCall.ToolUseData)\n\t}\n\n\treturn result\n}\n\nfunc processAllToolCalls(backend UseChatBackend, stopReason *uctypes.WaveStopReason, chatOpts uctypes.WaveChatOpts, sseHandler *sse.SSEHandlerCh, metrics *uctypes.AIMetrics) {\n\t// Create and send all data-tooluse packets at the beginning\n\tfor i := range stopReason.ToolCalls {\n\t\ttoolCall := &stopReason.ToolCalls[i]\n\t\t// Create toolUseData from the tool call input\n\t\tvar argsJSON string\n\t\tif toolCall.Input != nil {\n\t\t\targsBytes, err := json.Marshal(toolCall.Input)\n\t\t\tif err == nil {\n\t\t\t\targsJSON = string(argsBytes)\n\t\t\t}\n\t\t}\n\t\ttoolUseData := aiutil.CreateToolUseData(toolCall.ID, toolCall.Name, argsJSON, chatOpts)\n\t\tstopReason.ToolCalls[i].ToolUseData = &toolUseData\n\t\tlog.Printf(\"AI data-tooluse %s\\n\", toolCall.ID)\n\t\t_ = sseHandler.AiMsgData(\"data-tooluse\", toolCall.ID, toolUseData)\n\t\tupdateToolUseDataInChat(backend, chatOpts, toolCall.ID, toolUseData)\n\t\tif toolUseData.Approval == uctypes.ApprovalNeedsApproval {\n\t\t\tRegisterToolApproval(toolCall.ID, sseHandler)\n\t\t}\n\t}\n\t// At this point, all ToolCalls are guaranteed to have non-nil ToolUseData\n\n\tvar toolResults []uctypes.AIToolResult\n\tfor _, toolCall := range stopReason.ToolCalls {\n\t\tif sseHandler.Err() != nil {\n\t\t\tlog.Printf(\"AI tool processing stopped: %v\\n\", sseHandler.Err())\n\t\t\tbreak\n\t\t}\n\t\tresult := processToolCall(backend, toolCall, chatOpts, sseHandler, metrics)\n\t\ttoolResults = append(toolResults, result)\n\t}\n\n\t// Cleanup: unregister approvals, remove incomplete/canceled tool calls, and filter results\n\tvar filteredResults []uctypes.AIToolResult\n\tfor i, toolCall := range stopReason.ToolCalls {\n\t\tUnregisterToolApproval(toolCall.ID)\n\t\thasResult := i < len(toolResults)\n\t\tshouldRemove := !hasResult || (toolCall.ToolUseData != nil && toolCall.ToolUseData.Approval == uctypes.ApprovalCanceled)\n\t\tif shouldRemove {\n\t\t\tbackend.RemoveToolUseCall(chatOpts.ChatId, toolCall.ID)\n\t\t} else if hasResult {\n\t\t\tfilteredResults = append(filteredResults, toolResults[i])\n\t\t}\n\t}\n\n\tif len(filteredResults) > 0 {\n\t\ttoolResultMsgs, err := backend.ConvertToolResultsToNativeChatMessage(filteredResults)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"Failed to convert tool results to native chat messages: %v\", err)\n\t\t} else {\n\t\t\tfor _, msg := range toolResultMsgs {\n\t\t\t\tif err := chatstore.DefaultChatStore.PostMessage(chatOpts.ChatId, &chatOpts.Config, msg); err != nil {\n\t\t\t\t\tlog.Printf(\"Failed to post tool result message: %v\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc RunAIChat(ctx context.Context, sseHandler *sse.SSEHandlerCh, backend UseChatBackend, chatOpts uctypes.WaveChatOpts) (*uctypes.AIMetrics, error) {\n\tif !activeChats.SetUnless(chatOpts.ChatId, true) {\n\t\treturn nil, fmt.Errorf(\"chat %s is already running\", chatOpts.ChatId)\n\t}\n\tdefer activeChats.Delete(chatOpts.ChatId)\n\n\tstepNum := chatstore.DefaultChatStore.CountUserMessages(chatOpts.ChatId)\n\taiProvider := chatOpts.Config.Provider\n\tif aiProvider == \"\" {\n\t\taiProvider = uctypes.AIProvider_Custom\n\t}\n\tisLocal := isLocalEndpoint(chatOpts.Config.Endpoint)\n\tmetrics := &uctypes.AIMetrics{\n\t\tChatId:  chatOpts.ChatId,\n\t\tStepNum: stepNum,\n\t\tUsage: uctypes.AIUsage{\n\t\t\tAPIType: chatOpts.Config.APIType,\n\t\t\tModel:   chatOpts.Config.Model,\n\t\t},\n\t\tWidgetAccess:  chatOpts.WidgetAccess,\n\t\tToolDetail:    make(map[string]int),\n\t\tThinkingLevel: chatOpts.Config.ThinkingLevel,\n\t\tAIMode:        chatOpts.Config.AIMode,\n\t\tAIProvider:    aiProvider,\n\t\tIsLocal:       isLocal,\n\t}\n\tfirstStep := true\n\tvar cont *uctypes.WaveContinueResponse\n\tfor {\n\t\tif chatOpts.TabStateGenerator != nil {\n\t\t\ttabState, tabTools, tabId, tabErr := chatOpts.TabStateGenerator()\n\t\t\tif tabErr == nil {\n\t\t\t\tchatOpts.TabState = tabState\n\t\t\t\tchatOpts.TabTools = tabTools\n\t\t\t\tchatOpts.TabId = tabId\n\t\t\t}\n\t\t}\n\t\tif chatOpts.BuilderAppGenerator != nil {\n\t\t\tappGoFile, appStaticFiles, platformInfo, appErr := chatOpts.BuilderAppGenerator()\n\t\t\tif appErr == nil {\n\t\t\t\tchatOpts.AppGoFile = appGoFile\n\t\t\t\tchatOpts.AppStaticFiles = appStaticFiles\n\t\t\t\tchatOpts.PlatformInfo = platformInfo\n\t\t\t}\n\t\t}\n\t\tstopReason, rtnMessages, err := runAIChatStep(ctx, sseHandler, backend, chatOpts, cont)\n\t\tmetrics.RequestCount++\n\t\tif chatOpts.Config.IsWaveProxy() {\n\t\t\tmetrics.ProxyReqCount++\n\t\t\tif chatOpts.Config.IsPremiumModel() {\n\t\t\t\tmetrics.PremiumReqCount++\n\t\t\t}\n\t\t}\n\t\tif stopReason != nil {\n\t\t\tlogutil.DevPrintf(\"stopreason: %s (%s) (%s) (%s)\\n\", stopReason.Kind, stopReason.ErrorText, stopReason.ErrorType, stopReason.RawReason)\n\t\t}\n\t\tif len(rtnMessages) > 0 {\n\t\t\tusage := getUsage(rtnMessages)\n\t\t\tlog.Printf(\"usage: input=%d output=%d websearch=%d\\n\", usage.InputTokens, usage.OutputTokens, usage.NativeWebSearchCount)\n\t\t\tmetrics.Usage.InputTokens += usage.InputTokens\n\t\t\tmetrics.Usage.OutputTokens += usage.OutputTokens\n\t\t\tmetrics.Usage.NativeWebSearchCount += usage.NativeWebSearchCount\n\t\t\tif usage.Model != \"\" && metrics.Usage.Model != usage.Model {\n\t\t\t\tmetrics.Usage.Model = \"mixed\"\n\t\t\t}\n\t\t}\n\t\tif firstStep && err != nil {\n\t\t\tmetrics.HadError = true\n\t\t\treturn metrics, fmt.Errorf(\"failed to stream %s chat: %w\", chatOpts.Config.APIType, err)\n\t\t}\n\t\tif err != nil {\n\t\t\tmetrics.HadError = true\n\t\t\t_ = sseHandler.AiMsgError(err.Error())\n\t\t\t_ = sseHandler.AiMsgFinish(\"\", nil)\n\t\t\tbreak\n\t\t}\n\t\tfor _, msg := range rtnMessages {\n\t\t\tif msg != nil {\n\t\t\t\tif err := chatstore.DefaultChatStore.PostMessage(chatOpts.ChatId, &chatOpts.Config, msg); err != nil {\n\t\t\t\t\tlog.Printf(\"Failed to post message: %v\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tfirstStep = false\n\t\tif stopReason != nil && stopReason.Kind == uctypes.StopKindPremiumRateLimit && chatOpts.Config.APIType == uctypes.APIType_OpenAIResponses && chatOpts.Config.Model == uctypes.PremiumOpenAIModel {\n\t\t\tlog.Printf(\"Premium rate limit hit with %s, switching to %s\\n\", uctypes.PremiumOpenAIModel, uctypes.DefaultOpenAIModel)\n\t\t\tcont = &uctypes.WaveContinueResponse{\n\t\t\t\tModel:            uctypes.DefaultOpenAIModel,\n\t\t\t\tContinueFromKind: uctypes.StopKindPremiumRateLimit,\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif stopReason != nil && stopReason.Kind == uctypes.StopKindToolUse {\n\t\t\tmetrics.ToolUseCount += len(stopReason.ToolCalls)\n\t\t\tprocessAllToolCalls(backend, stopReason, chatOpts, sseHandler, metrics)\n\t\t\tcont = &uctypes.WaveContinueResponse{\n\t\t\t\tModel:            chatOpts.Config.Model,\n\t\t\t\tContinueFromKind: uctypes.StopKindToolUse,\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\treturn metrics, nil\n}\n\nfunc ResolveToolCall(toolDef *uctypes.ToolDefinition, toolCall uctypes.WaveToolCall, chatOpts uctypes.WaveChatOpts) (result uctypes.AIToolResult) {\n\tresult = uctypes.AIToolResult{\n\t\tToolName:  toolCall.Name,\n\t\tToolUseID: toolCall.ID,\n\t}\n\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tresult.ErrorText = fmt.Sprintf(\"panic in tool execution: %v\", r)\n\t\t\tresult.Text = \"\"\n\t\t}\n\t}()\n\n\tif toolDef == nil {\n\t\tresult.ErrorText = fmt.Sprintf(\"tool '%s' not found\", toolCall.Name)\n\t\treturn\n\t}\n\n\t// Try ToolTextCallback first, then ToolAnyCallback\n\tif toolDef.ToolTextCallback != nil {\n\t\ttext, err := toolDef.ToolTextCallback(toolCall.Input)\n\t\tif err != nil {\n\t\t\tresult.ErrorText = err.Error()\n\t\t} else {\n\t\t\tresult.Text = text\n\t\t\t// Recompute tool description with the result\n\t\t\tif toolDef.ToolCallDesc != nil && toolCall.ToolUseData != nil {\n\t\t\t\ttoolCall.ToolUseData.ToolDesc = toolDef.ToolCallDesc(toolCall.Input, text, toolCall.ToolUseData)\n\t\t\t}\n\t\t}\n\t} else if toolDef.ToolAnyCallback != nil {\n\t\toutput, err := toolDef.ToolAnyCallback(toolCall.Input, toolCall.ToolUseData)\n\t\tif err != nil {\n\t\t\tresult.ErrorText = err.Error()\n\t\t} else {\n\t\t\t// Marshal the result to JSON\n\t\t\tjsonBytes, marshalErr := json.Marshal(output)\n\t\t\tif marshalErr != nil {\n\t\t\t\tresult.ErrorText = fmt.Sprintf(\"failed to marshal tool output: %v\", marshalErr)\n\t\t\t} else {\n\t\t\t\tresult.Text = string(jsonBytes)\n\t\t\t\t// Recompute tool description with the result\n\t\t\t\tif toolDef.ToolCallDesc != nil && toolCall.ToolUseData != nil {\n\t\t\t\t\ttoolCall.ToolUseData.ToolDesc = toolDef.ToolCallDesc(toolCall.Input, output, toolCall.ToolUseData)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} else {\n\t\tresult.ErrorText = fmt.Sprintf(\"tool '%s' has no callback functions\", toolCall.Name)\n\t}\n\n\treturn\n}\n\nfunc WaveAIPostMessageWrap(ctx context.Context, sseHandler *sse.SSEHandlerCh, message *uctypes.AIMessage, chatOpts uctypes.WaveChatOpts) error {\n\tstartTime := time.Now()\n\n\t// Convert AIMessage to native chat message using backend\n\tbackend, err := GetBackendByAPIType(chatOpts.Config.APIType)\n\tif err != nil {\n\t\treturn err\n\t}\n\tconvertedMessage, err := backend.ConvertAIMessageToNativeChatMessage(*message)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"message conversion failed: %w\", err)\n\t}\n\n\t// Post message to chat store\n\tif err := chatstore.DefaultChatStore.PostMessage(chatOpts.ChatId, &chatOpts.Config, convertedMessage); err != nil {\n\t\treturn fmt.Errorf(\"failed to store message: %w\", err)\n\t}\n\n\tmetrics, err := RunAIChat(ctx, sseHandler, backend, chatOpts)\n\tif metrics != nil {\n\t\tmetrics.RequestDuration = int(time.Since(startTime).Milliseconds())\n\t\tfor _, part := range message.Parts {\n\t\t\tif part.Type == uctypes.AIMessagePartTypeText {\n\t\t\t\tmetrics.TextLen += len(part.Text)\n\t\t\t} else if part.Type == uctypes.AIMessagePartTypeFile {\n\t\t\t\tmimeType := strings.ToLower(part.MimeType)\n\t\t\t\tif strings.HasPrefix(mimeType, \"image/\") {\n\t\t\t\t\tmetrics.ImageCount++\n\t\t\t\t} else if mimeType == \"application/pdf\" {\n\t\t\t\t\tmetrics.PDFCount++\n\t\t\t\t} else {\n\t\t\t\t\tmetrics.TextDocCount++\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tlog.Printf(\"WaveAI call metrics: requests=%d tools=%d premium=%d proxy=%d images=%d pdfs=%d textdocs=%d textlen=%d duration=%dms error=%v\\n\",\n\t\t\tmetrics.RequestCount, metrics.ToolUseCount, metrics.PremiumReqCount, metrics.ProxyReqCount,\n\t\t\tmetrics.ImageCount, metrics.PDFCount, metrics.TextDocCount, metrics.TextLen, metrics.RequestDuration, metrics.HadError)\n\n\t\tsendAIMetricsTelemetry(ctx, metrics)\n\t}\n\treturn err\n}\n\nfunc sendAIMetricsTelemetry(ctx context.Context, metrics *uctypes.AIMetrics) {\n\tevent := telemetrydata.MakeTEvent(\"waveai:post\", telemetrydata.TEventProps{\n\t\tWaveAIAPIType:              metrics.Usage.APIType,\n\t\tWaveAIModel:                metrics.Usage.Model,\n\t\tWaveAIChatId:               metrics.ChatId,\n\t\tWaveAIStepNum:              metrics.StepNum,\n\t\tWaveAIInputTokens:          metrics.Usage.InputTokens,\n\t\tWaveAIOutputTokens:         metrics.Usage.OutputTokens,\n\t\tWaveAINativeWebSearchCount: metrics.Usage.NativeWebSearchCount,\n\t\tWaveAIRequestCount:         metrics.RequestCount,\n\t\tWaveAIToolUseCount:         metrics.ToolUseCount,\n\t\tWaveAIToolUseErrorCount:    metrics.ToolUseErrorCount,\n\t\tWaveAIToolDetail:           metrics.ToolDetail,\n\t\tWaveAIPremiumReq:           metrics.PremiumReqCount,\n\t\tWaveAIProxyReq:             metrics.ProxyReqCount,\n\t\tWaveAIHadError:             metrics.HadError,\n\t\tWaveAIImageCount:           metrics.ImageCount,\n\t\tWaveAIPDFCount:             metrics.PDFCount,\n\t\tWaveAITextDocCount:         metrics.TextDocCount,\n\t\tWaveAITextLen:              metrics.TextLen,\n\t\tWaveAIFirstByteMs:          metrics.FirstByteLatency,\n\t\tWaveAIRequestDurMs:         metrics.RequestDuration,\n\t\tWaveAIWidgetAccess:         metrics.WidgetAccess,\n\t\tWaveAIThinkingLevel:        metrics.ThinkingLevel,\n\t\tWaveAIMode:                 metrics.AIMode,\n\t\tWaveAIProvider:             metrics.AIProvider,\n\t\tWaveAIIsLocal:              metrics.IsLocal,\n\t})\n\t_ = telemetry.RecordTEvent(ctx, event)\n}\n\n// PostMessageRequest represents the request body for posting a message\ntype PostMessageRequest struct {\n\tTabId        string            `json:\"tabid,omitempty\"`\n\tBuilderId    string            `json:\"builderid,omitempty\"`\n\tBuilderAppId string            `json:\"builderappid,omitempty\"`\n\tChatID       string            `json:\"chatid\"`\n\tMsg          uctypes.AIMessage `json:\"msg\"`\n\tWidgetAccess bool              `json:\"widgetaccess,omitempty\"`\n\tAIMode       string            `json:\"aimode\"`\n}\n\nfunc WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) {\n\t// Only allow POST method\n\tif r.Method != http.MethodPost {\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\n\t// Parse request body\n\tvar req PostMessageRequest\n\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Invalid request body: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Validate chatid is present and is a UUID\n\tif req.ChatID == \"\" {\n\t\thttp.Error(w, \"chatid is required in request body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tif _, err := uuid.Parse(req.ChatID); err != nil {\n\t\thttp.Error(w, \"chatid must be a valid UUID\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Get RTInfo from TabId or BuilderId\n\tvar rtInfo *waveobj.ObjRTInfo\n\tif req.TabId != \"\" {\n\t\toref := waveobj.MakeORef(waveobj.OType_Tab, req.TabId)\n\t\trtInfo = wstore.GetRTInfo(oref)\n\t} else if req.BuilderId != \"\" {\n\t\toref := waveobj.MakeORef(waveobj.OType_Builder, req.BuilderId)\n\t\trtInfo = wstore.GetRTInfo(oref)\n\t}\n\tif rtInfo == nil {\n\t\trtInfo = &waveobj.ObjRTInfo{}\n\t}\n\n\t// Get WaveAI settings\n\tpremium := shouldUsePremium()\n\tbuilderMode := req.BuilderId != \"\"\n\tif req.AIMode == \"\" {\n\t\thttp.Error(w, \"aimode is required in request body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\taiOpts, err := getWaveAISettings(premium, builderMode, *rtInfo, req.AIMode)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"WaveAI configuration error: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Call the core WaveAIPostMessage function\n\tchatOpts := uctypes.WaveChatOpts{\n\t\tChatId:               req.ChatID,\n\t\tClientId:             wstore.GetClientId(),\n\t\tConfig:               *aiOpts,\n\t\tWidgetAccess:         req.WidgetAccess,\n\t\tAllowNativeWebSearch: true,\n\t\tBuilderId:            req.BuilderId,\n\t\tBuilderAppId:         req.BuilderAppId,\n\t}\n\tchatOpts.SystemPrompt = getSystemPrompt(chatOpts.Config.APIType, chatOpts.Config.Model, chatOpts.BuilderId != \"\", chatOpts.Config.HasCapability(uctypes.AICapabilityTools), chatOpts.WidgetAccess)\n\n\tif req.TabId != \"\" {\n\t\tchatOpts.TabStateGenerator = func() (string, []uctypes.ToolDefinition, string, error) {\n\t\t\ttabState, tabTools, err := GenerateTabStateAndTools(r.Context(), req.TabId, req.WidgetAccess, &chatOpts)\n\t\t\treturn tabState, tabTools, req.TabId, err\n\t\t}\n\t}\n\n\tif req.BuilderAppId != \"\" {\n\t\tchatOpts.BuilderAppGenerator = func() (string, string, string, error) {\n\t\t\treturn generateBuilderAppData(req.BuilderAppId)\n\t\t}\n\t}\n\n\tif req.BuilderAppId != \"\" {\n\t\tchatOpts.Tools = append(chatOpts.Tools,\n\t\t\tGetBuilderWriteAppFileToolDefinition(req.BuilderAppId, req.BuilderId),\n\t\t\tGetBuilderEditAppFileToolDefinition(req.BuilderAppId, req.BuilderId),\n\t\t\tGetBuilderListFilesToolDefinition(req.BuilderAppId),\n\t\t)\n\t}\n\n\t// Validate the message\n\tif err := req.Msg.Validate(); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Message validation failed: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Create SSE handler and set up streaming\n\tsseHandler := sse.MakeSSEHandlerCh(w, r.Context())\n\tdefer sseHandler.Close()\n\n\tif err := WaveAIPostMessageWrap(r.Context(), sseHandler, &req.Msg, chatOpts); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to post message: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n}\n\nfunc WaveAIGetChatHandler(w http.ResponseWriter, r *http.Request) {\n\t// Only allow GET method\n\tif r.Method != http.MethodGet {\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\n\t// Get chatid from URL parameters\n\tchatID := r.URL.Query().Get(\"chatid\")\n\tif chatID == \"\" {\n\t\thttp.Error(w, \"chatid parameter is required\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Validate chatid is a UUID\n\tif _, err := uuid.Parse(chatID); err != nil {\n\t\thttp.Error(w, \"chatid must be a valid UUID\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Get chat from store\n\tchat := chatstore.DefaultChatStore.Get(chatID)\n\tif chat == nil {\n\t\thttp.Error(w, \"chat not found\", http.StatusNotFound)\n\t\treturn\n\t}\n\n\t// Set response headers for JSON\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\n\t// Encode and return the chat\n\tif err := json.NewEncoder(w).Encode(chat); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to encode response: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n}\n\n// CreateWriteTextFileDiff generates a diff for write_text_file or edit_text_file tool calls.\n// Returns the original content, modified content, and any error.\n// For Anthropic, this returns an unimplemented error.\nfunc CreateWriteTextFileDiff(ctx context.Context, chatId string, toolCallId string) ([]byte, []byte, error) {\n\taiChat := chatstore.DefaultChatStore.Get(chatId)\n\tif aiChat == nil {\n\t\treturn nil, nil, fmt.Errorf(\"chat not found: %s\", chatId)\n\t}\n\n\tbackend, err := GetBackendByAPIType(aiChat.APIType)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tfuncCallInput := backend.GetFunctionCallInputByToolCallId(*aiChat, toolCallId)\n\tif funcCallInput == nil {\n\t\treturn nil, nil, fmt.Errorf(\"tool call not found: %s\", toolCallId)\n\t}\n\n\ttoolName := funcCallInput.Name\n\tif toolName != \"write_text_file\" && toolName != \"edit_text_file\" {\n\t\treturn nil, nil, fmt.Errorf(\"tool call %s is not a write_text_file or edit_text_file (got: %s)\", toolCallId, toolName)\n\t}\n\n\tvar backupFileName string\n\tif funcCallInput.ToolUseData != nil {\n\t\tbackupFileName = funcCallInput.ToolUseData.WriteBackupFileName\n\t}\n\n\tvar parsedArguments any\n\tif err := json.Unmarshal([]byte(funcCallInput.Arguments), &parsedArguments); err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to unmarshal arguments: %w\", err)\n\t}\n\n\tif toolName == \"edit_text_file\" {\n\t\toriginalContent, modifiedContent, err := EditTextFileDryRun(parsedArguments, backupFileName)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to generate diff: %w\", err)\n\t\t}\n\t\treturn originalContent, modifiedContent, nil\n\t}\n\n\tparams, err := parseWriteTextFileInput(parsedArguments)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to parse write_text_file input: %w\", err)\n\t}\n\n\tvar originalContent []byte\n\tif backupFileName != \"\" {\n\t\toriginalContent, err = os.ReadFile(backupFileName)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to read backup file: %w\", err)\n\t\t}\n\t} else {\n\t\texpandedPath, err := wavebase.ExpandHomeDir(params.Filename)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to expand path: %w\", err)\n\t\t}\n\t\toriginalContent, err = os.ReadFile(expandedPath)\n\t\tif err != nil && !os.IsNotExist(err) {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to read original file: %w\", err)\n\t\t}\n\t}\n\n\tmodifiedContent := []byte(params.Contents)\n\treturn originalContent, modifiedContent, nil\n}\n\ntype StaticFileInfo struct {\n\tName         string `json:\"name\"`\n\tSize         int64  `json:\"size\"`\n\tModified     string `json:\"modified\"`\n\tModifiedTime string `json:\"modified_time\"`\n}\n\nfunc generateBuilderAppData(appId string) (string, string, string, error) {\n\tappGoFile := \"\"\n\tfileData, err := waveappstore.ReadAppFile(appId, \"app.go\")\n\tif err == nil {\n\t\tappGoFile = string(fileData.Contents)\n\t}\n\n\tstaticFilesJSON := \"\"\n\tallFiles, err := waveappstore.ListAllAppFiles(appId)\n\tif err == nil {\n\t\tvar staticFiles []StaticFileInfo\n\t\tfor _, entry := range allFiles.Entries {\n\t\t\tif strings.HasPrefix(entry.Name, \"static/\") {\n\t\t\t\tstaticFiles = append(staticFiles, StaticFileInfo{\n\t\t\t\t\tName:         entry.Name,\n\t\t\t\t\tSize:         entry.Size,\n\t\t\t\t\tModified:     entry.Modified,\n\t\t\t\t\tModifiedTime: entry.ModifiedTime,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\tif len(staticFiles) > 0 {\n\t\t\tstaticFilesBytes, marshalErr := json.Marshal(staticFiles)\n\t\t\tif marshalErr == nil {\n\t\t\t\tstaticFilesJSON = string(staticFilesBytes)\n\t\t\t}\n\t\t}\n\t}\n\n\tplatformInfo := wavebase.GetSystemSummary()\n\tif currentUser, userErr := user.Current(); userErr == nil && currentUser.Username != \"\" {\n\t\tplatformInfo = fmt.Sprintf(\"Local Machine: %s, User: %s\", platformInfo, currentUser.Username)\n\t} else {\n\t\tplatformInfo = fmt.Sprintf(\"Local Machine: %s\", platformInfo)\n\t}\n\n\treturn appGoFile, staticFilesJSON, platformInfo, nil\n}\n"
  },
  {
    "path": "pkg/aiusechat/usechat_mode_test.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage aiusechat\n\nimport (\n\t\"testing\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n)\n\nfunc TestApplyProviderDefaultsGroq(t *testing.T) {\n\tconfig := wconfig.AIModeConfigType{\n\t\tProvider: uctypes.AIProvider_Groq,\n\t}\n\tapplyProviderDefaults(&config)\n\tif config.APIType != uctypes.APIType_OpenAIChat {\n\t\tt.Fatalf(\"expected API type %q, got %q\", uctypes.APIType_OpenAIChat, config.APIType)\n\t}\n\tif config.Endpoint != GroqChatEndpoint {\n\t\tt.Fatalf(\"expected endpoint %q, got %q\", GroqChatEndpoint, config.Endpoint)\n\t}\n\tif config.APITokenSecretName != GroqAPITokenSecretName {\n\t\tt.Fatalf(\"expected API token secret name %q, got %q\", GroqAPITokenSecretName, config.APITokenSecretName)\n\t}\n}\n\nfunc TestApplyProviderDefaultsKeepsProxyURL(t *testing.T) {\n\tconfig := wconfig.AIModeConfigType{\n\t\tProvider: uctypes.AIProvider_OpenAI,\n\t\tModel:    \"gpt-5-mini\",\n\t\tProxyURL: \"http://localhost:8080\",\n\t}\n\tapplyProviderDefaults(&config)\n\tif config.ProxyURL != \"http://localhost:8080\" {\n\t\tt.Fatalf(\"expected proxy URL to be preserved, got %q\", config.ProxyURL)\n\t}\n}\n"
  },
  {
    "path": "pkg/authkey/authkey.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage authkey\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n)\n\nvar authkey string\n\nconst WaveAuthKeyEnv = \"WAVETERM_AUTH_KEY\"\nconst AuthKeyHeader = \"X-AuthKey\"\n\nfunc ValidateIncomingRequest(r *http.Request) error {\n\treqAuthKey := r.Header.Get(AuthKeyHeader)\n\tif reqAuthKey == \"\" {\n\t\treturn fmt.Errorf(\"no x-authkey header\")\n\t}\n\tif reqAuthKey != GetAuthKey() {\n\t\treturn fmt.Errorf(\"x-authkey header is invalid\")\n\t}\n\treturn nil\n}\n\nfunc SetAuthKeyFromEnv() error {\n\tauthkey = os.Getenv(WaveAuthKeyEnv)\n\tif authkey == \"\" {\n\t\treturn fmt.Errorf(\"no auth key found in environment variables\")\n\t}\n\tos.Unsetenv(WaveAuthKeyEnv)\n\treturn nil\n}\n\nfunc GetAuthKey() string {\n\treturn authkey\n}\n"
  },
  {
    "path": "pkg/baseds/baseds.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// used for shared datastructures\npackage baseds\n\ntype LinkId int32\n\nconst NoLinkId = 0\n\ntype RpcInputChType struct {\n\tMsgBytes      []byte\n\tIngressLinkId LinkId\n}\n\ntype Badge struct {\n\tBadgeId   string  `json:\"badgeid\"` // must be a uuidv7\n\tIcon      string  `json:\"icon\"`\n\tColor     string  `json:\"color,omitempty\"`\n\tPriority  float64 `json:\"priority\"`\n\tPidLinked bool    `json:\"pidlinked,omitempty\"`\n}\n\ntype BadgeEvent struct {\n\tORef      string `json:\"oref\"`\n\tClear     bool   `json:\"clear,omitempty\"`\n\tClearAll  bool   `json:\"clearall,omitempty\"`\n\tClearById string `json:\"clearbyid,omitempty\"`\n\tBadge     *Badge `json:\"badge,omitempty\"`\n}\n"
  },
  {
    "path": "pkg/blockcontroller/.gitignore",
    "content": "*.old"
  },
  {
    "path": "pkg/blockcontroller/blockcontroller.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage blockcontroller\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/blocklogger\"\n\t\"github.com/wavetermdev/waveterm/pkg/filestore\"\n\t\"github.com/wavetermdev/waveterm/pkg/jobcontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/conncontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/ds\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wslconn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nconst (\n\tBlockController_Shell   = \"shell\"\n\tBlockController_Cmd     = \"cmd\"\n\tBlockController_Tsunami = \"tsunami\"\n)\n\nconst (\n\tStatus_Running = \"running\"\n\tStatus_Done    = \"done\"\n\tStatus_Init    = \"init\"\n)\n\nconst (\n\tDefaultTermMaxFileSize = 2 * 1024 * 1024\n\tDefaultHtmlMaxFileSize = 256 * 1024\n\tMaxInitScriptSize      = 50 * 1024\n)\n\nconst DefaultTimeout = 2 * time.Second\nconst DefaultGracefulKillWait = 400 * time.Millisecond\n\ntype BlockInputUnion struct {\n\tInputData []byte            `json:\"inputdata,omitempty\"`\n\tSigName   string            `json:\"signame,omitempty\"`\n\tTermSize  *waveobj.TermSize `json:\"termsize,omitempty\"`\n}\n\ntype BlockControllerRuntimeStatus struct {\n\tBlockId           string `json:\"blockid\"`\n\tVersion           int64  `json:\"version\"`\n\tShellProcStatus   string `json:\"shellprocstatus,omitempty\"`\n\tShellProcConnName string `json:\"shellprocconnname,omitempty\"`\n\tShellProcExitCode int    `json:\"shellprocexitcode\"`\n\tTsunamiPort       int    `json:\"tsunamiport,omitempty\"`\n}\n\n// Controller interface that all block controllers must implement\ntype Controller interface {\n\tStart(ctx context.Context, blockMeta waveobj.MetaMapType, rtOpts *waveobj.RuntimeOpts, force bool) error\n\tStop(graceful bool, newStatus string, destroy bool)\n\tGetRuntimeStatus() *BlockControllerRuntimeStatus // does not return nil\n\tGetConnName() string\n\tSendInput(input *BlockInputUnion) error\n}\n\n// Registry for all controllers\nvar (\n\tcontrollerRegistry  = make(map[string]Controller)\n\tregistryLock        sync.RWMutex\n\tblockResyncMutexMap = ds.MakeSyncMap[*sync.Mutex]()\n)\n\nfunc getBlockResyncMutex(blockId string) *sync.Mutex {\n\treturn blockResyncMutexMap.GetOrCreate(blockId, func() *sync.Mutex {\n\t\treturn &sync.Mutex{}\n\t})\n}\n\n// Registry operations\nfunc getController(blockId string) Controller {\n\tregistryLock.RLock()\n\tdefer registryLock.RUnlock()\n\treturn controllerRegistry[blockId]\n}\n\nfunc registerController(blockId string, controller Controller) {\n\tvar existingController Controller\n\n\tregistryLock.Lock()\n\texisting, exists := controllerRegistry[blockId]\n\tif exists {\n\t\texistingController = existing\n\t}\n\tcontrollerRegistry[blockId] = controller\n\tregistryLock.Unlock()\n\n\tif existingController != nil {\n\t\texistingController.Stop(false, Status_Done, true)\n\t\twstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, blockId))\n\t}\n}\n\nfunc deleteController(blockId string) {\n\tregistryLock.Lock()\n\tdefer registryLock.Unlock()\n\tdelete(controllerRegistry, blockId)\n}\n\nfunc getAllControllers() map[string]Controller {\n\tregistryLock.RLock()\n\tdefer registryLock.RUnlock()\n\t// Return a copy to avoid lock issues\n\tresult := make(map[string]Controller)\n\tfor k, v := range controllerRegistry {\n\t\tresult[k] = v\n\t}\n\treturn result\n}\n\nfunc InitBlockController() {\n\trpcClient := wshclient.GetBareRpcClient()\n\trpcClient.EventListener.On(wps.Event_BlockClose, handleBlockCloseEvent)\n\twshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{\n\t\tEvent:     wps.Event_BlockClose,\n\t\tAllScopes: true,\n\t}, nil)\n}\n\nfunc handleBlockCloseEvent(event *wps.WaveEvent) {\n\tblockId, ok := event.Data.(string)\n\tif !ok {\n\t\tlog.Printf(\"[blockclose] invalid event data type\")\n\t\treturn\n\t}\n\tgo DestroyBlockController(blockId)\n}\n\n// Public API Functions\n\nfunc ResyncController(ctx context.Context, tabId string, blockId string, rtOpts *waveobj.RuntimeOpts, force bool) error {\n\tif tabId == \"\" || blockId == \"\" {\n\t\treturn fmt.Errorf(\"invalid tabId or blockId passed to ResyncController\")\n\t}\n\n\tmu := getBlockResyncMutex(blockId)\n\tmu.Lock()\n\tdefer mu.Unlock()\n\n\tblockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting block: %w\", err)\n\t}\n\n\tcontrollerName := blockData.Meta.GetString(waveobj.MetaKey_Controller, \"\")\n\tconnName := blockData.Meta.GetString(waveobj.MetaKey_Connection, \"\")\n\n\t// Get existing controller\n\texisting := getController(blockId)\n\n\t// Check for connection change FIRST - always destroy on conn change\n\tif existing != nil {\n\t\texistingConnName := existing.GetConnName()\n\t\tif existingConnName != connName {\n\t\t\tlog.Printf(\"stopping blockcontroller %s due to conn change (from %q to %q)\\n\", blockId, existingConnName, connName)\n\t\t\tDestroyBlockController(blockId)\n\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t\texisting = nil\n\t\t}\n\t}\n\n\t// If no controller needed, stop existing if present\n\tif controllerName == \"\" {\n\t\tif existing != nil {\n\t\t\tDestroyBlockController(blockId)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// Determine if we should use DurableShellController vs ShellController\n\tshouldUseDurableShellController := controllerName == BlockController_Shell && jobcontroller.IsBlockIdTermDurable(blockId)\n\n\t// Check if we need to morph controller type\n\tif existing != nil {\n\t\tneedsReplace := false\n\n\t\tswitch existing.(type) {\n\t\tcase *ShellController:\n\t\t\tif controllerName != BlockController_Shell && controllerName != BlockController_Cmd {\n\t\t\t\tneedsReplace = true\n\t\t\t} else if shouldUseDurableShellController {\n\t\t\t\tneedsReplace = true\n\t\t\t}\n\t\tcase *DurableShellController:\n\t\t\tif !shouldUseDurableShellController {\n\t\t\t\tneedsReplace = true\n\t\t\t}\n\t\tcase *TsunamiController:\n\t\t\tif controllerName != BlockController_Tsunami {\n\t\t\t\tneedsReplace = true\n\t\t\t}\n\t\t}\n\n\t\tif needsReplace {\n\t\t\tlog.Printf(\"stopping blockcontroller %s due to controller type change\\n\", blockId)\n\t\t\tDestroyBlockController(blockId)\n\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t\texisting = nil\n\t\t}\n\t}\n\n\t// Force restart if requested\n\tif force && existing != nil {\n\t\tDestroyBlockController(blockId)\n\t\ttime.Sleep(100 * time.Millisecond)\n\t\texisting = nil\n\t}\n\n\t// Destroy done controllers before restarting\n\tif existing != nil {\n\t\tstatus := existing.GetRuntimeStatus()\n\t\tif status.ShellProcStatus == Status_Done {\n\t\t\tlog.Printf(\"destroying blockcontroller %s with done status before restart\\n\", blockId)\n\t\t\tDestroyBlockController(blockId)\n\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t\texisting = nil\n\t\t}\n\t}\n\n\t// Create or restart controller\n\tvar controller Controller\n\tif existing != nil {\n\t\tcontroller = existing\n\t} else {\n\t\t// Create new controller based on type\n\t\tswitch controllerName {\n\t\tcase BlockController_Shell, BlockController_Cmd:\n\t\t\tif shouldUseDurableShellController {\n\t\t\t\tcontroller = MakeDurableShellController(tabId, blockId, controllerName, connName)\n\t\t\t} else {\n\t\t\t\tcontroller = MakeShellController(tabId, blockId, controllerName, connName)\n\t\t\t}\n\t\t\tregisterController(blockId, controller)\n\n\t\tcase BlockController_Tsunami:\n\t\t\tcontroller = MakeTsunamiController(tabId, blockId, connName)\n\t\t\tregisterController(blockId, controller)\n\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"unknown controller type %q\", controllerName)\n\t\t}\n\t}\n\n\t// Check if we need to start/restart\n\tstatus := controller.GetRuntimeStatus()\n\tif status.ShellProcStatus == Status_Init {\n\t\t// For shell/cmd, check connection status first (for non-local connections)\n\t\tif controllerName == BlockController_Shell || controllerName == BlockController_Cmd {\n\t\t\tif !conncontroller.IsLocalConnName(connName) {\n\t\t\t\terr = CheckConnStatus(blockId)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"cannot start shellproc: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Start controller\n\t\terr = controller.Start(ctx, blockData.Meta, rtOpts, force)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error starting controller: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc GetBlockControllerRuntimeStatus(blockId string) *BlockControllerRuntimeStatus {\n\tcontroller := getController(blockId)\n\tif controller == nil {\n\t\treturn nil\n\t}\n\treturn controller.GetRuntimeStatus()\n}\n\nfunc DestroyBlockController(blockId string) {\n\tcontroller := getController(blockId)\n\tif controller == nil {\n\t\treturn\n\t}\n\tcontroller.Stop(true, Status_Done, true)\n\twstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, blockId))\n\tdeleteController(blockId)\n}\n\nfunc sendConnMonitorInputNotification(controller Controller) {\n\tconnName := controller.GetConnName()\n\tif connName == \"\" || conncontroller.IsLocalConnName(connName) || conncontroller.IsWslConnName(connName) {\n\t\treturn\n\t}\n\n\tconnOpts, parseErr := remote.ParseOpts(connName)\n\tif parseErr != nil {\n\t\treturn\n\t}\n\tsshConn := conncontroller.MaybeGetConn(connOpts)\n\tif sshConn != nil {\n\t\tmonitor := sshConn.GetMonitor()\n\t\tif monitor != nil {\n\t\t\tmonitor.NotifyInput()\n\t\t}\n\t}\n}\n\nfunc SendInput(blockId string, inputUnion *BlockInputUnion) error {\n\tcontroller := getController(blockId)\n\tif controller == nil {\n\t\treturn fmt.Errorf(\"no controller found for block %s\", blockId)\n\t}\n\tsendConnMonitorInputNotification(controller)\n\treturn controller.SendInput(inputUnion)\n}\n\n// only call this on shutdown\nfunc StopAllBlockControllersForShutdown() {\n\tcontrollers := getAllControllers()\n\tfor blockId, controller := range controllers {\n\t\tstatus := controller.GetRuntimeStatus()\n\t\tif status != nil && status.ShellProcStatus == Status_Running {\n\t\t\tgo func(id string, c Controller) {\n\t\t\t\tc.Stop(true, Status_Done, false)\n\t\t\t\twstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, id))\n\t\t\t}(blockId, controller)\n\t\t}\n\t}\n}\n\nfunc getBoolFromMeta(meta map[string]any, key string, def bool) bool {\n\tival, found := meta[key]\n\tif !found || ival == nil {\n\t\treturn def\n\t}\n\tif val, ok := ival.(bool); ok {\n\t\treturn val\n\t}\n\treturn def\n}\n\nfunc getTermSize(bdata *waveobj.Block) waveobj.TermSize {\n\tif bdata.RuntimeOpts != nil {\n\t\treturn bdata.RuntimeOpts.TermSize\n\t} else {\n\t\treturn waveobj.TermSize{\n\t\t\tRows: 25,\n\t\t\tCols: 80,\n\t\t}\n\t}\n}\n\nfunc HandleAppendBlockFile(blockId string, blockFile string, data []byte) error {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\terr := filestore.WFS.AppendData(ctx, blockId, blockFile, data)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error appending to blockfile: %w\", err)\n\t}\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent: wps.Event_BlockFile,\n\t\tScopes: []string{\n\t\t\twaveobj.MakeORef(waveobj.OType_Block, blockId).String(),\n\t\t},\n\t\tData: &wps.WSFileEventData{\n\t\t\tZoneId:   blockId,\n\t\t\tFileName: blockFile,\n\t\t\tFileOp:   wps.FileOp_Append,\n\t\t\tData64:   base64.StdEncoding.EncodeToString(data),\n\t\t},\n\t})\n\treturn nil\n}\n\nfunc HandleTruncateBlockFile(blockId string) error {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\terr := filestore.WFS.WriteFile(ctx, blockId, wavebase.BlockFile_Term, nil)\n\tif err == fs.ErrNotExist {\n\t\treturn nil\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error truncating blockfile: %w\", err)\n\t}\n\terr = filestore.WFS.DeleteFile(ctx, blockId, wavebase.BlockFile_Cache)\n\tif err == fs.ErrNotExist {\n\t\terr = nil\n\t}\n\tif err != nil {\n\t\tlog.Printf(\"error deleting cache file (continuing): %v\\n\", err)\n\t}\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent:  wps.Event_BlockFile,\n\t\tScopes: []string{waveobj.MakeORef(waveobj.OType_Block, blockId).String()},\n\t\tData: &wps.WSFileEventData{\n\t\t\tZoneId:   blockId,\n\t\t\tFileName: wavebase.BlockFile_Term,\n\t\t\tFileOp:   wps.FileOp_Truncate,\n\t\t},\n\t})\n\treturn nil\n\n}\n\nfunc debugLog(ctx context.Context, fmtStr string, args ...interface{}) {\n\tblocklogger.Infof(ctx, \"[conndebug] \"+fmtStr, args...)\n\tlog.Printf(fmtStr, args...)\n}\n\nfunc CheckConnStatus(blockId string) error {\n\tbdata, err := wstore.DBMustGet[*waveobj.Block](context.Background(), blockId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting block: %w\", err)\n\t}\n\tconnName := bdata.Meta.GetString(waveobj.MetaKey_Connection, \"\")\n\tif conncontroller.IsLocalConnName(connName) {\n\t\treturn nil\n\t}\n\tif strings.HasPrefix(connName, \"wsl://\") {\n\t\tdistroName := strings.TrimPrefix(connName, \"wsl://\")\n\t\tconn := wslconn.GetWslConn(distroName)\n\t\tconnStatus := conn.DeriveConnStatus()\n\t\tif connStatus.Status != conncontroller.Status_Connected {\n\t\t\treturn fmt.Errorf(\"not connected: %s\", connStatus.Status)\n\t\t}\n\t\treturn nil\n\t}\n\topts, err := remote.ParseOpts(connName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error parsing connection name: %w\", err)\n\t}\n\tconn := conncontroller.MaybeGetConn(opts)\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"no connection found\")\n\t}\n\tconnStatus := conn.DeriveConnStatus()\n\tif connStatus.Status != conncontroller.Status_Connected {\n\t\treturn fmt.Errorf(\"not connected: %s\", connStatus.Status)\n\t}\n\treturn nil\n}\n\nfunc makeSwapToken(ctx context.Context, logCtx context.Context, blockId string, blockMeta waveobj.MetaMapType, remoteName string, shellType string) *shellutil.TokenSwapEntry {\n\ttoken := &shellutil.TokenSwapEntry{\n\t\tToken: uuid.New().String(),\n\t\tEnv:   make(map[string]string),\n\t\tExp:   time.Now().Add(5 * time.Minute),\n\t}\n\ttoken.Env[\"TERM_PROGRAM\"] = \"waveterm\"\n\ttoken.Env[\"WAVETERM_BLOCKID\"] = blockId\n\ttoken.Env[\"WAVETERM_VERSION\"] = wavebase.WaveVersion\n\ttoken.Env[\"WAVETERM\"] = \"1\"\n\ttabId, err := wstore.DBFindTabForBlockId(ctx, blockId)\n\tif err != nil {\n\t\tlog.Printf(\"error finding tab for block: %v\\n\", err)\n\t} else {\n\t\ttoken.Env[\"WAVETERM_TABID\"] = tabId\n\t}\n\tif tabId != \"\" {\n\t\twsId, err := wstore.DBFindWorkspaceForTabId(ctx, tabId)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error finding workspace for tab: %v\\n\", err)\n\t\t} else {\n\t\t\ttoken.Env[\"WAVETERM_WORKSPACEID\"] = wsId\n\t\t}\n\t}\n\ttoken.Env[\"WAVETERM_CLIENTID\"] = wstore.GetClientId()\n\ttoken.Env[\"WAVETERM_CONN\"] = remoteName\n\tenvMap, err := resolveEnvMap(blockId, blockMeta, remoteName)\n\tif err != nil {\n\t\tlog.Printf(\"error resolving env map: %v\\n\", err)\n\t}\n\tfor k, v := range envMap {\n\t\ttoken.Env[k] = v\n\t}\n\ttoken.ScriptText = getCustomInitScript(logCtx, blockMeta, remoteName, shellType)\n\treturn token\n}\n"
  },
  {
    "path": "pkg/blockcontroller/durableshellcontroller.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage blockcontroller\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"log\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/jobcontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/conncontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/shellexec\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/utilds\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\ntype DurableShellController struct {\n\tLock *sync.Mutex\n\n\tControllerType string\n\tTabId          string\n\tBlockId        string\n\tConnName       string\n\tBlockDef       *waveobj.BlockDef\n\tVersionTs      utilds.VersionTs\n\n\tInputSessionId string // random uuid\n\tinputSeqNum    int    // monotonic sequence number for inputs, starts at 1\n\n\tJobId           string\n\tLastKnownStatus string\n}\n\nfunc MakeDurableShellController(tabId string, blockId string, controllerType string, connName string) Controller {\n\treturn &DurableShellController{\n\t\tLock:            &sync.Mutex{},\n\t\tControllerType:  controllerType,\n\t\tTabId:           tabId,\n\t\tBlockId:         blockId,\n\t\tConnName:        connName,\n\t\tLastKnownStatus: Status_Init,\n\t\tInputSessionId:  uuid.New().String(),\n\t}\n}\n\nfunc (dsc *DurableShellController) WithLock(f func()) {\n\tdsc.Lock.Lock()\n\tdefer dsc.Lock.Unlock()\n\tf()\n}\n\nfunc (dsc *DurableShellController) getJobId() string {\n\tdsc.Lock.Lock()\n\tdefer dsc.Lock.Unlock()\n\treturn dsc.JobId\n}\n\nfunc (dsc *DurableShellController) getNextInputSeq() (string, int) {\n\tdsc.Lock.Lock()\n\tdefer dsc.Lock.Unlock()\n\tdsc.inputSeqNum++\n\treturn dsc.InputSessionId, dsc.inputSeqNum\n}\n\nfunc (dsc *DurableShellController) getJobStatus_withlock() string {\n\tif dsc.JobId == \"\" {\n\t\tdsc.LastKnownStatus = Status_Init\n\t\treturn Status_Init\n\t}\n\tstatus, err := jobcontroller.GetJobManagerStatus(context.Background(), dsc.JobId)\n\tif err != nil {\n\t\tlog.Printf(\"error getting job status for %s: %v, using last known status: %s\", dsc.JobId, err, dsc.LastKnownStatus)\n\t\treturn dsc.LastKnownStatus\n\t}\n\tdsc.LastKnownStatus = status\n\treturn status\n}\n\nfunc (dsc *DurableShellController) getRuntimeStatus_withlock() BlockControllerRuntimeStatus {\n\tvar rtn BlockControllerRuntimeStatus\n\trtn.Version = dsc.VersionTs.GetVersionTs()\n\trtn.BlockId = dsc.BlockId\n\trtn.ShellProcStatus = dsc.getJobStatus_withlock()\n\trtn.ShellProcConnName = dsc.ConnName\n\treturn rtn\n}\n\nfunc (dsc *DurableShellController) GetRuntimeStatus() *BlockControllerRuntimeStatus {\n\tvar rtn BlockControllerRuntimeStatus\n\tdsc.WithLock(func() {\n\t\trtn = dsc.getRuntimeStatus_withlock()\n\t})\n\treturn &rtn\n}\n\nfunc (dsc *DurableShellController) GetConnName() string {\n\tdsc.Lock.Lock()\n\tdefer dsc.Lock.Unlock()\n\treturn dsc.ConnName\n}\n\nfunc (dsc *DurableShellController) sendUpdate_withlock() {\n\trtStatus := dsc.getRuntimeStatus_withlock()\n\tlog.Printf(\"sending blockcontroller update %#v\\n\", rtStatus)\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent: wps.Event_ControllerStatus,\n\t\tScopes: []string{\n\t\t\twaveobj.MakeORef(waveobj.OType_Tab, dsc.TabId).String(),\n\t\t\twaveobj.MakeORef(waveobj.OType_Block, dsc.BlockId).String(),\n\t\t},\n\t\tData: rtStatus,\n\t})\n}\n\n// Start initializes or reconnects to a durable shell for the block.\n// Logic:\n// - If block has no existing jobId: starts a new job and attaches it\n// - If block has existing jobId with running job manager: reconnects to existing job\n// - If block has existing jobId with non-running job manager:\n//   - force=true: detaches old job and starts new one\n//   - force=false: returns without starting (leaves block unstarted)\n//\n// After establishing jobId, ensures job connection is active (reconnects if needed)\nfunc (dsc *DurableShellController) Start(ctx context.Context, blockMeta waveobj.MetaMapType, rtOpts *waveobj.RuntimeOpts, force bool) error {\n\tblockData, err := wstore.DBMustGet[*waveobj.Block](ctx, dsc.BlockId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting block: %w\", err)\n\t}\n\n\tif conncontroller.IsLocalConnName(dsc.ConnName) {\n\t\treturn fmt.Errorf(\"durable shell controller requires a remote connection\")\n\t}\n\n\tvar jobId string\n\tif blockData.JobId != \"\" {\n\t\tstatus, err := jobcontroller.GetJobManagerStatus(ctx, blockData.JobId)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error getting job manager status: %w\", err)\n\t\t}\n\t\tif status == jobcontroller.JobManagerStatus_Running {\n\t\t\tjobId = blockData.JobId\n\t\t} else if !force {\n\t\t\tlog.Printf(\"block %q has jobId %s but manager is not running (status: %s), not starting (force=false)\\n\", dsc.BlockId, blockData.JobId, status)\n\t\t\treturn nil\n\t\t} else {\n\t\t\tlog.Printf(\"block %q has jobId %s but manager is not running (status: %s), starting new job (force=true)\\n\", dsc.BlockId, blockData.JobId, status)\n\t\t\t// intentionally leave jobId empty to trigger starting a new job below\n\t\t}\n\t}\n\n\tif jobId == \"\" {\n\t\tlog.Printf(\"block %q starting new durable shell\\n\", dsc.BlockId)\n\t\tnewJobId, err := dsc.startNewJob(ctx, blockMeta, dsc.ConnName, rtOpts)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to start new job: %w\", err)\n\t\t}\n\t\tjobId = newJobId\n\t}\n\n\tdsc.WithLock(func() {\n\t\tdsc.JobId = jobId\n\t\tdsc.sendUpdate_withlock()\n\t})\n\n\terr = jobcontroller.ReconnectJob(ctx, jobId, rtOpts)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to reconnect to job: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (dsc *DurableShellController) Stop(graceful bool, newStatus string, destroy bool) {\n\tif !destroy {\n\t\treturn\n\t}\n\tjobId := dsc.getJobId()\n\tif jobId == \"\" {\n\t\treturn\n\t}\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\tjobcontroller.TerminateAndDetachJob(ctx, jobId)\n}\n\nfunc (dsc *DurableShellController) SendInput(inputUnion *BlockInputUnion) error {\n\tif inputUnion == nil {\n\t\treturn nil\n\t}\n\tjobId := dsc.getJobId()\n\tif jobId == \"\" {\n\t\treturn fmt.Errorf(\"no job attached to controller\")\n\t}\n\tinputSessionId, seqNum := dsc.getNextInputSeq()\n\tdata := wshrpc.CommandJobInputData{\n\t\tJobId:          jobId,\n\t\tInputSessionId: inputSessionId,\n\t\tSeqNum:         seqNum,\n\t\tTermSize:       inputUnion.TermSize,\n\t\tSigName:        inputUnion.SigName,\n\t}\n\tif len(inputUnion.InputData) > 0 {\n\t\tdata.InputData64 = base64.StdEncoding.EncodeToString(inputUnion.InputData)\n\t}\n\treturn jobcontroller.SendInput(context.Background(), data)\n}\n\nfunc (dsc *DurableShellController) startNewJob(ctx context.Context, blockMeta waveobj.MetaMapType, connName string, rtOpts *waveobj.RuntimeOpts) (string, error) {\n\ttermSize := waveobj.TermSize{\n\t\tRows: shellutil.DefaultTermRows,\n\t\tCols: shellutil.DefaultTermCols,\n\t}\n\tif rtOpts != nil && rtOpts.TermSize.Rows > 0 && rtOpts.TermSize.Cols > 0 {\n\t\ttermSize = rtOpts.TermSize\n\t}\n\tcmdStr := blockMeta.GetString(waveobj.MetaKey_Cmd, \"\")\n\tcwd := blockMeta.GetString(waveobj.MetaKey_CmdCwd, \"\")\n\topts, err := remote.ParseOpts(connName)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"invalid ssh remote name (%s): %w\", connName, err)\n\t}\n\tconn := conncontroller.MaybeGetConn(opts)\n\tif conn == nil {\n\t\treturn \"\", fmt.Errorf(\"connection %q not found\", connName)\n\t}\n\tconnRoute := wshutil.MakeConnectionRouteId(connName)\n\tremoteInfo, err := wshclient.RemoteGetInfoCommand(wshclient.GetBareRpcClient(), &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000})\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to obtain remote info from connserver: %w\", err)\n\t}\n\tshellType := shellutil.GetShellTypeFromShellPath(remoteInfo.Shell)\n\tswapToken := makeSwapToken(ctx, ctx, dsc.BlockId, blockMeta, connName, shellType)\n\tsockName := wavebase.GetPersistentRemoteSockName(wstore.GetClientId())\n\trpcContext := wshrpc.RpcContext{\n\t\tProcRoute: true,\n\t\tSockName:  sockName,\n\t\tBlockId:   dsc.BlockId,\n\t\tConn:      connName,\n\t}\n\tjwtStr, err := wshutil.MakeClientJWTToken(rpcContext)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error making jwt token: %w\", err)\n\t}\n\tswapToken.RpcContext = &rpcContext\n\tswapToken.Env[wshutil.WaveJwtTokenVarName] = jwtStr\n\tcmdOpts := shellexec.CommandOptsType{\n\t\tInteractive: true,\n\t\tLogin:       true,\n\t\tCwd:         cwd,\n\t\tSwapToken:   swapToken,\n\t\tForceJwt:    blockMeta.GetBool(waveobj.MetaKey_CmdJwt, false),\n\t}\n\tjobId, err := shellexec.StartRemoteShellJob(ctx, ctx, termSize, cmdStr, cmdOpts, conn, dsc.BlockId)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to start durable shell: %w\", err)\n\t}\n\treturn jobId, nil\n}\n"
  },
  {
    "path": "pkg/blockcontroller/shellcontroller.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage blockcontroller\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"log\"\n\t\"os\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/blocklogger\"\n\t\"github.com/wavetermdev/waveterm/pkg/filestore\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/conncontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/shellexec\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/envutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/fileutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/utilds\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wslconn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nconst (\n\tConnType_Local = \"local\"\n\tConnType_Wsl   = \"wsl\"\n\tConnType_Ssh   = \"ssh\"\n)\n\nconst (\n\tLocalConnVariant_GitBash = \"gitbash\"\n)\n\ntype ShellController struct {\n\tLock *sync.Mutex\n\n\t// shared fields\n\tControllerType string\n\tTabId          string\n\tBlockId        string\n\tConnName       string\n\tBlockDef       *waveobj.BlockDef\n\tRunLock        *atomic.Bool\n\tProcStatus     string\n\tProcExitCode   int\n\tVersionTs      utilds.VersionTs\n\n\t// for shell/cmd\n\tShellProc    *shellexec.ShellProc\n\tShellInputCh chan *BlockInputUnion\n}\n\n// Constructor that returns the Controller interface\nfunc MakeShellController(tabId string, blockId string, controllerType string, connName string) Controller {\n\treturn &ShellController{\n\t\tLock:           &sync.Mutex{},\n\t\tControllerType: controllerType,\n\t\tTabId:          tabId,\n\t\tBlockId:        blockId,\n\t\tConnName:       connName,\n\t\tProcStatus:     Status_Init,\n\t\tRunLock:        &atomic.Bool{},\n\t}\n}\n\n// Implement Controller interface methods\n\nfunc (sc *ShellController) Start(ctx context.Context, blockMeta waveobj.MetaMapType, rtOpts *waveobj.RuntimeOpts, force bool) error {\n\t// Get the block data\n\tblockData, err := wstore.DBMustGet[*waveobj.Block](ctx, sc.BlockId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting block: %w\", err)\n\t}\n\n\t// Use the existing run method which handles all the start logic\n\tgo sc.run(ctx, blockData, blockData.Meta, rtOpts, force)\n\treturn nil\n}\n\nfunc (sc *ShellController) Stop(graceful bool, newStatus string, destroy bool) {\n\tsc.Lock.Lock()\n\tdefer sc.Lock.Unlock()\n\n\tif sc.ShellProc == nil || sc.ProcStatus == Status_Done || sc.ProcStatus == Status_Init {\n\t\tif newStatus != sc.ProcStatus {\n\t\t\tsc.ProcStatus = newStatus\n\t\t\tsc.sendUpdate_nolock()\n\t\t}\n\t\treturn\n\t}\n\n\tsc.ShellProc.Close()\n\tif graceful {\n\t\tdoneCh := sc.ShellProc.DoneCh\n\t\tsc.Lock.Unlock() // Unlock before waiting\n\t\t<-doneCh\n\t\tsc.Lock.Lock() // Re-lock after waiting\n\t}\n\n\t// Update status\n\tsc.ProcStatus = newStatus\n\tsc.sendUpdate_nolock()\n}\n\nfunc (sc *ShellController) getRuntimeStatus_nolock() BlockControllerRuntimeStatus {\n\tvar rtn BlockControllerRuntimeStatus\n\trtn.Version = sc.VersionTs.GetVersionTs()\n\trtn.BlockId = sc.BlockId\n\trtn.ShellProcStatus = sc.ProcStatus\n\trtn.ShellProcConnName = sc.ConnName\n\trtn.ShellProcExitCode = sc.ProcExitCode\n\treturn rtn\n}\n\nfunc (sc *ShellController) GetRuntimeStatus() *BlockControllerRuntimeStatus {\n\tvar rtn BlockControllerRuntimeStatus\n\tsc.WithLock(func() {\n\t\trtn = sc.getRuntimeStatus_nolock()\n\t})\n\treturn &rtn\n}\n\nfunc (sc *ShellController) GetConnName() string {\n\treturn sc.ConnName\n}\n\nfunc (sc *ShellController) SendInput(inputUnion *BlockInputUnion) error {\n\tvar shellInputCh chan *BlockInputUnion\n\tsc.WithLock(func() {\n\t\tshellInputCh = sc.ShellInputCh\n\t})\n\tif shellInputCh == nil {\n\t\treturn fmt.Errorf(\"no shell input chan\")\n\t}\n\tshellInputCh <- inputUnion\n\treturn nil\n}\n\nfunc (sc *ShellController) WithLock(f func()) {\n\tsc.Lock.Lock()\n\tdefer sc.Lock.Unlock()\n\tf()\n}\n\ntype RunShellOpts struct {\n\tTermSize waveobj.TermSize `json:\"termsize,omitempty\"`\n}\n\n// only call when holding the lock\nfunc (sc *ShellController) sendUpdate_nolock() {\n\trtStatus := sc.getRuntimeStatus_nolock()\n\tlog.Printf(\"sending blockcontroller update %#v\\n\", rtStatus)\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent: wps.Event_ControllerStatus,\n\t\tScopes: []string{\n\t\t\twaveobj.MakeORef(waveobj.OType_Tab, sc.TabId).String(),\n\t\t\twaveobj.MakeORef(waveobj.OType_Block, sc.BlockId).String(),\n\t\t},\n\t\tData: rtStatus,\n\t})\n}\n\nfunc (sc *ShellController) UpdateControllerAndSendUpdate(updateFn func() bool) {\n\tvar sendUpdate bool\n\tsc.WithLock(func() {\n\t\tsendUpdate = updateFn()\n\t})\n\tif sendUpdate {\n\t\trtStatus := sc.GetRuntimeStatus()\n\t\tlog.Printf(\"sending blockcontroller update %#v\\n\", rtStatus)\n\t\twps.Broker.Publish(wps.WaveEvent{\n\t\t\tEvent: wps.Event_ControllerStatus,\n\t\t\tScopes: []string{\n\t\t\t\twaveobj.MakeORef(waveobj.OType_Tab, sc.TabId).String(),\n\t\t\t\twaveobj.MakeORef(waveobj.OType_Block, sc.BlockId).String(),\n\t\t\t},\n\t\t\tData: rtStatus,\n\t\t})\n\t}\n}\n\nfunc (sc *ShellController) resetTerminalState(logCtx context.Context) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\twfile, statErr := filestore.WFS.Stat(ctx, sc.BlockId, wavebase.BlockFile_Term)\n\tif statErr == fs.ErrNotExist {\n\t\treturn\n\t}\n\tif statErr != nil {\n\t\tlog.Printf(\"error statting term file: %v\\n\", statErr)\n\t\treturn\n\t}\n\tif wfile.Size == 0 {\n\t\treturn\n\t}\n\tblocklogger.Debugf(logCtx, \"[conndebug] resetTerminalState: resetting terminal state\\n\")\n\tresetSeq := shellutil.GetTerminalResetSeq()\n\tresetSeq += \"\\r\\n\"\n\terr := HandleAppendBlockFile(sc.BlockId, wavebase.BlockFile_Term, []byte(resetSeq))\n\tif err != nil {\n\t\tlog.Printf(\"error appending to blockfile (terminal reset): %v\\n\", err)\n\t}\n}\n\nfunc (sc *ShellController) writeMutedMessageToTerminal(msg string) {\n\tif sc.BlockId == \"\" {\n\t\treturn\n\t}\n\tfullMsg := \"\\x1b[90m\" + msg + \"\\x1b[0m\\r\\n\"\n\terr := HandleAppendBlockFile(sc.BlockId, wavebase.BlockFile_Term, []byte(fullMsg))\n\tif err != nil {\n\t\tlog.Printf(\"error writing muted message to terminal (blockid=%s): %v\", sc.BlockId, err)\n\t}\n}\n\n// [All the other existing private methods remain exactly the same - I'm not including them all here for brevity, but they would all be copied over with sc. replacing bc. throughout]\n\nfunc (sc *ShellController) DoRunShellCommand(logCtx context.Context, rc *RunShellOpts, blockMeta waveobj.MetaMapType) error {\n\tblocklogger.Debugf(logCtx, \"[conndebug] DoRunShellCommand\\n\")\n\tshellProc, err := sc.setupAndStartShellProcess(logCtx, rc, blockMeta)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn sc.manageRunningShellProcess(shellProc, rc, blockMeta)\n}\n\n// [Continue with all other methods, replacing bc with sc throughout...]\n\nfunc (sc *ShellController) LockRunLock() bool {\n\trtn := sc.RunLock.CompareAndSwap(false, true)\n\tif rtn {\n\t\tlog.Printf(\"block %q run() lock\\n\", sc.BlockId)\n\t}\n\treturn rtn\n}\n\nfunc (sc *ShellController) UnlockRunLock() {\n\tsc.RunLock.Store(false)\n\tlog.Printf(\"block %q run() unlock\\n\", sc.BlockId)\n}\n\nfunc (sc *ShellController) run(logCtx context.Context, bdata *waveobj.Block, blockMeta map[string]any, rtOpts *waveobj.RuntimeOpts, force bool) {\n\tblocklogger.Debugf(logCtx, \"[conndebug] ShellController.run() %q\\n\", sc.BlockId)\n\trunningShellCommand := false\n\tok := sc.LockRunLock()\n\tif !ok {\n\t\tlog.Printf(\"block %q is already executing run()\\n\", sc.BlockId)\n\t\treturn\n\t}\n\tdefer func() {\n\t\tif !runningShellCommand {\n\t\t\tsc.UnlockRunLock()\n\t\t}\n\t}()\n\tcurStatus := sc.GetRuntimeStatus()\n\tcontrollerName := bdata.Meta.GetString(waveobj.MetaKey_Controller, \"\")\n\tif controllerName != BlockController_Shell && controllerName != BlockController_Cmd {\n\t\tlog.Printf(\"unknown controller %q\\n\", controllerName)\n\t\treturn\n\t}\n\trunOnce := getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdRunOnce, false)\n\trunOnStart := getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdRunOnStart, true)\n\tif ((runOnStart || runOnce) && curStatus.ShellProcStatus == Status_Init) || force {\n\t\tif getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdClearOnStart, false) {\n\t\t\terr := HandleTruncateBlockFile(sc.BlockId)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"error truncating term blockfile: %v\\n\", err)\n\t\t\t}\n\t\t}\n\t\tif runOnce {\n\t\t\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\t\t\tdefer cancelFn()\n\t\t\tmetaUpdate := map[string]any{\n\t\t\t\twaveobj.MetaKey_CmdRunOnce:    false,\n\t\t\t\twaveobj.MetaKey_CmdRunOnStart: false,\n\t\t\t}\n\t\t\terr := wstore.UpdateObjectMeta(ctx, waveobj.MakeORef(waveobj.OType_Block, sc.BlockId), metaUpdate, false)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"error updating block meta (in blockcontroller.run): %v\\n\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\trunningShellCommand = true\n\t\tgo func() {\n\t\t\tdefer func() {\n\t\t\t\tpanichandler.PanicHandler(\"blockcontroller:run-shell-command\", recover())\n\t\t\t}()\n\t\t\tdefer sc.UnlockRunLock()\n\t\t\tvar termSize waveobj.TermSize\n\t\t\tif rtOpts != nil {\n\t\t\t\ttermSize = rtOpts.TermSize\n\t\t\t} else {\n\t\t\t\ttermSize = getTermSize(bdata)\n\t\t\t}\n\t\t\terr := sc.DoRunShellCommand(logCtx, &RunShellOpts{TermSize: termSize}, bdata.Meta)\n\t\t\tif err != nil {\n\t\t\t\tdebugLog(logCtx, \"error running shell: %v\\n\", err)\n\t\t\t}\n\t\t}()\n\t}\n}\n\n// [Include all the remaining private methods with bc replaced by sc]\n\ntype ConnUnion struct {\n\tConnName   string\n\tConnType   string\n\tSshConn    *conncontroller.SSHConn\n\tWslConn    *wslconn.WslConn\n\tWshEnabled bool\n\tShellPath  string\n\tShellOpts  []string\n\tShellType  string\n\tHomeDir    string\n}\n\nfunc (bc *ShellController) getConnUnion(logCtx context.Context, remoteName string, blockMeta waveobj.MetaMapType) (ConnUnion, error) {\n\trtn := ConnUnion{ConnName: remoteName}\n\twshEnabled := !blockMeta.GetBool(waveobj.MetaKey_CmdNoWsh, false)\n\tif strings.HasPrefix(remoteName, \"wsl://\") {\n\t\twslName := strings.TrimPrefix(remoteName, \"wsl://\")\n\t\twslConn := wslconn.GetWslConn(wslName)\n\t\tif wslConn == nil {\n\t\t\treturn ConnUnion{}, fmt.Errorf(\"wsl connection not found: %s\", remoteName)\n\t\t}\n\t\tconnStatus := wslConn.DeriveConnStatus()\n\t\tif connStatus.Status != conncontroller.Status_Connected {\n\t\t\treturn ConnUnion{}, fmt.Errorf(\"wsl connection %s not connected, cannot start shellproc\", remoteName)\n\t\t}\n\t\trtn.ConnType = ConnType_Wsl\n\t\trtn.WslConn = wslConn\n\t\trtn.WshEnabled = wshEnabled && wslConn.WshEnabled.Load()\n\t} else if conncontroller.IsLocalConnName(remoteName) {\n\t\trtn.ConnType = ConnType_Local\n\t\trtn.WshEnabled = wshEnabled\n\t} else {\n\t\topts, err := remote.ParseOpts(remoteName)\n\t\tif err != nil {\n\t\t\treturn ConnUnion{}, fmt.Errorf(\"invalid ssh remote name (%s): %w\", remoteName, err)\n\t\t}\n\t\tconn := conncontroller.MaybeGetConn(opts)\n\t\tif conn == nil {\n\t\t\treturn ConnUnion{}, fmt.Errorf(\"ssh connection not found: %s\", remoteName)\n\t\t}\n\t\tconnStatus := conn.DeriveConnStatus()\n\t\tif connStatus.Status != conncontroller.Status_Connected {\n\t\t\treturn ConnUnion{}, fmt.Errorf(\"ssh connection %s not connected, cannot start shellproc\", remoteName)\n\t\t}\n\t\trtn.ConnType = ConnType_Ssh\n\t\trtn.SshConn = conn\n\t\trtn.WshEnabled = wshEnabled && conn.WshEnabled.Load()\n\t}\n\terr := rtn.getRemoteInfoAndShellType(blockMeta)\n\tif err != nil {\n\t\treturn ConnUnion{}, err\n\t}\n\treturn rtn, nil\n}\n\nfunc (bc *ShellController) setupAndStartShellProcess(logCtx context.Context, rc *RunShellOpts, blockMeta waveobj.MetaMapType) (*shellexec.ShellProc, error) {\n\t// create a circular blockfile for the output\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\tfsErr := filestore.WFS.MakeFile(ctx, bc.BlockId, wavebase.BlockFile_Term, nil, wshrpc.FileOpts{MaxSize: DefaultTermMaxFileSize, Circular: true})\n\tif fsErr != nil && fsErr != fs.ErrExist {\n\t\treturn nil, fmt.Errorf(\"error creating blockfile: %w\", fsErr)\n\t}\n\tif fsErr == fs.ErrExist {\n\t\t// reset the terminal state\n\t\tbc.resetTerminalState(logCtx)\n\t}\n\tbcInitStatus := bc.GetRuntimeStatus()\n\tif bcInitStatus.ShellProcStatus == Status_Running {\n\t\treturn nil, nil\n\t}\n\t// TODO better sync here (don't let two starts happen at the same times)\n\tremoteName := blockMeta.GetString(waveobj.MetaKey_Connection, \"\")\n\tconnUnion, err := bc.getConnUnion(logCtx, remoteName, blockMeta)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tblocklogger.Infof(logCtx, \"[conndebug] remoteName: %q, connType: %s, wshEnabled: %v, shell: %q, shellType: %s\\n\", remoteName, connUnion.ConnType, connUnion.WshEnabled, connUnion.ShellPath, connUnion.ShellType)\n\tvar cmdStr string\n\tvar cmdOpts shellexec.CommandOptsType\n\tif bc.ControllerType == BlockController_Shell {\n\t\tcmdOpts.Interactive = true\n\t\tcmdOpts.Login = true\n\t\tcmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, \"\")\n\t\tif cmdOpts.Cwd != \"\" {\n\t\t\tcwdPath, err := wavebase.ExpandHomeDir(cmdOpts.Cwd)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tcmdOpts.Cwd = cwdPath\n\t\t}\n\t} else if bc.ControllerType == BlockController_Cmd {\n\t\tvar cmdOptsPtr *shellexec.CommandOptsType\n\t\tcmdStr, cmdOptsPtr, err = createCmdStrAndOpts(bc.BlockId, blockMeta, remoteName)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcmdOpts = *cmdOptsPtr\n\t} else {\n\t\treturn nil, fmt.Errorf(\"unknown controller type %q\", bc.ControllerType)\n\t}\n\tvar shellProc *shellexec.ShellProc\n\tswapToken := makeSwapToken(ctx, logCtx, bc.BlockId, blockMeta, remoteName, connUnion.ShellType)\n\tcmdOpts.SwapToken = swapToken\n\tblocklogger.Debugf(logCtx, \"[conndebug] created swaptoken: %s\\n\", swapToken.Token)\n\tif connUnion.ConnType == ConnType_Wsl {\n\t\twslConn := connUnion.WslConn\n\t\tif !connUnion.WshEnabled {\n\t\t\tshellProc, err = shellexec.StartWslShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, wslConn)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t} else {\n\t\t\tsockName := wslConn.GetDomainSocketName()\n\t\t\trpcContext := wshrpc.RpcContext{\n\t\t\t\tProcRoute: true,\n\t\t\t\tSockName:  sockName,\n\t\t\t\tBlockId:   bc.BlockId,\n\t\t\t\tConn:      wslConn.GetName(),\n\t\t\t}\n\t\t\tjwtStr, err := wshutil.MakeClientJWTToken(rpcContext)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error making jwt token: %w\", err)\n\t\t\t}\n\t\t\tswapToken.RpcContext = &rpcContext\n\t\t\tswapToken.Env[wshutil.WaveJwtTokenVarName] = jwtStr\n\t\t\tshellProc, err = shellexec.StartWslShellProc(ctx, rc.TermSize, cmdStr, cmdOpts, wslConn)\n\t\t\tif err != nil {\n\t\t\t\twslConn.SetWshError(err)\n\t\t\t\twslConn.WshEnabled.Store(false)\n\t\t\t\tblocklogger.Infof(logCtx, \"[conndebug] error starting wsl shell proc with wsh: %v\\n\", err)\n\t\t\t\tblocklogger.Infof(logCtx, \"[conndebug] attempting install without wsh\\n\")\n\t\t\t\tshellProc, err = shellexec.StartWslShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, wslConn)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} else if connUnion.ConnType == ConnType_Ssh {\n\t\tconn := connUnion.SshConn\n\t\tif !connUnion.WshEnabled {\n\t\t\tshellProc, err = shellexec.StartRemoteShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, conn)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t} else {\n\t\t\tsockName := conn.GetDomainSocketName()\n\t\t\trpcContext := wshrpc.RpcContext{\n\t\t\t\tProcRoute: true,\n\t\t\t\tSockName:  sockName,\n\t\t\t\tBlockId:   bc.BlockId,\n\t\t\t\tConn:      conn.Opts.String(),\n\t\t\t}\n\t\t\tjwtStr, err := wshutil.MakeClientJWTToken(rpcContext)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error making jwt token: %w\", err)\n\t\t\t}\n\t\t\tswapToken.RpcContext = &rpcContext\n\t\t\tswapToken.Env[wshutil.WaveJwtTokenVarName] = jwtStr\n\t\t\tshellProc, err = shellexec.StartRemoteShellProc(ctx, logCtx, rc.TermSize, cmdStr, cmdOpts, conn)\n\t\t\tif err != nil {\n\t\t\t\tconn.SetWshError(err)\n\t\t\t\tconn.WshEnabled.Store(false)\n\t\t\t\tblocklogger.Infof(logCtx, \"[conndebug] error starting remote shell proc with wsh: %v\\n\", err)\n\t\t\t\tblocklogger.Infof(logCtx, \"[conndebug] attempting install without wsh\\n\")\n\t\t\t\tshellProc, err = shellexec.StartRemoteShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, conn)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} else if connUnion.ConnType == ConnType_Local {\n\t\tif connUnion.WshEnabled {\n\t\t\tsockName := wavebase.GetDomainSocketName()\n\t\t\trpcContext := wshrpc.RpcContext{\n\t\t\t\tProcRoute: true,\n\t\t\t\tSockName:  sockName,\n\t\t\t\tBlockId:   bc.BlockId,\n\t\t\t}\n\t\t\tjwtStr, err := wshutil.MakeClientJWTToken(rpcContext)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error making jwt token: %w\", err)\n\t\t\t}\n\t\t\tswapToken.RpcContext = &rpcContext\n\t\t\tswapToken.Env[wshutil.WaveJwtTokenVarName] = jwtStr\n\t\t}\n\t\tcmdOpts.ShellPath = connUnion.ShellPath\n\t\tcmdOpts.ShellOpts = getLocalShellOpts(blockMeta)\n\t\tshellProc, err = shellexec.StartLocalShellProc(logCtx, rc.TermSize, cmdStr, cmdOpts, remoteName)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t} else {\n\t\treturn nil, fmt.Errorf(\"unknown connection type for conn %q: %s\", remoteName, connUnion.ConnType)\n\t}\n\tbc.UpdateControllerAndSendUpdate(func() bool {\n\t\tbc.ShellProc = shellProc\n\t\tbc.ProcStatus = Status_Running\n\t\treturn true\n\t})\n\treturn shellProc, nil\n}\n\nfunc (bc *ShellController) manageRunningShellProcess(shellProc *shellexec.ShellProc, rc *RunShellOpts, blockMeta waveobj.MetaMapType) error {\n\tshellInputCh := make(chan *BlockInputUnion, 32)\n\tbc.ShellInputCh = shellInputCh\n\n\tgo func() {\n\t\t// handles regular output from the pty (goes to the blockfile and xterm)\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"blockcontroller:shellproc-pty-read-loop\", recover())\n\t\t}()\n\t\tdefer func() {\n\t\t\tlog.Printf(\"[shellproc] pty-read loop done\\n\")\n\t\t\tshellProc.Close()\n\t\t\tbc.WithLock(func() {\n\t\t\t\t// so no other events are sent\n\t\t\t\tbc.ShellInputCh = nil\n\t\t\t})\n\t\t\tshellProc.Cmd.Wait()\n\t\t\texitCode := shellProc.Cmd.ExitCode()\n\t\t\tblockData := bc.getBlockData_noErr()\n\t\t\tif blockData != nil && blockData.Meta.GetString(waveobj.MetaKey_Controller, \"\") == BlockController_Cmd {\n\t\t\t\ttermMsg := fmt.Sprintf(\"\\r\\nprocess finished with exit code = %d\\r\\n\\r\\n\", exitCode)\n\t\t\t\tHandleAppendBlockFile(bc.BlockId, wavebase.BlockFile_Term, []byte(termMsg))\n\t\t\t}\n\t\t\t// to stop the inputCh loop\n\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t\tclose(shellInputCh) // don't use bc.ShellInputCh (it's nil)\n\t\t}()\n\t\tbuf := make([]byte, 4096)\n\t\tfor {\n\t\t\tnr, err := shellProc.Cmd.Read(buf)\n\t\t\tif nr > 0 {\n\t\t\t\terr := HandleAppendBlockFile(bc.BlockId, wavebase.BlockFile_Term, buf[:nr])\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Printf(\"error appending to blockfile: %v\\n\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif err == io.EOF {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"error reading from shell: %v\\n\", err)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}()\n\tgo func() {\n\t\t// handles input from the shellInputCh, sent to pty\n\t\t// use shellInputCh instead of bc.ShellInputCh (because we want to be attached to *this* ch.  bc.ShellInputCh can be updated)\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"blockcontroller:shellproc-input-loop\", recover())\n\t\t}()\n\t\tfor ic := range shellInputCh {\n\t\t\tif len(ic.InputData) > 0 {\n\t\t\t\tshellProc.Cmd.Write(ic.InputData)\n\t\t\t}\n\t\t\tif ic.TermSize != nil {\n\t\t\t\tupdateTermSize(shellProc, bc.BlockId, *ic.TermSize)\n\t\t\t}\n\t\t}\n\t}()\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"blockcontroller:shellproc-wait-loop\", recover())\n\t\t}()\n\t\t// wait for the shell to finish\n\t\tvar exitCode int\n\t\tdefer func() {\n\t\t\tbc.UpdateControllerAndSendUpdate(func() bool {\n\t\t\t\tif bc.ProcStatus == Status_Running {\n\t\t\t\t\tbc.ProcStatus = Status_Done\n\t\t\t\t}\n\t\t\t\tbc.ProcExitCode = exitCode\n\t\t\t\treturn true\n\t\t\t})\n\t\t\tlog.Printf(\"[shellproc] shell process wait loop done\\n\")\n\t\t}()\n\t\twaitErr := shellProc.Cmd.Wait()\n\t\texitCode = shellProc.Cmd.ExitCode()\n\t\tshellProc.SetWaitErrorAndSignalDone(waitErr)\n\t\tbc.resetTerminalState(context.Background())\n\t\texitSignal := shellProc.Cmd.ExitSignal()\n\t\tvar baseMsg string\n\t\tif bc.ControllerType == BlockController_Shell {\n\t\t\tbaseMsg = \"shell terminated\"\n\t\t} else {\n\t\t\tbaseMsg = \"command exited\"\n\t\t}\n\t\tmsg := baseMsg\n\t\tif exitSignal != \"\" {\n\t\t\tmsg = fmt.Sprintf(\"%s (signal %s)\", baseMsg, exitSignal)\n\t\t} else if exitCode != 0 {\n\t\t\tmsg = fmt.Sprintf(\"%s (exit code %d)\", baseMsg, exitCode)\n\t\t}\n\t\tbc.writeMutedMessageToTerminal(\"[\" + msg + \"]\")\n\t\tgo checkCloseOnExit(bc.BlockId, exitCode)\n\t}()\n\treturn nil\n}\n\nfunc (union *ConnUnion) getRemoteInfoAndShellType(blockMeta waveobj.MetaMapType) error {\n\tif !union.WshEnabled {\n\t\treturn nil\n\t}\n\tif union.ConnType == ConnType_Ssh || union.ConnType == ConnType_Wsl {\n\t\tconnRoute := wshutil.MakeConnectionRouteId(union.ConnName)\n\t\tremoteInfo, err := wshclient.RemoteGetInfoCommand(wshclient.GetBareRpcClient(), &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000})\n\t\tif err != nil {\n\t\t\t// weird error, could flip the wshEnabled flag and allow it to go forward, but the connection should have already been vetted\n\t\t\treturn fmt.Errorf(\"unable to obtain remote info from connserver: %w\", err)\n\t\t}\n\t\t// TODO allow overriding remote shell path\n\t\tunion.ShellPath = remoteInfo.Shell\n\t\tunion.HomeDir = remoteInfo.HomeDir\n\t} else {\n\t\tshellPath, err := getLocalShellPath(blockMeta)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tunion.ShellPath = shellPath\n\t\tunion.HomeDir = wavebase.GetHomeDir()\n\t}\n\tunion.ShellType = shellutil.GetShellTypeFromShellPath(union.ShellPath)\n\treturn nil\n}\n\nfunc checkCloseOnExit(blockId string, exitCode int) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tblockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)\n\tif err != nil {\n\t\tlog.Printf(\"error getting block data: %v\\n\", err)\n\t\treturn\n\t}\n\tcloseOnExit := blockData.Meta.GetBool(waveobj.MetaKey_CmdCloseOnExit, false)\n\tcloseOnExitForce := blockData.Meta.GetBool(waveobj.MetaKey_CmdCloseOnExitForce, false)\n\tif !closeOnExitForce && !(closeOnExit && exitCode == 0) {\n\t\treturn\n\t}\n\tdelayMs := blockData.Meta.GetFloat(waveobj.MetaKey_CmdCloseOnExitDelay, 2000)\n\tif delayMs < 0 {\n\t\tdelayMs = 0\n\t}\n\ttime.Sleep(time.Duration(delayMs) * time.Millisecond)\n\trpcClient := wshclient.GetBareRpcClient()\n\terr = wshclient.DeleteBlockCommand(rpcClient, wshrpc.CommandDeleteBlockData{BlockId: blockId}, nil)\n\tif err != nil {\n\t\tlog.Printf(\"error deleting block data (close on exit): %v\\n\", err)\n\t}\n}\n\nfunc getLocalShellPath(blockMeta waveobj.MetaMapType) (string, error) {\n\tshellPath := blockMeta.GetString(waveobj.MetaKey_TermLocalShellPath, \"\")\n\tif shellPath != \"\" {\n\t\treturn shellPath, nil\n\t}\n\n\tconnName := blockMeta.GetString(waveobj.MetaKey_Connection, \"\")\n\tif strings.HasPrefix(connName, \"local:\") {\n\t\tvariant := strings.TrimPrefix(connName, \"local:\")\n\t\tif variant == LocalConnVariant_GitBash {\n\t\t\tif runtime.GOOS != \"windows\" {\n\t\t\t\treturn \"\", fmt.Errorf(\"connection \\\"local:gitbash\\\" is only supported on Windows\")\n\t\t\t}\n\t\t\tfullConfig := wconfig.GetWatcher().GetFullConfig()\n\t\t\tgitBashPath := shellutil.FindGitBash(&fullConfig, false)\n\t\t\tif gitBashPath == \"\" {\n\t\t\t\treturn \"\", fmt.Errorf(\"connection \\\"local:gitbash\\\": git bash not found on this system, please install Git for Windows or set term:localshellpath to specify the git bash location\")\n\t\t\t}\n\t\t\treturn gitBashPath, nil\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"unsupported local connection type: %q\", connName)\n\t}\n\n\tsettings := wconfig.GetWatcher().GetFullConfig().Settings\n\tif settings.TermLocalShellPath != \"\" {\n\t\treturn settings.TermLocalShellPath, nil\n\t}\n\treturn shellutil.DetectLocalShellPath(), nil\n}\n\nfunc getLocalShellOpts(blockMeta waveobj.MetaMapType) []string {\n\tif blockMeta.HasKey(waveobj.MetaKey_TermLocalShellOpts) {\n\t\topts := blockMeta.GetStringList(waveobj.MetaKey_TermLocalShellOpts)\n\t\treturn append([]string{}, opts...)\n\t}\n\tsettings := wconfig.GetWatcher().GetFullConfig().Settings\n\tif len(settings.TermLocalShellOpts) > 0 {\n\t\treturn append([]string{}, settings.TermLocalShellOpts...)\n\t}\n\treturn nil\n}\n\n// for \"cmd\" type blocks\nfunc createCmdStrAndOpts(blockId string, blockMeta waveobj.MetaMapType, connName string) (string, *shellexec.CommandOptsType, error) {\n\tvar cmdStr string\n\tvar cmdOpts shellexec.CommandOptsType\n\tcmdStr = blockMeta.GetString(waveobj.MetaKey_Cmd, \"\")\n\tif cmdStr == \"\" {\n\t\treturn \"\", nil, fmt.Errorf(\"missing cmd in block meta\")\n\t}\n\tcmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, \"\")\n\tif cmdOpts.Cwd != \"\" {\n\t\tcwdPath, err := wavebase.ExpandHomeDir(cmdOpts.Cwd)\n\t\tif err != nil {\n\t\t\treturn \"\", nil, err\n\t\t}\n\t\tcmdOpts.Cwd = cwdPath\n\t}\n\tuseShell := blockMeta.GetBool(waveobj.MetaKey_CmdShell, true)\n\tif !useShell {\n\t\tif strings.Contains(cmdStr, \" \") {\n\t\t\treturn \"\", nil, fmt.Errorf(\"cmd should not have spaces if cmd:shell is false (use cmd:args)\")\n\t\t}\n\t\tcmdArgs := blockMeta.GetStringList(waveobj.MetaKey_CmdArgs)\n\t\t// shell escape the args\n\t\tfor _, arg := range cmdArgs {\n\t\t\tcmdStr = cmdStr + \" \" + utilfn.ShellQuote(arg, false, -1)\n\t\t}\n\t}\n\tcmdOpts.ForceJwt = blockMeta.GetBool(waveobj.MetaKey_CmdJwt, false)\n\treturn cmdStr, &cmdOpts, nil\n}\n\nfunc (bc *ShellController) getBlockData_noErr() *waveobj.Block {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tblockData, err := wstore.DBGet[*waveobj.Block](ctx, bc.BlockId)\n\tif err != nil {\n\t\tlog.Printf(\"error getting block data (getBlockData_noErr): %v\\n\", err)\n\t\treturn nil\n\t}\n\treturn blockData\n}\n\nfunc resolveEnvMap(blockId string, blockMeta waveobj.MetaMapType, connName string) (map[string]string, error) {\n\trtn := make(map[string]string)\n\tconfig := wconfig.GetWatcher().GetFullConfig()\n\tconnKeywords := config.Connections[connName]\n\tckEnv := connKeywords.CmdEnv\n\tfor k, v := range ckEnv {\n\t\trtn[k] = v\n\t}\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\t_, envFileData, err := filestore.WFS.ReadFile(ctx, blockId, wavebase.BlockFile_Env)\n\tif err == fs.ErrNotExist {\n\t\terr = nil\n\t}\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error reading command env file: %w\", err)\n\t}\n\tif len(envFileData) > 0 {\n\t\tenvMap := envutil.EnvToMap(string(envFileData))\n\t\tfor k, v := range envMap {\n\t\t\trtn[k] = v\n\t\t}\n\t}\n\tcmdEnv := blockMeta.GetStringMap(waveobj.MetaKey_CmdEnv, true)\n\tfor k, v := range cmdEnv {\n\t\tif v == waveobj.MetaMap_DeleteSentinel {\n\t\t\tdelete(rtn, k)\n\t\t\tcontinue\n\t\t}\n\t\trtn[k] = v\n\t}\n\tconnEnv := blockMeta.GetConnectionOverride(connName).GetStringMap(waveobj.MetaKey_CmdEnv, true)\n\tfor k, v := range connEnv {\n\t\tif v == waveobj.MetaMap_DeleteSentinel {\n\t\t\tdelete(rtn, k)\n\t\t\tcontinue\n\t\t}\n\t\trtn[k] = v\n\t}\n\treturn rtn, nil\n}\n\nfunc getCustomInitScriptKeyCascade(shellType string) []string {\n\tif shellType == \"bash\" {\n\t\treturn []string{waveobj.MetaKey_CmdInitScriptBash, waveobj.MetaKey_CmdInitScriptSh, waveobj.MetaKey_CmdInitScript}\n\t}\n\tif shellType == \"zsh\" {\n\t\treturn []string{waveobj.MetaKey_CmdInitScriptZsh, waveobj.MetaKey_CmdInitScriptSh, waveobj.MetaKey_CmdInitScript}\n\t}\n\tif shellType == \"pwsh\" {\n\t\treturn []string{waveobj.MetaKey_CmdInitScriptPwsh, waveobj.MetaKey_CmdInitScript}\n\t}\n\tif shellType == \"fish\" {\n\t\treturn []string{waveobj.MetaKey_CmdInitScriptFish, waveobj.MetaKey_CmdInitScript}\n\t}\n\treturn []string{waveobj.MetaKey_CmdInitScript}\n}\n\nfunc getCustomInitScript(logCtx context.Context, meta waveobj.MetaMapType, connName string, shellType string) string {\n\tinitScriptVal, metaKeyName := getCustomInitScriptValue(meta, connName, shellType)\n\tif initScriptVal == \"\" {\n\t\treturn \"\"\n\t}\n\tif !fileutil.IsInitScriptPath(initScriptVal) {\n\t\tblocklogger.Infof(logCtx, \"[conndebug] inline initScript (size=%d) found in meta key: %s\\n\", len(initScriptVal), metaKeyName)\n\t\treturn initScriptVal\n\t}\n\tblocklogger.Infof(logCtx, \"[conndebug] initScript detected as a file %q from meta key: %s\\n\", initScriptVal, metaKeyName)\n\tinitScriptVal, err := wavebase.ExpandHomeDir(initScriptVal)\n\tif err != nil {\n\t\tblocklogger.Infof(logCtx, \"[conndebug] cannot expand home dir in Wave initscript file: %v\\n\", err)\n\t\treturn fmt.Sprintf(\"echo \\\"cannot expand home dir in Wave initscript file, from key %s\\\";\\n\", metaKeyName)\n\t}\n\tfileData, err := os.ReadFile(initScriptVal)\n\tif err != nil {\n\t\tblocklogger.Infof(logCtx, \"[conndebug] cannot open Wave initscript file: %v\\n\", err)\n\t\treturn fmt.Sprintf(\"echo \\\"cannot open Wave initscript file, from key %s\\\";\\n\", metaKeyName)\n\t}\n\tif len(fileData) > MaxInitScriptSize {\n\t\tblocklogger.Infof(logCtx, \"[conndebug] initscript file too large, size=%d, max=%d\\n\", len(fileData), MaxInitScriptSize)\n\t\treturn fmt.Sprintf(\"echo \\\"initscript file too large, from key %s\\\";\\n\", metaKeyName)\n\t}\n\tif utilfn.HasBinaryData(fileData) {\n\t\tblocklogger.Infof(logCtx, \"[conndebug] initscript file contains binary data\\n\")\n\t\treturn fmt.Sprintf(\"echo \\\"initscript file contains binary data, from key %s\\\";\\n\", metaKeyName)\n\t}\n\tblocklogger.Infof(logCtx, \"[conndebug] initscript file read successfully, size=%d\\n\", len(fileData))\n\treturn string(fileData)\n}\n\n// returns (value, metakey)\nfunc getCustomInitScriptValue(meta waveobj.MetaMapType, connName string, shellType string) (string, string) {\n\tkeys := getCustomInitScriptKeyCascade(shellType)\n\tconnMeta := meta.GetConnectionOverride(connName)\n\tif connMeta != nil {\n\t\tfor _, key := range keys {\n\t\t\tif connMeta.HasKey(key) {\n\t\t\t\treturn connMeta.GetString(key, \"\"), \"blockmeta/[\" + connName + \"]/\" + key\n\t\t\t}\n\t\t}\n\t}\n\tfor _, key := range keys {\n\t\tif meta.HasKey(key) {\n\t\t\treturn meta.GetString(key, \"\"), \"blockmeta/\" + key\n\t\t}\n\t}\n\tfullConfig := wconfig.GetWatcher().GetFullConfig()\n\tconnKeywords := fullConfig.Connections[connName]\n\tconnKeywordsMap := make(map[string]any)\n\terr := utilfn.ReUnmarshal(&connKeywordsMap, connKeywords)\n\tif err != nil {\n\t\tlog.Printf(\"error re-unmarshalling connKeywords: %v\\n\", err)\n\t\treturn \"\", \"\"\n\t}\n\tckMeta := waveobj.MetaMapType(connKeywordsMap)\n\tfor _, key := range keys {\n\t\tif ckMeta.HasKey(key) {\n\t\t\treturn ckMeta.GetString(key, \"\"), \"connections.json/\" + connName + \"/\" + key\n\t\t}\n\t}\n\treturn \"\", \"\"\n}\n\nfunc updateTermSize(shellProc *shellexec.ShellProc, blockId string, termSize waveobj.TermSize) {\n\terr := setTermSizeInDB(blockId, termSize)\n\tif err != nil {\n\t\tlog.Printf(\"error setting pty size: %v\\n\", err)\n\t}\n\terr = shellProc.Cmd.SetSize(termSize.Rows, termSize.Cols)\n\tif err != nil {\n\t\tlog.Printf(\"error setting pty size: %v\\n\", err)\n\t}\n}\n\nfunc setTermSizeInDB(blockId string, termSize waveobj.TermSize) error {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\tctx = waveobj.ContextWithUpdates(ctx)\n\tbdata, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting block data: %v\", err)\n\t}\n\tif bdata.RuntimeOpts == nil {\n\t\tbdata.RuntimeOpts = &waveobj.RuntimeOpts{}\n\t}\n\tbdata.RuntimeOpts.TermSize = termSize\n\terr = wstore.DBUpdate(ctx, bdata)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error updating block data: %v\", err)\n\t}\n\tupdates := waveobj.ContextGetUpdatesRtn(ctx)\n\twps.Broker.SendUpdateEvents(updates)\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/blockcontroller/tsunamicontroller.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage blockcontroller\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"sync\"\n\t\"syscall\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/tsunamiutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/utilds\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveappstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveapputil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n\t\"github.com/wavetermdev/waveterm/tsunami/build\"\n)\n\ntype TsunamiAppProc struct {\n\tCmd         *exec.Cmd\n\tLineBuffer  *utilds.MultiReaderLineBuffer\n\tStdinWriter io.WriteCloser\n\tPort        int           // Port the tsunami app is listening on\n\tWaitCh      chan struct{} // Channel that gets closed when cmd.Wait() returns\n\tWaitRtn     error         // Error returned by cmd.Wait()\n}\n\ntype TsunamiController struct {\n\tblockId     string\n\ttabId       string\n\tconnName    string\n\trunLock     sync.Mutex\n\ttsunamiProc *TsunamiAppProc\n\tstatusLock  sync.Mutex\n\tstatus      string\n\tversionTs   utilds.VersionTs\n\texitCode    int\n\tport        int\n}\n\nfunc (c *TsunamiController) setManifestMetadata(appId string) {\n\tmanifest, err := waveappstore.ReadAppManifest(appId)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tblockRef := waveobj.MakeORef(waveobj.OType_Block, c.blockId)\n\trtInfo := make(map[string]any)\n\trtInfo[\"tsunami:appmeta\"] = manifest.AppMeta\n\tif manifest.ConfigSchema != nil || manifest.DataSchema != nil {\n\t\tschemas := make(map[string]any)\n\t\tif manifest.ConfigSchema != nil {\n\t\t\tschemas[\"config\"] = manifest.ConfigSchema\n\t\t}\n\t\tif manifest.DataSchema != nil {\n\t\t\tschemas[\"data\"] = manifest.DataSchema\n\t\t}\n\t\trtInfo[\"tsunami:schemas\"] = schemas\n\t}\n\twstore.SetRTInfo(blockRef, rtInfo)\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent:  wps.Event_TsunamiUpdateMeta,\n\t\tScopes: []string{waveobj.MakeORef(waveobj.OType_Block, c.blockId).String()},\n\t\tData:   manifest.AppMeta,\n\t})\n}\n\nfunc (c *TsunamiController) clearSchemas() {\n\tblockRef := waveobj.MakeORef(waveobj.OType_Block, c.blockId)\n\twstore.SetRTInfo(blockRef, map[string]any{\n\t\t\"tsunami:schemas\": nil,\n\t})\n\tlog.Printf(\"TsunamiController: cleared schemas for block %s\", c.blockId)\n}\n\nfunc isBuildCacheUpToDate(appPath string) (bool, error) {\n\tappName := build.GetAppName(appPath)\n\n\tosArch := runtime.GOOS + \"-\" + runtime.GOARCH\n\n\tcachePath, err := tsunamiutil.GetTsunamiAppCachePath(\"local\", appName, osArch)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tcacheInfo, err := os.Stat(cachePath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\n\tappModTime, err := build.GetAppModTime(appPath)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tcacheModTime := cacheInfo.ModTime()\n\treturn !cacheModTime.Before(appModTime), nil\n}\n\nfunc (c *TsunamiController) Start(ctx context.Context, blockMeta waveobj.MetaMapType, rtOpts *waveobj.RuntimeOpts, force bool) error {\n\tlog.Printf(\"TsunamiController.Start called for block %s\", c.blockId)\n\tc.runLock.Lock()\n\tdefer c.runLock.Unlock()\n\n\tscaffoldPath := waveapputil.GetTsunamiScaffoldPath()\n\tsettings := wconfig.GetWatcher().GetFullConfig().Settings\n\tsdkReplacePath := settings.TsunamiSdkReplacePath\n\tsdkVersion := settings.TsunamiSdkVersion\n\tif sdkVersion == \"\" {\n\t\tsdkVersion = waveapputil.DefaultTsunamiSdkVersion\n\t}\n\tgoPath := settings.TsunamiGoPath\n\n\tappPath := blockMeta.GetString(waveobj.MetaKey_TsunamiAppPath, \"\")\n\tappId := blockMeta.GetString(waveobj.MetaKey_TsunamiAppId, \"\")\n\n\tif appPath == \"\" {\n\t\tif appId == \"\" {\n\t\t\treturn fmt.Errorf(\"tsunami:apppath or tsunami:appid is required\")\n\t\t}\n\t\tvar err error\n\t\tappPath, err = waveappstore.GetAppDir(appId)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get app directory from tsunami:appid: %w\", err)\n\t\t}\n\t} else {\n\t\tvar err error\n\t\tappPath, err = wavebase.ExpandHomeDir(appPath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"tsunami:apppath invalid: %w\", err)\n\t\t}\n\t\tif !filepath.IsAbs(appPath) {\n\t\t\treturn fmt.Errorf(\"tsunami:apppath must be absolute: %s\", appPath)\n\t\t}\n\t}\n\n\tif appId != \"\" {\n\t\tc.setManifestMetadata(appId)\n\t}\n\n\tappName := build.GetAppName(appPath)\n\tosArch := runtime.GOOS + \"-\" + runtime.GOARCH\n\n\tcachePath, err := tsunamiutil.GetTsunamiAppCachePath(\"local\", appName, osArch)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get cache path: %w\", err)\n\t}\n\n\tupToDate, err := isBuildCacheUpToDate(appPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to check build cache: %w\", err)\n\t}\n\n\tif !upToDate || force {\n\t\tnodePath := wavebase.GetWaveAppElectronExecPath()\n\t\tif nodePath == \"\" {\n\t\t\treturn fmt.Errorf(\"electron executable path not set\")\n\t\t}\n\n\t\topts := build.BuildOpts{\n\t\t\tAppPath:        appPath,\n\t\t\tVerbose:        true,\n\t\t\tOpen:           false,\n\t\t\tKeepTemp:       false,\n\t\t\tOutputFile:     cachePath,\n\t\t\tScaffoldPath:   scaffoldPath,\n\t\t\tSdkReplacePath: sdkReplacePath,\n\t\t\tSdkVersion:     sdkVersion,\n\t\t\tNodePath:       nodePath,\n\t\t\tGoPath:         goPath,\n\t\t}\n\n\t\terr = build.TsunamiBuild(opts)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"TsunamiController build error for block %s: %v\", c.blockId, err)\n\t\t\tlog.Printf(\"BuildOpts %#v\\n\", opts)\n\t\t\treturn fmt.Errorf(\"failed to build tsunami app: %w\", err)\n\t\t}\n\t}\n\n\tinfo, err := os.Stat(cachePath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn fmt.Errorf(\"app cache does not exist: %s\", cachePath)\n\t\t}\n\t\treturn fmt.Errorf(\"failed to stat app cache: %w\", err)\n\t}\n\n\tif runtime.GOOS != \"windows\" && info.Mode()&0111 == 0 {\n\t\treturn fmt.Errorf(\"app cache is not executable: %s\", cachePath)\n\t}\n\n\ttsunamiProc, err := runTsunamiAppBinary(ctx, cachePath, appPath, blockMeta)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to run tsunami app: %w\", err)\n\t}\n\n\tc.tsunamiProc = tsunamiProc\n\tc.WithStatusLock(func() {\n\t\tc.status = Status_Running\n\t\tc.port = tsunamiProc.Port\n\t})\n\tgo c.sendStatusUpdate()\n\n\t// Monitor process completion\n\tgo func() {\n\t\t<-tsunamiProc.WaitCh\n\t\tc.runLock.Lock()\n\t\tif c.tsunamiProc == tsunamiProc {\n\t\t\tc.tsunamiProc = nil\n\t\t\tc.WithStatusLock(func() {\n\t\t\t\tc.status = Status_Done\n\t\t\t\tc.port = 0\n\t\t\t\tc.exitCode = exitCodeFromWaitErr(tsunamiProc.WaitRtn)\n\t\t\t})\n\t\t\tc.clearSchemas()\n\t\t\tgo c.sendStatusUpdate()\n\t\t}\n\t\tc.runLock.Unlock()\n\t}()\n\n\treturn nil\n}\n\nfunc (c *TsunamiController) Stop(graceful bool, newStatus string, destroy bool) {\n\tlog.Printf(\"TsunamiController.Stop called for block %s (graceful: %t, newStatus: %s)\", c.blockId, graceful, newStatus)\n\tc.runLock.Lock()\n\tdefer c.runLock.Unlock()\n\n\tif c.tsunamiProc == nil {\n\t\treturn\n\t}\n\n\tif c.tsunamiProc.Cmd.Process != nil {\n\t\tc.tsunamiProc.Cmd.Process.Kill()\n\t}\n\n\tif c.tsunamiProc.StdinWriter != nil {\n\t\tc.tsunamiProc.StdinWriter.Close()\n\t}\n\n\tc.tsunamiProc = nil\n\tif newStatus == \"\" {\n\t\tnewStatus = Status_Done\n\t}\n\tc.WithStatusLock(func() {\n\t\tc.status = newStatus\n\t\tc.port = 0\n\t})\n\tc.clearSchemas()\n\tgo c.sendStatusUpdate()\n}\n\nfunc (c *TsunamiController) GetRuntimeStatus() *BlockControllerRuntimeStatus {\n\tvar rtn *BlockControllerRuntimeStatus\n\tc.WithStatusLock(func() {\n\t\trtn = &BlockControllerRuntimeStatus{\n\t\t\tBlockId:           c.blockId,\n\t\t\tVersion:           c.versionTs.GetVersionTs(),\n\t\t\tShellProcStatus:   c.status,\n\t\t\tShellProcConnName: c.connName,\n\t\t\tShellProcExitCode: c.exitCode,\n\t\t}\n\n\t\tif c.status == Status_Running && c.port > 0 {\n\t\t\trtn.TsunamiPort = c.port\n\t\t}\n\t})\n\n\treturn rtn\n}\n\nfunc (c *TsunamiController) GetConnName() string {\n\treturn c.connName\n}\n\nfunc (c *TsunamiController) SendInput(input *BlockInputUnion) error {\n\treturn fmt.Errorf(\"tsunami controller send input not implemented\")\n}\n\nfunc runTsunamiAppBinary(ctx context.Context, appBinPath string, appPath string, blockMeta waveobj.MetaMapType) (*TsunamiAppProc, error) {\n\tcmd := exec.Command(appBinPath)\n\tcmd.Env = append(os.Environ(), \"TSUNAMI_CLOSEONSTDIN=1\")\n\n\tif wavebase.IsDevMode() {\n\t\tcmd.Env = append(cmd.Env, \"TSUNAMI_CORS=\"+tsunamiutil.DevModeCorsOrigins)\n\t}\n\n\t// Add TsunamiEnv variables if configured\n\ttsunamiEnv := blockMeta.GetMap(waveobj.MetaKey_TsunamiEnv)\n\tfor key, value := range tsunamiEnv {\n\t\tif strValue, ok := value.(string); ok {\n\t\t\tcmd.Env = append(cmd.Env, key+\"=\"+strValue)\n\t\t}\n\t}\n\n\tstdoutPipe, err := cmd.StdoutPipe()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create stdout pipe: %w\", err)\n\t}\n\n\tstderrPipe, err := cmd.StderrPipe()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create stderr pipe: %w\", err)\n\t}\n\n\tstdinPipe, err := cmd.StdinPipe()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create stdin pipe: %w\", err)\n\t}\n\n\tappName := build.GetAppName(appPath)\n\n\tlineBuffer := utilds.MakeMultiReaderLineBuffer(1000)\n\tportChan := make(chan int, 1)\n\tportFound := false\n\n\tlineBuffer.SetLineCallback(func(line string) {\n\t\tlog.Printf(\"[tsunami:%s] %s\\n\", appName, line)\n\n\t\tif !portFound {\n\t\t\tif port := build.ParseTsunamiPort(line); port > 0 {\n\t\t\t\tportFound = true\n\t\t\t\tportChan <- port\n\t\t\t}\n\t\t}\n\t})\n\n\terr = cmd.Start()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to start tsunami app: %w\", err)\n\t}\n\n\t// Create wait channel and tsunami proc first\n\twaitCh := make(chan struct{})\n\ttsunamiProc := &TsunamiAppProc{\n\t\tCmd:         cmd,\n\t\tLineBuffer:  lineBuffer,\n\t\tStdinWriter: stdinPipe,\n\t\tWaitCh:      waitCh,\n\t}\n\n\t// Start goroutine to handle cmd.Wait()\n\tgo func() {\n\t\ttsunamiProc.WaitRtn = cmd.Wait()\n\t\tlog.Printf(\"WAIT RETURN: %v\\n\", tsunamiProc.WaitRtn)\n\t\tif err := tsunamiProc.WaitRtn; err != nil {\n\t\t\tif ee, ok := err.(*exec.ExitError); ok {\n\t\t\t\tif ws, ok := ee.ProcessState.Sys().(syscall.WaitStatus); ok {\n\t\t\t\t\tif ws.Signaled() {\n\t\t\t\t\t\tsig := ws.Signal()\n\t\t\t\t\t\tlog.Printf(\"tsunami proc killed by signal: %s (%d)\", sig, int(sig))\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlog.Printf(\"tsunami proc exited with code %d\", ee.ExitCode())\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"tsunami proc error: %v\", err)\n\t\t\t}\n\t\t}\n\n\t\tclose(waitCh)\n\t}()\n\n\t// Start reading both stdout and stderr\n\tgo lineBuffer.ReadAll(stdoutPipe)\n\tgo lineBuffer.ReadAll(stderrPipe)\n\n\t// Wait for either port detection, process death, or context timeout\n\terrChan := make(chan error, 1)\n\tgo func() {\n\t\t<-tsunamiProc.WaitCh\n\t\tselect {\n\t\tcase <-portChan:\n\t\t\t// Port already found, nothing to do\n\t\tdefault:\n\t\t\terrChan <- fmt.Errorf(\"tsunami process died before emitting listening message\")\n\t\t}\n\t}()\n\n\tselect {\n\tcase port := <-portChan:\n\t\ttsunamiProc.Port = port\n\t\treturn tsunamiProc, nil\n\tcase err := <-errChan:\n\t\tcmd.Process.Kill()\n\t\treturn nil, err\n\tcase <-ctx.Done():\n\t\tcmd.Process.Kill()\n\t\treturn nil, fmt.Errorf(\"timeout waiting for tsunami port: %w\", ctx.Err())\n\t}\n}\n\nfunc MakeTsunamiController(tabId string, blockId string, connName string) Controller {\n\tlog.Printf(\"make tsunami controller: %s %s\\n\", tabId, blockId)\n\treturn &TsunamiController{\n\t\tblockId:  blockId,\n\t\ttabId:    tabId,\n\t\tconnName: connName,\n\t\tstatus:   Status_Init,\n\t}\n}\n\n// requires the lock (so do not call while holding statusLock)\nfunc (c *TsunamiController) sendStatusUpdate() {\n\trtStatus := c.GetRuntimeStatus()\n\tlog.Printf(\"sending blockcontroller update %#v\\n\", rtStatus)\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent: wps.Event_ControllerStatus,\n\t\tScopes: []string{\n\t\t\twaveobj.MakeORef(waveobj.OType_Tab, c.tabId).String(),\n\t\t\twaveobj.MakeORef(waveobj.OType_Block, c.blockId).String(),\n\t\t},\n\t\tData: rtStatus,\n\t})\n}\n\nfunc (c *TsunamiController) WithStatusLock(fn func()) {\n\tc.statusLock.Lock()\n\tdefer c.statusLock.Unlock()\n\tfn()\n}\n\nfunc exitCodeFromWaitErr(waitErr error) int {\n\tif waitErr != nil {\n\t\tif exitError, ok := waitErr.(*exec.ExitError); ok {\n\t\t\treturn exitError.ExitCode()\n\t\t} else {\n\t\t\treturn 1\n\t\t}\n\t} else {\n\t\treturn 0\n\t}\n}\n"
  },
  {
    "path": "pkg/blocklogger/blocklogger.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage blocklogger\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"log\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\n// Buffer size for the output channel\nconst outputBufferSize = 1000\n\nvar outputChan chan wshrpc.CommandControllerAppendOutputData\n\nfunc InitBlockLogger() {\n\toutputChan = make(chan wshrpc.CommandControllerAppendOutputData, outputBufferSize)\n\t// Start the output runner\n\tgo outputRunner()\n}\n\nfunc outputRunner() {\n\tdefer log.Printf(\"blocklogger: outputRunner exiting\")\n\tclient := wshclient.GetBareRpcClient()\n\tfor data := range outputChan {\n\t\t// Process each output request synchronously, waiting for response\n\t\twshclient.ControllerAppendOutputCommand(client, data, nil)\n\t}\n}\n\ntype logBlockIdContextKeyType struct{}\n\nvar logBlockIdContextKey = logBlockIdContextKeyType{}\n\ntype logBlockIdData struct {\n\tBlockId string\n\tVerbose bool\n}\n\nfunc ContextWithLogBlockId(ctx context.Context, blockId string, verbose bool) context.Context {\n\treturn context.WithValue(ctx, logBlockIdContextKey, &logBlockIdData{BlockId: blockId, Verbose: verbose})\n}\n\nfunc getLogBlockData(ctx context.Context) *logBlockIdData {\n\tif ctx == nil {\n\t\treturn nil\n\t}\n\tdataPtr := ctx.Value(logBlockIdContextKey)\n\tif dataPtr == nil {\n\t\treturn nil\n\t}\n\treturn dataPtr.(*logBlockIdData)\n}\n\nfunc queueLogData(data wshrpc.CommandControllerAppendOutputData) {\n\tselect {\n\tcase outputChan <- data:\n\tdefault:\n\t}\n}\n\nfunc writeLogf(blockId string, format string, args []any) {\n\tlogStr := fmt.Sprintf(format, args...)\n\tlogStr = strings.ReplaceAll(logStr, \"\\n\", \"\\r\\n\")\n\tdata := wshrpc.CommandControllerAppendOutputData{\n\t\tBlockId: blockId,\n\t\tData64:  base64.StdEncoding.EncodeToString([]byte(logStr)),\n\t}\n\tqueueLogData(data)\n}\n\nfunc Infof(ctx context.Context, format string, args ...any) {\n\tlogData := getLogBlockData(ctx)\n\tif logData == nil {\n\t\treturn\n\t}\n\twriteLogf(logData.BlockId, format, args)\n}\n\nfunc Debugf(ctx context.Context, format string, args ...interface{}) {\n\tlogData := getLogBlockData(ctx)\n\tif logData == nil || !logData.Verbose {\n\t\treturn\n\t}\n\twriteLogf(logData.BlockId, format, args)\n}\n"
  },
  {
    "path": "pkg/buildercontroller/buildercontroller.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage buildercontroller\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/tsunamiutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/utilds\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveappstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveapputil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/tsunami/build\"\n)\n\nconst (\n\tBuilderStatus_Init     = \"init\"\n\tBuilderStatus_Building = \"building\"\n\tBuilderStatus_Running  = \"running\"\n\tBuilderStatus_Error    = \"error\"\n\tBuilderStatus_Stopped  = \"stopped\"\n)\n\ntype BuilderProcess struct {\n\tCmd         *exec.Cmd\n\tStdinWriter io.WriteCloser\n\tPort        int\n\tWaitCh      chan struct{}\n\tWaitRtn     error\n}\n\ntype BuildResult struct {\n\tSuccess      bool   `json:\"success\"`\n\tErrorMessage string `json:\"errormessage,omitempty\"`\n\tBuildOutput  string `json:\"buildoutput\"`\n}\n\ntype BuilderController struct {\n\tlock          sync.Mutex\n\tbuilderId     string\n\tappId         string\n\tprocess       *BuilderProcess\n\toutputBuffer  *utilds.MultiReaderLineBuffer\n\tstatusLock    sync.Mutex\n\tstatus        string\n\tstatusVersion int\n\tport          int\n\texitCode      int\n\terrorMsg      string\n}\n\nvar (\n\tcontrollerMap = make(map[string]*BuilderController) // key is builderid\n\tmapLock       sync.Mutex\n)\n\nfunc GetOrCreateController(builderId string) *BuilderController {\n\tmapLock.Lock()\n\tdefer mapLock.Unlock()\n\n\tbc := controllerMap[builderId]\n\tif bc != nil {\n\t\treturn bc\n\t}\n\n\tbc = &BuilderController{\n\t\tbuilderId:     builderId,\n\t\tstatus:        BuilderStatus_Init,\n\t\tstatusVersion: 0,\n\t}\n\tcontrollerMap[builderId] = bc\n\n\treturn bc\n}\n\nfunc GetController(builderId string) *BuilderController {\n\tmapLock.Lock()\n\tdefer mapLock.Unlock()\n\treturn controllerMap[builderId]\n}\n\nfunc DeleteController(builderId string) {\n\tmapLock.Lock()\n\tbc := controllerMap[builderId]\n\tdelete(controllerMap, builderId)\n\tmapLock.Unlock()\n\n\tif bc != nil {\n\t\tbc.Stop()\n\t}\n}\n\nfunc GetBuilderAppExecutablePath(appPath string) (string, error) {\n\tbinDir := filepath.Join(appPath, \"bin\")\n\n\tbinaryName := \"app\"\n\tif runtime.GOOS == \"windows\" {\n\t\tbinaryName = \"app.exe\"\n\t}\n\tbinPath := filepath.Join(binDir, binaryName)\n\n\terr := wavebase.TryMkdirs(binDir, 0755, \"app bin directory\")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create app bin directory: %w\", err)\n\t}\n\n\treturn binPath, nil\n}\n\nfunc Shutdown() {\n\tmapLock.Lock()\n\tcontrollers := make([]*BuilderController, 0, len(controllerMap))\n\tfor _, bc := range controllerMap {\n\t\tcontrollers = append(controllers, bc)\n\t}\n\tmapLock.Unlock()\n\n\tfor _, bc := range controllers {\n\t\tbc.Stop()\n\t}\n}\n\nfunc (bc *BuilderController) waitForBuildDone(ctx context.Context) error {\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\tdefault:\n\t\t}\n\n\t\tbc.statusLock.Lock()\n\t\tstatus := bc.status\n\t\tbc.statusLock.Unlock()\n\n\t\tif status != BuilderStatus_Building {\n\t\t\treturn nil\n\t\t}\n\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n}\n\nfunc (bc *BuilderController) Start(ctx context.Context, appId string, builderEnv map[string]string) error {\n\tif err := bc.waitForBuildDone(ctx); err != nil {\n\t\treturn err\n\t}\n\tbc.lock.Lock()\n\tdefer bc.lock.Unlock()\n\n\tif bc.appId != appId && bc.process != nil {\n\t\tlog.Printf(\"BuilderController: stopping previous app %s for builder %s\", bc.appId, bc.builderId)\n\t\tbc.stopProcess_nolock()\n\t}\n\n\tbc.appId = appId\n\tbc.outputBuffer = utilds.MakeMultiReaderLineBuffer(1000)\n\tbc.setStatus_nolock(BuilderStatus_Building, 0, 0, \"\")\n\n\tbc.publishOutputLine(\"\", true)\n\n\tbc.outputBuffer.SetLineCallback(func(line string) {\n\t\tbc.publishOutputLine(line, false)\n\t})\n\n\tbuildCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\n\tgo func() {\n\t\tdefer cancel()\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(fmt.Sprintf(\"buildercontroller[%s].buildAndRun\", bc.builderId), recover())\n\t\t}()\n\t\tbc.buildAndRun(buildCtx, appId, builderEnv, nil)\n\t}()\n\n\treturn nil\n}\n\nfunc (bc *BuilderController) buildAndRun(ctx context.Context, appId string, builderEnv map[string]string, resultCh chan<- *BuildResult) {\n\tappNS, _, err := waveappstore.ParseAppId(appId)\n\tif err != nil {\n\t\tbc.handleBuildError(fmt.Errorf(\"failed to parse app id: %w\", err), resultCh)\n\t\treturn\n\t}\n\n\tappPath, err := waveappstore.GetAppDir(appId)\n\tif err != nil {\n\t\tbc.handleBuildError(fmt.Errorf(\"failed to get app directory: %w\", err), resultCh)\n\t\treturn\n\t}\n\n\tcachePath, err := GetBuilderAppExecutablePath(appPath)\n\tif err != nil {\n\t\tbc.handleBuildError(fmt.Errorf(\"failed to get builder executable path: %w\", err), resultCh)\n\t\treturn\n\t}\n\n\tnodePath := wavebase.GetWaveAppElectronExecPath()\n\tif nodePath == \"\" {\n\t\tbc.handleBuildError(fmt.Errorf(\"electron executable path not set\"), resultCh)\n\t\treturn\n\t}\n\n\tscaffoldPath := waveapputil.GetTsunamiScaffoldPath()\n\tsettings := wconfig.GetWatcher().GetFullConfig().Settings\n\tsdkReplacePath := settings.TsunamiSdkReplacePath\n\tsdkVersion := settings.TsunamiSdkVersion\n\tif sdkVersion == \"\" {\n\t\tsdkVersion = waveapputil.DefaultTsunamiSdkVersion\n\t}\n\tgoPath := settings.TsunamiGoPath\n\n\toutputCapture := build.MakeOutputCapture()\n\t_, err = build.TsunamiBuildInternal(build.BuildOpts{\n\t\tAppPath:        appPath,\n\t\tAppNS:          appNS,\n\t\tVerbose:        true,\n\t\tOpen:           false,\n\t\tKeepTemp:       false,\n\t\tOutputFile:     cachePath,\n\t\tScaffoldPath:   scaffoldPath,\n\t\tSdkReplacePath: sdkReplacePath,\n\t\tSdkVersion:     sdkVersion,\n\t\tNodePath:       nodePath,\n\t\tGoPath:         goPath,\n\t\tOutputCapture:  outputCapture,\n\t\tMoveFileBack:   true,\n\t})\n\n\tfor _, line := range outputCapture.GetLines() {\n\t\tbc.outputBuffer.AddLine(line)\n\t}\n\n\tif err != nil {\n\t\tbc.handleBuildError(fmt.Errorf(\"build failed: %w\", err), resultCh)\n\t\treturn\n\t}\n\n\tinfo, err := os.Stat(cachePath)\n\tif err != nil {\n\t\tbc.handleBuildError(fmt.Errorf(\"build output not found: %w\", err), resultCh)\n\t\treturn\n\t}\n\n\tif runtime.GOOS != \"windows\" && info.Mode()&0111 == 0 {\n\t\tbc.handleBuildError(fmt.Errorf(\"build output is not executable\"), resultCh)\n\t\treturn\n\t}\n\n\tprocess, err := bc.runBuilderApp(ctx, appId, cachePath, builderEnv)\n\tif err != nil {\n\t\tbc.handleBuildError(fmt.Errorf(\"failed to run app: %w\", err), resultCh)\n\t\treturn\n\t}\n\n\tbc.lock.Lock()\n\tbc.process = process\n\tbc.setStatus_nolock(BuilderStatus_Running, process.Port, 0, \"\")\n\tbc.lock.Unlock()\n\n\ttime.Sleep(1 * time.Second)\n\n\tif resultCh != nil {\n\t\tbuildOutput := \"\"\n\t\tif bc.outputBuffer != nil {\n\t\t\tlines := bc.outputBuffer.GetLines()\n\t\t\tbuildOutput = strings.Join(lines, \"\\n\")\n\t\t}\n\t\tselect {\n\t\tcase resultCh <- &BuildResult{\n\t\t\tSuccess:     true,\n\t\t\tBuildOutput: buildOutput,\n\t\t}:\n\t\tdefault:\n\t\t}\n\t}\n\n\tgo func() {\n\t\t<-process.WaitCh\n\t\tbc.lock.Lock()\n\t\tif bc.process == process {\n\t\t\tbc.process = nil\n\t\t\texitCode := exitCodeFromWaitErr(process.WaitRtn)\n\t\t\tbc.setStatus_nolock(BuilderStatus_Stopped, 0, exitCode, \"\")\n\t\t}\n\t\tbc.lock.Unlock()\n\t}()\n}\n\nfunc (bc *BuilderController) runBuilderApp(ctx context.Context, appId string, appBinPath string, builderEnv map[string]string) (*BuilderProcess, error) {\n\tmanifest, err := waveappstore.ReadAppManifest(appId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read app manifest: %w\", err)\n\t}\n\n\tsecretBindings, err := waveappstore.ReadAppSecretBindings(appId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read secret bindings (ERR-SECRET): %w\", err)\n\t}\n\n\tsecretEnv, err := waveappstore.BuildAppSecretEnv(appId, manifest, secretBindings)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to build secret environment (ERR-SECRET): %w\", err)\n\t}\n\n\tif builderEnv == nil {\n\t\tbuilderEnv = make(map[string]string)\n\t}\n\tfor k, v := range secretEnv {\n\t\tbuilderEnv[k] = v\n\t}\n\n\tcmd := exec.Command(appBinPath)\n\tcmd.Env = append(os.Environ(), \"TSUNAMI_CLOSEONSTDIN=1\")\n\n\tif wavebase.IsDevMode() {\n\t\tcmd.Env = append(cmd.Env, \"TSUNAMI_CORS=\"+tsunamiutil.DevModeCorsOrigins)\n\t}\n\n\tfor key, value := range builderEnv {\n\t\tcmd.Env = append(cmd.Env, key+\"=\"+value)\n\t}\n\n\tstdoutPipe, err := cmd.StdoutPipe()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create stdout pipe: %w\", err)\n\t}\n\n\tstderrPipe, err := cmd.StderrPipe()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create stderr pipe: %w\", err)\n\t}\n\n\tstdinPipe, err := cmd.StdinPipe()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create stdin pipe: %w\", err)\n\t}\n\n\tportChan := make(chan int, 1)\n\tportFound := false\n\n\tbc.outputBuffer.SetLineCallback(func(line string) {\n\t\tif !portFound {\n\t\t\tif port := build.ParseTsunamiPort(line); port > 0 {\n\t\t\t\tportFound = true\n\t\t\t\tportChan <- port\n\t\t\t}\n\t\t}\n\t\tbc.publishOutputLine(line, false)\n\t})\n\n\terr = cmd.Start()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to start process: %w\", err)\n\t}\n\n\twaitCh := make(chan struct{})\n\tprocess := &BuilderProcess{\n\t\tCmd:         cmd,\n\t\tStdinWriter: stdinPipe,\n\t\tWaitCh:      waitCh,\n\t}\n\n\tgo func() {\n\t\tprocess.WaitRtn = cmd.Wait()\n\t\tclose(waitCh)\n\t}()\n\n\tgo bc.outputBuffer.ReadAll(stdoutPipe)\n\tgo bc.outputBuffer.ReadAll(stderrPipe)\n\n\terrChan := make(chan error, 1)\n\tgo func() {\n\t\t<-process.WaitCh\n\t\tselect {\n\t\tcase <-portChan:\n\t\tdefault:\n\t\t\terrChan <- fmt.Errorf(\"process died before emitting port\")\n\t\t}\n\t}()\n\n\ttimeout := time.NewTimer(5 * time.Second)\n\tdefer timeout.Stop()\n\n\tselect {\n\tcase port := <-portChan:\n\t\tprocess.Port = port\n\t\treturn process, nil\n\tcase err := <-errChan:\n\t\tcmd.Process.Kill()\n\t\treturn nil, err\n\tcase <-timeout.C:\n\t\tcmd.Process.Kill()\n\t\treturn nil, fmt.Errorf(\"timeout waiting for port\")\n\tcase <-ctx.Done():\n\t\tcmd.Process.Kill()\n\t\treturn nil, fmt.Errorf(\"cancelled while waiting for app port: %w\", ctx.Err())\n\t}\n}\n\nfunc (bc *BuilderController) handleBuildError(err error, resultCh chan<- *BuildResult) {\n\tbc.lock.Lock()\n\tdefer bc.lock.Unlock()\n\tbc.setStatus_nolock(BuilderStatus_Error, 0, 1, err.Error())\n\n\tif resultCh != nil {\n\t\tbuildOutput := \"\"\n\t\tif bc.outputBuffer != nil {\n\t\t\tlines := bc.outputBuffer.GetLines()\n\t\t\tbuildOutput = strings.Join(lines, \"\\n\")\n\t\t}\n\t\tselect {\n\t\tcase resultCh <- &BuildResult{\n\t\t\tSuccess:      false,\n\t\t\tErrorMessage: err.Error(),\n\t\t\tBuildOutput:  buildOutput,\n\t\t}:\n\t\tdefault:\n\t\t}\n\t}\n}\n\nfunc (bc *BuilderController) RestartAndWaitForBuild(ctx context.Context, appId string, builderEnv map[string]string) (*BuildResult, error) {\n\tif err := bc.waitForBuildDone(ctx); err != nil {\n\t\treturn nil, err\n\t}\n\n\tresultCh := make(chan *BuildResult, 1)\n\n\tbc.lock.Lock()\n\tif bc.appId != appId && bc.process != nil {\n\t\tlog.Printf(\"BuilderController: stopping previous app %s for builder %s\", bc.appId, bc.builderId)\n\t\tbc.stopProcess_nolock()\n\t}\n\n\tbc.appId = appId\n\tbc.outputBuffer = utilds.MakeMultiReaderLineBuffer(1000)\n\tbc.setStatus_nolock(BuilderStatus_Building, 0, 0, \"\")\n\n\tbc.publishOutputLine(\"\", true)\n\n\tbc.outputBuffer.SetLineCallback(func(line string) {\n\t\tbc.publishOutputLine(line, false)\n\t})\n\tbc.lock.Unlock()\n\n\ttime.Sleep(500 * time.Millisecond)\n\n\tbuildCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\n\tgo func() {\n\t\tdefer cancel()\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(fmt.Sprintf(\"buildercontroller[%s].buildAndRun\", bc.builderId), recover())\n\t\t}()\n\t\tbc.buildAndRun(buildCtx, appId, builderEnv, resultCh)\n\t}()\n\n\tselect {\n\tcase result := <-resultCh:\n\t\treturn result, nil\n\tcase <-ctx.Done():\n\t\treturn nil, ctx.Err()\n\t}\n}\n\nfunc (bc *BuilderController) Stop() error {\n\tif err := bc.waitForBuildDone(context.Background()); err != nil {\n\t\treturn err\n\t}\n\n\tbc.lock.Lock()\n\tdefer bc.lock.Unlock()\n\tbc.stopProcess_nolock()\n\tbc.setStatus_nolock(BuilderStatus_Stopped, 0, 0, \"\")\n\treturn nil\n}\n\nfunc (bc *BuilderController) stopProcess_nolock() {\n\tif bc.process == nil {\n\t\treturn\n\t}\n\n\tif bc.process.Cmd.Process != nil {\n\t\tbc.process.Cmd.Process.Kill()\n\t}\n\n\tif bc.process.StdinWriter != nil {\n\t\tbc.process.StdinWriter.Close()\n\t}\n\n\tbc.process = nil\n}\n\nfunc (bc *BuilderController) GetStatus() wshrpc.BuilderStatusData {\n\tbc.statusLock.Lock()\n\tdefer bc.statusLock.Unlock()\n\n\tbc.statusVersion++\n\tstatusData := wshrpc.BuilderStatusData{\n\t\tStatus:   bc.status,\n\t\tPort:     bc.port,\n\t\tExitCode: bc.exitCode,\n\t\tErrorMsg: bc.errorMsg,\n\t\tVersion:  bc.statusVersion,\n\t}\n\n\tif bc.appId != \"\" {\n\t\tmanifest, err := waveappstore.ReadAppManifest(bc.appId)\n\t\tif err == nil && manifest != nil {\n\t\t\twshrpcManifest := &wshrpc.AppManifest{\n\t\t\t\tAppMeta: wshrpc.AppMeta{\n\t\t\t\t\tTitle:     manifest.AppMeta.Title,\n\t\t\t\t\tShortDesc: manifest.AppMeta.ShortDesc,\n\t\t\t\t},\n\t\t\t\tConfigSchema: manifest.ConfigSchema,\n\t\t\t\tDataSchema:   manifest.DataSchema,\n\t\t\t\tSecrets:      make(map[string]wshrpc.SecretMeta),\n\t\t\t}\n\t\t\tfor k, v := range manifest.Secrets {\n\t\t\t\twshrpcManifest.Secrets[k] = wshrpc.SecretMeta{\n\t\t\t\t\tDesc:     v.Desc,\n\t\t\t\t\tOptional: v.Optional,\n\t\t\t\t}\n\t\t\t}\n\t\t\tstatusData.Manifest = wshrpcManifest\n\t\t}\n\n\t\tsecretBindings, err := waveappstore.ReadAppSecretBindings(bc.appId)\n\t\tif err == nil {\n\t\t\tstatusData.SecretBindings = secretBindings\n\t\t}\n\n\t\tif manifest != nil && secretBindings != nil {\n\t\t\t_, err := waveappstore.BuildAppSecretEnv(bc.appId, manifest, secretBindings)\n\t\t\tstatusData.SecretBindingsComplete = (err == nil)\n\t\t}\n\t}\n\n\treturn statusData\n}\n\nfunc (bc *BuilderController) GetOutput() []string {\n\tif bc.outputBuffer == nil {\n\t\treturn []string{}\n\t}\n\treturn bc.outputBuffer.GetLines()\n}\n\nfunc (bc *BuilderController) setStatus_nolock(status string, port int, exitCode int, errorMsg string) {\n\tbc.statusLock.Lock()\n\tbc.status = status\n\tbc.port = port\n\tbc.exitCode = exitCode\n\tbc.errorMsg = errorMsg\n\tbc.statusLock.Unlock()\n\n\tgo bc.publishStatus()\n}\n\nfunc (bc *BuilderController) publishStatus() {\n\tstatus := bc.GetStatus()\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent:  wps.Event_BuilderStatus,\n\t\tScopes: []string{waveobj.MakeORef(waveobj.OType_Builder, bc.builderId).String()},\n\t\tData:   status,\n\t})\n}\n\nfunc (bc *BuilderController) publishOutputLine(line string, reset bool) {\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent:  wps.Event_BuilderOutput,\n\t\tScopes: []string{waveobj.MakeORef(waveobj.OType_Builder, bc.builderId).String()},\n\t\tData: map[string]any{\n\t\t\t\"lines\": []string{line},\n\t\t\t\"reset\": reset,\n\t\t},\n\t})\n}\n\nfunc exitCodeFromWaitErr(waitErr error) int {\n\tif waitErr == nil {\n\t\treturn 0\n\t}\n\tif exitError, ok := waitErr.(*exec.ExitError); ok {\n\t\treturn exitError.ExitCode()\n\t}\n\treturn 1\n}\n"
  },
  {
    "path": "pkg/eventbus/eventbus.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage eventbus\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"sync\"\n)\n\nconst (\n\tWSEvent_ElectronCloseWindow     = \"electron:closewindow\"\n\tWSEvent_ElectronUpdateActiveTab = \"electron:updateactivetab\"\n\tWSEvent_Rpc                     = \"rpc\"\n)\n\ntype WSEventType struct {\n\tEventType string `json:\"eventtype\"`\n\tORef      string `json:\"oref,omitempty\"`\n\tData      any    `json:\"data\"`\n}\n\ntype WindowWatchData struct {\n\tWindowWSCh chan any\n\tRouteId    string\n}\n\nvar globalLock = &sync.Mutex{}\nvar wsMap = make(map[string]*WindowWatchData) // websocketid => WindowWatchData\n\nfunc RegisterWSChannel(connId string, routeId string, ch chan any) {\n\tglobalLock.Lock()\n\tdefer globalLock.Unlock()\n\twsMap[connId] = &WindowWatchData{\n\t\tWindowWSCh: ch,\n\t\tRouteId:    routeId,\n\t}\n}\n\nfunc UnregisterWSChannel(connId string) {\n\tglobalLock.Lock()\n\tdefer globalLock.Unlock()\n\tdelete(wsMap, connId)\n}\n\nfunc SendEventToElectron(event WSEventType) {\n\tbarr, err := json.Marshal(event)\n\tif err != nil {\n\t\tlog.Printf(\"cannot marshal electron message: %v\\n\", err)\n\t\treturn\n\t}\n\t// send to electron\n\tlog.Printf(\"sending event to electron: %q\\n\", event.EventType)\n\tfmt.Fprintf(os.Stderr, \"\\nWAVESRV-EVENT:%s\\n\", string(barr))\n}\n"
  },
  {
    "path": "pkg/faviconcache/faviconcache.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage faviconcache\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n)\n\n// --- Constants and Types ---\n\n// cacheDuration is how long a cached entry is considered “fresh.”\nconst cacheDuration = 24 * time.Hour\n\n// maxIconSize limits the favicon size to 256 KB.\nconst maxIconSize = 256 * 1024 // in bytes\n\n// FaviconCacheItem represents one cached favicon entry.\ntype FaviconCacheItem struct {\n\t// Data is the base64-encoded data URL string (e.g. \"data:image/png;base64,...\")\n\tData string\n\t// LastFetched is when this entry was last updated.\n\tLastFetched time.Time\n}\n\n// --- Global variables for managing in-flight fetches ---\n// We use a mutex and a simple map to prevent multiple simultaneous fetches for the same domain.\nvar (\n\tfetchLock sync.Mutex\n\tfetching  = make(map[string]bool)\n)\n\n// Use a semaphore (buffered channel) to limit concurrent fetches to 5.\nvar fetchSemaphore = make(chan bool, 5)\n\nvar (\n\tfaviconCacheLock sync.Mutex\n\tfaviconCache     = make(map[string]*FaviconCacheItem)\n)\n\n// --- GetFavicon ---\n//\n// GetFavicon takes a URL string and returns a base64-encoded src URL for an <img>\n// tag. If the favicon is already in cache and “fresh,” it returns it immediately.\n// Otherwise it kicks off a background fetch (if one isn’t already in progress)\n// and returns whatever is in the cache (which may be empty).\nfunc GetFavicon(urlStr string) string {\n\t// Parse the URL and extract the domain.\n\tparsedURL, err := url.Parse(urlStr)\n\tif err != nil {\n\t\tlog.Printf(\"GetFavicon: invalid URL %q: %v\", urlStr, err)\n\t\treturn \"\"\n\t}\n\tdomain := parsedURL.Hostname()\n\tif domain == \"\" {\n\t\tlog.Printf(\"GetFavicon: no hostname found in URL %q\", urlStr)\n\t\treturn \"\"\n\t}\n\n\t// Try to get from our cache.\n\titem, found := GetFromCache(domain)\n\tif found {\n\t\t// If the cached entry is not stale, return it.\n\t\tif time.Since(item.LastFetched) < cacheDuration {\n\t\t\treturn item.Data\n\t\t}\n\t}\n\n\t// Either the item was not found or it’s stale:\n\t// Launch an async fetch if one isn’t already running for this domain.\n\ttriggerAsyncFetch(domain)\n\n\t// Return the cached value (even if stale or empty).\n\treturn item.Data\n}\n\n// triggerAsyncFetch starts a goroutine to update the favicon cache\n// for the given domain if one isn’t already in progress.\nfunc triggerAsyncFetch(domain string) {\n\tfetchLock.Lock()\n\tif fetching[domain] {\n\t\t// Already fetching this domain; nothing to do.\n\t\tfetchLock.Unlock()\n\t\treturn\n\t}\n\t// Mark this domain as in-flight.\n\tfetching[domain] = true\n\tfetchLock.Unlock()\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"Favicon:triggerAsyncFetch\", recover())\n\t\t}()\n\n\t\t// Acquire a slot in the semaphore.\n\t\tfetchSemaphore <- true\n\n\t\t// When done, ensure that we clear the “fetching” flag.\n\t\tdefer func() {\n\t\t\t<-fetchSemaphore\n\t\t\tfetchLock.Lock()\n\t\t\tdelete(fetching, domain)\n\t\t\tfetchLock.Unlock()\n\t\t}()\n\n\t\ticonStr, err := fetchFavicon(domain)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"triggerAsyncFetch: error fetching favicon for %s: %v\", domain, err)\n\t\t}\n\t\tSetInCache(domain, FaviconCacheItem{Data: iconStr, LastFetched: time.Now()})\n\t}()\n}\n\nfunc fetchFavicon(domain string) (string, error) {\n\t// Create a context that times out after 5 seconds.\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\t// Special case for github.com - use their dark favicon from assets domain\n\turl := \"https://\" + domain + \"/favicon.ico\"\n\tif domain == \"github.com\" {\n\t\turl = \"https://github.githubassets.com/favicons/favicon-dark.png\"\n\t}\n\n\t// Create a new HTTP request with the context.\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error creating request for %s: %w\", url, err)\n\t}\n\n\t// Execute the HTTP request.\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error fetching favicon from %s: %w\", url, err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Ensure we got a 200 OK.\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"non-OK HTTP status: %d fetching %s\", resp.StatusCode, url)\n\t}\n\n\t// Read the favicon bytes.\n\tdata, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error reading favicon data from %s: %w\", url, err)\n\t}\n\n\t// Encode the image bytes to base64.\n\tb64Data := base64.StdEncoding.EncodeToString(data)\n\tif len(b64Data) > maxIconSize {\n\t\treturn \"\", fmt.Errorf(\"favicon too large: %d bytes\", len(b64Data))\n\t}\n\n\t// Try to detect MIME type from Content-Type header first\n\tmimeType := resp.Header.Get(\"Content-Type\")\n\tif mimeType == \"\" {\n\t\t// If no Content-Type header, detect from content\n\t\tmimeType = http.DetectContentType(data)\n\t}\n\n\tif !strings.HasPrefix(mimeType, \"image/\") {\n\t\treturn \"\", fmt.Errorf(\"unexpected MIME type: %s\", mimeType)\n\t}\n\n\treturn \"data:\" + mimeType + \";base64,\" + b64Data, nil\n}\n\n// TODO store in blockstore\n\nfunc GetFromCache(key string) (FaviconCacheItem, bool) {\n\tfaviconCacheLock.Lock()\n\tdefer faviconCacheLock.Unlock()\n\titem, found := faviconCache[key]\n\tif !found {\n\t\treturn FaviconCacheItem{}, false\n\t}\n\treturn *item, true\n}\n\nfunc SetInCache(key string, item FaviconCacheItem) {\n\tfaviconCacheLock.Lock()\n\tdefer faviconCacheLock.Unlock()\n\tfaviconCache[key] = &item\n}\n"
  },
  {
    "path": "pkg/filebackup/filebackup.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage filebackup\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\nconst BackupRetentionPeriod = 5 * 24 * time.Hour\n\ntype BackupMetadata struct {\n\tFullPath  string `json:\"fullpath\"`\n\tTimestamp string `json:\"timestamp\"`\n\tPerm      string `json:\"perm\"`\n}\n\nfunc MakeFileBackup(absFilePath string) (string, error) {\n\tfileInfo, err := os.Stat(absFilePath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to stat file for backup: %w\", err)\n\t}\n\n\tfileData, err := os.ReadFile(absFilePath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to read file for backup: %w\", err)\n\t}\n\n\tdir := filepath.Dir(absFilePath)\n\tbasename := filepath.Base(absFilePath)\n\n\thash := sha256.Sum256([]byte(dir))\n\tdirHash8 := hex.EncodeToString(hash[:])[:8]\n\n\tuuidV7, err := uuid.NewV7()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to generate UUID: %w\", err)\n\t}\n\tuuidStr := uuidV7.String()\n\n\tnow := time.Now()\n\tdateStr := now.Format(\"2006-01-02\")\n\n\tbackupDir := filepath.Join(wavebase.GetWaveCachesDir(), \"waveai-backups\", dateStr)\n\terr = os.MkdirAll(backupDir, 0700)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create backup directory: %w\", err)\n\t}\n\n\tbackupName := fmt.Sprintf(\"%s.%s.%s.bak\", basename, dirHash8, uuidStr)\n\tbackupPath := filepath.Join(backupDir, backupName)\n\n\terr = os.WriteFile(backupPath, fileData, 0600)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to write backup file: %w\", err)\n\t}\n\n\tmetadata := BackupMetadata{\n\t\tFullPath:  absFilePath,\n\t\tTimestamp: now.Format(time.RFC3339),\n\t\tPerm:      fmt.Sprintf(\"%04o\", fileInfo.Mode().Perm()),\n\t}\n\n\tmetadataJSON, err := json.MarshalIndent(metadata, \"\", \"  \")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to marshal backup metadata: %w\", err)\n\t}\n\n\tmetadataName := fmt.Sprintf(\"%s.%s.%s.json\", basename, dirHash8, uuidStr)\n\tmetadataPath := filepath.Join(backupDir, metadataName)\n\n\terr = os.WriteFile(metadataPath, metadataJSON, 0600)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to write backup metadata: %w\", err)\n\t}\n\n\treturn backupPath, nil\n}\n\nfunc RestoreBackup(backupFilePath string, restoreToFileName string) error {\n\tbackupData, err := os.ReadFile(backupFilePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read backup file: %w\", err)\n\t}\n\n\tmetadataPath := backupFilePath[:len(backupFilePath)-4] + \".json\"\n\tmetadataData, err := os.ReadFile(metadataPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read backup metadata: %w\", err)\n\t}\n\n\tvar metadata BackupMetadata\n\terr = json.Unmarshal(metadataData, &metadata)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to unmarshal backup metadata: %w\", err)\n\t}\n\n\tif metadata.FullPath != restoreToFileName {\n\t\treturn fmt.Errorf(\"backup metadata mismatch: expected %s, got %s\", restoreToFileName, metadata.FullPath)\n\t}\n\n\tvar perm os.FileMode\n\t_, err = fmt.Sscanf(metadata.Perm, \"%o\", &perm)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse file permissions: %w\", err)\n\t}\n\n\terr = os.WriteFile(restoreToFileName, backupData, perm)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to restore file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc CleanupOldBackups() error {\n\tbackupBaseDir := filepath.Join(wavebase.GetWaveCachesDir(), \"waveai-backups\")\n\n\tif _, err := os.Stat(backupBaseDir); os.IsNotExist(err) {\n\t\treturn nil\n\t}\n\n\tentries, err := os.ReadDir(backupBaseDir)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read backup directory: %w\", err)\n\t}\n\n\tcutoffTime := time.Now().Add(-BackupRetentionPeriod)\n\tvar removedCount int\n\n\tfor _, entry := range entries {\n\t\tif !entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\tdirPath := filepath.Join(backupBaseDir, entry.Name())\n\t\tinfo, err := entry.Info()\n\t\tif err != nil {\n\t\t\tlog.Printf(\"failed to get info for backup dir %s: %v\\n\", entry.Name(), err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif info.ModTime().Before(cutoffTime) {\n\t\t\terr = os.RemoveAll(dirPath)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"failed to remove old backup dir %s: %v\\n\", entry.Name(), err)\n\t\t\t} else {\n\t\t\t\tremovedCount++\n\t\t\t}\n\t\t}\n\t}\n\n\tif removedCount > 0 {\n\t\tlog.Printf(\"cleaned up %d old backup directories\\n\", removedCount)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/filestore/blockstore.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage filestore\n\n// the blockstore package implements a write cache for wave files\n// it is not a read cache (reads still go to the DB -- unless items are in the cache)\n// but all writes only go to the cache, and then the cache is periodically flushed to the DB\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log\"\n\t\"math\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/ijson\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nconst (\n\t// ijson meta keys\n\tIJsonNumCommands      = \"ijson:numcmds\"\n\tIJsonIncrementalBytes = \"ijson:incbytes\"\n)\n\nconst (\n\tIJsonHighCommands = 100\n\tIJsonHighRatio    = 3\n\tIJsonLowRatio     = 1\n\tIJsonLowCommands  = 10\n)\n\nconst DefaultPartDataSize = 64 * 1024\nconst DefaultFlushTime = 5 * time.Second\nconst NoPartIdx = -1\n\n// for unit tests\nvar warningCount = &atomic.Int32{}\nvar flushErrorCount = &atomic.Int32{}\n\nvar partDataSize int64 = DefaultPartDataSize // overridden in tests\nvar stopFlush = &atomic.Bool{}\n\nvar WFS *FileStore = &FileStore{\n\tLock:  &sync.Mutex{},\n\tCache: make(map[cacheKey]*CacheEntry),\n}\n\ntype WaveFile struct {\n\t// these fields are static (not updated)\n\tZoneId    string          `json:\"zoneid\"`\n\tName      string          `json:\"name\"`\n\tOpts      wshrpc.FileOpts `json:\"opts\"`\n\tCreatedTs int64           `json:\"createdts\"`\n\n\t//  these fields are mutable\n\tSize  int64           `json:\"size\"`\n\tModTs int64           `json:\"modts\"`\n\tMeta  wshrpc.FileMeta `json:\"meta\"` // only top-level keys can be updated (lower levels are immutable)\n}\n\n// for regular files this is just Size\n// for circular files this is min(Size, MaxSize)\nfunc (f WaveFile) DataLength() int64 {\n\tif f.Opts.Circular {\n\t\treturn minInt64(f.Size, f.Opts.MaxSize)\n\t}\n\treturn f.Size\n}\n\n// for regular files this is just 0\n// for circular files this is the index of the first byte of data we have\nfunc (f WaveFile) DataStartIdx() int64 {\n\tif f.Opts.Circular && f.Size > f.Opts.MaxSize {\n\t\treturn f.Size - f.Opts.MaxSize\n\t}\n\treturn 0\n}\n\n// this works because lower levels are immutable\nfunc copyMeta(meta wshrpc.FileMeta) wshrpc.FileMeta {\n\tnewMeta := make(wshrpc.FileMeta)\n\tfor k, v := range meta {\n\t\tnewMeta[k] = v\n\t}\n\treturn newMeta\n}\n\nfunc (f *WaveFile) DeepCopy() *WaveFile {\n\tif f == nil {\n\t\treturn nil\n\t}\n\tnewFile := *f\n\tnewFile.Meta = copyMeta(f.Meta)\n\treturn &newFile\n}\n\nfunc (WaveFile) UseDBMap() {}\n\ntype FileData struct {\n\tZoneId  string `json:\"zoneid\"`\n\tName    string `json:\"name\"`\n\tPartIdx int    `json:\"partidx\"`\n\tData    []byte `json:\"data\"`\n}\n\nfunc (FileData) UseDBMap() {}\n\n// synchronous (does not interact with the cache)\nfunc (s *FileStore) MakeFile(ctx context.Context, zoneId string, name string, meta wshrpc.FileMeta, opts wshrpc.FileOpts) error {\n\tif opts.MaxSize < 0 {\n\t\treturn fmt.Errorf(\"max size must be non-negative\")\n\t}\n\tif opts.Circular && opts.MaxSize <= 0 {\n\t\treturn fmt.Errorf(\"circular file must have a max size\")\n\t}\n\tif opts.Circular && opts.IJson {\n\t\treturn fmt.Errorf(\"circular file cannot be ijson\")\n\t}\n\tif opts.Circular {\n\t\tif opts.MaxSize%partDataSize != 0 {\n\t\t\topts.MaxSize = (opts.MaxSize/partDataSize + 1) * partDataSize\n\t\t}\n\t}\n\tif opts.IJsonBudget > 0 && !opts.IJson {\n\t\treturn fmt.Errorf(\"ijson budget requires ijson\")\n\t}\n\tif opts.IJsonBudget < 0 {\n\t\treturn fmt.Errorf(\"ijson budget must be non-negative\")\n\t}\n\treturn withLock(s, zoneId, name, func(entry *CacheEntry) error {\n\t\tif entry.File != nil {\n\t\t\treturn fs.ErrExist\n\t\t}\n\t\tnow := time.Now().UnixMilli()\n\t\tfile := &WaveFile{\n\t\t\tZoneId:    zoneId,\n\t\t\tName:      name,\n\t\t\tSize:      0,\n\t\t\tCreatedTs: now,\n\t\t\tModTs:     now,\n\t\t\tOpts:      opts,\n\t\t\tMeta:      meta,\n\t\t}\n\t\treturn dbInsertFile(ctx, file)\n\t})\n}\n\nfunc (s *FileStore) DeleteFile(ctx context.Context, zoneId string, name string) error {\n\treturn withLock(s, zoneId, name, func(entry *CacheEntry) error {\n\t\terr := dbDeleteFile(ctx, zoneId, name)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error deleting file: %v\", err)\n\t\t}\n\t\tentry.clear()\n\t\treturn nil\n\t})\n}\n\nfunc (s *FileStore) DeleteZone(ctx context.Context, zoneId string) error {\n\tfileNames, err := dbGetZoneFileNames(ctx, zoneId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting zone files: %v\", err)\n\t}\n\tfor _, name := range fileNames {\n\t\ts.DeleteFile(ctx, zoneId, name)\n\t}\n\treturn nil\n}\n\n// if file doesn't exsit, returns fs.ErrNotExist\nfunc (s *FileStore) Stat(ctx context.Context, zoneId string, name string) (*WaveFile, error) {\n\treturn withLockRtn(s, zoneId, name, func(entry *CacheEntry) (*WaveFile, error) {\n\t\tfile, err := entry.loadFileForRead(ctx)\n\t\tif err != nil {\n\t\t\tif err == fs.ErrNotExist {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"error getting file: %v\", err)\n\t\t}\n\t\treturn file.DeepCopy(), nil\n\t})\n}\n\nfunc (s *FileStore) ListFiles(ctx context.Context, zoneId string) ([]*WaveFile, error) {\n\tfiles, err := dbGetZoneFiles(ctx, zoneId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting zone files: %v\", err)\n\t}\n\tfor idx, file := range files {\n\t\twithLock(s, file.ZoneId, file.Name, func(entry *CacheEntry) error {\n\t\t\tif entry.File != nil {\n\t\t\t\tfiles[idx] = entry.File.DeepCopy()\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t}\n\treturn files, nil\n}\n\nfunc (s *FileStore) WriteMeta(ctx context.Context, zoneId string, name string, meta wshrpc.FileMeta, merge bool) error {\n\treturn withLock(s, zoneId, name, func(entry *CacheEntry) error {\n\t\terr := entry.loadFileIntoCache(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif merge {\n\t\t\tfor k, v := range meta {\n\t\t\t\tif v == nil {\n\t\t\t\t\tdelete(entry.File.Meta, k)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tentry.File.Meta[k] = v\n\t\t\t}\n\t\t} else {\n\t\t\tentry.File.Meta = meta\n\t\t}\n\t\tentry.File.ModTs = time.Now().UnixMilli()\n\t\treturn nil\n\t})\n}\n\nfunc (s *FileStore) WriteFile(ctx context.Context, zoneId string, name string, data []byte) error {\n\treturn withLock(s, zoneId, name, func(entry *CacheEntry) error {\n\t\terr := entry.loadFileIntoCache(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tentry.writeAt(0, data, true)\n\t\t// since WriteFile can *truncate* the file, we need to flush the file to the DB immediately\n\t\treturn entry.flushToDB(ctx, true)\n\t})\n}\n\nfunc (s *FileStore) WriteAt(ctx context.Context, zoneId string, name string, offset int64, data []byte) error {\n\tif offset < 0 {\n\t\treturn fmt.Errorf(\"offset must be non-negative\")\n\t}\n\treturn withLock(s, zoneId, name, func(entry *CacheEntry) error {\n\t\terr := entry.loadFileIntoCache(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfile := entry.File\n\t\tif offset > file.Size {\n\t\t\treturn fmt.Errorf(\"offset is past the end of the file\")\n\t\t}\n\t\tpartMap := file.computePartMap(offset, int64(len(data)))\n\t\tincompleteParts := incompletePartsFromMap(partMap)\n\t\terr = entry.loadDataPartsIntoCache(ctx, incompleteParts)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tentry.writeAt(offset, data, false)\n\t\treturn nil\n\t})\n}\n\nfunc (s *FileStore) AppendData(ctx context.Context, zoneId string, name string, data []byte) error {\n\treturn withLock(s, zoneId, name, func(entry *CacheEntry) error {\n\t\terr := entry.loadFileIntoCache(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tpartMap := entry.File.computePartMap(entry.File.Size, int64(len(data)))\n\t\tincompleteParts := incompletePartsFromMap(partMap)\n\t\tif len(incompleteParts) > 0 {\n\t\t\terr = entry.loadDataPartsIntoCache(ctx, incompleteParts)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tentry.writeAt(entry.File.Size, data, false)\n\t\treturn nil\n\t})\n}\n\nfunc metaIncrement(file *WaveFile, key string, amount int) int {\n\tif file.Meta == nil {\n\t\tfile.Meta = make(wshrpc.FileMeta)\n\t}\n\tval, ok := file.Meta[key].(int)\n\tif !ok {\n\t\tval = 0\n\t}\n\tnewVal := val + amount\n\tfile.Meta[key] = newVal\n\treturn newVal\n}\n\nfunc (s *FileStore) compactIJson(ctx context.Context, entry *CacheEntry) error {\n\t// we don't need to lock the entry because we have the lock on the filestore\n\t_, fullData, err := entry.readAt(ctx, 0, 0, true)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnewBytes, err := ijson.CompactIJson(fullData, entry.File.Opts.IJsonBudget)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry.writeAt(0, newBytes, true)\n\treturn nil\n}\n\nfunc (s *FileStore) CompactIJson(ctx context.Context, zoneId string, name string) error {\n\treturn withLock(s, zoneId, name, func(entry *CacheEntry) error {\n\t\terr := entry.loadFileIntoCache(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif !entry.File.Opts.IJson {\n\t\t\treturn fmt.Errorf(\"file %s:%s is not an ijson file\", zoneId, name)\n\t\t}\n\t\treturn s.compactIJson(ctx, entry)\n\t})\n}\n\nfunc (s *FileStore) AppendIJson(ctx context.Context, zoneId string, name string, command map[string]any) error {\n\tdata, err := ijson.ValidateAndMarshalCommand(command)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn withLock(s, zoneId, name, func(entry *CacheEntry) error {\n\t\terr := entry.loadFileIntoCache(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif !entry.File.Opts.IJson {\n\t\t\treturn fmt.Errorf(\"file %s:%s is not an ijson file\", zoneId, name)\n\t\t}\n\t\tpartMap := entry.File.computePartMap(entry.File.Size, int64(len(data)))\n\t\tincompleteParts := incompletePartsFromMap(partMap)\n\t\tif len(incompleteParts) > 0 {\n\t\t\terr = entry.loadDataPartsIntoCache(ctx, incompleteParts)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\toldSize := entry.File.Size\n\t\tentry.writeAt(entry.File.Size, data, false)\n\t\tentry.writeAt(entry.File.Size, []byte(\"\\n\"), false)\n\t\tif oldSize == 0 {\n\t\t\treturn nil\n\t\t}\n\t\t// check if we should compact\n\t\tnumCmds := metaIncrement(entry.File, IJsonNumCommands, 1)\n\t\tnumBytes := metaIncrement(entry.File, IJsonIncrementalBytes, len(data)+1)\n\t\tincRatio := float64(numBytes) / float64(entry.File.Size)\n\t\tif numCmds > IJsonHighCommands || incRatio >= IJsonHighRatio || (numCmds > IJsonLowCommands && incRatio >= IJsonLowRatio) {\n\t\t\terr := s.compactIJson(ctx, entry)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (s *FileStore) GetAllZoneIds(ctx context.Context) ([]string, error) {\n\treturn dbGetAllZoneIds(ctx)\n}\n\n// returns (offset, data, error)\n// we return the offset because the offset may have been adjusted if the size was too big (for circular files)\nfunc (s *FileStore) ReadAt(ctx context.Context, zoneId string, name string, offset int64, size int64) (rtnOffset int64, rtnData []byte, rtnErr error) {\n\tif size < 0 || size > math.MaxInt {\n\t\treturn 0, nil, fmt.Errorf(\"size must be non-negative and less than MaxInt\")\n\t}\n\twithLock(s, zoneId, name, func(entry *CacheEntry) error {\n\t\trtnOffset, rtnData, rtnErr = entry.readAt(ctx, offset, size, false)\n\t\treturn nil\n\t})\n\treturn\n}\n\n// returns (offset, data, error)\nfunc (s *FileStore) ReadFile(ctx context.Context, zoneId string, name string) (rtnOffset int64, rtnData []byte, rtnErr error) {\n\twithLock(s, zoneId, name, func(entry *CacheEntry) error {\n\t\trtnOffset, rtnData, rtnErr = entry.readAt(ctx, 0, 0, true)\n\t\treturn nil\n\t})\n\treturn\n}\n\ntype FlushStats struct {\n\tFlushDuration   time.Duration\n\tNumDirtyEntries int\n\tNumCommitted    int\n}\n\nfunc (s *FileStore) FlushCache(ctx context.Context) (stats FlushStats, rtnErr error) {\n\twasFlushing := s.setUnlessFlushing()\n\tif wasFlushing {\n\t\treturn stats, fmt.Errorf(\"flush already in progress\")\n\t}\n\tdefer s.setIsFlushing(false)\n\tstartTime := time.Now()\n\tdefer func() {\n\t\tstats.FlushDuration = time.Since(startTime)\n\t}()\n\n\t// get a copy of dirty keys so we can iterate without the lock\n\tdirtyCacheKeys := s.getDirtyCacheKeys()\n\tstats.NumDirtyEntries = len(dirtyCacheKeys)\n\tfor _, key := range dirtyCacheKeys {\n\t\terr := withLock(s, key.ZoneId, key.Name, func(entry *CacheEntry) error {\n\t\t\treturn entry.flushToDB(ctx, false)\n\t\t})\n\t\tif ctx.Err() != nil {\n\t\t\t// transient error (also must stop the loop)\n\t\t\treturn stats, ctx.Err()\n\t\t}\n\t\tif err != nil {\n\t\t\treturn stats, fmt.Errorf(\"error flushing cache entry[%v]: %v\", key, err)\n\t\t}\n\t\tstats.NumCommitted++\n\t}\n\treturn stats, nil\n}\n\n///////////////////////////////////\n\nfunc (f *WaveFile) partIdxAtOffset(offset int64) int {\n\tpartIdx := int(offset / partDataSize)\n\tif f.Opts.Circular {\n\t\tmaxPart := int(f.Opts.MaxSize / partDataSize)\n\t\tpartIdx = partIdx % maxPart\n\t}\n\treturn partIdx\n}\n\nfunc incompletePartsFromMap(partMap map[int]int) []int {\n\tvar incompleteParts []int\n\tfor partIdx, size := range partMap {\n\t\tif size != int(partDataSize) {\n\t\t\tincompleteParts = append(incompleteParts, partIdx)\n\t\t}\n\t}\n\treturn incompleteParts\n}\n\nfunc getPartIdxsFromMap(partMap map[int]int) []int {\n\tvar partIdxs []int\n\tfor partIdx := range partMap {\n\t\tpartIdxs = append(partIdxs, partIdx)\n\t}\n\treturn partIdxs\n}\n\n// returns a map of partIdx to amount of data to write to that part\nfunc (file *WaveFile) computePartMap(startOffset int64, size int64) map[int]int {\n\tpartMap := make(map[int]int)\n\tendOffset := startOffset + size\n\tstartFileOffset := startOffset - (startOffset % partDataSize)\n\tfor testOffset := startFileOffset; testOffset < endOffset; testOffset += partDataSize {\n\t\tpartIdx := file.partIdxAtOffset(testOffset)\n\t\tpartStartOffset := testOffset\n\t\tpartEndOffset := testOffset + partDataSize\n\t\tpartWriteStartOffset := 0\n\t\tpartWriteEndOffset := int(partDataSize)\n\t\tif startOffset > partStartOffset && startOffset < partEndOffset {\n\t\t\tpartWriteStartOffset = int(startOffset - partStartOffset)\n\t\t}\n\t\tif endOffset > partStartOffset && endOffset < partEndOffset {\n\t\t\tpartWriteEndOffset = int(endOffset - partStartOffset)\n\t\t}\n\t\tpartMap[partIdx] = partWriteEndOffset - partWriteStartOffset\n\t}\n\treturn partMap\n}\n\nfunc (s *FileStore) getDirtyCacheKeys() []cacheKey {\n\ts.Lock.Lock()\n\tdefer s.Lock.Unlock()\n\tvar dirtyCacheKeys []cacheKey\n\tfor key, entry := range s.Cache {\n\t\tif entry.File != nil {\n\t\t\tdirtyCacheKeys = append(dirtyCacheKeys, key)\n\t\t}\n\t}\n\treturn dirtyCacheKeys\n}\n\nfunc (s *FileStore) setIsFlushing(flushing bool) {\n\ts.Lock.Lock()\n\tdefer s.Lock.Unlock()\n\ts.IsFlushing = flushing\n}\n\n// returns old value of IsFlushing\nfunc (s *FileStore) setUnlessFlushing() bool {\n\ts.Lock.Lock()\n\tdefer s.Lock.Unlock()\n\tif s.IsFlushing {\n\t\treturn true\n\t}\n\ts.IsFlushing = true\n\treturn false\n}\n\nfunc (s *FileStore) runFlushWithNewContext() (FlushStats, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultFlushTime)\n\tdefer cancelFn()\n\treturn s.FlushCache(ctx)\n}\n\nfunc (s *FileStore) runFlusher() {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"filestore flusher\", recover())\n\t}()\n\tfor {\n\t\tstats, err := s.runFlushWithNewContext()\n\t\tif err != nil || stats.NumDirtyEntries > 0 {\n\t\t\tlog.Printf(\"filestore flush: %d/%d entries flushed, err:%v\\n\", stats.NumCommitted, stats.NumDirtyEntries, err)\n\t\t}\n\t\tif stopFlush.Load() {\n\t\t\tlog.Printf(\"filestore flusher stopping\\n\")\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(DefaultFlushTime)\n\t}\n}\n\nfunc minInt64(a, b int64) int64 {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n"
  },
  {
    "path": "pkg/filestore/blockstore_cache.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage filestore\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"sync\"\n\t\"time\"\n)\n\ntype cacheKey struct {\n\tZoneId string\n\tName   string\n}\n\ntype FileStore struct {\n\tLock       *sync.Mutex\n\tCache      map[cacheKey]*CacheEntry\n\tIsFlushing bool\n}\n\ntype DataCacheEntry struct {\n\tPartIdx int\n\tData    []byte // capacity is always ZoneDataPartSize\n}\n\n// if File or DataEntries are not nil then they are dirty (need to be flushed to disk)\ntype CacheEntry struct {\n\tPinCount int // this is synchronzed with the FileStore lock (not the entry lock)\n\n\tLock        *sync.Mutex\n\tZoneId      string\n\tName        string\n\tFile        *WaveFile\n\tDataEntries map[int]*DataCacheEntry\n\tFlushErrors int\n}\n\n//lint:ignore U1000 used for testing\nfunc (e *CacheEntry) dump() string {\n\tvar buf bytes.Buffer\n\tfmt.Fprintf(&buf, \"CacheEntry [ZoneId: %q, Name: %q] PinCount: %d\\n\", e.ZoneId, e.Name, e.PinCount)\n\tfmt.Fprintf(&buf, \"  FileEntry: %v\\n\", e.File)\n\tfor idx, dce := range e.DataEntries {\n\t\tfmt.Fprintf(&buf, \"  DataEntry[%d]: %q\\n\", idx, string(dce.Data))\n\t}\n\treturn buf.String()\n}\n\nfunc makeDataCacheEntry(partIdx int) *DataCacheEntry {\n\treturn &DataCacheEntry{\n\t\tPartIdx: partIdx,\n\t\tData:    make([]byte, 0, partDataSize),\n\t}\n}\n\n// will create new entries\nfunc (s *FileStore) getEntryAndPin(zoneId string, name string) *CacheEntry {\n\ts.Lock.Lock()\n\tdefer s.Lock.Unlock()\n\tentry := s.Cache[cacheKey{ZoneId: zoneId, Name: name}]\n\tif entry == nil {\n\t\tentry = makeCacheEntry(zoneId, name)\n\t\ts.Cache[cacheKey{ZoneId: zoneId, Name: name}] = entry\n\t}\n\tentry.PinCount++\n\treturn entry\n}\n\nfunc (s *FileStore) unpinEntryAndTryDelete(zoneId string, name string) {\n\ts.Lock.Lock()\n\tdefer s.Lock.Unlock()\n\tentry := s.Cache[cacheKey{ZoneId: zoneId, Name: name}]\n\tif entry == nil {\n\t\treturn\n\t}\n\tentry.PinCount--\n\tif entry.PinCount <= 0 && entry.File == nil {\n\t\tdelete(s.Cache, cacheKey{ZoneId: zoneId, Name: name})\n\t}\n}\n\nfunc (entry *CacheEntry) clear() {\n\tentry.File = nil\n\tentry.DataEntries = make(map[int]*DataCacheEntry)\n\tentry.FlushErrors = 0\n}\n\nfunc (entry *CacheEntry) getOrCreateDataCacheEntry(partIdx int) *DataCacheEntry {\n\tif entry.DataEntries[partIdx] == nil {\n\t\tentry.DataEntries[partIdx] = makeDataCacheEntry(partIdx)\n\t}\n\treturn entry.DataEntries[partIdx]\n}\n\n// returns err if file does not exist\nfunc (entry *CacheEntry) loadFileIntoCache(ctx context.Context) error {\n\tif entry.File != nil {\n\t\treturn nil\n\t}\n\tfile, err := entry.loadFileForRead(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry.File = file\n\treturn nil\n}\n\n// does not populate the cache entry, returns err if file does not exist\nfunc (entry *CacheEntry) loadFileForRead(ctx context.Context) (*WaveFile, error) {\n\tif entry.File != nil {\n\t\treturn entry.File, nil\n\t}\n\tfile, err := dbGetZoneFile(ctx, entry.ZoneId, entry.Name)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting file: %w\", err)\n\t}\n\tif file == nil {\n\t\treturn nil, fs.ErrNotExist\n\t}\n\treturn file, nil\n}\n\nfunc withLock(s *FileStore, zoneId string, name string, fn func(*CacheEntry) error) error {\n\tentry := s.getEntryAndPin(zoneId, name)\n\tdefer s.unpinEntryAndTryDelete(zoneId, name)\n\tentry.Lock.Lock()\n\tdefer entry.Lock.Unlock()\n\treturn fn(entry)\n}\n\nfunc withLockRtn[T any](s *FileStore, zoneId string, name string, fn func(*CacheEntry) (T, error)) (T, error) {\n\tvar rtnVal T\n\trtnErr := withLock(s, zoneId, name, func(entry *CacheEntry) error {\n\t\tvar err error\n\t\trtnVal, err = fn(entry)\n\t\treturn err\n\t})\n\treturn rtnVal, rtnErr\n}\n\nfunc (dce *DataCacheEntry) writeToPart(offset int64, data []byte) (int64, *DataCacheEntry) {\n\tleftInPart := partDataSize - offset\n\ttoWrite := int64(len(data))\n\tif toWrite > leftInPart {\n\t\ttoWrite = leftInPart\n\t}\n\tif int64(len(dce.Data)) < offset+toWrite {\n\t\tdce.Data = dce.Data[:offset+toWrite]\n\t}\n\tcopy(dce.Data[offset:], data[:toWrite])\n\treturn toWrite, dce\n}\n\nfunc (entry *CacheEntry) writeAt(offset int64, data []byte, replace bool) {\n\tif replace {\n\t\tentry.File.Size = 0\n\t}\n\tif entry.File.Opts.Circular {\n\t\tstartCirFileOffset := entry.File.Size - entry.File.Opts.MaxSize\n\t\tif offset+int64(len(data)) <= startCirFileOffset {\n\t\t\t// write is before the start of the circular file\n\t\t\treturn\n\t\t}\n\t\tif offset < startCirFileOffset {\n\t\t\t// truncate data (from the front), update offset\n\t\t\ttruncateAmt := startCirFileOffset - offset\n\t\t\tdata = data[truncateAmt:]\n\t\t\toffset += truncateAmt\n\t\t}\n\t\tif int64(len(data)) > entry.File.Opts.MaxSize {\n\t\t\t// truncate data (from the front), update offset\n\t\t\ttruncateAmt := int64(len(data)) - entry.File.Opts.MaxSize\n\t\t\tdata = data[truncateAmt:]\n\t\t\toffset += truncateAmt\n\t\t}\n\t}\n\tendWriteOffset := offset + int64(len(data))\n\tif replace {\n\t\tentry.DataEntries = make(map[int]*DataCacheEntry)\n\t}\n\tfor len(data) > 0 {\n\t\tpartIdx := int(offset / partDataSize)\n\t\tif entry.File.Opts.Circular {\n\t\t\tmaxPart := int(entry.File.Opts.MaxSize / partDataSize)\n\t\t\tpartIdx = partIdx % maxPart\n\t\t}\n\t\tpartOffset := offset % partDataSize\n\t\tpartData := entry.getOrCreateDataCacheEntry(partIdx)\n\t\tnw, newDce := partData.writeToPart(partOffset, data)\n\t\tentry.DataEntries[partIdx] = newDce\n\t\tdata = data[nw:]\n\t\toffset += nw\n\t}\n\tif endWriteOffset > entry.File.Size || replace {\n\t\tentry.File.Size = endWriteOffset\n\t}\n\tentry.File.ModTs = time.Now().UnixMilli()\n}\n\n// returns (realOffset, data, error)\nfunc (entry *CacheEntry) readAt(ctx context.Context, offset int64, size int64, readFull bool) (int64, []byte, error) {\n\tif offset < 0 {\n\t\treturn 0, nil, fmt.Errorf(\"offset cannot be negative\")\n\t}\n\tfile, err := entry.loadFileForRead(ctx)\n\tif err != nil {\n\t\treturn 0, nil, err\n\t}\n\tif readFull {\n\t\tsize = file.Size - offset\n\t}\n\tif offset+size > file.Size {\n\t\tsize = file.Size - offset\n\t}\n\tif file.Opts.Circular {\n\t\trealDataOffset := int64(0)\n\t\tif file.Size > file.Opts.MaxSize {\n\t\t\trealDataOffset = file.Size - file.Opts.MaxSize\n\t\t}\n\t\tif offset < realDataOffset {\n\t\t\ttruncateAmt := realDataOffset - offset\n\t\t\toffset += truncateAmt\n\t\t\tsize -= truncateAmt\n\t\t}\n\t\tif size <= 0 {\n\t\t\treturn realDataOffset, nil, nil\n\t\t}\n\t}\n\tpartMap := file.computePartMap(offset, size)\n\tdataEntryMap, err := entry.loadDataPartsForRead(ctx, getPartIdxsFromMap(partMap))\n\tif err != nil {\n\t\treturn 0, nil, err\n\t}\n\t// combine the entries into a single byte slice\n\t// note that we only want part of the first and last part depending on offset and size\n\trtnData := make([]byte, 0, size)\n\tamtLeftToRead := size\n\tcurReadOffset := offset\n\tfor amtLeftToRead > 0 {\n\t\tpartIdx := file.partIdxAtOffset(curReadOffset)\n\t\tpartDataEntry := dataEntryMap[partIdx]\n\t\tvar partData []byte\n\t\tif partDataEntry == nil {\n\t\t\tpartData = make([]byte, partDataSize)\n\t\t} else {\n\t\t\tpartData = partDataEntry.Data[0:partDataSize]\n\t\t}\n\t\tpartOffset := curReadOffset % partDataSize\n\t\tamtToRead := minInt64(partDataSize-partOffset, amtLeftToRead)\n\t\trtnData = append(rtnData, partData[partOffset:partOffset+amtToRead]...)\n\t\tamtLeftToRead -= amtToRead\n\t\tcurReadOffset += amtToRead\n\t}\n\treturn offset, rtnData, nil\n}\n\nfunc prunePartsWithCache(dataEntries map[int]*DataCacheEntry, parts []int) []int {\n\tvar rtn []int\n\tfor _, partIdx := range parts {\n\t\tif dataEntries[partIdx] != nil {\n\t\t\tcontinue\n\t\t}\n\t\trtn = append(rtn, partIdx)\n\t}\n\treturn rtn\n}\n\nfunc (entry *CacheEntry) loadDataPartsIntoCache(ctx context.Context, parts []int) error {\n\tparts = prunePartsWithCache(entry.DataEntries, parts)\n\tif len(parts) == 0 {\n\t\t// parts are already loaded\n\t\treturn nil\n\t}\n\tdbDataParts, err := dbGetFileParts(ctx, entry.ZoneId, entry.Name, parts)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting data parts: %w\", err)\n\t}\n\tfor partIdx, dce := range dbDataParts {\n\t\tentry.DataEntries[partIdx] = dce\n\t}\n\treturn nil\n}\n\nfunc (entry *CacheEntry) loadDataPartsForRead(ctx context.Context, parts []int) (map[int]*DataCacheEntry, error) {\n\tif len(parts) == 0 {\n\t\treturn nil, nil\n\t}\n\tdbParts := prunePartsWithCache(entry.DataEntries, parts)\n\tvar dbDataParts map[int]*DataCacheEntry\n\tif len(dbParts) > 0 {\n\t\tvar err error\n\t\tdbDataParts, err = dbGetFileParts(ctx, entry.ZoneId, entry.Name, dbParts)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error getting data parts: %w\", err)\n\t\t}\n\t}\n\trtn := make(map[int]*DataCacheEntry)\n\tfor _, partIdx := range parts {\n\t\tif entry.DataEntries[partIdx] != nil {\n\t\t\trtn[partIdx] = entry.DataEntries[partIdx]\n\t\t\tcontinue\n\t\t}\n\t\tif dbDataParts[partIdx] != nil {\n\t\t\trtn[partIdx] = dbDataParts[partIdx]\n\t\t\tcontinue\n\t\t}\n\t\t// part not found\n\t}\n\treturn rtn, nil\n}\n\nfunc makeCacheEntry(zoneId string, name string) *CacheEntry {\n\treturn &CacheEntry{\n\t\tLock:        &sync.Mutex{},\n\t\tZoneId:      zoneId,\n\t\tName:        name,\n\t\tPinCount:    0,\n\t\tFile:        nil,\n\t\tDataEntries: make(map[int]*DataCacheEntry),\n\t\tFlushErrors: 0,\n\t}\n}\n\nfunc (entry *CacheEntry) flushToDB(ctx context.Context, replace bool) error {\n\tif entry.File == nil {\n\t\treturn nil\n\t}\n\terr := dbWriteCacheEntry(ctx, entry.File, entry.DataEntries, replace)\n\tif ctx.Err() != nil {\n\t\t// transient error\n\t\treturn ctx.Err()\n\t}\n\tif err != nil {\n\t\tflushErrorCount.Add(1)\n\t\tentry.FlushErrors++\n\t\tif entry.FlushErrors > 3 {\n\t\t\tentry.clear()\n\t\t\treturn fmt.Errorf(\"too many flush errors (clearing entry): %w\", err)\n\t\t}\n\t\treturn err\n\t}\n\t// clear cache entry (data is now in db)\n\tentry.clear()\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/filestore/blockstore_dbops.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage filestore\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/dbutil\"\n)\n\n// can return fs.ErrExist\nfunc dbInsertFile(ctx context.Context, file *WaveFile) error {\n\t// will fail if file already exists\n\treturn WithTx(ctx, func(tx *TxWrap) error {\n\t\tquery := \"SELECT zoneid FROM db_wave_file WHERE zoneid = ? AND name = ?\"\n\t\tif tx.Exists(query, file.ZoneId, file.Name) {\n\t\t\treturn fs.ErrExist\n\t\t}\n\t\tquery = \"INSERT INTO db_wave_file (zoneid, name, size, createdts, modts, opts, meta) VALUES (?, ?, ?, ?, ?, ?, ?)\"\n\t\ttx.Exec(query, file.ZoneId, file.Name, file.Size, file.CreatedTs, file.ModTs, dbutil.QuickJson(file.Opts), dbutil.QuickJson(file.Meta))\n\t\treturn nil\n\t})\n}\n\nfunc dbDeleteFile(ctx context.Context, zoneId string, name string) error {\n\treturn WithTx(ctx, func(tx *TxWrap) error {\n\t\tquery := \"DELETE FROM db_wave_file WHERE zoneid = ? AND name = ?\"\n\t\ttx.Exec(query, zoneId, name)\n\t\tquery = \"DELETE FROM db_file_data WHERE zoneid = ? AND name = ?\"\n\t\ttx.Exec(query, zoneId, name)\n\t\treturn nil\n\t})\n}\n\nfunc dbGetZoneFileNames(ctx context.Context, zoneId string) ([]string, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) ([]string, error) {\n\t\tvar files []string\n\t\tquery := \"SELECT name FROM db_wave_file WHERE zoneid = ?\"\n\t\ttx.Select(&files, query, zoneId)\n\t\treturn files, nil\n\t})\n}\n\nfunc dbGetZoneFile(ctx context.Context, zoneId string, name string) (*WaveFile, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) (*WaveFile, error) {\n\t\tquery := \"SELECT * FROM db_wave_file WHERE zoneid = ? AND name = ?\"\n\t\tfile := dbutil.GetMappable[*WaveFile](tx, query, zoneId, name)\n\t\treturn file, nil\n\t})\n}\n\nfunc dbGetAllZoneIds(ctx context.Context) ([]string, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) ([]string, error) {\n\t\tvar ids []string\n\t\tquery := \"SELECT DISTINCT zoneid FROM db_wave_file\"\n\t\ttx.Select(&ids, query)\n\t\treturn ids, nil\n\t})\n}\n\nfunc dbGetFileParts(ctx context.Context, zoneId string, name string, parts []int) (map[int]*DataCacheEntry, error) {\n\tif len(parts) == 0 {\n\t\treturn nil, nil\n\t}\n\treturn WithTxRtn(ctx, func(tx *TxWrap) (map[int]*DataCacheEntry, error) {\n\t\tvar data []*DataCacheEntry\n\t\tquery := \"SELECT partidx, data FROM db_file_data WHERE zoneid = ? AND name = ? AND partidx IN (SELECT value FROM json_each(?))\"\n\t\ttx.Select(&data, query, zoneId, name, dbutil.QuickJsonArr(parts))\n\t\trtn := make(map[int]*DataCacheEntry)\n\t\tfor _, d := range data {\n\t\t\tif cap(d.Data) != int(partDataSize) {\n\t\t\t\tnewData := make([]byte, len(d.Data), partDataSize)\n\t\t\t\tcopy(newData, d.Data)\n\t\t\t\td.Data = newData\n\t\t\t}\n\t\t\trtn[d.PartIdx] = d\n\t\t}\n\t\treturn rtn, nil\n\t})\n}\n\nfunc dbGetZoneFiles(ctx context.Context, zoneId string) ([]*WaveFile, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) ([]*WaveFile, error) {\n\t\tquery := \"SELECT * FROM db_wave_file WHERE zoneid = ?\"\n\t\tfiles := dbutil.SelectMappable[*WaveFile](tx, query, zoneId)\n\t\treturn files, nil\n\t})\n}\n\nfunc dbWriteCacheEntry(ctx context.Context, file *WaveFile, dataEntries map[int]*DataCacheEntry, replace bool) error {\n\treturn WithTx(ctx, func(tx *TxWrap) error {\n\t\tquery := `SELECT zoneid FROM db_wave_file WHERE zoneid = ? AND name = ?`\n\t\tif !tx.Exists(query, file.ZoneId, file.Name) {\n\t\t\t// since deletion is synchronous this stops us from writing to a deleted file\n\t\t\treturn os.ErrNotExist\n\t\t}\n\t\t// we don't update CreatedTs or Opts\n\t\tquery = `UPDATE db_wave_file SET size = ?, modts = ?, meta = ? WHERE zoneid = ? AND name = ?`\n\t\ttx.Exec(query, file.Size, file.ModTs, dbutil.QuickJson(file.Meta), file.ZoneId, file.Name)\n\t\tif replace {\n\t\t\tquery = `DELETE FROM db_file_data WHERE zoneid = ? AND name = ?`\n\t\t\ttx.Exec(query, file.ZoneId, file.Name)\n\t\t}\n\t\tdataPartQuery := `REPLACE INTO db_file_data (zoneid, name, partidx, data) VALUES (?, ?, ?, ?)`\n\t\tfor partIdx, dataEntry := range dataEntries {\n\t\t\tif partIdx != dataEntry.PartIdx {\n\t\t\t\tpanic(fmt.Sprintf(\"partIdx:%d and dataEntry.PartIdx:%d do not match\", partIdx, dataEntry.PartIdx))\n\t\t\t}\n\t\t\ttx.Exec(dataPartQuery, file.ZoneId, file.Name, dataEntry.PartIdx, dataEntry.Data)\n\t\t}\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "pkg/filestore/blockstore_dbsetup.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage filestore\n\n// setup for filestore db\n// includes migration support and txwrap setup\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/migrateutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t_ \"github.com/mattn/go-sqlite3\"\n\t\"github.com/sawka/txwrap\"\n\n\tdbfs \"github.com/wavetermdev/waveterm/db\"\n)\n\nconst FilestoreDBName = \"filestore.db\"\n\ntype TxWrap = txwrap.TxWrap\n\nvar globalDB *sqlx.DB\nvar useTestingDb bool // just for testing (forces GetDB() to return an in-memory db)\n\nfunc InitFilestore() error {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\tvar err error\n\tglobalDB, err = MakeDB(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = migrateutil.Migrate(\"filestore\", globalDB.DB, dbfs.FilestoreMigrationFS, \"migrations-filestore\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !stopFlush.Load() {\n\t\tgo WFS.runFlusher()\n\t}\n\tlog.Printf(\"filestore initialized\\n\")\n\treturn nil\n}\n\nfunc GetDBName() string {\n\twaveHome := wavebase.GetWaveDataDir()\n\treturn filepath.Join(waveHome, wavebase.WaveDBDir, FilestoreDBName)\n}\n\nfunc MakeDB(ctx context.Context) (*sqlx.DB, error) {\n\tvar rtn *sqlx.DB\n\tvar err error\n\tif useTestingDb {\n\t\tdbName := \":memory:\"\n\t\tlog.Printf(\"[db] using in-memory db\\n\")\n\t\trtn, err = sqlx.Open(\"sqlite3\", dbName)\n\t} else {\n\t\tdbName := GetDBName()\n\t\tlog.Printf(\"[db] opening db %s\\n\", dbName)\n\t\trtn, err = sqlx.Open(\"sqlite3\", fmt.Sprintf(\"file:%s?mode=rwc&_journal_mode=WAL&_busy_timeout=5000\", dbName))\n\t}\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"opening db: %w\", err)\n\t}\n\trtn.DB.SetMaxOpenConns(1)\n\treturn rtn, nil\n}\n\nfunc WithTx(ctx context.Context, fn func(tx *TxWrap) error) error {\n\treturn txwrap.WithTx(ctx, globalDB, fn)\n}\n\nfunc WithTxRtn[RT any](ctx context.Context, fn func(tx *TxWrap) (RT, error)) (RT, error) {\n\treturn txwrap.WithTxRtn(ctx, globalDB, fn)\n}\n"
  },
  {
    "path": "pkg/filestore/blockstore_test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage filestore\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log\"\n\t\"reflect\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/ijson\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nfunc initDb(t *testing.T) {\n\tt.Logf(\"initializing db for %q\", t.Name())\n\tuseTestingDb = true\n\tpartDataSize = 50\n\twarningCount = &atomic.Int32{}\n\tstopFlush.Store(true)\n\terr := InitFilestore()\n\tif err != nil {\n\t\tt.Fatalf(\"error initializing filestore: %v\", err)\n\t}\n}\n\nfunc cleanupDb(t *testing.T) {\n\tt.Logf(\"cleaning up db for %q\", t.Name())\n\tif globalDB != nil {\n\t\tglobalDB.Close()\n\t\tglobalDB = nil\n\t}\n\tuseTestingDb = false\n\tpartDataSize = DefaultPartDataSize\n\tWFS.clearCache()\n\tif warningCount.Load() > 0 {\n\t\tt.Errorf(\"warning count: %d\", warningCount.Load())\n\t}\n\tif flushErrorCount.Load() > 0 {\n\t\tt.Errorf(\"flush error count: %d\", flushErrorCount.Load())\n\t}\n}\n\nfunc (s *FileStore) getCacheSize() int {\n\ts.Lock.Lock()\n\tdefer s.Lock.Unlock()\n\treturn len(s.Cache)\n}\n\nfunc (s *FileStore) clearCache() {\n\ts.Lock.Lock()\n\tdefer s.Lock.Unlock()\n\ts.Cache = make(map[cacheKey]*CacheEntry)\n}\n\n//lint:ignore U1000 used for testing\nfunc (s *FileStore) dump() string {\n\ts.Lock.Lock()\n\tdefer s.Lock.Unlock()\n\tvar buf bytes.Buffer\n\tbuf.WriteString(fmt.Sprintf(\"FileStore %d entries\\n\", len(s.Cache)))\n\tfor _, v := range s.Cache {\n\t\tentryStr := v.dump()\n\t\tbuf.WriteString(entryStr)\n\t\tbuf.WriteString(\"\\n\")\n\t}\n\treturn buf.String()\n}\n\nfunc TestCreate(t *testing.T) {\n\tinitDb(t)\n\tdefer cleanupDb(t)\n\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tzoneId := uuid.NewString()\n\terr := WFS.MakeFile(ctx, zoneId, \"testfile\", nil, wshrpc.FileOpts{})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\tfile, err := WFS.Stat(ctx, zoneId, \"testfile\")\n\tif err != nil {\n\t\tt.Fatalf(\"error stating file: %v\", err)\n\t}\n\tif file == nil {\n\t\tt.Fatalf(\"file not found\")\n\t}\n\tif file.ZoneId != zoneId {\n\t\tt.Fatalf(\"zone id mismatch\")\n\t}\n\tif file.Name != \"testfile\" {\n\t\tt.Fatalf(\"name mismatch\")\n\t}\n\tif file.Size != 0 {\n\t\tt.Fatalf(\"size mismatch\")\n\t}\n\tif file.CreatedTs == 0 {\n\t\tt.Fatalf(\"created ts zero\")\n\t}\n\tif file.ModTs == 0 {\n\t\tt.Fatalf(\"mod ts zero\")\n\t}\n\tif file.CreatedTs != file.ModTs {\n\t\tt.Fatalf(\"create ts != mod ts\")\n\t}\n\tif len(file.Meta) != 0 {\n\t\tt.Fatalf(\"meta should have no values\")\n\t}\n\tif file.Opts.Circular || file.Opts.IJson || file.Opts.MaxSize != 0 {\n\t\tt.Fatalf(\"opts not empty\")\n\t}\n\tzoneIds, err := WFS.GetAllZoneIds(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"error getting zone ids: %v\", err)\n\t}\n\tif len(zoneIds) != 1 {\n\t\tt.Fatalf(\"zone id count mismatch\")\n\t}\n\tif zoneIds[0] != zoneId {\n\t\tt.Fatalf(\"zone id mismatch\")\n\t}\n\terr = WFS.DeleteFile(ctx, zoneId, \"testfile\")\n\tif err != nil {\n\t\tt.Fatalf(\"error deleting file: %v\", err)\n\t}\n\tzoneIds, err = WFS.GetAllZoneIds(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"error getting zone ids: %v\", err)\n\t}\n\tif len(zoneIds) != 0 {\n\t\tt.Fatalf(\"zone id count mismatch\")\n\t}\n}\n\nfunc containsFile(arr []*WaveFile, name string) bool {\n\tfor _, f := range arr {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestDelete(t *testing.T) {\n\tinitDb(t)\n\tdefer cleanupDb(t)\n\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tzoneId := uuid.NewString()\n\terr := WFS.MakeFile(ctx, zoneId, \"testfile\", nil, wshrpc.FileOpts{})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\terr = WFS.DeleteFile(ctx, zoneId, \"testfile\")\n\tif err != nil {\n\t\tt.Fatalf(\"error deleting file: %v\", err)\n\t}\n\t_, err = WFS.Stat(ctx, zoneId, \"testfile\")\n\tif err == nil || !errors.Is(err, fs.ErrNotExist) {\n\t\tt.Errorf(\"expected file not found error\")\n\t}\n\n\t// create two files in same zone, use DeleteZone to delete\n\terr = WFS.MakeFile(ctx, zoneId, \"testfile1\", nil, wshrpc.FileOpts{})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\terr = WFS.MakeFile(ctx, zoneId, \"testfile2\", nil, wshrpc.FileOpts{})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\tfiles, err := WFS.ListFiles(ctx, zoneId)\n\tif err != nil {\n\t\tt.Fatalf(\"error listing files: %v\", err)\n\t}\n\tif len(files) != 2 {\n\t\tt.Fatalf(\"file count mismatch\")\n\t}\n\tif !containsFile(files, \"testfile1\") || !containsFile(files, \"testfile2\") {\n\t\tt.Fatalf(\"file names mismatch\")\n\t}\n\terr = WFS.DeleteZone(ctx, zoneId)\n\tif err != nil {\n\t\tt.Fatalf(\"error deleting zone: %v\", err)\n\t}\n\tfiles, err = WFS.ListFiles(ctx, zoneId)\n\tif err != nil {\n\t\tt.Fatalf(\"error listing files: %v\", err)\n\t}\n\tif len(files) != 0 {\n\t\tt.Fatalf(\"file count mismatch\")\n\t}\n}\n\nfunc checkMapsEqual(t *testing.T, m1 map[string]any, m2 map[string]any, msg string) {\n\tif len(m1) != len(m2) {\n\t\tt.Errorf(\"%s: map length mismatch\", msg)\n\t}\n\tfor k, v := range m1 {\n\t\tif m2[k] != v {\n\t\t\tt.Errorf(\"%s: value mismatch for key %q\", msg, k)\n\t\t}\n\t}\n}\n\nfunc TestSetMeta(t *testing.T) {\n\tinitDb(t)\n\tdefer cleanupDb(t)\n\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tzoneId := uuid.NewString()\n\terr := WFS.MakeFile(ctx, zoneId, \"testfile\", nil, wshrpc.FileOpts{})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\tif WFS.getCacheSize() != 0 {\n\t\tt.Errorf(\"cache size mismatch -- should have 0 entries after create\")\n\t}\n\terr = WFS.WriteMeta(ctx, zoneId, \"testfile\", map[string]any{\"a\": 5, \"b\": \"hello\", \"q\": 8}, false)\n\tif err != nil {\n\t\tt.Fatalf(\"error setting meta: %v\", err)\n\t}\n\tfile, err := WFS.Stat(ctx, zoneId, \"testfile\")\n\tif err != nil {\n\t\tt.Fatalf(\"error stating file: %v\", err)\n\t}\n\tif file == nil {\n\t\tt.Fatalf(\"file not found\")\n\t}\n\tcheckMapsEqual(t, map[string]any{\"a\": 5, \"b\": \"hello\", \"q\": 8}, file.Meta, \"meta\")\n\tif WFS.getCacheSize() != 1 {\n\t\tt.Errorf(\"cache size mismatch\")\n\t}\n\terr = WFS.WriteMeta(ctx, zoneId, \"testfile\", map[string]any{\"a\": 6, \"c\": \"world\", \"d\": 7, \"q\": nil}, true)\n\tif err != nil {\n\t\tt.Fatalf(\"error setting meta: %v\", err)\n\t}\n\tfile, err = WFS.Stat(ctx, zoneId, \"testfile\")\n\tif err != nil {\n\t\tt.Fatalf(\"error stating file: %v\", err)\n\t}\n\tif file == nil {\n\t\tt.Fatalf(\"file not found\")\n\t}\n\tcheckMapsEqual(t, map[string]any{\"a\": 6, \"b\": \"hello\", \"c\": \"world\", \"d\": 7}, file.Meta, \"meta\")\n\n\terr = WFS.WriteMeta(ctx, zoneId, \"testfile-notexist\", map[string]any{\"a\": 6}, true)\n\tif err == nil {\n\t\tt.Fatalf(\"expected error setting meta\")\n\t}\n\terr = nil\n}\n\nfunc checkFileSize(t *testing.T, ctx context.Context, zoneId string, name string, size int64) {\n\tfile, err := WFS.Stat(ctx, zoneId, name)\n\tif err != nil {\n\t\tt.Errorf(\"error stating file %q: %v\", name, err)\n\t\treturn\n\t}\n\tif file == nil {\n\t\tt.Errorf(\"file %q not found\", name)\n\t\treturn\n\t}\n\tif file.Size != size {\n\t\tt.Errorf(\"size mismatch for file %q: expected %d, got %d\", name, size, file.Size)\n\t}\n}\n\nfunc checkFileData(t *testing.T, ctx context.Context, zoneId string, name string, data string) {\n\t_, rdata, err := WFS.ReadFile(ctx, zoneId, name)\n\tif err != nil {\n\t\tt.Errorf(\"error reading data for file %q: %v\", name, err)\n\t\treturn\n\t}\n\tif string(rdata) != data {\n\t\tt.Errorf(\"data mismatch for file %q: expected %q, got %q\", name, data, string(rdata))\n\t}\n}\n\nfunc checkFileByteCount(t *testing.T, ctx context.Context, zoneId string, name string, val byte, expected int) {\n\t_, rdata, err := WFS.ReadFile(ctx, zoneId, name)\n\tif err != nil {\n\t\tt.Errorf(\"error reading data for file %q: %v\", name, err)\n\t\treturn\n\t}\n\tvar count int\n\tfor _, b := range rdata {\n\t\tif b == val {\n\t\t\tcount++\n\t\t}\n\t}\n\tif count != expected {\n\t\tt.Errorf(\"byte count mismatch for file %q: expected %d, got %d\", name, expected, count)\n\t}\n}\n\nfunc checkFileDataAt(t *testing.T, ctx context.Context, zoneId string, name string, offset int64, data string) {\n\t_, rdata, err := WFS.ReadAt(ctx, zoneId, name, offset, int64(len(data)))\n\tif err != nil {\n\t\tt.Errorf(\"error reading data for file %q: %v\", name, err)\n\t\treturn\n\t}\n\tif string(rdata) != data {\n\t\tt.Errorf(\"data mismatch for file %q: expected %q, got %q\", name, data, string(rdata))\n\t}\n}\n\nfunc TestWriteAt(t *testing.T) {\n\tinitDb(t)\n\tdefer cleanupDb(t)\n\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tfileName := \"t3\"\n\tzoneId := uuid.NewString()\n\terr := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\terr = WFS.WriteFile(ctx, zoneId, fileName, []byte(\"hello world!\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error writing data: %v\", err)\n\t}\n\tcheckFileData(t, ctx, zoneId, fileName, \"hello world!\")\n\terr = WFS.WriteAt(ctx, zoneId, fileName, 0, []byte(\"foo\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error writing data: %v\", err)\n\t}\n\tcheckFileSize(t, ctx, zoneId, fileName, 12)\n\tcheckFileData(t, ctx, zoneId, fileName, \"foolo world!\")\n}\n\nfunc TestAppend(t *testing.T) {\n\tinitDb(t)\n\tdefer cleanupDb(t)\n\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tzoneId := uuid.NewString()\n\tfileName := \"t2\"\n\terr := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\terr = WFS.AppendData(ctx, zoneId, fileName, []byte(\"hello\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error appending data: %v\", err)\n\t}\n\t// fmt.Print(GBS.dump())\n\tcheckFileSize(t, ctx, zoneId, fileName, 5)\n\tcheckFileData(t, ctx, zoneId, fileName, \"hello\")\n\terr = WFS.AppendData(ctx, zoneId, fileName, []byte(\" world\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error appending data: %v\", err)\n\t}\n\t// fmt.Print(GBS.dump())\n\tcheckFileSize(t, ctx, zoneId, fileName, 11)\n\tcheckFileData(t, ctx, zoneId, fileName, \"hello world\")\n}\n\nfunc TestWriteFile(t *testing.T) {\n\tinitDb(t)\n\tdefer cleanupDb(t)\n\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tzoneId := uuid.NewString()\n\tfileName := \"t3\"\n\terr := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\terr = WFS.WriteFile(ctx, zoneId, fileName, []byte(\"hello world!\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error writing data: %v\", err)\n\t}\n\tcheckFileData(t, ctx, zoneId, fileName, \"hello world!\")\n\terr = WFS.WriteFile(ctx, zoneId, fileName, []byte(\"goodbye world!\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error writing data: %v\", err)\n\t}\n\tcheckFileData(t, ctx, zoneId, fileName, \"goodbye world!\")\n\terr = WFS.WriteFile(ctx, zoneId, fileName, []byte(\"hello\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error writing data: %v\", err)\n\t}\n\tcheckFileData(t, ctx, zoneId, fileName, \"hello\")\n\n\t// circular file\n\terr = WFS.MakeFile(ctx, zoneId, \"c1\", nil, wshrpc.FileOpts{Circular: true, MaxSize: 50})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\terr = WFS.WriteFile(ctx, zoneId, \"c1\", []byte(\"123456789 123456789 123456789 123456789 123456789 apple\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error writing data: %v\", err)\n\t}\n\tcheckFileData(t, ctx, zoneId, \"c1\", \"6789 123456789 123456789 123456789 123456789 apple\")\n\terr = WFS.AppendData(ctx, zoneId, \"c1\", []byte(\" banana\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error appending data: %v\", err)\n\t}\n\tcheckFileData(t, ctx, zoneId, \"c1\", \"3456789 123456789 123456789 123456789 apple banana\")\n}\n\nfunc TestCircularWrites(t *testing.T) {\n\tinitDb(t)\n\tdefer cleanupDb(t)\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tzoneId := uuid.NewString()\n\terr := WFS.MakeFile(ctx, zoneId, \"c1\", nil, wshrpc.FileOpts{Circular: true, MaxSize: 50})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\terr = WFS.WriteFile(ctx, zoneId, \"c1\", []byte(\"123456789 123456789 123456789 123456789 123456789 \"))\n\tif err != nil {\n\t\tt.Fatalf(\"error writing data: %v\", err)\n\t}\n\tcheckFileData(t, ctx, zoneId, \"c1\", \"123456789 123456789 123456789 123456789 123456789 \")\n\terr = WFS.AppendData(ctx, zoneId, \"c1\", []byte(\"apple\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error appending data: %v\", err)\n\t}\n\tcheckFileData(t, ctx, zoneId, \"c1\", \"6789 123456789 123456789 123456789 123456789 apple\")\n\terr = WFS.WriteAt(ctx, zoneId, \"c1\", 0, []byte(\"foo\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error writing data: %v\", err)\n\t}\n\t// content should be unchanged because write is before the beginning of circular offset\n\tcheckFileData(t, ctx, zoneId, \"c1\", \"6789 123456789 123456789 123456789 123456789 apple\")\n\terr = WFS.WriteAt(ctx, zoneId, \"c1\", 5, []byte(\"a\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error writing data: %v\", err)\n\t}\n\tcheckFileSize(t, ctx, zoneId, \"c1\", 55)\n\tcheckFileData(t, ctx, zoneId, \"c1\", \"a789 123456789 123456789 123456789 123456789 apple\")\n\terr = WFS.AppendData(ctx, zoneId, \"c1\", []byte(\" banana\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error appending data: %v\", err)\n\t}\n\tcheckFileSize(t, ctx, zoneId, \"c1\", 62)\n\tcheckFileData(t, ctx, zoneId, \"c1\", \"3456789 123456789 123456789 123456789 apple banana\")\n\terr = WFS.WriteAt(ctx, zoneId, \"c1\", 20, []byte(\"foo\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error writing data: %v\", err)\n\t}\n\tcheckFileSize(t, ctx, zoneId, \"c1\", 62)\n\tcheckFileData(t, ctx, zoneId, \"c1\", \"3456789 foo456789 123456789 123456789 apple banana\")\n\toffset, _, _ := WFS.ReadFile(ctx, zoneId, \"c1\")\n\tif offset != 12 {\n\t\tt.Errorf(\"offset mismatch: expected 12, got %d\", offset)\n\t}\n\terr = WFS.AppendData(ctx, zoneId, \"c1\", []byte(\" world\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error appending data: %v\", err)\n\t}\n\tcheckFileSize(t, ctx, zoneId, \"c1\", 68)\n\toffset, _, _ = WFS.ReadFile(ctx, zoneId, \"c1\")\n\tif offset != 18 {\n\t\tt.Errorf(\"offset mismatch: expected 18, got %d\", offset)\n\t}\n\tcheckFileData(t, ctx, zoneId, \"c1\", \"9 foo456789 123456789 123456789 apple banana world\")\n\terr = WFS.AppendData(ctx, zoneId, \"c1\", []byte(\" 123456789 123456789 123456789 123456789 bar456789 123456789\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error appending data: %v\", err)\n\t}\n\tcheckFileSize(t, ctx, zoneId, \"c1\", 128)\n\tcheckFileData(t, ctx, zoneId, \"c1\", \" 123456789 123456789 123456789 bar456789 123456789\")\n\terr = withLock(WFS, zoneId, \"c1\", func(entry *CacheEntry) error {\n\t\tif entry == nil {\n\t\t\treturn fmt.Errorf(\"entry not found\")\n\t\t}\n\t\tif len(entry.DataEntries) != 1 {\n\t\t\treturn fmt.Errorf(\"data entries mismatch: expected 1, got %d\", len(entry.DataEntries))\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"error checking data entries: %v\", err)\n\t}\n}\n\nfunc makeText(n int) string {\n\tvar buf bytes.Buffer\n\tfor i := 0; i < n; i++ {\n\t\tbuf.WriteByte(byte('0' + (i % 10)))\n\t}\n\treturn buf.String()\n}\n\nfunc TestMultiPart(t *testing.T) {\n\tinitDb(t)\n\tdefer cleanupDb(t)\n\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tzoneId := uuid.NewString()\n\tfileName := \"m2\"\n\tdata := makeText(80)\n\terr := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\terr = WFS.AppendData(ctx, zoneId, fileName, []byte(data))\n\tif err != nil {\n\t\tt.Fatalf(\"error appending data: %v\", err)\n\t}\n\tcheckFileSize(t, ctx, zoneId, fileName, 80)\n\tcheckFileData(t, ctx, zoneId, fileName, data)\n\t_, barr, err := WFS.ReadAt(ctx, zoneId, fileName, 42, 10)\n\tif err != nil {\n\t\tt.Fatalf(\"error reading data: %v\", err)\n\t}\n\tif string(barr) != data[42:52] {\n\t\tt.Errorf(\"data mismatch: expected %q, got %q\", data[42:52], string(barr))\n\t}\n\tWFS.WriteAt(ctx, zoneId, fileName, 49, []byte(\"world\"))\n\tcheckFileSize(t, ctx, zoneId, fileName, 80)\n\tcheckFileDataAt(t, ctx, zoneId, fileName, 49, \"world\")\n\tcheckFileDataAt(t, ctx, zoneId, fileName, 48, \"8world4\")\n}\n\nfunc testIntMapsEq(t *testing.T, msg string, m map[int]int, expected map[int]int) {\n\tif len(m) != len(expected) {\n\t\tt.Errorf(\"%s: map length mismatch got:%d expected:%d\", msg, len(m), len(expected))\n\t\treturn\n\t}\n\tfor k, v := range m {\n\t\tif expected[k] != v {\n\t\t\tt.Errorf(\"%s: value mismatch for key %d, got:%d expected:%d\", msg, k, v, expected[k])\n\t\t}\n\t}\n}\n\nfunc TestComputePartMap(t *testing.T) {\n\tpartDataSize = 100\n\tdefer func() {\n\t\tpartDataSize = DefaultPartDataSize\n\t}()\n\tfile := &WaveFile{}\n\tm := file.computePartMap(0, 250)\n\ttestIntMapsEq(t, \"map1\", m, map[int]int{0: 100, 1: 100, 2: 50})\n\tm = file.computePartMap(110, 40)\n\tlog.Printf(\"map2:%#v\\n\", m)\n\ttestIntMapsEq(t, \"map2\", m, map[int]int{1: 40})\n\tm = file.computePartMap(110, 90)\n\ttestIntMapsEq(t, \"map3\", m, map[int]int{1: 90})\n\tm = file.computePartMap(110, 91)\n\ttestIntMapsEq(t, \"map4\", m, map[int]int{1: 90, 2: 1})\n\tm = file.computePartMap(820, 340)\n\ttestIntMapsEq(t, \"map5\", m, map[int]int{8: 80, 9: 100, 10: 100, 11: 60})\n\n\t// now test circular\n\tfile = &WaveFile{Opts: wshrpc.FileOpts{Circular: true, MaxSize: 1000}}\n\tm = file.computePartMap(10, 250)\n\ttestIntMapsEq(t, \"map6\", m, map[int]int{0: 90, 1: 100, 2: 60})\n\tm = file.computePartMap(990, 40)\n\ttestIntMapsEq(t, \"map7\", m, map[int]int{9: 10, 0: 30})\n\tm = file.computePartMap(990, 130)\n\ttestIntMapsEq(t, \"map8\", m, map[int]int{9: 10, 0: 100, 1: 20})\n\tm = file.computePartMap(5, 1105)\n\ttestIntMapsEq(t, \"map9\", m, map[int]int{0: 100, 1: 10, 2: 100, 3: 100, 4: 100, 5: 100, 6: 100, 7: 100, 8: 100, 9: 100})\n\tm = file.computePartMap(2005, 1105)\n\ttestIntMapsEq(t, \"map9\", m, map[int]int{0: 100, 1: 10, 2: 100, 3: 100, 4: 100, 5: 100, 6: 100, 7: 100, 8: 100, 9: 100})\n}\n\nfunc TestSimpleDBFlush(t *testing.T) {\n\tinitDb(t)\n\tdefer cleanupDb(t)\n\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tzoneId := uuid.NewString()\n\tfileName := \"t1\"\n\terr := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\terr = WFS.WriteFile(ctx, zoneId, fileName, []byte(\"hello world!\"))\n\tif err != nil {\n\t\tt.Fatalf(\"error writing data: %v\", err)\n\t}\n\tcheckFileData(t, ctx, zoneId, fileName, \"hello world!\")\n\t_, err = WFS.FlushCache(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"error flushing cache: %v\", err)\n\t}\n\tif WFS.getCacheSize() != 0 {\n\t\tt.Errorf(\"cache size mismatch\")\n\t}\n\tcheckFileData(t, ctx, zoneId, fileName, \"hello world!\")\n\tif WFS.getCacheSize() != 0 {\n\t\tt.Errorf(\"cache size mismatch (after read)\")\n\t}\n\tcheckFileDataAt(t, ctx, zoneId, fileName, 6, \"world!\")\n\tcheckFileSize(t, ctx, zoneId, fileName, 12)\n\tcheckFileByteCount(t, ctx, zoneId, fileName, 'l', 3)\n}\n\nfunc TestConcurrentAppend(t *testing.T) {\n\tinitDb(t)\n\tdefer cleanupDb(t)\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tzoneId := uuid.NewString()\n\tfileName := \"t1\"\n\terr := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\tvar wg sync.WaitGroup\n\tfor i := 0; i < 16; i++ {\n\t\twg.Add(1)\n\t\tgo func(n int) {\n\t\t\tdefer wg.Done()\n\t\t\tconst hexChars = \"0123456789abcdef\"\n\t\t\tch := hexChars[n]\n\t\t\tfor j := 0; j < 100; j++ {\n\t\t\t\terr := WFS.AppendData(ctx, zoneId, fileName, []byte{ch})\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"error appending data (%d): %v\", n, err)\n\t\t\t\t}\n\t\t\t\tif j == 50 {\n\t\t\t\t\t// ignore error here (concurrent flushing)\n\t\t\t\t\tWFS.FlushCache(ctx)\n\t\t\t\t}\n\t\t\t}\n\t\t}(i)\n\t}\n\twg.Wait()\n\tcheckFileSize(t, ctx, zoneId, fileName, 1600)\n\tcheckFileByteCount(t, ctx, zoneId, fileName, 'a', 100)\n\tcheckFileByteCount(t, ctx, zoneId, fileName, 'e', 100)\n\tWFS.FlushCache(ctx)\n\tcheckFileSize(t, ctx, zoneId, fileName, 1600)\n\tcheckFileByteCount(t, ctx, zoneId, fileName, 'a', 100)\n\tcheckFileByteCount(t, ctx, zoneId, fileName, 'e', 100)\n}\n\nfunc jsonDeepEqual(d1 any, d2 any) bool {\n\tif d1 == nil && d2 == nil {\n\t\treturn true\n\t}\n\tif d1 == nil || d2 == nil {\n\t\treturn false\n\t}\n\tt1 := reflect.TypeOf(d1)\n\tt2 := reflect.TypeOf(d2)\n\tif t1 != t2 {\n\t\treturn false\n\t}\n\tswitch d1.(type) {\n\tcase float64:\n\t\treturn d1.(float64) == d2.(float64)\n\tcase string:\n\t\treturn d1.(string) == d2.(string)\n\tcase bool:\n\t\treturn d1.(bool) == d2.(bool)\n\tcase []any:\n\t\ta1 := d1.([]any)\n\t\ta2 := d2.([]any)\n\t\tif len(a1) != len(a2) {\n\t\t\treturn false\n\t\t}\n\t\tfor i := 0; i < len(a1); i++ {\n\t\t\tif !jsonDeepEqual(a1[i], a2[i]) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\tcase map[string]any:\n\t\tm1 := d1.(map[string]any)\n\t\tm2 := d2.(map[string]any)\n\t\tif len(m1) != len(m2) {\n\t\t\treturn false\n\t\t}\n\t\tfor k, v := range m1 {\n\t\t\tif !jsonDeepEqual(v, m2[k]) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc TestIJson(t *testing.T) {\n\tinitDb(t)\n\tdefer cleanupDb(t)\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tzoneId := uuid.NewString()\n\tfileName := \"ij1\"\n\terr := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{IJson: true})\n\tif err != nil {\n\t\tt.Fatalf(\"error creating file: %v\", err)\n\t}\n\trootSet := ijson.MakeSetCommand(nil, map[string]any{\"tag\": \"div\", \"class\": \"root\"})\n\terr = WFS.AppendIJson(ctx, zoneId, fileName, rootSet)\n\tif err != nil {\n\t\tt.Fatalf(\"error appending ijson: %v\", err)\n\t}\n\t_, fullData, err := WFS.ReadFile(ctx, zoneId, fileName)\n\tif err != nil {\n\t\tt.Fatalf(\"error reading file: %v\", err)\n\t}\n\tcmds, err := ijson.ParseIJson(fullData)\n\tif err != nil {\n\t\tt.Fatalf(\"error parsing ijson: %v\", err)\n\t}\n\toutData, err := ijson.ApplyCommands(nil, cmds, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"error applying ijson: %v\", err)\n\t}\n\tif !jsonDeepEqual(rootSet[\"data\"], outData) {\n\t\tt.Errorf(\"data mismatch: expected %v, got %v\", rootSet[\"data\"], outData)\n\t}\n\tchildrenAppend := ijson.MakeAppendCommand(ijson.Path{\"children\"}, map[string]any{\"tag\": \"div\", \"class\": \"child\"})\n\terr = WFS.AppendIJson(ctx, zoneId, fileName, childrenAppend)\n\tif err != nil {\n\t\tt.Fatalf(\"error appending ijson: %v\", err)\n\t}\n\t_, fullData, err = WFS.ReadFile(ctx, zoneId, fileName)\n\tif err != nil {\n\t\tt.Fatalf(\"error reading file: %v\", err)\n\t}\n\tcmds, err = ijson.ParseIJson(fullData)\n\tif err != nil {\n\t\tt.Fatalf(\"error parsing ijson: %v\", err)\n\t}\n\tif len(cmds) != 2 {\n\t\tt.Fatalf(\"command count mismatch: expected 2, got %d\", len(cmds))\n\t}\n\toutData, err = ijson.ApplyCommands(nil, cmds, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"error applying ijson: %v\", err)\n\t}\n\tif !jsonDeepEqual(ijson.M{\"tag\": \"div\", \"class\": \"root\", \"children\": ijson.A{ijson.M{\"tag\": \"div\", \"class\": \"child\"}}}, outData) {\n\t\tt.Errorf(\"data mismatch: expected %v, got %v\", rootSet[\"data\"], outData)\n\t}\n\terr = WFS.CompactIJson(ctx, zoneId, fileName)\n\tif err != nil {\n\t\tt.Fatalf(\"error compacting ijson: %v\", err)\n\t}\n\t_, fullData, err = WFS.ReadFile(ctx, zoneId, fileName)\n\tif err != nil {\n\t\tt.Fatalf(\"error reading file: %v\", err)\n\t}\n\tcmds, err = ijson.ParseIJson(fullData)\n\tif err != nil {\n\t\tt.Fatalf(\"error parsing ijson: %v\", err)\n\t}\n\tif len(cmds) != 1 {\n\t\tt.Fatalf(\"command count mismatch: expected 1, got %d\", len(cmds))\n\t}\n\toutData, err = ijson.ApplyCommands(nil, cmds, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"error applying ijson: %v\", err)\n\t}\n\tif !jsonDeepEqual(ijson.M{\"tag\": \"div\", \"class\": \"root\", \"children\": ijson.A{ijson.M{\"tag\": \"div\", \"class\": \"child\"}}}, outData) {\n\t\tt.Errorf(\"data mismatch: expected %v, got %v\", rootSet[\"data\"], outData)\n\t}\n}\n"
  },
  {
    "path": "pkg/genconn/genconn.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// generic connection code (WSL + SSH)\npackage genconn\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/syncbuf\"\n)\n\ntype connContextKeyType struct{}\n\nvar connContextKey connContextKeyType\n\ntype connData struct {\n\tBlockId string\n}\n\nfunc ContextWithConnData(ctx context.Context, blockId string) context.Context {\n\tif blockId == \"\" {\n\t\treturn ctx\n\t}\n\treturn context.WithValue(ctx, connContextKey, &connData{BlockId: blockId})\n}\n\nfunc GetConnData(ctx context.Context) *connData {\n\tif ctx == nil {\n\t\treturn nil\n\t}\n\tdataPtr := ctx.Value(connContextKey)\n\tif dataPtr == nil {\n\t\treturn nil\n\t}\n\treturn dataPtr.(*connData)\n}\n\ntype CommandSpec struct {\n\tCmd string\n\tEnv map[string]string\n\tCwd string\n}\n\ntype ShellClient interface {\n\tMakeProcessController(cmd CommandSpec) (ShellProcessController, error)\n}\n\ntype ShellProcessController interface {\n\tStart() error\n\tWait() error\n\tKill()\n\n\t// these are not required to be called, if they are not called, the impl will set to discard output\n\tStdinPipe() (io.WriteCloser, error)\n\tStdoutPipe() (io.Reader, error)\n\tStderrPipe() (io.Reader, error)\n}\n\nfunc RunSimpleCommand(ctx context.Context, client ShellClient, spec CommandSpec) (string, string, error) {\n\tproc, err := client.MakeProcessController(spec)\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"failed to create process controller: %w\", err)\n\t}\n\n\tstdout, err := proc.StdoutPipe()\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"failed to get stdout pipe: %w\", err)\n\t}\n\tstderr, err := proc.StderrPipe()\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"failed to get stderr pipe: %w\", err)\n\t}\n\n\tif err := proc.Start(); err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"failed to start process: %w\", err)\n\t}\n\n\tstdoutBuf := syncbuf.MakeSyncBuffer()\n\tstderrBuf := syncbuf.MakeSyncBuffer()\n\tvar wg sync.WaitGroup\n\twg.Add(2)\n\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tio.Copy(stdoutBuf, stdout)\n\t}()\n\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tio.Copy(stderrBuf, stderr)\n\t}()\n\n\trunErr := ProcessContextWait(ctx, proc)\n\twg.Wait()\n\n\treturn stdoutBuf.String(), stderrBuf.String(), runErr\n}\n\nfunc ProcessContextWait(ctx context.Context, proc ShellProcessController) error {\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\tdone <- proc.Wait()\n\t}()\n\n\tselect {\n\tcase <-ctx.Done():\n\t\tproc.Kill()\n\t\treturn ctx.Err()\n\tcase err := <-done:\n\t\treturn err\n\t}\n}\n\nfunc MakeStdoutSyncBuffer(proc ShellProcessController) (*syncbuf.SyncBuffer, error) {\n\tstdout, err := proc.StdoutPipe()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get stdout pipe: %w\", err)\n\t}\n\treturn syncbuf.MakeSyncBufferFromReader(stdout), nil\n}\n\nfunc MakeStderrSyncBuffer(proc ShellProcessController) (*syncbuf.SyncBuffer, error) {\n\tstderr, err := proc.StderrPipe()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get stderr pipe: %w\", err)\n\t}\n\treturn syncbuf.MakeSyncBufferFromReader(stderr), nil\n}\n\nfunc BuildShellCommand(opts CommandSpec) (string, error) {\n\t// Build environment variables\n\tvar envVars strings.Builder\n\tfor key, value := range opts.Env {\n\t\tif !isValidEnvVarName(key) {\n\t\t\treturn \"\", fmt.Errorf(\"invalid environment variable name: %q\", key)\n\t\t}\n\t\tenvVars.WriteString(fmt.Sprintf(\"%s=%s \", key, shellutil.HardQuote(value)))\n\t}\n\n\t// Build the command\n\tshellCmd := opts.Cmd\n\tif opts.Cwd != \"\" {\n\t\tshellCmd = fmt.Sprintf(\"cd %s && %s\", shellutil.HardQuote(opts.Cwd), shellCmd)\n\t}\n\n\t// Quote the command for `sh -c`\n\treturn fmt.Sprintf(\"sh -c %s\", shellutil.HardQuote(envVars.String()+shellCmd)), nil\n}\n\nfunc isValidEnvVarName(name string) bool {\n\tvalidEnvVarName := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)\n\treturn validEnvVarName.MatchString(name)\n}\n"
  },
  {
    "path": "pkg/genconn/ssh-impl.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage genconn\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"sync\"\n\n\t\"golang.org/x/crypto/ssh\"\n)\n\nvar _ ShellClient = (*SSHShellClient)(nil)\n\ntype SSHShellClient struct {\n\tclient *ssh.Client\n}\n\nfunc MakeSSHShellClient(client *ssh.Client) *SSHShellClient {\n\treturn &SSHShellClient{client: client}\n}\n\nfunc (c *SSHShellClient) MakeProcessController(cmdSpec CommandSpec) (ShellProcessController, error) {\n\treturn MakeSSHCmdClient(c.client, cmdSpec)\n}\n\n// SSHProcessController implements ShellCmd for SSH connections\ntype SSHProcessController struct {\n\tclient      *ssh.Client\n\tsession     *ssh.Session\n\tlock        *sync.Mutex\n\tonce        *sync.Once\n\tstdinPiped  bool\n\tstdoutPiped bool\n\tstderrPiped bool\n\twaitErr     error\n\tstarted     bool\n\tcmdSpec     CommandSpec\n}\n\n// MakeSSHCmdClient creates a new instance of SSHCmdClient\nfunc MakeSSHCmdClient(client *ssh.Client, cmdSpec CommandSpec) (*SSHProcessController, error) {\n\tlog.Printf(\"SSH-NEWSESSION (cmdclient)\\n\")\n\tsession, err := client.NewSession()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create SSH session: %w\", err)\n\t}\n\treturn &SSHProcessController{\n\t\tclient:  client,\n\t\tlock:    &sync.Mutex{},\n\t\tonce:    &sync.Once{},\n\t\tcmdSpec: cmdSpec,\n\t\tsession: session,\n\t}, nil\n}\n\n// Start begins execution of the command\nfunc (s *SSHProcessController) Start() error {\n\ts.lock.Lock()\n\tdefer s.lock.Unlock()\n\n\tif s.started {\n\t\treturn fmt.Errorf(\"command already started\")\n\t}\n\n\tfullCmd, err := BuildShellCommand(s.cmdSpec)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to build shell command: %w\", err)\n\t}\n\t// if stdout/stderr weren't piped, then session.stdout/stderr will be nil\n\t// and the library guarantees that the outputs will be attached to io.Discard\n\t// if stdin hasn't been piped, then session.stdin will be nil\n\t// and the libary guarantees that it will be attached to an empty bytes.Buffer, which will produce an immediate EOF\n\t// tl;dr we don't need to worry about hanging beause of long input or explicitly closing stdin\n\tif err := s.session.Start(fullCmd); err != nil {\n\t\treturn fmt.Errorf(\"failed to start command: %w\", err)\n\t}\n\ts.started = true\n\treturn nil\n}\n\n// Wait waits for the command to complete\nfunc (s *SSHProcessController) Wait() error {\n\ts.once.Do(func() {\n\t\ts.waitErr = s.session.Wait()\n\t})\n\treturn s.waitErr\n}\n\n// Kill terminates the command\nfunc (s *SSHProcessController) Kill() {\n\ts.lock.Lock()\n\tdefer s.lock.Unlock()\n\n\tif s.session != nil {\n\t\ts.session.Close()\n\t}\n}\n\nfunc (s *SSHProcessController) StdinPipe() (io.WriteCloser, error) {\n\ts.lock.Lock()\n\tdefer s.lock.Unlock()\n\tif s.started {\n\t\treturn nil, fmt.Errorf(\"command already started\")\n\t}\n\tif s.stdinPiped {\n\t\treturn nil, fmt.Errorf(\"stdin already piped\")\n\t}\n\ts.stdinPiped = true\n\treturn s.session.StdinPipe()\n}\n\nfunc (s *SSHProcessController) StdoutPipe() (io.Reader, error) {\n\ts.lock.Lock()\n\tdefer s.lock.Unlock()\n\tif s.started {\n\t\treturn nil, fmt.Errorf(\"command already started\")\n\t}\n\tif s.stdoutPiped {\n\t\treturn nil, fmt.Errorf(\"stdout already piped\")\n\t}\n\ts.stdoutPiped = true\n\tstdout, err := s.session.StdoutPipe()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn stdout, nil\n}\n\nfunc (s *SSHProcessController) StderrPipe() (io.Reader, error) {\n\ts.lock.Lock()\n\tdefer s.lock.Unlock()\n\tif s.started {\n\t\treturn nil, fmt.Errorf(\"command already started\")\n\t}\n\tif s.stderrPiped {\n\t\treturn nil, fmt.Errorf(\"stderr already piped\")\n\t}\n\ts.stderrPiped = true\n\tstderr, err := s.session.StderrPipe()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn stderr, nil\n}\n"
  },
  {
    "path": "pkg/genconn/wsl-impl.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage genconn\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wsl\"\n)\n\nvar _ ShellClient = (*WSLShellClient)(nil)\n\ntype WSLShellClient struct {\n\tdistro *wsl.Distro\n}\n\nfunc MakeWSLShellClient(distro *wsl.Distro) *WSLShellClient {\n\treturn &WSLShellClient{distro: distro}\n}\n\nfunc (c *WSLShellClient) MakeProcessController(cmdSpec CommandSpec) (ShellProcessController, error) {\n\treturn MakeWSLProcessController(c.distro, cmdSpec)\n}\n\ntype WSLProcessController struct {\n\tdistro      *wsl.Distro\n\tcmd         *wsl.WslCmd\n\tlock        *sync.Mutex\n\tonce        *sync.Once\n\tstdinPiped  bool\n\tstdoutPiped bool\n\tstderrPiped bool\n\twaitErr     error\n\tstarted     bool\n\tcmdSpec     CommandSpec\n}\n\nfunc MakeWSLProcessController(distro *wsl.Distro, cmdSpec CommandSpec) (*WSLProcessController, error) {\n\tfullCmd, err := BuildShellCommand(cmdSpec)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to build shell command: %w\", err)\n\t}\n\n\tcmd := distro.WslCommand(context.Background(), fullCmd)\n\tif cmd == nil {\n\t\treturn nil, fmt.Errorf(\"failed to create WSL command\")\n\t}\n\n\treturn &WSLProcessController{\n\t\tdistro:  distro,\n\t\tcmd:     cmd,\n\t\tlock:    &sync.Mutex{},\n\t\tonce:    &sync.Once{},\n\t\tcmdSpec: cmdSpec,\n\t}, nil\n}\n\nfunc (w *WSLProcessController) Start() error {\n\tw.lock.Lock()\n\tdefer w.lock.Unlock()\n\n\tif w.started {\n\t\treturn fmt.Errorf(\"command already started\")\n\t}\n\n\tif err := w.cmd.Start(); err != nil {\n\t\treturn fmt.Errorf(\"failed to start command: %w\", err)\n\t}\n\n\tw.started = true\n\treturn nil\n}\n\nfunc (w *WSLProcessController) Wait() error {\n\tw.once.Do(func() {\n\t\tw.waitErr = w.cmd.Wait()\n\t})\n\treturn w.waitErr\n}\n\nfunc (w *WSLProcessController) Kill() {\n\tw.lock.Lock()\n\tdefer w.lock.Unlock()\n\n\tif w.cmd == nil {\n\t\treturn\n\t}\n\tprocess := w.cmd.GetProcess()\n\tif process == nil {\n\t\treturn\n\t}\n\tprocess.Kill()\n}\n\nfunc (w *WSLProcessController) StdinPipe() (io.WriteCloser, error) {\n\tw.lock.Lock()\n\tdefer w.lock.Unlock()\n\n\tif w.started {\n\t\treturn nil, fmt.Errorf(\"command already started\")\n\t}\n\tif w.stdinPiped {\n\t\treturn nil, fmt.Errorf(\"stdin already piped\")\n\t}\n\n\tw.stdinPiped = true\n\treturn w.cmd.StdinPipe()\n}\n\nfunc (w *WSLProcessController) StdoutPipe() (io.Reader, error) {\n\tw.lock.Lock()\n\tdefer w.lock.Unlock()\n\n\tif w.started {\n\t\treturn nil, fmt.Errorf(\"command already started\")\n\t}\n\tif w.stdoutPiped {\n\t\treturn nil, fmt.Errorf(\"stdout already piped\")\n\t}\n\n\tw.stdoutPiped = true\n\tstdout, err := w.cmd.StdoutPipe()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn stdout, nil\n}\n\nfunc (w *WSLProcessController) StderrPipe() (io.Reader, error) {\n\tw.lock.Lock()\n\tdefer w.lock.Unlock()\n\n\tif w.started {\n\t\treturn nil, fmt.Errorf(\"command already started\")\n\t}\n\tif w.stderrPiped {\n\t\treturn nil, fmt.Errorf(\"stderr already piped\")\n\t}\n\n\tw.stderrPiped = true\n\tstderr, err := w.cmd.StderrPipe()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn stderr, nil\n}\n"
  },
  {
    "path": "pkg/gogen/gogen.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage gogen\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nfunc GenerateBoilerplate(buf *strings.Builder, pkgName string, imports []string) {\n\tbuf.WriteString(\"// Copyright 2026, Command Line Inc.\\n\")\n\tbuf.WriteString(\"// SPDX-License-Identifier: Apache-2.0\\n\")\n\tbuf.WriteString(\"\\n// Generated Code. DO NOT EDIT.\\n\\n\")\n\tbuf.WriteString(fmt.Sprintf(\"package %s\\n\\n\", pkgName))\n\tif len(imports) > 0 {\n\t\tbuf.WriteString(\"import (\\n\")\n\t\tfor _, imp := range imports {\n\t\t\tbuf.WriteString(fmt.Sprintf(\"\\t%q\\n\", imp))\n\t\t}\n\t\tbuf.WriteString(\")\\n\\n\")\n\t}\n}\n\nfunc getBeforeColonPart(s string) string {\n\tif colonIdx := strings.Index(s, \":\"); colonIdx != -1 {\n\t\treturn s[:colonIdx]\n\t}\n\treturn s\n}\n\nfunc GenerateMetaMapConsts(buf *strings.Builder, constPrefix string, rtype reflect.Type, embedded bool) {\n\tif !embedded {\n\t\tbuf.WriteString(\"const (\\n\")\n\t} else {\n\t\tbuf.WriteString(\"\\n\")\n\t}\n\tvar lastBeforeColon = \"\"\n\tisFirst := true\n\tfor idx := 0; idx < rtype.NumField(); idx++ {\n\t\tfield := rtype.Field(idx)\n\t\tif field.PkgPath != \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif field.Anonymous {\n\t\t\tvar embeddedBuf strings.Builder\n\t\t\tGenerateMetaMapConsts(&embeddedBuf, constPrefix, field.Type, true)\n\t\t\tbuf.WriteString(embeddedBuf.String())\n\t\t\tcontinue\n\t\t}\n\t\tfieldName := field.Name\n\t\tjsonTag := utilfn.GetJsonTag(field)\n\t\tif jsonTag == \"\" {\n\t\t\tjsonTag = fieldName\n\t\t}\n\t\tbeforeColon := getBeforeColonPart(jsonTag)\n\t\tif beforeColon != lastBeforeColon {\n\t\t\tif !isFirst {\n\t\t\t\tbuf.WriteString(\"\\n\")\n\t\t\t}\n\t\t\tlastBeforeColon = beforeColon\n\t\t}\n\t\tcname := constPrefix + fieldName\n\t\tbuf.WriteString(fmt.Sprintf(\"\\t%-40s = %q\\n\", cname, jsonTag))\n\t\tisFirst = false\n\t}\n\tif !embedded {\n\t\tbuf.WriteString(\")\\n\")\n\t}\n}\n\nfunc GenMethod_Call(buf *strings.Builder, methodDecl *wshrpc.WshRpcMethodDecl) {\n\tfmt.Fprintf(buf, \"// command %q, wshserver.%s\\n\", methodDecl.Command, methodDecl.MethodName)\n\tdataType, dataVarName := getWshMethodDataParamsAndExpr(methodDecl)\n\treturnType := \"error\"\n\trespName := \"_\"\n\ttParamVal := \"any\"\n\tif methodDecl.DefaultResponseDataType != nil {\n\t\treturnType = \"(\" + methodDecl.DefaultResponseDataType.String() + \", error)\"\n\t\trespName = \"resp\"\n\t\ttParamVal = methodDecl.DefaultResponseDataType.String()\n\t}\n\tfmt.Fprintf(buf, \"func %s(w *wshutil.WshRpc%s, opts *wshrpc.RpcOpts) %s {\\n\", methodDecl.MethodName, dataType, returnType)\n\tfmt.Fprintf(buf, \"\\t%s, err := sendRpcRequestCallHelper[%s](w, %q, %s, opts)\\n\", respName, tParamVal, methodDecl.Command, dataVarName)\n\tif methodDecl.DefaultResponseDataType != nil {\n\t\tfmt.Fprintf(buf, \"\\treturn resp, err\\n\")\n\t} else {\n\t\tfmt.Fprintf(buf, \"\\treturn err\\n\")\n\t}\n\tfmt.Fprintf(buf, \"}\\n\\n\")\n}\n\nfunc GenMethod_ResponseStream(buf *strings.Builder, methodDecl *wshrpc.WshRpcMethodDecl) {\n\tfmt.Fprintf(buf, \"// command %q, wshserver.%s\\n\", methodDecl.Command, methodDecl.MethodName)\n\tdataType, dataVarName := getWshMethodDataParamsAndExpr(methodDecl)\n\trespType := \"any\"\n\tif methodDecl.DefaultResponseDataType != nil {\n\t\trespType = methodDecl.DefaultResponseDataType.String()\n\t}\n\tfmt.Fprintf(buf, \"func %s(w *wshutil.WshRpc%s, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[%s] {\\n\", methodDecl.MethodName, dataType, respType)\n\tfmt.Fprintf(buf, \"\\treturn sendRpcRequestResponseStreamHelper[%s](w, %q, %s, opts)\\n\", respType, methodDecl.Command, dataVarName)\n\tfmt.Fprintf(buf, \"}\\n\\n\")\n}\n\nfunc getWshMethodDataParamsAndExpr(methodDecl *wshrpc.WshRpcMethodDecl) (string, string) {\n\tdataTypes := methodDecl.GetCommandDataTypes()\n\tif len(dataTypes) == 0 {\n\t\treturn \"\", \"nil\"\n\t}\n\tif len(dataTypes) == 1 {\n\t\treturn \", data \" + dataTypes[0].String(), \"data\"\n\t}\n\tvar paramBuilder strings.Builder\n\tvar argBuilder strings.Builder\n\tfor idx, dataType := range dataTypes {\n\t\targName := fmt.Sprintf(\"arg%d\", idx+1)\n\t\tparamBuilder.WriteString(\", \")\n\t\tparamBuilder.WriteString(argName)\n\t\tparamBuilder.WriteString(\" \")\n\t\tparamBuilder.WriteString(dataType.String())\n\t\tif idx > 0 {\n\t\t\targBuilder.WriteString(\", \")\n\t\t}\n\t\targBuilder.WriteString(argName)\n\t}\n\treturn paramBuilder.String(), fmt.Sprintf(\"wshrpc.MultiArg{Args: []any{%s}}\", argBuilder.String())\n}\n"
  },
  {
    "path": "pkg/gogen/gogen_test.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage gogen\n\nimport (\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nfunc TestGetWshMethodDataParamsAndExpr_MultiArg(t *testing.T) {\n\tmethodDecl := &wshrpc.WshRpcMethodDecl{\n\t\tCommandDataTypes: []reflect.Type{\n\t\t\treflect.TypeOf(\"\"),\n\t\t\treflect.TypeOf(0),\n\t\t},\n\t}\n\tparams, expr := getWshMethodDataParamsAndExpr(methodDecl)\n\tif params != \", arg1 string, arg2 int\" {\n\t\tt.Fatalf(\"unexpected params: %q\", params)\n\t}\n\tif expr != \"wshrpc.MultiArg{Args: []any{arg1, arg2}}\" {\n\t\tt.Fatalf(\"unexpected expr: %q\", expr)\n\t}\n}\n\nfunc TestGenMethodCall_MultiArg(t *testing.T) {\n\tmethodDecl := &wshrpc.WshRpcMethodDecl{\n\t\tCommand:          \"test\",\n\t\tCommandType:      wshrpc.RpcType_Call,\n\t\tMethodName:       \"TestCommand\",\n\t\tCommandDataTypes: []reflect.Type{reflect.TypeOf(\"\"), reflect.TypeOf(0)},\n\t}\n\tvar sb strings.Builder\n\tGenMethod_Call(&sb, methodDecl)\n\tout := sb.String()\n\tif !strings.Contains(out, \"func TestCommand(w *wshutil.WshRpc, arg1 string, arg2 int, opts *wshrpc.RpcOpts) error {\") {\n\t\tt.Fatalf(\"generated method missing multi-arg signature:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"sendRpcRequestCallHelper[any](w, \\\"test\\\", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts)\") {\n\t\tt.Fatalf(\"generated method missing MultiArg payload:\\n%s\", out)\n\t}\n}\n"
  },
  {
    "path": "pkg/ijson/ijson.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// implements incremental json format\npackage ijson\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n)\n\n// ijson values are built out of standard go building blocks:\n// string, float64, bool, nil, []any, map[string]any\n\n// paths are arrays of strings and ints\n\nconst (\n\tSetCommandStr    = \"set\"\n\tDelCommandStr    = \"del\"\n\tAppendCommandStr = \"append\"\n)\n\ntype Command = map[string]any\ntype Path = []any\ntype M = map[string]any\ntype A = []any\n\n// instead of defining structs for commands, we just define a command shape\n// set: type, path, value\n// del: type, path\n// arrayappend: type, path, value\n\nfunc MakeSetCommand(path Path, value any) Command {\n\treturn Command{\n\t\t\"type\": SetCommandStr,\n\t\t\"path\": path,\n\t\t\"data\": value,\n\t}\n}\n\nfunc MakeDelCommand(path Path) Command {\n\treturn Command{\n\t\t\"type\": DelCommandStr,\n\t\t\"path\": path,\n\t}\n}\n\nfunc MakeAppendCommand(path Path, value any) Command {\n\treturn Command{\n\t\t\"type\": AppendCommandStr,\n\t\t\"path\": path,\n\t\t\"data\": value,\n\t}\n}\n\nvar pathPartKeyRe = regexp.MustCompile(`^[a-zA-Z0-9:_#-]+`)\n\nfunc ParseSimplePath(input string) ([]any, error) {\n\tvar path []any\n\t// Scan the input string character by character\n\tfor i := 0; i < len(input); {\n\t\tif input[i] == '[' {\n\t\t\t// Handle the index\n\t\t\tend := strings.Index(input[i:], \"]\")\n\t\t\tif end == -1 {\n\t\t\t\treturn nil, fmt.Errorf(\"unmatched bracket at position %d\", i)\n\t\t\t}\n\t\t\tindex, err := strconv.Atoi(input[i+1 : i+end])\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"invalid index at position %d: %v\", i, err)\n\t\t\t}\n\t\t\tpath = append(path, index)\n\t\t\ti += end + 1\n\t\t} else {\n\t\t\t// Handle the key\n\t\t\tj := i\n\t\t\tfor j < len(input) && input[j] != '.' && input[j] != '[' {\n\t\t\t\tj++\n\t\t\t}\n\t\t\tkey := input[i:j]\n\t\t\tif !pathPartKeyRe.MatchString(key) {\n\t\t\t\treturn nil, fmt.Errorf(\"invalid key at position %d: %s\", i, key)\n\t\t\t}\n\t\t\tpath = append(path, key)\n\t\t\ti = j\n\t\t}\n\t\tif i < len(input) && input[i] == '.' {\n\t\t\ti++\n\t\t}\n\t}\n\n\treturn path, nil\n}\n\ntype PathError struct {\n\tErr string\n}\n\nfunc (e PathError) Error() string {\n\treturn \"PathError: \" + e.Err\n}\n\nfunc MakePathTypeError(path Path, index int) error {\n\treturn PathError{fmt.Sprintf(\"invalid path element type:%T at index:%d (%s)\", path[index], index, FormatPath(path))}\n}\n\nfunc MakePathError(errStr string, path Path, index int) error {\n\treturn PathError{fmt.Sprintf(\"%s at index:%d (%s)\", errStr, index, FormatPath(path))}\n}\n\ntype SetTypeError struct {\n\tErr string\n}\n\nfunc (e SetTypeError) Error() string {\n\treturn \"SetTypeError: \" + e.Err\n}\n\nfunc MakeSetTypeError(errStr string, path Path, index int) error {\n\treturn SetTypeError{fmt.Sprintf(\"%s at index:%d (%s)\", errStr, index, FormatPath(path))}\n}\n\ntype BudgetError struct {\n\tErr string\n}\n\nfunc (e BudgetError) Error() string {\n\treturn \"BudgetError: \" + e.Err\n}\n\nfunc MakeBudgetError(errStr string, path Path, index int) error {\n\treturn BudgetError{fmt.Sprintf(\"%s at index:%d (%s)\", errStr, index, FormatPath(path))}\n}\n\nvar simplePathStrRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)\n\nfunc FormatPath(path Path) string {\n\tif len(path) == 0 {\n\t\treturn \"$\"\n\t}\n\tvar buf bytes.Buffer\n\tbuf.WriteByte('$')\n\tfor _, elem := range path {\n\t\tswitch elem := elem.(type) {\n\t\tcase string:\n\t\t\tif simplePathStrRe.MatchString(elem) {\n\t\t\t\tbuf.WriteByte('.')\n\t\t\t\tbuf.WriteString(elem)\n\t\t\t} else {\n\t\t\t\tbuf.WriteByte('[')\n\t\t\t\tbuf.WriteString(strconv.Quote(elem))\n\t\t\t\tbuf.WriteByte(']')\n\t\t\t}\n\t\tcase int:\n\t\t\tbuf.WriteByte('[')\n\t\t\tbuf.WriteString(strconv.Itoa(elem))\n\t\t\tbuf.WriteByte(']')\n\t\tdefault:\n\t\t\t// a placeholder for a bad value\n\t\t\tbuf.WriteString(\".*\")\n\t\t}\n\t}\n\treturn buf.String()\n}\n\ntype pathWithPos struct {\n\tPath  Path\n\tIndex int\n}\n\nfunc (pp pathWithPos) isLast() bool {\n\treturn pp.Index == len(pp.Path)-1\n}\n\nfunc GetPath(data any, path []any) (any, error) {\n\treturn getPathInternal(data, pathWithPos{Path: path, Index: 0})\n}\n\nfunc getPathInternal(data any, pp pathWithPos) (any, error) {\n\tif data == nil {\n\t\treturn nil, nil\n\t}\n\tif pp.Index >= len(pp.Path) {\n\t\treturn data, nil\n\t}\n\tpathElemAny := pp.Path[pp.Index]\n\tswitch pathElem := pathElemAny.(type) {\n\tcase string:\n\t\tmapVal, ok := data.(map[string]any)\n\t\tif !ok {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn getPathInternal(mapVal[pathElem], pathWithPos{Path: pp.Path, Index: pp.Index + 1})\n\tcase int:\n\t\tif pathElem < 0 {\n\t\t\treturn nil, MakePathError(\"negative index\", pp.Path, pp.Index)\n\t\t}\n\t\tarrVal, ok := data.([]any)\n\t\tif !ok {\n\t\t\treturn nil, nil\n\t\t}\n\t\tif pathElem >= len(arrVal) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn getPathInternal(arrVal[pathElem], pathWithPos{Path: pp.Path, Index: pp.Index + 1})\n\tdefault:\n\t\treturn nil, MakePathTypeError(pp.Path, pp.Index)\n\t}\n}\n\ntype CombiningFunc func(curValue any, newValue any, pp pathWithPos, opts SetPathOpts) (any, error)\n\ntype SetPathOpts struct {\n\tBudget    int // Budget 0 is unlimited (to set a 0 value, use -1)\n\tForce     bool\n\tRemove    bool\n\tCombineFn CombiningFunc\n}\n\nfunc SetPathNoErr(data any, path Path, value any, opts *SetPathOpts) any {\n\tret, _ := SetPath(data, path, value, opts)\n\treturn ret\n}\n\nfunc SetPath(data any, path Path, value any, opts *SetPathOpts) (any, error) {\n\tif opts == nil {\n\t\topts = &SetPathOpts{}\n\t}\n\tif opts.Remove && opts.CombineFn != nil {\n\t\treturn nil, fmt.Errorf(\"SetPath: Remove and CombineFn are mutually exclusive\")\n\t}\n\tif opts.Remove && value != nil {\n\t\treturn nil, fmt.Errorf(\"SetPath: Remove and value are mutually exclusive\")\n\t}\n\treturn setPathInternal(data, pathWithPos{Path: path, Index: 0}, value, *opts)\n}\n\nfunc checkAndModifyBudget(opts *SetPathOpts, pp pathWithPos, cost int) bool {\n\tif opts.Budget == 0 {\n\t\treturn true\n\t}\n\topts.Budget -= cost\n\tif opts.Budget < 0 {\n\t\treturn false\n\t}\n\tif opts.Budget == 0 {\n\t\t// 0 is weird since it means unlimited, so we set it to -1 to fail the next operation\n\t\topts.Budget = -1\n\t}\n\treturn true\n}\n\nfunc CombineFn_ArrayAppend(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) {\n\tif !checkAndModifyBudget(&opts, pp, 1) {\n\t\treturn nil, MakeBudgetError(\"trying to append to array\", pp.Path, pp.Index)\n\t}\n\tif data == nil {\n\t\tdata = make([]any, 0)\n\t}\n\tarrVal, ok := data.([]any)\n\tif !ok && !opts.Force {\n\t\treturn nil, MakeSetTypeError(fmt.Sprintf(\"expected array, but got %T\", data), pp.Path, pp.Index)\n\t}\n\tif !ok {\n\t\tarrVal = make([]any, 0)\n\t}\n\tarrVal = append(arrVal, value)\n\treturn arrVal, nil\n}\n\nfunc CombineFn_SetUnless(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) {\n\tif data != nil {\n\t\treturn data, nil\n\t}\n\treturn value, nil\n}\n\nfunc CombineFn_Max(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) {\n\tvalueFloat, ok := value.(float64)\n\tif !ok {\n\t\treturn nil, MakeSetTypeError(fmt.Sprintf(\"expected float64, but got %T\", value), pp.Path, pp.Index)\n\t}\n\tif data == nil {\n\t\treturn value, nil\n\t}\n\tdataFloat, ok := data.(float64)\n\tif !ok && !opts.Force {\n\t\treturn nil, MakeSetTypeError(fmt.Sprintf(\"expected float64, but got %T\", data), pp.Path, pp.Index)\n\t}\n\tif !ok {\n\t\treturn value, nil\n\t}\n\tif dataFloat > valueFloat {\n\t\treturn data, nil\n\t}\n\treturn value, nil\n}\n\nfunc CombineFn_Min(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) {\n\tvalueFloat, ok := value.(float64)\n\tif !ok {\n\t\treturn nil, MakeSetTypeError(fmt.Sprintf(\"expected float64, but got %T\", value), pp.Path, pp.Index)\n\t}\n\tif data == nil {\n\t\treturn value, nil\n\t}\n\tdataFloat, ok := data.(float64)\n\tif !ok && !opts.Force {\n\t\treturn nil, MakeSetTypeError(fmt.Sprintf(\"expected float64, but got %T\", data), pp.Path, pp.Index)\n\t}\n\tif !ok {\n\t\treturn value, nil\n\t}\n\tif dataFloat < valueFloat {\n\t\treturn data, nil\n\t}\n\treturn value, nil\n}\n\nfunc CombineFn_Inc(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) {\n\tvalueFloat, ok := value.(float64)\n\tif !ok {\n\t\treturn nil, MakeSetTypeError(fmt.Sprintf(\"expected float64, but got %T\", value), pp.Path, pp.Index)\n\t}\n\tif data == nil {\n\t\treturn value, nil\n\t}\n\tdataFloat, ok := data.(float64)\n\tif !ok && !opts.Force {\n\t\treturn nil, MakeSetTypeError(fmt.Sprintf(\"expected float64, but got %T\", data), pp.Path, pp.Index)\n\t}\n\tif !ok {\n\t\treturn value, nil\n\t}\n\treturn dataFloat + valueFloat, nil\n}\n\n// force will clobber existing values that don't conform to path\n// so SetPath(5, [\"a\"], 6 true) would return {\"a\": 6}\nfunc setPathInternal(data any, pp pathWithPos, value any, opts SetPathOpts) (any, error) {\n\tif pp.Index >= len(pp.Path) {\n\t\tif opts.CombineFn != nil {\n\t\t\treturn opts.CombineFn(data, value, pp, opts)\n\t\t}\n\t\treturn value, nil\n\t}\n\tpathElemAny := pp.Path[pp.Index]\n\tswitch pathElem := pathElemAny.(type) {\n\tcase string:\n\t\tif data == nil {\n\t\t\tif opts.Remove {\n\t\t\t\treturn nil, nil\n\t\t\t}\n\t\t\tdata = make(map[string]any)\n\t\t}\n\t\tmapVal, ok := data.(map[string]any)\n\t\tif !ok && !opts.Force {\n\t\t\treturn nil, MakeSetTypeError(fmt.Sprintf(\"expected map, but got %T\", data), pp.Path, pp.Index)\n\t\t}\n\t\tif !ok {\n\t\t\tmapVal = make(map[string]any)\n\t\t}\n\t\tif opts.Remove && pp.isLast() {\n\t\t\tdelete(mapVal, pathElem)\n\t\t\tif len(mapVal) == 0 {\n\t\t\t\treturn nil, nil\n\t\t\t}\n\t\t\treturn mapVal, nil\n\t\t}\n\t\tif _, ok := mapVal[pathElem]; !ok {\n\t\t\tif opts.Remove {\n\t\t\t\treturn mapVal, nil\n\t\t\t}\n\t\t\tif !checkAndModifyBudget(&opts, pp, 1) {\n\t\t\t\treturn nil, MakeBudgetError(\"trying to allocate map entry\", pp.Path, pp.Index)\n\t\t\t}\n\t\t}\n\t\tnewVal, err := setPathInternal(mapVal[pathElem], pathWithPos{Path: pp.Path, Index: pp.Index + 1}, value, opts)\n\t\tif opts.Remove && newVal == nil {\n\t\t\tdelete(mapVal, pathElem)\n\t\t\tif len(mapVal) == 0 {\n\t\t\t\treturn nil, nil\n\t\t\t}\n\t\t\treturn mapVal, nil\n\t\t}\n\t\tmapVal[pathElem] = newVal\n\t\treturn mapVal, err\n\tcase int:\n\t\tif pathElem < 0 {\n\t\t\treturn nil, MakePathError(\"negative index\", pp.Path, pp.Index)\n\t\t}\n\t\tif data == nil {\n\t\t\tif opts.Remove {\n\t\t\t\treturn nil, nil\n\t\t\t}\n\t\t\tif !checkAndModifyBudget(&opts, pp, pathElem+1) {\n\t\t\t\treturn nil, MakeBudgetError(fmt.Sprintf(\"trying to allocate array with %d elements\", pathElem+1), pp.Path, pp.Index)\n\t\t\t}\n\t\t\tdata = make([]any, pathElem+1)\n\t\t}\n\t\tarrVal, ok := data.([]any)\n\t\tif !ok && !opts.Force {\n\t\t\treturn nil, MakeSetTypeError(fmt.Sprintf(\"expected array, but got %T\", data), pp.Path, pp.Index)\n\t\t}\n\t\tif !ok {\n\t\t\tif opts.Remove {\n\t\t\t\treturn nil, nil\n\t\t\t}\n\t\t\tif !checkAndModifyBudget(&opts, pp, pathElem+1) {\n\t\t\t\treturn nil, MakeBudgetError(fmt.Sprintf(\"trying to allocate array with %d elements\", pathElem+1), pp.Path, pp.Index)\n\t\t\t}\n\t\t\tarrVal = make([]any, pathElem+1)\n\t\t}\n\t\tif opts.Remove && pp.isLast() {\n\t\t\tif pathElem == len(arrVal)-1 {\n\t\t\t\tarrVal = arrVal[:pathElem]\n\t\t\t\tif len(arrVal) == 0 {\n\t\t\t\t\treturn nil, nil\n\t\t\t\t}\n\t\t\t\treturn arrVal, nil\n\t\t\t}\n\t\t\tarrVal[pathElem] = nil\n\t\t\treturn arrVal, nil\n\t\t}\n\t\tentriesToAdd := pathElem + 1 - len(arrVal)\n\t\tif opts.Remove && entriesToAdd > 0 {\n\t\t\treturn nil, nil\n\t\t}\n\t\tif !checkAndModifyBudget(&opts, pp, entriesToAdd) {\n\t\t\treturn nil, MakeBudgetError(fmt.Sprintf(\"trying to add %d elements to array\", entriesToAdd), pp.Path, pp.Index)\n\t\t}\n\t\tfor len(arrVal) <= pathElem {\n\t\t\tarrVal = append(arrVal, nil)\n\t\t}\n\t\tnewVal, err := setPathInternal(arrVal[pathElem], pathWithPos{Path: pp.Path, Index: pp.Index + 1}, value, opts)\n\t\tif opts.Remove && newVal == nil && pathElem == len(arrVal)-1 {\n\t\t\tarrVal = arrVal[:pathElem]\n\t\t\tif len(arrVal) == 0 {\n\t\t\t\treturn nil, nil\n\t\t\t}\n\t\t\treturn arrVal, nil\n\t\t}\n\t\tarrVal[pathElem] = newVal\n\t\treturn arrVal, err\n\tdefault:\n\t\treturn nil, PathError{fmt.Sprintf(\"invalid path element type %T\", pathElem)}\n\t}\n}\n\nfunc NormalizeNumbers(v any) any {\n\tswitch v := v.(type) {\n\tcase int:\n\t\treturn float64(v)\n\tcase float32:\n\t\treturn float64(v)\n\tcase int8:\n\t\treturn float64(v)\n\tcase int16:\n\t\treturn float64(v)\n\tcase int32:\n\t\treturn float64(v)\n\tcase int64:\n\t\treturn float64(v)\n\tcase uint:\n\t\treturn float64(v)\n\tcase uint8:\n\t\treturn float64(v)\n\tcase uint16:\n\t\treturn float64(v)\n\tcase uint32:\n\t\treturn float64(v)\n\tcase uint64:\n\t\treturn float64(v)\n\tcase []any:\n\t\tfor i, elem := range v {\n\t\t\tv[i] = NormalizeNumbers(elem)\n\t\t}\n\tcase map[string]any:\n\t\tfor k, elem := range v {\n\t\t\tv[k] = NormalizeNumbers(elem)\n\t\t}\n\t}\n\treturn v\n\n}\n\nfunc DeepEqual(v1 any, v2 any) bool {\n\tif v1 == nil && v2 == nil {\n\t\treturn true\n\t}\n\tif v1 == nil || v2 == nil {\n\t\treturn false\n\t}\n\tswitch v1 := v1.(type) {\n\tcase bool:\n\t\tv2, ok := v2.(bool)\n\t\treturn ok && v1 == v2\n\tcase float64:\n\t\tv2, ok := v2.(float64)\n\t\treturn ok && v1 == v2\n\tcase string:\n\t\tv2, ok := v2.(string)\n\t\treturn ok && v1 == v2\n\tcase []any:\n\t\tv2, ok := v2.([]any)\n\t\tif !ok || len(v1) != len(v2) {\n\t\t\treturn false\n\t\t}\n\t\tfor i := range v1 {\n\t\t\tif !DeepEqual(v1[i], v2[i]) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\tcase map[string]any:\n\t\tv2, ok := v2.(map[string]any)\n\t\tif !ok || len(v1) != len(v2) {\n\t\t\treturn false\n\t\t}\n\t\tfor k, v := range v1 {\n\t\t\tif !DeepEqual(v, v2[k]) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\tdefault:\n\t\t// invalid data type, so just return false\n\t\treturn false\n\t}\n}\n\nfunc getCommandType(command Command) string {\n\ttypeVal, ok := command[\"type\"]\n\tif !ok {\n\t\treturn \"\"\n\t}\n\ttypeStr, ok := typeVal.(string)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn typeStr\n}\n\nfunc getCommandPath(command Command) []any {\n\tpathVal, ok := command[\"path\"]\n\tif !ok {\n\t\treturn nil\n\t}\n\tpath, ok := pathVal.([]any)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn path\n}\n\nfunc ValidatePath(path any) error {\n\tif path == nil {\n\t\t// nil path is allowed (sets the root)\n\t\treturn nil\n\t}\n\tpathArr, ok := path.([]any)\n\tif !ok {\n\t\treturn fmt.Errorf(\"path is not an array\")\n\t}\n\tfor idx, elem := range pathArr {\n\t\tswitch elem.(type) {\n\t\tcase string, int:\n\t\t\tcontinue\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"path element %d is not a string or int\", idx)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ValidateAndMarshalCommand(command Command) ([]byte, error) {\n\tcmdType := getCommandType(command)\n\tif cmdType != SetCommandStr && cmdType != DelCommandStr && cmdType != AppendCommandStr {\n\t\treturn nil, fmt.Errorf(\"unknown ijson command type %q\", cmdType)\n\t}\n\tpath := getCommandPath(command)\n\terr := ValidatePath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tbarr, err := json.Marshal(command)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error marshalling ijson command to json: %w\", err)\n\t}\n\treturn barr, nil\n}\n\nfunc ApplyCommand(data any, command Command, budget int) (any, error) {\n\tcommandType := getCommandType(command)\n\tif commandType == \"\" {\n\t\treturn nil, fmt.Errorf(\"ApplyCommand: missing type field\")\n\t}\n\tswitch commandType {\n\tcase SetCommandStr:\n\t\tpath := getCommandPath(command)\n\t\treturn SetPath(data, path, command[\"data\"], &SetPathOpts{Budget: budget})\n\tcase DelCommandStr:\n\t\tpath := getCommandPath(command)\n\t\treturn SetPath(data, path, nil, &SetPathOpts{Remove: true, Budget: budget})\n\tcase AppendCommandStr:\n\t\tpath := getCommandPath(command)\n\t\treturn SetPath(data, path, command[\"data\"], &SetPathOpts{CombineFn: CombineFn_ArrayAppend, Budget: budget})\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"ApplyCommand: unknown command type %q\", commandType)\n\t}\n}\n\nfunc ApplyCommands(data any, commands []Command, budget int) (any, error) {\n\tfor _, command := range commands {\n\t\tvar err error\n\t\tdata, err = ApplyCommand(data, command, budget)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\treturn data, nil\n}\n\nfunc CompactIJson(fullData []byte, budget int) ([]byte, error) {\n\tvar newData any\n\tfor len(fullData) > 0 {\n\t\tnlIdx := bytes.IndexByte(fullData, '\\n')\n\t\tvar cmdData []byte\n\t\tif nlIdx == -1 {\n\t\t\tcmdData = fullData\n\t\t\tfullData = nil\n\t\t} else {\n\t\t\tcmdData = fullData[:nlIdx]\n\t\t\tfullData = fullData[nlIdx+1:]\n\t\t}\n\t\tvar cmdMap Command\n\t\terr := json.Unmarshal(cmdData, &cmdMap)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error unmarshalling ijson command: %w\", err)\n\t\t}\n\t\tnewData, err = ApplyCommand(newData, cmdMap, budget)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error applying ijson command: %w\", err)\n\t\t}\n\t}\n\tnewRootCmd := MakeSetCommand(nil, newData)\n\treturn json.Marshal(newRootCmd)\n}\n\n// returns a list of commands\nfunc ParseIJson(fullData []byte) ([]Command, error) {\n\tvar commands []Command\n\tfor len(fullData) > 0 {\n\t\tnlIdx := bytes.IndexByte(fullData, '\\n')\n\t\tvar cmdData []byte\n\t\tif nlIdx == -1 {\n\t\t\tcmdData = fullData\n\t\t\tfullData = nil\n\t\t} else {\n\t\t\tcmdData = fullData[:nlIdx]\n\t\t\tfullData = fullData[nlIdx+1:]\n\t\t}\n\t\tvar cmdMap Command\n\t\terr := json.Unmarshal(cmdData, &cmdMap)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error unmarshalling ijson command: %w\", err)\n\t\t}\n\t\tcommands = append(commands, cmdMap)\n\t}\n\treturn commands, nil\n}\n"
  },
  {
    "path": "pkg/ijson/ijson_test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ijson\n\nimport \"testing\"\n\nfunc TestDeepEqual(t *testing.T) {\n\tif !DeepEqual(float64(1), float64(1)) {\n\t\tt.Errorf(\"DeepEqual(1, 1) should be true\")\n\t}\n\tif DeepEqual(float64(1), float64(2)) {\n\t\tt.Errorf(\"DeepEqual(1, 2) should be false\")\n\t}\n\tif !DeepEqual([]any{\"a\", 2.8, true, map[string]any{\"c\": 1.1}}, []any{\"a\", 2.8, true, map[string]any{\"c\": 1.1}}) {\n\t\tt.Errorf(\"DeepEqual complex should be true\")\n\t}\n}\n\nfunc TestGetPath(t *testing.T) {\n\tdata := []any{\"a\", 2.8, true, map[string]any{\"c\": 1.1}}\n\n\trtn, err := GetPath(data, []any{0})\n\tif err != nil {\n\t\tt.Errorf(\"GetPath failed: %v\", err)\n\t}\n\tif rtn != \"a\" {\n\t\tt.Errorf(\"GetPath failed: %v\", rtn)\n\t}\n\n\trtn, err = GetPath(data, []any{50})\n\tif err != nil {\n\t\tt.Errorf(\"GetPath failed: %v\", err)\n\t}\n\tif rtn != nil {\n\t\tt.Errorf(\"GetPath failed: %v\", rtn)\n\t}\n\n\trtn, err = GetPath(data, []any{3, \"c\"})\n\tif err != nil {\n\t\tt.Errorf(\"GetPath failed: %v\", err)\n\t}\n\tif rtn != 1.1 {\n\t\tt.Errorf(\"GetPath failed: %v\", rtn)\n\t}\n}\n\nfunc makeValue() any {\n\treturn []any{\"a\", 2.8, true, map[string]any{\"c\": 1.1}}\n}\n\nfunc TestSetPath(t *testing.T) {\n\trtn, err := SetPath(makeValue(), []any{0}, \"b\", nil)\n\tif err != nil {\n\t\tt.Errorf(\"SetPath failed: %v\", err)\n\t}\n\tif rtn.([]any)[0] != \"b\" {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n\trtn, err = SetPath(makeValue(), []any{10}, \"b\", nil)\n\tif err != nil {\n\t\tt.Errorf(\"SetPath failed: %v\", err)\n\t}\n\tif len(rtn.([]any)) != 11 {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n\trtn, _ = GetPath(rtn, []any{10})\n\tif rtn != \"b\" {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n\t_, err = SetPath(makeValue(), []any{\"a\"}, \"b\", nil)\n\tif err == nil {\n\t\tt.Errorf(\"SetPath should have failed\")\n\t}\n\trtn, err = SetPath(makeValue(), []any{\"a\"}, \"b\", &SetPathOpts{Force: true})\n\tif err != nil {\n\t\tt.Errorf(\"SetPath failed: %v\", err)\n\t}\n\tif !DeepEqual(rtn, map[string]any{\"a\": \"b\"}) {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n\trtn, err = SetPath(makeValue(), nil, \"c\", &SetPathOpts{CombineFn: CombineFn_ArrayAppend})\n\tif err != nil {\n\t\tt.Errorf(\"SetPath failed: %v\", err)\n\t}\n\tif !DeepEqual(rtn, []any{\"a\", 2.8, true, map[string]any{\"c\": 1.1}, \"c\"}) {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n\t_, err = SetPath(makeValue(), nil, \"c\", &SetPathOpts{CombineFn: CombineFn_ArrayAppend, Budget: -1})\n\tif err == nil {\n\t\tt.Errorf(\"SetPath should have failed\")\n\t}\n\trtn, err = SetPath(makeValue(), []any{5000}, \"c\", nil)\n\tif err != nil {\n\t\tt.Errorf(\"SetPath failed: %v\", err)\n\t}\n\tif len(rtn.([]any)) != 5001 {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n\t_, err = SetPath(makeValue(), []any{5000}, \"c\", &SetPathOpts{Budget: 1000})\n\tif err == nil {\n\t\tt.Errorf(\"SetPath should have failed\")\n\t}\n\trtn, err = SetPath(makeValue(), []any{3, \"c\"}, nil, &SetPathOpts{Remove: true})\n\tif err != nil {\n\t\tt.Errorf(\"SetPath failed: %v\", err)\n\t}\n\tif !DeepEqual(rtn, []any{\"a\", 2.8, true}) {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n\trtn, _ = SetPath(makeValue(), []any{3}, nil, &SetPathOpts{Remove: true})\n\trtn, _ = SetPath(rtn, []any{2}, nil, &SetPathOpts{Remove: true})\n\trtn, _ = SetPath(rtn, []any{1}, nil, &SetPathOpts{Remove: true})\n\trtn, _ = SetPath(rtn, []any{0}, nil, &SetPathOpts{Remove: true})\n\tif rtn != nil {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n\trtn, err = SetPath(makeValue(), []any{3, \"d\"}, 2.2, nil)\n\tif err != nil {\n\t\tt.Errorf(\"SetPath failed: %v\", err)\n\t}\n\tif !DeepEqual(rtn, []any{\"a\", 2.8, true, map[string]any{\"c\": 1.1, \"d\": 2.2}}) {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n\n\trtn, err = SetPath(makeValue(), []any{1}, 2.2, &SetPathOpts{CombineFn: CombineFn_Inc})\n\tif err != nil {\n\t\tt.Errorf(\"SetPath failed: %v\", err)\n\t}\n\tif !DeepEqual(rtn, []any{\"a\", 5.0, true, map[string]any{\"c\": 1.1}}) {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n\n\trtn, err = SetPath(makeValue(), []any{1}, 500.0, &SetPathOpts{CombineFn: CombineFn_Min})\n\tif err != nil {\n\t\tt.Errorf(\"SetPath failed: %v\", err)\n\t}\n\tif rtn.([]any)[1] != 2.8 {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n\n\trtn, err = SetPath(makeValue(), []any{1}, 500.0, &SetPathOpts{CombineFn: CombineFn_Max})\n\tif err != nil {\n\t\tt.Errorf(\"SetPath failed: %v\", err)\n\t}\n\tif rtn.([]any)[1] != 500.0 {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n\n\trtn, err = SetPath(makeValue(), []any{1}, 500.0, &SetPathOpts{CombineFn: CombineFn_SetUnless})\n\tif err != nil {\n\t\tt.Errorf(\"SetPath failed: %v\", err)\n\t}\n\tif rtn.([]any)[1] != 2.8 {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n\trtn, err = SetPath(makeValue(), []any{8}, 500.0, &SetPathOpts{CombineFn: CombineFn_SetUnless})\n\tif err != nil {\n\t\tt.Errorf(\"SetPath failed: %v\", err)\n\t}\n\tif rtn.([]any)[8] != 500.0 {\n\t\tt.Errorf(\"SetPath failed: %v\", rtn)\n\t}\n}\n"
  },
  {
    "path": "pkg/jobcontroller/jobcontroller.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage jobcontroller\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"log\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/blocklogger\"\n\t\"github.com/wavetermdev/waveterm/pkg/filestore\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/conncontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/streamclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/ds\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/envutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/utilds\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavejwt\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcore\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n\t\"golang.org/x/sync/singleflight\"\n)\n\nconst DefaultTimeout = 2 * time.Second\n\nconst (\n\tJobManagerStatus_Init    = \"init\"\n\tJobManagerStatus_Running = \"running\"\n\tJobManagerStatus_Done    = \"done\"\n)\n\nconst (\n\tJobDoneReason_StartupError = \"startuperror\"\n\tJobDoneReason_Gone         = \"gone\"\n\tJobDoneReason_Terminated   = \"terminated\"\n)\n\nconst (\n\tJobConnStatus_Disconnected = \"disconnected\"\n\tJobConnStatus_Connecting   = \"connecting\"\n\tJobConnStatus_Connected    = \"connected\"\n)\n\nconst (\n\tJobKind_Shell = \"shell\"\n\tJobKind_Task  = \"task\"\n)\n\nconst DefaultStreamRwnd = 64 * 1024\nconst MetaKey_TotalGap = \"totalgap\"\nconst JobOutputFileName = \"term\"\nconst AutoReconnectDelay = 1 * time.Second\nconst AutoReconnectCooldown = 30 * time.Second\n\ntype connState struct {\n\tactual      bool\n\tprocessed   bool\n\treconciling bool\n}\n\ntype connStateManager struct {\n\tsync.Mutex\n\tm           map[string]*connState\n\treconcileCh chan struct{}\n}\n\ntype jobState struct {\n\tstateLock       sync.Mutex\n\tisConnecting    bool\n\tconnectedStatus string\n}\n\nvar (\n\tjobConnStates         = make(map[string]string)\n\tjobControllerLock     sync.Mutex\n\tblockJobStatusVersion utilds.VersionTs\n\n\tconnStates = &connStateManager{\n\t\tm:           make(map[string]*connState),\n\t\treconcileCh: make(chan struct{}, 1),\n\t}\n\n\tjobStreamIds = ds.MakeSyncMap[string]()\n\n\tjobTerminationMessageWritten = ds.MakeSyncMap[bool]()\n\n\tlastAutoReconnectAttempt = ds.MakeSyncMap[int64]()\n\n\treconnectGroup           singleflight.Group\n\tterminateJobManagerGroup singleflight.Group\n)\n\nfunc InitJobController() {\n\tgo connReconcileWorker()\n\tgo jobPruningWorker()\n\n\trpcClient := wshclient.GetBareRpcClient()\n\trpcClient.EventListener.On(wps.Event_RouteUp, handleRouteUpEvent)\n\trpcClient.EventListener.On(wps.Event_RouteDown, handleRouteDownEvent)\n\trpcClient.EventListener.On(wps.Event_ConnChange, handleConnChangeEvent)\n\trpcClient.EventListener.On(wps.Event_BlockClose, handleBlockCloseEvent)\n\twshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{\n\t\tEvent:     wps.Event_RouteUp,\n\t\tAllScopes: true,\n\t}, nil)\n\twshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{\n\t\tEvent:     wps.Event_RouteDown,\n\t\tAllScopes: true,\n\t}, nil)\n\twshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{\n\t\tEvent:     wps.Event_ConnChange,\n\t\tAllScopes: true,\n\t}, nil)\n\twshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{\n\t\tEvent:     wps.Event_BlockClose,\n\t\tAllScopes: true,\n\t}, nil)\n}\n\nfunc isJobManagerRunning(job *waveobj.Job) bool {\n\treturn job.JobManagerStatus == JobManagerStatus_Running\n}\n\nfunc GetJobManagerStatus(ctx context.Context, jobId string) (string, error) {\n\tjob, err := wstore.DBGet[*waveobj.Job](ctx, jobId)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to get job: %w\", err)\n\t}\n\tif job == nil {\n\t\treturn JobManagerStatus_Done, nil\n\t}\n\treturn job.JobManagerStatus, nil\n}\n\nfunc GetAllJobManagerStatus(ctx context.Context) ([]*wshrpc.JobManagerStatusUpdate, error) {\n\tallJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get jobs: %w\", err)\n\t}\n\n\tvar statuses []*wshrpc.JobManagerStatusUpdate\n\tfor _, job := range allJobs {\n\t\tstatuses = append(statuses, &wshrpc.JobManagerStatusUpdate{\n\t\t\tJobId:            job.OID,\n\t\t\tJobManagerStatus: job.JobManagerStatus,\n\t\t})\n\t}\n\n\treturn statuses, nil\n}\n\nfunc GetBlockJobStatus(ctx context.Context, blockId string) (*wshrpc.BlockJobStatusData, error) {\n\tblock, err := wstore.DBGet[*waveobj.Block](ctx, blockId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get block: %w\", err)\n\t}\n\tif block == nil {\n\t\treturn nil, fmt.Errorf(\"block not found: %s\", blockId)\n\t}\n\n\tdata := &wshrpc.BlockJobStatusData{\n\t\tBlockId:   blockId,\n\t\tVersionTs: blockJobStatusVersion.GetVersionTs(),\n\t}\n\n\tif block.JobId == \"\" {\n\t\treturn data, nil\n\t}\n\n\tjob, err := wstore.DBGet[*waveobj.Job](ctx, block.JobId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get job: %w\", err)\n\t}\n\tif job == nil {\n\t\treturn data, nil\n\t}\n\n\tdata.JobId = job.OID\n\tdata.DoneReason = job.JobManagerDoneReason\n\tdata.StartupError = job.JobManagerStartupError\n\tdata.CmdExitTs = job.CmdExitTs\n\tdata.CmdExitCode = job.CmdExitCode\n\tdata.CmdExitSignal = job.CmdExitSignal\n\n\tif job.JobManagerStatus == JobManagerStatus_Init {\n\t\tdata.Status = \"init\"\n\t} else if job.JobManagerStatus == JobManagerStatus_Done {\n\t\tdata.Status = \"done\"\n\t} else if job.JobManagerStatus == JobManagerStatus_Running {\n\t\tconnStatus := GetJobConnStatus(job.OID)\n\t\tif connStatus == JobConnStatus_Connected {\n\t\t\tdata.Status = \"connected\"\n\t\t} else {\n\t\t\tdata.Status = \"disconnected\"\n\t\t}\n\t}\n\n\treturn data, nil\n}\n\nfunc SendBlockJobStatusEvent(ctx context.Context, blockId string) {\n\tdata, err := GetBlockJobStatus(ctx, blockId)\n\tif err != nil {\n\t\tlog.Printf(\"[block:%s] error getting block job status: %v\", blockId, err)\n\t\treturn\n\t}\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent:  wps.Event_BlockJobStatus,\n\t\tScopes: []string{fmt.Sprintf(\"block:%s\", blockId)},\n\t\tData:   data,\n\t})\n}\n\nfunc sendBlockJobStatusEventByJob(ctx context.Context, job *waveobj.Job) {\n\tif job == nil || job.AttachedBlockId == \"\" {\n\t\treturn\n\t}\n\tSendBlockJobStatusEvent(ctx, job.AttachedBlockId)\n}\n\nfunc connReconcileWorker() {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"jobcontroller:connReconcileWorker\", recover())\n\t}()\n\n\tfor range connStates.reconcileCh {\n\t\treconcileAllConns()\n\t}\n}\n\nfunc reconcileAllConns() {\n\tconnStates.Lock()\n\tdefer connStates.Unlock()\n\n\tfor connName, cs := range connStates.m {\n\t\tif cs.reconciling || cs.actual == cs.processed {\n\t\t\tcontinue\n\t\t}\n\n\t\tcs.reconciling = true\n\t\tactual := cs.actual\n\t\tgo reconcileConn(connName, actual)\n\t}\n}\n\nfunc reconcileConn(connName string, targetState bool) {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"jobcontroller:reconcileConn\", recover())\n\t}()\n\n\tif targetState {\n\t\tonConnectionUp(connName)\n\t} else {\n\t\tonConnectionDown(connName)\n\t}\n\n\tconnStates.Lock()\n\tdefer connStates.Unlock()\n\tif cs, exists := connStates.m[connName]; exists {\n\t\tcs.processed = targetState\n\t\tcs.reconciling = false\n\t}\n\n\tselect {\n\tcase connStates.reconcileCh <- struct{}{}:\n\tdefault:\n\t}\n}\n\nfunc getMetaInt64(meta wshrpc.FileMeta, key string) int64 {\n\tval, ok := meta[key]\n\tif !ok {\n\t\treturn 0\n\t}\n\tif intVal, ok := val.(int64); ok {\n\t\treturn intVal\n\t}\n\tif floatVal, ok := val.(float64); ok {\n\t\treturn int64(floatVal)\n\t}\n\treturn 0\n}\n\nfunc jobPruningWorker() {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"jobcontroller:jobPruningWorker\", recover())\n\t}()\n\n\tticker := time.NewTicker(1 * time.Minute)\n\tdefer ticker.Stop()\n\n\tvar previousCandidates []string\n\tfor range ticker.C {\n\t\tpreviousCandidates = pruneUnusedJobs(previousCandidates)\n\t}\n}\n\nfunc pruneUnusedJobs(previousCandidates []string) []string {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancelFn()\n\n\tallJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job)\n\tif err != nil {\n\t\tlog.Printf(\"[jobpruner] error getting all jobs: %v\", err)\n\t\treturn previousCandidates\n\t}\n\n\tvar currentCandidates []string\n\tfor _, job := range allJobs {\n\t\tif job.JobManagerStatus == JobManagerStatus_Done && job.AttachedBlockId == \"\" {\n\t\t\tcurrentCandidates = append(currentCandidates, job.OID)\n\t\t}\n\t}\n\n\tjobsToDelete := utilfn.StrSetIntersection(previousCandidates, currentCandidates)\n\tif len(previousCandidates) > 0 || len(currentCandidates) > 0 {\n\t\tlog.Printf(\"[jobpruner] prev=%d current=%d deleting=%d\", len(previousCandidates), len(currentCandidates), len(jobsToDelete))\n\t}\n\n\tfor _, jobId := range jobsToDelete {\n\t\terr := DeleteJob(ctx, jobId)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[jobpruner] error deleting job %s: %v\", jobId, err)\n\t\t}\n\t}\n\n\treturn currentCandidates\n}\n\nfunc handleRouteUpEvent(event *wps.WaveEvent) {\n\thandleRouteEvent(event, JobConnStatus_Connected)\n}\n\nfunc handleRouteDownEvent(event *wps.WaveEvent) {\n\thandleRouteEvent(event, JobConnStatus_Disconnected)\n}\n\nfunc handleRouteEvent(event *wps.WaveEvent, newStatus string) {\n\tctx := context.Background()\n\tfor _, scope := range event.Scopes {\n\t\tif strings.HasPrefix(scope, \"job:\") {\n\t\t\tjobId := strings.TrimPrefix(scope, \"job:\")\n\t\t\tSetJobConnStatus(jobId, newStatus)\n\t\t\tlog.Printf(\"[job:%s] connection status changed to %s\", jobId, newStatus)\n\n\t\t\tjob, err := wstore.DBGet[*waveobj.Job](ctx, jobId)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"[job:%s] error getting job for status event: %v\", jobId, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsendBlockJobStatusEventByJob(ctx, job)\n\n\t\t\tif newStatus == JobConnStatus_Disconnected && job != nil && isJobManagerRunning(job) {\n\t\t\t\tif shouldAttemptAutoReconnect(jobId) {\n\t\t\t\t\tgo attemptAutoReconnect(jobId, job.Connection)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc shouldAttemptAutoReconnect(jobId string) bool {\n\tnow := time.Now().Unix()\n\tlastAttempt, exists := lastAutoReconnectAttempt.GetEx(jobId)\n\n\tif !exists {\n\t\tlastAutoReconnectAttempt.Set(jobId, now)\n\t\treturn true\n\t}\n\n\ttimeSinceLastAttempt := time.Duration(now-lastAttempt) * time.Second\n\tif timeSinceLastAttempt >= AutoReconnectCooldown {\n\t\tlastAutoReconnectAttempt.Set(jobId, now)\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc attemptAutoReconnect(jobId string, connName string) {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"jobcontroller:attemptAutoReconnect\", recover())\n\t}()\n\n\ttime.Sleep(AutoReconnectDelay)\n\n\tisConnected, err := conncontroller.IsConnected(connName)\n\tif err != nil || !isConnected {\n\t\tlog.Printf(\"[job:%s] connection %s is down, skipping auto-reconnect\", jobId, connName)\n\t\treturn\n\t}\n\n\tlog.Printf(\"[job:%s] connection %s still up after route down, attempting auto-reconnect to determine job manager status\", jobId, connName)\n\tctx, cancelFn := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancelFn()\n\terr = ReconnectJob(ctx, jobId, nil)\n\tif err != nil {\n\t\tlog.Printf(\"[job:%s] auto-reconnect failed: %v\", jobId, err)\n\t} else {\n\t\tlog.Printf(\"[job:%s] auto-reconnect succeeded\", jobId)\n\t}\n}\n\nfunc handleConnChangeEvent(event *wps.WaveEvent) {\n\tvar connStatus wshrpc.ConnStatus\n\terr := utilfn.ReUnmarshal(&connStatus, event.Data)\n\tif err != nil {\n\t\tlog.Printf(\"[connchange] error unmarshaling ConnStatus: %v\", err)\n\t\treturn\n\t}\n\n\tvar connName string\n\tfor _, scope := range event.Scopes {\n\t\tif strings.HasPrefix(scope, \"connection:\") {\n\t\t\tconnName = strings.TrimPrefix(scope, \"connection:\")\n\t\t\tbreak\n\t\t}\n\t}\n\tif connName == \"\" {\n\t\treturn\n\t}\n\n\tconnStates.Lock()\n\tcs, exists := connStates.m[connName]\n\tif !exists {\n\t\tcs = &connState{actual: false, processed: false, reconciling: false}\n\t\tconnStates.m[connName] = cs\n\t}\n\tcs.actual = connStatus.Connected\n\tconnStates.Unlock()\n\n\tselect {\n\tcase connStates.reconcileCh <- struct{}{}:\n\tdefault:\n\t}\n}\n\nfunc handleBlockCloseEvent(event *wps.WaveEvent) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tblockId, ok := event.Data.(string)\n\tif !ok {\n\t\tlog.Printf(\"[blockclose] invalid event data type\")\n\t\treturn\n\t}\n\n\tjobIds, err := wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) ([]string, error) {\n\t\tquery := `SELECT oid FROM db_job WHERE json_extract(data, '$.attachedblockid') = ?`\n\t\tjobIds := tx.SelectStrings(query, blockId)\n\t\treturn jobIds, nil\n\t})\n\tif err != nil {\n\t\tlog.Printf(\"[block:%s] error looking up jobids: %v\", blockId, err)\n\t\treturn\n\t}\n\tif len(jobIds) == 0 {\n\t\treturn\n\t}\n\n\tfor _, jobId := range jobIds {\n\t\tTerminateAndDetachJob(ctx, jobId)\n\t}\n}\n\nfunc onConnectionUp(connName string) {\n\tlog.Printf(\"[conn:%s] connection became connected, reconnecting jobs\", connName)\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\n\tallJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job)\n\tif err != nil {\n\t\tlog.Printf(\"[conn:%s] failed to get jobs for reconnection: %v\", connName, err)\n\t\treturn\n\t}\n\n\tvar jobsToReconnect []*waveobj.Job\n\tfor _, job := range allJobs {\n\t\tif job.Connection == connName && isJobManagerRunning(job) {\n\t\t\tjobsToReconnect = append(jobsToReconnect, job)\n\t\t}\n\t}\n\n\tlog.Printf(\"[conn:%s] found %d jobs to reconnect\", connName, len(jobsToReconnect))\n\n\tsuccessCount := 0\n\tfor _, job := range jobsToReconnect {\n\t\terr = ReconnectJob(ctx, job.OID, nil)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[job:%s] error reconnecting: %v\", job.OID, err)\n\t\t} else {\n\t\t\tsuccessCount++\n\t\t}\n\t}\n\n\tlog.Printf(\"[conn:%s] finished reconnecting jobs: %d/%d successful\", connName, successCount, len(jobsToReconnect))\n}\n\nfunc onConnectionDown(connName string) {\n\tlog.Printf(\"[conn:%s] connection became disconnected\", connName)\n}\n\nfunc GetJobConnStatus(jobId string) string {\n\tjobControllerLock.Lock()\n\tdefer jobControllerLock.Unlock()\n\tstatus, exists := jobConnStates[jobId]\n\tif !exists {\n\t\treturn JobConnStatus_Disconnected\n\t}\n\treturn status\n}\n\nfunc SetJobConnStatus(jobId string, status string) {\n\tjobControllerLock.Lock()\n\tdefer jobControllerLock.Unlock()\n\tif status == JobConnStatus_Disconnected {\n\t\tdelete(jobConnStates, jobId)\n\t} else {\n\t\tjobConnStates[jobId] = status\n\t}\n}\n\nfunc GetConnectedJobIds() []string {\n\tjobControllerLock.Lock()\n\tdefer jobControllerLock.Unlock()\n\tvar connectedJobIds []string\n\tfor jobId, status := range jobConnStates {\n\t\tif status == JobConnStatus_Connected {\n\t\t\tconnectedJobIds = append(connectedJobIds, jobId)\n\t\t}\n\t}\n\treturn connectedJobIds\n}\n\nfunc GetNumJobsRunning() int {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\tallJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job)\n\tif err != nil {\n\t\treturn 0\n\t}\n\tcount := 0\n\tfor _, job := range allJobs {\n\t\tif job.JobManagerStatus == JobManagerStatus_Running {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc GetNumJobsConnected() int {\n\tjobControllerLock.Lock()\n\tdefer jobControllerLock.Unlock()\n\tcount := 0\n\tfor _, status := range jobConnStates {\n\t\tif status == JobConnStatus_Connected {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc CheckJobConnected(ctx context.Context, jobId string) (*waveobj.Job, error) {\n\tjob, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get job: %w\", err)\n\t}\n\n\tisConnected, err := conncontroller.IsConnected(job.Connection)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error checking connection status: %w\", err)\n\t}\n\tif !isConnected {\n\t\treturn nil, fmt.Errorf(\"connection %q is not connected\", job.Connection)\n\t}\n\n\tjobConnStatus := GetJobConnStatus(jobId)\n\tif jobConnStatus != JobConnStatus_Connected {\n\t\treturn nil, fmt.Errorf(\"job is not connected (status: %s)\", jobConnStatus)\n\t}\n\n\treturn job, nil\n}\n\ntype StartJobParams struct {\n\tConnName string\n\tJobKind  string\n\tCmd      string\n\tArgs     []string\n\tEnv      map[string]string\n\tTermSize *waveobj.TermSize\n\tBlockId  string\n}\n\nfunc StartJob(ctx context.Context, params StartJobParams) (string, error) {\n\tif params.ConnName == \"\" {\n\t\treturn \"\", fmt.Errorf(\"connection name is required\")\n\t}\n\tif params.JobKind != JobKind_Shell && params.JobKind != JobKind_Task {\n\t\treturn \"\", fmt.Errorf(\"jobkind must be %q or %q\", JobKind_Shell, JobKind_Task)\n\t}\n\tif params.Cmd == \"\" {\n\t\treturn \"\", fmt.Errorf(\"command is required\")\n\t}\n\tif params.TermSize == nil {\n\t\tparams.TermSize = &waveobj.TermSize{Rows: 24, Cols: 80}\n\t}\n\n\tisConnected, err := conncontroller.IsConnected(params.ConnName)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error checking connection status: %w\", err)\n\t}\n\tif !isConnected {\n\t\treturn \"\", fmt.Errorf(\"connection %q is not connected\", params.ConnName)\n\t}\n\n\tjobId := uuid.New().String()\n\tjobAuthToken, err := utilfn.RandomHexString(32)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to generate job auth token: %w\", err)\n\t}\n\n\tjobAccessClaims := &wavejwt.WaveJwtClaims{\n\t\tMainServer: true,\n\t\tJobId:      jobId,\n\t}\n\tjobAccessToken, err := wavejwt.Sign(jobAccessClaims)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to generate job access token: %w\", err)\n\t}\n\n\tjob := &waveobj.Job{\n\t\tOID:              jobId,\n\t\tConnection:       params.ConnName,\n\t\tJobKind:          params.JobKind,\n\t\tCmd:              params.Cmd,\n\t\tCmdArgs:          params.Args,\n\t\tCmdEnv:           params.Env,\n\t\tCmdTermSize:      *params.TermSize,\n\t\tJobAuthToken:     jobAuthToken,\n\t\tJobManagerStatus: JobManagerStatus_Init,\n\t\tAttachedBlockId:  params.BlockId,\n\t\tWaveVersion:      wavebase.WaveVersion,\n\t\tMeta:             make(waveobj.MetaMapType),\n\t}\n\n\terr = wstore.DBInsert(ctx, job)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create job in database: %w\", err)\n\t}\n\tif params.BlockId != \"\" {\n\t\t// AttachJobToBlock will send status\n\t\terr = AttachJobToBlock(ctx, jobId, params.BlockId)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to attach job to block: %w\", err)\n\t\t}\n\t}\n\tbareRpc := wshclient.GetBareRpcClient()\n\tbroker := bareRpc.StreamBroker\n\treaderRouteId := wshclient.GetBareRpcClientRouteId()\n\twriterRouteId := wshutil.MakeJobRouteId(jobId)\n\treader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, DefaultStreamRwnd)\n\tjobStreamIds.Set(jobId, streamMeta.Id)\n\n\tfileOpts := wshrpc.FileOpts{\n\t\tMaxSize:  10 * 1024 * 1024,\n\t\tCircular: true,\n\t}\n\terr = filestore.WFS.MakeFile(ctx, jobId, JobOutputFileName, wshrpc.FileMeta{}, fileOpts)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create WaveFS file: %w\", err)\n\t}\n\n\tclientId := wstore.GetClientId()\n\tpublicKey := wavejwt.GetPublicKey()\n\tpublicKeyBase64 := base64.StdEncoding.EncodeToString(publicKey)\n\tjobEnv := envutil.CopyAndAddToEnvMap(params.Env, \"WAVETERM_JOBID\", jobId)\n\tstartJobData := wshrpc.CommandRemoteStartJobData{\n\t\tCmd:                params.Cmd,\n\t\tArgs:               params.Args,\n\t\tEnv:                jobEnv,\n\t\tTermSize:           *params.TermSize,\n\t\tStreamMeta:         streamMeta,\n\t\tJobAuthToken:       jobAuthToken,\n\t\tJobId:              jobId,\n\t\tMainServerJwtToken: jobAccessToken,\n\t\tClientId:           clientId,\n\t\tPublicKeyBase64:    publicKeyBase64,\n\t}\n\n\trpcOpts := &wshrpc.RpcOpts{\n\t\tRoute:   wshutil.MakeConnectionRouteId(params.ConnName),\n\t\tTimeout: 30000,\n\t}\n\n\twriteSessionSeparatorToTerminal(params.BlockId, params.TermSize.Cols)\n\n\tlog.Printf(\"[job:%s] sending RemoteStartJobCommand to connection %s, cmd=%q, args=%v\", jobId, params.ConnName, params.Cmd, params.Args)\n\tlog.Printf(\"[job:%s] env=%v\", jobId, params.Env)\n\trtnData, err := wshclient.RemoteStartJobCommand(bareRpc, startJobData, rpcOpts)\n\tif err != nil {\n\t\tlog.Printf(\"[job:%s] RemoteStartJobCommand failed: %v\", jobId, err)\n\t\terrMsg := fmt.Sprintf(\"failed to start job: %v\", err)\n\t\tvar updatedJob *waveobj.Job\n\t\twstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) {\n\t\t\tjob.JobManagerStatus = JobManagerStatus_Done\n\t\t\tjob.JobManagerDoneReason = JobDoneReason_StartupError\n\t\t\tjob.JobManagerStartupError = errMsg\n\t\t\tupdatedJob = job\n\t\t})\n\t\tsendBlockJobStatusEventByJob(ctx, updatedJob)\n\t\ttelemetry.GoRecordTEventWrap(&telemetrydata.TEvent{\n\t\t\tEvent: \"job:done\",\n\t\t\tProps: telemetrydata.TEventProps{\n\t\t\t\tJobDoneReason: JobDoneReason_StartupError,\n\t\t\t\tJobKind:       params.JobKind,\n\t\t\t},\n\t\t})\n\t\treturn \"\", fmt.Errorf(\"failed to start remote job: %w\", err)\n\t}\n\n\tlog.Printf(\"[job:%s] RemoteStartJobCommand succeeded, cmdpid=%d cmdstartts=%d jobmanagerpid=%d jobmanagerstartts=%d\", jobId, rtnData.CmdPid, rtnData.CmdStartTs, rtnData.JobManagerPid, rtnData.JobManagerStartTs)\n\tvar updatedJob *waveobj.Job\n\terr = wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) {\n\t\tjob.CmdPid = rtnData.CmdPid\n\t\tjob.CmdStartTs = rtnData.CmdStartTs\n\t\tjob.JobManagerPid = rtnData.JobManagerPid\n\t\tjob.JobManagerStartTs = rtnData.JobManagerStartTs\n\t\tjob.JobManagerStatus = JobManagerStatus_Running\n\t\tupdatedJob = job\n\t})\n\tif err != nil {\n\t\tlog.Printf(\"[job:%s] warning: failed to update job status to running: %v\", jobId, err)\n\t} else {\n\t\tlog.Printf(\"[job:%s] job status updated to running\", jobId)\n\t\tsendBlockJobStatusEventByJob(ctx, updatedJob)\n\t}\n\n\ttelemetry.GoRecordTEventWrap(&telemetrydata.TEvent{\n\t\tEvent: \"job:start\",\n\t\tProps: telemetrydata.TEventProps{\n\t\t\tJobKind: params.JobKind,\n\t\t},\n\t})\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"jobcontroller:runOutputLoop\", recover())\n\t\t}()\n\t\trunOutputLoop(context.Background(), jobId, streamMeta.Id, reader)\n\t}()\n\n\treturn jobId, nil\n}\n\nfunc doWFSAppend(ctx context.Context, oref waveobj.ORef, fileName string, data []byte) error {\n\terr := filestore.WFS.AppendData(ctx, oref.OID, fileName, data)\n\tif err != nil {\n\t\treturn err\n\t}\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent: wps.Event_BlockFile,\n\t\tScopes: []string{\n\t\t\toref.String(),\n\t\t},\n\t\tData: &wps.WSFileEventData{\n\t\t\tZoneId:   oref.OID,\n\t\t\tFileName: fileName,\n\t\t\tFileOp:   wps.FileOp_Append,\n\t\t\tData64:   base64.StdEncoding.EncodeToString(data),\n\t\t},\n\t})\n\treturn nil\n}\n\nfunc handleAppendJobFile(ctx context.Context, jobId string, fileName string, data []byte) error {\n\terr := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Job, jobId), fileName, data)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error appending to job file: %w\", err)\n\t}\n\n\tjob, err := wstore.DBGet[*waveobj.Job](ctx, jobId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting job: %w\", err)\n\t}\n\tif job != nil && job.AttachedBlockId != \"\" {\n\t\terr = doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, job.AttachedBlockId), fileName, data)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error appending to block file: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc runOutputLoop(ctx context.Context, jobId string, streamId string, reader *streamclient.Reader) {\n\tdefer reader.Close()\n\tdefer func() {\n\t\tlog.Printf(\"[job:%s] [stream:%s] output loop finished\", jobId, streamId)\n\t}()\n\n\tlog.Printf(\"[job:%s] [stream:%s] output loop started\", jobId, streamId)\n\tbuf := make([]byte, 4096)\n\tfor {\n\t\tn, err := reader.Read(buf)\n\t\tcurrentStreamId, _ := jobStreamIds.GetEx(jobId)\n\t\tif currentStreamId != streamId {\n\t\t\tlog.Printf(\"[job:%s] [stream:%s] stream superseded by [stream:%s], exiting output loop\", jobId, streamId, currentStreamId)\n\t\t\tbreak\n\t\t}\n\t\tif n > 0 {\n\t\t\tappendErr := handleAppendJobFile(ctx, jobId, JobOutputFileName, buf[:n])\n\t\t\tif appendErr != nil {\n\t\t\t\tlog.Printf(\"[job:%s] error appending data to WaveFS: %v\", jobId, appendErr)\n\t\t\t}\n\t\t}\n\n\t\tif err == io.EOF {\n\t\t\tlog.Printf(\"[job:%s] stream ended (EOF)\", jobId)\n\t\t\tupdateErr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) {\n\t\t\t\tjob.StreamDone = true\n\t\t\t})\n\t\t\tif updateErr != nil {\n\t\t\t\tlog.Printf(\"[job:%s] error updating job stream status: %v\", jobId, updateErr)\n\t\t\t}\n\t\t\ttryTerminateJobManager(ctx, jobId)\n\t\t\tbreak\n\t\t}\n\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[job:%s] stream error: %v\", jobId, err)\n\t\t\tstreamErr := err.Error()\n\t\t\tupdateErr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) {\n\t\t\t\tjob.StreamDone = true\n\t\t\t\tjob.StreamError = streamErr\n\t\t\t})\n\t\t\tif updateErr != nil {\n\t\t\t\tlog.Printf(\"[job:%s] error updating job stream error: %v\", jobId, updateErr)\n\t\t\t}\n\t\t\ttryTerminateJobManager(ctx, jobId)\n\t\t\tbreak\n\t\t}\n\t}\n}\n\nfunc HandleCmdJobExited(ctx context.Context, jobId string, data wshrpc.CommandJobCmdExitedData) error {\n\tvar updatedJob *waveobj.Job\n\terr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) {\n\t\tjob.CmdExitError = data.ExitErr\n\t\tjob.CmdExitCode = data.ExitCode\n\t\tjob.CmdExitSignal = data.ExitSignal\n\t\tjob.CmdExitTs = data.ExitTs\n\t\tupdatedJob = job\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to update job exit status: %w\", err)\n\t}\n\tsendBlockJobStatusEventByJob(ctx, updatedJob)\n\ttryTerminateJobManager(ctx, jobId)\n\n\tshouldWrite := jobTerminationMessageWritten.TestAndSet(jobId, true, func(val bool, exists bool) bool {\n\t\treturn !exists || !val\n\t})\n\tif shouldWrite {\n\t\tresetTerminalState(ctx, updatedJob.AttachedBlockId)\n\t\tmsg := \"shell terminated\"\n\t\tif updatedJob.CmdExitCode != nil && *updatedJob.CmdExitCode != 0 {\n\t\t\tmsg = fmt.Sprintf(\"shell terminated (exit code %d)\", *updatedJob.CmdExitCode)\n\t\t} else if updatedJob.CmdExitSignal != \"\" {\n\t\t\tmsg = fmt.Sprintf(\"shell terminated (signal %s)\", updatedJob.CmdExitSignal)\n\t\t}\n\t\twriteMutedMessageToTerminal(updatedJob.AttachedBlockId, \"[\"+msg+\"]\")\n\t}\n\treturn nil\n}\n\nfunc tryTerminateJobManager(ctx context.Context, jobId string) {\n\tjob, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId)\n\tif err != nil {\n\t\tlog.Printf(\"[job:%s] error getting job for termination check: %v\", jobId, err)\n\t\treturn\n\t}\n\n\tif job.JobManagerStatus != JobManagerStatus_Running {\n\t\treturn\n\t}\n\n\tcmdExited := job.CmdExitTs != 0\n\n\tif !cmdExited || !job.StreamDone {\n\t\tlog.Printf(\"[job:%s] not ready for termination: exited=%v streamDone=%v\", jobId, cmdExited, job.StreamDone)\n\t\treturn\n\t}\n\n\tlog.Printf(\"[job:%s] both job cmd exited and stream finished, terminating job manager\", jobId)\n\n\terr = TerminateJobManager(ctx, jobId)\n\tif err != nil {\n\t\tlog.Printf(\"[job:%s] error terminating job manager: %v\", jobId, err)\n\t}\n}\n\nfunc TerminateAndDetachJob(ctx context.Context, jobId string) {\n\terr := TerminateJobManager(ctx, jobId)\n\tif err != nil {\n\t\tlog.Printf(\"[job:%s] error terminating job manager: %v\", jobId, err)\n\t}\n\terr = DetachJobFromBlock(ctx, jobId, true)\n\tif err != nil {\n\t\tlog.Printf(\"[job:%s] error detaching job from block: %v\", jobId, err)\n\t}\n}\n\nfunc TerminateJobManager(ctx context.Context, jobId string) error {\n\t_, err, _ := terminateJobManagerGroup.Do(jobId, func() (any, error) {\n\t\terr := doTerminateJobManager(ctx, jobId)\n\t\treturn nil, err\n\t})\n\treturn err\n}\n\nfunc doTerminateJobManager(ctx context.Context, jobId string) error {\n\tvar shouldTerminate bool\n\tvar job *waveobj.Job\n\terr := wstore.DBUpdateFn(ctx, jobId, func(j *waveobj.Job) {\n\t\tjob = j\n\t\tif j.JobManagerStatus == JobManagerStatus_Done {\n\t\t\tshouldTerminate = false\n\t\t\treturn\n\t\t}\n\t\tj.TerminateOnReconnect = true\n\t\tshouldTerminate = true\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to set TerminateOnReconnect: %w\", err)\n\t}\n\n\tif !shouldTerminate {\n\t\tlog.Printf(\"[job:%s] already terminated, skipping\", jobId)\n\t\treturn nil\n\t}\n\n\treturn remoteTerminateJobManager(ctx, job)\n}\n\nfunc DisconnectJob(ctx context.Context, jobId string) error {\n\tjob, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get job: %w\", err)\n\t}\n\n\tbareRpc := wshclient.GetBareRpcClient()\n\trpcOpts := &wshrpc.RpcOpts{\n\t\tRoute:   wshutil.MakeConnectionRouteId(job.Connection),\n\t\tTimeout: 5000,\n\t}\n\n\tdisconnectData := wshrpc.CommandRemoteDisconnectFromJobManagerData{\n\t\tJobId: jobId,\n\t}\n\n\terr = wshclient.RemoteDisconnectFromJobManagerCommand(bareRpc, disconnectData, rpcOpts)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to send disconnect command: %w\", err)\n\t}\n\n\tlog.Printf(\"[job:%s] job disconnect command sent successfully\", jobId)\n\treturn nil\n}\n\nfunc remoteTerminateJobManager(ctx context.Context, job *waveobj.Job) error {\n\tlog.Printf(\"[job:%s] terminating job manager\", job.OID)\n\n\tshouldWrite := jobTerminationMessageWritten.TestAndSet(job.OID, true, func(val bool, exists bool) bool {\n\t\treturn !exists || !val\n\t})\n\tif shouldWrite {\n\t\tresetTerminalState(ctx, job.AttachedBlockId)\n\t\twriteMutedMessageToTerminal(job.AttachedBlockId, \"[shell terminated]\")\n\t}\n\n\tif job.JobManagerStatus == JobManagerStatus_Done {\n\t\tlog.Printf(\"[job:%s] job manager already marked as done, skipping termination\", job.OID)\n\t\treturn nil\n\t}\n\n\tbareRpc := wshclient.GetBareRpcClient()\n\tterminateData := wshrpc.CommandRemoteTerminateJobManagerData{\n\t\tJobId:             job.OID,\n\t\tJobManagerPid:     job.JobManagerPid,\n\t\tJobManagerStartTs: job.JobManagerStartTs,\n\t}\n\n\trpcOpts := &wshrpc.RpcOpts{\n\t\tRoute:   wshutil.MakeConnectionRouteId(job.Connection),\n\t\tTimeout: 5000,\n\t}\n\n\terr := wshclient.RemoteTerminateJobManagerCommand(bareRpc, terminateData, rpcOpts)\n\tif err != nil {\n\t\tlog.Printf(\"[job:%s] error terminating job manager: %v\", job.OID, err)\n\t\treturn fmt.Errorf(\"failed to terminate job manager: %w\", err)\n\t}\n\n\tvar updatedJob *waveobj.Job\n\tupdateErr := wstore.DBUpdateFn(ctx, job.OID, func(job *waveobj.Job) {\n\t\tjob.JobManagerStatus = JobManagerStatus_Done\n\t\tjob.JobManagerDoneReason = JobDoneReason_Terminated\n\t\tjob.TerminateOnReconnect = false\n\t\tif !job.StreamDone {\n\t\t\tjob.StreamDone = true\n\t\t\tjob.StreamError = \"job manager terminated\"\n\t\t}\n\t\tupdatedJob = job\n\t})\n\tif updateErr != nil {\n\t\tlog.Printf(\"[job:%s] error updating job status after termination: %v\", job.OID, updateErr)\n\t} else {\n\t\tsendBlockJobStatusEventByJob(ctx, updatedJob)\n\t}\n\n\ttelemetry.GoRecordTEventWrap(&telemetrydata.TEvent{\n\t\tEvent: \"job:done\",\n\t\tProps: telemetrydata.TEventProps{\n\t\t\tJobDoneReason: JobDoneReason_Terminated,\n\t\t\tJobKind:       job.JobKind,\n\t\t},\n\t})\n\n\tlog.Printf(\"[job:%s] job manager terminated successfully\", job.OID)\n\treturn nil\n}\n\nfunc ReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOpts) error {\n\t_, err, _ := reconnectGroup.Do(jobId, func() (any, error) {\n\t\treturn nil, doReconnectJob(ctx, jobId, rtOpts)\n\t})\n\treturn err\n}\n\nfunc doReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOpts) error {\n\tjob, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get job: %w\", err)\n\t}\n\n\t_, err = CheckJobConnected(ctx, jobId)\n\tif err == nil {\n\t\tlog.Printf(\"[job:%s] already connected, skipping reconnect\", jobId)\n\t\treturn nil\n\t}\n\tlog.Printf(\"[job:%s] not connected, proceeding with reconnect: %v\", jobId, err)\n\n\tisConnected, err := conncontroller.IsConnected(job.Connection)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error checking connection status: %w\", err)\n\t}\n\tif !isConnected {\n\t\treturn fmt.Errorf(\"connection %q is not connected\", job.Connection)\n\t}\n\n\tif job.TerminateOnReconnect {\n\t\treturn remoteTerminateJobManager(ctx, job)\n\t}\n\n\tif rtOpts == nil {\n\t\trtOpts = &waveobj.RuntimeOpts{\n\t\t\tTermSize: job.CmdTermSize,\n\t\t}\n\t}\n\n\tbareRpc := wshclient.GetBareRpcClient()\n\n\tjobAccessClaims := &wavejwt.WaveJwtClaims{\n\t\tMainServer: true,\n\t\tJobId:      jobId,\n\t}\n\tjobAccessToken, err := wavejwt.Sign(jobAccessClaims)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to generate job access token: %w\", err)\n\t}\n\n\treconnectData := wshrpc.CommandRemoteReconnectToJobManagerData{\n\t\tJobId:              jobId,\n\t\tJobAuthToken:       job.JobAuthToken,\n\t\tMainServerJwtToken: jobAccessToken,\n\t\tJobManagerPid:      job.JobManagerPid,\n\t\tJobManagerStartTs:  job.JobManagerStartTs,\n\t}\n\n\trpcOpts := &wshrpc.RpcOpts{\n\t\tRoute:   wshutil.MakeConnectionRouteId(job.Connection),\n\t\tTimeout: 5000,\n\t}\n\n\tlog.Printf(\"[job:%s] sending RemoteReconnectToJobManagerCommand to connection %s\", jobId, job.Connection)\n\trtnData, err := wshclient.RemoteReconnectToJobManagerCommand(bareRpc, reconnectData, rpcOpts)\n\tif err != nil {\n\t\tlog.Printf(\"[job:%s] RemoteReconnectToJobManagerCommand failed: %v\", jobId, err)\n\t\treturn fmt.Errorf(\"failed to reconnect to job manager: %w\", err)\n\t}\n\n\tif !rtnData.Success {\n\t\tlog.Printf(\"[job:%s] RemoteReconnectToJobManagerCommand returned error: %s\", jobId, rtnData.Error)\n\t\tif rtnData.JobManagerGone {\n\t\t\tvar updatedJob *waveobj.Job\n\t\t\tupdateErr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) {\n\t\t\t\tjob.JobManagerStatus = JobManagerStatus_Done\n\t\t\t\tjob.JobManagerDoneReason = JobDoneReason_Gone\n\t\t\t\tupdatedJob = job\n\t\t\t})\n\t\t\tif updateErr != nil {\n\t\t\t\tlog.Printf(\"[job:%s] error updating job manager running status: %v\", jobId, updateErr)\n\t\t\t} else {\n\t\t\t\tsendBlockJobStatusEventByJob(ctx, updatedJob)\n\t\t\t}\n\t\t\ttelemetry.GoRecordTEventWrap(&telemetrydata.TEvent{\n\t\t\t\tEvent: \"job:done\",\n\t\t\t\tProps: telemetrydata.TEventProps{\n\t\t\t\t\tJobDoneReason: JobDoneReason_Gone,\n\t\t\t\t\tJobKind:       job.JobKind,\n\t\t\t\t},\n\t\t\t})\n\t\t\twriteJobTerminationMessage(ctx, jobId, updatedJob, \"[session gone]\")\n\t\t\treturn fmt.Errorf(\"job manager has exited: %s\", rtnData.Error)\n\t\t}\n\t\treturn fmt.Errorf(\"failed to reconnect to job manager: %s\", rtnData.Error)\n\t}\n\n\tlog.Printf(\"[job:%s] RemoteReconnectToJobManagerCommand succeeded, waiting for route\", jobId)\n\n\trouteId := wshutil.MakeJobRouteId(jobId)\n\twaitCtx, cancelFn := context.WithTimeout(ctx, 2*time.Second)\n\tdefer cancelFn()\n\terr = wshutil.DefaultRouter.WaitForRegister(waitCtx, routeId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"route did not establish after successful reconnection: %w\", err)\n\t}\n\tSetJobConnStatus(jobId, JobConnStatus_Connected)\n\tsendBlockJobStatusEventByJob(ctx, job)\n\n\ttelemetry.GoRecordTEventWrap(&telemetrydata.TEvent{\n\t\tEvent: \"job:reconnect\",\n\t\tProps: telemetrydata.TEventProps{\n\t\t\tJobKind: job.JobKind,\n\t\t},\n\t})\n\n\tlog.Printf(\"[job:%s] route established, restarting streaming\", jobId)\n\treturn restartStreaming(ctx, jobId, true, rtOpts)\n}\n\nfunc ReconnectJobsForConn(ctx context.Context, connName string) error {\n\tisConnected, err := conncontroller.IsConnected(connName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error checking connection status: %w\", err)\n\t}\n\tif !isConnected {\n\t\treturn fmt.Errorf(\"connection %q is not connected\", connName)\n\t}\n\n\tallJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get jobs: %w\", err)\n\t}\n\n\tvar jobsToReconnect []*waveobj.Job\n\tfor _, job := range allJobs {\n\t\tif job.Connection == connName && isJobManagerRunning(job) {\n\t\t\tjobsToReconnect = append(jobsToReconnect, job)\n\t\t}\n\t}\n\n\tlog.Printf(\"[conn:%s] found %d jobs to reconnect\", connName, len(jobsToReconnect))\n\n\tfor _, job := range jobsToReconnect {\n\t\terr = ReconnectJob(ctx, job.OID, nil)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[job:%s] error reconnecting: %v\", job.OID, err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc restartStreaming(ctx context.Context, jobId string, knownConnected bool, rtOpts *waveobj.RuntimeOpts) error {\n\tjob, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get job: %w\", err)\n\t}\n\n\ttermSize := job.CmdTermSize\n\tif rtOpts != nil && rtOpts.TermSize.Rows > 0 && rtOpts.TermSize.Cols > 0 {\n\t\ttermSize = rtOpts.TermSize\n\t\terr = wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) {\n\t\t\tjob.CmdTermSize = termSize\n\t\t})\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[job:%s] warning: failed to update termsize in DB: %v\", jobId, err)\n\t\t}\n\t}\n\n\tif !knownConnected {\n\t\tisConnected, err := conncontroller.IsConnected(job.Connection)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error checking connection status: %w\", err)\n\t\t}\n\t\tif !isConnected {\n\t\t\treturn fmt.Errorf(\"connection %q is not connected\", job.Connection)\n\t\t}\n\n\t\tjobConnStatus := GetJobConnStatus(jobId)\n\t\tif jobConnStatus != JobConnStatus_Connected {\n\t\t\treturn fmt.Errorf(\"job manager is not connected (status: %s)\", jobConnStatus)\n\t\t}\n\t}\n\n\tvar currentSeq int64 = 0\n\tvar totalGap int64 = 0\n\twaveFile, err := filestore.WFS.Stat(ctx, jobId, JobOutputFileName)\n\tif err == nil {\n\t\tcurrentSeq = waveFile.Size\n\t\ttotalGap = getMetaInt64(waveFile.Meta, MetaKey_TotalGap)\n\t\tcurrentSeq += totalGap\n\t}\n\n\tbareRpc := wshclient.GetBareRpcClient()\n\tbroker := bareRpc.StreamBroker\n\treaderRouteId := wshclient.GetBareRpcClientRouteId()\n\twriterRouteId := wshutil.MakeJobRouteId(jobId)\n\treader, streamMeta := broker.CreateStreamReaderWithSeq(readerRouteId, writerRouteId, DefaultStreamRwnd, currentSeq)\n\tjobStreamIds.Set(jobId, streamMeta.Id)\n\n\tprepareData := wshrpc.CommandJobPrepareConnectData{\n\t\tStreamMeta: *streamMeta,\n\t\tSeq:        currentSeq,\n\t\tTermSize:   termSize,\n\t}\n\n\trpcOpts := &wshrpc.RpcOpts{\n\t\tRoute:   wshutil.MakeJobRouteId(jobId),\n\t\tTimeout: 5000,\n\t}\n\n\tlog.Printf(\"[job:%s] sending JobPrepareConnectCommand with seq=%d (fileSize=%d, totalGap=%d)\", jobId, currentSeq, waveFile.Size, totalGap)\n\trtnData, err := wshclient.JobPrepareConnectCommand(bareRpc, prepareData, rpcOpts)\n\tif err != nil {\n\t\treader.Close()\n\t\treturn fmt.Errorf(\"failed to prepare connect: %w\", err)\n\t}\n\n\tif rtnData.HasExited {\n\t\texitCodeStr := \"nil\"\n\t\tif rtnData.ExitCode != nil {\n\t\t\texitCodeStr = fmt.Sprintf(\"%d\", *rtnData.ExitCode)\n\t\t}\n\t\tlog.Printf(\"[job:%s] job has already exited: code=%s signal=%q err=%q\", jobId, exitCodeStr, rtnData.ExitSignal, rtnData.ExitErr)\n\t\texitData := wshrpc.CommandJobCmdExitedData{\n\t\t\tExitCode:   rtnData.ExitCode,\n\t\t\tExitSignal: rtnData.ExitSignal,\n\t\t\tExitErr:    rtnData.ExitErr,\n\t\t\tExitTs:     time.Now().UnixMilli(),\n\t\t}\n\t\tHandleCmdJobExited(ctx, jobId, exitData)\n\t}\n\n\tif rtnData.StreamDone {\n\t\tlog.Printf(\"[job:%s] stream is already done: error=%q\", jobId, rtnData.StreamError)\n\t\tupdateErr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) {\n\t\t\tif !job.StreamDone {\n\t\t\t\tjob.StreamDone = true\n\t\t\t\tif rtnData.StreamError != \"\" {\n\t\t\t\t\tjob.StreamError = rtnData.StreamError\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t\tif updateErr != nil {\n\t\t\tlog.Printf(\"[job:%s] error updating job stream status: %v\", jobId, updateErr)\n\t\t}\n\t}\n\n\tif rtnData.StreamDone && rtnData.HasExited {\n\t\treader.Close()\n\t\tlog.Printf(\"[job:%s] both stream done and job exited, calling tryExitJobManager\", jobId)\n\t\ttryTerminateJobManager(ctx, jobId)\n\t\treturn nil\n\t}\n\n\tif rtnData.StreamDone {\n\t\treader.Close()\n\t\tlog.Printf(\"[job:%s] stream already done, no need to restart streaming\", jobId)\n\t\treturn nil\n\t}\n\n\tif rtnData.Seq > currentSeq {\n\t\tgap := rtnData.Seq - currentSeq\n\t\ttotalGap += gap\n\t\tlog.Printf(\"[job:%s] detected gap: our seq=%d, server seq=%d, gap=%d, new totalGap=%d\", jobId, currentSeq, rtnData.Seq, gap, totalGap)\n\n\t\tmetaErr := filestore.WFS.WriteMeta(ctx, jobId, JobOutputFileName, wshrpc.FileMeta{\n\t\t\tMetaKey_TotalGap: totalGap,\n\t\t}, true)\n\t\tif metaErr != nil {\n\t\t\tlog.Printf(\"[job:%s] error updating totalgap metadata: %v\", jobId, metaErr)\n\t\t}\n\n\t\treader.UpdateNextSeq(rtnData.Seq)\n\t}\n\n\tlog.Printf(\"[job:%s] sending JobStartStreamCommand\", jobId)\n\tstartStreamData := wshrpc.CommandJobStartStreamData{}\n\terr = wshclient.JobStartStreamCommand(bareRpc, startStreamData, rpcOpts)\n\tif err != nil {\n\t\treader.Close()\n\t\treturn fmt.Errorf(\"failed to start stream: %w\", err)\n\t}\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"jobcontroller:RestartStreaming:runOutputLoop\", recover())\n\t\t}()\n\t\trunOutputLoop(context.Background(), jobId, streamMeta.Id, reader)\n\t}()\n\n\tlog.Printf(\"[job:%s] streaming restarted successfully\", jobId)\n\treturn nil\n}\n\n// this function must be kept up to date with getBlockTermDurableAtom in frontend/app/store/global.ts\nfunc IsBlockTermDurable(block *waveobj.Block) bool {\n\tif block == nil {\n\t\treturn false\n\t}\n\n\t// Check if view is \"term\", and controller is \"shell\"\n\tif block.Meta.GetString(waveobj.MetaKey_View, \"\") != \"term\" || block.Meta.GetString(waveobj.MetaKey_Controller, \"\") != \"shell\" {\n\t\treturn false\n\t}\n\n\t// 1. Check if block has a JobId\n\tif block.JobId != \"\" {\n\t\treturn true\n\t}\n\n\t// 2. Check if connection is local or WSL (not durable)\n\tconnName := block.Meta.GetString(waveobj.MetaKey_Connection, \"\")\n\tif conncontroller.IsLocalConnName(connName) || conncontroller.IsWslConnName(connName) {\n\t\treturn false\n\t}\n\n\t// 3. Check config hierarchy: blockmeta → connection → global (default true)\n\t// Check block meta first\n\tif val, exists := block.Meta[waveobj.MetaKey_TermDurable]; exists {\n\t\tif boolVal, ok := val.(bool); ok {\n\t\t\treturn boolVal\n\t\t}\n\t}\n\t// Check connection config\n\tfullConfig := wconfig.GetWatcher().GetFullConfig()\n\tif connName != \"\" {\n\t\tif connConfig, exists := fullConfig.Connections[connName]; exists {\n\t\t\tif connConfig.TermDurable != nil {\n\t\t\t\treturn *connConfig.TermDurable\n\t\t\t}\n\t\t}\n\t}\n\t// Check global settings\n\tif fullConfig.Settings.TermDurable != nil {\n\t\treturn *fullConfig.Settings.TermDurable\n\t}\n\t// Default to true for non-local connections\n\treturn true\n}\n\nfunc IsBlockIdTermDurable(blockId string) bool {\n\tblock, err := wstore.DBGet[*waveobj.Block](context.Background(), blockId)\n\tif err != nil || block == nil {\n\t\treturn false\n\t}\n\treturn IsBlockTermDurable(block)\n}\n\nfunc DeleteJob(ctx context.Context, jobId string) error {\n\tSetJobConnStatus(jobId, JobConnStatus_Disconnected)\n\tjobTerminationMessageWritten.Delete(jobId)\n\terr := filestore.WFS.DeleteZone(ctx, jobId)\n\tif err != nil {\n\t\tlog.Printf(\"[job:%s] warning: error deleting WaveFS zone: %v\", jobId, err)\n\t}\n\treturn wstore.DBDelete(ctx, waveobj.OType_Job, jobId)\n}\n\nfunc AttachJobToBlock(ctx context.Context, jobId string, blockId string) error {\n\terr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error {\n\t\tvar oldJobId string\n\n\t\terr := wstore.DBUpdateFn(tx.Context(), blockId, func(block *waveobj.Block) {\n\t\t\toldJobId = block.JobId\n\t\t\tblock.JobId = jobId\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to update block: %w\", err)\n\t\t}\n\n\t\tif oldJobId != \"\" && oldJobId != jobId {\n\t\t\terr = wstore.DBUpdateFn(tx.Context(), oldJobId, func(oldJob *waveobj.Job) {\n\t\t\t\tif oldJob.AttachedBlockId == blockId {\n\t\t\t\t\toldJob.AttachedBlockId = \"\"\n\t\t\t\t}\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"[job:%s] warning: could not detach old job: %v\", oldJobId, err)\n\t\t\t}\n\t\t}\n\n\t\terr = wstore.DBUpdateFnErr(tx.Context(), jobId, func(job *waveobj.Job) error {\n\t\t\tif job.AttachedBlockId != \"\" && job.AttachedBlockId != blockId {\n\t\t\t\treturn fmt.Errorf(\"job %s already attached to block %s\", jobId, job.AttachedBlockId)\n\t\t\t}\n\t\t\tjob.AttachedBlockId = blockId\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to update job: %w\", err)\n\t\t}\n\n\t\tlog.Printf(\"[job:%s] attached to block:%s\", jobId, blockId)\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tSendBlockJobStatusEvent(ctx, blockId)\n\twcore.SendWaveObjUpdate(waveobj.MakeORef(waveobj.OType_Block, blockId))\n\treturn nil\n}\n\nfunc DetachJobFromBlock(ctx context.Context, jobId string, updateBlock bool) error {\n\tvar blockId string\n\tvar blockUpdated bool\n\terr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error {\n\t\tjob, err := wstore.DBMustGet[*waveobj.Job](tx.Context(), jobId)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get job: %w\", err)\n\t\t}\n\n\t\tblockId = job.AttachedBlockId\n\t\tif blockId == \"\" {\n\t\t\treturn nil\n\t\t}\n\n\t\tif updateBlock {\n\t\t\tblock, err := wstore.DBGet[*waveobj.Block](tx.Context(), blockId)\n\t\t\tif err == nil && block != nil {\n\t\t\t\terr = wstore.DBUpdateFn(tx.Context(), blockId, func(block *waveobj.Block) {\n\t\t\t\t\tblock.JobId = \"\"\n\t\t\t\t})\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Printf(\"[job:%s] warning: failed to clear JobId from block:%s: %v\", jobId, blockId, err)\n\t\t\t\t} else {\n\t\t\t\t\tblockUpdated = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\terr = wstore.DBUpdateFn(tx.Context(), jobId, func(job *waveobj.Job) {\n\t\t\tjob.AttachedBlockId = \"\"\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to update job: %w\", err)\n\t\t}\n\n\t\tlog.Printf(\"[job:%s] detached from block:%s\", jobId, blockId)\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif blockId != \"\" {\n\t\tSendBlockJobStatusEvent(ctx, blockId)\n\t\tif blockUpdated {\n\t\t\twcore.SendWaveObjUpdate(waveobj.MakeORef(waveobj.OType_Block, blockId))\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc SendInput(ctx context.Context, data wshrpc.CommandJobInputData) error {\n\tjobId := data.JobId\n\n\tif data.TermSize != nil {\n\t\terr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) {\n\t\t\tjob.CmdTermSize = *data.TermSize\n\t\t})\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[job:%s] warning: failed to update termsize in DB: %v\", jobId, err)\n\t\t}\n\t}\n\n\t_, err := CheckJobConnected(ctx, jobId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trpcOpts := &wshrpc.RpcOpts{\n\t\tRoute:      wshutil.MakeJobRouteId(jobId),\n\t\tTimeout:    5000,\n\t\tNoResponse: false,\n\t}\n\n\tbareRpc := wshclient.GetBareRpcClient()\n\terr = wshclient.JobInputCommand(bareRpc, data, rpcOpts)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to send input to job: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc resetTerminalState(logCtx context.Context, blockId string) {\n\tif blockId == \"\" {\n\t\treturn\n\t}\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tif isFileEmpty(ctx, blockId) {\n\t\treturn\n\t}\n\tblocklogger.Debugf(logCtx, \"[conndebug] resetTerminalState: resetting terminal state for block\\n\")\n\tresetSeq := shellutil.GetTerminalResetSeq()\n\tresetSeq += \"\\r\\n\"\n\terr := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, blockId), JobOutputFileName, []byte(resetSeq))\n\tif err != nil {\n\t\tlog.Printf(\"error appending terminal reset to block file: %v\\n\", err)\n\t}\n}\n\nfunc isFileEmpty(ctx context.Context, blockId string) bool {\n\tif blockId == \"\" {\n\t\treturn true\n\t}\n\tfile, statErr := filestore.WFS.Stat(ctx, blockId, JobOutputFileName)\n\tif statErr == fs.ErrNotExist {\n\t\treturn true\n\t}\n\tif statErr != nil {\n\t\tlog.Printf(\"error statting block output file: %v\\n\", statErr)\n\t\treturn true\n\t}\n\treturn file.Size == 0\n}\n\nfunc writeSessionSeparatorToTerminal(blockId string, termWidth int) {\n\tif blockId == \"\" {\n\t\treturn\n\t}\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tif isFileEmpty(ctx, blockId) {\n\t\treturn\n\t}\n\tseparatorLine := \"\\r\\n\"\n\terr := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, blockId), JobOutputFileName, []byte(separatorLine))\n\tif err != nil {\n\t\tlog.Printf(\"error writing session separator to terminal (blockid=%s): %v\", blockId, err)\n\t}\n}\n\n// msg should not have a terminating newline\nfunc writeMutedMessageToTerminal(blockId string, msg string) {\n\tif blockId == \"\" {\n\t\treturn\n\t}\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tfullMsg := \"\\x1b[90m\" + msg + \"\\x1b[0m\\r\\n\"\n\terr := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, blockId), JobOutputFileName, []byte(fullMsg))\n\tif err != nil {\n\t\tlog.Printf(\"error writing muted message to terminal (blockid=%s): %v\", blockId, err)\n\t}\n}\n\nfunc writeJobTerminationMessage(ctx context.Context, jobId string, job *waveobj.Job, msg string) {\n\tif job == nil {\n\t\treturn\n\t}\n\tshouldWrite := jobTerminationMessageWritten.TestAndSet(jobId, true, func(val bool, exists bool) bool {\n\t\treturn !exists || !val\n\t})\n\tif shouldWrite {\n\t\tresetTerminalState(ctx, job.AttachedBlockId)\n\t\twriteMutedMessageToTerminal(job.AttachedBlockId, msg)\n\t}\n}\n"
  },
  {
    "path": "pkg/jobmanager/cirbuf.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage jobmanager\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n)\n\ntype CirBuf struct {\n\tlock       sync.Mutex\n\twaiterChan chan chan struct{}\n\tbuf        []byte\n\treadPos    int\n\twritePos   int\n\tcount      int\n\ttotalSize  int64\n\tsyncMode   bool\n\twindowSize int\n}\n\nfunc MakeCirBuf(maxSize int, initSyncMode bool) *CirBuf {\n\tcb := &CirBuf{\n\t\tbuf:        make([]byte, maxSize),\n\t\tsyncMode:   initSyncMode,\n\t\twaiterChan: make(chan chan struct{}, 1),\n\t\twindowSize: maxSize,\n\t}\n\treturn cb\n}\n\n// SetEffectiveWindow changes the sync mode and effective window size for flow control.\n// The windowSize is capped at the buffer size.\n// When window shrinks: sync mode blocks new writes, async mode truncates old data to enforce limit.\n// When window increases: blocked writers are woken up if space becomes available.\nfunc (cb *CirBuf) SetEffectiveWindow(syncMode bool, windowSize int) {\n\tcb.lock.Lock()\n\tdefer cb.lock.Unlock()\n\n\tmaxSize := len(cb.buf)\n\tif windowSize > maxSize {\n\t\twindowSize = maxSize\n\t}\n\n\toldSyncMode := cb.syncMode\n\toldWindowSize := cb.windowSize\n\tcb.windowSize = windowSize\n\tcb.syncMode = syncMode\n\n\t// In async mode, enforce window size by truncating buffer if needed\n\tif !syncMode && cb.count > windowSize {\n\t\texcess := cb.count - windowSize\n\t\tcb.readPos = (cb.readPos + excess) % maxSize\n\t\tcb.count = windowSize\n\t}\n\n\t// Only sync mode blocks writers, so only wake if we were in sync mode.\n\t// Wake when window grows (more space available) or switching to async (no longer blocking).\n\tif oldSyncMode && (windowSize > oldWindowSize || !syncMode) {\n\t\tcb.tryWakeWriter()\n\t}\n}\n\n// WriteAvailable attempts to write as much data as possible without blocking.\n// Returns the number of bytes written and a channel to wait on if buffer is full (nil if not blocking).\n// In sync mode when buffer is full, returns 0 written and a channel that will be closed when space is available.\n// The caller should wait on the channel and retry the write.\n// NOTE: Only one concurrent blocked write is allowed. Multiple blocked writes will panic.\nfunc (cb *CirBuf) WriteAvailable(data []byte) (int, <-chan struct{}) {\n\tcb.lock.Lock()\n\tdefer cb.lock.Unlock()\n\n\tsize := len(cb.buf)\n\twritten := 0\n\n\tfor i := 0; i < len(data); i++ {\n\t\tif cb.syncMode && cb.count >= cb.windowSize {\n\t\t\tif written > 0 {\n\t\t\t\treturn written, nil\n\t\t\t}\n\t\t\tspaceAvailable := make(chan struct{})\n\t\t\tif !tryWriteCh(cb.waiterChan, spaceAvailable) {\n\t\t\t\tpanic(\"CirBuf: multiple concurrent blocked writes not allowed\")\n\t\t\t}\n\t\t\treturn 0, spaceAvailable\n\t\t}\n\n\t\tcb.buf[cb.writePos] = data[i]\n\t\tcb.writePos = (cb.writePos + 1) % size\n\t\tif cb.count < cb.windowSize {\n\t\t\tcb.count++\n\t\t} else {\n\t\t\tcb.readPos = (cb.readPos + 1) % size\n\t\t}\n\t\tcb.totalSize++\n\t\twritten++\n\t}\n\n\treturn written, nil\n}\n\nfunc (cb *CirBuf) PeekData(data []byte) int {\n\treturn cb.PeekDataAt(0, data)\n}\n\nfunc (cb *CirBuf) PeekDataAt(offset int, data []byte) int {\n\tcb.lock.Lock()\n\tdefer cb.lock.Unlock()\n\n\tif cb.count == 0 || offset >= cb.count {\n\t\treturn 0\n\t}\n\n\tsize := len(cb.buf)\n\tpos := (cb.readPos + offset) % size\n\tmaxRead := cb.count - offset\n\tread := 0\n\n\tfor i := 0; i < len(data) && i < maxRead; i++ {\n\t\tdata[i] = cb.buf[pos]\n\t\tpos = (pos + 1) % size\n\t\tread++\n\t}\n\n\treturn read\n}\n\nfunc (cb *CirBuf) Consume(numBytes int) error {\n\tcb.lock.Lock()\n\tdefer cb.lock.Unlock()\n\n\tif numBytes > cb.count {\n\t\treturn fmt.Errorf(\"cannot consume %d bytes, only %d available\", numBytes, cb.count)\n\t}\n\n\tsize := len(cb.buf)\n\tcb.readPos = (cb.readPos + numBytes) % size\n\tcb.count -= numBytes\n\n\tcb.tryWakeWriter()\n\n\treturn nil\n}\n\nfunc (cb *CirBuf) HeadPos() int64 {\n\tcb.lock.Lock()\n\tdefer cb.lock.Unlock()\n\treturn cb.totalSize - int64(cb.count)\n}\n\nfunc (cb *CirBuf) Size() int {\n\tcb.lock.Lock()\n\tdefer cb.lock.Unlock()\n\treturn cb.count\n}\n\nfunc (cb *CirBuf) TotalSize() int64 {\n\tcb.lock.Lock()\n\tdefer cb.lock.Unlock()\n\treturn cb.totalSize\n}\n\nfunc tryWriteCh[T any](ch chan<- T, val T) bool {\n\tselect {\n\tcase ch <- val:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc tryReadCh[T any](ch <-chan T) (*T, bool) {\n\tselect {\n\tcase rtn := <-ch:\n\t\treturn &rtn, true\n\tdefault:\n\t\treturn nil, false\n\t}\n}\n\nfunc (cb *CirBuf) tryWakeWriter() {\n\tif waiterCh, ok := tryReadCh(cb.waiterChan); ok {\n\t\tclose(*waiterCh)\n\t}\n}\n"
  },
  {
    "path": "pkg/jobmanager/jobcmd.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage jobmanager\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"log\"\n\t\"os/exec\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/creack/pty\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/unixutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype CmdDef struct {\n\tCmd      string\n\tArgs     []string\n\tEnv      map[string]string\n\tTermSize waveobj.TermSize\n}\n\ntype JobCmd struct {\n\tjobId         string\n\tlock          sync.Mutex\n\tcmd           *exec.Cmd\n\tcmdPty        pty.Pty\n\tptsName       string\n\ttermSize      waveobj.TermSize\n\tcleanedUp     bool\n\tptyClosed     bool\n\tprocessExited bool\n\texitCode      *int\n\texitSignal    string\n\texitErr       error\n\texitTs        int64\n}\n\nfunc MakeJobCmd(jobId string, cmdDef CmdDef) (*JobCmd, error) {\n\tjm := &JobCmd{\n\t\tjobId: jobId,\n\t}\n\tif cmdDef.TermSize.Rows == 0 || cmdDef.TermSize.Cols == 0 {\n\t\tcmdDef.TermSize.Rows = 25\n\t\tcmdDef.TermSize.Cols = 80\n\t}\n\tif cmdDef.TermSize.Rows <= 0 || cmdDef.TermSize.Cols <= 0 {\n\t\treturn nil, fmt.Errorf(\"invalid term size: %v\", cmdDef.TermSize)\n\t}\n\tecmd := exec.Command(cmdDef.Cmd, cmdDef.Args...)\n\tif len(cmdDef.Env) > 0 {\n\t\tecmd.Env = make([]string, 0, len(cmdDef.Env))\n\t\tfor key, val := range cmdDef.Env {\n\t\t\tecmd.Env = append(ecmd.Env, fmt.Sprintf(\"%s=%s\", key, val))\n\t\t}\n\t}\n\tcmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(cmdDef.TermSize.Rows), Cols: uint16(cmdDef.TermSize.Cols)})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to start command: %w\", err)\n\t}\n\tunixutil.SetCloseOnExec(int(cmdPty.Fd()))\n\tjm.cmd = ecmd\n\tjm.cmdPty = cmdPty\n\tjm.ptsName = jm.cmdPty.Name()\n\tjm.termSize = cmdDef.TermSize\n\tgo jm.waitForProcess()\n\treturn jm, nil\n}\n\nfunc (jm *JobCmd) waitForProcess() {\n\tif jm.cmd == nil || jm.cmd.Process == nil {\n\t\treturn\n\t}\n\terr := jm.cmd.Wait()\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\n\tjm.processExited = true\n\tjm.exitTs = time.Now().UnixMilli()\n\tjm.exitErr = err\n\tif err != nil {\n\t\tif exitErr, ok := err.(*exec.ExitError); ok {\n\t\t\tif status, ok := exitErr.Sys().(syscall.WaitStatus); ok {\n\t\t\t\tif status.Signaled() {\n\t\t\t\t\tjm.exitSignal = unixutil.GetSignalName(status.Signal())\n\t\t\t\t} else if status.Exited() {\n\t\t\t\t\tcode := status.ExitStatus()\n\t\t\t\t\tjm.exitCode = &code\n\t\t\t\t} else {\n\t\t\t\t\tlog.Printf(\"Invalid WaitStatus, not exited or signaled: %v\", status)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} else {\n\t\tcode := 0\n\t\tjm.exitCode = &code\n\t}\n\texitCodeStr := \"nil\"\n\tif jm.exitCode != nil {\n\t\texitCodeStr = fmt.Sprintf(\"%d\", *jm.exitCode)\n\t}\n\tlog.Printf(\"process exited: exitcode=%s, signal=%s, err=%v\\n\", exitCodeStr, jm.exitSignal, jm.exitErr)\n\n\tgo WshCmdJobManager.sendJobExited()\n}\n\nfunc (jm *JobCmd) GetCmd() (*exec.Cmd, pty.Pty) {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\treturn jm.cmd, jm.cmdPty\n}\n\nfunc (jm *JobCmd) GetPGID() (int, error) {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\tif jm.cmd == nil || jm.cmd.Process == nil {\n\t\treturn 0, fmt.Errorf(\"no active process\")\n\t}\n\tif jm.processExited {\n\t\treturn 0, fmt.Errorf(\"process already exited\")\n\t}\n\tpgid, err := unixutil.GetProcessGroupId(jm.cmd.Process.Pid)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to get pgid: %w\", err)\n\t}\n\tif pgid <= 0 {\n\t\treturn 0, fmt.Errorf(\"invalid pgid returned: %d\", pgid)\n\t}\n\treturn pgid, nil\n}\n\nfunc (jm *JobCmd) GetExitInfo() (bool, *wshrpc.CommandJobCmdExitedData) {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\tif !jm.processExited {\n\t\treturn false, nil\n\t}\n\texitData := &wshrpc.CommandJobCmdExitedData{\n\t\tJobId:      WshCmdJobManager.JobId,\n\t\tExitCode:   jm.exitCode,\n\t\tExitSignal: jm.exitSignal,\n\t\tExitTs:     jm.exitTs,\n\t}\n\tif jm.exitErr != nil {\n\t\texitData.ExitErr = jm.exitErr.Error()\n\t}\n\treturn true, exitData\n}\n\nfunc (jm *JobCmd) setTermSize_withlock(termSize waveobj.TermSize) error {\n\tif jm.cmdPty == nil {\n\t\treturn fmt.Errorf(\"no active pty\")\n\t}\n\tif jm.termSize.Rows == termSize.Rows && jm.termSize.Cols == termSize.Cols {\n\t\treturn nil\n\t}\n\terr := pty.Setsize(jm.cmdPty, &pty.Winsize{\n\t\tRows: uint16(termSize.Rows),\n\t\tCols: uint16(termSize.Cols),\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting terminal size: %w\", err)\n\t}\n\tjm.termSize = termSize\n\treturn nil\n}\n\nfunc (jm *JobCmd) SetTermSize(termSize waveobj.TermSize) error {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\treturn jm.setTermSize_withlock(termSize)\n}\n\n// TODO set up a single input handler loop + queue so we dont need to hold the lock but still get synchronized in-order execution\nfunc (jm *JobCmd) HandleInput(data wshrpc.CommandJobInputData) error {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\n\tif jm.cmd == nil || jm.cmdPty == nil {\n\t\treturn fmt.Errorf(\"no active process\")\n\t}\n\n\tif len(data.InputData64) > 0 {\n\t\tinputBuf := make([]byte, base64.StdEncoding.DecodedLen(len(data.InputData64)))\n\t\tnw, err := base64.StdEncoding.Decode(inputBuf, []byte(data.InputData64))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error decoding input data: %w\", err)\n\t\t}\n\t\t_, err = jm.cmdPty.Write(inputBuf[:nw])\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error writing to pty: %w\", err)\n\t\t}\n\t}\n\n\tif data.SigName != \"\" {\n\t\tsig := unixutil.ParseSignal(data.SigName)\n\t\tif sig != nil && jm.cmd.Process != nil {\n\t\t\terr := jm.cmd.Process.Signal(sig)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error sending signal: %w\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tif data.TermSize != nil {\n\t\terr := jm.setTermSize_withlock(*data.TermSize)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (jm *JobCmd) TerminateByClosingPtyMaster() {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\tif jm.ptyClosed {\n\t\treturn\n\t}\n\tif jm.cmdPty != nil {\n\t\tjm.cmdPty.Close()\n\t\tjm.ptyClosed = true\n\t\tlog.Printf(\"pty closed for job %s\\n\", jm.jobId)\n\t}\n}\n"
  },
  {
    "path": "pkg/jobmanager/jobmanager.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage jobmanager\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/shirou/gopsutil/v4/process\"\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/utilds\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavejwt\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nconst JobAccessTokenLabel = \"Wave-JobAccessToken\"\nconst JobManagerStartLabel = \"Wave-JobManagerStart\"\nconst JobInputQueueTimeout = 100 * time.Millisecond\nconst JobInputQueueSize = 1000\n\nvar WshCmdJobManager JobManager\n\ntype JobManager struct {\n\tClientId              string\n\tJobId                 string\n\tCmd                   *JobCmd\n\tJwtPublicKey          []byte\n\tJobAuthToken          string\n\tStreamManager         *StreamManager\n\tInputQueue            *utilds.QuickReorderQueue[wshrpc.CommandJobInputData]\n\tlock                  sync.Mutex\n\tattachedClient        *MainServerConn\n\tconnectedStreamClient *MainServerConn\n\tpendingStreamMeta     *wshrpc.StreamMeta\n}\n\nfunc SetupJobManager(clientId string, jobId string, publicKeyBytes []byte, jobAuthToken string, readyFile *os.File) error {\n\tif runtime.GOOS != \"linux\" && runtime.GOOS != \"darwin\" {\n\t\treturn fmt.Errorf(\"job manager only supported on unix systems, not %s\", runtime.GOOS)\n\t}\n\tWshCmdJobManager.ClientId = clientId\n\tWshCmdJobManager.JobId = jobId\n\tWshCmdJobManager.JwtPublicKey = publicKeyBytes\n\tWshCmdJobManager.JobAuthToken = jobAuthToken\n\tWshCmdJobManager.StreamManager = MakeStreamManager()\n\tWshCmdJobManager.InputQueue = utilds.MakeQuickReorderQueue[wshrpc.CommandJobInputData](JobInputQueueSize, JobInputQueueTimeout)\n\terr := wavejwt.SetPublicKey(publicKeyBytes)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to set public key: %w\", err)\n\t}\n\terr = MakeJobDomainSocket(clientId, jobId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"JobManager:processInputQueue\", recover())\n\t\t}()\n\t\tWshCmdJobManager.processInputQueue()\n\t}()\n\n\tfmt.Fprintf(readyFile, JobManagerStartLabel+\"\\n\")\n\treadyFile.Close()\n\n\terr = daemonize(clientId, jobId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to daemonize: %w\", err)\n\t}\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"JobManager:keepalive\", recover())\n\t\t}()\n\t\tticker := time.NewTicker(1 * time.Hour)\n\t\tdefer ticker.Stop()\n\t\tfor range ticker.C {\n\t\t\tlog.Printf(\"keepalive: job manager active\\n\")\n\t\t}\n\t}()\n\n\treturn nil\n}\n\nfunc (jm *JobManager) processInputQueue() {\n\tfor data := range jm.InputQueue.C() {\n\t\tjm.lock.Lock()\n\t\tcmd := jm.Cmd\n\t\tjm.lock.Unlock()\n\n\t\tif cmd == nil {\n\t\t\tlog.Printf(\"processInputQueue: skipping input, job not started\\n\")\n\t\t\tcontinue\n\t\t}\n\n\t\terr := cmd.HandleInput(data)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"processInputQueue: error handling input: %v\\n\", err)\n\t\t}\n\t}\n}\n\nfunc (jm *JobManager) GetCmd() *JobCmd {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\treturn jm.Cmd\n}\n\nfunc (jm *JobManager) sendJobExited() {\n\tjm.lock.Lock()\n\tattachedClient := jm.attachedClient\n\tcmd := jm.Cmd\n\tjm.lock.Unlock()\n\n\tif attachedClient == nil {\n\t\tlog.Printf(\"sendJobExited: no attached client, exit notification not sent\\n\")\n\t\treturn\n\t}\n\tif attachedClient.WshRpc == nil {\n\t\tlog.Printf(\"sendJobExited: no wsh rpc connection, exit notification not sent\\n\")\n\t\treturn\n\t}\n\tif cmd == nil {\n\t\tlog.Printf(\"sendJobExited: no cmd, exit notification not sent\\n\")\n\t\treturn\n\t}\n\n\texited, exitData := cmd.GetExitInfo()\n\tif !exited || exitData == nil {\n\t\tlog.Printf(\"sendJobExited: process not exited yet\\n\")\n\t\treturn\n\t}\n\n\texitCodeStr := \"nil\"\n\tif exitData.ExitCode != nil {\n\t\texitCodeStr = fmt.Sprintf(\"%d\", *exitData.ExitCode)\n\t}\n\tlog.Printf(\"sendJobExited: sending exit notification to main server exitcode=%s signal=%s\\n\", exitCodeStr, exitData.ExitSignal)\n\terr := wshclient.JobCmdExitedCommand(attachedClient.WshRpc, *exitData, nil)\n\tif err != nil {\n\t\tlog.Printf(\"sendJobExited: error sending exit notification: %v\\n\", err)\n\t}\n}\n\nfunc (jm *JobManager) GetJobAuthInfo() (string, string) {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\treturn jm.JobId, jm.JobAuthToken\n}\n\nfunc (jm *JobManager) IsJobStarted() bool {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\treturn jm.Cmd != nil\n}\n\nfunc (jm *JobManager) connectToStreamHelper_withlock(mainServerConn *MainServerConn, streamMeta wshrpc.StreamMeta, seq int64) (int64, error) {\n\trwndSize := int(streamMeta.RWnd)\n\tif rwndSize < 0 {\n\t\treturn 0, fmt.Errorf(\"invalid rwnd size: %d\", rwndSize)\n\t}\n\n\tif jm.connectedStreamClient != nil {\n\t\tlog.Printf(\"connectToStreamHelper: disconnecting existing client\\n\")\n\t\toldStreamId := jm.StreamManager.GetStreamId()\n\t\tjm.StreamManager.ClientDisconnected()\n\t\tif oldStreamId != \"\" {\n\t\t\tmainServerConn.WshRpc.StreamBroker.DetachStreamWriter(oldStreamId)\n\t\t\tlog.Printf(\"connectToStreamHelper: detached old stream id=%s\\n\", oldStreamId)\n\t\t}\n\t\tjm.connectedStreamClient = nil\n\t}\n\tdataSender := &routedDataSender{\n\t\twshRpc: mainServerConn.WshRpc,\n\t\troute:  streamMeta.ReaderRouteId,\n\t}\n\tserverSeq, err := jm.StreamManager.ClientConnected(\n\t\tstreamMeta.Id,\n\t\tdataSender,\n\t\trwndSize,\n\t\tseq,\n\t)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to connect client: %w\", err)\n\t}\n\tjm.connectedStreamClient = mainServerConn\n\treturn serverSeq, nil\n}\n\nfunc (jm *JobManager) disconnectFromStreamHelper(mainServerConn *MainServerConn) {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\tif jm.connectedStreamClient == nil || jm.connectedStreamClient != mainServerConn {\n\t\treturn\n\t}\n\tjm.StreamManager.ClientDisconnected()\n\tjm.connectedStreamClient = nil\n}\n\nfunc (jm *JobManager) SetAttachedClient(msc *MainServerConn) {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\n\tif jm.attachedClient != nil {\n\t\tlog.Printf(\"SetAttachedClient: kicking out existing client\\n\")\n\t\tjm.attachedClient.Close()\n\t}\n\tjm.attachedClient = msc\n}\n\nfunc (jm *JobManager) StartJob(msc *MainServerConn, data wshrpc.CommandStartJobData) (*wshrpc.CommandStartJobRtnData, error) {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\n\tif jm.Cmd != nil {\n\t\tlog.Printf(\"StartJob: job already started\")\n\t\treturn nil, fmt.Errorf(\"job already started\")\n\t}\n\n\tcmdDef := CmdDef{\n\t\tCmd:      data.Cmd,\n\t\tArgs:     data.Args,\n\t\tEnv:      data.Env,\n\t\tTermSize: data.TermSize,\n\t}\n\tlog.Printf(\"StartJob: creating job cmd for jobid=%s\", jm.JobId)\n\tjobCmd, err := MakeJobCmd(jm.JobId, cmdDef)\n\tif err != nil {\n\t\tlog.Printf(\"StartJob: failed to make job cmd: %v\", err)\n\t\treturn nil, fmt.Errorf(\"failed to start job: %w\", err)\n\t}\n\tjm.Cmd = jobCmd\n\tlog.Printf(\"StartJob: job cmd created successfully\")\n\n\tif data.StreamMeta != nil {\n\t\tserverSeq, err := jm.connectToStreamHelper_withlock(msc, *data.StreamMeta, 0)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to connect stream: %w\", err)\n\t\t}\n\t\terr = msc.WshRpc.StreamBroker.AttachStreamWriter(data.StreamMeta, jm.StreamManager)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to attach stream writer: %w\", err)\n\t\t}\n\t\tlog.Printf(\"StartJob: connected stream streamid=%s serverSeq=%d\\n\", data.StreamMeta.Id, serverSeq)\n\t}\n\n\tcmd, cmdPty := jobCmd.GetCmd()\n\tif cmdPty != nil {\n\t\tlog.Printf(\"StartJob: attaching pty reader to stream manager\")\n\t\terr = jm.StreamManager.AttachReader(cmdPty)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"StartJob: failed to attach reader: %v\", err)\n\t\t\treturn nil, fmt.Errorf(\"failed to attach reader to stream manager: %w\", err)\n\t\t}\n\t\tlog.Printf(\"StartJob: pty reader attached successfully\")\n\t} else {\n\t\tlog.Printf(\"StartJob: no pty to attach\")\n\t}\n\n\tif cmd == nil || cmd.Process == nil {\n\t\tlog.Printf(\"StartJob: cmd or process is nil\")\n\t\treturn nil, fmt.Errorf(\"cmd or process is nil\")\n\t}\n\tcmdPid := cmd.Process.Pid\n\tcmdProc, err := process.NewProcess(int32(cmdPid))\n\tif err != nil {\n\t\tlog.Printf(\"StartJob: failed to get cmd process: %v\", err)\n\t\treturn nil, fmt.Errorf(\"failed to get cmd process: %w\", err)\n\t}\n\tcmdStartTs, err := cmdProc.CreateTime()\n\tif err != nil {\n\t\tlog.Printf(\"StartJob: failed to get cmd start time: %v\", err)\n\t\treturn nil, fmt.Errorf(\"failed to get cmd start time: %w\", err)\n\t}\n\n\tjobManagerPid := os.Getpid()\n\tjobManagerProc, err := process.NewProcess(int32(jobManagerPid))\n\tif err != nil {\n\t\tlog.Printf(\"StartJob: failed to get job manager process: %v\", err)\n\t\treturn nil, fmt.Errorf(\"failed to get job manager process: %w\", err)\n\t}\n\tjobManagerStartTs, err := jobManagerProc.CreateTime()\n\tif err != nil {\n\t\tlog.Printf(\"StartJob: failed to get job manager start time: %v\", err)\n\t\treturn nil, fmt.Errorf(\"failed to get job manager start time: %w\", err)\n\t}\n\n\tlog.Printf(\"StartJob: job started successfully cmdPid=%d cmdStartTs=%d jobManagerPid=%d jobManagerStartTs=%d\", cmdPid, cmdStartTs, jobManagerPid, jobManagerStartTs)\n\treturn &wshrpc.CommandStartJobRtnData{\n\t\tCmdPid:            cmdPid,\n\t\tCmdStartTs:        cmdStartTs,\n\t\tJobManagerPid:     jobManagerPid,\n\t\tJobManagerStartTs: jobManagerStartTs,\n\t}, nil\n}\n\nfunc (jm *JobManager) PrepareConnect(msc *MainServerConn, data wshrpc.CommandJobPrepareConnectData) (*wshrpc.CommandJobConnectRtnData, error) {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\n\tif jm.Cmd == nil {\n\t\treturn nil, fmt.Errorf(\"job not started\")\n\t}\n\n\terr := jm.Cmd.SetTermSize(data.TermSize)\n\tif err != nil {\n\t\tlog.Printf(\"PrepareConnect: failed to set term size: %v\\n\", err)\n\t}\n\n\trtnData := &wshrpc.CommandJobConnectRtnData{}\n\tstreamDone, streamError := jm.StreamManager.GetStreamDoneInfo()\n\n\tif streamDone {\n\t\tlog.Printf(\"PrepareConnect: stream already done, skipping connection streamError=%q\\n\", streamError)\n\t\trtnData.Seq = data.Seq\n\t\trtnData.StreamDone = true\n\t\trtnData.StreamError = streamError\n\t} else {\n\t\tcorkedStreamMeta := data.StreamMeta\n\t\tcorkedStreamMeta.RWnd = 0\n\t\tserverSeq, err := jm.connectToStreamHelper_withlock(msc, corkedStreamMeta, data.Seq)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tjm.pendingStreamMeta = &data.StreamMeta\n\t\trtnData.Seq = serverSeq\n\t\trtnData.StreamDone = false\n\t}\n\n\thasExited, exitData := jm.Cmd.GetExitInfo()\n\tif hasExited && exitData != nil {\n\t\trtnData.HasExited = true\n\t\trtnData.ExitCode = exitData.ExitCode\n\t\trtnData.ExitSignal = exitData.ExitSignal\n\t\trtnData.ExitErr = exitData.ExitErr\n\t}\n\n\tlog.Printf(\"PrepareConnect: streamid=%s clientSeq=%d serverSeq=%d streamDone=%v streamError=%q hasExited=%v\\n\", data.StreamMeta.Id, data.Seq, rtnData.Seq, rtnData.StreamDone, rtnData.StreamError, hasExited)\n\treturn rtnData, nil\n}\n\nfunc (jm *JobManager) StartStream(msc *MainServerConn) error {\n\tjm.lock.Lock()\n\tdefer jm.lock.Unlock()\n\n\tif jm.Cmd == nil {\n\t\treturn fmt.Errorf(\"job not started\")\n\t}\n\tif jm.pendingStreamMeta == nil {\n\t\treturn fmt.Errorf(\"no pending stream (call PrepareConnect first)\")\n\t}\n\n\terr := msc.WshRpc.StreamBroker.AttachStreamWriter(jm.pendingStreamMeta, jm.StreamManager)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to attach stream writer: %w\", err)\n\t}\n\n\terr = jm.StreamManager.SetRwndSize(int(jm.pendingStreamMeta.RWnd))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to set rwnd size: %w\", err)\n\t}\n\n\tlog.Printf(\"StartStream: streamid=%s rwnd=%d streaming started\\n\", jm.pendingStreamMeta.Id, jm.pendingStreamMeta.RWnd)\n\tjm.pendingStreamMeta = nil\n\treturn nil\n}\n\nfunc MakeJobDomainSocket(clientId string, jobId string) error {\n\tsocketDir := filepath.Join(\"/tmp\", fmt.Sprintf(\"waveterm-%d\", os.Getuid()))\n\terr := os.MkdirAll(socketDir, 0700)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create socket directory: %w\", err)\n\t}\n\n\tsocketPath := wavebase.GetRemoteJobSocketPath(jobId)\n\n\tos.Remove(socketPath)\n\n\tlistener, err := net.Listen(\"unix\", socketPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to listen on domain socket: %w\", err)\n\t}\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"MakeJobDomainSocket:accept\", recover())\n\t\t\tlistener.Close()\n\t\t\tos.Remove(socketPath)\n\t\t}()\n\t\tfor {\n\t\t\tconn, err := listener.Accept()\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"error accepting connection: %v\\n\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tgo handleJobDomainSocketClient(conn)\n\t\t}\n\t}()\n\n\treturn nil\n}\n\nfunc handleJobDomainSocketClient(conn net.Conn) {\n\tinputCh := make(chan baseds.RpcInputChType, wshutil.DefaultInputChSize)\n\toutputCh := make(chan []byte, wshutil.DefaultOutputChSize)\n\n\tserverImpl := &MainServerConn{\n\t\tConn:    conn,\n\t\tinputCh: inputCh,\n\t}\n\trpcCtx := wshrpc.RpcContext{}\n\twshRpc := wshutil.MakeWshRpcWithChannels(inputCh, outputCh, rpcCtx, serverImpl, \"job-domain\")\n\tserverImpl.WshRpc = wshRpc\n\tdefer WshCmdJobManager.disconnectFromStreamHelper(serverImpl)\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"handleJobDomainSocketClient:AdaptOutputChToStream\", recover())\n\t\t}()\n\t\tdefer serverImpl.Close()\n\t\twriteErr := wshutil.AdaptOutputChToStream(outputCh, conn)\n\t\tif writeErr != nil {\n\t\t\tlog.Printf(\"error writing to domain socket: %v\\n\", writeErr)\n\t\t}\n\t}()\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"handleJobDomainSocketClient:AdaptStreamToMsgCh\", recover())\n\t\t}()\n\t\tdefer serverImpl.Close()\n\t\twshutil.AdaptStreamToMsgCh(conn, inputCh, nil)\n\t}()\n\n\t_ = wshRpc\n}\n"
  },
  {
    "path": "pkg/jobmanager/jobmanager_unix.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n//go:build unix\n\npackage jobmanager\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path/filepath\"\n\t\"syscall\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"golang.org/x/sys/unix\"\n)\n\nfunc daemonize(clientId string, jobId string) error {\n\t_, err := unix.Setsid()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to setsid: %w\", err)\n\t}\n\n\tdevNull, err := os.OpenFile(\"/dev/null\", os.O_RDWR, 0)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to open /dev/null: %w\", err)\n\t}\n\terr = unix.Dup2(int(devNull.Fd()), int(os.Stdin.Fd()))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to dup2 stdin: %w\", err)\n\t}\n\tdevNull.Close()\n\n\tlogPath := wavebase.GetRemoteJobFilePath(jobId, \"log\")\n\tlogDir := filepath.Dir(logPath)\n\terr = os.MkdirAll(logDir, 0700)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create log directory: %w\", err)\n\t}\n\n\tlogFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to open log file: %w\", err)\n\t}\n\terr = unix.Dup2(int(logFile.Fd()), int(os.Stdout.Fd()))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to dup2 stdout: %w\", err)\n\t}\n\terr = unix.Dup2(int(logFile.Fd()), int(os.Stderr.Fd()))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to dup2 stderr: %w\", err)\n\t}\n\n\tlog.SetOutput(logFile)\n\tlog.Printf(\"job manager daemonized, logging to %s\\n\", logPath)\n\tlog.Printf(\"job owner clientid: %s\\n\", clientId)\n\n\tsignal.Ignore(syscall.SIGHUP)\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/jobmanager/jobmanager_windows.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n//go:build windows\n\npackage jobmanager\n\nimport (\n\t\"fmt\"\n)\n\nfunc daemonize(clientId string, jobId string) error {\n\treturn fmt.Errorf(\"daemonize not supported on windows\")\n}\n"
  },
  {
    "path": "pkg/jobmanager/mainserverconn.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage jobmanager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavejwt\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\ntype MainServerConn struct {\n\tPeerAuthenticated atomic.Bool\n\tSelfAuthenticated atomic.Bool\n\tWshRpc            *wshutil.WshRpc\n\tConn              net.Conn\n\tinputCh           chan baseds.RpcInputChType\n\tcloseOnce         sync.Once\n}\n\nfunc (*MainServerConn) WshServerImpl() {}\n\nfunc (msc *MainServerConn) Close() {\n\tmsc.closeOnce.Do(func() {\n\t\tmsc.Conn.Close()\n\t\tclose(msc.inputCh)\n\t})\n}\n\ntype routedDataSender struct {\n\twshRpc *wshutil.WshRpc\n\troute  string\n}\n\nfunc (rds *routedDataSender) SendData(dataPk wshrpc.CommandStreamData) {\n\t// log.Printf(\"SendData: sending seq=%d, len=%d, eof=%t, error=%s, route=%s\",\n\t// \tdataPk.Seq, len(dataPk.Data64), dataPk.Eof, dataPk.Error, rds.route)\n\terr := wshclient.StreamDataCommand(rds.wshRpc, dataPk, &wshrpc.RpcOpts{NoResponse: true, Route: rds.route})\n\tif err != nil {\n\t\tlog.Printf(\"SendData: error sending stream data: %v\\n\", err)\n\t}\n}\n\nfunc (msc *MainServerConn) authenticateSelfToServer(jobAuthToken string) error {\n\tjobId, _ := WshCmdJobManager.GetJobAuthInfo()\n\tauthData := wshrpc.CommandAuthenticateJobManagerData{\n\t\tJobId:        jobId,\n\t\tJobAuthToken: jobAuthToken,\n\t}\n\terr := wshclient.AuthenticateJobManagerCommand(msc.WshRpc, authData, &wshrpc.RpcOpts{Route: wshutil.ControlRoute})\n\tif err != nil {\n\t\tlog.Printf(\"authenticateSelfToServer: failed to authenticate to server: %v\\n\", err)\n\t\treturn fmt.Errorf(\"failed to authenticate to server: %w\", err)\n\t}\n\tmsc.SelfAuthenticated.Store(true)\n\tlog.Printf(\"authenticateSelfToServer: successfully authenticated to server\\n\")\n\treturn nil\n}\n\nfunc (msc *MainServerConn) AuthenticateToJobManagerCommand(ctx context.Context, data wshrpc.CommandAuthenticateToJobData) error {\n\tjobId, jobAuthToken := WshCmdJobManager.GetJobAuthInfo()\n\n\tclaims, err := wavejwt.ValidateAndExtract(data.JobAccessToken)\n\tif err != nil {\n\t\tlog.Printf(\"AuthenticateToJobManager: failed to validate token: %v\\n\", err)\n\t\treturn fmt.Errorf(\"failed to validate token: %w\", err)\n\t}\n\tif !claims.MainServer {\n\t\tlog.Printf(\"AuthenticateToJobManager: MainServer claim not set\\n\")\n\t\treturn fmt.Errorf(\"MainServer claim not set\")\n\t}\n\tif claims.JobId != jobId {\n\t\tlog.Printf(\"AuthenticateToJobManager: JobId mismatch: expected %s, got %s\\n\", jobId, claims.JobId)\n\t\treturn fmt.Errorf(\"JobId mismatch\")\n\t}\n\tmsc.PeerAuthenticated.Store(true)\n\tlog.Printf(\"AuthenticateToJobManager: authentication successful for JobId=%s\\n\", claims.JobId)\n\n\terr = msc.authenticateSelfToServer(jobAuthToken)\n\tif err != nil {\n\t\tmsc.PeerAuthenticated.Store(false)\n\t\treturn err\n\t}\n\n\tWshCmdJobManager.SetAttachedClient(msc)\n\treturn nil\n}\n\nfunc (msc *MainServerConn) StartJobCommand(ctx context.Context, data wshrpc.CommandStartJobData) (*wshrpc.CommandStartJobRtnData, error) {\n\tlog.Printf(\"StartJobCommand: received command=%s args=%v\", data.Cmd, data.Args)\n\tif !msc.PeerAuthenticated.Load() {\n\t\tlog.Printf(\"StartJobCommand: not authenticated\")\n\t\treturn nil, fmt.Errorf(\"not authenticated\")\n\t}\n\treturn WshCmdJobManager.StartJob(msc, data)\n}\n\nfunc (msc *MainServerConn) JobPrepareConnectCommand(ctx context.Context, data wshrpc.CommandJobPrepareConnectData) (*wshrpc.CommandJobConnectRtnData, error) {\n\tif !msc.PeerAuthenticated.Load() {\n\t\treturn nil, fmt.Errorf(\"peer not authenticated\")\n\t}\n\tif !msc.SelfAuthenticated.Load() {\n\t\treturn nil, fmt.Errorf(\"not authenticated to server\")\n\t}\n\treturn WshCmdJobManager.PrepareConnect(msc, data)\n}\n\nfunc (msc *MainServerConn) JobStartStreamCommand(ctx context.Context, data wshrpc.CommandJobStartStreamData) error {\n\tif !msc.PeerAuthenticated.Load() {\n\t\treturn fmt.Errorf(\"not authenticated\")\n\t}\n\treturn WshCmdJobManager.StartStream(msc)\n}\n\nfunc (msc *MainServerConn) JobInputCommand(ctx context.Context, data wshrpc.CommandJobInputData) error {\n\tif !msc.PeerAuthenticated.Load() {\n\t\treturn fmt.Errorf(\"not authenticated\")\n\t}\n\tif !WshCmdJobManager.IsJobStarted() {\n\t\treturn fmt.Errorf(\"job not started\")\n\t}\n\n\tWshCmdJobManager.InputQueue.QueueItem(data.InputSessionId, data.SeqNum, data)\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/jobmanager/streammanager.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage jobmanager\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nconst (\n\tCwndSize      = 64 * 1024       // 64 KB window for connected mode\n\tCirBufSize    = 2 * 1024 * 1024 // 2 MB max buffer size\n\tDisconnReadSz = 4 * 1024        // 4 KB read chunks when disconnected\n\tMaxPacketSize = 4 * 1024        // 4 KB max data per packet\n)\n\ntype DataSender interface {\n\tSendData(dataPk wshrpc.CommandStreamData)\n}\n\ntype streamTerminalEvent struct {\n\tisEof bool\n\terr   string\n}\n\n// StreamManager handles PTY output buffering with ACK-based flow control\ntype StreamManager struct {\n\tlock      sync.Mutex\n\tdrainCond *sync.Cond\n\n\tstreamId string\n\n\t// this is the data read from the attached reader\n\tbuf           *CirBuf\n\tterminalEvent *streamTerminalEvent\n\teofPos        int64 // fixed position when EOF/error occurs (-1 if not yet)\n\n\treader io.Reader\n\n\tcwndSize int\n\trwndSize int\n\t// invariant: if connected is true, dataSender is non-nil\n\tconnected  bool\n\tdataSender DataSender\n\n\t// unacked state (reset on disconnect)\n\tsentNotAcked      int64\n\tterminalEventSent bool\n\n\t// track max acked to handle out-of-order ACKs (reset on disconnect)\n\tmaxAckedSeq  int64\n\tmaxAckedRwnd int64\n\n\t// terminal state - once true, stream is complete\n\tterminalEventAcked bool\n\tclosed             bool\n}\n\nfunc MakeStreamManager() *StreamManager {\n\treturn MakeStreamManagerWithSizes(CwndSize, CirBufSize)\n}\n\nfunc MakeStreamManagerWithSizes(cwndSize, cirbufSize int) *StreamManager {\n\tsm := &StreamManager{\n\t\tbuf:      MakeCirBuf(cirbufSize, true),\n\t\teofPos:   -1,\n\t\tcwndSize: cwndSize,\n\t\trwndSize: cwndSize,\n\t}\n\tsm.drainCond = sync.NewCond(&sm.lock)\n\tgo sm.senderLoop()\n\treturn sm\n}\n\n// AttachReader starts reading from the given reader\nfunc (sm *StreamManager) AttachReader(r io.Reader) error {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\n\tif sm.reader != nil {\n\t\treturn fmt.Errorf(\"reader already attached\")\n\t}\n\n\tsm.reader = r\n\tgo sm.readLoop()\n\n\treturn nil\n}\n\n// ClientConnected transitions to CONNECTED mode\nfunc (sm *StreamManager) ClientConnected(streamId string, dataSender DataSender, rwndSize int, clientSeq int64) (int64, error) {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\n\tif sm.closed || sm.terminalEventAcked {\n\t\treturn 0, fmt.Errorf(\"stream is closed\")\n\t}\n\n\tif sm.connected {\n\t\treturn 0, fmt.Errorf(\"client already connected\")\n\t}\n\n\tif dataSender == nil {\n\t\treturn 0, fmt.Errorf(\"dataSender cannot be nil\")\n\t}\n\n\theadPos := sm.buf.HeadPos()\n\tif clientSeq > headPos {\n\t\tbytesToConsume := int(clientSeq - headPos)\n\t\tavailable := sm.buf.Size()\n\t\tif bytesToConsume > available {\n\t\t\treturn 0, fmt.Errorf(\"client seq %d is beyond our stream end (head=%d, size=%d)\", clientSeq, headPos, available)\n\t\t}\n\t\tif bytesToConsume > 0 {\n\t\t\tif err := sm.buf.Consume(bytesToConsume); err != nil {\n\t\t\t\treturn 0, fmt.Errorf(\"failed to consume buffer: %w\", err)\n\t\t\t}\n\t\t\theadPos = sm.buf.HeadPos()\n\t\t}\n\t}\n\n\tsm.streamId = streamId\n\tsm.dataSender = dataSender\n\tsm.connected = true\n\tsm.rwndSize = rwndSize\n\tsm.sentNotAcked = 0\n\teffectiveWindow := sm.cwndSize\n\tif sm.rwndSize < effectiveWindow {\n\t\teffectiveWindow = sm.rwndSize\n\t}\n\tsm.buf.SetEffectiveWindow(true, effectiveWindow)\n\tsm.drainCond.Signal()\n\n\tstartSeq := headPos\n\tif clientSeq > startSeq {\n\t\tstartSeq = clientSeq\n\t}\n\n\treturn startSeq, nil\n}\n\n// GetStreamId returns the current stream ID (safe to call with lock held by caller)\nfunc (sm *StreamManager) GetStreamId() string {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\treturn sm.streamId\n}\n\n// GetStreamDoneInfo returns whether the stream is done and the error if there was one.\n// The error is only meaningful if done=true, as the error is delivered as part of the stream otherwise.\nfunc (sm *StreamManager) GetStreamDoneInfo() (done bool, streamError string) {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\tif !sm.terminalEventAcked {\n\t\treturn false, \"\"\n\t}\n\tif sm.terminalEvent != nil && !sm.terminalEvent.isEof {\n\t\treturn true, sm.terminalEvent.err\n\t}\n\treturn true, \"\"\n}\n\n// ClientDisconnected transitions to DISCONNECTED mode\nfunc (sm *StreamManager) ClientDisconnected() {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\n\tif !sm.connected {\n\t\treturn\n\t}\n\n\tsm.connected = false\n\tsm.dataSender = nil\n\tsm.sentNotAcked = 0\n\tsm.maxAckedSeq = 0\n\tsm.maxAckedRwnd = 0\n\tif !sm.terminalEventAcked {\n\t\tsm.terminalEventSent = false\n\t}\n\tsm.buf.SetEffectiveWindow(false, CirBufSize)\n\tsm.drainCond.Signal()\n}\n\n// RecvAck processes an ACK from the client\n// must be connected, and streamid must match\nfunc (sm *StreamManager) RecvAck(ackPk wshrpc.CommandStreamAckData) {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\n\tif !sm.connected || ackPk.Id != sm.streamId {\n\t\treturn\n\t}\n\n\tif ackPk.Fin {\n\t\tsm.terminalEventAcked = true\n\t\tsm.drainCond.Signal()\n\t\treturn\n\t}\n\n\tseq := ackPk.Seq\n\trwnd := ackPk.RWnd\n\n\t// Ignore stale ACKs using tuple comparison (seq, rwnd)\n\tif seq < sm.maxAckedSeq || (seq == sm.maxAckedSeq && rwnd <= sm.maxAckedRwnd) {\n\t\t// log.Printf(\"streammanager ignoring stale ACK: seq=%d rwnd=%d (max: seq=%d rwnd=%d)\",\n\t\t// \tseq, rwnd, sm.maxAckedSeq, sm.maxAckedRwnd)\n\t\treturn\n\t}\n\n\t// Update max acked tuple\n\tsm.maxAckedSeq = seq\n\tsm.maxAckedRwnd = rwnd\n\n\theadPos := sm.buf.HeadPos()\n\tif seq < headPos {\n\t\treturn\n\t}\n\n\tackedBytes := seq - headPos\n\tif ackedBytes > sm.sentNotAcked {\n\t\treturn\n\t}\n\n\tif ackedBytes > 0 {\n\t\tif err := sm.buf.Consume(int(ackedBytes)); err != nil {\n\t\t\treturn\n\t\t}\n\t\tsm.sentNotAcked -= ackedBytes\n\t}\n\n\tprevRwnd := sm.rwndSize\n\tsm.rwndSize = int(ackPk.RWnd)\n\teffectiveWindow := sm.cwndSize\n\tif sm.rwndSize < effectiveWindow {\n\t\teffectiveWindow = sm.rwndSize\n\t}\n\tsm.buf.SetEffectiveWindow(true, effectiveWindow)\n\n\tif sm.rwndSize > prevRwnd || ackedBytes > 0 {\n\t\tsm.drainCond.Signal()\n\t}\n}\n\n// SetRwndSize dynamically updates the receive window size\nfunc (sm *StreamManager) SetRwndSize(rwndSize int) error {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\tif rwndSize < 0 {\n\t\treturn fmt.Errorf(\"rwndSize cannot be negative\")\n\t}\n\tif !sm.connected {\n\t\treturn fmt.Errorf(\"not connected\")\n\t}\n\tsm.rwndSize = rwndSize\n\teffectiveWindow := sm.cwndSize\n\tif sm.rwndSize < effectiveWindow {\n\t\teffectiveWindow = sm.rwndSize\n\t}\n\tsm.buf.SetEffectiveWindow(true, effectiveWindow)\n\tsm.drainCond.Signal()\n\treturn nil\n}\n\n// Close shuts down the sender loop. The reader loop will exit on its next iteration\n// or when the underlying reader is closed.\nfunc (sm *StreamManager) Close() {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\tsm.closed = true\n\tsm.drainCond.Signal()\n}\n\n// readLoop is the main read goroutine\nfunc (sm *StreamManager) readLoop() {\n\treadBuf := make([]byte, MaxPacketSize)\n\tfor {\n\t\tsm.lock.Lock()\n\t\tclosed := sm.closed\n\t\tsm.lock.Unlock()\n\n\t\tif closed {\n\t\t\treturn\n\t\t}\n\n\t\tn, err := sm.reader.Read(readBuf)\n\n\t\tif n > 0 {\n\t\t\tsm.handleReadData(readBuf[:n])\n\t\t}\n\n\t\tif err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\tsm.handleEOF()\n\t\t\t} else {\n\t\t\t\tsm.handleError(err)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (sm *StreamManager) handleReadData(data []byte) {\n\toffset := 0\n\tfor offset < len(data) {\n\t\tn, waitCh := sm.buf.WriteAvailable(data[offset:])\n\t\toffset += n\n\n\t\tif n > 0 {\n\t\t\tsm.lock.Lock()\n\t\t\tsm.drainCond.Signal()\n\t\t\tsm.lock.Unlock()\n\t\t}\n\n\t\tif waitCh != nil {\n\t\t\t<-waitCh\n\t\t}\n\t}\n}\n\nfunc (sm *StreamManager) handleEOF() {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\n\tlog.Printf(\"handleEOF: PTY reached EOF, totalSize=%d\", sm.buf.TotalSize())\n\tsm.eofPos = sm.buf.TotalSize()\n\tsm.terminalEvent = &streamTerminalEvent{isEof: true}\n\tsm.drainCond.Signal()\n}\n\nfunc (sm *StreamManager) handleError(err error) {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\n\tlog.Printf(\"handleError: PTY error=%v, totalSize=%d\", err, sm.buf.TotalSize())\n\tsm.eofPos = sm.buf.TotalSize()\n\tsm.terminalEvent = &streamTerminalEvent{err: err.Error()}\n\tsm.drainCond.Signal()\n}\n\nfunc (sm *StreamManager) senderLoop() {\n\tfor {\n\t\tdone, pkt, sender := sm.prepareNextPacket()\n\t\tif done {\n\t\t\treturn\n\t\t}\n\t\tif pkt == nil {\n\t\t\tcontinue\n\t\t}\n\t\tsender.SendData(*pkt)\n\t}\n}\n\nfunc (sm *StreamManager) prepareNextPacket() (done bool, pkt *wshrpc.CommandStreamData, sender DataSender) {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\n\tavailable := sm.buf.Size()\n\n\tif sm.closed || sm.terminalEventAcked {\n\t\treturn true, nil, nil\n\t}\n\n\tif !sm.connected {\n\t\tsm.drainCond.Wait()\n\t\treturn false, nil, nil\n\t}\n\n\tif available == 0 {\n\t\tif sm.terminalEvent != nil && !sm.terminalEventSent {\n\t\t\treturn false, sm.prepareTerminalPacket(), sm.dataSender\n\t\t}\n\t\tsm.drainCond.Wait()\n\t\treturn false, nil, nil\n\t}\n\n\teffectiveRwnd := sm.rwndSize\n\tif sm.cwndSize < effectiveRwnd {\n\t\teffectiveRwnd = sm.cwndSize\n\t}\n\tavailableToSend := int64(effectiveRwnd) - sm.sentNotAcked\n\n\tif availableToSend <= 0 {\n\t\tsm.drainCond.Wait()\n\t\treturn false, nil, nil\n\t}\n\n\tpeekSize := int(availableToSend)\n\tif peekSize > MaxPacketSize {\n\t\tpeekSize = MaxPacketSize\n\t}\n\tif peekSize > available {\n\t\tpeekSize = available\n\t}\n\n\tdata := make([]byte, peekSize)\n\tn := sm.buf.PeekDataAt(int(sm.sentNotAcked), data)\n\tif n == 0 {\n\t\tsm.drainCond.Wait()\n\t\treturn false, nil, nil\n\t}\n\tdata = data[:n]\n\n\tseq := sm.buf.HeadPos() + sm.sentNotAcked\n\tsm.sentNotAcked += int64(n)\n\n\treturn false, &wshrpc.CommandStreamData{\n\t\tId:     sm.streamId,\n\t\tSeq:    seq,\n\t\tData64: base64.StdEncoding.EncodeToString(data),\n\t}, sm.dataSender\n}\n\nfunc (sm *StreamManager) prepareTerminalPacket() *wshrpc.CommandStreamData {\n\tif sm.terminalEventSent || sm.terminalEvent == nil {\n\t\treturn nil\n\t}\n\n\tpkt := &wshrpc.CommandStreamData{\n\t\tId:  sm.streamId,\n\t\tSeq: sm.eofPos,\n\t}\n\n\tif sm.terminalEvent.isEof {\n\t\tpkt.Eof = true\n\t} else {\n\t\tpkt.Error = sm.terminalEvent.err\n\t}\n\n\tsm.terminalEventSent = true\n\treturn pkt\n}\n"
  },
  {
    "path": "pkg/jobmanager/streammanager_test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage jobmanager\n\nimport (\n\t\"encoding/base64\"\n\t\"io\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype testWriter struct {\n\tmu      sync.Mutex\n\tpackets []wshrpc.CommandStreamData\n}\n\nfunc (tw *testWriter) SendData(pkt wshrpc.CommandStreamData) {\n\ttw.mu.Lock()\n\tdefer tw.mu.Unlock()\n\ttw.packets = append(tw.packets, pkt)\n}\n\nfunc (tw *testWriter) GetPackets() []wshrpc.CommandStreamData {\n\ttw.mu.Lock()\n\tdefer tw.mu.Unlock()\n\tresult := make([]wshrpc.CommandStreamData, len(tw.packets))\n\tcopy(result, tw.packets)\n\treturn result\n}\n\nfunc (tw *testWriter) Clear() {\n\ttw.mu.Lock()\n\tdefer tw.mu.Unlock()\n\ttw.packets = nil\n}\n\nfunc decodeData(data64 string) string {\n\tdecoded, _ := base64.StdEncoding.DecodeString(data64)\n\treturn string(decoded)\n}\n\nfunc TestBasicDisconnectedMode(t *testing.T) {\n\ttw := &testWriter{}\n\tsm := MakeStreamManager()\n\n\treader := strings.NewReader(\"hello world\")\n\terr := sm.AttachReader(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"AttachReader failed: %v\", err)\n\t}\n\n\ttime.Sleep(50 * time.Millisecond)\n\n\tpackets := tw.GetPackets()\n\tif len(packets) > 0 {\n\t\tt.Errorf(\"Expected no packets in DISCONNECTED mode without client, got %d\", len(packets))\n\t}\n\n\tsm.Close()\n}\n\nfunc TestConnectedModeBasicFlow(t *testing.T) {\n\ttw := &testWriter{}\n\tsm := MakeStreamManager()\n\n\treader := strings.NewReader(\"hello\")\n\terr := sm.AttachReader(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"AttachReader failed: %v\", err)\n\t}\n\n\t_, err = sm.ClientConnected(\"1\", tw, CwndSize, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"ClientConnected failed: %v\", err)\n\t}\n\n\ttime.Sleep(100 * time.Millisecond)\n\n\tpackets := tw.GetPackets()\n\tif len(packets) == 0 {\n\t\tt.Fatal(\"Expected packets after ClientConnected\")\n\t}\n\n\t// Verify we got the data\n\tallData := \"\"\n\tfor _, pkt := range packets {\n\t\tif pkt.Data64 != \"\" {\n\t\t\tallData += decodeData(pkt.Data64)\n\t\t}\n\t}\n\n\tif allData != \"hello\" {\n\t\tt.Errorf(\"Expected 'hello', got '%s'\", allData)\n\t}\n\n\t// Send ACK\n\tsm.RecvAck(wshrpc.CommandStreamAckData{Id: \"1\", Seq: 5, RWnd: CwndSize})\n\n\ttime.Sleep(50 * time.Millisecond)\n\n\t// Check for EOF packet\n\tpackets = tw.GetPackets()\n\thasEof := false\n\tfor _, pkt := range packets {\n\t\tif pkt.Eof {\n\t\t\thasEof = true\n\t\t}\n\t}\n\n\tif !hasEof {\n\t\tt.Error(\"Expected EOF packet after ACKing all data\")\n\t}\n\n\tsm.Close()\n}\n\nfunc TestDisconnectedToConnectedTransition(t *testing.T) {\n\ttw := &testWriter{}\n\tsm := MakeStreamManager()\n\n\treader := strings.NewReader(\"test data\")\n\terr := sm.AttachReader(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"AttachReader failed: %v\", err)\n\t}\n\n\ttime.Sleep(100 * time.Millisecond)\n\n\t_, err = sm.ClientConnected(\"1\", tw, CwndSize, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"ClientConnected failed: %v\", err)\n\t}\n\n\ttime.Sleep(100 * time.Millisecond)\n\n\tpackets := tw.GetPackets()\n\tif len(packets) == 0 {\n\t\tt.Fatal(\"Expected cirbuf drain after connect\")\n\t}\n\n\tallData := \"\"\n\tfor _, pkt := range packets {\n\t\tif pkt.Data64 != \"\" {\n\t\t\tallData += decodeData(pkt.Data64)\n\t\t}\n\t}\n\n\tif allData != \"test data\" {\n\t\tt.Errorf(\"Expected 'test data', got '%s'\", allData)\n\t}\n\n\tsm.Close()\n}\n\nfunc TestConnectedToDisconnectedTransition(t *testing.T) {\n\ttw := &testWriter{}\n\tsm := MakeStreamManager()\n\n\treader := &slowReader{data: []byte(\"slow data\"), delay: 50 * time.Millisecond}\n\terr := sm.AttachReader(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"AttachReader failed: %v\", err)\n\t}\n\n\t_, err = sm.ClientConnected(\"1\", tw, CwndSize, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"ClientConnected failed: %v\", err)\n\t}\n\n\ttime.Sleep(150 * time.Millisecond)\n\n\tsm.ClientDisconnected()\n\n\ttime.Sleep(100 * time.Millisecond)\n\n\tsm.Close()\n}\n\nfunc TestFlowControl(t *testing.T) {\n\tcwndSize := 1024\n\ttw := &testWriter{}\n\tsm := MakeStreamManagerWithSizes(cwndSize, 8*1024)\n\n\tlargeData := strings.Repeat(\"x\", cwndSize+500)\n\treader := strings.NewReader(largeData)\n\n\terr := sm.AttachReader(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"AttachReader failed: %v\", err)\n\t}\n\n\t_, err = sm.ClientConnected(\"1\", tw, cwndSize, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"ClientConnected failed: %v\", err)\n\t}\n\n\ttime.Sleep(100 * time.Millisecond)\n\n\tpackets := tw.GetPackets()\n\ttotalData := 0\n\tfor _, pkt := range packets {\n\t\tif pkt.Data64 != \"\" {\n\t\t\tdecoded, _ := base64.StdEncoding.DecodeString(pkt.Data64)\n\t\t\ttotalData += len(decoded)\n\t\t}\n\t}\n\n\tif totalData > cwndSize {\n\t\tt.Errorf(\"Sent %d bytes without ACK, exceeds cwnd size %d\", totalData, cwndSize)\n\t}\n\n\tsm.RecvAck(wshrpc.CommandStreamAckData{Id: \"1\", Seq: int64(totalData), RWnd: int64(cwndSize)})\n\n\ttime.Sleep(100 * time.Millisecond)\n\n\tsm.Close()\n}\n\nfunc TestSequenceNumbering(t *testing.T) {\n\ttw := &testWriter{}\n\tsm := MakeStreamManager()\n\n\treader := strings.NewReader(\"abcdefghij\")\n\terr := sm.AttachReader(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"AttachReader failed: %v\", err)\n\t}\n\n\t_, err = sm.ClientConnected(\"1\", tw, CwndSize, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"ClientConnected failed: %v\", err)\n\t}\n\n\ttime.Sleep(100 * time.Millisecond)\n\n\tpackets := tw.GetPackets()\n\tif len(packets) == 0 {\n\t\tt.Fatal(\"Expected packets\")\n\t}\n\n\texpectedSeq := int64(0)\n\tfor _, pkt := range packets {\n\t\tif pkt.Data64 == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif pkt.Seq != expectedSeq {\n\t\t\tt.Errorf(\"Expected seq %d, got %d\", expectedSeq, pkt.Seq)\n\t\t}\n\n\t\tdecoded, _ := base64.StdEncoding.DecodeString(pkt.Data64)\n\t\texpectedSeq += int64(len(decoded))\n\t}\n\n\tsm.Close()\n}\n\nfunc TestTerminalEventOrdering(t *testing.T) {\n\ttw := &testWriter{}\n\tsm := MakeStreamManager()\n\n\treader := strings.NewReader(\"data\")\n\terr := sm.AttachReader(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"AttachReader failed: %v\", err)\n\t}\n\n\t_, err = sm.ClientConnected(\"1\", tw, CwndSize, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"ClientConnected failed: %v\", err)\n\t}\n\n\ttime.Sleep(100 * time.Millisecond)\n\n\tpackets := tw.GetPackets()\n\tif len(packets) == 0 {\n\t\tt.Fatal(\"Expected data packets\")\n\t}\n\n\thasData := false\n\thasEof := false\n\teofSeq := int64(-1)\n\n\tfor _, pkt := range packets {\n\t\tif pkt.Data64 != \"\" {\n\t\t\thasData = true\n\t\t}\n\t\tif pkt.Eof {\n\t\t\thasEof = true\n\t\t\teofSeq = pkt.Seq\n\t\t}\n\t}\n\n\tif !hasData {\n\t\tt.Error(\"Expected data packet\")\n\t}\n\n\tif hasEof {\n\t\tt.Error(\"Should not have EOF before ACK\")\n\t}\n\n\tsm.RecvAck(wshrpc.CommandStreamAckData{Id: \"1\", Seq: 4, RWnd: CwndSize})\n\n\ttime.Sleep(50 * time.Millisecond)\n\n\tpackets = tw.GetPackets()\n\thasEof = false\n\tfor _, pkt := range packets {\n\t\tif pkt.Eof {\n\t\t\thasEof = true\n\t\t\teofSeq = pkt.Seq\n\t\t}\n\t}\n\n\tif !hasEof {\n\t\tt.Error(\"Expected EOF after ACKing all data\")\n\t}\n\n\tif eofSeq != 4 {\n\t\tt.Errorf(\"Expected EOF at seq 4, got %d\", eofSeq)\n\t}\n\n\tsm.Close()\n}\n\ntype slowReader struct {\n\tdata  []byte\n\tpos   int\n\tdelay time.Duration\n}\n\nfunc (sr *slowReader) Read(p []byte) (n int, err error) {\n\tif sr.pos >= len(sr.data) {\n\t\treturn 0, io.EOF\n\t}\n\n\ttime.Sleep(sr.delay)\n\n\tn = copy(p, sr.data[sr.pos:])\n\tsr.pos += n\n\n\treturn n, nil\n}\n"
  },
  {
    "path": "pkg/panichandler/panichandler.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage panichandler\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"runtime/debug\"\n)\n\n// to log NumPanics into the local telemetry system\n// gets around import cycles\nvar PanicTelemetryHandler func(panicType string)\n\nfunc PanicHandlerNoTelemetry(debugStr string, recoverVal any) {\n\tif recoverVal == nil {\n\t\treturn\n\t}\n\tlog.Printf(\"[panic] in %s: %v\\n\", debugStr, recoverVal)\n\tdebug.PrintStack()\n}\n\n// returns an error (wrapping the panic) if a panic occurred\nfunc PanicHandler(debugStr string, recoverVal any) error {\n\tif recoverVal == nil {\n\t\treturn nil\n\t}\n\tlog.Printf(\"[panic] in %s: %v\\n\", debugStr, recoverVal)\n\tdebug.PrintStack()\n\tif PanicTelemetryHandler != nil {\n\t\tgo func() {\n\t\t\tdefer func() {\n\t\t\t\tPanicHandlerNoTelemetry(\"PanicTelemetryHandler\", recover())\n\t\t\t}()\n\t\t\tPanicTelemetryHandler(debugStr)\n\t\t}()\n\t}\n\tif err, ok := recoverVal.(error); ok {\n\t\treturn fmt.Errorf(\"panic in %s: %w\", debugStr, err)\n\t}\n\treturn fmt.Errorf(\"panic in %s: %v\", debugStr, recoverVal)\n}\n"
  },
  {
    "path": "pkg/remote/conncontroller/conncontroller.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage conncontroller\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"log\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/kevinburke/ssh_config\"\n\t\"github.com/skeema/knownhosts\"\n\t\"github.com/wavetermdev/waveterm/pkg/blocklogger\"\n\t\"github.com/wavetermdev/waveterm/pkg/genconn\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\"\n\t\"github.com/wavetermdev/waveterm/pkg/userinput\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/envutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n\t\"golang.org/x/crypto/ssh\"\n\t\"golang.org/x/mod/semver\"\n)\n\nconst (\n\tStatus_Init         = \"init\"\n\tStatus_Connecting   = \"connecting\"\n\tStatus_Connected    = \"connected\"\n\tStatus_Disconnected = \"disconnected\"\n\tStatus_Error        = \"error\"\n)\n\nconst (\n\tNoWshCode_Disabled              = \"disabled\"\n\tNoWshCode_PermissionError       = \"permission-error\"\n\tNoWshCode_UserDeclined          = \"user-declined\"\n\tNoWshCode_DomainSocketError     = \"domainsocket-error\"\n\tNoWshCode_ConnServerStartError  = \"connserver-start-error\"\n\tNoWshCode_InstallError          = \"install-error\"\n\tNoWshCode_PostInstallStartError = \"postinstall-start-error\"\n\tNoWshCode_InstallVerifyError    = \"install-verify-error\"\n)\n\nconst (\n\tConnHealthStatus_Good     = \"good\"\n\tConnHealthStatus_Degraded = \"degraded\"\n\tConnHealthStatus_Stalled  = \"stalled\"\n)\n\nconst DefaultConnectionTimeout = 60 * time.Second\n\nvar globalLock = &sync.Mutex{}\nvar clientControllerMap = make(map[remote.SSHOpts]*SSHConn)\nvar activeConnCounter = &atomic.Int32{}\n\ntype SSHConn struct {\n\tlock          *sync.Mutex // this lock protects the fields in the struct from concurrent access\n\tlifecycleLock *sync.Mutex // this protects the lifecycle from concurrent calls\n\n\tStatus             string\n\tConnHealthStatus   string\n\tWshEnabled         *atomic.Bool\n\tOpts               *remote.SSHOpts\n\tClient             *ssh.Client\n\tDomainSockName     string // if \"\", then no domain socket\n\tDomainSockListener net.Listener\n\tConnController     *ssh.Session\n\tError              string\n\tWshError           string\n\tNoWshReason        string\n\tWshVersion         string\n\tLastConnectTime    int64\n\tActiveConnNum      int\n\tMonitor            *ConnMonitor // will not be nil\n}\n\nvar ConnServerCmdTemplate = strings.TrimSpace(\n\tstrings.Join([]string{\n\t\t\"%s version 2> /dev/null || (echo -n \\\"not-installed \\\"; uname -sm; exit 0);\",\n\t\t\"exec %s connserver --conn %s %s %s\",\n\t}, \"\\n\"))\n\nfunc IsLocalConnName(connName string) bool {\n\treturn strings.HasPrefix(connName, \"local:\") || connName == \"local\" || connName == \"\"\n}\n\nfunc IsWslConnName(connName string) bool {\n\treturn strings.HasPrefix(connName, \"wsl://\")\n}\n\nfunc GetAllConnStatus() []wshrpc.ConnStatus {\n\tglobalLock.Lock()\n\tdefer globalLock.Unlock()\n\n\tvar connStatuses []wshrpc.ConnStatus\n\tfor _, conn := range clientControllerMap {\n\t\tconnStatuses = append(connStatuses, conn.DeriveConnStatus())\n\t}\n\treturn connStatuses\n}\n\nfunc GetNumSSHHasConnected() int {\n\tglobalLock.Lock()\n\tdefer globalLock.Unlock()\n\n\tvar numConnected int\n\tfor _, conn := range clientControllerMap {\n\t\tif conn.LastConnectTime > 0 {\n\t\t\tnumConnected++\n\t\t}\n\t}\n\treturn numConnected\n}\n\nfunc (conn *SSHConn) DeriveConnStatus() wshrpc.ConnStatus {\n\tconn.lock.Lock()\n\tdefer conn.lock.Unlock()\n\tvar lastActivityBeforeStalledTime int64\n\tvar keepAliveSentTime int64\n\tmonitor := conn.Monitor\n\tif conn.ConnHealthStatus == ConnHealthStatus_Stalled && monitor != nil {\n\t\tlastActivityBeforeStalledTime = monitor.LastActivityTime.Load()\n\t\tkeepAliveSentTime = monitor.KeepAliveSentTime.Load()\n\t}\n\treturn wshrpc.ConnStatus{\n\t\tStatus:                        conn.Status,\n\t\tConnected:                     conn.Status == Status_Connected,\n\t\tConnection:                    conn.Opts.String(),\n\t\tHasConnected:                  (conn.LastConnectTime > 0),\n\t\tActiveConnNum:                 conn.ActiveConnNum,\n\t\tError:                         conn.Error,\n\t\tWshEnabled:                    conn.WshEnabled.Load(),\n\t\tWshError:                      conn.WshError,\n\t\tNoWshReason:                   conn.NoWshReason,\n\t\tWshVersion:                    conn.WshVersion,\n\t\tConnHealthStatus:              conn.ConnHealthStatus,\n\t\tLastActivityBeforeStalledTime: lastActivityBeforeStalledTime,\n\t\tKeepAliveSentTime:             keepAliveSentTime,\n\t}\n}\n\nfunc (conn *SSHConn) Infof(ctx context.Context, format string, args ...any) {\n\tlog.Print(fmt.Sprintf(\"[conn:%s] \", conn.GetName()) + fmt.Sprintf(format, args...))\n\tblocklogger.Infof(ctx, \"[conndebug] \"+format, args...)\n}\n\nfunc (conn *SSHConn) Debugf(ctx context.Context, format string, args ...any) {\n\tblocklogger.Debugf(ctx, \"[conndebug] \"+format, args...)\n}\n\nfunc (conn *SSHConn) FireConnChangeEvent() {\n\tstatus := conn.DeriveConnStatus()\n\tevent := wps.WaveEvent{\n\t\tEvent: wps.Event_ConnChange,\n\t\tScopes: []string{\n\t\t\tfmt.Sprintf(\"connection:%s\", conn.GetName()),\n\t\t},\n\t\tData: status,\n\t}\n\tlog.Printf(\"sending event: %+#v\", event)\n\twps.Broker.Publish(event)\n}\n\nfunc (conn *SSHConn) Close() error {\n\tconn.lifecycleLock.Lock()\n\tdefer conn.lifecycleLock.Unlock()\n\n\tdefer conn.FireConnChangeEvent()\n\tconn.WithLock(func() {\n\t\tif conn.Status == Status_Connected || conn.Status == Status_Connecting {\n\t\t\t// if status is init, disconnected, or error don't change it\n\t\t\tconn.Status = Status_Disconnected\n\t\t}\n\t})\n\tconn.closeInternal_withlifecyclelock()\n\treturn nil\n}\n\nfunc (conn *SSHConn) closeInternal_withlifecyclelock() {\n\t// does not set status (that should happen at another level)\n\tconn.WithLock(func() {\n\t\tif conn.Monitor != nil {\n\t\t\tconn.Monitor.Close()\n\t\t\tconn.Monitor = nil\n\t\t}\n\t\tconn.Monitor = nil\n\t})\n\tclient := conn.GetClient()\n\tif client != nil {\n\t\t// this MUST go first to force close the connection.\n\t\t// the DomainSockListener.Close() sends SSH protocol packets which can block on a dead network conn\n\t\tstartTime := time.Now()\n\t\tclient.Close()\n\t\tduration := time.Since(startTime).Milliseconds()\n\t\tif duration > 100 {\n\t\t\tlog.Printf(\"[conncontroller] conn:%s Client.Close() took %d ms\", conn.GetName(), duration)\n\t\t}\n\t\tconn.WithLock(func() {\n\t\t\tconn.Client = nil\n\t\t})\n\t}\n\tlistener := WithLockRtn(conn, func() net.Listener {\n\t\treturn conn.DomainSockListener\n\t})\n\tif listener != nil {\n\t\tstartTime := time.Now()\n\t\tlistener.Close()\n\t\tduration := time.Since(startTime).Milliseconds()\n\t\tif duration > 100 {\n\t\t\tlog.Printf(\"[conncontroller] conn:%s DomainSockListener.Close() took %d ms\", conn.GetName(), duration)\n\t\t}\n\t\tconn.WithLock(func() {\n\t\t\tconn.DomainSockListener = nil\n\t\t\tconn.DomainSockName = \"\"\n\t\t})\n\t}\n\tcontroller := WithLockRtn(conn, func() *ssh.Session {\n\t\treturn conn.ConnController\n\t})\n\tif controller != nil {\n\t\tstartTime := time.Now()\n\t\tcontroller.Close()\n\t\tduration := time.Since(startTime).Milliseconds()\n\t\tif duration > 100 {\n\t\t\tlog.Printf(\"[conncontroller] conn:%s ConnController.Close() took %d ms\", conn.GetName(), duration)\n\t\t}\n\t\tconn.WithLock(func() {\n\t\t\tconn.ConnController = nil\n\t\t})\n\t}\n}\n\nfunc (conn *SSHConn) GetDomainSocketName() string {\n\tconn.lock.Lock()\n\tdefer conn.lock.Unlock()\n\treturn conn.DomainSockName\n}\n\nfunc (conn *SSHConn) GetStatus() string {\n\tconn.lock.Lock()\n\tdefer conn.lock.Unlock()\n\treturn conn.Status\n}\n\nfunc (conn *SSHConn) GetName() string {\n\t// no lock required because opts is immutable\n\treturn conn.Opts.String()\n}\n\nfunc (conn *SSHConn) OpenDomainSocketListener(ctx context.Context) error {\n\tconn.Infof(ctx, \"running OpenDomainSocketListener...\\n\")\n\tallowed := WithLockRtn(conn, func() bool {\n\t\treturn conn.Status == Status_Connecting\n\t})\n\tif !allowed {\n\t\treturn fmt.Errorf(\"cannot open domain socket for %q when status is %q\", conn.GetName(), conn.GetStatus())\n\t}\n\tclient := conn.GetClient()\n\trandStr, err := utilfn.RandomHexString(16) // 64-bits of randomness\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error generating random string: %w\", err)\n\t}\n\tsockName := fmt.Sprintf(\"/tmp/waveterm-%s.sock\", randStr)\n\tconn.Infof(ctx, \"generated domain socket name %s\\n\", sockName)\n\tlistener, err := client.ListenUnix(sockName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to request connection domain socket: %v\", err)\n\t}\n\tconn.WithLock(func() {\n\t\tconn.DomainSockName = sockName\n\t\tconn.DomainSockListener = listener\n\t})\n\tconn.Infof(ctx, \"successfully connected domain socket\\n\")\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"conncontroller:OpenDomainSocketListener\", recover())\n\t\t}()\n\t\tdefer conn.WithLock(func() {\n\t\t\tconn.DomainSockListener = nil\n\t\t\tconn.DomainSockName = \"\"\n\t\t})\n\t\tmonitor := conn.GetMonitor()\n\t\tvar updateCallback func()\n\t\tif monitor != nil {\n\t\t\tupdateCallback = monitor.UpdateLastActivityTime\n\t\t}\n\t\twshutil.RunWshRpcOverListener(listener, updateCallback)\n\t}()\n\treturn nil\n}\n\n// expects the output of `wsh version` which looks like `wsh v0.10.4` or \"not-installed [os] [arch]\"\n// returns (up-to-date, semver, osArchStr, error)\n// if not up to date, or error, version might be \"\"\nfunc IsWshVersionUpToDate(logCtx context.Context, wshVersionLine string) (bool, string, string, error) {\n\twshVersionLine = strings.TrimSpace(wshVersionLine)\n\tif strings.HasPrefix(wshVersionLine, \"not-installed\") {\n\t\treturn false, \"not-installed\", strings.TrimSpace(strings.TrimPrefix(wshVersionLine, \"not-installed\")), nil\n\t}\n\tparts := strings.Fields(wshVersionLine)\n\tif len(parts) != 2 {\n\t\treturn false, \"\", \"\", fmt.Errorf(\"unexpected version format: %s\", wshVersionLine)\n\t}\n\tclientVersion := parts[1]\n\texpectedVersion := fmt.Sprintf(\"v%s\", wavebase.WaveVersion)\n\tif semver.Compare(clientVersion, expectedVersion) < 0 {\n\t\treturn false, clientVersion, \"\", nil\n\t}\n\treturn true, clientVersion, \"\", nil\n}\n\n// for testing only -- trying to determine the env difference when attaching or not attaching a pty to an ssh session\nfunc (conn *SSHConn) GetEnvironmentMaps(ctx context.Context) (map[string]string, map[string]string, error) {\n\tclient := conn.GetClient()\n\tif client == nil {\n\t\treturn nil, nil, fmt.Errorf(\"ssh client is not connected\")\n\t}\n\n\tnoPtyEnv, err := conn.getEnvironmentNoPty(ctx, client)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"error getting environment without PTY: %w\", err)\n\t}\n\n\tptyEnv, err := conn.getEnvironmentWithPty(ctx, client)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"error getting environment with PTY: %w\", err)\n\t}\n\n\treturn noPtyEnv, ptyEnv, nil\n}\n\nfunc runSessionWithContext(ctx context.Context, session *ssh.Session, cmd string) error {\n\terrCh := make(chan error, 1)\n\n\tgo func() {\n\t\terrCh <- session.Run(cmd)\n\t}()\n\n\tselect {\n\tcase <-ctx.Done():\n\t\tsession.Close()\n\t\treturn ctx.Err()\n\tcase err := <-errCh:\n\t\treturn err\n\t}\n}\n\nfunc (conn *SSHConn) getEnvironmentNoPty(ctx context.Context, client *ssh.Client) (map[string]string, error) {\n\tsession, err := client.NewSession()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create ssh session: %w\", err)\n\t}\n\tdefer session.Close()\n\n\toutputBuf := &strings.Builder{}\n\tsession.Stdout = outputBuf\n\tsession.Stderr = outputBuf\n\n\terr = runSessionWithContext(ctx, session, \"env -0\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error running env command: %w\", err)\n\t}\n\n\treturn envutil.EnvToMap(outputBuf.String()), nil\n}\n\nfunc (conn *SSHConn) getEnvironmentWithPty(ctx context.Context, client *ssh.Client) (map[string]string, error) {\n\tsession, err := client.NewSession()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create ssh session: %w\", err)\n\t}\n\tdefer session.Close()\n\n\ttermSize := waveobj.TermSize{Rows: 24, Cols: 80}\n\terr = session.RequestPty(\"xterm-256color\", termSize.Rows, termSize.Cols, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to request PTY: %w\", err)\n\t}\n\n\toutputBuf := &strings.Builder{}\n\tsession.Stdout = outputBuf\n\tsession.Stderr = outputBuf\n\n\terr = runSessionWithContext(ctx, session, \"env -0\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error running env command: %w\", err)\n\t}\n\n\treturn envutil.EnvToMap(outputBuf.String()), nil\n}\n\nfunc (conn *SSHConn) getWshPath() string {\n\tconfig, ok := conn.getConnectionConfig()\n\tif ok && config.ConnWshPath != \"\" {\n\t\treturn config.ConnWshPath\n\t}\n\treturn wavebase.RemoteFullWshBinPath\n}\n\nfunc (conn *SSHConn) GetConfigShellPath() string {\n\tconfig, ok := conn.getConnectionConfig()\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn config.ConnShellPath\n}\n\n// returns (needsInstall, clientVersion, osArchStr, error)\n// if wsh is not installed, the clientVersion will be \"not-installed\", and it will also return an osArchStr\n// if clientVersion is set, then no osArchStr will be returned\n// if useRouterMode is true, will start connserver with --router-domainsocket flag\nfunc (conn *SSHConn) StartConnServer(ctx context.Context, afterUpdate bool, useRouterMode bool) (bool, string, string, error) {\n\tconn.Infof(ctx, \"running StartConnServer (routerMode=%v)...\\n\", useRouterMode)\n\tallowed := WithLockRtn(conn, func() bool {\n\t\treturn conn.Status == Status_Connecting\n\t})\n\tif !allowed {\n\t\treturn false, \"\", \"\", fmt.Errorf(\"cannot start conn server for %q when status is %q\", conn.GetName(), conn.GetStatus())\n\t}\n\tclient := conn.GetClient()\n\twshPath := conn.getWshPath()\n\tsockName := conn.GetDomainSocketName()\n\tvar rpcCtx wshrpc.RpcContext\n\tif useRouterMode {\n\t\trpcCtx = wshrpc.RpcContext{\n\t\t\tIsRouter: true,\n\t\t\tSockName: sockName,\n\t\t\tConn:     conn.GetName(),\n\t\t}\n\t} else {\n\t\trpcCtx = wshrpc.RpcContext{\n\t\t\tRouteId:  wshutil.MakeConnectionRouteId(conn.GetName()),\n\t\t\tSockName: sockName,\n\t\t\tConn:     conn.GetName(),\n\t\t}\n\t}\n\tjwtToken, err := wshutil.MakeClientJWTToken(rpcCtx)\n\tif err != nil {\n\t\treturn false, \"\", \"\", fmt.Errorf(\"unable to create jwt token for conn controller: %w\", err)\n\t}\n\tconn.Infof(ctx, \"SSH-NEWSESSION (StartConnServer)\\n\")\n\tsshSession, err := client.NewSession()\n\tif err != nil {\n\t\treturn false, \"\", \"\", fmt.Errorf(\"unable to create ssh session for conn controller: %w\", err)\n\t}\n\tpipeRead, pipeWrite := io.Pipe()\n\tsshSession.Stdout = pipeWrite\n\tsshSession.Stderr = pipeWrite\n\tstdinPipe, err := sshSession.StdinPipe()\n\tif err != nil {\n\t\treturn false, \"\", \"\", fmt.Errorf(\"unable to get stdin pipe: %w\", err)\n\t}\n\tdevFlag := \"\"\n\tif wavebase.IsDevMode() {\n\t\tdevFlag = \"--dev\"\n\t}\n\trouterFlag := \"\"\n\tif useRouterMode {\n\t\trouterFlag = \"--router-domainsocket\"\n\t}\n\tcmdStr := fmt.Sprintf(ConnServerCmdTemplate, wshPath, wshPath, shellutil.HardQuote(conn.GetName()), devFlag, routerFlag)\n\tlog.Printf(\"starting conn controller: %q\\n\", cmdStr)\n\tshWrappedCmdStr := fmt.Sprintf(\"sh -c %s\", shellutil.HardQuote(cmdStr))\n\tblocklogger.Debugf(ctx, \"[conndebug] wrapped command:\\n%s\\n\", shWrappedCmdStr)\n\terr = sshSession.Start(shWrappedCmdStr)\n\tif err != nil {\n\t\treturn false, \"\", \"\", fmt.Errorf(\"unable to start conn controller command: %w\", err)\n\t}\n\tlinesChan := utilfn.StreamToLinesChan(pipeRead)\n\tversionLine, err := utilfn.ReadLineWithTimeout(linesChan, utilfn.TimeoutFromContext(ctx, 30*time.Second))\n\tif err != nil {\n\t\tsshSession.Close()\n\t\treturn false, \"\", \"\", fmt.Errorf(\"error reading wsh version: %w\", err)\n\t}\n\tconn.Infof(ctx, \"actual connnserverversion: %q\\n\", versionLine)\n\tconn.Infof(ctx, \"got connserver version: %s\\n\", strings.TrimSpace(versionLine))\n\tisUpToDate, clientVersion, osArchStr, err := IsWshVersionUpToDate(ctx, versionLine)\n\tif err != nil {\n\t\tsshSession.Close()\n\t\treturn false, \"\", \"\", fmt.Errorf(\"error checking wsh version: %w\", err)\n\t}\n\tif isUpToDate && !afterUpdate && os.Getenv(wavebase.WaveWshForceUpdateVarName) != \"\" {\n\t\tisUpToDate = false\n\t\tconn.Infof(ctx, \"%s set, forcing wsh update\\n\", wavebase.WaveWshForceUpdateVarName)\n\t}\n\tconn.Infof(ctx, \"connserver up-to-date: %v\\n\", isUpToDate)\n\tif !isUpToDate {\n\t\tsshSession.Close()\n\t\treturn true, clientVersion, osArchStr, nil\n\t}\n\tjwtLine, err := utilfn.ReadLineWithTimeout(linesChan, 3*time.Second)\n\tif err != nil {\n\t\tsshSession.Close()\n\t\treturn false, clientVersion, \"\", fmt.Errorf(\"error reading jwt status line: %w\", err)\n\t}\n\tconn.Infof(ctx, \"got jwt status line: %s\\n\", jwtLine)\n\tif strings.TrimSpace(jwtLine) == wavebase.NeedJwtConst {\n\t\t// write the jwt\n\t\tconn.Infof(ctx, \"writing jwt token to connserver\\n\")\n\t\t_, err = fmt.Fprintf(stdinPipe, \"%s\\n\", jwtToken)\n\t\tif err != nil {\n\t\t\tsshSession.Close()\n\t\t\treturn false, clientVersion, \"\", fmt.Errorf(\"failed to write JWT token: %w\", err)\n\t\t}\n\t}\n\tconn.WithLock(func() {\n\t\tconn.ConnController = sshSession\n\t})\n\t// service the I/O\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"conncontroller:sshSession.Wait\", recover())\n\t\t}()\n\t\t// wait for termination, clear the controller\n\t\tvar waitErr error\n\t\tdefer conn.WithLock(func() {\n\t\t\tif conn.ConnController != nil {\n\t\t\t\tconn.WshEnabled.Store(false)\n\t\t\t\tconn.NoWshReason = \"connserver terminated\"\n\t\t\t\tif waitErr != nil {\n\t\t\t\t\tconn.WshError = fmt.Sprintf(\"connserver terminated unexpectedly with error: %v\", waitErr)\n\t\t\t\t}\n\t\t\t}\n\t\t\tconn.ConnController = nil\n\t\t})\n\t\twaitErr = sshSession.Wait()\n\t\tlog.Printf(\"conn controller (%q) terminated: %v\", conn.GetName(), waitErr)\n\t}()\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"conncontroller:sshSession-output\", recover())\n\t\t}()\n\t\tfor output := range linesChan {\n\t\t\tif output.Error != nil {\n\t\t\t\tlog.Printf(\"[conncontroller:%s:output] error: %v\\n\", conn.GetName(), output.Error)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tmonitor := conn.GetMonitor()\n\t\t\tif monitor != nil {\n\t\t\t\tmonitor.UpdateLastActivityTime()\n\t\t\t}\n\t\t\tline := output.Line\n\t\t\tif !strings.HasSuffix(line, \"\\n\") {\n\t\t\t\tline += \"\\n\"\n\t\t\t}\n\t\t\tlog.Printf(\"[conncontroller:%s:output] %s\", conn.GetName(), line)\n\t\t}\n\t}()\n\tconn.Infof(ctx, \"connserver started, waiting for route to be registered\\n\")\n\tregCtx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\tconnRoute := wshutil.MakeConnectionRouteId(rpcCtx.Conn)\n\terr = wshutil.DefaultRouter.WaitForRegister(regCtx, connRoute)\n\tif err != nil {\n\t\treturn false, clientVersion, \"\", fmt.Errorf(\"timeout waiting for connserver to register\")\n\t}\n\ttime.Sleep(300 * time.Millisecond) // TODO remove this sleep (but we need to wait until connserver is \"ready\")\n\terr = wshclient.ConnServerInitCommand(\n\t\twshclient.GetBareRpcClient(),\n\t\twshrpc.CommandConnServerInitData{ClientId: wstore.GetClientId()},\n\t\t&wshrpc.RpcOpts{Route: connRoute},\n\t)\n\tif err != nil {\n\t\treturn false, clientVersion, \"\", fmt.Errorf(\"connserver init failed: %w\", err)\n\t}\n\tconn.Infof(ctx, \"connserver is registered and ready\\n\")\n\treturn false, clientVersion, \"\", nil\n}\n\ntype WshInstallOpts struct {\n\tForce        bool\n\tNoUserPrompt bool\n}\n\nvar queryTextTemplate = strings.TrimSpace(`\nWave requires Wave Shell Extensions to be\ninstalled on %q\nto ensure a seamless experience.\n\nWould you like to install them?\n`)\n\nfunc (conn *SSHConn) UpdateWsh(ctx context.Context, clientDisplayName string, remoteInfo *wshrpc.RemoteInfo) error {\n\tconn.Infof(ctx, \"attempting to update wsh for connection %s (os:%s arch:%s version:%s)\\n\",\n\t\tconn.GetName(), remoteInfo.ClientOs, remoteInfo.ClientArch, remoteInfo.ClientVersion)\n\tclient := conn.GetClient()\n\tif client == nil {\n\t\treturn fmt.Errorf(\"cannot update wsh: ssh client is not connected\")\n\t}\n\terr := remote.CpWshToRemote(ctx, client, remoteInfo.ClientOs, remoteInfo.ClientArch)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error installing wsh to remote: %w\", err)\n\t}\n\tconn.Infof(ctx, \"successfully updated wsh on %s\\n\", conn.GetName())\n\treturn nil\n\n}\n\n// returns (allowed, error)\nfunc (conn *SSHConn) getPermissionToInstallWsh(ctx context.Context, clientDisplayName string) (bool, error) {\n\tconn.Infof(ctx, \"running getPermissionToInstallWsh...\\n\")\n\tqueryText := fmt.Sprintf(queryTextTemplate, clientDisplayName)\n\ttitle := \"Install Wave Shell Extensions\"\n\trequest := &userinput.UserInputRequest{\n\t\tResponseType: \"confirm\",\n\t\tQueryText:    queryText,\n\t\tTitle:        title,\n\t\tMarkdown:     true,\n\t\tCheckBoxMsg:  \"Automatically install for all connections\",\n\t\tOkLabel:      \"Install wsh\",\n\t\tCancelLabel:  \"No wsh\",\n\t}\n\tconn.Infof(ctx, \"requesting user confirmation...\\n\")\n\tresponse, err := userinput.GetUserInput(ctx, request)\n\tif err != nil {\n\t\tconn.Infof(ctx, \"error getting user input: %v\\n\", err)\n\t\treturn false, err\n\t}\n\tconn.Infof(ctx, \"user response to allowing wsh: %v\\n\", response.Confirm)\n\tmeta := make(map[string]any)\n\tmeta[\"conn:wshenabled\"] = response.Confirm\n\tconn.Infof(ctx, \"writing conn:wshenabled=%v to connections.json\\n\", response.Confirm)\n\terr = wconfig.SetConnectionsConfigValue(conn.GetName(), meta)\n\tif err != nil {\n\t\tlog.Printf(\"warning: error writing to connections file: %v\", err)\n\t}\n\tif !response.Confirm {\n\t\treturn false, nil\n\t}\n\tif response.CheckboxStat {\n\t\tconn.Infof(ctx, \"writing conn:askbeforewshinstall=false to settings.json\\n\")\n\t\tmeta := waveobj.MetaMapType{\n\t\t\twconfig.ConfigKey_ConnAskBeforeWshInstall: false,\n\t\t}\n\t\tsetConfigErr := wconfig.SetBaseConfigValue(meta)\n\t\tif setConfigErr != nil {\n\t\t\t// this is not a critical error, just log and continue\n\t\t\tlog.Printf(\"warning: error writing to base config file: %v\", err)\n\t\t}\n\t}\n\treturn true, nil\n}\n\nfunc (conn *SSHConn) InstallWsh(ctx context.Context, osArchStr string) error {\n\tconn.Infof(ctx, \"running installWsh...\\n\")\n\tclient := conn.GetClient()\n\tif client == nil {\n\t\tconn.Infof(ctx, \"ERROR ssh client is not connected, cannot install\\n\")\n\t\treturn fmt.Errorf(\"ssh client is not connected, cannot install\")\n\t}\n\tvar clientOs, clientArch string\n\tvar err error\n\tif osArchStr != \"\" {\n\t\tclientOs, clientArch, err = remote.GetClientPlatformFromOsArchStr(ctx, osArchStr)\n\t} else {\n\t\tclientOs, clientArch, err = remote.GetClientPlatform(ctx, genconn.MakeSSHShellClient(client))\n\t}\n\tif err != nil {\n\t\tconn.Infof(ctx, \"ERROR detecting client platform: %v\\n\", err)\n\t\treturn fmt.Errorf(\"error detecting client platform: %w\", err)\n\t}\n\tconn.Infof(ctx, \"detected remote platform os:%s arch:%s\\n\", clientOs, clientArch)\n\terr = remote.CpWshToRemote(ctx, client, clientOs, clientArch)\n\tif err != nil {\n\t\tconn.Infof(ctx, \"ERROR copying wsh binary to remote: %v\\n\", err)\n\t\treturn fmt.Errorf(\"error copying wsh binary to remote: %w\", err)\n\t}\n\tconn.Infof(ctx, \"successfully installed wsh\\n\")\n\treturn nil\n}\n\nfunc (conn *SSHConn) GetClient() *ssh.Client {\n\tconn.lock.Lock()\n\tdefer conn.lock.Unlock()\n\treturn conn.Client\n}\n\nfunc (conn *SSHConn) GetMonitor() *ConnMonitor {\n\tconn.lock.Lock()\n\tdefer conn.lock.Unlock()\n\treturn conn.Monitor\n}\n\nfunc (conn *SSHConn) WaitForConnect(ctx context.Context) error {\n\tfor {\n\t\tstatus := conn.DeriveConnStatus()\n\t\tif status.Status == Status_Connected {\n\t\t\treturn nil\n\t\t}\n\t\tif status.Status == Status_Connecting {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn fmt.Errorf(\"context timeout\")\n\t\t\tcase <-time.After(100 * time.Millisecond):\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif status.Status == Status_Init || status.Status == Status_Disconnected {\n\t\t\treturn fmt.Errorf(\"disconnected\")\n\t\t}\n\t\tif status.Status == Status_Error {\n\t\t\treturn fmt.Errorf(\"error: %v\", status.Error)\n\t\t}\n\t\treturn fmt.Errorf(\"unknown status: %q\", status.Status)\n\t}\n}\n\n// does not return an error since that error is stored inside of SSHConn\nfunc (conn *SSHConn) Connect(ctx context.Context, connFlags *wconfig.ConnKeywords) error {\n\tconn.lifecycleLock.Lock()\n\tdefer conn.lifecycleLock.Unlock()\n\n\tblocklogger.Infof(ctx, \"\\n\")\n\tvar connectAllowed bool\n\tconn.WithLock(func() {\n\t\tif conn.Status == Status_Connecting || conn.Status == Status_Connected {\n\t\t\tconnectAllowed = false\n\t\t} else {\n\t\t\tconn.Status = Status_Connecting\n\t\t\tconn.Error = \"\"\n\t\t\tconnectAllowed = true\n\t\t}\n\t})\n\tif !connectAllowed {\n\t\tconn.Infof(ctx, \"cannot connect to %q when status is %q\\n\", conn.GetName(), conn.GetStatus())\n\t\treturn fmt.Errorf(\"cannot connect to %q when status is %q\", conn.GetName(), conn.GetStatus())\n\t}\n\tconn.Infof(ctx, \"trying to connect to %q...\\n\", conn.GetName())\n\tconn.FireConnChangeEvent()\n\terr := conn.connectInternal(ctx, connFlags)\n\tif err != nil {\n\t\terrorCode, subCode := remote.ClassifyConnError(err)\n\t\tisContextError := errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)\n\t\tconn.Infof(ctx, \"ERROR [%s] %v\\n\\n\", errorCode, err)\n\t\tconn.WithLock(func() {\n\t\t\tconn.Status = Status_Error\n\t\t\tconn.Error = err.Error()\n\t\t})\n\t\tconn.closeInternal_withlifecyclelock()\n\t\ttelemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{\n\t\t\tConn: map[string]int{\"ssh:connecterror\": 1},\n\t\t}, \"ssh-connconnect\")\n\t\ttelemetry.GoRecordTEventWrap(&telemetrydata.TEvent{\n\t\t\tEvent: \"conn:connecterror\",\n\t\t\tProps: telemetrydata.TEventProps{\n\t\t\t\tConnType:         \"ssh\",\n\t\t\t\tConnErrorCode:    errorCode,\n\t\t\t\tConnSubErrorCode: subCode,\n\t\t\t\tConnContextError: isContextError,\n\t\t\t},\n\t\t})\n\t} else {\n\t\tconn.Infof(ctx, \"successfully connected (wsh:%v)\\n\\n\", conn.WshEnabled.Load())\n\t\tconn.WithLock(func() {\n\t\t\tconn.Status = Status_Connected\n\t\t\tconn.LastConnectTime = time.Now().UnixMilli()\n\t\t\tif conn.ActiveConnNum == 0 {\n\t\t\t\tconn.ActiveConnNum = int(activeConnCounter.Add(1))\n\t\t\t}\n\t\t})\n\t\ttelemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{\n\t\t\tConn: map[string]int{\"ssh:connect\": 1},\n\t\t}, \"ssh-connconnect\")\n\t\ttelemetry.GoRecordTEventWrap(&telemetrydata.TEvent{\n\t\t\tEvent: \"conn:connect\",\n\t\t\tProps: telemetrydata.TEventProps{\n\t\t\t\tConnType: \"ssh\",\n\t\t\t},\n\t\t})\n\t}\n\tconn.FireConnChangeEvent()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// logic for saving connection and potential flags (we only save once a connection has been made successfully)\n\t// at the moment, identity files is the only saved flag\n\tvar identityFiles []string\n\texistingConnection, ok := conn.getConnectionConfig()\n\tif ok {\n\t\tidentityFiles = existingConnection.SshIdentityFile\n\t}\n\tif err != nil {\n\t\t// i do not consider this a critical failure\n\t\tlog.Printf(\"config read error: unable to save connection %s: %v\", conn.GetName(), err)\n\t}\n\n\tmeta := make(map[string]any)\n\tif connFlags.SshIdentityFile != nil {\n\t\tfor _, identityFile := range connFlags.SshIdentityFile {\n\t\t\tif utilfn.ContainsStr(identityFiles, identityFile) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tidentityFiles = append(identityFiles, connFlags.SshIdentityFile...)\n\t\t}\n\t\tmeta[\"ssh:identityfile\"] = identityFiles\n\t}\n\terr = wconfig.SetConnectionsConfigValue(conn.GetName(), meta)\n\tif err != nil {\n\t\t// i do not consider this a critical failure\n\t\tlog.Printf(\"config write error: unable to save connection %s: %v\", conn.GetName(), err)\n\t}\n\treturn nil\n}\n\nfunc (conn *SSHConn) WithLock(fn func()) {\n\tconn.lock.Lock()\n\tdefer conn.lock.Unlock()\n\tfn()\n}\n\nfunc WithLockRtn[T any](conn *SSHConn, fn func() T) T {\n\tconn.lock.Lock()\n\tdefer conn.lock.Unlock()\n\treturn fn()\n}\n\n// returns (enable-wsh, ask-before-install)\nfunc (conn *SSHConn) getConnWshSettings() (bool, bool) {\n\tconfig := wconfig.GetWatcher().GetFullConfig()\n\tenableWsh := config.Settings.ConnWshEnabled\n\taskBeforeInstall := wconfig.DefaultBoolPtr(config.Settings.ConnAskBeforeWshInstall, true)\n\tconnSettings, ok := conn.getConnectionConfig()\n\tif ok {\n\t\tif connSettings.ConnWshEnabled != nil {\n\t\t\tenableWsh = *connSettings.ConnWshEnabled\n\t\t}\n\t\t// if the connection object exists, and conn:askbeforewshinstall is not set, the user must have allowed it\n\t\t// TODO: in v0.12+ this should be removed.  we'll explicitly write a \"false\" into the connection object on successful connection\n\t\tif connSettings.ConnAskBeforeWshInstall == nil {\n\t\t\taskBeforeInstall = false\n\t\t} else {\n\t\t\taskBeforeInstall = *connSettings.ConnAskBeforeWshInstall\n\t\t}\n\t}\n\treturn enableWsh, askBeforeInstall\n}\n\ntype WshCheckResult struct {\n\tWshEnabled    bool\n\tClientVersion string\n\tNoWshReason   string\n\tNoWshCode     string\n\tWshError      error\n}\n\n// returns (wsh-enabled, clientVersion, text-reason, wshError)\nfunc (conn *SSHConn) tryEnableWsh(ctx context.Context, clientDisplayName string) WshCheckResult {\n\tconn.Infof(ctx, \"running tryEnableWsh...\\n\")\n\tenableWsh, askBeforeInstall := conn.getConnWshSettings()\n\tconn.Infof(ctx, \"wsh settings enable:%v ask:%v\\n\", enableWsh, askBeforeInstall)\n\tif !enableWsh {\n\t\treturn WshCheckResult{NoWshReason: \"conn:wshenabled set to false\", NoWshCode: NoWshCode_Disabled}\n\t}\n\tif askBeforeInstall {\n\t\tallowInstall, err := conn.getPermissionToInstallWsh(ctx, clientDisplayName)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error getting permission to install wsh: %v\\n\", err)\n\t\t\treturn WshCheckResult{NoWshReason: \"error getting user permission to install\", NoWshCode: NoWshCode_PermissionError, WshError: err}\n\t\t}\n\t\tif !allowInstall {\n\t\t\treturn WshCheckResult{NoWshReason: \"user selected not to install wsh extensions\", NoWshCode: NoWshCode_UserDeclined}\n\t\t}\n\t}\n\terr := conn.OpenDomainSocketListener(ctx)\n\tif err != nil {\n\t\tconn.Infof(ctx, \"ERROR opening domain socket listener: %v\\n\", err)\n\t\terr = fmt.Errorf(\"error opening domain socket listener: %w\", err)\n\t\treturn WshCheckResult{NoWshReason: \"error opening domain socket\", NoWshCode: NoWshCode_DomainSocketError, WshError: err}\n\t}\n\tneedsInstall, clientVersion, osArchStr, err := conn.StartConnServer(ctx, false, true)\n\tif err != nil {\n\t\tconn.Infof(ctx, \"ERROR starting conn server: %v\\n\", err)\n\t\terr = fmt.Errorf(\"error starting conn server: %w\", err)\n\t\treturn WshCheckResult{NoWshReason: \"error starting connserver\", NoWshCode: NoWshCode_ConnServerStartError, WshError: err}\n\t}\n\tif needsInstall {\n\t\tconn.Infof(ctx, \"connserver needs to be (re)installed\\n\")\n\t\terr = conn.InstallWsh(ctx, osArchStr)\n\t\tif err != nil {\n\t\t\tconn.Infof(ctx, \"ERROR installing wsh: %v\\n\", err)\n\t\t\terr = fmt.Errorf(\"error installing wsh: %w\", err)\n\t\t\treturn WshCheckResult{NoWshReason: \"error installing wsh/connserver\", NoWshCode: NoWshCode_InstallError, WshError: err}\n\t\t}\n\t\tneedsInstall, clientVersion, _, err = conn.StartConnServer(ctx, true, true)\n\t\tif err != nil {\n\t\t\tconn.Infof(ctx, \"ERROR starting conn server (after install): %v\\n\", err)\n\t\t\terr = fmt.Errorf(\"error starting conn server (after install): %w\", err)\n\t\t\treturn WshCheckResult{NoWshReason: \"error starting connserver\", NoWshCode: NoWshCode_PostInstallStartError, WshError: err}\n\t\t}\n\t\tif needsInstall {\n\t\t\tconn.Infof(ctx, \"conn server not installed correctly (after install)\\n\")\n\t\t\terr = fmt.Errorf(\"conn server not installed correctly (after install)\")\n\t\t\treturn WshCheckResult{NoWshReason: \"connserver not installed properly\", NoWshCode: NoWshCode_InstallVerifyError, WshError: err}\n\t\t}\n\t\treturn WshCheckResult{WshEnabled: true, ClientVersion: clientVersion}\n\t} else {\n\t\treturn WshCheckResult{WshEnabled: true, ClientVersion: clientVersion}\n\t}\n}\n\nfunc (conn *SSHConn) getConnectionConfig() (wconfig.ConnKeywords, bool) {\n\tconfig := wconfig.GetWatcher().GetFullConfig()\n\tconnSettings, ok := config.Connections[conn.GetName()]\n\tif !ok {\n\t\treturn wconfig.ConnKeywords{}, false\n\t}\n\treturn connSettings, true\n}\n\nfunc (conn *SSHConn) persistWshInstalled(ctx context.Context, result WshCheckResult) {\n\tconn.WshEnabled.Store(result.WshEnabled)\n\tconn.SetWshError(result.WshError)\n\tconn.WithLock(func() {\n\t\tconn.NoWshReason = result.NoWshReason\n\t\tconn.WshVersion = result.ClientVersion\n\t})\n\tconnConfig, ok := conn.getConnectionConfig()\n\tif ok && connConfig.ConnWshEnabled != nil {\n\t\treturn\n\t}\n\tmeta := make(map[string]any)\n\tmeta[\"conn:wshenabled\"] = result.WshEnabled\n\terr := wconfig.SetConnectionsConfigValue(conn.GetName(), meta)\n\tif err != nil {\n\t\tconn.Infof(ctx, \"WARN could not write conn:wshenabled=%v to connections.json: %v\\n\", result.WshEnabled, err)\n\t\tlog.Printf(\"warning: error writing to connections file: %v\", err)\n\t}\n\t// doesn't return an error since none of this is required for connection to work\n}\n\n// returns (connect-error)\nfunc (conn *SSHConn) connectInternal(ctx context.Context, connFlags *wconfig.ConnKeywords) error {\n\tconn.Infof(ctx, \"connectInternal %s\\n\", conn.GetName())\n\tclient, _, err := remote.ConnectToClient(ctx, conn.Opts, nil, 0, connFlags)\n\tif err != nil {\n\t\tconn.Infof(ctx, \"ERROR ConnectToClient: %s\\n\", remote.SimpleMessageFromPossibleConnectionError(err))\n\t\tlog.Printf(\"error: failed to connect to client %s: %s\\n\", conn.GetName(), err)\n\t\treturn err\n\t}\n\tconn.WithLock(func() {\n\t\tif conn.Monitor != nil {\n\t\t\tconn.Monitor.Close()\n\t\t\tconn.Monitor = nil\n\t\t}\n\t\tconn.Client = client\n\t\tconn.ConnHealthStatus = ConnHealthStatus_Good\n\t\tconn.Monitor = MakeConnMonitor(conn, client)\n\t})\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"conncontroller:waitForDisconnect\", recover())\n\t\t}()\n\t\tconn.waitForDisconnect()\n\t}()\n\tfmtAddr := knownhosts.Normalize(fmt.Sprintf(\"%s@%s\", client.User(), client.RemoteAddr().String()))\n\tconn.Infof(ctx, \"normalized knownhosts address: %s\\n\", fmtAddr)\n\tclientDisplayName := fmt.Sprintf(\"%s (%s)\", conn.GetName(), fmtAddr)\n\twshResult := conn.tryEnableWsh(ctx, clientDisplayName)\n\tif !wshResult.WshEnabled {\n\t\tif wshResult.WshError != nil {\n\t\t\tconn.Infof(ctx, \"ERROR enabling wsh: %v\\n\", wshResult.WshError)\n\t\t\tconn.Infof(ctx, \"will connect with wsh disabled\\n\")\n\t\t} else {\n\t\t\tconn.Infof(ctx, \"wsh not enabled: %s\\n\", wshResult.NoWshReason)\n\t\t}\n\t\ttelemetry.GoRecordTEventWrap(&telemetrydata.TEvent{\n\t\t\tEvent: \"conn:nowsh\",\n\t\t\tProps: telemetrydata.TEventProps{\n\t\t\t\tConnType:         \"ssh\",\n\t\t\t\tConnWshErrorCode: wshResult.NoWshCode,\n\t\t\t},\n\t\t})\n\t}\n\tconn.persistWshInstalled(ctx, wshResult)\n\treturn nil\n}\n\nfunc (conn *SSHConn) waitForDisconnect() {\n\tdefer conn.FireConnChangeEvent()\n\tclient := conn.GetClient()\n\tif client == nil {\n\t\treturn\n\t}\n\terr := client.Wait()\n\tif err != nil {\n\t\tlog.Printf(\"[conn:%s] client.Wait() returned error: %v\", conn.GetName(), err)\n\t} else {\n\t\tlog.Printf(\"[conn:%s] client.Wait() completed (clean disconnect)\", conn.GetName())\n\t}\n\tconn.lifecycleLock.Lock()\n\tdefer conn.lifecycleLock.Unlock()\n\tconn.WithLock(func() {\n\t\t// disconnects happen for a variety of reasons (like network, etc. and are typically transient)\n\t\t// so we just set the status to \"disconnected\" here (not error)\n\t\t// don't overwrite any existing error (or error status)\n\t\tif err != nil && conn.Error == \"\" {\n\t\t\tconn.Error = err.Error()\n\t\t}\n\t\tif conn.Status != Status_Error {\n\t\t\tconn.Status = Status_Disconnected\n\t\t}\n\t})\n\tconn.closeInternal_withlifecyclelock()\n}\n\nfunc (conn *SSHConn) SetWshError(err error) {\n\tconn.WithLock(func() {\n\t\tif err == nil {\n\t\t\tconn.WshError = \"\"\n\t\t} else {\n\t\t\tconn.WshError = err.Error()\n\t\t}\n\t})\n}\n\nfunc (conn *SSHConn) ClearWshError() {\n\tconn.WithLock(func() {\n\t\tconn.WshError = \"\"\n\t})\n}\n\nfunc (conn *SSHConn) SetConnHealthStatus(client *ssh.Client, status string) {\n\tchanged := false\n\tconn.WithLock(func() {\n\t\tif conn.Client != client {\n\t\t\treturn\n\t\t}\n\t\tif conn.ConnHealthStatus != status {\n\t\t\tconn.ConnHealthStatus = status\n\t\t\tchanged = true\n\t\t}\n\t})\n\tif changed {\n\t\tconn.FireConnChangeEvent()\n\t}\n}\n\nfunc (conn *SSHConn) GetConnHealthStatus() string {\n\tvar status string\n\tconn.WithLock(func() {\n\t\tstatus = conn.ConnHealthStatus\n\t})\n\treturn status\n}\n\nfunc getConnInternal(opts *remote.SSHOpts, createIfNotExists bool) *SSHConn {\n\tglobalLock.Lock()\n\tdefer globalLock.Unlock()\n\trtn := clientControllerMap[*opts]\n\tif rtn == nil && createIfNotExists {\n\t\trtn = &SSHConn{\n\t\t\tlock:             &sync.Mutex{},\n\t\t\tlifecycleLock:    &sync.Mutex{},\n\t\t\tStatus:           Status_Init,\n\t\t\tConnHealthStatus: ConnHealthStatus_Good,\n\t\t\tWshEnabled:       &atomic.Bool{},\n\t\t\tOpts:             opts,\n\t\t}\n\t\tclientControllerMap[*opts] = rtn\n\t}\n\treturn rtn\n}\n\n// does NOT connect, does not return nil\nfunc GetConn(opts *remote.SSHOpts) *SSHConn {\n\tconn := getConnInternal(opts, true)\n\treturn conn\n}\n\n// does NOT connect, can return nil\nfunc MaybeGetConn(opts *remote.SSHOpts) *SSHConn {\n\tconn := getConnInternal(opts, false)\n\treturn conn\n}\n\nfunc IsConnected(connName string) (bool, error) {\n\tif IsLocalConnName(connName) {\n\t\treturn true, nil\n\t}\n\tconnOpts, err := remote.ParseOpts(connName)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"error parsing connection name: %w\", err)\n\t}\n\tconn := getConnInternal(connOpts, false)\n\tif conn == nil {\n\t\treturn false, nil\n\t}\n\treturn conn.GetStatus() == Status_Connected, nil\n}\n\n// Convenience function for ensuring a connection is established\nfunc EnsureConnection(ctx context.Context, connName string) error {\n\tif IsLocalConnName(connName) {\n\t\treturn nil\n\t}\n\tconnOpts, err := remote.ParseOpts(connName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error parsing connection name: %w\", err)\n\t}\n\tconn := GetConn(connOpts)\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"connection not found: %s\", connName)\n\t}\n\tconnStatus := conn.DeriveConnStatus()\n\tswitch connStatus.Status {\n\tcase Status_Connected:\n\t\treturn nil\n\tcase Status_Connecting:\n\t\treturn conn.WaitForConnect(ctx)\n\tcase Status_Init, Status_Disconnected:\n\t\treturn conn.Connect(ctx, &wconfig.ConnKeywords{})\n\tcase Status_Error:\n\t\treturn fmt.Errorf(\"connection error: %s\", connStatus.Error)\n\tdefault:\n\t\treturn fmt.Errorf(\"unknown connection status %q\", connStatus.Status)\n\t}\n}\n\nfunc DisconnectClient(opts *remote.SSHOpts) error {\n\tconn := getConnInternal(opts, false)\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"client %q not found\", opts.String())\n\t}\n\terr := conn.Close()\n\treturn err\n}\n\nfunc resolveSshConfigPatterns(configFiles []string) ([]string, error) {\n\t// using two separate containers to track order and have O(1) lookups\n\t// since go does not have an ordered map primitive\n\tvar discoveredPatterns []string\n\talreadyUsed := make(map[string]bool)\n\talreadyUsed[\"\"] = true // this excludes the empty string from potential alias\n\tvar openedFiles []fs.File\n\n\tdefer func() {\n\t\tfor _, openedFile := range openedFiles {\n\t\t\topenedFile.Close()\n\t\t}\n\t}()\n\n\tvar errs []error\n\tfor _, configFile := range configFiles {\n\t\tfd, openErr := os.Open(configFile)\n\t\topenedFiles = append(openedFiles, fd)\n\t\tif fd == nil {\n\t\t\terrs = append(errs, openErr)\n\t\t\tcontinue\n\t\t}\n\n\t\tcfg, _ := ssh_config.Decode(fd, true)\n\t\tfor _, host := range cfg.Hosts {\n\t\t\t// for each host, find the first good alias\n\t\t\tfor _, hostPattern := range host.Patterns {\n\t\t\t\thostPatternStr := hostPattern.String()\n\t\t\t\tif hostPatternStr == \"\" || strings.Contains(hostPatternStr, \"*\") || strings.Contains(hostPatternStr, \"?\") || strings.Contains(hostPatternStr, \"!\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tnormalized := remote.NormalizeConfigPattern(hostPatternStr)\n\t\t\t\tif !alreadyUsed[normalized] {\n\t\t\t\t\tdiscoveredPatterns = append(discoveredPatterns, normalized)\n\t\t\t\t\talreadyUsed[normalized] = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif len(errs) == len(configFiles) {\n\t\terrs = append([]error{fmt.Errorf(\"no ssh config files could be opened: \")}, errs...)\n\t\treturn nil, errors.Join(errs...)\n\t}\n\tif len(discoveredPatterns) == 0 {\n\t\treturn nil, fmt.Errorf(\"no compatible hostnames found in ssh config files\")\n\t}\n\n\treturn discoveredPatterns, nil\n}\n\nfunc GetConnectionsList() ([]string, error) {\n\texisting := GetAllConnStatus()\n\tvar currentlyRunning []string\n\tvar hasConnected []string\n\n\t// populate all lists\n\tfor _, stat := range existing {\n\t\tif stat.Connected {\n\t\t\tcurrentlyRunning = append(currentlyRunning, stat.Connection)\n\t\t}\n\n\t\tif stat.HasConnected {\n\t\t\thasConnected = append(hasConnected, stat.Connection)\n\t\t}\n\t}\n\n\tfromInternal := GetConnectionsFromInternalConfig()\n\n\tfromConfig, err := GetConnectionsFromConfig()\n\tif err != nil {\n\t\t// this is not a fatal error. do not return\n\t\tlog.Printf(\"warning: no connections from ssh config found: %v\", err)\n\t}\n\n\t// sort into one final list and remove duplicates\n\talreadyUsed := make(map[string]struct{})\n\tvar connList []string\n\n\tfor _, subList := range [][]string{currentlyRunning, hasConnected, fromInternal, fromConfig} {\n\t\tfor _, pattern := range subList {\n\t\t\tif _, used := alreadyUsed[pattern]; !used {\n\t\t\t\tconnList = append(connList, pattern)\n\t\t\t\talreadyUsed[pattern] = struct{}{}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn connList, nil\n}\n\nfunc GetConnectionsFromInternalConfig() []string {\n\tvar internalNames []string\n\tconfig := wconfig.GetWatcher().GetFullConfig()\n\tfor internalName := range config.Connections {\n\t\tif strings.HasPrefix(internalName, \"wsl://\") {\n\t\t\t// don't add wsl conns to this list\n\t\t\tcontinue\n\t\t}\n\t\tinternalNames = append(internalNames, internalName)\n\t}\n\treturn internalNames\n}\n\nfunc GetConnectionsFromConfig() ([]string, error) {\n\thome := wavebase.GetHomeDir()\n\tlocalConfig := filepath.Join(home, \".ssh\", \"config\")\n\tsystemConfig := filepath.Join(\"/etc\", \"ssh\", \"config\")\n\tsshConfigFiles := []string{localConfig, systemConfig}\n\tremote.WaveSshConfigUserSettings().ReloadConfigs()\n\n\treturn resolveSshConfigPatterns(sshConfigFiles)\n}\n"
  },
  {
    "path": "pkg/remote/conncontroller/connmonitor.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage conncontroller\n\nimport (\n\t\"context\"\n\t\"log\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\n// Lock ordering: conn.lock > cm.lock (conn.lock is outer, cm.lock is inner)\n// CRITICAL: Methods that hold cm.lock must NEVER call into SSHConn (deadlock - violates ordering).\n// Methods called from SSHConn while conn.lock is held should avoid acquiring cm.lock (keep locking simple).\ntype ConnMonitor struct {\n\tlock              *sync.Mutex\n\tConn              *SSHConn    // always non-nil, set at creation\n\tClient            *ssh.Client // always non-nil, set at creation\n\tLastActivityTime  atomic.Int64\n\tLastInputTime     atomic.Int64\n\tKeepAliveSentTime atomic.Int64\n\tKeepAliveInFlight bool\n\tctx               context.Context\n\tcancelFunc        context.CancelFunc\n\tinputNotifyCh     chan int64\n}\n\nfunc MakeConnMonitor(conn *SSHConn, client *ssh.Client) *ConnMonitor {\n\tif conn == nil {\n\t\tpanic(\"conn cannot be nil\")\n\t}\n\tif client == nil {\n\t\tpanic(\"client cannot be nil\")\n\t}\n\tctx, cancelFunc := context.WithCancel(context.Background())\n\tcm := &ConnMonitor{\n\t\tlock:          &sync.Mutex{},\n\t\tConn:          conn,\n\t\tClient:        client,\n\t\tctx:           ctx,\n\t\tcancelFunc:    cancelFunc,\n\t\tinputNotifyCh: make(chan int64, 1),\n\t}\n\tgo cm.keepAliveMonitor()\n\treturn cm\n}\n\n// setConnHealthStatus calls into SSHConn.SetConnHealthStatus\n// CRITICAL: cm.lock must NOT be held when calling this method (violates lock ordering)\nfunc (cm *ConnMonitor) setConnHealthStatus(status string) {\n\tcm.Conn.SetConnHealthStatus(cm.Client, status)\n}\n\nfunc (cm *ConnMonitor) UpdateLastActivityTime() {\n\tcm.LastActivityTime.Store(time.Now().UnixMilli())\n\tcm.setConnHealthStatus(ConnHealthStatus_Good)\n}\n\nfunc (cm *ConnMonitor) NotifyInput() {\n\tinputTime := time.Now().UnixMilli()\n\tcm.LastInputTime.Store(inputTime)\n\tselect {\n\tcase cm.inputNotifyCh <- inputTime:\n\tdefault:\n\t}\n}\n\nfunc (cm *ConnMonitor) isUrgent() bool {\n\tlastInput := cm.LastInputTime.Load()\n\tif lastInput == 0 {\n\t\treturn false\n\t}\n\treturn time.Now().UnixMilli()-lastInput < 10000\n}\n\nfunc (cm *ConnMonitor) setKeepAliveInFlight() bool {\n\tcm.lock.Lock()\n\tdefer cm.lock.Unlock()\n\n\tif cm.KeepAliveInFlight {\n\t\treturn false\n\t}\n\tcm.KeepAliveInFlight = true\n\tcm.KeepAliveSentTime.Store(time.Now().UnixMilli())\n\treturn true\n}\n\nfunc (cm *ConnMonitor) clearKeepAliveInFlight() {\n\tcm.lock.Lock()\n\tdefer cm.lock.Unlock()\n\n\tcm.KeepAliveInFlight = false\n}\n\nfunc (cm *ConnMonitor) getTimeSinceKeepAlive() int64 {\n\tcm.lock.Lock()\n\tdefer cm.lock.Unlock()\n\n\tif !cm.KeepAliveInFlight {\n\t\treturn 0\n\t}\n\treturn time.Now().UnixMilli() - cm.KeepAliveSentTime.Load()\n}\n\nfunc (cm *ConnMonitor) SendKeepAlive() error {\n\tclient := cm.Client\n\tif !cm.setKeepAliveInFlight() {\n\t\treturn nil\n\t}\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"conncontroller:SendKeepAlive\", recover())\n\t\t}()\n\t\tdefer cm.clearKeepAliveInFlight()\n\t\tstartTime := time.Now()\n\t\t_, _, err := client.SendRequest(\"keepalive@openssh.com\", true, nil)\n\t\tif err != nil {\n\t\t\t// errors are only returned for network and I/O issues (likely disconnection). do not update last activity time\n\t\t\tduration := time.Since(startTime).Milliseconds()\n\t\t\tlog.Printf(\"[conncontroller] conn:%s keepalive error (duration=%dms): %v\", cm.Conn.GetName(), duration, err)\n\t\t\treturn\n\t\t}\n\t\tcm.UpdateLastActivityTime()\n\t}()\n\treturn nil\n}\n\nfunc (cm *ConnMonitor) checkConnection() {\n\tlastActivity := cm.LastActivityTime.Load()\n\tif lastActivity == 0 {\n\t\treturn\n\t}\n\turgent := cm.isUrgent()\n\ttimeSinceActivity := time.Now().UnixMilli() - lastActivity\n\n\tkeepAliveThreshold := int64(10000)\n\tif urgent {\n\t\tkeepAliveThreshold = 1000\n\t}\n\tif timeSinceActivity > keepAliveThreshold {\n\t\tcm.SendKeepAlive()\n\t}\n\n\tstalledThreshold := int64(10000)\n\tif urgent {\n\t\tstalledThreshold = 5000\n\t}\n\ttimeSinceKeepAlive := cm.getTimeSinceKeepAlive()\n\tif timeSinceKeepAlive > stalledThreshold {\n\t\tcm.setConnHealthStatus(ConnHealthStatus_Stalled)\n\t}\n}\n\nfunc (cm *ConnMonitor) keepAliveMonitor() {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"conncontroller:keepAliveMonitor\", recover())\n\t}()\n\tticker := time.NewTicker(5 * time.Second)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\t// check if our client is still the active one\n\t\tif cm.Conn.GetClient() != cm.Client {\n\t\t\treturn\n\t\t}\n\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\tcm.checkConnection()\n\n\t\tcase inputTime := <-cm.inputNotifyCh:\n\t\t\tselect {\n\t\t\tcase <-time.After(1 * time.Second):\n\t\t\t\tif cm.LastActivityTime.Load() >= inputTime {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tcm.setConnHealthStatus(ConnHealthStatus_Degraded)\n\t\t\t\tcm.checkConnection()\n\t\t\tcase <-cm.ctx.Done():\n\t\t\t\treturn\n\t\t\t}\n\n\t\tcase <-cm.ctx.Done():\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (cm *ConnMonitor) Close() {\n\tif cm.cancelFunc != nil {\n\t\tcm.cancelFunc()\n\t}\n}\n"
  },
  {
    "path": "pkg/remote/connparse/connparse.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage connparse\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nconst (\n\tConnectionTypeWsh = \"wsh\"\n\n\tConnHostCurrent = \"current\"\n\tConnHostWaveSrv = \"wavesrv\"\n)\n\nvar windowsDriveRegex = regexp.MustCompile(`^[a-zA-Z]:`)\nvar wslConnRegex = regexp.MustCompile(`^wsl://[^/]+`)\n\ntype Connection struct {\n\tScheme string\n\tHost   string\n\tPath   string\n}\n\nfunc (c *Connection) GetSchemeParts() []string {\n\treturn strings.Split(c.Scheme, \":\")\n}\n\nfunc (c *Connection) GetType() string {\n\tlastInd := strings.LastIndex(c.Scheme, \":\")\n\tif lastInd == -1 {\n\t\treturn c.Scheme\n\t}\n\treturn c.Scheme[lastInd+1:]\n}\n\nfunc (c *Connection) GetPathWithHost() string {\n\tif c.Host == \"\" {\n\t\treturn \"\"\n\t}\n\tif c.Path == \"\" {\n\t\treturn c.Host\n\t}\n\tif strings.HasPrefix(c.Path, \"/\") {\n\t\treturn c.Host + c.Path\n\t}\n\treturn c.Host + \"/\" + c.Path\n}\n\nfunc (c *Connection) GetFullURI() string {\n\treturn c.Scheme + \"://\" + c.GetPathWithHost()\n}\n\nfunc (c *Connection) GetSchemeAndHost() string {\n\treturn c.Scheme + \"://\" + c.Host\n}\n\nfunc ParseURIAndReplaceCurrentHost(ctx context.Context, uri string) (*Connection, error) {\n\tconn, err := ParseURI(uri)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error parsing connection: %v\", err)\n\t}\n\tif conn.Host == ConnHostCurrent {\n\t\tsource, err := GetConnNameFromContext(ctx)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error getting connection name from context: %v\", err)\n\t\t}\n\n\t\t// RPC context connection is empty for local connections\n\t\tif source == \"\" {\n\t\t\tsource = wshrpc.LocalConnName\n\t\t}\n\t\tconn.Host = source\n\t}\n\treturn conn, nil\n}\n\nfunc GetConnNameFromContext(ctx context.Context) (string, error) {\n\thandler := wshutil.GetRpcResponseHandlerFromContext(ctx)\n\tif handler == nil {\n\t\treturn \"\", fmt.Errorf(\"error getting rpc response handler from context\")\n\t}\n\treturn handler.GetRpcContext().Conn, nil\n}\n\n// ParseURI parses a connection URI and returns the connection type, host/path, and parameters.\nfunc ParseURI(uri string) (*Connection, error) {\n\tisWshShorthand := strings.HasPrefix(uri, \"//\")\n\tsplit := strings.SplitN(uri, \"://\", 2)\n\tvar scheme string\n\tvar rest string\n\tif isWshShorthand {\n\t\trest = strings.TrimPrefix(uri, \"//\")\n\t} else if len(split) > 1 {\n\t\tscheme = split[0]\n\t\trest = strings.TrimPrefix(split[1], \"//\")\n\t} else {\n\t\trest = split[0]\n\t}\n\n\tvar host string\n\tvar remotePath string\n\n\tparseGenericPath := func() {\n\t\tsplit = strings.SplitN(rest, \"/\", 2)\n\t\thost = split[0]\n\t\tif len(split) > 1 && split[1] != \"\" {\n\t\t\tremotePath = split[1]\n\t\t} else if strings.HasSuffix(rest, \"/\") {\n\t\t\t// preserve trailing slash\n\t\t\tremotePath = \"/\"\n\t\t} else {\n\t\t\tremotePath = \"\"\n\t\t}\n\t}\n\tparseWshPath := func() {\n\t\tif strings.HasPrefix(rest, \"wsl://\") {\n\t\t\thost = wslConnRegex.FindString(rest)\n\t\t\tremotePath = strings.TrimPrefix(rest, host)\n\t\t} else {\n\t\t\tparseGenericPath()\n\t\t}\n\t}\n\n\taddPrecedingSlash := true\n\n\tif scheme == \"\" {\n\t\tscheme = ConnectionTypeWsh\n\t\taddPrecedingSlash = false\n\t\tif isWshShorthand {\n\t\t\tparseWshPath()\n\t\t} else if strings.HasPrefix(rest, \"/~\") {\n\t\t\thost = wshrpc.LocalConnName\n\t\t\tremotePath = rest\n\t\t} else {\n\t\t\thost = ConnHostCurrent\n\t\t\tremotePath = rest\n\t\t}\n\t} else if scheme == ConnectionTypeWsh {\n\t\tparseWshPath()\n\t} else {\n\t\tparseGenericPath()\n\t}\n\n\tif scheme == ConnectionTypeWsh {\n\t\tif host == \"\" {\n\t\t\thost = wshrpc.LocalConnName\n\t\t}\n\t\tif strings.HasPrefix(remotePath, \"/~\") {\n\t\t\tremotePath = strings.TrimPrefix(remotePath, \"/\")\n\t\t} else if addPrecedingSlash && (len(remotePath) > 1 && !windowsDriveRegex.MatchString(remotePath) && !strings.HasPrefix(remotePath, \"/\") && !strings.HasPrefix(remotePath, \"~\") && !strings.HasPrefix(remotePath, \"./\") && !strings.HasPrefix(remotePath, \"../\") && !strings.HasPrefix(remotePath, \".\\\\\") && !strings.HasPrefix(remotePath, \"..\\\\\") && remotePath != \"..\") {\n\t\t\tremotePath = \"/\" + remotePath\n\t\t}\n\t}\n\n\tconn := &Connection{\n\t\tScheme: scheme,\n\t\tHost:   host,\n\t\tPath:   remotePath,\n\t}\n\treturn conn, nil\n}\n"
  },
  {
    "path": "pkg/remote/connparse/connparse_test.go",
    "content": "package connparse_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/remote/connparse\"\n)\n\nfunc TestParseURI_WSHWithScheme(t *testing.T) {\n\tt.Parallel()\n\n\t// Test with localhost\n\tcstr := \"wsh://user@localhost:8080/path/to/file\"\n\tc, err := connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected := \"/path/to/file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t}\n\texpected = \"user@localhost:8080\"\n\tif c.Host != expected {\n\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t}\n\texpected = \"user@localhost:8080/path/to/file\"\n\tpathWithHost := c.GetPathWithHost()\n\tif pathWithHost != expected {\n\t\tt.Fatalf(\"expected path with host to be \\\"%q\\\", got \\\"%q\\\"\", expected, pathWithHost)\n\t}\n\texpected = \"wsh\"\n\tif c.Scheme != expected {\n\t\tt.Fatalf(\"expected scheme to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t}\n\tif len(c.GetSchemeParts()) != 1 {\n\t\tt.Fatalf(\"expected scheme parts to be 1, got %d\", len(c.GetSchemeParts()))\n\t}\n\n\t// Test with an IP address\n\tcstr = \"wsh://user@192.168.0.1:22/path/to/file\"\n\tc, err = connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected = \"/path/to/file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t}\n\texpected = \"user@192.168.0.1:22\"\n\tif c.Host != expected {\n\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t}\n\texpected = \"user@192.168.0.1:22/path/to/file\"\n\tpathWithHost = c.GetPathWithHost()\n\tif pathWithHost != expected {\n\t\tt.Fatalf(\"expected path with host to be \\\"%q\\\", got \\\"%q\\\"\", expected, pathWithHost)\n\t}\n\texpected = \"wsh\"\n\tif c.GetType() != expected {\n\t\tt.Fatalf(\"expected conn type to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t}\n\tif len(c.GetSchemeParts()) != 1 {\n\t\tt.Fatalf(\"expected scheme parts to be 1, got %d\", len(c.GetSchemeParts()))\n\t}\n\tgot := c.GetFullURI()\n\tif got != cstr {\n\t\tt.Fatalf(\"expected full URI to be \\\"%q\\\", got \\\"%q\\\"\", cstr, got)\n\t}\n}\n\nfunc TestParseURI_WSHRemoteShorthand(t *testing.T) {\n\tt.Parallel()\n\n\t// Test with a simple remote path\n\tcstr := \"//conn/path/to/file\"\n\tc, err := connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected := \"path/to/file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t}\n\texpected = \"conn\"\n\tif c.Host != expected {\n\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t}\n\texpected = \"wsh\"\n\tif c.Scheme != expected {\n\t\tt.Fatalf(\"expected scheme to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t}\n\texpected = \"wsh://conn/path/to/file\"\n\tif c.GetFullURI() != expected {\n\t\tt.Fatalf(\"expected full URI to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.GetFullURI())\n\t}\n\n\t// Test with a complex remote path\n\tcstr = \"//user@localhost:8080/path/to/file\"\n\tc, err = connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected = \"path/to/file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t}\n\texpected = \"user@localhost:8080\"\n\tif c.Host != expected {\n\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t}\n\texpected = \"wsh\"\n\tif c.Scheme != expected {\n\t\tt.Fatalf(\"expected scheme to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t}\n\texpected = \"wsh://user@localhost:8080/path/to/file\"\n\tif c.GetFullURI() != expected {\n\t\tt.Fatalf(\"expected full URI to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.GetFullURI())\n\t}\n\n\t// Test with an IP address\n\tcstr = \"//user@192.168.0.1:8080/path/to/file\"\n\tc, err = connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected = \"path/to/file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t}\n\texpected = \"user@192.168.0.1:8080\"\n\tif c.Host != expected {\n\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t}\n\texpected = \"wsh\"\n\tif c.Scheme != expected {\n\t\tt.Fatalf(\"expected scheme to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t}\n\texpected = \"wsh://user@192.168.0.1:8080/path/to/file\"\n\tif c.GetFullURI() != expected {\n\t\tt.Fatalf(\"expected full URI to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.GetFullURI())\n\t}\n}\n\nfunc TestParseURI_WSHCurrentPathShorthand(t *testing.T) {\n\tt.Parallel()\n\n\t// Test with a relative path to home\n\tcstr := \"~/path/to/file\"\n\tc, err := connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected := \"~/path/to/file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t}\n\texpected = \"current\"\n\tif c.Host != expected {\n\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t}\n\texpected = \"wsh\"\n\tif c.Scheme != expected {\n\t\tt.Fatalf(\"expected scheme to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t}\n\texpected = \"wsh://current/~/path/to/file\"\n\tif c.GetFullURI() != expected {\n\t\tt.Fatalf(\"expected full URI to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.GetFullURI())\n\t}\n\n\t// Test with a absolute path\n\tcstr = \"/path/to/file\"\n\tc, err = connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"expected nil, got %v\", err)\n\t}\n\texpected = \"/path/to/file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t}\n\texpected = \"current\"\n\tif c.Host != expected {\n\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t}\n\texpected = \"wsh\"\n\tif c.Scheme != expected {\n\t\tt.Fatalf(\"expected scheme to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t}\n\texpected = \"wsh://current/path/to/file\"\n\tif c.GetFullURI() != expected {\n\t\tt.Fatalf(\"expected full URI to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.GetFullURI())\n\t}\n}\n\nfunc TestParseURI_WSHCurrentPath(t *testing.T) {\n\tcstr := \"./Documents/path/to/file\"\n\tc, err := connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected := \"./Documents/path/to/file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t}\n\texpected = \"current\"\n\tif c.Host != expected {\n\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t}\n\texpected = \"wsh\"\n\tif c.Scheme != expected {\n\t\tt.Fatalf(\"expected scheme to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t}\n\texpected = \"wsh://current/./Documents/path/to/file\"\n\tif c.GetFullURI() != expected {\n\t\tt.Fatalf(\"expected full URI to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.GetFullURI())\n\t}\n\n\tcstr = \"path/to/file\"\n\tc, err = connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected = \"path/to/file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be %q, got %q\", expected, c.Path)\n\t}\n\texpected = \"current\"\n\tif c.Host != expected {\n\t\tt.Fatalf(\"expected host to be %q, got %q\", expected, c.Host)\n\t}\n\texpected = \"wsh\"\n\tif c.Scheme != expected {\n\t\tt.Fatalf(\"expected scheme to be %q, got %q\", expected, c.Scheme)\n\t}\n\texpected = \"wsh://current/path/to/file\"\n\tif c.GetFullURI() != expected {\n\t\tt.Fatalf(\"expected full URI to be %q, got %q\", expected, c.GetFullURI())\n\t}\n\n\tcstr = \"/etc/path/to/file\"\n\tc, err = connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected = \"/etc/path/to/file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be %q, got %q\", expected, c.Path)\n\t}\n\texpected = \"current\"\n\tif c.Host != expected {\n\t\tt.Fatalf(\"expected host to be %q, got %q\", expected, c.Host)\n\t}\n\texpected = \"wsh\"\n\tif c.Scheme != expected {\n\t\tt.Fatalf(\"expected scheme to be %q, got %q\", expected, c.Scheme)\n\t}\n\texpected = \"wsh://current/etc/path/to/file\"\n\tif c.GetFullURI() != expected {\n\t\tt.Fatalf(\"expected full URI to be %q, got %q\", expected, c.GetFullURI())\n\t}\n}\n\nfunc TestParseURI_WSHCurrentPathWindows(t *testing.T) {\n\tcstr := \".\\\\Documents\\\\path\\\\to\\\\file\"\n\tc, err := connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected := \".\\\\Documents\\\\path\\\\to\\\\file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t}\n\texpected = \"current\"\n\tif c.Host != expected {\n\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t}\n\texpected = \"wsh\"\n\tif c.Scheme != expected {\n\t\tt.Fatalf(\"expected scheme to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t}\n\texpected = \"wsh://current/.\\\\Documents\\\\path\\\\to\\\\file\"\n\tif c.GetFullURI() != expected {\n\t\tt.Fatalf(\"expected full URI to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.GetFullURI())\n\t}\n}\n\nfunc TestParseURI_WSHLocalShorthand(t *testing.T) {\n\tt.Parallel()\n\tcstr := \"/~/path/to/file\"\n\tc, err := connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected := \"~/path/to/file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t}\n\tif c.Host != \"local\" {\n\t\tt.Fatalf(\"expected host to be empty, got \\\"%q\\\"\", c.Host)\n\t}\n\texpected = \"wsh\"\n\tif c.Scheme != expected {\n\t\tt.Fatalf(\"expected scheme to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t}\n\n\tcstr = \"wsh:///~/path/to/file\"\n\tc, err = connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected = \"~/path/to/file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t}\n\tif c.Host != \"local\" {\n\t\tt.Fatalf(\"expected host to be empty, got \\\"%q\\\"\", c.Host)\n\t}\n\texpected = \"wsh\"\n\tif c.Scheme != expected {\n\t\tt.Fatalf(\"expected scheme to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t}\n\texpected = \"wsh://local/~/path/to/file\"\n\tif c.GetFullURI() != expected {\n\t\tt.Fatalf(\"expected full URI to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.GetFullURI())\n\t}\n}\n\nfunc TestParseURI_WSHWSL(t *testing.T) {\n\tt.Parallel()\n\tcstr := \"wsh://wsl://Ubuntu/path/to/file\"\n\n\ttestUri := func() {\n\t\tc, err := connparse.ParseURI(cstr)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t\t}\n\t\texpected := \"/path/to/file\"\n\t\tif c.Path != expected {\n\t\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t\t}\n\t\texpected = \"wsl://Ubuntu\"\n\t\tif c.Host != expected {\n\t\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t\t}\n\t\texpected = \"wsh\"\n\t\tif c.Scheme != expected {\n\t\t\tt.Fatalf(\"expected scheme to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t\t}\n\t\texpected = \"wsh://wsl://Ubuntu/path/to/file\"\n\t\tif expected != c.GetFullURI() {\n\t\t\tt.Fatalf(\"expected full URI to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.GetFullURI())\n\t\t}\n\t}\n\tt.Log(\"Testing with scheme\")\n\ttestUri()\n\n\tt.Log(\"Testing without scheme\")\n\tcstr = \"//wsl://Ubuntu/path/to/file\"\n\ttestUri()\n}\n\nfunc TestParseUri_LocalWindowsAbsPath(t *testing.T) {\n\tt.Parallel()\n\tcstr := \"wsh://local/C:\\\\path\\\\to\\\\file\"\n\n\ttestAbsPath := func() {\n\t\tc, err := connparse.ParseURI(cstr)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t\t}\n\t\texpected := \"C:\\\\path\\\\to\\\\file\"\n\t\tif c.Path != expected {\n\t\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t\t}\n\t\texpected = \"local\"\n\t\tif c.Host != expected {\n\t\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t\t}\n\t\texpected = \"wsh\"\n\t\tif c.Scheme != expected {\n\t\t\tt.Fatalf(\"expected scheme to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t\t}\n\t\texpected = \"wsh://local/C:\\\\path\\\\to\\\\file\"\n\t\tif c.GetFullURI() != expected {\n\t\t\tt.Fatalf(\"expected full URI to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.GetFullURI())\n\t\t}\n\t}\n\n\tt.Log(\"Testing with scheme\")\n\ttestAbsPath()\n\tt.Log(\"Testing without scheme\")\n\tcstr = \"//local/C:\\\\path\\\\to\\\\file\"\n\ttestAbsPath()\n}\n\nfunc TestParseURI_LocalWindowsRelativeShorthand(t *testing.T) {\n\tcstr := \"/~\\\\path\\\\to\\\\file\"\n\tc, err := connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected := \"~\\\\path\\\\to\\\\file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t}\n\texpected = \"local\"\n\tif c.Host != expected {\n\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t}\n\texpected = \"wsh\"\n\tif c.Scheme != expected {\n\t\tt.Fatalf(\"expected scheme to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Scheme)\n\t}\n\texpected = \"wsh://local/~\\\\path\\\\to\\\\file\"\n\tif c.GetFullURI() != expected {\n\t\tt.Fatalf(\"expected full URI to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.GetFullURI())\n\t}\n}\n\nfunc TestParseURI_BasicS3(t *testing.T) {\n\tt.Parallel()\n\tcstr := \"profile:s3://bucket/path/to/file\"\n\tc, err := connparse.ParseURI(cstr)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t}\n\texpected := \"path/to/file\"\n\tif c.Path != expected {\n\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Path)\n\t}\n\texpected = \"bucket\"\n\tif c.Host != expected {\n\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t}\n\texpected = \"bucket/path/to/file\"\n\tpathWithHost := c.GetPathWithHost()\n\tif pathWithHost != expected {\n\t\tt.Fatalf(\"expected path with host to be \\\"%q\\\", got \\\"%q\\\"\", expected, pathWithHost)\n\t}\n\texpected = \"s3\"\n\tif c.GetType() != expected {\n\t\tt.Fatalf(\"expected conn type to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.GetType())\n\t}\n\tif len(c.GetSchemeParts()) != 2 {\n\t\tt.Fatalf(\"expected scheme parts to be 2, got %d\", len(c.GetSchemeParts()))\n\t}\n}\n\nfunc TestParseURI_S3BucketOnly(t *testing.T) {\n\tt.Parallel()\n\n\ttestUri := func(cstr string, pathExpected string, pathWithHostExpected string) {\n\t\tc, err := connparse.ParseURI(cstr)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to parse URI: %v\", err)\n\t\t}\n\t\tif c.Path != pathExpected {\n\t\t\tt.Fatalf(\"expected path to be \\\"%q\\\", got \\\"%q\\\"\", pathExpected, c.Path)\n\t\t}\n\t\texpected := \"bucket\"\n\t\tif c.Host != expected {\n\t\t\tt.Fatalf(\"expected host to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.Host)\n\t\t}\n\t\tpathWithHost := c.GetPathWithHost()\n\t\tif pathWithHost != pathWithHostExpected {\n\t\t\tt.Fatalf(\"expected path with host to be \\\"%q\\\", got \\\"%q\\\"\", expected, pathWithHost)\n\t\t}\n\t\texpected = \"s3\"\n\t\tif c.GetType() != expected {\n\t\t\tt.Fatalf(\"expected conn type to be \\\"%q\\\", got \\\"%q\\\"\", expected, c.GetType())\n\t\t}\n\t\tif len(c.GetSchemeParts()) != 2 {\n\t\t\tt.Fatalf(\"expected scheme parts to be 2, got %d\", len(c.GetSchemeParts()))\n\t\t}\n\t\tfullUri := c.GetFullURI()\n\t\tif fullUri != cstr {\n\t\t\tt.Fatalf(\"expected full URI to be \\\"%q\\\", got \\\"%q\\\"\", cstr, fullUri)\n\t\t}\n\t}\n\n\tt.Log(\"Testing with no trailing slash\")\n\ttestUri(\"profile:s3://bucket\", \"\", \"bucket\")\n\tt.Log(\"Testing with trailing slash\")\n\ttestUri(\"profile:s3://bucket/\", \"/\", \"bucket/\")\n}\n"
  },
  {
    "path": "pkg/remote/connutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage remote\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"os/user\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"text/template\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/blocklogger\"\n\t\"github.com/wavetermdev/waveterm/pkg/genconn\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/iterfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\nvar userHostRe = regexp.MustCompile(`^([a-zA-Z0-9][a-zA-Z0-9._@\\\\-]*@)?([a-zA-Z0-9][a-zA-Z0-9.-]*)(?::([0-9]+))?$`)\n\nfunc ParseOpts(input string) (*SSHOpts, error) {\n\tm := userHostRe.FindStringSubmatch(input)\n\tif m == nil {\n\t\treturn nil, fmt.Errorf(\"invalid format of user@host argument\")\n\t}\n\tremoteUser, remoteHost, remotePort := m[1], m[2], m[3]\n\tremoteUser = strings.Trim(remoteUser, \"@\")\n\n\treturn &SSHOpts{SSHHost: remoteHost, SSHUser: remoteUser, SSHPort: remotePort}, nil\n}\n\nfunc normalizeOs(os string) string {\n\tos = strings.ToLower(strings.TrimSpace(os))\n\treturn os\n}\n\nfunc normalizeArch(arch string) string {\n\tarch = strings.ToLower(strings.TrimSpace(arch))\n\tswitch arch {\n\tcase \"x86_64\", \"amd64\":\n\t\tarch = \"x64\"\n\tcase \"arm64\", \"aarch64\":\n\t\tarch = \"arm64\"\n\t}\n\treturn arch\n}\n\n// returns (os, arch, error)\n// guaranteed to return a supported platform\nfunc GetClientPlatform(ctx context.Context, shell genconn.ShellClient) (string, string, error) {\n\tblocklogger.Infof(ctx, \"[conndebug] running `uname -sm` to detect client platform\\n\")\n\tstdout, stderr, err := genconn.RunSimpleCommand(ctx, shell, genconn.CommandSpec{\n\t\tCmd: \"uname -sm\",\n\t})\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"error running uname -sm: %w, stderr: %s\", err, stderr)\n\t}\n\t// Parse and normalize output\n\tparts := strings.Fields(strings.ToLower(strings.TrimSpace(stdout)))\n\tif len(parts) != 2 {\n\t\treturn \"\", \"\", fmt.Errorf(\"unexpected output from uname: %s\", stdout)\n\t}\n\tos, arch := normalizeOs(parts[0]), normalizeArch(parts[1])\n\tif err := wavebase.ValidateWshSupportedArch(os, arch); err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\treturn os, arch, nil\n}\n\nfunc GetClientPlatformFromOsArchStr(ctx context.Context, osArchStr string) (string, string, error) {\n\tparts := strings.Fields(strings.TrimSpace(osArchStr))\n\tif len(parts) != 2 {\n\t\treturn \"\", \"\", fmt.Errorf(\"unexpected output from uname: %s\", osArchStr)\n\t}\n\tos, arch := normalizeOs(parts[0]), normalizeArch(parts[1])\n\tif err := wavebase.ValidateWshSupportedArch(os, arch); err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\treturn os, arch, nil\n}\n\nvar installTemplateRawDefault = strings.TrimSpace(`\nmkdir -p {{.installDir}} || exit 1;\ncat > {{.tempPath}} || exit 1;\nmv {{.tempPath}} {{.installPath}} || exit 1;\nchmod a+x {{.installPath}} || exit 1;\n`)\nvar installTemplate = template.Must(template.New(\"wsh-install-template\").Parse(installTemplateRawDefault))\n\nfunc CpWshToRemote(ctx context.Context, client *ssh.Client, clientOs string, clientArch string) error {\n\tdeadline, ok := ctx.Deadline()\n\tif ok {\n\t\tblocklogger.Debugf(ctx, \"[conndebug] CpWshToRemote, timeout: %v\\n\", time.Until(deadline))\n\t}\n\twshLocalPath, err := shellutil.GetLocalWshBinaryPath(wavebase.WaveVersion, clientOs, clientArch)\n\tif err != nil {\n\t\treturn err\n\t}\n\tinput, err := os.Open(wshLocalPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot open local file %s: %w\", wshLocalPath, err)\n\t}\n\tdefer input.Close()\n\tinstallWords := map[string]string{\n\t\t\"installDir\":  filepath.ToSlash(filepath.Dir(wavebase.RemoteFullWshBinPath)),\n\t\t\"tempPath\":    wavebase.RemoteFullWshBinPath + \".temp\",\n\t\t\"installPath\": wavebase.RemoteFullWshBinPath,\n\t}\n\tvar installCmd bytes.Buffer\n\tif err := installTemplate.Execute(&installCmd, installWords); err != nil {\n\t\treturn fmt.Errorf(\"failed to prepare install command: %w\", err)\n\t}\n\tblocklogger.Infof(ctx, \"[conndebug] copying %q to remote server %q\\n\", wshLocalPath, wavebase.RemoteFullWshBinPath)\n\tgenCmd, err := genconn.MakeSSHCmdClient(client, genconn.CommandSpec{\n\t\tCmd: installCmd.String(),\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create remote command: %w\", err)\n\t}\n\tstdin, err := genCmd.StdinPipe()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get stdin pipe: %w\", err)\n\t}\n\tdefer stdin.Close()\n\tstderrBuf, err := genconn.MakeStderrSyncBuffer(genCmd)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get stderr pipe: %w\", err)\n\t}\n\tif err := genCmd.Start(); err != nil {\n\t\treturn fmt.Errorf(\"failed to start remote command: %w\", err)\n\t}\n\tcopyDone := make(chan error, 1)\n\tgo func() {\n\t\tdefer close(copyDone)\n\t\tdefer stdin.Close()\n\t\tif _, err := io.Copy(stdin, input); err != nil && err != io.EOF {\n\t\t\tcopyDone <- fmt.Errorf(\"failed to copy data: %w\", err)\n\t\t} else {\n\t\t\tcopyDone <- nil\n\t\t}\n\t}()\n\tprocErr := genconn.ProcessContextWait(ctx, genCmd)\n\tif procErr != nil {\n\t\treturn fmt.Errorf(\"remote command failed: %w (stderr: %s)\", procErr, stderrBuf.String())\n\t}\n\tcopyErr := <-copyDone\n\tif copyErr != nil {\n\t\treturn fmt.Errorf(\"failed to copy data: %w (stderr: %s)\", copyErr, stderrBuf.String())\n\t}\n\treturn nil\n}\n\nfunc IsPowershell(shellPath string) bool {\n\t// get the base path, and then check contains\n\tshellBase := filepath.Base(shellPath)\n\treturn strings.Contains(shellBase, \"powershell\") || strings.Contains(shellBase, \"pwsh\")\n}\n\nfunc NormalizeConfigPattern(pattern string) string {\n\tuserName, err := WaveSshConfigUserSettings().GetStrict(pattern, \"User\")\n\tif err != nil || userName == \"\" {\n\t\tlog.Printf(\"warning: error parsing username of %s for conn dropdown: %v\", pattern, err)\n\t\tlocalUser, err := user.Current()\n\t\tif err == nil {\n\t\t\tuserName = localUser.Username\n\t\t}\n\t}\n\tport, err := WaveSshConfigUserSettings().GetStrict(pattern, \"Port\")\n\tif err != nil {\n\t\tport = \"22\"\n\t}\n\tif userName != \"\" {\n\t\tuserName += \"@\"\n\t}\n\tif port == \"22\" {\n\t\tport = \"\"\n\t} else {\n\t\tport = \":\" + port\n\t}\n\treturn fmt.Sprintf(\"%s%s%s\", userName, pattern, port)\n}\n\nfunc ParseProfiles() []string {\n\tconnfile, cerrs := wconfig.ReadWaveHomeConfigFile(wconfig.ProfilesFile)\n\tif len(cerrs) > 0 {\n\t\tlog.Printf(\"error reading config file: %v\", cerrs[0])\n\t\treturn nil\n\t}\n\n\treturn iterfn.MapKeysToSorted(connfile)\n}\n"
  },
  {
    "path": "pkg/remote/fileshare/fspath/fspath.go",
    "content": "package fspath\n\nimport (\n\tpathpkg \"path\"\n\t\"strings\"\n)\n\nconst (\n\t// Separator is the path separator\n\tSeparator = \"/\"\n)\n\nfunc Dir(path string) string {\n\treturn pathpkg.Dir(ToSlash(path))\n}\n\nfunc Base(path string) string {\n\treturn pathpkg.Base(ToSlash(path))\n}\n\nfunc Join(elem ...string) string {\n\tjoined := pathpkg.Join(elem...)\n\treturn ToSlash(joined)\n}\n\n// FirstLevelDir returns the first level directory of a path and a boolean indicating if the path has more than one level.\nfunc FirstLevelDir(path string) (string, bool) {\n\tif strings.Count(path, Separator) > 0 {\n\t\tpath = strings.SplitN(path, Separator, 2)[0]\n\t\treturn path, true\n\t}\n\treturn path, false\n}\n\nfunc ToSlash(path string) string {\n\treturn strings.ReplaceAll(path, \"\\\\\", Separator)\n}\n"
  },
  {
    "path": "pkg/remote/fileshare/fsutil/fsutil.go",
    "content": "package fsutil\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/remote/connparse\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/fileshare/fspath\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nfunc GetParentPath(conn *connparse.Connection) string {\n\thostAndPath := conn.GetPathWithHost()\n\treturn GetParentPathString(hostAndPath)\n}\n\nfunc GetParentPathString(hostAndPath string) string {\n\tif hostAndPath == \"\" || hostAndPath == fspath.Separator {\n\t\treturn \"\"\n\t}\n\n\t// Remove trailing slash if present\n\tif strings.HasSuffix(hostAndPath, fspath.Separator) {\n\t\thostAndPath = hostAndPath[:len(hostAndPath)-1]\n\t}\n\n\tlastSlash := strings.LastIndex(hostAndPath, fspath.Separator)\n\tif lastSlash <= 0 {\n\t\treturn \"\"\n\t}\n\treturn hostAndPath[:lastSlash+1]\n}\n\n// CleanPathPrefix corrects paths for prefix filesystems (i.e. ones that don't have directories)\nfunc CleanPathPrefix(path string) (string, error) {\n\tif path == \"\" {\n\t\treturn \"\", nil\n\t}\n\tif strings.HasPrefix(path, fspath.Separator) {\n\t\tpath = path[1:]\n\t}\n\tif strings.HasPrefix(path, \"~\") || strings.HasPrefix(path, \".\") || strings.HasPrefix(path, \"..\") {\n\t\treturn \"\", fmt.Errorf(\"path cannot start with ~, ., or ..\")\n\t}\n\tvar newParts []string\n\tfor _, part := range strings.Split(path, fspath.Separator) {\n\t\tif part == \"..\" {\n\t\t\tif len(newParts) > 0 {\n\t\t\t\tnewParts = newParts[:len(newParts)-1]\n\t\t\t}\n\t\t} else if part != \".\" {\n\t\t\tnewParts = append(newParts, part)\n\t\t}\n\t}\n\treturn fspath.Join(newParts...), nil\n}\n\nfunc ReadFileStream(ctx context.Context, readCh <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData], fileInfoCallback func(finfo wshrpc.FileInfo), dirCallback func(entries []*wshrpc.FileInfo) error, fileCallback func(data io.Reader) error) error {\n\tvar fileData *wshrpc.FileData\n\tfirstPk := true\n\tisDir := false\n\tdrain := true\n\tdefer func() {\n\t\tif drain {\n\t\t\tutilfn.DrainChannelSafe(readCh, \"ReadFileStream\")\n\t\t}\n\t}()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn fmt.Errorf(\"context cancelled: %v\", context.Cause(ctx))\n\t\tcase respUnion, ok := <-readCh:\n\t\t\tif !ok {\n\t\t\t\tdrain = false\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tif respUnion.Error != nil {\n\t\t\t\treturn respUnion.Error\n\t\t\t}\n\t\t\tresp := respUnion.Response\n\t\t\tif firstPk {\n\t\t\t\tfirstPk = false\n\t\t\t\t// first packet has the fileinfo\n\t\t\t\tif resp.Info == nil {\n\t\t\t\t\treturn fmt.Errorf(\"stream file protocol error, first pk fileinfo is empty\")\n\t\t\t\t}\n\t\t\t\tfileData = &resp\n\t\t\t\tif fileData.Info.IsDir {\n\t\t\t\t\tisDir = true\n\t\t\t\t}\n\t\t\t\tfileInfoCallback(*fileData.Info)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif isDir {\n\t\t\t\tif len(resp.Entries) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif resp.Data64 != \"\" {\n\t\t\t\t\treturn fmt.Errorf(\"stream file protocol error, directory entry has data\")\n\t\t\t\t}\n\t\t\t\tif err := dirCallback(resp.Entries); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif resp.Data64 == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tdecoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader([]byte(resp.Data64)))\n\t\t\t\tif err := fileCallback(decoder); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc ReadStreamToFileData(ctx context.Context, readCh <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData]) (*wshrpc.FileData, error) {\n\tvar fileData *wshrpc.FileData\n\tvar dataBuf bytes.Buffer\n\tvar entries []*wshrpc.FileInfo\n\terr := ReadFileStream(ctx, readCh, func(finfo wshrpc.FileInfo) {\n\t\tfileData = &wshrpc.FileData{\n\t\t\tInfo: &finfo,\n\t\t}\n\t}, func(fileEntries []*wshrpc.FileInfo) error {\n\t\tentries = append(entries, fileEntries...)\n\t\treturn nil\n\t}, func(data io.Reader) error {\n\t\tif _, err := io.Copy(&dataBuf, data); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif fileData == nil {\n\t\treturn nil, fmt.Errorf(\"stream file protocol error, no file info\")\n\t}\n\tif !fileData.Info.IsDir {\n\t\tfileData.Data64 = base64.StdEncoding.EncodeToString(dataBuf.Bytes())\n\t} else {\n\t\tfileData.Entries = entries\n\t}\n\treturn fileData, nil\n}\n\nfunc ReadFileStreamToWriter(ctx context.Context, readCh <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData], writer io.Writer) error {\n\treturn ReadFileStream(ctx, readCh, func(finfo wshrpc.FileInfo) {\n\t}, func(entries []*wshrpc.FileInfo) error {\n\t\treturn nil\n\t}, func(data io.Reader) error {\n\t\t_, err := io.Copy(writer, data)\n\t\treturn err\n\t})\n}\n"
  },
  {
    "path": "pkg/remote/fileshare/wshfs/wshfs.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshfs\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/remote/connparse\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/fileshare/fsutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nconst (\n\tRemoteFileTransferSizeLimit = 32 * 1024 * 1024\n\tDefaultTimeout              = 30 * time.Second\n\tFileMode                    = os.FileMode(0644)\n\tDirMode                     = os.FileMode(0755) | os.ModeDir\n\tRecursiveRequiredError      = \"recursive flag must be set for directory operations\"\n\tMergeRequiredError          = \"directory already exists at %q, set overwrite flag to delete the existing contents or set merge flag to merge the contents\"\n\tOverwriteRequiredError      = \"file already exists at %q, set overwrite flag to delete the existing file\"\n)\n\n// This needs to be set by whoever initializes the client, either main-server or wshcmd-connserver\nvar RpcClient *wshutil.WshRpc\n\nfunc parseConnection(ctx context.Context, path string) (*connparse.Connection, error) {\n\tconn, err := connparse.ParseURIAndReplaceCurrentHost(ctx, path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error parsing connection %s: %w\", path, err)\n\t}\n\treturn conn, nil\n}\n\nfunc Read(ctx context.Context, data wshrpc.FileData) (*wshrpc.FileData, error) {\n\tlog.Printf(\"Read: %v\", data.Info.Path)\n\tconn, err := parseConnection(ctx, data.Info.Path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trtnCh := readStream(conn, data)\n\treturn fsutil.ReadStreamToFileData(ctx, rtnCh)\n}\n\nfunc ReadStream(ctx context.Context, data wshrpc.FileData) <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData] {\n\tlog.Printf(\"ReadStream: %v\", data.Info.Path)\n\tconn, err := parseConnection(ctx, data.Info.Path)\n\tif err != nil {\n\t\treturn wshutil.SendErrCh[wshrpc.FileData](err)\n\t}\n\treturn readStream(conn, data)\n}\n\nfunc readStream(conn *connparse.Connection, data wshrpc.FileData) <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData] {\n\tbyteRange := \"\"\n\tif data.At != nil && data.At.Size > 0 {\n\t\tbyteRange = fmt.Sprintf(\"%d-%d\", data.At.Offset, data.At.Offset+int64(data.At.Size)-1)\n\t}\n\tstreamFileData := wshrpc.CommandRemoteStreamFileData{Path: conn.Path, ByteRange: byteRange}\n\treturn wshclient.RemoteStreamFileCommand(RpcClient, streamFileData, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)})\n}\n\nfunc GetConnectionRouteId(ctx context.Context, path string) (string, error) {\n\tconn, err := parseConnection(ctx, path)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn wshutil.MakeConnectionRouteId(conn.Host), nil\n}\n\nfunc FileStream(ctx context.Context, data wshrpc.CommandFileStreamData) (*wshrpc.FileInfo, error) {\n\tif data.Info == nil {\n\t\treturn nil, fmt.Errorf(\"file info is required\")\n\t}\n\tlog.Printf(\"FileStream: %v\", data.Info.Path)\n\tconn, err := parseConnection(ctx, data.Info.Path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tremoteData := wshrpc.CommandRemoteFileStreamData{\n\t\tPath:       conn.Path,\n\t\tByteRange:  data.ByteRange,\n\t\tStreamMeta: data.StreamMeta,\n\t}\n\treturn wshclient.RemoteFileStreamCommand(RpcClient, remoteData, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)})\n}\n\nfunc ListEntries(ctx context.Context, path string, opts *wshrpc.FileListOpts) ([]*wshrpc.FileInfo, error) {\n\tlog.Printf(\"ListEntries: %v\", path)\n\tconn, err := parseConnection(ctx, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar entries []*wshrpc.FileInfo\n\trtnCh := listEntriesStream(conn, opts)\n\tfor respUnion := range rtnCh {\n\t\tif respUnion.Error != nil {\n\t\t\treturn nil, respUnion.Error\n\t\t}\n\t\tresp := respUnion.Response\n\t\tentries = append(entries, resp.FileInfo...)\n\t}\n\treturn entries, nil\n}\n\nfunc ListEntriesStream(ctx context.Context, path string, opts *wshrpc.FileListOpts) <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] {\n\tlog.Printf(\"ListEntriesStream: %v\", path)\n\tconn, err := parseConnection(ctx, path)\n\tif err != nil {\n\t\treturn wshutil.SendErrCh[wshrpc.CommandRemoteListEntriesRtnData](err)\n\t}\n\treturn listEntriesStream(conn, opts)\n}\n\nfunc listEntriesStream(conn *connparse.Connection, opts *wshrpc.FileListOpts) <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] {\n\treturn wshclient.RemoteListEntriesCommand(RpcClient, wshrpc.CommandRemoteListEntriesData{Path: conn.Path, Opts: opts}, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)})\n}\n\nfunc Stat(ctx context.Context, path string) (*wshrpc.FileInfo, error) {\n\tlog.Printf(\"Stat: %v\", path)\n\tconn, err := parseConnection(ctx, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn stat(conn)\n}\n\nfunc stat(conn *connparse.Connection) (*wshrpc.FileInfo, error) {\n\treturn wshclient.RemoteFileInfoCommand(RpcClient, conn.Path, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)})\n}\n\nfunc PutFile(ctx context.Context, data wshrpc.FileData) error {\n\tlog.Printf(\"PutFile: %v\", data.Info.Path)\n\tconn, err := parseConnection(ctx, data.Info.Path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdataSize := base64.StdEncoding.DecodedLen(len(data.Data64))\n\tif dataSize > RemoteFileTransferSizeLimit {\n\t\treturn fmt.Errorf(\"file data size %d exceeds transfer limit of %d bytes\", dataSize, RemoteFileTransferSizeLimit)\n\t}\n\tinfo := data.Info\n\tif info == nil {\n\t\tinfo = &wshrpc.FileInfo{Opts: &wshrpc.FileOpts{}}\n\t} else if info.Opts == nil {\n\t\tinfo.Opts = &wshrpc.FileOpts{}\n\t}\n\tinfo.Path = conn.Path\n\tinfo.Opts.Truncate = true\n\tdata.Info = info\n\treturn wshclient.RemoteWriteFileCommand(RpcClient, data, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)})\n}\n\nfunc Append(ctx context.Context, data wshrpc.FileData) error {\n\tlog.Printf(\"Append: %v\", data.Info.Path)\n\tconn, err := parseConnection(ctx, data.Info.Path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdataSize := base64.StdEncoding.DecodedLen(len(data.Data64))\n\tif dataSize > RemoteFileTransferSizeLimit {\n\t\treturn fmt.Errorf(\"file data size %d exceeds transfer limit of %d bytes\", dataSize, RemoteFileTransferSizeLimit)\n\t}\n\tinfo := data.Info\n\tif info == nil {\n\t\tinfo = &wshrpc.FileInfo{Path: conn.Path, Opts: &wshrpc.FileOpts{}}\n\t} else if info.Opts == nil {\n\t\tinfo.Opts = &wshrpc.FileOpts{}\n\t}\n\tinfo.Path = conn.Path\n\tinfo.Opts.Append = true\n\tdata.Info = info\n\treturn wshclient.RemoteWriteFileCommand(RpcClient, data, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)})\n}\n\nfunc Mkdir(ctx context.Context, path string) error {\n\tlog.Printf(\"Mkdir: %v\", path)\n\tconn, err := parseConnection(ctx, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn wshclient.RemoteMkdirCommand(RpcClient, conn.Path, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)})\n}\n\nfunc Move(ctx context.Context, data wshrpc.CommandFileCopyData) error {\n\topts := data.Opts\n\tif opts == nil {\n\t\topts = &wshrpc.FileCopyOpts{}\n\t}\n\tlog.Printf(\"Move: srcuri: %v, desturi: %v, opts: %v\", data.SrcUri, data.DestUri, opts)\n\tsrcConn, err := parseConnection(ctx, data.SrcUri)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error parsing source connection: %w\", err)\n\t}\n\tdestConn, err := parseConnection(ctx, data.DestUri)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error parsing destination connection: %w\", err)\n\t}\n\tif srcConn.Host != destConn.Host {\n\t\tisDir, err := copyInternal(srcConn, destConn, opts)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"cannot copy %q to %q: %w\", data.SrcUri, data.DestUri, err)\n\t\t}\n\t\treturn delete_(srcConn, opts.Recursive && isDir)\n\t}\n\treturn moveInternal(srcConn, destConn, opts)\n}\n\nfunc Copy(ctx context.Context, data wshrpc.CommandFileCopyData) error {\n\topts := data.Opts\n\tif opts == nil {\n\t\topts = &wshrpc.FileCopyOpts{}\n\t}\n\tlog.Printf(\"Copy: srcuri: %v, desturi: %v, opts: %v\", data.SrcUri, data.DestUri, opts)\n\tsrcConn, err := parseConnection(ctx, data.SrcUri)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error parsing source connection: %w\", err)\n\t}\n\tdestConn, err := parseConnection(ctx, data.DestUri)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error parsing destination connection: %w\", err)\n\t}\n\t_, err = copyInternal(srcConn, destConn, opts)\n\treturn err\n}\n\nfunc Delete(ctx context.Context, data wshrpc.CommandDeleteFileData) error {\n\tlog.Printf(\"Delete: %v\", data)\n\tconn, err := parseConnection(ctx, data.Path)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn delete_(conn, data.Recursive)\n}\n\nfunc delete_(conn *connparse.Connection, recursive bool) error {\n\treturn wshclient.RemoteFileDeleteCommand(RpcClient, wshrpc.CommandDeleteFileData{Path: conn.Path, Recursive: recursive}, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)})\n}\n\nfunc Join(ctx context.Context, path string, parts ...string) (*wshrpc.FileInfo, error) {\n\tlog.Printf(\"Join: %v\", path)\n\tconn, err := parseConnection(ctx, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn wshclient.RemoteFileJoinCommand(RpcClient, append([]string{conn.Path}, parts...), &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)})\n}\n\nfunc moveInternal(srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) error {\n\tif srcConn.Host != destConn.Host {\n\t\treturn fmt.Errorf(\"move internal, src and dest hosts do not match\")\n\t}\n\tif opts == nil {\n\t\topts = &wshrpc.FileCopyOpts{}\n\t}\n\ttimeout := opts.Timeout\n\tif timeout == 0 {\n\t\ttimeout = DefaultTimeout.Milliseconds()\n\t}\n\treturn wshclient.RemoteFileMoveCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcConn.GetFullURI(), DestUri: destConn.GetFullURI(), Opts: opts}, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(destConn.Host), Timeout: timeout})\n}\n\nfunc copyInternal(srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) (bool, error) {\n\tif opts == nil {\n\t\topts = &wshrpc.FileCopyOpts{}\n\t}\n\ttimeout := opts.Timeout\n\tif timeout == 0 {\n\t\ttimeout = DefaultTimeout.Milliseconds()\n\t}\n\treturn wshclient.RemoteFileCopyCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcConn.GetFullURI(), DestUri: destConn.GetFullURI(), Opts: opts}, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(destConn.Host), Timeout: timeout})\n}\n"
  },
  {
    "path": "pkg/remote/sshagent_unix.go",
    "content": "//go:build !windows\n\npackage remote\n\nimport \"net\"\n\n// dialIdentityAgent connects to a Unix domain socket identity agent.\nfunc dialIdentityAgent(agentPath string) (net.Conn, error) {\n\treturn net.Dial(\"unix\", agentPath)\n}\n"
  },
  {
    "path": "pkg/remote/sshagent_unix_test.go",
    "content": "//go:build !windows\n\npackage remote\n\nimport (\n\t\"net\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestDialIdentityAgentUnix(t *testing.T) {\n\tsocketPath := filepath.Join(t.TempDir(), \"agent.sock\")\n\n\tln, err := net.Listen(\"unix\", socketPath)\n\tif err != nil {\n\t\tt.Fatalf(\"listen unix socket: %v\", err)\n\t}\n\tdefer ln.Close()\n\n\tacceptDone := make(chan struct{})\n\tgo func() {\n\t\tconn, _ := ln.Accept()\n\t\tif conn != nil {\n\t\t\tconn.Close()\n\t\t}\n\t\tclose(acceptDone)\n\t}()\n\n\tconn, err := dialIdentityAgent(socketPath)\n\tif err != nil {\n\t\tt.Fatalf(\"dialIdentityAgent: %v\", err)\n\t}\n\tconn.Close()\n\t<-acceptDone\n}\n"
  },
  {
    "path": "pkg/remote/sshagent_windows.go",
    "content": "//go:build windows\n\npackage remote\n\nimport (\n\t\"net\"\n\t\"time\"\n\n\t\"github.com/Microsoft/go-winio\"\n)\n\n// dialIdentityAgent connects to the Windows OpenSSH agent named pipe.\nfunc dialIdentityAgent(agentPath string) (net.Conn, error) {\n\ttimeout := 500 * time.Millisecond\n\treturn winio.DialPipe(agentPath, &timeout)\n}\n"
  },
  {
    "path": "pkg/remote/sshagent_windows_test.go",
    "content": "//go:build windows\n\npackage remote\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestDialIdentityAgentWindowsTimeout(t *testing.T) {\n\tstart := time.Now()\n\t_, err := dialIdentityAgent(`\\\\.\\\\pipe\\\\waveterm-nonexistent-agent`)\n\tif err == nil {\n\t\tt.Skip(\"unexpectedly connected to a test pipe; skipping\")\n\t}\n\t// Optionally verify error indicates connection/timeout failure\n\tt.Logf(\"dialIdentityAgent returned expected error: %v\", err)\n\tif time.Since(start) > 3*time.Second {\n\t\tt.Fatalf(\"dialIdentityAgent exceeded expected timeout window\")\n\t}\n}\n"
  },
  {
    "path": "pkg/remote/sshclient.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage remote\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/rsa\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"math\"\n\t\"net\"\n\t\"os\"\n\t\"os/exec\"\n\t\"os/user\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/kevinburke/ssh_config\"\n\t\"github.com/skeema/knownhosts\"\n\t\"github.com/wavetermdev/waveterm/pkg/blocklogger\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/secretstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/trimquotes\"\n\t\"github.com/wavetermdev/waveterm/pkg/userinput\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/utilds\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"golang.org/x/crypto/ssh\"\n\t\"golang.org/x/crypto/ssh/agent\"\n\txknownhosts \"golang.org/x/crypto/ssh/knownhosts\"\n)\n\nconst SshProxyJumpMaxDepth = 10\n\nconst (\n\tConnErrCode_ConfigParse    = \"config-parse\"\n\tConnErrCode_ConfigDefault  = \"config-default\"\n\tConnErrCode_ProxyDepth     = \"proxy-depth\"\n\tConnErrCode_ProxyParse     = \"proxy-parse\"\n\tConnErrCode_SecretStore    = \"secret-error\"\n\tConnErrCode_SecretNotFound = \"secret-notfound\"\n\tConnErrCode_KnownHostsNone = \"knownhosts-none\"\n\tConnErrCode_KnownHostsFmt  = \"knownhosts-format\"\n\tConnErrCode_Dial           = \"dial-error\"\n\tConnErrCode_ProxyJumpDial  = \"dial-proxy-jump\"\n\tConnErrCode_HostKeyRevoked = \"hostkey-revoked\"\n\tConnErrCode_HostKeyChanged = \"hostkey-changed\"\n\tConnErrCode_HostKeyVerify  = \"hostkey-verify\"\n\tConnErrCode_UserCancelled  = \"user-cancelled\"\n\tConnErrCode_UserTimeout    = \"user-timeout\"\n\tConnErrCode_AuthFailed     = \"auth-failed\"\n\tConnErrCode_Unknown        = \"unknown\"\n)\n\n// Dial error subcodes for more granular classification\nconst (\n\tDialSubCode_DNS             = \"dns\"\n\tDialSubCode_Refused         = \"refused\"\n\tDialSubCode_Timeout         = \"timeout\"\n\tDialSubCode_ContextCanceled = \"context-canceled\"\n\tDialSubCode_NoRoute         = \"no-route\"\n\tDialSubCode_HostUnreach     = \"host-unreachable\"\n\tDialSubCode_NetUnreach      = \"net-unreachable\"\n\tDialSubCode_ConnReset       = \"conn-reset\"\n\tDialSubCode_PermDenied      = \"perm-denied\"\n\tDialSubCode_ProxyJump       = \"proxy-jump\"\n\tDialSubCode_Other           = \"other\"\n)\n\n// Auth error subcodes for more granular classification\nconst (\n\tAuthSubCode_UnableToAuth    = \"unable-to-auth\"\n\tAuthSubCode_HandshakeFailed = \"handshake-failed\"\n)\n\nvar waveSshConfigUserSettingsInternal *ssh_config.UserSettings\nvar configUserSettingsOnce = &sync.Once{}\n\nfunc WaveSshConfigUserSettings() *ssh_config.UserSettings {\n\tconfigUserSettingsOnce.Do(func() {\n\t\twaveSshConfigUserSettingsInternal = ssh_config.DefaultUserSettings\n\t\twaveSshConfigUserSettingsInternal.IgnoreMatchDirective = true\n\t})\n\treturn waveSshConfigUserSettingsInternal\n}\n\ntype UserInputCancelError struct {\n\tErr error\n}\n\ntype HostKeyAlgorithms = func(hostWithPort string) (algos []string)\n\nfunc (uice UserInputCancelError) Error() string {\n\treturn uice.Err.Error()\n}\n\nfunc (uice UserInputCancelError) Unwrap() error {\n\treturn uice.Err\n}\n\ntype ConnectionDebugInfo struct {\n\tCurrentClient *ssh.Client\n\tNextOpts      *SSHOpts\n\tJumpNum       int32\n}\n\ntype ConnectionError struct {\n\t*ConnectionDebugInfo\n\tErr error\n}\n\nfunc (ce ConnectionError) Error() string {\n\tif ce.CurrentClient == nil {\n\t\treturn fmt.Sprintf(\"Connecting to %s, Error: %v\", ce.NextOpts, ce.Err)\n\t}\n\treturn fmt.Sprintf(\"Connecting from %v to %s (jump number %d), Error: %v\", ce.CurrentClient, ce.NextOpts, ce.JumpNum, ce.Err)\n}\n\nfunc (ce ConnectionError) Unwrap() error {\n\treturn ce.Err\n}\n\nfunc SimpleMessageFromPossibleConnectionError(err error) string {\n\tif err == nil {\n\t\treturn \"\"\n\t}\n\tif ce, ok := err.(ConnectionError); ok {\n\t\treturn ce.Err.Error()\n\t}\n\treturn err.Error()\n}\n\nfunc ClassifyConnError(err error) (string, string) {\n\tcode := utilds.GetErrorCode(err)\n\tsubCode := utilds.GetErrorSubCode(err)\n\tif code != \"\" {\n\t\treturn code, subCode\n\t}\n\tvar dnsErr *net.DNSError\n\tif errors.As(err, &dnsErr) {\n\t\treturn ConnErrCode_Dial, ClassifyDialErrorSubCode(err)\n\t}\n\tvar opErr *net.OpError\n\tif errors.As(err, &opErr) {\n\t\treturn ConnErrCode_Dial, ClassifyDialErrorSubCode(err)\n\t}\n\terrStr := err.Error()\n\tif strings.Contains(errStr, \"unable to authenticate\") {\n\t\treturn ConnErrCode_AuthFailed, AuthSubCode_UnableToAuth\n\t}\n\tif strings.Contains(errStr, \"handshake failed\") {\n\t\treturn ConnErrCode_AuthFailed, AuthSubCode_HandshakeFailed\n\t}\n\tif strings.Contains(errStr, \"connection refused\") {\n\t\treturn ConnErrCode_Dial, ClassifyDialErrorSubCode(err)\n\t}\n\tif strings.Contains(errStr, \"timed out\") || strings.Contains(errStr, \"timeout\") {\n\t\treturn ConnErrCode_Dial, ClassifyDialErrorSubCode(err)\n\t}\n\treturn ConnErrCode_Unknown, \"\"\n}\n\n// ClassifyDialErrorSubCode provides more granular classification of dial errors\n// to help identify root causes (DNS, VPN, timeouts, etc.)\nfunc ClassifyDialErrorSubCode(err error) string {\n\tif err == nil {\n\t\treturn \"\"\n\t}\n\n\t// Check for context cancellation first\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn DialSubCode_ContextCanceled\n\t}\n\n\t// Check if it's a DNS error\n\tvar dnsErr *net.DNSError\n\tif errors.As(err, &dnsErr) {\n\t\treturn DialSubCode_DNS\n\t}\n\n\t// Check if it's a network operation error\n\tvar opErr *net.OpError\n\tif errors.As(err, &opErr) {\n\t\t// Check the underlying error for more details\n\t\tif opErr.Err != nil {\n\t\t\terrStr := opErr.Err.Error()\n\t\t\tif strings.Contains(errStr, \"connection refused\") {\n\t\t\t\treturn DialSubCode_Refused\n\t\t\t}\n\t\t\tif strings.Contains(errStr, \"no route to host\") {\n\t\t\t\treturn DialSubCode_NoRoute\n\t\t\t}\n\t\t\tif strings.Contains(errStr, \"host is unreachable\") || strings.Contains(errStr, \"host unreachable\") {\n\t\t\t\treturn DialSubCode_HostUnreach\n\t\t\t}\n\t\t\tif strings.Contains(errStr, \"network is unreachable\") || strings.Contains(errStr, \"network unreachable\") {\n\t\t\t\treturn DialSubCode_NetUnreach\n\t\t\t}\n\t\t\tif strings.Contains(errStr, \"connection reset\") {\n\t\t\t\treturn DialSubCode_ConnReset\n\t\t\t}\n\t\t\tif strings.Contains(errStr, \"permission denied\") {\n\t\t\t\treturn DialSubCode_PermDenied\n\t\t\t}\n\t\t}\n\t\t// Generic timeout detection in OpError\n\t\tif opErr.Timeout() {\n\t\t\treturn DialSubCode_Timeout\n\t\t}\n\t}\n\n\t// Check error string for common patterns\n\terrStr := err.Error()\n\tif strings.Contains(errStr, \"connection refused\") {\n\t\treturn DialSubCode_Refused\n\t}\n\tif strings.Contains(errStr, \"timed out\") || strings.Contains(errStr, \"timeout\") || strings.Contains(errStr, \"i/o timeout\") {\n\t\treturn DialSubCode_Timeout\n\t}\n\tif strings.Contains(errStr, \"no route to host\") {\n\t\treturn DialSubCode_NoRoute\n\t}\n\tif strings.Contains(errStr, \"host is unreachable\") || strings.Contains(errStr, \"host unreachable\") {\n\t\treturn DialSubCode_HostUnreach\n\t}\n\tif strings.Contains(errStr, \"network is unreachable\") || strings.Contains(errStr, \"network unreachable\") {\n\t\treturn DialSubCode_NetUnreach\n\t}\n\tif strings.Contains(errStr, \"connection reset\") {\n\t\treturn DialSubCode_ConnReset\n\t}\n\tif strings.Contains(errStr, \"permission denied\") {\n\t\treturn DialSubCode_PermDenied\n\t}\n\n\treturn DialSubCode_Other\n}\n\n// This exists to trick the ssh library into continuing to try\n// different public keys even when the current key cannot be\n// properly parsed\nfunc createDummySigner() ([]ssh.Signer, error) {\n\tdummyKey, err := rsa.GenerateKey(rand.Reader, 2048)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdummySigner, err := ssh.NewSignerFromKey(dummyKey)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn []ssh.Signer{dummySigner}, nil\n\n}\n\n// This is a workaround to only process one identity file at a time,\n// even if they have passphrases. It must be combined with retryable\n// authentication to work properly\n//\n// Despite returning an array of signers, we only ever provide one since\n// it allows proper user interaction in between attempts\n//\n// A significant number of errors end up returning dummy values as if\n// they were successes. An error in this function prevents any other\n// keys from being attempted. But if there's an error because of a dummy\n// file, the library can still try again with a new key.\nfunc createPublicKeyCallback(connCtx context.Context, sshKeywords *wconfig.ConnKeywords, authSockSignersExt []ssh.Signer, agentClient agent.ExtendedAgent, debugInfo *ConnectionDebugInfo) func() ([]ssh.Signer, error) {\n\tvar identityFiles []string\n\texistingKeys := make(map[string][]byte)\n\n\t// checking the file early prevents us from needing to send a\n\t// dummy signer if there's a problem with the signer\n\tfor _, identityFile := range sshKeywords.SshIdentityFile {\n\t\tfilePath, err := wavebase.ExpandHomeDir(identityFile)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tprivateKey, err := os.ReadFile(filePath)\n\t\tif err != nil {\n\t\t\t// skip this key and try with the next\n\t\t\tcontinue\n\t\t}\n\t\texistingKeys[identityFile] = privateKey\n\t\tidentityFiles = append(identityFiles, identityFile)\n\t}\n\t// require pointer to modify list in closure\n\tidentityFilesPtr := &identityFiles\n\n\tvar authSockSigners []ssh.Signer\n\tauthSockSigners = append(authSockSigners, authSockSignersExt...)\n\tauthSockSignersPtr := &authSockSigners\n\n\treturn func() (outSigner []ssh.Signer, outErr error) {\n\t\tdefer func() {\n\t\t\tpanicErr := panichandler.PanicHandler(\"sshclient:publickey-callback\", recover())\n\t\t\tif panicErr != nil {\n\t\t\t\toutErr = panicErr\n\t\t\t}\n\t\t}()\n\t\t// try auth sock\n\t\tif len(*authSockSignersPtr) != 0 {\n\t\t\tauthSockSigner := (*authSockSignersPtr)[0]\n\t\t\t*authSockSignersPtr = (*authSockSignersPtr)[1:]\n\t\t\treturn []ssh.Signer{authSockSigner}, nil\n\t\t}\n\n\t\tif len(*identityFilesPtr) == 0 {\n\t\t\treturn nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: fmt.Errorf(\"no identity files remaining\")}\n\t\t}\n\t\tidentityFile := (*identityFilesPtr)[0]\n\t\tblocklogger.Infof(connCtx, \"[conndebug] trying keyfile %q...\\n\", identityFile)\n\t\t*identityFilesPtr = (*identityFilesPtr)[1:]\n\t\tprivateKey, ok := existingKeys[identityFile]\n\t\tif !ok {\n\t\t\tlog.Printf(\"error with existingKeys, this should never happen\")\n\t\t\t// skip this key and try with the next\n\t\t\treturn createDummySigner()\n\t\t}\n\n\t\tunencryptedPrivateKey, err := ssh.ParseRawPrivateKey(privateKey)\n\t\tif err == nil {\n\t\t\tsigner, err := ssh.NewSignerFromKey(unencryptedPrivateKey)\n\t\t\tif err == nil {\n\t\t\t\tif utilfn.SafeDeref(sshKeywords.SshAddKeysToAgent) && agentClient != nil {\n\t\t\t\t\tagentClient.Add(agent.AddedKey{\n\t\t\t\t\t\tPrivateKey: unencryptedPrivateKey,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\treturn []ssh.Signer{signer}, nil\n\t\t\t}\n\t\t}\n\t\tif _, ok := err.(*ssh.PassphraseMissingError); !ok {\n\t\t\t// skip this key and try with the next\n\t\t\treturn createDummySigner()\n\t\t}\n\n\t\t// batch mode deactivates user input\n\t\tif utilfn.SafeDeref(sshKeywords.SshBatchMode) {\n\t\t\t// skip this key and try with the next\n\t\t\treturn createDummySigner()\n\t\t}\n\n\t\trequest := &userinput.UserInputRequest{\n\t\t\tResponseType: \"text\",\n\t\t\tQueryText:    fmt.Sprintf(\"Enter passphrase for the SSH key: %s\", identityFile),\n\t\t\tTitle:        \"Publickey Auth + Passphrase\",\n\t\t}\n\t\tctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)\n\t\tdefer cancelFn()\n\t\tresponse, err := userinput.GetUserInput(ctx, request)\n\t\tif err != nil {\n\t\t\t// this is an error where we actually do want to stop\n\t\t\t// trying keys\n\n\t\t\treturn nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.MakeCodedError(ConnErrCode_UserCancelled, UserInputCancelError{Err: err})}\n\t\t}\n\t\tunencryptedPrivateKey, err = ssh.ParseRawPrivateKeyWithPassphrase(privateKey, []byte([]byte(response.Text)))\n\t\tif err != nil {\n\t\t\t// skip this key and try with the next\n\t\t\treturn createDummySigner()\n\t\t}\n\t\tsigner, err := ssh.NewSignerFromKey(unencryptedPrivateKey)\n\t\tif err != nil {\n\t\t\t// skip this key and try with the next\n\t\t\treturn createDummySigner()\n\t\t}\n\t\tif utilfn.SafeDeref(sshKeywords.SshAddKeysToAgent) && agentClient != nil {\n\t\t\tagentClient.Add(agent.AddedKey{\n\t\t\t\tPrivateKey: unencryptedPrivateKey,\n\t\t\t})\n\t\t}\n\t\treturn []ssh.Signer{signer}, nil\n\t}\n}\n\nfunc createPasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string, password *string, debugInfo *ConnectionDebugInfo) func() (secret string, err error) {\n\treturn func() (secret string, outErr error) {\n\t\tdefer func() {\n\t\t\tpanicErr := panichandler.PanicHandler(\"sshclient:password-callback\", recover())\n\t\t\tif panicErr != nil {\n\t\t\t\toutErr = panicErr\n\t\t\t}\n\t\t}()\n\t\tblocklogger.Infof(connCtx, \"[conndebug] Password Authentication requested from connection %s...\\n\", remoteDisplayName)\n\n\t\tif password != nil {\n\t\t\tblocklogger.Infof(connCtx, \"[conndebug] using password from secret store, sending to ssh\\n\")\n\t\t\treturn *password, nil\n\t\t}\n\n\t\tctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)\n\t\tdefer cancelFn()\n\t\tqueryText := fmt.Sprintf(\n\t\t\t\"Password Authentication requested from connection  \\n\"+\n\t\t\t\t\"%s\\n\\n\"+\n\t\t\t\t\"Password:\", remoteDisplayName)\n\t\trequest := &userinput.UserInputRequest{\n\t\t\tResponseType: \"text\",\n\t\t\tQueryText:    queryText,\n\t\t\tMarkdown:     true,\n\t\t\tTitle:        \"Password Authentication\",\n\t\t}\n\t\tresponse, err := userinput.GetUserInput(ctx, request)\n\t\tif err != nil {\n\t\t\tblocklogger.Infof(connCtx, \"[conndebug] ERROR Password Authentication failed: %v\\n\", SimpleMessageFromPossibleConnectionError(err))\n\t\t\treturn \"\", ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}\n\t\t}\n\t\tblocklogger.Infof(connCtx, \"[conndebug] got password from user, sending to ssh\\n\")\n\t\treturn response.Text, nil\n\t}\n}\n\nfunc createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteName string, debugInfo *ConnectionDebugInfo) func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {\n\treturn func(name, instruction string, questions []string, echos []bool) (answers []string, outErr error) {\n\t\tdefer func() {\n\t\t\tpanicErr := panichandler.PanicHandler(\"sshclient:kbdinteractive-callback\", recover())\n\t\t\tif panicErr != nil {\n\t\t\t\toutErr = panicErr\n\t\t\t}\n\t\t}()\n\t\tif len(questions) != len(echos) {\n\t\t\treturn nil, fmt.Errorf(\"bad response from server: questions has len %d, echos has len %d\", len(questions), len(echos))\n\t\t}\n\t\tfor i, question := range questions {\n\t\t\techo := echos[i]\n\t\t\tanswer, err := promptChallengeQuestion(connCtx, question, echo, remoteName)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.MakeCodedError(ConnErrCode_UserCancelled, err)}\n\t\t\t}\n\t\t\tanswers = append(answers, answer)\n\t\t}\n\t\treturn answers, nil\n\t}\n}\n\nfunc promptChallengeQuestion(connCtx context.Context, question string, echo bool, remoteName string) (answer string, err error) {\n\t// limited to 15 seconds for some reason. this should be investigated more\n\t// in the future\n\tctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)\n\tdefer cancelFn()\n\tqueryText := fmt.Sprintf(\n\t\t\"Keyboard Interactive Authentication requested from connection  \\n\"+\n\t\t\t\"%s\\n\\n\"+\n\t\t\t\"%s\", remoteName, question)\n\trequest := &userinput.UserInputRequest{\n\t\tResponseType: \"text\",\n\t\tQueryText:    queryText,\n\t\tMarkdown:     true,\n\t\tTitle:        \"Keyboard Interactive Authentication\",\n\t\tPublicText:   echo,\n\t}\n\tresponse, err := userinput.GetUserInput(ctx, request)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn response.Text, nil\n}\n\nfunc openKnownHostsForEdit(knownHostsFilename string) (*os.File, error) {\n\tpath, _ := filepath.Split(knownHostsFilename)\n\terr := os.MkdirAll(path, 0700)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn os.OpenFile(knownHostsFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)\n}\n\nfunc writeToKnownHosts(knownHostsFile string, newLine string, getUserVerification func() (*userinput.UserInputResponse, error)) error {\n\tif getUserVerification == nil {\n\t\tgetUserVerification = func() (*userinput.UserInputResponse, error) {\n\t\t\treturn &userinput.UserInputResponse{\n\t\t\t\tType:    \"confirm\",\n\t\t\t\tConfirm: true,\n\t\t\t}, nil\n\t\t}\n\t}\n\n\tpath, _ := filepath.Split(knownHostsFile)\n\terr := os.MkdirAll(path, 0700)\n\tif err != nil {\n\t\treturn err\n\t}\n\tf, err := os.OpenFile(knownHostsFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// do not close writeable files with defer\n\n\t// this file works, so let's ask the user for permission\n\tresponse, err := getUserVerification()\n\tif err != nil {\n\t\tf.Close()\n\t\treturn UserInputCancelError{Err: err}\n\t}\n\tif !response.Confirm {\n\t\tf.Close()\n\t\treturn UserInputCancelError{Err: fmt.Errorf(\"canceled by the user\")}\n\t}\n\n\t_, err = f.WriteString(newLine + \"\\n\")\n\tif err != nil {\n\t\tf.Close()\n\t\treturn err\n\t}\n\treturn f.Close()\n}\n\nfunc createUnknownKeyVerifier(ctx context.Context, knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponse, error) {\n\tbase64Key := base64.StdEncoding.EncodeToString(key.Marshal())\n\tqueryText := fmt.Sprintf(\n\t\t\"The authenticity of host '%s (%s)' can't be established \"+\n\t\t\t\"as it **does not exist in any checked known_hosts files**. \"+\n\t\t\t\"The host you are attempting to connect to provides this %s key:  \\n\"+\n\t\t\t\"%s.\\n\\n\"+\n\t\t\t\"**Would you like to continue connecting?** If so, the key will be permanently \"+\n\t\t\t\"added to the file %s \"+\n\t\t\t\"to protect from future man-in-the-middle attacks.\", hostname, remote, key.Type(), base64Key, knownHostsFile)\n\trequest := &userinput.UserInputRequest{\n\t\tResponseType: \"confirm\",\n\t\tQueryText:    queryText,\n\t\tMarkdown:     true,\n\t\tTitle:        \"Known Hosts Key Missing\",\n\t}\n\treturn func() (*userinput.UserInputResponse, error) {\n\t\tctx, cancelFn := context.WithTimeout(ctx, 60*time.Second)\n\t\tdefer cancelFn()\n\t\tresp, err := userinput.GetUserInput(ctx, request)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif !resp.Confirm {\n\t\t\treturn nil, fmt.Errorf(\"user selected no\")\n\t\t}\n\t\treturn resp, nil\n\t}\n}\n\nfunc createMissingKnownHostsVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponse, error) {\n\tbase64Key := base64.StdEncoding.EncodeToString(key.Marshal())\n\tqueryText := fmt.Sprintf(\n\t\t\"The authenticity of host '%s (%s)' can't be established \"+\n\t\t\t\"as **no known_hosts files could be found**. \"+\n\t\t\t\"The host you are attempting to connect to provides this %s key:  \\n\"+\n\t\t\t\"%s.\\n\\n\"+\n\t\t\t\"**Would you like to continue connecting?** If so:  \\n\"+\n\t\t\t\"- %s will be created  \\n\"+\n\t\t\t\"- the key will be added to %s\\n\\n\"+\n\t\t\t\"This will protect from future man-in-the-middle attacks.\", hostname, remote, key.Type(), base64Key, knownHostsFile, knownHostsFile)\n\trequest := &userinput.UserInputRequest{\n\t\tResponseType: \"confirm\",\n\t\tQueryText:    queryText,\n\t\tMarkdown:     true,\n\t\tTitle:        \"Known Hosts File Missing\",\n\t}\n\treturn func() (*userinput.UserInputResponse, error) {\n\t\tctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second)\n\t\tdefer cancelFn()\n\t\tresp, err := userinput.GetUserInput(ctx, request)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif !resp.Confirm {\n\t\t\treturn nil, fmt.Errorf(\"user selected no\")\n\t\t}\n\t\treturn resp, nil\n\t}\n}\n\nfunc lineContainsMatch(line []byte, matches [][]byte) bool {\n\tfor _, match := range matches {\n\t\tif bytes.Contains(line, match) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc createHostKeyCallback(ctx context.Context, sshKeywords *wconfig.ConnKeywords) (ssh.HostKeyCallback, HostKeyAlgorithms, error) {\n\tglobalKnownHostsFiles := sshKeywords.SshGlobalKnownHostsFile\n\tuserKnownHostsFiles := sshKeywords.SshUserKnownHostsFile\n\n\tosUser, err := user.Current()\n\tif err != nil {\n\t\treturn nil, nil, utilds.MakeCodedError(ConnErrCode_ConfigParse, err)\n\t}\n\tvar unexpandedKnownHostsFiles []string\n\tif osUser.Username == \"root\" {\n\t\tunexpandedKnownHostsFiles = globalKnownHostsFiles\n\t} else {\n\t\tunexpandedKnownHostsFiles = append(userKnownHostsFiles, globalKnownHostsFiles...)\n\t}\n\n\tvar knownHostsFiles []string\n\tfor _, filename := range unexpandedKnownHostsFiles {\n\t\tfilePath, err := wavebase.ExpandHomeDir(filename)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tknownHostsFiles = append(knownHostsFiles, filePath)\n\t}\n\n\t// there are no good known hosts files\n\tif len(knownHostsFiles) == 0 {\n\t\treturn nil, nil, utilds.Errorf(ConnErrCode_KnownHostsNone, \"no known_hosts files provided by ssh. defaults are overridden\")\n\t}\n\n\tvar unreadableFiles []string\n\n\t// the library we use isn't very forgiving about files that are formatted\n\t// incorrectly. if a problem file is found, it is removed from our list\n\t// and we try again\n\tvar basicCallback ssh.HostKeyCallback\n\tvar hostKeyAlgorithms HostKeyAlgorithms\n\tfor basicCallback == nil && len(knownHostsFiles) > 0 {\n\t\tkeyDb, err := knownhosts.NewDB(knownHostsFiles...)\n\t\tif serr, ok := err.(*os.PathError); ok {\n\t\t\tbadFile := serr.Path\n\t\t\tunreadableFiles = append(unreadableFiles, badFile)\n\t\t\tvar okFiles []string\n\t\t\tfor _, filename := range knownHostsFiles {\n\t\t\t\tif filename != badFile {\n\t\t\t\t\tokFiles = append(okFiles, filename)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(okFiles) >= len(knownHostsFiles) {\n\t\t\t\treturn nil, nil, utilds.Errorf(ConnErrCode_KnownHostsFmt, \"problem file (%s) doesn't exist. this should not be possible\", badFile)\n\t\t\t}\n\t\t\tknownHostsFiles = okFiles\n\t\t} else if err != nil {\n\t\t\treturn nil, nil, utilds.Errorf(ConnErrCode_KnownHostsFmt, \"known_hosts formatting error: %w\", err)\n\t\t} else {\n\t\t\tbasicCallback = keyDb.HostKeyCallback()\n\t\t\thostKeyAlgorithms = keyDb.HostKeyAlgorithms\n\t\t}\n\t}\n\n\tif basicCallback == nil {\n\t\tbasicCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error {\n\t\t\treturn &xknownhosts.KeyError{}\n\t\t}\n\t\t// need to return nil here to avoid null pointer from attempting to call\n\t\t// the one provided by the db if nothing was found\n\t\thostKeyAlgorithms = func(hostWithPort string) (algos []string) {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\twaveHostKeyCallback := func(hostname string, remote net.Addr, key ssh.PublicKey) (outErr error) {\n\t\tdefer func() {\n\t\t\tpanicErr := panichandler.PanicHandler(\"sshclient:wave-hostkey-callback\", recover())\n\t\t\tif panicErr != nil {\n\t\t\t\toutErr = panicErr\n\t\t\t}\n\t\t}()\n\t\terr := basicCallback(hostname, remote, key)\n\t\tif err == nil {\n\t\t\t// success\n\t\t\treturn nil\n\t\t} else if _, ok := err.(*xknownhosts.RevokedError); ok {\n\t\t\treturn utilds.MakeCodedError(ConnErrCode_HostKeyRevoked, err)\n\t\t} else if _, ok := err.(*xknownhosts.KeyError); !ok {\n\t\t\t// this is an unknown error (note the !ok is opposite of usual)\n\t\t\treturn err\n\t\t}\n\t\tserr, _ := err.(*xknownhosts.KeyError)\n\t\tif len(serr.Want) == 0 {\n\t\t\t// the key was not found\n\n\t\t\t// try to write to a file that could be read\n\t\t\terr := fmt.Errorf(\"placeholder, should not be returned\") // a null value here can cause problems with empty slice\n\t\t\tfor _, filename := range knownHostsFiles {\n\t\t\t\tnewLine := xknownhosts.Line([]string{xknownhosts.Normalize(hostname)}, key)\n\t\t\t\tgetUserVerification := createUnknownKeyVerifier(ctx, filename, hostname, remote.String(), key)\n\t\t\t\terr = writeToKnownHosts(filename, newLine, getUserVerification)\n\t\t\t\tif err == nil {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tif serr, ok := err.(UserInputCancelError); ok {\n\t\t\t\t\treturn utilds.MakeCodedError(ConnErrCode_UserCancelled, serr)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// try to write to a file that could not be read (file likely doesn't exist)\n\t\t\t// should catch cases where there is no known_hosts file\n\t\t\tif err != nil {\n\t\t\t\tfor _, filename := range unreadableFiles {\n\t\t\t\t\tnewLine := xknownhosts.Line([]string{xknownhosts.Normalize(hostname)}, key)\n\t\t\t\t\tgetUserVerification := createMissingKnownHostsVerifier(filename, hostname, remote.String(), key)\n\t\t\t\t\terr = writeToKnownHosts(filename, newLine, getUserVerification)\n\t\t\t\t\tif err == nil {\n\t\t\t\t\t\tknownHostsFiles = []string{filename}\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tif serr, ok := err.(UserInputCancelError); ok {\n\t\t\t\t\t\treturn utilds.MakeCodedError(ConnErrCode_UserCancelled, serr)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn utilds.Errorf(ConnErrCode_HostKeyVerify, \"unable to create new knownhost key: %w\", err)\n\t\t\t}\n\t\t} else {\n\t\t\t// the key changed\n\t\t\tcorrectKeyFingerprint := base64.StdEncoding.EncodeToString(key.Marshal())\n\t\t\tvar bulletListKnownHosts []string\n\t\t\tfor _, knownHostName := range knownHostsFiles {\n\t\t\t\twithBulletPoint := \"- \" + knownHostName\n\t\t\t\tbulletListKnownHosts = append(bulletListKnownHosts, withBulletPoint)\n\t\t\t}\n\t\t\tvar offendingKeysFmt []string\n\t\t\tfor _, badKey := range serr.Want {\n\t\t\t\tformattedKey := \"- \" + base64.StdEncoding.EncodeToString(badKey.Key.Marshal())\n\t\t\t\toffendingKeysFmt = append(offendingKeysFmt, formattedKey)\n\t\t\t}\n\t\t\t// todo\n\t\t\terrorMsg := fmt.Sprintf(\"**WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!**\\n\\n\"+\n\t\t\t\t\"If this is not expected, it is possible that someone could be trying to \"+\n\t\t\t\t\"eavesdrop on you via a man-in-the-middle attack. \"+\n\t\t\t\t\"Alternatively, the host you are connecting to may have changed its key. \"+\n\t\t\t\t\"The %s key sent by the remote hist has the fingerprint:  \\n\"+\n\t\t\t\t\"%s\\n\\n\"+\n\t\t\t\t\"If you are sure this is correct, please update your known_hosts files to \"+\n\t\t\t\t\"remove the lines with the offending before trying to connect again.  \\n\"+\n\t\t\t\t\"**Known Hosts Files**  \\n\"+\n\t\t\t\t\"%s\\n\\n\"+\n\t\t\t\t\"**Offending Keys**  \\n\"+\n\t\t\t\t\"%s\", key.Type(), correctKeyFingerprint, strings.Join(bulletListKnownHosts, \"  \\n\"), strings.Join(offendingKeysFmt, \"  \\n\"))\n\n\t\t\tlog.Print(errorMsg)\n\t\t\t//update := scbus.MakeUpdatePacket()\n\t\t\t// create update into alert message\n\n\t\t\t//send update via bus?\n\t\t\treturn utilds.Errorf(ConnErrCode_HostKeyChanged, \"remote host identification has changed\")\n\t\t}\n\n\t\tupdatedCallback, err := xknownhosts.New(knownHostsFiles...)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// try one final time\n\t\treturn updatedCallback(hostname, remote, key)\n\t}\n\n\treturn waveHostKeyCallback, hostKeyAlgorithms, nil\n}\n\nfunc createClientConfig(connCtx context.Context, sshKeywords *wconfig.ConnKeywords, debugInfo *ConnectionDebugInfo) (*ssh.ClientConfig, error) {\n\tchosenUser := utilfn.SafeDeref(sshKeywords.SshUser)\n\tchosenHostName := utilfn.SafeDeref(sshKeywords.SshHostName)\n\tchosenPort := utilfn.SafeDeref(sshKeywords.SshPort)\n\tremoteName := xknownhosts.Normalize(chosenHostName + \":\" + chosenPort)\n\tif chosenUser != \"\" {\n\t\tremoteName = chosenUser + \"@\" + remoteName\n\t}\n\n\tvar authSockSigners []ssh.Signer\n\tvar agentClient agent.ExtendedAgent\n\n\t// IdentitiesOnly indicates that only the keys listed in the identity and certificate files or passed as arguments should be used, even if there are matches in the SSH Agent, PKCS11Provider, or SecurityKeyProvider. See https://man.openbsd.org/ssh_config#IdentitiesOnly\n\t// TODO: Update if we decide to support PKCS11Provider and SecurityKeyProvider\n\tagentPath := strings.TrimSpace(utilfn.SafeDeref(sshKeywords.SshIdentityAgent))\n\tif !utilfn.SafeDeref(sshKeywords.SshIdentitiesOnly) && agentPath != \"\" {\n\t\tconn, err := dialIdentityAgent(agentPath)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"Failed to open Identity Agent Socket %q: %v\", agentPath, err)\n\t\t} else {\n\t\t\tagentClient = agent.NewClient(conn)\n\t\t\tauthSockSigners, _ = agentClient.Signers()\n\t\t}\n\t}\n\n\tvar sshPassword *string\n\tif sshKeywords.SshPasswordSecretName != nil && *sshKeywords.SshPasswordSecretName != \"\" {\n\t\tsecretName := *sshKeywords.SshPasswordSecretName\n\t\tpassword, exists, err := secretstore.GetSecret(secretName)\n\t\tif err != nil {\n\t\t\treturn nil, utilds.Errorf(ConnErrCode_SecretStore, \"error retrieving ssh:passwordsecretname %q: %w\", secretName, err)\n\t\t}\n\t\tif !exists {\n\t\t\treturn nil, utilds.Errorf(ConnErrCode_SecretNotFound, \"ssh:passwordsecretname %q not found in secret store\", secretName)\n\t\t}\n\t\tblocklogger.Infof(connCtx, \"[conndebug] successfully retrieved ssh:passwordsecretname %q from secret store\\n\", secretName)\n\t\tsshPassword = &password\n\t}\n\n\tpublicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(connCtx, sshKeywords, authSockSigners, agentClient, debugInfo))\n\tkeyboardInteractive := ssh.KeyboardInteractive(createInteractiveKbdInteractiveChallenge(connCtx, remoteName, debugInfo))\n\tpasswordCallback := ssh.PasswordCallback(createPasswordCallbackPrompt(connCtx, remoteName, sshPassword, debugInfo))\n\n\t// exclude gssapi-with-mic and hostbased until implemented\n\tauthMethodMap := map[string]ssh.AuthMethod{\n\t\t\"publickey\":            ssh.RetryableAuthMethod(publicKeyCallback, len(sshKeywords.SshIdentityFile)+len(authSockSigners)),\n\t\t\"keyboard-interactive\": ssh.RetryableAuthMethod(keyboardInteractive, 1),\n\t\t\"password\":             ssh.RetryableAuthMethod(passwordCallback, 1),\n\t}\n\n\t// note: batch mode turns off interactive input\n\tauthMethodActiveMap := map[string]bool{\n\t\t\"publickey\":            utilfn.SafeDeref(sshKeywords.SshPubkeyAuthentication),\n\t\t\"keyboard-interactive\": utilfn.SafeDeref(sshKeywords.SshKbdInteractiveAuthentication) && !utilfn.SafeDeref(sshKeywords.SshBatchMode),\n\t\t\"password\":             utilfn.SafeDeref(sshKeywords.SshPasswordAuthentication) && !utilfn.SafeDeref(sshKeywords.SshBatchMode),\n\t}\n\n\tvar authMethods []ssh.AuthMethod\n\tfor _, authMethodName := range sshKeywords.SshPreferredAuthentications {\n\t\tauthMethodActive, ok := authMethodActiveMap[authMethodName]\n\t\tif !ok || !authMethodActive {\n\t\t\tcontinue\n\t\t}\n\t\tauthMethod, ok := authMethodMap[authMethodName]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tauthMethods = append(authMethods, authMethod)\n\t}\n\n\thostKeyCallback, hostKeyAlgorithms, err := createHostKeyCallback(connCtx, sshKeywords)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tnetworkAddr := chosenHostName + \":\" + chosenPort\n\treturn &ssh.ClientConfig{\n\t\tUser:              chosenUser,\n\t\tAuth:              authMethods,\n\t\tHostKeyCallback:   hostKeyCallback,\n\t\tHostKeyAlgorithms: hostKeyAlgorithms(networkAddr),\n\t}, nil\n}\n\nfunc connectInternal(ctx context.Context, networkAddr string, clientConfig *ssh.ClientConfig, currentClient *ssh.Client) (*ssh.Client, error) {\n\tvar clientConn net.Conn\n\tvar err error\n\tif currentClient == nil {\n\t\td := net.Dialer{Timeout: clientConfig.Timeout}\n\t\tblocklogger.Infof(ctx, \"[conndebug] ssh dial %s\\n\", networkAddr)\n\t\tclientConn, err = d.DialContext(ctx, \"tcp\", networkAddr)\n\t\tif err != nil {\n\t\t\tsubCode := ClassifyDialErrorSubCode(err)\n\t\t\tblocklogger.Infof(ctx, \"[conndebug] ERROR dial error [%s]: %v\\n\", subCode, err)\n\t\t\treturn nil, utilds.MakeSubCodedError(ConnErrCode_Dial, subCode, err)\n\t\t}\n\t} else {\n\t\tblocklogger.Infof(ctx, \"[conndebug] ssh dial (from client) %s\\n\", networkAddr)\n\t\tclientConn, err = currentClient.DialContext(ctx, \"tcp\", networkAddr)\n\t\tif err != nil {\n\t\t\tsubCode := ClassifyDialErrorSubCode(err)\n\t\t\tblocklogger.Infof(ctx, \"[conndebug] ERROR dial error [%s]: %v\\n\", subCode, err)\n\t\t\treturn nil, utilds.MakeSubCodedError(ConnErrCode_ProxyJumpDial, subCode, err)\n\t\t}\n\t}\n\tc, chans, reqs, err := ssh.NewClientConn(clientConn, networkAddr, clientConfig)\n\tif err != nil {\n\t\tblocklogger.Infof(ctx, \"[conndebug] ERROR ssh auth/negotiation: %s\\n\", SimpleMessageFromPossibleConnectionError(err))\n\t\treturn nil, err\n\t}\n\tblocklogger.Infof(ctx, \"[conndebug] successful ssh connection to %s\\n\", networkAddr)\n\treturn ssh.NewClient(c, chans, reqs), nil\n}\n\nfunc ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh.Client, jumpNum int32, connFlags *wconfig.ConnKeywords) (*ssh.Client, int32, error) {\n\tblocklogger.Infof(connCtx, \"[conndebug] ConnectToClient %s (jump:%d)...\\n\", opts.String(), jumpNum)\n\tdebugInfo := &ConnectionDebugInfo{\n\t\tCurrentClient: currentClient,\n\t\tNextOpts:      opts,\n\t\tJumpNum:       jumpNum,\n\t}\n\tif jumpNum > SshProxyJumpMaxDepth {\n\t\treturn nil, jumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.Errorf(ConnErrCode_ProxyDepth, \"ProxyJump %d exceeds Wave's max depth of %d\", jumpNum, SshProxyJumpMaxDepth)}\n\t}\n\n\trawName := opts.String()\n\tfullConfig := wconfig.GetWatcher().GetFullConfig()\n\tinternalSshConfigKeywords, ok := fullConfig.Connections[rawName]\n\tif !ok {\n\t\tinternalSshConfigKeywords = wconfig.ConnKeywords{}\n\t}\n\n\tvar sshConfigKeywords *wconfig.ConnKeywords\n\tif utilfn.SafeDeref(internalSshConfigKeywords.ConnIgnoreSshConfig) {\n\t\tvar err error\n\t\tsshConfigKeywords, err = findSshDefaults(opts.SSHHost)\n\t\tif err != nil {\n\t\t\terr = utilds.MakeCodedError(ConnErrCode_ConfigDefault, fmt.Errorf(\"cannot determine default config keywords: %w\", err))\n\t\t\treturn nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}\n\t\t}\n\t} else {\n\t\tvar err error\n\t\tsshConfigKeywords, err = findSshConfigKeywords(opts.SSHHost)\n\t\tif err != nil {\n\t\t\terr = utilds.MakeCodedError(ConnErrCode_ConfigParse, fmt.Errorf(\"cannot determine config keywords: %w\", err))\n\t\t\treturn nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}\n\t\t}\n\t}\n\n\tparsedKeywords := &wconfig.ConnKeywords{}\n\tif opts.SSHUser != \"\" {\n\t\tparsedKeywords.SshUser = &opts.SSHUser\n\t}\n\tif opts.SSHPort != \"\" {\n\t\tparsedKeywords.SshPort = &opts.SSHPort\n\t}\n\n\t// cascade order:\n\t//   ssh config -> (optional) internal config -> specified flag keywords -> parsed keywords\n\tpartialMerged := sshConfigKeywords\n\tpartialMerged = mergeKeywords(partialMerged, &internalSshConfigKeywords)\n\tpartialMerged = mergeKeywords(partialMerged, connFlags)\n\tsshKeywords := mergeKeywords(partialMerged, parsedKeywords)\n\n\t// handle these separately since\n\t// - they append\n\t// - since they append, the order is reversed\n\t// - there is no reason to not include the internal config\n\t// - they are never part of the parsedKeywords\n\tsshKeywords.SshIdentityFile = append(sshKeywords.SshIdentityFile, connFlags.SshIdentityFile...)\n\tsshKeywords.SshIdentityFile = append(sshKeywords.SshIdentityFile, internalSshConfigKeywords.SshIdentityFile...)\n\tsshKeywords.SshIdentityFile = append(sshKeywords.SshIdentityFile, sshConfigKeywords.SshIdentityFile...)\n\n\tfor _, proxyName := range sshKeywords.SshProxyJump {\n\t\tproxyOpts, err := ParseOpts(proxyName)\n\t\tif err != nil {\n\t\t\treturn nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.MakeCodedError(ConnErrCode_ProxyParse, err)}\n\t\t}\n\n\t\t// ensure no overflow (this will likely never happen)\n\t\tif jumpNum < math.MaxInt32 {\n\t\t\tjumpNum += 1\n\t\t}\n\n\t\t// do not apply supplied keywords to proxies - ssh config must be used for that\n\t\tdebugInfo.CurrentClient, jumpNum, err = ConnectToClient(connCtx, proxyOpts, debugInfo.CurrentClient, jumpNum, &wconfig.ConnKeywords{})\n\t\tif err != nil {\n\t\t\t// do not add a context on a recursive call\n\t\t\t// (this can cause a recursive nested context that's arbitrarily deep)\n\t\t\treturn nil, jumpNum, err\n\t\t}\n\t}\n\tclientConfig, err := createClientConfig(connCtx, sshKeywords, debugInfo)\n\tif err != nil {\n\t\treturn nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}\n\t}\n\tnetworkAddr := utilfn.SafeDeref(sshKeywords.SshHostName) + \":\" + utilfn.SafeDeref(sshKeywords.SshPort)\n\tclient, err := connectInternal(connCtx, networkAddr, clientConfig, debugInfo.CurrentClient)\n\tif err != nil {\n\t\treturn client, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}\n\t}\n\treturn client, debugInfo.JumpNum, nil\n}\n\n// note that a `var == \"yes\"` will default to false\n// but `var != \"no\"` will default to true\n// when given unexpected strings\nfunc findSshConfigKeywords(hostPattern string) (connKeywords *wconfig.ConnKeywords, outErr error) {\n\tdefer func() {\n\t\tpanicErr := panichandler.PanicHandler(\"sshclient:find-ssh-config-keywords\", recover())\n\t\tif panicErr != nil {\n\t\t\toutErr = panicErr\n\t\t}\n\t}()\n\tWaveSshConfigUserSettings().ReloadConfigs()\n\tsshKeywords := &wconfig.ConnKeywords{}\n\tvar err error\n\n\tuserRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, \"User\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tuserClean := trimquotes.TryTrimQuotes(userRaw)\n\tif userClean == \"\" {\n\t\tuserDetails, err := user.Current()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tuserClean = userDetails.Username\n\t}\n\tsshKeywords.SshUser = &userClean\n\n\thostNameRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, \"HostName\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t// manually implementing default HostName here as it is not handled by ssh_config library\n\thostNameProcessed := trimquotes.TryTrimQuotes(hostNameRaw)\n\tif hostNameProcessed == \"\" {\n\t\tsshKeywords.SshHostName = &hostPattern\n\t} else {\n\t\tsshKeywords.SshHostName = &hostNameRaw\n\t}\n\n\tportRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, \"Port\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsshKeywords.SshPort = utilfn.Ptr(trimquotes.TryTrimQuotes(portRaw))\n\n\tidentityFileRaw := WaveSshConfigUserSettings().GetAll(hostPattern, \"IdentityFile\")\n\tfor i := 0; i < len(identityFileRaw); i++ {\n\t\tidentityFileRaw[i] = trimquotes.TryTrimQuotes(identityFileRaw[i])\n\t}\n\tsshKeywords.SshIdentityFile = identityFileRaw\n\n\tbatchModeRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, \"BatchMode\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsshKeywords.SshBatchMode = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(batchModeRaw)) == \"yes\")\n\n\t// we currently do not support host-bound or unbound but will use yes when they are selected\n\tpubkeyAuthenticationRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, \"PubkeyAuthentication\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsshKeywords.SshPubkeyAuthentication = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(pubkeyAuthenticationRaw)) != \"no\")\n\n\tpasswordAuthenticationRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, \"PasswordAuthentication\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsshKeywords.SshPasswordAuthentication = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(passwordAuthenticationRaw)) != \"no\")\n\n\tkbdInteractiveAuthenticationRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, \"KbdInteractiveAuthentication\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsshKeywords.SshKbdInteractiveAuthentication = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(kbdInteractiveAuthenticationRaw)) != \"no\")\n\n\t// these are parsed as a single string and must be separated\n\t// these are case sensitive in openssh so they are here too\n\tpreferredAuthenticationsRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, \"PreferredAuthentications\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsshKeywords.SshPreferredAuthentications = strings.Split(trimquotes.TryTrimQuotes(preferredAuthenticationsRaw), \",\")\n\taddKeysToAgentRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, \"AddKeysToAgent\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsshKeywords.SshAddKeysToAgent = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(addKeysToAgentRaw)) == \"yes\")\n\n\tidentitiesOnly, err := WaveSshConfigUserSettings().GetStrict(hostPattern, \"IdentitiesOnly\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsshKeywords.SshIdentitiesOnly = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(identitiesOnly)) == \"yes\")\n\n\tidentityAgentRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, \"IdentityAgent\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif identityAgentRaw == \"\" {\n\t\tif runtime.GOOS == \"windows\" {\n\t\t\tsshKeywords.SshIdentityAgent = utilfn.Ptr(`\\\\.\\pipe\\openssh-ssh-agent`)\n\t\t} else {\n\t\t\tshellPath := shellutil.DetectLocalShellPath()\n\t\t\tauthSockCommand := exec.Command(shellPath, \"-c\", \"echo ${SSH_AUTH_SOCK}\")\n\t\t\tsshAuthSock, err := authSockCommand.Output()\n\t\t\tif err == nil {\n\t\t\t\ttrimmedSock := strings.TrimSpace(string(sshAuthSock))\n\t\t\t\tif trimmedSock == \"\" {\n\t\t\t\t\tlog.Printf(\"SSH_AUTH_SOCK is empty in shell environment\")\n\t\t\t\t} else {\n\t\t\t\t\tagentPath, err := wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(trimmedSock))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t}\n\t\t\t\t\tsshKeywords.SshIdentityAgent = utilfn.Ptr(agentPath)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"unable to find SSH_AUTH_SOCK: %v\\n\", err)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tagentPath, err := wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(identityAgentRaw))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tsshKeywords.SshIdentityAgent = utilfn.Ptr(agentPath)\n\t}\n\n\tproxyJumpRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, \"ProxyJump\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tproxyJumpSplit := strings.Split(proxyJumpRaw, \",\")\n\tfor _, proxyJumpName := range proxyJumpSplit {\n\t\tproxyJumpName = strings.TrimSpace(proxyJumpName)\n\t\tif proxyJumpName == \"\" || strings.ToLower(proxyJumpName) == \"none\" {\n\t\t\tcontinue\n\t\t}\n\t\tsshKeywords.SshProxyJump = append(sshKeywords.SshProxyJump, proxyJumpName)\n\t}\n\trawUserKnownHostsFile, _ := WaveSshConfigUserSettings().GetStrict(hostPattern, \"UserKnownHostsFile\")\n\tsshKeywords.SshUserKnownHostsFile = strings.Fields(rawUserKnownHostsFile) // TODO - smarter splitting escaped spaces and quotes\n\trawGlobalKnownHostsFile, _ := WaveSshConfigUserSettings().GetStrict(hostPattern, \"GlobalKnownHostsFile\")\n\tsshKeywords.SshGlobalKnownHostsFile = strings.Fields(rawGlobalKnownHostsFile) // TODO - smarter splitting escaped spaces and quotes\n\n\treturn sshKeywords, nil\n}\n\nfunc findSshDefaults(hostPattern string) (connKeywords *wconfig.ConnKeywords, outErr error) {\n\tsshKeywords := &wconfig.ConnKeywords{}\n\n\tuserDetails, err := user.Current()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsshKeywords.SshUser = &userDetails.Username\n\tsshKeywords.SshHostName = &hostPattern\n\tsshKeywords.SshPort = utilfn.Ptr(ssh_config.Default(\"Port\"))\n\tsshKeywords.SshIdentityFile = ssh_config.DefaultAll(\"IdentityFile\", hostPattern, ssh_config.DefaultUserSettings) // use the sshconfig here. should be different later\n\tsshKeywords.SshBatchMode = utilfn.Ptr(false)\n\tsshKeywords.SshPubkeyAuthentication = utilfn.Ptr(true)\n\tsshKeywords.SshPasswordAuthentication = utilfn.Ptr(true)\n\tsshKeywords.SshKbdInteractiveAuthentication = utilfn.Ptr(true)\n\tsshKeywords.SshPreferredAuthentications = strings.Split(ssh_config.Default(\"PreferredAuthentications\"), \",\")\n\tsshKeywords.SshAddKeysToAgent = utilfn.Ptr(false)\n\tsshKeywords.SshIdentitiesOnly = utilfn.Ptr(false)\n\tsshKeywords.SshIdentityAgent = utilfn.Ptr(ssh_config.Default(\"IdentityAgent\"))\n\tsshKeywords.SshProxyJump = []string{}\n\tsshKeywords.SshUserKnownHostsFile = strings.Fields(ssh_config.Default(\"UserKnownHostsFile\"))\n\tsshKeywords.SshGlobalKnownHostsFile = strings.Fields(ssh_config.Default(\"GlobalKnownHostsFile\"))\n\treturn sshKeywords, nil\n}\n\ntype SSHOpts struct {\n\tSSHHost string `json:\"sshhost\"`\n\tSSHUser string `json:\"sshuser\"`\n\tSSHPort string `json:\"sshport,omitempty\"`\n}\n\nfunc (opts SSHOpts) String() string {\n\tstringRepr := \"\"\n\tif opts.SSHUser != \"\" {\n\t\tstringRepr = opts.SSHUser + \"@\"\n\t}\n\tstringRepr = stringRepr + opts.SSHHost\n\tif opts.SSHPort != \"22\" && opts.SSHPort != \"\" {\n\t\tstringRepr = stringRepr + \":\" + fmt.Sprint(opts.SSHPort)\n\t}\n\treturn stringRepr\n}\n\nfunc mergeKeywords(oldKeywords *wconfig.ConnKeywords, newKeywords *wconfig.ConnKeywords) *wconfig.ConnKeywords {\n\tif oldKeywords == nil {\n\t\toldKeywords = &wconfig.ConnKeywords{}\n\t}\n\tif newKeywords == nil {\n\t\treturn oldKeywords\n\t}\n\toutKeywords := *oldKeywords\n\n\tif newKeywords.SshHostName != nil {\n\t\toutKeywords.SshHostName = newKeywords.SshHostName\n\t}\n\tif newKeywords.SshUser != nil {\n\t\toutKeywords.SshUser = newKeywords.SshUser\n\t}\n\tif newKeywords.SshPort != nil {\n\t\toutKeywords.SshPort = newKeywords.SshPort\n\t}\n\t// skip identityfile (handled separately due to different behavior)\n\tif newKeywords.SshBatchMode != nil {\n\t\toutKeywords.SshBatchMode = newKeywords.SshBatchMode\n\t}\n\tif newKeywords.SshPubkeyAuthentication != nil {\n\t\toutKeywords.SshPubkeyAuthentication = newKeywords.SshPubkeyAuthentication\n\t}\n\tif newKeywords.SshPasswordAuthentication != nil {\n\t\toutKeywords.SshPasswordAuthentication = newKeywords.SshPasswordAuthentication\n\t}\n\tif newKeywords.SshKbdInteractiveAuthentication != nil {\n\t\toutKeywords.SshKbdInteractiveAuthentication = newKeywords.SshKbdInteractiveAuthentication\n\t}\n\tif newKeywords.SshPreferredAuthentications != nil {\n\t\toutKeywords.SshPreferredAuthentications = newKeywords.SshPreferredAuthentications\n\t}\n\tif newKeywords.SshAddKeysToAgent != nil {\n\t\toutKeywords.SshAddKeysToAgent = newKeywords.SshAddKeysToAgent\n\t}\n\tif newKeywords.SshIdentityAgent != nil {\n\t\toutKeywords.SshIdentityAgent = newKeywords.SshIdentityAgent\n\t}\n\tif newKeywords.SshIdentitiesOnly != nil {\n\t\toutKeywords.SshIdentitiesOnly = newKeywords.SshIdentitiesOnly\n\t}\n\tif newKeywords.SshProxyJump != nil {\n\t\toutKeywords.SshProxyJump = newKeywords.SshProxyJump\n\t}\n\tif newKeywords.SshUserKnownHostsFile != nil {\n\t\toutKeywords.SshUserKnownHostsFile = newKeywords.SshUserKnownHostsFile\n\t}\n\tif newKeywords.SshGlobalKnownHostsFile != nil {\n\t\toutKeywords.SshGlobalKnownHostsFile = newKeywords.SshGlobalKnownHostsFile\n\t}\n\tif newKeywords.SshPasswordSecretName != nil {\n\t\toutKeywords.SshPasswordSecretName = newKeywords.SshPasswordSecretName\n\t}\n\n\treturn &outKeywords\n}\n"
  },
  {
    "path": "pkg/schema/schema.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage schema\n\nimport (\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\nvar schemaHandler http.Handler\n\nfunc GetSchemaHandler() http.Handler {\n\tschemaStaticPath := filepath.Join(wavebase.GetWaveAppPath(), \"schema\")\n\tstat, err := os.Stat(schemaStaticPath)\n\tif schemaHandler == nil {\n\t\tlog.Println(\"Schema is nil, initializing\")\n\t\tif err == nil && stat.IsDir() {\n\t\t\tlog.Printf(\"Found static site at %s, serving\\n\", schemaStaticPath)\n\t\t\tschemaHandler = http.FileServer(JsonDir{http.Dir(schemaStaticPath)})\n\t\t} else {\n\t\t\tlog.Printf(\"Did not find static site at %s, serving not found handler. stat: %v, err: %v\\n\", schemaStaticPath, stat, err)\n\t\t\tschemaHandler = http.NotFoundHandler()\n\t\t}\n\t}\n\treturn addHeaders(schemaHandler)\n}\n\nfunc addHeaders(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Add(\"Content-Type\", \"application/schema+json\")\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\ntype JsonDir struct {\n\td http.Dir\n}\n\nfunc (d JsonDir) Open(name string) (http.File, error) {\n\t// Try name as supplied\n\tf, err := d.d.Open(name)\n\tif os.IsNotExist(err) {\n\t\t// Not found, try with .json\n\t\tif f, err := d.d.Open(name + \".json\"); err == nil {\n\t\t\treturn f, nil\n\t\t}\n\t}\n\treturn f, err\n}\n"
  },
  {
    "path": "pkg/secretstore/secretstore.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage secretstore\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nconst (\n\tSecretsFileName   = \"secrets.enc\"\n\tWriteDebounceMs   = 1000\n\tEncryptionTimeout = 5000\n\tInitRetryMs       = 1000\n\tSecretNamePattern = `^[A-Za-z][A-Za-z0-9_]*$`\n\tWriteTsKey        = \"wave:writets\"\n)\n\nvar lock sync.Mutex\nvar secrets = make(map[string]string)\nvar writeRequestChan chan struct{}\nvar initialized bool\nvar lastInitTryTime time.Time\nvar lastInitErr error\nvar secretNameRegexp = regexp.MustCompile(SecretNamePattern)\nvar linuxStorageBackend string\n\n// must hold lock\nfunc getLinuxStorageBackend() error {\n\tif runtime.GOOS != \"linux\" {\n\t\treturn nil\n\t}\n\n\trpcClient := wshclient.GetBareRpcClient()\n\tctx, cancel := context.WithTimeout(context.Background(), EncryptionTimeout*time.Millisecond)\n\tdefer cancel()\n\n\tencryptData := wshrpc.CommandElectronEncryptData{\n\t\tPlainText: \"hello\",\n\t}\n\trpcOpts := &wshrpc.RpcOpts{\n\t\tRoute:   wshutil.ElectronRoute,\n\t\tTimeout: EncryptionTimeout,\n\t}\n\n\tresult, err := wshclient.ElectronEncryptCommand(rpcClient, encryptData, rpcOpts)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get storage backend: %w\", err)\n\t}\n\n\tif ctx.Err() != nil {\n\t\treturn fmt.Errorf(\"encryption timeout: %w\", ctx.Err())\n\t}\n\n\tif result.StorageBackend != \"\" {\n\t\tlinuxStorageBackend = result.StorageBackend\n\t}\n\n\treturn nil\n}\n\n// must hold lock\nfunc readSecretsFromFile() (map[string]string, error) {\n\tconfigDir := wavebase.GetWaveConfigDir()\n\tsecretsPath := filepath.Join(configDir, SecretsFileName)\n\n\tencryptedData, err := os.ReadFile(secretsPath)\n\tif err != nil {\n\t\tif !os.IsNotExist(err) {\n\t\t\tlog.Printf(\"secretstore: could not read secrets file: %v\\n\", err)\n\t\t}\n\t\tif err := getLinuxStorageBackend(); err != nil {\n\t\t\tlog.Printf(\"secretstore: could not get linux storage backend: %v\\n\", err)\n\t\t}\n\t\treturn make(map[string]string), nil\n\t}\n\n\trpcClient := wshclient.GetBareRpcClient()\n\tctx, cancel := context.WithTimeout(context.Background(), EncryptionTimeout*time.Millisecond)\n\tdefer cancel()\n\n\tdecryptData := wshrpc.CommandElectronDecryptData{\n\t\tCipherText: string(encryptedData),\n\t}\n\trpcOpts := &wshrpc.RpcOpts{\n\t\tRoute:   wshutil.ElectronRoute,\n\t\tTimeout: EncryptionTimeout,\n\t}\n\n\tresult, err := wshclient.ElectronDecryptCommand(rpcClient, decryptData, rpcOpts)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decrypt secrets: %w\", err)\n\t}\n\n\tif ctx.Err() != nil {\n\t\treturn nil, fmt.Errorf(\"decryption timeout: %w\", ctx.Err())\n\t}\n\n\tif result.StorageBackend != \"\" {\n\t\tlinuxStorageBackend = result.StorageBackend\n\t}\n\n\tvar decryptedSecrets map[string]string\n\tif err := json.Unmarshal([]byte(result.PlainText), &decryptedSecrets); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse secrets: %w\", err)\n\t}\n\n\treturn decryptedSecrets, nil\n}\n\nfunc initSecretStore() error {\n\tlock.Lock()\n\tdefer lock.Unlock()\n\tif initialized {\n\t\treturn nil\n\t}\n\n\tnow := time.Now()\n\tif !lastInitTryTime.IsZero() && now.Sub(lastInitTryTime) < InitRetryMs*time.Millisecond {\n\t\treturn lastInitErr\n\t}\n\n\tlastInitTryTime = now\n\tloadedSecrets, err := readSecretsFromFile()\n\tif err != nil {\n\t\tlastInitErr = err\n\t\treturn err\n\t}\n\tsecrets = loadedSecrets\n\n\twriteRequestChan = make(chan struct{}, 1)\n\tinitialized = true\n\tlastInitErr = nil\n\tgo writerLoop()\n\treturn nil\n}\n\nfunc writerLoop() {\n\tvar timer *time.Timer\n\tfor range writeRequestChan {\n\t\tif timer != nil {\n\t\t\ttimer.Stop()\n\t\t}\n\t\ttimer = time.AfterFunc(WriteDebounceMs*time.Millisecond, func() {\n\t\t\tif err := writeSecretsToFile(); err != nil {\n\t\t\t\tlog.Printf(\"secretstore: error writing secrets: %v\\n\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc writeSecretsToFile() error {\n\tlock.Lock()\n\tsecretsCopy := make(map[string]string, len(secrets)+1)\n\tfor k, v := range secrets {\n\t\tsecretsCopy[k] = v\n\t}\n\tsecretsCopy[WriteTsKey] = time.Now().UTC().Format(time.RFC3339)\n\tlock.Unlock()\n\n\tjsonData, err := json.Marshal(secretsCopy)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal secrets: %w\", err)\n\t}\n\n\trpcClient := wshclient.GetBareRpcClient()\n\tctx, cancel := context.WithTimeout(context.Background(), EncryptionTimeout*time.Millisecond)\n\tdefer cancel()\n\n\tencryptData := wshrpc.CommandElectronEncryptData{\n\t\tPlainText: string(jsonData),\n\t}\n\trpcOpts := &wshrpc.RpcOpts{\n\t\tRoute:   wshutil.ElectronRoute,\n\t\tTimeout: EncryptionTimeout,\n\t}\n\n\tresult, err := wshclient.ElectronEncryptCommand(rpcClient, encryptData, rpcOpts)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to encrypt secrets: %w\", err)\n\t}\n\n\tif ctx.Err() != nil {\n\t\treturn fmt.Errorf(\"encryption timeout: %w\", ctx.Err())\n\t}\n\n\tconfigDir := wavebase.GetWaveConfigDir()\n\tsecretsPath := filepath.Join(configDir, SecretsFileName)\n\n\tif err := os.WriteFile(secretsPath, []byte(result.CipherText), 0600); err != nil {\n\t\treturn fmt.Errorf(\"failed to write secrets file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc requestWrite() {\n\tselect {\n\tcase writeRequestChan <- struct{}{}:\n\tdefault:\n\t}\n}\n\nfunc SetSecret(name string, value string) error {\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"secret name cannot be empty\")\n\t}\n\tif !secretNameRegexp.MatchString(name) {\n\t\treturn fmt.Errorf(\"secret name must start with a letter and contain only letters, numbers, and underscores\")\n\t}\n\tif err := initSecretStore(); err != nil {\n\t\treturn err\n\t}\n\tlock.Lock()\n\tdefer lock.Unlock()\n\n\tsecrets[name] = strings.TrimRight(value, \"\\r\\n\")\n\trequestWrite()\n\treturn nil\n}\n\nfunc DeleteSecret(name string) error {\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"secret name cannot be empty\")\n\t}\n\tif err := initSecretStore(); err != nil {\n\t\treturn err\n\t}\n\tlock.Lock()\n\tdefer lock.Unlock()\n\n\tdelete(secrets, name)\n\trequestWrite()\n\treturn nil\n}\n\nfunc GetSecret(name string) (string, bool, error) {\n\tif name == WriteTsKey {\n\t\treturn \"\", false, nil\n\t}\n\tif err := initSecretStore(); err != nil {\n\t\treturn \"\", false, err\n\t}\n\tlock.Lock()\n\tdefer lock.Unlock()\n\n\tvalue, exists := secrets[name]\n\treturn value, exists, nil\n}\n\nfunc GetSecretNames() ([]string, error) {\n\tif err := initSecretStore(); err != nil {\n\t\treturn nil, err\n\t}\n\tlock.Lock()\n\tdefer lock.Unlock()\n\n\tnames := make([]string, 0, len(secrets))\n\tfor name := range secrets {\n\t\tif name == WriteTsKey {\n\t\t\tcontinue\n\t\t}\n\t\tnames = append(names, name)\n\t}\n\treturn names, nil\n}\n\nfunc CountSecrets() (int, error) {\n\tlock.Lock()\n\tdefer lock.Unlock()\n\t\n\tif !initialized {\n\t\treturn 0, fmt.Errorf(\"secret store not initialized\")\n\t}\n\n\tcount := 0\n\tfor name := range secrets {\n\t\tif name == WriteTsKey {\n\t\t\tcontinue\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc GetLinuxStorageBackend() (string, error) {\n\tif runtime.GOOS != \"linux\" {\n\t\treturn \"\", nil\n\t}\n\n\tlock.Lock()\n\tdefer lock.Unlock()\n\n\tif linuxStorageBackend != \"\" {\n\t\treturn linuxStorageBackend, nil\n\t}\n\n\tif err := getLinuxStorageBackend(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif linuxStorageBackend == \"\" {\n\t\treturn \"\", fmt.Errorf(\"failed to determine linux storage backend\")\n\t}\n\n\treturn linuxStorageBackend, nil\n}\n"
  },
  {
    "path": "pkg/service/blockservice/blockservice.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage blockservice\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/blockcontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/filestore\"\n\t\"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcore\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\ntype BlockService struct{}\n\nconst DefaultTimeout = 2 * time.Second\n\nvar BlockServiceInstance = &BlockService{}\n\nfunc (bs *BlockService) SendCommand_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tDesc:     \"send command to block\",\n\t\tArgNames: []string{\"blockid\", \"cmd\"},\n\t}\n}\n\nfunc (bs *BlockService) GetControllerStatus(ctx context.Context, blockId string) (*blockcontroller.BlockControllerRuntimeStatus, error) {\n\treturn blockcontroller.GetBlockControllerRuntimeStatus(blockId), nil\n}\n\nfunc (*BlockService) SaveTerminalState_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tDesc:     \"save the terminal state to a blockfile\",\n\t\tArgNames: []string{\"ctx\", \"blockId\", \"state\", \"stateType\", \"ptyOffset\", \"termSize\"},\n\t}\n}\n\nfunc (bs *BlockService) SaveTerminalState(ctx context.Context, blockId string, state string, stateType string, ptyOffset int64, termSize waveobj.TermSize) error {\n\t_, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif stateType != \"full\" && stateType != \"preview\" {\n\t\treturn fmt.Errorf(\"invalid state type: %q\", stateType)\n\t}\n\t// ignore MakeFile error (already exists is ok)\n\tfilestore.WFS.MakeFile(ctx, blockId, \"cache:term:\"+stateType, nil, wshrpc.FileOpts{})\n\terr = filestore.WFS.WriteFile(ctx, blockId, \"cache:term:\"+stateType, []byte(state))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot save terminal state: %w\", err)\n\t}\n\tfileMeta := wshrpc.FileMeta{\n\t\t\"ptyoffset\": ptyOffset,\n\t\t\"termsize\":  termSize,\n\t}\n\terr = filestore.WFS.WriteMeta(ctx, blockId, \"cache:term:\"+stateType, fileMeta, true)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot save terminal state meta: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (bs *BlockService) SaveWaveAiData(ctx context.Context, blockId string, history []wshrpc.WaveAIPromptMessageType) error {\n\tblock, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)\n\tif err != nil {\n\t\treturn err\n\t}\n\tviewName := block.Meta.GetString(waveobj.MetaKey_View, \"\")\n\tif viewName != \"waveai\" {\n\t\treturn fmt.Errorf(\"invalid view type: %s\", viewName)\n\t}\n\thistoryBytes, err := json.Marshal(history)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to serialize ai history: %v\", err)\n\t}\n\t// ignore MakeFile error (already exists is ok)\n\tfilestore.WFS.MakeFile(ctx, blockId, \"aidata\", nil, wshrpc.FileOpts{})\n\terr = filestore.WFS.WriteFile(ctx, blockId, \"aidata\", historyBytes)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot save terminal state: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (*BlockService) CleanupOrphanedBlocks_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tDesc:     \"queue a layout action to cleanup orphaned blocks in the tab\",\n\t\tArgNames: []string{\"ctx\", \"tabId\"},\n\t}\n}\n\nfunc (bs *BlockService) CleanupOrphanedBlocks(ctx context.Context, tabId string) (waveobj.UpdatesRtnType, error) {\n\tctx = waveobj.ContextWithUpdates(ctx)\n\tlayoutAction := waveobj.LayoutActionData{\n\t\tActionType: wcore.LayoutActionDataType_CleanupOrphaned,\n\t\tActionId:   uuid.NewString(),\n\t}\n\terr := wcore.QueueLayoutActionForTab(ctx, tabId, layoutAction)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error queuing cleanup layout action: %w\", err)\n\t}\n\treturn waveobj.ContextGetUpdatesRtn(ctx), nil\n}\n"
  },
  {
    "path": "pkg/service/clientservice/clientservice.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage clientservice\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/remote/conncontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcore\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wslconn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\ntype ClientService struct{}\n\nconst DefaultTimeout = 2 * time.Second\n\nfunc (cs *ClientService) GetClientData() (*waveobj.Client, error) {\n\tlog.Println(\"GetClientData\")\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\treturn wcore.GetClientData(ctx)\n}\n\nfunc (cs *ClientService) GetTab(tabId string) (*waveobj.Tab, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\ttab, err := wstore.DBGet[*waveobj.Tab](ctx, tabId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting tab: %w\", err)\n\t}\n\treturn tab, nil\n}\n\nfunc (cs *ClientService) GetAllConnStatus(ctx context.Context) ([]wshrpc.ConnStatus, error) {\n\tsshStatuses := conncontroller.GetAllConnStatus()\n\twslStatuses := wslconn.GetAllConnStatus()\n\treturn append(sshStatuses, wslStatuses...), nil\n}\n\n// moves the window to the front of the windowId stack\nfunc (cs *ClientService) FocusWindow(ctx context.Context, windowId string) error {\n\treturn wcore.FocusWindow(ctx, windowId)\n}\n\nfunc (cs *ClientService) AgreeTos(ctx context.Context) (waveobj.UpdatesRtnType, error) {\n\tctx = waveobj.ContextWithUpdates(ctx)\n\tclientData, err := wstore.DBGetSingleton[*waveobj.Client](ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting client data: %w\", err)\n\t}\n\ttimestamp := time.Now().UnixMilli()\n\tclientData.TosAgreed = timestamp\n\terr = wstore.DBUpdate(ctx, clientData)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error updating client data: %w\", err)\n\t}\n\twcore.BootstrapStarterLayout(ctx)\n\treturn waveobj.ContextGetUpdatesRtn(ctx), nil\n}\n\nfunc (cs *ClientService) TelemetryUpdate(ctx context.Context, telemetryEnabled bool) error {\n\tmeta := waveobj.MetaMapType{\n\t\twconfig.ConfigKey_TelemetryEnabled: telemetryEnabled,\n\t}\n\terr := wconfig.SetBaseConfigValue(meta)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting telemetry value: %w\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/service/objectservice/objectservice.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage objectservice\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcore\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\ntype ObjectService struct{}\n\nconst DefaultTimeout = 2 * time.Second\nconst ConnContextTimeout = 60 * time.Second\n\nfunc parseORef(oref string) (*waveobj.ORef, error) {\n\tfields := strings.Split(oref, \":\")\n\tif len(fields) != 2 {\n\t\treturn nil, fmt.Errorf(\"invalid object reference: %q\", oref)\n\t}\n\treturn &waveobj.ORef{OType: fields[0], OID: fields[1]}, nil\n}\n\nfunc (svc *ObjectService) GetObject_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tDesc:     \"get wave object by oref\",\n\t\tArgNames: []string{\"oref\"},\n\t}\n}\n\nfunc (svc *ObjectService) GetObject(orefStr string) (waveobj.WaveObj, error) {\n\toref, err := parseORef(orefStr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tobj, err := wstore.DBGetORef(ctx, *oref)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting object: %w\", err)\n\t}\n\treturn obj, nil\n}\n\nfunc (svc *ObjectService) GetObjects_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames:   []string{\"orefs\"},\n\t\tReturnDesc: \"objects\",\n\t}\n}\n\nfunc (svc *ObjectService) GetObjects(orefStrArr []string) ([]waveobj.WaveObj, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\n\tvar orefArr []waveobj.ORef\n\tfor _, orefStr := range orefStrArr {\n\t\torefObj, err := parseORef(orefStr)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\torefArr = append(orefArr, *orefObj)\n\t}\n\treturn wstore.DBSelectORefs(ctx, orefArr)\n}\n\nfunc (svc *ObjectService) CreateBlock_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames:   []string{\"uiContext\", \"blockDef\", \"rtOpts\"},\n\t\tReturnDesc: \"blockId\",\n\t}\n}\n\nfunc (svc *ObjectService) CreateBlock(uiContext waveobj.UIContext, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (string, waveobj.UpdatesRtnType, error) {\n\tif uiContext.ActiveTabId == \"\" {\n\t\treturn \"\", nil, fmt.Errorf(\"no active tab\")\n\t}\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tctx = waveobj.ContextWithUpdates(ctx)\n\n\tblockData, err := wcore.CreateBlock(ctx, uiContext.ActiveTabId, blockDef, rtOpts)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\n\treturn blockData.OID, waveobj.ContextGetUpdatesRtn(ctx), nil\n}\n\nfunc (svc *ObjectService) DeleteBlock_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames: []string{\"uiContext\", \"blockId\"},\n\t}\n}\n\nfunc (svc *ObjectService) DeleteBlock(uiContext waveobj.UIContext, blockId string) (waveobj.UpdatesRtnType, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tctx = waveobj.ContextWithUpdates(ctx)\n\terr := wcore.DeleteBlock(ctx, blockId, true)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error deleting block: %w\", err)\n\t}\n\treturn waveobj.ContextGetUpdatesRtn(ctx), nil\n}\n\nfunc (svc *ObjectService) UpdateObjectMeta_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames: []string{\"uiContext\", \"oref\", \"meta\"},\n\t}\n}\n\nfunc (svc *ObjectService) UpdateObjectMeta(uiContext waveobj.UIContext, orefStr string, meta waveobj.MetaMapType) (waveobj.UpdatesRtnType, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tctx = waveobj.ContextWithUpdates(ctx)\n\toref, err := parseORef(orefStr)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error parsing object reference: %w\", err)\n\t}\n\terr = wstore.UpdateObjectMeta(ctx, *oref, meta, false)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error updating %q meta: %w\", orefStr, err)\n\t}\n\treturn waveobj.ContextGetUpdatesRtn(ctx), nil\n}\n\nfunc (svc *ObjectService) UpdateObject_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames: []string{\"uiContext\", \"waveObj\", \"returnUpdates\"},\n\t}\n}\n\nfunc (svc *ObjectService) UpdateObject(uiContext waveobj.UIContext, waveObj waveobj.WaveObj, returnUpdates bool) (waveobj.UpdatesRtnType, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tctx = waveobj.ContextWithUpdates(ctx)\n\tif waveObj == nil {\n\t\treturn nil, fmt.Errorf(\"update wavobj is nil\")\n\t}\n\toref := waveobj.ORefFromWaveObj(waveObj)\n\tfound, err := wstore.DBExistsORef(ctx, *oref)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting object: %w\", err)\n\t}\n\tif !found {\n\t\treturn nil, fmt.Errorf(\"object not found: %s\", oref)\n\t}\n\terr = wstore.DBUpdate(ctx, waveObj)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error updating object: %w\", err)\n\t}\n\tif (waveObj.GetOType() == waveobj.OType_Workspace) && (waveObj.(*waveobj.Workspace).Name != \"\") {\n\t\twps.Broker.Publish(wps.WaveEvent{\n\t\t\tEvent: wps.Event_WorkspaceUpdate})\n\t}\n\tif returnUpdates {\n\t\treturn waveobj.ContextGetUpdatesRtn(ctx), nil\n\t}\n\treturn nil, nil\n}\n"
  },
  {
    "path": "pkg/service/service.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage service\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/service/blockservice\"\n\t\"github.com/wavetermdev/waveterm/pkg/service/clientservice\"\n\t\"github.com/wavetermdev/waveterm/pkg/service/objectservice\"\n\t\"github.com/wavetermdev/waveterm/pkg/service/userinputservice\"\n\t\"github.com/wavetermdev/waveterm/pkg/service/windowservice\"\n\t\"github.com/wavetermdev/waveterm/pkg/service/workspaceservice\"\n\t\"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/web/webcmd\"\n)\n\nvar ServiceMap = map[string]any{\n\t\"block\":     blockservice.BlockServiceInstance,\n\t\"object\":    &objectservice.ObjectService{},\n\t\"client\":    &clientservice.ClientService{},\n\t\"window\":    &windowservice.WindowService{},\n\t\"workspace\": &workspaceservice.WorkspaceService{},\n\t\"userinput\": &userinputservice.UserInputService{},\n}\n\nvar contextRType = reflect.TypeOf((*context.Context)(nil)).Elem()\nvar errorRType = reflect.TypeOf((*error)(nil)).Elem()\nvar updatesRType = reflect.TypeOf(([]waveobj.WaveObjUpdate{}))\nvar waveObjRType = reflect.TypeOf((*waveobj.WaveObj)(nil)).Elem()\nvar waveObjSliceRType = reflect.TypeOf([]waveobj.WaveObj{})\nvar waveObjMapRType = reflect.TypeOf(map[string]waveobj.WaveObj{})\nvar methodMetaRType = reflect.TypeOf(tsgenmeta.MethodMeta{})\nvar waveObjUpdateRType = reflect.TypeOf(waveobj.WaveObjUpdate{})\nvar uiContextRType = reflect.TypeOf((*waveobj.UIContext)(nil)).Elem()\nvar wsCommandRType = reflect.TypeOf((*webcmd.WSCommandType)(nil)).Elem()\nvar orefRType = reflect.TypeOf((*waveobj.ORef)(nil)).Elem()\n\ntype WebCallType struct {\n\tService   string             `json:\"service\"`\n\tMethod    string             `json:\"method\"`\n\tUIContext *waveobj.UIContext `json:\"uicontext,omitempty\"`\n\tArgs      []any              `json:\"args\"`\n}\n\ntype WebReturnType struct {\n\tSuccess bool                    `json:\"success,omitempty\"`\n\tError   string                  `json:\"error,omitempty\"`\n\tData    any                     `json:\"data,omitempty\"`\n\tUpdates []waveobj.WaveObjUpdate `json:\"updates,omitempty\"`\n}\n\nfunc convertNumber(argType reflect.Type, jsonArg float64) (any, error) {\n\tswitch argType.Kind() {\n\tcase reflect.Int:\n\t\treturn int(jsonArg), nil\n\tcase reflect.Int8:\n\t\treturn int8(jsonArg), nil\n\tcase reflect.Int16:\n\t\treturn int16(jsonArg), nil\n\tcase reflect.Int32:\n\t\treturn int32(jsonArg), nil\n\tcase reflect.Int64:\n\t\treturn int64(jsonArg), nil\n\tcase reflect.Uint:\n\t\treturn uint(jsonArg), nil\n\tcase reflect.Uint8:\n\t\treturn uint8(jsonArg), nil\n\tcase reflect.Uint16:\n\t\treturn uint16(jsonArg), nil\n\tcase reflect.Uint32:\n\t\treturn uint32(jsonArg), nil\n\tcase reflect.Uint64:\n\t\treturn uint64(jsonArg), nil\n\tcase reflect.Float32:\n\t\treturn float32(jsonArg), nil\n\tcase reflect.Float64:\n\t\treturn jsonArg, nil\n\t}\n\treturn nil, fmt.Errorf(\"invalid number type %s\", argType)\n}\n\nfunc convertComplex(argType reflect.Type, jsonArg any) (any, error) {\n\tnativeArgVal := reflect.New(argType)\n\terr := utilfn.DoMapStructure(nativeArgVal.Interface(), jsonArg)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn nativeArgVal.Elem().Interface(), nil\n}\n\nfunc isSpecialWaveArgType(argType reflect.Type) bool {\n\treturn argType == waveObjRType || argType == waveObjSliceRType || argType == waveObjMapRType || argType == wsCommandRType\n}\n\nfunc convertWSCommand(argType reflect.Type, jsonArg any) (any, error) {\n\tif _, ok := jsonArg.(map[string]any); !ok {\n\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s\", jsonArg, argType)\n\t}\n\tcmd, err := webcmd.ParseWSCommandMap(jsonArg.(map[string]any))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error parsing command map: %w\", err)\n\t}\n\treturn cmd, nil\n}\n\nfunc convertSpecial(argType reflect.Type, jsonArg any) (any, error) {\n\tjsonType := reflect.TypeOf(jsonArg)\n\tif argType == orefRType {\n\t\tif jsonType.Kind() != reflect.String {\n\t\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s\", jsonArg, argType)\n\t\t}\n\t\toref, err := waveobj.ParseORef(jsonArg.(string))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid oref string: %v\", err)\n\t\t}\n\t\treturn oref, nil\n\t} else if argType == wsCommandRType {\n\t\treturn convertWSCommand(argType, jsonArg)\n\t} else if argType == waveObjRType {\n\t\tif jsonType.Kind() != reflect.Map {\n\t\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s\", jsonArg, argType)\n\t\t}\n\t\treturn waveobj.FromJsonMap(jsonArg.(map[string]any))\n\t} else if argType == waveObjSliceRType {\n\t\tif jsonType.Kind() != reflect.Slice {\n\t\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s\", jsonArg, argType)\n\t\t}\n\t\tsliceArg := jsonArg.([]any)\n\t\tnativeSlice := make([]waveobj.WaveObj, len(sliceArg))\n\t\tfor idx, elem := range sliceArg {\n\t\t\telemMap, ok := elem.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s (idx %d is not a map, is %T)\", jsonArg, waveObjSliceRType, idx, elem)\n\t\t\t}\n\t\t\tnativeObj, err := waveobj.FromJsonMap(elemMap)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s (idx %d) error: %v\", jsonArg, waveObjSliceRType, idx, err)\n\t\t\t}\n\t\t\tnativeSlice[idx] = nativeObj\n\t\t}\n\t\treturn nativeSlice, nil\n\t} else if argType == waveObjMapRType {\n\t\tif jsonType.Kind() != reflect.Map {\n\t\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s\", jsonArg, argType)\n\t\t}\n\t\tmapArg := jsonArg.(map[string]any)\n\t\tnativeMap := make(map[string]waveobj.WaveObj)\n\t\tfor key, elem := range mapArg {\n\t\t\telemMap, ok := elem.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s (key %s is not a map, is %T)\", jsonArg, waveObjMapRType, key, elem)\n\t\t\t}\n\t\t\tnativeObj, err := waveobj.FromJsonMap(elemMap)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s (key %s) error: %v\", jsonArg, waveObjMapRType, key, err)\n\t\t\t}\n\t\t\tnativeMap[key] = nativeObj\n\t\t}\n\t\treturn nativeMap, nil\n\t} else {\n\t\treturn nil, fmt.Errorf(\"invalid special wave argument type %s\", argType)\n\t}\n}\n\nfunc convertSpecialForReturn(argType reflect.Type, nativeArg any) (any, error) {\n\tif argType == waveObjRType {\n\t\treturn waveobj.ToJsonMap(nativeArg.(waveobj.WaveObj))\n\t} else if argType == waveObjSliceRType {\n\t\tnativeSlice := nativeArg.([]waveobj.WaveObj)\n\t\tjsonSlice := make([]map[string]any, len(nativeSlice))\n\t\tfor idx, elem := range nativeSlice {\n\t\t\telemMap, err := waveobj.ToJsonMap(elem)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tjsonSlice[idx] = elemMap\n\t\t}\n\t\treturn jsonSlice, nil\n\t} else if argType == waveObjMapRType {\n\t\tnativeMap := nativeArg.(map[string]waveobj.WaveObj)\n\t\tjsonMap := make(map[string]map[string]any)\n\t\tfor key, elem := range nativeMap {\n\t\t\telemMap, err := waveobj.ToJsonMap(elem)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tjsonMap[key] = elemMap\n\t\t}\n\t\treturn jsonMap, nil\n\t} else {\n\t\treturn nil, fmt.Errorf(\"invalid special wave argument type %s\", argType)\n\t}\n}\n\nfunc convertArgument(argType reflect.Type, jsonArg any) (any, error) {\n\tif jsonArg == nil {\n\t\treturn reflect.Zero(argType).Interface(), nil\n\t}\n\tif isSpecialWaveArgType(argType) {\n\t\treturn convertSpecial(argType, jsonArg)\n\t}\n\tjsonType := reflect.TypeOf(jsonArg)\n\tswitch argType.Kind() {\n\tcase reflect.String:\n\t\tif jsonType.Kind() == reflect.String {\n\t\t\treturn jsonArg, nil\n\t\t}\n\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s\", jsonArg, argType)\n\n\tcase reflect.Bool:\n\t\tif jsonType.Kind() == reflect.Bool {\n\t\t\treturn jsonArg, nil\n\t\t}\n\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s\", jsonArg, argType)\n\n\tcase reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,\n\t\treflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,\n\t\treflect.Float32, reflect.Float64:\n\t\tif jsonType.Kind() == reflect.Float64 {\n\t\t\treturn convertNumber(argType, jsonArg.(float64))\n\t\t}\n\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s\", jsonArg, argType)\n\n\tcase reflect.Map:\n\t\tif argType.Key().Kind() != reflect.String {\n\t\t\treturn nil, fmt.Errorf(\"invalid map key type %s\", argType.Key())\n\t\t}\n\t\tif jsonType.Kind() != reflect.Map {\n\t\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s\", jsonArg, argType)\n\t\t}\n\t\treturn convertComplex(argType, jsonArg)\n\n\tcase reflect.Slice:\n\t\tif jsonType.Kind() != reflect.Slice {\n\t\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s\", jsonArg, argType)\n\t\t}\n\t\treturn convertComplex(argType, jsonArg)\n\n\tcase reflect.Struct:\n\t\tif jsonType.Kind() != reflect.Map {\n\t\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s\", jsonArg, argType)\n\t\t}\n\t\treturn convertComplex(argType, jsonArg)\n\n\tcase reflect.Ptr:\n\t\tif argType.Elem().Kind() != reflect.Struct {\n\t\t\treturn nil, fmt.Errorf(\"invalid pointer type %s\", argType)\n\t\t}\n\t\tif jsonType.Kind() != reflect.Map {\n\t\t\treturn nil, fmt.Errorf(\"cannot convert %T to %s\", jsonArg, argType)\n\t\t}\n\t\treturn convertComplex(argType, jsonArg)\n\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"invalid argument type %s\", argType)\n\t}\n}\n\nfunc isNilable(val reflect.Value) bool {\n\tswitch val.Kind() {\n\tcase reflect.Ptr, reflect.Slice, reflect.Map, reflect.Interface, reflect.Chan, reflect.Func:\n\t\treturn true\n\t}\n\treturn false\n\n}\n\nfunc convertReturnValues(rtnVals []reflect.Value) *WebReturnType {\n\trtn := &WebReturnType{}\n\tif len(rtnVals) == 0 {\n\t\treturn rtn\n\t}\n\tfor _, val := range rtnVals {\n\t\tif isNilable(val) && val.IsNil() {\n\t\t\tcontinue\n\t\t}\n\t\tvalType := val.Type()\n\t\tif valType == errorRType {\n\t\t\trtn.Error = val.Interface().(error).Error()\n\t\t\tcontinue\n\t\t}\n\t\tif valType == updatesRType {\n\t\t\t// has a special MarshalJSON method\n\t\t\trtn.Updates = val.Interface().([]waveobj.WaveObjUpdate)\n\t\t\tcontinue\n\t\t}\n\t\tif isSpecialWaveArgType(valType) {\n\t\t\tjsonVal, err := convertSpecialForReturn(valType, val.Interface())\n\t\t\tif err != nil {\n\t\t\t\trtn.Error = fmt.Errorf(\"cannot convert special return value: %v\", err).Error()\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\trtn.Data = jsonVal\n\t\t\tcontinue\n\t\t}\n\t\trtn.Data = val.Interface()\n\t}\n\tif rtn.Error == \"\" {\n\t\trtn.Success = true\n\t}\n\treturn rtn\n}\n\nfunc webErrorRtn(err error) *WebReturnType {\n\treturn &WebReturnType{\n\t\tError: err.Error(),\n\t}\n}\n\nfunc CallService(ctx context.Context, webCall WebCallType) *WebReturnType {\n\tsvcObj := ServiceMap[webCall.Service]\n\tif svcObj == nil {\n\t\treturn webErrorRtn(fmt.Errorf(\"invalid service: %q\", webCall.Service))\n\t}\n\tmethod := reflect.ValueOf(svcObj).MethodByName(webCall.Method)\n\tif !method.IsValid() {\n\t\treturn webErrorRtn(fmt.Errorf(\"invalid method: %s.%s\", webCall.Service, webCall.Method))\n\t}\n\tvar valueArgs []reflect.Value\n\targIdx := 0\n\tfor idx := 0; idx < method.Type().NumIn(); idx++ {\n\t\targType := method.Type().In(idx)\n\t\tif idx == 0 && argType == contextRType {\n\t\t\tvalueArgs = append(valueArgs, reflect.ValueOf(ctx))\n\t\t\tcontinue\n\t\t}\n\t\tif argType == uiContextRType {\n\t\t\tif webCall.UIContext == nil {\n\t\t\t\treturn webErrorRtn(fmt.Errorf(\"missing UIContext for %s.%s\", webCall.Service, webCall.Method))\n\t\t\t}\n\t\t\tvalueArgs = append(valueArgs, reflect.ValueOf(*webCall.UIContext))\n\t\t\tcontinue\n\t\t}\n\t\tif argIdx >= len(webCall.Args) {\n\t\t\treturn webErrorRtn(fmt.Errorf(\"not enough arguments passed %s.%s idx:%d (type %T)\", webCall.Service, webCall.Method, idx, argType))\n\t\t}\n\t\tnativeArg, err := convertArgument(argType, webCall.Args[argIdx])\n\t\tif err != nil {\n\t\t\treturn webErrorRtn(fmt.Errorf(\"cannot convert argument %s.%s type:%T idx:%d error:%v\", webCall.Service, webCall.Method, argType, idx, err))\n\t\t}\n\t\tvalueArgs = append(valueArgs, reflect.ValueOf(nativeArg))\n\t\targIdx++\n\t}\n\tretValArr := method.Call(valueArgs)\n\treturn convertReturnValues(retValArr)\n}\n\n// ValidateServiceArg validates the argument type for a service method\n// does not allow interfaces (and the obvious invalid types)\n// arguments + return values have special handling for wave objects\nfunc baseValidateServiceArg(argType reflect.Type) error {\n\tif argType == waveObjUpdateRType {\n\t\t// has special MarshalJSON method, so it is safe\n\t\treturn nil\n\t}\n\tswitch argType.Kind() {\n\tcase reflect.Ptr, reflect.Slice, reflect.Array:\n\t\treturn baseValidateServiceArg(argType.Elem())\n\tcase reflect.Map:\n\t\tif argType.Key().Kind() != reflect.String {\n\t\t\treturn fmt.Errorf(\"invalid map key type %s\", argType.Key())\n\t\t}\n\t\treturn baseValidateServiceArg(argType.Elem())\n\tcase reflect.Struct:\n\t\tfor idx := 0; idx < argType.NumField(); idx++ {\n\t\t\tif err := baseValidateServiceArg(argType.Field(idx).Type); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\tcase reflect.Interface:\n\t\treturn fmt.Errorf(\"invalid argument type %s: contains interface\", argType)\n\n\tcase reflect.Chan, reflect.Func, reflect.Complex128, reflect.Complex64, reflect.Invalid, reflect.Uintptr, reflect.UnsafePointer:\n\t\treturn fmt.Errorf(\"invalid argument type %s\", argType)\n\t}\n\treturn nil\n}\n\nfunc validateMethodReturnArg(retType reflect.Type) error {\n\t// specifically allow waveobj.WaveObj, []waveobj.WaveObj, map[string]waveobj.WaveObj, and error\n\tif isSpecialWaveArgType(retType) || retType == errorRType {\n\t\treturn nil\n\t}\n\treturn baseValidateServiceArg(retType)\n}\n\nfunc validateMethodArg(argType reflect.Type) error {\n\t// specifically allow waveobj.WaveObj, []waveobj.WaveObj, map[string]waveobj.WaveObj, and context.Context\n\tif isSpecialWaveArgType(argType) || argType == contextRType {\n\t\treturn nil\n\t}\n\treturn baseValidateServiceArg(argType)\n}\n\nfunc validateServiceMethod(service string, method reflect.Method) error {\n\tfor idx := 0; idx < method.Type.NumOut(); idx++ {\n\t\tif err := validateMethodReturnArg(method.Type.Out(idx)); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid return type %s.%s %s: %v\", service, method.Name, method.Type.Out(idx), err)\n\t\t}\n\t}\n\tfor idx := 1; idx < method.Type.NumIn(); idx++ {\n\t\t// skip the first argument which is the receiver\n\t\tif err := validateMethodArg(method.Type.In(idx)); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid argument type %s.%s %s: %v\", service, method.Name, method.Type.In(idx), err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc validateServiceMetaMethod(service string, method reflect.Method) error {\n\tif method.Type.NumIn() != 1 {\n\t\treturn fmt.Errorf(\"invalid number of arguments %s.%s: got:%d, expected just the receiver\", service, method.Name, method.Type.NumIn())\n\t}\n\tif method.Type.NumOut() != 1 && method.Type.Out(0) != methodMetaRType {\n\t\treturn fmt.Errorf(\"invalid return type %s.%s: got:%s, expected servicemeta.MethodMeta\", service, method.Name, method.Type.Out(0))\n\t}\n\treturn nil\n}\n\nfunc ValidateService(serviceName string, svcObj any) error {\n\tsvcType := reflect.TypeOf(svcObj)\n\tif svcType.Kind() != reflect.Ptr {\n\t\treturn fmt.Errorf(\"service object %q must be a pointer\", serviceName)\n\t}\n\tsvcType = svcType.Elem()\n\tif svcType.Kind() != reflect.Struct {\n\t\treturn fmt.Errorf(\"service object %q must be a ptr to struct\", serviceName)\n\t}\n\tfor idx := 0; idx < svcType.NumMethod(); idx++ {\n\t\tmethod := svcType.Method(idx)\n\t\tif strings.HasSuffix(method.Name, \"_Meta\") {\n\t\t\terr := validateServiceMetaMethod(serviceName, method)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif err := validateServiceMethod(serviceName, method); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ValidateServiceMap() error {\n\tfor svcName, svcObj := range ServiceMap {\n\t\tif err := ValidateService(svcName, svcObj); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/service/userinputservice/userinputservice.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage userinputservice\n\nimport (\n\t\"github.com/wavetermdev/waveterm/pkg/userinput\"\n)\n\ntype UserInputService struct {\n}\n\nfunc (uis *UserInputService) SendUserInputResponse(response *userinput.UserInputResponse) {\n\tselect {\n\tcase userinput.MainUserInputHandler.Channels[response.RequestId] <- response:\n\tdefault:\n\t}\n}\n"
  },
  {
    "path": "pkg/service/windowservice/windowservice.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage windowservice\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcore\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nconst DefaultTimeout = 2 * time.Second\n\ntype WindowService struct{}\n\nfunc (svc *WindowService) GetWindow_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames: []string{\"windowId\"},\n\t}\n}\n\nfunc (svc *WindowService) GetWindow(windowId string) (*waveobj.Window, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\twindow, err := wstore.DBGet[*waveobj.Window](ctx, windowId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting window: %w\", err)\n\t}\n\treturn window, nil\n}\n\nfunc (svc *WindowService) CreateWindow_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames: []string{\"ctx\", \"winSize\", \"workspaceId\"},\n\t}\n}\n\nfunc (svc *WindowService) CreateWindow(ctx context.Context, winSize *waveobj.WinSize, workspaceId string) (*waveobj.Window, error) {\n\twindow, err := wcore.CreateWindow(ctx, winSize, workspaceId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating window: %w\", err)\n\t}\n\treturn window, nil\n}\n\nfunc (svc *WindowService) SetWindowPosAndSize_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tDesc:     \"set window position and size\",\n\t\tArgNames: []string{\"ctx\", \"windowId\", \"pos\", \"size\"},\n\t}\n}\n\nfunc (ws *WindowService) SetWindowPosAndSize(ctx context.Context, windowId string, pos *waveobj.Point, size *waveobj.WinSize) (waveobj.UpdatesRtnType, error) {\n\tif pos == nil && size == nil {\n\t\treturn nil, nil\n\t}\n\tctx = waveobj.ContextWithUpdates(ctx)\n\twin, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif pos != nil {\n\t\twin.Pos = *pos\n\t}\n\tif size != nil {\n\t\twin.WinSize = *size\n\t}\n\twin.IsNew = false\n\terr = wstore.DBUpdate(ctx, win)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn waveobj.ContextGetUpdatesRtn(ctx), nil\n}\n\nfunc (svc *WindowService) SwitchWorkspace_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames: []string{\"ctx\", \"windowId\", \"workspaceId\"},\n\t}\n}\n\nfunc (svc *WindowService) SwitchWorkspace(ctx context.Context, windowId string, workspaceId string) (*waveobj.Workspace, error) {\n\tctx = waveobj.ContextWithUpdates(ctx)\n\tws, err := wcore.SwitchWorkspace(ctx, windowId, workspaceId)\n\n\tupdates := waveobj.ContextGetUpdatesRtn(ctx)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"WindowService:SwitchWorkspace:SendUpdateEvents\", recover())\n\t\t}()\n\t\twps.Broker.SendUpdateEvents(updates)\n\t}()\n\treturn ws, err\n}\n\nfunc (svc *WindowService) CloseWindow_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames: []string{\"ctx\", \"windowId\", \"fromElectron\"},\n\t}\n}\n\nfunc (svc *WindowService) CloseWindow(ctx context.Context, windowId string, fromElectron bool) error {\n\tctx = waveobj.ContextWithUpdates(ctx)\n\treturn wcore.CloseWindow(ctx, windowId, fromElectron)\n}\n"
  },
  {
    "path": "pkg/service/workspaceservice/workspaceservice.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage workspaceservice\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/blockcontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcore\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nconst DefaultTimeout = 2 * time.Second\n\ntype WorkspaceService struct{}\n\nfunc (svc *WorkspaceService) CreateWorkspace_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames:   []string{\"ctx\", \"name\", \"icon\", \"color\", \"applyDefaults\"},\n\t\tReturnDesc: \"workspaceId\",\n\t}\n}\n\nfunc (svc *WorkspaceService) CreateWorkspace(ctx context.Context, name string, icon string, color string, applyDefaults bool) (string, error) {\n\tnewWS, err := wcore.CreateWorkspace(ctx, name, icon, color, applyDefaults, false)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error creating workspace: %w\", err)\n\t}\n\treturn newWS.OID, nil\n}\n\nfunc (svc *WorkspaceService) UpdateWorkspace_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames: []string{\"ctx\", \"workspaceId\", \"name\", \"icon\", \"color\", \"applyDefaults\"},\n\t}\n}\n\nfunc (svc *WorkspaceService) UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon string, color string, applyDefaults bool) (waveobj.UpdatesRtnType, error) {\n\tctx = waveobj.ContextWithUpdates(ctx)\n\t_, updated, err := wcore.UpdateWorkspace(ctx, workspaceId, name, icon, color, applyDefaults)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error updating workspace: %w\", err)\n\t}\n\tif !updated {\n\t\treturn nil, nil\n\t}\n\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent: wps.Event_WorkspaceUpdate,\n\t})\n\n\tupdates := waveobj.ContextGetUpdatesRtn(ctx)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"WorkspaceService:UpdateWorkspace:SendUpdateEvents\", recover())\n\t\t}()\n\t\twps.Broker.SendUpdateEvents(updates)\n\t}()\n\treturn updates, nil\n}\n\nfunc (svc *WorkspaceService) GetWorkspace_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames:   []string{\"workspaceId\"},\n\t\tReturnDesc: \"workspace\",\n\t}\n}\n\nfunc (svc *WorkspaceService) GetWorkspace(workspaceId string) (*waveobj.Workspace, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tws, err := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting workspace: %w\", err)\n\t}\n\treturn ws, nil\n}\n\nfunc (svc *WorkspaceService) DeleteWorkspace_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames: []string{\"workspaceId\"},\n\t}\n}\n\nfunc (svc *WorkspaceService) DeleteWorkspace(workspaceId string) (waveobj.UpdatesRtnType, string, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tctx = waveobj.ContextWithUpdates(ctx)\n\tdeleted, claimableWorkspace, err := wcore.DeleteWorkspace(ctx, workspaceId, true)\n\tif claimableWorkspace != \"\" {\n\t\treturn nil, claimableWorkspace, nil\n\t}\n\tif err != nil {\n\t\treturn nil, claimableWorkspace, fmt.Errorf(\"error deleting workspace: %w\", err)\n\t}\n\tif !deleted {\n\t\treturn nil, claimableWorkspace, nil\n\t}\n\tupdates := waveobj.ContextGetUpdatesRtn(ctx)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"WorkspaceService:DeleteWorkspace:SendUpdateEvents\", recover())\n\t\t}()\n\t\twps.Broker.SendUpdateEvents(updates)\n\t}()\n\treturn updates, claimableWorkspace, nil\n}\n\nfunc (svc *WorkspaceService) ListWorkspaces() (waveobj.WorkspaceList, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\treturn wcore.ListWorkspaces(ctx)\n}\n\nfunc (svc *WorkspaceService) CreateTab_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames:   []string{\"workspaceId\", \"tabName\", \"activateTab\"},\n\t\tReturnDesc: \"tabId\",\n\t}\n}\n\nfunc (svc *WorkspaceService) GetColors_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tReturnDesc: \"colors\",\n\t}\n}\n\nfunc (svc *WorkspaceService) GetColors() []string {\n\treturn wcore.WorkspaceColors[:]\n}\n\nfunc (svc *WorkspaceService) GetIcons_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tReturnDesc: \"icons\",\n\t}\n}\n\nfunc (svc *WorkspaceService) GetIcons() []string {\n\treturn wcore.WorkspaceIcons[:]\n}\n\nfunc (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tctx = waveobj.ContextWithUpdates(ctx)\n\ttabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab, false)\n\tif err != nil {\n\t\treturn \"\", nil, fmt.Errorf(\"error creating tab: %w\", err)\n\t}\n\tupdates := waveobj.ContextGetUpdatesRtn(ctx)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"WorkspaceService:CreateTab:SendUpdateEvents\", recover())\n\t\t}()\n\t\twps.Broker.SendUpdateEvents(updates)\n\t}()\n\treturn tabId, updates, nil\n}\n\nfunc (svc *WorkspaceService) SetActiveTab_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames: []string{\"workspaceId\", \"tabId\"},\n\t}\n}\n\nfunc (svc *WorkspaceService) SetActiveTab(workspaceId string, tabId string) (waveobj.UpdatesRtnType, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancelFn()\n\tctx = waveobj.ContextWithUpdates(ctx)\n\terr := wcore.SetActiveTab(ctx, workspaceId, tabId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error setting active tab: %w\", err)\n\t}\n\t// check all blocks in tab and start controllers (if necessary)\n\ttab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting tab: %w\", err)\n\t}\n\tblockORefs := tab.GetBlockORefs()\n\tblocks, err := wstore.DBSelectORefs(ctx, blockORefs)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting tab blocks: %w\", err)\n\t}\n\tupdates := waveobj.ContextGetUpdatesRtn(ctx)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"WorkspaceService:SetActiveTab:SendUpdateEvents\", recover())\n\t\t}()\n\t\twps.Broker.SendUpdateEvents(updates)\n\t}()\n\tvar extraUpdates waveobj.UpdatesRtnType\n\textraUpdates = append(extraUpdates, updates...)\n\textraUpdates = append(extraUpdates, waveobj.MakeUpdate(tab))\n\textraUpdates = append(extraUpdates, waveobj.MakeUpdates(blocks)...)\n\treturn extraUpdates, nil\n}\n\ntype CloseTabRtnType struct {\n\tCloseWindow    bool   `json:\"closewindow,omitempty\"`\n\tNewActiveTabId string `json:\"newactivetabid,omitempty\"`\n}\n\nfunc (svc *WorkspaceService) CloseTab_Meta() tsgenmeta.MethodMeta {\n\treturn tsgenmeta.MethodMeta{\n\t\tArgNames:   []string{\"ctx\", \"workspaceId\", \"tabId\", \"fromElectron\"},\n\t\tReturnDesc: \"CloseTabRtn\",\n\t}\n}\n\n// returns the new active tabid\nfunc (svc *WorkspaceService) CloseTab(ctx context.Context, workspaceId string, tabId string, fromElectron bool) (*CloseTabRtnType, waveobj.UpdatesRtnType, error) {\n\tctx = waveobj.ContextWithUpdates(ctx)\n\ttab, err := wstore.DBGet[*waveobj.Tab](ctx, tabId)\n\tif err == nil && tab != nil {\n\t\tgo func() {\n\t\t\tfor _, blockId := range tab.BlockIds {\n\t\t\t\tblockcontroller.DestroyBlockController(blockId)\n\t\t\t}\n\t\t}()\n\t}\n\tnewActiveTabId, err := wcore.DeleteTab(ctx, workspaceId, tabId, true)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"error closing tab: %w\", err)\n\t}\n\trtn := &CloseTabRtnType{}\n\tif newActiveTabId == \"\" {\n\t\trtn.CloseWindow = true\n\t} else {\n\t\trtn.NewActiveTabId = newActiveTabId\n\t}\n\tupdates := waveobj.ContextGetUpdatesRtn(ctx)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"WorkspaceService:CloseTab:SendUpdateEvents\", recover())\n\t\t}()\n\t\twps.Broker.SendUpdateEvents(updates)\n\t}()\n\treturn rtn, updates, nil\n}\n"
  },
  {
    "path": "pkg/shellexec/conninterface.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage shellexec\n\nimport (\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"runtime\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/creack/pty\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/unixutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wsl\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\ntype ConnInterface interface {\n\tKill()\n\tKillGraceful(time.Duration)\n\tWait() error\n\tStart() error\n\tExitCode() int\n\tExitSignal() string\n\tStdinPipe() (io.WriteCloser, error)\n\tStdoutPipe() (io.ReadCloser, error)\n\tStderrPipe() (io.ReadCloser, error)\n\tSetSize(w int, h int) error\n\tpty.Pty\n}\n\ntype CmdWrap struct {\n\tCmd      *exec.Cmd\n\tIsShell  bool\n\tWaitOnce *sync.Once\n\tWaitErr  error\n\tpty.Pty\n}\n\nfunc MakeCmdWrap(cmd *exec.Cmd, cmdPty pty.Pty, isShell bool) CmdWrap {\n\treturn CmdWrap{\n\t\tCmd:      cmd,\n\t\tIsShell:  isShell,\n\t\tWaitOnce: &sync.Once{},\n\t\tPty:      cmdPty,\n\t}\n}\n\nfunc (cw CmdWrap) Kill() {\n\tcw.Cmd.Process.Kill()\n}\n\nfunc (cw CmdWrap) Wait() error {\n\tcw.WaitOnce.Do(func() {\n\t\tcw.WaitErr = cw.Cmd.Wait()\n\t})\n\treturn cw.WaitErr\n}\n\n// only valid once Wait() has returned (or you know Cmd is done)\nfunc (cw CmdWrap) ExitCode() int {\n\tstate := cw.Cmd.ProcessState\n\tif state == nil {\n\t\treturn -1\n\t}\n\treturn state.ExitCode()\n}\n\nfunc (cw CmdWrap) ExitSignal() string {\n\tstate := cw.Cmd.ProcessState\n\tif state == nil {\n\t\treturn \"\"\n\t}\n\tif ws, ok := state.Sys().(syscall.WaitStatus); ok {\n\t\tif ws.Signaled() {\n\t\t\treturn unixutil.GetSignalName(ws.Signal())\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc (cw CmdWrap) KillGraceful(timeout time.Duration) {\n\tif cw.Cmd.Process == nil {\n\t\treturn\n\t}\n\tif cw.Cmd.ProcessState != nil && cw.Cmd.ProcessState.Exited() {\n\t\treturn\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\tcw.Cmd.Process.Kill()\n\t\treturn\n\t}\n\tif cw.IsShell {\n\t\tunixutil.SignalHup(cw.Cmd.Process.Pid)\n\t} else {\n\t\tunixutil.SignalTerm(cw.Cmd.Process.Pid)\n\t}\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"KillGraceful:Kill\", recover())\n\t\t}()\n\t\ttime.Sleep(timeout)\n\t\tif cw.Cmd.ProcessState == nil || !cw.Cmd.ProcessState.Exited() {\n\t\t\tcw.Cmd.Process.Kill() // force kill if it is already not exited\n\t\t}\n\t}()\n}\n\nfunc (cw CmdWrap) Start() error {\n\tdefer func() {\n\t\tfor _, extraFile := range cw.Cmd.ExtraFiles {\n\t\t\tif extraFile != nil {\n\t\t\t\textraFile.Close()\n\t\t\t}\n\t\t}\n\t}()\n\treturn cw.Cmd.Start()\n}\n\nfunc (cw CmdWrap) StdinPipe() (io.WriteCloser, error) {\n\treturn cw.Cmd.StdinPipe()\n}\n\nfunc (cw CmdWrap) StdoutPipe() (io.ReadCloser, error) {\n\treturn cw.Cmd.StdoutPipe()\n}\n\nfunc (cw CmdWrap) StderrPipe() (io.ReadCloser, error) {\n\treturn cw.Cmd.StderrPipe()\n}\n\nfunc (cw CmdWrap) SetSize(w int, h int) error {\n\terr := pty.Setsize(cw.Pty, &pty.Winsize{Rows: uint16(w), Cols: uint16(h)})\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\ntype SessionWrap struct {\n\tSession  *ssh.Session\n\tStartCmd string\n\tTty      pty.Tty\n\tWaitOnce *sync.Once\n\tWaitErr  error\n\tpty.Pty\n}\n\nfunc MakeSessionWrap(session *ssh.Session, startCmd string, sessionPty pty.Pty) SessionWrap {\n\treturn SessionWrap{\n\t\tSession:  session,\n\t\tStartCmd: startCmd,\n\t\tTty:      sessionPty,\n\t\tWaitOnce: &sync.Once{},\n\t\tPty:      sessionPty,\n\t}\n}\n\nfunc (sw SessionWrap) Kill() {\n\tsw.Tty.Close()\n\tsw.Session.Close()\n}\n\nfunc (sw SessionWrap) KillGraceful(timeout time.Duration) {\n\tsw.Kill()\n}\n\nfunc (sw SessionWrap) ExitCode() int {\n\twaitErr := sw.WaitErr\n\tif waitErr == nil {\n\t\treturn -1\n\t}\n\treturn ExitCodeFromWaitErr(waitErr)\n}\n\nfunc (sw SessionWrap) ExitSignal() string {\n\tif sw.WaitErr == nil {\n\t\treturn \"\"\n\t}\n\tif exitErr, ok := sw.WaitErr.(*ssh.ExitError); ok {\n\t\tsignal := exitErr.Signal()\n\t\tif signal != \"\" {\n\t\t\treturn signal\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc (sw SessionWrap) Wait() error {\n\tsw.WaitOnce.Do(func() {\n\t\tsw.WaitErr = sw.Session.Wait()\n\t})\n\treturn sw.WaitErr\n}\n\nfunc (sw SessionWrap) Start() error {\n\treturn sw.Session.Start(sw.StartCmd)\n}\n\nfunc (sw SessionWrap) StdinPipe() (io.WriteCloser, error) {\n\treturn sw.Session.StdinPipe()\n}\n\nfunc (sw SessionWrap) StdoutPipe() (io.ReadCloser, error) {\n\tstdoutReader, err := sw.Session.StdoutPipe()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn io.NopCloser(stdoutReader), nil\n}\n\nfunc (sw SessionWrap) StderrPipe() (io.ReadCloser, error) {\n\tstderrReader, err := sw.Session.StderrPipe()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn io.NopCloser(stderrReader), nil\n}\n\nfunc (sw SessionWrap) SetSize(h int, w int) error {\n\treturn sw.Session.WindowChange(h, w)\n}\n\ntype WslCmdWrap struct {\n\t*wsl.WslCmd\n\tTty pty.Tty\n\tpty.Pty\n}\n\nfunc (wcw WslCmdWrap) Kill() {\n\twcw.Tty.Close()\n\twcw.Close()\n}\n\nfunc (wcw WslCmdWrap) KillGraceful(timeout time.Duration) {\n\tprocess := wcw.WslCmd.GetProcess()\n\tif process == nil {\n\t\treturn\n\t}\n\tprocessState := wcw.WslCmd.GetProcessState()\n\tif processState != nil && processState.Exited() {\n\t\treturn\n\t}\n\tprocess.Signal(os.Interrupt)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"KillGraceful-wsl:Kill\", recover())\n\t\t}()\n\t\ttime.Sleep(timeout)\n\t\tprocess := wcw.WslCmd.GetProcess()\n\t\tprocessState := wcw.WslCmd.GetProcessState()\n\t\tif processState == nil || !processState.Exited() {\n\t\t\tprocess.Kill() // force kill if it is already not exited\n\t\t}\n\t}()\n}\n\n/**\n * SetSize does nothing for WslCmdWrap as there\n * is no pty to manage.\n**/\nfunc (wcw WslCmdWrap) SetSize(w int, h int) error {\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/shellexec/shellexec.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage shellexec\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"os/exec\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"maps\"\n\n\t\"github.com/creack/pty\"\n\t\"github.com/wavetermdev/waveterm/pkg/blocklogger\"\n\t\"github.com/wavetermdev/waveterm/pkg/jobcontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/conncontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/pamparse\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wslconn\"\n)\n\nconst DefaultGracefulKillWait = 400 * time.Millisecond\n\ntype CommandOptsType struct {\n\tInteractive bool                      `json:\"interactive,omitempty\"`\n\tLogin       bool                      `json:\"login,omitempty\"`\n\tCwd         string                    `json:\"cwd,omitempty\"`\n\tShellPath   string                    `json:\"shellPath,omitempty\"`\n\tShellOpts   []string                  `json:\"shellOpts,omitempty\"`\n\tSwapToken   *shellutil.TokenSwapEntry `json:\"swapToken,omitempty\"`\n\tForceJwt    bool                      `json:\"forcejwt,omitempty\"`\n}\n\ntype ShellProc struct {\n\tConnName  string\n\tCmd       ConnInterface\n\tCloseOnce *sync.Once\n\tDoneCh    chan any // closed after proc.Wait() returns\n\tWaitErr   error    // WaitErr is synchronized by DoneCh (written before DoneCh is closed) and CloseOnce\n}\n\nfunc (sp *ShellProc) Close() {\n\tsp.Cmd.KillGraceful(DefaultGracefulKillWait)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"ShellProc.Close\", recover())\n\t\t}()\n\t\twaitErr := sp.Cmd.Wait()\n\t\tsp.SetWaitErrorAndSignalDone(waitErr)\n\n\t\t// windows cannot handle the pty being\n\t\t// closed twice, so we let the pty\n\t\t// close itself instead\n\t\tif runtime.GOOS != \"windows\" {\n\t\t\tsp.Cmd.Close()\n\t\t}\n\t}()\n}\n\nfunc (sp *ShellProc) SetWaitErrorAndSignalDone(waitErr error) {\n\tsp.CloseOnce.Do(func() {\n\t\tsp.WaitErr = waitErr\n\t\tclose(sp.DoneCh)\n\t})\n}\n\nfunc (sp *ShellProc) Wait() error {\n\t<-sp.DoneCh\n\treturn sp.WaitErr\n}\n\n// returns (done, waitError)\nfunc (sp *ShellProc) WaitNB() (bool, error) {\n\tselect {\n\tcase <-sp.DoneCh:\n\t\treturn true, sp.WaitErr\n\tdefault:\n\t\treturn false, nil\n\t}\n}\n\nfunc ExitCodeFromWaitErr(err error) int {\n\tif err == nil {\n\t\treturn 0\n\t}\n\tif exitErr, ok := err.(*exec.ExitError); ok {\n\t\tif status, ok := exitErr.Sys().(syscall.WaitStatus); ok {\n\t\t\treturn status.ExitStatus()\n\t\t}\n\t}\n\treturn -1\n\n}\n\nfunc checkCwd(cwd string) error {\n\tif cwd == \"\" {\n\t\treturn fmt.Errorf(\"cwd is empty\")\n\t}\n\tif _, err := os.Stat(cwd); err != nil {\n\t\treturn fmt.Errorf(\"error statting cwd %q: %w\", cwd, err)\n\t}\n\treturn nil\n}\n\ntype PipePty struct {\n\tremoteStdinWrite *os.File\n\tremoteStdoutRead *os.File\n}\n\nfunc (pp *PipePty) Fd() uintptr {\n\treturn pp.remoteStdinWrite.Fd()\n}\n\nfunc (pp *PipePty) Name() string {\n\treturn \"pipe-pty\"\n}\n\nfunc (pp *PipePty) Read(p []byte) (n int, err error) {\n\treturn pp.remoteStdoutRead.Read(p)\n}\n\nfunc (pp *PipePty) Write(p []byte) (n int, err error) {\n\treturn pp.remoteStdinWrite.Write(p)\n}\n\nfunc (pp *PipePty) Close() error {\n\terr1 := pp.remoteStdinWrite.Close()\n\terr2 := pp.remoteStdoutRead.Close()\n\n\tif err1 != nil {\n\t\treturn err1\n\t}\n\treturn err2\n}\n\nfunc (pp *PipePty) WriteString(s string) (n int, err error) {\n\treturn pp.Write([]byte(s))\n}\n\nfunc StartWslShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *wslconn.WslConn) (*ShellProc, error) {\n\tclient := conn.GetClient()\n\tconn.Infof(ctx, \"WSL-NEWSESSION (StartWslShellProcNoWsh)\")\n\n\tecmd := exec.Command(\"wsl.exe\", \"~\", \"-d\", client.Name())\n\n\tif termSize.Rows == 0 || termSize.Cols == 0 {\n\t\ttermSize.Rows = shellutil.DefaultTermRows\n\t\ttermSize.Cols = shellutil.DefaultTermCols\n\t}\n\tif termSize.Rows <= 0 || termSize.Cols <= 0 {\n\t\treturn nil, fmt.Errorf(\"invalid term size: %v\", termSize)\n\t}\n\tcmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcmdWrap := MakeCmdWrap(ecmd, cmdPty, true)\n\treturn &ShellProc{Cmd: cmdWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil\n}\n\nfunc StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *wslconn.WslConn) (*ShellProc, error) {\n\tif cmdOpts.SwapToken == nil {\n\t\treturn nil, fmt.Errorf(\"SwapToken is required in CommandOptsType\")\n\t}\n\tclient := conn.GetClient()\n\tconn.Infof(ctx, \"WSL-NEWSESSION (StartWslShellProc)\")\n\tconnRoute := wshutil.MakeConnectionRouteId(conn.GetName())\n\trpcClient := wshclient.GetBareRpcClient()\n\tremoteInfo, err := wshclient.RemoteGetInfoCommand(rpcClient, &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to obtain client info: %w\", err)\n\t}\n\tlog.Printf(\"client info collected: %+#v\", remoteInfo)\n\tvar shellPath string\n\tif cmdOpts.ShellPath != \"\" {\n\t\tconn.Infof(ctx, \"using shell path from command opts: %s\\n\", cmdOpts.ShellPath)\n\t\tshellPath = cmdOpts.ShellPath\n\t}\n\tconfigShellPath := conn.GetConfigShellPath()\n\tif shellPath == \"\" && configShellPath != \"\" {\n\t\tconn.Infof(ctx, \"using shell path from config (conn:shellpath): %s\\n\", configShellPath)\n\t\tshellPath = configShellPath\n\t}\n\tif shellPath == \"\" && remoteInfo.Shell != \"\" {\n\t\tconn.Infof(ctx, \"using shell path detected on remote machine: %s\\n\", remoteInfo.Shell)\n\t\tshellPath = remoteInfo.Shell\n\t}\n\tif shellPath == \"\" {\n\t\tconn.Infof(ctx, \"no shell path detected, using default (/bin/bash)\\n\")\n\t\tshellPath = \"/bin/bash\"\n\t}\n\tvar shellOpts []string\n\tvar cmdCombined string\n\tlog.Printf(\"detected shell %q for conn %q\\n\", shellPath, conn.GetName())\n\n\terr = wshclient.RemoteInstallRcFilesCommand(rpcClient, &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000})\n\tif err != nil {\n\t\tlog.Printf(\"error installing rc files: %v\", err)\n\t\treturn nil, err\n\t}\n\tshellOpts = append(shellOpts, cmdOpts.ShellOpts...)\n\tshellType := shellutil.GetShellTypeFromShellPath(shellPath)\n\tconn.Infof(ctx, \"detected shell type: %s\\n\", shellType)\n\tconn.Debugf(ctx, \"cmdStr: %q\\n\", cmdStr)\n\n\tif cmdStr == \"\" {\n\t\t/* transform command in order to inject environment vars */\n\t\tif shellType == shellutil.ShellType_bash {\n\t\t\t// add --rcfile\n\t\t\t// cant set -l or -i with --rcfile\n\t\t\tbashPath := fmt.Sprintf(\"~/.waveterm/%s/.bashrc\", shellutil.BashIntegrationDir)\n\t\t\tshellOpts = append(shellOpts, \"--rcfile\", bashPath)\n\t\t} else if shellType == shellutil.ShellType_fish {\n\t\t\tif cmdOpts.Login {\n\t\t\t\tshellOpts = append(shellOpts, \"-l\")\n\t\t\t}\n\t\t\t// source the wave.fish file\n\t\t\twaveFishPath := fmt.Sprintf(\"~/.waveterm/%s/wave.fish\", shellutil.FishIntegrationDir)\n\t\t\tcarg := fmt.Sprintf(`\"source %s\"`, waveFishPath)\n\t\t\tshellOpts = append(shellOpts, \"-C\", carg)\n\t\t} else if shellType == shellutil.ShellType_pwsh {\n\t\t\tpwshPath := fmt.Sprintf(\"~/.waveterm/%s/wavepwsh.ps1\", shellutil.PwshIntegrationDir)\n\t\t\t// powershell is weird about quoted path executables and requires an ampersand first\n\t\t\tshellPath = \"& \" + shellPath\n\t\t\tshellOpts = append(shellOpts, \"-ExecutionPolicy\", \"Bypass\", \"-NoExit\", \"-File\", pwshPath)\n\t\t} else {\n\t\t\tif cmdOpts.Login {\n\t\t\t\tshellOpts = append(shellOpts, \"-l\")\n\t\t\t}\n\t\t\tif cmdOpts.Interactive {\n\t\t\t\tshellOpts = append(shellOpts, \"-i\")\n\t\t\t}\n\t\t\t// zdotdir setting moved to after session is created\n\t\t}\n\t\tcmdCombined = fmt.Sprintf(\"%s %s\", shellPath, strings.Join(shellOpts, \" \"))\n\t} else {\n\t\t// TODO check quoting of cmdStr\n\t\tshellOpts = append(shellOpts, \"-c\", cmdStr)\n\t\tcmdCombined = fmt.Sprintf(\"%s %s\", shellPath, strings.Join(shellOpts, \" \"))\n\t}\n\tconn.Infof(ctx, \"starting shell, using command: %s\\n\", cmdCombined)\n\tconn.Infof(ctx, \"WSL-NEWSESSION (StartWslShellProc)\\n\")\n\n\tif shellType == shellutil.ShellType_zsh {\n\t\tzshDir := fmt.Sprintf(\"~/.waveterm/%s\", shellutil.ZshIntegrationDir)\n\t\tconn.Infof(ctx, \"setting ZDOTDIR to %s\\n\", zshDir)\n\t\tcmdCombined = fmt.Sprintf(`ZDOTDIR=%s %s`, zshDir, cmdCombined)\n\t}\n\tpackedToken, err := cmdOpts.SwapToken.PackForClient()\n\tif err != nil {\n\t\tconn.Infof(ctx, \"error packing swap token: %v\", err)\n\t} else {\n\t\tconn.Debugf(ctx, \"packed swaptoken %s\\n\", packedToken)\n\t\tcmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveSwapTokenVarName, packedToken, cmdCombined)\n\t}\n\tjwtToken := cmdOpts.SwapToken.Env[wavebase.WaveJwtTokenVarName]\n\tif jwtToken != \"\" && cmdOpts.ForceJwt {\n\t\tconn.Debugf(ctx, \"adding JWT token to environment\\n\")\n\t\tcmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveJwtTokenVarName, jwtToken, cmdCombined)\n\t}\n\tlog.Printf(\"full combined command: %s\", cmdCombined)\n\tecmd := exec.Command(\"wsl.exe\", \"~\", \"-d\", client.Name(), \"--\", \"sh\", \"-c\", cmdCombined)\n\tif termSize.Rows == 0 || termSize.Cols == 0 {\n\t\ttermSize.Rows = shellutil.DefaultTermRows\n\t\ttermSize.Cols = shellutil.DefaultTermCols\n\t}\n\tif termSize.Rows <= 0 || termSize.Cols <= 0 {\n\t\treturn nil, fmt.Errorf(\"invalid term size: %v\", termSize)\n\t}\n\tshellutil.AddTokenSwapEntry(cmdOpts.SwapToken)\n\tcmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcmdWrap := MakeCmdWrap(ecmd, cmdPty, true)\n\treturn &ShellProc{Cmd: cmdWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil\n}\n\nfunc StartRemoteShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) {\n\tclient := conn.GetClient()\n\tconn.Infof(ctx, \"SSH-NEWSESSION (StartRemoteShellProcNoWsh)\")\n\tsession, err := client.NewSession()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tremoteStdinRead, remoteStdinWriteOurs, err := os.Pipe()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tremoteStdoutReadOurs, remoteStdoutWrite, err := os.Pipe()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpipePty := &PipePty{\n\t\tremoteStdinWrite: remoteStdinWriteOurs,\n\t\tremoteStdoutRead: remoteStdoutReadOurs,\n\t}\n\tif termSize.Rows == 0 || termSize.Cols == 0 {\n\t\ttermSize.Rows = shellutil.DefaultTermRows\n\t\ttermSize.Cols = shellutil.DefaultTermCols\n\t}\n\tif termSize.Rows <= 0 || termSize.Cols <= 0 {\n\t\treturn nil, fmt.Errorf(\"invalid term size: %v\", termSize)\n\t}\n\tsession.Stdin = remoteStdinRead\n\tsession.Stdout = remoteStdoutWrite\n\tsession.Stderr = remoteStdoutWrite\n\n\tsession.RequestPty(\"xterm-256color\", termSize.Rows, termSize.Cols, nil)\n\tsessionWrap := MakeSessionWrap(session, \"\", pipePty)\n\terr = session.Shell()\n\tif err != nil {\n\t\tpipePty.Close()\n\t\treturn nil, err\n\t}\n\treturn &ShellProc{Cmd: sessionWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil\n}\n\nfunc StartRemoteShellProc(ctx context.Context, logCtx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) {\n\tif cmdOpts.SwapToken == nil {\n\t\treturn nil, fmt.Errorf(\"SwapToken is required in CommandOptsType\")\n\t}\n\tclient := conn.GetClient()\n\tconnRoute := wshutil.MakeConnectionRouteId(conn.GetName())\n\trpcClient := wshclient.GetBareRpcClient()\n\tremoteInfo, err := wshclient.RemoteGetInfoCommand(rpcClient, &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to obtain client info: %w\", err)\n\t}\n\tif remoteInfo.HomeDir == \"\" {\n\t\treturn nil, fmt.Errorf(\"unable to obtain home directory from remote machine\")\n\t}\n\tlog.Printf(\"client info collected: %+#v\", remoteInfo)\n\tvar shellPath string\n\tif cmdOpts.ShellPath != \"\" {\n\t\tconn.Infof(logCtx, \"using shell path from command opts: %s\\n\", cmdOpts.ShellPath)\n\t\tshellPath = cmdOpts.ShellPath\n\t}\n\tconfigShellPath := conn.GetConfigShellPath()\n\tif shellPath == \"\" && configShellPath != \"\" {\n\t\tconn.Infof(logCtx, \"using shell path from config (conn:shellpath): %s\\n\", configShellPath)\n\t\tshellPath = configShellPath\n\t}\n\tif shellPath == \"\" && remoteInfo.Shell != \"\" {\n\t\tconn.Infof(logCtx, \"using shell path detected on remote machine: %s\\n\", remoteInfo.Shell)\n\t\tshellPath = remoteInfo.Shell\n\t}\n\tif shellPath == \"\" {\n\t\tconn.Infof(logCtx, \"no shell path detected, using default (/bin/bash)\\n\")\n\t\tshellPath = \"/bin/bash\"\n\t}\n\tvar shellOpts []string\n\tvar cmdCombined string\n\tlog.Printf(\"detected shell %q for conn %q\\n\", shellPath, conn.GetName())\n\tshellOpts = append(shellOpts, cmdOpts.ShellOpts...)\n\tshellType := shellutil.GetShellTypeFromShellPath(shellPath)\n\tconn.Infof(logCtx, \"detected shell type: %s\\n\", shellType)\n\tconn.Infof(logCtx, \"swaptoken: %s\\n\", cmdOpts.SwapToken.Token)\n\tconn.Debugf(logCtx, \"cmdStr: %q\\n\", cmdStr)\n\n\tif cmdStr == \"\" {\n\t\t/* transform command in order to inject environment vars */\n\t\tif shellType == shellutil.ShellType_bash {\n\t\t\t// add --rcfile\n\t\t\t// cant set -l or -i with --rcfile\n\t\t\tbashPath := fmt.Sprintf(\"%s/.waveterm/%s/.bashrc\", remoteInfo.HomeDir, shellutil.BashIntegrationDir)\n\t\t\tshellOpts = append(shellOpts, \"--rcfile\", bashPath)\n\t\t} else if shellType == shellutil.ShellType_fish {\n\t\t\tif cmdOpts.Login {\n\t\t\t\tshellOpts = append(shellOpts, \"-l\")\n\t\t\t}\n\t\t\t// source the wave.fish file\n\t\t\twaveFishPath := fmt.Sprintf(\"%s/.waveterm/%s/wave.fish\", remoteInfo.HomeDir, shellutil.FishIntegrationDir)\n\t\t\tcarg := fmt.Sprintf(`\"source %s\"`, waveFishPath)\n\t\t\tshellOpts = append(shellOpts, \"-C\", carg)\n\t\t} else if shellType == shellutil.ShellType_pwsh {\n\t\t\tpwshPath := fmt.Sprintf(\"%s/.waveterm/%s/wavepwsh.ps1\", remoteInfo.HomeDir, shellutil.PwshIntegrationDir)\n\t\t\t// powershell is weird about quoted path executables and requires an ampersand first\n\t\t\tshellPath = \"& \" + shellPath\n\t\t\tshellOpts = append(shellOpts, \"-ExecutionPolicy\", \"Bypass\", \"-NoExit\", \"-File\", pwshPath)\n\t\t} else {\n\t\t\tif cmdOpts.Login {\n\t\t\t\tshellOpts = append(shellOpts, \"-l\")\n\t\t\t}\n\t\t\tif cmdOpts.Interactive {\n\t\t\t\tshellOpts = append(shellOpts, \"-i\")\n\t\t\t}\n\t\t\t// zdotdir setting moved to after session is created\n\t\t}\n\t\tcmdCombined = fmt.Sprintf(\"%s %s\", shellPath, strings.Join(shellOpts, \" \"))\n\t} else {\n\t\t// TODO check quoting of cmdStr\n\t\tshellOpts = append(shellOpts, \"-c\", cmdStr)\n\t\tcmdCombined = fmt.Sprintf(\"%s %s\", shellPath, strings.Join(shellOpts, \" \"))\n\t}\n\tconn.Infof(logCtx, \"starting shell, using command: %s\\n\", cmdCombined)\n\tconn.Infof(logCtx, \"SSH-NEWSESSION (StartRemoteShellProc)\\n\")\n\tsession, err := client.NewSession()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tremoteStdinRead, remoteStdinWriteOurs, err := os.Pipe()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tremoteStdoutReadOurs, remoteStdoutWrite, err := os.Pipe()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpipePty := &PipePty{\n\t\tremoteStdinWrite: remoteStdinWriteOurs,\n\t\tremoteStdoutRead: remoteStdoutReadOurs,\n\t}\n\tif termSize.Rows == 0 || termSize.Cols == 0 {\n\t\ttermSize.Rows = shellutil.DefaultTermRows\n\t\ttermSize.Cols = shellutil.DefaultTermCols\n\t}\n\tif termSize.Rows <= 0 || termSize.Cols <= 0 {\n\t\treturn nil, fmt.Errorf(\"invalid term size: %v\", termSize)\n\t}\n\tsession.Stdin = remoteStdinRead\n\tsession.Stdout = remoteStdoutWrite\n\tsession.Stderr = remoteStdoutWrite\n\tif shellType == shellutil.ShellType_zsh {\n\t\tzshDir := fmt.Sprintf(\"~/.waveterm/%s\", shellutil.ZshIntegrationDir)\n\t\tconn.Infof(logCtx, \"setting ZDOTDIR to %s\\n\", zshDir)\n\t\tcmdCombined = fmt.Sprintf(`ZDOTDIR=%s %s`, zshDir, cmdCombined)\n\t}\n\tpackedToken, err := cmdOpts.SwapToken.PackForClient()\n\tif err != nil {\n\t\tconn.Infof(logCtx, \"error packing swap token: %v\", err)\n\t} else {\n\t\tconn.Debugf(logCtx, \"packed swaptoken %s\\n\", packedToken)\n\t\tcmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveSwapTokenVarName, packedToken, cmdCombined)\n\t}\n\tjwtToken := cmdOpts.SwapToken.Env[wavebase.WaveJwtTokenVarName]\n\tif jwtToken != \"\" && cmdOpts.ForceJwt {\n\t\tconn.Debugf(logCtx, \"adding JWT token to environment\\n\")\n\t\tcmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveJwtTokenVarName, jwtToken, cmdCombined)\n\t}\n\tshellutil.AddTokenSwapEntry(cmdOpts.SwapToken)\n\tsession.RequestPty(\"xterm-256color\", termSize.Rows, termSize.Cols, nil)\n\tsessionWrap := MakeSessionWrap(session, cmdCombined, pipePty)\n\terr = sessionWrap.Start()\n\tif err != nil {\n\t\tpipePty.Close()\n\t\treturn nil, err\n\t}\n\treturn &ShellProc{Cmd: sessionWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil\n}\n\nfunc StartRemoteShellJob(ctx context.Context, logCtx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn, optBlockId string) (string, error) {\n\tconnRoute := wshutil.MakeConnectionRouteId(conn.GetName())\n\trpcClient := wshclient.GetBareRpcClient()\n\tremoteInfo, err := wshclient.RemoteGetInfoCommand(rpcClient, &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000})\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to obtain client info: %w\", err)\n\t}\n\tif remoteInfo.HomeDir == \"\" {\n\t\treturn \"\", fmt.Errorf(\"unable to obtain home directory from remote machine\")\n\t}\n\tlog.Printf(\"client info collected: %+#v\", remoteInfo)\n\tvar shellPath string\n\tif cmdOpts.ShellPath != \"\" {\n\t\tconn.Infof(logCtx, \"using shell path from command opts: %s\\n\", cmdOpts.ShellPath)\n\t\tshellPath = cmdOpts.ShellPath\n\t}\n\tconfigShellPath := conn.GetConfigShellPath()\n\tif shellPath == \"\" && configShellPath != \"\" {\n\t\tconn.Infof(logCtx, \"using shell path from config (conn:shellpath): %s\\n\", configShellPath)\n\t\tshellPath = configShellPath\n\t}\n\tif shellPath == \"\" && remoteInfo.Shell != \"\" {\n\t\tconn.Infof(logCtx, \"using shell path detected on remote machine: %s\\n\", remoteInfo.Shell)\n\t\tshellPath = remoteInfo.Shell\n\t}\n\tif shellPath == \"\" {\n\t\tconn.Infof(logCtx, \"no shell path detected, using default (/bin/bash)\\n\")\n\t\tshellPath = \"/bin/bash\"\n\t}\n\tvar shellOpts []string\n\tlog.Printf(\"detected shell %q for conn %q\\n\", shellPath, conn.GetName())\n\tshellOpts = append(shellOpts, cmdOpts.ShellOpts...)\n\tshellType := shellutil.GetShellTypeFromShellPath(shellPath)\n\tconn.Infof(logCtx, \"detected shell type: %s\\n\", shellType)\n\tconn.Debugf(logCtx, \"cmdStr: %q\\n\", cmdStr)\n\n\tif cmdStr == \"\" {\n\t\tif shellType == shellutil.ShellType_bash {\n\t\t\tbashPath := fmt.Sprintf(\"%s/.waveterm/%s/.bashrc\", remoteInfo.HomeDir, shellutil.BashIntegrationDir)\n\t\t\tshellOpts = append(shellOpts, \"--rcfile\", bashPath)\n\t\t} else if shellType == shellutil.ShellType_fish {\n\t\t\tif cmdOpts.Login {\n\t\t\t\tshellOpts = append(shellOpts, \"-l\")\n\t\t\t}\n\t\t\twaveFishPath := fmt.Sprintf(\"%s/.waveterm/%s/wave.fish\", remoteInfo.HomeDir, shellutil.FishIntegrationDir)\n\t\t\tcarg := fmt.Sprintf(`source %s`, waveFishPath)\n\t\t\tshellOpts = append(shellOpts, \"-C\", carg)\n\t\t} else if shellType == shellutil.ShellType_pwsh {\n\t\t\tpwshPath := fmt.Sprintf(\"%s/.waveterm/%s/wavepwsh.ps1\", remoteInfo.HomeDir, shellutil.PwshIntegrationDir)\n\t\t\tshellOpts = append(shellOpts, \"-ExecutionPolicy\", \"Bypass\", \"-NoExit\", \"-File\", pwshPath)\n\t\t} else {\n\t\t\tif cmdOpts.Login {\n\t\t\t\tshellOpts = append(shellOpts, \"-l\")\n\t\t\t}\n\t\t\tif cmdOpts.Interactive {\n\t\t\t\tshellOpts = append(shellOpts, \"-i\")\n\t\t\t}\n\t\t}\n\t} else {\n\t\tshellOpts = append(shellOpts, \"-c\", cmdStr)\n\t}\n\tconn.Infof(logCtx, \"starting shell job, using command: %s %s\\n\", shellPath, strings.Join(shellOpts, \" \"))\n\n\tif termSize.Rows == 0 || termSize.Cols == 0 {\n\t\ttermSize.Rows = shellutil.DefaultTermRows\n\t\ttermSize.Cols = shellutil.DefaultTermCols\n\t}\n\tif termSize.Rows <= 0 || termSize.Cols <= 0 {\n\t\treturn \"\", fmt.Errorf(\"invalid term size: %v\", termSize)\n\t}\n\n\tenv := make(map[string]string)\n\tenv[\"TERM\"] = shellutil.DefaultTermType\n\tif shellType == shellutil.ShellType_zsh {\n\t\tzshDir := fmt.Sprintf(\"%s/.waveterm/%s\", remoteInfo.HomeDir, shellutil.ZshIntegrationDir)\n\t\tconn.Infof(logCtx, \"setting ZDOTDIR to %s\\n\", zshDir)\n\t\tenv[\"ZDOTDIR\"] = zshDir\n\t}\n\tif cmdOpts.SwapToken != nil {\n\t\tpackedToken, err := cmdOpts.SwapToken.PackForClient()\n\t\tif err != nil {\n\t\t\tconn.Infof(logCtx, \"error packing swap token: %v\", err)\n\t\t} else {\n\t\t\tconn.Debugf(logCtx, \"packed swaptoken %s\\n\", packedToken)\n\t\t\tenv[wavebase.WaveSwapTokenVarName] = packedToken\n\t\t}\n\t\tjwtToken := cmdOpts.SwapToken.Env[wavebase.WaveJwtTokenVarName]\n\t\tif jwtToken != \"\" && cmdOpts.ForceJwt {\n\t\t\tconn.Debugf(logCtx, \"adding JWT token to environment\\n\")\n\t\t\tenv[wavebase.WaveJwtTokenVarName] = jwtToken\n\t\t}\n\t\tshellutil.AddTokenSwapEntry(cmdOpts.SwapToken)\n\t}\n\n\tjobParams := jobcontroller.StartJobParams{\n\t\tConnName: conn.GetName(),\n\t\tJobKind:  jobcontroller.JobKind_Shell,\n\t\tCmd:      shellPath,\n\t\tArgs:     shellOpts,\n\t\tEnv:      env,\n\t\tTermSize: &termSize,\n\t\tBlockId:  optBlockId,\n\t}\n\tjobId, err := jobcontroller.StartJob(ctx, jobParams)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to start job: %w\", err)\n\t}\n\tconn.Infof(logCtx, \"started job: %s\\n\", jobId)\n\treturn jobId, nil\n}\n\nfunc StartLocalShellProc(logCtx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, connName string) (*ShellProc, error) {\n\tif cmdOpts.SwapToken == nil {\n\t\treturn nil, fmt.Errorf(\"SwapToken is required in CommandOptsType\")\n\t}\n\tshellutil.InitCustomShellStartupFiles()\n\tvar ecmd *exec.Cmd\n\tvar shellOpts []string\n\tshellPath := cmdOpts.ShellPath\n\tif shellPath == \"\" {\n\t\tshellPath = shellutil.DetectLocalShellPath()\n\t}\n\tshellType := shellutil.GetShellTypeFromShellPath(shellPath)\n\tshellOpts = append(shellOpts, cmdOpts.ShellOpts...)\n\tvar isShell bool\n\tif cmdStr == \"\" {\n\t\tisShell = true\n\t\tif shellType == shellutil.ShellType_bash {\n\t\t\t// add --rcfile\n\t\t\t// cant set -l or -i with --rcfile\n\t\t\tshellOpts = append(shellOpts, \"--rcfile\", shellutil.GetLocalBashRcFileOverride())\n\t\t} else if shellType == shellutil.ShellType_fish {\n\t\t\tif cmdOpts.Login {\n\t\t\t\tshellOpts = append(shellOpts, \"-l\")\n\t\t\t}\n\t\t\twaveFishPath := shellutil.GetLocalWaveFishFilePath()\n\t\t\tcarg := fmt.Sprintf(\"source %s\", shellutil.HardQuoteFish(waveFishPath))\n\t\t\tshellOpts = append(shellOpts, \"-C\", carg)\n\t\t} else if shellType == shellutil.ShellType_pwsh {\n\t\t\tshellOpts = append(shellOpts, \"-ExecutionPolicy\", \"Bypass\", \"-NoExit\", \"-File\", shellutil.GetLocalWavePowershellEnv())\n\t\t} else {\n\t\t\tif cmdOpts.Login {\n\t\t\t\tshellOpts = append(shellOpts, \"-l\")\n\t\t\t}\n\t\t\tif cmdOpts.Interactive {\n\t\t\t\tshellOpts = append(shellOpts, \"-i\")\n\t\t\t}\n\t\t}\n\t\tblocklogger.Debugf(logCtx, \"[conndebug] shell:%s shellOpts:%v\\n\", shellPath, shellOpts)\n\t\tecmd = exec.Command(shellPath, shellOpts...)\n\t\tecmd.Env = os.Environ()\n\t\tif shellType == shellutil.ShellType_zsh {\n\t\t\tshellutil.UpdateCmdEnv(ecmd, map[string]string{\"ZDOTDIR\": shellutil.GetLocalZshZDotDir()})\n\t\t}\n\t} else {\n\t\tisShell = false\n\t\tshellOpts = append(shellOpts, \"-c\", cmdStr)\n\t\tecmd = exec.Command(shellPath, shellOpts...)\n\t\tecmd.Env = os.Environ()\n\t}\n\n\tpackedToken, err := cmdOpts.SwapToken.PackForClient()\n\tif err != nil {\n\t\tblocklogger.Infof(logCtx, \"error packing swap token: %v\", err)\n\t} else {\n\t\tblocklogger.Debugf(logCtx, \"packed swaptoken %s\\n\", packedToken)\n\t\tshellutil.UpdateCmdEnv(ecmd, map[string]string{wavebase.WaveSwapTokenVarName: packedToken})\n\t}\n\tjwtToken := cmdOpts.SwapToken.Env[wavebase.WaveJwtTokenVarName]\n\tif jwtToken != \"\" && cmdOpts.ForceJwt {\n\t\tblocklogger.Debugf(logCtx, \"adding JWT token to environment\\n\")\n\t\tshellutil.UpdateCmdEnv(ecmd, map[string]string{wavebase.WaveJwtTokenVarName: jwtToken})\n\t}\n\n\t/*\n\t  For Snap installations, we need to correct the XDG environment variables as Snap\n\t  overrides them to point to snap directories. We will get the correct values, if\n\t  set, from the PAM environment. If the XDG variables are set in profile or in an\n\t  RC file, it will be overridden when the shell initializes.\n\t*/\n\tif os.Getenv(\"SNAP\") != \"\" {\n\t\tlog.Printf(\"Detected Snap installation, correcting XDG environment variables\")\n\t\tvarsToReplace := map[string]string{\"XDG_CONFIG_HOME\": \"\", \"XDG_DATA_HOME\": \"\", \"XDG_CACHE_HOME\": \"\", \"XDG_RUNTIME_DIR\": \"\", \"XDG_CONFIG_DIRS\": \"\", \"XDG_DATA_DIRS\": \"\"}\n\t\tpamEnvs := tryGetPamEnvVars()\n\t\tif len(pamEnvs) > 0 {\n\t\t\t// We only want to set the XDG variables from the PAM environment, all others should already be correct or may have been overridden by something else out of our control\n\t\t\tfor k := range pamEnvs {\n\t\t\t\tif _, ok := varsToReplace[k]; ok {\n\t\t\t\t\tvarsToReplace[k] = pamEnvs[k]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tlog.Printf(\"Setting XDG environment variables to: %v\", varsToReplace)\n\t\tshellutil.UpdateCmdEnv(ecmd, varsToReplace)\n\t}\n\n\tif cmdOpts.Cwd != \"\" {\n\t\tecmd.Dir = cmdOpts.Cwd\n\t}\n\tif cwdErr := checkCwd(ecmd.Dir); cwdErr != nil {\n\t\tecmd.Dir = wavebase.GetHomeDir()\n\t}\n\tenvToAdd := shellutil.WaveshellLocalEnvVars(shellutil.DefaultTermType)\n\tif os.Getenv(\"LANG\") == \"\" {\n\t\tenvToAdd[\"LANG\"] = wavebase.DetermineLang()\n\t}\n\tshellutil.UpdateCmdEnv(ecmd, envToAdd)\n\tif termSize.Rows == 0 || termSize.Cols == 0 {\n\t\ttermSize.Rows = shellutil.DefaultTermRows\n\t\ttermSize.Cols = shellutil.DefaultTermCols\n\t}\n\tif termSize.Rows <= 0 || termSize.Cols <= 0 {\n\t\treturn nil, fmt.Errorf(\"invalid term size: %v\", termSize)\n\t}\n\tshellutil.AddTokenSwapEntry(cmdOpts.SwapToken)\n\tcmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcmdWrap := MakeCmdWrap(ecmd, cmdPty, isShell)\n\treturn &ShellProc{Cmd: cmdWrap, ConnName: connName, CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil\n}\n\nfunc RunSimpleCmdInPty(ecmd *exec.Cmd, termSize waveobj.TermSize) ([]byte, error) {\n\tecmd.Env = os.Environ()\n\tshellutil.UpdateCmdEnv(ecmd, shellutil.WaveshellLocalEnvVars(shellutil.DefaultTermType))\n\tif termSize.Rows == 0 || termSize.Cols == 0 {\n\t\ttermSize.Rows = shellutil.DefaultTermRows\n\t\ttermSize.Cols = shellutil.DefaultTermCols\n\t}\n\tif termSize.Rows <= 0 || termSize.Cols <= 0 {\n\t\treturn nil, fmt.Errorf(\"invalid term size: %v\", termSize)\n\t}\n\tcmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)})\n\tif err != nil {\n\t\tcmdPty.Close()\n\t\treturn nil, err\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tdefer cmdPty.Close()\n\t}\n\tioDone := make(chan bool)\n\tvar outputBuf bytes.Buffer\n\tgo func() {\n\t\tpanichandler.PanicHandler(\"RunSimpleCmdInPty:ioCopy\", recover())\n\t\t// ignore error (/dev/ptmx has read error when process is done)\n\t\tdefer close(ioDone)\n\t\tio.Copy(&outputBuf, cmdPty)\n\t}()\n\texitErr := ecmd.Wait()\n\tif exitErr != nil {\n\t\treturn nil, exitErr\n\t}\n\t<-ioDone\n\treturn outputBuf.Bytes(), nil\n}\n\nconst etcEnvironmentPath = \"/etc/environment\"\nconst etcSecurityPath = \"/etc/security/pam_env.conf\"\nconst userEnvironmentPath = \"~/.pam_environment\"\n\nvar pamParseOpts *pamparse.PamParseOpts = pamparse.ParsePasswdSafe()\n\n/*\ntryGetPamEnvVars tries to get the environment variables from /etc/environment,\n/etc/security/pam_env.conf, and ~/.pam_environment.\n\nIt then returns a map of the environment variables, overriding duplicates with\nthe following order of precedence:\n1. /etc/environment\n2. /etc/security/pam_env.conf\n3. ~/.pam_environment\n*/\nfunc tryGetPamEnvVars() map[string]string {\n\tenvVars, err := pamparse.ParseEnvironmentFile(etcEnvironmentPath)\n\tif err != nil {\n\t\tlog.Printf(\"error parsing %s: %v\", etcEnvironmentPath, err)\n\t}\n\tenvVars2, err := pamparse.ParseEnvironmentConfFile(etcSecurityPath, pamParseOpts)\n\tif err != nil {\n\t\tlog.Printf(\"error parsing %s: %v\", etcSecurityPath, err)\n\t}\n\tenvVars3, err := pamparse.ParseEnvironmentConfFile(wavebase.ExpandHomeDirSafe(userEnvironmentPath), pamParseOpts)\n\tif err != nil {\n\t\tlog.Printf(\"error parsing %s: %v\", userEnvironmentPath, err)\n\t}\n\tmaps.Copy(envVars, envVars2)\n\tmaps.Copy(envVars, envVars3)\n\tif runtime_dir, ok := envVars[\"XDG_RUNTIME_DIR\"]; !ok || runtime_dir == \"\" {\n\t\tenvVars[\"XDG_RUNTIME_DIR\"] = \"/run/user/\" + fmt.Sprint(os.Getuid())\n\t}\n\treturn envVars\n}\n"
  },
  {
    "path": "pkg/streamclient/stream_test.go",
    "content": "package streamclient\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"io\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype fakeTransport struct {\n\tdataChan chan wshrpc.CommandStreamData\n\tackChan  chan wshrpc.CommandStreamAckData\n}\n\nfunc newFakeTransport() *fakeTransport {\n\treturn &fakeTransport{\n\t\tdataChan: make(chan wshrpc.CommandStreamData, 10),\n\t\tackChan:  make(chan wshrpc.CommandStreamAckData, 10),\n\t}\n}\n\nfunc (ft *fakeTransport) SendData(dataPk wshrpc.CommandStreamData) {\n\tft.dataChan <- dataPk\n}\n\nfunc (ft *fakeTransport) SendAck(ackPk wshrpc.CommandStreamAckData) {\n\tft.ackChan <- ackPk\n}\n\nfunc TestBasicReadWrite(t *testing.T) {\n\ttransport := newFakeTransport()\n\n\treader := NewReader(\"1\", 1024, transport)\n\twriter := NewWriter(\"1\", 1024, transport)\n\n\tgo func() {\n\t\tfor dataPk := range transport.dataChan {\n\t\t\treader.RecvData(dataPk)\n\t\t}\n\t}()\n\n\tgo func() {\n\t\tfor ackPk := range transport.ackChan {\n\t\t\twriter.RecvAck(ackPk)\n\t\t}\n\t}()\n\n\ttestData := []byte(\"Hello, World!\")\n\tn, err := writer.Write(testData)\n\tif err != nil {\n\t\tt.Fatalf(\"Write failed: %v\", err)\n\t}\n\tif n != len(testData) {\n\t\tt.Fatalf(\"Write returned %d, expected %d\", n, len(testData))\n\t}\n\n\tbuf := make([]byte, 1024)\n\tn, err = reader.Read(buf)\n\tif err != nil {\n\t\tt.Fatalf(\"Read failed: %v\", err)\n\t}\n\tif n != len(testData) {\n\t\tt.Fatalf(\"Read returned %d, expected %d\", n, len(testData))\n\t}\n\tif !bytes.Equal(buf[:n], testData) {\n\t\tt.Fatalf(\"Read data %q doesn't match written data %q\", buf[:n], testData)\n\t}\n}\n\nfunc TestEOF(t *testing.T) {\n\ttransport := newFakeTransport()\n\n\treader := NewReader(\"1\", 1024, transport)\n\twriter := NewWriter(\"1\", 1024, transport)\n\n\tgo func() {\n\t\tfor dataPk := range transport.dataChan {\n\t\t\treader.RecvData(dataPk)\n\t\t}\n\t}()\n\n\tgo func() {\n\t\tfor ackPk := range transport.ackChan {\n\t\t\twriter.RecvAck(ackPk)\n\t\t}\n\t}()\n\n\ttestData := []byte(\"Test data\")\n\twriter.Write(testData)\n\twriter.Close()\n\n\tbuf := make([]byte, 1024)\n\tn, err := reader.Read(buf)\n\tif err != nil {\n\t\tt.Fatalf(\"First read failed: %v\", err)\n\t}\n\tif !bytes.Equal(buf[:n], testData) {\n\t\tt.Fatalf(\"Read data doesn't match\")\n\t}\n\n\t_, err = reader.Read(buf)\n\tif err != io.EOF {\n\t\tt.Fatalf(\"Expected EOF, got %v\", err)\n\t}\n}\n\nfunc TestFlowControl(t *testing.T) {\n\tsmallWindow := int64(10)\n\ttransport := newFakeTransport()\n\n\treader := NewReader(\"1\", smallWindow, transport)\n\twriter := NewWriter(\"1\", smallWindow, transport)\n\n\tgo func() {\n\t\tfor dataPk := range transport.dataChan {\n\t\t\treader.RecvData(dataPk)\n\t\t}\n\t}()\n\n\tgo func() {\n\t\tfor ackPk := range transport.ackChan {\n\t\t\twriter.RecvAck(ackPk)\n\t\t}\n\t}()\n\n\tlargeData := make([]byte, 100)\n\tfor i := range largeData {\n\t\tlargeData[i] = byte(i)\n\t}\n\n\twriteDone := make(chan error)\n\tgo func() {\n\t\t_, err := writer.Write(largeData)\n\t\twriteDone <- err\n\t}()\n\n\treceived := make([]byte, 0, 100)\n\tbuf := make([]byte, 20)\n\tfor len(received) < len(largeData) {\n\t\tn, err := reader.Read(buf)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Read failed: %v\", err)\n\t\t}\n\t\treceived = append(received, buf[:n]...)\n\t}\n\n\tselect {\n\tcase err := <-writeDone:\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Write failed: %v\", err)\n\t\t}\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"Write didn't complete in time\")\n\t}\n\n\tif !bytes.Equal(received, largeData) {\n\t\tt.Fatal(\"Received data doesn't match sent data\")\n\t}\n}\n\nfunc TestError(t *testing.T) {\n\ttransport := newFakeTransport()\n\n\treader := NewReader(\"1\", 1024, transport)\n\twriter := NewWriter(\"1\", 1024, transport)\n\n\tgo func() {\n\t\tfor dataPk := range transport.dataChan {\n\t\t\treader.RecvData(dataPk)\n\t\t}\n\t}()\n\n\tgo func() {\n\t\tfor ackPk := range transport.ackChan {\n\t\t\twriter.RecvAck(ackPk)\n\t\t}\n\t}()\n\n\ttestErr := io.ErrUnexpectedEOF\n\twriter.CloseWithError(testErr)\n\n\tbuf := make([]byte, 1024)\n\t_, err := reader.Read(buf)\n\tif err == nil {\n\t\tt.Fatal(\"Expected error from read\")\n\t}\n\tif err.Error() != \"stream error: unexpected EOF\" {\n\t\tt.Fatalf(\"Expected stream error, got: %v\", err)\n\t}\n}\n\nfunc TestCancel(t *testing.T) {\n\ttransport := newFakeTransport()\n\n\treader := NewReader(\"1\", 1024, transport)\n\twriter := NewWriter(\"1\", 1024, transport)\n\n\tgo func() {\n\t\tfor dataPk := range transport.dataChan {\n\t\t\treader.RecvData(dataPk)\n\t\t}\n\t}()\n\n\tgo func() {\n\t\tfor ackPk := range transport.ackChan {\n\t\t\twriter.RecvAck(ackPk)\n\t\t}\n\t}()\n\n\treader.Close()\n\n\tselect {\n\tcase <-writer.GetCanceledChan():\n\t\t// Success\n\tcase <-time.After(1 * time.Second):\n\t\tt.Fatal(\"Writer not notified of cancellation\")\n\t}\n\n\t_, _, canceled := writer.GetAckState()\n\tif !canceled {\n\t\tt.Fatal(\"Writer should be in canceled state\")\n\t}\n}\n\nfunc TestMultipleWrites(t *testing.T) {\n\ttransport := newFakeTransport()\n\n\treader := NewReader(\"1\", 1024, transport)\n\twriter := NewWriter(\"1\", 1024, transport)\n\n\tgo func() {\n\t\tfor dataPk := range transport.dataChan {\n\t\t\treader.RecvData(dataPk)\n\t\t}\n\t}()\n\n\tgo func() {\n\t\tfor ackPk := range transport.ackChan {\n\t\t\twriter.RecvAck(ackPk)\n\t\t}\n\t}()\n\n\tmessages := []string{\"First\", \"Second\", \"Third\"}\n\tfor _, msg := range messages {\n\t\t_, err := writer.Write([]byte(msg))\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Write failed: %v\", err)\n\t\t}\n\t}\n\n\texpected := \"FirstSecondThird\"\n\tbuf := make([]byte, len(expected))\n\ttotalRead := 0\n\tfor totalRead < len(expected) {\n\t\tn, err := reader.Read(buf[totalRead:])\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Read failed: %v\", err)\n\t\t}\n\t\ttotalRead += n\n\t}\n\n\tif string(buf) != expected {\n\t\tt.Fatalf(\"Expected %q, got %q\", expected, string(buf))\n\t}\n}\n\nfunc TestOutOfOrderPackets(t *testing.T) {\n\ttransport := newFakeTransport()\n\treader := NewReader(\"test-ooo\", 1024, transport)\n\n\tpacket0 := wshrpc.CommandStreamData{\n\t\tId:     \"test-ooo\",\n\t\tSeq:    0,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"AAAAA\")),\n\t}\n\tpacket5 := wshrpc.CommandStreamData{\n\t\tId:     \"test-ooo\",\n\t\tSeq:    5,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"BBBBB\")),\n\t}\n\tpacket10 := wshrpc.CommandStreamData{\n\t\tId:     \"test-ooo\",\n\t\tSeq:    10,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"CCCCC\")),\n\t}\n\tpacket15 := wshrpc.CommandStreamData{\n\t\tId:     \"test-ooo\",\n\t\tSeq:    15,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"DDDDD\")),\n\t}\n\n\t// Send packets out of order: 0, 10, 15, 5\n\treader.RecvData(packet0)\n\treader.RecvData(packet10) // OOO - should be buffered\n\treader.RecvData(packet15) // OOO - should be buffered\n\treader.RecvData(packet5)  // fills the gap - should trigger processing\n\n\t// Read all data\n\tbuf := make([]byte, 1024)\n\ttotalRead := 0\n\texpectedLen := 20 // 4 packets * 5 bytes each\n\n\treadDone := make(chan struct{})\n\tgo func() {\n\t\tfor totalRead < expectedLen {\n\t\t\tn, err := reader.Read(buf[totalRead:])\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Read failed: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttotalRead += n\n\t\t}\n\t\tclose(readDone)\n\t}()\n\n\tselect {\n\tcase <-readDone:\n\t\t// Success\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatalf(\"Read didn't complete in time. Read %d bytes, expected %d\", totalRead, expectedLen)\n\t}\n\n\tif totalRead != expectedLen {\n\t\tt.Fatalf(\"Expected to read %d bytes, got %d\", expectedLen, totalRead)\n\t}\n}\n\nfunc TestOutOfOrderWithDuplicates(t *testing.T) {\n\ttransport := newFakeTransport()\n\treader := NewReader(\"test-dup\", 1024, transport)\n\n\tpacket0 := wshrpc.CommandStreamData{\n\t\tId:     \"test-dup\",\n\t\tSeq:    0,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"aaaaa\")),\n\t}\n\tpacket10 := wshrpc.CommandStreamData{\n\t\tId:     \"test-dup\",\n\t\tSeq:    10,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"ccccc\")),\n\t}\n\tpacket5First := wshrpc.CommandStreamData{\n\t\tId:     \"test-dup\",\n\t\tSeq:    5,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"xxxxx\")),\n\t}\n\tpacket5Second := wshrpc.CommandStreamData{\n\t\tId:     \"test-dup\",\n\t\tSeq:    5,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"bbbbb\")),\n\t}\n\n\treader.RecvData(packet0)\n\treader.RecvData(packet10)      // OOO - buffered\n\treader.RecvData(packet5First)  // OOO - buffered\n\treader.RecvData(packet5First)  // Duplicate - should be ignored\n\treader.RecvData(packet5Second) // Duplicate with different data - should be ignored\n\n\t// Read all data - should get all 3 packets in order\n\tbuf := make([]byte, 20)\n\tn, err := reader.Read(buf)\n\tif err != nil {\n\t\tt.Fatalf(\"Read failed: %v\", err)\n\t}\n\t\n\t// Should get all 15 bytes (3 packets * 5 bytes)\n\tif n != 15 {\n\t\tt.Fatalf(\"Expected to read 15 bytes, got %d\", n)\n\t}\n\t\n\t// Should be \"aaaaaxxxxxccccc\" (first packet received for each seq wins)\n\texpected := \"aaaaaxxxxxccccc\"\n\tif string(buf[:n]) != expected {\n\t\tt.Fatalf(\"Expected %q, got %q\", expected, string(buf[:n]))\n\t}\n}\n\nfunc TestOutOfOrderWithGaps(t *testing.T) {\n\ttransport := newFakeTransport()\n\treader := NewReader(\"test-gaps\", 1024, transport)\n\n\tpacket0 := wshrpc.CommandStreamData{\n\t\tId:     \"test-gaps\",\n\t\tSeq:    0,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"aaaaa\")),\n\t}\n\tpacket20 := wshrpc.CommandStreamData{\n\t\tId:     \"test-gaps\",\n\t\tSeq:    20,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"eeeee\")),\n\t}\n\tpacket40 := wshrpc.CommandStreamData{\n\t\tId:     \"test-gaps\",\n\t\tSeq:    40,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"iiiii\")),\n\t}\n\tpacket5 := wshrpc.CommandStreamData{\n\t\tId:     \"test-gaps\",\n\t\tSeq:    5,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"bbbbb\")),\n\t}\n\n\treader.RecvData(packet0)\n\treader.RecvData(packet40) // Way ahead - should be buffered\n\treader.RecvData(packet20) // Still ahead - should be buffered\n\t\n\t// Read first packet\n\tbuf := make([]byte, 10)\n\tn, err := reader.Read(buf)\n\tif err != nil {\n\t\tt.Fatalf(\"Read failed: %v\", err)\n\t}\n\tif n != 5 || string(buf[:n]) != \"aaaaa\" {\n\t\tt.Fatalf(\"Expected 'aaaaa', got %q\", string(buf[:n]))\n\t}\n\n\t// Send packet to partially fill gap\n\treader.RecvData(packet5)\n\n\t// Should be able to read it now\n\tn, err = reader.Read(buf)\n\tif err != nil {\n\t\tt.Fatalf(\"Second read failed: %v\", err)\n\t}\n\tif n != 5 || string(buf[:n]) != \"bbbbb\" {\n\t\tt.Fatalf(\"Expected 'bbbbb', got %q\", string(buf[:n]))\n\t}\n\n\tpacket10 := wshrpc.CommandStreamData{\n\t\tId:     \"test-gaps\",\n\t\tSeq:    10,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"ccccc\")),\n\t}\n\tpacket15 := wshrpc.CommandStreamData{\n\t\tId:     \"test-gaps\",\n\t\tSeq:    15,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"ddddd\")),\n\t}\n\tpacket25 := wshrpc.CommandStreamData{\n\t\tId:     \"test-gaps\",\n\t\tSeq:    25,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"fffff\")),\n\t}\n\tpacket30 := wshrpc.CommandStreamData{\n\t\tId:     \"test-gaps\",\n\t\tSeq:    30,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"ggggg\")),\n\t}\n\tpacket35 := wshrpc.CommandStreamData{\n\t\tId:     \"test-gaps\",\n\t\tSeq:    35,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"hhhhh\")),\n\t}\n\n\treader.RecvData(packet10)\n\treader.RecvData(packet15)\n\treader.RecvData(packet25)\n\treader.RecvData(packet30)\n\treader.RecvData(packet35)\n\n\t// Read all remaining data at once\n\tallData := make([]byte, 100)\n\ttotalRead := 0\n\tfor totalRead < 35 {\n\t\tn, err = reader.Read(allData[totalRead:])\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Read failed: %v\", err)\n\t\t}\n\t\ttotalRead += n\n\t}\n\n\texpected := \"cccccdddddeeeeefffffggggghhhhhiiiii\"\n\tif string(allData[:totalRead]) != expected {\n\t\tt.Fatalf(\"Expected %q, got %q\", expected, string(allData[:totalRead]))\n\t}\n}\n\nfunc TestOutOfOrderWithEOF(t *testing.T) {\n\ttransport := newFakeTransport()\n\treader := NewReader(\"test-eof\", 1024, transport)\n\n\tpacket0 := wshrpc.CommandStreamData{\n\t\tId:     \"test-eof\",\n\t\tSeq:    0,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"first\")),\n\t}\n\tpacket11 := wshrpc.CommandStreamData{\n\t\tId:     \"test-eof\",\n\t\tSeq:    11,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"third\")),\n\t\tEof:    true,\n\t}\n\tpacket5 := wshrpc.CommandStreamData{\n\t\tId:     \"test-eof\",\n\t\tSeq:    5,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(\"second\")),\n\t}\n\n\treader.RecvData(packet0)\n\treader.RecvData(packet11) // OOO with EOF\n\treader.RecvData(packet5)  // Fill the gap\n\n\t// Read all data\n\tbuf := make([]byte, 20)\n\tn, err := reader.Read(buf)\n\tif err != nil {\n\t\tt.Fatalf(\"Read failed: %v\", err)\n\t}\n\t\n\texpected := \"firstsecondthird\"\n\tif string(buf[:n]) != expected {\n\t\tt.Fatalf(\"Expected %q, got %q\", expected, string(buf[:n]))\n\t}\n\n\t// Should get EOF now\n\t_, err = reader.Read(buf)\n\tif err != io.EOF {\n\t\tt.Fatalf(\"Expected EOF, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/streamclient/streambroker.go",
    "content": "package streamclient\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/utilds\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype workItem struct {\n\tworkType string\n\tackPk    wshrpc.CommandStreamAckData\n\tdataPk   wshrpc.CommandStreamData\n}\n\ntype StreamWriter interface {\n\tRecvAck(ackPk wshrpc.CommandStreamAckData)\n}\n\ntype StreamRpcInterface interface {\n\tStreamDataAckCommand(data wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error\n\tStreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error\n}\n\ntype Broker struct {\n\tlock                sync.Mutex\n\trpcClient           StreamRpcInterface\n\treaders             map[string]*Reader\n\twriters             map[string]StreamWriter\n\treaderRoutes        map[string]string\n\twriterRoutes        map[string]string\n\treaderErrorSentTime map[string]time.Time\n\tsendQueue           *utilds.WorkQueue[workItem]\n\trecvQueue           *utilds.WorkQueue[workItem]\n}\n\nfunc NewBroker(rpcClient StreamRpcInterface) *Broker {\n\tb := &Broker{\n\t\trpcClient:           rpcClient,\n\t\treaders:             make(map[string]*Reader),\n\t\twriters:             make(map[string]StreamWriter),\n\t\treaderRoutes:        make(map[string]string),\n\t\twriterRoutes:        make(map[string]string),\n\t\treaderErrorSentTime: make(map[string]time.Time),\n\t}\n\tb.sendQueue = utilds.NewWorkQueue(b.processSendWork)\n\tb.recvQueue = utilds.NewWorkQueue(b.processRecvWork)\n\treturn b\n}\n\nfunc (b *Broker) CreateStreamReader(readerRoute string, writerRoute string, rwnd int64) (*Reader, *wshrpc.StreamMeta) {\n\treturn b.CreateStreamReaderWithSeq(readerRoute, writerRoute, rwnd, 0)\n}\n\nfunc (b *Broker) CreateStreamReaderWithSeq(readerRoute string, writerRoute string, rwnd int64, startSeq int64) (*Reader, *wshrpc.StreamMeta) {\n\tb.lock.Lock()\n\tdefer b.lock.Unlock()\n\n\tstreamId := uuid.New().String()\n\n\treader := NewReaderWithSeq(streamId, rwnd, startSeq, b)\n\tb.readers[streamId] = reader\n\tb.readerRoutes[streamId] = readerRoute\n\tb.writerRoutes[streamId] = writerRoute\n\n\tmeta := &wshrpc.StreamMeta{\n\t\tId:            streamId,\n\t\tRWnd:          rwnd,\n\t\tReaderRouteId: readerRoute,\n\t\tWriterRouteId: writerRoute,\n\t}\n\n\treturn reader, meta\n}\n\nfunc (b *Broker) AttachStreamWriter(meta *wshrpc.StreamMeta, writer StreamWriter) error {\n\tb.lock.Lock()\n\tdefer b.lock.Unlock()\n\n\tif _, exists := b.writers[meta.Id]; exists {\n\t\treturn fmt.Errorf(\"writer already registered for stream id %s\", meta.Id)\n\t}\n\n\tb.writers[meta.Id] = writer\n\tb.readerRoutes[meta.Id] = meta.ReaderRouteId\n\tb.writerRoutes[meta.Id] = meta.WriterRouteId\n\n\treturn nil\n}\n\nfunc (b *Broker) DetachStreamWriter(streamId string) {\n\tb.lock.Lock()\n\tdefer b.lock.Unlock()\n\n\tdelete(b.writers, streamId)\n\tdelete(b.writerRoutes, streamId)\n}\n\nfunc (b *Broker) CreateStreamWriter(meta *wshrpc.StreamMeta) (*Writer, error) {\n\twriter := NewWriter(meta.Id, meta.RWnd, b)\n\terr := b.AttachStreamWriter(meta, writer)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn writer, nil\n}\n\nfunc (b *Broker) SendAck(ackPk wshrpc.CommandStreamAckData) {\n\tb.sendQueue.Enqueue(workItem{workType: \"sendack\", ackPk: ackPk})\n}\n\nfunc (b *Broker) SendData(dataPk wshrpc.CommandStreamData) {\n\tb.sendQueue.Enqueue(workItem{workType: \"senddata\", dataPk: dataPk})\n}\n\n// RecvData and RecvAck are designed to be non-blocking and must remain so to prevent deadlock.\n// They only enqueue work items to be processed asynchronously by the work queue's goroutine.\n// These methods are called from the main RPC runServer loop, so blocking here would stall all RPC processing.\nfunc (b *Broker) RecvData(dataPk wshrpc.CommandStreamData) {\n\tb.recvQueue.Enqueue(workItem{workType: \"recvdata\", dataPk: dataPk})\n}\n\nfunc (b *Broker) RecvAck(ackPk wshrpc.CommandStreamAckData) {\n\tb.recvQueue.Enqueue(workItem{workType: \"recvack\", ackPk: ackPk})\n}\n\nfunc (b *Broker) processSendWork(item workItem) {\n\tswitch item.workType {\n\tcase \"sendack\":\n\t\tb.processSendAck(item.ackPk)\n\tcase \"senddata\":\n\t\tb.processSendData(item.dataPk)\n\t}\n}\n\nfunc (b *Broker) processRecvWork(item workItem) {\n\tswitch item.workType {\n\tcase \"recvdata\":\n\t\tb.processRecvData(item.dataPk)\n\tcase \"recvack\":\n\t\tb.processRecvAck(item.ackPk)\n\t}\n}\n\nfunc (b *Broker) processSendAck(ackPk wshrpc.CommandStreamAckData) {\n\tb.lock.Lock()\n\troute, ok := b.writerRoutes[ackPk.Id]\n\tb.lock.Unlock()\n\tif !ok {\n\t\treturn\n\t}\n\n\topts := &wshrpc.RpcOpts{\n\t\tRoute:      route,\n\t\tNoResponse: true,\n\t}\n\tb.rpcClient.StreamDataAckCommand(ackPk, opts)\n\n\tif ackPk.Fin || ackPk.Cancel {\n\t\tb.cleanupReader(ackPk.Id)\n\t}\n}\n\nfunc (b *Broker) processSendData(dataPk wshrpc.CommandStreamData) {\n\tb.lock.Lock()\n\troute := b.readerRoutes[dataPk.Id]\n\tb.lock.Unlock()\n\n\topts := &wshrpc.RpcOpts{\n\t\tRoute:      route,\n\t\tNoResponse: true,\n\t}\n\tb.rpcClient.StreamDataCommand(dataPk, opts)\n}\n\nfunc (b *Broker) processRecvData(dataPk wshrpc.CommandStreamData) {\n\tb.lock.Lock()\n\treader, ok := b.readers[dataPk.Id]\n\tif !ok {\n\t\tlastSent := b.readerErrorSentTime[dataPk.Id]\n\t\tnow := time.Now()\n\t\tif now.Sub(lastSent) < time.Second {\n\t\t\tb.lock.Unlock()\n\t\t\treturn\n\t\t}\n\t\tb.readerErrorSentTime[dataPk.Id] = now\n\t}\n\tb.lock.Unlock()\n\n\tif !ok {\n\t\tackPk := wshrpc.CommandStreamAckData{\n\t\t\tId:     dataPk.Id,\n\t\t\tSeq:    dataPk.Seq,\n\t\t\tCancel: true,\n\t\t\tError:  \"stream reader not found\",\n\t\t}\n\t\tb.SendAck(ackPk)\n\t\treturn\n\t}\n\n\treader.RecvData(dataPk)\n}\n\nfunc (b *Broker) processRecvAck(ackPk wshrpc.CommandStreamAckData) {\n\tb.lock.Lock()\n\twriter, ok := b.writers[ackPk.Id]\n\tb.lock.Unlock()\n\n\tif !ok {\n\t\treturn\n\t}\n\n\twriter.RecvAck(ackPk)\n\n\tif ackPk.Fin || ackPk.Cancel {\n\t\tb.cleanupWriter(ackPk.Id)\n\t}\n}\n\nfunc (b *Broker) Close() {\n\tb.sendQueue.Close(false)\n\tb.recvQueue.Close(false)\n\tb.sendQueue.Wait()\n\tb.recvQueue.Wait()\n}\n\nfunc (b *Broker) cleanupReader(streamId string) {\n\tb.lock.Lock()\n\tdefer b.lock.Unlock()\n\n\tdelete(b.readers, streamId)\n\tdelete(b.readerRoutes, streamId)\n\tdelete(b.readerErrorSentTime, streamId)\n}\n\nfunc (b *Broker) cleanupWriter(streamId string) {\n\tb.lock.Lock()\n\tdefer b.lock.Unlock()\n\n\tdelete(b.writers, streamId)\n\tdelete(b.writerRoutes, streamId)\n}\n"
  },
  {
    "path": "pkg/streamclient/streambroker_test.go",
    "content": "package streamclient\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype mockRpcInterface struct {\n\tdataChan chan wshrpc.CommandStreamData\n\tackChan  chan wshrpc.CommandStreamAckData\n}\n\nfunc (m *mockRpcInterface) StreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error {\n\tm.dataChan <- data\n\treturn nil\n}\n\nfunc (m *mockRpcInterface) StreamDataAckCommand(data wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error {\n\tm.ackChan <- data\n\treturn nil\n}\n\nfunc setupBrokerPair() (*Broker, *Broker) {\n\trpc1 := &mockRpcInterface{\n\t\tdataChan: make(chan wshrpc.CommandStreamData, 10),\n\t\tackChan:  make(chan wshrpc.CommandStreamAckData, 10),\n\t}\n\trpc2 := &mockRpcInterface{\n\t\tdataChan: make(chan wshrpc.CommandStreamData, 10),\n\t\tackChan:  make(chan wshrpc.CommandStreamAckData, 10),\n\t}\n\n\tbroker1 := NewBroker(rpc1)\n\tbroker2 := NewBroker(rpc2)\n\n\tgo func() {\n\t\tfor data := range rpc1.dataChan {\n\t\t\tbroker2.RecvData(data)\n\t\t}\n\t}()\n\n\tgo func() {\n\t\tfor ack := range rpc1.ackChan {\n\t\t\tbroker2.RecvAck(ack)\n\t\t}\n\t}()\n\n\tgo func() {\n\t\tfor data := range rpc2.dataChan {\n\t\t\tbroker1.RecvData(data)\n\t\t}\n\t}()\n\n\tgo func() {\n\t\tfor ack := range rpc2.ackChan {\n\t\t\tbroker1.RecvAck(ack)\n\t\t}\n\t}()\n\n\treturn broker1, broker2\n}\n\nfunc TestBrokerBasicReadWrite(t *testing.T) {\n\tbroker1, broker2 := setupBrokerPair()\n\n\treader, meta := broker1.CreateStreamReader(\"reader1\", \"writer1\", 1024)\n\twriter, err := broker2.CreateStreamWriter(meta)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateStreamWriter failed: %v\", err)\n\t}\n\n\ttestData := []byte(\"Hello, World!\")\n\tn, err := writer.Write(testData)\n\tif err != nil {\n\t\tt.Fatalf(\"Write failed: %v\", err)\n\t}\n\tif n != len(testData) {\n\t\tt.Fatalf(\"Write returned %d, expected %d\", n, len(testData))\n\t}\n\n\tbuf := make([]byte, 1024)\n\tn, err = reader.Read(buf)\n\tif err != nil {\n\t\tt.Fatalf(\"Read failed: %v\", err)\n\t}\n\tif n != len(testData) {\n\t\tt.Fatalf(\"Read returned %d, expected %d\", n, len(testData))\n\t}\n\tif !bytes.Equal(buf[:n], testData) {\n\t\tt.Fatalf(\"Read data %q doesn't match written data %q\", buf[:n], testData)\n\t}\n\n\twriter.Close()\n\t_, err = reader.Read(buf)\n\tif err != io.EOF {\n\t\tt.Fatalf(\"Expected EOF, got %v\", err)\n\t}\n}\n\nfunc TestBrokerEOF(t *testing.T) {\n\tbroker1, broker2 := setupBrokerPair()\n\n\treader, meta := broker1.CreateStreamReader(\"reader1\", \"writer1\", 1024)\n\twriter, err := broker2.CreateStreamWriter(meta)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateStreamWriter failed: %v\", err)\n\t}\n\n\ttestData := []byte(\"Test data\")\n\twriter.Write(testData)\n\twriter.Close()\n\n\tbuf := make([]byte, 1024)\n\tn, err := reader.Read(buf)\n\tif err != nil {\n\t\tt.Fatalf(\"First read failed: %v\", err)\n\t}\n\tif !bytes.Equal(buf[:n], testData) {\n\t\tt.Fatalf(\"Read data doesn't match\")\n\t}\n\n\t_, err = reader.Read(buf)\n\tif err != io.EOF {\n\t\tt.Fatalf(\"Expected EOF, got %v\", err)\n\t}\n}\n\nfunc TestBrokerFlowControl(t *testing.T) {\n\tbroker1, broker2 := setupBrokerPair()\n\n\tsmallWindow := int64(10)\n\treader, meta := broker1.CreateStreamReader(\"reader1\", \"writer1\", smallWindow)\n\twriter, err := broker2.CreateStreamWriter(meta)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateStreamWriter failed: %v\", err)\n\t}\n\n\tlargeData := make([]byte, 100)\n\tfor i := range largeData {\n\t\tlargeData[i] = byte(i)\n\t}\n\n\twriteDone := make(chan error)\n\tgo func() {\n\t\t_, err := writer.Write(largeData)\n\t\twriteDone <- err\n\t}()\n\n\treceived := make([]byte, 0, 100)\n\tbuf := make([]byte, 20)\n\tfor len(received) < len(largeData) {\n\t\tn, err := reader.Read(buf)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Read failed: %v\", err)\n\t\t}\n\t\treceived = append(received, buf[:n]...)\n\t}\n\n\tselect {\n\tcase err := <-writeDone:\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Write failed: %v\", err)\n\t\t}\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"Write didn't complete in time\")\n\t}\n\n\tif !bytes.Equal(received, largeData) {\n\t\tt.Fatal(\"Received data doesn't match sent data\")\n\t}\n\n\twriter.Close()\n}\n\nfunc TestBrokerError(t *testing.T) {\n\tbroker1, broker2 := setupBrokerPair()\n\n\treader, meta := broker1.CreateStreamReader(\"reader1\", \"writer1\", 1024)\n\twriter, err := broker2.CreateStreamWriter(meta)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateStreamWriter failed: %v\", err)\n\t}\n\n\ttestErr := io.ErrUnexpectedEOF\n\twriter.CloseWithError(testErr)\n\n\tbuf := make([]byte, 1024)\n\t_, err = reader.Read(buf)\n\tif err == nil {\n\t\tt.Fatal(\"Expected error from read\")\n\t}\n\tif err.Error() != \"stream error: unexpected EOF\" {\n\t\tt.Fatalf(\"Expected stream error, got: %v\", err)\n\t}\n}\n\nfunc TestBrokerCancel(t *testing.T) {\n\tbroker1, broker2 := setupBrokerPair()\n\n\treader, meta := broker1.CreateStreamReader(\"reader1\", \"writer1\", 1024)\n\twriter, err := broker2.CreateStreamWriter(meta)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateStreamWriter failed: %v\", err)\n\t}\n\n\treader.Close()\n\n\tselect {\n\tcase <-writer.GetCanceledChan():\n\t\t// Success\n\tcase <-time.After(1 * time.Second):\n\t\tt.Fatal(\"Writer not notified of cancellation\")\n\t}\n\n\t_, _, canceled := writer.GetAckState()\n\tif !canceled {\n\t\tt.Fatal(\"Writer should be in canceled state\")\n\t}\n}\n\nfunc TestBrokerMultipleWrites(t *testing.T) {\n\tbroker1, broker2 := setupBrokerPair()\n\n\treader, meta := broker1.CreateStreamReader(\"reader1\", \"writer1\", 1024)\n\twriter, err := broker2.CreateStreamWriter(meta)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateStreamWriter failed: %v\", err)\n\t}\n\n\tmessages := []string{\"First\", \"Second\", \"Third\"}\n\tfor _, msg := range messages {\n\t\t_, err := writer.Write([]byte(msg))\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Write failed: %v\", err)\n\t\t}\n\t}\n\n\texpected := \"FirstSecondThird\"\n\tbuf := make([]byte, len(expected))\n\ttotalRead := 0\n\tfor totalRead < len(expected) {\n\t\tn, err := reader.Read(buf[totalRead:])\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Read failed: %v\", err)\n\t\t}\n\t\ttotalRead += n\n\t}\n\n\tif string(buf) != expected {\n\t\tt.Fatalf(\"Expected %q, got %q\", expected, string(buf))\n\t}\n\n\twriter.Close()\n}\n\nfunc TestBrokerCleanup(t *testing.T) {\n\tbroker1, broker2 := setupBrokerPair()\n\n\treader, meta := broker1.CreateStreamReader(\"reader1\", \"writer1\", 1024)\n\twriter, err := broker2.CreateStreamWriter(meta)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateStreamWriter failed: %v\", err)\n\t}\n\n\ttestData := []byte(\"cleanup test\")\n\twriter.Write(testData)\n\n\tbuf := make([]byte, 1024)\n\treader.Read(buf)\n\n\twriter.Close()\n\n\ttime.Sleep(100 * time.Millisecond)\n\n\tbroker1.lock.Lock()\n\t_, readerExists := broker1.readers[meta.Id]\n\tbroker1.lock.Unlock()\n\n\tif readerExists {\n\t\tt.Fatal(\"Reader should have been cleaned up\")\n\t}\n\n\tbroker2.lock.Lock()\n\t_, writerExists := broker2.writers[meta.Id]\n\tbroker2.lock.Unlock()\n\n\tif writerExists {\n\t\tt.Fatal(\"Writer should have been cleaned up\")\n\t}\n}\n"
  },
  {
    "path": "pkg/streamclient/streamreader.go",
    "content": "package streamclient\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"sort\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype AckSender interface {\n\tSendAck(ackPk wshrpc.CommandStreamAckData)\n}\n\ntype Reader struct {\n\tlock         sync.Mutex\n\tcond         *sync.Cond\n\tid           string\n\tackSender    AckSender\n\treadWindow   int64\n\tnextSeq      int64\n\tbuffer       []byte\n\teof          bool\n\terr          error\n\tclosed       bool\n\tlastRwndSent int64\n\toooPackets   []wshrpc.CommandStreamData // out-of-order packets awaiting delivery\n}\n\nfunc NewReader(id string, readWindow int64, ackSender AckSender) *Reader {\n\treturn NewReaderWithSeq(id, readWindow, 0, ackSender)\n}\n\nfunc NewReaderWithSeq(id string, readWindow int64, startSeq int64, ackSender AckSender) *Reader {\n\tr := &Reader{\n\t\tid:           id,\n\t\treadWindow:   readWindow,\n\t\tackSender:    ackSender,\n\t\tnextSeq:      startSeq,\n\t\tlastRwndSent: readWindow,\n\t}\n\tr.cond = sync.NewCond(&r.lock)\n\treturn r\n}\n\nfunc (r *Reader) RecvData(dataPk wshrpc.CommandStreamData) {\n\tr.lock.Lock()\n\tdefer r.lock.Unlock()\n\n\tif r.closed || r.eof || r.err != nil {\n\t\treturn\n\t}\n\n\tif dataPk.Id != r.id {\n\t\treturn\n\t}\n\n\t// error packets can be sent without a valid Seq, so check for errors before validating sequence\n\tif dataPk.Error != \"\" {\n\t\tr.err = fmt.Errorf(\"stream error: %s\", dataPk.Error)\n\t\tr.cond.Broadcast()\n\t\tr.sendAckLocked(true, false, \"\")\n\t\treturn\n\t}\n\n\tif dataPk.Seq < r.nextSeq {\n\t\treturn\n\t}\n\tif dataPk.Seq > r.nextSeq {\n\t\tr.addOOOPacketLocked(dataPk)\n\t\treturn\n\t}\n\n\tr.recvDataOrderedLocked(dataPk)\n\tr.processOOOPacketsLocked()\n\tr.cond.Broadcast()\n\tr.sendAckLocked(r.eof, false, \"\")\n}\n\nfunc (r *Reader) recvDataOrderedLocked(dataPk wshrpc.CommandStreamData) {\n\tif dataPk.Data64 != \"\" {\n\t\tdata, err := base64.StdEncoding.DecodeString(dataPk.Data64)\n\t\tif err != nil {\n\t\t\tr.err = err\n\t\t\tr.sendAckLocked(false, true, \"base64 decode error\")\n\t\t\treturn\n\t\t}\n\t\tr.buffer = append(r.buffer, data...)\n\t\tr.nextSeq += int64(len(data))\n\t}\n\n\tif dataPk.Eof {\n\t\tr.eof = true\n\t}\n}\n\nfunc (r *Reader) addOOOPacketLocked(dataPk wshrpc.CommandStreamData) {\n\tfor _, pkt := range r.oooPackets {\n\t\tif pkt.Seq == dataPk.Seq {\n\t\t\t// this handles duplicates\n\t\t\treturn\n\t\t}\n\t}\n\tr.oooPackets = append(r.oooPackets, dataPk)\n}\n\nfunc (r *Reader) processOOOPacketsLocked() {\n\tif len(r.oooPackets) == 0 {\n\t\treturn\n\t}\n\tsort.Slice(r.oooPackets, func(i, j int) bool {\n\t\treturn r.oooPackets[i].Seq < r.oooPackets[j].Seq\n\t})\n\tconsumed := 0\n\tfor _, pkt := range r.oooPackets {\n\t\tif r.eof || r.err != nil {\n\t\t\t// we're done, so we can clear any pending ooo packets\n\t\t\tr.oooPackets = nil\n\t\t\treturn\n\t\t}\n\t\tif pkt.Seq != r.nextSeq {\n\t\t\tbreak\n\t\t}\n\t\tr.recvDataOrderedLocked(pkt)\n\t\tconsumed++\n\t}\n\tr.oooPackets = r.oooPackets[consumed:]\n}\n\nfunc (r *Reader) sendAckLocked(fin bool, cancel bool, errStr string) {\n\trwnd := r.readWindow - int64(len(r.buffer))\n\tif rwnd < 0 {\n\t\trwnd = 0\n\t}\n\tack := wshrpc.CommandStreamAckData{\n\t\tId:     r.id,\n\t\tSeq:    r.nextSeq,\n\t\tFin:    fin,\n\t\tCancel: cancel,\n\t\tRWnd:   rwnd,\n\t\tError:  errStr,\n\t}\n\tr.ackSender.SendAck(ack)\n\tr.lastRwndSent = rwnd\n}\n\nfunc (r *Reader) Read(p []byte) (int, error) {\n\tr.lock.Lock()\n\tdefer r.lock.Unlock()\n\n\tfor len(r.buffer) == 0 && !r.eof && r.err == nil && !r.closed {\n\t\tr.cond.Wait()\n\t}\n\n\tif r.closed {\n\t\treturn 0, io.ErrClosedPipe\n\t}\n\n\tif r.err != nil {\n\t\treturn 0, r.err\n\t}\n\n\tif len(r.buffer) == 0 && r.eof {\n\t\treturn 0, io.EOF\n\t}\n\n\tn := copy(p, r.buffer)\n\tr.buffer = r.buffer[n:]\n\n\tif n > 0 {\n\t\tcurrentRwnd := r.readWindow - int64(len(r.buffer))\n\t\tif currentRwnd < 0 {\n\t\t\tcurrentRwnd = 0\n\t\t}\n\n\t\tthreshold := r.readWindow / 5\n\t\trwndDiff := currentRwnd - r.lastRwndSent\n\n\t\tif len(r.buffer) == 0 || rwndDiff >= threshold {\n\t\t\tr.sendAckLocked(false, false, \"\")\n\t\t}\n\t}\n\n\treturn n, nil\n}\n\nfunc (r *Reader) UpdateNextSeq(newSeq int64) {\n\tr.lock.Lock()\n\tdefer r.lock.Unlock()\n\tr.nextSeq = newSeq\n}\n\nfunc (r *Reader) Close() error {\n\tr.lock.Lock()\n\tdefer r.lock.Unlock()\n\n\tif r.closed {\n\t\tif r.err != nil {\n\t\t\treturn r.err\n\t\t}\n\t\treturn io.ErrClosedPipe\n\t}\n\n\tr.closed = true\n\tif r.err == nil {\n\t\tr.err = io.ErrClosedPipe\n\t}\n\tr.cond.Broadcast()\n\tr.sendAckLocked(false, true, \"\")\n\n\treturn r.err\n}\n"
  },
  {
    "path": "pkg/streamclient/streamwriter.go",
    "content": "package streamclient\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype DataSender interface {\n\tSendData(dataPk wshrpc.CommandStreamData)\n}\n\ntype Writer struct {\n\tlock         sync.Mutex\n\tcond         *sync.Cond\n\tid           string\n\tdataSender   DataSender\n\treadWindow   int64\n\tnextSeq      int64\n\tbuffer       []byte\n\tsentNotAcked int64\n\tmaxAckedSeq  int64\n\tmaxAckedRwnd int64\n\tfinAcked     bool\n\tcanceled     bool\n\tcanceledChan chan struct{}\n\teof          bool\n\terr          error\n\tclosed       bool\n}\n\nfunc NewWriter(id string, readWindow int64, dataSender DataSender) *Writer {\n\tw := &Writer{\n\t\tid:           id,\n\t\treadWindow:   readWindow,\n\t\tdataSender:   dataSender,\n\t\tnextSeq:      0,\n\t\tsentNotAcked: 0,\n\t\tmaxAckedSeq:  0,\n\t\tcanceledChan: make(chan struct{}),\n\t}\n\tw.cond = sync.NewCond(&w.lock)\n\treturn w\n}\n\nfunc (w *Writer) RecvAck(ackPk wshrpc.CommandStreamAckData) {\n\tw.lock.Lock()\n\tdefer w.lock.Unlock()\n\n\tif ackPk.Id != w.id {\n\t\treturn\n\t}\n\n\tackedSeq := ackPk.Seq\n\trwnd := ackPk.RWnd\n\n\tif ackPk.Fin {\n\t\tw.finAcked = true\n\t\tw.maxAckedSeq = ackedSeq\n\t\treturn\n\t}\n\n\tif ackPk.Cancel && !w.canceled {\n\t\tw.canceled = true\n\t\tclose(w.canceledChan)\n\t\tif !w.closed {\n\t\t\tw.err = fmt.Errorf(\"stream cancelled\")\n\t\t\tw.cond.Broadcast()\n\t\t}\n\t\treturn\n\t}\n\n\t// Ignore stale ACKs using tuple comparison (seq, rwnd)\n\tif ackedSeq < w.maxAckedSeq || (ackedSeq == w.maxAckedSeq && rwnd <= w.maxAckedRwnd) {\n\t\treturn\n\t}\n\n\t// Update max acked tuple\n\tw.maxAckedSeq = ackedSeq\n\tw.maxAckedRwnd = rwnd\n\n\tif !w.closed {\n\t\tif ackedSeq > (w.nextSeq - w.sentNotAcked) {\n\t\t\tackedBytes := ackedSeq - (w.nextSeq - w.sentNotAcked)\n\t\t\tw.sentNotAcked -= ackedBytes\n\t\t\tif w.sentNotAcked < 0 {\n\t\t\t\tw.sentNotAcked = 0\n\t\t\t}\n\t\t}\n\n\t\tw.readWindow = rwnd\n\t\tw.cond.Broadcast()\n\t}\n}\n\nfunc (w *Writer) GetAckState() (maxAckedSeq int64, finAcked bool, canceled bool) {\n\tw.lock.Lock()\n\tdefer w.lock.Unlock()\n\n\treturn w.maxAckedSeq, w.finAcked, w.canceled\n}\n\nfunc (w *Writer) GetCanceledChan() <-chan struct{} {\n\treturn w.canceledChan\n}\n\nfunc (w *Writer) Write(p []byte) (int, error) {\n\tw.lock.Lock()\n\tdefer w.lock.Unlock()\n\n\tif w.closed {\n\t\treturn 0, io.ErrClosedPipe\n\t}\n\n\tif w.err != nil {\n\t\treturn 0, w.err\n\t}\n\n\tw.buffer = append(w.buffer, p...)\n\tn := len(p)\n\n\tfor len(w.buffer) > 0 {\n\t\tif w.closed {\n\t\t\treturn 0, io.ErrClosedPipe\n\t\t}\n\t\tif w.err != nil {\n\t\t\treturn 0, w.err\n\t\t}\n\n\t\tsent := w.trySendDataLocked()\n\t\tif !sent {\n\t\t\tw.cond.Wait()\n\t\t}\n\t}\n\n\treturn n, nil\n}\n\nfunc (w *Writer) trySendDataLocked() bool {\n\tavailWindow := w.readWindow - w.sentNotAcked\n\tif availWindow <= 0 {\n\t\treturn false\n\t}\n\n\ttoSend := len(w.buffer)\n\tif int64(toSend) > availWindow {\n\t\ttoSend = int(availWindow)\n\t}\n\n\tdata := w.buffer[:toSend]\n\tw.buffer = w.buffer[toSend:]\n\n\tdataStr := base64.StdEncoding.EncodeToString(data)\n\tdataPk := wshrpc.CommandStreamData{\n\t\tId:     w.id,\n\t\tSeq:    w.nextSeq,\n\t\tData64: dataStr,\n\t}\n\n\tw.dataSender.SendData(dataPk)\n\tw.nextSeq += int64(toSend)\n\tw.sentNotAcked += int64(toSend)\n\n\treturn toSend > 0\n}\n\n// If Close() is called while a Write is blocked, the Write will return an error and buffered data may be discarded.\nfunc (w *Writer) Close() error {\n\treturn w.CloseWithError(nil)\n}\n\n// If CloseWithError() is called while a Write is blocked, the Write will return an error and buffered data may be discarded.\nfunc (w *Writer) CloseWithError(err error) error {\n\tw.lock.Lock()\n\tdefer w.lock.Unlock()\n\n\tif w.closed {\n\t\treturn nil\n\t}\n\n\tw.closed = true\n\tif w.err == nil {\n\t\tw.err = io.ErrClosedPipe\n\t}\n\tw.cond.Broadcast()\n\n\tvar dataPk wshrpc.CommandStreamData\n\tif err == nil || err == io.EOF {\n\t\tdataPk = wshrpc.CommandStreamData{\n\t\t\tId:  w.id,\n\t\t\tSeq: w.nextSeq,\n\t\t\tEof: true,\n\t\t}\n\t} else {\n\t\tdataPk = wshrpc.CommandStreamData{\n\t\t\tId:    w.id,\n\t\t\tSeq:   w.nextSeq,\n\t\t\tError: err.Error(),\n\t\t}\n\t}\n\tw.dataSender.SendData(dataPk)\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/suggestion/filewalk.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage suggestion\n\nimport (\n\t\"container/list\"\n\t\"context\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"golang.org/x/sync/singleflight\"\n)\n\nconst ListDirChanSize = 50\n\n// cache settings\nconst (\n\tmaxCacheEntries = 20\n\tcacheTTL        = 60 * time.Second\n)\n\ntype cacheEntry struct {\n\tkey        string\n\tvalue      []DirEntryResult\n\texpiration time.Time\n\tlruElement *list.Element\n}\n\nvar (\n\tcache    = make(map[string]*cacheEntry)\n\tcacheLRU = list.New()\n\tcacheMu  sync.Mutex\n\n\t// group ensures only one listing per key is executed concurrently.\n\tgroup singleflight.Group\n)\n\nfunc init() {\n\tgo func() {\n\t\tticker := time.NewTicker(60 * time.Second)\n\t\tdefer ticker.Stop()\n\t\tfor range ticker.C {\n\t\t\tcleanCache()\n\t\t}\n\t}()\n}\n\nfunc cleanCache() {\n\tcacheMu.Lock()\n\tdefer cacheMu.Unlock()\n\tnow := time.Now()\n\tfor key, entry := range cache {\n\t\tif now.After(entry.expiration) {\n\t\t\tcacheLRU.Remove(entry.lruElement)\n\t\t\tdelete(cache, key)\n\t\t}\n\t}\n}\n\nfunc getCache(key string) ([]DirEntryResult, bool) {\n\tcacheMu.Lock()\n\tdefer cacheMu.Unlock()\n\tentry, ok := cache[key]\n\tif !ok {\n\t\treturn nil, false\n\t}\n\tif time.Now().After(entry.expiration) {\n\t\t// expired\n\t\tcacheLRU.Remove(entry.lruElement)\n\t\tdelete(cache, key)\n\t\treturn nil, false\n\t}\n\t// update LRU order\n\tcacheLRU.MoveToFront(entry.lruElement)\n\treturn entry.value, true\n}\n\nfunc setCache(key string, value []DirEntryResult) {\n\tcacheMu.Lock()\n\tdefer cacheMu.Unlock()\n\t// if already exists, update it\n\tif entry, ok := cache[key]; ok {\n\t\tentry.value = value\n\t\tentry.expiration = time.Now().Add(cacheTTL)\n\t\tcacheLRU.MoveToFront(entry.lruElement)\n\t\treturn\n\t}\n\t// evict if at capacity\n\tif cacheLRU.Len() >= maxCacheEntries {\n\t\toldest := cacheLRU.Back()\n\t\tif oldest != nil {\n\t\t\toldestKey := oldest.Value.(string)\n\t\t\tif oldEntry, ok := cache[oldestKey]; ok {\n\t\t\t\tcacheLRU.Remove(oldEntry.lruElement)\n\t\t\t\tdelete(cache, oldestKey)\n\t\t\t}\n\t\t}\n\t}\n\t// add new entry\n\telem := cacheLRU.PushFront(key)\n\tcache[key] = &cacheEntry{\n\t\tkey:        key,\n\t\tvalue:      value,\n\t\texpiration: time.Now().Add(cacheTTL),\n\t\tlruElement: elem,\n\t}\n}\n\n// cacheDispose clears all cache entries for the provided widgetId.\nfunc cacheDispose(widgetId string) {\n\tcacheMu.Lock()\n\tdefer cacheMu.Unlock()\n\tprefix := widgetId + \"|\"\n\tfor key, entry := range cache {\n\t\tif strings.HasPrefix(key, prefix) {\n\t\t\tcacheLRU.Remove(entry.lruElement)\n\t\t\tdelete(cache, key)\n\t\t}\n\t}\n}\n\ntype DirEntryResult struct {\n\tEntry fs.DirEntry\n\tErr   error\n}\n\nfunc listDirectory(ctx context.Context, widgetId string, dir string, maxFiles int) (<-chan DirEntryResult, error) {\n\tkey := widgetId + \"|\" + dir\n\tif cached, ok := getCache(key); ok {\n\t\tch := make(chan DirEntryResult, ListDirChanSize)\n\t\tgo func() {\n\t\t\tdefer close(ch)\n\t\t\tfor _, r := range cached {\n\t\t\t\tselect {\n\t\t\t\tcase ch <- r:\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t\treturn ch, nil\n\t}\n\n\t// Use singleflight to ensure only one listing operation occurs per key.\n\tvalue, err, _ := group.Do(key, func() (interface{}, error) {\n\t\tf, err := os.Open(dir)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tdefer f.Close()\n\t\tfi, err := f.Stat()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif !fi.IsDir() {\n\t\t\treturn nil, fmt.Errorf(\"%s is not a directory\", dir)\n\t\t}\n\t\tentries, err := f.ReadDir(maxFiles)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tvar results []DirEntryResult\n\t\tfor _, entry := range entries {\n\t\t\tresults = append(results, DirEntryResult{Entry: entry})\n\t\t}\n\t\t// Add parent directory (“..”) entry if not at the filesystem root.\n\t\tif filepath.Dir(dir) != dir {\n\t\t\tmockDir := &MockDirEntry{\n\t\t\t\tNameStr:  \"..\",\n\t\t\t\tIsDirVal: true,\n\t\t\t\tFileMode: fs.ModeDir | 0755,\n\t\t\t}\n\t\t\tresults = append(results, DirEntryResult{Entry: mockDir})\n\t\t}\n\t\treturn results, nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresults := value.([]DirEntryResult)\n\tsetCache(key, results)\n\n\tch := make(chan DirEntryResult, ListDirChanSize)\n\tgo func() {\n\t\tdefer close(ch)\n\t\tfor _, r := range results {\n\t\t\tselect {\n\t\t\tcase ch <- r:\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\treturn ch, nil\n}\n"
  },
  {
    "path": "pkg/suggestion/suggestion.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage suggestion\n\nimport (\n\t\"container/heap\"\n\t\"context\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/junegunn/fzf/src/algo\"\n\t\"github.com/junegunn/fzf/src/util\"\n\t\"github.com/wavetermdev/waveterm/pkg/faviconcache\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/fileutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nconst MaxSuggestions = 50\n\ntype MockDirEntry struct {\n\tNameStr  string\n\tIsDirVal bool\n\tFileMode fs.FileMode\n}\n\nfunc (m *MockDirEntry) Name() string               { return m.NameStr }\nfunc (m *MockDirEntry) IsDir() bool                { return m.IsDirVal }\nfunc (m *MockDirEntry) Type() fs.FileMode          { return m.FileMode }\nfunc (m *MockDirEntry) Info() (fs.FileInfo, error) { return nil, fs.ErrInvalid }\n\nvar PathSepStr = string(os.PathSeparator)\n\n// ensureTrailingSlash makes sure s ends with a slash.\nfunc ensureTrailingSlash(s string) string {\n\tif s == \"\" {\n\t\treturn s\n\t}\n\tif !strings.HasSuffix(s, PathSepStr) {\n\t\treturn s + PathSepStr\n\t}\n\treturn s\n}\n\n// resolveFileQuery returns (baseDir, queryPrefix, searchTerm, error).\n//\n// Our approach is to use the presence of a trailing slash to decide whether\n// to treat the query as a directory listing (searchTerm is empty) or a search\n// filter. (This means that a query of exactly \".\" or \"..\" is treated as a\n// search filter, so that files with a dot in their name––including \"..\"––will\n// be returned.)\n//\n// In addition, if there is a slash anywhere in the query (but not at the end),\n// we treat everything before the last slash as a relative directory to search\n// in, and the portion after the last slash as the search term.\nfunc resolveFileQuery(cwd string, query string) (string, string, string, error) {\n\t// If no current working directory, default to \"~\".\n\tif cwd == \"\" {\n\t\tcwd = \"~\"\n\t}\n\tvar err error\n\tcwd, err = wavebase.ExpandHomeDir(cwd)\n\tif err != nil {\n\t\treturn \"\", \"\", \"\", fmt.Errorf(\"error expanding home dir: %w\", err)\n\t}\n\tif query == \"\" {\n\t\treturn cwd, \"\", \"\", nil\n\t}\n\t// Expand home if needed.\n\ttildeSlash := \"~\" + PathSepStr\n\tif query == \"~\" || strings.HasPrefix(query, tildeSlash) {\n\t\togQuery := query\n\t\tquery, err = wavebase.ExpandHomeDir(query)\n\t\tif err != nil {\n\t\t\treturn \"\", \"\", \"\", fmt.Errorf(\"error expanding query home dir: %w\", err)\n\t\t}\n\t\tif ogQuery == \"~\" || ogQuery == tildeSlash {\n\t\t\treturn query, tildeSlash, \"\", nil\n\t\t}\n\t}\n\t// Handle absolute queries.\n\tif filepath.IsAbs(query) {\n\t\tif filepath.Dir(query) == query {\n\t\t\treturn query, query, \"\", nil\n\t\t}\n\t\tif strings.HasSuffix(query, PathSepStr) {\n\t\t\t// Remove trailing slash for canonical directory path.\n\t\t\tbaseDir := strings.TrimRight(query, PathSepStr)\n\t\t\t// But keep the trailing slash in the queryPrefix for display.\n\t\t\tqueryPrefix := query\n\t\t\treturn baseDir, queryPrefix, \"\", nil\n\t\t}\n\t\t// Otherwise, e.g. \"/var/f\"\n\t\tbaseDir := filepath.Dir(query)\n\t\tqueryPrefix := filepath.Dir(query)\n\t\tsearchTerm := filepath.Base(query)\n\t\treturn baseDir, queryPrefix, searchTerm, nil\n\t}\n\n\t// For relative queries:\n\t// If the query ends with a slash (e.g. \"./\" or \"waveterm/\"), then treat it\n\t// as a directory listing.\n\tif strings.HasSuffix(query, PathSepStr) {\n\t\tfullPath := filepath.Join(cwd, query)\n\t\tbaseDir := strings.TrimRight(fullPath, PathSepStr)\n\t\tqueryPrefix := query\n\t\treturn baseDir, queryPrefix, \"\", nil\n\t}\n\n\t// If there is a slash in the query, split into directory part and search term.\n\tif idx := strings.LastIndex(query, PathSepStr); idx != -1 {\n\t\tdirPart := query[:idx]\n\t\tterm := query[idx+1:]\n\t\tbaseDir := filepath.Join(cwd, dirPart)\n\t\t// For display purposes, set queryPrefix to the dirPart with a trailing slash.\n\t\tqueryPrefix := \"\"\n\t\tif dirPart != \"\" {\n\t\t\tqueryPrefix = ensureTrailingSlash(dirPart)\n\t\t}\n\t\treturn baseDir, queryPrefix, term, nil\n\t}\n\n\t// No slash in query: search in the cwd.\n\treturn cwd, \"\", query, nil\n}\n\nfunc DisposeSuggestions(ctx context.Context, widgetId string) {\n\tcacheDispose(widgetId)\n}\n\nfunc FetchSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) {\n\tif data.SuggestionType == \"file\" {\n\t\treturn fetchFileSuggestions(ctx, data)\n\t}\n\tif data.SuggestionType == \"bookmark\" {\n\t\treturn fetchBookmarkSuggestions(ctx, data)\n\t}\n\treturn nil, fmt.Errorf(\"unsupported suggestion type: %q\", data.SuggestionType)\n}\n\nfunc filterBookmarksForValid(bookmarks map[string]wconfig.WebBookmark) map[string]wconfig.WebBookmark {\n\tvalidBookmarks := make(map[string]wconfig.WebBookmark)\n\tfor k, v := range bookmarks {\n\t\tif v.Url == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.ParseRequestURI(v.Url)\n\t\tif err != nil || u.Scheme == \"\" || u.Host == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tvalidBookmarks[k] = v\n\t}\n\treturn validBookmarks\n}\n\nfunc fetchBookmarkSuggestions(_ context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) {\n\tif data.SuggestionType != \"bookmark\" {\n\t\treturn nil, fmt.Errorf(\"unsupported suggestion type: %q\", data.SuggestionType)\n\t}\n\n\t// scoredEntry holds a bookmark along with its computed score, the match positions for the\n\t// field that will be used for display, the positions for the secondary field (if any),\n\t// and its original index in the Bookmarks list.\n\ttype scoredEntry struct {\n\t\tbookmark    wconfig.WebBookmark\n\t\tscore       int\n\t\tmatchPos    []int // positions for the field that's used as Display\n\t\tsubMatchPos []int // positions for the other field (if any)\n\t\torigIndex   int\n\t}\n\n\tbookmarks := wconfig.GetWatcher().GetFullConfig().Bookmarks\n\tbookmarks = filterBookmarksForValid(bookmarks)\n\n\tsearchTerm := data.Query\n\tvar patternRunes []rune\n\tif searchTerm != \"\" {\n\t\tpatternRunes = []rune(strings.ToLower(searchTerm))\n\t}\n\n\tvar scoredEntries []scoredEntry\n\tvar slab util.Slab\n\n\tbookmarkKeys := utilfn.GetMapKeys(bookmarks)\n\t// sort by display:order and then by key\n\tsort.Slice(bookmarkKeys, func(i, j int) bool {\n\t\tbookmarkA := bookmarks[bookmarkKeys[i]]\n\t\tbookmarkB := bookmarks[bookmarkKeys[j]]\n\t\tif bookmarkA.DisplayOrder != bookmarkB.DisplayOrder {\n\t\t\treturn bookmarkA.DisplayOrder < bookmarkB.DisplayOrder\n\t\t}\n\t\treturn bookmarkKeys[i] < bookmarkKeys[j]\n\t})\n\tfor i, bmkey := range bookmarkKeys {\n\t\tbookmark := bookmarks[bmkey]\n\t\t// If no search term, include all bookmarks (score 0, no positions).\n\t\tif searchTerm == \"\" {\n\t\t\tscoredEntries = append(scoredEntries, scoredEntry{\n\t\t\t\tbookmark:  bookmark,\n\t\t\t\tscore:     0,\n\t\t\t\torigIndex: i,\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\t// For bookmarks with a title, Display is set to the title and SubText to the URL.\n\t\t// We perform fuzzy matching on both fields.\n\t\tif bookmark.Title != \"\" {\n\t\t\t// Fuzzy match against the title.\n\t\t\tcandidateTitle := strings.ToLower(bookmark.Title)\n\t\t\ttextTitle := util.ToChars([]byte(candidateTitle))\n\t\t\tresultTitle, titlePositionsPtr := algo.FuzzyMatchV2(false, true, true, &textTitle, patternRunes, true, &slab)\n\t\t\tvar titleScore int\n\t\t\tvar titlePositions []int\n\t\t\tif titlePositionsPtr != nil {\n\t\t\t\ttitlePositions = *titlePositionsPtr\n\t\t\t}\n\t\t\ttitleScore = resultTitle.Score\n\n\t\t\t// Fuzzy match against the URL.\n\t\t\tcandidateUrl := strings.ToLower(bookmark.Url)\n\t\t\ttextUrl := util.ToChars([]byte(candidateUrl))\n\t\t\tresultUrl, urlPositionsPtr := algo.FuzzyMatchV2(false, true, true, &textUrl, patternRunes, true, &slab)\n\t\t\tvar urlScore int\n\t\t\tvar urlPositions []int\n\t\t\tif urlPositionsPtr != nil {\n\t\t\t\turlPositions = *urlPositionsPtr\n\t\t\t}\n\t\t\turlScore = resultUrl.Score\n\n\t\t\t// Compute the overall score as the higher of the two.\n\t\t\tmaxScore := titleScore\n\t\t\tif urlScore > maxScore {\n\t\t\t\tmaxScore = urlScore\n\t\t\t}\n\n\t\t\t// If neither field produced a positive match, skip this bookmark.\n\t\t\tif maxScore <= 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Since Display is title, we use the title match positions as MatchPos and the URL match positions as SubMatchPos.\n\t\t\tscoredEntries = append(scoredEntries, scoredEntry{\n\t\t\t\tbookmark:    bookmark,\n\t\t\t\tscore:       maxScore,\n\t\t\t\tmatchPos:    titlePositions,\n\t\t\t\tsubMatchPos: urlPositions,\n\t\t\t\torigIndex:   i,\n\t\t\t})\n\t\t} else {\n\t\t\t// For bookmarks with no title, Display is set to the URL.\n\t\t\t// Only perform fuzzy matching against the URL.\n\t\t\tcandidateUrl := strings.ToLower(bookmark.Url)\n\t\t\ttextUrl := util.ToChars([]byte(candidateUrl))\n\t\t\tresultUrl, urlPositionsPtr := algo.FuzzyMatchV2(false, true, true, &textUrl, patternRunes, true, &slab)\n\t\t\turlScore := resultUrl.Score\n\t\t\tvar urlPositions []int\n\t\t\tif urlPositionsPtr != nil {\n\t\t\t\turlPositions = *urlPositionsPtr\n\t\t\t}\n\n\t\t\t// Skip this bookmark if the URL doesn't match.\n\t\t\tif urlScore <= 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tscoredEntries = append(scoredEntries, scoredEntry{\n\t\t\t\tbookmark:    bookmark,\n\t\t\t\tscore:       urlScore,\n\t\t\t\tmatchPos:    urlPositions, // match positions come from the URL, since that's what is displayed.\n\t\t\t\tsubMatchPos: nil,\n\t\t\t\torigIndex:   i,\n\t\t\t})\n\t\t}\n\t}\n\n\t// Sort the scored entries in descending order by score.\n\t// For equal scores, preserve the original order from the Bookmarks list.\n\tsort.Slice(scoredEntries, func(i, j int) bool {\n\t\tif scoredEntries[i].score != scoredEntries[j].score {\n\t\t\treturn scoredEntries[i].score > scoredEntries[j].score\n\t\t}\n\t\treturn scoredEntries[i].origIndex < scoredEntries[j].origIndex\n\t})\n\n\t// Build up to MaxSuggestions suggestions.\n\tvar suggestions []wshrpc.SuggestionType\n\tfor _, entry := range scoredEntries {\n\t\tvar display, subText string\n\t\tif entry.bookmark.Title != \"\" {\n\t\t\tdisplay = entry.bookmark.Title\n\t\t\tsubText = entry.bookmark.Url\n\t\t} else {\n\t\t\tdisplay = entry.bookmark.Url\n\t\t\tsubText = \"\"\n\t\t}\n\n\t\tsuggestion := wshrpc.SuggestionType{\n\t\t\tType:         \"url\",\n\t\t\tSuggestionId: utilfn.QuickHashString(entry.bookmark.Url),\n\t\t\tDisplay:      display,\n\t\t\tSubText:      subText,\n\t\t\tMatchPos:     entry.matchPos,    // These positions correspond to the field in Display.\n\t\t\tSubMatchPos:  entry.subMatchPos, // For bookmarks with a title, this is the URL match positions.\n\t\t\tScore:        entry.score,\n\t\t\tUrlUrl:       entry.bookmark.Url,\n\t\t}\n\t\tsuggestion.IconSrc = faviconcache.GetFavicon(entry.bookmark.Url)\n\t\tsuggestions = append(suggestions, suggestion)\n\t\tif len(suggestions) >= MaxSuggestions {\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn &wshrpc.FetchSuggestionsResponse{\n\t\tSuggestions: suggestions,\n\t\tReqNum:      data.ReqNum,\n\t}, nil\n}\n\n// Define a scored entry for fuzzy matching.\ntype scoredEntry struct {\n\tent       fs.DirEntry\n\tscore     int\n\tfileName  string\n\tpositions []int\n}\n\n// We'll use a heap to only keep the top MaxSuggestions when a search term is provided.\n// Define a min-heap so that the worst (lowest scoring) candidate is at the top.\ntype scoredEntryHeap []scoredEntry\n\n// Less: lower score is “less”. For equal scores, a candidate with a longer filename is considered worse.\nfunc (h scoredEntryHeap) Len() int { return len(h) }\nfunc (h scoredEntryHeap) Less(i, j int) bool {\n\tif h[i].score != h[j].score {\n\t\treturn h[i].score < h[j].score\n\t}\n\treturn len(h[i].fileName) > len(h[j].fileName)\n}\nfunc (h scoredEntryHeap) Swap(i, j int)       { h[i], h[j] = h[j], h[i] }\nfunc (h *scoredEntryHeap) Push(x interface{}) { *h = append(*h, x.(scoredEntry)) }\nfunc (h *scoredEntryHeap) Pop() interface{} {\n\told := *h\n\tn := len(old)\n\tx := old[n-1]\n\t*h = old[0 : n-1]\n\treturn x\n}\n\nfunc fetchFileSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) {\n\t// Only support file suggestions.\n\tif data.SuggestionType != \"file\" {\n\t\treturn nil, fmt.Errorf(\"unsupported suggestion type: %q\", data.SuggestionType)\n\t}\n\n\t// Resolve the base directory, query prefix (for display) and search term.\n\tbaseDir, queryPrefix, searchTerm, err := resolveFileQuery(data.FileCwd, data.Query)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error resolving base dir: %w\", err)\n\t}\n\n\t// Use a cancellable context for directory listing.\n\tlistingCtx, cancelFn := context.WithCancel(ctx)\n\tdefer cancelFn()\n\n\tentriesCh, err := listDirectory(listingCtx, data.WidgetId, baseDir, 1000)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error listing directory: %w\", err)\n\t}\n\n\tconst maxEntries = MaxSuggestions // top-k entries\n\n\t// Always use a heap.\n\tvar topHeap scoredEntryHeap\n\theap.Init(&topHeap)\n\n\tvar patternRunes []rune\n\tif searchTerm != \"\" {\n\t\tpatternRunes = []rune(strings.ToLower(searchTerm))\n\t}\n\n\tvar slab util.Slab\n\tvar index int // used for ordering when searchTerm is empty\n\n\t// Process each directory entry.\n\tfor result := range entriesCh {\n\t\tif result.Err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error reading directory: %w\", result.Err)\n\t\t}\n\t\tde := result.Entry\n\t\tfileName := de.Name()\n\t\tvar score int\n\t\tvar candidatePositions []int\n\n\t\tif searchTerm != \"\" {\n\t\t\t// Perform fuzzy matching.\n\t\t\tcandidate := strings.ToLower(fileName)\n\t\t\ttext := util.ToChars([]byte(candidate))\n\t\t\tmatchResult, positions := algo.FuzzyMatchV2(false, true, true, &text, patternRunes, true, &slab)\n\t\t\tif matchResult.Score <= 0 {\n\t\t\t\tindex++\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tscore = matchResult.Score\n\t\t\tif positions != nil {\n\t\t\t\tcandidatePositions = *positions\n\t\t\t}\n\t\t} else {\n\t\t\t// Use ordering: first entry gets highest score.\n\t\t\tscore = maxEntries - index\n\t\t}\n\t\tindex++\n\n\t\tse := scoredEntry{\n\t\t\tent:       de,\n\t\t\tscore:     score,\n\t\t\tfileName:  fileName,\n\t\t\tpositions: candidatePositions,\n\t\t}\n\n\t\tif topHeap.Len() < maxEntries {\n\t\t\theap.Push(&topHeap, se)\n\t\t} else {\n\t\t\t// Replace the worst candidate if this one is better.\n\t\t\tworst := topHeap[0]\n\t\t\tif se.score > worst.score || (se.score == worst.score && len(se.fileName) < len(worst.fileName)) {\n\t\t\t\theap.Pop(&topHeap)\n\t\t\t\theap.Push(&topHeap, se)\n\t\t\t}\n\t\t}\n\t\tif searchTerm == \"\" && topHeap.Len() >= maxEntries {\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// Extract and sort the scored entries (highest score first).\n\tscoredEntries := make([]scoredEntry, topHeap.Len())\n\tcopy(scoredEntries, topHeap)\n\tsort.Slice(scoredEntries, func(i, j int) bool {\n\t\tif scoredEntries[i].score != scoredEntries[j].score {\n\t\t\treturn scoredEntries[i].score > scoredEntries[j].score\n\t\t}\n\t\treturn len(scoredEntries[i].fileName) < len(scoredEntries[j].fileName)\n\t})\n\n\t// Build suggestions from the scored entries.\n\tvar suggestions []wshrpc.SuggestionType\n\tfor _, candidate := range scoredEntries {\n\t\tfileName := candidate.ent.Name()\n\t\tfullPath := filepath.Join(baseDir, fileName)\n\t\tsuggestionFileName := filepath.Join(queryPrefix, fileName)\n\t\toffset := len(suggestionFileName) - len(fileName)\n\t\tif offset > 0 && len(candidate.positions) > 0 {\n\t\t\t// Adjust match positions to account for the query prefix.\n\t\t\tfor j := range candidate.positions {\n\t\t\t\tcandidate.positions[j] += offset\n\t\t\t}\n\t\t}\n\t\ts := wshrpc.SuggestionType{\n\t\t\tType:         \"file\",\n\t\t\tFilePath:     fullPath,\n\t\t\tSuggestionId: utilfn.QuickHashString(fullPath),\n\t\t\tDisplay:      suggestionFileName,\n\t\t\tFileName:     suggestionFileName,\n\t\t\tFileMimeType: fileutil.DetectMimeTypeWithDirEnt(fullPath, candidate.ent),\n\t\t\tMatchPos:     candidate.positions,\n\t\t\tScore:        candidate.score,\n\t\t}\n\t\tsuggestions = append(suggestions, s)\n\t\tif len(suggestions) >= MaxSuggestions {\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn &wshrpc.FetchSuggestionsResponse{\n\t\tSuggestions: suggestions,\n\t\tReqNum:      data.ReqNum,\n\t}, nil\n}\n"
  },
  {
    "path": "pkg/telemetry/telemetry.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage telemetry\n\nimport (\n\t\"context\"\n\t\"database/sql/driver\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/daystr\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/dbutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nconst MaxTzNameLen = 50\nconst ActivityEventName = \"app:activity\"\n\nvar cachedTosAgreedTs atomic.Int64\n\nfunc GetTosAgreedTs() int64 {\n\tcached := cachedTosAgreedTs.Load()\n\tif cached != 0 {\n\t\treturn cached\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\tclient, err := wstore.DBGetSingleton[*waveobj.Client](ctx)\n\tif err != nil || client == nil || client.TosAgreed == 0 {\n\t\treturn 0\n\t}\n\n\tcachedTosAgreedTs.Store(client.TosAgreed)\n\treturn client.TosAgreed\n}\n\ntype ActivityType struct {\n\tDay           string        `json:\"day\"`\n\tUploaded      bool          `json:\"-\"`\n\tTData         TelemetryData `json:\"tdata\"`\n\tTzName        string        `json:\"tzname\"`\n\tTzOffset      int           `json:\"tzoffset\"`\n\tClientVersion string        `json:\"clientversion\"`\n\tClientArch    string        `json:\"clientarch\"`\n\tBuildTime     string        `json:\"buildtime\"`\n\tOSRelease     string        `json:\"osrelease\"`\n}\n\ntype TelemetryData struct {\n\tActiveMinutes       int                          `json:\"activeminutes\"`\n\tFgMinutes           int                          `json:\"fgminutes\"`\n\tOpenMinutes         int                          `json:\"openminutes\"`\n\tWaveAIActiveMinutes int                          `json:\"waveaiactiveminutes,omitempty\"`\n\tWaveAIFgMinutes     int                          `json:\"waveaifgminutes,omitempty\"`\n\tNumTabs             int                          `json:\"numtabs\"`\n\tNumBlocks           int                          `json:\"numblocks,omitempty\"`\n\tNumWindows          int                          `json:\"numwindows,omitempty\"`\n\tNumWS               int                          `json:\"numws,omitempty\"`\n\tNumWSNamed          int                          `json:\"numwsnamed,omitempty\"`\n\tNumSSHConn          int                          `json:\"numsshconn,omitempty\"`\n\tNumWSLConn          int                          `json:\"numwslconn,omitempty\"`\n\tNumMagnify          int                          `json:\"nummagnify,omitempty\"`\n\tNewTab              int                          `json:\"newtab\"`\n\tNumStartup          int                          `json:\"numstartup,omitempty\"`\n\tNumShutdown         int                          `json:\"numshutdown,omitempty\"`\n\tNumPanics           int                          `json:\"numpanics,omitempty\"`\n\tNumAIReqs           int                          `json:\"numaireqs,omitempty\"`\n\tSetTabTheme         int                          `json:\"settabtheme,omitempty\"`\n\tDisplays            []wshrpc.ActivityDisplayType `json:\"displays,omitempty\"`\n\tRenderers           map[string]int               `json:\"renderers,omitempty\"`\n\tBlocks              map[string]int               `json:\"blocks,omitempty\"`\n\tWshCmds             map[string]int               `json:\"wshcmds,omitempty\"`\n\tConn                map[string]int               `json:\"conn,omitempty\"`\n}\n\nfunc (tdata TelemetryData) Value() (driver.Value, error) {\n\treturn dbutil.QuickValueJson(tdata)\n}\n\nfunc (tdata *TelemetryData) Scan(val interface{}) error {\n\treturn dbutil.QuickScanJson(tdata, val)\n}\n\nfunc IsTelemetryEnabled() bool {\n\tsettings := wconfig.GetWatcher().GetFullConfig()\n\treturn settings.Settings.TelemetryEnabled\n}\n\nfunc IsAutoUpdateEnabled() bool {\n\tsettings := wconfig.GetWatcher().GetFullConfig()\n\treturn settings.Settings.AutoUpdateEnabled\n}\n\nfunc AutoUpdateChannel() string {\n\tsettings := wconfig.GetWatcher().GetFullConfig()\n\treturn settings.Settings.AutoUpdateChannel\n}\n\n// Wraps UpdateCurrentActivity, spawns goroutine, and logs errors\nfunc GoUpdateActivityWrap(update wshrpc.ActivityUpdate, debugStr string) {\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandlerNoTelemetry(\"GoUpdateActivityWrap\", recover())\n\t\t}()\n\t\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer cancelFn()\n\t\terr := UpdateActivity(ctx, update)\n\t\tif err != nil {\n\t\t\t// ignore error, just log, since this is not critical\n\t\t\tlog.Printf(\"error updating current activity (%s): %v\\n\", debugStr, err)\n\t\t}\n\t}()\n}\n\nfunc insertTEvent(ctx context.Context, event *telemetrydata.TEvent) error {\n\tif event.Uuid == \"\" {\n\t\treturn fmt.Errorf(\"cannot insert TEvent: uuid is empty\")\n\t}\n\tif event.Ts == 0 {\n\t\treturn fmt.Errorf(\"cannot insert TEvent: ts is 0\")\n\t}\n\tif event.TsLocal == \"\" {\n\t\treturn fmt.Errorf(\"cannot insert TEvent: tslocal is empty\")\n\t}\n\tif event.Event == \"\" {\n\t\treturn fmt.Errorf(\"cannot insert TEvent: event is empty\")\n\t}\n\treturn wstore.WithTx(ctx, func(tx *wstore.TxWrap) error {\n\t\tquery := `INSERT INTO db_tevent (uuid, ts, tslocal, event, props)\n\t\t\t\t  VALUES (?, ?, ?, ?, ?)`\n\t\ttx.Exec(query, event.Uuid, event.Ts, event.TsLocal, event.Event, dbutil.QuickJson(event.Props))\n\t\treturn nil\n\t})\n}\n\n// merges newActivity into curActivity, returns curActivity\nfunc mergeActivity(curActivity *telemetrydata.TEventProps, newActivity telemetrydata.TEventProps) {\n\tcurActivity.ActiveMinutes += newActivity.ActiveMinutes\n\tcurActivity.FgMinutes += newActivity.FgMinutes\n\tcurActivity.OpenMinutes += newActivity.OpenMinutes\n\tcurActivity.WaveAIActiveMinutes += newActivity.WaveAIActiveMinutes\n\tcurActivity.WaveAIFgMinutes += newActivity.WaveAIFgMinutes\n\tcurActivity.TermCommandsRun += newActivity.TermCommandsRun\n\tcurActivity.TermCommandsRemote += newActivity.TermCommandsRemote\n\tcurActivity.TermCommandsDurable += newActivity.TermCommandsDurable\n\tcurActivity.TermCommandsWsl += newActivity.TermCommandsWsl\n\tif newActivity.AppFirstDay {\n\t\tcurActivity.AppFirstDay = true\n\t}\n}\n\n// ignores the timestamp in tevent, and uses the current time\nfunc updateActivityTEvent(ctx context.Context, tevent *telemetrydata.TEvent) error {\n\teventTs := time.Now()\n\t// compute to 1-hour boundary, and round up to next 1-hour boundary\n\teventTs = eventTs.Truncate(time.Hour).Add(time.Hour)\n\n\treturn wstore.WithTx(ctx, func(tx *wstore.TxWrap) error {\n\t\t// find event that matches this timestamp with event name \"app:activity\"\n\t\tvar hasRow bool\n\t\tvar curActivity telemetrydata.TEventProps\n\t\tuuidStr := tx.GetString(`SELECT uuid FROM db_tevent WHERE ts = ? AND event = ?`, eventTs.UnixMilli(), ActivityEventName)\n\t\tif uuidStr != \"\" {\n\t\t\thasRow = true\n\t\t\trawProps := tx.GetString(`SELECT props FROM db_tevent WHERE uuid = ?`, uuidStr)\n\t\t\terr := json.Unmarshal([]byte(rawProps), &curActivity)\n\t\t\tif err != nil {\n\t\t\t\t// ignore, curActivity will just be 0\n\t\t\t\tlog.Printf(\"error unmarshalling activity props: %v\\n\", err)\n\t\t\t}\n\t\t}\n\t\tmergeActivity(&curActivity, tevent.Props)\n\n\t\tif hasRow {\n\t\t\tquery := `UPDATE db_tevent SET props = ? WHERE uuid = ?`\n\t\t\ttx.Exec(query, dbutil.QuickJson(curActivity), uuidStr)\n\t\t} else {\n\t\t\tquery := `INSERT INTO db_tevent (uuid, ts, tslocal, event, props) VALUES (?, ?, ?, ?, ?)`\n\t\t\ttsLocal := utilfn.ConvertToWallClockPT(eventTs).Format(time.RFC3339)\n\t\t\ttx.Exec(query, uuid.New().String(), eventTs.UnixMilli(), tsLocal, ActivityEventName, dbutil.QuickJson(curActivity))\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc TruncateActivityTEventForShutdown(ctx context.Context) error {\n\tnowTs := time.Now()\n\teventTs := nowTs.Truncate(time.Hour).Add(time.Hour)\n\treturn wstore.WithTx(ctx, func(tx *wstore.TxWrap) error {\n\t\t// find event that matches this timestamp with event name \"app:activity\"\n\t\tuuidStr := tx.GetString(`SELECT uuid FROM db_tevent WHERE ts = ? AND event = ?`, eventTs.UnixMilli(), ActivityEventName)\n\t\tif uuidStr == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\t// we're going to update this app:activity event back to nowTs\n\t\ttsLocal := utilfn.ConvertToWallClockPT(nowTs).Format(time.RFC3339)\n\t\tquery := `UPDATE db_tevent SET ts = ?, tslocal = ? WHERE uuid = ?`\n\t\ttx.Exec(query, nowTs.UnixMilli(), tsLocal, uuidStr)\n\t\treturn nil\n\t})\n}\n\nfunc GoRecordTEventWrap(tevent *telemetrydata.TEvent) {\n\tif tevent == nil || tevent.Event == \"\" {\n\t\treturn\n\t}\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandlerNoTelemetry(\"GoRecordTEventWrap\", recover())\n\t\t}()\n\t\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\t\tdefer cancelFn()\n\t\terr := RecordTEvent(ctx, tevent)\n\t\tif err != nil {\n\t\t\t// ignore error, just log, since this is not critical\n\t\t\tlog.Printf(\"error recording %q telemetry event: %v\\n\", tevent.Event, err)\n\t\t}\n\t}()\n}\n\nfunc RecordTEvent(ctx context.Context, tevent *telemetrydata.TEvent) error {\n\tif tevent == nil {\n\t\treturn nil\n\t}\n\tif tevent.Uuid == \"\" {\n\t\ttevent.Uuid = uuid.New().String()\n\t}\n\terr := tevent.Validate(true)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttevent.EnsureTimestamps()\n\n\t// Set AppFirstDay if on same calendar day as TOS agreement\n\ttosAgreedTs := GetTosAgreedTs()\n\tif tosAgreedTs == 0 {\n\t\ttevent.Props.AppFirstDay = true\n\t} else {\n\t\ttosYear, tosMonth, tosDay := time.UnixMilli(tosAgreedTs).Date()\n\t\tnowYear, nowMonth, nowDay := time.Now().Date()\n\t\tif tosYear == nowYear && tosMonth == nowMonth && tosDay == nowDay {\n\t\t\ttevent.Props.AppFirstDay = true\n\t\t}\n\t}\n\n\tif tevent.Event == ActivityEventName {\n\t\treturn updateActivityTEvent(ctx, tevent)\n\t}\n\treturn insertTEvent(ctx, tevent)\n}\n\nfunc CleanOldTEvents(ctx context.Context) error {\n\tdaysToKeep := 7\n\tif !IsTelemetryEnabled() {\n\t\tdaysToKeep = 1\n\t}\n\tolderThan := time.Now().AddDate(0, 0, -daysToKeep).UnixMilli()\n\treturn wstore.WithTx(ctx, func(tx *wstore.TxWrap) error {\n\t\tquery := `DELETE FROM db_tevent WHERE ts < ?`\n\t\ttx.Exec(query, olderThan)\n\t\treturn nil\n\t})\n}\n\nfunc GetNonUploadedTEvents(ctx context.Context, maxEvents int) ([]*telemetrydata.TEvent, error) {\n\tnow := time.Now()\n\treturn wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) ([]*telemetrydata.TEvent, error) {\n\t\tvar rtn []*telemetrydata.TEvent\n\t\tquery := `SELECT uuid, ts, tslocal, event, props, uploaded FROM db_tevent WHERE uploaded = 0 AND ts <= ? ORDER BY ts LIMIT ?`\n\t\ttx.Select(&rtn, query, now.UnixMilli(), maxEvents)\n\t\tfor _, event := range rtn {\n\t\t\tif err := event.ConvertRawJSON(); err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"scan json for event %s: %w\", event.Uuid, err)\n\t\t\t}\n\t\t}\n\t\treturn rtn, nil\n\t})\n}\n\nfunc MarkTEventsAsUploaded(ctx context.Context, events []*telemetrydata.TEvent) error {\n\treturn wstore.WithTx(ctx, func(tx *wstore.TxWrap) error {\n\t\tids := make([]string, 0, len(events))\n\t\tfor _, event := range events {\n\t\t\tids = append(ids, event.Uuid)\n\t\t}\n\t\tquery := `UPDATE db_tevent SET uploaded = 1 WHERE uuid IN (SELECT value FROM json_each(?))`\n\t\ttx.Exec(query, dbutil.QuickJson(ids))\n\t\treturn nil\n\t})\n}\n\nfunc UpdateActivity(ctx context.Context, update wshrpc.ActivityUpdate) error {\n\tnow := time.Now()\n\tdayStr := daystr.GetCurDayStr()\n\ttxErr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error {\n\t\tvar tdata TelemetryData\n\t\tquery := `SELECT tdata FROM db_activity WHERE day = ?`\n\t\tfound := tx.Get(&tdata, query, dayStr)\n\t\tif !found {\n\t\t\tquery = `INSERT INTO db_activity (day, uploaded, tdata, tzname, tzoffset, clientversion, clientarch, buildtime, osrelease)\n                                      VALUES (  ?,        0,     ?,      ?,        ?,             ?,          ?,         ?,         ?)`\n\t\t\ttzName, tzOffset := now.Zone()\n\t\t\tif len(tzName) > MaxTzNameLen {\n\t\t\t\ttzName = tzName[0:MaxTzNameLen]\n\t\t\t}\n\t\t\ttx.Exec(query, dayStr, tdata, tzName, tzOffset, wavebase.WaveVersion, wavebase.ClientArch(), wavebase.BuildTime, wavebase.UnameKernelRelease())\n\t\t}\n\t\ttdata.FgMinutes += update.FgMinutes\n\t\ttdata.ActiveMinutes += update.ActiveMinutes\n\t\ttdata.OpenMinutes += update.OpenMinutes\n\t\ttdata.WaveAIFgMinutes += update.WaveAIFgMinutes\n\t\ttdata.WaveAIActiveMinutes += update.WaveAIActiveMinutes\n\t\ttdata.NewTab += update.NewTab\n\t\ttdata.NumStartup += update.Startup\n\t\ttdata.NumShutdown += update.Shutdown\n\t\ttdata.SetTabTheme += update.SetTabTheme\n\t\ttdata.NumMagnify += update.NumMagnify\n\t\ttdata.NumPanics += update.NumPanics\n\t\ttdata.NumAIReqs += update.NumAIReqs\n\t\tif update.NumTabs > 0 {\n\t\t\ttdata.NumTabs = update.NumTabs\n\t\t}\n\t\tif update.NumBlocks > 0 {\n\t\t\ttdata.NumBlocks = update.NumBlocks\n\t\t}\n\t\tif update.NumWindows > 0 {\n\t\t\ttdata.NumWindows = update.NumWindows\n\t\t}\n\t\tif update.NumWS > 0 {\n\t\t\ttdata.NumWS = update.NumWS\n\t\t}\n\t\tif update.NumWSNamed > 0 {\n\t\t\ttdata.NumWSNamed = update.NumWSNamed\n\t\t}\n\t\tif update.NumSSHConn > 0 && update.NumSSHConn > tdata.NumSSHConn {\n\t\t\ttdata.NumSSHConn = update.NumSSHConn\n\t\t}\n\t\tif update.NumWSLConn > 0 && update.NumWSLConn > tdata.NumWSLConn {\n\t\t\ttdata.NumWSLConn = update.NumWSLConn\n\t\t}\n\t\tif len(update.Renderers) > 0 {\n\t\t\tif tdata.Renderers == nil {\n\t\t\t\ttdata.Renderers = make(map[string]int)\n\t\t\t}\n\t\t\tfor key, val := range update.Renderers {\n\t\t\t\ttdata.Renderers[key] += val\n\t\t\t}\n\t\t}\n\t\tif len(update.WshCmds) > 0 {\n\t\t\tif tdata.WshCmds == nil {\n\t\t\t\ttdata.WshCmds = make(map[string]int)\n\t\t\t}\n\t\t\tfor key, val := range update.WshCmds {\n\t\t\t\ttdata.WshCmds[key] += val\n\t\t\t}\n\t\t}\n\t\tif len(update.Conn) > 0 {\n\t\t\tif tdata.Conn == nil {\n\t\t\t\ttdata.Conn = make(map[string]int)\n\t\t\t}\n\t\t\tfor key, val := range update.Conn {\n\t\t\t\ttdata.Conn[key] += val\n\t\t\t}\n\t\t}\n\t\tif len(update.Displays) > 0 {\n\t\t\ttdata.Displays = update.Displays\n\t\t}\n\t\tif len(update.Blocks) > 0 {\n\t\t\ttdata.Blocks = update.Blocks\n\t\t}\n\t\tquery = `UPDATE db_activity\n                 SET tdata = ?,\n                     clientversion = ?,\n                     buildtime = ?\n                 WHERE day = ?`\n\t\ttx.Exec(query, tdata, wavebase.WaveVersion, wavebase.BuildTime, dayStr)\n\t\treturn nil\n\t})\n\tif txErr != nil {\n\t\treturn txErr\n\t}\n\treturn nil\n}\n\nfunc GetNonUploadedActivity(ctx context.Context) ([]*ActivityType, error) {\n\tvar rtn []*ActivityType\n\ttxErr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error {\n\t\tquery := `SELECT * FROM db_activity WHERE uploaded = 0 ORDER BY day DESC LIMIT 30`\n\t\ttx.Select(&rtn, query)\n\t\treturn nil\n\t})\n\tif txErr != nil {\n\t\treturn nil, txErr\n\t}\n\treturn rtn, nil\n}\n\nfunc MarkActivityAsUploaded(ctx context.Context, activityArr []*ActivityType) error {\n\tdayStr := daystr.GetCurDayStr()\n\ttxErr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error {\n\t\tquery := `UPDATE db_activity SET uploaded = 1 WHERE day = ?`\n\t\tfor _, activity := range activityArr {\n\t\t\tif activity.Day == dayStr {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttx.Exec(query, activity.Day)\n\t\t}\n\t\treturn nil\n\t})\n\treturn txErr\n}\n"
  },
  {
    "path": "pkg/telemetry/telemetrydata/telemetrydata.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage telemetrydata\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n)\n\nvar ValidEventNames = map[string]bool{\n\t\"app:startup\":  true,\n\t\"app:shutdown\": true,\n\t\"app:activity\": true,\n\t\"app:display\":  true,\n\t\"app:counts\":   true,\n\n\t\"action:magnify\":     true,\n\t\"action:settabtheme\": true,\n\t\"action:runaicmd\":    true,\n\t\"action:createtab\":   true,\n\t\"action:createblock\": true,\n\t\"action:openwaveai\":  true,\n\t\"action:other\":       true,\n\t\"action:term\":        true,\n\t\"action:termdurable\": true,\n\t\"action:link\":        true,\n\n\t\"wsh:run\": true,\n\n\t\"debug:panic\": true,\n\n\t\"conn:connect\":      true,\n\t\"conn:connecterror\": true,\n\t\"conn:nowsh\":        true,\n\n\t\"waveai:enabletelemetry\": true,\n\t\"waveai:post\":            true,\n\t\"waveai:feedback\":        true,\n\t\"waveai:showdiff\":        true,\n\t\"waveai:revertfile\":      true,\n\n\t\"onboarding:start\":      true,\n\t\"onboarding:skip\":       true,\n\t\"onboarding:fire\":       true,\n\t\"onboarding:githubstar\": true,\n\n\t\"job:start\":     true,\n\t\"job:reconnect\": true,\n\t\"job:done\":      true,\n}\n\ntype TEvent struct {\n\tUuid    string      `json:\"uuid,omitempty\" db:\"uuid\"`\n\tTs      int64       `json:\"ts,omitempty\" db:\"ts\"`\n\tTsLocal string      `json:\"tslocal,omitempty\" db:\"tslocal\"` // iso8601 format (wall clock converted to PT)\n\tEvent   string      `json:\"event\" db:\"event\"`\n\tProps   TEventProps `json:\"props\" db:\"-\"` // Don't scan directly to map\n\n\t// DB fields\n\tUploaded bool `json:\"-\" db:\"uploaded\"`\n\n\t// For database scanning\n\tRawProps string `json:\"-\" db:\"props\"`\n}\n\ntype TEventUserProps struct {\n\tClientArch           string `json:\"client:arch,omitempty\"`\n\tClientVersion        string `json:\"client:version,omitempty\"`\n\tClientInitialVersion string `json:\"client:initial_version,omitempty\"`\n\tClientBuildTime      string `json:\"client:buildtime,omitempty\"`\n\tClientOSRelease      string `json:\"client:osrelease,omitempty\"`\n\tClientIsDev          bool   `json:\"client:isdev,omitempty\"`\n\tClientPackageType    string `json:\"client:packagetype,omitempty\"`\n\tClientMacOSVersion   string `json:\"client:macos,omitempty\"`\n\n\tCohortMonth   string `json:\"cohort:month,omitempty\"`\n\tCohortISOWeek string `json:\"cohort:isoweek,omitempty\"`\n\n\tAutoUpdateChannel string `json:\"autoupdate:channel,omitempty\"`\n\tAutoUpdateEnabled bool   `json:\"autoupdate:enabled,omitempty\"`\n\n\tLocalShellType    string `json:\"localshell:type,omitempty\"`\n\tLocalShellVersion string `json:\"localshell:version,omitempty\"`\n\n\tLocCountryCode string `json:\"loc:countrycode,omitempty\"`\n\tLocRegionCode  string `json:\"loc:regioncode,omitempty\"`\n\n\tSettingsCustomWidgets   int  `json:\"settings:customwidgets,omitempty\"`\n\tSettingsCustomAIPresets int  `json:\"settings:customaipresets,omitempty\"`\n\tSettingsCustomSettings  int  `json:\"settings:customsettings,omitempty\"`\n\tSettingsCustomAIModes   int  `json:\"settings:customaimodes,omitempty\"`\n\tSettingsSecretsCount    int  `json:\"settings:secretscount,omitempty\"`\n\tSettingsTransparent     bool `json:\"settings:transparent,omitempty\"`\n}\n\ntype TEventProps struct {\n\tTEventUserProps `tstype:\"-\"` // generally don't need to set these since they will be automatically copied over\n\n\tActiveMinutes       int `json:\"activity:activeminutes,omitempty\"`\n\tFgMinutes           int `json:\"activity:fgminutes,omitempty\"`\n\tOpenMinutes         int `json:\"activity:openminutes,omitempty\"`\n\tWaveAIActiveMinutes int `json:\"activity:waveaiactiveminutes,omitempty\"`\n\tWaveAIFgMinutes     int `json:\"activity:waveaifgminutes,omitempty\"`\n\tTermCommandsRun     int `json:\"activity:termcommandsrun,omitempty\"`\n\tTermCommandsRemote  int `json:\"activity:termcommands:remote,omitempty\"`\n\tTermCommandsDurable int `json:\"activity:termcommands:durable,omitempty\"`\n\tTermCommandsWsl     int `json:\"activity:termcommands:wsl,omitempty\"`\n\n\tAppFirstDay    bool `json:\"app:firstday,omitempty\"`\n\tAppFirstLaunch bool `json:\"app:firstlaunch,omitempty\"`\n\n\tActionInitiator string `json:\"action:initiator,omitempty\" tstype:\"\\\"keyboard\\\" | \\\"mouse\\\"\"`\n\tActionType      string `json:\"action:type,omitempty\"`\n\n\tPanicType string `json:\"debug:panictype,omitempty\"`\n\n\tBlockView       string `json:\"block:view,omitempty\"`\n\tBlockController string `json:\"block:controller,omitempty\"`\n\n\tAiBackendType string `json:\"ai:backendtype,omitempty\"`\n\tAiLocal       bool   `json:\"ai:local,omitempty\"`\n\n\tWshCmd      string `json:\"wsh:cmd,omitempty\"`\n\tWshHadError bool   `json:\"wsh:haderror,omitempty\"`\n\n\tConnType         string `json:\"conn:conntype,omitempty\"`\n\tConnWshErrorCode string `json:\"conn:wsherrorcode,omitempty\"`\n\tConnErrorCode    string `json:\"conn:errorcode,omitempty\"`\n\tConnSubErrorCode string `json:\"conn:suberrorcode,omitempty\"`\n\tConnContextError bool   `json:\"conn:contexterror,omitempty\"`\n\n\tOnboardingFeature    string `json:\"onboarding:feature,omitempty\" tstype:\"\\\"waveai\\\" | \\\"durable\\\" | \\\"magnify\\\" | \\\"wsh\\\"\"`\n\tOnboardingVersion    string `json:\"onboarding:version,omitempty\"`\n\tOnboardingGithubStar string `json:\"onboarding:githubstar,omitempty\" tstype:\"\\\"already\\\" | \\\"star\\\" | \\\"later\\\"\"`\n\tOnboardingPage       string `json:\"onboarding:page,omitempty\"`\n\n\tDisplayHeight int         `json:\"display:height,omitempty\"`\n\tDisplayWidth  int         `json:\"display:width,omitempty\"`\n\tDisplayDPR    float64     `json:\"display:dpr,omitempty\"`\n\tDisplayCount  int         `json:\"display:count,omitempty\"`\n\tDisplayAll    interface{} `json:\"display:all,omitempty\"`\n\n\tCountBlocks        int            `json:\"count:blocks,omitempty\"`\n\tCountTabs          int            `json:\"count:tabs,omitempty\"`\n\tCountWindows       int            `json:\"count:windows,omitempty\"`\n\tCountWorkspaces    int            `json:\"count:workspaces,omitempty\"`\n\tCountSSHConn       int            `json:\"count:sshconn,omitempty\"`\n\tCountWSLConn       int            `json:\"count:wslconn,omitempty\"`\n\tCountJobs          int            `json:\"count:jobs,omitempty\"`\n\tCountJobsConnected int            `json:\"count:jobsconnected,omitempty\"`\n\tCountViews         map[string]int `json:\"count:views,omitempty\"`\n\n\tWaveAIAPIType              string         `json:\"waveai:apitype,omitempty\"`\n\tWaveAIModel                string         `json:\"waveai:model,omitempty\"`\n\tWaveAIChatId               string         `json:\"waveai:chatid,omitempty\"`\n\tWaveAIStepNum              int            `json:\"waveai:stepnum,omitempty\"`\n\tWaveAIInputTokens          int            `json:\"waveai:inputtokens,omitempty\"`\n\tWaveAIOutputTokens         int            `json:\"waveai:outputtokens,omitempty\"`\n\tWaveAINativeWebSearchCount int            `json:\"waveai:nativewebsearchcount,omitempty\"`\n\tWaveAIRequestCount         int            `json:\"waveai:requestcount,omitempty\"`\n\tWaveAIToolUseCount         int            `json:\"waveai:toolusecount,omitempty\"`\n\tWaveAIToolUseErrorCount    int            `json:\"waveai:tooluseerrorcount,omitempty\"`\n\tWaveAIToolDetail           map[string]int `json:\"waveai:tooldetail,omitempty\"`\n\tWaveAIPremiumReq           int            `json:\"waveai:premiumreq,omitempty\"`\n\tWaveAIProxyReq             int            `json:\"waveai:proxyreq,omitempty\"`\n\tWaveAIHadError             bool           `json:\"waveai:haderror,omitempty\"`\n\tWaveAIImageCount           int            `json:\"waveai:imagecount,omitempty\"`\n\tWaveAIPDFCount             int            `json:\"waveai:pdfcount,omitempty\"`\n\tWaveAITextDocCount         int            `json:\"waveai:textdoccount,omitempty\"`\n\tWaveAITextLen              int            `json:\"waveai:textlen,omitempty\"`\n\tWaveAIFirstByteMs          int            `json:\"waveai:firstbytems,omitempty\"`  // ms\n\tWaveAIRequestDurMs         int            `json:\"waveai:requestdurms,omitempty\"` // ms\n\tWaveAIWidgetAccess         bool           `json:\"waveai:widgetaccess,omitempty\"`\n\tWaveAIThinkingLevel        string         `json:\"waveai:thinkinglevel,omitempty\"`\n\tWaveAIMode                 string         `json:\"waveai:mode,omitempty\"`\n\tWaveAIProvider             string         `json:\"waveai:provider,omitempty\"`\n\tWaveAIIsLocal              bool           `json:\"waveai:islocal,omitempty\"`\n\tWaveAIFeedback             string         `json:\"waveai:feedback,omitempty\" tstype:\"\\\"good\\\" | \\\"bad\\\"\"`\n\tWaveAIAction               string         `json:\"waveai:action,omitempty\"`\n\n\tJobDoneReason string `json:\"job:donereason,omitempty\"`\n\tJobKind       string `json:\"job:kind,omitempty\"`\n\n\tUserSet     *TEventUserProps `json:\"$set,omitempty\"`\n\tUserSetOnce *TEventUserProps `json:\"$set_once,omitempty\"`\n}\n\nfunc MakeTEvent(event string, props TEventProps) *TEvent {\n\tnow := time.Now()\n\t// TsLocal gets set in EnsureTimestamps()\n\treturn &TEvent{\n\t\tUuid:  uuid.New().String(),\n\t\tTs:    now.UnixMilli(),\n\t\tEvent: event,\n\t\tProps: props,\n\t}\n}\n\nfunc MakeUntypedTEvent(event string, propsMap map[string]any) (*TEvent, error) {\n\tif event == \"\" {\n\t\treturn nil, fmt.Errorf(\"event name must be non-empty\")\n\t}\n\tvar props TEventProps\n\terr := utilfn.ReUnmarshal(&props, propsMap)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error re-marshalling TEvent props: %w\", err)\n\t}\n\treturn MakeTEvent(event, props), nil\n}\n\nfunc (t *TEvent) EnsureTimestamps() {\n\tif t.Ts == 0 {\n\t\tt.Ts = time.Now().UnixMilli()\n\t}\n\tgtime := time.UnixMilli(t.Ts)\n\tt.TsLocal = utilfn.ConvertToWallClockPT(gtime).Format(time.RFC3339)\n}\n\nfunc (t *TEvent) UserSetProps() *TEventUserProps {\n\tif t.Props.UserSet == nil {\n\t\tt.Props.UserSet = &TEventUserProps{}\n\t}\n\treturn t.Props.UserSet\n}\n\nfunc (t *TEvent) UserSetOnceProps() *TEventUserProps {\n\tif t.Props.UserSetOnce == nil {\n\t\tt.Props.UserSetOnce = &TEventUserProps{}\n\t}\n\treturn t.Props.UserSetOnce\n}\n\nfunc (t *TEvent) ConvertRawJSON() error {\n\tif t.RawProps != \"\" {\n\t\treturn json.Unmarshal([]byte(t.RawProps), &t.Props)\n\t}\n\treturn nil\n}\n\nvar eventNameRe = regexp.MustCompile(`^[a-zA-Z0-9.:_/-]+$`)\n\n// validates a tevent that was just created (not for validating out of the DB, or an uploaded TEvent)\n// checks that TS is pretty current (or unset)\nfunc (te *TEvent) Validate(current bool) error {\n\tif te == nil {\n\t\treturn fmt.Errorf(\"TEvent cannot be nil\")\n\t}\n\tif te.Event == \"\" {\n\t\treturn fmt.Errorf(\"TEvent.Event cannot be empty\")\n\t}\n\tif !eventNameRe.MatchString(te.Event) {\n\t\treturn fmt.Errorf(\"TEvent.Event invalid: %q\", te.Event)\n\t}\n\tif !ValidEventNames[te.Event] {\n\t\treturn fmt.Errorf(\"TEvent.Event not valid: %q\", te.Event)\n\t}\n\tif te.Uuid == \"\" {\n\t\treturn fmt.Errorf(\"TEvent.Uuid cannot be empty\")\n\t}\n\t_, err := uuid.Parse(te.Uuid)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"TEvent.Uuid invalid: %v\", err)\n\t}\n\tif current {\n\t\tif te.Ts != 0 {\n\t\t\tnow := time.Now().UnixMilli()\n\t\t\tif te.Ts > now+60000 || te.Ts < now-60000 {\n\t\t\t\treturn fmt.Errorf(\"TEvent.Ts is not current: %d\", te.Ts)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tif te.Ts == 0 {\n\t\t\treturn fmt.Errorf(\"TEvent.Ts must be set\")\n\t\t}\n\t\tif te.TsLocal == \"\" {\n\t\t\treturn fmt.Errorf(\"TEvent.TsLocal must be set\")\n\t\t}\n\t\tt, err := time.Parse(time.RFC3339, te.TsLocal)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"TEvent.TsLocal parse error: %v\", err)\n\t\t}\n\t\tnow := time.Now()\n\t\tif t.Before(now.Add(-30*24*time.Hour)) || t.After(now.Add(2*24*time.Hour)) {\n\t\t\treturn fmt.Errorf(\"tslocal out of valid range\")\n\t\t}\n\t}\n\tbarr, err := json.Marshal(te.Props)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"TEvent.Props JSON error: %v\", err)\n\t}\n\tif len(barr) > 20000 {\n\t\treturn fmt.Errorf(\"TEvent.Props too large: %d\", len(barr))\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/trimquotes/trimquotes.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage trimquotes\n\nimport (\n\t\"strconv\"\n)\n\nfunc TrimQuotes(s string) (string, bool) {\n\tif len(s) > 2 && s[0] == '\"' {\n\t\ttrimmed, err := strconv.Unquote(s)\n\t\tif err != nil {\n\t\t\treturn s, false\n\t\t}\n\t\treturn trimmed, true\n\t}\n\treturn s, false\n}\n\nfunc TryTrimQuotes(s string) string {\n\ttrimmed, _ := TrimQuotes(s)\n\treturn trimmed\n}\n\nfunc ReplaceQuotes(s string, shouldReplace bool) string {\n\tif shouldReplace {\n\t\treturn strconv.Quote(s)\n\t}\n\treturn s\n}\n"
  },
  {
    "path": "pkg/tsgen/tsgen.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage tsgen\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/eventbus\"\n\t\"github.com/wavetermdev/waveterm/pkg/filestore\"\n\t\"github.com/wavetermdev/waveterm/pkg/service\"\n\t\"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta\"\n\t\"github.com/wavetermdev/waveterm/pkg/userinput\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/vdom\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/web/webcmd\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\n// add extra types to generate here\nvar ExtraTypes = []any{\n\twaveobj.ORef{},\n\t(*waveobj.WaveObj)(nil),\n\tmap[string]any{},\n\tservice.WebCallType{},\n\tservice.WebReturnType{},\n\twaveobj.UIContext{},\n\teventbus.WSEventType{},\n\twps.WSFileEventData{},\n\twaveobj.LayoutActionData{},\n\tfilestore.WaveFile{},\n\twconfig.FullConfigType{},\n\twconfig.WatcherUpdate{},\n\twshutil.RpcMessage{},\n\twshrpc.WshServerCommandMeta{},\n\tuserinput.UserInputRequest{},\n\tvdom.VDomCreateContext{},\n\tvdom.VDomElem{},\n\tvdom.VDomFunc{},\n\tvdom.VDomRef{},\n\tvdom.VDomBinding{},\n\tvdom.VDomFrontendUpdate{},\n\tvdom.VDomBackendUpdate{},\n\twaveobj.MetaTSType{},\n\twaveobj.ObjRTInfo{},\n\tuctypes.RateLimitInfo{},\n\twconfig.AIModeConfigUpdate{},\n\twshrpc.BlockJobStatusData{},\n}\n\n// add extra type unions to generate here\nvar TypeUnions = []tsgenmeta.TypeUnionMeta{\n\twebcmd.WSCommandTypeUnionMeta(),\n}\n\nvar contextRType = reflect.TypeOf((*context.Context)(nil)).Elem()\nvar errorRType = reflect.TypeOf((*error)(nil)).Elem()\nvar anyRType = reflect.TypeOf((*interface{})(nil)).Elem()\nvar metaRType = reflect.TypeOf((*waveobj.MetaMapType)(nil)).Elem()\nvar metaSettingsType = reflect.TypeOf((*wshrpc.MetaSettingsType)(nil)).Elem()\nvar uiContextRType = reflect.TypeOf((*waveobj.UIContext)(nil)).Elem()\nvar waveObjRType = reflect.TypeOf((*waveobj.WaveObj)(nil)).Elem()\nvar updatesRtnRType = reflect.TypeOf(waveobj.UpdatesRtnType{})\nvar orefRType = reflect.TypeOf((*waveobj.ORef)(nil)).Elem()\nvar wshRpcInterfaceRType = reflect.TypeOf((*wshrpc.WshRpcInterface)(nil)).Elem()\n\nfunc generateTSMethodTypes(method reflect.Method, tsTypesMap map[reflect.Type]string, skipFirstArg bool) error {\n\tfor idx := 0; idx < method.Type.NumIn(); idx++ {\n\t\tif skipFirstArg && idx == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tinType := method.Type.In(idx)\n\t\tGenerateTSType(inType, tsTypesMap)\n\t}\n\tfor idx := 0; idx < method.Type.NumOut(); idx++ {\n\t\toutType := method.Type.Out(idx)\n\t\tGenerateTSType(outType, tsTypesMap)\n\t}\n\treturn nil\n}\n\nfunc getTSFieldName(field reflect.StructField) string {\n\ttsFieldTag := field.Tag.Get(\"tsfield\")\n\tif tsFieldTag != \"\" {\n\t\tif tsFieldTag == \"-\" {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn tsFieldTag\n\t}\n\tjsonTag := utilfn.GetJsonTag(field)\n\tif jsonTag == \"-\" {\n\t\treturn \"\"\n\t}\n\tif strings.Contains(jsonTag, \":\") {\n\t\treturn \"\\\"\" + jsonTag + \"\\\"\"\n\t}\n\tif jsonTag != \"\" {\n\t\treturn jsonTag\n\t}\n\treturn field.Name\n}\n\nfunc isFieldOmitEmpty(field reflect.StructField) bool {\n\tjsonTag := field.Tag.Get(\"json\")\n\tif jsonTag != \"\" {\n\t\tparts := strings.Split(jsonTag, \",\")\n\t\tif len(parts) > 1 {\n\t\t\tfor _, part := range parts[1:] {\n\t\t\t\tif part == \"omitempty\" {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TypeToTSType(t reflect.Type, tsTypesMap map[reflect.Type]string) (string, []reflect.Type) {\n\tswitch t.Kind() {\n\tcase reflect.String:\n\t\treturn \"string\", nil\n\tcase reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,\n\t\treflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,\n\t\treflect.Float32, reflect.Float64:\n\t\treturn \"number\", nil\n\tcase reflect.Bool:\n\t\treturn \"boolean\", nil\n\tcase reflect.Slice, reflect.Array:\n\t\t// special case for byte slice, marshals to base64 encoded string\n\t\tif t.Elem().Kind() == reflect.Uint8 {\n\t\t\treturn \"string\", nil\n\t\t}\n\t\telemType, subTypes := TypeToTSType(t.Elem(), tsTypesMap)\n\t\tif elemType == \"\" {\n\t\t\treturn \"\", nil\n\t\t}\n\t\treturn fmt.Sprintf(\"%s[]\", elemType), subTypes\n\tcase reflect.Map:\n\t\tif t.Key().Kind() != reflect.String {\n\t\t\treturn \"\", nil\n\t\t}\n\t\tif t == metaRType {\n\t\t\treturn \"MetaType\", nil\n\t\t}\n\t\telemType, subTypes := TypeToTSType(t.Elem(), tsTypesMap)\n\t\tif elemType == \"\" {\n\t\t\treturn \"\", nil\n\t\t}\n\t\treturn fmt.Sprintf(\"{[key: string]: %s}\", elemType), subTypes\n\tcase reflect.Struct:\n\t\tname := t.Name()\n\t\tif tsRename := tsRenameMap[name]; tsRename != \"\" {\n\t\t\tname = tsRename\n\t\t}\n\t\treturn name, []reflect.Type{t}\n\tcase reflect.Ptr:\n\t\treturn TypeToTSType(t.Elem(), tsTypesMap)\n\tcase reflect.Interface:\n\t\tif _, ok := tsTypesMap[t]; ok {\n\t\t\treturn t.Name(), nil\n\t\t}\n\t\treturn \"any\", nil\n\tdefault:\n\t\treturn \"\", nil\n\t}\n}\n\nvar tsRenameMap = map[string]string{\n\t\"Window\":           \"WaveWindow\",\n\t\"Elem\":             \"VDomElem\",\n\t\"MetaTSType\":       \"MetaType\",\n\t\"MetaSettingsType\": \"SettingsType\",\n}\n\nfunc generateTSTypeInternal(rtype reflect.Type, tsTypesMap map[reflect.Type]string, embedded bool) (string, []reflect.Type) {\n\tvar buf bytes.Buffer\n\ttsTypeName := rtype.Name()\n\tif tsRename, ok := tsRenameMap[tsTypeName]; ok {\n\t\ttsTypeName = tsRename\n\t}\n\tvar isWaveObj bool\n\tif !embedded {\n\t\tif rtype.Implements(waveObjRType) || reflect.PointerTo(rtype).Implements(waveObjRType) {\n\t\t\tisWaveObj = true\n\t\t}\n\t}\n\tvar fieldsBuf bytes.Buffer\n\tvar subTypes []reflect.Type\n\tfor i := 0; i < rtype.NumField(); i++ {\n\t\tfield := rtype.Field(i)\n\t\tif field.PkgPath != \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif field.Anonymous {\n\t\t\tembeddedBuf, embeddedTypes := generateTSTypeInternal(field.Type, tsTypesMap, true)\n\t\t\tfieldsBuf.WriteString(embeddedBuf)\n\t\t\tsubTypes = append(subTypes, embeddedTypes...)\n\t\t\tcontinue\n\t\t}\n\t\tfieldName := getTSFieldName(field)\n\t\tif fieldName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isWaveObj && (fieldName == waveobj.OTypeKeyName || fieldName == waveobj.OIDKeyName || fieldName == waveobj.VersionKeyName || fieldName == waveobj.MetaKeyName) {\n\t\t\tcontinue\n\t\t}\n\t\toptMarker := \"\"\n\t\tif isFieldOmitEmpty(field) {\n\t\t\toptMarker = \"?\"\n\t\t}\n\t\ttsTypeTag := field.Tag.Get(\"tstype\")\n\t\tif tsTypeTag != \"\" {\n\t\t\tif tsTypeTag == \"-\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfieldsBuf.WriteString(fmt.Sprintf(\"    %s%s: %s;\\n\", fieldName, optMarker, tsTypeTag))\n\t\t\tcontinue\n\t\t}\n\t\ttsType, fieldSubTypes := TypeToTSType(field.Type, tsTypesMap)\n\t\tif tsType == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsubTypes = append(subTypes, fieldSubTypes...)\n\t\tif tsType == \"UIContext\" {\n\t\t\toptMarker = \"?\"\n\t\t}\n\t\tfieldsBuf.WriteString(fmt.Sprintf(\"    %s%s: %s;\\n\", fieldName, optMarker, tsType))\n\t}\n\tif !embedded {\n\t\tbuf.WriteString(fmt.Sprintf(\"// %s\\n\", rtype.String()))\n\t\tif fieldsBuf.Len() == 0 && !isWaveObj {\n\t\t\t// empty struct - use \"object\" instead of \"{}\" to satisfy linter\n\t\t\tbuf.WriteString(fmt.Sprintf(\"type %s = object;\\n\", tsTypeName))\n\t\t} else if isWaveObj {\n\t\t\tbuf.WriteString(fmt.Sprintf(\"type %s = WaveObj & {\\n\", tsTypeName))\n\t\t\tbuf.Write(fieldsBuf.Bytes())\n\t\t\tbuf.WriteString(\"};\\n\")\n\t\t} else {\n\t\t\tbuf.WriteString(fmt.Sprintf(\"type %s = {\\n\", tsTypeName))\n\t\t\tbuf.Write(fieldsBuf.Bytes())\n\t\t\tbuf.WriteString(\"};\\n\")\n\t\t}\n\t} else {\n\t\tbuf.Write(fieldsBuf.Bytes())\n\t}\n\treturn buf.String(), subTypes\n}\n\nfunc GenerateWaveObjTSType() string {\n\tvar buf bytes.Buffer\n\tbuf.WriteString(\"// waveobj.WaveObj\\n\")\n\tbuf.WriteString(\"type WaveObj = {\\n\")\n\tbuf.WriteString(\"    otype: string;\\n\")\n\tbuf.WriteString(\"    oid: string;\\n\")\n\tbuf.WriteString(\"    version: number;\\n\")\n\tbuf.WriteString(\"    meta: MetaType;\\n\")\n\tbuf.WriteString(\"};\\n\")\n\treturn buf.String()\n}\n\nfunc GenerateTSTypeUnion(unionMeta tsgenmeta.TypeUnionMeta, tsTypeMap map[reflect.Type]string) {\n\trtn := generateTSTypeUnionInternal(unionMeta)\n\ttsTypeMap[unionMeta.BaseType] = rtn\n\tfor _, rtype := range unionMeta.Types {\n\t\tGenerateTSType(rtype, tsTypeMap)\n\t}\n}\n\nfunc generateTSTypeUnionInternal(unionMeta tsgenmeta.TypeUnionMeta) string {\n\tvar buf bytes.Buffer\n\tif unionMeta.Desc != \"\" {\n\t\tbuf.WriteString(fmt.Sprintf(\"// %s\\n\", unionMeta.Desc))\n\t}\n\tbuf.WriteString(fmt.Sprintf(\"type %s = {\\n\", unionMeta.BaseType.Name()))\n\tbuf.WriteString(fmt.Sprintf(\"    %s: string;\\n\", unionMeta.TypeFieldName))\n\tbuf.WriteString(\"} & ( \")\n\tfor idx, rtype := range unionMeta.Types {\n\t\tif idx > 0 {\n\t\t\tbuf.WriteString(\" | \")\n\t\t}\n\t\tbuf.WriteString(rtype.Name())\n\t}\n\tbuf.WriteString(\" );\\n\")\n\treturn buf.String()\n}\n\nfunc GenerateTSType(rtype reflect.Type, tsTypesMap map[reflect.Type]string) {\n\tif rtype == nil {\n\t\treturn\n\t}\n\tif rtype.Kind() == reflect.Chan {\n\t\trtype = rtype.Elem()\n\t}\n\tif rtype == contextRType || rtype == errorRType || rtype == anyRType {\n\t\treturn\n\t}\n\tif rtype.Kind() == reflect.Slice {\n\t\trtype = rtype.Elem()\n\t}\n\tif rtype.Kind() == reflect.Map {\n\t\trtype = rtype.Elem()\n\t}\n\tif rtype.Kind() == reflect.Ptr {\n\t\trtype = rtype.Elem()\n\t}\n\tif _, ok := tsTypesMap[rtype]; ok {\n\t\treturn\n\t}\n\tif rtype == orefRType {\n\t\ttsTypesMap[orefRType] = \"// waveobj.ORef\\ntype ORef = string;\\n\"\n\t\treturn\n\t}\n\tif rtype == waveObjRType {\n\t\ttsTypesMap[rtype] = GenerateWaveObjTSType()\n\t\treturn\n\t}\n\tif rtype == metaSettingsType {\n\t\treturn\n\t}\n\tif rtype.Kind() != reflect.Struct {\n\t\treturn\n\t}\n\ttsType, subTypes := generateTSTypeInternal(rtype, tsTypesMap, false)\n\ttsTypesMap[rtype] = tsType\n\tfor _, subType := range subTypes {\n\t\tGenerateTSType(subType, tsTypesMap)\n\t}\n}\n\nfunc hasUpdatesReturn(method reflect.Method) bool {\n\tfor idx := 0; idx < method.Type.NumOut(); idx++ {\n\t\toutType := method.Type.Out(idx)\n\t\tif outType == updatesRtnRType {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc GenerateMethodSignature(serviceName string, method reflect.Method, meta tsgenmeta.MethodMeta, isFirst bool, tsTypesMap map[reflect.Type]string) string {\n\tvar sb strings.Builder\n\tmayReturnUpdates := hasUpdatesReturn(method)\n\tif (meta.Desc != \"\" || meta.ReturnDesc != \"\" || mayReturnUpdates) && !isFirst {\n\t\tsb.WriteString(\"\\n\")\n\t}\n\tif meta.Desc != \"\" {\n\t\tsb.WriteString(fmt.Sprintf(\"    // %s\\n\", meta.Desc))\n\t}\n\tif mayReturnUpdates || meta.ReturnDesc != \"\" {\n\t\tif mayReturnUpdates && meta.ReturnDesc != \"\" {\n\t\t\tsb.WriteString(fmt.Sprintf(\"    // @returns %s (and object updates)\\n\", meta.ReturnDesc))\n\t\t} else if mayReturnUpdates {\n\t\t\tsb.WriteString(\"    // @returns object updates\\n\")\n\t\t} else {\n\t\t\tsb.WriteString(fmt.Sprintf(\"    // @returns %s\\n\", meta.ReturnDesc))\n\t\t}\n\t}\n\tsb.WriteString(\"    \")\n\tsb.WriteString(method.Name)\n\tsb.WriteString(\"(\")\n\twroteArg := false\n\t// skip first arg, which is the receiver\n\tfor idx := 1; idx < method.Type.NumIn(); idx++ {\n\t\tif wroteArg {\n\t\t\tsb.WriteString(\", \")\n\t\t}\n\t\tinType := method.Type.In(idx)\n\t\tif inType == contextRType || inType == uiContextRType {\n\t\t\tcontinue\n\t\t}\n\t\ttsTypeName, _ := TypeToTSType(inType, tsTypesMap)\n\t\tvar argName string\n\t\tif idx-1 < len(meta.ArgNames) {\n\t\t\targName = meta.ArgNames[idx-1] // subtract 1 for receiver\n\t\t} else {\n\t\t\targName = fmt.Sprintf(\"arg%d\", idx)\n\t\t}\n\t\tsb.WriteString(fmt.Sprintf(\"%s: %s\", argName, tsTypeName))\n\t\twroteArg = true\n\t}\n\tsb.WriteString(\"): \")\n\trtnTypes := []string{}\n\tfor idx := 0; idx < method.Type.NumOut(); idx++ {\n\t\toutType := method.Type.Out(idx)\n\t\tif outType == errorRType {\n\t\t\tcontinue\n\t\t}\n\t\tif outType == updatesRtnRType {\n\t\t\tcontinue\n\t\t}\n\t\ttsTypeName, _ := TypeToTSType(outType, tsTypesMap)\n\t\trtnTypes = append(rtnTypes, tsTypeName)\n\t}\n\tif len(rtnTypes) == 0 {\n\t\tsb.WriteString(\"Promise<void>\")\n\t} else if len(rtnTypes) == 1 {\n\t\tsb.WriteString(fmt.Sprintf(\"Promise<%s>\", rtnTypes[0]))\n\t} else {\n\t\tsb.WriteString(fmt.Sprintf(\"Promise<[%s]>\", strings.Join(rtnTypes, \", \")))\n\t}\n\tsb.WriteString(\" {\\n\")\n\treturn sb.String()\n}\n\nfunc GenerateMethodBody(serviceName string, method reflect.Method, meta tsgenmeta.MethodMeta) string {\n\treturn fmt.Sprintf(\"        return callBackendService(this?.waveEnv, %q, %q, Array.from(arguments))\\n\", serviceName, method.Name)\n}\n\nfunc GenerateServiceClass(serviceName string, serviceObj any, tsTypesMap map[reflect.Type]string) string {\n\tserviceType := reflect.TypeOf(serviceObj)\n\tvar sb strings.Builder\n\ttsServiceName := serviceType.Elem().Name()\n\tsb.WriteString(fmt.Sprintf(\"// %s (%s)\\n\", serviceType.Elem().String(), serviceName))\n\tsb.WriteString(\"export class \")\n\tsb.WriteString(tsServiceName + \"Type\")\n\tsb.WriteString(\" {\\n\")\n\tsb.WriteString(\"    waveEnv: WaveEnv;\\n\\n\")\n\tsb.WriteString(\"    constructor(waveEnv?: WaveEnv) {\\n\")\n\tsb.WriteString(\"        this.waveEnv = waveEnv;\\n\")\n\tsb.WriteString(\"    }\\n\\n\")\n\tisFirst := true\n\tfor midx := 0; midx < serviceType.NumMethod(); midx++ {\n\t\tmethod := serviceType.Method(midx)\n\t\tif strings.HasSuffix(method.Name, \"_Meta\") {\n\t\t\tcontinue\n\t\t}\n\t\tvar meta tsgenmeta.MethodMeta\n\t\tmetaMethod, found := serviceType.MethodByName(method.Name + \"_Meta\")\n\t\tif found {\n\t\t\tserviceObjVal := reflect.ValueOf(serviceObj)\n\t\t\tmetaVal := metaMethod.Func.Call([]reflect.Value{serviceObjVal})\n\t\t\tmeta = metaVal[0].Interface().(tsgenmeta.MethodMeta)\n\t\t}\n\t\tsb.WriteString(GenerateMethodSignature(serviceName, method, meta, isFirst, tsTypesMap))\n\t\tsb.WriteString(GenerateMethodBody(serviceName, method, meta))\n\t\tsb.WriteString(\"    }\\n\")\n\t\tisFirst = false\n\t}\n\tsb.WriteString(\"}\\n\\n\")\n\tsb.WriteString(fmt.Sprintf(\"export const %s = new %sType();\\n\", tsServiceName, tsServiceName))\n\treturn sb.String()\n}\n\nfunc GenerateWshClientApiMethod(methodDecl *wshrpc.WshRpcMethodDecl, tsTypesMap map[reflect.Type]string) string {\n\tif methodDecl.CommandType == wshrpc.RpcType_ResponseStream {\n\t\treturn generateWshClientApiMethod_ResponseStream(methodDecl, tsTypesMap)\n\t} else if methodDecl.CommandType == wshrpc.RpcType_Call {\n\t\treturn generateWshClientApiMethod_Call(methodDecl, tsTypesMap)\n\t} else {\n\t\tpanic(fmt.Sprintf(\"cannot generate wshserver commandtype %q\", methodDecl.CommandType))\n\t}\n}\n\nfunc generateWshClientApiMethod_ResponseStream(methodDecl *wshrpc.WshRpcMethodDecl, tsTypesMap map[reflect.Type]string) string {\n\tvar sb strings.Builder\n\tsb.WriteString(fmt.Sprintf(\"    // command %q [%s]\\n\", methodDecl.Command, methodDecl.CommandType))\n\trespType := \"any\"\n\tif methodDecl.DefaultResponseDataType != nil {\n\t\trespType, _ = TypeToTSType(methodDecl.DefaultResponseDataType, tsTypesMap)\n\t}\n\tmethodSigDataParams, dataName := getTsWshMethodDataParamsAndExpr(methodDecl, tsTypesMap)\n\tgenRespType := fmt.Sprintf(\"AsyncGenerator<%s, void, boolean>\", respType)\n\tif methodSigDataParams == \"\" {\n\t\tsb.WriteString(fmt.Sprintf(\"\t%s(client: WshClient, opts?: RpcOpts): %s {\\n\", methodDecl.MethodName, genRespType))\n\t} else {\n\t\tsb.WriteString(fmt.Sprintf(\"\t%s(client: WshClient, %s, opts?: RpcOpts): %s {\\n\", methodDecl.MethodName, methodSigDataParams, genRespType))\n\t}\n\tsb.WriteString(fmt.Sprintf(\"        if (this.mockClient) return this.mockClient.mockWshRpcStream(client, %q, %s, opts);\\n\", methodDecl.Command, dataName))\n\tsb.WriteString(fmt.Sprintf(\"        return client.wshRpcStream(%q, %s, opts);\\n\", methodDecl.Command, dataName))\n\tsb.WriteString(\"    }\\n\")\n\treturn sb.String()\n}\n\nfunc generateWshClientApiMethod_Call(methodDecl *wshrpc.WshRpcMethodDecl, tsTypesMap map[reflect.Type]string) string {\n\tvar sb strings.Builder\n\tsb.WriteString(fmt.Sprintf(\"    // command %q [%s]\\n\", methodDecl.Command, methodDecl.CommandType))\n\trtnType := \"Promise<void>\"\n\tif methodDecl.DefaultResponseDataType != nil {\n\t\trtnTypeName, _ := TypeToTSType(methodDecl.DefaultResponseDataType, tsTypesMap)\n\t\trtnType = fmt.Sprintf(\"Promise<%s>\", rtnTypeName)\n\t}\n\tmethodSigDataParams, dataName := getTsWshMethodDataParamsAndExpr(methodDecl, tsTypesMap)\n\tif methodSigDataParams == \"\" {\n\t\tsb.WriteString(fmt.Sprintf(\"    %s(client: WshClient, opts?: RpcOpts): %s {\\n\", methodDecl.MethodName, rtnType))\n\t} else {\n\t\tsb.WriteString(fmt.Sprintf(\"    %s(client: WshClient, %s, opts?: RpcOpts): %s {\\n\", methodDecl.MethodName, methodSigDataParams, rtnType))\n\t}\n\tsb.WriteString(fmt.Sprintf(\"        if (this.mockClient) return this.mockClient.mockWshRpcCall(client, %q, %s, opts);\\n\", methodDecl.Command, dataName))\n\tsb.WriteString(fmt.Sprintf(\"        return client.wshRpcCall(%q, %s, opts);\\n\", methodDecl.Command, dataName))\n\tsb.WriteString(\"    }\\n\")\n\treturn sb.String()\n}\n\nfunc getTsWshMethodDataParamsAndExpr(methodDecl *wshrpc.WshRpcMethodDecl, tsTypesMap map[reflect.Type]string) (string, string) {\n\tdataTypes := methodDecl.GetCommandDataTypes()\n\tif len(dataTypes) == 0 {\n\t\treturn \"\", \"null\"\n\t}\n\tif len(dataTypes) == 1 {\n\t\tcmdDataTsName, _ := TypeToTSType(dataTypes[0], tsTypesMap)\n\t\treturn fmt.Sprintf(\"data: %s\", cmdDataTsName), \"data\"\n\t}\n\tvar methodParamBuilder strings.Builder\n\tvar argBuilder strings.Builder\n\tfor idx, dataType := range dataTypes {\n\t\tif idx > 0 {\n\t\t\tmethodParamBuilder.WriteString(\", \")\n\t\t\targBuilder.WriteString(\", \")\n\t\t}\n\t\targName := fmt.Sprintf(\"arg%d\", idx+1)\n\t\tcmdDataTsName, _ := TypeToTSType(dataType, tsTypesMap)\n\t\tmethodParamBuilder.WriteString(fmt.Sprintf(\"%s: %s\", argName, cmdDataTsName))\n\t\targBuilder.WriteString(argName)\n\t}\n\treturn methodParamBuilder.String(), fmt.Sprintf(\"{ args: [%s] }\", argBuilder.String())\n}\n\nfunc GenerateWaveObjTypes(tsTypesMap map[reflect.Type]string) {\n\tfor _, typeUnion := range TypeUnions {\n\t\tGenerateTSTypeUnion(typeUnion, tsTypesMap)\n\t}\n\tfor _, extraType := range ExtraTypes {\n\t\tGenerateTSType(reflect.TypeOf(extraType), tsTypesMap)\n\t}\n\tfor _, rtype := range waveobj.AllWaveObjTypes() {\n\t\tif rtype.String() == \"*waveobj.MainServer\" {\n\t\t\tcontinue\n\t\t}\n\t\tGenerateTSType(rtype, tsTypesMap)\n\t}\n}\n\nfunc GenerateServiceTypes(tsTypesMap map[reflect.Type]string) error {\n\tfor _, serviceObj := range service.ServiceMap {\n\t\tserviceType := reflect.TypeOf(serviceObj)\n\t\tfor midx := 0; midx < serviceType.NumMethod(); midx++ {\n\t\t\tmethod := serviceType.Method(midx)\n\t\t\terr := generateTSMethodTypes(method, tsTypesMap, true)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error generating TS method types for %s.%s: %v\", serviceType, method.Name, err)\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc GenerateWshServerTypes(tsTypesMap map[reflect.Type]string) error {\n\tGenerateTSType(reflect.TypeOf(wshrpc.RpcOpts{}), tsTypesMap)\n\trtype := wshRpcInterfaceRType\n\tfor midx := 0; midx < rtype.NumMethod(); midx++ {\n\t\tmethod := rtype.Method(midx)\n\t\terr := generateTSMethodTypes(method, tsTypesMap, false)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error generating TS method types for %s.%s: %v\", rtype, method.Name, err)\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/tsgen/tsgen_wshclientapi_test.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage tsgen\n\nimport (\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nfunc TestGenerateWshClientApiMethodCall_MultiArg(t *testing.T) {\n\tmethodDecl := &wshrpc.WshRpcMethodDecl{\n\t\tCommand:          \"test\",\n\t\tCommandType:      wshrpc.RpcType_Call,\n\t\tMethodName:       \"TestCommand\",\n\t\tCommandDataTypes: []reflect.Type{reflect.TypeOf(\"\"), reflect.TypeOf(0)},\n\t}\n\tout := GenerateWshClientApiMethod(methodDecl, map[reflect.Type]string{})\n\tif !strings.Contains(out, \"TestCommand(client: WshClient, arg1: string, arg2: number, opts?: RpcOpts): Promise<void> {\") {\n\t\tt.Fatalf(\"generated method missing multi-arg signature:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"return client.wshRpcCall(\\\"test\\\", { args: [arg1, arg2] }, opts);\") {\n\t\tt.Fatalf(\"generated method missing MultiArg payload:\\n%s\", out)\n\t}\n}\n"
  },
  {
    "path": "pkg/tsgen/tsgenevent.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage tsgen\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"strconv\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/blockcontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/userinput\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nvar waveEventRType = reflect.TypeOf(wps.WaveEvent{})\n\nvar WaveEventDataTypes = map[string]reflect.Type{\n\twps.Event_BlockClose:          reflect.TypeOf(\"\"),\n\twps.Event_ConnChange:          reflect.TypeOf(wshrpc.ConnStatus{}),\n\twps.Event_SysInfo:             reflect.TypeOf(wshrpc.TimeSeriesData{}),\n\twps.Event_ControllerStatus:    reflect.TypeOf((*blockcontroller.BlockControllerRuntimeStatus)(nil)),\n\twps.Event_BuilderStatus:       reflect.TypeOf(wshrpc.BuilderStatusData{}),\n\twps.Event_BuilderOutput:       reflect.TypeOf(map[string]any{}),\n\twps.Event_WaveObjUpdate:       reflect.TypeOf(waveobj.WaveObjUpdate{}),\n\twps.Event_BlockFile:           reflect.TypeOf((*wps.WSFileEventData)(nil)),\n\twps.Event_Config:              reflect.TypeOf(wconfig.WatcherUpdate{}),\n\twps.Event_UserInput:           reflect.TypeOf((*userinput.UserInputRequest)(nil)),\n\twps.Event_RouteDown:           nil,\n\twps.Event_RouteUp:             nil,\n\twps.Event_WorkspaceUpdate:     nil,\n\twps.Event_WaveAIRateLimit:     reflect.TypeOf((*uctypes.RateLimitInfo)(nil)),\n\twps.Event_WaveAppAppGoUpdated: nil,\n\twps.Event_TsunamiUpdateMeta:   reflect.TypeOf(wshrpc.AppMeta{}),\n\twps.Event_AIModeConfig:        reflect.TypeOf(wconfig.AIModeConfigUpdate{}),\n\twps.Event_BlockJobStatus:      reflect.TypeOf(wshrpc.BlockJobStatusData{}),\n\twps.Event_Badge:               reflect.TypeOf(baseds.BadgeEvent{}),\n}\n\nfunc getWaveEventDataTSType(eventName string, tsTypesMap map[reflect.Type]string) string {\n\trtype, found := WaveEventDataTypes[eventName]\n\tif !found {\n\t\treturn \"any\"\n\t}\n\tif rtype == nil {\n\t\treturn \"null\"\n\t}\n\ttsType, _ := TypeToTSType(rtype, tsTypesMap)\n\tif tsType == \"\" {\n\t\treturn \"any\"\n\t}\n\treturn tsType\n}\n\nfunc GenerateWaveEventTypes(tsTypesMap map[reflect.Type]string) string {\n\tfor _, rtype := range WaveEventDataTypes {\n\t\tGenerateTSType(rtype, tsTypesMap)\n\t}\n\t// suppress default struct generation, this type is custom generated\n\ttsTypesMap[waveEventRType] = \"\"\n\n\tvar buf bytes.Buffer\n\tbuf.WriteString(\"// wps.WaveEvent\\n\")\n\tbuf.WriteString(\"type WaveEventName =\\n\")\n\tfor _, eventName := range wps.AllEvents {\n\t\tbuf.WriteString(fmt.Sprintf(\"    | %s\\n\", strconv.Quote(eventName)))\n\t}\n\tbuf.WriteString(\";\\n\\n\")\n\tbuf.WriteString(\"type WaveEvent = {\\n\")\n\tbuf.WriteString(\"    event: WaveEventName;\\n\")\n\tbuf.WriteString(\"    scopes?: string[];\\n\")\n\tbuf.WriteString(\"    sender?: string;\\n\")\n\tbuf.WriteString(\"    persist?: number;\\n\")\n\tbuf.WriteString(\"    data?: unknown;\\n\")\n\tbuf.WriteString(\"} & (\\n\")\n\tfor idx, eventName := range wps.AllEvents {\n\t\tif idx > 0 {\n\t\t\tbuf.WriteString(\" | \\n\")\n\t\t}\n\t\tbuf.WriteString(fmt.Sprintf(\"    { event: %s; data?: %s; }\", strconv.Quote(eventName), getWaveEventDataTSType(eventName, tsTypesMap)))\n\t}\n\tbuf.WriteString(\"\\n);\\n\")\n\treturn buf.String()\n}\n"
  },
  {
    "path": "pkg/tsgen/tsgenevent_test.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage tsgen\n\nimport (\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nfunc TestGenerateWaveEventTypes(t *testing.T) {\n\ttsTypesMap := make(map[reflect.Type]string)\n\twaveEventTypeDecl := GenerateWaveEventTypes(tsTypesMap)\n\n\tif !strings.Contains(waveEventTypeDecl, `type WaveEventName = \"blockclose\"`) {\n\t\tt.Fatalf(\"expected WaveEventName declaration, got:\\n%s\", waveEventTypeDecl)\n\t}\n\tif !strings.Contains(waveEventTypeDecl, `{ event: \"block:jobstatus\"; data?: BlockJobStatusData; }`) {\n\t\tt.Fatalf(\"expected typed block:jobstatus event, got:\\n%s\", waveEventTypeDecl)\n\t}\n\tif !strings.Contains(waveEventTypeDecl, `{ event: \"route:up\"; data?: null; }`) {\n\t\tt.Fatalf(\"expected null for known no-data event, got:\\n%s\", waveEventTypeDecl)\n\t}\n\tif got := getWaveEventDataTSType(\"unmapped:event\", tsTypesMap); got != \"any\" {\n\t\tt.Fatalf(\"expected any for unmapped event fallback, got: %q\", got)\n\t}\n\tif _, found := tsTypesMap[reflect.TypeOf(wps.WaveEvent{})]; !found {\n\t\tt.Fatalf(\"expected WaveEvent type to be seeded in tsTypesMap\")\n\t}\n\tif _, found := tsTypesMap[reflect.TypeOf(wshrpc.BlockJobStatusData{})]; !found {\n\t\tt.Fatalf(\"expected mapped data types to be generated into tsTypesMap\")\n\t}\n}\n"
  },
  {
    "path": "pkg/tsgen/tsgenmeta/tsgenmeta.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage tsgenmeta\n\nimport \"reflect\"\n\ntype MethodMeta struct {\n\tDesc       string\n\tArgNames   []string\n\tReturnDesc string\n}\n\ntype TypeUnionMeta struct {\n\tBaseType      reflect.Type\n\tDesc          string\n\tTypeFieldName string\n\tTypes         []reflect.Type\n}\n"
  },
  {
    "path": "pkg/tsunamiutil/tsunamiutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage tsunamiutil\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\nconst DevModeCorsOrigins = \"http://localhost:5173,http://localhost:5174\"\n\nfunc GetTsunamiAppCachePath(scope string, appName string, osArch string) (string, error) {\n\tcachesDir := wavebase.GetWaveCachesDir()\n\ttsunamiCacheDir := filepath.Join(cachesDir, \"tsunami-build-cache\")\n\tfullAppName := appName + \".\" + osArch\n\tif strings.HasPrefix(osArch, \"windows\") {\n\t\tfullAppName = fullAppName + \".exe\"\n\t}\n\tfullPath := filepath.Join(tsunamiCacheDir, scope, fullAppName)\n\n\tdirPath := filepath.Dir(fullPath)\n\terr := wavebase.TryMkdirs(dirPath, 0755, \"tsunami cache directory\")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create tsunami cache directory: %w\", err)\n\t}\n\n\treturn fullPath, nil\n}"
  },
  {
    "path": "pkg/userinput/userinput.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage userinput\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/blocklogger\"\n\t\"github.com/wavetermdev/waveterm/pkg/genconn\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nvar MainUserInputHandler = UserInputHandler{Channels: make(map[string](chan *UserInputResponse), 1)}\n\nvar defaultProvider UserInputProvider = &FrontendProvider{}\n\ntype UserInputProvider interface {\n\tGetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputResponse, error)\n}\n\ntype UserInputRequest struct {\n\tRequestId    string `json:\"requestid\"`\n\tQueryText    string `json:\"querytext\"`\n\tResponseType string `json:\"responsetype\"`\n\tTitle        string `json:\"title\"`\n\tMarkdown     bool   `json:\"markdown\"`\n\tTimeoutMs    int    `json:\"timeoutms\"`\n\tCheckBoxMsg  string `json:\"checkboxmsg\"`\n\tPublicText   bool   `json:\"publictext\"`\n\tOkLabel      string `json:\"oklabel,omitempty\"`\n\tCancelLabel  string `json:\"cancellabel,omitempty\"`\n}\n\ntype UserInputResponse struct {\n\tType         string `json:\"type\"`\n\tRequestId    string `json:\"requestid\"`\n\tText         string `json:\"text,omitempty\"`\n\tConfirm      bool   `json:\"confirm,omitempty\"`\n\tErrorMsg     string `json:\"errormsg,omitempty\"`\n\tCheckboxStat bool   `json:\"checkboxstat,omitempty\"`\n}\n\ntype UserInputHandler struct {\n\tLock     sync.Mutex\n\tChannels map[string](chan *UserInputResponse)\n}\n\ntype FrontendProvider struct{}\n\nfunc (ui *UserInputHandler) registerChannel() (string, chan *UserInputResponse) {\n\tui.Lock.Lock()\n\tdefer ui.Lock.Unlock()\n\n\tid := uuid.New().String()\n\tuich := make(chan *UserInputResponse, 1)\n\n\tui.Channels[id] = uich\n\treturn id, uich\n}\n\nfunc (ui *UserInputHandler) unregisterChannel(id string) {\n\tui.Lock.Lock()\n\tdefer ui.Lock.Unlock()\n\n\tdelete(ui.Channels, id)\n}\n\nfunc (ui *UserInputHandler) sendRequestToFrontend(request *UserInputRequest, scopes []string) {\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent:  wps.Event_UserInput,\n\t\tData:   request,\n\t\tScopes: scopes,\n\t})\n}\n\nfunc determineScopes(ctx context.Context) ([]string, error) {\n\tconnData := genconn.GetConnData(ctx)\n\tif connData == nil {\n\t\treturn nil, fmt.Errorf(\"context did not contain connection info\")\n\t}\n\t// resolve windowId from blockId\n\ttabId, err := wstore.DBFindTabForBlockId(ctx, connData.BlockId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unabled to determine tab for route: %w\", err)\n\t}\n\tworkspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, tabId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unabled to determine workspace for route: %w\", err)\n\t}\n\twindowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unabled to determine window for route: %w\", err)\n\t}\n\n\treturn []string{windowId}, nil\n}\n\nfunc (p *FrontendProvider) GetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputResponse, error) {\n\tid, uiCh := MainUserInputHandler.registerChannel()\n\tdefer MainUserInputHandler.unregisterChannel(id)\n\trequest.RequestId = id\n\trequest.TimeoutMs = int(utilfn.TimeoutFromContext(ctx, 30*time.Second).Milliseconds())\n\n\tscopes, scopesErr := determineScopes(ctx)\n\tif scopesErr != nil {\n\t\tlog.Printf(\"user input scopes could not be found: %v\", scopesErr)\n\t\tblocklogger.Infof(ctx, \"user input scopes could not be found: %v\", scopesErr)\n\t\tallWindows, err := wstore.DBGetAllOIDsByType(ctx, \"window\")\n\t\tif err != nil {\n\t\t\tblocklogger.Infof(ctx, \"unable to find windows for user input: %v\", err)\n\t\t\treturn nil, fmt.Errorf(\"unable to find windows for user input: %v\", err)\n\t\t}\n\t\tscopes = allWindows\n\t}\n\n\tMainUserInputHandler.sendRequestToFrontend(request, scopes)\n\n\tvar response *UserInputResponse\n\tvar err error\n\tselect {\n\tcase resp := <-uiCh:\n\t\tlog.Printf(\"checking received: %v\", resp.RequestId)\n\t\tresponse = resp\n\tcase <-ctx.Done():\n\t\treturn nil, fmt.Errorf(\"timed out waiting for user input\")\n\t}\n\n\tif response.ErrorMsg != \"\" {\n\t\terr = errors.New(response.ErrorMsg)\n\t}\n\n\treturn response, err\n}\n\nfunc GetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputResponse, error) {\n\treturn defaultProvider.GetUserInput(ctx, request)\n}\n\nfunc SetUserInputProvider(provider UserInputProvider) {\n\tdefaultProvider = provider\n}\n"
  },
  {
    "path": "pkg/util/daystr/daystr.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage daystr\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n)\n\nvar customDayStrRe = regexp.MustCompile(`^((?:\\d{4}-\\d{2}-\\d{2})|today|yesterday|bom|bow)?((?:[+-]\\d+[dwm])*)$`)\nvar daystrRe = regexp.MustCompile(`^(\\d{4})-(\\d{2})-(\\d{2})$`)\n\nfunc GetCurDayStr() string {\n\tnow := time.Now()\n\tdayStr := now.Format(\"2006-01-02\")\n\treturn dayStr\n}\n\nfunc GetRelDayStr(relDays int) string {\n\tnow := time.Now()\n\tdayStr := now.AddDate(0, 0, relDays).Format(\"2006-01-02\")\n\treturn dayStr\n}\n\n// accepts a custom format string to return a daystr\n// can be either a prefix, a delta, or a prefix w/ a delta\n// if no prefix is given, \"today\" is assumed\n// examples: today-2d, bow, bom+1m-1d (that's end of the month), 2025-04-01+1w\n//\n// prefixes:\n//\n//\tyyyy-mm-dd\n//\ttoday\n//\tyesterday\n//\tbom (beginning of month)\n//\tbow (beginning of week -- sunday)\n//\n// deltas:\n//\n//\t+[n]d, -[n]d (e.g. +1d, -5d)\n//\t+[n]w, -[n]w (e.g. +2w)\n//\t+[n]m, -[n]m (e.g. -1m)\n//\tdeltas can be combined e.g. +1w-2d\nfunc GetCustomDayStr(format string) (string, error) {\n\tm := customDayStrRe.FindStringSubmatch(format)\n\tif m == nil {\n\t\treturn \"\", fmt.Errorf(\"invalid daystr format\")\n\t}\n\tprefix, deltas := m[1], m[2]\n\tif prefix == \"\" {\n\t\tprefix = \"today\"\n\t}\n\tvar rtnTime time.Time\n\tnow := time.Now()\n\tswitch prefix {\n\tcase \"today\":\n\t\trtnTime = now\n\tcase \"yesterday\":\n\t\trtnTime = now.AddDate(0, 0, -1)\n\tcase \"bom\":\n\t\trtnTime = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())\n\tcase \"bow\":\n\t\tweekday := now.Weekday()\n\t\tif weekday == time.Sunday {\n\t\t\trtnTime = now\n\t\t} else {\n\t\t\trtnTime = now.AddDate(0, 0, -int(weekday))\n\t\t}\n\tdefault:\n\t\tm = daystrRe.FindStringSubmatch(prefix)\n\t\tif m == nil {\n\t\t\treturn \"\", fmt.Errorf(\"invalid prefix format\")\n\t\t}\n\t\tyear, month, day := m[1], m[2], m[3]\n\t\tyearInt, monthInt, dayInt := utilfn.AtoiNoErr(year), utilfn.AtoiNoErr(month), utilfn.AtoiNoErr(day)\n\t\tif yearInt == 0 || monthInt == 0 || dayInt == 0 {\n\t\t\treturn \"\", fmt.Errorf(\"invalid prefix format\")\n\t\t}\n\t\trtnTime = time.Date(yearInt, time.Month(monthInt), dayInt, 0, 0, 0, 0, now.Location())\n\t}\n\tfor _, delta := range regexp.MustCompile(`[+-]\\d+[dwm]`).FindAllString(deltas, -1) {\n\t\tdeltaVal, err := strconv.Atoi(delta[1 : len(delta)-1])\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"invalid delta format\")\n\t\t}\n\t\tif delta[0] == '-' {\n\t\t\tdeltaVal = -deltaVal\n\t\t}\n\t\tswitch delta[len(delta)-1] {\n\t\tcase 'd':\n\t\t\trtnTime = rtnTime.AddDate(0, 0, deltaVal)\n\t\tcase 'w':\n\t\t\trtnTime = rtnTime.AddDate(0, 0, deltaVal*7)\n\t\tcase 'm':\n\t\t\trtnTime = rtnTime.AddDate(0, deltaVal, 0)\n\t\t}\n\t}\n\treturn rtnTime.Format(\"2006-01-02\"), nil\n}\n"
  },
  {
    "path": "pkg/util/daystr/daystr_test.go",
    "content": "package daystr\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestGetCurDayStr(t *testing.T) {\n\texpected := time.Now().Format(\"2006-01-02\")\n\tresult := GetCurDayStr()\n\tif result != expected {\n\t\tt.Errorf(\"GetCurDayStr() = %v; want %v\", result, expected)\n\t}\n}\n\nfunc TestGetRelDayStr(t *testing.T) {\n\texpected := time.Now().AddDate(0, 0, 5).Format(\"2006-01-02\")\n\tresult := GetRelDayStr(5)\n\tif result != expected {\n\t\tt.Errorf(\"GetRelDayStr(5) = %v; want %v\", result, expected)\n\t}\n\n\texpected = time.Now().AddDate(0, 0, -5).Format(\"2006-01-02\")\n\tresult = GetRelDayStr(-5)\n\tif result != expected {\n\t\tt.Errorf(\"GetRelDayStr(-5) = %v; want %v\", result, expected)\n\t}\n}\n\nfunc TestGetCustomDayStr(t *testing.T) {\n\ttests := []struct {\n\t\tformat   string\n\t\texpected string\n\t}{\n\t\t{\"today\", time.Now().Format(\"2006-01-02\")},\n\t\t{\"yesterday\", time.Now().AddDate(0, 0, -1).Format(\"2006-01-02\")},\n\t\t{\"bom\", time.Date(time.Now().Year(), time.Now().Month(), 1, 0, 0, 0, 0, time.Now().Location()).Format(\"2006-01-02\")},\n\t\t{\"bow\", time.Now().AddDate(0, 0, -int(time.Now().Weekday())).Format(\"2006-01-02\")},\n\t\t{\"2025-04-01\", \"2025-04-01\"},\n\t\t{\"2025-04-01+1w\", \"2025-04-08\"},\n\t\t{\"2025-04-01+1w-1d\", \"2025-04-07\"},\n\t\t{\"2025-04-01+1m\", \"2025-05-01\"},\n\t\t{\"2025-04-01+1m-1d\", \"2025-04-30\"},\n\t}\n\n\tfor _, test := range tests {\n\t\tresult, err := GetCustomDayStr(test.format)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"GetCustomDayStr(%v) returned error: %v\", test.format, err)\n\t\t}\n\t\tif result != test.expected {\n\t\t\tt.Errorf(\"GetCustomDayStr(%v) = %v; want %v\", test.format, result, test.expected)\n\t\t}\n\t}\n\n\tinvalidTests := []string{\n\t\t\"invalid\",\n\t\t\"2025-04-01+1x\",\n\t\t\"2025-04-01+1m-1x\",\n\t}\n\n\tfor _, test := range invalidTests {\n\t\t_, err := GetCustomDayStr(test)\n\t\tif err == nil {\n\t\t\tt.Errorf(\"GetCustomDayStr(%v) expected error, got nil\", test)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/util/dbutil/dbmappable.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage dbutil\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/sawka/txwrap\"\n)\n\ntype DBMappable interface {\n\tUseDBMap()\n}\n\ntype MapEntry[T any] struct {\n\tKey string\n\tVal T\n}\n\ntype MapConverter interface {\n\tToMap() map[string]interface{}\n\tFromMap(map[string]interface{}) bool\n}\n\ntype HasSimpleKey interface {\n\tGetSimpleKey() string\n}\n\ntype HasSimpleInt64Key interface {\n\tGetSimpleKey() int64\n}\n\ntype MapConverterPtr[T any] interface {\n\tMapConverter\n\t*T\n}\n\ntype DBMappablePtr[T any] interface {\n\tDBMappable\n\t*T\n}\n\nfunc FromMap[PT MapConverterPtr[T], T any](m map[string]any) PT {\n\tif len(m) == 0 {\n\t\treturn nil\n\t}\n\trtn := PT(new(T))\n\tok := rtn.FromMap(m)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn rtn\n}\n\nfunc GetMapGen[PT MapConverterPtr[T], T any](tx *txwrap.TxWrap, query string, args ...interface{}) PT {\n\tm := tx.GetMap(query, args...)\n\treturn FromMap[PT](m)\n}\n\nfunc GetMappable[PT DBMappablePtr[T], T any](tx *txwrap.TxWrap, query string, args ...interface{}) PT {\n\tm := tx.GetMap(query, args...)\n\tif len(m) == 0 {\n\t\treturn nil\n\t}\n\trtn := PT(new(T))\n\tFromDBMap(rtn, m)\n\treturn rtn\n}\n\nfunc SelectMappable[PT DBMappablePtr[T], T any](tx *txwrap.TxWrap, query string, args ...interface{}) []PT {\n\tvar rtn []PT\n\tmarr := tx.SelectMaps(query, args...)\n\tfor _, m := range marr {\n\t\tif len(m) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tval := PT(new(T))\n\t\tFromDBMap(val, m)\n\t\trtn = append(rtn, val)\n\t}\n\treturn rtn\n}\n\nfunc SelectMapsGen[PT MapConverterPtr[T], T any](tx *txwrap.TxWrap, query string, args ...interface{}) []PT {\n\tvar rtn []PT\n\tmarr := tx.SelectMaps(query, args...)\n\tfor _, m := range marr {\n\t\tval := FromMap[PT](m)\n\t\tif val != nil {\n\t\t\trtn = append(rtn, val)\n\t\t}\n\t}\n\treturn rtn\n}\n\nfunc SelectSimpleMap[T any](tx *txwrap.TxWrap, query string, args ...interface{}) map[string]T {\n\tvar rtn []MapEntry[T]\n\ttx.Select(&rtn, query, args...)\n\tif len(rtn) == 0 {\n\t\treturn nil\n\t}\n\trtnMap := make(map[string]T)\n\tfor _, entry := range rtn {\n\t\trtnMap[entry.Key] = entry.Val\n\t}\n\treturn rtnMap\n}\n\nfunc MakeGenMap[T HasSimpleKey](arr []T) map[string]T {\n\trtn := make(map[string]T)\n\tfor _, val := range arr {\n\t\trtn[val.GetSimpleKey()] = val\n\t}\n\treturn rtn\n}\n\nfunc MakeGenMapInt64[T HasSimpleInt64Key](arr []T) map[int64]T {\n\trtn := make(map[int64]T)\n\tfor _, val := range arr {\n\t\trtn[val.GetSimpleKey()] = val\n\t}\n\treturn rtn\n}\n\nfunc isStructType(rt reflect.Type) bool {\n\tif rt.Kind() == reflect.Struct {\n\t\treturn true\n\t}\n\tif rt.Kind() == reflect.Pointer && rt.Elem().Kind() == reflect.Struct {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isByteArrayType(t reflect.Type) bool {\n\treturn t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8\n}\n\nfunc isStringMapType(t reflect.Type) bool {\n\treturn t.Kind() == reflect.Map && t.Key().Kind() == reflect.String\n}\n\nfunc ToDBMap(v DBMappable, useBytes bool) map[string]interface{} {\n\tif CheckNil(v) {\n\t\treturn nil\n\t}\n\trv := reflect.ValueOf(v)\n\tif rv.Kind() == reflect.Pointer {\n\t\trv = rv.Elem()\n\t}\n\tif rv.Kind() != reflect.Struct {\n\t\tpanic(fmt.Sprintf(\"invalid type %T (non-struct) passed to StructToDBMap\", v))\n\t}\n\trt := rv.Type()\n\tm := make(map[string]interface{})\n\tnumFields := rt.NumField()\n\tfor i := 0; i < numFields; i++ {\n\t\tfield := rt.Field(i)\n\t\tfieldVal := rv.FieldByIndex(field.Index)\n\t\tdbName := field.Tag.Get(\"dbmap\")\n\t\tif dbName == \"\" {\n\t\t\tdbName = strings.ToLower(field.Name)\n\t\t}\n\t\tif dbName == \"-\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isByteArrayType(field.Type) {\n\t\t\tm[dbName] = fieldVal.Interface()\n\t\t} else if field.Type.Kind() == reflect.Slice {\n\t\t\tif useBytes {\n\t\t\t\tm[dbName] = QuickJsonArrBytes(fieldVal.Interface())\n\t\t\t} else {\n\t\t\t\tm[dbName] = QuickJsonArr(fieldVal.Interface())\n\t\t\t}\n\t\t} else if isStructType(field.Type) || isStringMapType(field.Type) {\n\t\t\tif useBytes {\n\t\t\t\tm[dbName] = QuickJsonBytes(fieldVal.Interface())\n\t\t\t} else {\n\t\t\t\tm[dbName] = QuickJson(fieldVal.Interface())\n\t\t\t}\n\t\t} else {\n\t\t\tm[dbName] = fieldVal.Interface()\n\t\t}\n\t}\n\treturn m\n}\n\nfunc FromDBMap(v DBMappable, m map[string]interface{}) {\n\tif CheckNil(v) {\n\t\tpanic(\"StructFromDBMap, v cannot be nil\")\n\t}\n\trv := reflect.ValueOf(v)\n\tif rv.Kind() == reflect.Pointer {\n\t\trv = rv.Elem()\n\t}\n\tif rv.Kind() != reflect.Struct {\n\t\tpanic(fmt.Sprintf(\"invalid type %T (non-struct) passed to StructFromDBMap\", v))\n\t}\n\trt := rv.Type()\n\tnumFields := rt.NumField()\n\tfor i := 0; i < numFields; i++ {\n\t\tfield := rt.Field(i)\n\t\tfieldVal := rv.FieldByIndex(field.Index)\n\t\tdbName := field.Tag.Get(\"dbmap\")\n\t\tif dbName == \"\" {\n\t\t\tdbName = strings.ToLower(field.Name)\n\t\t}\n\t\tif dbName == \"-\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isByteArrayType(field.Type) {\n\t\t\tbarrVal := fieldVal.Addr().Interface()\n\t\t\tQuickSetBytes(barrVal.(*[]byte), m, dbName)\n\t\t} else if field.Type.Kind() == reflect.Slice {\n\t\t\tQuickSetJsonArr(fieldVal.Addr().Interface(), m, dbName)\n\t\t} else if isStructType(field.Type) || isStringMapType(field.Type) {\n\t\t\tQuickSetJson(fieldVal.Addr().Interface(), m, dbName)\n\t\t} else if field.Type.Kind() == reflect.String {\n\t\t\tstrVal := fieldVal.Addr().Interface()\n\t\t\tQuickSetStr(strVal.(*string), m, dbName)\n\t\t} else if field.Type.Kind() == reflect.Int64 {\n\t\t\tintVal := fieldVal.Addr().Interface()\n\t\t\tQuickSetInt64(intVal.(*int64), m, dbName)\n\t\t} else if field.Type.Kind() == reflect.Int {\n\t\t\tintVal := fieldVal.Addr().Interface()\n\t\t\tQuickSetInt(intVal.(*int), m, dbName)\n\t\t} else if field.Type.Kind() == reflect.Bool {\n\t\t\tboolVal := fieldVal.Addr().Interface()\n\t\t\tQuickSetBool(boolVal.(*bool), m, dbName)\n\t\t} else {\n\t\t\tpanic(fmt.Sprintf(\"StructFromDBMap invalid field type %v in %T\", fieldVal.Type(), v))\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/util/dbutil/dbutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage dbutil\n\nimport (\n\t\"database/sql/driver\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"strconv\"\n)\n\nfunc QuickSetStr(strVal *string, m map[string]any, name string) {\n\tv, ok := m[name]\n\tif !ok {\n\t\treturn\n\t}\n\tival, ok := v.(int64)\n\tif ok {\n\t\t*strVal = strconv.FormatInt(ival, 10)\n\t\treturn\n\t}\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn\n\t}\n\t*strVal = str\n}\n\nfunc QuickSetInt(ival *int, m map[string]any, name string) {\n\tv, ok := m[name]\n\tif !ok {\n\t\treturn\n\t}\n\tsqlInt, ok := v.(int)\n\tif ok {\n\t\t*ival = sqlInt\n\t\treturn\n\t}\n\tsqlInt64, ok := v.(int64)\n\tif ok {\n\t\t*ival = int(sqlInt64)\n\t\treturn\n\t}\n}\n\nfunc QuickSetNullableInt64(ival **int64, m map[string]any, name string) {\n\tv, ok := m[name]\n\tif !ok {\n\t\t// set to nil\n\t\treturn\n\t}\n\tsqlInt64, ok := v.(int64)\n\tif ok {\n\t\t*ival = &sqlInt64\n\t\treturn\n\t}\n\tsqlInt, ok := v.(int)\n\tif ok {\n\t\tsqlInt64 = int64(sqlInt)\n\t\t*ival = &sqlInt64\n\t\treturn\n\t}\n}\n\nfunc QuickSetInt64(ival *int64, m map[string]any, name string) {\n\tv, ok := m[name]\n\tif !ok {\n\t\t// leave as zero\n\t\treturn\n\t}\n\tsqlInt64, ok := v.(int64)\n\tif ok {\n\t\t*ival = sqlInt64\n\t\treturn\n\t}\n\tsqlInt, ok := v.(int)\n\tif ok {\n\t\t*ival = int64(sqlInt)\n\t\treturn\n\t}\n}\n\nfunc QuickSetBool(bval *bool, m map[string]any, name string) {\n\tv, ok := m[name]\n\tif !ok {\n\t\treturn\n\t}\n\tsqlInt, ok := v.(int64)\n\tif ok {\n\t\tif sqlInt > 0 {\n\t\t\t*bval = true\n\t\t}\n\t\treturn\n\t}\n\tsqlBool, ok := v.(bool)\n\tif ok {\n\t\t*bval = sqlBool\n\t}\n}\n\nfunc QuickSetBytes(bval *[]byte, m map[string]any, name string) {\n\tv, ok := m[name]\n\tif !ok {\n\t\treturn\n\t}\n\tsqlBytes, ok := v.([]byte)\n\tif ok {\n\t\t*bval = sqlBytes\n\t}\n}\n\nfunc getByteArr(m map[string]any, name string, def string) ([]byte, bool) {\n\tv, ok := m[name]\n\tif !ok {\n\t\treturn nil, false\n\t}\n\tbarr, ok := v.([]byte)\n\tif !ok {\n\t\tstr, ok := v.(string)\n\t\tif !ok {\n\t\t\treturn nil, false\n\t\t}\n\t\tbarr = []byte(str)\n\t}\n\tif len(barr) == 0 {\n\t\tbarr = []byte(def)\n\t}\n\treturn barr, true\n}\n\nfunc QuickSetJson(ptr any, m map[string]any, name string) {\n\tbarr, ok := getByteArr(m, name, \"{}\")\n\tif !ok {\n\t\treturn\n\t}\n\tjson.Unmarshal(barr, ptr)\n}\n\nfunc QuickSetNullableJson(ptr any, m map[string]any, name string) {\n\tbarr, ok := getByteArr(m, name, \"null\")\n\tif !ok {\n\t\treturn\n\t}\n\tjson.Unmarshal(barr, ptr)\n}\n\nfunc QuickSetJsonArr(ptr any, m map[string]any, name string) {\n\tbarr, ok := getByteArr(m, name, \"[]\")\n\tif !ok {\n\t\treturn\n\t}\n\tjson.Unmarshal(barr, ptr)\n}\n\nfunc CheckNil(v any) bool {\n\trv := reflect.ValueOf(v)\n\tif !rv.IsValid() {\n\t\treturn true\n\t}\n\tswitch rv.Kind() {\n\tcase reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:\n\t\treturn rv.IsNil()\n\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc QuickNullableJson(v any) string {\n\tif CheckNil(v) {\n\t\treturn \"null\"\n\t}\n\tbarr, _ := json.Marshal(v)\n\treturn string(barr)\n}\n\nfunc QuickJson(v any) string {\n\tif CheckNil(v) {\n\t\treturn \"{}\"\n\t}\n\tbarr, _ := json.Marshal(v)\n\treturn string(barr)\n}\n\nfunc QuickJsonBytes(v any) []byte {\n\tif CheckNil(v) {\n\t\treturn []byte(\"{}\")\n\t}\n\tbarr, _ := json.Marshal(v)\n\treturn barr\n}\n\nfunc QuickJsonArr(v any) string {\n\tif CheckNil(v) {\n\t\treturn \"[]\"\n\t}\n\tbarr, _ := json.Marshal(v)\n\treturn string(barr)\n}\n\nfunc QuickJsonArrBytes(v any) []byte {\n\tif CheckNil(v) {\n\t\treturn []byte(\"[]\")\n\t}\n\tbarr, _ := json.Marshal(v)\n\treturn barr\n}\n\nfunc QuickScanJson(ptr any, val any) error {\n\tbarrVal, ok := val.([]byte)\n\tif !ok {\n\t\tstrVal, ok := val.(string)\n\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"cannot scan '%T' into '%T'\", val, ptr)\n\t\t}\n\t\tbarrVal = []byte(strVal)\n\t}\n\tif len(barrVal) == 0 {\n\t\tbarrVal = []byte(\"{}\")\n\t}\n\treturn json.Unmarshal(barrVal, ptr)\n}\n\nfunc QuickValueJson(v any) (driver.Value, error) {\n\tif CheckNil(v) {\n\t\treturn \"{}\", nil\n\t}\n\tbarr, err := json.Marshal(v)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn string(barr), nil\n}\n\n// on error will return nil unless forceMake is set, in which case it returns make(map[string]any)\nfunc ParseJsonMap(val string, forceMake bool) map[string]any {\n\tvar noRtn map[string]any\n\tif forceMake {\n\t\tnoRtn = make(map[string]any)\n\t}\n\tif val == \"\" {\n\t\treturn noRtn\n\t}\n\tvar m map[string]any\n\terr := json.Unmarshal([]byte(val), &m)\n\tif err != nil {\n\t\treturn noRtn\n\t}\nreturn m\n}\n\nfunc ParseJsonArr[T any](val string) []T {\n\tif val == \"\" {\n\t\treturn nil\n\t}\n\tvar arr []T\n\terr := json.Unmarshal([]byte(val), &arr)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn arr\n}\n"
  },
  {
    "path": "pkg/util/ds/expmap.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ds\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/emirpasic/gods/trees/binaryheap\"\n)\n\n// an ExpMap has \"expiring\" keys, which are automatically deleted after a certain time\n\ntype ExpMap[T any] struct {\n\tlock    *sync.Mutex\n\texpHeap *binaryheap.Heap // heap of expEntries (sorted by time)\n\tm       map[string]expMapEntry[T]\n}\n\ntype expMapEntry[T any] struct {\n\tVal T\n\tExp time.Time\n}\n\ntype expEntry struct {\n\tKey string\n\tExp time.Time\n}\n\nfunc heapComparator(aArg, bArg any) int {\n\ta := aArg.(expEntry)\n\tb := bArg.(expEntry)\n\tif a.Exp.Before(b.Exp) {\n\t\treturn -1\n\t} else if a.Exp.After(b.Exp) {\n\t\treturn 1\n\t}\n\treturn 0\n}\n\nfunc MakeExpMap[T any]() *ExpMap[T] {\n\treturn &ExpMap[T]{\n\t\tlock:    &sync.Mutex{},\n\t\texpHeap: binaryheap.NewWith(heapComparator),\n\t\tm:       make(map[string]expMapEntry[T]),\n\t}\n}\n\nfunc (em *ExpMap[T]) Set(key string, value T, exp time.Time) {\n\tem.lock.Lock()\n\tdefer em.lock.Unlock()\n\toldEntry, ok := em.m[key]\n\tem.m[key] = expMapEntry[T]{Val: value, Exp: exp}\n\tif !ok || oldEntry.Exp != exp {\n\t\tem.expHeap.Push(expEntry{Key: key, Exp: exp}) // this might create duplicates.  that's ok.\n\t}\n}\n\nfunc (em *ExpMap[T]) expireItems_nolock() {\n\t// should already hold the lock\n\tnow := time.Now()\n\tfor {\n\t\tif em.expHeap.Empty() {\n\t\t\tbreak\n\t\t}\n\t\t// we know it isn't empty, so we ignore \"ok\"\n\t\ttopI, _ := em.expHeap.Peek()\n\t\ttop := topI.(expEntry)\n\t\tif top.Exp.After(now) {\n\t\t\tbreak\n\t\t}\n\t\tem.expHeap.Pop()\n\t\tentry, ok := em.m[top.Key]\n\t\tif ok && (entry.Exp.Before(now) || entry.Exp.Equal(now)) {\n\t\t\tdelete(em.m, top.Key)\n\t\t}\n\t}\n}\n\nfunc (em *ExpMap[T]) Get(key string) (T, bool) {\n\tem.lock.Lock()\n\tdefer em.lock.Unlock()\n\tem.expireItems_nolock()\n\tv, ok := em.m[key]\n\treturn v.Val, ok\n}\n"
  },
  {
    "path": "pkg/util/ds/syncmap.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ds\n\nimport \"sync\"\n\ntype SyncMap[T any] struct {\n\tlock *sync.Mutex\n\tm    map[string]T\n}\n\nfunc MakeSyncMap[T any]() *SyncMap[T] {\n\treturn &SyncMap[T]{\n\t\tlock: &sync.Mutex{},\n\t\tm:    make(map[string]T),\n\t}\n}\n\nfunc (sm *SyncMap[T]) Set(key string, value T) {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\tsm.m[key] = value\n}\n\nfunc (sm *SyncMap[T]) Get(key string) T {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\treturn sm.m[key]\n}\n\nfunc (sm *SyncMap[T]) GetEx(key string) (T, bool) {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\tv, ok := sm.m[key]\n\treturn v, ok\n}\n\nfunc (sm *SyncMap[T]) Delete(key string) {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\tdelete(sm.m, key)\n}\n\nfunc (sm *SyncMap[T]) SetUnless(key string, value T) bool {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\tif _, exists := sm.m[key]; exists {\n\t\treturn false\n\t}\n\tsm.m[key] = value\n\treturn true\n}\n\nfunc (sm *SyncMap[T]) TestAndSet(key string, newValue T, testFn func(T, bool) bool) bool {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\tcurrentValue, exists := sm.m[key]\n\tif testFn(currentValue, exists) {\n\t\tsm.m[key] = newValue\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (sm *SyncMap[T]) GetOrCreate(key string, createFn func() T) T {\n\tsm.lock.Lock()\n\tdefer sm.lock.Unlock()\n\tif v, ok := sm.m[key]; ok {\n\t\treturn v\n\t}\n\tv := createFn()\n\tsm.m[key] = v\n\treturn v\n}\n"
  },
  {
    "path": "pkg/util/ds/syncmap_test.go",
    "content": "package ds\n\nimport (\n\t\"testing\"\n)\n\nfunc TestSyncMap_Set(t *testing.T) {\n\tsm := MakeSyncMap[int]()\n\tsm.Set(\"key1\", 1)\n\tif sm.Get(\"key1\") != 1 {\n\t\tt.Errorf(\"expected 1, got %d\", sm.Get(\"key1\"))\n\t}\n}\n\nfunc TestSyncMap_Get(t *testing.T) {\n\tsm := MakeSyncMap[int]()\n\tsm.Set(\"key1\", 1)\n\tif sm.Get(\"key1\") != 1 {\n\t\tt.Errorf(\"expected 1, got %d\", sm.Get(\"key1\"))\n\t}\n\tif sm.Get(\"key2\") != 0 {\n\t\tt.Errorf(\"expected 0, got %d\", sm.Get(\"key2\"))\n\t}\n}\n\nfunc TestSyncMap_GetEx(t *testing.T) {\n\tsm := MakeSyncMap[int]()\n\tsm.Set(\"key1\", 1)\n\tvalue, ok := sm.GetEx(\"key1\")\n\tif !ok || value != 1 {\n\t\tt.Errorf(\"expected 1, got %d\", value)\n\t}\n\tvalue, ok = sm.GetEx(\"key2\")\n\tif ok || value != 0 {\n\t\tt.Errorf(\"expected 0, got %d\", value)\n\t}\n}\n\nfunc TestSyncMap_Delete(t *testing.T) {\n\tsm := MakeSyncMap[int]()\n\tsm.Set(\"key1\", 1)\n\tsm.Delete(\"key1\")\n\tif sm.Get(\"key1\") != 0 {\n\t\tt.Errorf(\"expected 0, got %d\", sm.Get(\"key1\"))\n\t}\n}\n"
  },
  {
    "path": "pkg/util/envutil/envutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage envutil\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\nconst MaxEnvSize = 1024 * 1024\n\n// env format:\n// KEY=VALUE\\0\n// keys cannot have '=' or '\\0' in them\n// values can have '=' but not '\\0'\n\nfunc EnvToMap(envStr string) map[string]string {\n\trtn := make(map[string]string)\n\tenvLines := strings.Split(envStr, \"\\x00\")\n\tfor _, line := range envLines {\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tparts := strings.SplitN(line, \"=\", 2)\n\t\tif len(parts) == 2 {\n\t\t\trtn[parts[0]] = parts[1]\n\t\t}\n\t}\n\treturn rtn\n}\n\nfunc MapToEnv(envMap map[string]string) string {\n\tvar sb strings.Builder\n\tfor key, val := range envMap {\n\t\tsb.WriteString(key)\n\t\tsb.WriteByte('=')\n\t\tsb.WriteString(val)\n\t\tsb.WriteByte('\\x00')\n\t}\n\treturn sb.String()\n}\n\nfunc GetEnv(envStr string, key string) string {\n\tenvMap := EnvToMap(envStr)\n\treturn envMap[key]\n}\n\nfunc SetEnv(envStr string, key string, val string) (string, error) {\n\tif strings.ContainsAny(key, \"=\\x00\") {\n\t\treturn \"\", fmt.Errorf(\"key cannot contain '=' or '\\\\x00'\")\n\t}\n\tif strings.Contains(val, \"\\x00\") {\n\t\treturn \"\", fmt.Errorf(\"value cannot contain '\\\\x00'\")\n\t}\n\tif len(key)+len(val)+2+len(envStr) > MaxEnvSize {\n\t\treturn \"\", fmt.Errorf(\"env string too large (max %d bytes)\", MaxEnvSize)\n\t}\n\tenvMap := EnvToMap(envStr)\n\tenvMap[key] = val\n\trtnStr := MapToEnv(envMap)\n\treturn rtnStr, nil\n}\n\nfunc RmEnv(envStr string, key string) string {\n\tenvMap := EnvToMap(envStr)\n\tdelete(envMap, key)\n\treturn MapToEnv(envMap)\n}\n\nfunc SliceToEnv(env []string) string {\n\tvar sb strings.Builder\n\tfor _, envVar := range env {\n\t\tif len(envVar) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsb.WriteString(envVar)\n\t\tsb.WriteByte('\\x00')\n\t}\n\treturn sb.String()\n}\n\nfunc EnvToSlice(envStr string) []string {\n\tenvLines := strings.Split(envStr, \"\\x00\")\n\tresult := make([]string, 0, len(envLines))\n\tfor _, line := range envLines {\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tresult = append(result, line)\n\t}\n\treturn result\n}\n\nfunc SliceToMap(env []string) map[string]string {\n\tenvMap := make(map[string]string)\n\tfor _, envVar := range env {\n\t\tparts := strings.SplitN(envVar, \"=\", 2)\n\t\tif len(parts) == 2 {\n\t\t\tenvMap[parts[0]] = parts[1]\n\t\t}\n\t}\n\treturn envMap\n}\n\nfunc CopyAndAddToEnvMap(envMap map[string]string, key string, val string) map[string]string {\n\tnewMap := make(map[string]string, len(envMap)+1)\n\tfor k, v := range envMap {\n\t\tnewMap[k] = v\n\t}\n\tnewMap[key] = val\n\treturn newMap\n}\n\nfunc PruneInitialEnv(envMap map[string]string) map[string]string {\n\tpruned := make(map[string]string)\n\tfor key, value := range envMap {\n\t\tif strings.HasPrefix(key, \"WAVETERM_\") || strings.HasPrefix(key, \"BASH_FUNC_\") {\n\t\t\tcontinue\n\t\t}\n\t\tif key == \"XDG_SESSION_ID\" || key == \"SHLVL\" || key == \"S_COLORS\" ||\n\t\t\tkey == \"SSH_CONNECTION\" || key == \"SSH_CLIENT\" || key == \"LESSOPEN\" ||\n\t\t\tkey == \"which_declare\" {\n\t\t\tcontinue\n\t\t}\n\t\tpruned[key] = value\n\t}\n\treturn pruned\n}\n"
  },
  {
    "path": "pkg/util/fileutil/fileutil.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage fileutil\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"mime\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\ntype ByteRangeType struct {\n\tAll     bool\n\tStart   int64\n\tEnd     int64 // inclusive; only valid when OpenEnd is false\n\tOpenEnd bool  // true when range is \"N-\" (read from Start to EOF)\n}\n\nfunc ParseByteRange(rangeStr string) (ByteRangeType, error) {\n\tif rangeStr == \"\" {\n\t\treturn ByteRangeType{All: true}, nil\n\t}\n\t// handle open-ended range \"N-\"\n\tif len(rangeStr) > 0 && rangeStr[len(rangeStr)-1] == '-' {\n\t\tvar start int64\n\t\t_, err := fmt.Sscanf(rangeStr, \"%d-\", &start)\n\t\tif err != nil || start < 0 {\n\t\t\treturn ByteRangeType{}, errors.New(\"invalid byte range\")\n\t\t}\n\t\treturn ByteRangeType{Start: start, OpenEnd: true}, nil\n\t}\n\tvar start, end int64\n\t_, err := fmt.Sscanf(rangeStr, \"%d-%d\", &start, &end)\n\tif err != nil {\n\t\treturn ByteRangeType{}, errors.New(\"invalid byte range\")\n\t}\n\tif start < 0 || end < 0 || start > end {\n\t\treturn ByteRangeType{}, errors.New(\"invalid byte range\")\n\t}\n\t// End is inclusive (HTTP byte range semantics: bytes=0-999 means 1000 bytes)\n\treturn ByteRangeType{Start: start, End: end}, nil\n}\n\nfunc FixPath(path string) (string, error) {\n\torigPath := path\n\tvar err error\n\tif strings.HasPrefix(path, \"~\") {\n\t\tpath = filepath.Join(wavebase.GetHomeDir(), path[1:])\n\t} else if !filepath.IsAbs(path) {\n\t\tpath, err = filepath.Abs(path)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\tif strings.HasSuffix(origPath, \"/\") && !strings.HasSuffix(path, \"/\") {\n\t\tpath += \"/\"\n\t}\n\treturn path, nil\n}\n\nconst (\n\twinFlagSoftlink = uint32(0x8000) // FILE_ATTRIBUTE_REPARSE_POINT\n\twinFlagJunction = uint32(0x80)   // FILE_ATTRIBUTE_JUNCTION\n)\n\nfunc WinSymlinkDir(path string, bits os.FileMode) bool {\n\t// Windows compatibility layer doesn't expose symlink target type through fileInfo\n\t// so we need to check file attributes and extension patterns\n\tisFileSymlink := func(filepath string) bool {\n\t\tif len(filepath) == 0 {\n\t\t\treturn false\n\t\t}\n\t\treturn strings.LastIndex(filepath, \".\") > strings.LastIndex(filepath, \"/\")\n\t}\n\n\tflags := uint32(bits >> 12)\n\n\tif flags == winFlagSoftlink {\n\t\treturn !isFileSymlink(path)\n\t} else if flags == winFlagJunction {\n\t\treturn true\n\t} else {\n\t\treturn false\n\t}\n}\n\n// on error just returns \"\"\n// does not return \"application/octet-stream\" as this is considered a detection failure\n// can pass an existing fileInfo to avoid re-statting the file\n// falls back to text/plain for 0 byte files\nfunc DetectMimeType(path string, fileInfo fs.FileInfo, extended bool) string {\n\tif fileInfo == nil {\n\t\tstatRtn, err := os.Stat(path)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\tfileInfo = statRtn\n\t}\n\n\tif fileInfo.IsDir() || WinSymlinkDir(path, fileInfo.Mode()) {\n\t\treturn \"directory\"\n\t}\n\tif fileInfo.Mode()&os.ModeNamedPipe == os.ModeNamedPipe {\n\t\treturn \"pipe\"\n\t}\n\tcharDevice := os.ModeDevice | os.ModeCharDevice\n\tif fileInfo.Mode()&charDevice == charDevice {\n\t\treturn \"character-special\"\n\t}\n\tif fileInfo.Mode()&os.ModeDevice == os.ModeDevice {\n\t\treturn \"block-special\"\n\t}\n\text := strings.ToLower(filepath.Ext(path))\n\tif mimeType, ok := StaticMimeTypeMap[ext]; ok {\n\t\treturn mimeType\n\t}\n\tif mimeType := mime.TypeByExtension(ext); mimeType != \"\" {\n\t\treturn mimeType\n\t}\n\tif fileInfo.Size() == 0 {\n\t\treturn \"text/plain\"\n\t}\n\tif !extended {\n\t\treturn \"\"\n\t}\n\tfd, err := os.Open(path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tdefer fd.Close()\n\tbuf := make([]byte, 512)\n\t// ignore the error (EOF / UnexpectedEOF is fine, just process how much we got back)\n\tn, _ := io.ReadAtLeast(fd, buf, 512)\n\tif n == 0 {\n\t\treturn \"\"\n\t}\n\tbuf = buf[:n]\n\trtn := http.DetectContentType(buf)\n\tif rtn == \"application/octet-stream\" {\n\t\treturn \"\"\n\t}\n\treturn rtn\n}\n\nfunc DetectMimeTypeWithDirEnt(path string, dirEnt fs.DirEntry) string {\n\tif dirEnt != nil {\n\t\tif dirEnt.IsDir() {\n\t\t\treturn \"directory\"\n\t\t}\n\t\tmode := dirEnt.Type()\n\t\tif mode&os.ModeNamedPipe == os.ModeNamedPipe {\n\t\t\treturn \"pipe\"\n\t\t}\n\t\tcharDevice := os.ModeDevice | os.ModeCharDevice\n\t\tif mode&charDevice == charDevice {\n\t\t\treturn \"character-special\"\n\t\t}\n\t\tif mode&os.ModeDevice == os.ModeDevice {\n\t\t\treturn \"block-special\"\n\t\t}\n\t}\n\text := strings.ToLower(filepath.Ext(path))\n\tif mimeType, ok := StaticMimeTypeMap[ext]; ok {\n\t\treturn mimeType\n\t}\n\treturn \"\"\n}\n\nfunc AtomicWriteFile(fileName string, data []byte, perm os.FileMode) error {\n\ttmpFileName := fileName + TempFileSuffix\n\tif err := os.WriteFile(tmpFileName, data, perm); err != nil {\n\t\tif removeErr := os.Remove(tmpFileName); removeErr != nil && !os.IsNotExist(removeErr) {\n\t\t\treturn fmt.Errorf(\"failed to write temp file %q: %w (also failed to remove temp file: %v)\", tmpFileName, err, removeErr)\n\t\t}\n\t\treturn err\n\t}\n\tif err := os.Rename(tmpFileName, fileName); err != nil {\n\t\tif removeErr := os.Remove(tmpFileName); removeErr != nil && !os.IsNotExist(removeErr) {\n\t\t\treturn fmt.Errorf(\"failed to rename temp file %q to %q: %w (also failed to remove temp file: %v)\", tmpFileName, fileName, err, removeErr)\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n\nvar (\n\tsystemBinDirs = []string{\n\t\t\"/bin/\",\n\t\t\"/usr/bin/\",\n\t\t\"/usr/local/bin/\",\n\t\t\"/opt/bin/\",\n\t\t\"/sbin/\",\n\t\t\"/usr/sbin/\",\n\t}\n\tsuspiciousPattern = regexp.MustCompile(`[:;#!&$\\t%=\"|>{}]`)\n\tflagPattern       = regexp.MustCompile(` --?[a-zA-Z0-9]`)\n)\n\n// IsInitScriptPath tries to determine if the input string is a path to a script\n// rather than an inline script content.\nfunc IsInitScriptPath(input string) bool {\n\tif len(input) == 0 || strings.Contains(input, \"\\n\") {\n\t\treturn false\n\t}\n\n\tif suspiciousPattern.MatchString(input) {\n\t\treturn false\n\t}\n\n\tif flagPattern.MatchString(input) {\n\t\treturn false\n\t}\n\n\t// Check for home directory path\n\tif strings.HasPrefix(input, \"~/\") {\n\t\treturn true\n\t}\n\n\t// Path must be absolute (if not home directory)\n\tif !filepath.IsAbs(input) {\n\t\treturn false\n\t}\n\n\t// Check if path starts with system binary directories\n\tnormalizedPath := filepath.ToSlash(input)\n\tfor _, binDir := range systemBinDirs {\n\t\tif strings.HasPrefix(normalizedPath, binDir) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\nconst (\n\tTempFileSuffix  = \".tmp\"\n\tMaxEditFileSize = 5 * 1024 * 1024 // 5MB\n)\n\ntype EditSpec struct {\n\tOldStr string `json:\"old_str\"`\n\tNewStr string `json:\"new_str\"`\n\tDesc   string `json:\"desc,omitempty\"`\n}\n\ntype EditResult struct {\n\tApplied bool   `json:\"applied\"`\n\tDesc    string `json:\"desc\"`\n\tError   string `json:\"error,omitempty\"`\n}\n\n// applyEdit applies a single edit to the content and returns the modified content and result.\nfunc applyEdit(content []byte, edit EditSpec, index int) ([]byte, EditResult) {\n\tresult := EditResult{\n\t\tDesc: edit.Desc,\n\t}\n\tif result.Desc == \"\" {\n\t\tresult.Desc = fmt.Sprintf(\"Edit %d\", index+1)\n\t}\n\n\tif edit.OldStr == \"\" {\n\t\tresult.Applied = false\n\t\tresult.Error = \"old_str cannot be empty\"\n\t\treturn content, result\n\t}\n\n\toldBytes := []byte(edit.OldStr)\n\tcount := bytes.Count(content, oldBytes)\n\tif count == 0 {\n\t\tresult.Applied = false\n\t\tresult.Error = \"old_str not found in file\"\n\t\treturn content, result\n\t}\n\tif count > 1 {\n\t\tresult.Applied = false\n\t\tresult.Error = fmt.Sprintf(\"old_str appears %d times, must appear exactly once\", count)\n\t\treturn content, result\n\t}\n\n\tmodifiedContent := bytes.Replace(content, oldBytes, []byte(edit.NewStr), 1)\n\tresult.Applied = true\n\treturn modifiedContent, result\n}\n\n// ApplyEdits applies a series of edits to the given content and returns the modified content.\n// This is atomic - all edits succeed or all fail.\nfunc ApplyEdits(originalContent []byte, edits []EditSpec) ([]byte, error) {\n\tmodifiedContents := originalContent\n\n\tfor i, edit := range edits {\n\t\tvar result EditResult\n\t\tmodifiedContents, result = applyEdit(modifiedContents, edit, i)\n\t\tif !result.Applied {\n\t\t\treturn nil, fmt.Errorf(\"edit %d (%s): %s\", i, result.Desc, result.Error)\n\t\t}\n\t}\n\n\treturn modifiedContents, nil\n}\n\n// ApplyEditsPartial applies edits incrementally, continuing until the first failure.\n// Returns the modified content (potentially partially applied) and results for each edit.\nfunc ApplyEditsPartial(originalContent []byte, edits []EditSpec) ([]byte, []EditResult) {\n\tmodifiedContents := originalContent\n\tresults := make([]EditResult, len(edits))\n\tfailed := false\n\n\tfor i, edit := range edits {\n\t\tif failed {\n\t\t\tresults[i].Desc = edit.Desc\n\t\t\tif results[i].Desc == \"\" {\n\t\t\t\tresults[i].Desc = fmt.Sprintf(\"Edit %d\", i+1)\n\t\t\t}\n\t\t\tresults[i].Applied = false\n\t\t\tresults[i].Error = \"previous edit failed\"\n\t\t\tcontinue\n\t\t}\n\n\t\tmodifiedContents, results[i] = applyEdit(modifiedContents, edit, i)\n\t\tif !results[i].Applied {\n\t\t\tfailed = true\n\t\t}\n\t}\n\n\treturn modifiedContents, results\n}\n\nfunc ReplaceInFile(filePath string, edits []EditSpec) error {\n\tfileInfo, err := os.Stat(filePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to stat file: %w\", err)\n\t}\n\n\tif !fileInfo.Mode().IsRegular() {\n\t\treturn fmt.Errorf(\"not a regular file: %s\", filePath)\n\t}\n\n\tif fileInfo.Size() > MaxEditFileSize {\n\t\treturn fmt.Errorf(\"file too large for editing: %d bytes (max: %d)\", fileInfo.Size(), MaxEditFileSize)\n\t}\n\n\tcontents, err := os.ReadFile(filePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read file: %w\", err)\n\t}\n\n\tmodifiedContents, err := ApplyEdits(contents, edits)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := os.WriteFile(filePath, modifiedContents, fileInfo.Mode()); err != nil {\n\t\treturn fmt.Errorf(\"failed to write file: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// ReplaceInFilePartial applies edits incrementally up to the first failure.\n// Returns the results for each edit and writes the partially modified content.\nfunc ReplaceInFilePartial(filePath string, edits []EditSpec) ([]EditResult, error) {\n\tfileInfo, err := os.Stat(filePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to stat file: %w\", err)\n\t}\n\n\tif !fileInfo.Mode().IsRegular() {\n\t\treturn nil, fmt.Errorf(\"not a regular file: %s\", filePath)\n\t}\n\n\tif fileInfo.Size() > MaxEditFileSize {\n\t\treturn nil, fmt.Errorf(\"file too large for editing: %d bytes (max: %d)\", fileInfo.Size(), MaxEditFileSize)\n\t}\n\n\tcontents, err := os.ReadFile(filePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read file: %w\", err)\n\t}\n\n\tmodifiedContents, results := ApplyEditsPartial(contents, edits)\n\n\tif err := os.WriteFile(filePath, modifiedContents, fileInfo.Mode()); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to write file: %w\", err)\n\t}\n\n\treturn results, nil\n}\n"
  },
  {
    "path": "pkg/util/fileutil/fileutil_test.go",
    "content": "package fileutil\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestAtomicWriteFile(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tfileName := filepath.Join(tmpDir, \"settings.json\")\n\n\terr := AtomicWriteFile(fileName, []byte(`{\"key\":\"value\"}`), 0644)\n\tif err != nil {\n\t\tt.Fatalf(\"AtomicWriteFile failed: %v\", err)\n\t}\n\n\tdata, err := os.ReadFile(fileName)\n\tif err != nil {\n\t\tt.Fatalf(\"ReadFile failed: %v\", err)\n\t}\n\tif string(data) != `{\"key\":\"value\"}` {\n\t\tt.Fatalf(\"unexpected file contents: %q\", string(data))\n\t}\n\tif _, err := os.Stat(fileName + TempFileSuffix); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"temporary file should not exist, stat err: %v\", err)\n\t}\n}\n\nfunc TestAtomicWriteFileRenameErrorCleansTempFile(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tfileName := filepath.Join(tmpDir, \"settings.json\")\n\n\tif err := os.Mkdir(fileName, 0755); err != nil {\n\t\tt.Fatalf(\"Mkdir failed: %v\", err)\n\t}\n\n\terr := AtomicWriteFile(fileName, []byte(`{\"key\":\"value\"}`), 0644)\n\tif err == nil {\n\t\tt.Fatalf(\"AtomicWriteFile expected error\")\n\t}\n\tif _, statErr := os.Stat(fileName + TempFileSuffix); !os.IsNotExist(statErr) {\n\t\tt.Fatalf(\"temporary file should be removed on rename error, stat err: %v\", statErr)\n\t}\n}\n"
  },
  {
    "path": "pkg/util/fileutil/mimetypes.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage fileutil\n\nvar StaticMimeTypeMap = map[string]string{\n\t\".a2l\":                       \"application/A2L\",\n\t\".aml\":                       \"application/AML\",\n\t\".ez\":                        \"application/andrew-inset\",\n\t\".anx\":                       \"application/annodex\",\n\t\".atf\":                       \"application/ATF\",\n\t\".atfx\":                      \"application/ATFX\",\n\t\".atom\":                      \"application/atom+xml\",\n\t\".atomcat\":                   \"application/atomcat+xml\",\n\t\".atomdeleted\":               \"application/atomdeleted+xml\",\n\t\".atomsrv\":                   \"application/atomserv+xml\",\n\t\".atomsvc\":                   \"application/atomsvc+xml\",\n\t\".dwd\":                       \"application/atsc-dwd+xml\",\n\t\".held\":                      \"application/atsc-held+xml\",\n\t\".rsat\":                      \"application/atsc-rsat+xml\",\n\t\".atxml\":                     \"application/ATXML\",\n\t\".apxml\":                     \"application/auth-policy+xml\",\n\t\".amlx\":                      \"application/automationml-amlx+zip\",\n\t\".xdd\":                       \"application/bacnet-xdd+zip\",\n\t\".lin\":                       \"application/bbolin\",\n\t\".xcs\":                       \"application/calendar+xml\",\n\t\".cbor\":                      \"application/cbor\",\n\t\".c3ex\":                      \"application/cccex\",\n\t\".ccmp\":                      \"application/ccmp+xml\",\n\t\".ccxml\":                     \"application/ccxml+xml\",\n\t\".cdfx\":                      \"application/CDFX+XML\",\n\t\".cdmia\":                     \"application/cdmi-capability\",\n\t\".cdmic\":                     \"application/cdmi-container\",\n\t\".cdmid\":                     \"application/cdmi-domain\",\n\t\".cdmio\":                     \"application/cdmi-object\",\n\t\".cdmiq\":                     \"application/cdmi-queue\",\n\t\".cea\":                       \"application/CEA\",\n\t\".cellml\":                    \"application/cellml+xml\",\n\t\".1clr\":                      \"application/clr\",\n\t\".clue\":                      \"application/clue_info+xml\",\n\t\".cmsc\":                      \"application/cms\",\n\t\".cpl\":                       \"application/cpl+xml\",\n\t\".csrattrs\":                  \"application/csrattrs\",\n\t\".cu\":                        \"application/cu-seeme\",\n\t\".cwl\":                       \"application/cwl\",\n\t\".cwl.json\":                  \"application/cwl+json\",\n\t\".mpd\":                       \"application/dash+xml\",\n\t\".mpdd\":                      \"application/dashdelta\",\n\t\".davmount\":                  \"application/davmount+xml\",\n\t\".dcd\":                       \"application/DCD\",\n\t\".dcm\":                       \"application/dicom\",\n\t\".dii\":                       \"application/DII\",\n\t\".dit\":                       \"application/DIT\",\n\t\".xmls\":                      \"application/dskpp+xml\",\n\t\".tsp\":                       \"application/dsptype\",\n\t\".dssc\":                      \"application/dssc+der\",\n\t\".xdssc\":                     \"application/dssc+xml\",\n\t\".dvc\":                       \"application/dvcs\",\n\t\".efi\":                       \"application/efi\",\n\t\".emma\":                      \"application/emma+xml\",\n\t\".emotionml\":                 \"application/emotionml+xml\",\n\t\".epub\":                      \"application/epub+zip\",\n\t\".exi\":                       \"application/exi\",\n\t\".exp\":                       \"application/express\",\n\t\".finf\":                      \"application/fastinfoset\",\n\t\".fdf\":                       \"application/fdf\",\n\t\".fdt\":                       \"application/fdt+xml\",\n\t\".pfr\":                       \"application/font-tdpfr\",\n\t\".spl\":                       \"application/futuresplash\",\n\t\".geojson\":                   \"application/geo+json\",\n\t\".gpkg\":                      \"application/geopackage+sqlite3\",\n\t\".glbin\":                     \"application/gltf-buffer\",\n\t\".gml\":                       \"application/gml+xml\",\n\t\".gql\":                       \"application/graphql\",\n\t\".graphql\":                   \"application/graphql\",\n\t\".gz\":                        \"application/gzip\",\n\t\".hta\":                       \"application/hta\",\n\t\".stk\":                       \"application/hyperstudio\",\n\t\".ink\":                       \"application/inkml+xml\",\n\t\".ipfix\":                     \"application/ipfix\",\n\t\".its\":                       \"application/its+xml\",\n\t\".jar\":                       \"application/java-archive\",\n\t\".ser\":                       \"application/java-serialized-object\",\n\t\".class\":                     \"application/java-vm\",\n\t\".jrd\":                       \"application/jrd+json\",\n\t\".json\":                      \"application/json\",\n\t\".json-patch\":                \"application/json-patch+json\",\n\t\".jsonld\":                    \"application/ld+json\",\n\t\".lgr\":                       \"application/lgr+xml\",\n\t\".wlnk\":                      \"application/link-format\",\n\t\".liquid\":                    \"application/liquid\",\n\t\".lostxml\":                   \"application/lost+xml\",\n\t\".lostsyncxml\":               \"application/lostsync+xml\",\n\t\".lpf\":                       \"application/lpf+zip\",\n\t\".lxf\":                       \"application/LXF\",\n\t\".m3g\":                       \"application/m3g\",\n\t\".hqx\":                       \"application/mac-binhex40\",\n\t\".cpt\":                       \"application/mac-compactpro\",\n\t\".mads\":                      \"application/mads+xml\",\n\t\".webmanifest\":               \"application/manifest+json\",\n\t\".mrc\":                       \"application/marc\",\n\t\".mrcx\":                      \"application/marcxml+xml\",\n\t\".ma\":                        \"application/mathematica\",\n\t\".mml\":                       \"application/mathml+xml\",\n\t\".mbox\":                      \"application/mbox\",\n\t\".meta4\":                     \"application/metalink4+xml\",\n\t\".mets\":                      \"application/mets+xml\",\n\t\".mf4\":                       \"application/MF4\",\n\t\".maei\":                      \"application/mmt-aei+xml\",\n\t\".musd\":                      \"application/mmt-usd+xml\",\n\t\".mods\":                      \"application/mods+xml\",\n\t\".m21\":                       \"application/mp21\",\n\t\".mdb\":                       \"application/msaccess\",\n\t\".doc\":                       \"application/msword\",\n\t\".mxf\":                       \"application/mxf\",\n\t\".nq\":                        \"application/n-quads\",\n\t\".nt\":                        \"application/n-triples\",\n\t\".orq\":                       \"application/ocsp-request\",\n\t\".ors\":                       \"application/ocsp-response\",\n\t\".bin\":                       \"application/octet-stream\",\n\t\".oda\":                       \"application/ODA\",\n\t\".odx\":                       \"application/ODX\",\n\t\".opf\":                       \"application/oebps-package+xml\",\n\t\".ogx\":                       \"application/ogg\",\n\t\".one\":                       \"application/onenote\",\n\t\".oxps\":                      \"application/oxps\",\n\t\".p21\":                       \"application/p21\",\n\t\".relo\":                      \"application/p2p-overlay+xml\",\n\t\".pdf\":                       \"application/pdf\",\n\t\".pdx\":                       \"application/PDX\",\n\t\".pgp\":                       \"application/pgp-encrypted\",\n\t\".asc\":                       \"application/pgp-keys\",\n\t\".sig\":                       \"application/pgp-signature\",\n\t\".prf\":                       \"application/pics-rules\",\n\t\".p10\":                       \"application/pkcs10\",\n\t\".p12\":                       \"application/pkcs12\",\n\t\".p7m\":                       \"application/pkcs7-mime\",\n\t\".p7s\":                       \"application/pkcs7-signature\",\n\t\".p8\":                        \"application/pkcs8\",\n\t\".p8e\":                       \"application/pkcs8-encrypted\",\n\t\".ac\":                        \"application/pkix-attr-cert\",\n\t\".cer\":                       \"application/pkix-cert\",\n\t\".crl\":                       \"application/pkix-crl\",\n\t\".pkipath\":                   \"application/pkix-pkipath\",\n\t\".pki\":                       \"application/pkixcmp\",\n\t\".ps\":                        \"application/postscript\",\n\t\".provx\":                     \"application/provenance+xml\",\n\t\".cw\":                        \"application/prs.cww\",\n\t\".hpub\":                      \"application/prs.hpub+zip\",\n\t\".rnd\":                       \"application/prs.nprend\",\n\t\".rdf-crypt\":                 \"application/prs.rdf-xml-crypt\",\n\t\".xsf\":                       \"application/prs.xsf+xml\",\n\t\".pskcxml\":                   \"application/pskc+xml\",\n\t\".rdf\":                       \"application/rdf+xml\",\n\t\".rif\":                       \"application/reginfo+xml\",\n\t\".rnc\":                       \"application/relax-ng-compact-syntax\",\n\t\".rl\":                        \"application/resource-lists+xml\",\n\t\".rld\":                       \"application/resource-lists-diff+xml\",\n\t\".rfcxml\":                    \"application/rfc+xml\",\n\t\".rapd\":                      \"application/route-apd+xml\",\n\t\".sls\":                       \"application/route-s-tsid+xml\",\n\t\".rusd\":                      \"application/route-usd+xml\",\n\t\".gbr\":                       \"application/rpki-ghostbusters\",\n\t\".mft\":                       \"application/rpki-manifest\",\n\t\".roa\":                       \"application/rpki-roa\",\n\t\".rtf\":                       \"application/rtf\",\n\t\".sarif\":                     \"application/sarif+json\",\n\t\".sarif-external-properties\": \"application/sarif-external-properties+json\",\n\t\".scim\":                      \"application/scim+json\",\n\t\".scq\":                       \"application/scvp-cv-request\",\n\t\".scs\":                       \"application/scvp-cv-response\",\n\t\".spq\":                       \"application/scvp-vp-request\",\n\t\".spp\":                       \"application/scvp-vp-response\",\n\t\".sdp\":                       \"application/sdp\",\n\t\".senmlc\":                    \"application/senml+cbor\",\n\t\".senml\":                     \"application/senml+json\",\n\t\".senmlx\":                    \"application/senml+xml\",\n\t\".senml-etchc\":               \"application/senml-etch+cbor\",\n\t\".senml-etchj\":               \"application/senml-etch+json\",\n\t\".senmle\":                    \"application/senml-exi\",\n\t\".sensmlc\":                   \"application/sensml+cbor\",\n\t\".sensml\":                    \"application/sensml+json\",\n\t\".sensmlx\":                   \"application/sensml+xml\",\n\t\".sensmle\":                   \"application/sensml-exi\",\n\t\".soc\":                       \"application/sgml-open-catalog\",\n\t\".shf\":                       \"application/shf+xml\",\n\t\".siv\":                       \"application/sieve\",\n\t\".smil\":                      \"application/smil+xml\",\n\t\".rq\":                        \"application/sparql-query\",\n\t\".srx\":                       \"application/sparql-results+xml\",\n\t\".spdx.json\":                 \"application/spdx+json\",\n\t\".sql\":                       \"application/sql\",\n\t\".gram\":                      \"application/srgs\",\n\t\".grxml\":                     \"application/srgs+xml\",\n\t\".sru\":                       \"application/sru+xml\",\n\t\".ssml\":                      \"application/ssml+xml\",\n\t\".stix\":                      \"application/stix+json\",\n\t\".coswid\":                    \"application/swid+cbor\",\n\t\".swidtag\":                   \"application/swid+xml\",\n\t\".tau\":                       \"application/tamp-apex-update\",\n\t\".auc\":                       \"application/tamp-apex-update-confirm\",\n\t\".tcu\":                       \"application/tamp-community-update\",\n\t\".cuc\":                       \"application/tamp-community-update-confirm\",\n\t\".ter\":                       \"application/tamp-error\",\n\t\".tsa\":                       \"application/tamp-sequence-adjust\",\n\t\".sac\":                       \"application/tamp-sequence-adjust-confirm\",\n\t\".tur\":                       \"application/tamp-update\",\n\t\".tuc\":                       \"application/tamp-update-confirm\",\n\t\".jsontd\":                    \"application/td+json\",\n\t\".tei\":                       \"application/tei+xml\",\n\t\".tfi\":                       \"application/thraud+xml\",\n\t\".tsq\":                       \"application/timestamp-query\",\n\t\".tsr\":                       \"application/timestamp-reply\",\n\t\".tsd\":                       \"application/timestamped-data\",\n\t\".tm.jsonld\":                 \"application/tm+json\",\n\t\".toml\":                      \"application/toml\",\n\t\".trig\":                      \"application/trig\",\n\t\".ttml\":                      \"application/ttml+xml\",\n\t\".gsheet\":                    \"application/urc-grpsheet+xml\",\n\t\".rsheet\":                    \"application/urc-ressheet+xml\",\n\t\".td\":                        \"application/urc-targetdesc+xml\",\n\t\".uis\":                       \"application/urc-uisocketdesc+xml\",\n\t\".1km\":                       \"application/vnd.1000minds.decision-model+xml\",\n\t\".ob\":                        \"application/vnd.1ob\",\n\t\".plb\":                       \"application/vnd.3gpp.pic-bw-large\",\n\t\".psb\":                       \"application/vnd.3gpp.pic-bw-small\",\n\t\".pvb\":                       \"application/vnd.3gpp.pic-bw-var\",\n\t\".sms\":                       \"application/vnd.3gpp2.sms\",\n\t\".tcap\":                      \"application/vnd.3gpp2.tcap\",\n\t\".imgcal\":                    \"application/vnd.3lightssoftware.imagescal\",\n\t\".pwn\":                       \"application/vnd.3M.Post-it-Notes\",\n\t\".aso\":                       \"application/vnd.accpac.simply.aso\",\n\t\".imp\":                       \"application/vnd.accpac.simply.imp\",\n\t\".acu\":                       \"application/vnd.acucobol\",\n\t\".atc\":                       \"application/vnd.acucorp\",\n\t\".swf\":                       \"application/vnd.adobe.flash.movie\",\n\t\".fcdt\":                      \"application/vnd.adobe.formscentral.fcdt\",\n\t\".fxp\":                       \"application/vnd.adobe.fxp\",\n\t\".xdp\":                       \"application/vnd.adobe.xdp+xml\",\n\t\".list3820\":                  \"application/vnd.afpc.modca\",\n\t\".ovl\":                       \"application/vnd.afpc.modca-overlay\",\n\t\".psg\":                       \"application/vnd.afpc.modca-pagesegment\",\n\t\".age\":                       \"application/vnd.age\",\n\t\".ahead\":                     \"application/vnd.ahead.space\",\n\t\".azf\":                       \"application/vnd.airzip.filesecure.azf\",\n\t\".azs\":                       \"application/vnd.airzip.filesecure.azs\",\n\t\".azw3\":                      \"application/vnd.amazon.mobi8-ebook\",\n\t\".acc\":                       \"application/vnd.americandynamics.acc\",\n\t\".ami\":                       \"application/vnd.amiga.ami\",\n\t\".ota\":                       \"application/vnd.android.ota\",\n\t\".apk\":                       \"application/vnd.android.package-archive\",\n\t\".apkg\":                      \"application/vnd.anki\",\n\t\".cii\":                       \"application/vnd.anser-web-certificate-issue-initiation\",\n\t\".fti\":                       \"application/vnd.anser-web-funds-transfer-initiation\",\n\t\".arrow\":                     \"application/vnd.apache.arrow.file\",\n\t\".arrows\":                    \"application/vnd.apache.arrow.stream\",\n\t\".apexlang\":                  \"application/vnd.apexlang\",\n\t\".dist\":                      \"application/vnd.apple.installer+xml\",\n\t\".keynote\":                   \"application/vnd.apple.keynote\",\n\t\".m3u8\":                      \"application/vnd.apple.mpegurl\",\n\t\".numbers\":                   \"application/vnd.apple.numbers\",\n\t\".pages\":                     \"application/vnd.apple.pages\",\n\t\".swi\":                       \"application/vnd.aristanetworks.swi\",\n\t\".artisan\":                   \"application/vnd.artisan+json\",\n\t\".iota\":                      \"application/vnd.astraea-software.iota\",\n\t\".aep\":                       \"application/vnd.audiograph\",\n\t\".package\":                   \"application/vnd.autopackage\",\n\t\".bmml\":                      \"application/vnd.balsamiq.bmml+xml\",\n\t\".bmpr\":                      \"application/vnd.balsamiq.bmpr\",\n\t\".ac2\":                       \"application/vnd.banana-accounting\",\n\t\".lhzd\":                      \"application/vnd.belightsoft.lhzd+zip\",\n\t\".lhzl\":                      \"application/vnd.belightsoft.lhzl+zip\",\n\t\".mpm\":                       \"application/vnd.blueice.multipass\",\n\t\".ep\":                        \"application/vnd.bluetooth.ep.oob\",\n\t\".le\":                        \"application/vnd.bluetooth.le.oob\",\n\t\".bmi\":                       \"application/vnd.bmi\",\n\t\".rep\":                       \"application/vnd.businessobjects\",\n\t\".tlclient\":                  \"application/vnd.cendio.thinlinc.clientconf\",\n\t\".cdxml\":                     \"application/vnd.chemdraw+xml\",\n\t\".pgn\":                       \"application/vnd.chess-pgn\",\n\t\".mmd\":                       \"application/vnd.chipnuts.karaoke-mmd\",\n\t\".cdy\":                       \"application/vnd.cinderella\",\n\t\".csl\":                       \"application/vnd.citationstyles.style+xml\",\n\t\".cla\":                       \"application/vnd.claymore\",\n\t\".rp9\":                       \"application/vnd.cloanto.rp9\",\n\t\".c4g\":                       \"application/vnd.clonk.c4group\",\n\t\".c11amc\":                    \"application/vnd.cluetrust.cartomobile-config\",\n\t\".c11amz\":                    \"application/vnd.cluetrust.cartomobile-config-pkg\",\n\t\".coffee\":                    \"application/vnd.coffeescript\",\n\t\".xodt\":                      \"application/vnd.collabio.xodocuments.document\",\n\t\".xott\":                      \"application/vnd.collabio.xodocuments.document-template\",\n\t\".xodp\":                      \"application/vnd.collabio.xodocuments.presentation\",\n\t\".xotp\":                      \"application/vnd.collabio.xodocuments.presentation-template\",\n\t\".xods\":                      \"application/vnd.collabio.xodocuments.spreadsheet\",\n\t\".xots\":                      \"application/vnd.collabio.xodocuments.spreadsheet-template\",\n\t\".cbz\":                       \"application/vnd.comicbook+zip\",\n\t\".cbr\":                       \"application/vnd.comicbook-rar\",\n\t\".icf\":                       \"application/vnd.commerce-battelle\",\n\t\".csp\":                       \"application/vnd.commonspace\",\n\t\".cdbcmsg\":                   \"application/vnd.contact.cmsg\",\n\t\".ign\":                       \"application/vnd.coreos.ignition+json\",\n\t\".cmc\":                       \"application/vnd.cosmocaller\",\n\t\".clkx\":                      \"application/vnd.crick.clicker\",\n\t\".clkk\":                      \"application/vnd.crick.clicker.keyboard\",\n\t\".clkp\":                      \"application/vnd.crick.clicker.palette\",\n\t\".clkt\":                      \"application/vnd.crick.clicker.template\",\n\t\".clkw\":                      \"application/vnd.crick.clicker.wordbank\",\n\t\".wbs\":                       \"application/vnd.criticaltools.wbs+xml\",\n\t\".ssvc\":                      \"application/vnd.crypto-shade-file\",\n\t\".c9r\":                       \"application/vnd.cryptomator.encrypted\",\n\t\".cryptomator\":               \"application/vnd.cryptomator.vault\",\n\t\".pml\":                       \"application/vnd.ctc-posml\",\n\t\".ppd\":                       \"application/vnd.cups-ppd\",\n\t\".rdz\":                       \"application/vnd.data-vision.rdz\",\n\t\".dl\":                        \"application/vnd.datalog\",\n\t\".dbf\":                       \"application/vnd.dbf\",\n\t\".deb\":                       \"application/vnd.debian.binary-package\",\n\t\".uvf\":                       \"application/vnd.dece.data\",\n\t\".uvt\":                       \"application/vnd.dece.ttml+xml\",\n\t\".uvx\":                       \"application/vnd.dece.unspecified\",\n\t\".uvz\":                       \"application/vnd.dece.zip\",\n\t\".fe_launch\":                 \"application/vnd.denovo.fcselayout-link\",\n\t\".dsm\":                       \"application/vnd.desmume.movie\",\n\t\".dna\":                       \"application/vnd.dna\",\n\t\".docjson\":                   \"application/vnd.document+json\",\n\t\".scld\":                      \"application/vnd.doremir.scorecloud-binary-document\",\n\t\".dpg\":                       \"application/vnd.dpgraph\",\n\t\".dfac\":                      \"application/vnd.dreamfactory\",\n\t\".fla\":                       \"application/vnd.dtg.local.flash\",\n\t\".ait\":                       \"application/vnd.dvb.ait\",\n\t\".svc\":                       \"application/vnd.dvb.service\",\n\t\".geo\":                       \"application/vnd.dynageo\",\n\t\".dzr\":                       \"application/vnd.dzr\",\n\t\".mag\":                       \"application/vnd.ecowin.chart\",\n\t\".ELN\":                       \"application/vnd.eln+zip\",\n\t\".nml\":                       \"application/vnd.enliven\",\n\t\".esf\":                       \"application/vnd.epson.esf\",\n\t\".msf\":                       \"application/vnd.epson.msf\",\n\t\".qam\":                       \"application/vnd.epson.quickanime\",\n\t\".slt\":                       \"application/vnd.epson.salt\",\n\t\".ssf\":                       \"application/vnd.epson.ssf\",\n\t\".qcall\":                     \"application/vnd.ericsson.quickcall\",\n\t\".espass\":                    \"application/vnd.espass-espass+zip\",\n\t\".es3\":                       \"application/vnd.eszigno3+xml\",\n\t\".asice\":                     \"application/vnd.etsi.asic-e+zip\",\n\t\".asics\":                     \"application/vnd.etsi.asic-s+zip\",\n\t\".tst\":                       \"application/vnd.etsi.timestamp-token\",\n\t\".carjson\":                   \"application/vnd.eu.kasparian.car+json\",\n\t\".ecigprofile\":               \"application/vnd.evolv.ecig.profile\",\n\t\".ecig\":                      \"application/vnd.evolv.ecig.settings\",\n\t\".ecigtheme\":                 \"application/vnd.evolv.ecig.theme\",\n\t\".mpw\":                       \"application/vnd.exstream-empower+zip\",\n\t\".ez2\":                       \"application/vnd.ezpix-album\",\n\t\".ez3\":                       \"application/vnd.ezpix-package\",\n\t\".gdz\":                       \"application/vnd.familysearch.gedcom+zip\",\n\t\".dim\":                       \"application/vnd.fastcopy-disk-image\",\n\t\".msd\":                       \"application/vnd.fdsn.mseed\",\n\t\".seed\":                      \"application/vnd.fdsn.seed\",\n\t\".flb\":                       \"application/vnd.ficlab.flb+zip\",\n\t\".zfc\":                       \"application/vnd.filmit.zfc\",\n\t\".gph\":                       \"application/vnd.FloGraphIt\",\n\t\".ftc\":                       \"application/vnd.fluxtime.clip\",\n\t\".sfd\":                       \"application/vnd.font-fontforge-sfd\",\n\t\".fm\":                        \"application/vnd.framemaker\",\n\t\".fsc\":                       \"application/vnd.fsc.weblaunch\",\n\t\".oas\":                       \"application/vnd.fujitsu.oasys\",\n\t\".oa2\":                       \"application/vnd.fujitsu.oasys2\",\n\t\".oa3\":                       \"application/vnd.fujitsu.oasys3\",\n\t\".fg5\":                       \"application/vnd.fujitsu.oasysgp\",\n\t\".bh2\":                       \"application/vnd.fujitsu.oasysprs\",\n\t\".ddd\":                       \"application/vnd.fujixerox.ddd\",\n\t\".xdw\":                       \"application/vnd.fujixerox.docuworks\",\n\t\".xbd\":                       \"application/vnd.fujixerox.docuworks.binder\",\n\t\".xct\":                       \"application/vnd.fujixerox.docuworks.container\",\n\t\".fzs\":                       \"application/vnd.fuzzysheet\",\n\t\".txd\":                       \"application/vnd.genomatix.tuxedo\",\n\t\".genozip\":                   \"application/vnd.genozip\",\n\t\".grd\":                       \"application/vnd.gentics.grd+json\",\n\t\".ebuild\":                    \"application/vnd.gentoo.ebuild\",\n\t\".eclass\":                    \"application/vnd.gentoo.eclass\",\n\t\".gpkg.tar\":                  \"application/vnd.gentoo.gpkg\",\n\t\".xpak\":                      \"application/vnd.gentoo.xpak\",\n\t\".ggb\":                       \"application/vnd.geogebra.file\",\n\t\".ggs\":                       \"application/vnd.geogebra.slides\",\n\t\".ggt\":                       \"application/vnd.geogebra.tool\",\n\t\".gex\":                       \"application/vnd.geometry-explorer\",\n\t\".gxt\":                       \"application/vnd.geonext\",\n\t\".g2w\":                       \"application/vnd.geoplan\",\n\t\".g3w\":                       \"application/vnd.geospace\",\n\t\".kml\":                       \"application/vnd.google-earth.kml+xml\",\n\t\".kmz\":                       \"application/vnd.google-earth.kmz\",\n\t\".gqf\":                       \"application/vnd.grafeq\",\n\t\".gac\":                       \"application/vnd.groove-account\",\n\t\".ghf\":                       \"application/vnd.groove-help\",\n\t\".gim\":                       \"application/vnd.groove-identity-message\",\n\t\".grv\":                       \"application/vnd.groove-injector\",\n\t\".gtm\":                       \"application/vnd.groove-tool-message\",\n\t\".tpl\":                       \"application/vnd.groove-tool-template\",\n\t\".vcg\":                       \"application/vnd.groove-vcard\",\n\t\".hal\":                       \"application/vnd.hal+xml\",\n\t\".zmm\":                       \"application/vnd.HandHeld-Entertainment+xml\",\n\t\".hbci\":                      \"application/vnd.hbci\",\n\t\".hdt\":                       \"application/vnd.hdt\",\n\t\".les\":                       \"application/vnd.hhe.lesson-player\",\n\t\".hpgl\":                      \"application/vnd.hp-HPGL\",\n\t\".hpi\":                       \"application/vnd.hp-hpid\",\n\t\".hps\":                       \"application/vnd.hp-hps\",\n\t\".jlt\":                       \"application/vnd.hp-jlyt\",\n\t\".pcl\":                       \"application/vnd.hp-PCL\",\n\t\".hsl\":                       \"application/vnd.hsl\",\n\t\".sfd-hdstx\":                 \"application/vnd.hydrostatix.sof-data\",\n\t\".emm\":                       \"application/vnd.ibm.electronic-media\",\n\t\".mpy\":                       \"application/vnd.ibm.MiniPay\",\n\t\".irm\":                       \"application/vnd.ibm.rights-management\",\n\t\".icc\":                       \"application/vnd.iccprofile\",\n\t\".1905.1\":                    \"application/vnd.ieee.1905\",\n\t\".igl\":                       \"application/vnd.igloader\",\n\t\".imf\":                       \"application/vnd.imagemeter.folder+zip\",\n\t\".imi\":                       \"application/vnd.imagemeter.image+zip\",\n\t\".ivp\":                       \"application/vnd.immervision-ivp\",\n\t\".ivu\":                       \"application/vnd.immervision-ivu\",\n\t\".imscc\":                     \"application/vnd.ims.imsccv1p1\",\n\t\".igm\":                       \"application/vnd.insors.igm\",\n\t\".xpw\":                       \"application/vnd.intercon.formnet\",\n\t\".i2g\":                       \"application/vnd.intergeo\",\n\t\".qbo\":                       \"application/vnd.intu.qbo\",\n\t\".qfx\":                       \"application/vnd.intu.qfx\",\n\t\".ipns-record\":               \"application/vnd.ipfs.ipns-record\",\n\t\".car\":                       \"application/vnd.ipld.car\",\n\t\".rcprofile\":                 \"application/vnd.ipunplugged.rcprofile\",\n\t\".irp\":                       \"application/vnd.irepository.package+xml\",\n\t\".xpr\":                       \"application/vnd.is-xpr\",\n\t\".fcs\":                       \"application/vnd.isac.fcs\",\n\t\".jam\":                       \"application/vnd.jam\",\n\t\".rms\":                       \"application/vnd.jcp.javame.midlet-rms\",\n\t\".jisp\":                      \"application/vnd.jisp\",\n\t\".joda\":                      \"application/vnd.joost.joda-archive\",\n\t\".ktz\":                       \"application/vnd.kahootz\",\n\t\".karbon\":                    \"application/vnd.kde.karbon\",\n\t\".chrt\":                      \"application/vnd.kde.kchart\",\n\t\".kfo\":                       \"application/vnd.kde.kformula\",\n\t\".flw\":                       \"application/vnd.kde.kivio\",\n\t\".kon\":                       \"application/vnd.kde.kontour\",\n\t\".kpr\":                       \"application/vnd.kde.kpresenter\",\n\t\".ksp\":                       \"application/vnd.kde.kspread\",\n\t\".kwd\":                       \"application/vnd.kde.kword\",\n\t\".htke\":                      \"application/vnd.kenameaapp\",\n\t\".kia\":                       \"application/vnd.kidspiration\",\n\t\".kne\":                       \"application/vnd.Kinar\",\n\t\".skp\":                       \"application/vnd.koan\",\n\t\".sse\":                       \"application/vnd.kodak-descriptor\",\n\t\".las\":                       \"application/vnd.las\",\n\t\".lasjson\":                   \"application/vnd.las.las+json\",\n\t\".lasxml\":                    \"application/vnd.las.las+xml\",\n\t\".lbd\":                       \"application/vnd.llamagraphics.life-balance.desktop\",\n\t\".lbe\":                       \"application/vnd.llamagraphics.life-balance.exchange+xml\",\n\t\".lcs\":                       \"application/vnd.logipipe.circuit+zip\",\n\t\".loom\":                      \"application/vnd.loom\",\n\t\".123\":                       \"application/vnd.lotus-1-2-3\",\n\t\".apr\":                       \"application/vnd.lotus-approach\",\n\t\".prz\":                       \"application/vnd.lotus-freelance\",\n\t\".nsf\":                       \"application/vnd.lotus-notes\",\n\t\".or3\":                       \"application/vnd.lotus-organizer\",\n\t\".lwp\":                       \"application/vnd.lotus-wordpro\",\n\t\".portpkg\":                   \"application/vnd.macports.portpkg\",\n\t\".mvt\":                       \"application/vnd.mapbox-vector-tile\",\n\t\".mdc\":                       \"application/vnd.marlin.drm.mdcf\",\n\t\".3tz\":                       \"application/vnd.maxar.archive.3tz+zip\",\n\t\".mmdb\":                      \"application/vnd.maxmind.maxmind-db\",\n\t\".mcd\":                       \"application/vnd.mcd\",\n\t\".mdl\":                       \"application/vnd.mdl\",\n\t\".mbsdf\":                     \"application/vnd.mdl-mbsdf\",\n\t\".mc1\":                       \"application/vnd.medcalcdata\",\n\t\".cdkey\":                     \"application/vnd.mediastation.cdkey\",\n\t\".rxt\":                       \"application/vnd.medicalholodeck.recordxr\",\n\t\".mwf\":                       \"application/vnd.MFER\",\n\t\".mfm\":                       \"application/vnd.mfmp\",\n\t\".flo\":                       \"application/vnd.micrografx.flo\",\n\t\".igx\":                       \"application/vnd.micrografx.igx\",\n\t\".mif\":                       \"application/vnd.mif\",\n\t\".daf\":                       \"application/vnd.Mobius.DAF\",\n\t\".dis\":                       \"application/vnd.Mobius.DIS\",\n\t\".mbk\":                       \"application/vnd.Mobius.MBK\",\n\t\".mqy\":                       \"application/vnd.Mobius.MQY\",\n\t\".msl\":                       \"application/vnd.Mobius.MSL\",\n\t\".plc\":                       \"application/vnd.Mobius.PLC\",\n\t\".txf\":                       \"application/vnd.Mobius.TXF\",\n\t\".modl\":                      \"application/vnd.modl\",\n\t\".mpn\":                       \"application/vnd.mophun.application\",\n\t\".mpc\":                       \"application/vnd.mophun.certificate\",\n\t\".xul\":                       \"application/vnd.mozilla.xul+xml\",\n\t\".3mf\":                       \"application/vnd.ms-3mfdocument\",\n\t\".cil\":                       \"application/vnd.ms-artgalry\",\n\t\".asf\":                       \"application/vnd.ms-asf\",\n\t\".cab\":                       \"application/vnd.ms-cab-compressed\",\n\t\".xls\":                       \"application/vnd.ms-excel\",\n\t\".xlam\":                      \"application/vnd.ms-excel.addin.macroEnabled.12\",\n\t\".xlsb\":                      \"application/vnd.ms-excel.sheet.binary.macroEnabled.12\",\n\t\".xlsm\":                      \"application/vnd.ms-excel.sheet.macroEnabled.12\",\n\t\".xltm\":                      \"application/vnd.ms-excel.template.macroEnabled.12\",\n\t\".eot\":                       \"application/vnd.ms-fontobject\",\n\t\".chm\":                       \"application/vnd.ms-htmlhelp\",\n\t\".ims\":                       \"application/vnd.ms-ims\",\n\t\".lrm\":                       \"application/vnd.ms-lrm\",\n\t\".thmx\":                      \"application/vnd.ms-officetheme\",\n\t\".cat\":                       \"application/vnd.ms-pki.seccat\",\n\t\".ppt\":                       \"application/vnd.ms-powerpoint\",\n\t\".ppam\":                      \"application/vnd.ms-powerpoint.addin.macroEnabled.12\",\n\t\".pptm\":                      \"application/vnd.ms-powerpoint.presentation.macroEnabled.12\",\n\t\".sldm\":                      \"application/vnd.ms-powerpoint.slide.macroEnabled.12\",\n\t\".ppsm\":                      \"application/vnd.ms-powerpoint.slideshow.macroEnabled.12\",\n\t\".potm\":                      \"application/vnd.ms-powerpoint.template.macroEnabled.12\",\n\t\".mpp\":                       \"application/vnd.ms-project\",\n\t\".tnef\":                      \"application/vnd.ms-tnef\",\n\t\".docm\":                      \"application/vnd.ms-word.document.macroEnabled.12\",\n\t\".dotm\":                      \"application/vnd.ms-word.template.macroEnabled.12\",\n\t\".wcm\":                       \"application/vnd.ms-works\",\n\t\".wpl\":                       \"application/vnd.ms-wpl\",\n\t\".xps\":                       \"application/vnd.ms-xpsdocument\",\n\t\".msa\":                       \"application/vnd.msa-disk-image\",\n\t\".mseq\":                      \"application/vnd.mseq\",\n\t\".crtr\":                      \"application/vnd.multiad.creator\",\n\t\".cif\":                       \"application/vnd.multiad.creator.cif\",\n\t\".mus\":                       \"application/vnd.musician\",\n\t\".msty\":                      \"application/vnd.muvee.style\",\n\t\".taglet\":                    \"application/vnd.mynfc\",\n\t\".nebul\":                     \"application/vnd.nebumind.line\",\n\t\".entity\":                    \"application/vnd.nervana\",\n\t\".nlu\":                       \"application/vnd.neurolanguage.nlu\",\n\t\".nimn\":                      \"application/vnd.nimn\",\n\t\".nds\":                       \"application/vnd.nintendo.nitro.rom\",\n\t\".sfc\":                       \"application/vnd.nintendo.snes.rom\",\n\t\".nitf\":                      \"application/vnd.nitf\",\n\t\".nnd\":                       \"application/vnd.noblenet-directory\",\n\t\".nns\":                       \"application/vnd.noblenet-sealer\",\n\t\".nnw\":                       \"application/vnd.noblenet-web\",\n\t\".ngdat\":                     \"application/vnd.nokia.n-gage.data\",\n\t\".rpst\":                      \"application/vnd.nokia.radio-preset\",\n\t\".rpss\":                      \"application/vnd.nokia.radio-presets\",\n\t\".edm\":                       \"application/vnd.novadigm.EDM\",\n\t\".edx\":                       \"application/vnd.novadigm.EDX\",\n\t\".ext\":                       \"application/vnd.novadigm.EXT\",\n\t\".odb\":                       \"application/vnd.oasis.opendocument.base\",\n\t\".odc\":                       \"application/vnd.oasis.opendocument.chart\",\n\t\".otc\":                       \"application/vnd.oasis.opendocument.chart-template\",\n\t\".odf\":                       \"application/vnd.oasis.opendocument.formula\",\n\t\".odg\":                       \"application/vnd.oasis.opendocument.graphics\",\n\t\".otg\":                       \"application/vnd.oasis.opendocument.graphics-template\",\n\t\".odi\":                       \"application/vnd.oasis.opendocument.image\",\n\t\".oti\":                       \"application/vnd.oasis.opendocument.image-template\",\n\t\".odp\":                       \"application/vnd.oasis.opendocument.presentation\",\n\t\".otp\":                       \"application/vnd.oasis.opendocument.presentation-template\",\n\t\".ods\":                       \"application/vnd.oasis.opendocument.spreadsheet\",\n\t\".ots\":                       \"application/vnd.oasis.opendocument.spreadsheet-template\",\n\t\".odt\":                       \"application/vnd.oasis.opendocument.text\",\n\t\".odm\":                       \"application/vnd.oasis.opendocument.text-master\",\n\t\".otm\":                       \"application/vnd.oasis.opendocument.text-master-template\",\n\t\".ott\":                       \"application/vnd.oasis.opendocument.text-template\",\n\t\".oth\":                       \"application/vnd.oasis.opendocument.text-web\",\n\t\".xo\":                        \"application/vnd.olpc-sugar\",\n\t\".dd2\":                       \"application/vnd.oma.dd2+xml\",\n\t\".tam\":                       \"application/vnd.onepager\",\n\t\".tamp\":                      \"application/vnd.onepagertamp\",\n\t\".tamx\":                      \"application/vnd.onepagertamx\",\n\t\".tat\":                       \"application/vnd.onepagertat\",\n\t\".tatp\":                      \"application/vnd.onepagertatp\",\n\t\".tatx\":                      \"application/vnd.onepagertatx\",\n\t\".obgx\":                      \"application/vnd.openblox.game+xml\",\n\t\".obg\":                       \"application/vnd.openblox.game-binary\",\n\t\".oeb\":                       \"application/vnd.openeye.oeb\",\n\t\".oxt\":                       \"application/vnd.openofficeorg.extension\",\n\t\".osm\":                       \"application/vnd.openstreetmap.data+xml\",\n\t\".exe\":                       \"application/vnd.microsoft.portable-executable\",\n\t\".dll\":                       \"application/vnd.microsoft.portable-executable\",\n\t\".pptx\":                      \"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n\t\".sldx\":                      \"application/vnd.openxmlformats-officedocument.presentationml.slide\",\n\t\".ppsx\":                      \"application/vnd.openxmlformats-officedocument.presentationml.slideshow\",\n\t\".potx\":                      \"application/vnd.openxmlformats-officedocument.presentationml.template\",\n\t\".xlsx\":                      \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n\t\".xltx\":                      \"application/vnd.openxmlformats-officedocument.spreadsheetml.template\",\n\t\".docx\":                      \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n\t\".dotx\":                      \"application/vnd.openxmlformats-officedocument.wordprocessingml.template\",\n\t\".ndc\":                       \"application/vnd.osa.netdeploy\",\n\t\".mgp\":                       \"application/vnd.osgeo.mapguide.package\",\n\t\".dp\":                        \"application/vnd.osgi.dp\",\n\t\".esa\":                       \"application/vnd.osgi.subsystem\",\n\t\".oxlicg\":                    \"application/vnd.oxli.countgraph\",\n\t\".pdb\":                       \"application/vnd.palm\",\n\t\".plp\":                       \"application/vnd.panoply\",\n\t\".dive\":                      \"application/vnd.patentdive\",\n\t\".paw\":                       \"application/vnd.pawaafile\",\n\t\".str\":                       \"application/vnd.pg.format\",\n\t\".ei6\":                       \"application/vnd.pg.osasli\",\n\t\".pil\":                       \"application/vnd.piaccess.application-licence\",\n\t\".efif\":                      \"application/vnd.picsel\",\n\t\".wg\":                        \"application/vnd.pmi.widget\",\n\t\".plf\":                       \"application/vnd.pocketlearn\",\n\t\".pbd\":                       \"application/vnd.powerbuilder6\",\n\t\".preminet\":                  \"application/vnd.preminet\",\n\t\".box\":                       \"application/vnd.previewsystems.box\",\n\t\".mgz\":                       \"application/vnd.proteus.magazine\",\n\t\".psfs\":                      \"application/vnd.psfs\",\n\t\".qps\":                       \"application/vnd.publishare-delta-tree\",\n\t\".ptid\":                      \"application/vnd.pvi.ptid1\",\n\t\".bar\":                       \"application/vnd.qualcomm.brew-app-res\",\n\t\".qxd\":                       \"application/vnd.Quark.QuarkXPress\",\n\t\".quox\":                      \"application/vnd.quobject-quoxdocument\",\n\t\".tree\":                      \"application/vnd.rainstor.data\",\n\t\".rar\":                       \"application/vnd.rar\",\n\t\".bed\":                       \"application/vnd.realvnc.bed\",\n\t\".mxl\":                       \"application/vnd.recordare.musicxml\",\n\t\".rlm\":                       \"application/vnd.resilient.logic\",\n\t\".cryptonote\":                \"application/vnd.rig.cryptonote\",\n\t\".cod\":                       \"application/vnd.rim.cod\",\n\t\".link66\":                    \"application/vnd.route66.link66+xml\",\n\t\".st\":                        \"application/vnd.sailingtracker.track\",\n\t\".SAR\":                       \"application/vnd.sar\",\n\t\".scd\":                       \"application/vnd.scribus\",\n\t\".s3df\":                      \"application/vnd.sealed.3df\",\n\t\".scsf\":                      \"application/vnd.sealed.csf\",\n\t\".sdoc\":                      \"application/vnd.sealed.doc\",\n\t\".seml\":                      \"application/vnd.sealed.eml\",\n\t\".smht\":                      \"application/vnd.sealed.mht\",\n\t\".sppt\":                      \"application/vnd.sealed.ppt\",\n\t\".stif\":                      \"application/vnd.sealed.tiff\",\n\t\".sxls\":                      \"application/vnd.sealed.xls\",\n\t\".stml\":                      \"application/vnd.sealedmedia.softseal.html\",\n\t\".spdf\":                      \"application/vnd.sealedmedia.softseal.pdf\",\n\t\".see\":                       \"application/vnd.seemail\",\n\t\".sema\":                      \"application/vnd.sema\",\n\t\".semd\":                      \"application/vnd.semd\",\n\t\".semf\":                      \"application/vnd.semf\",\n\t\".ssv\":                       \"application/vnd.shade-save-file\",\n\t\".ifm\":                       \"application/vnd.shana.informed.formdata\",\n\t\".itp\":                       \"application/vnd.shana.informed.formtemplate\",\n\t\".iif\":                       \"application/vnd.shana.informed.interchange\",\n\t\".ipk\":                       \"application/vnd.shana.informed.package\",\n\t\".shp\":                       \"application/vnd.shp\",\n\t\".shx\":                       \"application/vnd.shx\",\n\t\".sr\":                        \"application/vnd.sigrok.session\",\n\t\".twd\":                       \"application/vnd.SimTech-MindMapper\",\n\t\".mmf\":                       \"application/vnd.smaf\",\n\t\".notebook\":                  \"application/vnd.smart.notebook\",\n\t\".teacher\":                   \"application/vnd.smart.teacher\",\n\t\".sipa\":                      \"application/vnd.smintio.portals.archive\",\n\t\".ptrom\":                     \"application/vnd.snesdev-page-table\",\n\t\".fo\":                        \"application/vnd.software602.filler.form+xml\",\n\t\".zfo\":                       \"application/vnd.software602.filler.form-xml-zip\",\n\t\".sdkm\":                      \"application/vnd.solent.sdkm+xml\",\n\t\".dxp\":                       \"application/vnd.spotfire.dxp\",\n\t\".sfs\":                       \"application/vnd.spotfire.sfs\",\n\t\".sqlite\":                    \"application/vnd.sqlite3\",\n\t\".sdc\":                       \"application/vnd.stardivision.calc\",\n\t\".sds\":                       \"application/vnd.stardivision.chart\",\n\t\".sda\":                       \"application/vnd.stardivision.draw\",\n\t\".sdd\":                       \"application/vnd.stardivision.impress\",\n\t\".smf\":                       \"application/vnd.stardivision.math\",\n\t\".sdw\":                       \"application/vnd.stardivision.writer\",\n\t\".sgl\":                       \"application/vnd.stardivision.writer-global\",\n\t\".smzip\":                     \"application/vnd.stepmania.package\",\n\t\".sm\":                        \"application/vnd.stepmania.stepchart\",\n\t\".wadl\":                      \"application/vnd.sun.wadl+xml\",\n\t\".sxc\":                       \"application/vnd.sun.xml.calc\",\n\t\".stc\":                       \"application/vnd.sun.xml.calc.template\",\n\t\".sxd\":                       \"application/vnd.sun.xml.draw\",\n\t\".std\":                       \"application/vnd.sun.xml.draw.template\",\n\t\".sxi\":                       \"application/vnd.sun.xml.impress\",\n\t\".sti\":                       \"application/vnd.sun.xml.impress.template\",\n\t\".sxm\":                       \"application/vnd.sun.xml.math\",\n\t\".sxw\":                       \"application/vnd.sun.xml.writer\",\n\t\".sxg\":                       \"application/vnd.sun.xml.writer.global\",\n\t\".stw\":                       \"application/vnd.sun.xml.writer.template\",\n\t\".sus\":                       \"application/vnd.sus-calendar\",\n\t\".ml2\":                       \"application/vnd.sybyl.mol2\",\n\t\".scl\":                       \"application/vnd.sycle+xml\",\n\t\".syft.json\":                 \"application/vnd.syft+json\",\n\t\".sis\":                       \"application/vnd.symbian.install\",\n\t\".xsm\":                       \"application/vnd.syncml+xml\",\n\t\".bdm\":                       \"application/vnd.syncml.dm+wbxml\",\n\t\".xdm\":                       \"application/vnd.syncml.dm+xml\",\n\t\".ddf\":                       \"application/vnd.syncml.dmddf+xml\",\n\t\".tao\":                       \"application/vnd.tao.intent-module-archive\",\n\t\".pcap\":                      \"application/vnd.tcpdump.pcap\",\n\t\".qvd\":                       \"application/vnd.theqvd\",\n\t\".ppttc\":                     \"application/vnd.think-cell.ppttc+json\",\n\t\".vfr\":                       \"application/vnd.tml\",\n\t\".tmo\":                       \"application/vnd.tmobile-livetv\",\n\t\".tpt\":                       \"application/vnd.trid.tpt\",\n\t\".mxs\":                       \"application/vnd.triscape.mxs\",\n\t\".tra\":                       \"application/vnd.trueapp\",\n\t\".ufdl\":                      \"application/vnd.ufdl\",\n\t\".utz\":                       \"application/vnd.uiq.theme\",\n\t\".umj\":                       \"application/vnd.umajin\",\n\t\".unityweb\":                  \"application/vnd.unity\",\n\t\".uoml\":                      \"application/vnd.uoml+xml\",\n\t\".urim\":                      \"application/vnd.uri-map\",\n\t\".vmt\":                       \"application/vnd.valve.source.material\",\n\t\".vcx\":                       \"application/vnd.vcx\",\n\t\".mxi\":                       \"application/vnd.vd-study\",\n\t\".vwx\":                       \"application/vnd.vectorworks\",\n\t\".aion\":                      \"application/vnd.veritone.aion+json\",\n\t\".istc\":                      \"application/vnd.veryant.thin\",\n\t\".VES\":                       \"application/vnd.ves.encrypted\",\n\t\".vsc\":                       \"application/vnd.vidsoft.vidconference\",\n\t\".vsd\":                       \"application/vnd.visio\",\n\t\".vis\":                       \"application/vnd.visionary\",\n\t\".vsf\":                       \"application/vnd.vsf\",\n\t\".sic\":                       \"application/vnd.wap.sic\",\n\t\".slc\":                       \"application/vnd.wap.slc\",\n\t\".wbxml\":                     \"application/vnd.wap.wbxml\",\n\t\".wmlc\":                      \"application/vnd.wap.wmlc\",\n\t\".wmlsc\":                     \"application/vnd.wap.wmlscriptc\",\n\t\".wafl\":                      \"application/vnd.wasmflow.wafl\",\n\t\".wtb\":                       \"application/vnd.webturbo\",\n\t\".p2p\":                       \"application/vnd.wfa.p2p\",\n\t\".wsc\":                       \"application/vnd.wfa.wsc\",\n\t\".wmc\":                       \"application/vnd.wmc\",\n\t\".nb\":                        \"application/vnd.wolfram.mathematica\",\n\t\".nbp\":                       \"application/vnd.wolfram.player\",\n\t\".wpd\":                       \"application/vnd.wordperfect\",\n\t\".wqd\":                       \"application/vnd.wqd\",\n\t\".stf\":                       \"application/vnd.wt.stf\",\n\t\".wv\":                        \"application/vnd.wv.csp+wbxml\",\n\t\".xar\":                       \"application/vnd.xara\",\n\t\".xfdl\":                      \"application/vnd.xfdl\",\n\t\".cpkg\":                      \"application/vnd.xmpie.cpkg\",\n\t\".dpkg\":                      \"application/vnd.xmpie.dpkg\",\n\t\".ppkg\":                      \"application/vnd.xmpie.ppkg\",\n\t\".xlim\":                      \"application/vnd.xmpie.xlim\",\n\t\".hvd\":                       \"application/vnd.yamaha.hv-dic\",\n\t\".hvs\":                       \"application/vnd.yamaha.hv-script\",\n\t\".hvp\":                       \"application/vnd.yamaha.hv-voice\",\n\t\".osf\":                       \"application/vnd.yamaha.openscoreformat\",\n\t\".saf\":                       \"application/vnd.yamaha.smaf-audio\",\n\t\".spf\":                       \"application/vnd.yamaha.smaf-phrase\",\n\t\".yme\":                       \"application/vnd.yaoweme\",\n\t\".cmp\":                       \"application/vnd.yellowriver-custom-menu\",\n\t\".zir\":                       \"application/vnd.zul\",\n\t\".zaz\":                       \"application/vnd.zzazz.deck+xml\",\n\t\".vxml\":                      \"application/voicexml+xml\",\n\t\".vcj\":                       \"application/voucher-cms+json\",\n\t\".wasm\":                      \"application/wasm\",\n\t\".wif\":                       \"application/watcherinfo+xml\",\n\t\".wgt\":                       \"application/widget\",\n\t\".wsdl\":                      \"application/wsdl+xml\",\n\t\".wspolicy\":                  \"application/wspolicy+xml\",\n\t\".wk\":                        \"application/x-123\",\n\t\".7z\":                        \"application/x-7z-compressed\",\n\t\".abw\":                       \"application/x-abiword\",\n\t\".dmg\":                       \"application/x-apple-diskimage\",\n\t\".bcpio\":                     \"application/x-bcpio\",\n\t\".torrent\":                   \"application/x-bittorrent\",\n\t\".cdf\":                       \"application/x-cdf\",\n\t\".vcd\":                       \"application/x-cdlink\",\n\t\".mph\":                       \"application/x-comsol\",\n\t\".cpio\":                      \"application/x-cpio\",\n\t\".dcr\":                       \"application/x-director\",\n\t\".wad\":                       \"application/x-doom\",\n\t\".dvi\":                       \"application/x-dvi\",\n\t\".pfa\":                       \"application/x-font\",\n\t\".pcf\":                       \"application/x-font-pcf\",\n\t\".mm\":                        \"application/x-freemind\",\n\t\".gan\":                       \"application/x-ganttproject\",\n\t\".gnumeric\":                  \"application/x-gnumeric\",\n\t\".sgf\":                       \"application/x-go-sgf\",\n\t\".gcf\":                       \"application/x-graphing-calculator\",\n\t\".gtar\":                      \"application/x-gtar\",\n\t\".tgz\":                       \"application/x-gtar-compressed\",\n\t\".hdf\":                       \"application/x-hdf\",\n\t\".pem\":                       \"application/x-pem-file\",\n\t\".php\":                       \"application/x-php\",\n\t\".hwp\":                       \"application/x-hwp\",\n\t\".ica\":                       \"application/x-ica\",\n\t\".info\":                      \"application/x-info\",\n\t\".ins\":                       \"application/x-internet-signup\",\n\t\".iii\":                       \"application/x-iphone\",\n\t\".iso\":                       \"application/x-iso9660-image\",\n\t\".jnlp\":                      \"application/x-java-jnlp-file\",\n\t\".jmz\":                       \"application/x-jmol\",\n\t\".kil\":                       \"application/x-killustrator\",\n\t\".latex\":                     \"application/x-latex\",\n\t\".lha\":                       \"application/x-lha\",\n\t\".lyx\":                       \"application/x-lyx\",\n\t\".lzh\":                       \"application/x-lzh\",\n\t\".lzx\":                       \"application/x-lzx\",\n\t\".frm\":                       \"application/x-maker\",\n\t\".wmd\":                       \"application/x-ms-wmd\",\n\t\".wmz\":                       \"application/x-ms-wmz\",\n\t\".com\":                       \"application/x-msdos-program\",\n\t\".msi\":                       \"application/x-msi\",\n\t\".nc\":                        \"application/x-netcdf\",\n\t\".pac\":                       \"application/x-ns-proxy-autoconfig\",\n\t\".nwc\":                       \"application/x-nwc\",\n\t\".o\":                         \"application/x-object\",\n\t\".oza\":                       \"application/x-oz-application\",\n\t\".p7r\":                       \"application/x-pkcs7-certreqresp\",\n\t\".pyc\":                       \"application/x-python-code\",\n\t\".qgs\":                       \"application/x-qgis\",\n\t\".qtl\":                       \"application/x-quicktimeplayer\",\n\t\".rdp\":                       \"application/x-rdp\",\n\t\".rpm\":                       \"application/x-redhat-package-manager\",\n\t\".rss\":                       \"application/x-rss+xml\",\n\t\".rb\":                        \"application/x-ruby\",\n\t\".erb\":                       \"application/x-ruby\",\n\t\".sci\":                       \"application/x-scilab\",\n\t\".xcos\":                      \"application/x-scilab-xcos\",\n\t\".sh\":                        \"application/x-sh\",\n\t\".shar\":                      \"application/x-shar\",\n\t\".scr\":                       \"application/x-silverlight\",\n\t\".sit\":                       \"application/x-stuffit\",\n\t\".sv4cpio\":                   \"application/x-sv4cpio\",\n\t\".sv4crc\":                    \"application/x-sv4crc\",\n\t\".tar\":                       \"application/x-tar\",\n\t\".gf\":                        \"application/x-tex-gf\",\n\t\".pk\":                        \"application/x-tex-pk\",\n\t\".texinfo\":                   \"application/x-texinfo\",\n\t\".~\":                         \"application/x-trash\",\n\t\".man\":                       \"application/x-troff-man\",\n\t\".me\":                        \"application/x-troff-me\",\n\t\".ms\":                        \"application/x-troff-ms\",\n\t\".ustar\":                     \"application/x-ustar\",\n\t\".src\":                       \"application/x-wais-source\",\n\t\".wz\":                        \"application/x-wingz\",\n\t\".crt\":                       \"application/x-x509-ca-cert\",\n\t\".fig\":                       \"application/x-xfig\",\n\t\".xpi\":                       \"application/x-xpinstall\",\n\t\".xz\":                        \"application/x-xz\",\n\t\".xav\":                       \"application/xcap-att+xml\",\n\t\".xca\":                       \"application/xcap-caps+xml\",\n\t\".xdf\":                       \"application/xcap-diff+xml\",\n\t\".xel\":                       \"application/xcap-el+xml\",\n\t\".xer\":                       \"application/xcap-error+xml\",\n\t\".xns\":                       \"application/xcap-ns+xml\",\n\t\".xfdf\":                      \"application/xfdf\",\n\t\".xhtml\":                     \"application/xhtml+xml\",\n\t\".xlf\":                       \"application/xliff+xml\",\n\t\".xml\":                       \"application/xml\",\n\t\".dtd\":                       \"application/xml-dtd\",\n\t\".ent\":                       \"application/xml-external-parsed-entity\",\n\t\".xop\":                       \"application/xop+xml\",\n\t\".xsl\":                       \"application/xslt+xml\",\n\t\".xspf\":                      \"application/xspf+xml\",\n\t\".mxml\":                      \"application/xv+xml\",\n\t\".yaml\":                      \"application/x-yaml\",\n\t\".yml\":                       \"application/x-yaml\",\n\t\".yang\":                      \"application/yang\",\n\t\".yin\":                       \"application/yin+xml\",\n\t\".zip\":                       \"application/zip\",\n\t\".zst\":                       \"application/zstd\",\n\t\".726\":                       \"audio/32kadpcm\",\n\t\".adts\":                      \"audio/aac\",\n\t\".ac3\":                       \"audio/ac3\",\n\t\".amr\":                       \"audio/AMR\",\n\t\".awb\":                       \"audio/AMR-WB\",\n\t\".axa\":                       \"audio/annodex\",\n\t\".acn\":                       \"audio/asc\",\n\t\".aal\":                       \"audio/ATRAC-ADVANCED-LOSSLESS\",\n\t\".atx\":                       \"audio/ATRAC-X\",\n\t\".at3\":                       \"audio/ATRAC3\",\n\t\".au\":                        \"audio/basic\",\n\t\".csd\":                       \"audio/csound\",\n\t\".dls\":                       \"audio/dls\",\n\t\".evc\":                       \"audio/EVRC\",\n\t\".qcp\":                       \"audio/EVRC-QCP\",\n\t\".evb\":                       \"audio/EVRCB\",\n\t\".enw\":                       \"audio/EVRCNW\",\n\t\".evw\":                       \"audio/EVRCWB\",\n\t\".flac\":                      \"audio/flac\",\n\t\".lbc\":                       \"audio/iLBC\",\n\t\".l16\":                       \"audio/L16\",\n\t\".mhas\":                      \"audio/mhas\",\n\t\".mxmf\":                      \"audio/mobile-xmf\",\n\t\".m4a\":                       \"audio/mp4\",\n\t\".mpga\":                      \"audio/mpeg\",\n\t\".m3u\":                       \"audio/mpegurl\",\n\t\".oga\":                       \"audio/ogg\",\n\t\".sid\":                       \"audio/prs.sid\",\n\t\".smv\":                       \"audio/SMV\",\n\t\".sofa\":                      \"audio/sofa\",\n\t\".mid\":                       \"audio/sp-midi\",\n\t\".loas\":                      \"audio/usac\",\n\t\".koz\":                       \"audio/vnd.audiokoz\",\n\t\".uva\":                       \"audio/vnd.dece.audio\",\n\t\".eol\":                       \"audio/vnd.digital-winds\",\n\t\".mlp\":                       \"audio/vnd.dolby.mlp\",\n\t\".dts\":                       \"audio/vnd.dts\",\n\t\".dtshd\":                     \"audio/vnd.dts.hd\",\n\t\".plj\":                       \"audio/vnd.everad.plj\",\n\t\".lvp\":                       \"audio/vnd.lucent.voice\",\n\t\".pya\":                       \"audio/vnd.ms-playready.media.pya\",\n\t\".vbk\":                       \"audio/vnd.nortel.vbk\",\n\t\".ecelp4800\":                 \"audio/vnd.nuera.ecelp4800\",\n\t\".ecelp7470\":                 \"audio/vnd.nuera.ecelp7470\",\n\t\".ecelp9600\":                 \"audio/vnd.nuera.ecelp9600\",\n\t\".multitrack\":                \"audio/vnd.presonus.multitrack\",\n\t\".rip\":                       \"audio/vnd.rip\",\n\t\".smp3\":                      \"audio/vnd.sealedmedia.softseal.mpeg\",\n\t\".aif\":                       \"audio/x-aiff\",\n\t\".gsm\":                       \"audio/x-gsm\",\n\t\".wax\":                       \"audio/x-ms-wax\",\n\t\".wma\":                       \"audio/x-ms-wma\",\n\t\".ra\":                        \"audio/x-pn-realaudio\",\n\t\".pls\":                       \"audio/x-scpls\",\n\t\".sd2\":                       \"audio/x-sd2\",\n\t\".wav\":                       \"audio/x-wav\",\n\t\".alc\":                       \"chemical/x-alchemy\",\n\t\".cac\":                       \"chemical/x-cache\",\n\t\".csf\":                       \"chemical/x-cache-csf\",\n\t\".cbin\":                      \"chemical/x-cactvs-binary\",\n\t\".cdx\":                       \"chemical/x-cdx\",\n\t\".c3d\":                       \"chemical/x-chem3d\",\n\t\".cmdf\":                      \"chemical/x-cmdf\",\n\t\".cml\":                       \"chemical/x-cml\",\n\t\".cpa\":                       \"chemical/x-compass\",\n\t\".bsd\":                       \"chemical/x-crossfire\",\n\t\".csml\":                      \"chemical/x-csml\",\n\t\".ctx\":                       \"chemical/x-ctx\",\n\t\".cxf\":                       \"chemical/x-cxf\",\n\t\".smi\":                       \"#chemical/x-daylight-smiles\",\n\t\".emb\":                       \"chemical/x-embl-dl-nucleotide\",\n\t\".spc\":                       \"chemical/x-galactic-spc\",\n\t\".inp\":                       \"chemical/x-gamess-input\",\n\t\".fch\":                       \"chemical/x-gaussian-checkpoint\",\n\t\".cub\":                       \"chemical/x-gaussian-cube\",\n\t\".gau\":                       \"chemical/x-gaussian-input\",\n\t\".gal\":                       \"chemical/x-gaussian-log\",\n\t\".gcg\":                       \"chemical/x-gcg8-sequence\",\n\t\".gen\":                       \"chemical/x-genbank\",\n\t\".hin\":                       \"chemical/x-hin\",\n\t\".istr\":                      \"chemical/x-isostar\",\n\t\".jdx\":                       \"chemical/x-jcamp-dx\",\n\t\".kin\":                       \"chemical/x-kinemage\",\n\t\".mcm\":                       \"chemical/x-macmolecule\",\n\t\".mmod\":                      \"chemical/x-macromodel-input\",\n\t\".mol\":                       \"chemical/x-mdl-molfile\",\n\t\".rd\":                        \"chemical/x-mdl-rdfile\",\n\t\".rxn\":                       \"chemical/x-mdl-rxnfile\",\n\t\".sd\":                        \"chemical/x-mdl-sdfile\",\n\t\".tgf\":                       \"chemical/x-mdl-tgf\",\n\t\".mcif\":                      \"chemical/x-mmcif\",\n\t\".b\":                         \"chemical/x-molconn-Z\",\n\t\".gpt\":                       \"chemical/x-mopac-graph\",\n\t\".mop\":                       \"chemical/x-mopac-input\",\n\t\".moo\":                       \"chemical/x-mopac-out\",\n\t\".mvb\":                       \"chemical/x-mopac-vib\",\n\t\".asn\":                       \"chemical/x-ncbi-asn1\",\n\t\".prt\":                       \"chemical/x-ncbi-asn1-ascii\",\n\t\".val\":                       \"chemical/x-ncbi-asn1-binary\",\n\t\".ros\":                       \"chemical/x-rosdal\",\n\t\".sw\":                        \"chemical/x-swissprot\",\n\t\".vms\":                       \"chemical/x-vamas-iso14976\",\n\t\".vmd\":                       \"chemical/x-vmd\",\n\t\".xtel\":                      \"chemical/x-xtel\",\n\t\".xyz\":                       \"chemical/x-xyz\",\n\t\".ttc\":                       \"font/collection\",\n\t\".otf\":                       \"font/otf\",\n\t\".ttf\":                       \"font/ttf\",\n\t\".woff\":                      \"font/woff\",\n\t\".woff2\":                     \"font/woff2\",\n\t\".exr\":                       \"image/aces\",\n\t\".apng\":                      \"image/apng\",\n\t\".avci\":                      \"image/avci\",\n\t\".avcs\":                      \"image/avcs\",\n\t\".avif\":                      \"image/avif\",\n\t\".bmp\":                       \"image/bmp\",\n\t\".cgm\":                       \"image/cgm\",\n\t\".drle\":                      \"image/dicom-rle\",\n\t\".dpx\":                       \"image/dpx\",\n\t\".emf\":                       \"image/emf\",\n\t\".fits\":                      \"image/fits\",\n\t\".gif\":                       \"image/gif\",\n\t\".heic\":                      \"image/heic\",\n\t\".heics\":                     \"image/heic-sequence\",\n\t\".heif\":                      \"image/heif\",\n\t\".heifs\":                     \"image/heif-sequence\",\n\t\".hej2\":                      \"image/hej2k\",\n\t\".hsj2\":                      \"image/hsj2\",\n\t\".ief\":                       \"image/ief\",\n\t\".j2c\":                       \"image/j2c\",\n\t\".jls\":                       \"image/jls\",\n\t\".jp2\":                       \"image/jp2\",\n\t\".jpeg\":                      \"image/jpeg\",\n\t\".jph\":                       \"image/jph\",\n\t\".jhc\":                       \"image/jphc\",\n\t\".jpm\":                       \"image/jpm\",\n\t\".jpx\":                       \"image/jpx\",\n\t\".jxl\":                       \"image/jxl\",\n\t\".jxr\":                       \"image/jxr\",\n\t\".jxra\":                      \"image/jxrA\",\n\t\".jxrs\":                      \"image/jxrS\",\n\t\".jxs\":                       \"image/jxs\",\n\t\".jxsc\":                      \"image/jxsc\",\n\t\".jxsi\":                      \"image/jxsi\",\n\t\".jxss\":                      \"image/jxss\",\n\t\".ktx\":                       \"image/ktx\",\n\t\".ktx2\":                      \"image/ktx2\",\n\t\".png\":                       \"image/png\",\n\t\".btif\":                      \"image/prs.btif\",\n\t\".pti\":                       \"image/prs.pti\",\n\t\".svg\":                       \"image/svg+xml\",\n\t\".tiff\":                      \"image/tiff\",\n\t\".tfx\":                       \"image/tiff-fx\",\n\t\".psd\":                       \"image/vnd.adobe.photoshop\",\n\t\".azv\":                       \"image/vnd.airzip.accelerator.azv\",\n\t\".uvi\":                       \"image/vnd.dece.graphic\",\n\t\".djvu\":                      \"image/vnd.djvu\",\n\t\".dwg\":                       \"image/vnd.dwg\",\n\t\".dxf\":                       \"image/vnd.dxf\",\n\t\".fbs\":                       \"image/vnd.fastbidsheet\",\n\t\".fpx\":                       \"image/vnd.fpx\",\n\t\".fst\":                       \"image/vnd.fst\",\n\t\".mmr\":                       \"image/vnd.fujixerox.edmics-mmr\",\n\t\".rlc\":                       \"image/vnd.fujixerox.edmics-rlc\",\n\t\".PGB\":                       \"image/vnd.globalgraphics.pgb\",\n\t\".ico\":                       \"image/vnd.microsoft.icon\",\n\t\".mdi\":                       \"image/vnd.ms-modi\",\n\t\".b16\":                       \"image/vnd.pco.b16\",\n\t\".hdr\":                       \"image/vnd.radiance\",\n\t\".spng\":                      \"image/vnd.sealed.png\",\n\t\".sgif\":                      \"image/vnd.sealedmedia.softseal.gif\",\n\t\".sjpg\":                      \"image/vnd.sealedmedia.softseal.jpg\",\n\t\".tap\":                       \"image/vnd.tencent.tap\",\n\t\".vtf\":                       \"image/vnd.valve.source.texture\",\n\t\".wbmp\":                      \"image/vnd.wap.wbmp\",\n\t\".xif\":                       \"image/vnd.xiff\",\n\t\".pcx\":                       \"image/vnd.zbrush.pcx\",\n\t\".webp\":                      \"image/webp\",\n\t\".wmf\":                       \"image/wmf\",\n\t\".cr2\":                       \"image/x-canon-cr2\",\n\t\".crw\":                       \"image/x-canon-crw\",\n\t\".ras\":                       \"image/x-cmu-raster\",\n\t\".cdr\":                       \"image/x-coreldraw\",\n\t\".pat\":                       \"image/x-coreldrawpattern\",\n\t\".cdt\":                       \"image/x-coreldrawtemplate\",\n\t\".erf\":                       \"image/x-epson-erf\",\n\t\".art\":                       \"image/x-jg\",\n\t\".jng\":                       \"image/x-jng\",\n\t\".nef\":                       \"image/x-nikon-nef\",\n\t\".orf\":                       \"image/x-olympus-orf\",\n\t\".pnm\":                       \"image/x-portable-anymap\",\n\t\".pbm\":                       \"image/x-portable-bitmap\",\n\t\".pgm\":                       \"image/x-portable-graymap\",\n\t\".ppm\":                       \"image/x-portable-pixmap\",\n\t\".rgb\":                       \"image/x-rgb\",\n\t\".xbm\":                       \"image/x-xbitmap\",\n\t\".xcf\":                       \"image/x-xcf\",\n\t\".xpm\":                       \"image/x-xpixmap\",\n\t\".xwd\":                       \"image/x-xwindowdump\",\n\t\".u8msg\":                     \"message/global\",\n\t\".u8dsn\":                     \"message/global-delivery-status\",\n\t\".u8mdn\":                     \"message/global-disposition-notification\",\n\t\".u8hdr\":                     \"message/global-headers\",\n\t\".eml\":                       \"message/rfc822\",\n\t\".gltf\":                      \"model/gltf+json\",\n\t\".glb\":                       \"model/gltf-binary\",\n\t\".igs\":                       \"model/iges\",\n\t\".jt\":                        \"model/JT\",\n\t\".msh\":                       \"model/mesh\",\n\t\".mtl\":                       \"model/mtl\",\n\t\".obj\":                       \"model/obj\",\n\t\".prc\":                       \"model/prc\",\n\t\".stp\":                       \"model/step\",\n\t\".stpx\":                      \"model/step+xml\",\n\t\".stpz\":                      \"model/step+zip\",\n\t\".stpxz\":                     \"model/step-xml+zip\",\n\t\".stl\":                       \"model/stl\",\n\t\".u3d\":                       \"model/u3d\",\n\t\".bary\":                      \"model/vnd.bary\",\n\t\".cld\":                       \"model/vnd.cld\",\n\t\".dae\":                       \"model/vnd.collada+xml\",\n\t\".dwf\":                       \"model/vnd.dwf\",\n\t\".gdl\":                       \"model/vnd.gdl\",\n\t\".gtw\":                       \"model/vnd.gtw\",\n\t\".moml\":                      \"model/vnd.moml+xml\",\n\t\".mts\":                       \"model/vnd.mts\",\n\t\".ogex\":                      \"model/vnd.opengex\",\n\t\".x_b\":                       \"model/vnd.parasolid.transmit.binary\",\n\t\".x_t\":                       \"model/vnd.parasolid.transmit.text\",\n\t\".pyox\":                      \"model/vnd.pytha.pyox\",\n\t\".vds\":                       \"model/vnd.sap.vds\",\n\t\".usda\":                      \"model/vnd.usda\",\n\t\".usdz\":                      \"model/vnd.usdz+zip\",\n\t\".bsp\":                       \"model/vnd.valve.source.compiled-map\",\n\t\".vtu\":                       \"model/vnd.vtu\",\n\t\".wrl\":                       \"model/vrml\",\n\t\".x3db\":                      \"model/x3d+fastinfoset\",\n\t\".x3d\":                       \"model/x3d+xml\",\n\t\".x3dv\":                      \"model/x3d-vrml\",\n\t\".bmed\":                      \"multipart/vnd.bint.med-plus\",\n\t\".vpm\":                       \"multipart/voice-message\",\n\t\"ahk\":                        \"text/autohotkey\",\n\t\"au3\":                        \"text/autohotkey\",\n\t\".appcache\":                  \"text/cache-manifest\",\n\t\".ics\":                       \"text/calendar\",\n\t\"cof\":                        \"text/coffeescript\",\n\t\"coffee\":                     \"text/coffeescript\",\n\t\"coffeescript\":               \"text/coffeescript\",\n\t\".CQL\":                       \"text/cql\",\n\t\".css\":                       \"text/css\",\n\t\".csv\":                       \"text/csv\",\n\t\".csvs\":                      \"text/csv-schema\",\n\t\".soa\":                       \"text/dns\",\n\t\".gff3\":                      \"text/gff3\",\n\t\".htm\":                       \"text/html\",\n\t\".html\":                      \"text/html\",\n\t\".cjs\":                       \"text/javascript\",\n\t\".js\":                        \"text/javascript\",\n\t\".mjs\":                       \"text/javascript\",\n\t\".cnd\":                       \"text/jcr-cnd\",\n\t\".jsx\":                       \"text/jsx\",\n\t\".less\":                      \"text/less\",\n\t\".md\":                        \"text/markdown\",\n\t\".mdx\":                       \"text/mdx\",\n\t\".m\":                         \"text/mips\",\n\t\".miz\":                       \"text/mizar\",\n\t\".n3\":                        \"text/n3\",\n\t\".txt\":                       \"text/plain\",\n\t\".conf\":                      \"text/plain\",\n\t\".pub\":                       \"text/plain\",\n\t\".awk\":                       \"text/x-awk\",\n\t\".provn\":                     \"text/provenance-notation\",\n\t\".rst\":                       \"text/prs.fallenstein.rst\",\n\t\".tag\":                       \"text/prs.lines.tag\",\n\t\".rs\":                        \"text/x-rust\",\n\t\".ini\":                       \"text/x-ini\",\n\t\".sass\":                      \"text/scss\",\n\t\".scss\":                      \"text/scss\",\n\t\".sgml\":                      \"text/SGML\",\n\t\".shaclc\":                    \"text/shaclc\",\n\t\".shex\":                      \"text/shex\",\n\t\".spdx\":                      \"text/spdx\",\n\t\".tsv\":                       \"text/tab-separated-values\",\n\t\".tm\":                        \"text/texmacs\",\n\t\".t\":                         \"text/troff\",\n\t\".tsx\":                       \"text/typescript-jsx\",\n\t\".ttl\":                       \"text/turtle\",\n\t\".ts\":                        \"text/typescript\",\n\t\".uris\":                      \"text/uri-list\",\n\t\".vcf\":                       \"text/vcard\",\n\t\".a\":                         \"text/vnd.a\",\n\t\".abc\":                       \"text/vnd.abc\",\n\t\".ascii\":                     \"text/vnd.ascii-art\",\n\t\".curl\":                      \"text/vnd.curl\",\n\t\".copyright\":                 \"text/vnd.debian.copyright\",\n\t\".dms\":                       \"text/vnd.DMClientScript\",\n\t\".jtd\":                       \"text/vnd.esmertec.theme-descriptor\",\n\t\".VFK\":                       \"text/vnd.exchangeable\",\n\t\".ged\":                       \"text/vnd.familysearch.gedcom\",\n\t\".flt\":                       \"text/vnd.ficlab.flt\",\n\t\".fly\":                       \"text/vnd.fly\",\n\t\".flx\":                       \"text/vnd.fmi.flexstor\",\n\t\".gv\":                        \"text/vnd.graphviz\",\n\t\".hans\":                      \"text/vnd.hans\",\n\t\".hgl\":                       \"text/vnd.hgl\",\n\t\".3dml\":                      \"text/vnd.in3d.3dml\",\n\t\".spot\":                      \"text/vnd.in3d.spot\",\n\t\".mpf\":                       \"text/vnd.ms-mediapackage\",\n\t\".ccc\":                       \"text/vnd.net2phone.commcenter.command\",\n\t\".mc2\":                       \"text/vnd.senx.warpscript\",\n\t\".sos\":                       \"text/vnd.sosi\",\n\t\".jad\":                       \"text/vnd.sun.j2me.app-descriptor\",\n\t\".si\":                        \"text/vnd.wap.si\",\n\t\".sl\":                        \"text/vnd.wap.sl\",\n\t\".wml\":                       \"text/vnd.wap.wml\",\n\t\".wmls\":                      \"text/vnd.wap.wmlscript\",\n\t\".vtt\":                       \"text/vtt\",\n\t\".wgsl\":                      \"text/wgsl\",\n\t\".cls\":                       \"text/x-apex\",\n\t\".asp\":                       \"text/x-aspx\",\n\t\".aspx\":                      \"text/x-aspx\",\n\t\".bib\":                       \"text/x-bibtex\",\n\t\".boo\":                       \"text/x-boo\",\n\t\".h++\":                       \"text/x-c++hdr\",\n\t\".cc\":                        \"text/x-c++src\",\n\t\".cpp\":                       \"text/x-c++src\",\n\t\".c++\":                       \"text/x-c++src\",\n\t\".h\":                         \"text/x-chdr\",\n\t\".clojure\":                   \"text/x-clojure\",\n\t\".htc\":                       \"text/x-component\",\n\t\".csh\":                       \"text/x-csh\",\n\t\".cshtml\":                    \"text/x-cshtml\",\n\t\".c\":                         \"text/x-csrc\",\n\t\".dart\":                      \"text/x-dart\",\n\t\".diff\":                      \"text/x-diff\",\n\t\".d\":                         \"text/x-dsrc\",\n\t\".ex\":                        \"text/x-elixir\",\n\t\".elm\":                       \"text/x-elm\",\n\t\".erl\":                       \"text/x-erlang\",\n\t\".go\":                        \"text/x-go\",\n\t\".handlebars\":                \"text/x-handlebars-template\",\n\t\".hbs\":                       \"text/x-handlebars-template\",\n\t\".hs\":                        \"text/x-haskell\",\n\t\".java\":                      \"text/x-java\",\n\t\".jl\":                        \"text/x-julia\",\n\t\".kt\":                        \"text/x-kotlin\",\n\t\".kts\":                       \"text/x-kotlin\",\n\t\".ly\":                        \"text/x-lilypond\",\n\t\".cl\":                        \"text/x-common-lisp\",\n\t\".cs\":                        \"text/x-c#src\",\n\t\".l\":                         \"text/x-common-lisp\",\n\t\".lisp\":                      \"text/x-common-lisp\",\n\t\".lsp\":                       \"text/x-common-lisp\",\n\t\".lua\":                       \"text/x-lua\",\n\t\".lhs\":                       \"text/x-literate-haskell\",\n\t\".moc\":                       \"text/x-moc\",\n\t\".p\":                         \"text/x-pascal\",\n\t\".pas\":                       \"text/x-pascal\",\n\t\".pp\":                        \"text/x-pascal\",\n\t\".gcd\":                       \"text/x-pcs-gcd\",\n\t\".pl\":                        \"text/x-perl\",\n\t\".py\":                        \"text/x-python\",\n\t\".r\":                         \"text/x-r\",\n\t\".sbt\":                       \"text/x-scala\",\n\t\".sc\":                        \"text/x-scala\",\n\t\".scala\":                     \"text/x-scala\",\n\t\".scm\":                       \"text/x-scheme\",\n\t\".etx\":                       \"text/x-setext\",\n\t\".sfv\":                       \"text/x-sfv\",\n\t\".swift\":                     \"text/swift\",\n\t\".tcl\":                       \"text/x-tcl\",\n\t\".tex\":                       \"text/x-tex\",\n\t\".twig\":                      \"text/x-twig\",\n\t\".vcs\":                       \"text/x-vcalendar\",\n\t\".axv\":                       \"video/annodex\",\n\t\".dif\":                       \"video/dv\",\n\t\".fli\":                       \"video/fli\",\n\t\".gl\":                        \"video/gl\",\n\t\".m4s\":                       \"video/iso.segment\",\n\t\".mj2\":                       \"video/mj2\",\n\t\".m4v\":                       \"video/mp4\",\n\t\".mkv\":                       \"video/mp4\",\n\t\".mov\":                       \"video/mp4\",\n\t\".mp4\":                       \"video/mp4\",\n\t\".mpeg\":                      \"video/mpeg\",\n\t\".mpg\":                       \"video/mpeg\",\n\t\".ogv\":                       \"video/ogg\",\n\t\".qt\":                        \"video/quicktime\",\n\t\".uvh\":                       \"video/vnd.dece.hd\",\n\t\".uvm\":                       \"video/vnd.dece.mobile\",\n\t\".uvu\":                       \"video/vnd.dece.mp4\",\n\t\".uvp\":                       \"video/vnd.dece.pd\",\n\t\".uvs\":                       \"video/vnd.dece.sd\",\n\t\".uvv\":                       \"video/vnd.dece.video\",\n\t\".dvb\":                       \"video/vnd.dvb.file\",\n\t\".fvt\":                       \"video/vnd.fvt\",\n\t\".mxu\":                       \"video/vnd.mpegurl\",\n\t\".pyv\":                       \"video/vnd.ms-playready.media.pyv\",\n\t\".nim\":                       \"video/vnd.nokia.interleaved-multimedia\",\n\t\".bik\":                       \"video/vnd.radgamettools.bink\",\n\t\".smk\":                       \"video/vnd.radgamettools.smacker\",\n\t\".smpg\":                      \"video/vnd.sealed.mpeg1\",\n\t\".s14\":                       \"video/vnd.sealed.mpeg4\",\n\t\".sswf\":                      \"video/vnd.sealed.swf\",\n\t\".smov\":                      \"video/vnd.sealedmedia.softseal.mov\",\n\t\".viv\":                       \"video/vnd.vivo\",\n\t\".yt\":                        \"video/vnd.youtube.yt\",\n\t\".webm\":                      \"video/webm\",\n\t\".flv\":                       \"video/x-flv\",\n\t\".lsf\":                       \"video/x-la-asf\",\n\t\".mpv\":                       \"video/x-matroska\",\n\t\".mng\":                       \"video/x-mng\",\n\t\".wm\":                        \"video/x-ms-wm\",\n\t\".wmv\":                       \"video/x-ms-wmv\",\n\t\".wmx\":                       \"video/x-ms-wmx\",\n\t\".wvx\":                       \"video/x-ms-wvx\",\n\t\".avi\":                       \"video/x-msvideo\",\n\t\".movie\":                     \"video/x-sgi-movie\",\n}\n"
  },
  {
    "path": "pkg/util/fileutil/readdir.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage fileutil\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\ntype DirEntryOut struct {\n\tName         string `json:\"name\"`\n\tDir          bool   `json:\"dir,omitempty\"`\n\tSymlink      bool   `json:\"symlink,omitempty\"`\n\tSize         int64  `json:\"size,omitempty\"`\n\tMode         string `json:\"mode\"`\n\tModified     string `json:\"modified\"`\n\tModifiedTime string `json:\"modified_time\"`\n}\n\ntype ReadDirResult struct {\n\tPath         string        `json:\"path\"`\n\tAbsolutePath string        `json:\"absolute_path\"`\n\tParentDir    string        `json:\"parent_dir,omitempty\"`\n\tEntries      []DirEntryOut `json:\"entries\"`\n\tEntryCount   int           `json:\"entry_count\"`\n\tTotalEntries int           `json:\"total_entries\"`\n\tTruncated    bool          `json:\"truncated,omitempty\"`\n}\n\nfunc ReadDir(path string, maxEntries int) (*ReadDirResult, error) {\n\texpandedPath, err := wavebase.ExpandHomeDir(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to expand path: %w\", err)\n\t}\n\n\tfileInfo, err := os.Stat(expandedPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to stat path: %w\", err)\n\t}\n\n\tif !fileInfo.IsDir() {\n\t\treturn nil, fmt.Errorf(\"path is not a directory\")\n\t}\n\n\tentries, err := os.ReadDir(expandedPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read directory: %w\", err)\n\t}\n\n\ttotalEntries := len(entries)\n\n\tisDirMap := make(map[string]bool)\n\tsymlinkCount := 0\n\tfor _, entry := range entries {\n\t\tname := entry.Name()\n\t\tif entry.Type()&fs.ModeSymlink != 0 {\n\t\t\tif symlinkCount < 1000 {\n\t\t\t\tsymlinkCount++\n\t\t\t\tfullPath := filepath.Join(expandedPath, name)\n\t\t\t\tif info, err := os.Stat(fullPath); err == nil {\n\t\t\t\t\tisDirMap[name] = info.IsDir()\n\t\t\t\t} else {\n\t\t\t\t\tisDirMap[name] = entry.IsDir()\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tisDirMap[name] = entry.IsDir()\n\t\t\t}\n\t\t} else {\n\t\t\tisDirMap[name] = entry.IsDir()\n\t\t}\n\t}\n\n\tsort.Slice(entries, func(i, j int) bool {\n\t\tiIsDir := isDirMap[entries[i].Name()]\n\t\tjIsDir := isDirMap[entries[j].Name()]\n\t\tif iIsDir != jIsDir {\n\t\t\treturn iIsDir\n\t\t}\n\t\treturn entries[i].Name() < entries[j].Name()\n\t})\n\n\tvar truncated bool\n\tif len(entries) > maxEntries {\n\t\tentries = entries[:maxEntries]\n\t\ttruncated = true\n\t}\n\n\tvar entryList []DirEntryOut\n\tfor _, entry := range entries {\n\t\tinfo, err := entry.Info()\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tisDir := isDirMap[entry.Name()]\n\t\tisSymlink := entry.Type()&fs.ModeSymlink != 0\n\n\t\tentryData := DirEntryOut{\n\t\t\tName:         entry.Name(),\n\t\t\tDir:          isDir,\n\t\t\tSymlink:      isSymlink,\n\t\t\tMode:         info.Mode().String(),\n\t\t\tModified:     utilfn.FormatRelativeTime(info.ModTime()),\n\t\t\tModifiedTime: info.ModTime().UTC().Format(time.RFC3339),\n\t\t}\n\n\t\tif !isDir {\n\t\t\tentryData.Size = info.Size()\n\t\t}\n\n\t\tentryList = append(entryList, entryData)\n\t}\n\n\tresult := &ReadDirResult{\n\t\tPath:         path,\n\t\tAbsolutePath: expandedPath,\n\t\tEntries:      entryList,\n\t\tEntryCount:   len(entryList),\n\t\tTotalEntries: totalEntries,\n\t\tTruncated:    truncated,\n\t}\n\n\tparentDir := filepath.Dir(expandedPath)\n\tif parentDir != expandedPath {\n\t\tresult.ParentDir = parentDir\n\t}\n\n\treturn result, nil\n}\n\nfunc ReadDirRecursive(path string, maxEntries int) (*ReadDirResult, error) {\n\texpandedPath, err := wavebase.ExpandHomeDir(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to expand path: %w\", err)\n\t}\n\n\tfileInfo, err := os.Stat(expandedPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to stat path: %w\", err)\n\t}\n\n\tif !fileInfo.IsDir() {\n\t\treturn nil, fmt.Errorf(\"path is not a directory\")\n\t}\n\n\tvar allEntries []DirEntryOut\n\tisDirMap := make(map[string]bool)\n\tvar truncated bool\n\n\terr = filepath.WalkDir(expandedPath, func(fullPath string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\n\t\tif fullPath == expandedPath {\n\t\t\treturn nil\n\t\t}\n\n\t\tif len(allEntries) >= maxEntries {\n\t\t\ttruncated = true\n\t\t\treturn fs.SkipAll\n\t\t}\n\n\t\trelativePath, _ := filepath.Rel(expandedPath, fullPath)\n\n\t\tisSymlink := d.Type()&fs.ModeSymlink != 0\n\n\t\tinfo, infoErr := d.Info()\n\t\tif infoErr != nil {\n\t\t\treturn nil\n\t\t}\n\n\t\tisDir := d.IsDir()\n\t\tisDirMap[relativePath] = isDir\n\n\t\tentryData := DirEntryOut{\n\t\t\tName:         relativePath,\n\t\t\tDir:          isDir,\n\t\t\tSymlink:      isSymlink,\n\t\t\tMode:         info.Mode().String(),\n\t\t\tModified:     utilfn.FormatRelativeTime(info.ModTime()),\n\t\t\tModifiedTime: info.ModTime().UTC().Format(time.RFC3339),\n\t\t}\n\n\t\tif !isDir {\n\t\t\tentryData.Size = info.Size()\n\t\t}\n\n\t\tallEntries = append(allEntries, entryData)\n\n\t\tif isSymlink && isDir {\n\t\t\treturn fs.SkipDir\n\t\t}\n\n\t\treturn nil\n\t})\n\n\tif err != nil && err != fs.SkipAll {\n\t\treturn nil, fmt.Errorf(\"failed to walk directory: %w\", err)\n\t}\n\n\tsort.Slice(allEntries, func(i, j int) bool {\n\t\tiIsDir := isDirMap[allEntries[i].Name]\n\t\tjIsDir := isDirMap[allEntries[j].Name]\n\t\tif iIsDir != jIsDir {\n\t\t\treturn iIsDir\n\t\t}\n\t\treturn allEntries[i].Name < allEntries[j].Name\n\t})\n\n\tresult := &ReadDirResult{\n\t\tPath:         path,\n\t\tAbsolutePath: expandedPath,\n\t\tEntries:      allEntries,\n\t\tEntryCount:   len(allEntries),\n\t\tTotalEntries: 0,\n\t\tTruncated:    truncated,\n\t}\n\n\tparentDir := filepath.Dir(expandedPath)\n\tif parentDir != expandedPath {\n\t\tresult.ParentDir = parentDir\n\t}\n\n\treturn result, nil\n}"
  },
  {
    "path": "pkg/util/iochan/iochan.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// allows for streaming an io.Reader to a channel and an io.Writer from a channel\npackage iochan\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/iochan/iochantypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\n// ReaderChan reads from an io.Reader and sends the data to a channel\nfunc ReaderChan(ctx context.Context, r io.Reader, chunkSize int64, callback func()) chan wshrpc.RespOrErrorUnion[iochantypes.Packet] {\n\tch := make(chan wshrpc.RespOrErrorUnion[iochantypes.Packet], 32)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tlog.Printf(\"Closing ReaderChan\\n\")\n\t\t\tclose(ch)\n\t\t\tcallback()\n\t\t}()\n\t\tsha256Hash := sha256.New()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tif ctx.Err() == context.Canceled {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\tdefault:\n\t\t\t\tbuf := make([]byte, chunkSize)\n\t\t\t\tif n, err := r.Read(buf); err != nil {\n\t\t\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\t\t\tch <- wshrpc.RespOrErrorUnion[iochantypes.Packet]{Response: iochantypes.Packet{Checksum: sha256Hash.Sum(nil)}} // send the checksum\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tch <- wshutil.RespErr[iochantypes.Packet](fmt.Errorf(\"ReaderChan: read error: %v\", err))\n\t\t\t\t\treturn\n\t\t\t\t} else if n > 0 {\n\t\t\t\t\tif _, err := sha256Hash.Write(buf[:n]); err != nil {\n\t\t\t\t\t\tch <- wshutil.RespErr[iochantypes.Packet](fmt.Errorf(\"ReaderChan: error writing to sha256 hash: %v\", err))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tch <- wshrpc.RespOrErrorUnion[iochantypes.Packet]{Response: iochantypes.Packet{Data: buf[:n]}}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\treturn ch\n}\n\n// WriterChan reads from a channel and writes the data to an io.Writer\nfunc WriterChan(ctx context.Context, w io.Writer, ch <-chan wshrpc.RespOrErrorUnion[iochantypes.Packet], callback func(), cancel context.CancelCauseFunc) {\n\tgo func() {\n\t\tdefer func() {\n\t\t\tif ctx.Err() != nil {\n\t\t\t\tutilfn.DrainChannelSafe(ch, \"WriterChan\")\n\t\t\t}\n\t\t\tcallback()\n\t\t}()\n\t\tsha256Hash := sha256.New()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase resp, ok := <-ch:\n\t\t\t\tif !ok {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif resp.Error != nil {\n\t\t\t\t\tcancel(resp.Error)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif _, err := sha256Hash.Write(resp.Response.Data); err != nil {\n\t\t\t\t\tcancel(fmt.Errorf(\"WriterChan: error writing to sha256 hash: %v\", err))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// The checksum is sent as the last packet\n\t\t\t\tif resp.Response.Checksum != nil {\n\t\t\t\t\tlocalChecksum := sha256Hash.Sum(nil)\n\t\t\t\t\tif !bytes.Equal(localChecksum, resp.Response.Checksum) {\n\t\t\t\t\t\tcancel(fmt.Errorf(\"WriterChan: checksum mismatch\"))\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif _, err := w.Write(resp.Response.Data); err != nil {\n\t\t\t\t\tcancel(fmt.Errorf(\"WriterChan: write error: %v\", err))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n}\n"
  },
  {
    "path": "pkg/util/iochan/iochan_test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage iochan_test\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/iochan\"\n)\n\nconst (\n\tbuflen = 1024\n)\n\nfunc TestIochan_Basic(t *testing.T) {\n\t// Write the packet to the source pipe from a goroutine\n\tsrcPipeReader, srcPipeWriter := io.Pipe()\n\tpacket := []byte(\"hello world\")\n\tgo func() {\n\t\tsrcPipeWriter.Write(packet)\n\t\tsrcPipeWriter.Close()\n\t}()\n\n\t// Initialize the reader channel\n\treaderChanCallbackCalled := false\n\treaderChanCallback := func() {\n\t\tsrcPipeReader.Close()\n\t\treaderChanCallbackCalled = true\n\t}\n\tdefer readerChanCallback() // Ensure the callback is called\n\tioch := iochan.ReaderChan(context.TODO(), srcPipeReader, buflen, readerChanCallback)\n\n\t// Initialize the destination pipe and the writer channel\n\tdestPipeReader, destPipeWriter := io.Pipe()\n\twriterChanCallbackCalled := false\n\twriterChanCallback := func() {\n\t\tdestPipeReader.Close()\n\t\tdestPipeWriter.Close()\n\t\twriterChanCallbackCalled = true\n\t}\n\tdefer writerChanCallback() // Ensure the callback is called\n\tiochan.WriterChan(context.TODO(), destPipeWriter, ioch, writerChanCallback, func(err error) {})\n\n\t// Read the packet from the destination pipe and compare it to the original packet\n\tbuf := make([]byte, buflen)\n\tn, err := destPipeReader.Read(buf)\n\tif err != nil {\n\t\tt.Fatalf(\"Read failed: %v\", err)\n\t}\n\tif n != len(packet) {\n\t\tt.Fatalf(\"Read length mismatch: %d != %d\", n, len(packet))\n\t}\n\tif string(buf[:n]) != string(packet) {\n\t\tt.Fatalf(\"Read data mismatch: %s != %s\", buf[:n], packet)\n\t}\n\n\t// Give the callbacks a chance to run before checking if they were called\n\ttime.Sleep(10 * time.Millisecond)\n\tif !readerChanCallbackCalled {\n\t\tt.Fatalf(\"ReaderChan callback not called\")\n\t}\n\tif !writerChanCallbackCalled {\n\t\tt.Fatalf(\"WriterChan callback not called\")\n\t}\n}\n"
  },
  {
    "path": "pkg/util/iochan/iochantypes/iochantypes.go",
    "content": "package iochantypes\n\ntype Packet struct {\n\tData     []byte\n\tChecksum []byte\n}\n"
  },
  {
    "path": "pkg/util/iterfn/iterfn.go",
    "content": "package iterfn\n\nimport (\n\t\"cmp\"\n\t\"iter\"\n\t\"maps\"\n\t\"slices\"\n)\n\nfunc CollectSeqToSorted[T cmp.Ordered](seq iter.Seq[T]) []T {\n\trtn := []T{}\n\tfor v := range seq {\n\t\trtn = append(rtn, v)\n\t}\n\tslices.Sort(rtn)\n\treturn rtn\n}\n\nfunc CollectSeq[T any](seq iter.Seq[T]) []T {\n\trtn := []T{}\n\tfor v := range seq {\n\t\trtn = append(rtn, v)\n\t}\n\treturn rtn\n}\n\nfunc MapKeysToSorted[K cmp.Ordered, V any](m map[K]V) []K {\n\treturn CollectSeqToSorted(maps.Keys(m))\n}\n"
  },
  {
    "path": "pkg/util/iterfn/iterfn_test.go",
    "content": "package iterfn_test\n\nimport (\n\t\"maps\"\n\t\"slices\"\n\t\"testing\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/iterfn\"\n)\n\nfunc TestCollectSeqToSorted(t *testing.T) {\n\tt.Parallel()\n\n\t// Test code here\n\tm := map[int]struct{}{1: {}, 3: {}, 2: {}}\n\tgot := iterfn.CollectSeqToSorted(maps.Keys(m))\n\twant := []int{1, 2, 3}\n\tif !slices.Equal(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestCollectSeq(t *testing.T) {\n\tt.Parallel()\n\n\t// Test code here\n\tm := map[int]struct{}{1: {}, 3: {}, 2: {}}\n\tgot := iterfn.CollectSeq(maps.Keys(m))\n\ti := 0\n\tfor _, v := range got {\n\t\tif _, ok := m[v]; !ok {\n\t\t\tt.Errorf(\"collected value %v not in original map\", v)\n\t\t}\n\t\ti++\n\t}\n\tif i != len(m) {\n\t\tt.Errorf(\"collected array length %v, want %v\", i, len(m))\n\t}\n}\n\nfunc TestMapKeysToSorted(t *testing.T) {\n\tt.Parallel()\n\n\t// Test code here\n\tm := map[int]struct{}{1: {}, 3: {}, 2: {}}\n\tgot := iterfn.MapKeysToSorted(m)\n\twant := []int{1, 2, 3}\n\tif !slices.Equal(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n"
  },
  {
    "path": "pkg/util/logutil/logutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage logutil\n\nimport (\n\t\"log\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\n// DevPrintf logs using log.Printf only if running in dev mode\nfunc DevPrintf(format string, v ...any) {\n\tif wavebase.IsDevMode() {\n\t\tlog.Printf(format, v...)\n\t}\n}"
  },
  {
    "path": "pkg/util/logview/logview.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage logview\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"regexp\"\n)\n\nconst BufSize = 256 * 1024\nconst MaxLineSize = 1024\n\ntype LinePtr struct {\n\tOffset      int64\n\tRealLineNum int64\n\tLineNum     int64\n}\n\ntype LogView struct {\n\tFile     *os.File\n\tMultiBuf *MultiBufferByteGetter\n\tMatchRe  *regexp.Regexp\n}\n\nfunc MakeLogView(file *os.File) *LogView {\n\treturn &LogView{\n\t\tFile:     file,\n\t\tMultiBuf: MakeMultiBufferByteGetter(file, BufSize),\n\t}\n}\n\nfunc (lv *LogView) Close() {\n\tlv.File.Close()\n}\n\nfunc (lv *LogView) ReadLineData(linePtr *LinePtr) ([]byte, error) {\n\treturn lv.readLineAt(linePtr.Offset)\n}\n\nfunc (lv *LogView) readLineAt(offset int64) ([]byte, error) {\n\tvar rtn []byte\n\tfor {\n\t\tif len(rtn) > MaxLineSize {\n\t\t\tbreak\n\t\t}\n\t\tb, err := lv.MultiBuf.GetByte(offset)\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif b == '\\n' {\n\t\t\tbreak\n\t\t}\n\t\trtn = append(rtn, b)\n\t\toffset++\n\t}\n\treturn rtn, nil\n}\n\nfunc (lv *LogView) FirstLinePtr() (*LinePtr, error) {\n\tlinePtr := &LinePtr{Offset: 0, RealLineNum: 1, LineNum: 1}\n\tif lv.isLineMatch(0) {\n\t\treturn linePtr, nil\n\t}\n\treturn lv.NextLinePtr(linePtr)\n}\n\nfunc (lv *LogView) isLineMatch(offset int64) bool {\n\tif lv.MatchRe == nil {\n\t\treturn true\n\t}\n\tlineData, err := lv.readLineAt(offset)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn lv.MatchRe.Match(lineData)\n}\n\nfunc (lv *LogView) NextLinePtr(linePtr *LinePtr) (*LinePtr, error) {\n\tif linePtr == nil {\n\t\treturn nil, fmt.Errorf(\"linePtr is nil\")\n\t}\n\tnumLines := int64(0)\n\toffset := linePtr.Offset\n\tfor {\n\t\tvar err error\n\t\tnextOffset, err := lv.MultiBuf.NextLine(offset)\n\t\tif err == io.EOF {\n\t\t\treturn nil, nil\n\t\t}\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tnumLines++\n\t\tif lv.isLineMatch(nextOffset) {\n\t\t\treturn &LinePtr{Offset: nextOffset, RealLineNum: linePtr.RealLineNum + numLines, LineNum: linePtr.LineNum + 1}, nil\n\t\t}\n\t\toffset = nextOffset\n\t}\n}\n\nfunc (lv *LogView) PrevLinePtr(linePtr *LinePtr) (*LinePtr, error) {\n\tif linePtr == nil {\n\t\treturn nil, fmt.Errorf(\"linePtr is nil\")\n\t}\n\tnumLines := int64(0)\n\toffset := linePtr.Offset\n\tfor {\n\t\tvar err error\n\t\tprevOffset, err := lv.MultiBuf.PrevLine(offset)\n\t\tif err == ErrBOF {\n\t\t\treturn nil, nil\n\t\t}\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tnumLines++\n\t\tif lv.isLineMatch(prevOffset) {\n\t\t\treturn &LinePtr{Offset: prevOffset, RealLineNum: linePtr.RealLineNum - numLines, LineNum: linePtr.LineNum - 1}, nil\n\t\t}\n\t\toffset = prevOffset\n\t}\n}\n\nfunc (lv *LogView) Move(linePtr *LinePtr, offset int) (int, *LinePtr, error) {\n\tvar n int\n\tif offset > 0 {\n\t\tfor {\n\t\t\tnextLinePtr, err := lv.NextLinePtr(linePtr)\n\t\t\tif err == io.EOF {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn 0, nil, err\n\t\t\t}\n\t\t\tlinePtr = nextLinePtr\n\t\t\tn++\n\t\t\tif n == offset {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\treturn n, linePtr, nil\n\t}\n\tif offset < 0 {\n\t\tfor {\n\t\t\tprevLinePtr, err := lv.PrevLinePtr(linePtr)\n\t\t\tif err == ErrBOF {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn 0, nil, err\n\t\t\t}\n\t\t\tlinePtr = prevLinePtr\n\t\t\tn--\n\t\t\tif n == offset {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\treturn n, linePtr, nil\n\t}\n\treturn 0, linePtr, nil\n}\n\nfunc (lv *LogView) LastLinePtr(linePtr *LinePtr) (*LinePtr, error) {\n\tif linePtr == nil {\n\t\tvar err error\n\t\tlinePtr, err = lv.FirstLinePtr()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif linePtr == nil {\n\t\treturn nil, nil\n\t}\n\tfor {\n\t\tnextLinePtr, err := lv.NextLinePtr(linePtr)\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif nextLinePtr == nil {\n\t\t\tbreak\n\t\t}\n\t\tlinePtr = nextLinePtr\n\t}\n\treturn linePtr, nil\n}\n\nfunc (lv *LogView) ReadWindow(linePtr *LinePtr, winSize int) ([][]byte, error) {\n\tif linePtr == nil {\n\t\treturn nil, nil\n\t}\n\tvar rtn [][]byte\n\tfor len(rtn) < winSize {\n\t\tlineData, err := lv.readLineAt(linePtr.Offset)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\trtn = append(rtn, lineData)\n\t\tnextLinePtr, err := lv.NextLinePtr(linePtr)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif nextLinePtr == nil {\n\t\t\tbreak\n\t\t}\n\t\tlinePtr = nextLinePtr\n\t}\n\treturn rtn, nil\n}\n"
  },
  {
    "path": "pkg/util/logview/multibuf.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage logview\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n)\n\ntype MultiBufferByteGetter struct {\n\tFile    *os.File\n\tOffset  int64\n\tEOF     bool\n\tBuffers [][]byte\n\tBufSize int64\n}\n\nvar ErrBOF = errors.New(\"beginning of file\")\n\nfunc MakeMultiBufferByteGetter(file *os.File, bufSize int64) *MultiBufferByteGetter {\n\treturn &MultiBufferByteGetter{\n\t\tFile:    file,\n\t\tOffset:  0,\n\t\tEOF:     false,\n\t\tBuffers: [][]byte{},\n\t\tBufSize: bufSize,\n\t}\n}\n\nfunc (mb *MultiBufferByteGetter) readFromBuffer(offset int64) (byte, bool) {\n\tif offset < mb.Offset || offset >= mb.Offset+int64(mb.bufSize()) {\n\t\treturn 0, false\n\t}\n\tbufIdx := int((offset - mb.Offset) / mb.BufSize)\n\tbufOffset := (offset - mb.Offset) % mb.BufSize\n\treturn mb.Buffers[bufIdx][bufOffset], true\n}\n\nfunc (mb *MultiBufferByteGetter) bufSize() int {\n\treturn len(mb.Buffers) * int(mb.BufSize)\n}\n\nfunc (mb *MultiBufferByteGetter) rebuffer(newOffset int64) error {\n\tpartNum := int(newOffset / mb.BufSize)\n\tpartOffset := int64(partNum) * mb.BufSize\n\tnewBuf := make([]byte, mb.BufSize)\n\tn, err := mb.File.ReadAt(newBuf, partOffset)\n\tvar isEOF bool\n\tif err == io.EOF {\n\t\tnewBuf = newBuf[:n]\n\t\tisEOF = true\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar newBuffers [][]byte\n\tif len(mb.Buffers) > 0 {\n\t\tfirstBufPartNum := int(mb.Offset / mb.BufSize)\n\t\tlastBufPartNum := int((mb.Offset + int64(mb.bufSize())) / mb.BufSize)\n\t\tif firstBufPartNum == partNum+1 {\n\t\t\tnewBuffers = [][]byte{newBuf, mb.Buffers[0]}\n\t\t} else if lastBufPartNum == partNum-1 {\n\t\t\tnewBuffers = [][]byte{mb.Buffers[0], newBuf}\n\t\t} else {\n\t\t\tnewBuffers = [][]byte{newBuf}\n\t\t}\n\t} else {\n\t\tnewBuffers = [][]byte{newBuf}\n\t}\n\tmb.Buffers = newBuffers\n\tmb.Offset = partOffset\n\tmb.EOF = isEOF\n\treturn nil\n}\n\nfunc (mb *MultiBufferByteGetter) GetByte(offset int64) (byte, error) {\n\tb, ok := mb.readFromBuffer(offset)\n\tif ok {\n\t\treturn b, nil\n\t}\n\tif mb.EOF && offset >= mb.Offset+int64(mb.bufSize()) {\n\t\treturn 0, io.EOF\n\t}\n\terr := mb.rebuffer(offset)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tb, _ = mb.readFromBuffer(offset)\n\treturn b, nil\n}\n\nfunc (mb *MultiBufferByteGetter) NextLine(offset int64) (int64, error) {\n\tfor {\n\t\tb, err := mb.GetByte(offset)\n\t\tif err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tif b == '\\n' {\n\t\t\tbreak\n\t\t}\n\t\toffset++\n\t}\n\t_, lastErr := mb.GetByte(offset + 1)\n\tif lastErr == io.EOF {\n\t\treturn 0, io.EOF\n\t}\n\treturn offset + 1, nil\n}\n\nfunc (mb *MultiBufferByteGetter) PrevLine(offset int64) (int64, error) {\n\tif offset == 0 {\n\t\treturn 0, ErrBOF\n\t}\n\toffset = offset - 2\n\tfor {\n\t\tif offset < 0 {\n\t\t\tbreak\n\t\t}\n\t\tb, err := mb.GetByte(offset)\n\t\tif err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tif b == '\\n' {\n\t\t\tbreak\n\t\t}\n\t\toffset--\n\t}\n\treturn offset + 1, nil\n}\n"
  },
  {
    "path": "pkg/util/migrateutil/migrateutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage migrateutil\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log\"\n\n\t\"github.com/golang-migrate/migrate/v4\"\n\t\"github.com/golang-migrate/migrate/v4/source/iofs\"\n\n\tsqlite3migrate \"github.com/golang-migrate/migrate/v4/database/sqlite3\"\n)\n\nfunc GetMigrateVersion(m *migrate.Migrate) (uint, bool, error) {\n\tcurVersion, dirty, err := m.Version()\n\tif err == migrate.ErrNilVersion {\n\t\treturn 0, false, nil\n\t}\n\treturn curVersion, dirty, err\n}\n\nfunc MakeMigrate(storeName string, db *sql.DB, migrationFS fs.FS, migrationsName string) (*migrate.Migrate, error) {\n\tfsVar, err := iofs.New(migrationFS, migrationsName)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"opening fs: %w\", err)\n\t}\n\tmdriver, err := sqlite3migrate.WithInstance(db, &sqlite3migrate.Config{})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"making %s migration driver: %w\", storeName, err)\n\t}\n\tm, err := migrate.NewWithInstance(\"iofs\", fsVar, \"sqlite3\", mdriver)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"making %s migration: %w\", storeName, err)\n\t}\n\treturn m, nil\n}\n\nfunc Migrate(storeName string, db *sql.DB, migrationFS fs.FS, migrationsName string) error {\n\tlog.Printf(\"migrate %s\\n\", storeName)\n\tm, err := MakeMigrate(storeName, db, migrationFS, migrationsName)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcurVersion, dirty, err := GetMigrateVersion(m)\n\tif dirty {\n\t\treturn fmt.Errorf(\"%s, migrate up, database is dirty\", storeName)\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"%s, cannot get current migration version: %v\", storeName, err)\n\t}\n\terr = m.Up()\n\tif err != nil && err != migrate.ErrNoChange {\n\t\treturn fmt.Errorf(\"migrating %s: %w\", storeName, err)\n\t}\n\tnewVersion, _, err := GetMigrateVersion(m)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"%s, cannot get new migration version: %v\", storeName, err)\n\t}\n\tif newVersion != curVersion {\n\t\tlog.Printf(\"[db] %s migration done, version %d -> %d\\n\", storeName, curVersion, newVersion)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/util/packetparser/packetparser.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage packetparser\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n)\n\ntype PacketParser struct {\n\tReader io.Reader\n\tCh     chan []byte\n}\n\nfunc ParseWithLinesChan(input chan utilfn.LineOutput, packetCh chan baseds.RpcInputChType, rawCh chan []byte) {\n\tdefer close(packetCh)\n\tdefer close(rawCh)\n\tfor {\n\t\t// note this line doesn't have a trailing newline\n\t\tline, ok := <-input\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\t\tif line.Error != nil {\n\t\t\tlog.Printf(\"ParseWithLinesChan: error reading line: %v\", line.Error)\n\t\t\treturn\n\t\t}\n\t\tif len(line.Line) <= 1 {\n\t\t\t// just a blank line\n\t\t\tcontinue\n\t\t}\n\t\tif bytes.HasPrefix([]byte(line.Line), []byte{'#', '#', 'N', '{'}) && bytes.HasSuffix([]byte(line.Line), []byte{'}'}) {\n\t\t\t// strip off the leading \"##\"\n\t\t\tpacketCh <- baseds.RpcInputChType{MsgBytes: []byte(line.Line[3:len(line.Line)])}\n\t\t} else {\n\t\t\trawCh <- []byte(line.Line)\n\t\t}\n\t}\n}\n\nfunc Parse(input io.Reader, packetCh chan baseds.RpcInputChType, rawCh chan []byte) error {\n\tbufReader := bufio.NewReader(input)\n\tdefer close(packetCh)\n\tdefer close(rawCh)\n\tfor {\n\t\t// note this line does have a trailing newline\n\t\tline, err := bufReader.ReadBytes('\\n')\n\t\tif err == io.EOF {\n\t\t\treturn nil\n\t\t}\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif len(line) <= 1 {\n\t\t\t// just a blank line\n\t\t\tcontinue\n\t\t}\n\t\tif bytes.HasPrefix(line, []byte{'#', '#', 'N', '{'}) && bytes.HasSuffix(line, []byte{'}', '\\n'}) {\n\t\t\t// strip off the leading \"##\" and trailing \"\\n\" (single byte)\n\t\t\tpacketCh <- baseds.RpcInputChType{MsgBytes: line[3 : len(line)-1]}\n\t\t} else {\n\t\t\trawCh <- line\n\t\t}\n\t}\n}\n\nfunc WritePacket(output io.Writer, packet []byte) error {\n\tif len(packet) < 2 {\n\t\treturn nil\n\t}\n\tif packet[0] != '{' || packet[len(packet)-1] != '}' {\n\t\treturn fmt.Errorf(\"invalid packet, must start with '{' and end with '}'\")\n\t}\n\tfullPacket := make([]byte, 0, len(packet)+5)\n\t// we add the extra newline to make sure the ## appears at the beginning of the line\n\t// since writer isn't buffered, we want to send this all at once\n\tfullPacket = append(fullPacket, '\\n', '#', '#', 'N')\n\tfullPacket = append(fullPacket, packet...)\n\tfullPacket = append(fullPacket, '\\n')\n\t_, err := output.Write(fullPacket)\n\treturn err\n}\n"
  },
  {
    "path": "pkg/util/pamparse/pamparse.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// Package pamparse provides functions for parsing environment files in the format of /etc/environment, /etc/security/pam_env.conf, and ~/.pam_environment.\npackage pamparse\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"os\"\n\t\"regexp\"\n\t\"strings\"\n)\n\ntype PamParseOpts struct {\n\tHome  string\n\tShell string\n}\n\n// Parses a file in the format of /etc/environment. Accepts a path to the file and returns a map of environment variables.\nfunc ParseEnvironmentFile(path string) (map[string]string, error) {\n\trtn := make(map[string]string)\n\tfile, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\tscanner := bufio.NewScanner(file)\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tkey, val := parseEnvironmentLine(line)\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\trtn[key] = val\n\t}\n\treturn rtn, nil\n}\n\n// Parses a file in the format of /etc/security/pam_env.conf or ~/.pam_environment. Accepts a path to the file and returns a map of environment variables.\nfunc ParseEnvironmentConfFile(path string, opts *PamParseOpts) (map[string]string, error) {\n\trtn := make(map[string]string)\n\tfile, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\tif opts == nil {\n\t\topts, err = ParsePasswd()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tscanner := bufio.NewScanner(file)\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tkey, val := parseEnvironmentConfLine(line)\n\n\t\t// Fall back to ParseEnvironmentLine if ParseEnvironmentConfLine fails\n\t\tif key == \"\" {\n\t\t\tkey, val = parseEnvironmentLine(line)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\trtn[key] = replaceHomeAndShell(val, opts.Home, opts.Shell)\n\t}\n\treturn rtn, nil\n}\n\n// Gets the home directory and shell from /etc/passwd for the current user.\nfunc ParsePasswd() (*PamParseOpts, error) {\n\tfile, err := os.Open(\"/etc/passwd\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\tuserPrefix := fmt.Sprintf(\"%s:\", os.Getenv(\"USER\"))\n\tscanner := bufio.NewScanner(file)\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tif strings.HasPrefix(line, userPrefix) {\n\t\t\tparts := strings.Split(line, \":\")\n\t\t\tif len(parts) < 7 {\n\t\t\t\treturn nil, fmt.Errorf(\"invalid passwd entry: insufficient fields\")\n\t\t\t}\n\t\t\treturn &PamParseOpts{\n\t\t\t\tHome:  parts[5],\n\t\t\t\tShell: parts[6],\n\t\t\t}, nil\n\t\t}\n\t}\n\tif err := scanner.Err(); err != nil {\n\t\treturn nil, fmt.Errorf(\"error reading passwd file: %w\", err)\n\t}\n\treturn nil, nil\n}\n\n/*\nGets the home directory and shell from /etc/passwd for the current user and returns a map of environment variables from /etc/security/pam_env.conf or ~/.pam_environment.\nReturns nil if an error occurs.\n*/\nfunc ParsePasswdSafe() *PamParseOpts {\n\topts, err := ParsePasswd()\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn opts\n}\n\n// Replaces @{HOME} and @{SHELL} placeholders in a string with the provided values. Follows guidance from https://wiki.archlinux.org/title/Environment_variables#Using_pam_env\nfunc replaceHomeAndShell(val string, home string, shell string) string {\n\tval = strings.ReplaceAll(val, \"@{HOME}\", home)\n\tval = strings.ReplaceAll(val, \"@{SHELL}\", shell)\n\treturn val\n}\n\n// Regex to parse a line from /etc/environment. Follows the guidance from https://wiki.archlinux.org/title/Environment_variables#Using_pam_env\nvar envFileLineRe = regexp.MustCompile(`^(?:export\\s+)?([A-Z0-9_]+[A-Za-z0-9]*)=(.*)$`)\n\nfunc parseEnvironmentLine(line string) (string, string) {\n\tm := envFileLineRe.FindStringSubmatch(line)\n\tif m == nil {\n\t\treturn \"\", \"\"\n\t}\n\treturn m[1], sanitizeEnvVarValue(m[2])\n}\n\n// Regex to parse a line from /etc/security/pam_env.conf or ~/.pam_environment. Follows the guidance from https://wiki.archlinux.org/title/Environment_variables#Using_pam_env\nvar confFileLineRe = regexp.MustCompile(`^([A-Z0-9_]+[A-Za-z0-9]*)\\s+(?:(?:DEFAULT=)(\\S+(?: \\S+)*))\\s*(?:(?:OVERRIDE=)(\\S+(?: \\S+)*))?\\s*$`)\n\nfunc parseEnvironmentConfLine(line string) (string, string) {\n\tm := confFileLineRe.FindStringSubmatch(line)\n\tif m == nil {\n\t\treturn \"\", \"\"\n\t}\n\tvar vals []string\n\tif len(m) > 3 && m[3] != \"\" {\n\t\tvals = []string{sanitizeEnvVarValue(m[3]), sanitizeEnvVarValue(m[2])}\n\t} else {\n\t\tvals = []string{sanitizeEnvVarValue(m[2])}\n\t}\n\treturn m[1], strings.Join(vals, \":\")\n}\n\n// Sanitizes an environment variable value by stripping comments and trimming quotes.\nfunc sanitizeEnvVarValue(val string) string {\n\treturn stripComments(trimQuotes(val))\n}\n\n// Trims quotes as defined by https://unix.stackexchange.com/questions/748790/where-is-the-syntax-for-etc-environment-documented\nfunc trimQuotes(val string) string {\n\tif strings.HasPrefix(val, \"\\\"\") || strings.HasPrefix(val, \"'\") {\n\t\tval = val[1:]\n\t\tif strings.HasSuffix(val, \"\\\"\") || strings.HasSuffix(val, \"'\") {\n\t\t\tval = val[0 : len(val)-1]\n\t\t}\n\t}\n\treturn val\n}\n\n// Strips comments as defined by https://unix.stackexchange.com/questions/748790/where-is-the-syntax-for-etc-environment-documented\nfunc stripComments(val string) string {\n\tcommentIdx := strings.Index(val, \"#\")\n\tif commentIdx == -1 {\n\t\treturn val\n\t}\n\treturn val[0:commentIdx]\n}\n"
  },
  {
    "path": "pkg/util/pamparse/pamparse_test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage pamparse_test\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/pamparse\"\n)\n\n// Tests influenced by https://unix.stackexchange.com/questions/748790/where-is-the-syntax-for-etc-environment-documented\nfunc TestParseEnvironmentFile(t *testing.T) {\n\tconst fileContent = `\nFOO1=bar\nFOO2=\"bar\"\nFOO3=\"bar\nFOO4=bar\"\nFOO5='bar'\nFOO6='bar\"\nexport FOO7=bar\nFOO8=bar bar bar\n#FOO9=bar\nFOO10=$PATH\nFOO11=\"foo#bar\"\n\t`\n\n\t// create a temporary file with the content\n\ttempFile := filepath.Join(t.TempDir(), \"pam_env\")\n\tif err := os.WriteFile(tempFile, []byte(fileContent), 0644); err != nil {\n\t\tt.Fatalf(\"failed to write file: %v\", err)\n\t}\n\n\t// parse the file\n\tgot, err := pamparse.ParseEnvironmentFile(tempFile)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse pam environment file: %v\", err)\n\t}\n\n\twant := map[string]string{\n\t\t\"FOO1\":  \"bar\",\n\t\t\"FOO2\":  \"bar\",\n\t\t\"FOO3\":  \"bar\",\n\t\t\"FOO4\":  \"bar\\\"\",\n\t\t\"FOO5\":  \"bar\",\n\t\t\"FOO6\":  \"bar\",\n\t\t\"FOO7\":  \"bar\",\n\t\t\"FOO8\":  \"bar bar bar\",\n\t\t\"FOO10\": \"$PATH\",\n\t\t\"FOO11\": \"foo\",\n\t}\n\n\tif len(got) != len(want) {\n\t\tt.Fatalf(\"expected %d environment variables, got %d\", len(want), len(got))\n\t}\n\tfor k, v := range want {\n\t\tif got[k] != v {\n\t\t\tt.Errorf(\"expected %q to be %q, got %q\", k, v, got[k])\n\t\t}\n\t}\n}\n\nfunc TestParseEnvironmentConfFile(t *testing.T) {\n\tconst fileContent = `\nTEST   DEFAULT=@{HOME}/.config\\ state   OVERRIDE=./config\\ s\nFOO   DEFAULT=@{HOME}/.config\\ s\nSTRING   DEFAULT=\"string\"\nSTRINGOVERRIDE   DEFAULT=\"string\"   OVERRIDE=\"string2\"\nFOO11=\"foo#bar\"\n\t`\n\n\t// create a temporary file with the content\n\ttempFile := filepath.Join(t.TempDir(), \"pam_env_conf\")\n\tif err := os.WriteFile(tempFile, []byte(fileContent), 0644); err != nil {\n\t\tt.Fatalf(\"failed to write file: %v\", err)\n\t}\n\n\t// parse the file\n\tgot, err := pamparse.ParseEnvironmentConfFile(tempFile, &pamparse.PamParseOpts{\n\t\tHome:  \"/home/user\",\n\t\tShell: \"/bin/bash\"},\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse pam environment conf file: %v\", err)\n\t}\n\n\twant := map[string]string{\n\t\t\"TEST\":           \"./config\\\\ s:/home/user/.config\\\\ state\",\n\t\t\"FOO\":            \"/home/user/.config\\\\ s\",\n\t\t\"STRING\":         \"string\",\n\t\t\"STRINGOVERRIDE\": \"string2:string\",\n\t\t\"FOO11\":          \"foo\",\n\t}\n\n\tif len(got) != len(want) {\n\t\tt.Fatalf(\"expected %d environment variables, got %d\", len(want), len(got))\n\t}\n\tfor k, v := range want {\n\t\tif got[k] != v {\n\t\t\tt.Errorf(\"expected %q to be %q, got %q\", k, v, got[k])\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/util/readutil/readutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage readutil\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n)\n\nconst (\n\tStopReasonBOF       = \"bof\"\n\tStopReasonEOF       = \"eof\"\n\tStopReasonReadLimit = \"read_limit\"\n)\n\n// ReadLines reads lines from the reader, optionally skipping the first skipLines lines.\n// If lineCount is 0, no line limit is applied. If readLimit is 0, no byte limit is applied.\n// Stops when either limit is reached or EOF.\n// Returns lines (with trailing newlines), stop reason, and error.\n// Stop reason is StopReasonEOF when EOF is reached, StopReasonReadLimit when byte limit is reached,\n// or empty string for natural returns (line count limit or no limits applied).\nfunc ReadLines(reader io.Reader, lineCount int, skipLines int, readLimit int) ([]string, string, error) {\n\tbufReader := bufio.NewReader(reader)\n\tlines := make([]string, 0)\n\tbytesRead := 0\n\tskippedLines := 0\n\n\tfor {\n\t\tline, err := bufReader.ReadString('\\n')\n\t\tif len(line) > 0 {\n\t\t\tbytesRead += len(line)\n\t\t\t\n\t\t\tif skippedLines < skipLines {\n\t\t\t\tskippedLines++\n\t\t\t} else {\n\t\t\t\tlines = append(lines, line)\n\t\t\t\tif lineCount > 0 && len(lines) >= lineCount {\n\t\t\t\t\treturn lines, \"\", nil\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif readLimit > 0 && bytesRead >= readLimit {\n\t\t\t\treturn lines, StopReasonReadLimit, nil\n\t\t\t}\n\t\t}\n\n\t\tif err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\treturn lines, StopReasonEOF, nil\n\t\t\t}\n\t\t\treturn nil, \"\", err\n\t\t}\n\t}\n}\n\n// readLastNLineOffsets reads all line offsets from the reader, keeping only the last maxLines in a sliding window.\n// keepFirst indicates whether offset 0 should be included (true if starting from file beginning).\n// Returns the offsets and the total number of lines found.\nfunc ReadLastNLineOffsets(rs io.ReadSeeker, maxLines int, keepFirst bool) ([]int64, int, error) {\n\tif _, err := rs.Seek(0, io.SeekStart); err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tvar offsets []int64\n\treader := bufio.NewReader(rs)\n\tvar currentPos int64 = 0\n\ttotalLines := 0\n\n\tif keepFirst {\n\t\toffsets = append(offsets, 0)\n\t\ttotalLines = 1\n\t}\n\n\tfor {\n\t\tline, err := reader.ReadBytes('\\n')\n\n\t\tif len(line) > 0 {\n\t\t\tcurrentPos += int64(len(line))\n\t\t\toffsets = append(offsets, currentPos)\n\t\t\ttotalLines++\n\t\t\t// Keep maxLines+1 for sliding window (extra slot for EOF position)\n\t\t\tif len(offsets) > maxLines+1 {\n\t\t\t\toffsets = offsets[1:]\n\t\t\t}\n\t\t}\n\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\treturn nil, 0, err\n\t\t}\n\t}\n\n\t// Trim the final EOF offset if we have one\n\tif len(offsets) > 0 {\n\t\toffsets = offsets[:len(offsets)-1]\n\t\ttotalLines--\n\t}\n\n\treturn offsets, totalLines, nil\n}\n\n// readTailLinesInternal reads the last lineCount lines from the reader, excluding the last lineOffset lines.\n// For example, lineCount=10 and lineOffset=5 would return lines -15 through -6 (the 10 lines before the last 5).\n// keepFirst indicates whether the first line should be kept (true if starting at file position 0, false otherwise).\n// Returns the lines (with trailing newlines), a hasMore flag, and any error.\n// hasMore is true if there are lines before our window (didn't hit BOF), false if we read from the very beginning.\nfunc readTailLinesInternal(rs io.ReadSeeker, lineCount int, lineOffset int, keepFirst bool) ([]string, bool, error) {\n\tmaxOffsets := lineCount + lineOffset\n\toffsets, totalLines, err := ReadLastNLineOffsets(rs, maxOffsets, keepFirst)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\n\tif totalLines <= lineOffset {\n\t\treturn []string{}, false, nil\n\t}\n\n\tlinesToRead := lineCount\n\tif totalLines-lineOffset < lineCount {\n\t\tlinesToRead = totalLines - lineOffset\n\t}\n\tstartIdx := len(offsets) - lineOffset - linesToRead\n\thasMore := totalLines > lineCount+lineOffset\n\n\tif _, err := rs.Seek(offsets[startIdx], io.SeekStart); err != nil {\n\t\treturn nil, false, err\n\t}\n\n\tlines, _, err := ReadLines(rs, linesToRead, 0, 0)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\n\treturn lines, hasMore, nil\n}\n\n// ReadTailLines reads the last lineCount lines from a file, excluding the last lineOffset lines.\n// It progressively reads larger windows from the end of the file (starting at 1MB, doubling up to readLimit)\n// until it finds enough lines or reaches the limit. Returns the lines, stop reason, and any error.\n// Stop reason is StopReasonBOF when beginning of file is reached, StopReasonReadLimit when byte limit is reached,\n// or empty string for natural completion (found requested line count).\nfunc ReadTailLines(file *os.File, lineCount int, lineOffset int, readLimit int64) ([]string, string, error) {\n\tif readLimit <= 0 {\n\t\treturn nil, \"\", fmt.Errorf(\"ReadTailLines readLimit must be positive, got %d\", readLimit)\n\t}\n\n\tfileInfo, err := file.Stat()\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\tfileSize := fileInfo.Size()\n\n\treadBytes := int64(1024 * 1024)\n\tif readLimit < readBytes {\n\t\treadBytes = readLimit\n\t}\n\n\tfor {\n\t\tstartPos := fileSize - readBytes\n\t\tif startPos < 0 {\n\t\t\tstartPos = 0\n\t\t\treadBytes = fileSize\n\t\t}\n\n\t\tsectionReader := io.NewSectionReader(file, startPos, readBytes)\n\t\tkeepFirst := startPos == 0\n\n\t\tlines, hasMoreInWindow, err := readTailLinesInternal(sectionReader, lineCount, lineOffset, keepFirst)\n\t\tif err != nil {\n\t\t\treturn nil, \"\", err\n\t\t}\n\n\t\tif len(lines) == lineCount {\n\t\t\thasMore := startPos > 0 || hasMoreInWindow\n\t\t\tif !hasMore {\n\t\t\t\treturn lines, StopReasonBOF, nil\n\t\t\t}\n\t\t\treturn lines, \"\", nil\n\t\t}\n\n\t\tif readBytes >= readLimit || readBytes >= fileSize {\n\t\t\tif startPos > 0 {\n\t\t\t\treturn lines, StopReasonReadLimit, nil\n\t\t\t}\n\t\t\treturn lines, StopReasonBOF, nil\n\t\t}\n\n\t\treadBytes *= 2\n\t\tif readBytes > readLimit {\n\t\t\treadBytes = readLimit\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/util/shellutil/shellintegration/bash_bashrc.sh",
    "content": "\n# Source /etc/profile if it exists\nif [ -f /etc/profile ]; then\n    . /etc/profile\nfi\n\nWAVETERM_WSHBINDIR={{.WSHBINDIR}}\n\n# after /etc/profile which is likely to clobber the path\nexport PATH=\"$WAVETERM_WSHBINDIR:$PATH\"\n\n# Source the dynamic script from wsh token\neval \"$(wsh token \"$WAVETERM_SWAPTOKEN\" bash 2> /dev/null)\"\nunset WAVETERM_SWAPTOKEN\n\n# Source the first of ~/.bash_profile, ~/.bash_login, or ~/.profile that exists\nif [ -f ~/.bash_profile ]; then\n    . ~/.bash_profile\nelif [ -f ~/.bash_login ]; then\n    . ~/.bash_login\nelif [ -f ~/.profile ]; then\n    . ~/.profile\nfi\n\nif [[ \":$PATH:\" != *\":$WAVETERM_WSHBINDIR:\"* ]]; then\n    export PATH=\"$WAVETERM_WSHBINDIR:$PATH\"\nfi\nunset WAVETERM_WSHBINDIR\nif type _init_completion &>/dev/null; then\n  source <(wsh completion bash)\nfi\n\n# extdebug breaks bash-preexec semantics; bail out cleanly\nif shopt -q extdebug; then\n  # printf 'wave si: disabled (bash extdebug enabled)\\n' >&2\n  printf '\\033]16162;M;{\"integration\":false}\\007'\n  return 0\nfi\n\n# Source bash-preexec for proper preexec/precmd hook support\nif [ -z \"${bash_preexec_imported:-}\" ]; then\n    _WAVETERM_SI_BASHRC_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n    if [ -f \"$_WAVETERM_SI_BASHRC_DIR/bash_preexec.sh\" ]; then\n        source \"$_WAVETERM_SI_BASHRC_DIR/bash_preexec.sh\"\n    fi\n    unset _WAVETERM_SI_BASHRC_DIR\nfi\n\n# Check if bash-preexec was successfully imported\nif [ -z \"${bash_preexec_imported:-}\" ]; then\n    # bash-preexec failed to import, disable shell integration\n    printf '\\033]16162;M;{\"integration\":false}\\007'\n    return 0\nfi\n\n_WAVETERM_SI_FIRSTPROMPT=1\n\n# Wave Terminal Shell Integration\n_waveterm_si_blocked() {\n    [[ -n \"$TMUX\" || -n \"$STY\" || \"$TERM\" == tmux* || \"$TERM\" == screen* ]]\n}\n\n_waveterm_si_urlencode() {\n    local s=\"$1\"\n    s=\"${s//%/%25}\"\n    s=\"${s// /%20}\"\n    s=\"${s//#/%23}\"\n    s=\"${s//\\?/%3F}\"\n    s=\"${s//&/%26}\"\n    s=\"${s//;/%3B}\"\n    s=\"${s//+/%2B}\"\n    printf '%s' \"$s\"\n}\n\n_waveterm_si_osc7() {\n    _waveterm_si_blocked && return\n    local encoded_pwd=$(_waveterm_si_urlencode \"$PWD\")\n    printf '\\033]7;file://localhost%s\\007' \"$encoded_pwd\"\n}\n\n_waveterm_si_precmd() {\n    local _waveterm_si_status=$?\n    _waveterm_si_blocked && return\n    \n    if [ \"$_WAVETERM_SI_FIRSTPROMPT\" -eq 1 ]; then\n        local uname_info\n        uname_info=$(uname -smr 2>/dev/null)\n        printf '\\033]16162;M;{\"shell\":\"bash\",\"shellversion\":\"%s\",\"uname\":\"%s\",\"integration\":true}\\007' \"$BASH_VERSION\" \"$uname_info\"\n    else\n        printf '\\033]16162;D;{\"exitcode\":%d}\\007' \"$_waveterm_si_status\"\n    fi\n    # OSC 7 sent on every prompt - bash has no chpwd hook for directory changes\n    _waveterm_si_osc7\n    printf '\\033]16162;A\\007'\n    _WAVETERM_SI_FIRSTPROMPT=0\n}\n\n_waveterm_si_preexec() {\n    _waveterm_si_blocked && return\n    \n    local cmd=\"$1\"\n    local cmd_length=${#cmd}\n    if [ \"$cmd_length\" -gt 8192 ]; then\n        cmd=$(printf '# command too large (%d bytes)' \"$cmd_length\")\n    fi\n    local cmd64\n    cmd64=$(printf '%s' \"$cmd\" | base64 2>/dev/null | tr -d '\\n\\r')\n    if [ -n \"$cmd64\" ]; then\n        printf '\\033]16162;C;{\"cmd64\":\"%s\"}\\007' \"$cmd64\"\n    else\n        printf '\\033]16162;C\\007'\n    fi\n}\n\n# Add our functions to the bash-preexec arrays\nprecmd_functions+=(_waveterm_si_precmd)\npreexec_functions+=(_waveterm_si_preexec)"
  },
  {
    "path": "pkg/util/shellutil/shellintegration/bash_preexec.sh",
    "content": "# License for bash-preexec.sh follows below from https://github.com/rcaloras/bash-preexec v0.6.0\n# -----------------------------------------------------------------------------\n# The MIT License\n#\n# Copyright (c) 2017 Ryan Caloras and contributors (see https://github.com/rcaloras/bash-preexec)\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in\n# all copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n# THE SOFTWARE.\n#\n# -----------------------------------------------------------------------------\n#\n# bash-preexec.sh -- Bash support for ZSH-like 'preexec' and 'precmd' functions.\n# https://github.com/rcaloras/bash-preexec\n#\n#\n# 'preexec' functions are executed before each interactive command is\n# executed, with the interactive command as its argument. The 'precmd'\n# function is executed before each prompt is displayed.\n#\n# Author: Ryan Caloras (ryan@bashhub.com)\n# Forked from Original Author: Glyph Lefkowitz\n#\n# V0.6.0\n#\n\n# General Usage:\n#\n#  1. Source this file at the end of your bash profile so as not to interfere\n#     with anything else that's using PROMPT_COMMAND.\n#\n#  2. Add any precmd or preexec functions by appending them to their arrays:\n#       e.g.\n#       precmd_functions+=(my_precmd_function)\n#       precmd_functions+=(some_other_precmd_function)\n#\n#       preexec_functions+=(my_preexec_function)\n#\n#  3. Consider changing anything using the DEBUG trap or PROMPT_COMMAND\n#     to use preexec and precmd instead. Preexisting usages will be\n#     preserved, but doing so manually may be less surprising.\n#\n#  Note: This module requires two Bash features which you must not otherwise be\n#  using: the \"DEBUG\" trap, and the \"PROMPT_COMMAND\" variable. If you override\n#  either of these after bash-preexec has been installed it will most likely break.\n\n# Tell shellcheck what kind of file this is.\n# shellcheck shell=bash\n\n# Make sure this is bash that's running and return otherwise.\n# Use POSIX syntax for this line:\nif [ -z \"${BASH_VERSION-}\" ]; then\n    return 1\nfi\n\n# We only support Bash 3.1+.\n# Note: BASH_VERSINFO is first available in Bash-2.0.\nif [[ -z \"${BASH_VERSINFO-}\" ]] || (( BASH_VERSINFO[0] < 3 || (BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 1) )); then\n    return 1\nfi\n\n# Avoid duplicate inclusion\nif [[ -n \"${bash_preexec_imported:-}\" || -n \"${__bp_imported:-}\" ]]; then\n    return 0\nfi\nbash_preexec_imported=\"defined\"\n\n# WARNING: This variable is no longer used and should not be relied upon.\n# Use ${bash_preexec_imported} instead.\n# shellcheck disable=SC2034\n__bp_imported=\"${bash_preexec_imported}\"\n\n# Should be available to each precmd and preexec\n# functions, should they want it. $? and $_ are available as $? and $_, but\n# $PIPESTATUS is available only in a copy, $BP_PIPESTATUS.\n# TODO: Figure out how to restore PIPESTATUS before each precmd or preexec\n# function.\n__bp_last_ret_value=\"$?\"\nBP_PIPESTATUS=(\"${PIPESTATUS[@]}\")\n__bp_last_argument_prev_command=\"$_\"\n\n__bp_inside_precmd=0\n__bp_inside_preexec=0\n\n# Initial PROMPT_COMMAND string that is removed from PROMPT_COMMAND post __bp_install\n__bp_install_string=$'__bp_trap_string=\"$(trap -p DEBUG)\"\\ntrap - DEBUG\\n__bp_install'\n\n# Fails if any of the given variables are readonly\n# Reference https://stackoverflow.com/a/4441178\n__bp_require_not_readonly() {\n    local var\n    for var; do\n        if ! ( unset \"$var\" 2> /dev/null ); then\n            echo \"bash-preexec requires write access to ${var}\" >&2\n            return 1\n        fi\n    done\n}\n\n# Remove ignorespace and or replace ignoreboth from HISTCONTROL\n# so we can accurately invoke preexec with a command from our\n# history even if it starts with a space.\n__bp_adjust_histcontrol() {\n    local histcontrol\n    histcontrol=\"${HISTCONTROL:-}\"\n    histcontrol=\"${histcontrol//ignorespace}\"\n    # Replace ignoreboth with ignoredups\n    if [[ \"$histcontrol\" == *\"ignoreboth\"* ]]; then\n        histcontrol=\"ignoredups:${histcontrol//ignoreboth}\"\n    fi\n    export HISTCONTROL=\"$histcontrol\"\n}\n\n# This variable describes whether we are currently in \"interactive mode\";\n# i.e. whether this shell has just executed a prompt and is waiting for user\n# input.  It documents whether the current command invoked by the trace hook is\n# run interactively by the user; it's set immediately after the prompt hook,\n# and unset as soon as the trace hook is run.\n__bp_preexec_interactive_mode=\"\"\n\n# These arrays are used to add functions to be run before, or after, prompts.\ndeclare -a precmd_functions\ndeclare -a preexec_functions\n\n# Trims leading and trailing whitespace from $2 and writes it to the variable\n# name passed as $1\n__bp_trim_whitespace() {\n    local var=${1:?} text=${2:-}\n    text=\"${text#\"${text%%[![:space:]]*}\"}\"   # remove leading whitespace characters\n    text=\"${text%\"${text##*[![:space:]]}\"}\"   # remove trailing whitespace characters\n    printf -v \"$var\" '%s' \"$text\"\n}\n\n\n# Trims whitespace and removes any leading or trailing semicolons from $2 and\n# writes the resulting string to the variable name passed as $1. Used for\n# manipulating substrings in PROMPT_COMMAND\n__bp_sanitize_string() {\n    local var=${1:?} text=${2:-} sanitized\n    __bp_trim_whitespace sanitized \"$text\"\n    sanitized=${sanitized%;}\n    sanitized=${sanitized#;}\n    __bp_trim_whitespace sanitized \"$sanitized\"\n    printf -v \"$var\" '%s' \"$sanitized\"\n}\n\n# This function is installed as part of the PROMPT_COMMAND;\n# It sets a variable to indicate that the prompt was just displayed,\n# to allow the DEBUG trap to know that the next command is likely interactive.\n__bp_interactive_mode() {\n    __bp_preexec_interactive_mode=\"on\"\n}\n\n\n# This function is installed as part of the PROMPT_COMMAND.\n# It will invoke any functions defined in the precmd_functions array.\n__bp_precmd_invoke_cmd() {\n    # Save the returned value from our last command, and from each process in\n    # its pipeline. Note: this MUST be the first thing done in this function.\n    # BP_PIPESTATUS may be unused, ignore\n    # shellcheck disable=SC2034\n\n    __bp_last_ret_value=\"$?\" BP_PIPESTATUS=(\"${PIPESTATUS[@]}\")\n\n    # Don't invoke precmds if we are inside an execution of an \"original\n    # prompt command\" by another precmd execution loop. This avoids infinite\n    # recursion.\n    if (( __bp_inside_precmd > 0 )); then\n        return\n    fi\n    local __bp_inside_precmd=1\n\n    # Invoke every function defined in our function array.\n    local precmd_function\n    for precmd_function in \"${precmd_functions[@]}\"; do\n\n        # Only execute this function if it actually exists.\n        # Test existence of functions with: declare -[Ff]\n        if type -t \"$precmd_function\" 1>/dev/null; then\n            __bp_set_ret_value \"$__bp_last_ret_value\" \"$__bp_last_argument_prev_command\"\n            # Quote our function invocation to prevent issues with IFS\n            \"$precmd_function\"\n        fi\n    done\n\n    __bp_set_ret_value \"$__bp_last_ret_value\"\n}\n\n# Sets a return value in $?. We may want to get access to the $? variable in our\n# precmd functions. This is available for instance in zsh. We can simulate it in bash\n# by setting the value here.\n__bp_set_ret_value() {\n    return ${1:+\"$1\"}\n}\n\n__bp_in_prompt_command() {\n\n    local prompt_command_array IFS=$'\\n;'\n    read -rd '' -a prompt_command_array <<< \"${PROMPT_COMMAND[*]:-}\"\n\n    local trimmed_arg\n    __bp_trim_whitespace trimmed_arg \"${1:-}\"\n\n    local command trimmed_command\n    for command in \"${prompt_command_array[@]:-}\"; do\n        __bp_trim_whitespace trimmed_command \"$command\"\n        if [[ \"$trimmed_command\" == \"$trimmed_arg\" ]]; then\n            return 0\n        fi\n    done\n\n    return 1\n}\n\n# This function is installed as the DEBUG trap.  It is invoked before each\n# interactive prompt display.  Its purpose is to inspect the current\n# environment to attempt to detect if the current command is being invoked\n# interactively, and invoke 'preexec' if so.\n__bp_preexec_invoke_exec() {\n\n    # Save the contents of $_ so that it can be restored later on.\n    # https://stackoverflow.com/questions/40944532/bash-preserve-in-a-debug-trap#40944702\n    __bp_last_argument_prev_command=\"${1:-}\"\n    # Don't invoke preexecs if we are inside of another preexec.\n    if (( __bp_inside_preexec > 0 )); then\n        return\n    fi\n    local __bp_inside_preexec=1\n\n    # Checks if the file descriptor is not standard out (i.e. '1')\n    # __bp_delay_install checks if we're in test. Needed for bats to run.\n    # Prevents preexec from being invoked for functions in PS1\n    if [[ ! -t 1 && -z \"${__bp_delay_install:-}\" ]]; then\n        return\n    fi\n\n    if [[ -n \"${COMP_POINT:-}\" || -n \"${READLINE_POINT:-}\" ]]; then\n        # We're in the middle of a completer or a keybinding set up by \"bind\n        # -x\".  This obviously can't be an interactively issued command.\n        return\n    fi\n    if [[ -z \"${__bp_preexec_interactive_mode:-}\" ]]; then\n        # We're doing something related to displaying the prompt.  Let the\n        # prompt set the title instead of me.\n        return\n    else\n        # If we're in a subshell, then the prompt won't be re-displayed to put\n        # us back into interactive mode, so let's not set the variable back.\n        # In other words, if you have a subshell like\n        #   (sleep 1; sleep 2)\n        # You want to see the 'sleep 2' as a set_command_title as well.\n        if [[ 0 -eq \"${BASH_SUBSHELL:-}\" ]]; then\n            __bp_preexec_interactive_mode=\"\"\n        fi\n    fi\n\n    if  __bp_in_prompt_command \"${BASH_COMMAND:-}\"; then\n        # If we're executing something inside our prompt_command then we don't\n        # want to call preexec. Bash prior to 3.1 can't detect this at all :/\n        __bp_preexec_interactive_mode=\"\"\n        return\n    fi\n\n    local this_command\n    this_command=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1)\n    this_command=\"${this_command#*[[:digit:]][* ] }\"\n\n    # Sanity check to make sure we have something to invoke our function with.\n    if [[ -z \"$this_command\" ]]; then\n        return\n    fi\n\n    # Invoke every function defined in our function array.\n    local preexec_function\n    local preexec_function_ret_value\n    local preexec_ret_value=0\n    for preexec_function in \"${preexec_functions[@]:-}\"; do\n\n        # Only execute each function if it actually exists.\n        # Test existence of function with: declare -[fF]\n        if type -t \"$preexec_function\" 1>/dev/null; then\n            __bp_set_ret_value \"${__bp_last_ret_value:-}\"\n            # Quote our function invocation to prevent issues with IFS\n            \"$preexec_function\" \"$this_command\"\n            preexec_function_ret_value=\"$?\"\n            if [[ \"$preexec_function_ret_value\" != 0 ]]; then\n                preexec_ret_value=\"$preexec_function_ret_value\"\n            fi\n        fi\n    done\n\n    # Restore the last argument of the last executed command, and set the return\n    # value of the DEBUG trap to be the return code of the last preexec function\n    # to return an error.\n    # If `extdebug` is enabled a non-zero return value from any preexec function\n    # will cause the user's command not to execute.\n    # Run `shopt -s extdebug` to enable\n    __bp_set_ret_value \"$preexec_ret_value\" \"$__bp_last_argument_prev_command\"\n}\n\n__bp_install() {\n    # Exit if we already have this installed.\n    if [[ \"${PROMPT_COMMAND[*]:-}\" == *\"__bp_precmd_invoke_cmd\"* ]]; then\n        return 1\n    fi\n\n    trap '__bp_preexec_invoke_exec \"$_\"' DEBUG\n\n    # Preserve any prior DEBUG trap as a preexec function\n    eval \"local trap_argv=(${__bp_trap_string:-})\"\n    local prior_trap=${trap_argv[2]:-}\n    unset __bp_trap_string\n    if [[ -n \"$prior_trap\" ]]; then\n        eval '__bp_original_debug_trap() {\n            '\"$prior_trap\"'\n        }'\n        preexec_functions+=(__bp_original_debug_trap)\n    fi\n\n    # Adjust our HISTCONTROL Variable if needed.\n    __bp_adjust_histcontrol\n\n    # Issue #25. Setting debug trap for subshells causes sessions to exit for\n    # backgrounded subshell commands (e.g. (pwd)& ). Believe this is a bug in Bash.\n    #\n    # Disabling this by default. It can be enabled by setting this variable.\n    if [[ -n \"${__bp_enable_subshells:-}\" ]]; then\n\n        # Set so debug trap will work be invoked in subshells.\n        set -o functrace > /dev/null 2>&1\n        shopt -s extdebug > /dev/null 2>&1\n    fi\n\n    local existing_prompt_command\n    # Remove setting our trap install string and sanitize the existing prompt command string\n    existing_prompt_command=\"${PROMPT_COMMAND:-}\"\n    # Edge case of appending to PROMPT_COMMAND\n    existing_prompt_command=\"${existing_prompt_command//$__bp_install_string/:}\" # no-op\n    existing_prompt_command=\"${existing_prompt_command//$'\\n':$'\\n'/$'\\n'}\" # remove known-token only\n    existing_prompt_command=\"${existing_prompt_command//$'\\n':;/$'\\n'}\" # remove known-token only\n    __bp_sanitize_string existing_prompt_command \"$existing_prompt_command\"\n    if [[ \"${existing_prompt_command:-:}\" == \":\" ]]; then\n        existing_prompt_command=\n    fi\n\n    # Install our hooks in PROMPT_COMMAND to allow our trap to know when we've\n    # actually entered something.\n    PROMPT_COMMAND='__bp_precmd_invoke_cmd'\n    PROMPT_COMMAND+=${existing_prompt_command:+$'\\n'$existing_prompt_command}\n    if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then\n        PROMPT_COMMAND+=('__bp_interactive_mode')\n    else\n        # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0\n        PROMPT_COMMAND+=$'\\n__bp_interactive_mode'\n    fi\n\n    # Add two functions to our arrays for convenience\n    # of definition.\n    precmd_functions+=(precmd)\n    preexec_functions+=(preexec)\n\n    # Invoke our two functions manually that were added to $PROMPT_COMMAND\n    __bp_precmd_invoke_cmd\n    __bp_interactive_mode\n}\n\n# Sets an installation string as part of our PROMPT_COMMAND to install\n# after our session has started. This allows bash-preexec to be included\n# at any point in our bash profile.\n__bp_install_after_session_init() {\n    # bash-preexec needs to modify these variables in order to work correctly\n    # if it can't, just stop the installation\n    __bp_require_not_readonly PROMPT_COMMAND HISTCONTROL HISTTIMEFORMAT || return\n\n    local sanitized_prompt_command\n    __bp_sanitize_string sanitized_prompt_command \"${PROMPT_COMMAND:-}\"\n    if [[ -n \"$sanitized_prompt_command\" ]]; then\n        # shellcheck disable=SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0\n        PROMPT_COMMAND=${sanitized_prompt_command}$'\\n'\n    fi\n    # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0\n    PROMPT_COMMAND+=${__bp_install_string}\n}\n\n# Run our install so long as we're not delaying it.\nif [[ -z \"${__bp_delay_install:-}\" ]]; then\n    __bp_install_after_session_init\nfi"
  },
  {
    "path": "pkg/util/shellutil/shellintegration/fish_wavefish.sh",
    "content": "# this file is sourced with -C\n# Add Wave binary directory to PATH\nset -x PATH {{.WSHBINDIR}} $PATH\n\n# Source dynamic script from wsh token (the echo is to prevent fish from complaining about empty input)\nwsh token \"$WAVETERM_SWAPTOKEN\" fish 2>/dev/null | source\nset -e WAVETERM_SWAPTOKEN\n\n# Load Wave completions\nwsh completion fish | source\n\nset -g _WAVETERM_SI_FIRSTPROMPT 1\n\n# shell integration\nfunction _waveterm_si_blocked\n    # Check if we're in tmux or screen (using fish-native checks)\n    set -q TMUX; or set -q STY; or string match -q 'tmux*' -- $TERM; or string match -q 'screen*' -- $TERM\nend\n\nfunction _waveterm_si_osc7\n    _waveterm_si_blocked; and return\n    # Use fish-native URL encoding\n    set -l encoded_pwd (string escape --style=url -- \"$PWD\")\n    printf '\\033]7;file://localhost%s\\007' $encoded_pwd\nend\n\nfunction _waveterm_si_prompt --on-event fish_prompt\n    set -l _waveterm_si_status $status\n    _waveterm_si_blocked; and return\n    if test $_WAVETERM_SI_FIRSTPROMPT -eq 1\n        set -l uname_info (uname -smr 2>/dev/null)\n        printf '\\033]16162;M;{\"shell\":\"fish\",\"shellversion\":\"%s\",\"uname\":\"%s\",\"integration\":true}\\007' $FISH_VERSION \"$uname_info\"\n        # OSC 7 only sent on first prompt - chpwd hook handles directory changes\n        _waveterm_si_osc7\n    else\n        printf '\\033]16162;D;{\"exitcode\":%d}\\007' $_waveterm_si_status\n    end\n    printf '\\033]16162;A\\007'\n    set -g _WAVETERM_SI_FIRSTPROMPT 0\nend\n\nfunction _waveterm_si_preexec --on-event fish_preexec\n    _waveterm_si_blocked; and return\n    set -l cmd (string join -- ' ' $argv)\n    set -l cmd_length (string length -- \"$cmd\")\n    if test $cmd_length -gt 8192\n        set -l cmd64 (printf '# command too large (%d bytes)' $cmd_length | base64 2>/dev/null | string replace -a '\\n' '' | string replace -a '\\r' '')\n        printf '\\033]16162;C;{\"cmd64\":\"%s\"}\\007' \"$cmd64\"\n    else\n        set -l cmd64 (printf '%s' \"$cmd\" | base64 2>/dev/null | string replace -a '\\n' '' | string replace -a '\\r' '')\n        if test -n \"$cmd64\"\n            printf '\\033]16162;C;{\"cmd64\":\"%s\"}\\007' \"$cmd64\"\n        else\n            printf '\\033]16162;C\\007'\n        end\n    end\nend\n\n# Also update on directory change\nfunction _waveterm_si_chpwd --on-variable PWD\n    _waveterm_si_osc7\nend"
  },
  {
    "path": "pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh",
    "content": "# We source this file with -NoExit -File\n$env:PATH = {{.WSHBINDIR_PWSH}} + \"{{.PATHSEP}}\" + $env:PATH\n\n# Source dynamic script from wsh token\n$waveterm_swaptoken_output = wsh token $env:WAVETERM_SWAPTOKEN pwsh 2>$null | Out-String\nif ($waveterm_swaptoken_output -and $waveterm_swaptoken_output -ne \"\") {\n    Invoke-Expression $waveterm_swaptoken_output\n}\nRemove-Variable -Name waveterm_swaptoken_output\nRemove-Item Env:WAVETERM_SWAPTOKEN\n\n# Load Wave completions\nwsh completion powershell | Out-String | Invoke-Expression\n\nif ($PSVersionTable.PSVersion.Major -lt 7) {\n    return  # skip OSC setup entirely\n}\n\n$Global:_WAVETERM_SI_FIRSTPROMPT = $true\n\n# shell integration\nfunction Global:_waveterm_si_blocked {\n    # Check if we're in tmux or screen\n    return ($env:TMUX -or $env:STY -or $env:TERM -like \"tmux*\" -or $env:TERM -like \"screen*\")\n}\n\nfunction Global:_waveterm_si_osc7 {\n    if (_waveterm_si_blocked) { return }\n    \n    # Percent-encode the raw path as-is (handles UNC, drive letters, etc.)\n    $encoded_pwd = [System.Uri]::EscapeDataString($PWD.Path)\n    \n    # OSC 7 - current directory\n    Write-Host -NoNewline \"`e]7;file://localhost/$encoded_pwd`a\"\n}\n\nfunction Global:_waveterm_si_prompt {\n    if (_waveterm_si_blocked) { return }\n    \n    if ($Global:_WAVETERM_SI_FIRSTPROMPT) {\n\t\t# not sending uname\n\t\t       $shellversion = $PSVersionTable.PSVersion.ToString()\n\t\t       Write-Host -NoNewline \"`e]16162;M;{`\"shell`\":`\"pwsh`\",`\"shellversion`\":`\"$shellversion`\",`\"integration`\":false}`a\"\n        $Global:_WAVETERM_SI_FIRSTPROMPT = $false\n    }\n    \n    _waveterm_si_osc7\n}\n\n# Add the OSC 7 call to the prompt function\nif (Test-Path Function:\\prompt) {\n    $global:_waveterm_original_prompt = $function:prompt\n    function Global:prompt {\n        _waveterm_si_prompt\n        & $global:_waveterm_original_prompt\n    }\n} else {\n    function Global:prompt {\n        _waveterm_si_prompt\n        \"PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) \"\n    }\n}"
  },
  {
    "path": "pkg/util/shellutil/shellintegration/zsh_zlogin.sh",
    "content": "# Source the original zlogin\n[ -f ~/.zlogin ] && source ~/.zlogin\n\n# Unset ZDOTDIR only if it hasn't been modified\nif [ \"$ZDOTDIR\" = \"$WAVETERM_ZDOTDIR\" ]; then\n  unset ZDOTDIR\nfi"
  },
  {
    "path": "pkg/util/shellutil/shellintegration/zsh_zprofile.sh",
    "content": "# Source the original zprofile\n[ -f ~/.zprofile ] && source ~/.zprofile"
  },
  {
    "path": "pkg/util/shellutil/shellintegration/zsh_zshenv.sh",
    "content": "# Store the initial ZDOTDIR value\nWAVETERM_ZDOTDIR=\"$ZDOTDIR\"\n\n# Source the original zshenv\n[ -f ~/.zshenv ] && source ~/.zshenv\n\n# Detect if ZDOTDIR has changed\nif [ \"$ZDOTDIR\" != \"$WAVETERM_ZDOTDIR\" ]; then\n  # If changed, manually source your custom zshrc from the original WAVETERM_ZDOTDIR\n  [ -f \"$WAVETERM_ZDOTDIR/.zshrc\" ] && source \"$WAVETERM_ZDOTDIR/.zshrc\"\nfi"
  },
  {
    "path": "pkg/util/shellutil/shellintegration/zsh_zshrc.sh",
    "content": "# add wsh to path, source dynamic script from wsh token\nWAVETERM_WSHBINDIR={{.WSHBINDIR}}\nexport PATH=\"$WAVETERM_WSHBINDIR:$PATH\"\nsource <(wsh token \"$WAVETERM_SWAPTOKEN\" zsh 2>/dev/null)\nunset WAVETERM_SWAPTOKEN\n\n# Source the original zshrc only if ZDOTDIR has not been changed\nif [ \"$ZDOTDIR\" = \"$WAVETERM_ZDOTDIR\" ]; then\n  [ -f ~/.zshrc ] && source ~/.zshrc\nfi\n\nif [[ \":$PATH:\" != *\":$WAVETERM_WSHBINDIR:\"* ]]; then\n  export PATH=\"$WAVETERM_WSHBINDIR:$PATH\"\nfi\nunset WAVETERM_WSHBINDIR\n\nif [[ -n ${_comps+x} ]]; then\n  source <(wsh completion zsh)\nfi\n\n# fix history (macos)\nif [[ \"$HISTFILE\" == \"$WAVETERM_ZDOTDIR/.zsh_history\" ]]; then\n  HISTFILE=\"$HOME/.zsh_history\"\nfi\n\ntypeset -g _WAVETERM_SI_FIRSTPRECMD=1\n\n# shell integration\n_waveterm_si_blocked() {\n  [[ -n \"$TMUX\" || -n \"$STY\" || \"$TERM\" == tmux* || \"$TERM\" == screen* ]]\n}\n\n_waveterm_si_urlencode() {\n  if (( $+functions[omz_urlencode] )); then\n    omz_urlencode \"$1\"\n  else\n    local s=\"$1\"\n    # Escape % first\n    s=${s//\\%/%25}\n    # Common reserved characters in file paths\n    s=${s//\\ /%20}\n    s=${s//\\#/%23}\n    s=${s//\\?/%3F}\n    s=${s//\\&/%26}\n    s=${s//\\;/%3B}\n    s=${s//\\+/%2B}\n    printf '%s' \"$s\"\n  fi\n}\n\n_waveterm_si_compmode() {\n  # fzf-based completion wins\n  if typeset -f _fzf_tab_complete >/dev/null 2>&1 || typeset -f _fzf_complete >/dev/null 2>&1; then\n    echo \"fzf\"\n    return\n  fi\n\n  # Check zstyle menu setting\n  local _menuval\n  if zstyle -s ':completion:*' menu _menuval 2>/dev/null; then\n    if [[ \"$_menuval\" == *select* ]]; then\n      echo \"menu-select\"\n    else\n      echo \"menu\"\n    fi\n    return\n  fi\n\n  echo \"standard\"\n}\n\n_waveterm_si_osc7() {\n  _waveterm_si_blocked && return\n  local encoded_pwd=$(_waveterm_si_urlencode \"$PWD\")\n  printf '\\033]7;file://localhost%s\\007' \"$encoded_pwd\"  # OSC 7 - current directory\n}\n\n_waveterm_si_precmd() {\n  local _waveterm_si_status=$?\n  _waveterm_si_blocked && return\n  # D;status for previous command (skip before first prompt)\n  if (( !_WAVETERM_SI_FIRSTPRECMD )); then\n    printf '\\033]16162;D;{\"exitcode\":%d}\\007' \"$_waveterm_si_status\"\n  else\n    local uname_info=$(uname -smr 2>/dev/null)\n    local omz=false\n    local comp=$(_waveterm_si_compmode)\n    [[ -n \"$ZSH\" && -r \"$ZSH/oh-my-zsh.sh\" ]] && omz=true\n    printf '\\033]16162;M;{\"shell\":\"zsh\",\"shellversion\":\"%s\",\"uname\":\"%s\",\"integration\":true,\"omz\":%s,\"comp\":\"%s\"}\\007' \"$ZSH_VERSION\" \"$uname_info\" \"$omz\" \"$comp\"\n    # OSC 7 only sent on first prompt - chpwd hook handles directory changes\n    _waveterm_si_osc7\n  fi\n  printf '\\033]16162;A\\007'\n  _WAVETERM_SI_FIRSTPRECMD=0\n}\n\n_waveterm_si_preexec() {\n  _waveterm_si_blocked && return\n  local cmd=\"$1\"\n  local cmd_length=${#cmd}\n  if [ \"$cmd_length\" -gt 8192 ]; then\n    cmd=$(printf '# command too large (%d bytes)' \"$cmd_length\")\n  fi\n  local cmd64\n  cmd64=$(printf '%s' \"$cmd\" | base64 2>/dev/null | tr -d '\\n\\r')\n  if [ -n \"$cmd64\" ]; then\n    printf '\\033]16162;C;{\"cmd64\":\"%s\"}\\007' \"$cmd64\"\n  else\n    printf '\\033]16162;C\\007'\n  fi\n}\n\ntypeset -g WAVETERM_SI_INPUTEMPTY=1\n\n_waveterm_si_inputempty() {\n  _waveterm_si_blocked && return\n  \n  local current_empty=1\n  if [[ -n \"$BUFFER\" ]]; then\n    current_empty=0\n  fi\n  \n  if (( current_empty != WAVETERM_SI_INPUTEMPTY )); then\n    WAVETERM_SI_INPUTEMPTY=$current_empty\n    if (( current_empty )); then\n      printf '\\033]16162;I;{\"inputempty\":true}\\007'\n    else\n      printf '\\033]16162;I;{\"inputempty\":false}\\007'\n    fi\n  fi\n}\n\nautoload -Uz add-zle-hook-widget 2>/dev/null\nif (( $+functions[add-zle-hook-widget] )); then\n  add-zle-hook-widget zle-line-init _waveterm_si_inputempty\n  add-zle-hook-widget zle-line-pre-redraw _waveterm_si_inputempty\nfi\n\nautoload -U add-zsh-hook\nadd-zsh-hook precmd  _waveterm_si_precmd\nadd-zsh-hook preexec _waveterm_si_preexec\nadd-zsh-hook chpwd   _waveterm_si_osc7"
  },
  {
    "path": "pkg/util/shellutil/shellquote.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage shellutil\n\nimport (\n\t\"log\"\n\t\"regexp\"\n)\n\nconst (\n\tMaxQuoteSize = 10000000 // 10MB\n)\n\nvar (\n\tsafePattern       = regexp.MustCompile(`^[a-zA-Z0-9_@:,+=/.-]+$`)\n\tenvVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)\n)\n\nfunc IsValidEnvVarName(name string) bool {\n\treturn envVarNamePattern.MatchString(name)\n}\n\nfunc HardQuote(s string) string {\n\tif s == \"\" {\n\t\treturn \"\\\"\\\"\"\n\t}\n\n\tif safePattern.MatchString(s) {\n\t\treturn s\n\t}\n\n\tif !checkQuoteSize(s) {\n\t\treturn \"\"\n\t}\n\n\tbuf := make([]byte, 0, len(s)+5)\n\tbuf = append(buf, '\"')\n\n\tfor i := 0; i < len(s); i++ {\n\t\tswitch s[i] {\n\t\tcase '\"', '\\\\', '$', '`':\n\t\t\tbuf = append(buf, '\\\\', s[i])\n\t\tdefault:\n\t\t\tbuf = append(buf, s[i])\n\t\t}\n\t}\n\n\tbuf = append(buf, '\"')\n\treturn string(buf)\n}\n\n// does not encode newlines or backticks\nfunc HardQuoteFish(s string) string {\n\tif s == \"\" {\n\t\treturn \"\\\"\\\"\"\n\t}\n\n\tif safePattern.MatchString(s) {\n\t\treturn s\n\t}\n\n\tif !checkQuoteSize(s) {\n\t\treturn \"\"\n\t}\n\n\tbuf := make([]byte, 0, len(s)+5)\n\tbuf = append(buf, '\"')\n\n\tfor i := 0; i < len(s); i++ {\n\t\tswitch s[i] {\n\t\tcase '\"', '\\\\', '$':\n\t\t\tbuf = append(buf, '\\\\', s[i])\n\t\tdefault:\n\t\t\tbuf = append(buf, s[i])\n\t\t}\n\t}\n\n\tbuf = append(buf, '\"')\n\treturn string(buf)\n}\n\nfunc HardQuotePowerShell(s string) string {\n\tif s == \"\" {\n\t\treturn \"\\\"\\\"\"\n\t}\n\n\tif !checkQuoteSize(s) {\n\t\treturn \"\"\n\t}\n\n\tbuf := make([]byte, 0, len(s)+5)\n\tbuf = append(buf, '\"')\n\n\tfor i := 0; i < len(s); i++ {\n\t\tc := s[i]\n\t\t// In PowerShell, backtick (`) is the escape character\n\t\tswitch c {\n\t\tcase '\"', '`', '$':\n\t\t\tbuf = append(buf, '`')\n\t\tcase '\\n':\n\t\t\tbuf = append(buf, '`', 'n') // PowerShell uses `n for newline\n\t\t}\n\t\tbuf = append(buf, c)\n\t}\n\n\tbuf = append(buf, '\"')\n\treturn string(buf)\n}\n\nfunc SoftQuote(s string) string {\n\tif s == \"\" {\n\t\treturn \"\\\"\\\"\"\n\t}\n\n\t// Handle special case of ~ paths\n\tif len(s) > 0 && s[0] == '~' {\n\t\t// If it's just ~ or ~/something with no special chars, leave it as is\n\t\tif len(s) == 1 || (len(s) > 1 && s[1] == '/' && safePattern.MatchString(s[2:])) {\n\t\t\treturn s\n\t\t}\n\n\t\t// Otherwise quote everything after the ~ (including the /)\n\t\tif len(s) > 1 && s[1] == '/' {\n\t\t\treturn \"~\" + SoftQuote(s[1:])\n\t\t}\n\t}\n\n\tif safePattern.MatchString(s) {\n\t\treturn s\n\t}\n\n\tif !checkQuoteSize(s) {\n\t\treturn \"\"\n\t}\n\n\tbuf := make([]byte, 0, len(s)+5)\n\tbuf = append(buf, '\"')\n\n\tfor i := 0; i < len(s); i++ {\n\t\tc := s[i]\n\t\t// In soft quote, we don't escape $ to allow expansion\n\t\tif c == '\"' || c == '\\\\' || c == '`' {\n\t\t\tbuf = append(buf, '\\\\')\n\t\t}\n\t\tbuf = append(buf, c)\n\t}\n\n\tbuf = append(buf, '\"')\n\treturn string(buf)\n}\n\nfunc checkQuoteSize(s string) bool {\n\tif len(s) > MaxQuoteSize {\n\t\tlog.Printf(\"string too long to quote: %s\", s)\n\t\treturn false\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "pkg/util/shellutil/shellquote_test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\npackage shellutil\n\nimport \"testing\"\n\nfunc TestQuote(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\twantHard string\n\t\twantSoft string\n\t}{\n\t\t{\n\t\t\tname:     \"simple strings\",\n\t\t\tinput:    \"simple\",\n\t\t\twantHard: \"simple\",\n\t\t\twantSoft: \"simple\",\n\t\t},\n\t\t{\n\t\t\tname:     \"safe path\",\n\t\t\tinput:    \"path/to/file.txt\",\n\t\t\twantHard: \"path/to/file.txt\",\n\t\t\twantSoft: \"path/to/file.txt\",\n\t\t},\n\t\t{\n\t\t\tname:     \"empty string\",\n\t\t\tinput:    \"\",\n\t\t\twantHard: `\"\"`,\n\t\t\twantSoft: `\"\"`,\n\t\t},\n\t\t{\n\t\t\tname:     \"tilde alone\",\n\t\t\tinput:    \"~\",\n\t\t\twantHard: `\"~\"`,\n\t\t\twantSoft: \"~\",\n\t\t},\n\t\t{\n\t\t\tname:     \"tilde with safe path\",\n\t\t\tinput:    \"~/foo\",\n\t\t\twantHard: `\"~/foo\"`,\n\t\t\twantSoft: \"~/foo\",\n\t\t},\n\t\t{\n\t\t\tname:     \"tilde with spaces\",\n\t\t\tinput:    \"~/foo bar\",\n\t\t\twantHard: `\"~/foo bar\"`,\n\t\t\twantSoft: `~\"/foo bar\"`,\n\t\t},\n\t\t{\n\t\t\tname:     \"tilde with variable\",\n\t\t\tinput:    \"~/foo$bar\",\n\t\t\twantHard: `\"~/foo\\$bar\"`,\n\t\t\twantSoft: `~\"/foo$bar\"`,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid tilde path\",\n\t\t\tinput:    \"~foo\",\n\t\t\twantHard: `\"~foo\"`,\n\t\t\twantSoft: `\"~foo\"`,\n\t\t},\n\t\t{\n\t\t\tname:     \"variable at start\",\n\t\t\tinput:    \"$HOME/.config\",\n\t\t\twantHard: `\"\\$HOME/.config\"`,\n\t\t\twantSoft: `\"$HOME/.config\"`,\n\t\t},\n\t\t{\n\t\t\tname:     \"variable in middle\",\n\t\t\tinput:    \"prefix$HOME\",\n\t\t\twantHard: `\"prefix\\$HOME\"`,\n\t\t\twantSoft: `\"prefix$HOME\"`,\n\t\t},\n\t\t{\n\t\t\tname:     \"double quotes\",\n\t\t\tinput:    `has \"quotes\"`,\n\t\t\twantHard: `\"has \\\"quotes\\\"\"`,\n\t\t\twantSoft: `\"has \\\"quotes\\\"\"`,\n\t\t},\n\t\t{\n\t\t\tname:     \"backslash\",\n\t\t\tinput:    `back\\slash`,\n\t\t\twantHard: `\"back\\\\slash\"`,\n\t\t\twantSoft: `\"back\\\\slash\"`,\n\t\t},\n\t\t{\n\t\t\tname:     \"backtick\",\n\t\t\tinput:    \"`cmd`\",\n\t\t\twantHard: \"\\\"\\\\`cmd\\\\`\\\"\",\n\t\t\twantSoft: \"\\\"\\\\`cmd\\\\`\\\"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"spaces\",\n\t\t\tinput:    \"spaces here\",\n\t\t\twantHard: `\"spaces here\"`,\n\t\t\twantSoft: `\"spaces here\"`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := HardQuote(tt.input); got != tt.wantHard {\n\t\t\t\tt.Errorf(\"HardQuote(%q) = %q, want %q\", tt.input, got, tt.wantHard)\n\t\t\t}\n\t\t\tif got := SoftQuote(tt.input); got != tt.wantSoft {\n\t\t\t\tt.Errorf(\"SoftQuote(%q) = %q, want %q\", tt.input, got, tt.wantSoft)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/util/shellutil/shellutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage shellutil\n\nimport (\n\t\"context\"\n\t_ \"embed\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"os/exec\"\n\t\"os/user\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/envutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/utilds\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n)\n\nvar (\n\t//go:embed shellintegration/zsh_zprofile.sh\n\tZshStartup_Zprofile string\n\n\t//go:embed shellintegration/zsh_zshrc.sh\n\tZshStartup_Zshrc string\n\n\t//go:embed shellintegration/zsh_zlogin.sh\n\tZshStartup_Zlogin string\n\n\t//go:embed shellintegration/zsh_zshenv.sh\n\tZshStartup_Zshenv string\n\n\t//go:embed shellintegration/bash_bashrc.sh\n\tBashStartup_Bashrc string\n\n\t//go:embed shellintegration/bash_preexec.sh\n\tBashStartup_Preexec string\n\n\t//go:embed shellintegration/fish_wavefish.sh\n\tFishStartup_Wavefish string\n\n\t//go:embed shellintegration/pwsh_wavepwsh.sh\n\tPwshStartup_wavepwsh string\n\n\tZshExtendedHistoryPattern = regexp.MustCompile(`^: [0-9]+:`)\n)\n\nconst DefaultTermType = \"xterm-256color\"\nconst DefaultTermRows = 24\nconst DefaultTermCols = 80\n\nvar cachedMacUserShell string\nvar macUserShellOnce = &sync.Once{}\nvar userShellRegexp = regexp.MustCompile(`^UserShell: (.*)$`)\n\nvar gitBashCache = utilds.MakeSyncCache(findInstalledGitBash)\n\nconst DefaultShellPath = \"/bin/bash\"\n\nconst (\n\tShellType_bash    = \"bash\"\n\tShellType_zsh     = \"zsh\"\n\tShellType_fish    = \"fish\"\n\tShellType_pwsh    = \"pwsh\"\n\tShellType_unknown = \"unknown\"\n)\n\nconst (\n\t// there must be no spaces in these integration dir paths\n\tZshIntegrationDir  = \"shell/zsh\"\n\tBashIntegrationDir = \"shell/bash\"\n\tPwshIntegrationDir = \"shell/pwsh\"\n\tFishIntegrationDir = \"shell/fish\"\n\tWaveHomeBinDir     = \"bin\"\n\tZshHistoryFileName = \".zsh_history\"\n)\n\nfunc DetectLocalShellPath() string {\n\tif runtime.GOOS == \"windows\" {\n\t\tif pwshPath, lpErr := exec.LookPath(\"pwsh\"); lpErr == nil {\n\t\t\treturn pwshPath\n\t\t}\n\t\tif powershellPath, lpErr := exec.LookPath(\"powershell\"); lpErr == nil {\n\t\t\treturn powershellPath\n\t\t}\n\t\treturn \"powershell.exe\"\n\t}\n\tshellPath := GetMacUserShell()\n\tif shellPath == \"\" {\n\t\tshellPath = os.Getenv(\"SHELL\")\n\t}\n\tif shellPath == \"\" {\n\t\treturn DefaultShellPath\n\t}\n\treturn shellPath\n}\n\nfunc GetMacUserShell() string {\n\tif runtime.GOOS != \"darwin\" {\n\t\treturn \"\"\n\t}\n\tmacUserShellOnce.Do(func() {\n\t\tcachedMacUserShell = internalMacUserShell()\n\t})\n\treturn cachedMacUserShell\n}\n\n// dscl . -read /Users/[username] UserShell\n// defaults to /bin/bash\nfunc internalMacUserShell() string {\n\tosUser, err := user.Current()\n\tif err != nil {\n\t\treturn DefaultShellPath\n\t}\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\tuserStr := \"/Users/\" + osUser.Username\n\tout, err := exec.CommandContext(ctx, \"dscl\", \".\", \"-read\", userStr, \"UserShell\").CombinedOutput()\n\tif err != nil {\n\t\treturn DefaultShellPath\n\t}\n\toutStr := strings.TrimSpace(string(out))\n\tm := userShellRegexp.FindStringSubmatch(outStr)\n\tif m == nil {\n\t\treturn DefaultShellPath\n\t}\n\treturn m[1]\n}\n\nfunc hasDirPart(dir string, part string) bool {\n\tdir = filepath.Clean(dir)\n\tpart = strings.ToLower(part)\n\tfor {\n\t\tbase := strings.ToLower(filepath.Base(dir))\n\t\tif base == part {\n\t\t\treturn true\n\t\t}\n\t\tparent := filepath.Dir(dir)\n\t\tif parent == dir {\n\t\t\tbreak\n\t\t}\n\t\tdir = parent\n\t}\n\treturn false\n}\n\nfunc FindGitBash(config *wconfig.FullConfigType, rescan bool) string {\n\tif runtime.GOOS != \"windows\" {\n\t\treturn \"\"\n\t}\n\n\tif config != nil && config.Settings.TermGitBashPath != \"\" {\n\t\treturn config.Settings.TermGitBashPath\n\t}\n\n\tpath, _ := gitBashCache.Get(rescan)\n\treturn path\n}\n\nfunc findInstalledGitBash() (string, error) {\n\t// Try PATH first (skip system32, and only accept if in a Git directory)\n\tpathEnv := os.Getenv(\"PATH\")\n\tpathDirs := filepath.SplitList(pathEnv)\n\tfor _, dir := range pathDirs {\n\t\tdir = strings.Trim(dir, `\"`)\n\t\tif hasDirPart(dir, \"system32\") {\n\t\t\tcontinue\n\t\t}\n\t\tif !hasDirPart(dir, \"git\") {\n\t\t\tcontinue\n\t\t}\n\t\tbashPath := filepath.Join(dir, \"bash.exe\")\n\t\tif _, err := os.Stat(bashPath); err == nil {\n\t\t\treturn bashPath, nil\n\t\t}\n\t}\n\n\t// Try scoop location\n\tuserProfile := os.Getenv(\"USERPROFILE\")\n\tif userProfile != \"\" {\n\t\tscoopPath := filepath.Join(userProfile, \"scoop\", \"apps\", \"git\", \"current\", \"bin\", \"bash.exe\")\n\t\tif _, err := os.Stat(scoopPath); err == nil {\n\t\t\treturn scoopPath, nil\n\t\t}\n\t}\n\n\t// Try LocalAppData\\programs\\git\\bin\n\tlocalAppData := os.Getenv(\"LOCALAPPDATA\")\n\tif localAppData != \"\" {\n\t\tlocalPath := filepath.Join(localAppData, \"programs\", \"git\", \"bin\", \"bash.exe\")\n\t\tif _, err := os.Stat(localPath); err == nil {\n\t\t\treturn localPath, nil\n\t\t}\n\t}\n\n\t// Try C:\\Program Files\\Git\\bin\n\tprogramFilesPath := filepath.Join(\"C:\\\\\", \"Program Files\", \"Git\", \"bin\", \"bash.exe\")\n\tif _, err := os.Stat(programFilesPath); err == nil {\n\t\treturn programFilesPath, nil\n\t}\n\n\treturn \"\", nil\n}\n\nfunc DefaultTermSize() waveobj.TermSize {\n\treturn waveobj.TermSize{Rows: DefaultTermRows, Cols: DefaultTermCols}\n}\n\nfunc WaveshellLocalEnvVars(termType string) map[string]string {\n\trtn := make(map[string]string)\n\tif termType != \"\" {\n\t\trtn[\"TERM\"] = termType\n\t}\n\t// these are not necessary since they should be set with the swap token, but no harm in setting them here\n\trtn[\"TERM_PROGRAM\"] = \"waveterm\"\n\trtn[\"WAVETERM\"], _ = os.Executable()\n\trtn[\"WAVETERM_VERSION\"] = wavebase.WaveVersion\n\trtn[\"WAVETERM_WSHBINDIR\"] = filepath.Join(wavebase.GetWaveDataDir(), WaveHomeBinDir)\n\treturn rtn\n}\n\nfunc UpdateCmdEnv(cmd *exec.Cmd, envVars map[string]string) {\n\tif len(envVars) == 0 {\n\t\treturn\n\t}\n\tfound := make(map[string]bool)\n\tvar newEnv []string\n\tfor _, envStr := range cmd.Env {\n\t\tenvKey := GetEnvStrKey(envStr)\n\t\tnewEnvVal, ok := envVars[envKey]\n\t\tif ok {\n\t\t\tfound[envKey] = true\n\t\t\tif newEnvVal != \"\" {\n\t\t\t\tnewEnv = append(newEnv, envKey+\"=\"+newEnvVal)\n\t\t\t}\n\t\t} else {\n\t\t\tnewEnv = append(newEnv, envStr)\n\t\t}\n\t}\n\tfor envKey, envVal := range envVars {\n\t\tif found[envKey] {\n\t\t\tcontinue\n\t\t}\n\t\tnewEnv = append(newEnv, envKey+\"=\"+envVal)\n\t}\n\tcmd.Env = newEnv\n}\n\nfunc GetEnvStrKey(envStr string) string {\n\teqIdx := strings.Index(envStr, \"=\")\n\tif eqIdx == -1 {\n\t\treturn envStr\n\t}\n\treturn envStr[0:eqIdx]\n}\n\nvar initStartupFilesOnce = &sync.Once{}\n\n// in a Once block so it can be called multiple times\n// we run it at startup, but also before launching local shells so we know everything is initialized before starting the shell\nfunc InitCustomShellStartupFiles() error {\n\tvar err error\n\tinitStartupFilesOnce.Do(func() {\n\t\terr = initCustomShellStartupFilesInternal()\n\t})\n\treturn err\n}\n\nfunc GetLocalBashRcFileOverride() string {\n\treturn filepath.Join(wavebase.GetWaveDataDir(), BashIntegrationDir, \".bashrc\")\n}\n\nfunc GetLocalWaveFishFilePath() string {\n\treturn filepath.Join(wavebase.GetWaveDataDir(), FishIntegrationDir, \"wave.fish\")\n}\n\nfunc GetLocalWavePowershellEnv() string {\n\treturn filepath.Join(wavebase.GetWaveDataDir(), PwshIntegrationDir, \"wavepwsh.ps1\")\n}\n\nfunc GetLocalZshZDotDir() string {\n\treturn filepath.Join(wavebase.GetWaveDataDir(), ZshIntegrationDir)\n}\n\nfunc HasWaveZshHistory() (bool, int64) {\n\tzshDir := GetLocalZshZDotDir()\n\thistoryFile := filepath.Join(zshDir, ZshHistoryFileName)\n\tfileInfo, err := os.Stat(historyFile)\n\tif err != nil {\n\t\treturn false, 0\n\t}\n\treturn true, fileInfo.Size()\n}\n\nfunc IsExtendedZshHistoryFile(fileName string) (bool, error) {\n\tfile, err := os.Open(fileName)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tdefer file.Close()\n\n\tbuf := make([]byte, 1024)\n\tn, err := file.Read(buf)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tcontent := string(buf[:n])\n\tlines := strings.Split(content, \"\\n\")\n\n\tfor _, line := range lines {\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treturn ZshExtendedHistoryPattern.MatchString(line), nil\n\t}\n\n\treturn false, nil\n}\n\nfunc GetLocalWshBinaryPath(version string, goos string, goarch string) (string, error) {\n\text := \"\"\n\tif goarch == \"amd64\" {\n\t\tgoarch = \"x64\"\n\t}\n\tif goarch == \"aarch64\" {\n\t\tgoarch = \"arm64\"\n\t}\n\tif goos == \"windows\" {\n\t\text = \".exe\"\n\t}\n\tif !wavebase.SupportedWshBinaries[fmt.Sprintf(\"%s-%s\", goos, goarch)] {\n\t\treturn \"\", fmt.Errorf(\"unsupported wsh platform: %s-%s\", goos, goarch)\n\t}\n\tbaseName := fmt.Sprintf(\"wsh-%s-%s.%s%s\", version, goos, goarch, ext)\n\treturn filepath.Join(wavebase.GetWaveAppBinPath(), baseName), nil\n}\n\n// absWshBinDir must be an absolute, expanded path (no ~ or $HOME, etc.)\n// it will be hard-quoted appropriately for the shell\nfunc InitRcFiles(waveHome string, absWshBinDir string) error {\n\t// ensure directories exist\n\tzshDir := filepath.Join(waveHome, ZshIntegrationDir)\n\terr := wavebase.CacheEnsureDir(zshDir, ZshIntegrationDir, 0755, ZshIntegrationDir)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbashDir := filepath.Join(waveHome, BashIntegrationDir)\n\terr = wavebase.CacheEnsureDir(bashDir, BashIntegrationDir, 0755, BashIntegrationDir)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfishDir := filepath.Join(waveHome, FishIntegrationDir)\n\terr = wavebase.CacheEnsureDir(fishDir, FishIntegrationDir, 0755, FishIntegrationDir)\n\tif err != nil {\n\t\treturn err\n\t}\n\tpwshDir := filepath.Join(waveHome, PwshIntegrationDir)\n\terr = wavebase.CacheEnsureDir(pwshDir, PwshIntegrationDir, 0755, PwshIntegrationDir)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar pathSep string\n\tif runtime.GOOS == \"windows\" {\n\t\tpathSep = \";\"\n\t} else {\n\t\tpathSep = \":\"\n\t}\n\tparams := map[string]string{\n\t\t\"WSHBINDIR\":      HardQuote(absWshBinDir),\n\t\t\"WSHBINDIR_PWSH\": HardQuotePowerShell(absWshBinDir),\n\t\t\"PATHSEP\":        pathSep,\n\t}\n\n\t// write files to directory\n\terr = utilfn.WriteTemplateToFile(filepath.Join(zshDir, \".zprofile\"), ZshStartup_Zprofile, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error writing zsh-integration .zprofile: %v\", err)\n\t}\n\terr = utilfn.WriteTemplateToFile(filepath.Join(zshDir, \".zshrc\"), ZshStartup_Zshrc, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error writing zsh-integration .zshrc: %v\", err)\n\t}\n\terr = utilfn.WriteTemplateToFile(filepath.Join(zshDir, \".zlogin\"), ZshStartup_Zlogin, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error writing zsh-integration .zlogin: %v\", err)\n\t}\n\terr = utilfn.WriteTemplateToFile(filepath.Join(zshDir, \".zshenv\"), ZshStartup_Zshenv, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error writing zsh-integration .zshenv: %v\", err)\n\t}\n\terr = utilfn.WriteTemplateToFile(filepath.Join(bashDir, \".bashrc\"), BashStartup_Bashrc, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error writing bash-integration .bashrc: %v\", err)\n\t}\n\terr = os.WriteFile(filepath.Join(bashDir, \"bash_preexec.sh\"), []byte(BashStartup_Preexec), 0644)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error writing bash-integration bash_preexec.sh: %v\", err)\n\t}\n\terr = utilfn.WriteTemplateToFile(filepath.Join(fishDir, \"wave.fish\"), FishStartup_Wavefish, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error writing fish-integration wave.fish: %v\", err)\n\t}\n\terr = utilfn.WriteTemplateToFile(filepath.Join(pwshDir, \"wavepwsh.ps1\"), PwshStartup_wavepwsh, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error writing pwsh-integration wavepwsh.ps1: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc initCustomShellStartupFilesInternal() error {\n\tlog.Printf(\"initializing wsh and shell startup files\\n\")\n\twaveDataHome := wavebase.GetWaveDataDir()\n\tbinDir := filepath.Join(waveDataHome, WaveHomeBinDir)\n\terr := InitRcFiles(waveDataHome, binDir)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = wavebase.CacheEnsureDir(binDir, WaveHomeBinDir, 0755, WaveHomeBinDir)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// copy the correct binary to bin\n\twshFullPath, err := GetLocalWshBinaryPath(wavebase.WaveVersion, runtime.GOOS, runtime.GOARCH)\n\tif err != nil {\n\t\tlog.Printf(\"error (non-fatal), could not resolve wsh binary path: %v\\n\", err)\n\t}\n\tif _, err := os.Stat(wshFullPath); err != nil {\n\t\tlog.Printf(\"error (non-fatal), could not resolve wsh binary %q: %v\\n\", wshFullPath, err)\n\t\treturn nil\n\t}\n\twshDstPath := filepath.Join(binDir, \"wsh\")\n\tif runtime.GOOS == \"windows\" {\n\t\twshDstPath = wshDstPath + \".exe\"\n\t}\n\terr = utilfn.AtomicRenameCopy(wshDstPath, wshFullPath, 0755)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error copying wsh binary to bin: %v\", err)\n\t}\n\twshBaseName := filepath.Base(wshFullPath)\n\tlog.Printf(\"wsh binary successfully copied from %q to %q\\n\", wshBaseName, wshDstPath)\n\treturn nil\n}\n\nfunc GetShellTypeFromShellPath(shellPath string) string {\n\tshellBase := filepath.Base(shellPath)\n\tif strings.Contains(shellBase, \"bash\") {\n\t\treturn ShellType_bash\n\t}\n\tif strings.Contains(shellBase, \"zsh\") {\n\t\treturn ShellType_zsh\n\t}\n\tif strings.Contains(shellBase, \"fish\") {\n\t\treturn ShellType_fish\n\t}\n\tif strings.Contains(shellBase, \"pwsh\") || strings.Contains(shellBase, \"powershell\") {\n\t\treturn ShellType_pwsh\n\t}\n\treturn ShellType_unknown\n}\n\nvar (\n\tbashVersionRegexp = regexp.MustCompile(`\\bversion\\s+(\\d+\\.\\d+)`)\n\tzshVersionRegexp  = regexp.MustCompile(`\\bzsh\\s+(\\d+\\.\\d+)`)\n\tfishVersionRegexp = regexp.MustCompile(`\\bversion\\s+(\\d+\\.\\d+)`)\n\tpwshVersionRegexp = regexp.MustCompile(`(?:PowerShell\\s+)?(\\d+\\.\\d+)`)\n)\n\nfunc DetectShellTypeAndVersion() (string, string, error) {\n\tshellPath := DetectLocalShellPath()\n\treturn DetectShellTypeAndVersionFromPath(shellPath)\n}\n\nfunc DetectShellTypeAndVersionFromPath(shellPath string) (string, string, error) {\n\tshellType := GetShellTypeFromShellPath(shellPath)\n\tif shellType == ShellType_unknown {\n\t\treturn shellType, \"\", fmt.Errorf(\"unknown shell type: %s\", shellPath)\n\t}\n\n\tshellBase := filepath.Base(shellPath)\n\tif shellType == ShellType_pwsh && strings.Contains(shellBase, \"powershell\") && !strings.Contains(shellBase, \"pwsh\") {\n\t\treturn \"powershell\", \"\", nil\n\t}\n\n\tversion, err := getShellVersion(shellPath, shellType)\n\tif err != nil {\n\t\treturn shellType, \"\", err\n\t}\n\n\treturn shellType, version, nil\n}\n\nfunc getShellVersion(shellPath string, shellType string) (string, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\n\tvar cmd *exec.Cmd\n\tvar versionRegex *regexp.Regexp\n\n\tswitch shellType {\n\tcase ShellType_bash:\n\t\tcmd = exec.CommandContext(ctx, shellPath, \"--version\")\n\t\tversionRegex = bashVersionRegexp\n\tcase ShellType_zsh:\n\t\tcmd = exec.CommandContext(ctx, shellPath, \"--version\")\n\t\tversionRegex = zshVersionRegexp\n\tcase ShellType_fish:\n\t\tcmd = exec.CommandContext(ctx, shellPath, \"--version\")\n\t\tversionRegex = fishVersionRegexp\n\tcase ShellType_pwsh:\n\t\tcmd = exec.CommandContext(ctx, shellPath, \"--version\")\n\t\tversionRegex = pwshVersionRegexp\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"unsupported shell type: %s\", shellType)\n\t}\n\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to get version for %s: %w\", shellType, err)\n\t}\n\n\toutputStr := strings.TrimSpace(string(output))\n\tmatches := versionRegex.FindStringSubmatch(outputStr)\n\tif len(matches) < 2 {\n\t\treturn \"\", fmt.Errorf(\"failed to parse version from output: %q\", outputStr)\n\t}\n\n\treturn matches[1], nil\n}\n\nfunc FixupWaveZshHistory() error {\n\tif runtime.GOOS != \"darwin\" {\n\t\treturn nil\n\t}\n\n\thasHistory, size := HasWaveZshHistory()\n\tif !hasHistory {\n\t\treturn nil\n\t}\n\n\tzshDir := GetLocalZshZDotDir()\n\twaveHistFile := filepath.Join(zshDir, ZshHistoryFileName)\n\n\tif size == 0 {\n\t\terr := os.Remove(waveHistFile)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error removing wave zsh history file %s: %v\\n\", waveHistFile, err)\n\t\t}\n\t\treturn nil\n\t}\n\n\tlog.Printf(\"merging wave zsh history %s into ~/.zsh_history\\n\", waveHistFile)\n\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting home directory: %w\", err)\n\t}\n\trealHistFile := filepath.Join(homeDir, \".zsh_history\")\n\n\tisExtended, err := IsExtendedZshHistoryFile(realHistFile)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error checking if history is extended: %w\", err)\n\t}\n\n\thasExtendedStr := \"false\"\n\tif isExtended {\n\t\thasExtendedStr = \"true\"\n\t}\n\n\tquotedWaveHistFile := utilfn.ShellQuote(waveHistFile, true, -1)\n\n\tscript := fmt.Sprintf(`\n\t\tHISTFILE=~/.zsh_history\n\t\tHISTSIZE=999999\n\t\tSAVEHIST=999999\n\t\thas_extended_history=%s\n\t\t[[ $has_extended_history == true ]] && setopt EXTENDED_HISTORY\n\t\tfc -RI\n\t\tfc -RI %s\n\t\tfc -W\n\t`, hasExtendedStr, quotedWaveHistFile)\n\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\n\tcmd := exec.CommandContext(ctx, \"zsh\", \"-f\", \"-i\", \"-c\", script)\n\tcmd.Stdin = nil\n\tenvStr := envutil.SliceToEnv(os.Environ())\n\tenvStr = envutil.RmEnv(envStr, \"ZDOTDIR\")\n\tcmd.Env = envutil.EnvToSlice(envStr)\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error executing zsh history fixup script: %w, output: %s\", err, string(output))\n\t}\n\n\terr = os.Remove(waveHistFile)\n\tif err != nil {\n\t\tlog.Printf(\"error removing wave zsh history file %s: %v\\n\", waveHistFile, err)\n\t}\n\tlog.Printf(\"successfully merged wave zsh history %s into ~/.zsh_history\\n\", waveHistFile)\n\n\treturn nil\n}\n\nfunc GetTerminalResetSeq() string {\n\tresetSeq := \"\\x1b[0m\"             // reset attributes\n\tresetSeq += \"\\x1b[?25h\"           // show cursor\n\tresetSeq += \"\\x1b[?1l\"            // normal cursor keys\n\tresetSeq += \"\\x1b[?7h\"            // wraparound on\n\tresetSeq += \"\\x1b[?45l\"           // reverse wraparound off\n\tresetSeq += \"\\x1b[?66l\"           // application keypad off (DECNKM)\n\tresetSeq += \"\\x1b[4l\"             // insert mode off (IRM)\n\tresetSeq += \"\\x1b[?9l\"            // X10 mouse tracking off\n\tresetSeq += \"\\x1b[?1000l\"         // disable Send Mouse X & Y on button press\n\tresetSeq += \"\\x1b[?1002l\"         // disable Use Cell Motion Mouse Tracking\n\tresetSeq += \"\\x1b[?1003l\"         // disable Use All Motion Mouse Tracking\n\tresetSeq += \"\\x1b[?1004l\"         // disable Send FocusIn/FocusOut events\n\tresetSeq += \"\\x1b[?1006l\"         // disable Enable SGR Mouse Mode\n\tresetSeq += \"\\x1b[?1007l\"         // disable Enable Alternate Scroll Mode\n\tresetSeq += \"\\x1b[?2004l\"         // disable bracketed paste mode\n\tresetSeq += \"\\x1b[?2026l\"         // synchronized output off\n\tresetSeq += FormatOSC(16162, \"R\") // disable alternate screen mode\n\treturn resetSeq\n}\n\nfunc FormatOSC(oscNum int, parts ...string) string {\n\tif len(parts) == 0 {\n\t\treturn fmt.Sprintf(\"\\x1b]%d\\x07\", oscNum)\n\t}\n\treturn fmt.Sprintf(\"\\x1b]%d;%s\\x07\", oscNum, strings.Join(parts, \";\"))\n}\n"
  },
  {
    "path": "pkg/util/shellutil/tokenswap.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage shellutil\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nvar tokenSwapMap map[string]*TokenSwapEntry = make(map[string]*TokenSwapEntry)\nvar tokenMapLock = &sync.Mutex{}\n\ntype TokenSwapEntry struct {\n\tToken      string             `json:\"token\"`\n\tRpcContext *wshrpc.RpcContext `json:\"rpccontext,omitempty\"`\n\tEnv        map[string]string  `json:\"env,omitempty\"`\n\tScriptText string             `json:\"scripttext,omitempty\"`\n\tExp        time.Time          `json:\"-\"`\n}\n\ntype UnpackedTokenType struct {\n\tToken      string             `json:\"token\"` // uuid\n\tRpcContext *wshrpc.RpcContext `json:\"rpccontext,omitempty\"`\n}\n\nfunc (t *UnpackedTokenType) Pack() (string, error) {\n\t// convert to json, and then base64 encode\n\tbarr, err := json.Marshal(t)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn base64.StdEncoding.EncodeToString(barr), nil\n}\n\nfunc UnpackSwapToken(token string) (*UnpackedTokenType, error) {\n\t// base64 decode, then convert from json\n\tbarr, err := base64.StdEncoding.DecodeString(token)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar unpacked UnpackedTokenType\n\terr = json.Unmarshal(barr, &unpacked)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &unpacked, nil\n}\n\nfunc (t *TokenSwapEntry) PackForClient() (string, error) {\n\tunpackedToken := &UnpackedTokenType{\n\t\tToken:      t.Token,\n\t\tRpcContext: t.RpcContext,\n\t}\n\treturn unpackedToken.Pack()\n}\n\nfunc removeExpiredTokens() {\n\tnow := time.Now()\n\ttokenMapLock.Lock()\n\tdefer tokenMapLock.Unlock()\n\tfor k, v := range tokenSwapMap {\n\t\tif v.Exp.Before(now) {\n\t\t\tdelete(tokenSwapMap, k)\n\t\t}\n\t}\n}\n\nfunc AddTokenSwapEntry(entry *TokenSwapEntry) error {\n\tremoveExpiredTokens()\n\tif entry.Token == \"\" {\n\t\treturn fmt.Errorf(\"token cannot be empty\")\n\t}\n\ttokenMapLock.Lock()\n\tdefer tokenMapLock.Unlock()\n\tif _, ok := tokenSwapMap[entry.Token]; ok {\n\t\treturn fmt.Errorf(\"token already exists: %s\", entry.Token)\n\t}\n\ttokenSwapMap[entry.Token] = entry\n\treturn nil\n}\n\nfunc GetAndRemoveTokenSwapEntry(token string) *TokenSwapEntry {\n\tremoveExpiredTokens()\n\ttokenMapLock.Lock()\n\tdefer tokenMapLock.Unlock()\n\tif entry, ok := tokenSwapMap[token]; ok {\n\t\tdelete(tokenSwapMap, token)\n\t\treturn entry\n\t}\n\treturn nil\n}\n\nfunc encodeEnvVarsForBash(env map[string]string) (string, error) {\n\tvar encoded string\n\tfor k, v := range env {\n\t\t// validate key\n\t\tif !IsValidEnvVarName(k) {\n\t\t\treturn \"\", fmt.Errorf(\"invalid env var name: %q\", k)\n\t\t}\n\t\tencoded += fmt.Sprintf(\"export %s=%s\\n\", k, HardQuote(v))\n\t}\n\treturn encoded, nil\n}\n\nfunc encodeEnvVarsForFish(env map[string]string) (string, error) {\n\tvar encoded string\n\tfor k, v := range env {\n\t\t// validate key\n\t\tif !IsValidEnvVarName(k) {\n\t\t\treturn \"\", fmt.Errorf(\"invalid env var name: %q\", k)\n\t\t}\n\t\tencoded += fmt.Sprintf(\"set -x %s %s\\n\", k, HardQuoteFish(v))\n\t}\n\treturn encoded, nil\n}\n\nfunc encodeEnvVarsForPowerShell(env map[string]string) (string, error) {\n\tvar encoded string\n\tfor k, v := range env {\n\t\t// validate key\n\t\tif !IsValidEnvVarName(k) {\n\t\t\treturn \"\", fmt.Errorf(\"invalid env var name: %q\", k)\n\t\t}\n\t\tencoded += fmt.Sprintf(\"$env:%s = %s\\n\", k, HardQuotePowerShell(v))\n\t}\n\treturn encoded, nil\n}\n\nfunc EncodeEnvVarsForShell(shellType string, env map[string]string) (string, error) {\n\tswitch shellType {\n\tcase ShellType_bash, ShellType_zsh:\n\t\treturn encodeEnvVarsForBash(env)\n\tcase ShellType_fish:\n\t\treturn encodeEnvVarsForFish(env)\n\tcase ShellType_pwsh:\n\t\treturn encodeEnvVarsForPowerShell(env)\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"unknown or unsupported shell type for env var encoding: %s\", shellType)\n\t}\n}\n"
  },
  {
    "path": "pkg/util/sigutil/sigusr1_notwindows.go",
    "content": "//go:build !windows\n\n// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage sigutil\n\nimport (\n\t\"log\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n)\n\nconst DumpFilePath = \"/tmp/waveterm-usr1-dump.log\"\n\nfunc InstallSIGUSR1Handler() {\n\tsigCh := make(chan os.Signal, 1)\n\tsignal.Notify(sigCh, syscall.SIGUSR1)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"InstallSIGUSR1Handler\", recover())\n\t\t}()\n\t\tfor range sigCh {\n\t\t\tfile, err := os.Create(DumpFilePath)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"error creating dump file %q: %v\", DumpFilePath, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tutilfn.DumpGoRoutineStacks(file)\n\t\t\tfile.Close()\n\t\t}\n\t}()\n}\n"
  },
  {
    "path": "pkg/util/sigutil/sigusr1_windows.go",
    "content": "//go:build windows\n\n// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage sigutil\n\nfunc InstallSIGUSR1Handler() {\n\t// do nothing\n}\n"
  },
  {
    "path": "pkg/util/sigutil/sigutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage sigutil\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n)\n\nfunc InstallShutdownSignalHandlers(doShutdown func(string)) {\n\tsigCh := make(chan os.Signal, 1)\n\tsignal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"InstallShutdownSignalHandlers\", recover())\n\t\t}()\n\t\tfor sig := range sigCh {\n\t\t\tdoShutdown(fmt.Sprintf(\"got signal %v\", sig))\n\t\t\tbreak\n\t\t}\n\t}()\n}\n"
  },
  {
    "path": "pkg/util/syncbuf/syncbuf.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage syncbuf\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"sync\"\n)\n\ntype SyncBuffer struct {\n\tlock sync.Mutex\n\tbuf  *bytes.Buffer\n}\n\nfunc MakeSyncBuffer() *SyncBuffer {\n\treturn &SyncBuffer{\n\t\tlock: sync.Mutex{},\n\t\tbuf:  new(bytes.Buffer),\n\t}\n}\n\n// spawns a goroutine to copy the reader to the buffer\nfunc MakeSyncBufferFromReader(r io.Reader) *SyncBuffer {\n\trtn := MakeSyncBuffer()\n\tgo io.Copy(rtn, r)\n\treturn rtn\n}\n\nfunc (s *SyncBuffer) Write(p []byte) (n int, err error) {\n\ts.lock.Lock()\n\tdefer s.lock.Unlock()\n\treturn s.buf.Write(p)\n}\n\nfunc (s *SyncBuffer) String() string {\n\ts.lock.Lock()\n\tdefer s.lock.Unlock()\n\treturn s.buf.String()\n}\n"
  },
  {
    "path": "pkg/util/unixutil/unixutil_unix.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n//go:build unix\n\npackage unixutil\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"syscall\"\n\n\t\"golang.org/x/sys/unix\"\n)\n\nfunc GetProcessGroupId(pid int) (int, error) {\n\tpgid, err := syscall.Getpgid(pid)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn pgid, nil\n}\n\nfunc ParseSignal(sigName string) os.Signal {\n\tsigName = strings.TrimSpace(sigName)\n\tsigName = strings.ToUpper(sigName)\n\tif n, err := strconv.Atoi(sigName); err == nil {\n\t\tif n <= 0 {\n\t\t\treturn nil\n\t\t}\n\t\treturn syscall.Signal(n)\n\t}\n\tif !strings.HasPrefix(sigName, \"SIG\") {\n\t\tsigName = \"SIG\" + sigName\n\t}\n\tsig := unix.SignalNum(sigName)\n\tif sig == 0 {\n\t\treturn nil\n\t}\n\treturn sig\n}\n\nfunc GetSignalName(sig os.Signal) string {\n\tif sig == nil {\n\t\treturn \"\"\n\t}\n\tscSig, ok := sig.(syscall.Signal)\n\tif !ok {\n\t\treturn sig.String()\n\t}\n\tname := unix.SignalName(scSig)\n\tif name == \"\" {\n\t\treturn fmt.Sprintf(\"%d\", int(scSig))\n\t}\n\treturn name\n}\n\nfunc SetCloseOnExec(fd int) {\n\tunix.CloseOnExec(fd)\n}\n\nfunc SignalTerm(pid int) error {\n\treturn syscall.Kill(pid, syscall.SIGTERM)\n}\n\nfunc SignalHup(pid int) error {\n\treturn syscall.Kill(pid, syscall.SIGHUP)\n}\n\nfunc IsPidRunning(pid int) bool {\n\tif pid <= 0 {\n\t\treturn false\n\t}\n\terr := syscall.Kill(pid, 0)\n\t// EPERM means no permission, but it exists (ESRCH is not found)\n\tif err == nil || err == syscall.EPERM {\n\t\treturn true\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "pkg/util/unixutil/unixutil_windows.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n//go:build windows\n\npackage unixutil\n\nimport (\n\t\"fmt\"\n\t\"os\"\n)\n\nfunc GetProcessGroupId(pid int) (int, error) {\n\treturn 0, fmt.Errorf(\"process group id not supported on windows\")\n}\n\nfunc ParseSignal(sigName string) os.Signal {\n\treturn nil\n}\n\nfunc GetSignalName(sig os.Signal) string {\n\tif sig == nil {\n\t\treturn \"\"\n\t}\n\treturn sig.String()\n}\n\nfunc SetCloseOnExec(fd int) {\n}\n\nfunc SignalTerm(pid int) error {\n\tproc, err := os.FindProcess(pid)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn proc.Kill()\n}\n\n// this is a no-op on windows\nfunc SignalHup(pid int) error {\n\treturn nil\n}\n\nfunc IsPidRunning(pid int) bool {\n\treturn false\n}\n"
  },
  {
    "path": "pkg/util/utilfn/compare.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage utilfn\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"reflect\"\n)\n\nfunc CompareAsMarshaledJson(a, b any) bool {\n\tif a == nil && b == nil {\n\t\treturn true\n\t}\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\tbarrA, err := json.Marshal(a)\n\tif err != nil {\n\t\treturn false\n\t}\n\tbarrB, err := json.Marshal(b)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn bytes.Equal(barrA, barrB)\n}\n\n// this is a shallow equal, but with special handling for numeric types\n// it will up convert to float64 and compare\nfunc JsonValEqual(a, b any) bool {\n\tif a == nil && b == nil {\n\t\treturn true\n\t}\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\ttypeA := reflect.TypeOf(a)\n\ttypeB := reflect.TypeOf(b)\n\tif typeA == typeB && typeA.Comparable() {\n\t\treturn a == b\n\t}\n\tif IsNumericType(a) && IsNumericType(b) {\n\t\treturn CompareAsFloat64(a, b)\n\t}\n\tif typeA != typeB {\n\t\treturn false\n\t}\n\t// for slices and maps, compare their pointers\n\tvalA := reflect.ValueOf(a)\n\tvalB := reflect.ValueOf(b)\n\tswitch valA.Kind() {\n\tcase reflect.Slice, reflect.Map:\n\t\treturn valA.Pointer() == valB.Pointer()\n\t}\n\treturn false\n}\n\n// Helper to check if a value is a numeric type\nfunc IsNumericType(val any) bool {\n\tswitch val.(type) {\n\tcase int, int8, int16, int32, int64,\n\t\tuint, uint8, uint16, uint32, uint64,\n\t\tfloat32, float64:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// Helper to handle numeric comparisons as float64\nfunc CompareAsFloat64(a, b any) bool {\n\tvalA, okA := ToFloat64(a)\n\tvalB, okB := ToFloat64(b)\n\treturn okA && okB && valA == valB\n}\n\n// Convert various numeric types to float64 for comparison\nfunc ToFloat64(val any) (float64, bool) {\n\tif val == nil {\n\t\treturn 0, false\n\t}\n\tswitch v := val.(type) {\n\tcase int:\n\t\treturn float64(v), true\n\tcase int8:\n\t\treturn float64(v), true\n\tcase int16:\n\t\treturn float64(v), true\n\tcase int32:\n\t\treturn float64(v), true\n\tcase int64:\n\t\treturn float64(v), true\n\tcase uint:\n\t\treturn float64(v), true\n\tcase uint8:\n\t\treturn float64(v), true\n\tcase uint16:\n\t\treturn float64(v), true\n\tcase uint32:\n\t\treturn float64(v), true\n\tcase uint64:\n\t\treturn float64(v), true\n\tcase float32:\n\t\treturn float64(v), true\n\tcase float64:\n\t\treturn v, true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc ToInt64(val any) (int64, bool) {\n\tif val == nil {\n\t\treturn 0, false\n\t}\n\tswitch v := val.(type) {\n\tcase int:\n\t\treturn int64(v), true\n\tcase int8:\n\t\treturn int64(v), true\n\tcase int16:\n\t\treturn int64(v), true\n\tcase int32:\n\t\treturn int64(v), true\n\tcase int64:\n\t\treturn v, true\n\tcase uint:\n\t\treturn int64(v), true\n\tcase uint8:\n\t\treturn int64(v), true\n\tcase uint16:\n\t\treturn int64(v), true\n\tcase uint32:\n\t\treturn int64(v), true\n\tcase uint64:\n\t\treturn int64(v), true\n\tcase float32:\n\t\treturn int64(v), true\n\tcase float64:\n\t\treturn int64(v), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc ToInt(val any) (int, bool) {\n\ti, ok := ToInt64(val)\n\tif !ok {\n\t\treturn 0, false\n\t}\n\treturn int(i), true\n}\n\nfunc ToStr(val any) (string, bool) {\n\tif val == nil {\n\t\treturn \"\", false\n\t}\n\tswitch v := val.(type) {\n\tcase string:\n\t\treturn v, true\n\tdefault:\n\t\treturn \"\", false\n\t}\n}\n"
  },
  {
    "path": "pkg/util/utilfn/marshal.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage utilfn\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/mitchellh/mapstructure\"\n)\n\n// MarshalIndentNoHTMLString marshals the value to JSON with indentation and SetEscapeHTML(false), returning a string\nfunc MarshalIndentNoHTMLString(v any, prefix, indent string) (string, error) {\n\tvar buf bytes.Buffer\n\tencoder := json.NewEncoder(&buf)\n\tencoder.SetEscapeHTML(false)\n\tencoder.SetIndent(prefix, indent)\n\terr := encoder.Encode(v)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn strings.TrimRight(buf.String(), \"\\n\"), nil\n}\n\nfunc MustPrettyPrintJSON(v any) string {\n\tstr, _ := MarshalIndentNoHTMLString(v, \"\", \"  \")\n\treturn str\n}\n\nfunc ReUnmarshal(out any, in any) error {\n\tbarr, err := json.Marshal(in)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(barr, out)\n}\n\n// does a mapstructure using \"json\" tags\nfunc DoMapStructure(out any, input any) error {\n\tdconfig := &mapstructure.DecoderConfig{\n\t\tResult:  out,\n\t\tTagName: \"json\",\n\t}\n\tdecoder, err := mapstructure.NewDecoder(dconfig)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn decoder.Decode(input)\n}\n\nfunc MapToStruct(in map[string]any, out any) error {\n\t// Check that out is a pointer\n\toutValue := reflect.ValueOf(out)\n\tif outValue.Kind() != reflect.Ptr {\n\t\treturn fmt.Errorf(\"out parameter must be a pointer, got %v\", outValue.Kind())\n\t}\n\n\t// Get the struct it points to\n\telem := outValue.Elem()\n\tif elem.Kind() != reflect.Struct {\n\t\treturn fmt.Errorf(\"out parameter must be a pointer to struct, got pointer to %v\", elem.Kind())\n\t}\n\n\t// Get type information\n\ttyp := elem.Type()\n\n\t// For each field in the struct\n\tfor i := 0; i < typ.NumField(); i++ {\n\t\tfield := typ.Field(i)\n\n\t\t// Skip unexported fields\n\t\tif !field.IsExported() {\n\t\t\tcontinue\n\t\t}\n\n\t\tname := getJSONName(field)\n\t\tif value, ok := in[name]; ok {\n\t\t\tif err := setValue(elem.Field(i), value); err != nil {\n\t\t\t\treturn fmt.Errorf(\"error setting field %s: %w\", name, err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc StructToMap(in any) (map[string]any, error) {\n\t// Get value and handle pointer\n\tval := reflect.ValueOf(in)\n\tif val.Kind() == reflect.Ptr {\n\t\tval = val.Elem()\n\t}\n\n\t// Check that we have a struct\n\tif val.Kind() != reflect.Struct {\n\t\treturn nil, fmt.Errorf(\"input must be a struct or pointer to struct, got %v\", val.Kind())\n\t}\n\n\t// Get type information\n\ttyp := val.Type()\n\tout := make(map[string]any)\n\n\t// For each field in the struct\n\tfor i := 0; i < typ.NumField(); i++ {\n\t\tfield := typ.Field(i)\n\n\t\t// Skip unexported fields\n\t\tif !field.IsExported() {\n\t\t\tcontinue\n\t\t}\n\n\t\tname := getJSONName(field)\n\t\tout[name] = val.Field(i).Interface()\n\t}\n\n\treturn out, nil\n}\n\n// getJSONName returns the field name to use for JSON mapping\nfunc getJSONName(field reflect.StructField) string {\n\ttag := field.Tag.Get(\"json\")\n\tif tag == \"\" || tag == \"-\" {\n\t\treturn field.Name\n\t}\n\treturn strings.Split(tag, \",\")[0]\n}\n\n// setValue attempts to set a reflect.Value with a given interface{} value\nfunc setValue(field reflect.Value, value any) error {\n\tif value == nil {\n\t\treturn nil\n\t}\n\n\tvalueRef := reflect.ValueOf(value)\n\n\t// Direct assignment if types are exactly equal\n\tif valueRef.Type() == field.Type() {\n\t\tfield.Set(valueRef)\n\t\treturn nil\n\t}\n\n\t// Check if types are assignable\n\tif valueRef.Type().AssignableTo(field.Type()) {\n\t\tfield.Set(valueRef)\n\t\treturn nil\n\t}\n\n\t// If field is pointer and value isn't already a pointer, try address\n\tif field.Kind() == reflect.Ptr && valueRef.Kind() != reflect.Ptr {\n\t\treturn setValue(field, valueRef.Addr().Interface())\n\t}\n\n\t// Try conversion if types are convertible\n\tif valueRef.Type().ConvertibleTo(field.Type()) {\n\t\tfield.Set(valueRef.Convert(field.Type()))\n\t\treturn nil\n\t}\n\n\treturn fmt.Errorf(\"cannot set value of type %v to field of type %v\", valueRef.Type(), field.Type())\n}\n\n// DecodeDataURL decodes a data URL and returns the mimetype and raw data bytes\nfunc DecodeDataURL(dataURL string) (mimeType string, data []byte, err error) {\n\tif !strings.HasPrefix(dataURL, \"data:\") {\n\t\treturn \"\", nil, fmt.Errorf(\"invalid data URL: must start with 'data:'\")\n\t}\n\n\tparts := strings.SplitN(dataURL, \",\", 2)\n\tif len(parts) != 2 {\n\t\treturn \"\", nil, fmt.Errorf(\"invalid data URL format: missing comma separator\")\n\t}\n\n\theader := parts[0]\n\tdataStr := parts[1]\n\n\t// Parse mimetype from header: \"data:text/plain;base64\" -> \"text/plain\"\n\theaderWithoutPrefix := strings.TrimPrefix(header, \"data:\")\n\tmimeType = strings.Split(headerWithoutPrefix, \";\")[0]\n\tif mimeType == \"\" {\n\t\tmimeType = \"text/plain\" // default mimetype\n\t}\n\n\tif strings.Contains(header, \";base64\") {\n\t\tdecoded, decodeErr := base64.StdEncoding.DecodeString(dataStr)\n\t\tif decodeErr != nil {\n\t\t\treturn \"\", nil, fmt.Errorf(\"failed to decode base64 data: %w\", decodeErr)\n\t\t}\n\t\treturn mimeType, decoded, nil\n\t}\n\n\t// Non-base64 data URLs are percent-encoded\n\tdecoded, decodeErr := url.QueryUnescape(dataStr)\n\tif decodeErr != nil {\n\t\treturn \"\", nil, fmt.Errorf(\"failed to decode percent-encoded data: %w\", decodeErr)\n\t}\n\treturn mimeType, []byte(decoded), nil\n}\n\n// MarshalJSONString marshals a string to JSON format, returning the properly escaped JSON string.\n// Returns empty string if there's an error (rare).\nfunc MarshalJSONString(s string) string {\n\tjsonBytes, err := json.Marshal(s)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn string(jsonBytes)\n}\n\n// ContainsBinaryData checks if the provided data contains binary (non-text) content\nfunc ContainsBinaryData(data []byte) bool {\n\tfor _, b := range data {\n\t\tif b == 0 {\n\t\t\treturn true\n\t\t}\n\t\tif b < 32 && b != 9 && b != 10 && b != 13 {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "pkg/util/utilfn/partial.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage utilfn\n\nimport (\n\t\"encoding/json\"\n)\n\ntype stackItem int\n\nconst (\n\tstackInvalid stackItem = iota\n\tstackLBrace\n\tstackLBrack\n\tstackBeforeKey\n\tstackKey\n\tstackKeyColon\n\tstackQuote\n)\n\ntype jsonStack []stackItem\n\nfunc (s *jsonStack) push(item stackItem) {\n\t*s = append(*s, item)\n}\n\nfunc (s *jsonStack) pop() stackItem {\n\tif len(*s) == 0 {\n\t\treturn stackInvalid\n\t}\n\titem := (*s)[len(*s)-1]\n\t*s = (*s)[:len(*s)-1]\n\treturn item\n}\n\nfunc (s jsonStack) peek() stackItem {\n\tif len(s) == 0 {\n\t\treturn stackInvalid\n\t}\n\treturn s[len(s)-1]\n}\nfunc (s jsonStack) isTop(items ...stackItem) bool {\n\ttop := s.peek()\n\tfor _, item := range items {\n\t\tif top == item {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (s *jsonStack) replaceTop(item stackItem) {\n\tif len(*s) > 0 {\n\t\t(*s)[len(*s)-1] = item\n\t}\n}\n\nfunc repairJson(data []byte) []byte {\n\tif len(data) == 0 {\n\t\treturn data\n\t}\n\n\tvar stack jsonStack\n\tinString := false\n\tescaped := false\n\tlastComma := false\n\n\tfor i := 0; i < len(data); i++ {\n\t\tb := data[i]\n\n\t\tif escaped {\n\t\t\tescaped = false\n\t\t\tcontinue\n\t\t}\n\n\t\tif inString {\n\t\t\tif b == '\\\\' {\n\t\t\t\tescaped = true\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif b == '\"' {\n\t\t\t\tinString = false\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tif b == ' ' || b == '\\t' || b == '\\n' || b == '\\r' {\n\t\t\tcontinue\n\t\t}\n\t\tvalueStart := b == '{' || b == '[' || b == 'n' || b == 't' || b == 'f' || b == '\"' || (b >= '0' && b <= '9') || b == '-'\n\t\tif valueStart && lastComma {\n\t\t\tlastComma = false\n\t\t}\n\t\tif valueStart && stack.isTop(stackKeyColon) {\n\t\t\tstack.pop()\n\t\t}\n\t\tif valueStart && stack.isTop(stackBeforeKey) {\n\t\t\tstack.replaceTop(stackKey)\n\t\t}\n\t\tswitch b {\n\t\tcase '{':\n\t\t\tstack.push(stackLBrace)\n\t\t\tstack.push(stackBeforeKey)\n\t\tcase '[':\n\t\t\tstack.push(stackLBrack)\n\t\tcase '}':\n\t\t\tif stack.isTop(stackBeforeKey) {\n\t\t\t\tstack.pop()\n\t\t\t}\n\t\t\tif stack.isTop(stackLBrace) {\n\t\t\t\tstack.pop()\n\t\t\t}\n\t\tcase ']':\n\t\t\tif stack.isTop(stackLBrack) {\n\t\t\t\tstack.pop()\n\t\t\t}\n\t\tcase '\"':\n\t\t\tinString = true\n\t\tcase ':':\n\t\t\tif stack.isTop(stackKey) {\n\t\t\t\tstack.replaceTop(stackKeyColon)\n\t\t\t}\n\t\tcase ',':\n\t\t\tlastComma = true\n\t\t\tif stack.isTop(stackLBrace) {\n\t\t\t\tstack.push(stackBeforeKey)\n\t\t\t}\n\t\tdefault:\n\t\t}\n\t}\n\n\tif len(stack) == 0 && !inString {\n\t\treturn data\n\t}\n\n\tresult := append([]byte{}, data...)\n\tif escaped && len(result) > 0 {\n\t\tresult = result[:len(result)-1]\n\t}\n\tif inString {\n\t\tresult = append(result, '\"')\n\t}\n\tif lastComma {\n\t\tfor i := len(result) - 1; i >= 0; i-- {\n\t\t\tif result[i] == ',' {\n\t\t\t\tresult = result[:i]\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tfor i := len(stack) - 1; i >= 0; i-- {\n\t\tswitch stack[i] {\n\t\tcase stackKeyColon:\n\t\t\tresult = append(result, []byte(\"null\")...)\n\t\tcase stackKey:\n\t\t\tresult = append(result, []byte(\": null\")...)\n\t\tcase stackLBrace:\n\t\t\tresult = append(result, '}')\n\t\tcase stackLBrack:\n\t\t\tresult = append(result, ']')\n\t\t}\n\t}\n\treturn result\n}\n\nfunc ParsePartialJson(data []byte) (any, error) {\n\tfixedData := repairJson(data)\n\tvar output any\n\terr := json.Unmarshal(fixedData, &output)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn output, nil\n}\n"
  },
  {
    "path": "pkg/util/utilfn/partial_test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage utilfn\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\nfunc TestRepairJson(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"open bracket\",\n\t\t\tinput:    \"[\",\n\t\t\texpected: \"[]\",\n\t\t},\n\t\t{\n\t\t\tname:     \"empty array\",\n\t\t\tinput:    \"[]\",\n\t\t\texpected: \"[]\",\n\t\t},\n\t\t{\n\t\t\tname:     \"unclosed string in array\",\n\t\t\tinput:    `[\"a`,\n\t\t\texpected: `[\"a\"]`,\n\t\t},\n\t\t{\n\t\t\tname:     \"unclosed array with string\",\n\t\t\tinput:    `[\"a\"`,\n\t\t\texpected: `[\"a\"]`,\n\t\t},\n\t\t{\n\t\t\tname:     \"unclosed array with number\",\n\t\t\tinput:    `[5`,\n\t\t\texpected: `[5]`,\n\t\t},\n\t\t{\n\t\t\tname:     \"array with trailing comma\",\n\t\t\tinput:    `[\"a\",`,\n\t\t\texpected: `[\"a\"]`,\n\t\t},\n\t\t{\n\t\t\tname:     \"array with unclosed second string\",\n\t\t\tinput:    `[\"a\",\"`,\n\t\t\texpected: `[\"a\",\"\"]`,\n\t\t},\n\t\t{\n\t\t\tname:     \"unclosed array with string and number\",\n\t\t\tinput:    `[\"a\",5`,\n\t\t\texpected: `[\"a\",5]`,\n\t\t},\n\t\t{\n\t\t\tname:     \"open brace\",\n\t\t\tinput:    \"{\",\n\t\t\texpected: \"{}\",\n\t\t},\n\t\t{\n\t\t\tname:     \"empty object\",\n\t\t\tinput:    \"{}\",\n\t\t\texpected: \"{}\",\n\t\t},\n\t\t{\n\t\t\tname:     \"unclosed key\",\n\t\t\tinput:    `{\"a`,\n\t\t\texpected: `{\"a\": null}`,\n\t\t},\n\t\t{\n\t\t\tname:     \"key without colon\",\n\t\t\tinput:    `{\"a\"`,\n\t\t\texpected: `{\"a\": null}`,\n\t\t},\n\t\t{\n\t\t\tname:     \"key with colon no value\",\n\t\t\tinput:    `{\"a\": `,\n\t\t\texpected: `{\"a\": null}`,\n\t\t},\n\t\t{\n\t\t\tname:     \"unclosed object with number value\",\n\t\t\tinput:    `{\"a\": 5`,\n\t\t\texpected: `{\"a\": 5}`,\n\t\t},\n\t\t{\n\t\t\tname:     \"unclosed object with true\",\n\t\t\tinput:    `{\"a\": true`,\n\t\t\texpected: `{\"a\": true}`,\n\t\t},\n\t\t// {\n\t\t// \tname:     \"unclosed object with partial value\",\n\t\t// \tinput:    `{\"a\": fa`,\n\t\t// \texpected: `{\"a\": fa}`,\n\t\t// },\n\t\t{\n\t\t\tname:     \"object with trailing comma\",\n\t\t\tinput:    `{\"a\": true,`,\n\t\t\texpected: `{\"a\": true}`,\n\t\t},\n\t\t{\n\t\t\tname:     \"object with unclosed second key\",\n\t\t\tinput:    `{\"a\": true, \"`,\n\t\t\texpected: `{\"a\": true, \"\": null}`,\n\t\t},\n\t\t{\n\t\t\tname:     \"complete object\",\n\t\t\tinput:    `{\"a\": true, \"b\": false}`,\n\t\t\texpected: `{\"a\": true, \"b\": false}`,\n\t\t},\n\t\t{\n\t\t\tname:     \"nested incomplete\",\n\t\t\tinput:    `[1, {\"a\": true, \"b`,\n\t\t\texpected: `[1, {\"a\": true, \"b\": null}]`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := repairJson([]byte(tt.input))\n\t\t\tresultStr := string(result)\n\n\t\t\tif resultStr != tt.expected {\n\t\t\t\tt.Errorf(\"repairJson() of %s = %s, expected %s\", tt.input, resultStr, tt.expected)\n\t\t\t}\n\n\t\t\tvar parsed any\n\t\t\terr := json.Unmarshal(result, &parsed)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"repaired JSON is not valid: %v\\nInput: %q\\nOutput: %q\", err, tt.input, resultStr)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/util/utilfn/streamtolines.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage utilfn\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"time\"\n)\n\ntype LineOutput struct {\n\tLine  string\n\tError error\n}\n\ntype lineBuf struct {\n\tbuf        []byte\n\tinLongLine bool\n}\n\nconst maxLineLength = 128 * 1024\n\nfunc ReadLineWithTimeout(ch chan LineOutput, timeout time.Duration) (string, error) {\n\tselect {\n\tcase output := <-ch:\n\t\tif output.Error != nil {\n\t\t\treturn \"\", output.Error\n\t\t}\n\t\treturn output.Line, nil\n\tcase <-time.After(timeout):\n\t\treturn \"\", context.DeadlineExceeded\n\t}\n}\n\nfunc streamToLines_processBuf(lineBuf *lineBuf, readBuf []byte, lineFn func([]byte)) {\n\tfor len(readBuf) > 0 {\n\t\tnlIdx := bytes.IndexByte(readBuf, '\\n')\n\t\tif nlIdx == -1 {\n\t\t\tif lineBuf.inLongLine || len(lineBuf.buf)+len(readBuf) > maxLineLength {\n\t\t\t\tlineBuf.buf = nil\n\t\t\t\tlineBuf.inLongLine = true\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlineBuf.buf = append(lineBuf.buf, readBuf...)\n\t\t\treturn\n\t\t}\n\t\tif !lineBuf.inLongLine && len(lineBuf.buf)+nlIdx <= maxLineLength {\n\t\t\tline := append(lineBuf.buf, readBuf[:nlIdx]...)\n\t\t\tlineFn(line)\n\t\t}\n\t\tlineBuf.buf = nil\n\t\tlineBuf.inLongLine = false\n\t\treadBuf = readBuf[nlIdx+1:]\n\t}\n}\n\nfunc StreamToLines(input io.Reader, lineFn func([]byte), readCallback func()) error {\n\tvar lineBuf lineBuf\n\treadBuf := make([]byte, 64*1024)\n\tfor {\n\t\tn, err := input.Read(readBuf)\n\t\tstreamToLines_processBuf(&lineBuf, readBuf[:n], lineFn)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif readCallback != nil {\n\t\t\treadCallback()\n\t\t}\n\t}\n}\n\n// starts a goroutine to drive the channel\n// line output does not include the trailing newline\nfunc StreamToLinesChan(input io.Reader) chan LineOutput {\n\tch := make(chan LineOutput)\n\tgo func() {\n\t\tdefer close(ch)\n\t\terr := StreamToLines(input, func(line []byte) {\n\t\t\tch <- LineOutput{Line: string(line)}\n\t\t}, nil)\n\t\tif err != nil && err != io.EOF {\n\t\t\tch <- LineOutput{Error: err}\n\t\t}\n\t}()\n\treturn ch\n}\n\n// LineWriter is an io.Writer that processes data line-by-line via a callback.\n// Lines do not include the trailing newline. Lines longer than maxLineLength are dropped.\ntype LineWriter struct {\n\tlineBuf lineBuf\n\tlineFn  func([]byte)\n}\n\n// NewLineWriter creates a new LineWriter with the given callback function.\nfunc NewLineWriter(lineFn func([]byte)) *LineWriter {\n\treturn &LineWriter{\n\t\tlineFn: lineFn,\n\t}\n}\n\n// Write implements io.Writer, processing the data and calling the callback for each complete line.\nfunc (lw *LineWriter) Write(p []byte) (n int, err error) {\n\tstreamToLines_processBuf(&lw.lineBuf, p, lw.lineFn)\n\treturn len(p), nil\n}\n\n// Flush outputs any remaining buffered data as a final line.\n// Should be called when the input stream is complete (e.g., at EOF).\nfunc (lw *LineWriter) Flush() {\n\tif len(lw.lineBuf.buf) > 0 && !lw.lineBuf.inLongLine {\n\t\tlw.lineFn(lw.lineBuf.buf)\n\t\tlw.lineBuf.buf = nil\n\t}\n}\n"
  },
  {
    "path": "pkg/util/utilfn/utilfn.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage utilfn\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha1\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"hash/fnv\"\n\t\"io\"\n\t\"log\"\n\t\"math\"\n\tmathrand \"math/rand\"\n\t\"os\"\n\t\"os/exec\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"syscall\"\n\t\"text/template\"\n\t\"time\"\n\t\"unicode/utf8\"\n)\n\nvar HexDigits = []byte{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}\nvar PTLoc *time.Location\n\nfunc init() {\n\tloc, err := time.LoadLocation(\"America/Los_Angeles\")\n\tif err != nil {\n\t\tloc = time.FixedZone(\"PT\", -8*60*60)\n\t}\n\tPTLoc = loc\n}\n\nfunc GetStrArr(v interface{}, field string) []string {\n\tif v == nil {\n\t\treturn nil\n\t}\n\tm, ok := v.(map[string]interface{})\n\tif !ok {\n\t\treturn nil\n\t}\n\tfieldVal := m[field]\n\tif fieldVal == nil {\n\t\treturn nil\n\t}\n\tiarr, ok := fieldVal.([]interface{})\n\tif !ok {\n\t\treturn nil\n\t}\n\tvar sarr []string\n\tfor _, iv := range iarr {\n\t\tif sv, ok := iv.(string); ok {\n\t\t\tsarr = append(sarr, sv)\n\t\t}\n\t}\n\treturn sarr\n}\n\nfunc GetBool(v interface{}, field string) bool {\n\tif v == nil {\n\t\treturn false\n\t}\n\tm, ok := v.(map[string]interface{})\n\tif !ok {\n\t\treturn false\n\t}\n\tfieldVal := m[field]\n\tif fieldVal == nil {\n\t\treturn false\n\t}\n\tbval, ok := fieldVal.(bool)\n\tif !ok {\n\t\treturn false\n\t}\n\treturn bval\n}\n\n// converts an int, int64, or float64 to an int64\n// nil or bad type returns 0\nfunc ConvertInt(val any) int64 {\n\tif val == 0 {\n\t\treturn 0\n\t}\n\tswitch typedVal := val.(type) {\n\tcase int:\n\t\treturn int64(typedVal)\n\tcase int64:\n\t\treturn typedVal\n\tcase float64:\n\t\treturn int64(typedVal)\n\tdefault:\n\t\treturn 0\n\t}\n}\n\nfunc ConvertMap(val any) map[string]any {\n\tif val == nil {\n\t\treturn nil\n\t}\n\tm, ok := val.(map[string]any)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn m\n}\n\nvar needsQuoteRe = regexp.MustCompile(`[^\\w@%:,./=+-]`)\n\n// minimum maxlen=6, pass -1 for no max length\nfunc ShellQuote(val string, forceQuote bool, maxLen int) string {\n\tif maxLen != -1 && maxLen < 6 {\n\t\tmaxLen = 6\n\t}\n\trtn := val\n\tif needsQuoteRe.MatchString(val) {\n\t\trtn = \"'\" + strings.ReplaceAll(val, \"'\", `'\"'\"'`) + \"'\"\n\t} else if forceQuote {\n\t\trtn = \"\\\"\" + rtn + \"\\\"\"\n\t}\n\tif maxLen == -1 || len(rtn) <= maxLen {\n\t\treturn rtn\n\t}\n\tif strings.HasPrefix(rtn, \"\\\"\") || strings.HasPrefix(rtn, \"'\") {\n\t\treturn rtn[0:maxLen-4] + \"...\" + rtn[len(rtn)-1:]\n\t}\n\treturn rtn[0:maxLen-3] + \"...\"\n}\n\nfunc EllipsisStr(s string, maxLen int) string {\n\tif maxLen < 4 {\n\t\tmaxLen = 4\n\t}\n\tif len(s) > maxLen {\n\t\treturn s[0:maxLen-3] + \"...\"\n\t}\n\treturn s\n}\n\nfunc TruncateString(s string, maxLen int) string {\n\tif len(s) <= maxLen {\n\t\treturn s\n\t}\n\tif maxLen < 4 {\n\t\tmaxLen = 4\n\t}\n\treturn s[:maxLen-3] + \"...\"\n}\n\nfunc LongestPrefix(root string, strs []string) string {\n\tif len(strs) == 0 {\n\t\treturn root\n\t}\n\tif len(strs) == 1 {\n\t\tcomp := strs[0]\n\t\tif len(comp) >= len(root) && strings.HasPrefix(comp, root) {\n\t\t\tif strings.HasSuffix(comp, \"/\") {\n\t\t\t\treturn strs[0]\n\t\t\t}\n\t\t\treturn strs[0]\n\t\t}\n\t}\n\tlcp := strs[0]\n\tfor i := 1; i < len(strs); i++ {\n\t\ts := strs[i]\n\t\tfor j := 0; j < len(lcp); j++ {\n\t\t\tif j >= len(s) || lcp[j] != s[j] {\n\t\t\t\tlcp = lcp[0:j]\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tif len(lcp) < len(root) || !strings.HasPrefix(lcp, root) {\n\t\treturn root\n\t}\n\treturn lcp\n}\n\nfunc ContainsStr(strs []string, test string) bool {\n\tfor _, s := range strs {\n\t\tif s == test {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc IsPrefix(strs []string, test string) bool {\n\tfor _, s := range strs {\n\t\tif len(s) > len(test) && strings.HasPrefix(s, test) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// sentinel value for StrWithPos.Pos to indicate no position\nconst NoStrPos = -1\n\ntype StrWithPos struct {\n\tStr string `json:\"str\"`\n\tPos int    `json:\"pos\"` // this is a 'rune' position (not a byte position)\n}\n\nfunc (sp StrWithPos) String() string {\n\treturn strWithCursor(sp.Str, sp.Pos)\n}\n\nfunc ParseToSP(s string) StrWithPos {\n\tidx := strings.Index(s, \"[*]\")\n\tif idx == -1 {\n\t\treturn StrWithPos{Str: s, Pos: NoStrPos}\n\t}\n\treturn StrWithPos{Str: s[0:idx] + s[idx+3:], Pos: utf8.RuneCountInString(s[0:idx])}\n}\n\nfunc strWithCursor(str string, pos int) string {\n\tif pos == NoStrPos {\n\t\treturn str\n\t}\n\tif pos < 0 {\n\t\t// invalid position\n\t\treturn \"[*]_\" + str\n\t}\n\tif pos > len(str) {\n\t\t// invalid position\n\t\treturn str + \"_[*]\"\n\t}\n\tif pos == len(str) {\n\t\treturn str + \"[*]\"\n\t}\n\tvar rtn []rune\n\tfor _, ch := range str {\n\t\tif len(rtn) == pos {\n\t\t\trtn = append(rtn, '[', '*', ']')\n\t\t}\n\t\trtn = append(rtn, ch)\n\t}\n\treturn string(rtn)\n}\n\nfunc (sp StrWithPos) Prepend(str string) StrWithPos {\n\treturn StrWithPos{Str: str + sp.Str, Pos: utf8.RuneCountInString(str) + sp.Pos}\n}\n\nfunc (sp StrWithPos) Append(str string) StrWithPos {\n\treturn StrWithPos{Str: sp.Str + str, Pos: sp.Pos}\n}\n\n// returns base64 hash of data\nfunc Sha1Hash(data []byte) string {\n\thvalRaw := sha1.Sum(data)\n\thval := base64.StdEncoding.EncodeToString(hvalRaw[:])\n\treturn hval\n}\n\nfunc ChunkSlice[T any](s []T, chunkSize int) [][]T {\n\tvar rtn [][]T\n\tfor len(rtn) > 0 {\n\t\tif len(s) <= chunkSize {\n\t\t\trtn = append(rtn, s)\n\t\t\tbreak\n\t\t}\n\t\trtn = append(rtn, s[:chunkSize])\n\t\ts = s[chunkSize:]\n\t}\n\treturn rtn\n}\n\nvar ErrOverflow = errors.New(\"integer overflow\")\n\n// Add two int values, returning an error if the result overflows.\nfunc AddInt(left, right int) (int, error) {\n\tif right > 0 {\n\t\tif left > math.MaxInt-right {\n\t\t\treturn 0, ErrOverflow\n\t\t}\n\t} else {\n\t\tif left < math.MinInt-right {\n\t\t\treturn 0, ErrOverflow\n\t\t}\n\t}\n\treturn left + right, nil\n}\n\n// Add a slice of ints, returning an error if the result overflows.\nfunc AddIntSlice(vals ...int) (int, error) {\n\tvar rtn int\n\tfor _, v := range vals {\n\t\tvar err error\n\t\trtn, err = AddInt(rtn, v)\n\t\tif err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t}\n\treturn rtn, nil\n}\n\nfunc StrsEqual(s1arr []string, s2arr []string) bool {\n\tif len(s1arr) != len(s2arr) {\n\t\treturn false\n\t}\n\tfor i, s1 := range s1arr {\n\t\ts2 := s2arr[i]\n\t\tif s1 != s2 {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc StrMapsEqual(m1 map[string]string, m2 map[string]string) bool {\n\tif len(m1) != len(m2) {\n\t\treturn false\n\t}\n\tfor key, val1 := range m1 {\n\t\tval2, found := m2[key]\n\t\tif !found || val1 != val2 {\n\t\t\treturn false\n\t\t}\n\t}\n\tfor key := range m2 {\n\t\t_, found := m1[key]\n\t\tif !found {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc ByteMapsEqual(m1 map[string][]byte, m2 map[string][]byte) bool {\n\tif len(m1) != len(m2) {\n\t\treturn false\n\t}\n\tfor key, val1 := range m1 {\n\t\tval2, found := m2[key]\n\t\tif !found || !bytes.Equal(val1, val2) {\n\t\t\treturn false\n\t\t}\n\t}\n\tfor key := range m2 {\n\t\t_, found := m1[key]\n\t\tif !found {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc GetOrderedStringerMapKeys[K interface {\n\tcomparable\n\tfmt.Stringer\n}, V any](m map[K]V) []K {\n\tkeyStrMap := make(map[K]string)\n\tkeys := make([]K, 0, len(m))\n\tfor key := range m {\n\t\tkeys = append(keys, key)\n\t\tkeyStrMap[key] = key.String()\n\t}\n\tsort.Slice(keys, func(i, j int) bool {\n\t\treturn keyStrMap[keys[i]] < keyStrMap[keys[j]]\n\t})\n\treturn keys\n}\n\nfunc GetOrderedMapKeys[V any](m map[string]V) []string {\n\tkeys := make([]string, 0, len(m))\n\tfor key := range m {\n\t\tkeys = append(keys, key)\n\t}\n\tsort.Strings(keys)\n\treturn keys\n}\n\nconst (\n\tnullEncodeEscByte     = '\\\\'\n\tnullEncodeSepByte     = '|'\n\tnullEncodeEqByte      = '='\n\tnullEncodeZeroByteEsc = '0'\n\tnullEncodeEscByteEsc  = '\\\\'\n\tnullEncodeSepByteEsc  = 's'\n\tnullEncodeEqByteEsc   = 'e'\n)\n\nfunc EncodeStringMap(m map[string]string) []byte {\n\tvar buf bytes.Buffer\n\tfor idx, key := range GetOrderedMapKeys(m) {\n\t\tval := m[key]\n\t\tbuf.Write(NullEncodeStr(key))\n\t\tbuf.WriteByte(nullEncodeEqByte)\n\t\tbuf.Write(NullEncodeStr(val))\n\t\tif idx < len(m)-1 {\n\t\t\tbuf.WriteByte(nullEncodeSepByte)\n\t\t}\n\t}\n\treturn buf.Bytes()\n}\n\nfunc DecodeStringMap(barr []byte) (map[string]string, error) {\n\tif len(barr) == 0 {\n\t\treturn nil, nil\n\t}\n\tvar rtn = make(map[string]string)\n\tfor _, b := range bytes.Split(barr, []byte{nullEncodeSepByte}) {\n\t\tkeyVal := bytes.SplitN(b, []byte{nullEncodeEqByte}, 2)\n\t\tif len(keyVal) != 2 {\n\t\t\treturn nil, fmt.Errorf(\"invalid null encoding: %s\", string(b))\n\t\t}\n\t\tkey, err := NullDecodeStr(keyVal[0])\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tval, err := NullDecodeStr(keyVal[1])\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\trtn[key] = val\n\t}\n\treturn rtn, nil\n}\n\nfunc EncodeStringArray(arr []string) []byte {\n\tvar buf bytes.Buffer\n\tfor idx, s := range arr {\n\t\tbuf.Write(NullEncodeStr(s))\n\t\tif idx < len(arr)-1 {\n\t\t\tbuf.WriteByte(nullEncodeSepByte)\n\t\t}\n\t}\n\treturn buf.Bytes()\n}\n\nfunc DecodeStringArray(barr []byte) ([]string, error) {\n\tif len(barr) == 0 {\n\t\treturn nil, nil\n\t}\n\tvar rtn []string\n\tfor _, b := range bytes.Split(barr, []byte{nullEncodeSepByte}) {\n\t\ts, err := NullDecodeStr(b)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\trtn = append(rtn, s)\n\t}\n\treturn rtn, nil\n}\n\nfunc EncodedStringArrayHasFirstVal(encoded []byte, firstKey string) bool {\n\tfirstKeyBytes := NullEncodeStr(firstKey)\n\tif !bytes.HasPrefix(encoded, firstKeyBytes) {\n\t\treturn false\n\t}\n\tif len(encoded) == len(firstKeyBytes) || encoded[len(firstKeyBytes)] == nullEncodeSepByte {\n\t\treturn true\n\t}\n\treturn false\n}\n\n// on encoding error returns \"\"\n// this is used to perform logic on first value without decoding the entire array\nfunc EncodedStringArrayGetFirstVal(encoded []byte) string {\n\tsepIdx := bytes.IndexByte(encoded, nullEncodeSepByte)\n\tif sepIdx == -1 {\n\t\tstr, _ := NullDecodeStr(encoded)\n\t\treturn str\n\t}\n\tstr, _ := NullDecodeStr(encoded[0:sepIdx])\n\treturn str\n}\n\n// encodes a string, removing null/zero bytes (and separators '|')\n// a zero byte is encoded as \"\\0\", a '\\' is encoded as \"\\\\\", sep is encoded as \"\\s\"\n// allows for easy double splitting (first on \\x00, and next on \"|\")\nfunc NullEncodeStr(s string) []byte {\n\tstrBytes := []byte(s)\n\tif bytes.IndexByte(strBytes, 0) == -1 &&\n\t\tbytes.IndexByte(strBytes, nullEncodeEscByte) == -1 &&\n\t\tbytes.IndexByte(strBytes, nullEncodeSepByte) == -1 &&\n\t\tbytes.IndexByte(strBytes, nullEncodeEqByte) == -1 {\n\t\treturn strBytes\n\t}\n\tvar rtn []byte\n\tfor _, b := range strBytes {\n\t\tif b == 0 {\n\t\t\trtn = append(rtn, nullEncodeEscByte, nullEncodeZeroByteEsc)\n\t\t} else if b == nullEncodeEscByte {\n\t\t\trtn = append(rtn, nullEncodeEscByte, nullEncodeEscByteEsc)\n\t\t} else if b == nullEncodeSepByte {\n\t\t\trtn = append(rtn, nullEncodeEscByte, nullEncodeSepByteEsc)\n\t\t} else if b == nullEncodeEqByte {\n\t\t\trtn = append(rtn, nullEncodeEscByte, nullEncodeEqByteEsc)\n\t\t} else {\n\t\t\trtn = append(rtn, b)\n\t\t}\n\t}\n\treturn rtn\n}\n\nfunc NullDecodeStr(barr []byte) (string, error) {\n\tif bytes.IndexByte(barr, nullEncodeEscByte) == -1 {\n\t\treturn string(barr), nil\n\t}\n\tvar rtn []byte\n\tfor i := 0; i < len(barr); i++ {\n\t\tcurByte := barr[i]\n\t\tif curByte == nullEncodeEscByte {\n\t\t\ti++\n\t\t\tnextByte := barr[i]\n\t\t\tif nextByte == nullEncodeZeroByteEsc {\n\t\t\t\trtn = append(rtn, 0)\n\t\t\t} else if nextByte == nullEncodeEscByteEsc {\n\t\t\t\trtn = append(rtn, nullEncodeEscByte)\n\t\t\t} else if nextByte == nullEncodeSepByteEsc {\n\t\t\t\trtn = append(rtn, nullEncodeSepByte)\n\t\t\t} else if nextByte == nullEncodeEqByteEsc {\n\t\t\t\trtn = append(rtn, nullEncodeEqByte)\n\t\t\t} else {\n\t\t\t\t// invalid encoding\n\t\t\t\treturn \"\", fmt.Errorf(\"invalid null encoding: %d\", nextByte)\n\t\t\t}\n\t\t} else {\n\t\t\trtn = append(rtn, curByte)\n\t\t}\n\t}\n\treturn string(rtn), nil\n}\n\nfunc SortStringRunes(s string) string {\n\trunes := []rune(s)\n\tsort.Slice(runes, func(i, j int) bool {\n\t\treturn runes[i] < runes[j]\n\t})\n\treturn string(runes)\n}\n\n// will overwrite m1 with m2's values\nfunc CombineMaps[V any](m1 map[string]V, m2 map[string]V) {\n\tfor key, val := range m2 {\n\t\tm1[key] = val\n\t}\n}\n\n// returns hex escaped string (\\xNN for each byte)\nfunc ShellHexEscape(s string) string {\n\tvar rtn []byte\n\tfor _, ch := range []byte(s) {\n\t\trtn = append(rtn, []byte(fmt.Sprintf(\"\\\\x%02x\", ch))...)\n\t}\n\treturn string(rtn)\n}\n\nfunc GetMapKeys[K comparable, V any](m map[K]V) []K {\n\tvar rtn []K\n\tfor key := range m {\n\t\trtn = append(rtn, key)\n\t}\n\treturn rtn\n}\n\n// combines string arrays and removes duplicates (returns a new array)\nfunc CombineStrArrays(sarr1 []string, sarr2 []string) []string {\n\tvar rtn []string\n\tm := make(map[string]struct{})\n\tfor _, s := range sarr1 {\n\t\tif _, found := m[s]; found {\n\t\t\tcontinue\n\t\t}\n\t\tm[s] = struct{}{}\n\t\trtn = append(rtn, s)\n\t}\n\tfor _, s := range sarr2 {\n\t\tif _, found := m[s]; found {\n\t\t\tcontinue\n\t\t}\n\t\tm[s] = struct{}{}\n\t\trtn = append(rtn, s)\n\t}\n\treturn rtn\n}\n\nfunc StrSetIntersection(s1 []string, s2 []string) []string {\n\tset := make(map[string]bool)\n\tfor _, s := range s1 {\n\t\tset[s] = true\n\t}\n\tvar rtn []string\n\tfor _, s := range s2 {\n\t\tif set[s] {\n\t\t\trtn = append(rtn, s)\n\t\t}\n\t}\n\treturn rtn\n}\n\nfunc QuickJson(v interface{}) string {\n\tbarr, _ := json.Marshal(v)\n\treturn string(barr)\n}\n\nfunc QuickParseJson[T any](s string) T {\n\tvar v T\n\t_ = json.Unmarshal([]byte(s), &v)\n\treturn v\n}\n\nfunc StrArrayToMap(sarr []string) map[string]bool {\n\tm := make(map[string]bool)\n\tfor _, s := range sarr {\n\t\tm[s] = true\n\t}\n\treturn m\n}\n\nfunc AppendNonZeroRandomBytes(b []byte, randLen int) []byte {\n\tif randLen <= 0 {\n\t\treturn b\n\t}\n\tnumAdded := 0\n\tfor numAdded < randLen {\n\t\trn := mathrand.Intn(256)\n\t\tif rn > 0 && rn < 256 { // exclude 0, also helps to suppress security warning to have a guard here\n\t\t\tb = append(b, byte(rn))\n\t\t\tnumAdded++\n\t\t}\n\t}\n\treturn b\n}\n\n// returns (isEOF, error)\nfunc CopyWithEndBytes(outputBuf *bytes.Buffer, reader io.Reader, endBytes []byte) (bool, error) {\n\tbuf := make([]byte, 4096)\n\tfor {\n\t\tn, err := reader.Read(buf)\n\t\tif n > 0 {\n\t\t\toutputBuf.Write(buf[:n])\n\t\t\tobytes := outputBuf.Bytes()\n\t\t\tif bytes.HasSuffix(obytes, endBytes) {\n\t\t\t\toutputBuf.Truncate(len(obytes) - len(endBytes))\n\t\t\t\treturn (err == io.EOF), nil\n\t\t\t}\n\t\t}\n\t\tif err == io.EOF {\n\t\t\treturn true, nil\n\t\t}\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n}\n\n// does *not* close outputCh on EOF or error\nfunc CopyToChannel(outputCh chan<- []byte, reader io.Reader) error {\n\tbuf := make([]byte, 4096)\n\tfor {\n\t\tn, err := reader.Read(buf)\n\t\tif n > 0 {\n\t\t\t// copy so client can use []byte without it being overwritten\n\t\t\tbufCopy := make([]byte, n)\n\t\t\tcopy(bufCopy, buf[:n])\n\t\t\toutputCh <- bufCopy\n\t\t}\n\t\tif err == io.EOF {\n\t\t\treturn nil\n\t\t}\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n}\n\nfunc GetCmdExitCode(cmd *exec.Cmd, err error) int {\n\tif cmd == nil || cmd.ProcessState == nil {\n\t\treturn GetExitCode(err)\n\t}\n\tstatus, ok := cmd.ProcessState.Sys().(syscall.WaitStatus)\n\tif !ok {\n\t\treturn cmd.ProcessState.ExitCode()\n\t}\n\tsignaled := status.Signaled()\n\tif signaled {\n\t\tsignal := status.Signal()\n\t\treturn 128 + int(signal)\n\t}\n\texitStatus := status.ExitStatus()\n\treturn exitStatus\n}\n\nfunc GetExitCode(err error) int {\n\tif err == nil {\n\t\treturn 0\n\t}\n\tif exitErr, ok := err.(*exec.ExitError); ok {\n\t\treturn exitErr.ExitCode()\n\t} else {\n\t\treturn -1\n\t}\n}\n\nfunc GetFirstLine(s string) string {\n\tidx := strings.Index(s, \"\\n\")\n\tif idx == -1 {\n\t\treturn s\n\t}\n\treturn s[0:idx]\n}\n\nfunc JsonMapToStruct(m map[string]any, v interface{}) error {\n\tbarr, err := json.Marshal(m)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(barr, v)\n}\n\nfunc StructToJsonMap(v interface{}) (map[string]any, error) {\n\tbarr, err := json.Marshal(v)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar m map[string]any\n\terr = json.Unmarshal(barr, &m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn m, nil\n}\n\nfunc IndentString(indent string, str string) string {\n\tsplitArr := strings.Split(str, \"\\n\")\n\tvar rtn strings.Builder\n\tfor _, line := range splitArr {\n\t\tif line == \"\" {\n\t\t\trtn.WriteByte('\\n')\n\t\t\tcontinue\n\t\t}\n\t\trtn.WriteString(indent)\n\t\trtn.WriteString(line)\n\t\trtn.WriteByte('\\n')\n\t}\n\treturn rtn.String()\n}\n\nfunc SliceIdx[T comparable](arr []T, elem T) int {\n\tfor idx, e := range arr {\n\t\tif e == elem {\n\t\t\treturn idx\n\t\t}\n\t}\n\treturn -1\n}\n\n// removes an element from a slice and modifies the original slice (the backing elements)\n// if it removes the last element from the slice, it will return nil so we free the original slice's backing memory\nfunc RemoveElemFromSlice[T comparable](arr []T, elem T) []T {\n\tidx := SliceIdx(arr, elem)\n\tif idx == -1 {\n\t\treturn arr\n\t}\n\tif len(arr) == 1 {\n\t\treturn nil\n\t}\n\treturn append(arr[:idx], arr[idx+1:]...)\n}\n\nfunc AddElemToSliceUniq[T comparable](arr []T, elem T) []T {\n\tif SliceIdx(arr, elem) != -1 {\n\t\treturn arr\n\t}\n\treturn append(arr, elem)\n}\n\nfunc MoveSliceIdxToFront[T any](arr []T, idx int) []T {\n\t// create and return a new slice with idx moved to the front\n\tif idx == 0 || idx >= len(arr) {\n\t\t// make a copy still\n\t\treturn append([]T(nil), arr...)\n\t}\n\trtn := make([]T, 0, len(arr))\n\trtn = append(rtn, arr[idx])\n\trtn = append(rtn, arr[0:idx]...)\n\trtn = append(rtn, arr[idx+1:]...)\n\treturn rtn\n}\n\n// matches a delimited string with a pattern string\n// the pattern string can contain \"*\" to match a single part, or \"**\" to match the rest of the string\n// note that \"**\" may only appear at the end of the string\nfunc StarMatchString(pattern string, s string, delimiter string) bool {\n\tpatternParts := strings.Split(pattern, delimiter)\n\tstringParts := strings.Split(s, delimiter)\n\tpLen, sLen := len(patternParts), len(stringParts)\n\n\tfor i := 0; i < pLen; i++ {\n\t\tif patternParts[i] == \"**\" {\n\t\t\t// '**' must be at the end to be valid\n\t\t\treturn i == pLen-1\n\t\t}\n\t\tif i >= sLen {\n\t\t\t// If string is exhausted but pattern is not\n\t\t\treturn false\n\t\t}\n\t\tif patternParts[i] != \"*\" && patternParts[i] != stringParts[i] {\n\t\t\t// If current parts don't match and pattern part is not '*'\n\t\t\treturn false\n\t\t}\n\t}\n\t// Check if both pattern and string are fully matched\n\treturn pLen == sLen\n}\n\nfunc MergeStrMaps[T any](m1 map[string]T, m2 map[string]T) map[string]T {\n\trtn := make(map[string]T)\n\tfor key, val := range m1 {\n\t\trtn[key] = val\n\t}\n\tfor key, val := range m2 {\n\t\trtn[key] = val\n\t}\n\treturn rtn\n}\n\nfunc AtomicRenameCopy(dstPath string, srcPath string, perms os.FileMode) error {\n\t// first copy the file to dstPath.new, then rename into place\n\tsrcFd, err := os.Open(srcPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer srcFd.Close()\n\ttempName := dstPath + \".new\"\n\tdstFd, err := os.Create(tempName)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = io.Copy(dstFd, srcFd)\n\tif err != nil {\n\t\tdstFd.Close()\n\t\treturn err\n\t}\n\terr = dstFd.Close()\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = os.Chmod(tempName, perms)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = os.Rename(tempName, dstPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc AtoiNoErr(str string) int {\n\tval, err := strconv.Atoi(str)\n\tif err != nil {\n\t\treturn 0\n\t}\n\treturn val\n}\n\nfunc WriteTemplateToFile(fileName string, templateText string, vars map[string]string) error {\n\toutBuffer := &bytes.Buffer{}\n\ttemplate.Must(template.New(\"\").Parse(templateText)).Execute(outBuffer, vars)\n\treturn os.WriteFile(fileName, outBuffer.Bytes(), 0644)\n}\n\n// every byte is 4-bits of randomness\nfunc RandomHexString(numHexDigits int) (string, error) {\n\tnumBytes := (numHexDigits + 1) / 2 // Calculate the number of bytes needed\n\tbytes := make([]byte, numBytes)\n\tif _, err := rand.Read(bytes); err != nil {\n\t\treturn \"\", err\n\t}\n\n\thexStr := hex.EncodeToString(bytes)\n\treturn hexStr[:numHexDigits], nil // Return the exact number of hex digits\n}\n\nfunc GetJsonTag(field reflect.StructField) string {\n\tjsonTag := field.Tag.Get(\"json\")\n\tif jsonTag == \"\" {\n\t\treturn \"\"\n\t}\n\tcommaIdx := strings.Index(jsonTag, \",\")\n\tif commaIdx != -1 {\n\t\tjsonTag = jsonTag[:commaIdx]\n\t}\n\treturn jsonTag\n}\n\nfunc WriteFileIfDifferent(fileName string, contents []byte) (bool, error) {\n\toldContents, err := os.ReadFile(fileName)\n\tif err == nil && bytes.Equal(oldContents, contents) {\n\t\treturn false, nil\n\t}\n\terr = os.WriteFile(fileName, contents, 0644)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc GetLineColFromOffset(barr []byte, offset int) (int, int) {\n\tline := 1\n\tcol := 1\n\tfor i := 0; i < offset && i < len(barr); i++ {\n\t\tif barr[i] == '\\n' {\n\t\t\tline++\n\t\t\tcol = 1\n\t\t} else {\n\t\t\tcol++\n\t\t}\n\t}\n\treturn line, col\n}\n\nfunc FindStringInSlice(slice []string, val string) int {\n\tfor idx, v := range slice {\n\t\tif v == val {\n\t\t\treturn idx\n\t\t}\n\t}\n\treturn -1\n}\n\nfunc FormatLsTime(t time.Time) string {\n\tnow := time.Now()\n\tsixMonthsAgo := now.AddDate(0, -6, 0)\n\n\tif t.After(sixMonthsAgo) {\n\t\t// Recent files: \"Nov 18 18:40\"\n\t\treturn t.Format(\"Jan _2 15:04\")\n\t} else {\n\t\t// Older files: \"Apr 12  2025\"\n\t\treturn t.Format(\"Jan _2  2006\")\n\t}\n}\n\n/**\n * Helper function that will deref a pointer if not null\n * but returns a default value if it is null.\n */\nfunc SafeDeref[T any](x *T) T {\n\tif x == nil {\n\t\tvar safeOut T\n\t\treturn safeOut\n\t}\n\treturn *x\n}\n\n/**\n * Utility function for referencing a type with a pointer.\n * This is the same as dereferencing with &, but unlike &\n * you can directly use it on the ouput of a function\n * without needing to create an intermediate variable\n */\nfunc Ptr[T any](x T) *T {\n\treturn &x\n}\n\n/**\n * Utility function to convert know architecture patterns\n * to the patterns we use. It returns an error if the\n * provided name is unknown\n */\nfunc FilterValidArch(arch string) (string, error) {\n\tformatted := strings.TrimSpace(strings.ToLower(arch))\n\tswitch formatted {\n\tcase \"amd64\":\n\t\treturn \"x64\", nil\n\tcase \"x86_64\":\n\t\treturn \"x64\", nil\n\tcase \"x64\":\n\t\treturn \"x64\", nil\n\tcase \"arm64\":\n\t\treturn \"arm64\", nil\n\t}\n\treturn \"\", fmt.Errorf(\"unknown architecture: %s\", formatted)\n}\n\nfunc ConvertUUIDv4Tov7(uuidv4 string) (string, error) {\n\t// Parse the UUIDv4\n\tparts := strings.Split(uuidv4, \"-\")\n\tif len(parts) != 5 {\n\t\treturn \"\", fmt.Errorf(\"invalid UUIDv4 format\")\n\t}\n\n\t// Section 1 and 2: Fixed timestamp for Jan 1, 2024\n\tsection1 := \"01823a80\" // High 32 bits of the timestamp\n\tsection2 := \"0000\"     // Middle 16 bits of the timestamp\n\n\t// Section 3: Version (7) and the last 3 bytes of randomness from UUIDv4\n\tsection3 := \"7\" + parts[2][1:] // Replace the first nibble with '7' for version\n\n\t// Section 4 and 5: Copy from the original UUIDv4\n\tsection4 := parts[3]\n\tsection5 := parts[4]\n\n\t// Combine sections to form UUIDv7\n\tuuidv7 := fmt.Sprintf(\"%s-%s-%s-%s-%s\", section1, section2, section3, section4, section5)\n\treturn uuidv7, nil\n}\n\nfunc TimeoutFromContext(ctx context.Context, defaultTimeout time.Duration) time.Duration {\n\tdeadline, ok := ctx.Deadline()\n\tif !ok {\n\t\treturn defaultTimeout\n\t}\n\treturn time.Until(deadline)\n}\n\nfunc HasBinaryData(data []byte) bool {\n\tfor _, b := range data {\n\t\tif b < 32 && b != '\\n' && b != '\\r' && b != '\\t' && b != '\\f' && b != '\\b' {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc DumpGoRoutineStacks(w io.Writer) {\n\tbuf := make([]byte, 1<<20)\n\tn := runtime.Stack(buf, true)\n\tw.Write(buf[:n])\n}\n\nfunc ConvertToWallClockPT(t time.Time) time.Time {\n\tyear, month, day := t.Date()\n\thour, min, sec := t.Clock()\n\tpstTime := time.Date(year, month, day, hour, min, sec, 0, PTLoc)\n\treturn pstTime\n}\n\nfunc QuickHashString(s string) string {\n\th := fnv.New64a()\n\th.Write([]byte(s))\n\treturn base64.RawURLEncoding.EncodeToString(h.Sum(nil))\n}\n\nfunc SendWithCtxCheck[T any](ctx context.Context, ch chan<- T, val T) bool {\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn false\n\tcase ch <- val:\n\t\treturn true\n\t}\n}\n\nconst (\n\tmaxRetries = 5\n\tretryDelay = 10 * time.Millisecond\n)\n\nfunc GracefulClose(closer io.Closer, debugName, closerName string) bool {\n\tclosed := false\n\tfor retries := 0; retries < maxRetries; retries++ {\n\t\tif err := closer.Close(); err != nil {\n\t\t\tlog.Printf(\"%s: error closing %s: %v, trying again in %dms\\n\", debugName, closerName, err, retryDelay.Milliseconds())\n\t\t\ttime.Sleep(retryDelay)\n\t\t\tcontinue\n\t\t}\n\t\tclosed = true\n\t\tbreak\n\t}\n\tif !closed {\n\t\tlog.Printf(\"%s: unable to close %s after %d retries\\n\", debugName, closerName, maxRetries)\n\t}\n\treturn closed\n}\n\n// DrainChannelSafe will drain a channel until it is empty or until a timeout is reached.\nfunc DrainChannelSafe[T any](ch <-chan T, debugName string) {\n\tdrainTimeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tgo func() {\n\t\tdefer cancel()\n\touter:\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-drainTimeoutCtx.Done():\n\t\t\t\tlog.Printf(\"[error] timeout draining channel: %s\\n\", debugName)\n\t\t\t\tbreak outer\n\t\t\tcase _, ok := <-ch:\n\t\t\t\tif !ok {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n}\n\n\nfunc IsBinaryContent(data []byte) bool {\n\tif len(data) == 0 {\n\t\treturn false\n\t}\n\tsampleSize := min(8192, len(data))\n\tsample := data[:sampleSize]\n\t\n\tnullCount := 0\n\tfor _, b := range sample {\n\t\tif b == 0 {\n\t\t\tnullCount++\n\t\t}\n\t}\n\tif float64(nullCount)/float64(len(sample)) > 0.01 {\n\t\treturn true\n\t}\n\t\n\tif !utf8.Valid(sample) {\n\t\treturn true\n\t}\n\t\n\treturn false\n}\n\nfunc FormatRelativeTime(modTime time.Time) string {\n\tnow := time.Now()\n\tdiff := now.Sub(modTime)\n\t\n\tif diff < time.Minute {\n\t\treturn \"just now\"\n\t}\n\tif diff < time.Hour {\n\t\tminutes := int(diff.Minutes())\n\t\tif minutes == 1 {\n\t\t\treturn \"1 minute ago\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d minutes ago\", minutes)\n\t}\n\tif diff < 24*time.Hour {\n\t\thours := int(diff.Hours())\n\t\tif hours == 1 {\n\t\t\treturn \"1 hour ago\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d hours ago\", hours)\n\t}\n\tif diff < 30*24*time.Hour {\n\t\tdays := int(diff.Hours() / 24)\n\t\tif days == 1 {\n\t\t\treturn \"1 day ago\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d days ago\", days)\n\t}\n\tif diff < 365*24*time.Hour {\n\t\tmonths := int(diff.Hours() / 24 / 30)\n\t\tif months == 1 {\n\t\t\treturn \"1 month ago\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d months ago\", months)\n\t}\n\tyears := int(diff.Hours() / 24 / 365)\n\tif years == 1 {\n\t\treturn \"1 year ago\"\n\t}\n\treturn fmt.Sprintf(\"%d years ago\", years)\n}\n"
  },
  {
    "path": "pkg/utilds/codederror.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage utilds\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n)\n\n// CodedError wraps an error with a string code for categorization.\n// The code can be extracted from anywhere in an error chain using GetErrorCode.\n// SubCode provides additional granularity for error classification.\ntype CodedError struct {\n\tCode    string\n\tSubCode string\n\tErr     error\n}\n\nfunc (e CodedError) Error() string {\n\treturn e.Err.Error()\n}\n\nfunc (e CodedError) Unwrap() error {\n\treturn e.Err\n}\n\n// MakeCodedError creates a new CodedError with the given code and error.\nfunc MakeCodedError(code string, err error) CodedError {\n\treturn CodedError{Code: code, SubCode: \"\", Err: err}\n}\n\n// MakeSubCodedError creates a new CodedError with the given code, subcode, and error.\nfunc MakeSubCodedError(code string, subCode string, err error) CodedError {\n\treturn CodedError{Code: code, SubCode: subCode, Err: err}\n}\n\n// GetErrorCode extracts the error code from anywhere in the error chain.\n// Returns empty string if no CodedError is found.\nfunc GetErrorCode(err error) string {\n\tif err == nil {\n\t\treturn \"\"\n\t}\n\tvar coded CodedError\n\tif errors.As(err, &coded) {\n\t\treturn coded.Code\n\t}\n\treturn \"\"\n}\n\n// GetErrorSubCode extracts the error subcode from anywhere in the error chain.\n// Returns empty string if no CodedError is found or if SubCode is not set.\nfunc GetErrorSubCode(err error) string {\n\tif err == nil {\n\t\treturn \"\"\n\t}\n\tvar coded CodedError\n\tif errors.As(err, &coded) {\n\t\treturn coded.SubCode\n\t}\n\treturn \"\"\n}\n\n// Errorf creates a formatted error wrapped in a CodedError.\n// This is a convenience function that combines fmt.Errorf with MakeCodedError.\nfunc Errorf(code string, format string, args ...interface{}) error {\n\treturn MakeCodedError(code, fmt.Errorf(format, args...))\n}\n"
  },
  {
    "path": "pkg/utilds/idlist.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage utilds\n\nimport (\n\t\"sync\"\n\n\t\"github.com/google/uuid\"\n)\n\ntype idListEntry[T any] struct {\n\tid  string\n\tval T\n}\n\ntype IdList[T any] struct {\n\tlock    sync.Mutex\n\tentries []idListEntry[T]\n}\n\nfunc (il *IdList[T]) Register(val T) string {\n\til.lock.Lock()\n\tdefer il.lock.Unlock()\n\n\tid := uuid.New().String()\n\til.entries = append(il.entries, idListEntry[T]{id: id, val: val})\n\treturn id\n}\n\nfunc (il *IdList[T]) RegisterWithId(id string, val T) {\n\til.lock.Lock()\n\tdefer il.lock.Unlock()\n\n\til.unregister_nolock(id)\n\til.entries = append(il.entries, idListEntry[T]{id: id, val: val})\n}\n\nfunc (il *IdList[T]) Unregister(id string) {\n\til.lock.Lock()\n\tdefer il.lock.Unlock()\n\n\til.unregister_nolock(id)\n}\n\nfunc (il *IdList[T]) unregister_nolock(id string) {\n\tfor i, entry := range il.entries {\n\t\tif entry.id == id {\n\t\t\til.entries = append(il.entries[:i], il.entries[i+1:]...)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (il *IdList[T]) GetList() []T {\n\til.lock.Lock()\n\tdefer il.lock.Unlock()\n\n\tresult := make([]T, len(il.entries))\n\tfor i, entry := range il.entries {\n\t\tresult[i] = entry.val\n\t}\n\treturn result\n}"
  },
  {
    "path": "pkg/utilds/multireaderlinebuffer.go",
    "content": "package utilds\n\nimport (\n\t\"bufio\"\n\t\"io\"\n\t\"sync\"\n)\n\ntype MultiReaderLineBuffer struct {\n\tlock           sync.Mutex\n\tlines          []string\n\tmaxLines       int\n\ttotalLineCount int\n\tlineCallback   func(string)\n}\n\nfunc MakeMultiReaderLineBuffer(maxLines int) *MultiReaderLineBuffer {\n\tif maxLines <= 0 {\n\t\tmaxLines = 1000\n\t}\n\n\treturn &MultiReaderLineBuffer{\n\t\tlines:          make([]string, 0, maxLines),\n\t\tmaxLines:       maxLines,\n\t\ttotalLineCount: 0,\n\t}\n}\n\n// callback is synchronous.  will block the consuming of lines and\n// guaranteed to run in order.  it is also guaranteed only one callback\n// will be running at a time (protected by the internal line lock)\nfunc (mrlb *MultiReaderLineBuffer) SetLineCallback(callback func(string)) {\n\tmrlb.lock.Lock()\n\tdefer mrlb.lock.Unlock()\n\tmrlb.lineCallback = callback\n}\n\nfunc (mrlb *MultiReaderLineBuffer) ReadAll(r io.Reader) {\n\tscanner := bufio.NewScanner(r)\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tmrlb.addLine(line)\n\t\tmrlb.callLineCallback(line)\n\t}\n}\n\nfunc (mrlb *MultiReaderLineBuffer) callLineCallback(line string) {\n\tmrlb.lock.Lock()\n\tdefer mrlb.lock.Unlock()\n\n\tif mrlb.lineCallback != nil {\n\t\tmrlb.lineCallback(line)\n\t}\n}\n\nfunc (mrlb *MultiReaderLineBuffer) AddLine(line string) {\n\tmrlb.addLine(line)\n\tmrlb.callLineCallback(line)\n}\n\nfunc (mrlb *MultiReaderLineBuffer) addLine(line string) {\n\tmrlb.lock.Lock()\n\tdefer mrlb.lock.Unlock()\n\n\tmrlb.totalLineCount++\n\n\tif len(mrlb.lines) >= mrlb.maxLines {\n\t\tmrlb.lines = append(mrlb.lines[1:], line)\n\t} else {\n\t\tmrlb.lines = append(mrlb.lines, line)\n\t}\n}\n\nfunc (mrlb *MultiReaderLineBuffer) GetLines() []string {\n\tmrlb.lock.Lock()\n\tdefer mrlb.lock.Unlock()\n\n\tresult := make([]string, len(mrlb.lines))\n\tcopy(result, mrlb.lines)\n\treturn result\n}\n\nfunc (mrlb *MultiReaderLineBuffer) GetLineCount() int {\n\tmrlb.lock.Lock()\n\tdefer mrlb.lock.Unlock()\n\n\treturn len(mrlb.lines)\n}\n\nfunc (mrlb *MultiReaderLineBuffer) GetTotalLineCount() int {\n\tmrlb.lock.Lock()\n\tdefer mrlb.lock.Unlock()\n\n\treturn mrlb.totalLineCount\n}\n"
  },
  {
    "path": "pkg/utilds/quickreorderqueue.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage utilds\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\t\"sync\"\n\t\"time\"\n)\n\n// the quick reorder queue implements reordering of items with a certain timerame (the timeout passed)\n// if an item is queued in order, it gets processed immediately\n// if it comes in out of order it gets buffered for up to the timeout while we wait for the correct next seq to come in\n// if we still haven't received the \"correct\" next seq within the timeout the out of order event is flushed.\n// \"old\" events (less than the current nextseq) are flushed immediately\n//\n// we also implement a \"session\" system.  each session is assigned a virtual order based on the timestamp\n// it was first seen.  so all events of a session are either \"before\" or \"after\" all the events of a different session.\n// the assumption is that sessions will always be separated by an amount of time greater than the timeout of the reorder queue (e.g. a system reboot, or main server restart)\n//\n// enqueuing without a sessionid or if seqNum is 0, will bypass the reorder queue and just flush the event\n\ntype queuedItem[T any] struct {\n\tsessionId string\n\tseqNum    int\n\tdata      T\n\ttimestamp time.Time\n}\n\ntype QuickReorderQueue[T any] struct {\n\tlock             sync.Mutex\n\tsessionOrder     map[string]int64 // sessionId -> timestamp millis when first seen\n\tcurrentSessionId string\n\tnextSeqNum       int\n\tbuffer           []queuedItem[T]\n\toutCh            chan T\n\ttimeout          time.Duration\n\ttimer            *time.Timer\n\tclosed           bool\n}\n\nfunc MakeQuickReorderQueue[T any](bufSize int, timeout time.Duration) *QuickReorderQueue[T] {\n\treturn &QuickReorderQueue[T]{\n\t\tsessionOrder: make(map[string]int64),\n\t\tnextSeqNum:   1,\n\t\toutCh:        make(chan T, bufSize),\n\t\ttimeout:      timeout,\n\t}\n}\n\nfunc (q *QuickReorderQueue[T]) C() <-chan T {\n\treturn q.outCh\n}\n\nfunc (q *QuickReorderQueue[T]) SetNextSeqNum(seqNum int) {\n\tq.lock.Lock()\n\tdefer q.lock.Unlock()\n\tq.nextSeqNum = seqNum\n}\n\nfunc (q *QuickReorderQueue[T]) ensureSessionTs_withlock(sessionId string) {\n\tif sessionId == \"\" {\n\t\treturn\n\t}\n\tif _, ok := q.sessionOrder[sessionId]; ok {\n\t\treturn\n\t}\n\tts := time.Now().UnixMilli()\n\tq.sessionOrder[sessionId] = ts\n\tq.flushBuffer_withlock()\n\tq.currentSessionId = sessionId\n\tq.nextSeqNum = 1\n}\n\nfunc (q *QuickReorderQueue[T]) cmpSessionSeq_withlock(session1 string, seq1 int, session2 string, seq2 int) int {\n\tts1 := q.sessionOrder[session1]\n\tts2 := q.sessionOrder[session2]\n\tif ts1 < ts2 {\n\t\treturn -1\n\t}\n\tif ts1 > ts2 {\n\t\treturn 1\n\t}\n\tif seq1 < seq2 {\n\t\treturn -1\n\t}\n\tif seq1 > seq2 {\n\t\treturn 1\n\t}\n\treturn 0\n}\n\nfunc (q *QuickReorderQueue[T]) sortBuffer_withlock() {\n\tsort.Slice(q.buffer, func(i, j int) bool {\n\t\treturn q.cmpSessionSeq_withlock(q.buffer[i].sessionId, q.buffer[i].seqNum, q.buffer[j].sessionId, q.buffer[j].seqNum) < 0\n\t})\n}\n\nfunc (q *QuickReorderQueue[T]) flushBuffer_withlock() {\n\tif len(q.buffer) == 0 {\n\t\treturn\n\t}\n\tq.sortBuffer_withlock()\n\tfor _, item := range q.buffer {\n\t\tq.outCh <- item.data\n\t}\n\tq.buffer = nil\n\tif q.timer != nil {\n\t\tq.timer.Stop()\n\t\tq.timer = nil\n\t}\n}\n\nfunc (q *QuickReorderQueue[T]) QueueItem(sessionId string, seqNum int, data T) error {\n\tq.lock.Lock()\n\tdefer q.lock.Unlock()\n\n\tif q.closed {\n\t\treturn fmt.Errorf(\"ReorderQueue is closed, cannot queue new item\")\n\t}\n\n\tif len(q.buffer)+len(q.outCh) >= cap(q.outCh) {\n\t\treturn fmt.Errorf(\"queue is full, cannot accept new items, cap: %d\", cap(q.outCh))\n\t}\n\n\tq.ensureSessionTs_withlock(sessionId)\n\n\tcmp := q.cmpSessionSeq_withlock(sessionId, seqNum, q.currentSessionId, q.nextSeqNum)\n\n\tif cmp < 0 || seqNum == 0 || sessionId == \"\" {\n\t\tq.outCh <- data\n\t\treturn nil\n\t}\n\n\tif cmp == 0 {\n\t\tq.outCh <- data\n\t\tq.nextSeqNum++\n\t\tq.processBuffer_withlock()\n\t\treturn nil\n\t}\n\n\tq.buffer = append(q.buffer, queuedItem[T]{\n\t\tsessionId: sessionId,\n\t\tseqNum:    seqNum,\n\t\tdata:      data,\n\t\ttimestamp: time.Now(),\n\t})\n\tq.ensureTimer_withlock()\n\treturn nil\n}\n\nfunc (q *QuickReorderQueue[T]) processBuffer_withlock() {\n\tif len(q.buffer) == 0 {\n\t\treturn\n\t}\n\n\tq.sortBuffer_withlock()\n\n\tenqueued := 0\n\tfor i, item := range q.buffer {\n\t\tif item.sessionId == q.currentSessionId && item.seqNum == q.nextSeqNum {\n\t\t\tq.outCh <- item.data\n\t\t\tq.nextSeqNum++\n\t\t\tenqueued = i + 1\n\t\t} else {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif enqueued > 0 {\n\t\tq.buffer = q.buffer[enqueued:]\n\t}\n}\n\nfunc (q *QuickReorderQueue[T]) ensureTimer_withlock() {\n\tif q.timer != nil {\n\t\treturn\n\t}\n\tq.timer = time.AfterFunc(q.timeout, func() {\n\t\tq.onTimeout()\n\t})\n}\n\nfunc (q *QuickReorderQueue[T]) onTimeout() {\n\tq.lock.Lock()\n\tdefer q.lock.Unlock()\n\n\tif q.closed {\n\t\treturn\n\t}\n\n\tq.timer = nil\n\n\tif len(q.buffer) == 0 {\n\t\treturn\n\t}\n\n\tnow := time.Now()\n\n\tq.sortBuffer_withlock()\n\n\thighestTimedOutIdx := -1\n\tfor i, item := range q.buffer {\n\t\tif now.Sub(item.timestamp) >= q.timeout {\n\t\t\thighestTimedOutIdx = i\n\t\t}\n\t}\n\n\tif highestTimedOutIdx >= 0 {\n\t\tfor i := 0; i <= highestTimedOutIdx; i++ {\n\t\t\titem := q.buffer[i]\n\t\t\tq.outCh <- item.data\n\t\t\tif item.sessionId == q.currentSessionId && item.seqNum >= q.nextSeqNum {\n\t\t\t\tq.nextSeqNum = item.seqNum + 1\n\t\t\t}\n\t\t}\n\t\tq.buffer = q.buffer[highestTimedOutIdx+1:]\n\t}\n\n\tif len(q.buffer) > 0 {\n\t\toldestTime := q.buffer[0].timestamp\n\t\tfor _, item := range q.buffer[1:] {\n\t\t\tif item.timestamp.Before(oldestTime) {\n\t\t\t\toldestTime = item.timestamp\n\t\t\t}\n\t\t}\n\t\tnextTimeout := q.timeout - now.Sub(oldestTime)\n\t\tif nextTimeout < 0 {\n\t\t\tnextTimeout = 0\n\t\t}\n\t\tq.timer = time.AfterFunc(nextTimeout, func() {\n\t\t\tq.onTimeout()\n\t\t})\n\t}\n}\n\nfunc (q *QuickReorderQueue[T]) Close() {\n\tq.lock.Lock()\n\tdefer q.lock.Unlock()\n\n\tif q.closed {\n\t\treturn\n\t}\n\tq.closed = true\n\tif q.timer != nil {\n\t\tq.timer.Stop()\n\t\tq.timer = nil\n\t}\n\tclose(q.outCh)\n}\n"
  },
  {
    "path": "pkg/utilds/quickreorderqueue_test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage utilds\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nfunc collectItems[T any](ch <-chan T, count int, timeout time.Duration) []T {\n\tresult := make([]T, 0, count)\n\ttimer := time.NewTimer(timeout)\n\tdefer timer.Stop()\n\n\tfor i := 0; i < count; i++ {\n\t\tselect {\n\t\tcase item := <-ch:\n\t\t\tresult = append(result, item)\n\t\tcase <-timer.C:\n\t\t\treturn result\n\t\t}\n\t}\n\treturn result\n}\n\nfunc TestQuickReorderQueue_InOrder(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](10, 100*time.Millisecond)\n\tdefer q.Close()\n\n\tq.QueueItem(\"session1\", 1, \"item1\")\n\tq.QueueItem(\"session1\", 2, \"item2\")\n\tq.QueueItem(\"session1\", 3, \"item3\")\n\n\titems := collectItems(q.C(), 3, 500*time.Millisecond)\n\n\tif len(items) != 3 {\n\t\tt.Fatalf(\"expected 3 items, got %d\", len(items))\n\t}\n\tif items[0] != \"item1\" || items[1] != \"item2\" || items[2] != \"item3\" {\n\t\tt.Errorf(\"expected [item1, item2, item3], got %v\", items)\n\t}\n}\n\nfunc TestQuickReorderQueue_OutOfOrder(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](10, 200*time.Millisecond)\n\tdefer q.Close()\n\n\tq.QueueItem(\"session1\", 1, \"item1\")\n\tq.QueueItem(\"session1\", 3, \"item3\")\n\tq.QueueItem(\"session1\", 2, \"item2\")\n\n\titems := collectItems(q.C(), 3, 500*time.Millisecond)\n\n\tif len(items) != 3 {\n\t\tt.Fatalf(\"expected 3 items, got %d\", len(items))\n\t}\n\tif items[0] != \"item1\" || items[1] != \"item2\" || items[2] != \"item3\" {\n\t\tt.Errorf(\"expected [item1, item2, item3], got %v\", items)\n\t}\n}\n\nfunc TestQuickReorderQueue_MultipleOutOfOrder(t *testing.T) {\n\tq := MakeQuickReorderQueue[int](10, 200*time.Millisecond)\n\tdefer q.Close()\n\n\tq.QueueItem(\"session1\", 1, 1)\n\tq.QueueItem(\"session1\", 5, 5)\n\tq.QueueItem(\"session1\", 3, 3)\n\tq.QueueItem(\"session1\", 2, 2)\n\tq.QueueItem(\"session1\", 4, 4)\n\n\titems := collectItems(q.C(), 5, 500*time.Millisecond)\n\n\tif len(items) != 5 {\n\t\tt.Fatalf(\"expected 5 items, got %d\", len(items))\n\t}\n\tfor i := 0; i < 5; i++ {\n\t\tif items[i] != i+1 {\n\t\t\tt.Errorf(\"expected item %d at position %d, got %d\", i+1, i, items[i])\n\t\t}\n\t}\n}\n\nfunc TestQuickReorderQueue_TwoSessions_StrongSeparation(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](10, 200*time.Millisecond)\n\tdefer q.Close()\n\n\tq.QueueItem(\"session1\", 1, \"s1-1\")\n\tq.QueueItem(\"session1\", 2, \"s1-2\")\n\tq.QueueItem(\"session1\", 3, \"s1-3\")\n\n\ttime.Sleep(500 * time.Millisecond)\n\n\tq.QueueItem(\"session2\", 1, \"s2-1\")\n\tq.QueueItem(\"session2\", 2, \"s2-2\")\n\n\titems := collectItems(q.C(), 5, 500*time.Millisecond)\n\n\tif len(items) != 5 {\n\t\tt.Fatalf(\"expected 5 items, got %d\", len(items))\n\t}\n\n\texpected := []string{\"s1-1\", \"s1-2\", \"s1-3\", \"s2-1\", \"s2-2\"}\n\tfor i, exp := range expected {\n\t\tif items[i] != exp {\n\t\t\tt.Errorf(\"expected %s at position %d, got %s\", exp, i, items[i])\n\t\t}\n\t}\n}\n\nfunc TestQuickReorderQueue_TwoSessions_OutOfOrder(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](10, 200*time.Millisecond)\n\tdefer q.Close()\n\n\tq.QueueItem(\"session1\", 1, \"s1-1\")\n\tq.QueueItem(\"session1\", 3, \"s1-3\")\n\n\ttime.Sleep(500 * time.Millisecond)\n\n\tq.QueueItem(\"session2\", 1, \"s2-1\")\n\tq.QueueItem(\"session1\", 2, \"s1-2\")\n\tq.QueueItem(\"session2\", 3, \"s2-3\")\n\tq.QueueItem(\"session2\", 2, \"s2-2\")\n\n\titems := collectItems(q.C(), 6, 500*time.Millisecond)\n\n\tif len(items) != 6 {\n\t\tt.Fatalf(\"expected 6 items, got %d\", len(items))\n\t}\n\n\texpected := []string{\"s1-1\", \"s1-3\", \"s2-1\", \"s1-2\", \"s2-2\", \"s2-3\"}\n\tfor i, exp := range expected {\n\t\tif items[i] != exp {\n\t\t\tt.Errorf(\"expected %s at position %d, got %s\", exp, i, items[i])\n\t\t}\n\t}\n}\n\nfunc TestQuickReorderQueue_ThreeSessions_Sequential(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](20, 200*time.Millisecond)\n\tdefer q.Close()\n\n\tq.QueueItem(\"session1\", 1, \"s1-1\")\n\tq.QueueItem(\"session1\", 2, \"s1-2\")\n\n\ttime.Sleep(500 * time.Millisecond)\n\n\tq.QueueItem(\"session2\", 1, \"s2-1\")\n\tq.QueueItem(\"session2\", 2, \"s2-2\")\n\n\ttime.Sleep(500 * time.Millisecond)\n\n\tq.QueueItem(\"session3\", 1, \"s3-1\")\n\tq.QueueItem(\"session3\", 2, \"s3-2\")\n\n\titems := collectItems(q.C(), 6, 1*time.Second)\n\n\tif len(items) != 6 {\n\t\tt.Fatalf(\"expected 6 items, got %d\", len(items))\n\t}\n\n\texpected := []string{\"s1-1\", \"s1-2\", \"s2-1\", \"s2-2\", \"s3-1\", \"s3-2\"}\n\tfor i, exp := range expected {\n\t\tif items[i] != exp {\n\t\t\tt.Errorf(\"expected %s at position %d, got %s\", exp, i, items[i])\n\t\t}\n\t}\n}\n\nfunc TestQuickReorderQueue_SimpleTimeout(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](10, 50*time.Millisecond)\n\tdefer q.Close()\n\n\tq.QueueItem(\"session1\", 1, \"item1\")\n\tq.QueueItem(\"session1\", 3, \"item3\")\n\n\ttime.Sleep(100 * time.Millisecond)\n\n\titems := collectItems(q.C(), 2, 100*time.Millisecond)\n\n\tif len(items) != 2 {\n\t\tt.Fatalf(\"expected 2 items after timeout, got %d\", len(items))\n\t}\n\tif items[0] != \"item1\" {\n\t\tt.Errorf(\"expected item1 first, got %s\", items[0])\n\t}\n\tif items[1] != \"item3\" {\n\t\tt.Errorf(\"expected item3 second (due to timeout), got %s\", items[1])\n\t}\n\n\tq.QueueItem(\"session1\", 5, \"item5\")\n\tq.QueueItem(\"session1\", 4, \"item4\")\n\n\ttime.Sleep(100 * time.Millisecond)\n\n\titems2 := collectItems(q.C(), 2, 100*time.Millisecond)\n\n\tif len(items2) != 2 {\n\t\tt.Fatalf(\"expected 2 more items after second timeout, got %d\", len(items2))\n\t}\n\tif items2[0] != \"item4\" || items2[1] != \"item5\" {\n\t\tt.Errorf(\"expected [item4, item5] after reordering, got %v\", items2)\n\t}\n}\n\nfunc TestQuickReorderQueue_RollingTimeout(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](20, 50*time.Millisecond)\n\tdefer q.Close()\n\n\tq.QueueItem(\"session1\", 1, \"item1\")\n\ttime.Sleep(10 * time.Millisecond)\n\n\tq.QueueItem(\"session1\", 5, \"item5\")\n\ttime.Sleep(10 * time.Millisecond)\n\n\tq.QueueItem(\"session1\", 3, \"item3\")\n\ttime.Sleep(10 * time.Millisecond)\n\n\tq.QueueItem(\"session1\", 2, \"item2\")\n\ttime.Sleep(10 * time.Millisecond)\n\n\tq.QueueItem(\"session1\", 4, \"item4\")\n\ttime.Sleep(10 * time.Millisecond)\n\n\tq.QueueItem(\"session1\", 7, \"item7\")\n\ttime.Sleep(10 * time.Millisecond)\n\n\tq.QueueItem(\"session1\", 6, \"item6\")\n\n\ttime.Sleep(100 * time.Millisecond)\n\n\titems := collectItems(q.C(), 7, 200*time.Millisecond)\n\n\tif len(items) != 7 {\n\t\tt.Fatalf(\"expected 7 items, got %d: %v\", len(items), items)\n\t}\n\n\texpected := []string{\"item1\", \"item2\", \"item3\", \"item4\", \"item5\", \"item6\", \"item7\"}\n\tfor i, exp := range expected {\n\t\tif items[i] != exp {\n\t\t\tt.Errorf(\"expected %s at position %d, got %s. Full output: %v\", exp, i, items[i], items)\n\t\t}\n\t}\n}\n\nfunc TestQuickReorderQueue_Timeout(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](10, 150*time.Millisecond)\n\tdefer q.Close()\n\n\tq.QueueItem(\"session1\", 1, \"item1\")\n\tq.QueueItem(\"session1\", 3, \"item3\")\n\n\ttime.Sleep(200 * time.Millisecond)\n\n\titems := collectItems(q.C(), 2, 100*time.Millisecond)\n\n\tif len(items) != 2 {\n\t\tt.Fatalf(\"expected 2 items after timeout, got %d\", len(items))\n\t}\n\tif items[0] != \"item1\" {\n\t\tt.Errorf(\"expected item1 first, got %s\", items[0])\n\t}\n\tif items[1] != \"item3\" {\n\t\tt.Errorf(\"expected item3 second (due to timeout), got %s\", items[1])\n\t}\n}\n\nfunc TestQuickReorderQueue_TimeoutWithLateArrival(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](10, 100*time.Millisecond)\n\tdefer q.Close()\n\n\tq.QueueItem(\"session1\", 1, \"item1\")\n\tq.QueueItem(\"session1\", 3, \"item3\")\n\n\ttime.Sleep(150 * time.Millisecond)\n\n\titems := collectItems(q.C(), 2, 100*time.Millisecond)\n\n\tif len(items) != 2 {\n\t\tt.Fatalf(\"expected 2 items after timeout, got %d\", len(items))\n\t}\n\n\tq.QueueItem(\"session1\", 2, \"item2\")\n\n\tlateItem := collectItems(q.C(), 1, 100*time.Millisecond)\n\tif len(lateItem) != 1 {\n\t\tt.Fatalf(\"expected 1 late item, got %d\", len(lateItem))\n\t}\n\tif lateItem[0] != \"item2\" {\n\t\tt.Errorf(\"expected item2, got %s\", lateItem[0])\n\t}\n}\n\nfunc TestQuickReorderQueue_SessionOverlap_SmallWindow(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](10, 200*time.Millisecond)\n\tdefer q.Close()\n\n\tq.QueueItem(\"session1\", 1, \"s1-1\")\n\tq.QueueItem(\"session1\", 2, \"s1-2\")\n\tq.QueueItem(\"session1\", 3, \"s1-3\")\n\n\ttime.Sleep(500 * time.Millisecond)\n\n\tq.QueueItem(\"session2\", 1, \"s2-1\")\n\n\ttime.Sleep(50 * time.Millisecond)\n\n\tq.QueueItem(\"session1\", 4, \"s1-4\")\n\tq.QueueItem(\"session2\", 2, \"s2-2\")\n\n\titems := collectItems(q.C(), 6, 500*time.Millisecond)\n\n\tif len(items) != 6 {\n\t\tt.Fatalf(\"expected 6 items, got %d\", len(items))\n\t}\n\n\texpected := []string{\"s1-1\", \"s1-2\", \"s1-3\", \"s2-1\", \"s1-4\", \"s2-2\"}\n\tfor i, exp := range expected {\n\t\tif items[i] != exp {\n\t\t\tt.Errorf(\"expected %s at position %d, got %s\", exp, i, items[i])\n\t\t}\n\t}\n}\n\nfunc TestQuickReorderQueue_DuplicateSequence(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](10, 200*time.Millisecond)\n\tdefer q.Close()\n\n\tq.QueueItem(\"session1\", 1, \"item1-first\")\n\tq.QueueItem(\"session1\", 2, \"item2\")\n\tq.QueueItem(\"session1\", 1, \"item1-duplicate\")\n\n\titems := collectItems(q.C(), 3, 500*time.Millisecond)\n\n\tif len(items) != 3 {\n\t\tt.Fatalf(\"expected 3 items, got %d\", len(items))\n\t}\n\tif items[0] != \"item1-first\" || items[1] != \"item2\" || items[2] != \"item1-duplicate\" {\n\t\tt.Errorf(\"got %v\", items)\n\t}\n}\n\nfunc TestQuickReorderQueue_SetNextSeqNum(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](10, 200*time.Millisecond)\n\tdefer q.Close()\n\n\tq.SetNextSeqNum(5)\n\n\tq.QueueItem(\"session1\", 5, \"item5\")\n\tq.QueueItem(\"session1\", 6, \"item6\")\n\tq.QueueItem(\"session1\", 7, \"item7\")\n\n\titems := collectItems(q.C(), 3, 500*time.Millisecond)\n\n\tif len(items) != 3 {\n\t\tt.Fatalf(\"expected 3 items, got %d\", len(items))\n\t}\n\tif items[0] != \"item5\" || items[1] != \"item6\" || items[2] != \"item7\" {\n\t\tt.Errorf(\"expected [item5, item6, item7], got %v\", items)\n\t}\n}\n\nfunc TestQuickReorderQueue_EmptyBuffer(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](10, 200*time.Millisecond)\n\tdefer q.Close()\n\n\tselect {\n\tcase <-q.C():\n\t\tt.Error(\"should not have any items\")\n\tcase <-time.After(50 * time.Millisecond):\n\t}\n}\n\nfunc TestQuickReorderQueue_Close(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](10, 200*time.Millisecond)\n\n\tq.QueueItem(\"session1\", 1, \"item1\")\n\n\tq.Close()\n\n\t_, ok := <-q.C()\n\tif !ok {\n\t\tt.Error(\"expected to read item1 before close\")\n\t}\n\n\t_, ok = <-q.C()\n\tif ok {\n\t\tt.Error(\"channel should be closed\")\n\t}\n}\n\nfunc TestQuickReorderQueue_CloseWithBufferedItems(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](10, 200*time.Millisecond)\n\n\tq.QueueItem(\"session1\", 1, \"item1\")\n\tq.QueueItem(\"session1\", 3, \"item3\")\n\n\tq.Close()\n\n\titem, ok := <-q.C()\n\tif !ok || item != \"item1\" {\n\t\tt.Errorf(\"expected item1, got %s (ok=%v)\", item, ok)\n\t}\n\n\t_, ok = <-q.C()\n\tif ok {\n\t\tt.Error(\"channel should be closed, item3 should be dropped as buffered\")\n\t}\n}\n\nfunc TestQuickReorderQueue_MultiSessionComplexReordering(t *testing.T) {\n\tq := MakeQuickReorderQueue[string](20, 300*time.Millisecond)\n\tdefer q.Close()\n\n\tq.QueueItem(\"session1\", 1, \"s1-1\")\n\tq.QueueItem(\"session1\", 4, \"s1-4\")\n\tq.QueueItem(\"session1\", 2, \"s1-2\")\n\n\ttime.Sleep(500 * time.Millisecond)\n\n\tq.QueueItem(\"session2\", 2, \"s2-2\")\n\tq.QueueItem(\"session2\", 1, \"s2-1\")\n\tq.QueueItem(\"session1\", 3, \"s1-3\")\n\n\ttime.Sleep(500 * time.Millisecond)\n\n\tq.QueueItem(\"session3\", 1, \"s3-1\")\n\tq.QueueItem(\"session2\", 3, \"s2-3\")\n\n\titems := collectItems(q.C(), 8, 1*time.Second)\n\n\tif len(items) != 8 {\n\t\tt.Fatalf(\"expected 8 items, got %d\", len(items))\n\t}\n\n\texpected := []string{\"s1-1\", \"s1-2\", \"s1-4\", \"s2-1\", \"s2-2\", \"s1-3\", \"s3-1\", \"s2-3\"}\n\tfor i, exp := range expected {\n\t\tif items[i] != exp {\n\t\t\tt.Errorf(\"expected %s at position %d, got %s\", exp, i, items[i])\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/utilds/readerlinebuffer.go",
    "content": "package utilds\n\nimport (\n\t\"bufio\"\n\t\"io\"\n\t\"sync\"\n)\n\ntype ReaderLineBuffer struct {\n\tlock           sync.Mutex\n\tlines          []string\n\tmaxLines       int\n\ttotalLineCount int\n\treader         io.Reader\n\tscanner        *bufio.Scanner\n\tdone           bool\n\tlineCallback   func(string)\n}\n\nfunc MakeReaderLineBuffer(reader io.Reader, maxLines int) *ReaderLineBuffer {\n\tif maxLines <= 0 {\n\t\tmaxLines = 1000 // default max lines\n\t}\n\n\trlb := &ReaderLineBuffer{\n\t\tlines:          make([]string, 0, maxLines),\n\t\tmaxLines:       maxLines,\n\t\ttotalLineCount: 0,\n\t\treader:         reader,\n\t\tscanner:        bufio.NewScanner(reader),\n\t\tdone:           false,\n\t}\n\n\treturn rlb\n}\n\nfunc (rlb *ReaderLineBuffer) SetLineCallback(callback func(string)) {\n\trlb.lock.Lock()\n\tdefer rlb.lock.Unlock()\n\trlb.lineCallback = callback\n}\n\nfunc (rlb *ReaderLineBuffer) IsDone() bool {\n\trlb.lock.Lock()\n\tdefer rlb.lock.Unlock()\n\treturn rlb.done\n}\n\nfunc (rlb *ReaderLineBuffer) setDone() {\n\trlb.lock.Lock()\n\tdefer rlb.lock.Unlock()\n\trlb.done = true\n}\n\nfunc (rlb *ReaderLineBuffer) ReadLine() (string, error) {\n\tif rlb.IsDone() {\n\t\treturn \"\", io.EOF\n\t}\n\n\tif rlb.scanner.Scan() {\n\t\tline := rlb.scanner.Text()\n\t\trlb.addLine(line)\n\t\treturn line, nil\n\t}\n\n\t// Check for scanner error\n\tif err := rlb.scanner.Err(); err != nil {\n\t\trlb.setDone()\n\t\treturn \"\", err\n\t}\n\n\trlb.setDone()\n\treturn \"\", io.EOF\n}\n\nfunc (rlb *ReaderLineBuffer) addLine(line string) {\n\trlb.lock.Lock()\n\tdefer rlb.lock.Unlock()\n\n\trlb.totalLineCount++\n\n\tif len(rlb.lines) >= rlb.maxLines {\n\t\trlb.lines = append(rlb.lines[1:], line)\n\t} else {\n\t\trlb.lines = append(rlb.lines, line)\n\t}\n}\n\nfunc (rlb *ReaderLineBuffer) GetLines() []string {\n\trlb.lock.Lock()\n\tdefer rlb.lock.Unlock()\n\n\tresult := make([]string, len(rlb.lines))\n\tcopy(result, rlb.lines)\n\treturn result\n}\n\nfunc (rlb *ReaderLineBuffer) GetLineCount() int {\n\trlb.lock.Lock()\n\tdefer rlb.lock.Unlock()\n\n\treturn len(rlb.lines)\n}\n\nfunc (rlb *ReaderLineBuffer) GetTotalLineCount() int {\n\trlb.lock.Lock()\n\tdefer rlb.lock.Unlock()\n\n\treturn rlb.totalLineCount\n}\n\nfunc (rlb *ReaderLineBuffer) ReadAll() {\n\tfor {\n\t\tline, err := rlb.ReadLine()\n\t\tif err != nil {\n\t\t\tbreak\n\t\t}\n\t\tif rlb.lineCallback != nil {\n\t\t\trlb.lineCallback(line)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/utilds/synccache.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage utilds\n\nimport \"sync\"\n\ntype SyncCache[T any] struct {\n\tlock      sync.Mutex\n\tcomputeFn func() (T, error)\n\tvalue     T\n\terr       error\n\tcached    bool\n}\n\nfunc MakeSyncCache[T any](computeFn func() (T, error)) *SyncCache[T] {\n\treturn &SyncCache[T]{\n\t\tcomputeFn: computeFn,\n\t}\n}\n\nfunc (sc *SyncCache[T]) Get(force bool) (T, error) {\n\tsc.lock.Lock()\n\tdefer sc.lock.Unlock()\n\n\tif sc.cached && !force {\n\t\treturn sc.value, sc.err\n\t}\n\n\tsc.value, sc.err = sc.computeFn()\n\tsc.cached = true\n\treturn sc.value, sc.err\n}"
  },
  {
    "path": "pkg/utilds/versionts.go",
    "content": "package utilds\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\ntype VersionTs struct {\n\tlock        sync.Mutex\n\tlastVersion int64\n}\n\nfunc (v *VersionTs) GetVersionTs() int64 {\n\tv.lock.Lock()\n\tdefer v.lock.Unlock()\n\n\tnowMs := time.Now().UnixMilli()\n\tif nowMs <= v.lastVersion {\n\t\tv.lastVersion++\n\t\treturn v.lastVersion\n\t}\n\tv.lastVersion = nowMs\n\treturn v.lastVersion\n}\n"
  },
  {
    "path": "pkg/utilds/workqueue.go",
    "content": "package utilds\n\nimport \"sync\"\n\ntype WorkQueue[T any] struct {\n\tlock    sync.Mutex\n\tcond    *sync.Cond\n\tqueue   []T\n\tclosed  bool\n\tstarted bool\n\twg      sync.WaitGroup\n\tworkFn  func(T)\n}\n\nfunc NewWorkQueue[T any](workFn func(T)) *WorkQueue[T] {\n\twq := &WorkQueue[T]{\n\t\tworkFn: workFn,\n\t}\n\twq.cond = sync.NewCond(&wq.lock)\n\treturn wq\n}\n\nfunc (wq *WorkQueue[T]) Enqueue(item T) bool {\n\twq.lock.Lock()\n\tdefer wq.lock.Unlock()\n\tif wq.closed {\n\t\treturn false\n\t}\n\tif !wq.started {\n\t\twq.started = true\n\t\twq.wg.Add(1)\n\t\tgo wq.worker()\n\t}\n\twq.queue = append(wq.queue, item)\n\twq.cond.Signal()\n\treturn true\n}\n\nfunc (wq *WorkQueue[T]) worker() {\n\tdefer wq.wg.Done()\n\tfor {\n\t\twq.lock.Lock()\n\t\tfor len(wq.queue) == 0 && !wq.closed {\n\t\t\twq.cond.Wait()\n\t\t}\n\n\t\tif wq.closed && len(wq.queue) == 0 {\n\t\t\twq.lock.Unlock()\n\t\t\treturn\n\t\t}\n\n\t\titem := wq.queue[0]\n\t\twq.queue = wq.queue[1:]\n\t\twq.lock.Unlock()\n\n\t\twq.workFn(item)\n\t}\n}\n\nfunc (wq *WorkQueue[T]) Close(immediate bool) {\n\twq.lock.Lock()\n\twq.closed = true\n\tif immediate {\n\t\twq.queue = nil\n\t}\n\twq.cond.Broadcast()\n\twq.lock.Unlock()\n}\n\nfunc (wq *WorkQueue[T]) Wait() {\n\twq.wg.Wait()\n}\n"
  },
  {
    "path": "pkg/vdom/cssparser/cssparser.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cssparser\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"unicode\"\n)\n\ntype Parser struct {\n\tInput      string\n\tPos        int\n\tLength     int\n\tInQuote    bool\n\tQuoteChar  rune\n\tOpenParens int\n\tDebug      bool\n}\n\nfunc MakeParser(input string) *Parser {\n\treturn &Parser{\n\t\tInput:  input,\n\t\tLength: len(input),\n\t}\n}\n\nfunc (p *Parser) Parse() (map[string]string, error) {\n\tresult := make(map[string]string)\n\tlastProp := \"\"\n\tfor {\n\t\tp.skipWhitespace()\n\t\tif p.eof() {\n\t\t\tbreak\n\t\t}\n\t\tpropName, err := p.parseIdentifierColon(lastProp)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tlastProp = propName\n\t\tp.skipWhitespace()\n\t\tvalue, err := p.parseValue(propName)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult[propName] = value\n\t\tp.skipWhitespace()\n\t\tif p.eof() {\n\t\t\tbreak\n\t\t}\n\t\tif !p.expectChar(';') {\n\t\t\tbreak\n\t\t}\n\t}\n\tp.skipWhitespace()\n\tif !p.eof() {\n\t\treturn nil, fmt.Errorf(\"bad style attribute, unexpected character %q at pos %d\", string(p.Input[p.Pos]), p.Pos+1)\n\t}\n\treturn result, nil\n}\n\nfunc (p *Parser) parseIdentifierColon(lastProp string) (string, error) {\n\tstart := p.Pos\n\tfor !p.eof() {\n\t\tc := p.peekChar()\n\t\tif isIdentChar(c) || c == '-' {\n\t\t\tp.advance()\n\t\t} else {\n\t\t\tbreak\n\t\t}\n\t}\n\tattrName := p.Input[start:p.Pos]\n\tp.skipWhitespace()\n\tif p.eof() {\n\t\treturn \"\", fmt.Errorf(\"bad style attribute, expected colon after property %q, got EOF, at pos %d\", attrName, p.Pos+1)\n\t}\n\tif attrName == \"\" {\n\t\treturn \"\", fmt.Errorf(\"bad style attribute, invalid property name after property %q, at pos %d\", lastProp, p.Pos+1)\n\t}\n\tif !p.expectChar(':') {\n\t\treturn \"\", fmt.Errorf(\"bad style attribute, bad property name starting with %q, expected colon, got %q, at pos %d\", attrName, string(p.Input[p.Pos]), p.Pos+1)\n\t}\n\treturn attrName, nil\n}\n\nfunc (p *Parser) parseValue(propName string) (string, error) {\n\tstart := p.Pos\n\tquotePos := 0\n\tparenPosStack := make([]int, 0)\n\tfor !p.eof() {\n\t\tc := p.peekChar()\n\t\tif p.InQuote {\n\t\t\tif c == p.QuoteChar {\n\t\t\t\tp.InQuote = false\n\t\t\t} else if c == '\\\\' {\n\t\t\t\tp.advance()\n\t\t\t}\n\t\t} else {\n\t\t\tif c == '\"' || c == '\\'' {\n\t\t\t\tp.InQuote = true\n\t\t\t\tp.QuoteChar = c\n\t\t\t\tquotePos = p.Pos\n\t\t\t} else if c == '(' {\n\t\t\t\tp.OpenParens++\n\t\t\t\tparenPosStack = append(parenPosStack, p.Pos)\n\t\t\t} else if c == ')' {\n\t\t\t\tif p.OpenParens == 0 {\n\t\t\t\t\treturn \"\", fmt.Errorf(\"unmatched ')' at pos %d\", p.Pos+1)\n\t\t\t\t}\n\t\t\t\tp.OpenParens--\n\t\t\t\tparenPosStack = parenPosStack[:len(parenPosStack)-1]\n\t\t\t} else if c == ';' && p.OpenParens == 0 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tp.advance()\n\t}\n\tif p.eof() && p.InQuote {\n\t\treturn \"\", fmt.Errorf(\"bad style attribute, while parsing attribute %q, unmatched quote at pos %d\", propName, quotePos+1)\n\t}\n\tif p.eof() && p.OpenParens > 0 {\n\t\treturn \"\", fmt.Errorf(\"bad style attribute, while parsing property %q, unmatched '(' at pos %d\", propName, parenPosStack[len(parenPosStack)-1]+1)\n\t}\n\treturn strings.TrimSpace(p.Input[start:p.Pos]), nil\n}\n\nfunc isIdentChar(r rune) bool {\n\treturn unicode.IsLetter(r) || unicode.IsDigit(r)\n}\n\nfunc (p *Parser) skipWhitespace() {\n\tfor !p.eof() && unicode.IsSpace(p.peekChar()) {\n\t\tp.advance()\n\t}\n}\n\nfunc (p *Parser) expectChar(expected rune) bool {\n\tif !p.eof() && p.peekChar() == expected {\n\t\tp.advance()\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (p *Parser) peekChar() rune {\n\tif p.Pos >= p.Length {\n\t\treturn 0\n\t}\n\treturn rune(p.Input[p.Pos])\n}\n\nfunc (p *Parser) advance() {\n\tp.Pos++\n}\n\nfunc (p *Parser) eof() bool {\n\treturn p.Pos >= p.Length\n}\n"
  },
  {
    "path": "pkg/vdom/cssparser/cssparser_test.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cssparser\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"testing\"\n)\n\nfunc compareMaps(a, b map[string]string) error {\n\tif len(a) != len(b) {\n\t\treturn fmt.Errorf(\"map length mismatch: %d != %d\", len(a), len(b))\n\t}\n\tfor k, v := range a {\n\t\tif b[k] != v {\n\t\t\treturn fmt.Errorf(\"value mismatch for key %s: %q != %q\", k, v, b[k])\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc TestParse1(t *testing.T) {\n\tstyle := `background: url(\"example;with;semicolons.jpg\"); color: red; margin-right: 5px; content: \"hello;world\";`\n\tp := MakeParser(style)\n\tparsed, err := p.Parse()\n\tif err != nil {\n\t\tt.Fatalf(\"Parse failed: %v\", err)\n\t\treturn\n\t}\n\texpected := map[string]string{\n\t\t\"background\":   `url(\"example;with;semicolons.jpg\")`,\n\t\t\"color\":        \"red\",\n\t\t\"margin-right\": \"5px\",\n\t\t\"content\":      `\"hello;world\"`,\n\t}\n\tif err := compareMaps(parsed, expected); err != nil {\n\t\tt.Fatalf(\"Parsed map does not match expected: %v\", err)\n\t}\n\n\tstyle = `margin-right: calc(10px + 5px); color: red; font-family: \"Arial\";`\n\tp = MakeParser(style)\n\tparsed, err = p.Parse()\n\tif err != nil {\n\t\tt.Fatalf(\"Parse failed: %v\", err)\n\t\treturn\n\t}\n\texpected = map[string]string{\n\t\t\"margin-right\": `calc(10px + 5px)`,\n\t\t\"color\":        \"red\",\n\t\t\"font-family\":  `\"Arial\"`,\n\t}\n\tif err := compareMaps(parsed, expected); err != nil {\n\t\tt.Fatalf(\"Parsed map does not match expected: %v\", err)\n\t}\n}\n\nfunc TestParserErrors(t *testing.T) {\n\tstyle := `hello more: bad;`\n\tp := MakeParser(style)\n\t_, err := p.Parse()\n\tif err == nil {\n\t\tt.Fatalf(\"expected error, got nil\")\n\t}\n\tlog.Printf(\"got expected error: %v\\n\", err)\n\tstyle = `background: url(\"example.jpg`\n\tp = MakeParser(style)\n\t_, err = p.Parse()\n\tif err == nil {\n\t\tt.Fatalf(\"expected error, got nil\")\n\t}\n\tlog.Printf(\"got expected error: %v\\n\", err)\n\tstyle = `foo: url(...`\n\tp = MakeParser(style)\n\t_, err = p.Parse()\n\tif err == nil {\n\t\tt.Fatalf(\"expected error, got nil\")\n\t}\n\tlog.Printf(\"got expected error: %v\\n\", err)\n}\n"
  },
  {
    "path": "pkg/vdom/vdom.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage vdom\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"unicode\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n)\n\n// ReactNode types = nil | string | Elem\n\n// generic hook structure\ntype Hook struct {\n\tInit      bool          // is initialized\n\tIdx       int           // index in the hook array\n\tFn        func() func() // for useEffect\n\tUnmountFn func()        // for useEffect\n\tVal       any           // for useState, useMemo, useRef\n\tDeps      []any\n}\n\ntype Component[P any] func(props P) *VDomElem\n\ntype styleAttrWrapper struct {\n\tStyleAttr string\n\tVal       any\n}\n\ntype classAttrWrapper struct {\n\tClassName string\n\tCond      bool\n}\n\ntype styleAttrMapWrapper struct {\n\tStyleAttrMap map[string]any\n}\n\nfunc (e *VDomElem) Key() string {\n\tkeyVal, ok := e.Props[KeyPropKey]\n\tif !ok {\n\t\treturn \"\"\n\t}\n\tkeyStr, ok := keyVal.(string)\n\tif ok {\n\t\treturn keyStr\n\t}\n\treturn \"\"\n}\n\nfunc (e *VDomElem) WithKey(key string) *VDomElem {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Props == nil {\n\t\te.Props = make(map[string]any)\n\t}\n\te.Props[KeyPropKey] = key\n\treturn e\n}\n\nfunc TextElem(text string) VDomElem {\n\treturn VDomElem{Tag: TextTag, Text: text}\n}\n\nfunc mergeProps(props *map[string]any, newProps map[string]any) {\n\tif *props == nil {\n\t\t*props = make(map[string]any)\n\t}\n\tfor k, v := range newProps {\n\t\tif v == nil {\n\t\t\tdelete(*props, k)\n\t\t\tcontinue\n\t\t}\n\t\t(*props)[k] = v\n\t}\n}\n\nfunc mergeStyleAttr(props *map[string]any, styleAttr styleAttrWrapper) {\n\tif *props == nil {\n\t\t*props = make(map[string]any)\n\t}\n\tif (*props)[\"style\"] == nil {\n\t\t(*props)[\"style\"] = make(map[string]any)\n\t}\n\tstyleMap, ok := (*props)[\"style\"].(map[string]any)\n\tif !ok {\n\t\treturn\n\t}\n\tstyleMap[styleAttr.StyleAttr] = styleAttr.Val\n}\n\nfunc mergeClassAttr(props *map[string]any, classAttr classAttrWrapper) {\n\tif *props == nil {\n\t\t*props = make(map[string]any)\n\t}\n\tif classAttr.Cond {\n\t\tif (*props)[\"className\"] == nil {\n\t\t\t(*props)[\"className\"] = classAttr.ClassName\n\t\t\treturn\n\t\t}\n\t\tclassVal, ok := (*props)[\"className\"].(string)\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\t\t// check if class already exists (must split, contains won't work)\n\t\tsplitArr := strings.Split(classVal, \" \")\n\t\tfor _, class := range splitArr {\n\t\t\tif class == classAttr.ClassName {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\t(*props)[\"className\"] = classVal + \" \" + classAttr.ClassName\n\t} else {\n\t\tclassVal, ok := (*props)[\"className\"].(string)\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\t\tsplitArr := strings.Split(classVal, \" \")\n\t\tfor i, class := range splitArr {\n\t\t\tif class == classAttr.ClassName {\n\t\t\t\tsplitArr = append(splitArr[:i], splitArr[i+1:]...)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif len(splitArr) == 0 {\n\t\t\tdelete(*props, \"className\")\n\t\t} else {\n\t\t\t(*props)[\"className\"] = strings.Join(splitArr, \" \")\n\t\t}\n\t}\n}\n\nfunc Classes(classes ...any) string {\n\tvar parts []string\n\tfor _, class := range classes {\n\t\tswitch c := class.(type) {\n\t\tcase nil:\n\t\t\tcontinue\n\t\tcase string:\n\t\t\tif c != \"\" {\n\t\t\t\tparts = append(parts, c)\n\t\t\t}\n\t\t}\n\t\t// Ignore any other types\n\t}\n\treturn strings.Join(parts, \" \")\n}\n\nfunc H(tag string, props map[string]any, children ...any) *VDomElem {\n\trtn := &VDomElem{Tag: tag, Props: props}\n\tif len(children) > 0 {\n\t\tfor _, part := range children {\n\t\t\telems := partToElems(part)\n\t\t\trtn.Children = append(rtn.Children, elems...)\n\t\t}\n\t}\n\treturn rtn\n}\n\nfunc E(tag string, parts ...any) *VDomElem {\n\trtn := &VDomElem{Tag: tag}\n\tfor _, part := range parts {\n\t\tif part == nil {\n\t\t\tcontinue\n\t\t}\n\t\tprops, ok := part.(map[string]any)\n\t\tif ok {\n\t\t\tmergeProps(&rtn.Props, props)\n\t\t\tcontinue\n\t\t}\n\t\tif styleAttr, ok := part.(styleAttrWrapper); ok {\n\t\t\tmergeStyleAttr(&rtn.Props, styleAttr)\n\t\t\tcontinue\n\t\t}\n\t\tif styleAttrMap, ok := part.(styleAttrMapWrapper); ok {\n\t\t\tfor k, v := range styleAttrMap.StyleAttrMap {\n\t\t\t\tmergeStyleAttr(&rtn.Props, styleAttrWrapper{StyleAttr: k, Val: v})\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif classAttr, ok := part.(classAttrWrapper); ok {\n\t\t\tmergeClassAttr(&rtn.Props, classAttr)\n\t\t\tcontinue\n\t\t}\n\t\telems := partToElems(part)\n\t\trtn.Children = append(rtn.Children, elems...)\n\t}\n\treturn rtn\n}\n\nfunc Class(name string) classAttrWrapper {\n\treturn classAttrWrapper{ClassName: name, Cond: true}\n}\n\nfunc ClassIf(cond bool, name string) classAttrWrapper {\n\treturn classAttrWrapper{ClassName: name, Cond: cond}\n}\n\nfunc ClassIfElse(cond bool, name string, elseName string) classAttrWrapper {\n\tif cond {\n\t\treturn classAttrWrapper{ClassName: name, Cond: true}\n\t}\n\treturn classAttrWrapper{ClassName: elseName, Cond: true}\n}\n\nfunc If(cond bool, part any) any {\n\tif cond {\n\t\treturn part\n\t}\n\treturn nil\n}\n\nfunc IfElse(cond bool, part any, elsePart any) any {\n\tif cond {\n\t\treturn part\n\t}\n\treturn elsePart\n}\n\nfunc ForEach[T any](items []T, fn func(T) any) []any {\n\tvar elems []any\n\tfor _, item := range items {\n\t\tfnResult := fn(item)\n\t\telems = append(elems, fnResult)\n\t}\n\treturn elems\n}\n\nfunc ForEachIdx[T any](items []T, fn func(T, int) any) []any {\n\tvar elems []any\n\tfor idx, item := range items {\n\t\tfnResult := fn(item, idx)\n\t\telems = append(elems, fnResult)\n\t}\n\treturn elems\n}\n\nfunc Filter[T any](items []T, fn func(T) bool) []T {\n\tvar elems []T\n\tfor _, item := range items {\n\t\tif fn(item) {\n\t\t\telems = append(elems, item)\n\t\t}\n\t}\n\treturn elems\n}\n\nfunc FilterIdx[T any](items []T, fn func(T, int) bool) []T {\n\tvar elems []T\n\tfor idx, item := range items {\n\t\tif fn(item, idx) {\n\t\t\telems = append(elems, item)\n\t\t}\n\t}\n\treturn elems\n}\n\nfunc Props(props any) map[string]any {\n\tm, err := utilfn.StructToMap(props)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn m\n}\n\nfunc PStyle(styleAttr string, propVal any) any {\n\treturn styleAttrWrapper{StyleAttr: styleAttr, Val: propVal}\n}\n\nfunc Fragment(parts ...any) any {\n\treturn parts\n}\n\nfunc P(propName string, propVal any) any {\n\tif propVal == nil {\n\t\treturn map[string]any{propName: nil}\n\t}\n\tif propName == \"style\" {\n\t\tstrVal, ok := propVal.(string)\n\t\tif ok {\n\t\t\tstyleMap, err := styleAttrStrToStyleMap(strVal, nil)\n\t\t\tif err == nil {\n\t\t\t\treturn styleAttrMapWrapper{StyleAttrMap: styleMap}\n\t\t\t}\n\t\t\tlog.Printf(\"Error parsing style attribute: %v\\n\", err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn map[string]any{propName: propVal}\n}\n\nfunc getHookFromCtx(ctx context.Context) (*VDomContextVal, *Hook) {\n\tvc := getRenderContext(ctx)\n\tif vc == nil {\n\t\tpanic(\"UseState must be called within a component (no context)\")\n\t}\n\tif vc.Comp == nil {\n\t\tpanic(\"UseState must be called within a component (vc.Comp is nil)\")\n\t}\n\tfor len(vc.Comp.Hooks) <= vc.HookIdx {\n\t\tvc.Comp.Hooks = append(vc.Comp.Hooks, &Hook{Idx: len(vc.Comp.Hooks)})\n\t}\n\thookVal := vc.Comp.Hooks[vc.HookIdx]\n\tvc.HookIdx++\n\treturn vc, hookVal\n}\n\nfunc UseState[T any](ctx context.Context, initialVal T) (T, func(T)) {\n\tvc, hookVal := getHookFromCtx(ctx)\n\tif !hookVal.Init {\n\t\thookVal.Init = true\n\t\thookVal.Val = initialVal\n\t}\n\tvar rtnVal T\n\trtnVal, ok := hookVal.Val.(T)\n\tif !ok {\n\t\tpanic(\"UseState hook value is not a state (possible out of order or conditional hooks)\")\n\t}\n\tsetVal := func(newVal T) {\n\t\thookVal.Val = newVal\n\t\tvc.Root.AddRenderWork(vc.Comp.WaveId)\n\t}\n\treturn rtnVal, setVal\n}\n\nfunc UseStateWithFn[T any](ctx context.Context, initialVal T) (T, func(T), func(func(T) T)) {\n\tvc, hookVal := getHookFromCtx(ctx)\n\tif !hookVal.Init {\n\t\thookVal.Init = true\n\t\thookVal.Val = initialVal\n\t}\n\tvar rtnVal T\n\trtnVal, ok := hookVal.Val.(T)\n\tif !ok {\n\t\tpanic(\"UseState hook value is not a state (possible out of order or conditional hooks)\")\n\t}\n\n\tsetVal := func(newVal T) {\n\t\thookVal.Val = newVal\n\t\tvc.Root.AddRenderWork(vc.Comp.WaveId)\n\t}\n\n\tsetFuncVal := func(updateFunc func(T) T) {\n\t\thookVal.Val = updateFunc(hookVal.Val.(T))\n\t\tvc.Root.AddRenderWork(vc.Comp.WaveId)\n\t}\n\n\treturn rtnVal, setVal, setFuncVal\n}\n\nfunc UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) {\n\tvc, hookVal := getHookFromCtx(ctx)\n\tif !hookVal.Init {\n\t\thookVal.Init = true\n\t\tclosedWaveId := vc.Comp.WaveId\n\t\thookVal.UnmountFn = func() {\n\t\t\tatom := vc.Root.GetAtom(atomName)\n\t\t\tdelete(atom.UsedBy, closedWaveId)\n\t\t}\n\t}\n\tatom := vc.Root.GetAtom(atomName)\n\tatom.UsedBy[vc.Comp.WaveId] = true\n\tatomVal, ok := atom.Val.(T)\n\tif !ok {\n\t\tpanic(fmt.Sprintf(\"UseAtom %q value type mismatch (expected %T, got %T)\", atomName, atomVal, atom.Val))\n\t}\n\tsetVal := func(newVal T) {\n\t\tatom.Val = newVal\n\t\tfor waveId := range atom.UsedBy {\n\t\t\tvc.Root.AddRenderWork(waveId)\n\t\t}\n\t}\n\treturn atomVal, setVal\n}\n\nfunc UseVDomRef(ctx context.Context) *VDomRef {\n\tvc, hookVal := getHookFromCtx(ctx)\n\tif !hookVal.Init {\n\t\thookVal.Init = true\n\t\trefId := vc.Comp.WaveId + \":\" + strconv.Itoa(hookVal.Idx)\n\t\thookVal.Val = &VDomRef{Type: ObjectType_Ref, RefId: refId}\n\t}\n\trefVal, ok := hookVal.Val.(*VDomRef)\n\tif !ok {\n\t\tpanic(\"UseRef hook value is not a ref (possible out of order or conditional hooks)\")\n\t}\n\treturn refVal\n}\n\nfunc UseRef[T any](ctx context.Context, val T) *VDomSimpleRef[T] {\n\t_, hookVal := getHookFromCtx(ctx)\n\tif !hookVal.Init {\n\t\thookVal.Init = true\n\t\thookVal.Val = &VDomSimpleRef[T]{Current: val}\n\t}\n\trefVal, ok := hookVal.Val.(*VDomSimpleRef[T])\n\tif !ok {\n\t\tpanic(\"UseRef hook value is not a ref (possible out of order or conditional hooks)\")\n\t}\n\treturn refVal\n}\n\nfunc UseId(ctx context.Context) string {\n\tvc := getRenderContext(ctx)\n\tif vc == nil {\n\t\tpanic(\"UseId must be called within a component (no context)\")\n\t}\n\treturn vc.Comp.WaveId\n}\n\nfunc UseRenderTs(ctx context.Context) int64 {\n\tvc := getRenderContext(ctx)\n\tif vc == nil {\n\t\tpanic(\"UseRenderTs must be called within a component (no context)\")\n\t}\n\treturn vc.Root.RenderTs\n}\n\nfunc QueueRefOp(ctx context.Context, ref *VDomRef, op VDomRefOperation) {\n\tif ref == nil || !ref.HasCurrent {\n\t\treturn\n\t}\n\tvc := getRenderContext(ctx)\n\tif vc == nil {\n\t\tpanic(\"QueueRefOp must be called within a component (no context)\")\n\t}\n\tif op.RefId == \"\" {\n\t\top.RefId = ref.RefId\n\t}\n\tvc.Root.QueueRefOp(op)\n}\n\nfunc depsEqual(deps1 []any, deps2 []any) bool {\n\tif len(deps1) != len(deps2) {\n\t\treturn false\n\t}\n\tfor i := range deps1 {\n\t\tif deps1[i] != deps2[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc UseEffect(ctx context.Context, fn func() func(), deps []any) {\n\t// note UseEffect never actually runs anything, it just queues the effect to run later\n\tvc, hookVal := getHookFromCtx(ctx)\n\tif !hookVal.Init {\n\t\thookVal.Init = true\n\t\thookVal.Fn = fn\n\t\thookVal.Deps = deps\n\t\tvc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx)\n\t\treturn\n\t}\n\tif depsEqual(hookVal.Deps, deps) {\n\t\treturn\n\t}\n\thookVal.Fn = fn\n\thookVal.Deps = deps\n\tvc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx)\n}\n\nfunc numToString[T any](value T) (string, bool) {\n\tswitch v := any(value).(type) {\n\tcase int:\n\t\treturn strconv.FormatInt(int64(v), 10), true\n\tcase int8:\n\t\treturn strconv.FormatInt(int64(v), 10), true\n\tcase int16:\n\t\treturn strconv.FormatInt(int64(v), 10), true\n\tcase int32:\n\t\treturn strconv.FormatInt(int64(v), 10), true\n\tcase int64:\n\t\treturn strconv.FormatInt(v, 10), true\n\tcase uint:\n\t\treturn strconv.FormatUint(uint64(v), 10), true\n\tcase uint8:\n\t\treturn strconv.FormatUint(uint64(v), 10), true\n\tcase uint16:\n\t\treturn strconv.FormatUint(uint64(v), 10), true\n\tcase uint32:\n\t\treturn strconv.FormatUint(uint64(v), 10), true\n\tcase uint64:\n\t\treturn strconv.FormatUint(v, 10), true\n\tcase float32:\n\t\treturn strconv.FormatFloat(float64(v), 'f', -1, 32), true\n\tcase float64:\n\t\treturn strconv.FormatFloat(v, 'f', -1, 64), true\n\tdefault:\n\t\treturn \"\", false\n\t}\n}\n\nfunc partToElems(part any) []VDomElem {\n\tif part == nil {\n\t\treturn nil\n\t}\n\tswitch part := part.(type) {\n\tcase string:\n\t\treturn []VDomElem{TextElem(part)}\n\tcase *VDomElem:\n\t\tif part == nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn []VDomElem{*part}\n\tcase VDomElem:\n\t\treturn []VDomElem{part}\n\tcase []VDomElem:\n\t\treturn part\n\tcase []*VDomElem:\n\t\tvar rtn []VDomElem\n\t\tfor _, e := range part {\n\t\t\tif e == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\trtn = append(rtn, *e)\n\t\t}\n\t\treturn rtn\n\t}\n\tsval, ok := numToString(part)\n\tif ok {\n\t\treturn []VDomElem{TextElem(sval)}\n\t}\n\tpartVal := reflect.ValueOf(part)\n\tif partVal.Kind() == reflect.Slice {\n\t\tvar rtn []VDomElem\n\t\tfor i := 0; i < partVal.Len(); i++ {\n\t\t\tsubPart := partVal.Index(i).Interface()\n\t\t\trtn = append(rtn, partToElems(subPart)...)\n\t\t}\n\t\treturn rtn\n\t}\n\tstringer, ok := part.(fmt.Stringer)\n\tif ok {\n\t\treturn []VDomElem{TextElem(stringer.String())}\n\t}\n\tjsonStr, jsonErr := json.Marshal(part)\n\tif jsonErr == nil {\n\t\treturn []VDomElem{TextElem(string(jsonStr))}\n\t}\n\ttypeText := \"invalid:\" + reflect.TypeOf(part).String()\n\treturn []VDomElem{TextElem(typeText)}\n}\n\nfunc isWaveTag(tag string) bool {\n\treturn strings.HasPrefix(tag, \"wave:\") || strings.HasPrefix(tag, \"w:\")\n}\n\nfunc isBaseTag(tag string) bool {\n\tif len(tag) == 0 {\n\t\treturn false\n\t}\n\treturn tag[0] == '#' || unicode.IsLower(rune(tag[0])) || isWaveTag(tag)\n}\n"
  },
  {
    "path": "pkg/vdom/vdom_comp.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage vdom\n\n// so components either render to another component (or fragment)\n// or to a base element (text or vdom).  base elements can then render children\n\ntype ChildKey struct {\n\tTag string\n\tIdx int\n\tKey string\n}\n\ntype ComponentImpl struct {\n\tWaveId  string\n\tTag     string\n\tKey     string\n\tElem    *VDomElem\n\tMounted bool\n\n\t// hooks\n\tHooks []*Hook\n\n\t// #text component\n\tText string\n\n\t// base component -- vdom, wave elem, or #fragment\n\tChildren []*ComponentImpl\n\n\t// component -> component\n\tComp *ComponentImpl\n}\n\nfunc (c *ComponentImpl) compMatch(tag string, key string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\treturn c.Tag == tag && c.Key == key\n}\n"
  },
  {
    "path": "pkg/vdom/vdom_html.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage vdom\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/htmltoken\"\n\t\"github.com/wavetermdev/waveterm/pkg/vdom/cssparser\"\n)\n\n// can tokenize and bind HTML to Elems\n\nconst Html_BindPrefix = \"#bind:\"\nconst Html_ParamPrefix = \"#param:\"\nconst Html_GlobalEventPrefix = \"#globalevent\"\nconst Html_BindParamTagName = \"bindparam\"\nconst Html_BindTagName = \"bind\"\n\nfunc appendChildToStack(stack []*VDomElem, child *VDomElem) {\n\tif child == nil {\n\t\treturn\n\t}\n\tif len(stack) == 0 {\n\t\treturn\n\t}\n\tparent := stack[len(stack)-1]\n\tparent.Children = append(parent.Children, *child)\n}\n\nfunc pushElemStack(stack []*VDomElem, elem *VDomElem) []*VDomElem {\n\tif elem == nil {\n\t\treturn stack\n\t}\n\treturn append(stack, elem)\n}\n\nfunc popElemStack(stack []*VDomElem) []*VDomElem {\n\tif len(stack) <= 1 {\n\t\treturn stack\n\t}\n\tcurElem := stack[len(stack)-1]\n\tappendChildToStack(stack[:len(stack)-1], curElem)\n\treturn stack[:len(stack)-1]\n}\n\nfunc curElemTag(stack []*VDomElem) string {\n\tif len(stack) == 0 {\n\t\treturn \"\"\n\t}\n\treturn stack[len(stack)-1].Tag\n}\n\nfunc finalizeStack(stack []*VDomElem) *VDomElem {\n\tif len(stack) == 0 {\n\t\treturn nil\n\t}\n\tfor len(stack) > 1 {\n\t\tstack = popElemStack(stack)\n\t}\n\trtnElem := stack[0]\n\tif len(rtnElem.Children) == 0 {\n\t\treturn nil\n\t}\n\tif len(rtnElem.Children) == 1 {\n\t\treturn &rtnElem.Children[0]\n\t}\n\treturn rtnElem\n}\n\n// returns value, isjson\nfunc getAttrString(token htmltoken.Token, key string) string {\n\tfor _, attr := range token.Attr {\n\t\tif attr.Key == key {\n\t\t\treturn attr.Val\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc attrToProp(attrVal string, isJson bool, params map[string]any) any {\n\tif isJson {\n\t\tvar val any\n\t\terr := json.Unmarshal([]byte(attrVal), &val)\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\tunmStrVal, ok := val.(string)\n\t\tif !ok {\n\t\t\treturn val\n\t\t}\n\t\tattrVal = unmStrVal\n\t\t// fallthrough using the json str val\n\t}\n\tif strings.HasPrefix(attrVal, Html_ParamPrefix) {\n\t\tbindKey := attrVal[len(Html_ParamPrefix):]\n\t\tbindVal, ok := params[bindKey]\n\t\tif !ok {\n\t\t\treturn nil\n\t\t}\n\t\treturn bindVal\n\t}\n\tif strings.HasPrefix(attrVal, Html_BindPrefix) {\n\t\tbindKey := attrVal[len(Html_BindPrefix):]\n\t\tif bindKey == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn &VDomBinding{Type: ObjectType_Binding, Bind: bindKey}\n\t}\n\tif strings.HasPrefix(attrVal, Html_GlobalEventPrefix) {\n\t\tsplitArr := strings.Split(attrVal, \":\")\n\t\tif len(splitArr) < 2 {\n\t\t\treturn nil\n\t\t}\n\t\teventName := splitArr[1]\n\t\tif eventName == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn &VDomFunc{Type: ObjectType_Func, GlobalEvent: eventName}\n\t}\n\treturn attrVal\n}\n\nfunc tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem {\n\telem := &VDomElem{Tag: token.Data}\n\tif len(token.Attr) > 0 {\n\t\telem.Props = make(map[string]any)\n\t}\n\tfor _, attr := range token.Attr {\n\t\tif attr.Key == \"\" || attr.Val == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tpropVal := attrToProp(attr.Val, attr.IsJson, params)\n\t\telem.Props[attr.Key] = propVal\n\t}\n\treturn elem\n}\n\nfunc isWsChar(char rune) bool {\n\treturn char == ' ' || char == '\\t' || char == '\\n' || char == '\\r'\n}\n\nfunc isWsByte(char byte) bool {\n\treturn char == ' ' || char == '\\t' || char == '\\n' || char == '\\r'\n}\n\nfunc isFirstCharLt(s string) bool {\n\tfor _, char := range s {\n\t\tif isWsChar(char) {\n\t\t\tcontinue\n\t\t}\n\t\treturn char == '<'\n\t}\n\treturn false\n}\n\nfunc isLastCharGt(s string) bool {\n\tfor i := len(s) - 1; i >= 0; i-- {\n\t\tchar := s[i]\n\t\tif isWsByte(char) {\n\t\t\tcontinue\n\t\t}\n\t\treturn char == '>'\n\t}\n\treturn false\n}\n\nfunc isAllWhitespace(s string) bool {\n\tfor _, char := range s {\n\t\tif !isWsChar(char) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc trimWhitespaceConditionally(s string) string {\n\t// Trim leading whitespace if the first non-whitespace character is '<'\n\tif isAllWhitespace(s) {\n\t\treturn \"\"\n\t}\n\tif isFirstCharLt(s) {\n\t\ts = strings.TrimLeftFunc(s, func(r rune) bool {\n\t\t\treturn isWsChar(r)\n\t\t})\n\t}\n\t// Trim trailing whitespace if the last non-whitespace character is '>'\n\tif isLastCharGt(s) {\n\t\ts = strings.TrimRightFunc(s, func(r rune) bool {\n\t\t\treturn isWsChar(r)\n\t\t})\n\t}\n\treturn s\n}\n\nfunc processWhitespace(htmlStr string) string {\n\tlines := strings.Split(htmlStr, \"\\n\")\n\tvar newLines []string\n\tfor _, line := range lines {\n\t\ttrimmedLine := trimWhitespaceConditionally(line + \"\\n\")\n\t\tif trimmedLine == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tnewLines = append(newLines, trimmedLine)\n\t}\n\treturn strings.Join(newLines, \"\")\n}\n\nfunc processTextStr(s string) string {\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\tif isAllWhitespace(s) {\n\t\treturn \" \"\n\t}\n\treturn strings.TrimSpace(s)\n}\n\nfunc makePathStr(elemPath []string) string {\n\treturn strings.Join(elemPath, \" \")\n}\n\nfunc capitalizeAscii(s string) string {\n\tif s == \"\" || s[0] < 'a' || s[0] > 'z' {\n\t\treturn s\n\t}\n\treturn strings.ToUpper(s[:1]) + s[1:]\n}\n\nfunc toReactName(input string) string {\n\t// Check for CSS custom properties (variables) which start with '--'\n\tif strings.HasPrefix(input, \"--\") {\n\t\treturn input\n\t}\n\tparts := strings.Split(input, \"-\")\n\tresult := \"\"\n\tindex := 0\n\tif parts[0] == \"\" && len(parts) > 1 {\n\t\t// handle vendor prefixes\n\t\tprefix := parts[1]\n\t\tif prefix == \"ms\" {\n\t\t\tresult += \"ms\"\n\t\t} else {\n\t\t\tresult += capitalizeAscii(prefix)\n\t\t}\n\t\tindex = 2 // Skip the empty string and prefix\n\t} else {\n\t\tresult += parts[0]\n\t\tindex = 1\n\t}\n\t// Convert remaining parts to CamelCase\n\tfor ; index < len(parts); index++ {\n\t\tif parts[index] != \"\" {\n\t\t\tresult += capitalizeAscii(parts[index])\n\t\t}\n\t}\n\treturn result\n}\n\nfunc convertStyleToReactStyles(styleMap map[string]string, params map[string]any) map[string]any {\n\tif len(styleMap) == 0 {\n\t\treturn nil\n\t}\n\trtn := make(map[string]any)\n\tfor key, val := range styleMap {\n\t\trtn[toReactName(key)] = attrToProp(val, false, params)\n\t}\n\treturn rtn\n}\n\nfunc styleAttrStrToStyleMap(styleText string, params map[string]any) (map[string]any, error) {\n\tparser := cssparser.MakeParser(styleText)\n\tm, err := parser.Parse()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn convertStyleToReactStyles(m, params), nil\n}\n\nfunc fixStyleAttribute(elem *VDomElem, params map[string]any, elemPath []string) error {\n\tstyleText, ok := elem.Props[\"style\"].(string)\n\tif !ok {\n\t\treturn nil\n\t}\n\tstyleMap, err := styleAttrStrToStyleMap(styleText, params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"%v (at %s)\", err, makePathStr(elemPath))\n\t}\n\telem.Props[\"style\"] = styleMap\n\treturn nil\n}\n\nfunc fixupStyleAttributes(elem *VDomElem, params map[string]any, elemPath []string) {\n\tif elem == nil {\n\t\treturn\n\t}\n\t// call fixStyleAttribute, and walk children\n\telemCountMap := make(map[string]int)\n\tif len(elemPath) == 0 {\n\t\telemPath = append(elemPath, elem.Tag)\n\t}\n\tfixStyleAttribute(elem, params, elemPath)\n\tfor i := range elem.Children {\n\t\tchild := &elem.Children[i]\n\t\telemCountMap[child.Tag]++\n\t\tsubPath := child.Tag\n\t\tif elemCountMap[child.Tag] > 1 {\n\t\t\tsubPath = fmt.Sprintf(\"%s[%d]\", child.Tag, elemCountMap[child.Tag])\n\t\t}\n\t\telemPath = append(elemPath, subPath)\n\t\tfixupStyleAttributes(&elem.Children[i], params, elemPath)\n\t\telemPath = elemPath[:len(elemPath)-1]\n\t}\n}\n\nfunc Bind(htmlStr string, params map[string]any) *VDomElem {\n\thtmlStr = processWhitespace(htmlStr)\n\tr := strings.NewReader(htmlStr)\n\titer := htmltoken.NewTokenizer(r)\n\tvar elemStack []*VDomElem\n\telemStack = append(elemStack, &VDomElem{Tag: FragmentTag})\n\tvar tokenErr error\nouter:\n\tfor {\n\t\ttokenType := iter.Next()\n\t\ttoken := iter.Token()\n\t\tswitch tokenType {\n\t\tcase htmltoken.StartTagToken:\n\t\t\tif token.Data == Html_BindTagName || token.Data == Html_BindParamTagName {\n\t\t\t\ttokenErr = errors.New(\"bind tags must be self closing\")\n\t\t\t\tbreak outer\n\t\t\t}\n\t\t\telem := tokenToElem(token, params)\n\t\t\telemStack = pushElemStack(elemStack, elem)\n\t\tcase htmltoken.EndTagToken:\n\t\t\tif token.Data == Html_BindTagName || token.Data == Html_BindParamTagName {\n\t\t\t\ttokenErr = errors.New(\"bind tags must be self closing\")\n\t\t\t\tbreak outer\n\t\t\t}\n\t\t\tif len(elemStack) <= 1 {\n\t\t\t\ttokenErr = fmt.Errorf(\"end tag %q without start tag\", token.Data)\n\t\t\t\tbreak outer\n\t\t\t}\n\t\t\tif curElemTag(elemStack) != token.Data {\n\t\t\t\ttokenErr = fmt.Errorf(\"end tag %q does not match start tag %q\", token.Data, curElemTag(elemStack))\n\t\t\t\tbreak outer\n\t\t\t}\n\t\t\telemStack = popElemStack(elemStack)\n\t\tcase htmltoken.SelfClosingTagToken:\n\t\t\tif token.Data == Html_BindParamTagName {\n\t\t\t\tkeyAttr := getAttrString(token, \"key\")\n\t\t\t\tdataVal := params[keyAttr]\n\t\t\t\telemList := partToElems(dataVal)\n\t\t\t\tfor _, elem := range elemList {\n\t\t\t\t\tappendChildToStack(elemStack, &elem)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif token.Data == Html_BindTagName {\n\t\t\t\tkeyAttr := getAttrString(token, \"key\")\n\t\t\t\tbinding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr}\n\t\t\t\tappendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{\"text\": binding}})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\telem := tokenToElem(token, params)\n\t\t\tappendChildToStack(elemStack, elem)\n\t\tcase htmltoken.TextToken:\n\t\t\tif token.Data == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttextStr := processTextStr(token.Data)\n\t\t\tif textStr == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\telem := TextElem(textStr)\n\t\t\tappendChildToStack(elemStack, &elem)\n\t\tcase htmltoken.CommentToken:\n\t\t\tcontinue\n\t\tcase htmltoken.DoctypeToken:\n\t\t\ttokenErr = errors.New(\"doctype not supported\")\n\t\t\tbreak outer\n\t\tcase htmltoken.ErrorToken:\n\t\t\tif iter.Err() == io.EOF {\n\t\t\t\tbreak outer\n\t\t\t}\n\t\t\ttokenErr = iter.Err()\n\t\t\tbreak outer\n\t\t}\n\t}\n\tif tokenErr != nil {\n\t\terrTextElem := TextElem(tokenErr.Error())\n\t\tappendChildToStack(elemStack, &errTextElem)\n\t}\n\trtn := finalizeStack(elemStack)\n\tfixupStyleAttributes(rtn, params, nil)\n\treturn rtn\n}\n"
  },
  {
    "path": "pkg/vdom/vdom_root.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage vdom\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n)\n\nconst (\n\tBackendUpdate_InitialChunkSize = 50  // Size for initial chunks that contain both TransferElems and StateSync\n\tBackendUpdate_ChunkSize        = 100 // Size for subsequent chunks\n)\n\ntype vdomContextKeyType struct{}\n\nvar vdomContextKey = vdomContextKeyType{}\n\ntype VDomContextVal struct {\n\tRoot    *RootElem\n\tComp    *ComponentImpl\n\tHookIdx int\n}\n\ntype Atom struct {\n\tVal    any\n\tDirty  bool\n\tUsedBy map[string]bool // component waveid -> true\n}\n\ntype RootElem struct {\n\tOuterCtx        context.Context\n\tRoot            *ComponentImpl\n\tRenderTs        int64\n\tCFuncs          map[string]any\n\tCompMap         map[string]*ComponentImpl // component waveid -> component\n\tEffectWorkQueue []*EffectWorkElem\n\tNeedsRenderMap  map[string]bool\n\tAtoms           map[string]*Atom\n\tRefOperations   []VDomRefOperation\n}\n\nconst (\n\tWorkType_Render = \"render\"\n\tWorkType_Effect = \"effect\"\n)\n\ntype EffectWorkElem struct {\n\tId          string\n\tEffectIndex int\n}\n\nfunc (r *RootElem) AddRenderWork(id string) {\n\tif r.NeedsRenderMap == nil {\n\t\tr.NeedsRenderMap = make(map[string]bool)\n\t}\n\tr.NeedsRenderMap[id] = true\n}\n\nfunc (r *RootElem) AddEffectWork(id string, effectIndex int) {\n\tr.EffectWorkQueue = append(r.EffectWorkQueue, &EffectWorkElem{Id: id, EffectIndex: effectIndex})\n}\n\nfunc MakeRoot() *RootElem {\n\treturn &RootElem{\n\t\tRoot:    nil,\n\t\tCFuncs:  make(map[string]any),\n\t\tCompMap: make(map[string]*ComponentImpl),\n\t\tAtoms:   make(map[string]*Atom),\n\t}\n}\n\nfunc (r *RootElem) GetAtom(name string) *Atom {\n\tatom, ok := r.Atoms[name]\n\tif !ok {\n\t\tatom = &Atom{UsedBy: make(map[string]bool)}\n\t\tr.Atoms[name] = atom\n\t}\n\treturn atom\n}\n\nfunc (r *RootElem) GetAtomVal(name string) any {\n\tatom := r.GetAtom(name)\n\treturn atom.Val\n}\n\nfunc (r *RootElem) GetStateSync(full bool) []VDomStateSync {\n\tstateSync := make([]VDomStateSync, 0)\n\tfor atomName, atom := range r.Atoms {\n\t\tif atom.Dirty || full {\n\t\t\tstateSync = append(stateSync, VDomStateSync{Atom: atomName, Value: atom.Val})\n\t\t\tatom.Dirty = false\n\t\t}\n\t}\n\treturn stateSync\n}\n\nfunc (r *RootElem) SetAtomVal(name string, val any, markDirty bool) {\n\tatom := r.GetAtom(name)\n\tif !markDirty {\n\t\tatom.Val = val\n\t\treturn\n\t}\n\t// try to avoid setting the value and marking as dirty if it's the \"same\"\n\tif utilfn.JsonValEqual(val, atom.Val) {\n\t\treturn\n\t}\n\tatom.Val = val\n\tatom.Dirty = true\n}\n\nfunc (r *RootElem) SetOuterCtx(ctx context.Context) {\n\tr.OuterCtx = ctx\n}\n\nfunc validateCFunc(cfunc any) error {\n\tif cfunc == nil {\n\t\treturn fmt.Errorf(\"Component function cannot b nil\")\n\t}\n\trval := reflect.ValueOf(cfunc)\n\tif rval.Kind() != reflect.Func {\n\t\treturn fmt.Errorf(\"Component function must be a function\")\n\t}\n\trtype := rval.Type()\n\tif rtype.NumIn() != 2 {\n\t\treturn fmt.Errorf(\"Component function must take exactly 2 arguments\")\n\t}\n\tif rtype.NumOut() != 1 {\n\t\treturn fmt.Errorf(\"Component function must return exactly 1 value\")\n\t}\n\t// first arg must be context.Context\n\tif rtype.In(0) != reflect.TypeOf((*context.Context)(nil)).Elem() {\n\t\treturn fmt.Errorf(\"Component function first argument must be context.Context\")\n\t}\n\t// second can a map[string]any, or a struct, or ptr to struct (we'll reflect the value into it)\n\targ2Type := rtype.In(1)\n\tif arg2Type.Kind() == reflect.Ptr {\n\t\targ2Type = arg2Type.Elem()\n\t}\n\tif arg2Type.Kind() == reflect.Map {\n\t\tif arg2Type.Key().Kind() != reflect.String ||\n\t\t\t!(arg2Type.Elem().Kind() == reflect.Interface && arg2Type.Elem().NumMethod() == 0) {\n\t\t\treturn fmt.Errorf(\"Map argument must be map[string]any\")\n\t\t}\n\t} else if arg2Type.Kind() != reflect.Struct &&\n\t\t!(arg2Type.Kind() == reflect.Interface && arg2Type.NumMethod() == 0) {\n\t\treturn fmt.Errorf(\"Component function second argument must be map[string]any, struct, or any\")\n\t}\n\treturn nil\n}\n\nfunc (r *RootElem) RegisterComponent(name string, cfunc any) error {\n\tif err := validateCFunc(cfunc); err != nil {\n\t\treturn err\n\t}\n\tr.CFuncs[name] = cfunc\n\treturn nil\n}\n\nfunc (r *RootElem) Render(elem *VDomElem) {\n\tr.render(elem, &r.Root)\n}\n\nfunc (vdf *VDomFunc) CallFn(event VDomEvent) {\n\tif vdf.Fn == nil {\n\t\treturn\n\t}\n\trval := reflect.ValueOf(vdf.Fn)\n\tif rval.Kind() != reflect.Func {\n\t\treturn\n\t}\n\trtype := rval.Type()\n\tif rtype.NumIn() == 0 {\n\t\trval.Call(nil)\n\t}\n\tif rtype.NumIn() == 1 {\n\t\tif rtype.In(0) == reflect.TypeOf((*VDomEvent)(nil)).Elem() {\n\t\t\trval.Call([]reflect.Value{reflect.ValueOf(event)})\n\t\t}\n\t}\n}\n\nfunc callVDomFn(fnVal any, data VDomEvent) {\n\tif fnVal == nil {\n\t\treturn\n\t}\n\tfn := fnVal\n\tif vdf, ok := fnVal.(*VDomFunc); ok {\n\t\tfn = vdf.Fn\n\t}\n\tif fn == nil {\n\t\treturn\n\t}\n\trval := reflect.ValueOf(fn)\n\tif rval.Kind() != reflect.Func {\n\t\treturn\n\t}\n\trtype := rval.Type()\n\tif rtype.NumIn() == 0 {\n\t\trval.Call(nil)\n\t\treturn\n\t}\n\tif rtype.NumIn() == 1 {\n\t\trval.Call([]reflect.Value{reflect.ValueOf(data)})\n\t\treturn\n\t}\n}\n\nfunc (r *RootElem) Event(id string, propName string, event VDomEvent) {\n\tcomp := r.CompMap[id]\n\tif comp == nil || comp.Elem == nil {\n\t\treturn\n\t}\n\tfnVal := comp.Elem.Props[propName]\n\tcallVDomFn(fnVal, event)\n}\n\n// this will be called by the frontend to say the DOM has been mounted\n// it will eventually send any updated \"refs\" to the backend as well\nfunc (r *RootElem) RunWork() {\n\tworkQueue := r.EffectWorkQueue\n\tr.EffectWorkQueue = nil\n\t// first, run effect cleanups\n\tfor _, work := range workQueue {\n\t\tcomp := r.CompMap[work.Id]\n\t\tif comp == nil {\n\t\t\tcontinue\n\t\t}\n\t\thook := comp.Hooks[work.EffectIndex]\n\t\tif hook.UnmountFn != nil {\n\t\t\thook.UnmountFn()\n\t\t}\n\t}\n\t// now run, new effects\n\tfor _, work := range workQueue {\n\t\tcomp := r.CompMap[work.Id]\n\t\tif comp == nil {\n\t\t\tcontinue\n\t\t}\n\t\thook := comp.Hooks[work.EffectIndex]\n\t\tif hook.Fn != nil {\n\t\t\thook.UnmountFn = hook.Fn()\n\t\t}\n\t}\n\t// now check if we need a render\n\tif len(r.NeedsRenderMap) > 0 {\n\t\tr.NeedsRenderMap = nil\n\t\tr.render(r.Root.Elem, &r.Root)\n\t}\n}\n\nfunc (r *RootElem) render(elem *VDomElem, comp **ComponentImpl) {\n\tif elem == nil || elem.Tag == \"\" {\n\t\tr.unmount(comp)\n\t\treturn\n\t}\n\telemKey := elem.Key()\n\tif *comp == nil || !(*comp).compMatch(elem.Tag, elemKey) {\n\t\tr.unmount(comp)\n\t\tr.createComp(elem.Tag, elemKey, comp)\n\t}\n\t(*comp).Elem = elem\n\tif elem.Tag == TextTag {\n\t\tr.renderText(elem.Text, comp)\n\t\treturn\n\t}\n\tif isBaseTag(elem.Tag) {\n\t\t// simple vdom, fragment, wave element\n\t\tr.renderSimple(elem, comp)\n\t\treturn\n\t}\n\tcfunc := r.CFuncs[elem.Tag]\n\tif cfunc == nil {\n\t\ttext := fmt.Sprintf(\"<%s>\", elem.Tag)\n\t\tr.renderText(text, comp)\n\t\treturn\n\t}\n\tr.renderComponent(cfunc, elem, comp)\n}\n\nfunc (r *RootElem) unmount(comp **ComponentImpl) {\n\tif *comp == nil {\n\t\treturn\n\t}\n\t// parent clean up happens first\n\tfor _, hook := range (*comp).Hooks {\n\t\tif hook.UnmountFn != nil {\n\t\t\thook.UnmountFn()\n\t\t}\n\t}\n\t// clean up any children\n\tif (*comp).Comp != nil {\n\t\tr.unmount(&(*comp).Comp)\n\t}\n\tif (*comp).Children != nil {\n\t\tfor _, child := range (*comp).Children {\n\t\t\tr.unmount(&child)\n\t\t}\n\t}\n\tdelete(r.CompMap, (*comp).WaveId)\n\t*comp = nil\n}\n\nfunc (r *RootElem) createComp(tag string, key string, comp **ComponentImpl) {\n\t*comp = &ComponentImpl{WaveId: uuid.New().String(), Tag: tag, Key: key}\n\tr.CompMap[(*comp).WaveId] = *comp\n}\n\nfunc (r *RootElem) renderText(text string, comp **ComponentImpl) {\n\tif (*comp).Text != text {\n\t\t(*comp).Text = text\n\t}\n}\n\nfunc (r *RootElem) renderChildren(elems []VDomElem, curChildren []*ComponentImpl) []*ComponentImpl {\n\tnewChildren := make([]*ComponentImpl, len(elems))\n\tcurCM := make(map[ChildKey]*ComponentImpl)\n\tusedMap := make(map[*ComponentImpl]bool)\n\tfor idx, child := range curChildren {\n\t\tif child.Key != \"\" {\n\t\t\tcurCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child\n\t\t} else {\n\t\t\tcurCM[ChildKey{Tag: child.Tag, Idx: idx, Key: \"\"}] = child\n\t\t}\n\t}\n\tfor idx, elem := range elems {\n\t\telemKey := elem.Key()\n\t\tvar curChild *ComponentImpl\n\t\tif elemKey != \"\" {\n\t\t\tcurChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}]\n\t\t} else {\n\t\t\tcurChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: \"\"}]\n\t\t}\n\t\tusedMap[curChild] = true\n\t\tnewChildren[idx] = curChild\n\t\tr.render(&elem, &newChildren[idx])\n\t}\n\tfor _, child := range curChildren {\n\t\tif !usedMap[child] {\n\t\t\tr.unmount(&child)\n\t\t}\n\t}\n\treturn newChildren\n}\n\nfunc (r *RootElem) renderSimple(elem *VDomElem, comp **ComponentImpl) {\n\tif (*comp).Comp != nil {\n\t\tr.unmount(&(*comp).Comp)\n\t}\n\t(*comp).Children = r.renderChildren(elem.Children, (*comp).Children)\n}\n\nfunc (r *RootElem) makeRenderContext(comp *ComponentImpl) context.Context {\n\tvar ctx context.Context\n\tif r.OuterCtx != nil {\n\t\tctx = r.OuterCtx\n\t} else {\n\t\tctx = context.Background()\n\t}\n\tctx = context.WithValue(ctx, vdomContextKey, &VDomContextVal{Root: r, Comp: comp, HookIdx: 0})\n\treturn ctx\n}\n\nfunc getRenderContext(ctx context.Context) *VDomContextVal {\n\tv := ctx.Value(vdomContextKey)\n\tif v == nil {\n\t\treturn nil\n\t}\n\treturn v.(*VDomContextVal)\n}\n\nfunc callCFunc(cfunc any, ctx context.Context, props map[string]any) any {\n\trval := reflect.ValueOf(cfunc)\n\targ2Type := rval.Type().In(1)\n\n\tvar arg2Val reflect.Value\n\tif arg2Type.Kind() == reflect.Interface && arg2Type.NumMethod() == 0 {\n\t\t// For any/interface{}, pass nil properly\n\t\targ2Val = reflect.New(arg2Type)\n\t} else {\n\t\targ2Val = reflect.New(arg2Type)\n\t\t// if arg2 is a map, just pass props\n\t\tif arg2Type.Kind() == reflect.Map {\n\t\t\targ2Val.Elem().Set(reflect.ValueOf(props))\n\t\t} else {\n\t\t\terr := utilfn.MapToStruct(props, arg2Val.Interface())\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"error unmarshalling props: %v\\n\", err)\n\t\t\t}\n\t\t}\n\t}\n\trtnVal := rval.Call([]reflect.Value{reflect.ValueOf(ctx), arg2Val.Elem()})\n\tif len(rtnVal) == 0 {\n\t\treturn nil\n\t}\n\treturn rtnVal[0].Interface()\n}\n\nfunc (r *RootElem) renderComponent(cfunc any, elem *VDomElem, comp **ComponentImpl) {\n\tif (*comp).Children != nil {\n\t\tfor _, child := range (*comp).Children {\n\t\t\tr.unmount(&child)\n\t\t}\n\t\t(*comp).Children = nil\n\t}\n\tprops := make(map[string]any)\n\tfor k, v := range elem.Props {\n\t\tprops[k] = v\n\t}\n\tprops[ChildrenPropKey] = elem.Children\n\tctx := r.makeRenderContext(*comp)\n\trenderedElem := callCFunc(cfunc, ctx, props)\n\trtnElemArr := partToElems(renderedElem)\n\tif len(rtnElemArr) == 0 {\n\t\tr.unmount(&(*comp).Comp)\n\t\treturn\n\t}\n\tvar rtnElem *VDomElem\n\tif len(rtnElemArr) == 1 {\n\t\trtnElem = &rtnElemArr[0]\n\t} else {\n\t\trtnElem = &VDomElem{Tag: FragmentTag, Children: rtnElemArr}\n\t}\n\tr.render(rtnElem, &(*comp).Comp)\n}\n\nfunc (r *RootElem) UpdateRef(updateRef VDomRefUpdate) {\n\trefId := updateRef.RefId\n\tsplit := strings.SplitN(refId, \":\", 2)\n\tif len(split) != 2 {\n\t\tlog.Printf(\"invalid ref id: %s\\n\", refId)\n\t\treturn\n\t}\n\twaveId := split[0]\n\thookIdx, err := strconv.Atoi(split[1])\n\tif err != nil {\n\t\tlog.Printf(\"invalid ref id (bad hook idx): %s\\n\", refId)\n\t\treturn\n\t}\n\tcomp := r.CompMap[waveId]\n\tif comp == nil {\n\t\treturn\n\t}\n\tif hookIdx < 0 || hookIdx >= len(comp.Hooks) {\n\t\treturn\n\t}\n\thook := comp.Hooks[hookIdx]\n\tif hook == nil {\n\t\treturn\n\t}\n\tref, ok := hook.Val.(*VDomRef)\n\tif !ok {\n\t\treturn\n\t}\n\tref.HasCurrent = updateRef.HasCurrent\n\tref.Position = updateRef.Position\n\tr.AddRenderWork(waveId)\n}\n\nfunc (r *RootElem) QueueRefOp(op VDomRefOperation) {\n\tr.RefOperations = append(r.RefOperations, op)\n}\n\nfunc (r *RootElem) GetRefOperations() []VDomRefOperation {\n\tops := r.RefOperations\n\tr.RefOperations = nil\n\treturn ops\n}\n\nfunc convertPropsToVDom(props map[string]any) map[string]any {\n\tif len(props) == 0 {\n\t\treturn nil\n\t}\n\tvdomProps := make(map[string]any)\n\tfor k, v := range props {\n\t\tif v == nil {\n\t\t\tcontinue\n\t\t}\n\t\tval := reflect.ValueOf(v)\n\t\tif val.Kind() == reflect.Func {\n\t\t\tvdomProps[k] = VDomFunc{Type: ObjectType_Func}\n\t\t\tcontinue\n\t\t}\n\t\tvdomProps[k] = v\n\t}\n\treturn vdomProps\n}\n\nfunc convertBaseToVDom(c *ComponentImpl) *VDomElem {\n\telem := &VDomElem{WaveId: c.WaveId, Tag: c.Tag}\n\tif c.Elem != nil {\n\t\telem.Props = convertPropsToVDom(c.Elem.Props)\n\t}\n\tfor _, child := range c.Children {\n\t\tchildVDom := convertToVDom(child)\n\t\tif childVDom != nil {\n\t\t\telem.Children = append(elem.Children, *childVDom)\n\t\t}\n\t}\n\treturn elem\n}\n\nfunc convertToVDom(c *ComponentImpl) *VDomElem {\n\tif c == nil {\n\t\treturn nil\n\t}\n\tif c.Tag == TextTag {\n\t\treturn &VDomElem{Tag: TextTag, Text: c.Text}\n\t}\n\tif isBaseTag(c.Tag) {\n\t\treturn convertBaseToVDom(c)\n\t} else {\n\t\treturn convertToVDom(c.Comp)\n\t}\n}\n\nfunc (r *RootElem) makeVDom(comp *ComponentImpl) *VDomElem {\n\tvdomElem := convertToVDom(comp)\n\treturn vdomElem\n}\n\nfunc (r *RootElem) MakeVDom() *VDomElem {\n\treturn r.makeVDom(r.Root)\n}\n\nfunc ConvertElemsToTransferElems(elems []VDomElem) []VDomTransferElem {\n\tvar transferElems []VDomTransferElem\n\ttextCounter := 0 // Counter for generating unique IDs for #text nodes\n\n\t// Helper function to recursively process each VDomElem in preorder\n\tvar processElem func(elem VDomElem) string\n\tprocessElem = func(elem VDomElem) string {\n\t\t// Handle #text nodes by generating a unique placeholder ID\n\t\tif elem.Tag == \"#text\" {\n\t\t\ttextId := fmt.Sprintf(\"text-%d\", textCounter)\n\t\t\ttextCounter++\n\t\t\ttransferElems = append(transferElems, VDomTransferElem{\n\t\t\t\tWaveId:   textId,\n\t\t\t\tTag:      elem.Tag,\n\t\t\t\tText:     elem.Text,\n\t\t\t\tProps:    nil,\n\t\t\t\tChildren: nil,\n\t\t\t})\n\t\t\treturn textId\n\t\t}\n\n\t\t// Convert children to WaveId references, handling potential #text nodes\n\t\tchildrenIds := make([]string, len(elem.Children))\n\t\tfor i, child := range elem.Children {\n\t\t\tchildrenIds[i] = processElem(child) // Children are not roots\n\t\t}\n\n\t\t// Create the VDomTransferElem for the current element\n\t\ttransferElem := VDomTransferElem{\n\t\t\tWaveId:   elem.WaveId,\n\t\t\tTag:      elem.Tag,\n\t\t\tProps:    elem.Props,\n\t\t\tChildren: childrenIds,\n\t\t\tText:     elem.Text,\n\t\t}\n\t\ttransferElems = append(transferElems, transferElem)\n\n\t\treturn elem.WaveId\n\t}\n\n\t// Start processing each top-level element, marking them as roots\n\tfor _, elem := range elems {\n\t\tprocessElem(elem)\n\t}\n\n\treturn transferElems\n}\n\nfunc DedupTransferElems(elems []VDomTransferElem) []VDomTransferElem {\n\tseen := make(map[string]int) // maps WaveId to its index in the result slice\n\tvar result []VDomTransferElem\n\n\tfor _, elem := range elems {\n\t\tif idx, exists := seen[elem.WaveId]; exists {\n\t\t\t// Overwrite the previous element with the latest one\n\t\t\tresult[idx] = elem\n\t\t} else {\n\t\t\t// Add new element and store its index\n\t\t\tseen[elem.WaveId] = len(result)\n\t\t\tresult = append(result, elem)\n\t\t}\n\t}\n\n\treturn result\n}\n\nfunc (beUpdate *VDomBackendUpdate) CreateTransferElems() {\n\tvar vdomElems []VDomElem\n\tfor idx, reUpdate := range beUpdate.RenderUpdates {\n\t\tif reUpdate.VDom == nil {\n\t\t\tcontinue\n\t\t}\n\t\tvdomElems = append(vdomElems, *reUpdate.VDom)\n\t\tbeUpdate.RenderUpdates[idx].VDomWaveId = reUpdate.VDom.WaveId\n\t\tbeUpdate.RenderUpdates[idx].VDom = nil\n\t}\n\ttransferElems := ConvertElemsToTransferElems(vdomElems)\n\ttransferElems = DedupTransferElems(transferElems)\n\tbeUpdate.TransferElems = transferElems\n}\n\n// SplitBackendUpdate splits a large VDomBackendUpdate into multiple smaller updates\n// The first update contains all the core fields, while subsequent updates only contain\n// array elements that need to be appended\nfunc SplitBackendUpdate(update *VDomBackendUpdate) []*VDomBackendUpdate {\n\t// If the update is small enough, return it as is\n\tif len(update.TransferElems) <= BackendUpdate_InitialChunkSize && len(update.StateSync) <= BackendUpdate_InitialChunkSize {\n\t\treturn []*VDomBackendUpdate{update}\n\t}\n\n\tvar updates []*VDomBackendUpdate\n\n\t// First update contains core fields and initial chunks\n\tfirstUpdate := &VDomBackendUpdate{\n\t\tType:          update.Type,\n\t\tTs:            update.Ts,\n\t\tBlockId:       update.BlockId,\n\t\tOpts:          update.Opts,\n\t\tHasWork:       update.HasWork,\n\t\tRenderUpdates: update.RenderUpdates,\n\t\tRefOperations: update.RefOperations,\n\t\tMessages:      update.Messages,\n\t}\n\n\t// Add initial chunks of arrays\n\tif len(update.TransferElems) > 0 {\n\t\tfirstUpdate.TransferElems = update.TransferElems[:min(BackendUpdate_InitialChunkSize, len(update.TransferElems))]\n\t}\n\tif len(update.StateSync) > 0 {\n\t\tfirstUpdate.StateSync = update.StateSync[:min(BackendUpdate_InitialChunkSize, len(update.StateSync))]\n\t}\n\n\tupdates = append(updates, firstUpdate)\n\n\t// Create subsequent updates for remaining TransferElems\n\tfor i := BackendUpdate_InitialChunkSize; i < len(update.TransferElems); i += BackendUpdate_ChunkSize {\n\t\tend := min(i+BackendUpdate_ChunkSize, len(update.TransferElems))\n\t\tupdates = append(updates, &VDomBackendUpdate{\n\t\t\tType:          update.Type,\n\t\t\tTs:            update.Ts,\n\t\t\tBlockId:       update.BlockId,\n\t\t\tTransferElems: update.TransferElems[i:end],\n\t\t})\n\t}\n\n\t// Create subsequent updates for remaining StateSync\n\tfor i := BackendUpdate_InitialChunkSize; i < len(update.StateSync); i += BackendUpdate_ChunkSize {\n\t\tend := min(i+BackendUpdate_ChunkSize, len(update.StateSync))\n\t\tupdates = append(updates, &VDomBackendUpdate{\n\t\t\tType:      update.Type,\n\t\t\tTs:        update.Ts,\n\t\t\tBlockId:   update.BlockId,\n\t\t\tStateSync: update.StateSync[i:end],\n\t\t})\n\t}\n\n\treturn updates\n}\n"
  },
  {
    "path": "pkg/vdom/vdom_test.go",
    "content": "package vdom\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n)\n\ntype renderContextKeyType struct{}\n\nvar renderContextKey = renderContextKeyType{}\n\ntype TestContext struct {\n\tButtonId string\n}\n\nfunc Page(ctx context.Context, props map[string]any) any {\n\tclicked, setClicked := UseState(ctx, false)\n\tvar clickedDiv *VDomElem\n\tif clicked {\n\t\tclickedDiv = Bind(`<div>clicked</div>`, nil)\n\t}\n\tclickFn := func() {\n\t\tlog.Printf(\"run clickFn\\n\")\n\t\tsetClicked(true)\n\t}\n\treturn Bind(\n\t\t`\n<div>\n    <h1>hello world</h1>\n\t<Button onClick=\"#param:clickFn\">hello</Button>\n\t<bindparam key=\"clickedDiv\"/>\n</div>\n`,\n\t\tmap[string]any{\"clickFn\": clickFn, \"clickedDiv\": clickedDiv},\n\t)\n}\n\nfunc Button(ctx context.Context, props map[string]any) any {\n\tref := UseVDomRef(ctx)\n\tclName, setClName := UseState(ctx, \"button\")\n\tUseEffect(ctx, func() func() {\n\t\tfmt.Printf(\"Button useEffect\\n\")\n\t\tsetClName(\"button mounted\")\n\t\treturn nil\n\t}, nil)\n\tcompId := UseId(ctx)\n\ttestContext := getTestContext(ctx)\n\tif testContext != nil {\n\t\ttestContext.ButtonId = compId\n\t}\n\treturn Bind(`\n\t\t<div className=\"#param:clName\" ref=\"#param:ref\" onClick=\"#param:onClick\">\n\t\t\t<bindparam key=\"children\"/>\n\t\t</div>\n\t`, map[string]any{\"clName\": clName, \"ref\": ref, \"onClick\": props[\"onClick\"], \"children\": props[\"children\"]})\n}\n\nfunc printVDom(root *RootElem) {\n\tvd := root.MakeVDom()\n\tjsonBytes, _ := json.MarshalIndent(vd, \"\", \"  \")\n\tfmt.Printf(\"%s\\n\", string(jsonBytes))\n}\n\nfunc getTestContext(ctx context.Context) *TestContext {\n\tval := ctx.Value(renderContextKey)\n\tif val == nil {\n\t\treturn nil\n\t}\n\treturn val.(*TestContext)\n}\n\nfunc Test1(t *testing.T) {\n\tlog.Printf(\"hello!\\n\")\n\ttestContext := &TestContext{ButtonId: \"\"}\n\tctx := context.WithValue(context.Background(), renderContextKey, testContext)\n\troot := MakeRoot()\n\troot.SetOuterCtx(ctx)\n\troot.RegisterComponent(\"Page\", Page)\n\troot.RegisterComponent(\"Button\", Button)\n\troot.Render(E(\"Page\"))\n\tif root.Root == nil {\n\t\tt.Fatalf(\"root.Root is nil\")\n\t}\n\tprintVDom(root)\n\troot.RunWork()\n\tprintVDom(root)\n\troot.Event(testContext.ButtonId, \"onClick\", VDomEvent{EventType: \"onClick\"})\n\troot.RunWork()\n\tprintVDom(root)\n}\n\nfunc TestBind(t *testing.T) {\n\telem := Bind(`<div>clicked</div>`, nil)\n\tjsonBytes, _ := json.MarshalIndent(elem, \"\", \"  \")\n\tlog.Printf(\"%s\\n\", string(jsonBytes))\n\n\telem = Bind(`\n\t<div>\n\t    clicked\n    </div>`, nil)\n\tjsonBytes, _ = json.MarshalIndent(elem, \"\", \"  \")\n\tlog.Printf(\"%s\\n\", string(jsonBytes))\n\n\telem = Bind(`<Button>foo</Button>`, nil)\n\tjsonBytes, _ = json.MarshalIndent(elem, \"\", \"  \")\n\tlog.Printf(\"%s\\n\", string(jsonBytes))\n\n\telem = Bind(`\n<div>\n    <h1>hello world</h1>\n\t<Button onClick=\"#param:clickFn\">hello</Button>\n\t<bindparam key=\"clickedDiv\"/>\n</div>\n`, nil)\n\tjsonBytes, _ = json.MarshalIndent(elem, \"\", \"  \")\n\tlog.Printf(\"%s\\n\", string(jsonBytes))\n}\n\nfunc TestJsonBind(t *testing.T) {\n\telem := Bind(`<div data1={5} data2={[1,2,3]} data3={{\"a\": 1}}/>`, nil)\n\tif elem == nil {\n\t\tt.Fatalf(\"elem is nil\")\n\t}\n\tif elem.Tag != \"div\" {\n\t\tt.Fatalf(\"elem.Tag: %s (expected 'div')\\n\", elem.Tag)\n\t}\n\tif elem.Props == nil || len(elem.Props) != 3 {\n\t\tt.Fatalf(\"elem.Props: %v\\n\", elem.Props)\n\t}\n\tdata1Val, ok := elem.Props[\"data1\"]\n\tif !ok {\n\t\tt.Fatalf(\"data1 not found\\n\")\n\t}\n\t_, ok = data1Val.(float64)\n\tif !ok {\n\t\tt.Fatalf(\"data1: %T\\n\", data1Val)\n\t}\n\tdata1Int, ok := utilfn.ToInt(data1Val)\n\tif !ok || data1Int != 5 {\n\t\tt.Fatalf(\"data1: %v\\n\", data1Val)\n\t}\n\tdata2Val, ok := elem.Props[\"data2\"]\n\tif !ok {\n\t\tt.Fatalf(\"data2 not found\\n\")\n\t}\n\td2type := reflect.TypeOf(data2Val)\n\tif d2type.Kind() != reflect.Slice {\n\t\tt.Fatalf(\"data2: %T\\n\", data2Val)\n\t}\n\tdata2Arr := data2Val.([]any)\n\tif len(data2Arr) != 3 {\n\t\tt.Fatalf(\"data2: %v\\n\", data2Val)\n\t}\n\td2v2, ok := data2Arr[1].(float64)\n\tif !ok || d2v2 != 2 {\n\t\tt.Fatalf(\"data2: %v\\n\", data2Val)\n\t}\n\tdata3Val, ok := elem.Props[\"data3\"]\n\tif !ok || data3Val == nil {\n\t\tt.Fatalf(\"data3 not found\\n\")\n\t}\n\td3type := reflect.TypeOf(data3Val)\n\tif d3type.Kind() != reflect.Map {\n\t\tt.Fatalf(\"data3: %T\\n\", data3Val)\n\t}\n\tdata3Map := data3Val.(map[string]any)\n\tif len(data3Map) != 1 {\n\t\tt.Fatalf(\"data3: %v\\n\", data3Val)\n\t}\n\td3v1, ok := data3Map[\"a\"]\n\tif !ok {\n\t\tt.Fatalf(\"data3: %v\\n\", data3Val)\n\t}\n\tmval, ok := utilfn.ToInt(d3v1)\n\tif !ok || mval != 1 {\n\t\tt.Fatalf(\"data3: %v\\n\", data3Val)\n\t}\n\tlog.Printf(\"elem: %v\\n\", elem)\n}\n"
  },
  {
    "path": "pkg/vdom/vdom_types.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage vdom\n\nimport (\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n)\n\nconst TextTag = \"#text\"\nconst WaveTextTag = \"wave:text\"\nconst WaveNullTag = \"wave:null\"\nconst FragmentTag = \"#fragment\"\nconst BindTag = \"#bind\"\n\nconst ChildrenPropKey = \"children\"\nconst KeyPropKey = \"key\"\n\nconst ObjectType_Ref = \"ref\"\nconst ObjectType_Binding = \"binding\"\nconst ObjectType_Func = \"func\"\n\n// vdom element\ntype VDomElem struct {\n\tWaveId   string         `json:\"waveid,omitempty\"` // required, except for #text nodes\n\tTag      string         `json:\"tag\"`\n\tProps    map[string]any `json:\"props,omitempty\"`\n\tChildren []VDomElem     `json:\"children,omitempty\"`\n\tText     string         `json:\"text,omitempty\"`\n}\n\n// the over the wire format for a vdom element\ntype VDomTransferElem struct {\n\tWaveId   string         `json:\"waveid,omitempty\"` // required, except for #text nodes\n\tTag      string         `json:\"tag\"`\n\tProps    map[string]any `json:\"props,omitempty\"`\n\tChildren []string       `json:\"children,omitempty\"`\n\tText     string         `json:\"text,omitempty\"`\n}\n\n//// protocol messages\n\ntype VDomCreateContext struct {\n\tType    string              `json:\"type\" tstype:\"\\\"createcontext\\\"\"`\n\tTs      int64               `json:\"ts\"`\n\tMeta    waveobj.MetaMapType `json:\"meta,omitempty\"`\n\tTarget  *VDomTarget         `json:\"target,omitempty\"`\n\tPersist bool                `json:\"persist,omitempty\"`\n}\n\ntype VDomAsyncInitiationRequest struct {\n\tType    string `json:\"type\" tstype:\"\\\"asyncinitiationrequest\\\"\"`\n\tTs      int64  `json:\"ts\"`\n\tBlockId string `json:\"blockid,omitempty\"`\n}\n\nfunc MakeAsyncInitiationRequest(blockId string) VDomAsyncInitiationRequest {\n\treturn VDomAsyncInitiationRequest{\n\t\tType:    \"asyncinitiationrequest\",\n\t\tTs:      time.Now().UnixMilli(),\n\t\tBlockId: blockId,\n\t}\n}\n\ntype VDomFrontendUpdate struct {\n\tType          string            `json:\"type\" tstype:\"\\\"frontendupdate\\\"\"`\n\tTs            int64             `json:\"ts\"`\n\tBlockId       string            `json:\"blockid\"`\n\tCorrelationId string            `json:\"correlationid,omitempty\"`\n\tDispose       bool              `json:\"dispose,omitempty\"` // the vdom context was closed\n\tResync        bool              `json:\"resync,omitempty\"`  // resync (send all backend data).  useful when the FE reloads\n\tRenderContext VDomRenderContext `json:\"rendercontext,omitempty\"`\n\tEvents        []VDomEvent       `json:\"events,omitempty\"`\n\tStateSync     []VDomStateSync   `json:\"statesync,omitempty\"`\n\tRefUpdates    []VDomRefUpdate   `json:\"refupdates,omitempty\"`\n\tMessages      []VDomMessage     `json:\"messages,omitempty\"`\n}\n\ntype VDomBackendUpdate struct {\n\tType          string             `json:\"type\" tstype:\"\\\"backendupdate\\\"\"`\n\tTs            int64              `json:\"ts\"`\n\tBlockId       string             `json:\"blockid\"`\n\tOpts          *VDomBackendOpts   `json:\"opts,omitempty\"`\n\tHasWork       bool               `json:\"haswork,omitempty\"`\n\tRenderUpdates []VDomRenderUpdate `json:\"renderupdates,omitempty\"`\n\tTransferElems []VDomTransferElem `json:\"transferelems,omitempty\"`\n\tStateSync     []VDomStateSync    `json:\"statesync,omitempty\"`\n\tRefOperations []VDomRefOperation `json:\"refoperations,omitempty\"`\n\tMessages      []VDomMessage      `json:\"messages,omitempty\"`\n}\n\n///// prop types\n\n// used in props\ntype VDomBinding struct {\n\tType string `json:\"type\" tstype:\"\\\"binding\\\"\"`\n\tBind string `json:\"bind\"`\n}\n\n// used in props\ntype VDomFunc struct {\n\tFn              any      `json:\"-\"` // server side function (called with reflection)\n\tType            string   `json:\"type\" tstype:\"\\\"func\\\"\"`\n\tStopPropagation bool     `json:\"stoppropagation,omitempty\"`\n\tPreventDefault  bool     `json:\"preventdefault,omitempty\"`\n\tGlobalEvent     string   `json:\"globalevent,omitempty\"`\n\tKeys            []string `json:\"#keys,omitempty\"` // special for keyDown events a list of keys to \"capture\"\n}\n\n// used in props\ntype VDomRef struct {\n\tType          string           `json:\"type\" tstype:\"\\\"ref\\\"\"`\n\tRefId         string           `json:\"refid\"`\n\tTrackPosition bool             `json:\"trackposition,omitempty\"`\n\tPosition      *VDomRefPosition `json:\"position,omitempty\"`\n\tHasCurrent    bool             `json:\"hascurrent,omitempty\"`\n}\n\ntype VDomSimpleRef[T any] struct {\n\tCurrent T `json:\"current\"`\n}\n\ntype DomRect struct {\n\tTop    float64 `json:\"top\"`\n\tLeft   float64 `json:\"left\"`\n\tRight  float64 `json:\"right\"`\n\tBottom float64 `json:\"bottom\"`\n\tWidth  float64 `json:\"width\"`\n\tHeight float64 `json:\"height\"`\n}\n\ntype VDomRefPosition struct {\n\tOffsetHeight       int     `json:\"offsetheight\"`\n\tOffsetWidth        int     `json:\"offsetwidth\"`\n\tScrollHeight       int     `json:\"scrollheight\"`\n\tScrollWidth        int     `json:\"scrollwidth\"`\n\tScrollTop          int     `json:\"scrolltop\"`\n\tBoundingClientRect DomRect `json:\"boundingclientrect\"`\n}\n\n///// subbordinate protocol types\n\ntype VDomEvent struct {\n\tWaveId          string             `json:\"waveid\"`\n\tEventType       string             `json:\"eventtype\"` // usually the prop name (e.g. onClick, onKeyDown)\n\tGlobalEventType string             `json:\"globaleventtype,omitempty\"`\n\tTargetValue     string             `json:\"targetvalue,omitempty\"`\n\tTargetChecked   bool               `json:\"targetchecked,omitempty\"`\n\tTargetName      string             `json:\"targetname,omitempty\"`\n\tTargetId        string             `json:\"targetid,omitempty\"`\n\tKeyData         *WaveKeyboardEvent `json:\"keydata,omitempty\"`\n\tMouseData       *WavePointerData   `json:\"mousedata,omitempty\"`\n}\n\ntype VDomRenderContext struct {\n\tBlockId    string `json:\"blockid\"`\n\tFocused    bool   `json:\"focused\"`\n\tWidth      int    `json:\"width\"`\n\tHeight     int    `json:\"height\"`\n\tRootRefId  string `json:\"rootrefid\"`\n\tBackground bool   `json:\"background,omitempty\"`\n}\n\ntype VDomStateSync struct {\n\tAtom  string `json:\"atom\"`\n\tValue any    `json:\"value\"`\n}\n\ntype VDomRefUpdate struct {\n\tRefId      string           `json:\"refid\"`\n\tHasCurrent bool             `json:\"hascurrent\"`\n\tPosition   *VDomRefPosition `json:\"position,omitempty\"`\n}\n\ntype VDomBackendOpts struct {\n\tCloseOnCtrlC         bool `json:\"closeonctrlc,omitempty\"`\n\tGlobalKeyboardEvents bool `json:\"globalkeyboardevents,omitempty\"`\n\tGlobalStyles         bool `json:\"globalstyles,omitempty\"`\n}\n\ntype VDomRenderUpdate struct {\n\tUpdateType string    `json:\"updatetype\" tstype:\"\\\"root\\\"|\\\"append\\\"|\\\"replace\\\"|\\\"remove\\\"|\\\"insert\\\"\"`\n\tWaveId     string    `json:\"waveid,omitempty\"`\n\tVDomWaveId string    `json:\"vdomwaveid,omitempty\"`\n\tVDom       *VDomElem `json:\"vdom,omitempty\"` // these get removed for transfer (encoded to transferelems)\n\tIndex      *int      `json:\"index,omitempty\"`\n}\n\ntype VDomRefOperation struct {\n\tRefId     string `json:\"refid\"`\n\tOp        string `json:\"op\"`\n\tParams    []any  `json:\"params,omitempty\"`\n\tOutputRef string `json:\"outputref,omitempty\"`\n}\n\ntype VDomMessage struct {\n\tMessageType string `json:\"messagetype\"`\n\tMessage     string `json:\"message\"`\n\tStackTrace  string `json:\"stacktrace,omitempty\"`\n\tParams      []any  `json:\"params,omitempty\"`\n}\n\n// target -- to support new targets in the future, like toolbars, partial blocks, splits, etc.\n// default is vdom context inside of a terminal block\ntype VDomTarget struct {\n\tNewBlock  bool               `json:\"newblock,omitempty\"`\n\tMagnified bool               `json:\"magnified,omitempty\"`\n\tToolbar   *VDomTargetToolbar `json:\"toolbar,omitempty\"`\n}\n\ntype VDomTargetToolbar struct {\n\tToolbar bool   `json:\"toolbar\"`\n\tHeight  string `json:\"height,omitempty\"`\n}\n\n// matches WaveKeyboardEvent\ntype VDomKeyboardEvent struct {\n\tType     string `json:\"type\"`\n\tKey      string `json:\"key\"`\n\tCode     string `json:\"code\"`\n\tShift    bool   `json:\"shift,omitempty\"`\n\tControl  bool   `json:\"ctrl,omitempty\"`\n\tAlt      bool   `json:\"alt,omitempty\"`\n\tMeta     bool   `json:\"meta,omitempty\"`\n\tCmd      bool   `json:\"cmd,omitempty\"`\n\tOption   bool   `json:\"option,omitempty\"`\n\tRepeat   bool   `json:\"repeat,omitempty\"`\n\tLocation int    `json:\"location,omitempty\"`\n}\n\ntype WaveKeyboardEvent struct {\n\tType     string `json:\"type\" tstype:\"\\\"keydown\\\"|\\\"keyup\\\"|\\\"keypress\\\"|\\\"unknown\\\"\"`\n\tKey      string `json:\"key\"`  // KeyboardEvent.key\n\tCode     string `json:\"code\"` // KeyboardEvent.code\n\tRepeat   bool   `json:\"repeat,omitempty\"`\n\tLocation int    `json:\"location,omitempty\"` // KeyboardEvent.location\n\n\t// modifiers\n\tShift   bool `json:\"shift,omitempty\"`\n\tControl bool `json:\"control,omitempty\"`\n\tAlt     bool `json:\"alt,omitempty\"`\n\tMeta    bool `json:\"meta,omitempty\"`\n\tCmd     bool `json:\"cmd,omitempty\"`    // special (on mac it is meta, on windows/linux it is alt)\n\tOption  bool `json:\"option,omitempty\"` // special (on mac it is alt, on windows/linux it is meta)\n}\n\ntype WavePointerData struct {\n\tButton  int `json:\"button\"`\n\tButtons int `json:\"buttons\"`\n\n\tClientX   int `json:\"clientx,omitempty\"`\n\tClientY   int `json:\"clienty,omitempty\"`\n\tPageX     int `json:\"pagex,omitempty\"`\n\tPageY     int `json:\"pagey,omitempty\"`\n\tScreenX   int `json:\"screenx,omitempty\"`\n\tScreenY   int `json:\"screeny,omitempty\"`\n\tMovementX int `json:\"movementx,omitempty\"`\n\tMovementY int `json:\"movementy,omitempty\"`\n\n\t// Modifiers\n\tShift   bool `json:\"shift,omitempty\"`\n\tControl bool `json:\"control,omitempty\"`\n\tAlt     bool `json:\"alt,omitempty\"`\n\tMeta    bool `json:\"meta,omitempty\"`\n\tCmd     bool `json:\"cmd,omitempty\"`    // special (on mac it is meta, on windows/linux it is alt)\n\tOption  bool `json:\"option,omitempty\"` // special (on mac it is alt, on windows/linux it is meta)\n}\n"
  },
  {
    "path": "pkg/waveai/anthropicbackend.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveai\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype AnthropicBackend struct{}\n\nvar _ AIBackend = AnthropicBackend{}\n\n// Claude API request types\ntype anthropicMessage struct {\n\tRole    string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype anthropicRequest struct {\n\tModel       string             `json:\"model\"`\n\tMessages    []anthropicMessage `json:\"messages\"`\n\tSystem      string             `json:\"system,omitempty\"`\n\tMaxTokens   int                `json:\"max_tokens,omitempty\"`\n\tStream      bool               `json:\"stream\"`\n\tTemperature float32            `json:\"temperature,omitempty\"`\n}\n\n// Claude API response types for SSE events\ntype anthropicContentBlock struct {\n\tType string `json:\"type\"` // \"text\" or other content types\n\tText string `json:\"text,omitempty\"`\n}\n\ntype anthropicUsage struct {\n\tInputTokens  int `json:\"input_tokens\"`\n\tOutputTokens int `json:\"output_tokens\"`\n}\n\ntype anthropicResponseMessage struct {\n\tID           string                  `json:\"id\"`\n\tType         string                  `json:\"type\"`\n\tRole         string                  `json:\"role\"`\n\tContent      []anthropicContentBlock `json:\"content\"`\n\tModel        string                  `json:\"model\"`\n\tStopReason   string                  `json:\"stop_reason,omitempty\"`\n\tStopSequence string                  `json:\"stop_sequence,omitempty\"`\n\tUsage        *anthropicUsage         `json:\"usage,omitempty\"`\n}\n\ntype anthropicStreamEventError struct {\n\tType    string `json:\"type\"`\n\tMessage string `json:\"message\"`\n}\n\ntype anthropicStreamEventDelta struct {\n\tText string `json:\"text\"`\n}\n\ntype anthropicStreamEvent struct {\n\tType         string                     `json:\"type\"`\n\tMessage      *anthropicResponseMessage  `json:\"message,omitempty\"`\n\tContentBlock *anthropicContentBlock     `json:\"content_block,omitempty\"`\n\tDelta        *anthropicStreamEventDelta `json:\"delta,omitempty\"`\n\tError        *anthropicStreamEventError `json:\"error,omitempty\"`\n\tUsage        *anthropicUsage            `json:\"usage,omitempty\"`\n}\n\n// SSE event represents a parsed Server-Sent Event\ntype sseEvent struct {\n\tEvent string // The event type field\n\tData  string // The data field\n}\n\n// parseSSE reads and parses SSE format from a bufio.Reader\nfunc parseSSE(reader *bufio.Reader) (*sseEvent, error) {\n\tvar event sseEvent\n\n\tfor {\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\t// Empty line signals end of event\n\t\t\tif event.Event != \"\" || event.Data != \"\" {\n\t\t\t\treturn &event, nil\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tif strings.HasPrefix(line, \"event:\") {\n\t\t\tevent.Event = strings.TrimSpace(strings.TrimPrefix(line, \"event:\"))\n\t\t} else if strings.HasPrefix(line, \"data:\") {\n\t\t\tevent.Data = strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\t}\n\t}\n}\n\nfunc (AnthropicBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] {\n\trtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType])\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanicErr := panichandler.PanicHandler(\"AnthropicBackend.StreamCompletion\", recover())\n\t\t\tif panicErr != nil {\n\t\t\t\trtn <- makeAIError(panicErr)\n\t\t\t}\n\t\t\tclose(rtn)\n\t\t}()\n\n\t\tif request.Opts == nil {\n\t\t\trtn <- makeAIError(errors.New(\"no anthropic opts found\"))\n\t\t\treturn\n\t\t}\n\n\t\tmodel := request.Opts.Model\n\t\tif model == \"\" {\n\t\t\tmodel = \"claude-3-sonnet-20250229\" // default model\n\t\t}\n\n\t\t// Convert messages format\n\t\tvar messages []anthropicMessage\n\t\tvar systemPrompt string\n\n\t\tfor _, msg := range request.Prompt {\n\t\t\tif msg.Role == \"system\" {\n\t\t\t\tif systemPrompt != \"\" {\n\t\t\t\t\tsystemPrompt += \"\\n\"\n\t\t\t\t}\n\t\t\t\tsystemPrompt += msg.Content\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\trole := \"user\"\n\t\t\tif msg.Role == \"assistant\" {\n\t\t\t\trole = \"assistant\"\n\t\t\t}\n\n\t\t\tmessages = append(messages, anthropicMessage{\n\t\t\t\tRole:    role,\n\t\t\t\tContent: msg.Content,\n\t\t\t})\n\t\t}\n\n\t\tanthropicReq := anthropicRequest{\n\t\t\tModel:     model,\n\t\t\tMessages:  messages,\n\t\t\tSystem:    systemPrompt,\n\t\t\tStream:    true,\n\t\t\tMaxTokens: request.Opts.MaxTokens,\n\t\t}\n\n\t\treqBody, err := json.Marshal(anthropicReq)\n\t\tif err != nil {\n\t\t\trtn <- makeAIError(fmt.Errorf(\"failed to marshal anthropic request: %v\", err))\n\t\t\treturn\n\t\t}\n\n\t\t// Build endpoint allowing custom base URL from presets/settings\n\t\tendpoint := \"https://api.anthropic.com/v1/messages\"\n\t\tif request.Opts.BaseURL != \"\" {\n\t\t\tendpoint = strings.TrimSpace(request.Opts.BaseURL)\n\t\t}\n\n\t\treq, err := http.NewRequestWithContext(ctx, \"POST\", endpoint, strings.NewReader(string(reqBody)))\n\t\tif err != nil {\n\t\t\trtn <- makeAIError(fmt.Errorf(\"failed to create anthropic request: %v\", err))\n\t\t\treturn\n\t\t}\n\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treq.Header.Set(\"x-api-key\", request.Opts.APIToken)\n\t\tversion := \"2023-06-01\"\n\t\tif request.Opts.APIVersion != \"\" {\n\t\t\tversion = request.Opts.APIVersion\n\t\t}\n\t\treq.Header.Set(\"anthropic-version\", version)\n\n\t\t// Configure HTTP client with proxy if specified\n\t\tclient := &http.Client{}\n\t\tif request.Opts.ProxyURL != \"\" {\n\t\t\tproxyURL, err := url.Parse(request.Opts.ProxyURL)\n\t\t\tif err != nil {\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"invalid proxy URL: %v\", err))\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttransport := &http.Transport{\n\t\t\t\tProxy: http.ProxyURL(proxyURL),\n\t\t\t}\n\t\t\tclient.Transport = transport\n\t\t}\n\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\trtn <- makeAIError(fmt.Errorf(\"failed to send anthropic request: %v\", err))\n\t\t\treturn\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\t\trtn <- makeAIError(fmt.Errorf(\"Anthropic API error: %s - %s\", resp.Status, string(bodyBytes)))\n\t\t\treturn\n\t\t}\n\n\t\treader := bufio.NewReader(resp.Body)\n\t\tfor {\n\t\t\t// Check for context cancellation\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"request cancelled: %v\", ctx.Err()))\n\t\t\t\treturn\n\t\t\tdefault:\n\t\t\t}\n\n\t\t\tsse, err := parseSSE(reader)\n\t\t\tif err == io.EOF {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"error reading SSE stream: %v\", err))\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tif sse.Event == \"ping\" {\n\t\t\t\tcontinue // Ignore ping events\n\t\t\t}\n\n\t\t\tvar event anthropicStreamEvent\n\t\t\tif err := json.Unmarshal([]byte(sse.Data), &event); err != nil {\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"error unmarshaling event data: %v\", err))\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tif event.Error != nil {\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"Anthropic API error: %s - %s\", event.Error.Type, event.Error.Message))\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tswitch sse.Event {\n\t\t\tcase \"message_start\":\n\t\t\t\tif event.Message != nil {\n\t\t\t\t\tpk := MakeWaveAIPacket()\n\t\t\t\t\tpk.Model = event.Message.Model\n\t\t\t\t\trtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk}\n\t\t\t\t}\n\n\t\t\tcase \"content_block_start\":\n\t\t\t\tif event.ContentBlock != nil && event.ContentBlock.Text != \"\" {\n\t\t\t\t\tpk := MakeWaveAIPacket()\n\t\t\t\t\tpk.Text = event.ContentBlock.Text\n\t\t\t\t\trtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk}\n\t\t\t\t}\n\n\t\t\tcase \"content_block_delta\":\n\t\t\t\tif event.Delta != nil && event.Delta.Text != \"\" {\n\t\t\t\t\tpk := MakeWaveAIPacket()\n\t\t\t\t\tpk.Text = event.Delta.Text\n\t\t\t\t\trtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk}\n\t\t\t\t}\n\n\t\t\tcase \"content_block_stop\":\n\t\t\t\t// Note: According to the docs, this just signals the end of a content block\n\t\t\t\t// We might want to use this for tracking block boundaries, but for now\n\t\t\t\t// we don't need to send anything special to match OpenAI's format\n\n\t\t\tcase \"message_delta\":\n\t\t\t\t// Update message metadata, usage stats\n\t\t\t\tif event.Usage != nil {\n\t\t\t\t\tpk := MakeWaveAIPacket()\n\t\t\t\t\tpk.Usage = &wshrpc.WaveAIUsageType{\n\t\t\t\t\t\tPromptTokens:     event.Usage.InputTokens,\n\t\t\t\t\t\tCompletionTokens: event.Usage.OutputTokens,\n\t\t\t\t\t\tTotalTokens:      event.Usage.InputTokens + event.Usage.OutputTokens,\n\t\t\t\t\t}\n\t\t\t\t\trtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk}\n\t\t\t\t}\n\n\t\t\tcase \"message_stop\":\n\t\t\t\tif event.Message != nil {\n\t\t\t\t\tpk := MakeWaveAIPacket()\n\t\t\t\t\tpk.FinishReason = event.Message.StopReason\n\t\t\t\t\tif event.Message.Usage != nil {\n\t\t\t\t\t\tpk.Usage = &wshrpc.WaveAIUsageType{\n\t\t\t\t\t\t\tPromptTokens:     event.Message.Usage.InputTokens,\n\t\t\t\t\t\t\tCompletionTokens: event.Message.Usage.OutputTokens,\n\t\t\t\t\t\t\tTotalTokens:      event.Message.Usage.InputTokens + event.Message.Usage.OutputTokens,\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\trtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk}\n\t\t\t\t}\n\n\t\t\tdefault:\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"unknown Anthropic event type: %s\", sse.Event))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn rtn\n}\n"
  },
  {
    "path": "pkg/waveai/cloudbackend.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcloud\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype WaveAICloudBackend struct{}\n\nvar _ AIBackend = WaveAICloudBackend{}\n\nconst CloudWebsocketConnectTimeout = 1 * time.Minute\nconst OpenAICloudReqStr = \"openai-cloudreq\"\nconst PacketEOFStr = \"EOF\"\n\ntype WaveAICloudReqPacketType struct {\n\tType       string                           `json:\"type\"`\n\tClientId   string                           `json:\"clientid\"`\n\tPrompt     []wshrpc.WaveAIPromptMessageType `json:\"prompt\"`\n\tMaxTokens  int                              `json:\"maxtokens,omitempty\"`\n\tMaxChoices int                              `json:\"maxchoices,omitempty\"`\n}\n\nfunc MakeWaveAICloudReqPacket() *WaveAICloudReqPacketType {\n\treturn &WaveAICloudReqPacketType{\n\t\tType: OpenAICloudReqStr,\n\t}\n}\n\nfunc (WaveAICloudBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] {\n\trtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType])\n\twsEndpoint := wcloud.GetWSEndpoint()\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanicErr := panichandler.PanicHandler(\"WaveAICloudBackend.StreamCompletion\", recover())\n\t\t\tif panicErr != nil {\n\t\t\t\trtn <- makeAIError(panicErr)\n\t\t\t}\n\t\t\tclose(rtn)\n\t\t}()\n\t\tif wsEndpoint == \"\" {\n\t\t\trtn <- makeAIError(fmt.Errorf(\"no cloud ws endpoint found\"))\n\t\t\treturn\n\t\t}\n\t\tif request.Opts == nil {\n\t\t\trtn <- makeAIError(fmt.Errorf(\"no openai opts found\"))\n\t\t\treturn\n\t\t}\n\t\twebsocketContext, dialCancelFn := context.WithTimeout(context.Background(), CloudWebsocketConnectTimeout)\n\t\tdefer dialCancelFn()\n\t\tconn, _, err := websocket.DefaultDialer.DialContext(websocketContext, wsEndpoint, nil)\n\t\tif err == context.DeadlineExceeded {\n\t\t\trtn <- makeAIError(fmt.Errorf(\"OpenAI request, timed out connecting to cloud server: %v\", err))\n\t\t\treturn\n\t\t} else if err != nil {\n\t\t\trtn <- makeAIError(fmt.Errorf(\"OpenAI request, websocket connect error: %v\", err))\n\t\t\treturn\n\t\t}\n\t\tdefer func() {\n\t\t\terr = conn.Close()\n\t\t\tif err != nil {\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"unable to close openai channel: %v\", err))\n\t\t\t}\n\t\t}()\n\t\tvar sendablePromptMsgs []wshrpc.WaveAIPromptMessageType\n\t\tfor _, promptMsg := range request.Prompt {\n\t\t\tif promptMsg.Role == \"error\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsendablePromptMsgs = append(sendablePromptMsgs, promptMsg)\n\t\t}\n\t\treqPk := MakeWaveAICloudReqPacket()\n\t\treqPk.ClientId = request.ClientId\n\t\treqPk.Prompt = sendablePromptMsgs\n\t\treqPk.MaxTokens = request.Opts.MaxTokens\n\t\treqPk.MaxChoices = request.Opts.MaxChoices\n\t\tconfigMessageBuf, err := json.Marshal(reqPk)\n\t\tif err != nil {\n\t\t\trtn <- makeAIError(fmt.Errorf(\"OpenAI request, packet marshal error: %v\", err))\n\t\t\treturn\n\t\t}\n\t\terr = conn.WriteMessage(websocket.TextMessage, configMessageBuf)\n\t\tif err != nil {\n\t\t\trtn <- makeAIError(fmt.Errorf(\"OpenAI request, websocket write config error: %v\", err))\n\t\t\treturn\n\t\t}\n\t\tfor {\n\t\t\t_, socketMessage, err := conn.ReadMessage()\n\t\t\tif err == io.EOF {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"err received: %v\", err)\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"OpenAI request, websocket error reading message: %v\", err))\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tvar streamResp *wshrpc.WaveAIPacketType\n\t\t\terr = json.Unmarshal(socketMessage, &streamResp)\n\t\t\tif err != nil {\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"OpenAI request, websocket response json decode error: %v\", err))\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif streamResp.Error == PacketEOFStr {\n\t\t\t\t// got eof packet from socket\n\t\t\t\tbreak\n\t\t\t} else if streamResp.Error != \"\" {\n\t\t\t\t// use error from server directly\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"%v\", streamResp.Error))\n\t\t\t\tbreak\n\t\t\t}\n\t\t\trtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *streamResp}\n\t\t}\n\t}()\n\treturn rtn\n}\n"
  },
  {
    "path": "pkg/waveai/googlebackend.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveai\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.com/google/generative-ai-go/genai\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"google.golang.org/api/iterator\"\n\t\"google.golang.org/api/option\"\n)\n\ntype GoogleBackend struct{}\n\nvar _ AIBackend = GoogleBackend{}\n\nfunc (GoogleBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] {\n\tvar clientOptions []option.ClientOption\n\tclientOptions = append(clientOptions, option.WithAPIKey(request.Opts.APIToken))\n\n\t// Configure proxy if specified\n\tif request.Opts.ProxyURL != \"\" {\n\t\tproxyURL, err := url.Parse(request.Opts.ProxyURL)\n\t\tif err != nil {\n\t\t\trtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType])\n\t\t\tgo func() {\n\t\t\t\tdefer close(rtn)\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"invalid proxy URL: %v\", err))\n\t\t\t}()\n\t\t\treturn rtn\n\t\t}\n\t\ttransport := &http.Transport{\n\t\t\tProxy: http.ProxyURL(proxyURL),\n\t\t}\n\t\thttpClient := &http.Client{\n\t\t\tTransport: transport,\n\t\t}\n\t\tclientOptions = append(clientOptions, option.WithHTTPClient(httpClient))\n\t}\n\n\tclient, err := genai.NewClient(ctx, clientOptions...)\n\tif err != nil {\n\t\tlog.Printf(\"failed to create client: %v\", err)\n\t\treturn nil\n\t}\n\n\tmodel := client.GenerativeModel(request.Opts.Model)\n\tif model == nil {\n\t\tlog.Println(\"model not found\")\n\t\tclient.Close()\n\t\treturn nil\n\t}\n\n\tcs := model.StartChat()\n\tcs.History = extractHistory(request.Prompt)\n\titer := cs.SendMessageStream(ctx, extractPrompt(request.Prompt))\n\n\trtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType])\n\n\tgo func() {\n\t\tdefer client.Close()\n\t\tdefer close(rtn)\n\t\tfor {\n\t\t\t// Check for context cancellation\n\t\t\tif err := ctx.Err(); err != nil {\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"request cancelled: %v\", err))\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tresp, err := iter.Next()\n\t\t\tif err == iterator.Done {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"Google API error: %v\", err))\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\trtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: wshrpc.WaveAIPacketType{Text: convertCandidatesToText(resp.Candidates)}}\n\t\t}\n\t}()\n\treturn rtn\n}\n\nfunc extractHistory(history []wshrpc.WaveAIPromptMessageType) []*genai.Content {\n\tvar rtn []*genai.Content\n\tfor _, h := range history[:len(history)-1] {\n\t\tif h.Role == \"user\" || h.Role == \"model\" {\n\t\t\trtn = append(rtn, &genai.Content{\n\t\t\t\tRole:  h.Role,\n\t\t\t\tParts: []genai.Part{genai.Text(h.Content)},\n\t\t\t})\n\t\t}\n\t}\n\treturn rtn\n}\n\nfunc extractPrompt(prompt []wshrpc.WaveAIPromptMessageType) genai.Part {\n\tp := prompt[len(prompt)-1]\n\treturn genai.Text(p.Content)\n}\n\nfunc convertCandidatesToText(candidates []*genai.Candidate) string {\n\tvar rtn string\n\tfor _, c := range candidates {\n\t\tfor _, p := range c.Content.Parts {\n\t\t\trtn += fmt.Sprintf(\"%v\", p)\n\t\t}\n\t}\n\treturn rtn\n}\n"
  },
  {
    "path": "pkg/waveai/openaibackend.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveai\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\n\topenaiapi \"github.com/sashabaranov/go-openai\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype OpenAIBackend struct{}\n\nvar _ AIBackend = OpenAIBackend{}\n\nconst DefaultAzureAPIVersion = \"2023-05-15\"\n\n// copied from go-openai/config.go\nfunc defaultAzureMapperFn(model string) string {\n\treturn regexp.MustCompile(`[.:]`).ReplaceAllString(model, \"\")\n}\n\nfunc isReasoningModel(model string) bool {\n\tm := strings.ToLower(model)\n\treturn strings.HasPrefix(m, \"o1\") ||\n\t\tstrings.HasPrefix(m, \"o3\") ||\n\t\tstrings.HasPrefix(m, \"o4\") ||\n\t\tstrings.HasPrefix(m, \"gpt-5\") ||\n\t\tstrings.HasPrefix(m, \"gpt-5.1\")\n}\n\nfunc setApiType(opts *wshrpc.WaveAIOptsType, clientConfig *openaiapi.ClientConfig) error {\n\tourApiType := strings.ToLower(opts.APIType)\n\tif ourApiType == \"\" || ourApiType == APIType_OpenAI || ourApiType == strings.ToLower(string(openaiapi.APITypeOpenAI)) {\n\t\tclientConfig.APIType = openaiapi.APITypeOpenAI\n\t\treturn nil\n\t} else if ourApiType == strings.ToLower(string(openaiapi.APITypeAzure)) {\n\t\tclientConfig.APIType = openaiapi.APITypeAzure\n\t\tclientConfig.APIVersion = DefaultAzureAPIVersion\n\t\tclientConfig.AzureModelMapperFunc = defaultAzureMapperFn\n\t\treturn nil\n\t} else if ourApiType == strings.ToLower(string(openaiapi.APITypeAzureAD)) {\n\t\tclientConfig.APIType = openaiapi.APITypeAzureAD\n\t\tclientConfig.APIVersion = DefaultAzureAPIVersion\n\t\tclientConfig.AzureModelMapperFunc = defaultAzureMapperFn\n\t\treturn nil\n\t} else if ourApiType == strings.ToLower(string(openaiapi.APITypeCloudflareAzure)) {\n\t\tclientConfig.APIType = openaiapi.APITypeCloudflareAzure\n\t\tclientConfig.APIVersion = DefaultAzureAPIVersion\n\t\tclientConfig.AzureModelMapperFunc = defaultAzureMapperFn\n\t\treturn nil\n\t} else {\n\t\treturn fmt.Errorf(\"invalid api type %q\", opts.APIType)\n\t}\n}\n\nfunc convertPrompt(prompt []wshrpc.WaveAIPromptMessageType) []openaiapi.ChatCompletionMessage {\n\tvar rtn []openaiapi.ChatCompletionMessage\n\tfor _, p := range prompt {\n\t\tmsg := openaiapi.ChatCompletionMessage{Role: p.Role, Content: p.Content, Name: p.Name}\n\t\trtn = append(rtn, msg)\n\t}\n\treturn rtn\n}\n\nfunc (OpenAIBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] {\n\trtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType])\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanicErr := panichandler.PanicHandler(\"OpenAIBackend.StreamCompletion\", recover())\n\t\t\tif panicErr != nil {\n\t\t\t\trtn <- makeAIError(panicErr)\n\t\t\t}\n\t\t\tclose(rtn)\n\t\t}()\n\t\tif request.Opts == nil {\n\t\t\trtn <- makeAIError(errors.New(\"no openai opts found\"))\n\t\t\treturn\n\t\t}\n\t\tif request.Opts.Model == \"\" {\n\t\t\trtn <- makeAIError(errors.New(\"no openai model specified\"))\n\t\t\treturn\n\t\t}\n\t\tif request.Opts.BaseURL == \"\" && request.Opts.APIToken == \"\" {\n\t\t\trtn <- makeAIError(errors.New(\"no api token\"))\n\t\t\treturn\n\t\t}\n\n\t\tclientConfig := openaiapi.DefaultConfig(request.Opts.APIToken)\n\t\tif request.Opts.BaseURL != \"\" {\n\t\t\tclientConfig.BaseURL = request.Opts.BaseURL\n\t\t}\n\t\terr := setApiType(request.Opts, &clientConfig)\n\t\tif err != nil {\n\t\t\trtn <- makeAIError(err)\n\t\t\treturn\n\t\t}\n\t\tif request.Opts.OrgID != \"\" {\n\t\t\tclientConfig.OrgID = request.Opts.OrgID\n\t\t}\n\t\tif request.Opts.APIVersion != \"\" {\n\t\t\tclientConfig.APIVersion = request.Opts.APIVersion\n\t\t}\n\n\t\t// Configure proxy if specified\n\t\tif request.Opts.ProxyURL != \"\" {\n\t\t\tproxyURL, err := url.Parse(request.Opts.ProxyURL)\n\t\t\tif err != nil {\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"invalid proxy URL: %v\", err))\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttransport := &http.Transport{\n\t\t\t\tProxy: http.ProxyURL(proxyURL),\n\t\t\t}\n\t\t\tclientConfig.HTTPClient = &http.Client{\n\t\t\t\tTransport: transport,\n\t\t\t}\n\t\t}\n\n\t\tclient := openaiapi.NewClientWithConfig(clientConfig)\n\t\treq := openaiapi.ChatCompletionRequest{\n\t\t\tModel:    request.Opts.Model,\n\t\t\tMessages: convertPrompt(request.Prompt),\n\t\t}\n\n\t\t// Set MaxCompletionTokens for reasoning models, MaxTokens for others\n\t\tif isReasoningModel(request.Opts.Model) {\n\t\t\treq.MaxCompletionTokens = request.Opts.MaxTokens\n\t\t} else {\n\t\t\treq.MaxTokens = request.Opts.MaxTokens\n\t\t}\n\n\t\treq.Stream = true\n\t\tif request.Opts.MaxChoices > 1 {\n\t\t\treq.N = request.Opts.MaxChoices\n\t\t}\n\n\t\tapiResp, err := client.CreateChatCompletionStream(ctx, req)\n\t\tif err != nil {\n\t\t\trtn <- makeAIError(fmt.Errorf(\"error calling openai API: %v\", err))\n\t\t\treturn\n\t\t}\n\t\tsentHeader := false\n\t\tfor {\n\t\t\tstreamResp, err := apiResp.Recv()\n\t\t\tif err == io.EOF {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"OpenAI request, error reading message: %v\", err))\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif streamResp.Model != \"\" && !sentHeader {\n\t\t\t\tpk := MakeWaveAIPacket()\n\t\t\t\tpk.Model = streamResp.Model\n\t\t\t\tpk.Created = streamResp.Created\n\t\t\t\trtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk}\n\t\t\t\tsentHeader = true\n\t\t\t}\n\t\t\tfor _, choice := range streamResp.Choices {\n\t\t\t\tpk := MakeWaveAIPacket()\n\t\t\t\tpk.Index = choice.Index\n\t\t\t\tpk.Text = choice.Delta.Content\n\t\t\t\tpk.FinishReason = string(choice.FinishReason)\n\t\t\t\trtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk}\n\t\t\t}\n\t\t}\n\t}()\n\treturn rtn\n}\n"
  },
  {
    "path": "pkg/waveai/perplexitybackend.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveai\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype PerplexityBackend struct{}\n\nvar _ AIBackend = PerplexityBackend{}\n\n// Perplexity API request types\ntype perplexityMessage struct {\n\tRole    string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype perplexityRequest struct {\n\tModel    string              `json:\"model\"`\n\tMessages []perplexityMessage `json:\"messages\"`\n\tStream   bool                `json:\"stream\"`\n}\n\n// Perplexity API response types\ntype perplexityResponseDelta struct {\n\tContent string `json:\"content\"`\n}\n\ntype perplexityResponseChoice struct {\n\tDelta        perplexityResponseDelta `json:\"delta\"`\n\tFinishReason string                  `json:\"finish_reason\"`\n}\n\ntype perplexityResponse struct {\n\tID      string                     `json:\"id\"`\n\tChoices []perplexityResponseChoice `json:\"choices\"`\n\tModel   string                     `json:\"model\"`\n}\n\nfunc (PerplexityBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] {\n\trtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType])\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanicErr := panichandler.PanicHandler(\"PerplexityBackend.StreamCompletion\", recover())\n\t\t\tif panicErr != nil {\n\t\t\t\trtn <- makeAIError(panicErr)\n\t\t\t}\n\t\t\tclose(rtn)\n\t\t}()\n\n\t\tif request.Opts == nil {\n\t\t\trtn <- makeAIError(errors.New(\"no perplexity opts found\"))\n\t\t\treturn\n\t\t}\n\n\t\tmodel := request.Opts.Model\n\t\tif model == \"\" {\n\t\t\tmodel = \"llama-3.1-sonar-small-128k-online\"\n\t\t}\n\n\t\t// Convert messages format\n\t\tvar messages []perplexityMessage\n\t\tfor _, msg := range request.Prompt {\n\t\t\trole := \"user\"\n\t\t\tif msg.Role == \"assistant\" {\n\t\t\t\trole = \"assistant\"\n\t\t\t} else if msg.Role == \"system\" {\n\t\t\t\trole = \"system\"\n\t\t\t}\n\n\t\t\tmessages = append(messages, perplexityMessage{\n\t\t\t\tRole:    role,\n\t\t\t\tContent: msg.Content,\n\t\t\t})\n\t\t}\n\n\t\tperplexityReq := perplexityRequest{\n\t\t\tModel:    model,\n\t\t\tMessages: messages,\n\t\t\tStream:   true,\n\t\t}\n\n\t\treqBody, err := json.Marshal(perplexityReq)\n\t\tif err != nil {\n\t\t\trtn <- makeAIError(fmt.Errorf(\"failed to marshal perplexity request: %v\", err))\n\t\t\treturn\n\t\t}\n\n\t\treq, err := http.NewRequestWithContext(ctx, \"POST\", \"https://api.perplexity.ai/chat/completions\", strings.NewReader(string(reqBody)))\n\t\tif err != nil {\n\t\t\trtn <- makeAIError(fmt.Errorf(\"failed to create perplexity request: %v\", err))\n\t\t\treturn\n\t\t}\n\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+request.Opts.APIToken)\n\n\t\t// Configure HTTP client with proxy if specified\n\t\tclient := &http.Client{}\n\t\tif request.Opts.ProxyURL != \"\" {\n\t\t\tproxyURL, err := url.Parse(request.Opts.ProxyURL)\n\t\t\tif err != nil {\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"invalid proxy URL: %v\", err))\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttransport := &http.Transport{\n\t\t\t\tProxy: http.ProxyURL(proxyURL),\n\t\t\t}\n\t\t\tclient.Transport = transport\n\t\t}\n\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\trtn <- makeAIError(fmt.Errorf(\"failed to send perplexity request: %v\", err))\n\t\t\treturn\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\t\trtn <- makeAIError(fmt.Errorf(\"Perplexity API error: %s - %s\", resp.Status, string(bodyBytes)))\n\t\t\treturn\n\t\t}\n\n\t\treader := bufio.NewReader(resp.Body)\n\t\tsentHeader := false\n\n\t\tfor {\n\t\t\t// Check for context cancellation\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"request cancelled: %v\", ctx.Err()))\n\t\t\t\treturn\n\t\t\tdefault:\n\t\t\t}\n\n\t\t\tline, err := reader.ReadString('\\n')\n\t\t\tif err == io.EOF {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"error reading stream: %v\", err))\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tline = strings.TrimSpace(line)\n\t\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tdata := strings.TrimPrefix(line, \"data: \")\n\t\t\tif data == \"[DONE]\" {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tvar response perplexityResponse\n\t\t\tif err := json.Unmarshal([]byte(data), &response); err != nil {\n\t\t\t\trtn <- makeAIError(fmt.Errorf(\"error unmarshaling response: %v\", err))\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tif !sentHeader {\n\t\t\t\tpk := MakeWaveAIPacket()\n\t\t\t\tpk.Model = response.Model\n\t\t\t\trtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk}\n\t\t\t\tsentHeader = true\n\t\t\t}\n\n\t\t\tfor _, choice := range response.Choices {\n\t\t\t\tpk := MakeWaveAIPacket()\n\t\t\t\tpk.Text = choice.Delta.Content\n\t\t\t\tpk.FinishReason = choice.FinishReason\n\t\t\t\trtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk}\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn rtn\n}\n"
  },
  {
    "path": "pkg/waveai/waveai.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveai\n\nimport (\n\t\"context\"\n\t\"log\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nconst WaveAIPacketstr = \"waveai\"\nconst APIType_Anthropic = \"anthropic\"\nconst APIType_Perplexity = \"perplexity\"\nconst APIType_Google = \"google\"\nconst APIType_OpenAI = \"openai\"\n\ntype WaveAICmdInfoPacketOutputType struct {\n\tModel        string `json:\"model,omitempty\"`\n\tCreated      int64  `json:\"created,omitempty\"`\n\tFinishReason string `json:\"finish_reason,omitempty\"`\n\tMessage      string `json:\"message,omitempty\"`\n\tError        string `json:\"error,omitempty\"`\n}\n\nfunc MakeWaveAIPacket() *wshrpc.WaveAIPacketType {\n\treturn &wshrpc.WaveAIPacketType{Type: WaveAIPacketstr}\n}\n\ntype WaveAICmdInfoChatMessage struct {\n\tMessageID           int                            `json:\"messageid\"`\n\tIsAssistantResponse bool                           `json:\"isassistantresponse,omitempty\"`\n\tAssistantResponse   *WaveAICmdInfoPacketOutputType `json:\"assistantresponse,omitempty\"`\n\tUserQuery           string                         `json:\"userquery,omitempty\"`\n\tUserEngineeredQuery string                         `json:\"userengineeredquery,omitempty\"`\n}\n\ntype AIBackend interface {\n\tStreamCompletion(\n\t\tctx context.Context,\n\t\trequest wshrpc.WaveAIStreamRequest,\n\t) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]\n}\n\nfunc IsCloudAIRequest(opts *wshrpc.WaveAIOptsType) bool {\n\tif opts == nil {\n\t\treturn true\n\t}\n\treturn opts.BaseURL == \"\" && opts.APIToken == \"\"\n}\n\nfunc isLocalURL(baseURL string) bool {\n\tif baseURL == \"\" {\n\t\treturn false\n\t}\n\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"localhost\" || host == \"127.0.0.1\" || host == \"0.0.0.0\" || strings.HasPrefix(host, \"192.168.\") || strings.HasPrefix(host, \"10.\") || (strings.HasPrefix(host, \"172.\") && len(host) > 4)\n}\n\nfunc makeAIError(err error) wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] {\n\treturn wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Error: err}\n}\n\nfunc RunAICommand(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] {\n\ttelemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NumAIReqs: 1}, \"RunAICommand\")\n\n\tendpoint := request.Opts.BaseURL\n\tif endpoint == \"\" {\n\t\tendpoint = \"default\"\n\t}\n\tvar backend AIBackend\n\tvar backendType string\n\tif request.Opts.APIType == APIType_Anthropic {\n\t\tbackend = AnthropicBackend{}\n\t\tbackendType = APIType_Anthropic\n\t} else if request.Opts.APIType == APIType_Perplexity {\n\t\tbackend = PerplexityBackend{}\n\t\tbackendType = APIType_Perplexity\n\t} else if request.Opts.APIType == APIType_Google {\n\t\tbackend = GoogleBackend{}\n\t\tbackendType = APIType_Google\n\t} else if IsCloudAIRequest(request.Opts) {\n\t\tendpoint = \"waveterm cloud\"\n\t\trequest.Opts.APIType = APIType_OpenAI\n\t\trequest.Opts.Model = \"default\"\n\t\tbackend = WaveAICloudBackend{}\n\t\tbackendType = \"wave\"\n\t} else {\n\t\tbackend = OpenAIBackend{}\n\t\tbackendType = APIType_OpenAI\n\t}\n\tif backend == nil {\n\t\tlog.Printf(\"no backend found for %s\\n\", request.Opts.APIType)\n\t\treturn nil\n\t}\n\taiLocal := backendType != \"wave\" && isLocalURL(request.Opts.BaseURL)\n\ttelemetry.GoRecordTEventWrap(&telemetrydata.TEvent{\n\t\tEvent: \"action:runaicmd\",\n\t\tProps: telemetrydata.TEventProps{\n\t\t\tAiBackendType: backendType,\n\t\t\tAiLocal:       aiLocal,\n\t\t},\n\t})\n\n\tlog.Printf(\"sending ai chat message to %s endpoint %q using model %s\\n\", request.Opts.APIType, endpoint, request.Opts.Model)\n\treturn backend.StreamCompletion(ctx, request)\n}\n"
  },
  {
    "path": "pkg/waveapp/streamingresp.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveapp\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nconst maxChunkSize = 64 * 1024 // 64KB maximum chunk size\n\n// StreamingResponseWriter implements http.ResponseWriter interface to stream response\n// data through a channel rather than buffering it in memory. This is particularly\n// useful for handling large responses like video streams or file downloads.\ntype StreamingResponseWriter struct {\n\theader     http.Header\n\tstatusCode int\n\trespChan   chan<- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]\n\theaderSent bool\n\tbuffer     *bytes.Buffer\n}\n\nfunc NewStreamingResponseWriter(respChan chan<- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]) *StreamingResponseWriter {\n\treturn &StreamingResponseWriter{\n\t\theader:     make(http.Header),\n\t\tstatusCode: http.StatusOK,\n\t\trespChan:   respChan,\n\t\theaderSent: false,\n\t\tbuffer:     bytes.NewBuffer(make([]byte, 0, maxChunkSize)),\n\t}\n}\n\nfunc (w *StreamingResponseWriter) Header() http.Header {\n\treturn w.header\n}\n\nfunc (w *StreamingResponseWriter) WriteHeader(statusCode int) {\n\tif w.headerSent {\n\t\treturn\n\t}\n\n\tw.statusCode = statusCode\n\tw.headerSent = true\n\n\theaders := make(map[string]string)\n\tfor key, values := range w.header {\n\t\tif len(values) > 0 {\n\t\t\theaders[key] = values[0]\n\t\t}\n\t}\n\n\tw.respChan <- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]{\n\t\tResponse: wshrpc.VDomUrlRequestResponse{\n\t\t\tStatusCode: w.statusCode,\n\t\t\tHeaders:    headers,\n\t\t},\n\t}\n}\n\n// sendChunk sends a single chunk of exactly maxChunkSize (or less)\nfunc (w *StreamingResponseWriter) sendChunk(data []byte) {\n\tif len(data) == 0 {\n\t\treturn\n\t}\n\tchunk := make([]byte, len(data))\n\tcopy(chunk, data)\n\tw.respChan <- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]{\n\t\tResponse: wshrpc.VDomUrlRequestResponse{\n\t\t\tBody: chunk,\n\t\t},\n\t}\n}\n\nfunc (w *StreamingResponseWriter) Write(data []byte) (int, error) {\n\tif !w.headerSent {\n\t\tw.WriteHeader(http.StatusOK)\n\t}\n\n\toriginalLen := len(data)\n\n\t// If we already have data in the buffer\n\tif w.buffer.Len() > 0 {\n\t\t// Fill the buffer up to maxChunkSize\n\t\tspaceInBuffer := maxChunkSize - w.buffer.Len()\n\t\tif spaceInBuffer > 0 {\n\t\t\t// How much of the new data can fit in the buffer\n\t\t\ttoBuffer := spaceInBuffer\n\t\t\tif toBuffer > len(data) {\n\t\t\t\ttoBuffer = len(data)\n\t\t\t}\n\t\t\tw.buffer.Write(data[:toBuffer])\n\t\t\tdata = data[toBuffer:] // Advance data slice\n\t\t}\n\n\t\t// If buffer is full, send it\n\t\tif w.buffer.Len() == maxChunkSize {\n\t\t\tw.sendChunk(w.buffer.Bytes())\n\t\t\tw.buffer.Reset()\n\t\t}\n\t}\n\n\t// Send any full chunks from data\n\tfor len(data) >= maxChunkSize {\n\t\tw.sendChunk(data[:maxChunkSize])\n\t\tdata = data[maxChunkSize:]\n\t}\n\n\t// Buffer any remaining data\n\tif len(data) > 0 {\n\t\tw.buffer.Write(data)\n\t}\n\n\treturn originalLen, nil\n}\n\nfunc (w *StreamingResponseWriter) Close() error {\n\tif !w.headerSent {\n\t\tw.WriteHeader(http.StatusOK)\n\t}\n\n\tif w.buffer.Len() > 0 {\n\t\tw.sendChunk(w.buffer.Bytes())\n\t\tw.buffer.Reset()\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/waveapp/waveapp.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveapp\n\nimport (\n\t\"context\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\t\"unicode\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/gorilla/mux\"\n\t\"github.com/wavetermdev/waveterm/pkg/vdom\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\ntype AppOpts struct {\n\tCloseOnCtrlC         bool\n\tGlobalKeyboardEvents bool\n\tGlobalStyles         []byte\n\tRootComponentName    string // defaults to \"App\"\n\tNewBlockFlag         string // defaults to \"n\" (set to \"-\" to disable)\n\tTargetNewBlock       bool\n\tTargetToolbar        *vdom.VDomTargetToolbar\n}\n\ntype Client struct {\n\tLock               *sync.Mutex\n\tAppOpts            AppOpts\n\tRoot               *vdom.RootElem\n\tRootElem           *vdom.VDomElem\n\tRpcClient          *wshutil.WshRpc\n\tRpcContext         *wshrpc.RpcContext\n\tServerImpl         *WaveAppServerImpl\n\tIsDone             bool\n\tRouteId            string\n\tVDomContextBlockId string\n\tDoneReason         string\n\tDoneCh             chan struct{}\n\tOpts               vdom.VDomBackendOpts\n\tGlobalEventHandler func(client *Client, event vdom.VDomEvent)\n\tGlobalStylesOption *FileHandlerOption\n\tUrlHandlerMux      *mux.Router\n\tOverrideUrlHandler http.Handler\n\tNewBlockFlag       bool\n\tSetupFn            func()\n}\n\nfunc (c *Client) GetIsDone() bool {\n\tc.Lock.Lock()\n\tdefer c.Lock.Unlock()\n\treturn c.IsDone\n}\n\nfunc (c *Client) doShutdown(reason string) {\n\tc.Lock.Lock()\n\tdefer c.Lock.Unlock()\n\tif c.IsDone {\n\t\treturn\n\t}\n\tc.DoneReason = reason\n\tc.IsDone = true\n\tclose(c.DoneCh)\n}\n\nfunc (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) {\n\tc.GlobalEventHandler = handler\n}\n\nfunc (c *Client) SetOverrideUrlHandler(handler http.Handler) {\n\tc.OverrideUrlHandler = handler\n}\n\nfunc MakeClient(appOpts AppOpts) *Client {\n\tif appOpts.RootComponentName == \"\" {\n\t\tappOpts.RootComponentName = \"App\"\n\t}\n\tif appOpts.NewBlockFlag == \"\" {\n\t\tappOpts.NewBlockFlag = \"n\"\n\t}\n\tclient := &Client{\n\t\tLock:          &sync.Mutex{},\n\t\tAppOpts:       appOpts,\n\t\tRoot:          vdom.MakeRoot(),\n\t\tDoneCh:        make(chan struct{}),\n\t\tUrlHandlerMux: mux.NewRouter(),\n\t\tOpts: vdom.VDomBackendOpts{\n\t\t\tCloseOnCtrlC:         appOpts.CloseOnCtrlC,\n\t\t\tGlobalKeyboardEvents: appOpts.GlobalKeyboardEvents,\n\t\t},\n\t}\n\tif len(appOpts.GlobalStyles) > 0 {\n\t\tclient.Opts.GlobalStyles = true\n\t\tclient.GlobalStylesOption = &FileHandlerOption{Data: appOpts.GlobalStyles, MimeType: \"text/css\"}\n\t}\n\tclient.SetRootElem(vdom.E(appOpts.RootComponentName))\n\treturn client\n}\n\nfunc (client *Client) runMainE() error {\n\tif client.SetupFn != nil {\n\t\tclient.SetupFn()\n\t}\n\terr := client.Connect()\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := &vdom.VDomTarget{}\n\tif client.AppOpts.TargetNewBlock || client.NewBlockFlag {\n\t\ttarget.NewBlock = client.NewBlockFlag\n\t}\n\tif client.AppOpts.TargetToolbar != nil {\n\t\ttarget.Toolbar = client.AppOpts.TargetToolbar\n\t}\n\tif target.NewBlock && target.Toolbar != nil {\n\t\treturn fmt.Errorf(\"cannot specify both new block and toolbar target\")\n\t}\n\terr = client.CreateVDomContext(target)\n\tif err != nil {\n\t\treturn err\n\t}\n\t<-client.DoneCh\n\treturn nil\n}\n\nfunc (client *Client) AddSetupFn(fn func()) {\n\tclient.SetupFn = fn\n}\n\nfunc (client *Client) RegisterDefaultFlags() {\n\tif client.AppOpts.NewBlockFlag != \"-\" {\n\t\tflag.BoolVar(&client.NewBlockFlag, client.AppOpts.NewBlockFlag, false, \"new block\")\n\t}\n}\n\nfunc (client *Client) RunMain() {\n\tif !flag.Parsed() {\n\t\tclient.RegisterDefaultFlags()\n\t\tflag.Parse()\n\t}\n\terr := client.runMainE()\n\tif err != nil {\n\t\tfmt.Println(err)\n\t\tos.Exit(1)\n\t}\n}\n\nfunc (client *Client) Connect() error {\n\tjwtToken := os.Getenv(wshutil.WaveJwtTokenVarName)\n\tif jwtToken == \"\" {\n\t\treturn fmt.Errorf(\"no %s env var set\", wshutil.WaveJwtTokenVarName)\n\t}\n\trpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error extracting rpc context from %s: %v\", wshutil.WaveJwtTokenVarName, err)\n\t}\n\tclient.RpcContext = rpcCtx\n\tif client.RpcContext == nil || client.RpcContext.BlockId == \"\" {\n\t\treturn fmt.Errorf(\"no block id in rpc context\")\n\t}\n\tclient.ServerImpl = &WaveAppServerImpl{BlockId: client.RpcContext.BlockId, Client: client}\n\tsockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error extracting socket name from %s: %v\", wshutil.WaveJwtTokenVarName, err)\n\t}\n\trpcClient, err := wshutil.SetupDomainSocketRpcClient(sockName, client.ServerImpl, \"vdomclient\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting up domain socket rpc client: %v\", err)\n\t}\n\tclient.RpcClient = rpcClient\n\tauthRtnData, err := wshclient.AuthenticateCommand(client.RpcClient, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRoute})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error authenticating rpc connection: %v\", err)\n\t}\n\tif authRtnData.RouteId == \"\" {\n\t\treturn fmt.Errorf(\"authentication returned empty routeid\")\n\t}\n\tclient.RouteId = authRtnData.RouteId\n\treturn nil\n}\n\nfunc (c *Client) SetRootElem(elem *vdom.VDomElem) {\n\tc.RootElem = elem\n}\n\nfunc (c *Client) CreateVDomContext(target *vdom.VDomTarget) error {\n\tblockORef, err := wshclient.VDomCreateContextCommand(\n\t\tc.RpcClient,\n\t\tvdom.VDomCreateContext{Target: target},\n\t\t&wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)},\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\tc.VDomContextBlockId = blockORef.OID\n\tlog.Printf(\"created vdom context: %v\\n\", blockORef)\n\tgotRoute, err := wshclient.WaitForRouteCommand(c.RpcClient, wshrpc.CommandWaitForRouteData{\n\t\tRouteId: wshutil.MakeFeBlockRouteId(blockORef.OID),\n\t\tWaitMs:  4000,\n\t}, &wshrpc.RpcOpts{Timeout: 5000})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error waiting for vdom context route: %v\", err)\n\t}\n\tif !gotRoute {\n\t\treturn fmt.Errorf(\"vdom context route could not be established\")\n\t}\n\twshclient.EventSubCommand(c.RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{\n\t\tblockORef.String(),\n\t}}, nil)\n\tc.RpcClient.EventListener.On(\"blockclose\", func(event *wps.WaveEvent) {\n\t\tc.doShutdown(\"got blockclose event\")\n\t})\n\treturn nil\n}\n\nfunc (c *Client) SendAsyncInitiation() error {\n\tif c.VDomContextBlockId == \"\" {\n\t\treturn fmt.Errorf(\"no vdom context block id\")\n\t}\n\tif c.GetIsDone() {\n\t\treturn fmt.Errorf(\"client is done\")\n\t}\n\treturn wshclient.VDomAsyncInitiationCommand(\n\t\tc.RpcClient,\n\t\tvdom.MakeAsyncInitiationRequest(c.RpcContext.BlockId),\n\t\t&wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.VDomContextBlockId)},\n\t)\n}\n\nfunc (c *Client) SetAtomVals(m map[string]any) {\n\tfor k, v := range m {\n\t\tc.Root.SetAtomVal(k, v, true)\n\t}\n}\n\nfunc (c *Client) SetAtomVal(name string, val any) {\n\tc.Root.SetAtomVal(name, val, true)\n}\n\nfunc (c *Client) GetAtomVal(name string) any {\n\treturn c.Root.GetAtomVal(name)\n}\n\nfunc makeNullVDom() *vdom.VDomElem {\n\treturn &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag}\n}\n\nfunc DefineComponent[P any](client *Client, name string, renderFn func(ctx context.Context, props P) any) vdom.Component[P] {\n\tif name == \"\" {\n\t\tpanic(\"Component name cannot be empty\")\n\t}\n\tif !unicode.IsUpper(rune(name[0])) {\n\t\tpanic(\"Component name must start with an uppercase letter\")\n\t}\n\terr := client.RegisterComponent(name, renderFn)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn func(props P) *vdom.VDomElem {\n\t\treturn vdom.E(name, vdom.Props(props))\n\t}\n}\n\nfunc (c *Client) RegisterComponent(name string, cfunc any) error {\n\treturn c.Root.RegisterComponent(name, cfunc)\n}\n\nfunc (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) {\n\tc.Root.RunWork()\n\tc.Root.Render(c.RootElem)\n\trenderedVDom := c.Root.MakeVDom()\n\tif renderedVDom == nil {\n\t\trenderedVDom = makeNullVDom()\n\t}\n\treturn &vdom.VDomBackendUpdate{\n\t\tType:    \"backendupdate\",\n\t\tTs:      time.Now().UnixMilli(),\n\t\tBlockId: c.RpcContext.BlockId,\n\t\tHasWork: len(c.Root.EffectWorkQueue) > 0,\n\t\tOpts:    &c.Opts,\n\t\tRenderUpdates: []vdom.VDomRenderUpdate{\n\t\t\t{UpdateType: \"root\", VDom: renderedVDom},\n\t\t},\n\t\tRefOperations: c.Root.GetRefOperations(),\n\t\tStateSync:     c.Root.GetStateSync(true),\n\t}, nil\n}\n\nfunc (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) {\n\tc.Root.RunWork()\n\trenderedVDom := c.Root.MakeVDom()\n\tif renderedVDom == nil {\n\t\trenderedVDom = makeNullVDom()\n\t}\n\treturn &vdom.VDomBackendUpdate{\n\t\tType:    \"backendupdate\",\n\t\tTs:      time.Now().UnixMilli(),\n\t\tBlockId: c.RpcContext.BlockId,\n\t\tRenderUpdates: []vdom.VDomRenderUpdate{\n\t\t\t{UpdateType: \"root\", VDom: renderedVDom},\n\t\t},\n\t\tRefOperations: c.Root.GetRefOperations(),\n\t\tStateSync:     c.Root.GetStateSync(false),\n\t}, nil\n}\n\nfunc (c *Client) RegisterUrlPathHandler(path string, handler http.Handler) {\n\tc.UrlHandlerMux.Handle(path, handler)\n}\n\ntype FileHandlerOption struct {\n\tFilePath string    // optional file path on disk\n\tData     []byte    // optional byte slice content\n\tReader   io.Reader // optional reader for content\n\tFile     fs.File   // optional embedded or opened file\n\tMimeType string    // optional mime type\n\tETag     string    // optional ETag (if set, resource may be cached)\n}\n\nfunc determineMimeType(option FileHandlerOption) (string, []byte) {\n\t// If MimeType is set, use it directly\n\tif option.MimeType != \"\" {\n\t\treturn option.MimeType, nil\n\t}\n\n\t// Detect from Data if available, no need to buffer\n\tif option.Data != nil {\n\t\treturn http.DetectContentType(option.Data), nil\n\t}\n\n\t// Detect from FilePath, no buffering necessary\n\tif option.FilePath != \"\" {\n\t\tfilePath := wavebase.ExpandHomeDirSafe(option.FilePath)\n\t\tfile, err := os.Open(filePath)\n\t\tif err != nil {\n\t\t\treturn \"application/octet-stream\", nil // Fallback on error\n\t\t}\n\t\tdefer file.Close()\n\n\t\t// Read first 512 bytes for MIME detection\n\t\tbuf := make([]byte, 512)\n\t\t_, err = file.Read(buf)\n\t\tif err != nil && err != io.EOF {\n\t\t\treturn \"application/octet-stream\", nil\n\t\t}\n\t\treturn http.DetectContentType(buf), nil\n\t}\n\n\t// Buffer for File (fs.File), since it lacks Seek\n\tif option.File != nil {\n\t\tbuf := make([]byte, 512)\n\t\tn, err := option.File.Read(buf)\n\t\tif err != nil && err != io.EOF {\n\t\t\treturn \"application/octet-stream\", nil\n\t\t}\n\t\treturn http.DetectContentType(buf[:n]), buf[:n]\n\t}\n\n\t// Buffer for Reader (io.Reader), same as File\n\tif option.Reader != nil {\n\t\tbuf := make([]byte, 512)\n\t\tn, err := option.Reader.Read(buf)\n\t\tif err != nil && err != io.EOF {\n\t\t\treturn \"application/octet-stream\", nil\n\t\t}\n\t\treturn http.DetectContentType(buf[:n]), buf[:n]\n\t}\n\n\t// Default MIME type if none specified\n\treturn \"application/octet-stream\", nil\n}\n\n// ServeFileOption handles serving content based on the provided FileHandlerOption\nfunc ServeFileOption(w http.ResponseWriter, r *http.Request, option FileHandlerOption) error {\n\t// Determine MIME type and get buffered data if needed\n\tcontentType, bufferedData := determineMimeType(option)\n\tw.Header().Set(\"Content-Type\", contentType)\n\t// Handle ETag\n\tif option.ETag != \"\" {\n\t\tw.Header().Set(\"ETag\", option.ETag)\n\n\t\t// Check If-None-Match header\n\t\tif inm := r.Header.Get(\"If-None-Match\"); inm != \"\" {\n\t\t\t// Strip W/ prefix and quotes if present\n\t\t\tinm = strings.Trim(inm, `\"`)\n\t\t\tinm = strings.TrimPrefix(inm, \"W/\")\n\t\t\tetag := strings.Trim(option.ETag, `\"`)\n\t\t\tetag = strings.TrimPrefix(etag, \"W/\")\n\n\t\t\tif inm == etag {\n\t\t\t\t// Resource not modified\n\t\t\t\tw.WriteHeader(http.StatusNotModified)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t}\n\n\t// Handle the content based on the option type\n\tswitch {\n\tcase option.FilePath != \"\":\n\t\tfilePath := wavebase.ExpandHomeDirSafe(option.FilePath)\n\t\thttp.ServeFile(w, r, filePath)\n\n\tcase option.Data != nil:\n\t\tw.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", len(option.Data)))\n\t\tw.WriteHeader(http.StatusOK)\n\t\tif _, err := w.Write(option.Data); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to write data: %v\", err)\n\t\t}\n\n\tcase option.File != nil:\n\t\tif bufferedData != nil {\n\t\t\tif _, err := w.Write(bufferedData); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to write buffered data: %v\", err)\n\t\t\t}\n\t\t}\n\t\tif _, err := io.Copy(w, option.File); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to copy from file: %v\", err)\n\t\t}\n\n\tcase option.Reader != nil:\n\t\tif bufferedData != nil {\n\t\t\tif _, err := w.Write(bufferedData); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to write buffered data: %v\", err)\n\t\t\t}\n\t\t}\n\t\tif _, err := io.Copy(w, option.Reader); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to copy from reader: %v\", err)\n\t\t}\n\n\tdefault:\n\t\treturn fmt.Errorf(\"no content available\")\n\t}\n\n\treturn nil\n}\n\nfunc (c *Client) RegisterFilePrefixHandler(prefix string, optionProvider func(path string) (*FileHandlerOption, error)) {\n\tc.UrlHandlerMux.PathPrefix(prefix).HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\toption, err := optionProvider(r.URL.Path)\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tif option == nil {\n\t\t\thttp.Error(w, \"no content available\", http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\t\tif err := ServeFileOption(w, r, *option); err != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"Failed to serve content: %v\", err), http.StatusInternalServerError)\n\t\t}\n\t})\n}\n\nfunc (c *Client) RegisterFileHandler(path string, option FileHandlerOption) {\n\tc.UrlHandlerMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := ServeFileOption(w, r, option); err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "pkg/waveapp/waveappserverimpl.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveapp\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/vdom\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype WaveAppServerImpl struct {\n\tClient  *Client\n\tBlockId string\n}\n\nfunc (*WaveAppServerImpl) WshServerImpl() {}\n\nfunc (impl *WaveAppServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) chan wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate] {\n\trespChan := make(chan wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate], 5)\n\tdefer func() {\n\t\tpanicErr := panichandler.PanicHandler(\"VDomRenderCommand\", recover())\n\t\tif panicErr != nil {\n\t\t\trespChan <- wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{\n\t\t\t\tError: panicErr,\n\t\t\t}\n\t\t\tclose(respChan)\n\t\t}\n\t}()\n\n\tif feUpdate.Dispose {\n\t\tdefer close(respChan)\n\t\tlog.Printf(\"got dispose from frontend\\n\")\n\t\timpl.Client.doShutdown(\"got dispose from frontend\")\n\t\treturn respChan\n\t}\n\n\tif impl.Client.GetIsDone() {\n\t\tclose(respChan)\n\t\treturn respChan\n\t}\n\n\timpl.Client.Root.RenderTs = feUpdate.Ts\n\n\t// set atoms\n\tfor _, ss := range feUpdate.StateSync {\n\t\timpl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false)\n\t}\n\t// run events\n\tfor _, event := range feUpdate.Events {\n\t\tif event.GlobalEventType != \"\" {\n\t\t\tif impl.Client.GlobalEventHandler != nil {\n\t\t\t\timpl.Client.GlobalEventHandler(impl.Client, event)\n\t\t\t}\n\t\t} else {\n\t\t\timpl.Client.Root.Event(event.WaveId, event.EventType, event)\n\t\t}\n\t}\n\t// update refs\n\tfor _, ref := range feUpdate.RefUpdates {\n\t\timpl.Client.Root.UpdateRef(ref)\n\t}\n\n\tvar update *vdom.VDomBackendUpdate\n\tvar err error\n\n\tif feUpdate.Resync || true {\n\t\tupdate, err = impl.Client.fullRender()\n\t} else {\n\t\tupdate, err = impl.Client.incrementalRender()\n\t}\n\tupdate.CreateTransferElems()\n\n\tif err != nil {\n\t\trespChan <- wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{\n\t\t\tError: err,\n\t\t}\n\t\tclose(respChan)\n\t\treturn respChan\n\t}\n\n\t// Split the update into chunks and send them sequentially\n\tupdates := vdom.SplitBackendUpdate(update)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"VDomRenderCommand:splitUpdates\", recover())\n\t\t}()\n\t\tdefer close(respChan)\n\t\tfor _, splitUpdate := range updates {\n\t\t\trespChan <- wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{\n\t\t\t\tResponse: splitUpdate,\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn respChan\n}\n\nfunc (impl *WaveAppServerImpl) VDomUrlRequestCommand(ctx context.Context, data wshrpc.VDomUrlRequestData) chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse] {\n\trespChan := make(chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse])\n\twriter := NewStreamingResponseWriter(respChan)\n\n\tgo func() {\n\t\tdefer close(respChan) // Declared first, so it executes last\n\t\tdefer writer.Close()  // Ensures writer is closed before the channel is closed\n\n\t\tdefer func() {\n\t\t\tpanicErr := panichandler.PanicHandler(\"VDomUrlRequestCommand\", recover())\n\t\t\tif panicErr != nil {\n\t\t\t\twriter.WriteHeader(http.StatusInternalServerError)\n\t\t\t\twriter.Write([]byte(fmt.Sprintf(\"internal server error: %v\", panicErr)))\n\t\t\t}\n\t\t}()\n\n\t\t// Create an HTTP request from the RPC request data\n\t\tvar bodyReader *bytes.Reader\n\t\tif data.Body != nil {\n\t\t\tbodyReader = bytes.NewReader(data.Body)\n\t\t} else {\n\t\t\tbodyReader = bytes.NewReader([]byte{})\n\t\t}\n\n\t\thttpReq, err := http.NewRequest(data.Method, data.URL, bodyReader)\n\t\tif err != nil {\n\t\t\twriter.WriteHeader(http.StatusInternalServerError)\n\t\t\twriter.Write([]byte(err.Error()))\n\t\t\treturn\n\t\t}\n\n\t\tfor key, value := range data.Headers {\n\t\t\thttpReq.Header.Set(key, value)\n\t\t}\n\t\tif httpReq.URL.Path == \"/wave/global.css\" && impl.Client.GlobalStylesOption != nil {\n\t\t\tServeFileOption(writer, httpReq, *impl.Client.GlobalStylesOption)\n\t\t\treturn\n\t\t}\n\t\tif impl.Client.OverrideUrlHandler != nil {\n\t\t\timpl.Client.OverrideUrlHandler.ServeHTTP(writer, httpReq)\n\t\t\treturn\n\t\t}\n\t\timpl.Client.UrlHandlerMux.ServeHTTP(writer, httpReq)\n\t}()\n\n\treturn respChan\n}\n"
  },
  {
    "path": "pkg/waveappstore/waveappstore.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveappstore\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/secretstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/fileutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveapputil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nconst (\n\tAppNSLocal = \"local\"\n\tAppNSDraft = \"draft\"\n\n\tMaxNamespaceLen = 30\n\tMaxAppNameLen   = 50\n\n\tManifestFileName       = \"manifest.json\"\n\tSecretBindingsFileName = \"secret-bindings.json\"\n)\n\nvar (\n\tnamespaceRegex = regexp.MustCompile(`^@?[a-z0-9-]+$`)\n\tappNameRegex   = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)\n)\n\ntype FileData struct {\n\tContents []byte\n\tModTs    int64\n}\n\nfunc MakeAppId(appNS string, appName string) string {\n\treturn appNS + \"/\" + appName\n}\n\nfunc ParseAppId(appId string) (appNS string, appName string, err error) {\n\tparts := strings.Split(appId, \"/\")\n\tif len(parts) != 2 {\n\t\treturn \"\", \"\", fmt.Errorf(\"invalid appId format: must be namespace/name\")\n\t}\n\tappNS = parts[0]\n\tappName = parts[1]\n\tif appNS == \"\" || appName == \"\" {\n\t\treturn \"\", \"\", fmt.Errorf(\"invalid appId: namespace and name cannot be empty\")\n\t}\n\treturn appNS, appName, nil\n}\n\nfunc ValidateAppId(appId string) error {\n\tappNS, appName, err := ParseAppId(appId)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(appNS) > MaxNamespaceLen {\n\t\treturn fmt.Errorf(\"namespace too long: max %d characters\", MaxNamespaceLen)\n\t}\n\tif len(appName) > MaxAppNameLen {\n\t\treturn fmt.Errorf(\"app name too long: max %d characters\", MaxAppNameLen)\n\t}\n\tif !namespaceRegex.MatchString(appNS) {\n\t\treturn fmt.Errorf(\"invalid namespace: must match pattern @?[a-z0-9-]+\")\n\t}\n\tif !appNameRegex.MatchString(appName) {\n\t\treturn fmt.Errorf(\"invalid app name: must match pattern [a-zA-Z0-9_-]+\")\n\t}\n\treturn nil\n}\n\nfunc GetAppDir(appId string) (string, error) {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn \"\", err\n\t}\n\tappNS, appName, _ := ParseAppId(appId)\n\thomeDir := wavebase.GetHomeDir()\n\treturn filepath.Join(homeDir, \"waveapps\", appNS, appName), nil\n}\n\nfunc copyDir(src, dst string) error {\n\tif err := os.RemoveAll(dst); err != nil && !os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"failed to remove existing directory: %w\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create parent directory: %w\", err)\n\t}\n\n\treturn filepath.Walk(src, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\trelPath, err := filepath.Rel(src, path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdstPath := filepath.Join(dst, relPath)\n\n\t\tif info.IsDir() {\n\t\t\treturn os.MkdirAll(dstPath, info.Mode())\n\t\t}\n\n\t\tdata, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn os.WriteFile(dstPath, data, info.Mode())\n\t})\n}\n\nfunc PublishDraft(draftAppId string) (string, error) {\n\tif err := ValidateAppId(draftAppId); err != nil {\n\t\treturn \"\", fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappNS, appName, _ := ParseAppId(draftAppId)\n\tif appNS != AppNSDraft {\n\t\treturn \"\", fmt.Errorf(\"appId must be in draft namespace, got: %s\", appNS)\n\t}\n\n\tdraftDir, err := GetAppDir(draftAppId)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif _, err := os.Stat(draftDir); os.IsNotExist(err) {\n\t\treturn \"\", fmt.Errorf(\"draft app does not exist: %s\", draftDir)\n\t}\n\n\tlocalAppId := MakeAppId(AppNSLocal, appName)\n\tlocalDir, err := GetAppDir(localAppId)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif err := copyDir(draftDir, localDir); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn localAppId, nil\n}\n\nfunc RevertDraft(draftAppId string) error {\n\tif err := ValidateAppId(draftAppId); err != nil {\n\t\treturn fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappNS, appName, _ := ParseAppId(draftAppId)\n\tif appNS != AppNSDraft {\n\t\treturn fmt.Errorf(\"appId must be in draft namespace, got: %s\", appNS)\n\t}\n\n\tdraftDir, err := GetAppDir(draftAppId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlocalAppId := MakeAppId(AppNSLocal, appName)\n\tlocalDir, err := GetAppDir(localAppId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif _, err := os.Stat(localDir); os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"local app does not exist: %s\", localDir)\n\t}\n\n\treturn copyDir(localDir, draftDir)\n}\n\nfunc MakeDraftFromLocal(localAppId string) (string, error) {\n\tif err := ValidateAppId(localAppId); err != nil {\n\t\treturn \"\", fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappNS, appName, _ := ParseAppId(localAppId)\n\tif appNS != AppNSLocal {\n\t\treturn \"\", fmt.Errorf(\"appId must be in local namespace, got: %s\", appNS)\n\t}\n\n\tlocalDir, err := GetAppDir(localAppId)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif _, err := os.Stat(localDir); os.IsNotExist(err) {\n\t\treturn \"\", fmt.Errorf(\"local app does not exist: %s\", localDir)\n\t}\n\n\tdraftAppId := MakeAppId(AppNSDraft, appName)\n\tdraftDir, err := GetAppDir(draftAppId)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif _, err := os.Stat(draftDir); err == nil {\n\t\t// draft already exists, don't overwrite (that's what RevertDraft is for)\n\t\treturn draftAppId, nil\n\t} else if !os.IsNotExist(err) {\n\t\treturn \"\", err\n\t}\n\n\tif err := copyDir(localDir, draftDir); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn draftAppId, nil\n}\n\nfunc DeleteApp(appId string) error {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappDir, err := GetAppDir(appId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := os.RemoveAll(appDir); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete app directory: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc validateAndResolveFilePath(appDir string, fileName string) (string, error) {\n\tif filepath.IsAbs(fileName) {\n\t\treturn \"\", fmt.Errorf(\"fileName must be relative, got absolute path: %s\", fileName)\n\t}\n\n\tcleanPath := filepath.Clean(fileName)\n\tif strings.HasPrefix(cleanPath, \"..\") || strings.Contains(cleanPath, string(filepath.Separator)+\"..\") {\n\t\treturn \"\", fmt.Errorf(\"path traversal not allowed: %s\", fileName)\n\t}\n\n\tfullPath := filepath.Join(appDir, cleanPath)\n\tresolvedPath, err := filepath.Abs(fullPath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to resolve path: %w\", err)\n\t}\n\n\tresolvedAppDir, err := filepath.Abs(appDir)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to resolve app directory: %w\", err)\n\t}\n\n\tif !strings.HasPrefix(resolvedPath, resolvedAppDir+string(filepath.Separator)) && resolvedPath != resolvedAppDir {\n\t\treturn \"\", fmt.Errorf(\"path escapes app directory: %s\", fileName)\n\t}\n\n\treturn resolvedPath, nil\n}\n\nfunc WriteAppFile(appId string, fileName string, contents []byte) error {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappDir, err := GetAppDir(appId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfilePath, err := validateAndResolveFilePath(appDir, fileName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create directory: %w\", err)\n\t}\n\n\tif err := os.WriteFile(filePath, contents, 0644); err != nil {\n\t\treturn fmt.Errorf(\"failed to write file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc ReadAppFile(appId string, fileName string) (*FileData, error) {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappDir, err := GetAppDir(appId)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfilePath, err := validateAndResolveFilePath(appDir, fileName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfileInfo, err := os.Stat(filePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to stat file: %w\", err)\n\t}\n\n\tcontents, err := os.ReadFile(filePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read file: %w\", err)\n\t}\n\n\treturn &FileData{\n\t\tContents: contents,\n\t\tModTs:    fileInfo.ModTime().UnixMilli(),\n\t}, nil\n}\n\nfunc DeleteAppFile(appId string, fileName string) error {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappDir, err := GetAppDir(appId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfilePath, err := validateAndResolveFilePath(appDir, fileName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := os.Remove(filePath); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc ReplaceInAppFile(appId string, fileName string, edits []fileutil.EditSpec) error {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappDir, err := GetAppDir(appId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfilePath, err := validateAndResolveFilePath(appDir, fileName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn fileutil.ReplaceInFile(filePath, edits)\n}\n\nfunc ReplaceInAppFilePartial(appId string, fileName string, edits []fileutil.EditSpec) ([]fileutil.EditResult, error) {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappDir, err := GetAppDir(appId)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfilePath, err := validateAndResolveFilePath(appDir, fileName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn fileutil.ReplaceInFilePartial(filePath, edits)\n}\n\nfunc RenameAppFile(appId string, fromFileName string, toFileName string) error {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappDir, err := GetAppDir(appId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfromPath, err := validateAndResolveFilePath(appDir, fromFileName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid source path: %w\", err)\n\t}\n\n\ttoPath, err := validateAndResolveFilePath(appDir, toFileName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid destination path: %w\", err)\n\t}\n\n\tif err := os.MkdirAll(filepath.Dir(toPath), 0755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create destination directory: %w\", err)\n\t}\n\n\tif err := os.Rename(fromPath, toPath); err != nil {\n\t\treturn fmt.Errorf(\"failed to rename file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc FormatGoFile(appId string, fileName string) error {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappDir, err := GetAppDir(appId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfilePath, err := validateAndResolveFilePath(appDir, fileName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif filepath.Ext(filePath) != \".go\" {\n\t\treturn fmt.Errorf(\"file is not a Go file: %s\", fileName)\n\t}\n\n\tgofmtPath, err := waveapputil.ResolveGoFmtPath()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to resolve gofmt path: %w\", err)\n\t}\n\n\tcmd := exec.Command(gofmtPath, \"-w\", filePath)\n\tif output, err := cmd.CombinedOutput(); err != nil {\n\t\treturn fmt.Errorf(\"gofmt failed: %w\\nOutput: %s\", err, string(output))\n\t}\n\n\treturn nil\n}\n\nfunc ListAllAppFiles(appId string) (*fileutil.ReadDirResult, error) {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappDir, err := GetAppDir(appId)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif _, err := os.Stat(appDir); os.IsNotExist(err) {\n\t\treturn nil, fmt.Errorf(\"app directory does not exist: %s\", appDir)\n\t}\n\n\treturn fileutil.ReadDirRecursive(appDir, 10000)\n}\n\nfunc ListAllApps() ([]wshrpc.AppInfo, error) {\n\thomeDir := wavebase.GetHomeDir()\n\twaveappsDir := filepath.Join(homeDir, \"waveapps\")\n\n\tif _, err := os.Stat(waveappsDir); os.IsNotExist(err) {\n\t\treturn []wshrpc.AppInfo{}, nil\n\t}\n\n\tnamespaces, err := os.ReadDir(waveappsDir)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read waveapps directory: %w\", err)\n\t}\n\n\tvar appInfos []wshrpc.AppInfo\n\n\tfor _, ns := range namespaces {\n\t\tif !ns.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\tnamespace := ns.Name()\n\t\tnsPath := filepath.Join(waveappsDir, namespace)\n\n\t\tapps, err := os.ReadDir(nsPath)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, app := range apps {\n\t\t\tif !app.IsDir() {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tappName := app.Name()\n\t\t\tappId := MakeAppId(namespace, appName)\n\n\t\t\tif err := ValidateAppId(appId); err == nil {\n\t\t\t\tmodTime, _ := GetAppModTime(appId)\n\t\t\t\tappInfo := wshrpc.AppInfo{\n\t\t\t\t\tAppId:   appId,\n\t\t\t\t\tModTime: modTime,\n\t\t\t\t}\n\n\t\t\t\tif manifest, err := ReadAppManifest(appId); err == nil {\n\t\t\t\t\tappInfo.Manifest = manifest\n\t\t\t\t}\n\n\t\t\t\tappInfos = append(appInfos, appInfo)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn appInfos, nil\n}\n\nfunc GetAppModTime(appId string) (int64, error) {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn 0, err\n\t}\n\n\thomeDir := wavebase.GetHomeDir()\n\tappNS, appName, err := ParseAppId(appId)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tappPath := filepath.Join(homeDir, \"waveapps\", appNS, appName)\n\tappGoPath := filepath.Join(appPath, \"app.go\")\n\n\tfileInfo, err := os.Stat(appGoPath)\n\tif err == nil {\n\t\treturn fileInfo.ModTime().UnixMilli(), nil\n\t}\n\n\tdirInfo, err := os.Stat(appPath)\n\tif err != nil {\n\t\treturn 0, nil\n\t}\n\n\treturn dirInfo.ModTime().UnixMilli(), nil\n}\n\nfunc ListAllEditableApps() ([]wshrpc.AppInfo, error) {\n\thomeDir := wavebase.GetHomeDir()\n\twaveappsDir := filepath.Join(homeDir, \"waveapps\")\n\n\tif _, err := os.Stat(waveappsDir); os.IsNotExist(err) {\n\t\treturn []wshrpc.AppInfo{}, nil\n\t}\n\n\tlocalApps := make(map[string]bool)\n\tdraftApps := make(map[string]bool)\n\n\tlocalPath := filepath.Join(waveappsDir, AppNSLocal)\n\tif localEntries, err := os.ReadDir(localPath); err == nil {\n\t\tfor _, app := range localEntries {\n\t\t\tif app.IsDir() {\n\t\t\t\tappName := app.Name()\n\t\t\t\tappId := MakeAppId(AppNSLocal, appName)\n\t\t\t\tif err := ValidateAppId(appId); err == nil {\n\t\t\t\t\tlocalApps[appName] = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tdraftPath := filepath.Join(waveappsDir, AppNSDraft)\n\tif draftEntries, err := os.ReadDir(draftPath); err == nil {\n\t\tfor _, app := range draftEntries {\n\t\t\tif app.IsDir() {\n\t\t\t\tappName := app.Name()\n\t\t\t\tappId := MakeAppId(AppNSDraft, appName)\n\t\t\t\tif err := ValidateAppId(appId); err == nil {\n\t\t\t\t\tdraftApps[appName] = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tallAppNames := make(map[string]bool)\n\tfor appName := range localApps {\n\t\tallAppNames[appName] = true\n\t}\n\tfor appName := range draftApps {\n\t\tallAppNames[appName] = true\n\t}\n\n\tvar appInfos []wshrpc.AppInfo\n\tfor appName := range allAppNames {\n\t\tvar appId string\n\t\tvar manifestAppId string\n\t\tif localApps[appName] {\n\t\t\tappId = MakeAppId(AppNSLocal, appName)\n\t\t} else {\n\t\t\tappId = MakeAppId(AppNSDraft, appName)\n\t\t}\n\n\t\tif draftApps[appName] {\n\t\t\tmanifestAppId = MakeAppId(AppNSDraft, appName)\n\t\t} else {\n\t\t\tmanifestAppId = appId\n\t\t}\n\n\t\tmodTime, _ := GetAppModTime(manifestAppId)\n\n\t\tappInfo := wshrpc.AppInfo{\n\t\t\tAppId:   appId,\n\t\t\tModTime: modTime,\n\t\t}\n\n\t\tif manifest, err := ReadAppManifest(manifestAppId); err == nil {\n\t\t\tappInfo.Manifest = manifest\n\t\t}\n\n\t\tappInfos = append(appInfos, appInfo)\n\t}\n\n\treturn appInfos, nil\n}\n\nfunc DraftHasLocalVersion(draftAppId string) (bool, error) {\n\tif err := ValidateAppId(draftAppId); err != nil {\n\t\treturn false, fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappNS, appName, _ := ParseAppId(draftAppId)\n\tif appNS != AppNSDraft {\n\t\treturn false, fmt.Errorf(\"appId must be in draft namespace, got: %s\", appNS)\n\t}\n\n\tlocalAppId := MakeAppId(AppNSLocal, appName)\n\tlocalDir, err := GetAppDir(localAppId)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tif _, err := os.Stat(localDir); os.IsNotExist(err) {\n\t\treturn false, nil\n\t}\n\n\treturn true, nil\n}\n\n// RenameLocalApp renames a local app by renaming its directories in both the local and draft namespaces.\n// It takes the current app name and the new app name (without namespace prefixes).\n// Both local/[appName] and draft/[appName] will be renamed if they exist.\n// Returns an error if the app doesn't exist in either namespace, if the new name is invalid,\n// or if the new name conflicts with an existing app.\nfunc RenameLocalApp(appName string, newAppName string) error {\n\t// Validate the old app name by constructing a valid appId\n\toldLocalAppId := MakeAppId(AppNSLocal, appName)\n\tif err := ValidateAppId(oldLocalAppId); err != nil {\n\t\treturn fmt.Errorf(\"invalid app name: %w\", err)\n\t}\n\n\t// Validate the new app name by constructing a valid appId\n\tnewLocalAppId := MakeAppId(AppNSLocal, newAppName)\n\tif err := ValidateAppId(newLocalAppId); err != nil {\n\t\treturn fmt.Errorf(\"invalid new app name: %w\", err)\n\t}\n\n\thomeDir := wavebase.GetHomeDir()\n\twaveappsDir := filepath.Join(homeDir, \"waveapps\")\n\n\toldLocalDir := filepath.Join(waveappsDir, AppNSLocal, appName)\n\tnewLocalDir := filepath.Join(waveappsDir, AppNSLocal, newAppName)\n\toldDraftDir := filepath.Join(waveappsDir, AppNSDraft, appName)\n\tnewDraftDir := filepath.Join(waveappsDir, AppNSDraft, newAppName)\n\n\t// Check if at least one of the apps exists\n\tlocalExists := false\n\tdraftExists := false\n\tif _, err := os.Stat(oldLocalDir); err == nil {\n\t\tlocalExists = true\n\t} else if !os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"failed to check local app: %w\", err)\n\t}\n\n\tif _, err := os.Stat(oldDraftDir); err == nil {\n\t\tdraftExists = true\n\t} else if !os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"failed to check draft app: %w\", err)\n\t}\n\n\tif !localExists && !draftExists {\n\t\treturn fmt.Errorf(\"app '%s' does not exist in local or draft namespace\", appName)\n\t}\n\n\t// Check if new app name already exists in either namespace\n\tif _, err := os.Stat(newLocalDir); err == nil {\n\t\treturn fmt.Errorf(\"local app '%s' already exists\", newAppName)\n\t} else if !os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"failed to check if new local app exists: %w\", err)\n\t}\n\n\tif _, err := os.Stat(newDraftDir); err == nil {\n\t\treturn fmt.Errorf(\"draft app '%s' already exists\", newAppName)\n\t} else if !os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"failed to check if new draft app exists: %w\", err)\n\t}\n\n\t// Rename local app if it exists\n\tif localExists {\n\t\tif err := os.Rename(oldLocalDir, newLocalDir); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to rename local app: %w\", err)\n\t\t}\n\t}\n\n\t// Rename draft app if it exists\n\tif draftExists {\n\t\tif err := os.Rename(oldDraftDir, newDraftDir); err != nil {\n\t\t\t// If local was renamed but draft fails, try to rollback local rename\n\t\t\tif localExists {\n\t\t\t\tif rollbackErr := os.Rename(newLocalDir, oldLocalDir); rollbackErr != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to rename draft app (and failed to rollback local rename: %v): %w\", rollbackErr, err)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"failed to rename draft app: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc ReadAppManifest(appId string) (*wshrpc.AppManifest, error) {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappDir, err := GetAppDir(appId)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tmanifestPath := filepath.Join(appDir, ManifestFileName)\n\tdata, err := os.ReadFile(manifestPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read %s: %w\", ManifestFileName, err)\n\t}\n\n\tvar manifest wshrpc.AppManifest\n\tif err := json.Unmarshal(data, &manifest); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse %s: %w\", ManifestFileName, err)\n\t}\n\n\treturn &manifest, nil\n}\n\nfunc ReadAppSecretBindings(appId string) (map[string]string, error) {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappDir, err := GetAppDir(appId)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbindingsPath := filepath.Join(appDir, SecretBindingsFileName)\n\tdata, err := os.ReadFile(bindingsPath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn make(map[string]string), nil\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to read %s: %w\", SecretBindingsFileName, err)\n\t}\n\n\tvar bindings map[string]string\n\tif err := json.Unmarshal(data, &bindings); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse %s: %w\", SecretBindingsFileName, err)\n\t}\n\n\tif bindings == nil {\n\t\tbindings = make(map[string]string)\n\t}\n\n\treturn bindings, nil\n}\n\nfunc WriteAppSecretBindings(appId string, bindings map[string]string) error {\n\tif err := ValidateAppId(appId); err != nil {\n\t\treturn fmt.Errorf(\"invalid appId: %w\", err)\n\t}\n\n\tappDir, err := GetAppDir(appId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif bindings == nil {\n\t\tbindings = make(map[string]string)\n\t}\n\n\tdata, err := json.MarshalIndent(bindings, \"\", \"  \")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal bindings: %w\", err)\n\t}\n\n\tbindingsPath := filepath.Join(appDir, SecretBindingsFileName)\n\tif err := os.WriteFile(bindingsPath, data, 0644); err != nil {\n\t\treturn fmt.Errorf(\"failed to write %s: %w\", SecretBindingsFileName, err)\n\t}\n\n\treturn nil\n}\n\nfunc BuildAppSecretEnv(appId string, manifest *wshrpc.AppManifest, bindings map[string]string) (map[string]string, error) {\n\tif manifest == nil {\n\t\treturn make(map[string]string), nil\n\t}\n\n\tif bindings == nil {\n\t\tbindings = make(map[string]string)\n\t}\n\n\tsecretEnv := make(map[string]string)\n\n\tfor secretName, secretMeta := range manifest.Secrets {\n\t\tboundSecretName, hasBinding := bindings[secretName]\n\n\t\tif !secretMeta.Optional && !hasBinding {\n\t\t\treturn nil, fmt.Errorf(\"required secret %q is not bound\", secretName)\n\t\t}\n\n\t\tif !hasBinding {\n\t\t\tcontinue\n\t\t}\n\n\t\tsecretValue, exists, err := secretstore.GetSecret(boundSecretName)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get secret %q: %w\", boundSecretName, err)\n\t\t}\n\n\t\tif !exists {\n\t\t\tif !secretMeta.Optional {\n\t\t\t\treturn nil, fmt.Errorf(\"required secret %q is bound to %q which does not exist in secret store\", secretName, boundSecretName)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tsecretEnv[secretName] = secretValue\n\t}\n\n\treturn secretEnv, nil\n}\n"
  },
  {
    "path": "pkg/waveapputil/waveapputil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveapputil\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/tsunami/build\"\n)\n\nconst DefaultTsunamiSdkVersion = \"v0.12.4\"\n\nfunc GetTsunamiScaffoldPath() string {\n\tsettings := wconfig.GetWatcher().GetFullConfig().Settings\n\tscaffoldPath := settings.TsunamiScaffoldPath\n\tif scaffoldPath == \"\" {\n\t\tscaffoldPath = filepath.Join(wavebase.GetWaveAppResourcesPath(), \"tsunamiscaffold\")\n\t}\n\treturn scaffoldPath\n}\n\nfunc ResolveGoFmtPath() (string, error) {\n\tsettings := wconfig.GetWatcher().GetFullConfig().Settings\n\tgoPath := settings.TsunamiGoPath\n\n\tif goPath == \"\" {\n\t\tvar err error\n\t\tgoPath, err = build.FindGoExecutable()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\n\tgoDir := filepath.Dir(goPath)\n\tgofmtName := \"gofmt\"\n\tif runtime.GOOS == \"windows\" {\n\t\tgofmtName = \"gofmt.exe\"\n\t}\n\tgofmtPath := filepath.Join(goDir, gofmtName)\n\n\tinfo, err := os.Stat(gofmtPath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"gofmt not found at %s: %w\", gofmtPath, err)\n\t}\n\n\tif info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"gofmt path is a directory: %s\", gofmtPath)\n\t}\n\n\tif info.Mode()&0111 == 0 {\n\t\treturn \"\", fmt.Errorf(\"gofmt is not executable: %s\", gofmtPath)\n\t}\n\n\treturn gofmtPath, nil\n}\n\nfunc FormatGoCode(contents []byte) []byte {\n\tgofmtPath, err := ResolveGoFmtPath()\n\tif err != nil {\n\t\treturn contents\n\t}\n\n\tcmd := exec.Command(gofmtPath)\n\tcmd.Stdin = bytes.NewReader(contents)\n\tformattedOutput, err := cmd.Output()\n\tif err != nil {\n\t\treturn contents\n\t}\n\n\treturn formattedOutput\n}\n"
  },
  {
    "path": "pkg/wavebase/wavebase-posix.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n//go:build !windows\n\npackage wavebase\n\nimport (\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"golang.org/x/sys/unix\"\n)\n\nfunc AcquireWaveLock() (FDLock, error) {\n\tdataHomeDir := GetWaveDataDir()\n\tlockFileName := filepath.Join(dataHomeDir, WaveLockFile)\n\tlog.Printf(\"[base] acquiring lock on %s\\n\", lockFileName)\n\tfd, err := os.OpenFile(lockFileName, os.O_RDWR|os.O_CREATE, 0600)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\terr = unix.Flock(int(fd.Fd()), unix.LOCK_EX|unix.LOCK_NB)\n\tif err != nil {\n\t\tfd.Close()\n\t\treturn nil, err\n\t}\n\treturn fd, nil\n}\n"
  },
  {
    "path": "pkg/wavebase/wavebase-win.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n//go:build windows\n\npackage wavebase\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"path/filepath\"\n\n\t\"github.com/alexflint/go-filemutex\"\n)\n\nfunc AcquireWaveLock() (FDLock, error) {\n\tdataHomeDir := GetWaveDataDir()\n\tlockFileName := filepath.Join(dataHomeDir, WaveLockFile)\n\tlog.Printf(\"[base] acquiring lock on %s\\n\", lockFileName)\n\tm, err := filemutex.New(lockFileName)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"filemutex new error: %w\", err)\n\t}\n\terr = m.TryLock()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"filemutex trylock error: %w\", err)\n\t}\n\treturn m, nil\n}\n"
  },
  {
    "path": "pkg/wavebase/wavebase.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wavebase\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n)\n\n// set by main-server.go\nvar WaveVersion = \"0.0.0\"\nvar BuildTime = \"0\"\n\nconst (\n\tWaveConfigHomeEnvVar           = \"WAVETERM_CONFIG_HOME\"\n\tWaveDataHomeEnvVar             = \"WAVETERM_DATA_HOME\"\n\tWaveAppPathVarName             = \"WAVETERM_APP_PATH\"\n\tWaveAppResourcesPathVarName    = \"WAVETERM_RESOURCES_PATH\"\n\tWaveAppElectronExecPathVarName = \"WAVETERM_ELECTRONEXECPATH\"\n\tWaveDevVarName                 = \"WAVETERM_DEV\"\n\tWaveDevViteVarName             = \"WAVETERM_DEV_VITE\"\n\tWaveWshForceUpdateVarName      = \"WAVETERM_WSHFORCEUPDATE\"\n\tWaveNoConfirmQuitVarName       = \"WAVETERM_NOCONFIRMQUIT\"\n\n\tWaveJwtTokenVarName  = \"WAVETERM_JWT\"\n\tWaveSwapTokenVarName = \"WAVETERM_SWAPTOKEN\"\n)\n\nconst (\n\tBlockFile_Term  = \"term\"            // used for main pty output\n\tBlockFile_Cache = \"cache:term:full\" // for cached block\n\tBlockFile_VDom  = \"vdom\"            // used for alt html layout\n\tBlockFile_Env   = \"env\"\n)\n\nconst NeedJwtConst = \"NEED-JWT\"\n\nvar ConfigHome_VarCache string          // caches WAVETERM_CONFIG_HOME\nvar DataHome_VarCache string            // caches WAVETERM_DATA_HOME\nvar AppPath_VarCache string             // caches WAVETERM_APP_PATH\nvar AppResourcesPath_VarCache string    // caches WAVETERM_RESOURCES_PATH\nvar AppElectronExecPath_VarCache string // caches WAVETERM_ELECTRONEXECPATH\nvar Dev_VarCache string                 // caches WAVETERM_DEV\n\nconst WaveLockFile = \"wave.lock\"\nconst DomainSocketBaseName = \"wave.sock\"\nconst RemoteDomainSocketBaseName = \"wave-remote.sock\"\nconst WaveDBDir = \"db\"\nconst ConfigDir = \"config\"\nconst RemoteWaveHomeDirName = \".waveterm\"\nconst RemoteWshBinDirName = \"bin\"\nconst RemoteFullWshBinPath = \"~/.waveterm/bin/wsh\"\nconst RemoteFullDomainSocketPath = \"~/.waveterm/wave-remote.sock\"\n\nconst AppPathBinDir = \"bin\"\n\nvar baseLock = &sync.Mutex{}\nvar ensureDirCache = map[string]bool{}\n\nvar waveCachesDirOnce = &sync.Once{}\nvar waveCachesDir string\n\nvar SupportedWshBinaries = map[string]bool{\n\t\"darwin-x64\":    true,\n\t\"darwin-arm64\":  true,\n\t\"linux-x64\":     true,\n\t\"linux-arm64\":   true,\n\t\"windows-x64\":   true,\n\t\"windows-arm64\": true,\n}\n\ntype FDLock interface {\n\tClose() error\n}\n\nfunc CacheAndRemoveEnvVars() error {\n\tConfigHome_VarCache = os.Getenv(WaveConfigHomeEnvVar)\n\tif ConfigHome_VarCache == \"\" {\n\t\treturn fmt.Errorf(WaveConfigHomeEnvVar + \" not set\")\n\t}\n\tos.Unsetenv(WaveConfigHomeEnvVar)\n\tDataHome_VarCache = os.Getenv(WaveDataHomeEnvVar)\n\tif DataHome_VarCache == \"\" {\n\t\treturn fmt.Errorf(\"%s not set\", WaveDataHomeEnvVar)\n\t}\n\tos.Unsetenv(WaveDataHomeEnvVar)\n\tAppPath_VarCache = os.Getenv(WaveAppPathVarName)\n\tos.Unsetenv(WaveAppPathVarName)\n\tAppResourcesPath_VarCache = os.Getenv(WaveAppResourcesPathVarName)\n\tos.Unsetenv(WaveAppResourcesPathVarName)\n\tAppElectronExecPath_VarCache = os.Getenv(WaveAppElectronExecPathVarName)\n\tos.Unsetenv(WaveAppElectronExecPathVarName)\n\tDev_VarCache = os.Getenv(WaveDevVarName)\n\tos.Unsetenv(WaveDevVarName)\n\tos.Unsetenv(WaveDevViteVarName)\n\tos.Unsetenv(WaveNoConfirmQuitVarName)\n\treturn nil\n}\n\nfunc IsDevMode() bool {\n\treturn Dev_VarCache != \"\"\n}\n\nfunc GetWaveAppPath() string {\n\treturn AppPath_VarCache\n}\n\nfunc GetWaveAppResourcesPath() string {\n\treturn AppResourcesPath_VarCache\n}\n\nfunc GetWaveDataDir() string {\n\treturn DataHome_VarCache\n}\n\nfunc GetWaveConfigDir() string {\n\treturn ConfigHome_VarCache\n}\n\nfunc GetWaveAppBinPath() string {\n\treturn filepath.Join(GetWaveAppPath(), AppPathBinDir)\n}\n\nfunc GetWaveAppElectronExecPath() string {\n\treturn AppElectronExecPath_VarCache\n}\n\nfunc GetHomeDir() string {\n\thomeVar, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"/\"\n\t}\n\treturn homeVar\n}\n\nfunc ExpandHomeDir(pathStr string) (string, error) {\n\tif pathStr != \"~\" && !strings.HasPrefix(pathStr, \"~/\") && (!strings.HasPrefix(pathStr, `~\\`) || runtime.GOOS != \"windows\") {\n\t\treturn filepath.Clean(pathStr), nil\n\t}\n\thomeDir := GetHomeDir()\n\tif pathStr == \"~\" {\n\t\treturn homeDir, nil\n\t}\n\texpandedPath := filepath.Clean(filepath.Join(homeDir, pathStr[2:]))\n\tabsPath, err := filepath.Abs(filepath.Join(homeDir, expandedPath))\n\tif err != nil || !strings.HasPrefix(absPath, homeDir) {\n\t\treturn \"\", fmt.Errorf(\"potential path traversal detected for path %s\", pathStr)\n\t}\n\treturn expandedPath, nil\n}\n\nfunc ExpandHomeDirSafe(pathStr string) string {\n\tpath, _ := ExpandHomeDir(pathStr)\n\treturn path\n}\n\nfunc ReplaceHomeDir(pathStr string) string {\n\thomeDir := GetHomeDir()\n\tif pathStr == homeDir {\n\t\treturn \"~\"\n\t}\n\tif strings.HasPrefix(pathStr, homeDir+\"/\") {\n\t\treturn \"~\" + pathStr[len(homeDir):]\n\t}\n\treturn pathStr\n}\n\nfunc GetDomainSocketName() string {\n\treturn filepath.Join(GetWaveDataDir(), DomainSocketBaseName)\n}\n\n// returns a Unix-style path for the remote socket (using fmt.Sprintf instead of filepath.Join\n// because this path is for a remote Unix system, not the local OS which might be Windows)\nfunc GetPersistentRemoteSockName(clientId string) string {\n\treturn fmt.Sprintf(\"~/.waveterm/client/%s/waveterm.sock\", clientId)\n}\n\nfunc EnsureWaveDataDir() error {\n\treturn CacheEnsureDir(GetWaveDataDir(), \"wavehome\", 0700, \"wave home directory\")\n}\n\nfunc EnsureWaveDBDir() error {\n\treturn CacheEnsureDir(filepath.Join(GetWaveDataDir(), WaveDBDir), \"wavedb\", 0700, \"wave db directory\")\n}\n\nfunc EnsureWaveConfigDir() error {\n\treturn CacheEnsureDir(GetWaveConfigDir(), \"waveconfig\", 0700, \"wave config directory\")\n}\n\nfunc EnsureWavePresetsDir() error {\n\treturn CacheEnsureDir(filepath.Join(GetWaveConfigDir(), \"presets\"), \"wavepresets\", 0700, \"wave presets directory\")\n}\n\nfunc resolveWaveCachesDir() string {\n\tvar cacheDir string\n\tappBundle := \"waveterm\"\n\tif IsDevMode() {\n\t\tappBundle = \"waveterm-dev\"\n\t}\n\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\thomeDir := GetHomeDir()\n\t\tcacheDir = filepath.Join(homeDir, \"Library\", \"Caches\", appBundle)\n\tcase \"linux\":\n\t\txdgCache := os.Getenv(\"XDG_CACHE_HOME\")\n\t\tif xdgCache != \"\" {\n\t\t\tcacheDir = filepath.Join(xdgCache, appBundle)\n\t\t} else {\n\t\t\thomeDir := GetHomeDir()\n\t\t\tcacheDir = filepath.Join(homeDir, \".cache\", appBundle)\n\t\t}\n\tcase \"windows\":\n\t\tlocalAppData := os.Getenv(\"LOCALAPPDATA\")\n\t\tif localAppData != \"\" {\n\t\t\tcacheDir = filepath.Join(localAppData, appBundle, \"Cache\")\n\t\t}\n\t}\n\n\tif cacheDir == \"\" {\n\t\ttmpDir := os.TempDir()\n\t\tcacheDir = filepath.Join(tmpDir, appBundle)\n\t}\n\n\treturn cacheDir\n}\n\nfunc GetWaveCachesDir() string {\n\twaveCachesDirOnce.Do(func() {\n\t\twaveCachesDir = resolveWaveCachesDir()\n\t})\n\treturn waveCachesDir\n}\n\nfunc EnsureWaveCachesDir() error {\n\treturn CacheEnsureDir(GetWaveCachesDir(), \"wavecaches\", 0700, \"wave caches directory\")\n}\n\nfunc CacheEnsureDir(dirName string, cacheKey string, perm os.FileMode, dirDesc string) error {\n\tbaseLock.Lock()\n\tok := ensureDirCache[cacheKey]\n\tbaseLock.Unlock()\n\tif ok {\n\t\treturn nil\n\t}\n\terr := TryMkdirs(dirName, perm, dirDesc)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbaseLock.Lock()\n\tensureDirCache[cacheKey] = true\n\tbaseLock.Unlock()\n\treturn nil\n}\n\nfunc TryMkdirs(dirName string, perm os.FileMode, dirDesc string) error {\n\tinfo, err := os.Stat(dirName)\n\tif errors.Is(err, fs.ErrNotExist) {\n\t\terr = os.MkdirAll(dirName, perm)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"cannot make %s %q: %w\", dirDesc, dirName, err)\n\t\t}\n\t\tinfo, err = os.Stat(dirName)\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error trying to stat %s: %w\", dirDesc, err)\n\t}\n\tif !info.IsDir() {\n\t\treturn fmt.Errorf(\"%s %q must be a directory\", dirDesc, dirName)\n\t}\n\treturn nil\n}\n\nfunc listValidLangs(ctx context.Context) []string {\n\tout, err := exec.CommandContext(ctx, \"locale\", \"-a\").CombinedOutput()\n\tif err != nil {\n\t\tlog.Printf(\"error running 'locale -a': %s\\n\", err)\n\t\treturn []string{}\n\t}\n\t// don't bother with CRLF line endings\n\t// this command doesn't work on windows\n\treturn strings.Split(string(out), \"\\n\")\n}\n\nvar osLangOnce = &sync.Once{}\nvar osLang string\n\nfunc determineLang() string {\n\tdefaultLang := \"en_US.UTF-8\"\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\tif runtime.GOOS == \"darwin\" {\n\t\tout, err := exec.CommandContext(ctx, \"defaults\", \"read\", \"-g\", \"AppleLocale\").CombinedOutput()\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error executing 'defaults read -g AppleLocale', will use default 'en_US.UTF-8': %v\\n\", err)\n\t\t\treturn defaultLang\n\t\t}\n\t\tstrOut := string(out)\n\t\ttruncOut := strings.Split(strOut, \"@\")[0]\n\t\tpreferredLang := strings.TrimSpace(truncOut) + \".UTF-8\"\n\t\tvalidLangs := listValidLangs(ctx)\n\n\t\tif !utilfn.ContainsStr(validLangs, preferredLang) {\n\t\t\tlog.Printf(\"unable to use desired lang %s, will use default 'en_US.UTF-8'\\n\", preferredLang)\n\t\t\treturn defaultLang\n\t\t}\n\n\t\treturn preferredLang\n\t} else {\n\t\t// this is specifically to get the wavesrv LANG so waveshell\n\t\t// on a remote uses the same LANG\n\t\treturn os.Getenv(\"LANG\")\n\t}\n}\n\nfunc DetermineLang() string {\n\tosLangOnce.Do(func() {\n\t\tosLang = determineLang()\n\t})\n\treturn osLang\n}\n\nfunc DetermineLocale() string {\n\ttruncated := strings.Split(DetermineLang(), \".\")[0]\n\tif truncated == \"\" {\n\t\treturn \"C\"\n\t}\n\treturn strings.Replace(truncated, \"_\", \"-\", -1)\n}\n\nfunc ClientArch() string {\n\treturn fmt.Sprintf(\"%s/%s\", runtime.GOOS, runtime.GOARCH)\n}\n\nfunc ClientPackageType() string {\n\tif os.Getenv(\"SNAP\") != \"\" {\n\t\treturn \"snap\"\n\t}\n\tif os.Getenv(\"APPIMAGE\") != \"\" {\n\t\treturn \"appimage\"\n\t}\n\treturn \"\"\n}\n\nvar macOSVersionOnce = &sync.Once{}\nvar cachedMacOSVersion string\n\nvar macOSVersionRegex = regexp.MustCompile(`^(\\d+\\.\\d+(?:\\.\\d+)?)`)\n\nfunc internalMacOSVersion() string {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\tout, err := exec.CommandContext(ctx, \"sw_vers\", \"-productVersion\").Output()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tversionStr := strings.TrimSpace(string(out))\n\tm := macOSVersionRegex.FindStringSubmatch(versionStr)\n\tif len(m) < 2 {\n\t\treturn \"\"\n\t}\n\treturn m[1]\n}\n\nfunc ClientMacOSVersion() string {\n\tif runtime.GOOS != \"darwin\" {\n\t\treturn \"\"\n\t}\n\tmacOSVersionOnce.Do(func() {\n\t\tcachedMacOSVersion = internalMacOSVersion()\n\t})\n\treturn cachedMacOSVersion\n}\n\nvar releaseRegex = regexp.MustCompile(`^(\\d+\\.\\d+\\.\\d+)`)\nvar osReleaseOnce = &sync.Once{}\nvar osRelease string\n\nfunc unameKernelRelease() string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn \"-\"\n\t}\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\tout, err := exec.CommandContext(ctx, \"uname\", \"-r\").CombinedOutput()\n\tif err != nil {\n\t\tlog.Printf(\"error executing uname -r: %v\\n\", err)\n\t\treturn \"-\"\n\t}\n\treleaseStr := strings.TrimSpace(string(out))\n\tm := releaseRegex.FindStringSubmatch(releaseStr)\n\tif len(m) < 2 {\n\t\tlog.Printf(\"invalid uname -r output: [%s]\\n\", releaseStr)\n\t\treturn \"-\"\n\t}\n\treturn m[1]\n}\n\nfunc UnameKernelRelease() string {\n\tosReleaseOnce.Do(func() {\n\t\tosRelease = unameKernelRelease()\n\t})\n\treturn osRelease\n}\n\nvar systemSummaryOnce = &sync.Once{}\nvar systemSummary string\n\nfunc GetSystemSummary() string {\n\tsystemSummaryOnce.Do(func() {\n\t\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\t\tdefer cancelFn()\n\t\tsystemSummary = getSystemSummary(ctx)\n\t})\n\treturn systemSummary\n}\n\nfunc ValidateWshSupportedArch(os string, arch string) error {\n\tif SupportedWshBinaries[fmt.Sprintf(\"%s-%s\", os, arch)] {\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"unsupported wsh platform: %s-%s\", os, arch)\n}\n\nfunc getSystemSummary(ctx context.Context) string {\n\tosName := runtime.GOOS\n\n\tswitch osName {\n\tcase \"darwin\":\n\t\tout, _ := exec.CommandContext(ctx, \"sw_vers\", \"-productVersion\").Output()\n\t\treturn fmt.Sprintf(\"macOS %s (%s)\", strings.TrimSpace(string(out)), runtime.GOARCH)\n\tcase \"linux\":\n\t\t// Read /etc/os-release directly (standard location since 2012)\n\t\tdata, err := os.ReadFile(\"/etc/os-release\")\n\t\tvar prettyName string\n\t\tif err == nil {\n\t\t\tfor _, line := range strings.Split(string(data), \"\\n\") {\n\t\t\t\tline = strings.TrimSpace(line)\n\t\t\t\tif strings.HasPrefix(line, \"PRETTY_NAME=\") {\n\t\t\t\t\tprettyName = strings.Trim(strings.TrimPrefix(line, \"PRETTY_NAME=\"), \"\\\"\")\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif prettyName == \"\" {\n\t\t\tprettyName = \"Linux\"\n\t\t} else if !strings.Contains(strings.ToLower(prettyName), \"linux\") {\n\t\t\tprettyName = \"Linux \" + prettyName\n\t\t}\n\t\treturn fmt.Sprintf(\"%s (%s)\", prettyName, runtime.GOARCH)\n\tcase \"windows\":\n\t\tvar details string\n\t\tout, err := exec.CommandContext(ctx, \"powershell\", \"-NoProfile\", \"-NonInteractive\", \"-Command\", \"(Get-CimInstance Win32_OperatingSystem).Caption\").Output()\n\t\tif err == nil && len(out) > 0 {\n\t\t\tdetails = strings.TrimSpace(string(out))\n\t\t} else {\n\t\t\tdetails = \"Windows\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%s (%s)\", details, runtime.GOARCH)\n\tdefault:\n\t\treturn fmt.Sprintf(\"%s (%s)\", runtime.GOOS, runtime.GOARCH)\n\t}\n}\n\n// job socket path on remote machine\nfunc GetRemoteJobSocketPath(jobId string) string {\n\tsocketDir := filepath.Join(\"/tmp\", fmt.Sprintf(\"waveterm-%d\", os.Getuid()))\n\treturn filepath.Join(socketDir, fmt.Sprintf(\"%s.sock\", jobId))\n}\n\n// job file path on remote machine\nfunc GetRemoteJobFilePath(jobId string, extension string) string {\n\tjobDir := GetRemoteJobLogDir()\n\treturn filepath.Join(jobDir, fmt.Sprintf(\"%s.%s\", jobId, extension))\n}\n\n// job file dir on remote machines\nfunc GetRemoteJobLogDir() string {\n\thomeDir := GetHomeDir()\n\tjobDir := filepath.Join(homeDir, \".waveterm\", \"jobs\")\n\treturn jobDir\n}\n"
  },
  {
    "path": "pkg/wavejwt/wavejwt.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wavejwt\n\nimport (\n\t\"crypto/ed25519\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n)\n\nconst (\n\tIssuerWaveTerm = \"waveterm\"\n)\n\nvar (\n\tglobalLock sync.Mutex\n\tpublicKey  ed25519.PublicKey\n\tprivateKey ed25519.PrivateKey\n)\n\ntype WaveJwtClaims struct {\n\tjwt.RegisteredClaims\n\tMainServer bool   `json:\"mainserver,omitempty\"`\n\tSock       string `json:\"sock,omitempty\"`\n\tRouteId    string `json:\"routeid,omitempty\"`\n\tProcRoute  bool   `json:\"procroute,omitempty\"`\n\tBlockId    string `json:\"blockid,omitempty\"`\n\tJobId      string `json:\"jobid,omitempty\"`\n\tConn       string `json:\"conn,omitempty\"`\n\tRouter     bool   `json:\"router,omitempty\"`\n}\n\ntype KeyPair struct {\n\tPublicKey  []byte\n\tPrivateKey []byte\n}\n\nfunc GenerateKeyPair() (*KeyPair, error) {\n\tpubKey, privKey, err := ed25519.GenerateKey(rand.Reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to generate key pair: %w\", err)\n\t}\n\n\treturn &KeyPair{\n\t\tPublicKey:  pubKey,\n\t\tPrivateKey: privKey,\n\t}, nil\n}\n\nfunc SetPublicKey(keyData []byte) error {\n\tif len(keyData) != ed25519.PublicKeySize {\n\t\treturn fmt.Errorf(\"invalid public key size: expected %d, got %d\", ed25519.PublicKeySize, len(keyData))\n\t}\n\tglobalLock.Lock()\n\tdefer globalLock.Unlock()\n\tpublicKey = ed25519.PublicKey(keyData)\n\treturn nil\n}\n\nfunc GetPublicKey() []byte {\n\tglobalLock.Lock()\n\tdefer globalLock.Unlock()\n\treturn publicKey\n}\n\nfunc GetPublicKeyBase64() string {\n\tpubKey := GetPublicKey()\n\tif len(pubKey) == 0 {\n\t\treturn \"\"\n\t}\n\treturn base64.StdEncoding.EncodeToString(pubKey)\n}\n\nfunc SetPrivateKey(keyData []byte) error {\n\tif len(keyData) != ed25519.PrivateKeySize {\n\t\treturn fmt.Errorf(\"invalid private key size: expected %d, got %d\", ed25519.PrivateKeySize, len(keyData))\n\t}\n\tglobalLock.Lock()\n\tdefer globalLock.Unlock()\n\tprivateKey = ed25519.PrivateKey(keyData)\n\treturn nil\n}\n\nfunc ValidateAndExtract(tokenStr string) (*WaveJwtClaims, error) {\n\tglobalLock.Lock()\n\tpubKey := publicKey\n\tglobalLock.Unlock()\n\n\tif pubKey == nil {\n\t\treturn nil, fmt.Errorf(\"public key not set\")\n\t}\n\n\ttoken, err := jwt.ParseWithClaims(tokenStr, &WaveJwtClaims{}, func(token *jwt.Token) (interface{}, error) {\n\t\tif _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok {\n\t\t\treturn nil, fmt.Errorf(\"unexpected signing method: %v\", token.Header[\"alg\"])\n\t\t}\n\t\treturn pubKey, nil\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse token: %w\", err)\n\t}\n\n\tclaims, ok := token.Claims.(*WaveJwtClaims)\n\tif !ok || !token.Valid {\n\t\treturn nil, fmt.Errorf(\"invalid token\")\n\t}\n\n\treturn claims, nil\n}\n\nfunc Sign(claims *WaveJwtClaims) (string, error) {\n\tglobalLock.Lock()\n\tprivKey := privateKey\n\tglobalLock.Unlock()\n\n\tif privKey == nil {\n\t\treturn \"\", fmt.Errorf(\"private key not set\")\n\t}\n\n\tif claims.IssuedAt == nil {\n\t\tclaims.IssuedAt = jwt.NewNumericDate(time.Now())\n\t}\n\tif claims.Issuer == \"\" {\n\t\tclaims.Issuer = IssuerWaveTerm\n\t}\n\tif claims.ExpiresAt == nil {\n\t\tclaims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 365))\n\t}\n\n\ttoken := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)\n\ttokenStr, err := token.SignedString(privKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error signing token: %w\", err)\n\t}\n\n\treturn tokenStr, nil\n}\n"
  },
  {
    "path": "pkg/waveobj/ctxupdate.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveobj\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n)\n\nvar waveObjUpdateKey = struct{}{}\n\ntype contextUpdatesType struct {\n\tUpdatesStack []map[ORef]WaveObjUpdate\n}\n\nfunc dumpUpdateStack(updates *contextUpdatesType) {\n\tlog.Printf(\"dumpUpdateStack len:%d\\n\", len(updates.UpdatesStack))\n\tfor idx, update := range updates.UpdatesStack {\n\t\tvar buf bytes.Buffer\n\t\tbuf.WriteString(fmt.Sprintf(\"  [%d]:\", idx))\n\t\tfor k := range update {\n\t\t\tbuf.WriteString(fmt.Sprintf(\" %s:%s\", k.OType, k.OID))\n\t\t}\n\t\tbuf.WriteString(\"\\n\")\n\t\tlog.Print(buf.String())\n\t}\n}\n\nfunc ContextWithUpdates(ctx context.Context) context.Context {\n\tupdatesVal := ctx.Value(waveObjUpdateKey)\n\tif updatesVal != nil {\n\t\treturn ctx\n\t}\n\treturn context.WithValue(ctx, waveObjUpdateKey, &contextUpdatesType{\n\t\tUpdatesStack: []map[ORef]WaveObjUpdate{make(map[ORef]WaveObjUpdate)},\n\t})\n}\n\nfunc ContextGetUpdates(ctx context.Context) map[ORef]WaveObjUpdate {\n\tupdatesVal := ctx.Value(waveObjUpdateKey)\n\tif updatesVal == nil {\n\t\treturn nil\n\t}\n\tupdates := updatesVal.(*contextUpdatesType)\n\tif len(updates.UpdatesStack) == 1 {\n\t\treturn updates.UpdatesStack[0]\n\t}\n\trtn := make(map[ORef]WaveObjUpdate)\n\tfor _, update := range updates.UpdatesStack {\n\t\tfor k, v := range update {\n\t\t\trtn[k] = v\n\t\t}\n\t}\n\treturn rtn\n}\n\nfunc ContextGetUpdate(ctx context.Context, oref ORef) *WaveObjUpdate {\n\tupdatesVal := ctx.Value(waveObjUpdateKey)\n\tif updatesVal == nil {\n\t\treturn nil\n\t}\n\tupdates := updatesVal.(*contextUpdatesType)\n\tfor idx := len(updates.UpdatesStack) - 1; idx >= 0; idx-- {\n\t\tif obj, ok := updates.UpdatesStack[idx][oref]; ok {\n\t\t\treturn &obj\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ContextAddUpdate(ctx context.Context, update WaveObjUpdate) {\n\tupdatesVal := ctx.Value(waveObjUpdateKey)\n\tif updatesVal == nil {\n\t\treturn\n\t}\n\tupdates := updatesVal.(*contextUpdatesType)\n\toref := ORef{\n\t\tOType: update.OType,\n\t\tOID:   update.OID,\n\t}\n\tupdates.UpdatesStack[len(updates.UpdatesStack)-1][oref] = update\n}\n\nfunc ContextUpdatesBeginTx(ctx context.Context) context.Context {\n\tupdatesVal := ctx.Value(waveObjUpdateKey)\n\tif updatesVal == nil {\n\t\treturn ctx\n\t}\n\tupdates := updatesVal.(*contextUpdatesType)\n\tupdates.UpdatesStack = append(updates.UpdatesStack, make(map[ORef]WaveObjUpdate))\n\treturn ctx\n}\n\nfunc ContextUpdatesCommitTx(ctx context.Context) {\n\tupdatesVal := ctx.Value(waveObjUpdateKey)\n\tif updatesVal == nil {\n\t\treturn\n\t}\n\tupdates := updatesVal.(*contextUpdatesType)\n\tif len(updates.UpdatesStack) <= 1 {\n\t\tpanic(fmt.Errorf(\"no updates transaction to commit\"))\n\t}\n\t// merge the last two updates\n\tcurUpdateMap := updates.UpdatesStack[len(updates.UpdatesStack)-1]\n\tprevUpdateMap := updates.UpdatesStack[len(updates.UpdatesStack)-2]\n\tfor k, v := range curUpdateMap {\n\t\tprevUpdateMap[k] = v\n\t}\n\tupdates.UpdatesStack = updates.UpdatesStack[:len(updates.UpdatesStack)-1]\n}\n\nfunc ContextUpdatesRollbackTx(ctx context.Context) {\n\tupdatesVal := ctx.Value(waveObjUpdateKey)\n\tif updatesVal == nil {\n\t\treturn\n\t}\n\tupdates := updatesVal.(*contextUpdatesType)\n\tif len(updates.UpdatesStack) <= 1 {\n\t\tpanic(fmt.Errorf(\"no updates transaction to rollback\"))\n\t}\n\tupdates.UpdatesStack = updates.UpdatesStack[:len(updates.UpdatesStack)-1]\n}\n\nfunc ContextGetUpdatesRtn(ctx context.Context) UpdatesRtnType {\n\tupdatesMap := ContextGetUpdates(ctx)\n\tif updatesMap == nil {\n\t\treturn nil\n\t}\n\trtn := make(UpdatesRtnType, 0, len(updatesMap))\n\tfor _, v := range updatesMap {\n\t\trtn = append(rtn, v)\n\t}\n\treturn rtn\n}\n\nfunc ContextPrintUpdates(ctx context.Context) {\n\tupdatesVal := ctx.Value(waveObjUpdateKey)\n\tif updatesVal == nil {\n\t\tlog.Print(\"no updates\\n\")\n\t\treturn\n\t}\n\tupdates := updatesVal.(*contextUpdatesType)\n\tlog.Printf(\"updates len:%d\\n\", len(updates.UpdatesStack))\n\tfor idx, update := range updates.UpdatesStack {\n\t\tlog.Printf(\"  update[%d]:\\n\", idx)\n\t\tfor k, v := range update {\n\t\t\tlog.Printf(\"    %s:%s %s\\n\", k.OType, k.OID, v.UpdateType)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/waveobj/metaconsts.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// Generated Code. DO NOT EDIT.\n\npackage waveobj\n\nconst (\n\tMetaKey_View                             = \"view\"\n\n\tMetaKey_Controller                       = \"controller\"\n\n\tMetaKey_File                             = \"file\"\n\n\tMetaKey_Url                              = \"url\"\n\n\tMetaKey_PinnedUrl                        = \"pinnedurl\"\n\n\tMetaKey_Connection                       = \"connection\"\n\n\tMetaKey_Edit                             = \"edit\"\n\n\tMetaKey_History                          = \"history\"\n\tMetaKey_HistoryForward                   = \"history:forward\"\n\n\tMetaKey_DisplayName                      = \"display:name\"\n\tMetaKey_DisplayOrder                     = \"display:order\"\n\n\tMetaKey_Icon                             = \"icon\"\n\tMetaKey_IconColor                        = \"icon:color\"\n\n\tMetaKey_FrameClear                       = \"frame:*\"\n\tMetaKey_Frame                            = \"frame\"\n\tMetaKey_FrameBorderColor                 = \"frame:bordercolor\"\n\tMetaKey_FrameActiveBorderColor           = \"frame:activebordercolor\"\n\tMetaKey_FrameTitle                       = \"frame:title\"\n\tMetaKey_FrameIcon                        = \"frame:icon\"\n\tMetaKey_FrameText                        = \"frame:text\"\n\n\tMetaKey_CmdClear                         = \"cmd:*\"\n\tMetaKey_Cmd                              = \"cmd\"\n\tMetaKey_CmdInteractive                   = \"cmd:interactive\"\n\tMetaKey_CmdLogin                         = \"cmd:login\"\n\tMetaKey_CmdPersistent                    = \"cmd:persistent\"\n\tMetaKey_CmdRunOnStart                    = \"cmd:runonstart\"\n\tMetaKey_CmdClearOnStart                  = \"cmd:clearonstart\"\n\tMetaKey_CmdRunOnce                       = \"cmd:runonce\"\n\tMetaKey_CmdCloseOnExit                   = \"cmd:closeonexit\"\n\tMetaKey_CmdCloseOnExitForce              = \"cmd:closeonexitforce\"\n\tMetaKey_CmdCloseOnExitDelay              = \"cmd:closeonexitdelay\"\n\tMetaKey_CmdNoWsh                         = \"cmd:nowsh\"\n\tMetaKey_CmdArgs                          = \"cmd:args\"\n\tMetaKey_CmdShell                         = \"cmd:shell\"\n\tMetaKey_CmdAllowConnChange               = \"cmd:allowconnchange\"\n\tMetaKey_CmdJwt                           = \"cmd:jwt\"\n\tMetaKey_CmdEnv                           = \"cmd:env\"\n\tMetaKey_CmdCwd                           = \"cmd:cwd\"\n\tMetaKey_CmdInitScript                    = \"cmd:initscript\"\n\tMetaKey_CmdInitScriptSh                  = \"cmd:initscript.sh\"\n\tMetaKey_CmdInitScriptBash                = \"cmd:initscript.bash\"\n\tMetaKey_CmdInitScriptZsh                 = \"cmd:initscript.zsh\"\n\tMetaKey_CmdInitScriptPwsh                = \"cmd:initscript.pwsh\"\n\tMetaKey_CmdInitScriptFish                = \"cmd:initscript.fish\"\n\n\tMetaKey_AiClear                          = \"ai:*\"\n\tMetaKey_AiPresetKey                      = \"ai:preset\"\n\tMetaKey_AiApiType                        = \"ai:apitype\"\n\tMetaKey_AiBaseURL                        = \"ai:baseurl\"\n\tMetaKey_AiApiToken                       = \"ai:apitoken\"\n\tMetaKey_AiName                           = \"ai:name\"\n\tMetaKey_AiModel                          = \"ai:model\"\n\tMetaKey_AiOrgID                          = \"ai:orgid\"\n\tMetaKey_AIApiVersion                     = \"ai:apiversion\"\n\tMetaKey_AiMaxTokens                      = \"ai:maxtokens\"\n\tMetaKey_AiTimeoutMs                      = \"ai:timeoutms\"\n\n\tMetaKey_AiFileDiffChatId                 = \"aifilediff:chatid\"\n\tMetaKey_AiFileDiffToolCallId             = \"aifilediff:toolcallid\"\n\n\tMetaKey_EditorClear                      = \"editor:*\"\n\tMetaKey_EditorMinimapEnabled             = \"editor:minimapenabled\"\n\tMetaKey_EditorStickyScrollEnabled        = \"editor:stickyscrollenabled\"\n\tMetaKey_EditorWordWrap                   = \"editor:wordwrap\"\n\tMetaKey_EditorFontSize                   = \"editor:fontsize\"\n\n\tMetaKey_GraphClear                       = \"graph:*\"\n\tMetaKey_GraphNumPoints                   = \"graph:numpoints\"\n\tMetaKey_GraphMetrics                     = \"graph:metrics\"\n\n\tMetaKey_SysinfoType                      = \"sysinfo:type\"\n\n\tMetaKey_TabFlagColor                     = \"tab:flagcolor\"\n\n\tMetaKey_BgClear                          = \"bg:*\"\n\tMetaKey_Bg                               = \"bg\"\n\tMetaKey_BgOpacity                        = \"bg:opacity\"\n\tMetaKey_BgBlendMode                      = \"bg:blendmode\"\n\tMetaKey_BgBorderColor                    = \"bg:bordercolor\"\n\tMetaKey_BgActiveBorderColor              = \"bg:activebordercolor\"\n\n\tMetaKey_LayoutVTabBarWidth               = \"layout:vtabbarwidth\"\n\n\tMetaKey_WaveAiPanelOpen                  = \"waveai:panelopen\"\n\tMetaKey_WaveAiPanelWidth                 = \"waveai:panelwidth\"\n\tMetaKey_WaveAiModel                      = \"waveai:model\"\n\tMetaKey_WaveAiChatId                     = \"waveai:chatid\"\n\tMetaKey_WaveAiWidgetContext              = \"waveai:widgetcontext\"\n\n\tMetaKey_TermClear                        = \"term:*\"\n\tMetaKey_TermFontSize                     = \"term:fontsize\"\n\tMetaKey_TermFontFamily                   = \"term:fontfamily\"\n\tMetaKey_TermMode                         = \"term:mode\"\n\tMetaKey_TermTheme                        = \"term:theme\"\n\tMetaKey_TermLocalShellPath               = \"term:localshellpath\"\n\tMetaKey_TermLocalShellOpts               = \"term:localshellopts\"\n\tMetaKey_TermScrollback                   = \"term:scrollback\"\n\tMetaKey_TermVDomSubBlockId               = \"term:vdomblockid\"\n\tMetaKey_TermVDomToolbarBlockId           = \"term:vdomtoolbarblockid\"\n\tMetaKey_TermTransparency                 = \"term:transparency\"\n\tMetaKey_TermAllowBracketedPaste          = \"term:allowbracketedpaste\"\n\tMetaKey_TermShiftEnterNewline            = \"term:shiftenternewline\"\n\tMetaKey_TermMacOptionIsMeta              = \"term:macoptionismeta\"\n\tMetaKey_TermCursor                       = \"term:cursor\"\n\tMetaKey_TermCursorBlink                  = \"term:cursorblink\"\n\tMetaKey_TermConnDebug                    = \"term:conndebug\"\n\tMetaKey_TermBellSound                    = \"term:bellsound\"\n\tMetaKey_TermBellIndicator                = \"term:bellindicator\"\n\tMetaKey_TermOsc52                        = \"term:osc52\"\n\tMetaKey_TermDurable                      = \"term:durable\"\n\n\tMetaKey_WebZoom                          = \"web:zoom\"\n\tMetaKey_WebHideNav                       = \"web:hidenav\"\n\tMetaKey_WebPartition                     = \"web:partition\"\n\tMetaKey_WebUserAgentType                 = \"web:useragenttype\"\n\n\tMetaKey_MarkdownFontSize                 = \"markdown:fontsize\"\n\tMetaKey_MarkdownFixedFontSize            = \"markdown:fixedfontsize\"\n\n\tMetaKey_TsunamiClear                     = \"tsunami:*\"\n\tMetaKey_TsunamiSdkReplacePath            = \"tsunami:sdkreplacepath\"\n\tMetaKey_TsunamiAppPath                   = \"tsunami:apppath\"\n\tMetaKey_TsunamiAppId                     = \"tsunami:appid\"\n\tMetaKey_TsunamiScaffoldPath              = \"tsunami:scaffoldpath\"\n\tMetaKey_TsunamiEnv                       = \"tsunami:env\"\n\n\tMetaKey_VDomClear                        = \"vdom:*\"\n\tMetaKey_VDomInitialized                  = \"vdom:initialized\"\n\tMetaKey_VDomCorrelationId                = \"vdom:correlationid\"\n\tMetaKey_VDomRoute                        = \"vdom:route\"\n\tMetaKey_VDomPersist                      = \"vdom:persist\"\n\n\tMetaKey_OnboardingGithubStar             = \"onboarding:githubstar\"\n\tMetaKey_OnboardingLastVersion            = \"onboarding:lastversion\"\n\n\tMetaKey_Count                            = \"count\"\n)\n\n"
  },
  {
    "path": "pkg/waveobj/metamap.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveobj\n\nimport \"github.com/google/uuid\"\n\ntype MetaMapType map[string]any\n\nvar MetaMap_DeleteSentinel = uuid.NewString()\n\nfunc (m MetaMapType) GetString(key string, def string) string {\n\tif v, ok := m[key]; ok {\n\t\tif s, ok := v.(string); ok {\n\t\t\treturn s\n\t\t}\n\t}\n\treturn def\n}\n\nfunc (m MetaMapType) HasKey(key string) bool {\n\t_, ok := m[key]\n\treturn ok\n}\n\nfunc (m MetaMapType) GetConnectionOverride(connName string) MetaMapType {\n\tv, ok := m[\"[\"+connName+\"]\"]\n\tif !ok {\n\t\treturn nil\n\t}\n\tif mval, ok := v.(map[string]any); ok {\n\t\treturn MetaMapType(mval)\n\t}\n\treturn nil\n}\n\nfunc (m MetaMapType) GetStringList(key string) []string {\n\tv, ok := m[key]\n\tif !ok {\n\t\treturn nil\n\t}\n\tvarr, ok := v.([]any)\n\tif !ok {\n\t\treturn nil\n\t}\n\trtn := make([]string, 0)\n\tfor _, varrVal := range varr {\n\t\tif s, ok := varrVal.(string); ok {\n\t\t\trtn = append(rtn, s)\n\t\t}\n\t}\n\treturn rtn\n}\n\nfunc (m MetaMapType) GetStringMap(key string, useDeleteSentinel bool) map[string]string {\n\tmval := m.GetMap(key)\n\tif len(mval) == 0 {\n\t\treturn nil\n\t}\n\trtn := make(map[string]string, len(mval))\n\tfor k, v := range mval {\n\t\tif v == nil {\n\t\t\tif useDeleteSentinel {\n\t\t\t\trtn[k] = MetaMap_DeleteSentinel\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif s, ok := v.(string); ok {\n\t\t\trtn[k] = s\n\t\t}\n\t}\n\treturn rtn\n}\n\nfunc (m MetaMapType) GetBool(key string, def bool) bool {\n\tif v, ok := m[key]; ok {\n\t\tif b, ok := v.(bool); ok {\n\t\t\treturn b\n\t\t}\n\t}\n\treturn def\n}\n\nfunc (m MetaMapType) GetInt(key string, def int) int {\n\tif v, ok := m[key]; ok {\n\t\tif fval, ok := v.(float64); ok {\n\t\t\treturn int(fval)\n\t\t}\n\t}\n\treturn def\n}\n\nfunc (m MetaMapType) GetFloat(key string, def float64) float64 {\n\tif v, ok := m[key]; ok {\n\t\tif fval, ok := v.(float64); ok {\n\t\t\treturn fval\n\t\t}\n\t}\n\treturn def\n}\n\nfunc (m MetaMapType) GetMap(key string) MetaMapType {\n\tif v, ok := m[key]; ok {\n\t\tif mval, ok := v.(map[string]any); ok {\n\t\t\treturn MetaMapType(mval)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (m MetaMapType) GetArray(key string) []any {\n\tif v, ok := m[key]; ok {\n\t\tif aval, ok := v.([]any); ok {\n\t\t\treturn aval\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (m MetaMapType) GetStringArray(key string) []string {\n\tarr := m.GetArray(key)\n\tif len(arr) == 0 {\n\t\treturn nil\n\t}\n\trtn := make([]string, 0, len(arr))\n\tfor _, v := range arr {\n\t\tif s, ok := v.(string); ok {\n\t\t\trtn = append(rtn, s)\n\t\t}\n\t}\n\treturn rtn\n}\n"
  },
  {
    "path": "pkg/waveobj/objrtinfo.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveobj\n\ntype ObjRTInfo struct {\n\tTsunamiAppMeta any `json:\"tsunami:appmeta,omitempty\" tstype:\"AppMeta\"`\n\tTsunamiSchemas any `json:\"tsunami:schemas,omitempty\"`\n\n\tShellHasCurCwd       bool   `json:\"shell:hascurcwd,omitempty\"`\n\tShellState           string `json:\"shell:state,omitempty\"`\n\tShellType            string `json:\"shell:type,omitempty\"`\n\tShellVersion         string `json:\"shell:version,omitempty\"`\n\tShellUname           string `json:\"shell:uname,omitempty\"`\n\tShellIntegration     bool   `json:\"shell:integration,omitempty\"`\n\tShellOmz             bool   `json:\"shell:omz,omitempty\"`\n\tShellComp            string `json:\"shell:comp,omitempty\"`\n\tShellInputEmpty      bool   `json:\"shell:inputempty,omitempty\"`\n\tShellLastCmd         string `json:\"shell:lastcmd,omitempty\"`\n\tShellLastCmdExitCode int    `json:\"shell:lastcmdexitcode,omitempty\"`\n\n\tBuilderLayout map[string]float64 `json:\"builder:layout,omitempty\"`\n\tBuilderAppId  string             `json:\"builder:appid,omitempty\"`\n\tBuilderEnv    map[string]string  `json:\"builder:env,omitempty\"`\n\n\tWaveAIChatId          string `json:\"waveai:chatid,omitempty\"`\n\tWaveAIMode            string `json:\"waveai:mode,omitempty\"`\n\tWaveAIMaxOutputTokens int    `json:\"waveai:maxoutputtokens,omitempty\"`\n}\n"
  },
  {
    "path": "pkg/waveobj/waveobj.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveobj\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/mitchellh/mapstructure\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n)\n\nconst (\n\tOTypeKeyName   = \"otype\"\n\tOIDKeyName     = \"oid\"\n\tVersionKeyName = \"version\"\n\tMetaKeyName    = \"meta\"\n\n\tOIDGoFieldName     = \"OID\"\n\tVersionGoFieldName = \"Version\"\n\tMetaGoFieldName    = \"Meta\"\n)\n\ntype ORef struct {\n\t// special JSON marshalling to string\n\tOType string `json:\"otype\" mapstructure:\"otype\"`\n\tOID   string `json:\"oid\" mapstructure:\"oid\"`\n}\n\nfunc (oref ORef) String() string {\n\tif oref.OType == \"\" || oref.OID == \"\" {\n\t\treturn \"\"\n\t}\n\treturn fmt.Sprintf(\"%s:%s\", oref.OType, oref.OID)\n}\n\nfunc (oref ORef) MarshalJSON() ([]byte, error) {\n\treturn json.Marshal(oref.String())\n}\n\nfunc (oref ORef) IsEmpty() bool {\n\t// either being empty is not valid\n\treturn oref.OType == \"\" || oref.OID == \"\"\n}\n\nfunc (oref *ORef) UnmarshalJSON(data []byte) error {\n\tvar orefStr string\n\terr := json.Unmarshal(data, &orefStr)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(orefStr) == 0 {\n\t\toref.OType = \"\"\n\t\toref.OID = \"\"\n\t\treturn nil\n\t}\n\tparsed, err := ParseORef(orefStr)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*oref = parsed\n\treturn nil\n}\n\nfunc MakeORef(otype string, oid string) ORef {\n\treturn ORef{\n\t\tOType: otype,\n\t\tOID:   oid,\n\t}\n}\n\nvar otypeRe = regexp.MustCompile(`^[a-z]+$`)\n\nfunc ParseORef(orefStr string) (ORef, error) {\n\tfields := strings.Split(orefStr, \":\")\n\tif len(fields) != 2 {\n\t\treturn ORef{}, fmt.Errorf(\"invalid object reference: %q\", orefStr)\n\t}\n\totype := fields[0]\n\tif !otypeRe.MatchString(otype) {\n\t\treturn ORef{}, fmt.Errorf(\"invalid object type: %q\", otype)\n\t}\n\tif !ValidOTypes[otype] {\n\t\treturn ORef{}, fmt.Errorf(\"unknown object type: %q\", otype)\n\t}\n\toid := fields[1]\n\t_, err := uuid.Parse(oid)\n\tif err != nil {\n\t\treturn ORef{}, fmt.Errorf(\"invalid object id: %q\", oid)\n\t}\n\treturn ORef{OType: otype, OID: oid}, nil\n}\n\nfunc ParseORefNoErr(orefStr string) *ORef {\n\toref, err := ParseORef(orefStr)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &oref\n}\n\ntype WaveObj interface {\n\tGetOType() string // should not depend on object state (should work with nil value)\n}\n\ntype waveObjDesc struct {\n\tRType        reflect.Type\n\tOIDField     reflect.StructField\n\tVersionField reflect.StructField\n\tMetaField    reflect.StructField\n}\n\nvar waveObjMap = sync.Map{}\nvar waveObjRType = reflect.TypeOf((*WaveObj)(nil)).Elem()\nvar metaMapRType = reflect.TypeOf(MetaMapType{})\n\nfunc RegisterType(rtype reflect.Type) {\n\tif rtype.Kind() != reflect.Ptr {\n\t\tpanic(fmt.Sprintf(\"wave object must be a pointer for %v\", rtype))\n\t}\n\tif !rtype.Implements(waveObjRType) {\n\t\tpanic(fmt.Sprintf(\"wave object must implement WaveObj for %v\", rtype))\n\t}\n\twaveObj := reflect.Zero(rtype).Interface().(WaveObj)\n\totype := waveObj.GetOType()\n\tif otype == \"\" {\n\t\tpanic(fmt.Sprintf(\"otype is empty for %v\", rtype))\n\t}\n\toidField, found := rtype.Elem().FieldByName(OIDGoFieldName)\n\tif !found {\n\t\tpanic(fmt.Sprintf(\"missing OID field for %v\", rtype))\n\t}\n\tif oidField.Type.Kind() != reflect.String {\n\t\tpanic(fmt.Sprintf(\"OID field must be string for %v\", rtype))\n\t}\n\toidJsonTag := utilfn.GetJsonTag(oidField)\n\tif oidJsonTag != OIDKeyName {\n\t\tpanic(fmt.Sprintf(\"OID field json tag must be %q for %v\", OIDKeyName, rtype))\n\t}\n\tversionField, found := rtype.Elem().FieldByName(VersionGoFieldName)\n\tif !found {\n\t\tpanic(fmt.Sprintf(\"missing Version field for %v\", rtype))\n\t}\n\tif versionField.Type.Kind() != reflect.Int {\n\t\tpanic(fmt.Sprintf(\"Version field must be int for %v\", rtype))\n\t}\n\tversionJsonTag := utilfn.GetJsonTag(versionField)\n\tif versionJsonTag != VersionKeyName {\n\t\tpanic(fmt.Sprintf(\"Version field json tag must be %q for %v\", VersionKeyName, rtype))\n\t}\n\tmetaField, found := rtype.Elem().FieldByName(MetaGoFieldName)\n\tif !found {\n\t\tpanic(fmt.Sprintf(\"missing Meta field for %v\", rtype))\n\t}\n\tif metaField.Type != metaMapRType {\n\t\tpanic(fmt.Sprintf(\"Meta field must be MetaMapType for %v\", rtype))\n\t}\n\t_, found = waveObjMap.Load(otype)\n\tif found {\n\t\tpanic(fmt.Sprintf(\"otype %q already registered\", otype))\n\t}\n\twaveObjMap.Store(otype, &waveObjDesc{\n\t\tRType:        rtype,\n\t\tOIDField:     oidField,\n\t\tVersionField: versionField,\n\t\tMetaField:    metaField,\n\t})\n}\n\nfunc getWaveObjDesc(otype string) *waveObjDesc {\n\tdesc, _ := waveObjMap.Load(otype)\n\tif desc == nil {\n\t\treturn nil\n\t}\n\treturn desc.(*waveObjDesc)\n}\n\nfunc GetOID(waveObj WaveObj) string {\n\tdesc := getWaveObjDesc(waveObj.GetOType())\n\tif desc == nil {\n\t\treturn \"\"\n\t}\n\treturn reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.OIDField.Index).String()\n}\n\nfunc SetOID(waveObj WaveObj, oid string) {\n\tdesc := getWaveObjDesc(waveObj.GetOType())\n\tif desc == nil {\n\t\treturn\n\t}\n\treflect.ValueOf(waveObj).Elem().FieldByIndex(desc.OIDField.Index).SetString(oid)\n}\n\nfunc GetVersion(waveObj WaveObj) int {\n\tdesc := getWaveObjDesc(waveObj.GetOType())\n\tif desc == nil {\n\t\treturn 0\n\t}\n\treturn int(reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.VersionField.Index).Int())\n}\n\nfunc SetVersion(waveObj WaveObj, version int) {\n\tdesc := getWaveObjDesc(waveObj.GetOType())\n\tif desc == nil {\n\t\treturn\n\t}\n\treflect.ValueOf(waveObj).Elem().FieldByIndex(desc.VersionField.Index).SetInt(int64(version))\n}\n\nfunc GetMeta(waveObj WaveObj) MetaMapType {\n\tdesc := getWaveObjDesc(waveObj.GetOType())\n\tif desc == nil {\n\t\treturn nil\n\t}\n\tmval := reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.MetaField.Index).Interface()\n\tif mval == nil {\n\t\treturn nil\n\t}\n\treturn mval.(MetaMapType)\n}\n\nfunc SetMeta(waveObj WaveObj, meta map[string]any) {\n\tdesc := getWaveObjDesc(waveObj.GetOType())\n\tif desc == nil {\n\t\treturn\n\t}\n\treflect.ValueOf(waveObj).Elem().FieldByIndex(desc.MetaField.Index).Set(reflect.ValueOf(meta))\n}\n\nfunc ToJsonMap(w WaveObj) (map[string]any, error) {\n\tif w == nil {\n\t\treturn nil, nil\n\t}\n\tm := make(map[string]any)\n\tdconfig := &mapstructure.DecoderConfig{\n\t\tResult:  &m,\n\t\tTagName: \"json\",\n\t}\n\tdecoder, err := mapstructure.NewDecoder(dconfig)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\terr = decoder.Decode(w)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tm[OTypeKeyName] = w.GetOType()\n\tm[OIDKeyName] = GetOID(w)\n\tm[VersionKeyName] = GetVersion(w)\n\treturn m, nil\n}\n\nfunc ToJson(w WaveObj) ([]byte, error) {\n\tm, err := ToJsonMap(w)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn json.Marshal(m)\n}\n\nfunc FromJson(data []byte) (WaveObj, error) {\n\tvar m map[string]any\n\terr := json.Unmarshal(data, &m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn FromJsonMap(m)\n}\n\nfunc FromJsonMap(m map[string]any) (WaveObj, error) {\n\totype, ok := m[OTypeKeyName].(string)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"missing otype\")\n\t}\n\tdesc := getWaveObjDesc(otype)\n\tif desc == nil {\n\t\treturn nil, fmt.Errorf(\"unknown otype: %s\", otype)\n\t}\n\twobj := reflect.Zero(desc.RType).Interface().(WaveObj)\n\tdconfig := &mapstructure.DecoderConfig{\n\t\tResult:  &wobj,\n\t\tTagName: \"json\",\n\t}\n\tdecoder, err := mapstructure.NewDecoder(dconfig)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\terr = decoder.Decode(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn wobj, nil\n}\n\nfunc ORefFromMap(m map[string]any) (*ORef, error) {\n\toref := ORef{}\n\terr := mapstructure.Decode(m, &oref)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &oref, nil\n}\n\nfunc ORefFromWaveObj(w WaveObj) *ORef {\n\treturn &ORef{\n\t\tOType: w.GetOType(),\n\t\tOID:   GetOID(w),\n\t}\n}\n\nfunc FromJsonGen[T WaveObj](data []byte) (T, error) {\n\tobj, err := FromJson(data)\n\tif err != nil {\n\t\tvar zero T\n\t\treturn zero, err\n\t}\n\trtn, ok := obj.(T)\n\tif !ok {\n\t\tvar zero T\n\t\treturn zero, fmt.Errorf(\"type mismatch got %T, expected %T\", obj, zero)\n\t}\n\treturn rtn, nil\n}\n"
  },
  {
    "path": "pkg/waveobj/wtype.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveobj\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"reflect\"\n)\n\ntype UpdatesRtnType = []WaveObjUpdate\n\ntype UIContext struct {\n\tWindowId    string `json:\"windowid\"`\n\tActiveTabId string `json:\"activetabid\"`\n}\n\nconst (\n\tUpdateType_Update = \"update\"\n\tUpdateType_Delete = \"delete\"\n)\n\nconst (\n\tOType_Client      = \"client\"\n\tOType_Window      = \"window\"\n\tOType_Workspace   = \"workspace\"\n\tOType_Tab         = \"tab\"\n\tOType_LayoutState = \"layout\"\n\tOType_Block       = \"block\"\n\tOType_MainServer  = \"mainserver\"\n\tOType_Job         = \"job\"\n\tOType_Temp        = \"temp\"\n\tOType_Builder     = \"builder\" // not persisted to DB\n)\n\nvar ValidOTypes = map[string]bool{\n\tOType_Client:      true,\n\tOType_Window:      true,\n\tOType_Workspace:   true,\n\tOType_Tab:         true,\n\tOType_LayoutState: true,\n\tOType_Block:       true,\n\tOType_MainServer:  true,\n\tOType_Job:         true,\n\tOType_Temp:        true,\n\tOType_Builder:     true,\n}\n\ntype WaveObjUpdate struct {\n\tUpdateType string  `json:\"updatetype\"`\n\tOType      string  `json:\"otype\"`\n\tOID        string  `json:\"oid\"`\n\tObj        WaveObj `json:\"obj,omitempty\"`\n}\n\nfunc (update WaveObjUpdate) MarshalJSON() ([]byte, error) {\n\trtn := make(map[string]any)\n\trtn[\"updatetype\"] = update.UpdateType\n\trtn[\"otype\"] = update.OType\n\trtn[\"oid\"] = update.OID\n\tif update.Obj != nil {\n\t\tvar err error\n\t\trtn[\"obj\"], err = ToJsonMap(update.Obj)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\treturn json.Marshal(rtn)\n}\n\nfunc MakeUpdate(obj WaveObj) WaveObjUpdate {\n\treturn WaveObjUpdate{\n\t\tUpdateType: UpdateType_Update,\n\t\tOType:      obj.GetOType(),\n\t\tOID:        GetOID(obj),\n\t\tObj:        obj,\n\t}\n}\n\nfunc MakeUpdates(objs []WaveObj) []WaveObjUpdate {\n\trtn := make([]WaveObjUpdate, 0, len(objs))\n\tfor _, obj := range objs {\n\t\trtn = append(rtn, MakeUpdate(obj))\n\t}\n\treturn rtn\n}\n\nfunc (update *WaveObjUpdate) UnmarshalJSON(data []byte) error {\n\tvar objMap map[string]any\n\terr := json.Unmarshal(data, &objMap)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar ok1, ok2, ok3 bool\n\tif _, found := objMap[\"updatetype\"]; !found {\n\t\treturn fmt.Errorf(\"missing updatetype (in WaveObjUpdate)\")\n\t}\n\tupdate.UpdateType, ok1 = objMap[\"updatetype\"].(string)\n\tif !ok1 {\n\t\treturn fmt.Errorf(\"in WaveObjUpdate bad updatetype type %T\", objMap[\"updatetype\"])\n\t}\n\tif _, found := objMap[\"otype\"]; !found {\n\t\treturn fmt.Errorf(\"missing otype (in WaveObjUpdate)\")\n\t}\n\tupdate.OType, ok2 = objMap[\"otype\"].(string)\n\tif !ok2 {\n\t\treturn fmt.Errorf(\"in WaveObjUpdate bad otype type %T\", objMap[\"otype\"])\n\t}\n\tif _, found := objMap[\"oid\"]; !found {\n\t\treturn fmt.Errorf(\"missing oid (in WaveObjUpdate)\")\n\t}\n\tupdate.OID, ok3 = objMap[\"oid\"].(string)\n\tif !ok3 {\n\t\treturn fmt.Errorf(\"in WaveObjUpdate bad oid type %T\", objMap[\"oid\"])\n\t}\n\tif _, found := objMap[\"obj\"]; found {\n\t\tobjMap, ok := objMap[\"obj\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"in WaveObjUpdate bad obj type %T\", objMap[\"obj\"])\n\t\t}\n\t\twaveObj, err := FromJsonMap(objMap)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"in WaveObjUpdate error decoding obj: %w\", err)\n\t\t}\n\t\tupdate.Obj = waveObj\n\t}\n\treturn nil\n}\n\ntype Client struct {\n\tOID           string      `json:\"oid\"`\n\tVersion       int         `json:\"version\"`\n\tWindowIds     []string    `json:\"windowids\"`\n\tMeta          MetaMapType `json:\"meta\"`\n\tTosAgreed     int64       `json:\"tosagreed,omitempty\"` // unix milli\n\tHasOldHistory bool        `json:\"hasoldhistory,omitempty\"`\n\tTempOID       string      `json:\"tempoid,omitempty\"`\n\tInstallId     string      `json:\"installid,omitempty\"`\n}\n\nfunc (*Client) GetOType() string {\n\treturn OType_Client\n}\n\n// stores the ui-context of the window, points to a workspace containing the actual data being displayed in the window\ntype Window struct {\n\tOID         string      `json:\"oid\"`\n\tVersion     int         `json:\"version\"`\n\tWorkspaceId string      `json:\"workspaceid\"`\n\tIsNew       bool        `json:\"isnew,omitempty\"` // set when a window is created on the backend so the FE can size it properly.  cleared on first resize\n\tPos         Point       `json:\"pos\"`\n\tWinSize     WinSize     `json:\"winsize\"`\n\tLastFocusTs int64       `json:\"lastfocusts\"`\n\tMeta        MetaMapType `json:\"meta\"`\n}\n\nfunc (*Window) GetOType() string {\n\treturn OType_Window\n}\n\ntype WorkspaceListEntry struct {\n\tWorkspaceId string `json:\"workspaceid\"`\n\tWindowId    string `json:\"windowid\"`\n}\n\ntype WorkspaceList []*WorkspaceListEntry\n\ntype ActiveTabUpdate struct {\n\tWorkspaceId    string `json:\"workspaceid\"`\n\tNewActiveTabId string `json:\"newactivetabid\"`\n}\n\ntype Workspace struct {\n\tOID         string      `json:\"oid\"`\n\tVersion     int         `json:\"version\"`\n\tName        string      `json:\"name,omitempty\"`\n\tIcon        string      `json:\"icon,omitempty\"`\n\tColor       string      `json:\"color,omitempty\"`\n\tTabIds      []string    `json:\"tabids\"`\n\tActiveTabId string      `json:\"activetabid\"`\n\tMeta        MetaMapType `json:\"meta\"`\n}\n\nfunc (*Workspace) GetOType() string {\n\treturn OType_Workspace\n}\n\ntype Tab struct {\n\tOID         string      `json:\"oid\"`\n\tVersion     int         `json:\"version\"`\n\tName        string      `json:\"name\"`\n\tLayoutState string      `json:\"layoutstate\"`\n\tBlockIds    []string    `json:\"blockids\"`\n\tMeta        MetaMapType `json:\"meta\"`\n}\n\nfunc (*Tab) GetOType() string {\n\treturn OType_Tab\n}\n\nfunc (t *Tab) GetBlockORefs() []ORef {\n\trtn := make([]ORef, 0, len(t.BlockIds))\n\tfor _, blockId := range t.BlockIds {\n\t\trtn = append(rtn, ORef{OType: OType_Block, OID: blockId})\n\t}\n\treturn rtn\n}\n\ntype LayoutActionData struct {\n\tActionType    string `json:\"actiontype\"`\n\tActionId      string `json:\"actionid\"`\n\tBlockId       string `json:\"blockid\"`\n\tNodeSize      *uint  `json:\"nodesize,omitempty\"`\n\tIndexArr      *[]int `json:\"indexarr,omitempty\"`\n\tFocused       bool   `json:\"focused\"`\n\tMagnified     bool   `json:\"magnified\"`\n\tEphemeral     bool   `json:\"ephemeral\"`\n\tTargetBlockId string `json:\"targetblockid,omitempty\"`\n\tPosition      string `json:\"position,omitempty\"`\n}\n\ntype LeafOrderEntry struct {\n\tNodeId  string `json:\"nodeid\"`\n\tBlockId string `json:\"blockid\"`\n}\n\ntype LayoutState struct {\n\tOID                   string              `json:\"oid\"`\n\tVersion               int                 `json:\"version\"`\n\tRootNode              any                 `json:\"rootnode,omitempty\"`\n\tMagnifiedNodeId       string              `json:\"magnifiednodeid,omitempty\"`\n\tFocusedNodeId         string              `json:\"focusednodeid,omitempty\"`\n\tLeafOrder             *[]LeafOrderEntry   `json:\"leaforder,omitempty\"`\n\tPendingBackendActions *[]LayoutActionData `json:\"pendingbackendactions,omitempty\"`\n\tMeta                  MetaMapType         `json:\"meta,omitempty\"`\n}\n\nfunc (*LayoutState) GetOType() string {\n\treturn OType_LayoutState\n}\n\ntype FileDef struct {\n\tContent string         `json:\"content,omitempty\"`\n\tMeta    map[string]any `json:\"meta,omitempty\"`\n}\n\ntype BlockDef struct {\n\tFiles map[string]*FileDef `json:\"files,omitempty\"`\n\tMeta  MetaMapType         `json:\"meta,omitempty\"`\n}\n\ntype StickerClickOptsType struct {\n\tSendInput   string    `json:\"sendinput,omitempty\"`\n\tCreateBlock *BlockDef `json:\"createblock,omitempty\"`\n}\n\ntype StickerDisplayOptsType struct {\n\tIcon    string `json:\"icon\"`\n\tImgSrc  string `json:\"imgsrc\"`\n\tSvgBlob string `json:\"svgblob,omitempty\"`\n}\n\ntype StickerType struct {\n\tStickerType string                  `json:\"stickertype\"`\n\tStyle       map[string]any          `json:\"style\"`\n\tClickOpts   *StickerClickOptsType   `json:\"clickopts,omitempty\"`\n\tDisplay     *StickerDisplayOptsType `json:\"display\"`\n}\n\ntype RuntimeOpts struct {\n\tTermSize TermSize `json:\"termsize,omitempty\"`\n\tWinSize  WinSize  `json:\"winsize,omitempty\"`\n}\n\ntype Point struct {\n\tX int `json:\"x\"`\n\tY int `json:\"y\"`\n}\n\ntype WinSize struct {\n\tWidth  int `json:\"width\"`\n\tHeight int `json:\"height\"`\n}\n\ntype Block struct {\n\tOID         string         `json:\"oid\"`\n\tParentORef  string         `json:\"parentoref,omitempty\"`\n\tVersion     int            `json:\"version\"`\n\tRuntimeOpts *RuntimeOpts   `json:\"runtimeopts,omitempty\"`\n\tStickers    []*StickerType `json:\"stickers,omitempty\"`\n\tMeta        MetaMapType    `json:\"meta\"`\n\tSubBlockIds []string       `json:\"subblockids,omitempty\"`\n\tJobId       string         `json:\"jobid,omitempty\"` // if set, the block will render this jobid's pty output\n}\n\nfunc (*Block) GetOType() string {\n\treturn OType_Block\n}\n\ntype MainServer struct {\n\tOID           string      `json:\"oid\"`\n\tVersion       int         `json:\"version\"`\n\tMeta          MetaMapType `json:\"meta\"`\n\tJwtPrivateKey string      `json:\"jwtprivatekey\"` // base64\n\tJwtPublicKey  string      `json:\"jwtpublickey\"`  // base64\n}\n\nfunc (*MainServer) GetOType() string {\n\treturn OType_MainServer\n}\n\ntype Job struct {\n\tOID     string `json:\"oid\"`\n\tVersion int    `json:\"version\"`\n\n\t// job metadata\n\tConnection      string            `json:\"connection\"`\n\tJobKind         string            `json:\"jobkind\"` // shell, task\n\tCmd             string            `json:\"cmd\"`\n\tCmdArgs         []string          `json:\"cmdargs,omitempty\"`\n\tCmdEnv          map[string]string `json:\"cmdenv,omitempty\"`\n\tJobAuthToken    string            `json:\"jobauthtoken\"` // job manger -> wave\n\tAttachedBlockId string            `json:\"attachedblockid,omitempty\"`\n\tWaveVersion     string            `json:\"waveversion,omitempty\"`\n\n\t// reconnect option (e.g. orphaned, so we need to kill on connect)\n\tTerminateOnReconnect bool `json:\"terminateonreconnect,omitempty\"`\n\n\t// job manager state\n\tJobManagerStatus       string `json:\"jobmanagerstatus\"`               // init, running, done\n\tJobManagerDoneReason   string `json:\"jobmanagerdonereason,omitempty\"` // startuperror, gone, terminated\n\tJobManagerStartupError string `json:\"jobmanagerstartuperror,omitempty\"`\n\tJobManagerPid          int    `json:\"jobmanagerpid,omitempty\"`\n\tJobManagerStartTs      int64  `json:\"jobmanagerstartts,omitempty\"` // exact process start time (milliseconds)\n\n\t// cmd/process runtime info\n\tCmdPid        int      `json:\"cmdpid,omitempty\"`     // command process id\n\tCmdStartTs    int64    `json:\"cmdstartts,omitempty\"` // exact command process start time (milliseconds from epoch)\n\tCmdTermSize   TermSize `json:\"cmdtermsize\"`\n\tCmdExitTs     int64    `json:\"cmdexitts,omitempty\"`     // timestamp (milliseconds) -- use CmdExitTs > 0 to check if command has exited\n\tCmdExitCode   *int     `json:\"cmdexitcode,omitempty\"`   // nil when CmdExitSignal is set.  success exit is when CmdExitCode is 0\n\tCmdExitSignal string   `json:\"cmdexitsignal,omitempty\"` // empty string if CmdExitCode is set\n\tCmdExitError  string   `json:\"cmdexiterror,omitempty\"`\n\n\t// output info\n\tStreamDone  bool   `json:\"streamdone,omitempty\"`\n\tStreamError string `json:\"streamerror,omitempty\"`\n\n\tMeta MetaMapType `json:\"meta\"`\n}\n\nfunc (*Job) GetOType() string {\n\treturn OType_Job\n}\n\nfunc AllWaveObjTypes() []reflect.Type {\n\treturn []reflect.Type{\n\t\treflect.TypeOf(&Client{}),\n\t\treflect.TypeOf(&Window{}),\n\t\treflect.TypeOf(&Workspace{}),\n\t\treflect.TypeOf(&Tab{}),\n\t\treflect.TypeOf(&Block{}),\n\t\treflect.TypeOf(&LayoutState{}),\n\t\treflect.TypeOf(&MainServer{}),\n\t\treflect.TypeOf(&Job{}),\n\t}\n}\n\ntype TermSize struct {\n\tRows int `json:\"rows\"`\n\tCols int `json:\"cols\"`\n}\n"
  },
  {
    "path": "pkg/waveobj/wtypemeta.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage waveobj\n\nimport (\n\t\"strings\"\n)\n\nconst Entity_Any = \"any\"\n\n// for typescript typing\ntype MetaTSType struct {\n\t// shared\n\tView           string   `json:\"view,omitempty\"`\n\tController     string   `json:\"controller,omitempty\"`\n\tFile           string   `json:\"file,omitempty\"`\n\tUrl            string   `json:\"url,omitempty\"`\n\tPinnedUrl      string   `json:\"pinnedurl,omitempty\"`\n\tConnection     string   `json:\"connection,omitempty\"`\n\tEdit           bool     `json:\"edit,omitempty\"`\n\tHistory        []string `json:\"history,omitempty\"`\n\tHistoryForward []string `json:\"history:forward,omitempty\"`\n\n\tDisplayName  string  `json:\"display:name,omitempty\"`\n\tDisplayOrder float64 `json:\"display:order,omitempty\"`\n\n\tIcon      string `json:\"icon,omitempty\"`\n\tIconColor string `json:\"icon:color,omitempty\"`\n\n\tFrameClear             bool   `json:\"frame:*,omitempty\"`\n\tFrame                  bool   `json:\"frame,omitempty\"`\n\tFrameBorderColor       string `json:\"frame:bordercolor,omitempty\"`\n\tFrameActiveBorderColor string `json:\"frame:activebordercolor,omitempty\"`\n\tFrameTitle             string `json:\"frame:title,omitempty\"`\n\tFrameIcon              string `json:\"frame:icon,omitempty\"`\n\tFrameText              string `json:\"frame:text,omitempty\"`\n\n\tCmdClear            bool     `json:\"cmd:*,omitempty\"`\n\tCmd                 string   `json:\"cmd,omitempty\"`\n\tCmdInteractive      bool     `json:\"cmd:interactive,omitempty\"`\n\tCmdLogin            bool     `json:\"cmd:login,omitempty\"`\n\tCmdPersistent       bool     `json:\"cmd:persistent,omitempty\"`\n\tCmdRunOnStart       bool     `json:\"cmd:runonstart,omitempty\"`\n\tCmdClearOnStart     bool     `json:\"cmd:clearonstart,omitempty\"`\n\tCmdRunOnce          bool     `json:\"cmd:runonce,omitempty\"`\n\tCmdCloseOnExit      bool     `json:\"cmd:closeonexit,omitempty\"`\n\tCmdCloseOnExitForce bool     `json:\"cmd:closeonexitforce,omitempty\"`\n\tCmdCloseOnExitDelay float64  `json:\"cmd:closeonexitdelay,omitempty\"`\n\tCmdNoWsh            bool     `json:\"cmd:nowsh,omitempty\"`\n\tCmdArgs             []string `json:\"cmd:args,omitempty\"`  // args for cmd (only if cmd:shell is false)\n\tCmdShell            bool     `json:\"cmd:shell,omitempty\"` // shell expansion for cmd+args (defaults to true)\n\tCmdAllowConnChange  bool     `json:\"cmd:allowconnchange,omitempty\"`\n\tCmdJwt              bool     `json:\"cmd:jwt,omitempty\"` // force adding JWT to environment\n\n\t// these can be nested under \"[conn]\"\n\tCmdEnv            map[string]string `json:\"cmd:env,omitempty\"`\n\tCmdCwd            string            `json:\"cmd:cwd,omitempty\"`\n\tCmdInitScript     string            `json:\"cmd:initscript,omitempty\"`\n\tCmdInitScriptSh   string            `json:\"cmd:initscript.sh,omitempty\"`\n\tCmdInitScriptBash string            `json:\"cmd:initscript.bash,omitempty\"`\n\tCmdInitScriptZsh  string            `json:\"cmd:initscript.zsh,omitempty\"`\n\tCmdInitScriptPwsh string            `json:\"cmd:initscript.pwsh,omitempty\"`\n\tCmdInitScriptFish string            `json:\"cmd:initscript.fish,omitempty\"`\n\n\t// AI options match settings\n\tAiClear      bool    `json:\"ai:*,omitempty\"`\n\tAiPresetKey  string  `json:\"ai:preset,omitempty\"`\n\tAiApiType    string  `json:\"ai:apitype,omitempty\"`\n\tAiBaseURL    string  `json:\"ai:baseurl,omitempty\"`\n\tAiApiToken   string  `json:\"ai:apitoken,omitempty\"`\n\tAiName       string  `json:\"ai:name,omitempty\"`\n\tAiModel      string  `json:\"ai:model,omitempty\"`\n\tAiOrgID      string  `json:\"ai:orgid,omitempty\"`\n\tAIApiVersion string  `json:\"ai:apiversion,omitempty\"`\n\tAiMaxTokens  float64 `json:\"ai:maxtokens,omitempty\"`\n\tAiTimeoutMs  float64 `json:\"ai:timeoutms,omitempty\"`\n\n\tAiFileDiffChatId     string `json:\"aifilediff:chatid,omitempty\"`\n\tAiFileDiffToolCallId string `json:\"aifilediff:toolcallid,omitempty\"`\n\n\tEditorClear               bool    `json:\"editor:*,omitempty\"`\n\tEditorMinimapEnabled      bool    `json:\"editor:minimapenabled,omitempty\"`\n\tEditorStickyScrollEnabled bool    `json:\"editor:stickyscrollenabled,omitempty\"`\n\tEditorWordWrap            bool    `json:\"editor:wordwrap,omitempty\"`\n\tEditorFontSize            float64 `json:\"editor:fontsize,omitempty\"`\n\n\tGraphClear     bool     `json:\"graph:*,omitempty\"`\n\tGraphNumPoints int      `json:\"graph:numpoints,omitempty\"`\n\tGraphMetrics   []string `json:\"graph:metrics,omitempty\"`\n\n\tSysinfoType string `json:\"sysinfo:type,omitempty\"`\n\n\t// for tabs\n\tTabFlagColor        string  `json:\"tab:flagcolor,omitempty\"`\n\tBgClear             bool    `json:\"bg:*,omitempty\"`\n\tBg                  string  `json:\"bg,omitempty\"`\n\tBgOpacity           float64 `json:\"bg:opacity,omitempty\"`\n\tBgBlendMode         string  `json:\"bg:blendmode,omitempty\"`\n\tBgBorderColor       string  `json:\"bg:bordercolor,omitempty\"`       // frame:bordercolor\n\tBgActiveBorderColor string  `json:\"bg:activebordercolor,omitempty\"` // frame:activebordercolor\n\n\t// for workspace\n\tLayoutVTabBarWidth int `json:\"layout:vtabbarwidth,omitempty\"`\n\n\t// for tabs+waveai\n\tWaveAiPanelOpen     bool   `json:\"waveai:panelopen,omitempty\"`\n\tWaveAiPanelWidth    int    `json:\"waveai:panelwidth,omitempty\"`\n\tWaveAiModel         string `json:\"waveai:model,omitempty\"`\n\tWaveAiChatId        string `json:\"waveai:chatid,omitempty\"`\n\tWaveAiWidgetContext *bool  `json:\"waveai:widgetcontext,omitempty\"` // default is true\n\n\tTermClear               bool     `json:\"term:*,omitempty\"`\n\tTermFontSize            int      `json:\"term:fontsize,omitempty\"`\n\tTermFontFamily          string   `json:\"term:fontfamily,omitempty\"`\n\tTermMode                string   `json:\"term:mode,omitempty\"`\n\tTermTheme               string   `json:\"term:theme,omitempty\"`\n\tTermLocalShellPath      string   `json:\"term:localshellpath,omitempty\"` // matches settings\n\tTermLocalShellOpts      []string `json:\"term:localshellopts,omitempty\"` // matches settings\n\tTermScrollback          *int     `json:\"term:scrollback,omitempty\"`\n\tTermVDomSubBlockId      string   `json:\"term:vdomblockid,omitempty\"`\n\tTermVDomToolbarBlockId  string   `json:\"term:vdomtoolbarblockid,omitempty\"`\n\tTermTransparency        *float64 `json:\"term:transparency,omitempty\"` // default 0.5\n\tTermAllowBracketedPaste *bool    `json:\"term:allowbracketedpaste,omitempty\"`\n\tTermShiftEnterNewline   *bool    `json:\"term:shiftenternewline,omitempty\"`\n\tTermMacOptionIsMeta     *bool    `json:\"term:macoptionismeta,omitempty\"`\n\tTermCursor              string   `json:\"term:cursor,omitempty\"`\n\tTermCursorBlink         *bool    `json:\"term:cursorblink,omitempty\"`\n\tTermConnDebug           string   `json:\"term:conndebug,omitempty\"` // null, info, debug\n\tTermBellSound           *bool    `json:\"term:bellsound,omitempty\"`\n\tTermBellIndicator       *bool    `json:\"term:bellindicator,omitempty\"`\n\tTermOsc52               string   `json:\"term:osc52,omitempty\"`\n\tTermDurable             *bool    `json:\"term:durable,omitempty\"`\n\n\tWebZoom          float64 `json:\"web:zoom,omitempty\"`\n\tWebHideNav       *bool   `json:\"web:hidenav,omitempty\"`\n\tWebPartition     string  `json:\"web:partition,omitempty\"`\n\tWebUserAgentType string  `json:\"web:useragenttype,omitempty\"`\n\n\tMarkdownFontSize      float64 `json:\"markdown:fontsize,omitempty\"`\n\tMarkdownFixedFontSize float64 `json:\"markdown:fixedfontsize,omitempty\"`\n\n\tTsunamiClear          bool              `json:\"tsunami:*,omitempty\"`\n\tTsunamiSdkReplacePath string            `json:\"tsunami:sdkreplacepath,omitempty\"`\n\tTsunamiAppPath        string            `json:\"tsunami:apppath,omitempty\"`\n\tTsunamiAppId          string            `json:\"tsunami:appid,omitempty\"`\n\tTsunamiScaffoldPath   string            `json:\"tsunami:scaffoldpath,omitempty\"`\n\tTsunamiEnv            map[string]string `json:\"tsunami:env,omitempty\"`\n\n\tVDomClear         bool   `json:\"vdom:*,omitempty\"`\n\tVDomInitialized   bool   `json:\"vdom:initialized,omitempty\"`\n\tVDomCorrelationId string `json:\"vdom:correlationid,omitempty\"`\n\tVDomRoute         string `json:\"vdom:route,omitempty\"`\n\tVDomPersist       bool   `json:\"vdom:persist,omitempty\"`\n\n\tOnboardingGithubStar  bool   `json:\"onboarding:githubstar,omitempty\"`  // for client\n\tOnboardingLastVersion string `json:\"onboarding:lastversion,omitempty\"` // for client (tracks semver of last 'onboarding' shown)\n\n\tCount int `json:\"count,omitempty\"` // temp for cpu plot. will remove later\n}\n\ntype MetaDataDecl struct {\n\tKey        string   `json:\"key\"`\n\tDesc       string   `json:\"desc,omitempty\"`\n\tType       string   `json:\"type\"` // string, int, float, bool, array, object\n\tDefault    any      `json:\"default,omitempty\"`\n\tStrOptions []string `json:\"stroptions,omitempty\"`\n\tNumRange   []*int   `json:\"numrange,omitempty\"` // inclusive, null means no limit\n\tEntity     []string `json:\"entity\"`             // what entities this applies to, e.g. \"block\", \"tab\", \"any\", etc.\n\tSpecial    []string `json:\"special,omitempty\"`  // special handling.  things that need to happen if this gets updated\n}\n\ntype MetaPresetDecl struct {\n\tPreset string   `json:\"preset\"`\n\tDesc   string   `json:\"desc,omitempty\"`\n\tKeys   []string `json:\"keys\"`\n\tEntity []string `json:\"entity\"` // what entities this applies to, e.g. \"block\", \"tab\", etc.\n}\n\n// returns a clean copy of meta with mergeMeta merged in\n// if mergeSpecial is false, then special keys will not be merged (like display:*)\nfunc MergeMeta(meta MetaMapType, metaUpdate MetaMapType, mergeSpecial bool) MetaMapType {\n\trtn := make(MetaMapType)\n\tfor k, v := range meta {\n\t\trtn[k] = v\n\t}\n\t// deal with \"section:*\" keys\n\tfor k := range metaUpdate {\n\t\tif !strings.HasSuffix(k, \":*\") {\n\t\t\tcontinue\n\t\t}\n\t\tif !metaUpdate.GetBool(k, false) {\n\t\t\tcontinue\n\t\t}\n\t\tprefix := strings.TrimSuffix(k, \":*\")\n\t\tif prefix == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t// delete \"[prefix]\" and all keys that start with \"[prefix]:\"\n\t\tprefixColon := prefix + \":\"\n\t\tfor k2 := range rtn {\n\t\t\tif k2 == prefix || strings.HasPrefix(k2, prefixColon) {\n\t\t\t\tdelete(rtn, k2)\n\t\t\t}\n\t\t}\n\t}\n\t// now deal with regular keys\n\tfor k, v := range metaUpdate {\n\t\tif !mergeSpecial && strings.HasPrefix(k, \"display:\") {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.HasSuffix(k, \":*\") {\n\t\t\tcontinue\n\t\t}\n\t\tif v == nil {\n\t\t\tdelete(rtn, k)\n\t\t\tcontinue\n\t\t}\n\t\trtn[k] = v\n\t}\n\treturn rtn\n}\n"
  },
  {
    "path": "pkg/wcloud/wcloud.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wcloud\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/daystr\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n)\n\nconst WCloudEndpoint = \"https://api.waveterm.dev/central\"\nconst WCloudEndpointVarName = \"WCLOUD_ENDPOINT\"\nconst WCloudWSEndpoint = \"wss://wsapi.waveterm.dev/\"\nconst WCloudWSEndpointVarName = \"WCLOUD_WS_ENDPOINT\"\nconst WCloudPingEndpoint = \"https://ping.waveterm.dev/central\"\nconst WCloudPingEndpointVarName = \"WCLOUD_PING_ENDPOINT\"\n\nvar WCloudWSEndpoint_VarCache string\nvar WCloudEndpoint_VarCache string\nvar WCloudPingEndpoint_VarCache string\n\nconst APIVersion = 1\nconst MaxPtyUpdateSize = (128 * 1024)\nconst MaxUpdatesPerReq = 10\nconst MaxUpdatesToDeDup = 1000\nconst MaxUpdateWriterErrors = 3\nconst WCloudDefaultTimeout = 5 * time.Second\nconst WCloudWebShareUpdateTimeout = 15 * time.Second\n\n// setting to 1M to be safe (max is 6M for API-GW + Lambda, but there is base64 encoding and upload time)\n// we allow one extra update past this estimated size\nconst MaxUpdatePayloadSize = 1 * (1024 * 1024)\n\nconst TelemetryUrl = \"/telemetry\"\nconst TEventsUrl = \"/tevents\"\nconst NoTelemetryUrl = \"/no-telemetry\"\nconst WebShareUpdateUrl = \"/auth/web-share-update\"\nconst PingUrl = \"/ping\"\n\nfunc CacheAndRemoveEnvVars() error {\n\tWCloudEndpoint_VarCache = os.Getenv(WCloudEndpointVarName)\n\terr := checkEndpointVar(WCloudEndpoint_VarCache, \"wcloud endpoint\", WCloudEndpointVarName)\n\tif err != nil {\n\t\treturn err\n\t}\n\tos.Unsetenv(WCloudEndpointVarName)\n\tWCloudWSEndpoint_VarCache = os.Getenv(WCloudWSEndpointVarName)\n\terr = checkWSEndpointVar(WCloudWSEndpoint_VarCache, \"wcloud ws endpoint\", WCloudWSEndpointVarName)\n\tif err != nil {\n\t\treturn err\n\t}\n\tos.Unsetenv(WCloudWSEndpointVarName)\n\tWCloudPingEndpoint_VarCache = os.Getenv(WCloudPingEndpointVarName)\n\tos.Unsetenv(WCloudPingEndpointVarName)\n\treturn nil\n}\n\nfunc checkEndpointVar(endpoint string, debugName string, varName string) error {\n\tif !wavebase.IsDevMode() {\n\t\treturn nil\n\t}\n\tif endpoint == \"\" || !strings.HasPrefix(endpoint, \"https://\") {\n\t\treturn fmt.Errorf(\"invalid %s, %s not set or invalid\", debugName, varName)\n\t}\n\treturn nil\n}\n\nfunc checkWSEndpointVar(endpoint string, debugName string, varName string) error {\n\tif !wavebase.IsDevMode() {\n\t\treturn nil\n\t}\n\tlog.Printf(\"checking endpoint %q\\n\", endpoint)\n\tif endpoint == \"\" || !strings.HasPrefix(endpoint, \"wss://\") {\n\t\treturn fmt.Errorf(\"invalid %s, %s not set or invalid\", debugName, varName)\n\t}\n\treturn nil\n}\n\nfunc GetEndpoint() string {\n\tif !wavebase.IsDevMode() {\n\t\treturn WCloudEndpoint\n\t}\n\tendpoint := WCloudEndpoint_VarCache\n\treturn endpoint\n}\n\nfunc GetWSEndpoint() string {\n\tif !wavebase.IsDevMode() {\n\t\treturn WCloudWSEndpoint\n\t}\n\tendpoint := WCloudWSEndpoint_VarCache\n\treturn endpoint\n}\n\nfunc GetPingEndpoint() string {\n\tif !wavebase.IsDevMode() {\n\t\treturn WCloudPingEndpoint\n\t}\n\tendpoint := WCloudPingEndpoint_VarCache\n\treturn endpoint\n}\n\nfunc makeAnonPostReq(ctx context.Context, apiUrl string, data interface{}) (*http.Request, error) {\n\tendpoint := GetEndpoint()\n\tif endpoint == \"\" {\n\t\treturn nil, errors.New(\"wcloud endpoint not set\")\n\t}\n\tvar dataReader io.Reader\n\tif data != nil {\n\t\tbyteArr, err := json.Marshal(data)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error marshaling json for %s request: %v\", apiUrl, err)\n\t\t}\n\t\tdataReader = bytes.NewReader(byteArr)\n\t}\n\tfullUrl := GetEndpoint() + apiUrl\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", fullUrl, dataReader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating %s request: %v\", apiUrl, err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"X-PromptAPIVersion\", strconv.Itoa(APIVersion))\n\treq.Header.Set(\"X-PromptAPIUrl\", apiUrl)\n\treq.Close = true\n\treturn req, nil\n}\n\nfunc doRequest(req *http.Request, outputObj interface{}, verbose bool) (*http.Response, error) {\n\tapiUrl := req.Header.Get(\"X-PromptAPIUrl\")\n\tif verbose {\n\t\tlog.Printf(\"[wcloud] sending request %s %v\\n\", req.Method, req.URL)\n\t}\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error contacting wcloud %q service: %v\", apiUrl, err)\n\t}\n\tdefer resp.Body.Close()\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn resp, fmt.Errorf(\"error reading %q response body: %v\", apiUrl, err)\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn resp, fmt.Errorf(\"error contacting wcloud %q service: %s\", apiUrl, resp.Status)\n\t}\n\tif outputObj != nil && resp.Header.Get(\"Content-Type\") == \"application/json\" {\n\t\terr = json.Unmarshal(bodyBytes, outputObj)\n\t\tif err != nil {\n\t\t\treturn resp, fmt.Errorf(\"error decoding json: %v\", err)\n\t\t}\n\t}\n\treturn resp, nil\n}\n\ntype TEventsInputType struct {\n\tClientId string                  `json:\"clientid\"`\n\tEvents   []*telemetrydata.TEvent `json:\"events\"`\n}\n\nconst TEventsBatchSize = 200\nconst TEventsMaxBatches = 10\n\n// returns (done, num-sent, error)\nfunc sendTEventsBatch(clientId string) (bool, int, error) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), WCloudDefaultTimeout)\n\tdefer cancelFn()\n\tevents, err := telemetry.GetNonUploadedTEvents(ctx, TEventsBatchSize)\n\tif err != nil {\n\t\treturn true, 0, fmt.Errorf(\"cannot get events: %v\", err)\n\t}\n\tif len(events) == 0 {\n\t\treturn true, 0, nil\n\t}\n\tinput := TEventsInputType{\n\t\tClientId: clientId,\n\t\tEvents:   events,\n\t}\n\treq, err := makeAnonPostReq(ctx, TEventsUrl, input)\n\tif err != nil {\n\t\treturn true, 0, err\n\t}\n\tstartTime := time.Now()\n\t_, err = doRequest(req, nil, true)\n\tlatency := time.Since(startTime)\n\tlog.Printf(\"[wcloud] sent %d tevents (latency: %v)\\n\", len(events), latency)\n\tif err != nil {\n\t\treturn true, 0, err\n\t}\n\terr = telemetry.MarkTEventsAsUploaded(ctx, events)\n\tif err != nil {\n\t\treturn true, 0, fmt.Errorf(\"error marking activity as uploaded: %v\", err)\n\t}\n\treturn len(events) < TEventsBatchSize, len(events), nil\n}\n\nfunc sendTEvents(clientId string) (int, error) {\n\tnumIters := 0\n\ttotalEvents := 0\n\tfor {\n\t\tnumIters++\n\t\tdone, numEvents, err := sendTEventsBatch(clientId)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error sending telemetry events: %v\\n\", err)\n\t\t\tbreak\n\t\t}\n\t\ttotalEvents += numEvents\n\t\tif done {\n\t\t\tbreak\n\t\t}\n\t\tif numIters > TEventsMaxBatches {\n\t\t\tlog.Printf(\"sendTEvents, hit %d iterations, stopping\\n\", numIters)\n\t\t\tbreak\n\t\t}\n\t}\n\treturn totalEvents, nil\n}\n\nfunc SendAllTelemetry(clientId string) error {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\tif err := telemetry.CleanOldTEvents(ctx); err != nil {\n\t\tlog.Printf(\"error cleaning old telemetry events: %v\\n\", err)\n\t}\n\tif !telemetry.IsTelemetryEnabled() {\n\t\tlog.Printf(\"telemetry disabled, not sending\\n\")\n\t\treturn nil\n\t}\n\t_, err := sendTEvents(clientId)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc sendTelemetry(clientId string) error {\n\tctx, cancelFn := context.WithTimeout(context.Background(), WCloudDefaultTimeout)\n\tdefer cancelFn()\n\tactivity, err := telemetry.GetNonUploadedActivity(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot get activity: %v\", err)\n\t}\n\tif len(activity) == 0 {\n\t\treturn nil\n\t}\n\tlog.Printf(\"[wcloud] sending telemetry data\\n\")\n\tdayStr := daystr.GetCurDayStr()\n\tinput := TelemetryInputType{\n\t\tClientId:          clientId,\n\t\tUserId:            clientId,\n\t\tAppType:           \"w2\",\n\t\tAutoUpdateEnabled: telemetry.IsAutoUpdateEnabled(),\n\t\tAutoUpdateChannel: telemetry.AutoUpdateChannel(),\n\t\tCurDay:            dayStr,\n\t\tActivity:          activity,\n\t}\n\treq, err := makeAnonPostReq(ctx, TelemetryUrl, input)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = doRequest(req, nil, true)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = telemetry.MarkActivityAsUploaded(ctx, activity)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error marking activity as uploaded: %v\", err)\n\t}\n\treturn nil\n}\n\nfunc SendNoTelemetryUpdate(ctx context.Context, clientId string, noTelemetryVal bool) error {\n\treq, err := makeAnonPostReq(ctx, NoTelemetryUrl, NoTelemetryInputType{ClientId: clientId, Value: noTelemetryVal})\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = doRequest(req, nil, true)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc makePingPostReq(ctx context.Context, apiUrl string, data interface{}) (*http.Request, error) {\n\tendpoint := GetPingEndpoint()\n\tif endpoint == \"\" {\n\t\treturn nil, errors.New(\"wcloud ping endpoint not set\")\n\t}\n\tvar dataReader io.Reader\n\tif data != nil {\n\t\tbyteArr, err := json.Marshal(data)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error marshaling json for %s request: %v\", apiUrl, err)\n\t\t}\n\t\tdataReader = bytes.NewReader(byteArr)\n\t}\n\tfullUrl := endpoint + apiUrl\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", fullUrl, dataReader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating %s request: %v\", apiUrl, err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"X-PromptAPIVersion\", strconv.Itoa(APIVersion))\n\treq.Close = true\n\treturn req, nil\n}\n\ntype PingInputType struct {\n\tClientId       string `json:\"clientid\"`\n\tArch           string `json:\"arch\"`\n\tVersion        string `json:\"version\"`\n\tLocalDate      string `json:\"localdate\"`\n\tUsageTelemetry bool   `json:\"usagetelemetry\"`\n}\n\nfunc SendDiagnosticPing(ctx context.Context, clientId string, usageTelemetry bool) error {\n\tendpoint := GetPingEndpoint()\n\tif endpoint == \"\" {\n\t\treturn nil\n\t}\n\tlocalDate := time.Now().Format(\"2006-01-02\")\n\tinput := PingInputType{\n\t\tClientId:       clientId,\n\t\tArch:           wavebase.ClientArch(),\n\t\tVersion:        \"v\" + wavebase.WaveVersion,\n\t\tLocalDate:      localDate,\n\t\tUsageTelemetry: usageTelemetry,\n\t}\n\treq, err := makePingPostReq(ctx, PingUrl, input)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = doRequest(req, nil, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/wcloud/wclouddata.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wcloud\n\nimport (\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry\"\n)\n\ntype NoTelemetryInputType struct {\n\tClientId string `json:\"clientid\"`\n\tValue    bool   `json:\"value\"`\n}\n\ntype TelemetryInputType struct {\n\tUserId            string                    `json:\"userid\"`\n\tClientId          string                    `json:\"clientid\"`\n\tAppType           string                    `json:\"apptype,omitempty\"`\n\tAutoUpdateEnabled bool                      `json:\"autoupdateenabled,omitempty\"`\n\tAutoUpdateChannel string                    `json:\"autoupdatechannel,omitempty\"`\n\tCurDay            string                    `json:\"curday\"`\n\tActivity          []*telemetry.ActivityType `json:\"activity\"`\n}\n"
  },
  {
    "path": "pkg/wconfig/defaultconfig/defaultconfig.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage defaultconfig\n\nimport \"embed\"\n\n//go:embed *.json all:*/*.json\nvar ConfigFS embed.FS\n"
  },
  {
    "path": "pkg/wconfig/defaultconfig/mimetypes.json",
    "content": "{\n    \"audio\": {\n        \"icon\": \"file-audio\"\n    },\n    \"application/pdf\": {\n        \"icon\": \"file-pdf\"\n    },\n    \"application/javascript\": {\n        \"icon\": \"js fa-brands\"\n    },\n    \"application/typescript\": {\n        \"icon\": \"js fa-brands\"\n    },\n    \"application/json\": {\n        \"icon\": \"file-lines\"\n    },\n    \"directory\": {\n        \"icon\": \"folder\",\n        \"color\": \"var(--term-bright-blue)\"\n    },\n    \"font\": {\n        \"icon\": \"book-font\"\n    },\n    \"image\": {\n        \"icon\": \"file-image\"\n    },\n    \"text\": {\n        \"icon\": \"file-lines\"\n    },\n    \"text/css\": {\n        \"icon\": \"css3-alt fa-brands\"\n    },\n    \"text/javascript\": {\n        \"icon\": \"js fa-brands\"\n    },\n    \"text/typescript\": {\n        \"icon\": \"js fa-brands\"\n    },\n    \"text/golang\": {\n        \"icon\": \"golang fa-brands\"\n    },\n    \"text/html\": {\n        \"icon\": \"html5 fa-brands\"\n    },\n    \"text/less\": {\n        \"icon\": \"less fa-brands\"\n    },\n    \"text/markdown\": {\n        \"icon\": \"markdown fa-brands\"\n    },\n    \"text/rust\": {\n        \"icon\": \"rust fa-brands\"\n    },\n    \"text/scss\": {\n        \"icon\": \"sass fa-brands\"\n    },\n    \"video\": {\n        \"icon\": \"file-video\"\n    },\n    \"text/csv\": {\n        \"icon\": \"file-csv\"\n    },\n    \"text/x-dart\": {\n        \"icon\": \"dart-lang fa-brands\"\n    },\n    \"text/x-go\": {\n        \"icon\": \"golang fa-brands\"\n    },\n    \"text/x-rust\": {\n        \"icon\": \"rust fa-brands\"\n    }\n}\n"
  },
  {
    "path": "pkg/wconfig/defaultconfig/presets/ai.json",
    "content": "{\n    \"ai@global\": {\n        \"display:name\": \"Global default\",\n        \"display:order\": -1,\n        \"ai:*\": true\n    },\n    \"ai@wave\": {\n        \"display:name\": \"Wave Proxy - gpt-5-mini\",\n        \"display:order\": 0,\n        \"ai:*\": true,\n        \"ai:apitype\": \"\",\n        \"ai:baseurl\": \"\",\n        \"ai:apitoken\": \"\",\n        \"ai:name\": \"\",\n        \"ai:orgid\": \"\",\n        \"ai:model\": \"gpt-5-mini\",\n        \"ai:maxtokens\": 4000,\n        \"ai:timeoutms\": 60000\n    }\n}\n"
  },
  {
    "path": "pkg/wconfig/defaultconfig/presets.json",
    "content": "{\n    \"bg@default\": {\n        \"display:name\": \"Default\",\n        \"display:order\": -1,\n        \"bg:*\": true\n    },\n    \"bg@rainbow\": {\n        \"display:name\": \"Rainbow\",\n        \"display:order\": 2.1,\n        \"bg:*\": true,\n        \"bg\": \"linear-gradient( 226.4deg,  rgba(255,26,1,1) 28.9%, rgba(254,155,1,1) 33%, rgba(255,241,0,1) 48.6%, rgba(34,218,1,1) 65.3%, rgba(0,141,254,1) 80.6%, rgba(113,63,254,1) 100.1% )\",\n        \"bg:opacity\": 0.3\n    },\n    \"bg@green\": {\n        \"display:name\": \"Green\",\n        \"display:order\": 1.2,\n        \"bg:*\": true,\n        \"bg\": \"green\",\n        \"bg:opacity\": 0.3\n    },\n    \"bg@blue\": {\n        \"display:name\": \"Blue\",\n        \"display:order\": 1.1,\n        \"bg:*\": true,\n        \"bg\": \"blue\",\n        \"bg:opacity\": 0.3,\n        \"bg:activebordercolor\": \"rgba(0, 0, 255, 1.0)\"\n    },\n    \"bg@red\": {\n        \"display:name\": \"Red\",\n        \"display:order\": 1.3,\n        \"bg:*\": true,\n        \"bg\": \"red\",\n        \"bg:opacity\": 0.3,\n        \"bg:activebordercolor\": \"rgba(255, 0, 0, 1.0)\"\n    },\n    \"bg@ocean-depths\": {\n        \"display:name\": \"Ocean Depths\",\n        \"display:order\": 2.2,\n        \"bg:*\": true,\n        \"bg\": \"linear-gradient(135deg, purple, blue, teal)\",\n        \"bg:opacity\": 0.7\n    },\n    \"bg@aqua-horizon\": {\n        \"display:name\": \"Aqua Horizon\",\n        \"display:order\": 2.3,\n        \"bg:*\": true,\n        \"bg\": \"linear-gradient(135deg, rgba(15, 30, 50, 1) 0%, rgba(40, 90, 130, 0.85) 30%, rgba(20, 100, 150, 0.75) 60%, rgba(0, 120, 160, 0.65) 80%, rgba(0, 140, 180, 0.55) 100%), linear-gradient(135deg, rgba(100, 80, 255, 0.4), rgba(0, 180, 220, 0.4)), radial-gradient(circle at 70% 70%, rgba(255, 255, 255, 0.05), transparent 70%)\",\n        \"bg:opacity\": 0.85,\n        \"bg:blendmode\": \"overlay\"\n    },\n    \"bg@sunset\": {\n        \"display:name\": \"Sunset\",\n        \"display:order\": 2.4,\n        \"bg:*\": true,\n        \"bg\": \"linear-gradient(135deg, rgba(128, 0, 0, 1), rgba(255, 69, 0, 0.8), rgba(75, 0, 130, 1))\",\n        \"bg:opacity\": 0.8,\n        \"bg:blendmode\": \"normal\"\n    },\n    \"bg@enchantedforest\": {\n        \"display:name\": \"Enchanted Forest\",\n        \"display:order\": 2.7,\n        \"bg:*\": true,\n        \"bg\": \"linear-gradient(145deg, rgba(0,50,0,1), rgba(34,139,34,0.7) 20%, rgba(0,100,0,0.5) 40%, rgba(0,200,100,0.3) 60%, rgba(34,139,34,0.8) 80%, rgba(0,50,0,1)), radial-gradient(circle at 30% 30%, rgba(255,255,255,0.1), transparent 80%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.1), transparent 80%)\",\n        \"bg:opacity\": 0.8,\n        \"bg:blendmode\": \"soft-light\"\n    },\n    \"bg@twilight-mist\": {\n        \"display:name\": \"Twilight Mist\",\n        \"display:order\": 2.9,\n        \"bg:*\": true,\n        \"bg\": \"linear-gradient(180deg, rgba(60,60,90,1) 0%, rgba(90,110,140,0.8) 40%, rgba(120,140,160,0.6) 70%, rgba(60,60,90,1) 100%), radial-gradient(circle at 30% 40%, rgba(255,255,255,0.15), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.1), transparent 70%)\",\n        \"bg:opacity\": 0.9,\n        \"bg:blendmode\": \"soft-light\"\n    },\n    \"bg@duskhorizon\": {\n        \"display:name\": \"Dusk Horizon\",\n        \"display:order\": 3.1,\n        \"bg:*\": true,\n        \"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%)\",\n        \"bg:opacity\": 0.9,\n        \"bg:blendmode\": \"overlay\"\n    },\n    \"bg@tropical-radiance\": {\n        \"display:name\": \"Tropical Radiance\",\n        \"display:order\": 3.3,\n        \"bg:*\": true,\n        \"bg\": \"linear-gradient(135deg, rgba(204, 51, 255, 0.9) 0%, rgba(255, 85, 153, 0.75) 30%, rgba(255, 51, 153, 0.65) 60%, rgba(204, 51, 255, 0.6) 80%, rgba(51, 102, 255, 0.5) 100%), radial-gradient(circle at 30% 40%, rgba(255,255,255,0.1), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.05), transparent 70%)\",\n        \"bg:opacity\": 0.9,\n        \"bg:blendmode\": \"overlay\"\n    },\n    \"bg@twilight-ember\": {\n        \"display:name\": \"Twilight Ember\",\n        \"display:order\": 3.5,\n        \"bg:*\": true,\n        \"bg\": \"linear-gradient(120deg,hsla(350, 65%, 57%, 1),hsla(30,60%,60%, .75), hsla(208,69%,50%,.15), hsl(230,60%,40%)),radial-gradient(at top right,hsla(300,60%,70%,0.3),transparent),radial-gradient(at top left,hsla(330,100%,70%,.20),transparent),radial-gradient(at top right,hsla(190,100%,40%,.20),transparent),radial-gradient(at bottom left,hsla(323,54%,50%,.5),transparent),radial-gradient(at bottom left,hsla(144,54%,50%,.25),transparent)\",\n        \"bg:blendmode\": \"overlay\",\n        \"bg:text\": \"rgb(200, 200, 200)\"\n    },\n    \"bg@cosmic-tide\": {\n        \"display:name\": \"Cosmic Tide\",\n        \"display:order\": 3.6,\n        \"bg:activebordercolor\": \"#ff55aa\",\n        \"bg:*\": true,\n        \"bg\": \"linear-gradient(135deg, #00d9d9, #ff55aa, #1e1e2f, #2f3b57, #ff99ff)\",\n        \"bg:opacity\": 0.6\n    }\n}\n"
  },
  {
    "path": "pkg/wconfig/defaultconfig/settings.json",
    "content": "{\n    \"ai:preset\": \"ai@global\",\n    \"ai:model\": \"gpt-5-mini\",\n    \"ai:maxtokens\": 4000,\n    \"ai:timeoutms\": 60000,\n    \"app:defaultnewblock\": \"term\",\n    \"app:tabbar\": \"top\",\n    \"app:confirmquit\": true,\n    \"app:hideaibutton\": false,\n    \"app:disablectrlshiftarrows\": false,\n    \"app:disablectrlshiftdisplay\": false,\n    \"app:focusfollowscursor\": \"off\",\n    \"autoupdate:enabled\": true,\n    \"autoupdate:installonquit\": true,\n    \"autoupdate:intervalms\": 3600000,\n    \"conn:askbeforewshinstall\": true,\n    \"conn:wshenabled\": true,\n    \"editor:minimapenabled\": true,\n    \"web:defaulturl\": \"https://github.com/wavetermdev/waveterm\",\n    \"web:defaultsearch\": \"https://www.google.com/search?q={query}\",\n    \"window:tilegapsize\": 3,\n    \"window:maxtabcachesize\": 10,\n    \"window:nativetitlebar\": true,\n    \"window:magnifiedblockopacity\": 0.6,\n    \"window:magnifiedblocksize\": 0.95,\n    \"window:magnifiedblockblurprimarypx\": 10,\n    \"window:fullscreenonlaunch\": false,\n    \"window:magnifiedblockblursecondarypx\": 2,\n    \"window:confirmclose\": true,\n    \"window:savelastwindow\": true,\n    \"telemetry:enabled\": true,\n    \"term:bellsound\": false,\n    \"term:bellindicator\": true,\n    \"term:osc52\": \"always\",\n    \"term:cursor\": \"block\",\n    \"term:cursorblink\": false,\n    \"term:copyonselect\": true,\n    \"term:durable\": false,\n    \"waveai:showcloudmodes\": true,\n    \"waveai:defaultmode\": \"waveai@balanced\",\n    \"preview:defaultsort\": \"name\"\n}\n"
  },
  {
    "path": "pkg/wconfig/defaultconfig/termthemes.json",
    "content": "{\n    \"default-dark\": {\n        \"display:name\": \"Default Dark\",\n        \"display:order\": 1,\n        \"black\": \"#757575\",\n        \"red\": \"#cc685c\",\n        \"green\": \"#76c266\",\n        \"yellow\": \"#cbca9b\",\n        \"blue\": \"#85aacb\",\n        \"magenta\": \"#cc72ca\",\n        \"cyan\": \"#74a7cb\",\n        \"white\": \"#c1c1c1\",\n        \"brightBlack\": \"#727272\",\n        \"brightRed\": \"#cc9d97\",\n        \"brightGreen\": \"#a3dd97\",\n        \"brightYellow\": \"#cbcaaa\",\n        \"brightBlue\": \"#9ab6cb\",\n        \"brightMagenta\": \"#cc8ecb\",\n        \"brightCyan\": \"#b7b8cb\",\n        \"brightWhite\": \"#f0f0f0\",\n        \"gray\": \"#8b918a\",\n        \"cmdtext\": \"#f0f0f0\",\n        \"foreground\": \"#c1c1c1\",\n        \"selectionBackground\": \"\",\n        \"background\": \"#000000\",\n        \"cursor\": \"\"\n    },\n    \"onedarkpro\": {\n        \"display:name\": \"One Dark Pro\",\n        \"display:order\": 2,\n        \"background\": \"#21252B\",\n        \"foreground\": \"#ABB2BF\",\n        \"cursor\": \"#D7DAE0\",\n        \"black\": \"#3F4451\",\n        \"red\": \"#E06C75\",\n        \"green\": \"#98C379\",\n        \"yellow\": \"#D18F52\",\n        \"blue\": \"#61AFEF\",\n        \"magenta\": \"#C678DD\",\n        \"cyan\": \"#42B3C2\",\n        \"white\": \"#D7DAE0\",\n        \"brightBlack\": \"#4F5666\",\n        \"brightRed\": \"#FF616E\",\n        \"brightGreen\": \"#A5E075\",\n        \"brightYellow\": \"#F0A45D\",\n        \"brightBlue\": \"#4DC4FF\",\n        \"brightMagenta\": \"#DE73FF\",\n        \"brightCyan\": \"#4CD1E0\",\n        \"brightWhite\": \"#E6E6E6\",\n        \"gray\": \"#495162\",\n        \"cmdtext\": \"#ABB2BF\"\n    },\n    \"dracula\": {\n        \"display:name\": \"Dracula\",\n        \"display:order\": 3,\n        \"black\": \"#21222C\",\n        \"red\": \"#FF5555\",\n        \"green\": \"#50FA7B\",\n        \"yellow\": \"#F1FA8C\",\n        \"blue\": \"#BD93F9\",\n        \"magenta\": \"#FF79C6\",\n        \"cyan\": \"#8BE9FD\",\n        \"white\": \"#F8F8F2\",\n        \"brightBlack\": \"#6272A4\",\n        \"brightRed\": \"#FF6E6E\",\n        \"brightGreen\": \"#69FF94\",\n        \"brightYellow\": \"#FFFFA5\",\n        \"brightBlue\": \"#D6ACFF\",\n        \"brightMagenta\": \"#FF92DF\",\n        \"brightCyan\": \"#A4FFFF\",\n        \"brightWhite\": \"#FFFFFF\",\n        \"gray\": \"#6272A4\",\n        \"cmdtext\": \"#F8F8F2\",\n        \"foreground\": \"#F8F8F2\",\n        \"background\": \"#282a36\",\n        \"cursor\": \"#f8f8f2\"\n    },\n    \"monokai\": {\n        \"display:name\": \"Monokai\",\n        \"display:order\": 4,\n        \"black\": \"#1B1D1E\",\n        \"red\": \"#F92672\",\n        \"green\": \"#A6E22E\",\n        \"yellow\": \"#E6DB74\",\n        \"blue\": \"#66D9EF\",\n        \"magenta\": \"#AE81FF\",\n        \"cyan\": \"#A1EFE4\",\n        \"white\": \"#F8F8F2\",\n        \"brightBlack\": \"#75715E\",\n        \"brightRed\": \"#FD5FF1\",\n        \"brightGreen\": \"#A6E22E\",\n        \"brightYellow\": \"#E6DB74\",\n        \"brightBlue\": \"#66D9EF\",\n        \"brightMagenta\": \"#AE81FF\",\n        \"brightCyan\": \"#A1EFE4\",\n        \"brightWhite\": \"#F9F8F5\",\n        \"gray\": \"#75715E\",\n        \"cmdtext\": \"#F8F8F2\",\n        \"foreground\": \"#F8F8F2\",\n        \"background\": \"#272822\",\n        \"cursor\": \"#F8F8F2\"\n    },\n    \"campbell\": {\n        \"display:name\": \"Campbell\",\n        \"display:order\": 5,\n        \"black\": \"#0C0C0C\",\n        \"red\": \"#C50F1F\",\n        \"green\": \"#13A10E\",\n        \"yellow\": \"#C19C00\",\n        \"blue\": \"#0037DA\",\n        \"magenta\": \"#881798\",\n        \"cyan\": \"#3A96DD\",\n        \"white\": \"#CCCCCC\",\n        \"brightBlack\": \"#767676\",\n        \"brightRed\": \"#E74856\",\n        \"brightGreen\": \"#16C60C\",\n        \"brightYellow\": \"#F9F1A5\",\n        \"brightBlue\": \"#3B78FF\",\n        \"brightMagenta\": \"#B4009E\",\n        \"brightCyan\": \"#61D6D6\",\n        \"brightWhite\": \"#F2F2F2\",\n        \"gray\": \"#767676\",\n        \"cmdtext\": \"#CCCCCC\",\n        \"foreground\": \"#CCCCCC\",\n        \"selectionBackground\": \"#3A96DD77\",\n        \"background\": \"#0C0C0C\",\n        \"cursor\": \"#CCCCCC\"\n    },\n    \"warmyellow\": {\n        \"display:name\": \"Warm Yellow\",\n        \"display:order\": 6,\n        \"black\": \"#3C3228\",\n        \"red\": \"#E67E22\",\n        \"green\": \"#A5D6A7\",\n        \"yellow\": \"#F9D784\",\n        \"blue\": \"#7FB3D5\",\n        \"magenta\": \"#C39BD3\",\n        \"cyan\": \"#5DADE2\",\n        \"white\": \"#ECF0F1\",\n        \"brightBlack\": \"#7E705A\",\n        \"brightRed\": \"#E74C3C\",\n        \"brightGreen\": \"#82E0AA\",\n        \"brightYellow\": \"#F4D03F\",\n        \"brightBlue\": \"#3498DB\",\n        \"brightMagenta\": \"#9B59B6\",\n        \"brightCyan\": \"#1ABC9C\",\n        \"brightWhite\": \"#FFFFFF\",\n        \"background\": \"#2B2620\",\n        \"foreground\": \"#F2E6D4\",\n        \"selectionBackground\": \"#B7950B77\",\n        \"cursor\": \"#F9D784\"\n    },\n    \"rosepine\": {\n        \"display:name\": \"Rose Pine\",\n        \"display:order\": 7,\n        \"black\": \"#26233a\",\n        \"red\": \"#eb6f92\",\n        \"green\": \"#3e8fb0\",\n        \"yellow\": \"#f6c177\",\n        \"blue\": \"#9ccfd8\",\n        \"magenta\": \"#c4a7e7\",\n        \"cyan\": \"#ebbcba\",\n        \"white\": \"#e0def4\",\n        \"brightBlack\": \"#908caa\",\n        \"brightRed\": \"#ff8cab\",\n        \"brightGreen\": \"#9ccfb0\",\n        \"brightYellow\": \"#ffd196\",\n        \"brightBlue\": \"#bee6e0\",\n        \"brightMagenta\": \"#e2c4ff\",\n        \"brightCyan\": \"#ffd1d0\",\n        \"brightWhite\": \"#fffaf3\",\n        \"gray\": \"#908caa\",\n        \"cmdtext\": \"#e0def4\",\n        \"foreground\": \"#e0def4\",\n        \"background\": \"#191724\",\n        \"cursor\": \"#524f67\"\n    }\n}\n"
  },
  {
    "path": "pkg/wconfig/defaultconfig/waveai.json",
    "content": "{\n    \"waveai@quick\": {\n        \"display:name\": \"Quick\",\n        \"display:order\": -3,\n        \"display:icon\": \"bolt\",\n        \"display:description\": \"Fastest responses (gpt-5-mini)\",\n        \"ai:provider\": \"wave\",\n        \"ai:apitype\": \"openai-responses\",\n        \"ai:model\": \"gpt-5-mini\",\n        \"ai:thinkinglevel\": \"low\",\n        \"ai:verbosity\": \"low\",\n        \"ai:capabilities\": [\"tools\", \"images\", \"pdfs\"],\n        \"ai:switchcompat\": [\"wavecloud\"]\n    },\n    \"waveai@balanced\": {\n        \"display:name\": \"Balanced\",\n        \"display:order\": -2,\n        \"display:icon\": \"sparkles\",\n        \"display:description\": \"Good mix of speed and accuracy\\n(gpt-5.1 with minimal thinking)\",\n        \"ai:provider\": \"wave\",\n        \"ai:apitype\": \"openai-responses\",\n        \"ai:model\": \"gpt-5.1\",\n        \"ai:thinkinglevel\": \"low\",\n        \"ai:verbosity\": \"low\",\n        \"ai:capabilities\": [\"tools\", \"images\", \"pdfs\"],\n        \"waveai:premium\": true,\n        \"ai:switchcompat\": [\"wavecloud\"]\n    },\n    \"waveai@deep\": {\n        \"display:name\": \"Deep\",\n        \"display:order\": -1,\n        \"display:icon\": \"lightbulb\",\n        \"display:description\": \"Slower but most capable\\n(gpt-5.1 with full reasoning)\",\n        \"ai:provider\": \"wave\",\n        \"ai:apitype\": \"openai-responses\",\n        \"ai:model\": \"gpt-5.1\",\n        \"ai:thinkinglevel\": \"medium\",\n        \"ai:verbosity\": \"low\",\n        \"ai:capabilities\": [\"tools\", \"images\", \"pdfs\"],\n        \"waveai:premium\": true,\n        \"ai:switchcompat\": [\"wavecloud\"]\n    }\n}\n"
  },
  {
    "path": "pkg/wconfig/defaultconfig/widgets.json",
    "content": "{\n    \"defwidget@terminal\": {\n        \"display:order\": -5,\n        \"icon\": \"square-terminal\",\n        \"label\": \"terminal\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"term\",\n                \"controller\": \"shell\"\n            }\n        }\n    },\n    \"defwidget@files\": {\n        \"display:order\": -4,\n        \"icon\": \"folder\",\n        \"label\": \"files\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"preview\",\n                \"file\": \"~\"\n            }\n        }\n    },\n    \"defwidget@web\": {\n        \"display:order\": -3,\n        \"icon\": \"globe\",\n        \"label\": \"web\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"web\"\n            }\n        }\n    },\n    \"defwidget@ai\": {\n        \"display:order\": -2,\n        \"icon\": \"sparkles\",\n        \"label\": \"ai\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"waveai\"\n            }\n        }\n    },\n    \"defwidget@sysinfo\": {\n        \"display:order\": -1,\n        \"icon\": \"chart-line\",\n        \"label\": \"sysinfo\",\n        \"blockdef\": {\n            \"meta\": {\n                \"view\": \"sysinfo\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "pkg/wconfig/filewatcher.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wconfig\n\nimport (\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"sync\"\n\n\t\"github.com/fsnotify/fsnotify\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n)\n\nvar instance *Watcher\nvar once sync.Once\n\ntype ConfigUpdateHandler func(FullConfigType)\n\ntype Watcher struct {\n\tinitialized bool\n\twatcher     *fsnotify.Watcher\n\tmutex       sync.Mutex\n\tfullConfig  FullConfigType\n\thandlers    []ConfigUpdateHandler\n}\n\ntype WatcherUpdate struct {\n\tFullConfig FullConfigType `json:\"fullconfig\"`\n}\n\n// GetWatcher returns the singleton instance of the Watcher\nfunc GetWatcher() *Watcher {\n\tonce.Do(func() {\n\t\twatcher, err := fsnotify.NewWatcher()\n\t\tif err != nil {\n\t\t\tlog.Printf(\"failed to create file watcher: %v\", err)\n\t\t\treturn\n\t\t}\n\t\tconfigDirAbsPath := wavebase.GetWaveConfigDir()\n\t\tlog.Printf(\"create config watcher, configdir=%q\", configDirAbsPath)\n\t\tinstance = &Watcher{watcher: watcher}\n\t\terr = instance.watcher.Add(configDirAbsPath)\n\t\tconst failedStr = \"failed to add path %s to watcher: %v\"\n\t\tif err != nil {\n\t\t\tlog.Printf(failedStr, configDirAbsPath, err)\n\t\t}\n\n\t\tsubdirs := GetConfigSubdirs()\n\t\tfor _, dir := range subdirs {\n\t\t\terr = instance.watcher.Add(dir)\n\t\t\tif err != nil && !os.IsNotExist(err) {\n\t\t\t\tlog.Printf(failedStr, dir, err)\n\t\t\t}\n\t\t}\n\t})\n\treturn instance\n}\n\nfunc (w *Watcher) Start() {\n\tw.mutex.Lock()\n\tdefer w.mutex.Unlock()\n\n\tlog.Printf(\"starting file watcher\\n\")\n\tw.initialized = true\n\tw.sendInitialValues()\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"filewatcher:Start\", recover())\n\t\t}()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase event, ok := <-w.watcher.Events:\n\t\t\t\tif !ok {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.handleEvent(event)\n\t\t\tcase err, ok := <-w.watcher.Errors:\n\t\t\t\tif !ok {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tlog.Println(\"watcher error:\", err)\n\t\t\t}\n\t\t}\n\t}()\n}\n\n// for initial values, exit on first error\nfunc (w *Watcher) sendInitialValues() error {\n\tw.fullConfig = ReadFullConfig()\n\tmessage := WatcherUpdate{\n\t\tFullConfig: w.fullConfig,\n\t}\n\tw.broadcast(message)\n\treturn nil\n}\n\nfunc (w *Watcher) Close() {\n\tw.mutex.Lock()\n\tdefer w.mutex.Unlock()\n\tif w.watcher != nil {\n\t\tw.watcher.Close()\n\t\tw.watcher = nil\n\t\tlog.Println(\"file watcher closed\")\n\t}\n}\n\nfunc (w *Watcher) broadcast(message WatcherUpdate) {\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent: wps.Event_Config,\n\t\tData:  message,\n\t})\n\tw.notifyHandlers(message.FullConfig)\n}\n\nfunc (w *Watcher) RegisterUpdateHandler(handler ConfigUpdateHandler) {\n\tw.mutex.Lock()\n\tdefer w.mutex.Unlock()\n\tw.handlers = append(w.handlers, handler)\n}\n\nfunc (w *Watcher) notifyHandlers(config FullConfigType) {\n\thandlers := w.handlers\n\tfor _, handler := range handlers {\n\t\tgo func(h ConfigUpdateHandler) {\n\t\t\tdefer func() {\n\t\t\t\tpanichandler.PanicHandler(\"filewatcher:notifyHandlers\", recover())\n\t\t\t}()\n\t\t\th(config)\n\t\t}(handler)\n\t}\n}\n\nfunc (w *Watcher) GetFullConfig() FullConfigType {\n\tw.mutex.Lock()\n\tdefer w.mutex.Unlock()\n\treturn w.fullConfig\n}\n\nfunc (w *Watcher) handleEvent(event fsnotify.Event) {\n\tw.mutex.Lock()\n\tdefer w.mutex.Unlock()\n\n\tfileName := filepath.ToSlash(event.Name)\n\tif event.Op == fsnotify.Chmod {\n\t\treturn\n\t}\n\tif !isValidSubSettingsFileName(fileName) {\n\t\treturn\n\t}\n\tw.handleSettingsFileEvent(event, fileName)\n}\n\nvar validFileRe = regexp.MustCompile(`^[a-zA-Z0-9_@.-]+\\.json$`)\n\nfunc isValidSubSettingsFileName(fileName string) bool {\n\tif filepath.Ext(fileName) != \".json\" {\n\t\treturn false\n\t}\n\tbaseName := filepath.Base(fileName)\n\treturn validFileRe.MatchString(baseName)\n}\n\nfunc (w *Watcher) handleSettingsFileEvent(_ fsnotify.Event, _ string) {\n\tfullConfig := ReadFullConfig()\n\tw.fullConfig = fullConfig\n\tw.broadcast(WatcherUpdate{FullConfig: w.fullConfig})\n}\n"
  },
  {
    "path": "pkg/wconfig/metaconsts.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// Generated Code. DO NOT EDIT.\n\npackage wconfig\n\nconst (\n\tConfigKey_AppClear                       = \"app:*\"\n\tConfigKey_AppGlobalHotkey                = \"app:globalhotkey\"\n\tConfigKey_AppDismissArchitectureWarning  = \"app:dismissarchitecturewarning\"\n\tConfigKey_AppDefaultNewBlock             = \"app:defaultnewblock\"\n\tConfigKey_AppShowOverlayBlockNums        = \"app:showoverlayblocknums\"\n\tConfigKey_AppCtrlVPaste                  = \"app:ctrlvpaste\"\n\tConfigKey_AppConfirmQuit                 = \"app:confirmquit\"\n\tConfigKey_AppHideAiButton                = \"app:hideaibutton\"\n\tConfigKey_AppDisableCtrlShiftArrows      = \"app:disablectrlshiftarrows\"\n\tConfigKey_AppDisableCtrlShiftDisplay     = \"app:disablectrlshiftdisplay\"\n\tConfigKey_AppFocusFollowsCursor          = \"app:focusfollowscursor\"\n\tConfigKey_AppTabBar                      = \"app:tabbar\"\n\n\tConfigKey_FeatureWaveAppBuilder          = \"feature:waveappbuilder\"\n\n\tConfigKey_AiClear                        = \"ai:*\"\n\tConfigKey_AiPreset                       = \"ai:preset\"\n\tConfigKey_AiApiType                      = \"ai:apitype\"\n\tConfigKey_AiBaseURL                      = \"ai:baseurl\"\n\tConfigKey_AiApiToken                     = \"ai:apitoken\"\n\tConfigKey_AiName                         = \"ai:name\"\n\tConfigKey_AiModel                        = \"ai:model\"\n\tConfigKey_AiOrgID                        = \"ai:orgid\"\n\tConfigKey_AIApiVersion                   = \"ai:apiversion\"\n\tConfigKey_AiMaxTokens                    = \"ai:maxtokens\"\n\tConfigKey_AiTimeoutMs                    = \"ai:timeoutms\"\n\tConfigKey_AiProxyUrl                     = \"ai:proxyurl\"\n\tConfigKey_AiFontSize                     = \"ai:fontsize\"\n\tConfigKey_AiFixedFontSize                = \"ai:fixedfontsize\"\n\n\tConfigKey_WaveAiShowCloudModes           = \"waveai:showcloudmodes\"\n\tConfigKey_WaveAiDefaultMode              = \"waveai:defaultmode\"\n\n\tConfigKey_TermClear                      = \"term:*\"\n\tConfigKey_TermFontSize                   = \"term:fontsize\"\n\tConfigKey_TermFontFamily                 = \"term:fontfamily\"\n\tConfigKey_TermTheme                      = \"term:theme\"\n\tConfigKey_TermDisableWebGl               = \"term:disablewebgl\"\n\tConfigKey_TermLocalShellPath             = \"term:localshellpath\"\n\tConfigKey_TermLocalShellOpts             = \"term:localshellopts\"\n\tConfigKey_TermGitBashPath                = \"term:gitbashpath\"\n\tConfigKey_TermScrollback                 = \"term:scrollback\"\n\tConfigKey_TermCopyOnSelect               = \"term:copyonselect\"\n\tConfigKey_TermTransparency               = \"term:transparency\"\n\tConfigKey_TermAllowBracketedPaste        = \"term:allowbracketedpaste\"\n\tConfigKey_TermShiftEnterNewline          = \"term:shiftenternewline\"\n\tConfigKey_TermMacOptionIsMeta            = \"term:macoptionismeta\"\n\tConfigKey_TermCursor                     = \"term:cursor\"\n\tConfigKey_TermCursorBlink                = \"term:cursorblink\"\n\tConfigKey_TermBellSound                  = \"term:bellsound\"\n\tConfigKey_TermBellIndicator              = \"term:bellindicator\"\n\tConfigKey_TermOsc52                      = \"term:osc52\"\n\tConfigKey_TermDurable                    = \"term:durable\"\n\n\tConfigKey_EditorMinimapEnabled           = \"editor:minimapenabled\"\n\tConfigKey_EditorStickyScrollEnabled      = \"editor:stickyscrollenabled\"\n\tConfigKey_EditorWordWrap                 = \"editor:wordwrap\"\n\tConfigKey_EditorFontSize                 = \"editor:fontsize\"\n\tConfigKey_EditorInlineDiff               = \"editor:inlinediff\"\n\n\tConfigKey_WebClear                       = \"web:*\"\n\tConfigKey_WebOpenLinksInternally         = \"web:openlinksinternally\"\n\tConfigKey_WebDefaultUrl                  = \"web:defaulturl\"\n\tConfigKey_WebDefaultSearch               = \"web:defaultsearch\"\n\n\tConfigKey_AutoUpdateClear                = \"autoupdate:*\"\n\tConfigKey_AutoUpdateEnabled              = \"autoupdate:enabled\"\n\tConfigKey_AutoUpdateIntervalMs           = \"autoupdate:intervalms\"\n\tConfigKey_AutoUpdateInstallOnQuit        = \"autoupdate:installonquit\"\n\tConfigKey_AutoUpdateChannel              = \"autoupdate:channel\"\n\n\tConfigKey_MarkdownFontSize               = \"markdown:fontsize\"\n\tConfigKey_MarkdownFixedFontSize          = \"markdown:fixedfontsize\"\n\n\tConfigKey_PreviewShowHiddenFiles         = \"preview:showhiddenfiles\"\n\tConfigKey_PreviewDefaultSort             = \"preview:defaultsort\"\n\n\tConfigKey_TabPreset                      = \"tab:preset\"\n\tConfigKey_TabConfirmClose                = \"tab:confirmclose\"\n\n\tConfigKey_WidgetClear                    = \"widget:*\"\n\tConfigKey_WidgetShowHelp                 = \"widget:showhelp\"\n\n\tConfigKey_WindowClear                    = \"window:*\"\n\tConfigKey_WindowFullscreenOnLaunch       = \"window:fullscreenonlaunch\"\n\tConfigKey_WindowTransparent              = \"window:transparent\"\n\tConfigKey_WindowBlur                     = \"window:blur\"\n\tConfigKey_WindowOpacity                  = \"window:opacity\"\n\tConfigKey_WindowBgColor                  = \"window:bgcolor\"\n\tConfigKey_WindowReducedMotion            = \"window:reducedmotion\"\n\tConfigKey_WindowTileGapSize              = \"window:tilegapsize\"\n\tConfigKey_WindowShowMenuBar              = \"window:showmenubar\"\n\tConfigKey_WindowNativeTitleBar           = \"window:nativetitlebar\"\n\tConfigKey_WindowDisableHardwareAcceleration = \"window:disablehardwareacceleration\"\n\tConfigKey_WindowMaxTabCacheSize          = \"window:maxtabcachesize\"\n\tConfigKey_WindowMagnifiedBlockOpacity    = \"window:magnifiedblockopacity\"\n\tConfigKey_WindowMagnifiedBlockSize       = \"window:magnifiedblocksize\"\n\tConfigKey_WindowMagnifiedBlockBlurPrimaryPx = \"window:magnifiedblockblurprimarypx\"\n\tConfigKey_WindowMagnifiedBlockBlurSecondaryPx = \"window:magnifiedblockblursecondarypx\"\n\tConfigKey_WindowConfirmClose             = \"window:confirmclose\"\n\tConfigKey_WindowSaveLastWindow           = \"window:savelastwindow\"\n\tConfigKey_WindowDimensions               = \"window:dimensions\"\n\tConfigKey_WindowZoom                     = \"window:zoom\"\n\n\tConfigKey_TelemetryClear                 = \"telemetry:*\"\n\tConfigKey_TelemetryEnabled               = \"telemetry:enabled\"\n\n\tConfigKey_ConnClear                      = \"conn:*\"\n\tConfigKey_ConnAskBeforeWshInstall        = \"conn:askbeforewshinstall\"\n\tConfigKey_ConnWshEnabled                 = \"conn:wshenabled\"\n\tConfigKey_ConnLocalHostnameDisplay       = \"conn:localhostdisplayname\"\n\n\tConfigKey_DebugClear                     = \"debug:*\"\n\tConfigKey_DebugPprofPort                 = \"debug:pprofport\"\n\tConfigKey_DebugPprofMemProfileRate       = \"debug:pprofmemprofilerate\"\n\tConfigKey_DebugWebGlStatus               = \"debug:webglstatus\"\n\n\tConfigKey_TsunamiClear                   = \"tsunami:*\"\n\tConfigKey_TsunamiScaffoldPath            = \"tsunami:scaffoldpath\"\n\tConfigKey_TsunamiSdkReplacePath          = \"tsunami:sdkreplacepath\"\n\tConfigKey_TsunamiSdkVersion              = \"tsunami:sdkversion\"\n\tConfigKey_TsunamiGoPath                  = \"tsunami:gopath\"\n)\n\n"
  },
  {
    "path": "pkg/wconfig/settingsconfig.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wconfig\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/fileutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig/defaultconfig\"\n)\n\nconst SettingsFile = \"settings.json\"\nconst ConnectionsFile = \"connections.json\"\nconst ProfilesFile = \"profiles.json\"\n\nvar configWriteLock sync.Mutex\n\nconst AnySchema = `\n{\n  \"type\": \"object\",\n  \"additionalProperties\": true\n}\n`\n\n// old AI Widget presets (deprecated)\ntype AiSettingsType struct {\n\tAiClear         bool    `json:\"ai:*,omitempty\"`\n\tAiPreset        string  `json:\"ai:preset,omitempty\"`\n\tAiApiType       string  `json:\"ai:apitype,omitempty\"`\n\tAiBaseURL       string  `json:\"ai:baseurl,omitempty\"`\n\tAiApiToken      string  `json:\"ai:apitoken,omitempty\"`\n\tAiName          string  `json:\"ai:name,omitempty\"`\n\tAiModel         string  `json:\"ai:model,omitempty\"`\n\tAiOrgID         string  `json:\"ai:orgid,omitempty\"`\n\tAIApiVersion    string  `json:\"ai:apiversion,omitempty\"`\n\tAiMaxTokens     float64 `json:\"ai:maxtokens,omitempty\"`\n\tAiTimeoutMs     float64 `json:\"ai:timeoutms,omitempty\"`\n\tAiProxyUrl      string  `json:\"ai:proxyurl,omitempty\"`\n\tAiFontSize      float64 `json:\"ai:fontsize,omitempty\"`\n\tAiFixedFontSize float64 `json:\"ai:fixedfontsize,omitempty\"`\n\tDisplayName     string  `json:\"display:name,omitempty\"`\n\tDisplayOrder    float64 `json:\"display:order,omitempty\"`\n}\n\ntype SettingsType struct {\n\tAppClear                      bool   `json:\"app:*,omitempty\"`\n\tAppGlobalHotkey               string `json:\"app:globalhotkey,omitempty\"`\n\tAppDismissArchitectureWarning bool   `json:\"app:dismissarchitecturewarning,omitempty\"`\n\tAppDefaultNewBlock            string `json:\"app:defaultnewblock,omitempty\"`\n\tAppShowOverlayBlockNums       *bool  `json:\"app:showoverlayblocknums,omitempty\"`\n\tAppCtrlVPaste                 *bool  `json:\"app:ctrlvpaste,omitempty\"`\n\tAppConfirmQuit                *bool  `json:\"app:confirmquit,omitempty\"`\n\tAppHideAiButton               bool   `json:\"app:hideaibutton,omitempty\"`\n\tAppDisableCtrlShiftArrows     bool   `json:\"app:disablectrlshiftarrows,omitempty\"`\n\tAppDisableCtrlShiftDisplay    bool   `json:\"app:disablectrlshiftdisplay,omitempty\"`\n\tAppFocusFollowsCursor         string `json:\"app:focusfollowscursor,omitempty\" jsonschema:\"enum=off,enum=on,enum=term\"`\n\tAppTabBar                     string `json:\"app:tabbar,omitempty\" jsonschema:\"enum=top,enum=left\"`\n\n\tFeatureWaveAppBuilder bool `json:\"feature:waveappbuilder,omitempty\"`\n\n\tAiClear         bool    `json:\"ai:*,omitempty\"`\n\tAiPreset        string  `json:\"ai:preset,omitempty\"`\n\tAiApiType       string  `json:\"ai:apitype,omitempty\"`\n\tAiBaseURL       string  `json:\"ai:baseurl,omitempty\"`\n\tAiApiToken      string  `json:\"ai:apitoken,omitempty\"`\n\tAiName          string  `json:\"ai:name,omitempty\"`\n\tAiModel         string  `json:\"ai:model,omitempty\"`\n\tAiOrgID         string  `json:\"ai:orgid,omitempty\"`\n\tAIApiVersion    string  `json:\"ai:apiversion,omitempty\"`\n\tAiMaxTokens     float64 `json:\"ai:maxtokens,omitempty\"`\n\tAiTimeoutMs     float64 `json:\"ai:timeoutms,omitempty\"`\n\tAiProxyUrl      string  `json:\"ai:proxyurl,omitempty\"`\n\tAiFontSize      float64 `json:\"ai:fontsize,omitempty\"`\n\tAiFixedFontSize float64 `json:\"ai:fixedfontsize,omitempty\"`\n\n\tWaveAiShowCloudModes bool   `json:\"waveai:showcloudmodes,omitempty\"`\n\tWaveAiDefaultMode    string `json:\"waveai:defaultmode,omitempty\"`\n\n\tTermClear               bool     `json:\"term:*,omitempty\"`\n\tTermFontSize            float64  `json:\"term:fontsize,omitempty\"`\n\tTermFontFamily          string   `json:\"term:fontfamily,omitempty\"`\n\tTermTheme               string   `json:\"term:theme,omitempty\"`\n\tTermDisableWebGl        bool     `json:\"term:disablewebgl,omitempty\"`\n\tTermLocalShellPath      string   `json:\"term:localshellpath,omitempty\"`\n\tTermLocalShellOpts      []string `json:\"term:localshellopts,omitempty\"`\n\tTermGitBashPath         string   `json:\"term:gitbashpath,omitempty\"`\n\tTermScrollback          *int64   `json:\"term:scrollback,omitempty\"`\n\tTermCopyOnSelect        *bool    `json:\"term:copyonselect,omitempty\"`\n\tTermTransparency        *float64 `json:\"term:transparency,omitempty\"`\n\tTermAllowBracketedPaste *bool    `json:\"term:allowbracketedpaste,omitempty\"`\n\tTermShiftEnterNewline   *bool    `json:\"term:shiftenternewline,omitempty\"`\n\tTermMacOptionIsMeta     *bool    `json:\"term:macoptionismeta,omitempty\"`\n\tTermCursor              string   `json:\"term:cursor,omitempty\"`\n\tTermCursorBlink         *bool    `json:\"term:cursorblink,omitempty\"`\n\tTermBellSound           *bool    `json:\"term:bellsound,omitempty\"`\n\tTermBellIndicator       *bool    `json:\"term:bellindicator,omitempty\"`\n\tTermOsc52               string   `json:\"term:osc52,omitempty\" jsonschema:\"enum=focus,enum=always\"`\n\tTermDurable             *bool    `json:\"term:durable,omitempty\"`\n\n\tEditorMinimapEnabled      bool    `json:\"editor:minimapenabled,omitempty\"`\n\tEditorStickyScrollEnabled bool    `json:\"editor:stickyscrollenabled,omitempty\"`\n\tEditorWordWrap            bool    `json:\"editor:wordwrap,omitempty\"`\n\tEditorFontSize            float64 `json:\"editor:fontsize,omitempty\"`\n\tEditorInlineDiff          bool    `json:\"editor:inlinediff,omitempty\"`\n\n\tWebClear               bool   `json:\"web:*,omitempty\"`\n\tWebOpenLinksInternally bool   `json:\"web:openlinksinternally,omitempty\"`\n\tWebDefaultUrl          string `json:\"web:defaulturl,omitempty\"`\n\tWebDefaultSearch       string `json:\"web:defaultsearch,omitempty\"`\n\n\tAutoUpdateClear         bool    `json:\"autoupdate:*,omitempty\"`\n\tAutoUpdateEnabled       bool    `json:\"autoupdate:enabled,omitempty\"`\n\tAutoUpdateIntervalMs    float64 `json:\"autoupdate:intervalms,omitempty\"`\n\tAutoUpdateInstallOnQuit bool    `json:\"autoupdate:installonquit,omitempty\"`\n\tAutoUpdateChannel       string  `json:\"autoupdate:channel,omitempty\"`\n\n\tMarkdownFontSize      float64 `json:\"markdown:fontsize,omitempty\"`\n\tMarkdownFixedFontSize float64 `json:\"markdown:fixedfontsize,omitempty\"`\n\n\tPreviewShowHiddenFiles *bool  `json:\"preview:showhiddenfiles,omitempty\"`\n\tPreviewDefaultSort     string `json:\"preview:defaultsort,omitempty\" jsonschema:\"enum=name,enum=modtime\"`\n\n\tTabPreset       string `json:\"tab:preset,omitempty\"`\n\tTabConfirmClose bool   `json:\"tab:confirmclose,omitempty\"`\n\n\tWidgetClear    bool  `json:\"widget:*,omitempty\"`\n\tWidgetShowHelp *bool `json:\"widget:showhelp,omitempty\"`\n\n\tWindowClear                         bool     `json:\"window:*,omitempty\"`\n\tWindowFullscreenOnLaunch            bool     `json:\"window:fullscreenonlaunch,omitempty\"`\n\tWindowTransparent                   bool     `json:\"window:transparent,omitempty\"`\n\tWindowBlur                          bool     `json:\"window:blur,omitempty\"`\n\tWindowOpacity                       *float64 `json:\"window:opacity,omitempty\"`\n\tWindowBgColor                       string   `json:\"window:bgcolor,omitempty\"`\n\tWindowReducedMotion                 bool     `json:\"window:reducedmotion,omitempty\"`\n\tWindowTileGapSize                   *int64   `json:\"window:tilegapsize,omitempty\"`\n\tWindowShowMenuBar                   bool     `json:\"window:showmenubar,omitempty\"`\n\tWindowNativeTitleBar                bool     `json:\"window:nativetitlebar,omitempty\"`\n\tWindowDisableHardwareAcceleration   bool     `json:\"window:disablehardwareacceleration,omitempty\"`\n\tWindowMaxTabCacheSize               int      `json:\"window:maxtabcachesize,omitempty\"`\n\tWindowMagnifiedBlockOpacity         *float64 `json:\"window:magnifiedblockopacity,omitempty\"`\n\tWindowMagnifiedBlockSize            *float64 `json:\"window:magnifiedblocksize,omitempty\"`\n\tWindowMagnifiedBlockBlurPrimaryPx   *int64   `json:\"window:magnifiedblockblurprimarypx,omitempty\"`\n\tWindowMagnifiedBlockBlurSecondaryPx *int64   `json:\"window:magnifiedblockblursecondarypx,omitempty\"`\n\tWindowConfirmClose                  bool     `json:\"window:confirmclose,omitempty\"`\n\tWindowSaveLastWindow                bool     `json:\"window:savelastwindow,omitempty\"`\n\tWindowDimensions                    string   `json:\"window:dimensions,omitempty\"`\n\tWindowZoom                          *float64 `json:\"window:zoom,omitempty\"`\n\n\tTelemetryClear   bool `json:\"telemetry:*,omitempty\"`\n\tTelemetryEnabled bool `json:\"telemetry:enabled,omitempty\"`\n\n\tConnClear                bool    `json:\"conn:*,omitempty\"`\n\tConnAskBeforeWshInstall  *bool   `json:\"conn:askbeforewshinstall,omitempty\"`\n\tConnWshEnabled           bool    `json:\"conn:wshenabled,omitempty\"`\n\tConnLocalHostnameDisplay *string `json:\"conn:localhostdisplayname,omitempty\"`\n\n\tDebugClear               bool `json:\"debug:*,omitempty\"`\n\tDebugPprofPort           *int `json:\"debug:pprofport,omitempty\"`\n\tDebugPprofMemProfileRate *int `json:\"debug:pprofmemprofilerate,omitempty\"`\n\tDebugWebGlStatus         bool `json:\"debug:webglstatus,omitempty\"`\n\n\tTsunamiClear          bool   `json:\"tsunami:*,omitempty\"`\n\tTsunamiScaffoldPath   string `json:\"tsunami:scaffoldpath,omitempty\"`\n\tTsunamiSdkReplacePath string `json:\"tsunami:sdkreplacepath,omitempty\"`\n\tTsunamiSdkVersion     string `json:\"tsunami:sdkversion,omitempty\"`\n\tTsunamiGoPath         string `json:\"tsunami:gopath,omitempty\"`\n}\n\nfunc (s *SettingsType) GetAiSettings() *AiSettingsType {\n\treturn &AiSettingsType{\n\t\tAiClear:         s.AiClear,\n\t\tAiPreset:        s.AiPreset,\n\t\tAiApiType:       s.AiApiType,\n\t\tAiBaseURL:       s.AiBaseURL,\n\t\tAiApiToken:      s.AiApiToken,\n\t\tAiName:          s.AiName,\n\t\tAiModel:         s.AiModel,\n\t\tAiOrgID:         s.AiOrgID,\n\t\tAIApiVersion:    s.AIApiVersion,\n\t\tAiMaxTokens:     s.AiMaxTokens,\n\t\tAiTimeoutMs:     s.AiTimeoutMs,\n\t\tAiProxyUrl:      s.AiProxyUrl,\n\t\tAiFontSize:      s.AiFontSize,\n\t\tAiFixedFontSize: s.AiFixedFontSize,\n\t}\n}\n\nfunc MergeAiSettings(settings ...*AiSettingsType) *AiSettingsType {\n\tresult := &AiSettingsType{}\n\n\tfor _, s := range settings {\n\t\tif s == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\t// If this setting has AiClear=true, replace result with this entire setting\n\t\tif s.AiClear {\n\t\t\tresult = s\n\t\t\tresult.AiClear = false\n\t\t\tcontinue\n\t\t}\n\n\t\t// Merge non-empty values\n\t\tif s.AiPreset != \"\" {\n\t\t\tresult.AiPreset = s.AiPreset\n\t\t}\n\t\tif s.AiApiType != \"\" {\n\t\t\tresult.AiApiType = s.AiApiType\n\t\t}\n\t\tif s.AiBaseURL != \"\" {\n\t\t\tresult.AiBaseURL = s.AiBaseURL\n\t\t}\n\t\tif s.AiApiToken != \"\" {\n\t\t\tresult.AiApiToken = s.AiApiToken\n\t\t}\n\t\tif s.AiName != \"\" {\n\t\t\tresult.AiName = s.AiName\n\t\t}\n\t\tif s.AiModel != \"\" {\n\t\t\tresult.AiModel = s.AiModel\n\t\t}\n\t\tif s.AiOrgID != \"\" {\n\t\t\tresult.AiOrgID = s.AiOrgID\n\t\t}\n\t\tif s.AIApiVersion != \"\" {\n\t\t\tresult.AIApiVersion = s.AIApiVersion\n\t\t}\n\t\tif s.AiProxyUrl != \"\" {\n\t\t\tresult.AiProxyUrl = s.AiProxyUrl\n\t\t}\n\t\tif s.AiMaxTokens != 0 {\n\t\t\tresult.AiMaxTokens = s.AiMaxTokens\n\t\t}\n\t\tif s.AiTimeoutMs != 0 {\n\t\t\tresult.AiTimeoutMs = s.AiTimeoutMs\n\t\t}\n\t\tif s.AiFontSize != 0 {\n\t\t\tresult.AiFontSize = s.AiFontSize\n\t\t}\n\t\tif s.AiFixedFontSize != 0 {\n\t\t\tresult.AiFixedFontSize = s.AiFixedFontSize\n\t\t}\n\t\tif s.DisplayName != \"\" {\n\t\t\tresult.DisplayName = s.DisplayName\n\t\t}\n\t\tif s.DisplayOrder != 0 {\n\t\t\tresult.DisplayOrder = s.DisplayOrder\n\t\t}\n\t}\n\n\treturn result\n}\n\ntype ConfigError struct {\n\tFile string `json:\"file\"`\n\tErr  string `json:\"err\"`\n}\n\ntype WebBookmark struct {\n\tUrl          string  `json:\"url\"`\n\tTitle        string  `json:\"title,omitempty\"`\n\tIcon         string  `json:\"icon,omitempty\"`\n\tIconColor    string  `json:\"iconcolor,omitempty\"`\n\tIconUrl      string  `json:\"iconurl,omitempty\"`\n\tDisplayOrder float64 `json:\"display:order,omitempty\"`\n}\n\n// Wave AI panel mode configuration (NEW)\ntype AIModeConfigType struct {\n\tDisplayName        string   `json:\"display:name\"`\n\tDisplayOrder       float64  `json:\"display:order,omitempty\"`\n\tDisplayIcon        string   `json:\"display:icon,omitempty\"`\n\tDisplayDescription string   `json:\"display:description,omitempty\"`\n\tProvider           string   `json:\"ai:provider,omitempty\" jsonschema:\"enum=wave,enum=google,enum=groq,enum=openrouter,enum=nanogpt,enum=openai,enum=azure,enum=azure-legacy,enum=custom\"`\n\tAPIType            string   `json:\"ai:apitype,omitempty\" jsonschema:\"enum=google-gemini,enum=openai-responses,enum=openai-chat\"`\n\tModel              string   `json:\"ai:model,omitempty\"`\n\tThinkingLevel      string   `json:\"ai:thinkinglevel,omitempty\" jsonschema:\"enum=low,enum=medium,enum=high\"`\n\tVerbosity          string   `json:\"ai:verbosity,omitempty\" jsonschema:\"enum=low,enum=medium,enum=high,description=Text verbosity level (OpenAI Responses API only)\"`\n\tEndpoint           string   `json:\"ai:endpoint,omitempty\"`\n\tProxyURL           string   `json:\"ai:proxyurl,omitempty\"`\n\tAzureAPIVersion    string   `json:\"ai:azureapiversion,omitempty\"`\n\tAPIToken           string   `json:\"ai:apitoken,omitempty\"`\n\tAPITokenSecretName string   `json:\"ai:apitokensecretname,omitempty\"`\n\tAzureResourceName  string   `json:\"ai:azureresourcename,omitempty\"`\n\tAzureDeployment    string   `json:\"ai:azuredeployment,omitempty\"`\n\tCapabilities       []string `json:\"ai:capabilities,omitempty\" jsonschema:\"enum=pdfs,enum=images,enum=tools\"`\n\tSwitchCompat       []string `json:\"ai:switchcompat,omitempty\"`\n\tWaveAICloud        bool     `json:\"waveai:cloud,omitempty\"`\n\tWaveAIPremium      bool     `json:\"waveai:premium,omitempty\"`\n}\n\ntype AIModeConfigUpdate struct {\n\tConfigs map[string]AIModeConfigType `json:\"configs\"`\n}\n\ntype FullConfigType struct {\n\tSettings       SettingsType                   `json:\"settings\" merge:\"meta\"`\n\tMimeTypes      map[string]MimeTypeConfigType  `json:\"mimetypes\"`\n\tDefaultWidgets map[string]WidgetConfigType    `json:\"defaultwidgets\"`\n\tWidgets        map[string]WidgetConfigType    `json:\"widgets\"`\n\tPresets        map[string]waveobj.MetaMapType `json:\"presets\"`\n\tTermThemes     map[string]TermThemeType       `json:\"termthemes\"`\n\tConnections    map[string]ConnKeywords        `json:\"connections\"`\n\tBookmarks      map[string]WebBookmark         `json:\"bookmarks\"`\n\tWaveAIModes    map[string]AIModeConfigType    `json:\"waveai\"`\n\tConfigErrors   []ConfigError                  `json:\"configerrors\" configfile:\"-\"`\n}\n\ntype ConnKeywords struct {\n\tConnWshEnabled          *bool  `json:\"conn:wshenabled,omitempty\"`\n\tConnAskBeforeWshInstall *bool  `json:\"conn:askbeforewshinstall,omitempty\"`\n\tConnWshPath             string `json:\"conn:wshpath,omitempty\"`\n\tConnShellPath           string `json:\"conn:shellpath,omitempty\"`\n\tConnIgnoreSshConfig     *bool  `json:\"conn:ignoresshconfig,omitempty\"`\n\n\tDisplayHidden *bool   `json:\"display:hidden,omitempty\"`\n\tDisplayOrder  float32 `json:\"display:order,omitempty\"`\n\n\tTermClear      bool    `json:\"term:*,omitempty\"`\n\tTermFontSize   float64 `json:\"term:fontsize,omitempty\"`\n\tTermFontFamily string  `json:\"term:fontfamily,omitempty\"`\n\tTermTheme      string  `json:\"term:theme,omitempty\"`\n\tTermDurable    *bool   `json:\"term:durable,omitempty\"`\n\n\tCmdEnv            map[string]string `json:\"cmd:env,omitempty\"`\n\tCmdInitScript     string            `json:\"cmd:initscript,omitempty\"`\n\tCmdInitScriptSh   string            `json:\"cmd:initscript.sh,omitempty\"`\n\tCmdInitScriptBash string            `json:\"cmd:initscript.bash,omitempty\"`\n\tCmdInitScriptZsh  string            `json:\"cmd:initscript.zsh,omitempty\"`\n\tCmdInitScriptPwsh string            `json:\"cmd:initscript.pwsh,omitempty\"`\n\tCmdInitScriptFish string            `json:\"cmd:initscript.fish,omitempty\"`\n\n\tSshUser                         *string  `json:\"ssh:user,omitempty\"`\n\tSshHostName                     *string  `json:\"ssh:hostname,omitempty\"`\n\tSshPort                         *string  `json:\"ssh:port,omitempty\"`\n\tSshIdentityFile                 []string `json:\"ssh:identityfile,omitempty\"`\n\tSshPasswordSecretName           *string  `json:\"ssh:passwordsecretname,omitempty\"`\n\tSshBatchMode                    *bool    `json:\"ssh:batchmode,omitempty\"`\n\tSshPubkeyAuthentication         *bool    `json:\"ssh:pubkeyauthentication,omitempty\"`\n\tSshPasswordAuthentication       *bool    `json:\"ssh:passwordauthentication,omitempty\"`\n\tSshKbdInteractiveAuthentication *bool    `json:\"ssh:kbdinteractiveauthentication,omitempty\"`\n\tSshPreferredAuthentications     []string `json:\"ssh:preferredauthentications,omitempty\"`\n\tSshAddKeysToAgent               *bool    `json:\"ssh:addkeystoagent,omitempty\"`\n\tSshIdentityAgent                *string  `json:\"ssh:identityagent,omitempty\"`\n\tSshIdentitiesOnly               *bool    `json:\"ssh:identitiesonly,omitempty\"`\n\tSshProxyJump                    []string `json:\"ssh:proxyjump,omitempty\"`\n\tSshUserKnownHostsFile           []string `json:\"ssh:userknownhostsfile,omitempty\"`\n\tSshGlobalKnownHostsFile         []string `json:\"ssh:globalknownhostsfile,omitempty\"`\n}\n\nfunc DefaultBoolPtr(arg *bool, def bool) bool {\n\tif arg == nil {\n\t\treturn def\n\t}\n\treturn *arg\n}\n\nfunc goBackWS(barr []byte, offset int) int {\n\tif offset >= len(barr) {\n\t\toffset = offset - 1\n\t}\n\tfor i := offset - 1; i >= 0; i-- {\n\t\tif barr[i] == ' ' || barr[i] == '\\t' || barr[i] == '\\n' || barr[i] == '\\r' {\n\t\t\tcontinue\n\t\t}\n\t\treturn i\n\t}\n\treturn 0\n}\n\nfunc isTrailingCommaError(barr []byte, offset int) bool {\n\tif offset >= len(barr) {\n\t\toffset = offset - 1\n\t}\n\toffset = goBackWS(barr, offset)\n\tif barr[offset] == '}' {\n\t\toffset = goBackWS(barr, offset)\n\t\tif barr[offset] == ',' {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc resolveEnvReplacements(m waveobj.MetaMapType) {\n\tif m == nil {\n\t\treturn\n\t}\n\n\tfor key, value := range m {\n\t\tswitch v := value.(type) {\n\t\tcase string:\n\t\t\tif resolved, ok := resolveEnvValue(v); ok {\n\t\t\t\tm[key] = resolved\n\t\t\t}\n\t\tcase map[string]interface{}:\n\t\t\tresolveEnvReplacements(waveobj.MetaMapType(v))\n\t\tcase []interface{}:\n\t\t\tresolveEnvArray(v)\n\t\t}\n\t}\n}\n\nfunc resolveEnvArray(arr []interface{}) {\n\tfor i, value := range arr {\n\t\tswitch v := value.(type) {\n\t\tcase string:\n\t\t\tif resolved, ok := resolveEnvValue(v); ok {\n\t\t\t\tarr[i] = resolved\n\t\t\t}\n\t\tcase map[string]interface{}:\n\t\t\tresolveEnvReplacements(waveobj.MetaMapType(v))\n\t\tcase []interface{}:\n\t\t\tresolveEnvArray(v)\n\t\t}\n\t}\n}\n\nfunc resolveEnvValue(value string) (string, bool) {\n\tif !strings.HasPrefix(value, \"$ENV:\") {\n\t\treturn \"\", false\n\t}\n\n\tenvSpec := value[5:] // Remove \"$ENV:\" prefix\n\tparts := strings.SplitN(envSpec, \":\", 2)\n\tenvVar := parts[0]\n\tvar fallback string\n\tif len(parts) > 1 {\n\t\tfallback = parts[1]\n\t}\n\n\t// Get the environment variable value\n\tif envValue, exists := os.LookupEnv(envVar); exists {\n\t\treturn envValue, true\n\t}\n\n\t// Return fallback if provided, otherwise return empty string\n\tif fallback != \"\" {\n\t\treturn fallback, true\n\t}\n\treturn \"\", true\n}\n\nfunc readConfigHelper(fileName string, barr []byte, readErr error) (waveobj.MetaMapType, []ConfigError) {\n\tvar cerrs []ConfigError\n\tif readErr != nil && !os.IsNotExist(readErr) {\n\t\tcerrs = append(cerrs, ConfigError{File: fileName, Err: readErr.Error()})\n\t}\n\tif len(barr) == 0 {\n\t\treturn nil, cerrs\n\t}\n\tvar rtn waveobj.MetaMapType\n\terr := json.Unmarshal(barr, &rtn)\n\tif err != nil {\n\t\tif syntaxErr, ok := err.(*json.SyntaxError); ok {\n\t\t\toffset := syntaxErr.Offset\n\t\t\tif offset > 0 {\n\t\t\t\toffset = offset - 1\n\t\t\t}\n\t\t\tlineNum, colNum := utilfn.GetLineColFromOffset(barr, int(offset))\n\t\t\tisTrailingComma := isTrailingCommaError(barr, int(offset))\n\t\t\tif isTrailingComma {\n\t\t\t\terr = fmt.Errorf(\"json syntax error at line %d, col %d: probably an extra trailing comma: %v\", lineNum, colNum, syntaxErr)\n\t\t\t} else {\n\t\t\t\terr = fmt.Errorf(\"json syntax error at line %d, col %d: %v\", lineNum, colNum, syntaxErr)\n\t\t\t}\n\t\t}\n\t\tcerrs = append(cerrs, ConfigError{File: fileName, Err: err.Error()})\n\t}\n\n\t// Resolve environment variable replacements\n\tif rtn != nil {\n\t\tresolveEnvReplacements(rtn)\n\t}\n\n\treturn rtn, cerrs\n}\n\nfunc readConfigFileFS(fsys fs.FS, logPrefix string, fileName string) (waveobj.MetaMapType, []ConfigError) {\n\tbarr, readErr := fs.ReadFile(fsys, fileName)\n\tif readErr != nil {\n\t\t// If we get an error, we may be using the wrong path separator for the given FS interface. Try switching the separator.\n\t\tbarr, readErr = fs.ReadFile(fsys, filepath.ToSlash(fileName))\n\t}\n\treturn readConfigHelper(logPrefix+fileName, barr, readErr)\n}\n\nfunc ReadDefaultsConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) {\n\treturn readConfigFileFS(defaultconfig.ConfigFS, \"defaults:\", fileName)\n}\n\nfunc ReadWaveHomeConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) {\n\tconfigDirAbsPath := wavebase.GetWaveConfigDir()\n\tconfigDirFsys := os.DirFS(configDirAbsPath)\n\treturn readConfigFileFS(configDirFsys, \"\", fileName)\n}\n\nfunc WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error {\n\tconfigWriteLock.Lock()\n\tdefer configWriteLock.Unlock()\n\n\tconfigDirAbsPath := wavebase.GetWaveConfigDir()\n\tfullFileName := filepath.Join(configDirAbsPath, fileName)\n\tbarr, err := jsonMarshalConfigInOrder(m)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn fileutil.AtomicWriteFile(fullFileName, barr, 0644)\n}\n\n// simple merge that overwrites\nfunc mergeMetaMapSimple(m waveobj.MetaMapType, toMerge waveobj.MetaMapType) waveobj.MetaMapType {\n\tif m == nil {\n\t\treturn toMerge\n\t}\n\tif toMerge == nil {\n\t\treturn m\n\t}\n\tfor k, v := range toMerge {\n\t\tif v == nil {\n\t\t\tdelete(m, k)\n\t\t\tcontinue\n\t\t}\n\t\tm[k] = v\n\t}\n\tif len(m) == 0 {\n\t\treturn nil\n\t}\n\treturn m\n}\n\nfunc mergeMetaMap(m waveobj.MetaMapType, toMerge waveobj.MetaMapType, simpleMerge bool) waveobj.MetaMapType {\n\tif simpleMerge {\n\t\treturn mergeMetaMapSimple(m, toMerge)\n\t} else {\n\t\treturn waveobj.MergeMeta(m, toMerge, true)\n\t}\n}\n\nfunc selectDirEntsBySuffix(dirEnts []fs.DirEntry, fileNameSuffix string) []fs.DirEntry {\n\tvar rtn []fs.DirEntry\n\tfor _, ent := range dirEnts {\n\t\tif ent.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tif !strings.HasSuffix(ent.Name(), fileNameSuffix) {\n\t\t\tcontinue\n\t\t}\n\t\trtn = append(rtn, ent)\n\t}\n\treturn rtn\n}\n\nfunc SortFileNameDescend(files []fs.DirEntry) {\n\tsort.Slice(files, func(i, j int) bool {\n\t\treturn files[i].Name() > files[j].Name()\n\t})\n}\n\n// Read and merge all files in the specified directory matching the supplied suffix\nfunc readConfigFilesForDir(fsys fs.FS, logPrefix string, dirName string, fileName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) {\n\tdirEnts, _ := fs.ReadDir(fsys, dirName)\n\tsuffixEnts := selectDirEntsBySuffix(dirEnts, fileName+\".json\")\n\tSortFileNameDescend(suffixEnts)\n\tvar rtn waveobj.MetaMapType\n\tvar errs []ConfigError\n\tfor _, ent := range suffixEnts {\n\t\tfileVal, cerrs := readConfigFileFS(fsys, logPrefix, filepath.Join(dirName, ent.Name()))\n\t\trtn = mergeMetaMap(rtn, fileVal, simpleMerge)\n\t\terrs = append(errs, cerrs...)\n\t}\n\treturn rtn, errs\n}\n\n// Read and merge all files in the specified config filesystem matching the patterns `<partName>.json` and `<partName>/*.json`\nfunc readConfigPartForFS(fsys fs.FS, logPrefix string, partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) {\n\tconfig, errs := readConfigFilesForDir(fsys, logPrefix, partName, \"\", simpleMerge)\n\tallErrs := errs\n\trtn := config\n\tconfig, errs = readConfigFileFS(fsys, logPrefix, partName+\".json\")\n\tallErrs = append(allErrs, errs...)\n\treturn mergeMetaMap(rtn, config, simpleMerge), allErrs\n}\n\n// Combine files from the defaults and home directory for the specified config part name\nfunc readConfigPart(partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) {\n\tconfigDirAbsPath := wavebase.GetWaveConfigDir()\n\tconfigDirFsys := os.DirFS(configDirAbsPath)\n\tdefaultConfigs, cerrs := readConfigPartForFS(defaultconfig.ConfigFS, \"defaults:\", partName, simpleMerge)\n\thomeConfigs, cerrs1 := readConfigPartForFS(configDirFsys, \"\", partName, simpleMerge)\n\n\trtn := defaultConfigs\n\tallErrs := append(cerrs, cerrs1...)\n\treturn mergeMetaMap(rtn, homeConfigs, simpleMerge), allErrs\n}\n\n// this function should only be called by the wconfig code.\n// in golang code, the best way to get the current config is via the watcher -- wconfig.GetWatcher().GetFullConfig()\nfunc ReadFullConfig() FullConfigType {\n\tvar fullConfig FullConfigType\n\tconfigRType := reflect.TypeOf(fullConfig)\n\tconfigRVal := reflect.ValueOf(&fullConfig).Elem()\n\tfor fieldIdx := 0; fieldIdx < configRType.NumField(); fieldIdx++ {\n\t\tfield := configRType.Field(fieldIdx)\n\t\tif field.PkgPath != \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tconfigFile := field.Tag.Get(\"configfile\")\n\t\tif configFile == \"-\" {\n\t\t\tcontinue\n\t\t}\n\t\tjsonTag := utilfn.GetJsonTag(field)\n\t\tsimpleMerge := field.Tag.Get(\"merge\") == \"\"\n\t\tvar configPart waveobj.MetaMapType\n\t\tvar errs []ConfigError\n\t\tif jsonTag == \"-\" || jsonTag == \"\" {\n\t\t\tcontinue\n\t\t} else {\n\t\t\tconfigPart, errs = readConfigPart(jsonTag, simpleMerge)\n\t\t}\n\t\tfullConfig.ConfigErrors = append(fullConfig.ConfigErrors, errs...)\n\t\tif configPart != nil {\n\t\t\tfieldPtr := configRVal.Field(fieldIdx).Addr().Interface()\n\t\t\tutilfn.ReUnmarshal(fieldPtr, configPart)\n\t\t}\n\t}\n\treturn fullConfig\n}\n\nfunc GetConfigSubdirs() []string {\n\tvar fullConfig FullConfigType\n\tconfigRType := reflect.TypeOf(fullConfig)\n\tvar retVal []string\n\tconfigDirAbsPath := wavebase.GetWaveConfigDir()\n\tfor fieldIdx := 0; fieldIdx < configRType.NumField(); fieldIdx++ {\n\t\tfield := configRType.Field(fieldIdx)\n\t\tif field.PkgPath != \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tconfigFile := field.Tag.Get(\"configfile\")\n\t\tif configFile == \"-\" {\n\t\t\tcontinue\n\t\t}\n\t\tjsonTag := utilfn.GetJsonTag(field)\n\t\tif jsonTag != \"-\" && jsonTag != \"\" && jsonTag != \"settings\" {\n\t\t\tretVal = append(retVal, filepath.Join(configDirAbsPath, jsonTag))\n\t\t}\n\t}\n\tlog.Printf(\"subdirs: %v\\n\", retVal)\n\treturn retVal\n}\n\nfunc getConfigKeyType(configKey string) reflect.Type {\n\tctype := reflect.TypeOf(SettingsType{})\n\tfor i := 0; i < ctype.NumField(); i++ {\n\t\tfield := ctype.Field(i)\n\t\tjsonTag := utilfn.GetJsonTag(field)\n\t\tif jsonTag == configKey {\n\t\t\treturn field.Type\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc getConfigKeyNamespace(key string) string {\n\tcolonIdx := strings.Index(key, \":\")\n\tif colonIdx == -1 {\n\t\treturn \"\"\n\t}\n\treturn key[:colonIdx]\n}\n\nfunc orderConfigKeys(m waveobj.MetaMapType) []string {\n\tkeys := make([]string, 0, len(m))\n\tfor k := range m {\n\t\tkeys = append(keys, k)\n\t}\n\tsort.Slice(keys, func(i, j int) bool {\n\t\tk1 := keys[i]\n\t\tk2 := keys[j]\n\t\tk1ns := getConfigKeyNamespace(k1)\n\t\tk2ns := getConfigKeyNamespace(k2)\n\t\tif k1ns != k2ns {\n\t\t\treturn k1ns < k2ns\n\t\t}\n\t\treturn k1 < k2\n\t})\n\treturn keys\n}\n\nfunc reindentJson(barr []byte, indentStr string) []byte {\n\tif len(barr) < 2 {\n\t\treturn barr\n\t}\n\tif barr[0] != '{' && barr[0] != '[' {\n\t\treturn barr\n\t}\n\tif !bytes.Contains(barr, []byte(\"\\n\")) {\n\t\treturn barr\n\t}\n\toutputLines := bytes.Split(barr, []byte(\"\\n\"))\n\tfor i, line := range outputLines {\n\t\tif i == 0 {\n\t\t\tcontinue\n\t\t}\n\t\toutputLines[i] = append([]byte(indentStr), line...)\n\t}\n\treturn bytes.Join(outputLines, []byte(\"\\n\"))\n}\n\nfunc jsonMarshalConfigInOrder(m waveobj.MetaMapType) ([]byte, error) {\n\tif len(m) == 0 {\n\t\treturn []byte(\"{}\"), nil\n\t}\n\tvar buf bytes.Buffer\n\torderedKeys := orderConfigKeys(m)\n\tbuf.WriteString(\"{\\n\")\n\tfor idx, key := range orderedKeys {\n\t\tval := m[key]\n\t\tkeyBarr, err := json.Marshal(key)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tvalBarr, err := json.MarshalIndent(val, \"\", \"  \")\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tvalBarr = reindentJson(valBarr, \"  \")\n\t\tbuf.WriteString(\"  \")\n\t\tbuf.Write(keyBarr)\n\t\tbuf.WriteString(\": \")\n\t\tbuf.Write(valBarr)\n\t\tif idx < len(orderedKeys)-1 {\n\t\t\tbuf.WriteString(\",\")\n\t\t}\n\t\tbuf.WriteString(\"\\n\")\n\t}\n\tbuf.WriteString(\"}\")\n\treturn buf.Bytes(), nil\n}\n\nvar dummyNumber json.Number\n\nfunc convertJsonNumber(num json.Number, ctype reflect.Type) (interface{}, error) {\n\t// ctype might be int64, float64, string, *int64, *float64, *string\n\t// switch on ctype first\n\tif ctype.Kind() == reflect.Pointer {\n\t\tctype = ctype.Elem()\n\t}\n\tif reflect.Int64 == ctype.Kind() {\n\t\tif ival, err := num.Int64(); err == nil {\n\t\t\treturn ival, nil\n\t\t}\n\t\treturn nil, fmt.Errorf(\"invalid number for int64: %s\", num)\n\t}\n\tif reflect.Float64 == ctype.Kind() {\n\t\tif fval, err := num.Float64(); err == nil {\n\t\t\treturn fval, nil\n\t\t}\n\t\treturn nil, fmt.Errorf(\"invalid number for float64: %s\", num)\n\t}\n\tif reflect.String == ctype.Kind() {\n\t\treturn num.String(), nil\n\t}\n\treturn nil, fmt.Errorf(\"cannot convert number to %s\", ctype)\n}\n\nfunc SetBaseConfigValue(toMerge waveobj.MetaMapType) error {\n\tm, cerrs := ReadWaveHomeConfigFile(SettingsFile)\n\tif len(cerrs) > 0 {\n\t\treturn fmt.Errorf(\"error reading config file: %v\", cerrs[0])\n\t}\n\tif m == nil {\n\t\tm = make(waveobj.MetaMapType)\n\t}\n\tfor configKey, val := range toMerge {\n\t\tctype := getConfigKeyType(configKey)\n\t\tif ctype == nil {\n\t\t\treturn fmt.Errorf(\"invalid config key: %s\", configKey)\n\t\t}\n\t\tif val == nil {\n\t\t\tdelete(m, configKey)\n\t\t} else {\n\t\t\trtype := reflect.TypeOf(val)\n\t\t\tif rtype == reflect.TypeOf(dummyNumber) {\n\t\t\t\tconvertedVal, err := convertJsonNumber(val.(json.Number), ctype)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"cannot convert %s: %v\", configKey, err)\n\t\t\t\t}\n\t\t\t\tval = convertedVal\n\t\t\t\trtype = reflect.TypeOf(val)\n\t\t\t}\n\t\t\tif rtype != ctype {\n\t\t\t\tif ctype == reflect.PointerTo(rtype) {\n\t\t\t\t\tm[configKey] = &val\n\t\t\t\t} else {\n\t\t\t\t\treturn fmt.Errorf(\"invalid value type for %s: %T\", configKey, val)\n\t\t\t\t}\n\t\t\t}\n\t\t\tm[configKey] = val\n\t\t}\n\t}\n\treturn WriteWaveHomeConfigFile(SettingsFile, m)\n}\n\nfunc SetConnectionsConfigValue(connName string, toMerge waveobj.MetaMapType) error {\n\tm, cerrs := ReadWaveHomeConfigFile(ConnectionsFile)\n\tif len(cerrs) > 0 {\n\t\treturn fmt.Errorf(\"error reading config file: %v\", cerrs[0])\n\t}\n\tif m == nil {\n\t\tm = make(waveobj.MetaMapType)\n\t}\n\tconnData := m.GetMap(connName)\n\tif connData == nil {\n\t\tconnData = make(waveobj.MetaMapType)\n\t}\n\tfor configKey, val := range toMerge {\n\t\tconnData[configKey] = val\n\t}\n\tm[connName] = connData\n\treturn WriteWaveHomeConfigFile(ConnectionsFile, m)\n}\n\ntype WidgetConfigType struct {\n\tDisplayOrder  float64          `json:\"display:order,omitempty\"`\n\tDisplayHidden bool             `json:\"display:hidden,omitempty\"`\n\tIcon          string           `json:\"icon,omitempty\"`\n\tColor         string           `json:\"color,omitempty\"`\n\tLabel         string           `json:\"label,omitempty\"`\n\tDescription   string           `json:\"description,omitempty\"`\n\tWorkspaces    []string         `json:\"workspaces,omitempty\"`\n\tMagnified     bool             `json:\"magnified,omitempty\"`\n\tBlockDef      waveobj.BlockDef `json:\"blockdef\"`\n}\n\ntype BgPresetsType struct {\n\tBgClear             bool    `json:\"bg:*,omitempty\"`\n\tBg                  string  `json:\"bg,omitempty\" jsonschema_description:\"CSS background property value\"`\n\tBgOpacity           float64 `json:\"bg:opacity,omitempty\" jsonschema_description:\"Background opacity (0.0-1.0)\"`\n\tBgBlendMode         string  `json:\"bg:blendmode,omitempty\" jsonschema_description:\"CSS background-blend-mode property value\"`\n\tBgBorderColor       string  `json:\"bg:bordercolor,omitempty\" jsonschema_description:\"Block frame border color\"`\n\tBgActiveBorderColor string  `json:\"bg:activebordercolor,omitempty\" jsonschema_description:\"Block frame focused border color\"`\n\tDisplayName         string  `json:\"display:name,omitempty\" jsonschema_description:\"The name shown in the context menu\"`\n\tDisplayOrder        float64 `json:\"display:order,omitempty\" jsonschema_description:\"Determines the order of the background in the context menu\"`\n}\n\ntype MimeTypeConfigType struct {\n\tIcon  string `json:\"icon\"`\n\tColor string `json:\"color\"`\n}\n\ntype TermThemeType struct {\n\tDisplayName         string  `json:\"display:name\"`\n\tDisplayOrder        float64 `json:\"display:order\"`\n\tBlack               string  `json:\"black\"`\n\tRed                 string  `json:\"red\"`\n\tGreen               string  `json:\"green\"`\n\tYellow              string  `json:\"yellow\"`\n\tBlue                string  `json:\"blue\"`\n\tMagenta             string  `json:\"magenta\"`\n\tCyan                string  `json:\"cyan\"`\n\tWhite               string  `json:\"white\"`\n\tBrightBlack         string  `json:\"brightBlack\"`\n\tBrightRed           string  `json:\"brightRed\"`\n\tBrightGreen         string  `json:\"brightGreen\"`\n\tBrightYellow        string  `json:\"brightYellow\"`\n\tBrightBlue          string  `json:\"brightBlue\"`\n\tBrightMagenta       string  `json:\"brightMagenta\"`\n\tBrightCyan          string  `json:\"brightCyan\"`\n\tBrightWhite         string  `json:\"brightWhite\"`\n\tGray                string  `json:\"gray\"`\n\tCmdText             string  `json:\"cmdtext\"`\n\tForeground          string  `json:\"foreground\"`\n\tSelectionBackground string  `json:\"selectionBackground\"`\n\tBackground          string  `json:\"background\"`\n\tCursor              string  `json:\"cursor\"`\n}\n\n// CountCustomWidgets returns the number of custom widgets the user has defined.\n// Custom widgets are identified as widgets whose ID doesn't start with \"defwidget@\".\nfunc (fc *FullConfigType) CountCustomWidgets() int {\n\tcount := 0\n\tfor widgetID := range fc.Widgets {\n\t\tif !strings.HasPrefix(widgetID, \"defwidget@\") {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\n// CountCustomAIPresets returns the number of custom AI presets the user has defined.\n// Custom AI presets are identified as presets that start with \"ai@\" but aren't \"ai@global\" or \"ai@wave\".\nfunc (fc *FullConfigType) CountCustomAIPresets() int {\n\tcount := 0\n\tfor presetID := range fc.Presets {\n\t\tif strings.HasPrefix(presetID, \"ai@\") && presetID != \"ai@global\" && presetID != \"ai@wave\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\n// CountCustomAIModes returns the number of custom AI modes the user has defined.\n// Custom AI modes are identified as modes that don't start with \"waveai@\".\nfunc (fc *FullConfigType) CountCustomAIModes() int {\n\tcount := 0\n\tfor modeID := range fc.WaveAIModes {\n\t\tif !strings.HasPrefix(modeID, \"waveai@\") {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\n// CountCustomSettings returns the number of settings in the user's settings file.\n// This excludes telemetry:enabled and autoupdate:channel which don't count as customizations.\nfunc CountCustomSettings() int {\n\t// Load user settings\n\tuserSettings, _ := ReadWaveHomeConfigFile(\"settings.json\")\n\tif userSettings == nil {\n\t\treturn 0\n\t}\n\n\t// Count all keys except telemetry:enabled and autoupdate:channel\n\tcount := 0\n\tfor key := range userSettings {\n\t\tif key == \"telemetry:enabled\" || key == \"autoupdate:channel\" {\n\t\t\tcontinue\n\t\t}\n\t\tcount++\n\t}\n\n\treturn count\n}\n"
  },
  {
    "path": "pkg/wcore/badge.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wcore\n\nimport (\n\t\"log\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\n// BadgeStore is an in-memory store for transient badges.\n// Badges are not persisted and are cleared on restart.\n// Values are stored by value (not pointer) to prevent external mutation.\ntype BadgeStore struct {\n\tlock      *sync.Mutex\n\ttransient map[string]baseds.Badge // keyed by oref string\n}\n\nvar globalBadgeStore = &BadgeStore{\n\tlock:      &sync.Mutex{},\n\ttransient: make(map[string]baseds.Badge),\n}\n\n// InitBadgeStore subscribes to incoming badge events.\nfunc InitBadgeStore() error {\n\tlog.Printf(\"initializing badge store\\n\")\n\n\trpcClient := wshclient.GetBareRpcClient()\n\trpcClient.EventListener.On(wps.Event_Badge, handleBadgeEvent)\n\twshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{\n\t\tEvent:     wps.Event_Badge,\n\t\tAllScopes: true,\n\t}, nil)\n\n\treturn nil\n}\n\nfunc handleBadgeEvent(event *wps.WaveEvent) {\n\tif event.Event != wps.Event_Badge {\n\t\treturn\n\t}\n\tvar data baseds.BadgeEvent\n\terr := utilfn.ReUnmarshal(&data, event.Data)\n\tif err != nil {\n\t\tlog.Printf(\"badge store: error unmarshaling BadgeEvent: %v\\n\", err)\n\t\treturn\n\t}\n\tif data.ClearAll {\n\t\tclearAllBadges()\n\t\treturn\n\t}\n\tif data.ORef == \"\" {\n\t\tlog.Printf(\"badge store: received badge event with empty oref\\n\")\n\t\treturn\n\t}\n\toref, err := waveobj.ParseORef(data.ORef)\n\tif err != nil {\n\t\tlog.Printf(\"badge store: error parsing oref %q: %v\\n\", data.ORef, err)\n\t\treturn\n\t}\n\tif oref.OType != waveobj.OType_Block && oref.OType != waveobj.OType_Tab {\n\t\tlog.Printf(\"badge store: can only handle block/tab orefs\")\n\t\treturn\n\t}\n\n\tsetBadge(oref, data)\n}\n\n// cmpBadge compares two badges by priority then by badgeid (both descending).\n// Returns 1 if a > b, -1 if a < b, 0 if equal.\nfunc cmpBadge(a, b baseds.Badge) int {\n\tif a.Priority != b.Priority {\n\t\tif a.Priority > b.Priority {\n\t\t\treturn 1\n\t\t}\n\t\treturn -1\n\t}\n\tif a.BadgeId != b.BadgeId {\n\t\tif a.BadgeId > b.BadgeId {\n\t\t\treturn 1\n\t\t}\n\t\treturn -1\n\t}\n\treturn 0\n}\n\n// setBadge updates the in-memory transient map.\nfunc setBadge(oref waveobj.ORef, data baseds.BadgeEvent) {\n\tglobalBadgeStore.lock.Lock()\n\tdefer globalBadgeStore.lock.Unlock()\n\n\torefStr := oref.String()\n\tif orefStr == \"\" {\n\t\treturn\n\t}\n\n\tif data.ClearById != \"\" {\n\t\texisting, ok := globalBadgeStore.transient[orefStr]\n\t\tif !ok || existing.BadgeId != data.ClearById {\n\t\t\treturn\n\t\t}\n\t\tdelete(globalBadgeStore.transient, orefStr)\n\t\tlog.Printf(\"badge store: badge cleared by id: oref=%s id=%s\\n\", orefStr, data.ClearById)\n\t\treturn\n\t}\n\tif data.Clear {\n\t\tdelete(globalBadgeStore.transient, orefStr)\n\t\tlog.Printf(\"badge store: badge cleared: oref=%s\\n\", orefStr)\n\t\treturn\n\t}\n\tif data.Badge == nil {\n\t\treturn\n\t}\n\tincoming := *data.Badge\n\texisting, hasExisting := globalBadgeStore.transient[orefStr]\n\tif !hasExisting || cmpBadge(incoming, existing) > 0 {\n\t\tglobalBadgeStore.transient[orefStr] = incoming\n\t\tlog.Printf(\"badge store: badge set: oref=%s badge=%+v\\n\", orefStr, incoming)\n\t}\n}\n\n// clearAllBadges removes all badges from the transient store.\nfunc clearAllBadges() {\n\tglobalBadgeStore.lock.Lock()\n\tdefer globalBadgeStore.lock.Unlock()\n\n\tcount := len(globalBadgeStore.transient)\n\tglobalBadgeStore.transient = make(map[string]baseds.Badge)\n\tlog.Printf(\"badge store: cleared all %d badges\\n\", count)\n}\n\n// GetAllBadges returns a snapshot of all currently active badges.\nfunc GetAllBadges() []baseds.BadgeEvent {\n\tglobalBadgeStore.lock.Lock()\n\tdefer globalBadgeStore.lock.Unlock()\n\n\tresult := make([]baseds.BadgeEvent, 0, len(globalBadgeStore.transient))\n\tfor orefStr, badge := range globalBadgeStore.transient {\n\t\tb := badge // copy\n\t\tresult = append(result, baseds.BadgeEvent{\n\t\t\tORef:  orefStr,\n\t\t\tBadge: &b,\n\t\t})\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "pkg/wcore/block.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wcore\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/filestore\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nfunc CreateSubBlock(ctx context.Context, blockId string, blockDef *waveobj.BlockDef) (*waveobj.Block, error) {\n\tif blockDef == nil {\n\t\treturn nil, fmt.Errorf(\"blockDef is nil\")\n\t}\n\tif blockDef.Meta == nil || blockDef.Meta.GetString(waveobj.MetaKey_View, \"\") == \"\" {\n\t\treturn nil, fmt.Errorf(\"no view provided for new block\")\n\t}\n\tblockData, err := createSubBlockObj(ctx, blockId, blockDef)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating sub block: %w\", err)\n\t}\n\treturn blockData, nil\n}\n\nfunc createSubBlockObj(ctx context.Context, parentBlockId string, blockDef *waveobj.BlockDef) (*waveobj.Block, error) {\n\treturn wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) (*waveobj.Block, error) {\n\t\tparentBlock, _ := wstore.DBGet[*waveobj.Block](tx.Context(), parentBlockId)\n\t\tif parentBlock == nil {\n\t\t\treturn nil, fmt.Errorf(\"parent block not found: %q\", parentBlockId)\n\t\t}\n\t\tblockId := uuid.NewString()\n\t\tblockData := &waveobj.Block{\n\t\t\tOID:         blockId,\n\t\t\tParentORef:  waveobj.MakeORef(waveobj.OType_Block, parentBlockId).String(),\n\t\t\tRuntimeOpts: nil,\n\t\t\tMeta:        blockDef.Meta,\n\t\t}\n\t\twstore.DBInsert(tx.Context(), blockData)\n\t\tparentBlock.SubBlockIds = append(parentBlock.SubBlockIds, blockId)\n\t\twstore.DBUpdate(tx.Context(), parentBlock)\n\t\treturn blockData, nil\n\t})\n}\n\nfunc CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (rtnBlock *waveobj.Block, rtnErr error) {\n\treturn CreateBlockWithTelemetry(ctx, tabId, blockDef, rtOpts, true)\n}\n\nfunc CreateBlockWithTelemetry(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts, recordTelemetry bool) (rtnBlock *waveobj.Block, rtnErr error) {\n\tvar blockCreated bool\n\tvar newBlockOID string\n\tdefer func() {\n\t\tif rtnErr == nil {\n\t\t\treturn\n\t\t}\n\t\t// if there was an error, and we created the block, clean it up since the function failed\n\t\tif blockCreated && newBlockOID != \"\" {\n\t\t\tdeleteBlockObj(ctx, newBlockOID)\n\t\t\tfilestore.WFS.DeleteZone(ctx, newBlockOID)\n\t\t}\n\t}()\n\tif blockDef == nil {\n\t\treturn nil, fmt.Errorf(\"blockDef is nil\")\n\t}\n\tif blockDef.Meta == nil || blockDef.Meta.GetString(waveobj.MetaKey_View, \"\") == \"\" {\n\t\treturn nil, fmt.Errorf(\"no view provided for new block\")\n\t}\n\tblockData, err := createBlockObj(ctx, tabId, blockDef, rtOpts)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating block: %w\", err)\n\t}\n\tblockCreated = true\n\tnewBlockOID = blockData.OID\n\t// upload the files if present\n\tif len(blockDef.Files) > 0 {\n\t\tfor fileName, fileDef := range blockDef.Files {\n\t\t\terr := filestore.WFS.MakeFile(ctx, newBlockOID, fileName, fileDef.Meta, wshrpc.FileOpts{})\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error making blockfile %q: %w\", fileName, err)\n\t\t\t}\n\t\t\terr = filestore.WFS.WriteFile(ctx, newBlockOID, fileName, []byte(fileDef.Content))\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error writing blockfile %q: %w\", fileName, err)\n\t\t\t}\n\t\t}\n\t}\n\tif recordTelemetry {\n\t\tblockView := blockDef.Meta.GetString(waveobj.MetaKey_View, \"\")\n\t\tblockController := blockDef.Meta.GetString(waveobj.MetaKey_Controller, \"\")\n\t\tgo recordBlockCreationTelemetry(blockView, blockController)\n\t}\n\treturn blockData, nil\n}\n\nfunc recordBlockCreationTelemetry(blockView string, blockController string) {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"CreateBlock:telemetry\", recover())\n\t}()\n\tif blockView == \"\" {\n\t\treturn\n\t}\n\ttctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\ttelemetry.UpdateActivity(tctx, wshrpc.ActivityUpdate{\n\t\tRenderers: map[string]int{blockView: 1},\n\t})\n\ttelemetry.RecordTEvent(tctx, &telemetrydata.TEvent{\n\t\tEvent: \"action:createblock\",\n\t\tProps: telemetrydata.TEventProps{\n\t\t\tBlockView:       blockView,\n\t\t\tBlockController: blockController,\n\t\t},\n\t})\n}\n\nfunc createBlockObj(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) {\n\treturn wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) (*waveobj.Block, error) {\n\t\ttab, _ := wstore.DBGet[*waveobj.Tab](tx.Context(), tabId)\n\t\tif tab == nil {\n\t\t\treturn nil, fmt.Errorf(\"tab not found: %q\", tabId)\n\t\t}\n\t\tblockId := uuid.NewString()\n\t\tblockData := &waveobj.Block{\n\t\t\tOID:         blockId,\n\t\t\tParentORef:  waveobj.MakeORef(waveobj.OType_Tab, tabId).String(),\n\t\t\tRuntimeOpts: rtOpts,\n\t\t\tMeta:        blockDef.Meta,\n\t\t}\n\t\twstore.DBInsert(tx.Context(), blockData)\n\t\ttab.BlockIds = append(tab.BlockIds, blockId)\n\t\twstore.DBUpdate(tx.Context(), tab)\n\t\treturn blockData, nil\n\t})\n}\n\n// Must delete all blocks individually first.\n// Also deletes LayoutState.\n// recursive: if true, will recursively close parent tab, window, workspace, if they are empty.\n// Returns new active tab id, error.\nfunc DeleteBlock(ctx context.Context, blockId string, recursive bool) error {\n\tblock, err := wstore.DBGet[*waveobj.Block](ctx, blockId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting block: %w\", err)\n\t}\n\tif block == nil {\n\t\treturn nil\n\t}\n\tif len(block.SubBlockIds) > 0 {\n\t\tfor _, subBlockId := range block.SubBlockIds {\n\t\t\terr := DeleteBlock(ctx, subBlockId, recursive)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error deleting subblock %s: %w\", subBlockId, err)\n\t\t\t}\n\t\t}\n\t}\n\tparentBlockCount, err := deleteBlockObj(ctx, blockId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error deleting block: %w\", err)\n\t}\n\tlog.Printf(\"DeleteBlock: parentBlockCount: %d\", parentBlockCount)\n\tparentORef := waveobj.ParseORefNoErr(block.ParentORef)\n\n\tif recursive && parentORef.OType == waveobj.OType_Tab && parentBlockCount == 0 {\n\t\t// if parent tab has no blocks, delete the tab\n\t\tlog.Printf(\"DeleteBlock: parent tab has no blocks, deleting tab %s\", parentORef.OID)\n\t\tparentWorkspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, parentORef.OID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error finding workspace for tab to delete %s: %w\", parentORef.OID, err)\n\t\t}\n\t\tnewActiveTabId, err := DeleteTab(ctx, parentWorkspaceId, parentORef.OID, true)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error deleting tab %s: %w\", parentORef.OID, err)\n\t\t}\n\t\tSendActiveTabUpdate(ctx, parentWorkspaceId, newActiveTabId)\n\t}\n\tsendBlockCloseEvent(blockId)\n\treturn nil\n}\n\n// returns the updated block count for the parent object\nfunc deleteBlockObj(ctx context.Context, blockId string) (int, error) {\n\treturn wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) (int, error) {\n\t\tblock, err := wstore.DBGet[*waveobj.Block](tx.Context(), blockId)\n\t\tif err != nil {\n\t\t\treturn -1, fmt.Errorf(\"error getting block: %w\", err)\n\t\t}\n\t\tif block == nil {\n\t\t\treturn -1, fmt.Errorf(\"block not found: %q\", blockId)\n\t\t}\n\t\tif len(block.SubBlockIds) > 0 {\n\t\t\treturn -1, fmt.Errorf(\"block has subblocks, must delete subblocks first\")\n\t\t}\n\t\tparentORef := waveobj.ParseORefNoErr(block.ParentORef)\n\t\tparentBlockCount := -1\n\t\tif parentORef != nil {\n\t\t\tif parentORef.OType == waveobj.OType_Tab {\n\t\t\t\ttab, _ := wstore.DBGet[*waveobj.Tab](tx.Context(), parentORef.OID)\n\t\t\t\tif tab != nil {\n\t\t\t\t\ttab.BlockIds = utilfn.RemoveElemFromSlice(tab.BlockIds, blockId)\n\t\t\t\t\twstore.DBUpdate(tx.Context(), tab)\n\t\t\t\t\tparentBlockCount = len(tab.BlockIds)\n\t\t\t\t}\n\t\t\t} else if parentORef.OType == waveobj.OType_Block {\n\t\t\t\tparentBlock, _ := wstore.DBGet[*waveobj.Block](tx.Context(), parentORef.OID)\n\t\t\t\tif parentBlock != nil {\n\t\t\t\t\tparentBlock.SubBlockIds = utilfn.RemoveElemFromSlice(parentBlock.SubBlockIds, blockId)\n\t\t\t\t\twstore.DBUpdate(tx.Context(), parentBlock)\n\t\t\t\t\tparentBlockCount = len(parentBlock.SubBlockIds)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\twstore.DBDelete(tx.Context(), waveobj.OType_Block, blockId)\n\n\t\t// Clean up block runtime info\n\t\tblockORef := waveobj.MakeORef(waveobj.OType_Block, blockId)\n\t\twstore.DeleteRTInfo(blockORef)\n\n\t\treturn parentBlockCount, nil\n\t})\n}\n\nfunc sendBlockCloseEvent(blockId string) {\n\twaveEvent := wps.WaveEvent{\n\t\tEvent: wps.Event_BlockClose,\n\t\tScopes: []string{\n\t\t\twaveobj.MakeORef(waveobj.OType_Block, blockId).String(),\n\t\t},\n\t\tData: blockId,\n\t}\n\twps.Broker.Publish(waveEvent)\n}\n"
  },
  {
    "path": "pkg/wcore/layout.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wcore\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nconst (\n\tLayoutActionDataType_Insert          = \"insert\"\n\tLayoutActionDataType_InsertAtIndex   = \"insertatindex\"\n\tLayoutActionDataType_Remove          = \"delete\"\n\tLayoutActionDataType_ClearTree       = \"clear\"\n\tLayoutActionDataType_Replace         = \"replace\"\n\tLayoutActionDataType_SplitHorizontal = \"splithorizontal\"\n\tLayoutActionDataType_SplitVertical   = \"splitvertical\"\n\tLayoutActionDataType_CleanupOrphaned = \"cleanuporphaned\"\n)\n\ntype PortableLayout []struct {\n\tIndexArr []int             `json:\"indexarr\"`\n\tSize     *uint             `json:\"size,omitempty\"`\n\tBlockDef *waveobj.BlockDef `json:\"blockdef\"`\n\tFocused  bool              `json:\"focused\"`\n}\n\nfunc GetStarterLayout() PortableLayout {\n\treturn PortableLayout{\n\t\t{IndexArr: []int{0}, BlockDef: &waveobj.BlockDef{\n\t\t\tMeta: waveobj.MetaMapType{\n\t\t\t\twaveobj.MetaKey_View:       \"term\",\n\t\t\t\twaveobj.MetaKey_Controller: \"shell\",\n\t\t\t},\n\t\t}, Focused: true},\n\t\t{IndexArr: []int{1}, BlockDef: &waveobj.BlockDef{\n\t\t\tMeta: waveobj.MetaMapType{\n\t\t\t\twaveobj.MetaKey_View: \"sysinfo\",\n\t\t\t},\n\t\t}},\n\t\t{IndexArr: []int{1, 1}, BlockDef: &waveobj.BlockDef{\n\t\t\tMeta: waveobj.MetaMapType{\n\t\t\t\twaveobj.MetaKey_View: \"web\",\n\t\t\t\twaveobj.MetaKey_Url:  \"https://github.com/wavetermdev/waveterm\",\n\t\t\t},\n\t\t}},\n\t\t{IndexArr: []int{1, 2}, BlockDef: &waveobj.BlockDef{\n\t\t\tMeta: waveobj.MetaMapType{\n\t\t\t\twaveobj.MetaKey_View: \"preview\",\n\t\t\t\twaveobj.MetaKey_File: \"~\",\n\t\t\t},\n\t\t}},\n\t}\n}\n\nfunc GetNewTabLayout() PortableLayout {\n\treturn PortableLayout{\n\t\t{IndexArr: []int{0}, BlockDef: &waveobj.BlockDef{\n\t\t\tMeta: waveobj.MetaMapType{\n\t\t\t\twaveobj.MetaKey_View:       \"term\",\n\t\t\t\twaveobj.MetaKey_Controller: \"shell\",\n\t\t\t},\n\t\t}, Focused: true},\n\t}\n}\n\nfunc GetLayoutIdForTab(ctx context.Context, tabId string) (string, error) {\n\ttabObj, err := wstore.DBGet[*waveobj.Tab](ctx, tabId)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to get layout id for given tab id %s: %w\", tabId, err)\n\t}\n\treturn tabObj.LayoutState, nil\n}\n\nfunc QueueLayoutAction(ctx context.Context, layoutStateId string, actions ...waveobj.LayoutActionData) error {\n\tlayoutStateObj, err := wstore.DBGet[*waveobj.LayoutState](ctx, layoutStateId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to get layout state for given id %s: %w\", layoutStateId, err)\n\t}\n\n\tfor i := range actions {\n\t\tif actions[i].ActionId == \"\" {\n\t\t\tactions[i].ActionId = uuid.New().String()\n\t\t}\n\t}\n\n\tif layoutStateObj.PendingBackendActions == nil {\n\t\tlayoutStateObj.PendingBackendActions = &actions\n\t} else {\n\t\t*layoutStateObj.PendingBackendActions = append(*layoutStateObj.PendingBackendActions, actions...)\n\t}\n\n\terr = wstore.DBUpdate(ctx, layoutStateObj)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to update layout state with new actions: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc QueueLayoutActionForTab(ctx context.Context, tabId string, actions ...waveobj.LayoutActionData) error {\n\tlayoutStateId, err := GetLayoutIdForTab(ctx, tabId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn QueueLayoutAction(ctx, layoutStateId, actions...)\n}\n\nfunc ApplyPortableLayout(ctx context.Context, tabId string, layout PortableLayout, recordTelemetry bool) error {\n\tactions := make([]waveobj.LayoutActionData, len(layout)+1)\n\tactions[0] = waveobj.LayoutActionData{ActionType: LayoutActionDataType_ClearTree}\n\tfor i := 0; i < len(layout); i++ {\n\t\tlayoutAction := layout[i]\n\n\t\tblockData, err := CreateBlockWithTelemetry(ctx, tabId, layoutAction.BlockDef, &waveobj.RuntimeOpts{}, recordTelemetry)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"unable to create block to apply portable layout to tab %s: %w\", tabId, err)\n\t\t}\n\n\t\tactions[i+1] = waveobj.LayoutActionData{\n\t\t\tActionType: LayoutActionDataType_InsertAtIndex,\n\t\t\tBlockId:    blockData.OID,\n\t\t\tIndexArr:   &layoutAction.IndexArr,\n\t\t\tNodeSize:   layoutAction.Size,\n\t\t\tFocused:    layoutAction.Focused,\n\t\t}\n\t}\n\n\terr := QueueLayoutActionForTab(ctx, tabId, actions...)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to queue layout actions for portable layout: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc BootstrapStarterLayout(ctx context.Context) error {\n\tctx, cancelFn := context.WithTimeout(ctx, 2*time.Second)\n\tdefer cancelFn()\n\tclient, err := wstore.DBGetSingleton[*waveobj.Client](ctx)\n\tif err != nil {\n\t\tlog.Printf(\"unable to find client: %v\\n\", err)\n\t\treturn fmt.Errorf(\"unable to find client: %w\", err)\n\t}\n\n\tif len(client.WindowIds) < 1 {\n\t\treturn fmt.Errorf(\"error bootstrapping layout, no windows exist\")\n\t}\n\n\twindowId := client.WindowIds[0]\n\n\twindow, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting window: %w\", err)\n\t}\n\n\tworkspace, err := wstore.DBMustGet[*waveobj.Workspace](ctx, window.WorkspaceId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting workspace: %w\", err)\n\t}\n\n\ttabId := workspace.ActiveTabId\n\n\tstarterLayout := GetStarterLayout()\n\terr = ApplyPortableLayout(ctx, tabId, starterLayout, false)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error applying starter layout: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/wcore/wcore.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// wave core application coordinator\npackage wcore\n\nimport (\n\t\"context\"\n\t\"crypto/ed25519\"\n\t\"crypto/x509\"\n\t\"encoding/base64\"\n\t\"encoding/pem\"\n\t\"fmt\"\n\t\"log\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavejwt\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcloud\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\n// the wcore package coordinates actions across the storage layer\n// orchestrating the wave object store, the wave pubsub system, and the wave rpc system\n\n// Ensures that the initial data is present in the store, creates an initial window if needed\nfunc EnsureInitialData() (bool, error) {\n\t// does not need to run in a transaction since it is called on startup\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\tclient, err := wstore.DBGetSingleton[*waveobj.Client](ctx)\n\tfirstLaunch := false\n\tif err == wstore.ErrNotFound {\n\t\tclient, err = CreateClient(ctx)\n\t\tif err != nil {\n\t\t\treturn false, fmt.Errorf(\"error creating client: %w\", err)\n\t\t}\n\t\tfirstLaunch = true\n\t}\n\tif client.TempOID == \"\" {\n\t\tlog.Println(\"client.TempOID is empty\")\n\t\tclient.TempOID = uuid.NewString()\n\t\terr = wstore.DBUpdate(ctx, client)\n\t\tif err != nil {\n\t\t\treturn firstLaunch, fmt.Errorf(\"error updating client: %w\", err)\n\t\t}\n\t}\n\tif client.InstallId == \"\" {\n\t\tlog.Println(\"client.InstallId is empty\")\n\t\tclient.InstallId = uuid.NewString()\n\t\terr = wstore.DBUpdate(ctx, client)\n\t\tif err != nil {\n\t\t\treturn firstLaunch, fmt.Errorf(\"error updating client: %w\", err)\n\t\t}\n\t}\n\tlog.Printf(\"clientid: %s\\n\", client.OID)\n\twstore.SetClientId(client.OID)\n\tif len(client.WindowIds) == 1 {\n\t\tlog.Println(\"client has one window\")\n\t\tCheckAndFixWindow(ctx, client.WindowIds[0])\n\t\treturn firstLaunch, nil\n\t}\n\tif len(client.WindowIds) > 0 {\n\t\tlog.Println(\"client has windows\")\n\t\treturn firstLaunch, nil\n\t}\n\twsId := \"\"\n\tif firstLaunch {\n\t\tlog.Println(\"client has no windows and first launch, creating starter workspace\")\n\t\tstarterWs, err := CreateWorkspace(ctx, \"Starter workspace\", \"custom@wave-logo-solid\", \"#58C142\", false, true)\n\t\tif err != nil {\n\t\t\treturn firstLaunch, fmt.Errorf(\"error creating starter workspace: %w\", err)\n\t\t}\n\t\twsId = starterWs.OID\n\t}\n\t_, err = CreateWindow(ctx, nil, wsId)\n\tif err != nil {\n\t\treturn firstLaunch, fmt.Errorf(\"error creating window: %w\", err)\n\t}\n\treturn firstLaunch, nil\n}\n\nfunc CreateClient(ctx context.Context) (*waveobj.Client, error) {\n\tclient := &waveobj.Client{\n\t\tOID:       uuid.NewString(),\n\t\tWindowIds: []string{},\n\t}\n\terr := wstore.DBInsert(ctx, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error inserting client: %w\", err)\n\t}\n\treturn client, nil\n}\n\nfunc GetClientData(ctx context.Context) (*waveobj.Client, error) {\n\tclientData, err := wstore.DBGetSingleton[*waveobj.Client](ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting client data: %w\", err)\n\t}\n\treturn clientData, nil\n}\n\nfunc SendWaveObjUpdate(oref waveobj.ORef) {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\t// send a waveobj:update event\n\twaveObj, err := wstore.DBGetORef(ctx, oref)\n\tif err != nil {\n\t\tlog.Printf(\"error getting object for update event: %v\", err)\n\t\treturn\n\t}\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent:  wps.Event_WaveObjUpdate,\n\t\tScopes: []string{oref.String()},\n\t\tData: waveobj.WaveObjUpdate{\n\t\t\tUpdateType: waveobj.UpdateType_Update,\n\t\t\tOType:      waveObj.GetOType(),\n\t\t\tOID:        waveobj.GetOID(waveObj),\n\t\t\tObj:        waveObj,\n\t\t},\n\t})\n}\n\nfunc ResolveBlockIdFromPrefix(ctx context.Context, tabId string, blockIdPrefix string) (string, error) {\n\tif len(blockIdPrefix) != 8 {\n\t\treturn \"\", fmt.Errorf(\"widget_id must be 8 characters\")\n\t}\n\n\ttab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error getting tab: %w\", err)\n\t}\n\n\tfor _, blockId := range tab.BlockIds {\n\t\tif strings.HasPrefix(blockId, blockIdPrefix) {\n\t\t\treturn blockId, nil\n\t\t}\n\t}\n\n\treturn \"\", fmt.Errorf(\"widget_id not found: %q\", blockIdPrefix)\n}\n\nfunc GoSendNoTelemetryUpdate(telemetryEnabled bool) {\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"GoSendNoTelemetryUpdate\", recover())\n\t\t}()\n\t\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer cancelFn()\n\t\tclientId := wstore.GetClientId()\n\t\terr := wcloud.SendNoTelemetryUpdate(ctx, clientId, !telemetryEnabled)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[error] sending no-telemetry update: %v\\n\", err)\n\t\t\treturn\n\t\t}\n\t}()\n}\n\nfunc InitMainServer() error {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\n\tmainServer, err := wstore.DBGetSingleton[*waveobj.MainServer](ctx)\n\tif err == wstore.ErrNotFound {\n\t\tmainServer = &waveobj.MainServer{\n\t\t\tOID: uuid.NewString(),\n\t\t}\n\t\terr = wstore.DBInsert(ctx, mainServer)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error inserting mainserver: %w\", err)\n\t\t}\n\t} else if err != nil {\n\t\treturn fmt.Errorf(\"error getting mainserver: %w\", err)\n\t}\n\n\tneedsUpdate := false\n\tif mainServer.JwtPrivateKey == \"\" || mainServer.JwtPublicKey == \"\" {\n\t\tkeyPair, err := wavejwt.GenerateKeyPair()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error generating jwt keypair: %w\", err)\n\t\t}\n\t\tmainServer.JwtPrivateKey = base64.StdEncoding.EncodeToString(keyPair.PrivateKey)\n\t\tmainServer.JwtPublicKey = base64.StdEncoding.EncodeToString(keyPair.PublicKey)\n\t\tneedsUpdate = true\n\t}\n\n\tif needsUpdate {\n\t\terr = wstore.DBUpdate(ctx, mainServer)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error updating mainserver: %w\", err)\n\t\t}\n\t}\n\n\tprivateKeyBytes, err := base64.StdEncoding.DecodeString(mainServer.JwtPrivateKey)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error decoding jwt private key: %w\", err)\n\t}\n\tpublicKeyBytes, err := base64.StdEncoding.DecodeString(mainServer.JwtPublicKey)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error decoding jwt public key: %w\", err)\n\t}\n\n\terr = wavejwt.SetPrivateKey(privateKeyBytes)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting jwt private key: %w\", err)\n\t}\n\terr = wavejwt.SetPublicKey(publicKeyBytes)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting jwt public key: %w\", err)\n\t}\n\n\tpubKeyDer, err := x509.MarshalPKIXPublicKey(ed25519.PublicKey(publicKeyBytes))\n\tif err != nil {\n\t\tlog.Printf(\"warning: could not marshal public key for logging: %v\", err)\n\t} else {\n\t\tpubKeyPem := pem.EncodeToMemory(&pem.Block{\n\t\t\tType:  \"PUBLIC KEY\",\n\t\t\tBytes: pubKeyDer,\n\t\t})\n\t\tlog.Printf(\"JWT Public Key:\\n%s\", string(pubKeyPem))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/wcore/window.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wcore\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/eventbus\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nfunc SwitchWorkspace(ctx context.Context, windowId string, workspaceId string) (*waveobj.Workspace, error) {\n\tlog.Printf(\"SwitchWorkspace %s %s\\n\", windowId, workspaceId)\n\tws, err := GetWorkspace(ctx, workspaceId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting new workspace: %w\", err)\n\t}\n\twindow, err := GetWindow(ctx, windowId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting window: %w\", err)\n\t}\n\tcurWsId := window.WorkspaceId\n\tif curWsId == workspaceId {\n\t\treturn nil, nil\n\t}\n\n\tallWindows, err := wstore.DBGetAllObjsByType[*waveobj.Window](ctx, waveobj.OType_Window)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting all windows: %w\", err)\n\t}\n\n\tfor _, w := range allWindows {\n\t\tif w.WorkspaceId == workspaceId {\n\t\t\tlog.Printf(\"workspace %s already has a window %s, focusing that window\\n\", workspaceId, w.OID)\n\t\t\tclient := wshclient.GetBareRpcClient()\n\t\t\terr = wshclient.FocusWindowCommand(client, w.OID, &wshrpc.RpcOpts{Route: wshutil.ElectronRoute})\n\t\t\treturn nil, err\n\t\t}\n\t}\n\twindow.WorkspaceId = workspaceId\n\terr = wstore.DBUpdate(ctx, window)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error updating window: %w\", err)\n\t}\n\n\tdeleted, _, err := DeleteWorkspace(ctx, curWsId, false)\n\tif err != nil && deleted {\n\t\tprint(err.Error()) // @jalileh isolated the error for now, curwId/workspace was deleted when this occurs.\n\t} else if err != nil {\n\t\treturn nil, fmt.Errorf(\"error deleting workspace: %w\", err)\n\t}\n\n\tif !deleted {\n\t\tlog.Printf(\"current workspace %s was not deleted\\n\", curWsId)\n\t} else {\n\t\tlog.Printf(\"deleted current workspace %s\\n\", curWsId)\n\t}\n\n\tlog.Printf(\"switching window %s to workspace %s\\n\", windowId, workspaceId)\n\treturn ws, nil\n}\n\nfunc GetWindow(ctx context.Context, windowId string) (*waveobj.Window, error) {\n\twindow, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId)\n\tif err != nil {\n\t\tlog.Printf(\"error getting window %q: %v\\n\", windowId, err)\n\t\treturn nil, err\n\t}\n\treturn window, nil\n}\n\nfunc CreateWindow(ctx context.Context, winSize *waveobj.WinSize, workspaceId string) (*waveobj.Window, error) {\n\tlog.Printf(\"CreateWindow %v %v\\n\", winSize, workspaceId)\n\tvar ws *waveobj.Workspace\n\tif workspaceId == \"\" {\n\t\tws1, err := CreateWorkspace(ctx, \"\", \"\", \"\", false, false)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error creating workspace: %w\", err)\n\t\t}\n\t\tws = ws1\n\t} else {\n\t\tws1, err := GetWorkspace(ctx, workspaceId)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error getting workspace: %w\", err)\n\t\t}\n\t\tws = ws1\n\t}\n\twindowId := uuid.NewString()\n\tif winSize == nil {\n\t\twinSize = &waveobj.WinSize{\n\t\t\tWidth:  0,\n\t\t\tHeight: 0,\n\t\t}\n\t}\n\twindow := &waveobj.Window{\n\t\tOID:         windowId,\n\t\tWorkspaceId: ws.OID,\n\t\tIsNew:       true,\n\t\tPos: waveobj.Point{\n\t\t\tX: 0,\n\t\t\tY: 0,\n\t\t},\n\t\tWinSize: *winSize,\n\t}\n\terr := wstore.DBInsert(ctx, window)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error inserting window: %w\", err)\n\t}\n\tclient, err := GetClientData(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting client: %w\", err)\n\t}\n\tclient.WindowIds = append(client.WindowIds, windowId)\n\terr = wstore.DBUpdate(ctx, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error updating client: %w\", err)\n\t}\n\treturn GetWindow(ctx, windowId)\n}\n\n// CloseWindow closes a window and deletes its workspace if it is empty and not named.\n// If fromElectron is true, it does not send an event to Electron.\nfunc CloseWindow(ctx context.Context, windowId string, fromElectron bool) error {\n\tlog.Printf(\"CloseWindow %s\\n\", windowId)\n\twindow, err := GetWindow(ctx, windowId)\n\tif err == nil {\n\t\tlog.Printf(\"got window %s\\n\", windowId)\n\t\tdeleted, _, err := DeleteWorkspace(ctx, window.WorkspaceId, false)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error deleting workspace: %v\\n\", err)\n\t\t}\n\t\tif deleted {\n\t\t\tlog.Printf(\"deleted workspace %s\\n\", window.WorkspaceId)\n\t\t}\n\t\terr = wstore.DBDelete(ctx, waveobj.OType_Window, windowId)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error deleting window: %w\", err)\n\t\t}\n\t\tlog.Printf(\"deleted window %s\\n\", windowId)\n\t} else {\n\t\tlog.Printf(\"error getting window %s: %v\\n\", windowId, err)\n\t}\n\tclient, err := wstore.DBGetSingleton[*waveobj.Client](ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting client: %w\", err)\n\t}\n\tclient.WindowIds = utilfn.RemoveElemFromSlice(client.WindowIds, windowId)\n\terr = wstore.DBUpdate(ctx, client)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error updating client: %w\", err)\n\t}\n\tlog.Printf(\"updated client\\n\")\n\tif !fromElectron {\n\t\teventbus.SendEventToElectron(eventbus.WSEventType{\n\t\t\tEventType: eventbus.WSEvent_ElectronCloseWindow,\n\t\t\tData:      windowId,\n\t\t})\n\t}\n\treturn nil\n}\n\nfunc CheckAndFixWindow(ctx context.Context, windowId string) *waveobj.Window {\n\tlog.Printf(\"CheckAndFixWindow %s\\n\", windowId)\n\twindow, err := GetWindow(ctx, windowId)\n\tif err != nil {\n\t\tlog.Printf(\"error getting window %q (in checkAndFixWindow): %v\\n\", windowId, err)\n\t\treturn nil\n\t}\n\tws, err := GetWorkspace(ctx, window.WorkspaceId)\n\tif err != nil {\n\t\tlog.Printf(\"error getting workspace %q (in checkAndFixWindow): %v\\n\", window.WorkspaceId, err)\n\t\tCloseWindow(ctx, windowId, false)\n\t\treturn nil\n\t}\n\tif len(ws.TabIds) == 0 {\n\t\tlog.Printf(\"fixing workspace with no tabs %q (in checkAndFixWindow)\\n\", ws.OID)\n\t\t_, err = CreateTab(ctx, ws.OID, \"\", true, false)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error creating tab (in checkAndFixWindow): %v\\n\", err)\n\t\t}\n\t}\n\treturn window\n}\n\nfunc FocusWindow(ctx context.Context, windowId string) error {\n\tlog.Printf(\"FocusWindow %s\\n\", windowId)\n\tclient, err := GetClientData(ctx)\n\tif err != nil {\n\t\tlog.Printf(\"error getting client data: %v\\n\", err)\n\t\treturn err\n\t}\n\twinIdx := utilfn.SliceIdx(client.WindowIds, windowId)\n\tif winIdx == -1 {\n\t\tlog.Printf(\"window %s not found in client data\\n\", windowId)\n\t\treturn nil\n\t}\n\tclient.WindowIds = utilfn.MoveSliceIdxToFront(client.WindowIds, winIdx)\n\tlog.Printf(\"client.WindowIds: %v\\n\", client.WindowIds)\n\treturn wstore.DBUpdate(ctx, client)\n}\n"
  },
  {
    "path": "pkg/wcore/workspace.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wcore\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/eventbus\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nvar WorkspaceColors = [...]string{\n\t\"#58C142\", // Green (accent)\n\t\"#00FFDB\", // Teal\n\t\"#429DFF\", // Blue\n\t\"#BF55EC\", // Purple\n\t\"#FF453A\", // Red\n\t\"#FF9500\", // Orange\n\t\"#FFE900\", // Yellow\n}\n\nvar WorkspaceIcons = [...]string{\n\t\"custom@wave-logo-solid\",\n\t\"triangle\",\n\t\"star\",\n\t\"heart\",\n\t\"bolt\",\n\t\"solid@cloud\",\n\t\"moon\",\n\t\"layer-group\",\n\t\"rocket\",\n\t\"flask\",\n\t\"paperclip\",\n\t\"chart-line\",\n\t\"graduation-cap\",\n\t\"mug-hot\",\n}\n\nfunc CreateWorkspace(ctx context.Context, name string, icon string, color string, applyDefaults bool, isInitialLaunch bool) (*waveobj.Workspace, error) {\n\tws := &waveobj.Workspace{\n\t\tOID:    uuid.NewString(),\n\t\tTabIds: []string{},\n\t\tName:   \"\",\n\t\tIcon:   \"\",\n\t\tColor:  \"\",\n\t}\n\terr := wstore.DBInsert(ctx, ws)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error inserting workspace: %w\", err)\n\t}\n\t_, err = CreateTab(ctx, ws.OID, \"\", true, isInitialLaunch)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating tab: %w\", err)\n\t}\n\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent: wps.Event_WorkspaceUpdate,\n\t})\n\n\tws, _, err = UpdateWorkspace(ctx, ws.OID, name, icon, color, applyDefaults)\n\treturn ws, err\n}\n\n// Returns updated workspace, whether it was updated, error.\nfunc UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon string, color string, applyDefaults bool) (*waveobj.Workspace, bool, error) {\n\tws, err := GetWorkspace(ctx, workspaceId)\n\tupdated := false\n\tif err != nil {\n\t\treturn nil, updated, fmt.Errorf(\"workspace %s not found: %w\", workspaceId, err)\n\t}\n\tif name != \"\" {\n\t\tws.Name = name\n\t\tupdated = true\n\t} else if applyDefaults && ws.Name == \"\" {\n\t\tws.Name = fmt.Sprintf(\"New Workspace (%s)\", ws.OID[0:5])\n\t\tupdated = true\n\t}\n\tif icon != \"\" {\n\t\tws.Icon = icon\n\t\tupdated = true\n\t} else if applyDefaults && ws.Icon == \"\" {\n\t\tws.Icon = WorkspaceIcons[0]\n\t\tupdated = true\n\t}\n\tif color != \"\" {\n\t\tws.Color = color\n\t\tupdated = true\n\t} else if applyDefaults && ws.Color == \"\" {\n\t\twsList, err := ListWorkspaces(ctx)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error listing workspaces: %v\", err)\n\t\t\twsList = waveobj.WorkspaceList{}\n\t\t}\n\t\tws.Color = WorkspaceColors[len(wsList)%len(WorkspaceColors)]\n\t\tupdated = true\n\t}\n\tif updated {\n\t\twstore.DBUpdate(ctx, ws)\n\t}\n\treturn ws, updated, nil\n}\n\n// If force is true, it will delete even if workspace is named.\n// If workspace is empty, it will be deleted, even if it is named.\n// Returns true if workspace was deleted, false if it was not deleted.\nfunc DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool, string, error) {\n\tlog.Printf(\"DeleteWorkspace %s\\n\", workspaceId)\n\tworkspace, err := wstore.DBMustGet[*waveobj.Workspace](ctx, workspaceId)\n\tif err != nil && wstore.ErrNotFound == err {\n\t\treturn true, \"\", fmt.Errorf(\"workspace already deleted %w\", err)\n\t}\n\t// @jalileh list needs to be saved early on i assume\n\tworkspaces, err := ListWorkspaces(ctx)\n\tif err != nil {\n\t\treturn false, \"\", fmt.Errorf(\"error retrieving workspaceList: %w\", err)\n\t}\n\n\tif workspace.Name != \"\" && workspace.Icon != \"\" && !force && len(workspace.TabIds) > 0 {\n\t\tlog.Printf(\"Ignoring DeleteWorkspace for workspace %s as it is named\\n\", workspaceId)\n\t\treturn false, \"\", nil\n\t}\n\n\tfor _, tabId := range workspace.TabIds {\n\t\tlog.Printf(\"deleting tab %s\\n\", tabId)\n\t\t_, err := DeleteTab(ctx, workspaceId, tabId, false)\n\t\tif err != nil {\n\t\t\treturn false, \"\", fmt.Errorf(\"error closing tab: %w\", err)\n\t\t}\n\t}\n\twindowId, _ := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId)\n\terr = wstore.DBDelete(ctx, waveobj.OType_Workspace, workspaceId)\n\tif err != nil {\n\t\treturn false, \"\", fmt.Errorf(\"error deleting workspace: %w\", err)\n\t}\n\tlog.Printf(\"deleted workspace %s\\n\", workspaceId)\n\twps.Broker.Publish(wps.WaveEvent{\n\t\tEvent: wps.Event_WorkspaceUpdate,\n\t})\n\n\tif windowId != \"\" {\n\n\t\tUnclaimedWorkspace, findAfter := \"\", false\n\t\tfor _, ws := range workspaces {\n\t\t\tif ws.WorkspaceId == workspaceId {\n\t\t\t\tif UnclaimedWorkspace != \"\" {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tfindAfter = true\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif findAfter && ws.WindowId == \"\" {\n\t\t\t\tUnclaimedWorkspace = ws.WorkspaceId\n\t\t\t\tbreak\n\t\t\t} else if ws.WindowId == \"\" {\n\t\t\t\tUnclaimedWorkspace = ws.WorkspaceId\n\t\t\t}\n\t\t}\n\n\t\tif UnclaimedWorkspace != \"\" {\n\t\t\treturn true, UnclaimedWorkspace, nil\n\t\t} else {\n\t\t\terr = CloseWindow(ctx, windowId, false)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn false, \"\", fmt.Errorf(\"error closing window: %w\", err)\n\t\t}\n\t}\n\treturn true, \"\", nil\n}\n\nfunc GetWorkspace(ctx context.Context, wsID string) (*waveobj.Workspace, error) {\n\treturn wstore.DBMustGet[*waveobj.Workspace](ctx, wsID)\n}\n\nfunc getTabPresetMeta() (waveobj.MetaMapType, error) {\n\tsettings := wconfig.GetWatcher().GetFullConfig()\n\ttabPreset := settings.Settings.TabPreset\n\tif tabPreset == \"\" {\n\t\treturn nil, nil\n\t}\n\tpresetMeta := settings.Presets[tabPreset]\n\treturn presetMeta, nil\n}\n\nvar tabNameRe = regexp.MustCompile(`^T(\\d+)$`)\n\n// getNextTabName returns the next auto-generated tab name (e.g. \"T3\") given a\n// slice of existing tab names. It filters to names matching T[N] where N is a\n// positive integer, finds the maximum N, and returns T[max+1]. If no matching\n// names exist it returns \"T1\".\nfunc getNextTabName(tabNames []string) string {\n\tmaxNum := 0\n\tfor _, name := range tabNames {\n\t\tm := tabNameRe.FindStringSubmatch(name)\n\t\tif m == nil {\n\t\t\tcontinue\n\t\t}\n\t\tn, err := strconv.Atoi(m[1])\n\t\tif err != nil || n <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif n > maxNum {\n\t\t\tmaxNum = n\n\t\t}\n\t}\n\treturn \"T\" + strconv.Itoa(maxNum+1)\n}\n\n// returns tabid\nfunc CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool, isInitialLaunch bool) (string, error) {\n\tif tabName == \"\" {\n\t\tws, err := GetWorkspace(ctx, workspaceId)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"workspace %s not found: %w\", workspaceId, err)\n\t\t}\n\t\ttabNames := make([]string, 0, len(ws.TabIds))\n\t\tfor _, tabId := range ws.TabIds {\n\t\t\ttab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId)\n\t\t\tif err != nil || tab == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttabNames = append(tabNames, tab.Name)\n\t\t}\n\t\ttabName = getNextTabName(tabNames)\n\t}\n\n\ttab, err := createTabObj(ctx, workspaceId, tabName, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error creating tab: %w\", err)\n\t}\n\tif activateTab {\n\t\terr = SetActiveTab(ctx, workspaceId, tab.OID)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"error setting active tab: %w\", err)\n\t\t}\n\t}\n\n\t// No need to apply an initial layout for the initial launch, since the starter layout will get applied after onboarding modal dismissal\n\tif !isInitialLaunch {\n\t\terr = ApplyPortableLayout(ctx, tab.OID, GetNewTabLayout(), true)\n\t\tif err != nil {\n\t\t\treturn tab.OID, fmt.Errorf(\"error applying new tab layout: %w\", err)\n\t\t}\n\t\tpresetMeta, presetErr := getTabPresetMeta()\n\t\tif presetErr != nil {\n\t\t\tlog.Printf(\"error getting tab preset meta: %v\\n\", presetErr)\n\t\t} else if len(presetMeta) > 0 {\n\t\t\ttabORef := waveobj.ORefFromWaveObj(tab)\n\t\t\twstore.UpdateObjectMeta(ctx, *tabORef, presetMeta, true)\n\t\t}\n\t}\n\ttelemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NewTab: 1}, \"createtab\")\n\ttelemetry.GoRecordTEventWrap(&telemetrydata.TEvent{\n\t\tEvent: \"action:createtab\",\n\t})\n\treturn tab.OID, nil\n}\n\nfunc createTabObj(ctx context.Context, workspaceId string, name string, meta waveobj.MetaMapType) (*waveobj.Tab, error) {\n\tws, err := GetWorkspace(ctx, workspaceId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"workspace %s not found: %w\", workspaceId, err)\n\t}\n\tlayoutStateId := uuid.NewString()\n\ttab := &waveobj.Tab{\n\t\tOID:         uuid.NewString(),\n\t\tName:        name,\n\t\tBlockIds:    []string{},\n\t\tLayoutState: layoutStateId,\n\t\tMeta:        meta,\n\t}\n\tlayoutState := &waveobj.LayoutState{\n\t\tOID: layoutStateId,\n\t}\n\tws.TabIds = append(ws.TabIds, tab.OID)\n\twstore.DBInsert(ctx, tab)\n\twstore.DBInsert(ctx, layoutState)\n\twstore.DBUpdate(ctx, ws)\n\treturn tab, nil\n}\n\n// Must delete all blocks individually first.\n// Also deletes LayoutState.\n// recursive: if true, will recursively close parent window, workspace, if they are empty.\n// Returns new active tab id, error.\nfunc DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive bool) (string, error) {\n\tws, _ := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId)\n\tif ws == nil {\n\t\treturn \"\", fmt.Errorf(\"workspace not found: %q\", workspaceId)\n\t}\n\n\t// ensure tab is in workspace\n\ttabIdx := utilfn.FindStringInSlice(ws.TabIds, tabId)\n\tif tabIdx == -1 {\n\t\treturn \"\", fmt.Errorf(\"tab %s not found in workspace %s\", tabId, workspaceId)\n\t}\n\tws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...)\n\n\t// close blocks (sends events + stops block controllers)\n\ttab, _ := wstore.DBGet[*waveobj.Tab](ctx, tabId)\n\tif tab != nil {\n\t\tfor _, blockId := range tab.BlockIds {\n\t\t\terr := DeleteBlock(ctx, blockId, false)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", fmt.Errorf(\"error deleting block %s: %w\", blockId, err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// if the tab is active, determine new active tab\n\tnewActiveTabId := ws.ActiveTabId\n\tif ws.ActiveTabId == tabId {\n\t\tif len(ws.TabIds) > 0 {\n\t\t\tnewActiveTabId = ws.TabIds[max(0, min(tabIdx-1, len(ws.TabIds)-1))]\n\t\t} else {\n\t\t\tnewActiveTabId = \"\"\n\t\t}\n\t}\n\tws.ActiveTabId = newActiveTabId\n\n\twstore.DBUpdate(ctx, ws)\n\twstore.DBDelete(ctx, waveobj.OType_Tab, tabId)\n\tif tab != nil {\n\t\twstore.DBDelete(ctx, waveobj.OType_LayoutState, tab.LayoutState)\n\t}\n\n\t// if no tabs remaining, close window\n\tif recursive && newActiveTabId == \"\" {\n\t\tlog.Printf(\"no tabs remaining in workspace %s, closing window\\n\", workspaceId)\n\t\twindowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId)\n\t\tif err != nil {\n\t\t\treturn newActiveTabId, fmt.Errorf(\"unable to find window for workspace id %v: %w\", workspaceId, err)\n\t\t}\n\t\terr = CloseWindow(ctx, windowId, false)\n\t\tif err != nil {\n\t\t\treturn newActiveTabId, err\n\t\t}\n\t}\n\treturn newActiveTabId, nil\n}\n\nfunc SetActiveTab(ctx context.Context, workspaceId string, tabId string) error {\n\tif tabId != \"\" && workspaceId != \"\" {\n\t\tworkspace, err := GetWorkspace(ctx, workspaceId)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"workspace %s not found: %w\", workspaceId, err)\n\t\t}\n\t\ttab, _ := wstore.DBGet[*waveobj.Tab](ctx, tabId)\n\t\tif tab == nil {\n\t\t\treturn fmt.Errorf(\"tab not found: %q\", tabId)\n\t\t}\n\t\tworkspace.ActiveTabId = tabId\n\t\twstore.DBUpdate(ctx, workspace)\n\t}\n\treturn nil\n}\n\nfunc SendActiveTabUpdate(ctx context.Context, workspaceId string, newActiveTabId string) {\n\teventbus.SendEventToElectron(eventbus.WSEventType{\n\t\tEventType: eventbus.WSEvent_ElectronUpdateActiveTab,\n\t\tData:      &waveobj.ActiveTabUpdate{WorkspaceId: workspaceId, NewActiveTabId: newActiveTabId},\n\t})\n}\n\nfunc UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []string) error {\n\tws, _ := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId)\n\tif ws == nil {\n\t\treturn fmt.Errorf(\"workspace not found: %q\", workspaceId)\n\t}\n\tws.TabIds = tabIds\n\twstore.DBUpdate(ctx, ws)\n\treturn nil\n}\n\nfunc ListWorkspaces(ctx context.Context) (waveobj.WorkspaceList, error) {\n\tworkspaces, err := wstore.DBGetAllObjsByType[*waveobj.Workspace](ctx, waveobj.OType_Workspace)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\twindows, err := wstore.DBGetAllObjsByType[*waveobj.Window](ctx, waveobj.OType_Window)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tworkspaceToWindow := make(map[string]string)\n\tfor _, window := range windows {\n\t\tworkspaceToWindow[window.WorkspaceId] = window.OID\n\t}\n\n\tvar wl waveobj.WorkspaceList\n\tfor _, workspace := range workspaces {\n\t\tif workspace.Name == \"\" || workspace.Icon == \"\" || workspace.Color == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\twindowId, ok := workspaceToWindow[workspace.OID]\n\t\tif !ok {\n\t\t\twindowId = \"\"\n\t\t}\n\t\twl = append(wl, &waveobj.WorkspaceListEntry{\n\t\t\tWorkspaceId: workspace.OID,\n\t\t\tWindowId:    windowId,\n\t\t})\n\t}\n\treturn wl, nil\n}\n\nfunc SetIcon(workspaceId string, icon string) error {\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\tws, e := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId)\n\tif e != nil {\n\t\treturn e\n\t}\n\tif ws == nil {\n\t\treturn fmt.Errorf(\"workspace not found: %q\", workspaceId)\n\t}\n\tws.Icon = icon\n\twstore.DBUpdate(ctx, ws)\n\treturn nil\n}\n\nfunc SetColor(workspaceId string, color string) error {\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\tws, e := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId)\n\tif e != nil {\n\t\treturn e\n\t}\n\tif ws == nil {\n\t\treturn fmt.Errorf(\"workspace not found: %q\", workspaceId)\n\t}\n\tws.Color = color\n\twstore.DBUpdate(ctx, ws)\n\treturn nil\n}\n\nfunc SetName(workspaceId string, name string) error {\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\tws, e := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId)\n\tif e != nil {\n\t\treturn e\n\t}\n\tif ws == nil {\n\t\treturn fmt.Errorf(\"workspace not found: %q\", workspaceId)\n\t}\n\tws.Name = name\n\twstore.DBUpdate(ctx, ws)\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/web/sse/ssehandler.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage sse\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/utilds\"\n)\n\n// see /aiprompts/usechat-streamingproto.md for protocol\n\nconst (\n\tSSEContentType       = \"text/event-stream\"\n\tSSECacheControl      = \"no-cache\"\n\tSSEConnection        = \"keep-alive\"\n\tSSEKeepaliveMsg      = \": keepalive\\n\\n\"\n\tSSEStreamStartMsg    = \": stream-start\\n\\n\"\n\tSSEKeepaliveInterval = 1 * time.Second\n)\n\n// SSEMessageType represents the type of message to write\ntype SSEMessageType string\n\nconst (\n\tSSEMsgData    SSEMessageType = \"data\"\n\tSSEMsgEvent   SSEMessageType = \"event\"\n\tSSEMsgComment SSEMessageType = \"comment\"\n\tSSEMsgError   SSEMessageType = \"error\"\n)\n\n// AI message type constants\nconst (\n\tAiMsgStart               = \"start\"\n\tAiMsgTextStart           = \"text-start\"\n\tAiMsgTextDelta           = \"text-delta\"\n\tAiMsgTextEnd             = \"text-end\"\n\tAiMsgReasoningStart      = \"reasoning-start\"\n\tAiMsgReasoningDelta      = \"reasoning-delta\"\n\tAiMsgReasoningEnd        = \"reasoning-end\"\n\tAiMsgToolInputStart      = \"tool-input-start\"\n\tAiMsgToolInputDelta      = \"tool-input-delta\"\n\tAiMsgToolInputAvailable  = \"tool-input-available\"\n\tAiMsgToolOutputAvailable = \"tool-output-available\" // not used here, but reserved\n\tAiMsgStartStep           = \"start-step\"\n\tAiMsgFinishStep          = \"finish-step\"\n\tAiMsgFinish              = \"finish\"\n\tAiMsgError               = \"error\"\n)\n\n// SSEMessage represents a message to be written to the SSE stream\ntype SSEMessage struct {\n\tType      SSEMessageType\n\tData      string\n\tEventType string // Only used for SSEMsgEvent\n}\n\n// SSEHandlerCh provides channel-based Server-Sent Events functionality\ntype SSEHandlerCh struct {\n\tw       http.ResponseWriter\n\trc      *http.ResponseController\n\tctx     context.Context // the r.Context()\n\twriteCh chan SSEMessage\n\n\tlock        sync.Mutex\n\tclosed      bool\n\tinitialized bool\n\terr         error\n\n\twg              sync.WaitGroup\n\tonCloseHandlers utilds.IdList[func()]\n\thandlersRun     bool\n}\n\n// MakeSSEHandlerCh creates a new channel-based SSE handler\nfunc MakeSSEHandlerCh(w http.ResponseWriter, ctx context.Context) *SSEHandlerCh {\n\treturn &SSEHandlerCh{\n\t\tw:       w,\n\t\trc:      http.NewResponseController(w),\n\t\tctx:     ctx,\n\t\twriteCh: make(chan SSEMessage, 10), // Buffered to prevent blocking\n\t}\n}\n\nfunc (h *SSEHandlerCh) Context() context.Context {\n\treturn h.ctx\n}\n\n// SetupSSE configures the response headers and starts the writer goroutine\nfunc (h *SSEHandlerCh) SetupSSE() error {\n\th.lock.Lock()\n\tdefer h.lock.Unlock()\n\n\tif h.closed {\n\t\treturn fmt.Errorf(\"SSE handler is closed\")\n\t}\n\n\th.initialized = true\n\n\t// Reset write deadline for streaming\n\tif err := h.rc.SetWriteDeadline(time.Time{}); err != nil {\n\t\treturn fmt.Errorf(\"failed to reset write deadline: %v\", err)\n\t}\n\n\t// Set SSE headers\n\th.w.Header().Set(\"Content-Type\", SSEContentType)\n\th.w.Header().Set(\"Cache-Control\", \"no-cache, no-store, must-revalidate, no-transform\")\n\th.w.Header().Set(\"Connection\", SSEConnection)\n\th.w.Header().Set(\"x-vercel-ai-ui-message-stream\", \"v1\")\n\th.w.Header().Set(\"X-Accel-Buffering\", \"no\")\n\n\t// Send headers and establish streaming\n\th.w.WriteHeader(http.StatusOK)\n\tfmt.Fprint(h.w, SSEStreamStartMsg)\n\tif err := h.flush(); err != nil {\n\t\treturn err\n\t}\n\n\t// Start the writer goroutine\n\th.wg.Add(1)\n\tgo h.writerLoop()\n\n\treturn nil\n}\n\n// writerLoop handles all writes and keepalives in a single goroutine\nfunc (h *SSEHandlerCh) writerLoop() {\n\tdefer h.wg.Done()\n\tdefer h.runOnCloseHandlers()\n\n\tkeepaliveTicker := time.NewTicker(SSEKeepaliveInterval)\n\tdefer keepaliveTicker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase msg, ok := <-h.writeCh:\n\t\t\tif !ok {\n\t\t\t\t// Channel closed, send [DONE] and exit\n\t\t\t\th.writeDirectly(\"[DONE]\", SSEMsgData)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := h.writeMessage(msg); err != nil {\n\t\t\t\th.setError(err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\tcase <-keepaliveTicker.C:\n\t\t\tif err := h.writeDirectly(\"keepalive\", SSEMsgComment); err != nil {\n\t\t\t\th.setError(err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\tcase <-h.ctx.Done():\n\t\t\th.setError(h.ctx.Err())\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// writeMessage writes a message to the SSE stream\nfunc (h *SSEHandlerCh) writeMessage(msg SSEMessage) error {\n\tif h.ctx.Err() != nil {\n\t\treturn h.ctx.Err()\n\t}\n\tswitch msg.Type {\n\tcase SSEMsgData:\n\t\treturn h.writeDirectly(msg.Data, SSEMsgData)\n\tcase SSEMsgEvent:\n\t\treturn h.writeEvent(msg.EventType, msg.Data)\n\tcase SSEMsgComment:\n\t\treturn h.writeDirectly(msg.Data, SSEMsgComment)\n\tcase SSEMsgError:\n\t\treturn h.writeDirectly(msg.Data, SSEMsgData)\n\tdefault:\n\t\treturn fmt.Errorf(\"unknown message type: %s\", msg.Type)\n\t}\n}\n\n// isInitialized returns whether SetupSSE has been called\nfunc (h *SSEHandlerCh) isInitialized() bool {\n\th.lock.Lock()\n\tdefer h.lock.Unlock()\n\treturn h.initialized\n}\n\n// writeDirectly writes data directly to the response writer\nfunc (h *SSEHandlerCh) writeDirectly(data string, msgType SSEMessageType) error {\n\tif !h.isInitialized() {\n\t\tpanic(\"SSEHandlerCh not initialized - call SetupSSE first\")\n\t}\n\tswitch msgType {\n\tcase SSEMsgData:\n\t\t_, err := fmt.Fprintf(h.w, \"data: %s\\n\\n\", data)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\tcase SSEMsgComment:\n\t\t_, err := fmt.Fprintf(h.w, \": %s\\n\\n\", data)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\tdefault:\n\t\tpanic(fmt.Sprintf(\"unsupported direct write type: %s\", msgType))\n\t}\n\treturn h.flush()\n}\n\n// writeEvent writes an SSE event with optional event type\nfunc (h *SSEHandlerCh) writeEvent(eventType, data string) error {\n\tif !h.isInitialized() {\n\t\tpanic(\"SSEHandlerCh not initialized - call SetupSSE first\")\n\t}\n\tif eventType != \"\" {\n\t\tif _, err := fmt.Fprintf(h.w, \"event: %s\\n\", eventType); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif _, err := fmt.Fprintf(h.w, \"data: %s\\n\\n\", data); err != nil {\n\t\treturn err\n\t}\n\treturn h.flush()\n}\n\n// flush attempts to flush the response writer\nfunc (h *SSEHandlerCh) flush() error {\n\treturn h.rc.Flush()\n}\n\n// setError sets the error state thread-safely\nfunc (h *SSEHandlerCh) setError(err error) {\n\th.lock.Lock()\n\tdefer h.lock.Unlock()\n\n\tif h.err == nil {\n\t\th.err = err\n\t}\n}\n\n// queueMessage queues an SSEMessage to be written\nfunc (h *SSEHandlerCh) queueMessage(msg SSEMessage) error {\n\th.lock.Lock()\n\tclosed := h.closed\n\th.lock.Unlock()\n\n\tif closed {\n\t\treturn fmt.Errorf(\"SSE handler is closed\")\n\t}\n\n\tif err := h.Err(); err != nil {\n\t\treturn err\n\t}\n\n\tselect {\n\tcase h.writeCh <- msg:\n\t\treturn nil\n\tcase <-h.ctx.Done():\n\t\treturn h.ctx.Err()\n\tdefault:\n\t\treturn fmt.Errorf(\"write channel is full\")\n\t}\n}\n\n// WriteData queues data to be written in SSE format\nfunc (h *SSEHandlerCh) WriteData(data string) error {\n\treturn h.queueMessage(SSEMessage{Type: SSEMsgData, Data: data})\n}\n\n// WriteJsonData marshals data to JSON and queues it for writing\nfunc (h *SSEHandlerCh) WriteJsonData(data interface{}) error {\n\tjsonData, err := json.Marshal(data)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal JSON: %v\", err)\n\t}\n\treturn h.WriteData(string(jsonData))\n}\n\n// WriteError queues an error message and closes the handler\nfunc (h *SSEHandlerCh) WriteError(errorMsg string) error {\n\terrorResp := map[string]interface{}{\n\t\t\"type\":      AiMsgError,\n\t\t\"errorText\": errorMsg,\n\t}\n\tif err := h.WriteJsonData(errorResp); err != nil {\n\t\treturn err\n\t}\n\th.Close()\n\treturn nil\n}\n\n// WriteEvent queues an SSE event with optional event type\nfunc (h *SSEHandlerCh) WriteEvent(eventType, data string) error {\n\treturn h.queueMessage(SSEMessage{Type: SSEMsgEvent, Data: data, EventType: eventType})\n}\n\n// WriteComment queues an SSE comment\nfunc (h *SSEHandlerCh) WriteComment(comment string) error {\n\treturn h.queueMessage(SSEMessage{Type: SSEMsgComment, Data: comment})\n}\n\n// Err returns any error that occurred during writing\nfunc (h *SSEHandlerCh) Err() error {\n\th.lock.Lock()\n\tdefer h.lock.Unlock()\n\tif h.err == nil && h.ctx.Err() != nil {\n\t\th.err = h.ctx.Err()\n\t}\n\treturn h.err\n}\n\n// RegisterOnClose registers a handler function to be called when the connection closes\n// Returns an ID that can be used to unregister the handler\nfunc (h *SSEHandlerCh) RegisterOnClose(fn func()) string {\n\th.lock.Lock()\n\tdefer h.lock.Unlock()\n\treturn h.onCloseHandlers.Register(fn)\n}\n\n// UnregisterOnClose removes a previously registered onClose handler by ID\nfunc (h *SSEHandlerCh) UnregisterOnClose(id string) {\n\th.lock.Lock()\n\tdefer h.lock.Unlock()\n\th.onCloseHandlers.Unregister(id)\n}\n\n// runOnCloseHandlers runs all registered onClose handlers exactly once\nfunc (h *SSEHandlerCh) runOnCloseHandlers() {\n\th.lock.Lock()\n\tif h.handlersRun {\n\t\th.lock.Unlock()\n\t\treturn\n\t}\n\th.handlersRun = true\n\th.lock.Unlock()\n\n\thandlers := h.onCloseHandlers.GetList()\n\tfor _, fn := range handlers {\n\t\tfn()\n\t}\n}\n\n// Close closes the write channel, sends [DONE], and cleans up resources\nfunc (h *SSEHandlerCh) Close() {\n\th.lock.Lock()\n\tif h.closed || !h.initialized {\n\t\th.lock.Unlock()\n\t\treturn\n\t}\n\th.closed = true\n\n\t// Close the write channel, which will trigger [DONE] in writerLoop\n\tclose(h.writeCh)\n\th.lock.Unlock()\n\n\t// Wait for writer goroutine to finish (without holding the lock)\n\th.wg.Wait()\n}\n\n// AI message writing methods\n\nfunc (h *SSEHandlerCh) AiMsgStart(messageId string) error {\n\tresp := map[string]interface{}{\n\t\t\"type\":      AiMsgStart,\n\t\t\"messageId\": messageId,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgTextStart(textId string) error {\n\tresp := map[string]interface{}{\n\t\t\"type\": AiMsgTextStart,\n\t\t\"id\":   textId,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgTextDelta(textId string, text string) error {\n\tresp := map[string]interface{}{\n\t\t\"type\":  AiMsgTextDelta,\n\t\t\"id\":    textId,\n\t\t\"delta\": text,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgTextEnd(textId string) error {\n\tresp := map[string]interface{}{\n\t\t\"type\": AiMsgTextEnd,\n\t\t\"id\":   textId,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgFinish(finishReason string, usage interface{}) error {\n\tresp := map[string]interface{}{\n\t\t\"type\": AiMsgFinish,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgReasoningStart(reasoningId string) error {\n\tresp := map[string]interface{}{\n\t\t\"type\": AiMsgReasoningStart,\n\t\t\"id\":   reasoningId,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgReasoningDelta(reasoningId string, reasoning string) error {\n\tresp := map[string]interface{}{\n\t\t\"type\":  AiMsgReasoningDelta,\n\t\t\"id\":    reasoningId,\n\t\t\"delta\": reasoning,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgReasoningEnd(reasoningId string) error {\n\tresp := map[string]interface{}{\n\t\t\"type\": AiMsgReasoningEnd,\n\t\t\"id\":   reasoningId,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgToolInputStart(toolCallId, toolName string) error {\n\tresp := map[string]interface{}{\n\t\t\"type\":       AiMsgToolInputStart,\n\t\t\"toolCallId\": toolCallId,\n\t\t\"toolName\":   toolName,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgToolInputDelta(toolCallId, inputTextDelta string) error {\n\tresp := map[string]interface{}{\n\t\t\"type\":           AiMsgToolInputDelta,\n\t\t\"toolCallId\":     toolCallId,\n\t\t\"inputTextDelta\": inputTextDelta,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgToolInputAvailable(toolCallId, toolName string, input json.RawMessage) error {\n\tresp := map[string]interface{}{\n\t\t\"type\":       AiMsgToolInputAvailable,\n\t\t\"toolCallId\": toolCallId,\n\t\t\"toolName\":   toolName,\n\t\t\"input\":      json.RawMessage(input),\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgStartStep() error {\n\tresp := map[string]interface{}{\n\t\t\"type\": AiMsgStartStep,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgFinishStep() error {\n\tresp := map[string]interface{}{\n\t\t\"type\": AiMsgFinishStep,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgError(errText string) error {\n\tresp := map[string]interface{}{\n\t\t\"type\":      AiMsgError,\n\t\t\"errorText\": errText,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n\nfunc (h *SSEHandlerCh) AiMsgData(dataType string, id string, data interface{}) error {\n\tif !strings.HasPrefix(dataType, \"data-\") {\n\t\tpanic(fmt.Sprintf(\"AiMsgData type must start with 'data-', got: %s\", dataType))\n\t}\n\tresp := map[string]interface{}{\n\t\t\"type\": dataType,\n\t\t\"id\":   id,\n\t\t\"data\": data,\n\t}\n\treturn h.WriteJsonData(resp)\n}\n"
  },
  {
    "path": "pkg/web/web.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage web\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"log\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/gorilla/mux\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat\"\n\t\"github.com/wavetermdev/waveterm/pkg/authkey\"\n\t\"github.com/wavetermdev/waveterm/pkg/filestore\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs\"\n\t\"github.com/wavetermdev/waveterm/pkg/schema\"\n\t\"github.com/wavetermdev/waveterm/pkg/service\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/fileutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n)\n\ntype WebFnType = func(http.ResponseWriter, *http.Request)\n\nconst TransparentGif64 = \"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7\"\n\n// Header constants\nconst (\n\tCacheControlHeaderKey     = \"Cache-Control\"\n\tCacheControlHeaderNoCache = \"no-cache\"\n\n\tContentTypeHeaderKey = \"Content-Type\"\n\tContentTypeJson      = \"application/json\"\n\tContentTypeBinary    = \"application/octet-stream\"\n\n\tContentLengthHeaderKey = \"Content-Length\"\n\tLastModifiedHeaderKey  = \"Last-Modified\"\n\n\tWaveZoneFileInfoHeaderKey = \"X-ZoneFileInfo\"\n)\n\nconst HttpReadTimeout = 5 * time.Second\nconst HttpWriteTimeout = 21 * time.Second\nconst HttpMaxHeaderBytes = 60000\nconst HttpTimeoutDuration = 21 * time.Second\n\nconst WSStateReconnectTime = 30 * time.Second\nconst WSStatePacketChSize = 20\n\ntype WebFnOpts struct {\n\tAllowCaching bool\n\tJsonErrors   bool\n}\n\nfunc copyHeaders(dst, src http.Header) {\n\tfor key, values := range src {\n\t\tfor _, value := range values {\n\t\t\tdst.Add(key, value)\n\t\t}\n\t}\n}\n\ntype notFoundBlockingResponseWriter struct {\n\tw       http.ResponseWriter\n\tstatus  int\n\theaders http.Header\n}\n\nfunc (rw *notFoundBlockingResponseWriter) Header() http.Header {\n\treturn rw.headers\n}\n\nfunc (rw *notFoundBlockingResponseWriter) WriteHeader(status int) {\n\tif status == http.StatusNotFound {\n\t\trw.status = status\n\t\treturn\n\t}\n\trw.status = status\n\tcopyHeaders(rw.w.Header(), rw.headers)\n\trw.w.WriteHeader(status)\n}\n\nfunc (rw *notFoundBlockingResponseWriter) Write(b []byte) (int, error) {\n\tif rw.status == http.StatusNotFound {\n\t\t// Block the write if it's a 404\n\t\treturn len(b), nil\n\t}\n\tif rw.status == 0 {\n\t\trw.WriteHeader(http.StatusOK)\n\t}\n\treturn rw.w.Write(b)\n}\n\nfunc handleService(w http.ResponseWriter, r *http.Request) {\n\tbodyData, err := io.ReadAll(r.Body)\n\tif err != nil {\n\t\thttp.Error(w, \"Unable to read request body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tdefer r.Body.Close()\n\tif r.Method != http.MethodPost {\n\t\thttp.Error(w, \"Invalid request method\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\tvar webCall service.WebCallType\n\terr = json.Unmarshal(bodyData, &webCall)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"invalid request body: %v\", err), http.StatusBadRequest)\n\t}\n\n\trtn := service.CallService(r.Context(), webCall)\n\tjsonRtn, err := json.Marshal(rtn)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"error serializing response: %v\", err), http.StatusInternalServerError)\n\t}\n\tw.Header().Set(ContentTypeHeaderKey, ContentTypeJson)\n\tw.Header().Set(ContentLengthHeaderKey, fmt.Sprintf(\"%d\", len(jsonRtn)))\n\tw.WriteHeader(http.StatusOK)\n\tw.Write(jsonRtn)\n}\n\nfunc marshalReturnValue(data any, err error) []byte {\n\tvar mapRtn = make(map[string]any)\n\tif err != nil {\n\t\tmapRtn[\"error\"] = err.Error()\n\t} else {\n\t\tmapRtn[\"success\"] = true\n\t\tmapRtn[\"data\"] = data\n\t}\n\trtn, err := json.Marshal(mapRtn)\n\tif err != nil {\n\t\treturn marshalReturnValue(nil, fmt.Errorf(\"error serializing response: %v\", err))\n\t}\n\treturn rtn\n}\n\nfunc handleWaveFile(w http.ResponseWriter, r *http.Request) {\n\tzoneId := r.URL.Query().Get(\"zoneid\")\n\tname := r.URL.Query().Get(\"name\")\n\toffsetStr := r.URL.Query().Get(\"offset\")\n\tvar offset int64 = 0\n\tif offsetStr != \"\" {\n\t\tvar err error\n\t\toffset, err = strconv.ParseInt(offsetStr, 10, 64)\n\t\tif err != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"invalid offset: %v\", err), http.StatusBadRequest)\n\t\t}\n\t}\n\tif _, err := uuid.Parse(zoneId); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"invalid zoneid: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\tif name == \"\" {\n\t\thttp.Error(w, \"name is required\", http.StatusBadRequest)\n\t\treturn\n\n\t}\n\tfile, err := filestore.WFS.Stat(r.Context(), zoneId, name)\n\tif err == fs.ErrNotExist {\n\t\tw.WriteHeader(http.StatusNoContent)\n\t\treturn\n\t}\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"error getting file info: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\tjsonFileBArr, err := json.Marshal(file)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"error serializing file info: %v\", err), http.StatusInternalServerError)\n\t}\n\t// can make more efficient by checking modtime + If-Modified-Since headers to allow caching\n\tdataStartIdx := file.DataStartIdx()\n\tif offset >= dataStartIdx {\n\t\tdataStartIdx = offset\n\t}\n\tw.Header().Set(ContentTypeHeaderKey, ContentTypeBinary)\n\tw.Header().Set(ContentLengthHeaderKey, fmt.Sprintf(\"%d\", file.Size-dataStartIdx))\n\tw.Header().Set(WaveZoneFileInfoHeaderKey, base64.StdEncoding.EncodeToString(jsonFileBArr))\n\tw.Header().Set(LastModifiedHeaderKey, time.UnixMilli(file.ModTs).UTC().Format(http.TimeFormat))\n\tif dataStartIdx >= file.Size {\n\t\tw.WriteHeader(http.StatusOK)\n\t\treturn\n\t}\n\tfor offset := dataStartIdx; offset < file.Size; offset += filestore.DefaultPartDataSize {\n\t\t_, data, err := filestore.WFS.ReadAt(r.Context(), zoneId, name, offset, filestore.DefaultPartDataSize)\n\t\tif err != nil {\n\t\t\tif offset == 0 {\n\t\t\t\thttp.Error(w, fmt.Sprintf(\"error reading file: %v\", err), http.StatusInternalServerError)\n\t\t\t} else {\n\t\t\t\t// nothing to do, the headers have already been sent\n\t\t\t\tlog.Printf(\"error reading file %s/%s @ %d: %v\\n\", zoneId, name, offset, err)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tw.Write(data)\n\t}\n}\n\nfunc serveTransparentGIF(w http.ResponseWriter) {\n\tgifBytes, _ := base64.StdEncoding.DecodeString(TransparentGif64)\n\tw.Header().Set(\"Content-Type\", \"image/gif\")\n\tw.WriteHeader(http.StatusOK)\n\tw.Write(gifBytes)\n}\n\nfunc handleLocalStreamFile(w http.ResponseWriter, r *http.Request, path string, no404 bool) {\n\thttp.NewResponseController(w).SetWriteDeadline(time.Time{})\n\tif no404 {\n\t\tlog.Printf(\"streaming file w/no404: %q\\n\", path)\n\t\t// use the custom response writer\n\t\trw := &notFoundBlockingResponseWriter{w: w, headers: http.Header{}}\n\n\t\t// Serve the file using http.ServeFile\n\t\tpath, err := wavebase.ExpandHomeDir(path)\n\t\tif err == nil {\n\t\t\thttp.ServeFile(rw, r, filepath.Clean(path))\n\t\t\t// if the file was not found, serve the transparent GIF\n\t\t\tlog.Printf(\"got streamfile status: %d\\n\", rw.status)\n\t\t\tif rw.status == http.StatusNotFound {\n\t\t\t\tserveTransparentGIF(w)\n\t\t\t}\n\t\t} else {\n\t\t\tserveTransparentGIF(w)\n\t\t}\n\t} else {\n\t\tpath, err := wavebase.ExpandHomeDir(path)\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t}\n\t\thttp.ServeFile(w, r, path)\n\t}\n}\n\nfunc handleStreamFileFromReader(w http.ResponseWriter, r *http.Request, path string, no404 bool) error {\n\tstartTime := time.Now()\n\trangeHeader := r.Header.Get(\"Range\")\n\tlog.Printf(\"stream-file path=%q range=%q\\n\", path, rangeHeader)\n\n\twriterRouteId, err := wshfs.GetConnectionRouteId(r.Context(), path)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tbyteRange := \"\"\n\tif rangeHeader != \"\" {\n\t\tstripped := strings.TrimPrefix(rangeHeader, \"bytes=\")\n\t\tbr, parseErr := fileutil.ParseByteRange(stripped)\n\t\tif parseErr != nil || br.All {\n\t\t\thttp.Error(w, \"invalid range\", http.StatusRequestedRangeNotSatisfiable)\n\t\t\treturn nil\n\t\t}\n\t\tbyteRange = stripped\n\t}\n\n\tbareRpc := wshclient.GetBareRpcClient()\n\treaderRouteId := wshclient.GetBareRpcClientRouteId()\n\treader, streamMeta := bareRpc.StreamBroker.CreateStreamReader(readerRouteId, writerRouteId, 256*1024)\n\tdefer reader.Close()\n\tgo func() {\n\t\t<-r.Context().Done()\n\t\treader.Close()\n\t}()\n\n\tdata := wshrpc.CommandFileStreamData{\n\t\tInfo:       &wshrpc.FileInfo{Path: path},\n\t\tByteRange:  byteRange,\n\t\tStreamMeta: *streamMeta,\n\t}\n\tfileInfo, err := wshfs.FileStream(r.Context(), data)\n\tif err != nil {\n\t\tif no404 {\n\t\t\tserveTransparentGIF(w)\n\t\t\treturn nil\n\t\t}\n\t\treturn err\n\t}\n\tif fileInfo.NotFound {\n\t\tif no404 {\n\t\t\tserveTransparentGIF(w)\n\t\t\treturn nil\n\t\t}\n\t\thttp.Error(w, fmt.Sprintf(\"file not found: %q\", path), http.StatusNotFound)\n\t\treturn nil\n\t}\n\tif fileInfo.IsDir {\n\t\thttp.Error(w, fmt.Sprintf(\"cannot stream directory: %q\", path), http.StatusBadRequest)\n\t\treturn nil\n\t}\n\tlog.Printf(\"stream-file headers-ready path=%q time-to-headers=%v\\n\", path, time.Since(startTime))\n\tw.Header().Set(ContentTypeHeaderKey, fileInfo.MimeType)\n\tw.Header().Set(\"Accept-Ranges\", \"bytes\")\n\tif byteRange != \"\" {\n\t\tbr, _ := fileutil.ParseByteRange(byteRange)\n\t\tvar rangeEnd int64\n\t\tif br.OpenEnd {\n\t\t\trangeEnd = fileInfo.Size - 1\n\t\t} else {\n\t\t\trangeEnd = br.End\n\t\t}\n\t\tw.Header().Set(ContentLengthHeaderKey, fmt.Sprintf(\"%d\", rangeEnd-br.Start+1))\n\t\tw.Header().Set(\"Content-Range\", fmt.Sprintf(\"bytes %d-%d/%d\", br.Start, rangeEnd, fileInfo.Size))\n\t\tw.WriteHeader(http.StatusPartialContent)\n\t} else {\n\t\tw.Header().Set(ContentLengthHeaderKey, fmt.Sprintf(\"%d\", fileInfo.Size))\n\t}\n\thttp.NewResponseController(w).SetWriteDeadline(time.Time{})\n\t_, copyErr := io.Copy(w, reader)\n\tif copyErr != nil && r.Context().Err() == nil {\n\t\tlog.Printf(\"error streaming file %q: %v\\n\", path, copyErr)\n\t}\n\treturn nil\n}\n\nfunc handleStreamLocalFile(w http.ResponseWriter, r *http.Request) {\n\tpath := r.URL.Query().Get(\"path\")\n\tif path == \"\" {\n\t\thttp.Error(w, \"path is required\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tno404 := r.URL.Query().Get(\"no404\")\n\thandleLocalStreamFile(w, r, path, no404 != \"\")\n}\n\nfunc handleStreamFile(w http.ResponseWriter, r *http.Request) {\n\tpath := r.URL.Query().Get(\"path\")\n\tif path == \"\" {\n\t\thttp.Error(w, \"path is required\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tno404 := r.URL.Query().Get(\"no404\")\n\t// path should already be formatted as a wsh:// URI (e.g. wsh://local/path or wsh://connection/path)\n\terr := handleStreamFileFromReader(w, r, path, no404 != \"\")\n\tif err != nil {\n\t\tlog.Printf(\"error streaming file %q: %v\\n\", path, err)\n\t\thttp.Error(w, fmt.Sprintf(\"error streaming file: %v\", err), http.StatusInternalServerError)\n\t}\n}\n\nfunc WriteJsonError(w http.ResponseWriter, errVal error) {\n\tw.Header().Set(ContentTypeHeaderKey, ContentTypeJson)\n\tw.WriteHeader(http.StatusOK)\n\terrMap := make(map[string]interface{})\n\terrMap[\"error\"] = errVal.Error()\n\tbarr, _ := json.Marshal(errMap)\n\tw.Write(barr)\n}\n\nfunc WriteJsonSuccess(w http.ResponseWriter, data interface{}) {\n\tw.Header().Set(ContentTypeHeaderKey, ContentTypeJson)\n\trtnMap := make(map[string]interface{})\n\trtnMap[\"success\"] = true\n\tif data != nil {\n\t\trtnMap[\"data\"] = data\n\t}\n\tbarr, err := json.Marshal(rtnMap)\n\tif err != nil {\n\t\tWriteJsonError(w, err)\n\t\treturn\n\t}\n\tw.WriteHeader(http.StatusOK)\n\tw.Write(barr)\n}\n\ntype ClientActiveState struct {\n\tFg     bool `json:\"fg\"`\n\tActive bool `json:\"active\"`\n\tOpen   bool `json:\"open\"`\n}\n\nfunc WebFnWrap(opts WebFnOpts, fn WebFnType) WebFnType {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tdefer func() {\n\t\t\trecErr := panichandler.PanicHandler(\"WebFnWrap\", recover())\n\t\t\tif recErr == nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif opts.JsonErrors {\n\t\t\t\tjsonRtn := marshalReturnValue(nil, recErr)\n\t\t\t\tw.Header().Set(ContentTypeHeaderKey, ContentTypeJson)\n\t\t\t\tw.Header().Set(ContentLengthHeaderKey, fmt.Sprintf(\"%d\", len(jsonRtn)))\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tw.Write(jsonRtn)\n\t\t\t} else {\n\t\t\t\thttp.Error(w, recErr.Error(), http.StatusInternalServerError)\n\t\t\t}\n\t\t}()\n\t\tif !opts.AllowCaching {\n\t\t\tw.Header().Set(CacheControlHeaderKey, CacheControlHeaderNoCache)\n\t\t}\n\t\tw.Header().Set(\"Access-Control-Expose-Headers\", \"X-ZoneFileInfo\")\n\n\t\t// Handle CORS preflight OPTIONS requests without auth validation\n\t\tif r.Method == http.MethodOptions {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\treturn\n\t\t}\n\n\t\terr := authkey.ValidateIncomingRequest(r)\n\t\tif err != nil {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\tw.Write([]byte(fmt.Sprintf(\"error validating authkey: %v\", err)))\n\t\t\treturn\n\t\t}\n\t\tfn(w, r)\n\t}\n}\n\nfunc MakeTCPListener(serviceName string) (net.Listener, error) {\n\tserverAddr := \"127.0.0.1:\"\n\trtn, err := net.Listen(\"tcp\", serverAddr)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating listener at %v: %v\", serverAddr, err)\n\t}\n\tlog.Printf(\"Server [%s] listening on %s\\n\", serviceName, rtn.Addr())\n\treturn rtn, nil\n}\n\nfunc MakeUnixListener() (net.Listener, error) {\n\tserverAddr := wavebase.GetDomainSocketName()\n\tos.Remove(serverAddr) // ignore error\n\trtn, err := net.Listen(\"unix\", serverAddr)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating listener at %v: %v\", serverAddr, err)\n\t}\n\tos.Chmod(serverAddr, 0700)\n\tlog.Printf(\"Server [unix-domain] listening on %s\\n\", serverAddr)\n\treturn rtn, nil\n}\n\nconst schemaPrefix = \"/schema/\"\n\n// blocking\nfunc RunWebServer(listener net.Listener) {\n\tgr := mux.NewRouter()\n\n\t// Streaming routes must be registered before the /wave/ prefix catch-all to bypass TimeoutHandler.\n\t// http.TimeoutHandler buffers the entire response before flushing, which stalls streaming.\n\tgr.HandleFunc(\"/wave/stream-local-file\", WebFnWrap(WebFnOpts{AllowCaching: true}, handleStreamLocalFile))\n\tgr.HandleFunc(\"/wave/stream-file\", WebFnWrap(WebFnOpts{AllowCaching: true}, handleStreamFile))\n\tgr.PathPrefix(\"/wave/stream-file/\").HandlerFunc(WebFnWrap(WebFnOpts{AllowCaching: true}, handleStreamFile))\n\tgr.HandleFunc(\"/api/post-chat-message\", WebFnWrap(WebFnOpts{AllowCaching: false}, aiusechat.WaveAIPostMessageHandler))\n\n\t// Non-streaming /wave/ routes get timeout protection\n\twaveRouter := mux.NewRouter()\n\twaveRouter.HandleFunc(\"/wave/file\", WebFnWrap(WebFnOpts{AllowCaching: false}, handleWaveFile))\n\twaveRouter.HandleFunc(\"/wave/service\", WebFnWrap(WebFnOpts{JsonErrors: true}, handleService))\n\twaveRouter.HandleFunc(\"/wave/aichat\", WebFnWrap(WebFnOpts{JsonErrors: true, AllowCaching: false}, aiusechat.WaveAIGetChatHandler))\n\n\tvdomRouter := mux.NewRouter()\n\tvdomRouter.HandleFunc(\"/vdom/{uuid}/{path:.*}\", WebFnWrap(WebFnOpts{AllowCaching: true}, handleVDom))\n\n\tgr.PathPrefix(\"/wave/\").Handler(http.TimeoutHandler(waveRouter, HttpTimeoutDuration, \"Timeout\"))\n\tgr.PathPrefix(\"/vdom/\").Handler(http.TimeoutHandler(vdomRouter, HttpTimeoutDuration, \"Timeout\"))\n\n\t// Other routes without timeout\n\tgr.PathPrefix(schemaPrefix).Handler(http.StripPrefix(schemaPrefix, schema.GetSchemaHandler()))\n\n\thandler := http.Handler(gr)\n\tif wavebase.IsDevMode() {\n\t\toriginalHandler := handler\n\t\thandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\torigin := r.Header.Get(\"Origin\")\n\t\t\tif origin != \"\" {\n\t\t\t\tw.Header().Set(\"Access-Control-Allow-Origin\", origin)\n\t\t\t}\n\t\t\tw.Header().Set(\"Access-Control-Allow-Methods\", \"GET, POST, PUT, DELETE, OPTIONS\")\n\t\t\tw.Header().Set(\"Access-Control-Allow-Headers\", \"Content-Type, X-Session-Id, X-AuthKey, Authorization, X-Requested-With, Accept, x-vercel-ai-ui-message-stream\")\n\t\t\tw.Header().Set(\"Access-Control-Expose-Headers\", \"X-ZoneFileInfo, Content-Length, Content-Type, x-vercel-ai-ui-message-stream\")\n\t\t\tw.Header().Set(\"Access-Control-Allow-Credentials\", \"true\")\n\n\t\t\tif r.Method == \"OPTIONS\" {\n\t\t\t\tw.WriteHeader(204)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\toriginalHandler.ServeHTTP(w, r)\n\t\t})\n\t}\n\tserver := &http.Server{\n\t\tReadTimeout:    HttpReadTimeout,\n\t\tWriteTimeout:   HttpWriteTimeout,\n\t\tMaxHeaderBytes: HttpMaxHeaderBytes,\n\t\tHandler:        handler,\n\t}\n\terr := server.Serve(listener)\n\tif err != nil {\n\t\tlog.Printf(\"ERROR: %v\\n\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/web/webcmd/webcmd.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage webcmd\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nconst (\n\tWSCommand_Rpc = \"rpc\"\n)\n\ntype WSCommandType interface {\n\tGetWSCommand() string\n}\n\nfunc WSCommandTypeUnionMeta() tsgenmeta.TypeUnionMeta {\n\treturn tsgenmeta.TypeUnionMeta{\n\t\tBaseType:      reflect.TypeOf((*WSCommandType)(nil)).Elem(),\n\t\tTypeFieldName: \"wscommand\",\n\t\tTypes: []reflect.Type{\n\t\t\treflect.TypeOf(WSRpcCommand{}),\n\t\t},\n\t}\n}\n\ntype WSRpcCommand struct {\n\tWSCommand string              `json:\"wscommand\" tstype:\"\\\"rpc\\\"\"`\n\tMessage   *wshutil.RpcMessage `json:\"message\"`\n}\n\nfunc (cmd *WSRpcCommand) GetWSCommand() string {\n\treturn cmd.WSCommand\n}\n\nfunc ParseWSCommandMap(cmdMap map[string]any) (WSCommandType, error) {\n\tcmdType, ok := cmdMap[\"wscommand\"].(string)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"no wscommand field in command map\")\n\t}\n\tswitch cmdType {\n\tcase WSCommand_Rpc:\n\t\tvar cmd WSRpcCommand\n\t\terr := utilfn.DoMapStructure(&cmd, cmdMap)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error decoding WSRpcCommand: %w\", err)\n\t\t}\n\t\treturn &cmd, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unknown wscommand type %q\", cmdType)\n\t}\n}\n"
  },
  {
    "path": "pkg/web/webvdomproto.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage web\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\n// Add the new handler function\nfunc handleVDom(w http.ResponseWriter, r *http.Request) {\n\t// Extract UUID and path from URL\n\tpathParts := strings.Split(strings.TrimPrefix(r.URL.Path, \"/vdom/\"), \"/\")\n\tif len(pathParts) < 1 {\n\t\thttp.Error(w, \"Invalid VDOM URL format\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tuuid := pathParts[0]\n\t// Simple UUID validation\n\tif len(uuid) != 36 {\n\t\thttp.Error(w, \"Invalid UUID format\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Reconstruct the remaining path\n\tpath := \"/\" + strings.Join(pathParts[1:], \"/\")\n\tif r.URL.RawQuery != \"\" {\n\t\tpath += \"?\" + r.URL.RawQuery\n\t}\n\n\t// Read request body if present\n\tvar body []byte\n\tvar err error\n\tif r.Body != nil {\n\t\tbody, err = io.ReadAll(r.Body)\n\t\tif err != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"Error reading request body: %v\", err), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tdefer r.Body.Close()\n\t}\n\n\t// Convert headers to map\n\theaders := make(map[string]string)\n\tfor key, values := range r.Header {\n\t\tif len(values) > 0 {\n\t\t\theaders[key] = values[0]\n\t\t}\n\t}\n\n\t// Prepare RPC request data\n\tdata := wshrpc.VDomUrlRequestData{\n\t\tMethod:  r.Method,\n\t\tURL:     path,\n\t\tHeaders: headers,\n\t\tBody:    body,\n\t}\n\n\t// Get RPC client\n\tclient := wshserver.GetMainRpcClient()\n\n\t// Make RPC call with route to specific process\n\troute := wshutil.MakeProcRouteId(uuid)\n\trespCh := wshclient.VDomUrlRequestCommand(client, data, &wshrpc.RpcOpts{\n\t\tRoute: route,\n\t})\n\n\t// Handle first response to set headers\n\tfirstResp := true\n\tfor respUnion := range respCh {\n\t\tif respUnion.Error != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"RPC error: %v\", respUnion.Error), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tresp := respUnion.Response\n\t\tif firstResp {\n\t\t\tfirstResp = false\n\t\t\t// Set status code and headers from first response\n\t\t\tif resp.StatusCode > 0 {\n\t\t\t\tw.WriteHeader(resp.StatusCode)\n\t\t\t} else {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t}\n\t\t\t// Copy headers\n\t\t\tfor key, value := range resp.Headers {\n\t\t\t\tw.Header().Set(key, value)\n\t\t\t}\n\t\t}\n\n\t\t// Write body chunk if present\n\t\tif len(resp.Body) > 0 {\n\t\t\t_, err = w.Write(resp.Body)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"Error writing response: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/web/ws.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage web\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/gorilla/mux\"\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/wavetermdev/waveterm/pkg/authkey\"\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/eventbus\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/web/webcmd\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nconst wsReadWaitTimeout = 15 * time.Second\nconst wsWriteWaitTimeout = 10 * time.Second\nconst wsPingPeriodTickTime = 10 * time.Second\nconst wsInitialPingTime = 1 * time.Second\nconst wsMaxMessageSize = 10 * 1024 * 1024\n\nconst DefaultCommandTimeout = 2 * time.Second\nconst WebSocketChannelSize = 128\n\ntype StableConnInfo struct {\n\tConnId string\n\tLinkId baseds.LinkId\n}\n\nvar GlobalLock = &sync.Mutex{}\nvar RouteToConnMap = map[string]*StableConnInfo{} // stableid => StableConnInfo\n\nfunc RunWebSocketServer(listener net.Listener) {\n\tgr := mux.NewRouter()\n\tgr.HandleFunc(\"/ws\", HandleWs)\n\tserver := &http.Server{\n\t\tReadTimeout:    HttpReadTimeout,\n\t\tWriteTimeout:   HttpWriteTimeout,\n\t\tMaxHeaderBytes: HttpMaxHeaderBytes,\n\t\tHandler:        gr,\n\t}\n\tserver.SetKeepAlivesEnabled(false)\n\tlog.Printf(\"[websocket] running websocket server on %s\\n\", listener.Addr())\n\terr := server.Serve(listener)\n\tif err != nil {\n\t\tlog.Printf(\"[websocket] error trying to run websocket server: %v\\n\", err)\n\t}\n}\n\nvar WebSocketUpgrader = websocket.Upgrader{\n\tReadBufferSize:   4 * 1024,\n\tWriteBufferSize:  32 * 1024,\n\tHandshakeTimeout: 1 * time.Second,\n\tCheckOrigin:      func(r *http.Request) bool { return true },\n}\n\nfunc HandleWs(w http.ResponseWriter, r *http.Request) {\n\terr := HandleWsInternal(w, r)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t}\n}\n\nfunc getMessageType(jmsg map[string]any) string {\n\tif str, ok := jmsg[\"type\"].(string); ok {\n\t\treturn str\n\t}\n\treturn \"\"\n}\n\nfunc getStringFromMap(jmsg map[string]any, key string) string {\n\tif str, ok := jmsg[key].(string); ok {\n\t\treturn str\n\t}\n\treturn \"\"\n}\n\nfunc processWSCommand(jmsg map[string]any, outputCh chan any, rpcInputCh chan baseds.RpcInputChType) {\n\tvar rtnErr error\n\tvar cmdType string\n\tdefer func() {\n\t\tpanicCtx := \"processWSCommand\"\n\t\tif cmdType != \"\" {\n\t\t\tpanicCtx = fmt.Sprintf(\"processWSCommand:%s\", cmdType)\n\t\t}\n\t\tpanicErr := panichandler.PanicHandler(panicCtx, recover())\n\t\tif panicErr != nil {\n\t\t\trtnErr = panicErr\n\t\t}\n\t\tif rtnErr == nil {\n\t\t\treturn\n\t\t}\n\t\trtn := map[string]any{\"type\": \"error\", \"error\": rtnErr.Error()}\n\t\toutputCh <- rtn\n\t}()\n\twsCommand, err := webcmd.ParseWSCommandMap(jmsg)\n\tif err != nil {\n\t\trtnErr = fmt.Errorf(\"cannot parse wscommand: %v\", err)\n\t\treturn\n\t}\n\tcmdType = wsCommand.GetWSCommand()\n\tswitch cmd := wsCommand.(type) {\n\tcase *webcmd.WSRpcCommand:\n\t\trpcMsg := cmd.Message\n\t\tif rpcMsg == nil {\n\t\t\treturn\n\t\t}\n\t\tif rpcMsg.Command != \"\" {\n\t\t\tcmdType = fmt.Sprintf(\"%s:%s\", cmdType, rpcMsg.Command)\n\t\t}\n\t\tmsgBytes, err := json.Marshal(rpcMsg)\n\t\tif err != nil {\n\t\t\t// this really should never fail since we just unmarshalled this value\n\t\t\treturn\n\t\t}\n\t\trpcInputCh <- baseds.RpcInputChType{MsgBytes: msgBytes}\n\t}\n}\n\nfunc ReadLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any, rpcInputCh chan baseds.RpcInputChType, routeId string) {\n\treadWait := wsReadWaitTimeout\n\tconn.SetReadLimit(wsMaxMessageSize)\n\tconn.SetReadDeadline(time.Now().Add(readWait))\n\tdefer close(closeCh)\n\tfor {\n\t\t_, message, err := conn.ReadMessage()\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[websocket] ReadPump error (%s): %v\\n\", routeId, err)\n\t\t\tbreak\n\t\t}\n\t\tjmsg := map[string]any{}\n\t\terr = json.Unmarshal(message, &jmsg)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[websocket] error unmarshalling json: %v\\n\", err)\n\t\t\tbreak\n\t\t}\n\t\tconn.SetReadDeadline(time.Now().Add(readWait))\n\t\tmsgType := getMessageType(jmsg)\n\t\tif msgType == \"pong\" {\n\t\t\t// nothing\n\t\t\tcontinue\n\t\t}\n\t\tif msgType == \"ping\" {\n\t\t\tnow := time.Now()\n\t\t\tpongMessage := map[string]interface{}{\"type\": \"pong\", \"stime\": now.UnixMilli()}\n\t\t\toutputCh <- pongMessage\n\t\t\tcontinue\n\t\t}\n\t\twsCommand := getStringFromMap(jmsg, \"wscommand\")\n\t\tif wsCommand == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tprocessWSCommand(jmsg, outputCh, rpcInputCh)\n\t}\n}\n\nfunc WritePing(conn *websocket.Conn) error {\n\tnow := time.Now()\n\tpingMessage := map[string]interface{}{\"type\": \"ping\", \"stime\": now.UnixMilli()}\n\tjsonVal, _ := json.Marshal(pingMessage)\n\t_ = conn.SetWriteDeadline(time.Now().Add(wsWriteWaitTimeout)) // no error\n\terr := conn.WriteMessage(websocket.TextMessage, jsonVal)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc WriteLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any, routeId string) {\n\tticker := time.NewTicker(wsInitialPingTime)\n\tdefer ticker.Stop()\n\tinitialPing := true\n\tfor {\n\t\tselect {\n\t\tcase msg := <-outputCh:\n\t\t\tvar barr []byte\n\t\t\tvar err error\n\t\t\tif _, ok := msg.([]byte); ok {\n\t\t\t\tbarr = msg.([]byte)\n\t\t\t} else {\n\t\t\t\tbarr, err = json.Marshal(msg)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Printf(\"[websocket] cannot marshal websocket message: %v\\n\", err)\n\t\t\t\t\t// just loop again\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\terr = conn.WriteMessage(websocket.TextMessage, barr)\n\t\t\tif err != nil {\n\t\t\t\tconn.Close()\n\t\t\t\tlog.Printf(\"[websocket] WritePump error (%s): %v\\n\", routeId, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\tcase <-ticker.C:\n\t\t\terr := WritePing(conn)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"[websocket] WritePump error (%s): %v\\n\", routeId, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif initialPing {\n\t\t\t\tinitialPing = false\n\t\t\t\tticker.Reset(wsPingPeriodTickTime)\n\t\t\t}\n\n\t\tcase <-closeCh:\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc registerConn(wsConnId string, stableId string, wproxy *wshutil.WshRpcProxy) {\n\tGlobalLock.Lock()\n\tdefer GlobalLock.Unlock()\n\tcurConnInfo := RouteToConnMap[stableId]\n\tif curConnInfo != nil {\n\t\tlog.Printf(\"[websocket] warning: replacing existing connection for stableid %q\\n\", stableId)\n\t\tif curConnInfo.LinkId != baseds.NoLinkId {\n\t\t\twshutil.DefaultRouter.UnregisterLink(curConnInfo.LinkId)\n\t\t}\n\t}\n\tlinkId := wshutil.DefaultRouter.RegisterTrustedRouter(wproxy)\n\tRouteToConnMap[stableId] = &StableConnInfo{\n\t\tConnId: wsConnId,\n\t\tLinkId: linkId,\n\t}\n}\n\nfunc unregisterConn(wsConnId string, stableId string) {\n\tGlobalLock.Lock()\n\tdefer GlobalLock.Unlock()\n\tcurConnInfo := RouteToConnMap[stableId]\n\tif curConnInfo == nil || curConnInfo.ConnId != wsConnId {\n\t\tlog.Printf(\"[websocket] warning: trying to unregister connection %q for stableid %q but it is not the current connection (ignoring)\\n\", wsConnId, stableId)\n\t\treturn\n\t}\n\tdelete(RouteToConnMap, stableId)\n\tif curConnInfo.LinkId != baseds.NoLinkId {\n\t\twshutil.DefaultRouter.UnregisterLink(curConnInfo.LinkId)\n\t}\n}\n\nfunc HandleWsInternal(w http.ResponseWriter, r *http.Request) error {\n\tstableId := r.URL.Query().Get(\"stableid\")\n\tif stableId == \"\" {\n\t\treturn fmt.Errorf(\"stableid is required\")\n\t}\n\terr := authkey.ValidateIncomingRequest(r)\n\tif err != nil {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\tw.Write([]byte(fmt.Sprintf(\"error validating authkey: %v\", err)))\n\t\tlog.Printf(\"[websocket] error validating authkey: %v\\n\", err)\n\t\treturn err\n\t}\n\tconn, err := WebSocketUpgrader.Upgrade(w, r, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"WebSocket Upgrade Failed: %v\", err)\n\t}\n\tdefer conn.Close()\n\twsConnId := uuid.New().String()\n\toutputCh := make(chan any, WebSocketChannelSize)\n\tcloseCh := make(chan any)\n\tlog.Printf(\"[websocket] new connection: connid:%s stableid:%s\\n\", wsConnId, stableId)\n\teventbus.RegisterWSChannel(wsConnId, stableId, outputCh)\n\tdefer eventbus.UnregisterWSChannel(wsConnId)\n\twproxy := wshutil.MakeRpcProxyWithSize(fmt.Sprintf(\"ws:%s\", stableId), WebSocketChannelSize, WebSocketChannelSize)\n\tdefer close(wproxy.ToRemoteCh)\n\tregisterConn(wsConnId, stableId, wproxy)\n\tdefer unregisterConn(wsConnId, stableId)\n\twg := &sync.WaitGroup{}\n\twg.Add(2)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"HandleWsInternal:outputCh\", recover())\n\t\t}()\n\t\t// no waitgroup add here\n\t\t// move values from rpcOutputCh to outputCh\n\t\tfor msgBytes := range wproxy.ToRemoteCh {\n\t\t\trpcWSMsg := map[string]any{\n\t\t\t\t\"eventtype\": \"rpc\", // TODO don't hard code this (but def is in eventbus)\n\t\t\t\t\"data\":      json.RawMessage(msgBytes),\n\t\t\t}\n\t\t\toutputCh <- rpcWSMsg\n\t\t}\n\t}()\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"HandleWsInternal:ReadLoop\", recover())\n\t\t}()\n\t\tdefer wg.Done()\n\t\tReadLoop(conn, outputCh, closeCh, wproxy.FromRemoteCh, stableId)\n\t}()\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"HandleWsInternal:WriteLoop\", recover())\n\t\t}()\n\t\tdefer wg.Done()\n\t\tWriteLoop(conn, outputCh, closeCh, stableId)\n\t}()\n\twg.Wait()\n\tclose(wproxy.FromRemoteCh)\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/wps/wps.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// wave pubsub system\npackage wps\n\nimport (\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n)\n\n// this broker interface is mostly generic\n// strong typing and event types can be defined elsewhere\n\nconst MaxPersist = 4096\n\ntype Client interface {\n\tSendEvent(routeId string, event WaveEvent)\n}\n\ntype BrokerSubscription struct {\n\tAllSubs   []string            // routeids subscribed to \"all\" events\n\tScopeSubs map[string][]string // routeids subscribed to specific scopes\n\tStarSubs  map[string][]string // routeids subscribed to star scope (scopes with \"*\" or \"**\" in them)\n}\n\ntype persistKey struct {\n\tEvent string\n\tScope string\n}\n\ntype persistEventWrap struct {\n\tEvents []*WaveEvent\n}\n\ntype BrokerType struct {\n\tLock       *sync.Mutex\n\tClient     Client\n\tSubMap     map[string]*BrokerSubscription\n\tPersistMap map[persistKey]*persistEventWrap\n}\n\nvar Broker = &BrokerType{\n\tLock:       &sync.Mutex{},\n\tSubMap:     make(map[string]*BrokerSubscription),\n\tPersistMap: make(map[persistKey]*persistEventWrap),\n}\n\nfunc scopeHasStarMatch(scope string) bool {\n\tparts := strings.Split(scope, \":\")\n\tfor _, part := range parts {\n\t\tif part == \"*\" || part == \"**\" {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (b *BrokerType) SetClient(client Client) {\n\tb.Lock.Lock()\n\tdefer b.Lock.Unlock()\n\tb.Client = client\n}\n\nfunc (b *BrokerType) GetClient() Client {\n\tb.Lock.Lock()\n\tdefer b.Lock.Unlock()\n\treturn b.Client\n}\n\n// if already subscribed, this will *resubscribe* with the new subscription (remove the old one, and replace with this one)\nfunc (b *BrokerType) Subscribe(subRouteId string, sub SubscriptionRequest) {\n\t// log.Printf(\"[wps] sub %s %s\\n\", subRouteId, sub.Event)\n\tif sub.Event == \"\" {\n\t\treturn\n\t}\n\tb.Lock.Lock()\n\tdefer b.Lock.Unlock()\n\tb.unsubscribe_nolock(subRouteId, sub.Event)\n\tbs := b.SubMap[sub.Event]\n\tif bs == nil {\n\t\tbs = &BrokerSubscription{\n\t\t\tAllSubs:   []string{},\n\t\t\tScopeSubs: make(map[string][]string),\n\t\t\tStarSubs:  make(map[string][]string),\n\t\t}\n\t\tb.SubMap[sub.Event] = bs\n\t}\n\tif sub.AllScopes {\n\t\tbs.AllSubs = utilfn.AddElemToSliceUniq(bs.AllSubs, subRouteId)\n\t\treturn\n\t}\n\tfor _, scope := range sub.Scopes {\n\t\tstarMatch := scopeHasStarMatch(scope)\n\t\tif starMatch {\n\t\t\taddStrToScopeMap(bs.StarSubs, scope, subRouteId)\n\t\t} else {\n\t\t\taddStrToScopeMap(bs.ScopeSubs, scope, subRouteId)\n\t\t}\n\t}\n}\n\nfunc (bs *BrokerSubscription) IsEmpty() bool {\n\treturn len(bs.AllSubs) == 0 && len(bs.ScopeSubs) == 0 && len(bs.StarSubs) == 0\n}\n\nfunc removeStrFromScopeMap(scopeMap map[string][]string, scope string, routeId string) {\n\tscopeSubs := scopeMap[scope]\n\tscopeSubs = utilfn.RemoveElemFromSlice(scopeSubs, routeId)\n\tif len(scopeSubs) == 0 {\n\t\tdelete(scopeMap, scope)\n\t} else {\n\t\tscopeMap[scope] = scopeSubs\n\t}\n}\n\nfunc removeStrFromScopeMapAll(scopeMap map[string][]string, routeId string) {\n\tfor scope, scopeSubs := range scopeMap {\n\t\tscopeSubs = utilfn.RemoveElemFromSlice(scopeSubs, routeId)\n\t\tif len(scopeSubs) == 0 {\n\t\t\tdelete(scopeMap, scope)\n\t\t} else {\n\t\t\tscopeMap[scope] = scopeSubs\n\t\t}\n\t}\n}\n\nfunc addStrToScopeMap(scopeMap map[string][]string, scope string, routeId string) {\n\tscopeSubs := scopeMap[scope]\n\tscopeSubs = utilfn.AddElemToSliceUniq(scopeSubs, routeId)\n\tscopeMap[scope] = scopeSubs\n}\n\nfunc (b *BrokerType) Unsubscribe(subRouteId string, eventName string) {\n\t// log.Printf(\"[wps] unsub %s %s\\n\", subRouteId, eventName)\n\tb.Lock.Lock()\n\tdefer b.Lock.Unlock()\n\tb.unsubscribe_nolock(subRouteId, eventName)\n}\n\nfunc (b *BrokerType) unsubscribe_nolock(subRouteId string, eventName string) {\n\tbs := b.SubMap[eventName]\n\tif bs == nil {\n\t\treturn\n\t}\n\tbs.AllSubs = utilfn.RemoveElemFromSlice(bs.AllSubs, subRouteId)\n\tfor scope := range bs.ScopeSubs {\n\t\tremoveStrFromScopeMap(bs.ScopeSubs, scope, subRouteId)\n\t}\n\tfor scope := range bs.StarSubs {\n\t\tremoveStrFromScopeMap(bs.StarSubs, scope, subRouteId)\n\t}\n\tif bs.IsEmpty() {\n\t\tdelete(b.SubMap, eventName)\n\t}\n}\n\nfunc (b *BrokerType) UnsubscribeAll(subRouteId string) {\n\tb.Lock.Lock()\n\tdefer b.Lock.Unlock()\n\tfor eventType, bs := range b.SubMap {\n\t\tbs.AllSubs = utilfn.RemoveElemFromSlice(bs.AllSubs, subRouteId)\n\t\tremoveStrFromScopeMapAll(bs.StarSubs, subRouteId)\n\t\tremoveStrFromScopeMapAll(bs.ScopeSubs, subRouteId)\n\t\tif bs.IsEmpty() {\n\t\t\tdelete(b.SubMap, eventType)\n\t\t}\n\t}\n}\n\n// does not take wildcards, use \"\" for all\nfunc (b *BrokerType) ReadEventHistory(eventType string, scope string, maxItems int) []*WaveEvent {\n\tif maxItems <= 0 {\n\t\treturn nil\n\t}\n\tb.Lock.Lock()\n\tdefer b.Lock.Unlock()\n\tkey := persistKey{Event: eventType, Scope: scope}\n\tpe := b.PersistMap[key]\n\tif pe == nil || len(pe.Events) == 0 {\n\t\treturn nil\n\t}\n\tif maxItems > len(pe.Events) {\n\t\tmaxItems = len(pe.Events)\n\t}\n\t// return new arr\n\trtn := make([]*WaveEvent, maxItems)\n\tcopy(rtn, pe.Events[len(pe.Events)-maxItems:])\n\treturn rtn\n}\n\nfunc (b *BrokerType) persistEvent(event WaveEvent) {\n\tif event.Persist <= 0 {\n\t\treturn\n\t}\n\tnumPersist := event.Persist\n\tif numPersist > MaxPersist {\n\t\tnumPersist = MaxPersist\n\t}\n\tscopeMap := make(map[string]bool)\n\tfor _, scope := range event.Scopes {\n\t\tscopeMap[scope] = true\n\t}\n\tscopeMap[\"\"] = true\n\tb.Lock.Lock()\n\tdefer b.Lock.Unlock()\n\tfor scope := range scopeMap {\n\t\tkey := persistKey{Event: event.Event, Scope: scope}\n\t\tpe := b.PersistMap[key]\n\t\tif pe == nil {\n\t\t\tpe = &persistEventWrap{\n\t\t\t\tEvents: make([]*WaveEvent, 0, numPersist),\n\t\t\t}\n\t\t\tb.PersistMap[key] = pe\n\t\t}\n\t\tpe.Events = append(pe.Events, &event)\n\t\tif len(pe.Events) > numPersist {\n\t\t\tpe.Events = pe.Events[len(pe.Events)-numPersist:]\n\t\t}\n\t}\n}\n\nfunc (b *BrokerType) Publish(event WaveEvent) {\n\t// log.Printf(\"BrokerType.Publish: %v\\n\", event)\n\tif event.Persist > 0 {\n\t\tb.persistEvent(event)\n\t}\n\tclient := b.GetClient()\n\tif client == nil {\n\t\treturn\n\t}\n\trouteIds := b.getMatchingRouteIds(event)\n\tfor _, routeId := range routeIds {\n\t\tclient.SendEvent(routeId, event)\n\t}\n}\n\nfunc (b *BrokerType) SendUpdateEvents(updates waveobj.UpdatesRtnType) {\n\tfor _, update := range updates {\n\t\tb.Publish(WaveEvent{\n\t\t\tEvent:  Event_WaveObjUpdate,\n\t\t\tScopes: []string{waveobj.MakeORef(update.OType, update.OID).String()},\n\t\t\tData:   update,\n\t\t})\n\t}\n}\n\nfunc (b *BrokerType) getMatchingRouteIds(event WaveEvent) []string {\n\tb.Lock.Lock()\n\tdefer b.Lock.Unlock()\n\tbs := b.SubMap[event.Event]\n\tif bs == nil {\n\t\treturn nil\n\t}\n\trouteIds := make(map[string]bool)\n\tfor _, routeId := range bs.AllSubs {\n\t\trouteIds[routeId] = true\n\t}\n\tfor _, scope := range event.Scopes {\n\t\tfor _, routeId := range bs.ScopeSubs[scope] {\n\t\t\trouteIds[routeId] = true\n\t\t}\n\t\tfor starScope := range bs.StarSubs {\n\t\t\tif utilfn.StarMatchString(starScope, scope, \":\") {\n\t\t\t\tfor _, routeId := range bs.StarSubs[starScope] {\n\t\t\t\t\trouteIds[routeId] = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tvar rtn []string\n\tfor routeId := range routeIds {\n\t\trtn = append(rtn, routeId)\n\t}\n\t// log.Printf(\"getMatchingRouteIds %v %v\\n\", event, rtn)\n\treturn rtn\n}\n"
  },
  {
    "path": "pkg/wps/wpstypes.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wps\n\nimport (\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n)\n\n// IMPORTANT: When adding a new event constant, you MUST also:\n//  1. Add a \"// type: <TypeName>\" comment (use \"none\" if no data is sent)\n//  2. Add the constant to AllEvents below\n//  3. Add an entry to WaveEventDataTypes in pkg/tsgen/tsgenevent.go\n//     - Use reflect.TypeOf(YourType{}) for value types\n//     - Use reflect.TypeOf((*YourType)(nil)) for pointer types\n//     - Use nil if no data is sent for the event\nconst (\n\tEvent_BlockClose          = \"blockclose\"           // type: string\n\tEvent_ConnChange          = \"connchange\"           // type: wshrpc.ConnStatus\n\tEvent_SysInfo             = \"sysinfo\"              // type: wshrpc.TimeSeriesData\n\tEvent_ControllerStatus    = \"controllerstatus\"     // type: *blockcontroller.BlockControllerRuntimeStatus\n\tEvent_BuilderStatus       = \"builderstatus\"        // type: wshrpc.BuilderStatusData\n\tEvent_BuilderOutput       = \"builderoutput\"        // type: map[string]any\n\tEvent_WaveObjUpdate       = \"waveobj:update\"       // type: waveobj.WaveObjUpdate\n\tEvent_BlockFile           = \"blockfile\"            // type: *WSFileEventData\n\tEvent_Config              = \"config\"               // type: wconfig.WatcherUpdate\n\tEvent_UserInput           = \"userinput\"            // type: *userinput.UserInputRequest\n\tEvent_RouteDown           = \"route:down\"           // type: none\n\tEvent_RouteUp             = \"route:up\"             // type: none\n\tEvent_WorkspaceUpdate     = \"workspace:update\"     // type: none\n\tEvent_WaveAIRateLimit     = \"waveai:ratelimit\"     // type: *uctypes.RateLimitInfo\n\tEvent_WaveAppAppGoUpdated = \"waveapp:appgoupdated\" // type: none\n\tEvent_TsunamiUpdateMeta   = \"tsunami:updatemeta\"   // type: wshrpc.AppMeta\n\tEvent_AIModeConfig        = \"waveai:modeconfig\"    // type: wconfig.AIModeConfigUpdate\n\tEvent_BlockJobStatus      = \"block:jobstatus\"      // type: wshrpc.BlockJobStatusData\n\tEvent_Badge               = \"badge\"                // type: baseds.BadgeEvent\n)\n\nvar AllEvents []string = []string{\n\tEvent_BlockClose,\n\tEvent_ConnChange,\n\tEvent_SysInfo,\n\tEvent_ControllerStatus,\n\tEvent_BuilderStatus,\n\tEvent_BuilderOutput,\n\tEvent_WaveObjUpdate,\n\tEvent_BlockFile,\n\tEvent_Config,\n\tEvent_UserInput,\n\tEvent_RouteDown,\n\tEvent_RouteUp,\n\tEvent_WorkspaceUpdate,\n\tEvent_WaveAIRateLimit,\n\tEvent_WaveAppAppGoUpdated,\n\tEvent_TsunamiUpdateMeta,\n\tEvent_AIModeConfig,\n\tEvent_BlockJobStatus,\n\tEvent_Badge,\n}\n\ntype WaveEvent struct {\n\tEvent   string   `json:\"event\"`\n\tScopes  []string `json:\"scopes,omitempty\"`\n\tSender  string   `json:\"sender,omitempty\"`\n\tPersist int      `json:\"persist,omitempty\"`\n\tData    any      `json:\"data,omitempty\"`\n}\n\nfunc (e WaveEvent) HasScope(scope string) bool {\n\treturn utilfn.ContainsStr(e.Scopes, scope)\n}\n\ntype SubscriptionRequest struct {\n\tEvent     string   `json:\"event\"`\n\tScopes    []string `json:\"scopes,omitempty\"`\n\tAllScopes bool     `json:\"allscopes,omitempty\"`\n}\n\nconst (\n\tFileOp_Create     = \"create\"\n\tFileOp_Delete     = \"delete\"\n\tFileOp_Append     = \"append\"\n\tFileOp_Truncate   = \"truncate\"\n\tFileOp_Invalidate = \"invalidate\"\n)\n\ntype WSFileEventData struct {\n\tZoneId   string `json:\"zoneid\"`\n\tFileName string `json:\"filename\"`\n\tFileOp   string `json:\"fileop\"`\n\tData64   string `json:\"data64\"`\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshclient/barerpcclient.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshclient\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\ntype WshServer struct{}\n\nfunc (*WshServer) WshServerImpl() {}\n\nvar WshServerImpl = WshServer{}\n\nvar waveSrvClient_Singleton *wshutil.WshRpc\nvar waveSrvClient_Once = &sync.Once{}\nvar waveSrvClient_RouteId string\n\nfunc GetBareRpcClient() *wshutil.WshRpc {\n\twaveSrvClient_Once.Do(func() {\n\t\twaveSrvClient_Singleton = wshutil.MakeWshRpc(wshrpc.RpcContext{}, &WshServerImpl, \"bare-client\")\n\t\twaveSrvClient_RouteId = fmt.Sprintf(\"bare:%s\", uuid.New().String())\n\t\t// we can safely ignore the error from RegisterTrustedLeaf since the route is valid\n\t\twshutil.DefaultRouter.RegisterTrustedLeaf(waveSrvClient_Singleton, waveSrvClient_RouteId)\n\t\twps.Broker.SetClient(wshutil.DefaultRouter)\n\t})\n\treturn waveSrvClient_Singleton\n}\n\nfunc GetBareRpcClientRouteId() string {\n\tGetBareRpcClient()\n\treturn waveSrvClient_RouteId\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshclient/wshclient.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// Generated Code. DO NOT EDIT.\n\npackage wshclient\n\nimport (\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\"\n\t\"github.com/wavetermdev/waveterm/pkg/vdom\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\n// command \"activity\", wshserver.ActivityCommand\nfunc ActivityCommand(w *wshutil.WshRpc, data wshrpc.ActivityUpdate, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"activity\", data, opts)\n\treturn err\n}\n\n// command \"aisendmessage\", wshserver.AiSendMessageCommand\nfunc AiSendMessageCommand(w *wshutil.WshRpc, data wshrpc.AiMessageData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"aisendmessage\", data, opts)\n\treturn err\n}\n\n// command \"authenticate\", wshserver.AuthenticateCommand\nfunc AuthenticateCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (wshrpc.CommandAuthenticateRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[wshrpc.CommandAuthenticateRtnData](w, \"authenticate\", data, opts)\n\treturn resp, err\n}\n\n// command \"authenticatejobmanager\", wshserver.AuthenticateJobManagerCommand\nfunc AuthenticateJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandAuthenticateJobManagerData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"authenticatejobmanager\", data, opts)\n\treturn err\n}\n\n// command \"authenticatejobmanagerverify\", wshserver.AuthenticateJobManagerVerifyCommand\nfunc AuthenticateJobManagerVerifyCommand(w *wshutil.WshRpc, data wshrpc.CommandAuthenticateJobManagerData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"authenticatejobmanagerverify\", data, opts)\n\treturn err\n}\n\n// command \"authenticatetojobmanager\", wshserver.AuthenticateToJobManagerCommand\nfunc AuthenticateToJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandAuthenticateToJobData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"authenticatetojobmanager\", data, opts)\n\treturn err\n}\n\n// command \"authenticatetoken\", wshserver.AuthenticateTokenCommand\nfunc AuthenticateTokenCommand(w *wshutil.WshRpc, data wshrpc.CommandAuthenticateTokenData, opts *wshrpc.RpcOpts) (wshrpc.CommandAuthenticateRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[wshrpc.CommandAuthenticateRtnData](w, \"authenticatetoken\", data, opts)\n\treturn resp, err\n}\n\n// command \"authenticatetokenverify\", wshserver.AuthenticateTokenVerifyCommand\nfunc AuthenticateTokenVerifyCommand(w *wshutil.WshRpc, data wshrpc.CommandAuthenticateTokenData, opts *wshrpc.RpcOpts) (wshrpc.CommandAuthenticateRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[wshrpc.CommandAuthenticateRtnData](w, \"authenticatetokenverify\", data, opts)\n\treturn resp, err\n}\n\n// command \"badgewatchpid\", wshserver.BadgeWatchPidCommand\nfunc BadgeWatchPidCommand(w *wshutil.WshRpc, data wshrpc.CommandBadgeWatchPidData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"badgewatchpid\", data, opts)\n\treturn err\n}\n\n// command \"blockinfo\", wshserver.BlockInfoCommand\nfunc BlockInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BlockInfoData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.BlockInfoData](w, \"blockinfo\", data, opts)\n\treturn resp, err\n}\n\n// command \"blockjobstatus\", wshserver.BlockJobStatusCommand\nfunc BlockJobStatusCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BlockJobStatusData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.BlockJobStatusData](w, \"blockjobstatus\", data, opts)\n\treturn resp, err\n}\n\n// command \"blockslist\", wshserver.BlocksListCommand\nfunc BlocksListCommand(w *wshutil.WshRpc, data wshrpc.BlocksListRequest, opts *wshrpc.RpcOpts) ([]wshrpc.BlocksListEntry, error) {\n\tresp, err := sendRpcRequestCallHelper[[]wshrpc.BlocksListEntry](w, \"blockslist\", data, opts)\n\treturn resp, err\n}\n\n// command \"captureblockscreenshot\", wshserver.CaptureBlockScreenshotCommand\nfunc CaptureBlockScreenshotCommand(w *wshutil.WshRpc, data wshrpc.CommandCaptureBlockScreenshotData, opts *wshrpc.RpcOpts) (string, error) {\n\tresp, err := sendRpcRequestCallHelper[string](w, \"captureblockscreenshot\", data, opts)\n\treturn resp, err\n}\n\n// command \"checkgoversion\", wshserver.CheckGoVersionCommand\nfunc CheckGoVersionCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (*wshrpc.CommandCheckGoVersionRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandCheckGoVersionRtnData](w, \"checkgoversion\", nil, opts)\n\treturn resp, err\n}\n\n// command \"connconnect\", wshserver.ConnConnectCommand\nfunc ConnConnectCommand(w *wshutil.WshRpc, data wshrpc.ConnRequest, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"connconnect\", data, opts)\n\treturn err\n}\n\n// command \"conndisconnect\", wshserver.ConnDisconnectCommand\nfunc ConnDisconnectCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"conndisconnect\", data, opts)\n\treturn err\n}\n\n// command \"connensure\", wshserver.ConnEnsureCommand\nfunc ConnEnsureCommand(w *wshutil.WshRpc, data wshrpc.ConnExtData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"connensure\", data, opts)\n\treturn err\n}\n\n// command \"connlist\", wshserver.ConnListCommand\nfunc ConnListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) {\n\tresp, err := sendRpcRequestCallHelper[[]string](w, \"connlist\", nil, opts)\n\treturn resp, err\n}\n\n// command \"connreinstallwsh\", wshserver.ConnReinstallWshCommand\nfunc ConnReinstallWshCommand(w *wshutil.WshRpc, data wshrpc.ConnExtData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"connreinstallwsh\", data, opts)\n\treturn err\n}\n\n// command \"connserverinit\", wshserver.ConnServerInitCommand\nfunc ConnServerInitCommand(w *wshutil.WshRpc, data wshrpc.CommandConnServerInitData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"connserverinit\", data, opts)\n\treturn err\n}\n\n// command \"connstatus\", wshserver.ConnStatusCommand\nfunc ConnStatusCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.ConnStatus, error) {\n\tresp, err := sendRpcRequestCallHelper[[]wshrpc.ConnStatus](w, \"connstatus\", nil, opts)\n\treturn resp, err\n}\n\n// command \"connupdatewsh\", wshserver.ConnUpdateWshCommand\nfunc ConnUpdateWshCommand(w *wshutil.WshRpc, data wshrpc.RemoteInfo, opts *wshrpc.RpcOpts) (bool, error) {\n\tresp, err := sendRpcRequestCallHelper[bool](w, \"connupdatewsh\", data, opts)\n\treturn resp, err\n}\n\n// command \"controlgetrouteid\", wshserver.ControlGetRouteIdCommand\nfunc ControlGetRouteIdCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) {\n\tresp, err := sendRpcRequestCallHelper[string](w, \"controlgetrouteid\", nil, opts)\n\treturn resp, err\n}\n\n// command \"controllerappendoutput\", wshserver.ControllerAppendOutputCommand\nfunc ControllerAppendOutputCommand(w *wshutil.WshRpc, data wshrpc.CommandControllerAppendOutputData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"controllerappendoutput\", data, opts)\n\treturn err\n}\n\n// command \"controllerdestroy\", wshserver.ControllerDestroyCommand\nfunc ControllerDestroyCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"controllerdestroy\", data, opts)\n\treturn err\n}\n\n// command \"controllerinput\", wshserver.ControllerInputCommand\nfunc ControllerInputCommand(w *wshutil.WshRpc, data wshrpc.CommandBlockInputData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"controllerinput\", data, opts)\n\treturn err\n}\n\n// command \"controllerresync\", wshserver.ControllerResyncCommand\nfunc ControllerResyncCommand(w *wshutil.WshRpc, data wshrpc.CommandControllerResyncData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"controllerresync\", data, opts)\n\treturn err\n}\n\n// command \"createblock\", wshserver.CreateBlockCommand\nfunc CreateBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateBlockData, opts *wshrpc.RpcOpts) (waveobj.ORef, error) {\n\tresp, err := sendRpcRequestCallHelper[waveobj.ORef](w, \"createblock\", data, opts)\n\treturn resp, err\n}\n\n// command \"createsubblock\", wshserver.CreateSubBlockCommand\nfunc CreateSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateSubBlockData, opts *wshrpc.RpcOpts) (waveobj.ORef, error) {\n\tresp, err := sendRpcRequestCallHelper[waveobj.ORef](w, \"createsubblock\", data, opts)\n\treturn resp, err\n}\n\n// command \"debugterm\", wshserver.DebugTermCommand\nfunc DebugTermCommand(w *wshutil.WshRpc, data wshrpc.CommandDebugTermData, opts *wshrpc.RpcOpts) (*wshrpc.CommandDebugTermRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandDebugTermRtnData](w, \"debugterm\", data, opts)\n\treturn resp, err\n}\n\n// command \"deleteappfile\", wshserver.DeleteAppFileCommand\nfunc DeleteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteAppFileData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"deleteappfile\", data, opts)\n\treturn err\n}\n\n// command \"deleteblock\", wshserver.DeleteBlockCommand\nfunc DeleteBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"deleteblock\", data, opts)\n\treturn err\n}\n\n// command \"deletebuilder\", wshserver.DeleteBuilderCommand\nfunc DeleteBuilderCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"deletebuilder\", data, opts)\n\treturn err\n}\n\n// command \"deletesubblock\", wshserver.DeleteSubBlockCommand\nfunc DeleteSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"deletesubblock\", data, opts)\n\treturn err\n}\n\n// command \"dismisswshfail\", wshserver.DismissWshFailCommand\nfunc DismissWshFailCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"dismisswshfail\", data, opts)\n\treturn err\n}\n\n// command \"dispose\", wshserver.DisposeCommand\nfunc DisposeCommand(w *wshutil.WshRpc, data wshrpc.CommandDisposeData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"dispose\", data, opts)\n\treturn err\n}\n\n// command \"disposesuggestions\", wshserver.DisposeSuggestionsCommand\nfunc DisposeSuggestionsCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"disposesuggestions\", data, opts)\n\treturn err\n}\n\n// command \"electrondecrypt\", wshserver.ElectronDecryptCommand\nfunc ElectronDecryptCommand(w *wshutil.WshRpc, data wshrpc.CommandElectronDecryptData, opts *wshrpc.RpcOpts) (*wshrpc.CommandElectronDecryptRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandElectronDecryptRtnData](w, \"electrondecrypt\", data, opts)\n\treturn resp, err\n}\n\n// command \"electronencrypt\", wshserver.ElectronEncryptCommand\nfunc ElectronEncryptCommand(w *wshutil.WshRpc, data wshrpc.CommandElectronEncryptData, opts *wshrpc.RpcOpts) (*wshrpc.CommandElectronEncryptRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandElectronEncryptRtnData](w, \"electronencrypt\", data, opts)\n\treturn resp, err\n}\n\n// command \"electronsystembell\", wshserver.ElectronSystemBellCommand\nfunc ElectronSystemBellCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"electronsystembell\", nil, opts)\n\treturn err\n}\n\n// command \"eventpublish\", wshserver.EventPublishCommand\nfunc EventPublishCommand(w *wshutil.WshRpc, data wps.WaveEvent, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"eventpublish\", data, opts)\n\treturn err\n}\n\n// command \"eventreadhistory\", wshserver.EventReadHistoryCommand\nfunc EventReadHistoryCommand(w *wshutil.WshRpc, data wshrpc.CommandEventReadHistoryData, opts *wshrpc.RpcOpts) ([]*wps.WaveEvent, error) {\n\tresp, err := sendRpcRequestCallHelper[[]*wps.WaveEvent](w, \"eventreadhistory\", data, opts)\n\treturn resp, err\n}\n\n// command \"eventrecv\", wshserver.EventRecvCommand\nfunc EventRecvCommand(w *wshutil.WshRpc, data wps.WaveEvent, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"eventrecv\", data, opts)\n\treturn err\n}\n\n// command \"eventsub\", wshserver.EventSubCommand\nfunc EventSubCommand(w *wshutil.WshRpc, data wps.SubscriptionRequest, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"eventsub\", data, opts)\n\treturn err\n}\n\n// command \"eventunsub\", wshserver.EventUnsubCommand\nfunc EventUnsubCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"eventunsub\", data, opts)\n\treturn err\n}\n\n// command \"eventunsuball\", wshserver.EventUnsubAllCommand\nfunc EventUnsubAllCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"eventunsuball\", nil, opts)\n\treturn err\n}\n\n// command \"fetchsuggestions\", wshserver.FetchSuggestionsCommand\nfunc FetchSuggestionsCommand(w *wshutil.WshRpc, data wshrpc.FetchSuggestionsData, opts *wshrpc.RpcOpts) (*wshrpc.FetchSuggestionsResponse, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.FetchSuggestionsResponse](w, \"fetchsuggestions\", data, opts)\n\treturn resp, err\n}\n\n// command \"fileappend\", wshserver.FileAppendCommand\nfunc FileAppendCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"fileappend\", data, opts)\n\treturn err\n}\n\n// command \"filecopy\", wshserver.FileCopyCommand\nfunc FileCopyCommand(w *wshutil.WshRpc, data wshrpc.CommandFileCopyData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"filecopy\", data, opts)\n\treturn err\n}\n\n// command \"filecreate\", wshserver.FileCreateCommand\nfunc FileCreateCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"filecreate\", data, opts)\n\treturn err\n}\n\n// command \"filedelete\", wshserver.FileDeleteCommand\nfunc FileDeleteCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteFileData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"filedelete\", data, opts)\n\treturn err\n}\n\n// command \"fileinfo\", wshserver.FileInfoCommand\nfunc FileInfoCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, \"fileinfo\", data, opts)\n\treturn resp, err\n}\n\n// command \"filejoin\", wshserver.FileJoinCommand\nfunc FileJoinCommand(w *wshutil.WshRpc, data []string, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, \"filejoin\", data, opts)\n\treturn resp, err\n}\n\n// command \"filelist\", wshserver.FileListCommand\nfunc FileListCommand(w *wshutil.WshRpc, data wshrpc.FileListData, opts *wshrpc.RpcOpts) ([]*wshrpc.FileInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[[]*wshrpc.FileInfo](w, \"filelist\", data, opts)\n\treturn resp, err\n}\n\n// command \"fileliststream\", wshserver.FileListStreamCommand\nfunc FileListStreamCommand(w *wshutil.WshRpc, data wshrpc.FileListData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] {\n\treturn sendRpcRequestResponseStreamHelper[wshrpc.CommandRemoteListEntriesRtnData](w, \"fileliststream\", data, opts)\n}\n\n// command \"filemkdir\", wshserver.FileMkdirCommand\nfunc FileMkdirCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"filemkdir\", data, opts)\n\treturn err\n}\n\n// command \"filemove\", wshserver.FileMoveCommand\nfunc FileMoveCommand(w *wshutil.WshRpc, data wshrpc.CommandFileCopyData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"filemove\", data, opts)\n\treturn err\n}\n\n// command \"fileread\", wshserver.FileReadCommand\nfunc FileReadCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) (*wshrpc.FileData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.FileData](w, \"fileread\", data, opts)\n\treturn resp, err\n}\n\n// command \"filereadstream\", wshserver.FileReadStreamCommand\nfunc FileReadStreamCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.FileData] {\n\treturn sendRpcRequestResponseStreamHelper[wshrpc.FileData](w, \"filereadstream\", data, opts)\n}\n\n// command \"filerestorebackup\", wshserver.FileRestoreBackupCommand\nfunc FileRestoreBackupCommand(w *wshutil.WshRpc, data wshrpc.CommandFileRestoreBackupData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"filerestorebackup\", data, opts)\n\treturn err\n}\n\n// command \"filestream\", wshserver.FileStreamCommand\nfunc FileStreamCommand(w *wshutil.WshRpc, data wshrpc.CommandFileStreamData, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, \"filestream\", data, opts)\n\treturn resp, err\n}\n\n// command \"filewrite\", wshserver.FileWriteCommand\nfunc FileWriteCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"filewrite\", data, opts)\n\treturn err\n}\n\n// command \"findgitbash\", wshserver.FindGitBashCommand\nfunc FindGitBashCommand(w *wshutil.WshRpc, data bool, opts *wshrpc.RpcOpts) (string, error) {\n\tresp, err := sendRpcRequestCallHelper[string](w, \"findgitbash\", data, opts)\n\treturn resp, err\n}\n\n// command \"focuswindow\", wshserver.FocusWindowCommand\nfunc FocusWindowCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"focuswindow\", data, opts)\n\treturn err\n}\n\n// command \"getallbadges\", wshserver.GetAllBadgesCommand\nfunc GetAllBadgesCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]baseds.BadgeEvent, error) {\n\tresp, err := sendRpcRequestCallHelper[[]baseds.BadgeEvent](w, \"getallbadges\", nil, opts)\n\treturn resp, err\n}\n\n// command \"getallvars\", wshserver.GetAllVarsCommand\nfunc GetAllVarsCommand(w *wshutil.WshRpc, data wshrpc.CommandVarData, opts *wshrpc.RpcOpts) ([]wshrpc.CommandVarResponseData, error) {\n\tresp, err := sendRpcRequestCallHelper[[]wshrpc.CommandVarResponseData](w, \"getallvars\", data, opts)\n\treturn resp, err\n}\n\n// command \"getbuilderoutput\", wshserver.GetBuilderOutputCommand\nfunc GetBuilderOutputCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) ([]string, error) {\n\tresp, err := sendRpcRequestCallHelper[[]string](w, \"getbuilderoutput\", data, opts)\n\treturn resp, err\n}\n\n// command \"getbuilderstatus\", wshserver.GetBuilderStatusCommand\nfunc GetBuilderStatusCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BuilderStatusData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.BuilderStatusData](w, \"getbuilderstatus\", data, opts)\n\treturn resp, err\n}\n\n// command \"getfocusedblockdata\", wshserver.GetFocusedBlockDataCommand\nfunc GetFocusedBlockDataCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (*wshrpc.FocusedBlockData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.FocusedBlockData](w, \"getfocusedblockdata\", nil, opts)\n\treturn resp, err\n}\n\n// command \"getfullconfig\", wshserver.GetFullConfigCommand\nfunc GetFullConfigCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wconfig.FullConfigType, error) {\n\tresp, err := sendRpcRequestCallHelper[wconfig.FullConfigType](w, \"getfullconfig\", nil, opts)\n\treturn resp, err\n}\n\n// command \"getjwtpublickey\", wshserver.GetJwtPublicKeyCommand\nfunc GetJwtPublicKeyCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) {\n\tresp, err := sendRpcRequestCallHelper[string](w, \"getjwtpublickey\", nil, opts)\n\treturn resp, err\n}\n\n// command \"getmeta\", wshserver.GetMetaCommand\nfunc GetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandGetMetaData, opts *wshrpc.RpcOpts) (waveobj.MetaMapType, error) {\n\tresp, err := sendRpcRequestCallHelper[waveobj.MetaMapType](w, \"getmeta\", data, opts)\n\treturn resp, err\n}\n\n// command \"getrtinfo\", wshserver.GetRTInfoCommand\nfunc GetRTInfoCommand(w *wshutil.WshRpc, data wshrpc.CommandGetRTInfoData, opts *wshrpc.RpcOpts) (*waveobj.ObjRTInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[*waveobj.ObjRTInfo](w, \"getrtinfo\", data, opts)\n\treturn resp, err\n}\n\n// command \"getsecrets\", wshserver.GetSecretsCommand\nfunc GetSecretsCommand(w *wshutil.WshRpc, data []string, opts *wshrpc.RpcOpts) (map[string]string, error) {\n\tresp, err := sendRpcRequestCallHelper[map[string]string](w, \"getsecrets\", data, opts)\n\treturn resp, err\n}\n\n// command \"getsecretslinuxstoragebackend\", wshserver.GetSecretsLinuxStorageBackendCommand\nfunc GetSecretsLinuxStorageBackendCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) {\n\tresp, err := sendRpcRequestCallHelper[string](w, \"getsecretslinuxstoragebackend\", nil, opts)\n\treturn resp, err\n}\n\n// command \"getsecretsnames\", wshserver.GetSecretsNamesCommand\nfunc GetSecretsNamesCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) {\n\tresp, err := sendRpcRequestCallHelper[[]string](w, \"getsecretsnames\", nil, opts)\n\treturn resp, err\n}\n\n// command \"gettab\", wshserver.GetTabCommand\nfunc GetTabCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*waveobj.Tab, error) {\n\tresp, err := sendRpcRequestCallHelper[*waveobj.Tab](w, \"gettab\", data, opts)\n\treturn resp, err\n}\n\n// command \"gettempdir\", wshserver.GetTempDirCommand\nfunc GetTempDirCommand(w *wshutil.WshRpc, data wshrpc.CommandGetTempDirData, opts *wshrpc.RpcOpts) (string, error) {\n\tresp, err := sendRpcRequestCallHelper[string](w, \"gettempdir\", data, opts)\n\treturn resp, err\n}\n\n// command \"getupdatechannel\", wshserver.GetUpdateChannelCommand\nfunc GetUpdateChannelCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) {\n\tresp, err := sendRpcRequestCallHelper[string](w, \"getupdatechannel\", nil, opts)\n\treturn resp, err\n}\n\n// command \"getvar\", wshserver.GetVarCommand\nfunc GetVarCommand(w *wshutil.WshRpc, data wshrpc.CommandVarData, opts *wshrpc.RpcOpts) (*wshrpc.CommandVarResponseData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandVarResponseData](w, \"getvar\", data, opts)\n\treturn resp, err\n}\n\n// command \"getwaveaichat\", wshserver.GetWaveAIChatCommand\nfunc GetWaveAIChatCommand(w *wshutil.WshRpc, data wshrpc.CommandGetWaveAIChatData, opts *wshrpc.RpcOpts) (*uctypes.UIChat, error) {\n\tresp, err := sendRpcRequestCallHelper[*uctypes.UIChat](w, \"getwaveaichat\", data, opts)\n\treturn resp, err\n}\n\n// command \"getwaveaimodeconfig\", wshserver.GetWaveAIModeConfigCommand\nfunc GetWaveAIModeConfigCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wconfig.AIModeConfigUpdate, error) {\n\tresp, err := sendRpcRequestCallHelper[wconfig.AIModeConfigUpdate](w, \"getwaveaimodeconfig\", nil, opts)\n\treturn resp, err\n}\n\n// command \"getwaveairatelimit\", wshserver.GetWaveAIRateLimitCommand\nfunc GetWaveAIRateLimitCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (*uctypes.RateLimitInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[*uctypes.RateLimitInfo](w, \"getwaveairatelimit\", nil, opts)\n\treturn resp, err\n}\n\n// command \"jobcmdexited\", wshserver.JobCmdExitedCommand\nfunc JobCmdExitedCommand(w *wshutil.WshRpc, data wshrpc.CommandJobCmdExitedData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"jobcmdexited\", data, opts)\n\treturn err\n}\n\n// command \"jobcontrollerattachjob\", wshserver.JobControllerAttachJobCommand\nfunc JobControllerAttachJobCommand(w *wshutil.WshRpc, data wshrpc.CommandJobControllerAttachJobData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"jobcontrollerattachjob\", data, opts)\n\treturn err\n}\n\n// command \"jobcontrollerconnectedjobs\", wshserver.JobControllerConnectedJobsCommand\nfunc JobControllerConnectedJobsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) {\n\tresp, err := sendRpcRequestCallHelper[[]string](w, \"jobcontrollerconnectedjobs\", nil, opts)\n\treturn resp, err\n}\n\n// command \"jobcontrollerdeletejob\", wshserver.JobControllerDeleteJobCommand\nfunc JobControllerDeleteJobCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"jobcontrollerdeletejob\", data, opts)\n\treturn err\n}\n\n// command \"jobcontrollerdetachjob\", wshserver.JobControllerDetachJobCommand\nfunc JobControllerDetachJobCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"jobcontrollerdetachjob\", data, opts)\n\treturn err\n}\n\n// command \"jobcontrollerdisconnectjob\", wshserver.JobControllerDisconnectJobCommand\nfunc JobControllerDisconnectJobCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"jobcontrollerdisconnectjob\", data, opts)\n\treturn err\n}\n\n// command \"jobcontrollerexitjob\", wshserver.JobControllerExitJobCommand\nfunc JobControllerExitJobCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"jobcontrollerexitjob\", data, opts)\n\treturn err\n}\n\n// command \"jobcontrollergetalljobmanagerstatus\", wshserver.JobControllerGetAllJobManagerStatusCommand\nfunc JobControllerGetAllJobManagerStatusCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]*wshrpc.JobManagerStatusUpdate, error) {\n\tresp, err := sendRpcRequestCallHelper[[]*wshrpc.JobManagerStatusUpdate](w, \"jobcontrollergetalljobmanagerstatus\", nil, opts)\n\treturn resp, err\n}\n\n// command \"jobcontrollerlist\", wshserver.JobControllerListCommand\nfunc JobControllerListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]*waveobj.Job, error) {\n\tresp, err := sendRpcRequestCallHelper[[]*waveobj.Job](w, \"jobcontrollerlist\", nil, opts)\n\treturn resp, err\n}\n\n// command \"jobcontrollerreconnectjob\", wshserver.JobControllerReconnectJobCommand\nfunc JobControllerReconnectJobCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"jobcontrollerreconnectjob\", data, opts)\n\treturn err\n}\n\n// command \"jobcontrollerreconnectjobsforconn\", wshserver.JobControllerReconnectJobsForConnCommand\nfunc JobControllerReconnectJobsForConnCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"jobcontrollerreconnectjobsforconn\", data, opts)\n\treturn err\n}\n\n// command \"jobcontrollerstartjob\", wshserver.JobControllerStartJobCommand\nfunc JobControllerStartJobCommand(w *wshutil.WshRpc, data wshrpc.CommandJobControllerStartJobData, opts *wshrpc.RpcOpts) (string, error) {\n\tresp, err := sendRpcRequestCallHelper[string](w, \"jobcontrollerstartjob\", data, opts)\n\treturn resp, err\n}\n\n// command \"jobinput\", wshserver.JobInputCommand\nfunc JobInputCommand(w *wshutil.WshRpc, data wshrpc.CommandJobInputData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"jobinput\", data, opts)\n\treturn err\n}\n\n// command \"jobprepareconnect\", wshserver.JobPrepareConnectCommand\nfunc JobPrepareConnectCommand(w *wshutil.WshRpc, data wshrpc.CommandJobPrepareConnectData, opts *wshrpc.RpcOpts) (*wshrpc.CommandJobConnectRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandJobConnectRtnData](w, \"jobprepareconnect\", data, opts)\n\treturn resp, err\n}\n\n// command \"jobstartstream\", wshserver.JobStartStreamCommand\nfunc JobStartStreamCommand(w *wshutil.WshRpc, data wshrpc.CommandJobStartStreamData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"jobstartstream\", data, opts)\n\treturn err\n}\n\n// command \"listallappfiles\", wshserver.ListAllAppFilesCommand\nfunc ListAllAppFilesCommand(w *wshutil.WshRpc, data wshrpc.CommandListAllAppFilesData, opts *wshrpc.RpcOpts) (*wshrpc.CommandListAllAppFilesRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandListAllAppFilesRtnData](w, \"listallappfiles\", data, opts)\n\treturn resp, err\n}\n\n// command \"listallapps\", wshserver.ListAllAppsCommand\nfunc ListAllAppsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.AppInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[[]wshrpc.AppInfo](w, \"listallapps\", nil, opts)\n\treturn resp, err\n}\n\n// command \"listalleditableapps\", wshserver.ListAllEditableAppsCommand\nfunc ListAllEditableAppsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.AppInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[[]wshrpc.AppInfo](w, \"listalleditableapps\", nil, opts)\n\treturn resp, err\n}\n\n// command \"macosversion\", wshserver.MacOSVersionCommand\nfunc MacOSVersionCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) {\n\tresp, err := sendRpcRequestCallHelper[string](w, \"macosversion\", nil, opts)\n\treturn resp, err\n}\n\n// command \"makedraftfromlocal\", wshserver.MakeDraftFromLocalCommand\nfunc MakeDraftFromLocalCommand(w *wshutil.WshRpc, data wshrpc.CommandMakeDraftFromLocalData, opts *wshrpc.RpcOpts) (*wshrpc.CommandMakeDraftFromLocalRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandMakeDraftFromLocalRtnData](w, \"makedraftfromlocal\", data, opts)\n\treturn resp, err\n}\n\n// command \"message\", wshserver.MessageCommand\nfunc MessageCommand(w *wshutil.WshRpc, data wshrpc.CommandMessageData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"message\", data, opts)\n\treturn err\n}\n\n// command \"networkonline\", wshserver.NetworkOnlineCommand\nfunc NetworkOnlineCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (bool, error) {\n\tresp, err := sendRpcRequestCallHelper[bool](w, \"networkonline\", nil, opts)\n\treturn resp, err\n}\n\n// command \"notify\", wshserver.NotifyCommand\nfunc NotifyCommand(w *wshutil.WshRpc, data wshrpc.WaveNotificationOptions, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"notify\", data, opts)\n\treturn err\n}\n\n// command \"notifysystemresume\", wshserver.NotifySystemResumeCommand\nfunc NotifySystemResumeCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"notifysystemresume\", nil, opts)\n\treturn err\n}\n\n// command \"path\", wshserver.PathCommand\nfunc PathCommand(w *wshutil.WshRpc, data wshrpc.PathCommandData, opts *wshrpc.RpcOpts) (string, error) {\n\tresp, err := sendRpcRequestCallHelper[string](w, \"path\", data, opts)\n\treturn resp, err\n}\n\n// command \"publishapp\", wshserver.PublishAppCommand\nfunc PublishAppCommand(w *wshutil.WshRpc, data wshrpc.CommandPublishAppData, opts *wshrpc.RpcOpts) (*wshrpc.CommandPublishAppRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandPublishAppRtnData](w, \"publishapp\", data, opts)\n\treturn resp, err\n}\n\n// command \"readappfile\", wshserver.ReadAppFileCommand\nfunc ReadAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandReadAppFileData, opts *wshrpc.RpcOpts) (*wshrpc.CommandReadAppFileRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandReadAppFileRtnData](w, \"readappfile\", data, opts)\n\treturn resp, err\n}\n\n// command \"recordtevent\", wshserver.RecordTEventCommand\nfunc RecordTEventCommand(w *wshutil.WshRpc, data telemetrydata.TEvent, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"recordtevent\", data, opts)\n\treturn err\n}\n\n// command \"remotedisconnectfromjobmanager\", wshserver.RemoteDisconnectFromJobManagerCommand\nfunc RemoteDisconnectFromJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteDisconnectFromJobManagerData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"remotedisconnectfromjobmanager\", data, opts)\n\treturn err\n}\n\n// command \"remotefilecopy\", wshserver.RemoteFileCopyCommand\nfunc RemoteFileCopyCommand(w *wshutil.WshRpc, data wshrpc.CommandFileCopyData, opts *wshrpc.RpcOpts) (bool, error) {\n\tresp, err := sendRpcRequestCallHelper[bool](w, \"remotefilecopy\", data, opts)\n\treturn resp, err\n}\n\n// command \"remotefiledelete\", wshserver.RemoteFileDeleteCommand\nfunc RemoteFileDeleteCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteFileData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"remotefiledelete\", data, opts)\n\treturn err\n}\n\n// command \"remotefileinfo\", wshserver.RemoteFileInfoCommand\nfunc RemoteFileInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, \"remotefileinfo\", data, opts)\n\treturn resp, err\n}\n\n// command \"remotefilejoin\", wshserver.RemoteFileJoinCommand\nfunc RemoteFileJoinCommand(w *wshutil.WshRpc, data []string, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, \"remotefilejoin\", data, opts)\n\treturn resp, err\n}\n\n// command \"remotefilemove\", wshserver.RemoteFileMoveCommand\nfunc RemoteFileMoveCommand(w *wshutil.WshRpc, data wshrpc.CommandFileCopyData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"remotefilemove\", data, opts)\n\treturn err\n}\n\n// command \"remotefilemultiinfo\", wshserver.RemoteFileMultiInfoCommand\nfunc RemoteFileMultiInfoCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteFileMultiInfoData, opts *wshrpc.RpcOpts) (map[string]wshrpc.FileInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[map[string]wshrpc.FileInfo](w, \"remotefilemultiinfo\", data, opts)\n\treturn resp, err\n}\n\n// command \"remotefilestream\", wshserver.RemoteFileStreamCommand\nfunc RemoteFileStreamCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteFileStreamData, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, \"remotefilestream\", data, opts)\n\treturn resp, err\n}\n\n// command \"remotefiletouch\", wshserver.RemoteFileTouchCommand\nfunc RemoteFileTouchCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"remotefiletouch\", data, opts)\n\treturn err\n}\n\n// command \"remotegetinfo\", wshserver.RemoteGetInfoCommand\nfunc RemoteGetInfoCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wshrpc.RemoteInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[wshrpc.RemoteInfo](w, \"remotegetinfo\", nil, opts)\n\treturn resp, err\n}\n\n// command \"remoteinstallrcfiles\", wshserver.RemoteInstallRcFilesCommand\nfunc RemoteInstallRcFilesCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"remoteinstallrcfiles\", nil, opts)\n\treturn err\n}\n\n// command \"remotelistentries\", wshserver.RemoteListEntriesCommand\nfunc RemoteListEntriesCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteListEntriesData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] {\n\treturn sendRpcRequestResponseStreamHelper[wshrpc.CommandRemoteListEntriesRtnData](w, \"remotelistentries\", data, opts)\n}\n\n// command \"remotemkdir\", wshserver.RemoteMkdirCommand\nfunc RemoteMkdirCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"remotemkdir\", data, opts)\n\treturn err\n}\n\n// command \"remotereconnecttojobmanager\", wshserver.RemoteReconnectToJobManagerCommand\nfunc RemoteReconnectToJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteReconnectToJobManagerData, opts *wshrpc.RpcOpts) (*wshrpc.CommandRemoteReconnectToJobManagerRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandRemoteReconnectToJobManagerRtnData](w, \"remotereconnecttojobmanager\", data, opts)\n\treturn resp, err\n}\n\n// command \"remotestartjob\", wshserver.RemoteStartJobCommand\nfunc RemoteStartJobCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteStartJobData, opts *wshrpc.RpcOpts) (*wshrpc.CommandStartJobRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandStartJobRtnData](w, \"remotestartjob\", data, opts)\n\treturn resp, err\n}\n\n// command \"remotestreamcpudata\", wshserver.RemoteStreamCpuDataCommand\nfunc RemoteStreamCpuDataCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.TimeSeriesData] {\n\treturn sendRpcRequestResponseStreamHelper[wshrpc.TimeSeriesData](w, \"remotestreamcpudata\", nil, opts)\n}\n\n// command \"remotestreamfile\", wshserver.RemoteStreamFileCommand\nfunc RemoteStreamFileCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteStreamFileData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.FileData] {\n\treturn sendRpcRequestResponseStreamHelper[wshrpc.FileData](w, \"remotestreamfile\", data, opts)\n}\n\n// command \"remoteterminatejobmanager\", wshserver.RemoteTerminateJobManagerCommand\nfunc RemoteTerminateJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteTerminateJobManagerData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"remoteterminatejobmanager\", data, opts)\n\treturn err\n}\n\n// command \"remotewritefile\", wshserver.RemoteWriteFileCommand\nfunc RemoteWriteFileCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"remotewritefile\", data, opts)\n\treturn err\n}\n\n// command \"renameappfile\", wshserver.RenameAppFileCommand\nfunc RenameAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandRenameAppFileData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"renameappfile\", data, opts)\n\treturn err\n}\n\n// command \"resolveids\", wshserver.ResolveIdsCommand\nfunc ResolveIdsCommand(w *wshutil.WshRpc, data wshrpc.CommandResolveIdsData, opts *wshrpc.RpcOpts) (wshrpc.CommandResolveIdsRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[wshrpc.CommandResolveIdsRtnData](w, \"resolveids\", data, opts)\n\treturn resp, err\n}\n\n// command \"restartbuilderandwait\", wshserver.RestartBuilderAndWaitCommand\nfunc RestartBuilderAndWaitCommand(w *wshutil.WshRpc, data wshrpc.CommandRestartBuilderAndWaitData, opts *wshrpc.RpcOpts) (*wshrpc.RestartBuilderAndWaitResult, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.RestartBuilderAndWaitResult](w, \"restartbuilderandwait\", data, opts)\n\treturn resp, err\n}\n\n// command \"routeannounce\", wshserver.RouteAnnounceCommand\nfunc RouteAnnounceCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"routeannounce\", nil, opts)\n\treturn err\n}\n\n// command \"routeunannounce\", wshserver.RouteUnannounceCommand\nfunc RouteUnannounceCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"routeunannounce\", nil, opts)\n\treturn err\n}\n\n// command \"sendtelemetry\", wshserver.SendTelemetryCommand\nfunc SendTelemetryCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"sendtelemetry\", nil, opts)\n\treturn err\n}\n\n// command \"setblockfocus\", wshserver.SetBlockFocusCommand\nfunc SetBlockFocusCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"setblockfocus\", data, opts)\n\treturn err\n}\n\n// command \"setconfig\", wshserver.SetConfigCommand\nfunc SetConfigCommand(w *wshutil.WshRpc, data wshrpc.MetaSettingsType, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"setconfig\", data, opts)\n\treturn err\n}\n\n// command \"setconnectionsconfig\", wshserver.SetConnectionsConfigCommand\nfunc SetConnectionsConfigCommand(w *wshutil.WshRpc, data wshrpc.ConnConfigRequest, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"setconnectionsconfig\", data, opts)\n\treturn err\n}\n\n// command \"setmeta\", wshserver.SetMetaCommand\nfunc SetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandSetMetaData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"setmeta\", data, opts)\n\treturn err\n}\n\n// command \"setpeerinfo\", wshserver.SetPeerInfoCommand\nfunc SetPeerInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"setpeerinfo\", data, opts)\n\treturn err\n}\n\n// command \"setrtinfo\", wshserver.SetRTInfoCommand\nfunc SetRTInfoCommand(w *wshutil.WshRpc, data wshrpc.CommandSetRTInfoData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"setrtinfo\", data, opts)\n\treturn err\n}\n\n// command \"setsecrets\", wshserver.SetSecretsCommand\nfunc SetSecretsCommand(w *wshutil.WshRpc, data map[string]*string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"setsecrets\", data, opts)\n\treturn err\n}\n\n// command \"setvar\", wshserver.SetVarCommand\nfunc SetVarCommand(w *wshutil.WshRpc, data wshrpc.CommandVarData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"setvar\", data, opts)\n\treturn err\n}\n\n// command \"startbuilder\", wshserver.StartBuilderCommand\nfunc StartBuilderCommand(w *wshutil.WshRpc, data wshrpc.CommandStartBuilderData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"startbuilder\", data, opts)\n\treturn err\n}\n\n// command \"startjob\", wshserver.StartJobCommand\nfunc StartJobCommand(w *wshutil.WshRpc, data wshrpc.CommandStartJobData, opts *wshrpc.RpcOpts) (*wshrpc.CommandStartJobRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandStartJobRtnData](w, \"startjob\", data, opts)\n\treturn resp, err\n}\n\n// command \"stopbuilder\", wshserver.StopBuilderCommand\nfunc StopBuilderCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"stopbuilder\", data, opts)\n\treturn err\n}\n\n// command \"streamcpudata\", wshserver.StreamCpuDataCommand\nfunc StreamCpuDataCommand(w *wshutil.WshRpc, data wshrpc.CpuDataRequest, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.TimeSeriesData] {\n\treturn sendRpcRequestResponseStreamHelper[wshrpc.TimeSeriesData](w, \"streamcpudata\", data, opts)\n}\n\n// command \"streamdata\", wshserver.StreamDataCommand\nfunc StreamDataCommand(w *wshutil.WshRpc, data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"streamdata\", data, opts)\n\treturn err\n}\n\n// command \"streamdataack\", wshserver.StreamDataAckCommand\nfunc StreamDataAckCommand(w *wshutil.WshRpc, data wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"streamdataack\", data, opts)\n\treturn err\n}\n\n// command \"streamtest\", wshserver.StreamTestCommand\nfunc StreamTestCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[int] {\n\treturn sendRpcRequestResponseStreamHelper[int](w, \"streamtest\", nil, opts)\n}\n\n// command \"streamwaveai\", wshserver.StreamWaveAiCommand\nfunc StreamWaveAiCommand(w *wshutil.WshRpc, data wshrpc.WaveAIStreamRequest, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] {\n\treturn sendRpcRequestResponseStreamHelper[wshrpc.WaveAIPacketType](w, \"streamwaveai\", data, opts)\n}\n\n// command \"termgetscrollbacklines\", wshserver.TermGetScrollbackLinesCommand\nfunc TermGetScrollbackLinesCommand(w *wshutil.WshRpc, data wshrpc.CommandTermGetScrollbackLinesData, opts *wshrpc.RpcOpts) (*wshrpc.CommandTermGetScrollbackLinesRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandTermGetScrollbackLinesRtnData](w, \"termgetscrollbacklines\", data, opts)\n\treturn resp, err\n}\n\n// command \"test\", wshserver.TestCommand\nfunc TestCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"test\", data, opts)\n\treturn err\n}\n\n// command \"testmultiarg\", wshserver.TestMultiArgCommand\nfunc TestMultiArgCommand(w *wshutil.WshRpc, arg1 string, arg2 int, arg3 bool, opts *wshrpc.RpcOpts) (string, error) {\n\tresp, err := sendRpcRequestCallHelper[string](w, \"testmultiarg\", wshrpc.MultiArg{Args: []any{arg1, arg2, arg3}}, opts)\n\treturn resp, err\n}\n\n// command \"updatetabname\", wshserver.UpdateTabNameCommand\nfunc UpdateTabNameCommand(w *wshutil.WshRpc, arg1 string, arg2 string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"updatetabname\", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts)\n\treturn err\n}\n\n// command \"updateworkspacetabids\", wshserver.UpdateWorkspaceTabIdsCommand\nfunc UpdateWorkspaceTabIdsCommand(w *wshutil.WshRpc, arg1 string, arg2 []string, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"updateworkspacetabids\", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts)\n\treturn err\n}\n\n// command \"vdomasyncinitiation\", wshserver.VDomAsyncInitiationCommand\nfunc VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiationRequest, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"vdomasyncinitiation\", data, opts)\n\treturn err\n}\n\n// command \"vdomcreatecontext\", wshserver.VDomCreateContextCommand\nfunc VDomCreateContextCommand(w *wshutil.WshRpc, data vdom.VDomCreateContext, opts *wshrpc.RpcOpts) (*waveobj.ORef, error) {\n\tresp, err := sendRpcRequestCallHelper[*waveobj.ORef](w, \"vdomcreatecontext\", data, opts)\n\treturn resp, err\n}\n\n// command \"vdomrender\", wshserver.VDomRenderCommand\nfunc VDomRenderCommand(w *wshutil.WshRpc, data vdom.VDomFrontendUpdate, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate] {\n\treturn sendRpcRequestResponseStreamHelper[*vdom.VDomBackendUpdate](w, \"vdomrender\", data, opts)\n}\n\n// command \"vdomurlrequest\", wshserver.VDomUrlRequestCommand\nfunc VDomUrlRequestCommand(w *wshutil.WshRpc, data wshrpc.VDomUrlRequestData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse] {\n\treturn sendRpcRequestResponseStreamHelper[wshrpc.VDomUrlRequestResponse](w, \"vdomurlrequest\", data, opts)\n}\n\n// command \"waitforroute\", wshserver.WaitForRouteCommand\nfunc WaitForRouteCommand(w *wshutil.WshRpc, data wshrpc.CommandWaitForRouteData, opts *wshrpc.RpcOpts) (bool, error) {\n\tresp, err := sendRpcRequestCallHelper[bool](w, \"waitforroute\", data, opts)\n\treturn resp, err\n}\n\n// command \"waveaiaddcontext\", wshserver.WaveAIAddContextCommand\nfunc WaveAIAddContextCommand(w *wshutil.WshRpc, data wshrpc.CommandWaveAIAddContextData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"waveaiaddcontext\", data, opts)\n\treturn err\n}\n\n// command \"waveaienabletelemetry\", wshserver.WaveAIEnableTelemetryCommand\nfunc WaveAIEnableTelemetryCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"waveaienabletelemetry\", nil, opts)\n\treturn err\n}\n\n// command \"waveaigettooldiff\", wshserver.WaveAIGetToolDiffCommand\nfunc WaveAIGetToolDiffCommand(w *wshutil.WshRpc, data wshrpc.CommandWaveAIGetToolDiffData, opts *wshrpc.RpcOpts) (*wshrpc.CommandWaveAIGetToolDiffRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandWaveAIGetToolDiffRtnData](w, \"waveaigettooldiff\", data, opts)\n\treturn resp, err\n}\n\n// command \"waveaitoolapprove\", wshserver.WaveAIToolApproveCommand\nfunc WaveAIToolApproveCommand(w *wshutil.WshRpc, data wshrpc.CommandWaveAIToolApproveData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"waveaitoolapprove\", data, opts)\n\treturn err\n}\n\n// command \"wavefilereadstream\", wshserver.WaveFileReadStreamCommand\nfunc WaveFileReadStreamCommand(w *wshutil.WshRpc, data wshrpc.CommandWaveFileReadStreamData, opts *wshrpc.RpcOpts) (*wshrpc.WaveFileInfo, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.WaveFileInfo](w, \"wavefilereadstream\", data, opts)\n\treturn resp, err\n}\n\n// command \"waveinfo\", wshserver.WaveInfoCommand\nfunc WaveInfoCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (*wshrpc.WaveInfoData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.WaveInfoData](w, \"waveinfo\", nil, opts)\n\treturn resp, err\n}\n\n// command \"webselector\", wshserver.WebSelectorCommand\nfunc WebSelectorCommand(w *wshutil.WshRpc, data wshrpc.CommandWebSelectorData, opts *wshrpc.RpcOpts) ([]string, error) {\n\tresp, err := sendRpcRequestCallHelper[[]string](w, \"webselector\", data, opts)\n\treturn resp, err\n}\n\n// command \"workspacelist\", wshserver.WorkspaceListCommand\nfunc WorkspaceListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.WorkspaceInfoData, error) {\n\tresp, err := sendRpcRequestCallHelper[[]wshrpc.WorkspaceInfoData](w, \"workspacelist\", nil, opts)\n\treturn resp, err\n}\n\n// command \"writeappfile\", wshserver.WriteAppFileCommand\nfunc WriteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppFileData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"writeappfile\", data, opts)\n\treturn err\n}\n\n// command \"writeappgofile\", wshserver.WriteAppGoFileCommand\nfunc WriteAppGoFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppGoFileData, opts *wshrpc.RpcOpts) (*wshrpc.CommandWriteAppGoFileRtnData, error) {\n\tresp, err := sendRpcRequestCallHelper[*wshrpc.CommandWriteAppGoFileRtnData](w, \"writeappgofile\", data, opts)\n\treturn resp, err\n}\n\n// command \"writeappsecretbindings\", wshserver.WriteAppSecretBindingsCommand\nfunc WriteAppSecretBindingsCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppSecretBindingsData, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"writeappsecretbindings\", data, opts)\n\treturn err\n}\n\n// command \"writetempfile\", wshserver.WriteTempFileCommand\nfunc WriteTempFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteTempFileData, opts *wshrpc.RpcOpts) (string, error) {\n\tresp, err := sendRpcRequestCallHelper[string](w, \"writetempfile\", data, opts)\n\treturn resp, err\n}\n\n// command \"wshactivity\", wshserver.WshActivityCommand\nfunc WshActivityCommand(w *wshutil.WshRpc, data map[string]int, opts *wshrpc.RpcOpts) error {\n\t_, err := sendRpcRequestCallHelper[any](w, \"wshactivity\", data, opts)\n\treturn err\n}\n\n// command \"wsldefaultdistro\", wshserver.WslDefaultDistroCommand\nfunc WslDefaultDistroCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) {\n\tresp, err := sendRpcRequestCallHelper[string](w, \"wsldefaultdistro\", nil, opts)\n\treturn resp, err\n}\n\n// command \"wsllist\", wshserver.WslListCommand\nfunc WslListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) {\n\tresp, err := sendRpcRequestCallHelper[[]string](w, \"wsllist\", nil, opts)\n\treturn resp, err\n}\n\n// command \"wslstatus\", wshserver.WslStatusCommand\nfunc WslStatusCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.ConnStatus, error) {\n\tresp, err := sendRpcRequestCallHelper[[]wshrpc.ConnStatus](w, \"wslstatus\", nil, opts)\n\treturn resp, err\n}\n\n\n"
  },
  {
    "path": "pkg/wshrpc/wshclient/wshclientutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshclient\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nfunc sendRpcRequestCallHelper[T any](w *wshutil.WshRpc, command string, data interface{}, opts *wshrpc.RpcOpts) (T, error) {\n\tif opts == nil {\n\t\topts = &wshrpc.RpcOpts{}\n\t}\n\tvar respData T\n\tif w == nil {\n\t\treturn respData, errors.New(\"nil wshrpc passed to wshclient\")\n\t}\n\tif opts.NoResponse {\n\t\terr := w.SendCommand(command, data, opts)\n\t\tif err != nil {\n\t\t\treturn respData, err\n\t\t}\n\t\treturn respData, nil\n\t}\n\tresp, err := w.SendRpcRequest(command, data, opts)\n\tif err != nil {\n\t\treturn respData, err\n\t}\n\terr = utilfn.ReUnmarshal(&respData, resp)\n\tif err != nil {\n\t\treturn respData, err\n\t}\n\treturn respData, nil\n}\n\nfunc rtnErr[T any](ch chan wshrpc.RespOrErrorUnion[T], err error) {\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"wshclientutil:rtnErr\", recover())\n\t\t}()\n\t\tch <- wshrpc.RespOrErrorUnion[T]{Error: err}\n\t\tclose(ch)\n\t}()\n}\n\nfunc sendRpcRequestResponseStreamHelper[T any](w *wshutil.WshRpc, command string, data interface{}, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[T] {\n\tif opts == nil {\n\t\topts = &wshrpc.RpcOpts{}\n\t}\n\trespChan := make(chan wshrpc.RespOrErrorUnion[T], 32)\n\tif w == nil {\n\t\trtnErr(respChan, errors.New(\"nil wshrpc passed to wshclient\"))\n\t\treturn respChan\n\t}\n\treqHandler, err := w.SendComplexRequest(command, data, opts)\n\tif err != nil {\n\t\trtnErr(respChan, err)\n\t\treturn respChan\n\t}\n\topts.StreamCancelFn = func(ctx context.Context) error {\n\t\treturn reqHandler.SendCancel(ctx)\n\t}\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"sendRpcRequestResponseStreamHelper\", recover())\n\t\t}()\n\t\tdefer close(respChan)\n\t\tfor {\n\t\t\tif reqHandler.ResponseDone() {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tresp, err := reqHandler.NextResponse()\n\t\t\tif err != nil {\n\t\t\t\trespChan <- wshrpc.RespOrErrorUnion[T]{Error: err}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tvar respData T\n\t\t\terr = utilfn.ReUnmarshal(&respData, resp)\n\t\t\tif err != nil {\n\t\t\t\trespChan <- wshrpc.RespOrErrorUnion[T]{Error: err}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\trespChan <- wshrpc.RespOrErrorUnion[T]{Response: respData}\n\t\t}\n\t}()\n\treturn respChan\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshremote/sysinfo.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshremote\n\nimport (\n\t\"log\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/shirou/gopsutil/v4/cpu\"\n\t\"github.com/shirou/gopsutil/v4/mem\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nconst BYTES_PER_GB = 1073741824\n\nfunc getCpuData(values map[string]float64) {\n\tpercentArr, err := cpu.Percent(0, false)\n\tif err != nil {\n\t\treturn\n\t}\n\tif len(percentArr) > 0 {\n\t\tvalues[wshrpc.TimeSeries_Cpu] = percentArr[0]\n\t}\n\tpercentArr, err = cpu.Percent(0, true)\n\tif err != nil {\n\t\treturn\n\t}\n\tfor idx, percent := range percentArr {\n\t\tvalues[wshrpc.TimeSeries_Cpu+\":\"+strconv.Itoa(idx)] = percent\n\t}\n}\n\nfunc getMemData(values map[string]float64) {\n\tmemData, err := mem.VirtualMemory()\n\tif err != nil {\n\t\treturn\n\t}\n\tvalues[\"mem:total\"] = float64(memData.Total) / BYTES_PER_GB\n\tvalues[\"mem:available\"] = float64(memData.Available) / BYTES_PER_GB\n\tvalues[\"mem:used\"] = float64(memData.Used) / BYTES_PER_GB\n\tvalues[\"mem:free\"] = float64(memData.Free) / BYTES_PER_GB\n}\n\nfunc generateSingleServerData(client *wshutil.WshRpc, connName string) {\n\tnow := time.Now()\n\tvalues := make(map[string]float64)\n\tgetCpuData(values)\n\tgetMemData(values)\n\ttsData := wshrpc.TimeSeriesData{Ts: now.UnixMilli(), Values: values}\n\tevent := wps.WaveEvent{\n\t\tEvent:   wps.Event_SysInfo,\n\t\tScopes:  []string{connName},\n\t\tData:    tsData,\n\t\tPersist: 1024,\n\t}\n\twshclient.EventPublishCommand(client, event, &wshrpc.RpcOpts{NoResponse: true})\n}\n\nfunc RunSysInfoLoop(client *wshutil.WshRpc, connName string) {\n\tdefer func() {\n\t\tlog.Printf(\"sysinfo loop ended conn:%s\\n\", connName)\n\t}()\n\tfor {\n\t\tgenerateSingleServerData(client, connName)\n\t\ttime.Sleep(1 * time.Second)\n\t}\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshremote/wshremote.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshremote\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/suggestion\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/unixutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\ntype JobManagerConnection struct {\n\tJobId     string\n\tConn      net.Conn\n\tWshRpc    *wshutil.WshRpc\n\tCleanupFn func()\n}\n\ntype ServerImpl struct {\n\tLogWriter     io.Writer\n\tRouter        *wshutil.WshRouter\n\tRpcClient     *wshutil.WshRpc\n\tIsLocal       bool\n\tInitialEnv    map[string]string\n\tJobManagerMap map[string]*JobManagerConnection\n\tSockName      string\n\tLock          sync.Mutex\n}\n\nfunc MakeRemoteRpcServerImpl(logWriter io.Writer, router *wshutil.WshRouter, rpcClient *wshutil.WshRpc, isLocal bool, initialEnv map[string]string, sockName string) *ServerImpl {\n\treturn &ServerImpl{\n\t\tLogWriter:     logWriter,\n\t\tRouter:        router,\n\t\tRpcClient:     rpcClient,\n\t\tIsLocal:       isLocal,\n\t\tInitialEnv:    initialEnv,\n\t\tJobManagerMap: make(map[string]*JobManagerConnection),\n\t\tSockName:      sockName,\n\t}\n}\n\nfunc (*ServerImpl) WshServerImpl() {}\n\nfunc (impl *ServerImpl) Log(format string, args ...interface{}) {\n\tif impl.LogWriter != nil {\n\t\tfmt.Fprintf(impl.LogWriter, format, args...)\n\t} else {\n\t\tlog.Printf(format, args...)\n\t}\n}\n\nfunc (impl *ServerImpl) MessageCommand(ctx context.Context, data wshrpc.CommandMessageData) error {\n\timpl.Log(\"[message] %q\\n\", data.Message)\n\treturn nil\n}\n\nfunc (impl *ServerImpl) StreamTestCommand(ctx context.Context) chan wshrpc.RespOrErrorUnion[int] {\n\tch := make(chan wshrpc.RespOrErrorUnion[int], 16)\n\tgo func() {\n\t\tdefer close(ch)\n\t\tidx := 0\n\t\tfor {\n\t\t\tch <- wshrpc.RespOrErrorUnion[int]{Response: idx}\n\t\t\tidx++\n\t\t\tif idx == 1000 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}()\n\treturn ch\n}\n\nfunc (*ServerImpl) RemoteGetInfoCommand(ctx context.Context) (wshrpc.RemoteInfo, error) {\n\treturn wshutil.GetInfo(), nil\n}\n\nfunc (*ServerImpl) RemoteInstallRcFilesCommand(ctx context.Context) error {\n\treturn wshutil.InstallRcFiles()\n}\n\nfunc (*ServerImpl) FetchSuggestionsCommand(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) {\n\treturn suggestion.FetchSuggestions(ctx, data)\n}\n\nfunc (*ServerImpl) DisposeSuggestionsCommand(ctx context.Context, widgetId string) error {\n\tsuggestion.DisposeSuggestions(ctx, widgetId)\n\treturn nil\n}\n\nfunc (impl *ServerImpl) ConnServerInitCommand(ctx context.Context, data wshrpc.CommandConnServerInitData) error {\n\tif data.ClientId == \"\" {\n\t\treturn fmt.Errorf(\"clientid is required\")\n\t}\n\tif impl.SockName == \"\" {\n\t\treturn fmt.Errorf(\"sockname not set in server impl\")\n\t}\n\tsymlinkPath, err := wavebase.ExpandHomeDir(wavebase.GetPersistentRemoteSockName(data.ClientId))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot expand symlink path: %w\", err)\n\t}\n\tsymlinkDir := filepath.Dir(symlinkPath)\n\n\tif err := os.MkdirAll(symlinkDir, 0700); err != nil {\n\t\treturn fmt.Errorf(\"could not create client directory %s: %w\", symlinkDir, err)\n\t}\n\tos.Remove(symlinkPath)\n\tif err := os.Symlink(impl.SockName, symlinkPath); err != nil {\n\t\treturn fmt.Errorf(\"could not create symlink %s -> %s: %w\", symlinkPath, impl.SockName, err)\n\t}\n\timpl.Log(\"created symlink %s -> %s\\n\", symlinkPath, impl.SockName)\n\treturn nil\n}\n\nfunc (impl *ServerImpl) getWshPath() (string, error) {\n\tif impl.IsLocal {\n\t\treturn filepath.Join(wavebase.GetWaveDataDir(), \"bin\", \"wsh\"), nil\n\t}\n\twshPath, err := wavebase.ExpandHomeDir(\"~/.waveterm/bin/wsh\")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"cannot expand wsh path: %w\", err)\n\t}\n\treturn wshPath, nil\n}\n\nfunc (impl *ServerImpl) BadgeWatchPidCommand(ctx context.Context, data wshrpc.CommandBadgeWatchPidData) error {\n\tif data.Pid <= 0 {\n\t\treturn fmt.Errorf(\"invalid pid: %d\", data.Pid)\n\t}\n\tif data.ORef.IsEmpty() {\n\t\treturn fmt.Errorf(\"oref is required\")\n\t}\n\tif data.BadgeId == \"\" {\n\t\treturn fmt.Errorf(\"badgeid is required\")\n\t}\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"BadgeWatchPidCommand\", recover())\n\t\t}()\n\t\tfor {\n\t\t\ttime.Sleep(time.Second)\n\t\t\tif unixutil.IsPidRunning(data.Pid) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\torefStr := data.ORef.String()\n\t\t\tevent := wps.WaveEvent{\n\t\t\t\tEvent:  wps.Event_Badge,\n\t\t\t\tScopes: []string{orefStr},\n\t\t\t\tData: baseds.BadgeEvent{\n\t\t\t\t\tORef:      orefStr,\n\t\t\t\t\tClearById: data.BadgeId,\n\t\t\t\t},\n\t\t\t}\n\t\t\twshclient.EventPublishCommand(impl.RpcClient, event, nil)\n\t\t\tlog.Printf(\"BadgeWatchPidCommand: pid %d gone, cleared badge %s for oref %s\\n\", data.Pid, data.BadgeId, orefStr)\n\t\t\treturn\n\t\t}\n\t}()\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshremote/wshremote_file.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshremote\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/connparse\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/fileshare/fsutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/fileutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nconst RemoteFileTransferSizeLimit = 32 * 1024 * 1024\n\nvar DisableRecursiveFileOpts = true\n\n\nfunc (impl *ServerImpl) remoteStreamFileDir(ctx context.Context, path string, byteRange fileutil.ByteRangeType, dataCallback func(fileInfo []*wshrpc.FileInfo, data []byte, byteRange fileutil.ByteRangeType)) error {\n\tinnerFilesEntries, err := os.ReadDir(path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot open dir %q: %w\", path, err)\n\t}\n\tif byteRange.All {\n\t\tif len(innerFilesEntries) > wshrpc.MaxDirSize {\n\t\t\tinnerFilesEntries = innerFilesEntries[:wshrpc.MaxDirSize]\n\t\t}\n\t} else {\n\t\tif byteRange.Start < int64(len(innerFilesEntries)) {\n\t\t\tvar realEnd int64\n\t\t\tif byteRange.OpenEnd {\n\t\t\t\trealEnd = int64(len(innerFilesEntries))\n\t\t\t} else {\n\t\t\t\trealEnd = byteRange.End + 1\n\t\t\t\tif realEnd > int64(len(innerFilesEntries)) {\n\t\t\t\t\trealEnd = int64(len(innerFilesEntries))\n\t\t\t\t}\n\t\t\t}\n\t\t\tinnerFilesEntries = innerFilesEntries[byteRange.Start:realEnd]\n\t\t} else {\n\t\t\tinnerFilesEntries = []os.DirEntry{}\n\t\t}\n\t}\n\tvar fileInfoArr []*wshrpc.FileInfo\n\tfor _, innerFileEntry := range innerFilesEntries {\n\t\tif ctx.Err() != nil {\n\t\t\treturn ctx.Err()\n\t\t}\n\t\tinnerFileInfoInt, err := innerFileEntry.Info()\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tinnerFileInfo := statToFileInfo(filepath.Join(path, innerFileInfoInt.Name()), innerFileInfoInt, false)\n\t\tfileInfoArr = append(fileInfoArr, innerFileInfo)\n\t\tif len(fileInfoArr) >= wshrpc.DirChunkSize {\n\t\t\tdataCallback(fileInfoArr, nil, byteRange)\n\t\t\tfileInfoArr = nil\n\t\t}\n\t}\n\tif len(fileInfoArr) > 0 {\n\t\tdataCallback(fileInfoArr, nil, byteRange)\n\t}\n\treturn nil\n}\n\nfunc (impl *ServerImpl) remoteStreamFileRegular(ctx context.Context, path string, byteRange fileutil.ByteRangeType, dataCallback func(fileInfo []*wshrpc.FileInfo, data []byte, byteRange fileutil.ByteRangeType)) error {\n\tfd, err := os.Open(path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot open file %q: %w\", path, err)\n\t}\n\tdefer utilfn.GracefulClose(fd, \"remoteStreamFileRegular\", path)\n\tvar filePos int64\n\tif !byteRange.All && byteRange.Start > 0 {\n\t\t_, err := fd.Seek(byteRange.Start, io.SeekStart)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"seeking file %q: %w\", path, err)\n\t\t}\n\t\tfilePos = byteRange.Start\n\t}\n\tbuf := make([]byte, wshrpc.FileChunkSize)\n\tfor {\n\t\tif ctx.Err() != nil {\n\t\t\treturn ctx.Err()\n\t\t}\n\t\tn, err := fd.Read(buf)\n\t\tif n > 0 {\n\t\t\tif !byteRange.All && !byteRange.OpenEnd && filePos+int64(n) > byteRange.End+1 {\n\t\t\t\tn = int(byteRange.End + 1 - filePos)\n\t\t\t}\n\t\t\tfilePos += int64(n)\n\t\t\tdataCallback(nil, buf[:n], byteRange)\n\t\t}\n\t\tif !byteRange.All && !byteRange.OpenEnd && filePos >= byteRange.End+1 {\n\t\t\tbreak\n\t\t}\n\t\tif errors.Is(err, io.EOF) {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"reading file %q: %w\", path, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (impl *ServerImpl) remoteStreamFileInternal(ctx context.Context, data wshrpc.CommandRemoteStreamFileData, dataCallback func(fileInfo []*wshrpc.FileInfo, data []byte, byteRange fileutil.ByteRangeType)) error {\n\tbyteRange, err := fileutil.ParseByteRange(data.ByteRange)\n\tif err != nil {\n\t\treturn err\n\t}\n\tpath, err := wavebase.ExpandHomeDir(data.Path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfinfo, err := impl.fileInfoInternal(path, true)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot stat file %q: %w\", path, err)\n\t}\n\tdataCallback([]*wshrpc.FileInfo{finfo}, nil, byteRange)\n\tif finfo.NotFound {\n\t\treturn nil\n\t}\n\tif finfo.IsDir {\n\t\treturn impl.remoteStreamFileDir(ctx, path, byteRange, dataCallback)\n\t} else {\n\t\tif finfo.Size > RemoteFileTransferSizeLimit {\n\t\t\treturn fmt.Errorf(\"file %q size %d exceeds transfer limit of %d bytes\", path, finfo.Size, RemoteFileTransferSizeLimit)\n\t\t}\n\t\treturn impl.remoteStreamFileRegular(ctx, path, byteRange, dataCallback)\n\t}\n}\n\nfunc (impl *ServerImpl) RemoteStreamFileCommand(ctx context.Context, data wshrpc.CommandRemoteStreamFileData) chan wshrpc.RespOrErrorUnion[wshrpc.FileData] {\n\tch := make(chan wshrpc.RespOrErrorUnion[wshrpc.FileData], 16)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"RemoteStreamFileCommand\", recover())\n\t\t}()\n\t\tdefer close(ch)\n\t\tfirstPk := true\n\t\terr := impl.remoteStreamFileInternal(ctx, data, func(fileInfo []*wshrpc.FileInfo, data []byte, byteRange fileutil.ByteRangeType) {\n\t\t\tresp := wshrpc.FileData{}\n\t\t\tfileInfoLen := len(fileInfo)\n\t\t\tif fileInfoLen > 1 || !firstPk {\n\t\t\t\tresp.Entries = fileInfo\n\t\t\t} else if fileInfoLen == 1 {\n\t\t\t\tresp.Info = fileInfo[0]\n\t\t\t}\n\t\t\tif firstPk {\n\t\t\t\tfirstPk = false\n\t\t\t}\n\t\t\tif len(data) > 0 {\n\t\t\t\tresp.Data64 = base64.StdEncoding.EncodeToString(data)\n\t\t\t\tresp.At = &wshrpc.FileDataAt{Offset: byteRange.Start, Size: len(data)}\n\t\t\t}\n\t\t\tch <- wshrpc.RespOrErrorUnion[wshrpc.FileData]{Response: resp}\n\t\t})\n\t\tif err != nil {\n\t\t\tch <- wshutil.RespErr[wshrpc.FileData](err)\n\t\t}\n\t}()\n\treturn ch\n}\n\n// prepareDestForCopy resolves the final destination path and handles overwrite logic.\n// destPath is the raw destination path (may be a directory or file path).\n// srcBaseName is the basename of the source file (used when dest is a directory or ends with slash).\n// destHasSlash indicates if the original URI ended with a slash (forcing directory interpretation).\n// Returns the resolved path ready for writing.\nfunc prepareDestForCopy(destPath string, srcBaseName string, destHasSlash bool, overwrite bool) (string, error) {\n\tdestInfo, err := os.Stat(destPath)\n\tif err != nil && !errors.Is(err, fs.ErrNotExist) {\n\t\treturn \"\", fmt.Errorf(\"cannot stat destination %q: %w\", destPath, err)\n\t}\n\n\tdestExists := destInfo != nil\n\tdestIsDir := destExists && destInfo.IsDir()\n\n\tvar finalPath string\n\tif destHasSlash || destIsDir {\n\t\tfinalPath = filepath.Join(destPath, srcBaseName)\n\t} else {\n\t\tfinalPath = destPath\n\t}\n\n\tfinalInfo, err := os.Stat(finalPath)\n\tif err != nil && !errors.Is(err, fs.ErrNotExist) {\n\t\treturn \"\", fmt.Errorf(\"cannot stat file %q: %w\", finalPath, err)\n\t}\n\n\tif finalInfo != nil {\n\t\tif !overwrite {\n\t\t\treturn \"\", fmt.Errorf(wshfs.OverwriteRequiredError, finalPath)\n\t\t}\n\t\tif err := os.Remove(finalPath); err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"cannot remove file %q: %w\", finalPath, err)\n\t\t}\n\t}\n\n\treturn finalPath, nil\n}\n\n// remoteCopyFileInternal copies FROM local (this host) TO local (this host)\n// Only supports copying files, not directories\nfunc remoteCopyFileInternal(srcUri, destUri string, srcPathCleaned, destPathCleaned string, destHasSlash bool, overwrite bool) error {\n\tsrcFileStat, err := os.Stat(srcPathCleaned)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot stat file %q: %w\", srcPathCleaned, err)\n\t}\n\tif srcFileStat.IsDir() {\n\t\treturn fmt.Errorf(\"copying directories is not supported\")\n\t}\n\tif srcFileStat.Size() > RemoteFileTransferSizeLimit {\n\t\treturn fmt.Errorf(\"file %q size %d exceeds transfer limit of %d bytes\", srcPathCleaned, srcFileStat.Size(), RemoteFileTransferSizeLimit)\n\t}\n\n\tdestFilePath, err := prepareDestForCopy(destPathCleaned, filepath.Base(srcPathCleaned), destHasSlash, overwrite)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsrcFile, err := os.Open(srcPathCleaned)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot open file %q: %w\", srcPathCleaned, err)\n\t}\n\tdefer srcFile.Close()\n\n\tdestFile, err := os.OpenFile(destFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcFileStat.Mode())\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot create file %q: %w\", destFilePath, err)\n\t}\n\tdefer destFile.Close()\n\n\tif _, err = io.Copy(destFile, srcFile); err != nil {\n\t\treturn fmt.Errorf(\"cannot copy %q to %q: %w\", srcUri, destUri, err)\n\t}\n\treturn nil\n}\n\n// RemoteFileCopyCommand copies a file FROM somewhere TO here\nfunc (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.CommandFileCopyData) (bool, error) {\n\tlog.Printf(\"RemoteFileCopyCommand: src=%s, dest=%s\\n\", data.SrcUri, data.DestUri)\n\topts := data.Opts\n\tif opts == nil {\n\t\topts = &wshrpc.FileCopyOpts{}\n\t}\n\tif opts.Overwrite && opts.Merge {\n\t\treturn false, fmt.Errorf(\"cannot specify both overwrite and merge\")\n\t}\n\tif opts.Recursive {\n\t\treturn false, fmt.Errorf(\"directory copying is not supported\")\n\t}\n\tsrcConn, err := connparse.ParseURIAndReplaceCurrentHost(ctx, data.SrcUri)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"cannot parse source URI %q: %w\", data.SrcUri, err)\n\t}\n\tdestConn, err := connparse.ParseURIAndReplaceCurrentHost(ctx, data.DestUri)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"cannot parse destination URI %q: %w\", data.DestUri, err)\n\t}\n\tdestPathCleaned := filepath.Clean(wavebase.ExpandHomeDirSafe(destConn.Path))\n\tdestHasSlash := strings.HasSuffix(data.DestUri, \"/\")\n\n\tif srcConn.Host == destConn.Host {\n\t\tsrcPathCleaned := filepath.Clean(wavebase.ExpandHomeDirSafe(srcConn.Path))\n\t\terr := remoteCopyFileInternal(data.SrcUri, data.DestUri, srcPathCleaned, destPathCleaned, destHasSlash, opts.Overwrite)\n\t\treturn false, err\n\t}\n\n\t// FROM external TO here - only supports single file copying\n\ttimeout := wshfs.DefaultTimeout\n\tif opts.Timeout > 0 {\n\t\ttimeout = time.Duration(opts.Timeout) * time.Millisecond\n\t}\n\treadCtx, timeoutCancel := context.WithTimeoutCause(ctx, timeout, fmt.Errorf(\"timeout copying file %q to %q\", data.SrcUri, data.DestUri))\n\tdefer timeoutCancel()\n\tcopyStart := time.Now()\n\n\tsrcFileInfo, err := wshclient.RemoteFileInfoCommand(wshfs.RpcClient, srcConn.Path, &wshrpc.RpcOpts{Timeout: opts.Timeout, Route: wshutil.MakeConnectionRouteId(srcConn.Host)})\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"cannot get info for source file %q: %w\", data.SrcUri, err)\n\t}\n\tif srcFileInfo.IsDir {\n\t\treturn false, fmt.Errorf(\"copying directories is not supported\")\n\t}\n\tif srcFileInfo.Size > RemoteFileTransferSizeLimit {\n\t\treturn false, fmt.Errorf(\"file %q size %d exceeds transfer limit of %d bytes\", data.SrcUri, srcFileInfo.Size, RemoteFileTransferSizeLimit)\n\t}\n\n\tdestFilePath, err := prepareDestForCopy(destPathCleaned, filepath.Base(srcConn.Path), destHasSlash, opts.Overwrite)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tdestFile, err := os.OpenFile(destFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcFileInfo.Mode)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"cannot create destination file %q: %w\", destFilePath, err)\n\t}\n\tdefer destFile.Close()\n\n\tstreamChan := wshclient.RemoteStreamFileCommand(wshfs.RpcClient, wshrpc.CommandRemoteStreamFileData{Path: srcConn.Path}, &wshrpc.RpcOpts{Timeout: opts.Timeout, Route: wshutil.MakeConnectionRouteId(srcConn.Host)})\n\tif err = fsutil.ReadFileStreamToWriter(readCtx, streamChan, destFile); err != nil {\n\t\treturn false, fmt.Errorf(\"error copying file %q to %q: %w\", data.SrcUri, data.DestUri, err)\n\t}\n\n\ttotalTime := time.Since(copyStart).Seconds()\n\ttotalMegaBytes := float64(srcFileInfo.Size) / 1024 / 1024\n\trate := float64(0)\n\tif totalTime > 0 {\n\t\trate = totalMegaBytes / totalTime\n\t}\n\tlog.Printf(\"RemoteFileCopyCommand: done; 1 file copied in %.3fs, total of %.4f MB, %.2f MB/s\\n\", totalTime, totalMegaBytes, rate)\n\treturn false, nil\n}\n\nfunc (impl *ServerImpl) RemoteListEntriesCommand(ctx context.Context, data wshrpc.CommandRemoteListEntriesData) chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] {\n\tch := make(chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData], 16)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"RemoteListEntriesCommand\", recover())\n\t\t}()\n\t\tdefer close(ch)\n\t\tpath, err := wavebase.ExpandHomeDir(data.Path)\n\t\tif err != nil {\n\t\t\tch <- wshutil.RespErr[wshrpc.CommandRemoteListEntriesRtnData](err)\n\t\t\treturn\n\t\t}\n\t\tinnerFilesEntries := []os.DirEntry{}\n\t\tseen := 0\n\t\tif data.Opts.Limit == 0 {\n\t\t\tdata.Opts.Limit = wshrpc.MaxDirSize\n\t\t}\n\t\tif data.Opts.All {\n\t\t\tif DisableRecursiveFileOpts {\n\t\t\t\tch <- wshutil.RespErr[wshrpc.CommandRemoteListEntriesRtnData](fmt.Errorf(\"recursive directory listings are not supported\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfs.WalkDir(os.DirFS(path), \".\", func(path string, d fs.DirEntry, err error) error {\n\t\t\t\tdefer func() {\n\t\t\t\t\tseen++\n\t\t\t\t}()\n\t\t\t\tif seen < data.Opts.Offset {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\tif seen >= data.Opts.Offset+data.Opts.Limit {\n\t\t\t\t\treturn io.EOF\n\t\t\t\t}\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif d.IsDir() {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\tinnerFilesEntries = append(innerFilesEntries, d)\n\t\t\t\treturn nil\n\t\t\t})\n\t\t} else {\n\t\t\tinnerFilesEntries, err = os.ReadDir(path)\n\t\t\tif err != nil {\n\t\t\t\tch <- wshutil.RespErr[wshrpc.CommandRemoteListEntriesRtnData](fmt.Errorf(\"cannot open dir %q: %w\", path, err))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tvar fileInfoArr []*wshrpc.FileInfo\n\t\tfor _, innerFileEntry := range innerFilesEntries {\n\t\t\tif ctx.Err() != nil {\n\t\t\t\tch <- wshutil.RespErr[wshrpc.CommandRemoteListEntriesRtnData](ctx.Err())\n\t\t\t\treturn\n\t\t\t}\n\t\t\tinnerFileInfoInt, err := innerFileEntry.Info()\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"cannot stat file %q: %v\\n\", innerFileEntry.Name(), err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tinnerFileInfo := statToFileInfo(filepath.Join(path, innerFileInfoInt.Name()), innerFileInfoInt, false)\n\t\t\tfileInfoArr = append(fileInfoArr, innerFileInfo)\n\t\t\tif len(fileInfoArr) >= wshrpc.DirChunkSize {\n\t\t\t\tresp := wshrpc.CommandRemoteListEntriesRtnData{FileInfo: fileInfoArr}\n\t\t\t\tch <- wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData]{Response: resp}\n\t\t\t\tfileInfoArr = nil\n\t\t\t}\n\t\t}\n\t\tif len(fileInfoArr) > 0 {\n\t\t\tresp := wshrpc.CommandRemoteListEntriesRtnData{FileInfo: fileInfoArr}\n\t\t\tch <- wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData]{Response: resp}\n\t\t}\n\t}()\n\treturn ch\n}\n\nfunc statToFileInfo(fullPath string, finfo fs.FileInfo, extended bool) *wshrpc.FileInfo {\n\tmimeType := fileutil.DetectMimeType(fullPath, finfo, extended)\n\trtn := &wshrpc.FileInfo{\n\t\tPath:          wavebase.ReplaceHomeDir(fullPath),\n\t\tDir:           computeDirPart(fullPath),\n\t\tName:          finfo.Name(),\n\t\tSize:          finfo.Size(),\n\t\tMode:          finfo.Mode(),\n\t\tModeStr:       finfo.Mode().String(),\n\t\tModTime:       finfo.ModTime().UnixMilli(),\n\t\tIsDir:         finfo.IsDir(),\n\t\tMimeType:      mimeType,\n\t\tSupportsMkdir: true,\n\t}\n\tif finfo.IsDir() {\n\t\trtn.Size = -1\n\t}\n\treturn rtn\n}\n\n// fileInfo might be null\nfunc checkIsReadOnly(path string, fileInfo fs.FileInfo, exists bool) bool {\n\tif !exists || fileInfo.Mode().IsDir() {\n\t\tdirName := filepath.Dir(path)\n\t\trandHexStr, err := utilfn.RandomHexString(12)\n\t\tif err != nil {\n\t\t\t// we're not sure, just return false\n\t\t\treturn false\n\t\t}\n\t\ttmpFileName := filepath.Join(dirName, \"wsh-tmp-\"+randHexStr)\n\t\tfd, err := os.Create(tmpFileName)\n\t\tif err != nil {\n\t\t\treturn true\n\t\t}\n\t\tutilfn.GracefulClose(fd, \"checkIsReadOnly\", tmpFileName)\n\t\tos.Remove(tmpFileName)\n\t\treturn false\n\t}\n\t// try to open for writing, if this fails then it is read-only\n\tfile, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0666)\n\tif err != nil {\n\t\treturn true\n\t}\n\tutilfn.GracefulClose(file, \"checkIsReadOnly\", path)\n\treturn false\n}\n\nfunc computeDirPart(path string) string {\n\tpath = filepath.Clean(wavebase.ExpandHomeDirSafe(path))\n\tpath = filepath.ToSlash(path)\n\tif path == \"/\" {\n\t\treturn \"/\"\n\t}\n\treturn filepath.Dir(path)\n}\n\nfunc (*ServerImpl) fileInfoInternal(path string, extended bool) (*wshrpc.FileInfo, error) {\n\tcleanedPath := filepath.Clean(wavebase.ExpandHomeDirSafe(path))\n\tfinfo, err := os.Stat(cleanedPath)\n\tif os.IsNotExist(err) {\n\t\treturn &wshrpc.FileInfo{\n\t\t\tPath:          wavebase.ReplaceHomeDir(path),\n\t\t\tDir:           computeDirPart(path),\n\t\t\tNotFound:      true,\n\t\t\tReadOnly:      checkIsReadOnly(cleanedPath, finfo, false),\n\t\t\tSupportsMkdir: true,\n\t\t}, nil\n\t}\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"cannot stat file %q: %w\", path, err)\n\t}\n\trtn := statToFileInfo(cleanedPath, finfo, extended)\n\tif extended {\n\t\trtn.ReadOnly = checkIsReadOnly(cleanedPath, finfo, true)\n\t}\n\treturn rtn, nil\n}\n\nfunc resolvePaths(paths []string) string {\n\tif len(paths) == 0 {\n\t\treturn wavebase.ExpandHomeDirSafe(\"~\")\n\t}\n\trtnPath := wavebase.ExpandHomeDirSafe(paths[0])\n\tfor _, path := range paths[1:] {\n\t\tpath = wavebase.ExpandHomeDirSafe(path)\n\t\tif filepath.IsAbs(path) {\n\t\t\trtnPath = path\n\t\t\tcontinue\n\t\t}\n\t\trtnPath = filepath.Join(rtnPath, path)\n\t}\n\treturn rtnPath\n}\n\nfunc (impl *ServerImpl) RemoteFileJoinCommand(ctx context.Context, paths []string) (*wshrpc.FileInfo, error) {\n\trtnPath := resolvePaths(paths)\n\treturn impl.fileInfoInternal(rtnPath, true)\n}\n\nfunc (impl *ServerImpl) RemoteFileInfoCommand(ctx context.Context, path string) (*wshrpc.FileInfo, error) {\n\treturn impl.fileInfoInternal(path, true)\n}\n\nfunc (impl *ServerImpl) RemoteFileMultiInfoCommand(ctx context.Context, data wshrpc.CommandRemoteFileMultiInfoData) (map[string]wshrpc.FileInfo, error) {\n\tcwd := data.Cwd\n\tif cwd == \"\" {\n\t\tcwd = \"~\"\n\t}\n\tcwd = filepath.Clean(wavebase.ExpandHomeDirSafe(cwd))\n\trtn := make(map[string]wshrpc.FileInfo, len(data.Paths))\n\tfor _, path := range data.Paths {\n\t\tif _, found := rtn[path]; found {\n\t\t\tcontinue\n\t\t}\n\t\tif ctx.Err() != nil {\n\t\t\treturn nil, ctx.Err()\n\t\t}\n\t\tcleanedPath := wavebase.ExpandHomeDirSafe(path)\n\t\tif !filepath.IsAbs(cleanedPath) {\n\t\t\tcleanedPath = filepath.Join(cwd, cleanedPath)\n\t\t}\n\t\tfileInfo, err := impl.fileInfoInternal(cleanedPath, false)\n\t\tif err != nil {\n\t\t\trtn[path] = wshrpc.FileInfo{\n\t\t\t\tPath:          wavebase.ReplaceHomeDir(cleanedPath),\n\t\t\t\tDir:           computeDirPart(cleanedPath),\n\t\t\t\tName:          filepath.Base(cleanedPath),\n\t\t\t\tStatError:     err.Error(),\n\t\t\t\tSupportsMkdir: true,\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\trtn[path] = *fileInfo\n\t}\n\treturn rtn, nil\n}\n\nfunc (impl *ServerImpl) RemoteFileTouchCommand(ctx context.Context, path string) error {\n\tcleanedPath := filepath.Clean(wavebase.ExpandHomeDirSafe(path))\n\tif _, err := os.Stat(cleanedPath); err == nil {\n\t\treturn fmt.Errorf(\"file %q already exists\", path)\n\t}\n\tif err := os.MkdirAll(filepath.Dir(cleanedPath), 0755); err != nil {\n\t\treturn fmt.Errorf(\"cannot create directory %q: %w\", filepath.Dir(cleanedPath), err)\n\t}\n\tif err := os.WriteFile(cleanedPath, []byte{}, 0644); err != nil {\n\t\treturn fmt.Errorf(\"cannot create file %q: %w\", cleanedPath, err)\n\t}\n\treturn nil\n}\n\nfunc (impl *ServerImpl) RemoteFileMoveCommand(ctx context.Context, data wshrpc.CommandFileCopyData) error {\n\tdestUri := data.DestUri\n\tsrcUri := data.SrcUri\n\n\tdestConn, err := connparse.ParseURIAndReplaceCurrentHost(ctx, destUri)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot parse destination URI %q: %w\", srcUri, err)\n\t}\n\tdestPathCleaned := filepath.Clean(wavebase.ExpandHomeDirSafe(destConn.Path))\n\t_, err = os.Stat(destPathCleaned)\n\tif err == nil {\n\t\treturn fmt.Errorf(\"destination %q already exists\", destUri)\n\t} else if !errors.Is(err, fs.ErrNotExist) {\n\t\treturn fmt.Errorf(\"cannot stat destination %q: %w\", destUri, err)\n\t}\n\n\tsrcConn, err := connparse.ParseURIAndReplaceCurrentHost(ctx, srcUri)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot parse source URI %q: %w\", srcUri, err)\n\t}\n\n\tif srcConn.Host != destConn.Host {\n\t\treturn fmt.Errorf(\"cannot move file %q to %q: different hosts\", srcUri, destUri)\n\t}\n\n\tsrcPathCleaned := filepath.Clean(wavebase.ExpandHomeDirSafe(srcConn.Path))\n\terr = os.Rename(srcPathCleaned, destPathCleaned)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot move file %q to %q: %w\", srcPathCleaned, destPathCleaned, err)\n\t}\n\treturn nil\n}\n\nfunc (impl *ServerImpl) RemoteMkdirCommand(ctx context.Context, path string) error {\n\tcleanedPath := filepath.Clean(wavebase.ExpandHomeDirSafe(path))\n\tif stat, err := os.Stat(cleanedPath); err == nil {\n\t\tif stat.IsDir() {\n\t\t\treturn fmt.Errorf(\"directory %q already exists\", path)\n\t\t} else {\n\t\t\treturn fmt.Errorf(\"cannot create directory %q, file exists at path\", path)\n\t\t}\n\t}\n\tif err := os.MkdirAll(cleanedPath, 0755); err != nil {\n\t\treturn fmt.Errorf(\"cannot create directory %q: %w\", cleanedPath, err)\n\t}\n\treturn nil\n}\n\nfunc (*ServerImpl) RemoteWriteFileCommand(ctx context.Context, data wshrpc.FileData) error {\n\tvar truncate, append bool\n\tvar atOffset int64\n\tif data.Info != nil && data.Info.Opts != nil {\n\t\ttruncate = data.Info.Opts.Truncate\n\t\tappend = data.Info.Opts.Append\n\t}\n\tif data.At != nil {\n\t\tatOffset = data.At.Offset\n\t}\n\tif truncate && atOffset > 0 {\n\t\treturn fmt.Errorf(\"cannot specify non-zero offset with truncate option\")\n\t}\n\tif append && atOffset > 0 {\n\t\treturn fmt.Errorf(\"cannot specify non-zero offset with append option\")\n\t}\n\tpath, err := wavebase.ExpandHomeDir(data.Info.Path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcreateMode := os.FileMode(0644)\n\tif data.Info != nil && data.Info.Mode > 0 {\n\t\tcreateMode = data.Info.Mode\n\t}\n\tdataSize := base64.StdEncoding.DecodedLen(len(data.Data64))\n\tdataBytes := make([]byte, dataSize)\n\tn, err := base64.StdEncoding.Decode(dataBytes, []byte(data.Data64))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot decode base64 data: %w\", err)\n\t}\n\tfinfo, err := os.Stat(path)\n\tif err != nil && !errors.Is(err, fs.ErrNotExist) {\n\t\treturn fmt.Errorf(\"cannot stat file %q: %w\", path, err)\n\t}\n\tfileSize := int64(0)\n\tif finfo != nil {\n\t\tif finfo.IsDir() {\n\t\t\treturn fmt.Errorf(\"cannot use write file to overwrite a directory %q\", path)\n\t\t}\n\t\tfileSize = finfo.Size()\n\t}\n\tif atOffset > fileSize {\n\t\treturn fmt.Errorf(\"cannot write at offset %d, file size is %d\", atOffset, fileSize)\n\t}\n\topenFlags := os.O_CREATE | os.O_WRONLY\n\tif truncate {\n\t\topenFlags |= os.O_TRUNC\n\t}\n\tif append {\n\t\topenFlags |= os.O_APPEND\n\t}\n\n\tfile, err := os.OpenFile(path, openFlags, createMode)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot open file %q: %w\", path, err)\n\t}\n\tdefer utilfn.GracefulClose(file, \"RemoteWriteFileCommand\", path)\n\tif atOffset > 0 && !append {\n\t\tn, err = file.WriteAt(dataBytes[:n], atOffset)\n\t} else {\n\t\tn, err = file.Write(dataBytes[:n])\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot write to file %q: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc (impl *ServerImpl) RemoteFileStreamCommand(ctx context.Context, data wshrpc.CommandRemoteFileStreamData) (*wshrpc.FileInfo, error) {\n\twshRpc := wshutil.GetWshRpcFromContext(ctx)\n\tif wshRpc == nil || wshRpc.StreamBroker == nil {\n\t\treturn nil, fmt.Errorf(\"no stream broker available\")\n\t}\n\n\twriter, err := wshRpc.StreamBroker.CreateStreamWriter(&data.StreamMeta)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating stream writer: %w\", err)\n\t}\n\n\tpath, err := wavebase.ExpandHomeDir(data.Path)\n\tif err != nil {\n\t\twriter.CloseWithError(err)\n\t\treturn nil, err\n\t}\n\tcleanedPath := filepath.Clean(path)\n\n\tfinfo, err := os.Stat(cleanedPath)\n\tif err != nil {\n\t\twriter.CloseWithError(err)\n\t\treturn nil, fmt.Errorf(\"cannot stat file %q: %w\", data.Path, err)\n\t}\n\tif finfo.IsDir() {\n\t\twriter.CloseWithError(fmt.Errorf(\"path is a directory\"))\n\t\treturn nil, fmt.Errorf(\"cannot stream directory %q\", data.Path)\n\t}\n\n\tbyteRange, err := fileutil.ParseByteRange(data.ByteRange)\n\tif err != nil {\n\t\twriter.CloseWithError(err)\n\t\treturn nil, err\n\t}\n\n\tfileInfo := statToFileInfo(cleanedPath, finfo, true)\n\tfileInfo.Path = data.Path\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"RemoteFileStreamCommand\", recover())\n\t\t}()\n\t\tdefer writer.Close()\n\n\t\tfile, err := os.Open(cleanedPath)\n\t\tif err != nil {\n\t\t\twriter.CloseWithError(fmt.Errorf(\"cannot open file %q: %w\", data.Path, err))\n\t\t\treturn\n\t\t}\n\t\tdefer utilfn.GracefulClose(file, \"RemoteFileStreamCommand\", cleanedPath)\n\n\t\tif !byteRange.All && byteRange.Start > 0 {\n\t\t\tif _, err := file.Seek(byteRange.Start, io.SeekStart); err != nil {\n\t\t\t\twriter.CloseWithError(fmt.Errorf(\"cannot seek in file %q: %w\", data.Path, err))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tvar src io.Reader = file\n\t\tif !byteRange.All && !byteRange.OpenEnd {\n\t\t\tsrc = io.LimitReader(file, byteRange.End-byteRange.Start+1)\n\t\t}\n\n\t\tbuf := make([]byte, 32*1024)\n\t\tfor {\n\t\t\tn, readErr := src.Read(buf)\n\t\t\tif n > 0 {\n\t\t\t\tif _, writeErr := writer.Write(buf[:n]); writeErr != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tif readErr == io.EOF {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif readErr != nil {\n\t\t\t\twriter.CloseWithError(fmt.Errorf(\"error reading file %q: %w\", data.Path, readErr))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn fileInfo, nil\n}\n\nfunc (*ServerImpl) RemoteFileDeleteCommand(ctx context.Context, data wshrpc.CommandDeleteFileData) error {\n\texpandedPath, err := wavebase.ExpandHomeDir(data.Path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot delete file %q: %w\", data.Path, err)\n\t}\n\tcleanedPath := filepath.Clean(expandedPath)\n\n\tif data.Recursive {\n\t\terr = os.RemoveAll(cleanedPath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"cannot delete %q: %w\", data.Path, err)\n\t\t}\n\t\treturn nil\n\t}\n\n\terr = os.Remove(cleanedPath)\n\tif err != nil {\n\t\tfinfo, statErr := os.Stat(cleanedPath)\n\t\tif statErr == nil && finfo.IsDir() {\n\t\t\treturn fmt.Errorf(wshfs.RecursiveRequiredError)\n\t\t}\n\t\treturn fmt.Errorf(\"cannot delete file %q: %w\", data.Path, err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshremote/wshremote_job.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshremote\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/shirou/gopsutil/v4/process\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nfunc isProcessRunning(pid int, pidStartTs int64) (*process.Process, error) {\n\tif pid <= 0 {\n\t\treturn nil, nil\n\t}\n\tproc, err := process.NewProcess(int32(pid))\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\tcreateTime, err := proc.CreateTime()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif createTime != pidStartTs {\n\t\treturn nil, nil\n\t}\n\treturn proc, nil\n}\n\n// returns jobRouteId, cleanupFunc, error\nfunc (impl *ServerImpl) connectToJobManager(ctx context.Context, jobId string, mainServerJwtToken string) (string, func(), error) {\n\tsocketPath := wavebase.GetRemoteJobSocketPath(jobId)\n\tlog.Printf(\"connectToJobManager: connecting to socket: %s\\n\", socketPath)\n\tconn, err := net.Dial(\"unix\", socketPath)\n\tif err != nil {\n\t\tlog.Printf(\"connectToJobManager: error connecting to socket: %v\\n\", err)\n\t\treturn \"\", nil, fmt.Errorf(\"cannot connect to job manager socket: %w\", err)\n\t}\n\tlog.Printf(\"connectToJobManager: connected to socket\\n\")\n\n\tproxy := wshutil.MakeRpcProxy(\"jobmanager\")\n\tlinkId := impl.Router.RegisterUntrustedLink(proxy)\n\n\tvar cleanupOnce sync.Once\n\tcleanup := func() {\n\t\tcleanupOnce.Do(func() {\n\t\t\tconn.Close()\n\t\t\timpl.Router.UnregisterLink(linkId)\n\t\t\timpl.removeJobManagerConnection(jobId)\n\t\t})\n\t}\n\n\tgo func() {\n\t\twriteErr := wshutil.AdaptOutputChToStream(proxy.ToRemoteCh, conn)\n\t\tif writeErr != nil {\n\t\t\tlog.Printf(\"connectToJobManager: error writing to job manager socket: %v\\n\", writeErr)\n\t\t}\n\t}()\n\tgo func() {\n\t\tdefer func() {\n\t\t\tclose(proxy.FromRemoteCh)\n\t\t\tcleanup()\n\t\t}()\n\t\twshutil.AdaptStreamToMsgCh(conn, proxy.FromRemoteCh, nil)\n\t}()\n\n\trouteId := wshutil.MakeLinkRouteId(linkId)\n\tauthData := wshrpc.CommandAuthenticateToJobData{\n\t\tJobAccessToken: mainServerJwtToken,\n\t}\n\terr = wshclient.AuthenticateToJobManagerCommand(impl.RpcClient, authData, &wshrpc.RpcOpts{Route: routeId})\n\tif err != nil {\n\t\tcleanup()\n\t\treturn \"\", nil, fmt.Errorf(\"authentication to job manager failed: %w\", err)\n\t}\n\n\tjobRouteId := wshutil.MakeJobRouteId(jobId)\n\twaitCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)\n\tdefer cancel()\n\terr = impl.Router.WaitForRegister(waitCtx, jobRouteId)\n\tif err != nil {\n\t\tcleanup()\n\t\treturn \"\", nil, fmt.Errorf(\"timeout waiting for job route to register: %w\", err)\n\t}\n\n\tjobConn := &JobManagerConnection{\n\t\tJobId:     jobId,\n\t\tConn:      conn,\n\t\tCleanupFn: cleanup,\n\t}\n\timpl.addJobManagerConnection(jobConn)\n\n\tlog.Printf(\"connectToJobManager: successfully connected and authenticated\\n\")\n\treturn jobRouteId, cleanup, nil\n}\n\nfunc (impl *ServerImpl) addJobManagerConnection(conn *JobManagerConnection) {\n\timpl.Lock.Lock()\n\tdefer impl.Lock.Unlock()\n\timpl.JobManagerMap[conn.JobId] = conn\n\tlog.Printf(\"addJobManagerConnection: added job manager connection for jobid=%s\\n\", conn.JobId)\n}\n\nfunc (impl *ServerImpl) removeJobManagerConnection(jobId string) {\n\timpl.Lock.Lock()\n\tdefer impl.Lock.Unlock()\n\tif _, exists := impl.JobManagerMap[jobId]; exists {\n\t\tdelete(impl.JobManagerMap, jobId)\n\t\tlog.Printf(\"removeJobManagerConnection: removed job manager connection for jobid=%s\\n\", jobId)\n\t}\n}\n\nfunc (impl *ServerImpl) getJobManagerConnection(jobId string) *JobManagerConnection {\n\timpl.Lock.Lock()\n\tdefer impl.Lock.Unlock()\n\treturn impl.JobManagerMap[jobId]\n}\n\nfunc (impl *ServerImpl) RemoteStartJobCommand(ctx context.Context, data wshrpc.CommandRemoteStartJobData) (*wshrpc.CommandStartJobRtnData, error) {\n\tlog.Printf(\"RemoteStartJobCommand: starting, jobid=%s, clientid=%s\\n\", data.JobId, data.ClientId)\n\tif impl.Router == nil {\n\t\treturn nil, fmt.Errorf(\"cannot start remote job: no router available\")\n\t}\n\n\twshPath, err := impl.getWshPath()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tlog.Printf(\"RemoteStartJobCommand: wshPath=%s\\n\", wshPath)\n\n\treadyPipeRead, readyPipeWrite, err := os.Pipe()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"cannot create ready pipe: %w\", err)\n\t}\n\tdefer readyPipeRead.Close()\n\tdefer readyPipeWrite.Close()\n\n\tcmd := exec.Command(wshPath, \"jobmanager\", \"--jobid\", data.JobId, \"--clientid\", data.ClientId)\n\tif data.PublicKeyBase64 != \"\" {\n\t\tcmd.Env = append(os.Environ(), \"WAVETERM_PUBLICKEY=\"+data.PublicKeyBase64)\n\t}\n\tcmd.ExtraFiles = []*os.File{readyPipeWrite}\n\tstdin, err := cmd.StdinPipe()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"cannot create stdin pipe: %w\", err)\n\t}\n\tstdout, err := cmd.StdoutPipe()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"cannot create stdout pipe: %w\", err)\n\t}\n\tstderr, err := cmd.StderrPipe()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"cannot create stderr pipe: %w\", err)\n\t}\n\tlog.Printf(\"RemoteStartJobCommand: created pipes\\n\")\n\n\tif err := cmd.Start(); err != nil {\n\t\treturn nil, fmt.Errorf(\"cannot start job manager: %w\", err)\n\t}\n\treadyPipeWrite.Close()\n\tlog.Printf(\"RemoteStartJobCommand: job manager process started\\n\")\n\n\tjobAuthTokenLine := fmt.Sprintf(\"Wave-JobAccessToken:%s\\n\", data.JobAuthToken)\n\tif _, err := stdin.Write([]byte(jobAuthTokenLine)); err != nil {\n\t\tcmd.Process.Kill()\n\t\treturn nil, fmt.Errorf(\"cannot write job auth token: %w\", err)\n\t}\n\tstdin.Close()\n\tlog.Printf(\"RemoteStartJobCommand: wrote auth token to stdin\\n\")\n\n\tgo func() {\n\t\tscanner := bufio.NewScanner(stderr)\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Text()\n\t\t\tlog.Printf(\"RemoteStartJobCommand: stderr: %s\\n\", line)\n\t\t}\n\t\tif err := scanner.Err(); err != nil {\n\t\t\tlog.Printf(\"RemoteStartJobCommand: error reading stderr: %v\\n\", err)\n\t\t} else {\n\t\t\tlog.Printf(\"RemoteStartJobCommand: stderr EOF\\n\")\n\t\t}\n\t}()\n\n\tgo func() {\n\t\tscanner := bufio.NewScanner(stdout)\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Text()\n\t\t\tlog.Printf(\"RemoteStartJobCommand: stdout: %s\\n\", line)\n\t\t}\n\t\tif err := scanner.Err(); err != nil {\n\t\t\tlog.Printf(\"RemoteStartJobCommand: error reading stdout: %v\\n\", err)\n\t\t} else {\n\t\t\tlog.Printf(\"RemoteStartJobCommand: stdout EOF\\n\")\n\t\t}\n\t}()\n\n\tstartCh := make(chan error, 1)\n\tgo func() {\n\t\tscanner := bufio.NewScanner(readyPipeRead)\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Text()\n\t\t\tlog.Printf(\"RemoteStartJobCommand: ready pipe line: %s\\n\", line)\n\t\t\tif strings.Contains(line, \"Wave-JobManagerStart\") {\n\t\t\t\tstartCh <- nil\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tif err := scanner.Err(); err != nil {\n\t\t\tstartCh <- fmt.Errorf(\"error reading ready pipe: %w\", err)\n\t\t} else {\n\t\t\tlog.Printf(\"RemoteStartJobCommand: ready pipe EOF\\n\")\n\t\t\tstartCh <- fmt.Errorf(\"job manager exited without start signal\")\n\t\t}\n\t}()\n\n\ttimeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)\n\tdefer cancel()\n\n\tlog.Printf(\"RemoteStartJobCommand: waiting for start signal\\n\")\n\tselect {\n\tcase err := <-startCh:\n\t\tif err != nil {\n\t\t\tcmd.Process.Kill()\n\t\t\tlog.Printf(\"RemoteStartJobCommand: error from start signal: %v\\n\", err)\n\t\t\treturn nil, err\n\t\t}\n\t\tlog.Printf(\"RemoteStartJobCommand: received start signal\\n\")\n\tcase <-timeoutCtx.Done():\n\t\tcmd.Process.Kill()\n\t\tlog.Printf(\"RemoteStartJobCommand: timeout waiting for start signal\\n\")\n\t\treturn nil, fmt.Errorf(\"timeout waiting for job manager to start\")\n\t}\n\n\tgo func() {\n\t\tcmd.Wait()\n\t}()\n\n\tjobRouteId, cleanup, err := impl.connectToJobManager(ctx, data.JobId, data.MainServerJwtToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcombinedEnv := make(map[string]string)\n\tfor k, v := range impl.InitialEnv {\n\t\tcombinedEnv[k] = v\n\t}\n\tfor k, v := range data.Env {\n\t\tcombinedEnv[k] = v\n\t}\n\tstartJobData := wshrpc.CommandStartJobData{\n\t\tCmd:        data.Cmd,\n\t\tArgs:       data.Args,\n\t\tEnv:        combinedEnv,\n\t\tTermSize:   data.TermSize,\n\t\tStreamMeta: data.StreamMeta,\n\t}\n\trtnData, err := wshclient.StartJobCommand(impl.RpcClient, startJobData, &wshrpc.RpcOpts{Route: jobRouteId})\n\tif err != nil {\n\t\tcleanup()\n\t\treturn nil, fmt.Errorf(\"failed to start job: %w\", err)\n\t}\n\n\treturn rtnData, nil\n}\n\nfunc (impl *ServerImpl) RemoteReconnectToJobManagerCommand(ctx context.Context, data wshrpc.CommandRemoteReconnectToJobManagerData) (*wshrpc.CommandRemoteReconnectToJobManagerRtnData, error) {\n\tlog.Printf(\"RemoteReconnectToJobManagerCommand: reconnecting, jobid=%s\\n\", data.JobId)\n\tif impl.Router == nil {\n\t\treturn &wshrpc.CommandRemoteReconnectToJobManagerRtnData{\n\t\t\tSuccess: false,\n\t\t\tError:   \"cannot reconnect to job manager: no router available\",\n\t\t}, nil\n\t}\n\n\tproc, err := isProcessRunning(data.JobManagerPid, data.JobManagerStartTs)\n\tif err != nil {\n\t\treturn &wshrpc.CommandRemoteReconnectToJobManagerRtnData{\n\t\t\tSuccess: false,\n\t\t\tError:   fmt.Sprintf(\"error checking job manager process: %v\", err),\n\t\t}, nil\n\t}\n\tif proc == nil {\n\t\treturn &wshrpc.CommandRemoteReconnectToJobManagerRtnData{\n\t\t\tSuccess:        false,\n\t\t\tJobManagerGone: true,\n\t\t\tError:          fmt.Sprintf(\"job manager process (pid=%d) is not running\", data.JobManagerPid),\n\t\t}, nil\n\t}\n\n\texistingConn := impl.getJobManagerConnection(data.JobId)\n\tif existingConn != nil {\n\t\tlog.Printf(\"RemoteReconnectToJobManagerCommand: closing existing connection for jobid=%s\\n\", data.JobId)\n\t\tif existingConn.CleanupFn != nil {\n\t\t\texistingConn.CleanupFn()\n\t\t}\n\t}\n\n\t_, _, err = impl.connectToJobManager(ctx, data.JobId, data.MainServerJwtToken)\n\tif err != nil {\n\t\treturn &wshrpc.CommandRemoteReconnectToJobManagerRtnData{\n\t\t\tSuccess: false,\n\t\t\tError:   err.Error(),\n\t\t}, nil\n\t}\n\n\tlog.Printf(\"RemoteReconnectToJobManagerCommand: successfully reconnected to job manager\\n\")\n\treturn &wshrpc.CommandRemoteReconnectToJobManagerRtnData{\n\t\tSuccess: true,\n\t}, nil\n}\n\nfunc (impl *ServerImpl) RemoteDisconnectFromJobManagerCommand(ctx context.Context, data wshrpc.CommandRemoteDisconnectFromJobManagerData) error {\n\tlog.Printf(\"RemoteDisconnectFromJobManagerCommand: disconnecting, jobid=%s\\n\", data.JobId)\n\tconn := impl.getJobManagerConnection(data.JobId)\n\tif conn == nil {\n\t\tlog.Printf(\"RemoteDisconnectFromJobManagerCommand: no connection found for jobid=%s\\n\", data.JobId)\n\t\treturn nil\n\t}\n\n\tif conn.CleanupFn != nil {\n\t\tconn.CleanupFn()\n\t\tlog.Printf(\"RemoteDisconnectFromJobManagerCommand: cleanup completed for jobid=%s\\n\", data.JobId)\n\t}\n\n\treturn nil\n}\n\nfunc (impl *ServerImpl) RemoteTerminateJobManagerCommand(ctx context.Context, data wshrpc.CommandRemoteTerminateJobManagerData) error {\n\tlog.Printf(\"RemoteTerminateJobManagerCommand: terminating job manager, jobid=%s, pid=%d\\n\", data.JobId, data.JobManagerPid)\n\tproc, err := isProcessRunning(data.JobManagerPid, data.JobManagerStartTs)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error checking job manager process: %w\", err)\n\t}\n\tif proc == nil {\n\t\tlog.Printf(\"RemoteTerminateJobManagerCommand: job manager process not running, jobid=%s\\n\", data.JobId)\n\t\treturn nil\n\t}\n\terr = proc.SendSignal(syscall.SIGTERM)\n\tif err != nil {\n\t\tlog.Printf(\"failed to send SIGTERM to job manager: %v\", err)\n\t} else {\n\t\tlog.Printf(\"RemoteTerminateJobManagerCommand: sent SIGTERM to job manager process, jobid=%s, pid=%d\\n\", data.JobId, data.JobManagerPid)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshrpcmeta.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshrpc\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"reflect\"\n\t\"strings\"\n)\n\ntype WshRpcMethodDecl struct {\n\tCommand                 string\n\tCommandType             string\n\tMethodName              string\n\tCommandDataTypes        []reflect.Type\n\tDefaultResponseDataType reflect.Type\n}\n\nfunc (decl *WshRpcMethodDecl) GetCommandDataTypes() []reflect.Type {\n\treturn decl.CommandDataTypes\n}\n\nvar contextRType = reflect.TypeOf((*context.Context)(nil)).Elem()\nvar wshRpcInterfaceRType = reflect.TypeOf((*WshRpcInterface)(nil)).Elem()\n\nfunc getWshCommandType(method reflect.Method) string {\n\tif method.Type.NumOut() == 1 {\n\t\toutType := method.Type.Out(0)\n\t\tif outType.Kind() == reflect.Chan {\n\t\t\treturn RpcType_ResponseStream\n\t\t}\n\t}\n\treturn RpcType_Call\n}\n\nfunc getWshMethodResponseType(commandType string, method reflect.Method) reflect.Type {\n\tswitch commandType {\n\tcase RpcType_ResponseStream:\n\t\tif method.Type.NumOut() != 1 {\n\t\t\tpanic(fmt.Sprintf(\"method %q has invalid number of return values for response stream\", method.Name))\n\t\t}\n\t\toutType := method.Type.Out(0)\n\t\tif outType.Kind() != reflect.Chan {\n\t\t\tpanic(fmt.Sprintf(\"method %q has invalid return type %s for response stream\", method.Name, outType))\n\t\t}\n\t\telemType := outType.Elem()\n\t\tif !strings.HasPrefix(elemType.Name(), \"RespOrErrorUnion\") {\n\t\t\tpanic(fmt.Sprintf(\"method %q has invalid return element type %s for response stream (should be RespOrErrorUnion)\", method.Name, elemType))\n\t\t}\n\t\trespField, found := elemType.FieldByName(\"Response\")\n\t\tif !found {\n\t\t\tpanic(fmt.Sprintf(\"method %q has invalid return element type %s for response stream (missing Response field)\", method.Name, elemType))\n\t\t}\n\t\treturn respField.Type\n\tcase RpcType_Call:\n\t\tif method.Type.NumOut() > 1 {\n\t\t\treturn method.Type.Out(0)\n\t\t}\n\t\treturn nil\n\tdefault:\n\t\tpanic(fmt.Sprintf(\"unsupported command type %q\", commandType))\n\t}\n}\n\nfunc generateWshCommandDecl(method reflect.Method) *WshRpcMethodDecl {\n\tif method.Type.NumIn() == 0 || method.Type.In(0) != contextRType {\n\t\tpanic(fmt.Sprintf(\"method %q does not have context as first argument\", method.Name))\n\t}\n\tcmdStr := method.Name\n\tdecl := &WshRpcMethodDecl{}\n\t// remove Command suffix\n\tif !strings.HasSuffix(cmdStr, \"Command\") {\n\t\tpanic(fmt.Sprintf(\"method %q does not have Command suffix\", cmdStr))\n\t}\n\tcmdStr = cmdStr[:len(cmdStr)-len(\"Command\")]\n\tdecl.Command = strings.ToLower(cmdStr)\n\tdecl.CommandType = getWshCommandType(method)\n\tdecl.MethodName = method.Name\n\tvar cdataTypes []reflect.Type\n\tfor idx := 1; idx < method.Type.NumIn(); idx++ {\n\t\tcdataTypes = append(cdataTypes, method.Type.In(idx))\n\t}\n\tdecl.CommandDataTypes = cdataTypes\n\tdecl.DefaultResponseDataType = getWshMethodResponseType(decl.CommandType, method)\n\treturn decl\n}\n\nfunc MakeMethodMapForImpl(impl any, declMap map[string]*WshRpcMethodDecl) map[string]reflect.Method {\n\trtype := reflect.TypeOf(impl)\n\trtnMap := make(map[string]reflect.Method)\n\tfor midx := 0; midx < rtype.NumMethod(); midx++ {\n\t\tmethod := rtype.Method(midx)\n\t\tif !strings.HasSuffix(method.Name, \"Command\") {\n\t\t\tcontinue\n\t\t}\n\t\tcommandName := strings.ToLower(method.Name[:len(method.Name)-len(\"Command\")])\n\t\tdecl := declMap[commandName]\n\t\tif decl == nil {\n\t\t\tlog.Printf(\"WARNING: method %q does not match a command method\", method.Name)\n\t\t\tcontinue\n\t\t}\n\t\trtnMap[commandName] = method\n\t}\n\treturn rtnMap\n\n}\n\nfunc GenerateWshCommandDeclMap() map[string]*WshRpcMethodDecl {\n\trtype := wshRpcInterfaceRType\n\trtnMap := make(map[string]*WshRpcMethodDecl)\n\tfor midx := 0; midx < rtype.NumMethod(); midx++ {\n\t\tmethod := rtype.Method(midx)\n\t\tdecl := generateWshCommandDecl(method)\n\t\trtnMap[decl.Command] = decl\n\t}\n\treturn rtnMap\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshrpcmeta_test.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshrpc\n\nimport (\n\t\"context\"\n\t\"reflect\"\n\t\"testing\"\n)\n\ntype testRpcInterfaceForDecls interface {\n\tNoArgCommand(ctx context.Context) error\n\tOneArgCommand(ctx context.Context, data string) error\n\tTwoArgCommand(ctx context.Context, arg1 string, arg2 int) error\n}\n\nfunc TestGenerateWshCommandDecl_MultiArgs(t *testing.T) {\n\trtype := reflect.TypeOf((*testRpcInterfaceForDecls)(nil)).Elem()\n\tmethod, ok := rtype.MethodByName(\"TwoArgCommand\")\n\tif !ok {\n\t\tt.Fatalf(\"TwoArgCommand method not found\")\n\t}\n\tdecl := generateWshCommandDecl(method)\n\tif decl.Command != \"twoarg\" {\n\t\tt.Fatalf(\"expected command twoarg, got %q\", decl.Command)\n\t}\n\tif len(decl.CommandDataTypes) != 2 {\n\t\tt.Fatalf(\"expected 2 command data types, got %d\", len(decl.CommandDataTypes))\n\t}\n\tif decl.CommandDataTypes[0].Kind() != reflect.String || decl.CommandDataTypes[1].Kind() != reflect.Int {\n\t\tt.Fatalf(\"unexpected command data types: %#v\", decl.CommandDataTypes)\n\t}\n\tif len(decl.GetCommandDataTypes()) != 2 {\n\t\tt.Fatalf(\"expected helper to return two command data types\")\n\t}\n}\n\nfunc TestGenerateWshCommandDeclMap_TestMultiArgCommand(t *testing.T) {\n\tdecl := GenerateWshCommandDeclMap()[\"testmultiarg\"]\n\tif decl == nil {\n\t\tt.Fatalf(\"expected testmultiarg command declaration\")\n\t}\n\tif decl.MethodName != \"TestMultiArgCommand\" {\n\t\tt.Fatalf(\"expected TestMultiArgCommand method name, got %q\", decl.MethodName)\n\t}\n\tif len(decl.GetCommandDataTypes()) != 3 {\n\t\tt.Fatalf(\"expected 3 command args, got %d\", len(decl.GetCommandDataTypes()))\n\t}\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshrpctypes.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// types and methods for wsh rpc calls\npackage wshrpc\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\"\n\t\"github.com/wavetermdev/waveterm/pkg/vdom\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n)\n\ntype RespOrErrorUnion[T any] struct {\n\tResponse T\n\tError    error\n}\n\ntype MultiArg struct {\n\tArgs []any `json:\"args\"`\n}\n\n// Instructions for adding a new RPC call\n// * methods must end with Command\n// * methods must take context as their first parameter\n// * methods may take additional typed parameters, and may return either just an error, or one return value plus an error\n// * after modifying WshRpcInterface, run `task generate` to regnerate bindings\n\ntype WshRpcInterface interface {\n\tAuthenticateCommand(ctx context.Context, data string) (CommandAuthenticateRtnData, error)\n\tAuthenticateTokenCommand(ctx context.Context, data CommandAuthenticateTokenData) (CommandAuthenticateRtnData, error)\n\tAuthenticateTokenVerifyCommand(ctx context.Context, data CommandAuthenticateTokenData) (CommandAuthenticateRtnData, error) // (special) validates token without binding, root router only\n\tAuthenticateJobManagerCommand(ctx context.Context, data CommandAuthenticateJobManagerData) error\n\tAuthenticateJobManagerVerifyCommand(ctx context.Context, data CommandAuthenticateJobManagerData) error // (special) validates job auth token without binding, root router only\n\tDisposeCommand(ctx context.Context, data CommandDisposeData) error\n\tRouteAnnounceCommand(ctx context.Context) error               // (special) announces a new route to the main router\n\tRouteUnannounceCommand(ctx context.Context) error             // (special) unannounces a route to the main router\n\tControlGetRouteIdCommand(ctx context.Context) (string, error) // (special) gets the route for the link that we're on\n\tSetPeerInfoCommand(ctx context.Context, peerInfo string) error\n\tGetJwtPublicKeyCommand(ctx context.Context) (string, error) // (special) gets the public JWT signing key\n\n\tMessageCommand(ctx context.Context, data CommandMessageData) error\n\tGetMetaCommand(ctx context.Context, data CommandGetMetaData) (waveobj.MetaMapType, error)\n\tSetMetaCommand(ctx context.Context, data CommandSetMetaData) error\n\tControllerInputCommand(ctx context.Context, data CommandBlockInputData) error\n\tControllerDestroyCommand(ctx context.Context, blockId string) error\n\tControllerResyncCommand(ctx context.Context, data CommandControllerResyncData) error\n\tControllerAppendOutputCommand(ctx context.Context, data CommandControllerAppendOutputData) error\n\tResolveIdsCommand(ctx context.Context, data CommandResolveIdsData) (CommandResolveIdsRtnData, error)\n\tCreateBlockCommand(ctx context.Context, data CommandCreateBlockData) (waveobj.ORef, error)\n\tCreateSubBlockCommand(ctx context.Context, data CommandCreateSubBlockData) (waveobj.ORef, error)\n\tDeleteBlockCommand(ctx context.Context, data CommandDeleteBlockData) error\n\tDeleteSubBlockCommand(ctx context.Context, data CommandDeleteBlockData) error\n\tWaitForRouteCommand(ctx context.Context, data CommandWaitForRouteData) (bool, error)\n\n\tEventPublishCommand(ctx context.Context, data wps.WaveEvent) error\n\tEventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error\n\tEventUnsubCommand(ctx context.Context, data string) error\n\tEventUnsubAllCommand(ctx context.Context) error\n\tEventReadHistoryCommand(ctx context.Context, data CommandEventReadHistoryData) ([]*wps.WaveEvent, error)\n\n\tFileRestoreBackupCommand(ctx context.Context, data CommandFileRestoreBackupData) error\n\tGetTempDirCommand(ctx context.Context, data CommandGetTempDirData) (string, error)\n\tWriteTempFileCommand(ctx context.Context, data CommandWriteTempFileData) (string, error)\n\tStreamTestCommand(ctx context.Context) chan RespOrErrorUnion[int]\n\tStreamWaveAiCommand(ctx context.Context, request WaveAIStreamRequest) chan RespOrErrorUnion[WaveAIPacketType]\n\tStreamCpuDataCommand(ctx context.Context, request CpuDataRequest) chan RespOrErrorUnion[TimeSeriesData]\n\tTestCommand(ctx context.Context, data string) error\n\tTestMultiArgCommand(ctx context.Context, arg1 string, arg2 int, arg3 bool) (string, error)\n\tSetConfigCommand(ctx context.Context, data MetaSettingsType) error\n\tSetConnectionsConfigCommand(ctx context.Context, data ConnConfigRequest) error\n\tGetFullConfigCommand(ctx context.Context) (wconfig.FullConfigType, error)\n\tGetWaveAIModeConfigCommand(ctx context.Context) (wconfig.AIModeConfigUpdate, error)\n\tBlockInfoCommand(ctx context.Context, blockId string) (*BlockInfoData, error)\n\tDebugTermCommand(ctx context.Context, data CommandDebugTermData) (*CommandDebugTermRtnData, error)\n\tBlocksListCommand(ctx context.Context, data BlocksListRequest) ([]BlocksListEntry, error)\n\tWaveInfoCommand(ctx context.Context) (*WaveInfoData, error)\n\tMacOSVersionCommand(ctx context.Context) (string, error)\n\tWshActivityCommand(ct context.Context, data map[string]int) error\n\tActivityCommand(ctx context.Context, data ActivityUpdate) error\n\tRecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error\n\tGetVarCommand(ctx context.Context, data CommandVarData) (*CommandVarResponseData, error)\n\tGetAllVarsCommand(ctx context.Context, data CommandVarData) ([]CommandVarResponseData, error)\n\tSetVarCommand(ctx context.Context, data CommandVarData) error\n\tPathCommand(ctx context.Context, data PathCommandData) (string, error)\n\tSendTelemetryCommand(ctx context.Context) error\n\tFetchSuggestionsCommand(ctx context.Context, data FetchSuggestionsData) (*FetchSuggestionsResponse, error)\n\tDisposeSuggestionsCommand(ctx context.Context, widgetId string) error\n\tGetTabCommand(ctx context.Context, tabId string) (*waveobj.Tab, error)\n\tUpdateTabNameCommand(ctx context.Context, tabId string, newName string) error\n\tUpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error\n\tGetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error)\n\n\t// connection functions\n\tConnStatusCommand(ctx context.Context) ([]ConnStatus, error)\n\tWslStatusCommand(ctx context.Context) ([]ConnStatus, error)\n\tConnEnsureCommand(ctx context.Context, data ConnExtData) error\n\tConnReinstallWshCommand(ctx context.Context, data ConnExtData) error\n\tConnConnectCommand(ctx context.Context, connRequest ConnRequest) error\n\tConnDisconnectCommand(ctx context.Context, connName string) error\n\tConnListCommand(ctx context.Context) ([]string, error)\n\tWslListCommand(ctx context.Context) ([]string, error)\n\tWslDefaultDistroCommand(ctx context.Context) (string, error)\n\tDismissWshFailCommand(ctx context.Context, connName string) error\n\tConnUpdateWshCommand(ctx context.Context, remoteInfo RemoteInfo) (bool, error)\n\tFindGitBashCommand(ctx context.Context, rescan bool) (string, error)\n\tConnServerInitCommand(ctx context.Context, data CommandConnServerInitData) error\n\tNotifySystemResumeCommand(ctx context.Context) error\n\n\t// eventrecv is special, it's handled internally by WshRpc with EventListener\n\tEventRecvCommand(ctx context.Context, data wps.WaveEvent) error\n\n\t// remotes\n\tWshRpcRemoteFileInterface\n\tRemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData]\n\tRemoteGetInfoCommand(ctx context.Context) (RemoteInfo, error)\n\tRemoteInstallRcFilesCommand(ctx context.Context) error\n\tRemoteStartJobCommand(ctx context.Context, data CommandRemoteStartJobData) (*CommandStartJobRtnData, error)\n\tRemoteReconnectToJobManagerCommand(ctx context.Context, data CommandRemoteReconnectToJobManagerData) (*CommandRemoteReconnectToJobManagerRtnData, error)\n\tRemoteDisconnectFromJobManagerCommand(ctx context.Context, data CommandRemoteDisconnectFromJobManagerData) error\n\tRemoteTerminateJobManagerCommand(ctx context.Context, data CommandRemoteTerminateJobManagerData) error\n\tBadgeWatchPidCommand(ctx context.Context, data CommandBadgeWatchPidData) error\n\n\t// emain\n\tWebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error)\n\tNotifyCommand(ctx context.Context, notificationOptions WaveNotificationOptions) error\n\tFocusWindowCommand(ctx context.Context, windowId string) error\n\tElectronEncryptCommand(ctx context.Context, data CommandElectronEncryptData) (*CommandElectronEncryptRtnData, error)\n\tElectronDecryptCommand(ctx context.Context, data CommandElectronDecryptData) (*CommandElectronDecryptRtnData, error)\n\tNetworkOnlineCommand(ctx context.Context) (bool, error)\n\tElectronSystemBellCommand(ctx context.Context) error\n\n\t// secrets\n\tGetSecretsCommand(ctx context.Context, names []string) (map[string]string, error)\n\tGetSecretsNamesCommand(ctx context.Context) ([]string, error)\n\tSetSecretsCommand(ctx context.Context, secrets map[string]*string) error\n\tGetSecretsLinuxStorageBackendCommand(ctx context.Context) (string, error)\n\n\tWorkspaceListCommand(ctx context.Context) ([]WorkspaceInfoData, error)\n\tGetUpdateChannelCommand(ctx context.Context) (string, error)\n\n\t// terminal\n\tVDomCreateContextCommand(ctx context.Context, data vdom.VDomCreateContext) (*waveobj.ORef, error)\n\tVDomAsyncInitiationCommand(ctx context.Context, data vdom.VDomAsyncInitiationRequest) error\n\n\t// ai\n\tAiSendMessageCommand(ctx context.Context, data AiMessageData) error\n\tWaveAIEnableTelemetryCommand(ctx context.Context) error\n\tGetWaveAIChatCommand(ctx context.Context, data CommandGetWaveAIChatData) (*uctypes.UIChat, error)\n\tGetWaveAIRateLimitCommand(ctx context.Context) (*uctypes.RateLimitInfo, error)\n\tWaveAIToolApproveCommand(ctx context.Context, data CommandWaveAIToolApproveData) error\n\tWaveAIAddContextCommand(ctx context.Context, data CommandWaveAIAddContextData) error\n\tWaveAIGetToolDiffCommand(ctx context.Context, data CommandWaveAIGetToolDiffData) (*CommandWaveAIGetToolDiffRtnData, error)\n\n\t// screenshot\n\tCaptureBlockScreenshotCommand(ctx context.Context, data CommandCaptureBlockScreenshotData) (string, error)\n\n\t// block focus\n\tSetBlockFocusCommand(ctx context.Context, blockId string) error\n\tGetFocusedBlockDataCommand(ctx context.Context) (*FocusedBlockData, error)\n\n\t// rtinfo\n\tGetRTInfoCommand(ctx context.Context, data CommandGetRTInfoData) (*waveobj.ObjRTInfo, error)\n\tSetRTInfoCommand(ctx context.Context, data CommandSetRTInfoData) error\n\n\t// terminal\n\tTermGetScrollbackLinesCommand(ctx context.Context, data CommandTermGetScrollbackLinesData) (*CommandTermGetScrollbackLinesRtnData, error)\n\n\t// file\n\tWshRpcFileInterface\n\tWaveFileReadStreamCommand(ctx context.Context, data CommandWaveFileReadStreamData) (*WaveFileInfo, error)\n\n\t// builder\n\tWshRpcBuilderInterface\n\n\t// proc\n\tVDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) chan RespOrErrorUnion[*vdom.VDomBackendUpdate]\n\tVDomUrlRequestCommand(ctx context.Context, data VDomUrlRequestData) chan RespOrErrorUnion[VDomUrlRequestResponse]\n\n\t// streams\n\tStreamDataCommand(ctx context.Context, data CommandStreamData) error\n\tStreamDataAckCommand(ctx context.Context, data CommandStreamAckData) error\n\n\t// jobs\n\tAuthenticateToJobManagerCommand(ctx context.Context, data CommandAuthenticateToJobData) error\n\tStartJobCommand(ctx context.Context, data CommandStartJobData) (*CommandStartJobRtnData, error)\n\tJobPrepareConnectCommand(ctx context.Context, data CommandJobPrepareConnectData) (*CommandJobConnectRtnData, error)\n\tJobStartStreamCommand(ctx context.Context, data CommandJobStartStreamData) error\n\tJobInputCommand(ctx context.Context, data CommandJobInputData) error\n\tJobCmdExitedCommand(ctx context.Context, data CommandJobCmdExitedData) error // this is sent FROM the job manager => main server\n\n\t// job controller\n\tJobControllerDeleteJobCommand(ctx context.Context, jobId string) error\n\tJobControllerListCommand(ctx context.Context) ([]*waveobj.Job, error)\n\tJobControllerStartJobCommand(ctx context.Context, data CommandJobControllerStartJobData) (string, error)\n\tJobControllerExitJobCommand(ctx context.Context, jobId string) error\n\tJobControllerDisconnectJobCommand(ctx context.Context, jobId string) error\n\tJobControllerReconnectJobCommand(ctx context.Context, jobId string) error\n\tJobControllerReconnectJobsForConnCommand(ctx context.Context, connName string) error\n\tJobControllerConnectedJobsCommand(ctx context.Context) ([]string, error)\n\tJobControllerAttachJobCommand(ctx context.Context, data CommandJobControllerAttachJobData) error\n\tJobControllerDetachJobCommand(ctx context.Context, jobId string) error\n\tJobControllerGetAllJobManagerStatusCommand(ctx context.Context) ([]*JobManagerStatusUpdate, error)\n\tBlockJobStatusCommand(ctx context.Context, blockId string) (*BlockJobStatusData, error)\n}\n\n// for frontend\ntype WshServerCommandMeta struct {\n\tCommandType string `json:\"commandtype\"`\n}\n\ntype RpcOpts struct {\n\tTimeout    int64  `json:\"timeout,omitempty\"`\n\tNoResponse bool   `json:\"noresponse,omitempty\"`\n\tRoute      string `json:\"route,omitempty\"`\n\n\tStreamCancelFn func(context.Context) error `json:\"-\"` // this is an *output* parameter, set by the handler\n}\n\ntype RpcContext struct {\n\tSockName  string `json:\"sockname,omitempty\"`  // the domain socket name\n\tRouteId   string `json:\"routeid\"`             // the routeid from the jwt\n\tProcRoute bool   `json:\"procroute,omitempty\"` // use a random procid for route\n\tBlockId   string `json:\"blockid,omitempty\"`   // blockid for this rpc\n\tConn      string `json:\"conn,omitempty\"`      // the conn name\n\tIsRouter  bool   `json:\"isrouter,omitempty\"`  // if this is for a sub-router\n}\n\nfunc (rc RpcContext) GenerateRouteId() string {\n\tif rc.RouteId != \"\" {\n\t\treturn rc.RouteId\n\t}\n\treturn \"proc:\" + uuid.New().String()\n}\n\ntype CommandAuthenticateRtnData struct {\n\tRouteId string `json:\"routeid\"`\n\n\t// these fields are only set when doing a token swap\n\tEnv            map[string]string `json:\"env,omitempty\"`\n\tInitScriptText string            `json:\"initscripttext,omitempty\"`\n\tRpcContext     *RpcContext       `json:\"rpccontext,omitempty\"`\n}\n\ntype CommandAuthenticateTokenData struct {\n\tToken string `json:\"token\"`\n}\n\ntype CommandDisposeData struct {\n\tRouteId string `json:\"routeid\"`\n\t// auth token travels in the packet directly\n}\n\ntype CommandMessageData struct {\n\tMessage string `json:\"message\"`\n}\n\ntype CommandGetMetaData struct {\n\tORef waveobj.ORef `json:\"oref\"`\n}\n\ntype CommandSetMetaData struct {\n\tORef waveobj.ORef        `json:\"oref\"`\n\tMeta waveobj.MetaMapType `json:\"meta\"`\n}\n\ntype CommandResolveIdsData struct {\n\tBlockId string   `json:\"blockid\"`\n\tIds     []string `json:\"ids\"`\n}\n\ntype CommandResolveIdsRtnData struct {\n\tResolvedIds map[string]waveobj.ORef `json:\"resolvedids\"`\n}\n\ntype CommandCreateBlockData struct {\n\tTabId         string               `json:\"tabid\"`\n\tBlockDef      *waveobj.BlockDef    `json:\"blockdef\"`\n\tRtOpts        *waveobj.RuntimeOpts `json:\"rtopts,omitempty\"`\n\tMagnified     bool                 `json:\"magnified,omitempty\"`\n\tEphemeral     bool                 `json:\"ephemeral,omitempty\"`\n\tFocused       bool                 `json:\"focused,omitempty\"`\n\tTargetBlockId string               `json:\"targetblockid,omitempty\"`\n\tTargetAction  string               `json:\"targetaction,omitempty\"` // \"replace\", \"splitright\", \"splitdown\", \"splitleft\", \"splitup\"\n}\n\ntype CommandCreateSubBlockData struct {\n\tParentBlockId string            `json:\"parentblockid\"`\n\tBlockDef      *waveobj.BlockDef `json:\"blockdef\"`\n}\n\ntype CommandControllerResyncData struct {\n\tForceRestart bool                 `json:\"forcerestart,omitempty\"`\n\tTabId        string               `json:\"tabid\"`\n\tBlockId      string               `json:\"blockid\"`\n\tRtOpts       *waveobj.RuntimeOpts `json:\"rtopts,omitempty\"`\n}\n\ntype CommandControllerAppendOutputData struct {\n\tBlockId string `json:\"blockid\"`\n\tData64  string `json:\"data64\"`\n}\n\ntype CommandBlockInputData struct {\n\tBlockId     string            `json:\"blockid\"`\n\tInputData64 string            `json:\"inputdata64,omitempty\"`\n\tSigName     string            `json:\"signame,omitempty\"`\n\tTermSize    *waveobj.TermSize `json:\"termsize,omitempty\"`\n}\n\ntype CommandJobInputData struct {\n\tJobId          string            `json:\"jobid\"`\n\tInputSessionId string            `json:\"inputsessionid,omitempty\"`\n\tSeqNum         int               `json:\"seqnum,omitempty\"`\n\tInputData64    string            `json:\"inputdata64,omitempty\"`\n\tSigName        string            `json:\"signame,omitempty\"`\n\tTermSize       *waveobj.TermSize `json:\"termsize,omitempty\"`\n}\n\ntype CommandWaitForRouteData struct {\n\tRouteId string `json:\"routeid\"`\n\tWaitMs  int    `json:\"waitms\"`\n}\n\ntype CommandDeleteBlockData struct {\n\tBlockId string `json:\"blockid\"`\n}\n\ntype CommandEventReadHistoryData struct {\n\tEvent    string `json:\"event\"`\n\tScope    string `json:\"scope\"`\n\tMaxItems int    `json:\"maxitems\"`\n}\n\ntype WaveAIStreamRequest struct {\n\tClientId string                    `json:\"clientid,omitempty\"`\n\tOpts     *WaveAIOptsType           `json:\"opts\"`\n\tPrompt   []WaveAIPromptMessageType `json:\"prompt\"`\n}\n\ntype WaveAIPromptMessageType struct {\n\tRole    string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tName    string `json:\"name,omitempty\"`\n}\n\ntype WaveAIOptsType struct {\n\tModel      string `json:\"model\"`\n\tAPIType    string `json:\"apitype,omitempty\"`\n\tAPIToken   string `json:\"apitoken\"`\n\tOrgID      string `json:\"orgid,omitempty\"`\n\tAPIVersion string `json:\"apiversion,omitempty\"`\n\tBaseURL    string `json:\"baseurl,omitempty\"`\n\tProxyURL   string `json:\"proxyurl,omitempty\"`\n\tMaxTokens  int    `json:\"maxtokens,omitempty\"`\n\tMaxChoices int    `json:\"maxchoices,omitempty\"`\n\tTimeoutMs  int    `json:\"timeoutms,omitempty\"`\n}\n\ntype WaveAIPacketType struct {\n\tType         string           `json:\"type\"`\n\tModel        string           `json:\"model,omitempty\"`\n\tCreated      int64            `json:\"created,omitempty\"`\n\tFinishReason string           `json:\"finish_reason,omitempty\"`\n\tUsage        *WaveAIUsageType `json:\"usage,omitempty\"`\n\tIndex        int              `json:\"index,omitempty\"`\n\tText         string           `json:\"text,omitempty\"`\n\tError        string           `json:\"error,omitempty\"`\n}\n\ntype WaveAIUsageType struct {\n\tPromptTokens     int `json:\"prompt_tokens,omitempty\"`\n\tCompletionTokens int `json:\"completion_tokens,omitempty\"`\n\tTotalTokens      int `json:\"total_tokens,omitempty\"`\n}\n\ntype CpuDataRequest struct {\n\tId    string `json:\"id\"`\n\tCount int    `json:\"count\"`\n}\n\ntype CpuDataType struct {\n\tTime  int64   `json:\"time\"`\n\tValue float64 `json:\"value\"`\n}\n\ntype CommandFileRestoreBackupData struct {\n\tBackupFilePath    string `json:\"backupfilepath\"`\n\tRestoreToFileName string `json:\"restoretofilename\"`\n}\n\ntype CommandGetTempDirData struct {\n\tFileName string `json:\"filename,omitempty\"`\n}\n\ntype CommandWriteTempFileData struct {\n\tFileName string `json:\"filename\"`\n\tData64   string `json:\"data64\"`\n}\n\ntype ConnRequest struct {\n\tHost       string               `json:\"host\"`\n\tKeywords   wconfig.ConnKeywords `json:\"keywords,omitempty\"`\n\tLogBlockId string               `json:\"logblockid,omitempty\"`\n}\n\ntype RemoteInfo struct {\n\tClientArch    string `json:\"clientarch\"`\n\tClientOs      string `json:\"clientos\"`\n\tClientVersion string `json:\"clientversion\"`\n\tShell         string `json:\"shell\"`\n\tHomeDir       string `json:\"homedir\"`\n}\n\nconst (\n\tTimeSeries_Cpu = \"cpu\"\n)\n\ntype TimeSeriesData struct {\n\tTs     int64              `json:\"ts\"`\n\tValues map[string]float64 `json:\"values\"`\n}\n\ntype MetaSettingsType struct {\n\twaveobj.MetaMapType\n}\n\nfunc (m *MetaSettingsType) UnmarshalJSON(data []byte) error {\n\tvar metaMap waveobj.MetaMapType\n\tdecoder := json.NewDecoder(bytes.NewReader(data))\n\tdecoder.UseNumber()\n\tif err := decoder.Decode(&metaMap); err != nil {\n\t\treturn err\n\t}\n\t*m = MetaSettingsType{MetaMapType: metaMap}\n\treturn nil\n}\n\nfunc (m MetaSettingsType) MarshalJSON() ([]byte, error) {\n\treturn json.Marshal(m.MetaMapType)\n}\n\ntype ConnConfigRequest struct {\n\tHost        string              `json:\"host\"`\n\tMetaMapType waveobj.MetaMapType `json:\"metamaptype\"`\n}\n\ntype ConnStatus struct {\n\tStatus                        string `json:\"status\"`\n\tConnHealthStatus              string `json:\"connhealthstatus,omitempty\"`\n\tWshEnabled                    bool   `json:\"wshenabled\"`\n\tConnection                    string `json:\"connection\"`\n\tConnected                     bool   `json:\"connected\"`\n\tHasConnected                  bool   `json:\"hasconnected\"` // true if it has *ever* connected successfully\n\tActiveConnNum                 int    `json:\"activeconnnum\"`\n\tError                         string `json:\"error,omitempty\"`\n\tWshError                      string `json:\"wsherror,omitempty\"`\n\tNoWshReason                   string `json:\"nowshreason,omitempty\"`\n\tWshVersion                    string `json:\"wshversion,omitempty\"`\n\tLastActivityBeforeStalledTime int64  `json:\"lastactivitybeforestalledtime,omitempty\"`\n\tKeepAliveSentTime             int64  `json:\"keepalivesenttime,omitempty\"`\n}\n\ntype WebSelectorOpts struct {\n\tAll   bool `json:\"all,omitempty\"`\n\tInner bool `json:\"inner,omitempty\"`\n}\n\ntype CommandWebSelectorData struct {\n\tWorkspaceId string           `json:\"workspaceid\"`\n\tBlockId     string           `json:\"blockid\"`\n\tTabId       string           `json:\"tabid\"`\n\tSelector    string           `json:\"selector\"`\n\tOpts        *WebSelectorOpts `json:\"opts,omitempty\"`\n}\n\ntype BlockInfoData struct {\n\tBlockId     string          `json:\"blockid\"`\n\tTabId       string          `json:\"tabid\"`\n\tWorkspaceId string          `json:\"workspaceid\"`\n\tBlock       *waveobj.Block  `json:\"block\"`\n\tFiles       []*WaveFileInfo `json:\"files\"`\n}\n\ntype WaveNotificationOptions struct {\n\tTitle  string `json:\"title,omitempty\"`\n\tBody   string `json:\"body,omitempty\"`\n\tSilent bool   `json:\"silent,omitempty\"`\n}\n\ntype VDomUrlRequestData struct {\n\tMethod  string            `json:\"method\"`\n\tURL     string            `json:\"url\"`\n\tHeaders map[string]string `json:\"headers\"`\n\tBody    []byte            `json:\"body,omitempty\"`\n}\n\ntype VDomUrlRequestResponse struct {\n\tStatusCode int               `json:\"statuscode,omitempty\"`\n\tHeaders    map[string]string `json:\"headers,omitempty\"`\n\tBody       []byte            `json:\"body,omitempty\"`\n}\n\ntype WaveInfoData struct {\n\tVersion   string `json:\"version\"`\n\tClientId  string `json:\"clientid\"`\n\tBuildTime string `json:\"buildtime\"`\n\tConfigDir string `json:\"configdir\"`\n\tDataDir   string `json:\"datadir\"`\n}\n\ntype WorkspaceInfoData struct {\n\tWindowId      string             `json:\"windowid\"`\n\tWorkspaceData *waveobj.Workspace `json:\"workspacedata\"`\n}\n\ntype BlocksListRequest struct {\n\tWindowId    string `json:\"windowid,omitempty\"`\n\tWorkspaceId string `json:\"workspaceid,omitempty\"`\n}\n\ntype BlocksListEntry struct {\n\tWindowId    string              `json:\"windowid\"`\n\tWorkspaceId string              `json:\"workspaceid\"`\n\tTabId       string              `json:\"tabid\"`\n\tBlockId     string              `json:\"blockid\"`\n\tMeta        waveobj.MetaMapType `json:\"meta\"`\n}\n\ntype AiMessageData struct {\n\tMessage string `json:\"message,omitempty\"`\n}\n\ntype CommandGetWaveAIChatData struct {\n\tChatId string `json:\"chatid\"`\n}\n\ntype CommandWaveAIToolApproveData struct {\n\tToolCallId string `json:\"toolcallid\"`\n\tApproval   string `json:\"approval,omitempty\"`\n}\n\ntype AIAttachedFile struct {\n\tName   string `json:\"name\"`\n\tType   string `json:\"type\"`\n\tSize   int    `json:\"size\"`\n\tData64 string `json:\"data64\"`\n}\n\ntype CommandWaveAIAddContextData struct {\n\tFiles   []AIAttachedFile `json:\"files,omitempty\"`\n\tText    string           `json:\"text,omitempty\"`\n\tSubmit  bool             `json:\"submit,omitempty\"`\n\tNewChat bool             `json:\"newchat,omitempty\"`\n}\n\ntype CommandWaveAIGetToolDiffData struct {\n\tChatId     string `json:\"chatid\"`\n\tToolCallId string `json:\"toolcallid\"`\n}\n\ntype CommandWaveAIGetToolDiffRtnData struct {\n\tOriginalContents64 string `json:\"originalcontents64\"`\n\tModifiedContents64 string `json:\"modifiedcontents64\"`\n}\n\ntype CommandCaptureBlockScreenshotData struct {\n\tBlockId string `json:\"blockid\"`\n}\n\ntype CommandVarData struct {\n\tKey      string `json:\"key\"`\n\tVal      string `json:\"val,omitempty\"`\n\tRemove   bool   `json:\"remove,omitempty\"`\n\tZoneId   string `json:\"zoneid\"`\n\tFileName string `json:\"filename\"`\n}\n\ntype CommandVarResponseData struct {\n\tKey    string `json:\"key\"`\n\tVal    string `json:\"val\"`\n\tExists bool   `json:\"exists\"`\n}\n\ntype CommandDebugTermData struct {\n\tBlockId string `json:\"blockid\"`\n\tSize    int64  `json:\"size\"`\n}\n\ntype CommandDebugTermRtnData struct {\n\tOffset int64  `json:\"offset\"`\n\tData64 string `json:\"data64\"`\n}\n\ntype PathCommandData struct {\n\tPathType     string `json:\"pathtype\"`\n\tOpen         bool   `json:\"open\"`\n\tOpenExternal bool   `json:\"openexternal\"`\n\tTabId        string `json:\"tabid\"`\n}\n\ntype ActivityDisplayType struct {\n\tWidth    int     `json:\"width\"`\n\tHeight   int     `json:\"height\"`\n\tDPR      float64 `json:\"dpr\"`\n\tInternal bool    `json:\"internal,omitempty\"`\n}\n\ntype ActivityUpdate struct {\n\tFgMinutes           int                   `json:\"fgminutes,omitempty\"`\n\tActiveMinutes       int                   `json:\"activeminutes,omitempty\"`\n\tOpenMinutes         int                   `json:\"openminutes,omitempty\"`\n\tWaveAIFgMinutes     int                   `json:\"waveaifgminutes,omitempty\"`\n\tWaveAIActiveMinutes int                   `json:\"waveaiactiveminutes,omitempty\"`\n\tNumTabs             int                   `json:\"numtabs,omitempty\"`\n\tNewTab              int                   `json:\"newtab,omitempty\"`\n\tNumBlocks           int                   `json:\"numblocks,omitempty\"`\n\tNumWindows          int                   `json:\"numwindows,omitempty\"`\n\tNumWS               int                   `json:\"numws,omitempty\"`\n\tNumWSNamed          int                   `json:\"numwsnamed,omitempty\"`\n\tNumSSHConn          int                   `json:\"numsshconn,omitempty\"`\n\tNumWSLConn          int                   `json:\"numwslconn,omitempty\"`\n\tNumMagnify          int                   `json:\"nummagnify,omitempty\"`\n\tTermCommandsRun     int                   `json:\"termcommandsrun,omitempty\"`\n\tNumPanics           int                   `json:\"numpanics,omitempty\"`\n\tNumAIReqs           int                   `json:\"numaireqs,omitempty\"`\n\tStartup             int                   `json:\"startup,omitempty\"`\n\tShutdown            int                   `json:\"shutdown,omitempty\"`\n\tSetTabTheme         int                   `json:\"settabtheme,omitempty\"`\n\tBuildTime           string                `json:\"buildtime,omitempty\"`\n\tDisplays            []ActivityDisplayType `json:\"displays,omitempty\"`\n\tRenderers           map[string]int        `json:\"renderers,omitempty\"`\n\tBlocks              map[string]int        `json:\"blocks,omitempty\"`\n\tWshCmds             map[string]int        `json:\"wshcmds,omitempty\"`\n\tConn                map[string]int        `json:\"conn,omitempty\"`\n}\n\ntype ConnExtData struct {\n\tConnName   string `json:\"connname\"`\n\tLogBlockId string `json:\"logblockid,omitempty\"`\n}\n\ntype CommandConnServerInitData struct {\n\tClientId string `json:\"clientid\"`\n}\n\ntype FetchSuggestionsData struct {\n\tSuggestionType string `json:\"suggestiontype\"`\n\tQuery          string `json:\"query\"`\n\tWidgetId       string `json:\"widgetid\"`\n\tReqNum         int    `json:\"reqnum\"`\n\tFileCwd        string `json:\"file:cwd,omitempty\"`\n\tFileDirOnly    bool   `json:\"file:dironly,omitempty\"`\n\tFileConnection string `json:\"file:connection,omitempty\"`\n}\n\ntype FetchSuggestionsResponse struct {\n\tReqNum      int              `json:\"reqnum\"`\n\tSuggestions []SuggestionType `json:\"suggestions\"`\n}\n\ntype SuggestionType struct {\n\tType         string `json:\"type\"`\n\tSuggestionId string `json:\"suggestionid\"`\n\tDisplay      string `json:\"display\"`\n\tSubText      string `json:\"subtext,omitempty\"`\n\tIcon         string `json:\"icon,omitempty\"`\n\tIconColor    string `json:\"iconcolor,omitempty\"`\n\tIconSrc      string `json:\"iconsrc,omitempty\"`\n\tMatchPos     []int  `json:\"matchpos,omitempty\"`\n\tSubMatchPos  []int  `json:\"submatchpos,omitempty\"`\n\tScore        int    `json:\"score,omitempty\"`\n\tFileMimeType string `json:\"file:mimetype,omitempty\"`\n\tFilePath     string `json:\"file:path,omitempty\"`\n\tFileName     string `json:\"file:name,omitempty\"`\n\tUrlUrl       string `json:\"url:url,omitempty\"`\n}\n\ntype CommandGetRTInfoData struct {\n\tORef waveobj.ORef `json:\"oref\"`\n}\n\ntype CommandSetRTInfoData struct {\n\tORef   waveobj.ORef   `json:\"oref\"`\n\tData   map[string]any `json:\"data\" tstype:\"ObjRTInfo\"`\n\tDelete bool           `json:\"delete,omitempty\"`\n}\n\ntype CommandTermGetScrollbackLinesData struct {\n\tLineStart   int  `json:\"linestart\"`\n\tLineEnd     int  `json:\"lineend\"`\n\tLastCommand bool `json:\"lastcommand\"`\n}\n\ntype CommandTermGetScrollbackLinesRtnData struct {\n\tTotalLines  int      `json:\"totallines\"`\n\tLineStart   int      `json:\"linestart\"`\n\tLines       []string `json:\"lines\"`\n\tLastUpdated int64    `json:\"lastupdated\"`\n}\n\ntype CommandTermUpdateAttachedJobData struct {\n\tBlockId string `json:\"blockid\"`\n\tJobId   string `json:\"jobid,omitempty\"`\n}\n\ntype CommandElectronEncryptData struct {\n\tPlainText string `json:\"plaintext\"`\n}\n\ntype CommandElectronEncryptRtnData struct {\n\tCipherText     string `json:\"ciphertext\"`\n\tStorageBackend string `json:\"storagebackend\"` // only returned for linux\n}\n\ntype CommandElectronDecryptData struct {\n\tCipherText string `json:\"ciphertext\"`\n}\n\ntype CommandElectronDecryptRtnData struct {\n\tPlainText      string `json:\"plaintext\"`\n\tStorageBackend string `json:\"storagebackend\"` // only returned for linux\n}\n\ntype CommandStreamData struct {\n\tId     string `json:\"id\"`  // streamid\n\tSeq    int64  `json:\"seq\"` // start offset (bytes)\n\tData64 string `json:\"data64,omitempty\"`\n\tEof    bool   `json:\"eof,omitempty\"`   // can be set with data or without\n\tError  string `json:\"error,omitempty\"` // stream terminated with error\n}\n\ntype CommandStreamAckData struct {\n\tId     string `json:\"id\"`               // streamid\n\tSeq    int64  `json:\"seq\"`              // next expected byte\n\tRWnd   int64  `json:\"rwnd\"`             // receive window size\n\tFin    bool   `json:\"fin,omitempty\"`    // observed end-of-stream (eof or error)\n\tDelay  int64  `json:\"delay,omitempty\"`  // ack delay in microseconds (from when data was received to when we sent out ack -- monotonic clock)\n\tCancel bool   `json:\"cancel,omitempty\"` // used to cancel the stream\n\tError  string `json:\"error,omitempty\"`  // reason for cancel (may only be set if cancel is true)\n}\n\ntype StreamMeta struct {\n\tId            string `json:\"id\"`   // streamid\n\tRWnd          int64  `json:\"rwnd\"` // initial receive window size\n\tReaderRouteId string `json:\"readerrouteid\"`\n\tWriterRouteId string `json:\"writerrouteid\"`\n}\n\ntype CommandAuthenticateToJobData struct {\n\tJobAccessToken string `json:\"jobaccesstoken\"`\n}\n\ntype CommandAuthenticateJobManagerData struct {\n\tJobId        string `json:\"jobid\"`\n\tJobAuthToken string `json:\"jobauthtoken\"`\n}\n\ntype CommandStartJobData struct {\n\tCmd        string            `json:\"cmd\"`\n\tArgs       []string          `json:\"args\"`\n\tEnv        map[string]string `json:\"env\"`\n\tTermSize   waveobj.TermSize  `json:\"termsize\"`\n\tStreamMeta *StreamMeta       `json:\"streammeta,omitempty\"`\n}\n\ntype CommandRemoteStartJobData struct {\n\tCmd                string            `json:\"cmd\"`\n\tArgs               []string          `json:\"args\"`\n\tEnv                map[string]string `json:\"env\"`\n\tTermSize           waveobj.TermSize  `json:\"termsize\"`\n\tStreamMeta         *StreamMeta       `json:\"streammeta,omitempty\"`\n\tJobAuthToken       string            `json:\"jobauthtoken\"`\n\tJobId              string            `json:\"jobid\"`\n\tMainServerJwtToken string            `json:\"mainserverjwttoken\"`\n\tClientId           string            `json:\"clientid\"`\n\tPublicKeyBase64    string            `json:\"publickeybase64\"`\n}\n\ntype CommandRemoteReconnectToJobManagerData struct {\n\tJobId              string `json:\"jobid\"`\n\tJobAuthToken       string `json:\"jobauthtoken\"`\n\tMainServerJwtToken string `json:\"mainserverjwttoken\"`\n\tJobManagerPid      int    `json:\"jobmanagerpid\"`\n\tJobManagerStartTs  int64  `json:\"jobmanagerstartts\"`\n}\n\ntype CommandRemoteReconnectToJobManagerRtnData struct {\n\tSuccess        bool   `json:\"success\"`\n\tJobManagerGone bool   `json:\"jobmanagergone\"`\n\tError          string `json:\"error,omitempty\"`\n}\n\ntype CommandRemoteDisconnectFromJobManagerData struct {\n\tJobId string `json:\"jobid\"`\n}\n\ntype CommandRemoteTerminateJobManagerData struct {\n\tJobId             string `json:\"jobid\"`\n\tJobManagerPid     int    `json:\"jobmanagerpid\"`\n\tJobManagerStartTs int64  `json:\"jobmanagerstartts\"`\n}\n\ntype CommandStartJobRtnData struct {\n\tCmdPid            int   `json:\"cmdpid\"`\n\tCmdStartTs        int64 `json:\"cmdstartts\"`\n\tJobManagerPid     int   `json:\"jobmanagerpid\"`\n\tJobManagerStartTs int64 `json:\"jobmanagerstartts\"`\n}\n\ntype CommandJobPrepareConnectData struct {\n\tStreamMeta StreamMeta       `json:\"streammeta\"`\n\tSeq        int64            `json:\"seq\"`\n\tTermSize   waveobj.TermSize `json:\"termsize\"`\n}\n\ntype CommandJobStartStreamData struct {\n}\n\ntype CommandJobConnectRtnData struct {\n\tSeq         int64  `json:\"seq\"`\n\tStreamDone  bool   `json:\"streamdone,omitempty\"`\n\tStreamError string `json:\"streamerror,omitempty\"`\n\tHasExited   bool   `json:\"hasexited,omitempty\"`\n\tExitCode    *int   `json:\"exitcode,omitempty\"`\n\tExitSignal  string `json:\"exitsignal,omitempty\"`\n\tExitErr     string `json:\"exiterr,omitempty\"`\n}\n\ntype CommandJobCmdExitedData struct {\n\tJobId      string `json:\"jobid\"`\n\tExitCode   *int   `json:\"exitcode,omitempty\"`\n\tExitSignal string `json:\"exitsignal,omitempty\"`\n\tExitErr    string `json:\"exiterr,omitempty\"`\n\tExitTs     int64  `json:\"exitts,omitempty\"`\n}\n\ntype CommandJobControllerStartJobData struct {\n\tConnName string            `json:\"connname\"`\n\tJobKind  string            `json:\"jobkind\"`\n\tCmd      string            `json:\"cmd\"`\n\tArgs     []string          `json:\"args\"`\n\tEnv      map[string]string `json:\"env\"`\n\tTermSize *waveobj.TermSize `json:\"termsize,omitempty\"`\n}\n\ntype CommandJobControllerAttachJobData struct {\n\tJobId   string `json:\"jobid\"`\n\tBlockId string `json:\"blockid\"`\n}\n\ntype JobManagerStatusUpdate struct {\n\tJobId            string `json:\"jobid\"`\n\tJobManagerStatus string `json:\"jobmanagerstatus\"`\n}\n\ntype CommandWaveFileReadStreamData struct {\n\tZoneId     string     `json:\"zoneid\"`\n\tName       string     `json:\"name\"`\n\tStreamMeta StreamMeta `json:\"streammeta\"`\n}\n\n// see blockstore.go (WaveFile)\ntype WaveFileInfo struct {\n\tZoneId    string   `json:\"zoneid\"`\n\tName      string   `json:\"name\"`\n\tOpts      FileOpts `json:\"opts\"`\n\tCreatedTs int64    `json:\"createdts\"`\n\tSize      int64    `json:\"size\"`\n\tModTs     int64    `json:\"modts\"`\n\tMeta      FileMeta `json:\"meta\"`\n}\n\ntype CommandBadgeWatchPidData struct {\n\tPid     int          `json:\"pid\"`\n\tORef    waveobj.ORef `json:\"oref\"`\n\tBadgeId string       `json:\"badgeid\"`\n}\n\ntype BlockJobStatusData struct {\n\tBlockId       string `json:\"blockid\"`\n\tJobId         string `json:\"jobid\"`\n\tStatus        string `json:\"status,omitempty\" tstype:\"null | \\\"init\\\" | \\\"connected\\\" | \\\"disconnected\\\" | \\\"done\\\"\"`\n\tVersionTs     int64  `json:\"versionts\"`\n\tDoneReason    string `json:\"donereason,omitempty\"`\n\tStartupError  string `json:\"startuperror,omitempty\"`\n\tCmdExitTs     int64  `json:\"cmdexitts,omitempty\"`\n\tCmdExitCode   *int   `json:\"cmdexitcode,omitempty\"`\n\tCmdExitSignal string `json:\"cmdexitsignal,omitempty\"`\n}\n\ntype FocusedBlockData struct {\n\tBlockId                    string              `json:\"blockid\"`\n\tViewType                   string              `json:\"viewtype\"`\n\tController                 string              `json:\"controller\"`\n\tConnName                   string              `json:\"connname\"`\n\tBlockMeta                  waveobj.MetaMapType `json:\"blockmeta\"`\n\tTermJobStatus              *BlockJobStatusData `json:\"termjobstatus,omitempty\"`\n\tConnStatus                 *ConnStatus         `json:\"connstatus,omitempty\"`\n\tTermShellIntegrationStatus string              `json:\"termshellintegrationstatus,omitempty\"`\n\tTermLastCommand            string              `json:\"termlastcommand,omitempty\"`\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshrpctypes_builder.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// builder-related types and methods for wsh rpc calls\npackage wshrpc\n\nimport (\n\t\"context\"\n)\n\ntype WshRpcBuilderInterface interface {\n\tListAllAppsCommand(ctx context.Context) ([]AppInfo, error)\n\tListAllEditableAppsCommand(ctx context.Context) ([]AppInfo, error)\n\tListAllAppFilesCommand(ctx context.Context, data CommandListAllAppFilesData) (*CommandListAllAppFilesRtnData, error)\n\tReadAppFileCommand(ctx context.Context, data CommandReadAppFileData) (*CommandReadAppFileRtnData, error)\n\tWriteAppFileCommand(ctx context.Context, data CommandWriteAppFileData) error\n\tWriteAppGoFileCommand(ctx context.Context, data CommandWriteAppGoFileData) (*CommandWriteAppGoFileRtnData, error)\n\tDeleteAppFileCommand(ctx context.Context, data CommandDeleteAppFileData) error\n\tRenameAppFileCommand(ctx context.Context, data CommandRenameAppFileData) error\n\tWriteAppSecretBindingsCommand(ctx context.Context, data CommandWriteAppSecretBindingsData) error\n\tDeleteBuilderCommand(ctx context.Context, builderId string) error\n\tStartBuilderCommand(ctx context.Context, data CommandStartBuilderData) error\n\tStopBuilderCommand(ctx context.Context, builderId string) error\n\tRestartBuilderAndWaitCommand(ctx context.Context, data CommandRestartBuilderAndWaitData) (*RestartBuilderAndWaitResult, error)\n\tGetBuilderStatusCommand(ctx context.Context, builderId string) (*BuilderStatusData, error)\n\tGetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error)\n\tCheckGoVersionCommand(ctx context.Context) (*CommandCheckGoVersionRtnData, error)\n\tPublishAppCommand(ctx context.Context, data CommandPublishAppData) (*CommandPublishAppRtnData, error)\n\tMakeDraftFromLocalCommand(ctx context.Context, data CommandMakeDraftFromLocalData) (*CommandMakeDraftFromLocalRtnData, error)\n}\n\ntype AppInfo struct {\n\tAppId    string       `json:\"appid\"`\n\tModTime  int64        `json:\"modtime\"`\n\tManifest *AppManifest `json:\"manifest,omitempty\"`\n}\n\ntype CommandListAllAppFilesData struct {\n\tAppId string `json:\"appid\"`\n}\n\ntype CommandListAllAppFilesRtnData struct {\n\tPath         string        `json:\"path\"`\n\tAbsolutePath string        `json:\"absolutepath\"`\n\tParentDir    string        `json:\"parentdir,omitempty\"`\n\tEntries      []DirEntryOut `json:\"entries\"`\n\tEntryCount   int           `json:\"entrycount\"`\n\tTotalEntries int           `json:\"totalentries\"`\n\tTruncated    bool          `json:\"truncated,omitempty\"`\n}\n\ntype DirEntryOut struct {\n\tName         string `json:\"name\"`\n\tDir          bool   `json:\"dir,omitempty\"`\n\tSymlink      bool   `json:\"symlink,omitempty\"`\n\tSize         int64  `json:\"size,omitempty\"`\n\tMode         string `json:\"mode\"`\n\tModified     string `json:\"modified\"`\n\tModifiedTime string `json:\"modifiedtime\"`\n}\n\ntype CommandReadAppFileData struct {\n\tAppId    string `json:\"appid\"`\n\tFileName string `json:\"filename\"`\n}\n\ntype CommandReadAppFileRtnData struct {\n\tData64   string `json:\"data64\"`\n\tNotFound bool   `json:\"notfound,omitempty\"`\n\tModTs    int64  `json:\"modts,omitempty\"`\n}\n\ntype CommandWriteAppFileData struct {\n\tAppId    string `json:\"appid\"`\n\tFileName string `json:\"filename\"`\n\tData64   string `json:\"data64\"`\n}\n\ntype CommandWriteAppGoFileData struct {\n\tAppId  string `json:\"appid\"`\n\tData64 string `json:\"data64\"`\n}\n\ntype CommandWriteAppGoFileRtnData struct {\n\tData64 string `json:\"data64\"`\n}\n\ntype CommandDeleteAppFileData struct {\n\tAppId    string `json:\"appid\"`\n\tFileName string `json:\"filename\"`\n}\n\ntype CommandRenameAppFileData struct {\n\tAppId        string `json:\"appid\"`\n\tFromFileName string `json:\"fromfilename\"`\n\tToFileName   string `json:\"tofilename\"`\n}\n\ntype CommandWriteAppSecretBindingsData struct {\n\tAppId    string            `json:\"appid\"`\n\tBindings map[string]string `json:\"bindings\"`\n}\n\ntype CommandStartBuilderData struct {\n\tBuilderId string `json:\"builderid\"`\n}\n\ntype CommandRestartBuilderAndWaitData struct {\n\tBuilderId string `json:\"builderid\"`\n}\n\ntype RestartBuilderAndWaitResult struct {\n\tSuccess      bool   `json:\"success\"`\n\tErrorMessage string `json:\"errormessage,omitempty\"`\n\tBuildOutput  string `json:\"buildoutput\"`\n}\n\ntype AppMeta struct {\n\tTitle     string `json:\"title\"`\n\tShortDesc string `json:\"shortdesc\"`\n\tIcon      string `json:\"icon\"`\n\tIconColor string `json:\"iconcolor\"`\n}\n\ntype SecretMeta struct {\n\tDesc     string `json:\"desc\"`\n\tOptional bool   `json:\"optional\"`\n}\n\ntype AppManifest struct {\n\tAppMeta      AppMeta               `json:\"appmeta\"`\n\tConfigSchema map[string]any        `json:\"configschema\"`\n\tDataSchema   map[string]any        `json:\"dataschema\"`\n\tSecrets      map[string]SecretMeta `json:\"secrets\"`\n}\n\ntype BuilderStatusData struct {\n\tStatus                 string            `json:\"status\"`\n\tPort                   int               `json:\"port,omitempty\"`\n\tExitCode               int               `json:\"exitcode,omitempty\"`\n\tErrorMsg               string            `json:\"errormsg,omitempty\"`\n\tVersion                int               `json:\"version\"`\n\tManifest               *AppManifest      `json:\"manifest,omitempty\"`\n\tSecretBindings         map[string]string `json:\"secretbindings,omitempty\"`\n\tSecretBindingsComplete bool              `json:\"secretbindingscomplete\"`\n}\n\ntype CommandCheckGoVersionRtnData struct {\n\tGoStatus    string `json:\"gostatus\"`\n\tGoPath      string `json:\"gopath\"`\n\tGoVersion   string `json:\"goversion\"`\n\tErrorString string `json:\"errorstring,omitempty\"`\n}\n\ntype CommandPublishAppData struct {\n\tAppId string `json:\"appid\"`\n}\n\ntype CommandPublishAppRtnData struct {\n\tPublishedAppId string `json:\"publishedappid\"`\n}\n\ntype CommandMakeDraftFromLocalData struct {\n\tLocalAppId string `json:\"localappid\"`\n}\n\ntype CommandMakeDraftFromLocalRtnData struct {\n\tDraftAppId string `json:\"draftappid\"`\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshrpctypes_const.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// types and methods for wsh rpc calls\npackage wshrpc\n\nconst (\n\t// MaxFileSize is the maximum file size that can be read\n\tMaxFileSize = 50 * 1024 * 1024 // 50M\n\t// MaxDirSize is the maximum number of entries that can be read in a directory\n\tMaxDirSize = 1024\n\t// FileChunkSize is the size of the file chunk to read\n\tFileChunkSize = 64 * 1024\n\t// DirChunkSize is the size of the directory chunk to read\n\tDirChunkSize = 128\n)\n\nconst LocalConnName = \"local\"\n\nconst (\n\tRpcType_Call             = \"call\"             // single response (regular rpc)\n\tRpcType_ResponseStream   = \"responsestream\"   // stream of responses (streaming rpc)\n\tRpcType_StreamingRequest = \"streamingrequest\" // streaming request\n\tRpcType_Complex          = \"complex\"          // streaming request/response\n)\n\nconst (\n\tCreateBlockAction_Replace    = \"replace\"\n\tCreateBlockAction_SplitUp    = \"splitup\"\n\tCreateBlockAction_SplitDown  = \"splitdown\"\n\tCreateBlockAction_SplitLeft  = \"splitleft\"\n\tCreateBlockAction_SplitRight = \"splitright\"\n)\n\n// we only need consts for special commands handled in the router or\n// in the RPC code / WPS code directly.  other commands go through the clients\nconst (\n\tCommand_Authenticate                 = \"authenticate\"                 // $control\n\tCommand_AuthenticateToken            = \"authenticatetoken\"            // $control\n\tCommand_AuthenticateTokenVerify      = \"authenticatetokenverify\"      // $control:root (internal, for token validation only)\n\tCommand_AuthenticateJobManagerVerify = \"authenticatejobmanagerverify\" // $control:root (internal, for job auth token validation only)\n\tCommand_RouteAnnounce                = \"routeannounce\"                // $control (for routing)\n\tCommand_RouteUnannounce              = \"routeunannounce\"              // $control (for routing)\n\tCommand_Ping                         = \"ping\"                         // $control\n\tCommand_ControllerInput              = \"controllerinput\"\n\tCommand_EventRecv                    = \"eventrecv\"\n\tCommand_Message                      = \"message\"\n\tCommand_StreamData                   = \"streamdata\"\n\tCommand_StreamDataAck                = \"streamdataack\"\n)\n"
  },
  {
    "path": "pkg/wshrpc/wshrpctypes_file.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// file-related types and methods for wsh rpc calls\npackage wshrpc\n\nimport (\n\t\"context\"\n\t\"os\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/ijson\"\n)\n\ntype WshRpcFileInterface interface {\n\tFileMkdirCommand(ctx context.Context, data FileData) error\n\tFileCreateCommand(ctx context.Context, data FileData) error\n\tFileDeleteCommand(ctx context.Context, data CommandDeleteFileData) error\n\tFileAppendCommand(ctx context.Context, data FileData) error\n\tFileWriteCommand(ctx context.Context, data FileData) error\n\tFileReadCommand(ctx context.Context, data FileData) (*FileData, error)\n\tFileReadStreamCommand(ctx context.Context, data FileData) <-chan RespOrErrorUnion[FileData]\n\tFileMoveCommand(ctx context.Context, data CommandFileCopyData) error\n\tFileCopyCommand(ctx context.Context, data CommandFileCopyData) error\n\tFileInfoCommand(ctx context.Context, data FileData) (*FileInfo, error)\n\tFileListCommand(ctx context.Context, data FileListData) ([]*FileInfo, error)\n\tFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error)\n\tFileListStreamCommand(ctx context.Context, data FileListData) <-chan RespOrErrorUnion[CommandRemoteListEntriesRtnData]\n\t// modern streaming interface\n\tFileStreamCommand(ctx context.Context, data CommandFileStreamData) (*FileInfo, error)\n}\n\ntype WshRpcRemoteFileInterface interface {\n\t// old streaming inferface\n\tRemoteStreamFileCommand(ctx context.Context, data CommandRemoteStreamFileData) chan RespOrErrorUnion[FileData]\n\n\t// modern streaming interface\n\tRemoteFileStreamCommand(ctx context.Context, data CommandRemoteFileStreamData) (*FileInfo, error)\n\n\tRemoteFileCopyCommand(ctx context.Context, data CommandFileCopyData) (bool, error)\n\tRemoteListEntriesCommand(ctx context.Context, data CommandRemoteListEntriesData) chan RespOrErrorUnion[CommandRemoteListEntriesRtnData]\n\tRemoteFileInfoCommand(ctx context.Context, path string) (*FileInfo, error)\n\tRemoteFileMultiInfoCommand(ctx context.Context, data CommandRemoteFileMultiInfoData) (map[string]FileInfo, error)\n\tRemoteFileTouchCommand(ctx context.Context, path string) error\n\tRemoteFileMoveCommand(ctx context.Context, data CommandFileCopyData) error\n\tRemoteFileDeleteCommand(ctx context.Context, data CommandDeleteFileData) error\n\tRemoteWriteFileCommand(ctx context.Context, data FileData) error\n\tRemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error)\n\tRemoteMkdirCommand(ctx context.Context, path string) error\n}\n\ntype FileDataAt struct {\n\tOffset int64 `json:\"offset\"`\n\tSize   int   `json:\"size,omitempty\"`\n}\n\ntype FileData struct {\n\tInfo    *FileInfo   `json:\"info,omitempty\"`\n\tData64  string      `json:\"data64,omitempty\"`\n\tEntries []*FileInfo `json:\"entries,omitempty\"`\n\tAt      *FileDataAt `json:\"at,omitempty\"` // if set, this turns read/write ops to ReadAt/WriteAt ops (len is only used for ReadAt)\n}\n\ntype FileInfo struct {\n\tPath          string      `json:\"path\"`          // cleaned path (may have \"~\")\n\tDir           string      `json:\"dir,omitempty\"` // returns the directory part of the path (if this is a a directory, it will be equal to Path).  \"~\" will be expanded, and separators will be normalized to \"/\"\n\tName          string      `json:\"name,omitempty\"`\n\tStatError     string      `json:\"staterror,omitempty\"`\n\tNotFound      bool        `json:\"notfound,omitempty\"`\n\tOpts          *FileOpts   `json:\"opts,omitempty\"`\n\tSize          int64       `json:\"size,omitempty\"`\n\tMeta          *FileMeta   `json:\"meta,omitempty\"`\n\tMode          os.FileMode `json:\"mode,omitempty\"`\n\tModeStr       string      `json:\"modestr,omitempty\"`\n\tModTime       int64       `json:\"modtime,omitempty\"`\n\tIsDir         bool        `json:\"isdir,omitempty\"`\n\tSupportsMkdir bool        `json:\"supportsmkdir,omitempty\"`\n\tMimeType      string      `json:\"mimetype,omitempty\"`\n\tReadOnly      bool        `json:\"readonly,omitempty\"` // this is not set for fileinfo's returned from directory listings\n}\n\ntype FileOpts struct {\n\tMaxSize     int64 `json:\"maxsize,omitempty\"`\n\tCircular    bool  `json:\"circular,omitempty\"`\n\tIJson       bool  `json:\"ijson,omitempty\"`\n\tIJsonBudget int   `json:\"ijsonbudget,omitempty\"`\n\tTruncate    bool  `json:\"truncate,omitempty\"`\n\tAppend      bool  `json:\"append,omitempty\"`\n}\n\ntype FileMeta = map[string]any\n\ntype FileListStreamResponse <-chan RespOrErrorUnion[CommandRemoteListEntriesRtnData]\n\ntype FileListData struct {\n\tPath string        `json:\"path\"`\n\tOpts *FileListOpts `json:\"opts,omitempty\"`\n}\n\ntype FileListOpts struct {\n\tAll    bool `json:\"all,omitempty\"`\n\tOffset int  `json:\"offset,omitempty\"`\n\tLimit  int  `json:\"limit,omitempty\"`\n}\n\ntype FileCreateData struct {\n\tPath string         `json:\"path\"`\n\tMeta map[string]any `json:\"meta,omitempty\"`\n\tOpts *FileOpts      `json:\"opts,omitempty\"`\n}\n\ntype CommandAppendIJsonData struct {\n\tZoneId   string        `json:\"zoneid\"`\n\tFileName string        `json:\"filename\"`\n\tData     ijson.Command `json:\"data\"`\n}\n\ntype CommandDeleteFileData struct {\n\tPath      string `json:\"path\"`\n\tRecursive bool   `json:\"recursive\"`\n}\n\ntype CommandFileCopyData struct {\n\tSrcUri  string        `json:\"srcuri\"`\n\tDestUri string        `json:\"desturi\"`\n\tOpts    *FileCopyOpts `json:\"opts,omitempty\"`\n}\n\ntype FileCopyOpts struct {\n\tOverwrite bool  `json:\"overwrite,omitempty\"`\n\tRecursive bool  `json:\"recursive,omitempty\"` // only used for move, always true for copy\n\tMerge     bool  `json:\"merge,omitempty\"`\n\tTimeout   int64 `json:\"timeout,omitempty\"`\n}\n\ntype CommandRemoteStreamFileData struct {\n\tPath      string `json:\"path\"`\n\tByteRange string `json:\"byterange,omitempty\"`\n}\n\ntype CommandRemoteFileStreamData struct {\n\tPath       string     `json:\"path\"`\n\tByteRange  string     `json:\"byterange,omitempty\"`\n\tStreamMeta StreamMeta `json:\"streammeta\"`\n}\n\ntype CommandFileStreamData struct {\n\tInfo       *FileInfo  `json:\"info\"`\n\tByteRange  string     `json:\"byterange,omitempty\"`\n\tStreamMeta StreamMeta `json:\"streammeta\"`\n}\n\ntype CommandRemoteListEntriesData struct {\n\tPath string        `json:\"path\"`\n\tOpts *FileListOpts `json:\"opts,omitempty\"`\n}\n\ntype CommandRemoteFileMultiInfoData struct {\n\tCwd   string   `json:\"cwd\"`\n\tPaths []string `json:\"paths\"`\n}\n\ntype CommandRemoteListEntriesRtnData struct {\n\tFileInfo []*FileInfo `json:\"fileinfo,omitempty\"`\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshserver/resolvers.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshserver\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\nconst (\n\tSimpleId_This      = \"this\"\n\tSimpleId_Block     = \"block\"\n\tSimpleId_Tab       = \"tab\"\n\tSimpleId_Ws        = \"ws\"\n\tSimpleId_Workspace = \"workspace\"\n\tSimpleId_Client    = \"client\"\n\tSimpleId_Global    = \"global\"\n\tSimpleId_Temp      = \"temp\"\n)\n\nvar (\n\tsimpleTabNumRe = regexp.MustCompile(`^tab:(\\d{1,3})$`)\n\tshortUUIDRe    = regexp.MustCompile(`^[0-9a-f]{8}$`)\n\tviewBlockRe    = regexp.MustCompile(`^([a-z]+)(?::(\\d+))?$`) // Matches \"ai\" or \"ai:2\"\n)\n\n// First function: detect/choose discriminator\nfunc parseSimpleId(simpleId string) (discriminator string, value string, err error) {\n\t// Check for explicit discriminator with @\n\tif parts := strings.SplitN(simpleId, \"@\", 2); len(parts) == 2 {\n\t\treturn parts[0], parts[1], nil\n\t}\n\n\t// Handle special keywords\n\tif simpleId == SimpleId_This || simpleId == SimpleId_Block || simpleId == SimpleId_Tab ||\n\t\tsimpleId == SimpleId_Ws || simpleId == SimpleId_Workspace ||\n\t\tsimpleId == SimpleId_Client || simpleId == SimpleId_Global || simpleId == SimpleId_Temp {\n\t\treturn \"this\", simpleId, nil\n\t}\n\n\t// Check if it's a simple ORef (type:uuid)\n\tif _, err := waveobj.ParseORef(simpleId); err == nil {\n\t\treturn \"oref\", simpleId, nil\n\t}\n\n\t// Check for tab:N format\n\tif simpleTabNumRe.MatchString(simpleId) {\n\t\treturn \"tabnum\", simpleId, nil\n\t}\n\n\t// check for [view]:N format\n\tif viewBlockRe.MatchString(simpleId) {\n\t\treturn \"view\", simpleId, nil\n\t}\n\n\t// Check for plain number (block reference)\n\tif _, err := strconv.Atoi(simpleId); err == nil {\n\t\treturn \"blocknum\", simpleId, nil\n\t}\n\n\t// Check for UUIDs\n\tif _, err := uuid.Parse(simpleId); err == nil {\n\t\treturn \"uuid\", simpleId, nil\n\t}\n\tif shortUUIDRe.MatchString(strings.ToLower(simpleId)) {\n\t\treturn \"uuid8\", simpleId, nil\n\t}\n\n\treturn \"\", \"\", fmt.Errorf(\"invalid simple id format: %s\", simpleId)\n}\n\n// Individual resolvers\nfunc resolveThis(ctx context.Context, data wshrpc.CommandResolveIdsData, value string) (*waveobj.ORef, error) {\n\tif data.BlockId == \"\" {\n\t\treturn nil, fmt.Errorf(\"no blockid in request\")\n\t}\n\n\tif value == SimpleId_This || value == SimpleId_Block {\n\t\treturn &waveobj.ORef{OType: waveobj.OType_Block, OID: data.BlockId}, nil\n\t}\n\tif value == SimpleId_Tab {\n\t\ttabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error finding tab: %v\", err)\n\t\t}\n\t\treturn &waveobj.ORef{OType: waveobj.OType_Tab, OID: tabId}, nil\n\t}\n\tif value == SimpleId_Ws || value == SimpleId_Workspace {\n\t\ttabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error finding tab: %v\", err)\n\t\t}\n\t\twsId, err := wstore.DBFindWorkspaceForTabId(ctx, tabId)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error finding workspace: %v\", err)\n\t\t}\n\t\treturn &waveobj.ORef{OType: waveobj.OType_Workspace, OID: wsId}, nil\n\t}\n\tif value == SimpleId_Client || value == SimpleId_Global {\n\t\tclientId := wstore.GetClientId()\n\t\treturn &waveobj.ORef{OType: waveobj.OType_Client, OID: clientId}, nil\n\t}\n\tif value == SimpleId_Temp {\n\t\tclient, err := wstore.DBGetSingleton[*waveobj.Client](ctx)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error getting client: %v\", err)\n\t\t}\n\t\treturn &waveobj.ORef{OType: \"temp\", OID: client.TempOID}, nil\n\t}\n\treturn nil, fmt.Errorf(\"invalid value for 'this' resolver: %s\", value)\n}\n\nfunc resolveORef(_ context.Context, value string) (*waveobj.ORef, error) {\n\tparsedORef, err := waveobj.ParseORef(value)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error parsing oref: %v\", err)\n\t}\n\treturn &parsedORef, nil\n}\n\nfunc resolveTabNum(ctx context.Context, data wshrpc.CommandResolveIdsData, value string) (*waveobj.ORef, error) {\n\tm := simpleTabNumRe.FindStringSubmatch(value)\n\tif m == nil {\n\t\treturn nil, fmt.Errorf(\"error parsing simple tab id: %s\", value)\n\t}\n\n\ttabNum, err := strconv.Atoi(m[1])\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error parsing simple tab num: %v\", err)\n\t}\n\n\tcurTabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error finding tab for block: %v\", err)\n\t}\n\n\twsId, err := wstore.DBFindWorkspaceForTabId(ctx, curTabId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error finding current workspace: %v\", err)\n\t}\n\n\tws, err := wstore.DBMustGet[*waveobj.Workspace](ctx, wsId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting workspace: %v\", err)\n\t}\n\n\tnumTabs := len(ws.TabIds)\n\tif tabNum < 1 || tabNum > numTabs {\n\t\treturn nil, fmt.Errorf(\"tab num out of range, workspace has %d tabs\", numTabs)\n\t}\n\n\ttabIdx := tabNum - 1\n\tresolvedTabId := ws.TabIds[tabIdx]\n\treturn &waveobj.ORef{OType: waveobj.OType_Tab, OID: resolvedTabId}, nil\n}\n\nfunc resolveBlock(ctx context.Context, data wshrpc.CommandResolveIdsData, value string) (*waveobj.ORef, error) {\n\tblockNum, err := strconv.Atoi(value)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error parsing block number: %v\", err)\n\t}\n\n\ttabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error finding tab for blockid %s: %w\", data.BlockId, err)\n\t}\n\n\ttab, err := wstore.DBGet[*waveobj.Tab](ctx, tabId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error retrieving tab %s: %w\", tabId, err)\n\t}\n\n\tlayout, err := wstore.DBGet[*waveobj.LayoutState](ctx, tab.LayoutState)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error retrieving layout state %s: %w\", tab.LayoutState, err)\n\t}\n\n\tif layout.LeafOrder == nil {\n\t\treturn nil, fmt.Errorf(\"could not resolve block num %v, leaf order is empty\", blockNum)\n\t}\n\n\tleafIndex := blockNum - 1 // block nums are 1-indexed\n\tif len(*layout.LeafOrder) <= leafIndex {\n\t\treturn nil, fmt.Errorf(\"could not find a node in the layout matching blockNum %v\", blockNum)\n\t}\n\n\tleafEntry := (*layout.LeafOrder)[leafIndex]\n\treturn &waveobj.ORef{OType: waveobj.OType_Block, OID: leafEntry.BlockId}, nil\n}\n\nfunc resolveView(ctx context.Context, data wshrpc.CommandResolveIdsData, value string) (*waveobj.ORef, error) {\n\tmatches := viewBlockRe.FindStringSubmatch(value)\n\tif matches == nil {\n\t\treturn nil, fmt.Errorf(\"invalid view format: %s\", value)\n\t}\n\n\t// Default to first instance if no number specified\n\tviewType := matches[1]\n\tinstanceNum := 1\n\tif matches[2] != \"\" {\n\t\tnum, err := strconv.Atoi(matches[2])\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid view instance number: %v\", err)\n\t\t}\n\t\tinstanceNum = num\n\t}\n\tif instanceNum < 1 {\n\t\treturn nil, fmt.Errorf(\"invalid view instance number: %d\", instanceNum)\n\t}\n\t// Get current tab\n\ttabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error finding tab: %v\", err)\n\t}\n\ttab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error retrieving tab: %v\", err)\n\t}\n\tlayout, err := wstore.DBMustGet[*waveobj.LayoutState](ctx, tab.LayoutState)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error retrieving layout: %v\", err)\n\t}\n\tif layout.LeafOrder == nil {\n\t\treturn nil, fmt.Errorf(\"no blocks in layout\")\n\t}\n\t// Find nth instance of view type\n\tcount := 0\n\tfor _, leaf := range *layout.LeafOrder {\n\t\tleafBlockId := leaf.BlockId\n\t\tleafBlock, err := wstore.DBMustGet[*waveobj.Block](ctx, leafBlockId)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif leafBlock.Meta.GetString(\"view\", \"\") == viewType {\n\t\t\tcount++\n\t\t\tif count == instanceNum {\n\t\t\t\treturn &waveobj.ORef{OType: waveobj.OType_Block, OID: leaf.BlockId}, nil\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, fmt.Errorf(\"could not find block %d of type %s (found %d)\", instanceNum, viewType, count)\n}\n\nfunc resolveUUID(ctx context.Context, value string) (*waveobj.ORef, error) {\n\treturn wstore.DBResolveEasyOID(ctx, value)\n}\n\n// Main resolver function\nfunc resolveSimpleId(ctx context.Context, data wshrpc.CommandResolveIdsData, simpleId string) (*waveobj.ORef, error) {\n\tdiscriminator, value, err := parseSimpleId(simpleId)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tswitch discriminator {\n\tcase \"this\":\n\t\treturn resolveThis(ctx, data, value)\n\tcase \"oref\":\n\t\treturn resolveORef(ctx, value)\n\tcase \"tabnum\":\n\t\treturn resolveTabNum(ctx, data, value)\n\tcase \"blocknum\":\n\t\treturn resolveBlock(ctx, data, value)\n\tcase \"view\":\n\t\treturn resolveView(ctx, data, value)\n\tcase \"uuid\", \"uuid8\":\n\t\treturn resolveUUID(ctx, value)\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unknown discriminator: %s\", discriminator)\n\t}\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshserver/wshserver.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshserver\n\n// this file contains the implementation of the wsh server methods\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/skratchdot/open-golang/open\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes\"\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/blockcontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/blocklogger\"\n\t\"github.com/wavetermdev/waveterm/pkg/buildercontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/filebackup\"\n\t\"github.com/wavetermdev/waveterm/pkg/filestore\"\n\t\"github.com/wavetermdev/waveterm/pkg/genconn\"\n\t\"github.com/wavetermdev/waveterm/pkg/jobcontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/conncontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs\"\n\t\"github.com/wavetermdev/waveterm/pkg/secretstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/suggestion\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/envutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveai\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveappstore\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveapputil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavejwt\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcloud\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wcore\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wsl\"\n\t\"github.com/wavetermdev/waveterm/pkg/wslconn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n\t\"github.com/wavetermdev/waveterm/tsunami/build\"\n)\n\nvar InvalidWslDistroNames = []string{\"docker-desktop\", \"docker-desktop-data\"}\n\ntype WshServer struct{}\n\nfunc (*WshServer) WshServerImpl() {}\n\nvar WshServerImpl = WshServer{}\n\nfunc (ws *WshServer) GetJwtPublicKeyCommand(ctx context.Context) (string, error) {\n\treturn wavejwt.GetPublicKeyBase64(), nil\n}\n\nfunc (ws *WshServer) TestCommand(ctx context.Context, data string) error {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"TestCommand\", recover())\n\t}()\n\trpcSource := wshutil.GetRpcSourceFromContext(ctx)\n\tlog.Printf(\"TEST src:%s | %s\\n\", rpcSource, data)\n\treturn nil\n}\n\nfunc (ws *WshServer) TestMultiArgCommand(ctx context.Context, arg1 string, arg2 int, arg3 bool) (string, error) {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"TestMultiArgCommand\", recover())\n\t}()\n\trpcSource := wshutil.GetRpcSourceFromContext(ctx)\n\trtn := fmt.Sprintf(\"src:%s arg1:%q arg2:%d arg3:%t\", rpcSource, arg1, arg2, arg3)\n\tlog.Printf(\"TESTMULTI %s\\n\", rtn)\n\treturn rtn, nil\n}\n\n// for testing\nfunc (ws *WshServer) MessageCommand(ctx context.Context, data wshrpc.CommandMessageData) error {\n\tlog.Printf(\"MESSAGE: %s\\n\", data.Message)\n\treturn nil\n}\n\n// for testing\nfunc (ws *WshServer) StreamTestCommand(ctx context.Context) chan wshrpc.RespOrErrorUnion[int] {\n\trtn := make(chan wshrpc.RespOrErrorUnion[int])\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"StreamTestCommand\", recover())\n\t\t}()\n\t\tfor i := 1; i <= 5; i++ {\n\t\t\trtn <- wshrpc.RespOrErrorUnion[int]{Response: i}\n\t\t\ttime.Sleep(1 * time.Second)\n\t\t}\n\t\tclose(rtn)\n\t}()\n\treturn rtn\n}\n\nfunc (ws *WshServer) StreamWaveAiCommand(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] {\n\treturn waveai.RunAICommand(ctx, request)\n}\n\nfunc MakePlotData(ctx context.Context, blockId string) error {\n\tblock, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)\n\tif err != nil {\n\t\treturn err\n\t}\n\tviewName := block.Meta.GetString(waveobj.MetaKey_View, \"\")\n\tif viewName != \"cpuplot\" && viewName != \"sysinfo\" {\n\t\treturn fmt.Errorf(\"invalid view type: %s\", viewName)\n\t}\n\treturn filestore.WFS.MakeFile(ctx, blockId, \"cpuplotdata\", nil, wshrpc.FileOpts{})\n}\n\nfunc SavePlotData(ctx context.Context, blockId string, history string) error {\n\tblock, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)\n\tif err != nil {\n\t\treturn err\n\t}\n\tviewName := block.Meta.GetString(waveobj.MetaKey_View, \"\")\n\tif viewName != \"cpuplot\" && viewName != \"sysinfo\" {\n\t\treturn fmt.Errorf(\"invalid view type: %s\", viewName)\n\t}\n\t// todo: interpret the data being passed\n\t// for now, this is just to throw an error if the block was closed\n\thistoryBytes, err := json.Marshal(history)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to serialize plot data: %v\", err)\n\t}\n\t// ignore MakeFile error (already exists is ok)\n\treturn filestore.WFS.WriteFile(ctx, blockId, \"cpuplotdata\", historyBytes)\n}\n\nfunc (ws *WshServer) GetMetaCommand(ctx context.Context, data wshrpc.CommandGetMetaData) (waveobj.MetaMapType, error) {\n\tobj, err := wstore.DBGetORef(ctx, data.ORef)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting object: %w\", err)\n\t}\n\tif obj == nil {\n\t\treturn nil, fmt.Errorf(\"object not found: %s\", data.ORef)\n\t}\n\treturn waveobj.GetMeta(obj), nil\n}\n\nfunc (ws *WshServer) UpdateTabNameCommand(ctx context.Context, tabId string, newName string) error {\n\toref := waveobj.ORef{OType: waveobj.OType_Tab, OID: tabId}\n\terr := wstore.UpdateTabName(ctx, tabId, newName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error updating tab name: %w\", err)\n\t}\n\twcore.SendWaveObjUpdate(oref)\n\treturn nil\n}\n\nfunc (ws *WshServer) UpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error {\n\toref := waveobj.ORef{OType: waveobj.OType_Workspace, OID: workspaceId}\n\terr := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error updating workspace tab ids: %w\", err)\n\t}\n\twcore.SendWaveObjUpdate(oref)\n\treturn nil\n}\n\nfunc (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error {\n\tlog.Printf(\"SetMetaCommand: %s | %v\\n\", data.ORef, data.Meta)\n\toref := data.ORef\n\terr := wstore.UpdateObjectMeta(ctx, oref, data.Meta, false)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error updating object meta: %w\", err)\n\t}\n\twcore.SendWaveObjUpdate(oref)\n\treturn nil\n}\n\nfunc (ws *WshServer) GetRTInfoCommand(ctx context.Context, data wshrpc.CommandGetRTInfoData) (*waveobj.ObjRTInfo, error) {\n\treturn wstore.GetRTInfo(data.ORef), nil\n}\n\nfunc (ws *WshServer) SetRTInfoCommand(ctx context.Context, data wshrpc.CommandSetRTInfoData) error {\n\tif data.Delete {\n\t\twstore.DeleteRTInfo(data.ORef)\n\t\treturn nil\n\t}\n\twstore.SetRTInfo(data.ORef, data.Data)\n\treturn nil\n}\n\nfunc (ws *WshServer) ResolveIdsCommand(ctx context.Context, data wshrpc.CommandResolveIdsData) (wshrpc.CommandResolveIdsRtnData, error) {\n\trtn := wshrpc.CommandResolveIdsRtnData{}\n\trtn.ResolvedIds = make(map[string]waveobj.ORef)\n\tvar firstErr error\n\tfor _, simpleId := range data.Ids {\n\t\toref, err := resolveSimpleId(ctx, data, simpleId)\n\t\tif err != nil {\n\t\t\tif firstErr == nil {\n\t\t\t\tfirstErr = err\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif oref == nil {\n\t\t\tcontinue\n\t\t}\n\t\trtn.ResolvedIds[simpleId] = *oref\n\t}\n\tif firstErr != nil && len(data.Ids) == 1 {\n\t\treturn rtn, firstErr\n\t}\n\treturn rtn, nil\n}\n\nfunc (ws *WshServer) CreateBlockCommand(ctx context.Context, data wshrpc.CommandCreateBlockData) (*waveobj.ORef, error) {\n\tctx = waveobj.ContextWithUpdates(ctx)\n\ttabId := data.TabId\n\tblockData, err := wcore.CreateBlock(ctx, tabId, data.BlockDef, data.RtOpts)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating block: %w\", err)\n\t}\n\tvar layoutAction *waveobj.LayoutActionData\n\tif data.TargetBlockId != \"\" {\n\t\tswitch data.TargetAction {\n\t\tcase \"replace\":\n\t\t\tlayoutAction = &waveobj.LayoutActionData{\n\t\t\t\tActionType:    wcore.LayoutActionDataType_Replace,\n\t\t\t\tTargetBlockId: data.TargetBlockId,\n\t\t\t\tBlockId:       blockData.OID,\n\t\t\t\tFocused:       data.Focused,\n\t\t\t}\n\t\t\terr = wcore.DeleteBlock(ctx, data.TargetBlockId, false)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error deleting block (trying to do block replace): %w\", err)\n\t\t\t}\n\t\tcase \"splitright\":\n\t\t\tlayoutAction = &waveobj.LayoutActionData{\n\t\t\t\tActionType:    wcore.LayoutActionDataType_SplitHorizontal,\n\t\t\t\tBlockId:       blockData.OID,\n\t\t\t\tTargetBlockId: data.TargetBlockId,\n\t\t\t\tPosition:      \"after\",\n\t\t\t\tFocused:       data.Focused,\n\t\t\t}\n\t\tcase \"splitleft\":\n\t\t\tlayoutAction = &waveobj.LayoutActionData{\n\t\t\t\tActionType:    wcore.LayoutActionDataType_SplitHorizontal,\n\t\t\t\tBlockId:       blockData.OID,\n\t\t\t\tTargetBlockId: data.TargetBlockId,\n\t\t\t\tPosition:      \"before\",\n\t\t\t\tFocused:       data.Focused,\n\t\t\t}\n\t\tcase \"splitup\":\n\t\t\tlayoutAction = &waveobj.LayoutActionData{\n\t\t\t\tActionType:    wcore.LayoutActionDataType_SplitVertical,\n\t\t\t\tBlockId:       blockData.OID,\n\t\t\t\tTargetBlockId: data.TargetBlockId,\n\t\t\t\tPosition:      \"before\",\n\t\t\t\tFocused:       data.Focused,\n\t\t\t}\n\t\tcase \"splitdown\":\n\t\t\tlayoutAction = &waveobj.LayoutActionData{\n\t\t\t\tActionType:    wcore.LayoutActionDataType_SplitVertical,\n\t\t\t\tBlockId:       blockData.OID,\n\t\t\t\tTargetBlockId: data.TargetBlockId,\n\t\t\t\tPosition:      \"after\",\n\t\t\t\tFocused:       data.Focused,\n\t\t\t}\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"invalid target action: %s\", data.TargetAction)\n\t\t}\n\t} else {\n\t\tlayoutAction = &waveobj.LayoutActionData{\n\t\t\tActionType: wcore.LayoutActionDataType_Insert,\n\t\t\tBlockId:    blockData.OID,\n\t\t\tMagnified:  data.Magnified,\n\t\t\tEphemeral:  data.Ephemeral,\n\t\t\tFocused:    data.Focused,\n\t\t}\n\t}\n\terr = wcore.QueueLayoutActionForTab(ctx, tabId, *layoutAction)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error queuing layout action: %w\", err)\n\t}\n\tupdates := waveobj.ContextGetUpdatesRtn(ctx)\n\twps.Broker.SendUpdateEvents(updates)\n\treturn &waveobj.ORef{OType: waveobj.OType_Block, OID: blockData.OID}, nil\n}\n\nfunc (ws *WshServer) CreateSubBlockCommand(ctx context.Context, data wshrpc.CommandCreateSubBlockData) (*waveobj.ORef, error) {\n\tparentBlockId := data.ParentBlockId\n\tblockData, err := wcore.CreateSubBlock(ctx, parentBlockId, data.BlockDef)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating block: %w\", err)\n\t}\n\tblockRef := &waveobj.ORef{OType: waveobj.OType_Block, OID: blockData.OID}\n\treturn blockRef, nil\n}\n\nfunc (ws *WshServer) ControllerDestroyCommand(ctx context.Context, blockId string) error {\n\tblockcontroller.DestroyBlockController(blockId)\n\treturn nil\n}\n\nfunc (ws *WshServer) ControllerResyncCommand(ctx context.Context, data wshrpc.CommandControllerResyncData) error {\n\tctx = genconn.ContextWithConnData(ctx, data.BlockId)\n\tctx = termCtxWithLogBlockId(ctx, data.BlockId)\n\treturn blockcontroller.ResyncController(ctx, data.TabId, data.BlockId, data.RtOpts, data.ForceRestart)\n}\n\nfunc (ws *WshServer) ControllerInputCommand(ctx context.Context, data wshrpc.CommandBlockInputData) error {\n\tinputUnion := &blockcontroller.BlockInputUnion{\n\t\tSigName:  data.SigName,\n\t\tTermSize: data.TermSize,\n\t}\n\tif len(data.InputData64) > 0 {\n\t\tinputBuf := make([]byte, base64.StdEncoding.DecodedLen(len(data.InputData64)))\n\t\tnw, err := base64.StdEncoding.Decode(inputBuf, []byte(data.InputData64))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error decoding input data: %w\", err)\n\t\t}\n\t\tinputUnion.InputData = inputBuf[:nw]\n\t}\n\treturn blockcontroller.SendInput(data.BlockId, inputUnion)\n}\n\nfunc (ws *WshServer) ControllerAppendOutputCommand(ctx context.Context, data wshrpc.CommandControllerAppendOutputData) error {\n\toutputBuf := make([]byte, base64.StdEncoding.DecodedLen(len(data.Data64)))\n\tnw, err := base64.StdEncoding.Decode(outputBuf, []byte(data.Data64))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error decoding output data: %w\", err)\n\t}\n\terr = blockcontroller.HandleAppendBlockFile(data.BlockId, wavebase.BlockFile_Term, outputBuf[:nw])\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error appending to block file: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (ws *WshServer) FileCreateCommand(ctx context.Context, data wshrpc.FileData) error {\n\tdata.Data64 = \"\"\n\terr := wshfs.PutFile(ctx, data)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error creating file: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (ws *WshServer) FileMkdirCommand(ctx context.Context, data wshrpc.FileData) error {\n\treturn wshfs.Mkdir(ctx, data.Info.Path)\n}\n\nfunc (ws *WshServer) FileDeleteCommand(ctx context.Context, data wshrpc.CommandDeleteFileData) error {\n\treturn wshfs.Delete(ctx, data)\n}\n\nfunc (ws *WshServer) FileInfoCommand(ctx context.Context, data wshrpc.FileData) (*wshrpc.FileInfo, error) {\n\treturn wshfs.Stat(ctx, data.Info.Path)\n}\n\nfunc (ws *WshServer) FileListCommand(ctx context.Context, data wshrpc.FileListData) ([]*wshrpc.FileInfo, error) {\n\treturn wshfs.ListEntries(ctx, data.Path, data.Opts)\n}\n\nfunc (ws *WshServer) FileListStreamCommand(ctx context.Context, data wshrpc.FileListData) <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] {\n\treturn wshfs.ListEntriesStream(ctx, data.Path, data.Opts)\n}\n\nfunc (ws *WshServer) FileWriteCommand(ctx context.Context, data wshrpc.FileData) error {\n\treturn wshfs.PutFile(ctx, data)\n}\n\nfunc (ws *WshServer) FileReadCommand(ctx context.Context, data wshrpc.FileData) (*wshrpc.FileData, error) {\n\treturn wshfs.Read(ctx, data)\n}\n\nfunc (ws *WshServer) FileReadStreamCommand(ctx context.Context, data wshrpc.FileData) <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData] {\n\treturn wshfs.ReadStream(ctx, data)\n}\n\nfunc (ws *WshServer) FileStreamCommand(ctx context.Context, data wshrpc.CommandFileStreamData) (*wshrpc.FileInfo, error) {\n\treturn wshfs.FileStream(ctx, data)\n}\n\nfunc (ws *WshServer) FileCopyCommand(ctx context.Context, data wshrpc.CommandFileCopyData) error {\n\treturn wshfs.Copy(ctx, data)\n}\n\nfunc (ws *WshServer) FileMoveCommand(ctx context.Context, data wshrpc.CommandFileCopyData) error {\n\treturn wshfs.Move(ctx, data)\n}\n\nfunc (ws *WshServer) FileAppendCommand(ctx context.Context, data wshrpc.FileData) error {\n\treturn wshfs.Append(ctx, data)\n}\n\nfunc (ws *WshServer) FileJoinCommand(ctx context.Context, paths []string) (*wshrpc.FileInfo, error) {\n\tif len(paths) < 2 {\n\t\tif len(paths) == 0 {\n\t\t\treturn nil, fmt.Errorf(\"no paths provided\")\n\t\t}\n\t\treturn wshfs.Stat(ctx, paths[0])\n\t}\n\treturn wshfs.Join(ctx, paths[0], paths[1:]...)\n}\n\nfunc (ws *WshServer) FileRestoreBackupCommand(ctx context.Context, data wshrpc.CommandFileRestoreBackupData) error {\n\texpandedBackupPath, err := wavebase.ExpandHomeDir(data.BackupFilePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to expand backup file path: %w\", err)\n\t}\n\texpandedRestorePath, err := wavebase.ExpandHomeDir(data.RestoreToFileName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to expand restore file path: %w\", err)\n\t}\n\treturn filebackup.RestoreBackup(expandedBackupPath, expandedRestorePath)\n}\n\nfunc (ws *WshServer) GetTempDirCommand(ctx context.Context, data wshrpc.CommandGetTempDirData) (string, error) {\n\ttempDir := os.TempDir()\n\tif data.FileName != \"\" {\n\t\t// Reduce to a simple file name to avoid absolute paths or traversal\n\t\tname := filepath.Base(data.FileName)\n\t\t// Normalize/trim any stray separators and whitespace\n\t\tname = strings.Trim(name, `/\\`+\" \")\n\t\tif name == \"\" || name == \".\" {\n\t\t\treturn tempDir, nil\n\t\t}\n\t\treturn filepath.Join(tempDir, name), nil\n\t}\n\treturn tempDir, nil\n}\n\nfunc (ws *WshServer) WriteTempFileCommand(ctx context.Context, data wshrpc.CommandWriteTempFileData) (string, error) {\n\tif data.FileName == \"\" {\n\t\treturn \"\", fmt.Errorf(\"filename is required\")\n\t}\n\tname := filepath.Base(data.FileName)\n\tif name == \"\" || name == \".\" || name == \"..\" {\n\t\treturn \"\", fmt.Errorf(\"invalid filename\")\n\t}\n\ttempDir, err := os.MkdirTemp(\"\", \"waveterm-\")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error creating temp directory: %w\", err)\n\t}\n\tdecoded, err := base64.StdEncoding.DecodeString(data.Data64)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error decoding base64 data: %w\", err)\n\t}\n\ttempPath := filepath.Join(tempDir, name)\n\terr = os.WriteFile(tempPath, decoded, 0600)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error writing temp file: %w\", err)\n\t}\n\treturn tempPath, nil\n}\n\nfunc (ws *WshServer) DeleteSubBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error {\n\tif data.BlockId == \"\" {\n\t\treturn fmt.Errorf(\"blockid is required\")\n\t}\n\terr := wcore.DeleteBlock(ctx, data.BlockId, false)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error deleting block: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error {\n\tif data.BlockId == \"\" {\n\t\treturn fmt.Errorf(\"blockid is required\")\n\t}\n\tctx = waveobj.ContextWithUpdates(ctx)\n\ttabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error finding tab for block: %w\", err)\n\t}\n\tif tabId == \"\" {\n\t\treturn fmt.Errorf(\"no tab found for block\")\n\t}\n\terr = wcore.DeleteBlock(ctx, data.BlockId, true)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error deleting block: %w\", err)\n\t}\n\twcore.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{\n\t\tActionType: wcore.LayoutActionDataType_Remove,\n\t\tBlockId:    data.BlockId,\n\t})\n\tupdates := waveobj.ContextGetUpdatesRtn(ctx)\n\twps.Broker.SendUpdateEvents(updates)\n\treturn nil\n}\n\nfunc (ws *WshServer) WaitForRouteCommand(ctx context.Context, data wshrpc.CommandWaitForRouteData) (bool, error) {\n\twaitCtx, cancelFn := context.WithTimeout(ctx, time.Duration(data.WaitMs)*time.Millisecond)\n\tdefer cancelFn()\n\terr := wshutil.DefaultRouter.WaitForRegister(waitCtx, data.RouteId)\n\treturn err == nil, nil\n}\n\nfunc (ws *WshServer) EventRecvCommand(ctx context.Context, data wps.WaveEvent) error {\n\treturn nil\n}\n\nfunc (ws *WshServer) EventPublishCommand(ctx context.Context, data wps.WaveEvent) error {\n\trpcSource := wshutil.GetRpcSourceFromContext(ctx)\n\tif rpcSource == \"\" {\n\t\treturn fmt.Errorf(\"no rpc source set\")\n\t}\n\tif data.Sender == \"\" {\n\t\tdata.Sender = rpcSource\n\t}\n\twps.Broker.Publish(data)\n\treturn nil\n}\n\nfunc (ws *WshServer) EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error {\n\trpcSource := wshutil.GetRpcSourceFromContext(ctx)\n\tif rpcSource == \"\" {\n\t\treturn fmt.Errorf(\"no rpc source set\")\n\t}\n\twps.Broker.Subscribe(rpcSource, data)\n\treturn nil\n}\n\nfunc (ws *WshServer) EventUnsubCommand(ctx context.Context, data string) error {\n\trpcSource := wshutil.GetRpcSourceFromContext(ctx)\n\tif rpcSource == \"\" {\n\t\treturn fmt.Errorf(\"no rpc source set\")\n\t}\n\twps.Broker.Unsubscribe(rpcSource, data)\n\treturn nil\n}\n\nfunc (ws *WshServer) EventUnsubAllCommand(ctx context.Context) error {\n\trpcSource := wshutil.GetRpcSourceFromContext(ctx)\n\tif rpcSource == \"\" {\n\t\treturn fmt.Errorf(\"no rpc source set\")\n\t}\n\twps.Broker.UnsubscribeAll(rpcSource)\n\treturn nil\n}\n\nfunc (ws *WshServer) EventReadHistoryCommand(ctx context.Context, data wshrpc.CommandEventReadHistoryData) ([]*wps.WaveEvent, error) {\n\tevents := wps.Broker.ReadEventHistory(data.Event, data.Scope, data.MaxItems)\n\treturn events, nil\n}\n\nfunc (ws *WshServer) SetConfigCommand(ctx context.Context, data wshrpc.MetaSettingsType) error {\n\treturn wconfig.SetBaseConfigValue(data.MetaMapType)\n}\n\nfunc (ws *WshServer) SetConnectionsConfigCommand(ctx context.Context, data wshrpc.ConnConfigRequest) error {\n\treturn wconfig.SetConnectionsConfigValue(data.Host, data.MetaMapType)\n}\n\nfunc (ws *WshServer) GetFullConfigCommand(ctx context.Context) (wconfig.FullConfigType, error) {\n\twatcher := wconfig.GetWatcher()\n\treturn watcher.GetFullConfig(), nil\n}\n\nfunc (ws *WshServer) GetWaveAIModeConfigCommand(ctx context.Context) (wconfig.AIModeConfigUpdate, error) {\n\tfullConfig := wconfig.GetWatcher().GetFullConfig()\n\tresolvedConfigs := aiusechat.ComputeResolvedAIModeConfigs(fullConfig)\n\treturn wconfig.AIModeConfigUpdate{Configs: resolvedConfigs}, nil\n}\n\nfunc (ws *WshServer) ConnStatusCommand(ctx context.Context) ([]wshrpc.ConnStatus, error) {\n\trtn := conncontroller.GetAllConnStatus()\n\treturn rtn, nil\n}\n\nfunc (ws *WshServer) WslStatusCommand(ctx context.Context) ([]wshrpc.ConnStatus, error) {\n\trtn := wslconn.GetAllConnStatus()\n\treturn rtn, nil\n}\n\nfunc termCtxWithLogBlockId(ctx context.Context, logBlockId string) context.Context {\n\tif logBlockId == \"\" {\n\t\treturn ctx\n\t}\n\tblock, err := wstore.DBMustGet[*waveobj.Block](ctx, logBlockId)\n\tif err != nil {\n\t\treturn ctx\n\t}\n\tconnDebug := block.Meta.GetString(waveobj.MetaKey_TermConnDebug, \"\")\n\tif connDebug == \"\" {\n\t\treturn ctx\n\t}\n\treturn blocklogger.ContextWithLogBlockId(ctx, logBlockId, connDebug == \"debug\")\n}\n\nfunc (ws *WshServer) ConnEnsureCommand(ctx context.Context, data wshrpc.ConnExtData) error {\n\tctx = genconn.ContextWithConnData(ctx, data.LogBlockId)\n\tctx = termCtxWithLogBlockId(ctx, data.LogBlockId)\n\tif strings.HasPrefix(data.ConnName, \"wsl://\") {\n\t\tdistroName := strings.TrimPrefix(data.ConnName, \"wsl://\")\n\t\treturn wslconn.EnsureConnection(ctx, distroName)\n\t}\n\treturn conncontroller.EnsureConnection(ctx, data.ConnName)\n}\n\nfunc (ws *WshServer) ConnDisconnectCommand(ctx context.Context, connName string) error {\n\tif conncontroller.IsLocalConnName(connName) {\n\t\treturn nil\n\t}\n\tif strings.HasPrefix(connName, \"wsl://\") {\n\t\tdistroName := strings.TrimPrefix(connName, \"wsl://\")\n\t\tconn := wslconn.GetWslConn(distroName)\n\t\tif conn == nil {\n\t\t\treturn fmt.Errorf(\"distro not found: %s\", connName)\n\t\t}\n\t\treturn conn.Close()\n\t}\n\tconnOpts, err := remote.ParseOpts(connName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error parsing connection name: %w\", err)\n\t}\n\tconn := conncontroller.MaybeGetConn(connOpts)\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"connection not found: %s\", connName)\n\t}\n\treturn conn.Close()\n}\n\nfunc (ws *WshServer) ConnConnectCommand(ctx context.Context, connRequest wshrpc.ConnRequest) error {\n\tif conncontroller.IsLocalConnName(connRequest.Host) {\n\t\treturn nil\n\t}\n\tctx = genconn.ContextWithConnData(ctx, connRequest.LogBlockId)\n\tctx = termCtxWithLogBlockId(ctx, connRequest.LogBlockId)\n\tconnName := connRequest.Host\n\tif strings.HasPrefix(connName, \"wsl://\") {\n\t\tdistroName := strings.TrimPrefix(connName, \"wsl://\")\n\t\tconn := wslconn.GetWslConn(distroName)\n\t\tif conn == nil {\n\t\t\treturn fmt.Errorf(\"connection not found: %s\", connName)\n\t\t}\n\t\treturn conn.Connect(ctx)\n\t}\n\tconnOpts, err := remote.ParseOpts(connName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error parsing connection name: %w\", err)\n\t}\n\tconn := conncontroller.GetConn(connOpts)\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"connection not found: %s\", connName)\n\t}\n\treturn conn.Connect(ctx, &connRequest.Keywords)\n}\n\nfunc (ws *WshServer) ConnReinstallWshCommand(ctx context.Context, data wshrpc.ConnExtData) error {\n\tif conncontroller.IsLocalConnName(data.ConnName) {\n\t\treturn nil\n\t}\n\tctx = genconn.ContextWithConnData(ctx, data.LogBlockId)\n\tctx = termCtxWithLogBlockId(ctx, data.LogBlockId)\n\tconnName := data.ConnName\n\tif strings.HasPrefix(connName, \"wsl://\") {\n\t\tdistroName := strings.TrimPrefix(connName, \"wsl://\")\n\t\tconn := wslconn.GetWslConn(distroName)\n\t\tif conn == nil {\n\t\t\treturn fmt.Errorf(\"connection not found: %s\", connName)\n\t\t}\n\t\treturn conn.InstallWsh(ctx, \"\")\n\t}\n\tconnOpts, err := remote.ParseOpts(connName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error parsing connection name: %w\", err)\n\t}\n\tconn := conncontroller.GetConn(connOpts)\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"connection not found: %s\", connName)\n\t}\n\treturn conn.InstallWsh(ctx, \"\")\n}\n\nfunc (ws *WshServer) ConnUpdateWshCommand(ctx context.Context, remoteInfo wshrpc.RemoteInfo) (bool, error) {\n\thandler := wshutil.GetRpcResponseHandlerFromContext(ctx)\n\tif handler == nil {\n\t\treturn false, fmt.Errorf(\"could not determine handler from context\")\n\t}\n\tconnName := handler.GetRpcContext().Conn\n\tif connName == \"\" {\n\t\treturn false, fmt.Errorf(\"invalid remote info: missing connection name\")\n\t}\n\n\tlog.Printf(\"checking wsh version for connection %s (current: %s)\", connName, remoteInfo.ClientVersion)\n\tupToDate, _, _, err := conncontroller.IsWshVersionUpToDate(ctx, remoteInfo.ClientVersion)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"unable to compare wsh version: %w\", err)\n\t}\n\tif upToDate {\n\t\t// no need to update\n\t\tlog.Printf(\"wsh is already up to date for connection %s\", connName)\n\t\treturn false, nil\n\t}\n\n\t// todo: need to add user input code here for validation\n\n\tif strings.HasPrefix(connName, \"wsl://\") {\n\t\treturn false, fmt.Errorf(\"connupdatewshcommand is not supported for wsl connections\")\n\t}\n\tconnOpts, err := remote.ParseOpts(connName)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"error parsing connection name: %w\", err)\n\t}\n\tconn := conncontroller.GetConn(connOpts)\n\tif conn == nil {\n\t\treturn false, fmt.Errorf(\"connection not found: %s\", connName)\n\t}\n\terr = conn.UpdateWsh(ctx, connName, &remoteInfo)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"wsh update failed for connection %s: %w\", connName, err)\n\t}\n\n\t// todo: need to add code for modifying configs?\n\treturn true, nil\n}\n\nfunc (ws *WshServer) ConnListCommand(ctx context.Context) ([]string, error) {\n\treturn conncontroller.GetConnectionsList()\n}\n\nfunc (ws *WshServer) WslListCommand(ctx context.Context) ([]string, error) {\n\tdistros, err := wsl.RegisteredDistros(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar distroNames []string\n\tfor _, distro := range distros {\n\t\tdistroName := distro.Name()\n\t\tif utilfn.ContainsStr(InvalidWslDistroNames, distroName) {\n\t\t\tcontinue\n\t\t}\n\t\tdistroNames = append(distroNames, distroName)\n\t}\n\treturn distroNames, nil\n}\n\nfunc (ws *WshServer) WslDefaultDistroCommand(ctx context.Context) (string, error) {\n\tdistro, ok, err := wsl.DefaultDistro(ctx)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to determine default distro: %w\", err)\n\t}\n\tif !ok {\n\t\treturn \"\", fmt.Errorf(\"unable to determine default distro\")\n\t}\n\treturn distro.Name(), nil\n}\n\n/**\n * Dismisses the WshFail Command in runtime memory on the backend\n */\nfunc (ws *WshServer) DismissWshFailCommand(ctx context.Context, connName string) error {\n\tif strings.HasPrefix(connName, \"wsl://\") {\n\t\tdistroName := strings.TrimPrefix(connName, \"wsl://\")\n\t\tconn := wslconn.GetWslConn(distroName)\n\t\tif conn == nil {\n\t\t\treturn fmt.Errorf(\"connection not found: %s\", connName)\n\t\t}\n\t\tconn.ClearWshError()\n\t\tconn.FireConnChangeEvent()\n\t\treturn nil\n\t}\n\topts, err := remote.ParseOpts(connName)\n\tif err != nil {\n\t\treturn err\n\t}\n\tconn := conncontroller.GetConn(opts)\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"connection %s not found\", connName)\n\t}\n\tconn.ClearWshError()\n\tconn.FireConnChangeEvent()\n\treturn nil\n}\n\nfunc (ws *WshServer) NotifySystemResumeCommand(ctx context.Context) error {\n\tlog.Printf(\"NotifySystemResumeCommand called\\n\")\n\treturn nil\n}\n\nfunc (ws *WshServer) FindGitBashCommand(ctx context.Context, rescan bool) (string, error) {\n\tfullConfig := wconfig.GetWatcher().GetFullConfig()\n\treturn shellutil.FindGitBash(&fullConfig, rescan), nil\n}\n\nfunc waveFileToWaveFileInfo(wf *filestore.WaveFile) *wshrpc.WaveFileInfo {\n\treturn &wshrpc.WaveFileInfo{\n\t\tZoneId:    wf.ZoneId,\n\t\tName:      wf.Name,\n\t\tOpts:      wf.Opts,\n\t\tCreatedTs: wf.CreatedTs,\n\t\tSize:      wf.Size,\n\t\tModTs:     wf.ModTs,\n\t\tMeta:      wf.Meta,\n\t}\n}\n\nfunc (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wshrpc.BlockInfoData, error) {\n\tblockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting block: %w\", err)\n\t}\n\ttabId, err := wstore.DBFindTabForBlockId(ctx, blockId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error finding tab for block: %w\", err)\n\t}\n\tworkspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, tabId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error finding window for tab: %w\", err)\n\t}\n\tfileList, err := filestore.WFS.ListFiles(ctx, blockId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error listing blockfiles: %w\", err)\n\t}\n\tvar fileInfoList []*wshrpc.WaveFileInfo\n\tfor _, wf := range fileList {\n\t\tfileInfoList = append(fileInfoList, waveFileToWaveFileInfo(wf))\n\t}\n\treturn &wshrpc.BlockInfoData{\n\t\tBlockId:     blockId,\n\t\tTabId:       tabId,\n\t\tWorkspaceId: workspaceId,\n\t\tBlock:       blockData,\n\t\tFiles:       fileInfoList,\n\t}, nil\n}\n\nfunc (ws *WshServer) DebugTermCommand(ctx context.Context, data wshrpc.CommandDebugTermData) (*wshrpc.CommandDebugTermRtnData, error) {\n\tif data.BlockId == \"\" {\n\t\treturn nil, fmt.Errorf(\"blockid is required\")\n\t}\n\tif data.Size <= 0 {\n\t\treturn nil, fmt.Errorf(\"size must be greater than 0\")\n\t}\n\twaveFile, err := filestore.WFS.Stat(ctx, data.BlockId, wavebase.BlockFile_Term)\n\tif err == fs.ErrNotExist {\n\t\treturn &wshrpc.CommandDebugTermRtnData{}, nil\n\t}\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error statting term file: %w\", err)\n\t}\n\treadSize := data.Size\n\tdataLength := waveFile.DataLength()\n\tif readSize > dataLength {\n\t\treadSize = dataLength\n\t}\n\treadOffset := waveFile.Size - readSize\n\treadOffset, readData, err := filestore.WFS.ReadAt(ctx, data.BlockId, wavebase.BlockFile_Term, readOffset, readSize)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error reading term file: %w\", err)\n\t}\n\treturn &wshrpc.CommandDebugTermRtnData{\n\t\tOffset: readOffset,\n\t\tData64: base64.StdEncoding.EncodeToString(readData),\n\t}, nil\n}\n\nfunc (ws *WshServer) WaveInfoCommand(ctx context.Context) (*wshrpc.WaveInfoData, error) {\n\treturn &wshrpc.WaveInfoData{\n\t\tVersion:   wavebase.WaveVersion,\n\t\tClientId:  wstore.GetClientId(),\n\t\tBuildTime: wavebase.BuildTime,\n\t\tConfigDir: wavebase.GetWaveConfigDir(),\n\t\tDataDir:   wavebase.GetWaveDataDir(),\n\t}, nil\n}\n\nfunc (ws *WshServer) MacOSVersionCommand(ctx context.Context) (string, error) {\n\treturn wavebase.ClientMacOSVersion(), nil\n}\n\n// BlocksListCommand returns every block visible in the requested\n// scope (current workspace by default).\nfunc (ws *WshServer) BlocksListCommand(\n\tctx context.Context,\n\treq wshrpc.BlocksListRequest) ([]wshrpc.BlocksListEntry, error) {\n\tvar results []wshrpc.BlocksListEntry\n\n\t// Resolve the set of workspaces to inspect\n\tvar workspaceIDs []string\n\tif req.WorkspaceId != \"\" {\n\t\tworkspaceIDs = []string{req.WorkspaceId}\n\t} else if req.WindowId != \"\" {\n\t\twin, err := wcore.GetWindow(ctx, req.WindowId)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tworkspaceIDs = []string{win.WorkspaceId}\n\t} else {\n\t\t// \"current\" == first workspace in client focus list\n\t\tclient, err := wstore.DBGetSingleton[*waveobj.Client](ctx)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif len(client.WindowIds) == 0 {\n\t\t\treturn nil, fmt.Errorf(\"no active window\")\n\t\t}\n\t\twin, err := wcore.GetWindow(ctx, client.WindowIds[0])\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tworkspaceIDs = []string{win.WorkspaceId}\n\t}\n\n\tfor _, wsID := range workspaceIDs {\n\t\twsData, err := wcore.GetWorkspace(ctx, wsID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\twindowId, err := wstore.DBFindWindowForWorkspaceId(ctx, wsID)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error finding window for workspace %s: %v\", wsID, err)\n\t\t}\n\n\t\tfor _, tabID := range wsData.TabIds {\n\t\t\ttab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabID)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tfor _, blkID := range tab.BlockIds {\n\t\t\t\tblk, err := wstore.DBMustGet[*waveobj.Block](ctx, blkID)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\tresults = append(results, wshrpc.BlocksListEntry{\n\t\t\t\t\tWindowId:    windowId,\n\t\t\t\t\tWorkspaceId: wsID,\n\t\t\t\t\tTabId:       tabID,\n\t\t\t\t\tBlockId:     blkID,\n\t\t\t\t\tMeta:        blk.Meta,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\treturn results, nil\n}\n\nfunc (ws *WshServer) WorkspaceListCommand(ctx context.Context) ([]wshrpc.WorkspaceInfoData, error) {\n\tworkspaceList, err := wcore.ListWorkspaces(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error listing workspaces: %w\", err)\n\t}\n\tvar rtn []wshrpc.WorkspaceInfoData\n\tfor _, workspaceEntry := range workspaceList {\n\t\tworkspaceData, err := wcore.GetWorkspace(ctx, workspaceEntry.WorkspaceId)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error getting workspace: %w\", err)\n\t\t}\n\t\trtn = append(rtn, wshrpc.WorkspaceInfoData{\n\t\t\tWindowId:      workspaceEntry.WindowId,\n\t\t\tWorkspaceData: workspaceData,\n\t\t})\n\t}\n\treturn rtn, nil\n}\n\nfunc (ws *WshServer) ListAllAppsCommand(ctx context.Context) ([]wshrpc.AppInfo, error) {\n\treturn waveappstore.ListAllApps()\n}\n\nfunc (ws *WshServer) ListAllEditableAppsCommand(ctx context.Context) ([]wshrpc.AppInfo, error) {\n\treturn waveappstore.ListAllEditableApps()\n}\n\nfunc (ws *WshServer) ListAllAppFilesCommand(ctx context.Context, data wshrpc.CommandListAllAppFilesData) (*wshrpc.CommandListAllAppFilesRtnData, error) {\n\tif data.AppId == \"\" {\n\t\treturn nil, fmt.Errorf(\"must provide an appId to ListAllAppFilesCommand\")\n\t}\n\tresult, err := waveappstore.ListAllAppFiles(data.AppId)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tentries := make([]wshrpc.DirEntryOut, len(result.Entries))\n\tfor i, entry := range result.Entries {\n\t\tentries[i] = wshrpc.DirEntryOut{\n\t\t\tName:         entry.Name,\n\t\t\tDir:          entry.Dir,\n\t\t\tSymlink:      entry.Symlink,\n\t\t\tSize:         entry.Size,\n\t\t\tMode:         entry.Mode,\n\t\t\tModified:     entry.Modified,\n\t\t\tModifiedTime: entry.ModifiedTime,\n\t\t}\n\t}\n\treturn &wshrpc.CommandListAllAppFilesRtnData{\n\t\tPath:         result.Path,\n\t\tAbsolutePath: result.AbsolutePath,\n\t\tParentDir:    result.ParentDir,\n\t\tEntries:      entries,\n\t\tEntryCount:   result.EntryCount,\n\t\tTotalEntries: result.TotalEntries,\n\t\tTruncated:    result.Truncated,\n\t}, nil\n}\n\nfunc (ws *WshServer) ReadAppFileCommand(ctx context.Context, data wshrpc.CommandReadAppFileData) (*wshrpc.CommandReadAppFileRtnData, error) {\n\tif data.AppId == \"\" {\n\t\treturn nil, fmt.Errorf(\"must provide an appId to ReadAppFileCommand\")\n\t}\n\tfileData, err := waveappstore.ReadAppFile(data.AppId, data.FileName)\n\tif err != nil {\n\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\treturn &wshrpc.CommandReadAppFileRtnData{\n\t\t\t\tNotFound: true,\n\t\t\t}, nil\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to read app file: %w\", err)\n\t}\n\treturn &wshrpc.CommandReadAppFileRtnData{\n\t\tData64: base64.StdEncoding.EncodeToString(fileData.Contents),\n\t\tModTs:  fileData.ModTs,\n\t}, nil\n}\n\nfunc (ws *WshServer) WriteAppFileCommand(ctx context.Context, data wshrpc.CommandWriteAppFileData) error {\n\tif data.AppId == \"\" {\n\t\treturn fmt.Errorf(\"must provide an appId to WriteAppFileCommand\")\n\t}\n\tcontents, err := base64.StdEncoding.DecodeString(data.Data64)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to decode data64: %w\", err)\n\t}\n\treturn waveappstore.WriteAppFile(data.AppId, data.FileName, contents)\n}\n\nfunc (ws *WshServer) WaveFileReadStreamCommand(ctx context.Context, data wshrpc.CommandWaveFileReadStreamData) (*wshrpc.WaveFileInfo, error) {\n\tconst maxStreamFileSize = 5 * 1024 * 1024\n\n\twaveFile, err := filestore.WFS.Stat(ctx, data.ZoneId, data.Name)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error statting wavefile: %w\", err)\n\t}\n\n\tdataLength := waveFile.DataLength()\n\tif dataLength > maxStreamFileSize {\n\t\treturn nil, fmt.Errorf(\"file size %d exceeds maximum streaming size of %d bytes\", dataLength, maxStreamFileSize)\n\t}\n\n\twshRpc := wshutil.GetWshRpcFromContext(ctx)\n\tif wshRpc == nil || wshRpc.StreamBroker == nil {\n\t\treturn nil, fmt.Errorf(\"no stream broker available\")\n\t}\n\n\twriter, err := wshRpc.StreamBroker.CreateStreamWriter(&data.StreamMeta)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating stream writer: %w\", err)\n\t}\n\n\t_, fileData, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.Name)\n\tif err != nil {\n\t\twriter.Close()\n\t\treturn nil, fmt.Errorf(\"error reading wavefile: %w\", err)\n\t}\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"WaveFileReadStreamCommand\", recover())\n\t\t}()\n\t\tdefer writer.Close()\n\n\t\t_, err := writer.Write(fileData)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error writing to stream for wavefile %s:%s: %v\\n\", data.ZoneId, data.Name, err)\n\t\t}\n\t}()\n\n\trtnInfo := &wshrpc.WaveFileInfo{\n\t\tZoneId:    waveFile.ZoneId,\n\t\tName:      waveFile.Name,\n\t\tOpts:      waveFile.Opts,\n\t\tCreatedTs: waveFile.CreatedTs,\n\t\tSize:      waveFile.Size,\n\t\tModTs:     waveFile.ModTs,\n\t\tMeta:      waveFile.Meta,\n\t}\n\treturn rtnInfo, nil\n}\n\nfunc (ws *WshServer) WriteAppGoFileCommand(ctx context.Context, data wshrpc.CommandWriteAppGoFileData) (*wshrpc.CommandWriteAppGoFileRtnData, error) {\n\tif data.AppId == \"\" {\n\t\treturn nil, fmt.Errorf(\"must provide an appId to WriteAppGoFileCommand\")\n\t}\n\tcontents, err := base64.StdEncoding.DecodeString(data.Data64)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode data64: %w\", err)\n\t}\n\n\tformattedOutput := waveapputil.FormatGoCode(contents)\n\n\terr = waveappstore.WriteAppFile(data.AppId, \"app.go\", formattedOutput)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tencoded := base64.StdEncoding.EncodeToString(formattedOutput)\n\treturn &wshrpc.CommandWriteAppGoFileRtnData{Data64: encoded}, nil\n}\n\nfunc (ws *WshServer) DeleteAppFileCommand(ctx context.Context, data wshrpc.CommandDeleteAppFileData) error {\n\tif data.AppId == \"\" {\n\t\treturn fmt.Errorf(\"must provide an appId to DeleteAppFileCommand\")\n\t}\n\treturn waveappstore.DeleteAppFile(data.AppId, data.FileName)\n}\n\nfunc (ws *WshServer) RenameAppFileCommand(ctx context.Context, data wshrpc.CommandRenameAppFileData) error {\n\tif data.AppId == \"\" {\n\t\treturn fmt.Errorf(\"must provide an appId to RenameAppFileCommand\")\n\t}\n\treturn waveappstore.RenameAppFile(data.AppId, data.FromFileName, data.ToFileName)\n}\n\nfunc (ws *WshServer) WriteAppSecretBindingsCommand(ctx context.Context, data wshrpc.CommandWriteAppSecretBindingsData) error {\n\tif data.AppId == \"\" {\n\t\treturn fmt.Errorf(\"must provide an appId to WriteAppSecretBindingsCommand\")\n\t}\n\treturn waveappstore.WriteAppSecretBindings(data.AppId, data.Bindings)\n}\n\nfunc (ws *WshServer) DeleteBuilderCommand(ctx context.Context, builderId string) error {\n\tif builderId == \"\" {\n\t\treturn fmt.Errorf(\"must provide a builderId to DeleteBuilderCommand\")\n\t}\n\tbuildercontroller.DeleteController(builderId)\n\treturn nil\n}\n\nfunc (ws *WshServer) StartBuilderCommand(ctx context.Context, data wshrpc.CommandStartBuilderData) error {\n\tif data.BuilderId == \"\" {\n\t\treturn fmt.Errorf(\"must provide a builderId to StartBuilderCommand\")\n\t}\n\tbc := buildercontroller.GetOrCreateController(data.BuilderId)\n\trtInfo := wstore.GetRTInfo(waveobj.MakeORef(\"builder\", data.BuilderId))\n\tif rtInfo == nil {\n\t\treturn fmt.Errorf(\"builder rtinfo not found for builderid: %s\", data.BuilderId)\n\t}\n\tappId := rtInfo.BuilderAppId\n\tif appId == \"\" {\n\t\treturn fmt.Errorf(\"builder appid not set for builderid: %s\", data.BuilderId)\n\t}\n\treturn bc.Start(ctx, appId, rtInfo.BuilderEnv)\n}\n\nfunc (ws *WshServer) StopBuilderCommand(ctx context.Context, builderId string) error {\n\tif builderId == \"\" {\n\t\treturn fmt.Errorf(\"must provide a builderId to StopBuilderCommand\")\n\t}\n\tbc := buildercontroller.GetController(builderId)\n\tif bc == nil {\n\t\treturn nil\n\t}\n\treturn bc.Stop()\n}\n\nfunc (ws *WshServer) RestartBuilderAndWaitCommand(ctx context.Context, data wshrpc.CommandRestartBuilderAndWaitData) (*wshrpc.RestartBuilderAndWaitResult, error) {\n\tif data.BuilderId == \"\" {\n\t\treturn nil, fmt.Errorf(\"must provide a builderId to RestartBuilderAndWaitCommand\")\n\t}\n\n\tbc := buildercontroller.GetOrCreateController(data.BuilderId)\n\trtInfo := wstore.GetRTInfo(waveobj.MakeORef(\"builder\", data.BuilderId))\n\tif rtInfo == nil {\n\t\treturn nil, fmt.Errorf(\"builder rtinfo not found for builderid: %s\", data.BuilderId)\n\t}\n\n\tappId := rtInfo.BuilderAppId\n\tif appId == \"\" {\n\t\treturn nil, fmt.Errorf(\"builder appid not set for builderid: %s\", data.BuilderId)\n\t}\n\n\tresult, err := bc.RestartAndWaitForBuild(ctx, appId, rtInfo.BuilderEnv)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &wshrpc.RestartBuilderAndWaitResult{\n\t\tSuccess:      result.Success,\n\t\tErrorMessage: result.ErrorMessage,\n\t\tBuildOutput:  result.BuildOutput,\n\t}, nil\n}\n\nfunc (ws *WshServer) GetBuilderStatusCommand(ctx context.Context, builderId string) (*wshrpc.BuilderStatusData, error) {\n\tif builderId == \"\" {\n\t\treturn nil, fmt.Errorf(\"must provide a builderId to GetBuilderStatusCommand\")\n\t}\n\tbc := buildercontroller.GetOrCreateController(builderId)\n\tstatus := bc.GetStatus()\n\treturn &status, nil\n}\n\nfunc (ws *WshServer) GetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error) {\n\tif builderId == \"\" {\n\t\treturn nil, fmt.Errorf(\"must provide a builderId to GetBuilderOutputCommand\")\n\t}\n\tbc := buildercontroller.GetOrCreateController(builderId)\n\treturn bc.GetOutput(), nil\n}\n\nfunc (ws *WshServer) CheckGoVersionCommand(ctx context.Context) (*wshrpc.CommandCheckGoVersionRtnData, error) {\n\twatcher := wconfig.GetWatcher()\n\tfullConfig := watcher.GetFullConfig()\n\tgoPath := fullConfig.Settings.TsunamiGoPath\n\n\tresult := build.CheckGoVersion(goPath)\n\n\treturn &wshrpc.CommandCheckGoVersionRtnData{\n\t\tGoStatus:    result.GoStatus,\n\t\tGoPath:      result.GoPath,\n\t\tGoVersion:   result.GoVersion,\n\t\tErrorString: result.ErrorString,\n\t}, nil\n}\n\nfunc (ws *WshServer) PublishAppCommand(ctx context.Context, data wshrpc.CommandPublishAppData) (*wshrpc.CommandPublishAppRtnData, error) {\n\tpublishedAppId, err := waveappstore.PublishDraft(data.AppId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error publishing app: %w\", err)\n\t}\n\treturn &wshrpc.CommandPublishAppRtnData{\n\t\tPublishedAppId: publishedAppId,\n\t}, nil\n}\n\nfunc (ws *WshServer) MakeDraftFromLocalCommand(ctx context.Context, data wshrpc.CommandMakeDraftFromLocalData) (*wshrpc.CommandMakeDraftFromLocalRtnData, error) {\n\tdraftAppId, err := waveappstore.MakeDraftFromLocal(data.LocalAppId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error making draft from local: %w\", err)\n\t}\n\treturn &wshrpc.CommandMakeDraftFromLocalRtnData{\n\t\tDraftAppId: draftAppId,\n\t}, nil\n}\n\nfunc (ws *WshServer) RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error {\n\terr := telemetry.RecordTEvent(ctx, &data)\n\tif err != nil {\n\t\tlog.Printf(\"error recording telemetry event: %v\", err)\n\t}\n\treturn err\n}\n\nfunc (ws WshServer) SendTelemetryCommand(ctx context.Context) error {\n\treturn wcloud.SendAllTelemetry(wstore.GetClientId())\n}\n\nfunc (ws *WshServer) WaveAIEnableTelemetryCommand(ctx context.Context) error {\n\t// Enable telemetry in config\n\tmeta := waveobj.MetaMapType{\n\t\twconfig.ConfigKey_TelemetryEnabled: true,\n\t}\n\terr := wconfig.SetBaseConfigValue(meta)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting telemetry enabled: %w\", err)\n\t}\n\n\t// Record the telemetry event\n\tevent := telemetrydata.MakeTEvent(\"waveai:enabletelemetry\", telemetrydata.TEventProps{})\n\terr = telemetry.RecordTEvent(ctx, event)\n\tif err != nil {\n\t\tlog.Printf(\"error recording waveai:enabletelemetry event: %v\", err)\n\t}\n\n\t// Immediately send telemetry to cloud\n\terr = wcloud.SendAllTelemetry(wstore.GetClientId())\n\tif err != nil {\n\t\tlog.Printf(\"error sending telemetry after enabling: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (ws *WshServer) GetWaveAIChatCommand(ctx context.Context, data wshrpc.CommandGetWaveAIChatData) (*uctypes.UIChat, error) {\n\taiChat := chatstore.DefaultChatStore.Get(data.ChatId)\n\tif aiChat == nil {\n\t\treturn nil, nil\n\t}\n\tuiChat, err := aiusechat.ConvertAIChatToUIChat(aiChat)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error converting AI chat to UI chat: %w\", err)\n\t}\n\treturn uiChat, nil\n}\n\nfunc (ws *WshServer) GetWaveAIRateLimitCommand(ctx context.Context) (*uctypes.RateLimitInfo, error) {\n\treturn aiusechat.GetGlobalRateLimit(), nil\n}\n\nfunc (ws *WshServer) WaveAIToolApproveCommand(ctx context.Context, data wshrpc.CommandWaveAIToolApproveData) error {\n\treturn aiusechat.UpdateToolApproval(data.ToolCallId, data.Approval)\n}\n\nfunc (ws *WshServer) WaveAIGetToolDiffCommand(ctx context.Context, data wshrpc.CommandWaveAIGetToolDiffData) (*wshrpc.CommandWaveAIGetToolDiffRtnData, error) {\n\toriginalContent, modifiedContent, err := aiusechat.CreateWriteTextFileDiff(ctx, data.ChatId, data.ToolCallId)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &wshrpc.CommandWaveAIGetToolDiffRtnData{\n\t\tOriginalContents64: base64.StdEncoding.EncodeToString(originalContent),\n\t\tModifiedContents64: base64.StdEncoding.EncodeToString(modifiedContent),\n\t}, nil\n}\n\nvar wshActivityRe = regexp.MustCompile(`^[a-z:#]+$`)\n\nfunc (ws *WshServer) WshActivityCommand(ctx context.Context, data map[string]int) error {\n\tif len(data) == 0 {\n\t\treturn nil\n\t}\n\tprops := telemetrydata.TEventProps{}\n\tfor key, value := range data {\n\t\tif len(key) > 20 {\n\t\t\tdelete(data, key)\n\t\t}\n\t\tif !wshActivityRe.MatchString(key) {\n\t\t\tdelete(data, key)\n\t\t}\n\t\tif value != 1 {\n\t\t\tdelete(data, key)\n\t\t}\n\t\tif strings.HasSuffix(key, \"#error\") {\n\t\t\tprops.WshHadError = true\n\t\t} else {\n\t\t\tprops.WshCmd = key\n\t\t}\n\t}\n\tactivityUpdate := wshrpc.ActivityUpdate{\n\t\tWshCmds: data,\n\t}\n\ttelemetry.GoUpdateActivityWrap(activityUpdate, \"wsh-activity\")\n\ttelemetry.GoRecordTEventWrap(&telemetrydata.TEvent{\n\t\tEvent: \"wsh:run\",\n\t\tProps: props,\n\t})\n\treturn nil\n}\n\nfunc (ws *WshServer) ActivityCommand(ctx context.Context, activity wshrpc.ActivityUpdate) error {\n\ttelemetry.GoUpdateActivityWrap(activity, \"wshrpc-activity\")\n\treturn nil\n}\n\nfunc (ws *WshServer) GetVarCommand(ctx context.Context, data wshrpc.CommandVarData) (*wshrpc.CommandVarResponseData, error) {\n\t_, fileData, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.FileName)\n\tif err == fs.ErrNotExist {\n\t\treturn &wshrpc.CommandVarResponseData{Key: data.Key, Exists: false}, nil\n\t}\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error reading blockfile: %w\", err)\n\t}\n\tenvMap := envutil.EnvToMap(string(fileData))\n\tvalue, ok := envMap[data.Key]\n\treturn &wshrpc.CommandVarResponseData{Key: data.Key, Exists: ok, Val: value}, nil\n}\n\nfunc (ws *WshServer) GetAllVarsCommand(ctx context.Context, data wshrpc.CommandVarData) ([]wshrpc.CommandVarResponseData, error) {\n\t_, fileData, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.FileName)\n\tif err == fs.ErrNotExist {\n\t\treturn []wshrpc.CommandVarResponseData{}, nil\n\t}\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error reading blockfile: %w\", err)\n\t}\n\tenvMap := envutil.EnvToMap(string(fileData))\n\tkeys := make([]string, 0, len(envMap))\n\tfor k := range envMap {\n\t\tkeys = append(keys, k)\n\t}\n\tsort.Strings(keys)\n\tresult := make([]wshrpc.CommandVarResponseData, 0, len(keys))\n\tfor _, k := range keys {\n\t\tresult = append(result, wshrpc.CommandVarResponseData{\n\t\t\tKey:    k,\n\t\t\tVal:    envMap[k],\n\t\t\tExists: true,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (ws *WshServer) SetVarCommand(ctx context.Context, data wshrpc.CommandVarData) error {\n\t_, fileData, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.FileName)\n\tif err == fs.ErrNotExist {\n\t\tfileData = []byte{}\n\t\terr = filestore.WFS.MakeFile(ctx, data.ZoneId, data.FileName, nil, wshrpc.FileOpts{})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error creating blockfile: %w\", err)\n\t\t}\n\t} else if err != nil {\n\t\treturn fmt.Errorf(\"error reading blockfile: %w\", err)\n\t}\n\tenvMap := envutil.EnvToMap(string(fileData))\n\tif data.Remove {\n\t\tdelete(envMap, data.Key)\n\t} else {\n\t\tenvMap[data.Key] = data.Val\n\t}\n\tenvStr := envutil.MapToEnv(envMap)\n\treturn filestore.WFS.WriteFile(ctx, data.ZoneId, data.FileName, []byte(envStr))\n}\n\nfunc (ws *WshServer) PathCommand(ctx context.Context, data wshrpc.PathCommandData) (string, error) {\n\tpathType := data.PathType\n\topenInternal := data.Open\n\topenExternal := data.OpenExternal\n\tvar path string\n\tswitch pathType {\n\tcase \"config\":\n\t\tpath = wavebase.GetWaveConfigDir()\n\tcase \"data\":\n\t\tpath = wavebase.GetWaveDataDir()\n\tcase \"log\":\n\t\tpath = filepath.Join(wavebase.GetWaveDataDir(), \"waveapp.log\")\n\t}\n\n\tif openInternal && openExternal {\n\t\treturn \"\", fmt.Errorf(\"open and openExternal cannot both be true\")\n\t}\n\n\tif openInternal {\n\t\t_, err := ws.CreateBlockCommand(ctx, wshrpc.CommandCreateBlockData{\n\t\t\tTabId: data.TabId,\n\t\t\tBlockDef: &waveobj.BlockDef{Meta: map[string]any{\n\t\t\t\twaveobj.MetaKey_View: \"preview\",\n\t\t\t\twaveobj.MetaKey_File: path,\n\t\t\t}},\n\t\t\tEphemeral: true,\n\t\t\tFocused:   true,\n\t\t})\n\n\t\tif err != nil {\n\t\t\treturn path, fmt.Errorf(\"error opening path: %w\", err)\n\t\t}\n\t} else if openExternal {\n\t\terr := open.Run(path)\n\t\tif err != nil {\n\t\t\treturn path, fmt.Errorf(\"error opening path: %w\", err)\n\t\t}\n\t}\n\treturn path, nil\n}\n\nfunc (ws *WshServer) FetchSuggestionsCommand(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) {\n\treturn suggestion.FetchSuggestions(ctx, data)\n}\n\nfunc (ws *WshServer) DisposeSuggestionsCommand(ctx context.Context, widgetId string) error {\n\tsuggestion.DisposeSuggestions(ctx, widgetId)\n\treturn nil\n}\n\nfunc (ws *WshServer) GetTabCommand(ctx context.Context, tabId string) (*waveobj.Tab, error) {\n\ttab, err := wstore.DBGet[*waveobj.Tab](ctx, tabId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting tab: %w\", err)\n\t}\n\treturn tab, nil\n}\n\nfunc (ws *WshServer) GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error) {\n\treturn wcore.GetAllBadges(), nil\n}\n\nfunc (ws *WshServer) GetSecretsCommand(ctx context.Context, names []string) (map[string]string, error) {\n\tresult := make(map[string]string)\n\tfor _, name := range names {\n\t\tvalue, exists, err := secretstore.GetSecret(name)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error getting secret %q: %w\", name, err)\n\t\t}\n\t\tif exists {\n\t\t\tresult[name] = value\n\t\t}\n\t}\n\treturn result, nil\n}\n\nfunc (ws *WshServer) GetSecretsNamesCommand(ctx context.Context) ([]string, error) {\n\tnames, err := secretstore.GetSecretNames()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting secret names: %w\", err)\n\t}\n\treturn names, nil\n}\n\nfunc (ws *WshServer) SetSecretsCommand(ctx context.Context, secrets map[string]*string) error {\n\tfor name, value := range secrets {\n\t\tif value == nil {\n\t\t\terr := secretstore.DeleteSecret(name)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error deleting secret %q: %w\", name, err)\n\t\t\t}\n\t\t} else {\n\t\t\terr := secretstore.SetSecret(name, *value)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error setting secret %q: %w\", name, err)\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (ws *WshServer) GetSecretsLinuxStorageBackendCommand(ctx context.Context) (string, error) {\n\tbackend, err := secretstore.GetLinuxStorageBackend()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error getting linux storage backend: %w\", err)\n\t}\n\treturn backend, nil\n}\n\nfunc (ws *WshServer) JobCmdExitedCommand(ctx context.Context, data wshrpc.CommandJobCmdExitedData) error {\n\treturn jobcontroller.HandleCmdJobExited(ctx, data.JobId, data)\n}\n\nfunc (ws *WshServer) JobControllerListCommand(ctx context.Context) ([]*waveobj.Job, error) {\n\treturn wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job)\n}\n\nfunc (ws *WshServer) JobControllerDeleteJobCommand(ctx context.Context, jobId string) error {\n\treturn jobcontroller.DeleteJob(ctx, jobId)\n}\n\nfunc (ws *WshServer) JobControllerStartJobCommand(ctx context.Context, data wshrpc.CommandJobControllerStartJobData) (string, error) {\n\tparams := jobcontroller.StartJobParams{\n\t\tConnName: data.ConnName,\n\t\tJobKind:  data.JobKind,\n\t\tCmd:      data.Cmd,\n\t\tArgs:     data.Args,\n\t\tEnv:      data.Env,\n\t\tTermSize: data.TermSize,\n\t}\n\treturn jobcontroller.StartJob(ctx, params)\n}\n\nfunc (ws *WshServer) JobControllerExitJobCommand(ctx context.Context, jobId string) error {\n\treturn jobcontroller.TerminateJobManager(ctx, jobId)\n}\n\nfunc (ws *WshServer) JobControllerDisconnectJobCommand(ctx context.Context, jobId string) error {\n\treturn jobcontroller.DisconnectJob(ctx, jobId)\n}\n\nfunc (ws *WshServer) JobControllerReconnectJobCommand(ctx context.Context, jobId string) error {\n\treturn jobcontroller.ReconnectJob(ctx, jobId, nil)\n}\n\nfunc (ws *WshServer) JobControllerReconnectJobsForConnCommand(ctx context.Context, connName string) error {\n\treturn jobcontroller.ReconnectJobsForConn(ctx, connName)\n}\n\nfunc (ws *WshServer) JobControllerConnectedJobsCommand(ctx context.Context) ([]string, error) {\n\treturn jobcontroller.GetConnectedJobIds(), nil\n}\n\nfunc (ws *WshServer) JobControllerAttachJobCommand(ctx context.Context, data wshrpc.CommandJobControllerAttachJobData) error {\n\treturn jobcontroller.AttachJobToBlock(ctx, data.JobId, data.BlockId)\n}\n\nfunc (ws *WshServer) JobControllerDetachJobCommand(ctx context.Context, jobId string) error {\n\treturn jobcontroller.DetachJobFromBlock(ctx, jobId, true)\n}\n\nfunc (ws *WshServer) BlockJobStatusCommand(ctx context.Context, blockId string) (*wshrpc.BlockJobStatusData, error) {\n\treturn jobcontroller.GetBlockJobStatus(ctx, blockId)\n}\n"
  },
  {
    "path": "pkg/wshrpc/wshserver/wshserverutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshserver\n\nimport (\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n)\n\nconst (\n\tDefaultOutputChSize = 32\n\tDefaultInputChSize  = 32\n)\n\nvar waveSrvClient_Singleton *wshutil.WshRpc\nvar waveSrvClient_Once = &sync.Once{}\n\n// returns the wavesrv main rpc client singleton\nfunc GetMainRpcClient() *wshutil.WshRpc {\n\twaveSrvClient_Once.Do(func() {\n\t\twaveSrvClient_Singleton = wshutil.MakeWshRpc(wshrpc.RpcContext{}, &WshServerImpl, \"main-client\")\n\t})\n\treturn waveSrvClient_Singleton\n}\n"
  },
  {
    "path": "pkg/wshutil/wshadapter.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshutil\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nvar WshCommandDeclMap = wshrpc.GenerateWshCommandDeclMap()\nvar multiArgRType = reflect.TypeOf(wshrpc.MultiArg{})\n\nfunc findCmdMethod(impl any, cmd string) *reflect.Method {\n\trtype := reflect.TypeOf(impl)\n\tmethodName := cmd + \"command\"\n\tfor i := 0; i < rtype.NumMethod(); i++ {\n\t\tmethod := rtype.Method(i)\n\t\tif strings.ToLower(method.Name) == methodName {\n\t\t\treturn &method\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc decodeRtnVals(rtnVals []reflect.Value) (any, error) {\n\tswitch len(rtnVals) {\n\tcase 0:\n\t\treturn nil, nil\n\tcase 1:\n\t\terrIf := rtnVals[0].Interface()\n\t\tif errIf == nil {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, errIf.(error)\n\tcase 2:\n\t\terrIf := rtnVals[1].Interface()\n\t\tif errIf == nil {\n\t\t\treturn rtnVals[0].Interface(), nil\n\t\t}\n\t\treturn rtnVals[0].Interface(), errIf.(error)\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"too many return values: %d\", len(rtnVals))\n\t}\n}\n\nfunc noImplHandler(handler *RpcResponseHandler) bool {\n\thandler.SendResponseError(fmt.Errorf(\"command %q not implemented\", handler.GetCommand()))\n\treturn true\n}\n\nfunc recodeCommandData(command string, data any, commandDataType reflect.Type) (any, error) {\n\tif command == \"\" || commandDataType == nil {\n\t\treturn data, nil\n\t}\n\tmethodDecl := WshCommandDeclMap[command]\n\tif methodDecl == nil {\n\t\treturn data, fmt.Errorf(\"command %q not found\", command)\n\t}\n\tcommandDataPtr := reflect.New(commandDataType).Interface()\n\tif data != nil {\n\t\terr := utilfn.ReUnmarshal(commandDataPtr, data)\n\t\tif err != nil {\n\t\t\treturn data, fmt.Errorf(\"error re-marshalling command data: %w\", err)\n\t\t}\n\t}\n\treturn reflect.ValueOf(commandDataPtr).Elem().Interface(), nil\n}\n\nfunc serverImplAdapter(impl any) func(*RpcResponseHandler) bool {\n\tif impl == nil {\n\t\treturn noImplHandler\n\t}\n\trtype := reflect.TypeOf(impl)\n\tif rtype.Kind() != reflect.Ptr && rtype.Elem().Kind() != reflect.Struct {\n\t\tpanic(fmt.Sprintf(\"expected struct pointer, got %s\", rtype))\n\t}\n\t// returns isAsync\n\treturn func(handler *RpcResponseHandler) bool {\n\t\tcmd := handler.GetCommand()\n\t\tmethodDecl := WshCommandDeclMap[cmd]\n\t\tif methodDecl == nil {\n\t\t\thandler.SendResponseError(fmt.Errorf(\"command %q not found\", cmd))\n\t\t\treturn true\n\t\t}\n\t\trmethod := findCmdMethod(impl, cmd)\n\t\tif rmethod == nil {\n\t\t\tif !handler.NeedsResponse() && cmd != wshrpc.Command_Message {\n\t\t\t\t// we also send an out of band message here since this is likely unexpected and will require debugging\n\t\t\t\thandler.SendMessage(fmt.Sprintf(\"command %q method %q not found\", handler.GetCommand(), methodDecl.MethodName))\n\t\t\t}\n\t\t\thandler.SendResponseError(fmt.Errorf(\"command not implemented %q\", cmd))\n\t\t\treturn true\n\t\t}\n\t\timplMethod := reflect.ValueOf(impl).MethodByName(rmethod.Name)\n\t\tvar callParams []reflect.Value\n\t\tcallParams = append(callParams, reflect.ValueOf(handler.Context()))\n\t\tcommandDataTypes := methodDecl.GetCommandDataTypes()\n\t\tif len(commandDataTypes) == 1 {\n\t\t\tcmdData, err := recodeCommandData(cmd, handler.GetCommandRawData(), commandDataTypes[0])\n\t\t\tif err != nil {\n\t\t\t\thandler.SendResponseError(err)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tcallParams = append(callParams, reflect.ValueOf(cmdData))\n\t\t} else if len(commandDataTypes) > 1 {\n\t\t\tmultiArgAny, err := recodeCommandData(cmd, handler.GetCommandRawData(), multiArgRType)\n\t\t\tif err != nil {\n\t\t\t\thandler.SendResponseError(err)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tmultiArg, ok := multiArgAny.(wshrpc.MultiArg)\n\t\t\tif !ok {\n\t\t\t\thandler.SendResponseError(fmt.Errorf(\"command %q invalid multi arg payload\", cmd))\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif len(multiArg.Args) != len(commandDataTypes) {\n\t\t\t\thandler.SendResponseError(fmt.Errorf(\"command %q expected %d args, got %d\", cmd, len(commandDataTypes), len(multiArg.Args)))\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tfor idx, commandDataType := range commandDataTypes {\n\t\t\t\tcmdData, err := recodeCommandData(cmd, multiArg.Args[idx], commandDataType)\n\t\t\t\tif err != nil {\n\t\t\t\t\thandler.SendResponseError(err)\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t\tcallParams = append(callParams, reflect.ValueOf(cmdData))\n\t\t\t}\n\t\t}\n\t\tif methodDecl.CommandType == wshrpc.RpcType_Call {\n\t\t\trtnVals := implMethod.Call(callParams)\n\t\t\trtnData, rtnErr := decodeRtnVals(rtnVals)\n\t\t\tif rtnErr != nil {\n\t\t\t\thandler.SendResponseError(rtnErr)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\thandler.SendResponse(rtnData, true)\n\t\t\treturn true\n\t\t} else if methodDecl.CommandType == wshrpc.RpcType_ResponseStream {\n\t\t\trtnVals := implMethod.Call(callParams)\n\t\t\trtnChVal := rtnVals[0]\n\t\t\tif rtnChVal.IsNil() {\n\t\t\t\thandler.SendResponse(nil, true)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tgo func() {\n\t\t\t\tdefer func() {\n\t\t\t\t\tpanichandler.PanicHandler(\"serverImplAdapter:responseStream\", recover())\n\t\t\t\t}()\n\t\t\t\tdefer handler.Finalize()\n\t\t\t\t// must use reflection here because we don't know the generic type of RespOrErrorUnion\n\t\t\t\tfor {\n\t\t\t\t\trespVal, ok := rtnChVal.Recv()\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\terrorVal := respVal.FieldByName(\"Error\")\n\t\t\t\t\tif !errorVal.IsNil() {\n\t\t\t\t\t\thandler.SendResponseError(errorVal.Interface().(error))\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\trespData := respVal.FieldByName(\"Response\").Interface()\n\t\t\t\t\thandler.SendResponse(respData, false)\n\t\t\t\t}\n\t\t\t}()\n\t\t\treturn false\n\t\t} else {\n\t\t\thandler.SendResponseError(fmt.Errorf(\"unsupported command type %q\", methodDecl.CommandType))\n\t\t\treturn true\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/wshutil/wshcmdreader.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshutil\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n)\n\nconst (\n\tMode_Normal  = \"normal\"\n\tMode_Esc     = \"esc\"\n\tMode_WaveEsc = \"waveesc\"\n)\n\nconst MaxBufferedDataSize = 256 * 1024\n\ntype PtyBuffer struct {\n\tCVar        *sync.Cond\n\tDataBuf     *bytes.Buffer\n\tEscMode     string\n\tEscSeqBuf   []byte\n\tOSCPrefix   string\n\tInputReader io.Reader\n\tMessageCh   chan baseds.RpcInputChType\n\tAtEOF       bool\n\tErr         error\n}\n\n// closes messageCh when input is closed (or error)\nfunc MakePtyBuffer(oscPrefix string, input io.Reader, messageCh chan baseds.RpcInputChType) *PtyBuffer {\n\tif len(oscPrefix) != WaveOSCPrefixLen {\n\t\tpanic(fmt.Sprintf(\"invalid OSC prefix length: %d\", len(oscPrefix)))\n\t}\n\tb := &PtyBuffer{\n\t\tCVar:        sync.NewCond(&sync.Mutex{}),\n\t\tDataBuf:     &bytes.Buffer{},\n\t\tOSCPrefix:   oscPrefix,\n\t\tEscMode:     Mode_Normal,\n\t\tInputReader: input,\n\t\tMessageCh:   messageCh,\n\t}\n\tgo b.run()\n\treturn b\n}\n\nfunc (b *PtyBuffer) setErr(err error) {\n\tb.CVar.L.Lock()\n\tdefer b.CVar.L.Unlock()\n\tif b.Err == nil {\n\t\tb.Err = err\n\t}\n\tb.CVar.Broadcast()\n}\n\nfunc (b *PtyBuffer) setEOF() {\n\tb.CVar.L.Lock()\n\tdefer b.CVar.L.Unlock()\n\tb.AtEOF = true\n\tb.CVar.Broadcast()\n}\n\nfunc (b *PtyBuffer) processWaveEscSeq(escSeq []byte) {\n\tb.MessageCh <- baseds.RpcInputChType{MsgBytes: escSeq}\n}\n\nfunc (b *PtyBuffer) run() {\n\tdefer close(b.MessageCh)\n\tbuf := make([]byte, 4096)\n\tfor {\n\t\tn, err := b.InputReader.Read(buf)\n\t\tb.processData(buf[:n])\n\t\tif err == io.EOF {\n\t\t\tb.setEOF()\n\t\t\treturn\n\t\t}\n\t\tif err != nil {\n\t\t\tb.setErr(fmt.Errorf(\"error reading input: %w\", err))\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (b *PtyBuffer) processData(data []byte) {\n\toutputBuf := make([]byte, 0, len(data))\n\tfor _, ch := range data {\n\t\tif b.EscMode == Mode_WaveEsc {\n\t\t\tif ch == ESC {\n\t\t\t\t// terminates the escape sequence (and the rest was invalid)\n\t\t\t\tb.EscMode = Mode_Normal\n\t\t\t\toutputBuf = append(outputBuf, b.EscSeqBuf...)\n\t\t\t\toutputBuf = append(outputBuf, ch)\n\t\t\t\tb.EscSeqBuf = nil\n\t\t\t} else if ch == BEL || ch == ST {\n\t\t\t\t// terminates the escpae sequence (is a valid Wave OSC command)\n\t\t\t\tb.EscMode = Mode_Normal\n\t\t\t\twaveEscSeq := b.EscSeqBuf[WaveOSCPrefixLen:]\n\t\t\t\tb.EscSeqBuf = nil\n\t\t\t\tb.processWaveEscSeq(waveEscSeq)\n\t\t\t} else {\n\t\t\t\tb.EscSeqBuf = append(b.EscSeqBuf, ch)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif b.EscMode == Mode_Esc {\n\t\t\tif ch == ESC || ch == BEL || ch == ST {\n\t\t\t\t// these all terminate the escape sequence (invalid, not a Wave OSC)\n\t\t\t\tb.EscMode = Mode_Normal\n\t\t\t\toutputBuf = append(outputBuf, b.EscSeqBuf...)\n\t\t\t\toutputBuf = append(outputBuf, ch)\n\t\t\t\tb.EscSeqBuf = nil\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif ch != b.OSCPrefix[len(b.EscSeqBuf)] {\n\t\t\t\t// this is not a Wave OSC sequence, just an escape sequence\n\t\t\t\tb.EscMode = Mode_Normal\n\t\t\t\toutputBuf = append(outputBuf, b.EscSeqBuf...)\n\t\t\t\toutputBuf = append(outputBuf, ch)\n\t\t\t\tb.EscSeqBuf = nil\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// we're still building what could be a Wave OSC sequence\n\t\t\tb.EscSeqBuf = append(b.EscSeqBuf, ch)\n\t\t\t// check to see if we have a full Wave OSC prefix\n\t\t\tif len(b.EscSeqBuf) == len(b.OSCPrefix) {\n\t\t\t\tb.EscMode = Mode_WaveEsc\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\t// Mode_Normal\n\t\tif ch == ESC {\n\t\t\tb.EscMode = Mode_Esc\n\t\t\tb.EscSeqBuf = []byte{ch}\n\t\t\tcontinue\n\t\t}\n\t\toutputBuf = append(outputBuf, ch)\n\t}\n\tif len(outputBuf) > 0 {\n\t\tb.writeData(outputBuf)\n\t}\n}\n\nfunc (b *PtyBuffer) writeData(data []byte) {\n\tb.CVar.L.Lock()\n\tdefer b.CVar.L.Unlock()\n\t// only wait if buffer is currently over max size, otherwise allow this append to go through\n\tfor b.DataBuf.Len() > MaxBufferedDataSize {\n\t\tb.CVar.Wait()\n\t}\n\tb.DataBuf.Write(data)\n\tb.CVar.Broadcast()\n}\n\nfunc (b *PtyBuffer) Read(p []byte) (n int, err error) {\n\tb.CVar.L.Lock()\n\tdefer b.CVar.L.Unlock()\n\tfor b.DataBuf.Len() == 0 {\n\t\tif b.Err != nil {\n\t\t\treturn 0, b.Err\n\t\t}\n\t\tif b.AtEOF {\n\t\t\treturn 0, io.EOF\n\t\t}\n\t\tb.CVar.Wait()\n\t}\n\tb.CVar.Broadcast()\n\treturn b.DataBuf.Read(p)\n}\n"
  },
  {
    "path": "pkg/wshutil/wshevent.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshutil\n\nimport (\n\t\"sync\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n)\n\n// event inverter.  converts WaveEvents to a listener.On() API\n\ntype singleListener struct {\n\tId string\n\tFn func(*wps.WaveEvent)\n}\n\ntype EventListener struct {\n\tLock      *sync.Mutex\n\tListeners map[string][]singleListener\n}\n\nfunc MakeEventListener() *EventListener {\n\treturn &EventListener{\n\t\tLock:      &sync.Mutex{},\n\t\tListeners: make(map[string][]singleListener),\n\t}\n}\n\nfunc (el *EventListener) On(eventName string, fn func(*wps.WaveEvent)) string {\n\tid := uuid.New().String()\n\tel.Lock.Lock()\n\tdefer el.Lock.Unlock()\n\tlarr := el.Listeners[eventName]\n\tlarr = append(larr, singleListener{Id: id, Fn: fn})\n\tel.Listeners[eventName] = larr\n\treturn id\n}\n\nfunc (el *EventListener) Unregister(eventName string, id string) {\n\tel.Lock.Lock()\n\tdefer el.Lock.Unlock()\n\tlarr := el.Listeners[eventName]\n\tnewArr := make([]singleListener, 0)\n\tfor _, sl := range larr {\n\t\tif sl.Id == id {\n\t\t\tcontinue\n\t\t}\n\t\tnewArr = append(newArr, sl)\n\t}\n\tel.Listeners[eventName] = newArr\n}\n\nfunc (el *EventListener) getListeners(eventName string) []singleListener {\n\tel.Lock.Lock()\n\tdefer el.Lock.Unlock()\n\treturn el.Listeners[eventName]\n}\n\nfunc (el *EventListener) RecvEvent(e *wps.WaveEvent) {\n\tlarr := el.getListeners(e.Event)\n\tfor _, sl := range larr {\n\t\tsl.Fn(e)\n\t}\n}\n"
  },
  {
    "path": "pkg/wshutil/wshproxy.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshutil\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype WshRpcProxy struct {\n\tLock         *sync.Mutex\n\tRpcContext   *wshrpc.RpcContext\n\tToRemoteCh   chan []byte\n\tFromRemoteCh chan baseds.RpcInputChType\n\tPeerInfo     string\n}\n\nfunc MakeRpcProxy(peerInfo string) *WshRpcProxy {\n\treturn MakeRpcProxyWithSize(peerInfo, DefaultInputChSize, DefaultOutputChSize)\n}\n\nfunc MakeRpcProxyWithSize(peerInfo string, inputChSize int, outputChSize int) *WshRpcProxy {\n\treturn &WshRpcProxy{\n\t\tLock:         &sync.Mutex{},\n\t\tToRemoteCh:   make(chan []byte, inputChSize),\n\t\tFromRemoteCh: make(chan baseds.RpcInputChType, outputChSize),\n\t\tPeerInfo:     peerInfo,\n\t}\n}\n\nfunc (p *WshRpcProxy) GetPeerInfo() string {\n\treturn p.PeerInfo\n}\n\nfunc (p *WshRpcProxy) SetPeerInfo(peerInfo string) {\n\tp.Lock.Lock()\n\tdefer p.Lock.Unlock()\n\tp.PeerInfo = peerInfo\n}\n\nfunc (p *WshRpcProxy) SendRpcMessage(msg []byte, ingressLinkId baseds.LinkId, debugStr string) bool {\n\tdefer func() {\n\t\tpanicCtx := \"WshRpcProxy.SendRpcMessage\"\n\t\tif debugStr != \"\" {\n\t\t\tpanicCtx = fmt.Sprintf(\"%s:%s\", panicCtx, debugStr)\n\t\t}\n\t\tpanichandler.PanicHandler(panicCtx, recover())\n\t}()\n\tselect {\n\tcase p.ToRemoteCh <- msg:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (p *WshRpcProxy) RecvRpcMessage() ([]byte, bool) {\n\tinputVal, more := <-p.FromRemoteCh\n\treturn inputVal.MsgBytes, more\n}\n"
  },
  {
    "path": "pkg/wshutil/wshrouter.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshutil\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nconst (\n\tDefaultRoute     = \"wavesrv\"\n\tElectronRoute    = \"electron\"\n\tControlRoute     = \"$control\"      // control plane route\n\tControlRootRoute = \"$control:root\" // control plane route to root router\n\n\tControlPrefix = \"$\"\n\n\tRoutePrefix_Conn       = \"conn:\"\n\tRoutePrefix_Controller = \"controller:\"\n\tRoutePrefix_Proc       = \"proc:\"\n\tRoutePrefix_Tab        = \"tab:\"\n\tRoutePrefix_FeBlock    = \"feblock:\"\n\tRoutePrefix_Builder    = \"builder:\"\n\tRoutePrefix_Link       = \"link:\"\n\tRoutePrefix_Job        = \"job:\"\n\tRoutePrefix_Bare       = \"bare:\"\n)\n\nconst RouterInputChQueueSize = 100\n\nvar BacklogLogThresholds = map[int]bool{1: true, 5: true, 10: true, 20: true, 30: true, 40: true, 50: true, 100: true, 200: true, 500: true, 1000: true}\n\n// this works like a network switch\n\n// TODO maybe move the wps integration here instead of in wshserver\n\ntype routeInfo struct {\n\tRpcId         string\n\tSourceRouteId string\n\tDestRouteId   string\n}\n\nconst LinkKind_Leaf = \"leaf\"\nconst LinkKind_Router = \"router\"\n\ntype linkMeta struct {\n\tlinkId        baseds.LinkId\n\ttrusted       bool\n\tlinkKind      string\n\tsourceRouteId string\n\tclient        AbstractRpcClient\n}\n\nfunc (lm *linkMeta) Name() string {\n\treturn fmt.Sprintf(\"%d#[%s]\", lm.linkId, lm.client.GetPeerInfo())\n}\n\ntype rpcRoutingInfo struct {\n\trpcId        string\n\tsourceLinkId baseds.LinkId\n\tdestRouteId  string\n}\n\ntype messageWrap struct {\n\tmsgBytes []byte\n\tdebugStr string\n}\n\ntype backlogMessageWrap struct {\n\tmsgBytes      []byte\n\tingressLinkId baseds.LinkId\n\tdebugStr      string\n}\n\ntype WshRouter struct {\n\tlock           *sync.Mutex\n\tisRootRouter   bool\n\tnextLinkId     baseds.LinkId\n\tupstreamLinkId baseds.LinkId\n\tinputCh        chan baseds.RpcInputChType\n\trpcMap         map[string]rpcRoutingInfo // rpcid => routeinfo\n\trouteMap       map[string]baseds.LinkId  // routeid => linkid\n\tlinkMap        map[baseds.LinkId]*linkMeta\n\n\tupstreamBufLock     sync.Mutex\n\tupstreamBufCond     *sync.Cond\n\tupstreamBuf         []messageWrap\n\tupstreamLoopStarted bool\n\n\tlinkBacklogCond      *sync.Cond\n\tlinkMsgBacklog       map[baseds.LinkId][]backlogMessageWrap\n\tbacklogHighWaterMark map[baseds.LinkId]int\n\n\tcontrolRpc *WshRpc\n}\n\nfunc MakeConnectionRouteId(connId string) string {\n\treturn \"conn:\" + connId\n}\n\nfunc MakeControllerRouteId(blockId string) string {\n\treturn \"controller:\" + blockId\n}\n\nfunc MakeProcRouteId(procId string) string {\n\treturn \"proc:\" + procId\n}\n\nfunc MakeRandomProcRouteId() string {\n\treturn MakeProcRouteId(uuid.New().String())\n}\n\nfunc MakeTabRouteId(tabId string) string {\n\treturn \"tab:\" + tabId\n}\n\nfunc MakeFeBlockRouteId(blockId string) string {\n\treturn \"feblock:\" + blockId\n}\n\nfunc MakeBuilderRouteId(builderId string) string {\n\treturn \"builder:\" + builderId\n}\n\nfunc MakeJobRouteId(jobId string) string {\n\treturn \"job:\" + jobId\n}\n\nfunc MakeLinkRouteId(linkId baseds.LinkId) string {\n\treturn fmt.Sprintf(\"%s%d\", RoutePrefix_Link, linkId)\n}\n\nvar DefaultRouter *WshRouter\n\nfunc NewWshRouter() *WshRouter {\n\trtn := &WshRouter{\n\t\tlock:                 &sync.Mutex{},\n\t\tnextLinkId:           0,\n\t\tupstreamLinkId:       baseds.NoLinkId,\n\t\tinputCh:              make(chan baseds.RpcInputChType, RouterInputChQueueSize),\n\t\trpcMap:               make(map[string]rpcRoutingInfo),\n\t\tlinkMap:              make(map[baseds.LinkId]*linkMeta),\n\t\trouteMap:             make(map[string]baseds.LinkId),\n\t\tlinkMsgBacklog:       make(map[baseds.LinkId][]backlogMessageWrap),\n\t\tbacklogHighWaterMark: make(map[baseds.LinkId]int),\n\t}\n\trtn.upstreamBufCond = sync.NewCond(&rtn.upstreamBufLock)\n\trtn.linkBacklogCond = sync.NewCond(rtn.lock)\n\trtn.registerControlPlane()\n\tgo rtn.runServer()\n\tgo rtn.processBacklog()\n\treturn rtn\n}\n\nfunc (router *WshRouter) IsRootRouter() bool {\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\treturn router.isRootRouter\n}\n\nfunc (router *WshRouter) GetControlRpc() *WshRpc {\n\treturn router.controlRpc\n}\n\nfunc (router *WshRouter) SetAsRootRouter() {\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\trouter.isRootRouter = true\n\n\t// also bind $control:root to the control RPC\n\tlinkId := router.routeMap[ControlRoute]\n\tif linkId != baseds.NoLinkId {\n\t\trouter.routeMap[ControlRootRoute] = linkId\n\t\tlog.Printf(\"wshrouter registered control:root route linkid=%d\", linkId)\n\t}\n}\n\nfunc noRouteErr(routeId string) error {\n\tif routeId == \"\" {\n\t\treturn errors.New(\"no default route\")\n\t}\n\treturn fmt.Errorf(\"no route for %q\", routeId)\n}\n\nfunc (router *WshRouter) SendEvent(routeId string, event wps.WaveEvent) {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"WshRouter.SendEvent\", recover())\n\t}()\n\tlm := router.getLinkForRoute(routeId)\n\tif lm == nil {\n\t\treturn\n\t}\n\tmsg := RpcMessage{\n\t\tCommand: wshrpc.Command_EventRecv,\n\t\tRoute:   routeId,\n\t\tData:    event,\n\t}\n\tmsgBytes, err := json.Marshal(msg)\n\tif err != nil {\n\t\t// nothing to do\n\t\treturn\n\t}\n\trouter.sendRpcMessageToLink(lm.linkId, lm.client, msgBytes, baseds.NoLinkId, \"eventrecv\")\n}\n\nfunc (router *WshRouter) handleNoRoute(msg RpcMessage, ingressLinkId baseds.LinkId) {\n\tlm := router.getLinkMeta(ingressLinkId)\n\tif lm == nil {\n\t\treturn\n\t}\n\tnrErr := noRouteErr(msg.Route)\n\tif msg.ReqId == \"\" {\n\t\tif msg.Command == wshrpc.Command_Message {\n\t\t\t// to prevent infinite loops\n\t\t\treturn\n\t\t}\n\t\t// no response needed, but send message back to source\n\t\trespMsg := RpcMessage{\n\t\t\tCommand: wshrpc.Command_Message,\n\t\t\tRoute:   msg.Source,\n\t\t\tSource:  ControlRoute,\n\t\t\tData:    wshrpc.CommandMessageData{Message: nrErr.Error()},\n\t\t}\n\t\trespBytes, _ := json.Marshal(respMsg)\n\t\trouter.sendRpcMessageToLink(lm.linkId, lm.client, respBytes, baseds.NoLinkId, \"no-route-err\")\n\t\treturn\n\t}\n\t// send error response\n\tresponse := RpcMessage{\n\t\tResId: msg.ReqId,\n\t\tError: nrErr.Error(),\n\t}\n\trespBytes, _ := json.Marshal(response)\n\trouter.sendRoutedMessage(respBytes, msg.Source, msg.Command, baseds.NoLinkId)\n}\n\nfunc (router *WshRouter) registerRouteInfo(rpcId string, sourceLinkId baseds.LinkId, destRouteId string) {\n\tif rpcId == \"\" {\n\t\treturn\n\t}\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\trouter.rpcMap[rpcId] = rpcRoutingInfo{\n\t\trpcId:        rpcId,\n\t\tsourceLinkId: sourceLinkId,\n\t\tdestRouteId:  destRouteId,\n\t}\n}\n\nfunc (router *WshRouter) unregisterRouteInfo(rpcId string) {\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\tdelete(router.rpcMap, rpcId)\n}\n\nfunc (router *WshRouter) getRouteInfo(rpcId string) *rpcRoutingInfo {\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\trtn, ok := router.rpcMap[rpcId]\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn &rtn\n}\n\n// returns true if message was sent, false if failed\nfunc (router *WshRouter) sendRoutedMessage(msgBytes []byte, routeId string, commandName string, ingressLinkId baseds.LinkId) bool {\n\tif strings.HasPrefix(routeId, RoutePrefix_Link) {\n\t\tlinkIdStr := strings.TrimPrefix(routeId, RoutePrefix_Link)\n\t\tlinkIdInt, err := strconv.ParseInt(linkIdStr, 10, 32)\n\t\tif err == nil {\n\t\t\treturn router.sendMessageToLink(msgBytes, baseds.LinkId(linkIdInt), ingressLinkId)\n\t\t}\n\t}\n\tlm := router.getLinkForRoute(routeId)\n\tif lm != nil {\n\t\trouter.sendRpcMessageToLink(lm.linkId, lm.client, msgBytes, ingressLinkId, \"route\")\n\t\treturn true\n\t}\n\tupstreamLinkId, upstream := router.getUpstreamClient()\n\tif upstream != nil {\n\t\trouter.sendRpcMessageToLink(upstreamLinkId, upstream, msgBytes, ingressLinkId, \"route-upstream\")\n\t\treturn true\n\t}\n\tif commandName != \"\" {\n\t\tlog.Printf(\"[router] no rpc for route id %q command:%s\\n\", routeId, commandName)\n\t} else {\n\t\tlog.Printf(\"[router] no rpc for route id %q\\n\", routeId)\n\t}\n\treturn false\n}\n\nfunc (router *WshRouter) sendMessageToLink(msgBytes []byte, linkId baseds.LinkId, ingressLinkId baseds.LinkId) bool {\n\tlm := router.getLinkMeta(linkId)\n\tif lm == nil {\n\t\treturn false\n\t}\n\trouter.sendRpcMessageToLink(lm.linkId, lm.client, msgBytes, ingressLinkId, \"link\")\n\treturn true\n}\n\nfunc (router *WshRouter) addToBacklog_withlock(linkId baseds.LinkId, msgBytes []byte, ingressLinkId baseds.LinkId, debugStr string) {\n\tmapWasEmpty := len(router.linkMsgBacklog) == 0\n\tbacklog := router.linkMsgBacklog[linkId]\n\tbacklog = append(backlog, backlogMessageWrap{msgBytes: msgBytes, ingressLinkId: ingressLinkId, debugStr: debugStr})\n\trouter.linkMsgBacklog[linkId] = backlog\n\n\tnewLen := len(backlog)\n\thighWater := router.backlogHighWaterMark[linkId]\n\n\tif BacklogLogThresholds[newLen] && highWater < newLen {\n\t\tlog.Printf(\"[router] backlog for linkid=%d reached %d messages\\n\", linkId, newLen)\n\t}\n\n\tif newLen > highWater {\n\t\trouter.backlogHighWaterMark[linkId] = newLen\n\t}\n\n\tif mapWasEmpty {\n\t\trouter.linkBacklogCond.Signal()\n\t}\n}\n\nfunc (router *WshRouter) sendRpcMessageToLink(linkId baseds.LinkId, client AbstractRpcClient, msgBytes []byte, ingressLinkId baseds.LinkId, debugStr string) {\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\tsent := false\n\tbacklog := router.linkMsgBacklog[linkId]\n\tif len(backlog) == 0 {\n\t\tsent = client.SendRpcMessage(msgBytes, ingressLinkId, debugStr)\n\t}\n\tif !sent {\n\t\trouter.addToBacklog_withlock(linkId, msgBytes, ingressLinkId, debugStr)\n\t}\n}\n\nfunc (router *WshRouter) runServer() {\n\tfor input := range router.inputCh {\n\t\tmsgBytes := input.MsgBytes\n\t\tvar msg RpcMessage\n\t\terr := json.Unmarshal(msgBytes, &msg)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"error unmarshalling message: \", err)\n\t\t\tcontinue\n\t\t}\n\t\trouteId := msg.Route\n\t\tif msg.Command != \"\" {\n\t\t\t// new comand, setup new rpc\n\t\t\tok := router.sendRoutedMessage(msgBytes, routeId, msg.Command, input.IngressLinkId)\n\t\t\tif !ok {\n\t\t\t\trouter.handleNoRoute(msg, input.IngressLinkId)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\trouter.registerRouteInfo(msg.ReqId, input.IngressLinkId, routeId)\n\t\t\tcontinue\n\t\t}\n\t\t// look at reqid or resid to route correctly\n\t\tif msg.ReqId != \"\" {\n\t\t\trouteInfo := router.getRouteInfo(msg.ReqId)\n\t\t\tif routeInfo == nil {\n\t\t\t\t// no route info, nothing to do\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// no need to check the return value here (noop if failed)\n\t\t\trouter.sendRoutedMessage(msgBytes, routeInfo.destRouteId, \"\", input.IngressLinkId)\n\t\t\tcontinue\n\t\t} else if msg.ResId != \"\" {\n\t\t\trouteInfo := router.getRouteInfo(msg.ResId)\n\t\t\tif routeInfo == nil {\n\t\t\t\t// no route info, nothing to do\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\trouter.sendMessageToLink(msgBytes, routeInfo.sourceLinkId, input.IngressLinkId)\n\t\t\tif !msg.Cont {\n\t\t\t\trouter.unregisterRouteInfo(msg.ResId)\n\t\t\t}\n\t\t\tcontinue\n\t\t} else {\n\t\t\t// this is a bad message (no command, reqid, or resid)\n\t\t\tcontinue\n\t\t}\n\t}\n}\n\nfunc (router *WshRouter) WaitForRegister(ctx context.Context, routeId string) error {\n\tfor {\n\t\tif router.getLinkForRoute(routeId) != nil {\n\t\t\treturn nil\n\t\t}\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\tcase <-time.After(30 * time.Millisecond):\n\t\t\tcontinue\n\t\t}\n\t}\n}\n\n// this will never block, can be called while holding router.Lock\nfunc (router *WshRouter) queueUpstreamMessage(msgBytes []byte, debugStr string) {\n\t_, upstream := router.getUpstreamClient()\n\tif upstream == nil {\n\t\treturn\n\t}\n\trouter.upstreamBufLock.Lock()\n\tdefer router.upstreamBufLock.Unlock()\n\trouter.upstreamBuf = append(router.upstreamBuf, messageWrap{msgBytes: msgBytes, debugStr: debugStr})\n\tif !router.upstreamLoopStarted {\n\t\trouter.upstreamLoopStarted = true\n\t\tgo router.runUpstreamBufferLoop()\n\t}\n\trouter.upstreamBufCond.Signal()\n}\n\nfunc (router *WshRouter) runUpstreamBufferLoop() {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"WshRouter:runUpstreamBufferLoop\", recover())\n\t}()\n\tfor {\n\t\trouter.upstreamBufLock.Lock()\n\t\tfor len(router.upstreamBuf) == 0 {\n\t\t\trouter.upstreamBufCond.Wait()\n\t\t}\n\t\tmsg := router.upstreamBuf[0]\n\t\trouter.upstreamBuf = router.upstreamBuf[1:]\n\t\trouter.upstreamBufLock.Unlock()\n\n\t\tupstreamLinkId, upstream := router.getUpstreamClient()\n\t\tif upstream != nil {\n\t\t\trouter.sendRpcMessageToLink(upstreamLinkId, upstream, msg.msgBytes, baseds.NoLinkId, msg.debugStr)\n\t\t}\n\t}\n}\n\nfunc (router *WshRouter) drainLinkBacklog_withLock(linkId baseds.LinkId, lm *linkMeta, backlog []backlogMessageWrap) []backlogMessageWrap {\n\tfor len(backlog) > 0 {\n\t\tmsg := backlog[0]\n\t\tsent := lm.client.SendRpcMessage(msg.msgBytes, msg.ingressLinkId, msg.debugStr)\n\t\tif !sent {\n\t\t\treturn backlog\n\t\t}\n\t\tbacklog = backlog[1:]\n\t}\n\treturn backlog\n}\n\nfunc (router *WshRouter) processOneBacklogRound() {\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\tfor linkId, backlog := range router.linkMsgBacklog {\n\t\tlm := router.linkMap[linkId]\n\t\tif lm == nil {\n\t\t\thighWater := router.backlogHighWaterMark[linkId]\n\t\t\tif highWater > 0 {\n\t\t\t\tlog.Printf(\"[router] backlog for linkid=%d cleared, link gone (highwater mark was %d messages)\\n\", linkId, highWater)\n\t\t\t}\n\t\t\tdelete(router.linkMsgBacklog, linkId)\n\t\t\tdelete(router.backlogHighWaterMark, linkId)\n\t\t\tcontinue\n\t\t}\n\t\tnewBacklog := router.drainLinkBacklog_withLock(linkId, lm, backlog)\n\t\tif len(newBacklog) == 0 {\n\t\t\thighWater := router.backlogHighWaterMark[linkId]\n\t\t\tif highWater > 0 {\n\t\t\t\tlog.Printf(\"[router] backlog for linkid=%d cleared (highwater mark was %d messages)\\n\", linkId, highWater)\n\t\t\t}\n\t\t\tdelete(router.linkMsgBacklog, linkId)\n\t\t\tdelete(router.backlogHighWaterMark, linkId)\n\t\t\tcontinue\n\t\t}\n\t\trouter.linkMsgBacklog[linkId] = newBacklog\n\t}\n}\n\nfunc (router *WshRouter) processBacklog() {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"WshRouter:processBacklog\", recover())\n\t}()\n\tfor {\n\t\trouter.lock.Lock()\n\t\tfor len(router.linkMsgBacklog) == 0 {\n\t\t\trouter.linkBacklogCond.Wait()\n\t\t}\n\t\trouter.lock.Unlock()\n\t\trouter.processOneBacklogRound()\n\t\ttime.Sleep(50 * time.Millisecond)\n\t}\n}\n\nfunc (router *WshRouter) RegisterUntrustedLink(client AbstractRpcClient) baseds.LinkId {\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\trouter.nextLinkId++\n\tlinkId := router.nextLinkId\n\tlm := &linkMeta{\n\t\tlinkId:  linkId,\n\t\ttrusted: false,\n\t\tclient:  client,\n\t}\n\tlog.Printf(\"wshrouter register link %s\", lm.Name())\n\trouter.linkMap[linkId] = lm\n\tgo router.runLinkClientRecvLoop(linkId, client)\n\treturn linkId\n}\n\nfunc (router *WshRouter) trustLink(linkId baseds.LinkId, linkKind string) {\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\tlm := router.linkMap[linkId]\n\tif lm == nil {\n\t\treturn\n\t}\n\tlog.Printf(\"wshrouter trust link %s kind=%s\", lm.Name(), linkKind)\n\tlm.trusted = true\n\tlm.linkKind = linkKind\n}\n\nfunc (router *WshRouter) runLinkClientRecvLoop(linkId baseds.LinkId, client AbstractRpcClient) {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"WshRouter:runLinkClientRecvLoop\", recover())\n\t}()\n\texitReason := \"unknown\"\n\tlmForLog := router.getLinkMeta(linkId)\n\tlinkName := fmt.Sprintf(\"%d\", linkId)\n\tif lmForLog != nil {\n\t\tlinkName = lmForLog.Name()\n\t}\n\tlog.Printf(\"link recvloop start for %s\", linkName)\n\tdefer log.Printf(\"link recvloop done for %s (%s)\", linkName, exitReason)\n\tfor {\n\t\tmsgBytes, ok := client.RecvRpcMessage()\n\t\tif !ok {\n\t\t\texitReason = \"recv-eof\"\n\t\t\tbreak\n\t\t}\n\t\tvar rpcMsg RpcMessage\n\t\terr := json.Unmarshal(msgBytes, &rpcMsg)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tlm := router.getLinkMeta(linkId)\n\t\tif lm == nil {\n\t\t\texitReason = \"link-gone\"\n\t\t\tbreak\n\t\t}\n\t\tif rpcMsg.IsRpcRequest() {\n\t\t\tif lm.sourceRouteId != \"\" {\n\t\t\t\trpcMsg.Source = lm.sourceRouteId\n\t\t\t}\n\t\t\tif rpcMsg.Route == \"\" {\n\t\t\t\trpcMsg.Route = DefaultRoute\n\t\t\t}\n\t\t\tmsgBytes, err = json.Marshal(rpcMsg)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// allow control routes even for untrusted links (for authentication)\n\t\t\tisControlRoute := rpcMsg.Route == ControlRoute || rpcMsg.Route == ControlRootRoute\n\t\t\tif !lm.trusted {\n\t\t\t\tif !isControlRoute {\n\t\t\t\t\tsendControlUnauthenticatedErrorResponse(rpcMsg, *lm, router)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tlog.Printf(\"wshrouter control-msg route=%s link=%s command=%s source=%s\", rpcMsg.Route, lm.Name(), rpcMsg.Command, rpcMsg.Source)\n\t\t\t}\n\t\t} else {\n\t\t\t// non-request messages (responses)\n\t\t\tif !lm.trusted {\n\t\t\t\t// allow responses to RPCs we initiated\n\t\t\t\tif rpcMsg.ResId == \"\" || router.getRouteInfo(rpcMsg.ResId) == nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\trouter.inputCh <- baseds.RpcInputChType{MsgBytes: msgBytes, IngressLinkId: linkId}\n\t}\n}\n\n// synchronized, returns a copy\nfunc (router *WshRouter) getLinkMeta(linkId baseds.LinkId) *linkMeta {\n\tif linkId == baseds.NoLinkId {\n\t\treturn nil\n\t}\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\tlm := router.linkMap[linkId]\n\tif lm == nil {\n\t\treturn nil\n\t}\n\tlmCopy := *lm\n\treturn &lmCopy\n}\n\n// synchronized, returns a copy\nfunc (router *WshRouter) getLinkForRoute(routeId string) *linkMeta {\n\tif routeId == \"\" {\n\t\treturn nil\n\t}\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\tlinkId := router.routeMap[routeId]\n\tif linkId == baseds.NoLinkId {\n\t\treturn nil\n\t}\n\tlm := router.linkMap[linkId]\n\tif lm == nil {\n\t\treturn nil\n\t}\n\tlmCopy := *lm\n\treturn &lmCopy\n}\n\nfunc (router *WshRouter) GetLinkIdForRoute(routeId string) baseds.LinkId {\n\tlm := router.getLinkForRoute(routeId)\n\tif lm == nil {\n\t\treturn baseds.NoLinkId\n\t}\n\treturn lm.linkId\n}\n\n// only for leaves\nfunc (router *WshRouter) RegisterTrustedLeaf(rpc AbstractRpcClient, routeId string) (baseds.LinkId, error) {\n\tif !isBindableRouteId(routeId) {\n\t\treturn 0, fmt.Errorf(\"invalid routeid %q\", routeId)\n\t}\n\tlinkId := router.RegisterUntrustedLink(rpc)\n\trouter.trustLink(linkId, LinkKind_Leaf)\n\trouter.bindRoute(linkId, routeId, true)\n\treturn linkId, nil\n}\n\n// only for routers\nfunc (router *WshRouter) RegisterTrustedRouter(rpc AbstractRpcClient) baseds.LinkId {\n\tlinkId := router.RegisterUntrustedLink(rpc)\n\trouter.trustLink(linkId, LinkKind_Router)\n\treturn linkId\n}\n\nfunc (router *WshRouter) RegisterUpstream(rpc AbstractRpcClient) baseds.LinkId {\n\tif router.IsRootRouter() {\n\t\tpanic(\"cannot register upstream for root router\")\n\t}\n\tlinkId := router.RegisterUntrustedLink(rpc)\n\trouter.trustLink(linkId, LinkKind_Router)\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\trouter.upstreamLinkId = linkId\n\treturn linkId\n}\n\nfunc (router *WshRouter) registerControlPlane() {\n\tcontrolImpl := &WshRouterControlImpl{Router: router}\n\tcontrolRpcCtx := wshrpc.RpcContext{RouteId: ControlRoute}\n\trouter.controlRpc = MakeWshRpc(controlRpcCtx, controlImpl, \"control\")\n\n\tlinkId := router.RegisterUntrustedLink(router.controlRpc)\n\trouter.trustLink(linkId, LinkKind_Leaf)\n\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\tlm := router.linkMap[linkId]\n\tif lm != nil {\n\t\tlm.sourceRouteId = ControlRoute\n\t\trouter.routeMap[ControlRoute] = linkId\n\t\tlog.Printf(\"wshrouter registered control route %q linkid=%d\", ControlRoute, linkId)\n\t}\n}\n\nfunc (router *WshRouter) announceUpstream(routeId string) {\n\tmsg := RpcMessage{\n\t\tCommand: wshrpc.Command_RouteAnnounce,\n\t\tRoute:   ControlRoute,\n\t\tSource:  routeId,\n\t}\n\tmsgBytes, _ := json.Marshal(msg)\n\trouter.queueUpstreamMessage(msgBytes, \"upstream-announce\")\n}\n\nfunc (router *WshRouter) unannounceUpstream(routeId string) {\n\tmsg := RpcMessage{\n\t\tCommand: wshrpc.Command_RouteUnannounce,\n\t\tRoute:   ControlRoute,\n\t\tSource:  routeId,\n\t}\n\tmsgBytes, _ := json.Marshal(msg)\n\trouter.queueUpstreamMessage(msgBytes, \"upstream-unannounce\")\n}\n\nfunc (router *WshRouter) getRoutesForLink(linkId baseds.LinkId) []string {\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\tvar routes []string\n\tfor routeId, mappedLinkId := range router.routeMap {\n\t\tif mappedLinkId == linkId {\n\t\t\troutes = append(routes, routeId)\n\t\t}\n\t}\n\treturn routes\n}\n\nfunc (router *WshRouter) UnregisterLink(linkId baseds.LinkId) {\n\troutes := router.getRoutesForLink(linkId)\n\tfor _, routeId := range routes {\n\t\trouter.unbindRoute(linkId, routeId)\n\t}\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\tlm := router.linkMap[linkId]\n\tif lm != nil {\n\t\tlog.Printf(\"wshrouter unregister link %s\", lm.Name())\n\t}\n\tdelete(router.linkMap, linkId)\n\tif router.upstreamLinkId == linkId {\n\t\trouter.upstreamLinkId = baseds.NoLinkId\n\t}\n}\n\nfunc isBindableRouteId(routeId string) bool {\n\tif routeId == \"\" || strings.HasPrefix(routeId, ControlPrefix) || strings.HasPrefix(routeId, RoutePrefix_Link) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (router *WshRouter) unbindRouteLocally(linkId baseds.LinkId, routeId string) error {\n\tif linkId == baseds.NoLinkId {\n\t\treturn fmt.Errorf(\"cannot unbind %q to NoLinkId\", routeId)\n\t}\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\tif router.routeMap[routeId] == linkId {\n\t\tdelete(router.routeMap, routeId)\n\t}\n\treturn nil\n}\n\nfunc (router *WshRouter) unbindRoute(linkId baseds.LinkId, routeId string) error {\n\terr := router.unbindRouteLocally(linkId, routeId)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlm := router.getLinkMeta(linkId)\n\tif lm != nil {\n\t\tlog.Printf(\"wshrouter unbind route %q from %s\", routeId, lm.Name())\n\t}\n\trouter.unannounceUpstream(routeId)\n\tif router.IsRootRouter() {\n\t\trouter.unsubscribeFromBroker(routeId)\n\t}\n\treturn nil\n}\n\nfunc (router *WshRouter) bindRouteLocally(linkId baseds.LinkId, routeId string, isSourceRoute bool) error {\n\tif linkId == baseds.NoLinkId {\n\t\treturn fmt.Errorf(\"cannot bindroute %q to NoLinkId\", routeId)\n\t}\n\tif !isBindableRouteId(routeId) {\n\t\treturn fmt.Errorf(\"router cannot register %q route (invalid routeid)\", routeId)\n\t}\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\tlm := router.linkMap[linkId]\n\tif lm == nil {\n\t\treturn fmt.Errorf(\"cannot bind route %q, no link with id %d found\", routeId, linkId)\n\t}\n\tif !lm.trusted {\n\t\treturn fmt.Errorf(\"cannot bind route %q, link %d is not trusted\", routeId, linkId)\n\t}\n\tif isSourceRoute {\n\t\tif lm.linkKind != LinkKind_Leaf {\n\t\t\treturn fmt.Errorf(\"cannot bind source route %q to link %d (link is not a leaf)\", routeId, linkId)\n\t\t}\n\t\tif lm.sourceRouteId != \"\" && lm.sourceRouteId != routeId {\n\t\t\treturn fmt.Errorf(\"cannot bind source route %q to link %d (link already has source route %q)\", routeId, linkId, lm.sourceRouteId)\n\t\t}\n\t\tlm.sourceRouteId = routeId\n\t} else {\n\t\tif lm.linkKind != LinkKind_Router {\n\t\t\treturn fmt.Errorf(\"cannot bind route %q to link %d (link is not a router)\", routeId, linkId)\n\t\t}\n\t}\n\trouter.routeMap[routeId] = linkId\n\treturn nil\n}\n\nfunc (router *WshRouter) bindRoute(linkId baseds.LinkId, routeId string, isSourceRoute bool) error {\n\terr := router.bindRouteLocally(linkId, routeId, isSourceRoute)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlm := router.getLinkMeta(linkId)\n\tif lm != nil {\n\t\tlog.Printf(\"wshrouter bind route %q to %s\", routeId, lm.Name())\n\t}\n\t// don't announce control routes upstream (they are local only)\n\tif !strings.HasPrefix(routeId, ControlPrefix) {\n\t\trouter.announceUpstream(routeId)\n\t}\n\tif router.IsRootRouter() {\n\t\trouter.publishRouteToBroker(routeId)\n\t}\n\treturn nil\n}\n\nfunc (router *WshRouter) getUpstreamClient() (baseds.LinkId, AbstractRpcClient) {\n\trouter.lock.Lock()\n\tdefer router.lock.Unlock()\n\tif router.upstreamLinkId == baseds.NoLinkId {\n\t\treturn baseds.NoLinkId, nil\n\t}\n\tlm := router.linkMap[router.upstreamLinkId]\n\tif lm == nil {\n\t\treturn baseds.NoLinkId, nil\n\t}\n\treturn router.upstreamLinkId, lm.client\n}\n\nfunc (router *WshRouter) publishRouteToBroker(routeId string) {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"WshRouter:publishRouteToBroker\", recover())\n\t}()\n\twps.Broker.Publish(wps.WaveEvent{Event: wps.Event_RouteUp, Scopes: []string{routeId}})\n}\n\nfunc (router *WshRouter) unsubscribeFromBroker(routeId string) {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"WshRouter:unregisterRoute:routedown\", recover())\n\t}()\n\twps.Broker.UnsubscribeAll(routeId)\n\twps.Broker.Publish(wps.WaveEvent{Event: wps.Event_RouteDown, Scopes: []string{routeId}})\n}\n\nfunc sendControlUnauthenticatedErrorResponse(cmdMsg RpcMessage, linkMeta linkMeta, router *WshRouter) {\n\tif cmdMsg.ReqId == \"\" {\n\t\treturn\n\t}\n\trtnMsg := RpcMessage{\n\t\tSource: ControlRoute,\n\t\tResId:  cmdMsg.ReqId,\n\t\tError:  fmt.Sprintf(\"link is unauthenticated (%s), cannot call %q\", linkMeta.Name(), cmdMsg.Command),\n\t}\n\trtnBytes, _ := json.Marshal(rtnMsg)\n\trouter.sendRpcMessageToLink(linkMeta.linkId, linkMeta.client, rtnBytes, baseds.NoLinkId, \"unauthenticated\")\n}\n"
  },
  {
    "path": "pkg/wshutil/wshrouter_controlimpl.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshutil\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wstore\"\n)\n\ntype WshRouterControlImpl struct {\n\tRouter *WshRouter\n}\n\nfunc (impl *WshRouterControlImpl) WshServerImpl() {}\n\nfunc (impl *WshRouterControlImpl) RouteAnnounceCommand(ctx context.Context) error {\n\tsource := GetRpcSourceFromContext(ctx)\n\tif source == \"\" {\n\t\treturn fmt.Errorf(\"no source in routeannounce\")\n\t}\n\thandler := GetRpcResponseHandlerFromContext(ctx)\n\tif handler == nil {\n\t\treturn fmt.Errorf(\"no response handler in context\")\n\t}\n\tlinkId := handler.GetIngressLinkId()\n\tif linkId == baseds.NoLinkId {\n\t\treturn fmt.Errorf(\"no ingress link found\")\n\t}\n\treturn impl.Router.bindRoute(linkId, source, false)\n}\n\nfunc (impl *WshRouterControlImpl) RouteUnannounceCommand(ctx context.Context) error {\n\tsource := GetRpcSourceFromContext(ctx)\n\tif source == \"\" {\n\t\treturn fmt.Errorf(\"no source in routeunannounce\")\n\t}\n\thandler := GetRpcResponseHandlerFromContext(ctx)\n\tif handler == nil {\n\t\treturn fmt.Errorf(\"no response handler in context\")\n\t}\n\tlinkId := handler.GetIngressLinkId()\n\tif linkId == baseds.NoLinkId {\n\t\treturn fmt.Errorf(\"no ingress link found\")\n\t}\n\treturn impl.Router.unbindRoute(linkId, source)\n}\n\nfunc (impl *WshRouterControlImpl) ControlGetRouteIdCommand(ctx context.Context) (string, error) {\n\thandler := GetRpcResponseHandlerFromContext(ctx)\n\tif handler == nil {\n\t\treturn \"\", nil\n\t}\n\tlinkId := handler.GetIngressLinkId()\n\tif linkId == baseds.NoLinkId {\n\t\treturn \"\", nil\n\t}\n\tlm := impl.Router.getLinkMeta(linkId)\n\tif lm == nil {\n\t\treturn \"\", nil\n\t}\n\treturn lm.sourceRouteId, nil\n}\n\nfunc (impl *WshRouterControlImpl) SetPeerInfoCommand(ctx context.Context, peerInfo string) error {\n\tsource := GetRpcSourceFromContext(ctx)\n\tlinkId := impl.Router.GetLinkIdForRoute(source)\n\tif linkId == baseds.NoLinkId {\n\t\treturn fmt.Errorf(\"no link found for source route %q\", source)\n\t}\n\tlm := impl.Router.getLinkMeta(linkId)\n\tif lm == nil {\n\t\treturn fmt.Errorf(\"no link meta found for linkId %d\", linkId)\n\t}\n\tif proxy, ok := lm.client.(*WshRpcProxy); ok {\n\t\tproxy.SetPeerInfo(peerInfo)\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"setpeerinfo only valid for proxy connections\")\n}\n\nfunc (impl *WshRouterControlImpl) AuthenticateCommand(ctx context.Context, data string) (wshrpc.CommandAuthenticateRtnData, error) {\n\thandler := GetRpcResponseHandlerFromContext(ctx)\n\tif handler == nil {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"no response handler in context\")\n\t}\n\tlinkId := handler.GetIngressLinkId()\n\tif linkId == baseds.NoLinkId {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"no ingress link found\")\n\t}\n\n\tnewCtx, err := ValidateAndExtractRpcContextFromToken(data)\n\tif err != nil {\n\t\tlog.Printf(\"wshrouter authenticate error linkid=%d: %v\", linkId, err)\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"error validating token: %w\", err)\n\t}\n\trouteId, err := validateRpcContextFromAuth(newCtx)\n\tif err != nil {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, err\n\t}\n\n\trtnData := wshrpc.CommandAuthenticateRtnData{RouteId: routeId}\n\tif newCtx.IsRouter {\n\t\tlog.Printf(\"wshrouter authenticate success linkid=%d (router)\", linkId)\n\t\timpl.Router.trustLink(linkId, LinkKind_Router)\n\t} else {\n\t\tlog.Printf(\"wshrouter authenticate success linkid=%d routeid=%q\", linkId, routeId)\n\t\timpl.Router.trustLink(linkId, LinkKind_Leaf)\n\t\timpl.Router.bindRoute(linkId, routeId, true)\n\t}\n\n\treturn rtnData, nil\n}\n\nfunc extractTokenData(token string) (wshrpc.CommandAuthenticateRtnData, error) {\n\tentry := shellutil.GetAndRemoveTokenSwapEntry(token)\n\tif entry == nil {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"no token entry found\")\n\t}\n\t_, err := validateRpcContextFromAuth(entry.RpcContext)\n\tif err != nil {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, err\n\t}\n\tif entry.RpcContext.IsRouter {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"cannot auth router via token\")\n\t}\n\trouteId := entry.RpcContext.GenerateRouteId()\n\tif routeId == \"\" {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"no routeid\")\n\t}\n\treturn wshrpc.CommandAuthenticateRtnData{\n\t\tRouteId:        routeId,\n\t\tEnv:            entry.Env,\n\t\tInitScriptText: entry.ScriptText,\n\t\tRpcContext:     entry.RpcContext,\n\t}, nil\n}\n\nfunc (impl *WshRouterControlImpl) AuthenticateTokenVerifyCommand(ctx context.Context, data wshrpc.CommandAuthenticateTokenData) (wshrpc.CommandAuthenticateRtnData, error) {\n\tif !impl.Router.IsRootRouter() {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"authenticatetokenverify can only be called on root router\")\n\t}\n\tif data.Token == \"\" {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"no token in authenticatetoken message\")\n\t}\n\n\trtnData, err := extractTokenData(data.Token)\n\tif err != nil {\n\t\tlog.Printf(\"wshrouter authenticate-token-verify error: %v\", err)\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, err\n\t}\n\n\tlog.Printf(\"wshrouter authenticate-token-verify success routeid=%q\", rtnData.RouteId)\n\treturn rtnData, nil\n}\n\nfunc (impl *WshRouterControlImpl) AuthenticateTokenCommand(ctx context.Context, data wshrpc.CommandAuthenticateTokenData) (wshrpc.CommandAuthenticateRtnData, error) {\n\thandler := GetRpcResponseHandlerFromContext(ctx)\n\tif handler == nil {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"no response handler in context\")\n\t}\n\tlinkId := handler.GetIngressLinkId()\n\tif linkId == baseds.NoLinkId {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"no ingress link found\")\n\t}\n\n\tif data.Token == \"\" {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"no token in authenticatetoken message\")\n\t}\n\n\tvar rtnData wshrpc.CommandAuthenticateRtnData\n\tvar err error\n\n\tif impl.Router.IsRootRouter() {\n\t\trtnData, err = extractTokenData(data.Token)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"wshrouter authenticate-token error linkid=%d: %v\", linkId, err)\n\t\t\treturn wshrpc.CommandAuthenticateRtnData{}, err\n\t\t}\n\t} else {\n\t\twshRpc := GetWshRpcFromContext(ctx)\n\t\tif wshRpc == nil {\n\t\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"no wshrpc in context\")\n\t\t}\n\t\trespData, err := wshRpc.SendRpcRequest(wshrpc.Command_AuthenticateTokenVerify, data, &wshrpc.RpcOpts{Route: ControlRootRoute})\n\t\tif err != nil {\n\t\t\tlog.Printf(\"wshrouter authenticate-token error linkid=%d: failed to verify token: %v\", linkId, err)\n\t\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"failed to verify token: %w\", err)\n\t\t}\n\t\terr = utilfn.ReUnmarshal(&rtnData, respData)\n\t\tif err != nil {\n\t\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"failed to unmarshal response: %w\", err)\n\t\t}\n\t}\n\n\tif rtnData.RpcContext == nil {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"no rpccontext in token response\")\n\t}\n\tif rtnData.RouteId == \"\" {\n\t\treturn wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf(\"no routeid in token response\")\n\t}\n\tlog.Printf(\"wshrouter authenticate-token success linkid=%d routeid=%q\", linkId, rtnData.RouteId)\n\timpl.Router.trustLink(linkId, LinkKind_Leaf)\n\timpl.Router.bindRoute(linkId, rtnData.RouteId, true)\n\n\treturn rtnData, nil\n}\n\nfunc (impl *WshRouterControlImpl) AuthenticateJobManagerVerifyCommand(ctx context.Context, data wshrpc.CommandAuthenticateJobManagerData) error {\n\tif !impl.Router.IsRootRouter() {\n\t\treturn fmt.Errorf(\"authenticatejobmanagerverify can only be called on root router\")\n\t}\n\n\tif data.JobId == \"\" {\n\t\treturn fmt.Errorf(\"no jobid in authenticatejobmanager message\")\n\t}\n\tif data.JobAuthToken == \"\" {\n\t\treturn fmt.Errorf(\"no jobauthtoken in authenticatejobmanager message\")\n\t}\n\n\tjob, err := wstore.DBMustGet[*waveobj.Job](ctx, data.JobId)\n\tif err != nil {\n\t\tlog.Printf(\"wshrouter authenticate-jobmanager-verify error jobid=%q: failed to get job: %v\", data.JobId, err)\n\t\treturn fmt.Errorf(\"failed to get job: %w\", err)\n\t}\n\n\tif job.JobAuthToken != data.JobAuthToken {\n\t\tlog.Printf(\"wshrouter authenticate-jobmanager-verify error jobid=%q: invalid jobauthtoken\", data.JobId)\n\t\treturn fmt.Errorf(\"invalid jobauthtoken\")\n\t}\n\n\tlog.Printf(\"wshrouter authenticate-jobmanager-verify success jobid=%q\", data.JobId)\n\treturn nil\n}\n\nfunc (impl *WshRouterControlImpl) AuthenticateJobManagerCommand(ctx context.Context, data wshrpc.CommandAuthenticateJobManagerData) error {\n\thandler := GetRpcResponseHandlerFromContext(ctx)\n\tif handler == nil {\n\t\treturn fmt.Errorf(\"no response handler in context\")\n\t}\n\tlinkId := handler.GetIngressLinkId()\n\tif linkId == baseds.NoLinkId {\n\t\treturn fmt.Errorf(\"no ingress link found\")\n\t}\n\n\tif data.JobId == \"\" {\n\t\treturn fmt.Errorf(\"no jobid in authenticatejobmanager message\")\n\t}\n\tif data.JobAuthToken == \"\" {\n\t\treturn fmt.Errorf(\"no jobauthtoken in authenticatejobmanager message\")\n\t}\n\n\tif impl.Router.IsRootRouter() {\n\t\tjob, err := wstore.DBMustGet[*waveobj.Job](ctx, data.JobId)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"wshrouter authenticate-jobmanager error linkid=%d jobid=%q: failed to get job: %v\", linkId, data.JobId, err)\n\t\t\treturn fmt.Errorf(\"failed to get job: %w\", err)\n\t\t}\n\n\t\tif job.JobAuthToken != data.JobAuthToken {\n\t\t\tlog.Printf(\"wshrouter authenticate-jobmanager error linkid=%d jobid=%q: invalid jobauthtoken\", linkId, data.JobId)\n\t\t\treturn fmt.Errorf(\"invalid jobauthtoken\")\n\t\t}\n\t} else {\n\t\twshRpc := GetWshRpcFromContext(ctx)\n\t\tif wshRpc == nil {\n\t\t\treturn fmt.Errorf(\"no wshrpc in context\")\n\t\t}\n\t\t_, err := wshRpc.SendRpcRequest(wshrpc.Command_AuthenticateJobManagerVerify, data, &wshrpc.RpcOpts{Route: ControlRootRoute})\n\t\tif err != nil {\n\t\t\tlog.Printf(\"wshrouter authenticate-jobmanager error linkid=%d jobid=%q: failed to verify job auth token: %v\", linkId, data.JobId, err)\n\t\t\treturn fmt.Errorf(\"failed to verify job auth token: %w\", err)\n\t\t}\n\t}\n\n\trouteId := MakeJobRouteId(data.JobId)\n\tlog.Printf(\"wshrouter authenticate-jobmanager success linkid=%d jobid=%q routeid=%q\", linkId, data.JobId, routeId)\n\timpl.Router.trustLink(linkId, LinkKind_Leaf)\n\timpl.Router.bindRoute(linkId, routeId, true)\n\n\treturn nil\n}\n\nfunc validateRpcContextFromAuth(newCtx *wshrpc.RpcContext) (string, error) {\n\tif newCtx == nil {\n\t\treturn \"\", fmt.Errorf(\"no context found in jwt token\")\n\t}\n\tif newCtx.IsRouter && newCtx.RouteId != \"\" {\n\t\treturn \"\", fmt.Errorf(\"invalid context, router cannot have a routeid\")\n\t}\n\tif newCtx.IsRouter && newCtx.ProcRoute {\n\t\treturn \"\", fmt.Errorf(\"invalid context, router cannot have a proc-route\")\n\t}\n\tif !newCtx.IsRouter && newCtx.RouteId == \"\" && !newCtx.ProcRoute {\n\t\treturn \"\", fmt.Errorf(\"invalid context, must have a routeid\")\n\t}\n\tif newCtx.IsRouter {\n\t\treturn \"\", nil\n\t}\n\treturn newCtx.GenerateRouteId(), nil\n}\n"
  },
  {
    "path": "pkg/wshutil/wshrpc.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshutil\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"reflect\"\n\t\"runtime/pprof\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/streamclient\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/ds\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\nconst DefaultTimeoutMs = 5000\nconst RespChSize = 32\nconst DefaultMessageChSize = 32\nconst CtxDoneChSize = 10\n\nvar blockingExpMap = ds.MakeExpMap[bool]()\n\ntype ResponseFnType = func(any) error\n\n// returns true if handler is complete, false for an async handler\ntype CommandHandlerFnType = func(*RpcResponseHandler) bool\n\ntype ServerImpl interface {\n\tWshServerImpl()\n}\n\ntype AbstractRpcClient interface {\n\tGetPeerInfo() string\n\tSendRpcMessage(msg []byte, ingressLinkId baseds.LinkId, debugStr string) bool\n\tRecvRpcMessage() ([]byte, bool) // blocking\n}\n\ntype WshRpc struct {\n\tLock               *sync.Mutex\n\tInputCh            chan baseds.RpcInputChType\n\tOutputCh           chan []byte\n\tCtxDoneCh          chan string // for context cancellation, value is ResId\n\tRpcContext         *atomic.Pointer[wshrpc.RpcContext]\n\tRpcMap             map[string]*rpcData\n\tServerImpl         ServerImpl\n\tEventListener      *EventListener\n\tResponseHandlerMap map[string]*RpcResponseHandler // reqId => handler\n\tStreamBroker       *streamclient.Broker\n\tDebug              bool\n\tDebugName          string\n\tServerDone         bool\n}\n\ntype wshRpcContextKey struct{}\ntype wshRpcRespHandlerContextKey struct{}\n\nfunc withWshRpcContext(ctx context.Context, wshRpc *WshRpc) context.Context {\n\treturn context.WithValue(ctx, wshRpcContextKey{}, wshRpc)\n}\n\nfunc withRespHandler(ctx context.Context, handler *RpcResponseHandler) context.Context {\n\treturn context.WithValue(ctx, wshRpcRespHandlerContextKey{}, handler)\n}\n\nfunc GetWshRpcFromContext(ctx context.Context) *WshRpc {\n\trtn := ctx.Value(wshRpcContextKey{})\n\tif rtn == nil {\n\t\treturn nil\n\t}\n\treturn rtn.(*WshRpc)\n}\n\nfunc GetRpcSourceFromContext(ctx context.Context) string {\n\trtn := ctx.Value(wshRpcRespHandlerContextKey{})\n\tif rtn == nil {\n\t\treturn \"\"\n\t}\n\treturn rtn.(*RpcResponseHandler).GetSource()\n}\n\nfunc GetIsCanceledFromContext(ctx context.Context) bool {\n\trtn := ctx.Value(wshRpcRespHandlerContextKey{})\n\tif rtn == nil {\n\t\treturn false\n\t}\n\treturn rtn.(*RpcResponseHandler).IsCanceled()\n}\n\nfunc GetRpcResponseHandlerFromContext(ctx context.Context) *RpcResponseHandler {\n\trtn := ctx.Value(wshRpcRespHandlerContextKey{})\n\tif rtn == nil {\n\t\treturn nil\n\t}\n\treturn rtn.(*RpcResponseHandler)\n}\n\nfunc (w *WshRpc) GetPeerInfo() string {\n\treturn w.DebugName\n}\n\nfunc (w *WshRpc) SendRpcMessage(msg []byte, ingressLinkId baseds.LinkId, debugStr string) bool {\n\tselect {\n\tcase w.InputCh <- baseds.RpcInputChType{MsgBytes: msg, IngressLinkId: ingressLinkId}:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (w *WshRpc) RecvRpcMessage() ([]byte, bool) {\n\tmsg, more := <-w.OutputCh\n\treturn msg, more\n}\n\ntype RpcMessage struct {\n\tCommand  string `json:\"command,omitempty\"`\n\tReqId    string `json:\"reqid,omitempty\"`\n\tResId    string `json:\"resid,omitempty\"`\n\tTimeout  int64  `json:\"timeout,omitempty\"`\n\tRoute    string `json:\"route,omitempty\"`  // to route/forward requests to alternate servers\n\tSource   string `json:\"source,omitempty\"` // source route id\n\tCont     bool   `json:\"cont,omitempty\"`   // flag if additional requests/responses are forthcoming\n\tCancel   bool   `json:\"cancel,omitempty\"` // used to cancel a streaming request or response (sent from the side that is not streaming)\n\tError    string `json:\"error,omitempty\"`\n\tDataType string `json:\"datatype,omitempty\"`\n\tData     any    `json:\"data,omitempty\"`\n}\n\nfunc (r *RpcMessage) IsRpcRequest() bool {\n\treturn r.Command != \"\" || r.ReqId != \"\"\n}\n\nfunc (r *RpcMessage) Validate() error {\n\tif r.ReqId != \"\" && r.ResId != \"\" {\n\t\treturn fmt.Errorf(\"request packets may not have both reqid and resid set\")\n\t}\n\tif r.Cancel {\n\t\tif r.Command != \"\" {\n\t\t\treturn fmt.Errorf(\"cancel packets may not have command set\")\n\t\t}\n\t\tif r.ReqId == \"\" && r.ResId == \"\" {\n\t\t\treturn fmt.Errorf(\"cancel packets must have reqid or resid set\")\n\t\t}\n\t\tif r.Data != nil {\n\t\t\treturn fmt.Errorf(\"cancel packets may not have data set\")\n\t\t}\n\t\treturn nil\n\t}\n\tif r.Command != \"\" {\n\t\tif r.ResId != \"\" {\n\t\t\treturn fmt.Errorf(\"command packets may not have resid set\")\n\t\t}\n\t\tif r.Error != \"\" {\n\t\t\treturn fmt.Errorf(\"command packets may not have error set\")\n\t\t}\n\t\tif r.DataType != \"\" {\n\t\t\treturn fmt.Errorf(\"command packets may not have datatype set\")\n\t\t}\n\t\treturn nil\n\t}\n\tif r.ReqId != \"\" {\n\t\tif r.ResId == \"\" {\n\t\t\treturn fmt.Errorf(\"request packets must have resid set\")\n\t\t}\n\t\tif r.Timeout != 0 {\n\t\t\treturn fmt.Errorf(\"non-command request packets may not have timeout set\")\n\t\t}\n\t\treturn nil\n\t}\n\tif r.ResId != \"\" {\n\t\tif r.Command != \"\" {\n\t\t\treturn fmt.Errorf(\"response packets may not have command set\")\n\t\t}\n\t\tif r.ReqId == \"\" {\n\t\t\treturn fmt.Errorf(\"response packets must have reqid set\")\n\t\t}\n\t\tif r.Timeout != 0 {\n\t\t\treturn fmt.Errorf(\"response packets may not have timeout set\")\n\t\t}\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"invalid packet: must have command, reqid, or resid set\")\n}\n\ntype rpcData struct {\n\tCommand string\n\tRoute   string\n\tResCh   chan *RpcMessage\n\tHandler *RpcRequestHandler\n}\n\nfunc validateServerImpl(serverImpl ServerImpl) {\n\tif serverImpl == nil {\n\t\treturn\n\t}\n\tserverType := reflect.TypeOf(serverImpl)\n\tif serverType.Kind() != reflect.Pointer && serverType.Elem().Kind() != reflect.Struct {\n\t\tpanic(fmt.Sprintf(\"serverImpl must be a pointer to struct, got %v\", serverType))\n\t}\n}\n\n// closes outputCh when inputCh is closed/done\nfunc MakeWshRpcWithChannels(inputCh chan baseds.RpcInputChType, outputCh chan []byte, rpcCtx wshrpc.RpcContext, serverImpl ServerImpl, debugName string) *WshRpc {\n\tif inputCh == nil {\n\t\tinputCh = make(chan baseds.RpcInputChType, DefaultInputChSize)\n\t}\n\tif outputCh == nil {\n\t\toutputCh = make(chan []byte, DefaultOutputChSize)\n\t}\n\tvalidateServerImpl(serverImpl)\n\trtn := &WshRpc{\n\t\tLock:               &sync.Mutex{},\n\t\tDebugName:          debugName,\n\t\tInputCh:            inputCh,\n\t\tOutputCh:           outputCh,\n\t\tCtxDoneCh:          make(chan string, CtxDoneChSize),\n\t\tRpcMap:             make(map[string]*rpcData),\n\t\tRpcContext:         &atomic.Pointer[wshrpc.RpcContext]{},\n\t\tEventListener:      MakeEventListener(),\n\t\tServerImpl:         serverImpl,\n\t\tResponseHandlerMap: make(map[string]*RpcResponseHandler),\n\t}\n\trtn.RpcContext.Store(&rpcCtx)\n\trtn.StreamBroker = streamclient.NewBroker(AdaptWshRpc(rtn))\n\tgo rtn.runServer()\n\treturn rtn\n}\n\nfunc MakeWshRpc(rpcCtx wshrpc.RpcContext, serverImpl ServerImpl, debugName string) *WshRpc {\n\treturn MakeWshRpcWithChannels(nil, nil, rpcCtx, serverImpl, debugName)\n}\n\nfunc (w *WshRpc) GetRpcContext() wshrpc.RpcContext {\n\trtnPtr := w.RpcContext.Load()\n\treturn *rtnPtr\n}\n\nfunc (w *WshRpc) SetRpcContext(ctx wshrpc.RpcContext) {\n\tw.RpcContext.Store(&ctx)\n}\n\nfunc (w *WshRpc) registerResponseHandler(reqId string, handler *RpcResponseHandler) {\n\tw.Lock.Lock()\n\tdefer w.Lock.Unlock()\n\tw.ResponseHandlerMap[reqId] = handler\n}\n\nfunc (w *WshRpc) unregisterResponseHandler(reqId string) {\n\tw.Lock.Lock()\n\tdefer w.Lock.Unlock()\n\tdelete(w.ResponseHandlerMap, reqId)\n}\n\nfunc (w *WshRpc) cancelRequest(reqId string) {\n\tif reqId == \"\" {\n\t\treturn\n\t}\n\tw.Lock.Lock()\n\tdefer w.Lock.Unlock()\n\thandler := w.ResponseHandlerMap[reqId]\n\tif handler != nil {\n\t\thandler.canceled.Store(true)\n\t}\n\n}\n\nfunc (w *WshRpc) handleRequest(req *RpcMessage, ingressLinkId baseds.LinkId) {\n\tpprof.Do(context.Background(), pprof.Labels(\"rpc\", req.Command), func(pprofCtx context.Context) {\n\t\tw.handleRequestInternal(req, ingressLinkId, pprofCtx)\n\t})\n}\n\nfunc (w *WshRpc) handleEventRecv(req *RpcMessage) {\n\tif req.Data == nil {\n\t\treturn\n\t}\n\tvar waveEvent wps.WaveEvent\n\terr := utilfn.ReUnmarshal(&waveEvent, req.Data)\n\tif err != nil {\n\t\treturn\n\t}\n\tw.EventListener.RecvEvent(&waveEvent)\n}\n\nfunc (w *WshRpc) handleStreamData(req *RpcMessage) {\n\tif w.StreamBroker == nil {\n\t\treturn\n\t}\n\tif req.Data == nil {\n\t\treturn\n\t}\n\tvar dataPk wshrpc.CommandStreamData\n\terr := utilfn.ReUnmarshal(&dataPk, req.Data)\n\tif err != nil {\n\t\treturn\n\t}\n\tw.StreamBroker.RecvData(dataPk)\n}\n\nfunc (w *WshRpc) handleStreamAck(req *RpcMessage) {\n\tif w.StreamBroker == nil {\n\t\treturn\n\t}\n\tif req.Data == nil {\n\t\treturn\n\t}\n\tvar ackPk wshrpc.CommandStreamAckData\n\terr := utilfn.ReUnmarshal(&ackPk, req.Data)\n\tif err != nil {\n\t\treturn\n\t}\n\tw.StreamBroker.RecvAck(ackPk)\n}\n\nfunc (w *WshRpc) handleRequestInternal(req *RpcMessage, ingressLinkId baseds.LinkId, pprofCtx context.Context) {\n\tif req.Command == wshrpc.Command_EventRecv {\n\t\tw.handleEventRecv(req)\n\t\treturn\n\t}\n\n\tvar respHandler *RpcResponseHandler\n\ttimeoutMs := req.Timeout\n\tif timeoutMs <= 0 {\n\t\ttimeoutMs = DefaultTimeoutMs\n\t}\n\tctx, cancelFn := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond)\n\tctx = withWshRpcContext(ctx, w)\n\trespHandler = &RpcResponseHandler{\n\t\tw:               w,\n\t\tctx:             ctx,\n\t\treqId:           req.ReqId,\n\t\tcommand:         req.Command,\n\t\tcommandData:     req.Data,\n\t\tsource:          req.Source,\n\t\tingressLinkId:   ingressLinkId,\n\t\tdone:            &atomic.Bool{},\n\t\tcanceled:        &atomic.Bool{},\n\t\tcontextCancelFn: &atomic.Pointer[context.CancelFunc]{},\n\t\trpcCtx:          w.GetRpcContext(),\n\t}\n\trespHandler.contextCancelFn.Store(&cancelFn)\n\trespHandler.ctx = withRespHandler(ctx, respHandler)\n\tif req.ReqId != \"\" {\n\t\tw.registerResponseHandler(req.ReqId, respHandler)\n\t}\n\tisAsync := false\n\tdefer func() {\n\t\tpanicErr := panichandler.PanicHandler(\"handleRequest\", recover())\n\t\tif panicErr != nil {\n\t\t\trespHandler.SendResponseError(panicErr)\n\t\t}\n\t\tif isAsync {\n\t\t\tgo func() {\n\t\t\t\tdefer func() {\n\t\t\t\t\tpanichandler.PanicHandler(\"handleRequest:finalize\", recover())\n\t\t\t\t}()\n\t\t\t\t<-ctx.Done()\n\t\t\t\trespHandler.Finalize()\n\t\t\t}()\n\t\t} else {\n\t\t\tcancelFn()\n\t\t\trespHandler.Finalize()\n\t\t}\n\t}()\n\thandlerFn := serverImplAdapter(w.ServerImpl)\n\tisAsync = !handlerFn(respHandler)\n}\n\nfunc (w *WshRpc) runServer() {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"wshrpc.runServer\", recover())\n\t\tclose(w.OutputCh)\n\t\tw.setServerDone()\n\t}()\nouter:\n\tfor {\n\t\tvar inputVal baseds.RpcInputChType\n\t\tvar inputChMore bool\n\t\tvar resIdTimeout string\n\n\t\tselect {\n\t\tcase inputVal, inputChMore = <-w.InputCh:\n\t\t\tif !inputChMore {\n\t\t\t\tbreak outer\n\t\t\t}\n\t\t\tif w.Debug {\n\t\t\t\tlog.Printf(\"[%s] received message: %s\\n\", w.DebugName, string(inputVal.MsgBytes))\n\t\t\t}\n\t\tcase resIdTimeout = <-w.CtxDoneCh:\n\t\t\tif w.Debug {\n\t\t\t\tlog.Printf(\"[%s] received request timeout: %s\\n\", w.DebugName, resIdTimeout)\n\t\t\t}\n\t\t\tw.unregisterRpc(resIdTimeout, fmt.Errorf(\"EC-TIME: timeout waiting for response\"))\n\t\t\tcontinue\n\t\t}\n\n\t\tvar msg RpcMessage\n\t\terr := json.Unmarshal(inputVal.MsgBytes, &msg)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"wshrpc received bad message: %v\\n\", err)\n\t\t\tcontinue\n\t\t}\n\t\tif msg.Cancel {\n\t\t\tif msg.ReqId != \"\" {\n\t\t\t\tw.cancelRequest(msg.ReqId)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif msg.IsRpcRequest() {\n\t\t\t// Handle stream commands synchronously since the broker is designed to be non-blocking.\n\t\t\t// RecvData/RecvAck just enqueue to work queues, so there's no risk of blocking the main loop.\n\t\t\tif msg.Command == wshrpc.Command_StreamData {\n\t\t\t\tw.handleStreamData(&msg)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif msg.Command == wshrpc.Command_StreamDataAck {\n\t\t\t\tw.handleStreamAck(&msg)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tingressLinkId := inputVal.IngressLinkId\n\t\t\tgo func() {\n\t\t\t\tdefer func() {\n\t\t\t\t\tpanichandler.PanicHandler(\"handleRequest:goroutine\", recover())\n\t\t\t\t}()\n\t\t\t\tw.handleRequest(&msg, ingressLinkId)\n\t\t\t}()\n\t\t} else {\n\t\t\tw.sendRespWithBlockMessage(msg)\n\t\t\tif !msg.Cont {\n\t\t\t\tw.unregisterRpc(msg.ResId, nil)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (w *WshRpc) getResponseCh(resId string) (chan *RpcMessage, *rpcData) {\n\tif resId == \"\" {\n\t\treturn nil, nil\n\t}\n\tw.Lock.Lock()\n\tdefer w.Lock.Unlock()\n\trd := w.RpcMap[resId]\n\tif rd == nil {\n\t\treturn nil, nil\n\t}\n\treturn rd.ResCh, rd\n}\n\nfunc (w *WshRpc) SetServerImpl(serverImpl ServerImpl) {\n\tvalidateServerImpl(serverImpl)\n\tw.Lock.Lock()\n\tdefer w.Lock.Unlock()\n\tw.ServerImpl = serverImpl\n}\n\nfunc (w *WshRpc) registerRpc(handler *RpcRequestHandler, command string, route string, reqId string) chan *RpcMessage {\n\tw.Lock.Lock()\n\tdefer w.Lock.Unlock()\n\trpcCh := make(chan *RpcMessage, RespChSize)\n\tw.RpcMap[reqId] = &rpcData{\n\t\tHandler: handler,\n\t\tCommand: command,\n\t\tRoute:   route,\n\t\tResCh:   rpcCh,\n\t}\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"registerRpc:timeout\", recover())\n\t\t}()\n\t\t<-handler.ctx.Done()\n\t\tw.retrySendTimeout(reqId)\n\t}()\n\treturn rpcCh\n}\n\nfunc (w *WshRpc) unregisterRpc(reqId string, err error) {\n\tw.Lock.Lock()\n\tdefer w.Lock.Unlock()\n\trd := w.RpcMap[reqId]\n\tif rd == nil {\n\t\treturn\n\t}\n\tif err != nil {\n\t\terrResp := &RpcMessage{\n\t\t\tResId: reqId,\n\t\t\tError: err.Error(),\n\t\t}\n\t\t// non-blocking send since we're about to close anyway\n\t\t// likely the channel isn't being actively read\n\t\t// this also prevents us from blocking the main loop (and holding the lock)\n\t\tselect {\n\t\tcase rd.ResCh <- errResp:\n\t\tdefault:\n\t\t}\n\t}\n\tdelete(w.RpcMap, reqId)\n\tclose(rd.ResCh)\n\trd.Handler.callContextCancelFn()\n}\n\n// no response\nfunc (w *WshRpc) SendCommand(command string, data any, opts *wshrpc.RpcOpts) error {\n\tvar optsCopy wshrpc.RpcOpts\n\tif opts != nil {\n\t\toptsCopy = *opts\n\t}\n\toptsCopy.NoResponse = true\n\toptsCopy.Timeout = 0\n\thandler, err := w.SendComplexRequest(command, data, &optsCopy)\n\tif err != nil {\n\t\treturn err\n\t}\n\thandler.finalize()\n\treturn nil\n}\n\n// single response\nfunc (w *WshRpc) SendRpcRequest(command string, data any, opts *wshrpc.RpcOpts) (any, error) {\n\tvar optsCopy wshrpc.RpcOpts\n\tif opts != nil {\n\t\toptsCopy = *opts\n\t}\n\toptsCopy.NoResponse = false\n\thandler, err := w.SendComplexRequest(command, data, &optsCopy)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer handler.finalize()\n\treturn handler.NextResponse()\n}\n\ntype RpcRequestHandler struct {\n\tw           *WshRpc\n\tctx         context.Context\n\tctxCancelFn *atomic.Pointer[context.CancelFunc]\n\treqId       string\n\trespCh      chan *RpcMessage\n\tcachedResp  *RpcMessage\n}\n\nfunc (handler *RpcRequestHandler) Context() context.Context {\n\treturn handler.ctx\n}\n\nfunc (handler *RpcRequestHandler) SendCancel(ctx context.Context) error {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"SendCancel\", recover())\n\t}()\n\tmsg := &RpcMessage{\n\t\tCancel: true,\n\t\tReqId:  handler.reqId,\n\t}\n\tbarr, _ := json.Marshal(msg) // will never fail\n\tselect {\n\tcase handler.w.OutputCh <- barr:\n\t\thandler.finalize()\n\t\treturn nil\n\tcase <-ctx.Done():\n\t\thandler.finalize()\n\t\treturn fmt.Errorf(\"timeout sending cancel\")\n\t}\n}\n\nfunc (handler *RpcRequestHandler) ResponseDone() bool {\n\tif handler.cachedResp != nil {\n\t\treturn false\n\t}\n\tselect {\n\tcase msg, more := <-handler.respCh:\n\t\tif !more {\n\t\t\treturn true\n\t\t}\n\t\thandler.cachedResp = msg\n\t\treturn false\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (handler *RpcRequestHandler) NextResponse() (any, error) {\n\tvar resp *RpcMessage\n\tif handler.cachedResp != nil {\n\t\tresp = handler.cachedResp\n\t\thandler.cachedResp = nil\n\t} else {\n\t\tresp = <-handler.respCh\n\t}\n\tif resp == nil {\n\t\treturn nil, errors.New(\"response channel closed\")\n\t}\n\tif resp.Error != \"\" {\n\t\treturn nil, errors.New(resp.Error)\n\t}\n\treturn resp.Data, nil\n}\n\nfunc (handler *RpcRequestHandler) finalize() {\n\thandler.callContextCancelFn()\n\tif handler.reqId != \"\" {\n\t\thandler.w.unregisterRpc(handler.reqId, nil)\n\t}\n}\n\nfunc (handler *RpcRequestHandler) callContextCancelFn() {\n\tcancelFnPtr := handler.ctxCancelFn.Swap(nil)\n\tif cancelFnPtr != nil && *cancelFnPtr != nil {\n\t\t(*cancelFnPtr)()\n\t}\n}\n\ntype RpcResponseHandler struct {\n\tw               *WshRpc\n\tctx             context.Context\n\tcontextCancelFn *atomic.Pointer[context.CancelFunc]\n\treqId           string\n\tsource          string\n\tcommand         string\n\tcommandData     any\n\trpcCtx          wshrpc.RpcContext\n\tingressLinkId   baseds.LinkId\n\tcanceled        *atomic.Bool // canceled by requestor\n\tdone            *atomic.Bool\n}\n\nfunc (handler *RpcResponseHandler) Context() context.Context {\n\treturn handler.ctx\n}\n\nfunc (handler *RpcResponseHandler) GetCommand() string {\n\treturn handler.command\n}\n\nfunc (handler *RpcResponseHandler) GetCommandRawData() any {\n\treturn handler.commandData\n}\n\nfunc (handler *RpcResponseHandler) GetRpcContext() wshrpc.RpcContext {\n\treturn handler.rpcCtx\n}\n\nfunc (handler *RpcResponseHandler) GetSource() string {\n\treturn handler.source\n}\n\nfunc (handler *RpcResponseHandler) GetIngressLinkId() baseds.LinkId {\n\treturn handler.ingressLinkId\n}\n\nfunc (handler *RpcResponseHandler) NeedsResponse() bool {\n\treturn handler.reqId != \"\"\n}\n\nfunc (handler *RpcResponseHandler) SendMessage(msg string) {\n\trpcMsg := &RpcMessage{\n\t\tCommand: wshrpc.Command_Message,\n\t\tData: wshrpc.CommandMessageData{\n\t\t\tMessage: msg,\n\t\t},\n\t\tRoute: handler.source, // send back to source\n\t}\n\tmsgBytes, _ := json.Marshal(rpcMsg) // will never fail\n\tselect {\n\tcase handler.w.OutputCh <- msgBytes:\n\tcase <-handler.ctx.Done():\n\t}\n}\n\nfunc (handler *RpcResponseHandler) SendResponse(data any, done bool) error {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"SendResponse\", recover())\n\t}()\n\tif handler.done.Load() {\n\t\treturn fmt.Errorf(\"request already done, cannot send additional response\")\n\t}\n\tif done {\n\t\tdefer handler.close()\n\t}\n\tif handler.reqId == \"\" {\n\t\treturn nil\n\t}\n\tmsg := &RpcMessage{\n\t\tResId: handler.reqId,\n\t\tData:  data,\n\t\tCont:  !done,\n\t}\n\tbarr, err := json.Marshal(msg)\n\tif err != nil {\n\t\treturn err\n\t}\n\tselect {\n\tcase handler.w.OutputCh <- barr:\n\t\treturn nil\n\tcase <-handler.ctx.Done():\n\t\treturn fmt.Errorf(\"timeout sending response\")\n\t}\n}\n\nfunc (handler *RpcResponseHandler) SendResponseError(err error) {\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"SendResponseError\", recover())\n\t}()\n\tif handler.done.Load() {\n\t\treturn\n\t}\n\tdefer handler.close()\n\tif handler.reqId == \"\" {\n\t\treturn\n\t}\n\tmsg := &RpcMessage{\n\t\tResId: handler.reqId,\n\t\tError: err.Error(),\n\t}\n\tbarr, _ := json.Marshal(msg) // will never fail\n\tselect {\n\tcase handler.w.OutputCh <- barr:\n\tcase <-handler.ctx.Done():\n\t}\n}\n\nfunc (handler *RpcResponseHandler) IsCanceled() bool {\n\treturn handler.canceled.Load()\n}\n\nfunc (handler *RpcResponseHandler) close() {\n\tcancelFn := handler.contextCancelFn.Load()\n\tif cancelFn != nil && *cancelFn != nil {\n\t\t(*cancelFn)()\n\t\thandler.contextCancelFn.Store(nil)\n\t}\n\thandler.done.Store(true)\n}\n\n// if async, caller must call finalize\nfunc (handler *RpcResponseHandler) Finalize() {\n\t// Always unregister the handler from the map, even if already done\n\tif handler.reqId != \"\" {\n\t\thandler.w.unregisterResponseHandler(handler.reqId)\n\t}\n\tif handler.done.Load() {\n\t\treturn\n\t}\n\t// SendResponse with done=true will call close() via defer, even when reqId is empty\n\thandler.SendResponse(nil, true)\n}\n\nfunc (handler *RpcResponseHandler) IsDone() bool {\n\treturn handler.done.Load()\n}\n\nfunc (w *WshRpc) SendComplexRequest(command string, data any, opts *wshrpc.RpcOpts) (rtnHandler *RpcRequestHandler, rtnErr error) {\n\tif w.IsServerDone() {\n\t\treturn nil, errors.New(\"server is no longer running, cannot send new requests\")\n\t}\n\tif opts == nil {\n\t\topts = &wshrpc.RpcOpts{}\n\t}\n\ttimeoutMs := opts.Timeout\n\tif timeoutMs <= 0 {\n\t\ttimeoutMs = DefaultTimeoutMs\n\t}\n\tdefer func() {\n\t\tpanichandler.PanicHandler(\"SendComplexRequest\", recover())\n\t}()\n\tif command == \"\" {\n\t\treturn nil, fmt.Errorf(\"command cannot be empty\")\n\t}\n\thandler := &RpcRequestHandler{\n\t\tw:           w,\n\t\tctxCancelFn: &atomic.Pointer[context.CancelFunc]{},\n\t}\n\tvar cancelFn context.CancelFunc\n\thandler.ctx, cancelFn = context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond)\n\thandler.ctxCancelFn.Store(&cancelFn)\n\tif !opts.NoResponse {\n\t\thandler.reqId = uuid.New().String()\n\t}\n\treq := &RpcMessage{\n\t\tCommand: command,\n\t\tReqId:   handler.reqId,\n\t\tData:    data,\n\t\tTimeout: timeoutMs,\n\t\tRoute:   opts.Route,\n\t}\n\tbarr, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\thandler.respCh = w.registerRpc(handler, command, opts.Route, handler.reqId)\n\tselect {\n\tcase w.OutputCh <- barr:\n\t\treturn handler, nil\n\tcase <-handler.ctx.Done():\n\t\thandler.finalize()\n\t\treturn nil, fmt.Errorf(\"timeout sending request\")\n\t}\n}\n\nfunc (w *WshRpc) IsServerDone() bool {\n\tw.Lock.Lock()\n\tdefer w.Lock.Unlock()\n\treturn w.ServerDone\n}\n\nfunc (w *WshRpc) setServerDone() {\n\tw.Lock.Lock()\n\tdefer w.Lock.Unlock()\n\tw.ServerDone = true\n\tclose(w.CtxDoneCh)\n\tutilfn.DrainChannelSafe(w.InputCh, \"wshrpc.setServerDone\")\n}\n\nfunc (w *WshRpc) retrySendTimeout(resId string) {\n\tdone := func() bool {\n\t\tw.Lock.Lock()\n\t\tdefer w.Lock.Unlock()\n\t\tif w.ServerDone {\n\t\t\treturn true\n\t\t}\n\t\tselect {\n\t\tcase w.CtxDoneCh <- resId:\n\t\t\treturn true\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t}\n\tfor {\n\t\tif done() {\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n}\n\nfunc (w *WshRpc) sendRespWithBlockMessage(msg RpcMessage) {\n\trespCh, rd := w.getResponseCh(msg.ResId)\n\tif respCh == nil {\n\t\treturn\n\t}\n\tselect {\n\tcase respCh <- &msg:\n\t\t// normal case, message got sent, just return!\n\t\treturn\n\tdefault:\n\t\t// channel is full, we would block...\n\t}\n\t// log the fact that we're blocking\n\t_, noLog := blockingExpMap.Get(msg.ResId)\n\tif !noLog {\n\t\tlog.Printf(\"[rpc:%s] blocking on response command:%s route:%s resid:%s\\n\", w.DebugName, rd.Command, rd.Route, msg.ResId)\n\t\tblockingExpMap.Set(msg.ResId, true, time.Now().Add(time.Second))\n\t}\n\tctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)\n\tdefer cancel()\n\tselect {\n\tcase respCh <- &msg:\n\t\t// message got sent, just return!\n\t\treturn\n\tcase <-ctx.Done():\n\t}\n\tlog.Printf(\"[rpc:%s] failed to clear response channel (waited 1s), will fail RPC command:%s route:%s resid:%s\\n\", w.DebugName, rd.Command, rd.Route, msg.ResId)\n\tw.unregisterRpc(msg.ResId, nil) // we don't pass an error because the channel is full, it won't work anyway...\n}\n"
  },
  {
    "path": "pkg/wshutil/wshrpcio.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshutil\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n)\n\n// special I/O wrappers for wshrpc\n// * terminal (wrap with OSC codes)\n// * stream (json lines)\n// * websocket (json packets)\n\nfunc AdaptStreamToMsgCh(input io.Reader, output chan baseds.RpcInputChType, readCallback func()) error {\n\treturn utilfn.StreamToLines(input, func(line []byte) {\n\t\toutput <- baseds.RpcInputChType{MsgBytes: line}\n\t}, readCallback)\n}\n\nfunc AdaptOutputChToStream(outputCh chan []byte, output io.Writer) error {\n\tdrain := false\n\tdefer func() {\n\t\tif drain {\n\t\t\tutilfn.DrainChannelSafe(outputCh, \"AdaptOutputChToStream\")\n\t\t}\n\t}()\n\tfor msg := range outputCh {\n\t\tif _, err := output.Write(msg); err != nil {\n\t\t\tdrain = true\n\t\t\treturn fmt.Errorf(\"error writing to output (AdaptOutputChToStream): %w\", err)\n\t\t}\n\t\t// write trailing newline\n\t\tif _, err := output.Write([]byte{'\\n'}); err != nil {\n\t\t\tdrain = true\n\t\t\treturn fmt.Errorf(\"error writing trailing newline to output (AdaptOutputChToStream): %w\", err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc AdaptMsgChToPty(outputCh chan []byte, oscEsc string, output io.Writer) error {\n\tif len(oscEsc) != 5 {\n\t\tpanic(\"oscEsc must be 5 characters\")\n\t}\n\tfor msg := range outputCh {\n\t\tbarr, err := EncodeWaveOSCBytes(oscEsc, msg)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error encoding osc message (AdaptMsgChToPty): %w\", err)\n\t\t}\n\t\tif _, err := output.Write(barr); err != nil {\n\t\t\treturn fmt.Errorf(\"error writing osc message (AdaptMsgChToPty): %w\", err)\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/wshutil/wshstreamadapter.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshutil\n\nimport (\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\ntype WshRpcStreamClientAdapter struct {\n\trpc *WshRpc\n}\n\nfunc (a *WshRpcStreamClientAdapter) StreamDataAckCommand(data wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error {\n\treturn a.rpc.SendCommand(\"streamdataack\", data, opts)\n}\n\nfunc (a *WshRpcStreamClientAdapter) StreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error {\n\treturn a.rpc.SendCommand(\"streamdata\", data, opts)\n}\n\nfunc AdaptWshRpc(rpc *WshRpc) *WshRpcStreamClientAdapter {\n\treturn &WshRpcStreamClientAdapter{rpc: rpc}\n}\n"
  },
  {
    "path": "pkg/wshutil/wshutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wshutil\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/wavetermdev/waveterm/pkg/baseds\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/packetparser\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavejwt\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n)\n\n// these should both be 5 characters\nconst WaveOSC = \"23198\"\nconst WaveServerOSC = \"23199\"\nconst WaveOSCPrefixLen = 5 + 3 // \\x1b] + WaveOSC + ; + \\x07\n\nconst WaveOSCPrefix = \"\\x1b]\" + WaveOSC + \";\"\nconst WaveServerOSCPrefix = \"\\x1b]\" + WaveServerOSC + \";\"\n\nconst HexChars = \"0123456789ABCDEF\"\nconst BEL = 0x07\nconst ST = 0x9c\nconst ESC = 0x1b\n\nconst DefaultOutputChSize = 32\nconst DefaultInputChSize = 32\n\nconst WaveJwtTokenVarName = wavebase.WaveJwtTokenVarName\n\n// OSC escape types\n// OSC 23198 ; (JSON | base64-JSON) ST\n// JSON = must escape all ASCII control characters ([\\x00-\\x1F\\x7F])\n// we can tell the difference between JSON and base64-JSON by the first character: '{' or not\n\n// for responses (terminal -> program), we'll use OSC 23199\n// same json format\n\nfunc copyOscPrefix(dst []byte, oscNum string) {\n\tdst[0] = ESC\n\tdst[1] = ']'\n\tcopy(dst[2:], oscNum)\n\tdst[len(oscNum)+2] = ';'\n}\n\nfunc oscPrefixLen(oscNum string) int {\n\treturn 3 + len(oscNum)\n}\n\nfunc makeOscPrefix(oscNum string) []byte {\n\toutput := make([]byte, oscPrefixLen(oscNum))\n\tcopyOscPrefix(output, oscNum)\n\treturn output\n}\n\nfunc EncodeWaveOSCBytes(oscNum string, barr []byte) ([]byte, error) {\n\tif len(oscNum) != 5 {\n\t\treturn nil, fmt.Errorf(\"oscNum must be 5 characters\")\n\t}\n\tconst maxSize = 64 * 1024 * 1024 // 64 MB\n\tif len(barr) > maxSize {\n\t\treturn nil, fmt.Errorf(\"input data too large\")\n\t}\n\thasControlChars := false\n\tfor _, b := range barr {\n\t\tif b < 0x20 || b == 0x7F {\n\t\t\thasControlChars = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !hasControlChars {\n\t\t// If no control characters, directly construct the output\n\t\t// \\x1b] (2) + WaveOSC + ; (1) + message + \\x07 (1)\n\t\toutput := make([]byte, oscPrefixLen(oscNum)+len(barr)+1)\n\t\tcopyOscPrefix(output, oscNum)\n\t\tcopy(output[oscPrefixLen(oscNum):], barr)\n\t\toutput[len(output)-1] = BEL\n\t\treturn output, nil\n\t}\n\n\tvar buf bytes.Buffer\n\tbuf.Write(makeOscPrefix(oscNum))\n\tescSeq := [6]byte{'\\\\', 'u', '0', '0', '0', '0'}\n\tfor _, b := range barr {\n\t\tif b < 0x20 || b == 0x7f {\n\t\t\tescSeq[4] = HexChars[b>>4]\n\t\t\tescSeq[5] = HexChars[b&0x0f]\n\t\t\tbuf.Write(escSeq[:])\n\t\t} else {\n\t\t\tbuf.WriteByte(b)\n\t\t}\n\t}\n\tbuf.WriteByte(BEL)\n\treturn buf.Bytes(), nil\n}\n\nfunc EncodeWaveOSCMessageEx(oscNum string, msg *RpcMessage) ([]byte, error) {\n\tif msg == nil {\n\t\treturn nil, fmt.Errorf(\"nil message\")\n\t}\n\tbarr, err := json.Marshal(msg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error marshalling message to json: %w\", err)\n\t}\n\treturn EncodeWaveOSCBytes(oscNum, barr)\n}\n\nvar shutdownOnce sync.Once\n\nfunc DoShutdown(reason string, exitCode int, quiet bool) {\n\tshutdownOnce.Do(func() {\n\t\tdefer os.Exit(exitCode)\n\t\tif !quiet && reason != \"\" {\n\t\t\tlog.Printf(\"shutting down: %s\\n\", reason)\n\t\t}\n\t})\n}\n\nfunc SetupPacketRpcClient(input io.Reader, output io.Writer, serverImpl ServerImpl, debugStr string) (*WshRpc, chan []byte) {\n\tmessageCh := make(chan baseds.RpcInputChType, DefaultInputChSize)\n\toutputCh := make(chan []byte, DefaultOutputChSize)\n\trawCh := make(chan []byte, DefaultOutputChSize)\n\trpcClient := MakeWshRpcWithChannels(messageCh, outputCh, wshrpc.RpcContext{}, serverImpl, debugStr)\n\tgo packetparser.Parse(input, messageCh, rawCh)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"SetupPacketRpcClient:outputloop\", recover())\n\t\t}()\n\t\tfor msg := range outputCh {\n\t\t\tpacketparser.WritePacket(output, msg)\n\t\t}\n\t}()\n\treturn rpcClient, rawCh\n}\n\nfunc SetupConnRpcClient(conn net.Conn, serverImpl ServerImpl, debugStr string) (*WshRpc, chan error, error) {\n\tinputCh := make(chan baseds.RpcInputChType, DefaultInputChSize)\n\toutputCh := make(chan []byte, DefaultOutputChSize)\n\twriteErrCh := make(chan error, 1)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"SetupConnRpcClient:AdaptOutputChToStream\", recover())\n\t\t}()\n\t\twriteErr := AdaptOutputChToStream(outputCh, conn)\n\t\tif writeErr != nil {\n\t\t\twriteErrCh <- writeErr\n\t\t\tclose(writeErrCh)\n\t\t}\n\t}()\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"SetupConnRpcClient:AdaptStreamToMsgCh\", recover())\n\t\t}()\n\t\t// when input is closed, close the connection\n\t\tdefer conn.Close()\n\t\tAdaptStreamToMsgCh(conn, inputCh, nil)\n\t}()\n\trtn := MakeWshRpcWithChannels(inputCh, outputCh, wshrpc.RpcContext{}, serverImpl, debugStr)\n\treturn rtn, writeErrCh, nil\n}\n\nfunc tryTcpSocket(sockName string) (net.Conn, error) {\n\taddr, err := net.ResolveTCPAddr(\"tcp\", sockName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn net.DialTCP(\"tcp\", nil, addr)\n}\n\nfunc SetupDomainSocketRpcClient(sockName string, serverImpl ServerImpl, debugName string) (*WshRpc, error) {\n\tsockName = wavebase.ExpandHomeDirSafe(sockName)\n\tresolvedPath, err := filepath.EvalSymlinks(sockName)\n\tif err == nil {\n\t\tsockName = resolvedPath\n\t}\n\tif !filepath.IsAbs(sockName) {\n\t\treturn nil, fmt.Errorf(\"socket path must be absolute: %s\", sockName)\n\t}\n\tconn, tcpErr := tryTcpSocket(sockName)\n\tvar unixErr error\n\tif tcpErr != nil {\n\t\tconn, unixErr = net.Dial(\"unix\", sockName)\n\t}\n\tif tcpErr != nil && unixErr != nil {\n\t\treturn nil, fmt.Errorf(\"failed to connect to tcp or unix domain socket: tcp err:%w: unix socket err: %w\", tcpErr, unixErr)\n\t}\n\trtn, errCh, err := SetupConnRpcClient(conn, serverImpl, debugName)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"SetupDomainSocketRpcClient:closeConn\", recover())\n\t\t}()\n\t\tdefer conn.Close()\n\t\terr := <-errCh\n\t\tif err != nil && err != io.EOF {\n\t\t\tlog.Printf(\"error in domain socket connection: %v\\n\", err)\n\t\t}\n\t}()\n\treturn rtn, err\n}\n\nfunc MakeClientJWTToken(rpcCtx wshrpc.RpcContext) (string, error) {\n\tif wavebase.IsDevMode() {\n\t\tif rpcCtx.IsRouter && (rpcCtx.RouteId != \"\" || rpcCtx.ProcRoute) {\n\t\t\tpanic(\"Invalid RpcCtx, router w/ routeid\")\n\t\t}\n\t\tif !rpcCtx.IsRouter && (rpcCtx.RouteId == \"\" && !rpcCtx.ProcRoute) {\n\t\t\tpanic(\"Invalid RpcCtx, no routeid\")\n\t\t}\n\t}\n\tclaims := &wavejwt.WaveJwtClaims{\n\t\tSock:      rpcCtx.SockName,\n\t\tRouteId:   rpcCtx.RouteId,\n\t\tProcRoute: rpcCtx.ProcRoute,\n\t\tBlockId:   rpcCtx.BlockId,\n\t\tConn:      rpcCtx.Conn,\n\t\tRouter:    rpcCtx.IsRouter,\n\t}\n\treturn wavejwt.Sign(claims)\n}\n\nfunc claimsToRpcCtx(claims *wavejwt.WaveJwtClaims) *wshrpc.RpcContext {\n\treturn &wshrpc.RpcContext{\n\t\tSockName:  claims.Sock,\n\t\tRouteId:   claims.RouteId,\n\t\tProcRoute: claims.ProcRoute,\n\t\tBlockId:   claims.BlockId,\n\t\tConn:      claims.Conn,\n\t\tIsRouter:  claims.Router,\n\t}\n}\n\nfunc ValidateAndExtractRpcContextFromToken(tokenStr string) (*wshrpc.RpcContext, error) {\n\tclaims, err := wavejwt.ValidateAndExtract(tokenStr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn claimsToRpcCtx(claims), nil\n}\n\nfunc RunWshRpcOverListener(listener net.Listener, readCallback func()) {\n\tdefer log.Printf(\"domain socket listener shutting down\\n\")\n\tfor {\n\t\tconn, err := listener.Accept()\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error accepting connection: %v\\n\", err)\n\t\t\tbreak\n\t\t}\n\t\tlog.Print(\"got domain socket connection\\n\")\n\t\tgo handleDomainSocketClient(conn, readCallback)\n\t}\n}\n\ntype WriteFlusher interface {\n\tWrite([]byte) (int, error)\n\tFlush() error\n}\n\n// blocking, returns if there is an error, or on EOF of input\nfunc HandleStdIOClient(logName string, input chan utilfn.LineOutput, output io.Writer) {\n\tproxy := MakeRpcProxy(logName)\n\tlinkId := DefaultRouter.RegisterTrustedRouter(proxy)\n\trawCh := make(chan []byte, DefaultInputChSize)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"HandleStdIOClient:ParseWithLinesChan\", recover())\n\t\t}()\n\t\tpacketparser.ParseWithLinesChan(input, proxy.FromRemoteCh, rawCh)\n\t}()\n\tdoneCh := make(chan struct{})\n\tvar doneOnce sync.Once\n\tcloseDoneCh := func() {\n\t\tdoneOnce.Do(func() {\n\t\t\tclose(doneCh)\n\t\t\tDefaultRouter.UnregisterLink(linkId)\n\t\t\tclose(proxy.FromRemoteCh)\n\t\t})\n\t}\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"HandleStdIOClient:ToRemoteChLoop\", recover())\n\t\t}()\n\t\tdefer closeDoneCh()\n\t\tfor msg := range proxy.ToRemoteCh {\n\t\t\terr := packetparser.WritePacket(output, msg)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"[%s] error writing to output: %v\\n\", logName, err)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}()\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"HandleStdIOClient:RawChLoop\", recover())\n\t\t}()\n\t\tdefer closeDoneCh()\n\t\tfor msg := range rawCh {\n\t\t\tif !bytes.HasSuffix(msg, []byte{'\\n'}) {\n\t\t\t\tmsg = append(msg, '\\n')\n\t\t\t}\n\t\t\tlog.Printf(\"[%s:stdout] %s\", logName, msg)\n\t\t}\n\t}()\n\t<-doneCh\n}\n\nfunc handleDomainSocketClient(conn net.Conn, readCallback func()) {\n\tvar linkIdContainer atomic.Int32\n\tproxy := MakeRpcProxy(\"domain\")\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"handleDomainSocketClient:AdaptOutputChToStream\", recover())\n\t\t}()\n\t\twriteErr := AdaptOutputChToStream(proxy.ToRemoteCh, conn)\n\t\tif writeErr != nil {\n\t\t\tlog.Printf(\"error writing to domain socket: %v\\n\", writeErr)\n\t\t}\n\t}()\n\tgo func() {\n\t\t// when input is closed, close the connection\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"handleDomainSocketClient:AdaptStreamToMsgCh\", recover())\n\t\t}()\n\t\tdefer func() {\n\t\t\tconn.Close()\n\t\t\tclose(proxy.FromRemoteCh)\n\t\t\tclose(proxy.ToRemoteCh)\n\t\t\tlinkId := linkIdContainer.Load()\n\t\t\tif linkId != baseds.NoLinkId {\n\t\t\t\tDefaultRouter.UnregisterLink(baseds.LinkId(linkId))\n\t\t\t}\n\t\t}()\n\t\tAdaptStreamToMsgCh(conn, proxy.FromRemoteCh, readCallback)\n\t}()\n\tlinkId := DefaultRouter.RegisterUntrustedLink(proxy)\n\tlinkIdContainer.Store(int32(linkId))\n}\n\n// only for use on client\nfunc ExtractUnverifiedRpcContext(tokenStr string) (*wshrpc.RpcContext, error) {\n\ttoken, _, err := new(jwt.Parser).ParseUnverified(tokenStr, &wavejwt.WaveJwtClaims{})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error parsing token: %w\", err)\n\t}\n\tclaims, ok := token.Claims.(*wavejwt.WaveJwtClaims)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"error getting claims from token\")\n\t}\n\treturn claimsToRpcCtx(claims), nil\n}\n\n// only for use on client\nfunc ExtractUnverifiedSocketName(tokenStr string) (string, error) {\n\ttoken, _, err := new(jwt.Parser).ParseUnverified(tokenStr, &wavejwt.WaveJwtClaims{})\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error parsing token: %w\", err)\n\t}\n\tclaims, ok := token.Claims.(*wavejwt.WaveJwtClaims)\n\tif !ok {\n\t\treturn \"\", fmt.Errorf(\"error getting claims from token\")\n\t}\n\tsockName := claims.Sock\n\tif sockName == \"\" {\n\t\treturn \"\", fmt.Errorf(\"sock claim is missing or invalid\")\n\t}\n\tsockName = wavebase.ExpandHomeDirSafe(sockName)\n\treturn sockName, nil\n}\n\nfunc getShell() string {\n\tif runtime.GOOS == \"darwin\" {\n\t\treturn shellutil.GetMacUserShell()\n\t}\n\tshell := os.Getenv(\"SHELL\")\n\tif shell == \"\" {\n\t\treturn \"/bin/bash\"\n\t}\n\treturn strings.TrimSpace(shell)\n}\n\nfunc GetInfo() wshrpc.RemoteInfo {\n\treturn wshrpc.RemoteInfo{\n\t\tClientArch:    runtime.GOARCH,\n\t\tClientOs:      runtime.GOOS,\n\t\tClientVersion: wavebase.WaveVersion,\n\t\tShell:         getShell(),\n\t\tHomeDir:       wavebase.GetHomeDir(),\n\t}\n}\n\nfunc InstallRcFiles() error {\n\thome := wavebase.GetHomeDir()\n\twaveDir := filepath.Join(home, wavebase.RemoteWaveHomeDirName)\n\twshBinDir := filepath.Join(waveDir, wavebase.RemoteWshBinDirName)\n\treturn shellutil.InitRcFiles(waveDir, wshBinDir)\n}\n\nfunc SendErrCh[T any](err error) <-chan wshrpc.RespOrErrorUnion[T] {\n\tch := make(chan wshrpc.RespOrErrorUnion[T], 1)\n\tch <- RespErr[T](err)\n\tclose(ch)\n\treturn ch\n}\n\nfunc RespErr[T any](err error) wshrpc.RespOrErrorUnion[T] {\n\treturn wshrpc.RespOrErrorUnion[T]{Error: err}\n}\n"
  },
  {
    "path": "pkg/wsl/wsl-unix.go",
    "content": "//go:build !windows\n\n// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wsl\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n)\n\ntype WslName struct {\n\tDistro string `json:\"distro\"`\n}\n\nfunc RegisteredDistros(ctx context.Context) (distros []Distro, err error) {\n\treturn nil, fmt.Errorf(\"RegisteredDistros not implemented on this system\")\n}\n\nfunc DefaultDistro(ctx context.Context) (d Distro, ok bool, err error) {\n\treturn d, false, fmt.Errorf(\"DefaultDistro not implemented on this system\")\n}\n\ntype Distro struct{}\n\nfunc (d *Distro) Name() string {\n\treturn \"\"\n}\n\nfunc (d *Distro) WslCommand(ctx context.Context, cmd string) *WslCmd {\n\treturn nil\n}\n\n// just use the regular cmd since it's\n// similar enough to not cause issues\n// type WslCmd = exec.Cmd\ntype WslCmd struct {\n\texec.Cmd\n}\n\nfunc (wc *WslCmd) GetProcess() *os.Process {\n\treturn nil\n}\n\nfunc (wc *WslCmd) GetProcessState() *os.ProcessState {\n\treturn nil\n}\n\nfunc (wc *WslCmd) ExitCode() int {\n\treturn -1\n}\n\nfunc (wc *WslCmd) ExitSignal() string {\n\treturn \"\"\n}\n\nfunc (c *WslCmd) SetStdin(stdin io.Reader) {\n\tc.Stdin = stdin\n}\n\nfunc (c *WslCmd) SetStdout(stdout io.Writer) {\n\tc.Stdout = stdout\n}\n\nfunc (c *WslCmd) SetStderr(stderr io.Writer) {\n\tc.Stderr = stderr\n}\n\nfunc GetDistroCmd(ctx context.Context, wslDistroName string, cmd string) (*WslCmd, error) {\n\treturn nil, fmt.Errorf(\"GetDistroCmd not implemented on this system\")\n}\n\nfunc GetDistro(ctx context.Context, wslDistroName WslName) (*Distro, error) {\n\treturn nil, fmt.Errorf(\"GetDistro not implemented on this system\")\n}\n"
  },
  {
    "path": "pkg/wsl/wsl-win.go",
    "content": "//go:build windows\n\n// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wsl\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"sync\"\n\n\t\"github.com/ubuntu/gowsl\"\n)\n\nvar RegisteredDistros = gowsl.RegisteredDistros\nvar DefaultDistro = gowsl.DefaultDistro\n\ntype WslName struct {\n\tDistro string `json:\"distro\"`\n}\n\ntype Distro struct {\n\tgowsl.Distro\n}\n\ntype WslCmd struct {\n\tc       *gowsl.Cmd\n\twg      *sync.WaitGroup\n\tonce    *sync.Once\n\tlock    *sync.Mutex\n\twaitErr error\n}\n\nfunc (d *Distro) WslCommand(ctx context.Context, cmd string) *WslCmd {\n\tif ctx == nil {\n\t\tpanic(\"nil Context\")\n\t}\n\tinnerCmd := d.Command(ctx, cmd)\n\tvar wg sync.WaitGroup\n\tvar lock *sync.Mutex\n\treturn &WslCmd{innerCmd, &wg, new(sync.Once), lock, nil}\n}\n\nfunc (c *WslCmd) CombinedOutput() (out []byte, err error) {\n\treturn c.c.CombinedOutput()\n}\nfunc (c *WslCmd) Output() (out []byte, err error) {\n\treturn c.c.Output()\n}\nfunc (c *WslCmd) Run() error {\n\treturn c.c.Run()\n}\nfunc (c *WslCmd) Start() (err error) {\n\treturn c.c.Start()\n}\nfunc (c *WslCmd) StderrPipe() (r io.ReadCloser, err error) {\n\treturn c.c.StderrPipe()\n}\nfunc (c *WslCmd) StdinPipe() (w io.WriteCloser, err error) {\n\treturn c.c.StdinPipe()\n}\nfunc (c *WslCmd) StdoutPipe() (r io.ReadCloser, err error) {\n\treturn c.c.StdoutPipe()\n}\nfunc (c *WslCmd) Wait() (err error) {\n\tc.wg.Add(1)\n\tc.once.Do(func() {\n\t\tc.waitErr = c.c.Wait()\n\t})\n\tc.wg.Done()\n\tc.wg.Wait()\n\tif c.waitErr != nil && c.waitErr.Error() == \"not started\" {\n\t\tc.once = new(sync.Once)\n\t\treturn c.waitErr\n\t}\n\treturn c.waitErr\n}\nfunc (c *WslCmd) ExitCode() int {\n\tstate := c.c.ProcessState\n\tif state == nil {\n\t\treturn -1\n\t}\n\treturn state.ExitCode()\n}\n\nfunc (c *WslCmd) ExitSignal() string {\n\treturn \"\"\n}\n\nfunc (c *WslCmd) GetProcess() *os.Process {\n\treturn c.c.Process\n}\n\nfunc (c *WslCmd) GetProcessState() *os.ProcessState {\n\treturn c.c.ProcessState\n}\n\nfunc (c *WslCmd) SetStdin(stdin io.Reader) {\n\tc.c.Stdin = stdin\n}\n\nfunc (c *WslCmd) SetStdout(stdout io.Writer) {\n\tc.c.Stdout = stdout\n}\n\nfunc (c *WslCmd) SetStderr(stderr io.Writer) {\n\tc.c.Stderr = stderr\n}\n\nfunc GetDistroCmd(ctx context.Context, wslDistroName string, cmd string) (*WslCmd, error) {\n\tdistros, err := RegisteredDistros(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, distro := range distros {\n\t\tif distro.Name() != wslDistroName {\n\t\t\tcontinue\n\t\t}\n\t\twrappedDistro := Distro{distro}\n\t\treturn wrappedDistro.WslCommand(ctx, cmd), nil\n\t}\n\treturn nil, fmt.Errorf(\"wsl distro %s not found\", wslDistroName)\n}\n\nfunc GetDistro(ctx context.Context, wslDistroName WslName) (*Distro, error) {\n\tdistros, err := RegisteredDistros(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, distro := range distros {\n\t\tif distro.Name() != wslDistroName.Distro {\n\t\t\tcontinue\n\t\t}\n\t\twrappedDistro := Distro{distro}\n\t\treturn &wrappedDistro, nil\n\t}\n\treturn nil, fmt.Errorf(\"wsl distro %s not found\", wslDistroName)\n}\n"
  },
  {
    "path": "pkg/wslconn/wsl-util.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wslconn\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"text/template\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/blocklogger\"\n\t\"github.com/wavetermdev/waveterm/pkg/genconn\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/wsl\"\n)\n\nfunc hasBashInstalled(ctx context.Context, client *wsl.Distro) (bool, error) {\n\tcmd := client.WslCommand(ctx, \"which bash\")\n\tout, whichErr := cmd.Output()\n\tif whichErr == nil && len(out) != 0 {\n\t\treturn true, nil\n\t}\n\n\tcmd = client.WslCommand(ctx, \"where.exe bash\")\n\tout, whereErr := cmd.Output()\n\tif whereErr == nil && len(out) != 0 {\n\t\treturn true, nil\n\t}\n\n\t// note: we could also check in /bin/bash explicitly\n\t// just in case that wasn't added to the path. but if\n\t// that's true, we will most likely have worse\n\t// problems going forward\n\n\treturn false, nil\n}\n\nfunc normalizeOs(os string) string {\n\tos = strings.ToLower(strings.TrimSpace(os))\n\treturn os\n}\n\nfunc normalizeArch(arch string) string {\n\tarch = strings.ToLower(strings.TrimSpace(arch))\n\tswitch arch {\n\tcase \"x86_64\", \"amd64\":\n\t\tarch = \"x64\"\n\tcase \"arm64\", \"aarch64\":\n\t\tarch = \"arm64\"\n\t}\n\treturn arch\n}\n\n// returns (os, arch, error)\n// guaranteed to return a supported platform\nfunc GetClientPlatform(ctx context.Context, shell genconn.ShellClient) (string, string, error) {\n\tblocklogger.Infof(ctx, \"[conndebug] running `uname -sm` to detect client platform\\n\")\n\tstdout, stderr, err := genconn.RunSimpleCommand(ctx, shell, genconn.CommandSpec{\n\t\tCmd: \"uname -sm\",\n\t})\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"error running uname -sm: %w, stderr: %s\", err, stderr)\n\t}\n\t// Parse and normalize output\n\tparts := strings.Fields(strings.ToLower(strings.TrimSpace(stdout)))\n\tif len(parts) != 2 {\n\t\treturn \"\", \"\", fmt.Errorf(\"unexpected output from uname: %s\", stdout)\n\t}\n\tos, arch := normalizeOs(parts[0]), normalizeArch(parts[1])\n\tif err := wavebase.ValidateWshSupportedArch(os, arch); err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\treturn os, arch, nil\n}\n\nfunc GetClientPlatformFromOsArchStr(ctx context.Context, osArchStr string) (string, string, error) {\n\tparts := strings.Fields(strings.TrimSpace(osArchStr))\n\tif len(parts) != 2 {\n\t\treturn \"\", \"\", fmt.Errorf(\"unexpected output from uname: %s\", osArchStr)\n\t}\n\tos, arch := normalizeOs(parts[0]), normalizeArch(parts[1])\n\tif err := wavebase.ValidateWshSupportedArch(os, arch); err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\treturn os, arch, nil\n}\n\ntype CancellableCmd struct {\n\tCmd    *wsl.WslCmd\n\tCancel func()\n}\n\nvar installTemplatesRawBash = map[string]string{\n\t\"mkdir\": `bash -c 'mkdir -p {{.installDir}}'`,\n\t\"cat\":   `bash -c 'cat > {{.tempPath}}'`,\n\t\"mv\":    `bash -c 'mv {{.tempPath}} {{.installPath}}'`,\n\t\"chmod\": `bash -c 'chmod a+x {{.installPath}}'`,\n}\n\nvar installTemplatesRawDefault = map[string]string{\n\t\"mkdir\": `mkdir -p {{.installDir}}`,\n\t\"cat\":   `cat > {{.tempPath}}`,\n\t\"mv\":    `mv {{.tempPath}} {{.installPath}}`,\n\t\"chmod\": `chmod a+x {{.installPath}}`,\n}\n\nfunc makeCancellableCommand(ctx context.Context, client *wsl.Distro, cmdTemplateRaw string, words map[string]string) (*CancellableCmd, error) {\n\tcmdContext, cmdCancel := context.WithCancel(ctx)\n\n\tcmdStr := &bytes.Buffer{}\n\tcmdTemplate, err := template.New(\"\").Parse(cmdTemplateRaw)\n\tif err != nil {\n\t\tcmdCancel()\n\t\treturn nil, err\n\t}\n\tcmdTemplate.Execute(cmdStr, words)\n\n\tcmd := client.WslCommand(cmdContext, cmdStr.String())\n\treturn &CancellableCmd{cmd, cmdCancel}, nil\n}\n\nfunc CpWshToRemote(ctx context.Context, client *wsl.Distro, clientOs string, clientArch string) error {\n\twshLocalPath, err := shellutil.GetLocalWshBinaryPath(wavebase.WaveVersion, clientOs, clientArch)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// warning: does not work on windows remote yet\n\tbashInstalled, err := hasBashInstalled(ctx, client)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar selectedTemplatesRaw map[string]string\n\tif bashInstalled {\n\t\tselectedTemplatesRaw = installTemplatesRawBash\n\t} else {\n\t\tlog.Printf(\"bash is not installed on remote. attempting with default shell\")\n\t\tselectedTemplatesRaw = installTemplatesRawDefault\n\t}\n\n\t// I need to use toSlash here to force unix keybindings\n\t// this means we can't guarantee it will work on a remote windows machine\n\tvar installWords = map[string]string{\n\t\t\"installDir\":  filepath.ToSlash(filepath.Dir(wavebase.RemoteFullWshBinPath)),\n\t\t\"tempPath\":    wavebase.RemoteFullWshBinPath + \".temp\",\n\t\t\"installPath\": wavebase.RemoteFullWshBinPath,\n\t}\n\n\tblocklogger.Infof(ctx, \"[conndebug] copying %q to remote server %q\\n\", wshLocalPath, wavebase.RemoteFullWshBinPath)\n\tinstallStepCmds := make(map[string]*CancellableCmd)\n\tfor cmdName, selectedTemplateRaw := range selectedTemplatesRaw {\n\t\tcancellableCmd, err := makeCancellableCommand(ctx, client, selectedTemplateRaw, installWords)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tinstallStepCmds[cmdName] = cancellableCmd\n\t}\n\n\t_, err = installStepCmds[\"mkdir\"].Cmd.Output()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// the cat part of this is complicated since it requires stdin\n\tcatCmd := installStepCmds[\"cat\"].Cmd\n\tcatStdin, err := catCmd.StdinPipe()\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = catCmd.Start()\n\tif err != nil {\n\t\treturn err\n\t}\n\tinput, err := os.Open(wshLocalPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot open local file %s to send to host: %v\", wshLocalPath, err)\n\t}\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"wslutil:cpHostToRemote:catStdin\", recover())\n\t\t}()\n\t\tio.Copy(catStdin, input)\n\t\tinstallStepCmds[\"cat\"].Cancel()\n\n\t\t// backup just in case something weird happens\n\t\t// could cause potential race condition, but very\n\t\t// unlikely\n\t\ttime.Sleep(time.Second * 1)\n\t\tprocess := catCmd.GetProcess()\n\t\tif process != nil {\n\t\t\tprocess.Kill()\n\t\t}\n\t}()\n\tcatErr := catCmd.Wait()\n\tif catErr != nil && !errors.Is(catErr, context.Canceled) {\n\t\treturn catErr\n\t}\n\n\t_, err = installStepCmds[\"mv\"].Cmd.Output()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = installStepCmds[\"chmod\"].Cmd.Output()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc IsPowershell(shellPath string) bool {\n\t// get the base path, and then check contains\n\tshellBase := filepath.Base(shellPath)\n\treturn strings.Contains(shellBase, \"powershell\") || strings.Contains(shellBase, \"pwsh\")\n}\n"
  },
  {
    "path": "pkg/wslconn/wslconn.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wslconn\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/blocklogger\"\n\t\"github.com/wavetermdev/waveterm/pkg/genconn\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/remote/conncontroller\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry\"\n\t\"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata\"\n\t\"github.com/wavetermdev/waveterm/pkg/userinput\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/shellutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/utilfn\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\t\"github.com/wavetermdev/waveterm/pkg/wconfig\"\n\t\"github.com/wavetermdev/waveterm/pkg/wps\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshrpc\"\n\t\"github.com/wavetermdev/waveterm/pkg/wshutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wsl\"\n)\n\nconst (\n\tStatus_Init         = \"init\"\n\tStatus_Connecting   = \"connecting\"\n\tStatus_Connected    = \"connected\"\n\tStatus_Disconnected = \"disconnected\"\n\tStatus_Error        = \"error\"\n)\n\nconst DefaultConnectionTimeout = 60 * time.Second\n\nvar globalLock = &sync.Mutex{}\nvar clientControllerMap = make(map[string]*WslConn)\nvar activeConnCounter = &atomic.Int32{}\n\ntype WslConn struct {\n\tLock               *sync.Mutex\n\tStatus             string\n\tWshEnabled         *atomic.Bool\n\tName               wsl.WslName\n\tClient             *wsl.Distro\n\tDomainSockName     string // if \"\", then no domain socket\n\tDomainSockListener net.Listener\n\tConnController     *wsl.WslCmd\n\tError              string\n\tWshError           string\n\tNoWshReason        string\n\tWshVersion         string\n\tHasWaiter          *atomic.Bool\n\tLastConnectTime    int64\n\tActiveConnNum      int\n\tcancelFn           func()\n}\n\nvar ConnServerCmdTemplate = strings.TrimSpace(\n\tstrings.Join([]string{\n\t\t\"%s version 2> /dev/null || (echo -n \\\"not-installed \\\"; uname -sm);\",\n\t\t\"exec %s connserver --router --conn %s %s\",\n\t}, \"\\n\"))\n\nfunc GetAllConnStatus() []wshrpc.ConnStatus {\n\tglobalLock.Lock()\n\tdefer globalLock.Unlock()\n\n\tvar connStatuses []wshrpc.ConnStatus\n\tfor _, conn := range clientControllerMap {\n\t\tconnStatuses = append(connStatuses, conn.DeriveConnStatus())\n\t}\n\treturn connStatuses\n}\n\nfunc GetNumWSLHasConnected() int {\n\tglobalLock.Lock()\n\tdefer globalLock.Unlock()\n\n\tvar connectedCount int\n\tfor _, conn := range clientControllerMap {\n\t\tif conn.LastConnectTime > 0 {\n\t\t\tconnectedCount++\n\t\t}\n\t}\n\treturn connectedCount\n}\n\nfunc (conn *WslConn) DeriveConnStatus() wshrpc.ConnStatus {\n\tconn.Lock.Lock()\n\tdefer conn.Lock.Unlock()\n\treturn wshrpc.ConnStatus{\n\t\tStatus:        conn.Status,\n\t\tConnected:     conn.Status == Status_Connected,\n\t\tWshEnabled:    conn.WshEnabled.Load(),\n\t\tConnection:    conn.GetName(),\n\t\tHasConnected:  (conn.LastConnectTime > 0),\n\t\tActiveConnNum: conn.ActiveConnNum,\n\t\tError:         conn.Error,\n\t\tWshError:      conn.WshError,\n\t\tNoWshReason:   conn.NoWshReason,\n\t\tWshVersion:    conn.WshVersion,\n\t}\n}\n\nfunc (conn *WslConn) Infof(ctx context.Context, format string, args ...any) {\n\tlog.Print(fmt.Sprintf(\"[conn:%s] \", conn.GetName()) + fmt.Sprintf(format, args...))\n\tblocklogger.Infof(ctx, \"[conndebug] \"+format, args...)\n}\n\nfunc (conn *WslConn) Debugf(ctx context.Context, format string, args ...any) {\n\tblocklogger.Infof(ctx, \"[conndebug] \"+format, args...)\n}\n\nfunc (conn *WslConn) FireConnChangeEvent() {\n\tstatus := conn.DeriveConnStatus()\n\tevent := wps.WaveEvent{\n\t\tEvent: wps.Event_ConnChange,\n\t\tScopes: []string{\n\t\t\tfmt.Sprintf(\"connection:%s\", conn.GetName()),\n\t\t},\n\t\tData: status,\n\t}\n\tlog.Printf(\"sending event: %+#v\", event)\n\twps.Broker.Publish(event)\n}\n\nfunc (conn *WslConn) Close() error {\n\tdefer conn.FireConnChangeEvent()\n\tconn.WithLock(func() {\n\t\tif conn.Status == Status_Connected || conn.Status == Status_Connecting {\n\t\t\t// if status is init, disconnected, or error don't change it\n\t\t\tconn.Status = Status_Disconnected\n\t\t}\n\t\tconn.close_nolock()\n\t})\n\t// we must wait for the waiter to complete\n\tstartTime := time.Now()\n\tfor conn.HasWaiter.Load() {\n\t\ttime.Sleep(10 * time.Millisecond)\n\t\tif time.Since(startTime) > 2*time.Second {\n\t\t\treturn fmt.Errorf(\"timeout waiting for waiter to complete\")\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (conn *WslConn) close_nolock() {\n\t// does not set status (that should happen at another level)\n\tif conn.DomainSockListener != nil {\n\t\tconn.DomainSockListener.Close()\n\t\tconn.DomainSockListener = nil\n\t\tconn.DomainSockName = \"\"\n\t}\n\tif conn.ConnController != nil {\n\t\tconn.cancelFn() // this suspends the conn controller\n\t\tconn.ConnController = nil\n\t}\n\tif conn.Client != nil {\n\t\t// conn.Client.Close() is not relevant here\n\t\t// we do not want to completely close the wsl in case\n\t\t// other applications are using it\n\t\tconn.Client = nil\n\t}\n}\n\nfunc (conn *WslConn) GetDomainSocketName() string {\n\tconn.Lock.Lock()\n\tdefer conn.Lock.Unlock()\n\treturn conn.DomainSockName\n}\n\nfunc (conn *WslConn) GetStatus() string {\n\tconn.Lock.Lock()\n\tdefer conn.Lock.Unlock()\n\treturn conn.Status\n}\n\nfunc (conn *WslConn) GetName() string {\n\t// no lock required because opts is immutable\n\treturn \"wsl://\" + conn.Name.Distro\n}\n\n/**\n * This function is does not set a listener for WslConn\n * It is still required in order to set SockName\n**/\nfunc (conn *WslConn) OpenDomainSocketListener(ctx context.Context) error {\n\tconn.Infof(ctx, \"running OpenDomainSocketListener...\\n\")\n\tallowed := WithLockRtn(conn, func() bool {\n\t\treturn conn.Status == Status_Connecting\n\t})\n\tif !allowed {\n\t\treturn fmt.Errorf(\"cannot open domain socket for %q when status is %q\", conn.GetName(), conn.GetStatus())\n\t}\n\t/*\n\t\tlistener, err := client.ListenUnix(sockName)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"unable to request connection domain socket: %v\", err)\n\t\t}\n\t*/\n\tconn.Infof(ctx, \"setting domain socket to %s\\n\", wavebase.RemoteFullDomainSocketPath)\n\tconn.WithLock(func() {\n\t\tconn.DomainSockName = wavebase.RemoteFullDomainSocketPath\n\t\t//conn.DomainSockListener = listener\n\t})\n\tconn.Infof(ctx, \"successfully connected domain socket\\n\")\n\t/*\n\t\tgo func() {\n\t\t\tdefer func() {\n\t\t\t\tpanichandler.PanicHandler(\"wslconn:OpenDomainSocketListener\", recover())\n\t\t\t}()\n\t\t\tdefer conn.WithLock(func() {\n\t\t\t\tconn.DomainSockListener = nil\n\t\t\t\tconn.DomainSockName = \"\"\n\t\t\t})\n\t\t\twshutil.RunWshRpcOverListener(listener)\n\t\t}()\n\t*/\n\treturn nil\n}\n\nfunc (conn *WslConn) getWshPath() string {\n\tconfig, ok := conn.getConnectionConfig()\n\tif ok && config.ConnWshPath != \"\" {\n\t\treturn config.ConnWshPath\n\t}\n\treturn wavebase.RemoteFullWshBinPath\n}\n\nfunc (conn *WslConn) GetConfigShellPath() string {\n\tconfig, ok := conn.getConnectionConfig()\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn config.ConnShellPath\n}\n\n// returns (needsInstall, clientVersion, osArchStr, error)\n// if wsh is not installed, the clientVersion will be \"not-installed\", and it will also return an osArchStr\n// if clientVersion is set, then no osArchStr will be returned\nfunc (conn *WslConn) StartConnServer(ctx context.Context, afterUpdate bool) (bool, string, string, error) {\n\tconn.Infof(ctx, \"running StartConnServer...\\n\")\n\tallowed := WithLockRtn(conn, func() bool {\n\t\treturn conn.Status == Status_Connecting\n\t})\n\tif !allowed {\n\t\treturn false, \"\", \"\", fmt.Errorf(\"cannot start conn server for %q when status is %q\", conn.GetName(), conn.GetStatus())\n\t}\n\tclient := conn.GetClient()\n\twshPath := conn.getWshPath()\n\tconn.Infof(ctx, \"WSL-NEWSESSION (StartConnServer)\\n\")\n\tconnServerCtx, cancelFn := context.WithCancel(context.Background())\n\tconn.WithLock(func() {\n\t\tif conn.cancelFn != nil {\n\t\t\tconn.cancelFn()\n\t\t}\n\t\tconn.cancelFn = cancelFn\n\t})\n\tdevFlag := \"\"\n\tif wavebase.IsDevMode() {\n\t\tdevFlag = \"--dev\"\n\t}\n\tcmdStr := fmt.Sprintf(ConnServerCmdTemplate, wshPath, wshPath, shellutil.HardQuote(conn.GetName()), devFlag)\n\tshWrappedCmdStr := fmt.Sprintf(\"sh -c %s\", shellutil.HardQuote(cmdStr))\n\tcmd := client.WslCommand(connServerCtx, shWrappedCmdStr)\n\tpipeRead, pipeWrite := io.Pipe()\n\tinputPipeRead, inputPipeWrite := io.Pipe()\n\tcmd.SetStdout(pipeWrite)\n\tcmd.SetStderr(pipeWrite)\n\tcmd.SetStdin(inputPipeRead)\n\tlog.Printf(\"starting conn controller: %q\\n\", cmdStr)\n\tblocklogger.Debugf(ctx, \"[conndebug] wrapped command:\\n%s\\n\", shWrappedCmdStr)\n\terr := cmd.Start()\n\tif err != nil {\n\t\treturn false, \"\", \"\", fmt.Errorf(\"unable to start conn controller cmd: %w\", err)\n\t}\n\tlinesChan := utilfn.StreamToLinesChan(pipeRead)\n\tversionLine, err := utilfn.ReadLineWithTimeout(linesChan, 30*time.Second)\n\tif err != nil {\n\t\tcancelFn()\n\t\treturn false, \"\", \"\", fmt.Errorf(\"error reading wsh version: %w\", err)\n\t}\n\tconn.Infof(ctx, \"got connserver version: %s\\n\", strings.TrimSpace(versionLine))\n\tisUpToDate, clientVersion, osArchStr, err := conncontroller.IsWshVersionUpToDate(ctx, versionLine)\n\tif err != nil {\n\t\tcancelFn()\n\t\treturn false, \"\", \"\", fmt.Errorf(\"error checking wsh version: %w\", err)\n\t}\n\tif isUpToDate && !afterUpdate && os.Getenv(wavebase.WaveWshForceUpdateVarName) != \"\" {\n\t\tisUpToDate = false\n\t\tconn.Infof(ctx, \"%s set, forcing wsh update\\n\", wavebase.WaveWshForceUpdateVarName)\n\t}\n\tconn.Infof(ctx, \"connserver up-to-date: %v\\n\", isUpToDate)\n\tif !isUpToDate {\n\t\tcancelFn()\n\t\treturn true, clientVersion, osArchStr, nil\n\t}\n\tconn.WithLock(func() {\n\t\tconn.ConnController = cmd\n\t})\n\t// service the I/O\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"wslconn:cmd.Wait\", recover())\n\t\t}()\n\t\t// wait for termination, clear the controller\n\t\tvar waitErr error\n\t\tdefer conn.WithLock(func() {\n\t\t\tif conn.ConnController != nil {\n\t\t\t\tconn.WshEnabled.Store(false)\n\t\t\t\tconn.NoWshReason = \"connserver terminated\"\n\t\t\t\tif waitErr != nil {\n\t\t\t\t\tconn.WshError = fmt.Sprintf(\"connserver terminated unexpectedly with error: %v\", waitErr)\n\t\t\t\t}\n\t\t\t}\n\t\t\tconn.ConnController = nil\n\t\t})\n\t\twaitErr = cmd.Wait()\n\t\tlog.Printf(\"conn controller (%q) terminated: %v\", conn.GetName(), waitErr)\n\t}()\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"wsl:StartConnServer:handleStdIOClient\", recover())\n\t\t}()\n\t\tlogName := fmt.Sprintf(\"wslconn:%s\", conn.GetName())\n\t\twshutil.HandleStdIOClient(logName, linesChan, inputPipeWrite)\n\t}()\n\tconn.Infof(ctx, \"connserver started, waiting for route to be registered\\n\")\n\tregCtx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancelFn()\n\terr = wshutil.DefaultRouter.WaitForRegister(regCtx, wshutil.MakeConnectionRouteId(conn.GetName()))\n\tif err != nil {\n\t\treturn false, clientVersion, \"\", fmt.Errorf(\"timeout waiting for connserver to register\")\n\t}\n\ttime.Sleep(300 * time.Millisecond) // TODO remove this sleep (but we need to wait until connserver is \"ready\")\n\tconn.Infof(ctx, \"connserver is registered and ready\\n\")\n\treturn false, clientVersion, \"\", nil\n}\n\ntype WshInstallOpts struct {\n\tForce        bool\n\tNoUserPrompt bool\n}\n\nvar queryTextTemplate = strings.TrimSpace(`\nWave requires Wave Shell Extensions to be\ninstalled on %q\nto ensure a seamless experience.\n\nWould you like to install them?\n`)\n\nfunc (conn *WslConn) UpdateWsh(ctx context.Context, clientDisplayName string, remoteInfo *wshrpc.RemoteInfo) error {\n\tconn.Infof(ctx, \"attempting to update wsh for connection %s (os:%s arch:%s version:%s)\\n\",\n\t\tconn.GetName(), remoteInfo.ClientOs, remoteInfo.ClientArch, remoteInfo.ClientVersion)\n\tclient := conn.GetClient()\n\tif client == nil {\n\t\treturn fmt.Errorf(\"cannot update wsh: ssh client is not connected\")\n\t}\n\terr := CpWshToRemote(ctx, client, remoteInfo.ClientOs, remoteInfo.ClientArch)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error installing wsh to remote: %w\", err)\n\t}\n\tconn.Infof(ctx, \"successfully updated wsh on %s\\n\", conn.GetName())\n\treturn nil\n\n}\n\n// returns (allowed, error)\nfunc (conn *WslConn) getPermissionToInstallWsh(ctx context.Context, clientDisplayName string) (bool, error) {\n\tconn.Infof(ctx, \"running getPermissionToInstallWsh...\\n\")\n\tqueryText := fmt.Sprintf(queryTextTemplate, clientDisplayName)\n\ttitle := \"Install Wave Shell Extensions\"\n\trequest := &userinput.UserInputRequest{\n\t\tResponseType: \"confirm\",\n\t\tQueryText:    queryText,\n\t\tTitle:        title,\n\t\tMarkdown:     true,\n\t\tCheckBoxMsg:  \"Automatically install for all connections\",\n\t\tOkLabel:      \"Install wsh\",\n\t\tCancelLabel:  \"No wsh\",\n\t}\n\tconn.Infof(ctx, \"requesting user confirmation...\\n\")\n\tresponse, err := userinput.GetUserInput(ctx, request)\n\tif err != nil {\n\t\tconn.Infof(ctx, \"error getting user input: %v\\n\", err)\n\t\treturn false, err\n\t}\n\tconn.Infof(ctx, \"user response to allowing wsh: %v\\n\", response.Confirm)\n\tmeta := make(map[string]any)\n\tmeta[\"conn:wshenabled\"] = response.Confirm\n\tconn.Infof(ctx, \"writing conn:wshenabled=%v to connections.json\\n\", response.Confirm)\n\terr = wconfig.SetConnectionsConfigValue(conn.GetName(), meta)\n\tif err != nil {\n\t\tlog.Printf(\"warning: error writing to connections file: %v\", err)\n\t}\n\tif !response.Confirm {\n\t\treturn false, nil\n\t}\n\tif response.CheckboxStat {\n\t\tconn.Infof(ctx, \"writing conn:askbeforewshinstall=false to settings.json\\n\")\n\t\tmeta := waveobj.MetaMapType{\n\t\t\twconfig.ConfigKey_ConnAskBeforeWshInstall: false,\n\t\t}\n\t\tsetConfigErr := wconfig.SetBaseConfigValue(meta)\n\t\tif setConfigErr != nil {\n\t\t\t// this is not a critical error, just log and continue\n\t\t\tlog.Printf(\"warning: error writing to base config file: %v\", err)\n\t\t}\n\t}\n\treturn true, nil\n}\n\nfunc (conn *WslConn) InstallWsh(ctx context.Context, osArchStr string) error {\n\tconn.Infof(ctx, \"running installWsh...\\n\")\n\tclient := conn.GetClient()\n\tif client == nil {\n\t\tconn.Infof(ctx, \"ERROR ssh client is not connected, cannot install\\n\")\n\t\treturn fmt.Errorf(\"ssh client is not connected, cannot install\")\n\t}\n\tvar clientOs, clientArch string\n\tvar err error\n\tif osArchStr != \"\" {\n\t\tclientOs, clientArch, err = GetClientPlatformFromOsArchStr(ctx, osArchStr)\n\t} else {\n\t\tclientOs, clientArch, err = GetClientPlatform(ctx, genconn.MakeWSLShellClient(client))\n\t}\n\tif err != nil {\n\t\tconn.Infof(ctx, \"ERROR detecting client platform: %v\\n\", err)\n\t}\n\tconn.Infof(ctx, \"detected remote platform os:%s arch:%s\\n\", clientOs, clientArch)\n\terr = CpWshToRemote(ctx, client, clientOs, clientArch)\n\tif err != nil {\n\t\tconn.Infof(ctx, \"ERROR copying wsh binary to remote: %v\\n\", err)\n\t\treturn fmt.Errorf(\"error copying wsh binary to remote: %w\", err)\n\t}\n\tconn.Infof(ctx, \"successfully installed wsh\\n\")\n\treturn nil\n}\n\nfunc (conn *WslConn) GetClient() *wsl.Distro {\n\tconn.Lock.Lock()\n\tdefer conn.Lock.Unlock()\n\treturn conn.Client\n}\n\nfunc (conn *WslConn) Reconnect(ctx context.Context) error {\n\terr := conn.Close()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn conn.Connect(ctx)\n}\n\nfunc (conn *WslConn) WaitForConnect(ctx context.Context) error {\n\tfor {\n\t\tstatus := conn.DeriveConnStatus()\n\t\tif status.Status == Status_Connected {\n\t\t\treturn nil\n\t\t}\n\t\tif status.Status == Status_Connecting {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn fmt.Errorf(\"context timeout\")\n\t\t\tcase <-time.After(100 * time.Millisecond):\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif status.Status == Status_Init || status.Status == Status_Disconnected {\n\t\t\treturn fmt.Errorf(\"disconnected\")\n\t\t}\n\t\tif status.Status == Status_Error {\n\t\t\treturn fmt.Errorf(\"error: %v\", status.Error)\n\t\t}\n\t\treturn fmt.Errorf(\"unknown status: %q\", status.Status)\n\t}\n}\n\n// does not return an error since that error is stored inside of WslConn\nfunc (conn *WslConn) Connect(ctx context.Context) error {\n\tvar connectAllowed bool\n\tconn.WithLock(func() {\n\t\tif conn.Status == Status_Connecting || conn.Status == Status_Connected {\n\t\t\tconnectAllowed = false\n\t\t} else {\n\t\t\tconn.Status = Status_Connecting\n\t\t\tconn.Error = \"\"\n\t\t\tconnectAllowed = true\n\t\t}\n\t})\n\tlog.Printf(\"Connect %s\\n\", conn.GetName())\n\tif !connectAllowed {\n\t\tconn.Infof(ctx, \"cannot connect to %q when status is %q\\n\", conn.GetName(), conn.GetStatus())\n\t\treturn fmt.Errorf(\"cannot connect to %q when status is %q\", conn.GetName(), conn.GetStatus())\n\t}\n\tconn.FireConnChangeEvent()\n\terr := conn.connectInternal(ctx)\n\tconn.WithLock(func() {\n\t\tif err != nil {\n\t\t\tconn.Infof(ctx, \"ERROR %v\\n\\n\", err)\n\t\t\tconn.Status = Status_Error\n\t\t\tconn.Error = err.Error()\n\t\t\tconn.close_nolock()\n\t\t\ttelemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{\n\t\t\t\tConn: map[string]int{\"wsl:connecterror\": 1},\n\t\t\t}, \"wsl-connconnect\")\n\t\t\ttelemetry.GoRecordTEventWrap(&telemetrydata.TEvent{\n\t\t\t\tEvent: \"conn:connecterror\",\n\t\t\t\tProps: telemetrydata.TEventProps{\n\t\t\t\t\tConnType: \"wsl\",\n\t\t\t\t},\n\t\t\t})\n\t\t} else {\n\t\t\tconn.Infof(ctx, \"successfully connected (wsh:%v)\\n\\n\", conn.WshEnabled.Load())\n\t\t\tconn.Status = Status_Connected\n\t\t\tconn.LastConnectTime = time.Now().UnixMilli()\n\t\t\tif conn.ActiveConnNum == 0 {\n\t\t\t\tconn.ActiveConnNum = int(activeConnCounter.Add(1))\n\t\t\t}\n\t\t\ttelemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{\n\t\t\t\tConn: map[string]int{\"wsl:connect\": 1},\n\t\t\t}, \"wsl-connconnect\")\n\t\t\ttelemetry.GoRecordTEventWrap(&telemetrydata.TEvent{\n\t\t\t\tEvent: \"conn:connect\",\n\t\t\t\tProps: telemetrydata.TEventProps{\n\t\t\t\t\tConnType: \"wsl\",\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t})\n\tconn.FireConnChangeEvent()\n\treturn err\n}\n\nfunc (conn *WslConn) WithLock(fn func()) {\n\tconn.Lock.Lock()\n\tdefer conn.Lock.Unlock()\n\tfn()\n}\n\nfunc WithLockRtn[T any](conn *WslConn, fn func() T) T {\n\tconn.Lock.Lock()\n\tdefer conn.Lock.Unlock()\n\treturn fn()\n}\n\n// returns (enable-wsh, ask-before-install)\nfunc (conn *WslConn) getConnWshSettings() (bool, bool) {\n\tconfig := wconfig.GetWatcher().GetFullConfig()\n\tenableWsh := config.Settings.ConnWshEnabled\n\taskBeforeInstall := wconfig.DefaultBoolPtr(config.Settings.ConnAskBeforeWshInstall, true)\n\tconnSettings, ok := conn.getConnectionConfig()\n\tif ok {\n\t\tif connSettings.ConnWshEnabled != nil {\n\t\t\tenableWsh = *connSettings.ConnWshEnabled\n\t\t}\n\t\t// if the connection object exists, and conn:askbeforewshinstall is not set, the user must have allowed it\n\t\t// TODO: in v0.12+ this should be removed.  we'll explicitly write a \"false\" into the connection object on successful connection\n\t\tif connSettings.ConnAskBeforeWshInstall == nil {\n\t\t\taskBeforeInstall = false\n\t\t} else {\n\t\t\taskBeforeInstall = *connSettings.ConnAskBeforeWshInstall\n\t\t}\n\t}\n\treturn enableWsh, askBeforeInstall\n}\n\ntype WshCheckResult struct {\n\tWshEnabled    bool\n\tClientVersion string\n\tNoWshReason   string\n\tWshError      error\n}\n\n// returns (wsh-enabled, clientVersion, text-reason, wshError)\nfunc (conn *WslConn) tryEnableWsh(ctx context.Context, clientDisplayName string) WshCheckResult {\n\tconn.Infof(ctx, \"running tryEnableWsh...\\n\")\n\tenableWsh, askBeforeInstall := conn.getConnWshSettings()\n\tconn.Infof(ctx, \"wsh settings enable:%v ask:%v\\n\", enableWsh, askBeforeInstall)\n\tif !enableWsh {\n\t\treturn WshCheckResult{NoWshReason: \"conn:wshenabled set to false\"}\n\t}\n\tif askBeforeInstall {\n\t\tallowInstall, err := conn.getPermissionToInstallWsh(ctx, clientDisplayName)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error getting permission to install wsh: %v\\n\", err)\n\t\t\treturn WshCheckResult{NoWshReason: \"error getting user permission to install\", WshError: err}\n\t\t}\n\t\tif !allowInstall {\n\t\t\treturn WshCheckResult{NoWshReason: \"user selected not to install wsh extensions\"}\n\t\t}\n\t}\n\terr := conn.OpenDomainSocketListener(ctx)\n\tif err != nil {\n\t\tconn.Infof(ctx, \"ERROR opening domain socket listener: %v\\n\", err)\n\t\terr = fmt.Errorf(\"error opening domain socket listener: %w\", err)\n\t\treturn WshCheckResult{NoWshReason: \"error opening domain socket\", WshError: err}\n\t}\n\tneedsInstall, clientVersion, osArchStr, err := conn.StartConnServer(ctx, false)\n\tif err != nil {\n\t\tconn.Infof(ctx, \"ERROR starting conn server: %v\\n\", err)\n\t\terr = fmt.Errorf(\"error starting conn server: %w\", err)\n\t\treturn WshCheckResult{NoWshReason: \"error starting connserver\", WshError: err}\n\t}\n\tif needsInstall {\n\t\tconn.Infof(ctx, \"connserver needs to be (re)installed\\n\")\n\t\terr = conn.InstallWsh(ctx, osArchStr)\n\t\tif err != nil {\n\t\t\tconn.Infof(ctx, \"ERROR installing wsh: %v\\n\", err)\n\t\t\terr = fmt.Errorf(\"error installing wsh: %w\", err)\n\t\t\treturn WshCheckResult{NoWshReason: \"error installing wsh/connserver\", WshError: err}\n\t\t}\n\t\tneedsInstall, clientVersion, _, err = conn.StartConnServer(ctx, true)\n\t\tif err != nil {\n\t\t\tconn.Infof(ctx, \"ERROR starting conn server (after install): %v\\n\", err)\n\t\t\terr = fmt.Errorf(\"error starting conn server (after install): %w\", err)\n\t\t\treturn WshCheckResult{NoWshReason: \"error starting connserver\", WshError: err}\n\t\t}\n\t\tif needsInstall {\n\t\t\tconn.Infof(ctx, \"conn server not installed correctly (after install)\\n\")\n\t\t\terr = fmt.Errorf(\"conn server not installed correctly (after install)\")\n\t\t\treturn WshCheckResult{NoWshReason: \"connserver not installed properly\", WshError: err}\n\t\t}\n\t\treturn WshCheckResult{WshEnabled: true, ClientVersion: clientVersion}\n\t} else {\n\t\treturn WshCheckResult{WshEnabled: true, ClientVersion: clientVersion}\n\t}\n}\n\nfunc (conn *WslConn) getConnectionConfig() (wconfig.ConnKeywords, bool) {\n\tconfig := wconfig.GetWatcher().GetFullConfig()\n\tconnSettings, ok := config.Connections[conn.GetName()]\n\tif !ok {\n\t\treturn wconfig.ConnKeywords{}, false\n\t}\n\treturn connSettings, true\n}\n\nfunc (conn *WslConn) persistWshInstalled(ctx context.Context, result WshCheckResult) {\n\tconn.WshEnabled.Store(result.WshEnabled)\n\tconn.SetWshError(result.WshError)\n\tconn.WithLock(func() {\n\t\tconn.NoWshReason = result.NoWshReason\n\t\tconn.WshVersion = result.ClientVersion\n\t})\n\tconnConfig, ok := conn.getConnectionConfig()\n\tif ok && connConfig.ConnWshEnabled != nil {\n\t\treturn\n\t}\n\tmeta := make(map[string]any)\n\tmeta[\"conn:wshenabled\"] = result.WshEnabled\n\terr := wconfig.SetConnectionsConfigValue(conn.GetName(), meta)\n\tif err != nil {\n\t\tconn.Infof(ctx, \"WARN could not write conn:wshenabled=%v to connections.json: %v\\n\", result.WshEnabled, err)\n\t\tlog.Printf(\"warning: error writing to connections file: %v\", err)\n\t}\n\t// doesn't return an error since none of this is required for connection to work\n}\n\nfunc (conn *WslConn) connectInternal(ctx context.Context) error {\n\tconn.Infof(ctx, \"connectInternal %s\\n\", conn.GetName())\n\tclient, err := wsl.GetDistro(ctx, conn.Name)\n\tif err != nil {\n\t\tconn.Infof(ctx, \"ERROR GetDistro: %s\\n\", err)\n\t\tlog.Printf(\"error: failed to get distro %s: %s\\n\", conn.GetName(), err)\n\t\treturn err\n\t}\n\tconn.WithLock(func() {\n\t\tconn.Client = client\n\t})\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"wsl-waitForDisconnect\", recover())\n\t\t}()\n\t\tconn.waitForDisconnect()\n\t}()\n\twshResult := conn.tryEnableWsh(ctx, conn.GetName())\n\tif !wshResult.WshEnabled {\n\t\tif wshResult.WshError != nil {\n\t\t\tconn.Infof(ctx, \"ERROR enabling wsh: %v\\n\", wshResult.WshError)\n\t\t\tconn.Infof(ctx, \"will connect with wsh disabled\\n\")\n\t\t} else {\n\t\t\tconn.Infof(ctx, \"wsh not enabled: %s\\n\", wshResult.NoWshReason)\n\t\t}\n\t}\n\tconn.persistWshInstalled(ctx, wshResult)\n\treturn nil\n}\n\nfunc (conn *WslConn) waitForDisconnect() {\n\tlog.Printf(\"wait for disconnect in %+#v\", conn)\n\tdefer conn.FireConnChangeEvent()\n\tdefer conn.HasWaiter.Store(false)\n\tif conn.ConnController == nil {\n\t\treturn\n\t}\n\terr := conn.ConnController.Wait()\n\tconn.WithLock(func() {\n\t\t// disconnects happen for a variety of reasons (like network, etc. and are typically transient)\n\t\t// so we just set the status to \"disconnected\" here (not error)\n\t\t// don't overwrite any existing error (or error status)\n\t\tif err != nil && conn.Error == \"\" {\n\t\t\tconn.Error = err.Error()\n\t\t}\n\t\tif conn.Status != Status_Error {\n\t\t\tconn.Status = Status_Disconnected\n\t\t}\n\t\tconn.close_nolock()\n\t})\n}\n\nfunc (conn *WslConn) SetWshError(err error) {\n\tconn.WithLock(func() {\n\t\tif err == nil {\n\t\t\tconn.WshError = \"\"\n\t\t} else {\n\t\t\tconn.WshError = err.Error()\n\t\t}\n\t})\n}\n\nfunc (conn *WslConn) ClearWshError() {\n\tconn.WithLock(func() {\n\t\tconn.WshError = \"\"\n\t})\n}\n\nfunc getConnInternal(name string) *WslConn {\n\tglobalLock.Lock()\n\tdefer globalLock.Unlock()\n\tconnName := wsl.WslName{Distro: name}\n\trtn := clientControllerMap[name]\n\tif rtn == nil {\n\t\trtn = &WslConn{Lock: &sync.Mutex{}, Status: Status_Init, Name: connName, WshEnabled: &atomic.Bool{}, HasWaiter: &atomic.Bool{}, cancelFn: nil}\n\t\tclientControllerMap[name] = rtn\n\t}\n\treturn rtn\n}\n\nfunc GetWslConn(name string) *WslConn {\n\tconn := getConnInternal(name)\n\treturn conn\n}\n\n// Convenience function for ensuring a connection is established\nfunc EnsureConnection(ctx context.Context, connName string) error {\n\tif connName == \"\" {\n\t\treturn nil\n\t}\n\tconn := GetWslConn(connName)\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"connection not found: %s\", connName)\n\t}\n\tconnStatus := conn.DeriveConnStatus()\n\tswitch connStatus.Status {\n\tcase Status_Connected:\n\t\treturn nil\n\tcase Status_Connecting:\n\t\treturn conn.WaitForConnect(ctx)\n\tcase Status_Init, Status_Disconnected:\n\t\treturn conn.Connect(ctx)\n\tcase Status_Error:\n\t\treturn fmt.Errorf(\"connection error: %s\", connStatus.Error)\n\tdefault:\n\t\treturn fmt.Errorf(\"unknown connection status %q\", connStatus.Status)\n\t}\n}\n\nfunc DisconnectClient(connName string) error {\n\tconn := getConnInternal(connName)\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"client %q not found\", connName)\n\t}\n\terr := conn.Close()\n\treturn err\n}\n"
  },
  {
    "path": "pkg/wstore/wstore.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wstore\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n)\n\nfunc init() {\n\tfor _, rtype := range waveobj.AllWaveObjTypes() {\n\t\twaveobj.RegisterType(rtype)\n\t}\n}\n\nvar (\n\tclientIdLock   sync.Mutex\n\tcachedClientId string\n)\n\nfunc SetClientId(clientId string) {\n\tclientIdLock.Lock()\n\tdefer clientIdLock.Unlock()\n\tcachedClientId = clientId\n}\n\n// in the main server, this will not return empty string\n// it does return empty in wsh, but all wstore methods are invalid in wsh mode, so that shouldn't be an issue\nfunc GetClientId() string {\n\tclientIdLock.Lock()\n\tdefer clientIdLock.Unlock()\n\tif wavebase.IsDevMode() && cachedClientId == \"\" {\n\t\tpanic(\"cachedClientId is empty\")\n\t}\n\treturn cachedClientId\n}\n\nfunc UpdateTabName(ctx context.Context, tabId, name string) error {\n\treturn WithTx(ctx, func(tx *TxWrap) error {\n\t\ttab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId)\n\t\tif tab == nil {\n\t\t\treturn fmt.Errorf(\"tab not found: %q\", tabId)\n\t\t}\n\t\tif tabId != \"\" {\n\t\t\ttab.Name = name\n\t\t\tDBUpdate(tx.Context(), tab)\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaMapType, mergeSpecial bool) error {\n\treturn WithTx(ctx, func(tx *TxWrap) error {\n\t\tif oref.IsEmpty() {\n\t\t\treturn fmt.Errorf(\"empty object reference\")\n\t\t}\n\t\tobj, _ := DBGetORef(tx.Context(), oref)\n\t\tif obj == nil {\n\t\t\treturn ErrNotFound\n\t\t}\n\t\tobjMeta := waveobj.GetMeta(obj)\n\t\tif objMeta == nil {\n\t\t\tobjMeta = make(map[string]any)\n\t\t}\n\t\tnewMeta := waveobj.MergeMeta(objMeta, meta, mergeSpecial)\n\t\twaveobj.SetMeta(obj, newMeta)\n\t\tDBUpdate(tx.Context(), obj)\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "pkg/wstore/wstore_dboldmigration.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wstore\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"time\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n)\n\nconst OldDBName = \"~/.waveterm/waveterm.db\"\n\nfunc GetOldDBName() string {\n\treturn wavebase.ExpandHomeDirSafe(OldDBName)\n}\n\nfunc MakeOldDB(ctx context.Context) (*sqlx.DB, error) {\n\tdbName := GetOldDBName()\n\trtn, err := sqlx.Open(\"sqlite3\", fmt.Sprintf(\"file:%s?mode=ro&_busy_timeout=5000\", dbName))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trtn.DB.SetMaxOpenConns(1)\n\treturn rtn, nil\n}\n\ntype OldHistoryType struct {\n\tHistoryId  string\n\tTs         int64\n\tRemoteName string\n\tHadError   bool\n\tCmdStr     string\n\tExitCode   int\n\tDurationMs int64\n}\n\nfunc GetAllOldHistory() ([]*OldHistoryType, error) {\n\tquery := `\n\t\tSELECT \n\t\t    h.historyid, \n\t\t\th.ts, \n\t\t\tCOALESCE(r.remotecanonicalname, '') as remotename, \n\t\t\th.haderror,\n\t\t\th.cmdstr, \n\t\t\tCOALESCE(h.exitcode, 0) as exitcode, \n\t\t\tCOALESCE(h.durationms, 0) as durationms\n\t\tFROM history h, remote r\n\t\tWHERE h.remoteid = r.remoteid \n\t\t  AND NOT h.ismetacmd\n\t`\n\tdb, err := MakeOldDB(context.Background())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer db.Close()\n\tvar rtn []*OldHistoryType\n\terr = db.Select(&rtn, query)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn rtn, nil\n}\n\nfunc ReplaceOldHistory(ctx context.Context, hist []*OldHistoryType) error {\n\treturn WithTx(ctx, func(tx *TxWrap) error {\n\t\tquery := `DELETE FROM history_migrated`\n\t\ttx.Exec(query)\n\t\tquery = `INSERT INTO history_migrated (historyid, ts, remotename, haderror, cmdstr, exitcode, durationms) \n\t\t                               VALUES (?, ?, ?, ?, ?, ?, ?)`\n\t\tfor _, hobj := range hist {\n\t\t\ttx.Exec(query, hobj.HistoryId, hobj.Ts, hobj.RemoteName, hobj.HadError, hobj.CmdStr, hobj.ExitCode, hobj.DurationMs)\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc TryMigrateOldHistory() error {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancelFn()\n\thist, err := GetAllOldHistory()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(hist) == 0 {\n\t\treturn nil\n\t}\n\terr = ReplaceOldHistory(ctx, hist)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog.Printf(\"migrated %d old wave history records\\n\", len(hist))\n\tclient, err := DBGetSingleton[*waveobj.Client](ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\tclient.HasOldHistory = true\n\terr = DBUpdate(ctx, client)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/wstore/wstore_dbops.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wstore\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/filestore\"\n\t\"github.com/wavetermdev/waveterm/pkg/panichandler\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/dbutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n)\n\nvar ErrNotFound = fmt.Errorf(\"not found\")\n\nfunc waveObjTableName(w waveobj.WaveObj) string {\n\treturn \"db_\" + w.GetOType()\n}\n\nfunc tableNameFromOType(otype string) string {\n\treturn \"db_\" + otype\n}\n\nfunc tableNameGen[T waveobj.WaveObj]() string {\n\tvar zeroObj T\n\treturn tableNameFromOType(zeroObj.GetOType())\n}\n\nfunc getOTypeGen[T waveobj.WaveObj]() string {\n\tvar zeroObj T\n\treturn zeroObj.GetOType()\n}\n\nfunc DBGetCount[T waveobj.WaveObj](ctx context.Context) (int, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) (int, error) {\n\t\ttable := tableNameGen[T]()\n\t\tquery := fmt.Sprintf(\"SELECT count(*) FROM %s\", table)\n\t\treturn tx.GetInt(query), nil\n\t})\n}\n\n// returns (num named workespaces, num total workspaces, error)\nfunc DBGetWSCounts(ctx context.Context) (int, int, error) {\n\tvar named, total int\n\terr := WithTx(ctx, func(tx *TxWrap) error {\n\t\tquery := `SELECT count(*) FROM db_workspace WHERE COALESCE(json_extract(data, '$.name'), '') <> ''`\n\t\tnamed = tx.GetInt(query)\n\t\tquery = `SELECT count(*) FROM db_workspace`\n\t\ttotal = tx.GetInt(query)\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn 0, 0, err\n\t}\n\treturn named, total, nil\n}\n\nvar viewRe = regexp.MustCompile(`^[a-z0-9]{1,20}$`)\n\nfunc DBGetBlockViewCounts(ctx context.Context) (map[string]int, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) (map[string]int, error) {\n\t\tquery := `SELECT COALESCE(json_extract(data, '$.meta.view'), '') AS view FROM db_block`\n\t\tviews := tx.SelectStrings(query)\n\t\trtn := make(map[string]int)\n\t\tfor _, view := range views {\n\t\t\tif view == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif !viewRe.MatchString(view) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\trtn[view]++\n\t\t}\n\t\treturn rtn, nil\n\t})\n}\n\ntype idDataType struct {\n\tOId     string\n\tVersion int\n\tData    []byte\n}\n\nfunc genericCastWithErr[T any](v any, err error) (T, error) {\n\tif err != nil {\n\t\tvar zeroVal T\n\t\treturn zeroVal, err\n\t}\n\tif v == nil {\n\t\tvar zeroVal T\n\t\treturn zeroVal, nil\n\t}\n\treturn v.(T), err\n}\n\nfunc DBGetSingleton[T waveobj.WaveObj](ctx context.Context) (T, error) {\n\trtn, err := DBGetSingletonByType(ctx, getOTypeGen[T]())\n\treturn genericCastWithErr[T](rtn, err)\n}\n\nfunc DBGetSingletonByType(ctx context.Context, otype string) (waveobj.WaveObj, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) (waveobj.WaveObj, error) {\n\t\ttable := tableNameFromOType(otype)\n\t\tquery := fmt.Sprintf(\"SELECT oid, version, data FROM %s LIMIT 1\", table)\n\t\tvar row idDataType\n\t\tfound := tx.Get(&row, query)\n\t\tif !found {\n\t\t\treturn nil, ErrNotFound\n\t\t}\n\t\trtn, err := waveobj.FromJson(row.Data)\n\t\tif err != nil {\n\t\t\treturn rtn, err\n\t\t}\n\t\twaveobj.SetVersion(rtn, row.Version)\n\t\treturn rtn, nil\n\t})\n}\n\nfunc DBExistsORef(ctx context.Context, oref waveobj.ORef) (bool, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) (bool, error) {\n\t\ttable := tableNameFromOType(oref.OType)\n\t\tquery := fmt.Sprintf(\"SELECT oid FROM %s WHERE oid = ?\", table)\n\t\treturn tx.Exists(query, oref.OID), nil\n\t})\n}\n\nfunc DBGet[T waveobj.WaveObj](ctx context.Context, id string) (T, error) {\n\trtn, err := DBGetORef(ctx, waveobj.ORef{OType: getOTypeGen[T](), OID: id})\n\treturn genericCastWithErr[T](rtn, err)\n}\n\nfunc DBMustGet[T waveobj.WaveObj](ctx context.Context, id string) (T, error) {\n\trtn, err := DBGetORef(ctx, waveobj.ORef{OType: getOTypeGen[T](), OID: id})\n\tif err != nil {\n\t\tvar zeroVal T\n\t\treturn zeroVal, err\n\t}\n\tif rtn == nil {\n\t\tvar zeroVal T\n\t\treturn zeroVal, ErrNotFound\n\t}\n\treturn rtn.(T), nil\n}\n\nfunc DBGetORef(ctx context.Context, oref waveobj.ORef) (waveobj.WaveObj, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) (waveobj.WaveObj, error) {\n\t\ttable := tableNameFromOType(oref.OType)\n\t\tquery := fmt.Sprintf(\"SELECT oid, version, data FROM %s WHERE oid = ?\", table)\n\t\tvar row idDataType\n\t\tfound := tx.Get(&row, query, oref.OID)\n\t\tif !found {\n\t\t\treturn nil, nil\n\t\t}\n\t\trtn, err := waveobj.FromJson(row.Data)\n\t\tif err != nil {\n\t\t\treturn rtn, err\n\t\t}\n\t\twaveobj.SetVersion(rtn, row.Version)\n\t\treturn rtn, nil\n\t})\n}\n\nfunc dbSelectOIDs(ctx context.Context, otype string, oids []string) ([]waveobj.WaveObj, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) ([]waveobj.WaveObj, error) {\n\t\ttable := tableNameFromOType(otype)\n\t\tquery := fmt.Sprintf(\"SELECT oid, version, data FROM %s WHERE oid IN (SELECT value FROM json_each(?))\", table)\n\t\tvar rows []idDataType\n\t\ttx.Select(&rows, query, dbutil.QuickJson(oids))\n\t\trtn := make([]waveobj.WaveObj, 0, len(rows))\n\t\tfor _, row := range rows {\n\t\t\twaveObj, err := waveobj.FromJson(row.Data)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\twaveobj.SetVersion(waveObj, row.Version)\n\t\t\trtn = append(rtn, waveObj)\n\t\t}\n\t\treturn rtn, nil\n\t})\n}\n\nfunc DBSelectORefs(ctx context.Context, orefs []waveobj.ORef) ([]waveobj.WaveObj, error) {\n\toidsByType := make(map[string][]string)\n\tfor _, oref := range orefs {\n\t\toidsByType[oref.OType] = append(oidsByType[oref.OType], oref.OID)\n\t}\n\treturn WithTxRtn(ctx, func(tx *TxWrap) ([]waveobj.WaveObj, error) {\n\t\trtn := make([]waveobj.WaveObj, 0, len(orefs))\n\t\tfor otype, oids := range oidsByType {\n\t\t\trtnArr, err := dbSelectOIDs(tx.Context(), otype, oids)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\trtn = append(rtn, rtnArr...)\n\t\t}\n\t\treturn rtn, nil\n\t})\n}\n\nfunc DBGetAllOIDsByType(ctx context.Context, otype string) ([]string, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) ([]string, error) {\n\t\trtn := make([]string, 0)\n\t\ttable := tableNameFromOType(otype)\n\t\tquery := fmt.Sprintf(\"SELECT oid FROM %s\", table)\n\t\tvar rows []idDataType\n\t\ttx.Select(&rows, query)\n\t\tfor _, row := range rows {\n\t\t\trtn = append(rtn, row.OId)\n\t\t}\n\t\treturn rtn, nil\n\t})\n}\n\nfunc DBGetAllObjsByType[T waveobj.WaveObj](ctx context.Context, otype string) ([]T, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) ([]T, error) {\n\t\trtn := make([]T, 0)\n\t\ttable := tableNameFromOType(otype)\n\t\tquery := fmt.Sprintf(\"SELECT oid, version, data FROM %s\", table)\n\t\tvar rows []idDataType\n\t\ttx.Select(&rows, query)\n\t\tfor _, row := range rows {\n\t\t\twaveObj, err := waveobj.FromJson(row.Data)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\twaveobj.SetVersion(waveObj, row.Version)\n\n\t\t\trtn = append(rtn, waveObj.(T))\n\t\t}\n\t\treturn rtn, nil\n\t})\n}\n\nfunc DBResolveEasyOID(ctx context.Context, oid string) (*waveobj.ORef, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) (*waveobj.ORef, error) {\n\t\tfor _, rtype := range waveobj.AllWaveObjTypes() {\n\t\t\totype := reflect.Zero(rtype).Interface().(waveobj.WaveObj).GetOType()\n\t\t\ttable := tableNameFromOType(otype)\n\t\t\tvar fullOID string\n\t\t\tif len(oid) == 8 {\n\t\t\t\tquery := fmt.Sprintf(\"SELECT oid FROM %s WHERE oid LIKE ?\", table)\n\t\t\t\tfullOID = tx.GetString(query, oid+\"%\")\n\t\t\t} else {\n\t\t\t\tquery := fmt.Sprintf(\"SELECT oid FROM %s WHERE oid = ?\", table)\n\t\t\t\tfullOID = tx.GetString(query, oid)\n\t\t\t}\n\t\t\tif fullOID != \"\" {\n\t\t\t\toref := waveobj.MakeORef(otype, fullOID)\n\t\t\t\treturn &oref, nil\n\t\t\t}\n\t\t}\n\t\treturn nil, ErrNotFound\n\t})\n}\n\nfunc DBSelectMap[T waveobj.WaveObj](ctx context.Context, ids []string) (map[string]T, error) {\n\trtnArr, err := dbSelectOIDs(ctx, getOTypeGen[T](), ids)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trtnMap := make(map[string]T)\n\tfor _, obj := range rtnArr {\n\t\trtnMap[waveobj.GetOID(obj)] = obj.(T)\n\t}\n\treturn rtnMap, nil\n}\n\nfunc DBDelete(ctx context.Context, otype string, id string) error {\n\terr := WithTx(ctx, func(tx *TxWrap) error {\n\t\ttable := tableNameFromOType(otype)\n\t\tquery := fmt.Sprintf(\"DELETE FROM %s WHERE oid = ?\", table)\n\t\ttx.Exec(query, id)\n\t\twaveobj.ContextAddUpdate(ctx, waveobj.WaveObjUpdate{UpdateType: waveobj.UpdateType_Delete, OType: otype, OID: id})\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\tgo func() {\n\t\tdefer func() {\n\t\t\tpanichandler.PanicHandler(\"DBDelete:filestore.DeleteZone\", recover())\n\t\t}()\n\t\t// we spawn a go routine here because we don't want to reuse the DB connection\n\t\t// since DBDelete is called in a transaction from DeleteTab\n\t\tdeleteCtx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\t\tdefer cancelFn()\n\t\terr := filestore.WFS.DeleteZone(deleteCtx, id)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error deleting filestore zone (after deleting block): %v\", err)\n\t\t}\n\t}()\n\treturn nil\n}\n\nfunc DBUpdate(ctx context.Context, val waveobj.WaveObj) error {\n\toid := waveobj.GetOID(val)\n\tif oid == \"\" {\n\t\treturn fmt.Errorf(\"cannot update %T value with empty id\", val)\n\t}\n\tjsonData, err := waveobj.ToJson(val)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn WithTx(ctx, func(tx *TxWrap) error {\n\t\ttable := waveObjTableName(val)\n\t\tquery := fmt.Sprintf(\"UPDATE %s SET data = ?, version = version+1 WHERE oid = ? RETURNING version\", table)\n\t\tnewVersion := tx.GetInt(query, jsonData, oid)\n\t\twaveobj.SetVersion(val, newVersion)\n\t\twaveobj.ContextAddUpdate(ctx, waveobj.WaveObjUpdate{UpdateType: waveobj.UpdateType_Update, OType: val.GetOType(), OID: oid, Obj: val})\n\t\treturn nil\n\t})\n}\n\nfunc DBUpdateFn[T waveobj.WaveObj](ctx context.Context, id string, updateFn func(T)) error {\n\treturn WithTx(ctx, func(tx *TxWrap) error {\n\t\tval, err := DBMustGet[T](tx.Context(), id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tupdateFn(val)\n\t\treturn DBUpdate(tx.Context(), val)\n\t})\n}\n\nfunc DBUpdateFnErr[T waveobj.WaveObj](ctx context.Context, id string, updateFn func(T) error) error {\n\treturn WithTx(ctx, func(tx *TxWrap) error {\n\t\tval, err := DBMustGet[T](tx.Context(), id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\terr = updateFn(val)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn DBUpdate(tx.Context(), val)\n\t})\n}\n\nfunc DBInsert(ctx context.Context, val waveobj.WaveObj) error {\n\toid := waveobj.GetOID(val)\n\tif oid == \"\" {\n\t\treturn fmt.Errorf(\"cannot insert %T value with empty id\", val)\n\t}\n\tjsonData, err := waveobj.ToJson(val)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn WithTx(ctx, func(tx *TxWrap) error {\n\t\ttable := waveObjTableName(val)\n\t\twaveobj.SetVersion(val, 1)\n\t\tquery := fmt.Sprintf(\"INSERT INTO %s (oid, version, data) VALUES (?, ?, ?)\", table)\n\t\ttx.Exec(query, oid, 1, jsonData)\n\t\twaveobj.ContextAddUpdate(ctx, waveobj.WaveObjUpdate{UpdateType: waveobj.UpdateType_Update, OType: val.GetOType(), OID: oid, Obj: val})\n\t\treturn nil\n\t})\n}\n\nfunc DBFindTabForBlockId(ctx context.Context, blockId string) (string, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) (string, error) {\n\t\titerNum := 1\n\t\tfor {\n\t\t\tif iterNum > 5 {\n\t\t\t\treturn \"\", fmt.Errorf(\"too many iterations looking for tab in block parents\")\n\t\t\t}\n\t\t\tquery := `\n\t\t\tSELECT json_extract(b.data, '$.parentoref') AS parentoref\n\t\t\tFROM db_block b\n\t\t\tWHERE b.oid = ?;`\n\t\t\tparentORef := tx.GetString(query, blockId)\n\t\t\toref, err := waveobj.ParseORef(parentORef)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", fmt.Errorf(\"bad block parent oref: %v\", err)\n\t\t\t}\n\t\t\tif oref.OType == \"tab\" {\n\t\t\t\treturn oref.OID, nil\n\t\t\t}\n\t\t\tif oref.OType == \"block\" {\n\t\t\t\tblockId = oref.OID\n\t\t\t\titerNum++\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn \"\", fmt.Errorf(\"bad parent oref type: %v\", oref.OType)\n\t\t}\n\t})\n}\n\nfunc DBFindWorkspaceForTabId(ctx context.Context, tabId string) (string, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) (string, error) {\n\t\tquery := `\n\t\t\tWITH variable(value) AS (\n\t\t\t\tSELECT ?\n\t\t\t)\n\t\t\tSELECT w.oid\n\t\t\tFROM db_workspace w, variable\n\t\t\tWHERE EXISTS (\n\t\t\t\tSELECT 1\n\t\t\t\tFROM json_each(w.data, '$.tabids') AS je\n\t\t\t\tWHERE je.value = variable.value\n\t\t\t);\n\t\t\t`\n\t\twsId := tx.GetString(query, tabId)\n\t\treturn wsId, nil\n\t})\n}\n\nfunc DBFindWindowForWorkspaceId(ctx context.Context, workspaceId string) (string, error) {\n\treturn WithTxRtn(ctx, func(tx *TxWrap) (string, error) {\n\t\tquery := `\n\t\t\tSELECT w.oid\n\t\t\tFROM db_window w WHERE json_extract(data, '$.workspaceid') = ?`\n\t\treturn tx.GetString(query, workspaceId), nil\n\t})\n}\n"
  },
  {
    "path": "pkg/wstore/wstore_dbsetup.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wstore\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/sawka/txwrap\"\n\t\"github.com/wavetermdev/waveterm/pkg/util/migrateutil\"\n\t\"github.com/wavetermdev/waveterm/pkg/wavebase\"\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n\n\tdbfs \"github.com/wavetermdev/waveterm/db\"\n)\n\nconst WStoreDBName = \"waveterm.db\"\n\ntype TxWrap = txwrap.TxWrap\n\nvar globalDB *sqlx.DB\n\nfunc InitWStore() error {\n\tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelFn()\n\tvar err error\n\tglobalDB, err = MakeDB(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = migrateutil.Migrate(\"wstore\", globalDB.DB, dbfs.WStoreMigrationFS, \"migrations-wstore\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog.Printf(\"wstore initialized\\n\")\n\treturn nil\n}\n\nfunc GetDBName() string {\n\twaveHome := wavebase.GetWaveDataDir()\n\treturn filepath.Join(waveHome, wavebase.WaveDBDir, WStoreDBName)\n}\n\nfunc MakeDB(ctx context.Context) (*sqlx.DB, error) {\n\tdbName := GetDBName()\n\trtn, err := sqlx.Open(\"sqlite3\", fmt.Sprintf(\"file:%s?mode=rwc&_journal_mode=WAL&_busy_timeout=5000\", dbName))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trtn.DB.SetMaxOpenConns(1)\n\treturn rtn, nil\n}\n\nfunc WithTx(ctx context.Context, fn func(tx *TxWrap) error) (rtnErr error) {\n\twaveobj.ContextUpdatesBeginTx(ctx)\n\tdefer func() {\n\t\tif rtnErr != nil {\n\t\t\twaveobj.ContextUpdatesRollbackTx(ctx)\n\t\t} else {\n\t\t\twaveobj.ContextUpdatesCommitTx(ctx)\n\t\t}\n\t}()\n\treturn txwrap.WithTx(ctx, globalDB, fn)\n}\n\nfunc WithTxRtn[RT any](ctx context.Context, fn func(tx *TxWrap) (RT, error)) (rtnVal RT, rtnErr error) {\n\twaveobj.ContextUpdatesBeginTx(ctx)\n\tdefer func() {\n\t\tif rtnErr != nil {\n\t\t\twaveobj.ContextUpdatesRollbackTx(ctx)\n\t\t} else {\n\t\t\twaveobj.ContextUpdatesCommitTx(ctx)\n\t\t}\n\t}()\n\treturn txwrap.WithTxRtn(ctx, globalDB, fn)\n}\n"
  },
  {
    "path": "pkg/wstore/wstore_rtinfo.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wstore\n\nimport (\n\t\"reflect\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/pkg/waveobj\"\n)\n\nvar (\n\trtInfoStore = make(map[waveobj.ORef]*waveobj.ObjRTInfo)\n\trtInfoMutex sync.RWMutex\n)\n\nfunc setFieldValue(fieldValue reflect.Value, value any) {\n\tif value == nil {\n\t\tfieldValue.Set(reflect.Zero(fieldValue.Type()))\n\t\treturn\n\t}\n\n\tif valueStr, ok := value.(string); ok && fieldValue.Kind() == reflect.String {\n\t\tfieldValue.SetString(valueStr)\n\t\treturn\n\t}\n\n\tif valueBool, ok := value.(bool); ok && fieldValue.Kind() == reflect.Bool {\n\t\tfieldValue.SetBool(valueBool)\n\t\treturn\n\t}\n\n\tif fieldValue.Kind() == reflect.Int {\n\t\tswitch v := value.(type) {\n\t\tcase int:\n\t\t\tfieldValue.SetInt(int64(v))\n\t\tcase int64:\n\t\t\tfieldValue.SetInt(v)\n\t\tcase float64:\n\t\t\tfieldValue.SetInt(int64(v))\n\t\t}\n\t\treturn\n\t}\n\n\tif fieldValue.Kind() == reflect.Map {\n\t\tif fieldValue.Type().Key().Kind() == reflect.String && fieldValue.Type().Elem().Kind() == reflect.Float64 {\n\t\t\tif inputMap, ok := value.(map[string]any); ok {\n\t\t\t\toutputMap := make(map[string]float64)\n\t\t\t\tfor k, v := range inputMap {\n\t\t\t\t\tif floatVal, ok := v.(float64); ok {\n\t\t\t\t\t\toutputMap[k] = floatVal\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfieldValue.Set(reflect.ValueOf(outputMap))\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tif fieldValue.Type().Key().Kind() == reflect.String && fieldValue.Type().Elem().Kind() == reflect.String {\n\t\t\tif inputMap, ok := value.(map[string]any); ok {\n\t\t\t\toutputMap := make(map[string]string)\n\t\t\t\tfor k, v := range inputMap {\n\t\t\t\t\tif strVal, ok := v.(string); ok {\n\t\t\t\t\t\toutputMap[k] = strVal\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfieldValue.Set(reflect.ValueOf(outputMap))\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\treturn\n\t}\n\n\tif fieldValue.Kind() == reflect.Interface {\n\t\tfieldValue.Set(reflect.ValueOf(value))\n\t}\n}\n\n// SetRTInfo merges the provided info map into the ObjRTInfo for the given ORef.\n// Only updates fields that exist in the ObjRTInfo struct.\n// Removes fields that have nil values.\nfunc SetRTInfo(oref waveobj.ORef, info map[string]any) {\n\trtInfoMutex.Lock()\n\tdefer rtInfoMutex.Unlock()\n\n\trtInfo, exists := rtInfoStore[oref]\n\tif !exists {\n\t\trtInfo = &waveobj.ObjRTInfo{}\n\t\trtInfoStore[oref] = rtInfo\n\t}\n\n\trtInfoValue := reflect.ValueOf(rtInfo).Elem()\n\trtInfoType := rtInfoValue.Type()\n\n\t// Build a map of json tags to field indices for quick lookup\n\tjsonTagToField := make(map[string]int)\n\tfor i := 0; i < rtInfoType.NumField(); i++ {\n\t\tfield := rtInfoType.Field(i)\n\t\tjsonTag := field.Tag.Get(\"json\")\n\t\tif jsonTag != \"\" {\n\t\t\t// Remove omitempty and other options\n\t\t\ttagParts := strings.Split(jsonTag, \",\")\n\t\t\tif len(tagParts) > 0 && tagParts[0] != \"\" {\n\t\t\t\tjsonTagToField[tagParts[0]] = i\n\t\t\t}\n\t\t}\n\t}\n\n\t// Merge the info map into the struct\n\tfor key, value := range info {\n\t\tfieldIndex, exists := jsonTagToField[key]\n\t\tif !exists {\n\t\t\tcontinue // Skip keys that don't exist in the struct\n\t\t}\n\n\t\tfieldValue := rtInfoValue.Field(fieldIndex)\n\t\tif !fieldValue.CanSet() {\n\t\t\tcontinue\n\t\t}\n\n\t\tsetFieldValue(fieldValue, value)\n\t}\n}\n\n// GetRTInfo returns the ObjRTInfo for the given ORef, or nil if not found\nfunc GetRTInfo(oref waveobj.ORef) *waveobj.ObjRTInfo {\n\trtInfoMutex.RLock()\n\tdefer rtInfoMutex.RUnlock()\n\n\tif rtInfo, exists := rtInfoStore[oref]; exists {\n\t\t// Return a copy to avoid external modification\n\t\tcopy := *rtInfo\n\t\treturn &copy\n\t}\n\treturn nil\n}\n\n// DeleteRTInfo removes the ObjRTInfo for the given ORef\nfunc DeleteRTInfo(oref waveobj.ORef) {\n\trtInfoMutex.Lock()\n\tdefer rtInfoMutex.Unlock()\n\n\tdelete(rtInfoStore, oref)\n}\n"
  },
  {
    "path": "postinstall.cjs",
    "content": "const skip =\n    process.env.WAVETERM_SKIP_APP_DEPS === \"1\" || process.env.CF_PAGES === \"1\" || process.env.CF_PAGES === \"true\";\n\nif (skip) {\n    console.log(\"postinstall: skipping electron-builder install-app-deps\");\n    process.exit(0);\n}\n\nimport(\"child_process\").then(({ execSync }) => {\n    execSync(\"electron-builder install-app-deps\", { stdio: \"inherit\" });\n});\n"
  },
  {
    "path": "prettier.config.cjs",
    "content": "/** @type {import(\"prettier\").Config} */\nmodule.exports = {\n    plugins: [\"prettier-plugin-jsdoc\", \"prettier-plugin-organize-imports\"],\n    printWidth: 120,\n    trailingComma: \"es5\",\n    useTabs: false,\n    jsdocVerticalAlignment: true,\n    jsdocSeparateReturnsFromParam: true,\n    jsdocSeparateTagGroups: true,\n    jsdocPreferCodeFences: true,\n};\n"
  },
  {
    "path": "schema/aipresets.json",
    "content": "{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"$defs\": {\n    \"AiSettingsType\": {\n      \"properties\": {\n        \"ai:*\": {\n          \"type\": \"boolean\"\n        },\n        \"ai:preset\": {\n          \"type\": \"string\"\n        },\n        \"ai:apitype\": {\n          \"type\": \"string\"\n        },\n        \"ai:baseurl\": {\n          \"type\": \"string\"\n        },\n        \"ai:apitoken\": {\n          \"type\": \"string\"\n        },\n        \"ai:name\": {\n          \"type\": \"string\"\n        },\n        \"ai:model\": {\n          \"type\": \"string\"\n        },\n        \"ai:orgid\": {\n          \"type\": \"string\"\n        },\n        \"ai:apiversion\": {\n          \"type\": \"string\"\n        },\n        \"ai:maxtokens\": {\n          \"type\": \"number\"\n        },\n        \"ai:timeoutms\": {\n          \"type\": \"number\"\n        },\n        \"ai:proxyurl\": {\n          \"type\": \"string\"\n        },\n        \"ai:fontsize\": {\n          \"type\": \"number\"\n        },\n        \"ai:fixedfontsize\": {\n          \"type\": \"number\"\n        },\n        \"display:name\": {\n          \"type\": \"string\"\n        },\n        \"display:order\": {\n          \"type\": \"number\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    }\n  },\n  \"additionalProperties\": {\n    \"$ref\": \"#/$defs/AiSettingsType\"\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "schema/bgpresets.json",
    "content": "{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"$defs\": {\n    \"BgPresetsType\": {\n      \"properties\": {\n        \"bg:*\": {\n          \"type\": \"boolean\"\n        },\n        \"bg\": {\n          \"type\": \"string\",\n          \"description\": \"CSS background property value\"\n        },\n        \"bg:opacity\": {\n          \"type\": \"number\",\n          \"description\": \"Background opacity (0.0-1.0)\"\n        },\n        \"bg:blendmode\": {\n          \"type\": \"string\",\n          \"description\": \"CSS background-blend-mode property value\"\n        },\n        \"bg:bordercolor\": {\n          \"type\": \"string\",\n          \"description\": \"Block frame border color\"\n        },\n        \"bg:activebordercolor\": {\n          \"type\": \"string\",\n          \"description\": \"Block frame focused border color\"\n        },\n        \"display:name\": {\n          \"type\": \"string\",\n          \"description\": \"The name shown in the context menu\"\n        },\n        \"display:order\": {\n          \"type\": \"number\",\n          \"description\": \"Determines the order of the background in the context menu\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    }\n  },\n  \"additionalProperties\": {\n    \"$ref\": \"#/$defs/BgPresetsType\"\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "schema/connections.json",
    "content": "{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"$defs\": {\n    \"ConnKeywords\": {\n      \"properties\": {\n        \"conn:wshenabled\": {\n          \"type\": \"boolean\"\n        },\n        \"conn:askbeforewshinstall\": {\n          \"type\": \"boolean\"\n        },\n        \"conn:wshpath\": {\n          \"type\": \"string\"\n        },\n        \"conn:shellpath\": {\n          \"type\": \"string\"\n        },\n        \"conn:ignoresshconfig\": {\n          \"type\": \"boolean\"\n        },\n        \"display:hidden\": {\n          \"type\": \"boolean\"\n        },\n        \"display:order\": {\n          \"type\": \"number\"\n        },\n        \"term:*\": {\n          \"type\": \"boolean\"\n        },\n        \"term:fontsize\": {\n          \"type\": \"number\"\n        },\n        \"term:fontfamily\": {\n          \"type\": \"string\"\n        },\n        \"term:theme\": {\n          \"type\": \"string\"\n        },\n        \"term:durable\": {\n          \"type\": \"boolean\"\n        },\n        \"cmd:env\": {\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"object\"\n        },\n        \"cmd:initscript\": {\n          \"type\": \"string\"\n        },\n        \"cmd:initscript.sh\": {\n          \"type\": \"string\"\n        },\n        \"cmd:initscript.bash\": {\n          \"type\": \"string\"\n        },\n        \"cmd:initscript.zsh\": {\n          \"type\": \"string\"\n        },\n        \"cmd:initscript.pwsh\": {\n          \"type\": \"string\"\n        },\n        \"cmd:initscript.fish\": {\n          \"type\": \"string\"\n        },\n        \"ssh:user\": {\n          \"type\": \"string\"\n        },\n        \"ssh:hostname\": {\n          \"type\": \"string\"\n        },\n        \"ssh:port\": {\n          \"type\": \"string\"\n        },\n        \"ssh:identityfile\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        },\n        \"ssh:passwordsecretname\": {\n          \"type\": \"string\"\n        },\n        \"ssh:batchmode\": {\n          \"type\": \"boolean\"\n        },\n        \"ssh:pubkeyauthentication\": {\n          \"type\": \"boolean\"\n        },\n        \"ssh:passwordauthentication\": {\n          \"type\": \"boolean\"\n        },\n        \"ssh:kbdinteractiveauthentication\": {\n          \"type\": \"boolean\"\n        },\n        \"ssh:preferredauthentications\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        },\n        \"ssh:addkeystoagent\": {\n          \"type\": \"boolean\"\n        },\n        \"ssh:identityagent\": {\n          \"type\": \"string\"\n        },\n        \"ssh:identitiesonly\": {\n          \"type\": \"boolean\"\n        },\n        \"ssh:proxyjump\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        },\n        \"ssh:userknownhostsfile\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        },\n        \"ssh:globalknownhostsfile\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    }\n  },\n  \"additionalProperties\": {\n    \"$ref\": \"#/$defs/ConnKeywords\"\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "schema/settings.json",
    "content": "{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"$id\": \"https://github.com/wavetermdev/waveterm/pkg/wconfig/settings-type\",\n  \"$ref\": \"#/$defs/SettingsType\",\n  \"$defs\": {\n    \"SettingsType\": {\n      \"properties\": {\n        \"app:*\": {\n          \"type\": \"boolean\"\n        },\n        \"app:globalhotkey\": {\n          \"type\": \"string\"\n        },\n        \"app:dismissarchitecturewarning\": {\n          \"type\": \"boolean\"\n        },\n        \"app:defaultnewblock\": {\n          \"type\": \"string\"\n        },\n        \"app:showoverlayblocknums\": {\n          \"type\": \"boolean\"\n        },\n        \"app:ctrlvpaste\": {\n          \"type\": \"boolean\"\n        },\n        \"app:confirmquit\": {\n          \"type\": \"boolean\"\n        },\n        \"app:hideaibutton\": {\n          \"type\": \"boolean\"\n        },\n        \"app:disablectrlshiftarrows\": {\n          \"type\": \"boolean\"\n        },\n        \"app:disablectrlshiftdisplay\": {\n          \"type\": \"boolean\"\n        },\n        \"app:focusfollowscursor\": {\n          \"type\": \"string\",\n          \"enum\": [\n            \"off\",\n            \"on\",\n            \"term\"\n          ]\n        },\n        \"app:tabbar\": {\n          \"type\": \"string\",\n          \"enum\": [\n            \"top\",\n            \"left\"\n          ]\n        },\n        \"feature:waveappbuilder\": {\n          \"type\": \"boolean\"\n        },\n        \"ai:*\": {\n          \"type\": \"boolean\"\n        },\n        \"ai:preset\": {\n          \"type\": \"string\"\n        },\n        \"ai:apitype\": {\n          \"type\": \"string\"\n        },\n        \"ai:baseurl\": {\n          \"type\": \"string\"\n        },\n        \"ai:apitoken\": {\n          \"type\": \"string\"\n        },\n        \"ai:name\": {\n          \"type\": \"string\"\n        },\n        \"ai:model\": {\n          \"type\": \"string\"\n        },\n        \"ai:orgid\": {\n          \"type\": \"string\"\n        },\n        \"ai:apiversion\": {\n          \"type\": \"string\"\n        },\n        \"ai:maxtokens\": {\n          \"type\": \"number\"\n        },\n        \"ai:timeoutms\": {\n          \"type\": \"number\"\n        },\n        \"ai:proxyurl\": {\n          \"type\": \"string\"\n        },\n        \"ai:fontsize\": {\n          \"type\": \"number\"\n        },\n        \"ai:fixedfontsize\": {\n          \"type\": \"number\"\n        },\n        \"waveai:showcloudmodes\": {\n          \"type\": \"boolean\"\n        },\n        \"waveai:defaultmode\": {\n          \"type\": \"string\"\n        },\n        \"term:*\": {\n          \"type\": \"boolean\"\n        },\n        \"term:fontsize\": {\n          \"type\": \"number\"\n        },\n        \"term:fontfamily\": {\n          \"type\": \"string\"\n        },\n        \"term:theme\": {\n          \"type\": \"string\"\n        },\n        \"term:disablewebgl\": {\n          \"type\": \"boolean\"\n        },\n        \"term:localshellpath\": {\n          \"type\": \"string\"\n        },\n        \"term:localshellopts\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        },\n        \"term:gitbashpath\": {\n          \"type\": \"string\"\n        },\n        \"term:scrollback\": {\n          \"type\": \"integer\"\n        },\n        \"term:copyonselect\": {\n          \"type\": \"boolean\"\n        },\n        \"term:transparency\": {\n          \"type\": \"number\"\n        },\n        \"term:allowbracketedpaste\": {\n          \"type\": \"boolean\"\n        },\n        \"term:shiftenternewline\": {\n          \"type\": \"boolean\"\n        },\n        \"term:macoptionismeta\": {\n          \"type\": \"boolean\"\n        },\n        \"term:cursor\": {\n          \"type\": \"string\"\n        },\n        \"term:cursorblink\": {\n          \"type\": \"boolean\"\n        },\n        \"term:bellsound\": {\n          \"type\": \"boolean\"\n        },\n        \"term:bellindicator\": {\n          \"type\": \"boolean\"\n        },\n        \"term:osc52\": {\n          \"type\": \"string\",\n          \"enum\": [\n            \"focus\",\n            \"always\"\n          ]\n        },\n        \"term:durable\": {\n          \"type\": \"boolean\"\n        },\n        \"editor:minimapenabled\": {\n          \"type\": \"boolean\"\n        },\n        \"editor:stickyscrollenabled\": {\n          \"type\": \"boolean\"\n        },\n        \"editor:wordwrap\": {\n          \"type\": \"boolean\"\n        },\n        \"editor:fontsize\": {\n          \"type\": \"number\"\n        },\n        \"editor:inlinediff\": {\n          \"type\": \"boolean\"\n        },\n        \"web:*\": {\n          \"type\": \"boolean\"\n        },\n        \"web:openlinksinternally\": {\n          \"type\": \"boolean\"\n        },\n        \"web:defaulturl\": {\n          \"type\": \"string\"\n        },\n        \"web:defaultsearch\": {\n          \"type\": \"string\"\n        },\n        \"autoupdate:*\": {\n          \"type\": \"boolean\"\n        },\n        \"autoupdate:enabled\": {\n          \"type\": \"boolean\"\n        },\n        \"autoupdate:intervalms\": {\n          \"type\": \"number\"\n        },\n        \"autoupdate:installonquit\": {\n          \"type\": \"boolean\"\n        },\n        \"autoupdate:channel\": {\n          \"type\": \"string\"\n        },\n        \"markdown:fontsize\": {\n          \"type\": \"number\"\n        },\n        \"markdown:fixedfontsize\": {\n          \"type\": \"number\"\n        },\n        \"preview:showhiddenfiles\": {\n          \"type\": \"boolean\"\n        },\n        \"preview:defaultsort\": {\n          \"type\": \"string\",\n          \"enum\": [\n            \"name\",\n            \"modtime\"\n          ]\n        },\n        \"tab:preset\": {\n          \"type\": \"string\"\n        },\n        \"tab:confirmclose\": {\n          \"type\": \"boolean\"\n        },\n        \"widget:*\": {\n          \"type\": \"boolean\"\n        },\n        \"widget:showhelp\": {\n          \"type\": \"boolean\"\n        },\n        \"window:*\": {\n          \"type\": \"boolean\"\n        },\n        \"window:fullscreenonlaunch\": {\n          \"type\": \"boolean\"\n        },\n        \"window:transparent\": {\n          \"type\": \"boolean\"\n        },\n        \"window:blur\": {\n          \"type\": \"boolean\"\n        },\n        \"window:opacity\": {\n          \"type\": \"number\"\n        },\n        \"window:bgcolor\": {\n          \"type\": \"string\"\n        },\n        \"window:reducedmotion\": {\n          \"type\": \"boolean\"\n        },\n        \"window:tilegapsize\": {\n          \"type\": \"integer\"\n        },\n        \"window:showmenubar\": {\n          \"type\": \"boolean\"\n        },\n        \"window:nativetitlebar\": {\n          \"type\": \"boolean\"\n        },\n        \"window:disablehardwareacceleration\": {\n          \"type\": \"boolean\"\n        },\n        \"window:maxtabcachesize\": {\n          \"type\": \"integer\"\n        },\n        \"window:magnifiedblockopacity\": {\n          \"type\": \"number\"\n        },\n        \"window:magnifiedblocksize\": {\n          \"type\": \"number\"\n        },\n        \"window:magnifiedblockblurprimarypx\": {\n          \"type\": \"integer\"\n        },\n        \"window:magnifiedblockblursecondarypx\": {\n          \"type\": \"integer\"\n        },\n        \"window:confirmclose\": {\n          \"type\": \"boolean\"\n        },\n        \"window:savelastwindow\": {\n          \"type\": \"boolean\"\n        },\n        \"window:dimensions\": {\n          \"type\": \"string\"\n        },\n        \"window:zoom\": {\n          \"type\": \"number\"\n        },\n        \"telemetry:*\": {\n          \"type\": \"boolean\"\n        },\n        \"telemetry:enabled\": {\n          \"type\": \"boolean\"\n        },\n        \"conn:*\": {\n          \"type\": \"boolean\"\n        },\n        \"conn:askbeforewshinstall\": {\n          \"type\": \"boolean\"\n        },\n        \"conn:wshenabled\": {\n          \"type\": \"boolean\"\n        },\n        \"conn:localhostdisplayname\": {\n          \"type\": \"string\"\n        },\n        \"debug:*\": {\n          \"type\": \"boolean\"\n        },\n        \"debug:pprofport\": {\n          \"type\": \"integer\"\n        },\n        \"debug:pprofmemprofilerate\": {\n          \"type\": \"integer\"\n        },\n        \"debug:webglstatus\": {\n          \"type\": \"boolean\"\n        },\n        \"tsunami:*\": {\n          \"type\": \"boolean\"\n        },\n        \"tsunami:scaffoldpath\": {\n          \"type\": \"string\"\n        },\n        \"tsunami:sdkreplacepath\": {\n          \"type\": \"string\"\n        },\n        \"tsunami:sdkversion\": {\n          \"type\": \"string\"\n        },\n        \"tsunami:gopath\": {\n          \"type\": \"string\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    }\n  }\n}"
  },
  {
    "path": "schema/waveai.json",
    "content": "{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"$defs\": {\n    \"AIModeConfigType\": {\n      \"properties\": {\n        \"display:name\": {\n          \"type\": \"string\"\n        },\n        \"display:order\": {\n          \"type\": \"number\"\n        },\n        \"display:icon\": {\n          \"type\": \"string\"\n        },\n        \"display:description\": {\n          \"type\": \"string\"\n        },\n        \"ai:provider\": {\n          \"type\": \"string\",\n          \"enum\": [\n            \"wave\",\n            \"google\",\n            \"groq\",\n            \"openrouter\",\n            \"nanogpt\",\n            \"openai\",\n            \"azure\",\n            \"azure-legacy\",\n            \"custom\"\n          ]\n        },\n        \"ai:apitype\": {\n          \"type\": \"string\",\n          \"enum\": [\n            \"google-gemini\",\n            \"openai-responses\",\n            \"openai-chat\"\n          ]\n        },\n        \"ai:model\": {\n          \"type\": \"string\"\n        },\n        \"ai:thinkinglevel\": {\n          \"type\": \"string\",\n          \"enum\": [\n            \"low\",\n            \"medium\",\n            \"high\"\n          ]\n        },\n        \"ai:verbosity\": {\n          \"type\": \"string\",\n          \"enum\": [\n            \"low\",\n            \"medium\",\n            \"high\"\n          ],\n          \"description\": \"Text verbosity level (OpenAI Responses API only)\"\n        },\n        \"ai:endpoint\": {\n          \"type\": \"string\"\n        },\n        \"ai:proxyurl\": {\n          \"type\": \"string\"\n        },\n        \"ai:azureapiversion\": {\n          \"type\": \"string\"\n        },\n        \"ai:apitoken\": {\n          \"type\": \"string\"\n        },\n        \"ai:apitokensecretname\": {\n          \"type\": \"string\"\n        },\n        \"ai:azureresourcename\": {\n          \"type\": \"string\"\n        },\n        \"ai:azuredeployment\": {\n          \"type\": \"string\"\n        },\n        \"ai:capabilities\": {\n          \"items\": {\n            \"type\": \"string\",\n            \"enum\": [\n              \"pdfs\",\n              \"images\",\n              \"tools\"\n            ]\n          },\n          \"type\": \"array\"\n        },\n        \"ai:switchcompat\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        },\n        \"waveai:cloud\": {\n          \"type\": \"boolean\"\n        },\n        \"waveai:premium\": {\n          \"type\": \"boolean\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\",\n      \"required\": [\n        \"display:name\"\n      ]\n    }\n  },\n  \"additionalProperties\": {\n    \"$ref\": \"#/$defs/AIModeConfigType\"\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "schema/widgets.json",
    "content": "{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"$defs\": {\n    \"BlockDef\": {\n      \"properties\": {\n        \"files\": {\n          \"additionalProperties\": {\n            \"$ref\": \"#/$defs/FileDef\"\n          },\n          \"type\": \"object\"\n        },\n        \"meta\": {\n          \"properties\": {\n            \"view\": {\n              \"anyOf\": [\n                {\n                  \"enum\": [\n                    \"term\",\n                    \"preview\",\n                    \"web\",\n                    \"sysinfo\",\n                    \"launcher\"\n                  ]\n                },\n                {\n                  \"type\": \"string\"\n                }\n              ]\n            },\n            \"file\": {\n              \"type\": \"string\"\n            },\n            \"url\": {\n              \"type\": \"string\"\n            },\n            \"controller\": {\n              \"anyOf\": [\n                {\n                  \"enum\": [\n                    \"shell\",\n                    \"cmd\"\n                  ]\n                },\n                {\n                  \"type\": \"string\"\n                }\n              ]\n            },\n            \"cmd\": {\n              \"type\": \"string\"\n            },\n            \"cmd:interactive\": {\n              \"type\": \"boolean\"\n            },\n            \"cmd:login\": {\n              \"type\": \"boolean\"\n            },\n            \"cmd:persistent\": {\n              \"type\": \"boolean\"\n            },\n            \"cmd:runonstart\": {\n              \"type\": \"boolean\"\n            },\n            \"cmd:clearonstart\": {\n              \"type\": \"boolean\"\n            },\n            \"cmd:runonce\": {\n              \"type\": \"boolean\"\n            },\n            \"cmd:closeonexit\": {\n              \"type\": \"boolean\"\n            },\n            \"cmd:closeonexitforce\": {\n              \"type\": \"boolean\"\n            },\n            \"cmd:closeonexitdelay\": {\n              \"type\": \"number\"\n            },\n            \"cmd:nowsh\": {\n              \"type\": \"boolean\"\n            },\n            \"cmd:args\": {\n              \"items\": {\n                \"type\": \"string\"\n              },\n              \"type\": \"array\"\n            },\n            \"cmd:shell\": {\n              \"type\": \"boolean\"\n            },\n            \"cmd:allowconnchange\": {\n              \"type\": \"boolean\"\n            },\n            \"cmd:env\": {\n              \"additionalProperties\": {\n                \"type\": \"string\"\n              },\n              \"type\": \"object\"\n            },\n            \"cmd:cwd\": {\n              \"type\": \"string\"\n            },\n            \"cmd:initscript\": {\n              \"type\": \"string\"\n            },\n            \"cmd:initscript.sh\": {\n              \"type\": \"string\"\n            },\n            \"cmd:initscript.bash\": {\n              \"type\": \"string\"\n            },\n            \"cmd:initscript.zsh\": {\n              \"type\": \"string\"\n            },\n            \"cmd:initscript.pwsh\": {\n              \"type\": \"string\"\n            },\n            \"cmd:initscript.fish\": {\n              \"type\": \"string\"\n            },\n            \"term:fontsize\": {\n              \"type\": \"integer\"\n            },\n            \"term:fontfamily\": {\n              \"type\": \"string\"\n            },\n            \"term:mode\": {\n              \"type\": \"string\"\n            },\n            \"term:theme\": {\n              \"type\": \"string\"\n            },\n            \"term:localshellpath\": {\n              \"type\": \"string\"\n            },\n            \"term:localshellopts\": {\n              \"items\": {\n                \"type\": \"string\"\n              },\n              \"type\": \"array\"\n            },\n            \"term:scrollback\": {\n              \"type\": \"integer\"\n            },\n            \"term:transparency\": {\n              \"type\": \"number\"\n            },\n            \"term:allowbracketedpaste\": {\n              \"type\": \"boolean\"\n            },\n            \"term:shiftenternewline\": {\n              \"type\": \"boolean\"\n            },\n            \"term:macoptionismeta\": {\n              \"type\": \"boolean\"\n            },\n            \"term:bellsound\": {\n              \"type\": \"boolean\"\n            },\n            \"term:bellindicator\": {\n              \"type\": \"boolean\"\n            },\n            \"term:durable\": {\n              \"type\": \"boolean\"\n            }\n          },\n          \"additionalProperties\": true,\n          \"type\": \"object\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    },\n    \"FileDef\": {\n      \"properties\": {\n        \"content\": {\n          \"type\": \"string\"\n        },\n        \"meta\": {\n          \"type\": \"object\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    },\n    \"WidgetConfigType\": {\n      \"properties\": {\n        \"display:order\": {\n          \"type\": \"number\"\n        },\n        \"display:hidden\": {\n          \"type\": \"boolean\"\n        },\n        \"icon\": {\n          \"type\": \"string\"\n        },\n        \"color\": {\n          \"type\": \"string\"\n        },\n        \"label\": {\n          \"type\": \"string\"\n        },\n        \"description\": {\n          \"type\": \"string\"\n        },\n        \"workspaces\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        },\n        \"magnified\": {\n          \"type\": \"boolean\"\n        },\n        \"blockdef\": {\n          \"$ref\": \"#/$defs/BlockDef\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\",\n      \"required\": [\n        \"blockdef\"\n      ]\n    }\n  },\n  \"additionalProperties\": {\n    \"$ref\": \"#/$defs/WidgetConfigType\"\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "staticcheck.conf",
    "content": "checks = [\"all\", \"-ST1005\", \"-QF1003\", \"-ST1000\", \"-ST1003\", \"-ST1020\", \"-ST1021\", \"-ST1022\"]\n\n"
  },
  {
    "path": "testdriver/onboarding.yml",
    "content": "version: 4.0.65\nsteps:\n  - prompt: complete the onboarding of wave terminal\n    commands:\n      - command: hover-text\n        text: Continue\n        description: button to complete onboarding\n        action: click\n      - command: hover-text\n        text: Get Started\n        description: button to complete onboarding\n        action: click\n      - command: assert\n        expect: the cpu usage graph is being displayed\n"
  },
  {
    "path": "tests/copytests/cases/test000.sh",
    "content": "# copy a file to one with a different name\n# ensure that the original exists\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy foo.txt bar.txt\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test001.sh",
    "content": "# copy a file to one with a different name\n# ensure that the destination file exists\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy foo.txt bar.txt\n\nif [ ! -f bar.txt ]; then\n    echo \"bar.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test002.sh",
    "content": "# copy a file with contents\n# ensure the contents are the same\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\necho \"The quick brown fox jumps over the lazy dog\" > foo.txt\n\nwsh file copy foo.txt bar.txt\n\n\nFOO_MD5=$(md5sum foo.txt | cut -d \" \" -f1)\nBAR_MD5=$(md5sum bar.txt | cut -d \" \" -f1)\nif [ $FOO_MD5 != $BAR_MD5 ]; then\n    echo \"files are not the same\"\n    echo \"FOO_MD5 is $FOO_MD5\"\n    echo \"BAR_MD5 is $BAR_MD5\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test003.sh",
    "content": "# copy a file where source starts with ./\n# ensure the source file exists\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy ./foo.txt bar.txt\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test004.sh",
    "content": "# copy a file where source starts with ./\n# ensure the destination file exists\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy ./foo.txt bar.txt\n\nif [ ! -f bar.txt ]; then\n    echo \"bar.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test005.sh",
    "content": "# copy a file where destination starts with ./\n# ensure the source file exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy foo.txt ./bar.txt\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test006.sh",
    "content": "# copy a file where destination starts with ./\n# ensure the destination file exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy foo.txt ./bar.txt\n\nif [ ! -f bar.txt ]; then\n    echo \"bar.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test007.sh",
    "content": "# copy a file where source and destination start with ./\n# ensure the source file exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy ./foo.txt ./bar.txt\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test008.sh",
    "content": "# copy a file where source and destination start with ./\n# ensure the destination file exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy ./foo.txt ./bar.txt\n\nif [ ! -f bar.txt ]; then\n    echo \"bar.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test009.sh",
    "content": "# copy a file to itself with the same literal name\n# ensure the operation fails and the file still exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy foo.txt foo.txt >/dev/null 2>&1 && echo \"copy should have failed\" && exit 1\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test010.sh",
    "content": "# copy a file to itself with a different literal name\n# ensure the copy fails and the file still exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy foo.txt ./foo.txt >/dev/null 2>&1 && echo \"copy should have failed\" && exit 1\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test011.sh",
    "content": "# copy a file with ~ used to resolve the source\n# ensure the source still exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy ~/testcp/foo.txt bar.txt\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test012.sh",
    "content": "# copy a file with ~ used to resolve the source\n# ensure the destination exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy ~/testcp/foo.txt bar.txt\n\nif [ ! -f bar.txt ]; then\n    echo \"bar.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test013.sh",
    "content": "# copy a file with ~ used to resolve the destination\n# ensure the source exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy foo.txt ~/testcp/bar.txt\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test014.sh",
    "content": "# copy a file with ~ used to resolve the destination\n# ensure the destination exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy foo.txt ~/testcp/bar.txt\n\nif [ ! -f bar.txt ]; then\n    echo \"bar.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test015.sh",
    "content": "# copy a file where source and destination are resolved with ~\n# ensure the source file exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy ~/testcp/foo.txt ~/testcp/bar.txt\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test016.sh",
    "content": "# copy a file where source and destination are resolved with ~\n# ensure the destination file exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy ~/testcp/foo.txt ~/testcp/bar.txt\n\nif [ ! -f bar.txt ]; then\n    echo \"bar.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test017.sh",
    "content": "# copy a file to itself with ~ for destination resolution\n# ensure that the operation fails and the file still exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy foo.txt ~/testcp/foo.txt  >/dev/null 2>&1 && echo \"copy should have failed\" && exit 1\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test018.sh",
    "content": "# copy a file to itself with ~ for source resolution\n# ensure that the operation fails and the file still exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy ~/testcp/foo.txt foo.txt  >/dev/null 2>&1 && echo \"copy should have failed\" && exit 1\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test019.sh",
    "content": "# copy a file to itself with env var expansion in destination\n# ensure the operation fails and the file still exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy foo.txt \"${HOME}\"/testcp/foo.txt  >/dev/null 2>&1 && echo \"copy should have failed\" && exit 1\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test020.sh",
    "content": "# copy a file to itself with env var expansion in source\n# ensure the operation fails and the file still exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy \"${HOME}\"/testcp/foo.txt foo.txt  >/dev/null 2>&1 && echo \"copy should have failed\" && exit 1\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test021.sh",
    "content": "# copy to a deeper directory and rename\n# ensure the destination file exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\nmkdir baz\n\nwsh file copy foo.txt baz/bar.txt\n\nif [ ! -f baz/bar.txt ]; then\n    echo \"baz/bar.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test022.sh",
    "content": "# copy a file to a deeper directory with the same base name\n# ensure the destination file exists\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\nmkdir baz\n\nwsh file copy foo.txt baz/foo.txt\n\nif [ ! -f baz/foo.txt ]; then\n    echo \"baz/foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test023.sh",
    "content": "# copy into an existing directory ending in /\n# ensure the file is inserted in the directory\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\nmkdir baz\n\nwsh file copy foo.txt baz/\n\nif [ ! -f baz/foo.txt ]; then\n    echo \"baz/foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test024.sh",
    "content": "# copy into an existing directory not ending in /\n# ensure the file is inserted in the directory\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\nmkdir baz\n\nwsh file copy foo.txt baz\n\nif [ ! -f baz/foo.txt ]; then\n    echo \"baz/foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test025.sh",
    "content": "# copy into an non-existing directory where file has the same base name\n# ensure the file is copied to a file inside the directory\n# note that this is not regular cp behavior\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\n# this is different from cp behavior\nwsh file copy foo.txt baz/foo.txt\n\nif [ ! -f baz/foo.txt ]; then\n    echo \"baz/foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test026.sh",
    "content": "# copy into an non-existing directory ending with a /\n# ensure the file is copied to a file inside the directory\n# note that this is not regular cp behavior\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\n# this is different from cp behavior\nwsh file copy foo.txt baz/\n\nif [ ! -f baz/foo.txt ]; then\n    echo \"baz/foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test027.sh",
    "content": "# copy into an non-existing file name not-ending with a /\n# ensure the file is copied to a file instead of a directory\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\n\nwsh file copy foo.txt baz\n\nif [ ! -f baz ]; then\n    echo \"baz does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test028.sh",
    "content": "# copy from relative .. source to current directory .\n# ensure the file is copied correctly\n\nset -e\ncd \"$HOME/testcp\"\ntouch foo.txt\nmkdir baz\ncd baz\n\nwsh file copy ../foo.txt .\ncd ..\n\nif [ ! -f baz/foo.txt ]; then\n    echo \"baz/foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test029.sh",
    "content": "# copy from the current directory to a relative directory ..\n# ensure the file is copied correctly\n\nset -e\ncd \"$HOME/testcp\"\nmkdir baz\ncd baz\ntouch foo.txt\n\n\nwsh file copy foo.txt ..\ncd ..\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test030.sh",
    "content": "# copy from a deeper directory to the current directory .\n# ensure the file is copied correctly\n\nset -e\ncd \"$HOME/testcp\"\nmkdir baz\ntouch baz/foo.txt\n\nwsh file copy baz/foo.txt .\n\nif [ ! -f foo.txt ]; then\n    echo \"foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test032.sh",
    "content": "# copy an empty directory to a non-existing directory\n# ensure the empty directory is copied to one with the new name\n\nset -e\ncd \"$HOME/testcp\"\nmkdir foo\n\nwsh file copy foo bar\n\nif [ ! -d bar ]; then\n    echo \"bar does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test034.sh",
    "content": "# copy an empty directory ending with / to a non-existing directory\n# ensure the copy succeeds and the new directory exists\n\nset -e\ncd \"$HOME/testcp\"\nmkdir bar\n\nwsh file copy bar/ baz\nif [ ! -d baz ]; then\n    echo \"baz does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test036.sh",
    "content": "# copy an empty directory to a non-existing directory ending with /\n# ensure the copy succeeds and the new directory exists\n\nset -e\ncd \"$HOME/testcp\"\nmkdir bar\n\nwsh file copy bar baz/\n\nif [ ! -d baz ]; then\n    echo \"baz does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test037.sh",
    "content": "# copy an empty directory ending with // to a non-existing directory\n# ensure the copy succeeds and the new directory exists\n\nset -e\ncd \"$HOME/testcp\"\nmkdir bar\n\nwsh file copy bar// baz\n\nif [ ! -d baz ]; then\n    echo \"baz does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test038.sh",
    "content": "# copy an empty directory to a non-existing directory ending with //\n# ensure the copy succeeds and the new directory exists\n\nset -e\ncd \"$HOME/testcp\"\nmkdir bar\n\nwsh file copy bar baz//\n\nif [ ! -d baz ]; then\n    echo \"baz does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test040.sh",
    "content": "# copy a directory containing a file to a new directory\n# ensure this succeeds and the new files exist\n\nset -e\ncd \"$HOME/testcp\"\nmkdir bar\ntouch bar/foo.txt\n\nwsh file copy bar baz\n\nif [ ! -f baz/foo.txt ]; then\n    echo \"baz/foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test041.sh",
    "content": "# copy a directory containing a file to an existing directory\n# ensure this succeeds and the new files are nested in the existing directory\n\nset -e\ncd \"$HOME/testcp\"\nmkdir bar\ntouch bar/foo.txt\nmkdir baz\n\nwsh file copy bar baz\n\nif [ ! -f baz/bar/foo.txt ]; then\n    echo \"baz/bar/foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test042.sh",
    "content": "# copy a directory containing a file to an existing directory ending with /\n# ensure this succeeds and the new files are nested in the existing directory\n\nset -e\ncd \"$HOME/testcp\"\nmkdir bar\ntouch bar/foo.txt\nmkdir baz\n\nwsh file copy bar baz/\n\nif [ ! -f baz/bar/foo.txt ]; then\n    echo \"baz/bar/foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test043.sh",
    "content": "# copy a directory containing a file to an existing directory ending with /.\n# ensure this succeeds and the new files are nested in the existing directory\n\nset -e\ncd \"$HOME/testcp\"\nmkdir bar\ntouch bar/foo.txt\nmkdir baz\n\nwsh file copy bar baz/.\n\nif [ ! -f baz/bar/foo.txt ]; then\n    echo \"baz/bar/foo.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test044.sh",
    "content": "# copy a doubly nested directory containing a file to a non-existant directory\n# ensure this succeeds and the new files exist with the first directory renamed\n\nset -e\ncd \"$HOME/testcp\"\nmkdir foo\nmkdir foo/bar\ntouch foo/bar/baz.txt\n\nwsh file copy foo qux\n\nif [ ! -f qux/bar/baz.txt ]; then\n    echo \"qux/bar/baz.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test045.sh",
    "content": "# copy a doubly nested directory containing a file to an existing directory\n# ensure this succeeds and the new files exist and are nested in the existing directory\n\nset -e\ncd \"$HOME/testcp\"\nmkdir foo\nmkdir foo/bar\ntouch foo/bar/baz.txt\nmkdir qux\n\nwsh file copy foo qux\n\nif [ ! -f qux/foo/bar/baz.txt ]; then\n    echo \"qux/foo/bar/baz.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test046.sh",
    "content": "# copy a file with /// separating directory and file\n# ensure the copy succeeds and the file exists\n\nset -e\ncd \"$HOME/testcp\"\nmkdir foo\ntouch foo/bar.txt\n\nwsh file copy foo///bar.txt .\n\nif [ ! -f bar.txt ]; then\n    echo \"bar.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test047.sh",
    "content": "# copy a file with /// to a file with //\n# ensure the copy succeeds and the file exists\n\nset -e\ncd \"$HOME/testcp\"\nmkdir foo\ntouch foo/bar.txt\nmkdir baz\n\nwsh file copy foo///bar.txt baz//qux.txt\n\nif [ ! -f baz/qux.txt ]; then\n    echo \"baz/qux.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test048.sh",
    "content": "# copy the current directory into an existing directory\n# ensure the copy succeeds and the output exists\n\nset -e\ncd \"$HOME/testcp\"\nmkdir foo\ntouch foo/bar.txt\nmkdir baz\ncd foo\n\n\nwsh file copy . ../baz\ncd ..\n\n\nif [ ! -f baz/bar.txt ]; then\n    echo \"baz/bar.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test049.sh",
    "content": "# copy the current directory into a non-existing directory\n# ensure the copy succeeds and the output exists\n\nset -e\ncd \"$HOME/testcp\"\nmkdir foo\ntouch foo/bar.txt\ncd foo\n\nwsh file copy . ../baz\ncd ..\n\nif [ ! -f baz/bar.txt ]; then\n    echo \"baz/bar.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test051.sh",
    "content": "# copy the current directory into a non-existing directory\n# ensure the copy succeeds and the output exists\n\nset -e\ncd \"$HOME/testcp\"\nmkdir foo\ntouch foo/bar.txt\ncd foo\n\nwsh file copy . ../baz\ncd ..\n\nif [ ! -f baz/bar.txt ]; then\n    echo \"baz/bar.txt does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "tests/copytests/cases/test052.sh",
    "content": "# copy a directory with contents\n# ensure the contents are the same\nset -e\ncd \"$HOME/testcp\"\nmkdir foo\nmkdir foo/bar\ntouch foo/bar/baz.txt\nmkdir foo/bar/qux\ntouch foo/bar/qux/quux.txt\necho \"The quick brown fox jumps over the lazy dog.\" > foo/bar/baz.txt\necho \"Sphinx of black quartz, judge my vow.\" > foo/bar/qux/quux.txt\nmkdir corge\n\n# we need a nested corge/foo so the foo.zip contains the same exact file names\n# in other words, if one file was named foo and the other was corge, they would\n# not match. this allows them to be the same.\nwsh file copy foo corge/foo\n\n\nzip -r foo.zip foo >/dev/null 2>&1\nFOO_MD5=$(md5sum foo.zip | cut -d \" \" -f1)\n\ncd corge\nzip -r foo.zip foo >/dev/null 2>&1\nCORGE_MD5=$(md5sum foo.zip | cut -d \" \" -f1)\n\nif [ $FOO_MD5 != $CORGE_MD5 ]; then\n    echo \"directories are not the same\"\n    echo \"FOO_MD5 is $FOO_MD5\"\n    echo \"CORGE_MD5 is $CORGE_MD5\"\n    exit 1\nfi\n\n"
  },
  {
    "path": "tests/copytests/runner.sh",
    "content": "#!/bin/bash\n\ncd \"$(dirname \"$0\")\"\nsource testutil.sh\n\nTOTAL_COPY_TESTS_RUN=0\nTOTAL_COPY_TESTS_PASSED=0\n\nfor fname in cases/*.sh; do\n    setup_testcp\n    #\"${fname}\" | read outerr && printf \"\\e[32mPASS $fname\\n\\n\\e[0m\" || printf \"\\e[31mFAIL $fname: $outerr \\n\\n\\e[0m\"\n    if ! outerr=$(\"${fname}\" 2>&1); then\n        printf \"\\e[31mFAIL $fname:\\n$outerr \\n\\e[0m\"\n\t\tcat \"${fname}\"\n\t\tprintf \"\\n\"\n    else\n        printf \"\\e[32mPASS $fname\\n\\n\\e[0m\"\n        ((TOTAL_COPY_TESTS_PASSED++))\n    fi\n    cleanup_testcp\n\t((TOTAL_COPY_TESTS_RUN++))\ndone\n\nprintf \"\\n\\e[32m${TOTAL_COPY_TESTS_PASSED} of ${TOTAL_COPY_TESTS_RUN} Tests Passed \\e[0m\\n\\n\""
  },
  {
    "path": "tests/copytests/testutil.sh",
    "content": "setup_testcp () {\n    if [ -d \"$HOME/testcp\" ]; then\n        echo \"Test cannot run if testcp already exists\"\n        exit 1\n    fi\n\n    mkdir ~/testcp\n}\n\n\ncleanup_testcp () {\n    rm -rf \"$HOME/testcp\" || rmdir \"$HOME/testcp\"\n}"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n    \"include\": [\"frontend/**/*\", \"emain/**/*\"],\n    \"compilerOptions\": {\n        \"target\": \"es6\",\n        \"module\": \"es2020\",\n        \"jsx\": \"preserve\",\n        \"esModuleInterop\": true,\n        \"skipLibCheck\": true,\n        \"forceConsistentCasingInFileNames\": true,\n        \"moduleResolution\": \"bundler\",\n        \"allowSyntheticDefaultImports\": true,\n        \"resolveJsonModule\": true,\n        \"isolatedModules\": true,\n        \"experimentalDecorators\": true,\n        \"downlevelIteration\": true,\n        \"baseUrl\": \"./\",\n        \"paths\": {\n            \"@/app/*\": [\"frontend/app/*\"],\n            \"@/builder/*\": [\"frontend/builder/*\"],\n            \"@/util/*\": [\"frontend/util/*\"],\n            \"@/layout/*\": [\"frontend/layout/*\"],\n            \"@/store/*\": [\"frontend/app/store/*\"],\n            \"@/view/*\": [\"frontend/app/view/*\"],\n            \"@/element/*\": [\"frontend/app/element/*\"],\n            \"@/shadcn/*\": [\"frontend/app/shadcn/*\"],\n            \"@/preview/*\": [\"frontend/preview/*\"]\n        },\n        \"lib\": [\"dom\", \"dom.iterable\", \"es6\"],\n        \"allowJs\": true,\n        \"strict\": false,\n        \"noEmit\": true\n    }\n}\n"
  },
  {
    "path": "tsunami/.gitignore",
    "content": "bin/\n*.tsapp"
  },
  {
    "path": "tsunami/app/atom.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage app\n\nimport (\n\t\"log\"\n\t\"reflect\"\n\t\"runtime\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/engine\"\n\t\"github.com/wavetermdev/waveterm/tsunami/util\"\n)\n\n// AtomMeta provides metadata about an atom for validation and documentation\ntype AtomMeta struct {\n\tDesc    string   // short, user-facing\n\tUnits   string   // \"ms\", \"GiB\", etc.\n\tMin     *float64 // optional minimum (numeric types)\n\tMax     *float64 // optional maximum (numeric types)\n\tEnum    []string // allowed values if finite set\n\tPattern string   // regex constraint for strings\n}\n\n// SecretMeta provides metadata about a secret for documentation and validation\ntype SecretMeta struct {\n\tDesc     string\n\tOptional bool\n}\n\n// Atom[T] represents a typed atom implementation\ntype Atom[T any] struct {\n\tname   string\n\tclient *engine.ClientImpl\n}\n\n// logInvalidAtomSet logs an error when an atom is being set during component render\nfunc logInvalidAtomSet(atomName string) {\n\t_, file, line, ok := runtime.Caller(2)\n\tif ok {\n\t\tlog.Printf(\"invalid Set of atom '%s' in component render function at %s:%d\", atomName, file, line)\n\t} else {\n\t\tlog.Printf(\"invalid Set of atom '%s' in component render function\", atomName)\n\t}\n}\n\n// sameRef returns true if oldVal and newVal share the same underlying reference\n// (pointer, map, or slice). Nil values return false.\nfunc sameRef[T any](oldVal, newVal T) bool {\n\tvOld := reflect.ValueOf(oldVal)\n\tvNew := reflect.ValueOf(newVal)\n\n\tif !vOld.IsValid() || !vNew.IsValid() {\n\t\treturn false\n\t}\n\n\tswitch vNew.Kind() {\n\tcase reflect.Ptr:\n\t\t// direct comparison works for *T\n\t\treturn any(oldVal) == any(newVal)\n\n\tcase reflect.Map, reflect.Slice:\n\t\tif vOld.Kind() != vNew.Kind() || vOld.IsZero() || vNew.IsZero() {\n\t\t\treturn false\n\t\t}\n\t\treturn vOld.Pointer() == vNew.Pointer()\n\t}\n\n\t// primitives, structs, etc. → not a reference type\n\treturn false\n}\n\n// logMutationWarning logs a warning when mutation is detected\nfunc logMutationWarning(atomName string) {\n\t_, file, line, ok := runtime.Caller(2)\n\tif ok {\n\t\tlog.Printf(\"WARNING: atom '%s' appears to be mutated instead of copied at %s:%d - use app.DeepCopy to create a copy before mutating\", atomName, file, line)\n\t} else {\n\t\tlog.Printf(\"WARNING: atom '%s' appears to be mutated instead of copied - use app.DeepCopy to create a copy before mutating\", atomName)\n\t}\n}\n\n// AtomName implements the vdom.Atom interface\nfunc (a Atom[T]) AtomName() string {\n\treturn a.name\n}\n\n// Get returns the current value of the atom. When called during component render,\n// it automatically registers the component as a dependency for this atom, ensuring\n// the component re-renders when the atom value changes.\nfunc (a Atom[T]) Get() T {\n\tvc := engine.GetGlobalRenderContext()\n\tif vc != nil {\n\t\tvc.UsedAtoms[a.name] = true\n\t}\n\tval := a.client.Root.GetAtomVal(a.name)\n\ttypedVal := util.GetTypedAtomValue[T](val, a.name)\n\treturn typedVal\n}\n\n// Set updates the atom's value to the provided new value and triggers re-rendering\n// of any components that depend on this atom. This method cannot be called during\n// render cycles - use effects or event handlers instead.\nfunc (a Atom[T]) Set(newVal T) {\n\tvc := engine.GetGlobalRenderContext()\n\tif vc != nil {\n\t\tlogInvalidAtomSet(a.name)\n\t\treturn\n\t}\n\n\t// Check for potential mutation bugs with reference types\n\tcurrentVal := a.client.Root.GetAtomVal(a.name)\n\tcurrentTyped := util.GetTypedAtomValue[T](currentVal, a.name)\n\tif sameRef(currentTyped, newVal) {\n\t\tlogMutationWarning(a.name)\n\t}\n\n\tif err := a.client.Root.SetAtomVal(a.name, newVal); err != nil {\n\t\tlog.Printf(\"Failed to set atom value for %s: %v\", a.name, err)\n\t\treturn\n\t}\n\ta.client.Root.AtomAddRenderWork(a.name)\n}\n\n// SetFn updates the atom's value by applying the provided function to the current value.\n// The function receives a copy of the current atom value, which can be safely mutated\n// without affecting the original data. The return value from the function becomes the\n// new atom value. This method cannot be called during render cycles.\nfunc (a Atom[T]) SetFn(fn func(T) T) {\n\tvc := engine.GetGlobalRenderContext()\n\tif vc != nil {\n\t\tlogInvalidAtomSet(a.name)\n\t\treturn\n\t}\n\tcurrentVal := a.Get()\n\tcopiedVal := DeepCopy(currentVal)\n\tnewVal := fn(copiedVal)\n\ta.Set(newVal)\n}\n"
  },
  {
    "path": "tsunami/app/defaultclient.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage app\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/engine\"\n\t\"github.com/wavetermdev/waveterm/tsunami/util\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\nconst TsunamiCloseOnStdinEnvVar = \"TSUNAMI_CLOSEONSTDIN\"\nconst MaxShortDescLen = 120\n\ntype AppMeta engine.AppMeta\n\ntype staticFileInfo struct {\n\tfullPath string\n\tinfo     fs.FileInfo\n}\n\nfunc (sfi *staticFileInfo) Name() string       { return sfi.fullPath }\nfunc (sfi *staticFileInfo) Size() int64        { return sfi.info.Size() }\nfunc (sfi *staticFileInfo) Mode() fs.FileMode  { return sfi.info.Mode() }\nfunc (sfi *staticFileInfo) ModTime() time.Time { return sfi.info.ModTime() }\nfunc (sfi *staticFileInfo) IsDir() bool        { return sfi.info.IsDir() }\nfunc (sfi *staticFileInfo) Sys() any           { return sfi.info.Sys() }\n\nfunc DefineComponent[P any](name string, renderFn func(props P) any) vdom.Component[P] {\n\treturn engine.DefineComponentEx(engine.GetDefaultClient(), name, renderFn)\n}\n\nfunc Ptr[T any](v T) *T {\n\treturn &v\n}\n\nfunc SetGlobalEventHandler(handler func(event vdom.VDomEvent)) {\n\tengine.GetDefaultClient().SetGlobalEventHandler(handler)\n}\n\n// RegisterAppInitFn registers a single setup function that is called before the app starts running.\n// Only one setup function is allowed, so calling this will replace any previously registered\n// setup function.\nfunc RegisterAppInitFn(fn func() error) {\n\tengine.GetDefaultClient().RegisterAppInitFn(fn)\n}\n\n// SendAsyncInitiation notifies the frontend that the backend has updated state\n// and requires a re-render. Normally the frontend calls the backend in response\n// to events, but when the backend changes state independently (e.g., from a\n// background process), this function gives the frontend a \"nudge\" to update.\nfunc SendAsyncInitiation() error {\n\treturn engine.GetDefaultClient().SendAsyncInitiation()\n}\n\nfunc TermWrite(ref *vdom.VDomRef, data string) error {\n\tif ref == nil || !ref.HasCurrent.Load() {\n\t\treturn nil\n\t}\n\treturn engine.GetDefaultClient().SendTermWrite(ref.RefId, data)\n}\n\nfunc ConfigAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] {\n\tfullName := \"$config.\" + name\n\tclient := engine.GetDefaultClient()\n\tengineMeta := convertAppMetaToEngineMeta(meta)\n\tatom := engine.MakeAtomImpl(defaultValue, engineMeta)\n\tclient.Root.RegisterAtom(fullName, atom)\n\treturn Atom[T]{name: fullName, client: client}\n}\n\nfunc DataAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] {\n\tfullName := \"$data.\" + name\n\tclient := engine.GetDefaultClient()\n\tengineMeta := convertAppMetaToEngineMeta(meta)\n\tatom := engine.MakeAtomImpl(defaultValue, engineMeta)\n\tclient.Root.RegisterAtom(fullName, atom)\n\treturn Atom[T]{name: fullName, client: client}\n}\n\nfunc SharedAtom[T any](name string, defaultValue T) Atom[T] {\n\tfullName := \"$shared.\" + name\n\tclient := engine.GetDefaultClient()\n\tatom := engine.MakeAtomImpl(defaultValue, nil)\n\tclient.Root.RegisterAtom(fullName, atom)\n\treturn Atom[T]{name: fullName, client: client}\n}\n\nfunc convertAppMetaToEngineMeta(appMeta *AtomMeta) *engine.AtomMeta {\n\tif appMeta == nil {\n\t\treturn nil\n\t}\n\treturn &engine.AtomMeta{\n\t\tDescription: appMeta.Desc,\n\t\tUnits:       appMeta.Units,\n\t\tMin:         appMeta.Min,\n\t\tMax:         appMeta.Max,\n\t\tEnum:        appMeta.Enum,\n\t\tPattern:     appMeta.Pattern,\n\t}\n}\n\n// HandleDynFunc registers a dynamic HTTP handler function with the internal http.ServeMux.\n// The pattern MUST start with \"/dyn/\" to be valid. This allows registration of dynamic\n// routes that can be handled at runtime.\nfunc HandleDynFunc(pattern string, fn func(http.ResponseWriter, *http.Request)) {\n\tengine.GetDefaultClient().HandleDynFunc(pattern, fn)\n}\n\n// RunMain is used internally by generated code and should not be called directly.\nfunc RunMain() {\n\tcloseOnStdin := os.Getenv(TsunamiCloseOnStdinEnvVar) != \"\"\n\n\tif closeOnStdin {\n\t\tgo func() {\n\t\t\t// Read stdin until EOF/close, then exit the process\n\t\t\tio.Copy(io.Discard, os.Stdin)\n\t\t\tlog.Printf(\"[tsunami] shutting down due to close of stdin\\n\")\n\t\t\tos.Exit(0)\n\t\t}()\n\t}\n\n\tengine.GetDefaultClient().RunMain()\n}\n\n// RegisterEmbeds is used internally by generated code and should not be called directly.\nfunc RegisterEmbeds(assetsFilesystem fs.FS, staticFilesystem fs.FS, manifest []byte) {\n\tclient := engine.GetDefaultClient()\n\tclient.AssetsFS = assetsFilesystem\n\tclient.StaticFS = staticFilesystem\n\tclient.ManifestFileBytes = manifest\n}\n\n// DeepCopy creates a deep copy of the input value using JSON marshal/unmarshal.\n// Panics on JSON errors.\nfunc DeepCopy[T any](v T) T {\n\tdata, err := json.Marshal(v)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tvar result T\n\terr = json.Unmarshal(data, &result)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn result\n}\n\n// QueueRefOp queues a reference operation to be executed on the DOM element.\n// Operations include actions like \"focus\", \"scrollIntoView\", etc.\n// If the ref is nil or not current, the operation is ignored.\n// This function must be called within a component context.\nfunc QueueRefOp(ref *vdom.VDomRef, op vdom.VDomRefOperation) {\n\tif ref == nil || !ref.HasCurrent.Load() {\n\t\treturn\n\t}\n\tif op.RefId == \"\" {\n\t\top.RefId = ref.RefId\n\t}\n\tclient := engine.GetDefaultClient()\n\tclient.Root.QueueRefOp(op)\n}\n\nfunc SetAppMeta(meta AppMeta) {\n\tmeta.ShortDesc = util.TruncateString(meta.ShortDesc, MaxShortDescLen)\n\tclient := engine.GetDefaultClient()\n\tclient.SetAppMeta(engine.AppMeta(meta))\n}\n\nfunc SetTitle(title string) {\n\tclient := engine.GetDefaultClient()\n\tm := client.GetAppMeta()\n\tm.Title = title\n\tclient.SetAppMeta(m)\n}\n\nfunc SetShortDesc(shortDesc string) {\n\tshortDesc = util.TruncateString(shortDesc, MaxShortDescLen)\n\tclient := engine.GetDefaultClient()\n\tm := client.GetAppMeta()\n\tm.ShortDesc = shortDesc\n\tclient.SetAppMeta(m)\n}\n\nfunc DeclareSecret(secretName string, meta *SecretMeta) string {\n\tclient := engine.GetDefaultClient()\n\tvar secretDesc string\n\tvar secretOptional bool\n\tif meta != nil {\n\t\tsecretDesc = meta.Desc\n\t\tsecretOptional = meta.Optional\n\t}\n\tclient.DeclareSecret(secretName, secretDesc, secretOptional)\n\treturn os.Getenv(secretName)\n}\n\nfunc PrintAppManifest() {\n\tclient := engine.GetDefaultClient()\n\tclient.PrintAppManifest()\n}\n\n// ReadStaticFile reads a file from the embedded static filesystem.\n// The path MUST start with \"static/\" (e.g., \"static/config.json\").\n// Returns the file contents or an error if the file doesn't exist or can't be read.\nfunc ReadStaticFile(path string) ([]byte, error) {\n\tclient := engine.GetDefaultClient()\n\tif client.StaticFS == nil {\n\t\treturn nil, errors.New(\"static files not available before app initialization; use AppInit to access files during initialization\")\n\t}\n\tif !strings.HasPrefix(path, \"static/\") {\n\t\treturn nil, fmt.Errorf(\"ReadStaticFile path must start with 'static/': %w\", fs.ErrNotExist)\n\t}\n\t// Strip \"static/\" prefix since the FS is already sub'd to the static directory\n\trelativePath := strings.TrimPrefix(path, \"static/\")\n\treturn fs.ReadFile(client.StaticFS, relativePath)\n}\n\n// OpenStaticFile opens a file from the embedded static filesystem.\n// The path MUST start with \"static/\" (e.g., \"static/config.json\").\n// Returns an fs.File or an error if the file doesn't exist or can't be opened.\nfunc OpenStaticFile(path string) (fs.File, error) {\n\tclient := engine.GetDefaultClient()\n\tif client.StaticFS == nil {\n\t\treturn nil, errors.New(\"static files not available before app initialization; use AppInit to access files during initialization\")\n\t}\n\tif !strings.HasPrefix(path, \"static/\") {\n\t\treturn nil, fmt.Errorf(\"OpenStaticFile path must start with 'static/': %w\", fs.ErrNotExist)\n\t}\n\t// Strip \"static/\" prefix since the FS is already sub'd to the static directory\n\trelativePath := strings.TrimPrefix(path, \"static/\")\n\treturn client.StaticFS.Open(relativePath)\n}\n\n// ListStaticFiles returns FileInfo for all files in the embedded static filesystem.\n// The Name() of each FileInfo will be the full path prefixed with \"static/\" (e.g., \"static/config.json\"),\n// which can be passed directly to ReadStaticFile or OpenStaticFile.\nfunc ListStaticFiles() ([]fs.FileInfo, error) {\n\tclient := engine.GetDefaultClient()\n\tif client.StaticFS == nil {\n\t\treturn nil, errors.New(\"static files not available before app initialization; use AppInit to access files during initialization\")\n\t}\n\n\tvar fileInfos []fs.FileInfo\n\terr := fs.WalkDir(client.StaticFS, \".\", func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif !d.IsDir() {\n\t\t\tinfo, err := d.Info()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tfullPath := \"static/\" + path\n\t\t\tfileInfos = append(fileInfos, &staticFileInfo{\n\t\t\t\tfullPath: fullPath,\n\t\t\t\tinfo:     info,\n\t\t\t})\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn fileInfos, nil\n}\n"
  },
  {
    "path": "tsunami/app/hooks.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage app\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/tsunami/engine\"\n\t\"github.com/wavetermdev/waveterm/tsunami/rpctypes\"\n\t\"github.com/wavetermdev/waveterm/tsunami/util\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\n// UseVDomRef provides a reference to a DOM element in the VDOM tree.\n// It returns a VDomRef that can be attached to elements for direct DOM access.\n// The ref will not be current on the first render - refs are set and become\n// current after client-side mounting.\n// This hook must be called within a component context.\nfunc UseVDomRef() *vdom.VDomRef {\n\trc := engine.GetGlobalRenderContext()\n\tval := engine.UseVDomRef(rc)\n\trefVal, ok := val.(*vdom.VDomRef)\n\tif !ok {\n\t\tpanic(\"UseVDomRef hook value is not a ref (possible out of order or conditional hooks)\")\n\t}\n\treturn refVal\n}\n\n// TermRef wraps a VDomRef and implements io.Writer by forwarding writes to the terminal.\ntype TermRef struct {\n\t*vdom.VDomRef\n}\n\n// Write implements io.Writer by sending data to the terminal via TermWrite.\nfunc (tr *TermRef) Write(p []byte) (n int, err error) {\n\tif tr.VDomRef == nil || !tr.VDomRef.HasCurrent.Load() {\n\t\treturn 0, fmt.Errorf(\"TermRef not current\")\n\t}\n\terr = TermWrite(tr.VDomRef, string(p))\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn len(p), nil\n}\n\n// TermSize returns the current terminal size, or nil if not yet set.\nfunc (tr *TermRef) TermSize() *vdom.VDomTermSize {\n\tif tr.VDomRef == nil {\n\t\treturn nil\n\t}\n\treturn tr.VDomRef.TermSize\n}\n\n// UseTermRef returns a TermRef that can be passed as a ref to \"wave:term\" elements\n// and also implements io.Writer for writing directly to the terminal.\nfunc UseTermRef() *TermRef {\n\tref := UseVDomRef()\n\treturn &TermRef{VDomRef: ref}\n}\n\n// UseRef is the tsunami analog to React's useRef hook.\n// It provides a mutable ref object that persists across re-renders.\n// Unlike UseVDomRef, this is not tied to DOM elements but holds arbitrary values.\n// This hook must be called within a component context.\nfunc UseRef[T any](val T) *vdom.VDomSimpleRef[T] {\n\trc := engine.GetGlobalRenderContext()\n\trefVal := engine.UseRef(rc, &vdom.VDomSimpleRef[T]{Current: val})\n\ttypedRef, ok := refVal.(*vdom.VDomSimpleRef[T])\n\tif !ok {\n\t\tpanic(\"UseRef hook value is not a ref (possible out of order or conditional hooks)\")\n\t}\n\treturn typedRef\n}\n\n// UseId returns the underlying component's unique identifier (UUID).\n// The ID persists across re-renders but is recreated when the component\n// is recreated, following React component lifecycle.\n// This hook must be called within a component context.\nfunc UseId() string {\n\trc := engine.GetGlobalRenderContext()\n\tif rc == nil {\n\t\tpanic(\"UseId must be called within a component (no context)\")\n\t}\n\treturn engine.UseId(rc)\n}\n\n// UseRenderTs returns the timestamp of the current render.\n// This hook must be called within a component context.\nfunc UseRenderTs() int64 {\n\trc := engine.GetGlobalRenderContext()\n\tif rc == nil {\n\t\tpanic(\"UseRenderTs must be called within a component (no context)\")\n\t}\n\treturn engine.UseRenderTs(rc)\n}\n\n// UseResync returns whether the current render is a resync operation.\n// Resyncs happen on initial app loads or full refreshes, as opposed to\n// incremental renders which happen otherwise.\n// This hook must be called within a component context.\nfunc UseResync() bool {\n\trc := engine.GetGlobalRenderContext()\n\tif rc == nil {\n\t\tpanic(\"UseResync must be called within a component (no context)\")\n\t}\n\treturn engine.UseResync(rc)\n}\n\n// UseEffect is the tsunami analog to React's useEffect hook.\n// It queues effects to run after the render cycle completes.\n// The function can return a cleanup function that runs before the next effect\n// or when the component unmounts. Dependencies use shallow comparison, just like React.\n// This hook must be called within a component context.\nfunc UseEffect(fn func() func(), deps []any) {\n\t// note UseEffect never actually runs anything, it just queues the effect to run later\n\trc := engine.GetGlobalRenderContext()\n\tif rc == nil {\n\t\tpanic(\"UseEffect must be called within a component (no context)\")\n\t}\n\tengine.UseEffect(rc, fn, deps)\n}\n\n// UseLocal creates a component-local atom that is automatically cleaned up when the component unmounts.\n// The atom is created with a unique name based on the component's wave ID and hook index.\n// This hook must be called within a component context.\nfunc UseLocal[T any](initialVal T) Atom[T] {\n\trc := engine.GetGlobalRenderContext()\n\tif rc == nil {\n\t\tpanic(\"UseLocal must be called within a component (no context)\")\n\t}\n\tatomName := engine.UseLocal(rc, initialVal)\n\treturn Atom[T]{\n\t\tname:   atomName,\n\t\tclient: engine.GetDefaultClient(),\n\t}\n}\n\n// UseGoRoutine manages a goroutine lifecycle within a component.\n// It spawns a new goroutine with the provided function when dependencies change,\n// and automatically cancels the context on dependency changes or component unmount.\n// This hook must be called within a component context.\nfunc UseGoRoutine(fn func(ctx context.Context), deps []any) {\n\trc := engine.GetGlobalRenderContext()\n\tif rc == nil {\n\t\tpanic(\"UseGoRoutine must be called within a component (no context)\")\n\t}\n\n\t// Use UseRef to store the cancel function\n\tcancelRef := UseRef[context.CancelFunc](nil)\n\n\tUseEffect(func() func() {\n\t\t// Cancel any existing goroutine\n\t\tif cancelRef.Current != nil {\n\t\t\tcancelRef.Current()\n\t\t}\n\n\t\t// Create new context and start goroutine\n\t\tctx, cancel := context.WithCancel(context.Background())\n\t\tcancelRef.Current = cancel\n\n\t\tcomponentName := \"unknown\"\n\t\tif rc.Comp != nil && rc.Comp.Elem != nil {\n\t\t\tcomponentName = rc.Comp.Elem.Tag\n\t\t}\n\n\t\tgo func() {\n\t\t\tdefer func() {\n\t\t\t\tutil.PanicHandler(fmt.Sprintf(\"UseGoRoutine in component '%s'\", componentName), recover())\n\t\t\t}()\n\t\t\tfn(ctx)\n\t\t}()\n\n\t\t// Return cleanup function that cancels the context\n\t\treturn func() {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t}\n\t}, deps)\n}\n\n// UseTicker manages a ticker lifecycle within a component.\n// It creates a ticker that calls the provided function at regular intervals.\n// The ticker is automatically stopped on dependency changes or component unmount.\n// This hook must be called within a component context.\nfunc UseTicker(interval time.Duration, tickFn func(), deps []any) {\n\tUseGoRoutine(func(ctx context.Context) {\n\t\tticker := time.NewTicker(interval)\n\t\tdefer ticker.Stop()\n\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\ttickFn()\n\t\t\t}\n\t\t}\n\t}, deps)\n}\n\n// UseAfter manages a timeout lifecycle within a component.\n// It creates a timer that calls the provided function after the specified duration.\n// The timer is automatically canceled on dependency changes or component unmount.\n// This hook must be called within a component context.\nfunc UseAfter(duration time.Duration, timeoutFn func(), deps []any) {\n\tUseGoRoutine(func(ctx context.Context) {\n\t\ttimer := time.NewTimer(duration)\n\t\tdefer timer.Stop()\n\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-timer.C:\n\t\t\ttimeoutFn()\n\t\t}\n\t}, deps)\n}\n\n// ModalConfig contains all configuration options for modals\ntype ModalConfig struct {\n\tIcon       string     `json:\"icon,omitempty\"`       // Optional icon to display (emoji or icon name)\n\tTitle      string     `json:\"title\"`                // Modal title\n\tText       string     `json:\"text,omitempty\"`       // Optional body text\n\tOkText     string     `json:\"oktext,omitempty\"`     // Optional OK button text (defaults to \"OK\")\n\tCancelText string     `json:\"canceltext,omitempty\"` // Optional Cancel button text for confirm modals (defaults to \"Cancel\")\n\tOnClose    func()     `json:\"-\"`                    // Optional callback for alert modals when dismissed\n\tOnResult   func(bool) `json:\"-\"`                    // Optional callback for confirm modals with the result (true = confirmed, false = cancelled)\n}\n\n// UseAlertModal returns a boolean indicating if the modal is open and a function to trigger it\nfunc UseAlertModal() (modalOpen bool, triggerAlert func(config ModalConfig)) {\n\tisOpen := UseLocal(false)\n\t\n\ttrigger := func(config ModalConfig) {\n\t\tif isOpen.Get() {\n\t\t\tlog.Printf(\"warning: UseAlertModal trigger called while modal is already open\")\n\t\t\tif config.OnClose != nil {\n\t\t\t\tgo func() {\n\t\t\t\t\tdefer func() {\n\t\t\t\t\t\tutil.PanicHandler(\"UseAlertModal callback goroutine\", recover())\n\t\t\t\t\t}()\n\t\t\t\t\ttime.Sleep(10 * time.Millisecond)\n\t\t\t\t\tconfig.OnClose()\n\t\t\t\t}()\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tisOpen.Set(true)\n\t\t\n\t\t// Create modal config for backend\n\t\tmodalId := uuid.New().String()\n\t\tbackendConfig := rpctypes.ModalConfig{\n\t\t\tModalId:    modalId,\n\t\t\tModalType:  \"alert\",\n\t\t\tIcon:       config.Icon,\n\t\t\tTitle:      config.Title,\n\t\t\tText:       config.Text,\n\t\t\tOkText:     config.OkText,\n\t\t\tCancelText: config.CancelText,\n\t\t}\n\t\t\n\t\t// Show modal and wait for result in a goroutine\n\t\tgo func() {\n\t\t\tdefer func() {\n\t\t\t\tutil.PanicHandler(\"UseAlertModal goroutine\", recover())\n\t\t\t}()\n\t\t\tresultChan := engine.GetDefaultClient().ShowModal(backendConfig)\n\t\t\t<-resultChan // Wait for result (always dismissed for alerts)\n\t\t\tisOpen.Set(false)\n\t\t\tif config.OnClose != nil {\n\t\t\t\tconfig.OnClose()\n\t\t\t}\n\t\t}()\n\t}\n\t\n\treturn isOpen.Get(), trigger\n}\n\n// UseConfirmModal returns a boolean indicating if the modal is open and a function to trigger it\nfunc UseConfirmModal() (modalOpen bool, triggerConfirm func(config ModalConfig)) {\n\tisOpen := UseLocal(false)\n\t\n\ttrigger := func(config ModalConfig) {\n\t\tif isOpen.Get() {\n\t\t\tlog.Printf(\"warning: UseConfirmModal trigger called while modal is already open\")\n\t\t\tif config.OnResult != nil {\n\t\t\t\tgo func() {\n\t\t\t\t\tdefer func() {\n\t\t\t\t\t\tutil.PanicHandler(\"UseConfirmModal callback goroutine\", recover())\n\t\t\t\t\t}()\n\t\t\t\t\ttime.Sleep(10 * time.Millisecond)\n\t\t\t\t\tconfig.OnResult(false)\n\t\t\t\t}()\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tisOpen.Set(true)\n\t\t\n\t\t// Create modal config for backend\n\t\tmodalId := uuid.New().String()\n\t\tbackendConfig := rpctypes.ModalConfig{\n\t\t\tModalId:    modalId,\n\t\t\tModalType:  \"confirm\",\n\t\t\tIcon:       config.Icon,\n\t\t\tTitle:      config.Title,\n\t\t\tText:       config.Text,\n\t\t\tOkText:     config.OkText,\n\t\t\tCancelText: config.CancelText,\n\t\t}\n\t\t\n\t\t// Show modal and wait for result in a goroutine\n\t\tgo func() {\n\t\t\tdefer func() {\n\t\t\t\tutil.PanicHandler(\"UseConfirmModal goroutine\", recover())\n\t\t\t}()\n\t\t\tresultChan := engine.GetDefaultClient().ShowModal(backendConfig)\n\t\t\tresult := <-resultChan\n\t\t\tisOpen.Set(false)\n\t\t\tif config.OnResult != nil {\n\t\t\t\tconfig.OnResult(result)\n\t\t\t}\n\t\t}()\n\t}\n\t\n\treturn isOpen.Get(), trigger\n}\n\n"
  },
  {
    "path": "tsunami/build/build-ast.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage build\n\nimport (\n\t\"fmt\"\n\t\"go/ast\"\n\t\"go/parser\"\n\t\"go/token\"\n\t\"io/fs\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\nconst AppInitFnName = \"AppInit\"\n\nfunc buildImportsMap(dir string) (map[string]bool, error) {\n\timports := make(map[string]bool)\n\n\tfiles, err := filepath.Glob(filepath.Join(dir, \"*.go\"))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list go files: %w\", err)\n\t}\n\n\tfset := token.NewFileSet()\n\tfor _, file := range files {\n\t\tnode, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)\n\t\tif err != nil {\n\t\t\tcontinue // Skip files that can't be parsed\n\t\t}\n\n\t\tfor _, imp := range node.Imports {\n\t\t\t// Remove quotes from import path\n\t\t\timportPath := strings.Trim(imp.Path.Value, `\"`)\n\t\t\timports[importPath] = true\n\t\t}\n\t}\n\n\treturn imports, nil\n}\n\ntype parsedAppInfo struct {\n\tHasAppInit bool\n}\n\nfunc parseAndValidateAppFile(appFS fs.FS) (*parsedAppInfo, error) {\n\tappGoFile, err := fs.ReadFile(appFS, MainAppFileName)\n\tif err != nil {\n\t\treturn &parsedAppInfo{HasAppInit: false}, nil\n\t}\n\n\tfset := token.NewFileSet()\n\tnode, err := parser.ParseFile(fset, MainAppFileName, appGoFile, 0)\n\tif err != nil {\n\t\treturn &parsedAppInfo{HasAppInit: false}, nil\n\t}\n\n\thasAppInit := false\n\tfor _, decl := range node.Decls {\n\t\tfuncDecl, ok := decl.(*ast.FuncDecl)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tif funcDecl.Name.Name == \"init\" {\n\t\t\thasNoParams := funcDecl.Type.Params == nil || len(funcDecl.Type.Params.List) == 0\n\t\t\thasNoResults := funcDecl.Type.Results == nil || len(funcDecl.Type.Results.List) == 0\n\t\t\tif hasNoParams && hasNoResults {\n\t\t\t\treturn nil, fmt.Errorf(\"tsunami apps may not define an init() function, use %s for initialization\", AppInitFnName)\n\t\t\t}\n\t\t}\n\n\t\tif funcDecl.Name.Name == AppInitFnName {\n\t\t\tif funcDecl.Type.Params != nil && len(funcDecl.Type.Params.List) > 0 {\n\t\t\t\treturn nil, fmt.Errorf(\"%s function must take no parameters, but has %d parameter(s)\", AppInitFnName, len(funcDecl.Type.Params.List))\n\t\t\t}\n\n\t\t\tif funcDecl.Type.Results == nil || len(funcDecl.Type.Results.List) != 1 {\n\t\t\t\treturn nil, fmt.Errorf(\"%s function must return exactly one value of type error\", AppInitFnName)\n\t\t\t}\n\n\t\t\treturnType := funcDecl.Type.Results.List[0]\n\t\t\tident, ok := returnType.Type.(*ast.Ident)\n\t\t\tif !ok || ident.Name != \"error\" {\n\t\t\t\treturn nil, fmt.Errorf(\"%s function must return error, not %v\", AppInitFnName, returnType.Type)\n\t\t\t}\n\n\t\t\thasAppInit = true\n\t\t}\n\t}\n\n\treturn &parsedAppInfo{HasAppInit: hasAppInit}, nil\n}\n"
  },
  {
    "path": "tsunami/build/build.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage build\n\nimport (\n\t\"archive/zip\"\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"log\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"os/signal\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/util\"\n\t\"golang.org/x/mod/modfile\"\n)\n\nconst MinSupportedGoMinorVersion = 22\nconst TsunamiUIImportPath = \"github.com/wavetermdev/waveterm/tsunami/ui\"\nconst MainAppFileName = \"app.go\"\n\ntype OutputCapture struct {\n\tlock       sync.Mutex\n\tlines      []string\n\tlineWriter *util.LineWriter\n}\n\nfunc MakeOutputCapture() *OutputCapture {\n\toc := &OutputCapture{\n\t\tlines: make([]string, 0),\n\t}\n\toc.lineWriter = util.NewLineWriter(func(line []byte) {\n\t\t// synchronized via the Write/Flush functions\n\t\toc.lines = append(oc.lines, string(line))\n\t})\n\treturn oc\n}\n\nfunc (oc *OutputCapture) Write(p []byte) (n int, err error) {\n\tif oc == nil {\n\t\treturn os.Stdout.Write(p)\n\t}\n\toc.lock.Lock()\n\tdefer oc.lock.Unlock()\n\treturn oc.lineWriter.Write(p)\n}\n\nfunc (oc *OutputCapture) Flush() {\n\tif oc == nil || oc.lineWriter == nil {\n\t\treturn\n\t}\n\toc.lock.Lock()\n\tdefer oc.lock.Unlock()\n\toc.lineWriter.Flush()\n}\n\nfunc (oc *OutputCapture) Printf(format string, args ...interface{}) {\n\tif oc == nil || oc.lineWriter == nil {\n\t\tlog.Printf(format, args...)\n\t\treturn\n\t}\n\tline := fmt.Sprintf(format, args...)\n\toc.lock.Lock()\n\tdefer oc.lock.Unlock()\n\toc.lines = append(oc.lines, line)\n}\n\nfunc (oc *OutputCapture) GetLines() []string {\n\tif oc == nil {\n\t\treturn nil\n\t}\n\toc.lock.Lock()\n\tdefer oc.lock.Unlock()\n\tresult := make([]string, len(oc.lines))\n\tcopy(result, oc.lines)\n\treturn result\n}\n\ntype BuildOpts struct {\n\tAppPath        string\n\tAppNS          string\n\tVerbose        bool\n\tOpen           bool\n\tKeepTemp       bool\n\tOutputFile     string\n\tScaffoldPath   string\n\tSdkReplacePath string\n\tSdkVersion     string\n\tNodePath       string\n\tGoPath         string\n\tMoveFileBack   bool\n\tOutputCapture  *OutputCapture\n}\n\nfunc GetAppName(appPath string) string {\n\tbaseName := filepath.Base(appPath)\n\treturn strings.TrimSuffix(baseName, \".tsapp\")\n}\n\ntype BuildEnv struct {\n\tGoVersion   string\n\tGoPath      string\n\tTempDir     string\n\tcleanupOnce *sync.Once\n}\n\nfunc (opts BuildOpts) getNodePath() string {\n\tif opts.NodePath != \"\" {\n\t\treturn opts.NodePath\n\t}\n\treturn \"node\"\n}\n\ntype GoVersionCheckResult struct {\n\tGoStatus    string\n\tGoPath      string\n\tGoVersion   string\n\tErrorString string\n}\n\nfunc FindGoExecutable() (string, error) {\n\t// First try the standard PATH lookup\n\tif goPath, err := exec.LookPath(\"go\"); err == nil {\n\t\treturn goPath, nil\n\t}\n\n\t// Define platform-specific paths to check\n\tvar pathsToCheck []string\n\n\tif runtime.GOOS == \"windows\" {\n\t\tpathsToCheck = []string{\n\t\t\t`c:\\go\\bin\\go.exe`,\n\t\t\t`c:\\program files\\go\\bin\\go.exe`,\n\t\t}\n\t} else {\n\t\t// Unix-like systems (macOS, Linux, etc.)\n\t\tpathsToCheck = []string{\n\t\t\t\"/opt/homebrew/bin/go\", // Homebrew on Apple Silicon\n\t\t\t\"/usr/local/bin/go\",    // Traditional Homebrew or manual install\n\t\t\t\"/usr/local/go/bin/go\", // Official Go installation\n\t\t\t\"/usr/bin/go\",          // System package manager\n\t\t}\n\t}\n\n\t// Check each path\n\tfor _, path := range pathsToCheck {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\t// File exists, check if it's executable\n\t\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\t\treturn path, nil\n\t\t\t}\n\t\t}\n\t}\n\n\treturn \"\", fmt.Errorf(\"go command not found in PATH or common installation locations\")\n}\n\nfunc CheckGoVersion(customGoPath string) GoVersionCheckResult {\n\tvar goPath string\n\tvar err error\n\n\tif customGoPath != \"\" {\n\t\tgoPath = customGoPath\n\t} else {\n\t\tgoPath, err = FindGoExecutable()\n\t\tif err != nil {\n\t\t\treturn GoVersionCheckResult{\n\t\t\t\tGoStatus:    \"notfound\",\n\t\t\t\tGoPath:      \"\",\n\t\t\t\tGoVersion:   \"\",\n\t\t\t\tErrorString: \"\",\n\t\t\t}\n\t\t}\n\t}\n\n\tcmd := exec.Command(goPath, \"version\")\n\toutput, err := cmd.Output()\n\tif err != nil {\n\t\treturn GoVersionCheckResult{\n\t\t\tGoStatus:    \"error\",\n\t\t\tGoPath:      goPath,\n\t\t\tGoVersion:   \"\",\n\t\t\tErrorString: fmt.Sprintf(\"failed to run 'go version': %v\", err),\n\t\t}\n\t}\n\n\tversionStr := strings.TrimSpace(string(output))\n\n\tversionRegex := regexp.MustCompile(`go(1\\.\\d+)`)\n\tmatches := versionRegex.FindStringSubmatch(versionStr)\n\tif len(matches) < 2 {\n\t\treturn GoVersionCheckResult{\n\t\t\tGoStatus:    \"error\",\n\t\t\tGoPath:      goPath,\n\t\t\tGoVersion:   versionStr,\n\t\t\tErrorString: fmt.Sprintf(\"unable to parse go version from: %s\", versionStr),\n\t\t}\n\t}\n\n\tgoVersion := matches[1]\n\n\tminorRegex := regexp.MustCompile(`1\\.(\\d+)`)\n\tminorMatches := minorRegex.FindStringSubmatch(goVersion)\n\tif len(minorMatches) < 2 {\n\t\treturn GoVersionCheckResult{\n\t\t\tGoStatus:    \"error\",\n\t\t\tGoPath:      goPath,\n\t\t\tGoVersion:   versionStr,\n\t\t\tErrorString: fmt.Sprintf(\"unable to parse minor version from: %s\", goVersion),\n\t\t}\n\t}\n\n\tminor, err := strconv.Atoi(minorMatches[1])\n\tif err != nil {\n\t\treturn GoVersionCheckResult{\n\t\t\tGoStatus:    \"error\",\n\t\t\tGoPath:      goPath,\n\t\t\tGoVersion:   versionStr,\n\t\t\tErrorString: fmt.Sprintf(\"failed to parse minor version: %v\", err),\n\t\t}\n\t}\n\n\tif minor < MinSupportedGoMinorVersion {\n\t\treturn GoVersionCheckResult{\n\t\t\tGoStatus:    \"badversion\",\n\t\t\tGoPath:      goPath,\n\t\t\tGoVersion:   versionStr,\n\t\t\tErrorString: \"\",\n\t\t}\n\t}\n\n\treturn GoVersionCheckResult{\n\t\tGoStatus:    \"ok\",\n\t\tGoPath:      goPath,\n\t\tGoVersion:   versionStr,\n\t\tErrorString: \"\",\n\t}\n}\n\nfunc verifyEnvironment(verbose bool, opts BuildOpts) (*BuildEnv, error) {\n\toc := opts.OutputCapture\n\n\tif opts.SdkVersion == \"\" && opts.SdkReplacePath == \"\" {\n\t\treturn nil, fmt.Errorf(\"either SdkVersion or SdkReplacePath must be set\")\n\t}\n\n\tif opts.SdkVersion != \"\" {\n\t\tversionRegex := regexp.MustCompile(`^v\\d+\\.\\d+\\.\\d+`)\n\t\tif !versionRegex.MatchString(opts.SdkVersion) {\n\t\t\treturn nil, fmt.Errorf(\"SdkVersion must be in semantic version format (e.g., v0.0.0), got: %s\", opts.SdkVersion)\n\t\t}\n\t}\n\n\tresult := CheckGoVersion(opts.GoPath)\n\n\tswitch result.GoStatus {\n\tcase \"notfound\":\n\t\treturn nil, fmt.Errorf(\"go command not found\")\n\tcase \"badversion\":\n\t\treturn nil, fmt.Errorf(\"go version 1.%d or higher required, found: %s\", MinSupportedGoMinorVersion, result.GoVersion)\n\tcase \"error\":\n\t\treturn nil, fmt.Errorf(\"%s\", result.ErrorString)\n\tcase \"ok\":\n\t\tif verbose {\n\t\t\tif opts.GoPath != \"\" {\n\t\t\t\toc.Printf(\"[debug] Using custom go path: %s\", result.GoPath)\n\t\t\t} else {\n\t\t\t\toc.Printf(\"[debug] Using go path: %s\", result.GoPath)\n\t\t\t}\n\t\t\toc.Printf(\"[debug] Found %s\", result.GoVersion)\n\t\t}\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unexpected go status: %s\", result.GoStatus)\n\t}\n\n\tversionRegex := regexp.MustCompile(`go(1\\.\\d+)`)\n\tmatches := versionRegex.FindStringSubmatch(result.GoVersion)\n\tif len(matches) < 2 {\n\t\treturn nil, fmt.Errorf(\"unable to parse go version from: %s\", result.GoVersion)\n\t}\n\tgoVersion := matches[1]\n\n\tvar err error\n\n\t// Check if node is available\n\tif opts.NodePath != \"\" {\n\t\t// Custom node path specified - verify it's absolute and executable\n\t\tif !filepath.IsAbs(opts.NodePath) {\n\t\t\treturn nil, fmt.Errorf(\"NodePath must be an absolute path, got: %s\", opts.NodePath)\n\t\t}\n\n\t\tinfo, err := os.Stat(opts.NodePath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"NodePath does not exist: %s: %w\", opts.NodePath, err)\n\t\t}\n\n\t\tif info.IsDir() {\n\t\t\treturn nil, fmt.Errorf(\"NodePath is a directory, not an executable: %s\", opts.NodePath)\n\t\t}\n\n\t\t// Check if file is executable (Unix-like systems)\n\t\tif runtime.GOOS != \"windows\" && info.Mode()&0111 == 0 {\n\t\t\treturn nil, fmt.Errorf(\"NodePath is not executable: %s\", opts.NodePath)\n\t\t}\n\n\t\tif verbose {\n\t\t\toc.Printf(\"[debug] Using custom node path: %s\", opts.NodePath)\n\t\t}\n\t} else {\n\t\t// Use standard PATH lookup\n\t\t_, err = exec.LookPath(\"node\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"node command not found in PATH: %w\", err)\n\t\t}\n\n\t\tif verbose {\n\t\t\toc.Printf(\"[debug] Found node in PATH\")\n\t\t}\n\t}\n\n\treturn &BuildEnv{\n\t\tGoVersion:   goVersion,\n\t\tGoPath:      result.GoPath,\n\t\tcleanupOnce: &sync.Once{},\n\t}, nil\n}\n\nfunc createGoMod(tempDir, appNS, appName string, buildEnv *BuildEnv, opts BuildOpts, verbose bool) error {\n\toc := opts.OutputCapture\n\tif appNS == \"\" {\n\t\tappNS = \"app\"\n\t}\n\tmodulePath := fmt.Sprintf(\"tsunami/%s/%s\", appNS, appName)\n\n\t// Check if go.mod already exists in temp directory (copied from app path)\n\ttempGoModPath := filepath.Join(tempDir, \"go.mod\")\n\tvar modFile *modfile.File\n\tvar err error\n\n\tif _, err := os.Stat(tempGoModPath); err == nil {\n\t\t// go.mod exists in temp dir, parse it\n\t\tif verbose {\n\t\t\toc.Printf(\"[debug] Found existing go.mod in temp directory, parsing it\")\n\t\t}\n\n\t\t// Parse the existing go.mod\n\t\tgoModContent, err := os.ReadFile(tempGoModPath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to read go.mod: %w\", err)\n\t\t}\n\n\t\tmodFile, err = modfile.Parse(\"go.mod\", goModContent, nil)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to parse existing go.mod: %w\", err)\n\t\t}\n\t} else if os.IsNotExist(err) {\n\t\t// go.mod doesn't exist, create new one\n\t\tif verbose {\n\t\t\toc.Printf(\"[debug] No existing go.mod found, creating new one\")\n\t\t}\n\n\t\tmodFile = &modfile.File{}\n\t\tif err := modFile.AddModuleStmt(modulePath); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to add module statement: %w\", err)\n\t\t}\n\n\t\tif err := modFile.AddGoStmt(buildEnv.GoVersion); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to add go version: %w\", err)\n\t\t}\n\n\t\t// Add requirement for tsunami SDK\n\t\tif err := modFile.AddRequire(\"github.com/wavetermdev/waveterm/tsunami\", opts.SdkVersion); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to add require directive: %w\", err)\n\t\t}\n\t} else {\n\t\treturn fmt.Errorf(\"error checking for go.mod in temp directory: %w\", err)\n\t}\n\n\t// Add replace directive for tsunami SDK if path is provided\n\tif opts.SdkReplacePath != \"\" {\n\t\tif err := modFile.AddReplace(\"github.com/wavetermdev/waveterm/tsunami\", \"\", opts.SdkReplacePath, \"\"); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to add replace directive: %w\", err)\n\t\t}\n\t}\n\n\t// Format and write the file\n\tmodFile.Cleanup()\n\tgoModContent, err := modFile.Format()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to format go.mod: %w\", err)\n\t}\n\n\tgoModPath := filepath.Join(tempDir, \"go.mod\")\n\tif err := os.WriteFile(goModPath, goModContent, 0644); err != nil {\n\t\treturn fmt.Errorf(\"failed to write go.mod file: %w\", err)\n\t}\n\n\tif verbose {\n\t\toc.Printf(\"[debug] Created go.mod with module path: %s\", modulePath)\n\t\toc.Printf(\"[debug] Added require: github.com/wavetermdev/waveterm/tsunami %s\", opts.SdkVersion)\n\t\tif opts.SdkReplacePath != \"\" {\n\t\t\toc.Printf(\"[debug] Added replace directive: github.com/wavetermdev/waveterm/tsunami => %s\", opts.SdkReplacePath)\n\t\t}\n\t}\n\n\t// Run go mod tidy to clean up dependencies\n\ttidyCmd := exec.Command(buildEnv.GoPath, \"mod\", \"tidy\")\n\ttidyCmd.Dir = tempDir\n\n\tif verbose {\n\t\toc.Printf(\"[debug] Running go mod tidy\")\n\t}\n\n\tif oc != nil {\n\t\ttidyCmd.Stdout = oc\n\t\ttidyCmd.Stderr = oc\n\t} else {\n\t\ttidyCmd.Stdout = os.Stdout\n\t\ttidyCmd.Stderr = os.Stderr\n\t}\n\n\tif err := tidyCmd.Run(); err != nil {\n\t\toc.Flush()\n\t\treturn fmt.Errorf(\"go mod tidy failed (see output for errors)\")\n\t}\n\n\tif oc != nil {\n\t\toc.Flush()\n\t}\n\n\tif verbose {\n\t\toc.Printf(\"[debug] Successfully ran go mod tidy\")\n\t}\n\n\treturn nil\n}\n\nfunc verifyAppPathFs(fsys fs.FS) error {\n\tif err := checkFileExistsFS(fsys, MainAppFileName); err != nil {\n\t\treturn fmt.Errorf(\"%s check failed: %w\", MainAppFileName, err)\n\t}\n\n\t// Check static directory if it exists\n\tif err := isDirOrNotFoundFS(fsys, \"static\"); err != nil {\n\t\treturn fmt.Errorf(\"static directory check failed: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc GetAppModTime(appPath string) (time.Time, error) {\n\tif strings.HasSuffix(appPath, \".tsapp\") {\n\t\tinfo, err := os.Stat(appPath)\n\t\tif err != nil {\n\t\t\treturn time.Time{}, fmt.Errorf(\"failed to get tsapp mod time: %w\", err)\n\t\t}\n\t\treturn info.ModTime(), nil\n\t}\n\n\tappGoPath := filepath.Join(appPath, MainAppFileName)\n\tinfo, err := os.Stat(appGoPath)\n\tif err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"failed to get %s mod time: %w\", MainAppFileName, err)\n\t}\n\treturn info.ModTime(), nil\n}\n\nfunc verifyScaffoldFs(fsys fs.FS) error {\n\t// Check for dist directory\n\tif err := isDirOrNotFoundFS(fsys, \"dist\"); err != nil {\n\t\treturn fmt.Errorf(\"dist directory check failed: %w\", err)\n\t}\n\tinfo, err := fs.Stat(fsys, \"dist\")\n\tif err != nil || !info.IsDir() {\n\t\treturn fmt.Errorf(\"dist directory must exist in scaffold\")\n\t}\n\n\t// Check for app-main.go.tmpl file\n\tif err := checkFileExistsFS(fsys, \"app-main.go.tmpl\"); err != nil {\n\t\treturn fmt.Errorf(\"app-main.go check failed: %w\", err)\n\t}\n\t// Check for app-init.go.tmpl file\n\tif err := checkFileExistsFS(fsys, \"app-init.go.tmpl\"); err != nil {\n\t\treturn fmt.Errorf(\"app-init.go check failed: %w\", err)\n\t}\n\n\t// Check for tailwind.css file\n\tif err := checkFileExistsFS(fsys, \"tailwind.css\"); err != nil {\n\t\treturn fmt.Errorf(\"tailwind.css check failed: %w\", err)\n\t}\n\n\t// Check for package.json file\n\tif err := checkFileExistsFS(fsys, \"package.json\"); err != nil {\n\t\treturn fmt.Errorf(\"package.json check failed: %w\", err)\n\t}\n\n\t// Check for nm directory\n\tif err := isDirOrNotFoundFS(fsys, \"nm\"); err != nil {\n\t\treturn fmt.Errorf(\"nm (node_modules) directory check failed: %w\", err)\n\t}\n\tinfo, err = fs.Stat(fsys, \"nm\")\n\tif err != nil || !info.IsDir() {\n\t\treturn fmt.Errorf(\"nm (node_modules) directory must exist in scaffold\")\n\t}\n\n\treturn nil\n}\n\nfunc (be *BuildEnv) cleanupTempDir(keepTemp bool, verbose bool) {\n\tif be == nil || be.cleanupOnce == nil {\n\t\treturn\n\t}\n\n\tbe.cleanupOnce.Do(func() {\n\t\tif keepTemp || be.TempDir == \"\" {\n\t\t\tlog.Printf(\"NOT cleaning tempdir\\n\")\n\t\t\treturn\n\t\t}\n\t\tif err := os.RemoveAll(be.TempDir); err != nil {\n\t\t\tlog.Printf(\"Failed to remove temp directory %s: %v\", be.TempDir, err)\n\t\t} else if verbose {\n\t\t\tlog.Printf(\"Removed temp directory: %s\", be.TempDir)\n\t\t}\n\t})\n}\n\nfunc setupSignalCleanup(buildEnv *BuildEnv, keepTemp, verbose bool) {\n\tif keepTemp {\n\t\treturn\n\t}\n\tsigChan := make(chan os.Signal, 1)\n\tsignal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)\n\tgo func() {\n\t\tdefer signal.Stop(sigChan)\n\t\tsig := <-sigChan\n\t\tif verbose {\n\t\t\tlog.Printf(\"Received signal %v, cleaning up temp directory\", sig)\n\t\t}\n\t\tbuildEnv.cleanupTempDir(keepTemp, verbose)\n\t\tos.Exit(1)\n\t}()\n}\n\nfunc TsunamiBuild(opts BuildOpts) error {\n\tbuildEnv, err := TsunamiBuildInternal(opts)\n\tdefer buildEnv.cleanupTempDir(opts.KeepTemp, opts.Verbose)\n\tif err != nil {\n\t\treturn err\n\t}\n\tsetupSignalCleanup(buildEnv, opts.KeepTemp, opts.Verbose)\n\treturn nil\n}\n\nfunc TsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) {\n\toc := opts.OutputCapture\n\n\tbuildEnv, err := verifyEnvironment(opts.Verbose, opts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tappFS, canWrite, appCloser, err := pathToFS(opts.AppPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"bad app path: %w\", err)\n\t}\n\tif appCloser != nil {\n\t\tdefer appCloser()\n\t}\n\n\tif err := verifyAppPathFs(appFS); err != nil {\n\t\treturn nil, fmt.Errorf(\"bad app path: %w\", err)\n\t}\n\n\tscaffoldFS, _, scaffoldCloser, err := pathToFS(opts.ScaffoldPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"bad scaffold path: %w\", err)\n\t}\n\tif scaffoldCloser != nil {\n\t\tdefer scaffoldCloser()\n\t}\n\n\tif err := verifyScaffoldFs(scaffoldFS); err != nil {\n\t\treturn nil, err\n\t}\n\n\tappInfo, err := parseAndValidateAppFile(appFS)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Create temporary directory\n\ttempDir, err := os.MkdirTemp(\"\", \"tsunami-build-*\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create temp directory: %w\", err)\n\t}\n\n\tbuildEnv.TempDir = tempDir\n\n\toc.Printf(\"Building tsunami app from %s\", opts.AppPath)\n\toc.Printf(\"[debug] using scaffold path %s\", opts.ScaffoldPath)\n\n\tif opts.Verbose || opts.KeepTemp {\n\t\toc.Printf(\"[debug] Temp dir: %s\", tempDir)\n\t}\n\n\t// Copy files from app path (go.mod, go.sum, static/, *.go)\n\tcopyStats, err := copyFilesFromAppFS(appFS, opts.AppPath, tempDir, opts.Verbose, oc)\n\tif err != nil {\n\t\treturn buildEnv, fmt.Errorf(\"failed to copy files from app path: %w\", err)\n\t}\n\n\t// Copy scaffold directory contents selectively\n\tscaffoldCount, err := copyScaffoldFS(scaffoldFS, tempDir, appInfo.HasAppInit, opts.Verbose, oc)\n\tif err != nil {\n\t\treturn buildEnv, fmt.Errorf(\"failed to copy scaffold directory: %w\", err)\n\t}\n\n\tif opts.Verbose {\n\t\toc.Printf(\"[debug] Copied %d go files, %d static files, %d scaffold files (go.mod: %t, go.sum: %t)\",\n\t\t\tcopyStats.GoFiles, copyStats.StaticFiles, scaffoldCount, copyStats.GoMod, copyStats.GoSum)\n\t}\n\n\t// Create go.mod file\n\tappName := GetAppName(opts.AppPath)\n\tif err := createGoMod(tempDir, opts.AppNS, appName, buildEnv, opts, opts.Verbose); err != nil {\n\t\treturn buildEnv, err\n\t}\n\n\t// Generate Tailwind CSS\n\tif err := generateAppTailwindCss(tempDir, opts.Verbose, opts); err != nil {\n\t\treturn buildEnv, err\n\t}\n\n\t// Build the Go application\n\toutputPath, err := runGoBuild(tempDir, buildEnv, opts)\n\tif err != nil {\n\t\treturn buildEnv, err\n\t}\n\n\t// Generate manifest\n\tif err := generateManifest(tempDir, outputPath, opts); err != nil {\n\t\treturn buildEnv, err\n\t}\n\n\t// Move generated files back to original directory\n\tif opts.MoveFileBack && canWrite {\n\t\tif err := moveFilesBack(tempDir, opts.AppPath, opts.Verbose, oc); err != nil {\n\t\t\treturn buildEnv, fmt.Errorf(\"failed to move files back: %w\", err)\n\t\t}\n\t} else if opts.MoveFileBack && !canWrite {\n\t\tif opts.Verbose {\n\t\t\toc.Printf(\"Skipping move files back - app path is not writable: %s\", opts.AppPath)\n\t\t}\n\t}\n\n\treturn buildEnv, nil\n}\n\nfunc moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture) error {\n\t// Move go.mod back to original directory\n\tgoModSrc := filepath.Join(tempDir, \"go.mod\")\n\tgoModDest := filepath.Join(originalDir, \"go.mod\")\n\tif err := copyFile(goModSrc, goModDest); err != nil {\n\t\treturn fmt.Errorf(\"failed to copy go.mod back: %w\", err)\n\t}\n\tif verbose {\n\t\toc.Printf(\"[debug] Moved go.mod back to %s\", goModDest)\n\t}\n\n\t// Move go.sum back to original directory (only if it exists)\n\tgoSumSrc := filepath.Join(tempDir, \"go.sum\")\n\tif _, err := os.Stat(goSumSrc); err == nil {\n\t\tgoSumDest := filepath.Join(originalDir, \"go.sum\")\n\t\tif err := copyFile(goSumSrc, goSumDest); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to copy go.sum back: %w\", err)\n\t\t}\n\t\tif verbose {\n\t\t\toc.Printf(\"[debug] Moved go.sum back to %s\", goSumDest)\n\t\t}\n\t}\n\n\t// Ensure static directory exists in original directory\n\tstaticDir := filepath.Join(originalDir, \"static\")\n\tif err := os.MkdirAll(staticDir, 0755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create static directory: %w\", err)\n\t}\n\tif verbose {\n\t\toc.Printf(\"[debug] Ensured static directory exists at %s\", staticDir)\n\t}\n\n\t// Move tw.css back to original directory\n\ttwCssSrc := filepath.Join(tempDir, \"static\", \"tw.css\")\n\ttwCssDest := filepath.Join(originalDir, \"static\", \"tw.css\")\n\tif err := copyFile(twCssSrc, twCssDest); err != nil {\n\t\treturn fmt.Errorf(\"failed to copy tw.css back: %w\", err)\n\t}\n\tif verbose {\n\t\toc.Printf(\"[debug] Moved tw.css back to %s\", twCssDest)\n\t}\n\n\t// Move manifest.json back to original directory (only if it exists)\n\tmanifestSrc := filepath.Join(tempDir, \"manifest.json\")\n\tif _, err := os.Stat(manifestSrc); err == nil {\n\t\tmanifestDest := filepath.Join(originalDir, \"manifest.json\")\n\t\tif err := copyFile(manifestSrc, manifestDest); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to copy manifest.json back: %w\", err)\n\t\t}\n\t\tif verbose {\n\t\t\toc.Printf(\"[debug] Moved manifest.json back to %s\", manifestDest)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc runGoBuild(tempDir string, buildEnv *BuildEnv, opts BuildOpts) (string, error) {\n\toc := opts.OutputCapture\n\tvar outputPath string\n\tvar absOutputPath string\n\tif opts.OutputFile != \"\" {\n\t\t// Convert to absolute path resolved against current working directory\n\t\tvar err error\n\t\tabsOutputPath, err = filepath.Abs(opts.OutputFile)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to resolve output path: %w\", err)\n\t\t}\n\t\toutputPath = absOutputPath\n\t} else {\n\t\tbinDir := filepath.Join(tempDir, \"bin\")\n\t\tif err := os.MkdirAll(binDir, 0755); err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to create bin directory: %w\", err)\n\t\t}\n\t\toutputPath = \"bin/app\"\n\t\tabsOutputPath = filepath.Join(tempDir, \"bin\", \"app\")\n\t}\n\n\tgoFiles, err := listGoFilesInDir(tempDir)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to list go files: %w\", err)\n\t}\n\n\tif len(goFiles) == 0 {\n\t\treturn \"\", fmt.Errorf(\"no .go files found in %s\", tempDir)\n\t}\n\n\t// Build command with explicit go files\n\targs := append([]string{\"build\", \"-o\", outputPath}, \".\")\n\tbuildCmd := exec.Command(buildEnv.GoPath, args...)\n\tbuildCmd.Dir = tempDir\n\n\tif oc != nil || opts.Verbose {\n\t\toc.Printf(\"[debug] Running: %s\", strings.Join(buildCmd.Args, \" \"))\n\t\toc.Printf(\"Building application...\")\n\t}\n\tif oc != nil {\n\t\tbuildCmd.Stdout = oc\n\t\tbuildCmd.Stderr = oc\n\t} else {\n\t\tbuildCmd.Stdout = os.Stdout\n\t\tbuildCmd.Stderr = os.Stderr\n\t}\n\n\tif err := buildCmd.Run(); err != nil {\n\t\treturn \"\", fmt.Errorf(\"compilation failed (see output for errors)\")\n\t}\n\tif oc != nil {\n\t\toc.Flush()\n\t}\n\n\tif opts.Verbose {\n\t\toc.Printf(\"Application built successfully\")\n\t\toc.Printf(\"[debug] Output path: %s\", absOutputPath)\n\t}\n\n\treturn absOutputPath, nil\n}\n\nfunc generateManifest(tempDir, exePath string, opts BuildOpts) error {\n\toc := opts.OutputCapture\n\n\tmanifestCmd := exec.Command(exePath, \"--manifest\")\n\tmanifestCmd.Dir = tempDir\n\n\tif opts.Verbose {\n\t\toc.Printf(\"[debug] Running: %s --manifest\", exePath)\n\t\toc.Printf(\"Generating manifest...\")\n\t}\n\n\tmanifestOutput, err := manifestCmd.Output()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"manifest generation failed: %w\", err)\n\t}\n\n\t// Extract manifest between delimiters\n\tmanifestStr := string(manifestOutput)\n\tstartTag := \"<AppManifest>\"\n\tendTag := \"</AppManifest>\"\n\tstartIdx := strings.Index(manifestStr, startTag)\n\tendIdx := strings.Index(manifestStr, endTag)\n\n\tif startIdx == -1 || endIdx == -1 || endIdx <= startIdx {\n\t\treturn fmt.Errorf(\"manifest delimiters not found in output\")\n\t}\n\n\tmanifestJSON := manifestStr[startIdx+len(startTag) : endIdx]\n\tmanifestJSON = strings.TrimSpace(manifestJSON)\n\n\tmanifestPath := filepath.Join(tempDir, \"manifest.json\")\n\tif err := os.WriteFile(manifestPath, []byte(manifestJSON), 0644); err != nil {\n\t\treturn fmt.Errorf(\"failed to write manifest.json: %w\", err)\n\t}\n\n\tif opts.Verbose {\n\t\toc.Printf(\"Manifest generated successfully\")\n\t\toc.Printf(\"[debug] Manifest path: %s\", manifestPath)\n\t}\n\n\treturn nil\n}\n\nfunc generateAppTailwindCss(tempDir string, verbose bool, opts BuildOpts) error {\n\toc := opts.OutputCapture\n\t// tailwind.css is already in tempDir from scaffold copy\n\ttailwindOutput := filepath.Join(tempDir, \"static\", \"tw.css\")\n\ttailwindCmd := exec.Command(opts.getNodePath(), \"--preserve-symlinks-main\", \"--preserve-symlinks\",\n\t\t\"node_modules/@tailwindcss/cli/dist/index.mjs\",\n\t\t\"-i\", \"./tailwind.css\",\n\t\t\"-o\", tailwindOutput)\n\ttailwindCmd.Dir = tempDir\n\ttailwindCmd.Env = append(os.Environ(), \"ELECTRON_RUN_AS_NODE=1\")\n\n\tif verbose {\n\t\toc.Printf(\"[debug] Running: %s\", strings.Join(tailwindCmd.Args, \" \"))\n\t}\n\n\toutput, err := tailwindCmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"tailwind CSS generation failed (see output for errors)\")\n\t}\n\n\t// Process and filter tailwind output\n\tlines := strings.Split(string(output), \"\\n\")\n\tfor _, line := range lines {\n\t\t// Skip empty lines\n\t\tif strings.TrimSpace(line) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t// Skip version line (contains ≈ and tailwindcss)\n\t\tif strings.Contains(line, \"≈\") && strings.Contains(line, \"tailwindcss\") {\n\t\t\tcontinue\n\t\t}\n\t\t// Skip \"Done in\" timing line\n\t\tif strings.HasPrefix(strings.TrimSpace(line), \"Done in\") {\n\t\t\tcontinue\n\t\t}\n\t\t// Write remaining lines to output\n\t\toc.Printf(\"%s\", line)\n\t}\n\tif verbose {\n\t\toc.Printf(\"Tailwind CSS generated successfully\")\n\t}\n\treturn nil\n}\n\ntype CopyStats struct {\n\tGoFiles     int\n\tStaticFiles int\n\tGoMod       bool\n\tGoSum       bool\n}\n\nfunc copyGoFilesFromFS(fsys fs.FS, destDir string) (int, error) {\n\tentries, err := fs.ReadDir(fsys, \".\")\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tfileCount := 0\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\tif strings.HasSuffix(entry.Name(), \".go\") {\n\t\t\tdestPath := filepath.Join(destDir, entry.Name())\n\n\t\t\tif err := CopyFileFromFS(fsys, entry.Name(), destPath); err != nil {\n\t\t\t\treturn 0, fmt.Errorf(\"failed to copy %s: %w\", entry.Name(), err)\n\t\t\t}\n\t\t\tfileCount++\n\t\t}\n\t}\n\n\treturn fileCount, nil\n}\n\n// appPath is just used for logging (we do the copies from appFS)\nfunc copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool, oc *OutputCapture) (*CopyStats, error) {\n\tstats := &CopyStats{}\n\n\t// Copy go.mod if it exists\n\tgoModDest := filepath.Join(tempDir, \"go.mod\")\n\tcopied, err := CopyFileIfExists(appFS, \"go.mod\", goModDest)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tstats.GoMod = copied\n\tif copied && verbose {\n\t\toc.Printf(\"Copied go.mod from %s\", filepath.Join(appPath, \"go.mod\"))\n\t}\n\n\t// Copy go.sum if it exists\n\tgoSumDest := filepath.Join(tempDir, \"go.sum\")\n\tcopied, err = CopyFileIfExists(appFS, \"go.sum\", goSumDest)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tstats.GoSum = copied\n\tif copied && verbose {\n\t\toc.Printf(\"Copied go.sum from %s\", filepath.Join(appPath, \"go.sum\"))\n\t}\n\n\t// Copy manifest.json if it exists\n\tmanifestDest := filepath.Join(tempDir, \"manifest.json\")\n\tcopied, err = CopyFileIfExists(appFS, \"manifest.json\", manifestDest)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif copied && verbose {\n\t\toc.Printf(\"Copied manifest.json from %s\", filepath.Join(appPath, \"manifest.json\"))\n\t}\n\n\t// Copy static directory\n\tstaticDestDir := filepath.Join(tempDir, \"static\")\n\tstaticCount, err := copyDirFromFS(appFS, \"static\", staticDestDir, true)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to copy static directory: %w\", err)\n\t}\n\tstats.StaticFiles = staticCount\n\n\t// Copy all *.go files from the root directory\n\tgoCount, err := copyGoFilesFromFS(appFS, tempDir)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to copy go files: %w\", err)\n\t}\n\tstats.GoFiles = goCount\n\n\treturn stats, nil\n}\n\nfunc TsunamiRun(opts BuildOpts) error {\n\toc := opts.OutputCapture\n\tbuildEnv, err := TsunamiBuildInternal(opts)\n\tdefer buildEnv.cleanupTempDir(opts.KeepTemp, opts.Verbose)\n\tif err != nil {\n\t\treturn err\n\t}\n\tsetupSignalCleanup(buildEnv, opts.KeepTemp, opts.Verbose)\n\n\t// Run the built application\n\tappBinPath := filepath.Join(buildEnv.TempDir, \"bin\", \"app\")\n\trunCmd := exec.Command(appBinPath)\n\trunCmd.Dir = buildEnv.TempDir\n\n\toc.Printf(\"Running tsunami app from %s\", opts.AppPath)\n\n\trunCmd.Stdin = os.Stdin\n\n\tif opts.Open {\n\t\t// If --open flag is set, we need to capture stderr to parse the listening message\n\t\tstderr, err := runCmd.StderrPipe()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create stderr pipe: %w\", err)\n\t\t}\n\t\trunCmd.Stdout = os.Stdout\n\n\t\tif err := runCmd.Start(); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to start application: %w\", err)\n\t\t}\n\n\t\t// Monitor stderr for the listening message\n\t\tgo monitorAndOpenBrowser(stderr, opts.Verbose)\n\n\t\tif err := runCmd.Wait(); err != nil {\n\t\t\treturn fmt.Errorf(\"application exited with error: %w\", err)\n\t\t}\n\t} else {\n\t\t// Normal execution without browser opening\n\t\tif opts.Verbose {\n\t\t\tlog.Printf(\"Executing: %s\", appBinPath)\n\t\t\trunCmd.Stdout = os.Stdout\n\t\t\trunCmd.Stderr = os.Stderr\n\t\t}\n\n\t\tif err := runCmd.Start(); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to start application: %w\", err)\n\t\t}\n\n\t\tif err := runCmd.Wait(); err != nil {\n\t\t\treturn fmt.Errorf(\"application exited with error: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc monitorAndOpenBrowser(r io.ReadCloser, verbose bool) {\n\tdefer r.Close()\n\n\tscanner := bufio.NewScanner(r)\n\tbrowserOpened := false\n\tif verbose {\n\t\tlog.Printf(\"monitoring for browser open\\n\")\n\t}\n\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tfmt.Println(line)\n\n\t\tif !browserOpened {\n\t\t\tport := ParseTsunamiPort(line)\n\t\t\tif port > 0 {\n\t\t\t\turl := fmt.Sprintf(\"http://localhost:%d\", port)\n\t\t\t\tif verbose {\n\t\t\t\t\tlog.Printf(\"Opening browser to %s\", url)\n\t\t\t\t}\n\t\t\t\tgo util.OpenBrowser(url, 100*time.Millisecond)\n\t\t\t\tbrowserOpened = true\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc ParseTsunamiPort(line string) int {\n\turlRegex := regexp.MustCompile(`\\[tsunami\\] listening at (http://[^\\s]+)`)\n\tmatches := urlRegex.FindStringSubmatch(line)\n\tif len(matches) < 2 {\n\t\treturn 0\n\t}\n\n\tu, err := url.Parse(matches[1])\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\tportStr := u.Port()\n\tif portStr == \"\" {\n\t\treturn 0\n\t}\n\n\tport, err := strconv.Atoi(portStr)\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\treturn port\n}\n\nfunc copyScaffoldFS(scaffoldFS fs.FS, destDir string, hasAppInit bool, verbose bool, oc *OutputCapture) (int, error) {\n\tfileCount := 0\n\n\t// Handle nm (node_modules) directory - prefer symlink if possible, otherwise copy\n\tif _, err := fs.Stat(scaffoldFS, \"nm\"); err == nil {\n\t\tdestPath := filepath.Join(destDir, \"node_modules\")\n\n\t\t// Try to create symlink if we have DirFS\n\t\tsymlinked := false\n\t\tif dirFS, ok := scaffoldFS.(DirFS); ok {\n\t\t\tsrcPath := dirFS.JoinOS(\"nm\")\n\t\t\tif err := os.Symlink(srcPath, destPath); err == nil {\n\t\t\t\tif verbose {\n\t\t\t\t\toc.Printf(\"[debug] Symlinked nm to node_modules directory\")\n\t\t\t\t}\n\t\t\t\tfileCount++\n\t\t\t\tsymlinked = true\n\t\t\t}\n\t\t}\n\n\t\t// Fallback to recursive copy if symlink failed or not attempted\n\t\tif !symlinked {\n\t\t\tdirCount, err := copyDirFromFS(scaffoldFS, \"nm\", destPath, false)\n\t\t\tif err != nil {\n\t\t\t\treturn 0, fmt.Errorf(\"failed to copy nm (node_modules) directory: %w\", err)\n\t\t\t}\n\t\t\tif verbose {\n\t\t\t\toc.Printf(\"Copied nm to node_modules directory (%d files)\", dirCount)\n\t\t\t}\n\t\t\tfileCount += dirCount\n\t\t}\n\t} else if !os.IsNotExist(err) {\n\t\treturn 0, fmt.Errorf(\"error checking nm (node_modules): %w\", err)\n\t}\n\n\t// Copy package files instead of symlinking\n\tpackageFiles := []string{\"package.json\", \"package-lock.json\"}\n\tfor _, fileName := range packageFiles {\n\t\tdestPath := filepath.Join(destDir, fileName)\n\n\t\t// Check if source exists in FS\n\t\tif _, err := fs.Stat(scaffoldFS, fileName); err != nil {\n\t\t\tif os.IsNotExist(err) {\n\t\t\t\tcontinue // Skip if doesn't exist\n\t\t\t}\n\t\t\treturn 0, fmt.Errorf(\"error checking %s: %w\", fileName, err)\n\t\t}\n\n\t\t// Copy file from FS\n\t\tif err := CopyFileFromFS(scaffoldFS, fileName, destPath); err != nil {\n\t\t\treturn 0, fmt.Errorf(\"failed to copy %s: %w\", fileName, err)\n\t\t}\n\t\tfileCount++\n\t}\n\n\t// Copy dist directory using FS\n\tdistDestPath := filepath.Join(destDir, \"dist\")\n\tdirCount, err := copyDirFromFS(scaffoldFS, \"dist\", distDestPath, false)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to copy dist directory: %w\", err)\n\t}\n\tfileCount += dirCount\n\n\t// Always copy app-main.go.tmpl => app-main.go\n\tdestPath := filepath.Join(destDir, \"app-main.go\")\n\tif err := CopyFileFromFS(scaffoldFS, \"app-main.go.tmpl\", destPath); err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to copy app-main.go.tmpl: %w\", err)\n\t}\n\tfileCount++\n\n\t// Conditionally copy app-init.go.tmpl => app-init.go\n\tif hasAppInit {\n\t\tdestPath := filepath.Join(destDir, \"app-init.go\")\n\t\tif err := CopyFileFromFS(scaffoldFS, \"app-init.go.tmpl\", destPath); err != nil {\n\t\t\treturn 0, fmt.Errorf(\"failed to copy app-init.go.tmpl: %w\", err)\n\t\t}\n\t\tfileCount++\n\t}\n\n\t// Copy files by pattern (*.md, *.json, tailwind.css)\n\tpatterns := []string{\"*.md\", \"*.json\", \"tailwind.css\"}\n\n\tfor _, pattern := range patterns {\n\t\tmatches, err := fs.Glob(scaffoldFS, pattern)\n\t\tif err != nil {\n\t\t\treturn 0, fmt.Errorf(\"failed to glob pattern %s: %w\", pattern, err)\n\t\t}\n\n\t\tfor _, match := range matches {\n\t\t\tif slices.Contains(packageFiles, match) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdestPath := filepath.Join(destDir, match)\n\t\t\tif err := CopyFileFromFS(scaffoldFS, match, destPath); err != nil {\n\t\t\t\treturn 0, fmt.Errorf(\"failed to copy %s: %w\", match, err)\n\t\t\t}\n\t\t\tfileCount++\n\t\t}\n\t}\n\n\treturn fileCount, nil\n}\n\nfunc MakeAppPackage(appFS fs.FS, appPath string, verbose bool, outputFile string) error {\n\tif verbose {\n\t\tlog.Printf(\"Creating app package from %s to %s\", appPath, outputFile)\n\t}\n\n\t// Create output directory if it doesn't exist\n\toutputDir := filepath.Dir(outputFile)\n\tif err := os.MkdirAll(outputDir, 0755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create output directory: %w\", err)\n\t}\n\n\t// Create zip file\n\tzipFile, err := os.Create(outputFile)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create zip file: %w\", err)\n\t}\n\tdefer zipFile.Close()\n\n\tzipWriter := zip.NewWriter(zipFile)\n\tdefer zipWriter.Close()\n\n\tfileCount := 0\n\n\t// Add go.mod if it exists\n\tif err := addFileToZipIfExists(zipWriter, appFS, \"go.mod\", &fileCount, verbose); err != nil {\n\t\treturn fmt.Errorf(\"failed to add go.mod: %w\", err)\n\t}\n\n\t// Add go.sum if it exists\n\tif err := addFileToZipIfExists(zipWriter, appFS, \"go.sum\", &fileCount, verbose); err != nil {\n\t\treturn fmt.Errorf(\"failed to add go.sum: %w\", err)\n\t}\n\n\t// Add manifest.json if it exists\n\tif err := addFileToZipIfExists(zipWriter, appFS, \"manifest.json\", &fileCount, verbose); err != nil {\n\t\treturn fmt.Errorf(\"failed to add manifest.json: %w\", err)\n\t}\n\n\t// Add all *.go files\n\tif err := addGoFilesToZip(zipWriter, appFS, &fileCount, verbose); err != nil {\n\t\treturn fmt.Errorf(\"failed to add go files: %w\", err)\n\t}\n\n\t// Add static directory if it exists\n\tif err := addDirToZipIfExists(zipWriter, appFS, \"static\", &fileCount, verbose); err != nil {\n\t\treturn fmt.Errorf(\"failed to add static directory: %w\", err)\n\t}\n\n\tif verbose {\n\t\tlog.Printf(\"Package created successfully with %d files\", fileCount)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "tsunami/build/buildutil.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage build\n\nimport (\n\t\"archive/zip\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\ntype DirFS struct {\n\tRoot string\n\tfs.FS\n}\n\nfunc NewDirFS(root string) DirFS {\n\treturn DirFS{Root: root, FS: os.DirFS(root)}\n}\n\nfunc (d DirFS) JoinOS(name string) string {\n\treturn filepath.Join(d.Root, filepath.FromSlash(name))\n}\n\nfunc (d DirFS) Stat(name string) (fs.FileInfo, error)      { return fs.Stat(d.FS, name) }\nfunc (d DirFS) ReadFile(name string) ([]byte, error)       { return fs.ReadFile(d.FS, name) }\nfunc (d DirFS) ReadDir(name string) ([]fs.DirEntry, error) { return fs.ReadDir(d.FS, name) }\nfunc (d DirFS) Glob(p string) ([]string, error)            { return fs.Glob(d.FS, p) }\n\nfunc pathToFS(path string) (fs.FS, bool, func() error, error) {\n\tif path == \"\" {\n\t\treturn nil, false, nil, fmt.Errorf(\"directory path cannot be empty\")\n\t}\n\n\t// Check if path exists\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, false, nil, fmt.Errorf(\"path %q does not exist\", path)\n\t\t}\n\t\treturn nil, false, nil, fmt.Errorf(\"error accessing path %q: %w\", path, err)\n\t}\n\n\t// Check if it's a .tsapp file (zip archive)\n\tif strings.HasSuffix(path, \".tsapp\") {\n\t\tif info.IsDir() {\n\t\t\treturn nil, false, nil, fmt.Errorf(\"%q is a directory, but .tsapp files must be zip archives\", path)\n\t\t}\n\n\t\t// Open as zip file\n\t\tzipReader, err := zip.OpenReader(path)\n\t\tif err != nil {\n\t\t\treturn nil, false, nil, fmt.Errorf(\"failed to open .tsapp file %q as zip archive: %w\", path, err)\n\t\t}\n\n\t\t// Return zip filesystem (not writable) with closer function\n\t\treturn zipReader, false, zipReader.Close, nil\n\t}\n\n\t// Handle regular directories\n\tif !info.IsDir() {\n\t\treturn nil, false, nil, fmt.Errorf(\"%q is not a directory\", path)\n\t}\n\n\t// Check if directory is writable by checking permissions\n\tcanWrite := info.Mode().Perm()&0200 != 0 // Check if owner has write permission\n\n\treturn NewDirFS(path), canWrite, nil, nil\n}\n\nfunc IsDirOrNotFound(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil // Not found is OK\n\t\t}\n\t\treturn err // Other errors are not OK\n\t}\n\n\tif !info.IsDir() {\n\t\treturn fmt.Errorf(\"%q exists but is not a directory\", path)\n\t}\n\n\treturn nil // It's a directory, which is OK\n}\n\nfunc CheckFileExists(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn fmt.Errorf(\"file %q not found\", path)\n\t\t}\n\t\treturn fmt.Errorf(\"error accessing file %q: %w\", path, err)\n\t}\n\n\tif info.IsDir() {\n\t\treturn fmt.Errorf(\"%q is a directory, not a file\", path)\n\t}\n\n\treturn nil\n}\n\nfunc FileMustNotExist(path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\treturn fmt.Errorf(\"%q must not exist\", path)\n\t} else if !os.IsNotExist(err) {\n\t\treturn err // Other errors are not OK\n\t}\n\treturn nil // Not found is OK\n}\n\nfunc copyFile(srcPath, destPath string) error {\n\treturn CopyFileFromFS(os.DirFS(\"/\"), strings.TrimPrefix(srcPath, \"/\"), destPath)\n}\n\nfunc listGoFilesInDir(dirPath string) ([]string, error) {\n\tentries, err := os.ReadDir(dirPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read directory %s: %w\", dirPath, err)\n\t}\n\n\tvar goFiles []string\n\tfor _, entry := range entries {\n\t\tif !entry.IsDir() && filepath.Ext(entry.Name()) == \".go\" {\n\t\t\tgoFiles = append(goFiles, entry.Name())\n\t\t}\n\t}\n\n\treturn goFiles, nil\n}\n\nfunc CopyFileIfExists(fsys fs.FS, srcPath, destPath string) (bool, error) {\n\tif fileInfo, err := fs.Stat(fsys, srcPath); err == nil {\n\t\tif fileInfo.IsDir() {\n\t\t\treturn false, fmt.Errorf(\"source path %s is a directory\", srcPath)\n\t\t}\n\t\tif err := CopyFileFromFS(fsys, srcPath, destPath); err != nil {\n\t\t\treturn false, fmt.Errorf(\"failed to copy %s: %w\", srcPath, err)\n\t\t}\n\t\treturn true, nil\n\t} else if os.IsNotExist(err) {\n\t\treturn false, nil\n\t} else {\n\t\treturn false, fmt.Errorf(\"error checking %s: %w\", srcPath, err)\n\t}\n}\n\nfunc CopyFileFromFS(fsys fs.FS, srcPath, destPath string) error {\n\t// Open source file from filesystem\n\tsrcFile, err := fsys.Open(srcPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer srcFile.Close()\n\n\t// Get source file info\n\tsrcInfo, err := fs.Stat(fsys, srcPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Create destination directory if it doesn't exist\n\tdestDir := filepath.Dir(destPath)\n\tif err := os.MkdirAll(destDir, 0755); err != nil {\n\t\treturn err\n\t}\n\n\t// Create destination file\n\tdestFile, err := os.Create(destPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer destFile.Close()\n\n\t// Copy content\n\t_, err = io.Copy(destFile, srcFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Set the same mode as source file\n\treturn os.Chmod(destPath, srcInfo.Mode())\n}\n\nfunc checkFileExistsFS(fsys fs.FS, path string) error {\n\tinfo, err := fs.Stat(fsys, path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn fmt.Errorf(\"file %q not found\", path)\n\t\t}\n\t\treturn fmt.Errorf(\"error accessing file %q: %w\", path, err)\n\t}\n\n\tif info.IsDir() {\n\t\treturn fmt.Errorf(\"%q is a directory, not a file\", path)\n\t}\n\n\treturn nil\n}\n\nfunc isDirOrNotFoundFS(fsys fs.FS, path string) error {\n\tinfo, err := fs.Stat(fsys, path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil // Not found is OK\n\t\t}\n\t\treturn err // Other errors are not OK\n\t}\n\n\tif !info.IsDir() {\n\t\treturn fmt.Errorf(\"%q exists but is not a directory\", path)\n\t}\n\n\treturn nil // It's a directory, which is OK\n}\n\nfunc copyDirFromFS(fsys fs.FS, srcDir, destDir string, forceCreateDestDir bool) (int, error) {\n\tfileCount := 0\n\n\t// Check if source directory exists\n\tsrcInfo, err := fs.Stat(fsys, srcDir)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\tif forceCreateDestDir {\n\t\t\t\t// Create destination directory even if source doesn't exist\n\t\t\t\tif err := os.MkdirAll(destDir, 0755); err != nil {\n\t\t\t\t\treturn 0, fmt.Errorf(\"failed to create destination directory %s: %w\", destDir, err)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn 0, nil // Source doesn't exist, return 0 files copied\n\t\t}\n\t\treturn 0, fmt.Errorf(\"error accessing source directory %s: %w\", srcDir, err)\n\t}\n\n\t// Check if source is actually a directory\n\tif !srcInfo.IsDir() {\n\t\treturn 0, fmt.Errorf(\"source %s is not a directory\", srcDir)\n\t}\n\n\terr = fs.WalkDir(fsys, srcDir, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Calculate destination path\n\t\trelPath, err := filepath.Rel(srcDir, path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdestPath := filepath.Join(destDir, relPath)\n\n\t\tif d.IsDir() {\n\t\t\t// Create directory with standard permissions (0755) regardless of source permissions\n\t\t\t// This is important when extracting from zip files which may have read-only dirs\n\t\t\tif err := os.MkdirAll(destPath, 0755); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t} else {\n\t\t\t// Copy file\n\t\t\tif err := CopyFileFromFS(fsys, path, destPath); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tfileCount++\n\t\t}\n\n\t\treturn nil\n\t})\n\n\treturn fileCount, err\n}\n\nfunc addFileToZipIfExists(zipWriter *zip.Writer, fsys fs.FS, fileName string, fileCount *int, verbose bool) error {\n\tif _, err := fs.Stat(fsys, fileName); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"error checking %s: %w\", fileName, err)\n\t}\n\n\tif err := addFileToZip(zipWriter, fsys, fileName, fileName); err != nil {\n\t\treturn err\n\t}\n\n\t*fileCount++\n\tif verbose {\n\t\tlog.Printf(\"Added %s to package\", fileName)\n\t}\n\n\treturn nil\n}\n\nfunc addGoFilesToZip(zipWriter *zip.Writer, fsys fs.FS, fileCount *int, verbose bool) error {\n\tentries, err := fs.ReadDir(fsys, \".\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read directory: %w\", err)\n\t}\n\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\tif strings.HasSuffix(entry.Name(), \".go\") {\n\t\t\tif err := addFileToZip(zipWriter, fsys, entry.Name(), entry.Name()); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to add %s: %w\", entry.Name(), err)\n\t\t\t}\n\n\t\t\t*fileCount++\n\t\t\tif verbose {\n\t\t\t\tlog.Printf(\"Added %s to package\", entry.Name())\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc addDirToZipIfExists(zipWriter *zip.Writer, fsys fs.FS, dirName string, fileCount *int, verbose bool) error {\n\tinfo, err := fs.Stat(fsys, dirName)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"error checking %s: %w\", dirName, err)\n\t}\n\n\tif !info.IsDir() {\n\t\treturn fmt.Errorf(\"%s exists but is not a directory\", dirName)\n\t}\n\n\treturn fs.WalkDir(fsys, dirName, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !d.IsDir() {\n\t\t\tif err := addFileToZip(zipWriter, fsys, path, path); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to add file %s: %w\", path, err)\n\t\t\t}\n\n\t\t\t*fileCount++\n\t\t\tif verbose {\n\t\t\t\tlog.Printf(\"Added %s to package\", path)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc addFileToZip(zipWriter *zip.Writer, fsys fs.FS, srcPath, destPath string) error {\n\tsrcFile, err := fsys.Open(srcPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to open source file %s: %w\", srcPath, err)\n\t}\n\tdefer srcFile.Close()\n\n\tinfo, err := fs.Stat(fsys, srcPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get file info for %s: %w\", srcPath, err)\n\t}\n\n\theader, err := zip.FileInfoHeader(info)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create zip header for %s: %w\", srcPath, err)\n\t}\n\n\theader.Name = destPath\n\n\tdestFile, err := zipWriter.CreateHeader(header)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create zip entry for %s: %w\", destPath, err)\n\t}\n\n\t_, err = io.Copy(destFile, srcFile)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to copy content for %s: %w\", srcPath, err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "tsunami/cmd/main-tsunami.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wavetermdev/waveterm/tsunami/build\"\n\t\"github.com/wavetermdev/waveterm/tsunami/tsunamibase\"\n)\n\nconst (\n\tEnvTsunamiScaffoldPath   = \"TSUNAMI_SCAFFOLDPATH\"\n\tEnvTsunamiSdkReplacePath = \"TSUNAMI_SDKREPLACEPATH\"\n\tEnvTsunamiNodePath       = \"TSUNAMI_NODEPATH\"\n\tTsunamiSdkVersion        = \"v0.12.4\"\n)\n\n// these are set at build time\nvar TsunamiVersion = \"0.0.0\"\nvar BuildTime = \"0\"\n\nvar rootCmd = &cobra.Command{\n\tUse:   \"tsunami\",\n\tShort: \"Tsunami - A VDOM-based UI framework\",\n\tLong:  `Tsunami is a VDOM-based UI framework for building modern applications.`,\n}\n\nvar versionCmd = &cobra.Command{\n\tUse:   \"version\",\n\tShort: \"Print Tsunami version\",\n\tLong:  `Print Tsunami version`,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tfmt.Println(\"v\" + tsunamibase.TsunamiVersion)\n\t},\n}\n\nfunc validateEnvironmentVars(opts *build.BuildOpts) error {\n\tscaffoldPath := os.Getenv(EnvTsunamiScaffoldPath)\n\tif scaffoldPath == \"\" {\n\t\treturn fmt.Errorf(\"%s environment variable must be set\", EnvTsunamiScaffoldPath)\n\t}\n\tabsScaffoldPath, err := filepath.Abs(scaffoldPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to resolve %s to absolute path: %w\", EnvTsunamiScaffoldPath, err)\n\t}\n\n\tsdkReplacePath := os.Getenv(EnvTsunamiSdkReplacePath)\n\tif sdkReplacePath == \"\" {\n\t\treturn fmt.Errorf(\"%s environment variable must be set\", EnvTsunamiSdkReplacePath)\n\t}\n\tabsSdkReplacePath, err := filepath.Abs(sdkReplacePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to resolve %s to absolute path: %w\", EnvTsunamiSdkReplacePath, err)\n\t}\n\n\topts.ScaffoldPath = absScaffoldPath\n\topts.SdkReplacePath = absSdkReplacePath\n\n\t// NodePath is optional\n\tif nodePath := os.Getenv(EnvTsunamiNodePath); nodePath != \"\" {\n\t\topts.NodePath = nodePath\n\t}\n\n\treturn nil\n}\n\nvar buildCmd = &cobra.Command{\n\tUse:          \"build [apppath]\",\n\tShort:        \"Build a Tsunami application\",\n\tLong:         `Build a Tsunami application.`,\n\tArgs:         cobra.ExactArgs(1),\n\tSilenceUsage: true,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tverbose, _ := cmd.Flags().GetBool(\"verbose\")\n\t\tkeepTemp, _ := cmd.Flags().GetBool(\"keeptemp\")\n\t\toutput, _ := cmd.Flags().GetString(\"output\")\n\t\topts := build.BuildOpts{\n\t\t\tAppPath:      args[0],\n\t\t\tVerbose:      verbose,\n\t\t\tKeepTemp:     keepTemp,\n\t\t\tOutputFile:   output,\n\t\t\tMoveFileBack: true,\n\t\t\tSdkVersion:   TsunamiSdkVersion,\n\t\t}\n\t\tif err := validateEnvironmentVars(&opts); err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := build.TsunamiBuild(opts); err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\t},\n}\n\nvar runCmd = &cobra.Command{\n\tUse:          \"run [apppath]\",\n\tShort:        \"Build and run a Tsunami application\",\n\tLong:         `Build and run a Tsunami application.`,\n\tArgs:         cobra.ExactArgs(1),\n\tSilenceUsage: true,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tverbose, _ := cmd.Flags().GetBool(\"verbose\")\n\t\topen, _ := cmd.Flags().GetBool(\"open\")\n\t\tkeepTemp, _ := cmd.Flags().GetBool(\"keeptemp\")\n\t\topts := build.BuildOpts{\n\t\t\tAppPath:      args[0],\n\t\t\tVerbose:      verbose,\n\t\t\tOpen:         open,\n\t\t\tKeepTemp:     keepTemp,\n\t\t\tMoveFileBack: true,\n\t\t\tSdkVersion:   TsunamiSdkVersion,\n\t\t}\n\t\tif err := validateEnvironmentVars(&opts); err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := build.TsunamiRun(opts); err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\t},\n}\n\nvar packageCmd = &cobra.Command{\n\tUse:          \"package [apppath]\",\n\tShort:        \"Package a Tsunami application into a .tsapp file\",\n\tLong:         `Package a Tsunami application into a .tsapp file.`,\n\tArgs:         cobra.ExactArgs(1),\n\tSilenceUsage: true,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tverbose, _ := cmd.Flags().GetBool(\"verbose\")\n\t\toutput, _ := cmd.Flags().GetString(\"output\")\n\t\tappPath := args[0]\n\n\t\tif output == \"\" {\n\t\t\tappName := build.GetAppName(appPath)\n\t\t\toutput = filepath.Join(appPath, appName+\".tsapp\")\n\t\t}\n\n\t\tappFS := build.NewDirFS(appPath)\n\t\tif err := build.MakeAppPackage(appFS, appPath, verbose, output); err != nil {\n\t\t\tfmt.Printf(\"Error: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tif verbose {\n\t\t\tfmt.Printf(\"Successfully created package: %s\\n\", output)\n\t\t}\n\t},\n}\n\nfunc init() {\n\trootCmd.AddCommand(versionCmd)\n\n\tbuildCmd.Flags().BoolP(\"verbose\", \"v\", false, \"Enable verbose output\")\n\tbuildCmd.Flags().Bool(\"keeptemp\", false, \"Keep temporary build directory\")\n\tbuildCmd.Flags().StringP(\"output\", \"o\", \"\", \"Output file path for the built application\")\n\trootCmd.AddCommand(buildCmd)\n\n\trunCmd.Flags().BoolP(\"verbose\", \"v\", false, \"Enable verbose output\")\n\trunCmd.Flags().Bool(\"open\", false, \"Open the application in the browser after starting\")\n\trunCmd.Flags().Bool(\"keeptemp\", false, \"Keep temporary build directory\")\n\trootCmd.AddCommand(runCmd)\n\n\tpackageCmd.Flags().BoolP(\"verbose\", \"v\", false, \"Enable verbose output\")\n\tpackageCmd.Flags().StringP(\"output\", \"o\", \"\", \"Output file path for the package (default: [appname].tsapp in apppath)\")\n\trootCmd.AddCommand(packageCmd)\n}\n\nfunc main() {\n\ttsunamibase.TsunamiVersion = TsunamiVersion\n\tif err := rootCmd.Execute(); err != nil {\n\t\tfmt.Println(err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "tsunami/demo/.gitignore",
    "content": "test/"
  },
  {
    "path": "tsunami/demo/cpuchart/app.go",
    "content": "package main\n\nimport (\n\t\"log\"\n\t\"time\"\n\n\t\"github.com/shirou/gopsutil/v4/cpu\"\n\t\"github.com/wavetermdev/waveterm/tsunami/app\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\nvar AppMeta = app.AppMeta{\n\tTitle:     \"CPU Usage Monitor\",\n\tShortDesc: \"Real-time CPU usage monitoring and charting\",\n}\n\n// Global atoms for config and data\nvar (\n\tdataPointCountAtom = app.ConfigAtom(\"dataPointCount\", 60, &app.AtomMeta{\n\t\tDesc: \"Number of CPU data points to display in the chart\",\n\t\tMin:  app.Ptr(10.0),\n\t\tMax:  app.Ptr(300.0),\n\t})\n\tcpuDataAtom = app.DataAtom(\"cpuData\", func() []CPUDataPoint {\n\t\t// Initialize with empty data points to maintain consistent chart size\n\t\tdataPointCount := 60 // Default value for initialization\n\t\tinitialData := make([]CPUDataPoint, dataPointCount)\n\t\tfor i := range initialData {\n\t\t\tinitialData[i] = CPUDataPoint{\n\t\t\t\tTime:      0,\n\t\t\t\tCPUUsage:  nil, // Use nil to represent empty slots\n\t\t\t\tTimestamp: \"\",\n\t\t\t}\n\t\t}\n\t\treturn initialData\n\t}(), &app.AtomMeta{\n\t\tDesc: \"Historical CPU usage data points for charting\",\n\t})\n\tcurrentCpuUsageAtom = app.DataAtom(\"currentCpuUsage\", 0.0, &app.AtomMeta{\n\t\tDesc:  \"Current CPU usage percentage\",\n\t\tUnits: \"%\",\n\t\tMin:   app.Ptr(0.0),\n\t\tMax:   app.Ptr(100.0),\n\t})\n)\n\ntype CPUDataPoint struct {\n\tTime      int64    `json:\"time\" desc:\"Unix timestamp (seconds since epoch)\" units:\"s\"`\n\tCPUUsage  *float64 `json:\"cpuUsage\" desc:\"CPU usage percentage\" units:\"%\" min:\"0\" max:\"100\"`\n\tTimestamp string   `json:\"timestamp\" desc:\"Human-readable HH:MM:SS\"`\n}\n\ntype StatsPanelProps struct {\n\tData []CPUDataPoint `json:\"data\"`\n}\n\nfunc collectCPUUsage() (float64, error) {\n\tpercentages, err := cpu.Percent(time.Second, false)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tif len(percentages) == 0 {\n\t\treturn 0, nil\n\t}\n\treturn percentages[0], nil\n}\n\nfunc generateCPUDataPoint() CPUDataPoint {\n\tnow := time.Now()\n\tcpuUsage, err := collectCPUUsage()\n\tif err != nil {\n\t\tlog.Printf(\"Error collecting CPU usage: %v\", err)\n\t\tcpuUsage = 0\n\t}\n\tdataPoint := CPUDataPoint{\n\t\tTime:      now.Unix(),\n\t\tCPUUsage:  &cpuUsage, // Convert to pointer\n\t\tTimestamp: now.Format(\"15:04:05\"),\n\t}\n\treturn dataPoint\n}\n\nvar StatsPanel = app.DefineComponent(\"StatsPanel\", func(props StatsPanelProps) any {\n\tvar currentUsage float64\n\tvar avgUsage float64\n\tvar maxUsage float64\n\tvar validCount int\n\n\tif len(props.Data) > 0 {\n\t\tlastPoint := props.Data[len(props.Data)-1]\n\t\tif lastPoint.CPUUsage != nil {\n\t\t\tcurrentUsage = *lastPoint.CPUUsage\n\t\t}\n\n\t\t// Calculate average and max from non-nil values\n\t\ttotal := 0.0\n\t\tfor _, point := range props.Data {\n\t\t\tif point.CPUUsage != nil {\n\t\t\t\ttotal += *point.CPUUsage\n\t\t\t\tvalidCount++\n\t\t\t\tif *point.CPUUsage > maxUsage {\n\t\t\t\t\tmaxUsage = *point.CPUUsage\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif validCount > 0 {\n\t\t\tavgUsage = total / float64(validCount)\n\t\t}\n\t}\n\n\treturn vdom.H(\"div\", map[string]any{\n\t\t\"className\": \"bg-gray-800 rounded-lg p-4 mb-6\",\n\t},\n\t\tvdom.H(\"h3\", map[string]any{\n\t\t\t\"className\": \"text-lg font-semibold text-white mb-3\",\n\t\t}, \"CPU Statistics\"),\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"grid grid-cols-3 gap-4\",\n\t\t},\n\t\t\t// Current Usage\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"bg-gray-700 rounded p-3\",\n\t\t\t},\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"text-sm text-gray-400 mb-1\",\n\t\t\t\t}, \"Current\"),\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"text-2xl font-bold text-blue-400\",\n\t\t\t\t}, vdom.H(\"span\", nil, int(currentUsage+0.5), \"%\")),\n\t\t\t),\n\t\t\t// Average Usage\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"bg-gray-700 rounded p-3\",\n\t\t\t},\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"text-sm text-gray-400 mb-1\",\n\t\t\t\t}, \"Average\"),\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"text-2xl font-bold text-green-400\",\n\t\t\t\t}, vdom.H(\"span\", nil, int(avgUsage+0.5), \"%\")),\n\t\t\t),\n\t\t\t// Max Usage\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"bg-gray-700 rounded p-3\",\n\t\t\t},\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"text-sm text-gray-400 mb-1\",\n\t\t\t\t}, \"Peak\"),\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"text-2xl font-bold text-red-400\",\n\t\t\t\t}, vdom.H(\"span\", nil, int(maxUsage+0.5), \"%\")),\n\t\t\t),\n\t\t),\n\t)\n},\n)\n\nvar App = app.DefineComponent(\"App\", func(_ struct{}) any {\n\n\t// Use UseTicker for continuous CPU data collection - automatically cleaned up on unmount\n\tapp.UseTicker(time.Second, func() {\n\t\t// Collect new CPU data point and shift the data window\n\t\tnewPoint := generateCPUDataPoint()\n\n\t\t// Update current CPU usage atom for easy AI access\n\t\tif newPoint.CPUUsage != nil {\n\t\t\tcurrentCpuUsageAtom.Set(*newPoint.CPUUsage)\n\t\t}\n\n\t\tcpuDataAtom.SetFn(func(data []CPUDataPoint) []CPUDataPoint {\n\t\t\tcurrentDataPointCount := dataPointCountAtom.Get()\n\n\t\t\t// Ensure we have the right size array\n\t\t\tif len(data) != currentDataPointCount {\n\t\t\t\t// Resize array if config changed\n\t\t\t\tresized := make([]CPUDataPoint, currentDataPointCount)\n\t\t\t\tcopyCount := currentDataPointCount\n\t\t\t\tif len(data) < copyCount {\n\t\t\t\t\tcopyCount = len(data)\n\t\t\t\t}\n\t\t\t\tif copyCount > 0 {\n\t\t\t\t\tcopy(resized[currentDataPointCount-copyCount:], data[len(data)-copyCount:])\n\t\t\t\t}\n\t\t\t\tdata = resized\n\t\t\t}\n\n\t\t\t// Append new point and keep only the last currentDataPointCount elements\n\t\t\tdata = append(data, newPoint)\n\t\t\tif len(data) > currentDataPointCount {\n\t\t\t\tdata = data[len(data)-currentDataPointCount:]\n\t\t\t}\n\t\t\treturn data\n\t\t})\n\t}, []any{})\n\n\thandleClear := func() {\n\t\t// Reset with empty data points based on current config\n\t\tcurrentDataPointCount := dataPointCountAtom.Get()\n\t\tinitialData := make([]CPUDataPoint, currentDataPointCount)\n\t\tfor i := range initialData {\n\t\t\tinitialData[i] = CPUDataPoint{\n\t\t\t\tTime:      0,\n\t\t\t\tCPUUsage:  nil,\n\t\t\t\tTimestamp: \"\",\n\t\t\t}\n\t\t}\n\t\tcpuDataAtom.Set(initialData)\n\t\tcurrentCpuUsageAtom.Set(0.0)\n\t}\n\n\t// Read atom values once for rendering\n\tcpuData := cpuDataAtom.Get()\n\tdataPointCount := dataPointCountAtom.Get()\n\n\treturn vdom.H(\"div\", map[string]any{\n\t\t\"className\": \"min-h-screen bg-gray-900 text-white p-6\",\n\t},\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"max-w-6xl mx-auto\",\n\t\t},\n\t\t\t// Header\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"mb-8\",\n\t\t\t},\n\t\t\t\tvdom.H(\"h1\", map[string]any{\n\t\t\t\t\t\"className\": \"text-3xl font-bold text-white mb-2\",\n\t\t\t\t}, \"Real-Time CPU Usage Monitor\"),\n\t\t\t\tvdom.H(\"p\", map[string]any{\n\t\t\t\t\t\"className\": \"text-gray-400\",\n\t\t\t\t}, \"Live CPU usage data collected using gopsutil, displaying \", dataPointCount, \" seconds of history\"),\n\t\t\t),\n\n\t\t\t// Controls\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"bg-gray-800 rounded-lg p-4 mb-6\",\n\t\t\t},\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"flex items-center gap-4 flex-wrap\",\n\t\t\t\t},\n\t\t\t\t\t// Clear button\n\t\t\t\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\t\t\t\"className\": \"px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md text-sm font-medium transition-colors cursor-pointer\",\n\t\t\t\t\t\t\"onClick\":   handleClear,\n\t\t\t\t\t}, \"Clear Data\"),\n\n\t\t\t\t\t// Status indicator\n\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\"className\": \"flex items-center gap-2\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"w-2 h-2 rounded-full bg-green-500\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-sm text-gray-400\",\n\t\t\t\t\t\t}, \"Live Monitoring\"),\n\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-sm text-gray-500 ml-2\",\n\t\t\t\t\t\t}, \"(\", len(cpuData), \"/\", dataPointCount, \" data points)\"),\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t),\n\n\t\t\t// Statistics Panel\n\t\t\tStatsPanel(StatsPanelProps{\n\t\t\t\tData: cpuData,\n\t\t\t}),\n\n\t\t\t// Main chart\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"bg-gray-800 rounded-lg p-6 mb-6\",\n\t\t\t},\n\t\t\t\tvdom.H(\"h2\", map[string]any{\n\t\t\t\t\t\"className\": \"text-xl font-semibold text-white mb-4\",\n\t\t\t\t}, \"CPU Usage Over Time\"),\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"w-full h-96\",\n\t\t\t\t},\n\t\t\t\t\tvdom.H(\"recharts:ResponsiveContainer\", map[string]any{\n\t\t\t\t\t\t\"width\":  \"100%\",\n\t\t\t\t\t\t\"height\": \"100%\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"recharts:LineChart\", map[string]any{\n\t\t\t\t\t\t\t\"data\":              cpuData,\n\t\t\t\t\t\t\t\"isAnimationActive\": false,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.H(\"recharts:CartesianGrid\", map[string]any{\n\t\t\t\t\t\t\t\t\"strokeDasharray\": \"3 3\",\n\t\t\t\t\t\t\t\t\"stroke\":          \"#374151\",\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\tvdom.H(\"recharts:XAxis\", map[string]any{\n\t\t\t\t\t\t\t\t\"dataKey\":  \"timestamp\",\n\t\t\t\t\t\t\t\t\"stroke\":   \"#9CA3AF\",\n\t\t\t\t\t\t\t\t\"fontSize\": 12,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\tvdom.H(\"recharts:YAxis\", map[string]any{\n\t\t\t\t\t\t\t\t\"domain\":   []int{0, 100},\n\t\t\t\t\t\t\t\t\"stroke\":   \"#9CA3AF\",\n\t\t\t\t\t\t\t\t\"fontSize\": 12,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\tvdom.H(\"recharts:Tooltip\", map[string]any{\n\t\t\t\t\t\t\t\t\"labelStyle\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"color\": \"#374151\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"contentStyle\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"backgroundColor\": \"#1F2937\",\n\t\t\t\t\t\t\t\t\t\"border\":          \"1px solid #374151\",\n\t\t\t\t\t\t\t\t\t\"borderRadius\":    \"6px\",\n\t\t\t\t\t\t\t\t\t\"color\":           \"#F3F4F6\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\tvdom.H(\"recharts:Line\", map[string]any{\n\t\t\t\t\t\t\t\t\"type\":              \"monotone\",\n\t\t\t\t\t\t\t\t\"dataKey\":           \"cpuUsage\",\n\t\t\t\t\t\t\t\t\"stroke\":            \"#3B82F6\",\n\t\t\t\t\t\t\t\t\"strokeWidth\":       2,\n\t\t\t\t\t\t\t\t\"dot\":               false,\n\t\t\t\t\t\t\t\t\"name\":              \"CPU Usage (%)\",\n\t\t\t\t\t\t\t\t\"isAnimationActive\": false,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t),\n\n\t\t\t// Info section\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"bg-blue-900 bg-opacity-50 border border-blue-700 rounded-lg p-4\",\n\t\t\t},\n\t\t\t\tvdom.H(\"h3\", map[string]any{\n\t\t\t\t\t\"className\": \"text-lg font-semibold text-blue-200 mb-2\",\n\t\t\t\t}, \"Real-Time CPU Monitoring Features\"),\n\t\t\t\tvdom.H(\"ul\", map[string]any{\n\t\t\t\t\t\"className\": \"space-y-2 text-blue-100\",\n\t\t\t\t},\n\t\t\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-blue-400 mt-1\",\n\t\t\t\t\t\t}, \"•\"),\n\t\t\t\t\t\t\"Live CPU usage data collected using github.com/shirou/gopsutil/v4\",\n\t\t\t\t\t),\n\t\t\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-blue-400 mt-1\",\n\t\t\t\t\t\t}, \"•\"),\n\t\t\t\t\t\t\"Continuous monitoring with 1-second update intervals\",\n\t\t\t\t\t),\n\t\t\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-blue-400 mt-1\",\n\t\t\t\t\t\t}, \"•\"),\n\t\t\t\t\t\t\"Rolling window of \", dataPointCount, \" seconds of historical data\",\n\t\t\t\t\t),\n\t\t\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-blue-400 mt-1\",\n\t\t\t\t\t\t}, \"•\"),\n\t\t\t\t\t\t\"Real-time statistics: current, average, and peak usage\",\n\t\t\t\t\t),\n\t\t\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-blue-400 mt-1\",\n\t\t\t\t\t\t}, \"•\"),\n\t\t\t\t\t\t\"Dark theme optimized for Wave Terminal\",\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t),\n\t\t),\n\t)\n},\n)\n"
  },
  {
    "path": "tsunami/demo/cpuchart/go.mod",
    "content": "module tsunami/app/cpuchart\n\ngo 1.25.6\n\nrequire (\n\tgithub.com/shirou/gopsutil/v4 v4.25.8\n\tgithub.com/wavetermdev/waveterm/tsunami v0.0.0\n)\n\nrequire (\n\tgithub.com/ebitengine/purego v0.8.4 // indirect\n\tgithub.com/go-ole/go-ole v1.2.6 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect\n\tgithub.com/outrigdev/goid v0.3.0 // indirect\n\tgithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect\n\tgithub.com/tklauser/go-sysconf v0.3.15 // indirect\n\tgithub.com/tklauser/numcpus v0.10.0 // indirect\n\tgithub.com/yusufpapurcu/wmi v1.2.4 // indirect\n\tgolang.org/x/sys v0.35.0 // indirect\n)\n\nreplace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami\n"
  },
  {
    "path": "tsunami/demo/cpuchart/go.sum",
    "content": "github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=\ngithub.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=\ngithub.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=\ngithub.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=\ngithub.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=\ngithub.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=\ngithub.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=\ngithub.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=\ngithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=\ngithub.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=\ngithub.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=\ngithub.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=\ngithub.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=\ngithub.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=\ngithub.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=\ngithub.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=\ngolang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=\ngolang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "tsunami/demo/cpuchart/static/tw.css",
    "content": "/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */\n@layer properties;\n@layer theme, base, components, utilities;\n@layer theme {\n  :root, :host {\n    --font-sans: \"Inter\", sans-serif;\n    --font-mono: \"Hack\", monospace;\n    --color-red-100: oklch(93.6% 0.032 17.717);\n    --color-red-400: oklch(70.4% 0.191 22.216);\n    --color-red-500: oklch(63.7% 0.237 25.331);\n    --color-red-800: oklch(44.4% 0.177 26.899);\n    --color-green-400: oklch(79.2% 0.209 151.711);\n    --color-green-500: oklch(72.3% 0.219 149.579);\n    --color-blue-100: oklch(93.2% 0.032 255.585);\n    --color-blue-200: oklch(88.2% 0.059 254.128);\n    --color-blue-400: oklch(70.7% 0.165 254.624);\n    --color-blue-700: oklch(48.8% 0.243 264.376);\n    --color-blue-900: oklch(37.9% 0.146 265.522);\n    --color-gray-400: oklch(70.7% 0.022 261.325);\n    --color-gray-500: oklch(55.1% 0.027 264.364);\n    --color-gray-600: oklch(44.6% 0.03 256.802);\n    --color-gray-700: oklch(37.3% 0.034 259.733);\n    --color-gray-800: oklch(27.8% 0.033 256.848);\n    --color-gray-900: oklch(21% 0.034 264.665);\n    --color-white: #fff;\n    --spacing: 0.25rem;\n    --container-6xl: 72rem;\n    --text-sm: 0.875rem;\n    --text-sm--line-height: calc(1.25 / 0.875);\n    --text-base: 1rem;\n    --text-base--line-height: calc(1.5 / 1);\n    --text-lg: 1.125rem;\n    --text-lg--line-height: calc(1.75 / 1.125);\n    --text-xl: 1.25rem;\n    --text-xl--line-height: calc(1.75 / 1.25);\n    --text-2xl: 1.5rem;\n    --text-2xl--line-height: calc(2 / 1.5);\n    --text-3xl: 1.875rem;\n    --text-3xl--line-height: calc(2.25 / 1.875);\n    --font-weight-medium: 500;\n    --font-weight-semibold: 600;\n    --font-weight-bold: 700;\n    --leading-relaxed: 1.625;\n    --radius-md: 0.375rem;\n    --radius-lg: 0.5rem;\n    --ease-in: cubic-bezier(0.4, 0, 1, 1);\n    --ease-out: cubic-bezier(0, 0, 0.2, 1);\n    --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-transition-duration: 150ms;\n    --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-font-family: var(--font-sans);\n    --default-mono-font-family: var(--font-mono);\n    --radius: 8px;\n    --color-background: rgb(34, 34, 34);\n    --color-primary: rgb(247, 247, 247);\n    --color-secondary: rgba(215, 218, 224, 0.7);\n    --color-muted: rgba(215, 218, 224, 0.5);\n    --color-accent-300: rgb(110, 231, 133);\n    --color-panel: rgba(255, 255, 255, 0.12);\n    --color-border: rgba(255, 255, 255, 0.16);\n    --color-accent: rgb(88, 193, 66);\n  }\n}\n@layer base {\n  *, ::after, ::before, ::backdrop, ::file-selector-button {\n    box-sizing: border-box;\n    margin: 0;\n    padding: 0;\n    border: 0 solid;\n  }\n  html, :host {\n    line-height: 1.5;\n    -webkit-text-size-adjust: 100%;\n    tab-size: 4;\n    font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\");\n    font-feature-settings: var(--default-font-feature-settings, normal);\n    font-variation-settings: var(--default-font-variation-settings, normal);\n    -webkit-tap-highlight-color: transparent;\n  }\n  hr {\n    height: 0;\n    color: inherit;\n    border-top-width: 1px;\n  }\n  abbr:where([title]) {\n    -webkit-text-decoration: underline dotted;\n    text-decoration: underline dotted;\n  }\n  h1, h2, h3, h4, h5, h6 {\n    font-size: inherit;\n    font-weight: inherit;\n  }\n  a {\n    color: inherit;\n    -webkit-text-decoration: inherit;\n    text-decoration: inherit;\n  }\n  b, strong {\n    font-weight: bolder;\n  }\n  code, kbd, samp, pre {\n    font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace);\n    font-feature-settings: var(--default-mono-font-feature-settings, normal);\n    font-variation-settings: var(--default-mono-font-variation-settings, normal);\n    font-size: 1em;\n  }\n  small {\n    font-size: 80%;\n  }\n  sub, sup {\n    font-size: 75%;\n    line-height: 0;\n    position: relative;\n    vertical-align: baseline;\n  }\n  sub {\n    bottom: -0.25em;\n  }\n  sup {\n    top: -0.5em;\n  }\n  table {\n    text-indent: 0;\n    border-color: inherit;\n    border-collapse: collapse;\n  }\n  :-moz-focusring {\n    outline: auto;\n  }\n  progress {\n    vertical-align: baseline;\n  }\n  summary {\n    display: list-item;\n  }\n  ol, ul, menu {\n    list-style: none;\n  }\n  img, svg, video, canvas, audio, iframe, embed, object {\n    display: block;\n    vertical-align: middle;\n  }\n  img, video {\n    max-width: 100%;\n    height: auto;\n  }\n  button, input, select, optgroup, textarea, ::file-selector-button {\n    font: inherit;\n    font-feature-settings: inherit;\n    font-variation-settings: inherit;\n    letter-spacing: inherit;\n    color: inherit;\n    border-radius: 0;\n    background-color: transparent;\n    opacity: 1;\n  }\n  :where(select:is([multiple], [size])) optgroup {\n    font-weight: bolder;\n  }\n  :where(select:is([multiple], [size])) optgroup option {\n    padding-inline-start: 20px;\n  }\n  ::file-selector-button {\n    margin-inline-end: 4px;\n  }\n  ::placeholder {\n    opacity: 1;\n  }\n  @supports (not (-webkit-appearance: -apple-pay-button))  or (contain-intrinsic-size: 1px) {\n    ::placeholder {\n      color: currentcolor;\n      @supports (color: color-mix(in lab, red, red)) {\n        color: color-mix(in oklab, currentcolor 50%, transparent);\n      }\n    }\n  }\n  textarea {\n    resize: vertical;\n  }\n  ::-webkit-search-decoration {\n    -webkit-appearance: none;\n  }\n  ::-webkit-date-and-time-value {\n    min-height: 1lh;\n    text-align: inherit;\n  }\n  ::-webkit-datetime-edit {\n    display: inline-flex;\n  }\n  ::-webkit-datetime-edit-fields-wrapper {\n    padding: 0;\n  }\n  ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n    padding-block: 0;\n  }\n  ::-webkit-calendar-picker-indicator {\n    line-height: 1;\n  }\n  :-moz-ui-invalid {\n    box-shadow: none;\n  }\n  button, input:where([type=\"button\"], [type=\"reset\"], [type=\"submit\"]), ::file-selector-button {\n    appearance: button;\n  }\n  ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n    height: auto;\n  }\n  [hidden]:where(:not([hidden=\"until-found\"])) {\n    display: none !important;\n  }\n}\n@layer utilities {\n  .collapse {\n    visibility: collapse;\n  }\n  .invisible {\n    visibility: hidden;\n  }\n  .visible {\n    visibility: visible;\n  }\n  .sr-only {\n    position: absolute;\n    width: 1px;\n    height: 1px;\n    padding: 0;\n    margin: -1px;\n    overflow: hidden;\n    clip-path: inset(50%);\n    white-space: nowrap;\n    border-width: 0;\n  }\n  .not-sr-only {\n    position: static;\n    width: auto;\n    height: auto;\n    padding: 0;\n    margin: 0;\n    overflow: visible;\n    clip-path: none;\n    white-space: normal;\n  }\n  .absolute {\n    position: absolute;\n  }\n  .fixed {\n    position: fixed;\n  }\n  .relative {\n    position: relative;\n  }\n  .static {\n    position: static;\n  }\n  .sticky {\n    position: sticky;\n  }\n  .isolate {\n    isolation: isolate;\n  }\n  .isolation-auto {\n    isolation: auto;\n  }\n  .container {\n    width: 100%;\n    @media (width >= 40rem) {\n      max-width: 40rem;\n    }\n    @media (width >= 48rem) {\n      max-width: 48rem;\n    }\n    @media (width >= 64rem) {\n      max-width: 64rem;\n    }\n    @media (width >= 80rem) {\n      max-width: 80rem;\n    }\n    @media (width >= 96rem) {\n      max-width: 96rem;\n    }\n  }\n  .mx-auto {\n    margin-inline: auto;\n  }\n  .my-6 {\n    margin-block: calc(var(--spacing) * 6);\n  }\n  .mt-1 {\n    margin-top: calc(var(--spacing) * 1);\n  }\n  .mt-3 {\n    margin-top: calc(var(--spacing) * 3);\n  }\n  .mt-4 {\n    margin-top: calc(var(--spacing) * 4);\n  }\n  .mt-5 {\n    margin-top: calc(var(--spacing) * 5);\n  }\n  .mt-6 {\n    margin-top: calc(var(--spacing) * 6);\n  }\n  .mb-1 {\n    margin-bottom: calc(var(--spacing) * 1);\n  }\n  .mb-2 {\n    margin-bottom: calc(var(--spacing) * 2);\n  }\n  .mb-3 {\n    margin-bottom: calc(var(--spacing) * 3);\n  }\n  .mb-4 {\n    margin-bottom: calc(var(--spacing) * 4);\n  }\n  .mb-6 {\n    margin-bottom: calc(var(--spacing) * 6);\n  }\n  .mb-8 {\n    margin-bottom: calc(var(--spacing) * 8);\n  }\n  .ml-2 {\n    margin-left: calc(var(--spacing) * 2);\n  }\n  .ml-4 {\n    margin-left: calc(var(--spacing) * 4);\n  }\n  .block {\n    display: block;\n  }\n  .contents {\n    display: contents;\n  }\n  .flex {\n    display: flex;\n  }\n  .flow-root {\n    display: flow-root;\n  }\n  .grid {\n    display: grid;\n  }\n  .hidden {\n    display: none;\n  }\n  .inline {\n    display: inline;\n  }\n  .inline-block {\n    display: inline-block;\n  }\n  .inline-flex {\n    display: inline-flex;\n  }\n  .inline-grid {\n    display: inline-grid;\n  }\n  .inline-table {\n    display: inline-table;\n  }\n  .list-item {\n    display: list-item;\n  }\n  .table {\n    display: table;\n  }\n  .table-caption {\n    display: table-caption;\n  }\n  .table-cell {\n    display: table-cell;\n  }\n  .table-column {\n    display: table-column;\n  }\n  .table-column-group {\n    display: table-column-group;\n  }\n  .table-footer-group {\n    display: table-footer-group;\n  }\n  .table-header-group {\n    display: table-header-group;\n  }\n  .table-row {\n    display: table-row;\n  }\n  .table-row-group {\n    display: table-row-group;\n  }\n  .h-2 {\n    height: calc(var(--spacing) * 2);\n  }\n  .h-96 {\n    height: calc(var(--spacing) * 96);\n  }\n  .min-h-full {\n    min-height: 100%;\n  }\n  .min-h-screen {\n    min-height: 100vh;\n  }\n  .w-2 {\n    width: calc(var(--spacing) * 2);\n  }\n  .w-full {\n    width: 100%;\n  }\n  .max-w-6xl {\n    max-width: var(--container-6xl);\n  }\n  .max-w-none {\n    max-width: none;\n  }\n  .min-w-full {\n    min-width: 100%;\n  }\n  .shrink {\n    flex-shrink: 1;\n  }\n  .grow {\n    flex-grow: 1;\n  }\n  .border-collapse {\n    border-collapse: collapse;\n  }\n  .translate-none {\n    translate: none;\n  }\n  .scale-3d {\n    scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);\n  }\n  .transform {\n    transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);\n  }\n  .cursor-pointer {\n    cursor: pointer;\n  }\n  .touch-pinch-zoom {\n    --tw-pinch-zoom: pinch-zoom;\n    touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);\n  }\n  .resize {\n    resize: both;\n  }\n  .list-inside {\n    list-style-position: inside;\n  }\n  .list-decimal {\n    list-style-type: decimal;\n  }\n  .list-disc {\n    list-style-type: disc;\n  }\n  .grid-cols-3 {\n    grid-template-columns: repeat(3, minmax(0, 1fr));\n  }\n  .flex-wrap {\n    flex-wrap: wrap;\n  }\n  .items-center {\n    align-items: center;\n  }\n  .items-start {\n    align-items: flex-start;\n  }\n  .gap-2 {\n    gap: calc(var(--spacing) * 2);\n  }\n  .gap-4 {\n    gap: calc(var(--spacing) * 4);\n  }\n  .space-y-1 {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 0;\n      margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));\n      margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));\n    }\n  }\n  .space-y-2 {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 0;\n      margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));\n      margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));\n    }\n  }\n  .space-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 1;\n    }\n  }\n  .space-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-x-reverse: 1;\n    }\n  }\n  .divide-x {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 0;\n      border-inline-style: var(--tw-border-style);\n      border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));\n      border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));\n    }\n  }\n  .divide-y {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 0;\n      border-bottom-style: var(--tw-border-style);\n      border-top-style: var(--tw-border-style);\n      border-top-width: calc(1px * var(--tw-divide-y-reverse));\n      border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));\n    }\n  }\n  .divide-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 1;\n    }\n  }\n  .truncate {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .overflow-auto {\n    overflow: auto;\n  }\n  .overflow-x-auto {\n    overflow-x: auto;\n  }\n  .rounded {\n    border-radius: var(--radius);\n  }\n  .rounded-full {\n    border-radius: calc(infinity * 1px);\n  }\n  .rounded-lg {\n    border-radius: var(--radius-lg);\n  }\n  .rounded-md {\n    border-radius: var(--radius-md);\n  }\n  .rounded-s {\n    border-start-start-radius: var(--radius);\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-ss {\n    border-start-start-radius: var(--radius);\n  }\n  .rounded-e {\n    border-start-end-radius: var(--radius);\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-se {\n    border-start-end-radius: var(--radius);\n  }\n  .rounded-ee {\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-es {\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-t {\n    border-top-left-radius: var(--radius);\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-l {\n    border-top-left-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-tl {\n    border-top-left-radius: var(--radius);\n  }\n  .rounded-r {\n    border-top-right-radius: var(--radius);\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-tr {\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-b {\n    border-bottom-right-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-br {\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-bl {\n    border-bottom-left-radius: var(--radius);\n  }\n  .border {\n    border-style: var(--tw-border-style);\n    border-width: 1px;\n  }\n  .border-x {\n    border-inline-style: var(--tw-border-style);\n    border-inline-width: 1px;\n  }\n  .border-y {\n    border-block-style: var(--tw-border-style);\n    border-block-width: 1px;\n  }\n  .border-s {\n    border-inline-start-style: var(--tw-border-style);\n    border-inline-start-width: 1px;\n  }\n  .border-e {\n    border-inline-end-style: var(--tw-border-style);\n    border-inline-end-width: 1px;\n  }\n  .border-t {\n    border-top-style: var(--tw-border-style);\n    border-top-width: 1px;\n  }\n  .border-r {\n    border-right-style: var(--tw-border-style);\n    border-right-width: 1px;\n  }\n  .border-b {\n    border-bottom-style: var(--tw-border-style);\n    border-bottom-width: 1px;\n  }\n  .border-l {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 1px;\n  }\n  .border-l-4 {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 4px;\n  }\n  .border-blue-700 {\n    border-color: var(--color-blue-700);\n  }\n  .border-border {\n    border-color: var(--color-border);\n  }\n  .border-red-500 {\n    border-color: var(--color-red-500);\n  }\n  .bg-background {\n    background-color: var(--color-background);\n  }\n  .bg-blue-900 {\n    background-color: var(--color-blue-900);\n  }\n  .bg-gray-600 {\n    background-color: var(--color-gray-600);\n  }\n  .bg-gray-700 {\n    background-color: var(--color-gray-700);\n  }\n  .bg-gray-800 {\n    background-color: var(--color-gray-800);\n  }\n  .bg-gray-900 {\n    background-color: var(--color-gray-900);\n  }\n  .bg-green-500 {\n    background-color: var(--color-green-500);\n  }\n  .bg-panel {\n    background-color: var(--color-panel);\n  }\n  .bg-red-100 {\n    background-color: var(--color-red-100);\n  }\n  .bg-repeat {\n    background-repeat: repeat;\n  }\n  .mask-no-clip {\n    mask-clip: no-clip;\n  }\n  .mask-repeat {\n    mask-repeat: repeat;\n  }\n  .p-3 {\n    padding: calc(var(--spacing) * 3);\n  }\n  .p-4 {\n    padding: calc(var(--spacing) * 4);\n  }\n  .p-6 {\n    padding: calc(var(--spacing) * 6);\n  }\n  .px-1 {\n    padding-inline: calc(var(--spacing) * 1);\n  }\n  .px-4 {\n    padding-inline: calc(var(--spacing) * 4);\n  }\n  .py-0\\.5 {\n    padding-block: calc(var(--spacing) * 0.5);\n  }\n  .py-2 {\n    padding-block: calc(var(--spacing) * 2);\n  }\n  .pl-4 {\n    padding-left: calc(var(--spacing) * 4);\n  }\n  .text-left {\n    text-align: left;\n  }\n  .font-mono {\n    font-family: var(--font-mono);\n  }\n  .text-2xl {\n    font-size: var(--text-2xl);\n    line-height: var(--tw-leading, var(--text-2xl--line-height));\n  }\n  .text-3xl {\n    font-size: var(--text-3xl);\n    line-height: var(--tw-leading, var(--text-3xl--line-height));\n  }\n  .text-base {\n    font-size: var(--text-base);\n    line-height: var(--tw-leading, var(--text-base--line-height));\n  }\n  .text-lg {\n    font-size: var(--text-lg);\n    line-height: var(--tw-leading, var(--text-lg--line-height));\n  }\n  .text-sm {\n    font-size: var(--text-sm);\n    line-height: var(--tw-leading, var(--text-sm--line-height));\n  }\n  .text-xl {\n    font-size: var(--text-xl);\n    line-height: var(--tw-leading, var(--text-xl--line-height));\n  }\n  .leading-relaxed {\n    --tw-leading: var(--leading-relaxed);\n    line-height: var(--leading-relaxed);\n  }\n  .font-bold {\n    --tw-font-weight: var(--font-weight-bold);\n    font-weight: var(--font-weight-bold);\n  }\n  .font-medium {\n    --tw-font-weight: var(--font-weight-medium);\n    font-weight: var(--font-weight-medium);\n  }\n  .font-semibold {\n    --tw-font-weight: var(--font-weight-semibold);\n    font-weight: var(--font-weight-semibold);\n  }\n  .text-wrap {\n    text-wrap: wrap;\n  }\n  .text-clip {\n    text-overflow: clip;\n  }\n  .text-ellipsis {\n    text-overflow: ellipsis;\n  }\n  .text-accent {\n    color: var(--color-accent);\n  }\n  .text-blue-100 {\n    color: var(--color-blue-100);\n  }\n  .text-blue-200 {\n    color: var(--color-blue-200);\n  }\n  .text-blue-400 {\n    color: var(--color-blue-400);\n  }\n  .text-gray-400 {\n    color: var(--color-gray-400);\n  }\n  .text-gray-500 {\n    color: var(--color-gray-500);\n  }\n  .text-green-400 {\n    color: var(--color-green-400);\n  }\n  .text-muted {\n    color: var(--color-muted);\n  }\n  .text-primary {\n    color: var(--color-primary);\n  }\n  .text-red-400 {\n    color: var(--color-red-400);\n  }\n  .text-red-800 {\n    color: var(--color-red-800);\n  }\n  .text-secondary {\n    color: var(--color-secondary);\n  }\n  .text-white {\n    color: var(--color-white);\n  }\n  .capitalize {\n    text-transform: capitalize;\n  }\n  .lowercase {\n    text-transform: lowercase;\n  }\n  .normal-case {\n    text-transform: none;\n  }\n  .uppercase {\n    text-transform: uppercase;\n  }\n  .italic {\n    font-style: italic;\n  }\n  .not-italic {\n    font-style: normal;\n  }\n  .diagonal-fractions {\n    --tw-numeric-fraction: diagonal-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .lining-nums {\n    --tw-numeric-figure: lining-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .oldstyle-nums {\n    --tw-numeric-figure: oldstyle-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .ordinal {\n    --tw-ordinal: ordinal;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .proportional-nums {\n    --tw-numeric-spacing: proportional-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .slashed-zero {\n    --tw-slashed-zero: slashed-zero;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .stacked-fractions {\n    --tw-numeric-fraction: stacked-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .tabular-nums {\n    --tw-numeric-spacing: tabular-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .normal-nums {\n    font-variant-numeric: normal;\n  }\n  .line-through {\n    text-decoration-line: line-through;\n  }\n  .no-underline {\n    text-decoration-line: none;\n  }\n  .overline {\n    text-decoration-line: overline;\n  }\n  .underline {\n    text-decoration-line: underline;\n  }\n  .antialiased {\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n  .subpixel-antialiased {\n    -webkit-font-smoothing: auto;\n    -moz-osx-font-smoothing: auto;\n  }\n  .shadow {\n    --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .inset-ring {\n    --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor);\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .blur {\n    --tw-blur: blur(8px);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .drop-shadow {\n    --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));\n    --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .invert {\n    --tw-invert: invert(100%);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .filter {\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .backdrop-blur {\n    --tw-backdrop-blur: blur(8px);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-grayscale {\n    --tw-backdrop-grayscale: grayscale(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-invert {\n    --tw-backdrop-invert: invert(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-sepia {\n    --tw-backdrop-sepia: sepia(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-filter {\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .transition-colors {\n    transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;\n    transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));\n    transition-duration: var(--tw-duration, var(--default-transition-duration));\n  }\n  .ease-in {\n    --tw-ease: var(--ease-in);\n    transition-timing-function: var(--ease-in);\n  }\n  .ease-in-out {\n    --tw-ease: var(--ease-in-out);\n    transition-timing-function: var(--ease-in-out);\n  }\n  .ease-out {\n    --tw-ease: var(--ease-out);\n    transition-timing-function: var(--ease-out);\n  }\n  .divide-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 1;\n    }\n  }\n  .ring-inset {\n    --tw-ring-inset: inset;\n  }\n  .hover\\:bg-gray-700 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-gray-700);\n      }\n    }\n  }\n  .hover\\:text-accent-300 {\n    &:hover {\n      @media (hover: hover) {\n        color: var(--color-accent-300);\n      }\n    }\n  }\n}\n@property --tw-scale-x {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-y {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-z {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-rotate-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-z {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pinch-zoom {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-space-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-space-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-divide-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-border-style {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: solid;\n}\n@property --tw-divide-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-leading {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-font-weight {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ordinal {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-slashed-zero {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-figure {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-spacing {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-fraction {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-inset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-offset-width {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ease {\n  syntax: \"*\";\n  inherits: false;\n}\n@layer properties {\n  @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n    *, ::before, ::after, ::backdrop {\n      --tw-scale-x: 1;\n      --tw-scale-y: 1;\n      --tw-scale-z: 1;\n      --tw-rotate-x: initial;\n      --tw-rotate-y: initial;\n      --tw-rotate-z: initial;\n      --tw-skew-x: initial;\n      --tw-skew-y: initial;\n      --tw-pan-x: initial;\n      --tw-pan-y: initial;\n      --tw-pinch-zoom: initial;\n      --tw-space-y-reverse: 0;\n      --tw-space-x-reverse: 0;\n      --tw-divide-x-reverse: 0;\n      --tw-border-style: solid;\n      --tw-divide-y-reverse: 0;\n      --tw-leading: initial;\n      --tw-font-weight: initial;\n      --tw-ordinal: initial;\n      --tw-slashed-zero: initial;\n      --tw-numeric-figure: initial;\n      --tw-numeric-spacing: initial;\n      --tw-numeric-fraction: initial;\n      --tw-shadow: 0 0 #0000;\n      --tw-shadow-color: initial;\n      --tw-shadow-alpha: 100%;\n      --tw-inset-shadow: 0 0 #0000;\n      --tw-inset-shadow-color: initial;\n      --tw-inset-shadow-alpha: 100%;\n      --tw-ring-color: initial;\n      --tw-ring-shadow: 0 0 #0000;\n      --tw-inset-ring-color: initial;\n      --tw-inset-ring-shadow: 0 0 #0000;\n      --tw-ring-inset: initial;\n      --tw-ring-offset-width: 0px;\n      --tw-ring-offset-color: #fff;\n      --tw-ring-offset-shadow: 0 0 #0000;\n      --tw-blur: initial;\n      --tw-brightness: initial;\n      --tw-contrast: initial;\n      --tw-grayscale: initial;\n      --tw-hue-rotate: initial;\n      --tw-invert: initial;\n      --tw-opacity: initial;\n      --tw-saturate: initial;\n      --tw-sepia: initial;\n      --tw-drop-shadow: initial;\n      --tw-drop-shadow-color: initial;\n      --tw-drop-shadow-alpha: 100%;\n      --tw-drop-shadow-size: initial;\n      --tw-backdrop-blur: initial;\n      --tw-backdrop-brightness: initial;\n      --tw-backdrop-contrast: initial;\n      --tw-backdrop-grayscale: initial;\n      --tw-backdrop-hue-rotate: initial;\n      --tw-backdrop-invert: initial;\n      --tw-backdrop-opacity: initial;\n      --tw-backdrop-saturate: initial;\n      --tw-backdrop-sepia: initial;\n      --tw-ease: initial;\n    }\n  }\n}\n"
  },
  {
    "path": "tsunami/demo/githubaction/app.go",
    "content": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"sort\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/app\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\nvar AppMeta = app.AppMeta{\n\tTitle:     \"GitHub Actions Monitor\",\n\tShortDesc: \"Real-time GitHub Actions workflow monitoring and status tracking\",\n}\n\n// Global atoms for config and data\nvar (\n\tpollIntervalAtom = app.ConfigAtom(\"pollInterval\", 5, &app.AtomMeta{\n\t\tDesc:  \"Polling interval for GitHub API requests\",\n\t\tUnits: \"s\",\n\t\tMin:   app.Ptr(1.0),\n\t\tMax:   app.Ptr(300.0),\n\t})\n\trepositoryAtom = app.ConfigAtom(\"repository\", \"wavetermdev/waveterm\", &app.AtomMeta{\n\t\tDesc:    \"GitHub repository in owner/repo format\",\n\t\tPattern: `^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$`,\n\t})\n\tworkflowAtom = app.ConfigAtom(\"workflow\", \"build-helper.yml\", &app.AtomMeta{\n\t\tDesc:    \"GitHub Actions workflow file name\",\n\t\tPattern: `^.+\\.(yml|yaml)$`,\n\t})\n\tmaxWorkflowRunsAtom = app.ConfigAtom(\"maxWorkflowRuns\", 10, &app.AtomMeta{\n\t\tDesc: \"Maximum number of workflow runs to fetch\",\n\t\tMin:  app.Ptr(1.0),\n\t\tMax:  app.Ptr(100.0),\n\t})\n\tworkflowRunsAtom = app.DataAtom(\"workflowRuns\", []WorkflowRun{}, &app.AtomMeta{\n\t\tDesc: \"List of GitHub Actions workflow runs\",\n\t})\n\tlastErrorAtom = app.DataAtom(\"lastError\", \"\", &app.AtomMeta{\n\t\tDesc: \"Last error message from GitHub API\",\n\t})\n\tisLoadingAtom = app.DataAtom(\"isLoading\", true, &app.AtomMeta{\n\t\tDesc: \"Loading state for workflow data fetch\",\n\t})\n\tlastRefreshTimeAtom = app.DataAtom(\"lastRefreshTime\", time.Time{}, &app.AtomMeta{\n\t\tDesc: \"Timestamp of last successful data refresh\",\n\t})\n)\n\ntype WorkflowRun struct {\n\tID         int64     `json:\"id\"`\n\tName       string    `json:\"name\"`\n\tStatus     string    `json:\"status\"`\n\tConclusion string    `json:\"conclusion\"`\n\tCreatedAt  time.Time `json:\"created_at\"`\n\tUpdatedAt  time.Time `json:\"updated_at\"`\n\tHTMLURL    string    `json:\"html_url\"`\n\tRunNumber  int       `json:\"run_number\"`\n}\n\ntype GitHubResponse struct {\n\tTotalCount   int           `json:\"total_count\"`\n\tWorkflowRuns []WorkflowRun `json:\"workflow_runs\"`\n}\n\nfunc fetchWorkflowRuns(repository, workflow string, maxRuns int) ([]WorkflowRun, error) {\n\tapiKey := os.Getenv(\"GITHUB_APIKEY\")\n\tif apiKey == \"\" {\n\t\treturn nil, fmt.Errorf(\"GITHUB_APIKEY environment variable not set\")\n\t}\n\n\turl := fmt.Sprintf(\"https://api.github.com/repos/%s/actions/workflows/%s/runs?per_page=%d\", repository, workflow, maxRuns)\n\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\treq.Header.Set(\"Accept\", \"application/vnd.github.v3+json\")\n\treq.Header.Set(\"User-Agent\", \"WaveTerminal-GitHubMonitor\")\n\n\tclient := &http.Client{Timeout: 10 * time.Second}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to make request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"GitHub API returned status %d: %s\", resp.StatusCode, string(body))\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\tvar response GitHubResponse\n\tif err := json.Unmarshal(body, &response); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\treturn response.WorkflowRuns, nil\n}\n\nfunc getStatusIcon(status, conclusion string) string {\n\tswitch status {\n\tcase \"in_progress\", \"queued\", \"pending\":\n\t\treturn \"🔄\"\n\tcase \"completed\":\n\t\tswitch conclusion {\n\t\tcase \"success\":\n\t\t\treturn \"✅\"\n\t\tcase \"failure\":\n\t\t\treturn \"❌\"\n\t\tcase \"cancelled\":\n\t\t\treturn \"🚫\"\n\t\tcase \"skipped\":\n\t\t\treturn \"⏭️\"\n\t\tdefault:\n\t\t\treturn \"❓\"\n\t\t}\n\tdefault:\n\t\treturn \"❓\"\n\t}\n}\n\nfunc getStatusColor(status, conclusion string) string {\n\tswitch status {\n\tcase \"in_progress\", \"queued\", \"pending\":\n\t\treturn \"text-yellow-400\"\n\tcase \"completed\":\n\t\tswitch conclusion {\n\t\tcase \"success\":\n\t\t\treturn \"text-green-400\"\n\t\tcase \"failure\":\n\t\t\treturn \"text-red-400\"\n\t\tcase \"cancelled\":\n\t\t\treturn \"text-gray-400\"\n\t\tcase \"skipped\":\n\t\t\treturn \"text-blue-400\"\n\t\tdefault:\n\t\t\treturn \"text-gray-400\"\n\t\t}\n\tdefault:\n\t\treturn \"text-gray-400\"\n\t}\n}\n\nfunc formatDuration(start, end time.Time, isRunning bool) string {\n\tif isRunning {\n\t\tduration := time.Since(start)\n\t\treturn fmt.Sprintf(\"%v (running)\", duration.Round(time.Second))\n\t}\n\tif end.IsZero() {\n\t\treturn \"Unknown\"\n\t}\n\tduration := end.Sub(start)\n\treturn duration.Round(time.Second).String()\n}\n\nfunc getDisplayStatus(status, conclusion string) string {\n\tswitch status {\n\tcase \"in_progress\":\n\t\treturn \"Running\"\n\tcase \"queued\":\n\t\treturn \"Queued\"\n\tcase \"pending\":\n\t\treturn \"Pending\"\n\tcase \"completed\":\n\t\tswitch conclusion {\n\t\tcase \"success\":\n\t\t\treturn \"Success\"\n\t\tcase \"failure\":\n\t\t\treturn \"Failed\"\n\t\tcase \"cancelled\":\n\t\t\treturn \"Cancelled\"\n\t\tcase \"skipped\":\n\t\t\treturn \"Skipped\"\n\t\tdefault:\n\t\t\treturn \"Completed\"\n\t\t}\n\tdefault:\n\t\treturn status\n\t}\n}\n\ntype WorkflowRunItemProps struct {\n\tRun WorkflowRun `json:\"run\"`\n}\n\nvar WorkflowRunItem = app.DefineComponent(\"WorkflowRunItem\",\n\tfunc(props WorkflowRunItemProps) any {\n\t\trun := props.Run\n\t\tisRunning := run.Status == \"in_progress\" || run.Status == \"queued\" || run.Status == \"pending\"\n\n\t\treturn vdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"bg-gray-800 rounded-lg p-4 border border-gray-700 hover:border-gray-600 transition-colors\",\n\t\t},\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"flex items-start justify-between\",\n\t\t\t},\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"flex-1 min-w-0\",\n\t\t\t\t},\n\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\"className\": \"flex items-center gap-3 mb-2\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-2xl\",\n\t\t\t\t\t\t}, getStatusIcon(run.Status, run.Conclusion)),\n\t\t\t\t\t\tvdom.H(\"a\", map[string]any{\n\t\t\t\t\t\t\t\"href\":      run.HTMLURL,\n\t\t\t\t\t\t\t\"target\":    \"_blank\",\n\t\t\t\t\t\t\t\"className\": \"font-semibold text-blue-400 hover:text-blue-300 cursor-pointer\",\n\t\t\t\t\t\t}, run.Name),\n\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-sm text-gray-300\",\n\t\t\t\t\t\t}, \"#\", run.RunNumber),\n\t\t\t\t\t),\n\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\"className\": \"flex items-center gap-4 text-sm\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\"className\": vdom.Classes(\"font-medium\", getStatusColor(run.Status, run.Conclusion)),\n\t\t\t\t\t\t}, getDisplayStatus(run.Status, run.Conclusion)),\n\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-gray-400\",\n\t\t\t\t\t\t}, \"Duration: \", formatDuration(run.CreatedAt, run.UpdatedAt, isRunning)),\n\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-gray-300\",\n\t\t\t\t\t\t}, \"Started: \", run.CreatedAt.Format(\"15:04:05\")),\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t),\n\t\t)\n\t},\n)\n\nvar App = app.DefineComponent(\"App\",\n\tfunc(_ struct{}) any {\n\n\t\tfetchData := func() {\n\t\t\tcurrentMaxRuns := maxWorkflowRunsAtom.Get()\n\t\t\truns, err := fetchWorkflowRuns(repositoryAtom.Get(), workflowAtom.Get(), currentMaxRuns)\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"Error fetching workflow runs: %v\", err)\n\t\t\t\tlastErrorAtom.Set(err.Error())\n\t\t\t} else {\n\t\t\t\tsort.Slice(runs, func(i, j int) bool {\n\t\t\t\t\treturn runs[i].CreatedAt.After(runs[j].CreatedAt)\n\t\t\t\t})\n\t\t\t\tworkflowRunsAtom.Set(runs)\n\t\t\t\tlastErrorAtom.Set(\"\")\n\t\t\t}\n\t\t\tlastRefreshTimeAtom.Set(time.Now())\n\t\t\tisLoadingAtom.Set(false)\n\t\t}\n\n\t\t// Initial fetch on mount\n\t\tapp.UseEffect(func() func() {\n\t\t\tfetchData()\n\t\t\treturn nil\n\t\t}, []any{})\n\n\t\t// Automatic polling with UseTicker - automatically cleaned up on unmount\n\t\tapp.UseTicker(time.Duration(pollIntervalAtom.Get())*time.Second, func() {\n\t\t\tfetchData()\n\t\t}, []any{pollIntervalAtom.Get()})\n\n\t\thandleRefresh := func() {\n\t\t\tisLoadingAtom.Set(true)\n\t\t\tgo func() {\n\t\t\t\tfetchData()\n\t\t\t}()\n\t\t}\n\n\t\tworkflowRuns := workflowRunsAtom.Get()\n\t\tlastError := lastErrorAtom.Get()\n\t\tisLoading := isLoadingAtom.Get()\n\t\tlastRefreshTime := lastRefreshTimeAtom.Get()\n\t\tpollInterval := pollIntervalAtom.Get()\n\t\trepository := repositoryAtom.Get()\n\t\tworkflow := workflowAtom.Get()\n\t\tmaxWorkflowRuns := maxWorkflowRunsAtom.Get()\n\n\t\treturn vdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"min-h-screen bg-gray-900 text-white p-6\",\n\t\t},\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"max-w-6xl mx-auto\",\n\t\t\t},\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"mb-8\",\n\t\t\t\t},\n\t\t\t\t\tvdom.H(\"h1\", map[string]any{\n\t\t\t\t\t\t\"className\": \"text-3xl font-bold text-white mb-2\",\n\t\t\t\t\t}, \"GitHub Actions Monitor\"),\n\t\t\t\t\tvdom.H(\"p\", map[string]any{\n\t\t\t\t\t\t\"className\": \"text-gray-400\",\n\t\t\t\t\t}, \"Monitoring \", repositoryAtom.Get(), \" \", workflowAtom.Get(), \" workflow\"),\n\t\t\t\t),\n\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"bg-gray-800 rounded-lg p-4 mb-6\",\n\t\t\t\t},\n\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\"className\": \"flex items-center justify-between\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"flex items-center gap-4\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\t\t\t\t\t\"className\": vdom.Classes(\n\t\t\t\t\t\t\t\t\t\"px-4 py-2 rounded-md text-sm font-medium transition-colors cursor-pointer\",\n\t\t\t\t\t\t\t\t\tvdom.IfElse(isLoadingAtom.Get(), \"bg-gray-600 text-gray-400\", \"bg-blue-600 hover:bg-blue-700 text-white\"),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\"onClick\":  vdom.If(!isLoadingAtom.Get(), handleRefresh),\n\t\t\t\t\t\t\t\t\"disabled\": isLoadingAtom.Get(),\n\t\t\t\t\t\t\t}, vdom.IfElse(isLoadingAtom.Get(), \"Refreshing...\", \"Refresh\")),\n\n\t\t\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\t\t\"className\": \"flex items-center gap-2\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\t\t\t\"className\": vdom.Classes(\"w-2 h-2 rounded-full\", vdom.IfElse(lastError == \"\", \"bg-green-500\", \"bg-red-500\")),\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\t\t\"className\": \"text-sm text-gray-400\",\n\t\t\t\t\t\t\t\t}, vdom.IfElse(lastError == \"\", \"Connected\", \"Error\")),\n\t\t\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\t\t\"className\": \"text-sm text-gray-300 ml-2\",\n\t\t\t\t\t\t\t\t}, \"Poll interval: \", pollInterval, \"s\"),\n\t\t\t\t\t\t\t\tvdom.If(!lastRefreshTime.IsZero(),\n\t\t\t\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\"className\": \"text-sm text-gray-300 ml-4\",\n\t\t\t\t\t\t\t\t\t}, \"Last refresh: \", lastRefreshTime.Format(\"15:04:05\")),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t),\n\t\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-sm text-gray-300\",\n\t\t\t\t\t\t}, \"Last \", maxWorkflowRuns, \" workflow runs\"),\n\t\t\t\t\t),\n\t\t\t\t),\n\n\t\t\t\tvdom.If(lastError != \"\",\n\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\"className\": \"bg-red-900 bg-opacity-50 border border-red-700 rounded-lg p-4 mb-6\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"flex items-center gap-2 text-red-200\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.H(\"span\", nil, \"❌\"),\n\t\t\t\t\t\t\tvdom.H(\"strong\", nil, \"Error:\"),\n\t\t\t\t\t\t),\n\t\t\t\t\t\tvdom.H(\"p\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-red-100 mt-1\",\n\t\t\t\t\t\t}, lastError),\n\t\t\t\t\t),\n\t\t\t\t),\n\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"space-y-4\",\n\t\t\t\t},\n\t\t\t\t\tvdom.If(isLoading && len(workflowRuns) == 0,\n\t\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-center py-8 text-gray-400\",\n\t\t\t\t\t\t}, \"Loading workflow runs...\"),\n\t\t\t\t\t),\n\t\t\t\t\tvdom.If(len(workflowRuns) > 0,\n\t\t\t\t\t\tvdom.ForEach(workflowRuns, func(run WorkflowRun, idx int) any {\n\t\t\t\t\t\t\treturn WorkflowRunItem(WorkflowRunItemProps{\n\t\t\t\t\t\t\t\tRun: run,\n\t\t\t\t\t\t\t}).WithKey(strconv.FormatInt(run.ID, 10))\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t\tvdom.If(!isLoading && len(workflowRuns) == 0 && lastError == \"\",\n\t\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"text-center py-8 text-gray-400\",\n\t\t\t\t\t\t}, \"No workflow runs found\"),\n\t\t\t\t\t),\n\t\t\t\t),\n\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"mt-8 bg-blue-900 bg-opacity-50 border border-blue-700 rounded-lg p-4\",\n\t\t\t\t},\n\t\t\t\t\tvdom.H(\"h3\", map[string]any{\n\t\t\t\t\t\t\"className\": \"text-lg font-semibold text-blue-200 mb-2\",\n\t\t\t\t\t}, \"GitHub Actions Monitor Features\"),\n\t\t\t\t\tvdom.H(\"ul\", map[string]any{\n\t\t\t\t\t\t\"className\": \"space-y-2 text-blue-100\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\t\"className\": \"text-blue-400 mt-1\",\n\t\t\t\t\t\t\t}, \"•\"),\n\t\t\t\t\t\t\t\"Monitors \", repository, \" \", workflow, \" workflow\",\n\t\t\t\t\t\t),\n\t\t\t\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\t\"className\": \"text-blue-400 mt-1\",\n\t\t\t\t\t\t\t}, \"•\"),\n\t\t\t\t\t\t\t\"Polls GitHub API every \", pollInterval, \" seconds for real-time updates\",\n\t\t\t\t\t\t),\n\t\t\t\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\t\"className\": \"text-blue-400 mt-1\",\n\t\t\t\t\t\t\t}, \"•\"),\n\t\t\t\t\t\t\t\"Shows status icons: ✅ Success, ❌ Failure, 🔄 Running\",\n\t\t\t\t\t\t),\n\t\t\t\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\t\"className\": \"text-blue-400 mt-1\",\n\t\t\t\t\t\t\t}, \"•\"),\n\t\t\t\t\t\t\t\"Clickable workflow names open in GitHub (new tab)\",\n\t\t\t\t\t\t),\n\t\t\t\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\t\"className\": \"text-blue-400 mt-1\",\n\t\t\t\t\t\t\t}, \"•\"),\n\t\t\t\t\t\t\t\"Live duration tracking for running jobs\",\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t),\n\t\t)\n\t},\n)\n"
  },
  {
    "path": "tsunami/demo/githubaction/go.mod",
    "content": "module tsunami/app/githubaction\n\ngo 1.25.6\n\nrequire github.com/wavetermdev/waveterm/tsunami v0.0.0\n\nrequire (\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/outrigdev/goid v0.3.0 // indirect\n)\n\nreplace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami\n"
  },
  {
    "path": "tsunami/demo/githubaction/go.sum",
    "content": "github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=\ngithub.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=\n"
  },
  {
    "path": "tsunami/demo/githubaction/static/tw.css",
    "content": "/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */\n@layer properties;\n@layer theme, base, components, utilities;\n@layer theme {\n  :root, :host {\n    --font-sans: \"Inter\", sans-serif;\n    --font-mono: \"Hack\", monospace;\n    --color-red-100: oklch(93.6% 0.032 17.717);\n    --color-red-200: oklch(88.5% 0.062 18.334);\n    --color-red-400: oklch(70.4% 0.191 22.216);\n    --color-red-500: oklch(63.7% 0.237 25.331);\n    --color-red-700: oklch(50.5% 0.213 27.518);\n    --color-red-800: oklch(44.4% 0.177 26.899);\n    --color-red-900: oklch(39.6% 0.141 25.723);\n    --color-yellow-400: oklch(85.2% 0.199 91.936);\n    --color-green-400: oklch(79.2% 0.209 151.711);\n    --color-green-500: oklch(72.3% 0.219 149.579);\n    --color-blue-100: oklch(93.2% 0.032 255.585);\n    --color-blue-200: oklch(88.2% 0.059 254.128);\n    --color-blue-300: oklch(80.9% 0.105 251.813);\n    --color-blue-400: oklch(70.7% 0.165 254.624);\n    --color-blue-600: oklch(54.6% 0.245 262.881);\n    --color-blue-700: oklch(48.8% 0.243 264.376);\n    --color-blue-900: oklch(37.9% 0.146 265.522);\n    --color-gray-300: oklch(87.2% 0.01 258.338);\n    --color-gray-400: oklch(70.7% 0.022 261.325);\n    --color-gray-600: oklch(44.6% 0.03 256.802);\n    --color-gray-700: oklch(37.3% 0.034 259.733);\n    --color-gray-800: oklch(27.8% 0.033 256.848);\n    --color-gray-900: oklch(21% 0.034 264.665);\n    --color-white: #fff;\n    --spacing: 0.25rem;\n    --container-6xl: 72rem;\n    --text-sm: 0.875rem;\n    --text-sm--line-height: calc(1.25 / 0.875);\n    --text-base: 1rem;\n    --text-base--line-height: calc(1.5 / 1);\n    --text-lg: 1.125rem;\n    --text-lg--line-height: calc(1.75 / 1.125);\n    --text-xl: 1.25rem;\n    --text-xl--line-height: calc(1.75 / 1.25);\n    --text-2xl: 1.5rem;\n    --text-2xl--line-height: calc(2 / 1.5);\n    --text-3xl: 1.875rem;\n    --text-3xl--line-height: calc(2.25 / 1.875);\n    --font-weight-medium: 500;\n    --font-weight-semibold: 600;\n    --font-weight-bold: 700;\n    --leading-relaxed: 1.625;\n    --radius-md: 0.375rem;\n    --radius-lg: 0.5rem;\n    --ease-in: cubic-bezier(0.4, 0, 1, 1);\n    --ease-out: cubic-bezier(0, 0, 0.2, 1);\n    --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-transition-duration: 150ms;\n    --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-font-family: var(--font-sans);\n    --default-mono-font-family: var(--font-mono);\n    --radius: 8px;\n    --color-background: rgb(34, 34, 34);\n    --color-primary: rgb(247, 247, 247);\n    --color-secondary: rgba(215, 218, 224, 0.7);\n    --color-muted: rgba(215, 218, 224, 0.5);\n    --color-accent-300: rgb(110, 231, 133);\n    --color-panel: rgba(255, 255, 255, 0.12);\n    --color-border: rgba(255, 255, 255, 0.16);\n    --color-accent: rgb(88, 193, 66);\n  }\n}\n@layer base {\n  *, ::after, ::before, ::backdrop, ::file-selector-button {\n    box-sizing: border-box;\n    margin: 0;\n    padding: 0;\n    border: 0 solid;\n  }\n  html, :host {\n    line-height: 1.5;\n    -webkit-text-size-adjust: 100%;\n    tab-size: 4;\n    font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\");\n    font-feature-settings: var(--default-font-feature-settings, normal);\n    font-variation-settings: var(--default-font-variation-settings, normal);\n    -webkit-tap-highlight-color: transparent;\n  }\n  hr {\n    height: 0;\n    color: inherit;\n    border-top-width: 1px;\n  }\n  abbr:where([title]) {\n    -webkit-text-decoration: underline dotted;\n    text-decoration: underline dotted;\n  }\n  h1, h2, h3, h4, h5, h6 {\n    font-size: inherit;\n    font-weight: inherit;\n  }\n  a {\n    color: inherit;\n    -webkit-text-decoration: inherit;\n    text-decoration: inherit;\n  }\n  b, strong {\n    font-weight: bolder;\n  }\n  code, kbd, samp, pre {\n    font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace);\n    font-feature-settings: var(--default-mono-font-feature-settings, normal);\n    font-variation-settings: var(--default-mono-font-variation-settings, normal);\n    font-size: 1em;\n  }\n  small {\n    font-size: 80%;\n  }\n  sub, sup {\n    font-size: 75%;\n    line-height: 0;\n    position: relative;\n    vertical-align: baseline;\n  }\n  sub {\n    bottom: -0.25em;\n  }\n  sup {\n    top: -0.5em;\n  }\n  table {\n    text-indent: 0;\n    border-color: inherit;\n    border-collapse: collapse;\n  }\n  :-moz-focusring {\n    outline: auto;\n  }\n  progress {\n    vertical-align: baseline;\n  }\n  summary {\n    display: list-item;\n  }\n  ol, ul, menu {\n    list-style: none;\n  }\n  img, svg, video, canvas, audio, iframe, embed, object {\n    display: block;\n    vertical-align: middle;\n  }\n  img, video {\n    max-width: 100%;\n    height: auto;\n  }\n  button, input, select, optgroup, textarea, ::file-selector-button {\n    font: inherit;\n    font-feature-settings: inherit;\n    font-variation-settings: inherit;\n    letter-spacing: inherit;\n    color: inherit;\n    border-radius: 0;\n    background-color: transparent;\n    opacity: 1;\n  }\n  :where(select:is([multiple], [size])) optgroup {\n    font-weight: bolder;\n  }\n  :where(select:is([multiple], [size])) optgroup option {\n    padding-inline-start: 20px;\n  }\n  ::file-selector-button {\n    margin-inline-end: 4px;\n  }\n  ::placeholder {\n    opacity: 1;\n  }\n  @supports (not (-webkit-appearance: -apple-pay-button))  or (contain-intrinsic-size: 1px) {\n    ::placeholder {\n      color: currentcolor;\n      @supports (color: color-mix(in lab, red, red)) {\n        color: color-mix(in oklab, currentcolor 50%, transparent);\n      }\n    }\n  }\n  textarea {\n    resize: vertical;\n  }\n  ::-webkit-search-decoration {\n    -webkit-appearance: none;\n  }\n  ::-webkit-date-and-time-value {\n    min-height: 1lh;\n    text-align: inherit;\n  }\n  ::-webkit-datetime-edit {\n    display: inline-flex;\n  }\n  ::-webkit-datetime-edit-fields-wrapper {\n    padding: 0;\n  }\n  ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n    padding-block: 0;\n  }\n  ::-webkit-calendar-picker-indicator {\n    line-height: 1;\n  }\n  :-moz-ui-invalid {\n    box-shadow: none;\n  }\n  button, input:where([type=\"button\"], [type=\"reset\"], [type=\"submit\"]), ::file-selector-button {\n    appearance: button;\n  }\n  ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n    height: auto;\n  }\n  [hidden]:where(:not([hidden=\"until-found\"])) {\n    display: none !important;\n  }\n}\n@layer utilities {\n  .collapse {\n    visibility: collapse;\n  }\n  .invisible {\n    visibility: hidden;\n  }\n  .visible {\n    visibility: visible;\n  }\n  .sr-only {\n    position: absolute;\n    width: 1px;\n    height: 1px;\n    padding: 0;\n    margin: -1px;\n    overflow: hidden;\n    clip-path: inset(50%);\n    white-space: nowrap;\n    border-width: 0;\n  }\n  .not-sr-only {\n    position: static;\n    width: auto;\n    height: auto;\n    padding: 0;\n    margin: 0;\n    overflow: visible;\n    clip-path: none;\n    white-space: normal;\n  }\n  .absolute {\n    position: absolute;\n  }\n  .fixed {\n    position: fixed;\n  }\n  .relative {\n    position: relative;\n  }\n  .static {\n    position: static;\n  }\n  .sticky {\n    position: sticky;\n  }\n  .isolate {\n    isolation: isolate;\n  }\n  .isolation-auto {\n    isolation: auto;\n  }\n  .container {\n    width: 100%;\n    @media (width >= 40rem) {\n      max-width: 40rem;\n    }\n    @media (width >= 48rem) {\n      max-width: 48rem;\n    }\n    @media (width >= 64rem) {\n      max-width: 64rem;\n    }\n    @media (width >= 80rem) {\n      max-width: 80rem;\n    }\n    @media (width >= 96rem) {\n      max-width: 96rem;\n    }\n  }\n  .mx-auto {\n    margin-inline: auto;\n  }\n  .my-6 {\n    margin-block: calc(var(--spacing) * 6);\n  }\n  .mt-1 {\n    margin-top: calc(var(--spacing) * 1);\n  }\n  .mt-3 {\n    margin-top: calc(var(--spacing) * 3);\n  }\n  .mt-4 {\n    margin-top: calc(var(--spacing) * 4);\n  }\n  .mt-5 {\n    margin-top: calc(var(--spacing) * 5);\n  }\n  .mt-6 {\n    margin-top: calc(var(--spacing) * 6);\n  }\n  .mt-8 {\n    margin-top: calc(var(--spacing) * 8);\n  }\n  .mb-2 {\n    margin-bottom: calc(var(--spacing) * 2);\n  }\n  .mb-3 {\n    margin-bottom: calc(var(--spacing) * 3);\n  }\n  .mb-4 {\n    margin-bottom: calc(var(--spacing) * 4);\n  }\n  .mb-6 {\n    margin-bottom: calc(var(--spacing) * 6);\n  }\n  .mb-8 {\n    margin-bottom: calc(var(--spacing) * 8);\n  }\n  .ml-2 {\n    margin-left: calc(var(--spacing) * 2);\n  }\n  .ml-4 {\n    margin-left: calc(var(--spacing) * 4);\n  }\n  .block {\n    display: block;\n  }\n  .contents {\n    display: contents;\n  }\n  .flex {\n    display: flex;\n  }\n  .flow-root {\n    display: flow-root;\n  }\n  .grid {\n    display: grid;\n  }\n  .hidden {\n    display: none;\n  }\n  .inline {\n    display: inline;\n  }\n  .inline-block {\n    display: inline-block;\n  }\n  .inline-flex {\n    display: inline-flex;\n  }\n  .inline-grid {\n    display: inline-grid;\n  }\n  .inline-table {\n    display: inline-table;\n  }\n  .list-item {\n    display: list-item;\n  }\n  .table {\n    display: table;\n  }\n  .table-caption {\n    display: table-caption;\n  }\n  .table-cell {\n    display: table-cell;\n  }\n  .table-column {\n    display: table-column;\n  }\n  .table-column-group {\n    display: table-column-group;\n  }\n  .table-footer-group {\n    display: table-footer-group;\n  }\n  .table-header-group {\n    display: table-header-group;\n  }\n  .table-row {\n    display: table-row;\n  }\n  .table-row-group {\n    display: table-row-group;\n  }\n  .h-2 {\n    height: calc(var(--spacing) * 2);\n  }\n  .min-h-full {\n    min-height: 100%;\n  }\n  .min-h-screen {\n    min-height: 100vh;\n  }\n  .w-2 {\n    width: calc(var(--spacing) * 2);\n  }\n  .w-full {\n    width: 100%;\n  }\n  .max-w-6xl {\n    max-width: var(--container-6xl);\n  }\n  .max-w-none {\n    max-width: none;\n  }\n  .min-w-0 {\n    min-width: calc(var(--spacing) * 0);\n  }\n  .min-w-full {\n    min-width: 100%;\n  }\n  .flex-1 {\n    flex: 1;\n  }\n  .shrink {\n    flex-shrink: 1;\n  }\n  .grow {\n    flex-grow: 1;\n  }\n  .border-collapse {\n    border-collapse: collapse;\n  }\n  .translate-none {\n    translate: none;\n  }\n  .scale-3d {\n    scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);\n  }\n  .transform {\n    transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);\n  }\n  .cursor-pointer {\n    cursor: pointer;\n  }\n  .touch-pinch-zoom {\n    --tw-pinch-zoom: pinch-zoom;\n    touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);\n  }\n  .resize {\n    resize: both;\n  }\n  .list-inside {\n    list-style-position: inside;\n  }\n  .list-decimal {\n    list-style-type: decimal;\n  }\n  .list-disc {\n    list-style-type: disc;\n  }\n  .flex-wrap {\n    flex-wrap: wrap;\n  }\n  .items-center {\n    align-items: center;\n  }\n  .items-start {\n    align-items: flex-start;\n  }\n  .justify-between {\n    justify-content: space-between;\n  }\n  .gap-2 {\n    gap: calc(var(--spacing) * 2);\n  }\n  .gap-3 {\n    gap: calc(var(--spacing) * 3);\n  }\n  .gap-4 {\n    gap: calc(var(--spacing) * 4);\n  }\n  .space-y-1 {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 0;\n      margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));\n      margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));\n    }\n  }\n  .space-y-2 {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 0;\n      margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));\n      margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));\n    }\n  }\n  .space-y-4 {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 0;\n      margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));\n      margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));\n    }\n  }\n  .space-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 1;\n    }\n  }\n  .space-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-x-reverse: 1;\n    }\n  }\n  .divide-x {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 0;\n      border-inline-style: var(--tw-border-style);\n      border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));\n      border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));\n    }\n  }\n  .divide-y {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 0;\n      border-bottom-style: var(--tw-border-style);\n      border-top-style: var(--tw-border-style);\n      border-top-width: calc(1px * var(--tw-divide-y-reverse));\n      border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));\n    }\n  }\n  .divide-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 1;\n    }\n  }\n  .truncate {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .overflow-auto {\n    overflow: auto;\n  }\n  .overflow-x-auto {\n    overflow-x: auto;\n  }\n  .rounded {\n    border-radius: var(--radius);\n  }\n  .rounded-full {\n    border-radius: calc(infinity * 1px);\n  }\n  .rounded-lg {\n    border-radius: var(--radius-lg);\n  }\n  .rounded-md {\n    border-radius: var(--radius-md);\n  }\n  .rounded-s {\n    border-start-start-radius: var(--radius);\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-ss {\n    border-start-start-radius: var(--radius);\n  }\n  .rounded-e {\n    border-start-end-radius: var(--radius);\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-se {\n    border-start-end-radius: var(--radius);\n  }\n  .rounded-ee {\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-es {\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-t {\n    border-top-left-radius: var(--radius);\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-l {\n    border-top-left-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-tl {\n    border-top-left-radius: var(--radius);\n  }\n  .rounded-r {\n    border-top-right-radius: var(--radius);\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-tr {\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-b {\n    border-bottom-right-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-br {\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-bl {\n    border-bottom-left-radius: var(--radius);\n  }\n  .border {\n    border-style: var(--tw-border-style);\n    border-width: 1px;\n  }\n  .border-x {\n    border-inline-style: var(--tw-border-style);\n    border-inline-width: 1px;\n  }\n  .border-y {\n    border-block-style: var(--tw-border-style);\n    border-block-width: 1px;\n  }\n  .border-s {\n    border-inline-start-style: var(--tw-border-style);\n    border-inline-start-width: 1px;\n  }\n  .border-e {\n    border-inline-end-style: var(--tw-border-style);\n    border-inline-end-width: 1px;\n  }\n  .border-t {\n    border-top-style: var(--tw-border-style);\n    border-top-width: 1px;\n  }\n  .border-r {\n    border-right-style: var(--tw-border-style);\n    border-right-width: 1px;\n  }\n  .border-b {\n    border-bottom-style: var(--tw-border-style);\n    border-bottom-width: 1px;\n  }\n  .border-l {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 1px;\n  }\n  .border-l-4 {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 4px;\n  }\n  .border-blue-700 {\n    border-color: var(--color-blue-700);\n  }\n  .border-border {\n    border-color: var(--color-border);\n  }\n  .border-gray-700 {\n    border-color: var(--color-gray-700);\n  }\n  .border-red-500 {\n    border-color: var(--color-red-500);\n  }\n  .border-red-700 {\n    border-color: var(--color-red-700);\n  }\n  .bg-background {\n    background-color: var(--color-background);\n  }\n  .bg-blue-600 {\n    background-color: var(--color-blue-600);\n  }\n  .bg-blue-900 {\n    background-color: var(--color-blue-900);\n  }\n  .bg-gray-600 {\n    background-color: var(--color-gray-600);\n  }\n  .bg-gray-800 {\n    background-color: var(--color-gray-800);\n  }\n  .bg-gray-900 {\n    background-color: var(--color-gray-900);\n  }\n  .bg-green-500 {\n    background-color: var(--color-green-500);\n  }\n  .bg-panel {\n    background-color: var(--color-panel);\n  }\n  .bg-red-100 {\n    background-color: var(--color-red-100);\n  }\n  .bg-red-500 {\n    background-color: var(--color-red-500);\n  }\n  .bg-red-900 {\n    background-color: var(--color-red-900);\n  }\n  .bg-repeat {\n    background-repeat: repeat;\n  }\n  .mask-no-clip {\n    mask-clip: no-clip;\n  }\n  .mask-repeat {\n    mask-repeat: repeat;\n  }\n  .p-4 {\n    padding: calc(var(--spacing) * 4);\n  }\n  .p-6 {\n    padding: calc(var(--spacing) * 6);\n  }\n  .px-1 {\n    padding-inline: calc(var(--spacing) * 1);\n  }\n  .px-4 {\n    padding-inline: calc(var(--spacing) * 4);\n  }\n  .py-0\\.5 {\n    padding-block: calc(var(--spacing) * 0.5);\n  }\n  .py-2 {\n    padding-block: calc(var(--spacing) * 2);\n  }\n  .py-8 {\n    padding-block: calc(var(--spacing) * 8);\n  }\n  .pl-4 {\n    padding-left: calc(var(--spacing) * 4);\n  }\n  .text-center {\n    text-align: center;\n  }\n  .text-left {\n    text-align: left;\n  }\n  .font-mono {\n    font-family: var(--font-mono);\n  }\n  .text-2xl {\n    font-size: var(--text-2xl);\n    line-height: var(--tw-leading, var(--text-2xl--line-height));\n  }\n  .text-3xl {\n    font-size: var(--text-3xl);\n    line-height: var(--tw-leading, var(--text-3xl--line-height));\n  }\n  .text-base {\n    font-size: var(--text-base);\n    line-height: var(--tw-leading, var(--text-base--line-height));\n  }\n  .text-lg {\n    font-size: var(--text-lg);\n    line-height: var(--tw-leading, var(--text-lg--line-height));\n  }\n  .text-sm {\n    font-size: var(--text-sm);\n    line-height: var(--tw-leading, var(--text-sm--line-height));\n  }\n  .text-xl {\n    font-size: var(--text-xl);\n    line-height: var(--tw-leading, var(--text-xl--line-height));\n  }\n  .leading-relaxed {\n    --tw-leading: var(--leading-relaxed);\n    line-height: var(--leading-relaxed);\n  }\n  .font-bold {\n    --tw-font-weight: var(--font-weight-bold);\n    font-weight: var(--font-weight-bold);\n  }\n  .font-medium {\n    --tw-font-weight: var(--font-weight-medium);\n    font-weight: var(--font-weight-medium);\n  }\n  .font-semibold {\n    --tw-font-weight: var(--font-weight-semibold);\n    font-weight: var(--font-weight-semibold);\n  }\n  .text-wrap {\n    text-wrap: wrap;\n  }\n  .text-clip {\n    text-overflow: clip;\n  }\n  .text-ellipsis {\n    text-overflow: ellipsis;\n  }\n  .text-accent {\n    color: var(--color-accent);\n  }\n  .text-blue-100 {\n    color: var(--color-blue-100);\n  }\n  .text-blue-200 {\n    color: var(--color-blue-200);\n  }\n  .text-blue-400 {\n    color: var(--color-blue-400);\n  }\n  .text-gray-300 {\n    color: var(--color-gray-300);\n  }\n  .text-gray-400 {\n    color: var(--color-gray-400);\n  }\n  .text-green-400 {\n    color: var(--color-green-400);\n  }\n  .text-muted {\n    color: var(--color-muted);\n  }\n  .text-primary {\n    color: var(--color-primary);\n  }\n  .text-red-100 {\n    color: var(--color-red-100);\n  }\n  .text-red-200 {\n    color: var(--color-red-200);\n  }\n  .text-red-400 {\n    color: var(--color-red-400);\n  }\n  .text-red-800 {\n    color: var(--color-red-800);\n  }\n  .text-secondary {\n    color: var(--color-secondary);\n  }\n  .text-white {\n    color: var(--color-white);\n  }\n  .text-yellow-400 {\n    color: var(--color-yellow-400);\n  }\n  .capitalize {\n    text-transform: capitalize;\n  }\n  .lowercase {\n    text-transform: lowercase;\n  }\n  .normal-case {\n    text-transform: none;\n  }\n  .uppercase {\n    text-transform: uppercase;\n  }\n  .italic {\n    font-style: italic;\n  }\n  .not-italic {\n    font-style: normal;\n  }\n  .diagonal-fractions {\n    --tw-numeric-fraction: diagonal-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .lining-nums {\n    --tw-numeric-figure: lining-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .oldstyle-nums {\n    --tw-numeric-figure: oldstyle-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .ordinal {\n    --tw-ordinal: ordinal;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .proportional-nums {\n    --tw-numeric-spacing: proportional-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .slashed-zero {\n    --tw-slashed-zero: slashed-zero;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .stacked-fractions {\n    --tw-numeric-fraction: stacked-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .tabular-nums {\n    --tw-numeric-spacing: tabular-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .normal-nums {\n    font-variant-numeric: normal;\n  }\n  .line-through {\n    text-decoration-line: line-through;\n  }\n  .no-underline {\n    text-decoration-line: none;\n  }\n  .overline {\n    text-decoration-line: overline;\n  }\n  .underline {\n    text-decoration-line: underline;\n  }\n  .antialiased {\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n  .subpixel-antialiased {\n    -webkit-font-smoothing: auto;\n    -moz-osx-font-smoothing: auto;\n  }\n  .shadow {\n    --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .inset-ring {\n    --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor);\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .blur {\n    --tw-blur: blur(8px);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .drop-shadow {\n    --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));\n    --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .invert {\n    --tw-invert: invert(100%);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .filter {\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .backdrop-blur {\n    --tw-backdrop-blur: blur(8px);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-grayscale {\n    --tw-backdrop-grayscale: grayscale(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-invert {\n    --tw-backdrop-invert: invert(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-sepia {\n    --tw-backdrop-sepia: sepia(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-filter {\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .transition-colors {\n    transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;\n    transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));\n    transition-duration: var(--tw-duration, var(--default-transition-duration));\n  }\n  .ease-in {\n    --tw-ease: var(--ease-in);\n    transition-timing-function: var(--ease-in);\n  }\n  .ease-in-out {\n    --tw-ease: var(--ease-in-out);\n    transition-timing-function: var(--ease-in-out);\n  }\n  .ease-out {\n    --tw-ease: var(--ease-out);\n    transition-timing-function: var(--ease-out);\n  }\n  .divide-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 1;\n    }\n  }\n  .ring-inset {\n    --tw-ring-inset: inset;\n  }\n  .hover\\:border-gray-600 {\n    &:hover {\n      @media (hover: hover) {\n        border-color: var(--color-gray-600);\n      }\n    }\n  }\n  .hover\\:bg-blue-700 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-blue-700);\n      }\n    }\n  }\n  .hover\\:text-accent-300 {\n    &:hover {\n      @media (hover: hover) {\n        color: var(--color-accent-300);\n      }\n    }\n  }\n  .hover\\:text-blue-300 {\n    &:hover {\n      @media (hover: hover) {\n        color: var(--color-blue-300);\n      }\n    }\n  }\n}\n@property --tw-scale-x {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-y {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-z {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-rotate-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-z {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pinch-zoom {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-space-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-space-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-divide-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-border-style {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: solid;\n}\n@property --tw-divide-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-leading {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-font-weight {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ordinal {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-slashed-zero {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-figure {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-spacing {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-fraction {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-inset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-offset-width {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ease {\n  syntax: \"*\";\n  inherits: false;\n}\n@layer properties {\n  @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n    *, ::before, ::after, ::backdrop {\n      --tw-scale-x: 1;\n      --tw-scale-y: 1;\n      --tw-scale-z: 1;\n      --tw-rotate-x: initial;\n      --tw-rotate-y: initial;\n      --tw-rotate-z: initial;\n      --tw-skew-x: initial;\n      --tw-skew-y: initial;\n      --tw-pan-x: initial;\n      --tw-pan-y: initial;\n      --tw-pinch-zoom: initial;\n      --tw-space-y-reverse: 0;\n      --tw-space-x-reverse: 0;\n      --tw-divide-x-reverse: 0;\n      --tw-border-style: solid;\n      --tw-divide-y-reverse: 0;\n      --tw-leading: initial;\n      --tw-font-weight: initial;\n      --tw-ordinal: initial;\n      --tw-slashed-zero: initial;\n      --tw-numeric-figure: initial;\n      --tw-numeric-spacing: initial;\n      --tw-numeric-fraction: initial;\n      --tw-shadow: 0 0 #0000;\n      --tw-shadow-color: initial;\n      --tw-shadow-alpha: 100%;\n      --tw-inset-shadow: 0 0 #0000;\n      --tw-inset-shadow-color: initial;\n      --tw-inset-shadow-alpha: 100%;\n      --tw-ring-color: initial;\n      --tw-ring-shadow: 0 0 #0000;\n      --tw-inset-ring-color: initial;\n      --tw-inset-ring-shadow: 0 0 #0000;\n      --tw-ring-inset: initial;\n      --tw-ring-offset-width: 0px;\n      --tw-ring-offset-color: #fff;\n      --tw-ring-offset-shadow: 0 0 #0000;\n      --tw-blur: initial;\n      --tw-brightness: initial;\n      --tw-contrast: initial;\n      --tw-grayscale: initial;\n      --tw-hue-rotate: initial;\n      --tw-invert: initial;\n      --tw-opacity: initial;\n      --tw-saturate: initial;\n      --tw-sepia: initial;\n      --tw-drop-shadow: initial;\n      --tw-drop-shadow-color: initial;\n      --tw-drop-shadow-alpha: 100%;\n      --tw-drop-shadow-size: initial;\n      --tw-backdrop-blur: initial;\n      --tw-backdrop-brightness: initial;\n      --tw-backdrop-contrast: initial;\n      --tw-backdrop-grayscale: initial;\n      --tw-backdrop-hue-rotate: initial;\n      --tw-backdrop-invert: initial;\n      --tw-backdrop-opacity: initial;\n      --tw-backdrop-saturate: initial;\n      --tw-backdrop-sepia: initial;\n      --tw-ease: initial;\n    }\n  }\n}\n"
  },
  {
    "path": "tsunami/demo/modaltest/app.go",
    "content": "package main\n\nimport (\n\t\"github.com/wavetermdev/waveterm/tsunami/app\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\nvar AppMeta = app.AppMeta{\n\tTitle:     \"Modal Test (Tsunami Demo)\",\n\tShortDesc: \"Test alert and confirm modals in Tsunami\",\n}\n\nvar App = app.DefineComponent(\"App\", func(_ struct{}) any {\n\t// State to track modal results\n\talertResult := app.UseLocal(\"\")\n\tconfirmResult := app.UseLocal(\"\")\n\n\t// Hook for alert modal\n\talertOpen, triggerAlert := app.UseAlertModal()\n\n\t// Hook for confirm modal\n\tconfirmOpen, triggerConfirm := app.UseConfirmModal()\n\n\t// Event handlers for alert\n\thandleShowAlert := func() {\n\t\ttriggerAlert(app.ModalConfig{\n\t\t\tIcon:  \"⚠️\",\n\t\t\tTitle: \"Alert Message\",\n\t\t\tText:  \"This is an alert modal. Click OK to dismiss.\",\n\t\t\tOnClose: func() {\n\t\t\t\talertResult.Set(\"Alert dismissed\")\n\t\t\t},\n\t\t})\n\t}\n\n\thandleShowAlertSimple := func() {\n\t\ttriggerAlert(app.ModalConfig{\n\t\t\tTitle: \"Simple Alert\",\n\t\t\tText:  \"This alert has no icon and custom OK text.\",\n\t\t\tOkText: \"Got it!\",\n\t\t\tOnClose: func() {\n\t\t\t\talertResult.Set(\"Simple alert dismissed\")\n\t\t\t},\n\t\t})\n\t}\n\n\t// Event handlers for confirm\n\thandleShowConfirm := func() {\n\t\ttriggerConfirm(app.ModalConfig{\n\t\t\tIcon:  \"❓\",\n\t\t\tTitle: \"Confirm Action\",\n\t\t\tText:  \"Do you want to proceed with this action?\",\n\t\t\tOnResult: func(confirmed bool) {\n\t\t\t\tif confirmed {\n\t\t\t\t\tconfirmResult.Set(\"User confirmed the action\")\n\t\t\t\t} else {\n\t\t\t\t\tconfirmResult.Set(\"User cancelled the action\")\n\t\t\t\t}\n\t\t\t},\n\t\t})\n\t}\n\n\thandleShowConfirmCustom := func() {\n\t\ttriggerConfirm(app.ModalConfig{\n\t\t\tIcon:       \"🗑️\",\n\t\t\tTitle:      \"Delete Item\",\n\t\t\tText:       \"Are you sure you want to delete this item? This action cannot be undone.\",\n\t\t\tOkText:     \"Delete\",\n\t\t\tCancelText: \"Keep\",\n\t\t\tOnResult: func(confirmed bool) {\n\t\t\t\tif confirmed {\n\t\t\t\t\tconfirmResult.Set(\"Item deleted\")\n\t\t\t\t} else {\n\t\t\t\t\tconfirmResult.Set(\"Item kept\")\n\t\t\t\t}\n\t\t\t},\n\t\t})\n\t}\n\n\t// Read state values\n\tcurrentAlertResult := alertResult.Get()\n\tcurrentConfirmResult := confirmResult.Get()\n\n\treturn vdom.H(\"div\", map[string]any{\n\t\t\"className\": \"max-w-4xl mx-auto p-8\",\n\t},\n\t\tvdom.H(\"h1\", map[string]any{\n\t\t\t\"className\": \"text-3xl font-bold mb-6 text-white\",\n\t\t}, \"Tsunami Modal Test\"),\n\n\t\t// Alert Modal Section\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"mb-8 p-6 bg-gray-800 rounded-lg border border-gray-700\",\n\t\t},\n\t\t\tvdom.H(\"h2\", map[string]any{\n\t\t\t\t\"className\": \"text-2xl font-semibold mb-4 text-white\",\n\t\t\t}, \"Alert Modals\"),\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"flex gap-4 mb-4\",\n\t\t\t},\n\t\t\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\t\t\"className\": \"px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\",\n\t\t\t\t\t\"onClick\":   handleShowAlert,\n\t\t\t\t\t\"disabled\":  alertOpen,\n\t\t\t\t}, \"Show Alert with Icon\"),\n\t\t\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\t\t\"className\": \"px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\",\n\t\t\t\t\t\"onClick\":   handleShowAlertSimple,\n\t\t\t\t\t\"disabled\":  alertOpen,\n\t\t\t\t}, \"Show Simple Alert\"),\n\t\t\t),\n\t\t\tvdom.If(currentAlertResult != \"\", vdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"mt-4 p-3 bg-gray-700 rounded text-gray-200\",\n\t\t\t}, \"Result: \", currentAlertResult)),\n\t\t),\n\n\t\t// Confirm Modal Section\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"mb-8 p-6 bg-gray-800 rounded-lg border border-gray-700\",\n\t\t},\n\t\t\tvdom.H(\"h2\", map[string]any{\n\t\t\t\t\"className\": \"text-2xl font-semibold mb-4 text-white\",\n\t\t\t}, \"Confirm Modals\"),\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"flex gap-4 mb-4\",\n\t\t\t},\n\t\t\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\t\t\"className\": \"px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed\",\n\t\t\t\t\t\"onClick\":   handleShowConfirm,\n\t\t\t\t\t\"disabled\":  confirmOpen,\n\t\t\t\t}, \"Show Confirm Modal\"),\n\t\t\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\t\t\"className\": \"px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed\",\n\t\t\t\t\t\"onClick\":   handleShowConfirmCustom,\n\t\t\t\t\t\"disabled\":  confirmOpen,\n\t\t\t\t}, \"Show Delete Confirm\"),\n\t\t\t),\n\t\t\tvdom.If(currentConfirmResult != \"\", vdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"mt-4 p-3 bg-gray-700 rounded text-gray-200\",\n\t\t\t}, \"Result: \", currentConfirmResult)),\n\t\t),\n\n\t\t// Status info\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"p-6 bg-gray-800 rounded-lg border border-gray-700\",\n\t\t},\n\t\t\tvdom.H(\"h2\", map[string]any{\n\t\t\t\t\"className\": \"text-2xl font-semibold mb-4 text-white\",\n\t\t\t}, \"Modal Status\"),\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"text-gray-300\",\n\t\t\t},\n\t\t\t\tvdom.H(\"div\", nil, \"Alert Modal Open: \", vdom.IfElse(alertOpen, \"Yes\", \"No\")),\n\t\t\t\tvdom.H(\"div\", nil, \"Confirm Modal Open: \", vdom.IfElse(confirmOpen, \"Yes\", \"No\")),\n\t\t\t),\n\t\t),\n\t)\n})\n"
  },
  {
    "path": "tsunami/demo/modaltest/go.mod",
    "content": "module github.com/wavetermdev/waveterm/tsunami/demo/modaltest\n\ngo 1.25.6\n\nrequire github.com/wavetermdev/waveterm/tsunami v0.0.0-00010101000000-000000000000\n\nrequire (\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/outrigdev/goid v0.3.0 // indirect\n)\n\nreplace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami\n"
  },
  {
    "path": "tsunami/demo/modaltest/go.sum",
    "content": "github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=\ngithub.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=\n"
  },
  {
    "path": "tsunami/demo/modaltest/static/tw.css",
    "content": "/*! tailwindcss v4.1.16 | MIT License | https://tailwindcss.com */\n@layer properties;\n@layer theme, base, components, utilities;\n@layer theme {\n  :root, :host {\n    --font-sans: \"Inter\", sans-serif;\n    --font-mono: \"Hack\", monospace;\n    --color-red-100: oklch(93.6% 0.032 17.717);\n    --color-red-500: oklch(63.7% 0.237 25.331);\n    --color-red-600: oklch(57.7% 0.245 27.325);\n    --color-red-700: oklch(50.5% 0.213 27.518);\n    --color-red-800: oklch(44.4% 0.177 26.899);\n    --color-green-600: oklch(62.7% 0.194 149.214);\n    --color-green-700: oklch(52.7% 0.154 150.069);\n    --color-blue-500: oklch(62.3% 0.214 259.815);\n    --color-blue-600: oklch(54.6% 0.245 262.881);\n    --color-blue-700: oklch(48.8% 0.243 264.376);\n    --color-gray-200: oklch(92.8% 0.006 264.531);\n    --color-gray-300: oklch(87.2% 0.01 258.338);\n    --color-gray-500: oklch(55.1% 0.027 264.364);\n    --color-gray-600: oklch(44.6% 0.03 256.802);\n    --color-gray-700: oklch(37.3% 0.034 259.733);\n    --color-gray-800: oklch(27.8% 0.033 256.848);\n    --color-black: #000;\n    --color-white: #fff;\n    --spacing: 0.25rem;\n    --container-md: 28rem;\n    --container-4xl: 56rem;\n    --text-sm: 0.875rem;\n    --text-sm--line-height: calc(1.25 / 0.875);\n    --text-base: 1rem;\n    --text-base--line-height: calc(1.5 / 1);\n    --text-lg: 1.125rem;\n    --text-lg--line-height: calc(1.75 / 1.125);\n    --text-xl: 1.25rem;\n    --text-xl--line-height: calc(1.75 / 1.25);\n    --text-2xl: 1.5rem;\n    --text-2xl--line-height: calc(2 / 1.5);\n    --text-3xl: 1.875rem;\n    --text-3xl--line-height: calc(2.25 / 1.875);\n    --text-4xl: 2.25rem;\n    --text-4xl--line-height: calc(2.5 / 2.25);\n    --font-weight-semibold: 600;\n    --font-weight-bold: 700;\n    --leading-relaxed: 1.625;\n    --radius-lg: 0.5rem;\n    --ease-in: cubic-bezier(0.4, 0, 1, 1);\n    --ease-out: cubic-bezier(0, 0, 0.2, 1);\n    --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-font-family: var(--font-sans);\n    --default-mono-font-family: var(--font-mono);\n    --radius: 8px;\n    --color-background: rgb(34, 34, 34);\n    --color-primary: rgb(247, 247, 247);\n    --color-secondary: rgba(215, 218, 224, 0.7);\n    --color-muted: rgba(215, 218, 224, 0.5);\n    --color-accent-300: rgb(110, 231, 133);\n    --color-panel: rgba(255, 255, 255, 0.12);\n    --color-border: rgba(255, 255, 255, 0.16);\n    --color-accent: rgb(88, 193, 66);\n  }\n}\n@layer base {\n  *, ::after, ::before, ::backdrop, ::file-selector-button {\n    box-sizing: border-box;\n    margin: 0;\n    padding: 0;\n    border: 0 solid;\n  }\n  html, :host {\n    line-height: 1.5;\n    -webkit-text-size-adjust: 100%;\n    tab-size: 4;\n    font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\");\n    font-feature-settings: var(--default-font-feature-settings, normal);\n    font-variation-settings: var(--default-font-variation-settings, normal);\n    -webkit-tap-highlight-color: transparent;\n  }\n  hr {\n    height: 0;\n    color: inherit;\n    border-top-width: 1px;\n  }\n  abbr:where([title]) {\n    -webkit-text-decoration: underline dotted;\n    text-decoration: underline dotted;\n  }\n  h1, h2, h3, h4, h5, h6 {\n    font-size: inherit;\n    font-weight: inherit;\n  }\n  a {\n    color: inherit;\n    -webkit-text-decoration: inherit;\n    text-decoration: inherit;\n  }\n  b, strong {\n    font-weight: bolder;\n  }\n  code, kbd, samp, pre {\n    font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace);\n    font-feature-settings: var(--default-mono-font-feature-settings, normal);\n    font-variation-settings: var(--default-mono-font-variation-settings, normal);\n    font-size: 1em;\n  }\n  small {\n    font-size: 80%;\n  }\n  sub, sup {\n    font-size: 75%;\n    line-height: 0;\n    position: relative;\n    vertical-align: baseline;\n  }\n  sub {\n    bottom: -0.25em;\n  }\n  sup {\n    top: -0.5em;\n  }\n  table {\n    text-indent: 0;\n    border-color: inherit;\n    border-collapse: collapse;\n  }\n  :-moz-focusring {\n    outline: auto;\n  }\n  progress {\n    vertical-align: baseline;\n  }\n  summary {\n    display: list-item;\n  }\n  ol, ul, menu {\n    list-style: none;\n  }\n  img, svg, video, canvas, audio, iframe, embed, object {\n    display: block;\n    vertical-align: middle;\n  }\n  img, video {\n    max-width: 100%;\n    height: auto;\n  }\n  button, input, select, optgroup, textarea, ::file-selector-button {\n    font: inherit;\n    font-feature-settings: inherit;\n    font-variation-settings: inherit;\n    letter-spacing: inherit;\n    color: inherit;\n    border-radius: 0;\n    background-color: transparent;\n    opacity: 1;\n  }\n  :where(select:is([multiple], [size])) optgroup {\n    font-weight: bolder;\n  }\n  :where(select:is([multiple], [size])) optgroup option {\n    padding-inline-start: 20px;\n  }\n  ::file-selector-button {\n    margin-inline-end: 4px;\n  }\n  ::placeholder {\n    opacity: 1;\n  }\n  @supports (not (-webkit-appearance: -apple-pay-button))  or (contain-intrinsic-size: 1px) {\n    ::placeholder {\n      color: currentcolor;\n      @supports (color: color-mix(in lab, red, red)) {\n        color: color-mix(in oklab, currentcolor 50%, transparent);\n      }\n    }\n  }\n  textarea {\n    resize: vertical;\n  }\n  ::-webkit-search-decoration {\n    -webkit-appearance: none;\n  }\n  ::-webkit-date-and-time-value {\n    min-height: 1lh;\n    text-align: inherit;\n  }\n  ::-webkit-datetime-edit {\n    display: inline-flex;\n  }\n  ::-webkit-datetime-edit-fields-wrapper {\n    padding: 0;\n  }\n  ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n    padding-block: 0;\n  }\n  ::-webkit-calendar-picker-indicator {\n    line-height: 1;\n  }\n  :-moz-ui-invalid {\n    box-shadow: none;\n  }\n  button, input:where([type=\"button\"], [type=\"reset\"], [type=\"submit\"]), ::file-selector-button {\n    appearance: button;\n  }\n  ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n    height: auto;\n  }\n  [hidden]:where(:not([hidden=\"until-found\"])) {\n    display: none !important;\n  }\n}\n@layer utilities {\n  .collapse {\n    visibility: collapse;\n  }\n  .invisible {\n    visibility: hidden;\n  }\n  .visible {\n    visibility: visible;\n  }\n  .sr-only {\n    position: absolute;\n    width: 1px;\n    height: 1px;\n    padding: 0;\n    margin: -1px;\n    overflow: hidden;\n    clip-path: inset(50%);\n    white-space: nowrap;\n    border-width: 0;\n  }\n  .not-sr-only {\n    position: static;\n    width: auto;\n    height: auto;\n    padding: 0;\n    margin: 0;\n    overflow: visible;\n    clip-path: none;\n    white-space: normal;\n  }\n  .absolute {\n    position: absolute;\n  }\n  .fixed {\n    position: fixed;\n  }\n  .relative {\n    position: relative;\n  }\n  .static {\n    position: static;\n  }\n  .sticky {\n    position: sticky;\n  }\n  .inset-0 {\n    inset: calc(var(--spacing) * 0);\n  }\n  .isolate {\n    isolation: isolate;\n  }\n  .isolation-auto {\n    isolation: auto;\n  }\n  .z-50 {\n    z-index: 50;\n  }\n  .container {\n    width: 100%;\n    @media (width >= 40rem) {\n      max-width: 40rem;\n    }\n    @media (width >= 48rem) {\n      max-width: 48rem;\n    }\n    @media (width >= 64rem) {\n      max-width: 64rem;\n    }\n    @media (width >= 80rem) {\n      max-width: 80rem;\n    }\n    @media (width >= 96rem) {\n      max-width: 96rem;\n    }\n  }\n  .mx-4 {\n    margin-inline: calc(var(--spacing) * 4);\n  }\n  .mx-auto {\n    margin-inline: auto;\n  }\n  .my-6 {\n    margin-block: calc(var(--spacing) * 6);\n  }\n  .mt-2 {\n    margin-top: calc(var(--spacing) * 2);\n  }\n  .mt-3 {\n    margin-top: calc(var(--spacing) * 3);\n  }\n  .mt-4 {\n    margin-top: calc(var(--spacing) * 4);\n  }\n  .mt-5 {\n    margin-top: calc(var(--spacing) * 5);\n  }\n  .mt-6 {\n    margin-top: calc(var(--spacing) * 6);\n  }\n  .mb-2 {\n    margin-bottom: calc(var(--spacing) * 2);\n  }\n  .mb-3 {\n    margin-bottom: calc(var(--spacing) * 3);\n  }\n  .mb-4 {\n    margin-bottom: calc(var(--spacing) * 4);\n  }\n  .mb-6 {\n    margin-bottom: calc(var(--spacing) * 6);\n  }\n  .mb-8 {\n    margin-bottom: calc(var(--spacing) * 8);\n  }\n  .ml-4 {\n    margin-left: calc(var(--spacing) * 4);\n  }\n  .block {\n    display: block;\n  }\n  .contents {\n    display: contents;\n  }\n  .flex {\n    display: flex;\n  }\n  .flow-root {\n    display: flow-root;\n  }\n  .grid {\n    display: grid;\n  }\n  .hidden {\n    display: none;\n  }\n  .inline {\n    display: inline;\n  }\n  .inline-block {\n    display: inline-block;\n  }\n  .inline-flex {\n    display: inline-flex;\n  }\n  .inline-grid {\n    display: inline-grid;\n  }\n  .inline-table {\n    display: inline-table;\n  }\n  .list-item {\n    display: list-item;\n  }\n  .table {\n    display: table;\n  }\n  .table-caption {\n    display: table-caption;\n  }\n  .table-cell {\n    display: table-cell;\n  }\n  .table-column {\n    display: table-column;\n  }\n  .table-column-group {\n    display: table-column-group;\n  }\n  .table-footer-group {\n    display: table-footer-group;\n  }\n  .table-header-group {\n    display: table-header-group;\n  }\n  .table-row {\n    display: table-row;\n  }\n  .table-row-group {\n    display: table-row-group;\n  }\n  .min-h-full {\n    min-height: 100%;\n  }\n  .min-h-screen {\n    min-height: 100vh;\n  }\n  .w-full {\n    width: 100%;\n  }\n  .max-w-4xl {\n    max-width: var(--container-4xl);\n  }\n  .max-w-md {\n    max-width: var(--container-md);\n  }\n  .max-w-none {\n    max-width: none;\n  }\n  .min-w-full {\n    min-width: 100%;\n  }\n  .shrink {\n    flex-shrink: 1;\n  }\n  .grow {\n    flex-grow: 1;\n  }\n  .border-collapse {\n    border-collapse: collapse;\n  }\n  .translate-none {\n    translate: none;\n  }\n  .scale-3d {\n    scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);\n  }\n  .transform {\n    transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);\n  }\n  .touch-pinch-zoom {\n    --tw-pinch-zoom: pinch-zoom;\n    touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);\n  }\n  .resize {\n    resize: both;\n  }\n  .list-inside {\n    list-style-position: inside;\n  }\n  .list-decimal {\n    list-style-type: decimal;\n  }\n  .list-disc {\n    list-style-type: disc;\n  }\n  .flex-col {\n    flex-direction: column;\n  }\n  .flex-wrap {\n    flex-wrap: wrap;\n  }\n  .items-center {\n    align-items: center;\n  }\n  .justify-center {\n    justify-content: center;\n  }\n  .justify-end {\n    justify-content: flex-end;\n  }\n  .gap-3 {\n    gap: calc(var(--spacing) * 3);\n  }\n  .gap-4 {\n    gap: calc(var(--spacing) * 4);\n  }\n  .space-y-1 {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 0;\n      margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));\n      margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));\n    }\n  }\n  .space-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 1;\n    }\n  }\n  .space-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-x-reverse: 1;\n    }\n  }\n  .divide-x {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 0;\n      border-inline-style: var(--tw-border-style);\n      border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));\n      border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));\n    }\n  }\n  .divide-y {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 0;\n      border-bottom-style: var(--tw-border-style);\n      border-top-style: var(--tw-border-style);\n      border-top-width: calc(1px * var(--tw-divide-y-reverse));\n      border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));\n    }\n  }\n  .divide-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 1;\n    }\n  }\n  .truncate {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .overflow-auto {\n    overflow: auto;\n  }\n  .overflow-x-auto {\n    overflow-x: auto;\n  }\n  .rounded {\n    border-radius: var(--radius);\n  }\n  .rounded-lg {\n    border-radius: var(--radius-lg);\n  }\n  .rounded-s {\n    border-start-start-radius: var(--radius);\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-ss {\n    border-start-start-radius: var(--radius);\n  }\n  .rounded-e {\n    border-start-end-radius: var(--radius);\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-se {\n    border-start-end-radius: var(--radius);\n  }\n  .rounded-ee {\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-es {\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-t {\n    border-top-left-radius: var(--radius);\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-l {\n    border-top-left-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-tl {\n    border-top-left-radius: var(--radius);\n  }\n  .rounded-r {\n    border-top-right-radius: var(--radius);\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-tr {\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-b {\n    border-bottom-right-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-br {\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-bl {\n    border-bottom-left-radius: var(--radius);\n  }\n  .border {\n    border-style: var(--tw-border-style);\n    border-width: 1px;\n  }\n  .border-x {\n    border-inline-style: var(--tw-border-style);\n    border-inline-width: 1px;\n  }\n  .border-y {\n    border-block-style: var(--tw-border-style);\n    border-block-width: 1px;\n  }\n  .border-s {\n    border-inline-start-style: var(--tw-border-style);\n    border-inline-start-width: 1px;\n  }\n  .border-e {\n    border-inline-end-style: var(--tw-border-style);\n    border-inline-end-width: 1px;\n  }\n  .border-t {\n    border-top-style: var(--tw-border-style);\n    border-top-width: 1px;\n  }\n  .border-r {\n    border-right-style: var(--tw-border-style);\n    border-right-width: 1px;\n  }\n  .border-b {\n    border-bottom-style: var(--tw-border-style);\n    border-bottom-width: 1px;\n  }\n  .border-l {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 1px;\n  }\n  .border-l-4 {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 4px;\n  }\n  .border-border {\n    border-color: var(--color-border);\n  }\n  .border-gray-700 {\n    border-color: var(--color-gray-700);\n  }\n  .border-red-500 {\n    border-color: var(--color-red-500);\n  }\n  .bg-background {\n    background-color: var(--color-background);\n  }\n  .bg-black {\n    background-color: var(--color-black);\n  }\n  .bg-black\\/50 {\n    background-color: color-mix(in srgb, #000 50%, transparent);\n    @supports (color: color-mix(in lab, red, red)) {\n      background-color: color-mix(in oklab, var(--color-black) 50%, transparent);\n    }\n  }\n  .bg-blue-600 {\n    background-color: var(--color-blue-600);\n  }\n  .bg-gray-600 {\n    background-color: var(--color-gray-600);\n  }\n  .bg-gray-700 {\n    background-color: var(--color-gray-700);\n  }\n  .bg-gray-800 {\n    background-color: var(--color-gray-800);\n  }\n  .bg-green-600 {\n    background-color: var(--color-green-600);\n  }\n  .bg-panel {\n    background-color: var(--color-panel);\n  }\n  .bg-red-100 {\n    background-color: var(--color-red-100);\n  }\n  .bg-red-600 {\n    background-color: var(--color-red-600);\n  }\n  .bg-repeat {\n    background-repeat: repeat;\n  }\n  .mask-no-clip {\n    mask-clip: no-clip;\n  }\n  .mask-repeat {\n    mask-repeat: repeat;\n  }\n  .p-3 {\n    padding: calc(var(--spacing) * 3);\n  }\n  .p-4 {\n    padding: calc(var(--spacing) * 4);\n  }\n  .p-6 {\n    padding: calc(var(--spacing) * 6);\n  }\n  .p-8 {\n    padding: calc(var(--spacing) * 8);\n  }\n  .px-1 {\n    padding-inline: calc(var(--spacing) * 1);\n  }\n  .px-4 {\n    padding-inline: calc(var(--spacing) * 4);\n  }\n  .py-0\\.5 {\n    padding-block: calc(var(--spacing) * 0.5);\n  }\n  .py-2 {\n    padding-block: calc(var(--spacing) * 2);\n  }\n  .pl-4 {\n    padding-left: calc(var(--spacing) * 4);\n  }\n  .text-left {\n    text-align: left;\n  }\n  .font-mono {\n    font-family: var(--font-mono);\n  }\n  .text-2xl {\n    font-size: var(--text-2xl);\n    line-height: var(--tw-leading, var(--text-2xl--line-height));\n  }\n  .text-3xl {\n    font-size: var(--text-3xl);\n    line-height: var(--tw-leading, var(--text-3xl--line-height));\n  }\n  .text-4xl {\n    font-size: var(--text-4xl);\n    line-height: var(--tw-leading, var(--text-4xl--line-height));\n  }\n  .text-base {\n    font-size: var(--text-base);\n    line-height: var(--tw-leading, var(--text-base--line-height));\n  }\n  .text-lg {\n    font-size: var(--text-lg);\n    line-height: var(--tw-leading, var(--text-lg--line-height));\n  }\n  .text-sm {\n    font-size: var(--text-sm);\n    line-height: var(--tw-leading, var(--text-sm--line-height));\n  }\n  .text-xl {\n    font-size: var(--text-xl);\n    line-height: var(--tw-leading, var(--text-xl--line-height));\n  }\n  .leading-relaxed {\n    --tw-leading: var(--leading-relaxed);\n    line-height: var(--leading-relaxed);\n  }\n  .font-bold {\n    --tw-font-weight: var(--font-weight-bold);\n    font-weight: var(--font-weight-bold);\n  }\n  .font-semibold {\n    --tw-font-weight: var(--font-weight-semibold);\n    font-weight: var(--font-weight-semibold);\n  }\n  .text-wrap {\n    text-wrap: wrap;\n  }\n  .text-clip {\n    text-overflow: clip;\n  }\n  .text-ellipsis {\n    text-overflow: ellipsis;\n  }\n  .text-accent {\n    color: var(--color-accent);\n  }\n  .text-gray-200 {\n    color: var(--color-gray-200);\n  }\n  .text-gray-300 {\n    color: var(--color-gray-300);\n  }\n  .text-muted {\n    color: var(--color-muted);\n  }\n  .text-primary {\n    color: var(--color-primary);\n  }\n  .text-red-800 {\n    color: var(--color-red-800);\n  }\n  .text-secondary {\n    color: var(--color-secondary);\n  }\n  .text-white {\n    color: var(--color-white);\n  }\n  .capitalize {\n    text-transform: capitalize;\n  }\n  .lowercase {\n    text-transform: lowercase;\n  }\n  .normal-case {\n    text-transform: none;\n  }\n  .uppercase {\n    text-transform: uppercase;\n  }\n  .italic {\n    font-style: italic;\n  }\n  .not-italic {\n    font-style: normal;\n  }\n  .diagonal-fractions {\n    --tw-numeric-fraction: diagonal-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .lining-nums {\n    --tw-numeric-figure: lining-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .oldstyle-nums {\n    --tw-numeric-figure: oldstyle-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .ordinal {\n    --tw-ordinal: ordinal;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .proportional-nums {\n    --tw-numeric-spacing: proportional-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .slashed-zero {\n    --tw-slashed-zero: slashed-zero;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .stacked-fractions {\n    --tw-numeric-fraction: stacked-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .tabular-nums {\n    --tw-numeric-spacing: tabular-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .normal-nums {\n    font-variant-numeric: normal;\n  }\n  .line-through {\n    text-decoration-line: line-through;\n  }\n  .no-underline {\n    text-decoration-line: none;\n  }\n  .overline {\n    text-decoration-line: overline;\n  }\n  .underline {\n    text-decoration-line: underline;\n  }\n  .antialiased {\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n  .subpixel-antialiased {\n    -webkit-font-smoothing: auto;\n    -moz-osx-font-smoothing: auto;\n  }\n  .shadow {\n    --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .shadow-xl {\n    --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .inset-ring {\n    --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor);\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .blur {\n    --tw-blur: blur(8px);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .drop-shadow {\n    --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));\n    --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .invert {\n    --tw-invert: invert(100%);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .filter {\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .backdrop-blur {\n    --tw-backdrop-blur: blur(8px);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-grayscale {\n    --tw-backdrop-grayscale: grayscale(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-invert {\n    --tw-backdrop-invert: invert(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-sepia {\n    --tw-backdrop-sepia: sepia(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-filter {\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .ease-in {\n    --tw-ease: var(--ease-in);\n    transition-timing-function: var(--ease-in);\n  }\n  .ease-in-out {\n    --tw-ease: var(--ease-in-out);\n    transition-timing-function: var(--ease-in-out);\n  }\n  .ease-out {\n    --tw-ease: var(--ease-out);\n    transition-timing-function: var(--ease-out);\n  }\n  .divide-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 1;\n    }\n  }\n  .ring-inset {\n    --tw-ring-inset: inset;\n  }\n  .hover\\:bg-blue-700 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-blue-700);\n      }\n    }\n  }\n  .hover\\:bg-gray-700 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-gray-700);\n      }\n    }\n  }\n  .hover\\:bg-green-700 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-green-700);\n      }\n    }\n  }\n  .hover\\:bg-red-700 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-red-700);\n      }\n    }\n  }\n  .hover\\:text-accent-300 {\n    &:hover {\n      @media (hover: hover) {\n        color: var(--color-accent-300);\n      }\n    }\n  }\n  .focus\\:ring-2 {\n    &:focus {\n      --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n      box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n    }\n  }\n  .focus\\:ring-blue-500 {\n    &:focus {\n      --tw-ring-color: var(--color-blue-500);\n    }\n  }\n  .focus\\:ring-gray-500 {\n    &:focus {\n      --tw-ring-color: var(--color-gray-500);\n    }\n  }\n  .focus\\:outline-none {\n    &:focus {\n      --tw-outline-style: none;\n      outline-style: none;\n    }\n  }\n  .disabled\\:cursor-not-allowed {\n    &:disabled {\n      cursor: not-allowed;\n    }\n  }\n  .disabled\\:opacity-50 {\n    &:disabled {\n      opacity: 50%;\n    }\n  }\n}\n@property --tw-scale-x {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-y {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-z {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-rotate-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-z {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pinch-zoom {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-space-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-space-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-divide-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-border-style {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: solid;\n}\n@property --tw-divide-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-leading {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-font-weight {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ordinal {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-slashed-zero {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-figure {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-spacing {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-fraction {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-inset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-offset-width {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ease {\n  syntax: \"*\";\n  inherits: false;\n}\n@layer properties {\n  @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n    *, ::before, ::after, ::backdrop {\n      --tw-scale-x: 1;\n      --tw-scale-y: 1;\n      --tw-scale-z: 1;\n      --tw-rotate-x: initial;\n      --tw-rotate-y: initial;\n      --tw-rotate-z: initial;\n      --tw-skew-x: initial;\n      --tw-skew-y: initial;\n      --tw-pan-x: initial;\n      --tw-pan-y: initial;\n      --tw-pinch-zoom: initial;\n      --tw-space-y-reverse: 0;\n      --tw-space-x-reverse: 0;\n      --tw-divide-x-reverse: 0;\n      --tw-border-style: solid;\n      --tw-divide-y-reverse: 0;\n      --tw-leading: initial;\n      --tw-font-weight: initial;\n      --tw-ordinal: initial;\n      --tw-slashed-zero: initial;\n      --tw-numeric-figure: initial;\n      --tw-numeric-spacing: initial;\n      --tw-numeric-fraction: initial;\n      --tw-shadow: 0 0 #0000;\n      --tw-shadow-color: initial;\n      --tw-shadow-alpha: 100%;\n      --tw-inset-shadow: 0 0 #0000;\n      --tw-inset-shadow-color: initial;\n      --tw-inset-shadow-alpha: 100%;\n      --tw-ring-color: initial;\n      --tw-ring-shadow: 0 0 #0000;\n      --tw-inset-ring-color: initial;\n      --tw-inset-ring-shadow: 0 0 #0000;\n      --tw-ring-inset: initial;\n      --tw-ring-offset-width: 0px;\n      --tw-ring-offset-color: #fff;\n      --tw-ring-offset-shadow: 0 0 #0000;\n      --tw-blur: initial;\n      --tw-brightness: initial;\n      --tw-contrast: initial;\n      --tw-grayscale: initial;\n      --tw-hue-rotate: initial;\n      --tw-invert: initial;\n      --tw-opacity: initial;\n      --tw-saturate: initial;\n      --tw-sepia: initial;\n      --tw-drop-shadow: initial;\n      --tw-drop-shadow-color: initial;\n      --tw-drop-shadow-alpha: 100%;\n      --tw-drop-shadow-size: initial;\n      --tw-backdrop-blur: initial;\n      --tw-backdrop-brightness: initial;\n      --tw-backdrop-contrast: initial;\n      --tw-backdrop-grayscale: initial;\n      --tw-backdrop-hue-rotate: initial;\n      --tw-backdrop-invert: initial;\n      --tw-backdrop-opacity: initial;\n      --tw-backdrop-saturate: initial;\n      --tw-backdrop-sepia: initial;\n      --tw-ease: initial;\n    }\n  }\n}\n"
  },
  {
    "path": "tsunami/demo/pomodoro/app.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/app\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\nvar AppMeta = app.AppMeta{\n\tTitle:     \"Pomodoro Timer (Tsunami Demo)\",\n\tShortDesc: \"Productivity timer with work and break intervals\",\n}\n\ntype Mode struct {\n\tName     string `json:\"name\"`\n\tDuration int    `json:\"duration\"` // in minutes\n}\n\nvar (\n\tWorkMode  = Mode{Name: \"Work\", Duration: 25}\n\tBreakMode = Mode{Name: \"Break\", Duration: 5}\n\n\t// Data atom to expose remaining seconds to external systems\n\tremainingSecondsAtom = app.DataAtom(\"remainingSeconds\", WorkMode.Duration*60, &app.AtomMeta{\n\t\tDesc:  \"Remaining seconds in current pomodoro timer\",\n\t\tUnits: \"s\",\n\t\tMin:   app.Ptr(0.0),\n\t\tMax:   app.Ptr(3600.0),\n\t})\n)\n\ntype TimerDisplayProps struct {\n\tRemainingSeconds int    `json:\"remainingSeconds\"`\n\tMode             string `json:\"mode\"`\n}\n\ntype ControlButtonsProps struct {\n\tIsRunning bool      `json:\"isRunning\"`\n\tOnStart   func()    `json:\"onStart\"`\n\tOnPause   func()    `json:\"onPause\"`\n\tOnReset   func()    `json:\"onReset\"`\n\tOnMode    func(int) `json:\"onMode\"`\n}\n\nvar TimerDisplay = app.DefineComponent(\"TimerDisplay\",\n\tfunc(props TimerDisplayProps) any {\n\t\tminutes := props.RemainingSeconds / 60\n\t\tseconds := props.RemainingSeconds % 60\n\t\treturn vdom.H(\"div\",\n\t\t\tmap[string]any{\"className\": \"bg-slate-700 p-8 rounded-lg mb-8 text-center\"},\n\t\t\tvdom.H(\"div\",\n\t\t\t\tmap[string]any{\"className\": \"text-xl text-blue-400 mb-2\"},\n\t\t\t\tprops.Mode,\n\t\t\t),\n\t\t\tvdom.H(\"div\",\n\t\t\t\tmap[string]any{\"className\": \"text-6xl font-bold font-mono text-slate-100\"},\n\t\t\t\tfmt.Sprintf(\"%02d:%02d\", minutes, seconds),\n\t\t\t),\n\t\t)\n\t},\n)\n\nvar ControlButtons = app.DefineComponent(\"ControlButtons\",\n\tfunc(props ControlButtonsProps) any {\n\t\treturn vdom.H(\"div\",\n\t\t\tmap[string]any{\"className\": \"flex flex-col gap-4\"},\n\t\t\tvdom.IfElse(props.IsRunning,\n\t\t\t\tvdom.H(\"button\",\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"className\": \"px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200\",\n\t\t\t\t\t\t\"onClick\":   props.OnPause,\n\t\t\t\t\t},\n\t\t\t\t\t\"Pause\",\n\t\t\t\t),\n\t\t\t\tvdom.H(\"button\",\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"className\": \"px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200\",\n\t\t\t\t\t\t\"onClick\":   props.OnStart,\n\t\t\t\t\t},\n\t\t\t\t\t\"Start\",\n\t\t\t\t),\n\t\t\t),\n\t\t\tvdom.H(\"button\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"className\": \"px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200\",\n\t\t\t\t\t\"onClick\":   props.OnReset,\n\t\t\t\t},\n\t\t\t\t\"Reset\",\n\t\t\t),\n\t\t\tvdom.H(\"div\",\n\t\t\t\tmap[string]any{\"className\": \"flex gap-4 mt-4\"},\n\t\t\t\tvdom.H(\"button\",\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"className\": \"flex-1 px-3 py-3 text-base border-none rounded bg-green-500 text-white cursor-pointer hover:bg-green-600 transition-colors duration-200\",\n\t\t\t\t\t\t\"onClick\":   func() { props.OnMode(WorkMode.Duration) },\n\t\t\t\t\t},\n\t\t\t\t\t\"Work Mode\",\n\t\t\t\t),\n\t\t\t\tvdom.H(\"button\",\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"className\": \"flex-1 px-3 py-3 text-base border-none rounded bg-green-500 text-white cursor-pointer hover:bg-green-600 transition-colors duration-200\",\n\t\t\t\t\t\t\"onClick\":   func() { props.OnMode(BreakMode.Duration) },\n\t\t\t\t\t},\n\t\t\t\t\t\"Break Mode\",\n\t\t\t\t),\n\t\t\t),\n\t\t)\n\t},\n)\n\nvar App = app.DefineComponent(\"App\",\n\tfunc(_ struct{}) any {\n\n\t\tisRunning := app.UseLocal(false)\n\t\tmode := app.UseLocal(WorkMode.Name)\n\t\tisComplete := app.UseLocal(false)\n\t\tstartTime := app.UseRef(time.Time{})\n\t\ttotalDuration := app.UseRef(time.Duration(0))\n\n\t\t// Timer that updates every second using the new pattern\n\t\tapp.UseTicker(time.Second, func() {\n\t\t\tif !isRunning.Get() {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\telapsed := time.Since(startTime.Current)\n\t\t\tremaining := totalDuration.Current - elapsed\n\n\t\t\tif remaining <= 0 {\n\t\t\t\t// Timer completed\n\t\t\t\tisRunning.Set(false)\n\t\t\t\tremainingSecondsAtom.Set(0)\n\t\t\t\tisComplete.Set(true)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tnewSeconds := int(remaining.Seconds())\n\n\t\t\t// Only send update if value actually changed\n\t\t\tif newSeconds != remainingSecondsAtom.Get() {\n\t\t\t\tremainingSecondsAtom.Set(newSeconds)\n\t\t\t}\n\t\t}, []any{isRunning.Get()})\n\n\t\tstartTimer := func() {\n\t\t\tif isRunning.Get() {\n\t\t\t\treturn // Timer already running\n\t\t\t}\n\n\t\t\tisComplete.Set(false)\n\t\t\tstartTime.Current = time.Now()\n\t\t\ttotalDuration.Current = time.Duration(remainingSecondsAtom.Get()) * time.Second\n\t\t\tisRunning.Set(true)\n\t\t}\n\n\t\tpauseTimer := func() {\n\t\t\tif !isRunning.Get() {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Calculate remaining time and update remainingSeconds\n\t\t\telapsed := time.Since(startTime.Current)\n\t\t\tremaining := totalDuration.Current - elapsed\n\t\t\tif remaining > 0 {\n\t\t\t\tremainingSecondsAtom.Set(int(remaining.Seconds()))\n\t\t\t}\n\t\t\tisRunning.Set(false)\n\t\t}\n\n\t\tresetTimer := func() {\n\t\t\tisRunning.Set(false)\n\t\t\tisComplete.Set(false)\n\t\t\tif mode.Get() == WorkMode.Name {\n\t\t\t\tremainingSecondsAtom.Set(WorkMode.Duration * 60)\n\t\t\t} else {\n\t\t\t\tremainingSecondsAtom.Set(BreakMode.Duration * 60)\n\t\t\t}\n\t\t}\n\n\t\tchangeMode := func(duration int) {\n\t\t\tisRunning.Set(false)\n\t\t\tisComplete.Set(false)\n\t\t\tremainingSecondsAtom.Set(duration * 60)\n\t\t\tif duration == WorkMode.Duration {\n\t\t\t\tmode.Set(WorkMode.Name)\n\t\t\t} else {\n\t\t\t\tmode.Set(BreakMode.Name)\n\t\t\t}\n\t\t}\n\n\t\treturn vdom.H(\"div\",\n\t\t\tmap[string]any{\"className\": \"max-w-sm mx-auto my-8 p-8 bg-slate-800 rounded-xl text-slate-100 font-sans\"},\n\t\t\tvdom.H(\"h1\",\n\t\t\t\tmap[string]any{\"className\": \"text-center text-slate-100 mb-8 text-3xl\"},\n\t\t\t\t\"Pomodoro Timer\",\n\t\t\t),\n\t\t\tTimerDisplay(TimerDisplayProps{\n\t\t\t\tRemainingSeconds: remainingSecondsAtom.Get(),\n\t\t\t\tMode:             mode.Get(),\n\t\t\t}),\n\t\t\tControlButtons(ControlButtonsProps{\n\t\t\t\tIsRunning: isRunning.Get(),\n\t\t\t\tOnStart:   startTimer,\n\t\t\t\tOnPause:   pauseTimer,\n\t\t\t\tOnReset:   resetTimer,\n\t\t\t\tOnMode:    changeMode,\n\t\t\t}),\n\t\t)\n\t},\n)\n"
  },
  {
    "path": "tsunami/demo/pomodoro/go.mod",
    "content": "module tsunami/app/pomodoro\n\ngo 1.25.6\n\nrequire github.com/wavetermdev/waveterm/tsunami v0.0.0\n\nrequire (\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/outrigdev/goid v0.3.0 // indirect\n)\n\nreplace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami\n"
  },
  {
    "path": "tsunami/demo/pomodoro/go.sum",
    "content": "github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=\ngithub.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=\n"
  },
  {
    "path": "tsunami/demo/pomodoro/static/tw.css",
    "content": "/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */\n@layer properties;\n@layer theme, base, components, utilities;\n@layer theme {\n  :root, :host {\n    --font-sans: \"Inter\", sans-serif;\n    --font-mono: \"Hack\", monospace;\n    --color-red-100: oklch(93.6% 0.032 17.717);\n    --color-red-500: oklch(63.7% 0.237 25.331);\n    --color-red-800: oklch(44.4% 0.177 26.899);\n    --color-green-500: oklch(72.3% 0.219 149.579);\n    --color-green-600: oklch(62.7% 0.194 149.214);\n    --color-blue-400: oklch(70.7% 0.165 254.624);\n    --color-blue-500: oklch(62.3% 0.214 259.815);\n    --color-blue-600: oklch(54.6% 0.245 262.881);\n    --color-slate-100: oklch(96.8% 0.007 247.896);\n    --color-slate-700: oklch(37.2% 0.044 257.287);\n    --color-slate-800: oklch(27.9% 0.041 260.031);\n    --color-white: #fff;\n    --spacing: 0.25rem;\n    --container-sm: 24rem;\n    --text-sm: 0.875rem;\n    --text-sm--line-height: calc(1.25 / 0.875);\n    --text-base: 1rem;\n    --text-base--line-height: calc(1.5 / 1);\n    --text-lg: 1.125rem;\n    --text-lg--line-height: calc(1.75 / 1.125);\n    --text-xl: 1.25rem;\n    --text-xl--line-height: calc(1.75 / 1.25);\n    --text-2xl: 1.5rem;\n    --text-2xl--line-height: calc(2 / 1.5);\n    --text-3xl: 1.875rem;\n    --text-3xl--line-height: calc(2.25 / 1.875);\n    --text-6xl: 3.75rem;\n    --text-6xl--line-height: 1;\n    --font-weight-bold: 700;\n    --leading-relaxed: 1.625;\n    --radius-lg: 0.5rem;\n    --radius-xl: 0.75rem;\n    --ease-in: cubic-bezier(0.4, 0, 1, 1);\n    --ease-out: cubic-bezier(0, 0, 0.2, 1);\n    --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-transition-duration: 150ms;\n    --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-font-family: var(--font-sans);\n    --default-mono-font-family: var(--font-mono);\n    --radius: 8px;\n    --color-background: rgb(34, 34, 34);\n    --color-primary: rgb(247, 247, 247);\n    --color-secondary: rgba(215, 218, 224, 0.7);\n    --color-muted: rgba(215, 218, 224, 0.5);\n    --color-accent-300: rgb(110, 231, 133);\n    --color-panel: rgba(255, 255, 255, 0.12);\n    --color-border: rgba(255, 255, 255, 0.16);\n    --color-accent: rgb(88, 193, 66);\n  }\n}\n@layer base {\n  *, ::after, ::before, ::backdrop, ::file-selector-button {\n    box-sizing: border-box;\n    margin: 0;\n    padding: 0;\n    border: 0 solid;\n  }\n  html, :host {\n    line-height: 1.5;\n    -webkit-text-size-adjust: 100%;\n    tab-size: 4;\n    font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\");\n    font-feature-settings: var(--default-font-feature-settings, normal);\n    font-variation-settings: var(--default-font-variation-settings, normal);\n    -webkit-tap-highlight-color: transparent;\n  }\n  hr {\n    height: 0;\n    color: inherit;\n    border-top-width: 1px;\n  }\n  abbr:where([title]) {\n    -webkit-text-decoration: underline dotted;\n    text-decoration: underline dotted;\n  }\n  h1, h2, h3, h4, h5, h6 {\n    font-size: inherit;\n    font-weight: inherit;\n  }\n  a {\n    color: inherit;\n    -webkit-text-decoration: inherit;\n    text-decoration: inherit;\n  }\n  b, strong {\n    font-weight: bolder;\n  }\n  code, kbd, samp, pre {\n    font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace);\n    font-feature-settings: var(--default-mono-font-feature-settings, normal);\n    font-variation-settings: var(--default-mono-font-variation-settings, normal);\n    font-size: 1em;\n  }\n  small {\n    font-size: 80%;\n  }\n  sub, sup {\n    font-size: 75%;\n    line-height: 0;\n    position: relative;\n    vertical-align: baseline;\n  }\n  sub {\n    bottom: -0.25em;\n  }\n  sup {\n    top: -0.5em;\n  }\n  table {\n    text-indent: 0;\n    border-color: inherit;\n    border-collapse: collapse;\n  }\n  :-moz-focusring {\n    outline: auto;\n  }\n  progress {\n    vertical-align: baseline;\n  }\n  summary {\n    display: list-item;\n  }\n  ol, ul, menu {\n    list-style: none;\n  }\n  img, svg, video, canvas, audio, iframe, embed, object {\n    display: block;\n    vertical-align: middle;\n  }\n  img, video {\n    max-width: 100%;\n    height: auto;\n  }\n  button, input, select, optgroup, textarea, ::file-selector-button {\n    font: inherit;\n    font-feature-settings: inherit;\n    font-variation-settings: inherit;\n    letter-spacing: inherit;\n    color: inherit;\n    border-radius: 0;\n    background-color: transparent;\n    opacity: 1;\n  }\n  :where(select:is([multiple], [size])) optgroup {\n    font-weight: bolder;\n  }\n  :where(select:is([multiple], [size])) optgroup option {\n    padding-inline-start: 20px;\n  }\n  ::file-selector-button {\n    margin-inline-end: 4px;\n  }\n  ::placeholder {\n    opacity: 1;\n  }\n  @supports (not (-webkit-appearance: -apple-pay-button))  or (contain-intrinsic-size: 1px) {\n    ::placeholder {\n      color: currentcolor;\n      @supports (color: color-mix(in lab, red, red)) {\n        color: color-mix(in oklab, currentcolor 50%, transparent);\n      }\n    }\n  }\n  textarea {\n    resize: vertical;\n  }\n  ::-webkit-search-decoration {\n    -webkit-appearance: none;\n  }\n  ::-webkit-date-and-time-value {\n    min-height: 1lh;\n    text-align: inherit;\n  }\n  ::-webkit-datetime-edit {\n    display: inline-flex;\n  }\n  ::-webkit-datetime-edit-fields-wrapper {\n    padding: 0;\n  }\n  ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n    padding-block: 0;\n  }\n  ::-webkit-calendar-picker-indicator {\n    line-height: 1;\n  }\n  :-moz-ui-invalid {\n    box-shadow: none;\n  }\n  button, input:where([type=\"button\"], [type=\"reset\"], [type=\"submit\"]), ::file-selector-button {\n    appearance: button;\n  }\n  ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n    height: auto;\n  }\n  [hidden]:where(:not([hidden=\"until-found\"])) {\n    display: none !important;\n  }\n}\n@layer utilities {\n  .collapse {\n    visibility: collapse;\n  }\n  .invisible {\n    visibility: hidden;\n  }\n  .visible {\n    visibility: visible;\n  }\n  .sr-only {\n    position: absolute;\n    width: 1px;\n    height: 1px;\n    padding: 0;\n    margin: -1px;\n    overflow: hidden;\n    clip-path: inset(50%);\n    white-space: nowrap;\n    border-width: 0;\n  }\n  .not-sr-only {\n    position: static;\n    width: auto;\n    height: auto;\n    padding: 0;\n    margin: 0;\n    overflow: visible;\n    clip-path: none;\n    white-space: normal;\n  }\n  .absolute {\n    position: absolute;\n  }\n  .fixed {\n    position: fixed;\n  }\n  .relative {\n    position: relative;\n  }\n  .static {\n    position: static;\n  }\n  .sticky {\n    position: sticky;\n  }\n  .isolate {\n    isolation: isolate;\n  }\n  .isolation-auto {\n    isolation: auto;\n  }\n  .container {\n    width: 100%;\n    @media (width >= 40rem) {\n      max-width: 40rem;\n    }\n    @media (width >= 48rem) {\n      max-width: 48rem;\n    }\n    @media (width >= 64rem) {\n      max-width: 64rem;\n    }\n    @media (width >= 80rem) {\n      max-width: 80rem;\n    }\n    @media (width >= 96rem) {\n      max-width: 96rem;\n    }\n  }\n  .mx-auto {\n    margin-inline: auto;\n  }\n  .my-6 {\n    margin-block: calc(var(--spacing) * 6);\n  }\n  .my-8 {\n    margin-block: calc(var(--spacing) * 8);\n  }\n  .mt-3 {\n    margin-top: calc(var(--spacing) * 3);\n  }\n  .mt-4 {\n    margin-top: calc(var(--spacing) * 4);\n  }\n  .mt-5 {\n    margin-top: calc(var(--spacing) * 5);\n  }\n  .mt-6 {\n    margin-top: calc(var(--spacing) * 6);\n  }\n  .mb-2 {\n    margin-bottom: calc(var(--spacing) * 2);\n  }\n  .mb-3 {\n    margin-bottom: calc(var(--spacing) * 3);\n  }\n  .mb-4 {\n    margin-bottom: calc(var(--spacing) * 4);\n  }\n  .mb-8 {\n    margin-bottom: calc(var(--spacing) * 8);\n  }\n  .ml-4 {\n    margin-left: calc(var(--spacing) * 4);\n  }\n  .block {\n    display: block;\n  }\n  .contents {\n    display: contents;\n  }\n  .flex {\n    display: flex;\n  }\n  .flow-root {\n    display: flow-root;\n  }\n  .grid {\n    display: grid;\n  }\n  .hidden {\n    display: none;\n  }\n  .inline {\n    display: inline;\n  }\n  .inline-block {\n    display: inline-block;\n  }\n  .inline-flex {\n    display: inline-flex;\n  }\n  .inline-grid {\n    display: inline-grid;\n  }\n  .inline-table {\n    display: inline-table;\n  }\n  .list-item {\n    display: list-item;\n  }\n  .table {\n    display: table;\n  }\n  .table-caption {\n    display: table-caption;\n  }\n  .table-cell {\n    display: table-cell;\n  }\n  .table-column {\n    display: table-column;\n  }\n  .table-column-group {\n    display: table-column-group;\n  }\n  .table-footer-group {\n    display: table-footer-group;\n  }\n  .table-header-group {\n    display: table-header-group;\n  }\n  .table-row {\n    display: table-row;\n  }\n  .table-row-group {\n    display: table-row-group;\n  }\n  .min-h-full {\n    min-height: 100%;\n  }\n  .min-h-screen {\n    min-height: 100vh;\n  }\n  .w-full {\n    width: 100%;\n  }\n  .max-w-none {\n    max-width: none;\n  }\n  .max-w-sm {\n    max-width: var(--container-sm);\n  }\n  .min-w-full {\n    min-width: 100%;\n  }\n  .flex-1 {\n    flex: 1;\n  }\n  .shrink {\n    flex-shrink: 1;\n  }\n  .grow {\n    flex-grow: 1;\n  }\n  .border-collapse {\n    border-collapse: collapse;\n  }\n  .translate-none {\n    translate: none;\n  }\n  .scale-3d {\n    scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);\n  }\n  .transform {\n    transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);\n  }\n  .cursor-pointer {\n    cursor: pointer;\n  }\n  .touch-pinch-zoom {\n    --tw-pinch-zoom: pinch-zoom;\n    touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);\n  }\n  .resize {\n    resize: both;\n  }\n  .list-inside {\n    list-style-position: inside;\n  }\n  .list-decimal {\n    list-style-type: decimal;\n  }\n  .list-disc {\n    list-style-type: disc;\n  }\n  .flex-col {\n    flex-direction: column;\n  }\n  .flex-wrap {\n    flex-wrap: wrap;\n  }\n  .gap-4 {\n    gap: calc(var(--spacing) * 4);\n  }\n  .space-y-1 {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 0;\n      margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));\n      margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));\n    }\n  }\n  .space-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 1;\n    }\n  }\n  .space-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-x-reverse: 1;\n    }\n  }\n  .divide-x {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 0;\n      border-inline-style: var(--tw-border-style);\n      border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));\n      border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));\n    }\n  }\n  .divide-y {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 0;\n      border-bottom-style: var(--tw-border-style);\n      border-top-style: var(--tw-border-style);\n      border-top-width: calc(1px * var(--tw-divide-y-reverse));\n      border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));\n    }\n  }\n  .divide-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 1;\n    }\n  }\n  .truncate {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .overflow-auto {\n    overflow: auto;\n  }\n  .overflow-x-auto {\n    overflow-x: auto;\n  }\n  .rounded {\n    border-radius: var(--radius);\n  }\n  .rounded-lg {\n    border-radius: var(--radius-lg);\n  }\n  .rounded-xl {\n    border-radius: var(--radius-xl);\n  }\n  .rounded-s {\n    border-start-start-radius: var(--radius);\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-ss {\n    border-start-start-radius: var(--radius);\n  }\n  .rounded-e {\n    border-start-end-radius: var(--radius);\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-se {\n    border-start-end-radius: var(--radius);\n  }\n  .rounded-ee {\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-es {\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-t {\n    border-top-left-radius: var(--radius);\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-l {\n    border-top-left-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-tl {\n    border-top-left-radius: var(--radius);\n  }\n  .rounded-r {\n    border-top-right-radius: var(--radius);\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-tr {\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-b {\n    border-bottom-right-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-br {\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-bl {\n    border-bottom-left-radius: var(--radius);\n  }\n  .border {\n    border-style: var(--tw-border-style);\n    border-width: 1px;\n  }\n  .border-x {\n    border-inline-style: var(--tw-border-style);\n    border-inline-width: 1px;\n  }\n  .border-y {\n    border-block-style: var(--tw-border-style);\n    border-block-width: 1px;\n  }\n  .border-s {\n    border-inline-start-style: var(--tw-border-style);\n    border-inline-start-width: 1px;\n  }\n  .border-e {\n    border-inline-end-style: var(--tw-border-style);\n    border-inline-end-width: 1px;\n  }\n  .border-t {\n    border-top-style: var(--tw-border-style);\n    border-top-width: 1px;\n  }\n  .border-r {\n    border-right-style: var(--tw-border-style);\n    border-right-width: 1px;\n  }\n  .border-b {\n    border-bottom-style: var(--tw-border-style);\n    border-bottom-width: 1px;\n  }\n  .border-l {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 1px;\n  }\n  .border-l-4 {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 4px;\n  }\n  .border-none {\n    --tw-border-style: none;\n    border-style: none;\n  }\n  .border-border {\n    border-color: var(--color-border);\n  }\n  .border-red-500 {\n    border-color: var(--color-red-500);\n  }\n  .bg-background {\n    background-color: var(--color-background);\n  }\n  .bg-blue-500 {\n    background-color: var(--color-blue-500);\n  }\n  .bg-green-500 {\n    background-color: var(--color-green-500);\n  }\n  .bg-panel {\n    background-color: var(--color-panel);\n  }\n  .bg-red-100 {\n    background-color: var(--color-red-100);\n  }\n  .bg-slate-700 {\n    background-color: var(--color-slate-700);\n  }\n  .bg-slate-800 {\n    background-color: var(--color-slate-800);\n  }\n  .bg-repeat {\n    background-repeat: repeat;\n  }\n  .mask-no-clip {\n    mask-clip: no-clip;\n  }\n  .mask-repeat {\n    mask-repeat: repeat;\n  }\n  .p-4 {\n    padding: calc(var(--spacing) * 4);\n  }\n  .p-8 {\n    padding: calc(var(--spacing) * 8);\n  }\n  .px-1 {\n    padding-inline: calc(var(--spacing) * 1);\n  }\n  .px-3 {\n    padding-inline: calc(var(--spacing) * 3);\n  }\n  .px-4 {\n    padding-inline: calc(var(--spacing) * 4);\n  }\n  .px-6 {\n    padding-inline: calc(var(--spacing) * 6);\n  }\n  .py-0\\.5 {\n    padding-block: calc(var(--spacing) * 0.5);\n  }\n  .py-2 {\n    padding-block: calc(var(--spacing) * 2);\n  }\n  .py-3 {\n    padding-block: calc(var(--spacing) * 3);\n  }\n  .pl-4 {\n    padding-left: calc(var(--spacing) * 4);\n  }\n  .text-center {\n    text-align: center;\n  }\n  .text-left {\n    text-align: left;\n  }\n  .font-mono {\n    font-family: var(--font-mono);\n  }\n  .font-sans {\n    font-family: var(--font-sans);\n  }\n  .text-2xl {\n    font-size: var(--text-2xl);\n    line-height: var(--tw-leading, var(--text-2xl--line-height));\n  }\n  .text-3xl {\n    font-size: var(--text-3xl);\n    line-height: var(--tw-leading, var(--text-3xl--line-height));\n  }\n  .text-6xl {\n    font-size: var(--text-6xl);\n    line-height: var(--tw-leading, var(--text-6xl--line-height));\n  }\n  .text-base {\n    font-size: var(--text-base);\n    line-height: var(--tw-leading, var(--text-base--line-height));\n  }\n  .text-lg {\n    font-size: var(--text-lg);\n    line-height: var(--tw-leading, var(--text-lg--line-height));\n  }\n  .text-sm {\n    font-size: var(--text-sm);\n    line-height: var(--tw-leading, var(--text-sm--line-height));\n  }\n  .text-xl {\n    font-size: var(--text-xl);\n    line-height: var(--tw-leading, var(--text-xl--line-height));\n  }\n  .leading-relaxed {\n    --tw-leading: var(--leading-relaxed);\n    line-height: var(--leading-relaxed);\n  }\n  .font-bold {\n    --tw-font-weight: var(--font-weight-bold);\n    font-weight: var(--font-weight-bold);\n  }\n  .text-wrap {\n    text-wrap: wrap;\n  }\n  .text-clip {\n    text-overflow: clip;\n  }\n  .text-ellipsis {\n    text-overflow: ellipsis;\n  }\n  .text-accent {\n    color: var(--color-accent);\n  }\n  .text-blue-400 {\n    color: var(--color-blue-400);\n  }\n  .text-muted {\n    color: var(--color-muted);\n  }\n  .text-primary {\n    color: var(--color-primary);\n  }\n  .text-red-800 {\n    color: var(--color-red-800);\n  }\n  .text-secondary {\n    color: var(--color-secondary);\n  }\n  .text-slate-100 {\n    color: var(--color-slate-100);\n  }\n  .text-white {\n    color: var(--color-white);\n  }\n  .capitalize {\n    text-transform: capitalize;\n  }\n  .lowercase {\n    text-transform: lowercase;\n  }\n  .normal-case {\n    text-transform: none;\n  }\n  .uppercase {\n    text-transform: uppercase;\n  }\n  .italic {\n    font-style: italic;\n  }\n  .not-italic {\n    font-style: normal;\n  }\n  .diagonal-fractions {\n    --tw-numeric-fraction: diagonal-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .lining-nums {\n    --tw-numeric-figure: lining-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .oldstyle-nums {\n    --tw-numeric-figure: oldstyle-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .ordinal {\n    --tw-ordinal: ordinal;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .proportional-nums {\n    --tw-numeric-spacing: proportional-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .slashed-zero {\n    --tw-slashed-zero: slashed-zero;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .stacked-fractions {\n    --tw-numeric-fraction: stacked-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .tabular-nums {\n    --tw-numeric-spacing: tabular-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .normal-nums {\n    font-variant-numeric: normal;\n  }\n  .line-through {\n    text-decoration-line: line-through;\n  }\n  .no-underline {\n    text-decoration-line: none;\n  }\n  .overline {\n    text-decoration-line: overline;\n  }\n  .underline {\n    text-decoration-line: underline;\n  }\n  .antialiased {\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n  .subpixel-antialiased {\n    -webkit-font-smoothing: auto;\n    -moz-osx-font-smoothing: auto;\n  }\n  .shadow {\n    --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .inset-ring {\n    --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor);\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .blur {\n    --tw-blur: blur(8px);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .drop-shadow {\n    --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));\n    --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .invert {\n    --tw-invert: invert(100%);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .filter {\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .backdrop-blur {\n    --tw-backdrop-blur: blur(8px);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-grayscale {\n    --tw-backdrop-grayscale: grayscale(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-invert {\n    --tw-backdrop-invert: invert(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-sepia {\n    --tw-backdrop-sepia: sepia(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-filter {\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .transition-colors {\n    transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;\n    transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));\n    transition-duration: var(--tw-duration, var(--default-transition-duration));\n  }\n  .duration-200 {\n    --tw-duration: 200ms;\n    transition-duration: 200ms;\n  }\n  .ease-in {\n    --tw-ease: var(--ease-in);\n    transition-timing-function: var(--ease-in);\n  }\n  .ease-in-out {\n    --tw-ease: var(--ease-in-out);\n    transition-timing-function: var(--ease-in-out);\n  }\n  .ease-out {\n    --tw-ease: var(--ease-out);\n    transition-timing-function: var(--ease-out);\n  }\n  .divide-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 1;\n    }\n  }\n  .ring-inset {\n    --tw-ring-inset: inset;\n  }\n  .hover\\:bg-blue-600 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-blue-600);\n      }\n    }\n  }\n  .hover\\:bg-green-600 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-green-600);\n      }\n    }\n  }\n  .hover\\:text-accent-300 {\n    &:hover {\n      @media (hover: hover) {\n        color: var(--color-accent-300);\n      }\n    }\n  }\n}\n@property --tw-scale-x {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-y {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-z {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-rotate-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-z {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pinch-zoom {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-space-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-space-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-divide-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-border-style {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: solid;\n}\n@property --tw-divide-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-leading {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-font-weight {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ordinal {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-slashed-zero {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-figure {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-spacing {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-fraction {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-inset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-offset-width {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-duration {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ease {\n  syntax: \"*\";\n  inherits: false;\n}\n@layer properties {\n  @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n    *, ::before, ::after, ::backdrop {\n      --tw-scale-x: 1;\n      --tw-scale-y: 1;\n      --tw-scale-z: 1;\n      --tw-rotate-x: initial;\n      --tw-rotate-y: initial;\n      --tw-rotate-z: initial;\n      --tw-skew-x: initial;\n      --tw-skew-y: initial;\n      --tw-pan-x: initial;\n      --tw-pan-y: initial;\n      --tw-pinch-zoom: initial;\n      --tw-space-y-reverse: 0;\n      --tw-space-x-reverse: 0;\n      --tw-divide-x-reverse: 0;\n      --tw-border-style: solid;\n      --tw-divide-y-reverse: 0;\n      --tw-leading: initial;\n      --tw-font-weight: initial;\n      --tw-ordinal: initial;\n      --tw-slashed-zero: initial;\n      --tw-numeric-figure: initial;\n      --tw-numeric-spacing: initial;\n      --tw-numeric-fraction: initial;\n      --tw-shadow: 0 0 #0000;\n      --tw-shadow-color: initial;\n      --tw-shadow-alpha: 100%;\n      --tw-inset-shadow: 0 0 #0000;\n      --tw-inset-shadow-color: initial;\n      --tw-inset-shadow-alpha: 100%;\n      --tw-ring-color: initial;\n      --tw-ring-shadow: 0 0 #0000;\n      --tw-inset-ring-color: initial;\n      --tw-inset-ring-shadow: 0 0 #0000;\n      --tw-ring-inset: initial;\n      --tw-ring-offset-width: 0px;\n      --tw-ring-offset-color: #fff;\n      --tw-ring-offset-shadow: 0 0 #0000;\n      --tw-blur: initial;\n      --tw-brightness: initial;\n      --tw-contrast: initial;\n      --tw-grayscale: initial;\n      --tw-hue-rotate: initial;\n      --tw-invert: initial;\n      --tw-opacity: initial;\n      --tw-saturate: initial;\n      --tw-sepia: initial;\n      --tw-drop-shadow: initial;\n      --tw-drop-shadow-color: initial;\n      --tw-drop-shadow-alpha: 100%;\n      --tw-drop-shadow-size: initial;\n      --tw-backdrop-blur: initial;\n      --tw-backdrop-brightness: initial;\n      --tw-backdrop-contrast: initial;\n      --tw-backdrop-grayscale: initial;\n      --tw-backdrop-hue-rotate: initial;\n      --tw-backdrop-invert: initial;\n      --tw-backdrop-opacity: initial;\n      --tw-backdrop-saturate: initial;\n      --tw-backdrop-sepia: initial;\n      --tw-duration: initial;\n      --tw-ease: initial;\n    }\n  }\n}\n"
  },
  {
    "path": "tsunami/demo/recharts/app.go",
    "content": "package main\n\nimport (\n\t\"math\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/app\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\nvar AppMeta = app.AppMeta{\n\tTitle:     \"Recharts Demo\",\n\tShortDesc: \"Interactive charts and data visualization using Recharts\",\n}\n\n// Global atoms for config and data\nvar (\n\tchartDataAtom = app.DataAtom(\"chartData\", generateInitialData(), &app.AtomMeta{\n\t\tDesc: \"Chart data points for system metrics visualization\",\n\t})\n\tchartTypeAtom = app.ConfigAtom(\"chartType\", \"line\", &app.AtomMeta{\n\t\tDesc: \"Type of chart to display\",\n\t\tEnum: []string{\"line\", \"area\", \"bar\"},\n\t})\n\tisAnimatingAtom = app.ConfigAtom(\"isAnimating\", false, &app.AtomMeta{\n\t\tDesc: \"Whether the chart is currently animating with live data\",\n\t})\n)\n\ntype DataPoint struct {\n\tTime int     `json:\"time\"`\n\tCPU  float64 `json:\"cpu\"`\n\tMem  float64 `json:\"mem\"`\n\tDisk float64 `json:\"disk\"`\n}\n\nfunc generateInitialData() []DataPoint {\n\tdata := make([]DataPoint, 20)\n\tfor i := 0; i < 20; i++ {\n\t\tdata[i] = DataPoint{\n\t\t\tTime: i,\n\t\t\tCPU:  50 + 30*math.Sin(float64(i)*0.3) + 10*math.Sin(float64(i)*0.7),\n\t\t\tMem:  40 + 25*math.Cos(float64(i)*0.4) + 15*math.Sin(float64(i)*0.9),\n\t\t\tDisk: 30 + 20*math.Sin(float64(i)*0.2) + 10*math.Cos(float64(i)*1.1),\n\t\t}\n\t}\n\treturn data\n}\n\nfunc generateNewDataPoint(currentData []DataPoint) DataPoint {\n\tlastTime := 0\n\tif len(currentData) > 0 {\n\t\tlastTime = currentData[len(currentData)-1].Time\n\t}\n\tnewTime := lastTime + 1\n\n\treturn DataPoint{\n\t\tTime: newTime,\n\t\tCPU:  50 + 30*math.Sin(float64(newTime)*0.3) + 10*math.Sin(float64(newTime)*0.7),\n\t\tMem:  40 + 25*math.Cos(float64(newTime)*0.4) + 15*math.Sin(float64(newTime)*0.9),\n\t\tDisk: 30 + 20*math.Sin(float64(newTime)*0.2) + 10*math.Cos(float64(newTime)*1.1),\n\t}\n}\n\nvar InfoSection = app.DefineComponent(\"InfoSection\", func(_ struct{}) any {\n\treturn vdom.H(\"div\", map[string]any{\n\t\t\"className\": \"bg-blue-50 border border-blue-200 rounded-lg p-4\",\n\t},\n\t\tvdom.H(\"h3\", map[string]any{\n\t\t\t\"className\": \"text-lg font-semibold text-blue-900 mb-2\",\n\t\t}, \"Recharts Integration Features\"),\n\t\tvdom.H(\"ul\", map[string]any{\n\t\t\t\"className\": \"space-y-2 text-blue-800\",\n\t\t},\n\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t},\n\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\"className\": \"text-blue-500 mt-1\",\n\t\t\t\t}, \"•\"),\n\t\t\t\t\"Support for all major Recharts components (LineChart, AreaChart, BarChart, etc.)\",\n\t\t\t),\n\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t},\n\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\"className\": \"text-blue-500 mt-1\",\n\t\t\t\t}, \"•\"),\n\t\t\t\t\"Live data updates with animation support\",\n\t\t\t),\n\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t},\n\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\"className\": \"text-blue-500 mt-1\",\n\t\t\t\t}, \"•\"),\n\t\t\t\t\"Responsive containers that resize with the window\",\n\t\t\t),\n\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t},\n\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\"className\": \"text-blue-500 mt-1\",\n\t\t\t\t}, \"•\"),\n\t\t\t\t\"Full prop support for customization and styling\",\n\t\t\t),\n\t\t\tvdom.H(\"li\", map[string]any{\n\t\t\t\t\"className\": \"flex items-start gap-2\",\n\t\t\t},\n\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\"className\": \"text-blue-500 mt-1\",\n\t\t\t\t}, \"•\"),\n\t\t\t\t\"Uses recharts: namespace to dispatch to the recharts handler\",\n\t\t\t),\n\t\t),\n\t)\n},\n)\n\ntype MiniChartsProps struct {\n\tChartData []DataPoint `json:\"chartData\"`\n}\n\nvar MiniCharts = app.DefineComponent(\"MiniCharts\",\n\tfunc(props MiniChartsProps) any {\n\t\treturn vdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"grid grid-cols-1 md:grid-cols-3 gap-6 mb-6\",\n\t\t},\n\t\t\t// CPU Mini Chart\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"bg-white rounded-lg shadow-sm border p-4\",\n\t\t\t},\n\t\t\t\tvdom.H(\"h3\", map[string]any{\n\t\t\t\t\t\"className\": \"text-lg font-medium text-gray-900 mb-3\",\n\t\t\t\t}, \"CPU Usage\"),\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"h-32\",\n\t\t\t\t},\n\t\t\t\t\tvdom.H(\"recharts:ResponsiveContainer\", map[string]any{\n\t\t\t\t\t\t\"width\":  \"100%\",\n\t\t\t\t\t\t\"height\": \"100%\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"recharts:LineChart\", map[string]any{\n\t\t\t\t\t\t\t\"data\": props.ChartData,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.H(\"recharts:Line\", map[string]any{\n\t\t\t\t\t\t\t\t\"type\":        \"monotone\",\n\t\t\t\t\t\t\t\t\"dataKey\":     \"cpu\",\n\t\t\t\t\t\t\t\t\"stroke\":      \"#8884d8\",\n\t\t\t\t\t\t\t\t\"strokeWidth\": 2,\n\t\t\t\t\t\t\t\t\"dot\":         false,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t),\n\n\t\t\t// Memory Mini Chart\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"bg-white rounded-lg shadow-sm border p-4\",\n\t\t\t},\n\t\t\t\tvdom.H(\"h3\", map[string]any{\n\t\t\t\t\t\"className\": \"text-lg font-medium text-gray-900 mb-3\",\n\t\t\t\t}, \"Memory Usage\"),\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"h-32\",\n\t\t\t\t},\n\t\t\t\t\tvdom.H(\"recharts:ResponsiveContainer\", map[string]any{\n\t\t\t\t\t\t\"width\":  \"100%\",\n\t\t\t\t\t\t\"height\": \"100%\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"recharts:AreaChart\", map[string]any{\n\t\t\t\t\t\t\t\"data\": props.ChartData,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.H(\"recharts:Area\", map[string]any{\n\t\t\t\t\t\t\t\t\"type\":    \"monotone\",\n\t\t\t\t\t\t\t\t\"dataKey\": \"mem\",\n\t\t\t\t\t\t\t\t\"stroke\":  \"#82ca9d\",\n\t\t\t\t\t\t\t\t\"fill\":    \"#82ca9d\",\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t),\n\n\t\t\t// Disk Mini Chart\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"bg-white rounded-lg shadow-sm border p-4\",\n\t\t\t},\n\t\t\t\tvdom.H(\"h3\", map[string]any{\n\t\t\t\t\t\"className\": \"text-lg font-medium text-gray-900 mb-3\",\n\t\t\t\t}, \"Disk Usage\"),\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"h-32\",\n\t\t\t\t},\n\t\t\t\t\tvdom.H(\"recharts:ResponsiveContainer\", map[string]any{\n\t\t\t\t\t\t\"width\":  \"100%\",\n\t\t\t\t\t\t\"height\": \"100%\",\n\t\t\t\t\t},\n\t\t\t\t\t\tvdom.H(\"recharts:BarChart\", map[string]any{\n\t\t\t\t\t\t\t\"data\": props.ChartData,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.H(\"recharts:Bar\", map[string]any{\n\t\t\t\t\t\t\t\t\"dataKey\": \"disk\",\n\t\t\t\t\t\t\t\t\"fill\":    \"#ffc658\",\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t),\n\t\t)\n\t},\n)\n\nvar App = app.DefineComponent(\"App\",\n\tfunc(_ struct{}) any {\n\n\t\ttickerFn := func() {\n\t\t\tif !isAnimatingAtom.Get() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tchartDataAtom.SetFn(func(currentData []DataPoint) []DataPoint {\n\t\t\t\tnewData := append(currentData, generateNewDataPoint(currentData))\n\t\t\t\tif len(newData) > 20 {\n\t\t\t\t\tnewData = newData[1:]\n\t\t\t\t}\n\t\t\t\treturn newData\n\t\t\t})\n\t\t}\n\t\tapp.UseTicker(time.Second, tickerFn, []any{})\n\n\t\thandleStartStop := func() {\n\t\t\tisAnimatingAtom.Set(!isAnimatingAtom.Get())\n\t\t}\n\n\t\thandleReset := func() {\n\t\t\tchartDataAtom.Set(generateInitialData())\n\t\t\tisAnimatingAtom.Set(false)\n\t\t}\n\n\t\thandleChartTypeChange := func(newType string) {\n\t\t\tchartTypeAtom.Set(newType)\n\t\t}\n\n\t\tchartData := chartDataAtom.Get()\n\t\tchartType := chartTypeAtom.Get()\n\t\tisAnimating := isAnimatingAtom.Get()\n\n\t\treturn vdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"min-h-screen bg-gray-50 p-6\",\n\t\t},\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"max-w-6xl mx-auto\",\n\t\t\t},\n\t\t\t\t// Header\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"mb-8\",\n\t\t\t\t},\n\t\t\t\t\tvdom.H(\"h1\", map[string]any{\n\t\t\t\t\t\t\"className\": \"text-3xl font-bold text-gray-900 mb-2\",\n\t\t\t\t\t}, \"Recharts Integration Demo\"),\n\t\t\t\t\tvdom.H(\"p\", map[string]any{\n\t\t\t\t\t\t\"className\": \"text-gray-600\",\n\t\t\t\t\t}, \"Demonstrating recharts components in Tsunami VDOM system\"),\n\t\t\t\t),\n\n\t\t\t\t// Controls\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"bg-white rounded-lg shadow-sm border p-4 mb-6\",\n\t\t\t\t},\n\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\"className\": \"flex items-center gap-4 flex-wrap\",\n\t\t\t\t\t},\n\t\t\t\t\t\t// Chart type selector\n\t\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"flex items-center gap-2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.H(\"label\", map[string]any{\n\t\t\t\t\t\t\t\t\"className\": \"text-sm font-medium text-gray-700\",\n\t\t\t\t\t\t\t}, \"Chart Type:\"),\n\t\t\t\t\t\t\tvdom.H(\"select\", map[string]any{\n\t\t\t\t\t\t\t\t\"className\": \"px-3 py-1 border border-gray-300 rounded-md text-sm bg-white text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500\",\n\t\t\t\t\t\t\t\t\"value\":     chartType,\n\t\t\t\t\t\t\t\t\"onChange\": func(e vdom.VDomEvent) {\n\t\t\t\t\t\t\t\t\thandleChartTypeChange(e.TargetValue)\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tvdom.H(\"option\", map[string]any{\"value\": \"line\"}, \"Line Chart\"),\n\t\t\t\t\t\t\t\tvdom.H(\"option\", map[string]any{\"value\": \"area\"}, \"Area Chart\"),\n\t\t\t\t\t\t\t\tvdom.H(\"option\", map[string]any{\"value\": \"bar\"}, \"Bar Chart\"),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t),\n\n\t\t\t\t\t\t// Animation controls\n\t\t\t\t\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\t\t\t\t\"className\": vdom.Classes(\n\t\t\t\t\t\t\t\t\"px-4 py-2 rounded-md text-sm font-medium transition-colors\",\n\t\t\t\t\t\t\t\tvdom.IfElse(isAnimating,\n\t\t\t\t\t\t\t\t\t\"bg-red-500 hover:bg-red-600 text-white\",\n\t\t\t\t\t\t\t\t\t\"bg-green-500 hover:bg-green-600 text-white\",\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\"onClick\": handleStartStop,\n\t\t\t\t\t\t}, vdom.IfElse(isAnimating, \"Stop Animation\", \"Start Animation\")),\n\n\t\t\t\t\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-md text-sm font-medium transition-colors\",\n\t\t\t\t\t\t\t\"onClick\":   handleReset,\n\t\t\t\t\t\t}, \"Reset Data\"),\n\n\t\t\t\t\t\t// Status indicator\n\t\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\t\"className\": \"flex items-center gap-2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\t\t\"className\": vdom.Classes(\n\t\t\t\t\t\t\t\t\t\"w-2 h-2 rounded-full\",\n\t\t\t\t\t\t\t\t\tvdom.IfElse(isAnimating, \"bg-green-500\", \"bg-gray-400\"),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\t\t\t\t\"className\": \"text-sm text-gray-600\",\n\t\t\t\t\t\t\t}, vdom.IfElse(isAnimating, \"Live Updates\", \"Static\")),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t),\n\n\t\t\t\t// Main chart\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"bg-white rounded-lg shadow-sm border p-6 mb-6\",\n\t\t\t\t},\n\t\t\t\t\tvdom.H(\"h2\", map[string]any{\n\t\t\t\t\t\t\"className\": \"text-xl font-semibold text-gray-900 mb-4\",\n\t\t\t\t\t}, \"System Metrics Over Time\"),\n\t\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\t\"className\": \"w-full h-96\",\n\t\t\t\t\t},\n\t\t\t\t\t\t// Main chart - switches based on chartType\n\t\t\t\t\t\tvdom.IfElse(chartType == \"line\",\n\t\t\t\t\t\t\t// Line Chart\n\t\t\t\t\t\t\tvdom.H(\"recharts:ResponsiveContainer\", map[string]any{\n\t\t\t\t\t\t\t\t\"width\":  \"100%\",\n\t\t\t\t\t\t\t\t\"height\": \"100%\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tvdom.H(\"recharts:LineChart\", map[string]any{\n\t\t\t\t\t\t\t\t\t\"data\": chartData,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:CartesianGrid\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\"strokeDasharray\": \"3 3\",\n\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:XAxis\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\"dataKey\": \"time\",\n\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:YAxis\", nil),\n\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Tooltip\", nil),\n\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Legend\", nil),\n\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Line\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\"type\":    \"monotone\",\n\t\t\t\t\t\t\t\t\t\t\"dataKey\": \"cpu\",\n\t\t\t\t\t\t\t\t\t\t\"stroke\":  \"#8884d8\",\n\t\t\t\t\t\t\t\t\t\t\"name\":    \"CPU %\",\n\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Line\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\"type\":    \"monotone\",\n\t\t\t\t\t\t\t\t\t\t\"dataKey\": \"mem\",\n\t\t\t\t\t\t\t\t\t\t\"stroke\":  \"#82ca9d\",\n\t\t\t\t\t\t\t\t\t\t\"name\":    \"Memory %\",\n\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Line\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\"type\":    \"monotone\",\n\t\t\t\t\t\t\t\t\t\t\"dataKey\": \"disk\",\n\t\t\t\t\t\t\t\t\t\t\"stroke\":  \"#ffc658\",\n\t\t\t\t\t\t\t\t\t\t\"name\":    \"Disk %\",\n\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tvdom.IfElse(chartType == \"area\",\n\t\t\t\t\t\t\t\t// Area Chart\n\t\t\t\t\t\t\t\tvdom.H(\"recharts:ResponsiveContainer\", map[string]any{\n\t\t\t\t\t\t\t\t\t\"width\":  \"100%\",\n\t\t\t\t\t\t\t\t\t\"height\": \"100%\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:AreaChart\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\"data\": chartData,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:CartesianGrid\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\t\"strokeDasharray\": \"3 3\",\n\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:XAxis\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\t\"dataKey\": \"time\",\n\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:YAxis\", nil),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Tooltip\", nil),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Legend\", nil),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Area\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\t\"type\":    \"monotone\",\n\t\t\t\t\t\t\t\t\t\t\t\"dataKey\": \"cpu\",\n\t\t\t\t\t\t\t\t\t\t\t\"stackId\": \"1\",\n\t\t\t\t\t\t\t\t\t\t\t\"stroke\":  \"#8884d8\",\n\t\t\t\t\t\t\t\t\t\t\t\"fill\":    \"#8884d8\",\n\t\t\t\t\t\t\t\t\t\t\t\"name\":    \"CPU %\",\n\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Area\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\t\"type\":    \"monotone\",\n\t\t\t\t\t\t\t\t\t\t\t\"dataKey\": \"mem\",\n\t\t\t\t\t\t\t\t\t\t\t\"stackId\": \"1\",\n\t\t\t\t\t\t\t\t\t\t\t\"stroke\":  \"#82ca9d\",\n\t\t\t\t\t\t\t\t\t\t\t\"fill\":    \"#82ca9d\",\n\t\t\t\t\t\t\t\t\t\t\t\"name\":    \"Memory %\",\n\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Area\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\t\"type\":    \"monotone\",\n\t\t\t\t\t\t\t\t\t\t\t\"dataKey\": \"disk\",\n\t\t\t\t\t\t\t\t\t\t\t\"stackId\": \"1\",\n\t\t\t\t\t\t\t\t\t\t\t\"stroke\":  \"#ffc658\",\n\t\t\t\t\t\t\t\t\t\t\t\"fill\":    \"#ffc658\",\n\t\t\t\t\t\t\t\t\t\t\t\"name\":    \"Disk %\",\n\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t// Bar Chart\n\t\t\t\t\t\t\t\tvdom.H(\"recharts:ResponsiveContainer\", map[string]any{\n\t\t\t\t\t\t\t\t\t\"width\":  \"100%\",\n\t\t\t\t\t\t\t\t\t\"height\": \"100%\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:BarChart\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\"data\": chartData,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:CartesianGrid\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\t\"strokeDasharray\": \"3 3\",\n\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:XAxis\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\t\"dataKey\": \"time\",\n\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:YAxis\", nil),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Tooltip\", nil),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Legend\", nil),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Bar\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\t\"dataKey\": \"cpu\",\n\t\t\t\t\t\t\t\t\t\t\t\"fill\":    \"#8884d8\",\n\t\t\t\t\t\t\t\t\t\t\t\"name\":    \"CPU %\",\n\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Bar\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\t\"dataKey\": \"mem\",\n\t\t\t\t\t\t\t\t\t\t\t\"fill\":    \"#82ca9d\",\n\t\t\t\t\t\t\t\t\t\t\t\"name\":    \"Memory %\",\n\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\t\tvdom.H(\"recharts:Bar\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\t\"dataKey\": \"disk\",\n\t\t\t\t\t\t\t\t\t\t\t\"fill\":    \"#ffc658\",\n\t\t\t\t\t\t\t\t\t\t\t\"name\":    \"Disk %\",\n\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t),\n\n\t\t\t\t// Mini charts row\n\t\t\t\tMiniCharts(MiniChartsProps{\n\t\t\t\t\tChartData: chartData,\n\t\t\t\t}),\n\n\t\t\t\t// Info section\n\t\t\t\tInfoSection(struct{}{}),\n\t\t\t),\n\t\t)\n\t},\n)\n"
  },
  {
    "path": "tsunami/demo/recharts/go.mod",
    "content": "module tsunami/app/recharts\n\ngo 1.25.6\n\nrequire github.com/wavetermdev/waveterm/tsunami v0.0.0\n\nrequire (\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/outrigdev/goid v0.3.0 // indirect\n)\n\nreplace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami\n"
  },
  {
    "path": "tsunami/demo/recharts/go.sum",
    "content": "github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=\ngithub.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=\n"
  },
  {
    "path": "tsunami/demo/recharts/static/tw.css",
    "content": "/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */\n@layer properties;\n@layer theme, base, components, utilities;\n@layer theme {\n  :root, :host {\n    --font-sans: \"Inter\", sans-serif;\n    --font-mono: \"Hack\", monospace;\n    --color-red-500: oklch(63.7% 0.237 25.331);\n    --color-red-600: oklch(57.7% 0.245 27.325);\n    --color-green-500: oklch(72.3% 0.219 149.579);\n    --color-green-600: oklch(62.7% 0.194 149.214);\n    --color-blue-50: oklch(97% 0.014 254.604);\n    --color-blue-200: oklch(88.2% 0.059 254.128);\n    --color-blue-500: oklch(62.3% 0.214 259.815);\n    --color-blue-800: oklch(42.4% 0.199 265.638);\n    --color-blue-900: oklch(37.9% 0.146 265.522);\n    --color-gray-50: oklch(98.5% 0.002 247.839);\n    --color-gray-300: oklch(87.2% 0.01 258.338);\n    --color-gray-400: oklch(70.7% 0.022 261.325);\n    --color-gray-500: oklch(55.1% 0.027 264.364);\n    --color-gray-600: oklch(44.6% 0.03 256.802);\n    --color-gray-700: oklch(37.3% 0.034 259.733);\n    --color-gray-900: oklch(21% 0.034 264.665);\n    --color-white: #fff;\n    --spacing: 0.25rem;\n    --container-6xl: 72rem;\n    --text-sm: 0.875rem;\n    --text-sm--line-height: calc(1.25 / 0.875);\n    --text-base: 1rem;\n    --text-base--line-height: calc(1.5 / 1);\n    --text-lg: 1.125rem;\n    --text-lg--line-height: calc(1.75 / 1.125);\n    --text-xl: 1.25rem;\n    --text-xl--line-height: calc(1.75 / 1.25);\n    --text-2xl: 1.5rem;\n    --text-2xl--line-height: calc(2 / 1.5);\n    --text-3xl: 1.875rem;\n    --text-3xl--line-height: calc(2.25 / 1.875);\n    --font-weight-medium: 500;\n    --font-weight-semibold: 600;\n    --font-weight-bold: 700;\n    --leading-relaxed: 1.625;\n    --radius-md: 0.375rem;\n    --radius-lg: 0.5rem;\n    --ease-in: cubic-bezier(0.4, 0, 1, 1);\n    --ease-out: cubic-bezier(0, 0, 0.2, 1);\n    --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-transition-duration: 150ms;\n    --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-font-family: var(--font-sans);\n    --default-mono-font-family: var(--font-mono);\n    --radius: 8px;\n    --color-background: rgb(34, 34, 34);\n    --color-primary: rgb(247, 247, 247);\n    --color-secondary: rgba(215, 218, 224, 0.7);\n    --color-muted: rgba(215, 218, 224, 0.5);\n    --color-accent-300: rgb(110, 231, 133);\n    --color-panel: rgba(255, 255, 255, 0.12);\n    --color-border: rgba(255, 255, 255, 0.16);\n    --color-accent: rgb(88, 193, 66);\n  }\n}\n@layer base {\n  *, ::after, ::before, ::backdrop, ::file-selector-button {\n    box-sizing: border-box;\n    margin: 0;\n    padding: 0;\n    border: 0 solid;\n  }\n  html, :host {\n    line-height: 1.5;\n    -webkit-text-size-adjust: 100%;\n    tab-size: 4;\n    font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\");\n    font-feature-settings: var(--default-font-feature-settings, normal);\n    font-variation-settings: var(--default-font-variation-settings, normal);\n    -webkit-tap-highlight-color: transparent;\n  }\n  hr {\n    height: 0;\n    color: inherit;\n    border-top-width: 1px;\n  }\n  abbr:where([title]) {\n    -webkit-text-decoration: underline dotted;\n    text-decoration: underline dotted;\n  }\n  h1, h2, h3, h4, h5, h6 {\n    font-size: inherit;\n    font-weight: inherit;\n  }\n  a {\n    color: inherit;\n    -webkit-text-decoration: inherit;\n    text-decoration: inherit;\n  }\n  b, strong {\n    font-weight: bolder;\n  }\n  code, kbd, samp, pre {\n    font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace);\n    font-feature-settings: var(--default-mono-font-feature-settings, normal);\n    font-variation-settings: var(--default-mono-font-variation-settings, normal);\n    font-size: 1em;\n  }\n  small {\n    font-size: 80%;\n  }\n  sub, sup {\n    font-size: 75%;\n    line-height: 0;\n    position: relative;\n    vertical-align: baseline;\n  }\n  sub {\n    bottom: -0.25em;\n  }\n  sup {\n    top: -0.5em;\n  }\n  table {\n    text-indent: 0;\n    border-color: inherit;\n    border-collapse: collapse;\n  }\n  :-moz-focusring {\n    outline: auto;\n  }\n  progress {\n    vertical-align: baseline;\n  }\n  summary {\n    display: list-item;\n  }\n  ol, ul, menu {\n    list-style: none;\n  }\n  img, svg, video, canvas, audio, iframe, embed, object {\n    display: block;\n    vertical-align: middle;\n  }\n  img, video {\n    max-width: 100%;\n    height: auto;\n  }\n  button, input, select, optgroup, textarea, ::file-selector-button {\n    font: inherit;\n    font-feature-settings: inherit;\n    font-variation-settings: inherit;\n    letter-spacing: inherit;\n    color: inherit;\n    border-radius: 0;\n    background-color: transparent;\n    opacity: 1;\n  }\n  :where(select:is([multiple], [size])) optgroup {\n    font-weight: bolder;\n  }\n  :where(select:is([multiple], [size])) optgroup option {\n    padding-inline-start: 20px;\n  }\n  ::file-selector-button {\n    margin-inline-end: 4px;\n  }\n  ::placeholder {\n    opacity: 1;\n  }\n  @supports (not (-webkit-appearance: -apple-pay-button))  or (contain-intrinsic-size: 1px) {\n    ::placeholder {\n      color: currentcolor;\n      @supports (color: color-mix(in lab, red, red)) {\n        color: color-mix(in oklab, currentcolor 50%, transparent);\n      }\n    }\n  }\n  textarea {\n    resize: vertical;\n  }\n  ::-webkit-search-decoration {\n    -webkit-appearance: none;\n  }\n  ::-webkit-date-and-time-value {\n    min-height: 1lh;\n    text-align: inherit;\n  }\n  ::-webkit-datetime-edit {\n    display: inline-flex;\n  }\n  ::-webkit-datetime-edit-fields-wrapper {\n    padding: 0;\n  }\n  ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n    padding-block: 0;\n  }\n  ::-webkit-calendar-picker-indicator {\n    line-height: 1;\n  }\n  :-moz-ui-invalid {\n    box-shadow: none;\n  }\n  button, input:where([type=\"button\"], [type=\"reset\"], [type=\"submit\"]), ::file-selector-button {\n    appearance: button;\n  }\n  ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n    height: auto;\n  }\n  [hidden]:where(:not([hidden=\"until-found\"])) {\n    display: none !important;\n  }\n}\n@layer utilities {\n  .collapse {\n    visibility: collapse;\n  }\n  .invisible {\n    visibility: hidden;\n  }\n  .visible {\n    visibility: visible;\n  }\n  .sr-only {\n    position: absolute;\n    width: 1px;\n    height: 1px;\n    padding: 0;\n    margin: -1px;\n    overflow: hidden;\n    clip-path: inset(50%);\n    white-space: nowrap;\n    border-width: 0;\n  }\n  .not-sr-only {\n    position: static;\n    width: auto;\n    height: auto;\n    padding: 0;\n    margin: 0;\n    overflow: visible;\n    clip-path: none;\n    white-space: normal;\n  }\n  .absolute {\n    position: absolute;\n  }\n  .fixed {\n    position: fixed;\n  }\n  .relative {\n    position: relative;\n  }\n  .static {\n    position: static;\n  }\n  .sticky {\n    position: sticky;\n  }\n  .isolate {\n    isolation: isolate;\n  }\n  .isolation-auto {\n    isolation: auto;\n  }\n  .container {\n    width: 100%;\n    @media (width >= 40rem) {\n      max-width: 40rem;\n    }\n    @media (width >= 48rem) {\n      max-width: 48rem;\n    }\n    @media (width >= 64rem) {\n      max-width: 64rem;\n    }\n    @media (width >= 80rem) {\n      max-width: 80rem;\n    }\n    @media (width >= 96rem) {\n      max-width: 96rem;\n    }\n  }\n  .mx-auto {\n    margin-inline: auto;\n  }\n  .my-6 {\n    margin-block: calc(var(--spacing) * 6);\n  }\n  .mt-1 {\n    margin-top: calc(var(--spacing) * 1);\n  }\n  .mt-3 {\n    margin-top: calc(var(--spacing) * 3);\n  }\n  .mt-4 {\n    margin-top: calc(var(--spacing) * 4);\n  }\n  .mt-5 {\n    margin-top: calc(var(--spacing) * 5);\n  }\n  .mt-6 {\n    margin-top: calc(var(--spacing) * 6);\n  }\n  .mb-2 {\n    margin-bottom: calc(var(--spacing) * 2);\n  }\n  .mb-3 {\n    margin-bottom: calc(var(--spacing) * 3);\n  }\n  .mb-4 {\n    margin-bottom: calc(var(--spacing) * 4);\n  }\n  .mb-6 {\n    margin-bottom: calc(var(--spacing) * 6);\n  }\n  .mb-8 {\n    margin-bottom: calc(var(--spacing) * 8);\n  }\n  .ml-4 {\n    margin-left: calc(var(--spacing) * 4);\n  }\n  .block {\n    display: block;\n  }\n  .contents {\n    display: contents;\n  }\n  .flex {\n    display: flex;\n  }\n  .flow-root {\n    display: flow-root;\n  }\n  .grid {\n    display: grid;\n  }\n  .hidden {\n    display: none;\n  }\n  .inline {\n    display: inline;\n  }\n  .inline-block {\n    display: inline-block;\n  }\n  .inline-flex {\n    display: inline-flex;\n  }\n  .inline-grid {\n    display: inline-grid;\n  }\n  .inline-table {\n    display: inline-table;\n  }\n  .list-item {\n    display: list-item;\n  }\n  .table {\n    display: table;\n  }\n  .table-caption {\n    display: table-caption;\n  }\n  .table-cell {\n    display: table-cell;\n  }\n  .table-column {\n    display: table-column;\n  }\n  .table-column-group {\n    display: table-column-group;\n  }\n  .table-footer-group {\n    display: table-footer-group;\n  }\n  .table-header-group {\n    display: table-header-group;\n  }\n  .table-row {\n    display: table-row;\n  }\n  .table-row-group {\n    display: table-row-group;\n  }\n  .h-2 {\n    height: calc(var(--spacing) * 2);\n  }\n  .h-32 {\n    height: calc(var(--spacing) * 32);\n  }\n  .h-96 {\n    height: calc(var(--spacing) * 96);\n  }\n  .min-h-full {\n    min-height: 100%;\n  }\n  .min-h-screen {\n    min-height: 100vh;\n  }\n  .w-2 {\n    width: calc(var(--spacing) * 2);\n  }\n  .w-full {\n    width: 100%;\n  }\n  .max-w-6xl {\n    max-width: var(--container-6xl);\n  }\n  .max-w-none {\n    max-width: none;\n  }\n  .min-w-full {\n    min-width: 100%;\n  }\n  .shrink {\n    flex-shrink: 1;\n  }\n  .grow {\n    flex-grow: 1;\n  }\n  .border-collapse {\n    border-collapse: collapse;\n  }\n  .translate-none {\n    translate: none;\n  }\n  .scale-3d {\n    scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);\n  }\n  .transform {\n    transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);\n  }\n  .touch-pinch-zoom {\n    --tw-pinch-zoom: pinch-zoom;\n    touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);\n  }\n  .resize {\n    resize: both;\n  }\n  .list-inside {\n    list-style-position: inside;\n  }\n  .list-decimal {\n    list-style-type: decimal;\n  }\n  .list-disc {\n    list-style-type: disc;\n  }\n  .grid-cols-1 {\n    grid-template-columns: repeat(1, minmax(0, 1fr));\n  }\n  .flex-wrap {\n    flex-wrap: wrap;\n  }\n  .items-center {\n    align-items: center;\n  }\n  .items-start {\n    align-items: flex-start;\n  }\n  .gap-2 {\n    gap: calc(var(--spacing) * 2);\n  }\n  .gap-4 {\n    gap: calc(var(--spacing) * 4);\n  }\n  .gap-6 {\n    gap: calc(var(--spacing) * 6);\n  }\n  .space-y-1 {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 0;\n      margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));\n      margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));\n    }\n  }\n  .space-y-2 {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 0;\n      margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));\n      margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));\n    }\n  }\n  .space-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 1;\n    }\n  }\n  .space-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-x-reverse: 1;\n    }\n  }\n  .divide-x {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 0;\n      border-inline-style: var(--tw-border-style);\n      border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));\n      border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));\n    }\n  }\n  .divide-y {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 0;\n      border-bottom-style: var(--tw-border-style);\n      border-top-style: var(--tw-border-style);\n      border-top-width: calc(1px * var(--tw-divide-y-reverse));\n      border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));\n    }\n  }\n  .divide-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 1;\n    }\n  }\n  .truncate {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .overflow-auto {\n    overflow: auto;\n  }\n  .overflow-x-auto {\n    overflow-x: auto;\n  }\n  .rounded {\n    border-radius: var(--radius);\n  }\n  .rounded-full {\n    border-radius: calc(infinity * 1px);\n  }\n  .rounded-lg {\n    border-radius: var(--radius-lg);\n  }\n  .rounded-md {\n    border-radius: var(--radius-md);\n  }\n  .rounded-s {\n    border-start-start-radius: var(--radius);\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-ss {\n    border-start-start-radius: var(--radius);\n  }\n  .rounded-e {\n    border-start-end-radius: var(--radius);\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-se {\n    border-start-end-radius: var(--radius);\n  }\n  .rounded-ee {\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-es {\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-t {\n    border-top-left-radius: var(--radius);\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-l {\n    border-top-left-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-tl {\n    border-top-left-radius: var(--radius);\n  }\n  .rounded-r {\n    border-top-right-radius: var(--radius);\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-tr {\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-b {\n    border-bottom-right-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-br {\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-bl {\n    border-bottom-left-radius: var(--radius);\n  }\n  .border {\n    border-style: var(--tw-border-style);\n    border-width: 1px;\n  }\n  .border-x {\n    border-inline-style: var(--tw-border-style);\n    border-inline-width: 1px;\n  }\n  .border-y {\n    border-block-style: var(--tw-border-style);\n    border-block-width: 1px;\n  }\n  .border-s {\n    border-inline-start-style: var(--tw-border-style);\n    border-inline-start-width: 1px;\n  }\n  .border-e {\n    border-inline-end-style: var(--tw-border-style);\n    border-inline-end-width: 1px;\n  }\n  .border-t {\n    border-top-style: var(--tw-border-style);\n    border-top-width: 1px;\n  }\n  .border-r {\n    border-right-style: var(--tw-border-style);\n    border-right-width: 1px;\n  }\n  .border-b {\n    border-bottom-style: var(--tw-border-style);\n    border-bottom-width: 1px;\n  }\n  .border-l {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 1px;\n  }\n  .border-l-4 {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 4px;\n  }\n  .border-blue-200 {\n    border-color: var(--color-blue-200);\n  }\n  .border-border {\n    border-color: var(--color-border);\n  }\n  .border-gray-300 {\n    border-color: var(--color-gray-300);\n  }\n  .bg-background {\n    background-color: var(--color-background);\n  }\n  .bg-blue-50 {\n    background-color: var(--color-blue-50);\n  }\n  .bg-gray-50 {\n    background-color: var(--color-gray-50);\n  }\n  .bg-gray-400 {\n    background-color: var(--color-gray-400);\n  }\n  .bg-gray-500 {\n    background-color: var(--color-gray-500);\n  }\n  .bg-green-500 {\n    background-color: var(--color-green-500);\n  }\n  .bg-panel {\n    background-color: var(--color-panel);\n  }\n  .bg-red-500 {\n    background-color: var(--color-red-500);\n  }\n  .bg-white {\n    background-color: var(--color-white);\n  }\n  .bg-repeat {\n    background-repeat: repeat;\n  }\n  .mask-no-clip {\n    mask-clip: no-clip;\n  }\n  .mask-repeat {\n    mask-repeat: repeat;\n  }\n  .p-4 {\n    padding: calc(var(--spacing) * 4);\n  }\n  .p-6 {\n    padding: calc(var(--spacing) * 6);\n  }\n  .px-1 {\n    padding-inline: calc(var(--spacing) * 1);\n  }\n  .px-3 {\n    padding-inline: calc(var(--spacing) * 3);\n  }\n  .px-4 {\n    padding-inline: calc(var(--spacing) * 4);\n  }\n  .py-0\\.5 {\n    padding-block: calc(var(--spacing) * 0.5);\n  }\n  .py-1 {\n    padding-block: calc(var(--spacing) * 1);\n  }\n  .py-2 {\n    padding-block: calc(var(--spacing) * 2);\n  }\n  .pl-4 {\n    padding-left: calc(var(--spacing) * 4);\n  }\n  .text-left {\n    text-align: left;\n  }\n  .font-mono {\n    font-family: var(--font-mono);\n  }\n  .text-2xl {\n    font-size: var(--text-2xl);\n    line-height: var(--tw-leading, var(--text-2xl--line-height));\n  }\n  .text-3xl {\n    font-size: var(--text-3xl);\n    line-height: var(--tw-leading, var(--text-3xl--line-height));\n  }\n  .text-base {\n    font-size: var(--text-base);\n    line-height: var(--tw-leading, var(--text-base--line-height));\n  }\n  .text-lg {\n    font-size: var(--text-lg);\n    line-height: var(--tw-leading, var(--text-lg--line-height));\n  }\n  .text-sm {\n    font-size: var(--text-sm);\n    line-height: var(--tw-leading, var(--text-sm--line-height));\n  }\n  .text-xl {\n    font-size: var(--text-xl);\n    line-height: var(--tw-leading, var(--text-xl--line-height));\n  }\n  .leading-relaxed {\n    --tw-leading: var(--leading-relaxed);\n    line-height: var(--leading-relaxed);\n  }\n  .font-bold {\n    --tw-font-weight: var(--font-weight-bold);\n    font-weight: var(--font-weight-bold);\n  }\n  .font-medium {\n    --tw-font-weight: var(--font-weight-medium);\n    font-weight: var(--font-weight-medium);\n  }\n  .font-semibold {\n    --tw-font-weight: var(--font-weight-semibold);\n    font-weight: var(--font-weight-semibold);\n  }\n  .text-wrap {\n    text-wrap: wrap;\n  }\n  .text-clip {\n    text-overflow: clip;\n  }\n  .text-ellipsis {\n    text-overflow: ellipsis;\n  }\n  .text-accent {\n    color: var(--color-accent);\n  }\n  .text-blue-500 {\n    color: var(--color-blue-500);\n  }\n  .text-blue-800 {\n    color: var(--color-blue-800);\n  }\n  .text-blue-900 {\n    color: var(--color-blue-900);\n  }\n  .text-gray-600 {\n    color: var(--color-gray-600);\n  }\n  .text-gray-700 {\n    color: var(--color-gray-700);\n  }\n  .text-gray-900 {\n    color: var(--color-gray-900);\n  }\n  .text-muted {\n    color: var(--color-muted);\n  }\n  .text-primary {\n    color: var(--color-primary);\n  }\n  .text-secondary {\n    color: var(--color-secondary);\n  }\n  .text-white {\n    color: var(--color-white);\n  }\n  .capitalize {\n    text-transform: capitalize;\n  }\n  .lowercase {\n    text-transform: lowercase;\n  }\n  .normal-case {\n    text-transform: none;\n  }\n  .uppercase {\n    text-transform: uppercase;\n  }\n  .italic {\n    font-style: italic;\n  }\n  .not-italic {\n    font-style: normal;\n  }\n  .diagonal-fractions {\n    --tw-numeric-fraction: diagonal-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .lining-nums {\n    --tw-numeric-figure: lining-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .oldstyle-nums {\n    --tw-numeric-figure: oldstyle-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .ordinal {\n    --tw-ordinal: ordinal;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .proportional-nums {\n    --tw-numeric-spacing: proportional-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .slashed-zero {\n    --tw-slashed-zero: slashed-zero;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .stacked-fractions {\n    --tw-numeric-fraction: stacked-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .tabular-nums {\n    --tw-numeric-spacing: tabular-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .normal-nums {\n    font-variant-numeric: normal;\n  }\n  .line-through {\n    text-decoration-line: line-through;\n  }\n  .no-underline {\n    text-decoration-line: none;\n  }\n  .overline {\n    text-decoration-line: overline;\n  }\n  .underline {\n    text-decoration-line: underline;\n  }\n  .antialiased {\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n  .subpixel-antialiased {\n    -webkit-font-smoothing: auto;\n    -moz-osx-font-smoothing: auto;\n  }\n  .shadow {\n    --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .shadow-sm {\n    --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .inset-ring {\n    --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor);\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .blur {\n    --tw-blur: blur(8px);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .drop-shadow {\n    --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));\n    --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .invert {\n    --tw-invert: invert(100%);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .filter {\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .backdrop-blur {\n    --tw-backdrop-blur: blur(8px);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-grayscale {\n    --tw-backdrop-grayscale: grayscale(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-invert {\n    --tw-backdrop-invert: invert(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-sepia {\n    --tw-backdrop-sepia: sepia(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-filter {\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .transition-colors {\n    transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;\n    transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));\n    transition-duration: var(--tw-duration, var(--default-transition-duration));\n  }\n  .ease-in {\n    --tw-ease: var(--ease-in);\n    transition-timing-function: var(--ease-in);\n  }\n  .ease-in-out {\n    --tw-ease: var(--ease-in-out);\n    transition-timing-function: var(--ease-in-out);\n  }\n  .ease-out {\n    --tw-ease: var(--ease-out);\n    transition-timing-function: var(--ease-out);\n  }\n  .divide-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 1;\n    }\n  }\n  .ring-inset {\n    --tw-ring-inset: inset;\n  }\n  .hover\\:bg-gray-600 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-gray-600);\n      }\n    }\n  }\n  .hover\\:bg-green-600 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-green-600);\n      }\n    }\n  }\n  .hover\\:bg-red-600 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-red-600);\n      }\n    }\n  }\n  .hover\\:text-accent-300 {\n    &:hover {\n      @media (hover: hover) {\n        color: var(--color-accent-300);\n      }\n    }\n  }\n  .focus\\:border-blue-500 {\n    &:focus {\n      border-color: var(--color-blue-500);\n    }\n  }\n  .focus\\:ring-2 {\n    &:focus {\n      --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n      box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n    }\n  }\n  .focus\\:ring-blue-500 {\n    &:focus {\n      --tw-ring-color: var(--color-blue-500);\n    }\n  }\n  .md\\:grid-cols-3 {\n    @media (width >= 48rem) {\n      grid-template-columns: repeat(3, minmax(0, 1fr));\n    }\n  }\n}\n@property --tw-scale-x {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-y {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-z {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-rotate-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-z {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pinch-zoom {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-space-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-space-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-divide-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-border-style {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: solid;\n}\n@property --tw-divide-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-leading {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-font-weight {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ordinal {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-slashed-zero {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-figure {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-spacing {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-fraction {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-inset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-offset-width {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ease {\n  syntax: \"*\";\n  inherits: false;\n}\n@layer properties {\n  @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n    *, ::before, ::after, ::backdrop {\n      --tw-scale-x: 1;\n      --tw-scale-y: 1;\n      --tw-scale-z: 1;\n      --tw-rotate-x: initial;\n      --tw-rotate-y: initial;\n      --tw-rotate-z: initial;\n      --tw-skew-x: initial;\n      --tw-skew-y: initial;\n      --tw-pan-x: initial;\n      --tw-pan-y: initial;\n      --tw-pinch-zoom: initial;\n      --tw-space-y-reverse: 0;\n      --tw-space-x-reverse: 0;\n      --tw-divide-x-reverse: 0;\n      --tw-border-style: solid;\n      --tw-divide-y-reverse: 0;\n      --tw-leading: initial;\n      --tw-font-weight: initial;\n      --tw-ordinal: initial;\n      --tw-slashed-zero: initial;\n      --tw-numeric-figure: initial;\n      --tw-numeric-spacing: initial;\n      --tw-numeric-fraction: initial;\n      --tw-shadow: 0 0 #0000;\n      --tw-shadow-color: initial;\n      --tw-shadow-alpha: 100%;\n      --tw-inset-shadow: 0 0 #0000;\n      --tw-inset-shadow-color: initial;\n      --tw-inset-shadow-alpha: 100%;\n      --tw-ring-color: initial;\n      --tw-ring-shadow: 0 0 #0000;\n      --tw-inset-ring-color: initial;\n      --tw-inset-ring-shadow: 0 0 #0000;\n      --tw-ring-inset: initial;\n      --tw-ring-offset-width: 0px;\n      --tw-ring-offset-color: #fff;\n      --tw-ring-offset-shadow: 0 0 #0000;\n      --tw-blur: initial;\n      --tw-brightness: initial;\n      --tw-contrast: initial;\n      --tw-grayscale: initial;\n      --tw-hue-rotate: initial;\n      --tw-invert: initial;\n      --tw-opacity: initial;\n      --tw-saturate: initial;\n      --tw-sepia: initial;\n      --tw-drop-shadow: initial;\n      --tw-drop-shadow-color: initial;\n      --tw-drop-shadow-alpha: 100%;\n      --tw-drop-shadow-size: initial;\n      --tw-backdrop-blur: initial;\n      --tw-backdrop-brightness: initial;\n      --tw-backdrop-contrast: initial;\n      --tw-backdrop-grayscale: initial;\n      --tw-backdrop-hue-rotate: initial;\n      --tw-backdrop-invert: initial;\n      --tw-backdrop-opacity: initial;\n      --tw-backdrop-saturate: initial;\n      --tw-backdrop-sepia: initial;\n      --tw-ease: initial;\n    }\n  }\n}\n"
  },
  {
    "path": "tsunami/demo/tabletest/app.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/app\"\n\t\"github.com/wavetermdev/waveterm/tsunami/ui\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\nvar AppMeta = app.AppMeta{\n\tTitle:     \"Table Test Demo\",\n\tShortDesc: \"Testing table component with sortable columns and pagination\",\n}\n\n// Sample data structure for the table\ntype Person struct {\n\tName  string `json:\"name\"`\n\tAge   int    `json:\"age\"`\n\tEmail string `json:\"email\"`\n\tCity  string `json:\"city\"`\n}\n\n// Create the table component for Person data\nvar PersonTable = ui.MakeTableComponent[Person](\"PersonTable\")\n\n// Sample data exposed as DataAtom for external system access\nvar sampleData = app.DataAtom(\"sampleData\", []Person{\n\t{Name: \"Alice Johnson\", Age: 28, Email: \"alice@example.com\", City: \"New York\"},\n\t{Name: \"Bob Smith\", Age: 34, Email: \"bob@example.com\", City: \"Los Angeles\"},\n\t{Name: \"Carol Davis\", Age: 22, Email: \"carol@example.com\", City: \"Chicago\"},\n\t{Name: \"David Wilson\", Age: 41, Email: \"david@example.com\", City: \"Houston\"},\n\t{Name: \"Eve Brown\", Age: 29, Email: \"eve@example.com\", City: \"Phoenix\"},\n\t{Name: \"Frank Miller\", Age: 37, Email: \"frank@example.com\", City: \"Philadelphia\"},\n\t{Name: \"Grace Lee\", Age: 25, Email: \"grace@example.com\", City: \"San Antonio\"},\n\t{Name: \"Henry Taylor\", Age: 33, Email: \"henry@example.com\", City: \"San Diego\"},\n\t{Name: \"Ivy Chen\", Age: 26, Email: \"ivy@example.com\", City: \"Dallas\"},\n\t{Name: \"Jack Anderson\", Age: 31, Email: \"jack@example.com\", City: \"San Jose\"},\n}, &app.AtomMeta{\n\tDesc: \"Sample person data for table display testing\",\n})\n\n// The App component is the required entry point for every Tsunami application\nvar App = app.DefineComponent(\"App\", func(_ struct{}) any {\n\n\t// Define table columns\n\tcolumns := []ui.TableColumn[Person]{\n\t\t{\n\t\t\tAccessorKey: \"Name\",\n\t\t\tHeader:      \"Full Name\",\n\t\t\tSortable:    true,\n\t\t\tWidth:       \"200px\",\n\t\t},\n\t\t{\n\t\t\tAccessorKey: \"Age\", \n\t\t\tHeader:      \"Age\",\n\t\t\tSortable:    true,\n\t\t\tWidth:       \"80px\",\n\t\t},\n\t\t{\n\t\t\tAccessorKey: \"Email\",\n\t\t\tHeader:      \"Email Address\", \n\t\t\tSortable:    true,\n\t\t\tWidth:       \"250px\",\n\t\t},\n\t\t{\n\t\t\tAccessorKey: \"City\",\n\t\t\tHeader:      \"City\",\n\t\t\tSortable:    true,\n\t\t\tWidth:       \"150px\",\n\t\t},\n\t}\n\n\t// Handle row clicks\n\thandleRowClick := func(person Person, idx int) {\n\t\tfmt.Printf(\"Clicked on row %d: %s from %s\\n\", idx, person.Name, person.City)\n\t}\n\n\t// Handle sorting\n\thandleSort := func(column string, direction string) {\n\t\tfmt.Printf(\"Sorting by %s in %s order\\n\", column, direction)\n\t}\n\n\treturn vdom.H(\"div\", map[string]any{\n\t\t\"className\": \"max-w-6xl mx-auto p-6 space-y-6\",\n\t},\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"text-center\",\n\t\t},\n\t\t\tvdom.H(\"h1\", map[string]any{\n\t\t\t\t\"className\": \"text-3xl font-bold text-white mb-2\",\n\t\t\t}, \"Table Component Demo\"),\n\t\t\tvdom.H(\"p\", map[string]any{\n\t\t\t\t\"className\": \"text-gray-300\",\n\t\t\t}, \"Testing the Tsunami table component with sample data\"),\n\t\t),\n\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"bg-gray-800 p-4 rounded-lg\",\n\t\t},\n\t\t\tPersonTable(ui.TableProps[Person]{\n\t\t\t\tData:        sampleData.Get(),\n\t\t\t\tColumns:     columns,\n\t\t\t\tOnRowClick:  handleRowClick,\n\t\t\t\tOnSort:      handleSort,\n\t\t\t\tDefaultSort: \"Name\",\n\t\t\t\tSelectable:  true,\n\t\t\t\tPagination: &ui.PaginationConfig{\n\t\t\t\t\tPageSize:    5,\n\t\t\t\t\tCurrentPage: 0,\n\t\t\t\t\tShowSizes:   []int{5, 10, 25},\n\t\t\t\t},\n\t\t\t}),\n\t\t),\n\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"text-center text-gray-400 text-sm\",\n\t\t}, \"Click on rows to see interactions. Try sorting by clicking column headers.\"),\n\t)\n})"
  },
  {
    "path": "tsunami/demo/tabletest/go.mod",
    "content": "module tsunami/app/tabletest\n\ngo 1.25.6\n\nrequire github.com/wavetermdev/waveterm/tsunami v0.0.0\n\nrequire (\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/outrigdev/goid v0.3.0 // indirect\n)\n\nreplace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami\n"
  },
  {
    "path": "tsunami/demo/tabletest/go.sum",
    "content": "github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=\ngithub.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=\n"
  },
  {
    "path": "tsunami/demo/tabletest/static/tw.css",
    "content": "/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */\n@layer properties;\n@layer theme, base, components, utilities;\n@layer theme {\n  :root, :host {\n    --font-sans: \"Inter\", sans-serif;\n    --font-mono: \"Hack\", monospace;\n    --color-red-100: oklch(93.6% 0.032 17.717);\n    --color-red-500: oklch(63.7% 0.237 25.331);\n    --color-red-800: oklch(44.4% 0.177 26.899);\n    --color-blue-400: oklch(70.7% 0.165 254.624);\n    --color-blue-500: oklch(62.3% 0.214 259.815);\n    --color-blue-600: oklch(54.6% 0.245 262.881);\n    --color-blue-700: oklch(48.8% 0.243 264.376);\n    --color-blue-900: oklch(37.9% 0.146 265.522);\n    --color-gray-200: oklch(92.8% 0.006 264.531);\n    --color-gray-300: oklch(87.2% 0.01 258.338);\n    --color-gray-400: oklch(70.7% 0.022 261.325);\n    --color-gray-500: oklch(55.1% 0.027 264.364);\n    --color-gray-600: oklch(44.6% 0.03 256.802);\n    --color-gray-700: oklch(37.3% 0.034 259.733);\n    --color-gray-800: oklch(27.8% 0.033 256.848);\n    --color-gray-900: oklch(21% 0.034 264.665);\n    --color-white: #fff;\n    --spacing: 0.25rem;\n    --container-6xl: 72rem;\n    --text-sm: 0.875rem;\n    --text-sm--line-height: calc(1.25 / 0.875);\n    --text-base: 1rem;\n    --text-base--line-height: calc(1.5 / 1);\n    --text-lg: 1.125rem;\n    --text-lg--line-height: calc(1.75 / 1.125);\n    --text-xl: 1.25rem;\n    --text-xl--line-height: calc(1.75 / 1.25);\n    --text-2xl: 1.5rem;\n    --text-2xl--line-height: calc(2 / 1.5);\n    --text-3xl: 1.875rem;\n    --text-3xl--line-height: calc(2.25 / 1.875);\n    --font-weight-semibold: 600;\n    --font-weight-bold: 700;\n    --leading-relaxed: 1.625;\n    --radius-lg: 0.5rem;\n    --ease-in: cubic-bezier(0.4, 0, 1, 1);\n    --ease-out: cubic-bezier(0, 0, 0.2, 1);\n    --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-transition-duration: 150ms;\n    --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-font-family: var(--font-sans);\n    --default-mono-font-family: var(--font-mono);\n    --radius: 8px;\n    --color-background: rgb(34, 34, 34);\n    --color-primary: rgb(247, 247, 247);\n    --color-secondary: rgba(215, 218, 224, 0.7);\n    --color-muted: rgba(215, 218, 224, 0.5);\n    --color-accent-300: rgb(110, 231, 133);\n    --color-panel: rgba(255, 255, 255, 0.12);\n    --color-border: rgba(255, 255, 255, 0.16);\n    --color-accent: rgb(88, 193, 66);\n  }\n}\n@layer base {\n  *, ::after, ::before, ::backdrop, ::file-selector-button {\n    box-sizing: border-box;\n    margin: 0;\n    padding: 0;\n    border: 0 solid;\n  }\n  html, :host {\n    line-height: 1.5;\n    -webkit-text-size-adjust: 100%;\n    tab-size: 4;\n    font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\");\n    font-feature-settings: var(--default-font-feature-settings, normal);\n    font-variation-settings: var(--default-font-variation-settings, normal);\n    -webkit-tap-highlight-color: transparent;\n  }\n  hr {\n    height: 0;\n    color: inherit;\n    border-top-width: 1px;\n  }\n  abbr:where([title]) {\n    -webkit-text-decoration: underline dotted;\n    text-decoration: underline dotted;\n  }\n  h1, h2, h3, h4, h5, h6 {\n    font-size: inherit;\n    font-weight: inherit;\n  }\n  a {\n    color: inherit;\n    -webkit-text-decoration: inherit;\n    text-decoration: inherit;\n  }\n  b, strong {\n    font-weight: bolder;\n  }\n  code, kbd, samp, pre {\n    font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace);\n    font-feature-settings: var(--default-mono-font-feature-settings, normal);\n    font-variation-settings: var(--default-mono-font-variation-settings, normal);\n    font-size: 1em;\n  }\n  small {\n    font-size: 80%;\n  }\n  sub, sup {\n    font-size: 75%;\n    line-height: 0;\n    position: relative;\n    vertical-align: baseline;\n  }\n  sub {\n    bottom: -0.25em;\n  }\n  sup {\n    top: -0.5em;\n  }\n  table {\n    text-indent: 0;\n    border-color: inherit;\n    border-collapse: collapse;\n  }\n  :-moz-focusring {\n    outline: auto;\n  }\n  progress {\n    vertical-align: baseline;\n  }\n  summary {\n    display: list-item;\n  }\n  ol, ul, menu {\n    list-style: none;\n  }\n  img, svg, video, canvas, audio, iframe, embed, object {\n    display: block;\n    vertical-align: middle;\n  }\n  img, video {\n    max-width: 100%;\n    height: auto;\n  }\n  button, input, select, optgroup, textarea, ::file-selector-button {\n    font: inherit;\n    font-feature-settings: inherit;\n    font-variation-settings: inherit;\n    letter-spacing: inherit;\n    color: inherit;\n    border-radius: 0;\n    background-color: transparent;\n    opacity: 1;\n  }\n  :where(select:is([multiple], [size])) optgroup {\n    font-weight: bolder;\n  }\n  :where(select:is([multiple], [size])) optgroup option {\n    padding-inline-start: 20px;\n  }\n  ::file-selector-button {\n    margin-inline-end: 4px;\n  }\n  ::placeholder {\n    opacity: 1;\n  }\n  @supports (not (-webkit-appearance: -apple-pay-button))  or (contain-intrinsic-size: 1px) {\n    ::placeholder {\n      color: currentcolor;\n      @supports (color: color-mix(in lab, red, red)) {\n        color: color-mix(in oklab, currentcolor 50%, transparent);\n      }\n    }\n  }\n  textarea {\n    resize: vertical;\n  }\n  ::-webkit-search-decoration {\n    -webkit-appearance: none;\n  }\n  ::-webkit-date-and-time-value {\n    min-height: 1lh;\n    text-align: inherit;\n  }\n  ::-webkit-datetime-edit {\n    display: inline-flex;\n  }\n  ::-webkit-datetime-edit-fields-wrapper {\n    padding: 0;\n  }\n  ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n    padding-block: 0;\n  }\n  ::-webkit-calendar-picker-indicator {\n    line-height: 1;\n  }\n  :-moz-ui-invalid {\n    box-shadow: none;\n  }\n  button, input:where([type=\"button\"], [type=\"reset\"], [type=\"submit\"]), ::file-selector-button {\n    appearance: button;\n  }\n  ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n    height: auto;\n  }\n  [hidden]:where(:not([hidden=\"until-found\"])) {\n    display: none !important;\n  }\n}\n@layer utilities {\n  .collapse {\n    visibility: collapse;\n  }\n  .invisible {\n    visibility: hidden;\n  }\n  .visible {\n    visibility: visible;\n  }\n  .sr-only {\n    position: absolute;\n    width: 1px;\n    height: 1px;\n    padding: 0;\n    margin: -1px;\n    overflow: hidden;\n    clip-path: inset(50%);\n    white-space: nowrap;\n    border-width: 0;\n  }\n  .not-sr-only {\n    position: static;\n    width: auto;\n    height: auto;\n    padding: 0;\n    margin: 0;\n    overflow: visible;\n    clip-path: none;\n    white-space: normal;\n  }\n  .absolute {\n    position: absolute;\n  }\n  .fixed {\n    position: fixed;\n  }\n  .relative {\n    position: relative;\n  }\n  .static {\n    position: static;\n  }\n  .sticky {\n    position: sticky;\n  }\n  .isolate {\n    isolation: isolate;\n  }\n  .isolation-auto {\n    isolation: auto;\n  }\n  .container {\n    width: 100%;\n    @media (width >= 40rem) {\n      max-width: 40rem;\n    }\n    @media (width >= 48rem) {\n      max-width: 48rem;\n    }\n    @media (width >= 64rem) {\n      max-width: 64rem;\n    }\n    @media (width >= 80rem) {\n      max-width: 80rem;\n    }\n    @media (width >= 96rem) {\n      max-width: 96rem;\n    }\n  }\n  .mx-1 {\n    margin-inline: calc(var(--spacing) * 1);\n  }\n  .mx-auto {\n    margin-inline: auto;\n  }\n  .my-6 {\n    margin-block: calc(var(--spacing) * 6);\n  }\n  .mt-3 {\n    margin-top: calc(var(--spacing) * 3);\n  }\n  .mt-4 {\n    margin-top: calc(var(--spacing) * 4);\n  }\n  .mt-5 {\n    margin-top: calc(var(--spacing) * 5);\n  }\n  .mt-6 {\n    margin-top: calc(var(--spacing) * 6);\n  }\n  .mb-2 {\n    margin-bottom: calc(var(--spacing) * 2);\n  }\n  .mb-3 {\n    margin-bottom: calc(var(--spacing) * 3);\n  }\n  .mb-4 {\n    margin-bottom: calc(var(--spacing) * 4);\n  }\n  .ml-4 {\n    margin-left: calc(var(--spacing) * 4);\n  }\n  .block {\n    display: block;\n  }\n  .contents {\n    display: contents;\n  }\n  .flex {\n    display: flex;\n  }\n  .flow-root {\n    display: flow-root;\n  }\n  .grid {\n    display: grid;\n  }\n  .hidden {\n    display: none;\n  }\n  .inline {\n    display: inline;\n  }\n  .inline-block {\n    display: inline-block;\n  }\n  .inline-flex {\n    display: inline-flex;\n  }\n  .inline-grid {\n    display: inline-grid;\n  }\n  .inline-table {\n    display: inline-table;\n  }\n  .list-item {\n    display: list-item;\n  }\n  .table {\n    display: table;\n  }\n  .table-caption {\n    display: table-caption;\n  }\n  .table-cell {\n    display: table-cell;\n  }\n  .table-column {\n    display: table-column;\n  }\n  .table-column-group {\n    display: table-column-group;\n  }\n  .table-footer-group {\n    display: table-footer-group;\n  }\n  .table-header-group {\n    display: table-header-group;\n  }\n  .table-row {\n    display: table-row;\n  }\n  .table-row-group {\n    display: table-row-group;\n  }\n  .min-h-full {\n    min-height: 100%;\n  }\n  .min-h-screen {\n    min-height: 100vh;\n  }\n  .w-full {\n    width: 100%;\n  }\n  .max-w-6xl {\n    max-width: var(--container-6xl);\n  }\n  .max-w-none {\n    max-width: none;\n  }\n  .min-w-full {\n    min-width: 100%;\n  }\n  .shrink {\n    flex-shrink: 1;\n  }\n  .grow {\n    flex-grow: 1;\n  }\n  .border-collapse {\n    border-collapse: collapse;\n  }\n  .translate-none {\n    translate: none;\n  }\n  .scale-3d {\n    scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);\n  }\n  .transform {\n    transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);\n  }\n  .cursor-pointer {\n    cursor: pointer;\n  }\n  .touch-pinch-zoom {\n    --tw-pinch-zoom: pinch-zoom;\n    touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);\n  }\n  .resize {\n    resize: both;\n  }\n  .list-inside {\n    list-style-position: inside;\n  }\n  .list-decimal {\n    list-style-type: decimal;\n  }\n  .list-disc {\n    list-style-type: disc;\n  }\n  .flex-wrap {\n    flex-wrap: wrap;\n  }\n  .items-center {\n    align-items: center;\n  }\n  .justify-between {\n    justify-content: space-between;\n  }\n  .gap-2 {\n    gap: calc(var(--spacing) * 2);\n  }\n  .gap-3 {\n    gap: calc(var(--spacing) * 3);\n  }\n  .space-y-1 {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 0;\n      margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));\n      margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));\n    }\n  }\n  .space-y-6 {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 0;\n      margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));\n      margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));\n    }\n  }\n  .space-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 1;\n    }\n  }\n  .space-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-x-reverse: 1;\n    }\n  }\n  .divide-x {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 0;\n      border-inline-style: var(--tw-border-style);\n      border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));\n      border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));\n    }\n  }\n  .divide-y {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 0;\n      border-bottom-style: var(--tw-border-style);\n      border-top-style: var(--tw-border-style);\n      border-top-width: calc(1px * var(--tw-divide-y-reverse));\n      border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));\n    }\n  }\n  .divide-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 1;\n    }\n  }\n  .divide-gray-700 {\n    :where(& > :not(:last-child)) {\n      border-color: var(--color-gray-700);\n    }\n  }\n  .truncate {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .overflow-auto {\n    overflow: auto;\n  }\n  .overflow-x-auto {\n    overflow-x: auto;\n  }\n  .rounded {\n    border-radius: var(--radius);\n  }\n  .rounded-lg {\n    border-radius: var(--radius-lg);\n  }\n  .rounded-s {\n    border-start-start-radius: var(--radius);\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-ss {\n    border-start-start-radius: var(--radius);\n  }\n  .rounded-e {\n    border-start-end-radius: var(--radius);\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-se {\n    border-start-end-radius: var(--radius);\n  }\n  .rounded-ee {\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-es {\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-t {\n    border-top-left-radius: var(--radius);\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-l {\n    border-top-left-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-tl {\n    border-top-left-radius: var(--radius);\n  }\n  .rounded-r {\n    border-top-right-radius: var(--radius);\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-tr {\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-b {\n    border-bottom-right-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-br {\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-bl {\n    border-bottom-left-radius: var(--radius);\n  }\n  .border {\n    border-style: var(--tw-border-style);\n    border-width: 1px;\n  }\n  .border-x {\n    border-inline-style: var(--tw-border-style);\n    border-inline-width: 1px;\n  }\n  .border-y {\n    border-block-style: var(--tw-border-style);\n    border-block-width: 1px;\n  }\n  .border-s {\n    border-inline-start-style: var(--tw-border-style);\n    border-inline-start-width: 1px;\n  }\n  .border-e {\n    border-inline-end-style: var(--tw-border-style);\n    border-inline-end-width: 1px;\n  }\n  .border-t {\n    border-top-style: var(--tw-border-style);\n    border-top-width: 1px;\n  }\n  .border-r {\n    border-right-style: var(--tw-border-style);\n    border-right-width: 1px;\n  }\n  .border-b {\n    border-bottom-style: var(--tw-border-style);\n    border-bottom-width: 1px;\n  }\n  .border-l {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 1px;\n  }\n  .border-l-4 {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 4px;\n  }\n  .border-border {\n    border-color: var(--color-border);\n  }\n  .border-gray-600 {\n    border-color: var(--color-gray-600);\n  }\n  .border-red-500 {\n    border-color: var(--color-red-500);\n  }\n  .bg-background {\n    background-color: var(--color-background);\n  }\n  .bg-blue-600 {\n    background-color: var(--color-blue-600);\n  }\n  .bg-blue-900 {\n    background-color: var(--color-blue-900);\n  }\n  .bg-gray-600 {\n    background-color: var(--color-gray-600);\n  }\n  .bg-gray-700 {\n    background-color: var(--color-gray-700);\n  }\n  .bg-gray-800 {\n    background-color: var(--color-gray-800);\n  }\n  .bg-gray-900 {\n    background-color: var(--color-gray-900);\n  }\n  .bg-panel {\n    background-color: var(--color-panel);\n  }\n  .bg-red-100 {\n    background-color: var(--color-red-100);\n  }\n  .bg-repeat {\n    background-repeat: repeat;\n  }\n  .mask-no-clip {\n    mask-clip: no-clip;\n  }\n  .mask-repeat {\n    mask-repeat: repeat;\n  }\n  .p-3 {\n    padding: calc(var(--spacing) * 3);\n  }\n  .p-4 {\n    padding: calc(var(--spacing) * 4);\n  }\n  .p-6 {\n    padding: calc(var(--spacing) * 6);\n  }\n  .px-1 {\n    padding-inline: calc(var(--spacing) * 1);\n  }\n  .px-2 {\n    padding-inline: calc(var(--spacing) * 2);\n  }\n  .px-3 {\n    padding-inline: calc(var(--spacing) * 3);\n  }\n  .px-4 {\n    padding-inline: calc(var(--spacing) * 4);\n  }\n  .py-0\\.5 {\n    padding-block: calc(var(--spacing) * 0.5);\n  }\n  .py-1 {\n    padding-block: calc(var(--spacing) * 1);\n  }\n  .py-1\\.5 {\n    padding-block: calc(var(--spacing) * 1.5);\n  }\n  .py-2 {\n    padding-block: calc(var(--spacing) * 2);\n  }\n  .py-3 {\n    padding-block: calc(var(--spacing) * 3);\n  }\n  .pl-4 {\n    padding-left: calc(var(--spacing) * 4);\n  }\n  .text-center {\n    text-align: center;\n  }\n  .text-left {\n    text-align: left;\n  }\n  .font-mono {\n    font-family: var(--font-mono);\n  }\n  .text-2xl {\n    font-size: var(--text-2xl);\n    line-height: var(--tw-leading, var(--text-2xl--line-height));\n  }\n  .text-3xl {\n    font-size: var(--text-3xl);\n    line-height: var(--tw-leading, var(--text-3xl--line-height));\n  }\n  .text-base {\n    font-size: var(--text-base);\n    line-height: var(--tw-leading, var(--text-base--line-height));\n  }\n  .text-lg {\n    font-size: var(--text-lg);\n    line-height: var(--tw-leading, var(--text-lg--line-height));\n  }\n  .text-sm {\n    font-size: var(--text-sm);\n    line-height: var(--tw-leading, var(--text-sm--line-height));\n  }\n  .text-xl {\n    font-size: var(--text-xl);\n    line-height: var(--tw-leading, var(--text-xl--line-height));\n  }\n  .leading-relaxed {\n    --tw-leading: var(--leading-relaxed);\n    line-height: var(--leading-relaxed);\n  }\n  .font-bold {\n    --tw-font-weight: var(--font-weight-bold);\n    font-weight: var(--font-weight-bold);\n  }\n  .font-semibold {\n    --tw-font-weight: var(--font-weight-semibold);\n    font-weight: var(--font-weight-semibold);\n  }\n  .text-wrap {\n    text-wrap: wrap;\n  }\n  .text-clip {\n    text-overflow: clip;\n  }\n  .text-ellipsis {\n    text-overflow: ellipsis;\n  }\n  .text-accent {\n    color: var(--color-accent);\n  }\n  .text-blue-400 {\n    color: var(--color-blue-400);\n  }\n  .text-gray-200 {\n    color: var(--color-gray-200);\n  }\n  .text-gray-300 {\n    color: var(--color-gray-300);\n  }\n  .text-gray-400 {\n    color: var(--color-gray-400);\n  }\n  .text-gray-500 {\n    color: var(--color-gray-500);\n  }\n  .text-muted {\n    color: var(--color-muted);\n  }\n  .text-primary {\n    color: var(--color-primary);\n  }\n  .text-red-800 {\n    color: var(--color-red-800);\n  }\n  .text-secondary {\n    color: var(--color-secondary);\n  }\n  .text-white {\n    color: var(--color-white);\n  }\n  .capitalize {\n    text-transform: capitalize;\n  }\n  .lowercase {\n    text-transform: lowercase;\n  }\n  .normal-case {\n    text-transform: none;\n  }\n  .uppercase {\n    text-transform: uppercase;\n  }\n  .italic {\n    font-style: italic;\n  }\n  .not-italic {\n    font-style: normal;\n  }\n  .diagonal-fractions {\n    --tw-numeric-fraction: diagonal-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .lining-nums {\n    --tw-numeric-figure: lining-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .oldstyle-nums {\n    --tw-numeric-figure: oldstyle-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .ordinal {\n    --tw-ordinal: ordinal;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .proportional-nums {\n    --tw-numeric-spacing: proportional-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .slashed-zero {\n    --tw-slashed-zero: slashed-zero;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .stacked-fractions {\n    --tw-numeric-fraction: stacked-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .tabular-nums {\n    --tw-numeric-spacing: tabular-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .normal-nums {\n    font-variant-numeric: normal;\n  }\n  .line-through {\n    text-decoration-line: line-through;\n  }\n  .no-underline {\n    text-decoration-line: none;\n  }\n  .overline {\n    text-decoration-line: overline;\n  }\n  .underline {\n    text-decoration-line: underline;\n  }\n  .antialiased {\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n  .subpixel-antialiased {\n    -webkit-font-smoothing: auto;\n    -moz-osx-font-smoothing: auto;\n  }\n  .shadow {\n    --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .inset-ring {\n    --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor);\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .blur {\n    --tw-blur: blur(8px);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .drop-shadow {\n    --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));\n    --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .invert {\n    --tw-invert: invert(100%);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .filter {\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .backdrop-blur {\n    --tw-backdrop-blur: blur(8px);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-grayscale {\n    --tw-backdrop-grayscale: grayscale(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-invert {\n    --tw-backdrop-invert: invert(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-sepia {\n    --tw-backdrop-sepia: sepia(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-filter {\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .transition-colors {\n    transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;\n    transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));\n    transition-duration: var(--tw-duration, var(--default-transition-duration));\n  }\n  .ease-in {\n    --tw-ease: var(--ease-in);\n    transition-timing-function: var(--ease-in);\n  }\n  .ease-in-out {\n    --tw-ease: var(--ease-in-out);\n    transition-timing-function: var(--ease-in-out);\n  }\n  .ease-out {\n    --tw-ease: var(--ease-out);\n    transition-timing-function: var(--ease-out);\n  }\n  .divide-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 1;\n    }\n  }\n  .ring-inset {\n    --tw-ring-inset: inset;\n  }\n  .hover\\:bg-blue-700 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-blue-700);\n      }\n    }\n  }\n  .hover\\:bg-gray-700 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-gray-700);\n      }\n    }\n  }\n  .hover\\:bg-gray-800 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-gray-800);\n      }\n    }\n  }\n  .hover\\:text-accent-300 {\n    &:hover {\n      @media (hover: hover) {\n        color: var(--color-accent-300);\n      }\n    }\n  }\n  .focus\\:bg-gray-600 {\n    &:focus {\n      background-color: var(--color-gray-600);\n    }\n  }\n  .focus\\:ring-1 {\n    &:focus {\n      --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n      box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n    }\n  }\n  .focus\\:ring-blue-500 {\n    &:focus {\n      --tw-ring-color: var(--color-blue-500);\n    }\n  }\n  .focus\\:outline-none {\n    &:focus {\n      --tw-outline-style: none;\n      outline-style: none;\n    }\n  }\n}\n@property --tw-scale-x {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-y {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-z {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-rotate-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-z {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pinch-zoom {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-space-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-space-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-divide-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-border-style {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: solid;\n}\n@property --tw-divide-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-leading {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-font-weight {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ordinal {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-slashed-zero {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-figure {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-spacing {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-fraction {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-inset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-offset-width {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ease {\n  syntax: \"*\";\n  inherits: false;\n}\n@layer properties {\n  @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n    *, ::before, ::after, ::backdrop {\n      --tw-scale-x: 1;\n      --tw-scale-y: 1;\n      --tw-scale-z: 1;\n      --tw-rotate-x: initial;\n      --tw-rotate-y: initial;\n      --tw-rotate-z: initial;\n      --tw-skew-x: initial;\n      --tw-skew-y: initial;\n      --tw-pan-x: initial;\n      --tw-pan-y: initial;\n      --tw-pinch-zoom: initial;\n      --tw-space-y-reverse: 0;\n      --tw-space-x-reverse: 0;\n      --tw-divide-x-reverse: 0;\n      --tw-border-style: solid;\n      --tw-divide-y-reverse: 0;\n      --tw-leading: initial;\n      --tw-font-weight: initial;\n      --tw-ordinal: initial;\n      --tw-slashed-zero: initial;\n      --tw-numeric-figure: initial;\n      --tw-numeric-spacing: initial;\n      --tw-numeric-fraction: initial;\n      --tw-shadow: 0 0 #0000;\n      --tw-shadow-color: initial;\n      --tw-shadow-alpha: 100%;\n      --tw-inset-shadow: 0 0 #0000;\n      --tw-inset-shadow-color: initial;\n      --tw-inset-shadow-alpha: 100%;\n      --tw-ring-color: initial;\n      --tw-ring-shadow: 0 0 #0000;\n      --tw-inset-ring-color: initial;\n      --tw-inset-ring-shadow: 0 0 #0000;\n      --tw-ring-inset: initial;\n      --tw-ring-offset-width: 0px;\n      --tw-ring-offset-color: #fff;\n      --tw-ring-offset-shadow: 0 0 #0000;\n      --tw-blur: initial;\n      --tw-brightness: initial;\n      --tw-contrast: initial;\n      --tw-grayscale: initial;\n      --tw-hue-rotate: initial;\n      --tw-invert: initial;\n      --tw-opacity: initial;\n      --tw-saturate: initial;\n      --tw-sepia: initial;\n      --tw-drop-shadow: initial;\n      --tw-drop-shadow-color: initial;\n      --tw-drop-shadow-alpha: 100%;\n      --tw-drop-shadow-size: initial;\n      --tw-backdrop-blur: initial;\n      --tw-backdrop-brightness: initial;\n      --tw-backdrop-contrast: initial;\n      --tw-backdrop-grayscale: initial;\n      --tw-backdrop-hue-rotate: initial;\n      --tw-backdrop-invert: initial;\n      --tw-backdrop-opacity: initial;\n      --tw-backdrop-saturate: initial;\n      --tw-backdrop-sepia: initial;\n      --tw-ease: initial;\n    }\n  }\n}\n"
  },
  {
    "path": "tsunami/demo/todo/app.go",
    "content": "package main\n\nimport (\n\t_ \"embed\"\n\t\"strconv\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/app\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\nvar AppMeta = app.AppMeta{\n\tTitle:     \"Todo App (Tsunami Demo)\",\n\tShortDesc: \"Feature-rich todo list with component composition and state management\",\n}\n\n// Basic domain types with json tags for props\ntype Todo struct {\n\tId        int    `json:\"id\"`\n\tText      string `json:\"text\"`\n\tCompleted bool   `json:\"completed\"`\n}\n\n// Prop types demonstrate parent->child data flow\ntype TodoListProps struct {\n\tTodos    []Todo    `json:\"todos\"`\n\tOnToggle func(int) `json:\"onToggle\"`\n\tOnDelete func(int) `json:\"onDelete\"`\n}\n\ntype TodoItemProps struct {\n\tTodo     Todo   `json:\"todo\"`\n\tOnToggle func() `json:\"onToggle\"`\n\tOnDelete func() `json:\"onDelete\"`\n}\n\ntype InputFieldProps struct {\n\tValue    string       `json:\"value\"`\n\tOnChange func(string) `json:\"onChange\"`\n\tOnEnter  func()       `json:\"onEnter\"`\n}\n\n// Reusable input component showing keyboard event handling\nvar InputField = app.DefineComponent(\"InputField\", func(props InputFieldProps) any {\n\t// Example of special key handling with VDomFunc\n\tkeyDown := &vdom.VDomFunc{\n\t\tType:            vdom.ObjectType_Func,\n\t\tFn:              func(event vdom.VDomEvent) { props.OnEnter() },\n\t\tStopPropagation: true,\n\t\tPreventDefault:  true,\n\t\tKeys:            []string{\"Enter\", \"Cmd:Enter\"},\n\t}\n\n\treturn vdom.H(\"input\", map[string]any{\n\t\t\"className\":   \"flex-1 p-2 border border-border rounded\",\n\t\t\"type\":        \"text\",\n\t\t\"placeholder\": \"What needs to be done?\",\n\t\t\"value\":       props.Value,\n\t\t\"onChange\": func(e vdom.VDomEvent) {\n\t\t\tprops.OnChange(e.TargetValue)\n\t\t},\n\t\t\"onKeyDown\": keyDown,\n\t})\n},\n)\n\n// Item component showing conditional classes and event handling\nvar TodoItem = app.DefineComponent(\"TodoItem\", func(props TodoItemProps) any {\n\treturn vdom.H(\"div\", map[string]any{\n\t\t\"className\": vdom.Classes(\"flex items-center gap-2.5 p-2 border border-border rounded\", vdom.If(props.Todo.Completed, \"opacity-70\")),\n\t},\n\t\tvdom.H(\"input\", map[string]any{\n\t\t\t\"className\": \"w-4 h-4\",\n\t\t\t\"type\":      \"checkbox\",\n\t\t\t\"checked\":   props.Todo.Completed,\n\t\t\t\"onChange\":  props.OnToggle,\n\t\t}),\n\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\"className\": vdom.Classes(\"flex-1\", vdom.If(props.Todo.Completed, \"line-through\")),\n\t\t}, props.Todo.Text),\n\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\"className\": \"text-red-500 cursor-pointer px-2 py-1 rounded\",\n\t\t\t\"onClick\":   props.OnDelete,\n\t\t}, \"×\"),\n\t)\n},\n)\n\n// List component demonstrating mapping over data, using WithKey to set key on a component\nvar TodoList = app.DefineComponent(\"TodoList\", func(props TodoListProps) any {\n\treturn vdom.H(\"div\", map[string]any{\n\t\t\"className\": \"flex flex-col gap-2\",\n\t}, vdom.ForEach(props.Todos, func(todo Todo, _ int) any {\n\t\treturn TodoItem(TodoItemProps{\n\t\t\tTodo:     todo,\n\t\t\tOnToggle: func() { props.OnToggle(todo.Id) },\n\t\t\tOnDelete: func() { props.OnDelete(todo.Id) },\n\t\t}).WithKey(strconv.Itoa(todo.Id))\n\t}))\n},\n)\n\n// Root component showing state management and composition\nvar App = app.DefineComponent(\"App\", func(_ any) any {\n\n\t// Multiple local atoms example\n\ttodosAtom := app.UseLocal([]Todo{\n\t\t{Id: 1, Text: \"Learn VDOM\", Completed: false},\n\t\t{Id: 2, Text: \"Build a todo app\", Completed: false},\n\t})\n\tnextIdAtom := app.UseLocal(3)\n\tinputTextAtom := app.UseLocal(\"\")\n\n\t// Event handlers modifying multiple pieces of state\n\taddTodo := func() {\n\t\tif inputTextAtom.Get() == \"\" {\n\t\t\treturn\n\t\t}\n\t\ttodosAtom.SetFn(func(todos []Todo) []Todo {\n\t\t\treturn append(todos, Todo{\n\t\t\t\tId:        nextIdAtom.Get(),\n\t\t\t\tText:      inputTextAtom.Get(),\n\t\t\t\tCompleted: false,\n\t\t\t})\n\t\t})\n\t\tnextIdAtom.Set(nextIdAtom.Get() + 1)\n\t\tinputTextAtom.Set(\"\")\n\t}\n\n\t// Immutable state update pattern\n\ttoggleTodo := func(id int) {\n\t\ttodosAtom.SetFn(func(todos []Todo) []Todo {\n\t\t\tfor i := range todos {\n\t\t\t\tif todos[i].Id == id {\n\t\t\t\t\ttodos[i].Completed = !todos[i].Completed\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn todos\n\t\t})\n\t}\n\n\tdeleteTodo := func(id int) {\n\t\ttodosAtom.SetFn(func(todos []Todo) []Todo {\n\t\t\tnewTodos := make([]Todo, 0, len(todos)-1)\n\t\t\tfor _, todo := range todos {\n\t\t\t\tif todo.Id != id {\n\t\t\t\t\tnewTodos = append(newTodos, todo)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn newTodos\n\t\t})\n\t}\n\n\treturn vdom.H(\"div\", map[string]any{\n\t\t\"className\": \"max-w-[500px] m-5 font-sans\",\n\t},\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"mb-5\",\n\t\t}, vdom.H(\"h1\", map[string]any{\n\t\t\t\"className\": \"text-2xl font-bold\",\n\t\t}, \"Todo List\")),\n\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"flex gap-2.5 mb-5\",\n\t\t},\n\t\t\tInputField(InputFieldProps{\n\t\t\t\tValue:    inputTextAtom.Get(),\n\t\t\t\tOnChange: inputTextAtom.Set,\n\t\t\t\tOnEnter:  addTodo,\n\t\t\t}),\n\t\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\t\"className\": \"px-4 py-2 border border-border rounded cursor-pointer\",\n\t\t\t\t\"onClick\":   addTodo,\n\t\t\t}, \"Add Todo\"),\n\t\t),\n\n\t\tTodoList(TodoListProps{\n\t\t\tTodos:    todosAtom.Get(),\n\t\t\tOnToggle: toggleTodo,\n\t\t\tOnDelete: deleteTodo,\n\t\t}),\n\t)\n},\n)\n"
  },
  {
    "path": "tsunami/demo/todo/go.mod",
    "content": "module tsunami/app/todo\n\ngo 1.25.6\n\nrequire github.com/wavetermdev/waveterm/tsunami v0.0.0\n\nrequire (\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/outrigdev/goid v0.3.0 // indirect\n)\n\nreplace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami\n"
  },
  {
    "path": "tsunami/demo/todo/go.sum",
    "content": "github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=\ngithub.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=\n"
  },
  {
    "path": "tsunami/demo/todo/static/tw.css",
    "content": "/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */\n@layer properties;\n@layer theme, base, components, utilities;\n@layer theme {\n  :root, :host {\n    --font-sans: \"Inter\", sans-serif;\n    --font-mono: \"Hack\", monospace;\n    --color-red-500: oklch(63.7% 0.237 25.331);\n    --spacing: 0.25rem;\n    --text-sm: 0.875rem;\n    --text-sm--line-height: calc(1.25 / 0.875);\n    --text-base: 1rem;\n    --text-base--line-height: calc(1.5 / 1);\n    --text-lg: 1.125rem;\n    --text-lg--line-height: calc(1.75 / 1.125);\n    --text-xl: 1.25rem;\n    --text-xl--line-height: calc(1.75 / 1.25);\n    --text-2xl: 1.5rem;\n    --text-2xl--line-height: calc(2 / 1.5);\n    --text-3xl: 1.875rem;\n    --text-3xl--line-height: calc(2.25 / 1.875);\n    --font-weight-bold: 700;\n    --leading-relaxed: 1.625;\n    --radius-lg: 0.5rem;\n    --ease-in: cubic-bezier(0.4, 0, 1, 1);\n    --ease-out: cubic-bezier(0, 0, 0.2, 1);\n    --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-transition-duration: 150ms;\n    --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-font-family: var(--font-sans);\n    --default-mono-font-family: var(--font-mono);\n    --radius: 8px;\n    --color-background: rgb(34, 34, 34);\n    --color-primary: rgb(247, 247, 247);\n    --color-secondary: rgba(215, 218, 224, 0.7);\n    --color-muted: rgba(215, 218, 224, 0.5);\n    --color-accent-300: rgb(110, 231, 133);\n    --color-panel: rgba(255, 255, 255, 0.12);\n    --color-border: rgba(255, 255, 255, 0.16);\n    --color-accent: rgb(88, 193, 66);\n  }\n}\n@layer base {\n  *, ::after, ::before, ::backdrop, ::file-selector-button {\n    box-sizing: border-box;\n    margin: 0;\n    padding: 0;\n    border: 0 solid;\n  }\n  html, :host {\n    line-height: 1.5;\n    -webkit-text-size-adjust: 100%;\n    tab-size: 4;\n    font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\");\n    font-feature-settings: var(--default-font-feature-settings, normal);\n    font-variation-settings: var(--default-font-variation-settings, normal);\n    -webkit-tap-highlight-color: transparent;\n  }\n  hr {\n    height: 0;\n    color: inherit;\n    border-top-width: 1px;\n  }\n  abbr:where([title]) {\n    -webkit-text-decoration: underline dotted;\n    text-decoration: underline dotted;\n  }\n  h1, h2, h3, h4, h5, h6 {\n    font-size: inherit;\n    font-weight: inherit;\n  }\n  a {\n    color: inherit;\n    -webkit-text-decoration: inherit;\n    text-decoration: inherit;\n  }\n  b, strong {\n    font-weight: bolder;\n  }\n  code, kbd, samp, pre {\n    font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace);\n    font-feature-settings: var(--default-mono-font-feature-settings, normal);\n    font-variation-settings: var(--default-mono-font-variation-settings, normal);\n    font-size: 1em;\n  }\n  small {\n    font-size: 80%;\n  }\n  sub, sup {\n    font-size: 75%;\n    line-height: 0;\n    position: relative;\n    vertical-align: baseline;\n  }\n  sub {\n    bottom: -0.25em;\n  }\n  sup {\n    top: -0.5em;\n  }\n  table {\n    text-indent: 0;\n    border-color: inherit;\n    border-collapse: collapse;\n  }\n  :-moz-focusring {\n    outline: auto;\n  }\n  progress {\n    vertical-align: baseline;\n  }\n  summary {\n    display: list-item;\n  }\n  ol, ul, menu {\n    list-style: none;\n  }\n  img, svg, video, canvas, audio, iframe, embed, object {\n    display: block;\n    vertical-align: middle;\n  }\n  img, video {\n    max-width: 100%;\n    height: auto;\n  }\n  button, input, select, optgroup, textarea, ::file-selector-button {\n    font: inherit;\n    font-feature-settings: inherit;\n    font-variation-settings: inherit;\n    letter-spacing: inherit;\n    color: inherit;\n    border-radius: 0;\n    background-color: transparent;\n    opacity: 1;\n  }\n  :where(select:is([multiple], [size])) optgroup {\n    font-weight: bolder;\n  }\n  :where(select:is([multiple], [size])) optgroup option {\n    padding-inline-start: 20px;\n  }\n  ::file-selector-button {\n    margin-inline-end: 4px;\n  }\n  ::placeholder {\n    opacity: 1;\n  }\n  @supports (not (-webkit-appearance: -apple-pay-button))  or (contain-intrinsic-size: 1px) {\n    ::placeholder {\n      color: currentcolor;\n      @supports (color: color-mix(in lab, red, red)) {\n        color: color-mix(in oklab, currentcolor 50%, transparent);\n      }\n    }\n  }\n  textarea {\n    resize: vertical;\n  }\n  ::-webkit-search-decoration {\n    -webkit-appearance: none;\n  }\n  ::-webkit-date-and-time-value {\n    min-height: 1lh;\n    text-align: inherit;\n  }\n  ::-webkit-datetime-edit {\n    display: inline-flex;\n  }\n  ::-webkit-datetime-edit-fields-wrapper {\n    padding: 0;\n  }\n  ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n    padding-block: 0;\n  }\n  ::-webkit-calendar-picker-indicator {\n    line-height: 1;\n  }\n  :-moz-ui-invalid {\n    box-shadow: none;\n  }\n  button, input:where([type=\"button\"], [type=\"reset\"], [type=\"submit\"]), ::file-selector-button {\n    appearance: button;\n  }\n  ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n    height: auto;\n  }\n  [hidden]:where(:not([hidden=\"until-found\"])) {\n    display: none !important;\n  }\n}\n@layer utilities {\n  .collapse {\n    visibility: collapse;\n  }\n  .invisible {\n    visibility: hidden;\n  }\n  .visible {\n    visibility: visible;\n  }\n  .sr-only {\n    position: absolute;\n    width: 1px;\n    height: 1px;\n    padding: 0;\n    margin: -1px;\n    overflow: hidden;\n    clip-path: inset(50%);\n    white-space: nowrap;\n    border-width: 0;\n  }\n  .not-sr-only {\n    position: static;\n    width: auto;\n    height: auto;\n    padding: 0;\n    margin: 0;\n    overflow: visible;\n    clip-path: none;\n    white-space: normal;\n  }\n  .absolute {\n    position: absolute;\n  }\n  .fixed {\n    position: fixed;\n  }\n  .relative {\n    position: relative;\n  }\n  .static {\n    position: static;\n  }\n  .sticky {\n    position: sticky;\n  }\n  .isolate {\n    isolation: isolate;\n  }\n  .isolation-auto {\n    isolation: auto;\n  }\n  .container {\n    width: 100%;\n    @media (width >= 40rem) {\n      max-width: 40rem;\n    }\n    @media (width >= 48rem) {\n      max-width: 48rem;\n    }\n    @media (width >= 64rem) {\n      max-width: 64rem;\n    }\n    @media (width >= 80rem) {\n      max-width: 80rem;\n    }\n    @media (width >= 96rem) {\n      max-width: 96rem;\n    }\n  }\n  .m-5 {\n    margin: calc(var(--spacing) * 5);\n  }\n  .my-6 {\n    margin-block: calc(var(--spacing) * 6);\n  }\n  .mt-3 {\n    margin-top: calc(var(--spacing) * 3);\n  }\n  .mt-4 {\n    margin-top: calc(var(--spacing) * 4);\n  }\n  .mt-5 {\n    margin-top: calc(var(--spacing) * 5);\n  }\n  .mt-6 {\n    margin-top: calc(var(--spacing) * 6);\n  }\n  .mb-2 {\n    margin-bottom: calc(var(--spacing) * 2);\n  }\n  .mb-3 {\n    margin-bottom: calc(var(--spacing) * 3);\n  }\n  .mb-4 {\n    margin-bottom: calc(var(--spacing) * 4);\n  }\n  .mb-5 {\n    margin-bottom: calc(var(--spacing) * 5);\n  }\n  .ml-4 {\n    margin-left: calc(var(--spacing) * 4);\n  }\n  .block {\n    display: block;\n  }\n  .contents {\n    display: contents;\n  }\n  .flex {\n    display: flex;\n  }\n  .flow-root {\n    display: flow-root;\n  }\n  .grid {\n    display: grid;\n  }\n  .hidden {\n    display: none;\n  }\n  .inline {\n    display: inline;\n  }\n  .inline-block {\n    display: inline-block;\n  }\n  .inline-flex {\n    display: inline-flex;\n  }\n  .inline-grid {\n    display: inline-grid;\n  }\n  .inline-table {\n    display: inline-table;\n  }\n  .list-item {\n    display: list-item;\n  }\n  .table {\n    display: table;\n  }\n  .table-caption {\n    display: table-caption;\n  }\n  .table-cell {\n    display: table-cell;\n  }\n  .table-column {\n    display: table-column;\n  }\n  .table-column-group {\n    display: table-column-group;\n  }\n  .table-footer-group {\n    display: table-footer-group;\n  }\n  .table-header-group {\n    display: table-header-group;\n  }\n  .table-row {\n    display: table-row;\n  }\n  .table-row-group {\n    display: table-row-group;\n  }\n  .h-4 {\n    height: calc(var(--spacing) * 4);\n  }\n  .min-h-full {\n    min-height: 100%;\n  }\n  .min-h-screen {\n    min-height: 100vh;\n  }\n  .w-4 {\n    width: calc(var(--spacing) * 4);\n  }\n  .w-full {\n    width: 100%;\n  }\n  .max-w-\\[500px\\] {\n    max-width: 500px;\n  }\n  .max-w-none {\n    max-width: none;\n  }\n  .min-w-full {\n    min-width: 100%;\n  }\n  .flex-1 {\n    flex: 1;\n  }\n  .shrink {\n    flex-shrink: 1;\n  }\n  .grow {\n    flex-grow: 1;\n  }\n  .border-collapse {\n    border-collapse: collapse;\n  }\n  .translate-none {\n    translate: none;\n  }\n  .scale-3d {\n    scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);\n  }\n  .transform {\n    transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);\n  }\n  .cursor-pointer {\n    cursor: pointer;\n  }\n  .touch-pinch-zoom {\n    --tw-pinch-zoom: pinch-zoom;\n    touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);\n  }\n  .resize {\n    resize: both;\n  }\n  .list-inside {\n    list-style-position: inside;\n  }\n  .list-decimal {\n    list-style-type: decimal;\n  }\n  .list-disc {\n    list-style-type: disc;\n  }\n  .flex-col {\n    flex-direction: column;\n  }\n  .flex-wrap {\n    flex-wrap: wrap;\n  }\n  .items-center {\n    align-items: center;\n  }\n  .gap-2 {\n    gap: calc(var(--spacing) * 2);\n  }\n  .gap-2\\.5 {\n    gap: calc(var(--spacing) * 2.5);\n  }\n  .space-y-1 {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 0;\n      margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));\n      margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));\n    }\n  }\n  .space-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 1;\n    }\n  }\n  .space-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-x-reverse: 1;\n    }\n  }\n  .divide-x {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 0;\n      border-inline-style: var(--tw-border-style);\n      border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));\n      border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));\n    }\n  }\n  .divide-y {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 0;\n      border-bottom-style: var(--tw-border-style);\n      border-top-style: var(--tw-border-style);\n      border-top-width: calc(1px * var(--tw-divide-y-reverse));\n      border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));\n    }\n  }\n  .divide-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 1;\n    }\n  }\n  .truncate {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .overflow-auto {\n    overflow: auto;\n  }\n  .overflow-x-auto {\n    overflow-x: auto;\n  }\n  .rounded {\n    border-radius: var(--radius);\n  }\n  .rounded-lg {\n    border-radius: var(--radius-lg);\n  }\n  .rounded-s {\n    border-start-start-radius: var(--radius);\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-ss {\n    border-start-start-radius: var(--radius);\n  }\n  .rounded-e {\n    border-start-end-radius: var(--radius);\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-se {\n    border-start-end-radius: var(--radius);\n  }\n  .rounded-ee {\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-es {\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-t {\n    border-top-left-radius: var(--radius);\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-l {\n    border-top-left-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-tl {\n    border-top-left-radius: var(--radius);\n  }\n  .rounded-r {\n    border-top-right-radius: var(--radius);\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-tr {\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-b {\n    border-bottom-right-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-br {\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-bl {\n    border-bottom-left-radius: var(--radius);\n  }\n  .border {\n    border-style: var(--tw-border-style);\n    border-width: 1px;\n  }\n  .border-x {\n    border-inline-style: var(--tw-border-style);\n    border-inline-width: 1px;\n  }\n  .border-y {\n    border-block-style: var(--tw-border-style);\n    border-block-width: 1px;\n  }\n  .border-s {\n    border-inline-start-style: var(--tw-border-style);\n    border-inline-start-width: 1px;\n  }\n  .border-e {\n    border-inline-end-style: var(--tw-border-style);\n    border-inline-end-width: 1px;\n  }\n  .border-t {\n    border-top-style: var(--tw-border-style);\n    border-top-width: 1px;\n  }\n  .border-r {\n    border-right-style: var(--tw-border-style);\n    border-right-width: 1px;\n  }\n  .border-b {\n    border-bottom-style: var(--tw-border-style);\n    border-bottom-width: 1px;\n  }\n  .border-l {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 1px;\n  }\n  .border-l-4 {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 4px;\n  }\n  .border-border {\n    border-color: var(--color-border);\n  }\n  .bg-background {\n    background-color: var(--color-background);\n  }\n  .bg-panel {\n    background-color: var(--color-panel);\n  }\n  .bg-repeat {\n    background-repeat: repeat;\n  }\n  .mask-no-clip {\n    mask-clip: no-clip;\n  }\n  .mask-repeat {\n    mask-repeat: repeat;\n  }\n  .p-2 {\n    padding: calc(var(--spacing) * 2);\n  }\n  .p-4 {\n    padding: calc(var(--spacing) * 4);\n  }\n  .px-1 {\n    padding-inline: calc(var(--spacing) * 1);\n  }\n  .px-2 {\n    padding-inline: calc(var(--spacing) * 2);\n  }\n  .px-4 {\n    padding-inline: calc(var(--spacing) * 4);\n  }\n  .py-0\\.5 {\n    padding-block: calc(var(--spacing) * 0.5);\n  }\n  .py-1 {\n    padding-block: calc(var(--spacing) * 1);\n  }\n  .py-2 {\n    padding-block: calc(var(--spacing) * 2);\n  }\n  .pl-4 {\n    padding-left: calc(var(--spacing) * 4);\n  }\n  .text-left {\n    text-align: left;\n  }\n  .font-mono {\n    font-family: var(--font-mono);\n  }\n  .font-sans {\n    font-family: var(--font-sans);\n  }\n  .text-2xl {\n    font-size: var(--text-2xl);\n    line-height: var(--tw-leading, var(--text-2xl--line-height));\n  }\n  .text-3xl {\n    font-size: var(--text-3xl);\n    line-height: var(--tw-leading, var(--text-3xl--line-height));\n  }\n  .text-base {\n    font-size: var(--text-base);\n    line-height: var(--tw-leading, var(--text-base--line-height));\n  }\n  .text-lg {\n    font-size: var(--text-lg);\n    line-height: var(--tw-leading, var(--text-lg--line-height));\n  }\n  .text-sm {\n    font-size: var(--text-sm);\n    line-height: var(--tw-leading, var(--text-sm--line-height));\n  }\n  .text-xl {\n    font-size: var(--text-xl);\n    line-height: var(--tw-leading, var(--text-xl--line-height));\n  }\n  .leading-relaxed {\n    --tw-leading: var(--leading-relaxed);\n    line-height: var(--leading-relaxed);\n  }\n  .font-bold {\n    --tw-font-weight: var(--font-weight-bold);\n    font-weight: var(--font-weight-bold);\n  }\n  .text-wrap {\n    text-wrap: wrap;\n  }\n  .text-clip {\n    text-overflow: clip;\n  }\n  .text-ellipsis {\n    text-overflow: ellipsis;\n  }\n  .text-accent {\n    color: var(--color-accent);\n  }\n  .text-muted {\n    color: var(--color-muted);\n  }\n  .text-primary {\n    color: var(--color-primary);\n  }\n  .text-red-500 {\n    color: var(--color-red-500);\n  }\n  .text-secondary {\n    color: var(--color-secondary);\n  }\n  .capitalize {\n    text-transform: capitalize;\n  }\n  .lowercase {\n    text-transform: lowercase;\n  }\n  .normal-case {\n    text-transform: none;\n  }\n  .uppercase {\n    text-transform: uppercase;\n  }\n  .italic {\n    font-style: italic;\n  }\n  .not-italic {\n    font-style: normal;\n  }\n  .diagonal-fractions {\n    --tw-numeric-fraction: diagonal-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .lining-nums {\n    --tw-numeric-figure: lining-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .oldstyle-nums {\n    --tw-numeric-figure: oldstyle-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .ordinal {\n    --tw-ordinal: ordinal;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .proportional-nums {\n    --tw-numeric-spacing: proportional-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .slashed-zero {\n    --tw-slashed-zero: slashed-zero;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .stacked-fractions {\n    --tw-numeric-fraction: stacked-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .tabular-nums {\n    --tw-numeric-spacing: tabular-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .normal-nums {\n    font-variant-numeric: normal;\n  }\n  .line-through {\n    text-decoration-line: line-through;\n  }\n  .no-underline {\n    text-decoration-line: none;\n  }\n  .overline {\n    text-decoration-line: overline;\n  }\n  .underline {\n    text-decoration-line: underline;\n  }\n  .antialiased {\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n  .subpixel-antialiased {\n    -webkit-font-smoothing: auto;\n    -moz-osx-font-smoothing: auto;\n  }\n  .opacity-70 {\n    opacity: 70%;\n  }\n  .shadow {\n    --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .inset-ring {\n    --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor);\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .blur {\n    --tw-blur: blur(8px);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .drop-shadow {\n    --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));\n    --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .invert {\n    --tw-invert: invert(100%);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .filter {\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .backdrop-blur {\n    --tw-backdrop-blur: blur(8px);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-grayscale {\n    --tw-backdrop-grayscale: grayscale(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-invert {\n    --tw-backdrop-invert: invert(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-sepia {\n    --tw-backdrop-sepia: sepia(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-filter {\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .ease-in {\n    --tw-ease: var(--ease-in);\n    transition-timing-function: var(--ease-in);\n  }\n  .ease-in-out {\n    --tw-ease: var(--ease-in-out);\n    transition-timing-function: var(--ease-in-out);\n  }\n  .ease-out {\n    --tw-ease: var(--ease-out);\n    transition-timing-function: var(--ease-out);\n  }\n  .divide-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 1;\n    }\n  }\n  .ring-inset {\n    --tw-ring-inset: inset;\n  }\n  .hover\\:text-accent-300 {\n    &:hover {\n      @media (hover: hover) {\n        color: var(--color-accent-300);\n      }\n    }\n  }\n}\n@property --tw-scale-x {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-y {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-z {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-rotate-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-z {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pinch-zoom {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-space-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-space-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-divide-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-border-style {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: solid;\n}\n@property --tw-divide-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-leading {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-font-weight {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ordinal {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-slashed-zero {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-figure {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-spacing {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-fraction {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-inset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-offset-width {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ease {\n  syntax: \"*\";\n  inherits: false;\n}\n@layer properties {\n  @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n    *, ::before, ::after, ::backdrop {\n      --tw-scale-x: 1;\n      --tw-scale-y: 1;\n      --tw-scale-z: 1;\n      --tw-rotate-x: initial;\n      --tw-rotate-y: initial;\n      --tw-rotate-z: initial;\n      --tw-skew-x: initial;\n      --tw-skew-y: initial;\n      --tw-pan-x: initial;\n      --tw-pan-y: initial;\n      --tw-pinch-zoom: initial;\n      --tw-space-y-reverse: 0;\n      --tw-space-x-reverse: 0;\n      --tw-divide-x-reverse: 0;\n      --tw-border-style: solid;\n      --tw-divide-y-reverse: 0;\n      --tw-leading: initial;\n      --tw-font-weight: initial;\n      --tw-ordinal: initial;\n      --tw-slashed-zero: initial;\n      --tw-numeric-figure: initial;\n      --tw-numeric-spacing: initial;\n      --tw-numeric-fraction: initial;\n      --tw-shadow: 0 0 #0000;\n      --tw-shadow-color: initial;\n      --tw-shadow-alpha: 100%;\n      --tw-inset-shadow: 0 0 #0000;\n      --tw-inset-shadow-color: initial;\n      --tw-inset-shadow-alpha: 100%;\n      --tw-ring-color: initial;\n      --tw-ring-shadow: 0 0 #0000;\n      --tw-inset-ring-color: initial;\n      --tw-inset-ring-shadow: 0 0 #0000;\n      --tw-ring-inset: initial;\n      --tw-ring-offset-width: 0px;\n      --tw-ring-offset-color: #fff;\n      --tw-ring-offset-shadow: 0 0 #0000;\n      --tw-blur: initial;\n      --tw-brightness: initial;\n      --tw-contrast: initial;\n      --tw-grayscale: initial;\n      --tw-hue-rotate: initial;\n      --tw-invert: initial;\n      --tw-opacity: initial;\n      --tw-saturate: initial;\n      --tw-sepia: initial;\n      --tw-drop-shadow: initial;\n      --tw-drop-shadow-color: initial;\n      --tw-drop-shadow-alpha: 100%;\n      --tw-drop-shadow-size: initial;\n      --tw-backdrop-blur: initial;\n      --tw-backdrop-brightness: initial;\n      --tw-backdrop-contrast: initial;\n      --tw-backdrop-grayscale: initial;\n      --tw-backdrop-hue-rotate: initial;\n      --tw-backdrop-invert: initial;\n      --tw-backdrop-opacity: initial;\n      --tw-backdrop-saturate: initial;\n      --tw-backdrop-sepia: initial;\n      --tw-ease: initial;\n    }\n  }\n}\n"
  },
  {
    "path": "tsunami/demo/todo/style.css",
    "content": ".todo-app {\n    max-width: 500px;\n    margin: 20px;\n    font-family: sans-serif;\n}\n.todo-header {\n    margin-bottom: 20px;\n}\n.todo-form {\n    display: flex;\n    gap: 10px;\n    margin-bottom: 20px;\n}\n.todo-input {\n    flex: 1;\n    padding: 8px;\n    border: 1px solid var(--border-color);\n    border-radius: 4px;\n    background: var(--input-bg);\n    color: var(--text-color);\n}\n.todo-button {\n    padding: 8px 16px;\n    background: var(--button-bg);\n    border: 1px solid var(--border-color);\n    border-radius: 4px;\n    color: var(--text-color);\n    cursor: pointer;\n}\n.todo-button:hover {\n    background: var(--button-hover-bg);\n}\n.todo-list {\n    display: flex;\n    flex-direction: column;\n    gap: 8px;\n}\n.todo-item {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    padding: 8px;\n    border: 1px solid var(--border-color);\n    border-radius: 4px;\n    background: var(--block-bg);\n}\n.todo-item.completed {\n    opacity: 0.7;\n}\n.todo-item.completed .todo-text {\n    text-decoration: line-through;\n}\n.todo-text {\n    flex: 1;\n}\n.todo-checkbox {\n    width: 16px;\n    height: 16px;\n}\n.todo-delete {\n    color: var(--error-color);\n    cursor: pointer;\n    padding: 4px 8px;\n    border-radius: 4px;\n}\n.todo-delete:hover {\n    background: var(--error-bg);\n}\n"
  },
  {
    "path": "tsunami/demo/tsunamiconfig/app.go",
    "content": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/app\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\nvar AppMeta = app.AppMeta{\n\tTitle:     \"Tsunami Config Manager\",\n\tShortDesc: \"Configuration editor for remote servers with JSON validation\",\n}\n\n// Global atoms for config\nvar (\n\tserverURLAtom = app.ConfigAtom(\"serverURL\", \"\", &app.AtomMeta{\n\t\tDesc:    \"Server URL for config API (can be full URL, hostname:port, or just port)\",\n\t\tPattern: `^(https?://.*|[a-zA-Z0-9.-]+:\\d+|\\d+|[a-zA-Z0-9.-]+)$`,\n\t})\n)\n\ntype URLInputProps struct {\n\tValue     string       `json:\"value\"`\n\tOnChange  func(string) `json:\"onChange\"`\n\tOnSubmit  func()       `json:\"onSubmit\"`\n\tIsLoading bool         `json:\"isLoading\"`\n}\n\ntype JSONEditorProps struct {\n\tValue       string       `json:\"value\"`\n\tOnChange    func(string) `json:\"onChange\"`\n\tOnSubmit    func()       `json:\"onSubmit\"`\n\tIsLoading   bool         `json:\"isLoading\"`\n\tPlaceholder string       `json:\"placeholder\"`\n}\n\ntype ErrorDisplayProps struct {\n\tMessage string `json:\"message\"`\n}\n\ntype SuccessDisplayProps struct {\n\tMessage string `json:\"message\"`\n}\n\n// parseURL takes flexible URL input and returns a normalized base URL\nfunc parseURL(input string) (string, error) {\n\tif input == \"\" {\n\t\treturn \"\", fmt.Errorf(\"URL cannot be empty\")\n\t}\n\n\tinput = strings.TrimSpace(input)\n\n\t// Handle just port number (e.g., \"52848\")\n\tif portRegex := regexp.MustCompile(`^\\d+$`); portRegex.MatchString(input) {\n\t\treturn fmt.Sprintf(\"http://localhost:%s\", input), nil\n\t}\n\n\t// Add http:// if no protocol specified\n\tif !strings.HasPrefix(input, \"http://\") && !strings.HasPrefix(input, \"https://\") {\n\t\tinput = \"http://\" + input\n\t}\n\n\t// Parse the URL to validate and extract components\n\tparsedURL, err := url.Parse(input)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"invalid URL format: %v\", err)\n\t}\n\n\tif parsedURL.Host == \"\" {\n\t\treturn \"\", fmt.Errorf(\"no host specified in URL\")\n\t}\n\n\t// Return base URL (scheme + host)\n\tbaseURL := fmt.Sprintf(\"%s://%s\", parsedURL.Scheme, parsedURL.Host)\n\treturn baseURL, nil\n}\n\n// fetchConfig fetches JSON from the /api/config endpoint\nfunc fetchConfig(baseURL string) (string, error) {\n\tconfigURL := baseURL + \"/api/config\"\n\n\tclient := &http.Client{Timeout: 10 * time.Second}\n\tresp, err := client.Get(configURL)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to connect to %s: %v\", configURL, err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"server returned status %d from %s\", resp.StatusCode, configURL)\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to read response: %v\", err)\n\t}\n\n\t// Validate that it's valid JSON\n\tvar jsonObj interface{}\n\tif err := json.Unmarshal(body, &jsonObj); err != nil {\n\t\treturn \"\", fmt.Errorf(\"response is not valid JSON: %v\", err)\n\t}\n\n\t// Pretty print the JSON\n\tprettyJSON, err := json.MarshalIndent(jsonObj, \"\", \"  \")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to format JSON: %v\", err)\n\t}\n\n\treturn string(prettyJSON), nil\n}\n\n// postConfig sends JSON to the /api/config endpoint\nfunc postConfig(baseURL, jsonContent string) error {\n\tconfigURL := baseURL + \"/api/config\"\n\n\t// Validate JSON before sending\n\tvar jsonObj interface{}\n\tif err := json.Unmarshal([]byte(jsonContent), &jsonObj); err != nil {\n\t\treturn fmt.Errorf(\"invalid JSON: %v\", err)\n\t}\n\n\tclient := &http.Client{Timeout: 10 * time.Second}\n\tresp, err := client.Post(configURL, \"application/json\", strings.NewReader(jsonContent))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to send request to %s: %v\", configURL, err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn fmt.Errorf(\"server returned status %d: %s\", resp.StatusCode, string(body))\n\t}\n\n\treturn nil\n}\n\nvar URLInput = app.DefineComponent(\"URLInput\",\n\tfunc(props URLInputProps) any {\n\t\tkeyHandler := &vdom.VDomFunc{\n\t\t\tType: \"func\",\n\t\t\tFn: func(event vdom.VDomEvent) {\n\t\t\t\tif !props.IsLoading {\n\t\t\t\t\tprops.OnSubmit()\n\t\t\t\t}\n\t\t\t},\n\t\t\tKeys:           []string{\"Enter\"},\n\t\t\tPreventDefault: true,\n\t\t}\n\n\t\treturn vdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"flex gap-2 mb-4\",\n\t\t},\n\t\t\tvdom.H(\"input\", map[string]any{\n\t\t\t\t\"className\":   \"flex-1 px-3 py-2 bg-slate-700 border border-slate-600 rounded text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500\",\n\t\t\t\t\"type\":        \"text\",\n\t\t\t\t\"placeholder\": \"Enter URL (e.g., localhost:52848, http://localhost:52848/api/config, or just 52848)\",\n\t\t\t\t\"value\":       props.Value,\n\t\t\t\t\"disabled\":    props.IsLoading,\n\t\t\t\t\"onChange\": func(e vdom.VDomEvent) {\n\t\t\t\t\tprops.OnChange(e.TargetValue)\n\t\t\t\t},\n\t\t\t\t\"onKeyDown\": keyHandler,\n\t\t\t}),\n\t\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\t\"className\": vdom.Classes(\n\t\t\t\t\t\"px-4 py-2 rounded font-medium cursor-pointer transition-colors\",\n\t\t\t\t\tvdom.IfElse(props.IsLoading,\n\t\t\t\t\t\t\"bg-slate-600 text-slate-400 cursor-not-allowed\",\n\t\t\t\t\t\t\"bg-blue-600 text-white hover:bg-blue-700\",\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t\t\"onClick\":  vdom.If(!props.IsLoading, props.OnSubmit),\n\t\t\t\t\"disabled\": props.IsLoading,\n\t\t\t}, vdom.IfElse(props.IsLoading, \"Loading...\", \"Fetch\")),\n\t\t)\n\t},\n)\n\nvar JSONEditor = app.DefineComponent(\"JSONEditor\",\n\tfunc(props JSONEditorProps) any {\n\t\tif props.Value == \"\" && props.Placeholder == \"\" {\n\t\t\treturn vdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"text-slate-400 text-center py-8\",\n\t\t\t}, \"Enter a URL above and click Fetch to load configuration\")\n\t\t}\n\n\t\treturn vdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"flex flex-col\",\n\t\t},\n\t\t\tvdom.H(\"textarea\", map[string]any{\n\t\t\t\t\"className\":   \"w-full h-96 px-3 py-2 bg-slate-700 border border-slate-600 rounded text-slate-100 font-mono text-sm resize-y focus:outline-none focus:ring-2 focus:ring-blue-500\",\n\t\t\t\t\"value\":       props.Value,\n\t\t\t\t\"placeholder\": props.Placeholder,\n\t\t\t\t\"disabled\":    props.IsLoading,\n\t\t\t\t\"onChange\": func(e vdom.VDomEvent) {\n\t\t\t\t\tprops.OnChange(e.TargetValue)\n\t\t\t\t},\n\t\t\t}),\n\t\t\tvdom.If(props.Value != \"\",\n\t\t\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\t\t\"className\": vdom.Classes(\n\t\t\t\t\t\t\"mt-2 w-full py-2 rounded font-medium cursor-pointer transition-colors\",\n\t\t\t\t\t\tvdom.IfElse(props.IsLoading,\n\t\t\t\t\t\t\t\"bg-slate-600 text-slate-400 cursor-not-allowed\",\n\t\t\t\t\t\t\t\"bg-green-600 text-white hover:bg-green-700\",\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t\t\"onClick\":  vdom.If(!props.IsLoading, props.OnSubmit),\n\t\t\t\t\t\"disabled\": props.IsLoading,\n\t\t\t\t}, vdom.IfElse(props.IsLoading, \"Submitting...\", \"Submit Changes\")),\n\t\t\t),\n\t\t)\n\t},\n)\n\nvar ErrorDisplay = app.DefineComponent(\"ErrorDisplay\",\n\tfunc(props ErrorDisplayProps) any {\n\t\tif props.Message == \"\" {\n\t\t\treturn nil\n\t\t}\n\n\t\treturn vdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded mb-4\",\n\t\t},\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"font-medium\",\n\t\t\t}, \"Error\"),\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"text-sm mt-1\",\n\t\t\t}, props.Message),\n\t\t)\n\t},\n)\n\nvar SuccessDisplay = app.DefineComponent(\"SuccessDisplay\",\n\tfunc(props SuccessDisplayProps) any {\n\t\tif props.Message == \"\" {\n\t\t\treturn nil\n\t\t}\n\n\t\treturn vdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"bg-green-900 border border-green-700 text-green-100 px-4 py-3 rounded mb-4\",\n\t\t},\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"font-medium\",\n\t\t\t}, \"Success\"),\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"text-sm mt-1\",\n\t\t\t}, props.Message),\n\t\t)\n\t},\n)\n\nvar App = app.DefineComponent(\"App\",\n\tfunc(_ struct{}) any {\n\n\t\t// Get atom value once at the top\n\t\turlInput := serverURLAtom.Get()\n\t\tjsonContent := app.UseLocal(\"\")\n\t\terrorMessage := app.UseLocal(\"\")\n\t\tsuccessMessage := app.UseLocal(\"\")\n\t\tisLoading := app.UseLocal(false)\n\t\tlastFetch := app.UseLocal(\"\")\n\t\tcurrentBaseURL := app.UseLocal(\"\")\n\n\t\tclearMessages := func() {\n\t\t\terrorMessage.Set(\"\")\n\t\t\tsuccessMessage.Set(\"\")\n\t\t}\n\n\t\tfetchConfigData := func() {\n\t\t\tclearMessages()\n\n\t\t\tbaseURL, err := parseURL(serverURLAtom.Get())\n\t\t\tif err != nil {\n\t\t\t\terrorMessage.Set(err.Error())\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tisLoading.Set(true)\n\t\t\tcurrentBaseURL.Set(baseURL)\n\n\t\t\tgo func() {\n\t\t\t\tdefer func() {\n\t\t\t\t\tisLoading.Set(false)\n\t\t\t\t}()\n\n\t\t\t\tcontent, err := fetchConfig(baseURL)\n\t\t\t\tif err != nil {\n\t\t\t\t\terrorMessage.Set(err.Error())\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tjsonContent.Set(content)\n\t\t\t\tlastFetch.Set(time.Now().Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tsuccessMessage.Set(fmt.Sprintf(\"Successfully fetched config from %s\", baseURL))\n\t\t\t}()\n\t\t}\n\n\t\tsubmitConfigData := func() {\n\t\t\tif currentBaseURL.Get() == \"\" {\n\t\t\t\terrorMessage.Set(\"No base URL available. Please fetch config first.\")\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tclearMessages()\n\t\t\tisLoading.Set(true)\n\n\t\t\tgo func() {\n\t\t\t\tdefer func() {\n\t\t\t\t\tisLoading.Set(false)\n\t\t\t\t}()\n\n\t\t\t\terr := postConfig(currentBaseURL.Get(), jsonContent.Get())\n\t\t\t\tif err != nil {\n\t\t\t\t\terrorMessage.Set(fmt.Sprintf(\"Failed to submit config: %v\", err))\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tsuccessMessage.Set(fmt.Sprintf(\"Successfully submitted config to %s\", currentBaseURL.Get()))\n\t\t\t}()\n\t\t}\n\n\t\treturn vdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"max-w-4xl mx-auto p-6 bg-slate-800 text-slate-100 min-h-screen\",\n\t\t},\n\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\"className\": \"mb-6\",\n\t\t\t},\n\t\t\t\tvdom.H(\"h1\", map[string]any{\n\t\t\t\t\t\"className\": \"text-3xl font-bold mb-2\",\n\t\t\t\t}, \"Tsunami Config Manager\"),\n\t\t\t\tvdom.H(\"p\", map[string]any{\n\t\t\t\t\t\"className\": \"text-slate-400\",\n\t\t\t\t}, \"Fetch and edit configuration from remote servers\"),\n\t\t\t),\n\n\t\t\tURLInput(URLInputProps{\n\t\t\t\tValue:     urlInput,\n\t\t\t\tOnChange:  serverURLAtom.Set,\n\t\t\t\tOnSubmit:  fetchConfigData,\n\t\t\t\tIsLoading: isLoading.Get(),\n\t\t\t}),\n\n\t\t\tErrorDisplay(ErrorDisplayProps{\n\t\t\t\tMessage: errorMessage.Get(),\n\t\t\t}),\n\n\t\t\tSuccessDisplay(SuccessDisplayProps{\n\t\t\t\tMessage: successMessage.Get(),\n\t\t\t}),\n\n\t\t\tvdom.If(lastFetch.Get() != \"\",\n\t\t\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\t\t\"className\": \"text-sm text-slate-400 mb-4\",\n\t\t\t\t}, fmt.Sprintf(\"Last fetched: %s from %s\", lastFetch.Get(), currentBaseURL.Get())),\n\t\t\t),\n\n\t\t\tJSONEditor(JSONEditorProps{\n\t\t\t\tValue:       jsonContent.Get(),\n\t\t\t\tOnChange:    jsonContent.Set,\n\t\t\t\tOnSubmit:    submitConfigData,\n\t\t\t\tIsLoading:   isLoading.Get(),\n\t\t\t\tPlaceholder: \"JSON configuration will appear here after fetching...\",\n\t\t\t}),\n\t\t)\n\t},\n)\n"
  },
  {
    "path": "tsunami/demo/tsunamiconfig/go.mod",
    "content": "module tsunami/app/tsunamiconfig\n\ngo 1.25.6\n\nrequire github.com/wavetermdev/waveterm/tsunami v0.0.0\n\nrequire (\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/outrigdev/goid v0.3.0 // indirect\n)\n\nreplace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami\n"
  },
  {
    "path": "tsunami/demo/tsunamiconfig/go.sum",
    "content": "github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=\ngithub.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=\n"
  },
  {
    "path": "tsunami/demo/tsunamiconfig/static/tw.css",
    "content": "/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */\n@layer properties;\n@layer theme, base, components, utilities;\n@layer theme {\n  :root, :host {\n    --font-sans: \"Inter\", sans-serif;\n    --font-mono: \"Hack\", monospace;\n    --color-red-100: oklch(93.6% 0.032 17.717);\n    --color-red-500: oklch(63.7% 0.237 25.331);\n    --color-red-700: oklch(50.5% 0.213 27.518);\n    --color-red-800: oklch(44.4% 0.177 26.899);\n    --color-red-900: oklch(39.6% 0.141 25.723);\n    --color-green-100: oklch(96.2% 0.044 156.743);\n    --color-green-600: oklch(62.7% 0.194 149.214);\n    --color-green-700: oklch(52.7% 0.154 150.069);\n    --color-green-900: oklch(39.3% 0.095 152.535);\n    --color-blue-500: oklch(62.3% 0.214 259.815);\n    --color-blue-600: oklch(54.6% 0.245 262.881);\n    --color-blue-700: oklch(48.8% 0.243 264.376);\n    --color-slate-100: oklch(96.8% 0.007 247.896);\n    --color-slate-400: oklch(70.4% 0.04 256.788);\n    --color-slate-600: oklch(44.6% 0.043 257.281);\n    --color-slate-700: oklch(37.2% 0.044 257.287);\n    --color-slate-800: oklch(27.9% 0.041 260.031);\n    --color-white: #fff;\n    --spacing: 0.25rem;\n    --container-4xl: 56rem;\n    --text-sm: 0.875rem;\n    --text-sm--line-height: calc(1.25 / 0.875);\n    --text-base: 1rem;\n    --text-base--line-height: calc(1.5 / 1);\n    --text-lg: 1.125rem;\n    --text-lg--line-height: calc(1.75 / 1.125);\n    --text-xl: 1.25rem;\n    --text-xl--line-height: calc(1.75 / 1.25);\n    --text-2xl: 1.5rem;\n    --text-2xl--line-height: calc(2 / 1.5);\n    --text-3xl: 1.875rem;\n    --text-3xl--line-height: calc(2.25 / 1.875);\n    --font-weight-medium: 500;\n    --font-weight-bold: 700;\n    --leading-relaxed: 1.625;\n    --radius-lg: 0.5rem;\n    --ease-in: cubic-bezier(0.4, 0, 1, 1);\n    --ease-out: cubic-bezier(0, 0, 0.2, 1);\n    --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-transition-duration: 150ms;\n    --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n    --default-font-family: var(--font-sans);\n    --default-mono-font-family: var(--font-mono);\n    --radius: 8px;\n    --color-background: rgb(34, 34, 34);\n    --color-primary: rgb(247, 247, 247);\n    --color-secondary: rgba(215, 218, 224, 0.7);\n    --color-muted: rgba(215, 218, 224, 0.5);\n    --color-accent-300: rgb(110, 231, 133);\n    --color-panel: rgba(255, 255, 255, 0.12);\n    --color-border: rgba(255, 255, 255, 0.16);\n    --color-accent: rgb(88, 193, 66);\n  }\n}\n@layer base {\n  *, ::after, ::before, ::backdrop, ::file-selector-button {\n    box-sizing: border-box;\n    margin: 0;\n    padding: 0;\n    border: 0 solid;\n  }\n  html, :host {\n    line-height: 1.5;\n    -webkit-text-size-adjust: 100%;\n    tab-size: 4;\n    font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\");\n    font-feature-settings: var(--default-font-feature-settings, normal);\n    font-variation-settings: var(--default-font-variation-settings, normal);\n    -webkit-tap-highlight-color: transparent;\n  }\n  hr {\n    height: 0;\n    color: inherit;\n    border-top-width: 1px;\n  }\n  abbr:where([title]) {\n    -webkit-text-decoration: underline dotted;\n    text-decoration: underline dotted;\n  }\n  h1, h2, h3, h4, h5, h6 {\n    font-size: inherit;\n    font-weight: inherit;\n  }\n  a {\n    color: inherit;\n    -webkit-text-decoration: inherit;\n    text-decoration: inherit;\n  }\n  b, strong {\n    font-weight: bolder;\n  }\n  code, kbd, samp, pre {\n    font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace);\n    font-feature-settings: var(--default-mono-font-feature-settings, normal);\n    font-variation-settings: var(--default-mono-font-variation-settings, normal);\n    font-size: 1em;\n  }\n  small {\n    font-size: 80%;\n  }\n  sub, sup {\n    font-size: 75%;\n    line-height: 0;\n    position: relative;\n    vertical-align: baseline;\n  }\n  sub {\n    bottom: -0.25em;\n  }\n  sup {\n    top: -0.5em;\n  }\n  table {\n    text-indent: 0;\n    border-color: inherit;\n    border-collapse: collapse;\n  }\n  :-moz-focusring {\n    outline: auto;\n  }\n  progress {\n    vertical-align: baseline;\n  }\n  summary {\n    display: list-item;\n  }\n  ol, ul, menu {\n    list-style: none;\n  }\n  img, svg, video, canvas, audio, iframe, embed, object {\n    display: block;\n    vertical-align: middle;\n  }\n  img, video {\n    max-width: 100%;\n    height: auto;\n  }\n  button, input, select, optgroup, textarea, ::file-selector-button {\n    font: inherit;\n    font-feature-settings: inherit;\n    font-variation-settings: inherit;\n    letter-spacing: inherit;\n    color: inherit;\n    border-radius: 0;\n    background-color: transparent;\n    opacity: 1;\n  }\n  :where(select:is([multiple], [size])) optgroup {\n    font-weight: bolder;\n  }\n  :where(select:is([multiple], [size])) optgroup option {\n    padding-inline-start: 20px;\n  }\n  ::file-selector-button {\n    margin-inline-end: 4px;\n  }\n  ::placeholder {\n    opacity: 1;\n  }\n  @supports (not (-webkit-appearance: -apple-pay-button))  or (contain-intrinsic-size: 1px) {\n    ::placeholder {\n      color: currentcolor;\n      @supports (color: color-mix(in lab, red, red)) {\n        color: color-mix(in oklab, currentcolor 50%, transparent);\n      }\n    }\n  }\n  textarea {\n    resize: vertical;\n  }\n  ::-webkit-search-decoration {\n    -webkit-appearance: none;\n  }\n  ::-webkit-date-and-time-value {\n    min-height: 1lh;\n    text-align: inherit;\n  }\n  ::-webkit-datetime-edit {\n    display: inline-flex;\n  }\n  ::-webkit-datetime-edit-fields-wrapper {\n    padding: 0;\n  }\n  ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n    padding-block: 0;\n  }\n  ::-webkit-calendar-picker-indicator {\n    line-height: 1;\n  }\n  :-moz-ui-invalid {\n    box-shadow: none;\n  }\n  button, input:where([type=\"button\"], [type=\"reset\"], [type=\"submit\"]), ::file-selector-button {\n    appearance: button;\n  }\n  ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n    height: auto;\n  }\n  [hidden]:where(:not([hidden=\"until-found\"])) {\n    display: none !important;\n  }\n}\n@layer utilities {\n  .collapse {\n    visibility: collapse;\n  }\n  .invisible {\n    visibility: hidden;\n  }\n  .visible {\n    visibility: visible;\n  }\n  .sr-only {\n    position: absolute;\n    width: 1px;\n    height: 1px;\n    padding: 0;\n    margin: -1px;\n    overflow: hidden;\n    clip-path: inset(50%);\n    white-space: nowrap;\n    border-width: 0;\n  }\n  .not-sr-only {\n    position: static;\n    width: auto;\n    height: auto;\n    padding: 0;\n    margin: 0;\n    overflow: visible;\n    clip-path: none;\n    white-space: normal;\n  }\n  .absolute {\n    position: absolute;\n  }\n  .fixed {\n    position: fixed;\n  }\n  .relative {\n    position: relative;\n  }\n  .static {\n    position: static;\n  }\n  .sticky {\n    position: sticky;\n  }\n  .isolate {\n    isolation: isolate;\n  }\n  .isolation-auto {\n    isolation: auto;\n  }\n  .container {\n    width: 100%;\n    @media (width >= 40rem) {\n      max-width: 40rem;\n    }\n    @media (width >= 48rem) {\n      max-width: 48rem;\n    }\n    @media (width >= 64rem) {\n      max-width: 64rem;\n    }\n    @media (width >= 80rem) {\n      max-width: 80rem;\n    }\n    @media (width >= 96rem) {\n      max-width: 96rem;\n    }\n  }\n  .mx-auto {\n    margin-inline: auto;\n  }\n  .my-6 {\n    margin-block: calc(var(--spacing) * 6);\n  }\n  .mt-1 {\n    margin-top: calc(var(--spacing) * 1);\n  }\n  .mt-2 {\n    margin-top: calc(var(--spacing) * 2);\n  }\n  .mt-3 {\n    margin-top: calc(var(--spacing) * 3);\n  }\n  .mt-4 {\n    margin-top: calc(var(--spacing) * 4);\n  }\n  .mt-5 {\n    margin-top: calc(var(--spacing) * 5);\n  }\n  .mt-6 {\n    margin-top: calc(var(--spacing) * 6);\n  }\n  .mb-2 {\n    margin-bottom: calc(var(--spacing) * 2);\n  }\n  .mb-3 {\n    margin-bottom: calc(var(--spacing) * 3);\n  }\n  .mb-4 {\n    margin-bottom: calc(var(--spacing) * 4);\n  }\n  .mb-6 {\n    margin-bottom: calc(var(--spacing) * 6);\n  }\n  .ml-4 {\n    margin-left: calc(var(--spacing) * 4);\n  }\n  .block {\n    display: block;\n  }\n  .contents {\n    display: contents;\n  }\n  .flex {\n    display: flex;\n  }\n  .flow-root {\n    display: flow-root;\n  }\n  .grid {\n    display: grid;\n  }\n  .hidden {\n    display: none;\n  }\n  .inline {\n    display: inline;\n  }\n  .inline-block {\n    display: inline-block;\n  }\n  .inline-flex {\n    display: inline-flex;\n  }\n  .inline-grid {\n    display: inline-grid;\n  }\n  .inline-table {\n    display: inline-table;\n  }\n  .list-item {\n    display: list-item;\n  }\n  .table {\n    display: table;\n  }\n  .table-caption {\n    display: table-caption;\n  }\n  .table-cell {\n    display: table-cell;\n  }\n  .table-column {\n    display: table-column;\n  }\n  .table-column-group {\n    display: table-column-group;\n  }\n  .table-footer-group {\n    display: table-footer-group;\n  }\n  .table-header-group {\n    display: table-header-group;\n  }\n  .table-row {\n    display: table-row;\n  }\n  .table-row-group {\n    display: table-row-group;\n  }\n  .h-96 {\n    height: calc(var(--spacing) * 96);\n  }\n  .min-h-full {\n    min-height: 100%;\n  }\n  .min-h-screen {\n    min-height: 100vh;\n  }\n  .w-full {\n    width: 100%;\n  }\n  .max-w-4xl {\n    max-width: var(--container-4xl);\n  }\n  .max-w-none {\n    max-width: none;\n  }\n  .min-w-full {\n    min-width: 100%;\n  }\n  .flex-1 {\n    flex: 1;\n  }\n  .shrink {\n    flex-shrink: 1;\n  }\n  .grow {\n    flex-grow: 1;\n  }\n  .border-collapse {\n    border-collapse: collapse;\n  }\n  .translate-none {\n    translate: none;\n  }\n  .scale-3d {\n    scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);\n  }\n  .transform {\n    transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);\n  }\n  .cursor-not-allowed {\n    cursor: not-allowed;\n  }\n  .cursor-pointer {\n    cursor: pointer;\n  }\n  .touch-pinch-zoom {\n    --tw-pinch-zoom: pinch-zoom;\n    touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,);\n  }\n  .resize {\n    resize: both;\n  }\n  .resize-y {\n    resize: vertical;\n  }\n  .list-inside {\n    list-style-position: inside;\n  }\n  .list-decimal {\n    list-style-type: decimal;\n  }\n  .list-disc {\n    list-style-type: disc;\n  }\n  .flex-col {\n    flex-direction: column;\n  }\n  .flex-wrap {\n    flex-wrap: wrap;\n  }\n  .gap-2 {\n    gap: calc(var(--spacing) * 2);\n  }\n  .space-y-1 {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 0;\n      margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));\n      margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));\n    }\n  }\n  .space-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-y-reverse: 1;\n    }\n  }\n  .space-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-space-x-reverse: 1;\n    }\n  }\n  .divide-x {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 0;\n      border-inline-style: var(--tw-border-style);\n      border-inline-start-width: calc(1px * var(--tw-divide-x-reverse));\n      border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse)));\n    }\n  }\n  .divide-y {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 0;\n      border-bottom-style: var(--tw-border-style);\n      border-top-style: var(--tw-border-style);\n      border-top-width: calc(1px * var(--tw-divide-y-reverse));\n      border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));\n    }\n  }\n  .divide-y-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-y-reverse: 1;\n    }\n  }\n  .truncate {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .overflow-auto {\n    overflow: auto;\n  }\n  .overflow-x-auto {\n    overflow-x: auto;\n  }\n  .rounded {\n    border-radius: var(--radius);\n  }\n  .rounded-lg {\n    border-radius: var(--radius-lg);\n  }\n  .rounded-s {\n    border-start-start-radius: var(--radius);\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-ss {\n    border-start-start-radius: var(--radius);\n  }\n  .rounded-e {\n    border-start-end-radius: var(--radius);\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-se {\n    border-start-end-radius: var(--radius);\n  }\n  .rounded-ee {\n    border-end-end-radius: var(--radius);\n  }\n  .rounded-es {\n    border-end-start-radius: var(--radius);\n  }\n  .rounded-t {\n    border-top-left-radius: var(--radius);\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-l {\n    border-top-left-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-tl {\n    border-top-left-radius: var(--radius);\n  }\n  .rounded-r {\n    border-top-right-radius: var(--radius);\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-tr {\n    border-top-right-radius: var(--radius);\n  }\n  .rounded-b {\n    border-bottom-right-radius: var(--radius);\n    border-bottom-left-radius: var(--radius);\n  }\n  .rounded-br {\n    border-bottom-right-radius: var(--radius);\n  }\n  .rounded-bl {\n    border-bottom-left-radius: var(--radius);\n  }\n  .border {\n    border-style: var(--tw-border-style);\n    border-width: 1px;\n  }\n  .border-x {\n    border-inline-style: var(--tw-border-style);\n    border-inline-width: 1px;\n  }\n  .border-y {\n    border-block-style: var(--tw-border-style);\n    border-block-width: 1px;\n  }\n  .border-s {\n    border-inline-start-style: var(--tw-border-style);\n    border-inline-start-width: 1px;\n  }\n  .border-e {\n    border-inline-end-style: var(--tw-border-style);\n    border-inline-end-width: 1px;\n  }\n  .border-t {\n    border-top-style: var(--tw-border-style);\n    border-top-width: 1px;\n  }\n  .border-r {\n    border-right-style: var(--tw-border-style);\n    border-right-width: 1px;\n  }\n  .border-b {\n    border-bottom-style: var(--tw-border-style);\n    border-bottom-width: 1px;\n  }\n  .border-l {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 1px;\n  }\n  .border-l-4 {\n    border-left-style: var(--tw-border-style);\n    border-left-width: 4px;\n  }\n  .border-border {\n    border-color: var(--color-border);\n  }\n  .border-green-700 {\n    border-color: var(--color-green-700);\n  }\n  .border-red-500 {\n    border-color: var(--color-red-500);\n  }\n  .border-red-700 {\n    border-color: var(--color-red-700);\n  }\n  .border-slate-600 {\n    border-color: var(--color-slate-600);\n  }\n  .bg-background {\n    background-color: var(--color-background);\n  }\n  .bg-blue-600 {\n    background-color: var(--color-blue-600);\n  }\n  .bg-green-600 {\n    background-color: var(--color-green-600);\n  }\n  .bg-green-900 {\n    background-color: var(--color-green-900);\n  }\n  .bg-panel {\n    background-color: var(--color-panel);\n  }\n  .bg-red-100 {\n    background-color: var(--color-red-100);\n  }\n  .bg-red-900 {\n    background-color: var(--color-red-900);\n  }\n  .bg-slate-600 {\n    background-color: var(--color-slate-600);\n  }\n  .bg-slate-700 {\n    background-color: var(--color-slate-700);\n  }\n  .bg-slate-800 {\n    background-color: var(--color-slate-800);\n  }\n  .bg-repeat {\n    background-repeat: repeat;\n  }\n  .mask-no-clip {\n    mask-clip: no-clip;\n  }\n  .mask-repeat {\n    mask-repeat: repeat;\n  }\n  .p-4 {\n    padding: calc(var(--spacing) * 4);\n  }\n  .p-6 {\n    padding: calc(var(--spacing) * 6);\n  }\n  .px-1 {\n    padding-inline: calc(var(--spacing) * 1);\n  }\n  .px-3 {\n    padding-inline: calc(var(--spacing) * 3);\n  }\n  .px-4 {\n    padding-inline: calc(var(--spacing) * 4);\n  }\n  .py-0\\.5 {\n    padding-block: calc(var(--spacing) * 0.5);\n  }\n  .py-2 {\n    padding-block: calc(var(--spacing) * 2);\n  }\n  .py-3 {\n    padding-block: calc(var(--spacing) * 3);\n  }\n  .py-8 {\n    padding-block: calc(var(--spacing) * 8);\n  }\n  .pl-4 {\n    padding-left: calc(var(--spacing) * 4);\n  }\n  .text-center {\n    text-align: center;\n  }\n  .text-left {\n    text-align: left;\n  }\n  .font-mono {\n    font-family: var(--font-mono);\n  }\n  .text-2xl {\n    font-size: var(--text-2xl);\n    line-height: var(--tw-leading, var(--text-2xl--line-height));\n  }\n  .text-3xl {\n    font-size: var(--text-3xl);\n    line-height: var(--tw-leading, var(--text-3xl--line-height));\n  }\n  .text-base {\n    font-size: var(--text-base);\n    line-height: var(--tw-leading, var(--text-base--line-height));\n  }\n  .text-lg {\n    font-size: var(--text-lg);\n    line-height: var(--tw-leading, var(--text-lg--line-height));\n  }\n  .text-sm {\n    font-size: var(--text-sm);\n    line-height: var(--tw-leading, var(--text-sm--line-height));\n  }\n  .text-xl {\n    font-size: var(--text-xl);\n    line-height: var(--tw-leading, var(--text-xl--line-height));\n  }\n  .leading-relaxed {\n    --tw-leading: var(--leading-relaxed);\n    line-height: var(--leading-relaxed);\n  }\n  .font-bold {\n    --tw-font-weight: var(--font-weight-bold);\n    font-weight: var(--font-weight-bold);\n  }\n  .font-medium {\n    --tw-font-weight: var(--font-weight-medium);\n    font-weight: var(--font-weight-medium);\n  }\n  .text-wrap {\n    text-wrap: wrap;\n  }\n  .text-clip {\n    text-overflow: clip;\n  }\n  .text-ellipsis {\n    text-overflow: ellipsis;\n  }\n  .text-accent {\n    color: var(--color-accent);\n  }\n  .text-green-100 {\n    color: var(--color-green-100);\n  }\n  .text-muted {\n    color: var(--color-muted);\n  }\n  .text-primary {\n    color: var(--color-primary);\n  }\n  .text-red-100 {\n    color: var(--color-red-100);\n  }\n  .text-red-800 {\n    color: var(--color-red-800);\n  }\n  .text-secondary {\n    color: var(--color-secondary);\n  }\n  .text-slate-100 {\n    color: var(--color-slate-100);\n  }\n  .text-slate-400 {\n    color: var(--color-slate-400);\n  }\n  .text-white {\n    color: var(--color-white);\n  }\n  .capitalize {\n    text-transform: capitalize;\n  }\n  .lowercase {\n    text-transform: lowercase;\n  }\n  .normal-case {\n    text-transform: none;\n  }\n  .uppercase {\n    text-transform: uppercase;\n  }\n  .italic {\n    font-style: italic;\n  }\n  .not-italic {\n    font-style: normal;\n  }\n  .diagonal-fractions {\n    --tw-numeric-fraction: diagonal-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .lining-nums {\n    --tw-numeric-figure: lining-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .oldstyle-nums {\n    --tw-numeric-figure: oldstyle-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .ordinal {\n    --tw-ordinal: ordinal;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .proportional-nums {\n    --tw-numeric-spacing: proportional-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .slashed-zero {\n    --tw-slashed-zero: slashed-zero;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .stacked-fractions {\n    --tw-numeric-fraction: stacked-fractions;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .tabular-nums {\n    --tw-numeric-spacing: tabular-nums;\n    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n  }\n  .normal-nums {\n    font-variant-numeric: normal;\n  }\n  .line-through {\n    text-decoration-line: line-through;\n  }\n  .no-underline {\n    text-decoration-line: none;\n  }\n  .overline {\n    text-decoration-line: overline;\n  }\n  .underline {\n    text-decoration-line: underline;\n  }\n  .antialiased {\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n  .subpixel-antialiased {\n    -webkit-font-smoothing: auto;\n    -moz-osx-font-smoothing: auto;\n  }\n  .placeholder-slate-400 {\n    &::placeholder {\n      color: var(--color-slate-400);\n    }\n  }\n  .shadow {\n    --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .inset-ring {\n    --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor);\n    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  }\n  .blur {\n    --tw-blur: blur(8px);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .drop-shadow {\n    --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)));\n    --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06));\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .invert {\n    --tw-invert: invert(100%);\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .filter {\n    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n  }\n  .backdrop-blur {\n    --tw-backdrop-blur: blur(8px);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-grayscale {\n    --tw-backdrop-grayscale: grayscale(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-invert {\n    --tw-backdrop-invert: invert(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-sepia {\n    --tw-backdrop-sepia: sepia(100%);\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .backdrop-filter {\n    -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n    backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n  }\n  .transition-colors {\n    transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;\n    transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));\n    transition-duration: var(--tw-duration, var(--default-transition-duration));\n  }\n  .ease-in {\n    --tw-ease: var(--ease-in);\n    transition-timing-function: var(--ease-in);\n  }\n  .ease-in-out {\n    --tw-ease: var(--ease-in-out);\n    transition-timing-function: var(--ease-in-out);\n  }\n  .ease-out {\n    --tw-ease: var(--ease-out);\n    transition-timing-function: var(--ease-out);\n  }\n  .divide-x-reverse {\n    :where(& > :not(:last-child)) {\n      --tw-divide-x-reverse: 1;\n    }\n  }\n  .ring-inset {\n    --tw-ring-inset: inset;\n  }\n  .hover\\:bg-blue-700 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-blue-700);\n      }\n    }\n  }\n  .hover\\:bg-green-700 {\n    &:hover {\n      @media (hover: hover) {\n        background-color: var(--color-green-700);\n      }\n    }\n  }\n  .hover\\:text-accent-300 {\n    &:hover {\n      @media (hover: hover) {\n        color: var(--color-accent-300);\n      }\n    }\n  }\n  .focus\\:ring-2 {\n    &:focus {\n      --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n      box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n    }\n  }\n  .focus\\:ring-blue-500 {\n    &:focus {\n      --tw-ring-color: var(--color-blue-500);\n    }\n  }\n  .focus\\:outline-none {\n    &:focus {\n      --tw-outline-style: none;\n      outline-style: none;\n    }\n  }\n}\n@property --tw-scale-x {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-y {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-scale-z {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 1;\n}\n@property --tw-rotate-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-rotate-z {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-skew-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-x {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pan-y {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-pinch-zoom {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-space-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-space-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-divide-x-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-border-style {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: solid;\n}\n@property --tw-divide-y-reverse {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0;\n}\n@property --tw-leading {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-font-weight {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ordinal {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-slashed-zero {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-figure {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-spacing {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-numeric-fraction {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-inset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-inset-ring-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ring-offset-width {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n  syntax: \"*\";\n  inherits: false;\n  initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-color {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n  syntax: \"<percentage>\";\n  inherits: false;\n  initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-blur {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-brightness {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-contrast {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-grayscale {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-invert {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-opacity {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-saturate {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-backdrop-sepia {\n  syntax: \"*\";\n  inherits: false;\n}\n@property --tw-ease {\n  syntax: \"*\";\n  inherits: false;\n}\n@layer properties {\n  @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n    *, ::before, ::after, ::backdrop {\n      --tw-scale-x: 1;\n      --tw-scale-y: 1;\n      --tw-scale-z: 1;\n      --tw-rotate-x: initial;\n      --tw-rotate-y: initial;\n      --tw-rotate-z: initial;\n      --tw-skew-x: initial;\n      --tw-skew-y: initial;\n      --tw-pan-x: initial;\n      --tw-pan-y: initial;\n      --tw-pinch-zoom: initial;\n      --tw-space-y-reverse: 0;\n      --tw-space-x-reverse: 0;\n      --tw-divide-x-reverse: 0;\n      --tw-border-style: solid;\n      --tw-divide-y-reverse: 0;\n      --tw-leading: initial;\n      --tw-font-weight: initial;\n      --tw-ordinal: initial;\n      --tw-slashed-zero: initial;\n      --tw-numeric-figure: initial;\n      --tw-numeric-spacing: initial;\n      --tw-numeric-fraction: initial;\n      --tw-shadow: 0 0 #0000;\n      --tw-shadow-color: initial;\n      --tw-shadow-alpha: 100%;\n      --tw-inset-shadow: 0 0 #0000;\n      --tw-inset-shadow-color: initial;\n      --tw-inset-shadow-alpha: 100%;\n      --tw-ring-color: initial;\n      --tw-ring-shadow: 0 0 #0000;\n      --tw-inset-ring-color: initial;\n      --tw-inset-ring-shadow: 0 0 #0000;\n      --tw-ring-inset: initial;\n      --tw-ring-offset-width: 0px;\n      --tw-ring-offset-color: #fff;\n      --tw-ring-offset-shadow: 0 0 #0000;\n      --tw-blur: initial;\n      --tw-brightness: initial;\n      --tw-contrast: initial;\n      --tw-grayscale: initial;\n      --tw-hue-rotate: initial;\n      --tw-invert: initial;\n      --tw-opacity: initial;\n      --tw-saturate: initial;\n      --tw-sepia: initial;\n      --tw-drop-shadow: initial;\n      --tw-drop-shadow-color: initial;\n      --tw-drop-shadow-alpha: 100%;\n      --tw-drop-shadow-size: initial;\n      --tw-backdrop-blur: initial;\n      --tw-backdrop-brightness: initial;\n      --tw-backdrop-contrast: initial;\n      --tw-backdrop-grayscale: initial;\n      --tw-backdrop-hue-rotate: initial;\n      --tw-backdrop-invert: initial;\n      --tw-backdrop-opacity: initial;\n      --tw-backdrop-saturate: initial;\n      --tw-backdrop-sepia: initial;\n      --tw-ease: initial;\n    }\n  }\n}\n"
  },
  {
    "path": "tsunami/engine/asyncnotify.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage engine\n\nimport (\n\t\"time\"\n)\n\nconst NotifyMaxCadence = 10 * time.Millisecond\nconst NotifyDebounceTime = 500 * time.Microsecond\nconst NotifyMaxDebounceTime = 2 * time.Millisecond\n\nfunc (c *ClientImpl) notifyAsyncRenderWork() {\n\tc.notifyOnce.Do(func() {\n\t\tc.notifyWakeCh = make(chan struct{}, 1)\n\t\tgo c.asyncInitiationLoop()\n\t})\n\n\tnowNs := time.Now().UnixNano()\n\tc.notifyLastEventNs.Store(nowNs)\n\t// Establish batch start if there's no active batch.\n\tif c.notifyBatchStartNs.Load() == 0 {\n\t\tc.notifyBatchStartNs.CompareAndSwap(0, nowNs)\n\t}\n\t// Coalesced wake-up.\n\tselect {\n\tcase c.notifyWakeCh <- struct{}{}:\n\tdefault:\n\t}\n}\n\nfunc (c *ClientImpl) asyncInitiationLoop() {\n\tvar (\n\t\tlastSent time.Time\n\t\ttimer    *time.Timer\n\t\ttimerC   <-chan time.Time\n\t)\n\n\tschedule := func() {\n\t\tfirstNs := c.notifyBatchStartNs.Load()\n\t\tif firstNs == 0 {\n\t\t\t// No pending batch; stop timer if running.\n\t\t\tif timer != nil {\n\t\t\t\tif !timer.Stop() {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-timer.C:\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\ttimerC = nil\n\t\t\treturn\n\t\t}\n\t\tlastNs := c.notifyLastEventNs.Load()\n\n\t\tfirst := time.Unix(0, firstNs)\n\t\tlast := time.Unix(0, lastNs)\n\t\tcadenceReady := lastSent.Add(NotifyMaxCadence)\n\n\t\t// Reset the 2ms \"max debounce\" window at the cadence boundary:\n\t\t// deadline = max(first, cadenceReady) + 2ms\n\t\tanchor := first\n\t\tif cadenceReady.After(anchor) {\n\t\t\tanchor = cadenceReady\n\t\t}\n\t\tdeadline := anchor.Add(NotifyMaxDebounceTime)\n\n\t\t// candidate = min(last+500us, deadline)\n\t\tcandidate := last.Add(NotifyDebounceTime)\n\t\tif deadline.Before(candidate) {\n\t\t\tcandidate = deadline\n\t\t}\n\n\t\t// final target = max(cadenceReady, candidate)\n\t\ttarget := candidate\n\t\tif cadenceReady.After(target) {\n\t\t\ttarget = cadenceReady\n\t\t}\n\n\t\td := time.Until(target)\n\t\tif d < 0 {\n\t\t\td = 0\n\t\t}\n\t\tif timer == nil {\n\t\t\ttimer = time.NewTimer(d)\n\t\t} else {\n\t\t\tif !timer.Stop() {\n\t\t\t\tselect {\n\t\t\t\tcase <-timer.C:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t}\n\t\t\ttimer.Reset(d)\n\t\t}\n\t\ttimerC = timer.C\n\t}\n\n\tfor {\n\t\tselect {\n\t\tcase <-c.notifyWakeCh:\n\t\t\tschedule()\n\n\t\tcase <-timerC:\n\t\t\tnow := time.Now()\n\n\t\t\t// Recompute right before sending; if a late event arrived,\n\t\t\t// push the fire time out to respect the debounce.\n\t\t\tfirstNs := c.notifyBatchStartNs.Load()\n\t\t\tif firstNs == 0 {\n\t\t\t\t// Nothing to do.\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tlastNs := c.notifyLastEventNs.Load()\n\n\t\t\tfirst := time.Unix(0, firstNs)\n\t\t\tlast := time.Unix(0, lastNs)\n\t\t\tcadenceReady := lastSent.Add(NotifyMaxCadence)\n\n\t\t\tanchor := first\n\t\t\tif cadenceReady.After(anchor) {\n\t\t\t\tanchor = cadenceReady\n\t\t\t}\n\t\t\tdeadline := anchor.Add(NotifyMaxDebounceTime)\n\n\t\t\tcandidate := last.Add(NotifyDebounceTime)\n\t\t\tif deadline.Before(candidate) {\n\t\t\t\tcandidate = deadline\n\t\t\t}\n\t\t\ttarget := candidate\n\t\t\tif cadenceReady.After(target) {\n\t\t\t\ttarget = cadenceReady\n\t\t\t}\n\n\t\t\t// If we're early (because a new event just came in), reschedule.\n\t\t\tif now.Before(target) {\n\t\t\t\td := time.Until(target)\n\t\t\t\tif d < 0 {\n\t\t\t\t\td = 0\n\t\t\t\t}\n\t\t\t\tif !timer.Stop() {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-timer.C:\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\ttimer.Reset(d)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Fire.\n\t\t\t_ = c.SendAsyncInitiation()\n\t\t\tlastSent = now\n\n\t\t\t// Close current batch; a concurrent notify will CAS a new start.\n\t\t\tc.notifyBatchStartNs.Store(0)\n\n\t\t\t// If anything is already pending, this will arm the next timer.\n\t\t\tschedule()\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "tsunami/engine/atomimpl.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage engine\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"sync\"\n)\n\n// AtomMeta provides metadata about an atom for validation and documentation\ntype AtomMeta struct {\n\tDescription string   // short, user-facing\n\tUnits       string   // \"ms\", \"GiB\", etc.\n\tMin         *float64 // optional minimum (numeric types)\n\tMax         *float64 // optional maximum (numeric types)\n\tEnum        []string // allowed values if finite set\n\tPattern     string   // regex constraint for strings\n}\n\ntype AtomImpl[T any] struct {\n\tlock   *sync.Mutex\n\tval    T\n\tusedBy map[string]bool // component waveid -> true\n\tmeta   *AtomMeta       // optional metadata\n}\n\nfunc MakeAtomImpl[T any](initialVal T, meta *AtomMeta) *AtomImpl[T] {\n\treturn &AtomImpl[T]{\n\t\tlock:   &sync.Mutex{},\n\t\tval:    initialVal,\n\t\tusedBy: make(map[string]bool),\n\t\tmeta:   meta,\n\t}\n}\n\nfunc (a *AtomImpl[T]) GetVal() any {\n\ta.lock.Lock()\n\tdefer a.lock.Unlock()\n\treturn a.val\n}\n\nfunc (a *AtomImpl[T]) setVal_nolock(val any) error {\n\tif val == nil {\n\t\tvar zero T\n\t\ta.val = zero\n\t\treturn nil\n\t}\n\n\t// Try direct assignment if it's already type T\n\tif typed, ok := val.(T); ok {\n\t\ta.val = typed\n\t\treturn nil\n\t}\n\n\t// Try JSON marshaling/unmarshaling\n\tjsonBytes, err := json.Marshal(val)\n\tif err != nil {\n\t\tvar result T\n\t\treturn fmt.Errorf(\"failed to adapt type from %T => %T, input type failed to marshal: %w\", val, result, err)\n\t}\n\n\tvar result T\n\tif err := json.Unmarshal(jsonBytes, &result); err != nil {\n\t\treturn fmt.Errorf(\"failed to adapt type from %T => %T: %w\", val, result, err)\n\t}\n\n\ta.val = result\n\treturn nil\n}\n\nfunc (a *AtomImpl[T]) SetVal(val any) error {\n\ta.lock.Lock()\n\tdefer a.lock.Unlock()\n\treturn a.setVal_nolock(val)\n}\n\nfunc (a *AtomImpl[T]) SetUsedBy(waveId string, used bool) {\n\ta.lock.Lock()\n\tdefer a.lock.Unlock()\n\tif used {\n\t\ta.usedBy[waveId] = true\n\t} else {\n\t\tdelete(a.usedBy, waveId)\n\t}\n}\n\nfunc (a *AtomImpl[T]) GetUsedBy() []string {\n\ta.lock.Lock()\n\tdefer a.lock.Unlock()\n\n\tkeys := make([]string, 0, len(a.usedBy))\n\tfor compId := range a.usedBy {\n\t\tkeys = append(keys, compId)\n\t}\n\treturn keys\n}\n\nfunc (a *AtomImpl[T]) GetMeta() *AtomMeta {\n\ta.lock.Lock()\n\tdefer a.lock.Unlock()\n\treturn a.meta\n}\n\nfunc (a *AtomImpl[T]) GetAtomType() reflect.Type {\n\treturn reflect.TypeOf((*T)(nil)).Elem()\n}\n"
  },
  {
    "path": "tsunami/engine/clientimpl.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage engine\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/tsunami/rpctypes\"\n\t\"github.com/wavetermdev/waveterm/tsunami/util\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\nconst TsunamiListenAddrEnvVar = \"TSUNAMI_LISTENADDR\"\nconst DefaultListenAddr = \"localhost:0\"\nconst DefaultComponentName = \"App\"\n\ntype ModalState struct {\n\tConfig     rpctypes.ModalConfig\n\tResultChan chan bool // Channel to receive the result (true = confirmed/ok, false = cancelled)\n}\n\ntype ssEvent struct {\n\tEvent string\n\tData  []byte\n}\n\nvar defaultClient = makeClient()\n\ntype AppMeta struct {\n\tTitle     string `json:\"title\"`\n\tShortDesc string `json:\"shortdesc\"`\n\tIcon      string `json:\"icon\"`      // for waveapps, the icon to use (fontawesome names)\n\tIconColor string `json:\"iconcolor\"` // for waveapps, the icon color to use (HTML color -- name, hex, rgb)\n}\n\ntype SecretMeta struct {\n\tDesc     string `json:\"desc\"`\n\tOptional bool   `json:\"optional\"`\n}\n\ntype AppManifest struct {\n\tAppMeta      AppMeta               `json:\"appmeta\"`\n\tConfigSchema map[string]any        `json:\"configschema\"`\n\tDataSchema   map[string]any        `json:\"dataschema\"`\n\tSecrets      map[string]SecretMeta `json:\"secrets\"`\n}\n\ntype ClientImpl struct {\n\tLock               *sync.Mutex\n\tRoot               *RootElem\n\tRootElem           *vdom.VDomElem\n\tCurrentClientId    string\n\tMeta               AppMeta\n\tServerId           string\n\tIsDone             bool\n\tDoneReason         string\n\tDoneCh             chan struct{}\n\tSSEChannels        map[string]chan ssEvent // map of connectionId to SSE channel\n\tSSEChannelsLock    *sync.Mutex\n\tGlobalEventHandler func(event vdom.VDomEvent)\n\tUrlHandlerMux      *http.ServeMux\n\tAppInitFn          func() error\n\tAssetsFS           fs.FS\n\tStaticFS           fs.FS\n\tManifestFileBytes  []byte\n\n\t// for modals\n\tOpenModals     map[string]*ModalState // map of modalId to modal state\n\tOpenModalsLock *sync.Mutex\n\n\t// for secrets\n\tSecrets     map[string]SecretMeta // map of secret name to metadata\n\tSecretsLock *sync.Mutex\n\n\t// for notification\n\t// Atomics so we never drop \"last event\" timing info even if wakeCh is full.\n\t// 0 means \"no pending batch\".\n\tnotifyOnce         sync.Once\n\tnotifyWakeCh       chan struct{}\n\tnotifyBatchStartNs atomic.Int64 // ns of first event in current batch\n\tnotifyLastEventNs  atomic.Int64 // ns of most recent event\n}\n\nfunc makeClient() *ClientImpl {\n\tclient := &ClientImpl{\n\t\tLock:            &sync.Mutex{},\n\t\tDoneCh:          make(chan struct{}),\n\t\tSSEChannels:     make(map[string]chan ssEvent),\n\t\tSSEChannelsLock: &sync.Mutex{},\n\t\tOpenModals:      make(map[string]*ModalState),\n\t\tOpenModalsLock:  &sync.Mutex{},\n\t\tSecrets:         make(map[string]SecretMeta),\n\t\tSecretsLock:     &sync.Mutex{},\n\t\tUrlHandlerMux:   http.NewServeMux(),\n\t\tServerId:        uuid.New().String(),\n\t\tRootElem:        vdom.H(DefaultComponentName, nil),\n\t}\n\tclient.Root = MakeRoot(client)\n\treturn client\n}\n\nfunc GetDefaultClient() *ClientImpl {\n\treturn defaultClient\n}\n\nfunc (c *ClientImpl) GetIsDone() bool {\n\tc.Lock.Lock()\n\tdefer c.Lock.Unlock()\n\treturn c.IsDone\n}\n\nfunc (c *ClientImpl) checkClientId(clientId string) error {\n\tif clientId == \"\" {\n\t\treturn fmt.Errorf(\"client id cannot be empty\")\n\t}\n\tc.Lock.Lock()\n\tdefer c.Lock.Unlock()\n\tif c.CurrentClientId == \"\" || c.CurrentClientId == clientId {\n\t\tc.CurrentClientId = clientId\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"client id mismatch: expected %s, got %s\", c.CurrentClientId, clientId)\n}\n\nfunc (c *ClientImpl) clientTakeover(clientId string) {\n\tc.Lock.Lock()\n\tdefer c.Lock.Unlock()\n\tc.CurrentClientId = clientId\n}\n\nfunc (c *ClientImpl) doShutdown(reason string) {\n\tc.Lock.Lock()\n\tdefer c.Lock.Unlock()\n\tif c.IsDone {\n\t\treturn\n\t}\n\tc.DoneReason = reason\n\tc.IsDone = true\n\tclose(c.DoneCh)\n}\n\nfunc (c *ClientImpl) SetGlobalEventHandler(handler func(event vdom.VDomEvent)) {\n\tc.GlobalEventHandler = handler\n}\n\nfunc (c *ClientImpl) getFaviconPath() string {\n\tif c.StaticFS != nil {\n\t\tfaviconNames := []string{\"favicon.ico\", \"favicon.png\", \"favicon.svg\", \"favicon.gif\", \"favicon.jpg\"}\n\t\tfor _, name := range faviconNames {\n\t\t\tif _, err := c.StaticFS.Open(name); err == nil {\n\t\t\t\treturn \"/static/\" + name\n\t\t\t}\n\t\t}\n\t}\n\treturn \"/wave-logo-256.png\"\n}\n\nfunc (c *ClientImpl) makeBackendOpts() *rpctypes.VDomBackendOpts {\n\tappMeta := c.GetAppMeta()\n\treturn &rpctypes.VDomBackendOpts{\n\t\tTitle:                appMeta.Title,\n\t\tShortDesc:            appMeta.ShortDesc,\n\t\tGlobalKeyboardEvents: c.GlobalEventHandler != nil,\n\t\tFaviconPath:          c.getFaviconPath(),\n\t}\n}\n\nfunc (c *ClientImpl) runMainE() error {\n\tif c.AppInitFn != nil {\n\t\terr := c.AppInitFn()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\terr := c.listenAndServe(context.Background())\n\tif err != nil {\n\t\treturn err\n\t}\n\t<-c.DoneCh\n\treturn nil\n}\n\nfunc (c *ClientImpl) RegisterAppInitFn(fn func() error) {\n\tc.AppInitFn = fn\n}\n\nfunc (c *ClientImpl) RunMain() {\n\terr := c.runMainE()\n\tif err != nil {\n\t\tfmt.Println(err)\n\t\tos.Exit(1)\n\t}\n}\n\nfunc (c *ClientImpl) listenAndServe(ctx context.Context) error {\n\t// Create HTTP handlers\n\thandlers := newHTTPHandlers(c)\n\n\t// Create a new ServeMux and register handlers\n\tmux := http.NewServeMux()\n\thandlers.registerHandlers(mux, handlerOpts{\n\t\tAssetsFS:     c.AssetsFS,\n\t\tStaticFS:     c.StaticFS,\n\t\tManifestFile: c.ManifestFileBytes,\n\t})\n\n\t// Determine listen address from environment variable or use default\n\tlistenAddr := os.Getenv(TsunamiListenAddrEnvVar)\n\tif listenAddr == \"\" {\n\t\tlistenAddr = DefaultListenAddr\n\t}\n\n\t// Create server and listen on specified address\n\tserver := &http.Server{\n\t\tAddr:    listenAddr,\n\t\tHandler: mux,\n\t}\n\n\t// Start listening\n\tlistener, err := net.Listen(\"tcp\", listenAddr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to listen: %v\", err)\n\t}\n\n\t// Log the address we're listening on\n\tport := listener.Addr().(*net.TCPAddr).Port\n\tlog.Printf(\"[tsunami] listening at http://localhost:%d\", port)\n\n\t// Serve in a goroutine so we don't block\n\tgo func() {\n\t\tif err := server.Serve(listener); err != nil && err != http.ErrServerClosed {\n\t\t\tlog.Printf(\"HTTP server error: %v\", err)\n\t\t}\n\t}()\n\n\t// Wait for context cancellation and shutdown server gracefully\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tlog.Printf(\"Context canceled, shutting down server...\")\n\t\tif err := server.Shutdown(context.Background()); err != nil {\n\t\t\tlog.Printf(\"Server shutdown error: %v\", err)\n\t\t}\n\t}()\n\n\treturn nil\n}\n\nfunc (c *ClientImpl) RegisterSSEChannel(connectionId string) chan ssEvent {\n\tc.SSEChannelsLock.Lock()\n\tdefer c.SSEChannelsLock.Unlock()\n\n\tch := make(chan ssEvent, 100)\n\tc.SSEChannels[connectionId] = ch\n\treturn ch\n}\n\nfunc (c *ClientImpl) UnregisterSSEChannel(connectionId string) {\n\tc.SSEChannelsLock.Lock()\n\tdefer c.SSEChannelsLock.Unlock()\n\n\tif ch, exists := c.SSEChannels[connectionId]; exists {\n\t\tclose(ch)\n\t\tdelete(c.SSEChannels, connectionId)\n\t}\n}\n\nfunc (c *ClientImpl) SendSSEvent(event ssEvent) error {\n\tif c.GetIsDone() {\n\t\treturn fmt.Errorf(\"client is done\")\n\t}\n\n\tc.SSEChannelsLock.Lock()\n\tdefer c.SSEChannelsLock.Unlock()\n\n\t// Send to all registered SSE channels\n\tfor _, ch := range c.SSEChannels {\n\t\tselect {\n\t\tcase ch <- event:\n\t\t\t// Successfully sent\n\t\tdefault:\n\t\t\t// silently drop (below is just for debugging).  this wont happen in general\n\t\t\t// log.Printf(\"SSEvent channel is full for connection %s, skipping event\", connectionId)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *ClientImpl) SendAsyncInitiation() error {\n\treturn c.SendSSEvent(ssEvent{Event: \"asyncinitiation\", Data: nil})\n}\n\nfunc (c *ClientImpl) SendTermWrite(refId string, data string) error {\n\tpayload := rpctypes.TermWritePacket{\n\t\tRefId:  refId,\n\t\tData64: base64.StdEncoding.EncodeToString([]byte(data)),\n\t}\n\tjsonData, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn c.SendSSEvent(ssEvent{Event: \"termwrite\", Data: jsonData})\n}\n\nfunc makeNullRendered() *rpctypes.RenderedElem {\n\treturn &rpctypes.RenderedElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag}\n}\n\nfunc structToProps(props any) map[string]any {\n\tm, err := util.StructToMap(props)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn m\n}\n\nfunc DefineComponentEx[P any](client *ClientImpl, name string, renderFn func(props P) any) vdom.Component[P] {\n\tif name == \"\" {\n\t\tpanic(\"Component name cannot be empty\")\n\t}\n\tif !unicode.IsUpper(rune(name[0])) {\n\t\tpanic(\"Component name must start with an uppercase letter\")\n\t}\n\terr := client.registerComponent(name, renderFn)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn func(props P) *vdom.VDomElem {\n\t\treturn vdom.H(name, structToProps(props))\n\t}\n}\n\nfunc (c *ClientImpl) registerComponent(name string, cfunc any) error {\n\treturn c.Root.RegisterComponent(name, cfunc)\n}\n\nfunc (c *ClientImpl) fullRender() (*rpctypes.VDomBackendUpdate, error) {\n\topts := &RenderOpts{Resync: true}\n\tc.Root.RunWork(opts)\n\tc.Root.Render(c.RootElem, opts)\n\trenderedVDom := c.Root.MakeRendered()\n\tif renderedVDom == nil {\n\t\trenderedVDom = makeNullRendered()\n\t}\n\treturn &rpctypes.VDomBackendUpdate{\n\t\tType:       \"backendupdate\",\n\t\tTs:         time.Now().UnixMilli(),\n\t\tServerId:   c.ServerId,\n\t\tHasWork:    len(c.Root.EffectWorkQueue) > 0,\n\t\tFullUpdate: true,\n\t\tOpts:       c.makeBackendOpts(),\n\t\tRenderUpdates: []rpctypes.VDomRenderUpdate{\n\t\t\t{UpdateType: \"root\", VDom: renderedVDom},\n\t\t},\n\t\tRefOperations: c.Root.GetRefOperations(),\n\t}, nil\n}\n\nfunc (c *ClientImpl) incrementalRender() (*rpctypes.VDomBackendUpdate, error) {\n\topts := &RenderOpts{Resync: false}\n\tc.Root.RunWork(opts)\n\trenderedVDom := c.Root.MakeRendered()\n\tif renderedVDom == nil {\n\t\trenderedVDom = makeNullRendered()\n\t}\n\treturn &rpctypes.VDomBackendUpdate{\n\t\tType:       \"backendupdate\",\n\t\tTs:         time.Now().UnixMilli(),\n\t\tServerId:   c.ServerId,\n\t\tHasWork:    len(c.Root.EffectWorkQueue) > 0,\n\t\tFullUpdate: false,\n\t\tOpts:       c.makeBackendOpts(),\n\t\tRenderUpdates: []rpctypes.VDomRenderUpdate{\n\t\t\t{UpdateType: \"root\", VDom: renderedVDom},\n\t\t},\n\t\tRefOperations: c.Root.GetRefOperations(),\n\t}, nil\n}\n\nfunc (c *ClientImpl) HandleDynFunc(pattern string, fn func(http.ResponseWriter, *http.Request)) {\n\tif !strings.HasPrefix(pattern, \"/dyn/\") {\n\t\tlog.Printf(\"invalid dyn pattern: %s (must start with /dyn/)\", pattern)\n\t\treturn\n\t}\n\tc.UrlHandlerMux.HandleFunc(pattern, fn)\n}\n\nfunc (c *ClientImpl) RunEvents(events []vdom.VDomEvent) {\n\tfor _, event := range events {\n\t\tc.Root.Event(event, c.GlobalEventHandler)\n\t}\n}\n\nfunc (c *ClientImpl) GetAppMeta() AppMeta {\n\tc.Lock.Lock()\n\tdefer c.Lock.Unlock()\n\treturn c.Meta\n}\n\nfunc (c *ClientImpl) SetAppMeta(m AppMeta) {\n\tc.Lock.Lock()\n\tdefer c.Lock.Unlock()\n\tc.Meta = m\n}\n\n// addModalToMap adds a modal to the map and returns the result channel\nfunc (c *ClientImpl) addModalToMap(config rpctypes.ModalConfig) chan bool {\n\tc.OpenModalsLock.Lock()\n\tdefer c.OpenModalsLock.Unlock()\n\n\tresultChan := make(chan bool, 1)\n\tc.OpenModals[config.ModalId] = &ModalState{\n\t\tConfig:     config,\n\t\tResultChan: resultChan,\n\t}\n\treturn resultChan\n}\n\n// ShowModal displays a modal and returns a channel that will receive the result\nfunc (c *ClientImpl) ShowModal(config rpctypes.ModalConfig) chan bool {\n\tresultChan := c.addModalToMap(config)\n\n\tdata, err := json.Marshal(config)\n\tif err != nil {\n\t\tlog.Printf(\"failed to marshal modal config: %v\", err)\n\t\tc.CloseModal(config.ModalId, false)\n\t\treturn resultChan\n\t}\n\n\terr = c.SendSSEvent(ssEvent{Event: \"showmodal\", Data: data})\n\tif err != nil {\n\t\tlog.Printf(\"failed to send modal SSE event: %v\", err)\n\t\tc.CloseModal(config.ModalId, false)\n\t\treturn resultChan\n\t}\n\n\treturn resultChan\n}\n\n// removeModalFromMap removes a modal from the map and returns its state\nfunc (c *ClientImpl) removeModalFromMap(modalId string) *ModalState {\n\tc.OpenModalsLock.Lock()\n\tdefer c.OpenModalsLock.Unlock()\n\n\tmodalState, exists := c.OpenModals[modalId]\n\tif exists {\n\t\tdelete(c.OpenModals, modalId)\n\t}\n\treturn modalState\n}\n\n// CloseModal closes a modal with the given result\nfunc (c *ClientImpl) CloseModal(modalId string, result bool) {\n\tmodalState := c.removeModalFromMap(modalId)\n\tif modalState != nil {\n\t\tmodalState.ResultChan <- result\n\t\tclose(modalState.ResultChan)\n\t}\n}\n\n// CloseAllModals closes all open modals with cancelled result\n// This is called when the FE requests a resync (page refresh or new client)\nfunc (c *ClientImpl) CloseAllModals() {\n\tc.OpenModalsLock.Lock()\n\tmodalIds := make([]string, 0, len(c.OpenModals))\n\tfor modalId := range c.OpenModals {\n\t\tmodalIds = append(modalIds, modalId)\n\t}\n\tc.OpenModalsLock.Unlock()\n\n\tfor _, modalId := range modalIds {\n\t\tc.CloseModal(modalId, false)\n\t}\n}\n\nfunc (c *ClientImpl) DeclareSecret(name string, desc string, optional bool) {\n\tc.SecretsLock.Lock()\n\tdefer c.SecretsLock.Unlock()\n\tif _, exists := c.Secrets[name]; exists {\n\t\tpanic(fmt.Sprintf(\"secret '%s' already declared\", name))\n\t}\n\tc.Secrets[name] = SecretMeta{\n\t\tDesc:     desc,\n\t\tOptional: optional,\n\t}\n}\n\nfunc (c *ClientImpl) GetSecrets() map[string]SecretMeta {\n\tc.SecretsLock.Lock()\n\tdefer c.SecretsLock.Unlock()\n\tsecretsCopy := make(map[string]SecretMeta, len(c.Secrets))\n\tfor k, v := range c.Secrets {\n\t\tsecretsCopy[k] = v\n\t}\n\treturn secretsCopy\n}\n\nfunc (c *ClientImpl) GetAppManifest() AppManifest {\n\tappMeta := c.GetAppMeta()\n\tconfigSchema := GenerateConfigSchema(c.Root)\n\tdataSchema := GenerateDataSchema(c.Root)\n\tsecrets := c.GetSecrets()\n\n\treturn AppManifest{\n\t\tAppMeta:      appMeta,\n\t\tConfigSchema: configSchema,\n\t\tDataSchema:   dataSchema,\n\t\tSecrets:      secrets,\n\t}\n}\n\nfunc (c *ClientImpl) PrintAppManifest() {\n\tmanifest := c.GetAppManifest()\n\tmanifestJSON, err := json.MarshalIndent(manifest, \"\", \"  \")\n\tif err != nil {\n\t\tfmt.Printf(\"Error marshaling manifest: %v\\n\", err)\n\t\treturn\n\t}\n\tfmt.Println(\"<AppManifest>\")\n\tfmt.Println(string(manifestJSON))\n\tfmt.Println(\"</AppManifest>\")\n}\n"
  },
  {
    "path": "tsunami/engine/comp.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage engine\n\nimport \"github.com/wavetermdev/waveterm/tsunami/vdom\"\n\n// so components either render to another component (or fragment)\n// or to a base element (text or vdom).  base elements can then render children\n\ntype ChildKey struct {\n\tTag string\n\tIdx int\n\tKey string\n}\n\n// ComponentImpl represents a node in the persistent shadow component tree.\n// This is Tsunami's equivalent to React's Fiber nodes - it maintains component\n// identity, state, and lifecycle across renders while the VDomElem input/output\n// structures are ephemeral.\ntype ComponentImpl struct {\n\tWaveId         string         // Unique identifier for this component instance\n\tTag            string         // Component type (HTML tag, custom component name, \"#text\", etc.)\n\tKey            string         // User-provided key for reconciliation (like React keys)\n\tContainingComp string         // Which vdom component's render function created this ComponentImpl\n\tElem           *vdom.VDomElem // Reference to the current input VDomElem being rendered\n\tMounted        bool           // Whether this component is currently mounted\n\n\t// Hooks system (React-like)\n\tHooks []*Hook // Array of hooks (state, effects, etc.) attached to this component\n\n\t// Atom dependency tracking\n\tUsedAtoms map[string]bool // atomName -> true, tracks which atoms this component uses\n\n\t// Component content - exactly ONE of these patterns is used:\n\n\t// Pattern 1: Text nodes\n\tText string // For \"#text\" components - stores the actual text content\n\n\t// Pattern 2: Base/DOM elements with children\n\tChildren []*ComponentImpl // For HTML tags, fragments - array of child components\n\n\t// Pattern 3: Custom components that render to other components\n\tRenderedComp *ComponentImpl // For custom components - points to what this component rendered to\n}\n\nfunc (c *ComponentImpl) compMatch(tag string, key string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\treturn c.Tag == tag && c.Key == key\n}\n"
  },
  {
    "path": "tsunami/engine/errcomponent.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage engine\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\n// creates an error component for display when a component panics\nfunc renderErrorComponent(componentName string, errorMsg string) any {\n\treturn vdom.H(\"div\", map[string]any{\n\t\t\"className\": \"p-4 border border-red-500 bg-red-100 text-red-800 rounded font-mono\",\n\t},\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"font-bold mb-2\",\n\t\t}, fmt.Sprintf(\"Component Error: %s\", componentName)),\n\t\tvdom.H(\"div\", nil, errorMsg),\n\t)\n}\n"
  },
  {
    "path": "tsunami/engine/globalctx.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage engine\n\nimport (\n\t\"sync\"\n\n\t\"github.com/outrigdev/goid\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\nconst (\n\tGlobalContextType_async  = \"async\"\n\tGlobalContextType_render = \"render\"\n\tGlobalContextType_effect = \"effect\"\n\tGlobalContextType_event  = \"event\"\n)\n\n// is set ONLY when we're in the render function of a component\n// used for hooks, and automatic dependency tracking\nvar globalRenderContext *RenderContextImpl\nvar globalRenderGoId uint64\n\nvar globalEventContext *EventContextImpl\nvar globalEventGoId uint64\n\nvar globalEffectContext *EffectContextImpl\nvar globalEffectGoId uint64\n\nvar globalCtxMutex sync.Mutex\n\ntype EventContextImpl struct {\n\tEvent vdom.VDomEvent\n\tRoot  *RootElem\n}\n\ntype EffectContextImpl struct {\n\tWorkElem EffectWorkElem\n\tWorkType string // \"run\" or \"unmount\"\n\tRoot     *RootElem\n}\n\nfunc setGlobalRenderContext(vc *RenderContextImpl) {\n\tglobalCtxMutex.Lock()\n\tdefer globalCtxMutex.Unlock()\n\tglobalRenderContext = vc\n\tglobalRenderGoId = goid.Get()\n}\n\nfunc clearGlobalRenderContext() {\n\tglobalCtxMutex.Lock()\n\tdefer globalCtxMutex.Unlock()\n\tglobalRenderContext = nil\n\tglobalRenderGoId = 0\n}\n\nfunc withGlobalRenderCtx[T any](vc *RenderContextImpl, fn func() T) T {\n\tsetGlobalRenderContext(vc)\n\tdefer clearGlobalRenderContext()\n\treturn fn()\n}\n\nfunc GetGlobalRenderContext() *RenderContextImpl {\n\tglobalCtxMutex.Lock()\n\tdefer globalCtxMutex.Unlock()\n\tgid := goid.Get()\n\tif gid != globalRenderGoId {\n\t\treturn nil\n\t}\n\treturn globalRenderContext\n}\n\nfunc setGlobalEventContext(ec *EventContextImpl) {\n\tglobalCtxMutex.Lock()\n\tdefer globalCtxMutex.Unlock()\n\tglobalEventContext = ec\n\tglobalEventGoId = goid.Get()\n}\n\nfunc clearGlobalEventContext() {\n\tglobalCtxMutex.Lock()\n\tdefer globalCtxMutex.Unlock()\n\tglobalEventContext = nil\n\tglobalEventGoId = 0\n}\n\nfunc withGlobalEventCtx[T any](ec *EventContextImpl, fn func() T) T {\n\tsetGlobalEventContext(ec)\n\tdefer clearGlobalEventContext()\n\treturn fn()\n}\n\nfunc GetGlobalEventContext() *EventContextImpl {\n\tglobalCtxMutex.Lock()\n\tdefer globalCtxMutex.Unlock()\n\tgid := goid.Get()\n\tif gid != globalEventGoId {\n\t\treturn nil\n\t}\n\treturn globalEventContext\n}\n\nfunc setGlobalEffectContext(ec *EffectContextImpl) {\n\tglobalCtxMutex.Lock()\n\tdefer globalCtxMutex.Unlock()\n\tglobalEffectContext = ec\n\tglobalEffectGoId = goid.Get()\n}\n\nfunc clearGlobalEffectContext() {\n\tglobalCtxMutex.Lock()\n\tdefer globalCtxMutex.Unlock()\n\tglobalEffectContext = nil\n\tglobalEffectGoId = 0\n}\n\nfunc withGlobalEffectCtx[T any](ec *EffectContextImpl, fn func() T) T {\n\tsetGlobalEffectContext(ec)\n\tdefer clearGlobalEffectContext()\n\treturn fn()\n}\n\nfunc GetGlobalEffectContext() *EffectContextImpl {\n\tglobalCtxMutex.Lock()\n\tdefer globalCtxMutex.Unlock()\n\tgid := goid.Get()\n\tif gid != globalEffectGoId {\n\t\treturn nil\n\t}\n\treturn globalEffectContext\n}\n\n// inContextType returns the current global context type.\n// Returns one of:\n//   - GlobalContextType_render: when in a component render function\n//   - GlobalContextType_event: when in an event handler\n//   - GlobalContextType_effect: when in an effect function\n//   - GlobalContextType_async: when not in any specific context (default/async)\nfunc inContextType() string {\n\tglobalCtxMutex.Lock()\n\tdefer globalCtxMutex.Unlock()\n\t\n\tgid := goid.Get()\n\t\n\tif globalRenderContext != nil && gid == globalRenderGoId {\n\t\treturn GlobalContextType_render\n\t}\n\t\n\tif globalEventContext != nil && gid == globalEventGoId {\n\t\treturn GlobalContextType_event\n\t}\n\t\n\tif globalEffectContext != nil && gid == globalEffectGoId {\n\t\treturn GlobalContextType_effect\n\t}\n\t\n\treturn GlobalContextType_async\n}\n"
  },
  {
    "path": "tsunami/engine/hooks.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage engine\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\n// generic hook structure\ntype Hook struct {\n\tInit      bool          // is initialized\n\tIdx       int           // index in the hook array\n\tFn        func() func() // for useEffect\n\tUnmountFn func()        // for useEffect\n\tVal       any           // for useState, useMemo, useRef\n\tDeps      []any\n}\n\ntype RenderContextImpl struct {\n\tRoot       *RootElem\n\tComp       *ComponentImpl\n\tHookIdx    int\n\tRenderOpts *RenderOpts\n\tUsedAtoms  map[string]bool // Track atoms used during this render\n}\n\nfunc makeContextVal(root *RootElem, comp *ComponentImpl, opts *RenderOpts) *RenderContextImpl {\n\treturn &RenderContextImpl{\n\t\tRoot:       root,\n\t\tComp:       comp,\n\t\tHookIdx:    0,\n\t\tRenderOpts: opts,\n\t\tUsedAtoms:  make(map[string]bool),\n\t}\n}\n\nfunc (vc *RenderContextImpl) GetCompWaveId() string {\n\tif vc.Comp == nil {\n\t\treturn \"\"\n\t}\n\treturn vc.Comp.WaveId\n}\n\nfunc (vc *RenderContextImpl) getOrderedHook() *Hook {\n\tif vc.Comp == nil {\n\t\tpanic(\"tsunami hooks must be called within a component (vc.Comp is nil)\")\n\t}\n\tfor len(vc.Comp.Hooks) <= vc.HookIdx {\n\t\tvc.Comp.Hooks = append(vc.Comp.Hooks, &Hook{Idx: len(vc.Comp.Hooks)})\n\t}\n\thookVal := vc.Comp.Hooks[vc.HookIdx]\n\tvc.HookIdx++\n\treturn hookVal\n}\n\nfunc (vc *RenderContextImpl) getCompName() string {\n\tif vc.Comp == nil || vc.Comp.Elem == nil {\n\t\treturn \"\"\n\t}\n\treturn vc.Comp.Elem.Tag\n}\n\nfunc UseRenderTs(vc *RenderContextImpl) int64 {\n\treturn vc.Root.RenderTs\n}\n\nfunc UseId(vc *RenderContextImpl) string {\n\treturn vc.GetCompWaveId()\n}\n\nfunc UseLocal(vc *RenderContextImpl, initialVal any) string {\n\thookVal := vc.getOrderedHook()\n\tatomName := \"$local.\" + vc.GetCompWaveId() + \"#\" + strconv.Itoa(hookVal.Idx)\n\tif !hookVal.Init {\n\t\thookVal.Init = true\n\t\tatom := MakeAtomImpl(initialVal, nil)\n\t\tvc.Root.RegisterAtom(atomName, atom)\n\t\tclosedAtomName := atomName\n\t\thookVal.UnmountFn = func() {\n\t\t\tvc.Root.RemoveAtom(closedAtomName)\n\t\t}\n\t}\n\treturn atomName\n}\n\nfunc UseVDomRef(vc *RenderContextImpl) any {\n\thookVal := vc.getOrderedHook()\n\tif !hookVal.Init {\n\t\thookVal.Init = true\n\t\trefId := vc.GetCompWaveId() + \":\" + strconv.Itoa(hookVal.Idx)\n\t\thookVal.Val = &vdom.VDomRef{Type: vdom.ObjectType_Ref, RefId: refId}\n\t}\n\trefVal, ok := hookVal.Val.(*vdom.VDomRef)\n\tif !ok {\n\t\tpanic(\"UseVDomRef hook value is not a ref (possible out of order or conditional hooks)\")\n\t}\n\treturn refVal\n}\n\nfunc UseRef(vc *RenderContextImpl, hookInitialVal any) any {\n\thookVal := vc.getOrderedHook()\n\tif !hookVal.Init {\n\t\thookVal.Init = true\n\t\thookVal.Val = hookInitialVal\n\t}\n\treturn hookVal.Val\n}\n\nfunc depsEqual(deps1 []any, deps2 []any) bool {\n\tif len(deps1) != len(deps2) {\n\t\treturn false\n\t}\n\tfor i := range deps1 {\n\t\tif deps1[i] != deps2[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc UseEffect(vc *RenderContextImpl, fn func() func(), deps []any) {\n\thookVal := vc.getOrderedHook()\n\tcompTag := \"\"\n\tif vc.Comp != nil {\n\t\tcompTag = vc.Comp.Tag\n\t}\n\tif !hookVal.Init {\n\t\thookVal.Init = true\n\t\thookVal.Fn = fn\n\t\thookVal.Deps = deps\n\t\tvc.Root.addEffectWork(vc.GetCompWaveId(), hookVal.Idx, compTag)\n\t\treturn\n\t}\n\t// If deps is nil, always run (like React with no dependency array)\n\tif deps == nil {\n\t\thookVal.Fn = fn\n\t\thookVal.Deps = deps\n\t\tvc.Root.addEffectWork(vc.GetCompWaveId(), hookVal.Idx, compTag)\n\t\treturn\n\t}\n\n\tif depsEqual(hookVal.Deps, deps) {\n\t\treturn\n\t}\n\thookVal.Fn = fn\n\thookVal.Deps = deps\n\tvc.Root.addEffectWork(vc.GetCompWaveId(), hookVal.Idx, compTag)\n}\n\nfunc UseResync(vc *RenderContextImpl) bool {\n\tif vc.RenderOpts == nil {\n\t\treturn false\n\t}\n\treturn vc.RenderOpts.Resync\n}\n"
  },
  {
    "path": "tsunami/engine/render.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage engine\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"reflect\"\n\t\"unicode\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/wavetermdev/waveterm/tsunami/rpctypes\"\n\t\"github.com/wavetermdev/waveterm/tsunami/util\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\n// see render.md for a complete guide to how tsunami rendering, lifecycle, and reconciliation works\n\ntype RenderOpts struct {\n\tResync bool\n}\n\nfunc (r *RootElem) Render(elem *vdom.VDomElem, opts *RenderOpts) {\n\tr.render(elem, &r.Root, \"root\", opts)\n}\n\nfunc getElemKey(elem *vdom.VDomElem) string {\n\tif elem == nil {\n\t\treturn \"\"\n\t}\n\tkeyVal, ok := elem.Props[vdom.KeyPropKey]\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn fmt.Sprint(keyVal)\n}\n\nfunc (r *RootElem) render(elem *vdom.VDomElem, comp **ComponentImpl, containingComp string, opts *RenderOpts) {\n\tif elem == nil || elem.Tag == \"\" {\n\t\tr.unmount(comp)\n\t\treturn\n\t}\n\telemKey := getElemKey(elem)\n\tif *comp == nil || !(*comp).compMatch(elem.Tag, elemKey) {\n\t\tr.unmount(comp)\n\t\tr.createComp(elem.Tag, elemKey, containingComp, comp)\n\t}\n\t(*comp).Elem = elem\n\tif elem.Tag == vdom.TextTag {\n\t\t// Pattern 1: Text Nodes\n\t\tr.renderText(elem.Text, comp)\n\t\treturn\n\t}\n\tif isBaseTag(elem.Tag) {\n\t\t// Pattern 2: Base elements\n\t\tr.renderSimple(elem, comp, containingComp, opts)\n\t\treturn\n\t}\n\tcfunc := r.CFuncs[elem.Tag]\n\tif cfunc == nil {\n\t\ttext := fmt.Sprintf(\"<%s>\", elem.Tag)\n\t\tr.renderText(text, comp)\n\t\treturn\n\t}\n\t// Pattern 3: components\n\tr.renderComponent(cfunc, elem, comp, opts)\n}\n\n// Pattern 1\nfunc (r *RootElem) renderText(text string, comp **ComponentImpl) {\n\t// No need to clear Children/Comp - text components cannot have them\n\tif (*comp).Text != text {\n\t\t(*comp).Text = text\n\t}\n}\n\n// Pattern 2\nfunc (r *RootElem) renderSimple(elem *vdom.VDomElem, comp **ComponentImpl, containingComp string, opts *RenderOpts) {\n\tif (*comp).RenderedComp != nil {\n\t\t// Clear Comp since base elements don't use it\n\t\tr.unmount(&(*comp).RenderedComp)\n\t}\n\t(*comp).Children = r.renderChildren(elem.Children, (*comp).Children, containingComp, opts)\n}\n\n// Pattern 3\nfunc (r *RootElem) renderComponent(cfunc any, elem *vdom.VDomElem, comp **ComponentImpl, opts *RenderOpts) {\n\tif (*comp).Children != nil {\n\t\t// Clear Children since custom components don't use them\n\t\tfor _, child := range (*comp).Children {\n\t\t\tr.unmount(&child)\n\t\t}\n\t\t(*comp).Children = nil\n\t}\n\tprops := make(map[string]any)\n\tfor k, v := range elem.Props {\n\t\tprops[k] = v\n\t}\n\tprops[ChildrenPropKey] = elem.Children\n\tvc := makeContextVal(r, *comp, opts)\n\trtnElemArr := withGlobalRenderCtx(vc, func() []vdom.VDomElem {\n\t\trenderedElem := callCFuncWithErrorGuard(cfunc, props, elem.Tag)\n\t\treturn vdom.ToElems(renderedElem)\n\t})\n\n\t// Process atom usage after render\n\tr.updateComponentAtomUsage(*comp, vc.UsedAtoms)\n\n\tvar rtnElem *vdom.VDomElem\n\tif len(rtnElemArr) == 0 {\n\t\trtnElem = nil\n\t} else if len(rtnElemArr) == 1 {\n\t\trtnElem = &rtnElemArr[0]\n\t} else {\n\t\trtnElem = &vdom.VDomElem{Tag: vdom.FragmentTag, Children: rtnElemArr}\n\t}\n\tr.render(rtnElem, &(*comp).RenderedComp, elem.Tag, opts)\n}\n\nfunc (r *RootElem) unmount(comp **ComponentImpl) {\n\tif *comp == nil {\n\t\treturn\n\t}\n\twaveId := (*comp).WaveId\n\tfor _, hook := range (*comp).Hooks {\n\t\tif hook.UnmountFn != nil {\n\t\t\thook.UnmountFn()\n\t\t}\n\t}\n\tif (*comp).RenderedComp != nil {\n\t\tr.unmount(&(*comp).RenderedComp)\n\t}\n\tif (*comp).Children != nil {\n\t\tfor _, child := range (*comp).Children {\n\t\t\tr.unmount(&child)\n\t\t}\n\t}\n\tdelete(r.CompMap, waveId)\n\tr.cleanupUsedByForUnmount(*comp)\n\t*comp = nil\n}\n\nfunc (r *RootElem) createComp(tag string, key string, containingComp string, comp **ComponentImpl) {\n\t*comp = &ComponentImpl{WaveId: uuid.New().String(), Tag: tag, Key: key, ContainingComp: containingComp}\n\tr.CompMap[(*comp).WaveId] = *comp\n}\n\n// handles reconcilation\n// maps children via key or index (exclusively)\nfunc (r *RootElem) renderChildren(elems []vdom.VDomElem, curChildren []*ComponentImpl, containingComp string, opts *RenderOpts) []*ComponentImpl {\n\tnewChildren := make([]*ComponentImpl, len(elems))\n\tcurCM := make(map[ChildKey]*ComponentImpl)\n\tusedMap := make(map[*ComponentImpl]bool)\n\tfor idx, child := range curChildren {\n\t\tif child.Key != \"\" {\n\t\t\tcurCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child\n\t\t} else {\n\t\t\tcurCM[ChildKey{Tag: child.Tag, Idx: idx, Key: \"\"}] = child\n\t\t}\n\t}\n\tfor idx, elem := range elems {\n\t\telemKey := getElemKey(&elem)\n\t\tvar curChild *ComponentImpl\n\t\tif elemKey != \"\" {\n\t\t\tcurChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}]\n\t\t} else {\n\t\t\tcurChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: \"\"}]\n\t\t}\n\t\tusedMap[curChild] = true\n\t\tnewChildren[idx] = curChild\n\t\tr.render(&elem, &newChildren[idx], containingComp, opts)\n\t}\n\tfor _, child := range curChildren {\n\t\tif !usedMap[child] {\n\t\t\tr.unmount(&child)\n\t\t}\n\t}\n\treturn newChildren\n}\n\n// safely calls the component function with panic recovery\nfunc callCFuncWithErrorGuard(cfunc any, props map[string]any, componentName string) (result any) {\n\tdefer func() {\n\t\tif panicErr := util.PanicHandler(fmt.Sprintf(\"render component '%s'\", componentName), recover()); panicErr != nil {\n\t\t\tresult = renderErrorComponent(componentName, panicErr.Error())\n\t\t}\n\t}()\n\n\tresult = callCFunc(cfunc, props)\n\treturn result\n}\n\n// uses reflection to call the component function\nfunc callCFunc(cfunc any, props map[string]any) any {\n\trval := reflect.ValueOf(cfunc)\n\trtype := rval.Type()\n\n\tif rtype.NumIn() != 1 {\n\t\tfmt.Printf(\"component function must have exactly 1 parameter, got %d\\n\", rtype.NumIn())\n\t\treturn nil\n\t}\n\n\targType := rtype.In(0)\n\n\tvar arg1Val reflect.Value\n\tif argType.Kind() == reflect.Interface && argType.NumMethod() == 0 {\n\t\targ1Val = reflect.New(argType)\n\t} else {\n\t\targ1Val = reflect.New(argType)\n\t\tif argType.Kind() == reflect.Map {\n\t\t\targ1Val.Elem().Set(reflect.ValueOf(props))\n\t\t} else {\n\t\t\terr := util.MapToStruct(props, arg1Val.Interface())\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"error converting props: %v\\n\", err)\n\t\t\t}\n\t\t}\n\t}\n\trtnVal := rval.Call([]reflect.Value{arg1Val.Elem()})\n\tif len(rtnVal) == 0 {\n\t\treturn nil\n\t}\n\treturn rtnVal[0].Interface()\n}\n\nfunc convertPropsToVDom(props map[string]any) map[string]any {\n\tif len(props) == 0 {\n\t\treturn nil\n\t}\n\tvdomProps := make(map[string]any)\n\tfor k, v := range props {\n\t\tif v == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif vdomFunc, ok := v.(vdom.VDomFunc); ok {\n\t\t\t// ensure Type is set on all VDomFuncs\n\t\t\tvdomFunc.Type = vdom.ObjectType_Func\n\t\t\tvdomProps[k] = vdomFunc\n\t\t\tcontinue\n\t\t}\n\t\tif vdomFuncPtr, ok := v.(*vdom.VDomFunc); ok {\n\t\t\tif vdomFuncPtr == nil {\n\t\t\t\tcontinue // handled typed-nil\n\t\t\t}\n\t\t\t// ensure Type is set on all VDomFuncs (pointer)\n\t\t\tvdomFuncPtr.Type = vdom.ObjectType_Func\n\t\t\tvdomProps[k] = vdomFuncPtr\n\t\t\tcontinue\n\t\t}\n\t\tif vdomRefPtr, ok := v.(*vdom.VDomRef); ok {\n\t\t\tif vdomRefPtr == nil {\n\t\t\t\tcontinue // handle typed-nil\n\t\t\t}\n\t\t\t// ensure Type is set on all VDomRefs (pointer)\n\t\t\tvdomRefPtr.Type = vdom.ObjectType_Ref\n\t\t\tvdomProps[k] = vdomRefPtr\n\t\t\tcontinue\n\t\t}\n\t\tval := reflect.ValueOf(v)\n\t\tif val.Type() == reflect.TypeOf(vdom.VDomRef{}) {\n\t\t\tlog.Printf(\"warning: VDomRef passed as non-pointer for prop %q (VDomRef contains atomics and must be passed as *VDomRef); dropping prop\\n\", k)\n\t\t\tcontinue\n\t\t}\n\t\tif val.Kind() == reflect.Func {\n\t\t\t// convert go functions passed to event handlers to VDomFuncs\n\t\t\tvdomProps[k] = vdom.VDomFunc{Type: vdom.ObjectType_Func}\n\t\t\tcontinue\n\t\t}\n\t\tvdomProps[k] = v\n\t}\n\treturn vdomProps\n}\n\nfunc (r *RootElem) MakeRendered() *rpctypes.RenderedElem {\n\tif r.Root == nil {\n\t\treturn nil\n\t}\n\treturn r.convertCompToRendered(r.Root)\n}\n\nfunc (r *RootElem) convertCompToRendered(c *ComponentImpl) *rpctypes.RenderedElem {\n\tif c == nil {\n\t\treturn nil\n\t}\n\tif c.RenderedComp != nil {\n\t\treturn r.convertCompToRendered(c.RenderedComp)\n\t}\n\tif len(c.Children) == 0 && r.CFuncs[c.Tag] != nil {\n\t\treturn nil\n\t}\n\treturn r.convertBaseToRendered(c)\n}\n\nfunc (r *RootElem) convertBaseToRendered(c *ComponentImpl) *rpctypes.RenderedElem {\n\telem := &rpctypes.RenderedElem{WaveId: c.WaveId, Tag: c.Tag}\n\tif c.Elem != nil {\n\t\telem.Props = convertPropsToVDom(c.Elem.Props)\n\t}\n\tfor _, child := range c.Children {\n\t\tchildElem := r.convertCompToRendered(child)\n\t\tif childElem != nil {\n\t\t\telem.Children = append(elem.Children, *childElem)\n\t\t}\n\t}\n\tif c.Tag == vdom.TextTag {\n\t\telem.Text = c.Text\n\t}\n\treturn elem\n}\n\nfunc isBaseTag(tag string) bool {\n\tif tag == \"\" {\n\t\treturn false\n\t}\n\tif tag == vdom.TextTag || tag == vdom.WaveTextTag || tag == vdom.WaveNullTag || tag == vdom.FragmentTag {\n\t\treturn true\n\t}\n\tif tag[0] == '#' {\n\t\treturn true\n\t}\n\tfirstChar := rune(tag[0])\n\treturn unicode.IsLower(firstChar)\n}\n"
  },
  {
    "path": "tsunami/engine/render.md",
    "content": "# Tsunami Rendering Engine\n\nThe Tsunami rendering engine implements a React-like component system with virtual DOM reconciliation. It maintains a persistent shadow component tree that efficiently updates in response to new VDom input, similar to React's Fiber architecture.\n\n## Core Architecture\n\n### Two-Phase VDom System\n\nTsunami uses separate types for different phases of the rendering pipeline:\n\n- **VDomElem**: Input format used by developers (JSX-like elements created with `vdom.H()`)\n- **ComponentImpl**: Internal shadow tree that maintains component identity and state across renders\n- **RenderedElem**: Output format sent to the frontend with populated WaveIds\n\nThis separation mirrors React's approach where JSX elements, Fiber nodes, and DOM operations use different data structures optimized for their specific purposes.\n\n### ComponentImpl: The Shadow Tree\n\nThe `ComponentImpl` structure is Tsunami's equivalent to React's Fiber nodes. It maintains a persistent tree that survives between renders, preserving component identity, state, and lifecycle information.\n\nEach ComponentImpl contains:\n\n- **Identity fields**: WaveId (unique identifier), Tag (component type), Key (for reconciliation)\n- **State management**: Hooks array for React-like state and effects\n- **Content organization**: Exactly one of three mutually exclusive patterns\n\n## Three Component Patterns\n\nThe engine organizes components into three distinct patterns, each using different fields in ComponentImpl:\n\n### Pattern 1: Text Components\n\n```go\nText string                    // Text content (Pattern 1: text nodes only)\nChildren = nil                 // Not used\nRenderedComp = nil            // Not used\n```\n\nUsed for `#text` components that render string content directly. These are the leaf nodes of the component tree.\n\n**Example**: `vdom.H(\"#text\", nil, \"Hello World\")` creates a ComponentImpl with `Text = \"Hello World\"`\n\n### Pattern 2: Base/DOM Elements\n\n```go\nText = \"\"                      // Not used\nChildren []*ComponentImpl      // Child components (Pattern 2: containers only)\nRenderedComp = nil            // Not used\n```\n\nUsed for HTML elements, fragments, and Wave-specific elements that act as containers. These components render multiple children but don't transform into other component types.\n\n**Example**: `vdom.H(\"div\", nil, child1, child2)` creates a ComponentImpl with `Children = [child1Comp, child2Comp]`\n\n**Base elements include**:\n\n- HTML tags with lowercase first letter (`\"div\"`, `\"span\"`, `\"button\"`)\n- Hash-prefixed special elements (`\"#fragment\"`, `\"#text\"`)\n- Wave-specific elements (`\"wave:text\"`, `\"wave:null\"`)\n\n### Pattern 3: Custom Components\n\n```go\nText = \"\"                      // Not used\nChildren = nil                 // Not used\nRenderedComp *ComponentImpl   // Rendered output (Pattern 3: custom components only)\n```\n\nUsed for user-defined components that transform into other components through their render functions. These create component chains where custom components render to base elements.\n\n**Example**: A `TodoItem` component renders to a `div`, creating the chain:\n\n```\nTodoItem ComponentImpl (Pattern 3)\n└── RenderedComp → div ComponentImpl (Pattern 2)\n                   └── Children → [text, button, etc.]\n```\n\n## Rendering Flow\n\n### 1. Reconciliation and Pattern Routing\n\nThe main `render()` function performs React-like reconciliation:\n\n1. **Null handling**: `elem == nil` unmounts the component\n2. **Component matching**: Existing components are reused if tag and key match\n3. **Pattern routing**: Elements are routed to the appropriate pattern based on tag type\n\n```go\nif elem.Tag == vdom.TextTag {\n    // Pattern 1: Text Nodes\n    r.renderText(elem.Text, comp)\n} else if isBaseTag(elem.Tag) {\n    // Pattern 2: Base elements\n    r.renderSimple(elem, comp, opts)\n} else {\n    // Pattern 3: Custom components\n    r.renderComponent(cfunc, elem, comp, opts)\n}\n```\n\n### 2. Pattern-Specific Rendering\n\nEach pattern has its own rendering function that manages field usage:\n\n**renderText()**: Simply stores text content, no cleanup needed since text components can't have other patterns.\n\n**renderSimple()**: Clears any existing `RenderedComp` (Pattern 3) and renders children into the `Children` field (Pattern 2).\n\n**renderComponent()**: Clears any existing `Children` (Pattern 2), calls the component function, and renders the result into `RenderedComp` (Pattern 3).\n\n### 3. Component Function Execution\n\nCustom components are Go functions called via reflection:\n\n1. **Props conversion**: The VDomElem props map is converted to the expected Go struct type\n2. **Function execution**: The component function is called with context and typed props\n3. **Result processing**: Returned elements are converted to VDomElem arrays\n4. **Fragment wrapping**: Multiple returned elements are automatically wrapped in fragments\n\n```go\n// Single element: renders directly to RenderedComp\n// Multiple elements: wrapped in fragment, then rendered to RenderedComp\nif len(rtnElemArr) == 1 {\n    rtnElem = &rtnElemArr[0]\n} else {\n    rtnElem = &vdom.VDomElem{Tag: vdom.FragmentTag, Children: rtnElemArr}\n}\n```\n\n## Key-Based Reconciliation\n\nThe children reconciliation system implements React's key-matching logic:\n\n### ChildKey Structure\n\n```go\ntype ChildKey struct {\n    Tag string  // Component type must match\n    Idx int     // Position index for non-keyed elements\n    Key string  // Explicit key for keyed elements\n}\n```\n\n### Matching Rules\n\n1. **Keyed elements**: Match by tag + key, position ignored\n\n   - `<div key=\"a\">` only matches `<div key=\"a\">`\n   - Position changes don't break identity\n\n2. **Non-keyed elements**: Match by tag + position\n\n   - `<div>` at position 0 only matches `<div>` at position 0\n   - Moving elements breaks identity and causes remount\n\n3. **Key transitions**: Keyed and non-keyed elements never match\n   - `<div>` → `<div key=\"hello\">` causes remount\n   - Adding/removing keys breaks component identity\n\n### Reconciliation Algorithm\n\n```go\n// Build map of existing children by ChildKey\nfor idx, child := range curChildren {\n    if child.Key != \"\" {\n        curCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child\n    } else {\n        curCM[ChildKey{Tag: child.Tag, Idx: idx, Key: \"\"}] = child\n    }\n}\n\n// Match new elements against existing map\nfor idx, elem := range elems {\n    elemKey := getElemKey(&elem)\n    if elemKey != \"\" {\n        curChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}]\n    } else {\n        curChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: \"\"}]\n    }\n    // Reuse existing component or create new one\n}\n```\n\n## Component Lifecycle\n\n### Mounting\n\nNew components are created with:\n\n- Unique WaveId for tracking\n- Tag and Key for reconciliation\n- Registration in global ComponentMap\n- Empty pattern fields (populated during rendering)\n\n### Unmounting\n\nThe unmounting process ensures complete cleanup:\n\n1. **Hook cleanup**: All hook `UnmountFn` callbacks are executed\n2. **Pattern-specific cleanup**:\n   - Pattern 3: Recursively unmount `RenderedComp`\n   - Pattern 2: Recursively unmount all `Children`\n   - Pattern 1: No child cleanup needed\n3. **Global cleanup**: Remove from ComponentMap and dependency tracking\n\nThis prevents memory leaks and ensures proper lifecycle management.\n\n### Component vs Rendered Content Lifecycle\n\nA key distinction in Tsunami (matching React) is that component mounting/unmounting is separate from what they render:\n\n- **Component returns `nil`**: Component stays mounted (keeps state/hooks), but `RenderedComp` becomes `nil`\n- **Component returns content again**: Component reuses existing identity, new content gets mounted\n\nThis preserves component state across rendering/not-rendering cycles.\n\n## Output Generation\n\nThe shadow tree gets converted to frontend-ready format through `MakeRendered()`:\n\n1. **Component chain following**: For Pattern 3 components, follow `RenderedComp` until reaching a base element\n2. **Base element conversion**: Convert Pattern 1/2 components to RenderedElem with WaveIds\n3. **Null component filtering**: Components with `RenderedComp == nil` don't appear in output\n\nOnly base elements (Pattern 1/2) appear in the final output - custom components (Pattern 3) are invisible, having transformed into base elements.\n\n## React Similarities and Differences\n\n### Similarities\n\n- **Reconciliation**: Same key-based matching and component reuse logic\n- **Hooks**: Same lifecycle patterns with cleanup functions\n- **Component identity**: Persistent component instances across renders\n- **Null rendering**: Components can render nothing while staying mounted\n\n### Key Differences\n\n- **Server-side**: Runs entirely in Go backend, sends VDom to frontend\n- **Component chaining**: Pattern 3 allows direct component-to-component rendering via `RenderedComp`\n- **Explicit patterns**: Three mutually exclusive patterns vs React's more flexible structure\n- **Type separation**: Clear separation between input VDom, shadow tree, and output types\n\n### Performance Optimizations\n\nThe three-pattern system provides significant optimizations:\n\n- **Base element efficiency**: HTML elements use `Children` directly without intermediate transformation nodes\n- **Component chain efficiency**: Custom components chain via `RenderedComp` without wrapper overhead\n- **Memory efficiency**: Each pattern only allocates fields it actually uses\n\nThis avoids React's issue where every element creates wrapper nodes, leading to shorter traversal paths and fewer allocations.\n\n## Pattern Transition Rules\n\nComponents never transition between patterns - they maintain their pattern for their entire lifecycle:\n\n- **Tag determines pattern**: `#text` → Pattern 1, base tags → Pattern 2, custom tags → Pattern 3\n- **Tag changes cause remount**: Different tag = different component = complete unmount/remount\n- **Pattern fields are exclusive**: Only one pattern's fields are populated per component\n\nThis ensures clean memory management and predictable behavior - no cross-pattern cleanup is needed within individual render functions.\n"
  },
  {
    "path": "tsunami/engine/rootelem.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage engine\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/rpctypes\"\n\t\"github.com/wavetermdev/waveterm/tsunami/util\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\nconst ChildrenPropKey = \"children\"\n\ntype EffectWorkElem struct {\n\tWaveId      string\n\tEffectIndex int\n\tCompTag     string\n}\n\ntype genAtom interface {\n\tGetVal() any\n\tSetVal(any) error\n\tSetUsedBy(string, bool)\n\tGetUsedBy() []string\n\tGetMeta() *AtomMeta\n\tGetAtomType() reflect.Type\n}\n\ntype RootElem struct {\n\tRoot            *ComponentImpl\n\tRenderTs        int64\n\tCFuncs          map[string]any            // component name => render function\n\tCompMap         map[string]*ComponentImpl // component waveid -> component\n\tEffectWorkQueue []*EffectWorkElem\n\tneedsRenderMap  map[string]bool // key: waveid\n\tneedsRenderLock sync.Mutex\n\tAtoms           map[string]genAtom // key: atomName\n\tatomLock        sync.Mutex\n\tRefOperations   []vdom.VDomRefOperation\n\tClient          *ClientImpl\n}\n\nfunc (r *RootElem) addRenderWork(id string) {\n\tdefer func() {\n\t\tif inContextType() == GlobalContextType_async {\n\t\t\tr.Client.notifyAsyncRenderWork()\n\t\t}\n\t}()\n\n\tr.needsRenderLock.Lock()\n\tdefer r.needsRenderLock.Unlock()\n\n\tif r.needsRenderMap == nil {\n\t\tr.needsRenderMap = make(map[string]bool)\n\t}\n\tr.needsRenderMap[id] = true\n}\n\nfunc (r *RootElem) getAndClearRenderWork() []string {\n\tr.needsRenderLock.Lock()\n\tdefer r.needsRenderLock.Unlock()\n\n\tif len(r.needsRenderMap) == 0 {\n\t\treturn nil\n\t}\n\n\tids := make([]string, 0, len(r.needsRenderMap))\n\tfor id := range r.needsRenderMap {\n\t\tids = append(ids, id)\n\t}\n\tr.needsRenderMap = nil\n\treturn ids\n}\n\nfunc (r *RootElem) addEffectWork(id string, effectIndex int, compTag string) {\n\tr.EffectWorkQueue = append(r.EffectWorkQueue, &EffectWorkElem{WaveId: id, EffectIndex: effectIndex, CompTag: compTag})\n}\n\n// getAtomsByPrefix extracts all atoms that match the given prefix from RootElem\nfunc (r *RootElem) getAtomsByPrefix(prefix string) map[string]genAtom {\n\tr.atomLock.Lock()\n\tdefer r.atomLock.Unlock()\n\n\tresult := make(map[string]genAtom)\n\tfor atomName, atom := range r.Atoms {\n\t\tif strings.HasPrefix(atomName, prefix) {\n\t\t\tstrippedName := strings.TrimPrefix(atomName, prefix)\n\t\t\tresult[strippedName] = atom\n\t\t}\n\t}\n\treturn result\n}\n\nfunc (r *RootElem) GetDataMap() map[string]any {\n\tatoms := r.getAtomsByPrefix(\"$data.\")\n\tresult := make(map[string]any)\n\tfor name, atom := range atoms {\n\t\tresult[name] = atom.GetVal()\n\t}\n\treturn result\n}\n\nfunc (r *RootElem) GetConfigMap() map[string]any {\n\tatoms := r.getAtomsByPrefix(\"$config.\")\n\tresult := make(map[string]any)\n\tfor name, atom := range atoms {\n\t\tresult[name] = atom.GetVal()\n\t}\n\treturn result\n}\n\nfunc MakeRoot(client *ClientImpl) *RootElem {\n\treturn &RootElem{\n\t\tRoot:    nil,\n\t\tCFuncs:  make(map[string]any),\n\t\tCompMap: make(map[string]*ComponentImpl),\n\t\tAtoms:   make(map[string]genAtom),\n\t\tClient:  client,\n\t}\n}\n\nfunc (r *RootElem) RegisterAtom(name string, atom genAtom) {\n\tr.atomLock.Lock()\n\tdefer r.atomLock.Unlock()\n\n\tif _, ok := r.Atoms[name]; ok {\n\t\tpanic(fmt.Sprintf(\"atom %s already exists\", name))\n\t}\n\tr.Atoms[name] = atom\n}\n\n// cleanupUsedByForUnmount uses the reverse mapping for efficient cleanup\nfunc (r *RootElem) cleanupUsedByForUnmount(comp *ComponentImpl) {\n\tr.atomLock.Lock()\n\tdefer r.atomLock.Unlock()\n\n\t// Use reverse mapping for efficient cleanup\n\tfor atomName := range comp.UsedAtoms {\n\t\tif atom, ok := r.Atoms[atomName]; ok {\n\t\t\tatom.SetUsedBy(comp.WaveId, false)\n\t\t}\n\t}\n\n\t// Clear the component's atom tracking\n\tcomp.UsedAtoms = nil\n}\n\nfunc (r *RootElem) updateComponentAtomUsage(comp *ComponentImpl, newUsedAtoms map[string]bool) {\n\tr.atomLock.Lock()\n\tdefer r.atomLock.Unlock()\n\n\toldUsedAtoms := comp.UsedAtoms\n\n\t// Remove component from atoms it no longer uses\n\tfor atomName := range oldUsedAtoms {\n\t\tif !newUsedAtoms[atomName] {\n\t\t\tif atom, ok := r.Atoms[atomName]; ok {\n\t\t\t\tatom.SetUsedBy(comp.WaveId, false)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Add component to atoms it now uses\n\tfor atomName := range newUsedAtoms {\n\t\tif !oldUsedAtoms[atomName] {\n\t\t\tif atom, ok := r.Atoms[atomName]; ok {\n\t\t\t\tatom.SetUsedBy(comp.WaveId, true)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Update component's atom usage map\n\tif len(newUsedAtoms) == 0 {\n\t\tcomp.UsedAtoms = nil\n\t} else {\n\t\tcomp.UsedAtoms = make(map[string]bool)\n\t\tfor atomName := range newUsedAtoms {\n\t\t\tcomp.UsedAtoms[atomName] = true\n\t\t}\n\t}\n}\n\nfunc (r *RootElem) AtomAddRenderWork(atomName string) {\n\tr.atomLock.Lock()\n\tdefer r.atomLock.Unlock()\n\n\tatom, ok := r.Atoms[atomName]\n\tif !ok {\n\t\treturn\n\t}\n\tusedBy := atom.GetUsedBy()\n\tif len(usedBy) == 0 {\n\t\treturn\n\t}\n\tfor _, compId := range usedBy {\n\t\tr.addRenderWork(compId)\n\t}\n}\n\nfunc (r *RootElem) GetAtomVal(name string) any {\n\tr.atomLock.Lock()\n\tdefer r.atomLock.Unlock()\n\n\tatom, ok := r.Atoms[name]\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn atom.GetVal()\n}\n\nfunc (r *RootElem) SetAtomVal(name string, val any) error {\n\tr.atomLock.Lock()\n\tdefer r.atomLock.Unlock()\n\n\tatom, ok := r.Atoms[name]\n\tif !ok {\n\t\treturn fmt.Errorf(\"atom %q not found\", name)\n\t}\n\treturn atom.SetVal(val)\n}\n\nfunc (r *RootElem) RemoveAtom(name string) {\n\tr.atomLock.Lock()\n\tdefer r.atomLock.Unlock()\n\n\tdelete(r.Atoms, name)\n}\n\nfunc validateCFunc(cfunc any) error {\n\tif cfunc == nil {\n\t\treturn fmt.Errorf(\"Component function cannot b nil\")\n\t}\n\trval := reflect.ValueOf(cfunc)\n\tif rval.Kind() != reflect.Func {\n\t\treturn fmt.Errorf(\"Component function must be a function\")\n\t}\n\trtype := rval.Type()\n\tif rtype.NumIn() != 1 {\n\t\treturn fmt.Errorf(\"Component function must take exactly 1 argument\")\n\t}\n\tif rtype.NumOut() != 1 {\n\t\treturn fmt.Errorf(\"Component function must return exactly 1 value\")\n\t}\n\t// first argument can be a map[string]any, or a struct, or ptr to struct (we'll reflect the value into it)\n\targ1Type := rtype.In(0)\n\tif arg1Type.Kind() == reflect.Ptr {\n\t\targ1Type = arg1Type.Elem()\n\t}\n\tif arg1Type.Kind() == reflect.Map {\n\t\tif arg1Type.Key().Kind() != reflect.String ||\n\t\t\t!(arg1Type.Elem().Kind() == reflect.Interface && arg1Type.Elem().NumMethod() == 0) {\n\t\t\treturn fmt.Errorf(\"Map argument must be map[string]any\")\n\t\t}\n\t} else if arg1Type.Kind() != reflect.Struct &&\n\t\t!(arg1Type.Kind() == reflect.Interface && arg1Type.NumMethod() == 0) {\n\t\treturn fmt.Errorf(\"Component function argument must be map[string]any, struct, or any\")\n\t}\n\treturn nil\n}\n\nfunc (r *RootElem) RegisterComponent(name string, cfunc any) error {\n\tif err := validateCFunc(cfunc); err != nil {\n\t\treturn err\n\t}\n\tr.CFuncs[name] = cfunc\n\treturn nil\n}\n\nfunc callVDomFn(fnVal any, data vdom.VDomEvent) {\n\tif fnVal == nil {\n\t\treturn\n\t}\n\tfn := fnVal\n\tif vdf, ok := fnVal.(*vdom.VDomFunc); ok {\n\t\tfn = vdf.Fn\n\t}\n\tif fn == nil {\n\t\treturn\n\t}\n\trval := reflect.ValueOf(fn)\n\tif rval.Kind() != reflect.Func {\n\t\treturn\n\t}\n\trtype := rval.Type()\n\tif rtype.NumIn() == 0 {\n\t\trval.Call(nil)\n\t\treturn\n\t}\n\tif rtype.NumIn() == 1 {\n\t\trval.Call([]reflect.Value{reflect.ValueOf(data)})\n\t\treturn\n\t}\n}\n\nfunc (r *RootElem) Event(event vdom.VDomEvent, globalEventHandler func(vdom.VDomEvent)) {\n\tdefer func() {\n\t\tif event.GlobalEventType != \"\" {\n\t\t\tutil.PanicHandler(fmt.Sprintf(\"Global event handler - event:%s\", event.GlobalEventType), recover())\n\t\t} else {\n\t\t\tcomp := r.CompMap[event.WaveId]\n\t\t\ttag := \"\"\n\t\t\tif comp != nil && comp.Elem != nil {\n\t\t\t\ttag = comp.Elem.Tag\n\t\t\t}\n\t\t\tcompName := \"\"\n\t\t\tif comp != nil {\n\t\t\t\tcompName = comp.ContainingComp\n\t\t\t}\n\t\t\tutil.PanicHandler(fmt.Sprintf(\"Event handler - comp: %s, tag: %s, prop: %s\", compName, tag, event.EventType), recover())\n\t\t}\n\t}()\n\n\teventCtx := &EventContextImpl{Event: event, Root: r}\n\twithGlobalEventCtx(eventCtx, func() any {\n\t\tif event.GlobalEventType != \"\" {\n\t\t\tif globalEventHandler == nil {\n\t\t\t\tlog.Printf(\"global event %s but no handler\", event.GlobalEventType)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tglobalEventHandler(event)\n\t\t\treturn nil\n\t\t}\n\n\t\tcomp := r.CompMap[event.WaveId]\n\t\tif comp == nil || comp.Elem == nil {\n\t\t\treturn nil\n\t\t}\n\n\t\tfnVal := comp.Elem.Props[event.EventType]\n\t\tcallVDomFn(fnVal, event)\n\t\treturn nil\n\t})\n}\n\nfunc (r *RootElem) runEffectUnmount(work *EffectWorkElem, hook *Hook) {\n\tdefer func() {\n\t\tcomp := r.CompMap[work.WaveId]\n\t\tcompName := \"\"\n\t\tif comp != nil {\n\t\t\tcompName = comp.ContainingComp\n\t\t}\n\t\tutil.PanicHandler(fmt.Sprintf(\"UseEffect unmount - comp: %s\", compName), recover())\n\t}()\n\tif hook.UnmountFn == nil {\n\t\treturn\n\t}\n\teffectCtx := &EffectContextImpl{\n\t\tWorkElem: *work,\n\t\tWorkType: \"unmount\",\n\t\tRoot:     r,\n\t}\n\twithGlobalEffectCtx(effectCtx, func() any {\n\t\thook.UnmountFn()\n\t\treturn nil\n\t})\n}\n\nfunc (r *RootElem) runEffect(work *EffectWorkElem, hook *Hook) {\n\tdefer func() {\n\t\tcomp := r.CompMap[work.WaveId]\n\t\tcompName := \"\"\n\t\tif comp != nil {\n\t\t\tcompName = comp.ContainingComp\n\t\t}\n\t\tutil.PanicHandler(fmt.Sprintf(\"UseEffect run - comp: %s\", compName), recover())\n\t}()\n\tif hook.Fn == nil {\n\t\treturn\n\t}\n\teffectCtx := &EffectContextImpl{\n\t\tWorkElem: *work,\n\t\tWorkType: \"run\",\n\t\tRoot:     r,\n\t}\n\tunmountFn := withGlobalEffectCtx(effectCtx, func() func() {\n\t\treturn hook.Fn()\n\t})\n\thook.UnmountFn = unmountFn\n}\n\n// this will be called by the frontend to say the DOM has been mounted\n// it will eventually send any updated \"refs\" to the backend as well\nfunc (r *RootElem) RunWork(opts *RenderOpts) {\n\tworkQueue := r.EffectWorkQueue\n\tr.EffectWorkQueue = nil\n\t// first, run effect cleanups\n\tfor _, work := range workQueue {\n\t\tcomp := r.CompMap[work.WaveId]\n\t\tif comp == nil {\n\t\t\tcontinue\n\t\t}\n\t\thook := comp.Hooks[work.EffectIndex]\n\t\tr.runEffectUnmount(work, hook)\n\t}\n\t// now run, new effects\n\tfor _, work := range workQueue {\n\t\tcomp := r.CompMap[work.WaveId]\n\t\tif comp == nil {\n\t\t\tcontinue\n\t\t}\n\t\thook := comp.Hooks[work.EffectIndex]\n\t\tr.runEffect(work, hook)\n\t}\n\t// now check if we need a render\n\trenderIds := r.getAndClearRenderWork()\n\tif len(renderIds) > 0 {\n\t\tr.render(r.Root.Elem, &r.Root, \"root\", opts)\n\t}\n}\n\nfunc (r *RootElem) UpdateRef(updateRef rpctypes.VDomRefUpdate) {\n\trefId := updateRef.RefId\n\tsplit := strings.SplitN(refId, \":\", 2)\n\tif len(split) != 2 {\n\t\tlog.Printf(\"invalid ref id: %s\\n\", refId)\n\t\treturn\n\t}\n\twaveId := split[0]\n\thookIdx, err := strconv.Atoi(split[1])\n\tif err != nil {\n\t\tlog.Printf(\"invalid ref id (bad hook idx): %s\\n\", refId)\n\t\treturn\n\t}\n\tcomp := r.CompMap[waveId]\n\tif comp == nil {\n\t\treturn\n\t}\n\tif hookIdx < 0 || hookIdx >= len(comp.Hooks) {\n\t\treturn\n\t}\n\thook := comp.Hooks[hookIdx]\n\tif hook == nil {\n\t\treturn\n\t}\n\tref, ok := hook.Val.(*vdom.VDomRef)\n\tif !ok {\n\t\treturn\n\t}\n\tref.HasCurrent.Store(updateRef.HasCurrent)\n\tref.Position = updateRef.Position\n\tif updateRef.TermSize != nil {\n\t\tref.TermSize = updateRef.TermSize\n\t}\n}\n\nfunc (r *RootElem) QueueRefOp(op vdom.VDomRefOperation) {\n\tr.RefOperations = append(r.RefOperations, op)\n}\n\nfunc (r *RootElem) GetRefOperations() []vdom.VDomRefOperation {\n\tops := r.RefOperations\n\tr.RefOperations = nil\n\treturn ops\n}\n"
  },
  {
    "path": "tsunami/engine/schema.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage engine\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/util\"\n)\n\n// createStructDefinition creates a JSON schema definition for a struct type\nfunc createStructDefinition(t reflect.Type) map[string]any {\n\tstructDef := make(map[string]any)\n\tstructDef[\"type\"] = \"object\"\n\tproperties := make(map[string]any)\n\trequired := make([]string, 0)\n\n\tfor i := 0; i < t.NumField(); i++ {\n\t\tfield := t.Field(i)\n\t\tif !field.IsExported() {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Parse JSON tag\n\t\tfieldInfo, shouldInclude := util.ParseJSONTag(field)\n\t\tif !shouldInclude {\n\t\t\tcontinue // Skip this field\n\t\t}\n\n\t\t// If field has \"string\" option, force schema type to string\n\t\tvar fieldSchema map[string]any\n\t\tif fieldInfo.AsString {\n\t\t\tfieldSchema = map[string]any{\"type\": \"string\"}\n\t\t} else {\n\t\t\tfieldSchema = generateShallowJSONSchema(field.Type, nil)\n\t\t}\n\n\t\t// Add description from \"desc\" tag if present\n\t\tif desc := field.Tag.Get(\"desc\"); desc != \"\" {\n\t\t\tfieldSchema[\"description\"] = desc\n\t\t}\n\n\t\t// Add enum values from \"enum\" tag if present (only for string types)\n\t\tif enumTag := field.Tag.Get(\"enum\"); enumTag != \"\" && fieldSchema[\"type\"] == \"string\" {\n\t\t\tenumValues := make([]any, 0)\n\t\t\tfor _, val := range strings.Split(enumTag, \",\") {\n\t\t\t\ttrimmed := strings.TrimSpace(val)\n\t\t\t\tif trimmed != \"\" {\n\t\t\t\t\tenumValues = append(enumValues, trimmed)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(enumValues) > 0 {\n\t\t\t\tfieldSchema[\"enum\"] = enumValues\n\t\t\t}\n\t\t}\n\n\t\t// Add units from \"units\" tag if present\n\t\tif units := field.Tag.Get(\"units\"); units != \"\" {\n\t\t\tfieldSchema[\"units\"] = units\n\t\t}\n\n\t\t// Add min/max constraints for numeric types\n\t\tif fieldSchema[\"type\"] == \"number\" || fieldSchema[\"type\"] == \"integer\" {\n\t\t\tif minTag := field.Tag.Get(\"min\"); minTag != \"\" {\n\t\t\t\tif minVal, err := strconv.ParseFloat(minTag, 64); err == nil {\n\t\t\t\t\tfieldSchema[\"minimum\"] = minVal\n\t\t\t\t}\n\t\t\t}\n\t\t\tif maxTag := field.Tag.Get(\"max\"); maxTag != \"\" {\n\t\t\t\tif maxVal, err := strconv.ParseFloat(maxTag, 64); err == nil {\n\t\t\t\t\tfieldSchema[\"maximum\"] = maxVal\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Add pattern constraint for string types\n\t\tif fieldSchema[\"type\"] == \"string\" {\n\t\t\tif pattern := field.Tag.Get(\"pattern\"); pattern != \"\" {\n\t\t\t\tfieldSchema[\"pattern\"] = pattern\n\t\t\t}\n\t\t}\n\n\t\tproperties[fieldInfo.FieldName] = fieldSchema\n\n\t\t// Add to required if not a pointer and not marked as omitempty\n\t\tif field.Type.Kind() != reflect.Ptr && !fieldInfo.OmitEmpty {\n\t\t\trequired = append(required, fieldInfo.FieldName)\n\t\t}\n\t}\n\n\tif len(properties) > 0 {\n\t\tstructDef[\"properties\"] = properties\n\t}\n\tif len(required) > 0 {\n\t\tstructDef[\"required\"] = required\n\t}\n\n\treturn structDef\n}\n\n// collectStructDefs walks the type tree and adds struct definitions to defs map\nfunc collectStructDefs(t reflect.Type, defs map[reflect.Type]any) {\n\tswitch t.Kind() {\n\tcase reflect.Slice, reflect.Array:\n\t\tif t.Elem() != nil {\n\t\t\tcollectStructDefs(t.Elem(), defs)\n\t\t}\n\tcase reflect.Map:\n\t\tif t.Elem() != nil {\n\t\t\tcollectStructDefs(t.Elem(), defs)\n\t\t}\n\tcase reflect.Struct:\n\t\t// Skip time.Time since we handle it specially\n\t\tif t == reflect.TypeOf(time.Time{}) {\n\t\t\treturn\n\t\t}\n\n\t\t// Skip if we already have this struct definition\n\t\tif _, exists := defs[t]; exists {\n\t\t\treturn\n\t\t}\n\n\t\t// Create the struct definition\n\t\tstructDef := createStructDefinition(t)\n\n\t\t// Add the definition before recursing into field types\n\t\tdefs[t] = structDef\n\n\t\t// Now recurse into field types to collect their struct definitions\n\t\tfor i := 0; i < t.NumField(); i++ {\n\t\t\tfield := t.Field(i)\n\t\t\tif field.IsExported() {\n\t\t\t\t_, shouldInclude := util.ParseJSONTag(field)\n\t\t\t\tif shouldInclude {\n\t\t\t\t\tcollectStructDefs(field.Type, defs)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase reflect.Ptr:\n\t\tcollectStructDefs(t.Elem(), defs)\n\t}\n}\n\n// annotateSchemaWithAtomMeta applies AtomMeta annotations to a JSON schema\nfunc annotateSchemaWithAtomMeta(schema map[string]any, meta *AtomMeta) {\n\tif meta == nil {\n\t\treturn\n\t}\n\n\tif meta.Description != \"\" {\n\t\tschema[\"description\"] = meta.Description\n\t}\n\n\tif meta.Units != \"\" {\n\t\tschema[\"units\"] = meta.Units\n\t}\n\n\t// Add numeric constraints for number/integer types\n\tif schema[\"type\"] == \"number\" || schema[\"type\"] == \"integer\" {\n\t\tif meta.Min != nil {\n\t\t\tschema[\"minimum\"] = *meta.Min\n\t\t}\n\t\tif meta.Max != nil {\n\t\t\tschema[\"maximum\"] = *meta.Max\n\t\t}\n\t}\n\n\t// Add enum values if specified (only for string types)\n\tif len(meta.Enum) > 0 && schema[\"type\"] == \"string\" {\n\t\tenumValues := make([]any, len(meta.Enum))\n\t\tfor i, v := range meta.Enum {\n\t\t\tenumValues[i] = v\n\t\t}\n\t\tschema[\"enum\"] = enumValues\n\t}\n\n\t// Add pattern constraint for strings\n\tif schema[\"type\"] == \"string\" && meta.Pattern != \"\" {\n\t\tschema[\"pattern\"] = meta.Pattern\n\t}\n}\n\n// generateShallowJSONSchema creates a schema that references definitions instead of recursing\nfunc generateShallowJSONSchema(t reflect.Type, meta *AtomMeta) map[string]any {\n\tschema := make(map[string]any)\n\tdefer func() {\n\t\tannotateSchemaWithAtomMeta(schema, meta)\n\t}()\n\n\t// Special case for time.Time - treat as string with date-time format\n\tif t == reflect.TypeOf(time.Time{}) {\n\t\tschema[\"type\"] = \"string\"\n\t\tschema[\"format\"] = \"date-time\"\n\t\treturn schema\n\t}\n\n\t// Special case for []byte - treat as string with base64 encoding\n\tif t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 {\n\t\tschema[\"type\"] = \"string\"\n\t\tschema[\"contentEncoding\"] = \"base64\"\n\t\tschema[\"contentMediaType\"] = \"application/octet-stream\"\n\t\treturn schema\n\t}\n\n\tswitch t.Kind() {\n\tcase reflect.String:\n\t\tschema[\"type\"] = \"string\"\n\tcase reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,\n\t\treflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:\n\t\tschema[\"type\"] = \"integer\"\n\tcase reflect.Float32, reflect.Float64:\n\t\tschema[\"type\"] = \"number\"\n\tcase reflect.Bool:\n\t\tschema[\"type\"] = \"boolean\"\n\tcase reflect.Slice, reflect.Array:\n\t\tschema[\"type\"] = \"array\"\n\t\tif t.Elem() != nil {\n\t\t\tschema[\"items\"] = generateShallowJSONSchema(t.Elem(), nil)\n\t\t}\n\tcase reflect.Map:\n\t\tschema[\"type\"] = \"object\"\n\t\tif t.Elem() != nil {\n\t\t\tschema[\"additionalProperties\"] = generateShallowJSONSchema(t.Elem(), nil)\n\t\t}\n\tcase reflect.Struct:\n\t\t// Reference the definition instead of recursing\n\t\tschema[\"$ref\"] = fmt.Sprintf(\"#/$defs/%s\", t.Name())\n\tcase reflect.Ptr:\n\t\treturn generateShallowJSONSchema(t.Elem(), meta)\n\tcase reflect.Interface:\n\t\tschema[\"type\"] = \"object\"\n\tdefault:\n\t\tschema[\"type\"] = \"object\"\n\t}\n\n\treturn schema\n}\n\n// getAtomMeta extracts AtomMeta from the atom\nfunc getAtomMeta(atom genAtom) *AtomMeta {\n\treturn atom.GetMeta()\n}\n\n// generateSchemaFromAtoms generates a JSON schema from a map of atoms\nfunc generateSchemaFromAtoms(atoms map[string]genAtom, title, description string) map[string]any {\n\t// Collect all struct definitions\n\tdefs := make(map[reflect.Type]any)\n\tfor _, atom := range atoms {\n\t\tatomType := atom.GetAtomType()\n\t\tif atomType != nil {\n\t\t\tcollectStructDefs(atomType, defs)\n\t\t}\n\t}\n\n\t// Generate properties for each atom\n\tproperties := make(map[string]any)\n\tfor atomName, atom := range atoms {\n\t\tatomType := atom.GetAtomType()\n\t\tif atomType != nil {\n\t\t\tatomMeta := getAtomMeta(atom)\n\t\t\tproperties[atomName] = generateShallowJSONSchema(atomType, atomMeta)\n\t\t}\n\t}\n\n\t// Build the final schema\n\t// schema line unnecessary for AI (and burns tokens)\n\t// also dropping title since it is mostly redundant\n\tschema := map[string]any{\n\t\t// \"$schema\":              \"https://json-schema.org/draft/2020-12/schema\",\n\t\t\"type\": \"object\",\n\t\t// \"title\":                title,\n\t\t\"description\":          description,\n\t\t\"properties\":           properties,\n\t\t\"additionalProperties\": false,\n\t}\n\n\t// Add definitions if any\n\tif len(defs) > 0 {\n\t\tdefinitions := make(map[string]any)\n\t\tfor t, def := range defs {\n\t\t\tdefinitions[t.Name()] = def\n\t\t}\n\t\tschema[\"$defs\"] = definitions\n\t}\n\n\treturn schema\n}\n\n// GenerateConfigSchema generates a JSON schema for all config atoms\nfunc GenerateConfigSchema(root *RootElem) map[string]any {\n\tconfigAtoms := root.getAtomsByPrefix(\"$config.\")\n\treturn generateSchemaFromAtoms(configAtoms, \"Application Configuration\", \"Application configuration settings\")\n}\n\n// GenerateDataSchema generates a JSON schema for all data atoms\nfunc GenerateDataSchema(root *RootElem) map[string]any {\n\tdataAtoms := root.getAtomsByPrefix(\"$data.\")\n\treturn generateSchemaFromAtoms(dataAtoms, \"Application Data\", \"Application data schema\")\n}\n"
  },
  {
    "path": "tsunami/engine/serverhandlers.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage engine\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"log\"\n\t\"mime\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/rpctypes\"\n\t\"github.com/wavetermdev/waveterm/tsunami/util\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\nconst SSEKeepAliveDuration = 5 * time.Second\n\nfunc init() {\n\t// Add explicit mapping for .json files\n\tmime.AddExtensionType(\".json\", \"application/json\")\n}\n\ntype handlerOpts struct {\n\tAssetsFS     fs.FS\n\tStaticFS     fs.FS\n\tManifestFile []byte\n}\n\ntype httpHandlers struct {\n\tClient     *ClientImpl\n\trenderLock sync.Mutex\n}\n\nfunc newHTTPHandlers(client *ClientImpl) *httpHandlers {\n\treturn &httpHandlers{\n\t\tClient: client,\n\t}\n}\n\nfunc setNoCacheHeaders(w http.ResponseWriter) {\n\tw.Header().Set(\"Cache-Control\", \"no-cache, no-store, must-revalidate\")\n\tw.Header().Set(\"Pragma\", \"no-cache\")\n\tw.Header().Set(\"Expires\", \"0\")\n}\n\nfunc setCORSHeaders(w http.ResponseWriter, r *http.Request) bool {\n\tcorsOriginsStr := os.Getenv(\"TSUNAMI_CORS\")\n\tif corsOriginsStr == \"\" {\n\t\treturn false\n\t}\n\n\torigin := r.Header.Get(\"Origin\")\n\tif origin == \"\" {\n\t\treturn false\n\t}\n\n\tallowedOrigins := strings.Split(corsOriginsStr, \",\")\n\tfor _, allowedOrigin := range allowedOrigins {\n\t\tallowedOrigin = strings.TrimSpace(allowedOrigin)\n\t\tif allowedOrigin == origin {\n\t\t\tw.Header().Set(\"Access-Control-Allow-Origin\", origin)\n\t\t\tw.Header().Set(\"Access-Control-Allow-Methods\", \"GET, POST, OPTIONS\")\n\t\t\tw.Header().Set(\"Access-Control-Allow-Headers\", \"Content-Type\")\n\t\t\tw.Header().Set(\"Access-Control-Allow-Credentials\", \"true\")\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) {\n\tmux.HandleFunc(\"/api/render\", h.handleRender)\n\tmux.HandleFunc(\"/api/updates\", h.handleSSE)\n\tmux.HandleFunc(\"/api/data\", h.handleData)\n\tmux.HandleFunc(\"/api/config\", h.handleConfig)\n\tmux.HandleFunc(\"/api/schemas\", h.handleSchemas)\n\tmux.HandleFunc(\"/api/manifest\", h.handleManifest(opts.ManifestFile))\n\tmux.HandleFunc(\"/api/modalresult\", h.handleModalResult)\n\tmux.HandleFunc(\"/api/terminput\", h.handleTermInput)\n\tmux.HandleFunc(\"/dyn/\", h.handleDynContent)\n\n\t// Add handler for static files at /static/ path\n\tif opts.StaticFS != nil {\n\t\tmux.HandleFunc(\"/static/\", h.handleStaticPathFiles(opts.StaticFS))\n\t}\n\n\t// Add fallback handler for embedded static files in production mode\n\tif opts.AssetsFS != nil {\n\t\tmux.HandleFunc(\"/\", h.handleStaticFiles(opts.AssetsFS))\n\t}\n}\n\nfunc (h *httpHandlers) handleRender(w http.ResponseWriter, r *http.Request) {\n\tdefer func() {\n\t\tpanicErr := util.PanicHandler(\"handleRender\", recover())\n\t\tif panicErr != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"internal server error: %v\", panicErr), http.StatusInternalServerError)\n\t\t}\n\t}()\n\n\tsetNoCacheHeaders(w)\n\n\tif r.Method != http.MethodPost {\n\t\thttp.Error(w, \"method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\n\tbody, err := io.ReadAll(r.Body)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"failed to read request body: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tvar feUpdate rpctypes.VDomFrontendUpdate\n\tif err := json.Unmarshal(body, &feUpdate); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"failed to parse JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif feUpdate.ForceTakeover {\n\t\th.Client.clientTakeover(feUpdate.ClientId)\n\t}\n\n\tif err := h.Client.checkClientId(feUpdate.ClientId); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"client id error: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tstartTime := time.Now()\n\tupdate, err := h.processFrontendUpdate(&feUpdate)\n\tduration := time.Since(startTime)\n\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"render error: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\tif update == nil {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tif os.Getenv(\"TSUNAMI_DEBUG\") != \"\" {\n\t\t\tlog.Printf(\"render %4s %4dms %4dk %s\", \"none\", duration.Milliseconds(), 0, feUpdate.Reason)\n\t\t}\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\n\t// Encode to bytes first to calculate size\n\tresponseBytes, err := json.Marshal(update)\n\tif err != nil {\n\t\tlog.Printf(\"failed to encode response: %v\", err)\n\t\thttp.Error(w, \"failed to encode response\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tupdateSizeKB := len(responseBytes) / 1024\n\trenderType := \"inc\"\n\tif update.FullUpdate {\n\t\trenderType = \"full\"\n\t}\n\tif os.Getenv(\"TSUNAMI_DEBUG\") != \"\" {\n\t\tlog.Printf(\"render %4s %4dms %4dk %s\", renderType, duration.Milliseconds(), updateSizeKB, feUpdate.Reason)\n\t}\n\n\tif _, err := w.Write(responseBytes); err != nil {\n\t\tlog.Printf(\"failed to write response: %v\", err)\n\t}\n}\n\nfunc (h *httpHandlers) processFrontendUpdate(feUpdate *rpctypes.VDomFrontendUpdate) (*rpctypes.VDomBackendUpdate, error) {\n\th.renderLock.Lock()\n\tdefer h.renderLock.Unlock()\n\n\tif feUpdate.Dispose {\n\t\tlog.Printf(\"got dispose from frontend\\n\")\n\t\th.Client.doShutdown(\"got dispose from frontend\")\n\t\treturn nil, nil\n\t}\n\n\tif h.Client.GetIsDone() {\n\t\treturn nil, nil\n\t}\n\n\th.Client.Root.RenderTs = feUpdate.Ts\n\n\t// Close all open modals on resync (e.g., page refresh)\n\tif feUpdate.Resync {\n\t\th.Client.CloseAllModals()\n\t}\n\n\t// run events\n\th.Client.RunEvents(feUpdate.Events)\n\t// update refs\n\tfor _, ref := range feUpdate.RefUpdates {\n\t\th.Client.Root.UpdateRef(ref)\n\t}\n\n\tvar update *rpctypes.VDomBackendUpdate\n\tvar renderErr error\n\n\tif feUpdate.Resync || true {\n\t\tupdate, renderErr = h.Client.fullRender()\n\t} else {\n\t\tupdate, renderErr = h.Client.incrementalRender()\n\t}\n\n\tif renderErr != nil {\n\t\treturn nil, renderErr\n\t}\n\n\tupdate.CreateTransferElems()\n\treturn update, nil\n}\n\nfunc (h *httpHandlers) handleData(w http.ResponseWriter, r *http.Request) {\n\tdefer func() {\n\t\tpanicErr := util.PanicHandler(\"handleData\", recover())\n\t\tif panicErr != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"internal server error: %v\", panicErr), http.StatusInternalServerError)\n\t\t}\n\t}()\n\n\tsetCORSHeaders(w, r)\n\tsetNoCacheHeaders(w)\n\n\tif r.Method == http.MethodOptions {\n\t\tw.WriteHeader(http.StatusOK)\n\t\treturn\n\t}\n\n\tif r.Method != http.MethodGet {\n\t\thttp.Error(w, \"method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\n\tresult := h.Client.Root.GetDataMap()\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(result); err != nil {\n\t\tlog.Printf(\"failed to encode data response: %v\", err)\n\t\thttp.Error(w, \"failed to encode response\", http.StatusInternalServerError)\n\t}\n}\n\nfunc (h *httpHandlers) handleConfig(w http.ResponseWriter, r *http.Request) {\n\tdefer func() {\n\t\tpanicErr := util.PanicHandler(\"handleConfig\", recover())\n\t\tif panicErr != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"internal server error: %v\", panicErr), http.StatusInternalServerError)\n\t\t}\n\t}()\n\n\tsetCORSHeaders(w, r)\n\tsetNoCacheHeaders(w)\n\n\tif r.Method == http.MethodOptions {\n\t\tw.WriteHeader(http.StatusOK)\n\t\treturn\n\t}\n\n\tswitch r.Method {\n\tcase http.MethodGet:\n\t\th.handleConfigGet(w, r)\n\tcase http.MethodPost:\n\t\th.handleConfigPost(w, r)\n\tdefault:\n\t\thttp.Error(w, \"method not allowed\", http.StatusMethodNotAllowed)\n\t}\n}\n\nfunc (h *httpHandlers) handleConfigGet(w http.ResponseWriter, _ *http.Request) {\n\tresult := h.Client.Root.GetConfigMap()\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(result); err != nil {\n\t\tlog.Printf(\"failed to encode config response: %v\", err)\n\t\thttp.Error(w, \"failed to encode response\", http.StatusInternalServerError)\n\t}\n}\n\nfunc (h *httpHandlers) handleConfigPost(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(r.Body)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"failed to read request body: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tvar configData map[string]any\n\tif err := json.Unmarshal(body, &configData); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"failed to parse JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tvar failedKeys []string\n\tfor key, value := range configData {\n\t\tatomName := \"$config.\" + key\n\t\tif err := h.Client.Root.SetAtomVal(atomName, value); err != nil {\n\t\t\tfailedKeys = append(failedKeys, key)\n\t\t}\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\n\tvar response map[string]any\n\tif len(failedKeys) > 0 {\n\t\tresponse = map[string]any{\n\t\t\t\"error\": fmt.Sprintf(\"Failed to update keys: %s\", strings.Join(failedKeys, \", \")),\n\t\t}\n\t} else {\n\t\tresponse = map[string]any{\n\t\t\t\"success\": true,\n\t\t}\n\t}\n\n\tw.WriteHeader(http.StatusOK)\n\n\tjson.NewEncoder(w).Encode(response)\n}\n\nfunc (h *httpHandlers) handleSchemas(w http.ResponseWriter, r *http.Request) {\n\tdefer func() {\n\t\tpanicErr := util.PanicHandler(\"handleSchemas\", recover())\n\t\tif panicErr != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"internal server error: %v\", panicErr), http.StatusInternalServerError)\n\t\t}\n\t}()\n\n\tsetCORSHeaders(w, r)\n\tsetNoCacheHeaders(w)\n\n\tif r.Method == http.MethodOptions {\n\t\tw.WriteHeader(http.StatusOK)\n\t\treturn\n\t}\n\n\tif r.Method != http.MethodGet {\n\t\thttp.Error(w, \"method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\n\tconfigSchema := GenerateConfigSchema(h.Client.Root)\n\tdataSchema := GenerateDataSchema(h.Client.Root)\n\n\tresult := map[string]any{\n\t\t\"config\": configSchema,\n\t\t\"data\":   dataSchema,\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(result); err != nil {\n\t\tlog.Printf(\"failed to encode schemas response: %v\", err)\n\t\thttp.Error(w, \"failed to encode response\", http.StatusInternalServerError)\n\t}\n}\n\nfunc (h *httpHandlers) handleModalResult(w http.ResponseWriter, r *http.Request) {\n\tdefer func() {\n\t\tpanicErr := util.PanicHandler(\"handleModalResult\", recover())\n\t\tif panicErr != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"internal server error: %v\", panicErr), http.StatusInternalServerError)\n\t\t}\n\t}()\n\n\tsetNoCacheHeaders(w)\n\n\tif r.Method != http.MethodPost {\n\t\thttp.Error(w, \"method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\n\tbody, err := io.ReadAll(r.Body)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"failed to read request body: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tvar result rpctypes.ModalResult\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"failed to parse JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\th.Client.CloseModal(result.ModalId, result.Confirm)\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.WriteHeader(http.StatusOK)\n\tjson.NewEncoder(w).Encode(map[string]any{\"success\": true})\n}\n\nfunc (h *httpHandlers) handleTermInput(w http.ResponseWriter, r *http.Request) {\n\tdefer func() {\n\t\tpanicErr := util.PanicHandler(\"handleTermInput\", recover())\n\t\tif panicErr != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"internal server error: %v\", panicErr), http.StatusInternalServerError)\n\t\t}\n\t}()\n\n\tsetNoCacheHeaders(w)\n\n\tif r.Method != http.MethodPost {\n\t\thttp.Error(w, \"method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\n\tbody, err := io.ReadAll(r.Body)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"failed to read request body: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tvar event vdom.VDomEvent\n\tif err := json.Unmarshal(body, &event); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"failed to parse JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\tif strings.TrimSpace(event.WaveId) == \"\" {\n\t\thttp.Error(w, \"waveid is required\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tif event.TermInput == nil {\n\t\thttp.Error(w, \"terminput is required\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\th.renderLock.Lock()\n\th.Client.Root.Event(event, h.Client.GlobalEventHandler)\n\th.renderLock.Unlock()\n\n\tw.WriteHeader(http.StatusNoContent)\n}\n\nfunc (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) {\n\tdefer func() {\n\t\tpanicErr := util.PanicHandler(\"handleDynContent\", recover())\n\t\tif panicErr != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"internal server error: %v\", panicErr), http.StatusInternalServerError)\n\t\t}\n\t}()\n\n\t// Strip /assets prefix and update the request URL\n\tr.URL.Path = strings.TrimPrefix(r.URL.Path, \"/dyn\")\n\tif r.URL.Path == \"\" {\n\t\tr.URL.Path = \"/\"\n\t}\n\n\th.Client.UrlHandlerMux.ServeHTTP(w, r)\n}\n\nfunc (h *httpHandlers) handleSSE(w http.ResponseWriter, r *http.Request) {\n\tdefer func() {\n\t\tpanicErr := util.PanicHandler(\"handleSSE\", recover())\n\t\tif panicErr != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"internal server error: %v\", panicErr), http.StatusInternalServerError)\n\t\t}\n\t}()\n\n\tif r.Method != http.MethodGet {\n\t\thttp.Error(w, \"method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\n\tclientId := r.URL.Query().Get(\"clientId\")\n\tif err := h.Client.checkClientId(clientId); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"client id error: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Generate unique connection ID for this SSE connection\n\tconnectionId := fmt.Sprintf(\"%s-%d\", clientId, time.Now().UnixNano())\n\n\t// Register SSE channel for this connection\n\teventCh := h.Client.RegisterSSEChannel(connectionId)\n\tdefer h.Client.UnregisterSSEChannel(connectionId)\n\n\t// Set SSE headers\n\tsetNoCacheHeaders(w)\n\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tw.Header().Set(\"Connection\", \"keep-alive\")\n\tw.Header().Set(\"X-Accel-Buffering\", \"no\")\n\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\n\t// Use ResponseController for better flushing control\n\trc := http.NewResponseController(w)\n\tif err := rc.Flush(); err != nil {\n\t\thttp.Error(w, \"streaming not supported\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Create a ticker for keepalive packets\n\tkeepaliveTicker := time.NewTicker(SSEKeepAliveDuration)\n\tdefer keepaliveTicker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-r.Context().Done():\n\t\t\treturn\n\t\tcase <-keepaliveTicker.C:\n\t\t\t// Send keepalive comment\n\t\t\tfmt.Fprintf(w, \": keepalive\\n\\n\")\n\t\t\trc.Flush()\n\t\tcase event := <-eventCh:\n\t\t\tif event.Event == \"\" {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tfmt.Fprintf(w, \"event: %s\\n\", event.Event)\n\t\t\tfmt.Fprintf(w, \"data: %s\\n\", string(event.Data))\n\t\t\tfmt.Fprintf(w, \"\\n\")\n\t\t\trc.Flush()\n\t\t}\n\t}\n}\n\n// serveFileDirectly serves a file directly from an embed.FS to avoid redirect loops\n// when serving directory paths that end with \"/\"\nfunc serveFileDirectly(w http.ResponseWriter, r *http.Request, embeddedFS fs.FS, requestPath, fileName string) bool {\n\tif !strings.HasSuffix(requestPath, \"/\") {\n\t\treturn false\n\t}\n\n\t// Try to serve the specified file from that directory\n\tvar filePath string\n\tif requestPath == \"/\" {\n\t\tfilePath = fileName\n\t} else {\n\t\tfilePath = strings.TrimPrefix(requestPath, \"/\") + fileName\n\t}\n\n\tfile, err := embeddedFS.Open(filePath)\n\tif err != nil {\n\t\treturn false\n\t}\n\tdefer file.Close()\n\n\t// Get file info for modification time\n\tfileInfo, err := file.Stat()\n\tif err != nil {\n\t\treturn false\n\t}\n\n\t// Serve the file directly with proper mod time\n\thttp.ServeContent(w, r, fileName, fileInfo.ModTime(), file.(io.ReadSeeker))\n\treturn true\n}\n\nfunc (h *httpHandlers) handleStaticFiles(embeddedFS fs.FS) http.HandlerFunc {\n\tfileServer := http.FileServer(http.FS(embeddedFS))\n\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tdefer func() {\n\t\t\tpanicErr := util.PanicHandler(\"handleStaticFiles\", recover())\n\t\t\tif panicErr != nil {\n\t\t\t\thttp.Error(w, fmt.Sprintf(\"internal server error: %v\", panicErr), http.StatusInternalServerError)\n\t\t\t}\n\t\t}()\n\n\t\t// Skip if this is an API, files, or static request (already handled by other handlers)\n\t\tif strings.HasPrefix(r.URL.Path, \"/api/\") || strings.HasPrefix(r.URL.Path, \"/files/\") || strings.HasPrefix(r.URL.Path, \"/static/\") {\n\t\t\thttp.NotFound(w, r)\n\t\t\treturn\n\t\t}\n\n\t\t// Handle any path ending with \"/\" to avoid redirect loops\n\t\tif serveFileDirectly(w, r, embeddedFS, r.URL.Path, \"index.html\") {\n\t\t\treturn\n\t\t}\n\n\t\t// For other files, check if they exist before serving\n\t\tfilePath := strings.TrimPrefix(r.URL.Path, \"/\")\n\t\t_, err := embeddedFS.Open(filePath)\n\t\tif err != nil {\n\t\t\thttp.NotFound(w, r)\n\t\t\treturn\n\t\t}\n\n\t\t// Serve the file using the file server\n\t\tfileServer.ServeHTTP(w, r)\n\t}\n}\n\nfunc (h *httpHandlers) handleManifest(manifestFileBytes []byte) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tdefer func() {\n\t\t\tpanicErr := util.PanicHandler(\"handleManifest\", recover())\n\t\t\tif panicErr != nil {\n\t\t\t\thttp.Error(w, fmt.Sprintf(\"internal server error: %v\", panicErr), http.StatusInternalServerError)\n\t\t\t}\n\t\t}()\n\n\t\tsetCORSHeaders(w, r)\n\t\tsetNoCacheHeaders(w)\n\n\t\tif r.Method == http.MethodOptions {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\treturn\n\t\t}\n\n\t\tif r.Method != http.MethodGet {\n\t\t\thttp.Error(w, \"method not allowed\", http.StatusMethodNotAllowed)\n\t\t\treturn\n\t\t}\n\n\t\tif manifestFileBytes == nil {\n\t\t\thttp.NotFound(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.Write(manifestFileBytes)\n\t}\n}\n\nfunc (h *httpHandlers) handleStaticPathFiles(staticFS fs.FS) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tdefer func() {\n\t\t\tpanicErr := util.PanicHandler(\"handleStaticPathFiles\", recover())\n\t\t\tif panicErr != nil {\n\t\t\t\thttp.Error(w, fmt.Sprintf(\"internal server error: %v\", panicErr), http.StatusInternalServerError)\n\t\t\t}\n\t\t}()\n\n\t\t// Strip /static/ prefix from the path\n\t\tfilePath := strings.TrimPrefix(r.URL.Path, \"/static/\")\n\t\tif filePath == \"\" {\n\t\t\t// Handle requests to \"/static/\" directly\n\t\t\tif serveFileDirectly(w, r, staticFS, \"/\", \"index.html\") {\n\t\t\t\treturn\n\t\t\t}\n\t\t\thttp.NotFound(w, r)\n\t\t\treturn\n\t\t}\n\n\t\t// Handle directory paths ending with \"/\" to avoid redirect loops\n\t\tstrippedPath := \"/\" + filePath\n\t\tif serveFileDirectly(w, r, staticFS, strippedPath, \"index.html\") {\n\t\t\treturn\n\t\t}\n\n\t\t// Check if file exists in staticFS\n\t\t_, err := staticFS.Open(filePath)\n\t\tif err != nil {\n\t\t\thttp.NotFound(w, r)\n\t\t\treturn\n\t\t}\n\n\t\t// Create a file server and serve the file\n\t\tfileServer := http.FileServer(http.FS(staticFS))\n\n\t\t// Temporarily modify the URL path for the file server\n\t\toriginalPath := r.URL.Path\n\t\tr.URL.Path = \"/\" + filePath\n\t\tfileServer.ServeHTTP(w, r)\n\t\tr.URL.Path = originalPath\n\t}\n}\n"
  },
  {
    "path": "tsunami/frontend/.gitignore",
    "content": "scaffold/"
  },
  {
    "path": "tsunami/frontend/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Tsunami App</title>\n    <link rel=\"icon\" href=\"public/wave-logo-256.png\" />\n  </head>\n  <body className=\"bg-background text-primary\">\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "tsunami/frontend/package.json",
    "content": "{\n    \"name\": \"tsunami-frontend\",\n    \"author\": {\n        \"name\": \"Command Line Inc\",\n        \"email\": \"info@commandline.dev\"\n    },\n    \"description\": \"Tsunami Frontend - React application\",\n    \"license\": \"Apache-2.0\",\n    \"version\": \"0.1.0\",\n    \"private\": true,\n    \"type\": \"module\",\n    \"scripts\": {\n        \"dev\": \"vite\",\n        \"build\": \"vite build\",\n        \"build:dev\": \"NODE_ENV=development vite build --mode development\",\n        \"preview\": \"vite preview\",\n        \"type-check\": \"tsc --noEmit\"\n    },\n    \"dependencies\": {\n        \"clsx\": \"^2.1.1\",\n        \"debug\": \"^4.4.3\",\n        \"jotai\": \"^2.13.1\",\n        \"react\": \"^19.2.0\",\n        \"react-dom\": \"^19.2.0\",\n        \"react-markdown\": \"^10.1.0\",\n        \"recharts\": \"^3.1.2\",\n        \"tailwind-merge\": \"^3.3.1\"\n    },\n    \"devDependencies\": {\n        \"@tailwindcss/cli\": \"^4.2.1\",\n        \"@tailwindcss/vite\": \"^4.2.1\",\n        \"@types/react\": \"^19\",\n        \"@types/react-dom\": \"^19\",\n        \"@vitejs/plugin-react-swc\": \"^4.2.3\",\n        \"tailwindcss\": \"^4.2.1\",\n        \"typescript\": \"^5.9.3\",\n        \"vite\": \"^6.4.1\"\n    }\n}\n"
  },
  {
    "path": "tsunami/frontend/src/app.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { TsunamiModel } from \"@/model/tsunami-model\";\nimport { VDomView } from \"./vdom\";\n\n// Global model instance\nconst globalModel = new TsunamiModel();\n\nfunction App() {\n    return (\n        <div className=\"min-h-screen bg-background text-foreground\">\n            <VDomView model={globalModel} />\n        </div>\n    );\n}\n\nexport default App;\n"
  },
  {
    "path": "tsunami/frontend/src/element/markdown.tsx",
    "content": "import React from 'react';\nimport ReactMarkdown, { Components } from 'react-markdown';\nimport { twMerge } from 'tailwind-merge';\n\ninterface MarkdownProps {\n    text?: string;\n    style?: React.CSSProperties;\n    className?: string;\n    scrollable?: boolean;\n}\n\nconst markdownComponents: Partial<Components> = {\n    h1: ({ children }) => <h1 className=\"text-3xl font-bold mb-4 mt-6 text-foreground\">{children}</h1>,\n    h2: ({ children }) => <h2 className=\"text-2xl font-bold mb-3 mt-5 text-foreground\">{children}</h2>,\n    h3: ({ children }) => <h3 className=\"text-xl font-bold mb-3 mt-4 text-foreground\">{children}</h3>,\n    h4: ({ children }) => <h4 className=\"text-lg font-bold mb-2 mt-3 text-foreground\">{children}</h4>,\n    h5: ({ children }) => <h5 className=\"text-base font-bold mb-2 mt-3 text-foreground\">{children}</h5>,\n    h6: ({ children }) => <h6 className=\"text-sm font-bold mb-2 mt-3 text-foreground\">{children}</h6>,\n    p: ({ children }) => <p className=\"mb-4 leading-relaxed text-secondary\">{children}</p>,\n    a: ({ href, children }) => (\n        <a href={href} className=\"text-accent hover:underline\">\n            {children}\n        </a>\n    ),\n    ul: ({ children }) => <ul className=\"list-disc list-inside mb-4 space-y-1 text-secondary\">{children}</ul>,\n    ol: ({ children }) => <ol className=\"list-decimal list-inside mb-4 space-y-1 text-secondary\">{children}</ol>,\n    li: ({ children }) => <li className=\"ml-4\">{children}</li>,\n    code: ({ className, children }) => {\n        const isInline = !className;\n        if (isInline) {\n            return (\n                <code className=\"bg-panel text-foreground px-1 py-0.5 rounded text-sm font-mono\">\n                    {children}\n                </code>\n            );\n        }\n        return (\n            <code className={className}>\n                {children}\n            </code>\n        );\n    },\n    pre: ({ children }) => (\n        <pre className=\"bg-panel text-foreground p-4 rounded-lg overflow-x-auto mb-4 text-sm font-mono\">\n            {children}\n        </pre>\n    ),\n    blockquote: ({ children }) => (\n        <blockquote className=\"border-l-4 border-border pl-4 italic mb-4 text-muted\">\n            {children}\n        </blockquote>\n    ),\n    hr: () => <hr className=\"border-border my-6\" />,\n    table: ({ children }) => (\n        <div className=\"overflow-x-auto mb-4\">\n            <table className=\"min-w-full border-collapse border border-border\">\n                {children}\n            </table>\n        </div>\n    ),\n    th: ({ children }) => (\n        <th className=\"border border-border px-4 py-2 bg-panel font-bold text-left text-foreground\">\n            {children}\n        </th>\n    ),\n    td: ({ children }) => (\n        <td className=\"border border-border px-4 py-2 text-secondary\">\n            {children}\n        </td>\n    ),\n};\n\nexport function Markdown({ text, style, className, scrollable = true }: MarkdownProps) {\n    const scrollClasses = scrollable ? \"overflow-auto\" : \"\";\n    const baseClasses = \"prose prose-sm max-w-none\";\n    \n    return (\n        <div\n            className={twMerge(baseClasses, scrollClasses, className)}\n            style={style}\n        >\n            <ReactMarkdown components={markdownComponents}>\n                {text || ''}\n            </ReactMarkdown>\n        </div>\n    );\n}"
  },
  {
    "path": "tsunami/frontend/src/element/modals.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport { useEffect } from \"react\";\n\ninterface ModalProps {\n    config: ModalConfig;\n    onClose: (confirmed: boolean) => void;\n}\n\nexport function AlertModal({ config, onClose }: ModalProps) {\n    const handleOk = () => {\n        onClose(true);\n    };\n\n    // Handle escape key\n    useEffect(() => {\n        const handleEscape = (e: KeyboardEvent) => {\n            if (e.key === \"Escape\") {\n                onClose(false);\n            }\n        };\n        window.addEventListener(\"keydown\", handleEscape);\n        return () => window.removeEventListener(\"keydown\", handleEscape);\n    }, [onClose]);\n\n    return (\n        <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50\">\n            <div className=\"bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 p-6 border border-gray-700\">\n                <div className=\"flex flex-col gap-4\">\n                    <div className=\"flex items-center gap-3\">\n                        {config.icon && <div className=\"text-4xl\">{config.icon}</div>}\n                        <h2 className=\"text-xl font-semibold text-white\">{config.title}</h2>\n                    </div>\n                    {config.text && <p className=\"text-gray-300\">{config.text}</p>}\n                    <div className=\"flex justify-end gap-3 mt-2\">\n                        <button\n                            onClick={handleOk}\n                            className=\"px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500\"\n                        >\n                            {config.oktext || \"OK\"}\n                        </button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n}\n\nexport function ConfirmModal({ config, onClose }: ModalProps) {\n    const handleConfirm = () => {\n        onClose(true);\n    };\n\n    const handleCancel = () => {\n        onClose(false);\n    };\n\n    // Handle escape key\n    useEffect(() => {\n        const handleEscape = (e: KeyboardEvent) => {\n            if (e.key === \"Escape\") {\n                onClose(false);\n            }\n        };\n        window.addEventListener(\"keydown\", handleEscape);\n        return () => window.removeEventListener(\"keydown\", handleEscape);\n    }, [onClose]);\n\n    return (\n        <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50\">\n            <div className=\"bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 p-6 border border-gray-700\">\n                <div className=\"flex flex-col gap-4\">\n                    <div className=\"flex items-center gap-3\">\n                        {config.icon && <div className=\"text-4xl\">{config.icon}</div>}\n                        <h2 className=\"text-xl font-semibold text-white\">{config.title}</h2>\n                    </div>\n                    {config.text && <p className=\"text-gray-300\">{config.text}</p>}\n                    <div className=\"flex justify-end gap-3 mt-2\">\n                        <button\n                            onClick={handleCancel}\n                            className=\"px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500\"\n                        >\n                            {config.canceltext || \"Cancel\"}\n                        </button>\n                        <button\n                            onClick={handleConfirm}\n                            className=\"px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500\"\n                        >\n                            {config.oktext || \"OK\"}\n                        </button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "tsunami/frontend/src/element/tsunamiterm.tsx",
    "content": "import { FitAddon } from \"@xterm/addon-fit\";\nimport { Terminal } from \"@xterm/xterm\";\nimport \"@xterm/xterm/css/xterm.css\";\nimport * as React from \"react\";\n\nimport { base64ToArray } from \"@/util/base64\";\n\nexport type TsunamiTermElem = HTMLDivElement & {\n    __termWrite: (data64: string) => void;\n    __termFocus: () => void;\n    __termSize: () => VDomTermSize | null;\n};\n\ntype TsunamiTermProps = React.HTMLAttributes<HTMLDivElement> & {\n    onData?: (data: string | null, termsize: VDomTermSize | null) => void;\n    termFontSize?: number;\n    termFontFamily?: string;\n    termScrollback?: number;\n};\n\nconst TsunamiTerm = React.forwardRef<HTMLDivElement, TsunamiTermProps>(function TsunamiTerm(props, ref) {\n    const { onData, termFontSize, termFontFamily, termScrollback, ...outerProps } = props;\n    const outerRef = React.useRef<TsunamiTermElem>(null);\n    const termRef = React.useRef<HTMLDivElement>(null);\n    const terminalRef = React.useRef<Terminal | null>(null);\n    const onDataRef = React.useRef(onData);\n    onDataRef.current = onData;\n\n    const setOuterRef = React.useCallback(\n        (elem: TsunamiTermElem) => {\n            outerRef.current = elem;\n            if (elem != null) {\n                elem.__termWrite = (data64: string) => {\n                    if (data64 == null || data64 === \"\") {\n                        return;\n                    }\n                    try {\n                        terminalRef.current?.write(base64ToArray(data64));\n                    } catch (error) {\n                        console.error(\"Failed to write to terminal:\", error);\n                    }\n                };\n                elem.__termFocus = () => {\n                    terminalRef.current?.focus();\n                };\n                elem.__termSize = () => {\n                    const terminal = terminalRef.current;\n                    if (terminal == null) {\n                        return null;\n                    }\n                    return { rows: terminal.rows, cols: terminal.cols };\n                };\n            }\n            if (typeof ref === \"function\") {\n                ref(elem);\n                return;\n            }\n            if (ref != null) {\n                ref.current = elem;\n            }\n        },\n        [ref]\n    );\n\n    React.useEffect(() => {\n        if (termRef.current == null) {\n            return;\n        }\n        const terminal = new Terminal({\n            convertEol: false,\n            ...(termFontSize != null ? { fontSize: termFontSize } : {}),\n            ...(termFontFamily != null ? { fontFamily: termFontFamily } : {}),\n            ...(termScrollback != null ? { scrollback: termScrollback } : {}),\n        });\n        const fitAddon = new FitAddon();\n        terminal.loadAddon(fitAddon);\n        terminal.open(termRef.current);\n        fitAddon.fit();\n        terminalRef.current = terminal;\n\n        const onDataDisposable = terminal.onData((data) => {\n            if (onDataRef.current == null) {\n                return;\n            }\n            onDataRef.current(data, null);\n        });\n        const onResizeDisposable = terminal.onResize((size) => {\n            if (onDataRef.current == null) {\n                return;\n            }\n            onDataRef.current(null, { rows: size.rows, cols: size.cols });\n        });\n        if (onDataRef.current != null) {\n            onDataRef.current(null, { rows: terminal.rows, cols: terminal.cols });\n        }\n\n        const resizeObserver = new ResizeObserver(() => {\n            fitAddon.fit();\n        });\n        if (outerRef.current != null) {\n            resizeObserver.observe(outerRef.current);\n        }\n\n        return () => {\n            resizeObserver.disconnect();\n            onResizeDisposable.dispose();\n            onDataDisposable.dispose();\n            terminal.dispose();\n            terminalRef.current = null;\n        };\n    }, []);\n\n    React.useEffect(() => {\n        const terminal = terminalRef.current;\n        if (terminal == null) {\n            return;\n        }\n        if (termFontSize != null) {\n            terminal.options.fontSize = termFontSize;\n        }\n        if (termFontFamily != null) {\n            terminal.options.fontFamily = termFontFamily;\n        }\n        if (termScrollback != null) {\n            terminal.options.scrollback = termScrollback;\n        }\n    }, [termFontSize, termFontFamily, termScrollback]);\n\n    const handleFocus = React.useCallback(\n        (e: React.FocusEvent<HTMLDivElement>) => {\n            terminalRef.current?.focus();\n            outerProps.onFocus?.(e);\n        },\n        [outerProps.onFocus]\n    );\n\n    const handleBlur = React.useCallback(\n        (e: React.FocusEvent<HTMLDivElement>) => {\n            terminalRef.current?.blur();\n            outerProps.onBlur?.(e);\n        },\n        [outerProps.onBlur]\n    );\n\n    return (\n        <div\n            {...outerProps}\n            ref={setOuterRef as React.RefCallback<HTMLDivElement>}\n            onFocus={handleFocus}\n            onBlur={handleBlur}\n        >\n            <div ref={termRef} className=\"w-full h-full\" />\n        </div>\n    );\n});\n\nexport { TsunamiTerm };\n"
  },
  {
    "path": "tsunami/frontend/src/input.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport * as React from \"react\";\n\ntype Props = {\n    value?: string;\n    onChange?: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;\n    onInput?: (e: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>) => void;\n    ttlMs?: number; // default 100\n    ref?: React.Ref<HTMLInputElement | HTMLTextAreaElement>;\n    _tagName: \"input\" | \"textarea\";\n} & Omit<React.InputHTMLAttributes<HTMLInputElement> & React.TextareaHTMLAttributes<HTMLTextAreaElement>, \"value\" | \"onChange\" | \"onInput\">;\n\n/**\n * OptimisticInput - A React input component that provides optimistic UI updates for Tsunami's framework.\n *\n * Problem: In Tsunami's reactive framework, every onChange event is sent to the server, which can cause\n * the cursor to jump or typing to feel laggy as the server responds with updates.\n *\n * Solution: This component applies updates optimistically by maintaining a \"shadow\" value that shows\n * immediately in the UI while waiting for server acknowledgment. If the server responds with the same\n * value within the TTL period (default 100ms), the optimistic update is confirmed. If the server\n * doesn't respond or responds with a different value, the input reverts to the server value.\n *\n * Key behaviors:\n * - For controlled inputs (value provided): Uses optimistic updates with shadow state\n * - For uncontrolled inputs (value undefined): Behaves like a normal React input\n * - Skips optimistic logic when disabled or readonly\n * - Handles IME composition properly to avoid interfering with multi-byte character input\n * - Supports both onChange and onInput event handlers\n * - Preserves cursor position through React's natural behavior (no manual cursor management)\n *\n * Example usage:\n * ```tsx\n * <OptimisticInput\n *   value={serverValue}\n *   onChange={(e) => sendToServer(e.target.value)}\n *   ttlMs={200}\n * />\n * ```\n */\nfunction OptimisticInput({ value, onChange, onInput, ttlMs = 100, ref: forwardedRef, _tagName, ...rest }: Props) {\n    const [shadow, setShadow] = React.useState<string | null>(null);\n    const timer = React.useRef<number | undefined>(undefined);\n\n    const startTTL = React.useCallback(() => {\n        if (timer.current) clearTimeout(timer.current);\n        timer.current = window.setTimeout(() => {\n            // no ack within TTL → revert to server\n            setShadow(null);\n            // caret will follow serverValue; optionally restore selRef here if you track a server caret\n        }, ttlMs);\n    }, [ttlMs]);\n\n    const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {\n        // Skip validation during IME composition\n        // (works in modern browsers/React via nativeEvent)\n        // @ts-expect-error React typing doesn't surface this directly\n        if (e.nativeEvent?.isComposing) return;\n\n        // If uncontrolled (value is undefined), skip optimistic logic\n        if (value === undefined) {\n            onChange?.(e);\n            onInput?.(e);\n            return;\n        }\n\n        // Skip optimistic logic if readonly or disabled\n        if (rest.disabled || rest.readOnly) {\n            onChange?.(e);\n            onInput?.(e);\n            return;\n        }\n\n        const v = e.currentTarget.value;\n        setShadow(v); // optimistic echo\n        startTTL(); // wait for ack\n        onChange?.(e);\n        onInput?.(e);\n    };\n\n    // Ack: backend caught up → drop shadow (and stop the TTL)\n    React.useLayoutEffect(() => {\n        if (shadow !== null && shadow === value) {\n            setShadow(null);\n            if (timer.current) clearTimeout(timer.current);\n        }\n    }, [value, shadow]);\n\n    React.useEffect(\n        () => () => {\n            if (timer.current) clearTimeout(timer.current);\n        },\n        []\n    );\n\n    const realValue = value === undefined ? undefined : (shadow ?? value ?? \"\");\n    \n    if (_tagName === \"textarea\") {\n        return <textarea ref={forwardedRef as React.Ref<HTMLTextAreaElement>} value={realValue} onChange={handleChange} {...rest} />;\n    }\n    \n    return <input ref={forwardedRef as React.Ref<HTMLInputElement>} value={realValue} onChange={handleChange} {...rest} />;\n}\n\nexport default OptimisticInput;\n"
  },
  {
    "path": "tsunami/frontend/src/main.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport App from \"./app\";\nimport \"./tailwind.css\";\n\nReactDOM.createRoot(document.getElementById(\"root\")!).render(\n    <React.StrictMode>\n        <App />\n    </React.StrictMode>\n);\n"
  },
  {
    "path": "tsunami/frontend/src/model/model-utils.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport type { TsunamiTermElem } from \"@/element/tsunamiterm\";\n\nconst TextTag = \"#text\";\n\n// TODO support binding\nexport function getTextChildren(elem: VDomElem): string {\n    if (elem.tag == TextTag) {\n        return elem.text;\n    }\n    if (!elem.children) {\n        return null;\n    }\n    const textArr = elem.children.map((child) => {\n        return getTextChildren(child);\n    });\n    return textArr.join(\"\");\n}\n\nexport function restoreVDomElems(backendUpdate: VDomBackendUpdate) {\n    if (!backendUpdate.transferelems || !backendUpdate.renderupdates) {\n        return;\n    }\n\n    // Step 1: Create text map from transfertext\n    const textMap = new Map<number, string>();\n    if (backendUpdate.transfertext) {\n        backendUpdate.transfertext.forEach((textEntry) => {\n            textMap.set(textEntry.id, textEntry.text);\n        });\n    }\n\n    // Step 2: Map of waveid to VDomElem, skipping any without a waveid\n    const elemMap = new Map<string, VDomElem>();\n    backendUpdate.transferelems.forEach((transferElem) => {\n        if (!transferElem.waveid) {\n            return;\n        }\n        elemMap.set(transferElem.waveid, {\n            waveid: transferElem.waveid,\n            tag: transferElem.tag,\n            props: transferElem.props,\n            children: [], // Will populate children later\n            text: transferElem.text,\n        });\n    });\n\n    // Step 3: Build VDomElem trees by linking children\n    backendUpdate.transferelems.forEach((transferElem) => {\n        const parent = elemMap.get(transferElem.waveid);\n        if (!parent || !transferElem.children || transferElem.children.length === 0) {\n            return;\n        }\n        parent.children = transferElem.children\n            .map((childId) => {\n                // Check if this is a text reference\n                if (childId.startsWith(\"t:\")) {\n                    const textId = parseInt(childId.slice(2));\n                    const textContent = textMap.get(textId);\n                    if (textContent != null) {\n                        return {\n                            tag: TextTag,\n                            text: textContent,\n                        };\n                    }\n                    return null;\n                }\n                // Regular element reference\n                return elemMap.get(childId);\n            })\n            .filter((child) => child != null); // Explicit null check\n    });\n\n    // Step 4: Update renderupdates with rebuilt VDomElem trees\n    backendUpdate.renderupdates.forEach((update) => {\n        if (update.vdomwaveid) {\n            update.vdom = elemMap.get(update.vdomwaveid);\n        }\n    });\n}\n\nexport function isTsunamiTermElem(elem: HTMLElement): elem is TsunamiTermElem {\n    return elem != null && typeof (elem as TsunamiTermElem).__termWrite === \"function\";\n}\n\nexport function applyTermOp(elem: TsunamiTermElem, termOp: VDomRefOperation) {\n    const { op, params } = termOp;\n    if (op === \"termwrite\") {\n        const data64 = params?.[0];\n        if (typeof data64 === \"string\" && data64 !== \"\") {\n            elem.__termWrite(data64);\n        }\n    } else if (op === \"focus\") {\n        elem.__termFocus();\n    }\n}\n\nexport function applyCanvasOp(canvas: HTMLCanvasElement, canvasOp: VDomRefOperation, refStore: Map<string, any>) {\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) {\n        console.error(\"Canvas 2D context not available.\");\n        return;\n    }\n\n    let { op, params, outputref } = canvasOp;\n    if (params == null) {\n        params = [];\n    }\n    if (op == null || op == \"\") {\n        return;\n    }\n    // Resolve any reference parameters in params\n    const resolvedParams: any[] = [];\n    params.forEach((param) => {\n        if (typeof param === \"string\" && param.startsWith(\"#ref:\")) {\n            const refId = param.slice(5); // Remove \"#ref:\" prefix\n            resolvedParams.push(refStore.get(refId));\n        } else if (typeof param === \"string\" && param.startsWith(\"#spreadRef:\")) {\n            const refId = param.slice(11); // Remove \"#spreadRef:\" prefix\n            const arrayRef = refStore.get(refId);\n            if (Array.isArray(arrayRef)) {\n                resolvedParams.push(...arrayRef); // Spread array elements\n            } else {\n                console.error(`Reference ${refId} is not an array and cannot be spread.`);\n            }\n        } else {\n            resolvedParams.push(param);\n        }\n    });\n\n    // Apply the operation on the canvas context\n    if (op === \"dropRef\" && params.length > 0 && typeof params[0] === \"string\") {\n        refStore.delete(params[0]);\n    } else if (op === \"addRef\" && outputref) {\n        refStore.set(outputref, resolvedParams[0]);\n    } else if (typeof ctx[op as keyof CanvasRenderingContext2D] === \"function\") {\n        (ctx[op as keyof CanvasRenderingContext2D] as (...args: unknown[]) => unknown).apply(ctx, resolvedParams);\n    } else if (op in ctx) {\n        (ctx as any)[op] = resolvedParams[0];\n    } else {\n        console.error(`Unsupported canvas operation: ${op}`);\n    }\n}\n"
  },
  {
    "path": "tsunami/frontend/src/model/tsunami-model.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport debug from \"debug\";\nimport * as jotai from \"jotai\";\n\nimport { arrayBufferToBase64 } from \"@/util/base64\";\nimport { getOrCreateClientId } from \"@/util/clientid\";\nimport { adaptFromReactOrNativeKeyEvent } from \"@/util/keyutil\";\nimport { PLATFORM, PlatformMacOS } from \"@/util/platformutil\";\nimport { getDefaultStore } from \"jotai\";\nimport { applyCanvasOp, applyTermOp, isTsunamiTermElem, restoreVDomElems } from \"./model-utils\";\n\nconst dlog = debug(\"wave:vdom\");\n\ntype RefContainer = {\n    refFn: (elem: HTMLElement) => void;\n    vdomRef: VDomRef;\n    elem: HTMLElement;\n    updated: boolean;\n};\n\nfunction makeVDomIdMap(vdom: VDomElem, idMap: Map<string, VDomElem>) {\n    if (vdom == null) {\n        return;\n    }\n    if (vdom.waveid != null) {\n        idMap.set(vdom.waveid, vdom);\n    }\n    if (vdom.children == null) {\n        return;\n    }\n    for (let child of vdom.children) {\n        makeVDomIdMap(child, idMap);\n    }\n}\n\nfunction isBlank(v: string): boolean {\n    return v == null || v === \"\";\n}\n\nasync function fileToVDomFileData(file: File, fieldname: string): Promise<VDomFileData> {\n    const maxSize = 5 * 1024 * 1024;\n    if (file.size > maxSize) {\n        return {\n            fieldname: fieldname,\n            name: file.name,\n            size: file.size,\n            type: file.type,\n            error: \"File size exceeds 5MB limit\",\n        };\n    }\n    const buffer = await file.arrayBuffer();\n    const data64 = arrayBufferToBase64(buffer);\n    return {\n        fieldname: fieldname,\n        name: file.name,\n        size: file.size,\n        type: file.type,\n        data64: data64,\n    };\n}\n\nfunction annotateEvent(event: VDomEvent, propName: string, reactEvent: React.SyntheticEvent) {\n    if (reactEvent == null) {\n        return;\n    }\n    if (propName == \"onChange\") {\n        const changeEvent = reactEvent as React.ChangeEvent<any>;\n        event.targetvalue = changeEvent.target?.value;\n        event.targetchecked = changeEvent.target?.checked;\n    }\n    if (propName == \"onClick\" || propName == \"onMouseDown\" || propName == \"onMouseUp\" || propName == \"onDoubleClick\") {\n        const mouseEvent = reactEvent as React.MouseEvent<any>;\n        event.mousedata = {\n            button: mouseEvent.button,\n            buttons: mouseEvent.buttons,\n            alt: mouseEvent.altKey,\n            control: mouseEvent.ctrlKey,\n            shift: mouseEvent.shiftKey,\n            meta: mouseEvent.metaKey,\n            clientx: mouseEvent.clientX,\n            clienty: mouseEvent.clientY,\n            pagex: mouseEvent.pageX,\n            pagey: mouseEvent.pageY,\n            screenx: mouseEvent.screenX,\n            screeny: mouseEvent.screenY,\n            movementx: mouseEvent.movementX,\n            movementy: mouseEvent.movementY,\n        };\n        if (PLATFORM == PlatformMacOS) {\n            event.mousedata.cmd = event.mousedata.meta;\n            event.mousedata.option = event.mousedata.alt;\n        } else {\n            event.mousedata.cmd = event.mousedata.alt;\n            event.mousedata.option = event.mousedata.meta;\n        }\n    }\n    if (propName == \"onKeyDown\") {\n        const waveKeyEvent = adaptFromReactOrNativeKeyEvent(reactEvent as React.KeyboardEvent);\n        event.keydata = waveKeyEvent;\n    }\n}\n\nasync function asyncAnnotateEvent(event: VDomEvent, propName: string, reactEvent: React.SyntheticEvent) {\n    if (propName == \"onSubmit\") {\n        const formEvent = reactEvent as React.FormEvent<HTMLFormElement>;\n        const form = formEvent.currentTarget;\n\n        event.targetname = form.name;\n        event.targetid = form.id;\n\n        const formData: VDomFormData = {\n            method: (form.method || \"get\").toUpperCase(),\n            enctype: form.enctype || \"application/x-www-form-urlencoded\",\n            fields: {},\n            files: {},\n        };\n\n        if (form.action) {\n            formData.action = form.action;\n        }\n        if (form.id) {\n            formData.formid = form.id;\n        }\n        if (form.name) {\n            formData.formname = form.name;\n        }\n\n        const formDataObj = new FormData(form);\n\n        for (const [key, value] of formDataObj.entries()) {\n            if (value instanceof File) {\n                if (!value.name && value.size === 0) {\n                    continue;\n                }\n                if (!formData.files[key]) {\n                    formData.files[key] = [];\n                }\n                formData.files[key].push(await fileToVDomFileData(value, key));\n            } else {\n                if (!formData.fields[key]) {\n                    formData.fields[key] = [];\n                }\n                formData.fields[key].push(value.toString());\n            }\n        }\n\n        event.formdata = formData;\n    }\n    if (propName == \"onChange\") {\n        const changeEvent = reactEvent as React.ChangeEvent<HTMLInputElement>;\n        if (changeEvent.target?.type === \"file\" && changeEvent.target.files) {\n            event.targetname = changeEvent.target.name;\n            event.targetid = changeEvent.target.id;\n\n            const files: VDomFileData[] = [];\n            const fieldname = changeEvent.target.name || changeEvent.target.id || \"file\";\n            for (let i = 0; i < changeEvent.target.files.length; i++) {\n                const file = changeEvent.target.files[i];\n                files.push(await fileToVDomFileData(file, fieldname));\n            }\n            event.targetfiles = files;\n        }\n    }\n}\n\nexport class TsunamiModel {\n    clientId: string;\n    serverId: string;\n    viewRef: React.RefObject<HTMLDivElement> = { current: null };\n    vdomRoot: jotai.PrimitiveAtom<VDomElem> = jotai.atom();\n    refs: Map<string, RefContainer> = new Map(); // key is refid\n    batchedEvents: VDomEvent[] = [];\n    messages: VDomMessage[] = [];\n    needsResync: boolean = true;\n    vdomNodeVersion: WeakMap<VDomElem, jotai.PrimitiveAtom<number>> = new WeakMap();\n    rootRefId: string = crypto.randomUUID();\n    backendOpts: VDomBackendOpts;\n    shouldDispose: boolean;\n    disposed: boolean;\n    hasPendingRequest: boolean;\n    needsUpdate: boolean;\n    maxNormalUpdateIntervalMs: number = 100;\n    needsImmediateUpdate: boolean;\n    lastUpdateTs: number = 0;\n    queuedUpdate: { timeoutId: any; ts: number; quick: boolean };\n    contextActive: jotai.PrimitiveAtom<boolean>;\n    serverEventSource: EventSource;\n    refOutputStore: Map<string, any> = new Map();\n    globalVersion: jotai.PrimitiveAtom<number> = jotai.atom(0);\n    hasBackendWork: boolean = false;\n    noPadding: jotai.PrimitiveAtom<boolean>;\n    cachedFaviconPath: string | null = null;\n    cachedTitle: string | null = null;\n    cachedShortDesc: string | null = null;\n    reason: string | null = null;\n    currentModal: jotai.PrimitiveAtom<ModalConfig | null> = jotai.atom(null) as jotai.PrimitiveAtom<ModalConfig | null>;\n\n    constructor() {\n        this.clientId = getOrCreateClientId();\n        this.contextActive = jotai.atom(false);\n        this.reset();\n        this.noPadding = jotai.atom(true);\n        this.setupServerEventSource();\n        this.queueUpdate(true, \"initial\");\n    }\n\n    dispose() {\n        if (this.serverEventSource) {\n            this.serverEventSource.close();\n            this.serverEventSource = null;\n        }\n    }\n\n    setupServerEventSource() {\n        if (this.serverEventSource) {\n            this.serverEventSource.close();\n        }\n\n        const url = `/api/updates?clientId=${encodeURIComponent(this.clientId)}`;\n        this.serverEventSource = new EventSource(url);\n\n        this.serverEventSource.addEventListener(\"asyncinitiation\", (event) => {\n            dlog(\"async-initiation SSE event received\", event);\n            this.queueUpdate(true, \"asyncinitiation\");\n        });\n\n        this.serverEventSource.addEventListener(\"showmodal\", (event: MessageEvent) => {\n            dlog(\"showmodal SSE event received\", event);\n            try {\n                const config: ModalConfig = JSON.parse(event.data);\n                getDefaultStore().set(this.currentModal, config);\n            } catch (e) {\n                console.error(\"Failed to parse modal config:\", e);\n            }\n        });\n\n        this.serverEventSource.addEventListener(\"termwrite\", (event: MessageEvent) => {\n            try {\n                const packet = JSON.parse(event.data);\n                if (packet?.refid == null || packet?.data64 == null) {\n                    return;\n                }\n                const refOp: VDomRefOperation = { refid: packet.refid, op: \"termwrite\", params: [packet.data64] };\n                const elem = this.getRefElem(refOp.refid);\n                if (elem == null) {\n                    return;\n                }\n                if (isTsunamiTermElem(elem)) {\n                    applyTermOp(elem, refOp);\n                }\n            } catch (e) {\n                console.error(\"Failed to parse termwrite event:\", e);\n            }\n        });\n\n        this.serverEventSource.addEventListener(\"error\", (event) => {\n            console.error(\"SSE connection error:\", event);\n        });\n\n        this.serverEventSource.addEventListener(\"open\", (event) => {\n            dlog(\"SSE connection opened\", event);\n        });\n    }\n\n    reset() {\n        if (this.serverEventSource) {\n            this.serverEventSource.close();\n            this.serverEventSource = null;\n        }\n        getDefaultStore().set(this.vdomRoot, null);\n        this.refs.clear();\n        this.batchedEvents = [];\n        this.messages = [];\n        this.needsResync = true;\n        this.vdomNodeVersion = new WeakMap();\n        this.rootRefId = crypto.randomUUID();\n        this.backendOpts = {};\n        this.shouldDispose = false;\n        this.disposed = false;\n        this.hasPendingRequest = false;\n        this.needsUpdate = false;\n        this.maxNormalUpdateIntervalMs = 100;\n        this.needsImmediateUpdate = false;\n        this.lastUpdateTs = 0;\n        this.queuedUpdate = null;\n        this.refOutputStore.clear();\n        this.globalVersion = jotai.atom(0);\n        this.hasBackendWork = false;\n        this.reason = null;\n        this.cachedTitle = null;\n        this.cachedShortDesc = null;\n        getDefaultStore().set(this.contextActive, false);\n    }\n\n    keyDownHandler(e: VDomKeyboardEvent): boolean {\n        if (!this.backendOpts?.globalkeyboardevents) {\n            return false;\n        }\n        if (e.cmd || e.meta) {\n            return false;\n        }\n        this.batchedEvents.push({\n            globaleventtype: \"onKeyDown\",\n            waveid: null,\n            eventtype: \"onKeyDown\",\n            keydata: e,\n        });\n        this.queueUpdate(false, \"globalkeyboard\");\n        return true;\n    }\n\n    hasRefUpdates() {\n        for (let ref of this.refs.values()) {\n            if (ref.updated) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    getRefUpdates(): VDomRefUpdate[] {\n        let updates: VDomRefUpdate[] = [];\n        for (let ref of this.refs.values()) {\n            if (ref.updated || (ref.vdomRef.trackposition && ref.elem != null)) {\n                const ru: VDomRefUpdate = {\n                    refid: ref.vdomRef.refid,\n                    hascurrent: ref.vdomRef.hascurrent,\n                };\n                if (ref.vdomRef.trackposition && ref.elem != null) {\n                    ru.position = {\n                        offsetheight: ref.elem.offsetHeight,\n                        offsetwidth: ref.elem.offsetWidth,\n                        scrollheight: ref.elem.scrollHeight,\n                        scrollwidth: ref.elem.scrollWidth,\n                        scrolltop: ref.elem.scrollTop,\n                        boundingclientrect: ref.elem.getBoundingClientRect(),\n                    };\n                }\n                if (isTsunamiTermElem(ref.elem)) {\n                    const termsize = ref.elem.__termSize();\n                    if (termsize != null) {\n                        ru.termsize = termsize;\n                    }\n                }\n                updates.push(ru);\n                ref.updated = false;\n            }\n        }\n        return updates;\n    }\n\n    mergeReasons(newReason: string): string {\n        if (!this.reason) {\n            return newReason;\n        }\n        const existingReasons = this.reason.split(\",\");\n        const newReasons = newReason.split(\",\");\n        for (const reason of newReasons) {\n            if (!existingReasons.includes(reason)) {\n                existingReasons.push(reason);\n            }\n        }\n        return existingReasons.join(\",\");\n    }\n\n    queueUpdate(quick: boolean = false, reason: string | null) {\n        if (this.disposed) {\n            return;\n        }\n        if (reason) {\n            this.reason = this.mergeReasons(reason);\n        }\n        this.needsUpdate = true;\n        let delay = 10;\n        let nowTs = Date.now();\n        if (delay > this.maxNormalUpdateIntervalMs) {\n            delay = this.maxNormalUpdateIntervalMs;\n        }\n        if (quick) {\n            if (this.queuedUpdate) {\n                if (this.queuedUpdate.quick || this.queuedUpdate.ts <= nowTs) {\n                    return;\n                }\n                clearTimeout(this.queuedUpdate.timeoutId);\n                this.queuedUpdate = null;\n            }\n            let timeoutId = setTimeout(() => {\n                this._sendRenderRequest(true);\n            }, 0);\n            this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs, quick: true };\n            return;\n        }\n        if (this.queuedUpdate) {\n            return;\n        }\n        let lastUpdateDiff = nowTs - this.lastUpdateTs;\n        let timeoutMs: number = null;\n        if (lastUpdateDiff >= this.maxNormalUpdateIntervalMs) {\n            // it has been a while since the last update, so use delay\n            timeoutMs = delay;\n        } else {\n            timeoutMs = this.maxNormalUpdateIntervalMs - lastUpdateDiff;\n        }\n        if (timeoutMs < delay) {\n            timeoutMs = delay;\n        }\n        let timeoutId = setTimeout(() => {\n            this._sendRenderRequest(false);\n        }, timeoutMs);\n        this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs + timeoutMs, quick: false };\n    }\n\n    async _sendRenderRequest(force: boolean) {\n        this.queuedUpdate = null;\n        if (this.disposed) {\n            return;\n        }\n        if (this.hasPendingRequest) {\n            if (force) {\n                this.needsImmediateUpdate = true;\n            }\n            return;\n        }\n        if (!force && !this.needsUpdate) {\n            return;\n        }\n        this.hasPendingRequest = true;\n        this.needsImmediateUpdate = false;\n        try {\n            const feUpdate = this.createFeUpdate();\n            dlog(\"fe-update\", feUpdate);\n\n            const response = await fetch(\"/api/render\", {\n                method: \"POST\",\n                headers: {\n                    \"Content-Type\": \"application/json\",\n                },\n                body: JSON.stringify(feUpdate),\n            });\n\n            if (!response.ok) {\n                throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n            }\n\n            // Check if EventSource connection is closed and reconnect if needed\n            if (this.serverEventSource && this.serverEventSource.readyState === EventSource.CLOSED) {\n                dlog(\"EventSource connection closed, reconnecting\");\n                this.setupServerEventSource();\n            }\n\n            const backendUpdate: VDomBackendUpdate = await response.json();\n            if (backendUpdate !== null) {\n                restoreVDomElems(backendUpdate);\n                dlog(\"be-update\", backendUpdate);\n                this.handleBackendUpdate(backendUpdate);\n            }\n            dlog(\"update cycle done\");\n        } finally {\n            this.lastUpdateTs = Date.now();\n            this.hasPendingRequest = false;\n        }\n        if (this.needsImmediateUpdate) {\n            this.queueUpdate(true, null); // reason should already be set, dont try to add a new one\n        }\n    }\n\n    getOrCreateRefContainer(vdomRef: VDomRef): RefContainer {\n        let container = this.refs.get(vdomRef.refid);\n        if (container == null) {\n            container = {\n                refFn: (elem: HTMLElement) => {\n                    container.elem = elem;\n                    const hasElem = elem != null;\n                    if (vdomRef.hascurrent != hasElem) {\n                        container.updated = true;\n                        vdomRef.hascurrent = hasElem;\n                    }\n                },\n                vdomRef: vdomRef,\n                elem: null,\n                updated: false,\n            };\n            this.refs.set(vdomRef.refid, container);\n        }\n        return container;\n    }\n\n    getVDomNodeVersionAtom(vdom: VDomElem) {\n        let atom = this.vdomNodeVersion.get(vdom);\n        if (atom == null) {\n            atom = jotai.atom(0);\n            this.vdomNodeVersion.set(vdom, atom);\n        }\n        return atom;\n    }\n\n    incVDomNodeVersion(vdom: VDomElem) {\n        if (vdom == null) {\n            return;\n        }\n        const atom = this.getVDomNodeVersionAtom(vdom);\n        getDefaultStore().set(atom, getDefaultStore().get(atom) + 1);\n    }\n\n    addErrorMessage(message: string) {\n        this.messages.push({\n            messagetype: \"error\",\n            message: message,\n        });\n    }\n\n    logTsunamiMeta(opts: any) {\n        let hasChanges = false;\n        const logObj: { title?: string; shortdesc?: string } = {};\n\n        if (!isBlank(opts.title)) {\n            if (opts.title !== this.cachedTitle) {\n                hasChanges = true;\n                this.cachedTitle = opts.title;\n            }\n            logObj.title = opts.title;\n        }\n\n        if (!isBlank(opts.shortdesc)) {\n            if (opts.shortdesc !== this.cachedShortDesc) {\n                hasChanges = true;\n                this.cachedShortDesc = opts.shortdesc;\n            }\n            logObj.shortdesc = opts.shortdesc;\n        }\n\n        if (!hasChanges) {\n            return;\n        }\n\n        console.log(\"TSUNAMI_META \" + JSON.stringify(logObj));\n    }\n\n    handleRenderUpdates(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) {\n        if (!update.renderupdates) {\n            return;\n        }\n        for (let renderUpdate of update.renderupdates) {\n            if (renderUpdate.updatetype == \"root\") {\n                getDefaultStore().set(this.vdomRoot, renderUpdate.vdom);\n                continue;\n            }\n            if (renderUpdate.updatetype == \"append\") {\n                let parent = idMap.get(renderUpdate.waveid);\n                if (parent == null) {\n                    this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);\n                    continue;\n                }\n                if (parent.children == null) {\n                    parent.children = [];\n                }\n                parent.children.push(renderUpdate.vdom);\n                this.incVDomNodeVersion(parent);\n                continue;\n            }\n            if (renderUpdate.updatetype == \"replace\") {\n                let parent = idMap.get(renderUpdate.waveid);\n                if (parent == null) {\n                    this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);\n                    continue;\n                }\n                if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) {\n                    this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`);\n                    continue;\n                }\n                parent.children[renderUpdate.index] = renderUpdate.vdom;\n                this.incVDomNodeVersion(parent);\n                continue;\n            }\n            if (renderUpdate.updatetype == \"remove\") {\n                let parent = idMap.get(renderUpdate.waveid);\n                if (parent == null) {\n                    this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);\n                    continue;\n                }\n                if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) {\n                    this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`);\n                    continue;\n                }\n                parent.children.splice(renderUpdate.index, 1);\n                this.incVDomNodeVersion(parent);\n                continue;\n            }\n            if (renderUpdate.updatetype == \"insert\") {\n                let parent = idMap.get(renderUpdate.waveid);\n                if (parent == null) {\n                    this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`);\n                    continue;\n                }\n                if (parent.children == null) {\n                    parent.children = [];\n                }\n                if (renderUpdate.index < 0 || parent.children.length < renderUpdate.index) {\n                    this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`);\n                    continue;\n                }\n                parent.children.splice(renderUpdate.index, 0, renderUpdate.vdom);\n                this.incVDomNodeVersion(parent);\n                continue;\n            }\n            this.addErrorMessage(`Unknown updatetype ${renderUpdate.updatetype}`);\n        }\n    }\n\n    getRefElem(refId: string): HTMLElement {\n        if (refId == this.rootRefId) {\n            return this.viewRef.current;\n        }\n        const ref = this.refs.get(refId);\n        return ref?.elem;\n    }\n\n    handleRefOperations(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) {\n        if (update.refoperations == null) {\n            return;\n        }\n        for (let refOp of update.refoperations) {\n            const elem = this.getRefElem(refOp.refid);\n            if (elem == null) {\n                this.addErrorMessage(`Could not find ref with id ${refOp.refid}`);\n                continue;\n            }\n            if (elem instanceof HTMLCanvasElement) {\n                applyCanvasOp(elem, refOp, this.refOutputStore);\n                continue;\n            }\n            if (isTsunamiTermElem(elem)) {\n                applyTermOp(elem, refOp);\n                continue;\n            }\n            if (refOp.op == \"focus\") {\n                if (elem == null) {\n                    this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`);\n                    continue;\n                }\n                try {\n                    elem.focus();\n                } catch (e) {\n                    this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: ${e.message}`);\n                }\n            } else {\n                this.addErrorMessage(`Unknown ref operation ${refOp.refid} ${refOp.op}`);\n            }\n        }\n    }\n\n    updateFavicon(faviconPath: string | null) {\n        if (faviconPath === this.cachedFaviconPath) {\n            return;\n        }\n\n        this.cachedFaviconPath = faviconPath;\n\n        let existingFavicon = document.querySelector('link[rel=\"icon\"]') as HTMLLinkElement;\n\n        if (faviconPath) {\n            if (existingFavicon) {\n                existingFavicon.href = faviconPath;\n            } else {\n                const link = document.createElement(\"link\");\n                link.rel = \"icon\";\n                link.href = faviconPath;\n                document.head.appendChild(link);\n            }\n        } else {\n            if (existingFavicon) {\n                existingFavicon.remove();\n            }\n        }\n    }\n\n    handleBackendUpdate(update: VDomBackendUpdate) {\n        if (update == null) {\n            return;\n        }\n\n        // Check if serverId is changing and reset if needed\n        if (this.serverId != null && this.serverId !== update.serverid) {\n            // Server ID changed - reset the model state\n            this.reset();\n            this.setupServerEventSource();\n        }\n\n        this.serverId = update.serverid;\n        getDefaultStore().set(this.contextActive, true);\n        const idMap = new Map<string, VDomElem>();\n        const vdomRoot = getDefaultStore().get(this.vdomRoot);\n        if (update.opts != null) {\n            this.backendOpts = update.opts;\n            if (update.opts.title && update.opts.title.trim() !== \"\") {\n                document.title = update.opts.title;\n            }\n            if (update.opts.faviconpath !== undefined) {\n                this.updateFavicon(update.opts.faviconpath);\n            }\n            this.logTsunamiMeta(update.opts);\n        }\n        makeVDomIdMap(vdomRoot, idMap);\n        this.handleRenderUpdates(update, idMap);\n        this.handleRefOperations(update, idMap);\n        if (update.messages) {\n            for (let message of update.messages) {\n                console.log(\"vdom-message\", message.messagetype, message.message);\n                if (message.stacktrace) {\n                    console.log(\"vdom-message-stacktrace\", message.stacktrace);\n                }\n            }\n        }\n        getDefaultStore().set(this.globalVersion, getDefaultStore().get(this.globalVersion) + 1);\n        if (update.haswork) {\n            this.hasBackendWork = true;\n        }\n    }\n\n    renderDone(version: number) {\n        // called when the render is done\n        dlog(\"renderDone\", version);\n        let reasons: string[] = [];\n        let needsQueue = false;\n        if (this.hasRefUpdates()) {\n            reasons.push(\"refupdates\");\n            needsQueue = true;\n        }\n        if (this.hasBackendWork) {\n            reasons.push(\"backendwork\");\n            needsQueue = true;\n            this.hasBackendWork = false;\n        }\n        if (needsQueue) {\n            this.queueUpdate(true, reasons.join(\",\"));\n        }\n    }\n\n    callVDomFunc(fnDecl: VDomFunc, e: React.SyntheticEvent, compId: string, propName: string) {\n        const vdomEvent: VDomEvent = {\n            waveid: compId,\n            eventtype: propName,\n        };\n        if (fnDecl.globalevent) {\n            vdomEvent.globaleventtype = fnDecl.globalevent;\n        }\n        const needsAsync =\n            propName == \"onSubmit\" || (propName == \"onChange\" && (e.target as HTMLInputElement)?.type === \"file\");\n        if (needsAsync) {\n            asyncAnnotateEvent(vdomEvent, propName, e)\n                .then(() => {\n                    this.batchedEvents.push(vdomEvent);\n                    this.queueUpdate(true, \"event\");\n                })\n                .catch((err) => {\n                    console.error(\"Error processing event:\", err);\n                });\n        } else {\n            annotateEvent(vdomEvent, propName, e);\n            this.batchedEvents.push(vdomEvent);\n            this.queueUpdate(true, \"event\");\n        }\n    }\n\n    createFeUpdate(): VDomFrontendUpdate {\n        const isFocused = document.hasFocus();\n        const renderContext: VDomRenderContext = {\n            focused: isFocused,\n            width: this.viewRef?.current?.offsetWidth ?? 0,\n            height: this.viewRef?.current?.offsetHeight ?? 0,\n            rootrefid: this.rootRefId,\n            background: false,\n        };\n        const feUpdate: VDomFrontendUpdate = {\n            type: \"frontendupdate\",\n            ts: Date.now(),\n            clientid: this.clientId,\n            rendercontext: renderContext,\n            dispose: this.shouldDispose,\n            resync: this.needsResync,\n            events: this.batchedEvents,\n            refupdates: this.getRefUpdates(),\n            reason: this.reason,\n        };\n        this.needsResync = false;\n        this.batchedEvents = [];\n        this.reason = null;\n        if (this.shouldDispose) {\n            this.disposed = true;\n        }\n        return feUpdate;\n    }\n\n    async sendModalResult(modalId: string, confirm: boolean) {\n        const result: ModalResult = {\n            modalid: modalId,\n            confirm: confirm,\n        };\n\n        try {\n            const response = await fetch(\"/api/modalresult\", {\n                method: \"POST\",\n                headers: {\n                    \"Content-Type\": \"application/json\",\n                },\n                body: JSON.stringify(result),\n            });\n\n            if (!response.ok) {\n                console.error(\"Failed to send modal result:\", response.statusText);\n            }\n        } catch (error) {\n            console.error(\"Error sending modal result:\", error);\n        }\n\n        // Clear the current modal\n        getDefaultStore().set(this.currentModal, null);\n    }\n}\n"
  },
  {
    "path": "tsunami/frontend/src/recharts/recharts.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport * as React from \"react\";\nimport {\n    LineChart,\n    AreaChart,\n    BarChart,\n    PieChart,\n    ScatterChart,\n    RadarChart,\n    ComposedChart,\n    CartesianGrid,\n    XAxis,\n    YAxis,\n    ZAxis,\n    Tooltip,\n    Legend,\n    Line,\n    Area,\n    Bar,\n    Pie,\n    Cell,\n    Scatter,\n    Radar,\n    PolarGrid,\n    PolarAngleAxis,\n    PolarRadiusAxis,\n    ResponsiveContainer,\n    ReferenceLine,\n    ReferenceArea,\n    ReferenceDot,\n    Brush,\n    ErrorBar,\n    LabelList,\n    FunnelChart,\n    Funnel,\n    Treemap,\n} from \"recharts\";\n\nimport type { TsunamiModel } from \"@/model/tsunami-model\";\nimport { convertElemToTag } from \"@/vdom\";\n\ntype VDomRechartsTagType = (props: { elem: VDomElem; model: TsunamiModel }) => React.ReactElement;\n\n// Map recharts component names to their actual components\nconst RechartsComponentMap: Record<string, React.ComponentType<any>> = {\n    LineChart,\n    AreaChart,\n    BarChart,\n    PieChart,\n    ScatterChart,\n    RadarChart,\n    ComposedChart,\n    CartesianGrid,\n    XAxis,\n    YAxis,\n    ZAxis,\n    Tooltip,\n    Legend,\n    Line,\n    Area,\n    Bar,\n    Pie,\n    Cell,\n    Scatter,\n    Radar,\n    PolarGrid,\n    PolarAngleAxis,\n    PolarRadiusAxis,\n    ResponsiveContainer,\n    ReferenceLine,\n    ReferenceArea,\n    ReferenceDot,\n    Brush,\n    ErrorBar,\n    LabelList,\n    FunnelChart,\n    Funnel,\n    Treemap,\n};\n\n// Handler for recharts components - uses the same pattern as VDomTag from vdom.tsx\nfunction RechartsTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) {\n    // Convert props\n    const props = convertRechartsProps(model, elem);\n    \n    // Extract the component name from the tag (remove \"recharts:\" prefix)\n    const componentName = elem.tag.replace(\"recharts:\", \"\");\n    \n    // Get the React component from the map\n    const RechartsComponent = RechartsComponentMap[componentName];\n    \n    if (!RechartsComponent) {\n        return <div>{\"Invalid Recharts Component <\" + elem.tag + \">\"}</div>;\n    }\n    \n    const children = convertRechartsChildren(elem, model);\n    \n    // Add the waveid as key\n    props.key = \"recharts-\" + elem.waveid;\n    \n    return React.createElement(RechartsComponent, props, children);\n}\n\n// Simplified version of useVDom for recharts - handles basic prop conversion\nfunction convertRechartsProps(model: TsunamiModel, elem: VDomElem): any {\n    // For now, do a basic prop conversion without full binding support\n    // This can be enhanced later to use the full useVDom functionality\n    if (!elem.props) {\n        return {};\n    }\n    \n    const props: any = {};\n    for (const [key, value] of Object.entries(elem.props)) {\n        if (value != null) {\n            props[key] = value;\n        }\n    }\n    \n    return props;\n}\n\n// Convert children for recharts components - return literal Recharts components\nfunction convertRechartsChildren(elem: VDomElem, model: TsunamiModel): React.ReactNode[] | null {\n    if (!elem.children || elem.children.length === 0) {\n        return null;\n    }\n    \n    const children: React.ReactNode[] = [];\n    \n    for (const child of elem.children) {\n        if (!child) continue;\n        \n        if (child.tag === \"#text\") {\n            // Allow text nodes (rare but valid)\n            children.push(child.text ?? \"\");\n            continue;\n        }\n        \n        if (child.tag?.startsWith(\"recharts:\")) {\n            // Extract component name and get the actual Recharts component\n            const componentName = child.tag.replace(\"recharts:\", \"\");\n            const RechartsComponent = RechartsComponentMap[componentName];\n            \n            if (RechartsComponent) {\n                // Convert props using the same logic as convertRechartsProps\n                const childProps = convertRechartsProps(model, child);\n                childProps.key = \"recharts-\" + child.waveid;\n                \n                // Recursively convert children\n                const grandChildren = convertRechartsChildren(child, model);\n                \n                // Create the raw Recharts component directly\n                children.push(React.createElement(RechartsComponent, childProps, grandChildren));\n            }\n            continue;\n        }\n        \n        // Non-Recharts nodes under charts aren't supported; drop silently\n        // Could add warning: console.warn(\"Unsupported child type in Recharts:\", child.tag);\n    }\n    \n    return children.length > 0 ? children : null;\n}\n\n\nexport { RechartsTag };"
  },
  {
    "path": "tsunami/frontend/src/tailwind.css",
    "content": "/* Copyright 2025, Command Line Inc. */\n/* SPDX-License-Identifier: Apache-2.0 */\n\n@import \"tailwindcss\";\n\n@theme {\n    --color-background: rgb(34, 34, 34);\n    --color-foreground: #f7f7f7;\n    --color-white: #f7f7f7;\n    --color-secondary: rgba(215, 218, 224, 0.7);\n    --color-muted: rgba(215, 218, 224, 0.5);\n    --color-accent-50: rgb(236, 253, 232);\n    --color-accent-100: rgb(209, 250, 202);\n    --color-accent-200: rgb(167, 243, 168);\n    --color-accent-300: rgb(110, 231, 133);\n    --color-accent-400: rgb(88, 193, 66); /* main accent color */\n    --color-accent-500: rgb(63, 162, 51);\n    --color-accent-600: rgb(47, 133, 47);\n    --color-accent-700: rgb(34, 104, 43);\n    --color-accent-800: rgb(22, 81, 35);\n    --color-accent-900: rgb(15, 61, 29);\n    --color-error: rgb(229, 77, 46);\n    --color-warning: rgb(224, 185, 86);\n    --color-success: rgb(78, 154, 6);\n    --color-panel: rgba(31, 33, 31, 0.5);\n    --color-hover: rgba(255, 255, 255, 0.1);\n    --color-border: rgba(255, 255, 255, 0.16);\n    --color-modalbg: #232323;\n    --color-accentbg: rgba(88, 193, 66, 0.5);\n    --color-hoverbg: rgba(255, 255, 255, 0.2);\n    --color-accent: rgb(88, 193, 66);\n    --color-accenthover: rgb(118, 223, 96);\n\n    --font-sans: \"Inter\", sans-serif;\n    --font-mono: \"Hack\", monospace;\n    --font-markdown: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif,\n        \"Apple Color Emoji\", \"Segoe UI Emoji\";\n\n    --text-xxs: 10px;\n    --text-title: 18px;\n    --text-default: 14px;\n\n    --radius: 8px;\n\n    /* ANSI Colors (Default Dark Palette) */\n    --ansi-black: #757575;\n    --ansi-red: #cc685c;\n    --ansi-green: #76c266;\n    --ansi-yellow: #cbca9b;\n    --ansi-blue: #85aacb;\n    --ansi-magenta: #cc72ca;\n    --ansi-cyan: #74a7cb;\n    --ansi-white: #c1c1c1;\n    --ansi-brightblack: #727272;\n    --ansi-brightred: #cc9d97;\n    --ansi-brightgreen: #a3dd97;\n    --ansi-brightyellow: #cbcaaa;\n    --ansi-brightblue: #9ab6cb;\n    --ansi-brightmagenta: #cc8ecb;\n    --ansi-brightcyan: #b7b8cb;\n    --ansi-brightwhite: #f0f0f0;\n}\n\n/* Disable overscroll behavior */\nhtml, body {\n    overscroll-behavior: none;\n    overscroll-behavior-x: none;\n    overscroll-behavior-y: none;\n}\n\n/* Custom dark theme scrollbar */\n::-webkit-scrollbar {\n    width: 6px !important;\n    height: 6px !important;\n}\n\n::-webkit-scrollbar-track {\n    background: rgba(0, 0, 0, 0.1);\n    width: 6px !important;\n}\n\n::-webkit-scrollbar-thumb {\n    background: rgba(255, 255, 255, 0.15);\n    border-radius: 3px;\n    width: 6px !important;\n}\n\n::-webkit-scrollbar-thumb:hover {\n    background: rgba(255, 255, 255, 0.25);\n    width: 6px !important;\n}\n\n::-webkit-scrollbar-corner {\n    background: rgba(0, 0, 0, 0.1);\n}\n\n/* Force consistent scrollbar width across all states */\n::-webkit-scrollbar:horizontal {\n    height: 6px !important;\n}\n\n::-webkit-scrollbar:vertical {\n    width: 6px !important;\n}\n\n/* Firefox scrollbar styling */\n* {\n    scrollbar-width: thin;\n    scrollbar-color: rgba(255, 255, 255, 0.15) rgba(0, 0, 0, 0.1);\n}\n"
  },
  {
    "path": "tsunami/frontend/src/types/custom.d.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\ntype KeyPressDecl = {\n    mods: {\n        Cmd?: boolean;\n        Option?: boolean;\n        Shift?: boolean;\n        Ctrl?: boolean;\n        Alt?: boolean;\n        Meta?: boolean;\n    };\n    key: string;\n    keyType: string;\n};\n"
  },
  {
    "path": "tsunami/frontend/src/types/vdom.d.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\n// rpctypes.VDomBackendOpts\ntype VDomBackendOpts = {\n    globalkeyboardevents?: boolean;\n    title?: string;\n    shortdesc?: string;\n    faviconpath?: string;\n};\n\n// rpctypes.VDomBackendUpdate\ntype VDomBackendUpdate = {\n    type: \"backendupdate\";\n    ts: number;\n    serverid: string;\n    opts?: VDomBackendOpts;\n    haswork?: boolean;\n    fullupdate?: boolean;\n    renderupdates?: VDomRenderUpdate[];\n    transferelems?: VDomTransferElem[];\n    transfertext?: VDomText[];\n    refoperations?: VDomRefOperation[];\n    messages?: VDomMessage[];\n};\n\n// rpctypes.RenderedElem\ntype VDomElem = {\n    waveid?: string;\n    tag: string;\n    props?: { [key: string]: any };\n    children?: VDomElem[];\n    text?: string;\n};\n\n// vdom.VDomTermSize\ntype VDomTermSize = {\n    rows: number;\n    cols: number;\n};\n\n// vdom.VDomTermInputData\ntype VDomTermInputData = {\n    termsize?: VDomTermSize;\n    data?: string;\n};\n\n// vdom.VDomEvent\ntype VDomEvent = {\n    waveid: string;\n    eventtype: string;\n    globaleventtype?: string;\n    targetvalue?: string;\n    targetchecked?: boolean;\n    targetname?: string;\n    targetid?: string;\n    targetfiles?: VDomFileData[];\n    keydata?: VDomKeyboardEvent;\n    mousedata?: VDomPointerData;\n    formdata?: VDomFormData;\n    terminput?: VDomTermInputData;\n};\n\n// vdom.VDomFrontendUpdate\ntype VDomFrontendUpdate = {\n    type: \"frontendupdate\";\n    ts: number;\n    clientid: string;\n    forcetakeover?: boolean;\n    correlationid?: string;\n    reason?: string;\n    dispose?: boolean;\n    resync?: boolean;\n    rendercontext: VDomRenderContext;\n    events?: VDomEvent[];\n    refupdates?: VDomRefUpdate[];\n    messages?: VDomMessage[];\n};\n\n// vdom.VDomFunc\ntype VDomFunc = {\n    type: \"func\";\n    stoppropagation?: boolean;\n    preventdefault?: boolean;\n    globalevent?: string;\n    keys?: string[];\n};\n\n// vdom.VDomMessage\ntype VDomMessage = {\n    messagetype: string;\n    message: string;\n    stacktrace?: string;\n    params?: any[];\n};\n\n// rpctypes.ModalConfig\ntype ModalConfig = {\n    modalid: string;\n    modaltype: \"alert\" | \"confirm\";\n    icon?: string;\n    title: string;\n    text?: string;\n    oktext?: string;\n    canceltext?: string;\n};\n\n// rpctypes.ModalResult\ntype ModalResult = {\n    modalid: string;\n    confirm: boolean;\n};\n\n// vdom.VDomRef\ntype VDomRef = {\n    type: \"ref\";\n    refid: string;\n    trackposition?: boolean;\n    hascurrent?: boolean;\n};\n\n// vdom.VDomRefOperation\ntype VDomRefOperation = {\n    refid: string;\n    op: string;\n    params?: any[];\n    outputref?: string;\n};\n\n// vdom.VDomRefPosition\ntype VDomRefPosition = {\n    offsetheight: number;\n    offsetwidth: number;\n    scrollheight: number;\n    scrollwidth: number;\n    scrolltop: number;\n    boundingclientrect: DomRect;\n};\n\n// rpctypes.VDomRefUpdate\ntype VDomRefUpdate = {\n    refid: string;\n    hascurrent: boolean;\n    position?: VDomRefPosition;\n    termsize?: VDomTermSize;\n};\n\n// rpctypes.VDomRenderContext\ntype VDomRenderContext = {\n    focused: boolean;\n    width: number;\n    height: number;\n    rootrefid: string;\n    background?: boolean;\n};\n\n// rpctypes.VDomRenderUpdate\ntype VDomRenderUpdate = {\n    updatetype: \"root\" | \"append\" | \"replace\" | \"remove\" | \"insert\";\n    waveid?: string;\n    vdomwaveid?: string;\n    vdom?: VDomElem;\n    index?: number;\n};\n\n// rpctypes.VDomTransferElem\ntype VDomTransferElem = {\n    waveid?: string;\n    tag: string;\n    props?: { [key: string]: any };\n    children?: string[];\n    text?: string;\n};\n\n// rpctypes.VDomText\ntype VDomText = {\n    id: number;\n    text: string;\n};\n\n// rpctypes.VDomUrlRequestResponse\ntype VDomUrlRequestResponse = {\n    statuscode?: number;\n    headers?: { [key: string]: string };\n    body?: Uint8Array;\n};\n\n// vdom.VDomKeyboardEvent\ntype VDomKeyboardEvent = {\n    type: string;\n    key: string;\n    code: string;\n    shift?: boolean;\n    control?: boolean;\n    alt?: boolean;\n    meta?: boolean;\n    cmd?: boolean;\n    option?: boolean;\n    repeat?: boolean;\n    location?: number;\n};\n\n// vdom.VDomPointerData\ntype VDomPointerData = {\n    button: number;\n    buttons: number;\n    clientx?: number;\n    clienty?: number;\n    pagex?: number;\n    pagey?: number;\n    screenx?: number;\n    screeny?: number;\n    movementx?: number;\n    movementy?: number;\n    shift?: boolean;\n    control?: boolean;\n    alt?: boolean;\n    meta?: boolean;\n    cmd?: boolean;\n    option?: boolean;\n};\n\n// vdom.VDomFormData\ntype VDomFormData = {\n    action?: string;\n    method: string;\n    enctype: string;\n    formid?: string;\n    formname?: string;\n    fields: { [key: string]: string[] };\n    files: { [key: string]: VDomFileData[] };\n};\n\n// vdom.VDomFileData\ntype VDomFileData = {\n    fieldname: string;\n    name: string;\n    size: number;\n    type: string;\n    data64?: string;\n    error?: string;\n};\n"
  },
  {
    "path": "tsunami/frontend/src/util/base64.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport base64 from \"base64-js\";\n\nexport function base64ToString(b64: string): string {\n    if (b64 == null) {\n        return null;\n    }\n    if (b64 == \"\") {\n        return \"\";\n    }\n    const stringBytes = base64.toByteArray(b64);\n    return new TextDecoder().decode(stringBytes);\n}\n\nexport function stringToBase64(input: string): string {\n    const stringBytes = new TextEncoder().encode(input);\n    return base64.fromByteArray(stringBytes);\n}\n\nexport function base64ToArray(b64: string): Uint8Array<ArrayBufferLike> {\n    const cleanB64 = b64.replace(/\\s+/g, \"\");\n    return base64.toByteArray(cleanB64);\n}\n\nexport function base64ToArrayBuffer(b64: string): ArrayBuffer {\n    const cleanB64 = b64.replace(/\\s+/g, \"\");\n    const u8 = base64.toByteArray(cleanB64); // Uint8Array<ArrayBufferLike>\n    // Force a plain ArrayBuffer slice (no SharedArrayBuffer, no offset issues)\n    return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;\n}\n\nexport function arrayBufferToBase64(buffer: ArrayBuffer): string {\n    const u8 = new Uint8Array(buffer);\n    return base64.fromByteArray(u8);\n}\n"
  },
  {
    "path": "tsunami/frontend/src/util/clientid.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nconst CLIENT_ID_KEY = \"tsunami:clientid\";\n\n/**\n * Gets or creates a unique client ID for this browser tab/window.\n * The client ID is stored in sessionStorage and persists for the lifetime of the tab.\n * If no client ID exists, a new UUID is generated and stored.\n */\nexport function getOrCreateClientId(): string {\n    // First check for URL parameter\n    const urlParams = new URLSearchParams(window.location.search);\n    const urlClientId = urlParams.get(\"clientid\");\n    if (urlClientId) {\n        return urlClientId;\n    }\n    \n    // Fall back to session storage\n    let clientId = sessionStorage.getItem(CLIENT_ID_KEY);\n    if (!clientId) {\n        clientId = crypto.randomUUID();\n        sessionStorage.setItem(CLIENT_ID_KEY, clientId);\n    }\n    return clientId;\n}\n\n/**\n * Clears the stored client ID from sessionStorage.\n * A new client ID will be generated on the next call to getOrCreateClientId().\n */\nexport function clearClientId(): void {\n    sessionStorage.removeItem(CLIENT_ID_KEY);\n}"
  },
  {
    "path": "tsunami/frontend/src/util/keyutil.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nconst KeyTypeCodeRegex = /c{(.*)}/;\nconst KeyTypeKey = \"key\";\nconst KeyTypeCode = \"code\";\n\nlet PLATFORM: NodeJS.Platform = \"darwin\";\nconst PlatformMacOS = \"darwin\";\n\nfunction setKeyUtilPlatform(platform: NodeJS.Platform) {\n    PLATFORM = platform;\n}\n\nfunction getKeyUtilPlatform(): NodeJS.Platform {\n    return PLATFORM;\n}\n\nfunction keydownWrapper(\n    fn: (waveEvent: VDomKeyboardEvent) => boolean\n): (event: KeyboardEvent | React.KeyboardEvent) => void {\n    return (event: KeyboardEvent | React.KeyboardEvent) => {\n        const waveEvent = adaptFromReactOrNativeKeyEvent(event);\n        const rtnVal = fn(waveEvent);\n        if (rtnVal) {\n            event.preventDefault();\n            event.stopPropagation();\n        }\n    };\n}\n\nfunction waveEventToKeyDesc(waveEvent: VDomKeyboardEvent): string {\n    let keyDesc: string[] = [];\n    if (waveEvent.cmd) {\n        keyDesc.push(\"Cmd\");\n    }\n    if (waveEvent.option) {\n        keyDesc.push(\"Option\");\n    }\n    if (waveEvent.meta) {\n        keyDesc.push(\"Meta\");\n    }\n    if (waveEvent.control) {\n        keyDesc.push(\"Ctrl\");\n    }\n    if (waveEvent.shift) {\n        keyDesc.push(\"Shift\");\n    }\n    if (waveEvent.key != null && waveEvent.key != \"\") {\n        if (waveEvent.key == \" \") {\n            keyDesc.push(\"Space\");\n        } else {\n            keyDesc.push(waveEvent.key);\n        }\n    } else {\n        keyDesc.push(\"c{\" + waveEvent.code + \"}\");\n    }\n    return keyDesc.join(\":\");\n}\n\nfunction parseKey(key: string): { key: string; type: string } {\n    let regexMatch = key.match(KeyTypeCodeRegex);\n    if (regexMatch != null && regexMatch.length > 1) {\n        let code = regexMatch[1];\n        return { key: code, type: KeyTypeCode };\n    } else if (regexMatch != null) {\n        console.log(\"error: regexMatch is not null yet there is no captured group: \", regexMatch, key);\n    }\n    return { key: key, type: KeyTypeKey };\n}\n\nfunction parseKeyDescription(keyDescription: string): KeyPressDecl {\n    let rtn = { key: \"\", mods: {} } as KeyPressDecl;\n    let keys = keyDescription.replace(/[()]/g, \"\").split(\":\");\n    for (let key of keys) {\n        if (key == \"Cmd\") {\n            if (PLATFORM == PlatformMacOS) {\n                rtn.mods.Meta = true;\n            } else {\n                rtn.mods.Alt = true;\n            }\n            rtn.mods.Cmd = true;\n        } else if (key == \"Shift\") {\n            rtn.mods.Shift = true;\n        } else if (key == \"Ctrl\") {\n            rtn.mods.Ctrl = true;\n        } else if (key == \"Option\") {\n            if (PLATFORM == PlatformMacOS) {\n                rtn.mods.Alt = true;\n            } else {\n                rtn.mods.Meta = true;\n            }\n            rtn.mods.Option = true;\n        } else if (key == \"Alt\") {\n            if (PLATFORM == PlatformMacOS) {\n                rtn.mods.Option = true;\n            } else {\n                rtn.mods.Cmd = true;\n            }\n            rtn.mods.Alt = true;\n        } else if (key == \"Meta\") {\n            if (PLATFORM == PlatformMacOS) {\n                rtn.mods.Cmd = true;\n            } else {\n                rtn.mods.Option = true;\n            }\n            rtn.mods.Meta = true;\n        } else {\n            let { key: parsedKey, type: keyType } = parseKey(key);\n            rtn.key = parsedKey;\n            rtn.keyType = keyType;\n            if (rtn.keyType == KeyTypeKey && key.length == 1) {\n                // check for if key is upper case\n                // TODO what about unicode upper case?\n                if (/[A-Z]/.test(key.charAt(0))) {\n                    // this key is an upper case A - Z - we should apply the shift key, even if it wasn't specified\n                    rtn.mods.Shift = true;\n                } else if (key == \" \") {\n                    rtn.key = \"Space\";\n                    // we allow \" \" and \"Space\" to be mapped to Space key\n                }\n            }\n        }\n    }\n    return rtn;\n}\n\nfunction notMod(keyPressMod: boolean, eventMod: boolean) {\n    return (keyPressMod && !eventMod) || (eventMod && !keyPressMod);\n}\n\nfunction countGraphemes(str: string): number {\n    if (str == null) {\n        return 0;\n    }\n    // this exists (need to hack TS to get it to not show an error)\n    const seg = new (Intl as any).Segmenter(undefined, { granularity: \"grapheme\" });\n    return Array.from(seg.segment(str)).length;\n}\n\nfunction isCharacterKeyEvent(event: VDomKeyboardEvent): boolean {\n    if (event.alt || event.meta || event.control) {\n        return false;\n    }\n    return countGraphemes(event.key) == 1;\n}\n\nconst inputKeyMap = new Map<string, boolean>([\n    [\"Backspace\", true],\n    [\"Delete\", true],\n    [\"Enter\", true],\n    [\"Space\", true],\n    [\"Tab\", true],\n    [\"ArrowLeft\", true],\n    [\"ArrowRight\", true],\n    [\"ArrowUp\", true],\n    [\"ArrowDown\", true],\n    [\"Home\", true],\n    [\"End\", true],\n    [\"PageUp\", true],\n    [\"PageDown\", true],\n    [\"Cmd:a\", true],\n    [\"Cmd:c\", true],\n    [\"Cmd:v\", true],\n    [\"Cmd:x\", true],\n    [\"Cmd:z\", true],\n    [\"Cmd:Shift:z\", true],\n    [\"Cmd:ArrowLeft\", true],\n    [\"Cmd:ArrowRight\", true],\n    [\"Cmd:Backspace\", true],\n    [\"Cmd:Delete\", true],\n    [\"Shift:ArrowLeft\", true],\n    [\"Shift:ArrowRight\", true],\n    [\"Shift:ArrowUp\", true],\n    [\"Shift:ArrowDown\", true],\n    [\"Shift:Home\", true],\n    [\"Shift:End\", true],\n    [\"Cmd:Shift:ArrowLeft\", true],\n    [\"Cmd:Shift:ArrowRight\", true],\n    [\"Cmd:Shift:ArrowUp\", true],\n    [\"Cmd:Shift:ArrowDown\", true],\n]);\n\nfunction isInputEvent(event: VDomKeyboardEvent): boolean {\n    if (isCharacterKeyEvent(event)) {\n        return true;\n    }\n    for (let key of inputKeyMap.keys()) {\n        if (checkKeyPressed(event, key)) {\n            return true;\n        }\n    }\n}\n\nfunction checkKeyPressed(event: VDomKeyboardEvent, keyDescription: string): boolean {\n    let keyPress = parseKeyDescription(keyDescription);\n    if (notMod(keyPress.mods.Option, event.option)) {\n        return false;\n    }\n    if (notMod(keyPress.mods.Cmd, event.cmd)) {\n        return false;\n    }\n    if (notMod(keyPress.mods.Shift, event.shift)) {\n        return false;\n    }\n    if (notMod(keyPress.mods.Ctrl, event.control)) {\n        return false;\n    }\n    if (notMod(keyPress.mods.Alt, event.alt)) {\n        return false;\n    }\n    if (notMod(keyPress.mods.Meta, event.meta)) {\n        return false;\n    }\n    let eventKey = \"\";\n    let descKey = keyPress.key;\n    if (keyPress.keyType == KeyTypeCode) {\n        eventKey = event.code;\n    }\n    if (keyPress.keyType == KeyTypeKey) {\n        eventKey = event.key;\n        if (eventKey != null && eventKey.length == 1 && /[A-Z]/.test(eventKey.charAt(0))) {\n            // key is upper case A-Z, this means shift is applied, we want to allow\n            // \"Shift:e\" as well as \"Shift:E\" or \"E\"\n            eventKey = eventKey.toLocaleLowerCase();\n            descKey = descKey.toLocaleLowerCase();\n        } else if (eventKey == \" \") {\n            eventKey = \"Space\";\n            // a space key is shown as \" \", we want users to be able to set space key as \"Space\" or \" \", whichever they prefer\n        }\n    }\n    if (descKey != eventKey) {\n        return false;\n    }\n    return true;\n}\n\nfunction adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEvent): VDomKeyboardEvent {\n    let rtn: VDomKeyboardEvent = {} as VDomKeyboardEvent;\n    rtn.control = event.ctrlKey;\n    rtn.shift = event.shiftKey;\n    rtn.cmd = PLATFORM == PlatformMacOS ? event.metaKey : event.altKey;\n    rtn.option = PLATFORM == PlatformMacOS ? event.altKey : event.metaKey;\n    rtn.meta = event.metaKey;\n    rtn.alt = event.altKey;\n    rtn.code = event.code;\n    rtn.key = event.key;\n    rtn.location = event.location;\n    if (event.type == \"keydown\" || event.type == \"keyup\" || event.type == \"keypress\") {\n        rtn.type = event.type;\n    } else {\n        rtn.type = \"unknown\";\n    }\n    rtn.repeat = event.repeat;\n    return rtn;\n}\n\nfunction adaptFromElectronKeyEvent(event: any): VDomKeyboardEvent {\n    let rtn: VDomKeyboardEvent = {} as VDomKeyboardEvent;\n    if (event.type == \"keyUp\") {\n        rtn.type = \"keyup\";\n    } else if (event.type == \"keyDown\") {\n        rtn.type = \"keydown\";\n    } else {\n        rtn.type = \"unknown\";\n    }\n    rtn.control = event.control;\n    rtn.cmd = PLATFORM == PlatformMacOS ? event.meta : event.alt;\n    rtn.option = PLATFORM == PlatformMacOS ? event.alt : event.meta;\n    rtn.meta = event.meta;\n    rtn.alt = event.alt;\n    rtn.shift = event.shift;\n    rtn.repeat = event.isAutoRepeat;\n    rtn.location = event.location;\n    rtn.code = event.code;\n    rtn.key = event.key;\n    return rtn;\n}\n\nconst keyMap = {\n    Enter: \"\\r\",\n    Backspace: \"\\x7f\",\n    Tab: \"\\t\",\n    Escape: \"\\x1b\",\n    ArrowUp: \"\\x1b[A\",\n    ArrowDown: \"\\x1b[B\",\n    ArrowRight: \"\\x1b[C\",\n    ArrowLeft: \"\\x1b[D\",\n    Insert: \"\\x1b[2~\",\n    Delete: \"\\x1b[3~\",\n    Home: \"\\x1b[1~\",\n    End: \"\\x1b[4~\",\n    PageUp: \"\\x1b[5~\",\n    PageDown: \"\\x1b[6~\",\n};\n\nfunction keyboardEventToASCII(event: VDomKeyboardEvent): string {\n    // check modifiers\n    // if no modifiers are set, just send the key\n    if (!event.alt && !event.control && !event.meta) {\n        if (event.key == null || event.key == \"\") {\n            return \"\";\n        }\n        if (keyMap[event.key] != null) {\n            return keyMap[event.key];\n        }\n        if (event.key.length == 1) {\n            return event.key;\n        } else {\n            console.log(\"not sending keyboard event\", event.key, event);\n        }\n    }\n    // if meta or alt is set, there is no ASCII representation\n    if (event.meta || event.alt) {\n        return \"\";\n    }\n    // if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value\n    if (event.control) {\n        if (\n            (event.key.length === 1 && event.key >= \"A\" && event.key <= \"Z\") ||\n            (event.key >= \"a\" && event.key <= \"z\")\n        ) {\n            const key = event.key.toUpperCase();\n            return String.fromCharCode(key.charCodeAt(0) - 64);\n        }\n    }\n    return \"\";\n}\n\nexport {\n    adaptFromElectronKeyEvent,\n    adaptFromReactOrNativeKeyEvent,\n    checkKeyPressed,\n    getKeyUtilPlatform,\n    isCharacterKeyEvent,\n    isInputEvent,\n    keyboardEventToASCII,\n    keydownWrapper,\n    parseKeyDescription,\n    setKeyUtilPlatform,\n    waveEventToKeyDesc,\n};\n"
  },
  {
    "path": "tsunami/frontend/src/util/platformutil.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nexport const PlatformMacOS = \"darwin\";\nexport const PlatformWindows = \"win32\";\nexport let PLATFORM: NodeJS.Platform = PlatformMacOS;\n\nexport function setPlatform(platform: NodeJS.Platform) {\n    PLATFORM = platform;\n}\n\nexport function isMacOS(): boolean {\n    return PLATFORM == PlatformMacOS;\n}\n\nexport function isWindows(): boolean {\n    return PLATFORM == PlatformWindows;\n}\n"
  },
  {
    "path": "tsunami/frontend/src/vdom.tsx",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport clsx from \"clsx\";\nimport debug from \"debug\";\nimport * as jotai from \"jotai\";\nimport * as React from \"react\";\nimport { twMerge } from \"tailwind-merge\";\n\nimport { Markdown } from \"@/element/markdown\";\nimport { AlertModal, ConfirmModal } from \"@/element/modals\";\nimport { TsunamiTerm } from \"@/element/tsunamiterm\";\nimport { getTextChildren } from \"@/model/model-utils\";\nimport type { TsunamiModel } from \"@/model/tsunami-model\";\nimport { RechartsTag } from \"@/recharts/recharts\";\nimport { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from \"@/util/keyutil\";\nimport OptimisticInput from \"./input\";\n\nconst TextTag = \"#text\";\nconst FragmentTag = \"#fragment\";\nconst WaveTextTag = \"wave:text\";\nconst WaveNullTag = \"wave:null\";\nconst StyleTagName = \"style\";\n\nconst VDomObjType_Ref = \"ref\";\nconst VDomObjType_Func = \"func\";\n\nconst dlog = debug(\"wave:vdom\");\n\ntype VDomReactTagType = (props: { elem: VDomElem; model: TsunamiModel }) => React.ReactElement;\n\nconst WaveTagMap: Record<string, VDomReactTagType> = {\n    \"wave:markdown\": WaveMarkdown,\n    \"wave:term\": WaveTerm,\n};\n\nconst AllowedSimpleTags: { [tagName: string]: boolean } = {\n    div: true,\n    b: true,\n    i: true,\n    p: true,\n    s: true,\n    span: true,\n    a: true,\n    img: true,\n    h1: true,\n    h2: true,\n    h3: true,\n    h4: true,\n    h5: true,\n    h6: true,\n    ul: true,\n    ol: true,\n    li: true,\n    input: true,\n    button: true,\n    textarea: true,\n    select: true,\n    option: true,\n    form: true,\n    label: true,\n    table: true,\n    thead: true,\n    tbody: true,\n    tr: true,\n    th: true,\n    td: true,\n    hr: true,\n    br: true,\n    pre: true,\n    code: true,\n    canvas: true,\n    strong: true,\n    em: true,\n    small: true,\n    sub: true,\n    sup: true,\n    u: true,\n    mark: true,\n    blockquote: true,\n    section: true,\n    article: true,\n    header: true,\n    footer: true,\n    main: true,\n    nav: true,\n    dl: true,\n    dt: true,\n    dd: true,\n    video: true,\n    audio: true,\n    picture: true,\n    source: true,\n    figure: true,\n    figcaption: true,\n    details: true,\n    summary: true,\n    fieldset: true,\n    legend: true,\n    progress: true,\n    meter: true,\n};\n\nconst AllowedSvgTags = {\n    // SVG tags\n    svg: true,\n    circle: true,\n    ellipse: true,\n    line: true,\n    path: true,\n    polygon: true,\n    polyline: true,\n    rect: true,\n    g: true,\n    text: true,\n    tspan: true,\n    textPath: true,\n    use: true,\n    defs: true,\n    linearGradient: true,\n    radialGradient: true,\n    stop: true,\n    clipPath: true,\n    mask: true,\n    pattern: true,\n    image: true,\n    marker: true,\n    symbol: true,\n    filter: true,\n    feBlend: true,\n    feColorMatrix: true,\n    feComponentTransfer: true,\n    feComposite: true,\n    feConvolveMatrix: true,\n    feDiffuseLighting: true,\n    feDisplacementMap: true,\n    feFlood: true,\n    feGaussianBlur: true,\n    feImage: true,\n    feMerge: true,\n    feMorphology: true,\n    feOffset: true,\n    feSpecularLighting: true,\n    feTile: true,\n    feTurbulence: true,\n};\n\nconst IdAttributes = {\n    id: true,\n    for: true,\n    \"aria-labelledby\": true,\n    \"aria-describedby\": true,\n    \"aria-controls\": true,\n    \"aria-owns\": true,\n    form: true,\n    headers: true,\n    usemap: true,\n    list: true,\n};\n\nconst SvgUrlIdAttributes = {\n    \"clip-path\": true,\n    mask: true,\n    filter: true,\n    fill: true,\n    stroke: true,\n    \"marker-start\": true,\n    \"marker-mid\": true,\n    \"marker-end\": true,\n    \"text-decoration\": true,\n};\n\nfunction convertVDomFunc(model: TsunamiModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void {\n    return (e: any) => {\n        if ((propName == \"onKeyDown\" || propName == \"onKeyDownCapture\") && fnDecl[\"keys\"]) {\n            dlog(\"key event\", fnDecl, e);\n            let waveEvent = adaptFromReactOrNativeKeyEvent(e);\n            for (let keyDesc of fnDecl[\"keys\"] || []) {\n                if (checkKeyPressed(waveEvent, keyDesc)) {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    model.callVDomFunc(fnDecl, e, compId, propName);\n                    return;\n                }\n            }\n            return;\n        }\n        if (fnDecl.preventdefault) {\n            e.preventDefault();\n        }\n        if (fnDecl.stoppropagation) {\n            e.stopPropagation();\n        }\n        model.callVDomFunc(fnDecl, e, compId, propName);\n    };\n}\n\nexport function convertElemToTag(elem: VDomElem, model: TsunamiModel): React.ReactNode {\n    if (elem == null) {\n        return null;\n    }\n    if (elem.tag == TextTag) {\n        return elem.text;\n    }\n    return React.createElement(VDomTag, { key: elem.waveid, elem, model });\n}\n\nfunction isObject(v: any): boolean {\n    return v != null && !Array.isArray(v) && typeof v === \"object\";\n}\n\nfunction isArray(v: any): boolean {\n    return Array.isArray(v);\n}\n\ntype GenericPropsType = { [key: string]: any };\n\nfunction convertProps(elem: VDomElem, model: TsunamiModel): GenericPropsType {\n    let props: GenericPropsType = {};\n    if (elem.props == null) {\n        return props;\n    }\n    for (let key in elem.props) {\n        let val = elem.props[key];\n        if (val == null) {\n            continue;\n        }\n        if (key == \"ref\") {\n            if (val == null) {\n                continue;\n            }\n            if (isObject(val) && val.type == VDomObjType_Ref) {\n                const valRef = val as VDomRef;\n                const refContainer = model.getOrCreateRefContainer(valRef);\n                props[key] = refContainer.refFn;\n            }\n            continue;\n        }\n        if (key == \"className\" && typeof val === \"string\") {\n            props[key] = twMerge(val);\n            continue;\n        }\n        if (isObject(val) && val.type == VDomObjType_Func) {\n            const valFunc = val as VDomFunc;\n            props[key] = convertVDomFunc(model, valFunc, elem.waveid, key);\n            continue;\n        }\n        props[key] = val;\n    }\n    return props;\n}\n\nfunction convertChildren(elem: VDomElem, model: TsunamiModel): React.ReactNode[] {\n    if (elem.children == null || elem.children.length == 0) {\n        return null;\n    }\n    let childrenComps: React.ReactNode[] = [];\n    for (let child of elem.children) {\n        if (child == null) {\n            continue;\n        }\n        childrenComps.push(convertElemToTag(child, model));\n    }\n    if (childrenComps.length == 0) {\n        return null;\n    }\n    return childrenComps;\n}\n\nfunction useVDom(model: TsunamiModel, elem: VDomElem): GenericPropsType {\n    const version = jotai.useAtomValue(model.getVDomNodeVersionAtom(elem)); // this triggers updates when vdom nodes change\n    let props = convertProps(elem, model);\n    return props;\n}\n\nfunction WaveMarkdown({ elem, model }: { elem: VDomElem; model: TsunamiModel }) {\n    const props = useVDom(model, elem);\n    return (\n        <Markdown text={props?.text} style={props?.style} className={props?.className} scrollable={props?.scrollable} />\n    );\n}\n\nasync function sendTermInputEvent(event: VDomEvent) {\n    const response = await fetch(\"/api/terminput\", {\n        method: \"POST\",\n        headers: {\n            \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify(event),\n    });\n    if (!response.ok) {\n        throw new Error(`terminal input request failed: ${response.status} ${response.statusText}`);\n    }\n}\n\nfunction WaveTerm({ elem, model }: { elem: VDomElem; model: TsunamiModel }) {\n    const props = useVDom(model, elem);\n    const hasOnData = props.onData != null;\n    const onData = React.useCallback(\n        (data: string | null, termsize: VDomTermSize | null) => {\n            const terminput: VDomTermInputData = {};\n            if (data != null) {\n                terminput.data = data;\n            }\n            if (termsize != null) {\n                terminput.termsize = termsize;\n            }\n            const event: VDomEvent = {\n                waveid: elem.waveid,\n                eventtype: \"onData\",\n                terminput: terminput,\n            };\n            sendTermInputEvent(event).catch((error) => {\n                console.error(\"Failed to send terminal input:\", error);\n            });\n        },\n        [elem.waveid]\n    );\n    const termProps = { ...props, onData: hasOnData ? onData : undefined };\n    return <TsunamiTerm {...termProps} />;\n}\n\nfunction StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) {\n    const styleText = getTextChildren(elem);\n    if (styleText == null) {\n        return null;\n    }\n    return <style>{styleText}</style>;\n}\n\nfunction VDomTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) {\n    const props = useVDom(model, elem);\n    if (elem.tag == WaveNullTag) {\n        return null;\n    }\n    if (elem.tag == WaveTextTag) {\n        return props.text;\n    }\n\n    // Dispatch recharts: prefixed tags to RechartsTag\n    if (elem.tag.startsWith(\"recharts:\")) {\n        return <RechartsTag elem={elem} model={model} />;\n    }\n\n    const waveTag = WaveTagMap[elem.tag];\n    if (waveTag) {\n        return waveTag({ elem, model });\n    }\n    if (elem.tag == StyleTagName) {\n        return <StyleTag elem={elem} model={model} />;\n    }\n    if (!AllowedSimpleTags[elem.tag] && !AllowedSvgTags[elem.tag]) {\n        return <div>{\"Invalid Tag <\" + elem.tag + \">\"}</div>;\n    }\n    let childrenComps = convertChildren(elem, model);\n    if (elem.tag == FragmentTag) {\n        return childrenComps;\n    }\n\n    // Use OptimisticInput for input and textarea elements\n    if (elem.tag === \"input\" || elem.tag === \"textarea\") {\n        props.key = \"e-\" + elem.waveid;\n        const optimisticProps = {\n            ...props,\n            _tagName: elem.tag as \"input\" | \"textarea\",\n        };\n        return React.createElement(OptimisticInput, optimisticProps, childrenComps);\n    }\n\n    props.key = \"e-\" + elem.waveid;\n    return React.createElement(elem.tag, props, childrenComps);\n}\n\nfunction VDomRoot({ model }: { model: TsunamiModel }) {\n    let version = jotai.useAtomValue(model.globalVersion);\n    let rootNode = jotai.useAtomValue(model.vdomRoot);\n    React.useEffect(() => {\n        model.renderDone(version);\n    }, [version]);\n    if (model.viewRef.current == null || rootNode == null) {\n        return null;\n    }\n    dlog(\"render\", version, rootNode);\n    let rtn = convertElemToTag(rootNode, model);\n    return <div className=\"vdom\">{rtn}</div>;\n}\n\ntype VDomViewProps = {\n    model: TsunamiModel;\n};\n\nfunction VDomInnerView({ model }: VDomViewProps) {\n    let [styleMounted, setStyleMounted] = React.useState(false);\n    const handleStyleLoad = () => {\n        setStyleMounted(true);\n    };\n    return (\n        <>\n            <link rel=\"stylesheet\" href={`/static/tw.css?x=${model.serverId}`} onLoad={handleStyleLoad} />\n            {styleMounted ? <VDomRoot model={model} /> : null}\n        </>\n    );\n}\n\nfunction VDomView({ model }: VDomViewProps) {\n    let viewRef = React.useRef(null);\n    let contextActive = jotai.useAtomValue(model.contextActive);\n    let currentModal = jotai.useAtomValue(model.currentModal);\n    model.viewRef = viewRef;\n\n    const handleModalClose = React.useCallback(\n        (confirmed: boolean) => {\n            if (currentModal) {\n                model.sendModalResult(currentModal.modalid, confirmed);\n            }\n        },\n        [model, currentModal]\n    );\n\n    return (\n        <div className={clsx(\"overflow-auto w-full min-h-full\")} ref={viewRef}>\n            {contextActive ? <VDomInnerView model={model} /> : null}\n            {currentModal && currentModal.modaltype === \"alert\" && (\n                <AlertModal config={currentModal} onClose={handleModalClose} />\n            )}\n            {currentModal && currentModal.modaltype === \"confirm\" && (\n                <ConfirmModal config={currentModal} onClose={handleModalClose} />\n            )}\n        </div>\n    );\n}\n\nexport { VDomView };\n"
  },
  {
    "path": "tsunami/frontend/tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"target\": \"ES2022\",\n        \"lib\": [\"ES2023\", \"DOM\", \"DOM.Iterable\"],\n        \"module\": \"ESNext\",\n        \"skipLibCheck\": true,\n        \"allowJs\": false,\n        \"esModuleInterop\": false,\n        \"allowSyntheticDefaultImports\": true,\n        \"strict\": false,\n        \"strictNullChecks\": false,\n        \"forceConsistentCasingInFileNames\": true,\n        \"moduleResolution\": \"bundler\",\n        \"allowImportingTsExtensions\": true,\n        \"resolveJsonModule\": true,\n        \"isolatedModules\": true,\n        \"noEmit\": true,\n        \"jsx\": \"react-jsx\",\n        \"baseUrl\": \".\",\n        \"paths\": {\n            \"@/*\": [\"./src/*\"]\n        }\n    },\n    \"include\": [\"src/**/*\", \"vite.config.ts\"],\n    \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  },
  {
    "path": "tsunami/frontend/vite.config.ts",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\nimport tailwindcss from \"@tailwindcss/vite\";\nimport react from \"@vitejs/plugin-react-swc\";\nimport { defineConfig } from \"vite\";\n\nexport default defineConfig({\n    plugins: [react(), tailwindcss()],\n    resolve: {\n        alias: {\n            \"@\": \"/src\",\n        },\n    },\n    server: {\n        port: 12025,\n        open: true,\n        proxy: {\n            \"/api\": {\n                target: \"http://localhost:12026\",\n                changeOrigin: true,\n            },\n            \"/assets\": {\n                target: \"http://localhost:12026\",\n                changeOrigin: true,\n            },\n        },\n    },\n    build: {\n        outDir: \"dist\",\n        minify: process.env.NODE_ENV === \"development\" ? false : \"esbuild\",\n        sourcemap: process.env.NODE_ENV === \"development\" ? true : false,\n    },\n});\n"
  },
  {
    "path": "tsunami/go.mod",
    "content": "module github.com/wavetermdev/waveterm/tsunami\n\ngo 1.25.6\n\nrequire (\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/outrigdev/goid v0.3.0\n\tgithub.com/spf13/cobra v1.10.1\n\tgolang.org/x/mod v0.27.0\n)\n\nrequire (\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/spf13/pflag v1.0.9 // indirect\n)\n"
  },
  {
    "path": "tsunami/go.sum",
    "content": "github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=\ngithub.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=\ngithub.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=\ngithub.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=\ngithub.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngolang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=\ngolang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "tsunami/rpctypes/protocoltypes.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rpctypes\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\n// rendered element (output from rendering pipeline)\ntype RenderedElem struct {\n\tWaveId   string         `json:\"waveid,omitempty\"` // required, except for #text nodes\n\tTag      string         `json:\"tag\"`\n\tProps    map[string]any `json:\"props,omitempty\"`\n\tChildren []RenderedElem `json:\"children,omitempty\"`\n\tText     string         `json:\"text,omitempty\"`\n}\n\ntype VDomUrlRequestResponse struct {\n\tStatusCode int               `json:\"statuscode,omitempty\"`\n\tHeaders    map[string]string `json:\"headers,omitempty\"`\n\tBody       []byte            `json:\"body,omitempty\"`\n}\n\ntype VDomFrontendUpdate struct {\n\tType          string            `json:\"type\" tstype:\"\\\"frontendupdate\\\"\"`\n\tTs            int64             `json:\"ts\"`\n\tClientId      string            `json:\"clientid\"`\n\tForceTakeover bool              `json:\"forcetakeover,omitempty\"`\n\tCorrelationId string            `json:\"correlationid,omitempty\"`\n\tReason        string            `json:\"reason,omitempty\"`\n\tDispose       bool              `json:\"dispose,omitempty\"` // the vdom context was closed\n\tResync        bool              `json:\"resync,omitempty\"`  // resync (send all backend data).  useful when the FE reloads\n\tRenderContext VDomRenderContext `json:\"rendercontext,omitempty\"`\n\tEvents        []vdom.VDomEvent  `json:\"events,omitempty\"`\n\tRefUpdates    []VDomRefUpdate   `json:\"refupdates,omitempty\"`\n\tMessages      []VDomMessage     `json:\"messages,omitempty\"`\n}\n\ntype VDomBackendUpdate struct {\n\tType          string                  `json:\"type\" tstype:\"\\\"backendupdate\\\"\"`\n\tTs            int64                   `json:\"ts\"`\n\tServerId      string                  `json:\"serverid\"`\n\tOpts          *VDomBackendOpts        `json:\"opts,omitempty\"`\n\tHasWork       bool                    `json:\"haswork,omitempty\"`\n\tFullUpdate    bool                    `json:\"fullupdate,omitempty\"`\n\tRenderUpdates []VDomRenderUpdate      `json:\"renderupdates,omitempty\"`\n\tTransferElems []VDomTransferElem      `json:\"transferelems,omitempty\"`\n\tTransferText  []VDomText              `json:\"transfertext,omitempty\"`\n\tRefOperations []vdom.VDomRefOperation `json:\"refoperations,omitempty\"`\n\tMessages      []VDomMessage           `json:\"messages,omitempty\"`\n}\n\n// the over the wire format for a vdom element\ntype VDomTransferElem struct {\n\tWaveId   string         `json:\"waveid,omitempty\"` // required, except for #text nodes\n\tTag      string         `json:\"tag\"`\n\tProps    map[string]any `json:\"props,omitempty\"`\n\tChildren []string       `json:\"children,omitempty\"`\n\tText     string         `json:\"text,omitempty\"`\n}\n\ntype VDomText struct {\n\tId   int    `json:\"id\"`\n\tText string `json:\"text\"`\n}\n\nfunc (beUpdate *VDomBackendUpdate) CreateTransferElems() {\n\tvar renderedElems []RenderedElem\n\tfor idx, reUpdate := range beUpdate.RenderUpdates {\n\t\tif reUpdate.VDom == nil {\n\t\t\tcontinue\n\t\t}\n\t\trenderedElems = append(renderedElems, *reUpdate.VDom)\n\t\tbeUpdate.RenderUpdates[idx].VDomWaveId = reUpdate.VDom.WaveId\n\t\tbeUpdate.RenderUpdates[idx].VDom = nil\n\t}\n\ttransferElems, transferText := ConvertElemsToTransferElems(renderedElems)\n\ttransferElems = DedupTransferElems(transferElems)\n\tbeUpdate.TransferElems = transferElems\n\tbeUpdate.TransferText = transferText\n}\n\nfunc ConvertElemsToTransferElems(elems []RenderedElem) ([]VDomTransferElem, []VDomText) {\n\tvar transferElems []VDomTransferElem\n\tvar transferText []VDomText\n\ttextMap := make(map[string]int) // map text content to ID for deduplication\n\n\t// Helper function to recursively process each RenderedElem in preorder\n\tvar processElem func(elem RenderedElem) string\n\tprocessElem = func(elem RenderedElem) string {\n\t\t// Handle #text nodes with deduplication\n\t\tif elem.Tag == \"#text\" {\n\t\t\ttextId, exists := textMap[elem.Text]\n\t\t\tif !exists {\n\t\t\t\t// New text content, create new entry\n\t\t\t\ttextId = len(textMap) + 1\n\t\t\t\ttextMap[elem.Text] = textId\n\t\t\t\ttransferText = append(transferText, VDomText{\n\t\t\t\t\tId:   textId,\n\t\t\t\t\tText: elem.Text,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Return sentinel string with ID (no VDomTransferElem created)\n\t\t\ttextIdStr := fmt.Sprintf(\"t:%d\", textId)\n\t\t\treturn textIdStr\n\t\t}\n\n\t\t// Convert children to WaveId references, handling potential #text nodes\n\t\tchildrenIds := make([]string, len(elem.Children))\n\t\tfor i, child := range elem.Children {\n\t\t\tchildrenIds[i] = processElem(child) // Children are not roots\n\t\t}\n\n\t\t// Create the VDomTransferElem for the current element\n\t\ttransferElem := VDomTransferElem{\n\t\t\tWaveId:   elem.WaveId,\n\t\t\tTag:      elem.Tag,\n\t\t\tProps:    elem.Props,\n\t\t\tChildren: childrenIds,\n\t\t\tText:     elem.Text,\n\t\t}\n\t\ttransferElems = append(transferElems, transferElem)\n\n\t\treturn elem.WaveId\n\t}\n\n\t// Start processing each top-level element, marking them as roots\n\tfor _, elem := range elems {\n\t\tprocessElem(elem)\n\t}\n\n\treturn transferElems, transferText\n}\n\nfunc DedupTransferElems(elems []VDomTransferElem) []VDomTransferElem {\n\tseen := make(map[string]int) // maps WaveId to its index in the result slice\n\tvar result []VDomTransferElem\n\n\tfor _, elem := range elems {\n\t\tif idx, exists := seen[elem.WaveId]; exists {\n\t\t\t// Overwrite the previous element with the latest one\n\t\t\tresult[idx] = elem\n\t\t} else {\n\t\t\t// Add new element and store its index\n\t\t\tseen[elem.WaveId] = len(result)\n\t\t\tresult = append(result, elem)\n\t\t}\n\t}\n\n\treturn result\n}\n\ntype VDomRenderContext struct {\n\tFocused    bool   `json:\"focused\"`\n\tWidth      int    `json:\"width\"`\n\tHeight     int    `json:\"height\"`\n\tRootRefId  string `json:\"rootrefid\"`\n\tBackground bool   `json:\"background,omitempty\"`\n}\n\ntype VDomRefUpdate struct {\n\tRefId      string                `json:\"refid\"`\n\tHasCurrent bool                  `json:\"hascurrent\"`\n\tPosition   *vdom.VDomRefPosition `json:\"position,omitempty\"`\n\tTermSize   *vdom.VDomTermSize    `json:\"termsize,omitempty\"`\n}\n\ntype VDomBackendOpts struct {\n\tTitle                string `json:\"title,omitempty\"`\n\tShortDesc            string `json:\"shortdesc,omitempty\"`\n\tGlobalKeyboardEvents bool   `json:\"globalkeyboardevents,omitempty\"`\n\tFaviconPath          string `json:\"faviconpath,omitempty\"`\n}\n\ntype VDomRenderUpdate struct {\n\tUpdateType string        `json:\"updatetype\" tstype:\"\\\"root\\\"|\\\"append\\\"|\\\"replace\\\"|\\\"remove\\\"|\\\"insert\\\"\"`\n\tWaveId     string        `json:\"waveid,omitempty\"`\n\tVDomWaveId string        `json:\"vdomwaveid,omitempty\"`\n\tVDom       *RenderedElem `json:\"vdom,omitempty\"` // these get removed for transfer (encoded to transferelems)\n\tIndex      *int          `json:\"index,omitempty\"`\n}\n\ntype VDomMessage struct {\n\tMessageType string `json:\"messagetype\"`\n\tMessage     string `json:\"message\"`\n\tStackTrace  string `json:\"stacktrace,omitempty\"`\n\tParams      []any  `json:\"params,omitempty\"`\n}\n\n// ModalConfig contains all configuration options for modals\ntype ModalConfig struct {\n\tModalId    string `json:\"modalid\"`              // Unique identifier for the modal\n\tModalType  string `json:\"modaltype\"`            // \"alert\" or \"confirm\"\n\tIcon       string `json:\"icon,omitempty\"`       // Optional icon to display (emoji or icon name)\n\tTitle      string `json:\"title\"`                // Modal title\n\tText       string `json:\"text,omitempty\"`       // Optional body text\n\tOkText     string `json:\"oktext,omitempty\"`     // Optional OK button text (defaults to \"OK\")\n\tCancelText string `json:\"canceltext,omitempty\"` // Optional Cancel button text for confirm modals (defaults to \"Cancel\")\n}\n\n// ModalResult contains the result of a modal interaction\ntype ModalResult struct {\n\tModalId string `json:\"modalid\"` // ID of the modal\n\tConfirm bool   `json:\"confirm\"` // true = confirmed/ok, false = cancelled\n}\n\ntype TermWritePacket struct {\n\tRefId  string `json:\"refid\"`\n\tData64 string `json:\"data64\"`\n}\n"
  },
  {
    "path": "tsunami/templates/app-init.go.tmpl",
    "content": "package main\n\nimport \"github.com/wavetermdev/waveterm/tsunami/app\"\n\nfunc init() {\n    app.RegisterAppInitFn(AppInit)\n}"
  },
  {
    "path": "tsunami/templates/app-main.go.tmpl",
    "content": "package main\n\nimport (\n\t\"embed\"\n\t\"io/fs\"\n\t\"os\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/app\"\n)\n\n//go:embed dist/**\nvar distFS embed.FS\n\n//go:embed static/**\nvar staticFS embed.FS\n\nfunc main() {\n\tsubDistFS, _ := fs.Sub(distFS, \"dist\")\n\tsubStaticFS, _ := fs.Sub(staticFS, \"static\")\n\tapp.RegisterEmbeds(subDistFS, subStaticFS, nil)\n\tapp.SetAppMeta(AppMeta)\n\t\n\tif len(os.Args) == 2 && os.Args[1] == \"--manifest\" {\n\t\tapp.PrintAppManifest()\n\t\tos.Exit(0)\n\t}\n\t\n\tapp.RunMain()\n}\n"
  },
  {
    "path": "tsunami/templates/empty-gomod.tmpl",
    "content": "module empty\n\ngo 1.25.6\n"
  },
  {
    "path": "tsunami/templates/gitignore.tmpl",
    "content": "dist/\nnode_modules/\nbin/"
  },
  {
    "path": "tsunami/templates/package.json.tmpl",
    "content": "{\n  \"name\": \"tsunami-scaffold\",\n  \"version\": \"1.0.0\",\n  \"main\": \"index.js\",\n  \"keywords\": [],\n  \"license\": \"Apache-2.0\",\n  \"description\": \"\",\n  \"author\": {\n    \"name\": \"Command Line Inc\",\n    \"email\": \"info@commandline.dev\"\n  },\n  \"dependencies\": {\n    \"@tailwindcss/cli\": \"^4.2.1\",\n    \"tailwindcss\": \"^4.2.1\"\n  }\n}\n"
  },
  {
    "path": "tsunami/templates/tailwind.css",
    "content": "/* Copyright 2025, Command Line Inc. */\n/* SPDX-License-Identifier: Apache-2.0 */\n\n@import \"tailwindcss\";\n\n@theme {\n    --color-background: rgb(34, 34, 34); /* default background color */\n    --color-primary: rgb(247, 247, 247); /* primary text color (headers, bold text) */\n    --color-secondary: rgba(215, 218, 224, 0.7); /* secondary text */\n    --color-muted: rgba(215, 218, 224, 0.5); /* muted, faint, small text */\n    --color-accent-50: rgb(236, 253, 232);\n    --color-accent-100: rgb(209, 250, 202);\n    --color-accent-200: rgb(167, 243, 168);\n    --color-accent-300: rgb(110, 231, 133);\n    --color-accent-400: rgb(88, 193, 66); /* main accent color */\n    --color-accent-500: rgb(63, 162, 51);\n    --color-accent-600: rgb(47, 133, 47);\n    --color-accent-700: rgb(34, 104, 43);\n    --color-accent-800: rgb(22, 81, 35);\n    --color-accent-900: rgb(15, 61, 29);\n    --color-error: rgb(229, 77, 46); /* use as bg w/ primary text */\n    --color-warning: rgb(181, 137, 0); /* use as bg w/ primary text */\n    --color-success: rgb(78, 154, 6); /* use as bg w/ primary text */\n    --color-panel: rgba(255, 255, 255, 0.12); /* use a bg for panels */\n    --color-hoverbg: rgba(255, 255, 255, 0.16); /* on hover, can use as a bg to highlight */\n    --color-border: rgba(255, 255, 255, 0.16); /* fine border color */\n    --color-strongborder: rgba(255, 255, 255, 0.24); /* stronger border / divider color */\n    --color-accentbg: rgba(88, 193, 66, 0.4); /* accented bg color */\n    --color-accent: rgb(88, 193, 66); /* accent text color */\n    --color-accenthover: rgb(118, 223, 96); /* brighter accent text color (hover effect) */\n\n    --font-sans: \"Inter\", sans-serif; /* regular text font */\n    --font-mono: \"Hack\", monospace; /* monospace, code, terminal, command font */\n    --font-markdown:\n        -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif, \"Apple Color Emoji\",\n        \"Segoe UI Emoji\";\n\n    --text-xxs: 10px; /* small, very fine text */\n    --text-title: 18px; /* font size for titles */\n    --text-default: 14px; /* default font size */\n    --radius: 8px; /* default border radius */\n\n    /* ANSI Terminal Colors (Default Dark Palette) */\n    --ansi-black: #757575;\n    --ansi-red: #cc685c;\n    --ansi-green: #76c266;\n    --ansi-yellow: #cbca9b;\n    --ansi-blue: #85aacb;\n    --ansi-magenta: #cc72ca;\n    --ansi-cyan: #74a7cb;\n    --ansi-white: #c1c1c1;\n    --ansi-brightblack: #727272;\n    --ansi-brightred: #cc9d97;\n    --ansi-brightgreen: #a3dd97;\n    --ansi-brightyellow: #cbcaaa;\n    --ansi-brightblue: #9ab6cb;\n    --ansi-brightmagenta: #cc8ecb;\n    --ansi-brightcyan: #b7b8cb;\n    --ansi-brightwhite: #f0f0f0;\n}\n"
  },
  {
    "path": "tsunami/tsunamibase/tsunamibase.go",
    "content": "package tsunamibase\n\nvar TsunamiVersion = \"0.0.0\""
  },
  {
    "path": "tsunami/ui/table.go",
    "content": "package ui\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\t\"sort\"\n\t\"strconv\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/app\"\n\t\"github.com/wavetermdev/waveterm/tsunami/vdom\"\n)\n\n// Core table types\ntype CellContext[T any] struct {\n\tData   T      `json:\"row\"`\n\tValue  any    `json:\"value\"`\n\tRowIdx int    `json:\"rowIdx\"`\n\tColIdx int    `json:\"colIdx\"`\n\tColumn string `json:\"column\"`\n}\n\ntype RowContext[T any] struct {\n\tData   T   `json:\"row\"`\n\tRowIdx int `json:\"rowIdx\"`\n}\n\ntype HeaderContext struct {\n\tColumn        string `json:\"column\"`\n\tIsSorted      bool   `json:\"isSorted\"`\n\tSortDirection string `json:\"sortDirection\"`\n}\n\n// Column definition - similar to TanStack Table\ntype TableColumn[T any] struct {\n\tAccessorKey     string                         `json:\"accessorKey\"` // Field name in the data\n\tAccessorFn      func(rowCtx RowContext[T]) any `json:\"-\"`           // Function to extract value from row\n\tHeader          string                         `json:\"header\"`      // Display name\n\tWidth           string                         `json:\"width,omitempty\"`\n\tSortable        bool                           `json:\"sortable\"`\n\tCellClassName   string\n\tHeaderClassName string\n\tCellRender      func(ctx CellContext[T]) any `json:\"-\"` // Custom cell renderer\n\tHeaderRender    func(ctx HeaderContext) any  `json:\"-\"` // Custom header renderer\n}\n\n// Table props\ntype TableProps[T any] struct {\n\tData              []T                                   `json:\"data\"`\n\tColumns           []TableColumn[T]                      `json:\"columns\"`\n\tRowRender         func(ctx RowContext[T]) any           `json:\"-\"` // Custom row renderer (overrides columns)\n\tRowClassName      func(ctx RowContext[T]) string        `json:\"-\"` // Custom row class names\n\tOnRowClick        func(row T, idx int)                  `json:\"-\"`\n\tOnSort            func(column string, direction string) `json:\"-\"`\n\tDefaultSort       string                                `json:\"defaultSort,omitempty\"`\n\tPagination        *PaginationConfig                     `json:\"pagination,omitempty\"`\n\tSelectable        bool                                  `json:\"selectable\"`\n\tSelectedRows      []int                                 `json:\"selectedRows,omitempty\"`\n\tOnSelectionChange func(selectedRows []int)              `json:\"-\"`\n}\n\ntype PaginationConfig struct {\n\tPageSize    int   `json:\"pageSize\"`\n\tCurrentPage int   `json:\"currentPage\"`\n\tShowSizes   []int `json:\"showSizes,omitempty\"` // [10, 25, 50, 100]\n}\n\n// Helper to extract field value from struct/map using reflection\nfunc getFieldValueWithReflection(item any, key string) any {\n\tif item == nil {\n\t\treturn nil\n\t}\n\n\t// Handle map[string]any\n\tif m, ok := item.(map[string]any); ok {\n\t\treturn m[key]\n\t}\n\n\t// Handle struct via reflection\n\tv := reflect.ValueOf(item)\n\tif v.Kind() == reflect.Ptr {\n\t\tv = v.Elem()\n\t}\n\tif v.Kind() != reflect.Struct {\n\t\treturn nil\n\t}\n\n\tfield := v.FieldByName(key)\n\tif !field.IsValid() {\n\t\treturn nil\n\t}\n\treturn field.Interface()\n}\n\n// Generic helper to extract field value using either AccessorFn or reflection\nfunc getFieldValue[T any](row T, rowIdx int, col TableColumn[T]) any {\n\tif col.AccessorFn != nil {\n\t\treturn col.AccessorFn(RowContext[T]{\n\t\t\tData:   row,\n\t\t\tRowIdx: rowIdx,\n\t\t})\n\t}\n\treturn getFieldValueWithReflection(row, col.AccessorKey)\n}\n\n// Helper to find column by accessor key\nfunc findColumnByKey[T any](columns []TableColumn[T], key string) *TableColumn[T] {\n\tfor _, col := range columns {\n\t\tif col.AccessorKey == key {\n\t\t\treturn &col\n\t\t}\n\t}\n\treturn nil\n}\n\n// Sort data by column\nfunc sortData[T any](data []T, col TableColumn[T], direction string) []T {\n\tif len(data) == 0 || (col.AccessorKey == \"\" && col.AccessorFn == nil) {\n\t\treturn data\n\t}\n\n\tsorted := make([]T, len(data))\n\tcopy(sorted, data)\n\n\tsort.Slice(sorted, func(i, j int) bool {\n\t\tvalI := getFieldValue(sorted[i], i, col)\n\t\tvalJ := getFieldValue(sorted[j], j, col)\n\n\t\t// Handle nil values\n\t\tif valI == nil && valJ == nil {\n\t\t\treturn false\n\t\t}\n\t\tif valI == nil {\n\t\t\treturn direction == \"asc\"\n\t\t}\n\t\tif valJ == nil {\n\t\t\treturn direction == \"desc\"\n\t\t}\n\n\t\t// Convert to strings for comparison (could be enhanced for numbers/dates)\n\t\tstrI := fmt.Sprintf(\"%v\", valI)\n\t\tstrJ := fmt.Sprintf(\"%v\", valJ)\n\n\t\tif direction == \"asc\" {\n\t\t\treturn strI < strJ\n\t\t}\n\t\treturn strI > strJ\n\t})\n\n\treturn sorted\n}\n\n// Paginate data\nfunc paginateData[T any](data []T, config *PaginationConfig) []T {\n\tif config == nil || len(data) == 0 {\n\t\treturn data\n\t}\n\n\tstart := config.CurrentPage * config.PageSize\n\tend := start + config.PageSize\n\n\tif start >= len(data) {\n\t\treturn []T{}\n\t}\n\tif end > len(data) {\n\t\tend = len(data)\n\t}\n\n\treturn data[start:end]\n}\n\n// Default cell renderer\nfunc defaultCellRenderer[T any](ctx CellContext[T]) any {\n\tif ctx.Value == nil {\n\t\treturn vdom.H(\"span\", map[string]any{\n\t\t\t\"className\": \"text-gray-500\",\n\t\t}, \"-\")\n\t}\n\n\treturn vdom.H(\"span\", nil, fmt.Sprintf(\"%v\", ctx.Value))\n}\n\n// Default header renderer\nfunc defaultHeaderRenderer(ctx HeaderContext) any {\n\treturn vdom.H(\"div\", map[string]any{\n\t\t\"className\": \"flex items-center gap-2\",\n\t},\n\t\tvdom.H(\"span\", nil, ctx.Column),\n\t\tvdom.If(ctx.IsSorted,\n\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\"className\": \"text-blue-400\",\n\t\t\t}, vdom.IfElse(ctx.SortDirection == \"asc\", \"↑\", \"↓\")),\n\t\t),\n\t)\n}\n\n// Helper function to safely call RowClassName function\nfunc makeRowClassName[T any](rowClassNameFunc func(ctx RowContext[T]) string, rowCtx RowContext[T]) string {\n\tif rowClassNameFunc == nil {\n\t\treturn \"\"\n\t}\n\treturn rowClassNameFunc(rowCtx)\n}\n\nfunc MakeTableComponent[T any](componentName string) vdom.Component[TableProps[T]] {\n\treturn app.DefineComponent(componentName, genTableRenderFunc[T])\n}\n\nfunc genTableRenderFunc[T any](props TableProps[T]) any {\n\t// State for sorting\n\tsortColumnAtom := app.UseLocal(props.DefaultSort)\n\tsortDirectionAtom := app.UseLocal(\"asc\")\n\n\t// State for pagination - initialize with prop values\n\tinitialPage := 0\n\tinitialPageSize := 25\n\tif props.Pagination != nil {\n\t\tinitialPage = props.Pagination.CurrentPage\n\t\tinitialPageSize = props.Pagination.PageSize\n\t}\n\tcurrentPageAtom := app.UseLocal(initialPage)\n\tpageSizeAtom := app.UseLocal(initialPageSize)\n\n\t// State for selection - initialize with empty slice if nil\n\tinitialSelection := props.SelectedRows\n\tif initialSelection == nil {\n\t\tinitialSelection = []int{}\n\t}\n\tselectedRowsAtom := app.UseLocal(initialSelection)\n\n\n\t// Handle sorting\n\thandleSort := func(column string) {\n\t\tcurrentSort := sortColumnAtom.Get()\n\t\tcurrentDir := sortDirectionAtom.Get()\n\n\t\tif currentSort == column {\n\t\t\t// Toggle direction\n\t\t\tnewDir := vdom.IfElse(currentDir == \"asc\", \"desc\", \"asc\").(string)\n\t\t\tsortDirectionAtom.Set(newDir)\n\t\t\tif props.OnSort != nil {\n\t\t\t\tprops.OnSort(column, newDir)\n\t\t\t}\n\t\t} else {\n\t\t\t// New column\n\t\t\tsortColumnAtom.Set(column)\n\t\t\tsortDirectionAtom.Set(\"asc\")\n\t\t\tif props.OnSort != nil {\n\t\t\t\tprops.OnSort(column, \"asc\")\n\t\t\t}\n\t\t}\n\t}\n\n\t// Handle row selection\n\thandleRowSelect := func(rowIdx int) {\n\t\tif !props.Selectable {\n\t\t\treturn\n\t\t}\n\n\t\tselectedRowsAtom.SetFn(func(current []int) []int {\n\t\t\t// Toggle selection\n\t\t\tfor i, idx := range current {\n\t\t\t\tif idx == rowIdx {\n\t\t\t\t\t// Remove\n\t\t\t\t\treturn append(current[:i], current[i+1:]...)\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Add\n\t\t\treturn append(current, rowIdx)\n\t\t})\n\n\t\tif props.OnSelectionChange != nil {\n\t\t\tprops.OnSelectionChange(selectedRowsAtom.Get())\n\t\t}\n\t}\n\n\t// Handle pagination\n\thandlePageChange := func(page int) {\n\t\tcurrentPageAtom.Set(page)\n\t}\n\n\thandlePageSizeChange := func(size int) {\n\t\tpageSizeAtom.Set(size)\n\t\tcurrentPageAtom.Set(0) // Reset to first page\n\t}\n\n\t// Process data\n\tprocessedData := props.Data\n\tif sortColumnAtom.Get() != \"\" {\n\t\tif sortCol := findColumnByKey(props.Columns, sortColumnAtom.Get()); sortCol != nil {\n\t\t\tprocessedData = sortData(processedData, *sortCol, sortDirectionAtom.Get())\n\t\t}\n\t}\n\n\ttotalRows := len(processedData)\n\n\t// Apply pagination\n\tpaginationConfig := &PaginationConfig{\n\t\tPageSize:    pageSizeAtom.Get(),\n\t\tCurrentPage: currentPageAtom.Get(),\n\t}\n\n\tpaginatedData := paginateData(processedData, paginationConfig)\n\n\t// Get current state values\n\tcurrentSort := sortColumnAtom.Get()\n\tcurrentDir := sortDirectionAtom.Get()\n\tcurrentSelected := selectedRowsAtom.Get()\n\n\treturn vdom.H(\"div\", map[string]any{\n\t\t\"className\": \"w-full\",\n\t},\n\t\t// Table\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"overflow-auto border border-gray-600 rounded-lg\",\n\t\t},\n\t\t\tvdom.H(\"table\", map[string]any{\n\t\t\t\t\"className\": \"w-full bg-gray-900 text-gray-200\",\n\t\t\t},\n\t\t\t\t// Header\n\t\t\t\tvdom.H(\"thead\", map[string]any{\n\t\t\t\t\t\"className\": \"bg-gray-800 border-b border-gray-600 text-white\",\n\t\t\t\t},\n\t\t\t\t\tvdom.H(\"tr\", nil,\n\t\t\t\t\t\tvdom.If(props.Selectable,\n\t\t\t\t\t\t\tvdom.H(\"th\", map[string]any{\n\t\t\t\t\t\t\t\t\"className\": \"p-3 text-left\",\n\t\t\t\t\t\t\t\t\"style\":     map[string]any{\"width\": \"40px\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tvdom.H(\"input\", map[string]any{\n\t\t\t\t\t\t\t\t\t\"type\":    \"checkbox\",\n\t\t\t\t\t\t\t\t\t\"checked\": len(currentSelected) == len(paginatedData) && len(paginatedData) > 0,\n\t\t\t\t\t\t\t\t\t\"onChange\": func() {\n\t\t\t\t\t\t\t\t\t\tif len(currentSelected) == len(paginatedData) {\n\t\t\t\t\t\t\t\t\t\t\tselectedRowsAtom.Set([]int{})\n\t\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\t\tallSelected := make([]int, len(paginatedData))\n\t\t\t\t\t\t\t\t\t\t\tfor i := range paginatedData {\n\t\t\t\t\t\t\t\t\t\t\t\tallSelected[i] = i\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tselectedRowsAtom.Set(allSelected)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t),\n\t\t\t\t\t\tvdom.ForEach(props.Columns, func(col TableColumn[T], colIdx int) any {\n\t\t\t\t\t\t\tisSorted := currentSort == col.AccessorKey\n\n\t\t\t\t\t\t\theaderCtx := HeaderContext{\n\t\t\t\t\t\t\t\tColumn:        col.Header,\n\t\t\t\t\t\t\t\tIsSorted:      isSorted,\n\t\t\t\t\t\t\t\tSortDirection: currentDir,\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\theaderContent := defaultHeaderRenderer(headerCtx)\n\t\t\t\t\t\t\tif col.HeaderRender != nil {\n\t\t\t\t\t\t\t\theaderContent = col.HeaderRender(headerCtx)\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\treturn vdom.H(\"th\", map[string]any{\n\t\t\t\t\t\t\t\t\"key\": col.AccessorKey,\n\t\t\t\t\t\t\t\t\"className\": vdom.Classes(\n\t\t\t\t\t\t\t\t\t\"p-3 text-left font-semibold\",\n\t\t\t\t\t\t\t\t\tvdom.If(col.Sortable, \"cursor-pointer hover:bg-gray-700\"),\n\t\t\t\t\t\t\t\t\tcol.HeaderClassName,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\"style\":   vdom.If(col.Width != \"\", map[string]any{\"width\": col.Width}),\n\t\t\t\t\t\t\t\t\"onClick\": vdom.If(col.Sortable, func() { handleSort(col.AccessorKey) }),\n\t\t\t\t\t\t\t}, headerContent)\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t),\n\n\t\t\t\t// Body\n\t\t\t\tvdom.H(\"tbody\", map[string]any{\n\t\t\t\t\t\"className\": \"divide-y divide-gray-700\",\n\t\t\t\t},\n\t\t\t\t\tvdom.ForEach(paginatedData, func(row T, rowIdx int) any {\n\t\t\t\t\t\tisSelected := func() bool {\n\t\t\t\t\t\t\tfor _, idx := range currentSelected {\n\t\t\t\t\t\t\t\tif idx == rowIdx {\n\t\t\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn false\n\t\t\t\t\t\t}()\n\n\t\t\t\t\t\t// Custom row renderer\n\t\t\t\t\t\tif props.RowRender != nil {\n\t\t\t\t\t\t\treturn props.RowRender(RowContext[T]{\n\t\t\t\t\t\t\t\tData:   row,\n\t\t\t\t\t\t\t\tRowIdx: rowIdx,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Default row rendering with columns\n\t\t\t\t\t\trowCtx := RowContext[T]{\n\t\t\t\t\t\t\tData:   row,\n\t\t\t\t\t\t\tRowIdx: rowIdx,\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\treturn vdom.H(\"tr\", map[string]any{\n\t\t\t\t\t\t\t\"key\": rowIdx,\n\t\t\t\t\t\t\t\"className\": vdom.Classes(\n\t\t\t\t\t\t\t\t\"hover:bg-gray-800 transition-colors\",\n\t\t\t\t\t\t\t\tvdom.If(isSelected, \"bg-blue-900\"),\n\t\t\t\t\t\t\t\tvdom.If(props.OnRowClick != nil, \"cursor-pointer\"),\n\t\t\t\t\t\t\t\tmakeRowClassName(props.RowClassName, rowCtx),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\"onClick\": func() {\n\t\t\t\t\t\t\t\tif props.OnRowClick != nil {\n\t\t\t\t\t\t\t\t\tprops.OnRowClick(row, rowIdx)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\tvdom.If(props.Selectable,\n\t\t\t\t\t\t\t\tvdom.H(\"td\", map[string]any{\n\t\t\t\t\t\t\t\t\t\"className\": \"p-3\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tvdom.H(\"input\", map[string]any{\n\t\t\t\t\t\t\t\t\t\t\"type\":     \"checkbox\",\n\t\t\t\t\t\t\t\t\t\t\"checked\":  isSelected,\n\t\t\t\t\t\t\t\t\t\t\"onChange\": func() { handleRowSelect(rowIdx) },\n\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tvdom.ForEach(props.Columns, func(col TableColumn[T], colIdx int) any {\n\t\t\t\t\t\t\t\tvar value any\n\t\t\t\t\t\t\t\tvalue = getFieldValue(row, rowIdx, col)\n\t\t\t\t\t\t\t\tcellCtx := CellContext[T]{\n\t\t\t\t\t\t\t\t\tData:   row,\n\t\t\t\t\t\t\t\t\tValue:  value,\n\t\t\t\t\t\t\t\t\tRowIdx: rowIdx,\n\t\t\t\t\t\t\t\t\tColIdx: colIdx,\n\t\t\t\t\t\t\t\t\tColumn: col.AccessorKey,\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\tcellContent := defaultCellRenderer(cellCtx)\n\t\t\t\t\t\t\t\tif col.CellRender != nil {\n\t\t\t\t\t\t\t\t\tcellContent = col.CellRender(cellCtx)\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\treturn vdom.H(\"td\", map[string]any{\n\t\t\t\t\t\t\t\t\t\"key\":       col.AccessorKey,\n\t\t\t\t\t\t\t\t\t\"className\": vdom.Classes(\"p-3\", col.CellClassName),\n\t\t\t\t\t\t\t\t}, cellContent)\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t)\n\t\t\t\t\t}),\n\t\t\t\t),\n\t\t\t),\n\t\t),\n\n\t\t// Pagination\n\t\tvdom.If(props.Pagination != nil,\n\t\t\trenderPagination(totalRows, paginationConfig, handlePageChange, handlePageSizeChange),\n\t\t),\n\t)\n}\n\n// Pagination component\nfunc renderPagination(totalRows int, config *PaginationConfig, onPageChange func(int), onPageSizeChange func(int)) any {\n\ttotalPages := (totalRows + config.PageSize - 1) / config.PageSize\n\tcurrentPage := config.CurrentPage\n\n\treturn vdom.H(\"div\", map[string]any{\n\t\t\"className\": \"flex items-center justify-between mt-4 px-4 py-3 bg-gray-800 rounded-lg\",\n\t},\n\t\t// Page size selector\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"flex items-center gap-2\",\n\t\t},\n\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\"className\": \"text-sm text-gray-400\",\n\t\t\t}, \"Show\"),\n\t\t\tvdom.H(\"select\", map[string]any{\n\t\t\t\t\"className\": \"bg-gray-700 text-white rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:bg-gray-600 mx-1\",\n\t\t\t\t\"value\":     strconv.Itoa(config.PageSize),\n\t\t\t\t\"onChange\": func(e vdom.VDomEvent) {\n\t\t\t\t\tif size, err := strconv.Atoi(e.TargetValue); err == nil {\n\t\t\t\t\t\tonPageSizeChange(size)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t},\n\t\t\t\tvdom.H(\"option\", map[string]any{\"value\": \"10\"}, \"10\"),\n\t\t\t\tvdom.H(\"option\", map[string]any{\"value\": \"25\"}, \"25\"),\n\t\t\t\tvdom.H(\"option\", map[string]any{\"value\": \"50\"}, \"50\"),\n\t\t\t\tvdom.H(\"option\", map[string]any{\"value\": \"100\"}, \"100\"),\n\t\t\t),\n\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\"className\": \"text-sm text-gray-400\",\n\t\t\t}, \"entries\"),\n\t\t),\n\n\t\t// Page info\n\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\"className\": \"text-sm text-gray-400\",\n\t\t}, fmt.Sprintf(\"Showing %d-%d of %d\",\n\t\t\tcurrentPage*config.PageSize+1,\n\t\t\tvdom.Ternary(currentPage*config.PageSize+config.PageSize > totalRows,\n\t\t\t\ttotalRows,\n\t\t\t\tcurrentPage*config.PageSize+config.PageSize),\n\t\t\ttotalRows,\n\t\t)),\n\n\t\t// Page controls\n\t\tvdom.H(\"div\", map[string]any{\n\t\t\t\"className\": \"flex items-center gap-3\",\n\t\t},\n\t\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\t\"className\": vdom.Classes(\n\t\t\t\t\t\"px-3 py-1.5 rounded text-sm transition-colors\",\n\t\t\t\t\tvdom.IfElse(currentPage > 0,\n\t\t\t\t\t\t\"bg-blue-600 hover:bg-blue-700 text-white cursor-pointer\",\n\t\t\t\t\t\t\"bg-gray-600 text-gray-500\"),\n\t\t\t\t),\n\t\t\t\t\"disabled\": currentPage <= 0,\n\t\t\t\t\"onClick\": func() {\n\t\t\t\t\tif currentPage > 0 {\n\t\t\t\t\t\tonPageChange(currentPage - 1)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t}, \"Previous\"),\n\t\n\t\t\tvdom.H(\"span\", map[string]any{\n\t\t\t\t\"className\": \"text-sm text-gray-400 px-2\",\n\t\t\t}, fmt.Sprintf(\"Page %d of %d\", currentPage+1, totalPages)),\n\t\n\t\t\tvdom.H(\"button\", map[string]any{\n\t\t\t\t\"className\": vdom.Classes(\n\t\t\t\t\t\"px-3 py-1.5 rounded text-sm transition-colors\",\n\t\t\t\t\tvdom.IfElse(currentPage < totalPages-1,\n\t\t\t\t\t\t\"bg-blue-600 hover:bg-blue-700 text-white cursor-pointer\",\n\t\t\t\t\t\t\"bg-gray-600 text-gray-500\"),\n\t\t\t\t),\n\t\t\t\t\"disabled\": currentPage >= totalPages-1,\n\t\t\t\t\"onClick\": func() {\n\t\t\t\t\tif currentPage < totalPages-1 {\n\t\t\t\t\t\tonPageChange(currentPage + 1)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t}, \"Next\"),\n\t\t),\n\t)\n}\n"
  },
  {
    "path": "tsunami/util/compare.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage util\n\nimport (\n\t\"math\"\n\t\"reflect\"\n\t\"strconv\"\n)\n\n// this is a shallow equal, but with special handling for numeric types\n// it will up convert to float64 and compare\nfunc JsonValEqual(a, b any) bool {\n\tif a == nil && b == nil {\n\t\treturn true\n\t}\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\ttypeA := reflect.TypeOf(a)\n\ttypeB := reflect.TypeOf(b)\n\tif typeA == typeB && typeA.Comparable() {\n\t\treturn a == b\n\t}\n\tif IsNumericType(a) && IsNumericType(b) {\n\t\treturn CompareAsFloat64(a, b)\n\t}\n\tif typeA != typeB {\n\t\treturn false\n\t}\n\t// for slices and maps, compare their pointers\n\tvalA := reflect.ValueOf(a)\n\tvalB := reflect.ValueOf(b)\n\tswitch valA.Kind() {\n\tcase reflect.Slice, reflect.Map:\n\t\treturn valA.Pointer() == valB.Pointer()\n\t}\n\treturn false\n}\n\n// Helper to check if a value is a numeric type\nfunc IsNumericType(val any) bool {\n\tswitch val.(type) {\n\tcase int, int8, int16, int32, int64,\n\t\tuint, uint8, uint16, uint32, uint64,\n\t\tfloat32, float64:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// Helper to handle numeric comparisons as float64\nfunc CompareAsFloat64(a, b any) bool {\n\tvalA, okA := ToFloat64(a)\n\tvalB, okB := ToFloat64(b)\n\treturn okA && okB && valA == valB\n}\n\n// Convert various numeric types to float64 for comparison\nfunc ToFloat64(val any) (float64, bool) {\n\tif val == nil {\n\t\treturn 0, false\n\t}\n\tswitch v := val.(type) {\n\tcase int:\n\t\treturn float64(v), true\n\tcase int8:\n\t\treturn float64(v), true\n\tcase int16:\n\t\treturn float64(v), true\n\tcase int32:\n\t\treturn float64(v), true\n\tcase int64:\n\t\treturn float64(v), true\n\tcase uint:\n\t\treturn float64(v), true\n\tcase uint8:\n\t\treturn float64(v), true\n\tcase uint16:\n\t\treturn float64(v), true\n\tcase uint32:\n\t\treturn float64(v), true\n\tcase uint64:\n\t\treturn float64(v), true\n\tcase float32:\n\t\treturn float64(v), true\n\tcase float64:\n\t\treturn v, true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc ToInt64(val any) (int64, bool) {\n\tif val == nil {\n\t\treturn 0, false\n\t}\n\tswitch v := val.(type) {\n\tcase int:\n\t\treturn int64(v), true\n\tcase int8:\n\t\treturn int64(v), true\n\tcase int16:\n\t\treturn int64(v), true\n\tcase int32:\n\t\treturn int64(v), true\n\tcase int64:\n\t\treturn v, true\n\tcase uint:\n\t\treturn int64(v), true\n\tcase uint8:\n\t\treturn int64(v), true\n\tcase uint16:\n\t\treturn int64(v), true\n\tcase uint32:\n\t\treturn int64(v), true\n\tcase uint64:\n\t\treturn int64(v), true\n\tcase float32:\n\t\treturn int64(v), true\n\tcase float64:\n\t\treturn int64(v), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc ToInt(val any) (int, bool) {\n\ti, ok := ToInt64(val)\n\tif !ok {\n\t\treturn 0, false\n\t}\n\treturn int(i), true\n}\n\nfunc NumToString[T any](value T) (string, bool) {\n\tswitch v := any(value).(type) {\n\tcase int:\n\t\treturn strconv.FormatInt(int64(v), 10), true\n\tcase int8:\n\t\treturn strconv.FormatInt(int64(v), 10), true\n\tcase int16:\n\t\treturn strconv.FormatInt(int64(v), 10), true\n\tcase int32:\n\t\treturn strconv.FormatInt(int64(v), 10), true\n\tcase int64:\n\t\treturn strconv.FormatInt(v, 10), true\n\tcase uint:\n\t\treturn strconv.FormatUint(uint64(v), 10), true\n\tcase uint8:\n\t\treturn strconv.FormatUint(uint64(v), 10), true\n\tcase uint16:\n\t\treturn strconv.FormatUint(uint64(v), 10), true\n\tcase uint32:\n\t\treturn strconv.FormatUint(uint64(v), 10), true\n\tcase uint64:\n\t\treturn strconv.FormatUint(v, 10), true\n\tcase float32:\n\t\treturn strconv.FormatFloat(float64(v), 'f', -1, 32), true\n\tcase float64:\n\t\treturn strconv.FormatFloat(v, 'f', -1, 64), true\n\tdefault:\n\t\treturn \"\", false\n\t}\n}\n\n// FromFloat64 converts a float64 to the specified numeric type T\n// Returns the converted value and a bool indicating if the conversion was successful\nfunc FromFloat64[T any](val float64) (T, bool) {\n\tvar zero T\n\t\n\t// Check for NaN or infinity\n\tif math.IsNaN(val) || math.IsInf(val, 0) {\n\t\treturn zero, false\n\t}\n\t\n\tswitch any(zero).(type) {\n\tcase int:\n\t\tif val != float64(int64(val)) || val < math.MinInt || val > math.MaxInt {\n\t\t\treturn zero, false\n\t\t}\n\t\treturn any(int(val)).(T), true\n\tcase int8:\n\t\tif val != float64(int64(val)) || val < math.MinInt8 || val > math.MaxInt8 {\n\t\t\treturn zero, false\n\t\t}\n\t\treturn any(int8(val)).(T), true\n\tcase int16:\n\t\tif val != float64(int64(val)) || val < math.MinInt16 || val > math.MaxInt16 {\n\t\t\treturn zero, false\n\t\t}\n\t\treturn any(int16(val)).(T), true\n\tcase int32:\n\t\tif val != float64(int64(val)) || val < math.MinInt32 || val > math.MaxInt32 {\n\t\t\treturn zero, false\n\t\t}\n\t\treturn any(int32(val)).(T), true\n\tcase int64:\n\t\tif val != float64(int64(val)) || val < math.MinInt64 || val > math.MaxInt64 {\n\t\t\treturn zero, false\n\t\t}\n\t\treturn any(int64(val)).(T), true\n\tcase uint:\n\t\tif val < 0 || val != float64(uint64(val)) || val > math.MaxUint {\n\t\t\treturn zero, false\n\t\t}\n\t\treturn any(uint(val)).(T), true\n\tcase uint8:\n\t\tif val < 0 || val != float64(uint64(val)) || val > math.MaxUint8 {\n\t\t\treturn zero, false\n\t\t}\n\t\treturn any(uint8(val)).(T), true\n\tcase uint16:\n\t\tif val < 0 || val != float64(uint64(val)) || val > math.MaxUint16 {\n\t\t\treturn zero, false\n\t\t}\n\t\treturn any(uint16(val)).(T), true\n\tcase uint32:\n\t\tif val < 0 || val != float64(uint64(val)) || val > math.MaxUint32 {\n\t\t\treturn zero, false\n\t\t}\n\t\treturn any(uint32(val)).(T), true\n\tcase uint64:\n\t\tif val < 0 || val != float64(uint64(val)) || val > math.MaxUint64 {\n\t\t\treturn zero, false\n\t\t}\n\t\treturn any(uint64(val)).(T), true\n\tcase float32:\n\t\tif math.Abs(val) > math.MaxFloat32 {\n\t\t\treturn zero, false\n\t\t}\n\t\treturn any(float32(val)).(T), true\n\tcase float64:\n\t\treturn any(val).(T), true\n\tdefault:\n\t\treturn zero, false\n\t}\n}"
  },
  {
    "path": "tsunami/util/marshal.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage util\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\t\"strings\"\n)\n\nfunc MapToStruct(in map[string]any, out any) error {\n\t// Check that out is a pointer\n\toutValue := reflect.ValueOf(out)\n\tif outValue.Kind() != reflect.Ptr {\n\t\treturn fmt.Errorf(\"out parameter must be a pointer, got %v\", outValue.Kind())\n\t}\n\n\t// Get the struct it points to\n\telem := outValue.Elem()\n\tif elem.Kind() != reflect.Struct {\n\t\treturn fmt.Errorf(\"out parameter must be a pointer to struct, got pointer to %v\", elem.Kind())\n\t}\n\n\t// Get type information\n\ttyp := elem.Type()\n\n\t// For each field in the struct\n\tfor i := 0; i < typ.NumField(); i++ {\n\t\tfield := typ.Field(i)\n\n\t\t// Skip unexported fields\n\t\tif !field.IsExported() {\n\t\t\tcontinue\n\t\t}\n\n\t\tname := getJSONName(field)\n\t\tif value, ok := in[name]; ok {\n\t\t\tif err := setValue(elem.Field(i), value); err != nil {\n\t\t\t\treturn fmt.Errorf(\"error setting field %s: %w\", name, err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc StructToMap(in any) (map[string]any, error) {\n\t// Get value and handle pointer\n\tval := reflect.ValueOf(in)\n\tif val.Kind() == reflect.Ptr {\n\t\tval = val.Elem()\n\t}\n\n\t// Check that we have a struct\n\tif val.Kind() != reflect.Struct {\n\t\treturn nil, fmt.Errorf(\"input must be a struct or pointer to struct, got %v\", val.Kind())\n\t}\n\n\t// Get type information\n\ttyp := val.Type()\n\tout := make(map[string]any)\n\n\t// For each field in the struct\n\tfor i := 0; i < typ.NumField(); i++ {\n\t\tfield := typ.Field(i)\n\n\t\t// Skip unexported fields\n\t\tif !field.IsExported() {\n\t\t\tcontinue\n\t\t}\n\n\t\tname := getJSONName(field)\n\t\tout[name] = val.Field(i).Interface()\n\t}\n\n\treturn out, nil\n}\n\n// getJSONName returns the field name to use for JSON mapping\nfunc getJSONName(field reflect.StructField) string {\n\ttag := field.Tag.Get(\"json\")\n\tif tag == \"\" || tag == \"-\" {\n\t\treturn field.Name\n\t}\n\treturn strings.Split(tag, \",\")[0]\n}\n\n// setValue attempts to set a reflect.Value with a given interface{} value\nfunc setValue(field reflect.Value, value any) error {\n\tif value == nil {\n\t\treturn nil\n\t}\n\n\tvalueRef := reflect.ValueOf(value)\n\n\t// Direct assignment if types are exactly equal\n\tif valueRef.Type() == field.Type() {\n\t\tfield.Set(valueRef)\n\t\treturn nil\n\t}\n\n\t// Check if types are assignable\n\tif valueRef.Type().AssignableTo(field.Type()) {\n\t\tfield.Set(valueRef)\n\t\treturn nil\n\t}\n\n\t// If field is pointer and value isn't already a pointer, try address\n\tif field.Kind() == reflect.Ptr && valueRef.Kind() != reflect.Ptr {\n\t\treturn setValue(field, valueRef.Addr().Interface())\n\t}\n\n\t// Try conversion if types are convertible\n\tif valueRef.Type().ConvertibleTo(field.Type()) {\n\t\tfield.Set(valueRef.Convert(field.Type()))\n\t\treturn nil\n\t}\n\n\treturn fmt.Errorf(\"cannot set value of type %v to field of type %v\", valueRef.Type(), field.Type())\n}"
  },
  {
    "path": "tsunami/util/streamtolines.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage util\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"time\"\n)\n\ntype LineOutput struct {\n\tLine  string\n\tError error\n}\n\ntype lineBuf struct {\n\tbuf        []byte\n\tinLongLine bool\n}\n\nconst maxLineLength = 128 * 1024\n\nfunc ReadLineWithTimeout(ch chan LineOutput, timeout time.Duration) (string, error) {\n\tselect {\n\tcase output := <-ch:\n\t\tif output.Error != nil {\n\t\t\treturn \"\", output.Error\n\t\t}\n\t\treturn output.Line, nil\n\tcase <-time.After(timeout):\n\t\treturn \"\", context.DeadlineExceeded\n\t}\n}\n\nfunc streamToLines_processBuf(lineBuf *lineBuf, readBuf []byte, lineFn func([]byte)) {\n\tfor len(readBuf) > 0 {\n\t\tnlIdx := bytes.IndexByte(readBuf, '\\n')\n\t\tif nlIdx == -1 {\n\t\t\tif lineBuf.inLongLine || len(lineBuf.buf)+len(readBuf) > maxLineLength {\n\t\t\t\tlineBuf.buf = nil\n\t\t\t\tlineBuf.inLongLine = true\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlineBuf.buf = append(lineBuf.buf, readBuf...)\n\t\t\treturn\n\t\t}\n\t\tif !lineBuf.inLongLine && len(lineBuf.buf)+nlIdx <= maxLineLength {\n\t\t\tline := append(lineBuf.buf, readBuf[:nlIdx]...)\n\t\t\tlineFn(line)\n\t\t}\n\t\tlineBuf.buf = nil\n\t\tlineBuf.inLongLine = false\n\t\treadBuf = readBuf[nlIdx+1:]\n\t}\n}\n\nfunc StreamToLines(input io.Reader, lineFn func([]byte)) error {\n\tvar lineBuf lineBuf\n\treadBuf := make([]byte, 64*1024)\n\tfor {\n\t\tn, err := input.Read(readBuf)\n\t\tstreamToLines_processBuf(&lineBuf, readBuf[:n], lineFn)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n}\n\n// starts a goroutine to drive the channel\n// line output does not include the trailing newline\nfunc StreamToLinesChan(input io.Reader) chan LineOutput {\n\tch := make(chan LineOutput)\n\tgo func() {\n\t\tdefer close(ch)\n\t\terr := StreamToLines(input, func(line []byte) {\n\t\t\tch <- LineOutput{Line: string(line)}\n\t\t})\n\t\tif err != nil && err != io.EOF {\n\t\t\tch <- LineOutput{Error: err}\n\t\t}\n\t}()\n\treturn ch\n}\n\n// LineWriter is an io.Writer that processes data line-by-line via a callback.\n// Lines do not include the trailing newline. Lines longer than maxLineLength are dropped.\ntype LineWriter struct {\n\tlineBuf lineBuf\n\tlineFn  func([]byte)\n}\n\n// NewLineWriter creates a new LineWriter with the given callback function.\nfunc NewLineWriter(lineFn func([]byte)) *LineWriter {\n\treturn &LineWriter{\n\t\tlineFn: lineFn,\n\t}\n}\n\n// Write implements io.Writer, processing the data and calling the callback for each complete line.\nfunc (lw *LineWriter) Write(p []byte) (n int, err error) {\n\tstreamToLines_processBuf(&lw.lineBuf, p, lw.lineFn)\n\treturn len(p), nil\n}\n\n// Flush outputs any remaining buffered data as a final line.\n// Should be called when the input stream is complete (e.g., at EOF).\nfunc (lw *LineWriter) Flush() {\n\tif len(lw.lineBuf.buf) > 0 && !lw.lineBuf.inLongLine {\n\t\tlw.lineFn(lw.lineBuf.buf)\n\t\tlw.lineBuf.buf = nil\n\t}\n}\n"
  },
  {
    "path": "tsunami/util/util.go",
    "content": "package util\n\nimport (\n\t\"encoding\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"runtime\"\n\t\"runtime/debug\"\n\t\"strings\"\n\t\"time\"\n)\n\n// PanicHandler handles panic recovery and logging.\n// It can be called directly with recover() without checking for nil first.\n// Example usage:\n//\n//\tdefer func() {\n//\t    util.PanicHandler(\"operation name\", recover())\n//\t}()\nfunc PanicHandler(debugStr string, recoverVal any) error {\n\tif recoverVal == nil {\n\t\treturn nil\n\t}\n\tlog.Printf(\"[panic] in %s: %v\\n\", debugStr, recoverVal)\n\tdebug.PrintStack()\n\tif err, ok := recoverVal.(error); ok {\n\t\treturn fmt.Errorf(\"panic in %s: %w\", debugStr, err)\n\t}\n\treturn fmt.Errorf(\"panic in %s: %v\", debugStr, recoverVal)\n}\n\nfunc GetHomeDir() string {\n\thomeVar, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"/\"\n\t}\n\treturn homeVar\n}\n\nfunc ExpandHomeDir(pathStr string) (string, error) {\n\tif pathStr != \"~\" && !strings.HasPrefix(pathStr, \"~/\") && (!strings.HasPrefix(pathStr, `~\\`) || runtime.GOOS != \"windows\") {\n\t\treturn filepath.Clean(pathStr), nil\n\t}\n\thomeDir := GetHomeDir()\n\tif pathStr == \"~\" {\n\t\treturn homeDir, nil\n\t}\n\texpandedPath := filepath.Clean(filepath.Join(homeDir, pathStr[2:]))\n\tabsPath, err := filepath.Abs(filepath.Join(homeDir, expandedPath))\n\tif err != nil || !strings.HasPrefix(absPath, homeDir) {\n\t\treturn \"\", fmt.Errorf(\"potential path traversal detected for path %s\", pathStr)\n\t}\n\treturn expandedPath, nil\n}\n\nfunc ExpandHomeDirSafe(pathStr string) string {\n\tpath, _ := ExpandHomeDir(pathStr)\n\treturn path\n}\n\nfunc ChunkSlice[T any](slice []T, chunkSize int) [][]T {\n\tif len(slice) == 0 {\n\t\treturn nil\n\t}\n\tchunks := make([][]T, 0)\n\tfor i := 0; i < len(slice); i += chunkSize {\n\t\tend := i + chunkSize\n\t\tif end > len(slice) {\n\t\t\tend = len(slice)\n\t\t}\n\t\tchunks = append(chunks, slice[i:end])\n\t}\n\treturn chunks\n}\n\nfunc OpenBrowser(url string, delay time.Duration) {\n\tif delay > 0 {\n\t\ttime.Sleep(delay)\n\t}\n\n\tvar cmd string\n\tvar args []string\n\n\tswitch runtime.GOOS {\n\tcase \"windows\":\n\t\tcmd = \"cmd\"\n\t\targs = []string{\"/c\", \"start\", url}\n\tcase \"darwin\":\n\t\tcmd = \"open\"\n\t\targs = []string{url}\n\tdefault: // \"linux\", \"freebsd\", \"openbsd\", \"netbsd\"\n\t\tcmd = \"xdg-open\"\n\t\targs = []string{url}\n\t}\n\n\texec.Command(cmd, args...).Start()\n}\n\nfunc GetTypedAtomValue[T any](rawVal any, atomName string) T {\n\tvar result T\n\tif rawVal == nil {\n\t\treturn *new(T)\n\t}\n\n\tvar ok bool\n\tresult, ok = rawVal.(T)\n\tif !ok {\n\t\t// Try converting from float64 if rawVal is float64\n\t\tif f64Val, isFloat64 := rawVal.(float64); isFloat64 {\n\t\t\tif converted, convOk := FromFloat64[T](f64Val); convOk {\n\t\t\t\treturn converted\n\t\t\t}\n\t\t}\n\t\tpanic(fmt.Sprintf(\"GetTypedAtomValue %q value type mismatch (expected %T, got %T)\", atomName, *new(T), rawVal))\n\t}\n\treturn result\n}\n\nvar (\n\tjsonMarshalerT = reflect.TypeOf((*json.Marshaler)(nil)).Elem()\n\ttextMarshalerT = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem()\n\ttimeType       = reflect.TypeOf(time.Time{})\n)\n\nfunc implementsJSON(t reflect.Type) bool {\n\tif t.Implements(jsonMarshalerT) || t.Implements(textMarshalerT) {\n\t\treturn true\n\t}\n\tif t.Kind() != reflect.Pointer {\n\t\tpt := reflect.PointerTo(t)\n\t\treturn pt.Implements(jsonMarshalerT) || pt.Implements(textMarshalerT)\n\t}\n\treturn false\n}\n\nfunc ValidateAtomType(t reflect.Type, atomName string) error {\n\tseen := make(map[reflect.Type]bool)\n\treturn validateAtomTypeRecursive(t, seen, atomName, \"\")\n}\n\nfunc makeAtomError(atomName string, parentName string, message string) error {\n\tif parentName != \"\" {\n\t\treturn fmt.Errorf(\"atom %s: in %s: %s\", atomName, parentName, message)\n\t}\n\treturn fmt.Errorf(\"atom %s: %s\", atomName, message)\n}\n\nfunc validateAtomTypeRecursive(t reflect.Type, seen map[reflect.Type]bool, atomName string, parentName string) error {\n\tif t == nil {\n\t\treturn makeAtomError(atomName, parentName, \"nil type\")\n\t}\n\n\tif seen[t] {\n\t\treturn nil\n\t}\n\tseen[t] = true\n\n\t// Check if type implements json.Marshaler or encoding.TextMarshaler\n\tif implementsJSON(t) {\n\t\treturn nil\n\t}\n\n\t// Allow time.Time explicitly\n\tif t == timeType {\n\t\treturn nil\n\t}\n\n\tswitch t.Kind() {\n\tcase reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,\n\t\treflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,\n\t\treflect.Float32, reflect.Float64, reflect.String:\n\t\treturn nil\n\n\tcase reflect.Ptr:\n\t\treturn validateAtomTypeRecursive(t.Elem(), seen, atomName, parentName)\n\n\tcase reflect.Array, reflect.Slice:\n\t\telemType := t.Elem()\n\t\t// Allow []any as a JSON value slot\n\t\tif elemType.Kind() == reflect.Interface && elemType.NumMethod() == 0 {\n\t\t\treturn nil\n\t\t}\n\t\treturn validateAtomTypeRecursive(elemType, seen, atomName, parentName)\n\n\tcase reflect.Map:\n\t\tif t.Key().Kind() != reflect.String {\n\t\t\treturn makeAtomError(atomName, parentName, fmt.Sprintf(\"map key must be string, got %s\", t.Key().Kind()))\n\t\t}\n\t\telemType := t.Elem()\n\t\t// Allow map[string]any as a JSON value slot\n\t\tif elemType.Kind() == reflect.Interface && elemType.NumMethod() == 0 {\n\t\t\treturn nil\n\t\t}\n\t\treturn validateAtomTypeRecursive(elemType, seen, atomName, parentName)\n\n\tcase reflect.Struct:\n\t\tstructName := t.Name()\n\t\tif structName == \"\" {\n\t\t\tstructName = \"anonymous struct\"\n\t\t}\n\t\tfor i := 0; i < t.NumField(); i++ {\n\t\t\tfield := t.Field(i)\n\t\t\tfieldPath := fmt.Sprintf(\"%s.%s\", structName, field.Name)\n\n\t\t\tif !field.IsExported() {\n\t\t\t\treturn makeAtomError(atomName, fieldPath, \"field is not exported (cannot round trip)\")\n\t\t\t}\n\n\t\t\t// Check for json:\"-\" tag\n\t\t\tif tag := field.Tag.Get(\"json\"); tag != \"\" {\n\t\t\t\tif name, _, _ := strings.Cut(tag, \",\"); name == \"-\" {\n\t\t\t\t\treturn makeAtomError(atomName, fieldPath, `field has json:\"-\" (breaks round trip)`)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif err := validateAtomTypeRecursive(field.Type, seen, atomName, fieldPath); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\n\tcase reflect.Interface:\n\t\t// Allow empty interface (any) as JSON value slot\n\t\tif t.NumMethod() == 0 {\n\t\t\treturn nil\n\t\t}\n\t\treturn makeAtomError(atomName, parentName, \"non-empty interface types are not JSON serializable (cannot round trip)\")\n\n\tcase reflect.Func, reflect.Chan, reflect.UnsafePointer, reflect.Uintptr, reflect.Complex64, reflect.Complex128:\n\t\treturn makeAtomError(atomName, parentName, fmt.Sprintf(\"type %s is not JSON serializable\", t.Kind()))\n\n\tdefault:\n\t\treturn makeAtomError(atomName, parentName, fmt.Sprintf(\"unsupported type %s\", t.Kind()))\n\t}\n}\n\ntype JsonFieldInfo struct {\n\tFieldName string\n\tOmitEmpty bool\n\tAsString  bool\n\tOptions   []string\n}\n\nfunc ParseJSONTag(field reflect.StructField) (JsonFieldInfo, bool) {\n\ttag := field.Tag.Get(\"json\")\n\n\t// Ignore field\n\tif tag == \"-\" {\n\t\treturn JsonFieldInfo{}, false\n\t}\n\n\tname := field.Name\n\tvar opts []string\n\tvar omitEmpty, asString bool\n\n\tif tag != \"\" {\n\t\tparts := strings.Split(tag, \",\")\n\t\tif parts[0] != \"\" {\n\t\t\tname = parts[0]\n\t\t}\n\t\tif len(parts) > 1 {\n\t\t\topts = parts[1:]\n\t\t\tfor _, opt := range opts {\n\t\t\t\tswitch opt {\n\t\t\t\tcase \"omitempty\":\n\t\t\t\t\tomitEmpty = true\n\t\t\t\tcase \"string\":\n\t\t\t\t\tasString = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn JsonFieldInfo{\n\t\tFieldName: name,\n\t\tOmitEmpty: omitEmpty,\n\t\tAsString:  asString,\n\t\tOptions:   opts,\n\t}, true\n}\n\n// TruncateString truncates a string to maxLen runes (not bytes).\n// If the string is longer than maxLen, it truncates to maxLen-3 and appends \"...\".\nfunc TruncateString(s string, maxLen int) string {\n\trunes := []rune(s)\n\tif len(runes) <= maxLen {\n\t\treturn s\n\t}\n\treturn string(runes[0:maxLen-3]) + \"...\"\n}\n"
  },
  {
    "path": "tsunami/vdom/vdom.go",
    "content": "// Copyright 2025, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage vdom\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\t\"strings\"\n)\n\n// ReactNode types = nil | string | Elem\n\ntype Component[P any] func(props P) *VDomElem\n\n// WithKey sets the key property of the VDomElem and returns the element.\n// This is particularly useful for defined components since their prop types won't include keys.\n// Returns nil if the element is nil, otherwise returns the same element for chaining.\nfunc (e *VDomElem) WithKey(key any) *VDomElem {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Props == nil {\n\t\te.Props = make(map[string]any)\n\t}\n\te.Props[KeyPropKey] = fmt.Sprint(key)\n\treturn e\n}\n\nfunc textElem(text string) VDomElem {\n\treturn VDomElem{Tag: TextTag, Text: text}\n}\n\nfunc partToClasses(class any) []string {\n\tif class == nil {\n\t\treturn nil\n\t}\n\tswitch c := class.(type) {\n\tcase string:\n\t\tif c != \"\" {\n\t\t\treturn []string{c}\n\t\t}\n\tcase []string:\n\t\tvar parts []string\n\t\tfor _, s := range c {\n\t\t\tif s != \"\" {\n\t\t\t\tparts = append(parts, s)\n\t\t\t}\n\t\t}\n\t\treturn parts\n\tcase map[string]bool:\n\t\tvar parts []string\n\t\tfor k, v := range c {\n\t\t\tif v && k != \"\" {\n\t\t\t\tparts = append(parts, k)\n\t\t\t}\n\t\t}\n\t\treturn parts\n\tcase []any:\n\t\tvar parts []string\n\t\tfor _, item := range c {\n\t\t\tparts = append(parts, partToClasses(item)...)\n\t\t}\n\t\treturn parts\n\t}\n\treturn nil\n}\n\n// Classes combines multiple class values into a single space-separated string.\n// Similar to the JavaScript clsx library, it accepts:\n//   - strings: added directly if non-empty\n//   - nil: ignored (useful for vdom.If() statements)\n//   - []string: all non-empty strings are added\n//   - map[string]bool: keys with true values are added\n//   - []any: recursively processed\n//\n// Returns a space-separated string of all valid class names.\nfunc Classes(classes ...any) string {\n\tvar parts []string\n\tfor _, class := range classes {\n\t\tparts = append(parts, partToClasses(class)...)\n\t}\n\treturn strings.Join(parts, \" \")\n}\n\n// H creates a VDomElem with the specified tag, properties, and children.\n// This is the primary function for creating virtual DOM elements.\n// Children can be strings, VDomElems, *VDomElem, slices, booleans, numeric types,\n// or other types which are converted to strings using fmt.Sprint.\n// nil children are allowed and removed from the final list.\nfunc H(tag string, props map[string]any, children ...any) *VDomElem {\n\trtn := &VDomElem{Tag: tag, Props: props}\n\tif len(children) > 0 {\n\t\tfor _, part := range children {\n\t\t\telems := ToElems(part)\n\t\t\trtn.Children = append(rtn.Children, elems...)\n\t\t}\n\t}\n\treturn rtn\n}\n\n// If returns the provided part if the condition is true, otherwise returns nil.\n// This is useful for conditional rendering in VDOM children lists, props, and style attributes.\nfunc If(cond bool, part any) any {\n\tif cond {\n\t\treturn part\n\t}\n\treturn nil\n}\n\n// IfElse returns part if the condition is true, otherwise returns elsePart.\n// This provides ternary-like conditional logic for VDOM children, props, and attributes.\n// Accepts mixed types - part and elsePart don't need to be the same type, which is especially useful for children.\nfunc IfElse(cond bool, part any, elsePart any) any {\n\tif cond {\n\t\treturn part\n\t}\n\treturn elsePart\n}\n\n// Ternary returns trueRtn if the condition is true, otherwise returns falseRtn.\n// Unlike IfElse, this enforces type safety by requiring both return values to be the same type T.\nfunc Ternary[T any](cond bool, trueRtn T, falseRtn T) T {\n\tif cond {\n\t\treturn trueRtn\n\t} else {\n\t\treturn falseRtn\n\t}\n}\n\n// ForEach applies a function to each item in a slice and returns a slice of results.\n// The function receives the item and its index, and can return any type for flexible VDOM generation.\nfunc ForEach[T any](items []T, fn func(T, int) any) []any {\n\telems := make([]any, 0, len(items))\n\tfor idx, item := range items {\n\t\tfnResult := fn(item, idx)\n\t\telems = append(elems, fnResult)\n\t}\n\treturn elems\n}\n\n// ToElems converts various types into VDomElem slices for use in VDOM children.\n// It handles strings, booleans, VDomElems, *VDomElem, slices, and other types\n// by converting them to appropriate VDomElem representations.\n// nil values are ignored and removed from the final slice.\n// This is primarily an internal function and not typically called directly by application code.\nfunc ToElems(part any) []VDomElem {\n\tif part == nil {\n\t\treturn nil\n\t}\n\tswitch partTyped := part.(type) {\n\tcase string:\n\t\treturn []VDomElem{textElem(partTyped)}\n\tcase bool:\n\t\t// matches react\n\t\tif partTyped {\n\t\t\treturn []VDomElem{textElem(\"true\")}\n\t\t}\n\t\treturn nil\n\tcase VDomElem:\n\t\treturn []VDomElem{partTyped}\n\tcase *VDomElem:\n\t\tif partTyped == nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn []VDomElem{*partTyped}\n\tdefault:\n\t\tpartVal := reflect.ValueOf(part)\n\t\tif partVal.Kind() == reflect.Slice {\n\t\t\tvar rtn []VDomElem\n\t\t\tfor i := 0; i < partVal.Len(); i++ {\n\t\t\t\trtn = append(rtn, ToElems(partVal.Index(i).Interface())...)\n\t\t\t}\n\t\t\treturn rtn\n\t\t}\n\t\treturn []VDomElem{textElem(fmt.Sprint(part))}\n\t}\n}\n"
  },
  {
    "path": "tsunami/vdom/vdom_test.go",
    "content": "package vdom\n\nimport (\n\t\"encoding/json\"\n\t\"log\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/wavetermdev/waveterm/tsunami/util\"\n)\n\nfunc TestH(t *testing.T) {\n\telem := H(\"div\", nil, \"clicked\")\n\tjsonBytes, _ := json.MarshalIndent(elem, \"\", \"  \")\n\tlog.Printf(\"%s\\n\", string(jsonBytes))\n\n\telem = H(\"div\", nil, \"clicked\")\n\tjsonBytes, _ = json.MarshalIndent(elem, \"\", \"  \")\n\tlog.Printf(\"%s\\n\", string(jsonBytes))\n\n\telem = H(\"Button\", nil, \"foo\")\n\tjsonBytes, _ = json.MarshalIndent(elem, \"\", \"  \")\n\tlog.Printf(\"%s\\n\", string(jsonBytes))\n\n\tclickFn := \"test-click-function\"\n\tclickedDiv := H(\"div\", nil, \"test-content\")\n\telem = H(\"div\", nil,\n\t\tH(\"h1\", nil, \"hello world\"),\n\t\tH(\"Button\", map[string]any{\"onClick\": clickFn}, \"hello\"),\n\t\tclickedDiv,\n\t)\n\tjsonBytes, _ = json.MarshalIndent(elem, \"\", \"  \")\n\tlog.Printf(\"%s\\n\", string(jsonBytes))\n}\n\nfunc TestJsonH(t *testing.T) {\n\telem := H(\"div\", map[string]any{\n\t\t\"data1\": 5,\n\t\t\"data2\": []any{1, 2, 3},\n\t\t\"data3\": map[string]any{\"a\": 1},\n\t})\n\tif elem == nil {\n\t\tt.Fatalf(\"elem is nil\")\n\t}\n\tif elem.Tag != \"div\" {\n\t\tt.Fatalf(\"elem.Tag: %s (expected 'div')\\n\", elem.Tag)\n\t}\n\tif elem.Props == nil || len(elem.Props) != 3 {\n\t\tt.Fatalf(\"elem.Props: %v\\n\", elem.Props)\n\t}\n\tdata1Val, ok := elem.Props[\"data1\"]\n\tif !ok {\n\t\tt.Fatalf(\"data1 not found\\n\")\n\t}\n\t_, ok = data1Val.(float64)\n\tif !ok {\n\t\tt.Fatalf(\"data1: %T\\n\", data1Val)\n\t}\n\tdata1Int, ok := util.ToInt(data1Val)\n\tif !ok || data1Int != 5 {\n\t\tt.Fatalf(\"data1: %v\\n\", data1Val)\n\t}\n\tdata2Val, ok := elem.Props[\"data2\"]\n\tif !ok {\n\t\tt.Fatalf(\"data2 not found\\n\")\n\t}\n\td2type := reflect.TypeOf(data2Val)\n\tif d2type.Kind() != reflect.Slice {\n\t\tt.Fatalf(\"data2: %T\\n\", data2Val)\n\t}\n\tdata2Arr := data2Val.([]any)\n\tif len(data2Arr) != 3 {\n\t\tt.Fatalf(\"data2: %v\\n\", data2Val)\n\t}\n\td2v2, ok := data2Arr[1].(float64)\n\tif !ok || d2v2 != 2 {\n\t\tt.Fatalf(\"data2: %v\\n\", data2Val)\n\t}\n\tdata3Val, ok := elem.Props[\"data3\"]\n\tif !ok || data3Val == nil {\n\t\tt.Fatalf(\"data3 not found\\n\")\n\t}\n\td3type := reflect.TypeOf(data3Val)\n\tif d3type.Kind() != reflect.Map {\n\t\tt.Fatalf(\"data3: %T\\n\", data3Val)\n\t}\n\tdata3Map := data3Val.(map[string]any)\n\tif len(data3Map) != 1 {\n\t\tt.Fatalf(\"data3: %v\\n\", data3Val)\n\t}\n\td3v1, ok := data3Map[\"a\"]\n\tif !ok {\n\t\tt.Fatalf(\"data3: %v\\n\", data3Val)\n\t}\n\tmval, ok := util.ToInt(d3v1)\n\tif !ok || mval != 1 {\n\t\tt.Fatalf(\"data3: %v\\n\", data3Val)\n\t}\n\tlog.Printf(\"elem: %v\\n\", elem)\n}\n"
  },
  {
    "path": "tsunami/vdom/vdom_types.go",
    "content": "// Copyright 2026, Command Line Inc.\n// SPDX-License-Identifier: Apache-2.0\n\npackage vdom\n\nimport (\n\t\"encoding/json\"\n\t\"sync/atomic\"\n)\n\nconst TextTag = \"#text\"\nconst WaveTextTag = \"wave:text\"\nconst WaveNullTag = \"wave:null\"\nconst FragmentTag = \"#fragment\"\n\nconst KeyPropKey = \"key\"\n\nconst ObjectType_Ref = \"ref\"\nconst ObjectType_Func = \"func\"\n\n// vdom element\ntype VDomElem struct {\n\tTag      string         `json:\"tag\"`\n\tProps    map[string]any `json:\"props,omitempty\"`\n\tChildren []VDomElem     `json:\"children,omitempty\"`\n\tText     string         `json:\"text,omitempty\"`\n}\n\n// used in props\ntype VDomFunc struct {\n\tFn              any      `json:\"-\"` // server side function (called with reflection)\n\tType            string   `json:\"type\" tstype:\"\\\"func\\\"\"`\n\tStopPropagation bool     `json:\"stoppropagation,omitempty\"` // set to call e.stopPropagation() on the client side\n\tPreventDefault  bool     `json:\"preventdefault,omitempty\"`  // set to call e.preventDefault() on the client side\n\tGlobalEvent     string   `json:\"globalevent,omitempty\"`\n\tKeys            []string `json:\"keys,omitempty\"` // special for keyDown events a list of keys to \"capture\"\n}\n\n// used in props\ntype VDomRef struct {\n\tType          string           `json:\"type\" tstype:\"\\\"ref\\\"\"`\n\tRefId         string           `json:\"refid\"`\n\tTrackPosition bool             `json:\"trackposition,omitempty\"`\n\tPosition      *VDomRefPosition `json:\"-\"`\n\tHasCurrent    atomic.Bool      `json:\"-\"`\n\tTermSize      *VDomTermSize    `json:\"-\"`\n}\n\nfunc (r *VDomRef) MarshalJSON() ([]byte, error) {\n\ttype vdomRefAlias struct {\n\t\tType          string           `json:\"type\"`\n\t\tRefId         string           `json:\"refid\"`\n\t\tTrackPosition bool             `json:\"trackposition,omitempty\"`\n\t\tHasCurrent    bool             `json:\"hascurrent,omitempty\"`\n\t}\n\treturn json.Marshal(vdomRefAlias{\n\t\tType:          r.Type,\n\t\tRefId:         r.RefId,\n\t\tTrackPosition: r.TrackPosition,\n\t\tHasCurrent:    r.HasCurrent.Load(),\n\t})\n}\n\nfunc (r *VDomRef) UnmarshalJSON(data []byte) error {\n\ttype vdomRefAlias struct {\n\t\tType          string           `json:\"type\"`\n\t\tRefId         string           `json:\"refid\"`\n\t\tTrackPosition bool             `json:\"trackposition,omitempty\"`\n\t\tHasCurrent    bool             `json:\"hascurrent,omitempty\"`\n\t}\n\tvar alias vdomRefAlias\n\tif err := json.Unmarshal(data, &alias); err != nil {\n\t\treturn err\n\t}\n\tr.Type = alias.Type\n\tr.RefId = alias.RefId\n\tr.TrackPosition = alias.TrackPosition\n\tr.HasCurrent.Store(alias.HasCurrent)\n\treturn nil\n}\n\ntype VDomSimpleRef[T any] struct {\n\tCurrent T `json:\"current\"`\n}\n\ntype DomRect struct {\n\tTop    float64 `json:\"top\"`\n\tLeft   float64 `json:\"left\"`\n\tRight  float64 `json:\"right\"`\n\tBottom float64 `json:\"bottom\"`\n\tWidth  float64 `json:\"width\"`\n\tHeight float64 `json:\"height\"`\n}\n\ntype VDomRefPosition struct {\n\tOffsetHeight       int     `json:\"offsetheight\"`\n\tOffsetWidth        int     `json:\"offsetwidth\"`\n\tScrollHeight       int     `json:\"scrollheight\"`\n\tScrollWidth        int     `json:\"scrollwidth\"`\n\tScrollTop          int     `json:\"scrolltop\"`\n\tBoundingClientRect DomRect `json:\"boundingclientrect\"`\n}\n\ntype VDomTermInputData struct {\n\tTermSize *VDomTermSize `json:\"termsize,omitempty\"`\n\tData     string        `json:\"data,omitempty\"`\n}\n\ntype VDomTermSize struct {\n\tRows int `json:\"rows\"`\n\tCols int `json:\"cols\"`\n}\n\ntype VDomEvent struct {\n\tWaveId          string             `json:\"waveid\"`\n\tEventType       string             `json:\"eventtype\"` // usually the prop name (e.g. onClick, onKeyDown)\n\tGlobalEventType string             `json:\"globaleventtype,omitempty\"`\n\tTargetValue     string             `json:\"targetvalue,omitempty\"`   // set for onChange events on input/textarea/select\n\tTargetChecked   bool               `json:\"targetchecked,omitempty\"` // set for onChange events on checkbox/radio inputs\n\tTargetName      string             `json:\"targetname,omitempty\"`    // target element's name attribute\n\tTargetId        string             `json:\"targetid,omitempty\"`      // target element's id attribute\n\tTargetFiles     []VDomFileData     `json:\"targetfiles,omitempty\"`   // set for onChange events on file inputs\n\tKeyData         *VDomKeyboardEvent `json:\"keydata,omitempty\"`       // set for onKeyDown events\n\tMouseData       *VDomPointerData   `json:\"mousedata,omitempty\"`     // set for onClick, onMouseDown, onMouseUp, onDoubleClick events\n\tFormData        *VDomFormData      `json:\"formdata,omitempty\"`      // set for onSubmit events on forms\n\tTermInput       *VDomTermInputData `json:\"terminput,omitempty\"`     // set for onData events on wave:term elements\n}\n\ntype VDomKeyboardEvent struct {\n\tType     string `json:\"type\" tstype:\"\\\"keydown\\\"|\\\"keyup\\\"|\\\"keypress\\\"|\\\"unknown\\\"\"`\n\tKey      string `json:\"key\"`  // KeyboardEvent.key\n\tCode     string `json:\"code\"` // KeyboardEvent.code\n\tRepeat   bool   `json:\"repeat,omitempty\"`\n\tLocation int    `json:\"location,omitempty\"` // KeyboardEvent.location\n\n\t// modifiers\n\tShift   bool `json:\"shift,omitempty\"`\n\tControl bool `json:\"control,omitempty\"`\n\tAlt     bool `json:\"alt,omitempty\"`\n\tMeta    bool `json:\"meta,omitempty\"`\n\tCmd     bool `json:\"cmd,omitempty\"`    // special (on mac it is meta, on windows/linux it is alt)\n\tOption  bool `json:\"option,omitempty\"` // special (on mac it is alt, on windows/linux it is meta)\n}\n\ntype VDomPointerData struct {\n\tButton  int `json:\"button\"`\n\tButtons int `json:\"buttons\"`\n\n\tClientX   int `json:\"clientx,omitempty\"`\n\tClientY   int `json:\"clienty,omitempty\"`\n\tPageX     int `json:\"pagex,omitempty\"`\n\tPageY     int `json:\"pagey,omitempty\"`\n\tScreenX   int `json:\"screenx,omitempty\"`\n\tScreenY   int `json:\"screeny,omitempty\"`\n\tMovementX int `json:\"movementx,omitempty\"`\n\tMovementY int `json:\"movementy,omitempty\"`\n\n\t// Modifiers\n\tShift   bool `json:\"shift,omitempty\"`\n\tControl bool `json:\"control,omitempty\"`\n\tAlt     bool `json:\"alt,omitempty\"`\n\tMeta    bool `json:\"meta,omitempty\"`\n\tCmd     bool `json:\"cmd,omitempty\"`    // special (on mac it is meta, on windows/linux it is alt)\n\tOption  bool `json:\"option,omitempty\"` // special (on mac it is alt, on windows/linux it is meta)\n}\n\ntype VDomFormData struct {\n\tAction   string                    `json:\"action,omitempty\"`\n\tMethod   string                    `json:\"method\"`\n\tEnctype  string                    `json:\"enctype\"`\n\tFormId   string                    `json:\"formid,omitempty\"`\n\tFormName string                    `json:\"formname,omitempty\"`\n\tFields   map[string][]string       `json:\"fields\"`\n\tFiles    map[string][]VDomFileData `json:\"files\"`\n}\n\nfunc (f *VDomFormData) GetField(fieldName string) string {\n\tif f.Fields == nil {\n\t\treturn \"\"\n\t}\n\tvalues := f.Fields[fieldName]\n\tif len(values) == 0 {\n\t\treturn \"\"\n\t}\n\treturn values[0]\n}\n\ntype VDomFileData struct {\n\tFieldName string `json:\"fieldname\"`\n\tName      string `json:\"name\"`\n\tSize      int64  `json:\"size\"`\n\tType      string `json:\"type\"`\n\tData64    []byte `json:\"data64,omitempty\"`\n\tError     string `json:\"error,omitempty\"`\n}\n\ntype VDomRefOperation struct {\n\tRefId     string `json:\"refid\"`\n\tOp        string `json:\"op\"`\n\tParams    []any  `json:\"params,omitempty\"`\n\tOutputRef string `json:\"outputref,omitempty\"`\n}\n"
  },
  {
    "path": "version.cjs",
    "content": "/**\n * Script to get the current package version and bump the version, if specified.\n *\n * If no arguments are present, the current version will returned.\n * If only a single argument is given, the following are valid inputs:\n *      - `none`: No-op.\n *      - `patch`: Bumps the patch version.\n *      - `minor`: Bumps the minor version.\n *      - `major`: Bumps the major version.\n *      - '1', 'true': Bumps the prerelease version.\n * If two arguments are given, the following are valid inputs for the first argument:\n *      - `none`: No-op.\n *      - `patch`: Bumps the patch version.\n *      - `minor`: Bumps the minor version.\n *      - `major`: Bumps the major version.\n * The following are valid inputs for the second argument:\n *      - `0`, 'false': The release is not a prerelease, will remove any prerelease identifier from the version, if one was present.\n *      - '1', 'true': The release is a prerelease (any value other than `0` or `false` will be interpreted as `true`).\n */\n\nconst path = require(\"path\");\nconst packageJsonPath = path.resolve(__dirname, \"package.json\");\nconst packageJson = require(packageJsonPath);\n\nconst VERSION = `${packageJson.version}`;\nmodule.exports = VERSION;\n\nif (typeof require !== \"undefined\" && require.main === module) {\n    if (process.argv.length > 2) {\n        const fs = require(\"fs\");\n        const semver = require(\"semver\");\n\n        let action = process.argv[2];\n\n        // If prerelease argument is not explicitly set, mark it as undefined.\n        const isPrerelease =\n            process.argv.length > 3\n                ? process.argv[3] !== \"false\" && process.argv[3] !== \"0\"\n                : action === \"true\" || action === \"1\"\n                  ? true\n                  : undefined;\n\n        // This will remove the prerelease version string (i.e. 0.1.13-beta.1 -> 0.1.13) if the arguments are `none 0` and the current version is a prerelease.\n        if (action === \"none\" && isPrerelease === false && semver.prerelease(VERSION)) {\n            action = \"patch\";\n        }\n\n        let newVersion = packageJson.version;\n        switch (action) {\n            case \"major\":\n            case \"minor\":\n            case \"patch\":\n                newVersion = semver.inc(\n                    VERSION,\n                    `${isPrerelease ? \"pre\" : \"\"}${action}`,\n                    null,\n                    isPrerelease ? \"beta\" : null\n                );\n                break;\n            case \"none\":\n            case \"true\":\n            case \"1\":\n                if (isPrerelease) newVersion = semver.inc(VERSION, \"prerelease\", null, \"beta\");\n                break;\n            default:\n                throw new Error(`Unknown action ${action}`);\n        }\n        packageJson.version = newVersion;\n        fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 4) + \"\\n\");\n        console.log(newVersion);\n    } else {\n        console.log(VERSION);\n    }\n}\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "import { UserConfig, defineConfig, mergeConfig } from \"vitest/config\";\nimport electronViteConfig from \"./electron.vite.config\";\n\nexport default mergeConfig(\n    electronViteConfig.renderer as UserConfig,\n    defineConfig({\n        test: {\n            reporters: [\"verbose\", \"junit\"],\n            outputFile: {\n                junit: \"test-results.xml\",\n            },\n            coverage: {\n                provider: \"istanbul\",\n                reporter: [\"lcov\"],\n                reportsDirectory: \"./coverage\",\n            },\n            typecheck: {\n                tsconfig: \"tsconfig.json\",\n            },\n        },\n    })\n);\n"
  }
]