diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml new file mode 100644 index 0000000000..a40e424966 --- /dev/null +++ b/.github/workflows/build-macos.yml @@ -0,0 +1,54 @@ +name: Build macOS +on: + workflow_dispatch: + +env: + GO_VERSION: "1.26.2" + NODE_VERSION: 22 + NODE_OPTIONS: --max-old-space-size=4096 + +jobs: + build: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Go + run: | + curl -sL "https://go.dev/dl/go${{ env.GO_VERSION }}.darwin-arm64.tar.gz" | tar -xzf - -C . + mv go golang-${{ env.GO_VERSION }} + echo "module golang" > golang-${{ env.GO_VERSION }}/go.mod + + - name: Install Zig + run: | + curl -sL "https://ziglang.org/download/0.14.0/zig-macos-aarch64-0.14.0.tar.xz" | tar -xJf - + mv zig-macos-aarch64-0.14.0 zig-0.14.0 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: package-lock.json + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install deps + run: npm ci --no-audit --no-fund + env: + GIT_ASKPASS: "echo" + GIT_TERMINAL_PROMPT: "0" + + - name: Build + run: task package + env: + USE_SYSTEM_FPM: true + + - name: Upload artifacts + uses: actions/upload-artifact@v5 + with: + name: macos-build + path: make/ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 30a8979b9b..9adf8336d1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,25 +12,7 @@ name: "CodeQL" on: - push: - branches: ["main"] - paths: - - "**/*.go" - - "**/*.ts" - - "**/*.tsx" - pull_request: - branches: ["main"] - paths: - - "**/*.go" - - "**/*.ts" - - "**/*.tsx" - types: - - opened - - synchronize - - reopened - - ready_for_review - schedule: - - cron: "36 5 * * 5" + workflow_dispatch: env: NODE_VERSION: 22 diff --git a/.github/workflows/deploy-docsite.yml b/.github/workflows/deploy-docsite.yml deleted file mode 100644 index 092b024cb5..0000000000 --- a/.github/workflows/deploy-docsite.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Docsite CI/CD - -run-name: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'Build and Deploy' || 'Test Build' }} Docsite - -env: - NODE_VERSION: 22 - -on: - push: - branches: - - main - workflow_dispatch: - # Also run any time a PR is opened targeting the docs - pull_request: - branches: - - main - types: - - opened - - synchronize - - reopened - - ready_for_review - paths: - - "docs/**" - - ".github/workflows/deploy-docsite.yml" - - "Taskfile.yml" - -jobs: - build: - name: Build Docsite - runs-on: ubuntu-latest - if: github.event.pull_request.draft == false - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - uses: actions/setup-node@v6 - with: - node-version: ${{env.NODE_VERSION}} - cache: npm - cache-dependency-path: package-lock.json - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - uses: nick-fields/retry@v4 - name: npm ci - with: - command: npm ci --no-audit --no-fund - retry_on: error - max_attempts: 3 - timeout_minutes: 5 - - name: Build docsite - run: task docsite:build:public - - name: Upload Build Artifact - # Only upload the build artifact when pushed to the main branch - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v4 - with: - path: docs/build - deploy: - name: Deploy to GitHub Pages - # Only deploy when pushed to the main branch - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: build - # Grant GITHUB_TOKEN the permissions required to make a Pages deployment - permissions: - pages: write # to deploy to Pages - id-token: write # to verify the deployment originates from an appropriate source - - # Deploy to the github-pages environment - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - runs-on: ubuntu-latest - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5 diff --git a/.github/workflows/testdriver-build.yml b/.github/workflows/testdriver-build.yml deleted file mode 100644 index da190073e6..0000000000 --- a/.github/workflows/testdriver-build.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: TestDriver.ai Build - -on: - push: - branches: - - main - tags: - - "v[0-9]+.[0-9]+.[0-9]+*" - pull_request: - # branches: - # - main - # paths-ignore: - # - "docs/**" - # - ".storybook/**" - # - ".vscode/**" - # - ".editorconfig" - # - ".gitignore" - # - ".prettierrc" - # - ".eslintrc.js" - # - "**/*.md" - types: - - opened - - synchronize - - reopened - - ready_for_review - schedule: - - cron: 0 21 * * * - workflow_dispatch: null - -env: - GO_VERSION: "1.25.6" - NODE_VERSION: 22 - -permissions: - contents: read # To allow the action to read repository contents - pull-requests: write # To allow the action to create/update pull request comments - -jobs: - build_and_upload: - name: Build for TestDriver.ai - runs-on: windows-latest - if: github.event.pull_request.draft == false - steps: - - uses: actions/checkout@v6 - - # General build dependencies - - uses: actions/setup-go@v6 - with: - go-version: ${{env.GO_VERSION}} - - uses: actions/setup-node@v6 - with: - node-version: ${{env.NODE_VERSION}} - cache: npm - cache-dependency-path: package-lock.json - - uses: nick-fields/retry@v4 - name: npm ci - with: - command: npm ci --no-audit --no-fund - retry_on: error - max_attempts: 3 - timeout_minutes: 5 - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install Zig - uses: mlugg/setup-zig@v2 - - - name: Build - run: task package - env: - USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. - CSC_IDENTITY_AUTO_DISCOVERY: false # disable codesign - shell: powershell # electron-builder's Windows code signing package has some compatibility issues with pwsh, so we need to use Windows Powershell - - # Upload .exe as an artifact - - name: Upload .exe artifact - id: upload - uses: actions/upload-artifact@v5 - with: - name: windows-exe - path: make/*.exe diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml deleted file mode 100644 index 9d51ec7659..0000000000 --- a/.github/workflows/testdriver.yml +++ /dev/null @@ -1,141 +0,0 @@ -name: TestDriver.ai Run - -on: - workflow_run: - workflows: ["TestDriver.ai Build"] - types: - - completed - -env: - GO_VERSION: "1.25.6" - NODE_VERSION: 22 - -permissions: - contents: read - statuses: write - -jobs: - context: - runs-on: ubuntu-22.04 - steps: - - name: Dump GitHub context - env: - GITHUB_CONTEXT: ${{ toJson(github) }} - run: echo "$GITHUB_CONTEXT" - - name: Dump job context - env: - JOB_CONTEXT: ${{ toJson(job) }} - run: echo "$JOB_CONTEXT" - - name: Dump steps context - env: - STEPS_CONTEXT: ${{ toJson(steps) }} - run: echo "$STEPS_CONTEXT" - - name: Dump runner context - env: - RUNNER_CONTEXT: ${{ toJson(runner) }} - run: echo "$RUNNER_CONTEXT" - - name: Dump strategy context - env: - STRATEGY_CONTEXT: ${{ toJson(strategy) }} - run: echo "$STRATEGY_CONTEXT" - - name: Dump matrix context - env: - MATRIX_CONTEXT: ${{ toJson(matrix) }} - run: echo "$MATRIX_CONTEXT" - run_testdriver: - name: Run TestDriver.ai - runs-on: windows-latest - if: github.event.workflow_run.conclusion == 'success' - steps: - - uses: testdriverai/action@main - id: testdriver - env: - FORCE_COLOR: "3" - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - key: ${{ secrets.DASHCAM_API }} - prerun: | - $headers = @{ - Authorization = "token ${{ secrets.GITHUB_TOKEN }}" - } - - $downloadFolder = "./download" - $artifactFileName = "waveterm.exe" - $artifactFilePath = "$downloadFolder/$artifactFileName" - - Write-Host "Starting the artifact download process..." - - # Create the download directory if it doesn't exist - if (-not (Test-Path -Path $downloadFolder)) { - Write-Host "Creating download folder..." - mkdir $downloadFolder - } else { - Write-Host "Download folder already exists." - } - - # Fetch the artifact upload URL - Write-Host "Fetching the artifact upload URL..." - $artifactUrl = (Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}/artifacts" -Headers $headers).artifacts[0].archive_download_url - - if ($artifactUrl) { - Write-Host "Artifact URL successfully fetched: $artifactUrl" - } else { - Write-Error "Failed to fetch the artifact URL." - exit 1 - } - - # Download the artifact (zipped file) - Write-Host "Starting artifact download..." - $artifactZipPath = "$env:TEMP\artifact.zip" - try { - Invoke-WebRequest -Uri $artifactUrl ` - -Headers $headers ` - -OutFile $artifactZipPath ` - -MaximumRedirection 5 - - Write-Host "Artifact downloaded successfully to $artifactZipPath" - } catch { - Write-Error "Error downloading artifact: $_" - exit 1 - } - - # Unzip the artifact - $artifactUnzipPath = "$env:TEMP\artifact" - Write-Host "Unzipping the artifact to $artifactUnzipPath..." - try { - Expand-Archive -Path $artifactZipPath -DestinationPath $artifactUnzipPath -Force - Write-Host "Artifact unzipped successfully to $artifactUnzipPath" - } catch { - Write-Error "Failed to unzip the artifact: $_" - exit 1 - } - - # Find the installer or app executable - $artifactInstallerPath = Get-ChildItem -Path $artifactUnzipPath -Filter *.exe -Recurse | Select-Object -First 1 - - if ($artifactInstallerPath) { - Write-Host "Executable file found: $($artifactInstallerPath.FullName)" - } else { - Write-Error "Executable file not found. Exiting." - exit 1 - } - - # Run the installer and log the result - Write-Host "Running the installer: $($artifactInstallerPath.FullName)..." - try { - Start-Process -FilePath $artifactInstallerPath.FullName -Wait - Write-Host "Installer ran successfully." - } catch { - Write-Error "Failed to run the installer: $_" - exit 1 - } - - # Optional: If the app executable is different from the installer, find and launch it - $wavePath = Join-Path $env:USERPROFILE "AppData\Local\Programs\waveterm\Wave.exe" - - Write-Host "Launching the application: $($wavePath)" - Start-Process -FilePath $wavePath - Write-Host "Application launched." - - prompt: | - 1. /run testdriver/onboarding.yml diff --git a/.gitignore b/.gitignore index 7bd717e540..e9327e60dc 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ docsite/ .superpowers docs/superpowers .claude +golang-*/ +zig-*/ +.pi/delegate-results/ diff --git a/.pi/context.md b/.pi/context.md new file mode 100644 index 0000000000..c0f8aa764a --- /dev/null +++ b/.pi/context.md @@ -0,0 +1,50 @@ +# Project Context + +## Problem Statement + +Most modern terminals and developer tools assume a **local-first workflow**: +- Code lives on the local machine +- Build tools run locally +- Terminal access is to local shell or occasional remote SSH sessions +- AI assistants analyze local files and local terminal output + +This doesn't match how many developers actually work: +- Code lives on remote servers, cloud VMs, or containers +- Builds happen remotely (CI/CD, remote compile farms) +- The developer's machine is primarily a thin client +- Network connectivity is the primary bottleneck, not local CPU + +## What This Fork Targets + +A terminal where **remote is the default**, not an afterthought: +- SSH connections are first-class, not a plugin +- Port forwarding is automatic from SSH config +- File editing on remote machines feels as seamless as local +- Durable sessions survive network interruptions gracefully +- The terminal understands remote context (which host, which directory, which project) +- Local resources (AI, file previews) enhance remote work rather than competing with it + +## What's Different from Upstream + +Wave Terminal already has excellent SSH and durable session support. This fork will: + +| Area | Upstream Wave | This Fork | +|------|---------------|-----------| +| **Port forwarding** | Not supported from SSH config | Automatic from `~/.ssh/config` | +| **Local-first features** | AI, widgets, file previews for local | Evaluate which to keep/diminish | +| **Remote context** | Basic | Potentially enhanced (host-aware prompts, etc.) | +| **UI chrome** | Full Wave branding/chrome | Potentially stripped for remote-dev focus | + +## Non-Goals + +- Rebuild from scratch — this is a fork, not a rewrite +- Remove all local features indiscriminately — evaluate usefulness +- Compete with upstream — this is a specialized variant + +## Target User + +Developers who: +- Spend >50% of terminal time in SSH sessions +- Have multiple persistent remote development environments +- Use `~/.ssh/config` extensively for host/forwarding configuration +- Want a terminal that treats remote machines as "primary" workspaces diff --git a/.pi/decisions.md b/.pi/decisions.md new file mode 100644 index 0000000000..ece40128de --- /dev/null +++ b/.pi/decisions.md @@ -0,0 +1,189 @@ +# Architecture Decisions + +## 2026-05-10: Fork Purpose + +**Decision:** Fork Wave Terminal to create a remote-development-optimized variant. + +**Context:** Most terminals assume local-first workflows. This fork treats remote SSH environments as primary workspaces. + +**Consequences:** +- Upstream remains the base; we merge regularly +- Features evaluated against "remote-first" usefulness +- Local-first features may be removed/diminished if they conflict with remote workflow + +## 2026-05-10: `.pi/` as Planning Hub + +**Decision:** Use `.pi/` directory for all fork planning, specs, and agent context. + +**Context:** Keeps planning centralized and agent-accessible without cluttering the root or public docs. + +**Files:** +- `.pi/index.md` — entry point +- `.pi/context.md` — project background +- `.pi/todos.md` — active tasks +- `.pi/decisions.md` — this file +- `.pi/specs/` — feature specifications + +## 2026-05-10: Port Forwarding — Config-First Approach + +**Decision:** Implement `LocalForward`/`RemoteForward` from `~/.ssh/config` and `connections.json`, not CLI flags. + +**Context:** SSH config is the standard place developers already define forwarding rules. Making Wave respect them is the least-surprise approach. + +**Approach:** +1. Parse `LocalForward`/`RemoteForward` in `findSshConfigKeywords()` +2. Add to `ConnKeywords` struct +3. Return merged keywords from `ConnectToClient()` +4. Start forwarding goroutines in `SSHConn.connectInternal()` +5. Clean up listeners in `closeInternal_withlifecyclelock()` + +**Deferred:** +- `DynamicForward` (needs SOCKS5 handler) +- CLI flags on `wsh ssh` (can add later) +- UI status indicator + +## 2026-05-14: Tab-Close Crash — Root Cause Found & Fixed + +**Decision:** Remove redundant `DestroyBlockController` goroutine from `CloseTab`; add `sync.Once` to `ShellProc.Close()` as defense-in-depth. + +**Context:** Investigation confirmed a race where `CloseTab` explicitly launched `DestroyBlockController` in a goroutine while `DeleteTab` → `DeleteBlock` → `BlockCloseEvent` triggered the same destruction again. This caused concurrent double-`Stop` on `ShellController` (with its Lock/Unlock/Relock window) and `DurableShellController` (which has no lock), leading to double `Session.Close()` / double `TerminateAndDetachJob`. + +**Fix applied:** +1. `pkg/service/workspaceservice/workspaceservice.go` — removed the explicit `go DestroyBlockController()` loop; `DeleteTab` already triggers cleanup via events. +2. `pkg/shellexec/shellexec.go` — added `closeOnce sync.Once` to `ShellProc` and wrapped `Close()` in `sp.closeOnce.Do`, preventing double `KillGraceful` / double goroutine spawn even if two Stops race. +3. Added trace logging to `CloseTab`, `DestroyBlockController`, `ShellController.Stop`, `DurableShellController.Stop`, `handleBlockCloseEvent` for interactive diagnosis. +4. Fixed 2 test-code panics (manual `close` of channel already closed by mock `KillGraceful`). + +**Consequences:** +- `CloseTab` now has a single cleanup path: `DeleteTab` → `DeleteBlock` → event → `DestroyBlockController` +- `ShellProc.Close()` is idempotent; any future code path that calls it twice is safe +- 14 unit tests pass under `-race` + +## 2026-05-12: Secret Store — Keep + +**Decision:** Keep the secret store infrastructure; it's not AI-specific. + +**Context:** The secret store (`pkg/secretstore/`) is an encrypted key-value store backed by the OS keychain. It has three consumers: +1. **AI API tokens** (`ai:apitokensecretname`) — going away with AI removal +2. **SSH password auth** (`ssh:passwordsecretname`) — stays, useful for password-authenticated hosts +3. **Wave App Store** — stays, general-purpose + +**Consequences:** +- Remove `ai:apitokensecretname` field from `ConnKeywords` as part of AI cleanup +- Keep `pkg/secretstore/`, `wsh secret` CLI, and `ssh:passwordsecretname` intact +- Lightweight general infrastructure; useful for future features (e.g., file transfer credentials) + +## 2026-05-15: Claude Code Shell Integration — Analysis for Future Pi Agent Support + +**Finding:** Wave Terminal's Claude Code detection is built on top of a generic **shell integration protocol** (OSC 16162) that could be reused for pi coding agent support. + +### How Claude Code Integration Works + +| Layer | What it does | Relevant file | +|-------|-------------|---------------| +| **Shell integration protocol** | Custom OSC 16162 sequences injected into shell prompt. Sends command-start (`C`), command-done (`D`), shell-ready (`M`) events via base64-encoded payloads. | `frontend/app/view/term/osc-handlers.ts` | +| **Command detection** | `isClaudeCodeCommand(decodedCmd)` checks if normalized command matches `/^claude\b/`. Also detects `opencode` with similar regex. | `frontend/app/view/term/osc-handlers.ts` | +| **State atoms** | `shellIntegrationStatusAtom` (`"ready" \| "running-command" \| null`) and `claudeCodeActiveAtom` (`boolean`) track terminal state per block. | `frontend/app/view/term/termwrap.ts` | +| **Visual indicator** | `getShellIntegrationIconButton()` in `term-model.ts` reads atoms and renders either generic sparkle icon or `TermClaudeIcon` (Anthropic SVG logo) with status tooltip. | `frontend/app/view/term/term-model.ts` | +| **Telemetry gate** | `checkCommandForTelemetry()` filters out `ssh`, editors (`vim/nano/nvim`), `tail -f`, `claude`, and `opencode` from AI telemetry. | `frontend/app/view/term/osc-handlers.ts` | + +### What Was Removed Today + +- Sparkle icon + Claude logo from terminal block header (`getShellIntegrationIconButton` now returns `null`) +- All tooltips referencing "Wave AI can run commands" +- The `TermClaudeIcon` import from `term-model.ts` + +### What Remains (Dead Code, Phase D Cleanup) + +- `claudeCodeActiveAtom` in `termwrap.ts` — still set by OSC handlers, never read +- `shellIntegrationStatusAtom` in `termwrap.ts` — still set by OSC handlers, never read +- `isClaudeCodeCommand()` and `ClaudeCodeRegex` in `osc-handlers.ts` — still execute, results unused +- `TermClaudeIcon` component in `term.tsx` — still exported, never imported +- `checkCommandForTelemetry()` in `osc-handlers.ts` — still runs, telemetry already removed + +### Reuse Potential for Pi Coding Agent + +**The shell integration protocol itself is valuable** — it gives the terminal real-time awareness of: +- When a command starts / finishes +- What the command line is +- Exit codes +- Shell type and version +- Whether the terminal is in an alternate buffer (e.g., `vim`, `less`) + +**For pi integration, we could:** +1. Reuse the same OSC 16162 injection into `.bashrc`/`.zshrc` +2. Add a `piActiveAtom` alongside `claudeCodeActiveAtom` with a `/^pi\b/` regex +3. Show a pi icon in the terminal header when pi is the active command +4. Use command-start/finish events to show "pi is running" status in the UI +5. Use the alternate-buffer detection (`getBlockingCommand`) to suppress pi actions while inside `vim`/`less`/`ssh` + +**Key insight:** The protocol is generic AI-agent-agnostic infrastructure. The Claude-specific parts are just a regex (`/^claude\b/`) and an SVG icon. Replacing them with pi equivalents would be trivial if we want this later. + +**Decision:** Keep the underlying OSC 16162 shell integration infrastructure intact for now. Only the visual indicator (sparkle/Claude icon) and Wave-AI-specific tooltips were removed. If we want pi agent integration later, we can add `piActiveAtom` and a pi icon with minimal changes. + +## 2026-05-20: MOSH Research — Not a Priority + +**Finding:** MOSH (Mobile Shell) provides seamless reconnection (roaming, sleep/wake) and client-side local echo via UDP-based State Synchronization Protocol. However, it's not a priority for this fork. + +**Why not:** +- **No port forwarding** — open issue since 2014, no movement. Port forwarding is a core requirement. +- **No OSC52 clipboard** — remote programs can't put text in local clipboard. +- **No scrollback** — only syncs visible terminal state. +- **No file transfer** (scp/sftp). +- **C++ only** — no Go or JS library implementations of the core protocol. +- **Slow development** — last release 1.4.0 (October 2022). + +**Alternative: tsshd (trzsz-ssh)** — Go-based, supports full SSH features (port forwarding, agent forwarding, X11, scrollback, OSC52) + UDP roaming via QUIC/KCP. More architecturally relevant but would require significant integration effort. + +**Local echo with wsh** — Technically possible (Wave Terminal already knows screen state and intercepts keystrokes), but non-trivial (must detect line-editing vs application mode, validate predictions against round-trip timing). Low value for typical homelab latency (<50ms). + +## 2026-05-23: Auto-Reconnect P0 Fixed; Server Reboot → Manual Reconnect + +**Decision:** After fixing the three P0 auto-reconnect bugs (cooldown race, reconcile race, singleflight deduplication), we explicitly chose **NOT** to implement auto-restart of fresh shells on server reboot or `wsh` death. + +**Why manual reconnect:** +- Auto-restart would change durable-session semantics from *"resume my existing remote shell"* to *"keep a shell open at all costs."* +- Context loss (cwd, env, running processes) is confusing for users who think their old session survived. +- Risk of `wsh` re-install loops after server reboot. +- Cleaner to let the user explicitly click Connect and know it's a fresh session. + +**What we did:** +- `ReconnectJob` now correctly detects `JobManagerGone` and marks the job done. +- User sees `[session gone]` in the terminal and clicks Connect to start fresh. + +**Future direction (Jeremy's idea):** Tmux auto-restore on reconnect — instead of restarting raw shells, recreate tmux sessions/layouts after server reboot. This preserves tmux's own session persistence while giving WaveTerm visibility into the sessions. + +--- + +## 2026-06-01: CPU Spin Bug — Root Cause & Fix Strategy + +**Decision:** Fix the `x/crypto/ssh` drain loop bug locally via `go.mod` replace directive, not by reordering cleanup in waveterm. + +**Root cause:** `golang.org/x/crypto@v0.52.0` `ssh/mux.go` and `ssh/channel.go` have drain loops that spin forever when `globalResponses`/`ch.msg` channels are closed. Receiving from a closed channel always succeeds immediately (returns zero value), so `default` case is never reached. Tracked as [golang/go#79658](https://github.com/golang/go/issues/79658). + +**Upstream fixes:** Commits 4c4d20b (mux.go) and e3e62d9 (channel.go) on May 27, 2026. Not yet in a tagged release (awaiting v0.53.0). + +**Why the reorder workaround (issue #22 commit eb2c659a) was rolled back:** +- Only addressed the cleanup goroutine path, not keepalive monitors or `mux.loop()` exiting independently +- Wake-from-sleep pprof showed 37 spinning goroutines + 37 blocked on Mutex.Lock — reorder can't prevent all +- Original close order (client first) is correct: force-closes transport, unblocking pending `writePacket` calls +- With the mux patch, drain loops exit immediately on closed channels regardless of call order + +**Implementation:** +- `local_crypto_patch/contents/` — local copy of `x/crypto v0.52.0` with the 2-line drain loop fix applied +- `go.mod` replace directive: `replace golang.org/x/crypto v0.52.0 => ./local_crypto_patch/contents` +- Rollback plan: when `x/crypto >= v0.53.0` released, remove replace, delete `local_crypto_patch/`, `go mod tidy` + +**Consequences:** +- 100% CPU (wifi switch) and 900% CPU (wake from sleep) bugs both resolved +- No additional goroutines or timeouts needed in cleanup path +- Original close order restored (client first, then listener) + +--- + +**Priority order:** +1. Fix auto-reconnect bugs in durable sessions (#4) — DONE 2026-05-23 +2. SSH port forwarding (spec ready) +3. Remote file paste (image paste + drag-drop for SSH sessions) — primary use case: pi / Claude Code TUI +4. MOSH/tsshd support (backlog, if roaming becomes a real pain point) + diff --git a/.pi/draft-issue-autoconnect-bugs.md b/.pi/draft-issue-autoconnect-bugs.md new file mode 100644 index 0000000000..ed0ee8f3ff --- /dev/null +++ b/.pi/draft-issue-autoconnect-bugs.md @@ -0,0 +1,152 @@ +# Bug: Durable session auto-reconnect unreliable — cooldown set before connection check, connStates race condition + +## Symptom + +Durable SSH sessions sometimes stay disconnected after the SSH connection is restored, while other times they auto-reconnect correctly. The behavior is inconsistent. + +## Root Cause Analysis + +The auto-reconnect system has two paths: + +1. **Route-level** (`Event_RouteDown` on `job:`): Fires when the job stream drops. Triggers `attemptAutoReconnect` after a 1s delay, with a 30s cooldown per job. +2. **Connection-level** (`Event_ConnChange`): Fires when the SSH connection state changes. When connection comes up, `onConnectionUp` reconnects all running jobs on that connection. + +Both paths converge on `ReconnectJob()`. Neither is specific to durable vs standard — but **only durable sessions create jobs**, so only durable sessions have auto-reconnect. Standard sessions always require manual reconnect. + +## Bug #1: Route-level cooldown fires before connection check (High impact) + +In `pkg/jobcontroller/jobcontroller.go`: + +```go +func handleRouteEvent(event *wps.WaveEvent, newStatus string) { + // ... + if shouldAttemptAutoReconnect(jobId) { // sets cooldown timestamp HERE + go attemptAutoReconnect(jobId, job.Connection) + } +} + +func attemptAutoReconnect(jobId string, connName string) { + time.Sleep(AutoReconnectDelay) // 1s delay + isConnected, err := conncontroller.IsConnected(connName) + if err != nil || !isConnected { + return // cooldown already set, but reconnect never happened + } + // ... actual reconnect +} +``` + +The 30s cooldown (`lastAutoReconnectAttempt.Set(jobId, now)`) is set in `shouldAttemptAutoReconnect` **before** `attemptAutoReconnect` checks if the connection is actually up. If the connection is down, the reconnect is skipped but the cooldown has already been consumed. + +**Scenario**: SSH drops → job route goes down → cooldown set → 1s delay → connection still down → skip. Connection comes back 5s later → `onConnectionUp` tries `ReconnectJob` → may hit singleflight cache or other timing issues → job stays disconnected. + +**Fix**: Move `lastAutoReconnectAttempt.Set(jobId, time.Now().Unix())` into `attemptAutoReconnect`, only after the connection check passes. + +## Bug #2: connStates reconciliation race with buffered channel (Medium impact) + +In `reconcileAllConns()` / `reconcileConn()`: + +- `reconcileAllConns` scans `connStates.m`, sets `cs.reconciling = true`, spawns `go reconcileConn()`, releases lock +- `reconcileConn` does the work, then sets `cs.processed = targetState` and signals `reconcileCh` (buffer size 1) +- If `Event_ConnChange` fires rapidly (e.g., `disconnected` → `connected` → `disconnected`), `cs.actual` is updated by the event handler while the worker is mid-processing +- After worker sets `cs.processed`, it may match the new `cs.actual` — causing the next reconcile pass to skip with `cs.actual == cs.processed` +- The buffered `reconcileCh` (size 1) can also drop signals if multiple events fire before the worker drains it + +**Scenario**: Connection flaps quickly — reconcile worker misses a state transition — `onConnectionUp` never fires — jobs never reconnect. + +**Fix**: Pass the target state to the goroutine (already done), and have the goroutine do a fresh reconcile check after completing, rather than relying on the buffered channel signal. Or use an unbuffered channel with a dedicated worker loop. + +## Bug #3: singleflight in ReconnectJob can cache transient failures (Low impact, timing-dependent) + +`ReconnectJob` uses `reconnectGroup.Do(jobId, ...)` (singleflight). If route-level `attemptAutoReconnect` calls `ReconnectJob` concurrently with connection-level `onConnectionUp` calling `ReconnectJob` for the same jobId, they share the same result. If the route-level call runs first and fails (connection down), the connection-level call gets the cached failure. + +This only affects very tight timing windows (< 10s context timeout), but is worth noting. + +## Files involved + +| File | Relevant functions | +|------|--------------------| +| `pkg/jobcontroller/jobcontroller.go` | `handleRouteEvent`, `shouldAttemptAutoReconnect`, `attemptAutoReconnect`, `reconcileAllConns`, `reconcileConn`, `onConnectionUp`, `ReconnectJob` | +| `pkg/remote/conncontroller/conncontroller.go` | `waitForDisconnect`, `Connect`, `FireConnChangeEvent` | + +## Missing Detection Mechanisms + +Beyond the bugs above, several reconnection triggers are missing entirely: + +### Missing #1: System sleep/wake does nothing + +`emain/emain.ts` listens for `powerMonitor.on("resume")` and calls `NotifySystemResumeCommand`, which is a **stub that just logs and returns nil**. No reconnect is attempted. + +**Fix**: Have `NotifySystemResumeCommand` trigger reconnect for all disconnected durable jobs. + +### Missing #2: No network-online detection + +There is no monitoring of network connectivity state. The system relies entirely on TCP-level failure detection (SSH connection drops), which can be slow: +- TCP keepalive may not be enabled or may have very long timeouts +- Silent packet loss (asymmetric routing, firewall drop) may not trigger TCP timeout for minutes +- Network interface comes back up but no event triggers reconnect attempt + +**Fix**: Add periodic network-online check (e.g., every 30s) when durable jobs are in disconnected state. When network comes back up, trigger reconnect attempt. + +### Missing #3: No SSH/TCP keepalive configuration + +SSH connections may not have aggressive keepalive settings, meaning a "zombie" connection (network dropped but TCP hasn't detected it) can persist for a long time. The connection appears "up" in `connStates` but is actually dead. + +**Fix**: Configure `ClientAliveInterval` and `ClientAliveCountMax` on SSH connections to detect dead connections faster. + +## Edge Cases + +| Case | Scenario | Current behavior | Desired behavior | +|------|----------|------------------|------------------| +| **Job manager died** | `wsh jobmanager` process crashed on remote | Reconnect attempts fail repeatedly | Detect this case, mark job as "dead" instead of retrying | +| **User manually disconnects** | Click "Disconnect" in UI | May trigger auto-reconnect | Respect explicit disconnect vs network failure | +| **Multiple jobs, one connection** | SSH drops, 5 durable jobs on that host | Connection-level reconnect handles, but timing matters | Reconnect jobs in parallel after SSH is back, per-job backoff | +| **Reconnect during active typing** | User typing when network drops, comes back | Keystrokes lost, terminal may be inconsistent | Buffer keystrokes or show clear "reconnecting" state | +| **Connection flapping** | Network rapidly up/down | Each flap triggers reconnect, may hit cooldown | Exponential backoff with jitter | + +## Priority + +**P0 — Fix existing bugs:** +- Bug #1: Cooldown consumed before connection check (High impact) +- Bug #2: Channel buffer drops rapid state changes (Medium impact) +- Bug #3: singleflight caches transient failures (Low impact, timing-dependent) + +**P1 — Add missing detection:** +- Wire up `NotifySystemResumeCommand` to trigger reconnect on system wake +- Add network-online polling when jobs are disconnected +- Enable SSH keepalive on connections for faster dead-connection detection + +**P2 — Edge cases:** +- Detect job manager death vs route drop +- Respect manual disconnect (don't auto-reconnect) +- Reconnect state indicator in UI + +### UX Enhancement: Tab-focus triggers reconnect + +**Problem:** When a durable session is disconnected, the user sees a disconnected block with a connect button. But if they switch to another tab and back, they expect the session to be ready — instead they have to manually click connect. + +**Proposal:** When a tab gains focus, scan all blocks in that tab. For any terminal block with a disconnected durable session, automatically trigger a reconnect attempt for that connection. + +**Why tab, not block:** Blocks already have a connect button. Tab focus is the "I want to work in this context" gesture and may cover multiple blocks/connections at once. + +**Implementation:** +- Frontend: in tab focus handler, scan tab's blocks → find disconnected durable terminals → call `ReconnectConnection` RPC for each unique connection +- Backend: new `ReconnectConnectionCommand` that triggers existing connection-level reconnect logic +- Guardrails: respect cooldown, durable sessions only, respect manual disconnect +- UI: show "Reconnecting..." in block headers during attempt + +**Effort:** ~150 lines (frontend focus handler + RPC call + UI indicator, backend RPC wrapper). Does not fix underlying bugs but improves UX significantly. + +## Files involved + +| File | Relevant functions | +|------|--------------------| +| `pkg/jobcontroller/jobcontroller.go` | `handleRouteEvent`, `shouldAttemptAutoReconnect`, `attemptAutoReconnect`, `reconcileAllConns`, `reconcileConn`, `onConnectionUp`, `ReconnectJob` | +| `pkg/remote/conncontroller/conncontroller.go` | `waitForDisconnect`, `Connect`, `FireConnChangeEvent` | +| `emain/emain.ts` | `powerMonitor.on("resume")` — currently calls stub | +| `pkg/wshrpc/wshserver/wshserver.go` | `NotifySystemResumeCommand` — currently no-op | + +## Notes + +- These bugs affect **durable sessions only** — standard SSH sessions have no auto-reconnect machinery +- The `DurableDetachedContent` flyover tells users "Wave will automatically reconnect when the connection is restored" — but this is unreliable due to these bugs +- No existing tests cover `jobcontroller.go` reconnect paths diff --git a/.pi/index.md b/.pi/index.md new file mode 100644 index 0000000000..2d3e303ba5 --- /dev/null +++ b/.pi/index.md @@ -0,0 +1,28 @@ +# waveterm-remote Fork + +A fork of [Wave Terminal](https://github.com/wavetermdev/waveterm) optimized for **remote development workflows**. + +## Upstream + +- Original: `https://github.com/wavetermdev/waveterm` +- This fork: `https://github.com/whoisjeremylam/waveterm-remote` +- CWD origin points to this fork + +## Purpose + +Most developer terminals assume code is installed, built, and tested locally. This fork targets developers who primarily work on remote machines via SSH — with the local machine as a thin client. + +## Active Specs + +- [[specs/remove-telemetry.md]] — Remove all telemetry, analytics, and tracking +- [[specs/remove-waveai.md]] — Remove/disable all Wave AI features +- [[specs/portforwarding.md]] — SSH port forwarding (`LocalForward` / `RemoteForward`) + +## Context & Decisions + +- [[context.md]] — Full project background and goals +- [[decisions.md]] — Architecture decisions (ADRs) + +## Tasks + +- [[todos.md]] — Active work and backlog diff --git a/.pi/reviews/remove-telemetry-independent-review.md b/.pi/reviews/remove-telemetry-independent-review.md new file mode 100644 index 0000000000..321157ae69 --- /dev/null +++ b/.pi/reviews/remove-telemetry-independent-review.md @@ -0,0 +1,263 @@ +# Independent Review: Remove Telemetry Spec + +**Date:** 2026-05-13 +**Spec reviewed:** `.pi/specs/remove-telemetry.md` +**Method:** Full codebase audit against spec claims; did not read the prior review at `.pi/reviews/remove-telemetry-review.md` + +--- + +## Summary + +The spec is well-structured with a sound phased approach (A→B→C→D) and a thorough file-by-file inventory for most Go backend files. However, it has **4 critical gaps** (entire subsystems unaddressed), **5 errors/omissions** in listed items, and **6 unintended side effects** that need mitigation. The most significant gap is the complete absence of the Electron main process (`emain/`) which contains the primary activity-tracking loop. + +--- + +## 🔴 Critical Gaps — Items the Spec Misses Entirely + +### 1. `emain/emain.ts` — Electron main process telemetry + +The spec has **no section** for the Electron main process. `emain/emain.ts` contains the most important telemetry call sites: + +- **`sendDisplaysTDataEvent()`** (lines 135–162): Sends display info via `RpcApi.RecordTEventCommand` with event `"app:display"`. +- **`logActiveState()`** (lines 168–230+): Core activity tracking loop. Calls `RpcApi.ActivityCommand` and `RpcApi.RecordTEventCommand` with `"app:activity"` events including foreground minutes, active minutes, terminal command counts, AI usage minutes, and display data. +- **`emain-activity.ts`**: Module that collects `wasActive`, `wasInFg`, `termCommandsRun`, `termCommandsRemote`, `termCommandsWsl`, `termCommandsDurable`. All increment/export functions exist solely to feed telemetry. +- **`emain-ipc.ts`**: IPC handler for `"increment-term-commands"` (lines 441–454) which routes to `emain-activity.ts`. +- **`preload.ts`**: Exposes `incrementTermCommands` API to the renderer process. + +Without addressing these, telemetry removal is incomplete. The Electron main process is where periodic activity pings are orchestrated. + +**Recommendation:** Add new sections (B.7 for emain call sites, B.8 for emain-activity tracking module) covering: +- Remove `sendDisplaysTDataEvent()` and `logActiveState()` from `emain.ts` +- Remove all `RpcApi.RecordTEventCommand` and `RpcApi.ActivityCommand` calls from `emain.ts` +- Remove the `TEventProps`/`ActivityUpdate` type imports +- Remove `incrementTermCommands*` functions from `emain-activity.ts` (or make them no-ops) +- Remove the `"increment-term-commands"` IPC handler from `emain-ipc.ts` +- Remove `incrementTermCommands` from `preload.ts` +- Remove `getActivityState`, `setWasActive`, `setWasInFg` if only used by telemetry (verify no other callers) + +### 2. `cmd/wsh/cmd/wshcmd-root.go` — `sendActivity()` and `activityWrap()` + +The spec does not mention the `wsh` CLI's activity tracking: + +- **`activityWrap()`** (line 106): A wrapper that calls `sendActivity` after every wsh command execution. +- **`sendActivity()`** (line 221): Calls `wshclient.WshActivityCommand` to report which CLI command was run and whether it succeeded. +- **`wshcmd-file.go`**: Every file subcommand (`file list`, `file cat`, `file info`, `file rm`, `file write`, `file append`, `file cp`, `file mv`) uses `activityWrap`. + +The spec mentions removing `WshActivityCommand` from the server/RPC layers (A.2/A.3/A.4) but never addresses the **callers**. + +**Recommendation:** Add new section A.16: +- Remove `sendActivity()` function from `wshcmd-root.go` +- Remove `activityWrap()` function from `wshcmd-root.go` +- Remove `activityWrap` wrapping from all command `RunE` assignments in `wshcmd-file.go` (change `activityWrap("file", fileListRun)` to just `fileListRun`, etc.) +- Remove the `sendActivity` comment block (lines 216–218) +- Remove `wshclient` import if no other uses remain in `wshcmd-root.go` + +### 3. `cmd/generatego/main-generatego.go` — Code generator imports `telemetrydata` + +The Go code generator that produces `pkg/wshrpc/wshclient/wshclient.go` has a hardcoded import of `"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata"` (line 29). When Phase C deletes `pkg/telemetry/telemetrydata/`, this generator will fail to compile. The spec does not mention updating it. + +**Recommendation:** Add to Phase C.3 (or new C.4): +- Remove `"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata"` from `cmd/generatego/main-generatego.go` imports +- After removing telemetry RPC types from `wshrpctypes.go`, regenerate `wshclient.go` to remove the generated telemetry methods + +### 4. Auto-generated TypeScript files — manual edits will be overwritten + +`frontend/types/gotypes.d.ts`, `frontend/app/store/services.ts`, and `frontend/app/store/wshclientapi.ts` are **auto-generated** by `cmd/generatets/main-generatets.go`. They contain telemetry-related types and methods (`ActivityUpdate`, `TEvent`, `TEventProps`, `TEventUserProps`, `TelemetryUpdate`, etc.). The spec lists manual edits to these files (B.2, B.3), but since they're auto-generated, **manual edits will be overwritten** on the next build. + +**Recommendation:** Revise B.2/B.3 to note: +- These files are auto-generated; do NOT edit them directly +- Instead, remove the telemetry RPC methods/types from the Go source types that feed the generator: + - Remove `ActivityCommand`, `RecordTEventCommand`, `SendTelemetryCommand`, `WaveAIEnableTelemetryCommand`, `WshActivityCommand` from `pkg/wshrpc/wshrpctypes.go` + - Remove `TelemetryUpdate` from `pkg/service/clientservice/clientservice.go` + - Remove `ActivityUpdate` type from `pkg/wshrpc/wshrpctypes.go` + - Remove `TEvent`/`TEventProps`/`TEventUserProps` types from `pkg/telemetry/telemetrydata/telemetrydata.go` (or delete the package in Phase C) +- After Go source changes, regenerate with `task dev` (or the appropriate generate task) +- **Also** remove the manual call sites in frontend code that reference these generated types/methods (B.4 table entries) + +--- + +## 🟡 Errors and Omissions + +### 5. `WshActivityCommand` missing from A.2, A.3, A.4 + +The spec lists these methods to remove from wshserver/wshrpctypes/wshclient: +- `ActivityCommand()`, `RecordTEventCommand()`, `SendTelemetryCommand()`, `WaveAIEnableTelemetryCommand()` + +But it **omits `WshActivityCommand()`**, which is a distinct RPC method (separate from `ActivityCommand`). `WshActivityCommand` takes `map[string]int` and is used by the `wsh` CLI's `sendActivity()` function. It exists in all three layers: +- `wshserver.go` line 1316 +- `wshrpctypes.go` line 86 +- `wshclient.go` line 1051 + +**Fix:** Add `WshActivityCommand()` to A.2, A.3, and A.4. + +### 6. Phase A.1 missing `diagnosticLoop()` and related constants + +The spec lists functions to remove from `main-server.go` but omits: +- **`diagnosticLoop()`** (line 136) — the periodic diagnostic ping loop +- **`InitialDiagnosticWait`** constant (line 65) +- **`DiagnosticTick`** constant (line 66) +- **`go diagnosticLoop()`** startup call (line 570) + +The spec mentions `wcloud.SendDiagnosticPing()` and `WAVETERM_NOPING` but not the loop that orchestrates them. + +**Fix:** Add to A.1: +- Remove `diagnosticLoop()` function +- Remove `InitialDiagnosticWait` and `DiagnosticTick` constants +- Remove `go diagnosticLoop()` from startup sequence + +### 7. `frontend/app/store/keymodel.ts` — Missing call site + +The spec's table in B.4 does not include `keymodel.ts`, but it contains: +- Import of `recordTEvent` (line 18) +- `recordTEvent("action:other", { "action:type": "conndropdown", "action:initiator": "keyboard" })` (line 653) + +**Fix:** Add to B.4 table: +| `frontend/app/store/keymodel.ts` | `recordTEvent` import and call (line 653) | + +### 8. `frontend/app/aipanel/telemetryrequired.tsx` — Dedicated telemetry consent component + +This is an entire React component (`TelemetryRequiredMessage`) that blocks AI panel usage until the user enables telemetry. It calls `RpcApi.WaveAIEnableTelemetryCommand`. The spec's B.6 delegates AI panel files to the AI removal spec, but this component is **about telemetry**, not AI — it's the telemetry consent gate for AI features. It must be addressed here or with an explicit cross-reference guaranteeing the AI spec handles it. + +**Fix:** Either: +- Add explicit entry in B.6 noting that `telemetryrequired.tsx` must be removed or replaced with a non-telemetry gate when AI panel is modified, OR +- Add it to Phase B with a note that it's co-owned with the AI removal spec + +### 9. Preview/test files not addressed + +- `frontend/preview/mock/mockfilesystem.ts` line 317: References `telemetry.log` in mock filesystem data. +- `frontend/preview/previews/onboarding.preview.tsx` line 27: Passes `telemetryUpdateFn={async () => {}}` to `InitPage`. + +**Fix:** Add to B.4 or B.5: +| `frontend/preview/mock/mockfilesystem.ts` | Remove `telemetry.log` mock entry | +| `frontend/preview/previews/onboarding.preview.tsx` | Remove `telemetryUpdateFn` prop (after onboarding restructuring) | + +--- + +## 🟠 Unintended Side Effects + +### 10. Onboarding flow will break without restructuring + +The current onboarding has a 3-page flow: +``` +init → (telemetry enabled → features) | (telemetry disabled → notelemetrystar → features) +``` + +The `InitPage` component has: +- A telemetry toggle checkbox +- A `telemetryUpdateFn` prop that calls `TelemetryUpdate` +- Logic in `acceptTos` that opens the AI panel only when `telemetryEnabled` +- A `NoTelemetryStarPage` ("Telemetry Disabled ✓") dedicated page + +Simply removing telemetry calls without restructuring this UI will leave: +- A non-functional checkbox +- A dead `telemetryUpdateFn` callback +- An `acceptTos` handler that conditionally opens AI panel based on a setting that no longer exists +- A meaningless `NoTelemetryStarPage` + +**Recommendation:** Expand B.5 with a restructuring plan: +- Remove the telemetry toggle from `InitPage` +- Remove the `telemetryUpdateFn` prop +- Remove `NoTelemetryStarPage` and the `"notelemetrystar"` page state +- Simplify the flow to `init → features` +- Move the GitHub star prompt to `InitPage` or `FeaturesPage` without the telemetry-disabled framing +- Remove `telemetry:enabled` state reads (`useSettingsKeyAtom("telemetry:enabled")`) +- In `acceptTos`, remove the `if (telemetryEnabled)` AI panel check (or hardcode it if AI spec removes the panel) + +### 11. `AgreeTos`/TOS flow dependency in onboarding + +`AgreeTos()` in `clientservice.go` sets `TosAgreed` timestamp. The onboarding `acceptTos` function calls `AgreeTos` then checks `telemetryEnabled`. The spec says to keep `TosAgreed` "for now" but doesn't address how the TOS acceptance flow works without the telemetry consent step. + +**Recommendation:** `AgreeTos` should remain (it's a TOS acceptance, not telemetry), but the onboarding `acceptTos` handler needs restructuring to decouple TOS from telemetry consent. + +### 12. PanicHandler rename direction is confusing + +The spec says "Rename `PanicHandlerNoTelemetry` to `PanicHandler`" and "Update all callers of `PanicHandlerNoTelemetry`". But: +- `PanicHandlerNoTelemetry` is only called **within `pkg/telemetry/`** (2 calls in `telemetry.go`). After Phase C deletes that package, there are zero callers. +- `PanicHandler` (the one that calls `PanicTelemetryHandler`) is called by ~80+ sites across the codebase. + +The practical approach is: keep the name `PanicHandler`, remove the `PanicTelemetryHandler` dispatch code, and make it behave like `PanicHandlerNoTelemetry`. Delete `PanicHandlerNoTelemetry` entirely since it becomes redundant. No callers outside the telemetry package need updating. + +**Recommendation:** Revise A.12 to: +- Remove `PanicTelemetryHandler` variable and its `if` block from `PanicHandler` +- Remove `PanicHandlerNoTelemetry` function entirely +- Remove `panichandler.PanicTelemetryHandler = panicTelemetryHandler` from `main-server.go` +- No caller renames needed + +### 13. `autoupdate:channel` and `autoupdate:enabled` entangled with telemetry config + +`CountCustomSettings()` in `settingsconfig.go` excludes both `telemetry:enabled` AND `autoupdate:channel` from counting as "custom settings" (lines 990–993). After removing `telemetry:enabled`: +- The exclusion logic needs updating (remove the `telemetry:enabled` check) +- The `autoupdate:channel` exclusion should remain (it's not telemetry-related) + +Also, `telemetry.AutoUpdateChannel()` and `telemetry.IsAutoUpdateEnabled()` are convenience functions in `pkg/telemetry/` that just read settings config. They're called in `main-server.go` only for telemetry event payloads (lines 316–317). But `autoupdate` settings are used by `emain/updater.ts` for genuine auto-update. These functions should either: +- Be moved to `wconfig` or a utility package before Phase C, OR +- Have their callers in `main-server.go` removed (since they're only used for telemetry payloads) + +**Recommendation:** Add to A.1: Remove `telemetry.AutoUpdateChannel()` and `telemetry.IsAutoUpdateEnabled()` calls from `main-server.go` (they're only used in `startupActivityUpdate`). Add to A.14: Update `CountCustomSettings` to remove the `telemetry:enabled` exclusion. + +### 14. `waveai-model.tsx`/`aimode.tsx`/`aipanel.tsx` — `telemetry:enabled` reads + +These files read `telemetry:enabled` to gate AI features: +- `waveai-model.tsx` lines 144, 149: Returns `"invalid"` mode when telemetry disabled +- `waveai-model.tsx` lines 422–423: Blocks cloud AI when telemetry disabled +- `aimode.tsx` line 147: Reads `telemetry:enabled` +- `aipanel.tsx` lines 238, 265: Reads `telemetry:enabled` + +If the AI removal spec removes the AI panel entirely, these references vanish. If it doesn't, they become dangling references to a deleted setting. + +**Recommendation:** Add cross-reference note: If AI panel is NOT fully removed by the AI spec, these `telemetry:enabled` reads must be replaced with either hardcoded `true` (always allow) or removed entirely. + +### 15. Database schema — `db_tevent` and `db_activity` tables + +These tables are created by SQL migrations (`000003_activity.up.sql` and `000007_events.up.sql`). Phase C deletes the Go code that reads/writes them, but the tables remain in users' databases. This is harmless (empty tables), but for a clean removal: + +**Recommendation (optional):** Add a new migration that drops these tables: +```sql +-- 000012_drop_telemetry.up.sql +DROP TABLE IF EXISTS db_tevent; +DROP TABLE IF EXISTS db_activity; +``` + +### 16. Existing users' `telemetry:enabled` setting + +After removing `TelemetryEnabled` from `SettingsConfig`, existing users who have `telemetry:enabled` in their config will have an unrecognized key. The JSON unmarshaling with `omitempty` means it will be silently ignored, which is fine. But the `CountCustomSettings` function explicitly checks for this key — that check needs removal (covered in item 13). + +### 17. `WCLOUD_ENDPOINT`/`WCLOUD_PING_ENDPOINT` environment variables + +`wcloud.CacheAndRemoveEnvVars()` reads and then unsets these environment variables early in startup (main-server.go line 408). This is a security measure to prevent child processes from accessing cloud endpoint URLs. After removing wcloud, these env vars will remain set in the waveterm process. Harmless for a fork that doesn't use cloud services, but worth noting. + +--- + +## 🟢 What the Spec Gets Right + +- The phased approach (A→B→C→D) is sound: removing call sites first, then frontend, then packages, then docs. +- The file-by-file inventory in Phase A is thorough for the Go backend files it lists. +- The risk assessment correctly identifies `ClientId` dual-use and `TosAgreed` concerns. +- The interaction note with the AI removal spec is valuable. +- Phase C (delete packages) after call sites are clean is the right order. +- The verification checklist is practical and well-scoped. +- Correctly identifies that `pkg/telemetry/` and `pkg/wcloud/` should be kept during Phase A to minimize upstream merge conflicts. + +--- + +## Recommended Additions Summary + +| # | Item | Severity | Action | +|---|------|----------|--------| +| 1 | `emain/emain.ts` + `emain-activity.ts` + `emain-ipc.ts` + `preload.ts` | 🔴 Critical | New sections B.7/B.8 | +| 2 | `wshcmd-root.go` `sendActivity`/`activityWrap` + `wshcmd-file.go` callers | 🔴 Critical | New section A.16 | +| 3 | `cmd/generatego/main-generatego.go` telemetrydata import | 🔴 Gap | Add to Phase C | +| 4 | Auto-generated TS files — edit Go sources, not TS output | 🔴 Gap | Revise B.2/B.3 | +| 5 | `WshActivityCommand` missing from A.2/A.3/A.4 | 🟡 Error | Fix A.2/A.3/A.4 | +| 6 | `diagnosticLoop()` + constants missing from A.1 | 🟡 Error | Fix A.1 | +| 7 | `keymodel.ts` recordTEvent call site | 🟡 Gap | Add to B.4 table | +| 8 | `telemetryrequired.tsx` — telemetry consent gate | 🟡 Gap | Add to B.6 or cross-reference | +| 9 | Preview files (mockfilesystem.ts, onboarding.preview.tsx) | 🟡 Minor | Add to B.4/B.5 | +| 10 | Onboarding UI restructuring plan | 🟠 Side effect | Expand B.5 | +| 11 | `AgreeTos`/TOS flow decoupling | 🟠 Side effect | Add to B.5 | +| 12 | PanicHandler rename direction | 🟠 Side effect | Revise A.12 | +| 13 | `CountCustomSettings` + `AutoUpdateChannel`/`IsAutoUpdateEnabled` | 🟠 Side effect | Add to A.1 and A.14 | +| 14 | AI panel `telemetry:enabled` reads | 🟠 Side effect | Cross-reference with AI spec | +| 15 | Database migration to drop telemetry tables | 🟢 Optional | New Phase D.3 | +| 16 | Existing users' `telemetry:enabled` config key | 🟢 Low risk | Note in A.14 | +| 17 | `WCLOUD_ENDPOINT`/`WCLOUD_PING_ENDPOINT` env vars | 🟢 Low risk | Note in Phase C | \ No newline at end of file diff --git a/.pi/reviews/remove-telemetry-review.md b/.pi/reviews/remove-telemetry-review.md new file mode 100644 index 0000000000..c865de263d --- /dev/null +++ b/.pi/reviews/remove-telemetry-review.md @@ -0,0 +1,253 @@ +# Review: Remove Telemetry Spec + +**Date:** 2026-05-13 +**Spec:** `.pi/specs/remove-telemetry.md` +**Status:** Draft spec review — issues found + +--- + +## Executive Summary + +The spec is well-structured and covers the majority of telemetry call sites. However, there are **significant omissions** on both backend and frontend, **ambiguous instructions** for `PanicHandler` cleanup, and **missing consideration** of auto-generated code files. If executed as written, the app will fail to compile in Phase C due to unresolved imports in code generators, and the Electron main process (`emain/emain.ts`) will continue sending telemetry events unbeknownst to the remover. + +The onboarding flow requires more extensive structural changes than just removing `RecordTEventCommand` calls — the state machine has telemetry baked into page transitions. + +**Recommendation:** Update the spec before implementation. The issues are correctable with targeted additions. + +--- + +## Critical Omissions (Will Cause Compile Errors or Missed Telemetry) + +### 1. `emain/emain.ts` — Entirely Absent from Spec + +The Electron main process sends telemetry independently of the frontend React app. This file is **not mentioned anywhere** in the spec. + +**What it does:** +- `sendDisplaysTDataEvent()` — sends display metrics (count, resolution, DPR) via `RpcApi.RecordTEventCommand` (event: `app:display`) +- `logActiveState()` — every 60 seconds sends: + - `RpcApi.ActivityCommand(ElectronWshClient, activity, ...)` with fg/active minutes, terminal command counts, Wave AI active minutes + - `RpcApi.RecordTEventCommand(ElectronWshClient, { event: "app:activity", props }, ...)` with aggregated activity props +- `runActiveTimer()` — triggers `logActiveState()` on a 60-second loop, started at app launch (line 419) +- `sendDisplaysTDataEvent()` — called once at startup (line 420) + +**Where to add:** Phase B (frontend), or a new Phase E (electron main) + +### 2. `cmd/server/main-server.go` — Missing `diagnosticLoop` and Constants + +The spec's A.1 lists many removals but **completely omits** the diagnostic ping loop: + +| Missing Item | Lines | Purpose | +|--------------|-------|---------| +| `const InitialDiagnosticWait` | 65 | Wait before first diagnostic ping | +| `const DiagnosticTick` | 66 | Sleep interval between ping attempts | +| `func diagnosticLoop()` | 136-154 | Daily ping loop to `ping.waveterm.dev` | +| `func sendDiagnosticPing()` | 157-169 | Sends diagnostic ping via `wcloud.SendDiagnosticPing` | +| `go diagnosticLoop()` | 570 | Starts the diagnostic loop goroutine | + +The spec mentions removing `wcloud.SendDiagnosticPing()` call from startup and `WAVETERM_NOPING` env var, but the **loop that repeatedly calls `sendDiagnosticPing()` is never mentioned**. This is the most active telemetry channel (daily pings) and would survive Phase A if the spec is followed literally. + +**Note:** `WAVETERM_NOPING` is checked inside `diagnosticLoop()` (line 140), not just at startup. + +### 3. `cmd/wsh/cmd/wshcmd-root.go` — Missing CLI Activity Tracking + +The `wsh` CLI tool sends command usage stats to the local server via `WshActivityCommand`: + +```go +func sendActivity(wshCmdName string, success bool) { + ... + wshclient.WshActivityCommand(RpcClient, dataMap, nil) +} +``` + +This is called from the CLI root command after each `wsh` invocation. Even though the comment says "it does not contact any wave cloud infrastructure," the data is fed into the local telemetry system (`ActivityCommand` → `UpdateActivity` → `db_tevent`). + +**Where to add:** Phase A.15 or new A.16 + +### 4. `cmd/generatego/main-generatego.go` — Missing Code Generator Update + +The Go code generator that produces `pkg/wshrpc/wshclient/wshclient.go` explicitly imports `pkg/telemetry/telemetrydata` in its boilerplate (line 29). After Phase C deletes `pkg/telemetry/`, this generator will **fail to compile**, blocking future code regeneration. + +```go +gogen.GenerateBoilerplate(&buf, "wshclient", []string{ + ... + "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata", + ... +}) +``` + +Since `TEvent` is removed from the RPC interface in Phase A.2/A.3, the generated `wshclient.go` won't reference `telemetrydata` anymore, but the **static import in the generator template** will still cause a compile error. + +**Where to add:** Phase C.3 (cleanup) or Phase C.1 + +### 5. `frontend/app/store/keymodel.ts` — Missing Call Site + +The spec's B.4 table lists many frontend call sites but **misses** `frontend/app/store/keymodel.ts:653`: + +```typescript +recordTEvent("action:other", { "action:type": "conndropdown", "action:initiator": "keyboard" }); +``` + +This is triggered by a keyboard shortcut for the connection dropdown. + +### 6. Auto-Generated Files — Spec Treats Them as Manual Edits + +The spec instructs manual removal from: +- `frontend/app/store/wshclientapi.ts` (B.2) +- `frontend/app/store/services.ts` (B.3) + +Both files are **auto-generated** by `cmd/generatets/main-generatets.go`. If the Go RPC interface and service definitions are cleaned up first, regenerating these files will automatically remove the telemetry methods. Manual edits work but will be overwritten on next regeneration. + +**Recommendation:** Note that these files should be regenerated, not manually edited, or edit them AND update the generators to prevent drift. + +Similarly, `frontend/types/gotypes.d.ts` is auto-generated and will lose `ActivityUpdate`, `TEvent`, `TEventProps`, `TEventUserProps` types automatically once the Go source types are removed. + +--- + +## Side Effects & Risks + +### 1. `PanicHandler` Cleanup — Name Collision Risk + +The spec says (A.12): "Rename `PanicHandlerNoTelemetry` to `PanicHandler` (since all telemetry is gone, the 'NoTelemetry' variant is now the only one)". + +**Problem:** `PanicHandler` already exists in the same file. You cannot rename `PanicHandlerNoTelemetry` to `PanicHandler` while `PanicHandler` still exists. Go will reject the duplicate function name. + +**Correct approach:** +1. Modify the existing `PanicHandler` function to remove the `if PanicTelemetryHandler != nil` block and the `go func() { ... }()` telemetry call +2. Delete `PanicHandlerNoTelemetry` +3. Update the two callers of `PanicHandlerNoTelemetry` (both in `pkg/telemetry/telemetry.go`) to use `PanicHandler` — but since `pkg/telemetry/` is deleted in Phase C, this step may be moot +4. Remove `var PanicTelemetryHandler func(panicType string)` + +**Result:** The existing `PanicHandler` becomes the no-telemetry version automatically; all its existing callers (40+ locations across the codebase) continue to work without modification. + +### 2. Onboarding Flow — Structural Changes Required + +The onboarding state machine in `frontend/app/onboarding/onboarding.tsx` has telemetry embedded in its page transitions: + +```typescript +type PageName = "init" | "notelemetrystar" | "features"; +``` + +The `InitPage` component receives a `telemetryUpdateFn` prop and a telemetry toggle UI. The state transition is: +``` +init -> (telemetry enabled) -> features +init -> (telemetry disabled) -> notelemetrystar -> features +``` + +**What's needed beyond removing `RecordTEventCommand` calls:** +- Remove `telemetryUpdateFn` prop from `InitPage` +- Remove the telemetry toggle/checkbox from `InitPage` UI +- Remove `"notelemetrystar"` from `PageName` type +- Remove the `notelemetrystar` branch from the switch/case page renderer +- Simplify the transition to: `init -> features` unconditionally +- Remove `telemetryEnabled` state and `telemetrySetting` atom usage +- Remove `NoTelemetryStarPage` export if it's no longer used + +The spec mentions "The InitPage in onboarding has a telemetry toggle; remove that UI element" which is correct directionally, but the actual code changes are more extensive than just removing a toggle. + +### 3. AI Cloud Mode Gate + +In `pkg/aiusechat/usechat.go:86`: +```go +if config.WaveAICloud && !telemetry.IsTelemetryEnabled() { + return nil, fmt.Errorf("Wave AI cloud modes require telemetry to be enabled") +} +``` + +The spec says (A.11): "remove the gate or hardcode it to pass". If telemetry is removed first (before AI removal), this gate **must** be removed or hardcoded to allow cloud AI, or Wave AI cloud modes will break with a compile error (the `telemetry` package won't exist). + +**Side effect:** Removing this gate means Wave AI cloud modes will work without telemetry, which is the desired end state for the fork, but it's a behavioral change that should be noted. + +### 4. `wsh` CLI Activity Tracking Behavior Change + +The `sendActivity` function in `cmd/wsh/cmd/wshcmd-root.go` currently helps the developers "understand which commands are actually being used." Removing it means zero visibility into `wsh` command usage, but that's aligned with the goal. + +### 5. Database Tables — Orphaned Data + +`db_tevent` and `db_activity` tables exist in user's SQLite databases. The spec correctly deletes the code that writes to them, but **existing data remains on disk**. This is harmless (no new data accumulates, no data is sent), but if completeness is desired, a one-time cleanup or migration to drop these tables could be added. Not strictly necessary. + +### 6. `TosAgreed` Field + +The spec correctly notes that `TosAgreed` is harmless without telemetry reading it. However, `TosAgreed` is also referenced in `cmd/server/main-server.go` (line 326) as part of `startupActivityUpdate`, which is being removed anyway. No side effects. + +### 7. `ClientId` Non-Telemetry Uses + +Verified: `wstore.GetClientId()` is used by: +- `pkg/remote/sshclient.go` (durable sessions) +- `pkg/jobcontroller/jobcontroller.go` (job manager) +- `pkg/wcloud/wcloud.go` (telemetry — being removed) +- `cmd/server/main-server.go` (diagnostic ping — being removed) + +The spec correctly advises keeping `ClientId`. Good. + +--- + +## Documentation Audit Findings + +### Covered Correctly +- `docs/docs/telemetry.mdx` — listed for deletion ✓ +- `docs/docs/telemetry-old.mdx` — listed for deletion ✓ +- `docs/docs/config.mdx` — listed for audit ✓ +- `docs/docs/faq.mdx` — listed for audit ✓ +- `docs/docs/index.mdx` — listed for audit ✓ + +### Additional Doc References Found +- `docs/docs/releasenotes.mdx` — has telemetry mentions (lines 178, 379, 505, 690) +- `docs/docs/waveai.mdx` — line 93 mentions "anonymous telemetry" +- `docs/docs/waveai-modes.mdx` — line 80 mentions "telemetry requirement messages" + +These are noted in the spec as "handled by AI removal spec" or "optional, historical." This is reasonable. + +--- + +## Recommendations for Spec Updates + +### Immediate Additions (Before Implementation) + +| # | Addition | Phase | +|---|----------|-------| +| 1 | Add `emain/emain.ts`: remove `sendDisplaysTDataEvent()`, `logActiveState()`, `runActiveTimer()`, and their startup calls | Phase B or new Phase E | +| 2 | Add `cmd/server/main-server.go`: remove `diagnosticLoop()`, `sendDiagnosticPing()`, `go diagnosticLoop()`, `InitialDiagnosticWait`, `DiagnosticTick` | Phase A.1 | +| 3 | Add `cmd/wsh/cmd/wshcmd-root.go`: remove `sendActivity()` function and its call site | Phase A.16 | +| 4 | Add `cmd/generatego/main-generatego.go`: remove `telemetrydata` from generator boilerplate imports | Phase C.3 | +| 5 | Add `frontend/app/store/keymodel.ts` to Phase B.4 call sites table | Phase B.4 | +| 6 | Clarify `PanicHandler` cleanup: modify existing `PanicHandler` to remove telemetry block, delete `PanicHandlerNoTelemetry`, update its callers | Phase A.12 | +| 7 | Expand onboarding instructions: remove `telemetryUpdateFn` prop, `PageName` variants, simplify state machine transitions | Phase B.5 | +| 8 | Note auto-generated files (`wshclientapi.ts`, `services.ts`, `gotypes.d.ts`) should be regenerated after Go changes, not just manually edited | Phase B intro | + +### Optional but Recommended + +| # | Addition | Rationale | +|---|----------|-----------| +| 9 | Add DB cleanup note: `db_tevent` and `db_activity` tables will remain in existing user databases but won't receive new data | Verification checklist or risk assessment | +| 10 | Consider removing `autoupdate:channel` and `autoupdate:enabled` from `CountCustomSettings` exclusion since auto-update is being discussed for removal | If auto-update is removed in a follow-up spec, this becomes relevant | + +--- + +## Risk Matrix (Post-Spec-Correction) + +| Risk | Severity | Mitigation | +|------|----------|------------| +| ClientId used by non-telemetry code | Low | Keep `wstore.GetClientId()` — spec already covers ✓ | +| `TosAgreed` field harmless without readers | Low | Keep field, no migration needed — spec already covers ✓ | +| Onboarding structural changes | Medium | Expand Phase B.5 instructions per review | +| Upstream merge conflicts | Medium | Phase A keeps `pkg/telemetry/` intact; Phase C deferred — spec already covers ✓ | +| Compile errors from code generators | High | Add `cmd/generatego/` cleanup to spec | +| Electron main telemetry survives | High | Add `emain/emain.ts` to spec | +| Daily diagnostic pings survive | High | Add `diagnosticLoop` removal to spec | +| `PanicHandler` name collision | Medium | Clarify A.12 instructions | +| AI cloud modes break if gate removed before AI spec | Low | Hardcode gate to pass or remove — spec covers, just note execution order | + +--- + +## Verification Checklist Amendments + +After the spec is updated and implemented: + +- [ ] `emain/emain.ts` has no `RecordTEventCommand` or `ActivityCommand` calls +- [ ] `cmd/server/main-server.go` has no `diagnosticLoop`, `sendDiagnosticPing`, or `go diagnosticLoop()` +- [ ] `cmd/wsh/cmd/wshcmd-root.go` has no `sendActivity` function +- [ ] `cmd/generatego/main-generatego.go` does not import `telemetrydata` +- [ ] `frontend/app/store/keymodel.ts` has no `recordTEvent` calls +- [ ] Onboarding flow transitions directly from `init` to `features` without telemetry consent +- [ ] `pkg/panichandler/panichandler.go` has only one `PanicHandler` function (no telemetry side-effects) +- [ ] No references to `telemetry:enabled` in `docs/docs/` remain diff --git a/.pi/specs/bug-tabclose-crash.md b/.pi/specs/bug-tabclose-crash.md new file mode 100644 index 0000000000..f4650c6b7c --- /dev/null +++ b/.pi/specs/bug-tabclose-crash.md @@ -0,0 +1,442 @@ +# Bug: Crash on Tab Close After SSH Session Exit + +**Status:** Fixed (2026-05-14) +**Priority:** High +**Date:** 2026-05-13 +**Resolution:** Root cause confirmed; redundant goroutine removed from `CloseTab`; `ShellProc.Close()` made idempotent with `sync.Once`; trace logging added. + +## Reproduction Steps + +1. Connect to SSH from the dropdown +2. Type `exit` in the shell +3. Click the tab 'x' to close the tab +4. → Crash + +## Thesis: Root Cause Analysis + +### Primary Suspect: Race Condition in `CloseTab` — Double Block Controller Destruction + +**Location:** `pkg/service/workspaceservice/workspaceservice.go:218-232` + +The `CloseTab` method has a critical design flaw that triggers **concurrent** `DestroyBlockController` calls for each block: + +```go +func (svc *WorkspaceService) CloseTab(...) { + tab, err := wstore.DBGet[*waveobj.Tab](ctx, tabId) + if err == nil && tab != nil { + go func() { // ← Goroutine A + for _, blockId := range tab.BlockIds { + blockcontroller.DestroyBlockController(blockId) + } + }() + } + newActiveTabId, err := wcore.DeleteTab(ctx, ...) // ← Synchronous + // DeleteTab → DeleteBlock → sendBlockCloseEvent + // → handleBlockCloseEvent → go DestroyBlockController(blockId) ← Goroutine B +} +``` + +Each block gets `DestroyBlockController` called **twice concurrently** — once from the explicit goroutine in `CloseTab` (Goroutine A), and once from the block-close event handler triggered by `DeleteBlock` (Goroutine B). + +### How This Leads to a Crash + +#### Path 1: `ShellController.Stop` — Double `ShellProc.Close()` on SSH Session + +`DestroyBlockController` calls `controller.Stop(true, Status_Done, true)`. For `ShellController`, `Stop` has a **Lock/Unlock/Relock** pattern that creates a race window: + +```go +func (sc *ShellController) Stop(graceful bool, newStatus string, destroy bool) { + sc.Lock.Lock() + defer sc.Lock.Unlock() + + if sc.ShellProc == nil || sc.ProcStatus == Status_Done || sc.ProcStatus == Status_Init { + return // ← Guard check, but... + } + sc.ShellProc.Close() // ← First Close + if graceful { + doneCh := sc.ShellProc.DoneCh + sc.Lock.Unlock() // ← UNLOCKS here, allowing concurrent Stop to enter + <-doneCh // ← Waits for shell process to finish + sc.Lock.Lock() // ← Re-locks after waiting + } + sc.ProcStatus = newStatus // ← Only NOW updated, but second Stop already entered +} +``` + +**Race sequence:** +1. Goroutine A calls `Stop`, acquires lock, checks `ProcStatus == Status_Running`, calls `ShellProc.Close()`, **unlocks** to wait on `doneCh` +2. Goroutine B calls `Stop`, acquires lock, sees `ShellProc != nil` and `ProcStatus == Status_Running` (not yet updated), calls `ShellProc.Close()` **again** +3. `ShellProc.Close()` calls `Cmd.KillGraceful()` → `SessionWrap.Kill()` → `Tty.Close()` + `Session.Close()` +4. **Double `ssh.Session.Close()`** on a session that may already be closing (after user typed `exit`) +5. **Double `PipePty.Close()`** — closing `os.File` descriptors twice + +On a closed/dying SSH session, `Session.Close()` sends a channel close message over a potentially-dead SSH mux. The `x/crypto/ssh` library's `channel.Close()` calls `channel.sendMessage()` which writes to the transport. If the mux loop has already exited and cleaned up, this can cause: +- Panic from writing to a closed/cleaned-up channel +- Panic from `close` on a closed channel (Go runtime panic) +- Data race on mux internals that have been cleaned up + +#### Path 2: `ShellProc.Close()` Double Channel Close + +`ShellProc.Close()` spawns a goroutine: + +```go +func (sp *ShellProc) Close() { + sp.Cmd.KillGraceful(DefaultGracefulKillWait) + go func() { + waitErr := sp.Cmd.Wait() + sp.SetWaitErrorAndSignalDone(waitErr) + if runtime.GOOS != "windows" { + sp.Cmd.Close() + } + }() +} +``` + +When called twice concurrently, `KillGraceful` is called twice, and two goroutines are spawned that both call `Wait()` and `Close()`. While `Wait()` is protected by `sync.Once` and `SetWaitErrorAndSignalDone` is protected by `CloseOnce`, **`KillGraceful` and `Close` are NOT idempotent or protected**. + +For `SessionWrap`: +- `KillGraceful` → `Kill()` → `Tty.Close()` + `Session.Close()` — called twice +- `Close()` is a no-op (the `pty.Pty` interface has no `Close` method beyond `ReadWriteCloser`, and `SessionWrap` doesn't implement an explicit `Close()`) + +For `CmdWrap` (local shells): +- `KillGraceful` → sends signal, then force-kills after timeout +- `Close()` → `Cmd.Wait()` + pty close — double close on pty + +#### Path 3: Durable Shell — Job Termination Race with Block Deletion + +For SSH blocks using `DurableShellController` (the default for SSH connections): + +When user types `exit`: +1. The remote shell process exits +2. `HandleCmdJobExited` is called → `tryTerminateJobManager` terminates the job manager +3. The output stream reaches EOF → `StreamDone = true` + +When user clicks tab X: +- `DestroyBlockController` → `DurableShellController.Stop(true, Status_Done, true)` → `TerminateAndDetachJob(ctx, jobId)` +- But the job may already be terminated/detached +- `DetachJobFromBlock` tries to update the block's `JobId` field via `wstore.DBUpdateFn` +- But the block may already be **deleted from the DB** by `DeleteBlock` (running concurrently in `DeleteTab`) +- This could cause a DB error or panic if the update operates on a non-existent record + +#### Path 4: ConnMonitor Keepalive on Closing SSH Client + +When the SSH connection is still alive (connserver session persists after shell exit), `ConnMonitor` runs keepalive checks every 5 seconds: + +```go +func (cm *ConnMonitor) SendKeepAlive() error { + client := cm.Client // ← Stale reference captured at creation time + if !cm.setKeepAliveInFlight() { + return nil + } + go func() { + _, _, err := client.SendRequest("keepalive@openssh.com", true, nil) + // ... + }() +} +``` + +If `closeInternal_withlifecyclelock()` is called concurrently: +1. It calls `conn.Monitor.Close()` (cancels context) +2. It calls `client.Close()` (closes SSH client) +3. It sets `conn.Client = nil` + +But a keepalive goroutine may have already captured `client := cm.Client` and started `SendRequest` on a closing/closed client. While `x/crypto/ssh` generally handles this gracefully (returning `io.EOF`), if the mux loop has already exited and cleaned up its internal channels, accessing the mux can race. + +### Secondary Contributing Factors + +1. **`DurableShellController.Stop` has no lock** — Unlike `ShellController.Stop`, concurrent calls can race on the `JobId` field read. + +2. **No idempotency guard on `ShellProc.Close()`** — No `sync.Once` or closed-flag prevents double-close. + +3. **`ConnMonitor` holds stale `*ssh.Client` reference** — Never updated when client is closed/nilled. + +4. **`handleBlockCloseEvent` launches goroutine** — `go DestroyBlockController(blockId)` makes the double-destroy race uncontrolled. + +### Most Likely Crash Sequence (SSH + Durable Shell) + +1. User types `exit` in SSH shell → shell process on remote exits +2. `HandleCmdJobExited` fires → `tryTerminateJobManager` → job manager terminated +3. User clicks tab X → `CloseTab` called +4. Goroutine A: `DestroyBlockController(blockId)` → `DurableShellController.Stop()` → `TerminateAndDetachJob(jobId)` +5. `DeleteTab` → `DeleteBlock` → `sendBlockCloseEvent` +6. Goroutine B: `handleBlockCloseEvent` → `DestroyBlockController(blockId)` → second `DurableShellController.Stop()` → second `TerminateAndDetachJob(jobId)` +7. First call terminates job and detaches from block (block's `JobId` cleared) +8. Second call tries to detach from already-detached/non-existent block → potential DB error or nil pointer + +### Most Likely Crash Sequence (SSH + Non-Durable Shell) + +1. User types `exit` in SSH shell → `manageRunningShellProcess` wait loop detects exit → `ProcStatus = Status_Done` +2. User clicks tab X → `CloseTab` called +3. Goroutine A: `DestroyBlockController(blockId)` → `ShellController.Stop(true, Status_Done, true)` → sees `ProcStatus == Status_Done` → returns early (OK) +4. Goroutine B (from `sendBlockCloseEvent`): `DestroyBlockController(blockId)` → controller already deleted from registry → returns early (OK) +5. **But** if the timing is different — tab close happens while `ProcStatus` is still `Status_Running` (shell still exiting): + - Goroutine A: `Stop` acquires lock, sees Running, calls `ShellProc.Close()`, **unlocks** to wait + - Goroutine B: `Stop` acquires lock, sees Running (not yet Done), calls `ShellProc.Close()` **again** + - Double `Session.Close()` → potential panic in SSH library + +--- + +## Logging Additions (Suggested) + +### 1. In `CloseTab` — Trace the double-destroy path + +**File:** `pkg/service/workspaceservice/workspaceservice.go` + +```go +func (svc *WorkspaceService) CloseTab(ctx context.Context, workspaceId string, tabId string, fromElectron bool) (*CloseTabRtnType, waveobj.UpdatesRtnType, error) { + ctx = waveobj.ContextWithUpdates(ctx) + tab, err := wstore.DBGet[*waveobj.Tab](ctx, tabId) + if err == nil && tab != nil { + log.Printf("[closetab] tab=%s blocks=%v launching async DestroyBlockController goroutine", tabId, tab.BlockIds) + go func() { + for _, blockId := range tab.BlockIds { + log.Printf("[closetab] DestroyBlockController block=%s (from CloseTab goroutine)", blockId) + blockcontroller.DestroyBlockController(blockId) + } + }() + } + // ... +} +``` + +### 2. In `DestroyBlockController` — Detect double-destroy + +**File:** `pkg/blockcontroller/blockcontroller.go` + +```go +func DestroyBlockController(blockId string) { + controller := getController(blockId) + if controller == nil { + log.Printf("[destroy] block=%s: controller already nil (possible double-destroy)", blockId) + return + } + log.Printf("[destroy] block=%s: stopping controller (type=%T, connName=%s)", blockId, controller, controller.GetConnName()) + controller.Stop(true, Status_Done, true) + wstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, blockId)) + deleteController(blockId) + log.Printf("[destroy] block=%s: controller deleted from registry", blockId) +} +``` + +### 3. In `ShellController.Stop` — Detect concurrent Stop and double-Close + +**File:** `pkg/blockcontroller/shellcontroller.go` + +```go +func (sc *ShellController) Stop(graceful bool, newStatus string, destroy bool) { + sc.Lock.Lock() + defer sc.Lock.Unlock() + log.Printf("[shellcontroller] Stop block=%s procStatus=%s shellProcNil=%v destroy=%v", sc.BlockId, sc.ProcStatus, sc.ShellProc == nil, destroy) + + if sc.ShellProc == nil || sc.ProcStatus == Status_Done || sc.ProcStatus == Status_Init { + if newStatus != sc.ProcStatus { + sc.ProcStatus = newStatus + sc.sendUpdate_nolock() + } + return + } + // ... + sc.ShellProc.Close() + if graceful { + doneCh := sc.ShellProc.DoneCh + sc.Lock.Unlock() // ← RACE WINDOW STARTS HERE + log.Printf("[shellcontroller] Stop block=%s waiting on DoneCh (lock released)", sc.BlockId) + <-doneCh + sc.Lock.Lock() // ← RACE WINDOW ENDS HERE + log.Printf("[shellcontroller] Stop block=%s DoneCh closed (lock reacquired)", sc.BlockId) + } + // ... +} +``` + +### 4. In `DurableShellController.Stop` — Log concurrent access + +**File:** `pkg/blockcontroller/durableshellcontroller.go` + +```go +func (dsc *DurableShellController) Stop(graceful bool, newStatus string, destroy bool) { + if !destroy { + return + } + jobId := dsc.getJobId() + log.Printf("[durableshellcontroller] Stop block=%s jobId=%s destroy=%v", dsc.BlockId, jobId, destroy) + if jobId == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + jobcontroller.TerminateAndDetachJob(ctx, jobId) +} +``` + +### 5. In `ConnMonitor.SendKeepAlive` — Detect stale client reference + +**File:** `pkg/remote/conncontroller/connmonitor.go` + +```go +func (cm *ConnMonitor) SendKeepAlive() error { + client := cm.Client + currentClient := cm.Conn.GetClient() + if currentClient == nil { + log.Printf("[connmonitor] SendKeepAlive: conn=%s client is nil (connection closed)", cm.Conn.GetName()) + return nil + } + if client != currentClient { + log.Printf("[connmonitor] SendKeepAlive: conn=%s stale client reference (monitor client != current client)", cm.Conn.GetName()) + return nil + } + // ... rest of SendKeepAlive +} +``` + +### 6. In `handleBlockCloseEvent` — Log the event handling + +**File:** `pkg/blockcontroller/blockcontroller.go` + +```go +func handleBlockCloseEvent(event *wps.WaveEvent) { + blockId, ok := event.Data.(string) + if !ok { + log.Printf("[blockclose] invalid event data type") + return + } + log.Printf("[blockclose] block=%s: launching DestroyBlockController goroutine from event handler", blockId) + go DestroyBlockController(blockId) +} +``` + +**File:** `pkg/jobcontroller/jobcontroller.go` + +```go +func handleBlockCloseEvent(event *wps.WaveEvent) { + // ... existing code ... + log.Printf("[blockclose-job] block=%s: found %d jobs to terminate", blockId, len(jobIds)) + for _, jobId := range jobIds { + log.Printf("[blockclose-job] block=%s: terminating job=%s", blockId, jobId) + TerminateAndDetachJob(ctx, jobId) + } +} +``` + +--- + +## Tests Written + +**File:** `pkg/blockcontroller/blockcontroller_test.go` + +### Tests Implemented + +| Test | What it tests | Result | +|------|--------------|--------| +| `TestShellControllerStopConcurrent/double_stop_does_not_double_kill` | Two concurrent `Stop` calls on a running ShellController — uses slow mock to expose Lock/Unlock/Relock race | **PASS** — but only checks KillGraceful count, not data race | +| `TestShellControllerStopConcurrent/stop_after_proc_done_is_noop` | Stop on a controller with ProcStatus=Done | **PASS** | +| `TestShellControllerStopConcurrent/stop_sets_status_done` | Stop updates ProcStatus correctly | **PASS** | +| `TestDestroyBlockControllerDoubleCall` | Two concurrent `DestroyBlockController` calls for same blockId | **PASS** — second call finds nil controller and returns | +| `TestDestroyBlockControllerDoubleCallDurable` | Same test with DurableShellController | **PASS** | +| `TestDurableShellControllerStopConcurrent/stop_with_empty_jobid_is_noop` | Stop with no jobId | **PASS** | +| `TestDurableShellControllerStopConcurrent/stop_without_destroy_is_noop` | Stop with destroy=false | **PASS** | +| `TestShellControllerStopNilShellProc/nil_proc_updates_status` | Stop with nil ShellProc updates status | **PASS** | +| `TestShellControllerStopNilShellProc/nil_proc_already_done_noop` | Stop when already Done | **PASS** | +| `TestShellControllerStopNilShellProc/nil_proc_init_status` | Stop when Init | **PASS** | +| `TestShellProcDoubleClose/double_close_on_running_proc` | Two concurrent `ShellProc.Close()` calls | **PASS** | +| `TestShellProcDoubleClose/close_then_wait` | Close then second Close after Wait | **PASS** — Wait is protected by sync.Once | +| `TestShellControllerStopRaceWithDoneStatus` | Tab-close Stop racing with shell-exit status update | **PASS** | +| `TestShellControllerStopDoesNotPanicOnClosedSession/closed_session_stop` | Stop on closed SSH session | **PASS** | +| `TestShellControllerStopDoesNotPanicOnClosedSession/concurrent_stop_on_closing_session` | Three concurrent operations: two Stops + shell exit | **PASS** | + +### Test Infrastructure + +- **`mockConnInterface`**: Fast mock where `Wait()` returns immediately. Good for testing the guard conditions and status updates. +- **`slowMockConnInterface`**: Slow mock where `Wait()` blocks until `KillGraceful` signals it or `waitDone` is closed. Essential for exposing the Lock/Unlock/Relock race in `ShellController.Stop`. +- **`mockClosedConnInterface`**: Mock that returns errors from all operations, simulating a closed SSH session. + +### Key Finding from Tests + +The `double_stop_does_not_double_kill` test **passes without detecting the double-KillGraceful** in the default case because: +- With the slow mock, `KillGraceful` triggers `Wait()` to complete, and the `DoneCh` is signaled +- The second `Stop` call sees `ProcStatus == Status_Done` (updated after the first Stop completes) and returns early +- **However**, this depends on timing. If the second `Stop` enters during the `Lock.Unlock()` / `<-doneCh` / `Lock.Lock()` window, it WILL call `ShellProc.Close()` again + +Running with `go test -race` does not flag a data race in the test because the test's `Stop` calls are serialized by the `ShellController.Lock`. The actual race is a **logical race** (double-close), not a data race detectable by the race detector. The race detector would catch it if two goroutines accessed the same `ShellProc` fields without synchronization, but `ShellProc.Close()` is called under the controller's lock. + +**The real danger** is that `ShellProc.Close()` launches a **goroutine** (`go func() { waitErr := sp.Cmd.Wait(); ... }()`), and the second `Close()` launches another goroutine. Both goroutines call `Cmd.Wait()` and `Cmd.Close()` concurrently without synchronization. This IS a data race on the SSH session internals, but it happens inside the `x/crypto/ssh` library, not in waveterm code, so the Go race detector won't flag it directly. + +--- + +## In Flight / Not Yet Done + +### Tests Still Needed + +1. **`go test -race` on the double-ShellProc.Close goroutine race** — The `ShellProc.Close()` method spawns a goroutine that calls `Cmd.Wait()` and `Cmd.Close()`. Two concurrent `Close()` calls spawn two goroutines that race on SSH session internals. Need a test that directly exercises this goroutine race. + +2. **Integration test with real SSH session** — Unit tests can't fully simulate the `x/crypto/ssh` library's behavior when `Session.Close()` is called on a closing session. An integration test with a real SSH connection would catch panics in the SSH library. + +3. **`CloseTab` double-destroy integration test** — Test the full `CloseTab` flow: create a tab with blocks, then close it, and verify no double-destroy or panic. + +4. **`ConnMonitor` keepalive on stale client test** — Test that `SendKeepAlive` on a closed/nilled client doesn't panic. + +5. **`DurableShellController.Stop` concurrent job termination test** — Test two concurrent `TerminateAndDetachJob` calls on the same jobId. This requires setting up the job DB, which is more complex. + +### Logging Not Yet Added + +The logging additions described above are **designed but not yet implemented** in the source files. They should be added to enable interactive crash reproduction and diagnosis. + +### Fix Not Yet Implemented + +The fix for the primary root cause (double-destroy in `CloseTab`) should be one of: + +**Option A: Remove the redundant goroutine in `CloseTab`** +The explicit `go func() { DestroyBlockController(...) }()` goroutine in `CloseTab` is redundant because `DeleteTab` → `DeleteBlock` → `sendBlockCloseEvent` already triggers controller destruction. Removing it eliminates the double-destroy entirely. + +**Option B: Add idempotency to `DestroyBlockController`** +Make `DestroyBlockController` safe for concurrent calls by adding a `destroyed` flag or using `sync.Once`: + +```go +func DestroyBlockController(blockId string) { + // Use sync.Map or a separate set to track in-progress destructions + if !markDestroyInProgress(blockId) { + return // already being destroyed + } + defer clearDestroyInProgress(blockId) + controller := getController(blockId) + if controller == nil { + return + } + controller.Stop(true, Status_Done, true) + wstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, blockId)) + deleteController(blockId) +} +``` + +**Option C: Make `ShellProc.Close()` idempotent** +Add a `sync.Once` to `ShellProc.Close()`: + +```go +func (sp *ShellProc) Close() { + sp.closeOnce.Do(func() { + sp.Cmd.KillGraceful(DefaultGracefulKillWait) + go func() { + defer func() { + panichandler.PanicHandler("ShellProc.Close", recover()) + }() + waitErr := sp.Cmd.Wait() + sp.SetWaitErrorAndSignalDone(waitErr) + if runtime.GOOS != "windows" { + sp.Cmd.Close() + } + }() + }) +} +``` + +**Recommended approach:** Option A (remove redundant goroutine) + Option C (make ShellProc.Close idempotent) as defense-in-depth. Option A fixes the root cause; Option C protects against any other code path that might call Close twice. + +### Interactive Reproduction Needed + +The tests above confirm the structural race conditions exist but don't reproduce the actual crash. To confirm the crash: +1. Add the logging additions +2. Build and run waveterm with `task dev` +3. Connect to an SSH server from the dropdown +4. Type `exit` in the shell +5. Click the tab X +6. Check logs for double-destroy patterns and any panic/crash output \ No newline at end of file diff --git a/.pi/specs/configurable-reconnect-thresholds.md b/.pi/specs/configurable-reconnect-thresholds.md new file mode 100644 index 0000000000..b3d9070652 --- /dev/null +++ b/.pi/specs/configurable-reconnect-thresholds.md @@ -0,0 +1,282 @@ +# Configurable Reconnect & Monitor Thresholds (PR #3) + +## Problem + +PR #1 hardcodes fast thresholds (3s keepalive, 5s auto-disconnect, 5s retry interval) for laptop use. These work well for Wi-Fi switching but may be too aggressive for: + +- Slow SSH servers (high latency, old hardware) +- Cellular/satellite connections (intermittent, high jitter) +- Server room workstations (stable wired networks don't need aggressive polling) +- User preference (some users prefer stability over speed) + +Hardcoded values also prevent tuning without recompiling the app. + +## Scope + +- **In scope**: Per-connection configurable thresholds for monitor and scheduler; removal of the `degraded` health status; simplified monitor model. +- **Out of scope**: UI overlay changes (PR #2); changes to the underlying reconnect logic (PR #1). + +## Goals + +1. Every threshold from PR #1 becomes a `ConnKeywords` field with sensible defaults. +2. The `degraded` health status is removed — the monitor uses a single `good → stalled` transition. +3. The `inputNotifyCh` path is simplified: typing still triggers an immediate keepalive, but no `degraded` state is set. +4. Backward compatibility: missing config values fall back to PR #1's fast defaults. + +## New Configurable Fields + +### `ConnKeywords` additions (`pkg/wconfig/settingsconfig.go`) + +```go +type ConnKeywords struct { + // ... existing fields ... + + // Keepalive / Stall detection + ConnKeepaliveIntervalSec *int `json:"conn:keepaliveinterval,omitempty"` + ConnStallThresholdSec *int `json:"conn:stallthreshold,omitempty"` + ConnAutoDisconnectThresholdSec *int `json:"conn:autodisconnectthreshold,omitempty"` + + // Reconnect scheduler + ConnReconnectTimeoutSec *int `json:"conn:reconnecttimeout,omitempty"` + ConnReconnectIntervalSec *int `json:"conn:reconnectinterval,omitempty"` + ConnReconnectAggressiveIntervalSec *int `json:"conn:reconnectaggressiveinterval,omitempty"` + + // Feature flags + ConnEnableStallAutoDisconnect *bool `json:"conn:stallautodisconnect,omitempty"` +} +``` + +### Defaults (used when field is nil) + +| Field | PR #1 Default | Rationale | +|-------|---------------|-----------| +| `conn:keepaliveinterval` | 3 | Seconds of inactivity before sending keepalive | +| `conn:stallthreshold` | 3 | Seconds after keepalive before declaring stalled | +| `conn:autodisconnectthreshold` | 5 | Seconds of stall before forcing disconnect | +| `conn:reconnecttimeout` | 5 | Timeout for each `AttemptReconnect` dial | +| `conn:reconnectinterval` | 5 | Seconds between normal reconnect retries | +| `conn:reconnectaggressiveinterval` | 3 | Seconds between aggressive-mode retries | +| `conn:stallautodisconnect` | true | Whether to auto-disconnect on stall at all | + +### Example `connections.json` + +```json +{ + "user@slow-server": { + "conn:keepaliveinterval": 10, + "conn:stallthreshold": 10, + "conn:autodisconnectthreshold": 30, + "conn:reconnecttimeout": 30, + "conn:reconnectinterval": 30 + }, + "user@laptop-target": { + "conn:keepaliveinterval": 2, + "conn:stallthreshold": 2, + "conn:autodisconnectthreshold": 3, + "conn:reconnectinterval": 3 + } +} +``` + +## Changes + +### 1. `pkg/wconfig/settingsconfig.go` — Add fields + +Add the 7 new fields to `ConnKeywords`. Placement: after existing `ConnStall*` fields, before `Display*`. + +### 2. `pkg/remote/conncontroller/connmonitor.go` — Read config + remove `degraded` + +#### 2a. Config helpers + +```go +func (cm *ConnMonitor) getIntConfig(key string, defaultVal int) int { + connConfig, ok := cm.Conn.getConnectionConfig() + if !ok { + return defaultVal + } + switch key { + case "keepaliveinterval": + if connConfig.ConnKeepaliveIntervalSec != nil && *connConfig.ConnKeepaliveIntervalSec > 0 { + return *connConfig.ConnKeepaliveIntervalSec + } + case "stallthreshold": + if connConfig.ConnStallThresholdSec != nil && *connConfig.ConnStallThresholdSec > 0 { + return *connConfig.ConnStallThresholdSec + } + case "autodisconnectthreshold": + if connConfig.ConnAutoDisconnectThresholdSec != nil && *connConfig.ConnAutoDisconnectThresholdSec > 0 { + return *connConfig.ConnAutoDisconnectThresholdSec + } + } + return defaultVal +} +``` + +#### 2b. Remove `degraded` state + +Delete `LastInputTime`, `isUrgent()`, and the `degraded` status constant. The `inputNotifyCh` path is kept but simplified: + +```go +func (cm *ConnMonitor) keepAliveMonitor() { + ticker := time.NewTicker(cm.getTickerInterval()) + defer ticker.Stop() + + for { + if cm.Conn.GetClient() != cm.Client { + return + } + select { + case <-ticker.C: + cm.checkConnection() + + case <-cm.inputNotifyCh: + // Immediate keepalive on input, no "degraded" state + cm.SendKeepAlive() + + case <-cm.ctx.Done(): + return + } + } +} +``` + +Note: `getTickerInterval()` should return `min(keepaliveinterval, 1)` or similar — the ticker must run at least as fast as the keepalive interval. + +#### 2c. Use config in `checkConnection()` + +```go +func (cm *ConnMonitor) checkConnection() { + lastActivity := cm.LastActivityTime.Load() + if lastActivity == 0 { + return + } + timeSinceActivity := time.Now().UnixMilli() - lastActivity + + keepAliveThreshold := int64(cm.getIntConfig("keepaliveinterval", 3)) * 1000 + if timeSinceActivity > keepAliveThreshold { + cm.SendKeepAlive() + } + + stalledThreshold := int64(cm.getIntConfig("stallthreshold", 3)) * 1000 + timeSinceKeepAlive := cm.getTimeSinceKeepAlive() + if timeSinceKeepAlive > stalledThreshold { + cm.setConnHealthStatus(ConnHealthStatus_Stalled) + + stallStart := cm.StallStartTime.Load() + now := time.Now().UnixMilli() + if stallStart == 0 { + cm.StallStartTime.Store(now) + } else { + thresholdMs := int64(cm.getIntConfig("autodisconnectthreshold", 5)) * 1000 + if now-stallStart > thresholdMs { + cm.disconnectOnStall() + } + } + } else { + cm.StallStartTime.Store(0) + } +} +``` + +#### 2d. Remove `degraded` constant + +```go +const ( + ConnHealthStatus_Good = "good" + // ConnHealthStatus_Degraded = "degraded" // REMOVED + ConnHealthStatus_Stalled = "stalled" +) +``` + +### 3. `pkg/jobcontroller/jobcontroller.go` — Read scheduler config + +Replace hardcoded constants with config-aware lookups. The scheduler gets a reference to `ConnKeywords` via the connection name. + +```go +func getReconnectConfig(connName string) (timeout, interval, aggressiveInterval time.Duration) { + connOpts, err := remote.ParseOpts(connName) + if err != nil { + return 5*time.Second, 5*time.Second, 3*time.Second + } + conn := conncontroller.MaybeGetConn(connOpts) + if conn == nil { + return 5*time.Second, 5*time.Second, 3*time.Second + } + // ... read from conn config or defaults ... +} +``` + +Use these in `scheduleConnectionReconnect` instead of `ConnReconnectInterval`, etc. + +### 4. `pkg/remote/conncontroller/conncontroller.go` — Remove `degraded` references + +Update `DeriveConnStatus()` and any code that references `ConnHealthStatus_Degraded`. + +### 5. Schema & Type Updates + +#### `pkg/schema/schema.go` or `schema/connections.json` + +Add the new fields to the connections schema so Monaco editor validates them. + +#### `frontend/types/gotypes.d.ts` + +Add the new fields to the TypeScript `ConnKeywords` type. + +### 6. Documentation + +#### `docs/docs/connections.mdx` + +Add a new "Connection Resilience Settings" subsection: + +```markdown +### Connection Resilience + +These settings control how aggressively Wave detects and recovers from network interruptions. + +| Keyword | Default | Description | +|---------|---------|-------------| +| `conn:keepaliveinterval` | 3 | Seconds of inactivity before sending a keepalive probe. Lower = faster detection, more network traffic. | +| `conn:stallthreshold` | 3 | Seconds after keepalive before declaring the connection `stalled`. | +| `conn:autodisconnectthreshold` | 5 | Seconds of stall before forcing disconnect and starting reconnect. | +| `conn:reconnecttimeout` | 5 | Timeout for each reconnect attempt. | +| `conn:reconnectinterval` | 5 | Seconds between reconnect retries in normal mode. | +| `conn:reconnectaggressiveinterval` | 3 | Seconds between retries when the network appears unreachable. | +| `conn:stallautodisconnect` | true | Whether to auto-disconnect when stalled. Disable if you prefer manual control. | + +Example for a high-latency satellite connection: +```json +{ + "user@satellite": { + "conn:keepaliveinterval": 15, + "conn:stallthreshold": 15, + "conn:autodisconnectthreshold": 60, + "conn:reconnecttimeout": 30, + "conn:reconnectinterval": 30 + } +} +``` +``` + +## Test Plan + +| Test | Setup | Expected | +|------|-------|----------| +| Default values | No config set | Falls back to PR #1 fast defaults (3s/5s/5s) | +| Custom values | Set `conn:keepaliveinterval=10` | Monitor sends keepalive every 10s | +| `degraded` removed | Type during dead network | No `degraded` event; immediate keepalive sent instead | +| Backward compat | Existing `connections.json` without new fields | No error, defaults used | +| UI still works | PR #2 overlay with configurable intervals | Overlay countdown respects `conn:reconnectinterval` | + +## Validation Checklist + +- [ ] `task build:backend` succeeds +- [ ] `task build:frontend` succeeds +- [ ] `go test ./pkg/remote/conncontroller/...` passes +- [ ] `go test ./pkg/jobcontroller/...` passes +- [ ] Manual test: override one setting, verify behavior changes +- [ ] Manual test: delete all new settings, verify defaults work + +## Dependencies + +- **Requires PR #1**: Fast reconnect hardcoded thresholds must be in place first — this PR just makes them configurable. +- **No dependency on PR #2**: Configurable thresholds are backend-only; the overlay (PR #2) consumes whatever values are active. diff --git a/.pi/specs/fast-reconnect-hardcoded.md b/.pi/specs/fast-reconnect-hardcoded.md new file mode 100644 index 0000000000..83d6596053 --- /dev/null +++ b/.pi/specs/fast-reconnect-hardcoded.md @@ -0,0 +1,235 @@ +# Fast Reconnect — Hardcoded Thresholds (PR #1) + +## Problem + +When a laptop switches Wi-Fi networks or sleeps/resumes, Wave Terminal takes 30–60 seconds to detect the disconnection and reconnect. The root causes are: + +1. **Slow stall detection**: The keepalive monitor waits 10 seconds of inactivity before sending a probe, then 10 more seconds before declaring `stalled`. +2. **Slow auto-disconnect**: Even after `stalled` is declared, it takes 30 seconds (default) before forcing a disconnect. +3. **Slow reconnect scheduler**: The first reconnect attempt uses a 30-second timeout, and subsequent retries wait 30 seconds between attempts. +4. **Deadlock risk**: `Close()` and `Connect()` can deadlock on `lifecycleLock` when a fast reconnect is attempted after system resume. +5. **Event timing bug**: The disconnect `connchange` event was fired after `closeInternal()` returned, so if `client.Close()` blocked on dead TCP, the frontend never learned the connection was down. + +## Scope + +- **In scope**: Tighten all hardcoded thresholds to laptop-appropriate values; fix the lifecycle lock deadlock; fix the deferred disconnect event. +- **Out of scope**: UI overlay changes (PR #2); configurable thresholds (PR #3); removing the `degraded` health status. + +## Current Architecture + +``` +ConnMonitor (5s ticker) + ├─ checkConnection() every 5s + │ ├─ SendKeepAlive() if >10s inactivity (1s if "urgent") + │ └─ set stalled if keepalive unanswered >10s (>5s urgent) + ├─ inputNotifyCh branch + │ └─ set degraded after 1s if no echo + └─ auto-disconnect if stalled >30s + +waitForDisconnect() goroutine + └─ client.Wait() blocks on dead TCP → natural disconnect + +conn.Close() + ├─ set Status = Disconnected + ├─ FireConnChangeEvent() ← BUG: was deferred after closeInternal + └─ closeInternal_withlifecyclelock() + └─ client.Close() / listener.Close() / controller.Close() + +jobcontroller + ├─ onConnectionDown() → scheduleConnectionReconnect() + │ └─ loop: AttemptReconnect(timeout=30s), wait 30s, retry + └─ HandleSystemResume() → conn.Close() + AttemptReconnect() + └─ DEADLOCK: Close() holds lifecycleLock, Connect() needs it +``` + +## Changes + +### 1. `pkg/remote/conncontroller/conncontroller.go` — Event before blocking close + +#### 1a. `Close()` and `waitForDisconnect()` + +Move `FireConnChangeEvent()` to fire **immediately** after setting `Status = Disconnected` and `ConnHealthStatus = Good`, before calling `closeInternal_withlifecyclelock()`. + +```go +func (conn *SSHConn) Close() { + conn.WithLock(func() { + if conn.Status == Status_Connecting { + conn.cancelConnectCtx() + } + if conn.Status == Status_Connected || conn.Status == Status_Connecting { + conn.Status = Status_Disconnected + } + conn.ConnHealthStatus = ConnHealthStatus_Good + }) + // FIRE EVENT FIRST — so UI and jobcontroller react even if cleanup blocks + conn.FireConnChangeEvent() + conn.closeInternal_withlifecyclelock() +} +``` + +Same pattern in `waitForDisconnect()`: + +```go +func (conn *SSHConn) waitForDisconnect(client *ssh.Client, listener net.Listener, controller *genconn.ConnController) { + // ... wait for error ... + conn.WithLock(func() { + if conn.Client == client { + conn.Status = Status_Disconnected + conn.ConnHealthStatus = ConnHealthStatus_Good + } + }) + conn.FireConnChangeEvent() // ← moved BEFORE closeInternal + conn.closeInternal_withlifecyclelock() +} +``` + +#### 1b. `closeInternal_withlifecyclelock()` — Run blocking cleanup in goroutine + +To prevent the deadlock where `HandleSystemResume` calls `Close()` then spawns a reconnect goroutine that calls `Connect()`, which also needs `lifecycleLock`: + +```go +func (conn *SSHConn) closeInternal_withlifecyclelock() { + conn.lifecycleLock.Lock() + defer conn.lifecycleLock.Unlock() + + // Capture old references under conn.lock, nil them immediately + // so Connect() sees clean state and can proceed without waiting + var oldClient *ssh.Client + var oldListener net.Listener + var oldController *genconn.ConnController + conn.WithLock(func() { + oldClient = conn.Client + oldListener = conn.Listener + oldController = conn.ConnController + conn.Client = nil + conn.Monitor = nil + conn.Listener = nil + conn.ConnController = nil + }) + + // Run the actual blocking Close() calls in a background goroutine + // This frees lifecycleLock immediately for Connect() / HandleSystemResume + go func() { + if oldListener != nil { + oldListener.Close() + } + if oldController != nil { + oldController.Close() + } + if oldClient != nil { + oldClient.Close() + } + }() +} +``` + +### 2. `pkg/remote/conncontroller/connmonitor.go` — Tighten thresholds + +Change hardcoded constants to laptop-appropriate values: + +| Constant | Current | New | Rationale | +|----------|---------|-----|-----------| +| `keepAliveThreshold` (normal) | 10000 (10s) | **3000 (3s)** | Detect dead network faster | +| `keepAliveThreshold` (urgent) | 1000 (1s) | **1000 (1s)** | Keep — typing already triggers fast path | +| `stalledThreshold` (normal) | 10000 (10s) | **3000 (3s)** | Declare stall after 3s no response | +| `stalledThreshold` (urgent) | 5000 (5s) | **2000 (2s)** | Faster when user is actively typing | +| `getStallDisconnectThresholdMs()` default | 30000 (30s) | **5000 (5s)** | Disconnect 5s after stall, don't wait for TCP | +| `ticker interval` | 5 * time.Second | **3 * time.Second** | Check more frequently | + +```go +// In checkConnection(): +keepAliveThreshold := int64(3000) +if urgent { + keepAliveThreshold = 1000 +} + +stalledThreshold := int64(3000) +if urgent { + stalledThreshold = 2000 +} + +// In keepAliveMonitor(): +ticker := time.NewTicker(3 * time.Second) + +// In getStallDisconnectThresholdMs(): +return 5000 // 5s default +``` + +### 3. `pkg/jobcontroller/jobcontroller.go` — Tighten scheduler + +Change hardcoded constants: + +| Constant | Current | New | Rationale | +|----------|---------|-----|-----------| +| `ConnReconnectInterval` | 30s | **5s** | Retry every 5s instead of 30s | +| `ConnReconnectMaxDuration` | 5m | **5m** | Keep — don't retry forever | +| `ConnReconnectAggressiveInterval` | 5s | **3s** | Even faster when network is known down | +| `ConnReconnectAggressiveDuration` | 2m | **2m** | Keep | +| First attempt `connectTimeout` | 30s | **5s** | Don't block 30s on first attempt | +| Aggressive `connectTimeout` | 8s | **5s** | Consistent 5s timeout | + +```go +const ConnReconnectInterval = 5 * time.Second +const ConnReconnectAggressiveInterval = 3 * time.Second + +// In scheduleConnectionReconnect: +connectTimeout := 5 * time.Second +if aggressiveMode { + connectTimeout = 5 * time.Second +} +``` + +#### Add `context deadline exceeded` to `isNetworkUnreachableError` + +```go +func isNetworkUnreachableError(err error) bool { + // ... existing patterns ... + if strings.Contains(s, "context deadline exceeded") { + return true + } + return false +} +``` + +This ensures that ANY timeout (including the 5s context deadline) triggers aggressive mode. + +### 4. `pkg/remote/sshclient.go` — Dial timing diagnostics (optional, temporary) + +Add lightweight timing logs to `connectInternal()` for validating the fix in real-world testing: + +```go +startDial := time.Now() +// ... dial ... +log.Printf("[conndebug] dial %s: %v", addr, time.Since(startDial)) + +startHandshake := time.Now() +// ... ssh handshake ... +log.Printf("[conndebug] ssh handshake %s: %v", addr, time.Since(startHandshake)) +``` + +These can be removed or downgraded after validation. + +## Test Plan + +| Test | Setup | Expected | +|------|-------|----------| +| Disconnect event fires before cleanup | Unit test: mock `client.Close()` that blocks 10s | `FireConnChangeEvent` fires immediately, status shows `disconnected` within 1s | +| No lifecycleLock deadlock | Unit test: call `Close()` then `Connect()` from same goroutine | `Connect()` proceeds without blocking on `lifecycleLock` | +| Fast stall detection | Unit test: simulate no keepalive response | `stalled` declared within 3s of keepalive sent | +| Fast auto-disconnect | Unit test: simulate stall persists | `disconnectOnStall` fires within 5s of stall | +| Fast reconnect | Unit test: mock `AttemptReconnect` failure with network error | Aggressive mode triggers after first failure, retries every 3s | +| Real Wi-Fi switch (manual) | Build app, switch SSIDs, observe logs | Total disconnect-to-reconnect < 15s | + +## Validation Checklist + +- [ ] `task build:backend` succeeds +- [ ] `go test ./pkg/remote/conncontroller/...` passes +- [ ] `go test ./pkg/jobcontroller/...` passes +- [ ] Manual test: switch Wi-Fi, verify reconnect in < 15s +- [ ] Manual test: system sleep/resume, verify no deadlock/popup + +## Notes + +- All changes are **hardcoded constants** — no new config fields, no schema changes, no frontend changes. +- The `degraded` health status and `inputNotifyCh` path are **left untouched** — simplified in PR #3. +- Diagnostic logging in `sshclient.go` and `jobcontroller.go` is **temporary** for validation and can be removed after confirming behavior. diff --git a/.pi/specs/phase1-gaps.md b/.pi/specs/phase1-gaps.md new file mode 100644 index 0000000000..f7fc0c596a --- /dev/null +++ b/.pi/specs/phase1-gaps.md @@ -0,0 +1,200 @@ +# Phase 1–3 Auto-Reconnect Implementation (Complete) + +**Branch:** `fix/auto-reconnect-detection-gaps` +**Date:** 2026-05-27 +**Related:** Issue #7 (problem), Issue #8 (implementation plan), Issue #9 (future: native network hooks) + +## What Phase 1 Implements + +Auto-disconnect on persistent stall in `ConnMonitor`: when a connection stalls (keepalive timeout) for longer than a configurable threshold (default 30s), the connection is forcibly closed via `conn.Close()`. This converts a zombie "Connected" state into "Disconnected," allowing the existing auto-reconnect machinery to detect the state change. + +**Files changed:** +- `pkg/remote/conncontroller/connmonitor.go` — stall tracking + disconnect logic +- `pkg/wconfig/settingsconfig.go` — `ConnStallAutoDisconnect`, `ConnStallDisconnectThreshold` +- `pkg/remote/sshclient.go` — merge function for new config fields + +## What Phase 2 Implements + +macOS sleep/wake fast-path: `NotifySystemResumeCommand` is no longer a no-op. On system resume (Electron `powerMonitor` event), the Go backend iterates all connections with durable jobs, forces disconnect on stalled zombies, and immediately spawns `AttemptReconnect()` goroutines — bypassing the 30s scheduler tick. + +**Files changed:** +- `pkg/wshrpc/wshserver/wshserver.go` — `NotifySystemResumeCommand` calls `HandleSystemResume` +- `pkg/jobcontroller/jobcontroller.go` — `HandleSystemResume` added + +## What Phase 3 Implements + +Aggressive reconnect scheduler: when `AttemptReconnect` fails with a network-unreachable error (dial tcp i/o timeout, no route, DNS failure), the scheduler switches from 30s to 5s interval for 2 minutes. This catches Wi-Fi/VPN return quickly without any native modules. + +**Files changed:** +- `pkg/jobcontroller/jobcontroller.go` — `isNetworkUnreachableError`, aggressive mode in `scheduleConnectionReconnect` + +## Gaps Found + +### GAP-1: Disconnect→Reconnect loop is incomplete (critical) + +**The auto-disconnect fires, but nothing reconnects the connection automatically.** + +After `disconnectOnStall()` calls `conn.Close()`: + +1. `conn.Status` → `Disconnected` + `FireConnChangeEvent()` fires +2. `handleConnChangeEvent` records `cs.actual = false` → `reconcileConn` → `onConnectionDown()` (just logs, no reconnect) +3. Individual job routes go down → `handleRouteDownEvent` → `attemptAutoReconnect(jobId, connName)` +4. `attemptAutoReconnect` checks `conncontroller.IsConnected(connName)` → returns `false` → **logs "connection is down, skipping auto-reconnect"** and returns +5. Connection stays **Disconnected** indefinitely — user must still manually reconnect + +**Root cause:** `onConnectionUp` (which triggers `ReconnectJob` for all durable sessions) only fires when a connection transitions **to** Connected. `attemptAutoReconnect` only tries when the connection is **still up** after a route drops. Neither mechanism calls `conn.Connect()` to bring a Disconnected connection back. + +**Impact:** The user experience is essentially unchanged — they still see a disconnected session and must manually click "Connect." The only improvement is that the state accurately shows "Disconnected" instead of a zombie "Connected (stalled)" state. + +**Fix needed:** A mechanism that calls `conn.Connect()` when a connection becomes Disconnected and has durable jobs that need it. Options: + +| Option | Description | Effort | +|--------|-------------|--------| +| A. `onConnectionDown` reconnect scheduler | When connection goes down with running durable jobs, schedule periodic `Connect()` attempts (e.g., every 30s for 5 min) | ~80 lines in `jobcontroller.go` | +| B. Phase 2: `NotifySystemResumeCommand` | On macOS wake, force `Connect()` for all previously-connected sessions | ~50 lines in `wshserver.go` + helper | +| C. Phase 3: network-online polling | Detect network return and trigger `Connect()` | ~100 lines, cross-platform | +| D. Hybrid A+B | A for general network drops, B for immediate wake response | Recommended | + +Option A is the most general — it covers sleep/wake, Wi-Fi drops, VPN changes, and any other network interruption without platform-specific detection. Option B adds an immediate fast-path for the most common user-facing scenario (macOS wake). + +--- + +### GAP-2: Urgent guard prevents disconnect on dead connections (bug) + +When a connection is truly dead (TCP RST never received — the exact macOS sleep scenario), user keystrokes still call `NotifyInput()` which updates `LastInputTime`. This makes `isUrgent()` return `true` indefinitely, because the user keeps typing into a dead socket. + +The stall-disconnect code: +```go +} else if !urgent { + // only disconnects if user ISN'T typing + thresholdMs := cm.getStallDisconnectThresholdMs() + if now-stallStart > thresholdMs { + cm.disconnectOnStall() + } +} +``` + +**If `urgent` stays true, `disconnectOnStall()` never fires.** The connection remains in zombie "Connected + Stalled" state forever — the exact problem Phase 1 is meant to fix. + +**Fix:** When the connection health status is already `Stalled`, the urgent guard should be relaxed. Options: + +| Option | Description | +|--------|-------------| +| A. Remove urgent guard entirely for stall-disconnect | User typing on a stalled connection is going nowhere — disconnect regardless | +| B. Cap urgent-protected stall duration | Still honor urgent for the first threshold period, but disconnect after 2× threshold even if urgent | +| C. Check stall health in urgent | `urgent = isUrgent() && cm.Conn.GetConnHealthStatus() != ConnHealthStatus_Stalled` | + +Option A is simplest and most correct: if the keepalive monitor says the connection is stalled, the user's keystrokes are not reaching the remote. Disconnecting is the right action regardless of local input activity. + +--- + +### GAP-3: No unit tests + +No tests exist for any of the new code: +- `disconnectOnStall()` logic +- `getStallDisconnectThresholdMs()` config reading +- `shouldAutoDisconnectOnStall()` config reading +- `StallStartTime` tracking in `checkConnection()` +- `urgent` guard interaction with stall-disconnect + +The existing test pattern in `jobcontroller_test.go` uses hand-written mocks and `t.Parallel()`. New tests should follow the same pattern. + +**Suggested test cases:** + +| Test | Scenario | Expected | +|------|----------|----------| +| StallDisconnectAfterThreshold | Stall persists >30s, not urgent | `conn.Close()` called | +| NoDisconnectWhenUrgent | Stall persists >30s, user typing | `conn.Close()` NOT called (current behavior) | +| NoDisconnectWhenStallClears | Stall <30s then clears | `StallStartTime` reset, no disconnect | +| DisconnectRespectsConfig | `ConnStallAutoDisconnect=false` | `conn.Close()` NOT called | +| ThresholdFromConfig | `ConnStallDisconnectThreshold=10` (10s) | Disconnect after 10s, not 30s | +| UrgentOnDeadConnection | Stall + user typing on dead socket | Should disconnect (requires GAP-2 fix) | + +--- + +### GAP-4: No user-facing documentation + +New config keywords `conn:stallautodisconnect` and `conn:stalldisconnectthreshold` are not documented in `docs/docs/connections.mdx`. Users have no way to discover or configure these settings. + +**Fix:** Add a table entry in `docs/docs/connections.mdx` (similar to existing `conn:askbeforewshinstall` entry): + +| Keyword | Type | Default | Description | +|---------|------|---------|-------------| +| `conn:stallautodisconnect` | bool | true | Automatically disconnect SSH connections when stalled for the threshold duration | +| `conn:stalldisconnectthreshold` | int (seconds) | 30 | How long (in seconds) a connection must be stalled before auto-disconnecting | + +--- + +### GAP-5: `StallStartTime` not explicitly reset on monitor recreation + +When a connection reconnects, `connectInternal()` creates a new `ConnMonitor` via `MakeConnMonitor()`. The new `StallStartTime` defaults to 0 (correct for `atomic.Int64`). However, if the old monitor's `Close()` is called while a stall-disconnect goroutine is still running, there's a subtle timing window: + +1. `disconnectOnStall()` launches `go func() { cm.Conn.Close() }()` +2. `Close()` on the connection calls `conn.Monitor.Close()` (cancels the ticker) +3. The goroutine from step 1 still references `cm` (the old monitor) +4. `conn.Close()` runs (from both the goroutine AND the `waitForDisconnect` path) + +This is not a functional bug — `Close()` is idempotent (checks status via `lifecycleLock`) — but it means two `Close()` calls happen, one from the stall-disconnect goroutine and one from `waitForDisconnect`. The log message `"disconnecting due to persistent stall"` fires before `Close()`, which is fine for debugging. + +**Assessment:** Low risk, no code change needed. The `lifecycleLock` in `Close()` prevents double-status-change. Just noting for awareness. + +--- + +### GAP-6: Config field naming — unit not obvious + +`ConnStallDisconnectThreshold` is stored as `*int` in seconds, but the field name doesn't indicate the unit. Compare with `SshPort` (also `*string`, not `*int16` — different concern but same pattern of implicit units). + +**Suggestion:** Rename to `ConnStallDisconnectThresholdSec` or add a doc comment. Low priority — the `getStallDisconnectThresholdMs()` conversion makes the unit clear in code. + +--- + +## Priority Order + +| Priority | Gap | Action | Impact | +|----------|-----|--------|--------| +| **P0** | GAP-1 | Implement disconnect→reconnect loop | Without this, Phase 1 doesn't actually fix the user-facing problem | +| **P0** | GAP-2 | Fix urgent guard on dead connections | Without this, Phase 1 doesn't fire in the primary scenario (macOS sleep) | +| **P1** | GAP-3 | Add unit tests | No coverage for new logic | +| **P1** | GAP-4 | Add documentation | Users can't discover new config | +| **P2** | GAP-5 | Document timing window | No code change, awareness only | +| **P2** | GAP-6 | Consider field rename | Cosmetic | + +## Resolution + +All gaps were addressed across commits `a157b234` (Phase 1 fixes), `1672eb37` (Phase 2 wake fast-path), and `ec341ebb` (Phase 3 aggressive scheduler). + +| Phase | Gap | Fix | Lines | File(s) | +|-------|-----|-----|-------|---------| +| Phase 1 | GAP-1 | Reconnect scheduler in `onConnectionDown` + `AttemptReconnect` helper | ~120 | `pkg/jobcontroller/jobcontroller.go`, `pkg/remote/conncontroller/conncontroller.go` | +| Phase 1 | GAP-2 | Remove `!urgent` guard from stall-disconnect | 2 | `pkg/remote/conncontroller/connmonitor.go` | +| Phase 1 | GAP-3 | 11 new tests in `conncontroller_test.go`, 2 in `jobcontroller_test.go` | ~300 | `pkg/remote/conncontroller/conncontroller_test.go`, `pkg/jobcontroller/jobcontroller_test.go` | +| Phase 1 | GAP-4 | Document `conn:stallautodisconnect` and `conn:stalldisconnectthreshold` | 2 | `docs/docs/connections.mdx` | +| Phase 2 | — | `NotifySystemResumeCommand` fast-path wake | ~35 | `pkg/wshrpc/wshserver/wshserver.go`, `pkg/jobcontroller/jobcontroller.go` | +| Phase 3 | — | Aggressive scheduler (5s interval on network-unreachable) | ~50 | `pkg/jobcontroller/jobcontroller.go` | + +**Test hooks added:** `connectInternalTestHook`, `getConnectionConfigTestHook` in `conncontroller.go`; `hasRunningDurableJobsTestHook` in `jobcontroller.go`. + +## End-to-End Auto-Reconnect Flow + +### Sleep/Wake (macOS) +1. macOS sleep → network drops +2. Wake → `powerMonitor.resume` → `NotifySystemResumeCommand` +3. `HandleSystemResume` → disconnect stalled + `AttemptReconnect()` immediately +4. Network back? `Connect()` succeeds → `onConnectionUp` → `ReconnectJob` +5. **Delay: ~1-2s** + +### Wi-Fi/VPN Change +1. Wi-Fi drops/VPN toggles → connection stalls +2. Stall >30s → `disconnectOnStall()` → `Status = Disconnected` +3. `onConnectionDown` → scheduler starts +4. First `AttemptReconnect` fails with `dial tcp: i/o timeout` +5. Scheduler switches to aggressive: **5s interval for 2 minutes** +6. User switches to good Wi-Fi → next 5s tick: `Connect()` succeeds +7. **Delay: ~5-10s** (vs. ~60s before) + +### General Network Drop +1. Network drops → keepalive stall detected (~20s) +2. Stall threshold (~30s) → auto-disconnect +3. Scheduler tries every 30s for 5 minutes +4. Network returns within 5min → auto-reconnect, sessions restored +5. **Delay: ~30-60s** (worst case, but fully automatic) \ No newline at end of file diff --git a/.pi/specs/portforwarding.md b/.pi/specs/portforwarding.md new file mode 100644 index 0000000000..a42e377bff --- /dev/null +++ b/.pi/specs/portforwarding.md @@ -0,0 +1,438 @@ +# SSH Port Forwarding Implementation Spec + +## Problem + +Wave Terminal parses `~/.ssh/config` for connection settings but ignores `LocalForward`, `RemoteForward`, and `DynamicForward` directives. Users who define port forwarding rules in their SSH config get no forwarding when connecting through Wave. + +## Scope + +- **In scope**: `LocalForward` and `RemoteForward` parsed from `~/.ssh/config` and `connections.json` +- **Out of scope**: `DynamicForward` (requires SOCKS5 handler not in stdlib), CLI flags on `wsh ssh`, UI status indicators + +## Current Architecture + +``` +~/.ssh/config ──┐ + │ +connections.json ─┼──→ findSshConfigKeywords() / ConnKeywords struct + │ +wsh ssh flags ─┘ + │ + ▼ + ConnectToClient() — merges keywords, creates *ssh.Client + │ + ▼ + SSHConn.connectInternal() — stores client, starts monitor/wsh + │ + ▼ + SSHConn.Close() — tears down client, monitor, domain socket +``` + +The merged `ConnKeywords` are consumed inside `ConnectToClient()` to build `ssh.ClientConfig` and are **never returned** to the caller. `conncontroller` only receives `connFlags` (CLI/frontend flags), not the full merged config. + +## Changes + +### 1. `pkg/wconfig/settingsconfig.go` — ConnKeywords struct + +Add two fields to `ConnKeywords`: + +```go +SshLocalForward []string `json:"ssh:localforward,omitempty"` +SshRemoteForward []string `json:"ssh:remoteforward,omitempty"` +``` + +Placement: after `SshGlobalKnownHostsFile`, before the closing `}`. + +### 2. `pkg/remote/sshclient.go` — Config parsing + +#### 2a. `findSshConfigKeywords()` — Parse from `~/.ssh/config` + +Add after the `GlobalKnownHostsFile` parsing block (before the `return`): + +```go +localForwardRaw := WaveSshConfigUserSettings().GetAll(hostPattern, "LocalForward") +for i := 0; i < len(localForwardRaw); i++ { + localForwardRaw[i] = trimquotes.TryTrimQuotes(localForwardRaw[i]) +} +sshKeywords.SshLocalForward = localForwardRaw + +remoteForwardRaw := WaveSshConfigUserSettings().GetAll(hostPattern, "RemoteForward") +for i := 0; i < len(remoteForwardRaw); i++ { + remoteForwardRaw[i] = trimquotes.TryTrimQuotes(remoteForwardRaw[i]) +} +sshKeywords.SshRemoteForward = remoteForwardRaw +``` + +This follows the exact pattern used for `IdentityFile` (multi-value keyword via `GetAll` + quote trimming). + +#### 2b. `findSshDefaults()` — Default values + +Add to the defaults function: + +```go +sshKeywords.SshLocalForward = []string{} +sshKeywords.SshRemoteForward = []string{} +``` + +#### 2c. `mergeKeywords()` — Cascade merging + +Add to the merge function (follows the `SshProxyJump` pattern): + +```go +if newKeywords.SshLocalForward != nil { + outKeywords.SshLocalForward = newKeywords.SshLocalForward +} +if newKeywords.SshRemoteForward != nil { + outKeywords.SshRemoteForward = newKeywords.SshRemoteForward +} +``` + +#### 2d. `ConnectToClient()` — Return merged keywords + +Change the signature from: + +```go +func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh.Client, jumpNum int32, connFlags *wconfig.ConnKeywords) (*ssh.Client, int32, error) +``` + +To: + +```go +func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh.Client, jumpNum int32, connFlags *wconfig.ConnKeywords) (*ssh.Client, int32, *wconfig.ConnKeywords, error) +``` + +The `sshKeywords` variable already exists at the point of the final return. Change all return statements: + +- `return nil, jumpNum, ConnectionError{...}` → `return nil, jumpNum, nil, ConnectionError{...}` +- `return client, debugInfo.JumpNum, nil` → `return client, debugInfo.JumpNum, sshKeywords, nil` +- `return nil, debugInfo.JumpNum, ConnectionError{...}` → `return nil, debugInfo.JumpNum, nil, ConnectionError{...}` + +### 3. `pkg/remote/conncontroller/conncontroller.go` — Runtime forwarding + +#### 3a. `SSHConn` struct — Store forwarding state + +Add fields: + +```go +LocalForwardListeners []net.Listener // local listeners for LocalForward +RemoteForwardListeners []net.Listener // remote listeners (from client.Listen) for RemoteForward +``` + +#### 3b. `copyBoth` helper (unexported) + +Add near the `startPortForwarding` method (same file, package-private): + +```go +func copyBoth(a net.Conn, b net.Conn) { + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + io.Copy(a, b) + }() + go func() { + defer wg.Done() + io.Copy(b, a) + }() + wg.Wait() + a.Close() + b.Close() +} +``` + +#### 3c. Forwarding setup function + +Add a new unexported method: + +```go +func (conn *SSHConn) startPortForwarding(ctx context.Context, keywords *wconfig.ConnKeywords) { + client := conn.GetClient() + if client == nil { + return + } + + // LocalForward: listen locally, dial through SSH to remote + for _, fwd := range keywords.SshLocalForward { + parts := strings.Fields(fwd) + if len(parts) != 2 { + conn.Infof(ctx, "LocalForward: skipping malformed rule: %q\n", fwd) + continue + } + bindAddr, dest := parts[0], parts[1] + go func() { + defer panichandler.PanicHandler("conncontroller:localforward", recover()) + listener, err := net.Listen("tcp", bindAddr) + if err != nil { + conn.Infof(ctx, "LocalForward %s: failed to listen: %v\n", fwd, err) + return + } + conn.WithLock(func() { + conn.LocalForwardListeners = append(conn.LocalForwardListeners, listener) + }) + conn.Infof(ctx, "LocalForward started: %s -> %s\n", bindAddr, dest) + for { + localConn, err := listener.Accept() + if err != nil { + return + } + go func(dest string) { + defer panichandler.PanicHandler("conncontroller:localforward-tunnel", recover()) + remoteConn, err := client.Dial("tcp", dest) + if err != nil { + localConn.Close() + return + } + copyBoth(localConn, remoteConn) + }(dest) + } + }() + } + + // RemoteForward: listen on remote via SSH, dial locally + for _, fwd := range keywords.SshRemoteForward { + parts := strings.Fields(fwd) + if len(parts) != 2 { + conn.Infof(ctx, "RemoteForward: skipping malformed rule: %q\n", fwd) + continue + } + bindAddr, dest := parts[0], parts[1] + go func() { + defer panichandler.PanicHandler("conncontroller:remoteforward", recover()) + listener, err := client.Listen("tcp", bindAddr) + if err != nil { + conn.Infof(ctx, "RemoteForward %s: failed to listen: %v\n", fwd, err) + return + } + conn.WithLock(func() { + conn.RemoteForwardListeners = append(conn.RemoteForwardListeners, listener) + }) + conn.Infof(ctx, "RemoteForward started: %s -> %s\n", bindAddr, dest) + for { + remoteConn, err := listener.Accept() + if err != nil { + return + } + go func(dest string) { + defer panichandler.PanicHandler("conncontroller:remoteforward-tunnel", recover()) + localConn, err := net.Dial("tcp", dest) + if err != nil { + remoteConn.Close() + return + } + copyBoth(localConn, remoteConn) + }(dest) + } + }() + } +} +``` + +Notes: +- Follows the existing goroutine pattern: `defer panichandler.PanicHandler("...", recover())` +- Listeners are stored on the struct via `conn.WithLock` for cleanup +- Uses `conn.Infof` for debug logging (consistent with existing connection debug output) +- `copyBoth` (unexported helper) for bidirectional tunneling (spawns two `io.Copy` goroutines, waits for both, then closes both connections) + +#### 3d. `connectInternal()` — Call forwarding setup + +Change the `ConnectToClient` call to capture the merged keywords: + +```go +client, _, sshKeywords, err := remote.ConnectToClient(ctx, conn.Opts, nil, 0, connFlags) +``` + +After the client is stored and the monitor is started, add: + +```go +// Start port forwarding with merged SSH config keywords +if sshKeywords != nil { + conn.startPortForwarding(ctx, sshKeywords) +} +``` + +Placement: after the `conn.WithLock` block that sets `conn.Client` and `conn.Monitor`, before the `waitForDisconnect` goroutine. + +#### 3e. `closeInternal_withlifecyclelock()` — Cleanup + +Add forwarding listener capture alongside the existing `oldListener` capture, then close them in the cleanup goroutine: + +```go +var oldLocalForwardListeners []net.Listener +var oldRemoteForwardListeners []net.Listener +conn.WithLock(func() { + // ... existing oldClient, oldListener, oldController, oldMonitor capture ... + oldLocalForwardListeners = conn.LocalForwardListeners + conn.LocalForwardListeners = nil + oldRemoteForwardListeners = conn.RemoteForwardListeners + conn.RemoteForwardListeners = nil +}) + +// In the cleanup goroutine (after oldMonitor.Close()): +for _, l := range oldLocalForwardListeners { + l.Close() +} +for _, l := range oldRemoteForwardListeners { + l.Close() +} +``` + +This follows the existing pattern: references are captured and nilled under `conn.WithLock` (protected by the `expectedClient` stale-goroutine guard), then closed in the background goroutine so `lifecycleLock` is freed immediately. + +### 4. Call site updates + +Every caller of `remote.ConnectToClient` must handle the new 4th return value. + +#### `pkg/remote/conncontroller/conncontroller.go` + +Already covered in 3c above. + +#### `cmd/test-conn/main-test-conn.go` + +No direct `ConnectToClient` calls in `cmd/test-conn/` — it uses `conn.Connect()` → `connectInternal()` → `ConnectToClient()` indirectly. No changes needed. + +#### Other direct call sites + +Run `grep -rn "ConnectToClient" --include="*.go" .` to find any direct callers. As of this spec, only these direct calls exist: +- `pkg/remote/sshclient.go` — the function definition and the recursive ProxyJump call +- `pkg/remote/conncontroller/conncontroller.go` — `connectInternal` + +The recursive ProxyJump call (line ~1057) should capture the returned keywords with `_` since proxy connections don't need forwarding. + +### 5. Tests + +#### `pkg/remote/sshclient_test.go` (new file) + +Table-driven tests for config parsing. No network required. + +```go +package remote + +import "testing" + +func TestFindSshConfigKeywords_LocalForward(t *testing.T) { + t.Parallel() + // Uses a temp ~/.ssh/config with LocalForward directives + // Verifies SshLocalForward is populated correctly +} + +func TestMergeKeywords_LocalForward(t *testing.T) { + t.Parallel() + tests := []struct { + name string + old *wconfig.ConnKeywords + new *wconfig.ConnKeywords + wantLocal []string + wantRemote []string + }{ + { + name: "new overrides old", + old: &wconfig.ConnKeywords{SshLocalForward: []string{"8080 localhost:80"}}, + new: &wconfig.ConnKeywords{SshLocalForward: []string{"9090 localhost:90"}}, + wantLocal: []string{"9090 localhost:90"}, + }, + { + name: "nil new preserves old", + old: &wconfig.ConnKeywords{SshLocalForward: []string{"8080 localhost:80"}}, + new: &wconfig.ConnKeywords{}, + wantLocal: []string{"8080 localhost:80"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mergeKeywords(tt.old, tt.new) + // assert got.SshLocalForward matches tt.wantLocal + }) + } +} +``` + +#### `pkg/remote/conncontroller/conncontroller_test.go` (new file) + +Integration-style test using `net.Listener` (no real SSH): + +```go +package conncontroller + +func TestLocalForwardStartsAndStops(t *testing.T) { + // Create a mock SSHConn with a real net.Listener as the "remote" + // Verify LocalForward listener is created on startPortForwarding + // Verify it's closed on closeInternal_withlifecyclelock +} +``` + +This follows the `sshagent_unix_test.go` pattern: real sockets, no SSH daemon. + +### 6. Documentation + +#### `docs/docs/connections.mdx` + +**SSH Config Parsing table** — Add rows: + +| Keyword | Description | +|---------|-------------| +| LocalForward | Can be specified multiple times. Format: `bind_address destination` (e.g., `8080 localhost:80` or `127.0.0.1:8080 localhost:80`). Listens on the local machine and forwards connections through the SSH tunnel to the remote destination. | +| RemoteForward | Can be specified multiple times. Format: `bind_address destination` (e.g., `9090 localhost:3000`). Listens on the remote machine and forwards connections back to the local destination. Requires `AllowTcpForwarding` on the remote sshd. | + +**Internal SSH Configuration table** — Add rows: + +| Keyword | Description | +|---------|-------------| +| ssh:localforward | A list of strings for local port forwarding rules. Format: `"8080 localhost:80"`. Can be used to override or supplement `~/.ssh/config` values. | +| ssh:remoteforward | A list of strings for remote port forwarding rules. Format: `"9090 localhost:3000"`. Can be used to override or supplement `~/.ssh/config` values. | + +**New example section** after "Example SSH Config Host": + +```markdown +### Port Forwarding + +Port forwarding rules from `~/.ssh/config` are automatically applied when you connect through Wave: + +``` +Host myserver + User username + HostName 203.0.113.254 + LocalForward 8080 localhost:80 + RemoteForward 9090 localhost:3000 +``` + +Connecting to `myserver` will listen on local port 8080 (forwarded to the remote's localhost:80) and listen on the remote's port 9090 (forwarded to your local localhost:3000). + +Port forwarding can also be defined entirely in `connections.json`: + +```json +{ + "myusername@myhost": { + "ssh:localforward": ["8080 localhost:80"], + "ssh:remoteforward": ["9090 localhost:3000"] + } +} +``` +``` + +#### `docs/docs/releasenotes.mdx` + +Add entry under the current development version. + +## Error Handling + +- Malformed forwarding rules (wrong number of fields) are logged via `conn.Infof` and skipped — they never break the connection +- Listener bind failures (port already in use) are logged — the connection proceeds without that specific forward +- Tunnel dial failures log and close the individual connection — other tunnels and the SSH session continue +- All forwarding goroutines use `panichandler.PanicHandler` to prevent crashes from propagating + +## Lifecycle + +| Event | Action | +|-------|--------| +| Connect starts | `ConnectToClient` returns merged keywords including forwarding rules | +| Client established | `startPortForwarding` spawns goroutines, stores listeners on `SSHConn` | +| Connection active | Tunnels run via `copyBoth`; SSH transport activity keeps connection alive | +| Disconnect starts | `closeInternal_withlifecyclelock` closes all forwarding listeners under `lifecycleLock` | +| Client closes | `client.Close()` tears down remote listeners and all in-flight tunnels | + +## Out of Scope (Future) + +- **`DynamicForward`** — Requires a SOCKS5 proxy handler. The `golang.org/x/crypto/ssh` library has no built-in one. Would need a third-party package or custom ~200-line SOCKS5 implementation. +- **`wsh ssh -L` / `-R` CLI flags** — Can be added to `wshcmd-ssh.go` later, following the existing `-i`/`-l`/`-p` flag pattern. +- **UI status indicator** — A block header icon showing active port forwards (similar to the wsh icon). +- **`GatewayPorts`** support — The `ssh` keyword for binding remote forwards to all interfaces. diff --git a/.pi/specs/reconnect-ui-overlay.md b/.pi/specs/reconnect-ui-overlay.md new file mode 100644 index 0000000000..63f75685c6 --- /dev/null +++ b/.pi/specs/reconnect-ui-overlay.md @@ -0,0 +1,337 @@ +# Reconnect UI Overlay with Retry Transparency (PR #2) + +## Problem + +When a connection drops, the user experience is poor: + +1. **No immediate feedback on disconnect**: The terminal just stops responding. The `disconnected` status is only visible in a small toolbar icon — there's no overlay explaining what happened. +2. **No visibility into retry attempts**: The reconnect scheduler loops in the background, but the user sees nothing — just a frozen terminal. They don't know if retry #1 failed, when retry #2 will happen, or whether the app is even trying. +3. **Stale `stalled` overlay**: The existing `StalledOverlay` only shows after `stalled` is declared (which may still take seconds even with PR #1's faster thresholds). During `disconnected` → `connecting` → retry cycles, there's zero UI feedback. + +## Scope + +- **In scope**: Rich overlay for `disconnected`, `connecting` (retry), and inter-retry countdown; backend events to feed the UI. +- **Out of scope**: Configurable thresholds (PR #3); changing the monitor/scheduler logic itself (PR #1 handles that). + +## Desired User Experience + +``` +[09:16:43] User switches Wi-Fi +[09:16:45] Overlay appears: "Connection lost — retrying in 5s" +[09:16:46] Countdown: 4… 3… 2… 1… +[09:16:49] Overlay: "Attempt 1 — connecting to user@host…" +[09:16:51] Attempt fails (no route) +[09:16:51] Overlay: "No route to host — retrying in 3s" +[09:16:52] Countdown: 2… 1… +[09:16:54] Overlay: "Attempt 2 — connecting…" +[09:16:56] Wi-Fi ready! Reconnects. +[09:16:57] Overlay vanishes, terminal resumes. +``` + +Compare to current: blank frozen screen for 30–60 seconds, then sudden reconnect. + +## Architecture + +### Backend: New Retry State & Events + +The backend currently emits `connchange` events with `ConnStatus`. We need additional per-connection retry state and more granular events. + +**Option A: Extend `ConnStatus`** +Add fields to `wshrpc.ConnStatus`: +```go +type ConnStatus struct { + // ... existing fields ... + ReconnectAttempt int `json:"reconnectattempt,omitempty"` + ReconnectNextAttempt int64 `json:"reconnectnextattempt,omitempty"` // UnixMilli + ReconnectError string `json:"reconnecterror,omitempty"` +} +``` + +**Option B: Separate retry event** +Emit a new `wps.Event_ReconnectAttempt` with attempt details. Simpler, doesn't bloat `ConnStatus`. + +Recommendation: **Option A** — the overlay already consumes `ConnStatus`, so extending it is least frontend rework. + +**New events to emit from `scheduleConnectionReconnect`:** +- When attempt starts: set `ReconnectAttempt = N`, `ReconnectNextAttempt = 0`, fire `connchange` +- When attempt fails: set `ReconnectError = err.Error()`, `ReconnectNextAttempt = time.Now().Add(interval).UnixMilli()`, fire `connchange` +- When attempt succeeds: clear retry fields, fire `connchange` +- When entering aggressive mode: same pattern, shorter interval + +### Frontend: New Overlay States + +The current `ConnStatusOverlay` only handles: +- `stalled` → `StalledOverlay` (yellow warning bar) +- `error` / `disconnected` / `connected` → generic overlay + +We need a unified overlay that handles the full reconnect lifecycle: + +```tsx +// Unified states: +const showDisconnected = connStatus.status === "disconnected" && !connStatus.connected; +const showRetrying = connStatus.status === "connecting" && connStatus.reconnectattempt > 0; +const showCountdown = connStatus.reconnectnextattempt > 0 && connStatus.status === "disconnected"; +``` + +**Overlay components:** +1. **`DisconnectedOverlay`** — immediate on disconnect + - Red/yellow icon + - "Disconnected from `host`" + - Error detail (TCP error, connserver error) + - Reconnect button (manual trigger) + - If retry is scheduled: "Auto-retrying in `countdown`s" + +2. **`RetryingOverlay`** — during an active attempt + - Spinner icon + - "Attempt `N` — connecting to `host`…" + - Cancel button (stop scheduler) + +3. **`CountdownOverlay`** — between attempts + - Timer icon + - "Last attempt failed: `error`" + - "Retrying in `countdown`s" + - Reconnect now button (skip wait) + +## Changes + +### 1. Backend: `pkg/wshrpc/wshrpctypes.go` — Extend `ConnStatus` + +Add optional retry fields: + +```go +type ConnStatus struct { + Status string `json:"status"` + ConnHealthStatus string `json:"connhealthstatus"` + WshEnabled bool `json:"wshenabled"` + Connection string `json:"connection"` + Connected bool `json:"connected"` + HasConnected bool `json:"hasconnected"` + ActiveConnNum int `json:"activeconnnum"` + Error string `json:"error"` + WshError string `json:"wsherror"` + NoWshReason string `json:"nowshreason"` + WshVersion string `json:"wshversion"` + LastActivityBeforeStalledTime int64 `json:"lastactivitybeforestalledtime,omitempty"` + KeepAliveSentTime int64 `json:"keepalivesenttime,omitempty"` + // NEW: + ReconnectAttempt int `json:"reconnectattempt,omitempty"` + ReconnectNextAttempt int64 `json:"reconnectnextattempt,omitempty"` + ReconnectError string `json:"reconnecterror,omitempty"` +} +``` + +### 2. Backend: `pkg/jobcontroller/jobcontroller.go` — Emit retry state + +In `scheduleConnectionReconnect`, before/after each `AttemptReconnect` call, update the connection's retry state and fire events: + +```go +func scheduleConnectionReconnect(connName string) { + // ... existing setup ... + attempt := 0 + for { + // ... existing checks ... + + attempt++ + updateRetryState(connName, attempt, 0, "") // active attempt + + ctx, cancelFn := context.WithTimeout(context.Background(), connectTimeout) + err := conncontroller.AttemptReconnect(ctx, connName) + cancelFn() + + if err != nil { + isNetErr := isNetworkUnreachableError(err) + interval := ConnReconnectInterval + if aggressiveMode { + interval = ConnReconnectAggressiveInterval + } + nextAttempt := time.Now().Add(interval).UnixMilli() + updateRetryState(connName, attempt, nextAttempt, err.Error()) + // ... existing logging ... + } else { + clearRetryState(connName) + return + } + + // ... wait for interval ... + } +} +``` + +Add helper functions: + +```go +func updateRetryState(connName string, attempt int, nextAttempt int64, errMsg string) { + connOpts, _ := remote.ParseOpts(connName) + conn := conncontroller.GetConn(connOpts) + if conn != nil { + conn.SetReconnectState(attempt, nextAttempt, errMsg) + conn.FireConnChangeEvent() + } +} + +func clearRetryState(connName string) { + connOpts, _ := remote.ParseOpts(connName) + conn := conncontroller.GetConn(connOpts) + if conn != nil { + conn.ClearReconnectState() + conn.FireConnChangeEvent() + } +} +``` + +### 3. Backend: `pkg/remote/conncontroller/conncontroller.go` — Store retry state + +Add fields to `SSHConn`: + +```go +type SSHConn struct { + // ... existing fields ... + ReconnectAttempt int + ReconnectNextAttempt int64 + ReconnectError string +} +``` + +Add methods: + +```go +func (conn *SSHConn) SetReconnectState(attempt int, nextAttempt int64, err string) { + conn.WithLock(func() { + conn.ReconnectAttempt = attempt + conn.ReconnectNextAttempt = nextAttempt + conn.ReconnectError = err + }) +} + +func (conn *SSHConn) ClearReconnectState() { + conn.WithLock(func() { + conn.ReconnectAttempt = 0 + conn.ReconnectNextAttempt = 0 + conn.ReconnectError = "" + }) +} +``` + +Update `DeriveConnStatus()` to include retry fields: + +```go +func (conn *SSHConn) DeriveConnStatus() wshrpc.ConnStatus { + var status wshrpc.ConnStatus + conn.WithLock(func() { + status = wshrpc.ConnStatus{ + // ... existing fields ... + ReconnectAttempt: conn.ReconnectAttempt, + ReconnectNextAttempt: conn.ReconnectNextAttempt, + ReconnectError: conn.ReconnectError, + } + }) + return status +} +``` + +### 4. Frontend: `frontend/app/block/connstatusoverlay.tsx` — New overlay states + +Refactor `ConnStatusOverlay` to handle the full lifecycle: + +```tsx +export const ConnStatusOverlay = React.memo(...) => { + // ... existing setup ... + const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connName)); + + const showStalled = connStatus.status === "connected" && connStatus.connhealthstatus === "stalled"; + const showDisconnected = connStatus.status === "disconnected" && !connStatus.connected; + const showRetrying = connStatus.status === "connecting" && connStatus.reconnectattempt > 0; + const showCountdown = connStatus.reconnectnextattempt > 0 && connStatus.status === "disconnected"; + + if (showStalled && !showWshError) { + return ; + } + + if (showRetrying) { + return ; + } + + if (showCountdown) { + return ; + } + + if (showDisconnected) { + return ; + } + // ... +}; +``` + +**New components to implement:** + +1. `DisconnectedOverlay` — shows immediately on disconnect, with error detail and optional countdown +2. `RetryingOverlay` — spinner + "Attempt N — connecting…" +3. `CountdownOverlay` — countdown timer that updates every second, shows last error, "Reconnect now" button + +The `CountdownOverlay` needs a `useEffect` with `setInterval(1000)` to compute `Math.max(0, nextAttempt - Date.now())`. + +### 5. Frontend: `frontend/app/block/connectionbutton.tsx` — Update status icon + +Update the toolbar icon logic to show retry states: + +```tsx +} else if (connStatus?.status === "connecting" && connStatus?.reconnectattempt > 0) { + color = "var(--warning-color)"; + iconName = "fa-solid fa-rotate"; + titleText = `Reconnecting to ${connection} (attempt ${connStatus.reconnectattempt})`; +} else if (connStatus?.status === "disconnected" && connStatus?.reconnectnextattempt > 0) { + color = "var(--grey-text-color)"; + iconName = "fa-solid fa-clock"; + titleText = `Disconnected from ${connection} — retrying soon`; +} +``` + +### 6. Schema/Types: `frontend/types/gotypes.d.ts` — Update `ConnStatus` + +Add the new fields to the TypeScript type definition: + +```typescript +interface ConnStatus { + status: string; + connhealthstatus: string; + // ... existing fields ... + reconnectattempt?: number; + reconnectnextattempt?: number; + reconnecterror?: string; +} +``` + +## Test Plan + +| Test | Setup | Expected | +|------|-------|----------| +| Disconnect overlay | Block `client.Close()` for 5s, trigger disconnect | Overlay shows "Disconnected" within 1s (not after 5s) | +| Retry overlay | Start reconnect scheduler | Overlay shows "Attempt 1 — connecting…" during `AttemptReconnect` | +| Countdown overlay | Failed attempt with 5s interval | Overlay shows countdown 5…4…3…2…1… then "Attempt 2" | +| Toolbar icon | Same scenarios | Icon changes from green → warning spinner → grey clock | +| Manual reconnect | Click "Reconnect now" during countdown | Immediate `AttemptReconnect`, skipping countdown | +| Success clears overlay | Reconnect succeeds | Overlay vanishes, terminal resumes | + +## Validation Checklist + +- [ ] `task build:backend` succeeds +- [ ] `task build:frontend` succeeds +- [ ] `go test ./pkg/remote/conncontroller/...` passes +- [ ] `go test ./pkg/jobcontroller/...` passes +- [ ] Manual test: disconnect, verify overlay sequence (disconnected → countdown → retry → connected) +- [ ] Manual test: verify toolbar icon reflects each state + +## Dependencies + +- **Requires PR #1**: Fast reconnect with hardcoded thresholds must be merged first, or the overlay will still show 30s+ delays. +- **No dependency on PR #3**: This is purely UI + event plumbing, independent of configurable thresholds. diff --git a/.pi/specs/remove-telemetry.md b/.pi/specs/remove-telemetry.md new file mode 100644 index 0000000000..30468a898a --- /dev/null +++ b/.pi/specs/remove-telemetry.md @@ -0,0 +1,575 @@ +# Spec: Remove Telemetry + +**Date:** 2026-05-13 +**Status:** Implemented +**Review:** `.pi/reviews/remove-telemetry-independent-review.md` + +## Goal + +Completely remove all telemetry, analytics, and user tracking from waveterm. No data should be collected locally or sent to external servers. + +## What Telemetry Does Today + +1. **Local collection** — Events and activity metrics stored in SQLite (`db_tevent`, `db_activity` tables) +2. **Periodic upload** — Sent to `https://api.waveterm.dev/central` every 4 hours, on startup, on shutdown +3. **Diagnostics ping** — Sent to `https://ping.waveterm.dev/central` once on startup, then at most once/day +4. **Opt-out notification** — When user disables telemetry, a single record is sent to `/no-telemetry` +5. **Electron activity tracking** — `emain/emain.ts` collects display info, foreground/active state, terminal command counts, and AI usage minutes; sends via `ActivityCommand` and `RecordTEventCommand` +6. **wsh CLI activity** — Every `wsh` command reports its name and success/failure via `WshActivityCommand` + +## Scope + +### What to remove + +- All event recording (`RecordTEvent`, `GoRecordTEventWrap`) +- All activity tracking (`UpdateActivity`, `GoUpdateActivityWrap`, `WshActivityCommand`) +- All cloud uploads (`wcloud.SendAllTelemetry`, `wcloud.SendDiagnosticPing`, `wcloud.SendNoTelemetryUpdate`) +- Telemetry config setting (`telemetry:enabled`) +- Telemetry loops in `main-server.go` (including `diagnosticLoop`) +- Telemetry RPC commands (including `WshActivityCommand`) +- Telemetry call sites in all packages and `emain/` +- `pkg/telemetry/` and `pkg/wcloud/` directories +- Telemetry documentation +- `WAVETERM_NOPING` env var handling +- `WCLOUD_ENDPOINT` / `WCLOUD_PING_ENDPOINT` env var handling (via `wcloud.CacheAndRemoveEnvVars`) +- Onboarding telemetry consent flow +- Electron activity tracking (`emain-activity.ts` increment functions, IPC handler) +- `telemetryrequired.tsx` (telemetry consent gate for AI panel) + +### What to keep + +- `wstore.GetClientId()` / `SetClientId()` — ClientId is used for non-telemetry purposes (durable sessions, remote SSH, job manager) +- `pkg/waveobj/wtype.go` `TosAgreed` field — Keep for now; removing it requires DB migration schema changes. `AgreeTos()` in `clientservice.go` stays since it's a TOS acceptance, not telemetry +- `autoupdate:*` settings — `AutoUpdateEnabled`, `AutoUpdateChannel`, etc. are genuine auto-update config, not telemetry. Keep all `autoupdate` fields in `SettingsConfig` and `metaconsts.go` + +### Auto-generated files warning + +The following TypeScript files are **auto-generated** by `cmd/generatets/main-generatets.go` and **must not be edited manually** — changes will be overwritten on rebuild: + +- `frontend/types/gotypes.d.ts` (contains `ActivityUpdate`, `TEvent`, `TEventProps`, `TEventUserProps`, `telemetry:enabled` type) +- `frontend/app/store/services.ts` (contains `TelemetryUpdate()` method) +- `frontend/app/store/wshclientapi.ts` (contains `ActivityCommand()`, `RecordTEventCommand()`, `SendTelemetryCommand()`, `WaveAIEnableTelemetryCommand()`, `WshActivityCommand()`) + +Instead, remove the Go source types/methods that feed the generator (Phase A), then regenerate. After regeneration, also remove manual frontend call sites that reference these methods (Phase B). + +Similarly, `pkg/wshrpc/wshclient/wshclient.go` is auto-generated by `cmd/generatego/main-generatego.go`. Remove the Go source types (Phase A.3), then regenerate. + +## Implementation Phases + +### Phase A: Remove call sites (make everything no-op) + +**Goal:** Every telemetry call site is removed. The app builds and runs without collecting any data. `pkg/telemetry/` and `pkg/wcloud/` stay intact but orphaned. + +#### A.1: Remove telemetry from main server loops + +**File:** `cmd/server/main-server.go` + +- Remove imports: `telemetry`, `telemetrydata`, `wcloud` +- Remove constants: `InitialTelemetryWait`, `TelemetryTick`, `TelemetryInterval`, `TelemetryInitialCountsWait`, `TelemetryCountsInterval`, `InitialDiagnosticWait`, `DiagnosticTick` +- Remove functions: `telemetryLoop()`, `diagnosticLoop()`, `sendDiagnosticPing()`, `setupTelemetryConfigHandler()`, `panicTelemetryHandler()`, `sendTelemetryWrapper()`, `updateTelemetryCounts()`, `updateTelemetryCountsLoop()`, `beforeSendActivityUpdate()`, `startupActivityUpdate()`, `shutdownActivityUpdate()` +- Remove from startup sequence: + - `sendTelemetryWrapper()` call + - `go telemetryLoop()` + - `go diagnosticLoop()` + - `setupTelemetryConfigHandler()` + - `go updateTelemetryCountsLoop()` + - `wcloud.CacheAndRemoveEnvVars()` call + - `wcloud.SendDiagnosticPing()` call + - `panichandler.PanicTelemetryHandler = panicTelemetryHandler` + - `go startupActivityUpdate(firstLaunch)` call + - `shutdownActivityUpdate()` call +- Remove all `telemetry.UpdateActivity()` calls +- Remove all `telemetry.RecordTEvent()` calls (`app:startup`, `app:shutdown`, `app:counts`, `app:display`, etc.) +- Remove all `telemetrydata.TEventUserProps` and `TEventProps` construction +- Remove `telemetry.AutoUpdateChannel()`, `telemetry.IsAutoUpdateEnabled()`, `telemetry.GetTosAgreedTs()` calls (these only exist in telemetry payloads; the actual `autoupdate:*` settings remain in config) +- Remove `os.Getenv("WAVETERM_NOPING")` check and related code + +#### A.2: Remove telemetry from wshserver + +**File:** `pkg/wshrpc/wshserver/wshserver.go` + +- Remove imports: `telemetry`, `telemetrydata`, `wcloud` +- Remove methods: `RecordTEventCommand()`, `SendTelemetryCommand()`, `WaveAIEnableTelemetryCommand()`, `ActivityCommand()`, **`WshActivityCommand()`** +- Remove telemetry calls in `WshRunCommand()` (the `activityUpdate` and `GoRecordTEventWrap` at lines ~1338-1344) + +#### A.3: Remove telemetry RPC types + +**File:** `pkg/wshrpc/wshrpctypes.go` + +- Remove from interface: `ActivityCommand()`, `RecordTEventCommand()`, `SendTelemetryCommand()`, `WaveAIEnableTelemetryCommand()`, **`WshActivityCommand()`** +- Remove type: `ActivityUpdate` struct +- Remove import: `telemetrydata` (if no other uses) + +#### A.4: Remove telemetry RPC client helpers + +**File:** `pkg/wshrpc/wshclient/wshclient.go` (auto-generated) + +- Remove: `ActivityCommand()`, `RecordTEventCommand()`, `SendTelemetryCommand()`, `WaveAIEnableTelemetryCommand()`, **`WshActivityCommand()`** +- Remove import: `telemetrydata` (if no other uses) +- **Note:** This file is auto-generated by `cmd/generatego/main-generatego.go`. Remove the source types in A.3, then regenerate. Do NOT edit `wshclient.go` directly. + +#### A.5: Remove telemetry from block creation + +**File:** `pkg/wcore/block.go` + +- Remove imports: `telemetry`, `telemetrydata` +- Remove: `recordBlockCreationTelemetry()` function +- Remove calls to `recordBlockCreationTelemetry()` in `CreateBlock()` and `CreateBlockWithTelemetry()` +- Rename `CreateBlockWithTelemetry` to `CreateBlock` (or keep name, just remove the telemetry parameter) + +#### A.6: Remove telemetry from workspace + +**File:** `pkg/wcore/workspace.go` + +- Remove imports: `telemetry`, `telemetrydata` +- Remove: `GoUpdateActivityWrap()` and `GoRecordTEventWrap()` calls + +#### A.7: Remove telemetry from wcore + +**File:** `pkg/wcore/wcore.go` + +- Remove import: `wcloud` +- Remove: `GoSendNoTelemetryUpdate()` function + +#### A.8: Remove telemetry from connection controller + +**File:** `pkg/remote/conncontroller/conncontroller.go` + +- Remove imports: `telemetry`, `telemetrydata` +- Remove all `GoUpdateActivityWrap()` and `GoRecordTEventWrap()` calls (lines ~760, 763, 781, 784, 987) + +#### A.9: Remove telemetry from WSL connection + +**File:** `pkg/wslconn/wslconn.go` + +- Remove imports: `telemetry`, `telemetrydata` +- Remove all `GoUpdateActivityWrap()` and `GoRecordTEventWrap()` calls (lines ~515, 518, 531, 534) + +#### A.10: Remove telemetry from job controller + +**File:** `pkg/jobcontroller/jobcontroller.go` + +- Remove imports: `telemetry`, `telemetrydata` +- Remove all `GoRecordTEventWrap()` calls (lines ~730, 757, 1040, 1135, 1160) + +#### A.11: Remove telemetry from AI usechat + +**File:** `pkg/aiusechat/usechat.go` + +- Remove imports: `telemetry`, `telemetrydata` +- Remove: `sendAIMetricsTelemetry()` function +- Remove the `telemetry.IsTelemetryEnabled()` check for cloud modes (line ~86) — either remove the gate or hardcode it to pass + +#### A.12: Remove telemetry from panic handler + +**File:** `pkg/panichandler/panichandler.go` + +- Remove: `PanicTelemetryHandler` variable +- Remove the `if PanicTelemetryHandler != nil` block from `PanicHandler` +- Delete `PanicHandlerNoTelemetry` function entirely (it becomes redundant) +- **Do NOT rename** `PanicHandlerNoTelemetry` to `PanicHandler` — instead, keep `PanicHandler` as the name and strip the telemetry dispatch from it. After this change, `PanicHandler` behaves exactly as `PanicHandlerNoTelemetry` did. +- No callers outside `pkg/telemetry/` need renaming (the only callers of `PanicHandlerNoTelemetry` are in `telemetry.go`, which gets deleted in Phase C) +- Remove `panichandler.PanicTelemetryHandler = panicTelemetryHandler` from `main-server.go` (already listed in A.1) + +#### A.13: Remove telemetry from client service + +**File:** `pkg/service/clientservice/clientservice.go` + +- Remove: `TelemetryUpdate()` method +- Keep: `AgreeTos()` method (TOS acceptance, not telemetry) + +#### A.14: Remove telemetry config fields + +**File:** `pkg/wconfig/settingsconfig.go` + +- Remove from `SettingsConfig`: `TelemetryClear`, `TelemetryEnabled` +- Update `CountCustomSettings()`: Remove the `telemetry:enabled` exclusion check (line ~993). Keep the `autoupdate:channel` exclusion since that's not telemetry. + +**File:** `pkg/wconfig/metaconsts.go` + +- Remove: `ConfigKey_TelemetryClear`, `ConfigKey_TelemetryEnabled` +- **Note:** This file is auto-generated by `cmd/generatego/main-generatego.go` via `GenerateSettingsMetaConsts()`. After removing the fields from `SettingsConfig`, regenerate. + +#### A.15: Remove debug CLI command + +**File:** `cmd/wsh/cmd/wshcmd-debug.go` + +- Remove: `debugSendTelemetryCmd` and `debugSendTelemetryRun()` + +#### A.16: Remove wsh CLI activity tracking + +**File:** `cmd/wsh/cmd/wshcmd-root.go` + +- Remove: `sendActivity()` function (lines 221-231) +- Remove: `activityWrap()` function (lines 106-113) +- Remove the comment block above `sendActivity` (lines 216-218) +- Remove `wshclient` import if no other uses remain in the file +- Update all command `RunE` assignments that use `activityWrap` to use the inner function directly + +**File:** `cmd/wsh/cmd/wshcmd-file.go` + +- Change all `activityWrap("file", )` to just `` in `RunE` assignments: + - `activityWrap("file", fileListRun)` → `fileListRun` + - `activityWrap("file", fileCatRun)` → `fileCatRun` + - `activityWrap("file", fileInfoRun)` → `fileInfoRun` + - `activityWrap("file", fileRmRun)` → `fileRmRun` + - `activityWrap("file", fileWriteRun)` → `fileWriteRun` + - `activityWrap("file", fileAppendRun)` → `fileAppendRun` + - `activityWrap("file", fileCpRun)` → `fileCpRun` + - `activityWrap("file", fileMvRun)` → `fileMvRun` + +#### A.17: Remove code generator telemetrydata import + +**File:** `cmd/generatego/main-generatego.go` + +- Remove `"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata"` from the imports list in `GenerateWshClient()` (line 29) +- After Phase A.3 removes the telemetry RPC types from `wshrpctypes.go`, regenerate `wshclient.go` + +**File:** `cmd/generatets/main-generatets.go` + +- No direct changes needed — it reads Go type information via reflection. After Phase A removes the telemetry types from Go sources, regeneration will automatically exclude them. + +### Phase B: Remove frontend telemetry + +**Goal:** No telemetry calls from the frontend. No telemetry in onboarding. + +#### B.1: Remove recordTEvent from global store + +**File:** `frontend/app/store/global.ts` + +- Remove: `recordTEvent()` function +- Remove from exports + +#### B.2: Remove telemetry RPC client methods + +**File:** `frontend/app/store/wshclientapi.ts` (auto-generated) + +- Remove: `ActivityCommand()`, `RecordTEventCommand()`, `SendTelemetryCommand()`, `WaveAIEnableTelemetryCommand()`, `WshActivityCommand()` +- **Note:** This file is auto-generated by `cmd/generatets/main-generatets.go`. After removing the Go source types in A.3, regenerate. Then remove manual frontend call sites (B.4). + +#### B.3: Remove TelemetryUpdate from services + +**File:** `frontend/app/store/services.ts` (auto-generated) + +- Remove: `TelemetryUpdate()` method from `ClientService` +- **Note:** This file is auto-generated. After removing `TelemetryUpdate()` from `clientservice.go` (A.13), regeneration will exclude it. Then remove manual frontend call sites (B.5). + +#### B.4: Remove recordTEvent call sites (non-AI) + +| File | What to remove | +|------|----------------| +| `frontend/app/block/blockframe-header.tsx` | `recordTEvent("action:magnify")`, `ActivityCommand({nummagnify: 1})` | +| `frontend/app/block/connectionbutton.tsx` | `recordTEvent("action:other")` | +| `frontend/app/block/durable-session-flyover.tsx` | `recordTEvent("action:termdurable")` | +| `frontend/app/tab/tabcontextmenu.ts` | `recordTEvent("action:settabtheme")`, `ActivityCommand({settabtheme: 1})` | +| `frontend/app/view/term/osc-handlers.ts` | All `recordTEvent` calls (`conn:connect`, `action:term`); also remove `incrementTermCommands` IPC call | +| `frontend/app/view/term/term-model.ts` | `recordTEvent("action:term")` | +| `frontend/app/view/waveconfig/waveconfig-model.ts` | `RecordTEventCommand` calls | +| `frontend/app/workspace/workspace-layout-model.ts` | `recordTEvent("action:openwaveai")` | +| `frontend/app/modals/about.tsx` | `RecordTEventCommand` call | +| `frontend/app/store/keymodel.ts` | `recordTEvent` import and call (line 653: `"action:other"` for conndropdown) | +| `frontend/app/block/blockenv.ts` | `ActivityCommand` type reference | +| `frontend/app/tab/tabbarenv.ts` | `ActivityCommand` type reference | +| `frontend/app/tab/tab.tsx` | `ActivityCommand` type reference | +| `frontend/app/tab/vtabbarenv.ts` | `ActivityCommand` type reference | +| `frontend/app/view/waveconfig/waveconfigenv.ts` | `RecordTEventCommand` type reference | + +#### B.5: Remove telemetry from onboarding + +This section requires UI restructuring, not just deletion of telemetry calls. The current onboarding flow is: + +``` +init (telemetry toggle + TOS) → (enabled → features) | (disabled → notelemetrystar → features) +``` + +After telemetry removal, the flow should be: + +``` +init (TOS only) → features +``` + +**File:** `frontend/app/onboarding/onboarding.tsx` + +- Remove `InitPage`'s `telemetryUpdateFn` prop +- Remove `telemetry:enabled` setting read (`useSettingsKeyAtom("telemetry:enabled")`) +- Remove `telemetryEnabled` / `setTelemetryEnabled` state +- Remove `setTelemetry` function +- Remove the telemetry toggle checkbox UI (the entire "Anonymous usage data" section) +- Remove `telemetryEnabled` check in `acceptTos` — if AI panel is still present, always open it (or let AI removal spec handle this) +- Remove `NoTelemetryStarPage` component entirely +- Remove `"notelemetrystar"` page state from `pageNameAtom` +- Simplify page flow: `acceptTos` always goes to `"features"` +- Remove `RecordTEventCommand` calls in `handleStarClick` and `handleMaybeLater` +- Remove `TelemetryUpdate` import/call (via `services.ClientService.TelemetryUpdate`) +- Keep `AgreeTos` call in `acceptTos` (TOS acceptance is not telemetry) +- Keep `RecordTEventCommand` calls in `handleStarClick`/`handleMaybeLater` — remove them. The `SetMetaCommand` call (for `"onboarding:githubstar"`) should stay since it's local metadata, not telemetry upload. +- Update `NewInstallOnboardingModal`: remove `telemetryUpdateFn` prop from `InitPage` instantiation +- Move the GitHub star prompt from `NoTelemetryStarPage` to `InitPage` or `FeaturesPage` (without the "telemetry disabled" framing) + +**Other onboarding files:** + +| File | What to remove | +|------|----------------| +| `frontend/app/onboarding/onboarding-starask.tsx` | All `RecordTEventCommand` calls | +| `frontend/app/onboarding/onboarding-features.tsx` | All `RecordTEventCommand` calls | +| `frontend/app/onboarding/onboarding-durable.tsx` | `RecordTEventCommand` call | +| `frontend/app/onboarding/onboarding-upgrade-minor.tsx` | All `RecordTEventCommand` calls | +| `frontend/app/onboarding/onboarding-upgrade-v0131.tsx` | `RecordTEventCommand` calls (if any) | + +**Preview files:** + +| File | What to remove | +|------|----------------| +| `frontend/preview/previews/onboarding.preview.tsx` | Remove `telemetryUpdateFn` prop from `InitPage` (after onboarding restructuring) | +| `frontend/preview/mock/mockfilesystem.ts` | Remove `telemetry.log` mock entry (line 317) | + +> Note: Onboarding files that are part of the AI removal spec (`.pi/specs/remove-waveai.md`) can have their telemetry removed as part of that work instead. + +#### B.6: Remove telemetry from AI panel files + +> These are handled by the AI removal spec (`.pi/specs/remove-waveai.md`). The AI panel files have extensive telemetry calls that go away when the AI panel is removed. + +| File | Handled by | +|------|------------| +| `frontend/app/aipanel/*.tsx` | remove-waveai.md Phase A | +| `frontend/app/onboarding/fakechat.tsx` | remove-waveai.md Phase A | + +**Special case:** `frontend/app/aipanel/telemetryrequired.tsx` — This is a telemetry consent gate component (`TelemetryRequiredMessage`) that blocks AI panel usage until the user enables telemetry. It calls `RpcApi.WaveAIEnableTelemetryCommand`. This component is about **telemetry**, not AI. If the AI removal spec removes the AI panel entirely, this file gets deleted with it. If the AI panel is kept, this component must be removed or replaced with a non-telemetry gate. **Coordinate with AI removal spec.** + +**Special case:** `frontend/app/aipanel/waveai-model.tsx` and `frontend/app/aipanel/aimode.tsx` — These read `telemetry:enabled` to gate AI cloud features (returning `"invalid"` mode or blocking cloud AI when telemetry is disabled). After `telemetry:enabled` is removed from settings, these reads will return `undefined/false`, which could permanently disable AI cloud features for existing users. If the AI removal spec does not remove these files, replace the `telemetry:enabled` reads with `true` (always allow). **Coordinate with AI removal spec.** + +#### B.7: Remove Electron main process telemetry + +**File:** `emain/emain.ts` + +- Remove `sendDisplaysTDataEvent()` function (sends display info via `RecordTEventCommand` with `"app:display"`) +- Remove `logActiveState()` function (the core activity tracking loop that calls `ActivityCommand` and `RecordTEventCommand` with `"app:activity"`) +- Remove all `RpcApi.RecordTEventCommand` calls +- Remove all `RpcApi.ActivityCommand` calls +- Remove `ActivityUpdate`, `TEventProps`, `ActivityDisplayType` type imports +- Remove references to `getActivityState`, `setWasActive`, `setWasInFg`, `incrementTermCommands*`, `getAndClearTermCommands*` from `emain-activity` + +**File:** `emain/emain-activity.ts` + +- Remove `incrementTermCommandsRun()`, `incrementTermCommandsRemote()`, `incrementTermCommandsWsl()`, `incrementTermCommandsDurable()` functions +- Remove `getAndClearTermCommandsRun()`, `getAndClearTermCommandsRemote()`, `getAndClearTermCommandsWsl()`, `getAndClearTermCommandsDurable()` functions +- Remove `termCommandsRun`, `termCommandsRemote`, `termCommandsWsl`, `termCommandsDurable` variables +- Keep `wasActive`, `wasInFg`, `setWasActive`, `setWasInFg`, `getActivityState` if they have non-telemetry callers (verify first; if only used by `logActiveState`, remove them too) +- Keep quit/starting/relaunching state variables (`globalIsQuitting`, `globalIsStarting`, `globalIsRelaunching`, `forceQuit`, `userConfirmedQuit`) — these are process lifecycle state, not telemetry + +**File:** `emain/emain-ipc.ts` + +- Remove `"increment-term-commands"` IPC handler (lines ~441-454) +- Remove imports of `incrementTermCommandsDurable`, `incrementTermCommandsRemote`, `incrementTermCommandsRun`, `incrementTermCommandsWsl` from `emain-activity` +- Keep `setWasActive` import if it's used elsewhere in `emain-ipc.ts` + +**File:** `emain/preload.ts` + +- Remove `incrementTermCommands` API exposure (line 67) + +**File:** `frontend/types/custom.d.ts` + +- Remove `incrementTermCommands` type declaration from the Electron API interface (line 131) + +**File:** `frontend/app/view/term/osc-handlers.ts` + +- Remove `getApi().incrementTermCommands({ isRemote, isWsl, isDurable })` call (line 110) + +#### B.8: Remove telemetryrequired.tsx (if AI panel is kept) + +If the AI removal spec removes the AI panel entirely, this file is deleted with it. Otherwise: + +**File:** `frontend/app/aipanel/telemetryrequired.tsx` + +- Delete the entire file +- Remove all imports/references to `TelemetryRequiredMessage` from AI panel components +- Remove the `telemetry:enabled` gate that renders this component — AI cloud features should work without requiring telemetry + +### Phase C: Delete unused packages and regenerate + +**Goal:** Remove `pkg/telemetry/`, `pkg/telemetry/telemetrydata/`, `pkg/wcloud/` entirely. Regenerate auto-generated files. + +#### C.1: Delete pkg/telemetry/ + +- Delete entire directory (`telemetry.go`, `telemetrydata/telemetrydata.go`) +- **Before deletion:** Verify `telemetry.AutoUpdateChannel()` and `telemetry.IsAutoUpdateEnabled()` have no remaining callers. These are convenience functions that just read `wconfig` settings. Their callers in `main-server.go` were removed in A.1. If any other callers exist, move the functions to `pkg/wconfig/` before deleting `pkg/telemetry/`. + +#### C.2: Delete pkg/wcloud/ + +- Delete entire directory (`wcloud.go`, `wclouddata.go`) + +#### C.3: Clean up remaining imports and regenerate + +- Update `cmd/generatego/main-generatego.go`: Remove `"github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata"` from imports (if not already done in A.17) +- Run `go mod tidy` and fix any remaining import errors +- Regenerate auto-generated Go files: `pkg/wshrpc/wshclient/wshclient.go`, `pkg/wconfig/metaconsts.go` +- Regenerate auto-generated TypeScript files: `frontend/types/gotypes.d.ts`, `frontend/app/store/services.ts`, `frontend/app/store/wshclientapi.ts` + +#### C.4: Environment variable cleanup + +After deleting `pkg/wcloud/`, the `WCLOUD_ENDPOINT` and `WCLOUD_PING_ENDPOINT` environment variables will no longer be read or unset. This is harmless for a fork that doesn't use cloud services, but note that these env vars will remain set in the waveterm process environment (previously `wcloud.CacheAndRemoveEnvVars()` unset them for security). No action needed unless the fork wants to sanitize env vars for other reasons. + +### Phase D: Clean up docs, schemas, and database + +#### D.1: Remove telemetry documentation + +- Delete: `docs/docs/telemetry.mdx` +- Delete: `docs/docs/telemetry-old.mdx` +- Audit: `docs/docs/config.mdx` — remove telemetry config references +- Audit: `docs/docs/faq.mdx` — remove telemetry Q&A +- Audit: `docs/docs/index.mdx` — remove telemetry mentions +- Audit: `docs/docs/releasenotes.mdx` — remove telemetry release notes (optional, historical) + +#### D.2: Remove telemetry from other docs + +- Audit: `docs/docs/waveai.mdx`, `waveai-modes.mdx` — remove telemetry mentions (handled by AI removal spec) + +#### D.3: Drop telemetry database tables (optional) + +The `db_tevent` and `db_activity` tables were created by SQL migrations (`000003_activity.up.sql` and `000007_events.up.sql`). After Phase C, no code reads or writes these tables. They remain empty in existing databases. + +To fully clean up, add a new migration: + +``` +db/migrations-wstore/000012_drop_telemetry.up.sql: + DROP TABLE IF EXISTS db_tevent; + DROP TABLE IF EXISTS db_activity; + +db/migrations-wstore/000012_drop_telemetry.down.sql: + -- Recreating these tables is not necessary; the data is obsolete +``` + +(Verify the next available migration number — `000011_job.down.sql` is the latest existing.) + +#### D.4: Clean up existing users' `telemetry:enabled` config + +After removing `TelemetryEnabled` from `SettingsConfig`, existing users who have `telemetry:enabled` in their config JSON will have an unrecognized key. JSON unmarshaling with `omitempty` silently ignores unknown keys, so this is harmless. No migration needed. The `CountCustomSettings` fix in A.14 removes the code that explicitly checks for this key. + +## Verification Checklist + +After each phase: + +- [ ] `task dev` completes without errors +- [ ] `task start` launches the app +- [ ] No console errors about missing telemetry functions +- [ ] No network requests to `api.waveterm.dev` or `ping.waveterm.dev` +- [ ] No `db_tevent` or `db_activity` table writes +- [ ] App functions normally (terminals, SSH, file browser, etc.) +- [ ] Onboarding flow works without telemetry consent step (simplified `init → features` flow) +- [ ] `wsh` CLI commands work without activity tracking +- [ ] Electron activity tracking loop is no longer running +- [ ] AI panel works without telemetry gate (if AI panel is still present) +- [ ] Auto-generated files (`wshclient.go`, `gotypes.d.ts`, `services.ts`, `wshclientapi.ts`) have been regenerated and contain no telemetry types/methods + +## Risk Assessment + +| Risk | Mitigation | +|------|------------| +| ClientId is used by telemetry AND non-telemetry code | Keep `wstore.GetClientId()`/`SetClientId()` — only remove telemetry-specific usage | +| `TosAgreed` field is used for telemetry cohorts | Keep the field in `waveobj.Client`; it's harmless without telemetry reading it. Keep `AgreeTos()` in `clientservice.go`. | +| Onboarding flow assumes telemetry consent step | Restructure onboarding to remove telemetry toggle, simplify page flow to `init → features`, move GitHub star prompt out of `NoTelemetryStarPage`. See B.5. | +| Upstream merge conflicts | Phase A (remove call sites) keeps `pkg/telemetry/` and `pkg/wcloud/` intact, minimizing conflicts. Phase C (delete packages) is deferred. | +| `autoupdate:*` settings in `pkg/telemetry/` | `telemetry.AutoUpdateChannel()` and `telemetry.IsAutoUpdateEnabled()` are convenience functions that just read `wconfig` settings. Callers in `main-server.go` are removed in A.1. If any callers remain elsewhere, move these functions to `pkg/wconfig/` before deleting `pkg/telemetry/` in Phase C. | +| Auto-generated files overwritten by manual edits | Do NOT manually edit `wshclient.go`, `gotypes.d.ts`, `services.ts`, or `wshclientapi.ts`. Remove Go source types first, then regenerate. | +| AI panel `telemetry:enabled` reads become dangling | After removing the setting, reads return `undefined/false`. If AI panel is kept (not removed by AI spec), replace reads with `true`. See B.6. | +| `PanicHandlerNoTelemetry` callers | Only called within `pkg/telemetry/telemetry.go` (2 calls). After Phase C deletes that package, no callers remain. No rename needed. See A.12. | +| Existing `telemetry:enabled` in user configs | Silently ignored by JSON unmarshaling with `omitempty`. No migration needed. | +| `WCLOUD_ENDPOINT`/`WCLOUD_PING_ENDPOINT` env vars | After removing `wcloud`, these env vars will stay set in the process. Harmless for a fork. See C.4. | + +## Interaction with AI Removal Spec + +The AI removal spec (`.pi/specs/remove-waveai.md`) and this spec overlap in these areas: + +- `WaveAIEnableTelemetryCommand` — remove in both (telemetry spec covers it) +- AI panel telemetry calls — removed when AI panel is removed +- `ai:apitokensecretname` — removed in AI spec, not telemetry +- `telemetry.IsTelemetryEnabled()` check in AI cloud modes — removed in telemetry spec +- `telemetryrequired.tsx` — telemetry consent gate for AI. If AI panel is deleted, this file is deleted with it. If AI panel is kept, remove this component and the `telemetry:enabled` gate. See B.6. +- `waveai-model.tsx`/`aimode.tsx` `telemetry:enabled` reads — if AI panel is kept after AI spec, replace with `true`. See B.6. + +**Recommendation:** Execute telemetry spec first (Phase A), then AI spec. This way the AI spec doesn't need to worry about telemetry imports. + +## File Cross-Reference + +Complete list of all files that need changes, organized by phase: + +### Phase A (Go backend) + +| File | Section | What changes | +|------|---------|--------------| +| `cmd/server/main-server.go` | A.1 | Remove telemetry loops, functions, constants, imports | +| `pkg/wshrpc/wshserver/wshserver.go` | A.2 | Remove 5 RPC methods + WshRunCommand telemetry | +| `pkg/wshrpc/wshrpctypes.go` | A.3 | Remove 5 interface methods + `ActivityUpdate` type | +| `pkg/wshrpc/wshclient/wshclient.go` | A.4 | Auto-generated; regenerate after A.3 | +| `pkg/wcore/block.go` | A.5 | Remove `recordBlockCreationTelemetry` + rename | +| `pkg/wcore/workspace.go` | A.6 | Remove activity/event tracking calls | +| `pkg/wcore/wcore.go` | A.7 | Remove `GoSendNoTelemetryUpdate` | +| `pkg/remote/conncontroller/conncontroller.go` | A.8 | Remove activity/event calls | +| `pkg/wslconn/wslconn.go` | A.9 | Remove activity/event calls | +| `pkg/jobcontroller/jobcontroller.go` | A.10 | Remove event tracking calls | +| `pkg/aiusechat/usechat.go` | A.11 | Remove `sendAIMetricsTelemetry` + telemetry gate | +| `pkg/panichandler/panichandler.go` | A.12 | Remove `PanicTelemetryHandler`, simplify `PanicHandler` | +| `pkg/service/clientservice/clientservice.go` | A.13 | Remove `TelemetryUpdate` method | +| `pkg/wconfig/settingsconfig.go` | A.14 | Remove `TelemetryClear`/`TelemetryEnabled` fields + `CountCustomSettings` fix | +| `pkg/wconfig/metaconsts.go` | A.14 | Auto-generated; regenerate after settingsconfig change | +| `cmd/wsh/cmd/wshcmd-debug.go` | A.15 | Remove `debugSendTelemetryCmd` | +| `cmd/wsh/cmd/wshcmd-root.go` | A.16 | Remove `sendActivity`, `activityWrap`, comment block | +| `cmd/wsh/cmd/wshcmd-file.go` | A.16 | Unwrap `activityWrap` from all `RunE` assignments | +| `cmd/generatego/main-generatego.go` | A.17 | Remove `telemetrydata` import | + +### Phase B (Frontend + Electron) + +| File | Section | What changes | +|------|---------|--------------| +| `frontend/app/store/global.ts` | B.1 | Remove `recordTEvent` | +| `frontend/app/store/wshclientapi.ts` | B.2 | Auto-generated; regenerate after A.3 | +| `frontend/app/store/services.ts` | B.3 | Auto-generated; regenerate after A.13 | +| `frontend/app/block/blockframe-header.tsx` | B.4 | Remove `recordTEvent` + `ActivityCommand` calls | +| `frontend/app/block/connectionbutton.tsx` | B.4 | Remove `recordTEvent` call | +| `frontend/app/block/durable-session-flyover.tsx` | B.4 | Remove `recordTEvent` call | +| `frontend/app/tab/tabcontextmenu.ts` | B.4 | Remove `recordTEvent` + `ActivityCommand` calls | +| `frontend/app/view/term/osc-handlers.ts` | B.4 | Remove `recordTEvent` calls + `incrementTermCommands` IPC | +| `frontend/app/view/term/term-model.ts` | B.4 | Remove `recordTEvent` call | +| `frontend/app/view/waveconfig/waveconfig-model.ts` | B.4 | Remove `RecordTEventCommand` calls | +| `frontend/app/workspace/workspace-layout-model.ts` | B.4 | Remove `recordTEvent` call | +| `frontend/app/modals/about.tsx` | B.4 | Remove `RecordTEventCommand` call | +| `frontend/app/store/keymodel.ts` | B.4 | Remove `recordTEvent` import + call | +| `frontend/app/block/blockenv.ts` | B.4 | Remove `ActivityCommand` type ref | +| `frontend/app/tab/tabbarenv.ts` | B.4 | Remove `ActivityCommand` type ref | +| `frontend/app/tab/tab.tsx` | B.4 | Remove `ActivityCommand` type ref | +| `frontend/app/tab/vtabbarenv.ts` | B.4 | Remove `ActivityCommand` type ref | +| `frontend/app/view/waveconfig/waveconfigenv.ts` | B.4 | Remove `RecordTEventCommand` type ref | +| `frontend/app/onboarding/onboarding.tsx` | B.5 | Restructure: remove toggle, simplify flow | +| `frontend/app/onboarding/onboarding-starask.tsx` | B.5 | Remove `RecordTEventCommand` calls | +| `frontend/app/onboarding/onboarding-features.tsx` | B.5 | Remove `RecordTEventCommand` calls | +| `frontend/app/onboarding/onboarding-durable.tsx` | B.5 | Remove `RecordTEventCommand` call | +| `frontend/app/onboarding/onboarding-upgrade-minor.tsx` | B.5 | Remove `RecordTEventCommand` calls | +| `frontend/app/onboarding/onboarding-upgrade-v0131.tsx` | B.5 | Remove `RecordTEventCommand` calls | +| `frontend/preview/previews/onboarding.preview.tsx` | B.5 | Remove `telemetryUpdateFn` prop | +| `frontend/preview/mock/mockfilesystem.ts` | B.5 | Remove `telemetry.log` mock entry | +| `frontend/app/aipanel/telemetryrequired.tsx` | B.6/B.8 | Delete or replace (coordinate with AI spec) | +| `emain/emain.ts` | B.7 | Remove `sendDisplaysTDataEvent`, `logActiveState`, all RPC calls | +| `emain/emain-activity.ts` | B.7 | Remove term command tracking functions | +| `emain/emain-ipc.ts` | B.7 | Remove `"increment-term-commands"` IPC handler | +| `emain/preload.ts` | B.7 | Remove `incrementTermCommands` API | +| `frontend/types/custom.d.ts` | B.7 | Remove `incrementTermCommands` type decl | +| `frontend/types/gotypes.d.ts` | — | Auto-generated; regen removes `ActivityUpdate`, `TEvent`, `telemetry:enabled` types | + +### Phase C (Delete packages) + +| File/Directory | Section | What changes | +|------|---------|--------------| +| `pkg/telemetry/` | C.1 | Delete entire directory | +| `pkg/wcloud/` | C.2 | Delete entire directory | +| Various Go files | C.3 | `go mod tidy`, fix imports, regenerate | + +### Phase D (Docs + DB) + +| File/Directory | Section | What changes | +|------|---------|--------------| +| `docs/docs/telemetry.mdx` | D.1 | Delete | +| `docs/docs/telemetry-old.mdx` | D.1 | Delete | +| `docs/docs/config.mdx` | D.1 | Remove telemetry config refs | +| `docs/docs/faq.mdx` | D.1 | Remove telemetry Q&A | +| `docs/docs/index.mdx` | D.1 | Remove telemetry mentions | +| `db/migrations-wstore/` | D.3 | Add migration to drop `db_tevent`/`db_activity` tables | \ No newline at end of file diff --git a/.pi/specs/remove-updater-delete.md b/.pi/specs/remove-updater-delete.md new file mode 100644 index 0000000000..20c07eac0c --- /dev/null +++ b/.pi/specs/remove-updater-delete.md @@ -0,0 +1,333 @@ +# Spec: Delete Updater Dead Code + +**Date:** 2026-05-18 +**Status:** Draft +**Prerequisite:** [[.pi/specs/remove-updater.md]] (all phases A-E complete) + +## Goal + +Delete all unreachable updater code left behind by the disable-phase. After this spec, no updater-related code or dependencies remain in the fork. + +## Background + +The disable-phase ([[.pi/specs/remove-updater.md]]) made `configureAutoUpdater()` a no-op and stubbed IPC APIs, but left the `Updater` class, event listeners, and menu items intact for upstream compatibility. Several files still import from `emain/updater.ts` and reference the `updater` object. This spec removes all of it. + +## What Remains After Disable-Phase + +| File | What's Left | Status | +|------|-------------|--------| +| `emain/updater.ts` | Full `Updater` class, `getUpdateChannel()`, event listeners, `autoUpdater` import, `configureAutoUpdater()` no-op | Dead code | +| `emain/emain-menu.ts` | "Check for Updates" menu item → `updater?.checkForUpdates(true)` | Calls dead code | +| `emain/emain-wavesrv.ts` | `import { updater }`, checks `updater?.status == "installing"` before restart | Always false | +| `emain/emain-window.ts` | `import { updater }`, checks `updater?.status == "installing"` in quit handlers (2 places) | Always false | +| `emain/emain-wsh.ts` | `import { getResolvedUpdateChannel }`, `handle_getupdatechannel()` RPC handler | Returns stale value | +| `emain/emain.ts` | `updater?.stop()` in cleanup, `import { configureAutoUpdater, updater }` | Both no-ops | +| `package.json` | `"electron-updater": "^6.6"` dependency | Unused | + +## Implementation Phases + +### Phase A: Remove updater references from Electron main process + +**Goal:** No file imports from `emain/updater.ts`. All updater-related menu items and guards removed. + +#### A.1: Remove "Check for Updates" menu item + +**File:** `emain/emain-menu.ts` + +- Remove the import: `import { updater } from "./updater"` +- Remove the "Check for Updates" menu item from `appMenuItems`: + ```typescript + { + label: "Check for Updates", + click: () => { + fireAndForget(() => updater?.checkForUpdates(true)); + }, + }, + ``` +- Keep the "About Wave Terminal" item and separator (adjust separator if needed for menu formatting) + +**Before:** +```typescript +const appMenuItems: Electron.MenuItemConstructorOptions[] = [ + { label: "About Wave Terminal", ... }, + { label: "Check for Updates", ... }, + { type: "separator" }, +]; +``` + +**After:** +```typescript +const appMenuItems: Electron.MenuItemConstructorOptions[] = [ + { label: "About Wave Terminal", ... }, + { type: "separator" }, +]; +``` + +#### A.2: Remove updater guard from wavesrv restart + +**File:** `emain/emain-wavesrv.ts` + +- Remove the import: `import { updater } from "./updater"` +- Remove the `updater?.status == "installing"` check from the restart logic +- The check is a guard that prevents restart during an update install. Since updates are disabled, this check is always false and can be removed. + +**Before (approximate):** +```typescript +if (updater?.status == "installing") { + // skip restart during update install +} +``` + +**After:** +```typescript +// (check removed — updater disabled) +``` + +#### A.3: Remove updater guards from window quit handlers + +**File:** `emain/emain-window.ts` + +- Remove the import: `import { updater } from "./updater"` +- Remove `updater?.status == "installing"` from quit handler guards (2 locations, lines ~304 and ~335) +- These checks prevent window close during update install. Always false now. + +**Before (line ~304):** +```typescript +if (getGlobalIsQuitting() || updater?.status == "installing" || getGlobalIsRelaunching()) { +``` + +**After:** +```typescript +if (getGlobalIsQuitting() || getGlobalIsRelaunching()) { +``` + +**Before (line ~335):** +```typescript +if (getGlobalIsQuitting() || updater?.status == "installing") { +``` + +**After:** +```typescript +if (getGlobalIsQuitting()) { +``` + +#### A.4: Remove `handle_getupdatechannel` RPC handler + +**File:** `emain/emain-wsh.ts` + +- Remove the import: `import { getResolvedUpdateChannel } from "emain/updater"` +- Remove the `handle_getupdatechannel()` method entirely + +**Before:** +```typescript +async handle_getupdatechannel(rh: RpcResponseHelper): Promise { + return getResolvedUpdateChannel(); +} +``` + +**After:** +```typescript +// (method removed) +``` + +#### A.5: Clean up `emain/emain.ts` + +**File:** `emain/emain.ts` + +- Remove `updater` from the import: change `import { configureAutoUpdater, updater } from "./updater"` to `import { configureAutoUpdater } from "./updater"` +- Remove `updater?.stop()` call (line ~175) — updater is never created, this is a no-op + +### Phase B: Delete `emain/updater.ts` + +**Goal:** The entire updater file is removed. All imports are already cleaned in Phase A. + +#### B.1: Delete the file + +- Delete: `emain/updater.ts` +- This removes: `Updater` class (~150 lines), `getUpdateChannel()`, `getResolvedUpdateChannel()`, all `autoUpdater` event listeners, `configureAutoUpdater()` no-op + +#### B.2: Remove `configureAutoUpdater` import from `emain/emain.ts` + +- Remove `import { configureAutoUpdater } from "./updater"` (the last remaining import) +- Remove the `await configureAutoUpdater()` call from the startup sequence (line ~303) + +### Phase C: Remove `electron-updater` dependency + +**Goal:** The npm package is no longer in the project. + +#### C.1: Remove from `package.json` + +- Remove `"electron-updater": "^6.6"` from dependencies + +#### C.2: Update lock file + +- Run `npm install` to regenerate `package-lock.json` without `electron-updater` + +### Phase D: Clean up stub IPC APIs (optional) + +**Goal:** Remove the stubbed IPC methods that no longer serve any purpose. + +#### D.1: Remove stubs from preload + +**File:** `emain/preload.ts` + +- Remove the 4 updater IPC stubs: + ```typescript + onUpdaterStatusChange: (callback) => {}, + getUpdaterStatus: () => "up-to-date", + getUpdaterChannel: () => "latest", + installAppUpdate: () => {}, + ``` + +#### D.2: Remove type declarations + +**File:** `frontend/types/custom.d.ts` + +- Remove from `ElectronApi` interface: + ```typescript + onUpdaterStatusChange: (callback: (status: UpdaterStatus) => void) => void; + getUpdaterStatus: () => UpdaterStatus; + getUpdaterChannel: () => string; + installAppUpdate: () => void; + ``` +- Remove `UpdaterStatus` type: + ```typescript + type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error" | "installing"; + ``` + +#### D.3: Remove `updaterStatusAtom` from global atoms + +**File:** `frontend/app/store/global-atoms.ts` + +- Remove `updaterStatusAtom` declaration +- Remove it from the exported `atoms` object + +#### D.4: Remove updaterStatusAtom from env subsets + +| File | What to remove | +|------|----------------| +| `frontend/app/tab/tabbarenv.ts` | `updaterStatusAtom` from env subset type | +| `frontend/app/tab/vtabbarenv.ts` | `updaterStatusAtom` from env subset type | + +#### D.5: Remove from preview mocks + +| File | What to remove | +|------|----------------| +| `frontend/preview/mock/mockwaveenv.ts` | `updaterStatusAtom` from mock atoms | +| `frontend/preview/mock/preview-electron-api.ts` | `onUpdaterStatusChange`, `getUpdaterStatus`, `getUpdaterChannel`, `installAppUpdate` stubs | +| `frontend/preview/previews/tabbar.preview.tsx` | `updaterStatus` usage | +| `frontend/preview/previews/vtabbar.preview.tsx` | `updaterStatus` usage | + +#### D.6: Remove `UpdateStatusBanner` entirely + +| File | What to remove | +|------|----------------| +| `frontend/app/tab/updatebanner.tsx` | Delete entire file | +| `frontend/app/tab/tabbar.tsx` | Remove `import { UpdateStatusBanner }` and `` render | + +### Phase E: Clean up Go autoupdate settings (optional, later) + +**Goal:** Remove `autoupdate:*` fields from Go config structs. + +> **Recommendation: Defer this phase.** The Go fields are harmless and keeping them minimizes upstream merge conflicts. Only remove if the fork diverges significantly from upstream. + +| File | What to remove | +|------|----------------| +| `pkg/wconfig/settingsconfig.go` | `AutoUpdateClear`, `AutoUpdateEnabled`, `AutoUpdateIntervalMs`, `AutoUpdateInstallOnQuit`, `AutoUpdateChannel` fields | +| `pkg/wconfig/metaconsts.go` | `ConfigKey_AutoUpdate*` constants (auto-generated; regenerate after settingsconfig change) | +| `pkg/wconfig/defaultconfig/settings.json` | `autoupdate:enabled`, `autoupdate:installonquit`, `autoupdate:intervalms` entries | +| `schema/settings.json` | `autoupdate:*`, `autoupdate:enabled`, `autoupdate:intervalms`, `autoupdate:installonquit`, `autoupdate:channel` entries | +| `frontend/types/gotypes.d.ts` | Auto-generated; regenerate after Go changes | + +## Implementation Order + +1. **A.1** — Remove "Check for Updates" menu item +2. **A.2** — Remove updater guard from wavesrv restart +3. **A.3** — Remove updater guards from window quit handlers +4. **A.4** — Remove `handle_getupdatechannel` RPC handler +5. **A.5** — Clean up `emain.ts` import + `updater?.stop()` +6. **B.1** — Delete `emain/updater.ts` +7. **B.2** — Remove `configureAutoUpdater` import + call from `emain.ts` +8. **C.1** — Remove `electron-updater` from `package.json` +9. **C.2** — `npm install` to update lock file +10. **D.1–D.6** — Clean up stub IPC APIs (optional) +11. **E** — Clean up Go settings (deferred) + +## Verification Checklist + +- [ ] `task dev` completes without errors +- [ ] `task start` launches the app +- [ ] No "Check for Updates" menu item in the app menu +- [ ] No console errors about missing `updater` module +- [ ] No `electron-updater` in `package.json` dependencies +- [ ] `emain/updater.ts` no longer exists +- [ ] App functions normally (terminals, SSH, file browser, etc.) +- [ ] Existing tests pass + +## Risk Assessment + +| Risk | Mitigation | +|------|------------| +| `updater?.status == "installing"` guards prevent restart/quit during updates | Always false now; removing them is safe. No update can ever reach "installing" state. | +| `handle_getupdatechannel` RPC called by external code | Only called by frontend via IPC; frontend stub already returns `"latest"`. After D.2, no callers remain. | +| Upstream merge conflicts on `emain/` files | Larger diff than disable-phase, but changes are surgical (remove specific lines, not restructure files). | +| `electron-updater` transitive dependencies | Removing it may clean up several sub-dependencies. `npm install` handles this. | +| Preview mocks break without `updaterStatusAtom` | Phase D handles mock cleanup. If Phase D is deferred, mocks still work (atom returns static value). | + +## File Cross-Reference + +### Phase A (Remove updater references) + +| File | Section | What changes | +|------|---------|--------------| +| `emain/emain-menu.ts` | A.1 | Remove `updater` import + "Check for Updates" menu item | +| `emain/emain-wavesrv.ts` | A.2 | Remove `updater` import + `updater?.status == "installing"` guard | +| `emain/emain-window.ts` | A.3 | Remove `updater` import + 2 `updater?.status == "installing"` guards | +| `emain/emain-wsh.ts` | A.4 | Remove `getResolvedUpdateChannel` import + `handle_getupdatechannel()` method | +| `emain/emain.ts` | A.5 | Remove `updater` from import + `updater?.stop()` call | + +### Phase B (Delete updater file) + +| File | Section | What changes | +|------|---------|--------------| +| `emain/updater.ts` | B.1 | Delete entire file | +| `emain/emain.ts` | B.2 | Remove `configureAutoUpdater` import + `await configureAutoUpdater()` call | + +### Phase C (Remove dependency) + +| File | Section | What changes | +|------|---------|--------------| +| `package.json` | C.1 | Remove `"electron-updater"` from dependencies | +| `package-lock.json` | C.2 | Regenerated by `npm install` | + +### Phase D (Clean up stubs — optional) + +| File | Section | What changes | +|------|---------|--------------| +| `emain/preload.ts` | D.1 | Remove 4 updater IPC stubs | +| `frontend/types/custom.d.ts` | D.2 | Remove `UpdaterStatus` type + 4 IPC API declarations | +| `frontend/app/store/global-atoms.ts` | D.3 | Remove `updaterStatusAtom` declaration + export | +| `frontend/app/tab/tabbarenv.ts` | D.4 | Remove `updaterStatusAtom` from env subset | +| `frontend/app/tab/vtabbarenv.ts` | D.4 | Remove `updaterStatusAtom` from env subset | +| `frontend/preview/mock/mockwaveenv.ts` | D.5 | Remove `updaterStatusAtom` from mock atoms | +| `frontend/preview/mock/preview-electron-api.ts` | D.5 | Remove 4 updater API stubs | +| `frontend/preview/previews/tabbar.preview.tsx` | D.5 | Remove `updaterStatus` usage | +| `frontend/preview/previews/vtabbar.preview.tsx` | D.5 | Remove `updaterStatus` usage | +| `frontend/app/tab/updatebanner.tsx` | D.6 | Delete entire file | +| `frontend/app/tab/tabbar.tsx` | D.6 | Remove `UpdateStatusBanner` import + render | + +### Phase E (Go settings — deferred) + +| File | Section | What changes | +|------|---------|--------------| +| `pkg/wconfig/settingsconfig.go` | E | Remove `AutoUpdate*` fields | +| `pkg/wconfig/metaconsts.go` | E | Remove `ConfigKey_AutoUpdate*` constants | +| `pkg/wconfig/defaultconfig/settings.json` | E | Remove `autoupdate:*` entries | +| `schema/settings.json` | E | Remove `autoupdate:*` schema entries | +| `frontend/types/gotypes.d.ts` | E | Regenerate after Go changes | + +## Interaction with Other Specs + +- **[[.pi/specs/remove-updater.md]]** — This spec is the deletion follow-up. All phases A-E of the disable-spec must be complete first. +- **[[.pi/specs/remove-waveai.md]]** — No overlap. +- **[[.pi/specs/remove-telemetry.md]]** — No overlap. diff --git a/.pi/specs/remove-updater.md b/.pi/specs/remove-updater.md new file mode 100644 index 0000000000..5f3584906e --- /dev/null +++ b/.pi/specs/remove-updater.md @@ -0,0 +1,280 @@ +# Spec: Remove Auto-Updater + +**Date:** 2026-05-18 +**Status:** Draft + +## Goal + +Disable and remove all automatic update checking, downloading, and installation from Wave Terminal. The fork should not contact `dl.waveterm.dev`, check GitHub releases, or install updates. No update-related UI should be visible to the user. + +## What the Updater Does Today + +1. **Periodic check** — On startup, then every hour (configurable via `autoupdate:intervalms`), contacts `https://dl.waveterm.dev/releases-w2` to check for new versions +2. **Automatic download** — When a newer version is found, downloads it silently in the background +3. **User notification** — Shows a system notification + in-tab-bar banner when download completes +4. **Install on click** — User clicks notification → dialog with "Restart" / "Later" → `autoUpdater.quitAndInstall()` +5. **Install on quit** — `autoUpdater.autoInstallOnAppQuit = true` (default) — installs downloaded update on next launch if user quits normally +6. **Channel selection** — Supports `latest` and `beta` channels via `autoupdate:channel` setting; reads `app-update.yml` bundled in the binary + +## Scope + +### What to remove/disable + +- All outbound network calls to `dl.waveterm.dev` +- Background update checking (periodic + startup) +- Automatic download of new versions +- System notifications about updates +- In-tab-bar update status banner (`UpdateStatusBanner`) +- "Update Channel" line in the About modal +- IPC APIs for updater status (`onUpdaterStatusChange`, `getUpdaterStatus`, `getUpdaterChannel`, `installAppUpdate`) +- `updaterStatusAtom` in the frontend store +- `electron-updater` dependency (optional, see Phase E) +- `publish` config in `electron-builder.config.cjs` +- Default `autoupdate:*` settings values + +### What to keep + +- `autoupdate:*` fields in `SettingsType` (`settingsconfig.go`) — harmless, keeps fork closer to upstream +- `ConfigKey_AutoUpdate*` constants in `metaconsts.go` — auto-generated, harmless +- `autoupdate:*` entries in `schema/settings.json` — harmless +- `emain/updater.ts` file itself — stubbed to no-op, not deleted (upstream compatibility) +- `electron-updater` in `package.json` — kept as dependency (upstream compatibility, no runtime cost) + +## Implementation Phases + +### Phase A: Disable the updater in the Electron main process + +**Goal:** No network calls to update servers. No downloads. No installs. App starts without errors. + +#### A.1: Make `configureAutoUpdater()` a no-op + +**File:** `emain/updater.ts` + +- Replace the body of `configureAutoUpdater()` to skip all updater initialization: + ```typescript + export async function configureAutoUpdater() { + console.log("skipping auto-updater (disabled in this build)"); + } + ``` +- This eliminates: + - All calls to `autoUpdater.checkForUpdates()` + - All event listeners on `autoUpdater` + - All periodic intervals + - All system notifications + - All calls to `autoUpdater.quitAndInstall()` +- The `Updater` class, `getUpdateChannel()`, and all other code stays intact but unreachable. + +#### A.2: Remove `updater?.stop()` call in cleanup + +**File:** `emain/emain.ts` + +- Line ~175: `updater?.stop()` — This is harmless (updater is null since `configureAutoUpdater` is a no-op), but can be removed for cleanliness. +- **Decision:** Leave it. It's a single line that does nothing when `updater` is undefined/never created. Removing it creates a larger diff for no benefit. + +### Phase B: Remove update UI from the frontend + +**Goal:** No update status banner, no update channel in About modal. App renders without errors. + +#### B.1: Make `UpdateStatusBanner` always render null + +**File:** `frontend/app/tab/updatebanner.tsx` + +- Change `UpdateStatusBannerComponent` to return `null` unconditionally: + ```typescript + const UpdateStatusBannerComponent = () => { + return null; + }; + ``` +- Keep the file and export intact. The component is still imported by `tabbar.tsx` and `vtabbar` preview, so keeping the export avoids cascading changes. + +#### B.2: Remove "Update Channel" from the About modal + +**File:** `frontend/app/modals/about.tsx` + +- Remove the `updaterChannel` prop from `AboutModalVProps` interface +- Remove the "Update Channel: {updaterChannel}" line from the rendered JSX +- Remove the `updaterChannel` computation in `AboutModal` component: + ```typescript + // Remove this line: + const updaterChannel = fullConfig?.settings?.["autoupdate:channel"] ?? "latest"; + ``` +- Remove `updaterChannel` from the `` props + +### Phase C: Stub updater IPC APIs in the frontend + +**Goal:** Frontend code that references updater APIs doesn't crash. Atoms initialize cleanly. + +#### C.1: Stub IPC methods in preload + +**File:** `emain/preload.ts` + +- Replace the 4 updater IPC bindings with stubs: + ```typescript + // Updater disabled — stubs for upstream compatibility + onUpdaterStatusChange: (callback) => {}, // never fires + getUpdaterStatus: () => "up-to-date", + getUpdaterChannel: () => "latest", + installAppUpdate: () => {}, + ``` + +#### C.2: Simplify `updaterStatusAtom` initialization + +**File:** `frontend/app/store/global-atoms.ts` + +- Remove the `try/catch` block that calls `getApi().getUpdaterStatus()` and `getApi().onUpdaterStatusChange()` +- Initialize the atom to `"up-to-date"` directly: + ```typescript + const updaterStatusAtom = atom("up-to-date") as PrimitiveAtom; + ``` +- Remove the error logging for the updater init. +- Keep the atom in the exported `atoms` object (it's referenced by tabbar env subsets and preview mocks). + +#### C.3: Remove updater init from `wave.ts` + +**File:** `frontend/wave.ts` + +- Remove the line: + ```typescript + globalStore.set(atoms.updaterStatusAtom, getApi().getUpdaterStatus()); + ``` +- This is redundant since the atom is already initialized to `"up-to-date"` and the IPC API is stubbed. + +### Phase D: Update default settings + +**Goal:** Default config reflects disabled updater. Existing user configs are harmless (settings are just ignored). + +#### D.1: Set autoupdate defaults to disabled + +**File:** `pkg/wconfig/defaultconfig/settings.json` + +- Change `"autoupdate:enabled"` from `true` to `false` +- Leave `autoupdate:installonquit` and `autoupdate:intervalms` as-is (harmless, never read) + +### Phase E: Remove `publish` config from build configuration (optional) + +**Goal:** Even if someone runs `electron-builder` on the fork, it won't try to publish to upstream servers. + +#### E.1: Remove publish URL from electron-builder config + +**File:** `electron-builder.config.cjs` + +- Remove the `publish` block: + ```javascript + publish: { + provider: "generic", + url: "https://dl.waveterm.dev/releases-w2", + }, + ``` +- This prevents the build system from generating update manifests pointing to upstream servers. + +### Phase F: Remove `electron-updater` dependency (optional, later) + +**Goal:** Remove the `electron-updater` npm package entirely. + +#### F.1: Remove import from `emain/updater.ts` + +- Remove `import { autoUpdater } from "electron-updater"` +- Remove `import YAML from "yaml"` (only used for `app-update.yml` parsing in `getUpdateChannel()`) +- Remove `import { readFileSync } from "fs"` (only used in `getUpdateChannel()`) +- Remove `import path from "path"` (only used in `getUpdateChannel()`) +- The file becomes a single-line no-op export with no dependencies. + +#### F.2: Remove from `package.json` + +- Remove `"electron-updater": "^6.6"` from dependencies +- Run `npm install` to update `package-lock.json` + +**Recommendation:** Defer Phase F. The `electron-updater` package is ~300KB and adds no runtime overhead when `configureAutoUpdater()` is a no-op. Keeping it minimizes diff size and simplifies upstream merges. + +## Implementation Order + +1. **A.1** — Disable `configureAutoUpdater()` (stops all network activity immediately) +2. **B.1** — Hide update banner in tab bar +3. **B.2** — Remove update channel from About modal +4. **C.1** — Stub IPC APIs +5. **C.2** — Simplify `updaterStatusAtom` +6. **C.3** — Remove updater init from `wave.ts` +7. **D.1** — Update default settings +8. **E.1** — Remove publish config from build + +## Verification Checklist + +- [ ] `task dev` completes without errors +- [ ] `task start` launches the app +- [ ] No console errors related to updater +- [ ] No outbound HTTP to `dl.waveterm.dev` (verify with `lsof -i` or network monitoring) +- [ ] No update banner appears in tab bar +- [ ] About modal does not show "Update Channel" +- [ ] No system notifications about updates +- [ ] App functions normally (terminals, SSH, file browser, etc.) +- [ ] Existing tests pass (`task test` / `npm test`) + +## Risk Assessment + +| Risk | Mitigation | +|------|------------| +| `electron-updater` import causes build errors if removed | Phase F deferred; keep dependency. Even if removed, `emain/updater.ts` stays as a no-op stub. | +| Upstream merge conflicts on `updater.ts` | Minimal changes (single function body). Rest of file untouched. | +| Existing user configs with `autoupdate:enabled: true` | Harmless — the setting is never read after `configureAutoUpdater()` is a no-op. | +| Preview mocks reference `updaterStatusAtom` | Atom stays in the atoms object, just always `"up-to-date"`. Mocks work unchanged. | +| Tabbar env subsets declare `updaterStatusAtom` | Env declarations stay unchanged; atom exists and is accessible. | +| `app-update.yml` bundled in production binary | Without `configureAutoUpdater()` creating an `Updater`, the file is never read. Can be cleaned in a later pass. | + +## File Cross-Reference + +### Phase A (Electron main — disable updater) + +| File | Section | What changes | +|------|---------|--------------| +| `emain/updater.ts` | A.1 | `configureAutoUpdater()` → no-op | + +### Phase B (Frontend — hide UI) + +| File | Section | What changes | +|------|---------|--------------| +| `frontend/app/tab/updatebanner.tsx` | B.1 | Component always returns `null` | +| `frontend/app/modals/about.tsx` | B.2 | Remove "Update Channel" line + prop | + +### Phase C (Frontend — stub IPC) + +| File | Section | What changes | +|------|---------|--------------| +| `emain/preload.ts` | C.1 | 4 updater IPC methods → stubs | +| `frontend/app/store/global-atoms.ts` | C.2 | `updaterStatusAtom` → static `"up-to-date"`, remove IPC subscription | +| `frontend/wave.ts` | C.3 | Remove `set(atoms.updaterStatusAtom, ...)` call | + +### Phase D (Default settings) + +| File | Section | What changes | +|------|---------|--------------| +| `pkg/wconfig/defaultconfig/settings.json` | D.1 | `autoupdate:enabled` → `false` | + +### Phase E (Build config) + +| File | Section | What changes | +|------|---------|--------------| +| `electron-builder.config.cjs` | E.1 | Remove `publish` block | + +### Left Untouched + +| File/Directory | Why | +|----------------|-----| +| `emain/emain.ts` | `updater?.stop()` is harmless null-op; `configureAutoUpdater()` call stays (now no-op) | +| `pkg/wconfig/settingsconfig.go` | `AutoUpdate*` fields stay for upstream compatibility | +| `pkg/wconfig/metaconsts.go` | `ConfigKey_AutoUpdate*` stay (auto-generated) | +| `schema/settings.json` | `autoupdate:*` entries stay (harmless) | +| `frontend/types/custom.d.ts` | `UpdaterStatus` type + IPC API declarations stay (type-safe stubs) | +| `frontend/app/tab/tabbarenv.ts` | `updaterStatusAtom` env declaration stays | +| `frontend/app/tab/vtabbarenv.ts` | `updaterStatusAtom` env declaration stays | +| `frontend/app/tab/tabbar.tsx` | `` render stays (component returns null) | +| `frontend/preview/mock/mockwaveenv.ts` | `updaterStatusAtom` mock stays | +| `frontend/preview/previews/tabbar.preview.tsx` | `updaterStatus` usage stays | +| `frontend/preview/previews/vtabbar.preview.tsx` | `updaterStatus` usage stays | +| `package.json` | `electron-updater` dependency stays (Phase F deferred) | +| `.github/workflows/publish-release.yml` | Fork doesn't publish releases; workflow is upstream-only | +| `Taskfile.yml` | `RELEASES_BUCKET` var stays; `artifacts:publish:*` tasks are upstream-only | + +## Interaction with Other Specs + +- **remove-telemetry.md** — No overlap. Telemetry spec explicitly called out `autoupdate:*` settings as "genuine auto-update config, not telemetry" and left them untouched. +- **remove-waveai.md** — No overlap. AI removal doesn't touch updater code. diff --git a/.pi/specs/remove-waveai.md b/.pi/specs/remove-waveai.md new file mode 100644 index 0000000000..6506a80b3f --- /dev/null +++ b/.pi/specs/remove-waveai.md @@ -0,0 +1,459 @@ +# Spec: Remove Wave AI Features + +**Date:** 2026-05-12 +**Status:** Draft + +## Goal + +Disable and hide all Wave AI features from the UI. Do not delete code initially — comment out or guard behind no-ops so the fork stays close to upstream and re-enabling is trivial. + +## Scope + +### What to remove/disable + +- Wave AI chat panel (`waveai` block type) +- AI file diff viewer (`aifilediff` block type) +- AI modes configuration (`waveai.json`) +- AI presets configuration (`aipresets.json`) +- AI-related keyboard shortcuts +- AI focus management +- AI RPC commands (frontend client + backend server) +- AI web endpoints +- AI activity telemetry +- AI config fields in `settingsconfig.go` +- AI documentation pages +- `ai:apitokensecretname` field (AI token via secrets) +- AI button in tab bar (`WaveAIButton`) +- AI panel from workspace layout +- AI onboarding page (`WaveAIPage`, `fakechat.tsx`) + +### What to keep + +- `pkg/secretstore/` — general encrypted key-value store (used by SSH passwords, potentially future features) +- `ssh:passwordsecretname` — SSH password via secrets (non-AI use case) + +## Implementation Phases + +### Phase A: Disable the UI (frontend only) + +**Goal:** AI panels cannot be opened, AI is not visible in any menus or settings. App builds and runs without errors. + +#### A.1: Unregister AI block types + +**File:** `frontend/app/block/blockregistry.ts` + +- Comment out or remove: `BlockRegistry.set("waveai", WaveAiModel)` +- Comment out or remove: `BlockRegistry.set("aifilediff", AiFileDiffViewModel)` +- Remove imports: `WaveAiModel`, `AiFileDiffViewModel` + +**Verification:** App starts without errors. No AI block types registered. + +#### A.2: Strip AI from block utilities + +**File:** `frontend/app/block/blockutil.tsx` + +- Remove the `view == "waveai"` cases in `getBlockTitle()` and `getBlockIcon()` (or return empty/nil) + +#### A.3: Remove AI keyboard shortcuts + +**File:** `frontend/app/store/keymodel.ts` + +- Remove the `WaveAIModel` import +- Remove all `WaveAIModel.getInstance()` calls (lines ~151, 155, 177, 184, 192, 199, 227, 248, 252, 260, 265, 268, 687, 691, 696, 700) +- Remove `focusType === "waveai"` branches +- Remove `inWaveAI` variable and related navigation logic + +#### A.4: Remove AI focus management + +**File:** `frontend/app/store/focusManager.ts` + +- Remove `waveAIHasFocusWithin` and `WaveAIModel` imports +- Change `FocusStrType` from `"node" | "waveai"` to just `"node"` +- Remove `setWaveAIFocused()` and `requestWaveAIFocus()` methods +- Remove `"waveai"` branches in focus handling + +#### A.5: Remove AI global atoms + +**File:** `frontend/app/store/global-atoms.ts` + +- Remove `waveaiModeConfigAtom` +- Remove `ai@` preset filtering logic (line ~68) +- Remove from exported atoms list + +#### A.6: Remove AI event listeners + +**File:** `frontend/app/store/global.ts` + +- Remove `waveai:modeconfig` event handler +- Remove `waveai:ratelimit` event handler + +#### A.7: Remove AI RPC client methods + +**File:** `frontend/app/store/wshclientapi.ts` + +- Remove: `GetWaveAIChatCommand`, `GetWaveAIModeConfigCommand`, `GetWaveAIRateLimitCommand`, `WaveAIAddContextCommand`, `WaveAIEnableTelemetryCommand`, `WaveAIGetToolDiffCommand`, `WaveAIToolApproveCommand` + +**File:** `frontend/app/store/tabrpcclient.ts` + +- Remove `WaveAIModel` import +- Remove `handle_waveaiaddcontext()` method + +#### A.8: Remove AI from term model + +**File:** `frontend/app/view/term/term-model.ts` + +- Remove `WaveAIModel` import +- Remove the AI-related code at line ~848 + +#### A.9: Remove AI config file handling + +**File:** `frontend/app/view/waveconfig/waveconfig-model.ts` + +- Remove the `waveai.json` config file entry (line ~84) +- Remove `validateWaveAiJson()` function +- Remove `aipresets.json` references (line ~122) + +**File:** `frontend/app/monaco/schemaendpoints.ts` + +- Remove `waveaiSchema` import and registration +- Remove `aipresetsSchema` import and registration + +**File:** `frontend/preview/mock/defaultconfig.ts` + +- Remove `waveaiJson` import +- Remove `waveai` entry from mock config + +#### A.10: Remove AI visual component + +**File:** `frontend/app/view/waveconfig/waveaivisual.tsx` + +- Mark as unused (can keep file but it won't be imported anywhere) + +#### A.11: Remove AI button from tab bar + +**File:** `frontend/app/tab/tabbar.tsx` + +- Remove `WaveAIButton` component (lines ~48-76) +- Remove `` from render (line ~616) +- Remove `waveAIButtonRef` usage +- Remove `export { TabBar, WaveAIButton }` — change to `export { TabBar }` + +#### A.12: Remove AI from workspace + +**File:** `frontend/app/workspace/workspace.tsx` + +- Remove `import { AIPanel } from "@/app/aipanel/aipanel"` +- Remove `getApi().setWaveAIOpen(isVisible)` call (line ~87) +- Remove `` rendering from JSX + +**File:** `frontend/app/workspace/workspace-layout-model.ts` + +- Remove `import { WaveAIModel } from "@/app/aipanel/waveai-model"` +- Remove `waveai:panelopen` and `waveai:panelwidth` meta key handling (lines ~93, ~133, ~137) +- Remove `getApi().setWaveAIOpen(visible)` call (line ~397) +- Remove `WaveAIModel.getInstance().focusInput()` call (line ~409) +- Remove the "vtab stays constant, aipanel absorbs the change" logic (line ~230) + +#### A.13: Remove AI from onboarding + +**File:** `frontend/app/onboarding/onboarding-features.tsx` + +- Remove `WaveAIPage` component (lines ~22-247) +- Remove `"waveai"` from `FeaturePageName` type +- Change default `currentPage` from `"waveai"` to next feature (e.g., `"durable"`) +- Remove `"waveai"` case from page navigation logic +- Remove `handlePrev()` navigation to `"waveai"` + +**File:** `frontend/app/onboarding/fakechat.tsx` + +- Mark as unused (won't be imported after WaveAIPage is removed) + +#### A.14: Electron main — remove AI activity tracking + +**File:** `emain/emain.ts` + +- Already clean — no AI references found (telemetry removed in prior phase) + +**File:** `emain/emain-window.ts` + +- Remove `ipcMain.on("set-waveai-open", ...)` handler (line ~760) + +**File:** `emain/preload.ts` + +- Remove `setWaveAIOpen` from IPC exposed methods (line ~65) + +**File:** `emain/emain-tabview.ts` + +- Remove `isWaveAIOpen` field from tab view struct (line ~121) +- Remove `this.isWaveAIOpen = false` initialization (line ~145) + +### Phase B: Remove backend wiring (Go) + +**Goal:** No AI RPC handlers, no AI config fields, no AI web endpoints. `pkg/aiusechat/` stays intact but unused. + +#### B.1: Remove AI RPC types + +**File:** `pkg/wshrpc/wshrpctypes.go` + +- Remove from interface: `GetWaveAIModeConfigCommand`, `WaveAIEnableTelemetryCommand`, `GetWaveAIChatCommand`, `GetWaveAIRateLimitCommand`, `WaveAIToolApproveCommand`, `WaveAIAddContextCommand`, `WaveAIGetToolDiffCommand` +- Remove types: `CommandGetWaveAIChatData`, `CommandWaveAIToolApproveData`, `CommandWaveAIAddContextData`, `CommandWaveAIGetToolDiffData`, `CommandWaveAIGetToolDiffRtnData` +- Remove from telemetry props: `WaveAIFgMinutes`, `WaveAIActiveMinutes` +- Remove `uctypes` import if no longer needed + +#### B.2: Remove AI RPC server handlers + +**File:** `pkg/wshrpc/wshserver/wshserver.go` + +- Remove: `GetWaveAIModeConfigCommand()`, `WaveAIEnableTelemetryCommand()`, `GetWaveAIChatCommand()`, `GetWaveAIRateLimitCommand()`, `WaveAIToolApproveCommand()`, `WaveAIGetToolDiffCommand()` +- Remove imports: `aiusechat`, `chatstore`, `uctypes` (if no other uses remain) + +#### B.3: Remove AI RPC client helpers + +**File:** `pkg/wshrpc/wshclient/wshclient.go` + +- Remove: `GetWaveAIChatCommand()`, `GetWaveAIModeConfigCommand()`, `GetWaveAIRateLimitCommand()`, `WaveAIAddContextCommand()`, `WaveAIEnableTelemetryCommand()`, `WaveAIGetToolDiffCommand()`, `WaveAIToolApproveCommand()` +- Remove `uctypes` import if no longer needed + +#### B.4: Remove AI web endpoints + +**File:** `pkg/web/web.go` + +- Remove: `/api/post-chat-message` handler +- Remove: `/wave/aichat` handler +- Remove `aiusechat` import if no longer needed + +#### B.5: Remove AI initialization + +**File:** `cmd/server/main-server.go` + +- Remove: `aiusechat.InitAIModeConfigWatcher()` call +- Remove `aiusechat` import if no longer needed + +#### B.6: Remove AI config fields + +**File:** `pkg/wconfig/settingsconfig.go` + +- Remove from `FrontendConfig`: `WaveAiShowCloudModes`, `WaveAiDefaultMode` +- Remove from `AIProviderConfig`: `WaveAICloud`, `WaveAIPremium` +- Remove from `FullConfig`: `WaveAIModes` +- Remove `GetCustomAIModeConfigs()` function +- Remove `ai:apitokensecretname` from `AIProviderConfig` (field `APITokenSecretName`) +- Remove `AIModeConfigType` if no longer referenced + +#### B.7: Remove AI TypeScript generation + +**File:** `pkg/tsgen/tsgenevent.go` + +- Remove: `Event_WaveAIRateLimit` mapping +- Remove `uctypes` import if no longer needed + +#### B.8: Remove default AI config + +**File:** `pkg/wconfig/defaultconfig/waveai.json` + +- Delete or mark as unused (won't be loaded if `WaveAIModes` is removed from config) + +### Phase C: Clean up docs & schemas + +**Goal:** No AI references in public-facing documentation or JSON schemas. + +#### C.1: Remove AI documentation + +- Delete: `docs/docs/waveai.mdx` +- Delete: `docs/docs/waveai-modes.mdx` +- Delete: `docs/docs/ai-presets.mdx` +- Audit: `docs/docs/secrets.mdx` — remove AI token examples, keep SSH password secret examples +- Audit: `docs/docs/config.mdx` — remove AI config references +- Audit: `docs/docs/telemetry.mdx` — remove AI telemetry references +- Audit: `docs/docs/connections.mdx` — remove `ai:apitokensecretname` references + +#### C.2: Remove JSON schemas + +- Delete: `schema/waveai.json` +- Delete: `schema/aipresets.json` + +### Phase D: Delete unused code (optional, later) + +**Goal:** Remove dead code after the fork is stable and verified. + +- Delete: `pkg/aiusechat/` (entire directory, ~12K lines) +- Delete: `frontend/app/aipanel/` (17 files) +- Delete: `frontend/app/view/waveai/waveai.tsx` +- Delete: `frontend/app/view/aifilediff/aifilediff.tsx` +- Delete: `frontend/app/view/waveconfig/waveaivisual.tsx` + +## Implementation Order + +Start with deepest dependencies and work up to UI components to avoid dangling imports: + +1. **A.1–A.2** — Block registry + utilities (foundation) +2. **A.3–A.6** — Store layer (keyboard, focus, atoms, events) +3. **A.7–A.9** — RPC clients + config handling +4. **A.10** — Visual component (orphaned) +5. **A.11–A.13** — UI components (tab bar, workspace, onboarding) +6. **A.14** — Electron main (IPC cleanup) + +## Verification Checklist + +After each phase: + +- [ ] `task dev` completes without errors +- [ ] `task start` launches the app +- [ ] No console errors related to missing AI components +- [ ] No AI panels appear in the UI +- [ ] No AI entries in settings/config UI +- [ ] No AI keyboard shortcuts active +- [ ] App functions normally for non-AI features (terminals, file browser, SSH connections) + +## Phase A Review — 2026-05-14 + +### Issues Found During Review + +#### 🔴 A.15: Builder workspace still imports AIPanel and WaveAIModel (Not in original spec) + +The builder subsystem (`frontend/builder/`) has deep AI integration that was not covered by the original Phase A spec. These are live imports that will crash if `aipanel/` is ever deleted (Phase D). + +**Files:** +- `frontend/builder/builder-workspace.tsx` — imports `AIPanel` from `@/app/aipanel/aipanel`, renders `` +- `frontend/builder/builder-buildpanel.tsx` — imports `WaveAIModel`, calls `WaveAIModel.getInstance()` for "Add to Context" context menu and AI model access +- `frontend/builder/tabs/builder-previewtab.tsx` — imports `WaveAIModel`, calls `WaveAIModel.getInstance()` for chat ID access +- `frontend/builder/tabs/builder-filestab.tsx` — imports `formatFileSize` from `@/app/aipanel/ai-utils` (generic utility trapped in AI module) +- `frontend/builder/store/builder-focusmanager.ts` — has `BuilderFocusType = "waveai" | "app"` and `setWaveAIFocused()` method + +**Fix:** Remove AIPanel from builder workspace, replace WaveAIModel calls with stubs or no-ops, move `formatFileSize` to a shared utility, change `BuilderFocusType` to just `"app"`. + +#### 🟡 A.9 partial: "AI Presets" deprecated config entry still in settings UI + +`frontend/app/view/waveconfig/waveconfig-model.ts` still has: +- `validateAiJson()` function (lines 35-44) that validates keys starting with `ai@` +- "AI Presets" deprecated config file entry (lines 93-103) pointing to `presets/ai.json` with `docsUrl: "https://docs.waveterm.dev/ai-presets"` + +This means AI Presets still appears in the settings UI as a deprecated file. + +**Fix:** Remove `validateAiJson()` and the "AI Presets" entry from `deprecatedConfigFiles`. + +#### 🟡 A.3 partial: `inWaveAI` dead code in layoutModel.ts + +`frontend/layout/lib/layoutModel.ts` line 1107 still has `inWaveAI` parameter in `switchNodeFocusInDirection()`, and lines 1127-1131 have WaveAI-specific navigation logic. Caller in `keymodel.ts` passes `false`, so it's harmless but is dead code. + +**Fix:** Remove `inWaveAI` parameter and the WaveAI-specific branch from `switchNodeFocusInDirection()`. Update caller in `keymodel.ts`. + +#### 🟢 A.14 partial: Mock Electron API still has `setWaveAIOpen` + +`frontend/preview/mock/preview-electron-api.ts` line 53 still has `setWaveAIOpen: (_isOpen: boolean) => {}`. + +**Fix:** Remove `setWaveAIOpen` from the mock API object. + +#### 🟢 Dead `rateLimitInfoAtom` declaration in global-atoms.ts + +`frontend/app/store/global-atoms.ts` line 115 declares `rateLimitInfoAtom` but never exports it or adds it to the `atoms` object. Leftover from AI rate limit tracking. + +**Fix:** Remove the `rateLimitInfoAtom` declaration. + +### Deferred Items (Not Phase A Bugs) + +These are expected to be cleaned up in later phases: + +| Item | Why Deferred | Phase | +|------|-------------|-------| +| Auto-generated TS types (`gotypes.d.ts`, `waveevent.d.ts`, `wshclientapi.ts`, `custom.d.ts`) still have AI definitions | Regeneration depends on Go backend types being removed first | B → regenerate | +| `wshclientapi.ts` still has 7 AI RPC commands + `AiSendMessageCommand` | Auto-generated file; will be regenerated after Go types removed | B → regenerate | +| `aipanel/`, `aifilediff/`, `fakechat.tsx`, `waveaivisual.tsx` files still exist | Intentionally kept per spec; Phase D deletes them | D | +| `waveai.tsx` stub still exports `WaveAiModel` class | File exists but not registered in blockregistry; dead code | D | +| `schema/waveai.json`, `schema/aipresets.json` still exist | Phase C removes them | C | +| `schema/settings.json` has `waveai:showcloudmodes` and `waveai:defaultmode` | Phase B/C handles schema cleanup | B/C | +| `docs/docs/waveai.mdx`, `waveai-modes.mdx`, `ai-presets.mdx` still exist | Phase C removes them | C | +| `docs/docs/config.mdx` still has `waveai:showcloudmodes`/`waveai:defaultmode` | Phase C audits this | C | +| `pkg/wconfig/defaultconfig/waveai.json`, `presets/ai.json` still exist | Phase B.8 handles this | B | +| All Go backend types and handlers untouched | Phase B scope | B | +| `filebackup.go` uses `waveai-backups` directory name | Low priority; just a directory name, harmless | D | + +### Unintended Consequences to Track + +1. **Builder mode will break at Phase D** — The builder imports `AIPanel`, `WaveAIModel`, and `formatFileSize` from `aipanel/`. When that directory is deleted in Phase D, the builder crashes unless A.15 fixes are applied first. +2. **`formatFileSize` trapped in AI module** — `builder-filestab.tsx` imports it from `@/app/aipanel/ai-utils`. Must be relocated before Phase D. +3. **Type definitions out of sync** — Auto-generated TS files have AI types that no longer match runtime reality. No runtime errors but creates confusion. Will resolve when Go types removed and generator re-run. + +## Phase B Review — 2026-05-15 + +### Items Already Removed (from telemetry phase) + +- `WaveAIEnableTelemetryCommand` — already gone from interface, server, and client +- `WaveAIFgMinutes` / `WaveAIActiveMinutes` telemetry props — already gone from `wshrpctypes.go` +- `GetCustomAIModeConfigs()` — spec mentioned it but it doesn't exist in `settingsconfig.go` (it's `ComputeResolvedAIModeConfigs()` in `aiusechat/`) + +### Additional Items Found (Not in Original Spec) + +| ID | File | What to remove | +|----|------|---------------| +| **B.1 (extra)** | `pkg/wshrpc/wshrpctypes.go` | `AiSendMessageCommand` interface method + `AiMessageData` type (no server handler exists) | +| **B.3 (extra)** | `pkg/wshrpc/wshclient/wshclient.go` | `AiSendMessageCommand()` helper function | +| **B.6 (extra)** | `pkg/wconfig/settingsconfig.go` | `CountCustomAIModes()` function (dead code) | +| **B.9** | `pkg/wps/wpstypes.go` | `Event_WaveAIRateLimit`, `Event_AIModeConfig` constants + from `AllEvents` list | +| **B.10** | `pkg/wconfig/metaconsts.go` | `ConfigKey_WaveAiShowCloudModes`, `ConfigKey_WaveAiDefaultMode` (auto-generated; will not reappear after B.6) | +| **B.11** | `pkg/tsgen/tsgen.go` | `uctypes.RateLimitInfo{}` and `wconfig.AIModeConfigUpdate{}` from `Types` slice, `aiusechat/uctypes` import | +| **B.12** | `cmd/generateschema/main-generateschema.go` | `WaveSchemaWaveAIFileName` const + waveai schema generation block | +| **B.13** | `cmd/generatego/main-generatego.go` | `"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"` from boilerplate import list | + +### Updated Implementation Order + +1. **B.1** — `wshrpctypes.go` (interface + types, including `AiSendMessageCommand` + `AiMessageData`) +2. **B.2** — `wshserver.go` (server handlers) +3. **B.3** — `wshclient.go` (client helpers, including `AiSendMessageCommand`) +4. **B.4** — `web.go` (web endpoints) +5. **B.5** — `main-server.go` (init call) +6. **B.6** — `settingsconfig.go` (config types + `CountCustomAIModes`) +7. **B.7** — `tsgenevent.go` (event type mapping) +8. **B.11** — `tsgen.go` (Types slice + import) +9. **B.9** — `wpstypes.go` (event constants + AllEvents list) +10. **B.13** — `generatego/main-generatego.go` (import list) +11. **B.12** — `generateschema/main-generateschema.go` (schema generation) +12. **B.10** — `metaconsts.go` (AI config key constants) +13. **B.8** — `defaultconfig/waveai.json` (delete) + +### Left Untouched + +- `cmd/testai/`, `cmd/testopenai/`, `cmd/testsummarize/` — test-only binaries; harmless dead code +- `pkg/aiusechat/` — entire package stays intact (Phase D) + +### Phase B Completion — 2026-05-15 + +All Phase B items implemented and verified. `task build:backend` completes without errors. + +**Additional item found during implementation:** +- `cmd/wsh/cmd/wshcmd-ai.go` — `wsh ai` CLI command, deleted (used `AIAttachedFile`, `CommandWaveAIAddContextData`, `WaveAIAddContextCommand`) + +**Post-Phase B state:** +- `pkg/aiusechat/` is now a dead package (no external callers) +- Auto-generated TS types (`gotypes.d.ts`, `waveevent.d.ts`, `wshclientapi.ts`) still have stale AI definitions — will be regenerated when the generator is re-run +- `schema/waveai.json` still exists on disk (was pre-generated, not regenerated by `task build:schema` since the generator code is removed) — Phase C will delete it + +## Phase C Review — 2026-05-16 + +### Completed + +- **C.1**: Deleted `docs/docs/waveai.mdx`, `waveai-modes.mdx`, `ai-presets.mdx` +- **C.1**: Cleaned `docs/docs/config.mdx` — removed 13 AI config rows (`ai:*`, `waveai:*`, `app:hideaibutton`), cleaned default config JSON, updated env var examples +- **C.1**: Cleaned `docs/docs/gettingstarted.mdx` — removed AI mentions from intro, key features, quick start, and next steps +- **C.1**: Cleaned `docs/docs/index.mdx` — removed Wave AI card, removed AI from intro text +- **C.1**: Cleaned `docs/docs/wsh-reference.mdx` — removed `waveai` from view type filter, removed `presets/ai.json` example +- **C.1**: Truncated `docs/docs/releasenotes.mdx` — kept v0.14.x only (v0.13.1 and earlier removed), stripped all AI mentions from v0.14.x entries +- **C.2**: Deleted `schema/waveai.json`, `schema/aipresets.json` +- **Phase A carryover**: Fixed misleading AI text in `frontend/builder/tabs/builder-previewtab.tsx` EmptyStateView + +### Audit Results + +| File | AI references found | Action | +|------|-------------------|--------| +| `docs/docs/secrets.mdx` | None (already clean) | No change needed | +| `docs/docs/telemetry.mdx` | File doesn't exist (removed in telemetry phase) | N/A | +| `docs/docs/connections.mdx` | None | No change needed | +## Risk Assessment + +| Risk | Mitigation | +|------|------------| +| AI code is imported by non-AI files | Phase A handles frontend imports first; Phase B handles Go imports. Each phase is independently buildable. Builder imports discovered in A.15 review — must fix before Phase D. | +| Builder has AI dependencies | A.15 documents builder AI imports; must fix before Phase D deletes `aipanel/`. Move `formatFileSize` to shared utility. | +| Config migration for existing users | `waveai.json` and `aipresets.json` are simply ignored if not loaded. Existing files on disk are harmless. | +| Upstream merge conflicts | Keeping `pkg/aiusechat/` intact (Phase D deferred) minimizes conflicts. Only wiring code is removed. | +| Secret store still needed | `ssh:passwordsecretname` justifies keeping it. Documented in [[decisions.md#2026-05-12-secret-store--keep]]. | diff --git a/.pi/specs/widget-follow-focus.md b/.pi/specs/widget-follow-focus.md new file mode 100644 index 0000000000..a67dc51494 --- /dev/null +++ b/.pi/specs/widget-follow-focus.md @@ -0,0 +1,169 @@ +# Spec: System Widgets Follow Terminal Focus + +**Date:** 2026-05-20 +**Status:** Draft + +## Problem + +When opening a system widget (Process Viewer, File Browser, etc.), it always defaults to showing information for the local machine, regardless of which terminal has focus. For remote-first workflows, this means users must manually switch the widget's connection after opening it. + +**Example:** You're working in a remote SSH terminal, click the Process Viewer widget, and it shows local processes. You then have to change the connection dropdown to see the remote processes you actually care about. + +## Solution + +When creating a system widget block, inherit the `connection` meta from the currently focused terminal block. + +## Scope + +### Widgets that should follow focus + +| Widget | View Type | Reason | +|--------|-----------|--------| +| Process Viewer | `processviewer` | Shows processes on the focused host | +| File Browser / Preview | `preview` | Shows files at the focused terminal's cwd | + +### Widgets that should NOT follow focus (always local) + +| Widget | View Type | Reason | +|--------|-----------|--------| +| Settings | `waveconfig` | App configuration is local | +| Secrets | `waveconfig` + `file: "secrets"` | Secret store is local | +| Help | `help` | Static content | +| Tips | `tips` | Static content | +| Tsunami apps | `tsunami` | App-specific, may have own connection logic | + +## Current Behavior + +### Widget creation paths + +1. **Widgets bar** (`widgets.tsx`) — `handleWidgetSelect()` reads `widget.blockdef` from config, calls `env.createBlock(blockDef)` +2. **Terminal context menu** (`term-model.ts`) — e.g., File Browser already reads `blockData?.meta?.connection` and passes it +3. **Keyboard shortcuts** — various, depending on widget +4. **Programmatic** — stickers, other UI elements + +### Connection resolution today + +In `ProcessViewerViewModel.constructor()`: +```typescript +this.connection = jotai.atom((get) => { + const connValue = get(this.env.getBlockMetaKeyAtom(blockId, "connection")); + if (isBlank(connValue)) { + return "local"; // <-- always defaults to local + } + return connValue; +}); +``` + +### What already works + +The File Browser in the terminal context menu already inherits connection: +```typescript +// term-model.ts, line ~900 +const connection = blockData?.meta?.connection; +const meta: Record = { view: "preview", file: cwd }; +if (connection) { + meta.connection = connection; +} +``` + +## Proposed Changes + +### 1. Helper: get focused block's connection + +Add to `frontend/app/store/global.ts`: + +```typescript +/** + * Returns the connection name of the currently focused terminal block, + * or null if no terminal is focused or it's a local session. + */ +function getFocusedTerminalConnection(): string | null { + const focusedBlockId = getFocusedBlockId(); + if (!focusedBlockId) return null; + const blockData = globalStore.get(getBlockAtom(focusedBlockId)); + // Only inherit from terminal blocks + if (blockData?.meta?.view !== "term" && blockData?.meta?.view !== "splitterm") { + return null; + } + const conn = blockData?.meta?.connection; + return conn || null; // null means local session +} +``` + +### 2. Widget creation: inject connection + +Create a helper that wraps `createBlock` for system widgets: + +```typescript +/** + * Creates a block for a system widget, inheriting connection from focused terminal. + * If @inheritConnection is false, creates block without connection meta. + */ +async function createWidgetBlock( + blockDef: BlockDef, + magnified?: boolean, + ephemeral?: boolean, + inheritConnection: boolean = true +): Promise { + if (inheritConnection) { + const focusedConn = getFocusedTerminalConnection(); + if (focusedConn) { + blockDef.meta = { ...blockDef.meta, connection: focusedConn }; + } + // if focusedConn is null, don't set connection (defaults to "local" in widget) + } + return createBlock(blockDef, magnified, ephemeral); +} +``` + +### 3. Update creation sites + +| File | Widget | Change | +|------|--------|--------| +| `widgets.tsx` | Process Viewer (from widgets bar) | Use `createWidgetBlock` instead of `env.createBlock` | +| `term-model.ts` | File Browser | Already works, no change needed | +| Any future widget | N/A | Use `createWidgetBlock` with `inheritConnection: true/false` | + +### 4. Widget config: declare connection inheritance + +Add an optional field to `WidgetConfigType` in the widgets config schema: + +```jsonc +{ + "processviewer": { + "label": "Processes", + "icon": "microchip", + "blockdef": { "meta": { "view": "processviewer" } }, + "inheritconnection": true // <-- new field, default: false + } +} +``` + +This way each widget declares whether it should follow focus, rather than hardcoding a list in the code. + +## Files to Modify + +| File | Change | +|------|--------| +| `frontend/app/store/global.ts` | Add `getFocusedTerminalConnection()`, `createWidgetBlock()` | +| `frontend/app/workspace/widgets.tsx` | Use `createWidgetBlock` in `handleWidgetSelect()` | +| `frontend/app/waveenv/waveenv.ts` | Add `createWidgetBlock` to WaveEnv interface | +| `frontend/app/waveenv/waveenvimpl.ts` | Export `createWidgetBlock` | +| `docs/docs/widgets.json` (or schema) | Add `inheritconnection` field to widget config | + +## Test Cases + +| Scenario | Expected | +|----------|----------| +| Focus on remote SSH terminal, click Process Viewer | Widget shows remote processes | +| Focus on local terminal, click Process Viewer | Widget shows local processes | +| Focus on Settings widget, click Process Viewer | Widget shows local processes (Settings is not a terminal) | +| Focus on remote terminal, click Settings | Settings opens (no connection meta, stays local) | +| Switch focus between two remote terminals, open Process Viewer each time | Widget follows whichever terminal is focused | +| File Browser from terminal context menu | Already works, verify no regression | + +## Out of Scope + +- Widget remembers last-used connection per-host (could be a future enhancement) +- Multiple widgets showing different hosts simultaneously (already works, each widget is independent) +- Non-terminal views inheriting connection (e.g., splitting a process viewer) diff --git a/.pi/specs/wsh-agent-api.md b/.pi/specs/wsh-agent-api.md new file mode 100644 index 0000000000..69c5faf8f9 --- /dev/null +++ b/.pi/specs/wsh-agent-api.md @@ -0,0 +1,367 @@ +# Spec: wsh Agent API — Terminal Orchestration via wsh + +**Date:** 2026-05-20 +**Status:** Draft + +## Problem + +AI coding agents (pi, Claude Code, Cursor, etc.) running inside Wave Terminal terminals have no awareness of the Wave Terminal application itself. They can execute shell commands and edit files, but cannot: +- See what other blocks/connections exist +- Open new terminals on different connections +- Read terminal output from other blocks +- Reorganize layout or manage connections + +This limits agents to operating within a single terminal context. With Wave Terminal as an orchestration surface, agents can coordinate across connections, manage the workspace, and spawn other agents. + +## Scope Guardrail + +**Include:** Anything a human could do via the UI or keyboard. +**Exclude:** Streaming event subscriptions, programmatic UI rendering, anything beyond human capability. + +## Design Principles + +1. **wsh-first** — All agent capabilities exposed via `wsh` commands. Works locally and remotely (routed through connserver). +2. **JSON output** — `--json` flag on all read commands for machine parsing. +3. **Idempotent** — Commands are safe to retry (important for agents dealing with flaky networks). +4. **No new auth surface** — Agent runs as the same user; inherits existing permissions. No API keys or tokens. + +## Proposed Commands + +### Read Commands + +#### `wsh block list` + +List all blocks in the current workspace. + +```bash +wsh block list # human-readable table +wsh block list --json # JSON array +wsh block list --tab # filter by tab +wsh block list --connection # filter by connection +wsh block list --view term # filter by view type +``` + +**JSON output:** +```json +[ + { + "id": "block-uuid", + "view": "term", + "tab": "tab-uuid", + "connection": "prod-server", + "connStatus": "connected", + "cwd": "/home/user/project", + "title": "prod-server: ~/project", + "magnified": false, + "focused": true + } +] +``` + +#### `wsh block get` + +Get details for a specific block, including terminal scrollback. + +```bash +wsh block get --json +wsh block get --lines 50 # last N lines of scrollback +wsh block get --no-scrollback # block metadata only +``` + +**JSON output:** +```json +{ + "id": "block-uuid", + "view": "term", + "connection": "prod-server", + "connStatus": "connected", + "cwd": "/home/user/project", + "scrollback": [ + {"line": 1, "text": "$ ls -la"}, + {"line": 2, "text": "total 42"}, + {"line": 3, "text": "drwxr-xr-x 5 user user 4096 May 20 10:00 ."} + ], + "scrollbackTotal": 1250, + "scrollbackFrom": 1201, + "scrollbackTo": 1250 +} +``` + +#### `wsh connection list` + +List all connections and their status. + +```bash +wsh connection list --json +``` + +**JSON output:** +```json +[ + { + "name": "prod-server", + "type": "ssh", + "host": "prod.example.com", + "user": "deploy", + "status": "connected", + "durableSessions": 2 + }, + { + "name": "local", + "type": "local", + "status": "connected", + "durableSessions": 0 + } +] +``` + +#### `wsh tab list` + +List all tabs and their layout. + +```bash +wsh tab list --json +``` + +**JSON output:** +```json +[ + { + "id": "tab-uuid", + "title": "Production", + "blockIds": ["block-uuid-1", "block-uuid-2"], + "focusedBlockId": "block-uuid-1", + "layout": "split-horizontal" + } +] +``` + +#### `wsh config get` + +Read configuration values. + +```bash +wsh config get "term:fontfamily" --json +wsh config get --all --json # all settings +wsh config get --connection --json # connection-specific settings +``` + +### Write Commands + +#### `wsh block create` + +Create a new block (terminal, file browser, etc.). + +```bash +# Create terminal on a connection +wsh block create --view term --connection prod-server + +# Create terminal with a command +wsh block create --view term --connection prod-server --cmd "tail -f /var/log/syslog" + +# Create file browser at a path +wsh block create --view preview --connection prod-server --file /var/log + +# Split relative to focused block +wsh block create --view term --split horizontal --connection staging + +# In a specific tab +wsh block create --view term --tab --connection prod-server + +# Magnified (fullscreen within tab) +wsh block create --view term --magnified --connection prod-server +``` + +**Output:** `block-uuid` (the ID of the created block) + +#### `wsh block close` + +Close a block. + +```bash +wsh block close +wsh block close --force # close even if has running process +``` + +#### `wsh block send-keys` + +Send keystrokes to a terminal block (as if typed by a human). + +```bash +wsh block send-keys "ls -la" +wsh block send-keys "ls -la" --enter # append Enter key +wsh block send-keys --literal "\u001b[A" # Escape sequence (up arrow) +``` + +#### `wsh block focus` + +Focus a block (give it keyboard focus). + +```bash +wsh block focus +``` + +#### `wsh block magnify` + +Magnify (fullscreen) or unmagnify a block within its tab. + +```bash +wsh block magnify +wsh block magnify --toggle +wsh block unmagnify +``` + +#### `wsh connection connect` + +Connect to a connection (if disconnected). + +```bash +wsh connection connect +``` + +#### `wsh connection disconnect` + +Disconnect from a connection. + +```bash +wsh connection disconnect +``` + +#### `wsh config set` + +Set a configuration value. + +```bash +wsh config set "term:fontfamily" "JetBrains Mono" +wsh config set "term:fontsize" 14 +``` + +### Agent Orchestration + +#### `wsh agent spawn` + +Spawn an agent command in a new terminal block. + +```bash +# Spawn Claude Code on a remote connection +wsh agent spawn --connection prod-server --cmd "claude" + +# Spawn with a prompt +wsh agent spawn --connection prod-server --cmd "claude" --prompt "Fix the build error" + +# Spawn in a split next to focused block +wsh agent spawn --connection prod-server --cmd "claude" --split horizontal +``` + +This is syntactic sugar for `wsh block create --view term --connection --cmd ""`. + +## Implementation Approach + +### Phase 1: JSON output on existing commands + +Many `wsh` commands already exist. Add `--json` flag: + +| Command | Current state | Change | +|---------|---------------|--------| +| `wsh block list` | Exists, human-readable | Add `--json`, add filters (`--tab`, `--connection`, `--view`) | +| `wsh block get` | May not exist | New command with `--lines`, `--no-scrollback` | +| `wsh connection list` | Exists | Add `--json` | +| `wsh tab list` | May not exist | New command with `--json` | +| `wsh config get` | May not exist | New command | + +### Phase 2: Write commands + +| Command | Current state | Change | +|---------|---------------|--------| +| `wsh block create` | Exists via RPC | Expose via `wsh` CLI with more options | +| `wsh block close` | Exists | Already works | +| `wsh block send-keys` | New | Send keystrokes to terminal block | +| `wsh block focus` | New | Focus a block | +| `wsh config set` | New | Set configuration values | + +### Phase 3: Agent helpers + +| Command | Current state | Change | +|---------|---------------|--------| +| `wsh agent spawn` | New | Syntactic sugar for block create + cmd | +| `wsh agent help` | New | Discovery command — lists all agent-capable commands | + +## Files to Modify + +| File | Change | +|------|--------| +| `cmd/wsh/cmd/` | New CLI commands (`wshcmd-block-list.go`, `wshcmd-block-get.go`, etc.) | +| `pkg/wshrpc/wshserver/` | Server-side handlers for new RPC commands | +| `pkg/wshrpc/wshrpctypes.go` | New RPC method signatures | +| `pkg/wcore/block.go` | Block query helpers | +| `frontend/app/store/global.ts` | `createBlock` already exists, may need exposure via wsh | + +## Security Considerations + +| Concern | Mitigation | +|---------|-----------| +| Agent reads sensitive terminal output (API keys, passwords) | Same as human — if agent has shell access, it can read anything anyway. No additional risk. | +| Agent modifies config | Same as human — `wsh config set` is available to the user. No additional risk. | +| Agent on remote server controls local Wave Terminal | `wsh` commands already route through connserver RPC. Agent inherits user's permissions. | +| Agent spawns processes on other connections | Agent already has SSH access if it's running in a terminal on that connection. No additional risk. | +| **Secrets access** | `wsh secret get` already exists. Agent can use it. Document this as a capability, not hide it. | + +**Key insight:** The agent runs as the same user with the same permissions. There is no privilege escalation. The "security model" is identical to a human using the Wave Terminal UI. + +## Out of Scope (for now) + +- **Streaming/event subscriptions** — "notify me when block output changes" (needs WebSocket/domain socket) +- **Programmatic UI rendering** — Agent creates custom UI elements (beyond human capability) +- **Cross-workspace operations** — Agent controls multiple Wave Terminal windows (single workspace scope) +- **Agent authentication/API keys** — Agent inherits user's permissions, no separate auth +- **Rate limiting** — Agent runs locally, no DoS concern +- **Audit logging** — Can add later if needed + +## Test Cases + +| Scenario | Expected | +|----------|----------| +| `wsh block list --json` from local terminal | Returns all blocks including remote ones | +| `wsh block list --json` from remote terminal | Same result (routed through connserver) | +| `wsh block create --view term --connection prod` | New terminal block opens, connected to prod | +| `wsh block send-keys "echo hello" --enter` | "echo hello\n" sent to terminal, command executes | +| `wsh block get --lines 10` | Returns last 10 lines of scrollback | +| `wsh agent spawn --connection prod --cmd "claude"` | New block with claude running on prod | +| `wsh config get --all --json` | Returns all settings as JSON | +| Agent parses JSON, creates block, sends keys | Full orchestration flow works end-to-end | + +## Discovery + +How does an agent know this exists? + +1. **Environment variable** — `WAVE_TERMINAL=1` set in all terminal sessions +2. **wsh agent help** — Lists all agent-capable commands with examples +3. **Documentation** — `.pi/specs/wsh-agent-api.md` + public docs in `docs/docs/` + +Example `wsh agent help` output: +``` +Wave Terminal Agent API +======================= +The following wsh commands support --json output for agent integration: + +Read workspace state: + wsh block list --json List all blocks + wsh block get --json Get block details + scrollback + wsh connection list --json List connections + wsh tab list --json List tabs + +Modify workspace: + wsh block create --view term --connection + wsh block close + wsh block send-keys "command" --enter + wsh block focus + +Configuration: + wsh config get --json + wsh config set + +Spawn agents: + wsh agent spawn --connection --cmd "claude" + +For full documentation: wsh agent help --full +``` diff --git a/.pi/todos.md b/.pi/todos.md new file mode 100644 index 0000000000..6512d6c766 --- /dev/null +++ b/.pi/todos.md @@ -0,0 +1,190 @@ +# Active Tasks + +## Phase 1: Dev Environment ✅ + +- [x] Install Task (build runner) +- [x] Install Go 1.25+ +- [x] Run `task init` to install dependencies +- [x] Run `task dev` — confirm app launches +- [x] Run `task start` — confirm standalone build works +- [x] Set up macOS CI workflow + +## Phase 2: Feature Planning + +- [ ] Finalize list of features to ADD +- [x] Finalize list of features to REMOVE or DIMINISH +- [ ] Prioritize implementation order + +### Features to Remove / Disable + +> "Remove" means **disable and hide from the UI** — don't delete code initially. Makes it easy to re-enable if needed and keeps the fork closer to upstream. + +- **All Wave AI features** — AI widgets, AI chat, AI presets, context-aware assistant, AI-related UI elements and settings + +## Dependency Maintenance + +- [x] **Upstream dependency bumps** (issue #12) — completed 2026-05-29 + - [x] Merge 3 upstream commits (`a5ac0962`, `81f7b1a5`, `c0687de2`) + - `google.golang.org/api` 0.275.0 → 0.277.0 + - `qs` 6.14.2 → 6.15.2, `express` 4.22.1 → 4.22.2 + - Resolved merge conflict: `docs/docs/waveai-modes.mdx` (keep deletion) + - [x] Bump `golang.org/x/crypto` 0.50.0 → 0.52.0 (CVE-2026-39827 SSH memory leak, CVE-2026-46598 ed25519 panic) + - [x] `go mod tidy` cleaned up transitive deps (`x/net`, `x/sys`, `x/term`, `x/text`) + - [x] `task build:server` passes + +## Phase 3: Implementation + +### High Priority — Bugfix + +- [x] **Durable session auto-reconnect unreliable** (draft: [[.pi/draft-issue-autoconnect-bugs.md]]) — P0 bugs fixed 2026-05-23 + - [x] Bug #1 (P0): Route-level cooldown consumed before connection check — moved `lastAutoReconnectAttempt.Set` into `attemptAutoReconnect` after `IsConnected` passes + - [x] Bug #2 (P0): connStates reconciliation race — replaced `processed bool` with generation counters (`actualGen` / `procGen`); `reconcileConn` now sends follow-up signal if `actualGen != procGen` at finish + - [x] Bug #3 (P0): singleflight caches transient reconnect failures — split `reconnectGroup` into `reconnectConnGroup` and `reconnectRouteGroup`; route-level `attemptAutoReconnect` now calls `ReconnectJobRoute` instead of sharing the connection-level cache + - [x] Decision 2026-05-23: Server reboot / `wsh` death → manual reconnect (do NOT auto-restart fresh shell). Auto-restart would change durable-session semantics from "resume existing shell" to "keep shell open at all costs," creating context-loss confusion and `wsh` re-install loops. + - GitHub issue (problem): https://github.com/whoisjeremylam/waveterm-remote/issues/7 + - GitHub issue (implementation): https://github.com/whoisjeremylam/waveterm-remote/issues/8 + - Branch: `fix/auto-reconnect-detection-gaps` + - [x] Phase 1 (Gap C): Auto-disconnect on stall — `ConnMonitor` detects stall but doesn't set `Status=Disconnected` + - Commit `b4c4dbea`: Add configurable `ConnStallDisconnectThreshold` to `ConnKeywords` + - Trigger `conn.Close()` when stall exceeds threshold (removed `!isUrgent()` guard per spec review) + - Commit `a157b234`: Add `AttemptReconnect` helper + reconnect scheduler in `onConnectionDown` (fixes GAP-1) + - This makes sleep/Wi-Fi/VPN interruptions self-healing via existing `onConnectionUp` + - [x] Phase 2 (Gap A): Implement `NotifySystemResumeCommand` — commit `a157b234` + Phase 2 additions + - `emain.ts` already hooks `powerMonitor.on('resume')` → calls `NotifySystemResumeCommand` + - `wshserver.go`: `NotifySystemResumeCommand` now calls `jobcontroller.HandleSystemResume(ctx)` instead of no-op + - `jobcontroller.go`: `HandleSystemResume` iterates all connections, finds those with durable jobs, forces disconnect on stalled zombies, spawns `AttemptReconnect()` goroutines for immediate reconnect + - Fast-path: bypasses 30s scheduler tick, attempts reconnect within ~1-2s of system wake + - [x] Phase 3 (Gap B): Aggressive scheduler enhancement — implemented as Option B + - `isNetworkUnreachableError()` detects dial tcp i/o timeout, no route, DNS failure + - On network-unreachable error: switch to 5s interval for 2 minutes + - When user switches back to good Wi-Fi, next 5s tick reconnects automatically + - After 2 minutes aggressive: returns to 30s interval for remaining scheduler window + - If still no network after total 5 min: scheduler gives up (manual reconnect required) + - No native modules, zero build risk, cross-platform automatically + - Edge cases (P2): respect manual disconnect, reconnect UI indicator + +- [x] **Tmux mouse integration lost on durable session reconnect** — FIXED 2026-05-19 + - Bug: tmux mouse mode (click to switch windows, wheel scrollback, click-drag select) works in new sessions but NOT in reconnected durable sessions after full WaveTerm restart + - Repro: close WaveTerm completely → restart → durable sessions reconnect → tmux mouse integration disabled + - Expected: durable sessions should re-enable tmux mouse integration on reconnect, same as new sessions + - Root cause: xterm.js internal DEC private mode state lost on reconnect; only cached terminal data was replayed, not mode negotiation sequences + - Fix commits: `af669bcb` (original DEC mode restore), `01f5073d` (multi-param CSI tracking, clear-all reset, stale cache purge, replay whitelist) + - Tests: `f839f8ab` (14 Vitest unit tests with mocked xterm.js) + - GitHub comment posted to issue #2 with full analysis + - README fork notes updated with bug fix reference +- [x] **Crash on tab close after SSH session exit** — Fixed 2026-05-14 + - Root cause found: double `DestroyBlockController` race in `CloseTab` (explicit goroutine + `DeleteTab` → `BlockCloseEvent` handler) + - Fix: removed redundant goroutine in `CloseTab`; added `sync.Once` to `ShellProc.Close()` as defense-in-depth + - Added trace logging to `CloseTab`, `DestroyBlockController`, `ShellController.Stop`, `DurableShellController.Stop`, `handleBlockCloseEvent` + - Tests: fixed 2 panicking tests (channel double-close bug in test code), all 14 tests pass under `-race` + - Spec: [[.pi/specs/bug-tabclose-crash.md]] + - [x] **Post-confirm cleanup:** Removed trace logging 2026-05-14 + +### Features + +- [x] Remove telemetry (spec: [[.pi/specs/remove-telemetry.md]]) + - [x] Phase A: Remove call sites + - [x] Phase B: Remove frontend telemetry + - [x] Phase C: Delete unused packages + - [x] Phase D: Clean up docs +- [x] Remove Wave AI features (spec: [[.pi/specs/remove-waveai.md]]) + - [x] Phase A: Disable UI (frontend) — completed 2026-05-16 + - [x] Fix blank screen: invalid nested `` in `workspace.tsx` (removed inner PanelGroup but left VTabBar `` orphaned inside outer ``) + - [x] Remove sparkle/Claude icon from terminal block header (`getShellIntegrationIconButton` → no-op stub) + - [x] Minor: update misleading AI text in `builder-previewtab.tsx` EmptyStateView — fixed 2026-05-16 + - [x] Phase B: Remove backend wiring (Go) — 2026-05-15 + - [x] Phase C: Clean up docs & schemas — 2026-05-16 + - [x] Phase D: Delete unused code — completed 2026-05-16 + - [x] Remove builder AI dependencies (A.15: `AIPanel`, `WaveAIModel`, `formatFileSize`, `builder-focusmanager.ts`) + - [x] Move `formatFileSize` to shared utility (`@/util/util`) — completed in commit bd355fad + - [x] Delete `pkg/aiusechat/` (entire directory, ~12K lines, dead package) + - [x] Delete `frontend/app/aipanel/` (17 files, orphaned after builder deps removed) + - [x] Delete `frontend/app/view/waveai/`, `frontend/app/view/aifilediff/`, `frontend/app/view/waveconfig/waveaivisual.tsx` + - [x] Delete `frontend/app/onboarding/fakechat.tsx`, preview files + - [x] Clean Go structs: `SettingsType`, `MetaTSType`, `ObjRTInfo`, `FullConfigType`, `AIModeConfigType`, etc. + - [x] Delete default configs: `waveai.json`, `presets/ai.json`, clean `settings.json` + - [x] Regenerate auto-generated TS types (`gotypes.d.ts`, `waveevent.d.ts`, `wshclientapi.ts`) and Go metaconsts + - [x] Document Claude Code shell integration analysis for future pi agent reuse (`.pi/decisions.md`) +- [x] SSH port forwarding (`LocalForward` / `RemoteForward`) (spec: [[.pi/specs/portforwarding.md]]) — completed 2026-06-04 + - [x] Modify `pkg/wconfig/settingsconfig.go` + - [x] Modify `pkg/remote/sshclient.go` (parse + return merged keywords) + - [x] Modify `pkg/remote/conncontroller/conncontroller.go` (runtime forwarding) + - [x] Update call sites for new `ConnectToClient` signature + - [x] Add tests + - [x] Update documentation (`docs/docs/connections.mdx`) +- [x] **Port forwarding UI status indicators** (spec: [[.pi/specs/portforwarding-ui.md]]) — completed 2026-06-07 + - [x] Add `ForwardingRules []string` to `ConnStatus` struct (no new RPC needed) + - [x] Populate in `DeriveConnStatus()` from `LocalForwardListeners`/`RemoteForwardListeners` + - [x] Create `port-forward-status.tsx` component (plug icon + badge + tooltip) + - [x] Wire into `blockframe-header.tsx` between DurableSessionFlyover and badge + - [x] Go build passes, Go tests pass, TypeScript compiles cleanly +- [ ] **Remote file paste** — image paste + drag-drop for remote sessions + - Primary use case: pasting screenshots and dragging files when using pi or Claude Code's TUI over SSH + - Currently pastes local file paths that don't exist on the remote server + - Need: upload file to remote (SSH exec with stdin, SFTP, or SCP), then paste remote path + - Sub-tasks: + - [ ] Detect when terminal block is on a remote SSH connection + - [ ] Add RPC command to upload file bytes to remote server via existing SSH connection + - [ ] Wire up image paste (`termwrap.ts` `pasteHandler`) to use remote upload for SSH sessions + - [ ] Wire up drag-drop (`termwrap.ts` `dropHandler`) to use remote upload for SSH sessions + - [ ] Add tests + +- [ ] **System widgets follow terminal focus** (spec: [[.pi/specs/widget-follow-focus.md]]) + - When opening Process Viewer, File Browser, etc., inherit connection from focused terminal + - [ ] Add `getFocusedTerminalConnection()` helper in `global.ts` + - [ ] Add `createWidgetBlock()` wrapper that injects connection meta + - [ ] Update widgets bar (`widgets.tsx`) to use `createWidgetBlock` + - [ ] Add `inheritconnection` field to widget config schema + - [ ] Verify non-terminal widgets (Settings, Help) are unaffected + - [ ] Add tests + +- [ ] Paste screenshots into terminal (local sessions — polish) + - [ ] Consider implementing paste-as-image in Pi directly for tighter integration (avoid SCP+filename pattern, inject binary data or use OSC52/terminal-native paste) + +## Backlog / Ideas + +### Features to Add (discuss, spec, scope later) + +- **MOSH support** — Research done 2026-05-20. MOSH's main benefits: seamless reconnection (roaming, sleep/wake) and client-side local echo. Not a priority because: (1) no port forwarding (open issue since 2014), (2) no OSC52 clipboard, (3) no scrollback, (4) C++ only, slow development. tsshd (trzsz-ssh) is the more relevant reference — Go-based, full SSH features + UDP roaming, but significant architectural change. Local echo is technically possible with wsh but non-trivial and low-value for typical latency. +- **Vertical tabs** — Tab layout optimized for remote host switching + + + +### Agent Orchestration API + +- [ ] **wsh Agent API** — Agent orchestration via wsh commands (spec: [[.pi/specs/wsh-agent-api.md]]) + - Scope guardrail: "anything a human could do via the UI or keyboard" + - Phase 1: `--json` output on existing read commands (`block list`, `connection list`, `tab list`) + - Phase 2: New read commands (`block get` with scrollback, `config get`) + - Phase 3: Write commands (`block create` with options, `block send-keys`, `block focus`, `config set`) + - Phase 4: Agent helpers (`agent spawn`, `agent help`) + - Discovery: `WAVE_TERMINAL=1` env var + `wsh agent help` + - Security: no new auth surface — agent inherits user's permissions + +### Forwarding Enhancements + +- DynamicForward (SOCKS proxy) — out of scope for v1, needs SOCKS5 handler +- `wsh ssh -L` / `-R` CLI flags +- UI status indicator for active port forwards + +### Session Persistence (Tmux + Wsh Overlap) + +> Jeremy's note 2026-05-23: "I frequently lose all sessions when the server automatically restarts each week (part of a backup). I have to recreate tmux sessions manually." + +- **Tmux session auto-restore on reconnect** — After server reboot + reconnect, automatically recreate tmux sessions (restore layout, windows, sessions). Currently lost because `wsh` / job manager dies and WaveTerm only reconnects the raw shell. +- **Tab name sync with tmux session name** — WaveTerm tab label follows tmux session name for visibility. +- **Bring tmux features into wsh** — Tmux provides persistence, session multiplexing, and screen visibility (for agents). Consider which tmux features overlap with WaveTerm durable sessions and where wsh could natively replicate them (session restore, window splitting, scrollback capture). + +### UX Improvements + +- **New block default connection** — Currently clicking '+' defaults to local; for remote-first workflow, should default to SSH/remote or at least not require manual switching +- **SSH config as source of truth** — Connection management currently pushes users to JSON/settings UI instead of naturally leveraging `~/.ssh/config` as the primary management interface + +### File Transfer + +- **Drag and drop file transfer** — Drag files into the file browser to upload; drag from file browser to download + +### General + +- Remove checks to `dl.waveterm.dev` (e.g., update checks, download URLs) +- Evaluate which other local-first widgets to remove/diminish diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..64bf1784d6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# AGENTS.md — waveterm-remote Fork + +This fork of Wave Terminal is optimized for remote development workflows. The local machine is a thin client; remote SSH environments are primary workspaces. + +## Git Remotes + +- `origin` → `https://github.com/whoisjeremylam/waveterm-remote` (this fork) +- `upstream` → `https://github.com/wavetermdev/waveterm` (original) +- Do not run `git push` — the user handles pushes interactively with 2FA + +## Dev Environment + +| Tool | Status | +|------|--------| +| NodeJS v24.14.0 | Available | +| npm 11.9.0 | Available | +| git 2.43.0 | Available | +| Go 1.26.2 | Local install in `golang-1.26.2/` | +| Task (build runner) | Local npm dep (`@go-task/cli`) | + +Go and Task are installed locally (not globally). The Taskfile uses `{{.GO_DIR}}` and `{{.GO}}` vars to reference the local Go binary. + +**When upgrading Go**: download to `golang-/`, update `GO_DIR` in Taskfile.yml vars, and run `echo "module golang" > golang-/go.mod` (prevents `go mod tidy` from scanning the Go install dir as part of the project module). + +Run `./node_modules/.bin/task init` then `./node_modules/.bin/task dev`. + +## Planning Documents + +All fork planning lives in `.pi/`: +- `.pi/index.md` — entry point +- `.pi/context.md` — fork purpose and problem statement +- `.pi/todos.md` — active tasks and backlog +- `.pi/decisions.md` — architecture decisions +- `.pi/specs/` — feature specifications + +Current active spec: `.pi/specs/portforwarding.md` + +## Architecture + +- **Frontend**: React/TypeScript in `frontend/` +- **Backend**: Go in `pkg/` and `cmd/` +- **Electron main**: `emain/` (Node.js bridge between frontend and Go) +- **Go backend runs as separate process** — Electron main process bridges to it via IPC + +## Priorities + +1. Verify `task dev` and `task start` work (build tools installed) +2. Implement SSH port forwarding (`LocalForward`/`RemoteForward`) — spec ready +3. Later: remove/disable AI features, MOSH support, vertical tabs, UX improvements + +## Conventions + +- Follow existing code patterns: `panichandler` on goroutines, `WithLock` for struct mutations, table-driven tests with `t.Run`, manual `if` assertions (no testify) +- `docs/docs/` is public-facing documentation (Docusaurus) — do not mix fork planning with user docs +- `README.md` stays close to upstream; fork differences go in `.pi/` or `README-FORK.md` if needed +- All new SSH config keywords follow the parsing pattern in `pkg/remote/sshclient.go` +- ConnKeywords fields use `json:"ssh:..."` tags for SSH config and `json:"conn:..."` for internal config + +## Key Files for SSH Work + +| File | Purpose | +|------|---------| +| `pkg/wconfig/settingsconfig.go` | `ConnKeywords` struct — add new SSH fields here | +| `pkg/remote/sshclient.go` | Config parsing (`findSshConfigKeywords`), merging (`mergeKeywords`), `ConnectToClient` | +| `pkg/remote/conncontroller/conncontroller.go` | Connection lifecycle — start forwarding after connect, cleanup on disconnect | +| `pkg/genconn/ssh-impl.go` | SSH session implementation | +| `cmd/wsh/cmd/wshcmd-ssh.go` | `wsh ssh` CLI command | +| `docs/docs/connections.mdx` | Public docs for connections and SSH config | + +## Testing + +- No existing tests for `sshclient.go` or `conncontroller.go` — new tests would be first coverage +- Use `t.TempDir()` for filesystem fixtures, not external fixture files +- Use hand-written inline mocks, not gomock +- `t.Parallel()` on independent tests only + +## Out of Scope (Current) + +- DynamicForward (needs SOCKS5 handler) +- `wsh ssh -L`/`-R` CLI flags +- UI status indicators for port forwarding diff --git a/README.md b/README.md index a9f406725c..e518d0b93e 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@

+> **Fork:** This is a fork of [Wave Terminal](https://github.com/wavetermdev/waveterm) optimized for remote development workflows. + # Wave Terminal
@@ -17,23 +19,29 @@
-[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_shield) +Wave is an open-source terminal for macOS, Linux, and Windows. No accounts required. + +Wave 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. + +## Fork Notes -Wave is an open-source, AI-integrated terminal for macOS, Linux, and Windows. It works with any AI model. Bring your own API keys for OpenAI, Claude, or Gemini, or run local models via Ollama and LM Studio. No accounts required. +This fork is optimized for remote development workflows with a focus on macOS. -Wave also supports durable SSH sessions that survive network interruptions and restarts, with automatic reconnection. Edit remote files with a built-in graphical editor and preview files inline without leaving the terminal. +- **No telemetry** — All analytics, telemetry, and cloud data collection have been completely removed; no usage data is sent to external servers +- **Local toolchain** — Go and Task are installed locally (not global), no system dependencies required +- **macOS builds** — CI builds macOS `.dmg` via GitHub Actions (manual trigger) +- **Bug fixes** — Fixed tmux mouse integration lost on durable SSH session reconnect (commit `01f5073d`, issue #2); fixed crash on tab close after SSH session exit (commit `0cd6489b`) +- **Planned changes** — SSH port forwarding, remote file paste (image/drag-drop for SSH sessions), vertical tabs, SSH config as source of truth for connections ![WaveTerm Screenshot](./assets/wave-screenshot.webp) ## Key Features -- Wave AI - Context-aware terminal assistant that reads your terminal output, analyzes widgets, and performs file operations - Durable SSH Sessions - Remote terminal sessions survive connection interruptions, network changes, and Wave restarts with automatic reconnection -- Flexible drag & drop interface to organize terminal blocks, editors, web browsers, and AI assistants +- Flexible drag & drop interface to organize terminal blocks, editors, web browsers, and previews - Built-in editor for editing remote files with syntax highlighting and modern editor features - Rich file preview system for remote files (markdown, images, video, PDFs, CSVs, directories) - Quick full-screen toggle for any block - expand terminals, editors, and previews for better visibility, then instantly return to multi-block view -- AI chat widget with support for multiple models (OpenAI, Claude, Azure, Perplexity, Ollama) - Command Blocks for isolating and monitoring individual commands - One-click remote connections with full terminal and file system access - Secure secret storage using native system backends - store API keys and credentials locally, access them across SSH sessions @@ -41,28 +49,10 @@ Wave also supports durable SSH sessions that survive network interruptions and r - Powerful `wsh` command system for managing your workspace from the CLI and sharing data between terminal sessions - Connected file management with `wsh file` - seamlessly copy and sync files between local and remote SSH hosts -## Wave AI - -Wave AI is your context-aware terminal assistant with access to your workspace: - -- **Terminal Context**: Reads terminal output and scrollback for debugging and analysis -- **File Operations**: Read, write, and edit files with automatic backups and user approval -- **CLI Integration**: Use `wsh ai` to pipe output or attach files directly from the command line -- **BYOK Support**: Bring your own API keys for OpenAI, Claude, Gemini, Azure, and other providers -- **Local Models**: Run local models with Ollama, LM Studio, and other OpenAI-compatible providers -- **Free Beta**: Included AI credits while we refine the experience -- **Coming Soon**: Command execution (with approval) - -Learn more in our [Wave AI documentation](https://docs.waveterm.dev/waveai) and [Wave AI Modes documentation](https://docs.waveterm.dev/waveai-modes). - ## Installation Wave Terminal works on macOS, Linux, and Windows. -Platform-specific installation instructions can be found [here](https://docs.waveterm.dev/gettingstarted). - -You can also install Wave Terminal directly from: [www.waveterm.dev/download](https://www.waveterm.dev/download). - ### Minimum requirements Wave Terminal runs on the following platforms: @@ -77,20 +67,6 @@ The WSH helper runs on the following platforms: - Windows 10 or later (x64) - Linux Kernel 2.6.32 or later (x64), Linux Kernel 3.1 or later (arm64) -## Roadmap - -Wave is constantly improving! Our roadmap will be continuously updated with our goals for each release. You can find it [here](./ROADMAP.md). - -Want to provide input to our future releases? Connect with us on [Discord](https://discord.gg/XfvZ334gwU) or open a [Feature Request](https://github.com/wavetermdev/waveterm/issues/new/choose)! - -## Links - -- Homepage — https://www.waveterm.dev -- Download Page — https://www.waveterm.dev/download -- Documentation — https://docs.waveterm.dev -- X — https://x.com/wavetermdev -- Discord Community — https://discord.gg/XfvZ334gwU - ## Building from Source See [Building Wave Terminal](BUILD.md). @@ -104,14 +80,6 @@ Find more information in our [Contributions Guide](CONTRIBUTING.md), which inclu - [Ways to contribute](CONTRIBUTING.md#contributing-to-wave-terminal) - [Contribution guidelines](CONTRIBUTING.md#before-you-start) -### Sponsoring Wave ❤️ - -If Wave Terminal is useful to you or your company, consider sponsoring development. - -Sponsorship helps support the time spent building and maintaining the project. - -- https://github.com/sponsors/wavetermdev - ## License Wave Terminal is licensed under the Apache-2.0 License. For more information on our dependencies, see [here](./ACKNOWLEDGEMENTS.md). diff --git a/Taskfile.yml b/Taskfile.yml index bf37a83e45..76154d1b91 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -13,6 +13,10 @@ vars: ARTIFACTS_BUCKET: waveterm-github-artifacts/staging-w2 RELEASES_BUCKET: dl.waveterm.dev/releases-w2 WINGET_PACKAGE: CommandLine.Wave + GO_DIR: "golang-1.26.2" + GO: "{{.ROOT_DIR}}/{{.GO_DIR}}/bin/go" + ZIG_DIR: "zig-0.14.0" + ZIG: "{{.ROOT_DIR}}/{{.ZIG_DIR}}/zig" tasks: electron:dev: @@ -166,7 +170,7 @@ tasks: generates: - "dist/schema/**/*" cmds: - - go run cmd/generateschema/main-generateschema.go + - "{{.GO}} run cmd/generateschema/main-generateschema.go" - cmd: '{{.RMRF}} "dist/schema"' ignore_error: true - task: copyfiles:'schema':'dist/schema' @@ -225,7 +229,7 @@ tasks: - task: build:server:internal vars: ARCHS: amd64 - GO_ENV_VARS: CC="zig cc -target x86_64-windows-gnu" + GO_ENV_VARS: CC="{{.ZIG}} cc -target x86_64-windows-gnu" deps: - go:mod:tidy sources: @@ -248,7 +252,7 @@ tasks: ARCHS: sh: echo {{if eq "arm" ARCH}}arm64{{else}}{{ARCH}}{{end}} GO_ENV_VARS: - sh: echo "{{if eq "amd64" ARCH}}CC=\"zig cc -target x86_64-windows-gnu\"{{else}}CC=\"zig cc -target aarch64-windows-gnu\"{{end}}" + sh: echo "{{if eq "amd64" ARCH}}CC=\"{{.ZIG}} cc -target x86_64-windows-gnu\"{{else}}CC=\"{{.ZIG}} cc -target aarch64-windows-gnu\"{{end}}" build:server:linux: desc: Build the wavesrv component for Linux platforms (only generates artifacts for the current architecture). @@ -261,14 +265,14 @@ tasks: ARCHS: sh: echo {{if eq "arm" ARCH}}arm64{{else}}{{ARCH}}{{end}} GO_ENV_VARS: - sh: echo "{{if eq "amd64" ARCH}}CC=\"zig cc -target x86_64-linux-gnu.2.28\"{{else}}CC=\"zig cc -target aarch64-linux-gnu.2.28\"{{end}}" + sh: echo "{{if eq "amd64" ARCH}}CC=\"{{.ZIG}} cc -target x86_64-linux-gnu.2.28\"{{else}}CC=\"{{.ZIG}} cc -target aarch64-linux-gnu.2.28\"{{end}}" build:server:internal: requires: vars: - ARCHS cmd: - cmd: CGO_ENABLED=1 GOARCH={{.GOARCH}} {{.GO_ENV_VARS}} go build -tags "osusergo,sqlite_omit_load_extension" -ldflags "{{.GO_LDFLAGS}} -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wavesrv.{{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}{{exeExt}} cmd/server/main-server.go + cmd: CGO_ENABLED=1 GOARCH={{.GOARCH}} {{.GO_ENV_VARS}} {{.GO}} build -tags "osusergo,sqlite_omit_load_extension" -ldflags "{{.GO_LDFLAGS}} -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wavesrv.{{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}{{exeExt}} cmd/server/main-server.go for: var: ARCHS split: "," @@ -342,7 +346,7 @@ tasks: - GOOS - GOARCH - VERSION - cmd: (CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} go build -ldflags="-s -w -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wsh-{{.VERSION}}-{{.GOOS}}.{{.NORMALIZEDARCH}}{{.EXT}} cmd/wsh/main-wsh.go) + cmd: (CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} {{.GO}} build -ldflags="-s -w -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wsh-{{.VERSION}}-{{.GOOS}}.{{.NORMALIZEDARCH}}{{.EXT}} cmd/wsh/main-wsh.go) internal: true build:tsunamiscaffold: @@ -363,8 +367,8 @@ tasks: generate: desc: Generate Typescript bindings for the Go backend. cmds: - - go run cmd/generatets/main-generatets.go - - go run cmd/generatego/main-generatego.go + - "{{.GO}} run cmd/generatets/main-generatets.go" + - "{{.GO}} run cmd/generatego/main-generatego.go" deps: - build:schema sources: @@ -469,7 +473,7 @@ tasks: desc: Initialize the project for development. cmds: - npm install - - go mod tidy + - "{{.GO}} mod tidy" - cd docs && npm install dev:cleardata:windows: @@ -505,7 +509,7 @@ tasks: - go.sum sources: - go.mod - cmd: go mod tidy + cmd: "{{.GO}} mod tidy" copyfiles:*:*: desc: Recursively copy directory and its contents. @@ -522,7 +526,7 @@ tasks: tsunami:demo:todo: desc: Run the tsunami todo demo application - cmd: go run demo/todo/*.go + cmd: "{{.GO}} run demo/todo/*.go" dir: tsunami env: TSUNAMI_LISTENADDR: "localhost:12026" @@ -632,7 +636,7 @@ tasks: platforms: [windows] ignore_error: true - mkdir -p bin - - cd tsunami && go build -ldflags "-X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.TsunamiVersion={{.VERSION}}" -o ../bin/tsunami{{exeExt}} cmd/main-tsunami.go + - cd tsunami && {{.GO}} build -ldflags "-X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.TsunamiVersion={{.VERSION}}" -o ../bin/tsunami{{exeExt}} cmd/main-tsunami.go sources: - "tsunami/**/*.go" - "tsunami/go.mod" @@ -651,9 +655,9 @@ tasks: godoc: desc: Start the Go documentation server for the root module - cmd: $(go env GOPATH)/bin/pkgsite -http=:6060 + cmd: $({{.GO}} env GOPATH)/bin/pkgsite -http=:6060 tsunami:godoc: desc: Start the Go documentation server for the tsunami module - cmd: $(go env GOPATH)/bin/pkgsite -http=:6060 + cmd: $({{.GO}} env GOPATH)/bin/pkgsite -http=:6060 dir: tsunami diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go index ab7e338439..99e268ad85 100644 --- a/cmd/generatego/main-generatego.go +++ b/cmd/generatego/main-generatego.go @@ -24,9 +24,7 @@ func GenerateWshClient() error { fmt.Fprintf(os.Stderr, "generating wshclient file to %s\n", WshClientFileName) var buf strings.Builder gogen.GenerateBoilerplate(&buf, "wshclient", []string{ - "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes", "github.com/wavetermdev/waveterm/pkg/baseds", - "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata", "github.com/wavetermdev/waveterm/pkg/vdom", "github.com/wavetermdev/waveterm/pkg/waveobj", "github.com/wavetermdev/waveterm/pkg/wconfig", diff --git a/cmd/generateschema/main-generateschema.go b/cmd/generateschema/main-generateschema.go index dd24a4df0d..c9e77f4d79 100644 --- a/cmd/generateschema/main-generateschema.go +++ b/cmd/generateschema/main-generateschema.go @@ -18,10 +18,8 @@ import ( const WaveSchemaSettingsFileName = "schema/settings.json" const WaveSchemaConnectionsFileName = "schema/connections.json" -const WaveSchemaAiPresetsFileName = "schema/aipresets.json" const WaveSchemaWidgetsFileName = "schema/widgets.json" const WaveSchemaBackgroundsFileName = "schema/backgrounds.json" -const WaveSchemaWaveAIFileName = "schema/waveai.json" // ViewNameType is a string type whose JSON Schema offers enum suggestions for the most // common widget view names while still accepting any arbitrary string value. @@ -193,12 +191,6 @@ func main() { log.Fatalf("connections schema error: %v", err) } - aiPresetsTemplate := make(map[string]wconfig.AiSettingsType) - err = generateSchema(&aiPresetsTemplate, WaveSchemaAiPresetsFileName, false) - if err != nil { - log.Fatalf("ai presets schema error: %v", err) - } - err = generateWidgetsSchema(WaveSchemaWidgetsFileName) if err != nil { log.Fatalf("widgets schema error: %v", err) @@ -210,9 +202,5 @@ func main() { log.Fatalf("backgrounds schema error: %v", err) } - waveAITemplate := make(map[string]wconfig.AIModeConfigType) - err = generateSchema(&waveAITemplate, WaveSchemaWaveAIFileName, false) - if err != nil { - log.Fatalf("waveai schema error: %v", err) - } + } diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index b204643ee8..5e8593c424 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -14,7 +14,6 @@ import ( "time" "github.com/joho/godotenv" - "github.com/wavetermdev/waveterm/pkg/aiusechat" "github.com/wavetermdev/waveterm/pkg/authkey" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/blocklogger" @@ -22,19 +21,13 @@ import ( "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/jobcontroller" "github.com/wavetermdev/waveterm/pkg/panichandler" - "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" - "github.com/wavetermdev/waveterm/pkg/secretstore" "github.com/wavetermdev/waveterm/pkg/service" - "github.com/wavetermdev/waveterm/pkg/telemetry" - "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/sigutil" - "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wcloud" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/web" @@ -44,7 +37,6 @@ import ( "github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver" "github.com/wavetermdev/waveterm/pkg/wshutil" - "github.com/wavetermdev/waveterm/pkg/wslconn" "github.com/wavetermdev/waveterm/pkg/wstore" "net/http" @@ -55,15 +47,8 @@ import ( var WaveVersion = "0.0.0" var BuildTime = "0" -const InitialTelemetryWait = 10 * time.Second -const TelemetryTick = 2 * time.Minute -const TelemetryInterval = 4 * time.Hour -const TelemetryInitialCountsWait = 5 * time.Second -const TelemetryCountsInterval = 1 * time.Hour const BackupCleanupTick = 2 * time.Minute const BackupCleanupInterval = 4 * time.Hour -const InitialDiagnosticWait = 5 * time.Minute -const DiagnosticTick = 10 * time.Minute var shutdownOnce sync.Once @@ -81,8 +66,6 @@ func doShutdown(reason string) { ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() go blockcontroller.StopAllBlockControllersForShutdown() - shutdownActivityUpdate() - sendTelemetryWrapper() // TODO deal with flush in progress clearTempFiles() filestore.WFS.FlushCache(ctx) @@ -118,74 +101,6 @@ func startConfigWatcher() { } } -func telemetryLoop() { - defer func() { - panichandler.PanicHandler("telemetryLoop", recover()) - }() - var nextSend int64 - time.Sleep(InitialTelemetryWait) - for { - if time.Now().Unix() > nextSend { - nextSend = time.Now().Add(TelemetryInterval).Unix() - sendTelemetryWrapper() - } - time.Sleep(TelemetryTick) - } -} - -func diagnosticLoop() { - defer func() { - panichandler.PanicHandler("diagnosticLoop", recover()) - }() - if os.Getenv("WAVETERM_NOPING") != "" { - log.Printf("WAVETERM_NOPING set, disabling diagnostic ping\n") - return - } - var lastSentDate string - time.Sleep(InitialDiagnosticWait) - for { - currentDate := time.Now().Format("2006-01-02") - if lastSentDate == "" || lastSentDate != currentDate { - if sendDiagnosticPing() { - lastSentDate = currentDate - } - } - time.Sleep(DiagnosticTick) - } -} - -func sendDiagnosticPing() bool { - ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelFn() - - rpcClient := wshclient.GetBareRpcClient() - isOnline, err := wshclient.NetworkOnlineCommand(rpcClient, &wshrpc.RpcOpts{Route: "electron", Timeout: 2000}) - if err != nil || !isOnline { - return false - } - clientId := wstore.GetClientId() - usageTelemetry := telemetry.IsTelemetryEnabled() - wcloud.SendDiagnosticPing(ctx, clientId, usageTelemetry) - return true -} - -func setupTelemetryConfigHandler() { - watcher := wconfig.GetWatcher() - if watcher == nil { - return - } - currentConfig := watcher.GetFullConfig() - currentTelemetryEnabled := currentConfig.Settings.TelemetryEnabled - - watcher.RegisterUpdateHandler(func(newConfig wconfig.FullConfigType) { - newTelemetryEnabled := newConfig.Settings.TelemetryEnabled - if newTelemetryEnabled != currentTelemetryEnabled { - currentTelemetryEnabled = newTelemetryEnabled - wcore.GoSendNoTelemetryUpdate(newTelemetryEnabled) - } - }) -} - func backupCleanupLoop() { defer func() { panichandler.PanicHandler("backupCleanupLoop", recover()) @@ -203,185 +118,6 @@ func backupCleanupLoop() { } } -func panicTelemetryHandler(panicName string) { - activity := wshrpc.ActivityUpdate{NumPanics: 1} - err := telemetry.UpdateActivity(context.Background(), activity) - if err != nil { - log.Printf("error updating activity (panicTelemetryHandler): %v\n", err) - } - telemetry.RecordTEvent(context.Background(), telemetrydata.MakeTEvent("debug:panic", telemetrydata.TEventProps{ - PanicType: panicName, - })) -} - -func sendTelemetryWrapper() { - defer func() { - panichandler.PanicHandler("sendTelemetryWrapper", recover()) - }() - ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second) - defer cancelFn() - beforeSendActivityUpdate(ctx) - clientId := wstore.GetClientId() - err := wcloud.SendAllTelemetry(clientId) - if err != nil { - log.Printf("[error] sending telemetry: %v\n", err) - } -} - -func updateTelemetryCounts(lastCounts telemetrydata.TEventProps) telemetrydata.TEventProps { - ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelFn() - var props telemetrydata.TEventProps - props.CountBlocks, _ = wstore.DBGetCount[*waveobj.Block](ctx) - props.CountTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx) - props.CountWindows, _ = wstore.DBGetCount[*waveobj.Window](ctx) - props.CountWorkspaces, _, _ = wstore.DBGetWSCounts(ctx) - props.CountSSHConn = conncontroller.GetNumSSHHasConnected() - props.CountWSLConn = wslconn.GetNumWSLHasConnected() - props.CountJobs = jobcontroller.GetNumJobsRunning() - props.CountJobsConnected = jobcontroller.GetNumJobsConnected() - props.CountViews, _ = wstore.DBGetBlockViewCounts(ctx) - - fullConfig := wconfig.GetWatcher().GetFullConfig() - customWidgets := fullConfig.CountCustomWidgets() - customAIPresets := fullConfig.CountCustomAIPresets() - customSettings := wconfig.CountCustomSettings() - customAIModes := fullConfig.CountCustomAIModes() - - props.UserSet = &telemetrydata.TEventUserProps{ - SettingsCustomWidgets: customWidgets, - SettingsCustomAIPresets: customAIPresets, - SettingsCustomSettings: customSettings, - SettingsCustomAIModes: customAIModes, - } - - secretsCount, err := secretstore.CountSecrets() - if err == nil { - props.UserSet.SettingsSecretsCount = secretsCount - } - - if utilfn.CompareAsMarshaledJson(props, lastCounts) { - return lastCounts - } - tevent := telemetrydata.MakeTEvent("app:counts", props) - err = telemetry.RecordTEvent(ctx, tevent) - if err != nil { - log.Printf("error recording counts tevent: %v\n", err) - } - return props -} - -func updateTelemetryCountsLoop() { - defer func() { - panichandler.PanicHandler("updateTelemetryCountsLoop", recover()) - }() - var nextSend int64 - var lastCounts telemetrydata.TEventProps - time.Sleep(TelemetryInitialCountsWait) - for { - if time.Now().Unix() > nextSend { - nextSend = time.Now().Add(TelemetryCountsInterval).Unix() - lastCounts = updateTelemetryCounts(lastCounts) - } - time.Sleep(TelemetryTick) - } -} - -func beforeSendActivityUpdate(ctx context.Context) { - activity := wshrpc.ActivityUpdate{} - activity.NumTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx) - activity.NumBlocks, _ = wstore.DBGetCount[*waveobj.Block](ctx) - activity.Blocks, _ = wstore.DBGetBlockViewCounts(ctx) - activity.NumWindows, _ = wstore.DBGetCount[*waveobj.Window](ctx) - activity.NumSSHConn = conncontroller.GetNumSSHHasConnected() - activity.NumWSLConn = wslconn.GetNumWSLHasConnected() - activity.NumWSNamed, activity.NumWS, _ = wstore.DBGetWSCounts(ctx) - err := telemetry.UpdateActivity(ctx, activity) - if err != nil { - log.Printf("error updating before activity: %v\n", err) - } -} - -func startupActivityUpdate(firstLaunch bool) { - defer func() { - panichandler.PanicHandler("startupActivityUpdate", recover()) - }() - ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelFn() - activity := wshrpc.ActivityUpdate{Startup: 1} - err := telemetry.UpdateActivity(ctx, activity) // set at least one record into activity (don't use go routine wrap here) - if err != nil { - log.Printf("error updating startup activity: %v\n", err) - } - autoUpdateChannel := telemetry.AutoUpdateChannel() - autoUpdateEnabled := telemetry.IsAutoUpdateEnabled() - shellType, shellVersion, shellErr := shellutil.DetectShellTypeAndVersion() - if shellErr != nil { - shellType = "error" - shellVersion = "" - } - userSetOnce := &telemetrydata.TEventUserProps{ - ClientInitialVersion: "v" + WaveVersion, - } - tosTs := telemetry.GetTosAgreedTs() - var cohortTime time.Time - if tosTs > 0 { - cohortTime = time.UnixMilli(tosTs) - } else { - cohortTime = time.Now() - } - cohortMonth := cohortTime.Format("2006-01") - year, week := cohortTime.ISOWeek() - cohortISOWeek := fmt.Sprintf("%04d-W%02d", year, week) - userSetOnce.CohortMonth = cohortMonth - userSetOnce.CohortISOWeek = cohortISOWeek - fullConfig := wconfig.GetWatcher().GetFullConfig() - props := telemetrydata.TEventProps{ - UserSet: &telemetrydata.TEventUserProps{ - ClientVersion: "v" + wavebase.WaveVersion, - ClientBuildTime: wavebase.BuildTime, - ClientArch: wavebase.ClientArch(), - ClientOSRelease: wavebase.UnameKernelRelease(), - ClientIsDev: wavebase.IsDevMode(), - ClientPackageType: wavebase.ClientPackageType(), - ClientMacOSVersion: wavebase.ClientMacOSVersion(), - AutoUpdateChannel: autoUpdateChannel, - AutoUpdateEnabled: autoUpdateEnabled, - LocalShellType: shellType, - LocalShellVersion: shellVersion, - SettingsTransparent: fullConfig.Settings.WindowTransparent, - }, - UserSetOnce: userSetOnce, - } - if firstLaunch { - props.AppFirstLaunch = true - } - tevent := telemetrydata.MakeTEvent("app:startup", props) - err = telemetry.RecordTEvent(ctx, tevent) - if err != nil { - log.Printf("error recording startup event: %v\n", err) - } -} - -func shutdownActivityUpdate() { - ctx, cancelFn := context.WithTimeout(context.Background(), 1*time.Second) - defer cancelFn() - activity := wshrpc.ActivityUpdate{Shutdown: 1} - err := telemetry.UpdateActivity(ctx, activity) // do NOT use the go routine wrap here (this needs to be synchronous) - if err != nil { - log.Printf("error updating shutdown activity: %v\n", err) - } - err = telemetry.TruncateActivityTEventForShutdown(ctx) - if err != nil { - log.Printf("error truncating activity t-event for shutdown: %v\n", err) - } - tevent := telemetrydata.MakeTEvent("app:shutdown", telemetrydata.TEventProps{}) - err = telemetry.RecordTEvent(ctx, tevent) - if err != nil { - log.Printf("error recording shutdown event: %v\n", err) - } -} - func createMainWshClient() { rpc := wshserver.GetMainRpcClient() wshutil.DefaultRouter.RegisterTrustedLeaf(rpc, wshutil.DefaultRoute) @@ -405,11 +141,6 @@ func grabAndRemoveEnvVars() error { if err != nil { return err } - err = wcloud.CacheAndRemoveEnvVars() - if err != nil { - return err - } - // Remove WAVETERM env vars that leak from prod => dev os.Unsetenv("WAVETERM_CLIENTID") os.Unsetenv("WAVETERM_WORKSPACEID") @@ -525,7 +256,6 @@ func main() { log.Printf("error initializing wstore: %v\n", err) return } - panichandler.PanicTelemetryHandler = panicTelemetryHandler go func() { defer func() { panichandler.PanicHandler("InitCustomShellStartupFiles", recover()) @@ -563,15 +293,9 @@ func main() { sigutil.InstallSIGUSR1Handler() wconfig.MigratePresetsBackgrounds() startConfigWatcher() - aiusechat.InitAIModeConfigWatcher() maybeStartPprofServer() go stdinReadWatch() - go telemetryLoop() - go diagnosticLoop() - setupTelemetryConfigHandler() - go updateTelemetryCountsLoop() go backupCleanupLoop() - go startupActivityUpdate(firstLaunch) // must be after startConfigWatcher() blocklogger.InitBlockLogger() jobcontroller.InitJobController() blockcontroller.InitBlockController() diff --git a/cmd/testai/main-testai.go b/cmd/testai/main-testai.go deleted file mode 100644 index 606e6ac6a1..0000000000 --- a/cmd/testai/main-testai.go +++ /dev/null @@ -1,548 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "context" - _ "embed" - "encoding/json" - "flag" - "fmt" - "log" - "net/http" - "os" - "time" - - "github.com/google/uuid" - "github.com/wavetermdev/waveterm/pkg/aiusechat" - "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" - "github.com/wavetermdev/waveterm/pkg/web/sse" -) - -//go:embed testschema.json -var testSchemaJSON string - -const ( - DefaultAnthropicModel = "claude-sonnet-4-5" - DefaultOpenAIModel = "gpt-5.1" - DefaultOpenRouterModel = "mistralai/mistral-small-3.2-24b-instruct" - DefaultNanoGPTModel = "zai-org/glm-4.7" - DefaultGeminiModel = "gemini-3-pro-preview" -) - -// TestResponseWriter implements http.ResponseWriter and additional interfaces for testing -type TestResponseWriter struct { - header http.Header -} - -func (w *TestResponseWriter) Header() http.Header { - if w.header == nil { - w.header = make(http.Header) - } - return w.header -} - -func (w *TestResponseWriter) Write(data []byte) (int, error) { - fmt.Printf("SSE: %s", string(data)) - return len(data), nil -} - -func (w *TestResponseWriter) WriteHeader(statusCode int) { - fmt.Printf("Status: %d\n", statusCode) -} - -// Implement http.Flusher interface -func (w *TestResponseWriter) Flush() { - // No-op for testing -} - -// Implement interfaces needed by http.ResponseController -func (w *TestResponseWriter) SetWriteDeadline(deadline time.Time) error { - // No-op for testing - return nil -} - -func (w *TestResponseWriter) SetReadDeadline(deadline time.Time) error { - // No-op for testing - return nil -} - -func getToolDefinitions() []uctypes.ToolDefinition { - var schemas map[string]any - if err := json.Unmarshal([]byte(testSchemaJSON), &schemas); err != nil { - log.Printf("Error parsing schema: %v\n", err) - return nil - } - - var configSchema map[string]any - if rawSchema, ok := schemas["config"]; ok && rawSchema != nil { - if schema, ok := rawSchema.(map[string]any); ok { - configSchema = schema - } - } - if configSchema == nil { - configSchema = map[string]any{"type": "object"} - } - - return []uctypes.ToolDefinition{ - { - Name: "get_config", - Description: "Get the current GitHub Actions Monitor configuration settings including repository, workflow, polling interval, and max workflow runs", - InputSchema: map[string]any{ - "type": "object", - }, - }, - { - Name: "update_config", - Description: "Update GitHub Actions Monitor configuration settings", - InputSchema: configSchema, - }, - { - Name: "get_data", - Description: "Get the current GitHub Actions workflow run data including workflow runs, loading state, and errors", - InputSchema: map[string]any{ - "type": "object", - }, - }, - } -} - -func testOpenAI(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { - apiKey := os.Getenv("OPENAI_APIKEY") - if apiKey == "" { - fmt.Println("Error: OPENAI_APIKEY environment variable not set") - os.Exit(1) - } - - opts := &uctypes.AIOptsType{ - APIType: uctypes.APIType_OpenAIResponses, - APIToken: apiKey, - Model: model, - MaxTokens: 4096, - ThinkingLevel: uctypes.ThinkingLevelMedium, - } - - // Generate a chat ID - chatID := uuid.New().String() - - // Convert to AIMessage format for WaveAIPostMessageWrap - aiMessage := &uctypes.AIMessage{ - MessageId: uuid.New().String(), - Parts: []uctypes.AIMessagePart{ - { - Type: uctypes.AIMessagePartTypeText, - Text: message, - }, - }, - } - - fmt.Printf("Testing OpenAI streaming with WaveAIPostMessageWrap, model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Printf("Chat ID: %s\n", chatID) - fmt.Println("---") - - testWriter := &TestResponseWriter{} - sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) - defer sseHandler.Close() - - chatOpts := uctypes.WaveChatOpts{ - ChatId: chatID, - ClientId: uuid.New().String(), - Config: *opts, - Tools: tools, - } - err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) - if err != nil { - fmt.Printf("OpenAI streaming error: %v\n", err) - } -} - -func testOpenAIComp(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { - apiKey := os.Getenv("OPENAI_APIKEY") - if apiKey == "" { - fmt.Println("Error: OPENAI_APIKEY environment variable not set") - os.Exit(1) - } - - opts := &uctypes.AIOptsType{ - APIType: uctypes.APIType_OpenAIChat, - APIToken: apiKey, - Endpoint: "https://api.openai.com/v1/chat/completions", - Model: model, - MaxTokens: 4096, - ThinkingLevel: uctypes.ThinkingLevelMedium, - } - - chatID := uuid.New().String() - - aiMessage := &uctypes.AIMessage{ - MessageId: uuid.New().String(), - Parts: []uctypes.AIMessagePart{ - { - Type: uctypes.AIMessagePartTypeText, - Text: message, - }, - }, - } - - fmt.Printf("Testing OpenAI Completions API with WaveAIPostMessageWrap, model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Printf("Chat ID: %s\n", chatID) - fmt.Println("---") - - testWriter := &TestResponseWriter{} - sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) - defer sseHandler.Close() - - chatOpts := uctypes.WaveChatOpts{ - ChatId: chatID, - ClientId: uuid.New().String(), - Config: *opts, - Tools: tools, - SystemPrompt: []string{"You are a helpful assistant. Be concise and clear in your responses."}, - } - err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) - if err != nil { - fmt.Printf("OpenAI Completions API streaming error: %v\n", err) - } -} - -func testOpenRouter(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { - apiKey := os.Getenv("OPENROUTER_APIKEY") - if apiKey == "" { - fmt.Println("Error: OPENROUTER_APIKEY environment variable not set") - os.Exit(1) - } - - opts := &uctypes.AIOptsType{ - APIType: uctypes.APIType_OpenAIChat, - APIToken: apiKey, - Endpoint: "https://openrouter.ai/api/v1/chat/completions", - Model: model, - MaxTokens: 4096, - ThinkingLevel: uctypes.ThinkingLevelMedium, - } - - chatID := uuid.New().String() - - aiMessage := &uctypes.AIMessage{ - MessageId: uuid.New().String(), - Parts: []uctypes.AIMessagePart{ - { - Type: uctypes.AIMessagePartTypeText, - Text: message, - }, - }, - } - - fmt.Printf("Testing OpenRouter with WaveAIPostMessageWrap, model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Printf("Chat ID: %s\n", chatID) - fmt.Println("---") - - testWriter := &TestResponseWriter{} - sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) - defer sseHandler.Close() - - chatOpts := uctypes.WaveChatOpts{ - ChatId: chatID, - ClientId: uuid.New().String(), - Config: *opts, - Tools: tools, - SystemPrompt: []string{"You are a helpful assistant. Be concise and clear in your responses."}, - } - err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) - if err != nil { - fmt.Printf("OpenRouter streaming error: %v\n", err) - } -} - -func testNanoGPT(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { - apiKey := os.Getenv("NANOGPT_KEY") - if apiKey == "" { - fmt.Println("Error: NANOGPT_KEY environment variable not set") - os.Exit(1) - } - - opts := &uctypes.AIOptsType{ - APIType: uctypes.APIType_OpenAIChat, - APIToken: apiKey, - Endpoint: "https://nano-gpt.com/api/v1/chat/completions", - Model: model, - MaxTokens: 4096, - } - - chatID := uuid.New().String() - - aiMessage := &uctypes.AIMessage{ - MessageId: uuid.New().String(), - Parts: []uctypes.AIMessagePart{ - { - Type: uctypes.AIMessagePartTypeText, - Text: message, - }, - }, - } - - fmt.Printf("Testing NanoGPT with WaveAIPostMessageWrap, model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Printf("Chat ID: %s\n", chatID) - fmt.Println("---") - - testWriter := &TestResponseWriter{} - sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) - defer sseHandler.Close() - - chatOpts := uctypes.WaveChatOpts{ - ChatId: chatID, - ClientId: uuid.New().String(), - Config: *opts, - Tools: tools, - SystemPrompt: []string{"You are a helpful assistant. Be concise and clear in your responses."}, - } - err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) - if err != nil { - fmt.Printf("NanoGPT streaming error: %v\n", err) - } -} - -func testAnthropic(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { - apiKey := os.Getenv("ANTHROPIC_APIKEY") - if apiKey == "" { - fmt.Println("Error: ANTHROPIC_APIKEY environment variable not set") - os.Exit(1) - } - - opts := &uctypes.AIOptsType{ - APIType: uctypes.APIType_AnthropicMessages, - APIToken: apiKey, - Model: model, - MaxTokens: 4096, - ThinkingLevel: uctypes.ThinkingLevelMedium, - } - - // Generate a chat ID - chatID := uuid.New().String() - - // Convert to AIMessage format for WaveAIPostMessageWrap - aiMessage := &uctypes.AIMessage{ - MessageId: uuid.New().String(), - Parts: []uctypes.AIMessagePart{ - { - Type: uctypes.AIMessagePartTypeText, - Text: message, - }, - }, - } - - fmt.Printf("Testing Anthropic streaming with WaveAIPostMessageWrap, model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Printf("Chat ID: %s\n", chatID) - fmt.Println("---") - - testWriter := &TestResponseWriter{} - sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) - defer sseHandler.Close() - - chatOpts := uctypes.WaveChatOpts{ - ChatId: chatID, - ClientId: uuid.New().String(), - Config: *opts, - Tools: tools, - } - err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) - if err != nil { - fmt.Printf("Anthropic streaming error: %v\n", err) - } -} - -func testGemini(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { - apiKey := os.Getenv("GOOGLE_APIKEY") - if apiKey == "" { - fmt.Println("Error: GOOGLE_APIKEY environment variable not set") - os.Exit(1) - } - - opts := &uctypes.AIOptsType{ - APIType: uctypes.APIType_GoogleGemini, - APIToken: apiKey, - Model: model, - MaxTokens: 8192, - Capabilities: []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs}, - } - - // Generate a chat ID - chatID := uuid.New().String() - - // Convert to AIMessage format for WaveAIPostMessageWrap - aiMessage := &uctypes.AIMessage{ - MessageId: uuid.New().String(), - Parts: []uctypes.AIMessagePart{ - { - Type: uctypes.AIMessagePartTypeText, - Text: message, - }, - }, - } - - fmt.Printf("Testing Google Gemini streaming with WaveAIPostMessageWrap, model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Printf("Chat ID: %s\n", chatID) - fmt.Println("---") - - testWriter := &TestResponseWriter{} - sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) - defer sseHandler.Close() - - chatOpts := uctypes.WaveChatOpts{ - ChatId: chatID, - ClientId: uuid.New().String(), - Config: *opts, - Tools: tools, - SystemPrompt: []string{"You are a helpful assistant. Be concise and clear in your responses."}, - } - err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) - if err != nil { - fmt.Printf("Google Gemini streaming error: %v\n", err) - } -} - -func testT1(ctx context.Context) { - tool := aiusechat.GetAdderToolDefinition() - tools := []uctypes.ToolDefinition{tool} - testAnthropic(ctx, DefaultAnthropicModel, "what is 2+2, use the provider adder tool", tools) -} - -func testT2(ctx context.Context) { - tool := aiusechat.GetAdderToolDefinition() - tools := []uctypes.ToolDefinition{tool} - testOpenAI(ctx, DefaultOpenAIModel, "what is 2+2+8, use the provider adder tool", tools) -} - -func testT3(ctx context.Context) { - testOpenAIComp(ctx, "gpt-4o", "what is 2+2? please be brief", nil) -} - -func testT4(ctx context.Context) { - tool := aiusechat.GetAdderToolDefinition() - tools := []uctypes.ToolDefinition{tool} - testGemini(ctx, DefaultGeminiModel, "what is 2+2+8, use the provider adder tool", tools) -} - -func printUsage() { - fmt.Println("Usage: go run main-testai.go [--anthropic|--openaicomp|--openrouter|--nanogpt|--gemini] [--tools] [--model ] [message]") - fmt.Println("Examples:") - fmt.Println(" go run main-testai.go 'What is 2+2?'") - fmt.Println(" go run main-testai.go --model o4-mini 'What is 2+2?'") - fmt.Println(" go run main-testai.go --anthropic 'What is 2+2?'") - fmt.Println(" go run main-testai.go --anthropic --model claude-3-5-sonnet-20241022 'What is 2+2?'") - fmt.Println(" go run main-testai.go --openaicomp --model gpt-4o 'What is 2+2?'") - fmt.Println(" go run main-testai.go --openrouter 'What is 2+2?'") - fmt.Println(" go run main-testai.go --openrouter --model anthropic/claude-3.5-sonnet 'What is 2+2?'") - fmt.Println(" go run main-testai.go --nanogpt 'What is 2+2?'") - fmt.Println(" go run main-testai.go --nanogpt --model gpt-4o 'What is 2+2?'") - fmt.Println(" go run main-testai.go --gemini 'What is 2+2?'") - fmt.Println(" go run main-testai.go --gemini --model gemini-1.5-pro 'What is 2+2?'") - fmt.Println(" go run main-testai.go --tools 'Help me configure GitHub Actions monitoring'") - fmt.Println("") - fmt.Println("Default models:") - fmt.Printf(" OpenAI: %s\n", DefaultOpenAIModel) - fmt.Printf(" Anthropic: %s\n", DefaultAnthropicModel) - fmt.Printf(" OpenAI Completions: gpt-4o\n") - fmt.Printf(" OpenRouter: %s\n", DefaultOpenRouterModel) - fmt.Printf(" NanoGPT: %s\n", DefaultNanoGPTModel) - fmt.Printf(" Google Gemini: %s\n", DefaultGeminiModel) - fmt.Println("") - fmt.Println("Environment variables:") - fmt.Println(" OPENAI_APIKEY (for OpenAI models)") - fmt.Println(" ANTHROPIC_APIKEY (for Anthropic models)") - fmt.Println(" OPENROUTER_APIKEY (for OpenRouter models)") - fmt.Println(" NANOGPT_KEY (for NanoGPT models)") - fmt.Println(" GOOGLE_APIKEY (for Google Gemini models)") -} - -func main() { - var anthropic, openaicomp, openrouter, nanogpt, gemini, tools, help, t1, t2, t3, t4 bool - var model string - flag.BoolVar(&anthropic, "anthropic", false, "Use Anthropic API instead of OpenAI") - flag.BoolVar(&openaicomp, "openaicomp", false, "Use OpenAI Completions API") - flag.BoolVar(&openrouter, "openrouter", false, "Use OpenRouter API") - flag.BoolVar(&nanogpt, "nanogpt", false, "Use NanoGPT API") - flag.BoolVar(&gemini, "gemini", false, "Use Google Gemini API") - flag.BoolVar(&tools, "tools", false, "Enable GitHub Actions Monitor tools for testing") - flag.StringVar(&model, "model", "", fmt.Sprintf("AI model to use (defaults: %s for OpenAI, %s for Anthropic, %s for OpenRouter, %s for NanoGPT, %s for Gemini)", DefaultOpenAIModel, DefaultAnthropicModel, DefaultOpenRouterModel, DefaultNanoGPTModel, DefaultGeminiModel)) - flag.BoolVar(&help, "help", false, "Show usage information") - flag.BoolVar(&t1, "t1", false, fmt.Sprintf("Run preset T1 test (%s with 'what is 2+2')", DefaultAnthropicModel)) - flag.BoolVar(&t2, "t2", false, fmt.Sprintf("Run preset T2 test (%s with 'what is 2+2')", DefaultOpenAIModel)) - flag.BoolVar(&t3, "t3", false, "Run preset T3 test (OpenAI Completions API with gpt-5.1)") - flag.BoolVar(&t4, "t4", false, "Run preset T4 test (OpenAI Completions API with gemini-3-pro-preview)") - flag.Parse() - - if help { - printUsage() - os.Exit(0) - } - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - if t1 { - testT1(ctx) - return - } - if t2 { - testT2(ctx) - return - } - if t3 { - testT3(ctx) - return - } - if t4 { - testT4(ctx) - return - } - - // Set default model based on API type if not provided - if model == "" { - if anthropic { - model = DefaultAnthropicModel - } else if openaicomp { - model = "gpt-4o" - } else if openrouter { - model = DefaultOpenRouterModel - } else if nanogpt { - model = DefaultNanoGPTModel - } else if gemini { - model = DefaultGeminiModel - } else { - model = DefaultOpenAIModel - } - } - - args := flag.Args() - message := "What is 2+2?" - if len(args) > 0 { - message = args[0] - } - - var toolDefs []uctypes.ToolDefinition - if tools { - toolDefs = getToolDefinitions() - } - - if anthropic { - testAnthropic(ctx, model, message, toolDefs) - } else if openaicomp { - testOpenAIComp(ctx, model, message, toolDefs) - } else if openrouter { - testOpenRouter(ctx, model, message, toolDefs) - } else if nanogpt { - testNanoGPT(ctx, model, message, toolDefs) - } else if gemini { - testGemini(ctx, model, message, toolDefs) - } else { - testOpenAI(ctx, model, message, toolDefs) - } -} diff --git a/cmd/testai/testschema.json b/cmd/testai/testschema.json deleted file mode 100644 index dc9de2b834..0000000000 --- a/cmd/testai/testschema.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "config": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Application configuration settings", - "properties": { - "maxWorkflowRuns": { - "description": "Maximum number of workflow runs to fetch", - "maximum": 100, - "minimum": 1, - "type": "integer" - }, - "pollInterval": { - "description": "Polling interval for GitHub API requests", - "maximum": 300, - "minimum": 1, - "type": "integer", - "units": "s" - }, - "repository": { - "description": "GitHub repository in owner/repo format", - "pattern": "^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$", - "type": "string" - }, - "workflow": { - "description": "GitHub Actions workflow file name", - "pattern": "^.+\\.(yml|yaml)$", - "type": "string" - } - }, - "title": "Application Configuration", - "type": "object" - }, - "data": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "definitions": { - "WorkflowRun": { - "properties": { - "conclusion": { - "type": "string" - }, - "created_at": { - "format": "date-time", - "type": "string" - }, - "html_url": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "run_number": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "updated_at": { - "format": "date-time", - "type": "string" - } - }, - "required": [ - "id", - "name", - "status", - "conclusion", - "created_at", - "updated_at", - "html_url", - "run_number" - ], - "type": "object" - } - }, - "description": "Application data schema", - "properties": { - "isLoading": { - "description": "Loading state for workflow data fetch", - "type": "boolean" - }, - "lastError": { - "description": "Last error message from GitHub API", - "type": "string" - }, - "lastRefreshTime": { - "description": "Timestamp of last successful data refresh", - "format": "date-time", - "type": "string" - }, - "workflowRuns": { - "description": "List of GitHub Actions workflow runs", - "items": { - "$ref": "#/definitions/WorkflowRun" - }, - "type": "array" - } - }, - "title": "Application Data", - "type": "object" - } -} diff --git a/cmd/testopenai/main-testopenai.go b/cmd/testopenai/main-testopenai.go deleted file mode 100644 index 7017407b47..0000000000 --- a/cmd/testopenai/main-testopenai.go +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "os" - "time" - - "github.com/wavetermdev/waveterm/pkg/aiusechat" - "github.com/wavetermdev/waveterm/pkg/aiusechat/openai" -) - -func makeOpenAIRequest(ctx context.Context, apiKey, model, message string, tools bool) error { - reqBody := openai.OpenAIRequest{ - Model: model, - Input: []any{ - openai.OpenAIMessage{ - Role: "user", - Content: []openai.OpenAIMessageContent{ - { - Type: "input_text", - Text: message, - }, - }, - }, - }, - Stream: true, - StreamOptions: &openai.StreamOptionsType{IncludeObfuscation: false}, - Reasoning: &openai.ReasoningType{Effort: "medium"}, - } - if tools { - reqBody.Tools = []openai.OpenAIRequestTool{ - openai.ConvertToolDefinitionToOpenAI(aiusechat.GetAdderToolDefinition()), - } - } - - jsonData, err := json.Marshal(reqBody) - if err != nil { - return fmt.Errorf("error marshaling request: %v", err) - } - - // Pretty print the request JSON for debugging - prettyJSON, err := json.MarshalIndent(reqBody, "", " ") - if err == nil { - fmt.Printf("Request JSON:\n%s\n", string(prettyJSON)) - } - - req, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/responses", bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("error creating request: %v", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+apiKey) - req.Header.Set("Accept", "text/event-stream") - - client := &http.Client{ - Timeout: 60 * time.Second, - } - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("error making request: %v", err) - } - defer resp.Body.Close() - - fmt.Printf("Response Status: %s\n", resp.Status) - fmt.Printf("Response Headers:\n") - for name, values := range resp.Header { - for _, value := range values { - fmt.Printf(" %s: %s\n", name, value) - } - } - fmt.Println("---") - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) - } - - return processSSEStream(resp.Body) -} - -func processSSEStream(reader io.Reader) error { - scanner := bufio.NewScanner(reader) - - fmt.Println("SSE Stream:") - fmt.Println("---") - - for scanner.Scan() { - line := scanner.Text() - fmt.Println(line) - } - - if err := scanner.Err(); err != nil { - return fmt.Errorf("error reading stream: %v", err) - } - - return nil -} - -func printUsage() { - fmt.Println("Usage: go run main-testopenai.go [--model ] [--tools] [message]") - fmt.Println("Examples:") - fmt.Println(" go run main-testopenai.go 'Stream me a limerick about gophers coding in Go.'") - fmt.Println(" go run main-testopenai.go --model gpt-4 'What is 2+2?'") - fmt.Println(" go run main-testopenai.go --tools 'What is 2+2? Use the adder tool.'") - fmt.Println("") - fmt.Println("Default model: gpt-5-mini") - fmt.Println("") - fmt.Println("Environment variables:") - fmt.Println(" OPENAI_APIKEY (required)") -} - -func main() { - var model string - var showHelp bool - var tools bool - - flag.StringVar(&model, "model", "gpt-5-mini", "OpenAI model to use") - flag.BoolVar(&showHelp, "help", false, "Show usage information") - flag.BoolVar(&tools, "tools", false, "Enable tools for testing") - flag.Parse() - - if showHelp { - printUsage() - os.Exit(0) - } - - apiKey := os.Getenv("OPENAI_APIKEY") - if apiKey == "" { - fmt.Println("Error: OPENAI_APIKEY environment variable not set") - printUsage() - os.Exit(1) - } - - args := flag.Args() - message := "Stream me a limerick about gophers coding in Go." - if len(args) > 0 { - message = args[0] - } - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - fmt.Printf("Testing OpenAI Responses API\n") - fmt.Printf("Model: %s\n", model) - fmt.Printf("Message: %s\n", message) - fmt.Println("===") - - if err := makeOpenAIRequest(ctx, apiKey, model, message, tools); err != nil { - fmt.Printf("Error: %v\n", err) - os.Exit(1) - } -} diff --git a/cmd/testsummarize/main-testsummarize.go b/cmd/testsummarize/main-testsummarize.go deleted file mode 100644 index fc16e59e04..0000000000 --- a/cmd/testsummarize/main-testsummarize.go +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "context" - "flag" - "fmt" - "os" - "time" - - "github.com/wavetermdev/waveterm/pkg/aiusechat/google" -) - -func printUsage() { - fmt.Println("Usage: go run main-testsummarize.go [--help] [--mode MODE] ") - fmt.Println("Examples:") - fmt.Println(" go run main-testsummarize.go README.md") - fmt.Println(" go run main-testsummarize.go --mode useful /path/to/image.png") - fmt.Println(" go run main-testsummarize.go -m publiccode document.pdf") - fmt.Println("") - fmt.Println("Supported file types:") - fmt.Println(" - Text files (up to 200KB)") - fmt.Println(" - Images (up to 7MB)") - fmt.Println(" - PDFs (up to 5MB)") - fmt.Println("") - fmt.Println("Flags:") - fmt.Println(" --mode, -m Summarization mode (default: quick)") - fmt.Println(" Options: quick, useful, publiccode, htmlcontent, htmlfull") - fmt.Println("") - fmt.Println("Environment variables:") - fmt.Println(" GOOGLE_APIKEY (required)") -} - -func main() { - var showHelp bool - var mode string - flag.BoolVar(&showHelp, "help", false, "Show usage information") - flag.StringVar(&mode, "mode", "quick", "Summarization mode") - flag.StringVar(&mode, "m", "quick", "Summarization mode (shorthand)") - flag.Parse() - - if showHelp { - printUsage() - os.Exit(0) - } - - apiKey := os.Getenv("GOOGLE_APIKEY") - if apiKey == "" { - fmt.Println("Error: GOOGLE_APIKEY environment variable not set") - printUsage() - os.Exit(1) - } - - args := flag.Args() - if len(args) == 0 { - fmt.Println("Error: filename required") - printUsage() - os.Exit(1) - } - - filename := args[0] - - // Check if file exists - if _, err := os.Stat(filename); os.IsNotExist(err) { - fmt.Printf("Error: file '%s' does not exist\n", filename) - os.Exit(1) - } - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - fmt.Printf("Summarizing file: %s\n", filename) - fmt.Printf("Model: %s\n", google.SummarizeModel) - fmt.Printf("Mode: %s\n", mode) - - startTime := time.Now() - summary, usage, err := google.SummarizeFile(ctx, filename, google.SummarizeOpts{ - APIKey: apiKey, - Mode: mode, - }) - latency := time.Since(startTime) - - fmt.Printf("Latency: %d ms\n", latency.Milliseconds()) - fmt.Println("===") - if err != nil { - fmt.Printf("Error: %v\n", err) - os.Exit(1) - } - - fmt.Println("\nSummary:") - fmt.Println("---") - fmt.Println(summary) - fmt.Println("---") - - if usage != nil { - fmt.Println("\nUsage Statistics:") - fmt.Printf(" Prompt tokens: %d\n", usage.PromptTokenCount) - fmt.Printf(" Cached tokens: %d\n", usage.CachedContentTokenCount) - fmt.Printf(" Response tokens: %d\n", usage.CandidatesTokenCount) - fmt.Printf(" Total tokens: %d\n", usage.TotalTokenCount) - } -} \ No newline at end of file diff --git a/cmd/wsh/cmd/wshcmd-ai.go b/cmd/wsh/cmd/wshcmd-ai.go deleted file mode 100644 index 643c80ee7a..0000000000 --- a/cmd/wsh/cmd/wshcmd-ai.go +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/util/fileutil" - "github.com/wavetermdev/waveterm/pkg/util/utilfn" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -var aiCmd = &cobra.Command{ - Use: "ai [options] [files...]", - Short: "Append content to Wave AI sidebar prompt", - Long: `Append content to Wave AI sidebar prompt (does not auto-submit by default) - -Arguments: - files... Files to attach (use '-' for stdin) - -Examples: - git diff | wsh ai - # Pipe diff to AI, ask question in UI - wsh ai main.go # Attach file, ask question in UI - wsh ai *.go -m "find bugs" # Attach files with message - wsh ai -s - -m "review" < log.txt # Stdin + message, auto-submit - wsh ai -n config.json # New chat with file attached`, - RunE: aiRun, - PreRunE: preRunSetupRpcClient, - DisableFlagsInUseLine: true, -} - -var aiMessageFlag string -var aiSubmitFlag bool -var aiNewBlockFlag bool - -func init() { - rootCmd.AddCommand(aiCmd) - aiCmd.Flags().StringVarP(&aiMessageFlag, "message", "m", "", "optional message/question to append after files") - aiCmd.Flags().BoolVarP(&aiSubmitFlag, "submit", "s", false, "submit the prompt immediately after appending") - aiCmd.Flags().BoolVarP(&aiNewBlockFlag, "new", "n", false, "create a new AI chat instead of using existing") -} - -func detectMimeType(data []byte) string { - mimeType := http.DetectContentType(data) - return strings.Split(mimeType, ";")[0] -} - -func getMaxFileSize(mimeType string) (int, string) { - if mimeType == "application/pdf" { - return 5 * 1024 * 1024, "5MB" - } - if strings.HasPrefix(mimeType, "image/") { - return 7 * 1024 * 1024, "7MB" - } - return 200 * 1024, "200KB" -} - -func aiRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("ai", rtnErr == nil) - }() - - if len(args) == 0 && aiMessageFlag == "" { - OutputHelpMessage(cmd) - return fmt.Errorf("no files or message provided") - } - - const maxFileCount = 15 - const rpcTimeout = 30000 - - var allFiles []wshrpc.AIAttachedFile - var stdinUsed bool - - if len(args) > maxFileCount { - return fmt.Errorf("too many files (maximum %d files allowed)", maxFileCount) - } - - for _, filePath := range args { - var data []byte - var fileName string - var mimeType string - var err error - - if filePath == "-" { - if stdinUsed { - return fmt.Errorf("stdin (-) can only be used once") - } - stdinUsed = true - - data, err = io.ReadAll(os.Stdin) - if err != nil { - return fmt.Errorf("reading from stdin: %w", err) - } - fileName = "stdin" - mimeType = "text/plain" - } else { - fileInfo, err := os.Stat(filePath) - if err != nil { - return fmt.Errorf("accessing file %s: %w", filePath, err) - } - absPath, err := filepath.Abs(filePath) - if err != nil { - return fmt.Errorf("getting absolute path for %s: %w", filePath, err) - } - - if fileInfo.IsDir() { - result, err := fileutil.ReadDir(filePath, 500) - if err != nil { - return fmt.Errorf("reading directory %s: %w", filePath, err) - } - jsonData, err := json.Marshal(result) - if err != nil { - return fmt.Errorf("marshaling directory listing for %s: %w", filePath, err) - } - data = jsonData - fileName = absPath - mimeType = "directory" - } else { - data, err = os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("reading file %s: %w", filePath, err) - } - fileName = absPath - mimeType = detectMimeType(data) - } - } - - isPDF := mimeType == "application/pdf" - isImage := strings.HasPrefix(mimeType, "image/") - isDirectory := mimeType == "directory" - - if !isPDF && !isImage && !isDirectory { - mimeType = "text/plain" - if utilfn.ContainsBinaryData(data) { - return fmt.Errorf("file %s contains binary data and cannot be uploaded as text", fileName) - } - } - - maxSize, sizeStr := getMaxFileSize(mimeType) - if len(data) > maxSize { - return fmt.Errorf("file %s exceeds maximum size of %s for %s files", fileName, sizeStr, mimeType) - } - - allFiles = append(allFiles, wshrpc.AIAttachedFile{ - Name: fileName, - Type: mimeType, - Size: len(data), - Data64: base64.StdEncoding.EncodeToString(data), - }) - } - - tabId := os.Getenv("WAVETERM_TABID") - if tabId == "" { - return fmt.Errorf("WAVETERM_TABID environment variable not set") - } - - route := wshutil.MakeTabRouteId(tabId) - - if aiNewBlockFlag { - newChatData := wshrpc.CommandWaveAIAddContextData{ - NewChat: true, - } - err := wshclient.WaveAIAddContextCommand(RpcClient, newChatData, &wshrpc.RpcOpts{ - Route: route, - Timeout: rpcTimeout, - }) - if err != nil { - return fmt.Errorf("creating new chat: %w", err) - } - } - - for _, file := range allFiles { - contextData := wshrpc.CommandWaveAIAddContextData{ - Files: []wshrpc.AIAttachedFile{file}, - } - err := wshclient.WaveAIAddContextCommand(RpcClient, contextData, &wshrpc.RpcOpts{ - Route: route, - Timeout: rpcTimeout, - }) - if err != nil { - return fmt.Errorf("adding file %s: %w", file.Name, err) - } - } - - if aiMessageFlag != "" || aiSubmitFlag { - finalContextData := wshrpc.CommandWaveAIAddContextData{ - Text: aiMessageFlag, - Submit: aiSubmitFlag, - } - err := wshclient.WaveAIAddContextCommand(RpcClient, finalContextData, &wshrpc.RpcOpts{ - Route: route, - Timeout: rpcTimeout, - }) - if err != nil { - return fmt.Errorf("adding context: %w", err) - } - } - - return nil -} diff --git a/cmd/wsh/cmd/wshcmd-badge.go b/cmd/wsh/cmd/wshcmd-badge.go index 590ed1e40b..f2b17a0f24 100644 --- a/cmd/wsh/cmd/wshcmd-badge.go +++ b/cmd/wsh/cmd/wshcmd-badge.go @@ -44,7 +44,6 @@ func init() { func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("badge", rtnErr == nil) }() if badgePid > 0 && runtime.GOOS == "windows" { diff --git a/cmd/wsh/cmd/wshcmd-blocks.go b/cmd/wsh/cmd/wshcmd-blocks.go index 7e4b935ee3..8c231ae9c3 100644 --- a/cmd/wsh/cmd/wshcmd-blocks.go +++ b/cmd/wsh/cmd/wshcmd-blocks.go @@ -31,7 +31,7 @@ type BlockDetails struct { BlockId string `json:"blockid"` // Unique identifier for the block WorkspaceId string `json:"workspaceid"` // ID of the workspace containing the block TabId string `json:"tabid"` // ID of the tab containing the block - View string `json:"view"` // Canonical view type (term, web, preview, edit, sysinfo, waveai) + View string `json:"view"` // Canonical view type (term, web, preview, edit, sysinfo) Meta waveobj.MetaMapType `json:"meta"` // Block metadata including view type } @@ -74,7 +74,7 @@ func init() { blocksListCmd.Flags().StringVar(&blocksWindowId, "window", "", "restrict to window id") blocksListCmd.Flags().StringVar(&blocksWorkspaceId, "workspace", "", "restrict to workspace id") blocksListCmd.Flags().StringVar(&blocksTabId, "tab", "", "restrict to specific tab id") - blocksListCmd.Flags().StringVar(&blocksView, "view", "", "restrict to view type (term/terminal, web/browser, preview/edit, sysinfo, waveai)") + blocksListCmd.Flags().StringVar(&blocksView, "view", "", "restrict to view type (term/terminal, web/browser, preview/edit, sysinfo)") blocksListCmd.Flags().BoolVar(&blocksJSON, "json", false, "output as JSON") blocksListCmd.Flags().IntVar(&blocksTimeout, "timeout", 5000, "timeout in milliseconds for RPC calls (default: 5000)") @@ -100,7 +100,7 @@ func init() { func blocksListRun(cmd *cobra.Command, args []string) error { if v := strings.TrimSpace(blocksView); v != "" { if !isKnownViewFilter(v) { - return fmt.Errorf("unknown --view %q; try one of: term, web, preview, edit, sysinfo, waveai", v) + return fmt.Errorf("unknown --view %q; try one of: term, web, preview, edit, sysinfo", v) } } @@ -270,8 +270,6 @@ func matchesViewType(actual, filter string) bool { return strings.EqualFold(actual, "term") case "web", "browser", "url": return strings.EqualFold(actual, "web") - case "ai", "waveai", "assistant": - return strings.EqualFold(actual, "waveai") case "sys", "sysinfo", "system": return strings.EqualFold(actual, "sysinfo") } @@ -285,8 +283,7 @@ func isKnownViewFilter(f string) bool { case "term", "terminal", "shell", "console", "web", "browser", "url", "preview", "edit", - "sysinfo", "sys", "system", - "waveai", "ai", "assistant": + "sysinfo", "sys", "system": return true default: return false diff --git a/cmd/wsh/cmd/wshcmd-debug.go b/cmd/wsh/cmd/wshcmd-debug.go index 9efac0ff87..29a1857689 100644 --- a/cmd/wsh/cmd/wshcmd-debug.go +++ b/cmd/wsh/cmd/wshcmd-debug.go @@ -24,24 +24,11 @@ var debugBlockIdsCmd = &cobra.Command{ Hidden: true, } -var debugSendTelemetryCmd = &cobra.Command{ - Use: "send-telemetry", - Short: "send telemetry", - RunE: debugSendTelemetryRun, - Hidden: true, -} - func init() { debugCmd.AddCommand(debugBlockIdsCmd) - debugCmd.AddCommand(debugSendTelemetryCmd) rootCmd.AddCommand(debugCmd) } -func debugSendTelemetryRun(cmd *cobra.Command, args []string) error { - err := wshclient.SendTelemetryCommand(RpcClient, nil) - return err -} - func debugBlockIdsRun(cmd *cobra.Command, args []string) error { oref, err := resolveBlockArg() if err != nil { diff --git a/cmd/wsh/cmd/wshcmd-debugterm.go b/cmd/wsh/cmd/wshcmd-debugterm.go index 66346c460a..dabea2540e 100644 --- a/cmd/wsh/cmd/wshcmd-debugterm.go +++ b/cmd/wsh/cmd/wshcmd-debugterm.go @@ -50,7 +50,6 @@ func init() { func debugTermRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("debugterm", rtnErr == nil) }() mode, err := getDebugTermMode() if err != nil { diff --git a/cmd/wsh/cmd/wshcmd-deleteblock.go b/cmd/wsh/cmd/wshcmd-deleteblock.go index 76518e721c..c6406f928e 100644 --- a/cmd/wsh/cmd/wshcmd-deleteblock.go +++ b/cmd/wsh/cmd/wshcmd-deleteblock.go @@ -24,7 +24,6 @@ func init() { func deleteBlockRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("deleteblock", rtnErr == nil) }() fullORef, err := resolveBlockArg() if err != nil { diff --git a/cmd/wsh/cmd/wshcmd-editconfig.go b/cmd/wsh/cmd/wshcmd-editconfig.go index cbd4015bae..cedba81ad0 100644 --- a/cmd/wsh/cmd/wshcmd-editconfig.go +++ b/cmd/wsh/cmd/wshcmd-editconfig.go @@ -30,7 +30,6 @@ func init() { func editConfigRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("editconfig", rtnErr == nil) }() configFile := "settings.json" // default diff --git a/cmd/wsh/cmd/wshcmd-editor.go b/cmd/wsh/cmd/wshcmd-editor.go index 4968b17509..9459dcc065 100644 --- a/cmd/wsh/cmd/wshcmd-editor.go +++ b/cmd/wsh/cmd/wshcmd-editor.go @@ -32,7 +32,6 @@ func init() { func editorRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("editor", rtnErr == nil) }() if len(args) == 0 { OutputHelpMessage(cmd) diff --git a/cmd/wsh/cmd/wshcmd-file.go b/cmd/wsh/cmd/wshcmd-file.go index e40eb324d2..7c668ef2dd 100644 --- a/cmd/wsh/cmd/wshcmd-file.go +++ b/cmd/wsh/cmd/wshcmd-file.go @@ -90,7 +90,7 @@ var fileListCmd = &cobra.Command{ Short: "list files", Long: "List files in a directory. By default, lists files in the current directory." + UriHelpText, Example: " wsh file ls wsh://user@ec2/home/user/", - RunE: activityWrap("file", fileListRun), + RunE: fileListRun, PreRunE: preRunSetupRpcClient, } @@ -100,7 +100,7 @@ var fileCatCmd = &cobra.Command{ Long: "Display the contents of a file." + UriHelpText, Example: " wsh file cat wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileCatRun), + RunE: fileCatRun, PreRunE: preRunSetupRpcClient, } @@ -110,7 +110,7 @@ var fileInfoCmd = &cobra.Command{ Long: "Show information about a file." + UriHelpText, Example: " wsh file info wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileInfoRun), + RunE: fileInfoRun, PreRunE: preRunSetupRpcClient, } @@ -120,7 +120,7 @@ var fileRmCmd = &cobra.Command{ Long: "Remove a file." + UriHelpText, Example: " wsh file rm wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileRmRun), + RunE: fileRmRun, PreRunE: preRunSetupRpcClient, } @@ -130,7 +130,7 @@ var fileWriteCmd = &cobra.Command{ Long: "Write stdin into a file, buffering input (10MB total file size limit)." + UriHelpText, Example: " echo 'hello' | wsh file write ./greeting.txt", Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileWriteRun), + RunE: fileWriteRun, PreRunE: preRunSetupRpcClient, } @@ -140,7 +140,7 @@ var fileAppendCmd = &cobra.Command{ Long: "Append stdin to a file, buffering input (10MB total file size limit)." + UriHelpText, Example: " tail -f log.txt | wsh file append ./app.log", Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileAppendRun), + RunE: fileAppendRun, PreRunE: preRunSetupRpcClient, } @@ -151,7 +151,7 @@ var fileCpCmd = &cobra.Command{ Long: "Copy files between different storage systems." + UriHelpText, Example: " wsh file cp wsh://user@ec2/home/user/config.txt ./local-config.txt\n wsh file cp ./local-config.txt wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(2), - RunE: activityWrap("file", fileCpRun), + RunE: fileCpRun, PreRunE: preRunSetupRpcClient, } @@ -162,7 +162,7 @@ var fileMvCmd = &cobra.Command{ Long: "Move files between different storage systems. The source file will be deleted once the operation completes successfully." + UriHelpText, Example: " wsh file mv wsh://user@ec2/home/user/config.txt ./local-config.txt\n wsh file mv ./local-config.txt wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(2), - RunE: activityWrap("file", fileMvRun), + RunE: fileMvRun, PreRunE: preRunSetupRpcClient, } diff --git a/cmd/wsh/cmd/wshcmd-focusblock.go b/cmd/wsh/cmd/wshcmd-focusblock.go index 3f6603a3e2..ff5f224d9d 100644 --- a/cmd/wsh/cmd/wshcmd-focusblock.go +++ b/cmd/wsh/cmd/wshcmd-focusblock.go @@ -26,7 +26,6 @@ func init() { func focusBlockRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("focusblock", rtnErr == nil) }() tabId := os.Getenv("WAVETERM_TABID") diff --git a/cmd/wsh/cmd/wshcmd-getmeta.go b/cmd/wsh/cmd/wshcmd-getmeta.go index f5e1e40f67..b713bbef4f 100644 --- a/cmd/wsh/cmd/wshcmd-getmeta.go +++ b/cmd/wsh/cmd/wshcmd-getmeta.go @@ -74,7 +74,6 @@ func filterMetaKeys(meta map[string]interface{}, keys []string) map[string]inter func getMetaRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("getmeta", rtnErr == nil) }() fullORef, err := resolveBlockArg() if err != nil { diff --git a/cmd/wsh/cmd/wshcmd-getvar.go b/cmd/wsh/cmd/wshcmd-getvar.go index 9391c4f5f2..1a42be43a3 100644 --- a/cmd/wsh/cmd/wshcmd-getvar.go +++ b/cmd/wsh/cmd/wshcmd-getvar.go @@ -53,7 +53,6 @@ func shouldPrintNewline() bool { func getVarRun(cmd *cobra.Command, args []string) error { defer func() { - sendActivity("getvar", WshExitCode == 0) }() // Resolve block to get zoneId diff --git a/cmd/wsh/cmd/wshcmd-launch.go b/cmd/wsh/cmd/wshcmd-launch.go index 3ec582a6cd..d7d29385e1 100644 --- a/cmd/wsh/cmd/wshcmd-launch.go +++ b/cmd/wsh/cmd/wshcmd-launch.go @@ -28,7 +28,6 @@ func init() { func launchRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("launch", rtnErr == nil) }() widgetId := args[0] diff --git a/cmd/wsh/cmd/wshcmd-notify.go b/cmd/wsh/cmd/wshcmd-notify.go index de2086e1f7..a73f2b9551 100644 --- a/cmd/wsh/cmd/wshcmd-notify.go +++ b/cmd/wsh/cmd/wshcmd-notify.go @@ -31,7 +31,6 @@ func init() { func notifyRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("notify", rtnErr == nil) }() message := args[0] notificationOptions := &wshrpc.WaveNotificationOptions{ diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go index 9534d2e5f5..a477f4c5a4 100644 --- a/cmd/wsh/cmd/wshcmd-root.go +++ b/cmd/wsh/cmd/wshcmd-root.go @@ -103,15 +103,6 @@ func getIsTty() bool { type RunEFnType = func(*cobra.Command, []string) error -func activityWrap(activityStr string, origRunE RunEFnType) RunEFnType { - return func(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity(activityStr, rtnErr == nil) - }() - return origRunE(cmd, args) - } -} - func resolveBlockArg() (*waveobj.ORef, error) { oref := blockArg if oref == "" { @@ -213,23 +204,6 @@ func getTabIdFromEnv() string { return os.Getenv("WAVETERM_TABID") } -// this will send wsh activity to the client running on *your* local machine (it does not contact any wave cloud infrastructure) -// if you've turned off telemetry in your local client, this data never gets sent to us -// no parameters or timestamps are sent, as you can see below, it just sends the name of the command (and if there was an error) -// (e.g. "wsh ai ..." would send "ai") -// this helps us understand which commands are actually being used so we know where to concentrate our effort -func sendActivity(wshCmdName string, success bool) { - if RpcClient == nil || wshCmdName == "" { - return - } - dataMap := make(map[string]int) - dataMap[wshCmdName] = 1 - if !success { - dataMap[wshCmdName+"#"+"error"] = 1 - } - wshclient.WshActivityCommand(RpcClient, dataMap, nil) -} - // Execute executes the root command. func Execute() { defer func() { diff --git a/cmd/wsh/cmd/wshcmd-run.go b/cmd/wsh/cmd/wshcmd-run.go index 6faf424c99..dffa24f515 100644 --- a/cmd/wsh/cmd/wshcmd-run.go +++ b/cmd/wsh/cmd/wshcmd-run.go @@ -40,7 +40,6 @@ func init() { func runRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("run", rtnErr == nil) }() flags := cmd.Flags() diff --git a/cmd/wsh/cmd/wshcmd-secret.go b/cmd/wsh/cmd/wshcmd-secret.go index 916e3ae4a5..cbb45c45a9 100644 --- a/cmd/wsh/cmd/wshcmd-secret.go +++ b/cmd/wsh/cmd/wshcmd-secret.go @@ -77,7 +77,6 @@ func init() { func secretGetRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("secret", rtnErr == nil) }() name := args[0] @@ -101,7 +100,6 @@ func secretGetRun(cmd *cobra.Command, args []string) (rtnErr error) { func secretSetRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("secret", rtnErr == nil) }() parts := strings.SplitN(args[0], "=", 2) @@ -137,7 +135,6 @@ func secretSetRun(cmd *cobra.Command, args []string) (rtnErr error) { func secretListRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("secret", rtnErr == nil) }() names, err := wshclient.GetSecretsNamesCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) @@ -153,7 +150,6 @@ func secretListRun(cmd *cobra.Command, args []string) (rtnErr error) { func secretDeleteRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("secret", rtnErr == nil) }() name := args[0] @@ -173,7 +169,6 @@ func secretDeleteRun(cmd *cobra.Command, args []string) (rtnErr error) { func secretUiRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("secret", rtnErr == nil) }() tabId := getTabIdFromEnv() diff --git a/cmd/wsh/cmd/wshcmd-setbg.go b/cmd/wsh/cmd/wshcmd-setbg.go index 4385409187..68215a8c17 100644 --- a/cmd/wsh/cmd/wshcmd-setbg.go +++ b/cmd/wsh/cmd/wshcmd-setbg.go @@ -90,7 +90,6 @@ func validateColor(color string) error { func setBgRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("setbg", rtnErr == nil) }() borderColorChanged := cmd.Flags().Changed("border-color") diff --git a/cmd/wsh/cmd/wshcmd-setconfig.go b/cmd/wsh/cmd/wshcmd-setconfig.go index 3fcd1f94b2..6ce1b5297c 100644 --- a/cmd/wsh/cmd/wshcmd-setconfig.go +++ b/cmd/wsh/cmd/wshcmd-setconfig.go @@ -25,7 +25,6 @@ func init() { func setConfigRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("setconfig", rtnErr == nil) }() metaSetsStrs := args[:] diff --git a/cmd/wsh/cmd/wshcmd-setmeta.go b/cmd/wsh/cmd/wshcmd-setmeta.go index 79faa7e78c..2f2524e46f 100644 --- a/cmd/wsh/cmd/wshcmd-setmeta.go +++ b/cmd/wsh/cmd/wshcmd-setmeta.go @@ -158,7 +158,6 @@ func simpleMergeMeta(meta map[string]interface{}, metaUpdate map[string]interfac func setMetaRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("setmeta", rtnErr == nil) }() var jsonMeta map[string]interface{} if setMetaJsonFilePath != "" { diff --git a/cmd/wsh/cmd/wshcmd-setvar.go b/cmd/wsh/cmd/wshcmd-setvar.go index bbfb3e15a1..ea0918fee5 100644 --- a/cmd/wsh/cmd/wshcmd-setvar.go +++ b/cmd/wsh/cmd/wshcmd-setvar.go @@ -58,7 +58,6 @@ func parseKeyValue(arg string) (key, value string, err error) { func setVarRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("setvar", rtnErr == nil) }() // Resolve block to get zoneId diff --git a/cmd/wsh/cmd/wshcmd-ssh.go b/cmd/wsh/cmd/wshcmd-ssh.go index 4eb1d42a4e..cdcdff319a 100644 --- a/cmd/wsh/cmd/wshcmd-ssh.go +++ b/cmd/wsh/cmd/wshcmd-ssh.go @@ -39,7 +39,6 @@ func init() { func sshRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("ssh", rtnErr == nil) }() sshArg := args[0] diff --git a/cmd/wsh/cmd/wshcmd-tabindicator.go b/cmd/wsh/cmd/wshcmd-tabindicator.go index c3fa499cf9..56d892b1c3 100644 --- a/cmd/wsh/cmd/wshcmd-tabindicator.go +++ b/cmd/wsh/cmd/wshcmd-tabindicator.go @@ -43,7 +43,6 @@ func init() { func tabIndicatorRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("tabindicator", rtnErr == nil) }() fmt.Fprintf(os.Stderr, "tabindicator is deprecated, use 'wsh badge' instead\n") diff --git a/cmd/wsh/cmd/wshcmd-term.go b/cmd/wsh/cmd/wshcmd-term.go index f2119ad5b7..6a5f83ab2a 100644 --- a/cmd/wsh/cmd/wshcmd-term.go +++ b/cmd/wsh/cmd/wshcmd-term.go @@ -32,7 +32,6 @@ func init() { func termRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("term", rtnErr == nil) }() var cwd string diff --git a/cmd/wsh/cmd/wshcmd-termscrollback.go b/cmd/wsh/cmd/wshcmd-termscrollback.go index 6368e1559d..995a07eb0a 100644 --- a/cmd/wsh/cmd/wshcmd-termscrollback.go +++ b/cmd/wsh/cmd/wshcmd-termscrollback.go @@ -45,7 +45,6 @@ func init() { func termScrollbackRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("termscrollback", rtnErr == nil) }() // Resolve the block argument diff --git a/cmd/wsh/cmd/wshcmd-version.go b/cmd/wsh/cmd/wshcmd-version.go index 80caab9f69..6156ae1e35 100644 --- a/cmd/wsh/cmd/wshcmd-version.go +++ b/cmd/wsh/cmd/wshcmd-version.go @@ -11,7 +11,6 @@ import ( "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshutil" ) var versionVerbose bool @@ -46,19 +45,13 @@ func runVersionCmd(cmd *cobra.Command, args []string) error { return err } - updateChannel, err := wshclient.GetUpdateChannelCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000, Route: wshutil.ElectronRoute}) - if err != nil { - return err - } - if versionJSON { info := map[string]interface{}{ - "version": resp.Version, - "clientid": resp.ClientId, - "buildtime": resp.BuildTime, - "configdir": resp.ConfigDir, - "datadir": resp.DataDir, - "updatechannel": updateChannel, + "version": resp.Version, + "clientid": resp.ClientId, + "buildtime": resp.BuildTime, + "configdir": resp.ConfigDir, + "datadir": resp.DataDir, } outBArr, err := json.MarshalIndent(info, "", " ") if err != nil { @@ -73,6 +66,5 @@ func runVersionCmd(cmd *cobra.Command, args []string) error { fmt.Printf("clientid: %s\n", resp.ClientId) fmt.Printf("configdir: %s\n", resp.ConfigDir) fmt.Printf("datadir: %s\n", resp.DataDir) - fmt.Printf("update-channel: %s\n", updateChannel) return nil } diff --git a/cmd/wsh/cmd/wshcmd-view.go b/cmd/wsh/cmd/wshcmd-view.go index 1ba84b516f..2347cea407 100644 --- a/cmd/wsh/cmd/wshcmd-view.go +++ b/cmd/wsh/cmd/wshcmd-view.go @@ -43,7 +43,6 @@ func init() { func viewRun(cmd *cobra.Command, args []string) (rtnErr error) { cmdName := cmd.Name() defer func() { - sendActivity(cmdName, rtnErr == nil) }() if len(args) == 0 { OutputHelpMessage(cmd) diff --git a/cmd/wsh/cmd/wshcmd-wavepath.go b/cmd/wsh/cmd/wshcmd-wavepath.go index 9a5ad6af39..bdb54d4367 100644 --- a/cmd/wsh/cmd/wshcmd-wavepath.go +++ b/cmd/wsh/cmd/wshcmd-wavepath.go @@ -30,7 +30,6 @@ func init() { func wavepathRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("wavepath", rtnErr == nil) }() if len(args) == 0 { diff --git a/cmd/wsh/cmd/wshcmd-web.go b/cmd/wsh/cmd/wshcmd-web.go index bfda76b82c..3ffdc93d13 100644 --- a/cmd/wsh/cmd/wshcmd-web.go +++ b/cmd/wsh/cmd/wshcmd-web.go @@ -97,7 +97,6 @@ func webGetRun(cmd *cobra.Command, args []string) error { func webOpenRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("web", rtnErr == nil) }() var replaceBlockORef *waveobj.ORef diff --git a/cmd/wsh/cmd/wshcmd-wsl.go b/cmd/wsh/cmd/wshcmd-wsl.go index cfe9cd47d8..ee67f7a945 100644 --- a/cmd/wsh/cmd/wshcmd-wsl.go +++ b/cmd/wsh/cmd/wshcmd-wsl.go @@ -30,7 +30,6 @@ func init() { func wslRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { - sendActivity("wsl", rtnErr == nil) }() var err error diff --git a/db/migrations-wstore/000012_drop_telemetry.down.sql b/db/migrations-wstore/000012_drop_telemetry.down.sql new file mode 100644 index 0000000000..bcdcbcff58 --- /dev/null +++ b/db/migrations-wstore/000012_drop_telemetry.down.sql @@ -0,0 +1 @@ +-- Recreating these tables is not necessary; the data is obsolete diff --git a/db/migrations-wstore/000012_drop_telemetry.up.sql b/db/migrations-wstore/000012_drop_telemetry.up.sql new file mode 100644 index 0000000000..2650b693e0 --- /dev/null +++ b/db/migrations-wstore/000012_drop_telemetry.up.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS db_tevent; +DROP TABLE IF EXISTS db_activity; diff --git a/docs/docs/ai-presets.mdx b/docs/docs/ai-presets.mdx deleted file mode 100644 index 6321dae3ad..0000000000 --- a/docs/docs/ai-presets.mdx +++ /dev/null @@ -1,253 +0,0 @@ ---- -sidebar_position: 3.6 -id: "ai-presets" -title: "AI Presets (Deprecated)" ---- -:::warning Deprecation Notice -The AI Widget and its presets are being replaced by [Wave AI](./waveai.mdx). Please refer to the Wave AI documentation for the latest AI features and configuration options. -::: - - -![AI Presets Menu](./img/ai-presets.png#right) - -Wave's AI widget can be configured to work with various AI providers and models through presets. Presets allow you to define multiple AI configurations and easily switch between them using the dropdown menu in the AI widget. - -## How AI Presets Work - -AI presets are defined in `~/.config/waveterm/presets/ai.json`. You can easily edit this file using: - -```bash -wsh editconfig presets/ai.json -``` - -Each preset defines a complete set of configuration values for the AI widget. When you select a preset from the dropdown menu, those configuration values are applied to the widget. If no preset is selected, the widget uses the default values from `settings.json`. - -Here's a basic example using Claude: - -```json -{ - "ai@claude-sonnet": { - "display:name": "Claude 3 Sonnet", - "display:order": 1, - "ai:*": true, - "ai:apitype": "anthropic", - "ai:model": "claude-3-5-sonnet-latest", - "ai:apitoken": "" - } -} -``` - -To make a preset your default, add this single line to your `settings.json`: - -```json -{ - "ai:preset": "ai@claude-sonnet" -} -``` - -:::info -You can quickly set your default preset using the `setconfig` command: - -```bash -wsh setconfig ai:preset=ai@claude-sonnet -``` - -This is easier than editing settings.json directly! -::: - -## Provider-Specific Configurations - -### Anthropic (Claude) - -To use Claude models, create a preset like this: - -```json -{ - "ai@claude-sonnet": { - "display:name": "Claude 3 Sonnet", - "display:order": 1, - "ai:*": true, - "ai:apitype": "anthropic", - "ai:model": "claude-3-5-sonnet-latest", - "ai:apitoken": "" - } -} -``` - -### OpenAI - -To use OpenAI's models: - -```json -{ - "ai@openai-gpt41": { - "display:name": "GPT-4.1", - "display:order": 2, - "ai:*": true, - "ai:model": "gpt-4.1", - "ai:apitoken": "" - } -} -``` - -### Local LLMs (Ollama) - -To connect to a local Ollama instance: - -```json -{ - "ai@ollama-llama": { - "display:name": "Ollama - Llama2", - "display:order": 3, - "ai:*": true, - "ai:baseurl": "http://localhost:11434/v1", - "ai:name": "llama2", - "ai:model": "llama2", - "ai:apitoken": "ollama" - } -} -``` - -Note: The `ai:apitoken` is required but can be any value as Ollama ignores it. See [Ollama OpenAI compatibility docs](https://github.com/ollama/ollama/blob/main/docs/openai.md) for more details. - -### Azure OpenAI - -To connect to Azure AI services: - -```json -{ - "ai@azure-gpt4": { - "display:name": "Azure GPT-4", - "display:order": 4, - "ai:*": true, - "ai:apitype": "azure", - "ai:baseurl": "", - "ai:model": "", - "ai:apitoken": "" - } -} -``` - -Note: Do not include query parameters or `api-version` in the `ai:baseurl`. The `ai:model` should be your model deployment name in Azure. - -### Perplexity - -To use Perplexity's models: - -```json -{ - "ai@perplexity-sonar": { - "display:name": "Perplexity Sonar", - "display:order": 5, - "ai:*": true, - "ai:apitype": "perplexity", - "ai:model": "llama-3.1-sonar-small-128k-online", - "ai:apitoken": "" - } -} -``` - -### Google (Gemini) - -To use Google's Gemini models from [Google AI Studio](https://aistudio.google.com): - -```json -{ - "ai@gemini-2.0": { - "display:name": "Gemini 2.0", - "display:order": 6, - "ai:*": true, - "ai:apitype": "google", - "ai:model": "gemini-2.0-flash-exp", - "ai:apitoken": "" - } -} -``` - -### OpenRouter - -To use OpenRouter's models: - -```json -{ - "ai@openrouter": { - "display:name": "OpenRouter (Qwen)", - "display:order": 7, - "ai:*": true, - "ai:model": "qwen/qwen3-next-80b-a3b-thinking", - "ai:apitoken": "", - "ai:baseurl": "https://openrouter.ai/api/v1" - } -} -``` - -## Multiple Presets Example - -You can define multiple presets in your `ai.json` file: - -```json -{ - "ai@claude-sonnet": { - "display:name": "Claude 3 Sonnet", - "display:order": 1, - "ai:*": true, - "ai:apitype": "anthropic", - "ai:model": "claude-3-5-sonnet-latest", - "ai:apitoken": "" - }, - "ai@openai-gpt41": { - "display:name": "GPT-4.1", - "display:order": 2, - "ai:*": true, - "ai:model": "gpt-4.1", - "ai:apitoken": "" - }, - "ai@ollama-llama": { - "display:name": "Ollama - Llama2", - "display:order": 3, - "ai:*": true, - "ai:baseurl": "http://localhost:11434/v1", - "ai:name": "llama2", - "ai:model": "llama2", - "ai:apitoken": "ollama" - }, - "ai@perplexity-sonar": { - "display:name": "Perplexity Sonar", - "display:order": 4, - "ai:*": true, - "ai:apitype": "perplexity", - "ai:model": "llama-3.1-sonar-small-128k-online", - "ai:apitoken": "" - } -} -``` - -The `display:order` value determines the order in which presets appear in the dropdown menu. - -Remember to set your default preset in `settings.json`: - -```json -{ - "ai:preset": "ai@claude-sonnet" -} -``` - -## Using a Proxy - -If you need to route AI requests through an HTTP proxy, you can add the `ai:proxyurl` setting to any preset: - -```json -{ - "ai@claude-with-proxy": { - "display:name": "Claude 3 Sonnet (via Proxy)", - "display:order": 1, - "ai:*": true, - "ai:apitype": "anthropic", - "ai:model": "claude-3-5-sonnet-latest", - "ai:apitoken": "", - "ai:proxyurl": "http://proxy.example.com:8080" - } -} -``` - -The proxy URL should be in the format `http://host:port` or `https://host:port`. This setting works with all AI providers except Wave Cloud AI (the default). diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 05389e99ef..63020fb229 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -40,22 +40,10 @@ wsh editconfig | app:showoverlayblocknums | bool | Set to false to disable the Ctrl+Shift block number overlay that appears when holding Ctrl+Shift (defaults to true) | | app:ctrlvpaste | bool | On Windows/Linux, when null (default) uses Control+V on Windows only. Set to true to force Control+V on all non-macOS platforms, false to disable the accelerator. macOS always uses Command+V regardless of this setting | | app:confirmquit | bool | Set to false to disable the quit confirmation dialog when closing Wave Terminal (defaults to true, requires app restart) | -| app:hideaibutton | bool | Set to true to hide the AI button in the tab bar (defaults to false) | | app:disablectrlshiftarrows | bool | Set to true to disable Ctrl+Shift block-navigation keybindings (`Arrow` and `h/j/k/l`) (defaults to false) | | app:disablectrlshiftdisplay | bool | Set to true to disable the Ctrl+Shift visual indicator display (defaults to false) | | app:focusfollowscursor | string | Controls whether block focus follows cursor movement: `"off"` (default), `"on"` (all blocks), or `"term"` (terminal blocks only) | | app:tabbar | string | Controls the position of the tab bar: `"top"` (default) for a horizontal tab bar at the top of the window, or `"left"` for a vertical tab bar on the left side of the window | -| ai:preset | string | the default AI preset to use | -| ai:baseurl | string | Set the AI Base Url (must be OpenAI compatible) | -| ai:apitoken | string | your AI api token | -| ai:apitype | string | defaults to "open_ai", but can also set to "azure" (forspecial Azure AI handling), "anthropic", or "perplexity" | -| ai:name | string | string to display in the Wave AI block header | -| ai:model | string | model name to pass to API | -| ai:apiversion | string | for Azure AI only (when apitype is "azure", this will default to "2023-05-15") | -| ai:orgid | string | | -| ai:maxtokens | int | max tokens to pass to API | -| ai:timeoutms | int | timeout (in milliseconds) for AI calls | -| ai:proxyurl | string | HTTP proxy URL for AI API requests (does not apply to Wave Cloud AI) | | conn:askbeforewshinstall | bool | set to false to disable popup asking if you want to install wsh extensions on new machines | | conn:localhostdisplayname | string | override the display name for localhost in the UI (e.g., set to "My Laptop" or "Local", or set to empty string to hide the name) | | term:fontsize | float | the fontsize for the terminal block | @@ -115,19 +103,13 @@ wsh editconfig | window:savelastwindow | bool | when `true`, the last window that is closed is preserved and is reopened the next time the app is launched (defaults to `true`) | | window:confirmonclose | bool | when `true`, a prompt will ask a user to confirm that they want to close a window if it has an unsaved workspace with more than one tab (defaults to `true`) | | window:dimensions | string | set the default dimensions for new windows using the format "WIDTHxHEIGHT" (e.g. "1920x1080"). when a new window is created, these dimensions will be automatically applied. The width and height values should be specified in pixels. | -| telemetry:enabled | bool | set to enable/disable telemetry | For reference, this is the current default configuration (v0.14.0): ```json { - "ai:preset": "ai@global", - "ai:model": "gpt-5-mini", - "ai:maxtokens": 4000, - "ai:timeoutms": 60000, "app:defaultnewblock": "term", "app:confirmquit": true, - "app:hideaibutton": false, "app:disablectrlshiftarrows": false, "app:disablectrlshiftdisplay": false, "app:focusfollowscursor": "off", @@ -148,17 +130,13 @@ For reference, this is the current default configuration (v0.14.0): "window:fullscreenonlaunch": false, "window:magnifiedblockblursecondarypx": 2, "window:confirmclose": true, - "window:savelastwindow": true, - "telemetry:enabled": true, - "term:bellsound": false, + "window:savelastwindow": true, "term:bellsound": false, "term:bellindicator": false, "term:osc52": "always", "term:cursor": "block", "term:cursorblink": false, "term:copyonselect": true, "term:durable": false, - "waveai:showcloudmodes": true, - "waveai:defaultmode": "waveai@balanced", "preview:defaultsort": "name" } ``` @@ -173,12 +151,12 @@ files as well: `termthemes.json`, `presets.json`, and `widgets.json`. ## Environment Variable Resolution -To avoid putting secrets directly in config files, Wave supports environment variable resolution using `$ENV:VARIABLE_NAME` or `$ENV:VARIABLE_NAME:fallback` syntax. This works for any string value in any config file (settings.json, presets.json, ai.json, etc.). +To avoid putting secrets directly in config files, Wave supports environment variable resolution using `$ENV:VARIABLE_NAME` or `$ENV:VARIABLE_NAME:fallback` syntax. This works for any string value in any config file (settings.json, presets.json, etc.). ```json { - "ai:apitoken": "$ENV:OPENAI_APIKEY", - "ai:baseurl": "$ENV:AI_BASEURL:https://api.openai.com/v1" + "conn:sshpassword": "$ENV:SSH_PASSWORD", + "web:defaulturl": "$ENV:HOME_PAGE:https://example.com" } ``` diff --git a/docs/docs/connections.mdx b/docs/docs/connections.mdx index b5b050da6a..24085f865b 100644 --- a/docs/docs/connections.mdx +++ b/docs/docs/connections.mdx @@ -105,6 +105,8 @@ At the moment, we are capable of parsing any SSH config file that does not conta |ProxyJump| Specifies one or more jump proxies in a comma separated list. Each will be visited sequentially using TCP forwarding before connecting to the desired connection (also using TCP forwarding). It can be set to `none` to disable the feature.| |UserKnownHostsFile| Provides the location of one or more user host key database files for recording trusted remote connections. The filenames are entered in the same string and separated by whitespace. The default value is `"~/.ssh/known_hosts ~/.ssh/known_hosts2"`.| |GlobalKnownHostsFile| Provides the location of one or more global host key database files for recording trusted remote connections. The filenames are entered in the same string and separated by whitespace. The default value is `"/etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2"`.| +|LocalForward| Can be specified multiple times. Format: `bind_address destination` (e.g., `8080 localhost:80` or `127.0.0.1:8080 localhost:80`). Listens on the local machine and forwards connections through the SSH tunnel to the remote destination.| +|RemoteForward| Can be specified multiple times. Format: `bind_address destination` (e.g., `9090 localhost:3000`). Listens on the remote machine and forwards connections back to the local destination. Requires `AllowTcpForwarding` on the remote sshd.| ### Example SSH Config Host @@ -130,6 +132,8 @@ In addition to the regular ssh config file, wave also has its own config file to | conn:wshpath | A string indicating the path to the `wsh` executable on the connection. It defaults to `"~/.waveterm/bin/wsh"`.| | conn:shellpath | A string indicating the path to the shell executable on the connection. If not set, the output of `$SHELL` on the connection will be used.| | conn:ignoresshconfig | This boolean allows wave to ignore the `~/.ssh/config` file for resolving keywords for this connection. The regular defaults will be used, but all changes to those must be specified in the `connections.json` file instead. This defaults to false.| +| conn:stallautodisconnect | This boolean automatically disconnects SSH connections when they stall (keepalive timeout) for longer than the threshold. Once disconnected, the existing auto-reconnect machinery will attempt to restore the connection and any durable sessions. It defaults to `true`. See [Connection Resilience](/reconnect) for details on how detection and reconnection work.| +| conn:stalldisconnectthreshold | This int (seconds) controls how long a connection must be stalled before auto-disconnect triggers. It defaults to `30`. See [Connection Resilience](/reconnect) for details. | | display:hidden | This boolean hides the connection from the dropdown list. It defaults to `false` | | display:order | This float determines the order of connections in the connection dropdown. It defaults to `0`.| | term:fontsize | This int can be used to override the terminal font size for blocks using this connection. The block metadata takes priority over this setting. It defaults to null which means the global setting will be used instead. | @@ -158,6 +162,8 @@ In addition to the regular ssh config file, wave also has its own config file to | ssh:proxyjump | A list of strings specifying the names of hosts that must be successively visited with tcp forwarding to establish a connection. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| | ssh:userknownhostsfile | A list containing the paths of any user host key database files used to keep track of authorized connections. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| | ssh:globalknownhostsfile | A list containing the paths of any global host key database files used to keep track of authorized connections. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| +| ssh:localforward | A list of strings for local port forwarding rules. Format: `"8080 localhost:80"`. Can be used to override or supplement `~/.ssh/config` values.| +| ssh:remoteforward | A list of strings for remote port forwarding rules. Format: `"9090 localhost:3000"`. Can be used to override or supplement `~/.ssh/config` values.| ### SSH Agent Detection @@ -252,6 +258,31 @@ While Wave provides an option disable `wsh` when first connecting to a remote, t Note that this same line gets added to your `connections.json` file automatically when you choose to disable `wsh` in gui when initially connecting. +### Port Forwarding + +Port forwarding rules from `~/.ssh/config` are automatically applied when you connect through Wave: + +``` +Host myserver + User username + HostName 203.0.113.254 + LocalForward 8080 localhost:80 + RemoteForward 9090 localhost:3000 +``` + +Connecting to `myserver` will listen on local port 8080 (forwarded to the remote's localhost:80) and listen on the remote's port 9090 (forwarded to your local localhost:3000). + +Port forwarding can also be defined entirely in `connections.json`: + +```json +{ + "myusername@myserver" : { + "ssh:localforward": ["8080 localhost:80"], + "ssh:remoteforward": ["9090 localhost:3000"] + } +} +``` + ## Managing Connections with the CLI The `wsh` command gives some commands specifically for interacting with the connections. You can view these [here](/wsh-reference#conn). diff --git a/docs/docs/durable-sessions.mdx b/docs/docs/durable-sessions.mdx index fa112ba07d..fa66ac0ab2 100644 --- a/docs/docs/durable-sessions.mdx +++ b/docs/docs/durable-sessions.mdx @@ -122,7 +122,7 @@ Converting between standard and durable modes requires restarting the shell. Any Your terminal is connected to the remote session. You can interact with the shell and see real-time output. ### Detached -Connection lost, but the session continues running on the remote server. Wave will automatically reconnect when possible. Any commands you ran continue executing. +Connection lost, but the session continues running on the remote server. Wave will automatically reconnect when possible (see [Connection Resilience](/reconnect) for details on detection and retry timing). Any commands you ran continue executing. ### Awaiting Start Session configured for durability but not yet started. Click "Start Session" or run a command to begin. @@ -152,7 +152,7 @@ Start a build, deployment, or data processing job and close your laptop. The com ``` ### Unstable Networks -Work from a café, train, or cellular connection. Brief disconnections won't terminate your session or lose your work. +Work from a café, train, or cellular connection. Brief disconnections won't terminate your session or lose your work. Wave monitors connections with keepalive probes every 3 seconds and automatically attempts reconnection — see [Connection Resilience](/reconnect) for the full detection and retry strategy. ### Multiple Locations Start work on your desktop, continue on your laptop. Your session and its state are preserved on the remote server. diff --git a/docs/docs/faq.mdx b/docs/docs/faq.mdx index 61dc80beb4..69dc81cb7f 100644 --- a/docs/docs/faq.mdx +++ b/docs/docs/faq.mdx @@ -56,15 +56,3 @@ If you've installed via Snap, you can use the following command: ```sh sudo snap install waveterm --classic --beta ``` - -## Can I use Wave AI without enabling telemetry? - - - -Yes! Wave AI is normally disabled when telemetry is not enabled. However, you can enable Wave AI features without telemetry by configuring your own custom AI model (either a local model or using your own API key). - -To enable Wave AI without telemetry: -1. Configure a custom AI mode (see [Wave AI documentation](./waveai-modes)) -2. Set `waveai:defaultmode` to your custom mode's key in your Wave settings - -Once you've completed both steps, Wave AI will be enabled and you can use it completely privately without telemetry. This allows you to use local models like Ollama or your own API keys with providers like OpenAI, OpenRouter, or others. diff --git a/docs/docs/gettingstarted.mdx b/docs/docs/gettingstarted.mdx index 7ff961a9a9..5775055ef7 100644 --- a/docs/docs/gettingstarted.mdx +++ b/docs/docs/gettingstarted.mdx @@ -7,7 +7,7 @@ title: "Getting Started" import { PlatformProvider, PlatformSelectorButton, PlatformItem } from "@site/src/components/platformcontext"; import { Kbd } from "@site/src/components/kbd"; -Wave Terminal is a modern terminal that includes graphical capabilities like web browsing, file previews, and AI assistance alongside traditional terminal features. This guide will help you get started. +Wave Terminal is a modern terminal that includes graphical capabilities like web browsing and file previews alongside traditional terminal features. This guide will help you get started. ## Installation @@ -106,7 +106,6 @@ You can also download installers directly from our [Downloads page](https://www. - Preview files (images, video, markdown, code with syntax highlighting) - Browse web pages - - Ask questions and get AI help directly from the terminal (set up multiple AI models) - Basic system monitoring graphs 3. **Remote Connections** @@ -131,8 +130,6 @@ You can also download installers directly from our [Downloads page](https://www. # Open a webpage wsh web open github.com - # Get AI assistance - wsh ai -m "how do I find large files in my current directory?" -s ``` 3. **Customize Your Layout** @@ -152,7 +149,6 @@ You can also download installers directly from our [Downloads page](https://www. - Explore [Key Bindings](./keybindings) to work more efficiently - Learn about [Tab Layouts](./layout) to organize your workspace - Set up [Custom Widgets](./customwidgets) for quick access to your tools -- Configure [Wave AI](./waveai) to use your preferred AI models - Check out [Configuration](./config) for detailed customization options ## Getting Help diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index f1665faae8..b92b270a5e 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -10,21 +10,15 @@ import { Card, CardGroup } from "@site/src/components/card"; # Welcome to Wave Terminal -Wave is an [open-source](https://github.com/wavetermdev/waveterm) terminal that combines traditional terminal features with graphical capabilities like file previews, web browsing, and AI assistance. It runs on MacOS, Linux, and Windows. +Wave is an [open-source](https://github.com/wavetermdev/waveterm) terminal that combines traditional terminal features with graphical capabilities like file previews and web browsing. It runs on MacOS, Linux, and Windows. -Modern development involves constantly switching between terminals and browsers - checking documentation, previewing files, monitoring systems, and using AI tools. Wave brings these graphical tools directly into the terminal, letting you control them from the command line. This means you can stay in your terminal workflow while still having access to the visual interfaces you need. +Modern development involves constantly switching between terminals and browsers - checking documentation, previewing files, and monitoring systems. Wave brings these graphical tools directly into the terminal, letting you control them from the command line. This means you can stay in your terminal workflow while still having access to the visual interfaces you need. Check out [Getting Started](./gettingstarted) for installation instructions. ![Wave Screenshot](./img/wave-screenshot.webp) - | Open a new tab | | | Open a new block (defaults to a terminal block with the same connection and working directory). Switch to launcher using `app:defaultnewblock` setting | -| | Toggle WaveAI panel visibility | | | Split horizontally, open a new block to the right | | | Split vertically, open a new block below | | | Split vertically, open a new block above | @@ -41,7 +40,6 @@ Chords are shown with a + between the keys. You have 2 seconds to hit the 2nd ch | | Open the "connection" switcher | | | Refocus the current block (useful if the block has lost input focus) | | | Show block numbers | -| | Focus WaveAI input | | | Switch to block number | | / | Move left, right, up, down between blocks | | | Replace the current block with a launcher block | @@ -81,14 +79,6 @@ Chords are shown with a + between the keys. You have 2 seconds to hit the 2nd ch | | Find in webpage | | | Open a bookmark | -## WaveAI Keybindings - -| Key | Function | -| ----------------------- | ----------------------- | -| | Toggle WaveAI panel | -| | Focus WaveAI input | -| | Clear AI Chat | - ## Terminal Keybindings | Key | Function | diff --git a/docs/docs/reconnect.mdx b/docs/docs/reconnect.mdx new file mode 100644 index 0000000000..de482f754d --- /dev/null +++ b/docs/docs/reconnect.mdx @@ -0,0 +1,225 @@ +--- +sidebar_position: 3.2 +id: "reconnect" +title: "Connection Resilience" +--- + +# Connection Resilience + +Wave monitors every remote SSH connection and automatically detects disconnections, stalls, and network changes. When a connection drops, Wave attempts to reconnect so your durable sessions survive network interruptions, Wi-Fi switches, and computer sleep. + +## Overview + +The reconnect system has three layers: + +1. **Monitor** — detects connection problems by sending periodic keepalive probes +2. **Scheduler** — attempts reconnection with configurable timing +3. **Events** — triggers that start, stop, or restart the reconnect process + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Monitor │ +│ (keepalive probes, stall detection, auto-disconnect) │ +└──────────────────────────┬──────────────────────────────────┘ + │ disconnect detected + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Scheduler │ +│ (retry attempts, aggressive mode, max duration) │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + Wi-Fi switch System resume Manual reconnect + (automatic) (automatic) (user-initiated) +``` + +## Connection Monitor + +The monitor runs as a background goroutine for each active SSH connection. It periodically checks whether the connection is alive by sending SSH keepalive probes. + +### How Detection Works + +Every **3 seconds**, the monitor checks when the last SSH activity occurred. If more than **3 seconds** have passed with no activity, it sends a keepalive probe (`keepalive@openssh.com`). + +If the keepalive probe receives a response, the connection is confirmed alive. If no response arrives within **3 seconds**, the connection is declared **stalled**. + +If the stall persists for **5 seconds** (default), the monitor forces a disconnect and triggers the reconnect scheduler. + +### Timing Summary + +| Parameter | Value | Description | +|-----------|-------|-------------| +| Ticker interval | 3s | How often the monitor checks | +| Keepalive threshold | 3s | Inactivity before sending a keepalive probe | +| Keepalive threshold (urgent) | 1s | Inactivity before keepalive when user is actively typing | +| Stall threshold | 3s | Time after keepalive with no response before declaring stalled | +| Stall threshold (urgent) | 2s | Stall threshold when user is actively typing | +| Auto-disconnect threshold | 5s | How long a stall persists before forced disconnect | + +### Urgent Mode + +When the user types in a terminal, the monitor switches to **urgent mode** for 10 seconds. In urgent mode, keepalive probes and stall detection use shorter thresholds (1s and 2s respectively) to detect problems faster. This ensures that if a connection drops while the user is actively typing, the problem is detected within seconds rather than waiting for the normal 3s thresholds. + +### Auto-Disconnect + +When a stall persists beyond the auto-disconnect threshold (default 5s), the monitor forces the connection to disconnect. This triggers the reconnect scheduler. Without auto-disconnect, a zombie connection (TCP link is dead but the OS hasn't timed out) could block reconnection for 30+ seconds waiting for the OS-level TCP timeout. + +Auto-disconnect can be disabled per connection by setting `conn:stallautodisconnect` to `false` in `connections.json`. + +## Reconnect Scheduler + +When a connection drops, the reconnect scheduler periodically attempts to re-establish it. The scheduler only runs for connections that have **running durable jobs** — if there are no active terminal sessions, there's nothing to reconnect. + +### Timing + +| Parameter | Value | Description | +|-----------|-------|-------------| +| First attempt | Immediate | No delay before the first reconnect attempt | +| Connect timeout | 5s | How long each attempt waits for the SSH handshake | +| Normal interval | 5s | Time between attempts in normal mode | +| Aggressive interval | 3s | Time between attempts in aggressive mode | +| Aggressive duration | 2min | How long aggressive mode lasts before reverting to normal | +| Max duration | 5min | Total scheduler lifetime before giving up | + +### Aggressive Mode + +When a reconnect attempt fails with a network-unreachable error (e.g., "no route to host", "network is unreachable", "context deadline exceeded"), the scheduler switches to **aggressive mode**. In aggressive mode, attempts happen every 3 seconds instead of every 5 seconds, catching the moment the network returns more quickly. + +Aggressive mode lasts for 2 minutes. Each time a network-unreachable error occurs, the 2-minute window resets. After 2 minutes of no network errors, the scheduler returns to normal mode. + +### Scheduler Lifecycle + +The scheduler runs for a maximum of **5 minutes**. After 5 minutes, it stops entirely. The connection remains disconnected until a new event triggers reconnection (e.g., system resume, manual reconnect). + +``` +0s ── scheduler starts, attempt 1 (immediate) → fails +0s ── aggressive mode ON (3s interval) +3s ── attempt 2 → fails +6s ── attempt 3 → fails +... +120s ── aggressive window expires → normal mode (5s interval) +125s ── attempt → fails +130s ── attempt → fails +... +300s ── 5 minute max → scheduler stops +``` + +### Known Limitation: Scheduler Does Not Restart + +After the scheduler stops (5 minutes), the connection stays disconnected indefinitely until a new event triggers reconnection. If the network is down for longer than 5 minutes (e.g., server maintenance, extended outage), the user must manually reconnect or wait for a system resume event. + +## Events That Trigger Reconnection + +### Automatic Events + +| Event | Source | What Happens | +|-------|--------|-------------| +| Connection drops | Monitor detects stall | Scheduler starts for connections with durable jobs | +| System resume | macOS sleep/wake | Forces disconnect + immediate reconnect attempt | +| Route down | Network route lost | Job-level auto-reconnect (1s delay, 30s cooldown) | + +### User-Initiated Events + +| Event | Source | What Happens | +|-------|--------|-------------| +| Click Reconnect button | UI overlay | Immediate `AttemptReconnect` call | +| Focus terminal tab | UI interaction | Connection status checked, reconnect if needed | +| `wsh ssh` command | CLI | Establishes or reconnects the connection | + +### Event Flow + +``` +Connection drops + │ + ▼ +handleConnChangeEvent (wps.Event_ConnChange) + │ + ├── Connected=false → onConnectionDown + │ │ + │ ├── needsInteractiveAuth? → skip (user must reconnect manually) + │ │ + │ └── start scheduleConnectionReconnect + │ │ + │ ├── attempt reconnect (5s timeout) + │ │ │ + │ │ ├── success → stop + │ │ └── failure → retry after interval + │ │ + │ └── max duration (5min) → stop + │ + └── Connected=true → onConnectionUp + │ + └── reconnect all running durable jobs for this connection +``` + +## Interactive Authentication Handling + +Some SSH connections require password or keyboard-interactive authentication. The reconnect scheduler **cannot** handle these automatically because it cannot type a password for the user. + +### Detection + +Before starting the scheduler, Wave checks if the connection might need interactive authentication by examining the SSH config: + +- `ssh:passwordauthentication` — enables password auth (default: true) +- `ssh:kbdinteractiveauthentication` — enables keyboard-interactive auth (default: true) +- `ssh:batchmode` — disables all interactive prompts (default: false) +- `ssh:passwordsecretname` — stored password in secret store (no prompt needed) +- `ssh:preferredauthentications` — ordered list of auth methods to try + +If interactive auth is possible and no stored password exists, the scheduler is **skipped entirely**. The user must reconnect manually (which will prompt for the password). + +### Stored Passwords + +If `ssh:passwordsecretname` is set, the password is retrieved from the secret store automatically. No user prompt is needed, and the scheduler runs normally. + +## Configuration + +### Existing Config Fields + +These fields can be set per connection in `connections.json`: + +| Field | Default | Description | +|-------|---------|-------------| +| `conn:stallautodisconnect` | `true` | Enable auto-disconnect on stall | +| `conn:stalldisconnectthreshold` | `5` | Seconds of stall before forced disconnect | + +### Example + +```json +{ + "connections": { + "user@high-latency-server": { + "conn:stallautodisconnect": true, + "conn:stalldisconnectthreshold": 30 + } + } +} +``` + +:::note +All other reconnect thresholds (keepalive interval, stall threshold, reconnect interval, etc.) are currently hardcoded. Issue [#19](https://github.com/whoisjeremylam/waveterm-remote/issues/19) will make them configurable per connection. +::: + +## Connection Health States + +| State | Description | +|-------|-------------| +| `good` | Connection is healthy, keepalive responses received | +| `stalled` | Keepalive sent, no response received within threshold | + +The `stalled` state indicates the connection may be dead. If stall persists beyond the auto-disconnect threshold, the connection is forced to disconnect. + +## Troubleshooting + +### Connection takes 30+ seconds to reconnect + +Check if the connection uses password authentication without a stored secret. If `ssh:passwordauthentication` is true and `ssh:passwordsecretname` is not set, the scheduler is skipped. Store the password in the secret store or set `ssh:batchmode` to true. + +### Connection disconnects too aggressively + +If the remote server has high latency (satellite, international), the 5-second auto-disconnect threshold may be too short. Increase `conn:stalldisconnectthreshold` in `connections.json`. + +### Scheduler stops after 5 minutes + +This is the current max duration. After 5 minutes, the scheduler stops and the connection stays disconnected until manually reconnected or until a system resume event occurs. This limitation will be addressed in a future update. \ No newline at end of file diff --git a/docs/docs/releasenotes.mdx b/docs/docs/releasenotes.mdx index 987be81534..e3ccce8211 100644 --- a/docs/docs/releasenotes.mdx +++ b/docs/docs/releasenotes.mdx @@ -6,6 +6,15 @@ sidebar_position: 200 # Release Notes +### v0.14.5-fork — Jun 4, 2026 + +**SSH Port Forwarding:** + +- **`LocalForward` and `RemoteForward` support** — Port forwarding rules from `~/.ssh/config` are now automatically applied when connecting through Wave. Rules can also be defined in `connections.json` via `ssh:localforward` and `ssh:remoteforward` keys. +- `LocalForward` listens on the local machine and forwards connections through the SSH tunnel to the remote destination. +- `RemoteForward` listens on the remote machine and forwards connections back to the local destination (requires `AllowTcpForwarding` on the remote sshd). +- Malformed rules are logged and skipped without breaking the connection. + ### v0.14.5 — Apr 16, 2026 Wave v0.14.5 introduces a new Process Viewer widget, Quake Mode for global hotkey, and several quality-of-life improvements. @@ -40,8 +49,7 @@ Wave v0.14.4 introduces vertical tabs, upgrades to xterm.js v6, and includes a c - **Config Errors Moved** - Config errors removed from the tab bar and moved to Settings / WaveConfig view for less clutter - **Warn on Unsaved Changes** - WaveConfig view now warns before discarding unsaved changes - **Stream Performance** - Migrated file streaming to new modern interface with flow control, fixing a large time-to-first-byte streaming bug -- **macOS First Click** - Improved first-click handling on macOS (cancel the click but properly set block/WaveAI focus) -- Deprecated legacy AI widget has been removed +- **macOS First Click** - Improved first-click handling on macOS (cancel the click but properly set block focus) - [bugfix] Fixed focus bug for newly created blocks - [bugfix] Fixed an issue around starting a new durable session by splitting an old one - Electron upgraded to v41 @@ -65,8 +73,6 @@ Wave v0.14.2 adds block/tab badges, directory preview improvements, and assorted - [bugfix] Fixed "Save Session As..." (focused window tracking bug) - [bugfix] Zoom change notifications were not being properly sent to all tabs (layout inconsistencies) - Added a Release Notes link in the settings menu -- Working on anthropic-messages Wave AI backend (for native Claude integration) -- Lots of internal work on testing/mock infrastructure to enable quicker async AI edits - Documention updates - Package updates and dependency upgrades @@ -89,7 +95,6 @@ Wave v0.14.1 fixes several high-impact terminal bugs (Claude Code scrolling, IME - **Tab Close Confirmation** - New `tab:confirmclose` setting to prompt before closing a tab - **Workspace-Scoped Widgets** - New optional `workspaces` field in `widgets.json` to show/hide widgets per-workspace - **Vim-Style Block Navigation** - Added Ctrl+Shift+H/J/K/L to navigate between blocks -- **New AI Providers** - Added Groq and NanoGPT as built-in AI provider presets **Other Changes:** @@ -121,20 +126,12 @@ Wave v0.14 introduces Durable Sessions for SSH connections, allowing your remote - **Enhanced Context Menu** - Right-click terminals for quick access to splits, URL opening, themes, file browser, and more - **Streamlined Header Layout** - Terminal headers now focus on connection info without redundant view type labels -**Wave AI Updates:** -- **Image/Vision Support** - Added image support for OpenAI chat completions API, enabling vision capabilities with compatible models -- **Stop Generation** - New ability to stop AI responses mid-generation across OpenAI and Gemini backends -- **AI Panel Scroll Latch** - Improved auto-scrolling behavior in Wave AI panel -- **Configurable Verbosity** - Control verbosity levels for OpenAI Responses API -- Deprecated old AI-widget proxy endpoint - **RPC and Performance:** - **RPC Streaming with Flow Control** - New streaming primitives with built-in flow control for better performance and reliability - **WSH Router Refactor** - Major routing architecture improvements to prevent hangs on connection interruptions - **RPC Client/Server Cleanup** - Improved RPC implementation and error handling **Configuration Updates:** -- **Hide AI Button** - New `app:hideaibutton` setting to hide the AI button from the UI - **Disable Ctrl+Shift Arrows** - New `app:disablectrlshiftarrows` setting for keyboard shortcut conflicts - **Disable Ctrl+Shift Display** - New `app:disablectrlshiftdisplay` setting to disable overlay block numbers @@ -149,550 +146,11 @@ Wave v0.14 introduces Durable Sessions for SSH connections, allowing your remote - Removed OSC 23198 and OSC 9283 legacy handlers - Updated contribution guidelines - Upgraded Go toolchain to 1.25.6 -- Enhanced OpenAI-compatible API provider documentation - [bugfix] Fixed empty data handling in sysinfo view - [bugfix] Fixed `app:ctrlvpaste` setting on Windows (can now be disabled) -- [bugfix] Fixed duplicated Wave AI system prompt for some providers - [bugfix] Fixed disconnect hanging issue - disconnects now happen immediately - [bugfix] Fixed tool approval lifecycle to match SSE connection timing - [bugfix] Increased WSL connection timeout to handle slow initial WSL startup - [bugfix] Improved terminal shutdown with SIGHUP for graceful shell exit - Package updates and dependency upgrades -### v0.13.1 — Dec 16, 2025 - -**Windows Improvements and Wave AI Enhancements** - -This release focuses on significant Windows platform improvements, Wave AI visual updates, and better flexibility for local AI usage. - -**Windows Platform Enhancements:** -- **Integrated Window Layout** - Removed separate title bar and menu bar on Windows, integrating controls directly into the tab-bar header for a cleaner, more unified interface -- **Git Bash Auto-Detection** - Wave now automatically detects Git Bash installations and adds them to the connection dropdown for easy access -- **SSH Agent Fallback** - Improved SSH agent support with automatic fallback to `\\.\pipe\openssh-ssh-agent` on Windows -- **Updated Focus Keybinding** - Wave AI focus key changed to Alt:0 on Windows for better consistency -- **Config Schemas** - Improved configuration validation and schema support -- Ctrl-V now works as standard paste in terminal on Windows - -**Wave AI Updates:** -- **Refreshed Visual Design** - Complete UI refresh removing blue accents and adding transparency support for better integration with custom backgrounds -- **BYOK Without Telemetry** - Wave AI now works with bring-your-own-key and local models without requiring telemetry to be enabled -- [bugfix] Fixed tool type "function" compatibility with providers like Mistral - -**Terminal Improvements:** -- **New Scrolling Keybindings** - Added Shift+Home, Shift+End, Shift+PageUp, and Shift+PageDown for better terminal navigation - -**Other Changes:** -- Package updates and dependency upgrades - -### v0.13.0 — Dec 8, 2025 - -**Wave v0.13 Brings Local AI Support, BYOK, and Unified Configuration** - -Wave v0.13 is a major release that opens up Wave AI to local models, third-party providers, and bring-your-own-key (BYOK) configurations. This release also includes a completely redesigned configuration system and several terminal improvements. - -**Local AI & BYOK Support:** -- **OpenAI-Compatible API** - Wave now supports any provider or local server using the `/v1/chat/completions` endpoint, enabling use of Ollama, LM Studio, vLLM, OpenRouter, and countless other local and hosted models -- **Google Gemini Integration** - Native support for Google's Gemini models with a dedicated API adapter -- **Provider Presets** - Simplified configuration with built-in presets for OpenAI, OpenRouter, Google, Azure, and custom endpoints -- **Multiple AI Modes** - Easily switch between different models and providers with a unified interface -- See the new [Wave AI Modes documentation](https://docs.waveterm.dev/waveai-modes) for configuration examples and setup guides - -**Unified Configuration Widget:** -- **New Config Interface** - Replaced the basic JSON editor with a dedicated configuration widget accessible from the sidebar -- **Better Organization** - Browse and edit different configuration types (general settings, AI modes, secrets) with improved validation and error handling -- **Integrated Secrets Management** - Access Wave's secret store directly from the config widget for secure credential management - -**Terminal Improvements:** -- **Bracketed Paste Mode** - Now enabled by default to improve multi-line paste behavior and compatibility with tools like Claude Code -- **Windows Paste Fix** - Ctrl+V now works as a standard paste accelerator on Windows -- **SSH Password Management** - Store SSH connection passwords in Wave's secret store to avoid re-typing credentials - -**Other Changes:** -- Package updates and dependency upgrades -- Various bug fixes and stability improvements - -### v0.12.5 — Nov 24, 2025 - -Quick patch release to fix paste behavior on Linux (prevent raw HTML from getting pasted to the terminal). - -### v0.12.4 — Nov 21, 2025 - -Quick patch release with bug fixes and minor improvements. - -- New `term:macoptionismeta` setting for macOS to treat Option key as Meta key in terminal -- Fixed directory tracking for zsh shells -- Fixed editor copy operations -- Minor Wave AI improvements (image handling, scrolling, focus) -- Package updates and dependency upgrades -- WIP: WaveApps builder framework (not yet released) - -### v0.12.3 — Nov 17, 2025 - -Patch release with Wave AI model upgrade, new secret management features, and improved terminal input handling. - -**Wave AI Updates:** -- **GPT-5.1 Model** - Upgraded to use OpenAI's GPT-5.1 model for improved responses -- **Thinking Mode Toggle** - New dropdown to select between Quick, Balanced, and Deep thinking modes for optimal response quality vs speed -- [bugfix] Fixed path mismatch issue when restoring AI write file backups - -**New Features:** -- **Secret Store** - New secret management widget for storing and managing sensitive credentials. Access secrets via CLI with `wsh secret list/get/set` commands - -**Terminal Improvements:** -- **Enhanced Input Handling** - Better support for interactive CLI tools like Claude Code. Shift+Enter now inserts newlines by default for multi-line commands -- **Image Paste Support** - Paste images directly into terminal (saved to temp files with path inserted). Works great in Claude Code! -- **IME Fix** - Fixed duplicate text issue when switching input methods during Chinese/Japanese/Korean composition - -**Other Changes:** -- Improved backend panic tracking for better debugging -- Fixed memory leak around sysinfo events -- WIP: New WaveApps builder framework (not yet released) -- Package updates and dependency bumps - -### v0.12.2 — Nov 4, 2025 - -Wave v0.12.2 adds file editing ability to Wave AI. Before approving a file edit you can easily see a diff (rendered in the Monaco Editor diff viewer), and after approving an edit you can easily roll back the change using a "Revert File" button. - -**Wave AI Updates:** -- **File Write Tool** - Wave AI can now create and modify files with your approval -- **Visual Diff Preview** - See exactly what will change before approving edits, rendered in Monaco Editor -- **Easy Rollback** - Revert file changes with a simple "Revert File" button -- **Drag & Drop Files** - Drag files from the preview viewer directly to Wave AI -- **Directory Listings** - `wsh ai` can now attach directory listings to chats -- **Adjustable Settings** - Control thinking level and max output tokens per chat - -**Bug Fixes & Improvements:** -- Fixed a significant memory leak in the RPC system -- Schema validation working again for config files -- Improved tool descriptions and input validations (run before tool approvals) -- Fixed issue with premature tool timeouts -- Fixed regression with PowerShell 5.x -- Fixed prompt caching issue when attaching files - -### v0.12.1 — Oct 20, 2025 - -Patch release focused on shell integration improvements and Wave AI enhancements. This release fixes syntax highlighting in the code editor and adds significant shell context tracking capabilities. - -**Shell Integration & Context:** -- **OSC 7 Support** - Added OSC 7 (current working directory) support across bash, zsh, fish, and pwsh shells. Wave now automatically tracks and restores your current directory across restarts for both local and remote terminals. -- **Shell Context Tracking** - Implemented shell integration for bash, zsh, and fish shells. Wave now tracks when your shell is ready to receive commands, the last command executed, and exit codes. This enhanced context enables better terminal management and lays the groundwork for Wave AI to write and execute commands intelligently. - -**Wave AI Improvements:** -- Display reasoning summaries in the UI while waiting for AI responses -- Added enhanced terminal context - Wave AI now has access to shell state including current directory, command history, and exit codes -- Added feedback buttons (thumbs up/down) for AI responses to help improve the experience -- Added copy button to easily copy AI responses to clipboard - -**Other Changes:** -- Mobile user agent emulation support for web widgets [#2442](https://github.com/wavetermdev/waveterm/issues/2442) -- [bugfix] Fixed padding for header buttons in code editor (Tailwind regression) -- [bugfix] Restored syntax highlighting in code editor preview blocks [#2427](https://github.com/wavetermdev/waveterm/issues/2427) -- Package updates and dependency bumps - -### v0.12.0 — Oct 16, 2025 - -**Wave v0.12 Has Arrived with Wave AI (beta)!** - -Wave Terminal v0.12.0 introduces a completely redesigned AI experience powered by OpenAI GPT-5. This represents a major upgrade and modernization over Wave's previous AI integration, bringing multi-modal support, advanced tool integration, and an intuitive new interface. The main AI PR alone included 128 commits and added 13,000+ lines of code. - -**Wave AI Features:** -- **New Slide-Out Chat Panel** - Access Wave AI via hotkeys (Cmd-Shift-A or Ctrl-Shift-0) from the left side of your screen -- **Multi-Modal Input** - Support for images, PDFs, and text file attachments -- **Drag & Drop Files** - Simply drag files into the chat to attach them -- **Command-Line Integration** - Send files and command output directly to Wave AI using `wsh ai` -- **Smart Context Switching** - Enable Wave AI to see into your widgets and file system -- **Built-in Tools:** - - Web search capabilities - - Local file and directory operations - - Widget screenshots - - Terminal scrollback access - - Web navigation - -Wave AI is in active beta with included AI credits while we refine the experience. BYOK (Bring Your Own Key) will be available once we've stabilized core features and gathered feedback on what works best. Share your feedback in our [Discord](https://discord.gg/XfvZ334gwU). - -For more information and upcoming features, visit our [Wave AI documentation](https://docs.waveterm.dev/waveai). - -**Other Improvements:** -- New onboarding flow showcasing block magnification, Wave AI, and wsh view/edit capabilities -- New `wsh blocks list` command for listing and filtering blocks by workspace, tab, or view type -- Continued migration from SCSS to Tailwind v4 -- Package upgrades and dependency updates -- Internal code cleanup and refactoring - -### v0.11.6 — Sep 22, 2025 - -Patch release to address an editor bug when you open two files in separate edit widgets. Also adds Mermaid support to markdown blocks. - -* WIP: Big AI overhaul coming (multi-modal support, premium models, and tool support) -* WIP: Integrating new Tsunami widget framework to make writing and running Wave widgets easier -* Lots of package updates -* Much internal cleanup (preview widget) -* More migration to Tailwind v4 CSS -* Build updates, switched to npm from yarn - -### v0.11.5 — Aug 28, 2025 - -Another housekeeping release to modernize Wave and bring it more up to date. - -* Wave AI Cloud Proxy now uses gpt-5-mini (upgraded from gpt-4o-mini) -* Fixed JWT issue with running "Wave Apps" from widgets -* Added an "$ENV:envvar:fallback" syntax to the config files to allow Wave's config to pick up values from the environment (mostly to allow moving secrets out of the config files) -* New setting to disable showing overlay blocknums when holding Ctrl:Shift (`app:showoverlayblocknums`) -* New setting to allow Shift-Enter to work with tools like Claude Code (`term:shiftenternewline`) -* Upgraded frontend to React 19 -* Migrated more of the frontend to Tailwind v4 (work in progress) -* Removed Universal MacOS build. 90% of Mac users are now on Apple Silicon, so universal build is less important (has a larger file size, and complicates the build process). -* [bugfix] Removed build-ids in RPM build to try to fix conflicts with Slack -* Removed some Wave v7 aware upgrades and old code paths -* Internal cleanup, TypeScript errors, linting fixes, etc. -* Other assorted Go/npm package bumps - -### v0.11.4 — Aug 19, 2025 - -Quick patch release to update packages, fix some security issues (with dependent packages), and some small bug fixes. - -* Update AI Libraries, GPT-5 now supported in WaveAI -* Added `ai:proxyurl` setting to allow proxy access (e.g. SOCKS) for AI access - -### v0.11.3 — May 2, 2025 - -Quick patch release to update packages, fix some security issues (with dependent packages), and some small bug fixes. - -### v0.11.2 — March 8, 2025 - -Quick patch release to fix a backend panic, and revert a change that caused WSL connections to hang. - -### v0.11.1 — Feb 28, 2025 - -Wave Terminal v0.11.1 adds a lot of new functionality over v0.11.0 (it could have almost been a v0.12)! - -The headline feature is our files/preview widget now supports browsing S3 buckets. We read credential information directly from your ~/.aws/config, and you can now easily select any of your AWS profiles in our connections drop down to start viewing S3 files. We even support editing S3 text files using our built-in editor. - -Lots of other features and bug fixes as well: - -- **S3 Bucket** directory viewing and file previews -- **Drag and Drop Files and Directories** between Wave directory views. This works across machines and between remote machines and S3 conections. -- Added json-schema support for some of our config files. You'll now get auto-complete popups for fields in our settings.json, widgets.json, ai.json, and connections.json file. -- New block splitting support -- Use Cmd-D and Cmd-Shift-D to split horizontally and vertically. For more control you can use Ctrl-Shift-S and then Up/Down/Left/Right to split in the given direction. -- Delete block (without removing it from the layout). You can use Ctrl-Shift-D to remove a block, while keeping it in the layout. you can then launch a new widget in its place. -- `wsh file` now supports copying files between your local machine, remote machines, and to/from S3 -- New analytics framework (event based as opposed to counter based). See Telemetry Docs for more information. -- Web bookmarks! Edit in your bookmarks.json file, can open them in the web widget using Cmd+O -- Edits to your ai.json presets file will now take effect _immediately_ in AI widgets -- Much better error handling and messaging when errors occur in the preview or editor widget -- `wsh ssh --new` added to open the new ssh connection in a new widget -- new `wsh launch` command to open any custom widget defined in widget.json -- When using terminal multi-input (Ctrl-Shift-I), pasting text will now be sent to all terminals -- [bugfix] Fix some hanging goroutines when commands failed or timed out -- [bugfix] Fix some file extension mimetypes to enable the editor for more file types -- [bugfix] Hitting "tab" would sometimes scroll a widget off screen making it unusable -- [bugfix] XDG variables will no longer leak to terminal widgets -- Added tailwind CSS and shadcn support to help build new widgets faster -- Better internal widget abstractions - -### v0.11.0 — Jan 24, 2025 - -Wave Terminal v0.11.0 includes a major rewrite of our connections infrastructure, with changes to both our backend and remote file protocol systems, alongside numerous features, bug fixes, and stability improvements. - -A key addition in this release is the new shell initialization system, which enables customization of your shell environment across local and remote connections. You can now configure environment variables and shell-specific init scripts on both a per-block and per-connection basis. - -For day-to-day use, we've added search functionality across both terminal and web blocks, along with a terminal multi-input feature for simultaneous input to all terminals within a tab. We've also added support for Google Gemini to Wave AI, expanding our suite of AI integrations. - -Behind the scenes, we've redesigned our remote file protocol, laying the groundwork for upcoming S3 (and S3-compatible system) support in our preview widget. This architectural change sets the stage for adding more file backends in the future. - -- **Shell Environment Customization** -- Configure your shell environment using environment variables and init scripts, with support for both local and remote connections -- **Connection Backend Improvements** -- Major rewrite with improved shell detection, better error logging, and reduced 2FA prompts when using ForceCommand -- **Multi-Shell Support** -- Enhanced support for bash, zsh, pwsh, and fish shells, with shell-specific initialization capabilities -- **Terminal Search** -- use Cmd-F to search for text in terminal widgets -- **Web Search** -- use Cmd-F to search for text in web views -- **Terminal Multi-Input** -- Use Ctrl-Shift-I to allow multi-input to all terminals in the same tab -- **Wave AI now supports Google Gemini** -- Improved WSL support with wsh-free connection options -- Added inline connection debugging information -- Fixed file permission handling issues on Windows systems -- Connection related popups are now delivered only to the initiating window -- Improved timeout handling for SSH connections which require 2FA prompts -- Fixed escape key handling in global event handlers (closing modals) -- Directory preview now fills the entire block width -- Custom widgets can now be launched in magnified mode -- Various workspace UX improvements around closing/deleting -- file:/// urls now work in web widget -- Increased max size of files allowed in `wsh ai` to 50k -- Increased maximum allowed term:scrollback to 50k lines -- Allow connections to entirely be defined in connections.json without relying on ~/.ssh/config -- Added an option to reveal files in external file viewer for local connection -- Added a New Window option when right clicking the MacOS dock icon button -- [build] Switched to free Ubuntu ARM runners for better ARM64 build support -- [build] Windows builds now use zig, simplifying Windows dev setup -- [bugfix] Connections dropdown now populated even when ssh config is missing or invalid -- [bugfix] Disabled bracketed paste mode by default (configuration option to turn it back on) -- [bugfix] Timeout for `wsh ssh` increased to 60s -- [bugfix] Fix for sysinfo widget when displaying a huge number of CPU graphs -- [bugfix] Fixes XDG variables for Snap installs -- [bugfix] Honor SSH IdentitiesOnly flag (useful when many keys are loaded into ssh-agent) -- [bugfix] Better shell environment variable setup when running local shells -- [bugfix] Fix preview for large text files -- [bugfix] Fix URLs in terminal (now clickable again) -- [bugfix] Windows URLs now work properly for Wave background images -- [bugfix] Connections launch without wsh if the unix domain socket can't be opened -- [bugfix] Connection status list lights up correctly with currently connected connections -- [bugfix] Use en_US.UTF-8 if the requested LANG is not available in your terminal -- Other bug fixes, performance improvements, and dependency updates - -### v0.10.4 — Dec 20, 2024 - -Quick update with bug fixes and new configuration options - -- Added "window:confirmclose" and "window:savelastwindow" configuration options -- [bugfix] Fixed broken scroll bar in the AI widget -- [bugfix] Fixed default path for wsh shell detection (used in remote connections) -- Dependency updates - -### v0.10.3 — Dec 19, 2024 - -Quick update to v0.10 with new features and bug fixes. - -- Global hotkey support [docs](https://docs.waveterm.dev/config#customizable-systemwide-global-hotkey) -- Added configuration to override the font size for markdown, AI-chat, and preview editor [docs](https://docs.waveterm.dev/config) -- Added ability to set independent zoom level for the web view (right click block header) -- New `wsh wavepath` command to open the config directory, data directory, and log file -- [bugfix] Fixed crash when /etc/sshd_config contained an unsupported Match directive (most common on Fedora) -- [bugfix] Workspaces are now more consistent across windows, closes associated window when Workspaces are deleted -- [bugfix] Fixed zsh on WSL -- [bugfix] Fixed long-standing bug around control sequences sometimes showing up in terminal output when switching tabs -- Lots of new examples in the docs for shell overrides, presets, widgets, and connections -- Other bug fixes and UI updates - -(note, v0.10.2 and v0.10.3's release notes have been merged together) - -### v0.10.1 — Dec 12, 2024 - -Quick update to fix the workspace app menu actions. Also fixes workspace switching to always open a new window when invoked from a non-workspace window. This reduces the chance of losing a non-workspace window's tabs accidentally. - -### v0.10.0 — Dec 11, 2024 - -Wave Terminal v0.10.0 introduces workspaces, making it easier to manage multiple work environments. We've added powerful new command execution capabilities with `wsh run`, allowing you to launch and control commands in dedicated blocks. This release also brings significant improvements to SSH with a new connections configuration system for managing your remote environments. - -- **Workspaces**: Organize your work into separate environments, each with their own tabs, layouts, and settings -- **Command Blocks**: New `wsh run` command for launching terminal commands in dedicated blocks, with support for magnification, auto-closing, and execution control ([docs](https://docs.waveterm.dev/wsh-reference#run)) -- **Connections**: New configuration system for managing SSH connections, with support for wsh-free operation, per-connection themes, and more ([docs](https://docs.waveterm.dev/connections)) -- Improved tab management with better switching behavior and context menus (many bug fixes) -- New tab features including pinned tabs and drag-and-drop improvements -- Create, rename, and delete files/directories directly in directory preview -- Attempt wsh-free connection as a fallback if wsh installation or execution fails -- New `-i` flag to add identity files with the `wsh ssh` command -- Added Perplexity API integration ([docs](https://docs.waveterm.dev/faq#perplexity)) -- `wsh setbg` command for background handling ([docs](https://docs.waveterm.dev/wsh-reference#setbg)) -- Switched from Less to SCSS for styling -- [bugfix] Fixed tab flickering issues during tab switches -- [bugfix] Corrected WaveAI text area resize behavior -- [bugfix] Fixed concurrent block controller start issues -- [bugfix] Fixed Preview Blocks for uninitialized connections -- [bugfix] Fixed unresponsive context menus -- [bugfix] Fixed connection errors in Help block -- Upgraded Go toolchain to 1.23.4 -- Lots of new documentation, including new pages for [Getting Started](https://docs.waveterm.dev/gettingstarted), [AI Presets](https://docs.waveterm.dev/ai-presets), and [wsh overview](https://docs.waveterm.dev/wsh). -- Other bug fixes, performance improvements, and dependency updates - -### v0.9.3 — Nov 20, 2024 - -New minor release that introduces Wave's connected computing extensions. We've introduced new `wsh` commands that allow you to store variables and files, and access them across terminal sessions (on both local and remote machines). - -- `wsh setvar/getvar` to get and set variables -- [Docs](https://docs.waveterm.dev/wsh-reference#getvarsetvar) -- `wsh file` operations (cat, write, append, rm, info, cp, and ls) -- [Docs](https://docs.waveterm.dev/wsh-reference#file) -- Improved golang panic handling to prevent backend crashes -- Improved SSH config logging and fixes a reused connection bug -- Updated telemetry to track additional counters -- New configuration settings (under "window:magnifiedblock") to control magnified block margins and display -- New block/zone aliases (client, global, block, workspace, temp) -- `wsh ai` file attachments are now rendered with special handling in the AI block -- New ephemeral block type for creating modal widgets which will not disturb the underlying layout -- Editing the AI presets file from the Wave AI block now brings up an ephemeral editor -- Clicking outside of a magnified bglock will now un-magnify it -- New button to clear the AI chat (also bound to Cmd-L) -- New button to reset terminal commands in custom cmd widgets -- [bugfix] Presets directory was not loading correctly on Windows -- [bugfix] Magnified blocks were not showing correct on startup -- [bugfix] Window opacity and background color was not getting applied properly in all cases -- [bugfix] Fix terminal theming when applying global defaults [#1287](https://github.com/wavetermdev/waveterm/issues/1287) -- MacOS 10.15 (Catalina) is no longer supported -- Other bug fixes, docs improvements, and dependency bumps - -### v0.9.2 — Nov 11, 2024 - -New minor release with bug fixes and new features! Fixed the bug around making Wave fullscreen (also affecting certain window managers like Hyprland). We've also put a lot of work into the doc site (https://docs.waveterm.dev), including documenting how [Widgets](./widgets) and Presets work! - -- Updated documentation -- Wave AI now supports the Anthropic API! Checkout the [FAQ](./faq) for how to use the Claude models with Wave AI. -- Removed defaultwidgets.json and unified it to widgets.json. Makes it more straightforward to override the default widgets. -- New resolvers for `-b` param in `wsh`. "tab:N" for accessing the nth tab, "[view]" and "[view]:N" for accessing blocks of a particlar view. -- New `wsh ai` command to send AI chats (and files) directly to a new or existing AI block -- wsh setmeta/getmeta improvements. Allow setmeta to take a json file (and also read from stdin), also better output formats for getmeta (compatible with setmeta). -- [bugfix] Set max completion tokens in the OpenAI API so we can now work with o1 models (also fallback to non-streaming mode) -- [bugfix] Fixed content resizing when entering "full screen" mode. This bug also affected certain window managers (like Hyprland) -- Lots of other small bug fixes, docs updates, and dependency bumps - -### v0.9.1 — Nov 1, 2024 - -Minor bug fix release to follow-up on the v0.9.0 build. Lots of issues fixed (especially for Windows). - -- CLI applications that need microphone, camera, or location access will now work on MacOS. You'll see a security popup in Wave to allow/deny [#1086](https://github.com/wavetermdev/waveterm/issues/1086) -- Can now use `wsh version -v` to print out the new data/config directories -- Restores the old T1, T2, T3, ... tab naming logic -- Temporarily revert to using the "Title Bar" on windows to mitgate a bug where the window controls were overlaying on top of our tabs (working on a real fix for the next release) -- There is a new setting in the editor to enable/disable word wrapping [#1038](https://github.com/wavetermdev/waveterm/issues/1038) -- Ctrl-S will now save files in codeedit [#1081](https://github.com/wavetermdev/waveterm/issues/1081) -- [#1020](https://github.com/wavetermdev/waveterm/issues/1020) there is now a preset config option to change the active border color in tab themes -- [bugfix] Multiple fixes for [#1167](https://github.com/wavetermdev/waveterm/issues/1167) to try to address tab loss while updating -- [bugfix] Windows app crashed on opening View menu because of a bad accelerator key -- [bugfix] The auto-updater messages in the tab bar are now more consistent when switching tabs, and we don't show errors when the network is disconnected -- [bugfix] Full-screen mode now actually shows tabs in full screen -- [bugfix] [#1175](https://github.com/wavetermdev/waveterm/issues/1175) can now edit .awk files -- [bugfix] [#1066](https://github.com/wavetermdev/waveterm/issues/1066) applying a default theme now updates the background appropriately without a refresh - -### v0.9.0 — Oct 28, 2024 - -New major Wave Terminal release! Wave tabs are now cached. Tab switching performance is -now much faster and webview state, editor state, and scroll positions are now persisted -across tab changes. We also have native WSL2 support. You can create native Wave connections -to your Windows WSL2 distributions using the connection button. - -We've also laid the groundwork for some big features that will be released over the -next couple of weeks, including Workspaces, AI improvments, and custom widgets. - -Lots of other smaller changes and bug fixes. See full list of PRs at https://github.com/wavetermdev/waveterm/releases/tag/v0.9.0 - -### v0.8.13 — Oct 24, 2024 - -- Wave is now available as a Snap for Linux users! You can find it [in the Snap Store](https://snapcraft.io/waveterm). -- Wave is now available via the Windows Package Manager! You can install it via `winget install CommandLine.Wave` -- can now use "term:fontsize" to override an individual terminal block's font size (also in context menu) -- we now allow mixed case hostnames for connections to be compatible with ssh config -- The Linux app icon is now updated to match the Windows icon -- [bugfix] fixed a bug that sometimes caused escape sequences to be printed when switching between tabs -- [bugfix] fixed an issue where the preview block was not cleaning up temp files (Windows only) -- [bugfix] fixed chrome sandbox permissions errors in linux -- [bugfix] fixed shutdown logic on MacOS/Linux which sometimes allowed orphaned processes to survive - -### v0.8.12 — Oct 18, 2024 - -- Added support for multiple AI configurations! You can now run Open AI side-by-side with Ollama models. Can create AI presets in presets.json, and can easily switch between them using a new dropdown in the AI widget -- Fix WebSocket reconnection error. this sometimes caused the terminal to hang when waking up from sleep -- Added memory graphs, and per-CPU graphs to the sysinfo widget (and renamed it from cpuplot) -- Added a new huge red "Config Error" button when there are parse errors in the config JSON file -- Preview/CodeEdit widget now shows errors (squiggly lines) when JSON or YAML files fail to parse -- New app icon for Windows to better match Fluent UI standards -- Added copy-on-select to the terminal (on by default, can disable using "term:copyonselect") -- Added a button to mute audio in webviews -- Added a right-click "Open Clipboard URL" to easily open a webview from an URL stored in your system clipboard -- [bugfix] fixed blank "help" pages when waking from sleep or restarting the app - -### v0.8.11 — Oct 10, 2024 - -Hotfix release to address a couple of bugs introduced in v0.8.10 - -- Fixes a regression in v0.8.10 which caused new tabs to sometimes come up blank and broken -- Layout fixes to the AI widget spacing -- Terminal scrollbar is now semi-transparent and overlays last column -- Fixes initial window size (on first startup) for both smaller and larger screens -- Added a "Don't Ask Again" checkbox for installing `wsh` on remote machines (sets a new config flag) -- Prevent the app from downgrading when you install a beta build. Installing a beta-build will now switch you to the beta-update channel. - -### v0.8.10 — Oct 9, 2024 - -Minor big fix release (but there are some new features). - -- added support for Azure AI [See FAQ](https://docs.waveterm.dev/faq#how-can-i-connect-to-azure-ai) -- AI errors now appear in the chat -- on MacOS, hitting "Space" in directorypreview will open selected file in Quick Look -- [bugfix] fixed transparency settings -- [bugfix] fixed issue with non-standard port numbers in connection dropdown -- [bugfix] fixed issue with embedded docsite (returned 404 after refresh) - -### v0.8.9 — Oct 8, 2024 - -Lots of bug fixes and new features! - -- New "help" view -- uses an embedded version of our doc site -- https://docs.waveterm.dev -- [breaking] wsh getmeta, wsh setmeta, and wsh deleteblock now take a blockid using a `-b` parameter instead of as a positional parameter -- allow metadata to override the block icon, header, and text (frame:title, frame:icon, and frame:text) -- home button on web widget to return to the homepage, option to set a homepage default for the whole app or just for the given block -- checkpoint the terminal less often to reduce frequency of output bug (still working on a full fix) -- new terminal themes -- Warm Yellow, and One Dark Pro -- we now support github flavored markdown alerts -- `wsh notify` command to send a desktop notification -- `wsh createblock` to create any block via the CLI -- right click to "Save Image" in webview -- `wsh edit` will now allow you to open new files (as long as the parent directly exists) -- added 8 new fun tab background presets (right click on any tab and select "Backgrounds" to try them out) -- [config] new config key "term:scrollback" to set the number of lines of scrollback for terminals. Use "-1" to set 0, max is 10000. -- [config] new config key "term:theme" to set the default terminal theme for all new terminals -- [config] new config key "preview:showhiddenfiles" to set the default "show hidden files" setting for preview -- [bugfix] fixed an formatting issue with `wsh getmeta` -- [bugfix] fix for startup issue on Linux when home directory is an NFS mount -- [bugfix] fix cursor color in terminal themes to work -- [bugfix] fix some double scrollbars when showing markdown content -- [bugfix] improved shutdown sequence to better capture wavesrv logs -- [bugfix] fix Alt+G keyboard accelerator for Linux/Windows -- other assorted bug fixes, cleanups, and security fixes - -### v0.8.8 — Oct 1, 2024 - -Quick patch release to fix Windows/Linux "Alt" keybindings. Also brings a huge performance improvement to AI streaming speed. - -### v0.8.7 — Sep 30, 2024 - -Quick patch release to fix bugs: - -- Fixes windows SSH connections (invalid path while trying to install wsh tools) -- Fixes an issue resolving `~` in windows paths `~\` now works instead of just `~/` -- Tries to fix background color for webpages. Pulls meta tag for color-scheme, and sets a black background if dark detected (fixes issue rendering raw githubusercontent files) -- Fixed our useDimensions hook to fire correctly. Fixes some sizing issues including allowing error messages to show consistently when SSH connections fail. -- Allow "data:" urls in custom tab backgrounds -- All the alias "tab" for the current tab's UUID when using wsh -- [BUILD] conditional write generated files only if they are updated - -### v0.8.6 — Sep 26, 2024 - -Another quick hotfix update. Fixes an issue where, if you deleted all of the tabs in a window, the window would be restored on next startup as completely blank. - -Also, as a bonus, we added fish shell support! - -### v0.8.5 — Sep 25, 2024 - -Hot fix, dowgrade `jotai` library. Upgrading caused a major regression in codeedit which did not allow -users to edit files. - -### v0.8.4 — Sep 25, 2024 - -- Added a setting `window:disablehardwareacceleration` to disable native hardware acceleration -- New startup model for legacy users given them the option to download the WaveLegacy -- Use WAVETERM_HOME for the home directory consistently - -### v0.8.3 — Sep 25, 2024 - -More hotfixes for Linux users. We now link against an older version of glibc and use -the zig compiler on linux (the newer version caused us not to run on older distros). -Also fixes a permissions issue when installing via .deb. There is also a new config value -`window:nativetitlebar` which restores the native titlebar on windows/linux. - -### v0.8.2 — Sep 24, 2024 - -Hot fix, fixes a nasty crash on startup for Linux users (dynamic linking but with netcgo DNS library) - -### v0.8.1 — Sep 23, 2024 - -Minor cleanup release. - -- fix number parsing for certain config file values -- add link to docs site -- add new back button for directory view -- telemetry fixes - -### v0.8.0 — Sep 20, 2024 - -**Major New Release of Wave Terminal** - -The new build is a fresh start, and a clean break from the current version. As such, your history, settings, and configuration will not be carried over. If you'd like to continue to run the legacy version, you will need to download it separately. - -Release Artificats and source code diffs can be found on (Github)[https://github.com/wavetermdev/waveterm]. diff --git a/docs/docs/telemetry-old.mdx b/docs/docs/telemetry-old.mdx deleted file mode 100644 index dba263dacb..0000000000 --- a/docs/docs/telemetry-old.mdx +++ /dev/null @@ -1,130 +0,0 @@ ---- -id: "telemetry-old" -title: "Legacy Telemetry" -sidebar_class_name: hidden ---- - -Wave Terminal collects telemetry data to help us track feature use, direct future product efforts, and generate aggregate metrics on Wave's popularity and usage. We do not collect or store any PII (personal identifiable information) and all metric data is only associated with and aggregated using your randomly generated _ClientId_. You may opt out of collection at any time. - -If you would like to turn telemetry on or off, the first opportunity is a button on the initial welcome page. After this, it can be turned off by adding `"telemetry:enabled": false` to the `config/settings.json` file. It can alternatively be turned on by adding `"telemetry:enabled": true` to the `config/settings.json` file. - -:::info - -You can also change your telemetry setting by running the wsh command: - -``` -wsh setconfig telemetry:enabled=true -``` - -::: - ---- - -## Sending Telemetry - -Provided that telemetry is enabled, it is sent 10 seconds after Waveterm is first booted and then again every 4 hours thereafter. It can also be sent in response to a few special cases listed below. When telemetry is sent, it is grouped into individual days as determined by your time zone. Any data from a previous day is marked as `Uploaded` so it will not need to be sent again. - -### Sending Once Telemetry is Enabled - -As soon as telemetry is enabled, a telemetry update is sent regardless of how long it has been since the last send. This does not reset the usual timer for telemetry sends. - -### Notifying that Telemetry is Disabled - -As soon as telemetry is disabled, Waveterm sends a special update that notifies us of this change. See [When Telemetry is Turned Off](#when-telemetry-is-turned-off) for more info. The timer still runs in the background but no data is sent. - -### When Waveterm is Closed - -Provided that telemetry is enabled, it will be sent when Waveterm is closed. - ---- - -## Telemetry Data - -When telemetry is active, we collect the following data. It is stored in the `telemetry.TelemetryData` type in the source code. - -| Name | Description | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ActiveMinutes | The number of minutes that the user has actively used Waveterm on a given day. This requires the terminal window to be in focus while the user is actively interacting with it. | -| FgMinutes | The number of minutes that Waveterm has been in the foreground on a given day. This requires the terminal window to be in focus regardless of user interaction. | -| OpenMinutes | The number of minutes that Waveterm has been open on a given day. This only requires that the terminal is open, even if the window is out of focus. | -| NumBlocks | The number of existing blocks open on a given day | -| NumTabs | The number of existing tabs open on a given day. | -| NewTab | The number of new tabs created on a given day | -| NumWindows | The number of existing windows open on a given day. | -| NumWS | The number of existing workspaces on a given day. | -| NumWSNamed | The number of named workspaces on a give day. | -| NewTab | The number of new tabs opened on a given day. | -| NumStartup | The number of times waveterm has been started on a given day. | -| NumShutdown | The number of times waveterm has been shut down on a given day. | -| SetTabTheme | The number of times the tab theme is changed from the context menu | -| NumMagnify | The number of times any block is magnified | -| NumPanics | The number of backend (golang) panics caught in the current day | -| NumAIReqs | The number of AI requests made in the current day | -| NumSSHConn | The number of distinct SSH connections that have been made to distinct hosts | -| NumWSLConns | The number of distinct WSL connections that have been made to distinct distros | -| Renderers | The number of new block views of each type are open on a given day. | -| WshCmds | The number of wsh commands of each type run on a given day | -| Blocks | The number of blocks of different view types open on a given day | -| Conn | The number of successful remote connections made (and errors) on a given day | - -## Associated Data - -In addition to the telemetry data collected, the following is also reported. It is stored in the `telemetry.ActivityType` type in the source code. - -| Name | Description | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Day | The date the telemetry is associated with. It does not include the time. | -| Uploaded | A boolean that indicates if the telemetry for this day is finalized. It is false during the day the telemetry is associated with, but gets set true at the first telemetry upload after that. Once it is true, the data for that particular day will not be sent up with the telemetry any more. | -| TzName | The code for the timezone the user's OS is reporting (e.g. PST, GMT, JST) | -| TzOffset | The offset for the timezone the user's OS is reporting (e.g. -08:00, +00:00, +09:00) | -| ClientVersion | Which version of Waveterm is installed. | -| ClientArch | This includes the user's operating system (e.g. linux or darwin) and architecture (e.g. x86_64 or arm64). It does not include data for any Connections at this time. | -| BuildTime | This serves as a more accurate version number that keeps track of when we built the version. It has no bearing on when that version was installed by you. | -| OSRelease | This lists the version of the operating system the user has installed. | -| Displays | Display resolutions (added in v0.9.3 to help us understand what screen resolutions to optimize for) | - -## Telemetry Metadata - -Lastly, some data is sent along with the telemetry that describes how to classify it. It is stored in the `wcloud.TelemetryInputType` in the source code. - -| Name | Description | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------- | -| UserId | Currently Unused. This is an anonymous UUID intended for use in future features. | -| ClientId | This is an anonymous UUID created when Waveterm is first launched. It is used for telemetry and sending prompts to Open AI. | -| AppType | This is used to differentiate the current version of waveterm from the legacy app. | -| AutoUpdateEnabled | Whether or not auto update is turned on. | -| AutoUpdateChannel | The type of auto update in use. This specifically refers to whether a latest or beta channel is selected. | -| CurDay | The current day (in your time zone) when telemetry is sent. It does not include the time of day. | - -## Geo Data - -We do not store IP addresses in our telemetry table. However, CloudFlare passes us Geo-Location headers. We store these two header values: - -| Name | Description | -| ------------ | ----------------------------------------------------------------- | -| CFCountry | 2-letter country code (e.g. "US", "FR", or "JP") | -| CFRegionCode | region code (often a provence, region, or state within a country) | - ---- - -## When Telemetry is Turned Off - -When a user disables telemetry, Waveterm sends a notification that their anonymous _ClientId_ has had its telemetry disabled. This is done with the `wcloud.NoTelemetryInputType` type in the source code. Beyond that, no further information is sent unless telemetry is turned on again. If it is turned on again, the previous 30 days of telemetry will be sent. - ---- - -## A Note on IP Addresses - -Telemetry is uploaded via https, which means your IP address is known to the telemetry server. We **do not** store your IP address in our telemetry table and **do not** associate it with your _ClientId_. - ---- - -## Previously Collected Telemetry Data - -While we believe the data we collect with telemetry is fairly minimal, we cannot make that decision for every user. If you ever change your mind about what has been collected previously, you may request that your data be deleted by emailing us at [support@waveterm.dev](mailto:support@waveterm.dev). If you do, we will need your _ClientId_ to remove it. - ---- - -## Privacy Policy - -For a summary of the above, you can take a look at our [Privacy Policy](https://www.waveterm.dev/privacy). diff --git a/docs/docs/telemetry.mdx b/docs/docs/telemetry.mdx deleted file mode 100644 index 2f9132276d..0000000000 --- a/docs/docs/telemetry.mdx +++ /dev/null @@ -1,71 +0,0 @@ ---- -sidebar_position: 100 -title: Telemetry -id: "telemetry" ---- - -## tl;dr - -Wave Terminal collects telemetry data to help us track feature use, direct future product efforts, and generate aggregate metrics on Wave's popularity and usage. We do NOT collect personal information (PII), keystrokes, file contents, AI prompts, IP addresses, hostnames, or commands. We attach all information to an anonymous, randomly generated _ClientId_ (UUID). You may opt out of collection at any time. - -Here's a quick summary of what is collected: - -- Basic App/System Info - OS, architecture, app version, update settings -- Usage Metrics - App start/shutdown, active minutes, foreground time, tab/block counts/usage -- Feature Interactions - When you create tabs, run commands, change settings, etc. -- Display Info - Monitor resolution, number of displays -- Connection Events - SSH/WSL connection attempts (but NOT hostnames/IPs) -- Wave AI Usage - Model/provider selection, token counts, request metrics, latency (but NOT prompts or responses) -- Error Reports - Crash/panic events with minimal debugging info, but no stack traces or detailed errors - -Telemetry can be disabled at any time in settings. If not disabled it is sent on startup, on shutdown, and every 4-hours. - -## How to Disable Telemetry - -Telemetry can be enabled or disabled on the initial welcome screen when Wave first starts. After setup, telemetry can be disabled by setting the `telemetry:enabled` key to `false` in Wave’s general configuration file. It can also be disabled using the CLI command `wsh setconfig telemetry:enabled=false`. - -:::info - -This document outlines the current telemetry system as of v0.11.1. As of v0.12.5, Wave Terminal no longer sends legacy telemetry. The previous telemetry documentation can be found in our [Legacy Telemetry Documentation](./telemetry-old.mdx) for historical reference. - -::: - -## Diagnostics Ping - -Wave sends a small, anonymous diagnostics ping after the app has been running for a short time and at most once per day thereafter. This is used to estimate active installs and understand which versions are still in use, so we can make informed decisions about ongoing support and deprecations. - -The ping includes only: your Wave version, OS/CPU arch, local date (yyyy-mm-dd, no timezone or clock time), your randomly generated anonymous client ID, and whether usage telemetry is enabled or disabled. - -It does not include usage data, commands, files, or any telemetry events. - -This ping is intentionally separate from telemetry so Wave can count active installs. If you'd like to disable it, set the WAVETERM_NOPING environment variable. - -## Sending Telemetry - -Provided that telemetry is enabled, it is sent shortly after Wave is first launched and then again every 4 hours thereafter. It can also be sent in response to a few special cases listed below. When telemetry is sent, events are marked as sent to prevent duplicate transmissions. - -### Sending Once Telemetry is Enabled - -As soon as telemetry is enabled, a telemetry update is sent regardless of how long it has been since the last send. This does not reset the usual timer for telemetry sends. - -### When Wave is Closed - -Provided that telemetry is enabled, it will be sent when Waveterm is closed. - -## Event Types and Properties - -Wave collects the event types and properties described in the summary above. As we add features, new events and properties may be added to track their usage. - -For the complete, current list of all telemetry events and properties, see the source code: [telemetrydata.go](https://github.com/wavetermdev/waveterm/blob/main/pkg/telemetry/telemetrydata/telemetrydata.go) - -## GDPR Opt-Out Compliance - -When telemetry is disabled, Wave sends a single minimal opt-out record associated with the anonymous client ID, recording that telemetry was turned off and when it occurred. This record is retained for compliance purposes. After that, no telemetry or usage data is sent. - -## Deleting Your Data - -If you want your previously collected telemetry data deleted, email us at support (at) waveterm.dev with your _ClientId_ and we'll remove it. - -## Privacy Policy - -For a summary of the above, you can take a look at our [Privacy Policy](https://www.waveterm.dev/privacy). diff --git a/docs/docs/waveai-modes.mdx b/docs/docs/waveai-modes.mdx deleted file mode 100644 index 93403db800..0000000000 --- a/docs/docs/waveai-modes.mdx +++ /dev/null @@ -1,565 +0,0 @@ ---- -sidebar_position: 1.6 -id: "waveai-modes" -title: "Wave AI (Local Models + BYOK)" ---- - -import { VersionBadge } from "@site/src/components/versionbadge"; - - - -Wave AI supports custom AI modes that allow you to use local models, custom API endpoints, and alternative AI providers. This gives you complete control over which models and providers you use with Wave's AI features. - -## Configuration Overview - -AI modes are configured in `~/.config/waveterm/waveai.json`. - -**To edit using the UI:** -1. Click the settings (gear) icon in the widget bar -2. Select "Settings" from the menu -3. Choose "Wave AI Modes" from the settings sidebar - -**Or launch from the command line:** -```bash -wsh editconfig waveai.json -``` - -Each mode defines a complete AI configuration including the model, API endpoint, authentication, and display properties. - -## Provider-Based Configuration - -Wave AI now supports provider-based configuration which automatically applies sensible defaults for common providers. By specifying the `ai:provider` field, you can significantly simplify your configuration as the system will automatically set up endpoints, API types, and secret names. - -### Supported Providers - -- **`openai`** - OpenAI API (automatically configures endpoint and secret name) [[see example](#openai)] -- **`openrouter`** - OpenRouter API (automatically configures endpoint and secret name) [[see example](#openrouter)] -- **`nanogpt`** - NanoGPT API (automatically configures endpoint and secret name) [[see example](#nanogpt)] -- **`groq`** - Groq API (automatically configures endpoint and secret name) [[see example](#groq)] -- **`google`** - Google AI (Gemini) [[see example](#google-ai-gemini)] -- **`azure`** - Azure OpenAI Service (modern API) [[see example](#azure-openai-modern-api)] -- **`azure-legacy`** - Azure OpenAI Service (legacy deployment API) [[see example](#azure-openai-legacy-deployment-api)] -- **`custom`** - Custom API endpoint (fully manual configuration) [[see examples](#local-model-examples)] - -### Supported API Types - -Wave AI supports the following API types: - -- **`openai-chat`**: Uses the `/v1/chat/completions` endpoint (most common) -- **`openai-responses`**: Uses the `/v1/responses` endpoint (modern API for GPT-5+ models) -- **`google-gemini`**: Google's Gemini API format (automatically set when using `ai:provider: "google"`, not typically used directly) - -## Global Wave AI Settings - -You can configure global Wave AI behavior in your Wave Terminal settings (separate from the mode configurations in `waveai.json`). - -### Setting a Default AI Mode - -After configuring a local model or custom mode, you can make it the default by setting `waveai:defaultmode` in your Wave Terminal settings. - -:::important -Use the **mode key** (the key in your `waveai.json` configuration), not the display name. For example, use `"ollama-llama"` (the key), not `"Ollama - Llama 3.3"` (the display name). -::: - -**Using the settings command:** -```bash -wsh setconfig waveai:defaultmode="ollama-llama" -``` - -**Or edit settings.json directly:** -1. Click the settings (gear) icon in the widget bar -2. Select "Settings" from the menu -3. Add the `waveai:defaultmode` key to your settings.json: -```json - "waveai:defaultmode": "ollama-llama" -``` - -This will make the specified mode the default selection when opening Wave AI features. - -:::note -Wave AI normally requires telemetry to be enabled. However, if you configure your own custom model (local or BYOK) and set `waveai:defaultmode` to that custom mode's key, you will not receive telemetry requirement messages. This allows you to use Wave AI features completely privately with your own models. -::: - -### Hiding Wave Cloud Modes - -If you prefer to use only your local or custom models and want to hide Wave's cloud AI modes from the mode dropdown, set `waveai:showcloudmodes` to `false`: - -**Using the settings command:** -```bash -wsh setconfig waveai:showcloudmodes=false -``` - -**Or edit settings.json directly:** -1. Click the settings (gear) icon in the widget bar -2. Select "Settings" from the menu -3. Add the `waveai:showcloudmodes` key to your settings.json: -```json - "waveai:showcloudmodes": false -``` - -This will hide Wave's built-in cloud AI modes, showing only your custom configured modes. - -## Local Model Examples - -### Ollama - -[Ollama](https://ollama.ai) provides an OpenAI-compatible API for running models locally: - -```json -{ - "ollama-llama": { - "display:name": "Ollama - Llama 3.3", - "display:order": 1, - "display:icon": "microchip", - "display:description": "Local Llama 3.3 70B model via Ollama", - "ai:apitype": "openai-chat", - "ai:model": "llama3.3:70b", - "ai:thinkinglevel": "medium", - "ai:endpoint": "http://localhost:11434/v1/chat/completions", - "ai:apitoken": "ollama" - } -} -``` - -:::tip -The `ai:apitoken` field is required but Ollama ignores it - you can set it to any value like `"ollama"`. -::: - -### LM Studio - -[LM Studio](https://lmstudio.ai) provides a local server that can run various models: - -```json -{ - "lmstudio-qwen": { - "display:name": "LM Studio - Qwen", - "display:order": 2, - "display:icon": "server", - "display:description": "Local Qwen model via LM Studio", - "ai:apitype": "openai-chat", - "ai:model": "qwen/qwen-2.5-coder-32b-instruct", - "ai:thinkinglevel": "medium", - "ai:endpoint": "http://localhost:1234/v1/chat/completions", - "ai:apitoken": "not-needed" - } -} -``` - -### vLLM - -[vLLM](https://docs.vllm.ai) is a high-performance inference server with OpenAI API compatibility: - -```json -{ - "vllm-local": { - "display:name": "vLLM", - "display:order": 3, - "display:icon": "server", - "display:description": "Local model via vLLM", - "ai:apitype": "openai-chat", - "ai:model": "your-model-name", - "ai:thinkinglevel": "medium", - "ai:endpoint": "http://localhost:8000/v1/chat/completions", - "ai:apitoken": "not-needed" - } -} -``` - -## Cloud Provider Examples - -### OpenAI - -Using the `openai` provider automatically configures the endpoint and secret name: - -```json -{ - "openai-gpt4o": { - "display:name": "GPT-4o", - "ai:provider": "openai", - "ai:model": "gpt-4o" - } -} -``` - -The provider automatically sets: -- `ai:endpoint` to `https://api.openai.com/v1/chat/completions` -- `ai:apitype` to `openai-chat` (or `openai-responses` for GPT-5+ models) -- `ai:apitokensecretname` to `OPENAI_KEY` (store your OpenAI API key with this name) -- `ai:capabilities` to `["tools", "images", "pdfs"]` (automatically determined based on model) - -For newer models like GPT-4.1 or GPT-5, the API type is automatically determined: - -```json -{ - "openai-gpt41": { - "display:name": "GPT-4.1", - "ai:provider": "openai", - "ai:model": "gpt-4.1" - } -} -``` - -### OpenAI Compatible - -To use an OpenAI compatible API provider, you need to provide the ai:endpoint, ai:apitokensecretname, ai:model parameters, -and use "openai-chat" as the ai:apitype. - -:::note -The ai:endpoint is *NOT* a baseurl. The endpoint should contain the full endpoint, not just the baseurl. -For example: https://api.x.ai/v1/chat/completions - -If you provide only the baseurl, you are likely to get a 404 message. -::: - -```json -{ - "xai-grokfast": { - "display:name": "xAI Grok Fast", - "display:order": 2, - "display:icon": "server", - "ai:apitype": "openai-chat", - "ai:model": "grok-4-1-fast-reasoning", - "ai:endpoint": "https://api.x.ai/v1/chat/completions", - "ai:apitokensecretname": "XAI_KEY", - "ai:capabilities": ["tools", "images", "pdfs"] - } -} -``` - -The `ai:apitokensecretname` should be the name of an environment variable that contains your API key. Set this environment variable before running Wave Terminal. - - -### OpenRouter - -[OpenRouter](https://openrouter.ai) provides access to multiple AI models. Using the `openrouter` provider simplifies configuration: - -```json -{ - "openrouter-qwen": { - "display:name": "OpenRouter - Qwen", - "ai:provider": "openrouter", - "ai:model": "qwen/qwen-2.5-coder-32b-instruct" - } -} -``` - -The provider automatically sets: -- `ai:endpoint` to `https://openrouter.ai/api/v1/chat/completions` -- `ai:apitype` to `openai-chat` -- `ai:apitokensecretname` to `OPENROUTER_KEY` (store your OpenRouter API key with this name) - -:::note -For OpenRouter, you must manually specify `ai:capabilities` based on your model's features. Example: -```json -{ - "openrouter-qwen": { - "display:name": "OpenRouter - Qwen", - "ai:provider": "openrouter", - "ai:model": "qwen/qwen-2.5-coder-32b-instruct", - "ai:capabilities": ["tools"] - } -} -``` -::: - -### NanoGPT - -[NanoGPT](https://nano-gpt.com) provides access to multiple AI models at competitive prices. Using the `nanogpt` provider simplifies configuration: - -```json -{ - "nanogpt-glm47": { - "display:name": "NanoGPT - GLM 4.7", - "ai:provider": "nanogpt", - "ai:model": "zai-org/glm-4.7" - } -} -``` - -The provider automatically sets: -- `ai:endpoint` to `https://nano-gpt.com/api/v1/chat/completions` -- `ai:apitype` to `openai-chat` -- `ai:apitokensecretname` to `NANOGPT_KEY` (store your NanoGPT API key with this name) - -:::note -NanoGPT is a proxy service that provides access to multiple AI models. You must manually specify `ai:capabilities` based on the model's features. NanoGPT supports OpenAI-compatible tool calling for models that have that capability. Check the model's `capabilities.vision` field from the [NanoGPT models API](https://nano-gpt.com/api/v1/models?detailed=true) to determine image support. Example for a text-only model with tool support: -```json -{ - "nanogpt-glm47": { - "display:name": "NanoGPT - GLM 4.7", - "ai:provider": "nanogpt", - "ai:model": "zai-org/glm-4.7", - "ai:capabilities": ["tools"] - } -} -``` -For vision-capable models like `openai/gpt-5`, add `"images"` to capabilities. -::: - -### Groq - -[Groq](https://groq.com) provides fast inference for open models through an OpenAI-compatible API. Using the `groq` provider simplifies configuration: - -```json -{ - "groq-kimi-k2": { - "display:name": "Groq - Kimi K2", - "ai:provider": "groq", - "ai:model": "moonshotai/kimi-k2-instruct" - } -} -``` - -The provider automatically sets: -- `ai:endpoint` to `https://api.groq.com/openai/v1/chat/completions` -- `ai:apitype` to `openai-chat` -- `ai:apitokensecretname` to `GROQ_KEY` (store your Groq API key with this name) - -:::note -For Groq, you must manually specify `ai:capabilities` based on your model's features. -::: - -### Google AI (Gemini) - -[Google AI](https://ai.google.dev) provides the Gemini family of models. Using the `google` provider simplifies configuration: - -```json -{ - "google-gemini": { - "display:name": "Gemini 3.5 Flash", - "ai:provider": "google", - "ai:model": "gemini-3.5-flash" - } -} -``` - -The provider automatically sets: -- `ai:endpoint` to `https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent` -- `ai:apitype` to `google-gemini` -- `ai:apitokensecretname` to `GOOGLE_AI_KEY` (store your Google AI API key with this name) -- `ai:capabilities` to `["tools", "images", "pdfs"]` (automatically configured) - -### Azure OpenAI (Modern API) - -For the modern Azure OpenAI API, use the `azure` provider: - -```json -{ - "azure-gpt4": { - "display:name": "Azure GPT-4", - "ai:provider": "azure", - "ai:model": "gpt-4", - "ai:azureresourcename": "your-resource-name" - } -} -``` - -The provider automatically sets: -- `ai:endpoint` to `https://your-resource-name.openai.azure.com/openai/v1/chat/completions` (or `/responses` for newer models) -- `ai:apitype` based on the model -- `ai:apitokensecretname` to `AZURE_OPENAI_KEY` (store your Azure OpenAI key with this name) - -:::note -For Azure providers, you must manually specify `ai:capabilities` based on your model's features. Example: -```json -{ - "azure-gpt4": { - "display:name": "Azure GPT-4", - "ai:provider": "azure", - "ai:model": "gpt-4", - "ai:azureresourcename": "your-resource-name", - "ai:capabilities": ["tools", "images"] - } -} -``` -::: - -### Azure OpenAI (Legacy Deployment API) - -For legacy Azure deployments, use the `azure-legacy` provider: - -```json -{ - "azure-legacy-gpt4": { - "display:name": "Azure GPT-4 (Legacy)", - "ai:provider": "azure-legacy", - "ai:azureresourcename": "your-resource-name", - "ai:azuredeployment": "your-deployment-name" - } -} -``` - -The provider automatically constructs the full endpoint URL and sets the API version (defaults to `2025-04-01-preview`). You can override the API version with `ai:azureapiversion` if needed. - -:::note -For Azure Legacy provider, you must manually specify `ai:capabilities` based on your model's features. -::: - -## Using Secrets for API Keys - -Instead of storing API keys directly in the configuration, you should use Wave's secret store to keep your credentials secure. Secrets are stored encrypted using your system's native keychain. - -### Storing an API Key - -**Using the Secrets UI (recommended):** -1. Click the settings (gear) icon in the widget bar -2. Select "Secrets" from the menu -3. Click "Add New Secret" -4. Enter the secret name (e.g., `OPENAI_API_KEY`) and your API key -5. Click "Save" - -**Or from the command line:** -```bash -wsh secret set OPENAI_KEY=sk-xxxxxxxxxxxxxxxx -wsh secret set OPENROUTER_KEY=sk-xxxxxxxxxxxxxxxx -``` - -### Referencing the Secret - -When using providers like `openai` or `openrouter`, the secret name is automatically set. Just ensure the secret exists with the correct name: - -```json -{ - "my-openai-mode": { - "display:name": "OpenAI GPT-4o", - "ai:provider": "openai", - "ai:model": "gpt-4o" - } -} -``` - -The `openai` provider automatically looks for the `OPENAI_KEY` secret. See the [Secrets documentation](./secrets.mdx) for more information on managing secrets securely in Wave. - -## Multiple Modes Example - -You can define multiple AI modes and switch between them easily: - -```json -{ - "ollama-llama": { - "display:name": "Ollama - Llama 3.3", - "display:order": 1, - "ai:model": "llama3.3:70b", - "ai:endpoint": "http://localhost:11434/v1/chat/completions", - "ai:apitoken": "ollama" - }, - "ollama-codellama": { - "display:name": "Ollama - CodeLlama", - "display:order": 2, - "ai:model": "codellama:34b", - "ai:endpoint": "http://localhost:11434/v1/chat/completions", - "ai:apitoken": "ollama" - }, - "openai-gpt4o": { - "display:name": "GPT-4o", - "display:order": 10, - "ai:provider": "openai", - "ai:model": "gpt-4o" - } -} -``` - -## Troubleshooting - -### Connection Issues - -If Wave can't connect to your model server: - -1. **For cloud providers with `ai:provider` set**: Ensure you have the correct secret stored (e.g., `OPENAI_KEY`, `OPENROUTER_KEY`) -2. **For local/custom endpoints**: Verify the server is running (`curl http://localhost:11434/v1/models` for Ollama) -3. Check the `ai:endpoint` is the complete endpoint URL including the path (e.g., `http://localhost:11434/v1/chat/completions`) -4. Verify the `ai:apitype` matches your server's API (defaults are usually correct when using providers) -5. Check firewall settings if using a non-localhost address - -### Model Not Found - -If you get "model not found" errors: - -1. Verify the model name matches exactly what your server expects -2. For Ollama, use `ollama list` to see available models -3. Some servers require prefixes or specific naming formats - -### API Type Selection - -- The API type defaults to `openai-chat` if not specified, which works for most providers -- Use `openai-chat` for Ollama, LM Studio, custom endpoints, and most cloud providers -- Use `openai-responses` for newer OpenAI models (GPT-5+) or when your provider specifically requires it -- Provider presets automatically set the correct API type when needed - -## Configuration Reference - -### Minimal Configuration (with Provider) - -```json -{ - "mode-key": { - "display:name": "Qwen (OpenRouter)", - "ai:provider": "openrouter", - "ai:model": "qwen/qwen-2.5-coder-32b-instruct" - } -} -``` - -### Full Configuration (all fields) - -```json -{ - "mode-key": { - "display:name": "Display Name", - "display:order": 1, - "display:icon": "icon-name", - "display:description": "Full description", - "ai:provider": "custom", - "ai:apitype": "openai-chat", - "ai:model": "model-name", - "ai:thinkinglevel": "medium", - "ai:endpoint": "http://localhost:11434/v1/chat/completions", - "ai:azureapiversion": "v1", - "ai:apitoken": "your-token", - "ai:apitokensecretname": "PROVIDER_KEY", - "ai:azureresourcename": "your-resource", - "ai:azuredeployment": "your-deployment", - "ai:capabilities": ["tools", "images", "pdfs"] - } -} -``` - -### Field Reference - -| Field | Required | Description | -|-------|----------|-------------| -| `display:name` | Yes | Name shown in the AI mode selector | -| `display:order` | No | Sort order in the selector (lower numbers first) | -| `display:icon` | No | Icon identifier for the mode (can use any [FontAwesome icon](https://fontawesome.com/search), use the name without the "fa-" prefix). Default is "sparkles" | -| `display:description` | No | Full description of the mode | -| `ai:provider` | No | Provider preset: `openai`, `openrouter`, `nanogpt`, `groq`, `google`, `azure`, `azure-legacy`, `custom` | -| `ai:apitype` | No | API type: `openai-chat`, `openai-responses`, or `google-gemini` (defaults to `openai-chat` if not specified) | -| `ai:model` | No | Model identifier (required for most providers) | -| `ai:thinkinglevel` | No | Thinking level: `low`, `medium`, or `high` | -| `ai:endpoint` | No | *Full* API endpoint URL (auto-set by provider when available) | -| `ai:azureapiversion` | No | Azure API version (for `azure-legacy` provider, defaults to `2025-04-01-preview`) | -| `ai:apitoken` | No | API key/token (not recommended - use secrets instead) | -| `ai:apitokensecretname` | No | Name of secret containing API token (auto-set by provider) | -| `ai:azureresourcename` | No | Azure resource name (for Azure providers) | -| `ai:azuredeployment` | No | Azure deployment name (for `azure-legacy` provider) | -| `ai:capabilities` | No | Array of supported capabilities: `"tools"`, `"images"`, `"pdfs"` | -| `waveai:cloud` | No | Internal - for Wave Cloud AI configuration only | -| `waveai:premium` | No | Internal - for Wave Cloud AI configuration only | - -### AI Capabilities - -The `ai:capabilities` field specifies what features the AI mode supports: - -- **`tools`** - Enables AI tool usage for file reading/writing, shell integration, and widget interaction -- **`images`** - Allows image attachments in chat (model can view uploaded images) -- **`pdfs`** - Allows PDF file attachments in chat (model can read PDF content) - -**Provider-specific behavior:** -- **OpenAI and Google providers**: Capabilities are automatically configured based on the model. You don't need to specify them. -- **OpenRouter, NanoGPT, Groq, Azure, Azure-Legacy, and Custom providers**: You must manually specify capabilities based on your model's features. - -:::warning -If you don't include `"tools"` in the `ai:capabilities` array, the AI model will not be able to interact with your Wave terminal widgets, read/write files, or execute commands. Most AI modes should include `"tools"` for the best Wave experience. -::: - -Most models support `tools` and can benefit from it. Vision-capable models should include `images`. Not all models support PDFs, so only include `pdfs` if your model can process them. diff --git a/docs/docs/waveai.mdx b/docs/docs/waveai.mdx deleted file mode 100644 index 5189bc6792..0000000000 --- a/docs/docs/waveai.mdx +++ /dev/null @@ -1,110 +0,0 @@ ---- -sidebar_position: 1.5 -id: "waveai" -title: "Wave AI" ---- - -import { Kbd } from "@site/src/components/kbd"; -import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; - - - - - -

-Context-aware terminal assistant with access to terminal output, widgets, and filesystem. - -## Keyboard Shortcuts - -| Shortcut | Action | -|----------|--------| -| | Toggle AI panel | -| | Focus AI input | -| | Clear chat / start new | -| | Send message | -| | New line | - -## Widget Context Toggle - -Controls AI's access to your workspace: - -**ON**: AI can read terminal output, capture widget screenshots, access files/directories (with approval), navigate web widgets, and use custom widget tools. Use for debugging, code analysis, and workspace tasks. - -**OFF**: AI only sees your messages and attached files. Standard chat mode for general questions. - -## File Attachments - -Drag files onto the AI panel to attach (not supported with all models): - -| Type | Formats | Size Limit | Notes | -|------|---------|------------|-------| -| Images | JPEG, PNG, GIF, WebP, SVG | 10 MB | Auto-resized to 4096px max, converted to WebP | -| PDFs | `.pdf` | 5 MB | Text extraction for analysis | -| Text/Code | `.js`, `.ts`, `.py`, `.go`, `.md`, `.json`, `.yaml`, etc. | 200 KB | All common languages and configs | - -## CLI Integration - -Use `wsh ai` to send files and prompts from the command line: - -```bash -git diff | wsh ai - # Pipe to AI -wsh ai main.go -m "find bugs" # Attach files with message -wsh ai $(tail -n 500 my.log) -m "review" -s # Auto-submit with output -``` - -Supports text files, images, PDFs, and directories. Use `-n` for new chat, `-s` to auto-submit. - -## AI Tools (Widget Context Enabled) - -### Terminal -- **Read Terminal Output**: Fetches scrollback from terminal widgets, supports line ranges - -### File System -- **Read Files**: Reads text files with line range support (requires approval) -- **List Directories**: Returns file info, sizes, permissions, timestamps (requires approval) -- **Write Text Files**: Create or modify files with diff preview and approval (requires approval) - -### Web -- **Navigate Web**: Changes URLs in web browser widgets - -### All Widgets -- **Capture Screenshots**: Takes screenshots of any widget for visual analysis (not supported on all models) - -:::warning Security -File system operations require explicit approval. You control all file access. -::: - -## Local Models & BYOK - -Wave AI supports using your own AI models and API keys: - -- **Local Models**: Run AI models locally with [Ollama](https://ollama.ai), [LM Studio](https://lmstudio.ai), [vLLM](https://docs.vllm.ai), and other OpenAI-compatible servers -- **BYOK (Bring Your Own Key)**: Use your own API keys with OpenAI, OpenRouter, Google AI (Gemini), Azure OpenAI, and other cloud providers -- **Multiple Modes**: Configure and switch between multiple AI providers and models -- **Privacy**: Keep your data local or use your preferred cloud provider - -See the [**Local Models & BYOK guide**](./waveai-modes.mdx) for complete configuration instructions, examples, and troubleshooting. - -## Privacy - -**Default Wave AI Service:** -- Messages are proxied through the Wave Cloud AI service (powered by OpenAI's APIs). Please refer to OpenAI's privacy policy for details on how they handle your data. -- Wave does not store your chats, attachments, or use them for training -- Usage counters included in anonymous telemetry -- File access requires explicit approval - -**Local Models & BYOK:** -- When using local models, your chat data never leaves your machine -- When using BYOK with cloud providers, requests are sent directly to your chosen provider -- Refer to your provider's privacy policy for details on how they handle your data - -:::info Under Active Development -Wave AI is in active beta with included AI credits while we refine the experience. Share feedback in our [Discord](https://discord.gg/XfvZ334gwU). - -**Coming Soon:** -- **Remote File Access**: Read files on SSH-connected systems -- **Command Execution**: Run terminal commands with approval -- **Web Content**: Extract text from web pages (currently screenshots only) -::: - -
\ No newline at end of file diff --git a/docs/docs/wsh-reference.mdx b/docs/docs/wsh-reference.mdx index 6ed1bcaa3f..fb7247f91d 100644 --- a/docs/docs/wsh-reference.mdx +++ b/docs/docs/wsh-reference.mdx @@ -188,9 +188,6 @@ wsh editconfig presets.json # opens widgets.json wsh editconfig widgets.json - -# opens ai presets -wsh editconfig presets/ai.json ``` --- @@ -990,7 +987,7 @@ Flags: - `--workspace ` - restrict to specific workspace id - `--window ` - restrict to specific window id - `--tab ` - restrict to specific tab id -- `--view ` - filter by view type (term, web, preview, edit, sysinfo, waveai) +- `--view ` - filter by view type (term, web, preview, edit, sysinfo) - `--json` - output results as JSON - `--timeout ` - RPC timeout in milliseconds (default: 5000) diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index d49f2da616..1bddf2af97 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -117,10 +117,6 @@ const config = { // this should remove /usr/lib/.build-id/ links which can conflict with other electron apps like slack fpm: ["--rpm-rpmbuild-define", "_build_id_links none"], }, - publish: { - provider: "generic", - url: "https://dl.waveterm.dev/releases-w2", - }, afterPack: (context) => { // This is a workaround to restore file permissions to the wavesrv binaries on macOS after packaging the universal binary. if (context.electronPlatformName === "darwin" && context.arch === Arch.universal) { diff --git a/emain/emain-activity.ts b/emain/emain-activity.ts index 17dde466ae..360d93217f 100644 --- a/emain/emain-activity.ts +++ b/emain/emain-activity.ts @@ -9,10 +9,6 @@ let globalIsStarting = true; let globalIsRelaunching = false; let forceQuit = false; let userConfirmedQuit = false; -let termCommandsRun = 0; -let termCommandsRemote = 0; -let termCommandsWsl = 0; -let termCommandsDurable = 0; export function setWasActive(val: boolean) { wasActive = val; @@ -66,42 +62,4 @@ export function getUserConfirmedQuit(): boolean { return userConfirmedQuit; } -export function incrementTermCommandsRun() { - termCommandsRun++; -} - -export function getAndClearTermCommandsRun(): number { - const count = termCommandsRun; - termCommandsRun = 0; - return count; -} - -export function incrementTermCommandsRemote() { - termCommandsRemote++; -} - -export function getAndClearTermCommandsRemote(): number { - const count = termCommandsRemote; - termCommandsRemote = 0; - return count; -} -export function incrementTermCommandsWsl() { - termCommandsWsl++; -} - -export function getAndClearTermCommandsWsl(): number { - const count = termCommandsWsl; - termCommandsWsl = 0; - return count; -} - -export function incrementTermCommandsDurable() { - termCommandsDurable++; -} - -export function getAndClearTermCommandsDurable(): number { - const count = termCommandsDurable; - termCommandsDurable = 0; - return count; -} diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 5e5f15b302..618342104d 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -12,12 +12,7 @@ import { RpcApi } from "../frontend/app/store/wshclientapi"; import { getWebServerEndpoint } from "../frontend/util/endpoints"; import * as keyutil from "../frontend/util/keyutil"; import { fireAndForget, parseDataUrl } from "../frontend/util/util"; -import { - incrementTermCommandsDurable, - incrementTermCommandsRemote, - incrementTermCommandsRun, - incrementTermCommandsWsl, - setWasActive, +import { setWasActive, } from "./emain-activity"; import { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from "./emain-builder"; import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform"; @@ -438,22 +433,6 @@ export function initIpcHandlers() { console.log("fe-log", logStr); }); - electron.ipcMain.on( - "increment-term-commands", - (event, opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => { - incrementTermCommandsRun(); - if (opts?.isRemote) { - incrementTermCommandsRemote(); - } - if (opts?.isWsl) { - incrementTermCommandsWsl(); - } - if (opts?.isDurable) { - incrementTermCommandsDurable(); - } - } - ); - electron.ipcMain.on("native-paste", (event) => { event.sender.paste(); }); diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index 1bdf6a7139..7fee125a23 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -20,8 +20,6 @@ import { WaveBrowserWindow, } from "./emain-window"; import { ElectronWshClient } from "./emain-wsh"; -import { updater } from "./updater"; - type AppMenuCallbacks = { createNewWaveWindow: () => Promise; relaunchBrowserWindows: () => Promise; @@ -179,12 +177,6 @@ function makeAppMenuItems(webContents: electron.WebContents): Electron.MenuItemC (getWindowWebContents(window) ?? webContents)?.send("menu-item-about"); }, }, - { - label: "Check for Updates", - click: () => { - fireAndForget(() => updater?.checkForUpdates(true)); - }, - }, { type: "separator" }, ]; if (unamePlatform === "darwin") { diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index 753a53adec..fa89df5317 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -118,7 +118,6 @@ export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabVie export class WaveTabView extends WebContentsView { waveWindowId: string; // this will be set for any tabviews that are initialized. (unset for the hot spare) isActiveTab: boolean; - isWaveAIOpen: boolean; private _waveTabId: string; // always set, WaveTabViews are unique per tab lastUsedTs: number; // ts milliseconds createdTs: number; // ts milliseconds @@ -142,7 +141,6 @@ export class WaveTabView extends WebContentsView { }, }); this.createdTs = Date.now(); - this.isWaveAIOpen = false; this.savedInitOpts = null; this.initPromise = new Promise((resolve, _) => { this.initResolve = resolve; diff --git a/emain/emain-wavesrv.ts b/emain/emain-wavesrv.ts index f58d214a7e..14ac4c643b 100644 --- a/emain/emain-wavesrv.ts +++ b/emain/emain-wavesrv.ts @@ -24,7 +24,7 @@ import { WaveAppPathVarName, WaveAppResourcesPathVarName, } from "./emain-util"; -import { updater } from "./updater"; + let isWaveSrvDead = false; let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null; @@ -77,9 +77,6 @@ export function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promis env: envCopy, }); proc.on("exit", (e) => { - if (updater?.status == "installing") { - return; - } console.log("wavesrv exited, shutting down"); setForceQuit(true); isWaveSrvDead = true; diff --git a/emain/emain-window.ts b/emain/emain-window.ts index e3bfa87751..72b5584cd5 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -21,7 +21,7 @@ import { getElectronAppBasePath, isDev, unamePlatform } from "./emain-platform"; import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview"; import { delay, ensureBoundsAreVisible, waveKeyToElectronKey } from "./emain-util"; import { ElectronWshClient } from "./emain-wsh"; -import { updater } from "./updater"; + const DevInitTimeoutMs = 5000; @@ -301,7 +301,7 @@ export class WaveBrowserWindow extends BaseWindow { } this.closeAllDevTools(); console.log("win 'close' handler fired", this.waveWindowId); - if (getGlobalIsQuitting() || updater?.status == "installing" || getGlobalIsRelaunching()) { + if (getGlobalIsQuitting() || getGlobalIsRelaunching()) { return; } e.preventDefault(); @@ -332,7 +332,7 @@ export class WaveBrowserWindow extends BaseWindow { }); this.on("closed", () => { console.log("win 'closed' handler fired", this.waveWindowId); - if (getGlobalIsQuitting() || updater?.status == "installing") { + if (getGlobalIsQuitting()) { console.log("win quitting or updating", this.waveWindowId); return; } @@ -757,13 +757,6 @@ ipcMain.on("create-tab", async (event, _opts) => { return null; }); -ipcMain.on("set-waveai-open", (event, isOpen: boolean) => { - const tabView = getWaveTabViewByWebContentsId(event.sender.id); - if (tabView) { - tabView.isWaveAIOpen = isOpen; - } -}); - ipcMain.handle("close-tab", async (event, workspaceId: string, tabId: string, confirmClose: boolean) => { const ww = getWaveWindowByWorkspaceId(workspaceId); if (ww == null) { diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index d17dc2e106..3d935554e9 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -5,7 +5,7 @@ import { WindowService } from "@/app/store/services"; import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; import { Notification, net, safeStorage, shell } from "electron"; -import { getResolvedUpdateChannel } from "emain/updater"; + import { unamePlatform } from "./emain-platform"; import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; import { createBrowserWindow, getWaveWindowById, getWaveWindowByWorkspaceId } from "./emain-window"; @@ -39,10 +39,6 @@ export class ElectronWshClientType extends WshClient { }).show(); } - async handle_getupdatechannel(rh: RpcResponseHelper): Promise { - return getResolvedUpdateChannel(); - } - async handle_focuswindow(rh: RpcResponseHelper, windowId: string) { console.log(`focuswindow ${windowId}`); const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); diff --git a/emain/emain.ts b/emain/emain.ts index 8b08178aec..9d80261043 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -12,10 +12,6 @@ import { fireAndForget, sleep } from "../frontend/util/util"; import { AuthKey, configureAuthKeyRequestInjection } from "./authkey"; import { getActivityState, - getAndClearTermCommandsDurable, - getAndClearTermCommandsRemote, - getAndClearTermCommandsRun, - getAndClearTermCommandsWsl, getForceQuit, getGlobalIsRelaunching, getUserConfirmedQuit, @@ -56,7 +52,7 @@ import { } from "./emain-window"; import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; import { getLaunchSettings } from "./launchsettings"; -import { configureAutoUpdater, updater } from "./updater"; + const electronApp = electron.app; @@ -121,123 +117,8 @@ function handleWSEvent(evtMsg: WSEventType) { }); } -// we try to set the primary display as index [0] -function getActivityDisplays(): ActivityDisplayType[] { - const displays = electron.screen.getAllDisplays(); - const primaryDisplay = electron.screen.getPrimaryDisplay(); - const rtn: ActivityDisplayType[] = []; - for (const display of displays) { - const adt = { - width: display.size.width, - height: display.size.height, - dpr: display.scaleFactor, - internal: display.internal, - }; - if (display.id === primaryDisplay?.id) { - rtn.unshift(adt); - } else { - rtn.push(adt); - } - } - return rtn; -} - -async function sendDisplaysTDataEvent() { - const displays = getActivityDisplays(); - if (displays.length === 0) { - return; - } - const props: TEventProps = {}; - props["display:count"] = displays.length; - props["display:height"] = displays[0].height; - props["display:width"] = displays[0].width; - props["display:dpr"] = displays[0].dpr; - props["display:all"] = displays; - try { - await RpcApi.RecordTEventCommand( - ElectronWshClient, - { - event: "app:display", - props, - }, - { noresponse: true } - ); - } catch (e) { - console.log("error sending display tdata event", e); - } -} - -function logActiveState() { - fireAndForget(async () => { - const astate = getActivityState(); - const activity: ActivityUpdate = { openminutes: 1 }; - const ww = focusedWaveWindow; - const activeTabView = ww?.activeTabView; - const isWaveAIOpen = activeTabView?.isWaveAIOpen ?? false; - - if (astate.wasInFg) { - activity.fgminutes = 1; - } - if (astate.wasActive) { - activity.activeminutes = 1; - } - activity.displays = getActivityDisplays(); - - const termCmdCount = getAndClearTermCommandsRun(); - if (termCmdCount > 0) { - activity.termcommandsrun = termCmdCount; - } - const termCmdRemoteCount = getAndClearTermCommandsRemote(); - const termCmdWslCount = getAndClearTermCommandsWsl(); - const termCmdDurableCount = getAndClearTermCommandsDurable(); - - const props: TEventProps = { - "activity:activeminutes": activity.activeminutes, - "activity:fgminutes": activity.fgminutes, - "activity:openminutes": activity.openminutes, - }; - if (termCmdCount > 0) { - props["activity:termcommandsrun"] = termCmdCount; - } - if (termCmdRemoteCount > 0) { - props["activity:termcommands:remote"] = termCmdRemoteCount; - } - if (termCmdWslCount > 0) { - props["activity:termcommands:wsl"] = termCmdWslCount; - } - if (termCmdDurableCount > 0) { - props["activity:termcommands:durable"] = termCmdDurableCount; - } - if (astate.wasActive && isWaveAIOpen) { - props["activity:waveaiactiveminutes"] = 1; - } - if (astate.wasInFg && isWaveAIOpen) { - props["activity:waveaifgminutes"] = 1; - } - - try { - await RpcApi.ActivityCommand(ElectronWshClient, activity, { noresponse: true }); - await RpcApi.RecordTEventCommand( - ElectronWshClient, - { - event: "app:activity", - props, - }, - { noresponse: true } - ); - } catch (e) { - console.log("error logging active state", e); - } finally { - setWasInFg(ww?.isFocused() ?? false); - setWasActive(false); - } - }); -} - // this isn't perfect, but gets the job done without being complicated -function runActiveTimer() { - logActiveState(); - setTimeout(runActiveTimer, 60000); +function runActiveTimer() { setTimeout(runActiveTimer, 60000); } function hideWindowWithCatch(window: WaveBrowserWindow) { @@ -291,7 +172,7 @@ electronApp.on("before-quit", (e) => { return; } setGlobalIsQuitting(true); - updater?.stop(); + if (unamePlatform == "win32") { // win32 doesn't have a SIGINT, so we just let electron die, which // ends up killing wavesrv via closing it's stdin. @@ -342,7 +223,7 @@ process.on("uncaughtException", (error) => { return; } - // Check if the error is related to QUIC protocol, if so, ignore (can happen with the updater) + // Check if the error is related to QUIC protocol, if so, ignore (can happen during network changes) if (error?.message?.includes("net::ERR_QUIC_PROTOCOL_ERROR")) { console.log("Ignoring QUIC protocol error:", error.message); console.log("Stack Trace:", error.stack); @@ -417,11 +298,9 @@ async function appMain() { ensureHotSpareTab(fullConfig); await relaunchBrowserWindows(); setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe - setTimeout(sendDisplaysTDataEvent, 5000); - makeAndSetAppMenu(); makeDockTaskbar(); - await configureAutoUpdater(); + setGlobalIsStarting(false); if (fullConfig?.settings?.["window:maxtabcachesize"] != null) { setMaxTabCacheSize(fullConfig.settings["window:maxtabcachesize"]); diff --git a/emain/preload.ts b/emain/preload.ts index 8d2b18a308..d7484b9ac8 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -36,10 +36,6 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.on("fullscreen-change", (_event, isFullScreen) => callback(isFullScreen)), onZoomFactorChange: (callback) => ipcRenderer.on("zoom-factor-change", (_event, zoomFactor) => callback(zoomFactor)), - onUpdaterStatusChange: (callback) => ipcRenderer.on("app-update-status", (_event, status) => callback(status)), - getUpdaterStatus: () => ipcRenderer.sendSync("get-app-update-status"), - getUpdaterChannel: () => ipcRenderer.sendSync("get-updater-channel"), - installAppUpdate: () => ipcRenderer.send("install-app-update"), onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback), updateWindowControlsOverlay: (rect) => ipcRenderer.send("update-window-controls-overlay", rect), onReinjectKey: (callback) => ipcRenderer.on("reinject-key", (_event, waveEvent) => callback(waveEvent)), @@ -62,10 +58,7 @@ contextBridge.exposeInMainWorld("api", { captureScreenshot: (rect: Rectangle) => ipcRenderer.invoke("capture-screenshot", rect), setKeyboardChordMode: () => ipcRenderer.send("set-keyboard-chord-mode"), clearWebviewStorage: (webContentsId: number) => ipcRenderer.invoke("clear-webview-storage", webContentsId), - setWaveAIOpen: (isOpen: boolean) => ipcRenderer.send("set-waveai-open", isOpen), closeBuilderWindow: () => ipcRenderer.send("close-builder-window"), - incrementTermCommands: (opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => - ipcRenderer.send("increment-term-commands", opts), nativePaste: () => ipcRenderer.send("native-paste"), openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), diff --git a/emain/updater.ts b/emain/updater.ts deleted file mode 100644 index 8f06e6bec7..0000000000 --- a/emain/updater.ts +++ /dev/null @@ -1,253 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { dialog, ipcMain, Notification } from "electron"; -import { autoUpdater } from "electron-updater"; -import { readFileSync } from "fs"; -import path from "path"; -import YAML from "yaml"; -import { RpcApi } from "../frontend/app/store/wshclientapi"; -import { isDev } from "../frontend/util/isdev"; -import { fireAndForget } from "../frontend/util/util"; -import { setUserConfirmedQuit } from "./emain-activity"; -import { delay } from "./emain-util"; -import { focusedWaveWindow, getAllWaveWindows } from "./emain-window"; -import { ElectronWshClient } from "./emain-wsh"; - -export let updater: Updater; - -function getUpdateChannel(settings: SettingsType): string { - const updaterConfigPath = path.join(process.resourcesPath!, "app-update.yml"); - const updaterConfig = YAML.parse(readFileSync(updaterConfigPath, { encoding: "utf8" }).toString()); - console.log("Updater config from binary:", updaterConfig); - const updaterChannel: string = updaterConfig.channel ?? "latest"; - const settingsChannel = settings["autoupdate:channel"]; - let retVal = settingsChannel; - - // If the user setting doesn't exist yet, set it to the value of the updater config. - // If the user was previously on the `latest` channel and has downloaded a `beta` version, update their configured channel to `beta` to prevent downgrading. - if (!settingsChannel || (settingsChannel == "latest" && updaterChannel == "beta")) { - console.log("Update channel setting does not exist, setting to value from updater config."); - RpcApi.SetConfigCommand(ElectronWshClient, { "autoupdate:channel": updaterChannel }); - retVal = updaterChannel; - } - console.log("Update channel:", retVal); - return retVal; -} - -export class Updater { - autoCheckInterval: NodeJS.Timeout | null; - intervalms: number; - autoCheckEnabled: boolean; - availableUpdateReleaseName: string | null; - availableUpdateReleaseNotes: string | null; - private _status: UpdaterStatus; - lastUpdateCheck: Date; - - constructor(settings: SettingsType) { - this.intervalms = settings["autoupdate:intervalms"]; - console.log("Update check interval in milliseconds:", this.intervalms); - this.autoCheckEnabled = settings["autoupdate:enabled"]; - console.log("Update check enabled:", this.autoCheckEnabled); - - this._status = "up-to-date"; - this.lastUpdateCheck = new Date(0); - this.autoCheckInterval = null; - this.availableUpdateReleaseName = null; - - autoUpdater.autoInstallOnAppQuit = settings["autoupdate:installonquit"]; - console.log("Install update on quit:", settings["autoupdate:installonquit"]); - - // Only update the release channel if it's specified, otherwise use the one configured in the updater. - autoUpdater.channel = getUpdateChannel(settings); - autoUpdater.allowDowngrade = false; - - autoUpdater.removeAllListeners(); - - autoUpdater.on("error", (err) => { - console.log("updater error"); - console.log(err); - if (!err.toString()?.includes("net::ERR_INTERNET_DISCONNECTED")) this.status = "error"; - }); - - autoUpdater.on("checking-for-update", () => { - console.log("checking-for-update"); - this.status = "checking"; - }); - - autoUpdater.on("update-available", () => { - console.log("update-available; downloading..."); - this.status = "downloading"; - }); - - autoUpdater.on("update-not-available", () => { - console.log("update-not-available"); - this.status = "up-to-date"; - }); - - autoUpdater.on("update-downloaded", (event) => { - console.log("update-downloaded", [event]); - this.availableUpdateReleaseName = event.releaseName; - this.availableUpdateReleaseNotes = event.releaseNotes as string | null; - - // Display the update banner and create a system notification - this.status = "ready"; - const updateNotification = new Notification({ - title: "Wave Terminal", - body: "A new version of Wave Terminal is ready to install.", - }); - updateNotification.on("click", () => { - fireAndForget(this.promptToInstallUpdate.bind(this)); - }); - updateNotification.show(); - }); - } - - /** - * The status of the Updater. - */ - get status(): UpdaterStatus { - return this._status; - } - - private set status(value: UpdaterStatus) { - this._status = value; - getAllWaveWindows().forEach((window) => { - const allTabs = Array.from(window.allLoadedTabViews.values()); - allTabs.forEach((tab) => { - tab.webContents.send("app-update-status", value); - }); - }); - } - - /** - * Check for updates and start the background update check, if configured. - */ - async start() { - if (this.autoCheckEnabled) { - console.log("starting updater"); - this.autoCheckInterval = setInterval(() => { - fireAndForget(() => this.checkForUpdates(false)); - }, 600000); // intervals are unreliable when an app is suspended so we will check every 10 mins if the interval has passed. - await this.checkForUpdates(false); - } - } - - /** - * Stop the background update check, if configured. - */ - stop() { - console.log("stopping updater"); - if (this.autoCheckInterval) { - clearInterval(this.autoCheckInterval); - this.autoCheckInterval = null; - } - } - - /** - * Checks if the configured interval time has passed since the last update check, and if so, checks for updates using the `autoUpdater` object - * @param userInput Whether the user is requesting this. If so, an alert will report the result of the check. - */ - async checkForUpdates(userInput: boolean) { - const now = new Date(); - - // Run an update check always if the user requests it, otherwise only if there's an active update check interval and enough time has elapsed. - if ( - userInput || - (this.autoCheckInterval && - (!this.lastUpdateCheck || Math.abs(now.getTime() - this.lastUpdateCheck.getTime()) > this.intervalms)) - ) { - const result = await autoUpdater.checkForUpdates(); - - // If the user requested this check and we do not have an available update, let them know with a popup dialog. No need to tell them if there is an update, because we show a banner once the update is ready to install. - if (userInput && !result.downloadPromise) { - const dialogOpts: Electron.MessageBoxOptions = { - type: "info", - message: "There are currently no updates available.", - }; - if (focusedWaveWindow) { - dialog.showMessageBox(focusedWaveWindow, dialogOpts); - } - } - - // Only update the last check time if this is an automatic check. This ensures the interval remains consistent. - if (!userInput) this.lastUpdateCheck = now; - } - } - - /** - * Prompts the user to install the downloaded application update and restarts the application - */ - async promptToInstallUpdate() { - const dialogOpts: Electron.MessageBoxOptions = { - type: "info", - buttons: ["Restart", "Later"], - title: "Application Update", - message: process.platform === "win32" ? this.availableUpdateReleaseNotes : this.availableUpdateReleaseName, - detail: "A new version has been downloaded. Restart the application to apply the updates.", - }; - - const allWindows = getAllWaveWindows(); - if (allWindows.length > 0) { - await dialog.showMessageBox(focusedWaveWindow ?? allWindows[0], dialogOpts).then(({ response }) => { - if (response === 0) { - fireAndForget(this.installUpdate.bind(this)); - } - }); - } - } - - /** - * Restarts the app and installs an update if it is available. - */ - async installUpdate() { - if (this.status == "ready") { - this.status = "installing"; - await delay(1000); - setUserConfirmedQuit(true); - autoUpdater.quitAndInstall(); - } - } -} - -export function getResolvedUpdateChannel(): string { - return isDev() ? "dev" : (autoUpdater.channel ?? "latest"); -} - -ipcMain.on("install-app-update", () => fireAndForget(updater?.promptToInstallUpdate.bind(updater))); -ipcMain.on("get-app-update-status", (event) => { - event.returnValue = updater?.status; -}); -ipcMain.on("get-updater-channel", (event) => { - event.returnValue = getResolvedUpdateChannel(); -}); - -let autoUpdateLock = false; - -/** - * Configures the auto-updater based on the user's preference - */ -export async function configureAutoUpdater() { - if (isDev()) { - console.log("skipping auto-updater in dev mode"); - return; - } - - // simple lock to prevent multiple auto-update configuration attempts, this should be very rare - if (autoUpdateLock) { - console.log("auto-update configuration already in progress, skipping"); - return; - } - autoUpdateLock = true; - - try { - console.log("Configuring updater"); - const settings = (await RpcApi.GetFullConfigCommand(ElectronWshClient)).settings; - updater = new Updater(settings); - await updater.start(); - } catch (e) { - console.warn("error configuring updater", e.toString()); - } - - autoUpdateLock = false; -} diff --git a/frontend/app/aipanel/ai-utils.ts b/frontend/app/aipanel/ai-utils.ts deleted file mode 100644 index 8bfd67bdc0..0000000000 --- a/frontend/app/aipanel/ai-utils.ts +++ /dev/null @@ -1,598 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { sortByDisplayOrder } from "@/util/util"; - -const TextFileLimit = 200 * 1024; // 200KB -const PdfLimit = 5 * 1024 * 1024; // 5MB -const ImageLimit = 10 * 1024 * 1024; // 10MB -const ImagePreviewSize = 128; -const ImagePreviewWebPQuality = 0.8; -const ImageMaxEdge = 4096; - -export const isAcceptableFile = (file: File): boolean => { - const acceptableTypes = [ - // Images - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", - "image/svg+xml", - // PDFs - "application/pdf", - // Text files - "text/plain", - "text/markdown", - "text/html", - "text/css", - "text/javascript", - "text/typescript", - // Application types for code files - "application/javascript", - "application/typescript", - "application/json", - "application/xml", - ]; - - if (acceptableTypes.includes(file.type)) { - return true; - } - - // Check file extensions for files without proper MIME types - const extension = file.name.split(".").pop()?.toLowerCase(); - const acceptableExtensions = [ - "txt", - "log", - "md", - "js", - "mjs", - "cjs", - "jsx", - "ts", - "mts", - "cts", - "tsx", - "go", - "py", - "java", - "c", - "cpp", - "h", - "hpp", - "html", - "htm", - "css", - "scss", - "sass", - "json", - "jsonc", - "json5", - "jsonl", - "ndjson", - "xml", - "yaml", - "yml", - "sh", - "bat", - "sql", - "php", - "rb", - "rs", - "swift", - "kt", - "cs", - "vb", - "r", - "scala", - "clj", - "ex", - "exs", - "ini", - "toml", - "conf", - "cfg", - "env", - "zsh", - "fish", - "ps1", - "psm1", - "bazel", - "bzl", - "csv", - "tsv", - "properties", - "ipynb", - "rmd", - "gradle", - "groovy", - "cmake", - ]; - - if (extension && acceptableExtensions.includes(extension)) { - return true; - } - - // Check for specific filenames (case-insensitive) - const fileName = file.name.toLowerCase(); - const acceptableFilenames = [ - "makefile", - "dockerfile", - "containerfile", - "go.mod", - "go.sum", - "go.work", - "go.work.sum", - "package.json", - "package-lock.json", - "yarn.lock", - "pnpm-lock.yaml", - "composer.json", - "composer.lock", - "gemfile", - "gemfile.lock", - "podfile", - "podfile.lock", - "cargo.toml", - "cargo.lock", - "pipfile", - "pipfile.lock", - "requirements.txt", - "setup.py", - "pyproject.toml", - "poetry.lock", - "build.gradle", - "settings.gradle", - "pom.xml", - "build.xml", - "readme", - "readme.md", - "license", - "license.md", - "changelog", - "changelog.md", - "contributing", - "contributing.md", - "authors", - "codeowners", - "procfile", - "jenkinsfile", - "vagrantfile", - "rakefile", - "gruntfile.js", - "gulpfile.js", - "webpack.config.js", - "rollup.config.js", - "vite.config.js", - "jest.config.js", - "vitest.config.js", - ".dockerignore", - ".gitignore", - ".gitattributes", - ".gitmodules", - ".editorconfig", - ".eslintrc", - ".prettierrc", - ".pylintrc", - ".bashrc", - ".bash_profile", - ".bash_login", - ".bash_logout", - ".profile", - ".zshrc", - ".zprofile", - ".zshenv", - ".zlogin", - ".zlogout", - ".kshrc", - ".cshrc", - ".tcshrc", - ".xonshrc", - ".shrc", - ".aliases", - ".functions", - ".exports", - ".direnvrc", - ".vimrc", - ".gvimrc", - ]; - - return acceptableFilenames.includes(fileName); -}; - -export const getFileIcon = (fileName: string, fileType: string): string => { - if (fileType === "directory") { - return "fa-folder"; - } - - if (fileType.startsWith("image/")) { - return "fa-image"; - } - - if (fileType === "application/pdf") { - return "fa-file-pdf"; - } - - // Check file extensions for code files - const ext = fileName.split(".").pop()?.toLowerCase(); - switch (ext) { - case "js": - case "jsx": - case "ts": - case "tsx": - return "fa-file-code"; - case "go": - return "fa-file-code"; - case "py": - return "fa-file-code"; - case "java": - case "c": - case "cpp": - case "h": - case "hpp": - return "fa-file-code"; - case "html": - case "css": - case "scss": - case "sass": - return "fa-file-code"; - case "json": - case "xml": - case "yaml": - case "yml": - return "fa-file-code"; - case "md": - case "txt": - return "fa-file-text"; - default: - return "fa-file"; - } -}; - -export const formatFileSize = (bytes: number): string => { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; -}; - -// Normalize MIME type for AI processing -export const normalizeMimeType = (file: File): string => { - const fileType = file.type; - - // Images keep their real mimetype - if (fileType.startsWith("image/")) { - return fileType; - } - - // PDFs keep their mimetype - if (fileType === "application/pdf") { - return fileType; - } - - // Everything else (code files, markdown, text, etc.) becomes text/plain - return "text/plain"; -}; - -// Helper function to read file as base64 for AIMessage -export const readFileAsBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result as string; - // Remove data URL prefix to get just base64 - const base64 = result.split(",")[1]; - resolve(base64); - }; - reader.onerror = reject; - reader.readAsDataURL(file); - }); -}; - -// Helper function to create data URL for UIMessage -export const createDataUrl = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(file); - }); -}; - -export interface FileSizeError { - fileName: string; - fileSize: number; - maxSize: number; - fileType: "text" | "pdf" | "image"; -} - -export const validateFileSize = (file: File): FileSizeError | null => { - if (file.type.startsWith("image/")) { - if (file.size > ImageLimit) { - return { - fileName: file.name, - fileSize: file.size, - maxSize: ImageLimit, - fileType: "image", - }; - } - } else if (file.type === "application/pdf") { - if (file.size > PdfLimit) { - return { - fileName: file.name, - fileSize: file.size, - maxSize: PdfLimit, - fileType: "pdf", - }; - } - } else { - if (file.size > TextFileLimit) { - return { - fileName: file.name, - fileSize: file.size, - maxSize: TextFileLimit, - fileType: "text", - }; - } - } - - return null; -}; - -export const validateFileSizeFromInfo = ( - fileName: string, - fileSize: number, - mimeType: string -): FileSizeError | null => { - let maxSize: number; - let fileType: "text" | "pdf" | "image"; - - if (mimeType.startsWith("image/")) { - maxSize = ImageLimit; - fileType = "image"; - } else if (mimeType === "application/pdf") { - maxSize = PdfLimit; - fileType = "pdf"; - } else { - maxSize = TextFileLimit; - fileType = "text"; - } - - if (fileSize > maxSize) { - return { - fileName, - fileSize, - maxSize, - fileType, - }; - } - - return null; -}; - -export const formatFileSizeError = (error: FileSizeError): string => { - const typeLabel = error.fileType === "image" ? "Image" : error.fileType === "pdf" ? "PDF" : "Text file"; - return `${typeLabel} "${error.fileName}" is too large (${formatFileSize(error.fileSize)}). Maximum size is ${formatFileSize(error.maxSize)}.`; -}; - -/** - * Resize an image to have a maximum edge of 4096px and convert to WebP format - * Returns the optimized image if it's smaller than the original, otherwise returns the original - */ -export const resizeImage = async (file: File): Promise => { - // Only process actual image files (not SVG) - if (!file.type.startsWith("image/") || file.type === "image/svg+xml") { - return file; - } - - return new Promise((resolve) => { - const img = new Image(); - const url = URL.createObjectURL(file); - - img.onload = async () => { - URL.revokeObjectURL(url); - - let { width, height } = img; - - // Check if resizing is needed - if (width <= ImageMaxEdge && height <= ImageMaxEdge) { - // Image is already small enough, just try WebP conversion - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - ctx?.drawImage(img, 0, 0); - - canvas.toBlob( - (blob) => { - if (blob && blob.size < file.size) { - const webpFile = new File([blob], file.name.replace(/\.[^.]+$/, ".webp"), { - type: "image/webp", - }); - console.log( - `Image resized (no dimension change): ${file.name} - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}` - ); - resolve(webpFile); - } else { - console.log( - `Image kept original (WebP not smaller): ${file.name} - ${formatFileSize(file.size)}` - ); - resolve(file); - } - }, - "image/webp", - ImagePreviewWebPQuality - ); - return; - } - - // Calculate new dimensions while maintaining aspect ratio - if (width > height) { - height = Math.round((height * ImageMaxEdge) / width); - width = ImageMaxEdge; - } else { - width = Math.round((width * ImageMaxEdge) / height); - height = ImageMaxEdge; - } - - // Create canvas and resize - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - ctx?.drawImage(img, 0, 0, width, height); - - // Convert to WebP - canvas.toBlob( - (blob) => { - if (blob && blob.size < file.size) { - const webpFile = new File([blob], file.name.replace(/\.[^.]+$/, ".webp"), { - type: "image/webp", - }); - console.log( - `Image resized: ${file.name} (${img.width}x${img.height} → ${width}x${height}) - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}` - ); - resolve(webpFile); - } else { - console.log( - `Image kept original (WebP not smaller): ${file.name} (${img.width}x${img.height} → ${width}x${height}) - ${formatFileSize(file.size)}` - ); - resolve(file); - } - }, - "image/webp", - ImagePreviewWebPQuality - ); - }; - - img.onerror = () => { - URL.revokeObjectURL(url); - resolve(file); - }; - - img.src = url; - }); -}; - -/** - * Create a 128x128 preview data URL for an image file - */ -export const createImagePreview = async (file: File): Promise => { - if (!file.type.startsWith("image/") || file.type === "image/svg+xml") { - return null; - } - - return new Promise((resolve) => { - const img = new Image(); - const url = URL.createObjectURL(file); - - img.onload = () => { - URL.revokeObjectURL(url); - - let { width, height } = img; - - if (width > height) { - height = Math.round((height * ImagePreviewSize) / width); - width = ImagePreviewSize; - } else { - width = Math.round((width * ImagePreviewSize) / height); - height = ImagePreviewSize; - } - - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - ctx?.drawImage(img, 0, 0, width, height); - - canvas.toBlob( - (blob) => { - if (blob) { - const reader = new FileReader(); - reader.onloadend = () => { - resolve(reader.result as string); - }; - reader.readAsDataURL(blob); - } else { - resolve(null); - } - }, - "image/webp", - ImagePreviewWebPQuality - ); - }; - - img.onerror = () => { - URL.revokeObjectURL(url); - resolve(null); - }; - - img.src = url; - }); -}; - - -/** - * Filter and organize AI mode configs into Wave and custom provider groups - * Returns organized configs that should be displayed based on settings and premium status - */ -export interface FilteredAIModeConfigs { - waveProviderConfigs: Array<{ mode: string } & AIModeConfigType>; - otherProviderConfigs: Array<{ mode: string } & AIModeConfigType>; - shouldShowCloudModes: boolean; -} - -export const getFilteredAIModeConfigs = ( - aiModeConfigs: Record, - showCloudModes: boolean, - inBuilder: boolean, - hasPremium: boolean, - currentMode?: string -): FilteredAIModeConfigs => { - const hideQuick = inBuilder && hasPremium; - - const allConfigs = Object.entries(aiModeConfigs) - .map(([mode, config]) => ({ mode, ...config })) - .filter((config) => !(hideQuick && config.mode === "waveai@quick")); - - const otherProviderConfigs = allConfigs - .filter((config) => config["ai:provider"] !== "wave") - .sort(sortByDisplayOrder); - - const hasCustomModels = otherProviderConfigs.length > 0; - const isCurrentModeCloud = currentMode?.startsWith("waveai@") ?? false; - const shouldShowCloudModes = showCloudModes || !hasCustomModels || isCurrentModeCloud; - - const waveProviderConfigs = shouldShowCloudModes - ? allConfigs.filter((config) => config["ai:provider"] === "wave").sort(sortByDisplayOrder) - : []; - - return { - waveProviderConfigs, - otherProviderConfigs, - shouldShowCloudModes, - }; -}; - -/** - * Get the display name for an AI mode configuration. - * If display:name is set, use that. Otherwise, construct from model/provider. - * For azure-legacy, show "azureresourcename (azure)". - * For other providers, show "model (provider)". - */ -export function getModeDisplayName(config: AIModeConfigType): string { - if (config["display:name"]) { - return config["display:name"]; - } - - const provider = config["ai:provider"]; - const model = config["ai:model"]; - const azureResourceName = config["ai:azureresourcename"]; - - if (provider === "azure-legacy") { - return `${azureResourceName || "unknown"} (azure)`; - } - - return `${model || "unknown"} (${provider || "custom"})`; -} diff --git a/frontend/app/aipanel/aidroppedfiles.tsx b/frontend/app/aipanel/aidroppedfiles.tsx deleted file mode 100644 index d7051c412f..0000000000 --- a/frontend/app/aipanel/aidroppedfiles.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { cn } from "@/util/util"; -import { useAtomValue } from "jotai"; -import { memo } from "react"; -import { formatFileSize, getFileIcon } from "./ai-utils"; -import type { WaveAIModel } from "./waveai-model"; - -interface AIDroppedFilesProps { - model: WaveAIModel; -} - -export const AIDroppedFiles = memo(({ model }: AIDroppedFilesProps) => { - const droppedFiles = useAtomValue(model.droppedFiles); - - if (droppedFiles.length === 0) { - return null; - } - - return ( -
-
- {droppedFiles.map((file) => ( -
- - -
- {file.previewUrl ? ( -
- {file.name} -
- ) : ( -
- -
- )} - -
- {file.name} -
-
{formatFileSize(file.size)}
-
-
- ))} -
-
- ); -}); - -AIDroppedFiles.displayName = "AIDroppedFiles"; diff --git a/frontend/app/aipanel/aifeedbackbuttons.tsx b/frontend/app/aipanel/aifeedbackbuttons.tsx deleted file mode 100644 index 30d9accc07..0000000000 --- a/frontend/app/aipanel/aifeedbackbuttons.tsx +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { cn, makeIconClass } from "@/util/util"; -import { memo, useState } from "react"; -import { WaveAIModel } from "./waveai-model"; - -interface AIFeedbackButtonsProps { - messageText: string; -} - -export const AIFeedbackButtons = memo(({ messageText }: AIFeedbackButtonsProps) => { - const [thumbsUpClicked, setThumbsUpClicked] = useState(false); - const [thumbsDownClicked, setThumbsDownClicked] = useState(false); - const [copied, setCopied] = useState(false); - - const handleThumbsUp = () => { - setThumbsUpClicked(!thumbsUpClicked); - if (thumbsDownClicked) { - setThumbsDownClicked(false); - } - if (!thumbsUpClicked) { - WaveAIModel.getInstance().handleAIFeedback("good"); - } - }; - - const handleThumbsDown = () => { - setThumbsDownClicked(!thumbsDownClicked); - if (thumbsUpClicked) { - setThumbsUpClicked(false); - } - if (!thumbsDownClicked) { - WaveAIModel.getInstance().handleAIFeedback("bad"); - } - }; - - const handleCopy = () => { - navigator.clipboard.writeText(messageText); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
- - - {messageText?.trim() && ( - - )} -
- ); -}); - -AIFeedbackButtons.displayName = "AIFeedbackButtons"; \ No newline at end of file diff --git a/frontend/app/aipanel/aimessage.tsx b/frontend/app/aipanel/aimessage.tsx deleted file mode 100644 index 1bfadd121d..0000000000 --- a/frontend/app/aipanel/aimessage.tsx +++ /dev/null @@ -1,269 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { WaveStreamdown } from "@/app/element/streamdown"; -import { cn } from "@/util/util"; -import { memo, useEffect, useRef } from "react"; -import { getFileIcon } from "./ai-utils"; -import { AIFeedbackButtons } from "./aifeedbackbuttons"; -import { AIToolUseGroup } from "./aitooluse"; -import { WaveUIMessage, WaveUIMessagePart } from "./aitypes"; -import { WaveAIModel } from "./waveai-model"; - -const AIThinking = memo( - ({ - message = "AI is thinking...", - reasoningText, - isWaitingApproval = false, - }: { - message?: string; - reasoningText?: string; - isWaitingApproval?: boolean; - }) => { - const scrollRef = useRef(null); - - useEffect(() => { - if (scrollRef.current && reasoningText) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [reasoningText]); - - const displayText = reasoningText - ? (() => { - const lastDoubleNewline = reasoningText.lastIndexOf("\n\n"); - return lastDoubleNewline !== -1 ? reasoningText.substring(lastDoubleNewline + 2) : reasoningText; - })() - : ""; - - return ( -
-
- {isWaitingApproval ? ( - - ) : ( -
- - - -
- )} - {message && {message}} -
-
- {displayText} -
-
- ); - } -); - -AIThinking.displayName = "AIThinking"; - -interface UserMessageFilesProps { - fileParts: Array; -} - -const UserMessageFiles = memo(({ fileParts }: UserMessageFilesProps) => { - if (fileParts.length === 0) return null; - - return ( -
-
- {fileParts.map((file, index) => ( -
-
-
- {file.data?.previewurl ? ( - {file.data?.filename - ) : ( - - )} -
-
- {file.data?.filename || "File"} -
-
-
- ))} -
-
- ); -}); - -UserMessageFiles.displayName = "UserMessageFiles"; - -interface AIMessagePartProps { - part: WaveUIMessagePart; - role: string; - isStreaming: boolean; -} - -const AIMessagePart = memo(({ part, role, isStreaming }: AIMessagePartProps) => { - const model = WaveAIModel.getInstance(); - - if (part.type === "text") { - const content = part.text ?? ""; - - if (role === "user") { - return
{content}
; - } else { - return ( - - ); - } - } - - return null; -}); - -AIMessagePart.displayName = "AIMessagePart"; - -interface AIMessageProps { - message: WaveUIMessage; - isStreaming: boolean; -} - -const isDisplayPart = (part: WaveUIMessagePart): boolean => { - return ( - part.type === "text" || - part.type === "data-tooluse" || - part.type === "data-toolprogress" || - (part.type.startsWith("tool-") && "state" in part && part.state === "input-available") - ); -}; - -type MessagePart = - | { type: "single"; part: WaveUIMessagePart } - | { type: "toolgroup"; parts: Array }; - -const groupMessageParts = (parts: WaveUIMessagePart[]): MessagePart[] => { - const grouped: MessagePart[] = []; - let currentToolGroup: Array = []; - - for (const part of parts) { - if (part.type === "data-tooluse" || part.type === "data-toolprogress") { - currentToolGroup.push(part as WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }); - } else { - if (currentToolGroup.length > 0) { - grouped.push({ type: "toolgroup", parts: currentToolGroup }); - currentToolGroup = []; - } - grouped.push({ type: "single", part }); - } - } - - if (currentToolGroup.length > 0) { - grouped.push({ type: "toolgroup", parts: currentToolGroup }); - } - - return grouped; -}; - -const getThinkingMessage = ( - parts: WaveUIMessagePart[], - isStreaming: boolean, - role: string -): { message: string; reasoningText?: string; isWaitingApproval?: boolean } | null => { - if (!isStreaming || role !== "assistant") { - return null; - } - - const hasPendingApprovals = parts.some( - (part) => part.type === "data-tooluse" && part.data?.approval === "needs-approval" - ); - - if (hasPendingApprovals) { - return { message: "Waiting for Tool Approvals...", isWaitingApproval: true }; - } - - const lastPart = parts[parts.length - 1]; - - if (lastPart?.type === "reasoning") { - const reasoningContent = lastPart.text || ""; - return { message: "AI is thinking...", reasoningText: reasoningContent }; - } - - if (lastPart?.type === "text" && lastPart.text) { - return null; - } - - return { message: "" }; -}; - -export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => { - const parts = message.parts || []; - const displayParts = parts.filter(isDisplayPart); - const fileParts = parts.filter( - (part): part is WaveUIMessagePart & { type: "data-userfile" } => part.type === "data-userfile" - ); - - const thinkingData = getThinkingMessage(parts, isStreaming, message.role); - const groupedParts = groupMessageParts(displayParts); - - return ( -
-
*:first-child]:!mt-0", - message.role === "user" - ? "py-2 bg-zinc-700/60 text-white max-w-[calc(100%-50px)]" - : "min-w-[min(100%,500px)]" - )} - > - {displayParts.length === 0 && !isStreaming && !thinkingData ? ( -
(no text content)
- ) : ( - <> - {groupedParts.map((group, index: number) => - group.type === "toolgroup" ? ( - - ) : ( -
- -
- ) - )} - {thinkingData != null && ( -
- -
- )} - - )} - - {message.role === "user" && } - {message.role === "assistant" && !isStreaming && displayParts.length > 0 && ( - p.type === "text") - .map((p) => p.text || "") - .join("\n\n")} - /> - )} -
-
- ); -}); - -AIMessage.displayName = "AIMessage"; diff --git a/frontend/app/aipanel/aimode.tsx b/frontend/app/aipanel/aimode.tsx deleted file mode 100644 index 3602cdd360..0000000000 --- a/frontend/app/aipanel/aimode.tsx +++ /dev/null @@ -1,329 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { Tooltip } from "@/app/element/tooltip"; -import { atoms, getSettingsKeyAtom } from "@/app/store/global"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { cn, fireAndForget, makeIconClass } from "@/util/util"; -import { useAtomValue } from "jotai"; -import { memo, useRef, useState } from "react"; -import { getFilteredAIModeConfigs, getModeDisplayName } from "./ai-utils"; -import { WaveAIModel } from "./waveai-model"; - -interface AIModeMenuItemProps { - config: AIModeConfigWithMode; - isSelected: boolean; - isDisabled: boolean; - isPremiumDisabled: boolean; - onClick: () => void; - isFirst?: boolean; - isLast?: boolean; -} - -const AIModeMenuItem = memo(({ config, isSelected, isDisabled, isPremiumDisabled, onClick, isFirst, isLast }: AIModeMenuItemProps) => { - return ( - - ); -}); - -AIModeMenuItem.displayName = "AIModeMenuItem"; - -interface ConfigSection { - sectionName: string; - configs: AIModeConfigWithMode[]; - isIncompatible?: boolean; - noTelemetry?: boolean; -} - -function computeCompatibleSections( - currentMode: string, - aiModeConfigs: Record, - waveProviderConfigs: AIModeConfigWithMode[], - otherProviderConfigs: AIModeConfigWithMode[] -): ConfigSection[] { - const currentConfig = aiModeConfigs[currentMode]; - const allConfigs = [...waveProviderConfigs, ...otherProviderConfigs]; - - if (!currentConfig) { - return [{ sectionName: "Incompatible Modes", configs: allConfigs, isIncompatible: true }]; - } - - const currentSwitchCompat = currentConfig["ai:switchcompat"] || []; - const compatibleConfigs: AIModeConfigWithMode[] = [{ ...currentConfig, mode: currentMode }]; - const incompatibleConfigs: AIModeConfigWithMode[] = []; - - if (currentSwitchCompat.length === 0) { - allConfigs.forEach((config) => { - if (config.mode !== currentMode) { - incompatibleConfigs.push(config); - } - }); - } else { - allConfigs.forEach((config) => { - if (config.mode === currentMode) return; - - const configSwitchCompat = config["ai:switchcompat"] || []; - const hasMatch = currentSwitchCompat.some((currentTag: string) => configSwitchCompat.includes(currentTag)); - - if (hasMatch) { - compatibleConfigs.push(config); - } else { - incompatibleConfigs.push(config); - } - }); - } - - const sections: ConfigSection[] = []; - const compatibleSectionName = compatibleConfigs.length === 1 ? "Current" : "Compatible Modes"; - sections.push({ sectionName: compatibleSectionName, configs: compatibleConfigs }); - - if (incompatibleConfigs.length > 0) { - sections.push({ sectionName: "Incompatible Modes", configs: incompatibleConfigs, isIncompatible: true }); - } - - return sections; -} - -function computeWaveCloudSections( - waveProviderConfigs: AIModeConfigWithMode[], - otherProviderConfigs: AIModeConfigWithMode[], - telemetryEnabled: boolean -): ConfigSection[] { - const sections: ConfigSection[] = []; - - if (waveProviderConfigs.length > 0) { - sections.push({ - sectionName: "Wave AI Cloud", - configs: waveProviderConfigs, - noTelemetry: !telemetryEnabled, - }); - } - if (otherProviderConfigs.length > 0) { - sections.push({ sectionName: "Custom", configs: otherProviderConfigs }); - } - - return sections; -} - -interface AIModeDropdownProps { - compatibilityMode?: boolean; -} - -export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdownProps) => { - const model = WaveAIModel.getInstance(); - const currentMode = useAtomValue(model.currentAIMode); - const aiModeConfigs = useAtomValue(model.aiModeConfigs); - const waveaiModeConfigs = useAtomValue(atoms.waveaiModeConfigAtom); - const widgetContextEnabled = useAtomValue(model.widgetAccessAtom); - const hasPremium = useAtomValue(model.hasPremiumAtom); - const showCloudModes = useAtomValue(getSettingsKeyAtom("waveai:showcloudmodes")); - const telemetryEnabled = useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - - const { waveProviderConfigs, otherProviderConfigs } = getFilteredAIModeConfigs( - aiModeConfigs, - showCloudModes, - model.inBuilder, - hasPremium, - currentMode - ); - - const sections: ConfigSection[] = compatibilityMode - ? computeCompatibleSections(currentMode, aiModeConfigs, waveProviderConfigs, otherProviderConfigs) - : computeWaveCloudSections(waveProviderConfigs, otherProviderConfigs, telemetryEnabled); - - const showSectionHeaders = compatibilityMode || sections.length > 1; - - const handleSelect = (mode: string) => { - const config = aiModeConfigs[mode]; - if (!config) return; - if (!hasPremium && config["waveai:premium"]) { - return; - } - model.setAIMode(mode); - setIsOpen(false); - }; - - const displayConfig = aiModeConfigs[currentMode]; - const displayName = displayConfig ? getModeDisplayName(displayConfig) : `Invalid (${currentMode})`; - const displayIcon = displayConfig ? displayConfig["display:icon"] || "sparkles" : "question"; - const resolvedConfig = waveaiModeConfigs[currentMode]; - const hasToolsSupport = resolvedConfig && resolvedConfig["ai:capabilities"]?.includes("tools"); - const showNoToolsWarning = widgetContextEnabled && resolvedConfig && !hasToolsSupport; - - const handleNewChatClick = () => { - model.clearChat(); - setIsOpen(false); - }; - - const handleConfigureClick = () => { - fireAndForget(async () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "action:other", - props: { - "action:type": "waveai:configuremodes:contextmenu", - }, - }, - { noresponse: true } - ); - await model.openWaveAIConfig(); - setIsOpen(false); - }); - }; - - const handleEnableTelemetry = () => { - fireAndForget(async () => { - await RpcApi.WaveAIEnableTelemetryCommand(TabRpcClient); - setTimeout(() => { - model.focusInput(); - }, 100); - }); - }; - - return ( -
- - - {showNoToolsWarning && ( - - Warning: This custom mode was configured without the "tools" capability in the - "ai:capabilities" array. Without tool support, Wave AI will not be able to interact with - widgets or files. -
- } - placement="bottom" - > -
- - No Tools Support -
- - )} - - {isOpen && ( - <> -
setIsOpen(false)} /> -
- {sections.map((section, sectionIndex) => { - const isFirstSection = sectionIndex === 0; - const isLastSection = sectionIndex === sections.length - 1; - - return ( -
- {!isFirstSection &&
} - {showSectionHeaders && ( - <> -
- {section.sectionName} -
- {section.isIncompatible && ( -
- (Start a New Chat to Switch) -
- )} - {section.noTelemetry && ( - - )} - - )} - {section.configs.map((config, index) => { - const isFirst = index === 0 && isFirstSection && !showSectionHeaders; - const isLast = index === section.configs.length - 1 && isLastSection; - const isPremiumDisabled = !hasPremium && config["waveai:premium"]; - const isIncompatibleDisabled = section.isIncompatible || false; - const isTelemetryDisabled = section.noTelemetry || false; - const isDisabled = - isPremiumDisabled || isIncompatibleDisabled || isTelemetryDisabled; - const isSelected = currentMode === config.mode; - return ( - handleSelect(config.mode)} - isFirst={isFirst} - isLast={isLast} - /> - ); - })} -
- ); - })} -
- - -
- - )} -
- ); -}); - -AIModeDropdown.displayName = "AIModeDropdown"; diff --git a/frontend/app/aipanel/aipanel-contextmenu.ts b/frontend/app/aipanel/aipanel-contextmenu.ts deleted file mode 100644 index 4e78389198..0000000000 --- a/frontend/app/aipanel/aipanel-contextmenu.ts +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils"; -import { ContextMenuModel } from "@/app/store/contextmenu"; -import { isDev } from "@/app/store/global"; -import { globalStore } from "@/app/store/jotaiStore"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { WaveAIModel } from "./waveai-model"; - -export async function handleWaveAIContextMenu(e: React.MouseEvent, showCopy: boolean): Promise { - e.preventDefault(); - e.stopPropagation(); - - const model = WaveAIModel.getInstance(); - const menu: ContextMenuItem[] = []; - - if (showCopy) { - const hasSelection = waveAIHasSelection(); - if (hasSelection) { - menu.push({ - role: "copy", - }); - menu.push({ type: "separator" }); - } - } - - menu.push({ - label: "New Chat", - click: () => { - model.clearChat(); - }, - }); - - menu.push({ type: "separator" }); - - const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - }); - - const defaultTokens = model.inBuilder ? 24576 : 4096; - const currentMaxTokens = rtInfo?.["waveai:maxoutputtokens"] ?? defaultTokens; - - const maxTokensSubmenu: ContextMenuItem[] = []; - - if (model.inBuilder) { - maxTokensSubmenu.push( - { - label: "24k", - type: "checkbox", - checked: currentMaxTokens === 24576, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 24576 }, - }); - }, - }, - { - label: "64k (Pro)", - type: "checkbox", - checked: currentMaxTokens === 65536, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 65536 }, - }); - }, - } - ); - } else { - if (isDev()) { - maxTokensSubmenu.push({ - label: "1k (Dev Testing)", - type: "checkbox", - checked: currentMaxTokens === 1024, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 1024 }, - }); - }, - }); - } - maxTokensSubmenu.push( - { - label: "4k", - type: "checkbox", - checked: currentMaxTokens === 4096, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 4096 }, - }); - }, - }, - { - label: "16k (Pro)", - type: "checkbox", - checked: currentMaxTokens === 16384, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 16384 }, - }); - }, - }, - { - label: "64k (Pro)", - type: "checkbox", - checked: currentMaxTokens === 65536, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 65536 }, - }); - }, - } - ); - } - - menu.push({ - label: "Max Output Tokens", - submenu: maxTokensSubmenu, - }); - - menu.push({ type: "separator" }); - - menu.push({ - label: "Configure Modes", - click: () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "action:other", - props: { - "action:type": "waveai:configuremodes:contextmenu", - }, - }, - { noresponse: true } - ); - model.openWaveAIConfig(); - }, - }); - - if (model.canCloseWaveAIPanel()) { - menu.push({ type: "separator" }); - - menu.push({ - label: "Hide Wave AI", - click: () => { - model.closeWaveAIPanel(); - }, - }); - } - - ContextMenuModel.getInstance().showContextMenu(menu, e); -} diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx deleted file mode 100644 index 32b8582141..0000000000 --- a/frontend/app/aipanel/aipanel.tsx +++ /dev/null @@ -1,636 +0,0 @@ -// Copyright 2026, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { handleWaveAIContextMenu } from "@/app/aipanel/aipanel-contextmenu"; -import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils"; -import { useTabBackground } from "@/app/block/blockutil"; -import { ErrorBoundary } from "@/app/element/errorboundary"; -import { atoms, getSettingsKeyAtom } from "@/app/store/global"; -import { globalStore } from "@/app/store/jotaiStore"; -import { useTabModelMaybe } from "@/app/store/tab-model"; -import { isBuilderWindow } from "@/app/store/windowtype"; -import { useWaveEnv } from "@/app/waveenv/waveenv"; -import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; -import { isMacOS, isWindows } from "@/util/platformutil"; -import { cn } from "@/util/util"; -import { useChat } from "@ai-sdk/react"; -import { DefaultChatTransport } from "ai"; -import * as jotai from "jotai"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; -import { useDrop } from "react-dnd"; -import { formatFileSizeError, isAcceptableFile, validateFileSize } from "./ai-utils"; -import { AIDroppedFiles } from "./aidroppedfiles"; -import { AIModeDropdown } from "./aimode"; -import { AIPanelHeader } from "./aipanelheader"; -import { AIPanelInput } from "./aipanelinput"; -import { AIPanelMessages } from "./aipanelmessages"; -import { AIRateLimitStrip } from "./airatelimitstrip"; -import { WaveUIMessage } from "./aitypes"; -import { BYOKAnnouncement } from "./byokannouncement"; -import { TelemetryRequiredMessage } from "./telemetryrequired"; -import { WaveAIModel } from "./waveai-model"; - -const AIBlockMask = memo(() => { - return ( -
-
-
0
-
-
- ); -}); - -AIBlockMask.displayName = "AIBlockMask"; - -const AIDragOverlay = memo(() => { - return ( -
-
- -
Drop files here
-
Images, PDFs, and text/code files supported
-
-
- ); -}); - -AIDragOverlay.displayName = "AIDragOverlay"; - -const KeyCap = memo(({ children, className }: { children: React.ReactNode; className?: string }) => { - return ( - - {children} - - ); -}); - -KeyCap.displayName = "KeyCap"; - -const AIWelcomeMessage = memo(() => { - const modKey = isMacOS() ? "⌘" : "Alt"; - const aiModeConfigs = jotai.useAtomValue(atoms.waveaiModeConfigAtom); - const hasCustomModes = Object.keys(aiModeConfigs).some((key) => !key.startsWith("waveai@")); - return ( -
-
- -

Welcome to Wave AI

-
-
-

- Wave AI is your terminal assistant with context. I can read your terminal output, analyze widgets, - access files, and help you solve problems faster. -

-
-
Getting Started:
-
-
-
- -
-
- Widget Context -
When ON, I can read your terminal and analyze widgets.
-
When OFF, I'm sandboxed with no system access.
-
-
-
-
- -
-
Drag & drop files or images for analysis
-
-
-
- -
-
-
- {modKey} - K - to start a new chat -
-
- {modKey} - Shift - A - to toggle panel -
-
- {isWindows() ? ( - <> - Alt - 0 - to focus - - ) : ( - <> - Ctrl - Shift - 0 - to focus - - )} -
-
-
-
-
- -
-
- Questions or feedback?{" "} - - Join our Discord - -
-
-
-
- {!hasCustomModes && } -
- BETA: Free to use. Daily limits keep our costs in check. -
-
-
- ); -}); - -AIWelcomeMessage.displayName = "AIWelcomeMessage"; - -const AIBuilderWelcomeMessage = memo(() => { - return ( -
-
- -

WaveApp Builder

-
-
-

- The WaveApp builder helps create wave widgets that integrate seamlessly into Wave Terminal. -

-
-
- ); -}); - -AIBuilderWelcomeMessage.displayName = "AIBuilderWelcomeMessage"; - -const AIErrorMessage = memo(() => { - const model = WaveAIModel.getInstance(); - const errorMessage = jotai.useAtomValue(model.errorMessage); - - if (!errorMessage) { - return null; - } - - return ( -
- -
- {errorMessage} - -
-
- ); -}); - -AIErrorMessage.displayName = "AIErrorMessage"; - -const ConfigChangeModeFixer = memo(() => { - const model = WaveAIModel.getInstance(); - const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; - const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs); - - useEffect(() => { - model.fixModeAfterConfigChange(); - }, [telemetryEnabled, aiModeConfigs, model]); - - return null; -}); - -ConfigChangeModeFixer.displayName = "ConfigChangeModeFixer"; - -type AIPanelComponentInnerProps = { - roundTopLeft: boolean; -}; - -const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps) => { - const [isDragOver, setIsDragOver] = useState(false); - const [isReactDndDragOver, setIsReactDndDragOver] = useState(false); - const [initialLoadDone, setInitialLoadDone] = useState(false); - const model = WaveAIModel.getInstance(); - const containerRef = useRef(null); - const waveEnv = useWaveEnv(); - const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); - const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; - const isFocused = jotai.useAtomValue(model.isWaveAIFocusedAtom); - const focusFollowsCursorMode = jotai.useAtomValue(getSettingsKeyAtom("app:focusfollowscursor")) ?? "off"; - const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; - const isPanelVisible = jotai.useAtomValue(model.getPanelVisibleAtom()); - const tabModel = useTabModelMaybe(); - const [tabBorderColor, tabActiveBorderColor] = useTabBackground(waveEnv, tabModel?.tabId); - const defaultMode = jotai.useAtomValue(getSettingsKeyAtom("waveai:defaultmode")) ?? "waveai@balanced"; - const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs); - - const hasCustomModes = Object.keys(aiModeConfigs).some((key) => !key.startsWith("waveai@")); - const isUsingCustomMode = !defaultMode.startsWith("waveai@"); - const allowAccess = telemetryEnabled || (hasCustomModes && isUsingCustomMode); - - const { messages, sendMessage, status, setMessages, error, stop } = useChat({ - transport: new DefaultChatTransport({ - api: model.getUseChatEndpointUrl(), - prepareSendMessagesRequest: (_opts) => { - const msg = model.getAndClearMessage(); - const body: any = { - msg, - chatid: globalStore.get(model.chatId), - widgetaccess: globalStore.get(model.widgetAccessAtom), - aimode: globalStore.get(model.currentAIMode), - }; - if (isBuilderWindow()) { - body.builderid = globalStore.get(atoms.builderId); - body.builderappid = globalStore.get(atoms.builderAppId); - } else { - body.tabid = tabModel.tabId; - } - return { body }; - }, - }), - onError: (error) => { - console.error("AI Chat error:", error); - model.setError(error.message || "An error occurred"); - }, - }); - - model.registerUseChatData(sendMessage, setMessages, status, stop); - - // console.log("AICHAT messages", messages); - (window as any).aichatmessages = messages; - (window as any).aichatstatus = status; - - const handleKeyDown = (waveEvent: WaveKeyboardEvent): boolean => { - if (checkKeyPressed(waveEvent, "Cmd:k")) { - model.clearChat(); - return true; - } - return false; - }; - - useEffect(() => { - globalStore.set(model.isAIStreaming, status === "streaming" || status === "submitted"); - }, [status]); - - useEffect(() => { - const keyHandler = keydownWrapper(handleKeyDown); - document.addEventListener("keydown", keyHandler); - return () => { - document.removeEventListener("keydown", keyHandler); - }; - }, []); - - useEffect(() => { - const loadChat = async () => { - await model.uiLoadInitialChat(); - setInitialLoadDone(true); - }; - loadChat(); - }, [model]); - - useEffect(() => { - const updateWidth = () => { - if (containerRef.current) { - globalStore.set(model.containerWidth, containerRef.current.offsetWidth); - } - }; - - updateWidth(); - - const resizeObserver = new ResizeObserver(updateWidth); - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } - - return () => { - resizeObserver.disconnect(); - }; - }, [model]); - - useEffect(() => { - model.ensureRateLimitSet(); - }, [model]); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - await model.handleSubmit(); - setTimeout(() => { - model.focusInput(); - }, 100); - }; - - const hasFilesDragged = (dataTransfer: DataTransfer): boolean => { - // Check if the drag operation contains files by looking at the types - return dataTransfer.types.includes("Files"); - }; - - const handleDragOver = (e: React.DragEvent) => { - if (!allowAccess) { - return; - } - - const hasFiles = hasFilesDragged(e.dataTransfer); - - // Only handle native file drags here, let react-dnd handle FILE_ITEM drags - if (!hasFiles) { - return; - } - - e.preventDefault(); - e.stopPropagation(); - - if (!isDragOver) { - setIsDragOver(true); - } - }; - - const handleDragEnter = (e: React.DragEvent) => { - if (!allowAccess) { - return; - } - - const hasFiles = hasFilesDragged(e.dataTransfer); - - // Only handle native file drags here, let react-dnd handle FILE_ITEM drags - if (!hasFiles) { - return; - } - - e.preventDefault(); - e.stopPropagation(); - - setIsDragOver(true); - }; - - const handleDragLeave = (e: React.DragEvent) => { - if (!allowAccess) { - return; - } - - const hasFiles = hasFilesDragged(e.dataTransfer); - - // Only handle native file drags here, let react-dnd handle FILE_ITEM drags - if (!hasFiles) { - return; - } - - e.preventDefault(); - e.stopPropagation(); - - // Only set drag over to false if we're actually leaving the drop zone - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const x = e.clientX; - const y = e.clientY; - - if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { - setIsDragOver(false); - } - }; - - const handleDrop = async (e: React.DragEvent) => { - if (!allowAccess) { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - return; - } - - // Check if this is a FILE_ITEM drag from react-dnd - // If so, let react-dnd handle it instead - if (!e.dataTransfer.files.length) { - return; // Let react-dnd handle FILE_ITEM drags - } - - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - - const files = Array.from(e.dataTransfer.files); - const acceptableFiles = files.filter(isAcceptableFile); - - for (const file of acceptableFiles) { - const sizeError = validateFileSize(file); - if (sizeError) { - model.setError(formatFileSizeError(sizeError)); - return; - } - await model.addFile(file); - } - - if (acceptableFiles.length < files.length) { - const rejectedCount = files.length - acceptableFiles.length; - const rejectedFiles = files.filter((f) => !isAcceptableFile(f)); - const fileNames = rejectedFiles.map((f) => f.name).join(", "); - model.setError( - `${rejectedCount} file${rejectedCount > 1 ? "s" : ""} rejected (unsupported type): ${fileNames}. Supported: images, PDFs, and text/code files.` - ); - } - }; - - const handleFileItemDrop = useCallback( - (draggedFile: DraggedFile) => { - if (!allowAccess) { - return; - } - model.addFileFromRemoteUri(draggedFile); - }, - [model, allowAccess] - ); - - const [{ isOver, canDrop }, drop] = useDrop( - () => ({ - accept: "FILE_ITEM", - drop: handleFileItemDrop, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), - }), - }), - [handleFileItemDrop] - ); - - // Update drag over state for FILE_ITEM drags - useEffect(() => { - if (isOver && canDrop) { - setIsReactDndDragOver(true); - } else { - setIsReactDndDragOver(false); - } - }, [isOver, canDrop]); - - // Attach the drop ref to the container - useEffect(() => { - if (containerRef.current) { - drop(containerRef.current); - } - }, [drop]); - - const handleFocusCapture = useCallback( - (_event: React.FocusEvent) => { - // console.log("Wave AI focus capture", getElemAsStr(event.target)); - model.requestWaveAIFocus(); - }, - [model] - ); - - const handlePointerEnter = useCallback( - (event: React.PointerEvent) => { - if (focusFollowsCursorMode !== "on") return; - if (event.pointerType === "touch" || event.buttons > 0) return; - if (isFocused) return; - model.focusInput(); - }, - [focusFollowsCursorMode, isFocused, model] - ); - - const handleClick = (e: React.MouseEvent) => { - const target = e.target as HTMLElement; - const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]'); - - if (isInteractive) { - return; - } - - const hasSelection = waveAIHasSelection(); - if (hasSelection) { - model.requestWaveAIFocus(); - return; - } - - setTimeout(() => { - if (!waveAIHasSelection()) { - model.focusInput(); - } - }, 0); - }; - - const showBlockMask = isLayoutMode && showOverlayBlockNums; - const borderColor = isFocused ? (tabActiveBorderColor ?? null) : (tabBorderColor ?? null); - - return ( -
- - {(isDragOver || isReactDndDragOver) && allowAccess && } - {showBlockMask && } - - - -
- {!allowAccess ? ( - - ) : ( - <> - {messages.length === 0 && initialLoadDone ? ( -
handleWaveAIContextMenu(e, true)} - > -
- -
- {model.inBuilder ? : } -
- ) : ( - handleWaveAIContextMenu(e, true)} - /> - )} - - - - - )} -
-
- ); -}); - -AIPanelComponentInner.displayName = "AIPanelInner"; - -type AIPanelComponentProps = { - roundTopLeft: boolean; -}; - -const AIPanelComponent = ({ roundTopLeft }: AIPanelComponentProps) => { - return ( - - - - ); -}; - -AIPanelComponent.displayName = "AIPanel"; - -export { AIPanelComponent as AIPanel }; diff --git a/frontend/app/aipanel/aipanelheader.tsx b/frontend/app/aipanel/aipanelheader.tsx deleted file mode 100644 index da54f6c9e9..0000000000 --- a/frontend/app/aipanel/aipanelheader.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { handleWaveAIContextMenu } from "@/app/aipanel/aipanel-contextmenu"; -import { useAtomValue } from "jotai"; -import { memo } from "react"; -import { WaveAIModel } from "./waveai-model"; - -export const AIPanelHeader = memo(() => { - const model = WaveAIModel.getInstance(); - const widgetAccess = useAtomValue(model.widgetAccessAtom); - const inBuilder = model.inBuilder; - - const handleKebabClick = (e: React.MouseEvent) => { - handleWaveAIContextMenu(e, false); - }; - - const handleContextMenu = (e: React.MouseEvent) => { - handleWaveAIContextMenu(e, false); - }; - - return ( -
-

- - Wave AI -

- -
- {!inBuilder && ( -
- Context - Widget Context - -
- )} - - -
-
- ); -}); - -AIPanelHeader.displayName = "AIPanelHeader"; diff --git a/frontend/app/aipanel/aipanelinput.tsx b/frontend/app/aipanel/aipanelinput.tsx deleted file mode 100644 index ec52ca0d13..0000000000 --- a/frontend/app/aipanel/aipanelinput.tsx +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { formatFileSizeError, isAcceptableFile, validateFileSize } from "@/app/aipanel/ai-utils"; -import { waveAIHasFocusWithin } from "@/app/aipanel/waveai-focus-utils"; -import { type WaveAIModel } from "@/app/aipanel/waveai-model"; -import { Tooltip } from "@/element/tooltip"; -import { cn } from "@/util/util"; -import { useAtom, useAtomValue } from "jotai"; -import { memo, useCallback, useEffect, useRef } from "react"; - -interface AIPanelInputProps { - onSubmit: (e: React.FormEvent) => void; - status: string; - model: WaveAIModel; -} - -export interface AIPanelInputRef { - focus: () => void; - resize: () => void; - scrollToBottom: () => void; -} - -export const AIPanelInput = memo(({ onSubmit, status, model }: AIPanelInputProps) => { - const [input, setInput] = useAtom(model.inputAtom); - const isFocused = useAtomValue(model.isWaveAIFocusedAtom); - const isChatEmpty = useAtomValue(model.isChatEmptyAtom); - const textareaRef = useRef(null); - const fileInputRef = useRef(null); - const isPanelOpen = useAtomValue(model.getPanelVisibleAtom()); - - let placeholder: string; - if (!isChatEmpty) { - placeholder = "Continue..."; - } else if (model.inBuilder) { - placeholder = "What would you like to build..."; - } else { - placeholder = "Ask Wave AI anything..."; - } - - const resizeTextarea = useCallback(() => { - const textarea = textareaRef.current; - if (!textarea) return; - - textarea.style.height = "auto"; - const scrollHeight = textarea.scrollHeight; - const maxHeight = 7 * 24; - textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; - }, []); - - useEffect(() => { - const inputRefObject: React.RefObject = { - current: { - focus: () => { - textareaRef.current?.focus(); - }, - resize: resizeTextarea, - scrollToBottom: () => { - const textarea = textareaRef.current; - if (textarea) { - textarea.scrollTop = textarea.scrollHeight; - } - }, - }, - }; - model.registerInputRef(inputRefObject); - }, [model, resizeTextarea]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - const isComposing = e.nativeEvent?.isComposing || e.keyCode == 229; - if (e.key === "Enter" && !e.shiftKey && !isComposing) { - e.preventDefault(); - onSubmit(e as any); - } - }; - - const handleFocus = useCallback(() => { - model.requestWaveAIFocus(); - }, [model]); - - const handleBlur = useCallback( - (e: React.FocusEvent) => { - if (e.relatedTarget === null) { - return; - } - - if (waveAIHasFocusWithin(e.relatedTarget)) { - return; - } - - model.requestNodeFocus(); - }, - [model] - ); - - useEffect(() => { - resizeTextarea(); - }, [input, resizeTextarea]); - - useEffect(() => { - if (isPanelOpen) { - resizeTextarea(); - } - }, [isPanelOpen, resizeTextarea]); - - const handleUploadClick = () => { - fileInputRef.current?.click(); - }; - - const handleFileChange = async (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - const acceptableFiles = files.filter(isAcceptableFile); - - for (const file of acceptableFiles) { - const sizeError = validateFileSize(file); - if (sizeError) { - model.setError(formatFileSizeError(sizeError)); - if (e.target) { - e.target.value = ""; - } - return; - } - await model.addFile(file); - } - - if (acceptableFiles.length < files.length) { - console.warn(`${files.length - acceptableFiles.length} files were rejected due to unsupported file types`); - } - - if (e.target) { - e.target.value = ""; - } - }; - - return ( -
- -
-
-